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.
181 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|