From d2e40dcffb8707341eca28b5ab3366fb27b9abd6 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 24 Sep 2026 01:34:12 +0200 Subject: [PATCH 001/137] feat(design): design lab + DESIGN.md skeleton (E28 S2a) - docs/DESIGN.md: the written source of truth for the UI. Decided chapters (principles, device adaptation, action placement, text stays selectable, interaction rules, copy, anti-patterns, decision log); controls and surfaces are marked Pending until the owner picks from the lab. - /design-lab (dev only): renders each open decision's variants with the app's real CSS, themes and font scale. Batch A = six control decisions. - devOnlyGuard: a production build fails if anything under src/design-lab reaches the bundle (seen red with an unguarded import). - Size contract: tablet portrait + landscape and up, replacing the 1024x768 floor with its never-built "too small" message. --- docs/DESIGN.md | 177 ++++++ docs/development.md | 4 + frontend/devOnlyGuard.ts | 29 + frontend/eslint.config.js | 7 + .../src/components/ui/Button.dom.test.tsx | 2 +- frontend/src/components/ui/Button.tsx | 2 +- frontend/src/components/ui/README.md | 8 +- frontend/src/design-lab/DesignLab.tsx | 126 ++++ frontend/src/design-lab/batchA.tsx | 572 ++++++++++++++++++ frontend/src/design-lab/labKit.tsx | 150 +++++ frontend/src/index.css | 2 +- frontend/src/main.tsx | 14 +- frontend/tsconfig.node.json | 1 + frontend/vite.config.ts | 3 +- 14 files changed, 1087 insertions(+), 10 deletions(-) create mode 100644 docs/DESIGN.md create mode 100644 frontend/devOnlyGuard.ts create mode 100644 frontend/src/design-lab/DesignLab.tsx create mode 100644 frontend/src/design-lab/batchA.tsx create mode 100644 frontend/src/design-lab/labKit.tsx diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 00000000..fbb707f5 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,177 @@ +# DESIGN.md — how Siftlode looks and behaves + +This is the one written source of truth for the UI: principles, tokens, the canonical solution per pattern, +and the interaction rules. Screens are measured against it. A pattern that has more than one look in the +code is either a decision still open here (marked **Pending**) or a violation to fix. + +**Status:** being written in E28. Chapters marked _Decided_ are in force. Chapters marked **Pending** are +open decisions, each rendered side by side in the design lab (below) and settled by the product owner. + +## How this document changes + +- A rule changes only through an explicit decision recorded in the [decision log](#decision-log): what was + decided, the options it was chosen from, and why. No mid-sprint taste changes. A new idea goes on the + list for the next design round. +- Every rule rests on a named standard: Nielsen's heuristics, WCAG 2.2 AA, WAI-ARIA APG, Apple HIG or + Material. The tag is in brackets after the rule. +- A rule stated here should also be enforced in code (an eslint rule, a primitive's type, a test). A rule + that lives only in prose drifts. That is the finding that started this document. + +## The design lab + +`npm run dev`, then open **`http://localhost:5173/design-lab`**. The lab renders each open decision's variants +with the app's real CSS, tokens, glass and themes, and has its own switches for theme, scheme, text size and +backdrop. It is dev-only: `main.tsx` reaches it behind `import.meta.env.DEV`, and the build plugin +`frontend/devOnlyGuard.ts` fails a production build that would contain it. Once a decision is taken, the +losing variants are deleted and the chosen one stays as the reference rendering: the lab is the living +style guide. + +## Principles + +1. **Glass over a calm background is the look.** Surfaces are translucent layers (`.glass`, + `.glass-card`, `.glass-menu`, `.glass-media`) over a per-scheme backdrop. Depth comes from the + **header + fade**: a crisp sticky header with a short gradient just below it, so content dissolves as it + slides under. The reference is `DataTable`'s sticky header. A flat opaque box where a glass surface + belongs is a violation. +2. **Built for one known, daily user, on a desktop and a tablet.** Density is welcome where a mouse is + the pointer; on touch every target grows. Nothing is designed for phones. +3. **One primitive per pattern.** Where a `components/ui/` primitive exists and is used, the looks don't + drift. The drift lives in hand-rolled markup. A new generic control goes into `ui/`, and a screen + composes primitives rather than restyling them. +4. **Measured, not eyeballed.** Contrast, target sizes and layout at the contract sizes are measured in a + real browser before a UI change is called done. +5. **Honest states.** An error never renders as an empty state; loading, empty and error are three + different screens (`QueryState` / `StateMessage`). + +## Device adaptation — _Decided_ + +- **Supported:** tablet portrait and landscape and every size above them (from 768px wide). Phones are not a + target. Measure a UI change at 1024×768, 820×1180 (portrait) and a desktop size. +- **Adapt by capability, never by user agent:** `@media (pointer: coarse)` and `(hover: none)` decide touch + behaviour; container queries decide how a panel lays itself out. Breakpoints are named tokens, never + literal pixel values scattered in call sites. [HIG, WCAG 1.4.10] +- **Touch never loses a function.** Anything revealed on hover is visible at rest on `hover: none` (the + `.reveal` class), or it is removed on touch because the same action is reachable another way + (`.reveal-hover`). [WCAG 1.4.13, Nielsen #6] +- **Targets:** at least 2.75rem (44px) on a coarse pointer, as a real box, not an invisible hit area: + overlapping hit areas fire the wrong control. [HIG, WCAG 2.5.8] _Which controls this covers beyond + `Button` is decision A3._ +- **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page + when an input gets focus. +- **Below 768px** (a narrow window, split screen): what replaces the side rails is **Pending (C)**. + +## Tokens + +- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger + --warn --success --info --border`, each with a `-fg` partner where text sits on it). Six schemes × dark + and light. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. [WCAG 1.4.3, + 1.4.11] _The light-theme accent fix is decision B; the input edge token is decision A4._ +- **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a + px font size or a px-frozen box is an eslint error. +- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. _Buttons, inputs, triggers + and icon buttons: decisions A1, A2, A4, A5. Surfaces per role: decision B._ +- **Layers:** the fixed z-index scale in `index.css` (`base < menu < chrome < paneltab < panel < rail < + overlay < popover < tooltip`). No ad-hoc z-index. [maintainability] + +## Controls — **Pending (batch A)** + +Open in the lab: **A1** button shape and size rule · **A2** icon-button sizes and the on-media button · +**A3** the touch size beyond `Button` · **A4** the input primitive · **A5** the dropdown trigger · +**A6** checkbox, switch, slider. + +Already in force: +- An action button is `ui/Button` with a `SIZES` rung. A hand-rolled ` + ); +} + +const ico = "w-4 h-4"; + +// ── A1 · button shape and ladder ────────────────────────────────────────────────────────────────── +function ButtonSet({ radius }: { radius: Radius }) { + return ( + <> + +
+ }> + Sync now + + + Full history + +
+
+ +
+ + Cancel + + }> + Delete + + + Save + +
+
+ +
+ Download failed · HTTP 403 + + Retry + +
+
+ + ); +} + +export function A1() { + return ( + + + + + + + + + + + + ); +} + +// ── A2 · icon buttons ───────────────────────────────────────────────────────────────────────────── +function Icon({ box, radius, children }: { box: string; radius: string; children: ReactNode }) { + return ( + + ); +} + +function OnMedia({ radius }: { radius: string }) { + const b = clsx( + "grid h-8 w-8 place-items-center bg-black/55 text-white hover:bg-black/75", + radius, + ); + return ( +
+ + + + +
+ ); +} + +function IconSet({ row, bar, media }: { row: string; bar: string; media: string }) { + const r = row.includes("full") ? "rounded-full" : "rounded-lg"; + return ( + <> + +
+ Techno Tim + + + + + + + + + + + +
+
+ +
+ + + + + + +
+
+ + + + + ); +} + +export function A2() { + return ( + + + + + + + + + + + + ); +} + +// ── A3 · the touch rule beyond Button ──────────────────────────────────────────────────────────── +function TouchRow({ h, gap }: { h: string; gap: string }) { + const pill = clsx("inline-flex items-center px-3 text-sm", h); + return ( +
+ +
+ + Mine + Library + + + Sort: Newest + + +
+
+ +
+ Queue + Library +
+
+ +
+ Dark mode + + + +
+
+ Feed +
+
+
+ ); +} + +export function A3() { + return ( + + + + + + + + + + + + ); +} + +// ── A4 · the input ─────────────────────────────────────────────────────────────────────────────── +// The proposed stronger border: the text colour mixed into the page background, opaque, so its +// contrast is the same on glass and on flat surfaces. Measured over all 12 scheme × mode pairs: +// 45% fails (light/forest 2.68), 50% scrapes by (3.06), 55% clears 3:1 with margin (worst 3.52). +const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))"; + +function Field({ + cls, + style, + searchCls, + searchStyle, +}: { + cls: string; + style?: CSSProperties; + searchCls: string; + searchStyle?: CSSProperties; +}) { + return ( + <> + + + + + + + + ); +} + +export function A4() { + return ( + + + + + + + + + + + + + + + ); +} + +// ── A5 · dropdown trigger ──────────────────────────────────────────────────────────────────────── +function Trigger({ cls, label, value }: { cls: string; label: string; value: string }) { + return ( + + {label}: {value} + + + ); +} + +function DropdownSet({ bar, form }: { bar: string; form: string }) { + return ( + <> + +
+ + + +
+
+ + + + + ); +} + +export function A5() { + return ( + + + + + + + + + + + + ); +} + +// ── A6 · checkbox, switch, slider ──────────────────────────────────────────────────────────────── +function NativeSet() { + return ( + <> + + + + + ); +} + +function CustomSet() { + return ( + <> +
+ + + + Only unwatched +
+
+ Hide Shorts + + + +
+
+ Text size + + + + +
+ + ); +} + +export function A6() { + return ( + + + + + + + + + ); +} diff --git a/frontend/src/design-lab/labKit.tsx b/frontend/src/design-lab/labKit.tsx new file mode 100644 index 00000000..473f96ec --- /dev/null +++ b/frontend/src/design-lab/labKit.tsx @@ -0,0 +1,150 @@ +import { useSyncExternalStore, type ReactNode } from "react"; +import clsx from "clsx"; + +// Building blocks of the design lab. A lab page asks the owner ONE question per : two or +// three s rendered side by side with the app's real CSS, the recommended one marked and its +// one-line reason (the rule it rests on) printed under it. Everything is static markup on purpose — +// what is being decided is the look, and a variant that only exists here must not need app state. + +export function Decision({ + id, + code, + title, + question, + now, + children, +}: { + id: string; + code: string; + title: string; + question: string; + /** What the app does today, measured (the E28 S1 inventory). */ + now: ReactNode; + children: ReactNode; +}) { + return ( +
+
+
+ {code} +

{title}

+
+

{question}

+

+ Today: + {now} +

