#!/usr/bin/env node /** * codemod-extract-misc.mjs * * Phase 2 (extraction), Batch 4/4 (final sweep) of the design-token audit * (branch design/token-extraction). Replaces the remaining value-bearing * arbitrary Tailwind bracket utilities in `ui/src/components/**` and * `ui/src/pages/**` (including their *.test.tsx companions) with references * to CSS custom-property tokens defined in `ui/src/index.css`. * * Patterns covered this batch: * grid-cols-[...] / grid-rows-[...] -> --gtc- / --gtr- (track lists, * deduped on exact string match) * transition-[...] -> --tp- (property lists, * deduped on exact string match) * z-[...] -> --z- (bare z-index scale values) * scale-[...] -> --s- * ease-[cubic-bezier(...)] -> --e- * align-[...] -> --va- (vertical-align) * stroke-[...] -> --sw- (SVG stroke-WIDTH, * requires the `length:` hint) * blur-[...] -> --blur- * drop-shadow-[...] -> --drop-shadow-extract- (its own * family, separate from Batch 3's * --shadow-extract-* box-shadow * family — filter vs. box-shadow are * different CSS properties, see * TOKEN-AUDIT.md Batch 3 "Needs human * decision") * bg-[linear|radial|conic-gradient(...)] -> --gradient-extract-, * CONTINUING Batch 1's counter (not * restarting it) - 2 sites that use * hsl(var(...))/color-mix(...) * CSS-native color functions rather * than raw hex/rgb literals, so * Batch 1's hand-audited color table * did not catch them even though * they are the same gradient-bracket * shape. * bg|text|border-[var(--x)] -> bare paren passthrough, no new * (no fallback) token minted (same rule as Batch * 3's var()-only case) * bg|text|border-[var(--x,fallback)] -> mints a --*-resolved wrapper token * (fallback form) whose value is `var(--x, fallback)` * verbatim (paren-with-fallback-comma * does not parse in Tailwind v4 — * confirmed in the Step 0 spike -- * so the fallback expression itself * must live in the CSS token, not in * the utility). theme(colors.a.b) * fallbacks are resolved to their * BUILT-CSS equivalent var(...) form * first (inspected from * storybook-static output before * writing this codemod - theme() is * a Tailwind build-time function). * * NOT rewritten (documented, not a bug): * - Selector/variant brackets (data-[...], group-data-[...], has-[...], * aria-[...], supports-[...], etc.) are CSS selector conditions, not * visual values - out of scope for this codemod BY DEFINITION (see * check-token-gates.mjs header for the same distinction, gate 2). * - tw-animate/animate-plugin arbitrary utilities (zoom-in-[0.97], * zoom-out-[0.97], slide-in-from-top-[1%], slide-out-to-top-[1%]) plus * their siblings (animate-in, animate-out, fade-in-0, fade-out-0): * confirmed via the Step 0 spike that NONE of these compile to any CSS * at all in this repo's build (no tw-animate-css plugin is installed, * and no @utility overrides exist in index.css) - grep of the built * storybook-static CSS shows zero occurrences of "animate-in", "zoom", * "slide-in-from-top", etc. These are dead, no-op class names with zero * rendered visual value today, so there is nothing to tokenize; touching * them would not preserve OR change any pixel. Left untouched and * allowlisted (third-party plugin syntax, retained for whenever * tw-animate-css is actually installed) rather than silently deleted, * since deleting dead classes is itself a (no-op but non-mechanical) * edit outside this batch's mandate. * - max-[480px] / min-[420px] breakpoint variants and rounded-[inherit]: * documented allowlist entries, not code the codemod touches (variant * position cannot reference a CSS custom property; `inherit` is a * keyword). See ALLOWLIST block in index.css. * * Idempotent: the FIND regexes only match the ORIGINAL bracket-literal form; * once rewritten the pattern no longer matches, so re-running is a no-op. * * Usage: node scripts/codemod-extract-misc.mjs [--check] * --check Report what WOULD change without writing files (dry run). */ import { readFileSync, writeFileSync, readdirSync } from "node:fs"; import { resolve, dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, ".."); const UI_SRC = resolve(REPO_ROOT, "ui/src"); const SCAN_DIRS = ["components", "pages"]; const DRY_RUN = process.argv.includes("--check"); // ── Helpers ──────────────────────────────────────────────────────────── function walk(dir, out) { for (const entry of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, entry.name); if (entry.isDirectory()) walk(p, out); else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(p); } } // Tailwind bracket-escaping uses `_` for literal spaces; reverse it before // writing into a real CSS custom property value (Batch 1's gradient gotcha, // generalized in Batch 3, applies again here for grid track lists / calc // expressions embedded in transition/ease values). function unescapeSpaces(value) { return value.replace(/_/g, " "); } // "320" "0.7rem" "-50" "auto_minmax(0,1fr)" etc -> safe token-name suffix. // Dots become underscores, spaces/commas/parens/percent become hyphens, // collapsed and trimmed (matches Batch 1-3's `--fs-0_7rem`-style convention // for numeric suffixes; grid/transition/ease values use a slug instead since // their content isn't a single number). function slugify(value) { return value .trim() .replace(/_/g, " ") .replace(/\./g, "_") .replace(/[^a-zA-Z0-9_]+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "") .toLowerCase(); } // ── Token registries ──────────────────────────────────────────────────── const gtcTokens = new Map(); // --gtc- grid-template-columns const gtrTokens = new Map(); // --gtr- grid-template-rows const tpTokens = new Map(); // --tp- transition-property lists const zTokens = new Map(); // --z- const sTokens = new Map(); // --s- scale const eTokens = new Map(); // --e- ease / timing-function const vaTokens = new Map(); // --va- vertical-align const swTokens = new Map(); // --sw- stroke-width const blurTokens = new Map(); // --blur- const dropShadowTokens = new Map(); // --drop-shadow-extract- const resolvedVarTokens = new Map(); // --*-resolved wrapper tokens (fallback var() forms) // --gradient-extract- continuing Batch 1's family/counter (these 2 sites // use hsl(var(...))/color-mix(...) CSS-native color functions rather than // raw hex/rgb literals, which is why Batch 1's hand-audited color-literal // table did not catch them - they're still a value-bearing gradient bracket, // in scope for this batch's "remaining bracket utilities" sweep). const gradientTokens = new Map(); let gtcCounter = 0; let gtrCounter = 0; let dropShadowCounter = 0; // Batch 1 minted --gradient-extract-1 through --gradient-extract-24; this // batch's new gradient sites continue that numbering, not restart it. let gradientCounter = 24; const gtcByValue = new Map(); function registerGtcToken(unescapedValue, sourceNote) { if (gtcByValue.has(unescapedValue)) return gtcByValue.get(unescapedValue); gtcCounter += 1; const name = `gtc-${gtcCounter}`; gtcTokens.set(name, { value: unescapedValue, comment: sourceNote }); gtcByValue.set(unescapedValue, name); return name; } const gtrByValue = new Map(); function registerGtrToken(unescapedValue, sourceNote) { if (gtrByValue.has(unescapedValue)) return gtrByValue.get(unescapedValue); gtrCounter += 1; const name = `gtr-${gtrCounter}`; gtrTokens.set(name, { value: unescapedValue, comment: sourceNote }); gtrByValue.set(unescapedValue, name); return name; } function registerTpToken(unescapedValue, sourceNote) { const slug = slugify(unescapedValue.replace(/,/g, " ")); const name = `tp-${slug}`; if (!tpTokens.has(name)) tpTokens.set(name, { value: unescapedValue, comment: sourceNote }); return name; } function registerZToken(rawNum, sourceNote) { const name = `z-${rawNum}`; if (!zTokens.has(name)) zTokens.set(name, { value: rawNum, comment: sourceNote }); return name; } function registerSToken(unescapedValue, sourceNote) { const slug = slugify(unescapedValue); const name = `s-${slug}`; if (!sTokens.has(name)) sTokens.set(name, { value: unescapedValue, comment: sourceNote }); return name; } function registerEToken(unescapedValue, sourceNote) { const slug = slugify(unescapedValue); const name = `e-${slug}`; if (!eTokens.has(name)) eTokens.set(name, { value: unescapedValue, comment: sourceNote }); return name; } function registerVaToken(unescapedValue, sourceNote) { const slug = slugify(unescapedValue); const name = `va-${slug}`; if (!vaTokens.has(name)) vaTokens.set(name, { value: unescapedValue, comment: sourceNote }); return name; } function registerSwToken(unescapedValue, sourceNote) { const slug = slugify(unescapedValue); const name = `sw-${slug}`; if (!swTokens.has(name)) swTokens.set(name, { value: unescapedValue, comment: sourceNote }); return name; } function registerBlurToken(unescapedValue, sourceNote) { const slug = slugify(unescapedValue); const name = `blur-${slug}`; if (!blurTokens.has(name)) blurTokens.set(name, { value: unescapedValue, comment: sourceNote }); return name; } const gradientByValue = new Map(); function registerGradientToken(unescapedValue, sourceNote) { if (gradientByValue.has(unescapedValue)) return gradientByValue.get(unescapedValue); gradientCounter += 1; const name = `gradient-extract-${gradientCounter}`; gradientTokens.set(name, { value: unescapedValue, comment: sourceNote }); gradientByValue.set(unescapedValue, name); return name; } const dropShadowByValue = new Map(); function registerDropShadowToken(unescapedValue, sourceNote) { if (dropShadowByValue.has(unescapedValue)) return dropShadowByValue.get(unescapedValue); dropShadowCounter += 1; const name = `drop-shadow-extract-${dropShadowCounter}`; dropShadowTokens.set(name, { value: unescapedValue, comment: sourceNote }); dropShadowByValue.set(unescapedValue, name); return name; } // Wrapper tokens for the var(--x, fallback) forms that can't use the // paren-with-fallback-comma shorthand (confirmed unsupported in the Step 0 // spike). theme(colors.a.b) fallbacks are resolved to the equivalent // var(--token) form the Tailwind build already resolves them to today // (inspected in ui/storybook-static/assets/*.css before writing this // codemod - theme() is a Tailwind build-time function and cannot appear // inside a runtime custom property). const THEME_COLOR_MAP = { "theme(colors.muted.DEFAULT)": "var(--muted)", "theme(colors.muted.foreground)": "var(--muted-foreground)", }; function resolveThemeColor(raw) { let out = raw; for (const [from, to] of Object.entries(THEME_COLOR_MAP)) { out = out.split(from).join(to); } return out; } function registerResolvedVarToken(varName, fallbackRaw, sourceNote) { // varName like "--paperclip-code-highlight-bg" -> token name // "code-highlight-bg-resolved" (strip the leading "--paperclip-" prefix // for readability, matching the mission's suggested name for the first // site; other vars in the same family follow the same convention). const bare = varName.replace(/^--paperclip-/, "").replace(/^--/, ""); const name = `${bare}-resolved`; const fallback = resolveThemeColor(fallbackRaw); const value = `var(${varName}, ${fallback})`; if (!resolvedVarTokens.has(name)) resolvedVarTokens.set(name, { value, comment: sourceNote }); return name; } // ── Regexes ────────────────────────────────────────────────────────────── // Every regex requires the utility to start at a genuine class-token // boundary (preceded by whitespace, a quote/backtick, template-literal `${`, // or start-of-string) - see Batch 3's BOUNDARY GOTCHA. `:` is included for // variant prefixes (`data-[state=open]:`, `sm:`, etc.). const BOUNDARY = String.raw`(?<=^|[\s"'\`{:])`; const GRID_COLS_RE = new RegExp(`${BOUNDARY}(!?)grid-cols-\\[([^\\]]+)\\]`, "g"); const GRID_ROWS_RE = new RegExp(`${BOUNDARY}(!?)grid-rows-\\[([^\\]]+)\\]`, "g"); const TRANSITION_RE = new RegExp(`${BOUNDARY}(!?)transition-\\[([^\\]]+)\\]`, "g"); const Z_RE = new RegExp(`${BOUNDARY}(!?)z-\\[([0-9]+)\\]`, "g"); const SCALE_RE = new RegExp(`${BOUNDARY}(!?)scale-\\[([^\\]]+)\\]`, "g"); const EASE_RE = new RegExp(`${BOUNDARY}(!?)ease-\\[([^\\]]+)\\]`, "g"); const ALIGN_RE = new RegExp(`${BOUNDARY}(!?)align-\\[([^\\]]+)\\]`, "g"); const STROKE_RE = new RegExp(`${BOUNDARY}(!?)stroke-\\[([^\\]]+)\\]`, "g"); // NOTE: negative lookbehind isn't reliably portable across regex engines at // the boundary position used elsewhere, so drop-shadow is matched with its // own explicit prefix (drop-shadow-) which never collides with bare blur-. // Matches both bare `blur-[...]` and `backdrop-blur-[...]` (the latter's // `backdrop-` prefix means `blur` doesn't start at a class-token BOUNDARY, // so it needs its own alternative rather than relying on the shared // boundary-anchored pattern). const BLUR_RE = new RegExp(`${BOUNDARY}(!?)(backdrop-blur|blur)-\\[([^\\]]+)\\]`, "g"); const DROP_SHADOW_RE = new RegExp(`${BOUNDARY}(!?)drop-shadow-\\[([^\\]]+)\\]`, "g"); // bg-[linear-gradient(...)] / bg-[radial-gradient(...)] / bg-[conic-gradient(...)] // - continuing Batch 1's --gradient-extract-* family (see registerGradientToken). // Matched separately from the generic VAR_* regexes below since these are not // var() passthrough; they need the `image:` paren hint (Batch 1 convention). const GRADIENT_RE = new RegExp( `${BOUNDARY}(!?)bg-\\[((?:linear|radial|conic)-gradient\\([^\\]]*\\))\\]`, "g", ); // bg|text|border-[var(--x)] (no fallback) and bg|text|border-[var(--x,fallback)] // (with fallback). The fallback variant's raw capture includes everything up // to the matching `)]` - since none of these fallback values contain nested // brackets, a simple `[^\]]+` capture is safe (verified by inspection of all // matching sites in this batch). const VAR_NOFALLBACK_RE = new RegExp( `${BOUNDARY}(!?)(bg|text|border)-\\[var\\((--[a-zA-Z0-9-]+)\\)\\]`, "g", ); const VAR_FALLBACK_RE = new RegExp( `${BOUNDARY}(!?)(bg|text|border)-\\[var\\((--[a-zA-Z0-9-]+),([^\\]]+)\\)\\]`, "g", ); function rewriteFile(filePath, relPath) { const original = readFileSync(filePath, "utf8"); let content = original; let siteCount = 0; content = content.replace(GRID_COLS_RE, (match, bang, raw) => { const unescaped = unescapeSpaces(raw); const sourceNote = `Extracted from ${relPath} (grid-cols-[${raw}]).`; const name = registerGtcToken(unescaped, sourceNote); siteCount++; return `${bang}grid-cols-(--${name})`; }); content = content.replace(GRID_ROWS_RE, (match, bang, raw) => { const unescaped = unescapeSpaces(raw); const sourceNote = `Extracted from ${relPath} (grid-rows-[${raw}]).`; const name = registerGtrToken(unescaped, sourceNote); siteCount++; return `${bang}grid-rows-(--${name})`; }); content = content.replace(TRANSITION_RE, (match, bang, raw) => { const unescaped = unescapeSpaces(raw); const sourceNote = `Extracted from ${relPath} (transition-[${raw}]).`; const name = registerTpToken(unescaped, sourceNote); siteCount++; return `${bang}transition-(--${name})`; }); content = content.replace(Z_RE, (match, bang, raw) => { const sourceNote = `Extracted from ${relPath} (z-[${raw}]).`; const name = registerZToken(raw, sourceNote); siteCount++; return `${bang}z-(--${name})`; }); content = content.replace(SCALE_RE, (match, bang, raw) => { const unescaped = unescapeSpaces(raw); const sourceNote = `Extracted from ${relPath} (scale-[${raw}]).`; const name = registerSToken(unescaped, sourceNote); siteCount++; return `${bang}scale-(--${name})`; }); content = content.replace(EASE_RE, (match, bang, raw) => { const unescaped = unescapeSpaces(raw); const sourceNote = `Extracted from ${relPath} (ease-[${raw}]).`; const name = registerEToken(unescaped, sourceNote); siteCount++; return `${bang}ease-(--${name})`; }); content = content.replace(ALIGN_RE, (match, bang, raw) => { const unescaped = unescapeSpaces(raw); const sourceNote = `Extracted from ${relPath} (align-[${raw}]).`; const name = registerVaToken(unescaped, sourceNote); siteCount++; return `${bang}align-(--${name})`; }); content = content.replace(STROKE_RE, (match, bang, raw) => { const unescaped = unescapeSpaces(raw); const sourceNote = `Extracted from ${relPath} (stroke-[${raw}]).`; const name = registerSwToken(unescaped, sourceNote); siteCount++; // stroke-WIDTH requires the `length:` hint (bare stroke-(--x) is // ambiguous with the stroke-COLOR utility) - confirmed in Step 0 spike. return `${bang}stroke-(length:--${name})`; }); content = content.replace(DROP_SHADOW_RE, (match, bang, raw) => { const unescaped = unescapeSpaces(raw); const sourceNote = `Extracted from ${relPath} (drop-shadow-[${raw}]).`; const name = registerDropShadowToken(unescaped, sourceNote); siteCount++; return `${bang}drop-shadow-(--${name})`; }); // Gradient bg-[...] brackets must be rewritten BEFORE the generic var() // passthrough regexes below (a gradient value can itself contain // `var(--x)` sub-expressions, e.g. hsl(var(--primary)), which the generic // VAR_NOFALLBACK_RE must not also try to match against the outer bg-[...]). content = content.replace(GRADIENT_RE, (match, bang, raw) => { const unescaped = unescapeSpaces(raw); const sourceNote = `Extracted from ${relPath} (bg-[${raw}]).`; const name = registerGradientToken(unescaped, sourceNote); siteCount++; return `${bang}bg-(image:--${name})`; }); content = content.replace(BLUR_RE, (match, bang, util, raw) => { const unescaped = unescapeSpaces(raw); const sourceNote = `Extracted from ${relPath} (${util}-[${raw}]).`; const name = registerBlurToken(unescaped, sourceNote); siteCount++; return `${bang}${util}-(--${name})`; }); // var(--x, fallback) forms MUST be rewritten before the no-fallback form // (which would otherwise partially match the `var(--x` prefix of a // fallback expression and corrupt it - the fallback regex requires a // literal comma so there's no real ambiguity, but ordering fallback-first // keeps the intent explicit and avoids relying on regex engine match order). content = content.replace(VAR_FALLBACK_RE, (match, bang, util, varName, fallbackRaw) => { const sourceNote = `Extracted from ${relPath} (${util}-[var(${varName},${fallbackRaw})]).`; const name = registerResolvedVarToken(varName, fallbackRaw, sourceNote); siteCount++; return `${bang}${util}-(--${name})`; }); content = content.replace(VAR_NOFALLBACK_RE, (match, bang, util, varName) => { siteCount++; // Bare var() passthrough - no new token minted, per DESIGN.md/Batch 3's // special case for runtime library/component variables. Here the vars // already exist as first-class design tokens in index.css (--chip-match-*), // so this is a pure syntax modernization, not a token mint. return `${bang}${util}-(${varName})`; }); if (content !== original && !DRY_RUN) { writeFileSync(filePath, content, "utf8"); } return { changed: content !== original, siteCount }; } function main() { const files = []; for (const dir of SCAN_DIRS) walk(resolve(UI_SRC, dir), files); files.sort(); let totalSites = 0; let filesChanged = 0; const changedFiles = []; for (const filePath of files) { const relPath = "ui/src/" + relative(UI_SRC, filePath); const { changed, siteCount } = rewriteFile(filePath, relPath); if (changed) { filesChanged++; changedFiles.push(relPath); } totalSites += siteCount; } // ── index.css token block ────────────────────────────────────────── const cssPath = resolve(UI_SRC, "index.css"); const cssOriginal = readFileSync(cssPath, "utf8"); const marker = "/* ── Extracted verbatim MISC tokens (Phase 2 Batch 4, design/token-extraction) ── */"; let cssNext = cssOriginal; let cssChanged = false; const anyTokens = gtcTokens.size || gtrTokens.size || tpTokens.size || zTokens.size || sTokens.size || eTokens.size || vaTokens.size || swTokens.size || blurTokens.size || dropShadowTokens.size || resolvedVarTokens.size || gradientTokens.size; if (!cssOriginal.includes(marker) && anyTokens) { const lines = []; lines.push(marker); lines.push("/* Batch 4/4 (final sweep): grid track lists, transition-property"); lines.push(" lists, z-index, scale, easing, vertical-align, stroke-width, blur,"); lines.push(" drop-shadow, and half-migrated var(x, fallback) color forms,"); lines.push(" verbatim (no normalizing - the human scale-collapse decision comes"); lines.push(" later per DESIGN.md/TOKEN-AUDIT.md). --gtc-* and --gtr-* and --tp-* are"); lines.push(" sequentially numbered / slugged since their content (track lists,"); lines.push(" property lists) is not safely nameable by value alone."); lines.push(""); lines.push(" drop-shadow (a CSS filter function) gets its own"); lines.push(" --drop-shadow-extract-* family, kept separate from Batch 3's"); lines.push(" --shadow-extract-* box-shadow family per that batch's logged"); lines.push(" human-decision note."); lines.push(""); lines.push(" *-resolved wrapper tokens hold a verbatim var(--x, fallback)"); lines.push(" expression for sites where Tailwind v4's paren-with-fallback-comma"); lines.push(" shorthand does not parse (confirmed unsupported in this batch's"); lines.push(" Step 0 syntax spike). theme(colors.a.b) fallbacks were resolved to"); lines.push(" the equivalent var(--token) form the Tailwind build already"); lines.push(" compiles them to today (inspected byte-for-byte from the built"); lines.push(" storybook-static CSS before this codemod ran: theme(colors.muted.DEFAULT)"); lines.push(" -> var(--muted), theme(colors.muted.foreground) -> var(--muted-foreground))."); lines.push(""); lines.push(" --gradient-extract-* here CONTINUES Batch 1's counter (Batch 1 minted"); lines.push(" 1 through 24) rather than restarting it - these 2 sites use"); lines.push(" hsl(var(...))/color-mix(...) CSS-native color functions rather than raw"); lines.push(" hex/rgb literals, which is why Batch 1's hand-audited color-literal"); lines.push(" table did not catch them, but they are still value-bearing gradient"); lines.push(" brackets in scope for this batch's final sweep."); lines.push(""); lines.push(" Allowlist (sites intentionally left as-is - see ALLOWLIST doc-comment"); lines.push(" at the end of this file for the machine-readable list consumed by"); lines.push(" scripts/check-token-gates.mjs):"); lines.push(" allow ui/src/components/ui/dialog.tsx — tw-animate-css plugin utilities (zoom-in-[0.97] etc.) are dead/no-op classes today (plugin not installed, verified via built-CSS grep); nothing to tokenize without visually changing a currently-inert class"); lines.push(" allow ui/src/components/ui/alert-dialog.tsx — same tw-animate-css dead-class situation as dialog.tsx"); lines.push("*/"); lines.push(":root {"); for (const [name, { value, comment }] of gtcTokens) { lines.push(` --${name}: ${value}; /* ${comment} */`); } for (const [name, { value, comment }] of gtrTokens) { lines.push(` --${name}: ${value}; /* ${comment} */`); } for (const [name, { value, comment }] of tpTokens) { lines.push(` --${name}: ${value}; /* ${comment} */`); } for (const [name, { value, comment }] of zTokens) { lines.push(` --${name}: ${value}; /* ${comment} */`); } for (const [name, { value, comment }] of sTokens) { lines.push(` --${name}: ${value}; /* ${comment} */`); } for (const [name, { value, comment }] of eTokens) { lines.push(` --${name}: ${value}; /* ${comment} */`); } for (const [name, { value, comment }] of vaTokens) { lines.push(` --${name}: ${value}; /* ${comment} */`); } for (const [name, { value, comment }] of swTokens) { lines.push(` --${name}: ${value}; /* ${comment} */`); } for (const [name, { value, comment }] of blurTokens) { lines.push(` --${name}: ${value}; /* ${comment} */`); } for (const [name, { value, comment }] of dropShadowTokens) { lines.push(` --${name}: ${value}; /* ${comment} */`); } for (const [name, { value, comment }] of resolvedVarTokens) { lines.push(` --${name}: ${value}; /* ${comment} */`); } for (const [name, { value, comment }] of gradientTokens) { lines.push(` --${name}: ${value}; /* ${comment} */`); } lines.push("}"); const block = "\n" + lines.join("\n") + "\n"; cssNext = cssOriginal + block; cssChanged = true; } if (cssChanged && !DRY_RUN) writeFileSync(cssPath, cssNext, "utf8"); // ── Summary ───────────────────────────────────────────────────────── console.log(`\n${DRY_RUN ? "[DRY RUN] " : ""}codemod-extract-misc summary`); console.log(` Sites rewritten: ${totalSites}`); console.log(` Files changed: ${filesChanged}`); console.log(` New --gtc-*: ${gtcTokens.size}`); console.log(` New --gtr-*: ${gtrTokens.size}`); console.log(` New --tp-*: ${tpTokens.size}`); console.log(` New --z-*: ${zTokens.size}`); console.log(` New --s-*: ${sTokens.size}`); console.log(` New --e-*: ${eTokens.size}`); console.log(` New --va-*: ${vaTokens.size}`); console.log(` New --sw-*: ${swTokens.size}`); console.log(` New --blur-*: ${blurTokens.size}`); console.log(` New --drop-shadow-extract-*: ${dropShadowTokens.size}`); console.log(` New *-resolved wrapper: ${resolvedVarTokens.size}`); console.log(` New --gradient-extract-* (continuing Batch 1): ${gradientTokens.size}`); console.log(` index.css token block: ${cssChanged ? "added" : "already present or nothing to add (idempotent no-op)"}`); if (changedFiles.length) { console.log(`\n Changed files:`); for (const f of changedFiles) console.log(` - ${f}`); } } main();