feat(design): design lab + DESIGN.md skeleton (E28 S2a)
- docs/DESIGN.md: the written source of truth for the UI. Decided chapters (principles, device adaptation, action placement, text stays selectable, interaction rules, copy, anti-patterns, decision log); controls and surfaces are marked Pending until the owner picks from the lab. - /design-lab (dev only): renders each open decision's variants with the app's real CSS, themes and font scale. Batch A = six control decisions. - devOnlyGuard: a production build fails if anything under src/design-lab reaches the bundle (seen red with an unguarded import). - Size contract: tablet portrait + landscape and up, replacing the 1024x768 floor with its never-built "too small" message.
This commit is contained in:
+177
@@ -0,0 +1,177 @@
|
||||
# DESIGN.md — how Siftlode looks and behaves
|
||||
|
||||
This is the one written source of truth for the UI: principles, tokens, the canonical solution per pattern,
|
||||
and the interaction rules. Screens are measured against it. A pattern that has more than one look in the
|
||||
code is either a decision still open here (marked **Pending**) or a violation to fix.
|
||||
|
||||
**Status:** being written in E28. Chapters marked _Decided_ are in force. Chapters marked **Pending** are
|
||||
open decisions, each rendered side by side in the design lab (below) and settled by the product owner.
|
||||
|
||||
## How this document changes
|
||||
|
||||
- A rule changes only through an explicit decision recorded in the [decision log](#decision-log): what was
|
||||
decided, the options it was chosen from, and why. No mid-sprint taste changes. A new idea goes on the
|
||||
list for the next design round.
|
||||
- Every rule rests on a named standard: Nielsen's heuristics, WCAG 2.2 AA, WAI-ARIA APG, Apple HIG or
|
||||
Material. The tag is in brackets after the rule.
|
||||
- A rule stated here should also be enforced in code (an eslint rule, a primitive's type, a test). A rule
|
||||
that lives only in prose drifts. That is the finding that started this document.
|
||||
|
||||
## The design lab
|
||||
|
||||
`npm run dev`, then open **`http://localhost:5173/design-lab`**. The lab renders each open decision's variants
|
||||
with the app's real CSS, tokens, glass and themes, and has its own switches for theme, scheme, text size and
|
||||
backdrop. It is dev-only: `main.tsx` reaches it behind `import.meta.env.DEV`, and the build plugin
|
||||
`frontend/devOnlyGuard.ts` fails a production build that would contain it. Once a decision is taken, the
|
||||
losing variants are deleted and the chosen one stays as the reference rendering: the lab is the living
|
||||
style guide.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Glass over a calm background is the look.** Surfaces are translucent layers (`.glass`,
|
||||
`.glass-card`, `.glass-menu`, `.glass-media`) over a per-scheme backdrop. Depth comes from the
|
||||
**header + fade**: a crisp sticky header with a short gradient just below it, so content dissolves as it
|
||||
slides under. The reference is `DataTable`'s sticky header. A flat opaque box where a glass surface
|
||||
belongs is a violation.
|
||||
2. **Built for one known, daily user, on a desktop and a tablet.** Density is welcome where a mouse is
|
||||
the pointer; on touch every target grows. Nothing is designed for phones.
|
||||
3. **One primitive per pattern.** Where a `components/ui/` primitive exists and is used, the looks don't
|
||||
drift. The drift lives in hand-rolled markup. A new generic control goes into `ui/`, and a screen
|
||||
composes primitives rather than restyling them.
|
||||
4. **Measured, not eyeballed.** Contrast, target sizes and layout at the contract sizes are measured in a
|
||||
real browser before a UI change is called done.
|
||||
5. **Honest states.** An error never renders as an empty state; loading, empty and error are three
|
||||
different screens (`QueryState` / `StateMessage`).
|
||||
|
||||
## Device adaptation — _Decided_
|
||||
|
||||
- **Supported:** tablet portrait and landscape and every size above them (from 768px wide). Phones are not a
|
||||
target. Measure a UI change at 1024×768, 820×1180 (portrait) and a desktop size.
|
||||
- **Adapt by capability, never by user agent:** `@media (pointer: coarse)` and `(hover: none)` decide touch
|
||||
behaviour; container queries decide how a panel lays itself out. Breakpoints are named tokens, never
|
||||
literal pixel values scattered in call sites. [HIG, WCAG 1.4.10]
|
||||
- **Touch never loses a function.** Anything revealed on hover is visible at rest on `hover: none` (the
|
||||
`.reveal` class), or it is removed on touch because the same action is reachable another way
|
||||
(`.reveal-hover`). [WCAG 1.4.13, Nielsen #6]
|
||||
- **Targets:** at least 2.75rem (44px) on a coarse pointer, as a real box, not an invisible hit area:
|
||||
overlapping hit areas fire the wrong control. [HIG, WCAG 2.5.8] _Which controls this covers beyond
|
||||
`Button` is decision A3._
|
||||
- **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page
|
||||
when an input gets focus.
|
||||
- **Below 768px** (a narrow window, split screen): what replaces the side rails is **Pending (C)**.
|
||||
|
||||
## Tokens
|
||||
|
||||
- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger
|
||||
--warn --success --info --border`, each with a `-fg` partner where text sits on it). Six schemes × dark
|
||||
and light. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode. [WCAG 1.4.3,
|
||||
1.4.11] _The light-theme accent fix is decision B; the input edge token is decision A4._
|
||||
- **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a
|
||||
px font size or a px-frozen box is an eslint error.
|
||||
- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. _Buttons, inputs, triggers
|
||||
and icon buttons: decisions A1, A2, A4, A5. Surfaces per role: decision B._
|
||||
- **Layers:** the fixed z-index scale in `index.css` (`base < menu < chrome < paneltab < panel < rail <
|
||||
overlay < popover < tooltip`). No ad-hoc z-index. [maintainability]
|
||||
|
||||
## Controls — **Pending (batch A)**
|
||||
|
||||
Open in the lab: **A1** button shape and size rule · **A2** icon-button sizes and the on-media button ·
|
||||
**A3** the touch size beyond `Button` · **A4** the input primitive · **A5** the dropdown trigger ·
|
||||
**A6** checkbox, switch, slider.
|
||||
|
||||
Already in force:
|
||||
- An action button is `ui/Button` with a `SIZES` rung. A hand-rolled `<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`.
|
||||
Never a native `<select>` (lint-enforced).
|
||||
- A filter chip goes through `FilterChipGroup`.
|
||||
- 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)**
|
||||
|
||||
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).
|
||||
|
||||
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
|
||||
topmost layer only, and focus returns to the control that opened it. [WCAG 2.4.3, 2.1.2]
|
||||
|
||||
## Action placement — _Decided_
|
||||
|
||||
Which actions are visible and which live in a menu.
|
||||
|
||||
- **One menu, several triggers.** An object's actions are ONE list. The visible "…" button, a right-click,
|
||||
a long-press (touch) and Shift+F10 / the Menu key all open the same `ContextMenu` built from that list.
|
||||
A second, hand-made menu for the same object is a violation.
|
||||
- **Nothing is reachable only by a gesture.** Every menu action also has a visible path: the "…" button,
|
||||
the page toolbar, or the object's detail view. A right-click is a shortcut, never the only door.
|
||||
[Nielsen #6, #7]
|
||||
- **What stays visible:** the object's primary action (play or open) and any state the user needs to see
|
||||
(watched, saved, downloading). **What goes into the menu:** rare, secondary and destructive actions.
|
||||
Destructive ones keep their confirmation. [Nielsen #8, #5]
|
||||
- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu. _Which actions stay
|
||||
visible per object is part of batch C._
|
||||
- **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.
|
||||
Desktop only: on touch a long-press on the background clashes with scrolling and text selection, so the
|
||||
toolbar stays the touch path.
|
||||
|
||||
### 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
|
||||
the pointer moved past the drag slop, no play or open fires.
|
||||
- On touch the long-press belongs to the context menu, so the object's menu offers **Copy title** and
|
||||
**Copy link**.
|
||||
|
||||
## Interaction rules — _Decided_
|
||||
|
||||
- **Keyboard:** everything works by keyboard, in visual order, with no traps. A shortcut is a row in
|
||||
`lib/keymap.ts`; list and grid navigation is `NavGrid`/`keyboardNav`. [WCAG 2.1.1, 2.4.3, 2.1.2]
|
||||
- **Focus:** every control has a visible focus ring (the base ring in `index.css`; don't add a second). A
|
||||
sticky or fixed layer never covers the focused element. [WCAG 2.4.7, 2.4.11]
|
||||
- **Menus and tabs follow WAI-ARIA APG:** arrow keys move, Home/End jump, Escape closes and returns focus;
|
||||
tabs carry `tablist`/`tab`/`tabpanel` roles.
|
||||
- **Feedback:** a finished action is confirmed briefly (a toast that leaves by itself); anything that must
|
||||
be read or acted on is not only in a toast. Status changes are announced through a live region.
|
||||
[Nielsen #1, WCAG 2.2.1, 4.1.3]
|
||||
- **Errors:** a failed write goes through `meta.failTitle` / `notifyActionError`, never a silent `catch`.
|
||||
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._
|
||||
- **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]
|
||||
|
||||
## Copy
|
||||
|
||||
- Every user-facing string is in both Hungarian and English with zero key drift, and a user-facing error is
|
||||
a structured `{code, reason}` translated on the client.
|
||||
- A label says what happens ("Delete playlist"), not "OK". A destructive confirm names the object.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- A flat `bg-card` / `bg-surface` box where a glass surface belongs.
|
||||
- A hand-rolled control that restyles what a primitive already does.
|
||||
- Information or actions that exist only on hover, or only behind a right-click.
|
||||
- A click handler on text that swallows the user's text selection.
|
||||
- A colour literal, a px font size, a px-frozen box.
|
||||
- An empty state shown for an error.
|
||||
|
||||
## 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 |
|
||||
@@ -32,6 +32,10 @@ For a fast frontend edit loop, run the Vite dev server with HMR against that API
|
||||
cd frontend && npm install && npm run dev # http://localhost:5173, proxying the API on :8080
|
||||
```
|
||||
|
||||
The dev server also serves the **design lab** at `http://localhost:5173/design-lab`: the open design
|
||||
decisions and the chosen patterns, rendered with the app's real CSS. It never reaches a production build
|
||||
(see [`DESIGN.md`](DESIGN.md)).
|
||||
|
||||
## The gate
|
||||
|
||||
A change is done when all of this is green. CI runs the same checks server-side
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
// The design lab (`src/design-lab/`) is a dev-only page: main.tsx reaches it behind
|
||||
// `import.meta.env.DEV`, which Vite folds to `false` in a production build, so the dynamic import
|
||||
// is dead code and the chunk is never emitted. That rule used to be a sentence; this makes it a
|
||||
// build failure. If a future edit imports the lab unguarded (a static import, a shared helper, a
|
||||
// re-export), the production build refuses instead of shipping the lab to every visitor.
|
||||
const DEV_ONLY_DIR = "/src/design-lab/";
|
||||
|
||||
export function devOnlyGuard(): Plugin {
|
||||
return {
|
||||
name: "siftlode-dev-only-guard",
|
||||
apply: "build",
|
||||
generateBundle(_options, bundle) {
|
||||
for (const [fileName, output] of Object.entries(bundle)) {
|
||||
if (output.type !== "chunk") continue;
|
||||
const leaked = Object.keys(output.modules).find((id) =>
|
||||
id.replaceAll("\\", "/").includes(DEV_ONLY_DIR),
|
||||
);
|
||||
if (leaked) {
|
||||
this.error(
|
||||
`dev-only module ${leaked} reached the production bundle (${fileName}). ` +
|
||||
"Import src/design-lab only behind `import.meta.env.DEV`.",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -357,6 +357,13 @@ export default tseslint.config(
|
||||
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
|
||||
rules: { "local/focus-safe-disabled": "off" },
|
||||
},
|
||||
{
|
||||
// The design lab (src/design-lab, E28) is a dev-only page that never reaches the production
|
||||
// bundle (devOnlyGuard fails the build if it does). Its copy is the developer's and the product
|
||||
// owner's working text, not the product's, so the translate-every-label rule does not apply.
|
||||
files: ["src/design-lab/**/*.{ts,tsx}"],
|
||||
rules: { "local/literal-a11y-text": "off" },
|
||||
},
|
||||
{
|
||||
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
|
||||
// …and render throwaway markup whose labels no user ever hears.
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("Button primitive (S5)", () => {
|
||||
});
|
||||
|
||||
it("carries the coarse-pointer minimum target, except on a link", () => {
|
||||
// The 1024x768 comfort contract: the minimum lives in the primitive, so no call site can opt
|
||||
// The tablet comfort contract: the minimum lives in the primitive, so no call site can opt
|
||||
// out of it by accident. A link is text in a sentence and is excluded by design.
|
||||
const { unmount } = render(<Button>Tap</Button>);
|
||||
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
|
||||
|
||||
@@ -155,7 +155,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
||||
// here rather than by asking every call site to pass `size="none"` alongside `variant="link"` —
|
||||
// a pairing everyone would eventually forget, which is how a "link" grows a button's box.
|
||||
const resolvedSize: ButtonSize = size ?? (variant === "link" ? "none" : "md");
|
||||
// The 1024x768 comfort contract lives HERE, not in the call sites: on a coarse pointer every
|
||||
// The tablet comfort contract (docs/DESIGN.md) lives HERE, not in the call sites: on a coarse pointer every
|
||||
// button-shaped control gets at least a 2.75rem box (see .touch-target in index.css). `link` is
|
||||
// excluded on purpose — it is a run of text inside a sentence, and a 44px-tall inline link would
|
||||
// wreck the paragraph it sits in rather than make anything easier to hit.
|
||||
|
||||
@@ -46,10 +46,10 @@ domain-free.
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
The app optimises for **1024x768 (tablet landscape) as the floor**; below it we owe an honest
|
||||
message, above it correct rendering all the way up. That contract lives in these components, not in
|
||||
the ~160 call sites, because a rule repeated in 160 places is a rule that is already broken
|
||||
somewhere:
|
||||
The app supports **tablet portrait and landscape and every size above them** (from 768px wide; phones
|
||||
are not a target — see [`docs/DESIGN.md`](../../../../docs/DESIGN.md) § Device adaptation). Measure a UI
|
||||
change at 1024x768, 820x1180 and a desktop size. That contract lives in these components, not in the
|
||||
~160 call sites, because a rule repeated in 160 places is a rule that is already broken somewhere:
|
||||
|
||||
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES` are the rungs, derived from what
|
||||
the app actually used. Pick a rung; do not hand-roll padding on a primitive.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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";
|
||||
|
||||
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 },
|
||||
] as const;
|
||||
|
||||
// The design lab (E28): the living style guide. Dev-only — see main.tsx and devOnlyGuard.ts. It sits
|
||||
// OUTSIDE <App>, so it owns the theme attributes on <html> itself, through the same data-theme /
|
||||
// data-scheme / --font-scale / data-backdrop switches the app's PrefsProvider drives. Nothing here
|
||||
// is saved: the lab's bar changes this tab only.
|
||||
|
||||
type Look = { mode: "dark" | "light"; scheme: Scheme; fontScale: number; backdrop: boolean };
|
||||
|
||||
// The owner's own daily look (E28 S1 inventory) is the starting point — decisions are taken on it.
|
||||
const START: Look = { mode: "dark", scheme: "midnight", fontScale: 1.02, backdrop: true };
|
||||
|
||||
function applyLook(l: Look) {
|
||||
const el = document.documentElement;
|
||||
el.dataset.theme = l.mode;
|
||||
el.dataset.scheme = l.scheme;
|
||||
el.dataset.backdrop = l.backdrop ? "on" : "off";
|
||||
el.style.setProperty("--font-scale", String(l.fontScale));
|
||||
}
|
||||
|
||||
const chip = (on: boolean) =>
|
||||
clsx(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition",
|
||||
on ? "border-accent bg-accent text-accent-fg" : "border-border text-muted hover:text-fg",
|
||||
);
|
||||
|
||||
export default function DesignLab() {
|
||||
const [look, setLook] = useState<Look>(START);
|
||||
useLayoutEffect(() => {
|
||||
applyLook(START);
|
||||
document.title = "Design lab · Siftlode";
|
||||
}, []);
|
||||
const set = (patch: Partial<Look>) => {
|
||||
const next = { ...look, ...patch };
|
||||
applyLook(next);
|
||||
setLook(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh text-fg" data-testid="design-lab">
|
||||
<div className="glass sticky top-0 z-chrome flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2.5">
|
||||
<strong className="text-sm">Design lab</strong>
|
||||
<span className="flex gap-1">
|
||||
{(["dark", "light"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className={chip(look.mode === m)}
|
||||
onClick={() => set({ mode: m })}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{SCHEMES.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={chip(look.scheme === s.id)}
|
||||
onClick={() => set({ scheme: s.id })}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ background: s.swatch }} />
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={chip(look.backdrop)}
|
||||
onClick={() => set({ backdrop: !look.backdrop })}
|
||||
>
|
||||
backdrop
|
||||
</button>
|
||||
<label className="flex items-center gap-2 text-xs text-muted">
|
||||
text {look.fontScale.toFixed(2)}
|
||||
<input
|
||||
type="range"
|
||||
min={0.9}
|
||||
max={1.2}
|
||||
step={0.02}
|
||||
value={look.fontScale}
|
||||
onChange={(e) => set({ fontScale: Number(e.target.value) })}
|
||||
className="w-24 accent-accent"
|
||||
/>
|
||||
</label>
|
||||
<nav className="flex flex-wrap gap-1 text-xs">
|
||||
{BATCH_A.map((d) => (
|
||||
<a
|
||||
key={d.id}
|
||||
href={`#${d.id}`}
|
||||
className="rounded-md px-1.5 py-0.5 text-muted hover:bg-card hover:text-fg"
|
||||
>
|
||||
{d.code} {d.title}
|
||||
</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>
|
||||
<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) => (
|
||||
<d.render key={d.id} />
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
import clsx from "clsx";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Download,
|
||||
ListPlus,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Contrast, Decision, Sample, Variant } 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.
|
||||
|
||||
// ── 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",
|
||||
};
|
||||
|
||||
function Btn({
|
||||
tone,
|
||||
size,
|
||||
radius,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
tone: Tone;
|
||||
size: Size;
|
||||
radius: Radius;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"inline-flex items-center justify-center transition",
|
||||
TONE[tone],
|
||||
SIZE[size],
|
||||
radius,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"grid place-items-center text-muted hover:text-fg hover:bg-card transition",
|
||||
box,
|
||||
radius,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OnMedia({ radius }: { radius: string }) {
|
||||
const b = clsx(
|
||||
"grid h-8 w-8 place-items-center bg-black/55 text-white hover:bg-black/75",
|
||||
radius,
|
||||
);
|
||||
return (
|
||||
<div className="relative aspect-video 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} />
|
||||
</button>
|
||||
<button type="button" className={b}>
|
||||
<ListPlus className={ico} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSet({ row, bar, media }: { row: string; bar: string; media: string }) {
|
||||
const r = row.includes("full") ? "rounded-full" : "rounded-lg";
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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:
|
||||
// 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span
|
||||
className={clsx(
|
||||
"glass-card glass-hover inline-flex items-center gap-1.5 border text-sm",
|
||||
cls,
|
||||
)}
|
||||
>
|
||||
<span className="text-muted">{label}:</span> {value}
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<label className="flex min-h-11 items-center gap-3 rounded-lg px-2 text-sm hover:bg-card">
|
||||
<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">
|
||||
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" />
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 px-2 text-sm">
|
||||
Text size
|
||||
<input
|
||||
type="range"
|
||||
min={90}
|
||||
max={120}
|
||||
defaultValue={102}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomSet() {
|
||||
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."
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useSyncExternalStore, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
// Building blocks of the design lab. A lab page asks the owner ONE question per <Decision>: two or
|
||||
// three <Variant>s rendered side by side with the app's real CSS, the recommended one marked and its
|
||||
// one-line reason (the rule it rests on) printed under it. Everything is static markup on purpose —
|
||||
// what is being decided is the look, and a variant that only exists here must not need app state.
|
||||
|
||||
export function Decision({
|
||||
id,
|
||||
code,
|
||||
title,
|
||||
question,
|
||||
now,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
question: string;
|
||||
/** What the app does today, measured (the E28 S1 inventory). */
|
||||
now: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className="glass rounded-2xl p-5 scroll-mt-20">
|
||||
<header className="mb-4">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-xs font-bold tracking-widest text-accent">{code}</span>
|
||||
<h2 className="text-lg font-bold">{title}</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{question}</p>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
<span className="font-semibold">Today: </span>
|
||||
{now}
|
||||
</p>
|
||||
</header>
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Variant({
|
||||
letter,
|
||||
name,
|
||||
recommended = false,
|
||||
why,
|
||||
children,
|
||||
}: {
|
||||
letter: string;
|
||||
name: string;
|
||||
recommended?: boolean;
|
||||
why: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<article
|
||||
className={clsx(
|
||||
"glass-card rounded-xl p-4 flex flex-col gap-3 border",
|
||||
recommended ? "border-accent" : "border-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-6 w-6 place-items-center rounded-full bg-card text-xs font-bold">
|
||||
{letter}
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold flex-1">{name}</h3>
|
||||
{recommended && (
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-2xs font-bold text-accent-fg">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">{children}</div>
|
||||
<p className="mt-auto border-t border-border/60 pt-2 text-xs text-muted">{why}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** A small caption above a sample inside a variant. */
|
||||
export function Sample({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-2xs uppercase tracking-wider text-muted">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Measured contrast ───────────────────────────────────────────────────────────────────────────
|
||||
// A variant that changes a colour prints its contrast against the page background, computed from the
|
||||
// live CSS — so switching the theme or the scheme in the lab's bar re-measures it. The theme lives on
|
||||
// <html>'s attributes, so that is what the store subscribes to.
|
||||
|
||||
function subscribeTheme(onChange: () => void) {
|
||||
const mo = new MutationObserver(onChange);
|
||||
mo.observe(document.documentElement, { attributes: true });
|
||||
return () => mo.disconnect();
|
||||
}
|
||||
|
||||
let probe: HTMLSpanElement | null = null;
|
||||
/** Resolve any CSS colour expression (vars, color-mix) to [r, g, b, a] in 0..1 via a hidden probe. */
|
||||
function resolve(expr: string): [number, number, number, number] {
|
||||
if (!probe) {
|
||||
probe = document.createElement("span");
|
||||
probe.style.display = "none";
|
||||
document.body.append(probe);
|
||||
}
|
||||
probe.style.color = "";
|
||||
probe.style.color = expr;
|
||||
const c = getComputedStyle(probe).color;
|
||||
const [r = 0, g = 0, b = 0, a = 1] = (c.match(/[\d.]+/g) ?? []).map(Number);
|
||||
// `rgb()` is 0..255; a color-mix() result computes to `color(srgb …)`, already 0..1.
|
||||
return c.startsWith("color(") ? [r, g, b, a] : [r / 255, g / 255, b / 255, a];
|
||||
}
|
||||
|
||||
const lin = (v: number) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
||||
const lum = (r: number, g: number, b: number) =>
|
||||
0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
|
||||
function ratio(fg: string, bg: string): number {
|
||||
const [br, bg_, bb] = resolve(bg);
|
||||
const [fr, fg_, fb, fa] = resolve(fg);
|
||||
// Composite a translucent foreground over the background, as the eye sees it.
|
||||
const over = (f: number, b: number) => f * fa + b * (1 - fa);
|
||||
const l1 = lum(over(fr, br), over(fg_, bg_), over(fb, bb));
|
||||
const l2 = lum(br, bg_, bb);
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
/** "3.42:1 ✓" against `min` — re-measured whenever the lab switches theme or scheme. */
|
||||
export function Contrast({
|
||||
fg,
|
||||
bg = "var(--bg)",
|
||||
min,
|
||||
label,
|
||||
}: {
|
||||
fg: string;
|
||||
bg?: string;
|
||||
min: number;
|
||||
label: string;
|
||||
}) {
|
||||
const value = useSyncExternalStore(subscribeTheme, () => ratio(fg, bg).toFixed(2));
|
||||
const ok = Number(value) >= min;
|
||||
return (
|
||||
<span className={clsx("text-2xs tabular-nums", ok ? "text-success" : "text-danger")}>
|
||||
{label}: {value}:1 {ok ? "✓" : `✗ (needs ${min}:1)`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -583,7 +583,7 @@ body {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ===== E10 S1c · the minimum-target half of the 1024x768 comfort contract =====
|
||||
/* ===== E10 S1c · the minimum-target half of the tablet comfort contract (docs/DESIGN.md) =====
|
||||
A 32px icon button is fine with a mouse (a precise pointer plus hover to confirm the aim) and
|
||||
not fine with a thumb. So the minimum grows ONLY where there is no precise pointer, which on
|
||||
this app's real second device — a tablet — is exactly where it is needed.
|
||||
|
||||
+12
-2
@@ -38,6 +38,10 @@ const App = lazyWithPreload(() => import("./App"));
|
||||
const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy"));
|
||||
const Terms = lazy(() => import("./components/legal/Terms"));
|
||||
const WatchPage = lazy(() => import("./components/WatchPage"));
|
||||
// The design lab (E28): the living style guide, rendered with the app's real CSS so a design decision
|
||||
// is taken on what will ship. DEV only — Vite folds the condition to `false` in a production build,
|
||||
// the import becomes dead code and the chunk is never emitted (devOnlyGuard fails the build if it is).
|
||||
const DesignLab = import.meta.env.DEV ? lazy(() => import("./design-lab/DesignLab")) : null;
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
@@ -49,13 +53,19 @@ de.theme ||= "dark";
|
||||
de.scheme ||= "midnight";
|
||||
|
||||
const path = window.location.pathname;
|
||||
const isPublicPage = path === "/privacy" || path === "/terms" || path.startsWith("/watch/");
|
||||
const isLab = DesignLab !== null && path === "/design-lab";
|
||||
const isPublicPage =
|
||||
path === "/privacy" || path === "/terms" || path.startsWith("/watch/") || isLab;
|
||||
// Fetch the app chunk NOW, alongside BootGate's identity probe, instead of only once the gate opens:
|
||||
// the two waits then overlap rather than queue, and the gate usually opens straight onto the app. A
|
||||
// failed fetch is left to the render's own load, which retries it and reports to the ErrorBoundary.
|
||||
if (!isPublicPage) App.preload().catch(() => {});
|
||||
const root =
|
||||
path === "/privacy" ? (
|
||||
isLab && DesignLab ? (
|
||||
<FloatingTree>
|
||||
<DesignLab />
|
||||
</FloatingTree>
|
||||
) : path === "/privacy" ? (
|
||||
<PrivacyPolicy />
|
||||
) : path === "/terms" ? (
|
||||
<Terms />
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts",
|
||||
"reactPlugin.ts",
|
||||
"devOnlyGuard.ts",
|
||||
"tailwind.config.js",
|
||||
"postcss.config.js"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { reactWithCompiler } from "./reactPlugin";
|
||||
import { devOnlyGuard } from "./devOnlyGuard";
|
||||
|
||||
// During `vite dev` (host with Node), proxy API calls to the backend container.
|
||||
// Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the
|
||||
@@ -20,7 +21,7 @@ const proxy = {
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [reactWithCompiler()],
|
||||
plugins: [reactWithCompiler(), devOnlyGuard()],
|
||||
server: { port: 5173, proxy },
|
||||
build: { outDir: "dist" },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user