Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <path> [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 <file>` | 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 `<style>` blocks is rewritten, but inline `style="..."` attributes are left untouched.

### Authentication

Every command needs an LLM provider API key. You have three options — pick whichever fits your workflow:
Expand Down
127 changes: 127 additions & 0 deletions bin/__tests__/apply.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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); }'
)
})

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
})
})
143 changes: 143 additions & 0 deletions bin/mint-ds.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -108,6 +111,7 @@ ${styles.bold('COMMANDS')}
validate <file> [options] Validate tokens.json against a spec (e.g. dtcg)
diff <old> <new> Show changes between two token files
cache --clear Delete the local ${CACHE_FILE}
apply <path> Rewrite source CSS to use generated token variables
compat <dir> Scan CSS for Interop 2026 browser-compat issues (warnings + suggestions)
lint <dir> Run static CSS lint rules on files in <dir>
score <dir> Compute the CSS health score and per-metric breakdown
Expand All @@ -131,6 +135,13 @@ ${styles.bold('EXPORT OPTIONS')}
--out <file> Override the generated filename
--stdout Print to stdout instead of writing a file

${styles.bold('APPLY OPTIONS')}
--tokens <file> Tokens file (default: ${DEFAULT_TOKENS_FILE})
--target <name> 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 <name> Spec to validate against (default: dtcg). Values: dtcg
--json Output results as JSON
Expand Down Expand Up @@ -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
`)
}

Expand Down Expand Up @@ -727,6 +740,135 @@ 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 <dir>" first, or pass --tokens <file>.`
)
}
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))
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 (status.dirty.length > 0) {
die(
`${status.dirty.length} file(s) have uncommitted changes; commit them or pass --force:\n ` +
status.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()
Expand All @@ -751,6 +893,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}`)
Expand Down
Loading
Loading