feat(design): batch B recorded, batch C in the design lab (E28 S2c)
DESIGN.md: B1-B9 decided (compact density, the four-scheme set with the accent-vs-status rule, state layers, the sticky band, surface per role, the watch page, the slim NavRail with optional labels, loading by shape, the fade edges) with their decision-log rows. The lab keeps the chosen A and B renderings as reference on shared tokens (labTokens.ts) and adds batch C: the action-placement classification for review, side panels on touch and below 768, the Plex player on touch, and the dialog conventions.
This commit is contained in:
+441
-446
@@ -1,219 +1,51 @@
|
||||
import clsx from "clsx";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clapperboard,
|
||||
Download,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Home,
|
||||
ListVideo,
|
||||
Loader2,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
PlayCircle,
|
||||
Repeat,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Tv,
|
||||
Users,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { Btn } from "./batchA";
|
||||
import { hexContrast, hueGap } from "./labColor";
|
||||
import { Contrast, Decision, Sample, Variant } from "./labKit";
|
||||
import { Reference, Sample } from "./labKit";
|
||||
import {
|
||||
FADE_ABOVE_PANEL,
|
||||
FADE_BELOW,
|
||||
FADE_BELOW_PANEL,
|
||||
FADE_EDGES_X,
|
||||
FILLED,
|
||||
H,
|
||||
LAYER,
|
||||
} from "./labTokens";
|
||||
|
||||
// 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.
|
||||
// E28 S2 · Batch B — surfaces, chrome and colour, DECIDED (owner, 2026-09-24): every recommendation,
|
||||
// except B7, where the owner combined the soft tint + edge bar with optional labels. B1 (density) and
|
||||
// B3 (state layers) render inside batch A's references. Only the chosen rendering of each stays.
|
||||
|
||||
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.
|
||||
// A scheme that is not in index.css yet is rendered by scoping its tokens on a wrapper: every
|
||||
// utility resolves var(--…) at the use site (`@theme inline`), so the tile paints its own accent.
|
||||
|
||||
type Pal = {
|
||||
bg: string;
|
||||
surface: string;
|
||||
card: string;
|
||||
border: string;
|
||||
fg: string;
|
||||
muted: string;
|
||||
@@ -231,77 +63,37 @@ const STATUS: Record<Mode, Record<"danger" | "dangerFg" | "warn" | "success", st
|
||||
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 });
|
||||
): Pal => ({ bg, surface, 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"],
|
||||
// Backgrounds and text are today's (index.css); only the accents are new.
|
||||
const SCHEMES: SchemeDef[] = [
|
||||
{
|
||||
name: "Midnight · blue",
|
||||
dark: pal("#0b1020", "#121a2e", "#243049", "#e6e9f0", "#97a3c0", "#6d8cff", "#0b1020"),
|
||||
light: pal("#f4f6fc", "#ffffff", "#dde3f0", "#1a2238", "#5b6685", "#3b5bdb", "#ffffff"),
|
||||
},
|
||||
forest: {
|
||||
dark: ["#0a1512", "#0f1f1a", "#12241e", "#1f3a30", "#e6f0ec", "#90b1a4"],
|
||||
light: ["#f1f7f4", "#ffffff", "#ffffff", "#d6e7e0", "#102a22", "#4d6b60"],
|
||||
{
|
||||
name: "Forest · cyan",
|
||||
dark: pal("#0a1512", "#0f1f1a", "#1f3a30", "#e6f0ec", "#90b1a4", "#22d3ee", "#04110d"),
|
||||
light: pal("#f1f7f4", "#ffffff", "#d6e7e0", "#102a22", "#4d6b60", "#0e7490", "#ffffff"),
|
||||
},
|
||||
slate: {
|
||||
dark: ["#15171c", "#1b1e25", "#1f232b", "#2c313b", "#e7e9ee", "#9aa1ad"],
|
||||
light: ["#f6f7f9", "#ffffff", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e"],
|
||||
{
|
||||
name: "Slate · fuchsia",
|
||||
dark: pal("#15171c", "#1b1e25", "#2c313b", "#e7e9ee", "#9aa1ad", "#e879f9", "#1a0620"),
|
||||
light: pal("#f6f7f9", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e", "#a21caf", "#ffffff"),
|
||||
},
|
||||
youtube: {
|
||||
dark: ["#0f0f0f", "#181818", "#1f1f1f", "#303030", "#f1f1f1", "#aaaaaa"],
|
||||
light: ["#ffffff", "#f9f9f9", "#ffffff", "#e5e5e5", "#0f0f0f", "#606060"],
|
||||
{
|
||||
name: "Matrix · lime",
|
||||
dark: pal("#030a06", "#08130d", "#163d27", "#cfe8d6", "#6fae86", "#a3e635", "#04170c"),
|
||||
light: pal("#f0f7f1", "#ffffff", "#cfe6d6", "#08130b", "#4c6b56", "#4d7c0f", "#ffffff"),
|
||||
},
|
||||
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>
|
||||
@@ -314,7 +106,7 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
|
||||
const style = {
|
||||
"--bg": p.bg,
|
||||
"--surface": p.surface,
|
||||
"--card": p.card,
|
||||
"--card": p.surface,
|
||||
"--border": p.border,
|
||||
"--fg": p.fg,
|
||||
"--muted": p.muted,
|
||||
@@ -325,11 +117,9 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
|
||||
"--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 worst = (["danger", "warn", "success"] as const)
|
||||
.map((k) => ({ k, g: Math.round(hueGap(p.accent, st[k])) }))
|
||||
.reduce((a, b) => (b.g < a.g ? b : a));
|
||||
const text = hexContrast(p.accent, p.bg);
|
||||
const onFill = hexContrast(p.accentFg, p.accent);
|
||||
return (
|
||||
@@ -349,12 +139,8 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
|
||||
<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={worst.g >= 30}>{`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>
|
||||
@@ -362,204 +148,413 @@ function SchemeTile({ s, mode }: { s: SchemeDef; mode: Mode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SchemeGrid({ set }: { set: SchemeDef[] }) {
|
||||
// ── B4 · the page-chrome band ────────────────────────────────────────────────────────────────────
|
||||
function Frame({ h, label, children }: { h: string; label: string; children: ReactNode }) {
|
||||
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} />),
|
||||
<Sample label={label}>
|
||||
<div className={clsx("relative overflow-hidden rounded-xl border border-border bg-bg", h)}>
|
||||
<div className="h-full overflow-y-auto no-scrollbar">{children}</div>
|
||||
</div>
|
||||
</Sample>
|
||||
);
|
||||
}
|
||||
|
||||
function Cards() {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 p-3">
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<div key={i} className="glass-card flex flex-col gap-1 rounded-xl p-1.5">
|
||||
<div className="aspect-video rounded-lg bg-linear-to-br from-accent/40 to-card" />
|
||||
<span className="truncate text-2xs">Video title number {i + 1}</span>
|
||||
<span className="text-3xs text-muted">Channel · 2 days ago</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tab = (on: boolean) =>
|
||||
clsx(
|
||||
"rounded-lg px-2.5 py-1 text-xs",
|
||||
on ? "bg-accent text-accent-fg" : clsx("text-muted hover:text-fg", LAYER),
|
||||
);
|
||||
const pillCtl = clsx(
|
||||
"glass-card inline-flex items-center gap-1 rounded-full border px-2.5 text-xs",
|
||||
H.bar,
|
||||
);
|
||||
|
||||
function Tabs() {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<span className={tab(true)}>Queue</span>
|
||||
<span className={tab(false)}>Library</span>
|
||||
<span className={tab(false)}>History</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Band({ collapsed }: { collapsed?: boolean }) {
|
||||
return (
|
||||
<div className={clsx("sticky top-0 z-base flex flex-col gap-2 bg-bg px-3 py-2", FADE_BELOW)}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tabs />
|
||||
{collapsed && (
|
||||
<span className={pillCtl}>
|
||||
<SlidersHorizontal className="h-3 w-3" /> Filters · 3
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{["State: Unwatched", "Date: Any", "Sort: Newest"].map((f) => (
|
||||
<span key={f} className={pillCtl}>
|
||||
{f} <ChevronDown className="h-3 w-3 text-muted" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function B2() {
|
||||
// ── B5 · surface per role ────────────────────────────────────────────────────────────────────────
|
||||
function Surfaces() {
|
||||
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 className="flex flex-col gap-3 rounded-xl bg-linear-to-br from-accent/35 via-card to-bg p-3">
|
||||
<div className="glass flex flex-col gap-2 rounded-2xl p-3">
|
||||
<span className="text-sm font-semibold">Dialog · .glass r16</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="glass-card rounded-xl p-2 text-xs">Card · r12</div>
|
||||
<div className="glass-card rounded-xl p-2 text-xs">Picker · r12</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="glass-menu flex w-44 flex-col rounded-xl p-1 text-xs">
|
||||
{["Watch later", "Add to playlist", "Copy link"].map((t) => (
|
||||
<span key={t} className={clsx("flex h-7 items-center rounded-lg px-2", LAYER)}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="glass-menu rounded-lg px-2 py-1 text-2xs">Tooltip · .glass-menu r8</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayerSet({ l }: { l: Layers }) {
|
||||
// ── B6 · the public watch page ───────────────────────────────────────────────────────────────────
|
||||
function WatchPage() {
|
||||
return (
|
||||
<div className="flex max-w-xl flex-col gap-3 rounded-xl bg-linear-to-br from-accent/25 via-bg to-bg p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<PlayCircle className="h-4 w-4 text-accent" />
|
||||
<span className="font-semibold text-fg">Siftlode</span>
|
||||
</div>
|
||||
<div className="grid aspect-video place-items-center rounded-xl border border-border bg-black text-2xs text-white/60">
|
||||
the browser's own video controls
|
||||
</div>
|
||||
<div className="glass flex flex-wrap items-start gap-2 rounded-2xl p-3">
|
||||
<div className="min-w-0 flex-1 basis-40">
|
||||
<div className="text-sm font-semibold">Building a home lab on a budget</div>
|
||||
<div className="text-xs text-muted">Techno Tim</div>
|
||||
</div>
|
||||
<span className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("pill inline-flex items-center rounded-full border font-medium", H.md)}
|
||||
aria-pressed
|
||||
>
|
||||
<Repeat className="h-3.5 w-3.5" /> Repeat
|
||||
</button>
|
||||
<Btn tone="glass" size="md" icon={<Download className={ico} />}>
|
||||
Download
|
||||
</Btn>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── B7 · the nav rail (the owner's combination) ──────────────────────────────────────────────────
|
||||
// A soft tint + an edge bar for "you are here"; labels optional (a per-user Settings switch). Slim: the
|
||||
// rail is 3.75rem wide in BOTH modes (labels add height, never width), so each module needs a short
|
||||
// rail label that fits the item; the full name stays in the hover tooltip. Each rail reports whether
|
||||
// all its labels fit, measured live (it re-measures on the lab's text-size slider).
|
||||
|
||||
type NavDef = { icon: typeof Home; en: string; hu: string };
|
||||
const NAV: NavDef[] = [
|
||||
{ icon: Home, en: "Home", hu: "Kezdőlap" },
|
||||
{ icon: Youtube, en: "Feed", hu: "Feed" },
|
||||
{ icon: Tv, en: "Channels", hu: "Csatornák" },
|
||||
{ icon: ListVideo, en: "Playlists", hu: "Listák" },
|
||||
{ icon: Clapperboard, en: "Plex", hu: "Plex" },
|
||||
{ icon: Bell, en: "Alerts", hu: "Értesítés" },
|
||||
{ icon: MessageSquare, en: "Messages", hu: "Üzenetek" },
|
||||
{ icon: Download, en: "Downloads", hu: "Letöltések" },
|
||||
];
|
||||
const SYSTEM: NavDef[] = [
|
||||
{ icon: BarChart3, en: "Stats", hu: "Statisztika" },
|
||||
{ icon: Users, en: "Users", hu: "Fiókok" },
|
||||
{ icon: Settings, en: "Settings", hu: "Beállítások" },
|
||||
];
|
||||
|
||||
function RailItem({ d, on, lang }: { d: NavDef; on: boolean; lang?: "en" | "hu" }) {
|
||||
const I = d.icon;
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"relative flex w-full flex-col items-center justify-center gap-0.5 rounded-lg",
|
||||
lang ? "py-1.5" : "h-9",
|
||||
on ? "bg-accent/15 text-accent" : clsx("text-muted hover:text-fg", LAYER),
|
||||
)}
|
||||
>
|
||||
{on && (
|
||||
<span className="absolute -left-1 top-1.5 bottom-1.5 w-0.75 rounded-r-full bg-accent" />
|
||||
)}
|
||||
<I className="h-4.5 w-4.5" />
|
||||
{lang && (
|
||||
<span
|
||||
data-rail-label
|
||||
className="max-w-full overflow-hidden whitespace-nowrap text-3xs font-medium tracking-tight"
|
||||
>
|
||||
{d[lang]}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Rail({ lang }: { lang?: "en" | "hu" }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [clipped, setClipped] = useState<string[]>([]);
|
||||
useLayoutEffect(() => {
|
||||
const measure = () =>
|
||||
setClipped(
|
||||
[...(ref.current?.querySelectorAll<HTMLElement>("[data-rail-label]") ?? [])]
|
||||
.filter((e) => e.scrollWidth > e.clientWidth)
|
||||
.map((e) => e.textContent ?? ""),
|
||||
);
|
||||
measure();
|
||||
const mo = new MutationObserver(measure);
|
||||
mo.observe(document.documentElement, { attributes: true });
|
||||
return () => mo.disconnect();
|
||||
}, []);
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<div
|
||||
ref={ref}
|
||||
className="glass flex w-15 flex-col items-center gap-0.5 rounded-2xl px-1 py-2.5"
|
||||
>
|
||||
{NAV.map((d, i) => (
|
||||
<RailItem key={d.en} d={d} on={i === 1} lang={lang} />
|
||||
))}
|
||||
<span className="my-1.5 w-7 border-t-2 border-border" />
|
||||
{SYSTEM.map((d) => (
|
||||
<RailItem key={d.en} d={d} on={false} lang={lang} />
|
||||
))}
|
||||
</div>
|
||||
{lang && (
|
||||
<span className={clsx("text-2xs", clipped.length ? "text-danger" : "text-success")}>
|
||||
{clipped.length ? `clipped: ${clipped.join(", ")}` : "every label fits ✓"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── B8 · loading ─────────────────────────────────────────────────────────────────────────────────
|
||||
const bone = "animate-pulse rounded bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]";
|
||||
|
||||
function Loading() {
|
||||
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>
|
||||
<Sample label="A grid, first load: its skeleton (after ~300ms)">
|
||||
<div className="grid max-w-md grid-cols-3 gap-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="glass-card flex flex-col gap-1.5 rounded-xl p-1.5">
|
||||
<div className={clsx("aspect-video rounded-lg", bone)} />
|
||||
<div className={clsx("h-2 w-4/5", bone)} />
|
||||
<div className={clsx("h-2 w-1/2", bone)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="A control's own work: its spinner">
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex w-fit items-center rounded-lg font-medium opacity-80",
|
||||
FILLED,
|
||||
H.md,
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Saving
|
||||
</span>
|
||||
</Sample>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function B3() {
|
||||
// ── B9 · the fade edges (1–4 chosen) ─────────────────────────────────────────────────────────────
|
||||
const LINES = Array.from({ length: 14 }, (_, i) => `Row ${i + 1} · Some content that scrolls`);
|
||||
|
||||
function ScrollBody() {
|
||||
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."
|
||||
<div className="flex flex-col gap-1 px-3 pb-3 pt-2 text-xs">
|
||||
{LINES.map((l) => (
|
||||
<span key={l} className="rounded-md bg-card/60 px-2 py-1.5">
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CHIPS = ["Unwatched", "Shorts", "Live", "Music", "Tech", "Science", "News", "Gaming"];
|
||||
|
||||
function Fades() {
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(15rem,1fr))]">
|
||||
<Frame h="h-44" label="1 · Toolbars and table headers">
|
||||
<div
|
||||
className={clsx("sticky top-0 z-base bg-bg px-3 py-2 text-xs font-medium", FADE_BELOW)}
|
||||
>
|
||||
Title · Channel · Date
|
||||
</div>
|
||||
<ScrollBody />
|
||||
</Frame>
|
||||
<Sample label="2 · Tabs over a scrolling panel">
|
||||
<div className="glass relative flex h-44 flex-col overflow-hidden rounded-2xl">
|
||||
<div className={clsx("relative z-base px-3 pb-2 pt-3", FADE_BELOW_PANEL)}>
|
||||
<Tabs />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">
|
||||
<ScrollBody />
|
||||
</div>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="3 · A dialog's header and footer">
|
||||
<div className="glass relative flex h-44 flex-col overflow-hidden rounded-2xl">
|
||||
<div
|
||||
className={clsx(
|
||||
"relative z-base flex items-center gap-2 px-3 py-2 text-sm font-semibold",
|
||||
FADE_BELOW_PANEL,
|
||||
)}
|
||||
>
|
||||
<Lock className="h-4 w-4 text-accent" /> Edit details
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">
|
||||
<ScrollBody />
|
||||
</div>
|
||||
<div
|
||||
className={clsx("relative z-base flex justify-end gap-2 px-3 py-2", FADE_ABOVE_PANEL)}
|
||||
>
|
||||
<Btn tone="ghost" size="md">
|
||||
Cancel
|
||||
</Btn>
|
||||
<Btn tone="primary" size="md" icon={<Check className={ico} />}>
|
||||
Save
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</Sample>
|
||||
<Sample label="4 · An overflowing chip row">
|
||||
<div
|
||||
className="overflow-x-auto no-scrollbar"
|
||||
style={{ maskImage: FADE_EDGES_X, WebkitMaskImage: FADE_EDGES_X }}
|
||||
>
|
||||
<div className="flex w-max gap-1.5 px-6">
|
||||
{CHIPS.map((c) => (
|
||||
<span key={c} className="chip px-2.5 py-1 text-xs">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Sample>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BatchB() {
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]">
|
||||
<Reference
|
||||
id="b2"
|
||||
code="B2"
|
||||
title="The scheme set"
|
||||
rule="Four schemes, four hue families: Midnight blue, Forest cyan, Slate fuchsia, Matrix lime. YouTube and Starship are gone (a user on them lands on Midnight). Rule: an accent is ≥ 30° of OKLCH hue from danger, warn and success, and ≥ 4.5:1 as text and under its button text, dark and light."
|
||||
>
|
||||
<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."
|
||||
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]">
|
||||
{SCHEMES.flatMap((s) =>
|
||||
(["dark", "light"] as const).map((m) => <SchemeTile key={s.name + m} s={s} mode={m} />),
|
||||
)}
|
||||
</div>
|
||||
</Reference>
|
||||
<Reference
|
||||
id="b4"
|
||||
code="B4"
|
||||
title="Page chrome: one sticky band"
|
||||
rule="Every page's tabs and filters sit in one sticky band of the page colour, with the fade under it. Below ~600px of height the filter row folds into one 'Filters · n' button. Every page has a visually hidden h1."
|
||||
>
|
||||
<LayerSet l={LAYERS.material} />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="—"
|
||||
name="Today (reference)"
|
||||
why="The ghost-on-card row shows why the complaint exists."
|
||||
<Frame h="h-64" label="Normal height · scroll it">
|
||||
<Band />
|
||||
<Cards />
|
||||
</Frame>
|
||||
<Frame h="h-36" label="Short (keyboard up)">
|
||||
<Band collapsed />
|
||||
<Cards />
|
||||
</Frame>
|
||||
</Reference>
|
||||
<Reference
|
||||
id="b5"
|
||||
code="B5"
|
||||
title="Surface per role"
|
||||
rule="Dialog / panel .glass r16 · card and picker .glass-card r12 · menu .glass-menu r12, 4px padding, items r8 · tooltip .glass-menu r8. Translucency only from the tiers (app, Plex media, light), never per site."
|
||||
>
|
||||
<LayerSet l={LAYERS.today} />
|
||||
</Variant>
|
||||
</Decision>
|
||||
<Surfaces />
|
||||
</Reference>
|
||||
<Reference
|
||||
id="b6"
|
||||
code="B6"
|
||||
title="The public watch page"
|
||||
rule="The app's surfaces (glass, Button, the pressed-pill toggle, the scheme's ambient backdrop); the video keeps the browser's own controls."
|
||||
>
|
||||
<WatchPage />
|
||||
</Reference>
|
||||
<Reference
|
||||
id="b7"
|
||||
code="B7"
|
||||
title="The nav rail"
|
||||
rule="A NavRail primitive. 'You are here' = a soft accent tint + a 3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default). Slim: 3.75rem wide either way, labels add height only; each module gets a short rail label, the full name stays in the tooltip. 44 tall on touch (A3)."
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-6">
|
||||
<Sample label="Icons only">
|
||||
<Rail />
|
||||
</Sample>
|
||||
<Sample label="Labels · EN">
|
||||
<Rail lang="en" />
|
||||
</Sample>
|
||||
<Sample label="Labels · HU">
|
||||
<Rail lang="hu" />
|
||||
</Sample>
|
||||
</div>
|
||||
</Reference>
|
||||
<Reference
|
||||
id="b8"
|
||||
code="B8"
|
||||
title="Loading"
|
||||
rule="By shape: a Skeleton primitive for a first load of a grid, list or table; a spinner inside a control (the loading prop). Neither shows before ~300ms."
|
||||
>
|
||||
<Loading />
|
||||
</Reference>
|
||||
<Reference
|
||||
id="b9"
|
||||
code="B9"
|
||||
title="The header + fade edge"
|
||||
rule="On sticky toolbars and table headers, tabs over a scrolling panel, a long dialog's header and footer, and an overflowing chip row's clipped ends. Not on thumbnails, not on buttons."
|
||||
>
|
||||
<Fades />
|
||||
</Reference>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user