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:
@@ -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); }
|
||||
|
||||
@@ -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">
|
||||
<div className="page-title-group">
|
||||
<h1>THEME LAB</h1>
|
||||
<p className="theme-lab-subtitle">
|
||||
<span className="page-sub">
|
||||
dev only · primitive zoo for theme regression
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user