From f3f7bff717bf5ddc7208fa5a440033873bcbfb31 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 03:22:21 -0400 Subject: [PATCH] feat(decnet_web/theme-lab): kitchen-sink component zoo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/ThemeLab/ThemeLab.css | 353 ++++++++++++++++- .../src/components/ThemeLab/ThemeLab.tsx | 357 +++++++++++++++++- .../ThemeLab/__tests__/ThemeLab.test.tsx | 36 ++ 3 files changed, 731 insertions(+), 15 deletions(-) diff --git a/decnet_web/src/components/ThemeLab/ThemeLab.css b/decnet_web/src/components/ThemeLab/ThemeLab.css index 3a7a21c4..c076daa4 100644 --- a/decnet_web/src/components/ThemeLab/ThemeLab.css +++ b/decnet_web/src/components/ThemeLab/ThemeLab.css @@ -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); } diff --git a/decnet_web/src/components/ThemeLab/ThemeLab.tsx b/decnet_web/src/components/ThemeLab/ThemeLab.tsx index d0b6b4ec..3a51dc98 100644 --- a/decnet_web/src/components/ThemeLab/ThemeLab.tsx +++ b/decnet_web/src/components/ThemeLab/ThemeLab.tsx @@ -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 = [ + ['--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 = [ + ['--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([]); + 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 }) => ( +
+

{title}

+
{children}
+
+); + +const ColorSwatches: React.FC = () => { + const rows = useResolvedTokens(); + return ( +
+ {rows.map((r) => ( +
+
+
+ {r.name} + {r.value} + {r.desc} + {r.vsBg !== null && ( + = 4.5 ? 'ok' : r.vsBg >= 3 ? 'warn' : 'fail' + }`} + > + {r.vsBg.toFixed(2)}:1 + + )} +
+
+ ))} +
+ ); +}; + +const TypeScale: React.FC = () => ( +
+ {TYPE_SCALE.map(([token, label]) => ( +
+ {token} +
+ {label} · DECNET +
+
+ ))} +
+); + +const Buttons: React.FC = () => ( +
+ {( + [ + ['default', ''], + ['violet', 'violet'], + ['alert', 'alert'], + ['ghost', 'ghost'], + ['small', 'small'], + ] as const + ).map(([label, mod]) => ( +
+ {label} + + + +
+ ))} +
+); + +const Badges: React.FC = () => ( +
+ ● LIVE + ○ INACTIVE + ▲ THREAT: ELEVATED + 7 + 99+ +
+); + +const Banners: React.FC = () => ( +
+
+ HEADS UP. Tokens drive every surface — edits to{' '} + index.css reflow the entire app at once. +
+
+ ERROR. Failed to resolve --accent — check the + cascade. +
+
+); + +const MetricCards: React.FC = () => ( +
+
+
TOTAL ATTEMPTS
+
63,678
+
+0 in last 5m
+
+
+
QUEUED PROBES
+
+
no data yet
+
+
+); + +const TableRows: React.FC = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SOURCETARGETSTATE
10.0.0.41decoy-7ACTIVE
10.0.0.42decoy-3ACTIVE
10.0.0.43decoy-9PROBING
10.0.0.44(drop here)
+); + +const Inputs: React.FC = () => ( +
+ + + + +
+); + +const Drawer: React.FC = () => ( + +); + +const NetBoxes: React.FC = () => ( +
+ {(['internet', 'inactive', 'selected', 'drop-target'] as const).map((s) => ( +
+ {s.toUpperCase()} +
+ ))} +
+); + const ThemeLab: React.FC = () => { return ( -
+
-

THEME LAB

-

- dev only · primitive zoo for theme regression -

+
+

THEME LAB

+ + dev only · primitive zoo for theme regression + +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
); }; diff --git a/decnet_web/src/components/ThemeLab/__tests__/ThemeLab.test.tsx b/decnet_web/src/components/ThemeLab/__tests__/ThemeLab.test.tsx index 0c1ce49b..36076619 100644 --- a/decnet_web/src/components/ThemeLab/__tests__/ThemeLab.test.tsx +++ b/decnet_web/src/components/ThemeLab/__tests__/ThemeLab.test.tsx @@ -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(); + 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(); + // 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(); + for (const s of ['INTERNET', 'INACTIVE', 'SELECTED', 'DROP-TARGET']) { + expect(screen.getByText(s)).toBeInTheDocument(); + } + }); });