feat(web): mobile-first tokens and controls

44px controls, a separate 24px chip height, one row height, a gutter at
every width, min-width breakpoints, and the shared control and row
vocabulary in ui.ts. check-tokens now also rejects var() names that
nothing defines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-10-24 17:28:22 +01:00
co-authored by Claude Opus 5.5
parent 460b82f039
commit ce6d38d044
4 changed files with 2848 additions and 2302 deletions
+30 -4
View File
@@ -2,6 +2,11 @@
// Design-token gate (issue #45): components consume tokens from :root in
// web/src/style.css, never literal colour or spacing values.
//
// Every `var(--x)` must name a custom property something defines — a token in
// `:root`, a component-local one in the CSS, or one the TS sets with
// `setProperty` — so a mistyped or deleted token fails here rather than
// silently falling back to the browser default.
//
// Allowed colour literals: inside a `:root` block (token definitions) and
// inside `@font-face`. Relative colour derived from a token —
// `oklch(from var(--x) …)` — counts as consuming the token and is allowed
@@ -114,16 +119,37 @@ function checkPlain(source, rel) {
}
const files = [join(webRoot, "index.html"), ...walk(join(webRoot, "src"))];
for (const file of files) {
const rel = relative(webRoot, file);
const source = readFileSync(file, "utf8");
if (file.endsWith(".css")) {
const sources = files.map((file) => ({
rel: relative(webRoot, file),
source: readFileSync(file, "utf8"),
}));
for (const { rel, source } of sources) {
if (rel.endsWith(".css")) {
checkCss(source, rel);
} else {
checkPlain(source, rel);
}
}
const defined = new Set();
for (const { source } of sources) {
for (const match of source.matchAll(/(--[\w-]+)\s*:/g)) {
defined.add(match[1]);
}
for (const match of source.matchAll(/setProperty\(\s*["'](--[\w-]+)["']/g)) {
defined.add(match[1]);
}
}
for (const { rel, source } of sources) {
source.split("\n").forEach((line, index) => {
for (const match of line.matchAll(/var\(\s*(--[\w-]+)/g)) {
if (!defined.has(match[1])) {
errors.push(`${rel}:${index + 1}: unknown token ${match[1]} — nothing defines it`);
}
}
});
}
if (errors.length > 0) {
console.error("check-tokens: components must consume design tokens:\n");
for (const error of errors) {
+40 -22
View File
@@ -40,14 +40,36 @@ const ICON_PATHS = {
],
chevronUp: ["M3 10.2l5-4.6 5 4.6"],
chevronDown: ["M3 5.8l5 4.6 5-4.6"],
chevronRight: ["M6 3l4.6 5L6 13"],
back: ["M13 8H3.4", "M7.4 3.8L3.2 8l4.2 4.2"],
play: ["M4.6 2.9v10.2c0 .4.4.6.7.4l7.8-5.1c.3-.2.3-.6 0-.8L5.3 2.5c-.3-.2-.7 0-.7.4Z"],
more: [
"M3.2 7.3a.7.7 0 1 1 0 1.4.7.7 0 0 1 0-1.4Z",
"M8 7.3a.7.7 0 1 1 0 1.4.7.7 0 0 1 0-1.4Z",
"M12.8 7.3a.7.7 0 1 1 0 1.4.7.7 0 0 1 0-1.4Z",
],
plus: ["M8 3v10", "M3 8h10"],
grid: [
"M2.5 2.5h4.4v4.4H2.5z",
"M9.1 2.5h4.4v4.4H9.1z",
"M2.5 9.1h4.4v4.4H2.5z",
"M9.1 9.1h4.4v4.4H9.1z",
],
list: ["M5.6 4h8", "M5.6 8h8", "M5.6 12h8", "M2.4 4h.01", "M2.4 8h.01", "M2.4 12h.01"],
library: ["M2.5 2.5h3v11h-3z", "M6.5 2.5h3v11h-3z", "M10.3 3.1l2.9-.8 2.3 10.6-2.9.7z"],
close: ["M3.6 3.6l8.8 8.8", "M12.4 3.6l-8.8 8.8"],
info: ["M8 1.8a6.2 6.2 0 1 1 0 12.4A6.2 6.2 0 0 1 8 1.8Z", "M8 7.2v4", "M8 4.8h.01"],
} as const;
export type IconName = keyof typeof ICON_PATHS;
/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */
export function icon(name: keyof typeof ICON_PATHS): SVGSVGElement {
export function icon(name: IconName): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 16 16");
svg.setAttribute("class", "icon");
svg.setAttribute("aria-hidden", "true");
svg.dataset.icon = name;
for (const d of ICON_PATHS[name]) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
@@ -57,24 +79,27 @@ export function icon(name: keyof typeof ICON_PATHS): SVGSVGElement {
}
/**
* How much room the control has. A settings row can spell the confirmation
* out; a chip cannot, and says the same thing in one glyph.
* How much room the control has, and where the armed state explains itself.
*/
export interface ArmedDeleteOptions {
/**
* Chip scale: the control plate comes off and the armed state swaps the
* trash for a check instead of rendering the word "confirm", which a chip
* has no width for (§9.6 keeps a chip to one row). Everything else — the
* two clicks, the amber armed state, the 4-second disarm, the label — is
* the same control, so a chip and a settings row arm alike.
* Chip scale: the control plate comes off (§9.6 keeps a chip to one row).
* The two clicks, the amber armed state and the 4-second disarm are the
* same control either way.
*/
compact?: boolean;
/**
* The row's status slot. Arming writes "tap again to …" here and disarming
* clears it, so the button itself never changes size — the explanation
* lives beside it, not inside it.
*/
explain?: (text: string | null) => void;
}
/**
* The settings arm-then-confirm, icon-only: a trash glyph resting, a visible
* change of what the next click does while armed, so an accidental hit never
* destroys.
* The arm-then-confirm, icon-only: a trash glyph resting, an amber check
* while armed, in the same square either way, so an accidental hit never
* destroys and arming never moves its neighbours.
*/
export function armedDeleteIcon(
label: string,
@@ -83,12 +108,9 @@ export function armedDeleteIcon(
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = options.compact
? "control control-quiet control-icon control-micro"
: "control control-quiet control-icon";
button.className = options.compact ? "control-micro" : "control control-icon";
button.dataset.kind = "delete";
button.setAttribute("aria-label", label);
button.title = label;
button.replaceChildren(icon("trash"));
let armed = false;
let resetTimer: number | undefined;
@@ -97,8 +119,8 @@ export function armedDeleteIcon(
window.clearTimeout(resetTimer);
delete button.dataset.armed;
button.setAttribute("aria-label", label);
button.title = label;
button.replaceChildren(icon("trash"));
options.explain?.(null);
};
button.addEventListener("click", () => {
if (armed) {
@@ -109,13 +131,9 @@ export function armedDeleteIcon(
}
armed = true;
button.dataset.armed = "true";
if (options.compact) {
button.replaceChildren(icon("check"));
} else {
button.textContent = "confirm";
}
button.replaceChildren(icon("check"));
button.setAttribute("aria-label", `confirm — ${label}`);
button.title = `confirm — ${label}`;
options.explain?.(`tap again to ${label}`);
resetTimer = window.setTimeout(disarm, 4000);
});
button.addEventListener("blur", () => {
+2344 -2276
View File
File diff suppressed because it is too large Load Diff
+434
View File
@@ -0,0 +1,434 @@
// The shared control and row vocabulary (Master Control, revision 2). Every
// list in the app is one row primitive with a fixed action column, and every
// button is one of five controls. A view that cannot fit them grows a variant
// here; it does not keep a private copy.
import { type IconName, icon } from "./icons";
/* ---- controls ------------------------------------------------------------ */
export interface ControlOptions {
/** Secondary actions: ink on a hairline, never a toggle. */
quiet?: boolean;
/** A leading glyph beside the word. */
glyph?: IconName;
/** Fixed width, so a label that changes (GRAB → SENDING → SENT) never moves the row. */
fixed?: boolean;
}
/** A word control: the primary action of a header or panel, or a quiet secondary one. */
export function control(text: string, options: ControlOptions = {}): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "control";
if (options.quiet) {
button.classList.add("control-quiet");
}
if (options.fixed) {
button.classList.add("control-fixed");
}
setControlText(button, text, options.glyph);
return button;
}
/** Relabels a word control, keeping its glyph. */
export function setControlText(button: HTMLElement, text: string, glyph?: IconName) {
const word = document.createElement("span");
word.className = "control-word";
word.textContent = text;
button.replaceChildren(...(glyph ? [icon(glyph), word] : [word]));
}
/** A 44px square glyph control. The label is the accessible name and nothing else. */
export function iconControl(glyph: IconName, label: string): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "control control-icon";
button.setAttribute("aria-label", label);
button.append(icon(glyph));
return button;
}
/** An external link wearing the icon control's skin. */
export function iconLink(content: Node, label: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
link.className = "control control-icon";
link.href = href;
link.target = "_blank";
link.rel = "noreferrer";
link.setAttribute("aria-label", label);
link.append(content);
return link;
}
export interface LampToggle {
button: HTMLButtonElement;
set: (pressed: boolean) => void;
}
/**
* A rule that is on or off: a lamp plus the word, never a border colour
* alone (the Word-Beside-The-Lamp rule). The state word has a fixed width,
* so flipping it never moves anything.
*/
export function lampToggle(label: string, pressed: boolean): LampToggle {
const button = document.createElement("button");
button.type = "button";
button.className = "toggle";
const lamp = document.createElement("span");
lamp.className = "toggle-lamp";
lamp.setAttribute("aria-hidden", "true");
const name = document.createElement("span");
name.className = "toggle-label";
name.textContent = label;
const state = document.createElement("span");
state.className = "toggle-state";
state.setAttribute("aria-hidden", "true");
button.append(lamp, name, state);
const set = (next: boolean) => {
button.setAttribute("aria-pressed", String(next));
state.textContent = next ? "on" : "off";
};
set(pressed);
return { button, set };
}
export interface Disclosure {
button: HTMLButtonElement;
/** Repaints the label from the current count; the width is the longest label's. */
paint: (count: number) => void;
}
/**
* Show / hide at full control height. The verb swaps but the plate keeps
* one width, and the count is tabular, so opening never moves the head.
*/
export function disclosure(noun: string, target: HTMLElement): Disclosure {
const button = document.createElement("button");
button.type = "button";
button.className = "control control-quiet disclose";
button.setAttribute("aria-expanded", "false");
const word = document.createElement("span");
word.className = "control-word";
button.append(word, icon("chevronDown"));
let last = 0;
const paint = (count: number) => {
last = count;
const open = !target.hidden;
word.textContent = `${open ? "hide" : "show"} ${count}${noun === "" ? "" : ` ${noun}`}`;
button.setAttribute("aria-expanded", String(open));
};
button.addEventListener("click", () => {
target.hidden = !target.hidden;
paint(last);
});
return { button, paint };
}
/**
* The arm-then-confirm as a labelled control, for the ⋯ tray where a phone
* spells the action out. Full width there, so the armed label can be longer.
*/
export function armedDeleteWord(label: string, execute: () => void): HTMLButtonElement {
const button = control(label, { quiet: true, glyph: "trash" });
button.dataset.kind = "delete";
let armed = false;
let timer: number | undefined;
const disarm = () => {
armed = false;
window.clearTimeout(timer);
delete button.dataset.armed;
setControlText(button, label, "trash");
};
button.addEventListener("click", () => {
if (armed) {
disarm();
button.disabled = true;
execute();
return;
}
armed = true;
button.dataset.armed = "true";
setControlText(button, `tap again to ${label}`, "check");
timer = window.setTimeout(disarm, 4000);
});
button.addEventListener("blur", () => {
if (armed) {
disarm();
}
});
return button;
}
/* ---- status -------------------------------------------------------------- */
/**
* Writes a status slot. The slot is always laid out — an empty one keeps its
* line — so a message arriving or leaving never pushes the content below it.
*/
export function setSlot(
slot: HTMLElement,
text: string | null,
tone?: "fault" | "warn",
busy = false,
) {
slot.dataset.empty = String(text === null);
if (busy && text !== null) {
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = "probing";
lamp.setAttribute("aria-hidden", "true");
slot.replaceChildren(lamp, document.createTextNode(text));
} else {
slot.textContent = text ?? "";
}
if (tone) {
slot.dataset.tone = tone;
} else {
delete slot.dataset.tone;
}
}
/* ---- the row --------------------------------------------------------------- */
/** One cell in a row's action column. An empty cell still takes its width. */
export interface ActionSlot {
node: HTMLElement | null;
/** The width class: a glyph square, a lamp toggle, or a fixed word control. */
width: "icon" | "toggle" | "word";
/** Shown from 40rem up; on a phone this action lives in the ⋯ tray instead. */
wide?: boolean;
/** Shown below 40rem only — an empty cell that keeps a phone column aligned. */
narrow?: boolean;
}
export interface RowParts {
/** `data-row`: which kind of row, for the few rules that differ by kind. */
kind: string;
lead?: HTMLElement | null;
title?: HTMLElement | string | null;
meta?: (HTMLElement | null)[];
sub?: HTMLElement | string | null;
actions?: ActionSlot[];
/** The phone tray: labelled, full-width versions of the wide-only actions. */
more?: HTMLElement[];
/** The whole row opens something: a stretched button under the controls. */
open?: { label: string; run: (origin: HTMLElement) => void; chevron?: boolean };
}
export interface RowDom {
item: HTMLLIElement;
body: HTMLElement;
meta: HTMLElement;
sub: HTMLElement | null;
act: HTMLElement;
/** Full-width area under the line: notes, the ⋯ actions, inline panels. */
tray: HTMLElement;
link: HTMLButtonElement | null;
/** The row's status slot, in the tray. Null clears it. */
say: (text: string | null, tone?: "fault" | "warn") => void;
}
/** A chip, the §9.3 column unit. */
export function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement {
const span = document.createElement("span");
span.className = "chip readout";
span.textContent = text;
extra?.(span);
return span;
}
/**
* The one row: `lead | body | actions`, plus a full-width tray. The body is
* `minmax(0, 1fr)`, so a long title or a run of chips is clipped inside it
* and can never push the actions onto a second line.
*/
export function row(parts: RowParts): RowDom {
const item = document.createElement("li");
item.className = "r";
item.dataset.row = parts.kind;
if (parts.lead) {
const lead = document.createElement("div");
lead.className = "r-lead";
lead.append(parts.lead);
item.append(lead);
} else {
item.dataset.nolead = "";
}
const body = document.createElement("div");
body.className = "r-body";
if (parts.title !== undefined && parts.title !== null) {
const title = document.createElement("div");
title.className = "r-title";
if (typeof parts.title === "string") {
title.textContent = parts.title;
} else {
title.append(parts.title);
}
body.append(title);
}
const meta = document.createElement("div");
meta.className = "r-meta";
for (const piece of parts.meta ?? []) {
if (piece !== null) {
meta.append(piece);
}
}
body.append(meta);
let sub: HTMLElement | null = null;
if (parts.sub !== undefined && parts.sub !== null) {
sub = document.createElement("div");
sub.className = "r-sub readout";
if (typeof parts.sub === "string") {
sub.textContent = parts.sub;
} else {
sub.append(parts.sub);
}
body.append(sub);
}
item.append(body);
const act = document.createElement("div");
act.className = "r-act";
const tray = document.createElement("div");
tray.className = "r-tray";
const note = document.createElement("p");
note.className = "r-note readout";
note.setAttribute("role", "status");
note.hidden = true;
tray.append(note);
for (const slot of parts.actions ?? []) {
const cell = document.createElement("span");
cell.className = "r-slot";
cell.dataset.width = slot.width;
if (slot.wide) {
cell.dataset.wide = "";
}
if (slot.narrow) {
cell.dataset.narrow = "";
}
if (slot.node) {
cell.append(slot.node);
}
act.append(cell);
}
if (parts.more && parts.more.length > 0) {
const panel = document.createElement("div");
panel.className = "r-morepanel";
panel.hidden = true;
panel.append(...parts.more);
const more = iconControl("more", "more actions");
more.setAttribute("aria-expanded", "false");
more.addEventListener("click", () => {
panel.hidden = !panel.hidden;
more.setAttribute("aria-expanded", String(!panel.hidden));
});
const cell = document.createElement("span");
cell.className = "r-slot";
cell.dataset.width = "icon";
cell.dataset.narrow = "";
cell.append(more);
act.append(cell);
tray.append(panel);
}
let link: HTMLButtonElement | null = null;
if (parts.open) {
const open = parts.open;
link = document.createElement("button");
link.type = "button";
link.className = "r-link";
link.setAttribute("aria-label", open.label);
const target = link;
link.addEventListener("click", () => open.run(target));
item.prepend(link);
if (open.chevron !== false && (parts.actions ?? []).length === 0) {
const cell = document.createElement("span");
cell.className = "r-slot r-chevron";
cell.dataset.width = "icon";
cell.setAttribute("aria-hidden", "true");
cell.append(icon("chevronRight"));
act.append(cell);
}
}
item.append(act, tray);
const say = (text: string | null, tone?: "fault" | "warn") => {
note.hidden = text === null;
note.textContent = text ?? "";
if (tone) {
note.dataset.tone = tone;
} else {
delete note.dataset.tone;
}
};
return { item, body, meta, sub, act, tray, link, say };
}
/** A row-shaped placeholder: same height and columns as the row it stands for. */
export function skeletonRows(count: number, withLead = true): HTMLLIElement[] {
return Array.from({ length: count }, () => {
const item = document.createElement("li");
item.className = "r r-skel";
item.setAttribute("aria-hidden", "true");
if (withLead) {
const lead = document.createElement("div");
lead.className = "r-lead";
lead.append(skeletonBar("skel-lead"));
item.append(lead);
} else {
item.dataset.nolead = "";
}
const body = document.createElement("div");
body.className = "r-body";
body.append(skeletonBar("skel-line"), skeletonBar("skel-line skel-short"));
const act = document.createElement("div");
act.className = "r-act";
item.append(body, act);
return item;
});
}
export function skeletonBar(className: string): HTMLSpanElement {
const bar = document.createElement("span");
bar.className = `skel ${className}`;
return bar;
}
/* ---- tap to explain -------------------------------------------------------- */
/**
* A chip whose meaning would otherwise live in a tooltip. Touch has no hover,
* so the chip becomes a button that writes its sentence into the row's own
* status slot, and a second tap takes it away.
*/
export function explainable(
target: HTMLElement,
text: string,
say: (text: string | null, tone?: "fault" | "warn") => void,
tone?: "fault" | "warn",
) {
target.setAttribute("role", "button");
target.tabIndex = 0;
target.dataset.explain = "";
target.setAttribute("aria-expanded", "false");
const toggle = (event: Event) => {
event.stopPropagation();
event.preventDefault();
const open = target.getAttribute("aria-expanded") !== "true";
target.setAttribute("aria-expanded", String(open));
say(open ? text : null, tone);
};
target.addEventListener("click", toggle);
target.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
toggle(event);
}
});
}