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)";