Files
siftlode/frontend/src/design-lab/labKit.tsx
T
peter 07e0990ecf design(files): the one-library round in the lab — aligned list columns, the tray, the done notice
The Files page replaces the Downloads page: list rows with one column per value kind and a
right-click menu, an app-wide download tray, a done notice that copies the link. Picked
variants are logged in DESIGN.md.
2026-10-11 02:59:51 +02:00

181 lines
6.0 KiB
TypeScript

import { useSyncExternalStore, type ReactNode } from "react";
import clsx from "clsx";
import { Badge } from "../components/ui/Badge";
import { SURFACE } from "../components/ui/surfaces";
// Building blocks of the design lab. A taken decision is a <Reference>: the chosen rendering with the
// rule it became. An open design round shows its questions as <Decision>s with their <Variant>s side
// by side, until the owner picks. Everything
// is static markup on purpose: a rendering that only exists here must not need app state.
/** A decision already taken: the chosen rendering only, with the rule it became. The lab's
* reference half — the living style guide. */
export function Reference({
id,
code,
title,
rule,
children,
}: {
id: string;
code: string;
title: string;
rule: string;
children: ReactNode;
}) {
return (
<section id={id} className={`${SURFACE.card} p-4 flex flex-col gap-3 scroll-mt-20`}>
<header>
<div className="flex items-baseline gap-3">
<span className="text-xs font-bold tracking-widest text-success">{code} ✓</span>
<h3 className="text-sm font-semibold">{title}</h3>
</div>
<p className="mt-1 text-xs text-muted">{rule}</p>
</header>
<div className="flex flex-col gap-3">{children}</div>
</section>
);
}
/** An open question of a design round: what the app does today, then the variants side by side. */
export function Decision({
id,
code,
title,
question,
now,
children,
}: {
id: string;
code: string;
title: string;
question: string;
/** What the app does today. */
now: ReactNode;
children: ReactNode;
}) {
return (
<section id={id} className={`${SURFACE.panel} p-5 scroll-mt-20`}>
<header className="mb-4">
<div className="flex items-baseline gap-3">
<span className="text-xs font-bold tracking-widest text-accent">{code}</span>
<h3 className="text-lg font-bold">{title}</h3>
</div>
<p className="mt-1 text-sm">{question}</p>
<p className="mt-1 text-xs text-muted">
<span className="font-semibold">Today: </span>
{now}
</p>
</header>
<div className="flex flex-col gap-4">{children}</div>
</section>
);
}
/** One answer to a Decision; the recommended one carries the accent edge. */
export function Variant({
letter,
name,
recommended = false,
why,
children,
}: {
letter: string;
name: string;
recommended?: boolean;
why: string;
children: ReactNode;
}) {
return (
<article
className={clsx(
SURFACE.card,
"flex flex-col gap-3 border p-4",
recommended ? "border-accent" : "border-transparent",
)}
>
<div className="flex items-center gap-2">
<span className="grid h-6 w-6 place-items-center rounded-full bg-fg/8 text-xs font-bold">
{letter}
</span>
<h4 className="flex-1 text-sm font-semibold">{name}</h4>
{recommended && <Badge tone="accent">Recommended</Badge>}
</div>
<div className="flex flex-col gap-3">{children}</div>
<p className="mt-auto border-t border-border/60 pt-2 text-xs text-muted">{why}</p>
</article>
);
}
/** A small caption above a sample inside a variant. */
export function Sample({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<span className="text-2xs uppercase tracking-wider text-muted">{label}</span>
{children}
</div>
);
}
// ── Measured contrast ───────────────────────────────────────────────────────────────────────────
// A variant that changes a colour prints its contrast against the page background, computed from the
// live CSS — so switching the theme or the scheme in the lab's bar re-measures it. The theme lives on
// <html>'s attributes, so that is what the store subscribes to.
function subscribeTheme(onChange: () => void) {
const mo = new MutationObserver(onChange);
mo.observe(document.documentElement, { attributes: true });
return () => mo.disconnect();
}
let probe: HTMLSpanElement | null = null;
/** Resolve any CSS colour expression (vars, color-mix) to [r, g, b, a] in 0..1 via a hidden probe. */
function resolve(expr: string): [number, number, number, number] {
if (!probe) {
probe = document.createElement("span");
probe.style.display = "none";
document.body.append(probe);
}
probe.style.color = "";
probe.style.color = expr;
const c = getComputedStyle(probe).color;
const [r = 0, g = 0, b = 0, a = 1] = (c.match(/[\d.]+/g) ?? []).map(Number);
// `rgb()` is 0..255; a color-mix() result computes to `color(srgb …)`, already 0..1.
return c.startsWith("color(") ? [r, g, b, a] : [r / 255, g / 255, b / 255, a];
}
const lin = (v: number) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
const lum = (r: number, g: number, b: number) =>
0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
function ratio(fg: string, bg: string): number {
const [br, bg_, bb] = resolve(bg);
const [fr, fg_, fb, fa] = resolve(fg);
// Composite a translucent foreground over the background, as the eye sees it.
const over = (f: number, b: number) => f * fa + b * (1 - fa);
const l1 = lum(over(fr, br), over(fg_, bg_), over(fb, bb));
const l2 = lum(br, bg_, bb);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
/** "3.42:1 ✓" against `min` — re-measured whenever the lab switches theme or scheme. */
export function Contrast({
fg,
bg = "var(--bg)",
min,
label,
}: {
fg: string;
bg?: string;
min: number;
label: string;
}) {
const value = useSyncExternalStore(subscribeTheme, () => ratio(fg, bg).toFixed(2));
const ok = Number(value) >= min;
return (
<span className={clsx("text-2xs tabular-nums", ok ? "text-success" : "text-danger")}>
{label}: {value}:1 {ok ? "✓" : `✗ (needs ${min}:1)`}
</span>
);
}