diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 00000000..fbb707f5 --- /dev/null +++ b/docs/DESIGN.md @@ -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 ` + ); +} + +const ico = "w-4 h-4"; + +// ── A1 · button shape and ladder ────────────────────────────────────────────────────────────────── +function ButtonSet({ radius }: { radius: Radius }) { + return ( + <> + +
+ }> + Sync now + + + Full history + +
+
+ +
+ + Cancel + + }> + Delete + + + Save + +
+
+ +
+ Download failed · HTTP 403 + + Retry + +
+
+ + ); +} + +export function A1() { + return ( + + + + + + + + + + + + ); +} + +// ── A2 · icon buttons ───────────────────────────────────────────────────────────────────────────── +function Icon({ box, radius, children }: { box: string; radius: string; children: ReactNode }) { + return ( + + ); +} + +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 ( +
+ + + + +
+ ); +} + +function IconSet({ row, bar, media }: { row: string; bar: string; media: string }) { + const r = row.includes("full") ? "rounded-full" : "rounded-lg"; + return ( + <> + +
+ Techno Tim + + + + + + + + + + + +
+
+ +
+ + + + + + +
+
+ + + + + ); +} + +export function A2() { + return ( + + + + + + + + + + + + ); +} + +// ── 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 ( +
+ +
+ + Mine + Library + + + Sort: Newest + + +
+
+ +
+ Queue + Library +
+
+ +
+ Dark mode + + + +
+
+ Feed +
+
+
+ ); +} + +export function A3() { + return ( + + + + + + + + + + + + ); +} + +// ── 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 ( + <> + + + + + + + + ); +} + +export function A4() { + return ( + + + + + + + + + + + + + + + ); +} + +// ── A5 · dropdown trigger ──────────────────────────────────────────────────────────────────────── +function Trigger({ cls, label, value }: { cls: string; label: string; value: string }) { + return ( + + {label}: {value} + + + ); +} + +function DropdownSet({ bar, form }: { bar: string; form: string }) { + return ( + <> + +
+ + + +
+
+ + + + + ); +} + +export function A5() { + return ( + + + + + + + + + + + + ); +} + +// ── A6 · checkbox, switch, slider ──────────────────────────────────────────────────────────────── +function NativeSet() { + return ( + <> + + + + + ); +} + +function CustomSet() { + return ( + <> +
+ + + + Only unwatched +
+
+ Hide Shorts + + + +
+
+ Text size + + + + +
+ + ); +} + +export function A6() { + return ( + + + + + + + + + ); +} diff --git a/frontend/src/design-lab/labKit.tsx b/frontend/src/design-lab/labKit.tsx new file mode 100644 index 00000000..473f96ec --- /dev/null +++ b/frontend/src/design-lab/labKit.tsx @@ -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 : two or +// three 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 ( +
+
+
+ {code} +

{title}

+
+

{question}

+

+ Today: + {now} +

+
+
{children}
+
+ ); +} + +export function Variant({ + letter, + name, + recommended = false, + why, + children, +}: { + letter: string; + name: string; + recommended?: boolean; + why: string; + children: ReactNode; +}) { + return ( +
+
+ + {letter} + +

{name}

+ {recommended && ( + + Recommended + + )} +
+
{children}
+

{why}

+
+ ); +} + +/** A small caption above a sample inside a variant. */ +export function Sample({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +// ── 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 +// '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 ( + + {label}: {value}:1 {ok ? "✓" : `✗ (needs ${min}:1)`} + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index fa6267a1..947f6e0b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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. diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2b50c27d..08db0e45 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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 ? ( + + + + ) : path === "/privacy" ? ( ) : path === "/terms" ? ( diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index 1c9ac7a5..3b6092e4 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -37,6 +37,7 @@ "vite.config.ts", "vitest.config.ts", "reactPlugin.ts", + "devOnlyGuard.ts", "tailwind.config.js", "postcss.config.js" ] diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b49c66a3..d7f1f568 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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" }, });