feat(design): batch A recorded, batch B in the design lab (E28 S2b)
DESIGN.md: the Controls chapter decided (A1-A6) with the decision-log rows; density stays open as B1. The lab keeps only the chosen A renderings as reference and adds batch B: density, the scheme set (measured accent hue gaps and light contrast), state layers, the page-chrome model, surface per role, the public watch page, the nav rail, the loading form and where the header + fade edge goes.
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
import clsx from "clsx";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { hexContrast, hueGap } from "./labColor";
|
||||
import { Contrast, Decision, Sample, Variant } from "./labKit";
|
||||
|
||||
// E28 S2 · Batch B, part 1 — the look of the controls themselves: density (A1's open sub-point),
|
||||
// the scheme set, and the hover / pressed state layers. Static markup on the app's real CSS.
|
||||
|
||||
const ico = "w-4 h-4";
|
||||
const STRONG = "color-mix(in srgb, var(--fg) 55%, var(--bg))";
|
||||
|
||||
// ── B1 · density ─────────────────────────────────────────────────────────────────────────────────
|
||||
// One ladder per variant. "Compact" sets explicit heights so a toolbar row lines up: the sm button,
|
||||
// the search pill, the dropdown trigger and the small icon button are all the same 28px.
|
||||
type Ladder = {
|
||||
md: string;
|
||||
sm: string;
|
||||
xs: string;
|
||||
iconSm: string;
|
||||
icon: string;
|
||||
field: string;
|
||||
bar: string;
|
||||
};
|
||||
const STANDARD: Ladder = {
|
||||
md: "px-3 py-2 text-sm gap-1.5",
|
||||
sm: "px-3 py-1.5 text-sm gap-1.5",
|
||||
xs: "px-2 py-1 text-xs gap-1",
|
||||
iconSm: "w-8 h-8",
|
||||
icon: "w-9 h-9",
|
||||
field: "h-9.5",
|
||||
bar: "h-9",
|
||||
};
|
||||
const COMPACT: Ladder = {
|
||||
md: "h-8 px-3 text-sm gap-1.5",
|
||||
sm: "h-7 px-2.5 text-sm gap-1.5",
|
||||
xs: "h-6 px-2 text-xs gap-1",
|
||||
iconSm: "w-7 h-7",
|
||||
icon: "w-8 h-8",
|
||||
field: "h-8",
|
||||
bar: "h-7",
|
||||
};
|
||||
|
||||
function LBtn({
|
||||
cls,
|
||||
tone,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
cls: string;
|
||||
tone: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("inline-flex items-center justify-center rounded-lg font-medium", tone, cls)}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const GHOST = "text-muted hover:text-fg hover:bg-card";
|
||||
const OUTLINE = "border border-border text-muted hover:text-accent hover:border-accent";
|
||||
const GLASS = "glass-card glass-hover hover:text-accent";
|
||||
|
||||
function IconBtn({ box, children }: { box: string; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
"grid place-items-center rounded-lg text-muted hover:bg-card hover:text-fg",
|
||||
box,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DensitySet({ l }: { l: Ladder }) {
|
||||
return (
|
||||
<>
|
||||
<Sample label="Toolbar · search, dropdown, sm button, icon">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label
|
||||
className={clsx("flex w-44 items-center gap-2 rounded-full border px-3", l.bar)}
|
||||
style={{ borderColor: STRONG }}
|
||||
>
|
||||
<Search className="h-4 w-4 text-muted" />
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none"
|
||||
placeholder="Search"
|
||||
/>
|
||||
</label>
|
||||
<span
|
||||
className={clsx(
|
||||
"glass-card inline-flex items-center gap-1.5 rounded-full border px-3 text-sm",
|
||||
l.bar,
|
||||
)}
|
||||
>
|
||||
<span className="text-muted">Sort:</span> Newest
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted" />
|
||||
</span>
|
||||
<LBtn cls={l.sm} tone={GLASS} icon={<RefreshCw className={ico} />}>
|
||||
Sync now
|
||||
</LBtn>
|
||||
<IconBtn box={l.iconSm}>
|
||||
<MoreHorizontal className={ico} />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="Dialog · a form field and the footer (md)">
|
||||
<div className="flex flex-col gap-3 rounded-xl glass p-3">
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium">Playlist name</span>
|
||||
<input
|
||||
className={clsx(
|
||||
"rounded-lg border bg-transparent px-3 text-sm outline-none",
|
||||
l.field,
|
||||
)}
|
||||
style={{ borderColor: STRONG }}
|
||||
defaultValue="Evening mix"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<LBtn cls={l.md} tone={GHOST}>
|
||||
Cancel
|
||||
</LBtn>
|
||||
<LBtn cls={l.md} tone="btn-danger" icon={<Trash2 className={ico} />}>
|
||||
Delete
|
||||
</LBtn>
|
||||
<LBtn cls={l.md} tone="btn-primary">
|
||||
Save
|
||||
</LBtn>
|
||||
</div>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="Table row · xs and the small icon buttons">
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg bg-card/60 px-3 py-1 text-sm">
|
||||
<span className="truncate">Download failed · HTTP 403</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<LBtn cls={l.xs} tone={OUTLINE}>
|
||||
Retry
|
||||
</LBtn>
|
||||
<IconBtn box={l.iconSm}>
|
||||
<Pencil className={ico} />
|
||||
</IconBtn>
|
||||
<IconBtn box={l.iconSm}>
|
||||
<Trash2 className={ico} />
|
||||
</IconBtn>
|
||||
</span>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="On its own · md + the larger icon button">
|
||||
<div className="flex items-center gap-2">
|
||||
<LBtn cls={l.md} tone={GLASS} icon={<Download className={ico} />}>
|
||||
Download
|
||||
</LBtn>
|
||||
<IconBtn box={l.icon}>
|
||||
<MoreHorizontal className={ico} />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</Sample>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function B1() {
|
||||
return (
|
||||
<Decision
|
||||
id="b1"
|
||||
code="B1"
|
||||
title="Button density (A1's open sub-point)"
|
||||
question="How much room is around a button's label on a mouse? Touch is not affected: on a coarse pointer everything grows to 44 (A3)."
|
||||
now="md 38 (px-3 py-2), sm 32, xs 26; icon buttons 32 / 36. Already denser than Material 3 (40, 24px side padding) and Apple HIG (44). Heights come from padding, so a toolbar's controls end up 30 / 32 / 36 side by side."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="Compact: 32 / 28 / 24, icons 28 / 32"
|
||||
recommended
|
||||
why="Your read of the lab: the label sits in less air. The big industry paddings exist for aim (Fitts) and for fingers, and fingers are handled separately here. Explicit heights make one toolbar row line up at 28. Every target stays ≥ 24 (WCAG 2.5.8). Button's four rungs keep their names; only their numbers change."
|
||||
>
|
||||
<DensitySet l={COMPACT} />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Standard (today's numbers): 38 / 32 / 26, icons 32 / 36"
|
||||
why="Closer to the platform guidelines and a little easier to hit, at the cost of about a fifth more toolbar and footer height. The toolbar's controls stay at three different heights."
|
||||
>
|
||||
<DensitySet l={STANDARD} />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── B2 · the scheme set ──────────────────────────────────────────────────────────────────────────
|
||||
// A proposed scheme is rendered by scoping the tokens on a wrapper: every utility resolves var(--…)
|
||||
// at the use site (the `@theme inline` block in index.css), so bg-accent inside the tile paints the
|
||||
// tile's accent, not the page's. The numbers under a tile come from the hex values, not the probe.
|
||||
|
||||
type Pal = {
|
||||
bg: string;
|
||||
surface: string;
|
||||
card: string;
|
||||
border: string;
|
||||
fg: string;
|
||||
muted: string;
|
||||
accent: string;
|
||||
accentFg: string;
|
||||
};
|
||||
type Mode = "dark" | "light";
|
||||
type SchemeDef = { name: string; dark: Pal; light: Pal };
|
||||
|
||||
const STATUS: Record<Mode, Record<"danger" | "dangerFg" | "warn" | "success", string>> = {
|
||||
dark: { danger: "#f87171", dangerFg: "#2a0a0a", warn: "#fbbf24", success: "#34d399" },
|
||||
light: { danger: "#dc2626", dangerFg: "#ffffff", warn: "#b45309", success: "#047857" },
|
||||
};
|
||||
|
||||
const pal = (
|
||||
bg: string,
|
||||
surface: string,
|
||||
card: string,
|
||||
border: string,
|
||||
fg: string,
|
||||
muted: string,
|
||||
accent: string,
|
||||
accentFg: string,
|
||||
): Pal => ({ bg, surface, card, border, fg, muted, accent, accentFg });
|
||||
|
||||
// Today's six, copied from index.css (the scheme blocks).
|
||||
const N = {
|
||||
midnight: {
|
||||
dark: ["#0b1020", "#121a2e", "#161f38", "#243049", "#e6e9f0", "#97a3c0"],
|
||||
light: ["#f4f6fc", "#ffffff", "#ffffff", "#dde3f0", "#1a2238", "#5b6685"],
|
||||
},
|
||||
forest: {
|
||||
dark: ["#0a1512", "#0f1f1a", "#12241e", "#1f3a30", "#e6f0ec", "#90b1a4"],
|
||||
light: ["#f1f7f4", "#ffffff", "#ffffff", "#d6e7e0", "#102a22", "#4d6b60"],
|
||||
},
|
||||
slate: {
|
||||
dark: ["#15171c", "#1b1e25", "#1f232b", "#2c313b", "#e7e9ee", "#9aa1ad"],
|
||||
light: ["#f6f7f9", "#ffffff", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e"],
|
||||
},
|
||||
youtube: {
|
||||
dark: ["#0f0f0f", "#181818", "#1f1f1f", "#303030", "#f1f1f1", "#aaaaaa"],
|
||||
light: ["#ffffff", "#f9f9f9", "#ffffff", "#e5e5e5", "#0f0f0f", "#606060"],
|
||||
},
|
||||
starship: {
|
||||
dark: ["#050b16", "#0b1728", "#101f36", "#1f3557", "#dfeaff", "#8aa6cc"],
|
||||
light: ["#eef4fb", "#ffffff", "#ffffff", "#d3e0f0", "#0b1b30", "#4a5f7d"],
|
||||
},
|
||||
matrix: {
|
||||
dark: ["#030a06", "#08130d", "#0b1c12", "#163d27", "#cfe8d6", "#6fae86"],
|
||||
light: ["#f0f7f1", "#ffffff", "#ffffff", "#cfe6d6", "#08130b", "#4c6b56"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
function scheme(
|
||||
name: string,
|
||||
id: keyof typeof N,
|
||||
dark: [string, string],
|
||||
light: [string, string],
|
||||
): SchemeDef {
|
||||
const [db, ds, dc, dbo, df, dm] = N[id].dark;
|
||||
const [lb, ls, lc, lbo, lf, lm] = N[id].light;
|
||||
return {
|
||||
name,
|
||||
dark: pal(db, ds, dc, dbo, df, dm, dark[0], dark[1]),
|
||||
light: pal(lb, ls, lc, lbo, lf, lm, light[0], light[1]),
|
||||
};
|
||||
}
|
||||
|
||||
const TODAY: SchemeDef[] = [
|
||||
scheme("Midnight", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]),
|
||||
scheme("Forest", "forest", ["#2dd4bf", "#04110d"], ["#0d9488", "#ffffff"]),
|
||||
scheme("Slate", "slate", ["#e8913c", "#1a1206"], ["#d97706", "#ffffff"]),
|
||||
scheme("YouTube", "youtube", ["#ff3b46", "#ffffff"], ["#ff0033", "#ffffff"]),
|
||||
scheme("Starship", "starship", ["#38b6ff", "#04101f"], ["#0b6bd6", "#ffffff"]),
|
||||
scheme("Matrix", "matrix", ["#46b87c", "#04170c"], ["#12a150", "#ffffff"]),
|
||||
];
|
||||
|
||||
// The proposal: four schemes, four hue families far from each other and from red / amber / green.
|
||||
// Only the accents move; every background and text colour stays as it is.
|
||||
const PROPOSED: SchemeDef[] = [
|
||||
scheme("Midnight · blue", "midnight", ["#6d8cff", "#0b1020"], ["#3b5bdb", "#ffffff"]),
|
||||
scheme("Forest · cyan", "forest", ["#22d3ee", "#04110d"], ["#0e7490", "#ffffff"]),
|
||||
scheme("Slate · fuchsia", "slate", ["#e879f9", "#1a0620"], ["#a21caf", "#ffffff"]),
|
||||
scheme("Matrix · lime", "matrix", ["#a3e635", "#04170c"], ["#4d7c0f", "#ffffff"]),
|
||||
];
|
||||
|
||||
const MIN_GAP = 30;
|
||||
|
||||
function Metric({ ok, children }: { ok: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<span className={clsx("tabular-nums", ok ? "text-success" : "text-danger")}>{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
|
||||
const p = s[mode];
|
||||
const st = STATUS[mode];
|
||||
const style = {
|
||||
"--bg": p.bg,
|
||||
"--surface": p.surface,
|
||||
"--card": p.card,
|
||||
"--border": p.border,
|
||||
"--fg": p.fg,
|
||||
"--muted": p.muted,
|
||||
"--accent": p.accent,
|
||||
"--accent-fg": p.accentFg,
|
||||
"--danger": st.danger,
|
||||
"--danger-fg": st.dangerFg,
|
||||
"--warn": st.warn,
|
||||
"--success": st.success,
|
||||
} as CSSProperties;
|
||||
const gaps = (["danger", "warn", "success"] as const).map((k) => ({
|
||||
k,
|
||||
g: Math.round(hueGap(p.accent, st[k])),
|
||||
}));
|
||||
const worst = gaps.reduce((a, b) => (b.g < a.g ? b : a));
|
||||
const text = hexContrast(p.accent, p.bg);
|
||||
const onFill = hexContrast(p.accentFg, p.accent);
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className="flex flex-col gap-2 rounded-lg border border-border bg-bg p-2.5 text-fg"
|
||||
>
|
||||
<span className="text-xs font-semibold">
|
||||
{s.name} <span className="font-normal text-muted">· {mode}</span>
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="btn-primary rounded-lg px-2.5 py-1 text-xs font-medium">Save</span>
|
||||
<span className="btn-danger rounded-lg px-2.5 py-1 text-xs font-medium">Delete</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 text-2xs">
|
||||
<span className="chip px-2 py-0.5">Filter</span>
|
||||
<span className="rounded-full border border-warn/40 px-2 py-0.5 text-warn">Retrying</span>
|
||||
<span className="rounded-full border border-success/40 px-2 py-0.5 text-success">Done</span>
|
||||
</div>
|
||||
<span className="text-xs">
|
||||
Text with a <span className="font-medium text-accent underline">link</span> in it.
|
||||
</span>
|
||||
{/* Measured from the hex values; drawn in the tile's own status colours. */}
|
||||
<span className="flex flex-col rounded bg-surface px-1.5 py-1 text-2xs">
|
||||
<Metric ok={worst.g >= MIN_GAP}>{`closest status: ${worst.k} ${worst.g}°`}</Metric>
|
||||
<Metric ok={text >= 4.5}>{`accent as text ${text.toFixed(2)}:1`}</Metric>
|
||||
<Metric ok={onFill >= 4.5}>{`button text ${onFill.toFixed(2)}:1`}</Metric>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SchemeGrid({ set }: { set: SchemeDef[] }) {
|
||||
return (
|
||||
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]">
|
||||
{set.flatMap((s) =>
|
||||
(["dark", "light"] as const).map((m) => <SchemeTile key={s.name + m} s={s} mode={m} />),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function B2() {
|
||||
return (
|
||||
<Decision
|
||||
id="b2"
|
||||
code="B2"
|
||||
title="The scheme set (and the light-theme accent)"
|
||||
question="Which colour schemes stay? Rule proposed with them: a scheme's accent is at least 30° of hue away from danger (red), warn (amber) and success (green), and passes 4.5:1 as text and under white/dark button text, in dark AND light."
|
||||
now="Six schemes. YouTube's accent is danger's red (2–4°), Slate's is warn's amber (9–23°), Forest and Matrix are success's green (6–19°), and Midnight / Starship are both blue. Four of six fail as text in light. Measured below, per tile."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="Four schemes, four hue families"
|
||||
recommended
|
||||
why="Each scheme earns its place with its own hue: blue, cyan, fuchsia, lime, all ≥ 30° from every status colour and from each other, and all pass in light. Only the accent colour moves; backgrounds stay. YouTube can't be fixed (red is its identity) and Starship is Midnight twice, so both go; a user on them lands on Midnight. A violet Starship was measured too: 24° from Midnight's periwinkle, too close."
|
||||
>
|
||||
<SchemeGrid set={PROPOSED} />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Today's six (reference)"
|
||||
why="Red marks each failure. Dropping YouTube and Starship alone would still leave Slate, Forest and Matrix colliding and three light themes failing."
|
||||
>
|
||||
<SchemeGrid set={TODAY} />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
|
||||
// ── B3 · state layers ────────────────────────────────────────────────────────────────────────────
|
||||
// A state is a layer of a colour mixed over whatever the control sits on, so it works on the page,
|
||||
// on a card and on glass alike. `bg-card` as a hover could not: on a white light-theme card it is the
|
||||
// card's own colour. Each control is shown at rest, hovered and pressed, then once live.
|
||||
|
||||
type Layers = {
|
||||
name: string;
|
||||
/** static hover / pressed classes for the ghost & row sample */
|
||||
gh: string;
|
||||
gp: string;
|
||||
/** static hover / pressed classes for the filled sample */
|
||||
fh: string;
|
||||
fp: string;
|
||||
/** live classes */
|
||||
ghostLive: string;
|
||||
filledLive: string;
|
||||
/** colour expressions for the measured readout */
|
||||
ghostOnPage: string;
|
||||
rowOnCard: string;
|
||||
filledHover: string;
|
||||
};
|
||||
|
||||
const mix = (c: string, p: number, over: string) => `color-mix(in srgb, var(${c}) ${p}%, ${over})`;
|
||||
|
||||
const LAYERS: Record<"strong" | "material" | "today", Layers> = {
|
||||
strong: {
|
||||
name: "10% / 16%",
|
||||
gh: "text-fg bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]",
|
||||
gp: "text-fg bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]",
|
||||
fh: "bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] text-accent-fg",
|
||||
fp: "bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))] text-accent-fg",
|
||||
ghostLive:
|
||||
"text-muted hover:text-fg hover:bg-[color-mix(in_srgb,var(--fg)_10%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_16%,transparent)]",
|
||||
filledLive:
|
||||
"bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_24%,var(--accent))]",
|
||||
ghostOnPage: mix("--fg", 10, "var(--bg)"),
|
||||
rowOnCard: mix("--fg", 10, "var(--card)"),
|
||||
filledHover: mix("--accent-fg", 14, "var(--accent)"),
|
||||
},
|
||||
material: {
|
||||
name: "8% / 12% (Material 3)",
|
||||
gh: "text-fg bg-[color-mix(in_srgb,var(--fg)_8%,transparent)]",
|
||||
gp: "text-fg bg-[color-mix(in_srgb,var(--fg)_12%,transparent)]",
|
||||
fh: "bg-[color-mix(in_srgb,var(--accent-fg)_8%,var(--accent))] text-accent-fg",
|
||||
fp: "bg-[color-mix(in_srgb,var(--accent-fg)_12%,var(--accent))] text-accent-fg",
|
||||
ghostLive:
|
||||
"text-muted hover:text-fg hover:bg-[color-mix(in_srgb,var(--fg)_8%,transparent)] active:bg-[color-mix(in_srgb,var(--fg)_12%,transparent)]",
|
||||
filledLive:
|
||||
"bg-accent text-accent-fg hover:bg-[color-mix(in_srgb,var(--accent-fg)_8%,var(--accent))] active:bg-[color-mix(in_srgb,var(--accent-fg)_12%,var(--accent))]",
|
||||
ghostOnPage: mix("--fg", 8, "var(--bg)"),
|
||||
rowOnCard: mix("--fg", 8, "var(--card)"),
|
||||
filledHover: mix("--accent-fg", 8, "var(--accent)"),
|
||||
},
|
||||
today: {
|
||||
name: "today",
|
||||
gh: "text-fg bg-card",
|
||||
gp: "text-fg bg-card",
|
||||
fh: "bg-accent text-accent-fg opacity-90",
|
||||
fp: "bg-accent text-accent-fg opacity-90",
|
||||
ghostLive: "text-muted hover:text-fg hover:bg-card",
|
||||
filledLive: "btn-primary",
|
||||
ghostOnPage: "var(--card)",
|
||||
rowOnCard: "var(--card)",
|
||||
filledHover: "color-mix(in srgb, var(--accent) 90%, var(--bg))",
|
||||
},
|
||||
};
|
||||
|
||||
const cell = "inline-flex h-8 items-center justify-center rounded-lg px-3 text-sm font-medium";
|
||||
const HOVER_MIN = 1.15;
|
||||
|
||||
function StateRow({
|
||||
label,
|
||||
rest,
|
||||
hover,
|
||||
pressed,
|
||||
}: {
|
||||
label: string;
|
||||
rest: string;
|
||||
hover: string;
|
||||
pressed: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[5rem_repeat(3,1fr)] items-center gap-1.5 text-xs">
|
||||
<span className="text-muted">{label}</span>
|
||||
<span className={clsx(cell, rest)}>Rest</span>
|
||||
<span className={clsx(cell, hover)}>Hover</span>
|
||||
<span className={clsx(cell, pressed)}>Pressed</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayerSet({ l }: { l: Layers }) {
|
||||
return (
|
||||
<>
|
||||
<Sample label="Ghost button, on the page">
|
||||
<StateRow label="ghost" rest="text-muted" hover={l.gh} pressed={l.gp} />
|
||||
<Contrast fg={l.ghostOnPage} min={HOVER_MIN} label="hover vs rest" />
|
||||
</Sample>
|
||||
<Sample label="List row, on a card (menus, tables, the nav)">
|
||||
<div className="glass-card rounded-xl p-1.5">
|
||||
<StateRow label="row" rest="text-muted" hover={l.gh} pressed={l.gp} />
|
||||
</div>
|
||||
<Contrast fg={l.rowOnCard} bg="var(--card)" min={HOVER_MIN} label="hover vs rest" />
|
||||
</Sample>
|
||||
<Sample label="Filled (primary) button">
|
||||
<StateRow label="primary" rest="bg-accent text-accent-fg" hover={l.fh} pressed={l.fp} />
|
||||
<Contrast fg={l.filledHover} bg="var(--accent)" min={HOVER_MIN} label="hover vs rest" />
|
||||
</Sample>
|
||||
<Sample label="Live: hover and press these">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" className={clsx(cell, l.ghostLive)}>
|
||||
Ghost
|
||||
</button>
|
||||
<button type="button" className={clsx(cell, l.filledLive)}>
|
||||
Primary
|
||||
</button>
|
||||
</div>
|
||||
<div className="glass-card flex flex-col rounded-xl p-1">
|
||||
{["Watch later", "Add to playlist", "Copy link"].map((t) => (
|
||||
<button key={t} type="button" className={clsx(cell, "justify-start", l.ghostLive)}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Sample>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function B3() {
|
||||
return (
|
||||
<Decision
|
||||
id="b3"
|
||||
code="B3"
|
||||
title="State layers: hover and pressed"
|
||||
question="How strongly does a control answer the pointer? Switch the scheme and light / dark in the bar: the readouts re-measure (below 1.15:1 a change is hard to see; WCAG sets no number for hover)."
|
||||
now="Filled buttons dim to 90% opacity; ghost buttons and rows get bg-card, which is ~1.07:1 in light and exactly the card's own colour on a card (1.00:1, invisible). There is no pressed state."
|
||||
>
|
||||
<Variant
|
||||
letter="A"
|
||||
name="A text-coloured layer: 10% hover, 16% pressed"
|
||||
recommended
|
||||
why="The layer is the text colour mixed over whatever is underneath, so it darkens in light and lightens in dark, on the page, a card or glass alike. A filled button mixes its own text colour into the fill. One token pair for every control. Measured: ≥ 1.2:1 on the page and on cards in every scheme and mode; the filled hover is 1.26–1.32 with B2's proposed accents (only today's YouTube red stays faint, 1.06). The selected state (the solid accent) is unchanged."
|
||||
>
|
||||
<LayerSet l={LAYERS.strong} />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="The same layer at Material's 8% / 12%"
|
||||
why="Material 3's numbers. Calmer, but measured here it lands right at the edge of visible in light, the exact complaint."
|
||||
>
|
||||
<LayerSet l={LAYERS.material} />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="—"
|
||||
name="Today (reference)"
|
||||
why="The ghost-on-card row shows why the complaint exists."
|
||||
>
|
||||
<LayerSet l={LAYERS.today} />
|
||||
</Variant>
|
||||
</Decision>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user