Files
19e70faeec badges: webapp feature branch (#7548)
* badges: webapp (#7433)

* badges: service migrations, store and catalog

* badges: BTCPay provider and settlement poller

* badges: web listener and /api endpoints

* web: checkout single-page app

* badges: tests and BTCPay fixtures

* badges: README and ini reference

* badges: fix hex16 build on GHC 8.10.7

* badges: Stripe card lane

* badges: fix Stripe card checkout, add theming

* badges: add a discount row to the order summary

* badges: site navbar, embedding, theme, Forget move

* badges: use SB code prefix in web checkout

* badges: rename sxb app namespace to sb

* badges: embed checkout nav via site; keep original app navbar

* badges: post iframe height, apply site background when embedded

* badges: embed dark surfaces, steadier iframe height

* badges: hide app footer when embedded

* badges: size embedded body to content, not viewport

* badges: declare color-scheme to stop reload flash

* badges: fade shell in on load, no reload blank

* badges: prerender app shell into index.html

* badges: pre-paint theme, hide shell on deep reload

* badges: logo returns to landing client-side

* badges: embedded wizard back, buy-a-code, resume

* badges: signal app-managed screens, resume across reload

* badges: rebuild wizard history on deep load so Back walks it

* badges: carry welcome-page height as the iframe floor

* badges: keep selection on Buy a code; rename to Your codes

* badges: read web shell as UTF-8, not locale

* badges: resume the exact paid order after Stripe card redirect

* badges: move docker deploy under scripts

* badges: add serve_webapp toggle and webapp export

* badges: wire split webapp deploy in docker config

* badges: quiet agent logs by default

* badges: resume card redirect in the embedded frame

* badges: migrate Stripe adapter to PaymentIntents

* badges: correct Stripe restricted key scopes in ini example

* badges: card via Payment Element and PaymentIntents

* badges: fix stale Checkout Session wording in Stripe adapter

* badges: fix stale CheckoutActions reference in card comment

* badges: order shell stylesheet before bootstrap script

* badges: remove development card stand-in

* badges: theme the Stripe card form with the site palette

* badges: exclude web from the Haskell build stage

* badges: unify invoice cancel and mark canceled

* badges: default log level to info

* badges: unify closed-invoice buy-again button

* badges: mute agent connection logs at info level

* badges: show purchase time in local timezone in Your codes

* badges: log service events on own channel, quiet agent

* badges: fold service migrations into one baseline

* badges: run compose on postgres over host network

* badges: use high-res hero art

* badges: add web CI to catch stale builds

* badges: rebuild web shell from committed source

* badges: normalize invoice-code link and columns

* badges: drop unused columns, rename index

* badges: note deferred receipt_hash in migrations

* badges: apply code-review fixes

* badges: reduce comments across service and web

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: improve web page (#7546)

* badges: improve web page

* improve layout

* improve layout

* fix

* small changes

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: read one issuer key from the ini

* badges: move and group the service tests

* badges: service fixes (#7567)

* badges: match the redeem error wording in tests

* badges: drop unused imports in the bot tests

* badges: cancel Stripe orders when they expire

* badges: correct the Stripe config and docs

* badges: refuse to revoke a redeemed code

* badges: make the fake Stripe cancel like Stripe

* badges: limit replayed webhook deliveries

---------

Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2026-09-25 09:01:51 +00:00

692 lines
37 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { timedTest } from "./boot.js";
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import { decl, mediaFor, type Rule, ruleFor, sheet } from "./css.js";
import * as stripe from "../src/stripe.js";
const designTest = timedTest(2000);
import { StubElement, installDocument } from "./stub-dom.js";
installDocument();
const icons = await import("../src/icons.js");
const screens = await import("../src/screens.js");
function render(node: unknown): StubElement { return node as unknown as StubElement; }
// The website's palette, as its blog pages use it: website/tailwind.config.js (primary-light,
// primary-hover-light, primary-pressed-light, secondary-bg-light; primary-dark and its steps,
// primary-bg-dark, card-bg-dark, secondary-bg-dark) and website/src/css/blog.css (the heading
// gradients and the table rules); the two text colours are simplex.domains' ink and ink-soft.
const REFERENCE_LIGHT: ReadonlyArray<readonly [string, string]> = [
["--accent", "#0053D0"],
["--accent-hover", "#1661D1"],
["--accent-pressed", "#004ABB"],
["--on-accent", "#FFFFFF"],
["--ink", "#0D1B3A"],
["--bg", "#F3FAFF"],
["--surface", "#FFFFFF"],
["--menu", "#FFFFFF"],
["--line", "rgba(0, 83, 208, .14)"],
["--ink-soft", "rgba(13, 27, 58, .651)"],
["--title", "linear-gradient(to bottom, #53C1FF -50%, #0053D0 160%)"],
];
const REFERENCE_DARK: ReadonlyArray<readonly [string, string]> = [
["--accent", "#70F0F9"],
["--accent-hover", "#66D9E2"],
["--accent-pressed", "#5CD3DC"],
["--on-accent", "#000832"],
["--ink", "#EAF2FF"],
["--bg", "#000832"],
["--surface", "#0B2A59"],
["--menu", "#0B2A59"],
["--line", "rgba(112, 240, 249, .18)"],
["--ink-soft", "rgba(234, 242, 255, .735)"],
["--title", "linear-gradient(to bottom, #70F0F9 0%, #70F0F9 33%, #50D8F1 100%)"],
];
const SEMANTIC: ReadonlyArray<readonly [string, string]> = [
["--ok-fg", "#1c7c3f"],
["--ok-bg", "#e8f5ec"],
["--ok-line", "#bfe3cc"],
["--warn-fg", "#8a6100"],
["--warn-bg", "#fff8e6"],
["--warn-line", "#f0dcae"],
["--danger-fg", "#b3261e"],
["--danger-bg", "#fdecec"],
["--danger-line", "#f3c9c6"],
];
const PALETTE: ReadonlyArray<readonly [string, string]> = [...REFERENCE_LIGHT, ...SEMANTIC];
const darkQuery = (): Rule => {
const media = mediaFor("(prefers-color-scheme: dark)");
assert.ok(media, "the dark query must exist");
const rule = ruleFor(media.rules, ':root:not([data-theme="light"])');
assert.ok(rule, `the dark query must claim everything but an explicit light, and got ${JSON.stringify(media.rules.map((r) => r.selector))}`);
return rule;
};
const darkAttribute = (): Rule => {
const rule = ruleFor(sheet.rules, ':root[data-theme="dark"]');
assert.ok(rule, "an explicit dark choice must beat an operating system set to light");
return rule;
};
designTest("design: every light token holds the value it is measured from", () => {
const root = ruleFor(sheet.rules, ":root");
assert.ok(root, "the palette must be defined on bare :root, not only inside a media query");
for (const [token, value] of PALETTE) {
const found = root.decls.get(token);
assert.ok(found !== undefined, `${token} is not defined on :root`);
assert.equal(found.toLowerCase(), value.toLowerCase(), `${token} must be ${value}`);
}
});
designTest("design: the Stripe card form's palette is the site's own tokens, not a second copy", () => {
// stripe.ts hard-codes the palette because Stripe.js runs in a cross-origin iframe that cannot read our CSS variables.
const light = ruleFor(sheet.rules, ":root")!;
const dark = darkAttribute();
const tok = (rule: Rule, name: string): string => (rule.decls.get(name) ?? light.decls.get(name) ?? "").toLowerCase();
const norm = (s: string | undefined): string => (s ?? "").trim().toLowerCase();
for (const [label, t, rule] of [["light", "light", light], ["dark", "dark", dark]] as const) {
const a = stripe.appearanceFor(t, false);
assert.equal(a.theme, "flat", `${label} uses the flat theme`);
const v = a.variables!, r = a.rules!;
assert.equal(norm(v.colorPrimary), tok(rule, "--accent"), `${label} colorPrimary is --accent`);
assert.equal(norm(v.colorBackground), tok(rule, "--surface"), `${label} colorBackground is --surface`);
assert.equal(norm(v.colorText), tok(rule, "--ink"), `${label} colorText is --ink`);
assert.equal(norm(v.colorTextSecondary), tok(rule, "--ink-soft"), `${label} colorTextSecondary is --ink-soft`);
assert.equal(norm(v.colorDanger), tok(rule, "--danger-fg"), `${label} colorDanger is --danger-fg`);
assert.equal(norm(r[".Input"]?.backgroundColor), tok(rule, "--bg"), `${label} .Input fill is --bg`);
assert.ok(norm(r[".Input"]?.border).includes(tok(rule, "--line")), `${label} .Input border is --line`);
assert.ok(norm(r[".Input:focus"]?.border).includes(tok(rule, "--accent")), `${label} .Input:focus is --accent`);
}
});
designTest("design: the dark theme is the website's own, down to the ground the dark button's label takes", () => {
const light = ruleFor(sheet.rules, ":root")!;
const dark = darkQuery();
for (const [token, value] of REFERENCE_DARK) {
assert.equal(dark.decls.get(token)?.toLowerCase(), value.toLowerCase(), `${token} must be ${value} under dark`);
}
assert.equal(dark.decls.get("--on-accent")?.toLowerCase(), dark.decls.get("--bg")?.toLowerCase(),
"the label on the dark accent is the page's ground, as the site's dark buttons are drawn");
for (const [label, rule] of [["light", light], ["dark", dark]] as const) {
assert.match(rule.decls.get("--page") ?? "", /^radial-gradient\(50% 50% at 50% 50%,/,
`${label} has one faint centred wash of its accent over the ground`);
}
assert.notEqual(dark.decls.get("--page"), light.decls.get("--page"), "each wash is in its own theme's accent");
assert.equal(decl("body", "background-image"), "var(--page)");
});
designTest("design: the two dark blocks are the same palette, token for token", () => {
const fromQuery = darkQuery();
const fromAttribute = darkAttribute();
assert.deepEqual([...fromAttribute.decls.entries()].sort(), [...fromQuery.decls.entries()].sort());
assert.ok(fromQuery.decls.size >= REFERENCE_DARK.length + SEMANTIC.length);
});
designTest("design: dark restates every token, and none of them by the light value", () => {
const light = ruleFor(sheet.rules, ":root")!;
const dark = darkQuery();
for (const [token] of PALETTE) {
const value = dark.decls.get(token);
assert.ok(value !== undefined, `${token} is not redefined under dark`);
assert.notEqual(value.toLowerCase(), light.decls.get(token)!.toLowerCase(),
`${token} is the same in both themes, which is one of them being wrong`);
}
for (const token of ["--hero", "--wordmark"]) {
assert.notEqual(dark.decls.get(token), light.decls.get(token),
`${token} is a second image, not the first one dimmed`);
}
});
designTest("design: the content column is 640px, and the gutter is outside it", () => {
assert.equal(decl("#app", "max-width"), "640px");
assert.equal(decl("#app", "margin"), "0 auto", "and it is centred");
assert.equal(decl("footer", "max-width"), "640px", "the footer rule spans the same column");
assert.match(decl("body", "padding") ?? "", /^0 \d+px$/, "the gutter is the sides, and nothing on top");
const panelPadding = decl(".panel", "padding") ?? "";
assert.ok(/^\d+px 0( \d+px)?$/.test(panelPadding),
`a panel adds no horizontal padding, and got "${panelPadding}"`);
});
designTest("design: the primary button is the reference's pill, in both themes", () => {
assert.equal(decl(".primary", "width"), "100%");
assert.equal(decl(".primary", "min-height"), "48px", "ref draws it 48 tall");
assert.equal(decl(".primary", "font-weight"), "700", "and labelled at 700");
assert.equal(decl(".primary", "border-radius"), "var(--r-pill)");
assert.equal(decl(".secondary", "border-radius"), "var(--r-pill)");
assert.equal(ruleFor(sheet.rules, ":root")!.decls.get("--r-pill"), "9999px");
assert.equal(decl(".menu-button", "border-radius"), "50%");
assert.equal(decl(".primary", "background"), "var(--accent)");
assert.equal(decl(".primary", "color"), "var(--on-accent)",
"the label is a token, because the dark theme's accent is a light blue");
assert.equal(decl(".primary", "font-size"), "1rem");
});
designTest("design: a card is a 1px hairline at one shared radius, and the chosen one is accent", () => {
assert.equal(ruleFor(sheet.rules, ":root")!.decls.get("--r-card"), "20px", "ref's radius for a card or a field");
assert.equal(decl(".choice", "border"), "1px solid var(--line)");
assert.equal(decl(".choice", "border-radius"), "var(--r-card)");
assert.equal(decl(".rows", "border"), "1px solid var(--line)");
assert.equal(decl(".rows", "border-radius"), "var(--r-card)");
assert.equal(decl(".entry", "border-radius"), "var(--r-card)");
for (const selector of [".choice", ".rows", ".entry"]) {
assert.equal(decl(selector, "box-shadow"), undefined, `${selector} must not carry a shadow`);
}
assert.equal(decl(".menu", "box-shadow"), "var(--shadow-pop)");
assert.equal(decl('.choice[aria-pressed="true"]', "border-color"), "var(--accent)");
assert.equal(decl(".info", "background"), "var(--surface)", "the landing screen's info panel is a card like any other");
assert.equal(decl(".notice", "background"), "var(--warn-bg)", "waiting and repriced are the warn ground");
assert.equal(decl(".warn", "background"), "var(--danger-bg)", "a failure or a loss is the danger ground");
});
designTest("design: a landed copy is not overwritten by the hover it is under", () => {
const copyable = [".primary", ".primary.outline", ".secondary", ".secondary.inline"];
for (const rule of sheet.rules) {
if (!/:hover|:active/.test(rule.selector)) continue;
const base = rule.selector.replace(/:(hover|active|not\([^)]*\))/g, "").trim();
if (!copyable.includes(base)) continue;
assert.match(rule.selector, /:not\(\.copied\)/,
`${rule.selector} would repaint a confirmation the buyer is looking at`);
}
assert.equal(decl(".primary.copied", "color"), "var(--ok-fg)");
});
designTest("design: the shell paints the right ground before the stylesheet lands", () => {
const shell = readFileSync(new URL("../../public/index.html", import.meta.url), "utf8");
const root = ruleFor(sheet.rules, ":root")!;
const dark = darkQuery();
for (const [scheme, token] of [["light", root], ["dark", dark]] as const) {
const ground = token.decls.get("--bg")!;
assert.ok(shell.includes(`<meta name="theme-color" content="${ground}" media="(prefers-color-scheme: ${scheme})">`),
`theme-color for ${scheme} must be ${ground}, or the browser frames the page in the wrong colour`);
}
});
designTest("design: the footer is a hairline rule then a centred accent link", () => {
assert.equal(decl("footer a", "border-top"), "1px solid var(--line)");
assert.equal(decl("footer a", "color"), "var(--accent)");
assert.equal(decl("footer", "text-align"), "center");
assert.equal(decl("footer a", "font-size"), ".875rem");
});
designTest("design: the hero is a CSS background, relative to the stylesheet's own directory", () => {
const root = ruleFor(sheet.rules, ":root")!;
const hero = root.decls.get("--hero");
assert.ok(hero !== undefined, "--hero must be a token, so the theme can swap it");
assert.match(hero, /^url\([^/][^)]*\.png\)$/, `the URL must be relative, and got "${hero}"`);
assert.equal(decl(".hero", "background-image"), "var(--hero)");
assert.equal(decl(".hero", "height"), "min(337px, 36vh)", "the mockup's 337 tall, giving way on a short screen");
assert.equal(decl(".hero", "width"), "auto", "the width follows from the ratio");
assert.equal(decl(".hero", "aspect-ratio"), "460 / 578", "at the source image's own proportions");
});
const SCALE: readonly string[] = [
"2.25rem", "1.875rem", "1.625rem", "1.375rem", "1.25rem", "1.125rem", "1rem", ".875rem", ".75rem",
];
const WEIGHTS: readonly string[] = ["400", "500", "600", "700", "800"];
function everyRule(): Array<{ selector: string; decls: Map<string, string>; phone: boolean }> {
const out = sheet.rules.map((r) => ({ ...r, phone: false }));
for (const block of sheet.media) {
if (block.query.replace(/\s+/g, "") !== "(max-width:560px)") continue;
for (const r of block.rules) out.push({ ...r, phone: true });
}
return out;
}
function phoneRules(): Array<{ selector: string; decls: Map<string, string> }> {
return sheet.media
.filter((m) => m.query.replace(/\s+/g, "") === "(max-width:560px)")
.flatMap((m) => m.rules);
}
// The last declaration for the selector in the band wins, as the browser resolves it.
function phoneDecl(selector: string, property: string): string | undefined {
let value: string | undefined;
for (const rule of phoneRules()) {
if (rule.selector !== selector) continue;
const found = rule.decls.get(property);
if (found !== undefined) value = found;
}
return value;
}
function specificity(selector: string): [number, number, number] {
const bare = selector.replace(/\s*[>+~]\s*/g, " ");
const ids = (bare.match(/#[\w-]+/g) ?? []).length;
const classes = (bare.match(/\.[\w-]+|\[[^\]]+\]|:[\w-]+\([^)]*\)|:(?!:)[\w-]+/g) ?? []).length;
const elements = (bare.match(/(^|\s)[a-z][\w-]*/g) ?? []).length;
return [ids, classes, elements];
}
const beats = (a: [number, number, number], b: [number, number, number]): boolean =>
a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] > b[2];
const subject = (selector: string): string => selector.trim().split(/\s*[>+~]\s*|\s+/).pop()!;
designTest("type: every size the sheet declares is a step of the reference scale", () => {
const offScale: string[] = [];
for (const rule of everyRule()) {
const size = rule.decls.get("font-size");
if (size === undefined) continue;
if (rule.selector === "body") {
assert.equal(size, "16px", "the root size the whole scale is relative to");
continue;
}
if (!SCALE.includes(size)) offScale.push(`${rule.phone ? "@phone " : ""}${rule.selector} { font-size: ${size} }`);
}
assert.deepEqual(offScale, [], `these sizes stand on no step of the scale: ${offScale.join(", ")}`);
});
designTest("type: every weight is a rung of one ladder, and 800 belongs to the heading alone", () => {
const wrong: string[] = [];
for (const rule of everyRule()) {
const weight = rule.decls.get("font-weight");
if (weight === undefined) continue;
if (!WEIGHTS.includes(weight)) wrong.push(`${rule.selector} { font-weight: ${weight} }`);
else if (weight === "800") assert.equal(rule.selector, "h1", "only the primary heading is 800");
}
assert.deepEqual(wrong, [], `these weights are off the ladder ${WEIGHTS.join("/")}: ${wrong.join(", ")}`);
});
designTest("type: every heading size is declared with the line-height the reference pairs it with", () => {
const PAIRS: ReadonlyArray<readonly [string, string, string]> = [
["h1", "2.25rem", "3rem"],
["h1.tight", "1.875rem", "2.5rem"],
];
for (const [selector, size, height] of PAIRS) {
assert.equal(decl(selector, "font-size"), size, `${selector} takes the ${size} step`);
assert.equal(decl(selector, "line-height"), height, `${selector} takes the ${size} step's own line-height`);
}
const PHONE: ReadonlyArray<readonly [string, string, string]> = [
["h1", "1.875rem", "2.5rem"],
["h1.tight", "1.625rem", "2.125rem"],
];
for (const [selector, size, height] of PHONE) {
assert.equal(phoneDecl(selector, "font-size"), size, `@phone ${selector} takes the ${size} step`);
assert.equal(phoneDecl(selector, "line-height"), height, `@phone ${selector} pairs ${size} with its own line-height`);
}
});
designTest("type: a block title and its copy are one rule across all four grounds", () => {
const title = sheet.rules.filter((r) => /^\.(notice|warn|info) \.title$/.test(r.selector) && r.decls.has("font-size"));
assert.equal(title.length, 3, "the three grounds must share one title rule, and got " + title.map((r) => r.selector).join(", "));
for (const rule of title) {
assert.equal(rule.decls.get("font-size"), "1rem", `${rule.selector} is the body step`);
assert.equal(rule.decls.get("font-weight"), "600", `${rule.selector} is a title weight, not a label weight`);
}
const body = sheet.rules.filter((r) => /^\.(notice|warn|info) p$/.test(r.selector) && r.decls.has("font-size"));
assert.equal(body.length, 3, "and one body rule, and got " + body.map((r) => r.selector).join(", "));
for (const rule of body) assert.equal(rule.decls.get("font-size"), ".875rem", `${rule.selector} is the small step`);
for (const rule of everyRule()) {
if (!/^\.(notice|warn|info)(\s|$)/.test(rule.selector)) continue;
if (/\.(notice|warn|info) (\.title|p)$/.test(rule.selector)) continue;
for (const property of ["font-size", "font-weight"]) {
assert.equal(rule.decls.get(property), undefined,
`${rule.selector} restates ${property}; the four grounds must differ only in colour`);
}
}
});
designTest("type: block copy carries no leading of its own — the page has one", () => {
assert.equal(decl("body", "line-height"), "1.5", "the page's one leading");
for (const selector of [".notice p", ".warn p", ".info p", ".choice .feature", ".muted", ".lede"]) {
const declared = decl(selector, "line-height");
assert.ok(declared === undefined || declared === "1.5",
`${selector} declares line-height: ${declared}, which is a second leading for the same copy`);
}
});
designTest("type: `Legend, 12 months` is one title, on the history list's rows and on the paid-no-code screen's summary", () => {
const rule = sheet.rules.find((r) => r.selector === ".rows.field > .name" && r.decls.has("font-size"));
assert.ok(rule, "the paid-no-code screen's summary title must be styled, not left to inherit the body");
const row = sheet.rules.find((r) => r.selector === ".entry .name" && r.decls.has("font-size"));
assert.ok(row, "the history list's row title must be styled");
for (const [name, r] of [["paidNoCode", rule], ["the history list", row]] as const) {
assert.equal(r.decls.get("font-size"), "1.125rem", `${name}'s purchase title takes the same step`);
assert.equal(r.decls.get("font-weight"), "600", `${name}'s purchase title takes the same weight`);
}
assert.equal(phoneDecl(".entry .name", "font-size"), "1rem");
assert.equal(phoneDecl(".rows.field > .name", "font-size"), "1rem");
const order = {
orderId: "inv_9f3a", badgeType: "legend", months: 12,
createdAt: "2026-08-28T11:46:00Z", status: "open" as const,
};
const paidNoCode = render(screens.paidNoCode({ order, settledAt: undefined }));
const history = render(screens.purchaseHistory({ onForget: () => {}, keepsNewCodes: true,
rows: [{ kind: "open" as const, order }], onOpen: () => {}, onStart: () => {},
}));
assert.equal(paidNoCode.all("div.name")[0]!.textContent, "Legend, 12 months");
assert.equal(history.all("div.name")[0]!.textContent, "Legend, 12 months",
"the same string, so it must not be two type roles");
});
designTest("type: a tier leads with its name and a duration with its price — never both", () => {
assert.equal(decl(".choice .name", "font-weight"), "600", "a tier's name leads");
assert.equal(decl(".choice .price", "font-weight"), "400", "and its price is stated plainly");
assert.equal(decl(".choice.term .name", "font-weight"), "500", "a duration's term steps back");
assert.equal(decl(".choice.term .price", "font-weight"), "600", "and its price leads");
assert.equal(decl(".choice.term .price", "font-size"), "1.375rem", "at a larger step than the term above it");
for (const kind of ["", ".term"]) {
const name = decl(`.choice${kind} .name`, "font-weight") ?? decl(".choice .name", "font-weight");
const price = decl(`.choice${kind} .price`, "font-weight") ?? decl(".choice .price", "font-weight");
assert.notEqual(name, price, `.choice${kind} weights its name and its price alike`);
}
assert.equal(decl(".choice .badge-art + .name", "font-size"), "1.25rem");
assert.equal(decl(".choice .badge-art ~ .price", "font-size"), "1.125rem");
assert.equal(decl(".choice .badge-art ~ .price", "font-weight"), undefined,
"artwork changes the step, never the weight");
});
designTest("type: the struck total is a step of the scale, and stacks at every width", () => {
assert.equal(decl(".choice .price .was", "font-size"), ".875rem");
assert.equal(decl(".choice .price .was", "color"), "var(--ink-soft)");
assert.equal(decl(".choice .price .was", "display"), "block");
assert.equal(phoneDecl(".choice .price .was", "display"), undefined,
"the stack is the layout, not a phone variant of it");
});
designTest("type: the phone band steps the selectors that can actually win", () => {
const sized = sheet.rules.filter((r) => r.decls.has("font-size"));
for (const banded of phoneRules()) {
const step = banded.decls.get("font-size");
if (step === undefined) continue;
const mine = specificity(banded.selector);
for (const base of sized) {
if (subject(base.selector) !== subject(banded.selector)) continue;
if (!beats(specificity(base.selector), mine)) continue;
if (base.decls.get("font-size") === step) continue;
assert.ok(phoneRules().some((r) => r.selector === base.selector && r.decls.has("font-size")),
`@phone ${banded.selector} steps to ${step} but is beaten by `
+ `${base.selector} { font-size: ${base.decls.get("font-size")} }, which the band never steps — `
+ "the declaration reaches nothing");
}
}
});
designTest("type: figures that tick or line up are set in tabular numerals", () => {
const rule = sheet.rules.filter((r) => r.decls.get("font-variant-numeric") === "tabular-nums");
const claimed = new Set(rule.map((r) => r.selector));
for (const selector of [".rate", ".notice .title", ".choice .price", ".row", ".entry .meta"]) {
assert.ok(claimed.has(selector), `${selector} holds a figure and must set tabular numerals`);
}
});
designTest("type: the uppercase micro-labels are one rule, tracked in em", () => {
assert.equal(decl(".label", "text-transform"), "uppercase");
assert.equal(decl(".label", "font-size"), ".875rem", "the floor for text: 14px, as simplex.domains keeps it");
assert.equal(decl(".label", "font-weight"), "700");
assert.match(decl(".label", "letter-spacing") ?? "", /em$/, "tracking follows the size, so it survives a step down");
for (const property of ["font-size", "font-weight", "letter-spacing", "text-transform"]) {
assert.equal(decl(".label.standalone", property), undefined,
`.label.standalone restates ${property}; only its place on the screen differs`);
}
assert.match(decl(".code", "letter-spacing") ?? "", /em$/);
});
designTest("type: the monospace runs are sized against the copy beside them", () => {
assert.equal(decl(".code", "font-size"), "1.375rem");
assert.equal(decl(".code", "font-family"), "var(--mono)");
assert.equal(decl(".mono", "font-size"), "1rem");
assert.equal(decl(".mono", "font-family"), "var(--mono)");
assert.equal(decl(".entry .code-row code", "font-size"), ".875rem", ".entry .code-row code is the small step");
assert.equal(decl(".warn p", "font-size"), ".875rem",
"which is the size of a notice's own copy");
assert.equal(decl(".entry .mono", "font-size"), undefined);
});
const officialAssets: ReadonlyArray<readonly [string, string, (source: string) => string]> = [
["wordmark-light.svg", "../../../../../website/src/img/simplex.svg", (x) => x],
// wordmark-dark is the official file with its navy lettering set to white, as simplex.chat's own dark header does.
["wordmark-dark.svg", "../../../../../website/src/img/simplex.svg",
(x) => x.replace(/fill="#030749"/g, 'fill="#FFFFFF"')],
["symbol-light.svg", "../../../../../media-logos/simplex-symbol-light.svg", (x) => x],
["symbol-dark.svg", "../../../../../media-logos/simplex-symbol-dark.svg", (x) => x],
];
designTest("design: the brand artwork is the official file, byte for byte", () => {
for (const [shipped, source, transform] of officialAssets) {
const served = readFileSync(new URL(`../../public/img/${shipped}`, import.meta.url), "utf8");
const official = readFileSync(new URL(source, import.meta.url), "utf8");
assert.equal(served, transform(official),
`public/img/${shipped} is not ${source} — the mark must be the file, never a redrawing of it`);
}
});
designTest("design: no module reaches for a sink that turns a string into markup", () => {
const sinks = /\.innerHTML|\.outerHTML|insertAdjacentHTML|document\.write|\beval\(|new Function\(|srcdoc/;
const dir = new URL("../../src/", import.meta.url);
for (const name of readdirSync(dir).filter((f) => f.endsWith(".ts"))) {
const source = readFileSync(new URL(name, dir), "utf8");
assert.equal(sinks.test(source), false, `src/${name} reaches for a markup sink`);
}
});
designTest("design: no module draws the mark, so no transcription can drift", () => {
assert.equal("simplexLogo" in icons, false,
"the mark is served as a file and painted by the stylesheet; a builder here is a second copy of it");
const source = readFileSync(new URL("../../src/icons.ts", import.meta.url), "utf8");
assert.equal(source.includes("M3.02972 8.59396"), false, "the mark's path data is back in the module");
});
designTest("design: the wordmark is the header's, themed and precachable", () => {
const root = ruleFor(sheet.rules, ":root")!;
const wordmark = root.decls.get("--wordmark");
assert.ok(wordmark !== undefined, "--wordmark must be a token, so the theme can swap it");
assert.match(wordmark, /^url\([^/][^)]*\.svg\)$/, `the URL must be relative, and got "${wordmark}"`);
assert.equal(decl(".brand", "background-image"), "var(--wordmark)");
assert.equal(decl(".brand", "aspect-ratio"), "161 / 40", "at the source file's own proportions");
});
function testChrome(over: Partial<Parameters<typeof screens.chrome>[0]> = {}): ReturnType<typeof screens.chrome> {
return screens.chrome({
onNewPurchase: () => {}, onHistory: () => {},
theme: "system", onTheme: () => {}, onToggle: () => {}, onHome: () => {}, ...over,
});
}
designTest("design: the chrome is a wordmark home link and a menu, and holds no order", () => {
let started = 0;
let history = 0;
const ui = testChrome({
onNewPurchase: () => { started += 1; },
onHistory: () => { history += 1; },
});
const bar = render(ui.node);
const brand = bar.all("a.brand")[0]!;
assert.equal(brand.getAttribute("href"), "/", "the wordmark goes home");
assert.equal(brand.getAttribute("target"), null, "standalone, it stays in this page");
assert.equal(brand.getAttribute("aria-label"), "SimpleX", "and carries the name, since it has no text");
const trigger = bar.all("button.menu-button")[0]!;
assert.equal(trigger.getAttribute("aria-expanded"), "false");
assert.equal(trigger.getAttribute("aria-controls"), screens.MENU_ID);
assert.equal(bar.all("svg.bars").length, 1, "the hamburger is drawn, not typed");
assert.deepEqual(bar.all("button.menu-item").map((b) => b.textContent),
[screens.NEW_PURCHASE, screens.PURCHASE_HISTORY]);
for (const b of bar.all("button.menu-item")) b.click();
assert.deepEqual([started, history], [1, 1]);
assert.equal(bar.all("button.danger").length, 0, "the menu holds nothing destructive");
assert.equal(bar.all("div.menu-section").length, 2);
assert.equal(/SB-|order=|inv_/.test(bar.serialize()), false, bar.serialize());
});
designTest("design: the theme control is the reference's segmented setting, over three values", () => {
const chosen: string[] = [];
const ui = testChrome({ theme: "dark", onTheme: (t) => chosen.push(t) });
const bar = render(ui.node);
assert.ok(bar.textContent.includes(screens.THEME_LABEL));
const segments = bar.all("button.segment");
assert.deepEqual(segments.map((b) => b.textContent), ["Light", "Dark", "System"]);
assert.deepEqual(segments.map((b) => b.getAttribute("aria-pressed")), ["false", "true", "false"],
"the stored theme is the one drawn as chosen");
segments[0]!.click();
assert.deepEqual(chosen, ["light"], "the choice is reported, and applying it is main.ts's");
assert.deepEqual(segments.map((b) => b.getAttribute("aria-pressed")), ["false", "true", "false"]);
ui.showTheme("light");
assert.deepEqual(segments.map((b) => b.getAttribute("aria-pressed")), ["true", "false", "false"]);
});
designTest("design: the menu opens, closes on choosing, and lists what the keyboard can reach", () => {
const toggles: boolean[] = [];
const ui = testChrome({ onToggle: (open) => toggles.push(open) });
const bar = render(ui.node);
const trigger = bar.all("button.menu-button")[0]!;
const menu = bar.all("div.menu")[0]!;
assert.equal(menu.getAttribute("role"), "dialog", "so a screen reader knows the page has a layer over it");
assert.equal(menu.hasAttribute("hidden"), true, "it starts closed");
trigger.click();
assert.equal(ui.isOpen(), true);
assert.equal(menu.hasAttribute("hidden"), false);
assert.equal(trigger.getAttribute("aria-expanded"), "true");
assert.equal(ui.focusables().length, 5);
bar.all("button.menu-item")[1]!.click();
assert.equal(ui.isOpen(), false, "choosing an item closes it before the screen changes");
assert.equal(menu.hasAttribute("hidden"), true);
trigger.click();
ui.close();
assert.equal(ui.isOpen(), false);
assert.deepEqual(toggles, [true, false, true, false]);
});
designTest("design: the withholding reaches the menu — no second invoice while a card is in flight", () => {
const ui = testChrome();
const bar = render(ui.node);
const fresh = bar.all("button.menu-item")[0]!;
assert.equal(fresh.textContent, screens.NEW_PURCHASE);
ui.offerNewPurchase(false);
assert.equal(fresh.hasAttribute("hidden"), true,
"the create endpoint has no idempotency key, so a menu that offered one would be a second charge");
assert.equal(ui.focusables().length, 4, "and the keyboard cannot reach what the eye cannot see");
ui.offerNewPurchase(true);
assert.equal(fresh.hasAttribute("hidden"), false);
});
designTest("design: each badge is its source file's viewBox, gradient stops and glyph", () => {
const expected: Record<string, string[]> = {
supporter: ["#29f5ff", "#29f5ff", "#527eed", "#3669e9"],
legend: ["#29f5ff", "#26dee8", "#3064ea", "#001064"],
};
for (const [tier, colours] of Object.entries(expected)) {
assert.equal(icons.hasBadgeArt(tier), true);
const art = render(icons.badgeIcon(tier as "supporter" | "legend"));
assert.equal(art.getAttribute("viewBox"), "8.25 8.25 300 399", `${tier}'s box is the source file's`);
assert.equal(art.getAttribute("aria-hidden"), "true");
assert.deepEqual(art.all("stop").map((s) => s.getAttribute("stop-color")), colours,
`${tier}'s gradient must be the file's four stops, in order`);
assert.deepEqual(art.all("stop").map((s) => s.getAttribute("offset")), ["0%", "5%", "50%", "100%"].map(
(o, i) => (tier === "supporter" ? ["0%", "5%", "95%", "100%"][i]! : o)));
const paths = art.all("path");
assert.equal(paths.length, 2, "a body and a glyph");
assert.match(paths[0]!.getAttribute("fill") ?? "", /^url\(#/, "the body takes the gradient");
assert.equal(paths[1]!.getAttribute("fill"), "#ffffff", "and the glyph is white");
}
assert.equal(icons.hasBadgeArt("investor"), false, "a tier the catalog does not sell has no art here");
});
designTest("design: two badges on one screen get two gradients, or both would be one colour", () => {
// A gradient id is document-wide, so two with the same id would paint both in the first one's colour.
const ids = [icons.badgeIcon("supporter"), icons.badgeIcon("legend"), icons.badgeIcon("supporter")]
.map((n) => render(n).all("linearGradient")[0]!.getAttribute("id"));
assert.equal(new Set(ids).size, 3, `every gradient needs its own id, and got ${JSON.stringify(ids)}`);
});
designTest("design: the tier list's tier cards carry the badge art, one each, above the name", () => {
const p = render(screens.tiers({
tiers: [
{ priceId: "price_supporter", badgeType: "supporter", name: "Supporter", price: "$7 / month", features: ["2 GB files"], disabled: false },
{ priceId: "price_legend", badgeType: "legend", name: "Legend", price: "$70 / month", features: ["5 GB files"], disabled: false },
],
selected: "price_legend", onSelect: () => {}, onContinue: () => {}, onBack: () => {},
}));
const cards = p.all("button.choice");
assert.equal(cards.length, 2);
for (const [i, card] of cards.entries()) {
const art = card.all("svg.badge-art");
assert.equal(art.length, 1, `tier ${i} must draw exactly one badge`);
assert.equal(art[0]!.getAttribute("viewBox"), "8.25 8.25 300 399");
assert.equal(card.children.indexOf(art[0]!), 0, "and it is the first thing in the card, above the name");
}
assert.equal(cards[0]!.all("stop")[3]!.getAttribute("stop-color"), "#3669e9");
assert.equal(cards[1]!.all("stop")[3]!.getAttribute("stop-color"), "#001064");
});
designTest("design: a tier with no artwork still renders, without a badge", () => {
const p = render(screens.tiers({
tiers: [{ priceId: "p", badgeType: "founder", name: "Founder", price: "$1 / month", features: [], disabled: false }],
selected: undefined, onSelect: () => {}, onContinue: () => {}, onBack: () => {},
}));
assert.equal(p.all("svg.badge-art").length, 0, "no art is better than the wrong badge");
assert.ok(p.textContent.includes("Founder"), "and the tier is still on the screen");
});
designTest("design: the order summary's method row carries all three payment marks, in order", () => {
const p = render(screens.orderSummary({ canKeepTheCode: true,
badgeType: "legend", months: 12, total: "$420.00", selected: "xmr",
onSelect: () => {}, onPay: () => {}, onBack: () => {},
}));
const cards = p.all("button.choice");
assert.equal(cards.length, 3);
const marks = cards.map((c) => c.all("svg.mark"));
for (const [i, m] of marks.entries()) {
assert.equal(m.length, 1, `method ${i} must carry exactly one mark`);
assert.equal(m[0]!.getAttribute("viewBox"), "0 0 24 24", "every mark is drawn in the same 24×24 box");
assert.equal(m[0]!.getAttribute("aria-hidden"), "true");
}
assert.equal(marks[0]![0]!.all("path").length, 0, "the card is a rect and a line, not a filled glyph");
assert.equal(marks[0]![0]!.all("rect")[0]!.getAttribute("stroke"), "currentColor");
assert.equal(marks[0]![0]!.all("line")[0]!.getAttribute("stroke"), "currentColor");
assert.equal(marks[1]![0]!.all("path")[0]!.getAttribute("fill"), "#F7931A");
assert.deepEqual(marks[2]![0]!.all("path").map((p) => p.getAttribute("fill")), ["#F60", "#4C4C4C"],
"the Monero glyph is the official two-colour mark, as simplex.domains draws it");
assert.deepEqual(cards.map((c) => c.textContent), ["Card", "Bitcoin", "Monero"], "the card first, then the two coins");
});
designTest("design: no screen repeats the mark — the header carries it, and the code screen has its tick", () => {
const order = {
orderId: "inv_9f3a", badgeType: "legend", months: 12,
createdAt: "2026-08-28T11:46:00Z", status: "open" as const,
};
const everyScreen: Array<[string, unknown]> = [
["the landing screen", screens.landing({ onStart: () => {} })],
["the catalog-changed screen", screens.catalogChanged(() => {})],
["awaitingConfirmation", screens.awaitingConfirmation({ order, invoice: undefined, method: undefined, gaveUp: false, onCheckAgain: () => {} })],
["windowClosed", screens.windowClosed({ onNewInvoice: () => {}, order, invoice: { status: "expired" } })],
["paidNoCode", screens.paidNoCode({ order, settledAt: undefined })],
["unknownOrder", screens.unknownOrder(() => {})],
["invoiceFailure", screens.invoiceFailure({ onBack: () => {}, onRetry: () => {} })],
];
for (const [name, node] of everyScreen) {
assert.equal(render(node).all("svg.logo").length, 0, `${name} must not draw a second mark`);
assert.equal(render(node).all("a.brand").length, 0, `${name} must not draw a second wordmark`);
}
const codeIssued = render(screens.codeIssued({ code: "SB-YDC8A-YGQTM-PUYZ9-2TUXP", savedLocally: true }));
assert.equal(codeIssued.all("div.tick").length, 1, "the code screen opens on the settled tick");
});
designTest("design: every image the sheet asks for is precached, so the landing screen works offline", () => {
const worker = readFileSync(new URL("../../public/sw.js", import.meta.url), "utf8");
const build = /const BUILD = "([0-9a-f]+)";/.exec(worker);
assert.ok(build, "the worker must name a build");
const root = ruleFor(sheet.rules, ":root")!;
const dark = darkQuery();
const tokens = ["--hero", "--wordmark"].flatMap((t) => [root.decls.get(t)!, dark.decls.get(t)!]);
for (const token of tokens) {
const file = /^url\(([^)]+)\)$/.exec(token)![1]!;
assert.ok(worker.includes(`\${ASSETS}${file}\``),
`the stylesheet asks for ${file}, and the worker does not precache it`);
}
const shell = readFileSync(new URL("../../public/index.html", import.meta.url), "utf8");
for (const icon of ["symbol-light.svg", "symbol-dark.svg"]) {
assert.ok(shell.includes(`/assets/${build[1]!}/${icon}`), `the shell must name ${icon} under this build`);
assert.ok(worker.includes(`\${ASSETS}${icon}\``), `and the worker must precache ${icon}`);
}
assert.match(shell, /<link rel="icon"[^>]*media="\(prefers-color-scheme: light\)"/,
"and pick the variant by theme, as the wordmark does");
});