Files
arr/web/src/icons.ts
T
Miguel PalhasandClaude Opus 5.5 ce6d38d044 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>
2026-10-24 17:28:22 +01:00

146 lines
5.2 KiB
TypeScript

// The shared icon-only control block (#230), consumed by the title pages,
// decks and readouts in main.ts, the settings rows (#231) and the subtitle
// chips and provider list (#251). One glyph set, one arm-then-confirm idiom —
// never a second implementation per view. A view that cannot fit the shared
// control grows a variant of it here; it does not keep a private copy.
export const SVG_NS = "http://www.w3.org/2000/svg";
const ICON_PATHS = {
trash: [
"M2.8 4.3h10.4",
"M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2",
"M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5",
"M6.6 7v4.2",
"M9.4 7v4.2",
],
search: ["M7.2 2.6a4.4 4.4 0 1 1 0 8.8 4.4 4.4 0 0 1 0-8.8Z", "M10.4 10.4l3.2 3.2"],
disk: [
"M2.5 9.8l1.6-5c.2-.7.8-1.2 1.5-1.2h4.8c.7 0 1.3.5 1.5 1.2l1.6 5",
"M2.5 9.8h11v2.5c0 .7-.5 1.2-1.2 1.2H3.7c-.7 0-1.2-.5-1.2-1.2z",
"M10.9 11.6h.01",
],
pencil: [
"M11.2 2.4a1.5 1.5 0 0 1 2.1 0l.3.3a1.5 1.5 0 0 1 0 2.1L5.9 12.5l-3.2.8.8-3.2z",
"M10.1 3.5l2.4 2.4",
],
cog: [
"M6.81 3.56 L7.00 1.68 L9.00 1.68 L9.19 3.56 L11.25 4.75 L12.97 3.97 L13.97 5.71 L12.44 6.81 L12.44 9.19 L13.97 10.29 L12.97 12.03 L11.25 11.25 L9.19 12.44 L9.00 14.32 L7.00 14.32 L6.81 12.44 L4.75 11.25 L3.03 12.03 L2.03 10.29 L3.56 9.19 L3.56 6.81 L2.03 5.71 L3.03 3.97 L4.75 4.75Z",
"M8 5.6a2.4 2.4 0 1 0 0 4.8 2.4 2.4 0 0 0 0-4.8Z",
],
warning: ["M8 1.8l6.2 11H1.8z", "M8 5.3v3.4M8 11.2h.01"],
check: ["M3.2 8.4l3 3 6.6-6.8"],
// the caption frame the rest of the world draws for subtitles: a screen
// with two lines of text sitting low in it
captions: [
"M2 3.4h12c.4 0 .7.3.7.7v7.8c0 .4-.3.7-.7.7H2c-.4 0-.7-.3-.7-.7V4.1c0-.4.3-.7.7-.7Z",
"M4.1 8.2h3.1",
"M8.8 8.2h3.1",
"M4.1 10.5h7.8",
],
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: 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);
svg.append(path);
}
return svg;
}
/**
* How much room the control has, and where the armed state explains itself.
*/
export interface ArmedDeleteOptions {
/**
* 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 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,
execute: () => void,
options: ArmedDeleteOptions = {},
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = options.compact ? "control-micro" : "control control-icon";
button.dataset.kind = "delete";
button.setAttribute("aria-label", label);
button.replaceChildren(icon("trash"));
let armed = false;
let resetTimer: number | undefined;
const disarm = () => {
armed = false;
window.clearTimeout(resetTimer);
delete button.dataset.armed;
button.setAttribute("aria-label", label);
button.replaceChildren(icon("trash"));
options.explain?.(null);
};
button.addEventListener("click", () => {
if (armed) {
disarm();
button.disabled = true;
execute();
return;
}
armed = true;
button.dataset.armed = "true";
button.replaceChildren(icon("check"));
button.setAttribute("aria-label", `confirm — ${label}`);
options.explain?.(`tap again to ${label}`);
resetTimer = window.setTimeout(disarm, 4000);
});
button.addEventListener("blur", () => {
if (armed) {
disarm();
}
});
return button;
}