diff --git a/apps/ui/package.json b/apps/ui/package.json index 96c776e9..2753c774 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -11,6 +11,7 @@ "preview": "vite preview", "clean": "rm -rf dist", "lint": "tsc --noEmit", + "lint:style": "node scripts/check-style-discipline.mjs", "lint:react": "react-doctor", "test": "vitest run", "test:unit": "vitest run tests/services", @@ -19,7 +20,7 @@ "test:e2e:headed": "playwright test --config=playwright.full.config.ts --headed --reporter=list", "test:smoke": "playwright test --reporter=list", "test:smoke:live-gemini": "playwright test tests/smoke/upload-phase2-live-gemini.spec.ts --reporter=list", - "verify": "npm run lint && npm run test:unit && npm run build && npm run test:smoke", + "verify": "npm run lint && npm run lint:style && npm run test:unit && npm run build && npm run test:smoke", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build -o storybook-static", "chromatic": "chromatic --exit-zero-on-changes" diff --git a/apps/ui/scripts/check-style-discipline.mjs b/apps/ui/scripts/check-style-discipline.mjs new file mode 100644 index 00000000..e914b9fc --- /dev/null +++ b/apps/ui/scripts/check-style-discipline.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * check-style-discipline.mjs — design-token enforcement guard. + * + * Fails when source under an enforced directory uses: + * 1. Arbitrary Tailwind font sizes → text-[13px] / text-[0.8rem] + * Use the --text-* scale tokens from src/index.css instead: + * text-nano · text-micro · text-meta · text-eyebrow · text-body-sm + * text-body · text-value · text-value-lg · text-value-xl + * 2. Raw color hex literals → #ff8800 / #1a1a1a + * Use the semantic color tokens / Tailwind color utilities instead. + * + * Why this exists: a 2026-05-13 pass removed hardcoded hex, but it silently + * returned because nothing enforced the rule. This guard is that enforcement. + * ENFORCED_DIRS is widened directory-by-directory as the UI overhaul clears + * each area, so the guard can never regress what it has already cleaned. + * + * Out of scope (allowlisted): + * - *.stories.tsx — dev-only Storybook artifacts, not shipped UI. + * - Canvas / color-math modules that compute colors CSS classes can't express. + * - src/utils/colorScales.ts — the sanctioned home for centralized palettes. + * - DenseDawConcept.tsx — the off-system ?view=daw demo (excluded from overhaul). + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, basename, relative, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const uiRoot = join(here, '..'); +const srcRoot = join(uiRoot, 'src'); + +// Directories currently held to the discipline. Widen as overhaul phases land. +const ENFORCED_DIRS = [join(srcRoot, 'components', 'ui')]; + +// Basenames exempt anywhere within enforced dirs. +const ALLOWLIST = new Set([ + 'colorScales.ts', + 'DenseDawConcept.tsx', + 'RetroVisualizer.tsx', + 'SpectrogramViewer.tsx', + 'ChromaHeatmap.tsx', + 'SpectralEvolutionChart.tsx', + 'Sparkline.tsx', + 'TranscriptionPianoroll.tsx', + 'WaveformPlayer.tsx', + 'PianoRollCanvas.tsx', +]); + +const RULES = [ + { + id: 'arbitrary-text-size', + re: /text-\[[0-9.]+(?:px|rem|em)\]/g, + hint: 'use a --text-* scale token (e.g. text-meta, text-eyebrow, text-body-sm)', + }, + { + id: 'raw-hex-color', + re: /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g, + hint: 'use a semantic color token (var(--color-*) or a Tailwind color utility)', + }, +]; + +function walk(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...walk(full)); + } else if ( + /\.(ts|tsx)$/.test(entry) && + !/\.stories\.tsx$/.test(entry) && + !/\.test\.tsx?$/.test(entry) + ) { + out.push(full); + } + } + return out; +} + +const violations = []; +for (const dir of ENFORCED_DIRS) { + for (const file of walk(dir)) { + if (ALLOWLIST.has(basename(file))) continue; + const lines = readFileSync(file, 'utf8').split('\n'); + lines.forEach((line, i) => { + for (const rule of RULES) { + rule.re.lastIndex = 0; + let m; + while ((m = rule.re.exec(line)) !== null) { + violations.push({ + file: relative(uiRoot, file), + line: i + 1, + match: m[0], + rule: rule.id, + hint: rule.hint, + }); + } + } + }); + } +} + +if (violations.length > 0) { + console.error(`\n✗ style-discipline: ${violations.length} violation(s)\n`); + for (const v of violations) { + console.error(` ${v.file}:${v.line} ${v.match} [${v.rule}]`); + console.error(` → ${v.hint}`); + } + console.error(''); + process.exit(1); +} + +console.log( + '✓ style-discipline: no arbitrary text sizes or raw hex in enforced dirs', +); diff --git a/apps/ui/src/components/ui/Button.tsx b/apps/ui/src/components/ui/Button.tsx index f24b031f..0be174d5 100644 --- a/apps/ui/src/components/ui/Button.tsx +++ b/apps/ui/src/components/ui/Button.tsx @@ -40,9 +40,9 @@ const buttonVariants = cva( ], }, size: { - sm: 'px-2 py-1 text-[10px]', - md: 'px-3 py-1.5 text-[11px]', - lg: 'px-6 py-2.5 text-[12px]', + sm: 'px-2 py-1 text-meta', + md: 'px-3 py-1.5 text-eyebrow', + lg: 'px-6 py-2.5 text-body-sm', }, iconOnly: { true: '!gap-0', diff --git a/apps/ui/src/components/ui/Checkbox.tsx b/apps/ui/src/components/ui/Checkbox.tsx index 07bcf0ae..4dba64e0 100644 --- a/apps/ui/src/components/ui/Checkbox.tsx +++ b/apps/ui/src/components/ui/Checkbox.tsx @@ -87,12 +87,12 @@ export const Checkbox = React.forwardRef< {box} {label && ( - + {label} )} {description && ( - + {description} )} diff --git a/apps/ui/src/components/ui/DeviceRack.tsx b/apps/ui/src/components/ui/DeviceRack.tsx index 09018cfd..3a627ba8 100644 --- a/apps/ui/src/components/ui/DeviceRack.tsx +++ b/apps/ui/src/components/ui/DeviceRack.tsx @@ -86,7 +86,7 @@ export const DeviceRack = React.forwardRef( {name} {subtitle && ( - + {subtitle} )} diff --git a/apps/ui/src/components/ui/EmptyState.tsx b/apps/ui/src/components/ui/EmptyState.tsx index e57fe53a..ca9a18ec 100644 --- a/apps/ui/src/components/ui/EmptyState.tsx +++ b/apps/ui/src/components/ui/EmptyState.tsx @@ -44,12 +44,12 @@ export const EmptyState = React.forwardRef( > {icon &&
{icon}
} {title && ( -

+

{title}

)} {description && ( -

+

{description}

)} diff --git a/apps/ui/src/components/ui/LedIndicator.tsx b/apps/ui/src/components/ui/LedIndicator.tsx index 6c0efa94..51199318 100644 --- a/apps/ui/src/components/ui/LedIndicator.tsx +++ b/apps/ui/src/components/ui/LedIndicator.tsx @@ -37,7 +37,7 @@ export const LedIndicator = React.forwardRef {...rest} > - + {label} diff --git a/apps/ui/src/components/ui/MetricBar.tsx b/apps/ui/src/components/ui/MetricBar.tsx index 77ed7b91..e0e1147e 100644 --- a/apps/ui/src/components/ui/MetricBar.tsx +++ b/apps/ui/src/components/ui/MetricBar.tsx @@ -79,7 +79,7 @@ export const MetricBar = React.forwardRef(functi /> {(leftLabel || rightLabel) && ( -
+
{leftLabel ?? ''} {rightLabel ?? ''}
diff --git a/apps/ui/src/components/ui/MetricTile.tsx b/apps/ui/src/components/ui/MetricTile.tsx index b88075ff..60c72f8d 100644 --- a/apps/ui/src/components/ui/MetricTile.tsx +++ b/apps/ui/src/components/ui/MetricTile.tsx @@ -29,9 +29,9 @@ const valueSizeClass: Record, string> = { }; const unitSizeClass: Record, string> = { - sm: 'text-[9px]', - md: 'text-[10px]', - lg: 'text-[11px]', + sm: 'text-micro', + md: 'text-meta', + lg: 'text-eyebrow', xl: 'text-xs', }; @@ -77,7 +77,7 @@ export const MetricTile = React.forwardRef( {icon && {icon}} {label} diff --git a/apps/ui/src/components/ui/Pill.tsx b/apps/ui/src/components/ui/Pill.tsx index 3553035a..d8ad185c 100644 --- a/apps/ui/src/components/ui/Pill.tsx +++ b/apps/ui/src/components/ui/Pill.tsx @@ -20,8 +20,8 @@ const pillVariants = cva( ghost: 'border-transparent', }, size: { - xs: 'px-1.5 py-0.5 text-[9px]', - sm: 'px-2 py-0.5 text-[10px]', + xs: 'px-1.5 py-0.5 text-micro', + sm: 'px-2 py-0.5 text-meta', }, }, compoundVariants: [ diff --git a/apps/ui/src/components/ui/SignalChain.tsx b/apps/ui/src/components/ui/SignalChain.tsx index 889d6d65..4e4faa18 100644 --- a/apps/ui/src/components/ui/SignalChain.tsx +++ b/apps/ui/src/components/ui/SignalChain.tsx @@ -100,7 +100,7 @@ export const SignalChain = React.forwardRef( {stage.statusLabel && ( ( {elapsedLabel} {hasEstimate && ( - + est {estimateLabel} )} diff --git a/apps/ui/src/components/ui/Tooltip.tsx b/apps/ui/src/components/ui/Tooltip.tsx index 24f0ea5f..f3be3c66 100644 --- a/apps/ui/src/components/ui/Tooltip.tsx +++ b/apps/ui/src/components/ui/Tooltip.tsx @@ -51,7 +51,7 @@ export function Tooltip({ sideOffset={6} className={cn( 'z-50 max-w-xs px-3 py-2 rounded-sm border border-border bg-bg-card shadow-md', - 'font-mono text-[11px] text-text-primary leading-snug', + 'font-mono text-eyebrow text-text-primary leading-snug', 'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0', className, )} diff --git a/apps/ui/src/index.css b/apps/ui/src/index.css index 7050e9c8..2c1a835d 100644 --- a/apps/ui/src/index.css +++ b/apps/ui/src/index.css @@ -67,7 +67,11 @@ --space-title-strip-h: 1.5rem; --space-rail-h: 0.875rem; - /* --- Type-scale lock (bans arbitrary text-[Npx] in components/ui/) --- */ + /* --- Type-scale lock: the ONLY sanctioned font sizes. Arbitrary + text-[Npx] under src/components/** is banned by + scripts/check-style-discipline.mjs. --- */ + --text-nano: 0.5rem; /* 8px — dense meter / axis labels */ + --text-micro: 0.5625rem; /* 9px — compact eyebrows, pill xs */ --text-meta: 0.625rem; --text-eyebrow: 0.6875rem; --text-body-sm: 0.75rem;