diff --git a/README.md b/README.md index 679e92b..c7fd0f1 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,13 @@ Both share the same prompts and Claude pipeline. ## How it works ``` -CSS / SCSS / HTML → Claude Audit → Review & curate → Clean tokens → Export +CSS / SCSS / HTML → Claude Audit → Review & curate → Clean tokens → Export → Apply ``` 1. **Audit** — Claude analyzes your CSS, groups near-duplicate colors into clusters, detects fonts, flags spacing values that don't fit a 4px grid, identifies duplicate transition/animation declarations, and lints layout patterns for accessibility and modern-CSS pitfalls (see [CSS layout linting](#css-layout-linting)). 2. **Curate** — Review each cluster. Pick the canonical color, rename tokens, include or exclude entries, and select which fonts to keep. (CLI applies sensible defaults: include every cluster, keep non-system fonts, use the suggested 4px scale.) 3. **Export** — Generate production-ready output in any format. +4. **Apply** — Rewrite your source CSS in place so raw values reference the generated tokens (`#1976d2` → `var(--color-primary)`). Deterministic, no LLM — adoption becomes a reviewable git diff. See [`mint-ds apply`](#applying-tokens-to-source-css). ## CSS layout linting @@ -258,8 +259,50 @@ npx mint-ds audit ./src/styles npx mint-ds export --target tailwind # → tailwind.config.js npx mint-ds export --target react # → components.tsx npx mint-ds export --target css # → variables.css + +# Rewrite your source CSS to reference the generated tokens +npx mint-ds apply ./src/styles --dry-run # preview the diff +npx mint-ds apply ./src/styles # write the changes in place +``` + +### Applying tokens to source CSS + +`mint-ds apply` closes the loop: it rewrites raw values in your source CSS/SCSS to reference the tokens you generated, so adopting a design system is a reviewable migration commit instead of manual find-and-replace. + +```bash +npx mint-ds apply [options] +``` + +It works from the tokens file alone — **deterministic, no LLM call**. Every color/spacing/font-family literal is normalized and matched against the tokens: + +```css +/* before */ /* after */ +color: #1976d2; +color: var(--color-primary); +border-color: #1565c0; +border-color: var(--color-primary-600); +background: rgb(25 118 210); +background: var(--color-primary); +padding: 8px; +padding: var(--spacing-2); +font-family: Inter, sans-serif; +font-family: var(--font-body); ``` +Different textual forms of the same color (`#1976D2`, `#1976d2`, `rgb(...)`) all match. It only touches the value side of declarations — comments, `url(...)`, and existing `var(...)` are left alone, and re-running is a no-op. + +| Flag | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--tokens ` | Tokens file to read (default `mint-ds.tokens.json`, or your `mint.config` value). | +| `--dry-run` | Print the substitutions without writing any file. | +| `--fuzzy` | Also snap near-duplicate colors and off-scale spacing to the closest token, leaving a `/* was 13px */` note. Off by default, so a plain run only makes exact, lossless substitutions. | +| `--force` | Skip the git-clean safety check (see below). | +| `--target` | Substitution style. Only `css-var` (the default) is supported today. | + +**Safety.** `apply` writes to your files in place, so by default it refuses to run when any target file has uncommitted changes (or when it can't verify git state) — commit or stash first, or pass `--force`. It never descends into `node_modules` and only touches source extensions (`.css`, `.scss`, `.sass`, `.less`, `.html`). Use `--dry-run` to preview, then let `git diff` be your review. + +> **Note:** in HTML files, CSS inside `').output).toBe( + '' + ) + expect(run('
').output).toBe( + '
' + ) + }) +}) diff --git a/lib/__tests__/css-values.test.mjs b/lib/__tests__/css-values.test.mjs new file mode 100644 index 0000000..bdd00a0 --- /dev/null +++ b/lib/__tests__/css-values.test.mjs @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest' +import { + normalizeColor, + parseSpacingPx, + normalizeFontFamily, + hexToRgb, +} from '../css-values.mjs' + +describe('normalizeColor', () => { + it('lowercases and expands hex forms to #rrggbb', () => { + expect(normalizeColor('#1976D2')).toBe('#1976d2') + expect(normalizeColor('#abc')).toBe('#aabbcc') + expect(normalizeColor('#1976d2')).toBe('#1976d2') + expect(normalizeColor('#197f')).toBe('#119977') + }) + + it('parses rgb() to #rrggbb', () => { + expect(normalizeColor('rgb(25, 118, 210)')).toBe('#1976d2') + expect(normalizeColor('rgb(25,118,210)')).toBe('#1976d2') + }) + + it('parses hsl() to #rrggbb', () => { + expect(normalizeColor('hsl(207, 79%, 46%)')).toBe('#197fd2') + }) + + it('returns null for colors with alpha < 1', () => { + expect(normalizeColor('rgba(25, 118, 210, 0.5)')).toBeNull() + expect(normalizeColor('#1976d280')).toBeNull() + expect(normalizeColor('hsla(207, 79%, 46%, 0.5)')).toBeNull() + expect(normalizeColor('#1976')).toBeNull() + }) + + it('keeps fully-opaque alpha forms', () => { + expect(normalizeColor('rgba(25, 118, 210, 1)')).toBe('#1976d2') + expect(normalizeColor('#1976d2ff')).toBe('#1976d2') + }) + + it('returns null for non-colors', () => { + expect(normalizeColor('inherit')).toBeNull() + expect(normalizeColor('')).toBeNull() + expect(normalizeColor('var(--x)')).toBeNull() + }) +}) + +describe('parseSpacingPx', () => { + it('parses px values to a number', () => { + expect(parseSpacingPx('13px')).toBe(13) + expect(parseSpacingPx(' 8px ')).toBe(8) + expect(parseSpacingPx('0px')).toBe(0) + }) + + it('returns null for non-px or unitless (except explicit px)', () => { + expect(parseSpacingPx('1rem')).toBeNull() + expect(parseSpacingPx('50%')).toBeNull() + expect(parseSpacingPx('auto')).toBeNull() + expect(parseSpacingPx('16')).toBeNull() + }) + + it('returns null for malformed multi-dot numeric values', () => { + expect(parseSpacingPx('1.2.3px')).toBeNull() + }) +}) + +describe('normalizeFontFamily', () => { + it('lowercases, trims, and collapses whitespace after commas', () => { + expect(normalizeFontFamily('Inter, sans-serif')).toBe('inter, sans-serif') + expect(normalizeFontFamily(' Inter , sans-serif ')).toBe( + 'inter, sans-serif' + ) + }) + + it('strips surrounding quotes on individual families', () => { + expect(normalizeFontFamily('"Helvetica Neue", Arial')).toBe( + 'helvetica neue, arial' + ) + expect(normalizeFontFamily("'Open Sans', sans-serif")).toBe( + 'open sans, sans-serif' + ) + }) +}) + +describe('hexToRgb', () => { + it('parses #rrggbb into r/g/b integers', () => { + expect(hexToRgb('#1976d2')).toEqual({ r: 25, g: 118, b: 210 }) + }) +}) diff --git a/lib/__tests__/git-status.test.mjs b/lib/__tests__/git-status.test.mjs new file mode 100644 index 0000000..a94d74d --- /dev/null +++ b/lib/__tests__/git-status.test.mjs @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import * as cp from 'node:child_process' +import { getDirtyFiles } from '../git-status.mjs' + +vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })) + +afterEach(() => vi.restoreAllMocks()) + +describe('getDirtyFiles', () => { + it('returns isRepo=false when git rev-parse fails', () => { + vi.spyOn(cp, 'execFileSync').mockImplementation(() => { + throw new Error('not a git repo') + }) + const res = getDirtyFiles(['a.css'], '/tmp/x') + expect(res.isRepo).toBe(false) + expect(res.dirty).toEqual([]) + }) + + it('returns the dirty subset from git status --porcelain', () => { + vi.spyOn(cp, 'execFileSync').mockImplementation((_bin, args) => { + if (args.includes('rev-parse')) return 'true\n' + return ' M a.css\n?? b.css\n' + }) + const res = getDirtyFiles(['a.css', 'b.css', 'c.css'], '/repo') + expect(res.isRepo).toBe(true) + expect(res.dirty.sort()).toEqual(['a.css', 'b.css']) + }) + + it('returns empty dirty list when the tree is clean', () => { + vi.spyOn(cp, 'execFileSync').mockImplementation((_bin, args) => { + if (args.includes('rev-parse')) return 'true\n' + return '' + }) + const res = getDirtyFiles(['a.css'], '/repo') + expect(res.isRepo).toBe(true) + expect(res.dirty).toEqual([]) + }) + + it('propagates a git status failure instead of reporting the tree clean', () => { + vi.spyOn(cp, 'execFileSync').mockImplementation((_bin, args) => { + if (args.includes('rev-parse')) return 'true\n' + const e = new Error('fatal: ../x.css: outside repository') + e.status = 128 + throw e + }) + expect(() => getDirtyFiles(['../x.css'], '/repo')).toThrow( + /outside repository/ + ) + }) +}) diff --git a/lib/__tests__/token-index.test.mjs b/lib/__tests__/token-index.test.mjs new file mode 100644 index 0000000..81cd8d5 --- /dev/null +++ b/lib/__tests__/token-index.test.mjs @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest' +import { buildTokenIndex } from '../token-index.mjs' + +const TOKENS = { + brand: 'demo', + colors: [ + { + name: 'primary', + value: '#1976d2', + scale: { 500: '#1976d2', 600: '#1565c0', 50: '#e3f2fd' }, + }, + ], + typography: { fontFamilies: { body: 'Inter, sans-serif' } }, + spacing: { 1: '4px', 2: '8px', 6: '24px' }, +} + +describe('buildTokenIndex', () => { + it('maps the representative color to the semantic base var', () => { + const idx = buildTokenIndex(TOKENS) + expect(idx.colorExact.get('#1976d2')).toBe('--color-primary') + }) + + it('maps non-500 scale steps to numbered vars', () => { + const idx = buildTokenIndex(TOKENS) + expect(idx.colorExact.get('#1565c0')).toBe('--color-primary-600') + expect(idx.colorExact.get('#e3f2fd')).toBe('--color-primary-50') + }) + + it('inverts the spacing scale to px -> var', () => { + const idx = buildTokenIndex(TOKENS) + expect(idx.spacingExact.get(4)).toBe('--spacing-1') + expect(idx.spacingExact.get(8)).toBe('--spacing-2') + expect(idx.spacingExact.get(24)).toBe('--spacing-6') + }) + + it('maps normalized font families to font vars', () => { + const idx = buildTokenIndex(TOKENS) + expect(idx.fontExact.get('inter, sans-serif')).toBe('--font-body') + }) + + it('marks a color shared by two tokens as ambiguous and drops it from colorExact', () => { + const tokens = { + ...TOKENS, + colors: [ + { name: 'primary', value: '#1976d2', scale: { 500: '#1976d2' } }, + { name: 'brandblue', value: '#1976d2', scale: { 500: '#1976d2' } }, + ], + } + const idx = buildTokenIndex(tokens) + expect(idx.ambiguousColors.has('#1976d2')).toBe(true) + expect(idx.colorExact.has('#1976d2')).toBe(false) + }) + + it('builds colorList and spacingList for fuzzy matching', () => { + const idx = buildTokenIndex(TOKENS) + expect(idx.colorList).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + r: 25, + g: 118, + b: 210, + varName: '--color-primary', + }), + ]) + ) + expect(idx.spacingList).toEqual( + expect.arrayContaining([{ px: 4, varName: '--spacing-1' }]) + ) + }) +}) diff --git a/lib/css-codemod.mjs b/lib/css-codemod.mjs new file mode 100644 index 0000000..df43f72 --- /dev/null +++ b/lib/css-codemod.mjs @@ -0,0 +1,218 @@ +// Substitution engine: rewrites raw color/spacing/font values in CSS text to +// reference design-token CSS variables. Operates on the value side of +// `prop: value` declarations only; skips comments, url(), and existing var(). + +import { + normalizeColor, + parseSpacingPx, + normalizeFontFamily, + hexToRgb, +} from './css-values.mjs' + +const SPACING_PROPS = new Set([ + 'margin', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'margin-block', + 'margin-block-start', + 'margin-block-end', + 'margin-inline', + 'margin-inline-start', + 'margin-inline-end', + 'padding', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'padding-block', + 'padding-block-start', + 'padding-block-end', + 'padding-inline', + 'padding-inline-start', + 'padding-inline-end', + 'gap', + 'row-gap', + 'column-gap', + 'top', + 'right', + 'bottom', + 'left', + 'inset', + 'inset-block', + 'inset-block-start', + 'inset-block-end', + 'inset-inline', + 'inset-inline-start', + 'inset-inline-end', +]) +const FONT_PROPS = new Set(['font-family']) + +const COLOR_RE = /#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/g +const LENGTH_RE = /-?[\d.]+px\b/g +const URL_RE = /url\([^)]*\)/gi +const COMMENT_RE = /\/\*[\s\S]*?\*\//g + +// Spans whose contents must never be substituted: comments, url(), and existing var(). +const PROTECTED_RE = /\/\*[\s\S]*?\*\/|url\([^)]*\)|var\([^)]*\)/gi + +const COLOR_FUZZY_THRESHOLD = 10 +const SPACING_FUZZY_THRESHOLD = 2 + +// Replace comments and url(...) spans with equal-length blanks so their offsets +// are preserved but their contents never match. Returns a masked copy. +function maskProtectedSpans(text) { + const blank = (m) => ' '.repeat(m.length) + return text.replace(COMMENT_RE, blank).replace(URL_RE, blank) +} + +function nearestColor(hex, colorList) { + const { r, g, b } = hexToRgb(hex) + let best = null + let bestD = Infinity + for (const c of colorList) { + const d = Math.sqrt((c.r - r) ** 2 + (c.g - g) ** 2 + (c.b - b) ** 2) + if (d < bestD) { + bestD = d + best = c + } + } + return best && bestD <= COLOR_FUZZY_THRESHOLD ? best : null +} + +function nearestSpacing(px, spacingList) { + let best = null + let bestD = Infinity + for (const s of spacingList) { + const d = Math.abs(s.px - px) + if (d < bestD) { + bestD = d + best = s + } + } + return best && bestD > 0 && bestD <= SPACING_FUZZY_THRESHOLD ? best : null +} + +// Split the value into protected spans (copied verbatim) and open segments +// (where raw literals are substituted). Because emitted output — var(...) and +// /* was ... */ comments — is itself protected, re-running is idempotent. +function rewriteValue(value, prop, index, options, edits) { + let result = '' + let last = 0 + let m + PROTECTED_RE.lastIndex = 0 + while ((m = PROTECTED_RE.exec(value)) !== null) { + result += rewriteOpen( + value.slice(last, m.index), + prop, + index, + options, + edits + ) + result += m[0] + last = m.index + m[0].length + } + result += rewriteOpen(value.slice(last), prop, index, options, edits) + return result +} + +function rewriteOpen(value, prop, index, { fuzzy }, edits) { + let out = value + + // Colors (any property) + out = out.replace(COLOR_RE, (token) => { + const hex = normalizeColor(token) + if (!hex) return token + if (index.colorExact.has(hex)) { + const to = `var(${index.colorExact.get(hex)})` + edits.push({ kind: 'color', from: token, to, lossy: false }) + return to + } + if (fuzzy && !index.ambiguousColors.has(hex)) { + const near = nearestColor(hex, index.colorList) + if (near) { + const to = `var(${near.varName})` + edits.push({ + kind: 'color', + from: token, + to: `${to} /* was ${token} */`, + lossy: true, + }) + return `${to} /* was ${token} */` + } + } + return token + }) + + // Spacing (spacing properties only) + if (SPACING_PROPS.has(prop)) { + out = out.replace(LENGTH_RE, (token) => { + const px = parseSpacingPx(token) + if (px === null || px === 0) return token + if (index.spacingExact.has(px)) { + const to = `var(${index.spacingExact.get(px)})` + edits.push({ kind: 'spacing', from: token, to, lossy: false }) + return to + } + if (fuzzy) { + const near = nearestSpacing(px, index.spacingList) + if (near) { + const to = `var(${near.varName})` + edits.push({ + kind: 'spacing', + from: token, + to: `${to} /* was ${token} */`, + lossy: true, + }) + return `${to} /* was ${token} */` + } + } + return token + }) + } + + // Font family (font-family only), whole-value match + if (FONT_PROPS.has(prop)) { + const norm = normalizeFontFamily(out) + if (index.fontExact.has(norm)) { + const to = `var(${index.fontExact.get(norm)})` + edits.push({ kind: 'font', from: value.trim(), to, lossy: false }) + const lead = value.match(/^\s*/)[0] + const trail = value.match(/\s*$/)[0] + out = `${lead}${to}${trail}` + } + } + + return out +} + +export function applyCodemod(source, index, options = {}) { + const { fuzzy = false } = options + const edits = [] + const masked = maskProtectedSpans(source) + + // Find declarations `prop: value` inside rule bodies. Value ends at ; or }. + const DECL_RE = /([{;]\s*)([-\w]+)(\s*:\s*)([^;{}]+)/g + let output = '' + let cursor = 0 + let m + while ((m = DECL_RE.exec(masked)) !== null) { + const rawProp = m[2] + const rawValue = m[4] + const prop = rawProp.toLowerCase() + const valueStart = m.index + m[1].length + m[2].length + m[3].length + const valueEnd = valueStart + rawValue.length + output += source.slice(cursor, valueStart) + output += rewriteValue( + source.slice(valueStart, valueEnd), + prop, + index, + { fuzzy }, + edits + ) + cursor = valueEnd + } + output += source.slice(cursor) + return { output, edits } +} diff --git a/lib/css-values.mjs b/lib/css-values.mjs new file mode 100644 index 0000000..b86d803 --- /dev/null +++ b/lib/css-values.mjs @@ -0,0 +1,113 @@ +// Pure CSS value normalization helpers. Dependency-free. + +function clamp255(n) { + return Math.max(0, Math.min(255, Math.round(n))) +} + +function toHex(r, g, b) { + const h = (n) => clamp255(n).toString(16).padStart(2, '0') + return `#${h(r)}${h(g)}${h(b)}` +} + +function hslToRgb(h, s, l) { + h = ((h % 360) + 360) % 360 + s /= 100 + l /= 100 + const c = (1 - Math.abs(2 * l - 1)) * s + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = l - c / 2 + let r = 0, + g = 0, + b = 0 + if (h < 60) [r, g, b] = [c, x, 0] + else if (h < 120) [r, g, b] = [x, c, 0] + else if (h < 180) [r, g, b] = [0, c, x] + else if (h < 240) [r, g, b] = [0, x, c] + else if (h < 300) [r, g, b] = [x, 0, c] + else [r, g, b] = [c, 0, x] + return [(r + m) * 255, (g + m) * 255, (b + m) * 255] +} + +function parseAlpha(a) { + return a.endsWith('%') ? Number(a.slice(0, -1)) / 100 : Number(a) +} + +// Returns a canonical "#rrggbb" for fully-opaque colors, or null when the input +// is not an opaque color literal (alpha < 1, keyword, var(), empty, unsupported). +export function normalizeColor(input) { + if (typeof input !== 'string') return null + const str = input.trim().toLowerCase() + if (!str) return null + + // hex + let m = /^#([0-9a-f]{3,8})$/.exec(str) + if (m) { + const h = m[1] + if (h.length === 3) return `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}` + if (h.length === 4) { + if (h[3] !== 'f') return null // alpha < 1 + return `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}` + } + if (h.length === 6) return `#${h}` + if (h.length === 8) { + if (h.slice(6) !== 'ff') return null // alpha < 1 + return `#${h.slice(0, 6)}` + } + return null + } + + // rgb()/rgba() + m = + /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,/\s]+([\d.]+%?))?\s*\)$/.exec( + str + ) + if (m) { + if (m[4] !== undefined && parseAlpha(m[4]) < 1) return null + return toHex(Number(m[1]), Number(m[2]), Number(m[3])) + } + + // hsl()/hsla() + m = + /^hsla?\(\s*([\d.]+)(?:deg)?[,\s]+([\d.]+)%[,\s]+([\d.]+)%(?:[,/\s]+([\d.]+%?))?\s*\)$/.exec( + str + ) + if (m) { + if (m[4] !== undefined && parseAlpha(m[4]) < 1) return null + const [r, g, b] = hslToRgb(Number(m[1]), Number(m[2]), Number(m[3])) + return toHex(r, g, b) + } + + return null +} + +// Splits a "#rrggbb" hex string into its r/g/b integer components. +export function hexToRgb(hex) { + return { + r: parseInt(hex.slice(1, 3), 16), + g: parseInt(hex.slice(3, 5), 16), + b: parseInt(hex.slice(5, 7), 16), + } +} + +// Returns the numeric px value, or null when the value is not an explicit px length. +export function parseSpacingPx(input) { + if (typeof input !== 'string') return null + const m = /^\s*(-?\d+(?:\.\d+)?)px\s*$/.exec(input) + return m ? Number(m[1]) : null +} + +// Canonical form for comparing font-family declarations: lowercased, unquoted, +// single space after each comma. +export function normalizeFontFamily(input) { + if (typeof input !== 'string') return '' + return input + .split(',') + .map((part) => + part + .trim() + .replace(/^['"]|['"]$/g, '') + .toLowerCase() + ) + .filter(Boolean) + .join(', ') +} diff --git a/lib/git-status.mjs b/lib/git-status.mjs new file mode 100644 index 0000000..ad779e1 --- /dev/null +++ b/lib/git-status.mjs @@ -0,0 +1,26 @@ +// Thin wrapper over `git status --porcelain` used by `apply` to refuse +// overwriting files with uncommitted changes. Falls back gracefully when the +// path is not inside a git repository. + +import { execFileSync } from 'node:child_process' + +function git(args, cwd) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }) +} + +// Returns { isRepo, dirty } where `dirty` is the subset of `files` that have +// uncommitted changes (modified, staged, or untracked). +export function getDirtyFiles(files, cwd) { + try { + git(['rev-parse', '--is-inside-work-tree'], cwd) + } catch { + return { isRepo: false, dirty: [] } + } + const out = git(['status', '--porcelain', '--', ...files], cwd) + const dirty = out + .split('\n') + .filter(Boolean) + .map((line) => line.slice(3).trim()) + .filter(Boolean) + return { isRepo: true, dirty } +} diff --git a/lib/token-index.mjs b/lib/token-index.mjs new file mode 100644 index 0000000..b392202 --- /dev/null +++ b/lib/token-index.mjs @@ -0,0 +1,76 @@ +// Builds deterministic lookup maps from a DSTokens object. Owns the CSS variable +// naming convention (must match the css-variables export prompt): +// color base value -> --color- +// other scale steps -> --color-- +// spacing -> --spacing- +// font family -> --font- + +import { + normalizeColor, + parseSpacingPx, + normalizeFontFamily, + hexToRgb, +} from './css-values.mjs' + +export function buildTokenIndex(tokens) { + const colorExact = new Map() + const ambiguousColors = new Set() + const spacingExact = new Map() + const fontExact = new Map() + + const setColor = (hex, varName) => { + if (!hex) return + if (ambiguousColors.has(hex)) return + if (colorExact.has(hex) && colorExact.get(hex) !== varName) { + colorExact.delete(hex) + ambiguousColors.add(hex) + return + } + colorExact.set(hex, varName) + } + + for (const color of tokens.colors || []) { + const base = normalizeColor(color.value) + setColor(base, `--color-${color.name}`) + for (const [step, val] of Object.entries(color.scale || {})) { + if (String(step) === '500') continue // same value as base; base wins + setColor(normalizeColor(val), `--color-${color.name}-${step}`) + } + } + + // Unlike colors (which track ambiguity via `ambiguousColors` and drop the + // mapping on conflict), spacing and font collisions resolve first-wins: the + // first key/value seen for a given px or normalized font wins and later + // duplicates are silently ignored. This is rare in practice (design tokens + // rarely define two names for the same spacing step or font stack). + for (const [key, val] of Object.entries(tokens.spacing || {})) { + const px = parseSpacingPx(val) + if (px !== null && !spacingExact.has(px)) { + spacingExact.set(px, `--spacing-${key}`) + } + } + + const families = (tokens.typography && tokens.typography.fontFamilies) || {} + for (const [key, val] of Object.entries(families)) { + const norm = normalizeFontFamily(val) + if (norm && !fontExact.has(norm)) fontExact.set(norm, `--font-${key}`) + } + + const colorList = [...colorExact.entries()].map(([hex, varName]) => ({ + ...hexToRgb(hex), + varName, + })) + const spacingList = [...spacingExact.entries()].map(([px, varName]) => ({ + px, + varName, + })) + + return { + colorExact, + spacingExact, + fontExact, + colorList, + spacingList, + ambiguousColors, + } +} diff --git a/package.json b/package.json index 50e54dc..3f553cb 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,10 @@ "lib/token-diff.mjs", "lib/mint-config.mjs", "lib/net-utils.mjs", + "lib/css-values.mjs", + "lib/token-index.mjs", + "lib/css-codemod.mjs", + "lib/git-status.mjs", "lib/llm_providers/**/*.mjs", "README.md" ],