From 6d16dc0a8ae7bf95c6a12f7f84e6992467b6e0d3 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 14 Oct 2026 17:36:05 +0800 Subject: [PATCH 01/12] fix(core): classify Tailwind v4 --font-* variables as font families The v4 classifier matched only `font-family`, so `--font-sans`, `--font-mono` and the other `--font-*` theme variables were dropped. `--font-weight-*` and companions such as `--font-sans--font-feature-settings` are not families and stay out. Refs #484 --- .../core/src/brand/tailwindExtractor.test.ts | 21 +++++++++++++++++++ packages/core/src/brand/tailwindExtractor.ts | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) 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'; From d752fc25ac36b84b3600f1d1880f9acf5c2bbce7 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 14 Oct 2026 17:37:02 +0800 Subject: [PATCH 02/12] feat(core): record the theme block of CSS variable tokens A `:root` value and a `[data-theme="dark"]` value with the same name looked identical. DesignToken gets an optional `block`, set by the CSS variable extractor to the selector of a `[data-theme]` block and left unset for `:root`, so existing tokens and callers are unchanged. Refs #484 --- .../core/src/brand/cssVarExtractor.test.ts | 27 +++++++++++++++++++ packages/core/src/brand/cssVarExtractor.ts | 25 ++++++++++------- packages/shared/src/design-token.ts | 5 ++++ 3 files changed, 48 insertions(+), 9 deletions(-) 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; From 77bf5f82ad4c66a48afb3f8dfe75327bcb485b66 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 14 Oct 2026 17:42:24 +0800 Subject: [PATCH 03/12] feat(core): convert CSS colors to the sRGB hex DESIGN.md requires cssColorToHex() handles hex, rgb(), hsl() and oklch(). A color with an alpha below 1 or in another format is reported, not approximated. oklch() goes OKLab -> linear sRGB -> gamma; a channel outside sRGB is clamped to [0, 1] and the result says so. The oklch test vectors were computed on a separate route (CSS Color 4 reference matrices through XYZ) and include the Tailwind v4 palette steps whose hex values Tailwind documents, in-gamut, out-of-gamut, achromatic and values a fraction of a step outside sRGB. Refs #484 --- packages/core/src/brand/color.test.ts | 181 ++++++++++++++++++++++++++ packages/core/src/brand/color.ts | 180 +++++++++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 packages/core/src/brand/color.test.ts create mode 100644 packages/core/src/brand/color.ts diff --git a/packages/core/src/brand/color.test.ts b/packages/core/src/brand/color.test.ts new file mode 100644 index 00000000..afde9e08 --- /dev/null +++ b/packages/core/src/brand/color.test.ts @@ -0,0 +1,181 @@ +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' }); + }); +}); diff --git a/packages/core/src/brand/color.ts b/packages/core/src/brand/color.ts new file mode 100644 index 00000000..ae7ba3f5 --- /dev/null +++ b/packages/core/src/brand/color.ts @@ -0,0 +1,180 @@ +/** + * 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'); +const FUNCTION_RE = /^(rgba?|hsla?|oklch)\(\s*([^()]*?)\s*\)$/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); +} From 5034476170f6a94f685a2607045a6fbc4edb2b6f Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 14 Oct 2026 17:43:41 +0800 Subject: [PATCH 04/12] feat(shared): serialize a DESIGN.md document serializeDesignMd() writes the frontmatter fields validateDesignMd accepts in the canonical order, then the body sections in the canonical section order (the ordering formatDesignMdForPrompt already uses). Hex colors stay quoted strings and long values are not folded. It sits next to the parser because yaml is a dependency of packages/shared, not of packages/core. Refs #484 --- packages/shared/src/design-md.test.ts | 90 ++++++++++++++++++++++++++- packages/shared/src/design-md.ts | 42 +++++++++++-- packages/shared/src/index.ts | 2 + 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/design-md.test.ts b/packages/shared/src/design-md.test.ts index ddbe1875..ff576eb4 100644 --- a/packages/shared/src/design-md.test.ts +++ b/packages/shared/src/design-md.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { formatDesignMdForPrompt, parseDesignMd, validateDesignMd } from './design-md'; +import { + formatDesignMdForPrompt, + parseDesignMd, + serializeDesignMd, + validateDesignMd, +} from './design-md'; const VALID_DESIGN_MD = `--- version: alpha @@ -258,3 +263,86 @@ 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('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..2e109aac 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'; @@ -356,11 +356,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 +384,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 +408,35 @@ 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( + [...ALLOWED_TOP_LEVEL].map((key) => [key, input.frontmatter[key as keyof DesignMdFrontmatter]]), + ); + 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/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'; From 86ccc5b1b3066fa698acb972baf33b6f50440939 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 14 Oct 2026 17:50:23 +0800 Subject: [PATCH 05/12] feat(core): map design tokens to DESIGN.md fields mapTokensToDesignFields() turns scanned tokens into the fields validateDesignMd accepts: colors (sRGB hex), typography (font families), rounded and spacing. Font sizes, line heights and shadows have no frontmatter field and come back as Typography and Elevation & Depth sections. A var() is replaced with the scanned token it names, with a cycle guard and the CSS fallback. Nothing is logged. The result carries where every value came from and a structured report: clamped colors with their original value, values DESIGN.md cannot hold (alpha, other color formats, other units), unresolved var(), values left out because they sit in another theme block, and names already taken by an earlier value. Refs #484 --- .changeset/design-tokens-to-design-md.md | 6 + packages/core/src/brand/designFields.test.ts | 316 +++++++++++++++++++ packages/core/src/brand/designFields.ts | 250 +++++++++++++++ packages/core/src/brand/index.ts | 9 + packages/core/src/index.ts | 9 + 5 files changed, 590 insertions(+) create mode 100644 .changeset/design-tokens-to-design-md.md create mode 100644 packages/core/src/brand/designFields.test.ts create mode 100644 packages/core/src/brand/designFields.ts 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/designFields.test.ts b/packages/core/src/brand/designFields.test.ts new file mode 100644 index 00000000..7602c5cb --- /dev/null +++ b/packages/core/src/brand/designFields.test.ts @@ -0,0 +1,316 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { type DesignToken, serializeDesignMd, validateDesignMd } from '@open-codesign/shared'; +import { describe, expect, it } from 'vitest'; +import { extractFromCssVars } from './cssVarExtractor.js'; +import { mapTokensToDesignFields, type TokenFile } from './designFields.js'; +import { extractFromTailwindConfig } from './tailwindExtractor.js'; + +const token = ( + type: DesignToken['type'], + name: string, + value: string, + extra: Partial = {}, +): 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('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('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..a58c285d --- /dev/null +++ b/packages/core/src/brand/designFields.ts @@ -0,0 +1,250 @@ +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 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, or at itself. */ + unresolved: Array<{ reason: 'undefined' | 'cycle'; 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; +} + +const DIMENSION = /^-?\d+(?:\.\d+)?(?:px|em|rem)$/; +const UNITLESS = /^-?\d+(?:\.\d+)?$/; + +// 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; +} + +type Resolved = { ok: true; value: string } | { ok: false; reason: 'undefined' | 'cycle' }; + +/** Replaces each `var(--name, fallback)` with the scanned token it names. */ +function resolveVars(value: string, lookup: Map, chain: string[]): Resolved { + let out = ''; + 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 target = lookup.get(name); + const source = target ?? fallback; + if (source === undefined) return { ok: false, reason: 'undefined' }; + const resolved = resolveVars(source, lookup, [...chain, name]); + if (!resolved.ok) return resolved; + out += resolved.value; + from = end + 1; + } + return { ok: true, value: out }; +} + +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 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, lookup, [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' : value; + if (!DIMENSION.test(dimension)) unsupported('dimension'); + else if (claim(`rounded.${key}`, dimension, origin)) rounded[key] = dimension; + break; + } + case 'spacing': + if (DIMENSION.test(value)) { + if (claim(`spacing.${key}`, value, origin)) spacing[key] = value; + } else if (UNITLESS.test(value)) { + if (claim(`spacing.${key}`, value, origin)) spacing[key] = Number(value); + } else { + unsupported('dimension'); + } + break; + case 'fontSize': + case 'lineHeight': { + const label = token.type === 'fontSize' ? 'font size' : 'line height'; + const valid = + DIMENSION.test(value) || (token.type === 'lineHeight' && UNITLESS.test(value)); + if (!valid) unsupported('dimension'); + else if (claim(`Typography.${label}.${key}`, value, origin)) { + typographyLines.push(`- ${label} \`${key}\`: ${value}`); + } + 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..add8ea5a 100644 --- a/packages/core/src/brand/index.ts +++ b/packages/core/src/brand/index.ts @@ -1,4 +1,13 @@ export type { DesignToken, DesignTokenSet } from '@open-codesign/shared'; export { extractFromCssVars } from './cssVarExtractor.js'; +export type { + DesignFields, + ImportOrigin, + TokenFile, + TokenImport, + TokenImportReport, + 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/index.ts b/packages/core/src/index.ts index 3eeeee86..309ec7bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -36,10 +36,19 @@ export { ModelRegistry, SessionManager, } from './agent-session.js'; +export type { + DesignFields, + ImportOrigin, + TokenFile, + TokenImport, + TokenImportReport, + UnsupportedReason, +} from './brand/index.js'; export { extractFromCssVars, extractFromTailwindConfig, importDtcgJson, + mapTokensToDesignFields, } from './brand/index.js'; export { DEFAULT_CONTEXT_WINDOW, resolveContextWindow } from './context-window.js'; export { From f269488a828e6f1436f6f6c0331ddc88a64145bf Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 14 Oct 2026 18:03:49 +0800 Subject: [PATCH 06/12] fix(core): match CSS color functions in linear time CodeQL flagged the function pattern in cssColorToHex: the lazy body between two `\s*` can match whitespace in several ways, so `rgb(` followed by many tabs backtracks quadratically. The numeric pattern `\d+\.?\d*` has the same ambiguity on a long run of digits. The input is CSS from a folder the user picked, which can be someone else's repository. The body is now matched greedily and split afterwards, and the number pattern is `\d+(?:\.\d*)?`. Tests run 100,000-character inputs for each case. --- packages/core/src/brand/color.test.ts | 27 +++++++++++++++++++++++++++ packages/core/src/brand/color.ts | 5 +++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/core/src/brand/color.test.ts b/packages/core/src/brand/color.test.ts index afde9e08..8109d30c 100644 --- a/packages/core/src/brand/color.test.ts +++ b/packages/core/src/brand/color.test.ts @@ -178,4 +178,31 @@ describe('cssColorToHex()', () => { ])('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 index ae7ba3f5..24855a31 100644 --- a/packages/core/src/brand/color.ts +++ b/packages/core/src/brand/color.ts @@ -18,9 +18,10 @@ export type ColorConversion = | { ok: false; reason: 'alpha' | 'format' }; const FORMAT: ColorConversion = { ok: false, reason: 'format' }; -const NUMBER = '[+-]?(?:\\d+\\.?\\d*|\\.\\d+)(?:e[+-]?\\d+)?'; +const NUMBER = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?'; const COMPONENT_RE = new RegExp(`^(${NUMBER})(%|deg|grad|rad|turn)?$`, 'i'); -const FUNCTION_RE = /^(rgba?|hsla?|oklch)\(\s*([^()]*?)\s*\)$/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 { From f90c57925612d9300d1abac1a7ec51702bebee7c Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 14 Oct 2026 18:03:50 +0800 Subject: [PATCH 07/12] fix(core): bound nested var() in the token mapping A value that names the same token twice at every level takes 2^levels steps to resolve in full, so a few lines of CSS could stall the scan. A chain is now followed at most 32 levels deep and a resolved value may reach 4,096 characters; past either limit the token is reported as unresolved with the reason `too-complex`. --- packages/core/src/brand/designFields.test.ts | 37 +++++++++++++++++++- packages/core/src/brand/designFields.ts | 14 ++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/core/src/brand/designFields.test.ts b/packages/core/src/brand/designFields.test.ts index 7602c5cb..f2f52543 100644 --- a/packages/core/src/brand/designFields.test.ts +++ b/packages/core/src/brand/designFields.test.ts @@ -4,7 +4,12 @@ import { join } from 'node:path'; import { type DesignToken, serializeDesignMd, validateDesignMd } from '@open-codesign/shared'; import { describe, expect, it } from 'vitest'; import { extractFromCssVars } from './cssVarExtractor.js'; -import { mapTokensToDesignFields, type TokenFile } from './designFields.js'; +import { + MAX_RESOLVED_LENGTH, + MAX_VAR_DEPTH, + mapTokensToDesignFields, + type TokenFile, +} from './designFields.js'; import { extractFromTailwindConfig } from './tailwindExtractor.js'; const token = ( @@ -213,6 +218,36 @@ describe('mapTokensToDesignFields()', () => { 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); + } + }); + it('does not resolve through a value that only a theme variant defines', () => { const result = map( token('color', 'bg', '#000000', { block: '[data-theme="dark"]' }), diff --git a/packages/core/src/brand/designFields.ts b/packages/core/src/brand/designFields.ts index a58c285d..123d6a8b 100644 --- a/packages/core/src/brand/designFields.ts +++ b/packages/core/src/brand/designFields.ts @@ -26,6 +26,8 @@ export interface ImportOrigin { token: string; } +export type UnresolvedReason = 'undefined' | 'cycle' | 'too-complex'; + export type UnsupportedReason = 'color-alpha' | 'color-format' | 'dimension' | 'type'; export interface TokenImportReport { @@ -33,8 +35,8 @@ export interface TokenImportReport { 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, or at itself. */ - unresolved: Array<{ reason: 'undefined' | 'cycle'; 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. */ @@ -50,6 +52,10 @@ export interface TokenImport { 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+)?(?:px|em|rem)$/; const UNITLESS = /^-?\d+(?:\.\d+)?$/; @@ -73,10 +79,11 @@ function tokenKey(name: string, type: DesignToken['type']): string { return key === '' ? 'base' : key; } -type Resolved = { ok: true; value: string } | { ok: false; reason: 'undefined' | 'cycle' }; +type Resolved = { ok: true; value: string } | { ok: false; reason: UnresolvedReason }; /** Replaces each `var(--name, fallback)` with the scanned token it names. */ function resolveVars(value: string, lookup: Map, chain: string[]): Resolved { + if (chain.length > MAX_VAR_DEPTH) return { ok: false, reason: 'too-complex' }; let out = ''; let from = 0; while (from < value.length) { @@ -110,6 +117,7 @@ function resolveVars(value: string, lookup: Map, chain: string[] const resolved = resolveVars(source, lookup, [...chain, name]); if (!resolved.ok) return resolved; out += resolved.value; + if (out.length > MAX_RESOLVED_LENGTH) return { ok: false, reason: 'too-complex' }; from = end + 1; } return { ok: true, value: out }; From 189c1f1dfdc3757df26af2cf5df7187408699252 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 14 Oct 2026 18:16:06 +0800 Subject: [PATCH 08/12] fix(core): accept the CSS spellings of a length DESIGN.md does not take `.5rem`, `-.5rem`, `.5` and `1REM` are valid CSS but were reported as unsupported dimensions, so hand-written token files never imported. The mapper now accepts them and writes the form validateDesignMd takes (a digit before the point, a lowercase unit): `0.5rem`, `-0.5rem`, 0.5, `1rem`. Widening the pattern alone would have written `.5rem`, which validateDesignMd rejects. The same length spelled two ways counts as one value. Refs #484 --- packages/core/src/brand/designFields.test.ts | 75 ++++++++++++++++++++ packages/core/src/brand/designFields.ts | 41 +++++++---- 2 files changed, 103 insertions(+), 13 deletions(-) diff --git a/packages/core/src/brand/designFields.test.ts b/packages/core/src/brand/designFields.test.ts index f2f52543..d8567af4 100644 --- a/packages/core/src/brand/designFields.test.ts +++ b/packages/core/src/brand/designFields.test.ts @@ -137,6 +137,81 @@ describe('mapTokensToDesignFields()', () => { }); }); + 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')); diff --git a/packages/core/src/brand/designFields.ts b/packages/core/src/brand/designFields.ts index 123d6a8b..22a40942 100644 --- a/packages/core/src/brand/designFields.ts +++ b/packages/core/src/brand/designFields.ts @@ -56,8 +56,19 @@ export interface TokenImport { export const MAX_VAR_DEPTH = 32; export const MAX_RESOLVED_LENGTH = 4_096; -const DIMENSION = /^-?\d+(?:\.\d+)?(?:px|em|rem)$/; -const UNITLESS = /^-?\d+(?:\.\d+)?$/; +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`. @@ -203,28 +214,32 @@ export function mapTokensToDesignFields(files: TokenFile[]): TokenImport { if (claim(`typography.${key}`, value, origin)) typography[key] = { fontFamily: value }; break; case 'radius': { - const dimension = value === '0' ? '0px' : value; - if (!DIMENSION.test(dimension)) unsupported('dimension'); + 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': - if (DIMENSION.test(value)) { - if (claim(`spacing.${key}`, value, origin)) spacing[key] = value; + case 'spacing': { + const dimension = normalizeDimension(value); + if (dimension !== null) { + if (claim(`spacing.${key}`, dimension, origin)) spacing[key] = dimension; } else if (UNITLESS.test(value)) { - if (claim(`spacing.${key}`, value, origin)) spacing[key] = Number(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 valid = - DIMENSION.test(value) || (token.type === 'lineHeight' && UNITLESS.test(value)); - if (!valid) unsupported('dimension'); - else if (claim(`Typography.${label}.${key}`, value, origin)) { - typographyLines.push(`- ${label} \`${key}\`: ${value}`); + 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; } From 224c3b05de800670f1ab7204ecdf90d2af6ac52f Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 14 Oct 2026 18:16:40 +0800 Subject: [PATCH 09/12] refactor(shared): derive the DESIGN.md allowed fields and the serializer from one list serializeDesignMd cast every allowed field name to keyof DesignMdFrontmatter, so a name added to one list and not the other compiled and was dropped by yaml. The fields are now one `as const` list: the allowed set is built from it and the serializer indexes the frontmatter with it, so a name the type lacks is a compile error. A test writes a document with every field set. Refs #484 --- packages/shared/src/design-md.test.ts | 18 ++++++++++++++++++ packages/shared/src/design-md.ts | 11 ++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/design-md.test.ts b/packages/shared/src/design-md.test.ts index ff576eb4..8841620d 100644 --- a/packages/shared/src/design-md.test.ts +++ b/packages/shared/src/design-md.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + type DesignMdFrontmatter, formatDesignMdForPrompt, parseDesignMd, serializeDesignMd, @@ -290,6 +291,23 @@ describe('serializeDesignMd', () => { ]); }); + 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' } }, diff --git a/packages/shared/src/design-md.ts b/packages/shared/src/design-md.ts index 2e109aac..cfb093da 100644 --- a/packages/shared/src/design-md.ts +++ b/packages/shared/src/design-md.ts @@ -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', @@ -431,9 +434,7 @@ export function serializeDesignMd(input: { sections?: DesignMdBodySection[]; }): string { // yaml leaves out the fields that are undefined. - const fields = Object.fromEntries( - [...ALLOWED_TOP_LEVEL].map((key) => [key, input.frontmatter[key as keyof DesignMdFrontmatter]]), - ); + 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) From 241fc55195e65293b863685df123efcb15aba021 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 14 Oct 2026 18:16:44 +0800 Subject: [PATCH 10/12] fix(core): export UnresolvedReason next to UnsupportedReason A caller that switches on report.unresolved[].reason could not name the union from the public entry point. Refs #484 --- packages/core/src/brand/index.ts | 1 + packages/core/src/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/core/src/brand/index.ts b/packages/core/src/brand/index.ts index add8ea5a..b37df164 100644 --- a/packages/core/src/brand/index.ts +++ b/packages/core/src/brand/index.ts @@ -6,6 +6,7 @@ export type { TokenFile, TokenImport, TokenImportReport, + UnresolvedReason, UnsupportedReason, } from './designFields.js'; export { mapTokensToDesignFields } from './designFields.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 309ec7bf..74f4dcb4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,6 +42,7 @@ export type { TokenFile, TokenImport, TokenImportReport, + UnresolvedReason, UnsupportedReason, } from './brand/index.js'; export { From ee2952d205f1cbbfeec84ce514e6ee4bd8c93b01 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Fri, 16 Oct 2026 00:41:32 +0800 Subject: [PATCH 11/12] fix(core): expand each CSS variable once resolveVars expanded a name once per reference. With --b: var(--a)var(--a) at every level, resolving the last one took 2^levels steps even when the result stayed empty, because the length limit never applies to an empty value and the depth limit counts levels, not calls: 24 tokens and 1,159 bytes of CSS took 9 seconds. A name that expanded is now kept for the rest of the mapping and reused by every later reference, so the work follows the size of the input. Only a success is kept: it depends on the name alone, since a cycle needs a loop in the names and a name that expanded reaches none. Each entry also keeps how many levels the name nests, so the depth limit still counts them when a name is reused deeper in a chain, and a name nothing defines still takes the fallback of each reference. Checked against the version without the cache on 600 random variable graphs, 227 of which reach the depth limit: the results are identical. --- packages/core/src/brand/designFields.test.ts | 133 +++++++++++++++++++ packages/core/src/brand/designFields.ts | 51 +++++-- 2 files changed, 174 insertions(+), 10 deletions(-) diff --git a/packages/core/src/brand/designFields.test.ts b/packages/core/src/brand/designFields.test.ts index d8567af4..c35d7208 100644 --- a/packages/core/src/brand/designFields.test.ts +++ b/packages/core/src/brand/designFields.test.ts @@ -323,6 +323,139 @@ describe('mapTokensToDesignFields()', () => { } }); + 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"]' }), diff --git a/packages/core/src/brand/designFields.ts b/packages/core/src/brand/designFields.ts index 22a40942..95215a6a 100644 --- a/packages/core/src/brand/designFields.ts +++ b/packages/core/src/brand/designFields.ts @@ -90,12 +90,29 @@ function tokenKey(name: string, type: DesignToken['type']): string { return key === '' ? 'base' : key; } -type Resolved = { ok: true; value: string } | { ok: false; reason: UnresolvedReason }; +/** `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, lookup: Map, chain: string[]): Resolved { +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); @@ -122,16 +139,29 @@ function resolveVars(value: string, lookup: Map, chain: string[] 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 target = lookup.get(name); - const source = target ?? fallback; - if (source === undefined) return { ok: false, reason: 'undefined' }; - const resolved = resolveVars(source, lookup, [...chain, name]); - if (!resolved.ok) return resolved; - out += resolved.value; + 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 }; + return { ok: true, value: out, below }; } export function mapTokensToDesignFields(files: TokenFile[]): TokenImport { @@ -142,6 +172,7 @@ export function mapTokensToDesignFields(files: TokenFile[]): TokenImport { 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 = {}; @@ -184,7 +215,7 @@ export function mapTokensToDesignFields(files: TokenFile[]): TokenImport { report.otherThemes.push({ block: token.block, value: token.value, ...origin }); continue; } - const resolved = resolveVars(token.value, lookup, [token.name]); + const resolved = resolveVars(token.value, expansion, [token.name]); if (!resolved.ok) { report.unresolved.push({ reason: resolved.reason, value: token.value, ...origin }); continue; From 0c4cc7c31b9f82ce16a42778d91a4aa6d47e56ef Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Fri, 16 Oct 2026 01:33:34 +0800 Subject: [PATCH 12/12] chore: run the checks again The last run failed in packages/exporters, which this PR does not touch: pptx-repair-edge.browser.test.ts timed out waiting 10 s for system Chromium to start, and pptx-native.browser.test.ts timed out at 30 s. main failed the same way on 466e64d, and the run for #492 on 82945a2 passed with the same test files. A pull request cannot re-run a workflow without admin rights, so this empty commit starts a new run. Refs #484