CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 49s
CI / package-release (pull_request) Successful in 47s
CI / migration-smoke (pull_request) Successful in 2m18s
CI / image-build (pull_request) Successful in 1m3s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
A second real-iPhone review of the Stage 1 Dashboard found three defects. This fixes them, and ADR 0065 records the decisions. - Settings -> Displays now sets up a screen in one place. A "Set up a new screen" panel shows the platform display page to open on the screen, a Copy link button, and Add display directly beneath it; the step-by-step version is folded into a <details>. The addresses come from a new GET /api/v1/devices/setup, which requires core.devices.manage and lists each running display module's /display/<module-id> page. Nothing names Signage. With no display module installed, the panel says so and offers no Add display. - iOS Safari zoomed into the pairing field because the shared .ek-control rendered at 14px. On phones and touch screens it now renders at max(1rem, 16px). The viewport is untouched, so zoom stays available, and a test guards that. The shared focus trap now returns focus with preventScroll, so closing a dialog no longer moves the page under the phone header. - While the phone drawer is open, the page behind it no longer scrolls. The root's overflow is hidden, a non-passive touchmove listener cancels drags outside the drawer's list and overscroll past its ends, and root overscroll is turned off. The list itself still scrolls. Closing restores the exact scroll position unless another page opened. Co-Authored-By: Claude Opus 5.5 <[email protected]>
180 lines
7.9 KiB
TypeScript
180 lines
7.9 KiB
TypeScript
import { readdir, readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { ekklesiaDesignTokens } from "@ekklesia/design-tokens";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
EKKLESIA_UI_STYLE_ATTRIBUTE,
|
|
EKKLESIA_UI_STYLESHEET,
|
|
installEkklesiaUiStyles,
|
|
} from "../src/index.js";
|
|
|
|
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
|
|
async function sourceFiles(): Promise<readonly string[]> {
|
|
const directory = path.join(packageDirectory, "src");
|
|
return (await readdir(directory))
|
|
.filter((entry) => entry.endsWith(".ts") || entry.endsWith(".tsx"))
|
|
.map((entry) => path.join(directory, entry));
|
|
}
|
|
|
|
/** Any literal that would be a theme decision rather than a layout one. */
|
|
const THEME_LITERAL_PATTERN =
|
|
/#[0-9a-f]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\(|\boklch\s*\(|\bsystem-ui\b|\bsans-serif\b/giu;
|
|
|
|
describe("the shared elements consume design tokens, never hardcoded theme values", () => {
|
|
it("expresses every theme value in the stylesheet as a token reference", () => {
|
|
expect(EKKLESIA_UI_STYLESHEET.match(THEME_LITERAL_PATTERN)).toBeNull();
|
|
expect(EKKLESIA_UI_STYLESHEET).toContain("var(--ekklesia-");
|
|
});
|
|
|
|
it("references only registered tokens", () => {
|
|
const referenced = [...EKKLESIA_UI_STYLESHEET.matchAll(/var\((--ekklesia-[a-z0-9-]+)\)/gu)].map(
|
|
([, variable]) => String(variable),
|
|
);
|
|
const registered = new Set(ekklesiaDesignTokens.list().map((token) => token.cssVariable));
|
|
|
|
expect(referenced.length).toBeGreaterThan(0);
|
|
expect([...new Set(referenced)].filter((variable) => !registered.has(variable))).toEqual([]);
|
|
});
|
|
|
|
it("never defines a global Ekklesia theme variable of its own", () => {
|
|
// Referencing `--ekklesia-*` is the contract; *declaring* one would invent a global theme
|
|
// variable outside the registry, which is precisely what the design-token contract forbids.
|
|
expect(EKKLESIA_UI_STYLESHEET).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u);
|
|
});
|
|
|
|
it("hardcodes no theme value in component source either", async () => {
|
|
for (const file of await sourceFiles()) {
|
|
if (path.basename(file) === "styles.ts") {
|
|
continue;
|
|
}
|
|
expect(await readFile(file, "utf8")).not.toMatch(THEME_LITERAL_PATTERN);
|
|
}
|
|
});
|
|
|
|
it("never names a Node built-in or reads the environment", async () => {
|
|
for (const file of await sourceFiles()) {
|
|
const source = await readFile(file, "utf8");
|
|
|
|
expect(source).not.toContain('"node:');
|
|
expect(source).not.toContain("process.env");
|
|
}
|
|
});
|
|
|
|
it("depends only on the design tokens and React", async () => {
|
|
const manifest = JSON.parse(
|
|
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
|
) as {
|
|
dependencies?: Record<string, string>;
|
|
devDependencies?: Record<string, string>;
|
|
peerDependencies?: Record<string, string>;
|
|
};
|
|
|
|
// React is a peer only (ADR 0053): the consumer's React is the one in every bundle these
|
|
// elements are built into. The development dependency resolves the package's own imports for its
|
|
// builds and tests; nothing loads it from this workspace in a production Node.js process
|
|
// (`runtime-imports-are-production-dependencies` allows exactly this peer).
|
|
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/design-tokens"]);
|
|
expect(manifest.devDependencies?.react).toBeDefined();
|
|
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual(["react"]);
|
|
});
|
|
|
|
it("installs its stylesheet into a document root exactly once", () => {
|
|
const remove = installEkklesiaUiStyles(document);
|
|
installEkklesiaUiStyles(document);
|
|
|
|
expect(document.querySelectorAll(`style[${EKKLESIA_UI_STYLE_ATTRIBUTE}]`).length).toBe(1);
|
|
|
|
remove();
|
|
remove();
|
|
expect(document.querySelectorAll(`style[${EKKLESIA_UI_STYLE_ATTRIBUTE}]`).length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("the element stylesheet takes every colour, radius, and duration from a token (ADR 0060)", () => {
|
|
// Comments are dropped: they may mention a value without using it.
|
|
const css = EKKLESIA_UI_STYLESHEET.replace(/\/\*[\s\S]*?\*\//gu, "");
|
|
|
|
it("contains no raw colour", () => {
|
|
expect(css).not.toMatch(/#[0-9a-f]{3,8}\b/iu);
|
|
expect(css).not.toMatch(/\b(?:rgba?|hsla?|oklch|lab|lch|hwb)\(/iu);
|
|
// A named colour is a raw colour too; `transparent` and `currentColor` are not colours.
|
|
expect(css).not.toMatch(/:\s*(?:white|black|red|green|blue|gray|grey)\s*;/iu);
|
|
});
|
|
|
|
it("rounds corners only with radius tokens", () => {
|
|
for (const match of css.matchAll(/border-radius:\s*([^;]+);/gu)) {
|
|
// Each corner is a radius token, or square.
|
|
for (const corner of (match[1] ?? "").split(/\s+/u)) {
|
|
expect(corner, match[0]).toMatch(/^(?:var\(--ekklesia-radius-[a-z]+\)|0)$/u);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("animates only for motion-token durations", () => {
|
|
for (const match of css.matchAll(/(?:animation|transition):\s*([^;]+);/gu)) {
|
|
expect(match[1], match[0]).not.toMatch(/\b\d+(?:\.\d+)?m?s\b/u);
|
|
expect(match[1], match[0]).toContain("--ekklesia-motion-duration-");
|
|
}
|
|
});
|
|
});
|
|
|
|
/** The body of the rule whose whole selector list is exactly `selector`. */
|
|
function ruleBody(selector: string): string {
|
|
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
const match = new RegExp(`(?<!,)\n${escaped} \\{([^}]*)\\}`, "u").exec(EKKLESIA_UI_STYLESHEET);
|
|
return match?.[1] ?? "";
|
|
}
|
|
|
|
describe("choice indicators are drawn from tokens (ADR 0064)", () => {
|
|
it("never leaves a checkbox or radio to the browser's accent-tinted rendering", () => {
|
|
// `accent-color` lets each browser pick its own mark for the selected state — a near-black dot
|
|
// on the light theme's pale teal — so the selected state matched neither theme.
|
|
expect(EKKLESIA_UI_STYLESHEET).not.toMatch(/accent-color\s*:/u);
|
|
const base = ruleBody(".ek-checkbox__input,\n.ek-radio__input");
|
|
expect(base).toContain("appearance: none");
|
|
expect(base).toContain("var(--ekklesia-border-control)");
|
|
expect(base).toContain("var(--ekklesia-background-surface)");
|
|
});
|
|
|
|
it("marks the selected state with the strong accent, the same in light and dark", () => {
|
|
const radio = ruleBody(".ek-radio__input:checked");
|
|
expect(radio).toContain("background: var(--ekklesia-accent-strong)");
|
|
expect(radio).toContain("border-color: var(--ekklesia-accent-strong)");
|
|
expect(radio).toContain("var(--ekklesia-background-surface)");
|
|
const checkbox = ruleBody(".ek-checkbox__input:checked");
|
|
expect(checkbox).toContain("background: var(--ekklesia-accent-strong)");
|
|
expect(ruleBody(".ek-checkbox__input:checked::before")).toContain(
|
|
"background: var(--ekklesia-background-surface)",
|
|
);
|
|
// A radio is round by the pill radius token, never a literal.
|
|
expect(ruleBody(".ek-radio__input")).toContain("var(--ekklesia-radius-pill)");
|
|
});
|
|
|
|
it("keeps disabled and forced-colour states", () => {
|
|
expect(ruleBody(".ek-checkbox__input:disabled,\n.ek-radio__input:disabled")).toContain(
|
|
"var(--ekklesia-state-disabled-opacity)",
|
|
);
|
|
expect(EKKLESIA_UI_STYLESHEET).toMatch(/@media \(forced-colors: active\)/u);
|
|
});
|
|
});
|
|
|
|
describe("text controls never make iOS Safari zoom (ADR 0065)", () => {
|
|
it("renders typed text at 16px or more on phones and touch screens, and compact on desktop", () => {
|
|
// iOS Safari zooms the page into a focused field whose font is under 16px.
|
|
expect(ruleBody(".ek-control")).toContain("font-size: var(--ekklesia-typography-size-sm)");
|
|
const phone =
|
|
/@media \(max-width: 47\.999em\), \(pointer: coarse\) \{\s*\.ek-control \{([^}]*)\}/u.exec(
|
|
EKKLESIA_UI_STYLESHEET,
|
|
)?.[1];
|
|
expect(phone).toContain("font-size: max(var(--ekklesia-typography-size-md), 16px)");
|
|
});
|
|
|
|
it("puts the phone rule after the base rule, so it wins", () => {
|
|
expect(EKKLESIA_UI_STYLESHEET.indexOf("(pointer: coarse)")).toBeGreaterThan(
|
|
EKKLESIA_UI_STYLESHEET.indexOf("\n.ek-control {"),
|
|
);
|
|
});
|
|
});
|