+
+
{children}
+
+ ); +} + +export function Variant({ + letter, + name, + recommended = false, + why, + children, +}: { + letter: string; + name: string; + recommended?: boolean; + why: string; + children: ReactNode; +}) { + return ( +
+
+ + {letter} + +

{name}

+ {recommended && ( + + Recommended + + )} +
+
{children}
+

{why}

+
+ ); +} + +/** A small caption above a sample inside a variant. */ +export function Sample({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +// ── Measured contrast ─────────────────────────────────────────────────────────────────────────── +// A variant that changes a colour prints its contrast against the page background, computed from the +// live CSS — so switching the theme or the scheme in the lab's bar re-measures it. The theme lives on +// 's attributes, so that is what the store subscribes to. + +function subscribeTheme(onChange: () => void) { + const mo = new MutationObserver(onChange); + mo.observe(document.documentElement, { attributes: true }); + return () => mo.disconnect(); +} + +let probe: HTMLSpanElement | null = null; +/** Resolve any CSS colour expression (vars, color-mix) to [r, g, b, a] in 0..1 via a hidden probe. */ +function resolve(expr: string): [number, number, number, number] { + if (!probe) { + probe = document.createElement("span"); + probe.style.display = "none"; + document.body.append(probe); + } + probe.style.color = ""; + probe.style.color = expr; + const c = getComputedStyle(probe).color; + const [r = 0, g = 0, b = 0, a = 1] = (c.match(/[\d.]+/g) ?? []).map(Number); + // `rgb()` is 0..255; a color-mix() result computes to `color(srgb …)`, already 0..1. + return c.startsWith("color(") ? [r, g, b, a] : [r / 255, g / 255, b / 255, a]; +} + +const lin = (v: number) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); +const lum = (r: number, g: number, b: number) => + 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + +function ratio(fg: string, bg: string): number { + const [br, bg_, bb] = resolve(bg); + const [fr, fg_, fb, fa] = resolve(fg); + // Composite a translucent foreground over the background, as the eye sees it. + const over = (f: number, b: number) => f * fa + b * (1 - fa); + const l1 = lum(over(fr, br), over(fg_, bg_), over(fb, bb)); + const l2 = lum(br, bg_, bb); + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); +} + +/** "3.42:1 ✓" against `min` — re-measured whenever the lab switches theme or scheme. */ +export function Contrast({ + fg, + bg = "var(--bg)", + min, + label, +}: { + fg: string; + bg?: string; + min: number; + label: string; +}) { + const value = useSyncExternalStore(subscribeTheme, () => ratio(fg, bg).toFixed(2)); + const ok = Number(value) >= min; + return ( + + {label}: {value}:1 {ok ? "✓" : `✗ (needs ${min}:1)`} + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index fa6267a1..947f6e0b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -583,7 +583,7 @@ body { opacity: 0.9; } - /* ===== E10 S1c · the minimum-target half of the 1024x768 comfort contract ===== + /* ===== E10 S1c · the minimum-target half of the tablet comfort contract (docs/DESIGN.md) ===== A 32px icon button is fine with a mouse (a precise pointer plus hover to confirm the aim) and not fine with a thumb. So the minimum grows ONLY where there is no precise pointer, which on this app's real second device — a tablet — is exactly where it is needed. diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2b50c27d..08db0e45 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -38,6 +38,10 @@ const App = lazyWithPreload(() => import("./App")); const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy")); const Terms = lazy(() => import("./components/legal/Terms")); const WatchPage = lazy(() => import("./components/WatchPage")); +// The design lab (E28): the living style guide, rendered with the app's real CSS so a design decision +// is taken on what will ship. DEV only — Vite folds the condition to `false` in a production build, +// the import becomes dead code and the chunk is never emitted (devOnlyGuard fails the build if it is). +const DesignLab = import.meta.env.DEV ? lazy(() => import("./design-lab/DesignLab")) : null; const queryClient = createQueryClient(); @@ -49,13 +53,19 @@ de.theme ||= "dark"; de.scheme ||= "midnight"; const path = window.location.pathname; -const isPublicPage = path === "/privacy" || path === "/terms" || path.startsWith("/watch/"); +const isLab = DesignLab !== null && path === "/design-lab"; +const isPublicPage = + path === "/privacy" || path === "/terms" || path.startsWith("/watch/") || isLab; // Fetch the app chunk NOW, alongside BootGate's identity probe, instead of only once the gate opens: // the two waits then overlap rather than queue, and the gate usually opens straight onto the app. A // failed fetch is left to the render's own load, which retries it and reports to the ErrorBoundary. if (!isPublicPage) App.preload().catch(() => {}); const root = - path === "/privacy" ? ( + isLab && DesignLab ? ( + + + + ) : path === "/privacy" ? ( ) : path === "/terms" ? ( diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 1c9ac7a5..3b6092e4 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -37,6 +37,7 @@ "vite.config.ts", "vitest.config.ts", "reactPlugin.ts", + "devOnlyGuard.ts", "tailwind.config.js", "postcss.config.js" ] diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b49c66a3..d7f1f568 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "vite"; import { reactWithCompiler } from "./reactPlugin"; +import { devOnlyGuard } from "./devOnlyGuard"; // During `vite dev` (host with Node), proxy API calls to the backend container. // Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the @@ -20,7 +21,7 @@ const proxy = { }; export default defineConfig({ - plugins: [reactWithCompiler()], + plugins: [reactWithCompiler(), devOnlyGuard()], server: { port: 5173, proxy }, build: { outDir: "dist" }, }); From 50923bb6847ef468df68e4fa98b4336e03c6332c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 24 Sep 2026 02:21:20 +0200 Subject: [PATCH 002/137] fix(build): keep JSX line-end spaces in dev and vitest plugin-react recognises the React Compiler only by its package name, and reactPlugin.ts passes the imported plugin, so plugin-react left Babel's retainLines on in dev. With the compiler's re-printed JSX that trimmed the space at the end of a text line (the privacy page read "for example/api/feed" on :5173). Production builds never had it on. Turn retainLines off through generatorOpts, which wins over the plugin's top-level option, and pin it with a DOM test seen red without the fix. --- frontend/reactPlugin.ts | 8 ++++++ frontend/src/jsxWhitespace.dom.test.tsx | 35 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 frontend/src/jsxWhitespace.dom.test.tsx diff --git a/frontend/reactPlugin.ts b/frontend/reactPlugin.ts index 044a1da6..ce3ddc66 100644 --- a/frontend/reactPlugin.ts +++ b/frontend/reactPlugin.ts @@ -18,9 +18,17 @@ import reactCompiler from "babel-plugin-react-compiler"; // reasons, both about the gate: a string is invisible to knip, which then reports the dependency as // unused, and — worse — a typo in that string is a silent no-op that ships an uncompiled bundle, // while a bad import is a resolve error on the tsc lane. +// +// The price of the import: plugin-react finds the compiler only by that string, so it doesn't know the +// compiler is there. Its one compiler-specific switch is Babel's `retainLines`, which it turns on for +// dev (and so for vitest) unless the compiler is present. With the compiler's re-printed JSX that eats +// the space at the end of a text line ("for example " rendered "for example/api/feed"). The +// production build never had it on. `generatorOpts` wins over the top-level option, so it is switched +// off here, for every mode. Pinned by src/jsxWhitespace.dom.test.tsx. export const reactWithCompiler = () => react({ babel: { plugins: [[reactCompiler, { target: "19" }]], + generatorOpts: { retainLines: false }, }, }); diff --git a/frontend/src/jsxWhitespace.dom.test.tsx b/frontend/src/jsxWhitespace.dom.test.tsx new file mode 100644 index 00000000..5fd0021e --- /dev/null +++ b/frontend/src/jsxWhitespace.dom.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom +// +// The React transform must not eat the space between a word and the element or expression that +// follows it on the same line. It did, in dev and in this test lane (never in the production build): +// @vitejs/plugin-react only recognises the React Compiler by its package NAME, and reactPlugin.ts +// passes the imported plugin, so the React plugin kept Babel's `retainLines` on. The compiler then +// printed "for example " and the next element on different lines, and the JSX transform trimmed the +// line-end space ("for example/api/feed" on the privacy page). reactPlugin.ts now turns +// `retainLines` off itself. The component goes through the same transform the app does. +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; + +afterEach(cleanup); + +function Sentence({ value }: { value: string }) { + // The line breaks ARE the test: collapsed onto one line, the defect doesn't fire. + // prettier-ignore + return ( +

+ which part of the app was called (for example /api/feed, never the search + + status: {value} end + +

+ ); +} + +describe("JSX text whitespace through the React transform", () => { + it("keeps the space before an element and before an expression", () => { + const { container } = render(); + expect(container.textContent).toBe( + "which part of the app was called (for example /api/feed, never the searchstatus: ok end", + ); + }); +}); From 477271907e9b72b44fecb0c7930b9d95cd6c2155 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 24 Sep 2026 02:21:20 +0200 Subject: [PATCH 003/137] feat(design): batch A recorded, batch B in the design lab (E28 S2b) DESIGN.md: the Controls chapter decided (A1-A6) with the decision-log rows; density stays open as B1. The lab keeps only the chosen A renderings as reference and adds batch B: density, the scheme set (measured accent hue gaps and light contrast), state layers, the page-chrome model, surface per role, the public watch page, the nav rail, the loading form and where the header + fade edge goes. --- docs/DESIGN.md | 71 ++- frontend/src/design-lab/DesignLab.tsx | 38 +- frontend/src/design-lab/batchA.tsx | 300 ++-------- frontend/src/design-lab/batchB.tsx | 565 ++++++++++++++++++ frontend/src/design-lab/batchBChrome.tsx | 719 +++++++++++++++++++++++ frontend/src/design-lab/labColor.ts | 33 ++ frontend/src/design-lab/labKit.tsx | 29 + 7 files changed, 1481 insertions(+), 274 deletions(-) create mode 100644 frontend/src/design-lab/batchB.tsx create mode 100644 frontend/src/design-lab/batchBChrome.tsx create mode 100644 frontend/src/design-lab/labColor.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index fbb707f5..b0b27dff 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -54,8 +54,8 @@ style guide. `.reveal` class), or it is removed on touch because the same action is reachable another way (`.reveal-hover`). [WCAG 1.4.13, Nielsen #6] - **Targets:** at least 2.75rem (44px) on a coarse pointer, as a real box, not an invisible hit area: - overlapping hit areas fire the wrong control. [HIG, WCAG 2.5.8] _Which controls this covers beyond - `Button` is decision A3._ + overlapping hit areas fire the wrong control. It covers every control primitive, not only `Button` + (A3, under Controls). [HIG, WCAG 2.5.8] - **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page when an input gets focus. - **Below 768px** (a narrow window, split screen): what replaces the side rails is **Pending (C)**. @@ -63,23 +63,40 @@ style guide. ## Tokens - **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger - --warn --success --info --border`, each with a `-fg` partner where text sits on it). Six schemes × dark +--warn --success --info --border`, each with a `-fg` partner where text sits on it). Six schemes × dark and light. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. [WCAG 1.4.3, - 1.4.11] _The light-theme accent fix is decision B; the input edge token is decision A4._ + 1.4.11] _The scheme set and the light-theme accent are decision B2._ The input edge token is set under + Controls (A4). - **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a px font size or a px-frozen box is an eslint error. -- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. _Buttons, inputs, triggers - and icon buttons: decisions A1, A2, A4, A5. Surfaces per role: decision B._ +- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. Buttons, icon buttons and + form fields `rounded-lg`; toolbar triggers and search a pill (Controls). _Surfaces per role: decision B5._ - **Layers:** the fixed z-index scale in `index.css` (`base < menu < chrome < paneltab < panel < rail < - overlay < popover < tooltip`). No ad-hoc z-index. [maintainability] +overlay < popover < tooltip`). No ad-hoc z-index. [maintainability] -## Controls — **Pending (batch A)** +## Controls — _Decided_ (batch A; density **Pending**, B1) -Open in the lab: **A1** button shape and size rule · **A2** icon-button sizes and the on-media button · -**A3** the touch size beyond `Button` · **A4** the input primitive · **A5** the dropdown trigger · -**A6** checkbox, switch, slider. +The chosen rendering of each rule is in the lab under "Decided". The primitives are built to it in S3. + +- **Buttons (A1):** `rounded-lg` (r8): a control's corner is smaller than the surface it sits on (card r12, + dialog r16). The rung follows the context: `md` in forms and dialog footers, `sm` in toolbars and panel + headers, `xs` inside a table row. Glass is a `Button` variant, not a hand-built glass button. _The rung + heights (standard 38/32/26 or compact 32/28/24) are decision B1._ [HIG, Material] +- **Icon buttons (A2):** two sizes, r8: the smaller in rows and dense toolbars, the larger on its own. On a + picture, a round, dark translucent button, readable on any thumbnail. Round means "on media" only. +- **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`, + `Tabs`, `Switch`, chips, the nav rail) grows to 2.75rem (44px) through one token, as a real box. Mouse + sizes don't change. [HIG, WCAG 2.5.8] +- **Input (A4):** one bordered input primitive. The edge is `color-mix(in srgb, var(--fg) 55%, var(--bg))`, + ≥ 3:1 against the page in every scheme and mode. A form field is r8 at the `md` height; a toolbar search + is a pill. Text is 1rem on a coarse pointer. [WCAG 1.4.11] +- **Dropdown trigger (A5):** a pill in a toolbar (where the pill means "a filter or a choice"), r8 in a form + (it lines up with the inputs). +- **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color`; the whole + labelled row is the 44px target. Keyboard, screen reader and forced-colours support come from the browser. + +Also in force: -Already in force: - An action button is `ui/Button` with a `SIZES` rung. A hand-rolled ` + ); +} + +const GHOST = "text-muted hover:text-fg hover:bg-card"; +const OUTLINE = "border border-border text-muted hover:text-accent hover:border-accent"; +const GLASS = "glass-card glass-hover hover:text-accent"; + +function IconBtn({ box, children }: { box: string; children: ReactNode }) { + return ( + + ); +} + +function DensitySet({ l }: { l: Ladder }) { + return ( + <> + +
+ + + Sort: Newest + + + }> + Sync now + + + + +
+
+ +
+ +
+ + Cancel + + }> + Delete + + + Save + +
+
+
+ +
+ Download failed · HTTP 403 + + + Retry + + + + + + + + +
+
+ +
+ }> + Download + + + + +
+
+ + ); +} + +export function B1() { + return ( + + + + + + + + + ); +} + +// ── B2 · the scheme set ────────────────────────────────────────────────────────────────────────── +// A proposed scheme is rendered by scoping the tokens on a wrapper: every utility resolves var(--…) +// at the use site (the `@theme inline` block in index.css), so bg-accent inside the tile paints the +// tile's accent, not the page's. The numbers under a tile come from the hex values, not the probe. + +type Pal = { + bg: string; + surface: string; + card: string; + border: string; + fg: string; + muted: string; + accent: string; + accentFg: string; +}; +type Mode = "dark" | "light"; +type SchemeDef = { name: string; dark: Pal; light: Pal }; + +const STATUS: Record> = { + dark: { danger: "#f87171", dangerFg: "#2a0a0a", warn: "#fbbf24", success: "#34d399" }, + light: { danger: "#dc2626", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" }, +}; + +const pal = ( + bg: string, + surface: string, + card: string, + border: string, + fg: string, + muted: string, + accent: string, + accentFg: string, +): Pal => ({ bg, surface, card, border, fg, muted, accent, accentFg }); + +// Today's six, copied from index.css (the scheme blocks). +const N = { + midnight: { + dark: ["#0b1020", "#121a2e", "#161f38", "#243049", "#e6e9f0", "#97a3c0"], + light: ["#f4f6fc", "#ffffff", "#ffffff", "#dde3f0", "#1a2238", "#5b6685"], + }, + forest: { + dark: ["#0a1512", "#0f1f1a", "#12241e", "#1f3a30", "#e6f0ec", "#90b1a4"], + light: ["#f1f7f4", "#ffffff", "#ffffff", "#d6e7e0", "#102a22", "#4d6b60"], + }, + slate: { + dark: ["#15171c", "#1b1e25", "#1f232b", "#2c313b", "#e7e9ee", "#9aa1ad"], + light: ["#f6f7f9", "#ffffff", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e"], + }, + youtube: { + dark: ["#0f0f0f", "#181818", "#1f1f1f", "#303030", "#f1f1f1", "#aaaaaa"], + light: ["#ffffff", "#f9f9f9", "#ffffff", "#e5e5e5", "#0f0f0f", "#606060"], + }, + starship: { + dark: ["#050b16", "#0b1728", "#101f36", "#1f3557", "#dfeaff", "#8aa6cc"], + light: ["#eef4fb", "#ffffff", "#ffffff", "#d3e0f0", "#0b1b30", "#4a5f7d"], + }, + matrix: { + dark: ["#030a06", "#08130d", "#0b1c12", "#163d27", "#cfe8d6", "#6fae86"], + light: ["#f0f7f1", "#ffffff", "#ffffff", "#cfe6d6", "#08130b", "#4c6b56"], + }, +} as const; + +function scheme( + name: string, + id: keyof typeof N, + dark: [string, string], + light: [string, string], +): SchemeDef { + const [db, ds, dc, dbo, df, dm] = N[id].dark; + const [lb, ls, lc, lbo, lf, lm] = N[id].light; + return { + name, + dark: pal(db, ds, dc, dbo, df, dm, dark[0], dark[1]), + light: pal(lb, ls, lc, lbo, lf, lm, light[0], light[1]), + }; +} + +const TODAY: SchemeDef[] = [ + scheme("Midnight", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]), + scheme("Forest", "forest", ["#2dd4bf", "#04110d"], ["#0d9488", "#ffffff"]), + scheme("Slate", "slate", ["#e8913c", "#1a1206"], ["#d97706", "#ffffff"]), + scheme("YouTube", "youtube", ["#ff3b46", "#ffffff"], ["#ff0033", "#ffffff"]), + scheme("Starship", "starship", ["#38b6ff", "#04101f"], ["#0b6bd6", "#ffffff"]), + scheme("Matrix", "matrix", ["#46b87c", "#04170c"], ["#12a150", "#ffffff"]), +]; + +// The proposal: four schemes, four hue families far from each other and from red / amber / green. +// Only the accents move; every background and text colour stays as it is. +const PROPOSED: SchemeDef[] = [ + scheme("Midnight · blue", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]), + scheme("Forest · cyan", "forest", ["#22d3ee", "#04110d"], ["#0e7490", "#ffffff"]), + scheme("Slate · fuchsia", "slate", ["#e879f9", "#1a0620"], ["#a21caf", "#ffffff"]), + scheme("Matrix · lime", "matrix", ["#a3e635", "#04170c"], ["#4d7c0f", "#ffffff"]), +]; + +const MIN_GAP = 30; + +function Metric({ ok, children }: { ok: boolean; children: ReactNode }) { + return ( + {children} + ); +} + +function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) { + const p = s[mode]; + const st = STATUS[mode]; + const style = { + "--bg": p.bg, + "--surface": p.surface, + "--card": p.card, + "--border": p.border, + "--fg": p.fg, + "--muted": p.muted, + "--accent": p.accent, + "--accent-fg": p.accentFg, + "--danger": st.danger, + "--danger-fg": st.dangerFg, + "--warn": st.warn, + "--success": st.success, + } as CSSProperties; + const gaps = (["danger", "warn", "success"] as const).map((k) => ({ + k, + g: Math.round(hueGap(p.accent, st[k])), + })); + const worst = gaps.reduce((a, b) => (b.g < a.g ? b : a)); + const text = hexContrast(p.accent, p.bg); + const onFill = hexContrast(p.accentFg, p.accent); + return ( +
+ + {s.name} · {mode} + +
+ Save + Delete +
+
+ Filter + Retrying + Done +
+ + Text with a link in it. + + {/* Measured from the hex values; drawn in the tile's own status colours. */} + + = MIN_GAP}>{`closest status: ${worst.k} ${worst.g}°`} + = 4.5}>{`accent as text ${text.toFixed(2)}:1`} + = 4.5}>{`button text ${onFill.toFixed(2)}:1`} + +
+ ); +} + +function SchemeGrid({ set }: { set: SchemeDef[] }) { + return ( +
+ {set.flatMap((s) => + (["dark", "light"] as const).map((m) => ), + )} +
+ ); +} + +export function B2() { + return ( + + + + + + + + + ); +} + +// ── B3 · state layers ──────────────────────────────────────────────────────────────────────────── +// A state is a layer of a colour mixed over whatever the control sits on, so it works on the page, +// on a card and on glass alike. `bg-card` as a hover could not: on a white light-theme card it is the +// card's own colour. Each control is shown at rest, hovered and pressed, then once live. + +type Layers = { + name: string; + /** static hover / pressed classes for the ghost & row sample */ + gh: string; + gp: string; + /** static hover / pressed classes for the filled sample */ + fh: string; + fp: string; + /** live classes */ + ghostLive: string; + filledLive: string; + /** colour expressions for the measured readout */ + ghostOnPage: string; + rowOnCard: string; + filledHover: string; +}; + +const mix = (c: string, p: number, over: string) => `color-mix(in srgb, var(${c}) ${p}%, ${over})`; + +const LAYERS: Record<"strong" | "material" | "today", Layers> = { + strong: { + name: "10% / 16%", + gh: "text-fg bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]", + gp: "text-fg bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]", + fh: "bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] text-accent-fg", + fp: "bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))] text-accent-fg", + ghostLive: + "text-muted hover:text-fg hover:bg-[color-mix(in_srgb,var(--fg)_10%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]", + filledLive: + "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))]", + ghostOnPage: mix("--fg", 10, "var(--bg)"), + rowOnCard: mix("--fg", 10, "var(--card)"), + filledHover: mix("--accent-fg", 14, "var(--accent)"), + }, + material: { + name: "8% / 12% (Material 3)", + gh: "text-fg bg-[color-mix(in_srgb,var(--fg)_8%,transparent)]", + gp: "text-fg bg-[color-mix(in_srgb,var(--fg)_12%,transparent)]", + fh: "bg-[color-mix(in_srgb,var(--accent-fg)_8%,var(--accent))] text-accent-fg", + fp: "bg-[color-mix(in_srgb,var(--accent-fg)_12%,var(--accent))] text-accent-fg", + ghostLive: + "text-muted hover:text-fg hover:bg-[color-mix(in_srgb,var(--fg)_8%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_12%,transparent)]", + filledLive: + "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_8%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_12%,var(--accent))]", + ghostOnPage: mix("--fg", 8, "var(--bg)"), + rowOnCard: mix("--fg", 8, "var(--card)"), + filledHover: mix("--accent-fg", 8, "var(--accent)"), + }, + today: { + name: "today", + gh: "text-fg bg-card", + gp: "text-fg bg-card", + fh: "bg-accent text-accent-fg opacity-90", + fp: "bg-accent text-accent-fg opacity-90", + ghostLive: "text-muted hover:text-fg hover:bg-card", + filledLive: "btn-primary", + ghostOnPage: "var(--card)", + rowOnCard: "var(--card)", + filledHover: "color-mix(in srgb, var(--accent) 90%, var(--bg))", + }, +}; + +const cell = "inline-flex h-8 items-center justify-center rounded-lg px-3 text-sm font-medium"; +const HOVER_MIN = 1.15; + +function StateRow({ + label, + rest, + hover, + pressed, +}: { + label: string; + rest: string; + hover: string; + pressed: string; +}) { + return ( +
+ {label} + Rest + Hover + Pressed +
+ ); +} + +function LayerSet({ l }: { l: Layers }) { + return ( + <> + + + + + +
+ +
+ +
+ + + + + +
+ + +
+
+ {["Watch later", "Add to playlist", "Copy link"].map((t) => ( + + ))} +
+
+ + ); +} + +export function B3() { + return ( + + + + + + + + + + + + ); +} diff --git a/frontend/src/design-lab/batchBChrome.tsx b/frontend/src/design-lab/batchBChrome.tsx new file mode 100644 index 00000000..3d650915 --- /dev/null +++ b/frontend/src/design-lab/batchBChrome.tsx @@ -0,0 +1,719 @@ +import clsx from "clsx"; +import type { ReactNode } from "react"; +import { + Bell, + Check, + ChevronDown, + Download, + Home, + ListVideo, + Loader2, + Lock, + PlayCircle, + Repeat, + Rss, + Settings, + SlidersHorizontal, + Tv, +} from "lucide-react"; +import { Decision, Sample, Variant } from "./labKit"; + +// E28 S2 · Batch B, part 2 — surfaces and page chrome: the page-chrome model, the surface per role, +// the public watch page, the nav rail, the loading form, and where the header + fade edge goes. +// The mini pages are real scroll boxes: scroll them to see what stays and what slides under. + +const ico = "w-4 h-4"; + +/** The north-star edge (DataTable's sticky thead): a crisp band, and a short gradient of the colour + * it sits on just below it, so content dissolves as it slides under. Literal strings per colour — + * Tailwind only generates classes it can read whole. */ +const FADE_BELOW = + "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']"; +const FADE_BELOW_PANEL = + "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--surface),transparent)] after:content-['']"; +const FADE_ABOVE_PANEL = + "before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--surface),transparent)] before:content-['']"; + +function Frame({ h, label, children }: { h: string; label: string; children: ReactNode }) { + return ( + +
+
{children}
+
+
+ ); +} + +function Cards({ n = 12 }: { n?: number }) { + return ( +
+ {Array.from({ length: n }, (_, i) => ( +
+
+ Video title number {i + 1} + Channel · 2 days ago +
+ ))} +
+ ); +} + +const tab = (on: boolean) => + clsx( + "rounded-lg px-2.5 py-1 text-xs", + on ? "bg-accent text-accent-fg" : "text-muted hover:text-fg", + ); +const pillCtl = "glass-card inline-flex h-7 items-center gap-1 rounded-full border px-2.5 text-xs"; + +function Tabs() { + return ( +
+ Queue + Library + History +
+ ); +} + +function Filters() { + return ( +
+ + State: Unwatched + + + Date: Any + + + Sort: Newest + +
+ ); +} + +// ── B4 · the page-chrome model ─────────────────────────────────────────────────────────────────── +function BandToolbar({ collapsed }: { collapsed?: boolean }) { + return ( +
+
+ + {collapsed && ( + + Filters · 3 + + )} +
+ {!collapsed && } +
+ ); +} + +function GlassToolbar() { + return ( +
+
+ + +
+
+ ); +} + +function InlineToolbar() { + return ( +
+ + +
+ ); +} + +export function B4() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +// ── B5 · surface per role ──────────────────────────────────────────────────────────────────────── +type Roles = { dialog: string; card: string; menu: string; item: string; tip: string }; + +function Surfaces({ r }: { r: Roles }) { + return ( +
+
+ Dialog +
+
Card
+
Card
+
+
+ Save +
+
+
+
+ {["Watch later", "Add to playlist", "Copy link"].map((t, i) => ( + + {t} + + ))} +
+ Tooltip +
+
+ ); +} + +export function B5() { + return ( + + + + + + + + + ); +} + +// ── B6 · the public watch page ─────────────────────────────────────────────────────────────────── +function WatchMock({ app }: { app: boolean }) { + const btn = app + ? "glass-card glass-hover rounded-lg h-8 px-3" + : "rounded-lg bg-surface hover:bg-card px-3 py-2"; + return ( +
+
+ + Siftlode +
+
+ the browser's own video controls +
+
+
+
Building a home lab on a budget
+
Techno Tim
+
+ + + + Download + + +
+
+ ); +} + +export function B6() { + return ( + + + + + + + + + ); +} + +// ── B7 · the nav rail ──────────────────────────────────────────────────────────────────────────── +const NAV = [Home, Rss, ListVideo, Tv, Download, Bell]; + +function Rail({ kind }: { kind: "solid" | "soft" | "indicator" }) { + return ( +
+ {NAV.map((I, i) => { + const on = i === 1; + if (kind === "indicator") + return ( + + + + + + {["Home", "Feed", "Lists", "Plex", "Saved", "Alerts"][i]} + + + ); + return ( + + {kind === "soft" && on && ( + + )} + + + ); + })} + + + + +
+ ); +} + +export function B7() { + return ( + + + + + + + + + + + + ); +} + +// ── B8 · the loading form ──────────────────────────────────────────────────────────────────────── +function SkeletonGrid() { + return ( +
+ {Array.from({ length: 6 }, (_, i) => ( +
+
+
+
+
+ ))} +
+ ); +} + +function SpinnerBlock() { + return ( +
+ + Loading… + +
+ ); +} + +function SavingButton() { + return ( + + Saving + + ); +} + +export function B8() { + return ( + + + + + + + + + + + + + + + + + + + ); +} + +// ── B9 · where the fade edge goes ──────────────────────────────────────────────────────────────── +const LINES = Array.from({ length: 14 }, (_, i) => `Row ${i + 1} · Some content that scrolls`); + +function ScrollBody({ children }: { children?: ReactNode }) { + return ( +
+ {children} + {LINES.map((l) => ( + + {l} + + ))} +
+ ); +} + +function FadeToolbar() { + return ( + +
+ Title · Channel · Date +
+ + + ); +} + +function FadeTabs() { + return ( + +
+
+ +
+
+ +
+
+
+ ); +} + +function FadeButtons() { + const depth = + "bg-[linear-gradient(to_bottom,color-mix(in_srgb,#fff_14%,var(--accent)),var(--accent)_55%,color-mix(in_srgb,#000_14%,var(--accent)))] text-accent-fg shadow-[inset_0_-1px_0_rgb(0_0_0/0.25)]"; + const glassDepth = + "glass-card bg-[linear-gradient(to_bottom,color-mix(in_srgb,var(--fg)_8%,transparent),transparent)]"; + return ( + +
+ + Flat + + + Depth + + + Flat glass + + + Depth glass + +
+
+ ); +} + +function FadeChips() { + const mask = + "linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent)"; + const chips = [ + "Unwatched", + "Shorts", + "Live", + "Music", + "Tech", + "Science", + "News", + "Gaming", + "Travel", + ]; + return ( + +
+
+ {chips.map((c) => ( + + {c} + + ))} +
+
+
+ ); +} + +function FadeCard() { + return ( + +
+ {[false, true].map((fade) => ( +
+
+ {fade && ( + + )} +
+ + {fade ? "Faded edge" : "Hard edge (today)"} + +
+ ))} +
+
+ ); +} + +function FadeDialog() { + return ( + +
+
+ Edit details +
+
+ +
+
+ + Cancel + + + Save + +
+
+
+ ); +} + +export function B9() { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/design-lab/labColor.ts b/frontend/src/design-lab/labColor.ts new file mode 100644 index 00000000..fbc98764 --- /dev/null +++ b/frontend/src/design-lab/labColor.ts @@ -0,0 +1,33 @@ +// ── Fixed-palette maths ────────────────────────────────────────────────────────────────────────── +// A proposed scheme is not in the CSS yet, so it can't be measured through the probe: these work on +// literal hex values instead. Hue distance is taken in OKLCH, where equal angles look equally far +// apart, unlike HSL's. + +const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); +const lin2 = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); + +export function hexContrast(a: string, b: string): number { + const l = (h: string) => { + const [r = 0, g = 0, bl = 0] = rgb(h).map(lin2); + return 0.2126 * r + 0.7152 * g + 0.0722 * bl; + }; + const x = l(a); + const y = l(b); + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); +} + +function hue(hex: string): number { + const [r = 0, g = 0, b = 0] = rgb(hex).map(lin2); + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; + const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; + return ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360; +} + +/** The OKLCH hue angle between two colours, 0..180°. */ +export function hueGap(a: string, b: string): number { + const d = Math.abs(hue(a) - hue(b)) % 360; + return Math.min(d, 360 - d); +} diff --git a/frontend/src/design-lab/labKit.tsx b/frontend/src/design-lab/labKit.tsx index 473f96ec..4b08758a 100644 --- a/frontend/src/design-lab/labKit.tsx +++ b/frontend/src/design-lab/labKit.tsx @@ -77,6 +77,35 @@ export function Variant({ ); } +/** A decision already taken: the chosen rendering only, with the rule it became. The lab's + * reference half — the living style guide the open decisions sit on top of. */ +export function Reference({ + id, + code, + title, + rule, + children, +}: { + id: string; + code: string; + title: string; + rule: string; + children: ReactNode; +}) { + return ( +
+
+
+ {code} ✓ +

{title}

+
+

{rule}

+
+
{children}
+
+ ); +} + /** A small caption above a sample inside a variant. */ export function Sample({ label, children }: { label: string; children: ReactNode }) { return ( From 7055548260833f0d51ab016cf48897b89166e5da Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 24 Sep 2026 03:10:00 +0200 Subject: [PATCH 004/137] feat(design): batch B recorded, batch C in the design lab (E28 S2c) DESIGN.md: B1-B9 decided (compact density, the four-scheme set with the accent-vs-status rule, state layers, the sticky band, surface per role, the watch page, the slim NavRail with optional labels, loading by shape, the fade edges) with their decision-log rows. The lab keeps the chosen A and B renderings as reference on shared tokens (labTokens.ts) and adds batch C: the action-placement classification for review, side panels on touch and below 768, the Plex player on touch, and the dialog conventions. --- docs/DESIGN.md | 104 ++- frontend/src/design-lab/DesignLab.tsx | 30 +- frontend/src/design-lab/batchA.tsx | 387 ++++------ frontend/src/design-lab/batchB.tsx | 887 +++++++++++----------- frontend/src/design-lab/batchBChrome.tsx | 719 ------------------ frontend/src/design-lab/batchC.tsx | 269 +++++++ frontend/src/design-lab/batchCActions.tsx | 416 ++++++++++ frontend/src/design-lab/labTokens.ts | 36 + 8 files changed, 1403 insertions(+), 1445 deletions(-) delete mode 100644 frontend/src/design-lab/batchBChrome.tsx create mode 100644 frontend/src/design-lab/batchC.tsx create mode 100644 frontend/src/design-lab/batchCActions.tsx create mode 100644 frontend/src/design-lab/labTokens.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b0b27dff..b35442d8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -58,38 +58,48 @@ style guide. (A3, under Controls). [HIG, WCAG 2.5.8] - **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page when an input gets focus. -- **Below 768px** (a narrow window, split screen): what replaces the side rails is **Pending (C)**. +- **Below 768px** (a narrow window, split screen): what replaces the side rails, and how a side panel opens on touch, is **Pending (C2)**. The Plex player on touch is **Pending (C3)**. ## Tokens - **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger ---warn --success --info --border`, each with a `-fg` partner where text sits on it). Six schemes × dark - and light. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. [WCAG 1.4.3, - 1.4.11] _The scheme set and the light-theme accent are decision B2._ The input edge token is set under - Controls (A4). +--warn --success --info --border`, each with a `-fg` partner where text sits on it). Four schemes × dark + and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (lime). Each earns its + place with its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. + [WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). +- **The accent never looks like a status.** A scheme's accent is at least 30° of OKLCH hue away from + `--danger`, `--warn` and `--success`, and passes 4.5:1 both as text on `--bg` and under `--accent-fg` on + the fill, in dark and light. A primary button must never read as a destructive one. A new scheme is + measured against this before it ships (the lab's B2 tiles do the arithmetic). [WCAG 1.4.1, 1.4.3] +- **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%** + (hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass + alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and + `bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. - **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a px font size or a px-frozen box is an eslint error. - **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. Buttons, icon buttons and - form fields `rounded-lg`; toolbar triggers and search a pill (Controls). _Surfaces per role: decision B5._ + form fields `rounded-lg`; toolbar triggers and search a pill (Controls). Surfaces: see Surfaces. - **Layers:** the fixed z-index scale in `index.css` (`base < menu < chrome < paneltab < panel < rail < overlay < popover < tooltip`). No ad-hoc z-index. [maintainability] -## Controls — _Decided_ (batch A; density **Pending**, B1) +## Controls — _Decided_ (batches A and B) The chosen rendering of each rule is in the lab under "Decided". The primitives are built to it in S3. - **Buttons (A1):** `rounded-lg` (r8): a control's corner is smaller than the surface it sits on (card r12, dialog r16). The rung follows the context: `md` in forms and dialog footers, `sm` in toolbars and panel - headers, `xs` inside a table row. Glass is a `Button` variant, not a hand-built glass button. _The rung - heights (standard 38/32/26 or compact 32/28/24) are decision B1._ [HIG, Material] -- **Icon buttons (A2):** two sizes, r8: the smaller in rows and dense toolbars, the larger on its own. On a + headers, `xs` inside a table row. Glass is a `Button` variant, not a hand-built glass button. [HIG, Material] +- **Density (B1): compact, by explicit height.** `md` 2rem (32), `sm` 1.75rem (28), `xs` 1.5rem (24); icon + buttons 1.75rem / 2rem. A toolbar's search pill, dropdown trigger, `sm` button and small icon button are + the same 28, so the row lines up. Nothing goes under 24 (WCAG 2.5.8); touch grows it to 44 (A3). +- **Icon buttons (A2):** two sizes (28 / 32), r8: the smaller in rows and dense toolbars, the larger on its own. On a picture, a round, dark translucent button, readable on any thumbnail. Round means "on media" only. - **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`, `Tabs`, `Switch`, chips, the nav rail) grows to 2.75rem (44px) through one token, as a real box. Mouse sizes don't change. [HIG, WCAG 2.5.8] - **Input (A4):** one bordered input primitive. The edge is `color-mix(in srgb, var(--fg) 55%, var(--bg))`, - ≥ 3:1 against the page in every scheme and mode. A form field is r8 at the `md` height; a toolbar search - is a pill. Text is 1rem on a coarse pointer. [WCAG 1.4.11] + ≥ 3:1 against the page in every scheme and mode. A form field is r8 at the `md` height (32); a toolbar search + is a pill at the `sm` height (28). Text is 1rem on a coarse pointer. [WCAG 1.4.11] - **Dropdown trigger (A5):** a pill in a toolbar (where the pill means "a filter or a choice"), r8 in a form (it lines up with the inputs). - **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color`; the whole @@ -105,13 +115,29 @@ Also in force: - A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice. [Nielsen #1] -## Surfaces and page chrome — **Pending (batch B)** +## Surfaces and page chrome — _Decided_ (batch B) -Open in the lab: **B1** button density · **B2** the scheme set (and the light-theme accent) · **B3** state -layers (hover, pressed) · **B4** the page-chrome model (a sticky band vs controls in the content, folding on -a short screen, the page `h1`) · **B5** the surface and radius per role (card, dialog, menu, tooltip) · **B6** -the public watch page · **B7** the nav rail · **B8** the loading form (spinner vs skeleton) · **B9** where -the header + fade edge goes. +- **Surface per role (B5):** a dialog or panel is `.glass` r16; a card or picker `.glass-card` r12; a menu + `.glass-menu` r12 with 4px padding and r8 items (12 − 4, so the corners nest); a tooltip `.glass-menu` r8. + Translucency comes only from the tiers (the app backdrop, Plex media, light); a per-site alpha override is a + violation. +- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade + under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has + a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6] +- **The header + fade edge (B9):** a crisp band and a 1rem gradient of the colour it sits on, so content + dissolves as it slides under. It goes on sticky toolbars and table headers, tabs over a scrolling panel, a + long dialog's header and footer, and the clipped ends of an overflowing chip row. Not on thumbnails (they + are the content) and not on buttons (it becomes a dated gloss). +- **Nav rail (B7):** a `NavRail` primitive, slim: 3.75rem wide. "You are here" is a soft accent tint plus a + 3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default); they add + height, never width, so each module has a short rail label that fits, and the full name stays in the + tooltip. No wide padding around the icons. +- **Loading (B8):** a first load of a grid, list or table shows its skeleton (a `Skeleton` primitive), so + nothing jumps when the data lands; a control's own work shows its spinner (`loading`). Neither appears + before ~300ms. [Nielsen #1] +- **The public watch page (B6)** follows these same rules (glass, `Button`, the pressed-pill toggle, the + scheme's backdrop); only the video keeps the browser's own controls, because a stranger's device is + unknown. Already in force: @@ -134,7 +160,7 @@ Which actions are visible and which live in a menu. (watched, saved, downloading). **What goes into the menu:** rare, secondary and destructive actions. Destructive ones keep their confirmation. [Nielsen #8, #5] - **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu. _Which actions stay - visible per object is part of batch C._ + visible per object is the classification table, **Pending review (C1)**._ - **Background menu:** a right-click on EMPTY content area (not over text, inputs, links or media) may open a page menu. It only mirrors page-scope actions that already exist in the toolbar (sort, view, quick filters, select all); it adds no new function. Shift+right-click falls through to the browser's own menu. @@ -167,12 +193,17 @@ Wherever there is text, the user can select it and copy it, and no click action An error sits next to what failed and says how to recover; a field error is under its field and linked with `aria-describedby`. Colour is never the only signal. [Nielsen #9, WCAG 3.3.1, 1.4.1] - **Loading:** anything slower than ~300ms shows a loading state; a near-instant load doesn't flash one. - Space is reserved for late content so the layout doesn't jump. _Spinner vs skeleton: decision B8._ + Space is reserved for late content so the layout doesn't jump; the form is under Surfaces (B8). - **Reordering:** anything you can drag also has a button or menu way to move it (Top / Up / Down / Bottom), and a drag handle carries `touch-action: none` so a finger drags instead of scrolling. [WCAG 2.5.7] - **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected. [WCAG 2.3.3] +## Dialogs — **Pending (C4)** + +Where focus lands when a dialog opens (by purpose, APG), and whether the footer stays pinned while the body +scrolls. Open in the lab. + ## Copy - Every user-facing string is in both Hungarian and English with zero key drift, and a user-facing error is @@ -190,15 +221,24 @@ Wherever there is text, the user can select it and copy it, and no click action ## Decision log -| Date | Decision | Chosen | Over | Why | -| ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------- | -| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait | -| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has | -| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function | -| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows | -| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" | -| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags | -| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers | -| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass | -| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context | -| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites | +| Date | Decision | Chosen | Over | Why | +| ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait | +| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has | +| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function | +| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows | +| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" | +| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags | +| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers | +| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass | +| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context | +| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites | +| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 | +| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 | +| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card | +| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content | +| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift | +| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device | +| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost | +| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck | +| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls | diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx index 4101fdd8..d4e756a5 100644 --- a/frontend/src/design-lab/DesignLab.tsx +++ b/frontend/src/design-lab/DesignLab.tsx @@ -2,20 +2,16 @@ import { useLayoutEffect, useState } from "react"; import clsx from "clsx"; import { SCHEMES, type Scheme } from "../lib/theme"; import { BatchA } from "./batchA"; -import { B1, B2, B3 } from "./batchB"; -import { B4, B5, B6, B7, B8, B9 } from "./batchBChrome"; +import { BatchB } from "./batchB"; +import { C1 } from "./batchCActions"; +import { C2, C3, C4 } from "./batchC"; // The open decisions, in the order they are asked. The decided ones render below them as reference. const OPEN = [ - { id: "b1", code: "B1", title: "Density", render: B1 }, - { id: "b2", code: "B2", title: "Schemes", render: B2 }, - { id: "b3", code: "B3", title: "Hover", render: B3 }, - { id: "b4", code: "B4", title: "Page chrome", render: B4 }, - { id: "b5", code: "B5", title: "Surfaces", render: B5 }, - { id: "b6", code: "B6", title: "/watch", render: B6 }, - { id: "b7", code: "B7", title: "Nav rail", render: B7 }, - { id: "b8", code: "B8", title: "Loading", render: B8 }, - { id: "b9", code: "B9", title: "Fade", render: B9 }, + { id: "c1", code: "C1", title: "Action placement", render: C1 }, + { id: "c2", code: "C2", title: "Side panels", render: C2 }, + { id: "c3", code: "C3", title: "Player touch", render: C3 }, + { id: "c4", code: "C4", title: "Dialogs", render: C4 }, ] as const; // The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits @@ -115,12 +111,15 @@ export default function DesignLab() { A ✓ + + B ✓ +
-

Batch B · surfaces, chrome and colour

+

Batch C · behaviour and device

Each decision shows its variants with the app's real CSS. The framed one is the recommendation. Resize the window to tablet size to see them there too. @@ -136,6 +135,13 @@ export default function DesignLab() {

+
+

Decided · batch B, surfaces, chrome and colour

+

+ B1 (density) and B3 (state layers) are rendered inside the batch A references above. +

+
+
); diff --git a/frontend/src/design-lab/batchA.tsx b/frontend/src/design-lab/batchA.tsx index c83ab7dc..4009c01d 100644 --- a/frontend/src/design-lab/batchA.tsx +++ b/frontend/src/design-lab/batchA.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import type { CSSProperties, ReactNode } from "react"; +import type { ReactNode } from "react"; import { ChevronDown, Download, @@ -12,39 +12,30 @@ import { Trash2, } from "lucide-react"; import { Contrast, Reference, Sample } from "./labKit"; +import { DANGER, FILLED, H, LAYER, STRONG } from "./labTokens"; -// E28 S2 · Batch A — controls, DECIDED (owner, 2026-09-24: all six as recommended). Only the chosen -// rendering of each stays, as the reference the S3 primitives are built to; the losing variants were -// deleted with the pick. The markup uses the app's real CSS classes and is wired to no data. +// E28 S2 · Batch A — controls, DECIDED (owner, 2026-09-24: all six as recommended), rendered with the +// batch B decisions that refine them: B1's compact ladder and B3's state layers. Only the chosen +// rendering of each stays, as the reference the S3 primitives are built to. Wired to no data. -// ── shared proposal pieces ───────────────────────────────────────────────────────────────────── type Tone = "primary" | "ghost" | "outline" | "glass" | "danger"; -type Size = "xs" | "sm" | "md"; -type Radius = "rounded-lg" | "rounded-xl" | "rounded-full"; const TONE: Record = { - primary: "font-medium btn-primary", - ghost: "font-medium text-muted hover:text-fg hover:bg-card", - outline: "font-medium border border-border text-muted hover:text-accent hover:border-accent", - glass: "font-medium glass-card glass-hover hover:text-accent", - danger: "font-medium btn-danger", -}; -const SIZE: Record = { - xs: "px-2 py-1 text-xs gap-1", - sm: "px-3 py-1.5 text-sm gap-1.5", - md: "px-3 py-2 text-sm gap-1.5", + primary: FILLED, + ghost: clsx("text-muted hover:text-fg", LAYER), + outline: clsx("border border-border text-muted hover:text-fg", LAYER), + glass: clsx("glass-card glass-hover hover:text-accent"), + danger: DANGER, }; -function Btn({ +export function Btn({ tone, size, - radius, icon, children, }: { tone: Tone; - size: Size; - radius: Radius; + size: "xs" | "sm" | "md"; icon?: ReactNode; children: ReactNode; }) { @@ -52,10 +43,9 @@ function Btn({ - ); -} - -const GHOST = "text-muted hover:text-fg hover:bg-card"; -const OUTLINE = "border border-border text-muted hover:text-accent hover:border-accent"; -const GLASS = "glass-card glass-hover hover:text-accent"; - -function IconBtn({ box, children }: { box: string; children: ReactNode }) { - return ( - - ); -} - -function DensitySet({ l }: { l: Ladder }) { - return ( - <> - -
- - - Sort: Newest - - - }> - Sync now - - - - -
-
- -
- -
- - Cancel - - }> - Delete - - - Save - -
-
-
- -
- Download failed · HTTP 403 - - - Retry - - - - - - - - -
-
- -
- }> - Download - - - - -
-
- - ); -} - -export function B1() { - return ( - - - - - - - - - ); -} // ── B2 · the scheme set ────────────────────────────────────────────────────────────────────────── -// A proposed scheme is rendered by scoping the tokens on a wrapper: every utility resolves var(--…) -// at the use site (the `@theme inline` block in index.css), so bg-accent inside the tile paints the -// tile's accent, not the page's. The numbers under a tile come from the hex values, not the probe. +// A scheme that is not in index.css yet is rendered by scoping its tokens on a wrapper: every +// utility resolves var(--…) at the use site (`@theme inline`), so the tile paints its own accent. type Pal = { bg: string; surface: string; - card: string; border: string; fg: string; muted: string; @@ -231,77 +63,37 @@ const STATUS: Record ({ bg, surface, card, border, fg, muted, accent, accentFg }); +): Pal => ({ bg, surface, border, fg, muted, accent, accentFg }); -// Today's six, copied from index.css (the scheme blocks). -const N = { - midnight: { - dark: ["#0b1020", "#121a2e", "#161f38", "#243049", "#e6e9f0", "#97a3c0"], - light: ["#f4f6fc", "#ffffff", "#ffffff", "#dde3f0", "#1a2238", "#5b6685"], +// Backgrounds and text are today's (index.css); only the accents are new. +const SCHEMES: SchemeDef[] = [ + { + name: "Midnight · blue", + dark: pal("#0b1020", "#121a2e", "#243049", "#e6e9f0", "#97a3c0", "#6d8cff", "#0b1020"), + light: pal("#f4f6fc", "#ffffff", "#dde3f0", "#1a2238", "#5b6685", "#3b5bdb", "#ffffff"), }, - forest: { - dark: ["#0a1512", "#0f1f1a", "#12241e", "#1f3a30", "#e6f0ec", "#90b1a4"], - light: ["#f1f7f4", "#ffffff", "#ffffff", "#d6e7e0", "#102a22", "#4d6b60"], + { + name: "Forest · cyan", + dark: pal("#0a1512", "#0f1f1a", "#1f3a30", "#e6f0ec", "#90b1a4", "#22d3ee", "#04110d"), + light: pal("#f1f7f4", "#ffffff", "#d6e7e0", "#102a22", "#4d6b60", "#0e7490", "#ffffff"), }, - slate: { - dark: ["#15171c", "#1b1e25", "#1f232b", "#2c313b", "#e7e9ee", "#9aa1ad"], - light: ["#f6f7f9", "#ffffff", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e"], + { + name: "Slate · fuchsia", + dark: pal("#15171c", "#1b1e25", "#2c313b", "#e7e9ee", "#9aa1ad", "#e879f9", "#1a0620"), + light: pal("#f6f7f9", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e", "#a21caf", "#ffffff"), }, - youtube: { - dark: ["#0f0f0f", "#181818", "#1f1f1f", "#303030", "#f1f1f1", "#aaaaaa"], - light: ["#ffffff", "#f9f9f9", "#ffffff", "#e5e5e5", "#0f0f0f", "#606060"], + { + name: "Matrix · lime", + dark: pal("#030a06", "#08130d", "#163d27", "#cfe8d6", "#6fae86", "#a3e635", "#04170c"), + light: pal("#f0f7f1", "#ffffff", "#cfe6d6", "#08130b", "#4c6b56", "#4d7c0f", "#ffffff"), }, - starship: { - dark: ["#050b16", "#0b1728", "#101f36", "#1f3557", "#dfeaff", "#8aa6cc"], - light: ["#eef4fb", "#ffffff", "#ffffff", "#d3e0f0", "#0b1b30", "#4a5f7d"], - }, - matrix: { - dark: ["#030a06", "#08130d", "#0b1c12", "#163d27", "#cfe8d6", "#6fae86"], - light: ["#f0f7f1", "#ffffff", "#ffffff", "#cfe6d6", "#08130b", "#4c6b56"], - }, -} as const; - -function scheme( - name: string, - id: keyof typeof N, - dark: [string, string], - light: [string, string], -): SchemeDef { - const [db, ds, dc, dbo, df, dm] = N[id].dark; - const [lb, ls, lc, lbo, lf, lm] = N[id].light; - return { - name, - dark: pal(db, ds, dc, dbo, df, dm, dark[0], dark[1]), - light: pal(lb, ls, lc, lbo, lf, lm, light[0], light[1]), - }; -} - -const TODAY: SchemeDef[] = [ - scheme("Midnight", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]), - scheme("Forest", "forest", ["#2dd4bf", "#04110d"], ["#0d9488", "#ffffff"]), - scheme("Slate", "slate", ["#e8913c", "#1a1206"], ["#d97706", "#ffffff"]), - scheme("YouTube", "youtube", ["#ff3b46", "#ffffff"], ["#ff0033", "#ffffff"]), - scheme("Starship", "starship", ["#38b6ff", "#04101f"], ["#0b6bd6", "#ffffff"]), - scheme("Matrix", "matrix", ["#46b87c", "#04170c"], ["#12a150", "#ffffff"]), ]; -// The proposal: four schemes, four hue families far from each other and from red / amber / green. -// Only the accents move; every background and text colour stays as it is. -const PROPOSED: SchemeDef[] = [ - scheme("Midnight · blue", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]), - scheme("Forest · cyan", "forest", ["#22d3ee", "#04110d"], ["#0e7490", "#ffffff"]), - scheme("Slate · fuchsia", "slate", ["#e879f9", "#1a0620"], ["#a21caf", "#ffffff"]), - scheme("Matrix · lime", "matrix", ["#a3e635", "#04170c"], ["#4d7c0f", "#ffffff"]), -]; - -const MIN_GAP = 30; - function Metric({ ok, children }: { ok: boolean; children: ReactNode }) { return ( {children} @@ -314,7 +106,7 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) { const style = { "--bg": p.bg, "--surface": p.surface, - "--card": p.card, + "--card": p.surface, "--border": p.border, "--fg": p.fg, "--muted": p.muted, @@ -325,11 +117,9 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) { "--warn": st.warn, "--success": st.success, } as CSSProperties; - const gaps = (["danger", "warn", "success"] as const).map((k) => ({ - k, - g: Math.round(hueGap(p.accent, st[k])), - })); - const worst = gaps.reduce((a, b) => (b.g < a.g ? b : a)); + const worst = (["danger", "warn", "success"] as const) + .map((k) => ({ k, g: Math.round(hueGap(p.accent, st[k])) })) + .reduce((a, b) => (b.g < a.g ? b : a)); const text = hexContrast(p.accent, p.bg); const onFill = hexContrast(p.accentFg, p.accent); return ( @@ -349,12 +139,8 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) { Retrying Done
- - Text with a link in it. - - {/* Measured from the hex values; drawn in the tile's own status colours. */} - = MIN_GAP}>{`closest status: ${worst.k} ${worst.g}°`} + = 30}>{`closest status: ${worst.k} ${worst.g}°`} = 4.5}>{`accent as text ${text.toFixed(2)}:1`} = 4.5}>{`button text ${onFill.toFixed(2)}:1`} @@ -362,204 +148,413 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) { ); } -function SchemeGrid({ set }: { set: SchemeDef[] }) { +// ── B4 · the page-chrome band ──────────────────────────────────────────────────────────────────── +function Frame({ h, label, children }: { h: string; label: string; children: ReactNode }) { return ( -
- {set.flatMap((s) => - (["dark", "light"] as const).map((m) => ), + +
+
{children}
+
+
+ ); +} + +function Cards() { + return ( +
+ {Array.from({ length: 12 }, (_, i) => ( +
+
+ Video title number {i + 1} + Channel · 2 days ago +
+ ))} +
+ ); +} + +const tab = (on: boolean) => + clsx( + "rounded-lg px-2.5 py-1 text-xs", + on ? "bg-accent text-accent-fg" : clsx("text-muted hover:text-fg", LAYER), + ); +const pillCtl = clsx( + "glass-card inline-flex items-center gap-1 rounded-full border px-2.5 text-xs", + H.bar, +); + +function Tabs() { + return ( +
+ Queue + Library + History +
+ ); +} + +function Band({ collapsed }: { collapsed?: boolean }) { + return ( +
+
+ + {collapsed && ( + + Filters · 3 + + )} +
+ {!collapsed && ( +
+ {["State: Unwatched", "Date: Any", "Sort: Newest"].map((f) => ( + + {f} + + ))} +
)}
); } -export function B2() { +// ── B5 · surface per role ──────────────────────────────────────────────────────────────────────── +function Surfaces() { return ( - - - - - - - - - ); -} - -// ── B3 · state layers ──────────────────────────────────────────────────────────────────────────── -// A state is a layer of a colour mixed over whatever the control sits on, so it works on the page, -// on a card and on glass alike. `bg-card` as a hover could not: on a white light-theme card it is the -// card's own colour. Each control is shown at rest, hovered and pressed, then once live. - -type Layers = { - name: string; - /** static hover / pressed classes for the ghost & row sample */ - gh: string; - gp: string; - /** static hover / pressed classes for the filled sample */ - fh: string; - fp: string; - /** live classes */ - ghostLive: string; - filledLive: string; - /** colour expressions for the measured readout */ - ghostOnPage: string; - rowOnCard: string; - filledHover: string; -}; - -const mix = (c: string, p: number, over: string) => `color-mix(in srgb, var(${c}) ${p}%, ${over})`; - -const LAYERS: Record<"strong" | "material" | "today", Layers> = { - strong: { - name: "10% / 16%", - gh: "text-fg bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]", - gp: "text-fg bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]", - fh: "bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] text-accent-fg", - fp: "bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))] text-accent-fg", - ghostLive: - "text-muted hover:text-fg hover:bg-[color-mix(in_srgb,var(--fg)_10%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]", - filledLive: - "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))]", - ghostOnPage: mix("--fg", 10, "var(--bg)"), - rowOnCard: mix("--fg", 10, "var(--card)"), - filledHover: mix("--accent-fg", 14, "var(--accent)"), - }, - material: { - name: "8% / 12% (Material 3)", - gh: "text-fg bg-[color-mix(in_srgb,var(--fg)_8%,transparent)]", - gp: "text-fg bg-[color-mix(in_srgb,var(--fg)_12%,transparent)]", - fh: "bg-[color-mix(in_srgb,var(--accent-fg)_8%,var(--accent))] text-accent-fg", - fp: "bg-[color-mix(in_srgb,var(--accent-fg)_12%,var(--accent))] text-accent-fg", - ghostLive: - "text-muted hover:text-fg hover:bg-[color-mix(in_srgb,var(--fg)_8%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_12%,transparent)]", - filledLive: - "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_8%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_12%,var(--accent))]", - ghostOnPage: mix("--fg", 8, "var(--bg)"), - rowOnCard: mix("--fg", 8, "var(--card)"), - filledHover: mix("--accent-fg", 8, "var(--accent)"), - }, - today: { - name: "today", - gh: "text-fg bg-card", - gp: "text-fg bg-card", - fh: "bg-accent text-accent-fg opacity-90", - fp: "bg-accent text-accent-fg opacity-90", - ghostLive: "text-muted hover:text-fg hover:bg-card", - filledLive: "btn-primary", - ghostOnPage: "var(--card)", - rowOnCard: "var(--card)", - filledHover: "color-mix(in srgb, var(--accent) 90%, var(--bg))", - }, -}; - -const cell = "inline-flex h-8 items-center justify-center rounded-lg px-3 text-sm font-medium"; -const HOVER_MIN = 1.15; - -function StateRow({ - label, - rest, - hover, - pressed, -}: { - label: string; - rest: string; - hover: string; - pressed: string; -}) { - return ( -
- {label} - Rest - Hover - Pressed +
+
+ Dialog · .glass r16 +
+
Card · r12
+
Picker · r12
+
+
+
+
+ {["Watch later", "Add to playlist", "Copy link"].map((t) => ( + + {t} + + ))} +
+ Tooltip · .glass-menu r8 +
); } -function LayerSet({ l }: { l: Layers }) { +// ── B6 · the public watch page ─────────────────────────────────────────────────────────────────── +function WatchPage() { + return ( +
+
+ + Siftlode +
+
+ the browser's own video controls +
+
+
+
Building a home lab on a budget
+
Techno Tim
+
+ + + }> + Download + + +
+
+ ); +} + +// ── B7 · the nav rail (the owner's combination) ────────────────────────────────────────────────── +// A soft tint + an edge bar for "you are here"; labels optional (a per-user Settings switch). Slim: the +// rail is 3.75rem wide in BOTH modes (labels add height, never width), so each module needs a short +// rail label that fits the item; the full name stays in the hover tooltip. Each rail reports whether +// all its labels fit, measured live (it re-measures on the lab's text-size slider). + +type NavDef = { icon: typeof Home; en: string; hu: string }; +const NAV: NavDef[] = [ + { icon: Home, en: "Home", hu: "Kezdőlap" }, + { icon: Youtube, en: "Feed", hu: "Feed" }, + { icon: Tv, en: "Channels", hu: "Csatornák" }, + { icon: ListVideo, en: "Playlists", hu: "Listák" }, + { icon: Clapperboard, en: "Plex", hu: "Plex" }, + { icon: Bell, en: "Alerts", hu: "Értesítés" }, + { icon: MessageSquare, en: "Messages", hu: "Üzenetek" }, + { icon: Download, en: "Downloads", hu: "Letöltések" }, +]; +const SYSTEM: NavDef[] = [ + { icon: BarChart3, en: "Stats", hu: "Statisztika" }, + { icon: Users, en: "Users", hu: "Fiókok" }, + { icon: Settings, en: "Settings", hu: "Beállítások" }, +]; + +function RailItem({ d, on, lang }: { d: NavDef; on: boolean; lang?: "en" | "hu" }) { + const I = d.icon; + return ( + + {on && ( + + )} + + {lang && ( + + {d[lang]} + + )} + + ); +} + +function Rail({ lang }: { lang?: "en" | "hu" }) { + const ref = useRef(null); + const [clipped, setClipped] = useState([]); + useLayoutEffect(() => { + const measure = () => + setClipped( + [...(ref.current?.querySelectorAll("[data-rail-label]") ?? [])] + .filter((e) => e.scrollWidth > e.clientWidth) + .map((e) => e.textContent ?? ""), + ); + measure(); + const mo = new MutationObserver(measure); + mo.observe(document.documentElement, { attributes: true }); + return () => mo.disconnect(); + }, []); + return ( +
+
+ {NAV.map((d, i) => ( + + ))} + + {SYSTEM.map((d) => ( + + ))} +
+ {lang && ( + + {clipped.length ? `clipped: ${clipped.join(", ")}` : "every label fits ✓"} + + )} +
+ ); +} + +// ── B8 · loading ───────────────────────────────────────────────────────────────────────────────── +const bone = "animate-pulse rounded bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]"; + +function Loading() { return ( <> - - - - - -
- -
- -
- - - - - -
- - -
-
- {["Watch later", "Add to playlist", "Copy link"].map((t) => ( - + +
+ {Array.from({ length: 3 }, (_, i) => ( +
+
+
+
+
))}
+ + + Saving + + ); } -export function B3() { +// ── B9 · the fade edges (1–4 chosen) ───────────────────────────────────────────────────────────── +const LINES = Array.from({ length: 14 }, (_, i) => `Row ${i + 1} · Some content that scrolls`); + +function ScrollBody() { return ( - - + {LINES.map((l) => ( + + {l} + + ))} +
+ ); +} + +const CHIPS = ["Unwatched", "Shorts", "Live", "Music", "Tech", "Science", "News", "Gaming"]; + +function Fades() { + return ( +
+ +
+ Title · Channel · Date +
+ + + +
+
+ +
+
+ +
+
+
+ +
+
+ Edit details +
+
+ +
+
+ + Cancel + + }> + Save + +
+
+
+ +
+
+ {CHIPS.map((c) => ( + + {c} + + ))} +
+
+
+
+ ); +} + +export function BatchB() { + return ( +
+ - - - + {SCHEMES.flatMap((s) => + (["dark", "light"] as const).map((m) => ), + )} +
+ + - - - + + + + + + + + + - - - + + + + + + +
+ + + + + + + + + +
+
+ + + + + + +
); } diff --git a/frontend/src/design-lab/batchBChrome.tsx b/frontend/src/design-lab/batchBChrome.tsx deleted file mode 100644 index 3d650915..00000000 --- a/frontend/src/design-lab/batchBChrome.tsx +++ /dev/null @@ -1,719 +0,0 @@ -import clsx from "clsx"; -import type { ReactNode } from "react"; -import { - Bell, - Check, - ChevronDown, - Download, - Home, - ListVideo, - Loader2, - Lock, - PlayCircle, - Repeat, - Rss, - Settings, - SlidersHorizontal, - Tv, -} from "lucide-react"; -import { Decision, Sample, Variant } from "./labKit"; - -// E28 S2 · Batch B, part 2 — surfaces and page chrome: the page-chrome model, the surface per role, -// the public watch page, the nav rail, the loading form, and where the header + fade edge goes. -// The mini pages are real scroll boxes: scroll them to see what stays and what slides under. - -const ico = "w-4 h-4"; - -/** The north-star edge (DataTable's sticky thead): a crisp band, and a short gradient of the colour - * it sits on just below it, so content dissolves as it slides under. Literal strings per colour — - * Tailwind only generates classes it can read whole. */ -const FADE_BELOW = - "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']"; -const FADE_BELOW_PANEL = - "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--surface),transparent)] after:content-['']"; -const FADE_ABOVE_PANEL = - "before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--surface),transparent)] before:content-['']"; - -function Frame({ h, label, children }: { h: string; label: string; children: ReactNode }) { - return ( - -
-
{children}
-
-
- ); -} - -function Cards({ n = 12 }: { n?: number }) { - return ( -
- {Array.from({ length: n }, (_, i) => ( -
-
- Video title number {i + 1} - Channel · 2 days ago -
- ))} -
- ); -} - -const tab = (on: boolean) => - clsx( - "rounded-lg px-2.5 py-1 text-xs", - on ? "bg-accent text-accent-fg" : "text-muted hover:text-fg", - ); -const pillCtl = "glass-card inline-flex h-7 items-center gap-1 rounded-full border px-2.5 text-xs"; - -function Tabs() { - return ( -
- Queue - Library - History -
- ); -} - -function Filters() { - return ( -
- - State: Unwatched - - - Date: Any - - - Sort: Newest - -
- ); -} - -// ── B4 · the page-chrome model ─────────────────────────────────────────────────────────────────── -function BandToolbar({ collapsed }: { collapsed?: boolean }) { - return ( -
-
- - {collapsed && ( - - Filters · 3 - - )} -
- {!collapsed && } -
- ); -} - -function GlassToolbar() { - return ( -
-
- - -
-
- ); -} - -function InlineToolbar() { - return ( -
- - -
- ); -} - -export function B4() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} - -// ── B5 · surface per role ──────────────────────────────────────────────────────────────────────── -type Roles = { dialog: string; card: string; menu: string; item: string; tip: string }; - -function Surfaces({ r }: { r: Roles }) { - return ( -
-
- Dialog -
-
Card
-
Card
-
-
- Save -
-
-
-
- {["Watch later", "Add to playlist", "Copy link"].map((t, i) => ( - - {t} - - ))} -
- Tooltip -
-
- ); -} - -export function B5() { - return ( - - - - - - - - - ); -} - -// ── B6 · the public watch page ─────────────────────────────────────────────────────────────────── -function WatchMock({ app }: { app: boolean }) { - const btn = app - ? "glass-card glass-hover rounded-lg h-8 px-3" - : "rounded-lg bg-surface hover:bg-card px-3 py-2"; - return ( -
-
- - Siftlode -
-
- the browser's own video controls -
-
-
-
Building a home lab on a budget
-
Techno Tim
-
- - - - Download - - -
-
- ); -} - -export function B6() { - return ( - - - - - - - - - ); -} - -// ── B7 · the nav rail ──────────────────────────────────────────────────────────────────────────── -const NAV = [Home, Rss, ListVideo, Tv, Download, Bell]; - -function Rail({ kind }: { kind: "solid" | "soft" | "indicator" }) { - return ( -
- {NAV.map((I, i) => { - const on = i === 1; - if (kind === "indicator") - return ( - - - - - - {["Home", "Feed", "Lists", "Plex", "Saved", "Alerts"][i]} - - - ); - return ( - - {kind === "soft" && on && ( - - )} - - - ); - })} - - - - -
- ); -} - -export function B7() { - return ( - - - - - - - - - - - - ); -} - -// ── B8 · the loading form ──────────────────────────────────────────────────────────────────────── -function SkeletonGrid() { - return ( -
- {Array.from({ length: 6 }, (_, i) => ( -
-
-
-
-
- ))} -
- ); -} - -function SpinnerBlock() { - return ( -
- - Loading… - -
- ); -} - -function SavingButton() { - return ( - - Saving - - ); -} - -export function B8() { - return ( - - - - - - - - - - - - - - - - - - - ); -} - -// ── B9 · where the fade edge goes ──────────────────────────────────────────────────────────────── -const LINES = Array.from({ length: 14 }, (_, i) => `Row ${i + 1} · Some content that scrolls`); - -function ScrollBody({ children }: { children?: ReactNode }) { - return ( -
- {children} - {LINES.map((l) => ( - - {l} - - ))} -
- ); -} - -function FadeToolbar() { - return ( - -
- Title · Channel · Date -
- - - ); -} - -function FadeTabs() { - return ( - -
-
- -
-
- -
-
-
- ); -} - -function FadeButtons() { - const depth = - "bg-[linear-gradient(to_bottom,color-mix(in_srgb,#fff_14%,var(--accent)),var(--accent)_55%,color-mix(in_srgb,#000_14%,var(--accent)))] text-accent-fg shadow-[inset_0_-1px_0_rgb(0_0_0/0.25)]"; - const glassDepth = - "glass-card bg-[linear-gradient(to_bottom,color-mix(in_srgb,var(--fg)_8%,transparent),transparent)]"; - return ( - -
- - Flat - - - Depth - - - Flat glass - - - Depth glass - -
-
- ); -} - -function FadeChips() { - const mask = - "linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent)"; - const chips = [ - "Unwatched", - "Shorts", - "Live", - "Music", - "Tech", - "Science", - "News", - "Gaming", - "Travel", - ]; - return ( - -
-
- {chips.map((c) => ( - - {c} - - ))} -
-
-
- ); -} - -function FadeCard() { - return ( - -
- {[false, true].map((fade) => ( -
-
- {fade && ( - - )} -
- - {fade ? "Faded edge" : "Hard edge (today)"} - -
- ))} -
-
- ); -} - -function FadeDialog() { - return ( - -
-
- Edit details -
-
- -
-
- - Cancel - - - Save - -
-
-
- ); -} - -export function B9() { - return ( - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/frontend/src/design-lab/batchC.tsx b/frontend/src/design-lab/batchC.tsx new file mode 100644 index 00000000..094ec1cd --- /dev/null +++ b/frontend/src/design-lab/batchC.tsx @@ -0,0 +1,269 @@ +import clsx from "clsx"; +import type { ReactNode } from "react"; +import { Maximize, Pause, Pin, SkipForward, Subtitles, Volume2, X } from "lucide-react"; +import { Btn } from "./batchA"; +import { Decision, Sample, Variant } from "./labKit"; +import { FADE_ABOVE_PANEL, FADE_BELOW_PANEL, H, LAYER, STRONG } from "./labTokens"; + +// E28 S2 · Batch C, part 2 — behaviour on touch and on a narrow window: the side panels, the player, +// the dialog conventions. These are behaviours, so each variant is a still of the moment that +// differs, with the sequence written under it. + +const ico = "w-4 h-4"; + +function Screen({ label, children }: { label: string; children: ReactNode }) { + return ( + +
+ {children} +
+
+ ); +} + +function Content({ cols = 3 }: { cols?: number }) { + return ( +
+ {Array.from({ length: cols * 3 }, (_, i) => ( +
+
+ Video {i + 1} +
+ ))} +
+ ); +} + +function FilterPanel({ pinnable }: { pinnable?: boolean }) { + return ( +
+
+ Filters + + {pinnable && ( + + + + )} + + + + +
+ {["Unwatched", "Shorts hidden", "Last 30 days", "Tech", "Science"].map((f) => ( + + {f} + + ))} +
+ ); +} + +// ── C2 · side panels on touch and on a narrow window ───────────────────────────────────────────── +export function C2() { + return ( + + + + +
+
+ +
+ + + + +
+ +
+ +
+
+
+
+ + ); +} + +// ── C3 · the player on touch ───────────────────────────────────────────────────────────────────── +function Player({ touch }: { touch: boolean }) { + const btn = touch ? "h-11 w-11" : "h-8 w-8"; + return ( +
+
+ {/* The seek bar: the visible track stays thin; on touch the hit area around it is 44 tall. */} +
+
+
+
+ {touch && ( + + )} +
+
+ {[Pause, SkipForward, Volume2, Subtitles].map((I, i) => ( + + + + ))} + 12:04 / 31:40 + + + +
+
+
+ ); +} + +export function C3() { + return ( + + + + + + + + + ); +} + +// ── C4 · dialog conventions ────────────────────────────────────────────────────────────────────── +function Field({ label, value, focused }: { label: string; value: string; focused?: boolean }) { + return ( + + ); +} + +function ShortDialog({ pinned }: { pinned: boolean }) { + const fields = ( +
+ + + + + {!pinned && ( +
+ + Cancel + + + Save + +
+ )} +
+ ); + return ( +
+
+ Edit details + + + +
+
{fields}
+ {pinned && ( +
+ + Cancel + + + Save + +
+ )} +
+ ); +} + +export function C4() { + return ( + + + + + + + + + ); +} diff --git a/frontend/src/design-lab/batchCActions.tsx b/frontend/src/design-lab/batchCActions.tsx new file mode 100644 index 00000000..7bc9ebbe --- /dev/null +++ b/frontend/src/design-lab/batchCActions.tsx @@ -0,0 +1,416 @@ +import clsx from "clsx"; +import { Bookmark, Check, ListPlus, MoreHorizontal, Play } from "lucide-react"; +import { Decision, Sample, Variant } from "./labKit"; +import { H, LAYER } from "./labTokens"; + +// E28 S2 · Batch C, part 1 — action placement: the classification table (object × action → where it +// lives). The owner delegated this to the UX lead, so it is reviewed rather than picked. It applies +// the decided guard rails (DESIGN.md "Action placement"): ONE action list per object feeds every +// trigger; the primary action and visible state stay on the object; rare, secondary and destructive +// actions go to the menu; nothing is gesture-only, because the "…" button opens the same menu. + +type Where = "rest" | "hover" | "menu"; +type Row = { + action: string; + /** mouse: visible at rest / on hover / only in the menu (the menu always lists everything) */ + mouse: Where; + /** touch card / row: visible, or reached through "…" */ + touch: "visible" | "…"; + /** what changes against today (the S2 inventory), empty when nothing does */ + change?: string; + danger?: boolean; +}; +type Group = { object: string; rows: Row[] }; + +const TABLE: Group[] = [ + { + object: "Video card (feed grid and list rows, Home shelves)", + rows: [ + { + action: "Play / continue", + mouse: "rest", + touch: "visible", + change: + "the thumbnail plays; the title is a link-styled element that ignores a click ending a text selection", + }, + { action: "Open channel", mouse: "rest", touch: "visible" }, + { + action: "Watched (toggle)", + mouse: "hover", + touch: "…", + change: "the watched / progress STATE stays visible at rest", + }, + { action: "Save (watch later)", mouse: "hover", touch: "…" }, + { + action: "Add to playlist", + mouse: "hover", + touch: "…", + change: "today hover only, not in the menu", + }, + { + action: "Download", + mouse: "menu", + touch: "…", + change: "today hover only, not in the menu", + }, + { + action: "Details (ⓘ)", + mouse: "menu", + touch: "…", + change: "today hover only, gone on touch", + }, + { + action: "Hide", + mouse: "menu", + touch: "…", + change: "leaves the hover bar (undo via the toast)", + }, + { action: "Reset state", mouse: "menu", touch: "…", change: "leaves the hover bar" }, + { action: "Pin to shelf", mouse: "menu", touch: "…" }, + { + action: "Copy title / Copy link", + mouse: "menu", + touch: "…", + change: "new (text stays selectable)", + }, + ], + }, + { + object: "Playlist item row", + rows: [ + { action: "Play", mouse: "rest", touch: "visible" }, + { + action: "Reorder (drag grip)", + mouse: "rest", + touch: "visible", + change: "the grip gets touch-action: none", + }, + { action: "Move top / up / down / bottom", mouse: "menu", touch: "…" }, + { action: "Move to… / to a recent playlist", mouse: "menu", touch: "…" }, + { action: "Pin", mouse: "menu", touch: "…" }, + { action: "Copy title / Copy link", mouse: "menu", touch: "…", change: "new" }, + { + action: "Remove from playlist", + mouse: "menu", + touch: "…", + danger: true, + change: "the X column goes; Delete key + undo toast", + }, + ], + }, + { + object: "Playlist (the rail row and the page header)", + rows: [ + { action: "Open", mouse: "rest", touch: "visible" }, + { action: "Play all", mouse: "rest", touch: "visible", change: "header" }, + { + action: "Rename", + mouse: "menu", + touch: "…", + change: "the header pencil becomes a menu item", + }, + { action: "Pin", mouse: "menu", touch: "…" }, + { + action: "Open on YouTube / Push to YouTube", + mouse: "menu", + touch: "…", + change: "from the header into the menu", + }, + { action: "Revert to YouTube", mouse: "menu", touch: "…", danger: true }, + { + action: "Delete", + mouse: "menu", + touch: "…", + danger: true, + change: "the header trash goes into the menu", + }, + ], + }, + { + object: "Channel row (Channels manager)", + rows: [ + { action: "Open the channel", mouse: "rest", touch: "visible" }, + { + action: "Priority (stepper)", + mouse: "rest", + touch: "visible", + change: "it is a value, so it stays", + }, + { action: "Tags", mouse: "rest", touch: "visible", change: "a value, stays" }, + { + action: "Hidden from feed (state)", + mouse: "rest", + touch: "visible", + change: "a badge; the toggle moves to the menu", + }, + { + action: "Hide / unhide, Backfill", + mouse: "menu", + touch: "…", + change: "from row buttons into the menu", + }, + { action: "Pin, Copy name / link", mouse: "menu", touch: "…" }, + { + action: "Reset backfill (admin), Unsubscribe", + mouse: "menu", + touch: "…", + danger: true, + change: "from row buttons into the menu", + }, + ], + }, + { + object: "Plex card (movie, show, season, episode)", + rows: [ + { action: "Open / play", mouse: "rest", touch: "visible" }, + { + action: "Watched (state)", + mouse: "rest", + touch: "visible", + change: "the badge stays; the toggle is hover + menu", + }, + { + action: "Watched (toggle)", + mouse: "hover", + touch: "…", + change: "today hover only, not in the menu", + }, + { + action: "Add to playlist", + mouse: "hover", + touch: "…", + change: "today hover only on seasons", + }, + { action: "Details", mouse: "menu", touch: "…", change: "today hover only" }, + { action: "Pin", mouse: "menu", touch: "…" }, + ], + }, + { + object: "Download row", + rows: [ + { action: "Play (library, shared)", mouse: "rest", touch: "visible" }, + { + action: "Pause / resume / retry (queue)", + mouse: "rest", + touch: "visible", + change: "the queue's primary action", + }, + { action: "Save file", mouse: "rest", touch: "visible" }, + { action: "Error details (state)", mouse: "rest", touch: "visible" }, + { + action: "Edit clip, Edit details, Share, Manage shares, Redownload, Copy source URL", + mouse: "menu", + touch: "…", + change: "six row buttons become one '…'; a menu is new here", + }, + { + action: "Cancel, Delete, Remove shared", + mouse: "menu", + touch: "…", + danger: true, + change: "from row buttons into the menu", + }, + ], + }, + { + object: "Notification row", + rows: [ + { + action: "Its link (open Downloads / Scheduler, review access…)", + mouse: "rest", + touch: "visible", + }, + { action: "Unread (state), Mark read", mouse: "rest", touch: "visible" }, + { + action: "Revert / find (activity rows)", + mouse: "menu", + touch: "…", + change: "a menu is new here", + }, + { action: "Dismiss", mouse: "hover", touch: "…" }, + { action: "Header: Mark all read", mouse: "rest", touch: "visible" }, + { + action: "Header: Clear everything", + mouse: "menu", + touch: "…", + danger: true, + change: "into the header's '…'", + }, + ], + }, + { + object: "Scheduler job row and page", + rows: [ + { action: "Run now", mouse: "rest", touch: "visible" }, + { action: "Interval (value, click to edit)", mouse: "rest", touch: "visible" }, + { action: "Page: Run all, Pause / resume", mouse: "rest", touch: "visible" }, + { + action: "Page: Purge discovery", + mouse: "menu", + touch: "…", + danger: true, + change: "into the page's '…'", + }, + ], + }, +]; + +const BACKGROUND: [string, string][] = [ + ["Feed", "Sort · View (grid / list) · Watch state · Shorts on / off"], + ["Plex browse", "Sort · View"], + ["Channels", "Sort · Sync subscriptions · Backfill everything"], + ["Playlists", "New playlist · Sync with YouTube"], + ["Downloads", "Queue / Library / Shared (the tabs)"], + ["Notifications", "Mark all read"], +]; + +const MOUSE: Record = { + rest: "● at rest", + hover: "◐ on hover", + menu: "☰ menu", +}; + +function Table() { + return ( +
+ + + + + + + + + + {TABLE.map((g) => ( + + + + + {g.rows.map((r) => ( + + + + + + + ))} + + ))} +
ActionMouseTouchChange against today
+ {g.object} +
+ {r.action} + {r.danger && " ⚠"} + {MOUSE[r.mouse]} + {r.touch === "visible" ? "● visible" : "… menu"} + {r.change}
+
+ ); +} + +function Card({ touch }: { touch: boolean }) { + const act = clsx( + "grid place-items-center rounded-lg bg-black/55 text-white", + touch ? "h-11 w-11" : H.iconSm, + ); + return ( +
+
+ + + + + 12:04 left + + {!touch && ( + + + + + + + + + + + + + + + )} + + + + + +
+
+
+
+ Building a home lab on a budget: Proxmox, ZFS and a cheap NAS +
+
Techno Tim · 2 days ago
+
+ {touch && ( + + + + )} +
+
+ ); +} + +export function C1() { + return ( + + +
+ + + + + + +
+
+ + + + {BACKGROUND.map(([page, items]) => ( + + + + + ))} + +
{page}{items}
+
+
+ + + + ); +} diff --git a/frontend/src/design-lab/labTokens.ts b/frontend/src/design-lab/labTokens.ts new file mode 100644 index 00000000..04db6fe4 --- /dev/null +++ b/frontend/src/design-lab/labTokens.ts @@ -0,0 +1,36 @@ +// The decided values the lab's reference renderings share (batches A and B, 2026-09-24). They are +// the lab's stand-ins until S3 moves each into index.css / the ui/ primitives; then these go. + +/** B1 compact ladder: explicit heights, so one toolbar row lines up at 28. */ +export const H = { + md: "h-8 px-3 text-sm gap-1.5", + sm: "h-7 px-2.5 text-sm gap-1.5", + xs: "h-6 px-2 text-xs gap-1", + iconSm: "w-7 h-7", + icon: "w-8 h-8", + field: "h-8", + bar: "h-7", +} as const; + +/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode. */ +export const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))"; + +/** B3 state layers: the text colour at 10% (hover) / 16% (pressed) over whatever is underneath; + * a filled control mixes its own text colour into the fill at 14% / 24%. */ +export const LAYER = + "hover:bg-[color-mix(in_srgb,var(--fg)_10%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]"; +export const FILLED = + "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))]"; +export const DANGER = + "bg-danger text-danger-fg hover:bg-[color-mix(in_srgb,var(--danger-fg)_14%,var(--danger))] active:bg-[color-mix(in_srgb,var(--danger-fg)_24%,var(--danger))]"; + +/** B9: the header + fade edge — a crisp band and a 1rem gradient of the colour it sits on. Literal + * strings per colour: Tailwind only generates classes it can read whole. */ +export const FADE_BELOW = + "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']"; +export const FADE_BELOW_PANEL = + "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--surface),transparent)] after:content-['']"; +export const FADE_ABOVE_PANEL = + "before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--surface),transparent)] before:content-['']"; +export const FADE_EDGES_X = + "linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent)"; From f4bc60d6591886586947e9e808331a76ae2a5d62 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 24 Sep 2026 03:27:26 +0200 Subject: [PATCH 005/137] feat(design): batch C recorded, the player row open as C5 (E28 S2d) C1-C4 as proposed: the action classification, overlay-drawer side panels, touch-first players, dialog focus by purpose with a pinned footer. The players were then played live at tablet size, which found the Plex control row running off-screen below 1024; how it fits is open as C5. CLAUDE.md now points at DESIGN.md. --- CLAUDE.md | 4 +- docs/DESIGN.md | 94 ++++-- frontend/src/design-lab/DesignLab.tsx | 22 +- frontend/src/design-lab/batchC.tsx | 367 +++++++++++++--------- frontend/src/design-lab/batchCActions.tsx | 65 ++-- 5 files changed, 318 insertions(+), 234 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c256bd4f..846ffe0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,9 @@ how it gets built. Concretely: behind each option and the weight of the decision. These decision points are where the product owner engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one. - **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to - choose from before building. + choose from before building. How the UI looks and behaves is decided in + [`docs/DESIGN.md`](docs/DESIGN.md) (its variants render in the dev-only `/design-lab`): build to it, + and change it only through its decision log. - **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real arguments, push back or propose an alternative — don't comply blindly. - **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b35442d8..218d2ef7 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -4,8 +4,9 @@ This is the one written source of truth for the UI: principles, tokens, the cano and the interaction rules. Screens are measured against it. A pattern that has more than one look in the code is either a decision still open here (marked **Pending**) or a violation to fix. -**Status:** being written in E28. Chapters marked _Decided_ are in force. Chapters marked **Pending** are -open decisions, each rendered side by side in the design lab (below) and settled by the product owner. +**Status:** written in E28. Chapters marked _Decided_ are in force. One item is still **Pending** (C5, the +Plex control row on a narrow screen), rendered side by side in the design lab (below) and settled by the +product owner. ## How this document changes @@ -58,7 +59,12 @@ style guide. (A3, under Controls). [HIG, WCAG 2.5.8] - **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page when an input gets focus. -- **Below 768px** (a narrow window, split screen): what replaces the side rails, and how a side panel opens on touch, is **Pending (C2)**. The Plex player on touch is **Pending (C3)**. +- **Side panels (C2):** on touch, and at any width below 768 (a narrow window, split screen), the rail tab + or the band's "Filters · n" button slides the panel **over** the content with a scrim; a tap outside, Esc + or Back closes it, and the content never reflows. Pinning is its own button in the panel header, offered + only where a pinned panel leaves the content room (≥ 1024 wide). The mouse keeps hover-to-peek and + click-to-pin. A filter is never unreachable at any supported width. [Nielsen #7, WCAG 1.4.10] +- **Players on touch (C3):** see [Players](#players). ## Tokens @@ -159,8 +165,17 @@ Which actions are visible and which live in a menu. - **What stays visible:** the object's primary action (play or open) and any state the user needs to see (watched, saved, downloading). **What goes into the menu:** rare, secondary and destructive actions. Destructive ones keep their confirmation. [Nielsen #8, #5] -- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu. _Which actions stay - visible per object is the classification table, **Pending review (C1)**._ +- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu. +- **The classification (C1)** says, per object, what is visible at rest, on hover and on touch; its full + table is the lab's C1 reference, and S3 turns it into each object's one action list in code. In short: + - A video or Plex card: play and its state (watched, progress) at rest; the everyday toggles (watched, + save, add to playlist) and "…" on hover; on touch the state and a 44px "…" beside the title. Details, + download, hide and reset live in the menu. + - A row (playlist item, channel, download): its primary action and its values (priority, tags, error) + stay visible; destructive and rare row buttons (unsubscribe, delete, cancel, remove) move into the menu. + A playlist item's X column goes: Delete key plus an undo toast. + - Downloads, Notifications and the Scheduler get a menu (they have none today); a page's destructive + header action ("Clear everything", "Purge discovery") goes into the header's "…". - **Background menu:** a right-click on EMPTY content area (not over text, inputs, links or media) may open a page menu. It only mirrors page-scope actions that already exist in the toolbar (sort, view, quick filters, select all); it adds no new function. Shift+right-click falls through to the browser's own menu. @@ -199,10 +214,27 @@ Wherever there is text, the user can select it and copy it, and no click action - **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected. [WCAG 2.3.3] -## Dialogs — **Pending (C4)** +## Players — _Decided_ (C3), one item **Pending (C5)** -Where focus lands when a dialog opens (by purpose, APG), and whether the footer stays pinned while the body -scrolls. Open in the lab. +- **A tap on the Plex video only shows or hides the controls** (C3); play/pause is the button. The mouse keeps + click-to-pause. Measured live: today a tap with the bar hidden both reveals it and pauses. [Nielsen #3] +- **The hide timer starts with playback,** not with the first touch (today, on touch, the bar stays up until + the first tap however long the video plays). +- **The seek bar:** the track stays thin inside a 2.75rem (44px) hit area with `touch-action: none`, so a finger + drag scrubs instead of panning; the scrub head is always visible on touch. Controls are 44 on touch. [WCAG 2.5.8] +- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the + screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured + live). [WCAG 1.4.13] +- **The control row on a narrow screen — Pending (C5):** today it runs off the right edge below 1024 (five + controls unreachable at 767 and at 820 portrait). Overflow into a "⋯ More" menu vs wrapping onto a second + line is open in the lab. + +## Dialogs — _Decided_ (C4) + +- **Initial focus by purpose** (a `Modal` `initialFocus` rule): a form focuses its first field, a confirm its + least destructive button, anything else Close. [WAI-ARIA APG] +- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at both edges, so the + primary action stays on screen when the on-screen keyboard takes half of it. [Nielsen #3, WCAG 2.4.11] ## Copy @@ -221,24 +253,28 @@ scrolls. Open in the lab. ## Decision log -| Date | Decision | Chosen | Over | Why | -| ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait | -| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has | -| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function | -| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows | -| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" | -| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags | -| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers | -| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass | -| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context | -| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites | -| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 | -| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 | -| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card | -| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content | -| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift | -| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device | -| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost | -| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck | -| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls | +| Date | Decision | Chosen | Over | Why | +| ---------- | --------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait | +| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has | +| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function | +| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows | +| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" | +| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags | +| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers | +| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass | +| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context | +| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites | +| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 | +| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 | +| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card | +| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content | +| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift | +| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device | +| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost | +| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck | +| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls | +| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function | +| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid | +| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap | +| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up | diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx index d4e756a5..082fe312 100644 --- a/frontend/src/design-lab/DesignLab.tsx +++ b/frontend/src/design-lab/DesignLab.tsx @@ -3,16 +3,10 @@ import clsx from "clsx"; import { SCHEMES, type Scheme } from "../lib/theme"; import { BatchA } from "./batchA"; import { BatchB } from "./batchB"; -import { C1 } from "./batchCActions"; -import { C2, C3, C4 } from "./batchC"; +import { BatchC, C5 } from "./batchC"; // The open decisions, in the order they are asked. The decided ones render below them as reference. -const OPEN = [ - { id: "c1", code: "C1", title: "Action placement", render: C1 }, - { id: "c2", code: "C2", title: "Side panels", render: C2 }, - { id: "c3", code: "C3", title: "Player touch", render: C3 }, - { id: "c4", code: "C4", title: "Dialogs", render: C4 }, -] as const; +const OPEN = [{ id: "c5", code: "C5", title: "Player row", render: C5 }] as const; // The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits // OUTSIDE , so it owns the theme attributes on itself, through the same data-theme / @@ -114,12 +108,15 @@ export default function DesignLab() { B ✓ + + C ✓ +
-

