From 0c2eb4fdf2d290110541396eb94e08c6286d5c93 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:02:32 -0700 Subject: [PATCH] feat: multi-theme support via per-token mode values (light/dark) Inline per-token modes (`{ light, dark }`) with per-mode resolution and Tailwind v4 export. Additive and backward compatible. Refs #13. --- packages/cli/src/linter/model/handler.test.ts | 127 +++- packages/cli/src/linter/model/handler.ts | 541 ++++++++++++++++-- packages/cli/src/linter/model/spec.ts | 39 ++ .../cli/src/linter/parser/handler.test.ts | 24 + packages/cli/src/linter/parser/handler.ts | 4 + packages/cli/src/linter/parser/spec.ts | 4 + packages/cli/src/linter/spec-config.ts | 3 + .../src/linter/tailwind/v4/handler.test.ts | 58 ++ .../cli/src/linter/tailwind/v4/serialize.ts | 72 ++- 9 files changed, 809 insertions(+), 63 deletions(-) diff --git a/packages/cli/src/linter/model/handler.test.ts b/packages/cli/src/linter/model/handler.test.ts index 191e31ae..a1fc2d61 100644 --- a/packages/cli/src/linter/model/handler.test.ts +++ b/packages/cli/src/linter/model/handler.test.ts @@ -220,6 +220,131 @@ describe('ModelHandler', () => { }); }); + describe('theme mode values', () => { + it('resolves color mode objects and keeps default-theme in the existing color map', () => { + const result = handler.execute(makeParsed({ + themes: ['light', 'dark'], + defaultTheme: 'light', + colors: { + surface: { + light: '#ffffff', + dark: '#111111', + }, + }, + })); + + expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0); + expect(result.designSystem.colors.get('surface')?.hex).toBe('#ffffff'); + expect(result.designSystem.colors.has('surface.light')).toBe(false); + expect(result.designSystem.modes?.get('light')?.colors.get('surface')?.hex).toBe('#ffffff'); + expect(result.designSystem.modes?.get('dark')?.colors.get('surface')?.hex).toBe('#111111'); + expect(result.designSystem.symbolTable.get('colors.surface')).toBe(result.designSystem.colors.get('surface')); + }); + + it('resolves references against each theme mode', () => { + const result = handler.execute(makeParsed({ + themes: ['light', 'dark'], + defaultTheme: 'light', + colors: { + brand: { + light: '#eeeeee', + dark: '#111111', + }, + surface: { + light: '{colors.brand}', + dark: '{colors.brand}', + }, + }, + })); + + expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0); + expect(result.designSystem.colors.get('surface')?.hex).toBe('#eeeeee'); + expect(result.designSystem.modes?.get('dark')?.colors.get('surface')?.hex).toBe('#111111'); + }); + + it('resolves dimension mode objects', () => { + const result = handler.execute(makeParsed({ + themes: ['light', 'dark'], + defaultTheme: 'light', + rounded: { + card: { + light: '4px', + dark: '8px', + }, + }, + spacing: { + gutter: { + light: '16px', + dark: '20px', + }, + }, + })); + + expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0); + expect(result.designSystem.rounded.get('card')?.value).toBe(4); + expect(result.designSystem.modes?.get('dark')?.rounded.get('card')?.value).toBe(8); + expect(result.designSystem.spacing.get('gutter')?.value).toBe(16); + expect(result.designSystem.modes?.get('dark')?.spacing.get('gutter')?.value).toBe(20); + }); + + it('emits a finding when a mode object is missing the default-theme key', () => { + const result = handler.execute(makeParsed({ + themes: ['light', 'dark'], + defaultTheme: 'light', + colors: { + surface: { + dark: '#111111', + }, + }, + })); + + expect(result.findings.some(f => ( + f.severity === 'error' && + f.path === 'colors.surface' && + f.message.includes("default-theme key 'light'") + ))).toBe(true); + }); + + it('emits a finding for unknown mode keys', () => { + const result = handler.execute(makeParsed({ + themes: ['light', 'dark'], + defaultTheme: 'light', + colors: { + surface: { + light: '#ffffff', + sepia: '#f4ecd8', + }, + }, + })); + + expect(result.findings.some(f => ( + f.severity === 'error' && + f.path === 'colors.surface.sepia' && + f.message.includes("Unknown theme mode 'sepia'") + ))).toBe(true); + }); + + it('emits a structured finding for non-scalar mode values', () => { + const result = handler.execute(makeParsed({ + themes: ['light', 'dark'], + defaultTheme: 'light', + colors: { + surface: { + light: '#ffffff', + dark: { value: '#111111' }, + }, + }, + })); + + expect(result.findings.some(f => ( + f.severity === 'error' && + f.path === 'colors.surface.dark' && + f.message.includes('must be a scalar value') + ))).toBe(true); + expect(result.designSystem.colors.get('surface')?.hex).toBe('#ffffff'); + }); + }); + // ── Cycle 10: Resolve single-level token reference ──────────────── describe('single-level token reference resolution', () => { it('resolves a direct {section.token} reference in components', () => { @@ -665,4 +790,4 @@ describe('ModelHandler', () => { expect(result.designSystem.colors.has(path)).toBe(true); }); }); -}); \ No newline at end of file +}); diff --git a/packages/cli/src/linter/model/handler.ts b/packages/cli/src/linter/model/handler.ts index bee269ff..9d5fcccf 100644 --- a/packages/cli/src/linter/model/handler.ts +++ b/packages/cli/src/linter/model/handler.ts @@ -23,9 +23,18 @@ import type { ResolvedValue, ComponentDef, Finding, + ThemeModeState, + TailwindV4ModeCategory, } from './spec.js'; -import { isValidColor, isParseableDimension, isTokenReference, parseDimensionParts } from './spec.js'; +import { + isValidColor, + isParseableDimension, + isTokenReference, + parseDimensionParts, + resetTailwindV4ModeRegistry, + registerTailwindV4ModeRegistry, +} from './spec.js'; import { parseCssColor } from './color-parser.js'; import { @@ -35,6 +44,22 @@ import { const SCHEMA_KEY_SET: ReadonlySet = new Set(SCHEMA_KEYS); +type PrimitiveCategory = 'colors' | 'rounded' | 'spacing'; + +interface RawModeEntry { + value: unknown; + path: string; + reportFindings: boolean; +} + +interface RawModeBucket { + colors: Map; + rounded: Map; + spacing: Map; +} + +type ModeTokenNames = Record>; + /** * Builds a resolved DesignSystemState from parsed YAML tokens. * Handles color parsing, dimension parsing, typography construction, @@ -50,26 +75,21 @@ export class ModelHandler implements ModelSpec { const typography = new Map(); const rounded = new Map(); const spacing = new Map(); + const themes = normalizeThemes(input.themes); + const defaultTheme = normalizeDefaultTheme(input.defaultTheme, themes, findings); + const modeRaw = createRawModeBuckets(themes); + const modeTokenNames = createModeTokenNames(); + resetTailwindV4ModeRegistry(); // ── Phase 1: Resolve primitive tokens ────────────────────────── // Colors if (input.colors) { - forEachLeaf(input.colors, (name, raw) => { - if (typeof raw === 'string' && isTokenReference(raw)) { - // Store raw reference for later resolution - symbolTable.set(`colors.${name}`, raw); - } else if (isValidColor(raw)) { - const resolved = parseColor(raw); - colors.set(name, resolved); - symbolTable.set(`colors.${name}`, resolved); + forEachTokenValue(input.colors, themes, (name, raw) => { + if (isModeValueObject(raw, themes)) { + processColorModeValue(name, raw, themes, defaultTheme, colors, symbolTable, modeRaw, modeTokenNames, findings); } else { - findings.push({ - severity: 'error', - path: `colors.${name}`, - message: `'${raw}' is not a valid color. Expected a CSS color value (e.g., #ffffff, rgb(0 0 0), oklch(0.5 0.2 240)).`, - }); - // Store as-is for fallback - symbolTable.set(`colors.${name}`, raw); + processColorScalar(name, raw, `colors.${name}`, colors, symbolTable, findings); + recordScalarModeValue(modeRaw, 'colors', name, raw, `colors.${name}`); } }, '', 0, findings, 'colors'); } @@ -85,42 +105,24 @@ export class ModelHandler implements ModelSpec { // Rounded if (input.rounded) { - forEachLeaf(input.rounded, (name, raw) => { - if (typeof raw === 'string') { - if (isParseableDimension(raw)) { - const resolved = parseDimension(raw); - if (resolved.unit !== 'px' && resolved.unit !== 'rem' && resolved.unit !== 'em') { - findings.push({ - severity: 'error', - path: `rounded.${name}`, - message: `'${raw}' has an invalid unit '${resolved.unit}'. Only px, rem, and em are allowed.`, - }); - } - rounded.set(name, resolved); - symbolTable.set(`rounded.${name}`, resolved); - } else if (!isTokenReference(raw)) { - findings.push({ - severity: 'error', - path: `rounded.${name}`, - message: `'${raw}' is not a valid dimension.`, - }); - symbolTable.set(`rounded.${name}`, raw); - } else { - symbolTable.set(`rounded.${name}`, raw); - } + forEachTokenValue(input.rounded, themes, (name, raw) => { + if (isModeValueObject(raw, themes)) { + processDimensionModeValue('rounded', name, raw, themes, defaultTheme, rounded, symbolTable, modeRaw, modeTokenNames, findings); + } else { + processRoundedScalar(name, raw, `rounded.${name}`, rounded, symbolTable, findings); + recordScalarModeValue(modeRaw, 'rounded', name, raw, `rounded.${name}`); } }, '', 0, findings, 'rounded'); } // Spacing if (input.spacing) { - forEachLeaf(input.spacing, (name, raw) => { - if (isParseableDimension(raw)) { - const resolved = parseDimension(raw); - spacing.set(name, resolved); - symbolTable.set(`spacing.${name}`, resolved); + forEachTokenValue(input.spacing, themes, (name, raw) => { + if (isModeValueObject(raw, themes)) { + processDimensionModeValue('spacing', name, raw, themes, defaultTheme, spacing, symbolTable, modeRaw, modeTokenNames, findings); } else { - symbolTable.set(`spacing.${name}`, raw); + processSpacingScalar(name, raw, `spacing.${name}`, spacing, symbolTable); + recordScalarModeValue(modeRaw, 'spacing', name, raw, `spacing.${name}`); } }, '', 0, findings, 'spacing'); } @@ -128,9 +130,10 @@ export class ModelHandler implements ModelSpec { // ── Phase 2: Resolve chained color references ────────────────── // Iterate color entries that are still raw references and resolve them if (input.colors) { - forEachLeaf(input.colors, (name, raw) => { - if (typeof raw === 'string' && isTokenReference(raw)) { - const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set()); + forEachTokenValue(input.colors, themes, (name, raw) => { + const reference = getDefaultReference(raw, themes, defaultTheme); + if (reference) { + const resolved = resolveReference(symbolTable, reference.slice(1, -1), new Set()); if (resolved !== null && typeof resolved === 'object' && 'type' in resolved && resolved.type === 'color') { colors.set(name, resolved as ResolvedColor); symbolTable.set(`colors.${name}`, resolved); @@ -141,9 +144,10 @@ export class ModelHandler implements ModelSpec { // Resolve chained rounded references if (input.rounded) { - forEachLeaf(input.rounded, (name, raw) => { - if (typeof raw === 'string' && isTokenReference(raw)) { - const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set()); + forEachTokenValue(input.rounded, themes, (name, raw) => { + const reference = getDefaultReference(raw, themes, defaultTheme); + if (reference) { + const resolved = resolveReference(symbolTable, reference.slice(1, -1), new Set()); if ( resolved !== null && typeof resolved === 'object' && @@ -159,9 +163,10 @@ export class ModelHandler implements ModelSpec { // Resolve chained spacing references if (input.spacing) { - forEachLeaf(input.spacing, (name, raw) => { - if (typeof raw === 'string' && isTokenReference(raw)) { - const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set()); + forEachTokenValue(input.spacing, themes, (name, raw) => { + const reference = getDefaultReference(raw, themes, defaultTheme); + if (reference) { + const resolved = resolveReference(symbolTable, reference.slice(1, -1), new Set()); if ( resolved !== null && typeof resolved === 'object' && @@ -175,6 +180,9 @@ export class ModelHandler implements ModelSpec { }); } + const modes = resolveModeStates(themes, modeRaw, findings); + registerModeRegistry(modes, defaultTheme, modeTokenNames); + // ── Phase 3: Build components ────────────────────────────────── const components = new Map(); if (input.components) { @@ -229,11 +237,14 @@ export class ModelHandler implements ModelSpec { designSystem: { name: input.name, description: input.description, + themes: themes.length > 0 ? themes : undefined, + defaultTheme, colors, typography, rounded, spacing, components, + modes: modes.size > 0 ? modes : undefined, symbolTable, sections: input.sections, unknownKeys, @@ -265,6 +276,411 @@ export class ModelHandler implements ModelSpec { // ── Pure utility functions ───────────────────────────────────────── +function normalizeThemes(themes: string[] | undefined): string[] { + const normalized: string[] = []; + for (const theme of themes ?? []) { + const trimmed = theme.trim(); + if (trimmed && !normalized.includes(trimmed)) { + normalized.push(trimmed); + } + } + return normalized; +} + +function normalizeDefaultTheme(defaultTheme: string | undefined, themes: string[], findings: Finding[]): string | undefined { + if (themes.length === 0) return defaultTheme; + + const normalizedDefault = defaultTheme?.trim() || themes[0]; + if (defaultTheme && !themes.includes(defaultTheme)) { + findings.push({ + severity: 'error', + path: 'default-theme', + message: `default-theme '${defaultTheme}' is not listed in themes.`, + }); + } + + return normalizedDefault && themes.includes(normalizedDefault) ? normalizedDefault : themes[0]; +} + +function createRawModeBuckets(themes: string[]): Map { + const buckets = new Map(); + for (const theme of themes) { + buckets.set(theme, { + colors: new Map(), + rounded: new Map(), + spacing: new Map(), + }); + } + return buckets; +} + +function createModeTokenNames(): ModeTokenNames { + return { + colors: new Set(), + rounded: new Set(), + spacing: new Set(), + }; +} + +function processColorScalar( + name: string, + raw: unknown, + path: string, + colors: Map, + symbolTable: Map, + findings: Finding[], + reportFindings = true, +): void { + if (typeof raw === 'string' && isTokenReference(raw)) { + symbolTable.set(`colors.${name}`, raw); + } else if (isValidColor(raw as string)) { + const resolved = parseColor(raw as string); + colors.set(name, resolved); + symbolTable.set(`colors.${name}`, resolved); + } else { + if (reportFindings) { + findings.push({ + severity: 'error', + path, + message: `'${raw}' is not a valid color. Expected a CSS color value (e.g., #ffffff, rgb(0 0 0), oklch(0.5 0.2 240)).`, + }); + } + symbolTable.set(`colors.${name}`, raw as ResolvedValue); + } +} + +function processRoundedScalar( + name: string, + raw: unknown, + path: string, + rounded: Map, + symbolTable: Map, + findings: Finding[], + reportFindings = true, +): void { + if (typeof raw !== 'string') { + return; + } + + if (isParseableDimension(raw)) { + const resolved = parseDimension(raw); + if (resolved.unit !== 'px' && resolved.unit !== 'rem' && resolved.unit !== 'em') { + findings.push({ + severity: 'error', + path, + message: `'${raw}' has an invalid unit '${resolved.unit}'. Only px, rem, and em are allowed.`, + }); + } + rounded.set(name, resolved); + symbolTable.set(`rounded.${name}`, resolved); + } else if (!isTokenReference(raw)) { + if (reportFindings) { + findings.push({ + severity: 'error', + path, + message: `'${raw}' is not a valid dimension.`, + }); + } + symbolTable.set(`rounded.${name}`, raw); + } else { + symbolTable.set(`rounded.${name}`, raw); + } +} + +function processSpacingScalar( + name: string, + raw: unknown, + path: string, + spacing: Map, + symbolTable: Map, + findings?: Finding[], + reportFindings = false, +): void { + if (isParseableDimension(raw as string)) { + const resolved = parseDimension(raw as string); + spacing.set(name, resolved); + symbolTable.set(`spacing.${name}`, resolved); + } else { + if (reportFindings && findings) { + findings.push({ + severity: 'error', + path, + message: `'${raw}' is not a valid dimension.`, + }); + } + symbolTable.set(`spacing.${name}`, raw as ResolvedValue); + } +} + +function processColorModeValue( + name: string, + raw: Record, + themes: string[], + defaultTheme: string | undefined, + colors: Map, + symbolTable: Map, + modeRaw: Map, + modeTokenNames: ModeTokenNames, + findings: Finding[], +): void { + validateModeObject('colors', name, raw, themes, defaultTheme, findings); + modeTokenNames.colors.add(name); + + const defaultRaw = defaultTheme && Object.prototype.hasOwnProperty.call(raw, defaultTheme) + ? raw[defaultTheme] + : undefined; + if (defaultTheme && defaultRaw !== undefined && isScalarModeValue(defaultRaw)) { + processColorScalar(name, defaultRaw, `colors.${name}.${defaultTheme}`, colors, symbolTable, findings); + } + + recordModeObjectValues(modeRaw, 'colors', name, raw, themes, defaultTheme); +} + +function processDimensionModeValue( + category: 'rounded' | 'spacing', + name: string, + raw: Record, + themes: string[], + defaultTheme: string | undefined, + target: Map, + symbolTable: Map, + modeRaw: Map, + modeTokenNames: ModeTokenNames, + findings: Finding[], +): void { + validateModeObject(category, name, raw, themes, defaultTheme, findings); + modeTokenNames[category].add(name); + + const defaultRaw = defaultTheme && Object.prototype.hasOwnProperty.call(raw, defaultTheme) + ? raw[defaultTheme] + : undefined; + if (defaultTheme && defaultRaw !== undefined && isScalarModeValue(defaultRaw)) { + if (category === 'rounded') { + processRoundedScalar(name, defaultRaw, `${category}.${name}.${defaultTheme}`, target, symbolTable, findings); + } else { + processSpacingScalar(name, defaultRaw, `${category}.${name}.${defaultTheme}`, target, symbolTable, findings, true); + } + } + + recordModeObjectValues(modeRaw, category, name, raw, themes, defaultTheme); +} + +function validateModeObject( + category: PrimitiveCategory, + name: string, + raw: Record, + themes: string[], + defaultTheme: string | undefined, + findings: Finding[], +): void { + const themeSet = new Set(themes); + for (const [mode, value] of Object.entries(raw)) { + const path = `${category}.${name}.${mode}`; + if (!themeSet.has(mode)) { + findings.push({ + severity: 'error', + path, + message: `Unknown theme mode '${mode}'. Expected one of: ${themes.join(', ')}.`, + }); + } else if (!isScalarModeValue(value)) { + findings.push({ + severity: 'error', + path, + message: `Theme mode '${mode}' must be a scalar value.`, + }); + } + } + + if (defaultTheme && !Object.prototype.hasOwnProperty.call(raw, defaultTheme)) { + findings.push({ + severity: 'error', + path: `${category}.${name}`, + message: `Theme mode object is missing the default-theme key '${defaultTheme}'.`, + }); + } +} + +function recordScalarModeValue( + modeRaw: Map, + category: PrimitiveCategory, + name: string, + value: unknown, + path: string, +): void { + for (const bucket of modeRaw.values()) { + bucket[category].set(name, { value, path, reportFindings: false }); + } +} + +function recordModeObjectValues( + modeRaw: Map, + category: PrimitiveCategory, + name: string, + raw: Record, + themes: string[], + defaultTheme: string | undefined, +): void { + const defaultRaw = defaultTheme && Object.prototype.hasOwnProperty.call(raw, defaultTheme) && isScalarModeValue(raw[defaultTheme]) + ? raw[defaultTheme] + : undefined; + + for (const theme of themes) { + const bucket = modeRaw.get(theme); + if (!bucket) continue; + + const hasThemeValue = Object.prototype.hasOwnProperty.call(raw, theme); + const themeValue = hasThemeValue ? raw[theme] : defaultRaw; + if (themeValue === undefined || !isScalarModeValue(themeValue)) continue; + + bucket[category].set(name, { + value: themeValue, + path: `${category}.${name}.${hasThemeValue ? theme : defaultTheme}`, + reportFindings: hasThemeValue && theme !== defaultTheme, + }); + } +} + +function getDefaultReference(raw: unknown, themes: string[], defaultTheme: string | undefined): string | undefined { + if (typeof raw === 'string' && isTokenReference(raw)) return raw; + if (!isModeValueObject(raw, themes) || !defaultTheme) return undefined; + const value = raw[defaultTheme]; + return typeof value === 'string' && isTokenReference(value) ? value : undefined; +} + +function resolveModeStates( + themes: string[], + modeRaw: Map, + findings: Finding[], +): Map { + const modes = new Map(); + + for (const theme of themes) { + const bucket = modeRaw.get(theme); + if (!bucket) continue; + + const modeState: ThemeModeState = { + colors: new Map(), + rounded: new Map(), + spacing: new Map(), + symbolTable: new Map(), + }; + + for (const [name, entry] of bucket.colors) { + processColorScalar(name, entry.value, entry.path, modeState.colors, modeState.symbolTable, findings, entry.reportFindings); + } + for (const [name, entry] of bucket.rounded) { + processRoundedScalar(name, entry.value, entry.path, modeState.rounded, modeState.symbolTable, findings, entry.reportFindings); + } + for (const [name, entry] of bucket.spacing) { + processSpacingScalar(name, entry.value, entry.path, modeState.spacing, modeState.symbolTable, findings, entry.reportFindings); + } + + modes.set(theme, modeState); + } + + for (const modeState of modes.values()) { + resolveModeReferences(modeState); + } + + return modes; +} + +function resolveModeReferences(modeState: ThemeModeState): void { + for (const [path, raw] of modeState.symbolTable) { + if (typeof raw !== 'string' || !isTokenReference(raw)) continue; + + const resolved = resolveReference(modeState.symbolTable, raw.slice(1, -1), new Set()); + if (resolved === null || typeof resolved !== 'object' || !('type' in resolved)) continue; + + if (path.startsWith('colors.') && resolved.type === 'color') { + const name = path.slice('colors.'.length); + modeState.colors.set(name, resolved as ResolvedColor); + modeState.symbolTable.set(path, resolved); + } else if (path.startsWith('rounded.') && resolved.type === 'dimension') { + const name = path.slice('rounded.'.length); + modeState.rounded.set(name, resolved as ResolvedDimension); + modeState.symbolTable.set(path, resolved); + } else if (path.startsWith('spacing.') && resolved.type === 'dimension') { + const name = path.slice('spacing.'.length); + modeState.spacing.set(name, resolved as ResolvedDimension); + modeState.symbolTable.set(path, resolved); + } + } +} + +function registerModeRegistry( + modes: Map, + defaultTheme: string | undefined, + modeTokenNames: ModeTokenNames, +): void { + if (!defaultTheme || modes.size === 0) return; + const defaultMode = modes.get(defaultTheme); + if (!defaultMode) return; + + const tokens: Partial }>>> = {}; + + registerCategoryModeTokens(tokens, 'colors', modeTokenNames.colors, modes, defaultTheme, defaultMode, valueToCss); + registerCategoryModeTokens(tokens, 'borderRadius', modeTokenNames.rounded, modes, defaultTheme, defaultMode, valueToCss); + registerCategoryModeTokens(tokens, 'spacing', modeTokenNames.spacing, modes, defaultTheme, defaultMode, valueToCss); + + if (Object.keys(tokens).length > 0) { + registerTailwindV4ModeRegistry({ defaultTheme, tokens }); + } +} + +function registerCategoryModeTokens( + tokens: Partial }>>>, + outputCategory: TailwindV4ModeCategory, + names: Set, + modes: Map, + defaultTheme: string, + defaultMode: ThemeModeState, + serialize: (category: TailwindV4ModeCategory, state: ThemeModeState, name: string) => string | undefined, +): void { + for (const name of names) { + const defaultValue = serialize(outputCategory, defaultMode, name); + if (!defaultValue) continue; + + const modeValues: Record = {}; + for (const [mode, modeState] of modes) { + if (mode === defaultTheme) continue; + const modeValue = serialize(outputCategory, modeState, name); + if (modeValue && modeValue !== defaultValue) { + modeValues[mode] = modeValue; + } + } + + if (Object.keys(modeValues).length > 0) { + tokens[outputCategory] ??= {}; + tokens[outputCategory]![name] = { defaultValue, modes: modeValues }; + } + } +} + +function valueToCss(category: TailwindV4ModeCategory, state: ThemeModeState, name: string): string | undefined { + if (category === 'colors') { + return state.colors.get(name)?.hex; + } + + const value = category === 'borderRadius' + ? state.rounded.get(name) + : state.spacing.get(name); + return value ? `${value.value}${value.unit}` : undefined; +} + +function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isScalarModeValue(value: unknown): boolean { + return value === null || typeof value !== 'object'; +} + +function isModeValueObject(value: unknown, themes: string[]): value is Record { + if (themes.length === 0 || !isPlainRecord(value)) return false; + return Object.keys(value).some(key => themes.includes(key)); +} + /** * Parse a CSS color string into a ResolvedColor with RGB + WCAG luminance. */ @@ -406,11 +822,14 @@ export function contrastRatio(a: ResolvedColor, b: ResolvedColor): number { } /** - * Recursively iterate over an object and call a function for each leaf node. - * Leaf node paths are dot-separated (e.g. "background.light"). + * Recursively iterate over a token object and call a function for each scalar + * token or mode object. Leaf node paths are dot-separated (e.g. + * "background.light"). When themes are declared, an object with a declared + * theme key is treated as one mode-aware token rather than nested tokens. */ -function forEachLeaf( +function forEachTokenValue( obj: Record, + themes: string[], fn: (path: string, value: any) => void, prefix = '', depth = 0, @@ -432,10 +851,12 @@ function forEachLeaf( } for (const [key, value] of Object.entries(obj)) { const fullPath = prefix ? `${prefix}.${key}` : key; - if (value !== null && typeof value === 'object' && !Array.isArray(value)) { - forEachLeaf(value, fn, fullPath, depth + 1, findings, rootPath); + if (isModeValueObject(value, themes)) { + fn(fullPath, value); + } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + forEachTokenValue(value, themes, fn, fullPath, depth + 1, findings, rootPath); } else { fn(fullPath, value); } } -} \ No newline at end of file +} diff --git a/packages/cli/src/linter/model/spec.ts b/packages/cli/src/linter/model/spec.ts index 115ee47c..bd907b39 100644 --- a/packages/cli/src/linter/model/spec.ts +++ b/packages/cli/src/linter/model/spec.ts @@ -63,6 +63,13 @@ export interface ResolvedTypography { export type ResolvedValue = ResolvedColor | ResolvedDimension | ResolvedTypography | string | number | boolean; +export interface ThemeModeState { + colors: Map; + rounded: Map; + spacing: Map; + symbolTable: Map; +} + // ── Re-exported from spec-config (single source of truth) ───────── export const VALID_TYPOGRAPHY_PROPS = _VALID_TYPOGRAPHY_PROPS; export const VALID_COMPONENT_SUB_TOKENS = _VALID_COMPONENT_SUB_TOKENS; @@ -71,11 +78,15 @@ export const VALID_COMPONENT_SUB_TOKENS = _VALID_COMPONENT_SUB_TOKENS; export interface DesignSystemState { name?: string | undefined; description?: string | undefined; + themes?: string[] | undefined; + defaultTheme?: string | undefined; colors: Map; typography: Map; rounded: Map; spacing: Map; components: Map; + /** Per-theme resolved primitive tokens. Includes the default theme when themes are declared. */ + modes?: Map | undefined; /** Flat lookup: "colors.primary" → ResolvedColor */ symbolTable: Map; /** Markdown heading names found in the document */ @@ -92,6 +103,34 @@ export interface ComponentDef { unresolvedRefs: string[]; } +export type TailwindV4ModeCategory = 'colors' | 'borderRadius' | 'spacing'; + +export interface TailwindV4ModeToken { + defaultValue: string; + modes: Record; +} + +export type TailwindV4ModeTokens = Partial>>; + +export interface TailwindV4ModeRegistry { + defaultTheme?: string | undefined; + tokens: TailwindV4ModeTokens; +} + +let tailwindV4ModeRegistry: TailwindV4ModeRegistry = { tokens: {} }; + +export function resetTailwindV4ModeRegistry(): void { + tailwindV4ModeRegistry = { tokens: {} }; +} + +export function registerTailwindV4ModeRegistry(registry: TailwindV4ModeRegistry): void { + tailwindV4ModeRegistry = registry; +} + +export function getTailwindV4ModeRegistry(): TailwindV4ModeRegistry { + return tailwindV4ModeRegistry; +} + // ── ERROR CODES ──────────────────────────────────────────────────── export const ModelErrorCode = z.enum([ 'INVALID_COLOR', diff --git a/packages/cli/src/linter/parser/handler.test.ts b/packages/cli/src/linter/parser/handler.test.ts index 96e57ff6..d881e4b8 100644 --- a/packages/cli/src/linter/parser/handler.test.ts +++ b/packages/cli/src/linter/parser/handler.test.ts @@ -36,6 +36,30 @@ Some markdown content here. expect(result.data.colors?.['primary']).toBe('#647D66'); } }); + + it('captures declared theme modes from frontmatter', () => { + const input = `--- +name: Modeful +themes: + - light + - dark +default-theme: light +colors: + surface: + light: "#ffffff" + dark: "#111111" +---`; + const result = handler.execute({ content: input }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.themes).toEqual(['light', 'dark']); + expect(result.data.defaultTheme).toBe('light'); + expect(result.data.colors?.['surface']).toEqual({ + light: '#ffffff', + dark: '#111111', + }); + } + }); }); // ── Cycle 3: Code block extraction ──────────────────────────────── diff --git a/packages/cli/src/linter/parser/handler.ts b/packages/cli/src/linter/parser/handler.ts index 4475aabe..0ba0dfb4 100644 --- a/packages/cli/src/linter/parser/handler.ts +++ b/packages/cli/src/linter/parser/handler.ts @@ -194,6 +194,10 @@ export class ParserHandler implements ParserSpec { version: typeof raw['version'] === 'string' ? raw['version'] : undefined, name: typeof raw['name'] === 'string' ? raw['name'] : undefined, description: typeof raw['description'] === 'string' ? raw['description'] : undefined, + themes: Array.isArray(raw['themes']) && raw['themes'].every(theme => typeof theme === 'string') + ? raw['themes'] as string[] + : undefined, + defaultTheme: typeof raw['default-theme'] === 'string' ? raw['default-theme'] : undefined, colors: raw['colors'] as Record | undefined, typography: raw['typography'] as Record> | undefined, rounded: raw['rounded'] as Record | undefined, diff --git a/packages/cli/src/linter/parser/spec.ts b/packages/cli/src/linter/parser/spec.ts index 6e84d121..c6d8b97b 100644 --- a/packages/cli/src/linter/parser/spec.ts +++ b/packages/cli/src/linter/parser/spec.ts @@ -13,6 +13,7 @@ // limitations under the License. import { z } from 'zod'; +import { THEME_MODE_KEYS } from '../spec-config.js'; // ── INPUT ────────────────────────────────────────────────────────── export const ParserInputSchema = z.object({ @@ -42,6 +43,8 @@ export interface ParsedDesignSystem { version?: string | undefined; name?: string | undefined; description?: string | undefined; + themes?: string[] | undefined; + defaultTheme?: string | undefined; colors?: Record | undefined; typography?: Record> | undefined; rounded?: Record | undefined; @@ -61,6 +64,7 @@ export const SCHEMA_KEYS = [ 'version', 'name', 'description', + ...THEME_MODE_KEYS, 'colors', 'typography', 'rounded', diff --git a/packages/cli/src/linter/spec-config.ts b/packages/cli/src/linter/spec-config.ts index b146a47b..5b55519b 100644 --- a/packages/cli/src/linter/spec-config.ts +++ b/packages/cli/src/linter/spec-config.ts @@ -167,6 +167,9 @@ export const VALID_TYPOGRAPHY_PROPS = TYPOGRAPHY_PROPERTIES.map(p => p.name); /** Valid component sub-token names (for linter validation). */ export const VALID_COMPONENT_SUB_TOKENS = COMPONENT_SUB_TOKENS.map(p => p.name); +/** Top-level keys for theme mode declarations. */ +export const THEME_MODE_KEYS = ['themes', 'default-theme'] as const; + // ── Aggregate type ──────────────────────────────────────────────────── /** All config values bundled as a single object for renderer injection. */ diff --git a/packages/cli/src/linter/tailwind/v4/handler.test.ts b/packages/cli/src/linter/tailwind/v4/handler.test.ts index dbea34ae..3f587fb2 100644 --- a/packages/cli/src/linter/tailwind/v4/handler.test.ts +++ b/packages/cli/src/linter/tailwind/v4/handler.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect } from 'bun:test'; import { TailwindV4EmitterHandler } from './handler.js'; +import { serializeToCss } from './serialize.js'; import { ModelHandler } from '../../model/handler.js'; import type { ParsedDesignSystem } from '../../parser/spec.js'; @@ -41,6 +42,38 @@ describe('TailwindV4EmitterHandler', () => { expect(result.data.theme.colors?.['primary']).toBe('#647d66'); expect(result.data.theme.colors?.['secondary']).toBe('#ff0000'); }); + + it('serializes scalar-only themes identically to the previous Tailwind v4 output', () => { + const state = buildState({ + colors: { primary: '#647D66' }, + }); + const result = emitter.execute(state); + if (!result.success) throw new Error('Expected success'); + expect(serializeToCss(result.data.theme)).toBe('@theme {\n --color-primary: #647d66;\n}\n'); + }); + + it('serializes default and alternate color modes to CSS variable overrides', () => { + const state = buildState({ + themes: ['light', 'dark'], + defaultTheme: 'light', + colors: { + surface: { + light: '#ffffff', + dark: '#111111', + }, + }, + }); + const result = emitter.execute(state); + if (!result.success) throw new Error('Expected success'); + const css = serializeToCss(result.data.theme); + + expect(result.data.theme.colors?.['surface']).toBe('#ffffff'); + expect(css).toContain('@theme {\n --color-surface: #ffffff;\n}\n'); + expect(css).toContain(':root {\n --color-surface: #ffffff;\n}\n'); + expect(css).toContain('@media (prefers-color-scheme: dark)'); + expect(css).toContain(':root:not([data-theme])'); + expect(css).toContain('[data-theme="dark"] {\n --color-surface: #111111;\n}\n'); + }); }); describe('typography mapping', () => { @@ -117,6 +150,31 @@ describe('TailwindV4EmitterHandler', () => { expect(theme.spacing?.['gutter-s']).toBe('8px'); expect(theme.spacing?.['gutter-l']).toBe('16px'); }); + + it('serializes alternate dimension modes to CSS variable overrides', () => { + const state = buildState({ + themes: ['light', 'dark'], + defaultTheme: 'light', + rounded: { + card: { + light: '4px', + dark: '8px', + }, + }, + spacing: { + gutter: { + light: '16px', + dark: '20px', + }, + }, + }); + const result = emitter.execute(state); + if (!result.success) throw new Error('Expected success'); + const css = serializeToCss(result.data.theme); + + expect(css).toContain(':root {\n --radius-card: 4px;\n --spacing-gutter: 16px;\n}\n'); + expect(css).toContain('[data-theme="dark"] {\n --radius-card: 8px;\n --spacing-gutter: 20px;\n}\n'); + }); }); describe('empty state', () => { diff --git a/packages/cli/src/linter/tailwind/v4/serialize.ts b/packages/cli/src/linter/tailwind/v4/serialize.ts index 49e6a51d..1c067cba 100644 --- a/packages/cli/src/linter/tailwind/v4/serialize.ts +++ b/packages/cli/src/linter/tailwind/v4/serialize.ts @@ -13,6 +13,8 @@ // limitations under the License. import type { TailwindV4ThemeData } from './spec.js'; +import { getTailwindV4ModeRegistry } from '../../model/spec.js'; +import type { TailwindV4ModeCategory, TailwindV4ModeRegistry } from '../../model/spec.js'; // Category → CSS-variable prefix. Iteration order of this array is the output order. const CATEGORIES: ReadonlyArray = [ @@ -32,6 +34,13 @@ const CATEGORIES: ReadonlyArray = * be done by the handler before calling this). */ export function serializeToCss(data: TailwindV4ThemeData): string { + const lines = collectThemeLines(data); + const themeCss = lines.length === 0 ? '@theme {\n}\n' : `@theme {\n${lines.join('\n')}\n}\n`; + const modeCss = serializeModeOverrides(data, getTailwindV4ModeRegistry()); + return modeCss ? `${themeCss}\n${modeCss}` : themeCss; +} + +function collectThemeLines(data: TailwindV4ThemeData): string[] { const lines: string[] = []; for (const [category, prefix] of CATEGORIES) { const entries = data[category]; @@ -40,6 +49,65 @@ export function serializeToCss(data: TailwindV4ThemeData): string { lines.push(` ${prefix}${name}: ${value};`); } } - if (lines.length === 0) return '@theme {\n}\n'; - return `@theme {\n${lines.join('\n')}\n}\n`; + return lines; +} + +function serializeModeOverrides(data: TailwindV4ThemeData, registry: TailwindV4ModeRegistry): string { + const defaultLines = collectRegisteredModeLines(data, registry); + if (defaultLines.length === 0) return ''; + + const blocks = [`:root {\n${defaultLines.join('\n')}\n}\n`]; + for (const mode of collectRegisteredModes(data, registry)) { + const modeLines = collectRegisteredModeLines(data, registry, mode); + if (modeLines.length === 0) continue; + + if (mode === 'dark') { + blocks.push(`@media (prefers-color-scheme: dark) {\n :root:not([data-theme]) {\n${modeLines.map(line => ` ${line}`).join('\n')}\n }\n}\n`); + } + + blocks.push(`[data-theme="${escapeAttributeValue(mode)}"] {\n${modeLines.join('\n')}\n}\n`); + } + + return blocks.join('\n'); +} + +function collectRegisteredModes(data: TailwindV4ThemeData, registry: TailwindV4ModeRegistry): string[] { + const modes = new Set(); + for (const [category] of CATEGORIES) { + const entries = data[category]; + const tokens = registry.tokens[category as TailwindV4ModeCategory]; + if (!entries || !tokens) continue; + + for (const [name, value] of Object.entries(entries)) { + const token = tokens[name]; + if (!token || token.defaultValue !== value) continue; + for (const mode of Object.keys(token.modes)) { + modes.add(mode); + } + } + } + return [...modes]; +} + +function collectRegisteredModeLines(data: TailwindV4ThemeData, registry: TailwindV4ModeRegistry, mode?: string): string[] { + const lines: string[] = []; + for (const [category, prefix] of CATEGORIES) { + const entries = data[category]; + const tokens = registry.tokens[category as TailwindV4ModeCategory]; + if (!entries || !tokens) continue; + + for (const [name, value] of Object.entries(entries)) { + const token = tokens[name]; + if (!token || token.defaultValue !== value) continue; + const modeValue = mode ? token.modes[mode] : token.defaultValue; + if (modeValue) { + lines.push(` ${prefix}${name}: ${modeValue};`); + } + } + } + return lines; +} + +function escapeAttributeValue(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }