From 2ac6dd1a2efb5c625230f2ee6676c3bed777e96b Mon Sep 17 00:00:00 2001 From: Nadia Nujovich Date: Fri, 17 Jul 2026 16:15:09 +0200 Subject: [PATCH 1/3] feat: add mint-ds apply command to codemod source CSS to tokens (#35) Deterministic, no-LLM codemod that rewrites raw color/spacing/font-family values in source CSS to reference the generated design-token CSS variables. Exact matches always; near-duplicate/off-scale snaps behind --fuzzy (with /* was X */ comments). Writes in place, guarded by a git-clean check (--force bypasses). Skips comments/url()/var(), never node_modules, only source extensions, idempotent. --target css-var only in slice 1. New modules: lib/css-values.mjs, lib/token-index.mjs, lib/css-codemod.mjs, lib/git-status.mjs (all added to published files + check:pack). --- bin/__tests__/apply.test.mjs | 118 +++++++++++++++ bin/mint-ds.mjs | 136 +++++++++++++++++ lib/__tests__/css-codemod.test.mjs | 144 ++++++++++++++++++ lib/__tests__/css-values.test.mjs | 79 ++++++++++ lib/__tests__/git-status.test.mjs | 38 +++++ lib/__tests__/token-index.test.mjs | 70 +++++++++ lib/css-codemod.mjs | 225 +++++++++++++++++++++++++++++ lib/css-values.mjs | 104 +++++++++++++ lib/git-status.mjs | 31 ++++ lib/token-index.mjs | 78 ++++++++++ package.json | 4 + 11 files changed, 1027 insertions(+) create mode 100644 bin/__tests__/apply.test.mjs create mode 100644 lib/__tests__/css-codemod.test.mjs create mode 100644 lib/__tests__/css-values.test.mjs create mode 100644 lib/__tests__/git-status.test.mjs create mode 100644 lib/__tests__/token-index.test.mjs create mode 100644 lib/css-codemod.mjs create mode 100644 lib/css-values.mjs create mode 100644 lib/git-status.mjs create mode 100644 lib/token-index.mjs diff --git a/bin/__tests__/apply.test.mjs b/bin/__tests__/apply.test.mjs new file mode 100644 index 0000000..c9cbb61 --- /dev/null +++ b/bin/__tests__/apply.test.mjs @@ -0,0 +1,118 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' + +const BIN = path.resolve('bin/mint-ds.mjs') +const TMP = path.resolve('node_modules/.tmp-apply') + +async function writeRepo(files) { + await fs.mkdir(TMP, { recursive: true }) + const dir = await fs.mkdtemp(path.join(TMP, 'case-')) + execFileSync('git', ['init', '-q'], { cwd: dir }) + execFileSync('git', ['config', 'user.email', 't@t.co'], { cwd: dir }) + execFileSync('git', ['config', 'user.name', 't'], { cwd: dir }) + execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: dir }) + for (const [name, content] of Object.entries(files)) { + await fs.writeFile(path.join(dir, name), content, 'utf8') + } + await fs.writeFile( + path.join(dir, 'mint-ds.tokens.json'), + JSON.stringify({ + colors: [ + { name: 'primary', value: '#1976d2', scale: { 500: '#1976d2' } }, + ], + typography: { fontFamilies: {} }, + spacing: { 2: '8px' }, + }), + 'utf8' + ) + execFileSync('git', ['add', '.'], { cwd: dir }) + execFileSync('git', ['commit', '-qm', 'init'], { cwd: dir }) + return dir +} + +function runCli(args, cwd) { + try { + const stdout = execFileSync('node', [BIN, ...args], { + cwd, + encoding: 'utf8', + }) + return { code: 0, stdout } + } catch (e) { + return { code: e.status || 1, stdout: (e.stdout || '') + (e.stderr || '') } + } +} + +afterEach(async () => { + await fs.rm(TMP, { recursive: true, force: true }) +}) + +describe('mint-ds apply', () => { + it('--dry-run prints the diff and writes nothing', async () => { + const dir = await writeRepo({ 'a.css': '.a { color: #1976d2; }' }) + const res = runCli(['apply', '.', '--dry-run'], dir) + expect(res.stdout).toContain('var(--color-primary)') + const after = await fs.readFile(path.join(dir, 'a.css'), 'utf8') + expect(after).toBe('.a { color: #1976d2; }') + }) + + it('writes in place when the tree is clean', async () => { + const dir = await writeRepo({ + 'a.css': '.a { color: #1976d2; padding: 8px; }', + }) + const res = runCli(['apply', '.'], dir) + expect(res.code).toBe(0) + const after = await fs.readFile(path.join(dir, 'a.css'), 'utf8') + expect(after).toBe( + '.a { color: var(--color-primary); padding: var(--spacing-2); }' + ) + }) + + it('refuses to write when a target file is dirty, unless --force', async () => { + const dir = await writeRepo({ 'a.css': '.a { color: #1976d2; }' }) + await fs.writeFile( + path.join(dir, 'a.css'), + '.a { color: #1976d2; /* edit */ }', + 'utf8' + ) + const refused = runCli(['apply', '.'], dir) + expect(refused.code).not.toBe(0) + expect(refused.stdout).toMatch(/uncommitted changes/i) + expect(await fs.readFile(path.join(dir, 'a.css'), 'utf8')).toContain( + '/* edit */' + ) + const forced = runCli(['apply', '.', '--force'], dir) + expect(forced.code).toBe(0) + expect(await fs.readFile(path.join(dir, 'a.css'), 'utf8')).toContain( + 'var(--color-primary)' + ) + }) + + it('rejects an unsupported --target', async () => { + const dir = await writeRepo({ 'a.css': '.a { color: #1976d2; }' }) + const res = runCli(['apply', '.', '--target', 'tailwind-class'], dir) + expect(res.code).not.toBe(0) + expect(res.stdout).toMatch(/target/i) + }) + + it('refuses to rewrite a single non-source file', async () => { + const dir = await writeRepo({ 'a.css': '.a { color: #1976d2; }' }) + await fs.writeFile(path.join(dir, 'notes.txt'), 'raw #1976d2 here', 'utf8') + const res = runCli(['apply', 'notes.txt'], dir) + expect(res.code).not.toBe(0) + expect(res.stdout).toMatch(/unsupported file type/i) + expect(await fs.readFile(path.join(dir, 'notes.txt'), 'utf8')).toBe( + 'raw #1976d2 here' + ) + }) + + it('rewrites a single source file passed directly', async () => { + const dir = await writeRepo({ 'a.css': '.a { color: #1976d2; }' }) + const res = runCli(['apply', 'a.css'], dir) + expect(res.code).toBe(0) + expect(await fs.readFile(path.join(dir, 'a.css'), 'utf8')).toBe( + '.a { color: var(--color-primary); }' + ) + }) +}) diff --git a/bin/mint-ds.mjs b/bin/mint-ds.mjs index 6095238..8a71f33 100755 --- a/bin/mint-ds.mjs +++ b/bin/mint-ds.mjs @@ -24,6 +24,9 @@ import { formatLintSummary } from '../lib/audit-summary.mjs' import { checkCompat } from '../lib/css-compat-data.mjs' import { lintCss, lintGapDecorationAdoption } from '../lib/css-lint-rules.mjs' import { applyWsl2DnsWorkaround } from '../lib/net-utils.mjs' +import { buildTokenIndex } from '../lib/token-index.mjs' +import { applyCodemod } from '../lib/css-codemod.mjs' +import { getDirtyFiles } from '../lib/git-status.mjs' import { DEFAULT_TOKENS_FILE, loadConfig, @@ -108,6 +111,7 @@ ${styles.bold('COMMANDS')} validate [options] Validate tokens.json against a spec (e.g. dtcg) diff Show changes between two token files cache --clear Delete the local ${CACHE_FILE} + apply Rewrite source CSS to use generated token variables compat Scan CSS for Interop 2026 browser-compat issues (warnings + suggestions) lint Run static CSS lint rules on files in score Compute the CSS health score and per-metric breakdown @@ -131,6 +135,13 @@ ${styles.bold('EXPORT OPTIONS')} --out Override the generated filename --stdout Print to stdout instead of writing a file +${styles.bold('APPLY OPTIONS')} + --tokens Tokens file (default: ${DEFAULT_TOKENS_FILE}) + --target Codemod target (default: css-var; only value supported for now) + --fuzzy Also snap near-duplicate/off-scale values + --dry-run Print the diff without writing + --force Skip the git-clean check + ${styles.bold('VALIDATE OPTIONS')} --spec Spec to validate against (default: dtcg). Values: dtcg --json Output results as JSON @@ -179,6 +190,8 @@ ${styles.bold('EXAMPLES')} npx mint-ds lint ./src/styles npx mint-ds score ./src/styles npx mint-ds score ./src/styles --json + npx mint-ds apply ./src/styles --dry-run + npx mint-ds apply ./src/styles `) } @@ -727,6 +740,128 @@ async function cmdScore(argv) { if (report.exitCode) process.exit(report.exitCode) } +// Walk `target` (file or directory) for source files, reusing the same walk +// used by `collectSources` but returning individual file paths rather than a +// combined CSS blob — `apply` rewrites each file separately. +async function collectSourceFiles(target, ignore = []) { + const root = path.resolve(target) + const stat = await fs.stat(root).catch(() => null) + if (!stat) die(`Path not found: ${target}`) + + if (stat.isFile()) { + if (!SOURCE_EXTS.has(path.extname(root).toLowerCase())) { + die( + `Unsupported file type: ${target} (expected .css/.scss/.sass/.less/.html)` + ) + } + // apply writes to disk — respect the ignore list even for an explicit file + if (matchesIgnore(path.relative(process.cwd(), root), ignore)) return [] + return [root] + } + + const files = [] + for await (const file of walk(root, root, ignore)) files.push(file) + return files +} + +async function cmdApply(argv) { + const { flags, rest } = parseFlags(argv) + const target = rest[0] || '.' + + const targetFormat = flags.target ? String(flags.target) : 'css-var' + if (targetFormat !== 'css-var') { + die( + `Unsupported --target "${targetFormat}". Slice 1 supports only "css-var".` + ) + } + + const { config } = await loadConfig(process.cwd()) + const { ignore } = resolveAuditOptions({ config }) + const tokensPath = flags.tokens + ? String(flags.tokens) + : (config.tokens ?? DEFAULT_TOKENS_FILE) + + const tokensRaw = await fs.readFile(tokensPath, 'utf8').catch(() => null) + if (tokensRaw === null) { + die( + `Tokens file not found: ${tokensPath}\n Run "mint-ds audit " first, or pass --tokens .` + ) + } + let tokens + try { + tokens = JSON.parse(tokensRaw) + } catch { + die(`Tokens file is not valid JSON: ${tokensPath}`) + } + const index = buildTokenIndex(tokens) + + log(styles.cyan('→') + ` Reading sources from ${styles.bold(target)}…`) + const files = await collectSourceFiles(target, ignore) + if (files.length === 0) { + process.stdout.write('No source files found.\n') + return + } + + const fuzzy = Boolean(flags.fuzzy) + const dryRun = Boolean(flags['dry-run']) + const force = Boolean(flags.force) + + const results = [] + for (const abs of files) { + const src = await fs.readFile(abs, 'utf8') + const { output, edits } = applyCodemod(src, index, { fuzzy }) + if (edits.length > 0) results.push({ abs, output, edits }) + } + + if (results.length === 0) { + process.stdout.write('No substitutions to make.\n') + return + } + + if (dryRun) { + for (const r of results) { + process.stdout.write(`\n--- ${path.relative(process.cwd(), r.abs)}\n`) + for (const e of r.edits) { + process.stdout.write(` ${e.from} -> ${e.to}\n`) + } + } + const total = results.reduce((n, r) => n + r.edits.length, 0) + process.stdout.write( + `\n${total} substitution(s) in ${results.length} file(s) (dry run).\n` + ) + return + } + + if (!force) { + const rel = results.map((r) => path.relative(process.cwd(), r.abs)) + const { isRepo, dirty } = getDirtyFiles(rel, process.cwd()) + if (!isRepo) { + die( + 'Not a git repository — cannot verify a clean tree. Re-run with --force to proceed.' + ) + } + if (dirty.length > 0) { + die( + `${dirty.length} file(s) have uncommitted changes; commit them or pass --force:\n ` + + dirty.join('\n ') + ) + } + } + + let total = 0 + let lossy = 0 + for (const r of results) { + await fs.writeFile(r.abs, r.output, 'utf8') + total += r.edits.length + lossy += r.edits.filter((e) => e.lossy).length + } + process.stdout.write( + `Rewrote ${results.length} file(s), ${total} substitution(s)` + + (lossy > 0 ? ` (${lossy} lossy)` : '') + + '.\n' + ) +} + async function main() { // Mitigate WSL2 IPv6 fetch hangs to remote LLM APIs (issue #19). applyWsl2DnsWorkaround() @@ -751,6 +886,7 @@ async function main() { else if (cmd === 'compat') await cmdCompat(rest) else if (cmd === 'lint') await cmdLint(rest) else if (cmd === 'score') await cmdScore(rest) + else if (cmd === 'apply') await cmdApply(rest) else { printHelp() die(`Unknown command: ${cmd}`) diff --git a/lib/__tests__/css-codemod.test.mjs b/lib/__tests__/css-codemod.test.mjs new file mode 100644 index 0000000..398b260 --- /dev/null +++ b/lib/__tests__/css-codemod.test.mjs @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest' +import { applyCodemod } from '../css-codemod.mjs' +import { buildTokenIndex } from '../token-index.mjs' + +const INDEX = buildTokenIndex({ + colors: [ + { + name: 'primary', + value: '#1976d2', + scale: { 500: '#1976d2', 600: '#1565c0' }, + }, + ], + typography: { fontFamilies: { body: 'Inter, sans-serif' } }, + spacing: { 1: '4px', 2: '8px' }, +}) + +const run = (src, opts) => applyCodemod(src, INDEX, opts) + +describe('applyCodemod — exact', () => { + it('replaces an exact representative color with the semantic base var', () => { + const { output } = run('.a { color: #1976d2; }') + expect(output).toBe('.a { color: var(--color-primary); }') + }) + + it('normalizes format before matching (uppercase hex, rgb form)', () => { + expect(run('.a { color: #1976D2; }').output).toBe( + '.a { color: var(--color-primary); }' + ) + expect(run('.a { color: rgb(25, 118, 210); }').output).toBe( + '.a { color: var(--color-primary); }' + ) + }) + + it('replaces a non-500 scale step with its numbered var', () => { + expect(run('.a { border-color: #1565c0; }').output).toBe( + '.a { border-color: var(--color-primary-600); }' + ) + }) + + it('replaces exact spacing only in spacing properties', () => { + expect(run('.a { padding: 8px; }').output).toBe( + '.a { padding: var(--spacing-2); }' + ) + expect(run('.a { width: 8px; }').output).toBe('.a { width: 8px; }') + }) + + it('replaces each token in a multi-value shorthand', () => { + expect(run('.a { padding: 4px 8px; }').output).toBe( + '.a { padding: var(--spacing-1) var(--spacing-2); }' + ) + }) + + it('replaces an exact font-family', () => { + expect(run('.a { font-family: Inter, sans-serif; }').output).toBe( + '.a { font-family: var(--font-body); }' + ) + }) + + it('is idempotent — already-var values are left alone', () => { + const once = run('.a { color: #1976d2; }').output + expect(run(once).output).toBe(once) + }) + + it('never touches values inside url() or comments', () => { + expect(run('.a { background: url(#1976d2.png); }').output).toBe( + '.a { background: url(#1976d2.png); }' + ) + expect(run('/* #1976d2 */ .a { top: 0; }').output).toBe( + '/* #1976d2 */ .a { top: 0; }' + ) + }) + + it('reports edits', () => { + const { edits } = run('.a { color: #1976d2; padding: 8px; }') + expect(edits).toEqual([ + { + kind: 'color', + from: '#1976d2', + to: 'var(--color-primary)', + lossy: false, + }, + { kind: 'spacing', from: '8px', to: 'var(--spacing-2)', lossy: false }, + ]) + }) +}) + +describe('applyCodemod — fuzzy', () => { + it('does nothing near-duplicate without --fuzzy', () => { + expect(run('.a { color: #1a75d1; }').output).toBe('.a { color: #1a75d1; }') + }) + + it('snaps a near-duplicate color with a comment when fuzzy', () => { + const { output, edits } = run('.a { color: #1a75d1; }', { fuzzy: true }) + expect(output).toBe('.a { color: var(--color-primary) /* was #1a75d1 */; }') + expect(edits[0]).toEqual({ + kind: 'color', + from: '#1a75d1', + to: 'var(--color-primary) /* was #1a75d1 */', + lossy: true, + }) + }) + + it('snaps an off-scale spacing to the nearest step with a comment when fuzzy', () => { + expect(run('.a { padding: 9px; }', { fuzzy: true }).output).toBe( + '.a { padding: var(--spacing-2) /* was 9px */; }' + ) + }) + + it('leaves far-off values untouched even with fuzzy', () => { + expect(run('.a { color: #00ff00; }', { fuzzy: true }).output).toBe( + '.a { color: #00ff00; }' + ) + expect(run('.a { padding: 13px; }', { fuzzy: true }).output).toBe( + '.a { padding: 13px; }' + ) + }) +}) + +describe('applyCodemod — protected spans', () => { + it('leaves token-matching literals inside an inline comment untouched', () => { + expect(run('.a { color: #1976d2 /* keep #1565c0 */; }').output).toBe( + '.a { color: var(--color-primary) /* keep #1565c0 */; }' + ) + }) + + it('does not substitute a raw literal inside an existing var() fallback', () => { + expect(run('.a { color: var(--brand, #1976d2); }').output).toBe( + '.a { color: var(--brand, #1976d2); }' + ) + }) + + it('fuzzy output is idempotent when re-run', () => { + const once = run('.a { color: #1a75d1; padding: 9px; }', { + fuzzy: true, + }).output + const twice = run(once, { fuzzy: true }).output + expect(twice).toBe(once) + }) + + it('exact output is idempotent when re-run', () => { + const once = run('.a { color: #1976d2; padding: 8px; }').output + expect(run(once).output).toBe(once) + }) +}) diff --git a/lib/__tests__/css-values.test.mjs b/lib/__tests__/css-values.test.mjs new file mode 100644 index 0000000..de3c6f6 --- /dev/null +++ b/lib/__tests__/css-values.test.mjs @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { + normalizeColor, + parseSpacingPx, + normalizeFontFamily, +} 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' + ) + }) +}) diff --git a/lib/__tests__/git-status.test.mjs b/lib/__tests__/git-status.test.mjs new file mode 100644 index 0000000..6c100ac --- /dev/null +++ b/lib/__tests__/git-status.test.mjs @@ -0,0 +1,38 @@ +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([]) + }) +}) 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..ea0c918 --- /dev/null +++ b/lib/css-codemod.mjs @@ -0,0 +1,225 @@ +// 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, +} 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 hexToRgb(hex) { + return { + r: parseInt(hex.slice(1, 3), 16), + g: parseInt(hex.slice(3, 5), 16), + b: parseInt(hex.slice(5, 7), 16), + } +} + +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..3973811 --- /dev/null +++ b/lib/css-values.mjs @@ -0,0 +1,104 @@ +// 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 +} + +// 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..8dc8d22 --- /dev/null +++ b/lib/git-status.mjs @@ -0,0 +1,31 @@ +// 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: [] } + } + let out = '' + try { + out = git(['status', '--porcelain', '--', ...files], cwd) + } catch { + return { isRepo: true, dirty: [] } + } + 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..4fd415d --- /dev/null +++ b/lib/token-index.mjs @@ -0,0 +1,78 @@ +// 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, +} from './css-values.mjs' + +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), + } +} + +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}`) + } + } + + 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" ], From 13995272e75177cadda4fa01208eb4eea1720c25 Mon Sep 17 00:00:00 2001 From: Nadia Nujovich Date: Fri, 17 Jul 2026 16:20:47 +0200 Subject: [PATCH 2/3] fix: harden apply git-guard, document HTML scope, dedupe hexToRgb (#35) --- bin/__tests__/apply.test.mjs | 9 +++++++++ bin/mint-ds.mjs | 17 ++++++++++++----- lib/__tests__/css-codemod.test.mjs | 11 +++++++++++ lib/__tests__/css-values.test.mjs | 7 +++++++ lib/__tests__/git-status.test.mjs | 12 ++++++++++++ lib/css-codemod.mjs | 9 +-------- lib/css-values.mjs | 9 +++++++++ lib/git-status.mjs | 7 +------ lib/token-index.mjs | 14 ++++++-------- 9 files changed, 68 insertions(+), 27 deletions(-) diff --git a/bin/__tests__/apply.test.mjs b/bin/__tests__/apply.test.mjs index c9cbb61..72bfafd 100644 --- a/bin/__tests__/apply.test.mjs +++ b/bin/__tests__/apply.test.mjs @@ -115,4 +115,13 @@ describe('mint-ds apply', () => { '.a { color: var(--color-primary); }' ) }) + + it('refuses to write a file outside the git repo without --force', async () => { + const dir = await writeRepo({ 'a.css': '.a { color: #1976d2; }' }) + const outside = path.join(TMP, 'outside.css') + await fs.writeFile(outside, '.x { color: #1976d2; }', 'utf8') + const res = runCli(['apply', outside], dir) + expect(res.code).not.toBe(0) + expect(await fs.readFile(outside, 'utf8')).toBe('.x { color: #1976d2; }') // untouched + }) }) diff --git a/bin/mint-ds.mjs b/bin/mint-ds.mjs index 8a71f33..f24ce75 100755 --- a/bin/mint-ds.mjs +++ b/bin/mint-ds.mjs @@ -834,16 +834,23 @@ async function cmdApply(argv) { if (!force) { const rel = results.map((r) => path.relative(process.cwd(), r.abs)) - const { isRepo, dirty } = getDirtyFiles(rel, process.cwd()) - if (!isRepo) { + let status + try { + status = getDirtyFiles(rel, process.cwd()) + } catch (err) { + die( + `Could not verify git status (${String(err.message).split('\n')[0]}); commit your changes or pass --force.` + ) + } + if (!status.isRepo) { die( 'Not a git repository — cannot verify a clean tree. Re-run with --force to proceed.' ) } - if (dirty.length > 0) { + if (status.dirty.length > 0) { die( - `${dirty.length} file(s) have uncommitted changes; commit them or pass --force:\n ` + - dirty.join('\n ') + `${status.dirty.length} file(s) have uncommitted changes; commit them or pass --force:\n ` + + status.dirty.join('\n ') ) } } diff --git a/lib/__tests__/css-codemod.test.mjs b/lib/__tests__/css-codemod.test.mjs index 398b260..09478bc 100644 --- a/lib/__tests__/css-codemod.test.mjs +++ b/lib/__tests__/css-codemod.test.mjs @@ -142,3 +142,14 @@ describe('applyCodemod — protected spans', () => { expect(run(once).output).toBe(once) }) }) + +describe('applyCodemod — html', () => { + it('rewrites CSS in ').output).toBe( + '' + ) + expect(run('
').output).toBe( + '
' + ) + }) +}) diff --git a/lib/__tests__/css-values.test.mjs b/lib/__tests__/css-values.test.mjs index de3c6f6..bdd00a0 100644 --- a/lib/__tests__/css-values.test.mjs +++ b/lib/__tests__/css-values.test.mjs @@ -3,6 +3,7 @@ import { normalizeColor, parseSpacingPx, normalizeFontFamily, + hexToRgb, } from '../css-values.mjs' describe('normalizeColor', () => { @@ -77,3 +78,9 @@ describe('normalizeFontFamily', () => { ) }) }) + +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 index 6c100ac..a94d74d 100644 --- a/lib/__tests__/git-status.test.mjs +++ b/lib/__tests__/git-status.test.mjs @@ -35,4 +35,16 @@ describe('getDirtyFiles', () => { 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/css-codemod.mjs b/lib/css-codemod.mjs index ea0c918..df43f72 100644 --- a/lib/css-codemod.mjs +++ b/lib/css-codemod.mjs @@ -6,6 +6,7 @@ import { normalizeColor, parseSpacingPx, normalizeFontFamily, + hexToRgb, } from './css-values.mjs' const SPACING_PROPS = new Set([ @@ -66,14 +67,6 @@ function maskProtectedSpans(text) { return text.replace(COMMENT_RE, blank).replace(URL_RE, blank) } -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), - } -} - function nearestColor(hex, colorList) { const { r, g, b } = hexToRgb(hex) let best = null diff --git a/lib/css-values.mjs b/lib/css-values.mjs index 3973811..b86d803 100644 --- a/lib/css-values.mjs +++ b/lib/css-values.mjs @@ -80,6 +80,15 @@ export function normalizeColor(input) { 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 diff --git a/lib/git-status.mjs b/lib/git-status.mjs index 8dc8d22..ad779e1 100644 --- a/lib/git-status.mjs +++ b/lib/git-status.mjs @@ -16,12 +16,7 @@ export function getDirtyFiles(files, cwd) { } catch { return { isRepo: false, dirty: [] } } - let out = '' - try { - out = git(['status', '--porcelain', '--', ...files], cwd) - } catch { - return { isRepo: true, dirty: [] } - } + const out = git(['status', '--porcelain', '--', ...files], cwd) const dirty = out .split('\n') .filter(Boolean) diff --git a/lib/token-index.mjs b/lib/token-index.mjs index 4fd415d..b392202 100644 --- a/lib/token-index.mjs +++ b/lib/token-index.mjs @@ -9,16 +9,9 @@ import { normalizeColor, parseSpacingPx, normalizeFontFamily, + hexToRgb, } from './css-values.mjs' -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), - } -} - export function buildTokenIndex(tokens) { const colorExact = new Map() const ambiguousColors = new Set() @@ -45,6 +38,11 @@ export function buildTokenIndex(tokens) { } } + // 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)) { From 77d5c097e46151eefbe56d7e845c20468301825b Mon Sep 17 00:00:00 2001 From: Nadia Nujovich Date: Mon, 20 Jul 2026 15:58:04 +0200 Subject: [PATCH 3/3] docs: document mint-ds apply command in README (#35) --- README.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) 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 `