Files
paperclip/scripts/codemod-extract-misc.mjs
c07e650cd7 feat(ui): single-source design tokens, visual regression suite, and theme retune (#9134)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Its UI is the operator's daily surface: task lists, boards, budgets,
agent status — all built on shadcn components and Tailwind
> - Visual values (colors, spacing, type sizes, radii) were hardcoded at
~1,600 call sites: the same "small gray label" was 9/10/11px depending
on the file, charts disagreed with chips about status colors, two
toggle-switch implementations coexisted in two greens, and there was no
visual regression coverage
> - This made the UI drift-prone and made any restyle a
hundreds-of-files project, which discourages design iteration
> - This pull request extracts visual values into a single token layer
in `ui/src/index.css`, adds a Storybook visual regression suite backed
by external immutable baseline archives, and then applies a deliberate
retune reviewed change-by-change on screenshot diffs
> - The benefit is that Paperclip's look becomes a config surface:
retheming is a token edit reviewed as a snapshot diff, drift is blocked
by a token gate, and future UI PRs can prove exactly what changed
visually without committing hundreds of PNGs

## Linked Issues or Issue Description

No existing public issue covers this work (searched "design tokens",
"visual regression", "design system" across issues and PRs). Related in
spirit: Refs #8982 (theming a hardcoded panel — a one-off instance of
the same problem class this PR addresses systematically).

**Problem (feature-request form):** UI visual values are hardcoded per
call site with no source of truth and no regression coverage;
consistency depends on reviewer memory, and restyling requires mass file
edits.
**Proposed solution (this PR):** a single token layer + enforcement gate
+ externally stored visual snapshot suite, then an intentional restyle
on top of that foundation.

## What Changed

- **Token extraction (zero visual change, machine-verified during
development):** committed codemods (`scripts/codemod-*.mjs`) moved
~1,600 hardcoded color/type/spacing/radius/shadow/misc values into named
tokens in a non-inline `:root` block of `ui/src/index.css`.
- **Visual regression suite:** `pnpm test:storybook-visual` covers 255
stories × light/dark = 510 Playwright screenshots at `maxDiffPixels: 0`,
plus new primitive-coverage stories and deterministic-render fixes.
- **External visual baselines:** committed PNG snapshots were removed.
`tests/storybook-visual/baseline-manifest.json` pins an immutable
archive URL/hash/size/count, and `scripts/storybook-visual-baseline.mjs`
handles `download`, `verify`, `pack`, and trusted maintainer `upload`
flows.
- **Opt-in visual CI artifacts:** added a `Storybook Visual` workflow
that runs on manual dispatch or PRs labeled `storybook-visual`,
downloads/verifies the baseline, runs Playwright, and uploads Playwright
report/test-result artifacts for review. Normal PR runs do not mutate
baseline objects.
- **Token gate:** `pnpm check:token-gates` — zero hex literals, zero
arbitrary bracket values, zero raw font-sizes in `ui/src/components/**`
and `ui/src/pages/**`, with a documented inline allowlist for legitimate
opt-outs.
- **Theme retune (intentional, snapshot-reviewed):** new base theme
values; radius ladder derived from a single `--radius` knob; micro-type
cluster collapsed to a named ladder (`--text-nano/micro/compact` +
Tailwind `text-xs`/`text-sm`); letter-spacing collapsed to named steps.
- **One status-color vocabulary:** charts, quota/budget bar fills,
RUNNING/live chips, and liveness indicators all use the canonical
`--status-*` hues. Light-mode legibility fixes for red alert surfaces
that used dark-tuned text classes.
- **One switch:** `ToggleSwitch` restyled to the registry capsule form,
second hand-rolled implementation removed, and all call sites unified.
- **Docs:** `DESIGN.md` is the design contract; `doc/design/` holds
audit reports, decision logs, and updated guidance for external baseline
review/update workflows.
- Dead code removed (`agentStatusBadge` duplicate map), byte-identical
contrast constants consolidated, semantic renames
(`--project-seed`/`--project-none`, `--liveness-blue`).

## Verification

