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:
2026-09-24 01:34:12 +02:00
parent 58878958d8
commit d2e40dcffb
14 changed files with 1087 additions and 10 deletions
+177
View File
@@ -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 |
+4
View File
@@ -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
+29
View File
@@ -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`.",
);
}
}
},
};
}
+7
View File
@@ -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");
+1 -1
View File
@@ -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.
+4 -4
View File
@@ -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.
+126
View File
@@ -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&apos;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>
);
}
+572
View File
@@ -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>
);
}
+150
View File
@@ -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>
);
}
+1 -1
View File
@@ -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
View File
@@ -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 />
+1
View File
@@ -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"
] ]
+2 -1
View File
@@ -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" },
}); });