mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-30 06:39:09 +00:00
* 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>
692 lines
37 KiB
TypeScript
692 lines
37 KiB
TypeScript
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");
|
||
});
|