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>
146 lines
5.2 KiB
TypeScript
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;
|
|
}
|