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
|
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
|
## The gate
|
||||||
|
|
||||||
A change is done when all of this is green. CI runs the same checks server-side
|
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"],
|
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
|
||||||
rules: { "local/focus-safe-disabled": "off" },
|
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.
|
// 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.
|
// …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", () => {
|
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.
|
// out of it by accident. A link is text in a sentence and is excluded by design.
|
||||||
const { unmount } = render(<Button>Tap</Button>);
|
const { unmount } = render(<Button>Tap</Button>);
|
||||||
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
|
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"` —
|
// 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.
|
// a pairing everyone would eventually forget, which is how a "link" grows a button's box.
|
||||||
const resolvedSize: ButtonSize = size ?? (variant === "link" ? "none" : "md");
|
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
|
// 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
|
// 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.
|
// 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 size contract (E10 S1c)
|
||||||
|
|
||||||
The app optimises for **1024x768 (tablet landscape) as the floor**; below it we owe an honest
|
The app supports **tablet portrait and landscape and every size above them** (from 768px wide; phones
|
||||||
message, above it correct rendering all the way up. That contract lives in these components, not in
|
are not a target — see [`docs/DESIGN.md`](../../../../docs/DESIGN.md) § Device adaptation). Measure a UI
|
||||||
the ~160 call sites, because a rule repeated in 160 places is a rule that is already broken
|
change at 1024x768, 820x1180 and a desktop size. That contract lives in these components, not in the
|
||||||
somewhere:
|
~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
|
- **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.
|
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;
|
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
|
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
|
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.
|
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 PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy"));
|
||||||
const Terms = lazy(() => import("./components/legal/Terms"));
|
const Terms = lazy(() => import("./components/legal/Terms"));
|
||||||
const WatchPage = lazy(() => import("./components/WatchPage"));
|
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();
|
const queryClient = createQueryClient();
|
||||||
|
|
||||||
@@ -49,13 +53,19 @@ de.theme ||= "dark";
|
|||||||
de.scheme ||= "midnight";
|
de.scheme ||= "midnight";
|
||||||
|
|
||||||
const path = window.location.pathname;
|
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:
|
// 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
|
// 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.
|
// failed fetch is left to the render's own load, which retries it and reports to the ErrorBoundary.
|
||||||
if (!isPublicPage) App.preload().catch(() => {});
|
if (!isPublicPage) App.preload().catch(() => {});
|
||||||
const root =
|
const root =
|
||||||
path === "/privacy" ? (
|
isLab && DesignLab ? (
|
||||||
|
<FloatingTree>
|
||||||
|
<DesignLab />
|
||||||
|
</FloatingTree>
|
||||||
|
) : path === "/privacy" ? (
|
||||||
<PrivacyPolicy />
|
<PrivacyPolicy />
|
||||||
) : path === "/terms" ? (
|
) : path === "/terms" ? (
|
||||||
<Terms />
|
<Terms />
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
"vitest.config.ts",
|
"vitest.config.ts",
|
||||||
"reactPlugin.ts",
|
"reactPlugin.ts",
|
||||||
|
"devOnlyGuard.ts",
|
||||||
"tailwind.config.js",
|
"tailwind.config.js",
|
||||||
"postcss.config.js"
|
"postcss.config.js"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { reactWithCompiler } from "./reactPlugin";
|
import { reactWithCompiler } from "./reactPlugin";
|
||||||
|
import { devOnlyGuard } from "./devOnlyGuard";
|
||||||
|
|
||||||
// During `vite dev` (host with Node), proxy API calls to the backend container.
|
// 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
|
// 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({
|
export default defineConfig({
|
||||||
plugins: [reactWithCompiler()],
|
plugins: [reactWithCompiler(), devOnlyGuard()],
|
||||||
server: { port: 5173, proxy },
|
server: { port: 5173, proxy },
|
||||||
build: { outDir: "dist" },
|
build: { outDir: "dist" },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user