feat(design): batch A recorded, batch B in the design lab (E28 S2b)
DESIGN.md: the Controls chapter decided (A1-A6) with the decision-log rows; density stays open as B1. The lab keeps only the chosen A renderings as reference and adds batch B: density, the scheme set (measured accent hue gaps and light contrast), state layers, the page-chrome model, surface per role, the public watch page, the nav rail, the loading form and where the header + fade edge goes.
This commit is contained in:
+49
-22
@@ -54,8 +54,8 @@ style guide.
|
||||
`.reveal` class), or it is removed on touch because the same action is reachable another way
|
||||
(`.reveal-hover`). [WCAG 1.4.13, Nielsen #6]
|
||||
- **Targets:** at least 2.75rem (44px) on a coarse pointer, as a real box, not an invisible hit area:
|
||||
overlapping hit areas fire the wrong control. [HIG, WCAG 2.5.8] _Which controls this covers beyond
|
||||
`Button` is decision A3._
|
||||
overlapping hit areas fire the wrong control. It covers every control primitive, not only `Button`
|
||||
(A3, under Controls). [HIG, WCAG 2.5.8]
|
||||
- **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page
|
||||
when an input gets focus.
|
||||
- **Below 768px** (a narrow window, split screen): what replaces the side rails is **Pending (C)**.
|
||||
@@ -63,23 +63,40 @@ style guide.
|
||||
## Tokens
|
||||
|
||||
- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger
|
||||
--warn --success --info --border`, each with a `-fg` partner where text sits on it). Six schemes × dark
|
||||
--warn --success --info --border`, each with a `-fg` partner where text sits on it). Six schemes × dark
|
||||
and light. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. [WCAG 1.4.3,
|
||||
1.4.11] _The light-theme accent fix is decision B; the input edge token is decision A4._
|
||||
1.4.11] _The scheme set and the light-theme accent are decision B2._ The input edge token is set under
|
||||
Controls (A4).
|
||||
- **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a
|
||||
px font size or a px-frozen box is an eslint error.
|
||||
- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. _Buttons, inputs, triggers
|
||||
and icon buttons: decisions A1, A2, A4, A5. Surfaces per role: decision B._
|
||||
- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. Buttons, icon buttons and
|
||||
form fields `rounded-lg`; toolbar triggers and search a pill (Controls). _Surfaces per role: decision B5._
|
||||
- **Layers:** the fixed z-index scale in `index.css` (`base < menu < chrome < paneltab < panel < rail <
|
||||
overlay < popover < tooltip`). No ad-hoc z-index. [maintainability]
|
||||
overlay < popover < tooltip`). No ad-hoc z-index. [maintainability]
|
||||
|
||||
## Controls — **Pending (batch A)**
|
||||
## Controls — _Decided_ (batch A; density **Pending**, B1)
|
||||
|
||||
Open in the lab: **A1** button shape and size rule · **A2** icon-button sizes and the on-media button ·
|
||||
**A3** the touch size beyond `Button` · **A4** the input primitive · **A5** the dropdown trigger ·
|
||||
**A6** checkbox, switch, slider.
|
||||
The chosen rendering of each rule is in the lab under "Decided". The primitives are built to it in S3.
|
||||
|
||||
- **Buttons (A1):** `rounded-lg` (r8): a control's corner is smaller than the surface it sits on (card r12,
|
||||
dialog r16). The rung follows the context: `md` in forms and dialog footers, `sm` in toolbars and panel
|
||||
headers, `xs` inside a table row. Glass is a `Button` variant, not a hand-built glass button. _The rung
|
||||
heights (standard 38/32/26 or compact 32/28/24) are decision B1._ [HIG, Material]
|
||||
- **Icon buttons (A2):** two sizes, r8: the smaller in rows and dense toolbars, the larger on its own. On a
|
||||
picture, a round, dark translucent button, readable on any thumbnail. Round means "on media" only.
|
||||
- **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`,
|
||||
`Tabs`, `Switch`, chips, the nav rail) grows to 2.75rem (44px) through one token, as a real box. Mouse
|
||||
sizes don't change. [HIG, WCAG 2.5.8]
|
||||
- **Input (A4):** one bordered input primitive. The edge is `color-mix(in srgb, var(--fg) 55%, var(--bg))`,
|
||||
≥ 3:1 against the page in every scheme and mode. A form field is r8 at the `md` height; a toolbar search
|
||||
is a pill. Text is 1rem on a coarse pointer. [WCAG 1.4.11]
|
||||
- **Dropdown trigger (A5):** a pill in a toolbar (where the pill means "a filter or a choice"), r8 in a form
|
||||
(it lines up with the inputs).
|
||||
- **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color`; the whole
|
||||
labelled row is the 44px target. Keyboard, screen reader and forced-colours support come from the browser.
|
||||
|
||||
Also in force:
|
||||
|
||||
Already in force:
|
||||
- An action button is `ui/Button` with a `SIZES` rung. A hand-rolled `<button className="px-3 …">` is a
|
||||
violation. A text-less button must have an accessible name, and the type enforces it. [WCAG 4.1.2]
|
||||
- A choice between a few values is `SegmentedToggle`; a long list or a user's own list is `FilterSelect`.
|
||||
@@ -90,11 +107,14 @@ Already in force:
|
||||
|
||||
## Surfaces and page chrome — **Pending (batch B)**
|
||||
|
||||
Open: the page-chrome model (a sticky toolbar vs controls in the content, collapsing, the page title), the
|
||||
glass role per surface, the public watch page, the light-theme accent, menu items and popover surfaces, the
|
||||
nav rail, and the loading form (spinner vs skeleton).
|
||||
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.
|
||||
|
||||
Already in force:
|
||||
|
||||
- The glass role is load-bearing: a dropdown or menu is `.glass-menu` (it floats over undimmed content, so a
|
||||
card alpha would bleed through), a dialog is `.glass`, and a grid card is `.glass-card`.
|
||||
- Every overlay is a `Modal`/`Overlay` or an anchored popover from the one popover engine. Escape closes the
|
||||
@@ -124,6 +144,7 @@ Which actions are visible and which live in a menu.
|
||||
### Text stays selectable — _Decided_
|
||||
|
||||
Wherever there is text, the user can select it and copy it, and no click action overrides that.
|
||||
|
||||
- A title or a name is never the text content of a `<button>`. The play/open target is the thumbnail, an
|
||||
icon, or a link-styled element.
|
||||
- A click that ends a text selection does nothing: if the selection inside the element is non-empty, or
|
||||
@@ -146,7 +167,7 @@ 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: batch B._
|
||||
Space is reserved for late content so the layout doesn't jump. _Spinner vs skeleton: decision 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.
|
||||
@@ -169,9 +190,15 @@ 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { SCHEMES, type Scheme } from "../lib/theme";
|
||||
import { A1, A2, A3, A4, A5, A6 } from "./batchA";
|
||||
import { BatchA } from "./batchA";
|
||||
import { B1, B2, B3 } from "./batchB";
|
||||
import { B4, B5, B6, B7, B8, B9 } from "./batchBChrome";
|
||||
|
||||
const BATCH_A = [
|
||||
{ id: "a1", code: "A1", title: "Button shape", render: A1 },
|
||||
{ id: "a2", code: "A2", title: "Icon buttons", render: A2 },
|
||||
{ id: "a3", code: "A3", title: "Touch size", render: A3 },
|
||||
{ id: "a4", code: "A4", title: "Input", render: A4 },
|
||||
{ id: "a5", code: "A5", title: "Dropdown trigger", render: A5 },
|
||||
{ id: "a6", code: "A6", title: "Checkbox / switch / slider", render: A6 },
|
||||
// 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 },
|
||||
] as const;
|
||||
|
||||
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
|
||||
@@ -97,7 +103,7 @@ export default function DesignLab() {
|
||||
/>
|
||||
</label>
|
||||
<nav className="flex flex-wrap gap-1 text-xs">
|
||||
{BATCH_A.map((d) => (
|
||||
{OPEN.map((d) => (
|
||||
<a
|
||||
key={d.id}
|
||||
href={`#${d.id}`}
|
||||
@@ -106,20 +112,30 @@ export default function DesignLab() {
|
||||
{d.code} {d.title}
|
||||
</a>
|
||||
))}
|
||||
<a href="#decided" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
|
||||
A ✓
|
||||
</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 A · controls</h1>
|
||||
<h1 className="text-2xl font-bold">Batch B · surfaces, chrome and colour</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.
|
||||
</p>
|
||||
</header>
|
||||
{BATCH_A.map((d) => (
|
||||
{OPEN.map((d) => (
|
||||
<d.render key={d.id} />
|
||||
))}
|
||||
<header id="decided" className="mt-6 scroll-mt-20">
|
||||
<h2 className="text-xl font-bold">Decided · batch A, controls</h2>
|
||||
<p className="text-sm text-muted">
|
||||
The chosen rendering of each decision, the reference the primitives are built to.
|
||||
</p>
|
||||
</header>
|
||||
<BatchA />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Download,
|
||||
ListPlus,
|
||||
@@ -12,10 +11,11 @@ import {
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Contrast, Decision, Sample, Variant } from "./labKit";
|
||||
import { Contrast, Reference, Sample } from "./labKit";
|
||||
|
||||
// E28 S2 · Batch A — controls. Each decision renders its variants with the app's real CSS classes
|
||||
// (glass, btn-primary, tokens); nothing here is wired to data. The markup is the proposal itself.
|
||||
// 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.
|
||||
|
||||
// ── shared proposal pieces ─────────────────────────────────────────────────────────────────────
|
||||
type Tone = "primary" | "ghost" | "outline" | "glass" | "danger";
|
||||
@@ -105,41 +105,6 @@ function ButtonSet({ radius }: { radius: Radius }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function A1() {
|
||||
return (
|
||||
<Decision
|
||||
id="a1"
|
||||
code="A1"
|
||||
title="Button shape and ladder"
|
||||
question="Which corner radius do action buttons have, and which size goes where?"
|
||||
now="69 button variants. Through Button: r8, but the glass buttons built directly on FocusSafeButton are r12 (6 modules), following an older glass-phase house note (glass buttons = rounded-xl). Ten heights in use; dialog footers h32, forms h38."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="r8 (rounded-lg) + a size rule per context"
|
||||
recommended
|
||||
why="The inner corner is smaller than the outer one: an r8 button inside an r12 card or an r16 dialog. It matches the locked tabs = rounded-lg. Glass becomes a Button variant, so the r12 FocusSafeButton buttons go. Sizes: md = forms and dialog footers, sm = toolbars and panel headers, xs = inside a table row."
|
||||
>
|
||||
<ButtonSet radius="rounded-lg" />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="r12 (rounded-xl)"
|
||||
why="Softer, but the button now has the same radius as the card it sits in, so it reads as a card inside a card. On a 26px xs button r12 is almost a pill."
|
||||
>
|
||||
<ButtonSet radius="rounded-xl" />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="C"
|
||||
name="Pill (rounded-full)"
|
||||
why="The same shape as chips, segmented toggles and dropdown triggers. In this app the pill means 'a filter or a choice', so actions and filters would look alike."
|
||||
>
|
||||
<ButtonSet radius="rounded-full" />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── A2 · icon buttons ─────────────────────────────────────────────────────────────────────────────
|
||||
function Icon({ box, radius, children }: { box: string; radius: string; children: ReactNode }) {
|
||||
return (
|
||||
@@ -212,41 +177,6 @@ function IconSet({ row, bar, media }: { row: string; bar: string; media: string
|
||||
);
|
||||
}
|
||||
|
||||
export function A2() {
|
||||
return (
|
||||
<Decision
|
||||
id="a2"
|
||||
code="A2"
|
||||
title="Icon buttons"
|
||||
question="How many icon-button sizes are there, and what does one look like on a picture?"
|
||||
now="44 variants, 33 of them hand-rolled. Heights 10–38, six radii. The video-card trio is hand-rolled at 24 and 28."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="Two sizes (32 / 36), r8, plus a round on-media button"
|
||||
recommended
|
||||
why="32 in rows and dense toolbars, 36 on its own. The only round one sits on a picture: a dark translucent disc stays readable on any thumbnail. The two existing Button sizes (icon-sm / icon) become the only ones."
|
||||
>
|
||||
<IconSet row="w-8 h-8" bar="w-9 h-9" media="rounded-full" />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="One size (32) everywhere, square on the picture too"
|
||||
why="The fewest decisions, but a lone toolbar icon at 32 looks lost next to 38px buttons, and a square on-media button looks like a UI panel pasted on the image."
|
||||
>
|
||||
<IconSet row="w-8 h-8" bar="w-8 h-8" media="rounded-lg" />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="C"
|
||||
name="Every icon button round"
|
||||
why="Uniform, but round in the app means a chip or a player control. Rows of round buttons read as tags, not actions."
|
||||
>
|
||||
<IconSet row="w-8 h-8 rounded-full" bar="w-9 h-9 rounded-full" media="rounded-full" />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
@@ -287,41 +217,6 @@ function TouchRow({ h, gap }: { h: string; gap: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function A3() {
|
||||
return (
|
||||
<Decision
|
||||
id="a3"
|
||||
code="A3"
|
||||
title="The touch size beyond Button"
|
||||
question="On a tablet (a coarse pointer), how big do the segmented toggles, dropdown triggers, tabs, switch rows and nav items become? These are shown in their TABLET size; the mouse sizes stay as they are."
|
||||
now="Only ui/Button grows on touch (to 44). On a tablet the segmented toggle is 24, the trigger 30, the tabs 32, the switch 36×20, the nav item 38×34."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="44 (2.75rem): the same rule as Button"
|
||||
recommended
|
||||
why="HIG 44pt, and the rule Button already follows, extended to every control primitive through one token. A tablet toolbar grows by about 14px; mouse sizes don't change."
|
||||
>
|
||||
<TouchRow h="min-h-11" gap="gap-2" />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="36 (2.25rem) + 8px gaps"
|
||||
why="Meets WCAG 2.5.8 (24 + spacing) comfortably and keeps the toolbar denser, but is below HIG. Two different touch sizes (Button 44, the rest 36) is a rule nobody remembers."
|
||||
>
|
||||
<TouchRow h="min-h-9" gap="gap-2" />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="—"
|
||||
name="Today, on a tablet (reference)"
|
||||
why="What the S1c measurement found at a coarse pointer: 24 / 30 / 32 / 20 / 34."
|
||||
>
|
||||
<TouchRow h="min-h-6" gap="gap-1" />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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:
|
||||
@@ -365,55 +260,6 @@ function Field({
|
||||
);
|
||||
}
|
||||
|
||||
export function A4() {
|
||||
return (
|
||||
<Decision
|
||||
id="a4"
|
||||
code="A4"
|
||||
title="The input"
|
||||
question="What does a text input look like? One primitive in two sizes: a toolbar pill and a form field."
|
||||
now="There is no input primitive: 10 + 10 variants, heights 20–38, five radii. The border is --border, 1.2–1.6:1 against the page (WCAG 1.4.11 asks 3:1 for a control's edge). Every input is 14px, and the pager's are 12."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="Bordered, with a stronger border token"
|
||||
recommended
|
||||
why="The edge clears 3:1 in every scheme, so the field is recognisable without a hover. Form: r8, the height of an md button. Toolbar: a pill, like the other toolbar controls. The font is 1rem on a coarse pointer, so a tablet browser doesn't zoom in when the field gets focus."
|
||||
>
|
||||
<Field
|
||||
cls="h-9.5 rounded-lg border bg-transparent"
|
||||
style={{ borderColor: STRONG }}
|
||||
searchCls="h-9 rounded-full border"
|
||||
searchStyle={{ borderColor: STRONG }}
|
||||
/>
|
||||
<Contrast fg={STRONG} min={3} label="Edge vs page" />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Filled, no border (accent underline on focus)"
|
||||
why="Calm, material-style. But the fill has to be the thing that clears 3:1, and --surface over --bg is far from it, so the fill would need a new, much stronger token that looks heavy on glass."
|
||||
>
|
||||
<Field
|
||||
cls="h-9.5 rounded-lg bg-surface border-b-2 border-transparent focus:border-accent"
|
||||
searchCls="h-9 rounded-full bg-surface"
|
||||
/>
|
||||
<Contrast fg="var(--surface)" min={3} label="Fill vs page" />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="C"
|
||||
name="Today's look, made the primitive"
|
||||
why="No visual change, only the drift goes. The edge stays under 3:1."
|
||||
>
|
||||
<Field
|
||||
cls="h-9.5 rounded-xl glass-card border border-border"
|
||||
searchCls="h-9 rounded-full glass-card border border-border"
|
||||
/>
|
||||
<Contrast fg="var(--border)" min={3} label="Edge vs page" />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── A5 · dropdown trigger ────────────────────────────────────────────────────────────────────────
|
||||
function Trigger({ cls, label, value }: { cls: string; label: string; value: string }) {
|
||||
return (
|
||||
@@ -454,41 +300,6 @@ function DropdownSet({ bar, form }: { bar: string; form: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function A5() {
|
||||
return (
|
||||
<Decision
|
||||
id="a5"
|
||||
code="A5"
|
||||
title="The dropdown trigger"
|
||||
question="What shape is a dropdown trigger?"
|
||||
now="Two shapes side by side in the same feed toolbar: FilterSelect / Sort / View are pills (h30), the date filter, channel stats, DataTable filter and Plex collections are r8 (h34)."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="By context: a pill in a toolbar, r8 in a form"
|
||||
recommended
|
||||
why="A toolbar holds filters and choices, and the pill already means that here (chips, segmented). A form field matches the input next to it (A4), so a form reads as one column of the same boxes."
|
||||
>
|
||||
<DropdownSet bar="rounded-full px-3 py-1" form="h-9.5 rounded-lg px-3" />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="A pill everywhere"
|
||||
why="One shape, but in a form a pill select next to r8 inputs breaks the column."
|
||||
>
|
||||
<DropdownSet bar="rounded-full px-3 py-1" form="h-9.5 rounded-full px-3" />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="C"
|
||||
name="r8 everywhere"
|
||||
why="One shape, but the toolbar filters then look like the action buttons next to them."
|
||||
>
|
||||
<DropdownSet bar="rounded-lg px-3 py-1.5" form="h-9.5 rounded-lg px-3" />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── A6 · checkbox, switch, slider ────────────────────────────────────────────────────────────────
|
||||
function NativeSet() {
|
||||
return (
|
||||
@@ -517,56 +328,63 @@ function NativeSet() {
|
||||
);
|
||||
}
|
||||
|
||||
function CustomSet() {
|
||||
export function BatchA() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-11 items-center gap-3 px-2 text-sm">
|
||||
<span className="grid h-5 w-5 place-items-center rounded-md bg-accent text-accent-fg">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
Only unwatched
|
||||
</div>
|
||||
<div className="flex min-h-11 items-center justify-between gap-3 px-2 text-sm">
|
||||
Hide Shorts
|
||||
<span className="relative h-6 w-11 rounded-full bg-accent shadow-inner">
|
||||
<span className="absolute right-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-2 text-sm">
|
||||
Text size
|
||||
<span className="relative h-1.5 rounded-full bg-border">
|
||||
<span className="absolute inset-y-0 left-0 w-2/5 rounded-full bg-accent" />
|
||||
<span className="absolute left-2/5 top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow" />
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function A6() {
|
||||
return (
|
||||
<Decision
|
||||
id="a6"
|
||||
code="A6"
|
||||
title="Checkbox, switch, slider"
|
||||
question="Do these stay the browser's own controls (tinted), or get drawn primitives?"
|
||||
now="Native checkbox in Playlists (13×13), native range in Appearance and Notifications (two looks, h16), ui/Switch 36×20."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="Native, tinted with the accent; the whole row is the target"
|
||||
recommended
|
||||
why="Keyboard, screen reader and high-contrast mode come free from the browser. The row label is the touch target (44 tall), so the small box itself does not have to grow. Only 3 sites today, so it is cheap to keep this way."
|
||||
<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."
|
||||
>
|
||||
<ButtonSet radius="rounded-lg" />
|
||||
</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."
|
||||
>
|
||||
<IconSet row="w-8 h-8" bar="w-9 h-9" media="rounded-full" />
|
||||
</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."
|
||||
>
|
||||
<TouchRow h="min-h-11" gap="gap-2" />
|
||||
</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."
|
||||
>
|
||||
<Field
|
||||
cls="h-9.5 rounded-lg border bg-transparent"
|
||||
style={{ borderColor: STRONG }}
|
||||
searchCls="h-9 rounded-full border"
|
||||
searchStyle={{ borderColor: STRONG }}
|
||||
/>
|
||||
<Contrast fg={STRONG} min={3} label="Edge vs page" />
|
||||
</Reference>
|
||||
<Reference
|
||||
id="a5"
|
||||
code="A5"
|
||||
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" />
|
||||
</Reference>
|
||||
<Reference
|
||||
id="a6"
|
||||
code="A6"
|
||||
title="Checkbox, switch, slider"
|
||||
rule="The browser's own controls tinted with the accent; the whole labelled row is the 44px target."
|
||||
>
|
||||
<NativeSet />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Drawn primitives (Checkbox / Slider)"
|
||||
why="Identical in every browser and fully on-brand, but every one of those free behaviours has to be rebuilt and tested by hand. That cost is worth it at 30 sites, not at 3."
|
||||
>
|
||||
<CustomSet />
|
||||
</Variant>
|
||||
</Decision>
|
||||
</Reference>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
import clsx from "clsx";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { hexContrast, hueGap } from "./labColor";
|
||||
import { Contrast, Decision, Sample, Variant } from "./labKit";
|
||||
|
||||
// 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.
|
||||
|
||||
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.
|
||||
|
||||
type Pal = {
|
||||
bg: string;
|
||||
surface: string;
|
||||
card: string;
|
||||
border: string;
|
||||
fg: string;
|
||||
muted: string;
|
||||
accent: string;
|
||||
accentFg: string;
|
||||
};
|
||||
type Mode = "dark" | "light";
|
||||
type SchemeDef = { name: string; dark: Pal; light: Pal };
|
||||
|
||||
const STATUS: Record<Mode, Record<"danger" | "dangerFg" | "warn" | "success", string>> = {
|
||||
dark: { danger: "#f87171", dangerFg: "#2a0a0a", warn: "#fbbf24", success: "#34d399" },
|
||||
light: { danger: "#dc2626", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" },
|
||||
};
|
||||
|
||||
const pal = (
|
||||
bg: string,
|
||||
surface: string,
|
||||
card: string,
|
||||
border: string,
|
||||
fg: string,
|
||||
muted: string,
|
||||
accent: string,
|
||||
accentFg: string,
|
||||
): Pal => ({ bg, surface, card, border, fg, muted, accent, accentFg });
|
||||
|
||||
// Today's six, copied from index.css (the scheme blocks).
|
||||
const N = {
|
||||
midnight: {
|
||||
dark: ["#0b1020", "#121a2e", "#161f38", "#243049", "#e6e9f0", "#97a3c0"],
|
||||
light: ["#f4f6fc", "#ffffff", "#ffffff", "#dde3f0", "#1a2238", "#5b6685"],
|
||||
},
|
||||
forest: {
|
||||
dark: ["#0a1512", "#0f1f1a", "#12241e", "#1f3a30", "#e6f0ec", "#90b1a4"],
|
||||
light: ["#f1f7f4", "#ffffff", "#ffffff", "#d6e7e0", "#102a22", "#4d6b60"],
|
||||
},
|
||||
slate: {
|
||||
dark: ["#15171c", "#1b1e25", "#1f232b", "#2c313b", "#e7e9ee", "#9aa1ad"],
|
||||
light: ["#f6f7f9", "#ffffff", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e"],
|
||||
},
|
||||
youtube: {
|
||||
dark: ["#0f0f0f", "#181818", "#1f1f1f", "#303030", "#f1f1f1", "#aaaaaa"],
|
||||
light: ["#ffffff", "#f9f9f9", "#ffffff", "#e5e5e5", "#0f0f0f", "#606060"],
|
||||
},
|
||||
starship: {
|
||||
dark: ["#050b16", "#0b1728", "#101f36", "#1f3557", "#dfeaff", "#8aa6cc"],
|
||||
light: ["#eef4fb", "#ffffff", "#ffffff", "#d3e0f0", "#0b1b30", "#4a5f7d"],
|
||||
},
|
||||
matrix: {
|
||||
dark: ["#030a06", "#08130d", "#0b1c12", "#163d27", "#cfe8d6", "#6fae86"],
|
||||
light: ["#f0f7f1", "#ffffff", "#ffffff", "#cfe6d6", "#08130b", "#4c6b56"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
function scheme(
|
||||
name: string,
|
||||
id: keyof typeof N,
|
||||
dark: [string, string],
|
||||
light: [string, string],
|
||||
): SchemeDef {
|
||||
const [db, ds, dc, dbo, df, dm] = N[id].dark;
|
||||
const [lb, ls, lc, lbo, lf, lm] = N[id].light;
|
||||
return {
|
||||
name,
|
||||
dark: pal(db, ds, dc, dbo, df, dm, dark[0], dark[1]),
|
||||
light: pal(lb, ls, lc, lbo, lf, lm, light[0], light[1]),
|
||||
};
|
||||
}
|
||||
|
||||
const TODAY: SchemeDef[] = [
|
||||
scheme("Midnight", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]),
|
||||
scheme("Forest", "forest", ["#2dd4bf", "#04110d"], ["#0d9488", "#ffffff"]),
|
||||
scheme("Slate", "slate", ["#e8913c", "#1a1206"], ["#d97706", "#ffffff"]),
|
||||
scheme("YouTube", "youtube", ["#ff3b46", "#ffffff"], ["#ff0033", "#ffffff"]),
|
||||
scheme("Starship", "starship", ["#38b6ff", "#04101f"], ["#0b6bd6", "#ffffff"]),
|
||||
scheme("Matrix", "matrix", ["#46b87c", "#04170c"], ["#12a150", "#ffffff"]),
|
||||
];
|
||||
|
||||
// The proposal: four schemes, four hue families far from each other and from red / amber / green.
|
||||
// Only the accents move; every background and text colour stays as it is.
|
||||
const PROPOSED: SchemeDef[] = [
|
||||
scheme("Midnight · blue", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]),
|
||||
scheme("Forest · cyan", "forest", ["#22d3ee", "#04110d"], ["#0e7490", "#ffffff"]),
|
||||
scheme("Slate · fuchsia", "slate", ["#e879f9", "#1a0620"], ["#a21caf", "#ffffff"]),
|
||||
scheme("Matrix · lime", "matrix", ["#a3e635", "#04170c"], ["#4d7c0f", "#ffffff"]),
|
||||
];
|
||||
|
||||
const MIN_GAP = 30;
|
||||
|
||||
function Metric({ ok, children }: { ok: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<span className={clsx("tabular-nums", ok ? "text-success" : "text-danger")}>{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
|
||||
const p = s[mode];
|
||||
const st = STATUS[mode];
|
||||
const style = {
|
||||
"--bg": p.bg,
|
||||
"--surface": p.surface,
|
||||
"--card": p.card,
|
||||
"--border": p.border,
|
||||
"--fg": p.fg,
|
||||
"--muted": p.muted,
|
||||
"--accent": p.accent,
|
||||
"--accent-fg": p.accentFg,
|
||||
"--danger": st.danger,
|
||||
"--danger-fg": st.dangerFg,
|
||||
"--warn": st.warn,
|
||||
"--success": st.success,
|
||||
} as CSSProperties;
|
||||
const gaps = (["danger", "warn", "success"] as const).map((k) => ({
|
||||
k,
|
||||
g: Math.round(hueGap(p.accent, st[k])),
|
||||
}));
|
||||
const worst = gaps.reduce((a, b) => (b.g < a.g ? b : a));
|
||||
const text = hexContrast(p.accent, p.bg);
|
||||
const onFill = hexContrast(p.accentFg, p.accent);
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className="flex flex-col gap-2 rounded-lg border border-border bg-bg p-2.5 text-fg"
|
||||
>
|
||||
<span className="text-xs font-semibold">
|
||||
{s.name} <span className="font-normal text-muted">· {mode}</span>
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="btn-primary rounded-lg px-2.5 py-1 text-xs font-medium">Save</span>
|
||||
<span className="btn-danger rounded-lg px-2.5 py-1 text-xs font-medium">Delete</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 text-2xs">
|
||||
<span className="chip px-2 py-0.5">Filter</span>
|
||||
<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={text >= 4.5}>{`accent as text ${text.toFixed(2)}:1`}</Metric>
|
||||
<Metric ok={onFill >= 4.5}>{`button text ${onFill.toFixed(2)}:1`}</Metric>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SchemeGrid({ set }: { set: SchemeDef[] }) {
|
||||
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} />),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function B2() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function LayerSet({ l }: { l: Layers }) {
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</Sample>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function B3() {
|
||||
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."
|
||||
>
|
||||
<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."
|
||||
>
|
||||
<LayerSet l={LAYERS.material} />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="—"
|
||||
name="Today (reference)"
|
||||
why="The ghost-on-card row shows why the complaint exists."
|
||||
>
|
||||
<LayerSet l={LAYERS.today} />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
import clsx from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Home,
|
||||
ListVideo,
|
||||
Loader2,
|
||||
Lock,
|
||||
PlayCircle,
|
||||
Repeat,
|
||||
Rss,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Tv,
|
||||
} from "lucide-react";
|
||||
import { Decision, Sample, Variant } from "./labKit";
|
||||
|
||||
// E28 S2 · Batch B, part 2 — surfaces and page chrome: the page-chrome model, the surface per role,
|
||||
// the public watch page, the nav rail, the loading form, and where the header + fade edge goes.
|
||||
// The mini pages are real scroll boxes: scroll them to see what stays and what slides under.
|
||||
|
||||
const ico = "w-4 h-4";
|
||||
|
||||
/** The north-star edge (DataTable's sticky thead): a crisp band, and a short gradient of the colour
|
||||
* it sits on just below it, so content dissolves as it slides under. Literal strings per colour —
|
||||
* Tailwind only generates classes it can read whole. */
|
||||
const FADE_BELOW =
|
||||
"after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']";
|
||||
const FADE_BELOW_PANEL =
|
||||
"after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--surface),transparent)] after:content-['']";
|
||||
const FADE_ABOVE_PANEL =
|
||||
"before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-4 before:bg-[linear-gradient(to_top,var(--surface),transparent)] before:content-['']";
|
||||
|
||||
function Frame({ h, label, children }: { h: string; label: string; children: ReactNode }) {
|
||||
return (
|
||||
<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,33 @@
|
||||
// ── Fixed-palette maths ──────────────────────────────────────────────────────────────────────────
|
||||
// A proposed scheme is not in the CSS yet, so it can't be measured through the probe: these work on
|
||||
// literal hex values instead. Hue distance is taken in OKLCH, where equal angles look equally far
|
||||
// apart, unlike HSL's.
|
||||
|
||||
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
||||
const lin2 = (v: number) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
||||
|
||||
export function hexContrast(a: string, b: string): number {
|
||||
const l = (h: string) => {
|
||||
const [r = 0, g = 0, bl = 0] = rgb(h).map(lin2);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
|
||||
};
|
||||
const x = l(a);
|
||||
const y = l(b);
|
||||
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
||||
}
|
||||
|
||||
function hue(hex: string): number {
|
||||
const [r = 0, g = 0, b = 0] = rgb(hex).map(lin2);
|
||||
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
||||
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
||||
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
||||
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
||||
return ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360;
|
||||
}
|
||||
|
||||
/** The OKLCH hue angle between two colours, 0..180°. */
|
||||
export function hueGap(a: string, b: string): number {
|
||||
const d = Math.abs(hue(a) - hue(b)) % 360;
|
||||
return Math.min(d, 360 - d);
|
||||
}
|
||||
@@ -77,6 +77,35 @@ export function Variant({
|
||||
);
|
||||
}
|
||||
|
||||
/** A decision already taken: the chosen rendering only, with the rule it became. The lab's
|
||||
* reference half — the living style guide the open decisions sit on top of. */
|
||||
export function Reference({
|
||||
id,
|
||||
code,
|
||||
title,
|
||||
rule,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
rule: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className="glass-card rounded-xl p-4 flex flex-col gap-3 scroll-mt-20">
|
||||
<header>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-xs font-bold tracking-widest text-success">{code} ✓</span>
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">{rule}</p>
|
||||
</header>
|
||||
<div className="flex flex-col gap-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A small caption above a sample inside a variant. */
|
||||
export function Sample({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user