diff --git a/web/src/icons.ts b/web/src/icons.ts new file mode 100644 index 0000000..45786e2 --- /dev/null +++ b/web/src/icons.ts @@ -0,0 +1,80 @@ +// The shared icon-only control block (#230), consumed by the title pages, +// decks and readouts in main.ts and the settings rows (#231). One glyph set, +// one arm-then-confirm idiom — never a second implementation per view. + +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", + ], +} as const; + +/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */ +export function icon(name: keyof typeof ICON_PATHS): SVGSVGElement { + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", "0 0 16 16"); + svg.setAttribute("class", "icon"); + svg.setAttribute("aria-hidden", "true"); + for (const d of ICON_PATHS[name]) { + const path = document.createElementNS(SVG_NS, "path"); + path.setAttribute("d", d); + svg.append(path); + } + return svg; +} + +/** + * The settings arm-then-confirm, icon-only: a trash glyph resting, the + * visible word "confirm" while armed, so an accidental hit never destroys. + */ +export function armedDeleteIcon(label: string, execute: () => void): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "control control-quiet control-icon"; + button.setAttribute("aria-label", label); + button.title = 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")); + }; + button.addEventListener("click", () => { + if (armed) { + disarm(); + button.disabled = true; + execute(); + return; + } + armed = true; + button.dataset.armed = "true"; + button.textContent = "confirm"; + button.setAttribute("aria-label", `confirm — ${label}`); + resetTimer = window.setTimeout(disarm, 4000); + }); + button.addEventListener("blur", () => { + if (armed) { + disarm(); + } + }); + return button; +} diff --git a/web/src/main.ts b/web/src/main.ts index 4def99e..9aabc27 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,4 +1,5 @@ import { type CheckStatus, type Probe, probeHealth } from "./health"; +import { armedDeleteIcon, icon, SVG_NS } from "./icons"; import { fetchLibrary, type LibrarySeries, @@ -1265,37 +1266,8 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) { /* ---- icon-only controls (#230) ----------------------------------------- */ -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", - ], -} as const; - -/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */ -function icon(name: keyof typeof ICON_PATHS): SVGSVGElement { - const svg = document.createElementNS(SVG_NS, "svg"); - svg.setAttribute("viewBox", "0 0 16 16"); - svg.setAttribute("class", "icon"); - svg.setAttribute("aria-hidden", "true"); - for (const d of ICON_PATHS[name]) { - const path = document.createElementNS(SVG_NS, "path"); - path.setAttribute("d", d); - svg.append(path); - } - return svg; -} +// icon() and armedDeleteIcon() live in icons.ts, shared with the settings +// rows (#231); the §9.6 painters below stay here as their only consumer. /** §9.6 provider marks, drawn and shipped inline — never a remote image. */ function providerMark(name: "tmdb" | "tvdb" | "imdb" | "rt"): SVGSVGElement { @@ -1334,47 +1306,6 @@ function providerMark(name: "tmdb" | "tvdb" | "imdb" | "rt"): SVGSVGElement { return svg; } -/** - * The settings arm-then-confirm, icon-only: a trash glyph resting, the - * visible word "confirm" while armed, so an accidental hit never destroys. - */ -function armedDeleteIcon(label: string, execute: () => void): HTMLButtonElement { - const button = document.createElement("button"); - button.type = "button"; - button.className = "control control-quiet control-icon"; - button.setAttribute("aria-label", label); - button.title = 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")); - }; - button.addEventListener("click", () => { - if (armed) { - disarm(); - button.disabled = true; - execute(); - return; - } - armed = true; - button.dataset.armed = "true"; - button.textContent = "confirm"; - button.setAttribute("aria-label", `confirm — ${label}`); - resetTimer = window.setTimeout(disarm, 4000); - }); - button.addEventListener("blur", () => { - if (armed) { - disarm(); - } - }); - return button; -} - /* ---- §9.6 painters shared by both detail pages ------------------------- */ /** The drawn star before a TMDB rating — no glyph standing in for an icon. */