- `pnpm check:token-gates` — 3/3 gates CLEAN during the design-system
run
- `pnpm typecheck` && `pnpm --filter @paperclipai/ui build` — green
during the design-system run
- `node --test scripts/__tests__/storybook-visual-baseline.test.mjs` —
pass after external-baseline rework
- `pnpm exec tsc --noEmit --pretty false --module NodeNext
--moduleResolution NodeNext --target ES2022 --types
node,@playwright/test tests/storybook-visual/playwright.config.ts
tests/storybook-visual/storybook-visual.spec.ts` — pass after
external-baseline rework
- `git diff --check origin/pr/9134..HEAD` — pass after external-baseline
rework
- `find tests/storybook-visual -type f -name '*.png' -print | wc -l` —
`0`
- `node scripts/storybook-visual-baseline.mjs verify` — intentionally
fails closed until the first trusted maintainer publishes the baseline
archive and updates `baseline-manifest.json`

## Risks

- **Large but shallow:** the PR still touches many UI files due to
mechanical token extraction and retune work, but committed PNG snapshot
churn has been removed from the branch.
- **Baseline publication required before the visual suite can pass in
clean clones:** the manifest currently has placeholder archive metadata.
A trusted maintainer must publish the first immutable archive, then
update `baseline-manifest.json`.
- **Rendering platform variance:** the external baseline should be
captured in the documented Linux/Chromium environment. Future CI runs
verify against the pinned archive and fail closed on checksum/count
mismatch.
- **Visual CI is opt-in while stabilizing:** add the `storybook-visual`
label or dispatch the workflow manually to produce downloadable
Playwright report/test-result artifacts.
- **Scheduled follow-ups, deliberately out of scope:** Tailwind palette
classes map to semantic tokens in a dedicated pass; card/pill component
consolidation; ESLint ratchet. Tracked in
`doc/design/DECISION-SHEET.md`.

## Model Used

Claude Fable 5 (Anthropic, `claude-fable-5`, Mythos-class tier) with
extended thinking, running in Claude Code with tool use; mechanical
phases delegated to Claude Sonnet subagents. Follow-up external-baseline
rework assisted by OpenAI Codex (`gpt-5` coding agent with repository,
terminal, and GitHub tool use). All bulk rewrites executed via
deterministic, idempotent scripts committed in `scripts/`; intentional
visual changes were human-reviewed on screenshot contact sheets.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run targeted local verification and documented the
intentional baseline-publication failure above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green *(pending new CI run after this
rework)*
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
*(pending review)*
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code) and
OpenAI Codex

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Dotta <bippadotta@protonmail.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-07 16:22:16 -05:00

600 lines
28 KiB
JavaScript

#!/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-<n> / --gtr-<n> (track lists,
* deduped on exact string match)
* transition-[...] -> --tp-<slug> (property lists,
* deduped on exact string match)
* z-[...] -> --z-<n> (bare z-index scale values)
* scale-[...] -> --s-<slug>
* ease-[cubic-bezier(...)] -> --e-<slug>
* align-[...] -> --va-<slug> (vertical-align)
* stroke-[...] -> --sw-<slug> (SVG stroke-WIDTH,
* requires the `length:` hint)
* blur-[...] -> --blur-<slug>
* drop-shadow-[...] -> --drop-shadow-extract-<n> (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-<n>,
* 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-<n> grid-template-columns
const gtrTokens = new Map(); // --gtr-<n> grid-template-rows
const tpTokens = new Map(); // --tp-<slug> transition-property lists
const zTokens = new Map(); // --z-<n>
const sTokens = new Map(); // --s-<slug> scale
const eTokens = new Map(); // --e-<slug> ease / timing-function
const vaTokens = new Map(); // --va-<slug> vertical-align
const swTokens = new Map(); // --sw-<slug> stroke-width
const blurTokens = new Map(); // --blur-<slug>
const dropShadowTokens = new Map(); // --drop-shadow-extract-<n>
const resolvedVarTokens = new Map(); // --*-resolved wrapper tokens (fallback var() forms)
// --gradient-extract-<n> 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();