Files
siftlode/frontend/src/design-lab/DesignLab.tsx
T
peter f4bc60d659 feat(design): batch C recorded, the player row open as C5 (E28 S2d)
C1-C4 as proposed: the action classification, overlay-drawer side panels,
touch-first players, dialog focus by purpose with a pinned footer. The
players were then played live at tablet size, which found the Plex
control row running off-screen below 1024; how it fits is open as C5.
CLAUDE.md now points at DESIGN.md.
2026-09-24 03:27:26 +02:00

153 lines
5.6 KiB
TypeScript

import { useLayoutEffect, useState } from "react";
import clsx from "clsx";
import { SCHEMES, type Scheme } from "../lib/theme";
import { BatchA } from "./batchA";
import { BatchB } from "./batchB";
import { BatchC, C5 } from "./batchC";
// The open decisions, in the order they are asked. The decided ones render below them as reference.
const OPEN = [{ id: "c5", code: "C5", title: "Player row", render: C5 }] 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">
{OPEN.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>
))}
<a href="#decided" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
A ✓
</a>
<a href="#decided-b" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
B ✓
</a>
<a href="#decided-c" className="rounded-md px-1.5 py-0.5 text-success hover:bg-card">
C ✓
</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">Open · the player row on a narrow screen</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>
{OPEN.map((d) => (
<d.render key={d.id} />
))}
<header id="decided" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · batch A, controls</h2>
<p className="text-sm text-muted">
The chosen rendering of each decision, the reference the primitives are built to.
</p>
</header>
<BatchA />
<header id="decided-b" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · batch B, surfaces, chrome and colour</h2>
<p className="text-sm text-muted">
B1 (density) and B3 (state layers) are rendered inside the batch A references above.
</p>
</header>
<BatchB />
<header id="decided-c" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · batch C, behaviour and device</h2>
<p className="text-sm text-muted">
C1 is the action classification S3 builds each object&apos;s one action list from.
</p>
</header>
<BatchC />
</main>
</div>
);
}