feat(decnet_web/theme-lab): kitchen-sink component zoo

Renders every primitive in the design system on the lab page so
theme-token edits can be evaluated against all states at once:
colour swatches with WCAG contrast vs --bg, the full type scale,
buttons (5 variants × default/hover/disabled), badges and status
pills, info/error banners, metric cards, table rows
(default/hover/selected/drop-target), form inputs, drawer panel
sample, and net-box compose states (internet/inactive/selected/
drop-target — independent classes layering, per memory).

Wrapper uses .fleet-root so global .btn/.btn.violet/etc resolve
identically to real pages. Lab-local CSS owns layout only — every
colour comes from index.css tokens.
This commit is contained in:
2026-05-09 03:22:21 -04:00
parent 846a50dbbf
commit f3f7bff717
3 changed files with 731 additions and 15 deletions

View File

@@ -3,19 +3,362 @@
.theme-lab {
padding: var(--space-8);
color: var(--text-color);
font-family: var(--font-mono);
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.theme-lab .page-header h1 {
font-size: var(--fs-page);
letter-spacing: var(--ls-title);
color: var(--accent-color);
margin-bottom: var(--space-2);
color: var(--accent);
margin: 0;
}
.theme-lab-subtitle {
.theme-lab .page-sub {
font-size: var(--fs-mini);
letter-spacing: var(--ls-label);
opacity: 0.5;
}
.lab-section {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-5);
border: 1px solid var(--border);
background: var(--matrix-tint-5);
}
.lab-section-title {
font-size: var(--fs-small);
letter-spacing: var(--ls-nav);
color: var(--accent);
margin: 0;
}
.lab-section-body {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* ── Colour swatches ─────────────────────────────────── */
.lab-swatch-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-3);
}
.lab-swatch {
display: flex;
gap: var(--space-3);
border: 1px solid var(--border);
padding: var(--space-2);
background: var(--panel);
}
.lab-swatch-chip {
width: 56px;
height: 56px;
border: 1px solid var(--border);
flex-shrink: 0;
}
.lab-swatch-meta {
display: flex;
flex-direction: column;
gap: 2px;
font-size: var(--fs-mini);
min-width: 0;
}
.lab-swatch-meta code {
font-size: var(--fs-mini);
color: var(--matrix);
}
.lab-swatch-value {
opacity: 0.6;
word-break: break-all;
}
.lab-swatch-desc {
opacity: 0.5;
font-size: var(--fs-micro);
}
.lab-swatch-contrast {
font-size: var(--fs-micro);
letter-spacing: var(--ls-label);
margin-top: 2px;
}
.lab-swatch-contrast.ok { color: var(--matrix); }
.lab-swatch-contrast.warn { color: var(--violet); }
.lab-swatch-contrast.fail { color: var(--alert); }
/* ── Typography scale ────────────────────────────────── */
.lab-type-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.lab-type-row {
display: grid;
grid-template-columns: 140px 1fr;
align-items: baseline;
gap: var(--space-3);
border-bottom: 1px dashed var(--border);
padding-bottom: var(--space-2);
}
.lab-type-token {
font-size: var(--fs-mini);
opacity: 0.6;
}
.lab-type-sample {
color: var(--matrix);
font-weight: 700;
}
/* ── Buttons ─────────────────────────────────────────── */
.lab-btn-grid {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.lab-btn-row {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.lab-btn-label {
font-size: var(--fs-mini);
letter-spacing: var(--ls-label);
opacity: 0.6;
width: 80px;
}
/* Force the visual hover state without relying on user pointer. */
.lab-btn-row .btn[data-state='hover-demo'] {
background: var(--matrix);
color: var(--bg);
box-shadow: var(--matrix-glow);
}
.lab-btn-row .btn.violet[data-state='hover-demo'] {
background: var(--violet);
color: var(--bg);
box-shadow: var(--violet-glow);
}
.lab-btn-row .btn.alert[data-state='hover-demo'] {
background: var(--alert);
color: var(--bg);
box-shadow: 0 0 10px var(--alert);
}
/* ── Badges ──────────────────────────────────────────── */
.lab-badge-row {
display: flex;
gap: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.lab-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
border: 1px solid var(--border);
font-size: var(--fs-micro);
letter-spacing: var(--ls-label);
border-radius: var(--radius-pill);
}
.lab-pill.live {
color: var(--matrix);
border-color: var(--matrix);
background: var(--matrix-tint-10);
}
.lab-pill.inactive {
opacity: 0.6;
}
.lab-pill.threat {
color: var(--alert);
border-color: var(--alert);
background: var(--alert-tint-10);
}
.nav-badge {
display: inline-flex;
min-width: 22px;
height: 18px;
padding: 0 6px;
align-items: center;
justify-content: center;
border: 1px solid var(--alert);
color: var(--alert);
font-size: var(--fs-micro);
letter-spacing: var(--ls-label);
}
/* ── Banners ─────────────────────────────────────────── */
.lab-banner-stack {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.lab-banner-error {
border-left-color: var(--alert);
}
.lab-banner-error em { color: var(--alert); }
/* ── Metric cards ────────────────────────────────────── */
.lab-metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-3);
}
.lab-stat-card {
border: 1px solid var(--border);
border-left: 2px solid var(--accent);
background: var(--panel);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.lab-stat-card.empty { border-left-color: var(--border); opacity: 0.7; }
.lab-stat-label {
font-size: var(--fs-mini);
letter-spacing: var(--ls-label);
opacity: 0.6;
}
.lab-stat-value {
font-size: var(--fs-hero);
color: var(--accent);
font-weight: 700;
letter-spacing: var(--ls-tight);
}
.lab-stat-empty { color: var(--matrix); opacity: 0.4; }
.lab-stat-foot {
font-size: var(--fs-micro);
opacity: 0.6;
}
.lab-stat-foot.dim { opacity: 0.4; }
/* ── Table ───────────────────────────────────────────── */
.lab-table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-small);
}
.lab-table th,
.lab-table td {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border);
text-align: left;
}
.lab-table th {
font-size: var(--fs-micro);
letter-spacing: var(--ls-nav);
opacity: 0.6;
}
.lab-table tr.hover-demo {
background: var(--matrix-tint-5);
}
.lab-table tr.selected {
background: var(--accent-tint-10);
outline: 1px solid var(--accent);
outline-offset: -1px;
}
.lab-table tr.drop-target {
background: var(--violet-tint-10);
outline: 1px dashed var(--violet);
outline-offset: -1px;
}
.lab-table td.ok { color: var(--matrix); }
.lab-table td.warn { color: var(--violet); }
.lab-table td.dim { opacity: 0.4; }
/* ── Inputs ──────────────────────────────────────────── */
.lab-input-grid {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
}
.lab-input-grid select {
background: var(--panel);
border: 1px solid var(--border);
color: var(--matrix);
padding: 8px 12px;
}
.lab-checkbox {
display: inline-flex;
gap: 6px;
align-items: center;
font-size: var(--fs-small);
opacity: 0.85;
}
/* ── Drawer ──────────────────────────────────────────── */
.lab-drawer {
width: 360px;
max-width: 100%;
border: 1px solid var(--border);
border-left: 2px solid var(--violet);
background: var(--panel);
display: flex;
flex-direction: column;
}
.lab-drawer-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
font-size: var(--fs-mini);
letter-spacing: var(--ls-nav);
color: var(--violet);
}
.lab-drawer-body {
padding: var(--space-4);
font-size: var(--fs-small);
opacity: 0.8;
}
/* ── Net-box states ──────────────────────────────────── */
.lab-netbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--space-3);
}
.lab-netbox {
border: 1px solid var(--border);
background: var(--panel);
padding: var(--space-4);
display: flex;
align-items: center;
justify-content: center;
min-height: 80px;
font-size: var(--fs-mini);
letter-spacing: var(--ls-nav);
position: relative;
}
/* States compose: independent classes layer onto the base.
* Per memory: "Net-box visual states compose". */
.lab-netbox.internet { border-color: var(--accent); color: var(--accent); }
.lab-netbox.inactive { opacity: 0.4; }
.lab-netbox.selected { outline: 2px solid var(--accent); outline-offset: -2px; }
.lab-netbox.drop-target { border-style: dashed; border-color: var(--violet); }

