diff --git a/.changeset/design-tokens-to-design-md.md b/.changeset/design-tokens-to-design-md.md new file mode 100644 index 00000000..b7ed074a --- /dev/null +++ b/.changeset/design-tokens-to-design-md.md @@ -0,0 +1,6 @@ +--- +"@open-codesign/core": patch +"@open-codesign/shared": patch +--- + +Prepare the design-token import. Core exports `mapTokensToDesignFields`, which turns scanned tokens into DESIGN.md colors, typography, rounded and spacing fields, with font sizes, line heights and shadows as Markdown sections. It converts `rgb()`, `hsl()` and `oklch()` colors to sRGB hex (a clamped out-of-gamut color is reported with its original value), replaces `var()` with the scanned token it names, leaves other `[data-theme]` values out, and returns what it could not map instead of logging it. Shared exports `serializeDesignMd`. Tailwind v4 `--font-*` variables are now read as font families, and a CSS variable declared in a `[data-theme]` block records that block. diff --git a/packages/core/src/brand/color.test.ts b/packages/core/src/brand/color.test.ts new file mode 100644 index 00000000..8109d30c --- /dev/null +++ b/packages/core/src/brand/color.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest'; +import { cssColorToHex } from './color.js'; + +const ok = (hex: string, clamped = false) => ({ ok: true, hex, clamped }); + +describe('cssColorToHex()', () => { + describe('hex', () => { + it.each([ + ['#fff', '#ffffff'], + ['#ABC', '#aabbcc'], + ['#FFAA00', '#ffaa00'], + ['#f00f', '#ff0000'], + ['#ff0000ff', '#ff0000'], + ])('%s -> %s', (input, hex) => { + expect(cssColorToHex(input)).toEqual(ok(hex)); + }); + + it.each(['#f008', '#ff000080'])('reports alpha for %s', (input) => { + expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'alpha' }); + }); + + it.each(['#ggg', '#12345', '#', 'fff'])('reports the format of %s', (input) => { + expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'format' }); + }); + }); + + describe('rgb()', () => { + it.each([ + ['rgb(255, 0, 0)', '#ff0000'], + ['rgb(255 128 0)', '#ff8000'], + ['rgba(255, 0, 0, 1)', '#ff0000'], + ['rgb(255 0 0 / 100%)', '#ff0000'], + ['rgb(100% 50% 0%)', '#ff8000'], + ['RGB(0,0,0)', '#000000'], + ])('%s -> %s', (input, hex) => { + expect(cssColorToHex(input)).toEqual(ok(hex)); + }); + + it.each([ + 'rgb(255 0 0 / 50%)', + 'rgba(0, 0, 0, .5)', + 'rgb(0 0 0 / 0)', + ])('reports alpha for %s', (input) => { + expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'alpha' }); + }); + + it('clamps a channel outside 0-255 and says so', () => { + expect(cssColorToHex('rgb(300 -4 0)')).toEqual(ok('#ff0000', true)); + }); + + it.each([ + 'rgb(1 2)', + 'rgb(1 2 3 4 5)', + 'rgb(1 2 3 4)', + 'rgb(1, 2, 3, 4, 5)', + 'rgb(1 2 3 / 0.5 / 0.2)', + 'rgb()', + 'rgb(1deg 2 3)', + 'rgb(red)', + ])('reports the format of %s', (input) => { + expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'format' }); + }); + }); + + describe('hsl()', () => { + it.each([ + ['hsl(0 100% 50%)', '#ff0000'], + ['hsl(120, 100%, 25%)', '#008000'], + ['hsl(210 50% 40%)', '#336699'], + ['hsl(30deg 80% 60%)', '#eb9947'], + ['hsl(-120 100% 50%)', '#0000ff'], + ['hsl(0.5turn 100% 50%)', '#00ffff'], + ['hsl(200grad 100% 50%)', '#00ffff'], + ['hsla(0, 0%, 100%, 1)', '#ffffff'], + ['hsl(359 100% 50%)', '#ff0004'], + ])('%s -> %s', (input, hex) => { + expect(cssColorToHex(input)).toEqual(ok(hex)); + }); + + it('reports alpha', () => { + expect(cssColorToHex('hsl(0 100% 50% / 0.5)')).toEqual({ ok: false, reason: 'alpha' }); + }); + + it.each([ + 'hsl(0 100 50)', + 'hsl(0 100%)', + 'hsl(red 100% 50%)', + ])('reports the format of %s', (input) => { + expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'format' }); + }); + }); + + // Expected values come from a separate implementation: the CSS Color 4 reference route + // (OKLab -> LMS -> XYZ D65 -> linear sRGB), not the matrices used in color.ts. The Tailwind + // v4 entries match the hex values Tailwind documents for those palette steps. + describe('oklch()', () => { + it.each([ + ['oklch(1 0 0)', '#ffffff'], + ['oklch(0 0 0)', '#000000'], + ['oklch(0.5 0 0)', '#636363'], + ['oklch(0.627955 0.257683 29.2339)', '#ff0000'], + ['oklch(0.452014 0.313214 264.052)', '#0000ff'], + ['oklch(0.86644 0.294827 142.495)', '#00ff00'], + ['oklch(0.7 0.15 250)', '#4ba3f7'], + ['oklch(0.5 0.1 0)', '#904961'], + ['oklch(0.75 0.12 120)', '#a4b95e'], + ['oklch(0.637 0.237 25.331)', '#fb2c36'], + ['oklch(0.551 0.027 264.364)', '#6a7282'], + ['oklch(0.97 0.014 254.604)', '#eff6ff'], + ])('in gamut: %s -> %s', (input, hex) => { + expect(cssColorToHex(input)).toEqual(ok(hex)); + }); + + it.each([ + ['oklch(0.7 0.4 150)', '#00d600'], + ['oklch(0.9 0.35 200)', '#00ffff'], + ['oklch(0.3 0.3 30)', '#8f0000'], + ])('out of gamut: %s is clamped to %s and says so', (input, hex) => { + expect(cssColorToHex(input)).toEqual(ok(hex, true)); + }); + + // Only a clamp that changes a channel by a full step counts: the hex is the same as the + // rounded unclamped color, so a channel 0.06 steps outside sRGB is not reported. + it.each([ + ['oklch(0.627955 0.2577 29.2339)', false], + ['oklch(0.627955 0.258 29.2339)', true], + ['oklch(1.001 0 0)', false], + ['oklch(1.002 0 0)', true], + ['oklch(1.01 0 0)', true], + ])('reports %s as clamped: %s', (input, clamped) => { + const result = cssColorToHex(input); + expect(result).toMatchObject({ ok: true, clamped }); + expect(result).toHaveProperty('hex', input.startsWith('oklch(0.6') ? '#ff0000' : '#ffffff'); + }); + + it('treats an achromatic color as a gray of the same lightness', () => { + expect(cssColorToHex('oklch(0.5 0 0)')).toEqual(cssColorToHex('oklch(0.5 0 217)')); + expect(cssColorToHex('oklch(0.5 none none)')).toEqual(ok('#636363')); + }); + + it.each([ + ['oklch(50% 0 0)', '#636363'], + ['OKLCH(0.5 0% 0deg)', '#636363'], + ['oklch(0.75 30% 120)', '#a4b95e'], + ['oklch(0.75 0.12 0.3333333333turn)', '#a4b95e'], + ['oklch(0.75 0.12 133.33333grad)', '#a4b95e'], + ['oklch(0.75 0.12 2.0943951rad)', '#a4b95e'], + ['oklch(0.5 0.1 0 / 1)', '#904961'], + ['oklch(0.5 0.1 0 / 100%)', '#904961'], + ])('accepts the notation %s', (input, hex) => { + expect(cssColorToHex(input)).toEqual(ok(hex)); + }); + + it('reports alpha', () => { + expect(cssColorToHex('oklch(0.5 0.1 0 / 50%)')).toEqual({ ok: false, reason: 'alpha' }); + }); + + it.each([ + 'oklch(0.5 0.1)', + 'oklch(0.5 0.1 0 0)', + 'oklch(0.5, 0.1, 0)', + 'oklch(from red l c h)', + 'oklch(0.5 0.1 0px)', + 'oklch(0.5deg 0.1 0)', + ])('reports the format of %s', (input) => { + expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'format' }); + }); + }); + + it.each([ + 'red', + 'transparent', + 'currentColor', + 'var(--brand)', + 'lab(50 20 20)', + 'color-mix(in srgb, red, blue)', + '', + ])('reports the format of %j', (input) => { + expect(cssColorToHex(input)).toEqual({ ok: false, reason: 'format' }); + }); + // The input is CSS from a folder the user picked, which can be someone else's repository. + describe('long input', () => { + const LONG = 100_000; + const fast = (value: string) => { + const start = performance.now(); + const result = cssColorToHex(value); + expect(performance.now() - start).toBeLessThan(250); + return result; + }; + + it.each([ + ['whitespace inside the parentheses', `rgb(${'\t'.repeat(LONG)}(`], + ['whitespace before the closing parenthesis', `rgb(1${' '.repeat(LONG)}x)`], + ['digits before a bad suffix', `rgb(${'1'.repeat(LONG)}x 0 0)`], + ['an exponent without digits', `rgb(1${'e'.repeat(LONG)} 0 0)`], + ])('is rejected in linear time: %s', (_name, input) => { + expect(fast(input)).toEqual({ ok: false, reason: 'format' }); + }); + + it('still converts a long run of whitespace between valid parts', () => { + expect( + fast( + `rgb(${' '.repeat(LONG)}255${' '.repeat(LONG)}0${' '.repeat(LONG)}0${' '.repeat(LONG)})`, + ), + ).toEqual(ok('#ff0000')); + }); + }); +}); diff --git a/packages/core/src/brand/color.ts b/packages/core/src/brand/color.ts new file mode 100644 index 00000000..24855a31 --- /dev/null +++ b/packages/core/src/brand/color.ts @@ -0,0 +1,181 @@ +/** + * Converts CSS color values to the sRGB hex that DESIGN.md colors require. + * + * Handles hex, `rgb()`/`rgba()`, `hsl()`/`hsla()` and `oklch()`. A color with an alpha + * below 1 or in another format is reported, never approximated. `oklch()` goes + * OKLab → linear sRGB → gamma; a channel outside sRGB is clamped to [0, 1] and the + * result says so, so the caller can show what changed. + */ + +export type ColorConversion = + | { + ok: true; + /** Lowercase `#rrggbb`. */ + hex: string; + /** True when a channel was outside sRGB and was clamped, so the hex is not the exact color. */ + clamped: boolean; + } + | { ok: false; reason: 'alpha' | 'format' }; + +const FORMAT: ColorConversion = { ok: false, reason: 'format' }; +const NUMBER = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?'; +const COMPONENT_RE = new RegExp(`^(${NUMBER})(%|deg|grad|rad|turn)?$`, 'i'); +// The body is matched greedily and split later, so whitespace has one way to match. +const FUNCTION_RE = /^(rgba?|hsla?|oklch)\(([^()]*)\)$/i; +const HEX_RE = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; + +interface Component { + value: number; + unit: string; +} + +const isNumber = (value: number | null | undefined): value is number => typeof value === 'number'; + +// `none` is the missing-component keyword; it converts as 0. +function parseComponent(raw: string): Component | null { + if (raw.toLowerCase() === 'none') return { value: 0, unit: '' }; + const match = COMPONENT_RE.exec(raw); + if (match === null) return null; + return { value: Number(match[1]), unit: (match[2] ?? '').toLowerCase() }; +} + +function hueDegrees({ value, unit }: Component): number | null { + if (unit === '' || unit === 'deg') return value; + if (unit === 'grad') return value * 0.9; + if (unit === 'rad') return (value * 180) / Math.PI; + if (unit === 'turn') return value * 360; + return null; +} + +function alphaValue({ value, unit }: Component): number | null { + if (unit === '') return value; + return unit === '%' ? value / 100 : null; +} + +interface ColorArguments { + channels: string[]; + alpha?: string; + /** The legacy comma syntax, where a fourth value is the alpha; `oklch()` has no such syntax. */ + comma: boolean; +} + +/** Splits `a b c / d` and the legacy `a, b, c, d` into the channels and the optional alpha. */ +function splitArguments(body: string): ColorArguments | null { + if (body.includes(',')) { + const parts = body.split(',').map((part) => part.trim()); + const alpha = parts.length === 4 ? parts[3] : undefined; + const channels = alpha === undefined ? parts : parts.slice(0, 3); + return alpha === undefined ? { channels, comma: true } : { channels, alpha, comma: true }; + } + const sections = body.split('/').map((part) => part.trim()); + if (sections.length > 2) return null; + const [channelText = '', alpha] = sections; + const channels = channelText.split(/\s+/).filter((part) => part !== ''); + return alpha === undefined ? { channels, comma: false } : { channels, alpha, comma: false }; +} + +function hex(channels: [number, number, number]): ColorConversion { + const clamped = channels.some((channel) => channel < 0 || channel > 255); + const digits = channels + .map((channel) => Math.max(0, Math.min(255, channel)).toString(16).padStart(2, '0')) + .join(''); + return { ok: true, hex: `#${digits}`, clamped }; +} + +const eightBit = (unit: number): number => Math.round(unit * 255); + +function fromHex(digits: string): ColorConversion { + const full = digits.length <= 4 ? [...digits].map((digit) => digit + digit).join('') : digits; + if (full.length === 8 && Number.parseInt(full.slice(6), 16) < 255) { + return { ok: false, reason: 'alpha' }; + } + return { ok: true, hex: `#${full.slice(0, 6).toLowerCase()}`, clamped: false }; +} + +function srgbChannel({ value, unit }: Component): number | null { + if (unit === '%') return eightBit(value / 100); + return unit === '' ? Math.round(value) : null; +} + +function fromRgb(components: Component[]): ColorConversion { + const [r, g, b] = components.map(srgbChannel); + if (components.length !== 3 || !isNumber(r) || !isNumber(g) || !isNumber(b)) return FORMAT; + return hex([r, g, b]); +} + +function fromHsl(components: Component[]): ColorConversion { + const [h, s, l] = components; + if (components.length !== 3 || h === undefined || s?.unit !== '%' || l?.unit !== '%') { + return FORMAT; + } + const degrees = hueDegrees(h); + if (degrees === null) return FORMAT; + const saturation = Math.max(0, Math.min(1, s.value / 100)); + const lightness = Math.max(0, Math.min(1, l.value / 100)); + const spread = saturation * Math.min(lightness, 1 - lightness); + const channel = (offset: number): number => { + const k = (((offset + degrees / 30) % 12) + 12) % 12; + return lightness - spread * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + }; + return hex([eightBit(channel(0)), eightBit(channel(8)), eightBit(channel(4))]); +} + +function gamma(linear: number): number { + const magnitude = Math.abs(linear); + const encoded = + magnitude <= 0.0031308 ? 12.92 * magnitude : 1.055 * magnitude ** (1 / 2.4) - 0.055; + return Math.sign(linear) * encoded; +} + +function fromOklch(components: Component[]): ColorConversion { + const [lightness, chroma, hue] = components; + if ( + components.length !== 3 || + lightness === undefined || + chroma === undefined || + hue === undefined + ) { + return FORMAT; + } + const degrees = hueDegrees(hue); + const percentOrNumber = (c: Component) => c.unit === '' || c.unit === '%'; + if (degrees === null || !percentOrNumber(lightness) || !percentOrNumber(chroma)) return FORMAT; + const L = lightness.unit === '%' ? lightness.value / 100 : lightness.value; + const C = chroma.unit === '%' ? (chroma.value / 100) * 0.4 : chroma.value; + const a = C * Math.cos((degrees * Math.PI) / 180); + const b = C * Math.sin((degrees * Math.PI) / 180); + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + return hex([ + eightBit(gamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s)), + eightBit(gamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s)), + eightBit(gamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)), + ]); +} + +export function cssColorToHex(value: string): ColorConversion { + const text = value.trim(); + const hexMatch = HEX_RE.exec(text); + if (hexMatch !== null) return fromHex(hexMatch[1] ?? ''); + + const fn = FUNCTION_RE.exec(text); + if (fn === null) return FORMAT; + const name = (fn[1] ?? '').toLowerCase(); + const args = splitArguments(fn[2] ?? ''); + if (args === null || (args.comma && name === 'oklch')) return FORMAT; + const { channels, alpha } = args; + const components = channels.map(parseComponent).filter((c): c is Component => c !== null); + if (components.length !== channels.length) return FORMAT; + + if (alpha !== undefined) { + const parsed = parseComponent(alpha); + const opacity = parsed === null ? null : alphaValue(parsed); + if (opacity === null) return FORMAT; + if (opacity < 1) return { ok: false, reason: 'alpha' }; + } + + if (name.startsWith('rgb')) return fromRgb(components); + if (name.startsWith('hsl')) return fromHsl(components); + return fromOklch(components); +} diff --git a/packages/core/src/brand/cssVarExtractor.test.ts b/packages/core/src/brand/cssVarExtractor.test.ts index 93d5e434..633af44f 100644 --- a/packages/core/src/brand/cssVarExtractor.test.ts +++ b/packages/core/src/brand/cssVarExtractor.test.ts @@ -126,4 +126,31 @@ describe('extractFromCssVars()', () => { expect(byName.get('border-radius-md')?.type).toBe('radius'); expect(byName.get('border-color-muted')?.type).toBe('color'); }); + it('records the selector of a [data-theme] block and leaves :root tokens unchanged', async () => { + const dir = await mkdtemp(join(tmpdir(), 'css-vars-')); + const file = join(dir, 'tokens.css'); + await writeFile( + file, + [ + ':root {', + ' --color-bg: #ffffff;', + '}', + '[data-theme="dark"] {', + ' --color-bg: #000000;', + '}', + ':root[data-theme=contrast] {', + ' --color-bg: #111111;', + '}', + '', + ].join('\n'), + ); + const tokens = await extractFromCssVars(file); + + expect(tokens.map((t) => [t.value, t.block])).toEqual([ + ['#ffffff', undefined], + ['#000000', '[data-theme="dark"]'], + ['#111111', '[data-theme=contrast]'], + ]); + expect('block' in (tokens[0] ?? {})).toBe(false); + }); }); diff --git a/packages/core/src/brand/cssVarExtractor.ts b/packages/core/src/brand/cssVarExtractor.ts index 168f3d44..21b98b85 100644 --- a/packages/core/src/brand/cssVarExtractor.ts +++ b/packages/core/src/brand/cssVarExtractor.ts @@ -49,16 +49,20 @@ function looksLikeLength(value: string): boolean { } // Extract all CSS custom-property declarations from `:root` or `[data-theme]` -// blocks in the given CSS source text. -function extractDeclarations(source: string): Array<{ prop: string; value: string }> { - const results: Array<{ prop: string; value: string }> = []; - - const blockRe = /(?::root|\[data-theme[^\]]*\])\s*\{([^}]*)\}/g; +// blocks in the given CSS source text. `block` is the selector of a `[data-theme]` +// block and stays unset for `:root`, the default. +function extractDeclarations( + source: string, +): Array<{ prop: string; value: string; block?: string }> { + const results: Array<{ prop: string; value: string; block?: string }> = []; + + const blockRe = /(:root|\[data-theme[^\]]*\])\s*\{([^}]*)\}/g; const blockMatches = [...source.matchAll(blockRe)]; for (const blockMatch of blockMatches) { - const body = blockMatch[1]; - if (!body) continue; + const selector = blockMatch[1]; + const body = blockMatch[2]; + if (!selector || !body) continue; const declRe = /--([\w-]+)\s*:\s*([^;]+);/g; const declMatches = [...body.matchAll(declRe)]; @@ -66,7 +70,9 @@ function extractDeclarations(source: string): Array<{ prop: string; value: strin for (const dm of declMatches) { const prop = dm[1]?.trim(); const value = dm[2]?.trim(); - if (prop && value) results.push({ prop, value }); + if (prop && value) { + results.push({ prop, value, ...(selector === ':root' ? {} : { block: selector }) }); + } } } @@ -78,7 +84,7 @@ export async function extractFromCssVars(filePath: string): Promise = {}, +): DesignToken => ({ schemaVersion: 1, type, name, value, origin: 'css-vars', ...extra }); +const file = (path: string, ...tokens: DesignToken[]): TokenFile => ({ path, tokens }); +const map = (...tokens: DesignToken[]) => mapTokensToDesignFields([file('tokens.css', ...tokens)]); + +describe('mapTokensToDesignFields()', () => { + it('maps every token type to the field DESIGN.md has for it', () => { + const result = map( + token('color', 'hex', '#FFF'), + token('color', 'rgb', 'rgb(255 128 0)'), + token('color', 'hsl', 'hsl(210 50% 40%)'), + token('color', 'oklch', 'oklch(0.5 0 0)'), + token('fontFamily', 'font-sans', 'Inter, ui-sans-serif'), + token('radius', 'radius-lg', '0.5rem'), + token('spacing', 'spacing-4', '1rem'), + token('spacing', 'spacing-px', '1.5'), + token('fontSize', 'text-lg', '1.125rem'), + token('lineHeight', 'leading-tight', '1.25'), + token('shadow', 'shadow-md', '0 4px 6px -1px rgb(0 0 0 / 0.1)'), + ); + expect(result.fields).toEqual({ + colors: { hex: '#ffffff', rgb: '#ff8000', hsl: '#336699', oklch: '#636363' }, + typography: { sans: { fontFamily: 'Inter, ui-sans-serif' } }, + rounded: { lg: '0.5rem' }, + spacing: { 4: '1rem', px: 1.5 }, + }); + expect(result.sections).toEqual([ + { + heading: 'Typography', + content: '- font size `lg`: 1.125rem\n- line height `tight`: 1.25', + }, + { + heading: 'Elevation & Depth', + content: '- `md`: 0 4px 6px -1px rgb(0 0 0 / 0.1)', + }, + ]); + expect(result.report).toEqual({ + clamped: [], + unsupported: [], + unresolved: [], + otherThemes: [], + shadowed: [], + }); + }); + + it('returns nothing for no tokens', () => { + expect(mapTokensToDesignFields([])).toEqual({ + fields: {}, + sections: [], + origins: {}, + report: { clamped: [], unsupported: [], unresolved: [], otherThemes: [], shadowed: [] }, + }); + }); + + describe('names', () => { + // [type, the name an extractor gives it, where it lands] + it.each([ + ['color', 'colors.brand.500', 'colors.brand-500', '#ffffff'], + ['color', 'color-brand-500', 'colors.brand-500', '#ffffff'], + ['color', 'color.brand.primary', 'colors.brand-primary', '#ffffff'], + ['color', 'background', 'colors.background', '#ffffff'], + ['color', 'text-primary', 'colors.text-primary', '#ffffff'], + ['fontFamily', 'fontFamily.sans', 'typography.sans', 'Inter'], + ['fontFamily', 'font-sans', 'typography.sans', 'Inter'], + ['fontFamily', 'font-family-sans', 'typography.sans', 'Inter'], + ['radius', 'borderRadius.lg', 'rounded.lg', '4px'], + ['radius', 'radius-lg', 'rounded.lg', '4px'], + ['radius', 'radius', 'rounded.base', '4px'], + ['spacing', 'spacing.4', 'spacing.4', '4px'], + ['spacing', 'spacing', 'spacing.base', '4px'], + ['fontSize', 'fontSize.lg', 'Typography.font size.lg', '1rem'], + ['fontSize', 'text-lg', 'Typography.font size.lg', '1rem'], + ['fontSize', 'font-size-base', 'Typography.font size.base', '1rem'], + ['lineHeight', 'leading-tight', 'Typography.line height.tight', '1.5'], + ['lineHeight', 'text-lg--line-height', 'Typography.line height.lg', '1.5'], + ['shadow', 'boxShadow.md', 'Elevation & Depth.md', '0 1px 2px #000'], + ['shadow', 'shadow-md', 'Elevation & Depth.md', '0 1px 2px #000'], + ] as const)('%s %s lands at %s', (type, name, path, value) => { + expect(Object.keys(map(token(type, name, value)).origins)).toEqual([path]); + }); + }); + + describe('colors', () => { + it('clamps a color outside sRGB and reports the original value', () => { + const result = map( + token('color', 'vivid', 'oklch(0.7 0.4 150)'), + token('color', 'plain', 'oklch(0.5 0 0)'), + ); + expect(result.fields.colors).toEqual({ vivid: '#00d600', plain: '#636363' }); + expect(result.report.clamped).toEqual([ + { + path: 'colors.vivid', + original: 'oklch(0.7 0.4 150)', + hex: '#00d600', + source: 'tokens.css', + token: 'vivid', + }, + ]); + }); + + it('reports a color it cannot hold and leaves it out', () => { + const result = map( + token('color', 'ring', 'oklch(0.7 0.1 250 / 50%)'), + token('color', 'overlay', '#00000080'), + token('color', 'none', 'transparent'), + token('color', 'wide', 'lab(50 20 20)'), + ); + expect(result.fields).toEqual({}); + expect(result.report.unsupported.map((u) => [u.token, u.reason])).toEqual([ + ['ring', 'color-alpha'], + ['overlay', 'color-alpha'], + ['none', 'color-format'], + ['wide', 'color-format'], + ]); + expect(result.report.unsupported[0]).toMatchObject({ + value: 'oklch(0.7 0.1 250 / 50%)', + source: 'tokens.css', + }); + }); + }); + + describe('CSS spellings DESIGN.md does not take as written', () => { + // DESIGN.md wants a digit before the point and a lowercase unit; CSS allows neither. + it.each([ + ['.5rem', '0.5rem'], + ['-.5rem', '-0.5rem'], + ['1REM', '1rem'], + ['.25EM', '0.25em'], + ['8PX', '8px'], + ['0.50rem', '0.50rem'], + ])('writes %s as %s for a radius and for spacing', (written, written_) => { + const result = map( + token('radius', 'radius-a', written), + token('spacing', 'spacing-a', written), + ); + expect(result.fields).toEqual({ rounded: { a: written_ }, spacing: { a: written_ } }); + expect(result.report.unsupported).toEqual([]); + }); + + it.each([ + ['.5', 0.5], + ['-.5', -0.5], + ['1.50', 1.5], + ])('writes a unitless spacing %s as the number %s', (written, number) => { + expect(map(token('spacing', 'spacing-a', written)).fields.spacing).toEqual({ a: number }); + }); + + it('accepts them for font sizes and line heights', () => { + const result = map( + token('fontSize', 'text-a', '.875REM'), + token('lineHeight', 'leading-a', '.5'), + token('lineHeight', 'leading-b', '1.5EM'), + ); + expect(result.sections).toEqual([ + { + heading: 'Typography', + content: '- font size `a`: 0.875rem\n- line height `a`: .5\n- line height `b`: 1.5em', + }, + ]); + }); + + it('treats the same length spelled two ways as one value', () => { + const result = mapTokensToDesignFields([ + file('a.css', token('radius', 'radius-md', '.5rem'), token('spacing', 'spacing-1', '.5')), + file('b.css', token('radius', 'radius-md', '0.5REM'), token('spacing', 'spacing-1', '0.5')), + ]); + expect(result.report.shadowed).toEqual([]); + }); + + it('still leaves out what is not a length', () => { + const result = map( + token('radius', 'radius-a', '5.rem'), + token('radius', 'radius-b', '.rem'), + token('spacing', 'spacing-a', '1.5vw'), + token('spacing', 'spacing-b', '..5'), + ); + expect(result.fields).toEqual({}); + expect(result.report.unsupported.map((u) => u.reason)).toEqual(Array(4).fill('dimension')); + }); + + it('produces a DESIGN.md that validates', () => { + const result = map( + token('radius', 'radius-a', '.5rem'), + token('radius', 'radius-b', '1REM'), + token('spacing', 'spacing-a', '-.5rem'), + token('spacing', 'spacing-b', '.5'), + ); + expect(result.fields).toEqual({ + rounded: { a: '0.5rem', b: '1rem' }, + spacing: { a: '-0.5rem', b: 0.5 }, + }); + const raw = serializeDesignMd({ frontmatter: { name: 'X', ...result.fields } }); + expect(validateDesignMd(raw)).toEqual([]); + }); + }); + + describe('other values', () => { + it('writes a bare 0 as 0px for a radius and as the number 0 for spacing', () => { + const result = map(token('radius', 'radius-none', '0'), token('spacing', 'spacing-0', '0')); + expect(result.fields).toEqual({ rounded: { none: '0px' }, spacing: { 0: 0 } }); + }); + + it('reports a value with a unit DESIGN.md does not take', () => { + const result = map( + token('radius', 'radius-full', '50%'), + token('radius', 'radius-sm', 'calc(0.5rem - 2px)'), + token('spacing', 'spacing-fluid', 'clamp(1rem, 2vw, 2rem)'), + token('fontSize', 'text-fluid', 'clamp(1rem, 2vw, 2rem)'), + token('lineHeight', 'leading-loose', '150%'), + token('fontSize', 'text-unitless', '16'), + token('unknown', 'misc', '42'), + ); + expect(result.fields).toEqual({}); + expect(result.sections).toEqual([]); + expect(result.report.unsupported.map((u) => [u.token, u.reason])).toEqual([ + ['radius-full', 'dimension'], + ['radius-sm', 'dimension'], + ['spacing-fluid', 'dimension'], + ['text-fluid', 'dimension'], + ['leading-loose', 'dimension'], + ['text-unitless', 'dimension'], + ['misc', 'type'], + ]); + }); + }); + + describe('var()', () => { + it('replaces a var() with the scanned token it names, through a chain and across files', () => { + const result = mapTokensToDesignFields([ + file('theme.css', token('color', 'color-ink', 'var(--color-text)')), + file( + 'base.css', + token('color', 'color-text', 'var(--brand)'), + token('color', 'brand', 'oklch(0.5 0 0)'), + ), + ]); + expect(result.fields.colors).toEqual({ ink: '#636363', text: '#636363', brand: '#636363' }); + expect(result.origins['colors.ink']).toEqual({ source: 'theme.css', token: 'color-ink' }); + expect(result.report.unresolved).toEqual([]); + }); + + it('uses the fallback only when the token is not scanned', () => { + const result = map( + token('color', 'a', 'var(--missing, rgb(0 0 0))'), + token('color', 'b', 'var(--c, #ff0000)'), + token('color', 'c', '#00ff00'), + ); + expect(result.fields.colors).toEqual({ a: '#000000', b: '#00ff00', c: '#00ff00' }); + }); + + it('substitutes inside a color function', () => { + const result = map( + token('unknown', 'hue', '210'), + token('color', 'accent', 'hsl(var(--hue) 50% 40%)'), + ); + expect(result.fields.colors).toEqual({ accent: '#336699' }); + }); + + it('reports a var() that names nothing, or names itself', () => { + const result = map( + token('color', 'lost', 'var(--nope)'), + token('color', 'loop-a', 'var(--loop-b)'), + token('color', 'loop-b', 'var(--loop-a)'), + token('color', 'self', 'var(--self)'), + token('color', 'open', 'var(--nope'), + ); + expect(result.fields).toEqual({}); + expect(result.report.unresolved.map((u) => [u.token, u.reason])).toEqual([ + ['lost', 'undefined'], + ['loop-a', 'cycle'], + ['loop-b', 'cycle'], + ['self', 'cycle'], + ['open', 'undefined'], + ]); + expect(result.report.unresolved[0]).toMatchObject({ value: 'var(--nope)' }); + }); + + it('stops a chain nested deeper than the limit and resolves the ones within it', () => { + const chain = Array.from({ length: MAX_VAR_DEPTH + 8 }, (_, i) => + token('color', `c${i}`, i === 0 ? '#112233' : `var(--c${i - 1})`), + ); + const result = map(...chain); + expect(Object.keys(result.fields.colors ?? {})).toEqual( + chain.slice(0, MAX_VAR_DEPTH).map((c) => c.name), + ); + expect(result.report.unresolved.map((u) => u.reason)).toEqual(Array(8).fill('too-complex')); + }); + + // Each level names the one below twice, so resolving it in full takes 2^levels steps. + it('gives up on a value that grows past the limit instead of expanding it', () => { + const levels = 20; + const tokens = [ + token('shadow', 'l0', '0 0 0 #000'), + ...Array.from({ length: levels }, (_, i) => + token('shadow', `l${i + 1}`, `var(--l${i}), var(--l${i})`), + ), + ]; + const start = performance.now(); + const result = map(...tokens); + expect(performance.now() - start).toBeLessThan(250); + const tooComplex = result.report.unresolved.filter((u) => u.reason === 'too-complex'); + expect(tooComplex.length).toBeGreaterThan(0); + for (const line of result.sections[0]?.content.split('\n') ?? []) { + expect(line.length).toBeLessThanOrEqual(MAX_RESOLVED_LENGTH + 20); + } + }); + + describe('a name that several references reach', () => { + /** `--l0: leaf`, and every later level names the one before it twice. */ + const fanOut = (levels: number, leaf: string) => [ + token('shadow', 'l0', leaf), + ...Array.from({ length: levels }, (_, i) => + token('shadow', `l${i + 1}`, `var(--l${i})var(--l${i})`), + ), + ]; + const within = (limitMs: number, work: () => void) => { + const start = performance.now(); + work(); + expect(performance.now() - start).toBeLessThan(limitMs); + }; + + // Expanding in full takes 2^levels steps (about 3.5 seconds at 22 before names were kept), + // and the empty output never reaches the length limit. A level count that keeps a regression + // to a failed test rather than a stuck one. + it('expands each name once, so a fan-out with an empty result is linear', async () => { + const levels = 22; + const declarations = ['--shadow-l0: var(--absent,);']; + for (let i = 1; i <= levels; i++) { + declarations.push(`--shadow-l${i}: var(--shadow-l${i - 1})var(--shadow-l${i - 1});`); + } + const dir = await mkdtemp(join(tmpdir(), 'design-fields-')); + const path = join(dir, 'tokens.css'); + await writeFile(path, `:root { ${declarations.join('\n')} }`); + const tokens = await extractFromCssVars(path); + + within(250, () => { + const result = mapTokensToDesignFields([{ path, tokens }]); + expect(tokens).toHaveLength(levels + 1); + expect(result.report.unresolved).toEqual([]); + expect(result.sections[0]?.content.split('\n')).toHaveLength(levels + 1); + }); + }); + + it('gives every token that reaches the name the same value', () => { + const result = map( + token('color', 'base', '#112233'), + token('color', 'a', 'var(--base)'), + token('color', 'b', 'var(--base)'), + token('color', 'c', 'var(--a)'), + ); + + expect(result.fields.colors).toEqual({ + base: '#112233', + a: '#112233', + b: '#112233', + c: '#112233', + }); + }); + + // c0 to c31 nest within the limit. A token naming c30 adds one level and still fits; one + // naming c31 adds a level past the limit. Listing the chain from the deepest name first makes + // the names expand through their references before any later token reuses them. + it.each([ + ['shallowest first', false], + ['deepest first', true], + ])('still counts the levels a name nests when a later token reuses it (%s)', (_order, reverse) => { + const chain = Array.from({ length: MAX_VAR_DEPTH }, (_, i) => + token('color', `c${i}`, i === 0 ? '#112233' : `var(--c${i - 1})`), + ); + const result = map( + ...(reverse ? chain.reverse() : chain), + token('color', 'fits', `var(--c${MAX_VAR_DEPTH - 2})`), + token('color', 'too-deep', `var(--c${MAX_VAR_DEPTH - 1})`), + ); + + expect(result.fields.colors).toHaveProperty('fits', '#112233'); + expect(result.report.unresolved.map((u) => [u.token, u.reason])).toEqual([ + ['too-deep', 'too-complex'], + ]); + }); + + it('reports the same depth limit whether a name was expanded before or not', () => { + const chain = (names: string[]) => + names.map((name, i) => + token('color', name, i === 0 ? '#112233' : `var(--${names[i - 1]})`), + ); + const names = Array.from({ length: MAX_VAR_DEPTH + 4 }, (_, i) => `c${i}`); + const inOrder = map(...chain(names)); + // The deepest token first: nothing is expanded before it. + const deepFirst = map(...chain(names).reverse()); + + expect(deepFirst.report.unresolved.map((u) => u.token).sort()).toEqual( + inOrder.report.unresolved.map((u) => u.token).sort(), + ); + expect(Object.keys(deepFirst.fields.colors ?? {}).sort()).toEqual( + Object.keys(inOrder.fields.colors ?? {}).sort(), + ); + }); + + it('still finds a cycle when other names were expanded first', () => { + const result = map( + token('color', 'base', '#112233'), + token('color', 'x', 'var(--base)'), + token('color', 'loop-a', 'var(--x) var(--loop-b)'), + token('color', 'loop-b', 'var(--loop-a)'), + ); + + expect(result.report.unresolved.map((u) => [u.token, u.reason])).toEqual([ + ['loop-a', 'cycle'], + ['loop-b', 'cycle'], + ]); + expect(result.fields.colors).toEqual({ base: '#112233', x: '#112233' }); + }); + + it('uses each reference its own fallback for a name nothing defines', () => { + const result = map( + token('color', 'one', 'var(--missing, #ff0000)'), + token('color', 'two', 'var(--missing, #0000ff)'), + ); + + expect(result.fields.colors).toEqual({ one: '#ff0000', two: '#0000ff' }); + }); + + it('fails fast on a fan-out whose leaf names nothing', () => { + within(250, () => { + const result = map(...fanOut(30, 'var(--absent)')); + expect(result.report.unresolved.every((u) => u.reason === 'undefined')).toBe(true); + expect(result.report.unresolved).toHaveLength(31); + }); + }); + + it('still gives up on a fan-out whose result grows past the length limit', () => { + within(250, () => { + const result = map(...fanOut(30, '0 0 0 #000')); + const reasons = new Set(result.report.unresolved.map((u) => u.reason)); + expect(reasons).toEqual(new Set(['too-complex'])); + }); + }); + }); + + it('does not resolve through a value that only a theme variant defines', () => { + const result = map( + token('color', 'bg', '#000000', { block: '[data-theme="dark"]' }), + token('color', 'surface', 'var(--bg)'), + ); + expect(result.report.unresolved.map((u) => u.token)).toEqual(['surface']); + }); + }); + + describe('themes and repeated names', () => { + it('keeps the default block and lists the other themes as not imported', () => { + const dark = '[data-theme="dark"]'; + const result = map( + token('color', 'bg', '#ffffff'), + token('color', 'bg', '#000000', { block: dark }), + token('color', 'only-dark', '#111111', { block: dark }), + ); + expect(result.fields.colors).toEqual({ bg: '#ffffff' }); + expect(result.report.otherThemes).toEqual([ + { block: dark, value: '#000000', source: 'tokens.css', token: 'bg' }, + { block: dark, value: '#111111', source: 'tokens.css', token: 'only-dark' }, + ]); + }); + + it('ignores the same value again and reports a different one, keeping the first', () => { + const result = mapTokensToDesignFields([ + file('a.css', token('color', 'brand', '#ff0000'), token('radius', 'radius-md', '4px')), + file( + 'b.css', + token('color', 'color-brand', 'rgb(255 0 0)'), + token('radius', 'radius-md', '8px'), + ), + ]); + expect(result.fields).toEqual({ colors: { brand: '#ff0000' }, rounded: { md: '4px' } }); + expect(result.origins['colors.brand']).toEqual({ source: 'a.css', token: 'brand' }); + expect(result.report.shadowed).toEqual([ + { + path: 'rounded.md', + value: '8px', + kept: { source: 'a.css', token: 'radius-md' }, + source: 'b.css', + token: 'radius-md', + }, + ]); + }); + }); + + it('maps a Tailwind v4 stylesheet end to end into a DESIGN.md that validates', async () => { + const dir = await mkdtemp(join(tmpdir(), 'design-fields-')); + const path = join(dir, 'globals.css'); + await writeFile( + path, + [ + '@import "tailwindcss";', + ':root {', + ' --radius: 0.625rem;', + ' --background: oklch(1 0 0);', + ' --foreground: oklch(0.145 0 0);', + ' --primary: oklch(0.205 0 0);', + ' --ring: oklch(0.708 0 0 / 50%);', + '}', + '[data-theme="dark"] {', + ' --background: oklch(0.145 0 0);', + '}', + '@theme inline {', + ' --font-sans: Inter, ui-sans-serif, system-ui;', + ' --color-background: var(--background);', + ' --color-primary: var(--primary);', + ' --radius-lg: var(--radius);', + ' --radius-sm: calc(var(--radius) - 4px);', + '}', + '', + ].join('\n'), + ); + const tokens = [ + ...(await extractFromTailwindConfig(path)), + ...(await extractFromCssVars(path)), + ]; + const result = mapTokensToDesignFields([{ path: 'globals.css', tokens }]); + + expect(result.fields).toEqual({ + colors: { background: '#ffffff', primary: '#171717', foreground: '#0a0a0a' }, + typography: { sans: { fontFamily: 'Inter, ui-sans-serif, system-ui' } }, + rounded: { lg: '0.625rem', base: '0.625rem' }, + }); + expect(result.report.otherThemes.map((o) => [o.token, o.block])).toEqual([ + ['background', '[data-theme="dark"]'], + ]); + expect(result.report.unsupported.map((u) => [u.token, u.reason])).toEqual([ + ['radius-sm', 'dimension'], + ['ring', 'color-alpha'], + ]); + expect(result.report.unresolved).toEqual([]); + + const raw = serializeDesignMd({ + frontmatter: { name: 'Imported', ...result.fields }, + sections: result.sections, + }); + expect(validateDesignMd(raw)).toEqual([]); + }); +}); diff --git a/packages/core/src/brand/designFields.ts b/packages/core/src/brand/designFields.ts new file mode 100644 index 00000000..95215a6a --- /dev/null +++ b/packages/core/src/brand/designFields.ts @@ -0,0 +1,304 @@ +import type { DesignMdBodySection, DesignMdFrontmatter, DesignToken } from '@open-codesign/shared'; +import { cssColorToHex } from './color.js'; + +/** + * Maps extracted design tokens to the fields `validateDesignMd` accepts. + * + * Pure: nothing is logged. What could not be mapped, what was clamped and what was left out + * come back in `report`, so the caller can show each one instead of changing values silently. + */ + +export interface TokenFile { + /** Path shown to the user, relative to the folder that was scanned. */ + path: string; + tokens: DesignToken[]; +} + +export type DesignFields = Pick< + DesignMdFrontmatter, + 'colors' | 'typography' | 'rounded' | 'spacing' +>; + +export interface ImportOrigin { + /** The file the value came from. */ + source: string; + /** The token's name in that file. */ + token: string; +} + +export type UnresolvedReason = 'undefined' | 'cycle' | 'too-complex'; + +export type UnsupportedReason = 'color-alpha' | 'color-format' | 'dimension' | 'type'; + +export interface TokenImportReport { + /** Colors outside sRGB, clamped. `original` is the value as written, with `var()` replaced. */ + clamped: Array<{ path: string; original: string; hex: string } & ImportOrigin>; + /** Values DESIGN.md cannot hold: alpha, another color format, a unit it does not take. */ + unsupported: Array<{ reason: UnsupportedReason; value: string } & ImportOrigin>; + /** A `var()` that points at no scanned token, at itself, or through more than the limits allow. */ + unresolved: Array<{ reason: UnresolvedReason; value: string } & ImportOrigin>; + /** Values declared in a theme block other than the default one. */ + otherThemes: Array<{ block: string; value: string } & ImportOrigin>; + /** A second value for a name already taken; the first one is kept. */ + shadowed: Array<{ path: string; value: string; kept: ImportOrigin } & ImportOrigin>; +} + +export interface TokenImport { + fields: DesignFields; + /** Font sizes, line heights and shadows, which the frontmatter has no field for. */ + sections: DesignMdBodySection[]; + /** Where every mapped value came from, keyed like `colors.brand-500`. */ + origins: Record; + report: TokenImportReport; +} + +/** A folder can hold someone else's CSS, so nested `var()` stops here instead of growing without bound. */ +export const MAX_VAR_DEPTH = 32; +export const MAX_RESOLVED_LENGTH = 4_096; + +const DIMENSION = /^(-?)(\d+(?:\.\d+)?|\.\d+)(px|em|rem)$/i; +const UNITLESS = /^-?(?:\d+(?:\.\d+)?|\.\d+)$/; + +/** + * A CSS length in the form DESIGN.md takes: a digit before the point and a lowercase unit, + * which CSS does not require (`.5rem`, `1REM`). Null when the value is not a px, em or rem length. + */ +function normalizeDimension(value: string): string | null { + const match = DIMENSION.exec(value); + if (match === null) return null; + const [, sign = '', magnitude = '', unit = ''] = match; + return `${sign}${magnitude.startsWith('.') ? `0${magnitude}` : magnitude}${unit.toLowerCase()}`; +} + +// The words each extractor puts in front of a token's own name: `colors.brand.500` (v3), +// `color-brand-500` (v4), `font-family-sans` (CSS variables), `fontSize.lg`. +const NAME_PREFIX: Partial> = { + color: /^(?:colors?|palette)(?:[.-]|$)/, + fontFamily: /^(?:font-family|fontFamily|font|typeface)(?:[.-]|$)/, + fontSize: /^(?:font-size|fontSize|text-size|text)(?:[.-]|$)/, + lineHeight: /^(?:line-height|lineHeight|leading)(?:[.-]|$)/, + radius: /^(?:border-radius|borderRadius|radius|rounded)(?:[.-]|$)/, + spacing: /^(?:spacing|space)(?:[.-]|$)/, + shadow: /^(?:box-shadow|boxShadow|shadow|elevation)(?:[.-]|$)/, +}; + +/** `--spacing` and `--radius` alone name the base unit, so the prefix is all there is. */ +function tokenKey(name: string, type: DesignToken['type']): string { + const companion = type === 'lineHeight' ? /^text-(.+)--line-height$/.exec(name) : null; + const stripped = companion?.[1] ?? name.replace(NAME_PREFIX[type] ?? '', ''); + const key = stripped.replace(/\./g, '-'); + return key === '' ? 'base' : key; +} + +/** `below` is how many levels of `var()` the value nests, which the depth limit counts. */ +type Resolved = + | { ok: true; value: string; below: number } + | { ok: false; reason: UnresolvedReason }; + +interface Expansion { + /** The default-block tokens a `var()` can name. */ + lookup: Map; + /** + * What each of those names expands to, once it has expanded. A name reached through two + * references is then expanded once, not once per reference: with `--b: var(--a) var(--a)` at + * every level, expanding in full takes 2^levels steps even when the result stays empty. + * Only a success is kept. It depends on the name alone, because a cycle needs a loop in the + * names, which a name that expanded does not reach. + */ + resolved: Map; +} + +/** Replaces each `var(--name, fallback)` with the scanned token it names. */ +function resolveVars(value: string, expansion: Expansion, chain: string[]): Resolved { + if (chain.length > MAX_VAR_DEPTH) return { ok: false, reason: 'too-complex' }; + let out = ''; + let below = 0; + let from = 0; + while (from < value.length) { + const start = value.indexOf('var(', from); + if (start === -1) { + out += value.slice(from); + break; + } + out += value.slice(from, start); + let depth = 0; + let end = -1; + for (let i = start + 3; i < value.length; i++) { + if (value[i] === '(') depth += 1; + else if (value[i] === ')') { + depth -= 1; + if (depth === 0) { + end = i; + break; + } + } + } + if (end === -1) return { ok: false, reason: 'undefined' }; + const inner = value.slice(start + 4, end); + const comma = inner.indexOf(','); + const name = (comma === -1 ? inner : inner.slice(0, comma)).trim().replace(/^--/, ''); + const fallback = comma === -1 ? undefined : inner.slice(comma + 1).trim(); + if (chain.includes(name)) return { ok: false, reason: 'cycle' }; + const known = expansion.resolved.get(name); + if (known !== undefined) { + // The depth limit counts the levels this name nests, as expanding it here would. + if (chain.length + 1 + known.below > MAX_VAR_DEPTH) + return { ok: false, reason: 'too-complex' }; + out += known.value; + below = Math.max(below, known.below + 1); + } else { + const target = expansion.lookup.get(name); + const source = target ?? fallback; + if (source === undefined) return { ok: false, reason: 'undefined' }; + const resolved = resolveVars(source, expansion, [...chain, name]); + if (!resolved.ok) return resolved; + if (target !== undefined) { + expansion.resolved.set(name, { value: resolved.value, below: resolved.below }); + } + out += resolved.value; + below = Math.max(below, resolved.below + 1); + } + if (out.length > MAX_RESOLVED_LENGTH) return { ok: false, reason: 'too-complex' }; + from = end + 1; + } + return { ok: true, value: out, below }; +} + +export function mapTokensToDesignFields(files: TokenFile[]): TokenImport { + // Only the default block defines a name that `var()` can point at; the first definition wins. + const lookup = new Map(); + for (const file of files) { + for (const token of file.tokens) { + if (token.block === undefined && !lookup.has(token.name)) lookup.set(token.name, token.value); + } + } + const expansion: Expansion = { lookup, resolved: new Map() }; + + const colors: Record = {}; + const typography: Record = {}; + const rounded: Record = {}; + const spacing: Record = {}; + const typographyLines: string[] = []; + const elevationLines: string[] = []; + const origins: Record = {}; + const report: TokenImportReport = { + clamped: [], + unsupported: [], + unresolved: [], + otherThemes: [], + shadowed: [], + }; + const claimed = new Map(); + + // True when `path` is free, so the caller writes the value. The same value again is ignored; + // a different one is reported and the first stays. + const claim = (path: string, value: string, origin: ImportOrigin): boolean => { + const earlier = claimed.get(path); + if (earlier === undefined) { + claimed.set(path, { value, origin }); + origins[path] = origin; + return true; + } + if (earlier.value !== value) { + report.shadowed.push({ path, value, kept: earlier.origin, ...origin }); + } + return false; + }; + + for (const file of files) { + for (const token of file.tokens) { + const origin = { source: file.path, token: token.name }; + const unsupported = (reason: UnsupportedReason) => + report.unsupported.push({ reason, value: token.value, ...origin }); + + if (token.block !== undefined) { + report.otherThemes.push({ block: token.block, value: token.value, ...origin }); + continue; + } + const resolved = resolveVars(token.value, expansion, [token.name]); + if (!resolved.ok) { + report.unresolved.push({ reason: resolved.reason, value: token.value, ...origin }); + continue; + } + const value = resolved.value.trim(); + const key = tokenKey(token.name, token.type); + + switch (token.type) { + case 'color': { + const color = cssColorToHex(value); + if (!color.ok) { + unsupported(color.reason === 'alpha' ? 'color-alpha' : 'color-format'); + } else if (claim(`colors.${key}`, color.hex, origin)) { + colors[key] = color.hex; + if (color.clamped) { + report.clamped.push({ + path: `colors.${key}`, + original: value, + hex: color.hex, + ...origin, + }); + } + } + break; + } + case 'fontFamily': + if (claim(`typography.${key}`, value, origin)) typography[key] = { fontFamily: value }; + break; + case 'radius': { + const dimension = value === '0' ? '0px' : normalizeDimension(value); + if (dimension === null) unsupported('dimension'); + else if (claim(`rounded.${key}`, dimension, origin)) rounded[key] = dimension; + break; + } + case 'spacing': { + const dimension = normalizeDimension(value); + if (dimension !== null) { + if (claim(`spacing.${key}`, dimension, origin)) spacing[key] = dimension; + } else if (UNITLESS.test(value)) { + const unitless = Number(value); + if (claim(`spacing.${key}`, String(unitless), origin)) spacing[key] = unitless; + } else { + unsupported('dimension'); + } + break; + } + case 'fontSize': + case 'lineHeight': { + const label = token.type === 'fontSize' ? 'font size' : 'line height'; + const size = + normalizeDimension(value) ?? + (token.type === 'lineHeight' && UNITLESS.test(value) ? value : null); + if (size === null) unsupported('dimension'); + else if (claim(`Typography.${label}.${key}`, size, origin)) { + typographyLines.push(`- ${label} \`${key}\`: ${size}`); + } + break; + } + case 'shadow': { + const shadow = value.replace(/\s+/g, ' '); + if (claim(`Elevation & Depth.${key}`, shadow, origin)) { + elevationLines.push(`- \`${key}\`: ${shadow}`); + } + break; + } + default: + unsupported('type'); + } + } + } + + const fields: DesignFields = {}; + if (Object.keys(colors).length > 0) fields.colors = colors; + if (Object.keys(typography).length > 0) fields.typography = typography; + if (Object.keys(rounded).length > 0) fields.rounded = rounded; + if (Object.keys(spacing).length > 0) fields.spacing = spacing; + + const sections: DesignMdBodySection[] = []; + if (typographyLines.length > 0) { + sections.push({ heading: 'Typography', content: typographyLines.join('\n') }); + } + if (elevationLines.length > 0) { + sections.push({ heading: 'Elevation & Depth', content: elevationLines.join('\n') }); + } + return { fields, sections, origins, report }; +} diff --git a/packages/core/src/brand/index.ts b/packages/core/src/brand/index.ts index 95041df7..b37df164 100644 --- a/packages/core/src/brand/index.ts +++ b/packages/core/src/brand/index.ts @@ -1,4 +1,14 @@ export type { DesignToken, DesignTokenSet } from '@open-codesign/shared'; export { extractFromCssVars } from './cssVarExtractor.js'; +export type { + DesignFields, + ImportOrigin, + TokenFile, + TokenImport, + TokenImportReport, + UnresolvedReason, + UnsupportedReason, +} from './designFields.js'; +export { mapTokensToDesignFields } from './designFields.js'; export { importDtcgJson } from './dtcgImporter.js'; export { extractFromTailwindConfig } from './tailwindExtractor.js'; diff --git a/packages/core/src/brand/tailwindExtractor.test.ts b/packages/core/src/brand/tailwindExtractor.test.ts index 9fda223a..d7a3dda6 100644 --- a/packages/core/src/brand/tailwindExtractor.test.ts +++ b/packages/core/src/brand/tailwindExtractor.test.ts @@ -104,6 +104,27 @@ describe('extractFromTailwindConfig()', () => { expect(names).toContain('color-brand-primary'); }); + it('classifies v4 --font-* theme variables as font families', async () => { + const dir = await mkdtemp(join(tmpdir(), 'tw-extract-')); + const file = join(dir, 'tailwind.css'); + await writeFile( + file, + [ + '@theme {', + ' --font-sans: Inter, ui-sans-serif, system-ui;', + ' --font-display-serif: "Fraunces", serif;', + ' --font-sans--font-feature-settings: "cv11";', + ' --font-weight-bold: 700;', + '}', + ].join('\n'), + ); + const tokens = await extractFromTailwindConfig(file); + expect(tokens.map((t) => [t.name, t.type, t.value])).toEqual([ + ['font-sans', 'fontFamily', 'Inter, ui-sans-serif, system-ui'], + ['font-display-serif', 'fontFamily', '"Fraunces", serif'], + ]); + }); + it('ignores `}` inside a CSS comment (v4)', async () => { const dir = await mkdtemp(join(tmpdir(), 'tw-extract-')); const file = join(dir, 'tailwind.css'); diff --git a/packages/core/src/brand/tailwindExtractor.ts b/packages/core/src/brand/tailwindExtractor.ts index a77768e1..901728ef 100644 --- a/packages/core/src/brand/tailwindExtractor.ts +++ b/packages/core/src/brand/tailwindExtractor.ts @@ -189,7 +189,9 @@ function inferTypeFromCssProp(prop: string, value: string): TokenType | null { // Order matters: specific shape patterns must run before broad keyword classifiers. // so Tailwind v4 `--text-*` size tokens are not misclassified as color. if (/font-size|text-size/.test(prop)) return 'fontSize'; - if (/font-family/.test(prop)) return 'fontFamily'; + // v4 names font families `--font-*` (`--font-sans`). `--font-weight-*` is not a family, and + // companions such as `--font-sans--font-feature-settings` carry a double hyphen. + if (/font-family|^font-(?!weight)[a-z0-9]+(?:-[a-z0-9]+)*$/.test(prop)) return 'fontFamily'; if (/line-height|leading/.test(prop)) return 'lineHeight'; if (/radius|rounded/.test(prop)) return 'radius'; if (/shadow/.test(prop)) return 'shadow'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3eeeee86..74f4dcb4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -36,10 +36,20 @@ export { ModelRegistry, SessionManager, } from './agent-session.js'; +export type { + DesignFields, + ImportOrigin, + TokenFile, + TokenImport, + TokenImportReport, + UnresolvedReason, + UnsupportedReason, +} from './brand/index.js'; export { extractFromCssVars, extractFromTailwindConfig, importDtcgJson, + mapTokensToDesignFields, } from './brand/index.js'; export { DEFAULT_CONTEXT_WINDOW, resolveContextWindow } from './context-window.js'; export { diff --git a/packages/shared/src/design-md.test.ts b/packages/shared/src/design-md.test.ts index ddbe1875..8841620d 100644 --- a/packages/shared/src/design-md.test.ts +++ b/packages/shared/src/design-md.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { formatDesignMdForPrompt, parseDesignMd, validateDesignMd } from './design-md'; +import { + type DesignMdFrontmatter, + formatDesignMdForPrompt, + parseDesignMd, + serializeDesignMd, + validateDesignMd, +} from './design-md'; const VALID_DESIGN_MD = `--- version: alpha @@ -258,3 +264,103 @@ Keep these colors. expect(formatted).toContain('[DESIGN.md body truncated to 2000 chars]'); }); }); + +describe('serializeDesignMd', () => { + const frontmatter = { + name: 'Imported: "Acme"', + version: 'alpha', + colors: { primary: '#1a1c1e', 'brand-500': '#fff' }, + typography: { + sans: { fontFamily: 'Inter, ui-sans-serif, system-ui' }, + mono: { fontFamily: '"JetBrains Mono", monospace' }, + }, + rounded: { lg: '0.5rem', none: '0px' }, + spacing: { base: '0.25rem', zero: 0, '4': '1rem' }, + }; + + it('writes a document that validates and parses back to the same fields', () => { + const raw = serializeDesignMd({ + frontmatter, + sections: [{ heading: 'Overview', content: 'Imported from the project.' }], + }); + expect(validateDesignMd(raw)).toEqual([]); + const doc = parseDesignMd(raw); + expect(doc.frontmatter).toEqual(frontmatter); + expect(doc.bodySections).toEqual([ + { heading: 'Overview', content: 'Imported from the project.' }, + ]); + }); + + it('writes every frontmatter field, so the field list and the type cannot drift', () => { + const everyField: Required = { + version: 'alpha', + name: 'X', + description: 'Every field.', + colors: { primary: '#111111' }, + typography: { body: { fontFamily: 'Inter' } }, + rounded: { sm: '4px' }, + spacing: { sm: '8px' }, + components: { button: { backgroundColor: '#111111' } }, + }; + const raw = serializeDesignMd({ frontmatter: everyField }); + expect(Object.keys(parseDesignMd(raw).frontmatter)).toEqual(Object.keys(everyField)); + expect(parseDesignMd(raw).frontmatter).toEqual(everyField); + expect(validateDesignMd(raw)).toEqual([]); + }); + + it('keeps hex colors as strings', () => { + const raw = serializeDesignMd({ + frontmatter: { name: 'X', colors: { a: '#fff', b: '#000000' } }, + }); + expect(parseDesignMd(raw).frontmatter['colors']).toEqual({ a: '#fff', b: '#000000' }); + }); + + it('writes fields and sections in the canonical order whatever order they arrive in', () => { + const raw = serializeDesignMd({ + frontmatter: { + spacing: { sm: '8px' }, + colors: { a: '#ffffff' }, + name: 'X', + version: 'alpha', + }, + sections: [ + { heading: 'Typography', content: 'Type.' }, + { heading: 'Notes', content: 'Extra.' }, + { heading: 'Overview', content: 'Intro.' }, + ], + }); + expect(Object.keys(parseDesignMd(raw).frontmatter)).toEqual([ + 'version', + 'name', + 'colors', + 'spacing', + ]); + expect(parseDesignMd(raw).bodySections.map((section) => section.heading)).toEqual([ + 'Overview', + 'Typography', + 'Notes', + ]); + expect(validateDesignMd(raw)).toEqual([]); + }); + + it('writes only the frontmatter when there are no sections', () => { + const raw = serializeDesignMd({ frontmatter: { name: 'X' } }); + expect(raw).toBe('---\nname: X\n---\n'); + expect(parseDesignMd(raw).body).toBe(''); + }); + + it('does not fold long values', () => { + const fontFamily = Array.from({ length: 40 }, (_, n) => `Font Number ${n}`).join(', '); + const raw = serializeDesignMd({ + frontmatter: { name: 'X', typography: { t: { fontFamily } } }, + }); + expect(raw.split('\n').some((line) => line.includes(fontFamily))).toBe(true); + expect(parseDesignMd(raw).frontmatter['typography']).toEqual({ t: { fontFamily } }); + }); + + it('keeps a multi-line description', () => { + const description = 'First line.\nSecond line: with a colon.'; + const raw = serializeDesignMd({ frontmatter: { name: 'X', description } }); + expect(parseDesignMd(raw).frontmatter['description']).toBe(description); + }); +}); diff --git a/packages/shared/src/design-md.ts b/packages/shared/src/design-md.ts index 52e304ae..cfb093da 100644 --- a/packages/shared/src/design-md.ts +++ b/packages/shared/src/design-md.ts @@ -1,4 +1,4 @@ -import { parse as parseYaml } from 'yaml'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; export type DesignMdFindingSeverity = 'error' | 'warning' | 'info'; @@ -23,7 +23,8 @@ export interface DesignMdDocument { const FRONTMATTER_MAX_CHARS = 64 * 1024; const BODY_PROMPT_MAX_CHARS = 2000; -const ALLOWED_TOP_LEVEL = new Set([ +/** The top-level fields, in the order the serializer writes them. */ +const FRONTMATTER_FIELDS = [ 'version', 'name', 'description', @@ -32,7 +33,9 @@ const ALLOWED_TOP_LEVEL = new Set([ 'rounded', 'spacing', 'components', -]); +] as const; + +const ALLOWED_TOP_LEVEL: ReadonlySet = new Set(FRONTMATTER_FIELDS); const TYPOGRAPHY_KEYS = new Set([ 'fontFamily', @@ -356,11 +359,11 @@ export function formatDesignMdForPrompt(raw: string): string { return ['---', doc.frontmatterText.trimEnd(), '---', '', body].join('\n'); } -function orderedBodySections(doc: DesignMdDocument): DesignMdBodySection[] { - if (doc.bodySections.length === 0) return []; +function orderedBodySections(sections: DesignMdBodySection[]): DesignMdBodySection[] { + if (sections.length === 0) return []; const known: DesignMdBodySection[] = []; const unknown: DesignMdBodySection[] = []; - for (const section of doc.bodySections) { + for (const section of sections) { const canonical = canonicalSectionHeading(section.heading); if (KNOWN_SECTION_INDEX.has(canonical)) known.push({ ...section, heading: canonical }); else unknown.push(section); @@ -384,7 +387,7 @@ function formatBodySectionsForPrompt(doc: DesignMdDocument): string { : doc.body; } - const rendered = orderedBodySections(doc).map(renderSection); + const rendered = orderedBodySections(doc.bodySections).map(renderSection); const full = rendered.join('\n\n'); if (full.length <= BODY_PROMPT_MAX_CHARS) return full; @@ -408,3 +411,33 @@ function formatBodySectionsForPrompt(doc: DesignMdDocument): string { } return `${out.join('\n\n')}${suffix}`; } + +/** The frontmatter fields `validateDesignMd` accepts, in the order they are written. */ +export interface DesignMdFrontmatter { + name: string; + version?: string; + description?: string; + colors?: Record; + typography?: Record>; + rounded?: Record; + spacing?: Record; + components?: Record>; +} + +/** + * Writes a DESIGN.md: the frontmatter in the canonical field order, then the body sections + * in the canonical section order. Strings such as `#1a1c1e` are quoted so they stay strings, + * and long values are not folded. `validateDesignMd` reports anything the fields get wrong. + */ +export function serializeDesignMd(input: { + frontmatter: DesignMdFrontmatter; + sections?: DesignMdBodySection[]; +}): string { + // yaml leaves out the fields that are undefined. + const fields = Object.fromEntries(FRONTMATTER_FIELDS.map((key) => [key, input.frontmatter[key]])); + const frontmatter = stringifyYaml(fields, { lineWidth: 0 }).trimEnd(); + const body = orderedBodySections(input.sections ?? []) + .map(renderSection) + .join('\n\n'); + return body === '' ? `---\n${frontmatter}\n---\n` : `---\n${frontmatter}\n---\n\n${body}\n`; +} diff --git a/packages/shared/src/design-token.ts b/packages/shared/src/design-token.ts index d24278f2..97a665ac 100644 --- a/packages/shared/src/design-token.ts +++ b/packages/shared/src/design-token.ts @@ -16,6 +16,11 @@ export const DesignTokenV1 = z.object({ value: z.string().min(1), origin: z.enum(['tailwind-config', 'css-vars', 'figma', 'manual', 'pdf', 'dtcg-json']), group: z.string().optional(), + /** + * Selector of the block a CSS variable was declared in, set only when it is not the + * default `:root` or `@theme` block, so a theme variant never looks like the default. + */ + block: z.string().optional(), }); export type DesignToken = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ea34663c..d31e6212 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -50,8 +50,10 @@ export { type DesignMdDocument, type DesignMdFinding, type DesignMdFindingSeverity, + type DesignMdFrontmatter, formatDesignMdForPrompt, parseDesignMd, + serializeDesignMd, validateDesignMd, } from './design-md'; export * from './source-edits';