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.
This commit is contained in:
+72
-32
@@ -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 |
|
||||
|
||||
@@ -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 href="#decided" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
|
||||
A ✓
|
||||
</a>
|
||||
<a href="#decided-b" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
|
||||
B ✓
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<main className="mx-auto flex max-w-screen-2xl flex-col gap-6 p-4">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Batch B · surfaces, chrome and colour</h1>
|
||||
<h1 className="text-2xl font-bold">Batch C · behaviour and device</h1>
|
||||
<p className="text-sm text-muted">
|
||||
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() {
|
||||
</p>
|
||||
</header>
|
||||
<BatchA />
|
||||
<header id="decided-b" className="mt-6 scroll-mt-20">
|
||||
<h2 className="text-xl font-bold">Decided · batch B, surfaces, chrome and colour</h2>
|
||||
<p className="text-sm text-muted">
|
||||
B1 (density) and B3 (state layers) are rendered inside the batch A references above.
|
||||
</p>
|
||||
</header>
|
||||
<BatchB />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
+151
-236
@@ -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<Tone, string> = {
|
||||
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<Size, string> = {
|
||||
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({
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"inline-flex items-center justify-center transition",
|
||||
"inline-flex items-center justify-center rounded-lg font-medium transition",
|
||||
TONE[tone],
|
||||
SIZE[size],
|
||||
radius,
|
||||
H[size],
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
@@ -66,54 +56,14 @@ function Btn({
|
||||
|
||||
const ico = "w-4 h-4";
|
||||
|
||||
// ── A1 · button shape and ladder ──────────────────────────────────────────────────────────────────
|
||||
function ButtonSet({ radius }: { radius: Radius }) {
|
||||
return (
|
||||
<>
|
||||
<Sample label="Toolbar / panel header · sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Btn tone="glass" size="sm" radius={radius} icon={<RefreshCw className={ico} />}>
|
||||
Sync now
|
||||
</Btn>
|
||||
<Btn tone="outline" size="sm" radius={radius}>
|
||||
Full history
|
||||
</Btn>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="Dialog footer / form · md">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Btn tone="ghost" size="md" radius={radius}>
|
||||
Cancel
|
||||
</Btn>
|
||||
<Btn tone="danger" size="md" radius={radius} icon={<Trash2 className={ico} />}>
|
||||
Delete
|
||||
</Btn>
|
||||
<Btn tone="primary" size="md" radius={radius}>
|
||||
Save
|
||||
</Btn>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="Inside a table row · xs">
|
||||
<div className="flex items-center justify-between rounded-lg bg-card/60 px-3 py-2 text-sm">
|
||||
<span className="truncate">Download failed · HTTP 403</span>
|
||||
<Btn tone="outline" size="xs" radius={radius}>
|
||||
Retry
|
||||
</Btn>
|
||||
</div>
|
||||
</Sample>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── A2 · icon buttons ─────────────────────────────────────────────────────────────────────────────
|
||||
function Icon({ box, radius, children }: { box: string; radius: string; children: ReactNode }) {
|
||||
function IconBtn({ box, children }: { box: string; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"grid place-items-center text-muted hover:text-fg hover:bg-card transition",
|
||||
"grid place-items-center rounded-lg text-muted hover:text-fg transition",
|
||||
LAYER,
|
||||
box,
|
||||
radius,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -121,13 +71,10 @@ function Icon({ box, radius, children }: { box: string; radius: string; children
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
function OnMedia() {
|
||||
const b = "grid h-8 w-8 place-items-center rounded-full bg-black/55 text-white hover:bg-black/75";
|
||||
return (
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg bg-linear-to-br from-accent/50 to-card">
|
||||
<div className="relative aspect-video w-56 overflow-hidden rounded-lg bg-linear-to-br from-accent/50 to-card">
|
||||
<span className="absolute bottom-1.5 right-1.5 flex gap-1.5">
|
||||
<button type="button" className={b}>
|
||||
<Play className={ico} />
|
||||
@@ -140,133 +87,36 @@ function OnMedia({ radius }: { radius: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function IconSet({ row, bar, media }: { row: string; bar: string; media: string }) {
|
||||
const r = row.includes("full") ? "rounded-full" : "rounded-lg";
|
||||
function TouchRow() {
|
||||
const pill = "inline-flex min-h-11 items-center px-3 text-sm";
|
||||
return (
|
||||
<>
|
||||
<Sample label="Table-row actions">
|
||||
<div className="flex items-center justify-between rounded-lg bg-card/60 px-3 py-1 text-sm">
|
||||
<span className="truncate">Techno Tim</span>
|
||||
<span className="flex gap-1">
|
||||
<Icon box={row} radius={r}>
|
||||
<Pencil className={ico} />
|
||||
</Icon>
|
||||
<Icon box={row} radius={r}>
|
||||
<RefreshCw className={ico} />
|
||||
</Icon>
|
||||
<Icon box={row} radius={r}>
|
||||
<Trash2 className={ico} />
|
||||
</Icon>
|
||||
</span>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="Toolbar, on its own">
|
||||
<div className="flex gap-2">
|
||||
<Icon box={bar} radius={r}>
|
||||
<Download className={ico} />
|
||||
</Icon>
|
||||
<Icon box={bar} radius={r}>
|
||||
<MoreHorizontal className={ico} />
|
||||
</Icon>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="On a picture (on-media)">
|
||||
<OnMedia radius={media} />
|
||||
</Sample>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className={clsx("flex flex-col", gap)}>
|
||||
<Sample label="Segmented toggle · dropdown trigger">
|
||||
<div className={clsx("flex flex-wrap items-center", gap)}>
|
||||
<span className="inline-flex rounded-full border border-border bg-card p-0.5 text-xs">
|
||||
<span className={clsx(pill, "rounded-full bg-accent text-accent-fg")}>Mine</span>
|
||||
<span className={clsx(pill, "rounded-full text-muted")}>Library</span>
|
||||
</span>
|
||||
<span className={clsx(pill, "glass-card rounded-full border gap-1.5")}>
|
||||
<span className="text-muted">Sort:</span> Newest
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted" />
|
||||
</span>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="Tabs">
|
||||
<div className={clsx("flex", gap)}>
|
||||
<span className={clsx(pill, "rounded-lg bg-accent text-accent-fg")}>Queue</span>
|
||||
<span className={clsx(pill, "rounded-lg text-muted")}>Library</span>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="Switch row · nav item">
|
||||
<div className={clsx("flex items-center justify-between rounded-lg bg-card/60 px-3", h)}>
|
||||
<span className="text-sm">Dark mode</span>
|
||||
<span className="relative h-5 w-9 rounded-full bg-accent">
|
||||
<span className="absolute right-0.5 top-0.5 h-4 w-4 rounded-full bg-white" />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx("flex items-center gap-2 rounded-lg bg-accent/15 px-3 text-accent", h)}
|
||||
>
|
||||
<Play className={ico} /> <span className="text-sm">Feed</span>
|
||||
</div>
|
||||
</Sample>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex rounded-full border border-border bg-card p-0.5 text-xs">
|
||||
<span className={clsx(pill, "rounded-full bg-accent text-accent-fg")}>Mine</span>
|
||||
<span className={clsx(pill, "rounded-full text-muted")}>Library</span>
|
||||
</span>
|
||||
<span className={clsx(pill, "glass-card gap-1.5 rounded-full border")}>
|
||||
<span className="text-muted">Sort:</span> Newest
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-11 items-center justify-between rounded-lg bg-card/60 px-3">
|
||||
<span className="text-sm">Dark mode</span>
|
||||
<span className="relative h-5 w-9 rounded-full bg-accent">
|
||||
<span className="absolute right-0.5 top-0.5 h-4 w-4 rounded-full bg-white" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<>
|
||||
<Sample label="Toolbar search">
|
||||
<label className={clsx("flex items-center gap-2 px-3", searchCls)} style={searchStyle}>
|
||||
<Search className="h-4 w-4 text-muted" />
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none"
|
||||
placeholder="Search your subscriptions"
|
||||
/>
|
||||
</label>
|
||||
</Sample>
|
||||
<Sample label="Form field, with a label and an error">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">Playlist name</span>
|
||||
<input
|
||||
className={clsx("px-3 text-sm outline-none focus:ring-2 focus:ring-accent", cls)}
|
||||
style={style}
|
||||
defaultValue="Evening mix"
|
||||
/>
|
||||
<span className="text-xs text-danger">A playlist with this name already exists.</span>
|
||||
</label>
|
||||
</Sample>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── A5 · dropdown trigger ────────────────────────────────────────────────────────────────────────
|
||||
function Trigger({ cls, label, value }: { cls: string; label: string; value: string }) {
|
||||
function Trigger({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"glass-card glass-hover inline-flex items-center gap-1.5 border text-sm",
|
||||
cls,
|
||||
"glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 text-sm",
|
||||
H.bar,
|
||||
)}
|
||||
>
|
||||
<span className="text-muted">{label}:</span> {value}
|
||||
@@ -275,40 +125,15 @@ function Trigger({ cls, label, value }: { cls: string; label: string; value: str
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownSet({ bar, form }: { bar: string; form: string }) {
|
||||
return (
|
||||
<>
|
||||
<Sample label="In a toolbar">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Trigger cls={bar} label="Watch state" value="Unwatched" />
|
||||
<Trigger cls={bar} label="Date" value="Any time" />
|
||||
<Trigger cls={bar} label="Sort" value="Newest" />
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="In a form">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">Download format</span>
|
||||
<span
|
||||
className={clsx("glass-card flex items-center justify-between border text-sm", form)}
|
||||
>
|
||||
MP4 · 1080p
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted" />
|
||||
</span>
|
||||
</label>
|
||||
</Sample>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── A6 · checkbox, switch, slider ────────────────────────────────────────────────────────────────
|
||||
function NativeSet() {
|
||||
const row = clsx("flex min-h-11 items-center gap-3 rounded-lg px-2 text-sm", LAYER);
|
||||
return (
|
||||
<>
|
||||
<label className="flex min-h-11 items-center gap-3 rounded-lg px-2 text-sm hover:bg-card">
|
||||
<label className={row}>
|
||||
<input type="checkbox" defaultChecked className="h-4.5 w-4.5 accent-accent" />
|
||||
Only unwatched
|
||||
</label>
|
||||
<label className="flex min-h-11 items-center justify-between gap-3 rounded-lg px-2 text-sm hover:bg-card">
|
||||
<label className={clsx(row, "justify-between")}>
|
||||
Hide Shorts
|
||||
<span className="relative h-5 w-9 rounded-full bg-accent">
|
||||
<span className="absolute right-0.5 top-0.5 h-4 w-4 rounded-full bg-white" />
|
||||
@@ -333,40 +158,115 @@ export function BatchA() {
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]">
|
||||
<Reference
|
||||
id="a1"
|
||||
code="A1"
|
||||
title="Button shape and ladder"
|
||||
rule="r8 (rounded-lg), the inner corner smaller than the card's. md = forms and dialog footers, sm = toolbars and panel headers, xs = inside a table row. Glass is a Button variant. Density (standard vs compact) is decided in batch B."
|
||||
code="A1 + B1 + B3"
|
||||
title="Buttons: shape, ladder, density, states"
|
||||
rule="r8. Compact rungs by context: md 32 (forms, dialog footers), sm 28 (toolbars, panel headers), xs 24 (inside a table row). Hover = the text colour at 10% over whatever is underneath, pressed 16%; a filled button mixes its text colour into the fill (14 / 24%). Glass is a Button variant."
|
||||
>
|
||||
<ButtonSet radius="rounded-lg" />
|
||||
<Sample label="Toolbar · sm, with a search, a trigger and an icon button, all 28">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label
|
||||
className={clsx("flex w-40 items-center gap-2 rounded-full border px-3", H.bar)}
|
||||
style={{ borderColor: STRONG }}
|
||||
>
|
||||
<Search className="h-4 w-4 text-muted" />
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none"
|
||||
placeholder="Search"
|
||||
/>
|
||||
</label>
|
||||
<Trigger label="Sort" value="Newest" />
|
||||
<Btn tone="glass" size="sm" icon={<RefreshCw className={ico} />}>
|
||||
Sync now
|
||||
</Btn>
|
||||
<IconBtn box={H.iconSm}>
|
||||
<MoreHorizontal className={ico} />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="Dialog footer · md">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Btn tone="ghost" size="md">
|
||||
Cancel
|
||||
</Btn>
|
||||
<Btn tone="danger" size="md" icon={<Trash2 className={ico} />}>
|
||||
Delete
|
||||
</Btn>
|
||||
<Btn tone="primary" size="md">
|
||||
Save
|
||||
</Btn>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="Table row · xs">
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg bg-card/60 px-3 py-1 text-sm">
|
||||
<span className="truncate">Download failed · HTTP 403</span>
|
||||
<Btn tone="outline" size="xs">
|
||||
Retry
|
||||
</Btn>
|
||||
</div>
|
||||
</Sample>
|
||||
</Reference>
|
||||
<Reference
|
||||
id="a2"
|
||||
code="A2"
|
||||
title="Icon buttons"
|
||||
rule="Two sizes, r8: the smaller in rows and dense toolbars, the larger on its own. On a picture: a round, dark translucent button."
|
||||
rule="Two sizes, r8: 28 in rows and dense toolbars, 32 on its own. On a picture: a round, dark translucent button."
|
||||
>
|
||||
<IconSet row="w-8 h-8" bar="w-9 h-9" media="rounded-full" />
|
||||
<Sample label="Row actions · 28">
|
||||
<div className="flex items-center justify-between rounded-lg bg-card/60 px-3 py-0.5 text-sm">
|
||||
<span className="truncate">Techno Tim</span>
|
||||
<span className="flex gap-1">
|
||||
<IconBtn box={H.iconSm}>
|
||||
<Pencil className={ico} />
|
||||
</IconBtn>
|
||||
<IconBtn box={H.iconSm}>
|
||||
<RefreshCw className={ico} />
|
||||
</IconBtn>
|
||||
<IconBtn box={H.iconSm}>
|
||||
<Trash2 className={ico} />
|
||||
</IconBtn>
|
||||
</span>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="On its own · 32">
|
||||
<div className="flex gap-2">
|
||||
<IconBtn box={H.icon}>
|
||||
<Download className={ico} />
|
||||
</IconBtn>
|
||||
<IconBtn box={H.icon}>
|
||||
<MoreHorizontal className={ico} />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="On a picture">
|
||||
<OnMedia />
|
||||
</Sample>
|
||||
</Reference>
|
||||
<Reference
|
||||
id="a3"
|
||||
code="A3"
|
||||
title="Touch size beyond Button"
|
||||
rule="On a coarse pointer every control primitive grows to 2.75rem (44px), one token for all. Shown here at the tablet size; mouse sizes are unchanged."
|
||||
rule="On a coarse pointer every control primitive grows to 2.75rem (44px), one token for all. Shown at the tablet size; mouse sizes are the compact ones above."
|
||||
>
|
||||
<TouchRow h="min-h-11" gap="gap-2" />
|
||||
<TouchRow />
|
||||
</Reference>
|
||||
<Reference
|
||||
id="a4"
|
||||
code="A4"
|
||||
title="Input"
|
||||
rule="One bordered primitive, edge = the fg mixed 55% into the bg (≥ 3:1 in every scheme). Form field r8 at the md height; toolbar search a pill. 1rem text on a coarse pointer."
|
||||
rule="One bordered primitive, edge = the fg mixed 55% into the bg (≥ 3:1 in every scheme). Form field r8 at the md height (32); toolbar search a pill at 28. 1rem text on a coarse pointer."
|
||||
>
|
||||
<Field
|
||||
cls="h-9.5 rounded-lg border bg-transparent"
|
||||
style={{ borderColor: STRONG }}
|
||||
searchCls="h-9 rounded-full border"
|
||||
searchStyle={{ borderColor: STRONG }}
|
||||
/>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">Playlist name</span>
|
||||
<input
|
||||
className={clsx(
|
||||
"rounded-lg border bg-transparent px-3 text-sm outline-none focus:ring-2 focus:ring-accent",
|
||||
H.field,
|
||||
)}
|
||||
style={{ borderColor: STRONG }}
|
||||
defaultValue="Evening mix"
|
||||
/>
|
||||
<span className="text-xs text-danger">A playlist with this name already exists.</span>
|
||||
</label>
|
||||
<Contrast fg={STRONG} min={3} label="Edge vs page" />
|
||||
</Reference>
|
||||
<Reference
|
||||
@@ -375,7 +275,22 @@ export function BatchA() {
|
||||
title="Dropdown trigger"
|
||||
rule="By context: a pill in a toolbar (a filter or a choice), r8 in a form (it lines up with the inputs)."
|
||||
>
|
||||
<DropdownSet bar="rounded-full px-3 py-1" form="h-9.5 rounded-lg px-3" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Trigger label="Watch state" value="Unwatched" />
|
||||
<Trigger label="Date" value="Any time" />
|
||||
</div>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">Download format</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"glass-card flex items-center justify-between rounded-lg border px-3 text-sm",
|
||||
H.field,
|
||||
)}
|
||||
>
|
||||
MP4 · 1080p
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted" />
|
||||
</span>
|
||||
</label>
|
||||
</Reference>
|
||||
<Reference
|
||||
id="a6"
|
||||
|
||||
+441
-446
@@ -1,219 +1,51 @@
|
||||
import clsx from "clsx";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clapperboard,
|
||||
Download,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Home,
|
||||
ListVideo,
|
||||
Loader2,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
PlayCircle,
|
||||
Repeat,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Tv,
|
||||
Users,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { Btn } from "./batchA";
|
||||
import { hexContrast, hueGap } from "./labColor";
|
||||
import { Contrast, Decision, Sample, Variant } from "./labKit";
|
||||
import { Reference, Sample } from "./labKit";
|
||||
import {
|
||||
FADE_ABOVE_PANEL,
|
||||
FADE_BELOW,
|
||||
FADE_BELOW_PANEL,
|
||||
FADE_EDGES_X,
|
||||
FILLED,
|
||||
H,
|
||||
LAYER,
|
||||
} from "./labTokens";
|
||||
|
||||
// E28 S2 · Batch B, part 1 — the look of the controls themselves: density (A1's open sub-point),
|
||||
// the scheme set, and the hover / pressed state layers. Static markup on the app's real CSS.
|
||||
// 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
|
||||
// B3 (state layers) render inside batch A's references. Only the chosen rendering of each stays.
|
||||
|
||||
const ico = "w-4 h-4";
|
||||
const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))";
|
||||
|
||||
// ── B1 · density ─────────────────────────────────────────────────────────────────────────────────
|
||||
// One ladder per variant. "Compact" sets explicit heights so a toolbar row lines up: the sm button,
|
||||
// the search pill, the dropdown trigger and the small icon button are all the same 28px.
|
||||
type Ladder = {
|
||||
md: string;
|
||||
sm: string;
|
||||
xs: string;
|
||||
iconSm: string;
|
||||
icon: string;
|
||||
field: string;
|
||||
bar: string;
|
||||
};
|
||||
const STANDARD: Ladder = {
|
||||
md: "px-3 py-2 text-sm gap-1.5",
|
||||
sm: "px-3 py-1.5 text-sm gap-1.5",
|
||||
xs: "px-2 py-1 text-xs gap-1",
|
||||
iconSm: "w-8 h-8",
|
||||
icon: "w-9 h-9",
|
||||
field: "h-9.5",
|
||||
bar: "h-9",
|
||||
};
|
||||
const COMPACT: Ladder = {
|
||||
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",
|
||||
};
|
||||
|
||||
function LBtn({
|
||||
cls,
|
||||
tone,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
cls: string;
|
||||
tone: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("inline-flex items-center justify-center rounded-lg font-medium", tone, cls)}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"grid place-items-center rounded-lg text-muted hover:bg-card hover:text-fg",
|
||||
box,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DensitySet({ l }: { l: Ladder }) {
|
||||
return (
|
||||
<>
|
||||
<Sample label="Toolbar · search, dropdown, sm button, icon">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label
|
||||
className={clsx("flex w-44 items-center gap-2 rounded-full border px-3", l.bar)}
|
||||
style={{ borderColor: STRONG }}
|
||||
>
|
||||
<Search className="h-4 w-4 text-muted" />
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none"
|
||||
placeholder="Search"
|
||||
/>
|
||||
</label>
|
||||
<span
|
||||
className={clsx(
|
||||
"glass-card inline-flex items-center gap-1.5 rounded-full border px-3 text-sm",
|
||||
l.bar,
|
||||
)}
|
||||
>
|
||||
<span className="text-muted">Sort:</span> Newest
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted" />
|
||||
</span>
|
||||
<LBtn cls={l.sm} tone={GLASS} icon={<RefreshCw className={ico} />}>
|
||||
Sync now
|
||||
</LBtn>
|
||||
<IconBtn box={l.iconSm}>
|
||||
<MoreHorizontal className={ico} />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="Dialog · a form field and the footer (md)">
|
||||
<div className="flex flex-col gap-3 rounded-xl glass p-3">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">Playlist name</span>
|
||||
<input
|
||||
className={clsx(
|
||||
"rounded-lg border bg-transparent px-3 text-sm outline-none",
|
||||
l.field,
|
||||
)}
|
||||
style={{ borderColor: STRONG }}
|
||||
defaultValue="Evening mix"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<LBtn cls={l.md} tone={GHOST}>
|
||||
Cancel
|
||||
</LBtn>
|
||||
<LBtn cls={l.md} tone="btn-danger" icon={<Trash2 className={ico} />}>
|
||||
Delete
|
||||
</LBtn>
|
||||
<LBtn cls={l.md} tone="btn-primary">
|
||||
Save
|
||||
</LBtn>
|
||||
</div>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="Table row · xs and the small icon buttons">
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg bg-card/60 px-3 py-1 text-sm">
|
||||
<span className="truncate">Download failed · HTTP 403</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<LBtn cls={l.xs} tone={OUTLINE}>
|
||||
Retry
|
||||
</LBtn>
|
||||
<IconBtn box={l.iconSm}>
|
||||
<Pencil className={ico} />
|
||||
</IconBtn>
|
||||
<IconBtn box={l.iconSm}>
|
||||
<Trash2 className={ico} />
|
||||
</IconBtn>
|
||||
</span>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="On its own · md + the larger icon button">
|
||||
<div className="flex items-center gap-2">
|
||||
<LBtn cls={l.md} tone={GLASS} icon={<Download className={ico} />}>
|
||||
Download
|
||||
</LBtn>
|
||||
<IconBtn box={l.icon}>
|
||||
<MoreHorizontal className={ico} />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</Sample>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function B1() {
|
||||
return (
|
||||
<Decision
|
||||
id="b1"
|
||||
code="B1"
|
||||
title="Button density (A1's open sub-point)"
|
||||
question="How much room is around a button's label on a mouse? Touch is not affected: on a coarse pointer everything grows to 44 (A3)."
|
||||
now="md 38 (px-3 py-2), sm 32, xs 26; icon buttons 32 / 36. Already denser than Material 3 (40, 24px side padding) and Apple HIG (44). Heights come from padding, so a toolbar's controls end up 30 / 32 / 36 side by side."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="Compact: 32 / 28 / 24, icons 28 / 32"
|
||||
recommended
|
||||
why="Your read of the lab: the label sits in less air. The big industry paddings exist for aim (Fitts) and for fingers, and fingers are handled separately here. Explicit heights make one toolbar row line up at 28. Every target stays ≥ 24 (WCAG 2.5.8). Button's four rungs keep their names; only their numbers change."
|
||||
>
|
||||
<DensitySet l={COMPACT} />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Standard (today's numbers): 38 / 32 / 26, icons 32 / 36"
|
||||
why="Closer to the platform guidelines and a little easier to hit, at the cost of about a fifth more toolbar and footer height. The toolbar's controls stay at three different heights."
|
||||
>
|
||||
<DensitySet l={STANDARD} />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<Mode, Record<"danger" | "dangerFg" | "warn" | "success", st
|
||||
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 });
|
||||
): 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 (
|
||||
<span className={clsx("tabular-nums", ok ? "text-success" : "text-danger")}>{children}</span>
|
||||
@@ -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 }) {
|
||||
<span className="rounded-full border border-warn/40 px-2 py-0.5 text-warn">Retrying</span>
|
||||
<span className="rounded-full border border-success/40 px-2 py-0.5 text-success">Done</span>
|
||||
</div>
|
||||
<span className="text-xs">
|
||||
Text with a <span className="font-medium text-accent underline">link</span> in it.
|
||||
</span>
|
||||
{/* Measured from the hex values; drawn in the tile's own status colours. */}
|
||||
<span className="flex flex-col rounded bg-surface px-1.5 py-1 text-2xs">
|
||||
<Metric ok={worst.g >= MIN_GAP}>{`closest status: ${worst.k} ${worst.g}°`}</Metric>
|
||||
<Metric ok={worst.g >= 30}>{`closest status: ${worst.k} ${worst.g}°`}</Metric>
|
||||
<Metric ok={text >= 4.5}>{`accent as text ${text.toFixed(2)}:1`}</Metric>
|
||||
<Metric ok={onFill >= 4.5}>{`button text ${onFill.toFixed(2)}:1`}</Metric>
|
||||
</span>
|
||||
@@ -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 (
|
||||
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]">
|
||||
{set.flatMap((s) =>
|
||||
(["dark", "light"] as const).map((m) => <SchemeTile key={s.name + m} s={s} mode={m} />),
|
||||
<Sample label={label}>
|
||||
<div className={clsx("relative overflow-hidden rounded-xl border border-border bg-bg", h)}>
|
||||
<div className="h-full overflow-y-auto no-scrollbar">{children}</div>
|
||||
</div>
|
||||
</Sample>
|
||||
);
|
||||
}
|
||||
|
||||
function Cards() {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 p-3">
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<div key={i} className="glass-card flex flex-col gap-1 rounded-xl p-1.5">
|
||||
<div className="aspect-video rounded-lg bg-linear-to-br from-accent/40 to-card" />
|
||||
<span className="truncate text-2xs">Video title number {i + 1}</span>
|
||||
<span className="text-3xs text-muted">Channel · 2 days ago</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex gap-1">
|
||||
<span className={tab(true)}>Queue</span>
|
||||
<span className={tab(false)}>Library</span>
|
||||
<span className={tab(false)}>History</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Band({ collapsed }: { collapsed?: boolean }) {
|
||||
return (
|
||||
<div className={clsx("sticky top-0 z-base flex flex-col gap-2 bg-bg px-3 py-2", FADE_BELOW)}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tabs />
|
||||
{collapsed && (
|
||||
<span className={pillCtl}>
|
||||
<SlidersHorizontal className="h-3 w-3" /> Filters · 3
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{["State: Unwatched", "Date: Any", "Sort: Newest"].map((f) => (
|
||||
<span key={f} className={pillCtl}>
|
||||
{f} <ChevronDown className="h-3 w-3 text-muted" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function B2() {
|
||||
// ── B5 · surface per role ────────────────────────────────────────────────────────────────────────
|
||||
function Surfaces() {
|
||||
return (
|
||||
<Decision
|
||||
id="b2"
|
||||
code="B2"
|
||||
title="The scheme set (and the light-theme accent)"
|
||||
question="Which colour schemes stay? Rule proposed with them: a scheme's accent is at least 30° of hue away from danger (red), warn (amber) and success (green), and passes 4.5:1 as text and under white/dark button text, in dark AND light."
|
||||
now="Six schemes. YouTube's accent is danger's red (2–4°), Slate's is warn's amber (9–23°), Forest and Matrix are success's green (6–19°), and Midnight / Starship are both blue. Four of six fail as text in light. Measured below, per tile."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="Four schemes, four hue families"
|
||||
recommended
|
||||
why="Each scheme earns its place with its own hue: blue, cyan, fuchsia, lime, all ≥ 30° from every status colour and from each other, and all pass in light. Only the accent colour moves; backgrounds stay. YouTube can't be fixed (red is its identity) and Starship is Midnight twice, so both go; a user on them lands on Midnight. A violet Starship was measured too: 24° from Midnight's periwinkle, too close."
|
||||
>
|
||||
<SchemeGrid set={PROPOSED} />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Today's six (reference)"
|
||||
why="Red marks each failure. Dropping YouTube and Starship alone would still leave Slate, Forest and Matrix colliding and three light themes failing."
|
||||
>
|
||||
<SchemeGrid set={TODAY} />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="grid grid-cols-[5rem_repeat(3,1fr)] items-center gap-1.5 text-xs">
|
||||
<span className="text-muted">{label}</span>
|
||||
<span className={clsx(cell, rest)}>Rest</span>
|
||||
<span className={clsx(cell, hover)}>Hover</span>
|
||||
<span className={clsx(cell, pressed)}>Pressed</span>
|
||||
<div className="flex flex-col gap-3 rounded-xl bg-linear-to-br from-accent/35 via-card to-bg p-3">
|
||||
<div className="glass flex flex-col gap-2 rounded-2xl p-3">
|
||||
<span className="text-sm font-semibold">Dialog · .glass r16</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="glass-card rounded-xl p-2 text-xs">Card · r12</div>
|
||||
<div className="glass-card rounded-xl p-2 text-xs">Picker · r12</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="glass-menu flex w-44 flex-col rounded-xl p-1 text-xs">
|
||||
{["Watch later", "Add to playlist", "Copy link"].map((t) => (
|
||||
<span key={t} className={clsx("flex h-7 items-center rounded-lg px-2", LAYER)}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="glass-menu rounded-lg px-2 py-1 text-2xs">Tooltip · .glass-menu r8</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayerSet({ l }: { l: Layers }) {
|
||||
// ── B6 · the public watch page ───────────────────────────────────────────────────────────────────
|
||||
function WatchPage() {
|
||||
return (
|
||||
<div className="flex max-w-xl flex-col gap-3 rounded-xl bg-linear-to-br from-accent/25 via-bg to-bg p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<PlayCircle className="h-4 w-4 text-accent" />
|
||||
<span className="font-semibold text-fg">Siftlode</span>
|
||||
</div>
|
||||
<div className="grid aspect-video place-items-center rounded-xl border border-border bg-black text-2xs text-white/60">
|
||||
the browser's own video controls
|
||||
</div>
|
||||
<div className="glass flex flex-wrap items-start gap-2 rounded-2xl p-3">
|
||||
<div className="min-w-0 flex-1 basis-40">
|
||||
<div className="text-sm font-semibold">Building a home lab on a budget</div>
|
||||
<div className="text-xs text-muted">Techno Tim</div>
|
||||
</div>
|
||||
<span className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("pill inline-flex items-center rounded-full border font-medium", H.md)}
|
||||
aria-pressed
|
||||
>
|
||||
<Repeat className="h-3.5 w-3.5" /> Repeat
|
||||
</button>
|
||||
<Btn tone="glass" size="md" icon={<Download className={ico} />}>
|
||||
Download
|
||||
</Btn>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<span
|
||||
className={clsx(
|
||||
"relative flex w-full flex-col items-center justify-center gap-0.5 rounded-lg",
|
||||
lang ? "py-1.5" : "h-9",
|
||||
on ? "bg-accent/15 text-accent" : clsx("text-muted hover:text-fg", LAYER),
|
||||
)}
|
||||
>
|
||||
{on && (
|
||||
<span className="absolute -left-1 top-1.5 bottom-1.5 w-0.75 rounded-r-full bg-accent" />
|
||||
)}
|
||||
<I className="h-4.5 w-4.5" />
|
||||
{lang && (
|
||||
<span
|
||||
data-rail-label
|
||||
className="max-w-full overflow-hidden whitespace-nowrap text-3xs font-medium tracking-tight"
|
||||
>
|
||||
{d[lang]}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Rail({ lang }: { lang?: "en" | "hu" }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [clipped, setClipped] = useState<string[]>([]);
|
||||
useLayoutEffect(() => {
|
||||
const measure = () =>
|
||||
setClipped(
|
||||
[...(ref.current?.querySelectorAll<HTMLElement>("[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 (
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<div
|
||||
ref={ref}
|
||||
className="glass flex w-15 flex-col items-center gap-0.5 rounded-2xl px-1 py-2.5"
|
||||
>
|
||||
{NAV.map((d, i) => (
|
||||
<RailItem key={d.en} d={d} on={i === 1} lang={lang} />
|
||||
))}
|
||||
<span className="my-1.5 w-7 border-t-2 border-border" />
|
||||
{SYSTEM.map((d) => (
|
||||
<RailItem key={d.en} d={d} on={false} lang={lang} />
|
||||
))}
|
||||
</div>
|
||||
{lang && (
|
||||
<span className={clsx("text-2xs", clipped.length ? "text-danger" : "text-success")}>
|
||||
{clipped.length ? `clipped: ${clipped.join(", ")}` : "every label fits ✓"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── B8 · loading ─────────────────────────────────────────────────────────────────────────────────
|
||||
const bone = "animate-pulse rounded bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]";
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<>
|
||||
<Sample label="Ghost button, on the page">
|
||||
<StateRow label="ghost" rest="text-muted" hover={l.gh} pressed={l.gp} />
|
||||
<Contrast fg={l.ghostOnPage} min={HOVER_MIN} label="hover vs rest" />
|
||||
</Sample>
|
||||
<Sample label="List row, on a card (menus, tables, the nav)">
|
||||
<div className="glass-card rounded-xl p-1.5">
|
||||
<StateRow label="row" rest="text-muted" hover={l.gh} pressed={l.gp} />
|
||||
</div>
|
||||
<Contrast fg={l.rowOnCard} bg="var(--card)" min={HOVER_MIN} label="hover vs rest" />
|
||||
</Sample>
|
||||
<Sample label="Filled (primary) button">
|
||||
<StateRow label="primary" rest="bg-accent text-accent-fg" hover={l.fh} pressed={l.fp} />
|
||||
<Contrast fg={l.filledHover} bg="var(--accent)" min={HOVER_MIN} label="hover vs rest" />
|
||||
</Sample>
|
||||
<Sample label="Live: hover and press these">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" className={clsx(cell, l.ghostLive)}>
|
||||
Ghost
|
||||
</button>
|
||||
<button type="button" className={clsx(cell, l.filledLive)}>
|
||||
Primary
|
||||
</button>
|
||||
</div>
|
||||
<div className="glass-card flex flex-col rounded-xl p-1">
|
||||
{["Watch later", "Add to playlist", "Copy link"].map((t) => (
|
||||
<button key={t} type="button" className={clsx(cell, "justify-start", l.ghostLive)}>
|
||||
{t}
|
||||
</button>
|
||||
<Sample label="A grid, first load: its skeleton (after ~300ms)">
|
||||
<div className="grid max-w-md grid-cols-3 gap-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="glass-card flex flex-col gap-1.5 rounded-xl p-1.5">
|
||||
<div className={clsx("aspect-video rounded-lg", bone)} />
|
||||
<div className={clsx("h-2 w-4/5", bone)} />
|
||||
<div className={clsx("h-2 w-1/2", bone)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="A control's own work: its spinner">
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex w-fit items-center rounded-lg font-medium opacity-80",
|
||||
FILLED,
|
||||
H.md,
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Saving
|
||||
</span>
|
||||
</Sample>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Decision
|
||||
id="b3"
|
||||
code="B3"
|
||||
title="State layers: hover and pressed"
|
||||
question="How strongly does a control answer the pointer? Switch the scheme and light / dark in the bar: the readouts re-measure (below 1.15:1 a change is hard to see; WCAG sets no number for hover)."
|
||||
now="Filled buttons dim to 90% opacity; ghost buttons and rows get bg-card, which is ~1.07:1 in light and exactly the card's own colour on a card (1.00:1, invisible). There is no pressed state."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="A text-coloured layer: 10% hover, 16% pressed"
|
||||
recommended
|
||||
why="The layer is the text colour mixed over whatever is underneath, so it darkens in light and lightens in dark, on the page, a card or glass alike. A filled button mixes its own text colour into the fill. One token pair for every control. Measured: ≥ 1.2:1 on the page and on cards in every scheme and mode; the filled hover is 1.26–1.32 with B2's proposed accents (only today's YouTube red stays faint, 1.06). The selected state (the solid accent) is unchanged."
|
||||
<div className="flex flex-col gap-1 px-3 pb-3 pt-2 text-xs">
|
||||
{LINES.map((l) => (
|
||||
<span key={l} className="rounded-md bg-card/60 px-2 py-1.5">
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CHIPS = ["Unwatched", "Shorts", "Live", "Music", "Tech", "Science", "News", "Gaming"];
|
||||
|
||||
function Fades() {
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(15rem,1fr))]">
|
||||
<Frame h="h-44" label="1 · Toolbars and table headers">
|
||||
<div
|
||||
className={clsx("sticky top-0 z-base bg-bg px-3 py-2 text-xs font-medium", FADE_BELOW)}
|
||||
>
|
||||
Title · Channel · Date
|
||||
</div>
|
||||
<ScrollBody />
|
||||
</Frame>
|
||||
<Sample label="2 · Tabs over a scrolling panel">
|
||||
<div className="glass relative flex h-44 flex-col overflow-hidden rounded-2xl">
|
||||
<div className={clsx("relative z-base px-3 pb-2 pt-3", FADE_BELOW_PANEL)}>
|
||||
<Tabs />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">
|
||||
<ScrollBody />
|
||||
</div>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="3 · A dialog's header and footer">
|
||||
<div className="glass relative flex h-44 flex-col overflow-hidden rounded-2xl">
|
||||
<div
|
||||
className={clsx(
|
||||
"relative z-base flex items-center gap-2 px-3 py-2 text-sm font-semibold",
|
||||
FADE_BELOW_PANEL,
|
||||
)}
|
||||
>
|
||||
<Lock className="h-4 w-4 text-accent" /> Edit details
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">
|
||||
<ScrollBody />
|
||||
</div>
|
||||
<div
|
||||
className={clsx("relative z-base flex justify-end gap-2 px-3 py-2", FADE_ABOVE_PANEL)}
|
||||
>
|
||||
<Btn tone="ghost" size="md">
|
||||
Cancel
|
||||
</Btn>
|
||||
<Btn tone="primary" size="md" icon={<Check className={ico} />}>
|
||||
Save
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="4 · An overflowing chip row">
|
||||
<div
|
||||
className="overflow-x-auto no-scrollbar"
|
||||
style={{ maskImage: FADE_EDGES_X, WebkitMaskImage: FADE_EDGES_X }}
|
||||
>
|
||||
<div className="flex w-max gap-1.5 px-6">
|
||||
{CHIPS.map((c) => (
|
||||
<span key={c} className="chip px-2.5 py-1 text-xs">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Sample>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BatchB() {
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]">
|
||||
<Reference
|
||||
id="b2"
|
||||
code="B2"
|
||||
title="The scheme set"
|
||||
rule="Four schemes, four hue families: Midnight blue, Forest cyan, Slate fuchsia, Matrix lime. YouTube and Starship are gone (a user on them lands on Midnight). Rule: an accent is ≥ 30° of OKLCH hue from danger, warn and success, and ≥ 4.5:1 as text and under its button text, dark and light."
|
||||
>
|
||||
<LayerSet l={LAYERS.strong} />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="The same layer at Material's 8% / 12%"
|
||||
why="Material 3's numbers. Calmer, but measured here it lands right at the edge of visible in light, the exact complaint."
|
||||
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]">
|
||||
{SCHEMES.flatMap((s) =>
|
||||
(["dark", "light"] as const).map((m) => <SchemeTile key={s.name + m} s={s} mode={m} />),
|
||||
)}
|
||||
</div>
|
||||
</Reference>
|
||||
<Reference
|
||||
id="b4"
|
||||
code="B4"
|
||||
title="Page chrome: one sticky band"
|
||||
rule="Every page's tabs and filters sit in one sticky band of the page colour, with the fade under it. Below ~600px of height the filter row folds into one 'Filters · n' button. Every page has a visually hidden h1."
|
||||
>
|
||||
<LayerSet l={LAYERS.material} />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="—"
|
||||
name="Today (reference)"
|
||||
why="The ghost-on-card row shows why the complaint exists."
|
||||
<Frame h="h-64" label="Normal height · scroll it">
|
||||
<Band />
|
||||
<Cards />
|
||||
</Frame>
|
||||
<Frame h="h-36" label="Short (keyboard up)">
|
||||
<Band collapsed />
|
||||
<Cards />
|
||||
</Frame>
|
||||
</Reference>
|
||||
<Reference
|
||||
id="b5"
|
||||
code="B5"
|
||||
title="Surface per role"
|
||||
rule="Dialog / panel .glass r16 · card and picker .glass-card r12 · menu .glass-menu r12, 4px padding, items r8 · tooltip .glass-menu r8. Translucency only from the tiers (app, Plex media, light), never per site."
|
||||
>
|
||||
<LayerSet l={LAYERS.today} />
|
||||
</Variant>
|
||||
</Decision>
|
||||
<Surfaces />
|
||||
</Reference>
|
||||
<Reference
|
||||
id="b6"
|
||||
code="B6"
|
||||
title="The public watch page"
|
||||
rule="The app's surfaces (glass, Button, the pressed-pill toggle, the scheme's ambient backdrop); the video keeps the browser's own controls."
|
||||
>
|
||||
<WatchPage />
|
||||
</Reference>
|
||||
<Reference
|
||||
id="b7"
|
||||
code="B7"
|
||||
title="The nav rail"
|
||||
rule="A NavRail primitive. 'You are here' = a soft accent tint + a 3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default). Slim: 3.75rem wide either way, labels add height only; each module gets a short rail label, the full name stays in the tooltip. 44 tall on touch (A3)."
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-6">
|
||||
<Sample label="Icons only">
|
||||
<Rail />
|
||||
</Sample>
|
||||
<Sample label="Labels · EN">
|
||||
<Rail lang="en" />
|
||||
</Sample>
|
||||
<Sample label="Labels · HU">
|
||||
<Rail lang="hu" />
|
||||
</Sample>
|
||||
</div>
|
||||
</Reference>
|
||||
<Reference
|
||||
id="b8"
|
||||
code="B8"
|
||||
title="Loading"
|
||||
rule="By shape: a Skeleton primitive for a first load of a grid, list or table; a spinner inside a control (the loading prop). Neither shows before ~300ms."
|
||||
>
|
||||
<Loading />
|
||||
</Reference>
|
||||
<Reference
|
||||
id="b9"
|
||||
code="B9"
|
||||
title="The header + fade edge"
|
||||
rule="On sticky toolbars and table headers, tabs over a scrolling panel, a long dialog's header and footer, and an overflowing chip row's clipped ends. Not on thumbnails, not on buttons."
|
||||
>
|
||||
<Fades />
|
||||
</Reference>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Sample label={label}>
|
||||
<div className={clsx("relative overflow-hidden rounded-xl border border-border bg-bg", h)}>
|
||||
<div className="h-full overflow-y-auto no-scrollbar">{children}</div>
|
||||
</div>
|
||||
</Sample>
|
||||
);
|
||||
}
|
||||
|
||||
function Cards({ n = 12 }: { n?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 p-3">
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<div key={i} className="glass-card flex flex-col gap-1 rounded-xl p-1.5">
|
||||
<div className="aspect-video rounded-lg bg-linear-to-br from-accent/40 to-card" />
|
||||
<span className="truncate text-2xs">Video title number {i + 1}</span>
|
||||
<span className="text-3xs text-muted">Channel · 2 days ago</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex gap-1">
|
||||
<span className={tab(true)}>Queue</span>
|
||||
<span className={tab(false)}>Library</span>
|
||||
<span className={tab(false)}>History</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Filters() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className={pillCtl}>
|
||||
State: Unwatched <ChevronDown className="h-3 w-3 text-muted" />
|
||||
</span>
|
||||
<span className={pillCtl}>
|
||||
Date: Any <ChevronDown className="h-3 w-3 text-muted" />
|
||||
</span>
|
||||
<span className={pillCtl}>
|
||||
Sort: Newest <ChevronDown className="h-3 w-3 text-muted" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── B4 · the page-chrome model ───────────────────────────────────────────────────────────────────
|
||||
function BandToolbar({ collapsed }: { collapsed?: boolean }) {
|
||||
return (
|
||||
<div className={clsx("sticky top-0 z-base flex flex-col gap-2 bg-bg px-3 py-2", FADE_BELOW)}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tabs />
|
||||
{collapsed && (
|
||||
<span className={pillCtl}>
|
||||
<SlidersHorizontal className="h-3 w-3" /> Filters · 3
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && <Filters />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GlassToolbar() {
|
||||
return (
|
||||
<div className="sticky top-0 z-base p-2">
|
||||
<div className="glass flex flex-col gap-2 rounded-2xl px-3 py-2">
|
||||
<Tabs />
|
||||
<Filters />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineToolbar() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-3 pt-2">
|
||||
<Tabs />
|
||||
<Filters />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function B4() {
|
||||
return (
|
||||
<Decision
|
||||
id="b4"
|
||||
code="B4"
|
||||
title="The page-chrome model"
|
||||
question="Where do a page's tabs and filters live, and what happens to them on a short screen (a tablet with the keyboard up)?"
|
||||
now="Two models: a sticky toolbar on 7 pages, controls that scroll away on 8, so the Settings and Stats tabs scroll off while the Downloads and Config tabs stay. The toolbar surface differs per page (glass pill, glass r16, a flat card, transparent). It never collapses: with the keyboard up, Config leaves 42% of the screen to the content. No page has an h1."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="One sticky band with the fade; filters fold on a short screen"
|
||||
recommended
|
||||
why="Page tabs and filters always stay reachable, in the same place on every page. The band is the page colour, crisp, with the north-star fade under it (DataTable's header, page-wide). Below ~600px of height the filter row folds into one 'Filters · 3' button. Every page gets a visually hidden h1 for screen readers; the nav rail and the tab title stay the visible identity."
|
||||
>
|
||||
<Frame h="h-72" label="Normal height · scroll it">
|
||||
<BandToolbar />
|
||||
<Cards />
|
||||
</Frame>
|
||||
<Frame h="h-40" label="Short (keyboard up)">
|
||||
<BandToolbar collapsed />
|
||||
<Cards />
|
||||
</Frame>
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="A sticky floating glass bar"
|
||||
why="Today's feed look. It floats nicely over the content, but the content shows through the bar and scrolls visibly behind the controls, and a second glass layer sits under the floating header. It takes the same height on a short screen."
|
||||
>
|
||||
<Frame h="h-72" label="Normal height · scroll it">
|
||||
<GlassToolbar />
|
||||
<Cards />
|
||||
</Frame>
|
||||
<Frame h="h-40" label="Short (keyboard up)">
|
||||
<GlassToolbar />
|
||||
<Cards />
|
||||
</Frame>
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="C"
|
||||
name="Everything scrolls with the content"
|
||||
why="The most room for content, but after a scroll the tabs and filters are gone, and changing a filter means scrolling back to the top."
|
||||
>
|
||||
<Frame h="h-72" label="Normal height · scroll it">
|
||||
<InlineToolbar />
|
||||
<Cards />
|
||||
</Frame>
|
||||
<Frame h="h-40" label="Short (keyboard up)">
|
||||
<InlineToolbar />
|
||||
<Cards />
|
||||
</Frame>
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── B5 · surface per role ────────────────────────────────────────────────────────────────────────
|
||||
type Roles = { dialog: string; card: string; menu: string; item: string; tip: string };
|
||||
|
||||
function Surfaces({ r }: { r: Roles }) {
|
||||
return (
|
||||
<div className="relative flex flex-col gap-3 overflow-hidden rounded-xl bg-linear-to-br from-accent/35 via-card to-bg p-3">
|
||||
<div className={clsx("glass flex flex-col gap-2 p-3", r.dialog)}>
|
||||
<span className="text-sm font-semibold">Dialog</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className={clsx("glass-card p-2 text-xs", r.card)}>Card</div>
|
||||
<div className={clsx("glass-card p-2 text-xs", r.card)}>Card</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<span className="btn-primary rounded-lg px-3 py-1 text-xs font-medium">Save</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={clsx("flex w-40 flex-col text-xs", r.menu)}>
|
||||
{["Watch later", "Add to playlist", "Copy link"].map((t, i) => (
|
||||
<span
|
||||
key={t}
|
||||
className={clsx(
|
||||
"flex h-7 items-center px-2",
|
||||
r.item,
|
||||
i === 1 && "bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]",
|
||||
)}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className={clsx("px-2 py-1 text-2xs", r.tip)}>Tooltip</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function B5() {
|
||||
return (
|
||||
<Decision
|
||||
id="b5"
|
||||
code="B5"
|
||||
title="Surface per role"
|
||||
question="Which glass class and which radius does each kind of surface get?"
|
||||
now=".glass-card is r12 in 14 modules, r8 in the pickers, r16 on the Plex panels, and some sites override its alpha. Menu items are r6 in three primitives and r8 in three others. AccountMenu is .glass instead of .glass-menu, and the tooltip is .glass r8."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="A fixed pair per role"
|
||||
recommended
|
||||
why="Dialog / panel: .glass r16. Card and picker: .glass-card r12. Menu: .glass-menu r12 with 4px padding, items r8 (12 − 4, so the corners nest). Tooltip: .glass-menu r8 (it floats over undimmed content like a menu). Translucency comes only from the tiers (app, Plex media, light), never a per-site override."
|
||||
>
|
||||
<Surfaces
|
||||
r={{
|
||||
dialog: "rounded-2xl",
|
||||
card: "rounded-xl",
|
||||
menu: "glass-menu rounded-xl p-1",
|
||||
item: "rounded-lg",
|
||||
tip: "glass-menu rounded-lg",
|
||||
}}
|
||||
/>
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="—"
|
||||
name="Today's mix (reference)"
|
||||
why="Picker cards at r8 inside r16 panels, r6 items in an r12 menu with 6px padding, and a .glass tooltip that lets the content under it show through."
|
||||
>
|
||||
<Surfaces
|
||||
r={{
|
||||
dialog: "rounded-2xl",
|
||||
card: "rounded-lg",
|
||||
menu: "glass-menu rounded-xl p-1.5",
|
||||
item: "rounded-md",
|
||||
tip: "glass rounded-lg",
|
||||
}}
|
||||
/>
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col gap-3 rounded-xl p-3",
|
||||
app ? "bg-linear-to-br from-accent/25 via-bg to-bg" : "bg-bg",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<PlayCircle className="h-4 w-4 text-accent" />
|
||||
<span className="font-semibold text-fg">Siftlode</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"grid aspect-video place-items-center bg-black text-2xs text-white/60",
|
||||
"rounded-xl border border-border",
|
||||
)}
|
||||
>
|
||||
the browser's own video controls
|
||||
</div>
|
||||
<div className={clsx("flex flex-wrap items-start gap-2", app && "glass rounded-2xl p-3")}>
|
||||
<div className="min-w-0 flex-1 basis-40">
|
||||
<div className="text-sm font-semibold">Building a home lab on a budget</div>
|
||||
<div className="text-xs text-muted">Techno Tim</div>
|
||||
</div>
|
||||
<span className="flex gap-1.5 text-xs font-medium">
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1",
|
||||
app
|
||||
? "pill h-8 rounded-full border px-3"
|
||||
: "rounded-lg border border-accent px-3 py-2 text-accent",
|
||||
)}
|
||||
aria-pressed={app ? true : undefined}
|
||||
>
|
||||
<Repeat className="h-3.5 w-3.5" /> Repeat
|
||||
</button>
|
||||
<span className={clsx("inline-flex items-center gap-1", btn)}>
|
||||
<Download className="h-3.5 w-3.5" /> Download
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function B6() {
|
||||
return (
|
||||
<Decision
|
||||
id="b6"
|
||||
code="B6"
|
||||
title="The public watch page (/watch)"
|
||||
question="Does the one page strangers see follow the app's surface rules?"
|
||||
now="No glass at all, flat bg-surface buttons, a bordered accent 'Repeat' box, the browser's own video controls. It is a separate entry point with no account preferences, so it always shows the default scheme."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="The app's surfaces, the browser's player"
|
||||
recommended
|
||||
why="The same glass, Button and toggle rules as the app, with the scheme's ambient backdrop, so a shared link looks like the product. Repeat is a toggle, so it is a pill with a pressed state (like every choice here). The video keeps the browser's own controls: a stranger's device and browser are unknown, and native controls always work."
|
||||
>
|
||||
<WatchMock app />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Today: flat and minimal"
|
||||
why="Plain and fast to load, but it reads as a different product, and the Repeat box looks like a button that is permanently pressed."
|
||||
>
|
||||
<WatchMock app={false} />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── B7 · the nav rail ────────────────────────────────────────────────────────────────────────────
|
||||
const NAV = [Home, Rss, ListVideo, Tv, Download, Bell];
|
||||
|
||||
function Rail({ kind }: { kind: "solid" | "soft" | "indicator" }) {
|
||||
return (
|
||||
<div className="glass flex w-16 flex-col items-center gap-1 rounded-2xl px-2 py-3">
|
||||
{NAV.map((I, i) => {
|
||||
const on = i === 1;
|
||||
if (kind === "indicator")
|
||||
return (
|
||||
<span key={i} className="flex flex-col items-center gap-0.5 py-0.5">
|
||||
<span
|
||||
className={clsx(
|
||||
"grid h-7 w-11 place-items-center rounded-full",
|
||||
on ? "bg-accent/25 text-accent" : "text-muted",
|
||||
)}
|
||||
>
|
||||
<I className={ico} />
|
||||
</span>
|
||||
<span className={clsx("text-3xs", on ? "text-fg" : "text-muted")}>
|
||||
{["Home", "Feed", "Lists", "Plex", "Saved", "Alerts"][i]}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={clsx(
|
||||
"relative grid h-9 w-full place-items-center rounded-lg",
|
||||
kind === "solid" && on && "bg-accent text-accent-fg",
|
||||
kind === "soft" && on && "bg-accent/15 text-accent",
|
||||
!on && "text-muted",
|
||||
)}
|
||||
>
|
||||
{kind === "soft" && on && (
|
||||
<span className="absolute -left-2 top-1.5 bottom-1.5 w-1 rounded-r-full bg-accent" />
|
||||
)}
|
||||
<I className={ico} />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="my-1 w-7 border-t border-border" />
|
||||
<span className="grid h-9 w-full place-items-center rounded-lg text-muted">
|
||||
<Settings className={ico} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function B7() {
|
||||
return (
|
||||
<Decision
|
||||
id="b7"
|
||||
code="B7"
|
||||
title="The nav rail"
|
||||
question="How does the primary navigation mark where you are? Whichever wins becomes a NavRail primitive (today it is hand-rolled), 44 tall on touch (A3)."
|
||||
now="NavSidebar: a glass r16 rail, icon-only r8 items 34 tall, the current page solid accent (the .is-current recipe)."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="Solid accent (today's look, made a primitive)"
|
||||
recommended
|
||||
why="The strongest 'you are here' there is, and it is the same solid fill as every other selected thing in the app (tabs, segmented toggles, chips). Nothing to relearn."
|
||||
>
|
||||
<Rail kind="solid" />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Soft tint + an edge bar"
|
||||
why="Calmer: the rail stops competing with the page's own primary button for the eye. But it is a second 'selected' look next to the solid one used everywhere else."
|
||||
>
|
||||
<Rail kind="soft" />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="C"
|
||||
name="Material rail: a pill indicator and labels"
|
||||
why="Labels help a new user; you know the icons. The rail gets wider or the labels get tiny (as here), and it is a third selected look."
|
||||
>
|
||||
<Rail kind="indicator" />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── B8 · the loading form ────────────────────────────────────────────────────────────────────────
|
||||
function SkeletonGrid() {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="glass-card flex flex-col gap-1.5 rounded-xl p-1.5">
|
||||
<div className="aspect-video animate-pulse rounded-lg bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]" />
|
||||
<div className="h-2 w-4/5 animate-pulse rounded bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]" />
|
||||
<div className="h-2 w-1/2 animate-pulse rounded bg-[color-mix(in_srgb,var(--fg)_7%,transparent)]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpinnerBlock() {
|
||||
return (
|
||||
<div className="grid h-40 place-items-center rounded-xl border border-border/60">
|
||||
<span className="flex items-center gap-2 text-sm text-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavingButton() {
|
||||
return (
|
||||
<span className="btn-primary inline-flex h-8 w-fit items-center gap-1.5 rounded-lg px-3 text-sm font-medium opacity-80">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Saving
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function B8() {
|
||||
return (
|
||||
<Decision
|
||||
id="b8"
|
||||
code="B8"
|
||||
title="The loading form"
|
||||
question="What does a loading grid, list or table look like?"
|
||||
now="19 spinners in 13 files and 11 skeletons in 8 files, with no Skeleton primitive; each is drawn by hand."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="By shape: a skeleton when the shape is known, a spinner inside a control"
|
||||
recommended
|
||||
why="A first load of a grid, list or table shows its skeleton (a Skeleton primitive), so nothing jumps when the data lands. A button's own work keeps its spinner (the loading prop). Either appears only after ~300ms, so a fast load flashes nothing."
|
||||
>
|
||||
<Sample label="A grid, first load">
|
||||
<SkeletonGrid />
|
||||
</Sample>
|
||||
<Sample label="A button's own work">
|
||||
<SavingButton />
|
||||
</Sample>
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="A spinner everywhere"
|
||||
why="One pattern and nothing to build, but the page is empty and then jumps into shape, and a long wait looks the same as a stuck one."
|
||||
>
|
||||
<Sample label="A grid, first load">
|
||||
<SpinnerBlock />
|
||||
</Sample>
|
||||
<Sample label="A button's own work">
|
||||
<SavingButton />
|
||||
</Sample>
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="flex flex-col gap-1 px-3 pb-3 pt-2 text-xs">
|
||||
{children}
|
||||
{LINES.map((l) => (
|
||||
<span key={l} className="rounded-md bg-card/60 px-2 py-1.5">
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FadeToolbar() {
|
||||
return (
|
||||
<Frame h="h-44" label="Scroll it">
|
||||
<div className={clsx("sticky top-0 z-base bg-bg px-3 py-2 text-xs font-medium", FADE_BELOW)}>
|
||||
Title · Channel · Date
|
||||
</div>
|
||||
<ScrollBody />
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
function FadeTabs() {
|
||||
return (
|
||||
<Sample label="Scroll the panel body">
|
||||
<div className="glass relative flex h-48 flex-col overflow-hidden rounded-2xl">
|
||||
<div className={clsx("relative z-base px-3 pt-3 pb-2", FADE_BELOW_PANEL)}>
|
||||
<Tabs />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">
|
||||
<ScrollBody />
|
||||
</div>
|
||||
</div>
|
||||
</Sample>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Sample label="Flat (today) vs a top-to-bottom depth gradient">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="btn-primary inline-flex h-8 items-center rounded-lg px-3 text-sm font-medium">
|
||||
Flat
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex h-8 items-center rounded-lg px-3 text-sm font-medium",
|
||||
depth,
|
||||
)}
|
||||
>
|
||||
Depth
|
||||
</span>
|
||||
<span className="glass-card inline-flex h-8 items-center rounded-lg px-3 text-sm font-medium">
|
||||
Flat glass
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex h-8 items-center rounded-lg px-3 text-sm font-medium",
|
||||
glassDepth,
|
||||
)}
|
||||
>
|
||||
Depth glass
|
||||
</span>
|
||||
</div>
|
||||
</Sample>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Sample label="An overflowing chip row, its clipped edges faded">
|
||||
<div
|
||||
className="overflow-x-auto no-scrollbar"
|
||||
style={{ maskImage: mask, WebkitMaskImage: mask }}
|
||||
>
|
||||
<div className="flex w-max gap-1.5 px-6">
|
||||
{chips.map((c) => (
|
||||
<span key={c} className="chip px-2.5 py-1 text-xs">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Sample>
|
||||
);
|
||||
}
|
||||
|
||||
function FadeCard() {
|
||||
return (
|
||||
<Sample label="The thumbnail fading into the card under the title">
|
||||
<div className="grid w-64 grid-cols-2 gap-2">
|
||||
{[false, true].map((fade) => (
|
||||
<div key={String(fade)} className="glass-card flex flex-col overflow-hidden rounded-xl">
|
||||
<div className="relative aspect-video bg-linear-to-br from-accent/60 to-card">
|
||||
{fade && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-1/2 bg-[linear-gradient(to_bottom,transparent,var(--card))]" />
|
||||
)}
|
||||
</div>
|
||||
<span className="px-2 py-1.5 text-2xs">
|
||||
{fade ? "Faded edge" : "Hard edge (today)"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Sample>
|
||||
);
|
||||
}
|
||||
|
||||
function FadeDialog() {
|
||||
return (
|
||||
<Sample label="A long dialog: crisp header and footer, the body fading under both">
|
||||
<div className="glass relative flex h-56 flex-col overflow-hidden rounded-2xl">
|
||||
<div
|
||||
className={clsx(
|
||||
"relative z-base flex items-center gap-2 px-3 py-2.5 text-sm font-semibold",
|
||||
FADE_BELOW_PANEL,
|
||||
)}
|
||||
>
|
||||
<Lock className="h-4 w-4 text-accent" /> Edit details
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">
|
||||
<ScrollBody />
|
||||
</div>
|
||||
<div
|
||||
className={clsx("relative z-base flex justify-end gap-2 px-3 py-2.5", FADE_ABOVE_PANEL)}
|
||||
>
|
||||
<span className="inline-flex h-8 items-center rounded-lg px-3 text-sm text-muted">
|
||||
Cancel
|
||||
</span>
|
||||
<span className="btn-primary inline-flex h-8 items-center gap-1 rounded-lg px-3 text-sm font-medium">
|
||||
<Check className="h-4 w-4" /> Save
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Sample>
|
||||
);
|
||||
}
|
||||
|
||||
export function B9() {
|
||||
return (
|
||||
<Decision
|
||||
id="b9"
|
||||
code="B9"
|
||||
title="Where the header + fade edge goes"
|
||||
question="The long-open north-star task: the fade you liked under DataTable's header, tried on other elements. Pick ANY number of these (not one of them)."
|
||||
now="Only DataTable's sticky header has it; the Carousel fades its clipped edges."
|
||||
>
|
||||
<Variant
|
||||
letter="1"
|
||||
name="Sticky toolbars and table headers"
|
||||
recommended
|
||||
why="The reference itself, page-wide through B4's band. This is where it came from."
|
||||
>
|
||||
<FadeToolbar />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="2"
|
||||
name="Tabs over a scrolling panel"
|
||||
recommended
|
||||
why="The same edge one level down: a panel's tabs stay crisp and its body slides under them."
|
||||
>
|
||||
<FadeTabs />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="3"
|
||||
name="A dialog's header and footer"
|
||||
recommended
|
||||
why="A long dialog (Edit details, Settings sections) keeps its title and buttons crisp while the form fades under both edges. It also answers 'is there more?' without a scrollbar."
|
||||
>
|
||||
<FadeDialog />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="4"
|
||||
name="Overflowing chip and filter rows"
|
||||
recommended
|
||||
why="A clipped row fades at the edge that has more, so a cut-off chip reads as 'scroll me', not as a bug. Carousel already does this."
|
||||
>
|
||||
<FadeChips />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="5"
|
||||
name="Thumbnails fading into the card"
|
||||
why="Softer cards, but half the thumbnail is washed out, and the thumbnail is the content you choose a video by."
|
||||
>
|
||||
<FadeCard />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="6"
|
||||
name="A depth gradient on buttons"
|
||||
why="The fade idea on a button becomes a top-to-bottom shine. On a 28–32px button it reads as a 2010-era glossy button, and glass already gives the top edge its highlight."
|
||||
>
|
||||
<FadeButtons />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Sample label={label}>
|
||||
<div className="relative h-56 overflow-hidden rounded-xl border border-border bg-bg">
|
||||
{children}
|
||||
</div>
|
||||
</Sample>
|
||||
);
|
||||
}
|
||||
|
||||
function Content({ cols = 3 }: { cols?: number }) {
|
||||
return (
|
||||
<div
|
||||
className="grid gap-2 p-3"
|
||||
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{Array.from({ length: cols * 3 }, (_, i) => (
|
||||
<div key={i} className="glass-card flex flex-col gap-1 rounded-xl p-1.5">
|
||||
<div className="aspect-video rounded-lg bg-linear-to-br from-accent/40 to-card" />
|
||||
<span className="truncate text-2xs">Video {i + 1}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterPanel({ pinnable }: { pinnable?: boolean }) {
|
||||
return (
|
||||
<div className="glass flex h-full w-44 flex-col gap-2 rounded-r-2xl p-3 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">Filters</span>
|
||||
<span className="flex gap-0.5">
|
||||
{pinnable && (
|
||||
<span
|
||||
className={clsx("grid place-items-center rounded-lg text-muted", H.iconSm, LAYER)}
|
||||
>
|
||||
<Pin className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx("grid place-items-center rounded-lg text-muted", H.iconSm, LAYER)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{["Unwatched", "Shorts hidden", "Last 30 days", "Tech", "Science"].map((f) => (
|
||||
<span key={f} className="chip px-2 py-1">
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── C2 · side panels on touch and on a narrow window ─────────────────────────────────────────────
|
||||
export function C2() {
|
||||
return (
|
||||
<Decision
|
||||
id="c2"
|
||||
code="C2"
|
||||
title="Side panels on touch and on a narrow window"
|
||||
question="How does a side panel (the feed filters, the Plex sidebar, the playlists rail, the channel picker) open on a tablet, and what replaces it below 768px?"
|
||||
now="Below 768 the panels are hidden (SidePanel is hidden md:block) and their filters are unreachable. On touch at 768+, a tap on the rail tab fires the synthetic hover and then the click, which PINS the panel: at 1024 wide a pinned panel pushes the feed from 3 to 2 columns. There is no 'open once' on touch."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="A tap opens an overlay drawer; pinning is a separate button"
|
||||
recommended
|
||||
why="On touch, and at any width below 768, the tab (or the band's 'Filters · n' button, B4) slides the panel over the content with a scrim. A tap outside, Esc or Back closes it, and the content never reflows. Pin is its own button in the panel's header, offered only where a pinned panel leaves the content room (≥ 1024 wide). The mouse keeps today's hover-to-peek and click-to-pin."
|
||||
>
|
||||
<Screen label="Tablet: after one tap on the tab">
|
||||
<Content />
|
||||
<div className="absolute inset-0 bg-black/40" />
|
||||
<div className="absolute inset-y-0 left-0">
|
||||
<FilterPanel pinnable />
|
||||
</div>
|
||||
</Screen>
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Today: a tap pins, the content reflows"
|
||||
why="A tap is taken as the mouse's click, so the panel pins and pushes the grid to two columns. Closing it needs a second tap on a small tab. Below 768 the filters are simply gone."
|
||||
>
|
||||
<Screen label="Tablet: after one tap on the tab">
|
||||
<div className="flex h-full">
|
||||
<FilterPanel />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Content cols={2} />
|
||||
</div>
|
||||
</div>
|
||||
</Screen>
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── C3 · the player on touch ─────────────────────────────────────────────────────────────────────
|
||||
function Player({ touch }: { touch: boolean }) {
|
||||
const btn = touch ? "h-11 w-11" : "h-8 w-8";
|
||||
return (
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl bg-linear-to-br from-slate-700 to-black text-white">
|
||||
<div className="absolute inset-x-0 bottom-0 flex flex-col gap-1 bg-linear-to-t from-black/80 to-transparent px-3 pb-2 pt-8">
|
||||
{/* The seek bar: the visible track stays thin; on touch the hit area around it is 44 tall. */}
|
||||
<div
|
||||
className={clsx("relative flex items-center", touch ? "h-11" : "h-1.5")}
|
||||
style={touch ? { outline: "1px dashed rgb(255 255 255 / 0.35)" } : undefined}
|
||||
>
|
||||
<div className="h-1 w-full rounded-full bg-white/30">
|
||||
<div className="h-1 w-2/5 rounded-full bg-accent" />
|
||||
</div>
|
||||
{touch && (
|
||||
<span className="absolute left-2/5 h-4 w-4 -translate-x-1/2 rounded-full bg-white shadow" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{[Pause, SkipForward, Volume2, Subtitles].map((I, i) => (
|
||||
<span key={i} className={clsx("grid place-items-center rounded-full", btn)}>
|
||||
<I className="h-5 w-5" />
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-auto text-2xs text-white/80">12:04 / 31:40</span>
|
||||
<span className={clsx("grid place-items-center rounded-full", btn)}>
|
||||
<Maximize className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function C3() {
|
||||
return (
|
||||
<Decision
|
||||
id="c3"
|
||||
code="C3"
|
||||
title="The Plex player on touch"
|
||||
question="What does a tap on the video do, and how big are the seek bar and the controls under a finger? (Measured from the code, not played, so no watch state was written.)"
|
||||
now="A tap reaches both the wrapper (wake the controls) and the <video> (toggle play), so the tap that reveals the hidden controls also pauses. The seek bar's hit area is 6px tall and its head appears only on hover. The buttons are ≈ 32px, their labels only on hover."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="Tap = show the controls; play is a button"
|
||||
recommended
|
||||
why="On touch the first tap only shows the controls (a second tap on the empty video hides them). Play/pause is the button, never the tap on the video. The seek track stays thin but sits in a 44px-tall hit area (dashed), with the head always visible. The buttons are 44 (A3). The YouTube and Apple TV apps work this way. The mouse keeps click-to-pause."
|
||||
>
|
||||
<Player touch />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Today"
|
||||
why="Every tap that wakes the controls also pauses, so you can't reach the seek bar without stopping the video, and the 6px seek bar is hard to catch."
|
||||
>
|
||||
<Player touch={false} />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── C4 · dialog conventions ──────────────────────────────────────────────────────────────────────
|
||||
function Field({ label, value, focused }: { label: string; value: string; focused?: boolean }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="font-medium">{label}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"flex items-center rounded-lg border px-3 text-sm",
|
||||
H.field,
|
||||
focused && "ring-2 ring-accent",
|
||||
)}
|
||||
style={{ borderColor: STRONG }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortDialog({ pinned }: { pinned: boolean }) {
|
||||
const fields = (
|
||||
<div className="flex flex-col gap-2 px-3 pb-3 pt-2">
|
||||
<Field label="Title" value="Building a home lab" focused={pinned} />
|
||||
<Field label="Channel" value="Techno Tim" />
|
||||
<Field label="Tags" value="homelab, proxmox" />
|
||||
<Field label="Note" value="Watch with the team" />
|
||||
{!pinned && (
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Btn tone="ghost" size="md">
|
||||
Cancel
|
||||
</Btn>
|
||||
<Btn tone="primary" size="md">
|
||||
Save
|
||||
</Btn>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="glass relative flex h-52 flex-col overflow-hidden rounded-2xl">
|
||||
<div
|
||||
className={clsx(
|
||||
"relative z-base flex items-center justify-between px-3 py-2 text-sm font-semibold",
|
||||
FADE_BELOW_PANEL,
|
||||
)}
|
||||
>
|
||||
Edit details
|
||||
<span
|
||||
className={clsx(
|
||||
"grid place-items-center rounded-lg text-muted",
|
||||
H.iconSm,
|
||||
!pinned && "ring-2 ring-accent",
|
||||
)}
|
||||
>
|
||||
<X className={ico} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">{fields}</div>
|
||||
{pinned && (
|
||||
<div className={clsx("relative z-base flex justify-end gap-2 px-3 py-2", FADE_ABOVE_PANEL)}>
|
||||
<Btn tone="ghost" size="md">
|
||||
Cancel
|
||||
</Btn>
|
||||
<Btn tone="primary" size="md">
|
||||
Save
|
||||
</Btn>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function C4() {
|
||||
return (
|
||||
<Decision
|
||||
id="c4"
|
||||
code="C4"
|
||||
title="Dialog conventions"
|
||||
question="Where does focus land when a dialog opens, and does the footer stay on screen when the keyboard takes half of it? Shown at a keyboard-up height."
|
||||
now="Initial focus is split: most dialogs focus Close, Edit details its first input, Confirm focuses Cancel. With the keyboard up (1024×420) the Save footer scrolls with the body and sits below the fold."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="Focus by purpose; the footer is pinned"
|
||||
recommended
|
||||
why="Modal gets an initialFocus rule (APG): a form focuses its first field, a confirm its least destructive button, anything else Close. The header and footer are fixed and only the body scrolls, with B9's fade at both edges, so Save is always one tap away, keyboard up or not."
|
||||
>
|
||||
<ShortDialog pinned />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Focus on Close; the footer scrolls with the body"
|
||||
why="Today's most common behaviour. Opening a form means a second tap into the first field, and with the keyboard up Save is below the fold until you scroll down to it."
|
||||
>
|
||||
<ShortDialog pinned={false} />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
@@ -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<Where, string> = {
|
||||
rest: "● at rest",
|
||||
hover: "◐ on hover",
|
||||
menu: "☰ menu",
|
||||
};
|
||||
|
||||
function Table() {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border/60">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-muted">
|
||||
<tr className="[&>th]:px-2 [&>th]:py-1.5 [&>th]:font-medium">
|
||||
<th>Action</th>
|
||||
<th>Mouse</th>
|
||||
<th>Touch</th>
|
||||
<th>Change against today</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{TABLE.map((g) => (
|
||||
<tbody key={g.object} className="border-t border-border/60">
|
||||
<tr>
|
||||
<th colSpan={4} className="bg-card/60 px-2 py-1.5 font-semibold">
|
||||
{g.object}
|
||||
</th>
|
||||
</tr>
|
||||
{g.rows.map((r) => (
|
||||
<tr key={r.action} className="[&>td]:px-2 [&>td]:py-1 align-top">
|
||||
<td className={clsx(r.danger && "text-danger")}>
|
||||
{r.action}
|
||||
{r.danger && " ⚠"}
|
||||
</td>
|
||||
<td className="whitespace-nowrap">{MOUSE[r.mouse]}</td>
|
||||
<td className="whitespace-nowrap">
|
||||
{r.touch === "visible" ? "● visible" : "… menu"}
|
||||
</td>
|
||||
<td className="text-muted">{r.change}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="glass-card group flex w-60 flex-col gap-1.5 rounded-xl p-1.5">
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg bg-linear-to-br from-accent/50 to-card">
|
||||
<span className="absolute inset-x-0 bottom-0 h-1 bg-white/25">
|
||||
<span className="block h-1 w-1/3 bg-accent" />
|
||||
</span>
|
||||
<span className="absolute left-1.5 top-1.5 rounded-full bg-black/60 px-1.5 py-0.5 text-3xs text-white">
|
||||
12:04 left
|
||||
</span>
|
||||
{!touch && (
|
||||
<span className="absolute bottom-2 right-1.5 flex gap-1">
|
||||
<span className={act}>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className={act}>
|
||||
<Bookmark className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className={act}>
|
||||
<ListPlus className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className={act}>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute inset-0 grid place-items-center">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-full bg-black/55 text-white">
|
||||
<Play className="h-5 w-5" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-1">
|
||||
<div className="min-w-0 flex-1 px-0.5">
|
||||
<div className="line-clamp-2 select-text text-xs font-medium">
|
||||
Building a home lab on a budget: Proxmox, ZFS and a cheap NAS
|
||||
</div>
|
||||
<div className="text-2xs text-muted">Techno Tim · 2 days ago</div>
|
||||
</div>
|
||||
{touch && (
|
||||
<span
|
||||
className={clsx(
|
||||
"grid shrink-0 place-items-center rounded-lg text-muted",
|
||||
"h-11 w-11",
|
||||
LAYER,
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function C1() {
|
||||
return (
|
||||
<Decision
|
||||
id="c1"
|
||||
code="C1"
|
||||
title="Action placement: the classification"
|
||||
question="Where each object's actions live. You delegated this one, so it is for review, not a pick: say where you disagree. The menu (right-click, long-press, Shift+F10 or the '…' button) always lists ALL of an object's actions from one list; the table says what is ALSO visible."
|
||||
now="No object gets its buttons and its menu from one list: the feed card's menu re-declares watched / save / hide / reset and leaves out add-to-playlist and download (hover only). Move up / down and pin live only in menus. Downloads, Notifications and the Scheduler have no menu at all; Channels and Plex menus offer only Pin."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="The card, mouse (hovered) and touch"
|
||||
recommended
|
||||
why="Mouse: play, the watched / progress state and the channel at rest; the three everyday toggles plus '…' on hover. Touch: nothing to hover, so the card keeps its state and a 44px '…' next to the title; everything else is in the menu. The title is plain, selectable text."
|
||||
>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Sample label="Mouse, hovered">
|
||||
<Card touch={false} />
|
||||
</Sample>
|
||||
<Sample label="Touch">
|
||||
<Card touch />
|
||||
</Sample>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="The background menu (desktop only)"
|
||||
why="A right-click on empty content area opens the page's menu. It only mirrors toolbar actions that already exist; Shift+right-click still gives the browser's own menu. Not on touch: a long-press on the background would fight scrolling and text selection."
|
||||
>
|
||||
<table className="text-xs">
|
||||
<tbody>
|
||||
{BACKGROUND.map(([page, items]) => (
|
||||
<tr key={page} className="align-top [&>td]:py-1 [&>td]:pr-3">
|
||||
<td className="whitespace-nowrap font-medium">{page}</td>
|
||||
<td className="text-muted">{items}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Variant>
|
||||
<div className="col-span-full">
|
||||
<Table />
|
||||
</div>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
@@ -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)";
|
||||
Reference in New Issue
Block a user