View File

@@ -1,21 +1,358 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import './ThemeLab.css';
/* Kitchen-sink theme lab.
*
* Dev-only page (gated upstream in App.tsx via isDeveloperMode()).
* Subsequent tasks fill this in with every design-system primitive
* and a Dark/Light toggle. For now: header stub so the route + gate
* can land in isolation. */
* Renders every primitive in the design system so theme-token edits
* can be evaluated against all states at once. Dev-only (gated in
* App.tsx via isDeveloperMode()).
*
* Conventions:
* - Wrapper uses .fleet-root so global .btn/.btn.violet/etc resolve
* the same way they do on real pages.
* - All colours come from index.css tokens. Lab-local CSS only owns
* layout (grid for swatches, section spacing).
* - Every section has a data-testid for smoke-test assertions. */
// Tokens enumerated explicitly so the lab serves as documentation
// of the supported design system surface, not a runtime introspection.
const COLOR_TOKENS: ReadonlyArray<readonly [string, string]> = [
['--bg', 'background'],
['--matrix', 'primary text / live'],
['--violet', 'accent'],
['--panel', 'panel surface'],
['--border', 'borders'],
['--alert', 'alert / critical'],
['--accent', 'active accent (matrix or violet)'],
['--accent-tint-10', 'accent surface tint'],
['--matrix-tint-5', 'subtle matrix wash'],
['--matrix-tint-10', 'matrix tint'],
['--matrix-tint-30', 'matrix tint strong'],
['--violet-tint-10', 'violet tint'],
['--alert-tint-10', 'alert tint'],
['--grid-line', 'scangrid line'],
];
const TYPE_SCALE: ReadonlyArray<readonly [string, string]> = [
['--fs-display', 'DISPLAY'],
['--fs-hero', 'HERO'],
['--fs-page', 'PAGE'],
['--fs-head', 'HEAD'],
['--fs-base', 'BASE'],
['--fs-ui', 'UI'],
['--fs-body', 'BODY'],
['--fs-small', 'SMALL'],
['--fs-tiny', 'TINY'],
['--fs-mini', 'MINI'],
['--fs-micro', 'MICRO'],
];
/* WCAG relative luminance + contrast ratio.
* Accepts any css color string by going through the canvas trick. */
function parseColor(input: string): [number, number, number, number] | null {
if (typeof document === 'undefined') return null;
let ctx: CanvasRenderingContext2D | null = null;
try {
ctx = document.createElement('canvas').getContext('2d');
} catch {
return null;
}
if (!ctx) return null;
ctx.fillStyle = '#000';
ctx.fillStyle = input;
const computed = ctx.fillStyle as string;
// computed is now either #rrggbb or rgba(...)
if (computed.startsWith('#')) {
const r = parseInt(computed.slice(1, 3), 16);
const g = parseInt(computed.slice(3, 5), 16);
const b = parseInt(computed.slice(5, 7), 16);
return [r, g, b, 1];
}
const m = computed.match(/rgba?\(([^)]+)\)/);
if (!m) return null;
const parts = m[1].split(',').map((s) => parseFloat(s.trim()));
return [parts[0], parts[1], parts[2], parts[3] ?? 1];
}
function relLum(r: number, g: number, b: number): number {
const ch = (c: number) => {
const n = c / 255;
return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4);
};
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b);
}
function contrast(fg: string, bg: string): number | null {
const a = parseColor(fg);
const b = parseColor(bg);
if (!a || !b) return null;
const la = relLum(a[0], a[1], a[2]);
const lb = relLum(b[0], b[1], b[2]);
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
return (hi + 0.05) / (lo + 0.05);
}
interface Resolved {
name: string;
desc: string;
value: string;
vsBg: number | null;
}
function useResolvedTokens(deps: unknown[] = []): Resolved[] {
const [rows, setRows] = useState<Resolved[]>([]);
useEffect(() => {
const cs = getComputedStyle(document.documentElement);
const bg = cs.getPropertyValue('--bg').trim() || '#000';
setRows(
COLOR_TOKENS.map(([name, desc]) => {
const value = cs.getPropertyValue(name).trim() || '—';
const vsBg = name === '--bg' ? null : contrast(value, bg);
return { name, desc, value, vsBg };
}),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return rows;
}
const Section: React.FC<{
id: string;
title: string;
children: React.ReactNode;
}> = ({ id, title, children }) => (
<section className="lab-section" data-testid={`lab-section-${id}`}>
<h2 className="lab-section-title">{title}</h2>
<div className="lab-section-body">{children}</div>
</section>
);
const ColorSwatches: React.FC = () => {
const rows = useResolvedTokens();
return (
<div className="lab-swatch-grid">
{rows.map((r) => (
<div className="lab-swatch" key={r.name}>
<div
className="lab-swatch-chip"
style={{ background: `var(${r.name})` }}
/>
<div className="lab-swatch-meta">
<code>{r.name}</code>
<span className="lab-swatch-value">{r.value}</span>
<span className="lab-swatch-desc">{r.desc}</span>
{r.vsBg !== null && (
<span
className={`lab-swatch-contrast ${
r.vsBg >= 4.5 ? 'ok' : r.vsBg >= 3 ? 'warn' : 'fail'
}`}
>
{r.vsBg.toFixed(2)}:1
</span>
)}
</div>
</div>
))}
</div>
);
};
const TypeScale: React.FC = () => (
<div className="lab-type-list">
{TYPE_SCALE.map(([token, label]) => (
<div className="lab-type-row" key={token}>
<code className="lab-type-token">{token}</code>
<div
className="lab-type-sample"
style={{ fontSize: `var(${token})`, letterSpacing: 'var(--ls-title)' }}
>
{label} · DECNET
</div>
</div>
))}
</div>
);
const Buttons: React.FC = () => (
<div className="lab-btn-grid">
{(
[
['default', ''],
['violet', 'violet'],
['alert', 'alert'],
['ghost', 'ghost'],
['small', 'small'],
] as const
).map(([label, mod]) => (
<div className="lab-btn-row" key={label}>
<span className="lab-btn-label">{label}</span>
<button className={`btn ${mod}`}>NORMAL</button>
<button className={`btn ${mod}`} data-state="hover-demo">
HOVER
</button>
<button className={`btn ${mod}`} disabled>
DISABLED
</button>
</div>
))}
</div>
);
const Badges: React.FC = () => (
<div className="lab-badge-row">
<span className="lab-pill live"> LIVE</span>
<span className="lab-pill inactive"> INACTIVE</span>
<span className="lab-pill threat"> THREAT: ELEVATED</span>
<span className="nav-badge">7</span>
<span className="nav-badge">99+</span>
</div>
);
const Banners: React.FC = () => (
<div className="lab-banner-stack">
<div className="info-banner">
<em>HEADS UP.</em> Tokens drive every surface edits to{' '}
<code>index.css</code> reflow the entire app at once.
</div>
<div className="info-banner lab-banner-error">
<em>ERROR.</em> Failed to resolve <code>--accent</code> check the
cascade.
</div>
</div>
);
const MetricCards: React.FC = () => (
<div className="lab-metric-grid">
<div className="lab-stat-card">
<div className="lab-stat-label">TOTAL ATTEMPTS</div>
<div className="lab-stat-value">63,678</div>
<div className="lab-stat-foot">+0 in last 5m</div>
</div>
<div className="lab-stat-card empty">
<div className="lab-stat-label">QUEUED PROBES</div>
<div className="lab-stat-value lab-stat-empty"></div>
<div className="lab-stat-foot dim">no data yet</div>
</div>
</div>
);
const TableRows: React.FC = () => (
<table className="lab-table">
<thead>
<tr>
<th>SOURCE</th>
<th>TARGET</th>
<th>STATE</th>
</tr>
</thead>
<tbody>
<tr>
<td>10.0.0.41</td>
<td>decoy-7</td>
<td className="ok">ACTIVE</td>
</tr>
<tr className="hover-demo">
<td>10.0.0.42</td>
<td>decoy-3</td>
<td className="ok">ACTIVE</td>
</tr>
<tr className="selected">
<td>10.0.0.43</td>
<td>decoy-9</td>
<td className="warn">PROBING</td>
</tr>
<tr className="drop-target">
<td>10.0.0.44</td>
<td>(drop here)</td>
<td className="dim"></td>
</tr>
</tbody>
</table>
);
const Inputs: React.FC = () => (
<div className="lab-input-grid">
<input type="text" placeholder="text input" defaultValue="" />
<input type="search" placeholder="search…" />
<select defaultValue="b">
<option value="a">option a</option>
<option value="b">option b</option>
</select>
<label className="lab-checkbox">
<input type="checkbox" defaultChecked /> enabled flag
</label>
</div>
);
const Drawer: React.FC = () => (
<aside className="lab-drawer">
<header className="lab-drawer-head">
<span>DRAWER · SAMPLE</span>
<button className="btn ghost small" type="button">
CLOSE
</button>
</header>
<div className="lab-drawer-body">
<p>
Standalone panel preview. Real drawers portal into the layout root;
this one sits inline so token impact is visible.
</p>
</div>
</aside>
);
const NetBoxes: React.FC = () => (
<div className="lab-netbox-grid">
{(['internet', 'inactive', 'selected', 'drop-target'] as const).map((s) => (
<div className={`lab-netbox ${s}`} key={s}>
<span className="lab-netbox-label">{s.toUpperCase()}</span>
</div>
))}
</div>
);
const ThemeLab: React.FC = () => {
return (
<div className="theme-lab" data-testid="theme-lab">
<div className="fleet-root theme-lab" data-testid="theme-lab">
<header className="page-header">
<h1>THEME LAB</h1>
<p className="theme-lab-subtitle">
dev only · primitive zoo for theme regression
</p>
<div className="page-title-group">
<h1>THEME LAB</h1>
<span className="page-sub">
dev only · primitive zoo for theme regression
</span>
</div>
</header>
<Section id="swatches" title="COLOUR TOKENS">
<ColorSwatches />
</Section>
<Section id="type" title="TYPOGRAPHY SCALE">
<TypeScale />
</Section>
<Section id="buttons" title="BUTTONS">
<Buttons />
</Section>
<Section id="badges" title="BADGES & STATUS PILLS">
<Badges />
</Section>
<Section id="banners" title="BANNERS">
<Banners />
</Section>
<Section id="metrics" title="METRIC CARDS">
<MetricCards />
</Section>
<Section id="table" title="TABLE ROWS">
<TableRows />
</Section>
<Section id="inputs" title="FORM INPUTS">
<Inputs />
</Section>
<Section id="drawer" title="DRAWER / MODAL">
<Drawer />
</Section>
<Section id="netbox" title="NET-BOX STATES">
<NetBoxes />
</Section>
</div>
);
};

View File

@@ -9,4 +9,40 @@ describe('ThemeLab', () => {
expect(screen.getByText(/THEME LAB/i)).toBeInTheDocument();
expect(screen.getByText(/dev only/i)).toBeInTheDocument();
});
it('renders every primitive section', () => {
render(<ThemeLab />);
for (const id of [
'swatches',
'type',
'buttons',
'badges',
'banners',
'metrics',
'table',
'inputs',
'drawer',
'netbox',
]) {
expect(screen.getByTestId(`lab-section-${id}`)).toBeInTheDocument();
}
});
it('renders button variants × states', () => {
render(<ThemeLab />);
// 5 variants × 3 states (normal/hover/disabled) = 15 rendered buttons
// plus the drawer's CLOSE button = 16 total.
const allButtons = screen.getAllByRole('button');
expect(allButtons.length).toBeGreaterThanOrEqual(15);
// Disabled buttons exist
const disabled = allButtons.filter((b) => (b as HTMLButtonElement).disabled);
expect(disabled.length).toBe(5);
});
it('renders the four net-box compose states', () => {
render(<ThemeLab />);
for (const s of ['INTERNET', 'INACTIVE', 'SELECTED', 'DROP-TARGET']) {
expect(screen.getByText(s)).toBeInTheDocument();
}
});
});