From ce6d38d044ff861e7128ea536fae89486fc3c480 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 24 Oct 2026 17:28:22 +0100 Subject: [PATCH] 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) --- web/scripts/check-tokens.mjs | 34 +- web/src/icons.ts | 62 +- web/src/style.css | 4620 +++++++++++++++++----------------- web/src/ui.ts | 434 ++++ 4 files changed, 2848 insertions(+), 2302 deletions(-) create mode 100644 web/src/ui.ts diff --git a/web/scripts/check-tokens.mjs b/web/scripts/check-tokens.mjs index 63a01bb..2a43104 100644 --- a/web/scripts/check-tokens.mjs +++ b/web/scripts/check-tokens.mjs @@ -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) { diff --git a/web/src/icons.ts b/web/src/icons.ts index 072f1e7..640b9d5 100644 --- a/web/src/icons.ts +++ b/web/src/icons.ts @@ -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", () => { diff --git a/web/src/style.css b/web/src/style.css index f48b157..b702ef7 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1,6 +1,10 @@ -/* arr — master-control board. +/* arr — master-control board, revision 2 (mobile first). Tokens mirror .impeccable/design.json; components consume tokens, never - literals. Committed dark: a control room is lit by its lamps. */ + literals. Committed dark: a control room is lit by its lamps. + The phone is the base layout: rules here are the 390px truth, and the + min-width queries at the end of each block add room as it appears. Rows + answer to their container (`.deck` and every inline panel are containers), + so a row lays out the same in a narrow panel as on a narrow screen. */ @font-face { font-family: "Rajdhani"; @@ -32,7 +36,7 @@ --ink-muted: oklch(0.72 0.015 250); --ink-faint: oklch(0.58 0.015 250); - /* signal lamps */ + /* signal lamps: ok on disk, warn needs you or armed, fault broken */ --signal-ok: oklch(0.8 0.17 150); --signal-warn: oklch(0.82 0.14 80); --signal-fault: oklch(0.68 0.21 25); @@ -51,8 +55,7 @@ --verdict-waived: oklch(0.82 0.14 80); --verdict-rejected: oklch(0.68 0.02 250); - /* score heat scale (issue #111) — one list-relative gradient from lowest - red to highest green; JS picks the stop, colour stays in tokens */ + /* score heat scale (issue #111) — list-relative, lowest red to highest green */ --score-0: oklch(0.68 0.19 25); --score-1: oklch(0.7 0.185 46); --score-2: oklch(0.72 0.175 67); @@ -61,19 +64,15 @@ --score-5: oklch(0.79 0.165 129); --score-6: oklch(0.8 0.17 150); - /* library state ramp (§4.2): green satisfied, amber gap, violet active, - neutral nothing-happening. Satisfaction reuses --signal-ok and a gap - --signal-warn, so the only hue this family still owns is the active one, - which doubles as the downloading colour. */ + /* activity: airing, downloading */ --status-airing: oklch(0.78 0.14 305); /* type */ - --font-label: system-ui, "Segoe UI", sans-serif; + --font-label: system-ui, -apple-system, "Segoe UI", sans-serif; --font-readout: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace; --font-display: "Rajdhani", "Avenir Next Condensed", "Arial Narrow", sans-serif; - /* type scale */ --text-2xs: 0.6875rem; --text-xs: 0.75rem; --text-sm: 0.8125rem; @@ -92,8 +91,39 @@ --space-12: 3rem; --radius: 4px; - --control-h: 2rem; --ease-out: cubic-bezier(0.25, 1, 0.5, 1); + + /* geometry: a 44px touch target, a 24px chip, one row height */ + --control-h: 2.75rem; + --chip-h: 1.5rem; + --row-min: 3.5rem; + --toggle-w: 8.5rem; + --word-w: 5.5rem; + --rail-h: 3.25rem; + --tabbar-h: 3.5rem; + + /* the device's own edges */ + --safe-top: env(safe-area-inset-top, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + --gutter: max(var(--space-4), env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px)); + --tabbar-space: calc(var(--tabbar-h) + var(--safe-bottom)); +} + +/* the tab bar becomes rail links */ +@media (min-width: 40rem) { + :root { + --tabbar-space: 0px; + --word-w: 7.5rem; + } +} + +/* dense only where a mouse and room both exist */ +@media (pointer: fine) and (min-width: 64rem) { + :root { + --control-h: 2.25rem; + --row-min: 3rem; + --toggle-w: 8rem; + } } * { @@ -106,83 +136,61 @@ body { padding: 0; } -/* a scrollbar appearing must not reflow the rail's wrap */ html { - scrollbar-gutter: stable; + scroll-padding-top: var(--rail-h); + -webkit-text-size-adjust: 100%; } body { min-height: 100dvh; display: flex; flex-direction: column; + padding-bottom: var(--tabbar-space); background: var(--ground); color: var(--ink); font-family: var(--font-label); font-size: var(--text-base); line-height: 1.5; + -webkit-tap-highlight-color: transparent; +} + +::selection { + background: oklch(from var(--accent) l c h / 35%); + color: var(--ink); +} + +* { + scrollbar-color: var(--line-strong) transparent; } .readout { font-family: var(--font-readout); font-size: var(--text-sm); + font-variant-numeric: tabular-nums; } .dim { color: var(--ink-muted); } -/* ---- rails ---------------------------------------------------------- */ - -.rail { - padding: var(--space-3) var(--space-6); - border-bottom: 1px solid var(--line); - background: var(--panel); - box-shadow: inset 0 1px var(--highlight); +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } -/* contents share the deck's column; the panel itself stays full-bleed */ -.rail-inner { - position: relative; - display: flex; - align-items: center; - gap: var(--space-6); - width: 100%; - max-width: 68rem; - margin-inline: auto; +/* a heading focused on navigation is a reading position, not a control */ +[tabindex="-1"]:focus { + outline: none; } -.rail-id { - display: flex; - align-items: center; - gap: var(--space-3); +/* hidden wins over every display rule a component sets; the one exception is + a status slot, which keeps its line while empty */ +[hidden]:not(.deck-status) { + /* biome-ignore lint/complexity/noImportantStyles: `hidden` must beat component display rules */ + display: none !important; } -/* §9.7: the wordmark is a link home — quiet at rest, cyan on approach - like every other interactive control */ -.wordmark { - margin: 0; - font-family: var(--font-display); - font-size: var(--text-xl); - font-weight: 700; - letter-spacing: 0.1em; - line-height: 1; - color: var(--ink); - text-decoration: none; -} - -.wordmark:hover, -.wordmark:focus-visible { - color: var(--accent-bright); -} - -.rail-meta { - display: flex; - align-items: center; - gap: var(--space-4); - margin-left: auto; -} - -/* ---- lamps ---------------------------------------------------------- */ +/* ---- lamps ------------------------------------------------------------ */ .lamp { --lamp: var(--signal-idle); @@ -207,11 +215,9 @@ body { --lamp: var(--signal-ok); } -.lamp[data-state="unconfigured"] { - --lamp: var(--signal-warn); -} - -.lamp[data-state="degraded"] { +.lamp[data-state="unconfigured"], +.lamp[data-state="degraded"], +.lamp[data-state="warn"] { --lamp: var(--signal-warn); } @@ -239,7 +245,7 @@ body { } } -/* power-up: lamps flicker on left to right, once, on first verdict */ +/* power-up: lamps flicker on upstream to downstream, once, on first verdict */ @keyframes lamp-strike { 0% { opacity: 0.2; @@ -264,24 +270,2251 @@ body { animation: none; } -@media (prefers-reduced-motion: reduce) { - .lamp[data-state="probing"], - .chain.powered .lamp { - animation: none; +/* ---- type roles --------------------------------------------------------- */ + +/* one role per job: page title, section, group label, row title, readout */ +.page-title { + margin: 0; + min-width: 0; + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 700; + letter-spacing: 0.04em; + line-height: 1.1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.section-title { + margin: 0; + font-family: var(--font-display); + font-size: var(--text-md); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + line-height: 1.2; + color: var(--ink-muted); +} + +/* the engraved micro-label: a group inside a section, never a kicker */ +.deck-label { + margin: 0; + font-size: var(--text-2xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink-muted); +} + +.deck-count { + font-size: var(--text-xs); + color: var(--ink-faint); +} + +/* ---- controls ------------------------------------------------------------- */ + +/* primary: cyan word on a raised plate — one per header or panel */ +.control { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-height: var(--control-h); + padding: 0 var(--space-4); + font-family: var(--font-readout); + font-size: var(--text-xs); + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1; + text-transform: uppercase; + letter-spacing: 0.08em; + white-space: nowrap; + text-decoration: none; + color: var(--accent); + background: var(--panel-raised); + border: 1px solid var(--line-strong); + border-radius: var(--radius); + box-shadow: inset 0 1px var(--highlight); + cursor: pointer; + transition: + border-color 150ms var(--ease-out), + color 150ms var(--ease-out), + background 150ms var(--ease-out); +} + +.control:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent-bright); +} + +.control:active:not(:disabled) { + box-shadow: inset 0 1px var(--lowlight); +} + +.control:disabled { + color: var(--ink-faint); + border-color: var(--line); + cursor: default; +} + +.control[data-busy] { + color: var(--ink-faint); + border-style: dashed; + cursor: progress; +} + +.control[data-done] { + color: var(--signal-ok); + border-color: oklch(from var(--signal-ok) l c h / 55%); +} + +.control[data-failed] { + color: var(--signal-fault); + border-color: oklch(from var(--signal-fault) l c h / 55%); +} + +/* quiet: secondary actions — cancel, add, back, retry. Never a toggle. */ +.control-quiet { + color: var(--ink); + background: transparent; + border-color: var(--line); + box-shadow: none; +} + +/* a word control whose label changes keeps one width */ +.control-fixed { + min-width: var(--word-w); +} + +/* icon: a square glyph, the accessible name carries the meaning */ +.control-icon { + width: var(--control-h); + min-width: var(--control-h); + padding: 0; +} + +.control[aria-expanded="true"] { + border-color: var(--accent); + color: var(--accent-bright); +} + +/* destructive rests red, so delete never reads like the glyph beside it */ +.control[data-kind="delete"]:not(:disabled) { + color: var(--signal-fault); +} + +.control[data-kind="delete"]:hover:not(:disabled), +.control[data-kind="delete"]:focus-visible:not(:disabled) { + border-color: var(--signal-fault); + color: var(--signal-fault); +} + +/* armed: the same square, now an amber check — the next tap fires */ +.control[data-armed="true"]:not(:disabled), +.control[data-armed="true"]:hover:not(:disabled) { + color: var(--ground); + background: var(--signal-warn); + border-color: var(--signal-warn); +} + +.icon { + display: block; + flex: none; + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.icon[data-icon="more"] { + stroke-width: 2.2; +} + +.icon[data-icon="play"] { + fill: currentColor; + stroke-width: 1; +} + +/* provider wordmark badges (§9.6): drawn inline, monochrome */ +.icon-mark { + width: auto; + height: 1.125rem; +} + +.icon-mark rect { + stroke-width: 1.6; +} + +.icon-mark text { + fill: currentColor; + stroke: none; + font-family: var(--font-readout); + font-weight: 700; + letter-spacing: 0.06em; +} + +/* the chip-scale delete (#251): no plate, the armed glyph does the word's job */ +.control-micro { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + padding: 0; + margin-inline-start: var(--space-1); + color: var(--ink-faint); + background: none; + border: 0; + border-radius: 2px; + cursor: pointer; +} + +.control-micro .icon { + width: 0.75rem; + height: 0.75rem; +} + +.control-micro:hover:not(:disabled), +.control-micro:focus-visible { + color: var(--signal-fault); + background: oklch(from var(--signal-fault) l c h / 15%); +} + +.control-micro[data-armed="true"] { + color: var(--ground); + background: var(--signal-warn); +} + +.control-micro:disabled { + opacity: 0.5; +} + +/* disclosure: full control height, the chevron turns, the plate holds its width */ +.disclose { + justify-content: space-between; + min-width: 9rem; +} + +.disclose .icon { + transition: transform 200ms var(--ease-out); +} + +.disclose[aria-expanded="true"] .icon { + transform: rotate(180deg); +} + +/* lamp toggle: a rule that is on or off, lamp plus word */ +.toggle { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-height: var(--control-h); + padding: 0 var(--space-3); + font-family: var(--font-readout); + font-size: var(--text-xs); + font-weight: 600; + line-height: 1; + text-transform: uppercase; + letter-spacing: 0.08em; + white-space: nowrap; + color: var(--ink-muted); + background: transparent; + border: 1px solid var(--line); + border-radius: var(--radius); + cursor: pointer; + transition: + border-color 150ms var(--ease-out), + color 150ms var(--ease-out); +} + +.toggle:hover:not(:disabled) { + border-color: var(--accent); +} + +.toggle:disabled { + cursor: progress; + opacity: 0.6; +} + +.toggle-lamp { + width: 0.5rem; + height: 0.5rem; + flex: none; + border-radius: 50%; + background: var(--lamp-off); + transition: + background 300ms var(--ease-out), + box-shadow 300ms var(--ease-out); +} + +.toggle-state { + min-width: 3ch; + margin-left: auto; + text-align: left; + color: var(--ink-faint); +} + +.toggle[aria-pressed="true"] { + color: var(--ink); + border-color: var(--line-strong); +} + +.toggle[aria-pressed="true"] .toggle-lamp { + background: var(--accent); + box-shadow: 0 0 6px oklch(from var(--accent) l c h / 60%); +} + +.toggle[aria-pressed="true"] .toggle-state { + color: var(--accent); +} + +/* segmented: one choice of a few, the engaged side lit */ +.segmented { + display: inline-flex; + flex: none; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.seg { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-height: var(--control-h); + padding: 0 var(--space-3); + font-family: var(--font-readout); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-muted); + background: transparent; + border: 1px solid var(--line); + cursor: pointer; + transition: + border-color 150ms var(--ease-out), + color 150ms var(--ease-out); +} + +.seg:first-child { + border-radius: var(--radius) 0 0 var(--radius); +} + +.seg:last-child { + border-radius: 0 var(--radius) var(--radius) 0; +} + +.seg + .seg { + border-left-width: 0; +} + +.seg-icon { + width: var(--control-h); + padding: 0; +} + +.seg:hover { + color: var(--accent-bright); +} + +/* the lit edge is drawn inside, so the joined borders never double */ +.seg[aria-pressed="true"] { + color: var(--accent-bright); + background: var(--panel-raised); + box-shadow: inset 0 0 0 1px var(--accent); +} + +.filter-count { + color: var(--ink-faint); + font-variant-numeric: tabular-nums; +} + +.seg[aria-pressed="true"] .filter-count { + color: var(--ink-muted); +} + +/* ---- chips ---------------------------------------------------------------- */ + +/* a label, not a target: shorter than a control, one line always */ +.chip { + display: inline-flex; + flex: none; + align-items: center; + gap: var(--space-1); + height: var(--chip-h); + padding: 0 var(--space-2); + background: var(--panel-raised); + border: 1px solid var(--line); + border-radius: 3px; + font-family: var(--font-readout); + font-size: var(--text-2xs); + font-variant-numeric: tabular-nums; + line-height: 1; + white-space: nowrap; + color: var(--ink-muted); +} + +.chip .icon { + width: 0.75rem; + height: 0.75rem; + stroke-width: 1.7; +} + +/* a chip that explains itself on tap (§9.6 tooltips have no touch) */ +.chip[data-explain], +.chip[role="button"], +button.chip { + font: inherit; + font-family: var(--font-readout); + font-size: var(--text-2xs); + cursor: pointer; + transition: + color 150ms var(--ease-out), + border-color 150ms var(--ease-out); +} + +.chip[data-explain]:hover, +.chip[data-explain][aria-expanded="true"] { + border-color: currentColor; +} + +/* release verdicts */ +.chip[data-verdict="eligible"] { + color: var(--verdict-eligible); + border-color: oklch(from var(--verdict-eligible) l c h / 45%); +} + +/* the dashed border is the visible mark of a relaxed rule */ +.chip[data-verdict="waived"] { + color: var(--verdict-waived); + border-style: dashed; + border-color: oklch(from var(--verdict-waived) l c h / 55%); +} + +.chip[data-verdict="rejected"] { + color: var(--verdict-rejected); +} + +/* a subtitle wanted and not yet on disk is the system's errand, not yours */ +.chip[data-verdict="wanted"] { + color: var(--ink); + border-color: var(--line-strong); +} + +/* #227: blacklisted at import — the policy's own floor (amber, act on it) or + the release itself broken (fault red) */ +.chip[data-blacklist="policy"] { + color: var(--signal-warn); + border-color: oklch(from var(--signal-warn) l c h / 55%); +} + +.chip[data-blacklist="release"] { + color: var(--signal-fault); + border-color: oklch(from var(--signal-fault) l c h / 55%); +} + +/* media state (§4.2): green on disk, violet downloading; wanted-and-missing + is ink — the system is on it, nothing needs you yet */ +.chip[data-movie-state="available"] { + color: var(--signal-ok); + border-color: oklch(from var(--signal-ok) l c h / 45%); +} + +.chip[data-movie-state="downloading"] { + color: var(--status-airing); + border-color: oklch(from var(--status-airing) l c h / 45%); +} + +.chip[data-movie-state="missing"][data-wanted="true"] { + color: var(--ink); + border-color: var(--line-strong); +} + +/* §9.8: live download state, fixed width so 9% → 10% never moves a neighbour */ +.dl-chip { + min-width: 7ch; + justify-content: center; +} + +.dl-chip[data-dl-phase="downloading"] { + color: var(--status-airing); + border-color: oklch(from var(--status-airing) l c h / 45%); +} + +.dl-chip[data-dl-phase="stalled"] { + color: var(--signal-warn); + border-color: oklch(from var(--signal-warn) l c h / 55%); +} + +.dl-chip[data-dl-phase="errored"] { + color: var(--signal-fault); + border-color: oklch(from var(--signal-fault) l c h / 55%); +} + +.dl-chip[data-dl-phase="seeding"] { + color: var(--signal-ok); + border-color: oklch(from var(--signal-ok) l c h / 45%); +} + +/* library status (§4.2): violet while live, green once complete; incomplete + and waiting are neutral — amber is kept for what needs you */ +.chip[data-status="airing"] { + color: var(--status-airing); + border-color: oklch(from var(--status-airing) l c h / 45%); +} + +.chip[data-status="incomplete"] { + color: var(--ink); + border-color: var(--line-strong); +} + +.chip[data-status="complete"], +.chip[data-status="ended"], +.chip[data-counts="complete"] { + color: var(--signal-ok); + border-color: oklch(from var(--signal-ok) l c h / 45%); +} + +.chip[data-counts="partial"] { + color: var(--ink); +} + +/* issue 122: gone upstream while its file remained — a conflict, amber dashed */ +.chip[data-flag="vanished"] { + color: var(--signal-warn); + border-style: dashed; + border-color: oklch(from var(--signal-warn) l c h / 55%); +} + +/* a machine translation reads as machine-made (§15) — the relaxed-case dash */ +.chip[data-mt="true"] { + border-style: dashed; +} + +.chip[data-tone="fault"] { + color: var(--signal-fault); + border-color: oklch(from var(--signal-fault) l c h / 55%); +} + +/* a chip whose whole label is its glyph */ +.chip-glyph { + min-width: var(--chip-h); + justify-content: center; + padding: 0 var(--space-1); +} + +.star { + width: 0.625rem; + height: 0.625rem; + flex: none; + fill: var(--ink); +} + +/* ---- the rail and the tab bar ------------------------------------------- */ + +.rail { + position: sticky; + top: 0; + z-index: 20; + padding-top: var(--safe-top); + border-bottom: 1px solid var(--line); + background: var(--panel); + box-shadow: inset 0 1px var(--highlight); +} + +.rail-inner { + display: flex; + align-items: center; + gap: var(--space-3); + width: 100%; + max-width: 68rem; + height: var(--rail-h); + margin-inline: auto; + padding-inline: var(--gutter); +} + +.rail-id { + display: flex; + flex: none; + align-items: center; + gap: var(--space-3); + margin-right: auto; +} + +/* §9.7: the wordmark is a link home */ +.wordmark { + font-family: var(--font-display); + font-size: var(--text-xl); + font-weight: 700; + letter-spacing: 0.1em; + line-height: 1; + color: var(--ink); + text-decoration: none; +} + +.wordmark:hover, +.wordmark:focus-visible { + color: var(--accent-bright); +} + +.rail-nav { + display: none; +} + +.nav-link { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-height: var(--control-h); + padding: 0 var(--space-3); + font-family: var(--font-readout); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-muted); + text-decoration: none; + border-bottom: 2px solid transparent; +} + +.nav-link:hover { + color: var(--accent-bright); +} + +.nav-link[aria-current="page"] { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* attention: amber count on the queues destination, wherever it sits */ +.nav-count { + font-size: var(--text-xs); + color: var(--signal-warn); +} + +.rail-search { + position: relative; + display: none; + flex: 1; + align-items: center; + min-width: 0; +} + +.rail-search-glyph { + position: absolute; + left: var(--space-3); + color: var(--ink-faint); + pointer-events: none; +} + +.search-input { + width: 100%; + min-height: var(--control-h); + padding: 0 var(--space-8) 0 var(--space-8); + font-size: var(--text-base); + color: var(--ink); + background: var(--ground); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: inset 0 1px var(--lowlight); + caret-color: var(--accent); + transition: border-color 150ms var(--ease-out); +} + +.search-input::placeholder { + color: var(--ink-faint); + opacity: 1; +} + +.search-input:hover { + border-color: var(--line-strong); +} + +.search-input:focus { + border-color: var(--accent); + outline: none; +} + +.search-hint { + position: absolute; + right: var(--space-3); + display: none; + padding: 0 var(--space-1); + font-size: var(--text-2xs); + color: var(--ink-faint); + border: 1px solid var(--line); + border-radius: var(--radius); + pointer-events: none; +} + +.rail-search-close { + flex: none; + margin-left: var(--space-2); +} + +.rail-version { + display: none; + font-size: var(--text-xs); +} + +.tabbar { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 20; + display: grid; + grid-template-columns: repeat(4, 1fr); + height: var(--tabbar-space); + padding-bottom: var(--safe-bottom); + background: var(--panel); + border-top: 1px solid var(--line); +} + +.tab { + position: relative; + display: grid; + place-items: center; + align-content: center; + gap: var(--space-1); + font-size: var(--text-2xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--ink-faint); + text-decoration: none; +} + +.tab[aria-current="page"] { + color: var(--accent); +} + +.tab[aria-current="page"]::before { + content: ""; + position: absolute; + top: 0; + left: 30%; + right: 30%; + height: 2px; + background: var(--accent); +} + +.tab-badge { + position: absolute; + top: var(--space-1); + left: 55%; + min-width: 1.1rem; + height: 1.1rem; + padding: 0 var(--space-1); + border-radius: 999px; + font-size: var(--text-2xs); + font-weight: 700; + line-height: 1.1rem; + text-align: center; + color: var(--ground); + background: var(--signal-warn); +} + +/* phone search mode: the field takes the rail, the identity steps aside */ +.rail[data-searching] .rail-id, +.rail[data-searching] .rail-search-open { + display: none; +} + +.rail[data-searching] .rail-search { + display: flex; +} + +@media (min-width: 40rem) { + .tabbar, + .rail-search-close { + display: none; + } + + /* biome-ignore lint/style/noDescendingSpecificity: the phone search-mode rule never applies from 40rem */ + .rail-search-open { + display: none; + } + + .rail-id { + margin-right: 0; + } + + .rail-nav { + display: flex; + align-items: center; + gap: var(--space-1); + } + + .rail-search, + .rail[data-searching] .rail-search { + display: flex; + max-width: 26rem; + margin-left: auto; + } + + .rail[data-searching] .rail-id { + display: flex; + } + + .search-input { + font-size: var(--text-sm); } } -/* ---- signal chain (§9.7: a settings section, no route of its own) ----- */ +@media (min-width: 64rem) { + .search-hint, + .rail-version { + display: inline; + } +} +/* ---- decks, pages, sections --------------------------------------------- */ + +.deck { + container-type: inline-size; + width: 100%; + max-width: 68rem; + margin: 0 auto; + padding: var(--space-4) 0 var(--space-12); + flex: 1; +} + +/* the gutter belongs to the content, so a hero can still reach both edges */ +.deck > * { + margin-inline: var(--gutter); +} + +.deck > section.hero { + margin-inline: 0; +} + +.deck.detail { + padding-top: 0; +} + +.page-bar .page-title { + text-transform: capitalize; +} + +/* a page's line: title, count, a one-line status slot, the page's controls */ +.page-bar { + display: flex; + align-items: center; + gap: var(--space-3); + min-height: var(--control-h); + margin-bottom: var(--space-4); +} + +.page-bar .deck-count { + flex: none; +} + +/* always laid out, so a message never pushes the page down */ +.head-status { + flex: 1; + min-width: 0; + margin: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.head-status[data-tone="fault"] { + color: var(--signal-fault); +} + +/* a section's status: one reserved line, wraps only when the sentence must */ +.deck-status { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2) var(--space-3); + min-height: 1.5rem; + margin-bottom: var(--space-3); + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.deck-status[hidden] { + visibility: hidden; +} + +.deck-status[data-tone="fault"], +.head-status[data-tone="fault"] { + color: var(--signal-fault); +} + +.deck-status .lamp, +.head-status .lamp { + display: inline-block; + margin-right: var(--space-2); +} + +.deck-group { + margin-bottom: var(--space-8); +} + +.deck-head { + display: flex; + align-items: center; + gap: var(--space-3); + min-height: var(--control-h); + padding-bottom: var(--space-1); + border-bottom: 1px solid var(--line); +} + +.deck-head .head-action, +.deck-head .disclose { + margin-left: auto; +} + +/* nested heads inside a section are group labels, one step quieter */ +.deck-group .deck-group .deck-head { + min-height: var(--chip-h); + padding-top: var(--space-3); +} + +.deck-rows { + margin: 0; + padding: 0; + list-style: none; +} + +/* one plain sentence, the long form behind a tap */ +.group-note { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-1) var(--space-2); + padding: var(--space-2) 0 0; + font-size: var(--text-sm); + color: var(--ink-muted); +} + +.group-note p { + margin: 0; +} + +.group-note .group-note-more { + flex-basis: 100%; + max-width: 65ch; + font-size: var(--text-xs); +} + +.group-note .control-icon { + width: var(--chip-h); + min-width: var(--chip-h); + min-height: var(--chip-h); + border-color: transparent; + background: none; + box-shadow: none; + color: var(--ink-faint); +} + +/* ---- the row ------------------------------------------------------------------ */ + +/* lead | body | actions, then the tray full width. The body is minmax(0, 1fr): + whatever is too long is clipped inside it and never pushes the actions. */ +.r { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: + "lead body act" + "tray tray tray"; + align-items: center; + column-gap: var(--space-3); + min-height: var(--row-min); + padding: var(--space-2) 0; + border-bottom: 1px solid oklch(from var(--line) l c h / 60%); +} + +.r[data-nolead] { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "body act" + "tray tray"; +} + +.r-lead { + grid-area: lead; + display: flex; + align-items: center; + justify-content: center; +} + +.r-body { + grid-area: body; + display: grid; + align-content: center; + gap: var(--space-1); + min-width: 0; +} + +.r-title { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: var(--text-sm); + font-weight: 600; + line-height: 1.3; + color: var(--ink); +} + +.r-title .yr { + margin-left: var(--space-2); + font-family: var(--font-readout); + font-size: var(--text-xs); + font-weight: 400; + color: var(--ink-faint); +} + +/* one line of chips; what does not fit fades out at the edge rather than + being cut through a word */ +.r-meta { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: var(--space-1); + min-width: 0; + overflow: hidden; + mask-image: linear-gradient(to right, var(--ink) calc(100% - 1.5rem), transparent); +} + +.r-meta:empty { + display: none; +} + +.r-meta [data-pri="2"] { + display: none; +} + +.r-sub { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: var(--text-2xs); + line-height: 1.3; + color: var(--ink-faint); +} + +/* the full string, on a tap (§9.3: the release name is evidence, on expand) */ +.r-sub[data-full] { + white-space: normal; + overflow-wrap: anywhere; +} + +.r-sub[data-tone="fault"] { + color: var(--signal-fault); +} + +.r-act { + grid-area: act; + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-1); +} + +/* every slot has its width whether or not it holds a control, so a row of a + kind keeps one action column */ +.r-slot { + display: flex; + flex: none; + align-items: center; + justify-content: center; +} + +.r-slot[data-width="icon"] { + width: var(--control-h); +} + +.r-slot[data-width="toggle"] { + width: var(--toggle-w); +} + +.r-slot[data-width="toggle"] > .toggle { + width: 100%; +} + +.r-slot[data-width="word"] { + min-width: var(--word-w); +} + +.r-slot[data-width="word"] > .control { + width: 100%; +} + +.r-slot[data-wide] { + display: none; +} + +.r-chevron { + color: var(--ink-faint); +} + +.r-chevron .icon { + width: 0.875rem; + height: 0.875rem; +} + +/* the row as a link: a stretched button under everything; real controls sit + above it, everything else lets the tap through */ +.r-link { + position: absolute; + inset: 0; + z-index: 0; + padding: 0; + background: transparent; + border: 0; + cursor: pointer; + transition: background 150ms var(--ease-out); +} + +.r-link:hover { + background: var(--highlight); +} + +.r-link:focus-visible { + outline-offset: -2px; +} + +.r:has(> .r-link) > :not(.r-link) { + position: relative; + z-index: 1; + pointer-events: none; +} + +.r:has(> .r-link) button:not(.r-link), +.r:has(> .r-link) a, +.r:has(> .r-link) select, +.r:has(> .r-link) input, +.r:has(> .r-link) [role="button"] { + pointer-events: auto; +} + +.r-tray { + grid-area: tray; + display: grid; + gap: var(--space-2); + min-width: 0; + padding-top: var(--space-2); +} + +.r-tray:not(:has(> :not([hidden]))) { + display: none; +} + +.r:has(> .r-link) > .r-tray { + pointer-events: auto; +} + +.r-note { + margin: 0; + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.r-note[data-tone="fault"] { + color: var(--signal-fault); +} + +.r-note[data-tone="warn"] { + color: var(--signal-warn); +} + +/* the ⋯ tray: the row's other actions, labelled, full width */ +.r-morepanel { + display: grid; + gap: var(--space-2); +} + +.r-morepanel .control, +.r-morepanel .toggle { + width: 100%; + justify-content: flex-start; +} + +.r-morepanel .control .icon { + margin-right: var(--space-1); +} + +.r-empty { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: var(--row-min); + padding: var(--space-2) 0; + font-size: var(--text-xs); + color: var(--ink-muted); + border-bottom: 1px solid oklch(from var(--line) l c h / 60%); +} + +.r-empty[data-tone="fault"] { + color: var(--signal-fault); +} + +/* row leads */ +.row-poster, +.row-poster-blank { + display: block; + width: 2.25rem; + aspect-ratio: 2 / 3; + flex: none; + object-fit: cover; + background: var(--panel-raised); + border: 1px solid var(--line); + border-radius: 3px; +} + +.lead-tag { + width: var(--control-h); + text-align: center; + font-size: var(--text-xs); + font-weight: 600; + color: var(--ink-faint); +} + +.lead-lamp { + display: flex; + width: var(--space-4); + justify-content: center; +} + +/* score: the release's lead, on the list-relative heat scale (issue 111) */ +.score { + display: inline-grid; + place-items: center; + width: var(--control-h); + height: var(--control-h); + font-family: var(--font-readout); + font-size: var(--text-sm); + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--ink-muted); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.score[data-score="0"] { + color: var(--score-0); + border-color: oklch(from var(--score-0) l c h / 50%); +} +.score[data-score="1"] { + color: var(--score-1); + border-color: oklch(from var(--score-1) l c h / 50%); +} +.score[data-score="2"] { + color: var(--score-2); + border-color: oklch(from var(--score-2) l c h / 50%); +} +.score[data-score="3"] { + color: var(--score-3); + border-color: oklch(from var(--score-3) l c h / 50%); +} +.score[data-score="4"] { + color: var(--score-4); + border-color: oklch(from var(--score-4) l c h / 50%); +} +.score[data-score="5"] { + color: var(--score-5); + border-color: oklch(from var(--score-5) l c h / 50%); +} +.score[data-score="6"] { + color: var(--score-6); + border-color: oklch(from var(--score-6) l c h / 50%); +} + +/* season disclosure: the lead of a season row */ +.r-lead .disclose-lead { + border-color: var(--line); + background: transparent; + box-shadow: none; + color: var(--ink-muted); +} + +.disclose-lead .icon { + transition: transform 200ms var(--ease-out); +} + +.disclose-lead[aria-expanded="true"] .icon { + transform: rotate(90deg); +} + +.r[data-row="season"] > .r-body > .r-title { + font-family: var(--font-display); + font-size: var(--text-base); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.episodes { + margin: 0; + padding: 0; + list-style: none; + background: oklch(from var(--panel) l c h / 50%); +} + +/* release cells: the §9.3 fixed columns, from 46rem; the chips carry them below */ +.r-cells, +.colhead { + display: none; +} + +.colhead-cells, +.r-cells { + grid-template-columns: 4rem 4.5rem 4.5rem 6rem 7rem 3.5rem; + gap: var(--space-4); +} + +.r-cells > span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-family: var(--font-readout); + font-size: var(--text-xs); + font-variant-numeric: tabular-nums; + color: var(--ink-muted); +} + +.r-cells > .num, +.colhead-cells > .num { + text-align: right; +} + +.r-cells > .unclaimed { + color: var(--ink-faint); +} + +.colhead { + grid-template-columns: var(--control-h) minmax(0, 1fr) var(--word-w); + column-gap: var(--space-3); + padding: var(--space-2) 0 var(--space-1); + border-bottom: 1px solid oklch(from var(--line) l c h / 60%); +} + +.colhead-cells { + grid-column: 2; + display: grid; +} + +.colhead-score, +.colhead-cells > span { + font-family: var(--font-readout); + font-size: var(--text-2xs); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-faint); +} + +/* skeleton: the row's own geometry, no shimmer (Dead-Air) */ +.r-skel { + pointer-events: none; +} + +.skel { + display: block; + background: var(--panel-raised); + border-radius: 3px; +} + +.skel-lead { + width: 2.25rem; + height: 3.375rem; +} + +.skel-line { + width: 70%; + height: 0.75rem; +} + +.skel-short { + width: 40%; +} + +@container (min-width: 40rem) { + .r-meta [data-pri="2"] { + display: inline-flex; + } + + .r-slot[data-wide] { + display: flex; + } + + .r-slot[data-narrow], + .r-morepanel { + display: none; + } + + .r-title { + font-size: var(--text-base); + } +} + +@container (min-width: 46rem) { + .r[data-row="release"] .r-meta [data-cell] { + display: none; + } + + .r[data-row="release"] .r-cells, + .colhead { + display: grid; + } +} + +/* ---- poster grid (§9.6, issue 151) -------------------------------------- */ + +.deck-rows.grid-rows { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + gap: var(--space-3); + padding-top: var(--space-3); +} + +.card { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: 0; + font: inherit; + text-align: left; + color: inherit; + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; + cursor: pointer; + transition: border-color 150ms var(--ease-out); +} + +.card:hover { + border-color: var(--accent); +} + +.card-art { + display: block; + aspect-ratio: 2 / 3; + background: var(--panel-raised); +} + +.card-poster { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* same ratio as a poster, carrying the title: the grid never has a hole */ +.card-blank { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + padding: var(--space-3); +} + +.card-blank-title { + font-family: var(--font-display); + font-size: var(--text-base); + font-weight: 600; + text-align: center; + color: var(--ink-faint); +} + +.card-body { + display: grid; + gap: var(--space-1); + padding: var(--space-2); +} + +.card-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: var(--text-sm); + font-weight: 600; + color: var(--ink); +} + +.card-chips { + display: flex; + gap: var(--space-1); + min-height: var(--chip-h); + overflow: hidden; +} + +.card-skel .card-art { + background: var(--panel-raised); +} + +/* ---- search rows and the add flow (§9.2) ---------------------------------- */ + +.add-panel { + display: grid; + gap: var(--space-3); + padding: var(--space-3); + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: inset 0 1px var(--highlight); +} + +.add-roots { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: var(--space-2); +} + +.root-option { + display: grid; + gap: var(--space-1); + min-height: var(--control-h); + padding: var(--space-2) var(--space-3); + font: inherit; + text-align: left; + color: var(--ink); + background: var(--panel-raised); + border: 1px solid var(--line); + border-radius: var(--radius); + cursor: pointer; + transition: border-color 150ms var(--ease-out); +} + +.root-option:hover:not(:disabled) { + border-color: var(--line-strong); +} + +.root-option[aria-pressed="true"] { + border-color: var(--accent); +} + +.root-option:disabled { + cursor: default; + opacity: 0.6; +} + +.root-audience { + font-family: var(--font-display); + font-size: var(--text-md); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + line-height: 1; +} + +.root-policy, +.root-path { + font-size: var(--text-xs); +} + +.add-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); +} + +.add-autotrack { + display: grid; + justify-items: start; + gap: var(--space-1); +} + +.add-note { + margin: 0; + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.add-note[data-tone="fault"] { + color: var(--signal-fault); +} + +.add-note[data-tone="ok"] { + color: var(--signal-ok); +} + +/* ---- title detail (§9.6) ---------------------------------------------------- */ + +/* the hero: back and title, then poster and what the title IS, over the + backdrop sunk into the console charcoal by a scrim */ +.hero { + position: relative; + overflow: hidden; + margin-bottom: var(--space-3); + padding: var(--space-2) var(--gutter) var(--space-4); +} + +.hero::before { + content: ""; + position: absolute; + inset: 0; + background-image: var(--backdrop); + background-size: cover; + background-position: center 20%; + opacity: 0; + transition: opacity 400ms var(--ease-out); +} + +.hero.has-backdrop::before { + opacity: 1; +} + +.hero::after { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient( + to top, + oklch(from var(--ground) l c h / 96%) 0%, + oklch(from var(--ground) l c h / 78%) 50%, + oklch(from var(--ground) l c h / 55%) 100% + ), + oklch(from var(--ground) l c h / 30%); +} + +.hero > * { + position: relative; + z-index: 1; +} + +.page-head { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: var(--control-h); + margin-bottom: var(--space-3); +} + +/* a detail page without a hero still gives its head room under the rail */ +.deck.detail > .page-head { + padding-top: var(--space-2); +} + +.page-head .page-title { + flex: 0 1 auto; +} + +.page-head > .control-icon { + flex: none; + border-color: transparent; + background: transparent; + box-shadow: none; +} + +.page-id { + display: grid; + min-width: 0; +} + +.page-year, +.page-sub { + flex: none; + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.page-sub { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* the title is known before the page is: a bar the title's own size */ +.page-title:empty::before, +[data-loading] .page-title::before { + content: ""; + display: inline-block; + width: 12rem; + height: 1em; + vertical-align: middle; + background: var(--panel-raised); + border-radius: 3px; +} + +.hero-body { + display: grid; + grid-template-columns: 6rem minmax(0, 1fr); + gap: var(--space-4); + align-items: start; +} + +/* the poster's box is there before the poster is */ +.hero-poster { + aspect-ratio: 2 / 3; + background: var(--panel-raised); + border: 1px solid var(--line-strong); + border-radius: var(--radius); + overflow: hidden; +} + +.movie-poster { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 300ms var(--ease-out); +} + +.movie-poster[data-loaded] { + opacity: 1; +} + +.hero-info { + display: grid; + gap: var(--space-2); + align-content: start; + min-width: 0; +} + +.hero-chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + min-height: var(--chip-h); +} + +/* each line keeps its height while empty, so the hero never grows in steps */ +.hero-rating, +.hero-meta { + display: flex; + align-items: center; + gap: var(--space-1); + min-height: 1.25rem; + margin: 0; + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.hero-rating { + color: var(--ink); +} + +.hero-tagline { + display: none; + margin: 0; + font-family: var(--font-display); + font-size: var(--text-md); + font-weight: 600; + color: var(--ink-muted); +} + +/* three lines, reserved, a tap opens the rest */ +.hero-overview { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + min-height: 4.5em; + max-width: 65ch; + margin: 0; + overflow: hidden; + font-size: var(--text-sm); + color: var(--ink); + cursor: pointer; +} + +.hero-overview[data-full] { + -webkit-line-clamp: unset; +} + +.hero-note { + margin-top: var(--space-2); +} + +/* one action bar: the primary word, the links as glyphs, the rest behind ⋯ */ +.actionbar { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: var(--control-h); + margin-bottom: var(--space-3); +} + +.actionbar-links { + display: flex; + flex: 1; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + +.actionbar > .control-icon:last-child { + margin-left: auto; +} + +.trailer-link .control-word { + display: none; +} + +.more-panel { + display: grid; + gap: var(--space-3); + margin-bottom: var(--space-4); + padding: var(--space-3); + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: inset 0 1px var(--highlight); +} + +.more-links { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.more-links:empty { + display: none; +} + +.more-toggles { + display: grid; + gap: var(--space-2); +} + +.more-toggles .toggle, +.more-panel > .control { + width: 100%; + justify-content: flex-start; +} + +.more-field { + display: grid; +} + +.control-note { + margin: 0; + font-size: var(--text-xs); + color: var(--ink-muted); +} + +@container (min-width: 40rem) { + .hero { + padding-top: var(--space-4); + } + + .hero-body { + grid-template-columns: 9rem minmax(0, 1fr); + gap: var(--space-6); + } + + .hero-tagline { + display: block; + } + + .page-title { + font-size: var(--text-xl); + } + + .trailer-link .control-word { + display: inline; + } + + .trailer-link { + width: auto; + padding: 0 var(--space-4); + } + + .more-panel { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: end; + } + + .more-toggles { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-column: 1 / -1; + } + + .more-panel > .remove-panel, + .more-panel > .more-links, + .more-panel > .control-note { + grid-column: 1 / -1; + } +} + +@container (min-width: 64rem) { + .hero-body { + grid-template-columns: 10.5rem minmax(0, 1fr); + } +} + +/* ---- removal confirmation (§7.4, issue 104) ------------------------------- */ + +.remove-body { + display: grid; + gap: var(--space-2); + padding: var(--space-3); + background: var(--panel-raised); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.remove-body[data-armed="true"] { + border-color: oklch(from var(--signal-warn) l c h / 55%); +} + +.remove-evidence, +.remove-path, +.remove-note { + margin: 0; + font-size: var(--text-xs); +} + +/* the folder is evidence: it wraps rather than truncating */ +.remove-path { + color: var(--ink-muted); + overflow-wrap: anywhere; + white-space: pre-line; +} + +.remove-note { + color: var(--ink-muted); +} + +.remove-note[data-tone="warn"] { + color: var(--signal-warn); +} + +.remove-note[data-tone="fault"] { + color: var(--signal-fault); +} + +.remove-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.remove-body[data-armed="true"] .remove-actions .control:first-child:not(:disabled) { + color: var(--signal-warn); + border-color: oklch(from var(--signal-warn) l c h / 55%); +} + +/* ---- release deck (§9.3) ------------------------------------------------- */ + +.buckets { + container-type: inline-size; +} + +.bucket-more { + padding-top: var(--space-3); +} + +/* #227: a pack abandoned at import, above the candidates — a quiet panel */ +.deck-notice { + margin-bottom: var(--space-6); + padding: var(--space-3) var(--space-4) var(--space-4); + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.deck-notice[data-blacklist="policy"] { + background: oklch(from var(--signal-warn) l c h / 7%); + border-color: oklch(from var(--signal-warn) l c h / 40%); +} + +.deck-notice[data-blacklist="release"] { + background: oklch(from var(--signal-fault) l c h / 7%); + border-color: oklch(from var(--signal-fault) l c h / 40%); +} + +.deck-notice .deck-label { + margin-bottom: var(--space-2); +} + +.deck-notice[data-blacklist="policy"] .deck-label { + color: var(--signal-warn); +} + +.deck-notice[data-blacklist="release"] .deck-label { + color: var(--signal-fault); +} + +.notice-release { + margin: 0 0 var(--space-2); + overflow-wrap: anywhere; + font-size: var(--text-xs); + color: var(--ink); +} + +.notice-line { + margin: 0; + max-width: 68ch; + font-size: var(--text-sm); + color: var(--ink-muted); +} + +.notice-line + .notice-line { + margin-top: var(--space-2); +} + +.r-why { + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.r-why[data-blacklist="release"] { + color: oklch(from var(--signal-fault) 0.78 0.1 h); +} + +/* ---- subtitles (§15, §9.6) -------------------------------------------- */ + +/* the one subtitle chip a file's row carries: it opens the panel */ +.subs-chip .icon { + opacity: 0.7; +} + +.subs-chip:hover, +.subs-chip:focus-visible, +.subs-chip[aria-expanded="true"] { + border-color: currentColor; +} + +/* the languages read as one string, as wide as the wanted set can need */ +.subs-chip-langs { + display: inline-flex; + align-items: baseline; + flex: none; + overflow: hidden; + white-space: nowrap; +} + +/* the check has its slot whether or not it is drawn */ +.subs-chip-mark { + display: inline-flex; + flex: none; + justify-content: center; + width: 0.75rem; +} + +.subs-chip-langs [data-mt="true"] { + text-decoration: underline dotted; + text-underline-offset: 2px; +} + +.subs-panel { + container-type: inline-size; + min-width: 0; + padding: var(--space-3); + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.subs-panel .deck-group { + margin-bottom: var(--space-4); +} + +.subs-panel .deck-group:last-child { + margin-bottom: 0; +} + +.subs-present { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-1); + padding-top: var(--space-3); +} + +.subs-present > p { + margin: 0; + font-size: var(--text-xs); +} + +.subs-controls { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--space-2) var(--space-3); + padding-top: var(--space-3); +} + +.subs-langs { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); +} + +.subs-field { + flex: 1 1 12rem; + min-width: 9rem; + max-width: 18rem; +} + +.subs-field-narrow { + flex: 0 1 9rem; + max-width: 11rem; +} + +.subs-note { + margin: var(--space-2) 0 0; + font-size: var(--text-xs); + color: var(--ink-muted); +} + +.subs-note[data-tone="fault"] { + color: var(--signal-fault); +} + +.subs-buckets { + margin-top: var(--space-3); +} + +.subs-buckets .deck-group { + margin-bottom: var(--space-3); +} + +/* ---- settings (issue #116) -------------------------------------------- */ + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: var(--space-3); + align-items: end; +} + +.field { + display: grid; + gap: var(--space-1); +} + +.field-label { + font-size: var(--text-2xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink-muted); +} + +.form-input { + width: 100%; + min-height: var(--control-h); + padding: 0 var(--space-3); + font-family: var(--font-readout); + font-size: var(--text-base); + color: var(--ink); + background: var(--ground); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: inset 0 1px var(--lowlight); + caret-color: var(--accent); + transition: border-color 150ms var(--ease-out); +} + +.form-input:hover { + border-color: var(--line-strong); +} + +.form-input:focus { + border-color: var(--accent); + outline: none; +} + +.form-input:disabled { + color: var(--ink-faint); + border-color: var(--line); +} + +/* a select draws its own chevron: appearance is gone, so the shape returns */ +select.form-input { + appearance: none; + padding-right: var(--space-8); + background-image: + linear-gradient(45deg, transparent 50%, var(--ink-muted) 50%), + linear-gradient(135deg, var(--ink-muted) 50%, transparent 50%); + background-position: + calc(100% - 1.1rem) 50%, + calc(100% - 0.8rem) 50%; + background-size: 0.3rem 0.3rem; + background-repeat: no-repeat; +} + +.form-input[data-invalid] { + border-color: var(--signal-fault); +} + +.band-line { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.band-line .field-label { + width: 3.5rem; +} + +.band-num { + width: 4.5rem; +} + +.subtitle-panel .field-label { + display: block; + margin: var(--space-4) 0 var(--space-2); +} + +.provider-list { + margin: 0 0 var(--space-3); + padding: 0; + list-style: none; +} + +.provider-toggle { + width: 1.25rem; + height: 1.25rem; + margin: 0; + accent-color: var(--accent); +} + +.provider-budget { + width: 5.5rem; +} + +@media (min-width: 40rem) { + .form-input { + font-size: var(--text-sm); + } +} + +/* ---- signal chain (§9.7: a settings section) ------------------------------- */ + +.chain-wrap { + container-type: inline-size; + padding-top: var(--space-4); +} + +/* the phone stacks the chain into one junctioned trace */ .chain { width: 100%; display: grid; - grid-template-columns: 1fr 2.5rem 1.2fr 2.5rem 1fr; + grid-template-columns: 1fr; grid-template-areas: - "tmdb t1 master out sink" - "prow t2 master out sink"; + "tmdb" + "prow" + "t1" + "master" + "out" + "sink"; align-items: center; - row-gap: var(--space-6); + row-gap: var(--space-3); } .area-tmdb { @@ -297,7 +2530,7 @@ body { } .area-t2 { - grid-area: t2; + display: none; } .area-master { @@ -312,20 +2545,16 @@ body { grid-area: sink; } -/* ---- modules --------------------------------------------------------- */ - -/* The subtitle lane (#200): one lamp per upstream actually in use, rendered - by the health poll under the main chain. Same module vocabulary, no bus — - these feed the reconcile loop, not each other. */ .chain-lane { - margin-top: var(--space-6); + margin-top: var(--space-4); display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); - gap: var(--space-4); + gap: var(--space-3); align-items: stretch; } .module { + min-width: 0; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); @@ -336,7 +2565,6 @@ body { .module-master { background: var(--panel-raised); border-color: var(--line-strong); - padding: var(--space-6) var(--space-4) var(--space-4); } .module-head { @@ -389,17 +2617,25 @@ body { .module-detail { margin: var(--space-1) 0 0; + min-height: 1.2em; font-size: var(--text-xs); color: var(--ink-muted); - min-height: 1.2em; } -/* ---- bus traces ------------------------------------------------------ */ +.intake .module-detail { + overflow-wrap: anywhere; +} .bus { display: grid; align-content: center; justify-items: center; + height: 2rem; +} + +.bus svg { + transform: rotate(90deg); + width: 2rem; } .bus-trace { @@ -431,2211 +2667,43 @@ body { --trace: var(--signal-fault); } -/* ---- controls -------------------------------------------------------- */ - -.control { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: var(--control-h); - padding: 0 var(--space-4); - font-family: var(--font-readout); - font-size: var(--text-xs); - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--accent); - background: var(--panel-raised); - border: 1px solid var(--line-strong); - border-radius: var(--radius); - box-shadow: inset 0 1px var(--highlight); - cursor: pointer; - transition: - border-color 150ms var(--ease-out), - color 150ms var(--ease-out); -} - -.control:hover { - border-color: var(--accent); - color: var(--accent-bright); -} - -.control:active { - box-shadow: inset 0 1px var(--lowlight); -} - -.control:disabled { - color: var(--ink-faint); - border-color: var(--line); - cursor: default; -} - -/* the engaged view control stays lit while its surface is up */ -.control[aria-pressed="true"] { - border-color: var(--accent); - color: var(--accent-bright); -} - -:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* ---- icon-only controls (#230) ---------------------------------------- */ - -/* One block, shared: title pages, decks and readouts here, the settings - rows via #231. The glyph carries the action, the aria-label carries the - meaning, and the geometry stays on the --control-h rail with the chips. */ - -.icon { - display: block; - flex: none; - height: 0.875rem; - width: auto; - fill: none; - stroke: currentColor; - stroke-width: 1.5; - stroke-linecap: round; - stroke-linejoin: round; -} - -.control-icon { - min-width: var(--control-h); - padding: 0 var(--space-2); -} - -/* provider wordmark badges (§9.6): the mark is drawn and shipped inline, - monochrome on currentColor — never a remote image */ -.icon-mark { - height: 1.125rem; -} - -.icon-mark rect { - stroke-width: 1.6; -} - -/* the mark's letters live in viewBox units, so the size rides the SVG - scale as an attribute; only voice and weight come from the tokens */ -.icon-mark text { - fill: currentColor; - stroke: none; - font-family: var(--font-readout); - font-weight: 700; - letter-spacing: 0.06em; -} - -/* a readout chip's glyph sits beside its figure, sized to the readout */ -.chip .icon { - height: 0.75rem; - stroke-width: 1.7; -} - -/* rows widen every .control's padding; an icon control stays near-square */ -.season-line .control-icon, -.ep-actions .control-icon, -.movie-controls .control-icon { - padding: 0 var(--space-2); -} - -/* ---- unified search (§9.2) ------------------------------------------- */ - -.rail-search { - position: absolute; - left: 50%; - width: min(30rem, calc(100% - 20rem)); - max-width: 30rem; - transform: translateX(-50%); - display: flex; - align-items: center; -} - -.search-input { - width: 100%; - min-height: var(--control-h); - padding: 0 var(--space-8) 0 var(--space-3); - color: var(--ink); - background: var(--ground); - border: 1px solid var(--line); - border-radius: var(--radius); - box-shadow: inset 0 1px var(--lowlight); - caret-color: var(--accent); - transition: border-color 150ms var(--ease-out); -} - -.search-input::placeholder { - color: var(--ink-faint); - opacity: 1; -} - -.search-input:hover { - border-color: var(--line-strong); -} - -.search-input:focus { - border-color: var(--accent); - outline: none; -} - -.search-hint { - position: absolute; - right: var(--space-3); - padding: 0 var(--space-1); - font-size: var(--text-2xs); - color: var(--ink-faint); - border: 1px solid var(--line); - border-radius: var(--radius); - pointer-events: none; -} - -/* ---- search deck ------------------------------------------------------ */ - -.deck { - width: 100%; - max-width: 68rem; - margin: 0 auto; - padding: var(--space-8) var(--space-6) var(--space-12); - flex: 1; -} - -/* movie and series: the hero banner meets the rail — no top padding (#229) */ -.deck#movie, -.deck#series { - padding-top: 0; -} - -.deck-status { - margin: 0 0 var(--space-4); - color: var(--ink-muted); -} - -.deck-status[data-tone="fault"] { - color: var(--signal-fault); -} - -/* a status that carries its own way out keeps the control on the sentence's - own left edge, so the explanation reads first and the action follows it - (#182). Long enough copy wraps the control to its own line, which is the - reading order anyway. */ -.deck-status[data-action] { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-3); -} - -/* the kind filter narrows both groups at once, so it sits above them rather - than inside either head. Its counts are the whole point — they say what - the filter is holding back, so an empty group never reads as no match. */ -.deck-filter { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - margin: 0 0 var(--space-6); -} - -.control-filter { - gap: var(--space-2); -} - -.filter-count { - color: var(--ink-faint); - font-variant-numeric: tabular-nums; -} - -.control-filter[aria-pressed="true"] .filter-count { - color: var(--ink-muted); -} - -/* a sweep in flight borrows the rail's probing-lamp idiom, inline */ -.deck-status .lamp { - display: inline-block; - margin-right: var(--space-2); - vertical-align: baseline; -} - -/* #227: a pack abandoned at import, above the candidates. A quiet panel, not - an alert: the failure is history the season owes an explanation for, and - the operator opened this deck to grab something, not to be shouted at. The - tone follows the same reading as the row chips — amber for their own policy - floor, red for a release that failed on its own. */ -.deck-notice { - margin: 0 0 var(--space-6); - padding: var(--space-3) var(--space-4) var(--space-4); - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--radius); -} - -.deck-notice[data-blacklist="policy"] { - background: oklch(from var(--signal-warn) l c h / 7%); - border-color: oklch(from var(--signal-warn) l c h / 40%); -} - -.deck-notice[data-blacklist="release"] { - background: oklch(from var(--signal-fault) l c h / 7%); - border-color: oklch(from var(--signal-fault) l c h / 40%); -} - -/* the release name is evidence, in the readout face like every other one */ -.notice-release { - margin: 0 0 var(--space-2); - overflow-wrap: anywhere; - font-size: var(--text-xs); - color: var(--ink); -} - -.notice-line { - margin: 0; - max-width: 68ch; - font-size: var(--text-sm); - color: var(--ink-muted); -} - -.notice-line + .notice-line { - margin-top: var(--space-2); -} - -.deck-group { - margin: 0 0 var(--space-8); -} - -.deck-head { - display: flex; - align-items: baseline; - gap: var(--space-3); - padding: 0 var(--space-1) var(--space-2); - border-bottom: 1px solid var(--line); -} - -/* the engraved micro-label is the heading itself, not a kicker */ -.deck-label { - margin: 0; - font-size: var(--text-2xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.12em; - color: var(--ink-muted); -} - -.deck-count { - font-size: var(--text-xs); - color: var(--ink-faint); -} - -.deck-notice .deck-label { - margin-bottom: var(--space-2); -} - -.deck-notice[data-blacklist="policy"] .deck-label { - color: var(--signal-warn); -} - -.deck-notice[data-blacklist="release"] .deck-label { - color: var(--signal-fault); -} - -.deck-rows { - margin: 0; - padding: 0; - list-style: none; -} - -.row { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: var(--space-1) var(--space-3); - width: 100%; - padding: var(--space-2) var(--space-1); - border-bottom: 1px solid oklch(from var(--line) l c h / 45%); - text-align: left; -} - -.row-id { - display: inline-flex; - align-items: baseline; - gap: var(--space-2); - min-width: 0; -} - -/* titles are names: the display face, per the Readout Rule in design.json */ -.row-title { - font-family: var(--font-display); - font-size: var(--text-md); - font-weight: 600; - letter-spacing: 0.02em; - color: var(--ink); -} - -.row-year { - font-size: var(--text-xs); -} - -.row-chips { - display: inline-flex; - align-items: center; - gap: var(--space-1); - margin-left: auto; -} - -.chip { - display: inline-flex; - align-items: center; - gap: var(--space-1); - min-height: var(--control-h); - padding: 0 var(--space-2); - background: var(--panel-raised); - border: 1px solid var(--line); - border-radius: var(--radius); - font-family: var(--font-readout); - font-size: var(--text-xs); - color: var(--ink-muted); -} - -.chip[data-verdict="eligible"] { - color: var(--verdict-eligible); - border-color: oklch(from var(--verdict-eligible) l c h / 45%); -} - -/* the dashed border is the visible mark of a relaxed rule */ -.chip[data-verdict="waived"] { - color: var(--verdict-waived); - border-style: dashed; - border-color: oklch(from var(--verdict-waived) l c h / 55%); -} - -/* rejected is the policy working, never fault red */ -.chip[data-verdict="rejected"] { - color: var(--verdict-rejected); -} - -/* #227: a blacklisted release was grabbed, downloaded in full and thrown out - at import, and the two ways that happens want opposite decisions. The - policy turning a file down is the operator's own floor — amber, the hue - this app already gives a gap they can act on. The release itself failing is - the one case in the deck that is genuinely broken, so it takes fault red; - the "never fault red" rule above is about a rejection, and this is not one. - A row whose blacklist entry is gone claims nothing and stays slate. */ -.chip[data-blacklist="policy"] { - color: var(--signal-warn); - border-color: oklch(from var(--signal-warn) l c h / 55%); -} - -.chip[data-blacklist="release"] { - color: var(--signal-fault); - border-color: oklch(from var(--signal-fault) l c h / 55%); -} - -/* a reason written as a sentence is longer than any rule name, and the chips - around it are fixed-width columns that must not be pushed off the line - (§9.3: no horizontal scroll at any viewport). This one chip wraps instead. */ -.chip[data-blacklist], -.chip[data-flag="import-failed"] { - min-width: 0; - max-width: 100%; - padding-top: var(--space-1); - padding-bottom: var(--space-1); - overflow-wrap: anywhere; -} - -/* media state ramp (§4.2): green on disk, violet downloading, amber wanted - and still missing. Unwanted-missing and parked are nothing-happening and - stay neutral; `parked` exists so a vanished grab never reads as a gap. */ -.chip[data-movie-state="available"] { - color: var(--signal-ok); - border-color: oklch(from var(--signal-ok) l c h / 45%); -} - -.chip[data-movie-state="downloading"] { - color: var(--status-airing); - border-color: oklch(from var(--status-airing) l c h / 45%); -} - -.chip[data-movie-state="missing"][data-wanted="true"] { - color: var(--signal-warn); - border-color: oklch(from var(--signal-warn) l c h / 55%); -} - -/* §9.8: live download state on the row that owns the item. Fixed width so a - percentage ticking from 9% to 10%, or a phase word replacing it, never - reflows the chips beside it — sized to the longest phase word since the - set of values is fixed, unlike the subtitle chip's configured language set. */ -.dl-chip { - min-width: 7ch; - justify-content: center; - text-align: center; -} - -.dl-chip[data-dl-phase="downloading"] { - color: var(--status-airing); - border-color: oklch(from var(--status-airing) l c h / 45%); -} - -.dl-chip[data-dl-phase="stalled"] { - color: var(--signal-warn); - border-color: oklch(from var(--signal-warn) l c h / 55%); -} - -.dl-chip[data-dl-phase="errored"] { - color: var(--signal-fault); - border-color: oklch(from var(--signal-fault) l c h / 55%); -} - -.dl-chip[data-dl-phase="seeding"] { - color: var(--signal-ok); - border-color: oklch(from var(--signal-ok) l c h / 45%); -} - -/* library status ramp (§4.2): violet while activity is live, green once - everything wanted is on disk, amber for a grabbable gap; waiting is - between seasons with nothing to do, so it stays neutral */ -.chip[data-status="airing"] { - color: var(--status-airing); - border-color: oklch(from var(--status-airing) l c h / 45%); -} - -.chip[data-status="incomplete"] { - color: var(--signal-warn); - border-color: oklch(from var(--signal-warn) l c h / 55%); -} - -.chip[data-status="complete"], -.chip[data-status="ended"] { - color: var(--signal-ok); - border-color: oklch(from var(--signal-ok) l c h / 45%); -} - -/* season/series counts chip (§4.2): the same ramp read off N/M itself */ -.chip[data-counts="complete"] { - color: var(--signal-ok); - border-color: oklch(from var(--signal-ok) l c h / 45%); -} - -.chip[data-counts="partial"] { - color: var(--signal-warn); - border-color: oklch(from var(--signal-warn) l c h / 55%); -} - -/* a TMDB row is the affordance: the whole row opens the add flow */ -.row-tmdb { - font: inherit; - background: none; - border: 0; - border-bottom: 1px solid oklch(from var(--line) l c h / 45%); - cursor: pointer; - transition: background 150ms var(--ease-out); -} - -.row-tmdb:hover:not(:disabled):not([aria-disabled="true"]), -.row-tmdb:focus-visible:not([aria-disabled="true"]) { - background: var(--panel); -} - -.row-tmdb:disabled, -.row-tmdb[aria-disabled="true"] { - cursor: default; -} - -.row-add { - font-size: var(--text-xs); - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--accent); -} - -.row-overview { - flex-basis: 100%; - overflow: hidden; - font-size: var(--text-xs); - color: var(--ink-faint); - white-space: nowrap; - text-overflow: ellipsis; -} - -/* an episode hit's title sits beside its SxxEyy chip: a name, not a readout */ -.row-sub { - font-size: var(--text-sm); - color: var(--ink-muted); -} - -/* ---- rich search rows (#148): poster, rating chip, trailer chip -------- */ - -.row-rich { - align-items: flex-start; -} - -.row-main { - display: flex; - flex: 1; - align-items: baseline; - flex-wrap: wrap; - gap: var(--space-1) var(--space-3); - min-width: 0; -} - -/* w92 is the right order of magnitude for a row; the blank is the same - box, so the column edge holds while results stream in */ -.row-poster, -.row-poster-blank { - width: 2.75rem; - aspect-ratio: 2 / 3; - flex-shrink: 0; - align-self: center; - border: 1px solid var(--line); - border-radius: var(--radius); -} - -.row-poster { - display: block; - background: var(--panel-raised); - object-fit: cover; -} - -.chip[role="button"] { - cursor: pointer; - transition: - color 150ms var(--ease-out), - border-color 150ms var(--ease-out); -} - -.chip[role="button"]:hover, -.chip[role="button"]:focus-visible { - color: var(--accent); - border-color: oklch(from var(--accent) l c h / 45%); -} - -.row-trailer[data-state="pending"] { - color: var(--ink-faint); -} - -/* no video on TMDB is an ordinary outcome: quiet and dashed, not fault red */ -.row-trailer[data-state="none"] { - border-style: dashed; - color: var(--ink-faint); - cursor: default; -} - -.row-trailer[data-state="blocked"] { - color: var(--signal-warn); - cursor: default; -} - -/* ---- add flow (inline, root and policy pre-filled) -------------------- */ - -.add-panel { - margin: 0 0 var(--space-2); - padding: var(--space-3) var(--space-3) var(--space-3); - background: var(--panel); - border: 1px solid var(--line); - border-top: 0; - border-radius: 0 0 var(--radius) var(--radius); - box-shadow: inset 0 1px var(--highlight); -} - -.add-roots { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); - gap: var(--space-2); -} - -.root-option { - display: grid; - gap: var(--space-1); - padding: var(--space-2) var(--space-3); - font: inherit; - text-align: left; - color: var(--ink); - background: var(--panel-raised); - border: 1px solid var(--line); - border-radius: var(--radius); - box-shadow: inset 0 1px var(--highlight); - cursor: pointer; - transition: border-color 150ms var(--ease-out); -} - -.root-option:hover { - border-color: var(--line-strong); -} - -.root-option[aria-pressed="true"] { - border-color: var(--accent); -} - -.root-audience { - font-family: var(--font-display); - font-size: var(--text-md); - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.12em; - line-height: 1; -} - -.root-policy, -.root-path { - font-size: var(--text-xs); -} - -.add-actions { - display: flex; - align-items: center; - gap: var(--space-3); - margin: var(--space-3) 0 0; -} - -/* the one series-level add-time question (§4.1): the choice and what it does */ -.add-autotrack { - display: grid; - justify-content: start; - gap: var(--space-1); - margin: var(--space-3) 0 0; -} - -.add-note { - margin: 0; - font-size: var(--text-xs); - color: var(--ink-muted); -} - -.add-note[data-tone="fault"] { - color: var(--signal-fault); -} - -.add-done { - margin: 0; - color: var(--verdict-eligible); -} - -/* ---- release deck (§9.3) ---------------------------------------------- */ - -.releases-head { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-2) var(--space-3); - margin: 0 0 var(--space-6); -} - -.releases-id { - flex: 1; - min-width: 0; - display: flex; - align-items: baseline; - gap: var(--space-2); -} - -.releases-title { - margin: 0; - font-family: var(--font-display); - font-size: var(--text-lg); - font-weight: 700; - letter-spacing: 0.04em; - line-height: 1.1; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.releases-year { - font-size: var(--text-xs); -} - -/* ---- removal confirmation (§7.4, issue 104) --------------------------- */ - -/* remove is not the deck's errand: it reads as a quiet control until the - operator reaches for it, and never competes with the sweep */ -.control-quiet { - color: var(--ink-muted); - border-color: var(--line); -} - -/* `:not(:disabled)` because this rule sits after `.control:disabled` at the - same specificity: without it a disabled quiet control lights accent-bright - under the cursor and invites a click that does nothing (#255). */ -.control-quiet:hover:not(:disabled), -.control-quiet[aria-expanded="true"] { - color: var(--accent-bright); - border-color: var(--accent); -} - -.remove-panel { - margin: 0 0 var(--space-6); -} - -.remove-body { - display: grid; - gap: var(--space-2); - padding: var(--space-3); - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--radius); - box-shadow: inset 0 1px var(--highlight); -} - -/* armed is a state, not a toggle: it is amber whenever files are on disk to - delete */ -.remove-body[data-armed="true"] { - border-color: oklch(from var(--signal-warn) l c h / 55%); -} - -.remove-evidence, -.remove-path, -.remove-note { - margin: 0; - font-size: var(--text-xs); -} - -/* the folder is evidence: it wraps rather than truncating, because a path - the operator cannot read whole is not a confirmation */ -.remove-path { - color: var(--ink-muted); - overflow-wrap: anywhere; - white-space: pre-line; -} - -.remove-note { - color: var(--ink-muted); -} - -.remove-note[data-tone="warn"] { - color: var(--signal-warn); -} - -.remove-note[data-tone="fault"] { - color: var(--signal-fault); -} - -.remove-actions { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - margin: var(--space-1) 0 0; -} - -.remove-body[data-armed="true"] .remove-actions .control:first-child { - color: var(--signal-warn); - border-color: oklch(from var(--signal-warn) l c h / 55%); -} - -.remove-body[data-armed="true"] .remove-actions .control:first-child:hover:not(:disabled) { - border-color: var(--signal-warn); -} - -/* a destructive control rests red, so delete never reads like the search - glyph beside it; arming hands over to the shared amber treatment below */ -.control[data-kind="delete"]:not(:disabled) { - color: var(--signal-fault); - border-color: oklch(from var(--signal-fault) l c h / 40%); -} - -.control[data-kind="delete"]:hover:not(:disabled), -.control[data-kind="delete"]:focus-visible:not(:disabled) { - border-color: var(--signal-fault); -} - -/* the settings arm-then-confirm, shared with season and episode rows: an - armed control turns caution amber until it disarms or fires */ -.control[data-armed="true"] { - color: var(--signal-warn); - border-color: oklch(from var(--signal-warn) l c h / 55%); -} - -.control[data-armed="true"]:hover:not(:disabled) { - border-color: var(--signal-warn); -} - -/* The chip-scale variant of the same control (#251). A chip has room for - neither the plate nor the word "confirm", so the plate comes off and the - armed glyph does the word's job — the two clicks, the amber and the - 4-second disarm are the shared block's, unchanged. */ -.control-micro { - min-width: 0; - min-height: 0; - width: 1rem; - height: 1rem; - padding: 0; - color: var(--ink-faint); - background: none; - border: 0; - box-shadow: none; - transition: - color 150ms var(--ease-out), - background 150ms var(--ease-out); -} - -.control-micro:hover:not(:disabled), -.control-micro:focus-visible { - color: var(--signal-fault); - background: oklch(from var(--signal-fault) l c h / 15%); -} - -/* armed wins over hover: the pointer is already on it when it arms */ -.control-micro[data-armed="true"], -.control-micro[data-armed="true"]:hover:not(:disabled) { - color: var(--signal-warn); - background: oklch(from var(--signal-warn) l c h / 15%); -} - -.control-micro:disabled { - opacity: 0.5; -} - -.bucket-head { - align-items: center; -} - -.bucket-toggle { - margin-left: auto; - min-height: var(--control-h); - padding: 0 var(--space-2); - font-size: var(--text-xs); - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--accent); - background: none; - border: 0; - cursor: pointer; - transition: color 150ms var(--ease-out); -} - -.bucket-toggle:hover { - color: var(--accent-bright); -} - -/* fixed-width mono columns: chips and header labels share rem widths, so - the pseudo-table survives font-size differences and flex wrapping alike */ -.cw { - display: inline-block; - flex: none; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - /* single nowrap line: line-height equal to the box height centres it, - and keeps ellipsis + text-align working, which flex would break */ - line-height: var(--control-h); -} - -.cw-score { - width: 4.5rem; - text-align: right; -} - -.cw-res { - width: 4rem; -} - -.cw-src { - width: 4.5rem; -} - -.cw-hdr { - width: 6rem; -} - -.cw-aud { - width: 7.5rem; -} - -.cw-size { - width: 4.5rem; - text-align: right; -} - -.cw-seed { - width: 3.5rem; - text-align: right; -} - -/* §9.3: headers never wrap — the narrow-deck block below sheds columns - instead */ -.colhead { - display: flex; - gap: var(--space-1); - padding: var(--space-2) var(--space-1) 0; -} - -.colhead .cw { - padding: 0 var(--space-2); - border: 1px solid transparent; - font-family: var(--font-readout); - font-size: var(--text-2xs); - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--ink-faint); -} - -.rel { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-1) var(--space-2); - padding: var(--space-2) var(--space-1); - border-bottom: 1px solid oklch(from var(--line) l c h / 45%); -} - -/* the line is plain content now — grab is its own control */ -.rel-line { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-1); -} - -/* §9.3: the release name is secondary — evidence, not the primary key */ -.rel-name { - flex-basis: 100%; - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - font-size: var(--text-xs); - color: var(--ink-faint); -} - -.rel-none { - padding: var(--space-2) var(--space-1); - font-size: var(--text-xs); - border-bottom: 1px solid oklch(from var(--line) l c h / 45%); -} - -/* #227: what the blacklisting means for the next move, on its own line under - the chips. Only a blacklisted row carries it, so the dense list stays dense - everywhere else. */ -.rel-why { - flex-basis: 100%; - min-width: 0; - font-size: var(--text-xs); - color: var(--ink-muted); -} - -.rel-why[data-blacklist="release"] { - color: oklch(from var(--signal-fault) 0.78 0.1 h); -} - -.rel-note { - font-size: var(--text-xs); - color: var(--ink-muted); -} - -.rel-note[data-tone="fault"] { - color: var(--signal-fault); -} - -/* score heat: data-score is the list-relative stop, 0 lowest … 6 highest */ -.rel-line .cw-score[data-score="0"] { - color: var(--score-0); - border-color: oklch(from var(--score-0) l c h / 45%); -} -.rel-line .cw-score[data-score="1"] { - color: var(--score-1); - border-color: oklch(from var(--score-1) l c h / 45%); -} -.rel-line .cw-score[data-score="2"] { - color: var(--score-2); - border-color: oklch(from var(--score-2) l c h / 45%); -} -.rel-line .cw-score[data-score="3"] { - color: var(--score-3); - border-color: oklch(from var(--score-3) l c h / 45%); -} -.rel-line .cw-score[data-score="4"] { - color: var(--score-4); - border-color: oklch(from var(--score-4) l c h / 45%); -} -.rel-line .cw-score[data-score="5"] { - color: var(--score-5); - border-color: oklch(from var(--score-5) l c h / 45%); -} -.rel-line .cw-score[data-score="6"] { - color: var(--score-6); - border-color: oklch(from var(--score-6) l c h / 45%); -} - -/* ---- library view (§4.2) ---------------------------------------------- */ - -/* the library's master line: total readout left, the one §4.2 toggle right */ -.library-bar { - margin: 0 0 var(--space-6); -} - -/* the toggle's own padding would inset its text past the chip column's - right rag; with the shape toggle right of it, SATISFIED sits flush - against its neighbour instead of the deck edge */ -.library-bar .bucket-toggle { - padding: 0; - margin-left: var(--space-2); -} - -/* ---- library poster grid (§9.6, issue 151) ------------------------------ */ - -/* the shape toggle: a joined pair, engaged side lit like any view control */ -.view-toggle { - margin-left: auto; - display: inline-flex; -} - -.view-btn { - min-height: var(--control-h); - padding: 0 var(--space-2); - font-family: var(--font-readout); - font-size: var(--text-xs); - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--ink-faint); - background: none; - border: 1px solid var(--line); - cursor: pointer; - transition: - border-color 150ms var(--ease-out), - color 150ms var(--ease-out); -} - -.view-btn + .view-btn { - border-left-width: 0; -} - -.view-btn:hover { - color: var(--accent-bright); -} - -.view-btn[aria-pressed="true"] { - color: var(--accent-bright); - border-color: var(--accent); -} - -/* Wider tiles give artwork its due without forcing a desktop-style grid onto - a phone; the slightly wider crop avoids a wall of overly tall posters. */ -.deck-rows.grid-rows { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); - gap: var(--space-4); -} - -.card { - display: flex; - flex-direction: column; - align-items: stretch; - width: 100%; - height: 100%; - padding: 0; - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--radius); - overflow: hidden; - text-align: left; - cursor: pointer; - transition: - border-color 150ms var(--ease-out), - transform 150ms var(--ease-out); -} - -.card:hover { - border-color: var(--accent); -} - -.card:active { - transform: translateY(1px); -} - -/* the poster is framed evidence, like every other image on a panel */ -.card-art { - display: block; - aspect-ratio: 18 / 25; -} - -.card-poster { - display: block; - width: 100%; - height: 100%; - object-fit: cover; - background: var(--panel-raised); -} - -/* same ratio as a poster, carrying the title: the grid never has a hole */ -.card-blank { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - padding: var(--space-3); - background: var(--panel-raised); - box-shadow: inset 0 1px var(--highlight); -} - -.card-blank-title { - font-family: var(--font-display); - font-size: var(--text-base); - font-weight: 600; - letter-spacing: 0.02em; - text-align: center; - color: var(--ink-faint); -} - -.card-body { - display: flex; - flex-direction: column; - gap: var(--space-1); - padding: var(--space-2); -} - -.card-name { - /* two clamped lines: a long title stays readable where a single - ellipsised line at tile width would not be */ - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; - font-family: var(--font-display); - font-size: var(--text-sm); - font-weight: 600; - letter-spacing: 0.02em; - color: var(--ink); -} - -.card-chips { - display: flex; - flex-wrap: wrap; - gap: var(--space-1); -} - -/* ---- series detail (§4.1 + §4.2, issue 129) --------------------------- */ - -/* one season: a collapsible group with its rule, counts and actions */ -.season { - border-bottom: 1px solid oklch(from var(--line) l c h / 45%); -} - -/* the line never wraps: the head shrinks and wraps inside itself, so the - controls keep the right edge on every viewport */ -.season-line { - display: flex; - align-items: center; - flex-wrap: nowrap; - gap: var(--space-2) var(--space-3); - padding: var(--space-2) var(--space-1); - cursor: pointer; - transition: background 150ms var(--ease-out); -} - -.season-head { - display: flex; - align-items: center; - flex-wrap: wrap; - flex: 1; - min-width: 0; - gap: var(--space-2) var(--space-3); -} - -.season-actions { - display: flex; - align-items: center; - flex: none; - gap: var(--space-3); -} - -/* a row under the pointer is visibly the row the click will land on — - the same lift a search result row gets */ -.season-line:hover, -.episode:hover { - background: var(--panel); -} - -/* the disclosure is a drawn chevron — no glyph standing in for an icon */ -.season-disclose { - flex: none; - width: 1.75rem; - height: 1.75rem; - display: inline-flex; - align-items: center; - justify-content: center; - background: none; - border: 1px solid var(--line); - border-radius: var(--radius); - cursor: pointer; - transition: border-color 150ms var(--ease-out); -} - -.season-disclose:hover, -.season-disclose:focus-visible { - border-color: var(--accent); -} - -.season-disclose::before { - content: ""; - width: 0.4rem; - height: 0.4rem; - border-right: 2px solid var(--ink-muted); - border-bottom: 2px solid var(--ink-muted); - transform: rotate(-45deg) translate(-5%, -5%); - transition: transform 200ms var(--ease-out); -} - -.season-disclose[aria-expanded="true"]::before { - transform: rotate(45deg) translate(-5%, -5%); -} - -.season-name { - flex: none; - font-family: var(--font-display); - font-size: var(--text-base); - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.12em; - color: var(--ink); -} - -/* one name, two lengths — the wide one reads as prose, the narrow one is the - SxxEyy tag used everywhere else. display:none keeps the unused one out of - the accessibility tree, so the row is never announced twice. */ -.season-name-short { - display: none; -} - -/* the tracked toggle reads as the rule it is; pressed = on */ -.season-line .control, -.ep-actions .control { - padding: 0 var(--space-3); -} - -.episodes { - margin: 0; - padding: 0 0 var(--space-2) var(--space-8); - list-style: none; -} - -/* the row is a strip; the disclosed subtitles panel is a block under it. - Two boxes, not one wrapping line — a row told not to wrap has nowhere to - put the panel, which is how it came to sit beside the episode on a phone */ -.episode { - padding: var(--space-2) var(--space-1); - border-bottom: 1px solid oklch(from var(--line) l c h / 45%); - transition: background 150ms var(--ease-out); -} - -.episode-line { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-1) var(--space-2); -} - -.ep-tag { - flex: none; - width: 2.75rem; - color: var(--ink-faint); -} - -.ep-title { - min-width: 0; - font-size: var(--text-sm); - color: var(--ink); -} - -.ep-air { - flex: none; - width: 6.5rem; - color: var(--ink-faint); -} - -.ep-chips { - margin-left: auto; -} - -.ep-actions { - display: flex; - align-items: center; - gap: var(--space-2); - margin-left: var(--space-3); -} - -.ep-unaired { - font-size: var(--text-xs); -} - -/* an invisible spacer that IS an icon control: rows without a delete reserve - its slot so search sits in the same column on every row */ -.slot-icon { - visibility: hidden; - pointer-events: none; -} - -/* #227: the season the operator's own report was about read `0/10` beside - this chip's absence. It sits next to the counts because that number is what - looked like nothing had ever been tried. */ -.chip[data-flag="import-failed"] { - cursor: help; -} - -/* issue 122: gone upstream while its file remained — a conflict, amber dashed */ -.chip[data-flag="vanished"] { - color: var(--signal-warn); - border-style: dashed; - border-color: oklch(from var(--signal-warn) l c h / 55%); -} - -/* ---- subtitle status (§15, §9.6, issue #201) --------------------------- */ - -/* the one subtitle chip a file's row carries (§9.6 keeps a file to one - line): it names the wanted languages and opens the panel, so it is a - button wearing the chip's geometry rather than a second control idiom */ -.subs-chip { - font-weight: 500; - cursor: pointer; - transition: - color 150ms var(--ease-out), - border-color 150ms var(--ease-out); -} - -.subs-chip .icon { - height: 0.75rem; - opacity: 0.7; -} - -/* the comma between two codes belongs to the string, so the languages sit - in their own gapless run inside the chip. Its width is set from the - wanted set (see `summaryWidth`) rather than from the row's own content: - the same fixed-column reasoning as `.cw`, so a chip that says `en` is as - wide as one that says `pt-PT,en`. */ -.subs-chip-langs { - display: inline-flex; - align-items: baseline; - flex: none; - gap: 0; - overflow: hidden; - white-space: nowrap; -} - -/* the satisfied check has a slot whether or not it is drawn — a language - landing must not shove the chips after it sideways */ -.subs-chip-mark { - display: inline-flex; - flex: none; - justify-content: center; - width: 0.75rem; -} - -/* §15: a machine translation reads as machine-made at a glance. The chip has - no room for the word, so the language carries a dotted mark and the title - spells the origin out in full. */ -.subs-chip-langs [data-mt="true"] { - text-decoration: underline dotted; - text-underline-offset: 2px; -} - -/* amber is §4.2's own hue for wanted and not yet on disk; a satisfied chip - takes the eligible green through the shared [data-verdict] rule */ -.chip[data-verdict="wanted"] { - color: var(--signal-warn); - border-color: oklch(from var(--signal-warn) l c h / 55%); -} - -.subs-chip:hover, -.subs-chip:focus-visible, -.subs-chip[aria-expanded="true"] { - border-color: currentcolor; -} - -/* the panel's own list of what is on the file: every track, including the - languages nobody asked for and the forced ones the row's chip elides */ -.subs-present { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-1); - padding: var(--space-3) var(--space-1) 0; -} - -/* a chip whose whole label is its glyph: the words live in the title and the - aria-label, so the box shrinks to the icon rather than to a truncated - sentence */ -.chip-glyph { - min-width: var(--control-h); - justify-content: center; - padding: 0 var(--space-2); -} - -/* a machine translation reads as machine-made at a glance (§15) — the same - dashed mark a waived release already carries for "not the clean case" */ -.chip[data-mt="true"] { - border-style: dashed; -} - -/* a delete that failed reads the same fault red the rest of the app uses, - right on the chip it happened to — no separate note element for it */ -.chip[data-tone="fault"] { - color: var(--signal-fault); - border-color: oklch(from var(--signal-fault) l c h / 55%); -} - -/* the delete affordance nested in a present-subtitle chip (issue #223): the - shared .control-micro, spaced off the chip's own label */ -.chip .control-micro { - margin-inline-start: var(--space-1); -} - -/* ---- manual subtitle deck (§9.3, §15, issue #203) ---------------------- */ - -/* the panel opens under the file's own row, sharing the release deck's - layout language rather than inventing a second one */ -.subs-panel { - flex-basis: 100%; - /* a flex item will not shrink below its min-content unless told to; the - deck's fixed columns would otherwise push the row into horizontal - scroll, which §9.3 forbids at every viewport */ - min-width: 0; - margin-top: var(--space-2); - padding: var(--space-3) var(--space-3) var(--space-1); - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--radius); -} - -.subs-panel .deck-group { - margin-bottom: var(--space-4); -} - -.subs-panel .deck-group:last-child { - margin-bottom: var(--space-2); -} - -/* the lane's own controls: pickers first, then the verb */ -.subs-controls { - display: flex; - align-items: flex-end; - flex-wrap: wrap; - gap: var(--space-2) var(--space-3); - padding: var(--space-3) var(--space-1) 0; -} - -.subs-langs { - display: flex; - flex-wrap: wrap; - gap: var(--space-1); -} - -.subs-field { - flex: 1 1 12rem; - min-width: 9rem; - max-width: 18rem; -} - -/* the engine name is one short word; the source label is a whole subtitle */ -.subs-field-narrow { - flex: 0 1 9rem; - max-width: 11rem; -} - -.subs-note { - margin: var(--space-2) var(--space-1) 0; -} - -/* a row's own note takes its own line, so reporting an outcome never moves - the grab control out from under the pointer that just used it */ -.rel > .subs-note { - flex-basis: 100%; - margin-top: var(--space-1); -} - -.subs-buckets { - margin-top: var(--space-3); -} - -/* nested one level under the lane heading, so the buckets read as parts of - the fetch lane rather than as siblings of it */ -.subs-buckets .deck-group { - margin-bottom: var(--space-3); -} - -/* the buckets sit under the lane heading, not beside it */ -.subs-buckets .deck-label { - color: var(--ink-faint); -} - -/* §9.3's fixed columns, subtitle facts: provider and the ranking tiers */ -.cw-prov { - width: 7.5rem; -} - -.cw-hash { - width: 4rem; -} - -.cw-match { - width: 5rem; -} - -.cw-rate { - width: 4.25rem; - text-align: right; -} - -.cw-dl { - width: 6rem; - text-align: right; -} - -.cw-flag { - width: 5.5rem; -} - -/* ---- attention queues (§5.2 + §5.7) ------------------------------------ */ - -/* the badge is a signal, not a control: amber attention on the cyan word */ -.nav-count { - margin-left: var(--space-2); - font-size: var(--text-xs); - color: var(--signal-warn); -} - -.rail #nav-queues[hidden] { - display: none; -} - -.queue-note { - margin: var(--space-2) var(--space-1) 0; - font-size: var(--text-xs); - color: var(--ink-faint); -} - -/* like .rel: the line expands to fill, the action is its own control */ -.queue-item { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-1) var(--space-3); - padding: var(--space-2) var(--space-1); - border-bottom: 1px solid oklch(from var(--line) l c h / 45%); -} - -.queue-item .queue-line { - flex: 1; - min-width: 0; - width: auto; - padding: 0; - border-bottom: 0; -} - -.queue-empty { - padding: var(--space-2) var(--space-1); - font-size: var(--text-xs); - border-bottom: 1px solid oklch(from var(--line) l c h / 45%); -} - -.queue-feedback { - font-size: var(--text-xs); - color: var(--ink-muted); -} - -.queue-feedback[data-tone="fault"] { - color: var(--signal-fault); -} - -/* ---- manual intake ---------------------------------------------------- */ - -.intake .module-detail { - overflow-wrap: anywhere; -} - -/* ---- title detail pages (§9.6, issues #149 and #150) ------------------- */ - -/* The hero is one machined module carrying what the title IS — shared by - the movie and series pages, which is why its classes read `movie-*`. - The backdrop hotlinks from image.tmdb.org (§9.6) and sinks into the - console charcoal under a scrim, so the readouts keep their contrast — a - photo never sits behind text bare. */ - -/* the backdrop bleeds to the rail; a .module border and inset highlight - would fight that edge, so the hero drops both */ -.movie-hero { - position: relative; - overflow: hidden; - margin: 0 0 var(--space-8); - border: 0; - box-shadow: none; -} - -/* the backdrop layer: painted only when detail supplied one */ -.movie-hero::before { - content: ""; - position: absolute; - inset: 0; - background-image: var(--backdrop); - background-size: cover; - background-position: center 20%; - opacity: 0; - transition: opacity 400ms var(--ease-out); -} - -.movie-hero.has-backdrop::before { - opacity: 1; -} - -/* the scrim: ground at the bottom, heavy everywhere the text sits */ -.movie-hero::after { - content: ""; - position: absolute; - inset: 0; - background: - linear-gradient( - to top, - oklch(from var(--ground) l c h / 92%) 0%, - oklch(from var(--ground) l c h / 72%) 45%, - oklch(from var(--ground) l c h / 40%) 100% - ), - oklch(from var(--ground) l c h / 35%); -} - -.movie-body { - position: relative; - z-index: 1; - display: flex; - gap: var(--space-6); - align-items: flex-start; -} - -/* the poster is framed evidence, like every other image on a panel */ -.movie-poster { - flex: none; - width: 10.5rem; - aspect-ratio: 2 / 3; - object-fit: cover; - border: 1px solid var(--line-strong); - border-radius: var(--radius); - box-shadow: 0 2px 12px oklch(from var(--lowlight) l c h / 75%); -} - -.movie-info { - min-width: 0; - display: grid; - gap: var(--space-2); - padding: var(--space-6) 0; -} - -.movie-idline { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: var(--space-2) var(--space-3); -} - -/* the page's voice: bigger than a deck title, same display face */ -.movie-title { - margin: 0; - font-family: var(--font-display); - font-size: var(--text-xl); - font-weight: 700; - letter-spacing: 0.04em; - line-height: 1.05; -} - -.movie-year { - font-size: var(--text-sm); -} - -.movie-chips { - margin-left: 0; -} - -/* rating and meta are readouts on one line each; the star is drawn */ -.movie-rating { - display: inline-flex; - align-items: center; - gap: var(--space-1); - margin: 0; - color: var(--ink); -} - -/* the star is amber (§4.2): rating reads at a glance, the number stays ink */ -.star { - width: 0.75rem; - height: 0.75rem; - fill: var(--signal-warn); -} - -/* a row chip is smaller than the detail page's rating readout */ -.row-rating .star { - width: 0.625rem; - height: 0.625rem; -} - -.movie-meta { - margin: 0; -} - -.movie-tagline { - margin: var(--space-1) 0 0; - font-family: var(--font-display); - font-size: var(--text-md); - font-weight: 600; - letter-spacing: 0.02em; - color: var(--ink-muted); -} - -.movie-overview { - max-width: 65ch; - margin: var(--space-1) 0 0; - color: var(--ink); -} - -.movie-actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-2); - margin-top: var(--space-2); -} - -.movie-actions .add-note[data-tone="fault"] { - margin: 0; - color: var(--signal-fault); -} - -/* external links wear the control skin; anchors must drop the underline */ -.movie-actions a.control { - text-decoration: none; -} - -/* library controls follow the hero on their own row: state left, errands right */ -.movie-controls { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-2) var(--space-3); - padding: var(--space-2) var(--space-1); -} - -.movie-controls .control { - padding: 0 var(--space-3); -} - -/* The one control whose effect is narrower than its name suggests (§6.3) - says so under the row while it is on: three words on the button, the - half they cannot hold on the line below. Aligned with the row's own - padding so it reads as a note on the controls, not a new section. */ -.control-note { - margin: 0; - padding: 0 var(--space-1) var(--space-2); - color: var(--ink-muted); - font-size: var(--text-xs); -} - -/* wanted and blocked read as rules, pressed = on — the tracked-toggle idiom */ -.movie-controls .control[aria-pressed="true"] { - color: var(--accent-bright); - border-color: var(--accent); -} - -.movie-root-field { - display: inline-flex; - align-items: center; - gap: var(--space-2); -} - -.movie-root { - width: auto; - min-width: 11rem; -} - -.movie-controls-space { - flex: 1; -} - -/* on-disk rows share the deck's rel grammar: name leads, chips trail */ -.disk-name { - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - color: var(--ink); -} - -.rel[data-tone="fault"] { - color: var(--signal-fault); -} - -/* ---- small screens --------------------------------------------------- */ - -@media (max-width: 46rem) { - .rail-inner { - position: static; - flex-wrap: wrap; - gap: var(--space-3) var(--space-4); - } - - /* the library bar wraps to two lines at phone widths: identity and count - on the first, shape and satisfied toggles on the second, each label - whole instead of squeezed into a mid-word break */ - .library-bar { - flex-wrap: wrap; - row-gap: var(--space-2); - } - - .library-bar .bucket-toggle, - .view-btn { - white-space: nowrap; - } - - .rail-search { - position: relative; - left: auto; - width: auto; - order: 4; - flex-basis: 100%; - max-width: none; - transform: none; - } - - .rail-meta { - order: 2; - margin-left: auto; - } - - /* no side gutter on a phone: the width belongs to the content, and the - hero reaches both edges the way the rail already does */ - .deck { - padding: var(--space-6) 0 var(--space-8); - } - - .row-chips { - margin-left: 0; - flex-basis: 100%; - } - - /* §9.8: only the active-grab percentage survives at phone width — stalled, - errored and seeding shed like any other secondary chip. Covers the movie - row and the season row; the episode row's own rule above does the same - for .ep-chips. */ - .dl-chip:not([data-dl-phase="downloading"]) { - display: none; - } - - /* the season name shrinks to its tag so the head keeps fitting beside the - controls; the long form has nowhere to go on a phone */ - .season-name-long { - display: none; - } - - .season-name-short { - display: inline; - } - - /* no indent: the episode list is already inside its season, and the gutter - it used to sit in is gone */ - .episodes { - padding-left: 0; - } - - /* an episode row holds one line too. A phone has room for the identity, - whether the file is on disk, and the control — everything else is detail - you open the desktop for. Hidden, not shrunk: half a date is worse than - no date. */ - .episode-line { - flex-wrap: nowrap; - } - - /* the title takes the slack and truncates rather than pushing anything down */ - .ep-title { - flex: 1; - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - .ep-air { - display: none; - } - - /* the state chip stays, and so does a flag — a vanished episode is a - conflict, not an attribute. Resolution, language and the waived-rule - chip go; they are what a wide screen is for. A live downloading - percentage stays too (§9.8) — everything else the download chip can - say is what a wide screen is for. */ - .ep-chips - .chip:not([data-movie-state]):not([data-flag]):not(.subs-chip):not( - .dl-chip[data-dl-phase="downloading"] - ) { - display: none; - } - - /* no auto margin: it would absorb the free space before .ep-title's - flex-grow could claim it, truncating titles that had room to spare */ - .ep-chips { - margin-left: 0; - flex: none; - flex-basis: auto; - } - - .ep-actions { - flex: none; - margin-left: var(--space-2); - } - - .releases-id { - order: -1; - flex-basis: 100%; - } - - /* the line shrinks rather than wrapping, so the grab control never - leaves the row — the release name absorbs the squeeze via ellipsis */ - .rel-grab { - margin-left: auto; - } - - /* same wrap on a queue entry: the one click drops below its line */ - .queue-item .queue-line { - flex-basis: 100%; - } - - .queue-allow { - margin-left: auto; - } - - /* the hero stacks: poster above the info block, both full width */ - .movie-body { - flex-direction: column; - align-items: stretch; - gap: var(--space-3); - } - - .movie-poster { - width: 8.5rem; - } - - .movie-info { - padding-bottom: var(--space-4); - } - - /* state controls wrap to their own line, errands stay right-aligned */ - .movie-controls-space { - display: none; - } - - .movie-root { - min-width: 0; - flex: 1; - } - +@container (min-width: 46rem) { .chain { - grid-template-columns: 1fr; + grid-template-columns: 1fr 2.5rem 1.2fr 2.5rem 1fr; grid-template-areas: - "tmdb" - "prow" - "t1" - "master" - "out" - "sink"; - row-gap: var(--space-4); + "tmdb t1 master out sink" + "prow t2 master out sink"; + row-gap: var(--space-6); } - /* one junctioned trace on the stacked chain; the second is redundant */ .area-t2 { - display: none; + display: grid; + grid-area: t2; } .bus { - height: 2.25rem; - justify-content: center; + height: auto; } .bus svg { - transform: rotate(90deg); - transform-origin: center; - width: 2.25rem; - height: 0.5rem; + transform: none; + width: 100%; + } + + .module-master { + padding: var(--space-6) var(--space-4) var(--space-4); } } -/* ---- narrow deck (§9.3 at phone widths) ------------------------------- */ - -/* the full seven-column line is 36rem; under 40rem it cannot fit, so hdr - and audio leave the line and the five surviving columns tighten — - aligned, never wrapped, never scrolled */ -@media (max-width: 40rem) { - .colhead .cw, - .rel-line .chip { - padding: 0 var(--space-1); +@media (prefers-reduced-motion: reduce) { + *, + *::before { + /* biome-ignore lint/complexity/noImportantStyles: reduced motion overrides every transition */ + transition: none !important; } - .cw-hdr, - .cw-aud { - display: none; - } - - .cw-score { - width: 3rem; - } - - .cw-res { - width: 3rem; - } - - .cw-src { - width: 3.5rem; - } - - .cw-size { - width: 3rem; - } - - .cw-seed { - width: 2.5rem; - } - - /* §15's tiebreakers are the first columns to go: hash and release match - decide the row, rating and download count only order what is left */ - .cw-rate, - .cw-dl { - display: none; - } - - /* what is left wraps to two lines at this width, so an aligned header - would name the wrong cell. The remaining chips say what they are on - their own, and the header is shed rather than allowed to lie. */ - .subs-buckets .colhead { - display: none; - } - - .cw-prov { - width: 6.5rem; - } - - .cw-flag { - width: 4.5rem; + .lamp[data-state="probing"], + .chain.powered .lamp { + animation: none; } } - -/* ---- settings (issue #116) -------------------------------------------- */ - -/* one machined line per row (#231): identity, chips at the right edge, - then the icon action pair — the episode-row idiom, not a two-line group */ -.settings-row-actions { - display: flex; - align-items: center; - gap: var(--space-2); - margin-left: var(--space-3); -} - -.settings-row .row-title { - overflow-wrap: anywhere; -} - -/* feedback and the edit panel drop below the line, full width */ -.settings-row .add-note, -.settings-row .edit-panel { - flex-basis: 100%; -} - -/* narrow, the row wraps like an episode row: identity first, chips - full-width (the shared .row-chips rule), the icon pair keeps the right - edge. Lives here, after the base rule, so the margin actually flips. */ -@media (max-width: 46rem) { - .settings-row-actions { - margin-left: auto; - } -} - -.form-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); - gap: var(--space-3); - align-items: end; -} - -.field { - display: grid; - gap: var(--space-1); -} - -.field-label { - font-size: var(--text-2xs); - text-transform: uppercase; - letter-spacing: 0.12em; -} - -.form-input { - min-height: 2.25rem; - width: 100%; - padding: 0 var(--space-2); - color: var(--ink); - font-family: var(--font-readout); - font-size: var(--text-xs); - background: var(--ground); - border: 1px solid var(--line); - border-radius: var(--radius); - box-shadow: inset 0 1px var(--lowlight); - caret-color: var(--accent); - transition: border-color 150ms var(--ease-out); -} - -.form-input:hover { - border-color: var(--line-strong); -} - -.form-input:focus { - border-color: var(--accent); - outline: none; -} - -.form-input:disabled { - color: var(--ink-faint); - border-color: var(--line); -} - -select.form-input { - appearance: none; -} - -.form-input[data-invalid] { - border-color: var(--signal-fault); -} - -/* one resolution's size band: label, floor, target, penalty on a line */ -.band-line { - display: flex; - align-items: center; - gap: var(--space-2); -} - -.band-line .field-label { - width: 3.5rem; -} - -.band-num { - width: 4.5rem; -} - -/* ---- subtitle settings (§15, issue #204) ------------------------------ */ - -.subtitle-panel .field-label { - display: block; - margin: var(--space-4) 0 var(--space-2); -} - -.provider-list { - margin: 0 0 var(--space-3); - padding: 0; - list-style: none; -} - -.provider-row { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-1); - border-bottom: 1px solid oklch(from var(--line) l c h / 45%); -} - -.provider-toggle { - accent-color: var(--accent); -} - -.provider-name { - flex: 1; - min-width: 0; -} - -.provider-budget { - width: 5.5rem; -} diff --git a/web/src/ui.ts b/web/src/ui.ts new file mode 100644 index 0000000..7453c67 --- /dev/null +++ b/web/src/ui.ts @@ -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); + } + }); +}