Files
siftlode/frontend/src/design-lab/batchB.tsx
T
peter d3e1d8219c docs(design): Fjord stays a fifth scheme beside Midnight
The trial is over: the owner kept it. The trial wording goes from the
tokens, the scheme list, the lab and DESIGN; B2's own-hue rule still
binds any new scheme.
2026-09-27 06:46:52 +02:00

549 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import clsx from "clsx";
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import {
BarChart3,
Bell,
Check,
Clapperboard,
Download,
Home,
ListVideo,
Lock,
MessageSquare,
PlayCircle,
Repeat,
Settings,
SlidersHorizontal,
Tv,
Users,
Youtube,
} from "lucide-react";
import { Btn } from "./batchA";
import { hexContrast, hueGap } from "../lib/colorMath";
import { Reference, Sample } from "./labKit";
import { FADE_ABOVE_PANEL, FADE_BELOW, FADE_BELOW_PANEL, LAYER } from "./labTokens";
import { Chip } from "../components/ui/Chip";
import { SURFACE } from "../components/ui/surfaces";
import { SelectTrigger } from "../components/ui/SelectTrigger";
import { SkeletonShapes } from "../components/ui/Skeleton";
import { Button } from "../components/ui/Button";
// 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";
// ── B2 · the scheme set ──────────────────────────────────────────────────────────────────────────
// 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;
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: "#b91c1c", dangerFg: "#ffffff", warn: "#a14a07", success: "#047857" },
};
const pal = (
bg: string,
surface: string,
border: string,
fg: string,
muted: string,
accent: string,
accentFg: string,
): Pal => ({ bg, surface, border, fg, muted, accent, accentFg });
// A literal copy of index.css's scheme tokens, so the tiles render side by side (the live CSS
// switches only one scheme at a time). index.css is the source; theme.contract.test.ts holds it.
const SCHEMES: SchemeDef[] = [
{
name: "Midnight · blue",
dark: pal("#0b1020", "#121a2e", "#243049", "#e6e9f0", "#97a3c0", "#6d8cff", "#0b1020"),
light: pal("#f4f6fc", "#ffffff", "#dde3f0", "#1a2238", "#5b6685", "#3b5bdb", "#ffffff"),
},
{
name: "Forest · cyan",
dark: pal("#0a1512", "#0f1f1a", "#1f3a30", "#e6f0ec", "#90b1a4", "#22d3ee", "#04110d"),
light: pal("#f1f7f4", "#ffffff", "#d6e7e0", "#102a22", "#4d6b60", "#0e7490", "#ffffff"),
},
{
name: "Slate · fuchsia",
dark: pal("#15171c", "#1b1e25", "#2c313b", "#e7e9ee", "#9aa1ad", "#e879f9", "#1a0620"),
light: pal("#f6f7f9", "#ffffff", "#e3e6eb", "#1c1f26", "#5d636e", "#a21caf", "#ffffff"),
},
{
name: "Matrix · moss",
dark: pal("#0b0e0b", "#111510", "#2a3226", "#d9ded6", "#8f9a8a", "#86a05a", "#0c1208"),
light: pal("#f4f5f0", "#ffffff", "#dfe3d8", "#161a14", "#5a6352", "#556b2f", "#ffffff"),
},
{
name: "Fjord · steel",
dark: pal("#0d1225", "#121931", "#25506e", "#eef1f5", "#9fb0c4", "#5aa1cb", "#0d1225"),
light: pal("#f3f6fa", "#ffffff", "#c9d6e3", "#152232", "#4f6278", "#256b8f", "#ffffff"),
},
];
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.surface,
"--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 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 (
<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="state-filled rounded-lg px-2.5 py-1 text-xs font-medium">Save</span>
<span className="state-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 bg-warn/6 px-2 py-0.5 text-warn">Retrying</span>
<span className="rounded-full bg-success/6 px-2 py-0.5 text-success">Done</span>
</div>
<span className="flex flex-col rounded bg-surface px-1.5 py-1 text-2xs">
<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>
</div>
);
}
// ── B4 · the page-chrome band ────────────────────────────────────────────────────────────────────
function Frame({ h, label, children }: { h: string; label: string; children: ReactNode }) {
return (
<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={`${SURFACE.card} flex flex-col gap-1 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),
);
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 && (
<SelectTrigger
context="toolbar"
open={false}
icon={<SlidersHorizontal className="size-3.5 shrink-0 text-muted" />}
>
Filters · 3
</SelectTrigger>
)}
</div>
{!collapsed && (
<div className="flex flex-wrap gap-1.5">
{[
["State", "Unwatched"],
["Date", "Any"],
["Sort", "Newest"],
].map(([k, v]) => (
<SelectTrigger key={k} context="toolbar" open={false} label={k}>
{v}
</SelectTrigger>
))}
</div>
)}
</div>
);
}
// ── B5 · surface per role ────────────────────────────────────────────────────────────────────────
function Surfaces() {
return (
<div className="flex flex-col gap-3 rounded-xl bg-linear-to-br from-accent/35 via-card to-bg p-3">
<div className={`${SURFACE.panel} flex flex-col gap-2 p-3`}>
<span className="text-sm font-semibold">Dialog · .glass r16</span>
<div className="grid grid-cols-2 gap-2">
<div className={`${SURFACE.card} p-2 text-xs`}>Card · r12</div>
<div className={`${SURFACE.card} p-2 text-xs`}>Picker · r12</div>
</div>
</div>
<div className="flex items-start gap-3">
<div className={`${SURFACE.menu} flex w-44 flex-col 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={`${SURFACE.tooltip} px-2 py-1 text-2xs`}>Tooltip · .glass-menu r8</span>
</div>
</div>
);
}
// ── 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&apos;s own video controls
</div>
<div className={`${SURFACE.panel} flex flex-wrap items-start gap-2 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">
<Chip size="md" pressed icon={<Repeat className="size-3.5" />} className="font-medium">
Repeat
</Chip>
<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={`${SURFACE.panel} flex w-15 flex-col items-center gap-0.5 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 ─────────────────────────────────────────────────────────────────────────────────
function Loading() {
return (
<>
{(["grid", "poster", "list", "table"] as const).map((shape) => (
<Sample key={shape} label={`First load, ${shape}: its skeleton (after ~300ms)`}>
<div className="max-w-xl">
<SkeletonShapes shape={shape} />
</div>
</Sample>
))}
<Sample label="A control's own work: its spinner">
<Button loading>Saving</Button>
</Sample>
</>
);
}
// ── B9 · the fade edges (1–4 chosen) ─────────────────────────────────────────────────────────────
const LINES = Array.from({ length: 14 }, (_, i) => `Row ${i + 1} · Some content that scrolls`);
function ScrollBody() {
return (
<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={`${SURFACE.panel} relative flex h-44 flex-col overflow-hidden panel-scroll-scope`}
>
<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 panel-scroller">
<ScrollBody />
</div>
</div>
</Sample>
<Sample label="3 · A dialog's header and footer">
<div
className={`${SURFACE.panel} relative flex h-44 flex-col overflow-hidden panel-scroll-scope`}
>
<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 panel-scroller">
<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 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="Five schemes: four hue families — Midnight blue, Forest cyan, Slate fuchsia, Matrix moss — and Fjord, the one kept exception (Midnight's navy, Playnite's calm steel accent). 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."
>
<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."
>
<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."
>
<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>
);
}