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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user