Batch C · behaviour and device

+

Open · the player row on a narrow screen

Each decision shows its variants with the app's real CSS. The framed one is the recommendation. Resize the window to tablet size to see them there too. @@ -142,6 +139,13 @@ export default function DesignLab() {

+
+

Decided · batch C, behaviour and device

+

+ C1 is the action classification S3 builds each object's one action list from. +

+
+
); diff --git a/frontend/src/design-lab/batchC.tsx b/frontend/src/design-lab/batchC.tsx index 094ec1cd..5189e732 100644 --- a/frontend/src/design-lab/batchC.tsx +++ b/frontend/src/design-lab/batchC.tsx @@ -1,13 +1,34 @@ import clsx from "clsx"; import type { ReactNode } from "react"; -import { Maximize, Pause, Pin, SkipForward, Subtitles, Volume2, X } from "lucide-react"; +import { + Activity, + Download, + FastForward, + Info, + Keyboard, + Maximize, + MoreHorizontal, + Pause, + Pin, + Rewind, + RotateCcw, + Settings, + SkipBack, + SkipForward, + Square, + Subtitles, + Volume2, + X, +} from "lucide-react"; import { Btn } from "./batchA"; -import { Decision, Sample, Variant } from "./labKit"; +import { C1 } from "./batchCActions"; +import { Decision, Reference, Sample, Variant } from "./labKit"; import { FADE_ABOVE_PANEL, FADE_BELOW_PANEL, H, LAYER, STRONG } from "./labTokens"; -// E28 S2 · Batch C, part 2 — behaviour on touch and on a narrow window: the side panels, the player, -// the dialog conventions. These are behaviours, so each variant is a still of the moment that -// differs, with the sequence written under it. +// E28 S2 · Batch C — behaviour on touch and on a narrow window. C1–C4 are DECIDED (owner, 2026-09-24: +// all as proposed) and stay as references. C5 is open: the live player pass (E28 S2d) found the Plex +// control row running off-screen below 1024, which C3's 44px buttons make worse, so how the row +// fits is asked on its own. const ico = "w-4 h-4"; @@ -21,13 +42,10 @@ function Screen({ label, children }: { label: string; children: ReactNode }) { ); } -function Content({ cols = 3 }: { cols?: number }) { +function Content() { return ( -
- {Array.from({ length: cols * 3 }, (_, i) => ( +
+ {Array.from({ length: 9 }, (_, i) => (
Video {i + 1} @@ -37,19 +55,15 @@ function Content({ cols = 3 }: { cols?: number }) { ); } -function FilterPanel({ pinnable }: { pinnable?: boolean }) { +function FilterPanel() { return (
Filters - {pinnable && ( - - - - )} + + + @@ -65,106 +79,61 @@ function FilterPanel({ pinnable }: { pinnable?: boolean }) { } // ── C2 · side panels on touch and on a narrow window ───────────────────────────────────────────── -export function C2() { +function C2() { return ( - - - - -
-
- -
- - - - -
- -
- -
-
-
-
- + + +
+
+ +
+ + ); } -// ── C3 · the player on touch ───────────────────────────────────────────────────────────────────── -function Player({ touch }: { touch: boolean }) { - const btn = touch ? "h-11 w-11" : "h-8 w-8"; +// ── C3 · the players on touch ──────────────────────────────────────────────────────────────────── +function C3() { + const btn = "grid h-11 w-11 place-items-center rounded-full"; return ( -
-
- {/* The seek bar: the visible track stays thin; on touch the hit area around it is 44 tall. */} -
-
-
-
- {touch && ( - - )} -
-
- {[Pause, SkipForward, Volume2, Subtitles].map((I, i) => ( - - - - ))} - 12:04 / 31:40 - - - -
-
-
- ); -} - -export function C3() { - return ( - - - - - - - - +
+
+ {/* The visible track stays thin; the dashed box is the 44-tall hit area around it. */} +
+
+
+
+ +
+
+ {[Pause, SkipForward, Volume2, Subtitles].map((I, i) => ( + + + + ))} + 12:04 / 31:40 + + + +
+
+
+ ); } @@ -187,46 +156,34 @@ function Field({ label, value, focused }: { label: string; value: string; focuse ); } -function ShortDialog({ pinned }: { pinned: boolean }) { - const fields = ( -
- - - - - {!pinned && ( -
- - Cancel - - - Save - -
- )} -
- ); +function C4() { return ( -
-
- Edit details - +
+
- - -
-
{fields}
- {pinned && ( + Edit details + + + +
+
+
+ + + + +
+
Cancel @@ -235,34 +192,132 @@ function ShortDialog({ pinned }: { pinned: boolean }) { Save
- )} +
+ + ); +} + +export function BatchC() { + return ( +
+ +
+ + + +
); } -export function C4() { +// ── C5 · the Plex control row on a narrow screen ───────────────────────────────────────────────── +// Every control of today's row, in the order it sits; `keep` = stays in the row under variant A. +const CONTROLS: { icon: typeof Pause; label: string; keep: boolean }[] = [ + { icon: Rewind, label: "Back 10 s", keep: true }, + { icon: Pause, label: "Play / pause", keep: true }, + { icon: FastForward, label: "Forward 10 s", keep: true }, + { icon: RotateCcw, label: "Restart from the beginning", keep: false }, + { icon: Square, label: "Stop", keep: false }, + { icon: SkipBack, label: "Previous episode", keep: true }, + { icon: SkipForward, label: "Next episode", keep: true }, + { icon: Volume2, label: "Mute", keep: true }, + { icon: Subtitles, label: "Audio & subtitles", keep: true }, + { icon: Settings, label: "Settings", keep: false }, + { icon: Activity, label: "Stats for nerds", keep: false }, + { icon: Info, label: "Media info", keep: false }, + { icon: Keyboard, label: "Keyboard shortcuts", keep: false }, + { icon: Download, label: "Download original file", keep: false }, +]; + +const ctl = "grid h-11 w-11 shrink-0 place-items-center rounded-full"; + +function Frame({ children }: { children: ReactNode }) { + // Stands in for a portrait tablet: the frame is the screen width, narrower than the full row. + return ( +
+
+
+
+
+
+ +
+ {children} +
+
+ ); +} + +function OverflowRow() { + return ( + +
+ {CONTROLS.filter((c) => c.keep).map((c) => ( + + + + ))} + 12:04 / 42:21 + + + + + + +
+
+ {CONTROLS.filter((c) => !c.keep).map((c) => ( + + + {c.label} + + ))} +
+ + ); +} + +function WrappedRow() { + return ( + +
+ {CONTROLS.map((c) => ( + + + + ))} + + + + 12:04 / 42:21 +
+ + ); +} + +export function C5() { return ( - + - + ); diff --git a/frontend/src/design-lab/batchCActions.tsx b/frontend/src/design-lab/batchCActions.tsx index 7bc9ebbe..1fd014ed 100644 --- a/frontend/src/design-lab/batchCActions.tsx +++ b/frontend/src/design-lab/batchCActions.tsx @@ -1,10 +1,11 @@ import clsx from "clsx"; import { Bookmark, Check, ListPlus, MoreHorizontal, Play } from "lucide-react"; -import { Decision, Sample, Variant } from "./labKit"; +import { Reference, Sample } from "./labKit"; import { H, LAYER } from "./labTokens"; // E28 S2 · Batch C, part 1 — action placement: the classification table (object × action → where it -// lives). The owner delegated this to the UX lead, so it is reviewed rather than picked. It applies +// lives), DECIDED (owner, 2026-09-24: accepted as proposed). It is the source S3 builds each object's +// ONE action list from. It applies // the decided guard rails (DESIGN.md "Action placement"): ONE action list per object feeds every // trigger; the primary action and visible state stay on the object; rare, secondary and destructive // actions go to the menu; nothing is gesture-only, because the "…" button opens the same menu. @@ -370,47 +371,33 @@ function Card({ touch }: { touch: boolean }) { export function C1() { return ( - - -
- - - - - - -
-
- -
- - {BACKGROUND.map(([page, items]) => ( - - - - - ))} - -
{page}{items}
- -
- +
+ + + + + + + +
+ + {BACKGROUND.map(([page, items]) => ( + + + + + ))} + +
{page}{items}
+
- + + ); } From 3f5634a44f33c9f4c5a1d42c88ef1e8846b517bb Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 24 Sep 2026 04:05:09 +0200 Subject: [PATCH 006/137] feat(design): C5 redrawn around the owner's reworked Plex controls Stop, Restart and the 10 s skips leave the row, Back restarts or steps to the previous episode, Next shows only when there is a next item, and the card carries Resume + From the start. The lab asks back two points: how Back tells restart from previous, and a touch path for the 10 s skip. --- frontend/src/design-lab/DesignLab.tsx | 4 +- frontend/src/design-lab/batchC.tsx | 194 +++++++++++++++++--------- 2 files changed, 128 insertions(+), 70 deletions(-) diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx index 082fe312..8420481a 100644 --- a/frontend/src/design-lab/DesignLab.tsx +++ b/frontend/src/design-lab/DesignLab.tsx @@ -6,7 +6,7 @@ import { BatchB } from "./batchB"; import { BatchC, C5 } from "./batchC"; // The open decisions, in the order they are asked. The decided ones render below them as reference. -const OPEN = [{ id: "c5", code: "C5", title: "Player row", render: C5 }] as const; +const OPEN = [{ id: "c5", code: "C5", title: "Player controls", render: C5 }] as const; // The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits // OUTSIDE , so it owns the theme attributes on itself, through the same data-theme / @@ -116,7 +116,7 @@ export default function DesignLab() {
-

Open · the player row on a narrow screen

+

Open · the Plex player controls

Each decision shows its variants with the app's real CSS. The framed one is the recommendation. Resize the window to tablet size to see them there too. diff --git a/frontend/src/design-lab/batchC.tsx b/frontend/src/design-lab/batchC.tsx index 5189e732..d220d9a1 100644 --- a/frontend/src/design-lab/batchC.tsx +++ b/frontend/src/design-lab/batchC.tsx @@ -9,13 +9,13 @@ import { Maximize, MoreHorizontal, Pause, + Play, Pin, Rewind, RotateCcw, Settings, SkipBack, SkipForward, - Square, Subtitles, Volume2, X, @@ -27,8 +27,8 @@ import { FADE_ABOVE_PANEL, FADE_BELOW_PANEL, H, LAYER, STRONG } from "./labToken // E28 S2 · Batch C — behaviour on touch and on a narrow window. C1–C4 are DECIDED (owner, 2026-09-24: // all as proposed) and stay as references. C5 is open: the live player pass (E28 S2d) found the Plex -// control row running off-screen below 1024, which C3's 44px buttons make worse, so how the row -// fits is asked on its own. +// control row running off-screen below 1024; the owner answered with a reworked control set, and +// two of its points are asked back. const ico = "w-4 h-4"; @@ -210,31 +210,45 @@ export function BatchC() { ); } -// ── C5 · the Plex control row on a narrow screen ───────────────────────────────────────────────── -// Every control of today's row, in the order it sits; `keep` = stays in the row under variant A. -const CONTROLS: { icon: typeof Pause; label: string; keep: boolean }[] = [ - { icon: Rewind, label: "Back 10 s", keep: true }, - { icon: Pause, label: "Play / pause", keep: true }, - { icon: FastForward, label: "Forward 10 s", keep: true }, - { icon: RotateCcw, label: "Restart from the beginning", keep: false }, - { icon: Square, label: "Stop", keep: false }, - { icon: SkipBack, label: "Previous episode", keep: true }, - { icon: SkipForward, label: "Next episode", keep: true }, - { icon: Volume2, label: "Mute", keep: true }, - { icon: Subtitles, label: "Audio & subtitles", keep: true }, - { icon: Settings, label: "Settings", keep: false }, - { icon: Activity, label: "Stats for nerds", keep: false }, - { icon: Info, label: "Media info", keep: false }, - { icon: Keyboard, label: "Keyboard shortcuts", keep: false }, - { icon: Download, label: "Download original file", keep: false }, +// ── C5 · the Plex player's controls, reworked (the owner's model, 2026-09-24) ───────────────────── +// The owner's direction: Stop, Restart and ±10 s leave the row; Back restarts or steps to the previous +// episode; Next exists only when there is a next item; restarting moves onto the card. What is left +// fits a portrait tablet in one row, with a fixed "⋯" for the rare tools. + +const ROW: { icon: typeof Pause; label: string }[] = [ + { icon: Pause, label: "Play / pause" }, + { icon: SkipBack, label: "Back: restart, then the previous episode" }, + { icon: SkipForward, label: "Next episode (only when there is one)" }, + { icon: Volume2, label: "Mute" }, +]; +const MORE: { icon: typeof Pause; label: string }[] = [ + { icon: Settings, label: "Player settings" }, + { icon: Info, label: "Media info" }, + { icon: Activity, label: "Stats for nerds" }, + { icon: Keyboard, label: "Keyboard shortcuts" }, + { icon: Download, label: "Download original file" }, ]; const ctl = "grid h-11 w-11 shrink-0 place-items-center rounded-full"; -function Frame({ children }: { children: ReactNode }) { - // Stands in for a portrait tablet: the frame is the screen width, narrower than the full row. +function PlayerRow({ touchZones }: { touchZones: boolean }) { return (

+ {touchZones && ( + // Double-tap zones: the left and right thirds of the picture skip ∓10 s on touch. +
+ + + double-tap −10 s + + + + + double-tap +10 s + + +
+ )}
@@ -242,56 +256,99 @@ function Frame({ children }: { children: ReactNode }) {
- {children} +
+ {ROW.map((c) => ( + + + + ))} + 12:04 / 42:21 + + + + + + + + + +
+
+ {MORE.map((c) => ( + + + {c.label} + + ))} +
); } -function OverflowRow() { +function MediaCard({ state }: { state: "new" | "started" | "touch" }) { + const pill = "inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-xs font-medium"; return ( - -
- {CONTROLS.filter((c) => c.keep).map((c) => ( - - +
+
+ {state !== "new" && ( + + - ))} - 12:04 / 42:21 - - - - - - -
-
- {CONTROLS.filter((c) => !c.keep).map((c) => ( - - - {c.label} + )} + {state === "new" && ( + + + + - ))} + )} + {state === "started" && ( + + + Resume 14:02 + + + From the start + + + )}
- +
+
+
Darkman III
+
+ {state === "new" ? "1996 · not started" : "1996 · 14 min in"} +
+
+ {state === "touch" && ( + + + + )} +
+
); } -function WrappedRow() { +function Cards() { return ( - -
- {CONTROLS.map((c) => ( - - - - ))} - - - - 12:04 / 42:21 -
- +
+ + + + + + + + + +
); } @@ -300,24 +357,25 @@ export function C5() { - + + - + ); From 9c4a999e545e3c69ccf76f1f0629f1471787792a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 24 Sep 2026 04:12:19 +0200 Subject: [PATCH 007/137] feat(design): C5 recorded, every E28 decision taken The owner's lean Plex control set with both refinements (Back by position, a touch double-tap for the 10 s skip) goes into DESIGN.md's Players chapter and the log. The lab is now the reference only. --- docs/DESIGN.md | 81 +++++++++++++++------------ frontend/src/design-lab/DesignLab.tsx | 23 ++------ frontend/src/design-lab/batchC.tsx | 74 ++++++++++-------------- frontend/src/design-lab/labKit.tsx | 81 ++------------------------- 4 files changed, 85 insertions(+), 174 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 218d2ef7..90ee3714 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -4,9 +4,9 @@ This is the one written source of truth for the UI: principles, tokens, the cano and the interaction rules. Screens are measured against it. A pattern that has more than one look in the code is either a decision still open here (marked **Pending**) or a violation to fix. -**Status:** written in E28. Chapters marked _Decided_ are in force. One item is still **Pending** (C5, the -Plex control row on a narrow screen), rendered side by side in the design lab (below) and settled by the -product owner. +**Status:** written in E28; every chapter is _Decided_ and in force. The design lab (below) renders each rule's +chosen form. A new question opens a **Pending** item here, with its variants in the lab, until the product +owner settles it. ## How this document changes @@ -214,20 +214,30 @@ Wherever there is text, the user can select it and copy it, and no click action - **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected. [WCAG 2.3.3] -## Players — _Decided_ (C3), one item **Pending (C5)** +## Players — _Decided_ (C3, C5) - **A tap on the Plex video only shows or hides the controls** (C3); play/pause is the button. The mouse keeps - click-to-pause. Measured live: today a tap with the bar hidden both reveals it and pauses. [Nielsen #3] -- **The hide timer starts with playback,** not with the first touch (today, on touch, the bar stays up until - the first tap however long the video plays). + click-to-pause. Measured live: before this, a tap with the bar hidden both revealed it and paused. [Nielsen #3] +- **The hide timer starts with playback,** not with the first touch (before, on touch, the bar stayed up until + the first tap however long the video played). - **The seek bar:** the track stays thin inside a 2.75rem (44px) hit area with `touch-action: none`, so a finger drag scrubs instead of panning; the scrub head is always visible on touch. Controls are 44 on touch. [WCAG 2.5.8] +- **The Plex control set (C5, the owner's model):** one row that fits a portrait tablet. + - No Stop (closing the player is stopping), no Restart, no ±10 s buttons. + - **Back:** more than 3 s in → back to the start; within the first 3 s → the previous item, at its resume + point if it has one, else its start. The first item has only the restart. + - **Next** shows only when there is a next item (an episode, a playlist queue). It starts that item at its + resume point and leaves one behind on the current item, which does not count as watched unless it is + already inside the finish margin (the credits). + - **±10 s:** the keyboard keeps ← / →; on touch a double-tap on the left or right third of the picture skips + ∓10 s, while a single tap still only shows the controls. Touch never loses a function. [WCAG 2.5.1] + - The row: play/pause, Back, Next, mute, the time, audio & subtitles, a fixed **⋯** (player settings, media + info, stats, keyboard shortcuts, download the original file), fullscreen. +- **Restarting lives on the media card** (C5): not started → Play; started → **Resume** (primary) + **From + the start**. On touch a tap resumes and the card's "…" menu has From the start. - **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured live). [WCAG 1.4.13] -- **The control row on a narrow screen — Pending (C5):** today it runs off the right edge below 1024 (five - controls unreachable at 767 and at 820 portrait). Overflow into a "⋯ More" menu vs wrapping onto a second - line is open in the lab. ## Dialogs — _Decided_ (C4) @@ -253,28 +263,29 @@ Wherever there is text, the user can select it and copy it, and no click action ## Decision log -| Date | Decision | Chosen | Over | Why | -| ---------- | --------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait | -| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has | -| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function | -| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows | -| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" | -| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags | -| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers | -| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass | -| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context | -| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites | -| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 | -| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 | -| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card | -| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content | -| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift | -| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device | -| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost | -| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck | -| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls | -| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function | -| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid | -| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap | -| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up | +| Date | Decision | Chosen | Over | Why | +| ---------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait | +| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has | +| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function | +| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows | +| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" | +| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags | +| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers | +| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass | +| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context | +| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites | +| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 | +| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 | +| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card | +| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content | +| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift | +| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device | +| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost | +| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck | +| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls | +| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function | +| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid | +| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap | +| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up | +| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip | diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx index 8420481a..c28b1186 100644 --- a/frontend/src/design-lab/DesignLab.tsx +++ b/frontend/src/design-lab/DesignLab.tsx @@ -3,10 +3,7 @@ import clsx from "clsx"; import { SCHEMES, type Scheme } from "../lib/theme"; import { BatchA } from "./batchA"; import { BatchB } from "./batchB"; -import { BatchC, C5 } from "./batchC"; - -// The open decisions, in the order they are asked. The decided ones render below them as reference. -const OPEN = [{ id: "c5", code: "C5", title: "Player controls", render: C5 }] as const; +import { BatchC } from "./batchC"; // The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits // OUTSIDE , so it owns the theme attributes on itself, through the same data-theme / @@ -93,15 +90,6 @@ export default function DesignLab() { />
+ ); } -// ── C5 · the Plex player's controls, reworked (the owner's model, 2026-09-24) ───────────────────── -// The owner's direction: Stop, Restart and ±10 s leave the row; Back restarts or steps to the previous -// episode; Next exists only when there is a next item; restarting moves onto the card. What is left -// fits a portrait tablet in one row, with a fixed "⋯" for the rare tools. +// ── C5 · the Plex player's controls (the owner's model, 2026-09-24) ────────────────────────────── +// What is left after Stop, Restart and ±10 s leave the row fits a portrait tablet in one row, with a +// fixed "⋯" for the rare tools; restarting moves onto the card. const ROW: { icon: typeof Pause; label: string }[] = [ { icon: Pause, label: "Play / pause" }, @@ -231,24 +230,22 @@ const MORE: { icon: typeof Pause; label: string }[] = [ const ctl = "grid h-11 w-11 shrink-0 place-items-center rounded-full"; -function PlayerRow({ touchZones }: { touchZones: boolean }) { +function PlayerRow() { return (
- {touchZones && ( - // Double-tap zones: the left and right thirds of the picture skip ∓10 s on touch. -
- - - double-tap −10 s - + {/* Double-tap zones: the left and right thirds of the picture skip ∓10 s on touch. */} +
+ + + double-tap −10 s - - - double-tap +10 s - + + + + double-tap +10 s -
- )} +
+
@@ -352,31 +349,20 @@ function Cards() { ); } -export function C5() { +function C5() { return ( - - - +
+
+ +
- - - - - +
+ ); } diff --git a/frontend/src/design-lab/labKit.tsx b/frontend/src/design-lab/labKit.tsx index 4b08758a..050b282c 100644 --- a/frontend/src/design-lab/labKit.tsx +++ b/frontend/src/design-lab/labKit.tsx @@ -1,84 +1,13 @@ import { useSyncExternalStore, type ReactNode } from "react"; import clsx from "clsx"; -// Building blocks of the design lab. A lab page asks the owner ONE question per : two or -// three s rendered side by side with the app's real CSS, the recommended one marked and its -// one-line reason (the rule it rests on) printed under it. Everything is static markup on purpose — -// what is being decided is the look, and a variant that only exists here must not need app state. - -export function Decision({ - id, - code, - title, - question, - now, - children, -}: { - id: string; - code: string; - title: string; - question: string; - /** What the app does today, measured (the E28 S1 inventory). */ - now: ReactNode; - children: ReactNode; -}) { - return ( -
-
-
- {code} -

{title}

-
-

{question}

-

- Today: - {now} -

-
-
{children}
-
- ); -} - -export function Variant({ - letter, - name, - recommended = false, - why, - children, -}: { - letter: string; - name: string; - recommended?: boolean; - why: string; - children: ReactNode; -}) { - return ( -
-
- - {letter} - -

{name}

- {recommended && ( - - Recommended - - )} -
-
{children}
-

{why}

-
- ); -} +// Building blocks of the design lab. Every E28 decision is taken, so the lab is the reference half +// only: each is the chosen rendering with the rule it became. A new design round brings +// back the side-by-side / pair (E28 S2 history) for its open questions. Everything +// is static markup on purpose: a rendering that only exists here must not need app state. /** A decision already taken: the chosen rendering only, with the rule it became. The lab's - * reference half — the living style guide the open decisions sit on top of. */ + * reference half — the living style guide. */ export function Reference({ id, code, From ad136c3fe8fc64883b16af8026e2e7c8fa7cafee Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 24 Sep 2026 04:33:13 +0200 Subject: [PATCH 008/137] feat(design): the scheme set, the colour contract and the token utilities (E28 S3a) Four schemes (Midnight; Forest cyan, Slate fuchsia, Matrix lime), YouTube and Starship retired with no data migration: normalizeTheme maps a stored retired scheme to Midnight. A vitest contract measures every scheme x mode (text 4.5:1, control edge 3:1, status text, the accent's 30-degree hue gap); it caught light-mode danger at 4.43:1, now red-700. The state layers, the strong edge, the touch target and the fades are index.css tokens and utilities the lab now reads. Backdrops recoloured to the new accents, with a grey overlay grain that actually breaks the banding (longest flat run 15-26px -> 4-8px). The glass tuner is retired. --- docs/DESIGN.md | 13 + frontend/public/backdrops/forest.svg | 8 +- frontend/public/backdrops/light/forest.svg | 8 +- frontend/public/backdrops/light/matrix.svg | 10 +- frontend/public/backdrops/light/midnight.svg | 4 +- frontend/public/backdrops/light/slate.svg | 14 +- frontend/public/backdrops/light/starship.svg | 18 - frontend/public/backdrops/light/youtube.svg | 18 - frontend/public/backdrops/matrix.svg | 10 +- frontend/public/backdrops/midnight.svg | 4 +- frontend/public/backdrops/slate.svg | 14 +- frontend/public/backdrops/starship.svg | 18 - frontend/public/backdrops/youtube.svg | 18 - frontend/src/App.tsx | 3 - frontend/src/components/GlassTuner.tsx | 465 ------------------ frontend/src/components/PrefsProvider.tsx | 6 +- frontend/src/components/SettingsPanel.tsx | 10 - frontend/src/design-lab/batchB.tsx | 22 +- frontend/src/design-lab/labTokens.ts | 35 +- frontend/src/i18n/locales/en/settings.json | 57 --- frontend/src/i18n/locales/hu/settings.json | 57 --- frontend/src/index.css | 205 ++++---- .../labColor.ts => lib/colorMath.ts} | 18 +- frontend/src/lib/theme.contract.test.ts | 76 +++ frontend/src/lib/theme.test.ts | 30 ++ frontend/src/lib/theme.ts | 36 +- 26 files changed, 326 insertions(+), 851 deletions(-) delete mode 100644 frontend/public/backdrops/light/starship.svg delete mode 100644 frontend/public/backdrops/light/youtube.svg delete mode 100644 frontend/public/backdrops/starship.svg delete mode 100644 frontend/public/backdrops/youtube.svg delete mode 100644 frontend/src/components/GlassTuner.tsx rename frontend/src/{design-lab/labColor.ts => lib/colorMath.ts} (61%) create mode 100644 frontend/src/lib/theme.contract.test.ts create mode 100644 frontend/src/lib/theme.test.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 90ee3714..17c48a41 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -77,10 +77,22 @@ style guide. `--danger`, `--warn` and `--success`, and passes 4.5:1 both as text on `--bg` and under `--accent-fg` on the fill, in dark and light. A primary button must never read as a destructive one. A new scheme is measured against this before it ships (the lab's B2 tiles do the arithmetic). [WCAG 1.4.1, 1.4.3] +- **Held in code:** `frontend/src/lib/theme.contract.test.ts` measures every scheme × mode in `index.css` + against the two rules above (text 4.5:1, a control edge 3:1, the status colours as text, the accent's hue + gap). A scheme or a retuned colour that breaks them does not pass the gate. +- **No per-user colour tuning.** The schemes are the only colour choice; the old floating glass tuner was + retired (E28), because free tuning is exactly the drift these rules exist to stop. +- **Backdrops:** one per scheme and mode (`public/backdrops/`), soft pools in the accent's family, with a + grey `overlay` grain that breaks the gradient banding (measured by pixel-scanning a canvas, never a + screenshot, which rescales). - **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%** (hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and `bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. +- **Tokens as utilities:** the decided values live once in `index.css` — `--hover` / `--press`, + `--edge-strong`, `--touch-target`, and the utilities `state-layer`, `state-filled`, `state-danger`, + `edge-strong`, `fade-below`, `fade-below-panel`, `fade-above-panel`, `fade-edges-x`. A primitive or a lab + rendering uses them; restating one as an arbitrary class is a violation. - **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a px font size or a px-frozen box is an eslint error. - **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. Buttons, icon buttons and @@ -289,3 +301,4 @@ Wherever there is text, the user can select it and copy it, and no click action | 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap | | 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up | | 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip | +| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) | diff --git a/frontend/public/backdrops/forest.svg b/frontend/public/backdrops/forest.svg index 5e8f0f44..fc664188 100644 --- a/frontend/public/backdrops/forest.svg +++ b/frontend/public/backdrops/forest.svg @@ -1,11 +1,11 @@ - - + + - + @@ -14,5 +14,5 @@ - + diff --git a/frontend/public/backdrops/light/forest.svg b/frontend/public/backdrops/light/forest.svg index 9ecade12..205a6cbe 100644 --- a/frontend/public/backdrops/light/forest.svg +++ b/frontend/public/backdrops/light/forest.svg @@ -1,11 +1,11 @@ - - + + - + @@ -14,5 +14,5 @@ - + diff --git a/frontend/public/backdrops/light/matrix.svg b/frontend/public/backdrops/light/matrix.svg index b679c1b9..9ef8303c 100644 --- a/frontend/public/backdrops/light/matrix.svg +++ b/frontend/public/backdrops/light/matrix.svg @@ -1,11 +1,11 @@ - - - + + + - + @@ -14,5 +14,5 @@ - + diff --git a/frontend/public/backdrops/light/midnight.svg b/frontend/public/backdrops/light/midnight.svg index 37dd1b7b..15cf1c8a 100644 --- a/frontend/public/backdrops/light/midnight.svg +++ b/frontend/public/backdrops/light/midnight.svg @@ -5,7 +5,7 @@ - + @@ -14,5 +14,5 @@ - + diff --git a/frontend/public/backdrops/light/slate.svg b/frontend/public/backdrops/light/slate.svg index 83354ab3..c8027b7d 100644 --- a/frontend/public/backdrops/light/slate.svg +++ b/frontend/public/backdrops/light/slate.svg @@ -1,18 +1,18 @@ - - - - + + + + - + - + - + diff --git a/frontend/public/backdrops/light/starship.svg b/frontend/public/backdrops/light/starship.svg deleted file mode 100644 index 9ba4aeb9..00000000 --- a/frontend/public/backdrops/light/starship.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/backdrops/light/youtube.svg b/frontend/public/backdrops/light/youtube.svg deleted file mode 100644 index 16cb1ffa..00000000 --- a/frontend/public/backdrops/light/youtube.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/backdrops/matrix.svg b/frontend/public/backdrops/matrix.svg index 3e699a7b..038ecfc8 100644 --- a/frontend/public/backdrops/matrix.svg +++ b/frontend/public/backdrops/matrix.svg @@ -1,11 +1,11 @@ - - - + + + - + @@ -14,5 +14,5 @@ - + diff --git a/frontend/public/backdrops/midnight.svg b/frontend/public/backdrops/midnight.svg index 217531ff..f51b6564 100644 --- a/frontend/public/backdrops/midnight.svg +++ b/frontend/public/backdrops/midnight.svg @@ -5,7 +5,7 @@ - + @@ -14,5 +14,5 @@ - + diff --git a/frontend/public/backdrops/slate.svg b/frontend/public/backdrops/slate.svg index b5c4889c..bb77777f 100644 --- a/frontend/public/backdrops/slate.svg +++ b/frontend/public/backdrops/slate.svg @@ -1,18 +1,18 @@ - - - - + + + + - + - + - + diff --git a/frontend/public/backdrops/starship.svg b/frontend/public/backdrops/starship.svg deleted file mode 100644 index 2a8d704d..00000000 --- a/frontend/public/backdrops/starship.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/public/backdrops/youtube.svg b/frontend/public/backdrops/youtube.svg deleted file mode 100644 index 6d50059a..00000000 --- a/frontend/public/backdrops/youtube.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 38104105..6a09d077 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,7 +32,6 @@ import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import BackToTop from "./components/ui/BackToTop"; import PageShell from "./components/ui/PageShell"; -import GlassTuner from "./components/GlassTuner"; import ChatDock from "./components/ChatDock"; import DebugRecorder from "./components/DebugRecorder"; import Toaster from "./components/ui/Toaster"; @@ -475,8 +474,6 @@ export default function App() { {/* Re-binds to the active page's scroll container on navigation (page or channel change). */} - {/* Opt-in live appearance tuner (Settings → Appearance; off by default). */} -
); } diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx deleted file mode 100644 index f1c3a679..00000000 --- a/frontend/src/components/GlassTuner.tsx +++ /dev/null @@ -1,465 +0,0 @@ -import { useEffect, useId, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import Overlay from "./ui/Overlay"; -import { copyText } from "../lib/clipboard"; -import { usePrefs } from "./prefsContext"; - -/** - * A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it - * writes the glass CSS custom properties (index.css `:root`) inline on the document element for - * instant, build-free feedback, and persists to localStorage. - * - * Opt-in via Settings → Appearance (`theme.showTuner`, off by default) — a secondary "geek toy" - * for dialling in the surface values. "Copy CSS" exports the values. - */ -const LS_KEY = "siftlode.devGlassTuner"; - -type Slider = { - k: string; - /** Its name under `settings.tuner.slider.*` — every user-facing word here is translated. */ - id: string; - min: number; - max: number; - step: number; - unit: string; - def: number; -}; - -// def = the BAKED baseline in index.css (:root). "Current" preset + the export diff both key off it. -// def = the default rendered baseline = the "glass over image" (backdrop-on) tier from index.css. -const SLIDERS: Slider[] = [ - { k: "--glass-blur", id: "blur", min: 0, max: 40, step: 1, unit: "px", def: 10 }, - { k: "--glass-saturate", id: "saturate", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.6 }, - { - k: "--glass-dark-bright", - id: "darkBright", - min: 1, - max: 2, - step: 0.05, - unit: "", - def: 1.2, - }, - { - k: "--glass-surface-alpha", - id: "surfaceAlpha", - min: 40, - max: 100, - step: 1, - unit: "%", - def: 50, - }, - { - k: "--glass-card-alpha", - id: "cardAlpha", - min: 40, - max: 100, - step: 1, - unit: "%", - def: 60, - }, - { - k: "--glass-menu-alpha", - id: "menuAlpha", - min: 40, - max: 100, - step: 1, - unit: "%", - def: 66, - }, - { - k: "--glass-border-alpha", - id: "borderAlpha", - min: 20, - max: 100, - step: 1, - unit: "%", - def: 80, - }, - { k: "--glass-edge", id: "edge", min: 0, max: 60, step: 1, unit: "%", def: 25 }, - { k: "--glass-inset", id: "inset", min: 0, max: 40, step: 1, unit: "%", def: 15 }, - { k: "--glass-scrim", id: "scrim", min: 0, max: 85, step: 1, unit: "%", def: 30 }, - { k: "--ambient", id: "ambient", min: 0, max: 2, step: 0.05, unit: "", def: 1.1 }, -]; - -const PALETTE: { k: string; id: string }[] = [ - { k: "--bg", id: "bg" }, - { k: "--surface", id: "surface" }, - { k: "--card", id: "card" }, - { k: "--border", id: "border" }, - { k: "--fg", id: "fg" }, - { k: "--muted", id: "muted" }, - { k: "--accent", id: "accent" }, -]; - -// Each preset resets every slider to its default, then applies these overrides. -// Named under `settings.tuner.preset.`. -const PRESETS: { id: string; over: Record }[] = [ - { id: "current", over: {} }, - { - id: "solid", - over: { - "--glass-blur": 20, - "--glass-dark-bright": 1.25, - "--glass-surface-alpha": 94, - "--glass-card-alpha": 94, - "--glass-menu-alpha": 98, - "--glass-edge": 10, - "--glass-inset": 16, - "--glass-scrim": 14, - }, - }, - { - id: "readable", - over: { - "--glass-edge": 42, - "--glass-scrim": 45, - "--glass-surface-alpha": 96, - "--glass-card-alpha": 96, - }, - }, -]; - -const ALL = SLIDERS; -const root = () => document.documentElement; -const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim(); - -type Saved = { - vars?: Record; - palette?: Record; - open?: boolean; -}; - -function loadSaved(): Saved { - try { - return JSON.parse(localStorage.getItem(LS_KEY) || "{}") as Saved; - } catch { - return {}; - } -} - -export default function GlassTuner() { - // Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider. - const enabled = usePrefs().theme.showTuner; - const { t } = useTranslation(); - // Read once at mount. useState's lazy initializer rather than a useMemo with an empty dep list: - // a memo is a caching hint React may discard and recompute, while "the saved values as they were - // when this mounted" is state — and the effect below applies them exactly once. - const [initial] = useState(loadSaved); - const [vars, setVars] = useState>(() => { - const base: Record = {}; - ALL.forEach((s) => (base[s.k] = s.def)); - return { ...base, ...(initial.vars || {}) }; - }); - const [palette, setPalette] = useState>(() => initial.palette || {}); - const [open, setOpen] = useState(() => initial.open ?? true); - const [tab, setTab] = useState<"glass" | "palette">("glass"); - const [copied, setCopied] = useState(false); - const [themeTick, setThemeTick] = useState(0); - // Seed the colour pickers from the live scheme for display (without forcing them onto ). - const [swatches, setSwatches] = useState>({}); - - // Apply saved var overrides (and any saved palette edits) once on mount. - useEffect(() => { - Object.entries(initial.vars || {}).forEach(([k, v]) => { - const unit = ALL.find((s) => s.k === k)?.unit ?? ""; - root().style.setProperty(k, v + unit); - }); - Object.entries(initial.palette || {}).forEach(([k, v]) => root().style.setProperty(k, v)); - const sw: Record = {}; - PALETTE.forEach((p) => { - const v = (initial.palette?.[p.k] || readVar(p.k)).trim(); - sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000"; - }); - // eslint-disable-next-line react-hooks/set-state-in-effect -- the swatches are READ FROM THE DOM (readVar on the computed style) after the saved overrides have been applied to it, so they cannot exist before a commit. - setSwatches(sw); - }, [initial]); - - function persist(next: Partial) { - const cur = loadSaved(); - localStorage.setItem(LS_KEY, JSON.stringify({ ...cur, ...next })); - } - - function setVar(k: string, value: number) { - const unit = ALL.find((s) => s.k === k)?.unit ?? ""; - root().style.setProperty(k, value + unit); - setVars((prev) => { - const next = { ...prev, [k]: value }; - persist({ vars: next }); - return next; - }); - } - - function setColor(k: string, hex: string) { - root().style.setProperty(k, hex); - setSwatches((s) => ({ ...s, [k]: hex })); - setPalette((prev) => { - const next = { ...prev, [k]: hex }; - persist({ palette: next }); - return next; - }); - } - - function applyPreset(over: Record) { - const next: Record = { ...vars }; - SLIDERS.forEach((s) => { - const v = over[s.k] ?? s.def; - next[s.k] = v; - root().style.setProperty(s.k, v + s.unit); - }); - setVars(next); - persist({ vars: next }); - } - - function reset() { - [...ALL, ...PALETTE].forEach((s) => root().style.removeProperty(s.k)); - const base: Record = {}; - ALL.forEach((s) => (base[s.k] = s.def)); - setVars(base); - setPalette({}); - localStorage.removeItem(LS_KEY); - const sw: Record = {}; - PALETTE.forEach((p) => { - const v = readVar(p.k); - sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000"; - }); - setSwatches(sw); - } - - // Re-read the LIVE, theme-driven glass values into the sliders (clearing inline overrides first), - // so the panel reflects whatever theme is active — switch to light, hit this, and the opacity - // sliders show the light-theme values (index.css `html[data-theme="light"]`) ready to tune. - function syncToTheme() { - ALL.forEach((s) => root().style.removeProperty(s.k)); - const next: Record = { ...vars }; - ALL.forEach((s) => { - const n = parseFloat(readVar(s.k)); - if (!Number.isNaN(n)) next[s.k] = n; - }); - setVars(next); - persist({ vars: {} }); - setThemeTick((t) => t + 1); - } - - // Export the values that differ from the baseline, headered for the active theme's scope so a - // light-theme tune pastes into the right block. - const cssOut = useMemo(() => { - void themeTick; // re-run when the active theme is re-synced - const lines: string[] = []; - ALL.forEach((s) => { - if (vars[s.k] !== s.def) lines.push(` ${s.k}: ${vars[s.k]}${s.unit};`); - }); - PALETTE.forEach((p) => { - if (palette[p.k]) lines.push(` ${p.k}: ${palette[p.k]};`); - }); - const scope = root().dataset.theme === "light" ? 'html[data-theme="light"]' : ":root"; - return lines.length ? `${scope} {\n${lines.join("\n")}\n}` : "/* all values at defaults */"; - }, [vars, palette, themeTick]); - - async function copy() { - if (await copyText(cssOut)) { - setCopied(true); - setTimeout(() => setCopied(false), 1200); - } - } - - function persistOpen(v: boolean) { - setOpen(v); - persist({ open: v }); - } - - if (!enabled) return null; - - return ( - -
- {!open && ( - - )} - {open && ( -
-
- {t("settings.tuner.title")} - - {t("settings.tuner.lab")} - - -
- -
- {/* Treatment presets */} -
-
- {t("settings.tuner.treatment")} -
-
- {PRESETS.map((p) => ( - - ))} -
-
- - {/* Tab switch */} -
- {(["glass", "palette"] as const).map((id) => ( - - ))} -
- - {tab === "glass" && ( -
- {SLIDERS.map((s) => ( - setVar(s.k, v)} - /> - ))} -
- )} - - {tab === "palette" && ( -
-

- {t("settings.tuner.paletteHint")} -

- {PALETTE.map((p) => ( - - ))} -
- )} - - {/* Export */} -
-
- {t("settings.tuner.export")} -
- -
- - -
-
-                  {cssOut}
-                
-
-
-
- )} -
-
- ); -} - -function Ctl({ - s, - label, - value, - onChange, -}: { - s: Slider; - label: string; - value: number; - onChange: (v: number) => void; -}) { - // The label names the number box, and the range beside it says the same thing: both controls - // are one setting, and the label was not tied to either before (a screen reader read "spin - // button" and "slider" with no name at all). - const id = useId(); - return ( -
- - onChange(Number(e.target.value))} - className="w-16 text-right text-2xs tabular-nums rounded-sm bg-card border border-border px-1.5 py-0.5 text-accent" - /> - onChange(Number(e.target.value))} - aria-label={label} - className="col-span-2 w-full accent-accent" - /> -
- ); -} diff --git a/frontend/src/components/PrefsProvider.tsx b/frontend/src/components/PrefsProvider.tsx index 7a0f655e..3d2878bd 100644 --- a/frontend/src/components/PrefsProvider.tsx +++ b/frontend/src/components/PrefsProvider.tsx @@ -12,8 +12,8 @@ import { useQueryClient } from "@tanstack/react-query"; import { api, type Me } from "../lib/api"; import { applyTheme, - DEFAULT_THEME, loadLocalTheme, + normalizeTheme, saveLocalTheme, type ThemePrefs, } from "../lib/theme"; @@ -28,7 +28,7 @@ const PERF_KEY = LS.perfMode; // The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced) // to the server — there is no draft or Save button. This provider owns them (lifted out of the App god // component) plus the last-saved "baseline" (the server values it was seeded with, then each successful -// save) used to suppress saving what the server already holds. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs() (prefsContext.ts). +// save) used to suppress saving what the server already holds. Consumers (SettingsPanel) read the PrefsController via usePrefs() (prefsContext.ts). type EditablePrefs = { theme: ThemePrefs; performanceMode: boolean; @@ -44,7 +44,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) { const [seed] = useState(() => { const prefs = qc.getQueryData(qk.me())?.preferences; return { - theme: prefs?.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(), + theme: prefs?.theme ? normalizeTheme(prefs.theme) : loadLocalTheme(), performanceMode: typeof prefs?.performanceMode === "boolean" ? prefs.performanceMode diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 6da08fbd..c69407f3 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -213,16 +213,6 @@ function Appearance({ prefs }: { prefs: PrefsController }) { > - - setTheme({ ...theme, showTuner: v })} - /> -
diff --git a/frontend/src/design-lab/batchB.tsx b/frontend/src/design-lab/batchB.tsx index 97700769..b3418d02 100644 --- a/frontend/src/design-lab/batchB.tsx +++ b/frontend/src/design-lab/batchB.tsx @@ -21,17 +21,9 @@ import { Youtube, } from "lucide-react"; import { Btn } from "./batchA"; -import { hexContrast, hueGap } from "./labColor"; +import { hexContrast, hueGap } from "../lib/colorMath"; import { Reference, Sample } from "./labKit"; -import { - FADE_ABOVE_PANEL, - FADE_BELOW, - FADE_BELOW_PANEL, - FADE_EDGES_X, - FILLED, - H, - LAYER, -} from "./labTokens"; +import { FADE_ABOVE_PANEL, FADE_BELOW, FADE_BELOW_PANEL, FILLED, H, LAYER } from "./labTokens"; // E28 S2 · Batch B — surfaces, chrome and colour, DECIDED (owner, 2026-09-24): every recommendation, // except B7, where the owner combined the soft tint + edge bar with optional labels. B1 (density) and @@ -57,7 +49,7 @@ type SchemeDef = { name: string; dark: Pal; light: Pal }; const STATUS: Record> = { dark: { danger: "#f87171", dangerFg: "#2a0a0a", warn: "#fbbf24", success: "#34d399" }, - light: { danger: "#dc2626", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" }, + light: { danger: "#b91c1c", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" }, }; const pal = ( @@ -70,7 +62,8 @@ const pal = ( accentFg: string, ): Pal => ({ bg, surface, border, fg, muted, accent, accentFg }); -// Backgrounds and text are today's (index.css); only the accents are new. +// A literal copy of index.css's scheme tokens, so the four tiles render side by side (the live CSS +// switches only one scheme at a time). index.css is the source; theme.contract.test.ts holds it. const SCHEMES: SchemeDef[] = [ { name: "Midnight · blue", @@ -458,10 +451,7 @@ function Fades() {
-
+
{CHIPS.map((c) => ( diff --git a/frontend/src/design-lab/labTokens.ts b/frontend/src/design-lab/labTokens.ts index 04db6fe4..6f22f927 100644 --- a/frontend/src/design-lab/labTokens.ts +++ b/frontend/src/design-lab/labTokens.ts @@ -1,5 +1,6 @@ -// The decided values the lab's reference renderings share (batches A and B, 2026-09-24). They are -// the lab's stand-ins until S3 moves each into index.css / the ui/ primitives; then these go. +// The decided values the lab's reference renderings share (batches A and B, 2026-09-24). The colour +// rules are real now (E28 S3a): each constant below names the index.css utility or token that holds +// it, so the lab renders what the app ships. The height ladder moves into ui/Button in S3b. /** B1 compact ladder: explicit heights, so one toolbar row lines up at 28. */ export const H = { @@ -12,25 +13,15 @@ export const H = { bar: "h-7", } as const; -/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode. */ -export const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))"; +/** A4: the input edge, ≥ 3:1 against the page in every scheme and mode (index.css --edge-strong). */ +export const STRONG = "var(--edge-strong)"; -/** B3 state layers: the text colour at 10% (hover) / 16% (pressed) over whatever is underneath; - * a filled control mixes its own text colour into the fill at 14% / 24%. */ -export const LAYER = - "hover:bg-[color-mix(in_srgb,var(--fg)_10%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]"; -export const FILLED = - "bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))]"; -export const DANGER = - "bg-danger text-danger-fg hover:bg-[color-mix(in_srgb,var(--danger-fg)_14%,var(--danger))] active:bg-[color-mix(in_srgb,var(--danger-fg)_24%,var(--danger))]"; +/** B3 state layers (index.css utilities). */ +export const LAYER = "state-layer"; +export const FILLED = "state-filled"; +export const DANGER = "state-danger"; -/** B9: the header + fade edge — a crisp band and a 1rem gradient of the colour it sits on. Literal - * strings per colour: Tailwind only generates classes it can read whole. */ -export const FADE_BELOW = - "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']"; -export const FADE_BELOW_PANEL = - "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--surface),transparent)] after:content-['']"; -export const FADE_ABOVE_PANEL = - "before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--surface),transparent)] before:content-['']"; -export const FADE_EDGES_X = - "linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent)"; +/** B9: the header + fade edge (index.css utilities; the host is positioned). */ +export const FADE_BELOW = "fade-below"; +export const FADE_BELOW_PANEL = "fade-below-panel"; +export const FADE_ABOVE_PANEL = "fade-above-panel"; diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 197099d5..dd1b4883 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -33,8 +33,6 @@ "performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.", "showHints": "Show hints", "showHintsHint": "These little hover explanations. Turn them off once you know your way around.", - "showTuner": "Show the glass tuner", - "showTunerHint": "A floating panel for live-tweaking the frosted-glass look. A geek toy — off by default.", "startup": "Startup", "startOnHome": "Start on Home", "startOnHomeHint": "Every launch opens “What should I watch?”. Off = pick up on the module you left open.", @@ -249,60 +247,5 @@ "clear": "Clear", "empty": "No event matches the filter.", "emptyAll": "No events yet." - }, - "tuner": { - "open": "Open the glass tuner", - "tab": "◐ GLASS", - "title": "◐ Glass Tuner", - "lab": "lab", - "collapse": "Collapse", - "treatment": "Dark treatment", - "tabs": { - "glass": "Glass", - "palette": "Palette" - }, - "paletteHint": "Overrides the live scheme (inline on ). Reset clears these back to the scheme.", - "export": "Export → bake in", - "syncHint": "Load the values of the theme you're currently viewing (light or dark) into the sliders", - "sync": "⟳ Sync to current theme", - "copy": "Copy CSS", - "copied": "Copied ✓", - "reset": "Reset", - "slider": { - "blur": "Blur radius", - "saturate": "Saturation", - "darkBright": "Dark brightness", - "surfaceAlpha": "Panel opacity", - "cardAlpha": "Card opacity", - "menuAlpha": "Menu opacity", - "borderAlpha": "Border opacity", - "edge": "Edge-light rim", - "inset": "Top highlight", - "scrim": "Under-text scrim", - "ambient": "Ambient intensity" - }, - "color": { - "bg": "Background", - "surface": "Surface", - "card": "Card", - "border": "Border", - "fg": "Text", - "muted": "Muted", - "accent": "Accent" - }, - "preset": { - "current": { - "name": "Glass/image", - "hint": "The default (over image)" - }, - "solid": { - "name": "Solid (flat)", - "hint": "As shown w/ image off" - }, - "readable": { - "name": "Max readable", - "hint": "Solid + strong scrim" - } - } } } diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 3456df8d..1d27c829 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -33,8 +33,6 @@ "performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.", "showHints": "Tippek megjelenítése", "showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.", - "showTuner": "Glass tuner megjelenítése", - "showTunerHint": "Lebegő panel a mattüveg-hatás élő hangolásához. Geek-játékszer — alapból kikapcsolva.", "startup": "Indulás", "startOnHome": "Indítás a kezdőlapon", "startOnHomeHint": "Minden induláskor a „Mit nézzek most?” oldal nyílik meg. Kikapcsolva ott folytatod, ahol abbahagytad.", @@ -249,60 +247,5 @@ "clear": "Ürítés", "empty": "Nincs a szűrésnek megfelelő esemény.", "emptyAll": "Még nincs esemény." - }, - "tuner": { - "open": "Glass tuner megnyitása", - "tab": "◐ ÜVEG", - "title": "◐ Glass tuner", - "lab": "labor", - "collapse": "Összecsukás", - "treatment": "Sötét téma", - "tabs": { - "glass": "Üveg", - "palette": "Paletta" - }, - "paletteHint": "Felülírja az élő színsémát (inline a -en). A Visszaállítás a séma színeit adja vissza.", - "export": "Exportálás → beégetés", - "syncHint": "A most látott téma (világos vagy sötét) értékeinek betöltése a csúszkákba", - "sync": "⟳ Igazítás a jelenlegi témához", - "copy": "CSS másolása", - "copied": "Másolva ✓", - "reset": "Visszaállítás", - "slider": { - "blur": "Elmosás sugara", - "saturate": "Telítettség", - "darkBright": "Sötét fényerő", - "surfaceAlpha": "Panel fedése", - "cardAlpha": "Kártya fedése", - "menuAlpha": "Menü fedése", - "borderAlpha": "Keret fedése", - "edge": "Élfény-perem", - "inset": "Felső csúcsfény", - "scrim": "Szöveg alatti sötétítés", - "ambient": "Környezeti intenzitás" - }, - "color": { - "bg": "Háttér", - "surface": "Felület", - "card": "Kártya", - "border": "Keret", - "fg": "Szöveg", - "muted": "Halvány", - "accent": "Kiemelés" - }, - "preset": { - "current": { - "name": "Üveg/kép", - "hint": "Az alapérték (kép fölött)" - }, - "solid": { - "name": "Tömör (lapos)", - "hint": "Ahogy kép nélkül látszik" - }, - "readable": { - "name": "Legolvashatóbb", - "hint": "Tömör + erős sötétítés" - } - } } } diff --git a/frontend/src/index.css b/frontend/src/index.css index 947f6e0b..a2d21f3a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -46,7 +46,6 @@ --z-index-overlay: 50; --z-index-popover: 60; --z-index-tooltip: 100; - --z-index-tuner: 9999; } /* Extra-small text steps in rem, so these labels scale with --font-scale like every other @@ -62,6 +61,90 @@ --text-3xs: 0.625rem; /* 10px @ base */ } +/* ===== E28 tokens as utilities (docs/DESIGN.md) — the ONE source the ui/ primitives and the design + lab both read, so a decided value is never restated as an arbitrary class. ===== */ + +/* B3: a transparent control's hover / pressed layer. */ +@utility state-layer { + &:hover { + background-color: var(--hover); + } + &:active { + background-color: var(--press); + } +} +/* B3: a filled control mixes its own text colour into its fill (14% hover, 24% pressed). */ +@utility state-filled { + background-color: var(--accent); + color: var(--accent-fg); + &:hover { + background-color: color-mix(in srgb, var(--accent-fg) 14%, var(--accent)); + } + &:active { + background-color: color-mix(in srgb, var(--accent-fg) 24%, var(--accent)); + } +} +@utility state-danger { + background-color: var(--danger); + color: var(--danger-fg); + &:hover { + background-color: color-mix(in srgb, var(--danger-fg) 14%, var(--danger)); + } + &:active { + background-color: color-mix(in srgb, var(--danger-fg) 24%, var(--danger)); + } +} +/* A4: a control's ≥ 3:1 edge. */ +@utility edge-strong { + border-color: var(--edge-strong); +} + +/* B9: the header + fade edge — a crisp band with a 1rem gradient of the colour it sits on, so content + dissolves as it slides under (the reference is DataTable's sticky header). The host is positioned. */ +@utility fade-below { + &::after { + content: ""; + pointer-events: none; + position: absolute; + inset-inline: 0; + top: 100%; + height: 1rem; + background: linear-gradient(to bottom, var(--bg), transparent); + } +} +@utility fade-below-panel { + &::after { + content: ""; + pointer-events: none; + position: absolute; + inset-inline: 0; + top: 100%; + height: 1rem; + background: linear-gradient(to bottom, var(--surface), transparent); + } +} +@utility fade-above-panel { + &::before { + content: ""; + pointer-events: none; + position: absolute; + inset-inline: 0; + bottom: 100%; + height: 1rem; + background: linear-gradient(to top, var(--surface), transparent); + } +} +/* B9: the clipped ends of an overflowing chip row. */ +@utility fade-edges-x { + mask-image: linear-gradient( + to right, + transparent, + #000 1.75rem, + #000 calc(100% - 1.75rem), + transparent + ); +} + /* The default border color has changed to `currentcolor` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -225,22 +308,6 @@ html[data-backdrop="on"][data-theme="dark"][data-scheme="slate"] body { ), url("/backdrops/slate.svg"); } -html[data-backdrop="on"][data-theme="dark"][data-scheme="youtube"] body { - background-image: - linear-gradient( - color-mix(in srgb, var(--bg) var(--bg-fade), transparent), - color-mix(in srgb, var(--bg) var(--bg-fade), transparent) - ), - url("/backdrops/youtube.svg"); -} -html[data-backdrop="on"][data-theme="dark"][data-scheme="starship"] body { - background-image: - linear-gradient( - color-mix(in srgb, var(--bg) var(--bg-fade), transparent), - color-mix(in srgb, var(--bg) var(--bg-fade), transparent) - ), - url("/backdrops/starship.svg"); -} html[data-backdrop="on"][data-theme="dark"][data-scheme="matrix"] body { background-image: linear-gradient( @@ -273,22 +340,6 @@ html[data-backdrop="on"][data-theme="light"][data-scheme="slate"] body { ), url("/backdrops/light/slate.svg"); } -html[data-backdrop="on"][data-theme="light"][data-scheme="youtube"] body { - background-image: - linear-gradient( - color-mix(in srgb, var(--bg) var(--bg-fade), transparent), - color-mix(in srgb, var(--bg) var(--bg-fade), transparent) - ), - url("/backdrops/light/youtube.svg"); -} -html[data-backdrop="on"][data-theme="light"][data-scheme="starship"] body { - background-image: - linear-gradient( - color-mix(in srgb, var(--bg) var(--bg-fade), transparent), - color-mix(in srgb, var(--bg) var(--bg-fade), transparent) - ), - url("/backdrops/light/starship.svg"); -} html[data-backdrop="on"][data-theme="light"][data-scheme="matrix"] body { background-image: linear-gradient( @@ -320,11 +371,17 @@ html { jitter at DPR=1. round() keeps the font-scale feature but quantises to crisp integer metrics. */ font-size: round(calc(16px * var(--font-scale)), 1px); background: var(--bg); - /* Shared interactive-row hover tint. `--card` reads faint in dark schemes (it sits close to `--bg`), - so hover was barely visible; a foreground-mix reads clearly in every scheme + theme (it lightens - on dark, darkens on light) and adapts automatically since --fg is per-scheme. One source for every - hoverable row (the .selectable recipe + DataTable rows). */ - --hover: color-mix(in srgb, var(--fg) 9%, transparent); + /* State layers (docs/DESIGN.md B3): hover and pressed are the text colour laid over whatever the + control sits on — the page, a card or glass — so they read in every scheme and theme (it lightens + on dark, darkens on light). Never an opacity dim or a bg-card swap (invisible on a light card). + Applied through the state-layer / state-filled / state-danger utilities below. */ + --hover: color-mix(in srgb, var(--fg) 10%, transparent); + --press: color-mix(in srgb, var(--fg) 16%, transparent); + /* A control's edge (DESIGN.md A4): ≥ 3:1 against the page in every scheme and mode, where --border + (a decorative divider) is not. theme.contract.test.ts holds the ratio. */ + --edge-strong: color-mix(in srgb, var(--fg) 55%, var(--bg)); + /* The coarse-pointer target (DESIGN.md A3): one token, read by .touch-target and the primitives. */ + --touch-target: 2.75rem; } body { @@ -598,8 +655,8 @@ body { would be the one control that ignores the user's text-size setting. */ @media (pointer: coarse) { .touch-target { - min-width: 2.75rem; - min-height: 2.75rem; + min-width: var(--touch-target); + min-height: var(--touch-target); } } @@ -858,7 +915,7 @@ html[data-scheme="midnight"][data-theme="light"] { --accent-fg: #ffffff; } -/* Forest — dark slate with teal/green accent */ +/* Forest — dark green-slate with a cyan accent (E28 B2: teal read as success) */ html[data-scheme="forest"][data-theme="dark"] { --bg: #0a1512; --surface: #0f1f1a; @@ -866,7 +923,7 @@ html[data-scheme="forest"][data-theme="dark"] { --border: #1f3a30; --fg: #e6f0ec; --muted: #90b1a4; - --accent: #2dd4bf; + --accent: #22d3ee; --accent-fg: #04110d; } html[data-scheme="forest"][data-theme="light"] { @@ -876,11 +933,11 @@ html[data-scheme="forest"][data-theme="light"] { --border: #d6e7e0; --fg: #102a22; --muted: #4d6b60; - --accent: #0d9488; + --accent: #0e7490; --accent-fg: #ffffff; } -/* Slate — neutral grey with warm orange accent (muted in dark) */ +/* Slate — neutral grey with a fuchsia accent (E28 B2: orange read as warn) */ html[data-scheme="slate"][data-theme="dark"] { --bg: #15171c; --surface: #1b1e25; @@ -888,8 +945,8 @@ html[data-scheme="slate"][data-theme="dark"] { --border: #2c313b; --fg: #e7e9ee; --muted: #9aa1ad; - --accent: #e8913c; - --accent-fg: #1a1206; + --accent: #e879f9; + --accent-fg: #1a0620; } html[data-scheme="slate"][data-theme="light"] { --bg: #f6f7f9; @@ -898,55 +955,11 @@ html[data-scheme="slate"][data-theme="light"] { --border: #e3e6eb; --fg: #1c1f26; --muted: #5d636e; - --accent: #d97706; + --accent: #a21caf; --accent-fg: #ffffff; } -/* YouTube — near-black with red accent */ -html[data-scheme="youtube"][data-theme="dark"] { - --bg: #0f0f0f; - --surface: #181818; - --card: #1f1f1f; - --border: #303030; - --fg: #f1f1f1; - --muted: #aaaaaa; - --accent: #ff3b46; - --accent-fg: #ffffff; -} -html[data-scheme="youtube"][data-theme="light"] { - --bg: #ffffff; - --surface: #f9f9f9; - --card: #ffffff; - --border: #e5e5e5; - --fg: #0f0f0f; - --muted: #606060; - --accent: #ff0033; - --accent-fg: #ffffff; -} - -/* Starship — deep space blue-black with a bright azure "LCARS/computer" accent */ -html[data-scheme="starship"][data-theme="dark"] { - --bg: #050b16; - --surface: #0b1728; - --card: #101f36; - --border: #1f3557; - --fg: #dfeaff; - --muted: #8aa6cc; - --accent: #38b6ff; - --accent-fg: #04101f; -} -html[data-scheme="starship"][data-theme="light"] { - --bg: #eef4fb; - --surface: #ffffff; - --card: #ffffff; - --border: #d3e0f0; - --fg: #0b1b30; - --muted: #4a5f7d; - --accent: #0b6bd6; - --accent-fg: #ffffff; -} - -/* Matrix — near-black with a muted terminal-green accent (softened from neon phosphor) */ +/* Matrix — near-black with a lime accent (E28 B2: the terminal green read as success) */ html[data-scheme="matrix"][data-theme="dark"] { --bg: #030a06; --surface: #08130d; @@ -954,7 +967,7 @@ html[data-scheme="matrix"][data-theme="dark"] { --border: #163d27; --fg: #cfe8d6; --muted: #6fae86; - --accent: #46b87c; + --accent: #a3e635; --accent-fg: #04170c; } html[data-scheme="matrix"][data-theme="light"] { @@ -964,7 +977,7 @@ html[data-scheme="matrix"][data-theme="light"] { --border: #cfe6d6; --fg: #08130b; --muted: #4c6b56; - --accent: #12a150; + --accent: #4d7c0f; --accent-fg: #ffffff; } @@ -986,7 +999,9 @@ html[data-theme="dark"] { --info-fg: #08172e; } html[data-theme="light"] { - --danger: #dc2626; + /* red-700, like warn (amber-700) and success (emerald-700): red-600 fell to 4.43:1 on the tinted + light pages (Midnight, Forest, Matrix). theme.contract.test.ts holds every status at 4.5:1. */ + --danger: #b91c1c; --danger-fg: #ffffff; --warn: #b45309; --warn-fg: #ffffff; diff --git a/frontend/src/design-lab/labColor.ts b/frontend/src/lib/colorMath.ts similarity index 61% rename from frontend/src/design-lab/labColor.ts rename to frontend/src/lib/colorMath.ts index fbc98764..c5d072f0 100644 --- a/frontend/src/design-lab/labColor.ts +++ b/frontend/src/lib/colorMath.ts @@ -1,7 +1,7 @@ -// ── Fixed-palette maths ────────────────────────────────────────────────────────────────────────── -// A proposed scheme is not in the CSS yet, so it can't be measured through the probe: these work on -// literal hex values instead. Hue distance is taken in OKLCH, where equal angles look equally far -// apart, unlike HSL's. +// WCAG contrast and OKLCH hue distance on literal hex colours. The design constitution's colour rules +// (docs/DESIGN.md, Tokens) are measured with these: theme.contract.test.ts runs them over every scheme +// in index.css, and the design lab's scheme tiles render the same numbers. OKLCH, because equal hue +// angles there look equally far apart, unlike HSL's. const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); const lin2 = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); @@ -31,3 +31,13 @@ export function hueGap(a: string, b: string): number { const d = Math.abs(hue(a) - hue(b)) % 360; return Math.min(d, 360 - d); } + +/** `color-mix(in srgb, a w, b)` for two opaque hex colours: the channels mix gamma-encoded, as the + * browser does in the srgb space. */ +export function mixHex(a: string, b: string, w: number): string { + const [x, y] = [rgb(a), rgb(b)]; + return `#${x + .map((v, i) => Math.round((v * w + (y[i] ?? 0) * (1 - w)) * 255)) + .map((n) => n.toString(16).padStart(2, "0")) + .join("")}`; +} diff --git a/frontend/src/lib/theme.contract.test.ts b/frontend/src/lib/theme.contract.test.ts new file mode 100644 index 00000000..0ef21cbf --- /dev/null +++ b/frontend/src/lib/theme.contract.test.ts @@ -0,0 +1,76 @@ +import { existsSync, readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { hexContrast, hueGap, mixHex } from "./colorMath"; +import { SCHEMES } from "./theme"; + +// docs/DESIGN.md's colour rules, held in code instead of prose. Every scheme × mode in index.css: +// - body text, muted text and accent text ≥ 4.5:1 on the page, and the accent's own text ≥ 4.5:1 on +// an accent fill [WCAG 1.4.3]; +// - a control's edge (--edge-strong, the fg mixed 55% into the bg) ≥ 3:1 on the page [WCAG 1.4.11]; +// - the accent ≥ 30° of OKLCH hue away from danger, warn and success, so a primary button never +// reads as a status (decision B2: the old YouTube, Slate, Forest and Matrix accents all did). +// The status colours are text too, so they are held to 4.5:1 on every scheme's page as well. +// A new scheme, or a retuned colour, has to pass this before it ships. + +const css = readFileSync(new URL("../index.css", import.meta.url), "utf8"); +type Mode = "dark" | "light"; +const MODES: Mode[] = ["dark", "light"]; + +/** The `--name: #hex;` declarations of every block whose selector is exactly `selector`, merged in + * source order (the same selector also carries non-colour blocks, e.g. `color-scheme`). */ +function block(selector: string): Record { + const out: Record = {}; + for (let at = css.indexOf(`${selector} {`); at >= 0; at = css.indexOf(`${selector} {`, at + 1)) { + const body = css.slice(at, css.indexOf("}", at)); + for (const m of body.matchAll(/--([\w-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) out[m[1]!] = m[2]!; + } + expect(Object.keys(out), `no colour tokens for ${selector} in index.css`).not.toHaveLength(0); + return out; +} + +const status = (mode: Mode) => block(`html[data-theme="${mode}"]`); +const scheme = (id: string, mode: Mode) => block(`html[data-scheme="${id}"][data-theme="${mode}"]`); + +it("styles exactly the schemes the app offers", () => { + const inCss = new Set( + [...css.matchAll(/html\[data-scheme="(\w+)"\]\[data-theme="dark"\]/g)].map((m) => m[1]), + ); + expect([...inCss].sort()).toEqual(SCHEMES.map((s) => s.id).sort()); + // The edge token is defined the way this test measures it. + expect(css).toMatch(/--edge-strong:\s*color-mix\(in srgb, var\(--fg\) 55%, var\(--bg\)\)/); +}); + +describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => { + it.each(MODES)("%s: backdrop rule and file", (mode) => { + const file = `backdrops/${mode === "light" ? "light/" : ""}${id}.svg`; + expect(css).toContain(`url("/${file}")`); + expect(existsSync(new URL(`../../public/${file}`, import.meta.url)), file).toBe(true); + }); + + it.each(MODES)("%s: text and control contrast", (mode) => { + const t = scheme(id, mode); + const s = status(mode); + const bg = t.bg!; + const measured = { + fg: hexContrast(t.fg!, bg), + muted: hexContrast(t.muted!, bg), + accentText: hexContrast(t.accent!, bg), + onAccent: hexContrast(t["accent-fg"]!, t.accent!), + edge: hexContrast(mixHex(t.fg!, bg, 0.55), bg), + danger: hexContrast(s.danger!, bg), + warn: hexContrast(s.warn!, bg), + success: hexContrast(s.success!, bg), + }; + const failing = Object.entries(measured).filter(([k, v]) => v < (k === "edge" ? 3 : 4.5)); + expect(failing.map(([k, v]) => `${k} ${v.toFixed(2)}:1`)).toEqual([]); + }); + + it.each(MODES)("%s: the accent never looks like a status", (mode) => { + const accent = scheme(id, mode).accent!; + const s = status(mode); + const close = (["danger", "warn", "success"] as const) + .map((k) => [k, hueGap(accent, s[k]!)] as const) + .filter(([, gap]) => gap < 30); + expect(close.map(([k, gap]) => `${k} ${gap.toFixed(0)}°`)).toEqual([]); + }); +}); diff --git a/frontend/src/lib/theme.test.ts b/frontend/src/lib/theme.test.ts new file mode 100644 index 00000000..dfe647f8 --- /dev/null +++ b/frontend/src/lib/theme.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from "vitest"; +import { DEFAULT_THEME, normalizeTheme } from "./theme"; + +// YouTube and Starship were retired in E28 with no data migration (owner, 2026-09-24): an account or +// a browser that still stores one must land on the default scheme, not on an html[data-scheme] that no +// CSS block styles (an unstyled page), and the retired tuner flag must not ride along into the next save. +it("maps a retired scheme to the default and keeps only the known keys", () => { + const t = normalizeTheme({ + mode: "light", + scheme: "youtube", + fontScale: 1.1, + bgImage: false, + bgFade: 35, + ...{ showTuner: true }, + }); + expect(t).toEqual({ + mode: "light", + scheme: "midnight", + fontScale: 1.1, + bgImage: false, + bgFade: 35, + }); +}); + +it("keeps a live scheme and fills what is missing or malformed from the defaults", () => { + expect(normalizeTheme({ scheme: "matrix", mode: "sepia", fontScale: Number.NaN })).toEqual({ + ...DEFAULT_THEME, + scheme: "matrix", + }); +}); diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index dd00f66a..cca27b92 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -1,15 +1,16 @@ import { LS, readAccountMerged, writeAccount } from "./storage"; type Mode = "dark" | "light"; -export type Scheme = "midnight" | "forest" | "slate" | "youtube" | "starship" | "matrix"; +export type Scheme = "midnight" | "forest" | "slate" | "matrix"; +// The scheme set (docs/DESIGN.md, decision B2): each earns its place with its own hue family, and its +// accent stays ≥ 30° of OKLCH hue away from danger / warn / success. theme.contract.test.ts measures +// every scheme's tokens in index.css against that rule and WCAG contrast. export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [ { id: "midnight", name: "Midnight", swatch: "#6d8cff" }, - { id: "forest", name: "Forest", swatch: "#2dd4bf" }, - { id: "slate", name: "Slate", swatch: "#e8913c" }, - { id: "youtube", name: "YouTube", swatch: "#ff3b46" }, - { id: "starship", name: "Starship", swatch: "#38b6ff" }, - { id: "matrix", name: "Matrix", swatch: "#46b87c" }, + { id: "forest", name: "Forest", swatch: "#22d3ee" }, + { id: "slate", name: "Slate", swatch: "#e879f9" }, + { id: "matrix", name: "Matrix", swatch: "#a3e635" }, ]; export interface ThemePrefs { @@ -20,8 +21,6 @@ export interface ThemePrefs { bgImage: boolean; /** How strongly the background image is faded out, 0–100% (higher = fainter). */ bgFade: number; - /** Show the floating glass/theme tuner (a secondary "geek toy"); off by default. */ - showTuner: boolean; } export const DEFAULT_THEME: ThemePrefs = { @@ -30,19 +29,34 @@ export const DEFAULT_THEME: ThemePrefs = { fontScale: 1.06, bgImage: true, bgFade: 60, - showTuner: false, }; +/** A stored theme (this browser's copy or the account's server prefs) as the app can apply it: only + * the known keys, and a scheme or mode that no longer exists (YouTube and Starship were retired in + * E28) falls back to the default instead of painting an unstyled page. */ +export function normalizeTheme(raw: Partial>): ThemePrefs { + const d = DEFAULT_THEME; + const num = (v: unknown, fallback: number) => + typeof v === "number" && Number.isFinite(v) ? v : fallback; + return { + mode: raw.mode === "light" || raw.mode === "dark" ? raw.mode : d.mode, + scheme: SCHEMES.some((s) => s.id === raw.scheme) ? (raw.scheme as Scheme) : d.scheme, + fontScale: num(raw.fontScale, d.fontScale), + bgImage: typeof raw.bgImage === "boolean" ? raw.bgImage : d.bgImage, + bgFade: num(raw.bgFade, d.bgFade), + }; +} + export function applyTheme(t: ThemePrefs): void { const el = document.documentElement; el.dataset.theme = t.mode; el.dataset.scheme = t.scheme; el.style.setProperty("--font-scale", String(t.fontScale)); - if (typeof t.bgFade === "number") el.style.setProperty("--bg-fade", `${t.bgFade}%`); + el.style.setProperty("--bg-fade", `${t.bgFade}%`); } export function loadLocalTheme(): ThemePrefs { - return readAccountMerged(LS.theme, DEFAULT_THEME); + return normalizeTheme(readAccountMerged(LS.theme, DEFAULT_THEME)); } export function saveLocalTheme(t: ThemePrefs): void { From 7d480e2ad0f192d733ac7f2a90ffbb30c82a2bd8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 24 Sep 2026 04:49:07 +0200 Subject: [PATCH 009/137] feat(design): Matrix toned down to moss, text on an accent fill held to --accent-fg The lime accent was the loudest of the four (OKLCH L 0.85, C 0.21) and read as neon; the owner chose the moss variant at Midnight's lightness with near-neutral greys, and the Matrix backdrops follow it. Fixed white/black parts on a solid accent fill (two switch knobs, three play buttons, the Up next pill) now use --accent-fg, and theme.contract.test.ts refuses a fixed white or black next to a solid bg-accent. --- docs/DESIGN.md | 3 ++- frontend/public/backdrops/light/matrix.svg | 10 +++---- frontend/public/backdrops/matrix.svg | 10 +++---- frontend/src/components/PlexInfo.tsx | 2 +- frontend/src/components/PlexPlaylistView.tsx | 2 +- frontend/src/components/UpNextCard.tsx | 2 +- frontend/src/components/plexCards.tsx | 2 +- frontend/src/components/plexDetailUi.tsx | 2 +- frontend/src/components/ui/form.tsx | 4 +-- frontend/src/design-lab/batchA.tsx | 4 +-- frontend/src/design-lab/batchB.tsx | 8 +++--- frontend/src/index.css | 28 +++++++++++--------- frontend/src/lib/theme.contract.test.ts | 19 ++++++++++++- frontend/src/lib/theme.ts | 2 +- 14 files changed, 59 insertions(+), 39 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 17c48a41..c406a549 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -70,7 +70,7 @@ style guide. - **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger --warn --success --info --border`, each with a `-fg` partner where text sits on it). Four schemes × dark - and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (lime). Each earns its + and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss). Each earns its place with its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. [WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). - **The accent never looks like a status.** A scheme's accent is at least 30° of OKLCH hue away from @@ -302,3 +302,4 @@ Wherever there is text, the user can select it and copy it, and no click action | 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up | | 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip | | 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) | +| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 | diff --git a/frontend/public/backdrops/light/matrix.svg b/frontend/public/backdrops/light/matrix.svg index 9ef8303c..f794cacf 100644 --- a/frontend/public/backdrops/light/matrix.svg +++ b/frontend/public/backdrops/light/matrix.svg @@ -1,13 +1,13 @@ - - - - + + + + - + diff --git a/frontend/public/backdrops/matrix.svg b/frontend/public/backdrops/matrix.svg index 038ecfc8..b5380ffa 100644 --- a/frontend/public/backdrops/matrix.svg +++ b/frontend/public/backdrops/matrix.svg @@ -1,13 +1,13 @@ - - - - + + + + - + diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index e85f5bd1..84709be4 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -594,7 +594,7 @@ export default function PlexInfo({ {onPlay && (
diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx index f2a8c6fc..11b6aff2 100644 --- a/frontend/src/components/ui/form.tsx +++ b/frontend/src/components/ui/form.tsx @@ -43,8 +43,8 @@ export function Switch({ className={`w-9 h-5 rounded-full transition relative shrink-0 disabled:opacity-50 disabled:cursor-not-allowed ${checked ? "bg-accent" : "bg-border"}`} > diff --git a/frontend/src/design-lab/batchA.tsx b/frontend/src/design-lab/batchA.tsx index 4009c01d..7f9e48f9 100644 --- a/frontend/src/design-lab/batchA.tsx +++ b/frontend/src/design-lab/batchA.tsx @@ -104,7 +104,7 @@ function TouchRow() {
Dark mode - +
@@ -136,7 +136,7 @@ function NativeSet() {
-
); } diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index 69d92968..18428c9c 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BookmarkPlus, Check, GripVertical, Play, Save, Star, Trash2, X } from "lucide-react"; +import { BookmarkPlus, Check, Play, Save, Star, Trash2, X } from "lucide-react"; import { closestCenter, DndContext, @@ -14,13 +14,15 @@ import { CSS } from "@dnd-kit/utilities"; import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api"; import Modal from "./ui/Modal"; import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; import { useSavedViews, asFilters } from "./savedViewsContext"; import { useActiveFilters } from "../lib/useActiveFilters"; import { buildPlexActiveFilters } from "./usePlexActiveFilters"; import { useUndoable } from "../lib/useUndoable"; import UndoToolbar from "./ui/UndoToolbar"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; import { useSortableA11y, useSortableItem } from "./ui/sortableA11y"; +import { DragHandle } from "./ui/DragHandle"; +import { Pressable } from "./ui/Pressable"; // Master–detail manager for saved ("personal") views: the left column lists the views (drag to // reorder) plus a "save the current filters" input; the right column shows the selected view's name @@ -50,22 +52,13 @@ function ViewRow({ selected ? "bg-accent/15" : "hover:bg-card" }`} > - - + {view.is_default && } {active && }
@@ -214,16 +207,15 @@ export default function PersonalViewsModal() { placeholder={t("views.namePlaceholder")} className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30" /> -
@@ -238,15 +230,15 @@ export default function PersonalViewsModal() { onBlur={() => void onRename()} className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30" /> - void onRename()} disabled={!nameChanged} - title={t("views.rename")} - aria-label={t("views.rename")} - className="inline-flex shrink-0 items-center rounded-lg border border-border p-2 text-muted transition enabled:not-aria-disabled:hover:border-accent enabled:not-aria-disabled:hover:text-accent disabled:opacity-30" + label={t("views.rename")} > - - + +
@@ -294,7 +286,10 @@ export default function PersonalViewsModal() { > {t("views.apply")} - } onClick={async () => { const saved = await saveCurrent(selected.id); // Re-seed the draft to what we just stored (the live filters) so the chip row @@ -305,36 +300,32 @@ export default function PersonalViewsModal() { title={ saveCurrentDisabled ? t("views.saveCurrentUpToDate") : t("views.saveCurrentHint") } - className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted transition enabled:not-aria-disabled:hover:border-accent enabled:not-aria-disabled:hover:text-accent disabled:opacity-40" > - {t("views.saveCurrent")} - - - +
) : ( diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index c3a3e8b0..ce6497ed 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -61,7 +61,9 @@ import { useYtVolume, } from "../lib/playerModalHooks"; import { useScrollFade } from "../lib/useScrollFade"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // Experiment (branch experiment/inline-player): play the video in-app via the // YouTube IFrame Player API (not a bare embed) so we can read playback position @@ -574,7 +576,7 @@ export default function PlayerModal({ where there's no room beside the card. */}
{hasQueue && ( - { e.stopPropagation(); goPrev(); @@ -585,7 +587,7 @@ export default function PlayerModal({ className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid" > - + )}
(a body-portaled node is painted underneath the element holding the lock, i.e. invisible). */} + {!navigated && } {!navigated && ( - + )} {!navigated && ( - - )} - {!navigated && ( - + )} - - +
)} {/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */} @@ -802,14 +788,15 @@ export default function PlayerModal({ {navigated && ( - + )} {t("player.noDescription")}
)} - + + {/* Channel + meta on one line, with the watched toggle pushed to the right. @@ -952,60 +940,56 @@ export default function PlayerModal({ own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there the docked bar offers OUR true fullscreen. */} {!navigated && ( - - )} - {!navigated && ( - + + )} + {!navigated && } {!navigated && ( )} {!navigated && onRemoveFromList && ( - + + )} {!navigated && ( - + )} {hasQueue && ( - { e.stopPropagation(); goNext(); @@ -1016,7 +1000,7 @@ export default function PlayerModal({ className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid" > - + )} {/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 20f8f7d8..4d53dc01 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -49,6 +49,7 @@ import { formatRuntime } from "../lib/format"; import { applyView, sameOrder } from "../lib/playlistView"; const PlayerModal = lazy(() => import("./PlayerModal")); import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { type ActionMenuItem } from "./ui/ActionMenu"; import { useComfortPin, pinVideo } from "./comfortPin"; @@ -964,26 +965,25 @@ export default function Playlists() { )} {linked && detail.dirty && ( - + )} {editable && ( - + ) : ( @@ -257,13 +259,13 @@ function CrewPanel({ {crew.map((c, i) => (
{onOpenPerson ? ( - + ) : (
{c.name}
)} @@ -471,13 +473,14 @@ export default function PlexInfo({ } /> {overlay && onClose && ( - + + )}
@@ -592,57 +595,41 @@ export default function PlexInfo({ {!overlay && (
{onPlay && ( - + )} {detail.status === "watched" ? ( - + ) : ( - + )} {inProgress && ( - + )} {detail.kind === "movie" && ( - + )} {isAdmin && detail.kind === "movie" && library && ( - + )}
)} @@ -709,8 +696,9 @@ export default function PlexInfo({ ); return clickable ? ( - + ) : (
{inner} @@ -747,27 +735,28 @@ export default function PlexInfo({ {col.title} {onFilter && ( - + )}
{col.items.map((m, i) => ( - onPlayItem?.(m.id)} disabled={!onPlayItem} - className="group/col w-28 shrink-0 text-left" + className="group/col w-28 shrink-0" >
{m.year ?
{m.year}
: null} - + ))}
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index ee942539..e4ac92e9 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -16,7 +16,6 @@ import { ArrowLeft, ChevronDown, ChevronRight, - GripVertical, ListTree, Pencil, Play, @@ -32,7 +31,9 @@ import { useConfirm } from "./confirmContext"; import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; +import { DragHandle } from "./ui/DragHandle"; import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits"; import { sameListCollision, @@ -128,21 +129,6 @@ function Poster({ src, className }: { src: string; className: string }) { ); } -/** `title` names the row it moves: a screen reader heard the English word "reorder" on every - * row, with nothing to say which. */ -function DragHandle({ title, ...props }: { title: string } & Record) { - const { t } = useTranslation(); - return ( - - ); -} - function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean }) { const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({ @@ -161,13 +147,17 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean style={style} className={`flex items-center gap-2.5 rounded-lg ${dense ? "px-1.5 py-1" : "glass-card p-1.5"}`} > - + {ep.episode_number} - - + ctx.onRemove([ep.id])} disabled={ctx.busy} - className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30" - aria-label={t("plex.playlist.removeItem", { title: ep.title })} + label={t("plex.playlist.removeItem", { title: ep.title })} > - - + + ); } @@ -215,10 +205,14 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) { style={style} className="glass-card flex items-center gap-3 rounded-xl p-2" > - - - + ctx.onRemove([item.id])} disabled={ctx.busy} - className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30" - aria-label={t("plex.playlist.removeItem", { title: item.title })} + label={t("plex.playlist.removeItem", { title: item.title })} > - - + + ); } @@ -263,19 +257,22 @@ function ShowGroup({ block, ctx }: { block: Extract; ct
{/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */}
- - + {open ? : } + {!dense && ( // A second, pointer-only target for the same toggle (its images are decorative): out of // the tab order, so the keyboard does not stop on a button with no name. - + )} - - + ctx.onRemove(block.items.map((i) => i.id))} disabled={ctx.busy} - title={t("plex.playlist.removeShow")} - className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30" + label={t("plex.playlist.removeShow")} > - - + +
{open && ( @@ -322,29 +319,26 @@ function ShowGroup({ block, ctx }: { block: Extract; ct
{dense && ( - + {sOpen ? : } + )} {seasonLabel(s)} · {s.items.length} - ctx.onRemove(s.items.map((i) => i.id))} disabled={ctx.busy} - title={t("plex.playlist.removeSeason")} - className="ml-1 rounded-sm p-0.5 text-muted hover:text-danger disabled:opacity-30" + label={t("plex.playlist.removeSeason")} + className="ml-1" > - - + +
{sOpen && (
- + + {items.length > 0 && ( - + )} - + + +
{q.isError && !q.data ? ( diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index bb3659f7..0a0c8a28 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -26,7 +26,8 @@ import { formatNumber, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./ui/Tooltip"; import { notifyActionError } from "../lib/actionErrors"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; const POLL_MS = 4000; // While any job is running, poll faster so short scheduled runs aren't missed between ticks. @@ -200,21 +201,12 @@ function JobRow({ }`} /> {t("scheduler.minutes")} - - - - + + + + + + {!valid && ( {t("scheduler.range", { min: 1, max: 1440 })} @@ -223,14 +215,14 @@ function JobRow({ ) : ( - + )}
@@ -261,14 +253,14 @@ function JobRow({ )}
- - - + + ); @@ -516,17 +508,20 @@ export default function Scheduler() {
- } onClick={() => runAllMut.mutate()} disabled={runAllMut.isPending || data.paused} - className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" > - {t("scheduler.runAll")} - + - { const ok = await confirm({ title: t("scheduler.purgeConfirmTitle"), @@ -537,21 +532,21 @@ export default function Scheduler() { if (ok) purgeMut.mutate(); }} disabled={purgeMut.isPending} - className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" > {t("scheduler.purgeDiscovery")} - + - : } onClick={() => pauseResume.mutate(data.paused)} disabled={pauseResume.isPending} - className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" > - {data.paused ? : } {data.paused ? t("scheduler.resume") : t("scheduler.pause")} - +
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index c69407f3..fff99313 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -24,6 +24,7 @@ import { START_ON_HOME } from "./navigationInit"; import { usePrefs } from "./prefsContext"; import Tabs from "./ui/Tabs"; import { useMe } from "../lib/useMe"; +import { Pressable } from "./ui/Pressable"; // The Settings page edits server-persisted preferences. Each change applies locally for instant // preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the @@ -147,7 +148,7 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
{SCHEMES.map((s) => ( -
)} - + ); } @@ -439,14 +441,15 @@ function SignInMethods({ me }: { me: Me }) { {t("settings.account.googleLink.connected")}
) : ( - + )} )} @@ -461,17 +464,19 @@ function SignInMethods({ me }: { me: Me }) { : t("settings.account.password.unsetHint")}

- + {open && (
@@ -598,13 +603,15 @@ function PlexWatchSync() { ? t("settings.plexSync.lastSync", { when: last }) : t("settings.plexSync.never")}

- + ) )} @@ -861,9 +868,9 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { }} /> - + ) : null} diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 0ea941bd..299b49ff 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -13,6 +13,7 @@ import { formatDate } from "../lib/format"; import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api"; import { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; import { FocusSafeButton } from "./ui/FocusSafeButton"; const EXPIRY_DAYS = [null, 1, 7, 30] as const; @@ -148,39 +149,35 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } value={fullUrl} className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden" /> - + {/* All three on the same rung. Only Copy was migrated in S1, which left one 2rem button between two 1.75rem ones with a different radius and hover fill — and, on a coarse pointer, `.touch-target` grew ONLY the migrated one to 2.75rem while the destructive Revoke stayed small (E10 review round 1). */} - - - - - + {video.status === "hidden" ? : } + {onResetState && resettable && ( - + + )} ); @@ -285,45 +280,59 @@ function Thumb({
{inProgress ? ( <> - - + {small ? ( + + + + ) : ( + + )} + {small ? ( + + + + ) : ( + + )} ) : ( - + + )}
)} diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx index b9eddea7..1c48e5ec 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -8,11 +8,12 @@ import Modal from "./ui/Modal"; import { notify } from "../lib/notifications"; import { api, type DownloadJob, type EditSpec } from "../lib/api"; import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry"; -import { inputCls, btnCls } from "./ui/form"; +import { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; import { failureReason, notifyActionError } from "../lib/actionErrors"; import { markReported } from "../lib/api/core"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // mm:ss.d timecode <-> seconds. function tc(s: number): string { @@ -431,23 +432,21 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos {/* transport */}
- + + {playing ? : } +
{tc(current)} / {tc(duration)}
- + {t("editor.splitHere")} +
{/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */} @@ -499,7 +498,8 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")} > {/* keep/drop toggle */} - +
))} {/* draggable boundary markers */} @@ -528,7 +528,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos setDrag({ type: "boundary", bi }); }} /> - + ))} {/* playhead */} @@ -571,16 +571,15 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos {sel && (
{t("editor.segment", { n: selIdx + 1 })} - +
{/* Quick watched toggle (whole season), on hover. */} {onToggleWatched && ( - + )} {/* Add whole season to a playlist, on hover. */} {onAdd && ( - + + )} {watched && ( ); return clickable ? ( - + ) : (
{inner} @@ -428,12 +429,12 @@ export function RelatedStrip({

{t("plex.series.related")}

{related.map((r, i) => ( - + ))} @@ -506,17 +507,17 @@ export function EpisodeCard({
{/* Quick watched toggle (single episode), on hover. */} {onToggleWatched && ( - + )} {ep.status === "watched" && ( {formatRuntime(ep.duration_seconds)}
{onAdd && ( - + + + )} diff --git a/frontend/src/components/plexDetailUi.tsx b/frontend/src/components/plexDetailUi.tsx index 1b0e9cb5..47faf675 100644 --- a/frontend/src/components/plexDetailUi.tsx +++ b/frontend/src/components/plexDetailUi.tsx @@ -9,7 +9,9 @@ import { type LucideIcon, } from "lucide-react"; import { useDismiss } from "../lib/useDismiss"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages, // so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two @@ -32,13 +34,13 @@ export function Filterable({ }) { if (!onClick) return {children}; return ( - + ); } @@ -84,14 +86,16 @@ export function DetailCustomizeMenu({ // any of them needs the platform to portal INTO a caller-named (scaled) container. return (
- + + {open && (
void; }) { return ( - + ); } @@ -156,13 +163,9 @@ export function PrefToggle({ export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) { return ( - + ); } @@ -182,19 +185,16 @@ export function ActionBtn({ disabled?: boolean; }) { return ( - } > - - - {label} - {sub && {sub}} - - + {label} + {/* The resume point rides on the same line: the md rung is one line tall (B1). */} + {sub && · {sub}} + ); } @@ -224,7 +224,7 @@ export function DetailHero({ return (
{customizeMenu}
-
)} - +
{children}
); diff --git a/frontend/src/components/ui/Banner.tsx b/frontend/src/components/ui/Banner.tsx index 4aa46974..faf180c9 100644 --- a/frontend/src/components/ui/Banner.tsx +++ b/frontend/src/components/ui/Banner.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { LucideIcon } from "lucide-react"; import { X } from "lucide-react"; -import { Button } from "./Button"; +import { IconButton } from "./IconButton"; type BannerTone = "accent" | "warning"; @@ -62,14 +62,15 @@ export default function Banner({ )} {onDismiss && ( -
); diff --git a/frontend/src/components/ui/Button.dom.test.tsx b/frontend/src/components/ui/Button.dom.test.tsx index bea62648..ae1d4d60 100644 --- a/frontend/src/components/ui/Button.dom.test.tsx +++ b/frontend/src/components/ui/Button.dom.test.tsx @@ -1,9 +1,9 @@ // @vitest-environment jsdom // -// S5: the shared , ); const btn = screen.getByRole("button", { name: "Delete" }); - expect(btn.className).toContain("btn-danger"); - expect(btn.className).toContain("py-1.5"); + expect(btn.className).toContain("state-danger"); + expect(btn.className).toContain("h-7"); }); - it("exposes an accessible name for an icon-only button while dropping the text", () => { - render( - ); + const cls = screen.getByRole("button", { name: "X" }).className.split(/\s+/); + expect(cls).toContain(h); + expect(cls).toContain("rounded-lg"); + expect(cls.filter((c) => c.startsWith("py-"))).toEqual([]); + unmount(); + } + }); + + it("hovers with a state layer, never an opacity dim or a card fill (B3)", () => { + for (const variant of ["outline", "ghost", "outline-danger", "danger-soft"] as const) { + const { unmount } = render(); + const cls = screen.getByRole("button", { name: "X" }).className; + expect(cls).toContain("state-layer"); + expect(cls).not.toMatch(/hover:(bg-card|bg-surface|opacity)/); + unmount(); + } + }); + + it("a toggle announces aria-pressed and paints the pressed state", () => { + const { rerender } = render( + , ); - const btn = screen.getByRole("button", { name: "Block channel" }); - expect(btn.getAttribute("title")).toBe("Block channel"); - expect(btn.textContent).not.toContain("this text is dropped"); - expect(btn.querySelector("[data-testid=ic]")).not.toBeNull(); + const btn = screen.getByRole("button", { name: "Default" }); + expect(btn.getAttribute("aria-pressed")).toBe("false"); + expect(btn.className).toContain("aria-pressed:bg-accent"); + rerender( + , + ); + expect(btn.getAttribute("aria-pressed")).toBe("true"); }); it("loading marks aria-busy + aria-disabled, blocks clicks, and swaps the icon", () => { @@ -112,7 +142,7 @@ describe("Button primitive (S5)", () => { , ); btn = screen.getByRole("button", { name: "Show all" }); - expect(btn.className).toContain("px-3"); + expect(btn.className).toContain("px-2.5"); }); it("keeps one font-weight utility whatever the variant", () => { @@ -132,10 +162,13 @@ describe("Button primitive (S5)", () => { const { unmount } = render(); expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target"); unmount(); - render(); - expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain( - "touch-target", - ); + for (const variant of ["link", "link-muted"] as const) { + const { unmount: u } = render(); + expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain( + "touch-target", + ); + u(); + } }); it("fullWidth adds the w-full utility", () => { @@ -205,16 +238,13 @@ describe("Button accessible-name contract (type level)", () => { it("rejects a text-less button with no name, and accepts every named spelling", () => { render( <> - {/* @ts-expect-error size="icon" with no label/aria-label/title has no accessible name */} - , ); diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index a31ecc5a..4ea21558 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -4,12 +4,12 @@ import clsx from "clsx"; import { Loader2 } from "lucide-react"; import { FocusSafeButton } from "./FocusSafeButton"; -// The ONE action-button primitive. Before this there were 385 raw + ); +}); diff --git a/frontend/src/components/ui/FocusSafeButton.tsx b/frontend/src/components/ui/FocusSafeButton.tsx index 5e83d44a..2005e364 100644 --- a/frontend/src/components/ui/FocusSafeButton.tsx +++ b/frontend/src/components/ui/FocusSafeButton.tsx @@ -38,13 +38,16 @@ export const FocusSafeButton = forwardRef< const focused = focusEvent || (!!node.current && node.current.ownerDocument.activeElement === node.current); const soft = busy || (!!disabled && focused); + // A caller's own aria-disabled is the "unavailable, and here is why" state (the tooltip or label + // carries the reason, so the button stays focusable): it is kept, never overwritten by ours. + const callerSoft = rest["aria-disabled"] === true || rest["aria-disabled"] === "true"; return ( + {iconExpanded ?? {icon}} {onTitleClick ? ( + ); } const ico = "w-4 h-4"; -function IconBtn({ box, children }: { box: string; children: ReactNode }) { - return ( - - ); -} - function OnMedia() { - const b = "grid h-8 w-8 place-items-center rounded-full bg-black/55 text-white hover:bg-black/75"; return (
- - + + + + + +
); @@ -178,9 +149,9 @@ export function BatchA() { }> Sync now - - - + + + @@ -215,26 +186,26 @@ export function BatchA() {
Techno Tim - - - - - - - - - + + + + + + + + +
- - - - - - + + + + + +
diff --git a/frontend/src/design-lab/labTokens.ts b/frontend/src/design-lab/labTokens.ts index 6f22f927..719258a1 100644 --- a/frontend/src/design-lab/labTokens.ts +++ b/frontend/src/design-lab/labTokens.ts @@ -19,7 +19,6 @@ export const STRONG = "var(--edge-strong)"; /** B3 state layers (index.css utilities). */ export const LAYER = "state-layer"; export const FILLED = "state-filled"; -export const DANGER = "state-danger"; /** B9: the header + fade edge (index.css utilities; the host is positioned). */ export const FADE_BELOW = "fade-below"; diff --git a/frontend/src/index.css b/frontend/src/index.css index 189836a8..41fe76fa 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -64,12 +64,14 @@ /* ===== E28 tokens as utilities (docs/DESIGN.md) — the ONE source the ui/ primitives and the design lab both read, so a decided value is never restated as an arbitrary class. ===== */ -/* B3: a transparent control's hover / pressed layer. */ +/* B3: a transparent control's hover / pressed layer. The layer answers only a control that can act: + not a disabled one, not a busy one (FocusSafeButton's aria-disabled), and not a pressed toggle, whose + solid accent is its state and must not turn grey under the pointer. */ @utility state-layer { - &:hover { + &:hover:not(:disabled, [aria-disabled="true"], [aria-pressed="true"]) { background-color: var(--hover); } - &:active { + &:active:not(:disabled, [aria-disabled="true"], [aria-pressed="true"]) { background-color: var(--press); } } @@ -77,20 +79,20 @@ @utility state-filled { background-color: var(--accent); color: var(--accent-fg); - &:hover { + &:hover:not(:disabled, [aria-disabled="true"]) { background-color: color-mix(in srgb, var(--accent-fg) 14%, var(--accent)); } - &:active { + &:active:not(:disabled, [aria-disabled="true"]) { background-color: color-mix(in srgb, var(--accent-fg) 24%, var(--accent)); } } @utility state-danger { background-color: var(--danger); color: var(--danger-fg); - &:hover { + &:hover:not(:disabled, [aria-disabled="true"]) { background-color: color-mix(in srgb, var(--danger-fg) 14%, var(--danger)); } - &:active { + &:active:not(:disabled, [aria-disabled="true"]) { background-color: color-mix(in srgb, var(--danger-fg) 24%, var(--danger)); } } diff --git a/frontend/src/lib/theme.contract.test.ts b/frontend/src/lib/theme.contract.test.ts index 64bc24a5..93396a90 100644 --- a/frontend/src/lib/theme.contract.test.ts +++ b/frontend/src/lib/theme.contract.test.ts @@ -40,6 +40,23 @@ it("styles exactly the schemes the app offers", () => { expect(css).toMatch(/--edge-strong:\s*color-mix\(in srgb, var\(--fg\) 55%, var\(--bg\)\)/); }); +// B3: the state layers answer only a control that can act. A disabled or busy button that lights up +// advertises a click that does nothing, and a pressed toggle that greys under the pointer hides its +// own state; the primitives (Button, IconButton) rely on the utilities to hold this for them. +it("the state layers stand down on a disabled, busy or pressed control", () => { + const guard = /:not\(:disabled, \[aria-disabled="true"\]/; + for (const name of ["state-layer", "state-filled", "state-danger"]) { + const at = css.indexOf(`@utility ${name} {`); + const body = css.slice(at, css.indexOf("\n}", at)); + const states = [...body.matchAll(/&:(hover|active)([^{]*)\{/g)]; + expect(states.length, name).toBe(2); + for (const [, state, sel] of states) expect(sel, `${name} :${state}`).toMatch(guard); + } + expect(css.slice(css.indexOf("@utility state-layer {"))).toMatch( + /&:hover:not\([^)]*\[aria-pressed="true"\]\)/, + ); +}); + // The 4.5:1 above only holds for text that USES --accent-fg: a fixed white or black on a solid accent // fill passes on one scheme and fails on the next (white on the old Matrix lime was 1.5:1). it("text on a solid accent fill uses --accent-fg", () => { From 81936e9f56c838d988b9555a8be5c4cb50869f11 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 24 Sep 2026 23:03:23 +0200 Subject: [PATCH 011/137] feat(design): every button on its family, the chip primitive, the lint rule (E28 S3b2) The second half of the button sweep: the remaining hand-rolled buttons in feature components, the design lab and the components/ui composites now stand on Button, IconButton, Pressable, Chip or DragHandle. - Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px floor, 44 on touch) replace five chip geometries; FilterChipGroup renders Chips; overflow lanes measure a mirror painted by chipClass. - The Plex control row is IconButton's player rung (36, 20px glyph, round, overlay; 44 on touch); the YT modal bar is on the sm rung with pressed cycle toggles and names that survive a folded label. - New variants: IconButton ghost-accent, danger-soft; Button warn on a new state-warn utility. The .btn-{tone} recipes and btnCls are gone. - Fixed on the way: membership rows (collection editor, Plex playlist add) announce checked/mixed; a one-letter tag chip was 21px wide; descriptionLinks moved out of lib/ (it renders components). - local/button-primitive: a raw + ); diff --git a/frontend/src/components/ExcludedChannelList.tsx b/frontend/src/components/ExcludedChannelList.tsx index 811bfd2e..31588d94 100644 --- a/frontend/src/components/ExcludedChannelList.tsx +++ b/frontend/src/components/ExcludedChannelList.tsx @@ -2,6 +2,7 @@ import { type ReactNode } from "react"; import ChannelLink from "./ChannelLink"; import NavGrid from "./ui/NavGrid"; import { navItemAttrs } from "../lib/listNav"; +import { Button } from "./ui/Button"; // The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked. // A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each @@ -58,15 +59,9 @@ export default function ExcludedChannelList({ onView={onView ? () => onView(c) : undefined} /> - + ))} diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index cb2ce71d..3c11edd6 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -42,6 +42,7 @@ import { PlexPosterCard } from "./plexCards"; import { useNavigationActions } from "./navigationContext"; import { usePlexActions } from "./plexContext"; import { navItemAttrs } from "../lib/listNav"; +import { Pressable } from "./ui/Pressable"; // The Home surface (E24) — "Mit nézzek most?". // @@ -244,8 +245,7 @@ function ShelfTile({ ? t("home.shelf.unwatchedCount", { count: count ?? 0 }) : t("home.shelf.itemCount", { count: count ?? 0 }); return ( - + ); } @@ -307,8 +307,7 @@ function Hero({ {...pinMenu.triggerProps(card)} className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]" > - +

{kicker}

diff --git a/frontend/src/components/KeyGate.tsx b/frontend/src/components/KeyGate.tsx index 3683c045..7b21ff9d 100644 --- a/frontend/src/components/KeyGate.tsx +++ b/frontend/src/components/KeyGate.tsx @@ -172,9 +172,9 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa {configured ? t("messages.unlock") : t("messages.setUp")} {configured && ( - + )} {!configured && !compact &&

{t("messages.setupWarn")}

}
diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index 12dd501f..40959122 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -3,6 +3,7 @@ import { Check, Globe } from "lucide-react"; import { LANGUAGES, type LangCode } from "../i18n"; import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; +import { Pressable } from "./ui/Pressable"; // Compact language picker (globe + current code). Presentational: the parent decides what // changing the language does (set it; persist server-side when signed in). @@ -42,15 +43,16 @@ export default function LanguageSwitcher({ return ( <> - + - + {open &&
{children}
} ); diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index ca4b44d0..c08d9366 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -19,6 +19,7 @@ import { inputCls } from "./ui/form"; import ChatThread from "./ChatThread"; import NavGrid from "./ui/NavGrid"; import { navItemAttrs } from "../lib/listNav"; +import { Pressable } from "./ui/Pressable"; type View = | "list" @@ -158,10 +159,10 @@ function ConversationList({ {...navItemAttrs(i, c.partner.name)} className="group flex items-center gap-1 rounded-xl hover:bg-card transition" > - + {!isSystem && ready && ( - + + )} ); @@ -253,10 +254,11 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac ) : (
{users.map((u) => ( - + ))}
)} @@ -317,14 +319,13 @@ function PageThread({ )}
{!isSystem && ready && ( - + + )}
diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx index 62cb3183..b642c494 100644 --- a/frontend/src/components/OnboardingWizard.tsx +++ b/frontend/src/components/OnboardingWizard.tsx @@ -11,6 +11,7 @@ import Toaster from "./ui/Toaster"; import { useOverlayOrder } from "./ui/overlayOrder"; import { useLayer } from "../lib/layerStack"; import { trapTab, useDialogChrome } from "./ui/dialogFocus"; +import { IconButton } from "./ui/IconButton"; // A first-login wizard that walks the user through granting YouTube access one scope at a // time, each with a plain explanation and an up-front heads-up about Google's "unverified @@ -172,13 +173,14 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () aria-labelledby={titleId} className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center outline-none animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]" > - + + {step === "read" && ( <> @@ -196,12 +198,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()

- + @@ -254,21 +253,21 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () )}

{importSubs.isError && ( - + {t("onboarding.write.retryImport")} + )}
- + + )} diff --git a/frontend/src/components/PaletteLauncher.tsx b/frontend/src/components/PaletteLauncher.tsx index b1f1a206..cc10af0f 100644 --- a/frontend/src/components/PaletteLauncher.tsx +++ b/frontend/src/components/PaletteLauncher.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { Command } from "lucide-react"; import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap"; +import { Pressable } from "./ui/Pressable"; // The visible way into the command palette (and, from there, the shortcut sheet). Ctrl/Cmd+K and // `h` had no entry point anywhere but a palette row — the one in-app pointer to the cheat sheet sat @@ -30,15 +31,14 @@ export function PaletteKeycap() { const { t } = useTranslation(); const cap = useCap(); return ( - + ); } @@ -47,8 +47,7 @@ export function PaletteLauncher() { const { t } = useTranslation(); const cap = useCap(); return ( - + ); } diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index 18428c9c..0cba1667 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BookmarkPlus, Check, Play, Save, Star, Trash2, X } from "lucide-react"; +import { BookmarkPlus, Check, Play, Save, Star, Trash2 } from "lucide-react"; import { closestCenter, DndContext, @@ -23,6 +23,7 @@ import UndoToolbar from "./ui/UndoToolbar"; import { useSortableA11y, useSortableItem } from "./ui/sortableA11y"; import { DragHandle } from "./ui/DragHandle"; import { Pressable } from "./ui/Pressable"; +import { DismissibleChip } from "./ui/Chip"; // Master–detail manager for saved ("personal") views: the left column lists the views (drag to // reorder) plus a "save the current filters" input; the right column shows the selected view's name @@ -259,20 +260,14 @@ export default function PersonalViewsModal() { ) : (
{captured.map((f) => ( - {f.label} - - + ))}
)} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index ce6497ed..61ec5b8e 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -46,7 +46,7 @@ import { formatViews, relativeTime, } from "../lib/format"; -import { renderDescription } from "../lib/descriptionLinks"; +import { renderDescription } from "./descriptionLinks"; import { fullscreenElement } from "../lib/fullscreen"; import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; import { useBackToClose } from "../lib/history"; @@ -748,9 +748,9 @@ export default function PlayerModal({ href={`https://www.youtube.com/watch?v=${currentVideoId}`} target="_blank" rel="noreferrer" - className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition" + className="inline-flex h-8 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium state-filled touch-target" > - + {t("player.openOnYoutube")}
diff --git a/frontend/src/components/PlaylistLimitsHint.tsx b/frontend/src/components/PlaylistLimitsHint.tsx index 64ca4cfb..b5c96ba2 100644 --- a/frontend/src/components/PlaylistLimitsHint.tsx +++ b/frontend/src/components/PlaylistLimitsHint.tsx @@ -4,6 +4,7 @@ import { Info } from "lucide-react"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; +import { Button } from "./ui/Button"; // The playlists module shows fewer lists than youtube.com does, and without a word about it that // reads as Siftlode being broken (the owner's ask, 2026-09-03: say it is YouTube's API, up front). @@ -29,14 +30,9 @@ export default function PlaylistLimitsHint() { >
) : ( - + ), )} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 72ba9f61..c32ab588 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -43,6 +43,9 @@ import { useContextMenu } from "./ui/useContextMenu"; import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews"; import { dispatch, useKeyScope } from "../lib/keymap"; import NavGrid from "./ui/NavGrid"; +import { Chip } from "./ui/Chip"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // Lazy: the rich player (pulls in hls.js) loads only when something is first played. const PlexPlayer = lazy(() => import("./PlexPlayer")); @@ -553,10 +556,10 @@ export default function PlexBrowse() { className="glass-card flex items-center gap-1 rounded-xl py-1.5 pl-1.5 pr-2.5" > {/* The photo + name open the actor's filmography page; the ✕ removes the filter. */} - - + + ))} @@ -604,12 +606,9 @@ export default function PlexBrowse() { // query — say so and offer a one-click escape, instead of a bare "No matches".
{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })} - +
) : (

{dq ? t("plex.noMatches") : t("plex.empty")}

diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx index 3271f9e0..b20950e4 100644 --- a/frontend/src/components/PlexCollectionEditor.tsx +++ b/frontend/src/components/PlexCollectionEditor.tsx @@ -9,7 +9,7 @@ import { useScrollFade } from "../lib/useScrollFade"; import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; import { useConfirm } from "./confirmContext"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Pressable } from "./ui/Pressable"; // Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE // collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to @@ -191,10 +191,12 @@ export default function PlexCollectionEditor({ key={c.id} className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm" > - toggle(c.id)} disabled={!!busy} - className={`inline-flex min-w-0 flex-1 items-center gap-2 text-left disabled:opacity-50 ${ + className={`inline-flex min-w-0 flex-1 items-center gap-2 disabled:opacity-50 ${ inIt ? "text-accent" : "" }`} > @@ -209,14 +211,15 @@ export default function PlexCollectionEditor({ {t("plex.collEditor.count", { count: c.child_count ?? 0 })} - - + ); }) @@ -243,13 +246,14 @@ export default function PlexCollectionEditor({ {t("plex.collEditor.count", { count: c.child_count ?? 0 })} - markEditable(c.id)} disabled={!!busy} - className="glass-card glass-hover shrink-0 rounded-sm px-2 py-0.5 text-xs disabled:opacity-50" > {t("plex.collEditor.makeEditable")} - + ))} diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index ecf0ca8c..634129b2 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -21,7 +21,8 @@ import PlexLibraryPicker from "./PlexLibraryPicker"; import { toggleWatchState } from "../lib/plexWatch"; import { LoadingState, StateMessage } from "./ui/QueryState"; import NavGrid from "./ui/NavGrid"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; // The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the // ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external, @@ -312,12 +313,14 @@ export default function PlexCollectionView({ > {d.summary}

- + )} @@ -412,15 +415,15 @@ export default function PlexCollectionView({ menuProps={pinMenu.triggerProps} /> {canEdit && ( - doRemove(c)} disabled={busy} - title={t("plex.collection.removeItem")} - aria-label={t("plex.collection.removeItem")} - className="glass-card glass-hover absolute right-1.5 top-1.5 z-base grid h-6 w-6 place-items-center rounded-full text-fg disabled:opacity-50" + label={t("plex.collection.removeItem")} + className="absolute right-1.5 top-1.5 z-base" > - - + + )} ))} @@ -475,13 +478,9 @@ function AddItemPanel({ aria-label={t("plex.collection.addSearch")} className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - + + + { const inColl = existing.has(c.id) || added.has(c.id); return ( - : } onClick={() => add(c)} disabled={inColl} - className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50" > - {inColl ? : } {inColl ? t("plex.collection.added") : t("plex.collection.add")} - + ); }} /> diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index 00a87b4d..90ceeed1 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -14,6 +14,7 @@ import { PlexPinMenu } from "./ComfortPinMenu"; import { useContextMenu } from "./ui/useContextMenu"; import { LoadingState, StateMessage } from "./ui/QueryState"; import NavGrid from "./ui/NavGrid"; +import { IconButton } from "./ui/IconButton"; // The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE // /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you @@ -213,15 +214,13 @@ export default function PlexDiscover({ ariaLabel={t("plex.discover.surprise")} cards={surprise} action={ - + + } {...railProps} /> diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 2d513091..f869c1d9 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -82,6 +82,9 @@ import { anyBlockingLayerAbove, escapeHandled, } from "../lib/layerStack"; +import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. const PlexInfo = lazy(() => import("./PlexInfo")); @@ -1231,13 +1234,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { }`} >
- + +
{detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title} @@ -1270,9 +1275,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { {/* Skip intro / credits (with the auto-skip countdown fill, if armed) */} {activeMarker && ( - + )} {/* Bottom controls */} @@ -1476,14 +1482,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { {/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */} {detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
- + + {tracksOpen && (
{t("plex.player.audio")} {detail.audio_streams.map((a) => ( - + ))} )} {textSubs.length > 0 && ( <> {t("plex.player.subtitles")} - + {textSubs.map((s) => ( - + ))} )} @@ -1543,14 +1552,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { {/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */} {detail && (
- + + {menuOpen && (
{settingsTabs.map((tab) => ( - + ))}
@@ -253,8 +256,10 @@ export function PlexPlayerSettings(props: { {prefs.clockDate && (
{(["hu", "en"] as const).map((st) => ( - + ))}
)} diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/components/PlexPlaylistAdd.tsx index e8fee825..dad4da0c 100644 --- a/frontend/src/components/PlexPlaylistAdd.tsx +++ b/frontend/src/components/PlexPlaylistAdd.tsx @@ -8,7 +8,7 @@ import { notifyActionError } from "../lib/actionErrors"; import { useScrollFade } from "../lib/useScrollFade"; import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Pressable } from "./ui/Pressable"; // What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a // set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist. @@ -146,11 +146,13 @@ export default function PlexPlaylistAdd({ const all = n >= size; const partial = n > 0 && n < size; return ( - toggle(p)} disabled={busy} - className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm disabled:opacity-50 ${ + className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm disabled:opacity-50 ${ all ? "text-accent" : "" }`} > @@ -175,7 +177,7 @@ export default function PlexPlaylistAdd({ {t("plex.playlistAdd.count", { count: p.item_count })} )} - + ); }) )} diff --git a/frontend/src/components/PlexSearchBox.tsx b/frontend/src/components/PlexSearchBox.tsx index 1195527c..5f757fef 100644 --- a/frontend/src/components/PlexSearchBox.tsx +++ b/frontend/src/components/PlexSearchBox.tsx @@ -9,6 +9,7 @@ import { api } from "../lib/api"; import { qk } from "../lib/queryKeys"; import { useDebounced } from "../lib/useDebounced"; import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch"; +import { Pressable } from "./ui/Pressable"; // The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a // GitHub-style field selector inside the box) and an instant typeahead of the top matches attached @@ -144,17 +145,16 @@ export default function PlexSearchBox() {
{t("plex.loading")}
)} {suggestions.map((c, i) => ( - + ))}
) : null diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index aa016893..57932d75 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -4,7 +4,7 @@ import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles"; import { useTranslation } from "react-i18next"; import { plexLang } from "../i18n"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; +import { ListMusic, Plus, SlidersHorizontal } from "lucide-react"; import { api, plexFilterCount, type PlexFilters } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { type PanelLayout } from "../lib/panelLayout"; @@ -20,6 +20,7 @@ import PersonalViewsModal from "./PersonalViewsModal"; import { useSavedViews } from "./savedViewsContext"; import { usePlex, usePlexActions } from "./plexContext"; import { useMe } from "../lib/useMe"; +import { DismissibleChip } from "./ui/Chip"; // The Plex module's left filter column — now the shared floating SidePanel with its filter groups // as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata @@ -498,16 +499,13 @@ function CollectionPicker({ function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) { const { t } = useTranslation(); return ( - - {label} - - + + {label} + ); } diff --git a/frontend/src/components/PlexStatsOverlay.tsx b/frontend/src/components/PlexStatsOverlay.tsx index 0cadd95f..24e33974 100644 --- a/frontend/src/components/PlexStatsOverlay.tsx +++ b/frontend/src/components/PlexStatsOverlay.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import type Hls from "hls.js"; import { X } from "lucide-react"; import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats"; +import { IconButton } from "./ui/IconButton"; // "Stats for nerds" debug overlay (E6 S2). Samples the live HTML5
{/* No aria-live here: the rows re-sample every second, so a live region would make a screen reader announce the changing values continuously and drown out real player announcements. diff --git a/frontend/src/components/PosterTile.tsx b/frontend/src/components/PosterTile.tsx index 64e3b75a..a2298e5c 100644 --- a/frontend/src/components/PosterTile.tsx +++ b/frontend/src/components/PosterTile.tsx @@ -1,6 +1,7 @@ import { type ReactNode } from "react"; import { hueOf, hueTileGradient } from "../lib/hueTile"; import { navItemAttrs } from "../lib/listNav"; +import { Pressable } from "./ui/Pressable"; // A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}), // the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable @@ -26,12 +27,11 @@ export default function PosterTile({ const posters = (art ?? []).slice(0, 2); const band = hueTileGradient(hueOf(title)); return ( -
- + ); } diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx index f1645337..90fb3077 100644 --- a/frontend/src/components/ProfileEditor.tsx +++ b/frontend/src/components/ProfileEditor.tsx @@ -10,6 +10,7 @@ import { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; import SegmentedToggle from "./ui/SegmentedToggle"; import FilterSelect from "./ui/FilterSelect"; +import { IconButton } from "./ui/IconButton"; const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360]; @@ -129,20 +130,16 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) { className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg glass-card" > {p.name} - - + +
))}
diff --git a/frontend/src/components/SavedViewsPopover.tsx b/frontend/src/components/SavedViewsPopover.tsx index e3a576fd..72c15954 100644 --- a/frontend/src/components/SavedViewsPopover.tsx +++ b/frontend/src/components/SavedViewsPopover.tsx @@ -4,6 +4,8 @@ import { Bookmark, Check, Settings2 } from "lucide-react"; import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { useSavedViews } from "./savedViewsContext"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as // their trigger: click → a popover list of the user's saved views (apply on click, the active one @@ -26,15 +28,26 @@ export default function SavedViewsPopover({ const label = ariaLabel ?? t("views.saved"); return ( <> - + {/* A caller-shaped trigger (the toolbar's view chip) is a surface; the default one a glyph. */} + {className ? ( + + {trigger} + + ) : ( + + {trigger} + + )} {value && ( - + onChange("")} label={t("header.clearSearch")}> + + )} {onGo && ( - {goIcon} {goLabel ?? t("header.go")} - + )}
{below} diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx index a02bcfee..b1dc5f13 100644 --- a/frontend/src/components/SetupWizard.tsx +++ b/frontend/src/components/SetupWizard.tsx @@ -6,7 +6,6 @@ import { setLanguage, type LangCode } from "../i18n"; import LanguageSwitcher from "./LanguageSwitcher"; import { Button } from "./ui/Button"; import { inputCls } from "./ui/form"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; // First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time // setup token comes from the URL the app printed to the container logs (?token=…); every step @@ -280,27 +279,23 @@ export default function SetupWizard() { )}
- } onClick={() => go(-1)} disabled={stepIdx === 0 || busy} - className="glass-card glass-hover inline-flex items-center gap-1 px-3 py-2 rounded-xl text-sm disabled:opacity-40 transition" > - {t("setup.nav.back")} - - +
diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 299b49ff..5b471306 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -3,7 +3,6 @@ import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react"; -import clsx from "clsx"; import Modal from "./ui/Modal"; import { useConfirm } from "./confirmContext"; import { notify } from "../lib/notifications"; @@ -14,7 +13,7 @@ import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../l import { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Pressable } from "./ui/Pressable"; const EXPIRY_DAYS = [null, 1, 7, 30] as const; // --- share with a registered user (ACL) ----------------------------------------------------- @@ -72,15 +71,16 @@ function UserShare({ job }: { job: DownloadJob }) { {open && filtered.length > 0 && (
{filtered.map((u) => ( - share.mutate(u.email)} disabled={share.isPending} - className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col" + className="w-full px-3 py-2 text-sm flex flex-col" > {u.display_name || u.email} {u.display_name && {u.email}} - + ))}
)} @@ -227,15 +227,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } )} className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> - savePass.mutate(newPass.trim())} - disabled={savePass.isPending || !newPass.trim()} - className="px-2 py-1 rounded-md text-xs font-semibold btn-primary disabled:opacity-40 transition" + loading={savePass.isPending} + disabled={!newPass.trim()} > {t("downloads.share.savePassword")} - + {link.has_password && ( - { if ( await confirm({ @@ -248,10 +251,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } savePass.mutate(""); }} disabled={savePass.isPending} - className="px-2 py-1 rounded-md text-xs text-muted hover:text-danger transition" > {t("downloads.share.removePassword")} - + )} )} @@ -336,20 +338,17 @@ function LinkShare({ job }: { job: DownloadJob }) {
{t("downloads.share.expiry")}: {EXPIRY_DAYS.map((d) => ( - + ))}
- - syncSubs.mutate()} - disabled={syncSubs.isPending} - className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" - > - - {t("stats.sync.syncSubscriptions")} - - - - deepAll.mutate()} - disabled={deepAll.isPending} - className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2" - > - - {t("stats.sync.backfillEverything")} - - +
+ + + + + + +
)} @@ -227,15 +230,16 @@ function SystemStats() { {s && (
- +
)} @@ -321,9 +325,10 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) { const isSystem = row.user_id === null; return (
- + {/* Proportion bar */}
0 || notFull > 0) && ( - toggle.mutate()} - disabled={toggle.isPending} - title={data.paused ? t("header.sync.resume") : t("header.sync.pause")} - className="p-1 rounded-md hover:bg-card hover:text-fg transition" + loading={toggle.isPending} + label={data.paused ? t("header.sync.resume") : t("header.sync.pause")} > - {data.paused ? : } - + {data.paused ? : } + ); // Chip: the headline count rides in the header; the state, the history link and pause live in a @@ -117,7 +118,7 @@ export default function SyncStatus({ )} ${t("header.sync.total")}`; return (
- + 0 && (
- + {!showMain && pauseBtn}
)} diff --git a/frontend/src/components/TagFilterChips.tsx b/frontend/src/components/TagFilterChips.tsx index c9ba083e..f65a9b3c 100644 --- a/frontend/src/components/TagFilterChips.tsx +++ b/frontend/src/components/TagFilterChips.tsx @@ -2,7 +2,8 @@ import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { fitChipsWithPill } from "../lib/chipOverflow"; import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit"; -import { filterChipClass } from "./ui/FilterChipGroup"; +import { Chip } from "./ui/Chip"; +import { chipClass } from "./ui/chipStyles"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; @@ -20,16 +21,11 @@ export interface FilterChip { onToggle: () => void; } -function Chip({ chip, extra }: { chip: FilterChip; extra?: string }) { +function TagChip({ chip }: { chip: FilterChip }) { return ( - + ); } @@ -103,28 +99,22 @@ export default function TagFilterChips({ }} className="inline-flex" > - {c.label} + {c.label} ))} - + +{chips.length}
{chips.slice(0, visible).map((c) => ( - + ))} {hidden > 0 && ( - + )}
@@ -134,7 +124,7 @@ export default function TagFilterChips({ className="glass-menu flex max-w-[22rem] flex-wrap items-center gap-1.5 rounded-xl p-2 shadow-lg animate-[popIn_0.16s_ease]" > {chips.slice(visible).map((c) => ( - + ))}
diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx index c66fa8d4..765fef0d 100644 --- a/frontend/src/components/TagManager.tsx +++ b/frontend/src/components/TagManager.tsx @@ -12,6 +12,8 @@ import Modal from "./ui/Modal"; import { useConfirm } from "./confirmContext"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a // hover popover on the count listing the tagged channels (each a link that focuses it in the @@ -67,14 +69,9 @@ function TagRow({ > {t("tagManager.channels", { count: tag.channel_count })} - + + + {channels.map((ch) => ( - + ))}
diff --git a/frontend/src/components/UpNextCard.tsx b/frontend/src/components/UpNextCard.tsx index e2901bcf..720285fd 100644 --- a/frontend/src/components/UpNextCard.tsx +++ b/frontend/src/components/UpNextCard.tsx @@ -5,6 +5,8 @@ import { Play, X } from "lucide-react"; import { api } from "../lib/api"; import { plexLang } from "../i18n"; import { qk } from "../lib/queryKeys"; +import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; // Up-next / end-card (E6 S4). Shown at episode end when a next item exists and the pref is on, INSTEAD // of the old instant jump — so binge stays under the viewer's control (D6-A). Counts down ~10s then @@ -84,10 +86,7 @@ export function UpNextCard({
{heading || "…"}
{sub &&
{sub}
}
- - +
- + + ); } diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index 1b6a762e..91fe61c9 100644 --- a/frontend/src/components/UploadDialog.tsx +++ b/frontend/src/components/UploadDialog.tsx @@ -14,7 +14,7 @@ import { navigateTo } from "../lib/nav"; import { formatBytes } from "../lib/format"; import { DISPLAY_NAME_MAX } from "../lib/api/downloads"; import { inputCls } from "./ui/form"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Pressable } from "./ui/Pressable"; // Local-file ingest (E7 S5c · D5-A). A general upload — ANY file type, no format list: media // (video/audio) joins the library with the full function set, a generic file is download-only. The @@ -102,8 +102,8 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {

{t("downloads.upload.hint")}

{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */} - { e.preventDefault(); @@ -158,7 +158,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) { )} - + + {/* Resume progress bar (sits at the very bottom, under the duration badge). */} {pct > 0 && (
@@ -559,7 +555,7 @@ function VideoCard({ budget). ROW_COL.actions already counts its width. */}
{actions}
- +
{/* Everything else is shrink-0, so the title is the only thing that can give — which means once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop diff --git a/frontend/src/components/VideoInfoCard.tsx b/frontend/src/components/VideoInfoCard.tsx index 21004e08..1433609d 100644 --- a/frontend/src/components/VideoInfoCard.tsx +++ b/frontend/src/components/VideoInfoCard.tsx @@ -9,6 +9,7 @@ import { linkify } from "../lib/linkify"; import Avatar from "./ui/Avatar"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; +import { IconButton } from "./ui/IconButton"; // The feed card's "more than the card face" hover infobox (E8·S3, decision D3): full (un-clamped) // title, channel, views·duration·date, a description snippet, and the channel's tags. It hangs off a @@ -31,11 +32,14 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; // so the trigger deliberately lives OUTSIDE it. export default function VideoInfoCard({ video, + onMedia = false, className, }: { video: Video; - /** Trigger-button classes (placement + hover-reveal + skin). The reveal/touch-suppression rules - * live here so each host can match its surface (overlay vs inline). */ + /** The trigger sits on the thumbnail: the round, dark translucent IconButton (A2). */ + onMedia?: boolean; + /** Trigger placement + hover-reveal. The reveal/touch-suppression rules live with the host, which + * knows its surface (overlay vs inline); the skin is the IconButton's. */ className?: string; }) { const { t, i18n } = useTranslation(); @@ -90,8 +94,8 @@ export default function VideoInfoCard({ return ( <> - + + { id: T; @@ -59,7 +60,7 @@ export default function ViewSwitcher({ return ( <> - + {/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a Button, whose variants differ only in colour. */} - + {T.repeat} + {meta.allow_download && (
{ const on = selected === y.year; return ( - + ); })} {years.length > quick.length && ( <> - + ( - + ))}
diff --git a/frontend/src/components/channelCells.tsx b/frontend/src/components/channelCells.tsx index 13c0d799..7faef334 100644 --- a/frontend/src/components/channelCells.tsx +++ b/frontend/src/components/channelCells.tsx @@ -4,7 +4,7 @@ import type { ManagedChannel } from "../lib/api"; import { formatNumber } from "../lib/format"; import Tooltip from "./ui/Tooltip"; import { isFullySynced } from "./channelSync"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { IconButton } from "./ui/IconButton"; // The Channels-manager cells that are components in their own right (they read i18n), split out of // channelColumns so that file holds only the column factory and its predicates (Fast Refresh). @@ -67,39 +67,35 @@ export function ActionsCell({ return (
- - - + + - + {c.hidden ? : } + {canWrite && ( - + + )}
diff --git a/frontend/src/lib/descriptionLinks.tsx b/frontend/src/components/descriptionLinks.tsx similarity index 97% rename from frontend/src/lib/descriptionLinks.tsx rename to frontend/src/components/descriptionLinks.tsx index 7ef0b2a2..4ed6c032 100644 --- a/frontend/src/lib/descriptionLinks.tsx +++ b/frontend/src/components/descriptionLinks.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import type { TFunction } from "i18next"; +import { Pressable } from "./ui/Pressable"; // Turn a video description into clickable nodes: // - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player @@ -78,7 +79,7 @@ export function renderDescription( if (yt) { const sameVideo = yt.id === opts.currentId; out.push( - , + , ); } else { out.push( @@ -133,13 +134,13 @@ export function renderDescription( // alternative — the timestamp group — is the one that fired. const ts = m[3]!; out.push( - , + , ); } last = m.index + m[0].length; diff --git a/frontend/src/components/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx index 180ef597..a19c3452 100644 --- a/frontend/src/components/downloadAdmin.tsx +++ b/frontend/src/components/downloadAdmin.tsx @@ -11,6 +11,8 @@ import { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; import { StatusPill } from "./downloadRow"; import { jobTitle } from "./downloadTitle"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job // list. Split out of DownloadCenter (self-contained, admin-only surface). @@ -165,18 +167,19 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string } {open && filtered.length > 0 && (
{filtered.map((u) => ( - + ))}
)} @@ -225,13 +228,12 @@ export function AdminSystem() { > {u.email} {formatBytes(u.footprint_bytes)} - + + ))} {s.per_user.length === 0 && ( diff --git a/frontend/src/components/downloadModals.tsx b/frontend/src/components/downloadModals.tsx index e12ebdae..fc43b4ad 100644 --- a/frontend/src/components/downloadModals.tsx +++ b/frontend/src/components/downloadModals.tsx @@ -95,12 +95,9 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () ))} - +

{t("downloads.edit.hint")}

@@ -148,12 +145,14 @@ export function ErrorModal({

{t(failCopyKeys(job, "body"))}

{job.error && (
- + {showRaw && (
                 {job.error}
diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx
index 9de3413d..17008407 100644
--- a/frontend/src/components/downloadRow.tsx
+++ b/frontend/src/components/downloadRow.tsx
@@ -18,6 +18,8 @@ import {
 import { navItemAttrs } from "../lib/listNav";
 import { failCopyKeys } from "../lib/downloadFailure";
 import { jobTitle } from "./downloadTitle";
+import { IconButton } from "./ui/IconButton";
+import { Pressable } from "./ui/Pressable";
 
 // Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
 // split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
@@ -136,17 +138,14 @@ export function IconBtn({
   children: ReactNode;
 }) {
   return (
-    
+    
   );
 }
 
@@ -182,14 +181,9 @@ function SourceRef({ url }: { url: string }) {
       >
         {displayUrl(url)}
       
-      
+      
+        
+      
     
); } @@ -267,14 +261,14 @@ export function DownloadRow({ ) : null} {job.status === "error" ? ( - + ) : null}
{job.source_url && } @@ -306,12 +300,10 @@ export function DownloadRow({ )} - {/* Row action buttons: grown a step here (bigger tap target + icon) without touching the shared - IconBtn — scoped so the same primitive stays smaller in the modals. `shrink-0` + no wrap keeps - them on the title's line; the text column shrinks instead. */} -
- {children} -
+ {/* Row action buttons: the md rung here (the row stands alone), the sm rung in the modals, one + IconBtn for both. `shrink-0` + no wrap keeps them on the title's line; the text column + shrinks instead. */} +
{children}
); } diff --git a/frontend/src/components/playerModalControls.tsx b/frontend/src/components/playerModalControls.tsx index 7e8c5b15..b2933370 100644 --- a/frontend/src/components/playerModalControls.tsx +++ b/frontend/src/components/playerModalControls.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react"; import type { AutoMode, LoopMode } from "../lib/playerModes"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Button } from "./ui/Button"; // Presentational controls for the YouTube modal player (PlayerModal), split out of the god component — // the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props. @@ -35,76 +35,69 @@ export function PlayerTransportControls({ }) { const { t } = useTranslation(); const docked = variant === "docked"; - const navBtn = docked - ? "inline-flex items-center gap-1 text-white/80 enabled:not-aria-disabled:hover:text-white disabled:opacity-30 transition" - : "inline-flex items-center gap-1.5 text-muted enabled:not-aria-disabled:hover:text-fg disabled:opacity-40 transition"; + // The docked bar sits on the video (always dark): the overlay variant; the modal's is a page surface. + const chrome = docked ? "overlay" : "ghost"; const navLabel = docked ? "hidden sm:inline" : ""; const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums"; const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border"; - const toggle = (on: boolean) => - docked - ? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${ - on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white" - }` - : `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${ - on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg" - }`; const toggleLabel = docked ? "hidden md:inline" : ""; return ( <> - } onClick={onPrev} disabled={index === 0} + label={t("player.previous")} title={`${t("player.previous")} · Shift+←`} - className={navBtn} > - {t("player.previous")} - + {index + 1} / {queueLength} - {t("player.next")} - - + + {/* Persistent playback settings — cycle on click, saved to your account. */} - - + ); } diff --git a/frontend/src/components/playlistColumns.tsx b/frontend/src/components/playlistColumns.tsx index f3017786..654f49be 100644 --- a/frontend/src/components/playlistColumns.tsx +++ b/frontend/src/components/playlistColumns.tsx @@ -3,6 +3,8 @@ import type { TFunction } from "i18next"; import type { Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import type { Column } from "../lib/tableColumns"; +import { IconButton } from "./ui/IconButton"; +import { Pressable } from "./ui/Pressable"; // Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`. // @@ -56,7 +58,7 @@ export function playlistColumns({ cardPrimary: true, render: (v) => (
- - +
), }, @@ -129,14 +131,14 @@ export function playlistColumns({ width: "2.5rem", nowrap: true, render: (v) => ( - + + ), }); } diff --git a/frontend/src/components/plexBrowseLists.tsx b/frontend/src/components/plexBrowseLists.tsx index c8c006e9..e42d5dcd 100644 --- a/frontend/src/components/plexBrowseLists.tsx +++ b/frontend/src/components/plexBrowseLists.tsx @@ -12,7 +12,8 @@ import { LoadingState, StateMessage } from "./ui/QueryState"; import PosterTile from "./PosterTile"; import PlexLibraryPicker from "./PlexLibraryPicker"; import NavGrid from "./ui/NavGrid"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { Button } from "./ui/Button"; +import { IconButton } from "./ui/IconButton"; // A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only // collections have no movie runtime). @@ -109,13 +110,9 @@ function NewCollectionPanel({

{t("plex.collection.newCollection")}

- + + +
( - } onClick={() => create(c)} - disabled={busy} - className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50" + loading={busy} > - {t("plex.collection.createWith")} - + )} />
@@ -190,13 +188,9 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: className="glass-card w-full max-w-sm rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" /> {isAdmin && !creating && ( - + )} {creating && ( diff --git a/frontend/src/components/plexPlayerControls.tsx b/frontend/src/components/plexPlayerControls.tsx index b081ac26..2afa1cc9 100644 --- a/frontend/src/components/plexPlayerControls.tsx +++ b/frontend/src/components/plexPlayerControls.tsx @@ -1,7 +1,7 @@ import { type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { RotateCcw } from "lucide-react"; -import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { IconButton } from "./ui/IconButton"; // ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native // muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double @@ -82,13 +82,14 @@ export function Slider({ {suffix ?? ""}
{onReset && ( - + + )} @@ -146,14 +147,17 @@ export function Ctrl({ align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2"; return (
- {children} - + diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index c3ccd0dd..92826a98 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -27,6 +27,8 @@ import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexCollectionEditor from "./PlexCollectionEditor"; import { LoadingState, StateMessage } from "./ui/QueryState"; import NavGrid from "./ui/NavGrid"; +import { Chip } from "./ui/Chip"; +import { Pressable } from "./ui/Pressable"; // The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with // shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state, @@ -222,12 +224,9 @@ export function PlexPersonView({

)}
- + {d.biography && ( @@ -428,7 +427,7 @@ export function PlexShowView({ if (!badge) return null; const { score } = badge; return ( - + ); })()} @@ -645,12 +644,9 @@ export function PlexSeasonView({ /> } > - +

{se.title}

{t("plex.series.episodeCount", { count: se.episode_count })} diff --git a/frontend/src/components/ui/ActionMenu.tsx b/frontend/src/components/ui/ActionMenu.tsx index 797a54e6..e092f36b 100644 --- a/frontend/src/components/ui/ActionMenu.tsx +++ b/frontend/src/components/ui/ActionMenu.tsx @@ -2,6 +2,8 @@ import { Fragment, useState, type ReactNode } from "react"; import { ChevronRight, MoreHorizontal } from "lucide-react"; import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks"; +import { IconButton } from "./IconButton"; +import { Pressable } from "./Pressable"; // A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions. // Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher @@ -209,19 +211,28 @@ export default function ActionMenu({ const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" }); return ( <> - + {/* A caller-shaped trigger (a labelled pill) is a surface; the default "…" is a glyph. */} + {className ? ( + + {trigger ?? + ) : ( + + {trigger ?? + )} setOpen(false)} /> diff --git a/frontend/src/components/ui/BackToTop.tsx b/frontend/src/components/ui/BackToTop.tsx index 51399a3a..f4a2bc0e 100644 --- a/frontend/src/components/ui/BackToTop.tsx +++ b/frontend/src/components/ui/BackToTop.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ArrowUp } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { Pressable } from "./Pressable"; // A floating "back to top" button that fades in once the current page's scroll container is // scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a @@ -50,7 +51,8 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr // commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"]. // The accessible name comes from a visually-hidden child instead, which those selectors can't match. return createPortal( - , + , document.body, ); } diff --git a/frontend/src/components/ui/Banner.tsx b/frontend/src/components/ui/Banner.tsx index faf180c9..b39e3275 100644 --- a/frontend/src/components/ui/Banner.tsx +++ b/frontend/src/components/ui/Banner.tsx @@ -2,20 +2,21 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { LucideIcon } from "lucide-react"; import { X } from "lucide-react"; +import { Button } from "./Button"; import { IconButton } from "./IconButton"; type BannerTone = "accent" | "warning"; -const TONES: Record = { +const TONES: Record = { accent: { bar: "bg-accent/15 border-accent/30", icon: "text-accent", - action: "btn-primary", + action: "primary", }, warning: { bar: "bg-warn/15 border-warn/30", icon: "text-warn", - action: "btn-warn", + action: "warn", }, }; @@ -54,12 +55,14 @@ export default function Banner({ {children} {action && ( - + )} {onDismiss && ( - + )} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 4ea21558..72cd7dfb 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -17,6 +17,7 @@ import { FocusSafeButton } from "./FocusSafeButton"; type ButtonVariant = | "primary" // solid accent — the main affirmative action | "danger" // solid danger — destructive confirm + | "warn" // solid warn — the action of a warning bar (reload for a new version) | "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border) | "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve) | "outline-danger" // neutral at rest, danger on hover — a row-level Delete @@ -43,6 +44,7 @@ const ACT = "enabled:not-aria-disabled:"; const VARIANTS: Record = { primary: "font-medium state-filled", danger: "font-medium state-danger", + warn: "font-medium state-warn", outline: `font-medium border border-border text-muted state-layer ${ACT}hover:text-fg`, "outline-accent": "font-medium border border-accent/40 text-accent state-layer", // Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference diff --git a/frontend/src/components/ui/Carousel.tsx b/frontend/src/components/ui/Carousel.tsx index ea858cb3..c6295e31 100644 --- a/frontend/src/components/ui/Carousel.tsx +++ b/frontend/src/components/ui/Carousel.tsx @@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next"; import clsx from "clsx"; import { useScrollEdges } from "../../lib/useScrollEdges"; import { useListNav } from "../../lib/listNav"; +import { Button } from "./Button"; +import { IconButton } from "./IconButton"; // Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing // the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future @@ -95,10 +97,10 @@ export function Carousel({ // Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover` // removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap. + // Round, dark translucent: they sit over the pictures (A2). const arrowBtn = - "reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " + - "bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " + - "opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75"; + "reveal-hover absolute top-1/2 -translate-y-1/2 z-10 backdrop-blur-xs border border-white/15 shadow-lg " + + "opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100"; return (

@@ -106,27 +108,25 @@ export function Carousel({

{title}

{onSeeAll && ( - + )}
)}
{left && ( - + + )} {/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */} @@ -140,14 +140,15 @@ export function Carousel({ {children}
{right && ( - + + )}
diff --git a/frontend/src/components/ui/Chip.dom.test.tsx b/frontend/src/components/ui/Chip.dom.test.tsx new file mode 100644 index 00000000..fc036f02 --- /dev/null +++ b/frontend/src/components/ui/Chip.dom.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +// +// E28 S3b: the chip family (docs/DESIGN.md "One primitive per button family"). One geometry for a +// filter, an applied narrowing, a tag and a suggestion, where five files had five. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { Chip, DismissibleChip } from "./Chip"; +import { chipClass } from "./chipStyles"; + +afterEach(cleanup); + +describe("Chip", () => { + it("is a pill on the 24px floor that never submits a form, and a toggle announces its state", () => { + const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault()); + render( +
+ 2024 + , + ); + const chip = screen.getByRole("button", { name: "2024" }); + expect(chip.className).toContain("rounded-full"); + expect(chip.className).toContain("h-6"); + expect(chip.className).toContain("touch-target"); + expect(chip.getAttribute("aria-pressed")).toBe("false"); + fireEvent.click(chip); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("a removing chip is named by the removal and shows the ×", () => { + const onClick = vi.fn(); + render( + + Music + , + ); + const chip = screen.getByRole("button", { name: "Remove Music" }); + expect(chip.getAttribute("title")).toBe("Remove Music"); + expect(chip.className).toContain("h-7"); + expect(chip.querySelector("svg")).not.toBeNull(); + fireEvent.click(chip); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("the measuring mirror lays out exactly like the chip it measures", () => { + render( + + tag + , + ); + const cls = screen.getByRole("button", { name: "tag" }).className; + for (const c of chipClass("tag", "xs").split(" ")) expect(cls).toContain(c); + }); +}); + +describe("DismissibleChip", () => { + it("is two sibling controls at most, never a button in a button", () => { + const onAccept = vi.fn(); + const onDismiss = vi.fn(); + const { container } = render( + + Music + , + ); + expect(container.querySelector("button button")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Add the tag Music" })); + fireEvent.click(screen.getByRole("button", { name: "Not now" })); + expect(onAccept).toHaveBeenCalledOnce(); + expect(onDismiss).toHaveBeenCalledOnce(); + }); + + it("with no body action the label is text, and only the × is a control", () => { + render( + {}} dismissLabel="Remove Year: 2024"> + Year: 2024 + , + ); + expect(screen.getAllByRole("button")).toHaveLength(1); + expect(screen.getByText("Year: 2024").closest("button")).toBeNull(); + }); +}); diff --git a/frontend/src/components/ui/Chip.tsx b/frontend/src/components/ui/Chip.tsx new file mode 100644 index 00000000..9a2e70e6 --- /dev/null +++ b/frontend/src/components/ui/Chip.tsx @@ -0,0 +1,125 @@ +import { forwardRef } from "react"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import clsx from "clsx"; +import { X } from "lucide-react"; +import { FocusSafeButton } from "./FocusSafeButton"; +import { chipClass, type ChipSize, type ChipVariant } from "./chipStyles"; + +// The chip family (docs/DESIGN.md "One primitive per button family"): a small rounded-full token that +// is a filter, an applied narrowing, a tag, or a suggestion. It was hand-painted in five files with +// five geometries; this is the one. `FilterChipGroup` renders a single-select row of these. +// +// filter — a selectable option (a year, a tag filter, a content type). Toggles with `pressed`: the +// `.pill` recipe in index.css paints the solid accent when on. Also the "+N" overflow and +// "More…" triggers, which light up while their popover is open (aria-expanded). +// token — an applied narrowing, tinted accent (the `.chip` recipe): it exists because it is on. +// tag — a user's tag, the solid accent (`.chip-solid`). Dense: it sits in table rows. +// suggest — dashed accent: a suggestion not yet taken. +// ghost — dashed neutral: the "add a tag" trigger, quiet in every table row until reached for. +// +// Nothing goes under 24px (B1, WCAG 2.5.8) and a coarse pointer grows every chip to 44 (A3). + +type ChipProps = ButtonHTMLAttributes & { + variant?: ChipVariant; + size?: ChipSize; + /** A filter chip that toggles: aria-pressed, painted solid when on. */ + pressed?: boolean; + /** Leading glyph (a lucide element). */ + icon?: ReactNode; + /** The whole chip removes what it stands for: a trailing × and the removal as its name. */ + removeLabel?: string; +}; + +/** A chip that is itself the control (a toggle, a removal, an accept, a "+N" trigger). */ +export const Chip = forwardRef(function Chip( + { + variant = "filter", + size = "sm", + pressed, + icon, + removeLabel, + type, + className, + children, + ...rest + }, + ref, +) { + return ( + + {icon} + {children} + {removeLabel && ( + + ); +}); + +/** A chip whose label is not the control but which carries its own trailing × (a captured filter in + * a saved view, a suggestion with a dismiss). Two sibling controls at most, never a button in a + * button: `onClick` makes the body one (an accept), `onDismiss` the ×. */ +export function DismissibleChip({ + variant = "token", + size = "sm", + icon, + children, + onClick, + bodyLabel, + bodyTitle, + onDismiss, + dismissLabel, + className, +}: { + variant?: Exclude; + size?: ChipSize; + icon?: ReactNode; + children: ReactNode; + onClick?: () => void; + bodyLabel?: string; + bodyTitle?: string; + onDismiss: () => void; + dismissLabel: string; + className?: string; +}) { + const body = ( + <> + {icon} + {children} + + ); + return ( + + {onClick ? ( + + {body} + + ) : ( + {body} + )} + + + + ); +} diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx index b661d342..200cc6e1 100644 --- a/frontend/src/components/ui/CommandPalette.tsx +++ b/frontend/src/components/ui/CommandPalette.tsx @@ -4,6 +4,7 @@ import { Search } from "lucide-react"; import Modal from "./Modal"; import { closeTopOverlayThenRun } from "../../lib/history"; import { pageStep, pageTarget } from "../../lib/listPaging"; +import { Pressable } from "./Pressable"; // The app's jump-to bar: one text field over every place you can go and every app-wide thing you can // do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three @@ -243,14 +244,13 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups: {g.label} {g.items.map((item) => ( - + ))} ))} diff --git a/frontend/src/components/ui/FilterChipGroup.dom.test.tsx b/frontend/src/components/ui/FilterChipGroup.dom.test.tsx index 388872b6..0a20d674 100644 --- a/frontend/src/components/ui/FilterChipGroup.dom.test.tsx +++ b/frontend/src/components/ui/FilterChipGroup.dom.test.tsx @@ -4,7 +4,7 @@ // paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed / onChange / testId contract. import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent, cleanup } from "@testing-library/react"; -import FilterChipGroup, { filterChipClass } from "./FilterChipGroup"; +import FilterChipGroup from "./FilterChipGroup"; const OPTS = [ { value: "all", label: "All", testId: "chip-all" }, @@ -56,8 +56,12 @@ describe("FilterChipGroup", () => { expect(screen.getByRole("group", { name: "Sync state" })).toBeTruthy(); }); - it("exports the canonical chip class for multi-select siblings to reuse", () => { - expect(filterChipClass).toContain("rounded-full"); - expect(filterChipClass).toContain("pill"); + it("renders its options as Chips: the pill, on the 24px rung", () => { + render(); + const chip = screen.getByTestId("chip-all"); + expect(chip.className).toContain("rounded-full"); + expect(chip.className).toContain("pill"); + expect(chip.className).toContain("h-6"); + expect(chip.getAttribute("type")).toBe("button"); }); }); diff --git a/frontend/src/components/ui/FilterChipGroup.tsx b/frontend/src/components/ui/FilterChipGroup.tsx index 99bc79d9..4c107422 100644 --- a/frontend/src/components/ui/FilterChipGroup.tsx +++ b/frontend/src/components/ui/FilterChipGroup.tsx @@ -1,13 +1,6 @@ import { type ReactNode } from "react"; import CountBadge from "./CountBadge"; - -// Canonical filter-chip geometry — the rounded-full "Filters bar" look used app-wide for a selectable -// filter chip. Exported so single-select groups (this component) AND standalone/multi-select filter -// chips (e.g. the feed content-type toggles) render identically from ONE source of truth. The active -// paint comes entirely from the `.pill[aria-pressed="true"]` recipe (index.css) — carry no -// bg/text/border-colour utilities here or a utility class would beat the pressed fill. -export const filterChipClass = - "pill inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px"; +import { Chip } from "./Chip"; export interface FilterChipOption { value: T; @@ -19,7 +12,7 @@ export interface FilterChipOption { } // A single-select row of filter chips (exactly one active), radio-like. For a multi-select chip row, -// render plain buttons with `filterChipClass` instead — same look, different (independent) semantics. +// render `Chip`s with `pressed` instead — same look, different (independent) semantics. export default function FilterChipGroup({ options, value, @@ -39,18 +32,17 @@ export default function FilterChipGroup({ {options.map((o) => { const on = value === o.value; return ( - + ); })} diff --git a/frontend/src/components/ui/IconButton.tsx b/frontend/src/components/ui/IconButton.tsx index cc76174d..5b670bd9 100644 --- a/frontend/src/components/ui/IconButton.tsx +++ b/frontend/src/components/ui/IconButton.tsx @@ -11,12 +11,14 @@ import { FocusSafeButton } from "./FocusSafeButton"; type IconButtonVariant = | "ghost" // the default: quiet glyph, a state layer on hover + | "ghost-accent" // an accent glyph: the action it stands for is already in effect (queued, requested) | "primary" // solid accent — the one affirmative action of a compact form (Send, Save view) | "outline" // bordered — an icon action that stands on its own in a form or a header | "outline-accent" // bordered + accent — an affirmative row action (Approve) | "outline-danger" // bordered, danger on hover — a row's destructive action beside bordered siblings | "outline-warn" // bordered + warn — a reversible restriction that is currently ON (Suspended) | "danger" // quiet at rest, danger on hover — a row's delete/remove + | "danger-soft" // bordered AND danger at rest — a destructive restriction that is ON (Blocked) | "glass" // glass card — beside the glass Buttons on a Plex page | "media" // round, dark translucent — on a thumbnail or a poster | "overlay"; // white glyph on the always-dark player chrome, whatever the theme @@ -24,17 +26,20 @@ type IconButtonVariant = type IconButtonSize = | "sm" // 28 — rows and dense toolbars | "md" // 32 — on its own - | "lg"; // 48 — only the Play in the middle of a picture (C5: a not-started card) + | "lg" // 48 — only the Play in the middle of a picture (C5: a not-started card) + | "player"; // 36 with a 20px glyph, 44 on touch (A3) — the Plex player's control row (C5) const ACT = "enabled:not-aria-disabled:"; const VARIANTS: Record = { ghost: `text-muted state-layer ${ACT}hover:text-fg`, + "ghost-accent": "text-accent state-layer", primary: "state-filled", outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`, "outline-accent": "border border-accent/40 text-accent state-layer", "outline-danger": `border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`, "outline-warn": "border border-warn/40 text-warn state-layer", danger: `text-muted state-layer ${ACT}hover:text-danger`, + "danger-soft": `border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`, glass: `glass-card glass-hover ${ACT}hover:text-accent`, // The player chrome and a thumbnail are dark in the light theme too, so these two paint with // white and black, not with the theme's fg (whose state layer would be a dark wash on a dark video). @@ -50,6 +55,7 @@ const SIZES: Record = { sm: "size-7 [&>svg]:size-4", md: "size-8 [&>svg]:size-4", lg: "size-12 [&>svg]:size-6", + player: "size-9 [&>svg]:size-5", }; export interface IconButtonProps extends Omit, "children"> { @@ -94,7 +100,7 @@ export const IconButton = forwardRef(functio aria-pressed={pressed} title={noTitle ? undefined : label} className={clsx( - "inline-grid shrink-0 place-items-center transition touch-target disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed", + "inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed", VARIANTS[variant], onMedia || variant === "media" ? "rounded-full" : "rounded-lg", pressed !== undefined && PRESSED, diff --git a/frontend/src/components/ui/Pager.tsx b/frontend/src/components/ui/Pager.tsx index 2623ad1c..3cdfab19 100644 --- a/frontend/src/components/ui/Pager.tsx +++ b/frontend/src/components/ui/Pager.tsx @@ -2,8 +2,8 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { hasPagerContent, pageCount } from "./pagerContent"; -import { FocusSafeButton } from "./FocusSafeButton"; import SegmentedToggle from "./SegmentedToggle"; +import { Button } from "./Button"; // The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view // (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page + @@ -66,14 +66,15 @@ export default function Pager({
{totalPages > 1 && (
- } onClick={() => goToPage(Math.max(0, safePage - 1))} disabled={safePage === 0} - className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition" > - {t("datatable.pager.prev")} - + {t("datatable.pager.pageLabel")} {t("datatable.pager.ofTotal", { total: totalPages })} - goToPage(Math.min(totalPages - 1, safePage + 1))} disabled={safePage >= totalPages - 1} - className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition" > {t("datatable.pager.next")} - +
)} {total > 0 && ( diff --git a/frontend/src/components/ui/PanelGroup.tsx b/frontend/src/components/ui/PanelGroup.tsx index 487a06de..8b42a007 100644 --- a/frontend/src/components/ui/PanelGroup.tsx +++ b/frontend/src/components/ui/PanelGroup.tsx @@ -1,8 +1,11 @@ import { type ReactNode, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react"; +import { ChevronDown, Eye, EyeOff, Pencil } from "lucide-react"; import { CSS } from "@dnd-kit/utilities"; import { useSortableItem } from "./sortableA11y"; +import { DragHandle } from "./DragHandle"; +import { IconButton } from "./IconButton"; +import { Pressable } from "./Pressable"; // One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag // handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three @@ -46,7 +49,7 @@ export default function PanelGroup({ const titleIsTrigger = !editing && !collapsible && !!onTitleClick; const titleCls = - "flex-1 min-w-0 flex items-center gap-1 text-left select-none text-sm uppercase tracking-wide"; + "flex-1 min-w-0 flex items-center gap-1 select-none text-sm uppercase tracking-wide"; return (
{editing && ( - + label={t("sidebar.dragToReorder")} + className="-ml-1" + /> )} {collapsible && !editing ? ( - + ) : titleIsTrigger ? ( - + ) : ( {title} )} {editing ? ( - + {hidden ? : } + ) : ( collapsible && ( - + ) )}
diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 5097d2e8..0be8fb54 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -39,7 +39,7 @@ domain-free. | area | components | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` | -| actions & inputs | `Button`, `IconButton`, `Pressable`, `DragHandle` (one per button family, docs/DESIGN.md), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` | +| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw ` + {expanded && (
diff --git a/frontend/src/components/ui/Toaster.tsx b/frontend/src/components/ui/Toaster.tsx index 263cbbf3..daf5d5a1 100644 --- a/frontend/src/components/ui/Toaster.tsx +++ b/frontend/src/components/ui/Toaster.tsx @@ -6,6 +6,8 @@ import { createStore } from "../../lib/store"; import { useToastDock } from "../../lib/toastDock"; import { useOverlayOrder } from "./overlayOrder"; import { LEVEL_STYLE } from "./toastLevels"; +import { Button } from "./Button"; +import { IconButton } from "./IconButton"; // Overlay hosts. The app's own Toaster sits in the content column, and that column is a stacking // context (its `@container` implies layout containment) — so anything portalled to at the @@ -170,24 +172,25 @@ export default function Toaster({ )}
{toast.action && ( - + )} - + + {toast.duration && ( // Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too. diff --git a/frontend/src/components/ui/UndoToolbar.tsx b/frontend/src/components/ui/UndoToolbar.tsx index 3579dc42..4c5ec3d8 100644 --- a/frontend/src/components/ui/UndoToolbar.tsx +++ b/frontend/src/components/ui/UndoToolbar.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Redo2, Undo2 } from "lucide-react"; import { dispatch, useKeyScope } from "../../lib/keymap"; -import { FocusSafeButton } from "./FocusSafeButton"; +import { IconButton } from "./IconButton"; // Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z // (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any @@ -44,28 +44,14 @@ export default function UndoToolbar({ return () => window.removeEventListener("keydown", onKey); }, [canUndo, canRedo, onUndo, onRedo]); - const btn = - "p-1.5 rounded-lg border border-border text-muted enabled:not-aria-disabled:hover:text-fg enabled:not-aria-disabled:hover:border-accent disabled:opacity-30 transition"; return (
- - - - - - + + + + + +
); } diff --git a/frontend/src/components/ui/chipStyles.ts b/frontend/src/components/ui/chipStyles.ts new file mode 100644 index 00000000..8a1d56a7 --- /dev/null +++ b/frontend/src/components/ui/chipStyles.ts @@ -0,0 +1,37 @@ +import clsx from "clsx"; + +// The chip family's geometry and paint, apart from the component (`Chip.tsx`) so the hidden measuring +// mirror of an overflowing chip lane can lay out exactly like the chips it measures. + +export type ChipVariant = "filter" | "token" | "tag" | "suggest" | "ghost"; +export type ChipSize = + | "md" // 28, text-sm — the applied-filter row above the feed + | "sm" // 24, text-xs — a filter row + | "xs"; // 24, text-2xs, tight — a tag in a table row + +const ACT = "enabled:not-aria-disabled:"; +const PAINT: Record = { + // `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed + // fill, so the open-popover state is the only one spelled out. + filter: "pill border shadow-xs aria-expanded:border-accent aria-expanded:text-accent", + token: `chip ${ACT}hover:border-accent ${ACT}hover:bg-accent/20 aria-expanded:border-accent`, + // B3: a filled control's hover mixes its own text colour into the fill, never an opacity dim. + tag: `chip-solid font-medium ${ACT}hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))]`, + suggest: `border border-dashed border-accent/50 text-accent/90 ${ACT}hover:text-accent ${ACT}hover:border-accent`, + ghost: `border border-dashed border-border text-muted ${ACT}hover:text-accent ${ACT}hover:border-accent`, +}; + +const SIZES: Record = { + md: "h-7 px-2.5 text-sm gap-1", + sm: "h-6 px-2.5 text-xs gap-1.5", + xs: "h-6 px-1.5 text-2xs gap-1", +}; + +const BASE = + "inline-flex min-w-6 shrink-0 items-center justify-center rounded-full whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0"; + +/** The chip's geometry + paint as a class string: for the hidden measuring mirror of an overflowing + * chip lane, which must lay out exactly like the chips it measures but must not be buttons. */ +export function chipClass(variant: ChipVariant = "filter", size: ChipSize = "sm") { + return clsx(BASE, PAINT[variant], SIZES[size]); +} diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx index 11b6aff2..694ffd7b 100644 --- a/frontend/src/components/ui/form.tsx +++ b/frontend/src/components/ui/form.tsx @@ -14,9 +14,6 @@ import { FocusSafeButton } from "./FocusSafeButton"; export const inputCls = "w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"; -/** The standard text button style (used verbatim by ShareDialog + VideoEditor). */ -export const btnCls = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50"; - /** Pill on/off switch — the bare control; compose it with your own label/row. Pass `label` for * the accessible name (the visible row text) since the control itself has no inner text. */ export function Switch({ diff --git a/frontend/src/components/ui/tableHeader.tsx b/frontend/src/components/ui/tableHeader.tsx index f0542f97..69feeea0 100644 --- a/frontend/src/components/ui/tableHeader.tsx +++ b/frontend/src/components/ui/tableHeader.tsx @@ -8,6 +8,9 @@ import { type SortState } from "../../lib/columnSort"; import { isActive, type Column, type FilterMap } from "../../lib/tableColumns"; import { alignClass } from "./tableAlign"; import { useStickyTop } from "./stickyTop"; +import { Button } from "./Button"; +import { IconButton } from "./IconButton"; +import { Pressable } from "./Pressable"; // The column-header machinery — the sortable header row with its filter funnel + popover — lifted // OUT of DataTable so a non-DataTable body can render under the same headers. The column contract @@ -75,17 +78,16 @@ function FilterFunnel({ return ( <> - + + ({ )} {filterOn && ( - + )} @@ -229,7 +232,7 @@ export function ColumnHead({ focusable no-op button, which put unlabeled dead stops in the tab order — two of them per playlist table, whose grip and actions headers are empty. */} {col.sortable ? ( - + ) : ( {col.header} )} diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx index c28b1186..ccab4cd8 100644 --- a/frontend/src/design-lab/DesignLab.tsx +++ b/frontend/src/design-lab/DesignLab.tsx @@ -1,9 +1,9 @@ import { useLayoutEffect, useState } from "react"; -import clsx from "clsx"; import { SCHEMES, type Scheme } from "../lib/theme"; import { BatchA } from "./batchA"; import { BatchB } from "./batchB"; import { BatchC } from "./batchC"; +import { Chip } from "../components/ui/Chip"; // The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits // OUTSIDE , so it owns the theme attributes on itself, through the same data-theme / @@ -23,12 +23,6 @@ function applyLook(l: Look) { el.style.setProperty("--font-scale", String(l.fontScale)); } -const chip = (on: boolean) => - clsx( - "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition", - on ? "border-accent bg-accent text-accent-fg" : "border-border text-muted hover:text-fg", - ); - export default function DesignLab() { const [look, setLook] = useState(START); useLayoutEffect(() => { @@ -47,36 +41,22 @@ export default function DesignLab() { Design lab {(["dark", "light"] as const).map((m) => ( - + ))} {SCHEMES.map((s) => ( - + ))} - +