mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
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>
This commit is contained in:
270 files changed
+6946
-1538
No files matched your search
@@ -0,0 +1,114 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const script = new URL("../storybook-visual-baseline.mjs", import.meta.url).pathname;
|
||||
|
||||
test("downloads and verifies a checksum-pinned local baseline archive", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "storybook-visual-baseline-test-"));
|
||||
try {
|
||||
const source = join(root, "source");
|
||||
const cache = join(root, "cache");
|
||||
const snapshots = join(root, "snapshots");
|
||||
const manifest = join(root, "manifest.json");
|
||||
mkdirSync(source, { recursive: true });
|
||||
writeFileSync(join(source, "one.png"), "png-one");
|
||||
writeFileSync(join(source, "two.png"), "png-two");
|
||||
const archive = join(root, "snapshots.tgz");
|
||||
run("tar", ["--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "-czf", archive, "-C", source, "."]);
|
||||
const sha256 = createHash("sha256").update(readFileSync(archive)).digest("hex");
|
||||
const byteSize = statSync(archive).size;
|
||||
writeFileSync(
|
||||
manifest,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
baselineId: "test-baseline",
|
||||
snapshotCount: 2,
|
||||
archive: {
|
||||
url: `file://${archive}`,
|
||||
sha256,
|
||||
byteSize,
|
||||
},
|
||||
environment: {
|
||||
browser: "chromium",
|
||||
viewport: "1200x800",
|
||||
deviceScaleFactor: 1,
|
||||
platform: "test",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
STORYBOOK_VISUAL_BASELINE_MANIFEST: manifest,
|
||||
STORYBOOK_VISUAL_BASELINE_CACHE_DIR: cache,
|
||||
STORYBOOK_VISUAL_SNAPSHOT_DIR: snapshots,
|
||||
};
|
||||
const download = spawnSync(process.execPath, [script, "download"], { env, encoding: "utf8" });
|
||||
assert.equal(download.status, 0, download.stderr);
|
||||
const verify = spawnSync(process.execPath, [script, "verify"], { env, encoding: "utf8" });
|
||||
assert.equal(verify.status, 0, verify.stderr);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verify fails closed on snapshot count mismatch", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "storybook-visual-baseline-test-"));
|
||||
try {
|
||||
const snapshots = join(root, "snapshots");
|
||||
const manifest = join(root, "manifest.json");
|
||||
mkdirSync(snapshots, { recursive: true });
|
||||
writeFileSync(join(snapshots, "one.png"), "png-one");
|
||||
const archive = join(root, "snapshots.tgz");
|
||||
run("tar", ["--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "-czf", archive, "-C", snapshots, "."]);
|
||||
const sha256 = createHash("sha256").update(readFileSync(archive)).digest("hex");
|
||||
const manifestBody = {
|
||||
version: 1,
|
||||
baselineId: "test-baseline",
|
||||
snapshotCount: 1,
|
||||
archive: {
|
||||
url: `file://${archive}`,
|
||||
sha256,
|
||||
byteSize: statSync(archive).size,
|
||||
},
|
||||
};
|
||||
writeFileSync(manifest, JSON.stringify(manifestBody));
|
||||
const env = {
|
||||
...process.env,
|
||||
STORYBOOK_VISUAL_BASELINE_MANIFEST: manifest,
|
||||
STORYBOOK_VISUAL_BASELINE_CACHE_DIR: root,
|
||||
STORYBOOK_VISUAL_SNAPSHOT_DIR: snapshots,
|
||||
};
|
||||
const download = spawnSync(process.execPath, [script, "download"], { env, encoding: "utf8" });
|
||||
assert.equal(download.status, 0, download.stderr);
|
||||
writeFileSync(manifest, JSON.stringify({ ...manifestBody, snapshotCount: 2 }));
|
||||
unlinkSync(join(snapshots, "one.png"));
|
||||
const result = spawnSync(process.execPath, [script, "verify"], { env, encoding: "utf8" });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /snapshot count mismatch/i);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function run(cmd, args) {
|
||||
const result = spawnSync(cmd, args, { stdio: "pipe", encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-token-gates.mjs
|
||||
*
|
||||
* Phase 2 (extraction) DONE-WHEN gate check for the design-token-extraction
|
||||
* run (branch design/token-extraction; see DESIGN.md, GOAL-PROMPT.md,
|
||||
* TOKEN-AUDIT.md). Scans `ui/src/components/**` and `ui/src/pages/**`
|
||||
* (excluding `ui/src/lib|context|plugins`, which are explicitly out of
|
||||
* scope for this run per TOKEN-AUDIT.md's Batch 4 log) for three gates:
|
||||
*
|
||||
* Gate 1 — zero hardcoded COLOR LITERALS: hex colors (#fff, #ffffff,
|
||||
* #ffffffff) and rgb()/rgba()/hsl()/hsla()/oklch() value literals
|
||||
* (i.e. NOT a var() reference, and not merely referencing a CSS
|
||||
* variable inside one of those functions, e.g. hsl(var(--primary)) is
|
||||
* fine — only a literal numeric color argument fails the gate).
|
||||
*
|
||||
* Gate 2 — zero VALUE-BEARING arbitrary Tailwind bracket utilities:
|
||||
* bracket contents (`utility-[...]`) that carry a rendered CSS value
|
||||
* (digits with CSS units, bare numbers, color literals, or CSS value
|
||||
* functions like calc()/min()/max()/clamp()/var()/linear-gradient()/
|
||||
* cubic-bezier()/rgba()/env()). This is checked on the UTILITY
|
||||
* position, i.e. `word-[...]` where `word` is not itself a selector/
|
||||
* variant keyword.
|
||||
*
|
||||
* SELECTOR/VARIANT BRACKETS ARE EXCLUDED BY DEFINITION, not by
|
||||
* omission: `data-[...]`, `group-data-[...]`, `has-[...]`,
|
||||
* `group-has-data-[...]`, `aria-[...]`, `supports-[...]`, and
|
||||
* `max-[...]`/`min-[...]` used as a BREAKPOINT VARIANT PREFIX (i.e.
|
||||
* immediately followed by `:`, such as `max-[480px]:hidden`) are CSS
|
||||
* SELECTOR CONDITIONS or responsive variant prefixes, not visual
|
||||
* values applied to a property — they describe WHEN a rule applies,
|
||||
* not WHAT value it sets. A variant's bracket cannot reference a CSS
|
||||
* custom property (Tailwind resolves variants at build time, before
|
||||
* any `var()` could be evaluated), so there is nothing to tokenize;
|
||||
* tokenizing would require changing Tailwind's own variant syntax,
|
||||
* which is out of scope. These are recognized structurally: a
|
||||
* bracket immediately followed by `:` (not part of a class string's
|
||||
* trailing utility) is a variant, not a utility value.
|
||||
*
|
||||
* True exceptions that DO carry a value but cannot be tokenized are
|
||||
* ALLOWLISTED, not silently excluded (see ALLOWLIST parsing below):
|
||||
* `max-[480px]`/`min-[420px]` breakpoint variants (variant position
|
||||
* cannot reference a var), and `rounded-[inherit]` (a CSS-wide
|
||||
* keyword, not a literal value, cannot come from a custom property).
|
||||
*
|
||||
* Gate 3 — zero raw FONT-SIZE declarations: `text-[Npx]`/`text-[N.Nrem]`
|
||||
* Tailwind arbitrary font-size utilities (a subset of gate 2, checked
|
||||
* explicitly since font-size is its own DESIGN.md-named category) and
|
||||
* `fontSize: "..."` / `font-size:` string-literal declarations in
|
||||
* inline styles or css-in-js.
|
||||
*
|
||||
* The ALLOWLIST is parsed from the machine-readable block in
|
||||
* ui/src/index.css (search for "── ALLOWLIST" below it), one entry per
|
||||
* line in the form:
|
||||
* * allow <repo-relative-path> — <reason>
|
||||
* A violation at a path is suppressed if the path CONTAINS (substring
|
||||
* match) any allowlisted path. This intentionally allowlists the whole
|
||||
* file for simplicity/reviewability, matching how Batches 1-3 allowlisted
|
||||
* entire sites' surrounding functional code rather than individual
|
||||
* characters.
|
||||
*
|
||||
* Exit code: 0 if all three gates are clean (prints a per-gate summary).
|
||||
* Exit code: 1 if any gate has violations (lists them, grouped by gate).
|
||||
*
|
||||
* Usage: node scripts/check-token-gates.mjs
|
||||
*/
|
||||
|
||||
import { readFileSync, 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 CSS_PATH = resolve(UI_SRC, "index.css");
|
||||
|
||||
// ── Allowlist parsing ────────────────────────────────────────────────────
|
||||
// Reads the machine-readable "* allow <path> — <reason>" lines from the
|
||||
// ALLOWLIST block in ui/src/index.css. Tolerant of either em-dash (—) or
|
||||
// a plain hyphen-minus as the path/reason separator, and of the historical
|
||||
// per-batch prose blocks NOT being in this format (they are not parsed;
|
||||
// only lines starting with "* allow " are).
|
||||
function loadAllowlist(cssPath) {
|
||||
const css = readFileSync(cssPath, "utf8");
|
||||
const entries = [];
|
||||
const lineRe = /^\s*\*\s*allow\s+(\S+)\s+(?:—|-{1,2})\s*(.*)$/;
|
||||
for (const rawLine of css.split("\n")) {
|
||||
const m = rawLine.match(lineRe);
|
||||
if (m) {
|
||||
entries.push({ path: m[1], reason: m[2].trim() });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isAllowlisted(relPath, allowlist) {
|
||||
return allowlist.some((entry) => relPath.includes(entry.path));
|
||||
}
|
||||
|
||||
// ── File walking ─────────────────────────────────────────────────────────
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function listFiles() {
|
||||
const files = [];
|
||||
for (const dir of SCAN_DIRS) walk(resolve(UI_SRC, dir), files);
|
||||
files.sort();
|
||||
return files;
|
||||
}
|
||||
|
||||
// ── Gate 1: color literals ───────────────────────────────────────────────
|
||||
// Hex colors: #abc, #aabbcc, #aabbccdd — word-boundary guarded so it
|
||||
// doesn't match inside identifiers, and NOT preceded by another hex digit
|
||||
// (avoids over-matching truncated substrings of longer non-color tokens,
|
||||
// though `#` itself is a strong enough anchor in practice).
|
||||
// A genuine CSS hex color is never glued directly to an identifier
|
||||
// character (letter/digit/underscore) or `/` immediately before the `#` —
|
||||
// that shape is an issue/PR reference like "acme/web#241" or "acme/web#12"
|
||||
// (Batch 1's codemod header documented this exact false-positive risk for
|
||||
// its own hex-literal sweep; the same guard applies here). A real color
|
||||
// literal is preceded by a delimiter (quote, colon, paren, comma,
|
||||
// whitespace, backtick, template `${`) or sits at the start of the string.
|
||||
const HEX_COLOR_RE = /(?<![a-zA-Z0-9_/])#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
|
||||
|
||||
// rgb()/rgba()/hsl()/hsla()/oklch() with a LITERAL first argument (a digit,
|
||||
// a `.` decimal, or a `%` — i.e. not `var(` or `calc(` immediately inside).
|
||||
// `hsl(var(--x)/0.16)` must NOT match (var() reference); `rgba(0,0,0,0.5)`
|
||||
// MUST match (literal numeric channels).
|
||||
const COLOR_FN_LITERAL_RE = /\b(?:rgb|rgba|hsl|hsla|oklch)\(\s*(?!var\()[0-9.%-]/g;
|
||||
|
||||
function findColorLiteralIssues(content) {
|
||||
const issues = [];
|
||||
for (const m of content.matchAll(HEX_COLOR_RE)) {
|
||||
issues.push({ index: m.index, snippet: m[0] });
|
||||
}
|
||||
for (const m of content.matchAll(COLOR_FN_LITERAL_RE)) {
|
||||
issues.push({ index: m.index, snippet: m[0] });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
// ── Gate 2: value-bearing arbitrary bracket utilities ───────────────────
|
||||
// Matches `word-[content]` (optionally prefixed by `!`, and optionally
|
||||
// preceded by a Tailwind variant chain like `sm:` / `dark:` / `hover:` /
|
||||
// `data-[state=open]:` etc. — the regex only needs to find the utility's
|
||||
// OWN bracket, not parse the whole variant chain, since VARIANT_KEYWORDS
|
||||
// below excludes variant-shaped words directly at the match site).
|
||||
//
|
||||
// A bracket is a VARIANT (excluded by definition, see header) if:
|
||||
// (a) the word immediately before `-[` is one of the known variant
|
||||
// keywords (data, group-data, has, group-has-data, aria, supports,
|
||||
// group-aria, peer-data, peer-aria, in, not), OR
|
||||
// (b) the bracket is immediately followed by `:` (a breakpoint-style
|
||||
// variant prefix, e.g. `max-[480px]:hidden` — the `:` right after
|
||||
/// `]` is the structural signal that this bracket is a CONDITION,
|
||||
// not a value).
|
||||
const BRACKET_RE = /(!?)([a-zA-Z][a-zA-Z0-9-]*)-\[([^\[\]]*)\]/g;
|
||||
|
||||
const VARIANT_WORD_RE =
|
||||
/(?:^|[\s"'`{])(?:group-|peer-)?(?:data|has|aria|supports|in|not)(?:-[a-zA-Z0-9]+)*$/;
|
||||
|
||||
// A bracket carries a VALUE (not just a keyword/selector fragment) if its
|
||||
// content looks like: a number (optionally with a CSS unit or %), a CSS
|
||||
// color literal (# hex or a color function), OR a known CSS value function
|
||||
// call (calc/min/max/clamp/var/env/linear-gradient/radial-gradient/
|
||||
// conic-gradient/cubic-bezier/rgba/rgb/hsl/hsla/oklch). Pure CSS KEYWORDS
|
||||
// (e.g. `inherit`, `auto`, `pointer`) do NOT match and are not gated here
|
||||
// (they're a separate, allowlisted concern — see `rounded-[inherit]`).
|
||||
const VALUE_UNIT_RE = /^-?[0-9.]+(?:px|rem|em|vh|vw|dvh|dvw|svh|svw|ch|%|deg|s|ms|fr)?$/;
|
||||
const VALUE_FUNC_RE =
|
||||
/^(?:calc|min|max|clamp|var|env|linear-gradient|radial-gradient|conic-gradient|cubic-bezier|rgba?|hsla?|oklch|color-mix)\(/;
|
||||
const HEX_ONLY_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
function bracketCarriesValue(raw) {
|
||||
const trimmed = raw.trim();
|
||||
if (VALUE_UNIT_RE.test(trimmed)) return true;
|
||||
if (HEX_ONLY_RE.test(trimmed)) return true;
|
||||
if (VALUE_FUNC_RE.test(trimmed)) return true;
|
||||
// A bracket containing an embedded CSS value function anywhere (e.g. a
|
||||
// grid track list `56px_56px_24px_minmax(0,1fr)` that doesn't itself
|
||||
// start with one of the above, or `translate-y-[-50%]`-style negative
|
||||
// percentages already covered by VALUE_UNIT_RE) also counts.
|
||||
if (/[0-9](?:px|rem|em|vh|vw|dvh|dvw|svh|svw|ch|%|deg|fr)\b/.test(trimmed)) return true;
|
||||
if (/\b(?:calc|min|max|clamp|var|env|linear-gradient|radial-gradient|conic-gradient|cubic-bezier|rgba?|hsla?|oklch|color-mix)\(/.test(trimmed)) return true;
|
||||
if (HEX_COLOR_RE.test(trimmed)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function findArbitraryBracketIssues(content) {
|
||||
const issues = [];
|
||||
for (const m of content.matchAll(BRACKET_RE)) {
|
||||
const [full, , word, raw] = m;
|
||||
const matchEnd = m.index + full.length;
|
||||
const followedByColon = content[matchEnd] === ":";
|
||||
if (followedByColon) continue; // breakpoint/arbitrary-variant prefix, not a utility value
|
||||
|
||||
// Reject if `word` itself IS (or ends in) a variant keyword shape, e.g.
|
||||
// a match that accidentally captured "...data" as the utility name for
|
||||
// some malformed/edge case. In practice BRACKET_RE's utility-name
|
||||
// capture group only ever contains real utility names (data-[...] etc.
|
||||
// are matched with `word` = "data", "group-data", "has", etc.).
|
||||
const precedingContext = content.slice(Math.max(0, m.index - 1), m.index + word.length + 1);
|
||||
if (VARIANT_WORD_RE.test(precedingContext)) continue;
|
||||
if (/^(?:data|has|aria|supports|group-data|group-has-data|group-aria|peer-data|peer-aria|group-has-data-slot|in|not)$/.test(word)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!raw.includes("[") && bracketCarriesValue(raw)) {
|
||||
issues.push({ index: m.index, snippet: `${word}-[${raw}]` });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
// ── Gate 3: raw font-size declarations ──────────────────────────────────
|
||||
const FONT_SIZE_CLASS_RE = /\btext-\[(?:[0-9.]+(?:px|rem|em)|[0-9.]+\/[0-9.]+)\]/g;
|
||||
// A raw literal font-size value: starts with a digit (px/rem/em number) —
|
||||
// EXCLUDES `fontSize: "var(--text-micro)"`-style token references, which start
|
||||
// with `var(` and are the desired post-extraction form, not a violation.
|
||||
const FONT_SIZE_INLINE_RE = /\bfontSize\s*:\s*["'][0-9][^"']*["']/g;
|
||||
const FONT_SIZE_CSS_PROP_RE = /(?<!-)\bfont-size\s*:\s*["'`][0-9][^"'`]*["'`]/g;
|
||||
|
||||
function findFontSizeIssues(content) {
|
||||
const issues = [];
|
||||
for (const m of content.matchAll(FONT_SIZE_CLASS_RE)) {
|
||||
issues.push({ index: m.index, snippet: m[0] });
|
||||
}
|
||||
for (const m of content.matchAll(FONT_SIZE_INLINE_RE)) {
|
||||
issues.push({ index: m.index, snippet: m[0] });
|
||||
}
|
||||
for (const m of content.matchAll(FONT_SIZE_CSS_PROP_RE)) {
|
||||
issues.push({ index: m.index, snippet: m[0] });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function lineNumberAt(content, index) {
|
||||
return content.slice(0, index).split("\n").length;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const allowlist = loadAllowlist(CSS_PATH);
|
||||
const files = listFiles();
|
||||
|
||||
const violations = { gate1: [], gate2: [], gate3: [] };
|
||||
let allowlistedSkips = 0;
|
||||
|
||||
for (const filePath of files) {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const relPathPosix = relPathToPosix(filePath);
|
||||
|
||||
const allowed = isAllowlisted(relPathPosix, allowlist);
|
||||
|
||||
const g1 = findColorLiteralIssues(content);
|
||||
const g2 = findArbitraryBracketIssues(content);
|
||||
const g3 = findFontSizeIssues(content);
|
||||
|
||||
if (allowed) {
|
||||
allowlistedSkips += g1.length + g2.length + g3.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const issue of g1) {
|
||||
violations.gate1.push({ file: relPathPosix, line: lineNumberAt(content, issue.index), snippet: issue.snippet });
|
||||
}
|
||||
for (const issue of g2) {
|
||||
violations.gate2.push({ file: relPathPosix, line: lineNumberAt(content, issue.index), snippet: issue.snippet });
|
||||
}
|
||||
for (const issue of g3) {
|
||||
violations.gate3.push({ file: relPathPosix, line: lineNumberAt(content, issue.index), snippet: issue.snippet });
|
||||
}
|
||||
}
|
||||
|
||||
const totalViolations = violations.gate1.length + violations.gate2.length + violations.gate3.length;
|
||||
|
||||
console.log("check-token-gates summary");
|
||||
console.log(` Files scanned: ${files.length}`);
|
||||
console.log(` Allowlist entries loaded: ${allowlist.length}`);
|
||||
console.log(` Allowlisted issues skipped: ${allowlistedSkips}`);
|
||||
console.log("");
|
||||
console.log(` Gate 1 (color literals): ${violations.gate1.length === 0 ? "CLEAN" : `${violations.gate1.length} violation(s)`}`);
|
||||
console.log(` Gate 2 (arbitrary bracket vals): ${violations.gate2.length === 0 ? "CLEAN" : `${violations.gate2.length} violation(s)`}`);
|
||||
console.log(` Gate 3 (raw font-size): ${violations.gate3.length === 0 ? "CLEAN" : `${violations.gate3.length} violation(s)`}`);
|
||||
|
||||
if (totalViolations > 0) {
|
||||
console.log("\nViolations:\n");
|
||||
for (const [gateName, list] of Object.entries(violations)) {
|
||||
if (list.length === 0) continue;
|
||||
console.log(`── ${gateName} ──`);
|
||||
for (const v of list) {
|
||||
console.log(` ${v.file}:${v.line} ${v.snippet}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\nAll gates clean.");
|
||||
process.exitCode = 0;
|
||||
}
|
||||
|
||||
// Windows path separators never appear in this repo's CI, but keep relative
|
||||
// paths POSIX-style for allowlist substring matching regardless of platform.
|
||||
function relPathToPosix(filePath) {
|
||||
return ("ui/src/" + relative(UI_SRC, filePath)).split("\\").join("/");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* codemod-extract-colors.mjs
|
||||
*
|
||||
* Phase 2 (extraction), Batch 1/4 of the design-token audit
|
||||
* (branch design/token-extraction). Replaces hardcoded COLOR literals
|
||||
* (hex / rgb() / rgba() / hsl() / hsla() / oklch()) in Tailwind class
|
||||
* strings and inline style objects, 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`.
|
||||
*
|
||||
* Scope is deliberately a fixed, manually-audited SITE TABLE rather than a
|
||||
* blind hex-matching regex sweep: a generic `#[0-9a-f]{3,8}` regex produces
|
||||
* false positives on this codebase (issue references like "acme/web#241",
|
||||
* PR/comment numbers like "React #10140", etc.). Every entry below was
|
||||
* verified by hand against TOKEN-AUDIT.md section 1 (see repo root) to
|
||||
* confirm it is (a) a real color value, (b) consumed as a rendered CSS
|
||||
* value (not fed into contrast math, canvas painting, or persisted /
|
||||
* compared JS state), and (c) safe to swap for `var(--token)` without any
|
||||
* visual difference.
|
||||
*
|
||||
* Idempotent: every site's `find` string is the ORIGINAL literal-bearing
|
||||
* form; once rewritten the file no longer contains that string, so
|
||||
* re-running the script is a no-op (each replace() call only fires if the
|
||||
* exact original substring is still present).
|
||||
*
|
||||
* Usage: node scripts/codemod-extract-colors.mjs [--check]
|
||||
* --check Report what WOULD change without writing files (dry run).
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve, dirname } 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 DRY_RUN = process.argv.includes("--check");
|
||||
|
||||
/**
|
||||
* Token table — every new token minted by this batch, value VERBATIM.
|
||||
* `name` is the CSS custom-property name (without leading --).
|
||||
* `value` is the exact literal value from the source site.
|
||||
* `comment` documents where it came from / why, emitted above the
|
||||
* declaration in ui/src/index.css.
|
||||
*/
|
||||
const NEW_TOKENS = [
|
||||
// --- Tailwind bracket color-class hex sites (section: bracket hex) ---
|
||||
{ name: "hex-959596", value: "#959596", comment: "Muted feed actor/verb/title text (ActivityFeed.tsx, FeedCard.tsx) — PRIOR-ART-flagged gap cluster, no existing token match." },
|
||||
{ name: "hex-1d1d1d", value: "#1d1d1d", comment: "OnboardingWizard.tsx dark decorative panel background, singleton." },
|
||||
|
||||
// --- OrgChart.tsx status dot colors (independent from --status-agent-* hues) ---
|
||||
{ name: "hex-22d3ee", value: "#22d3ee", comment: "OrgChart.tsx agent status dot — 'running' (independent palette from --status-agent-*, see TOKEN-AUDIT.md 1.2)." },
|
||||
{ name: "hex-4ade80", value: "#4ade80", comment: "OrgChart.tsx agent status dot — 'active'." },
|
||||
{ name: "hex-facc15", value: "#facc15", comment: "OrgChart.tsx agent status dot — 'paused' / 'idle' (shared value)." },
|
||||
{ name: "hex-f87171", value: "#f87171", comment: "OrgChart.tsx agent status dot — 'error'." },
|
||||
{ name: "hex-a3a3a3", value: "#a3a3a3", comment: "OrgChart.tsx agent status dot — 'terminated' + defaultDotColor fallback." },
|
||||
|
||||
// --- ActivityCharts.tsx priority + status color maps (independent palette, see TOKEN-AUDIT.md 1.2) ---
|
||||
{ name: "hex-ef4444", value: "#ef4444", comment: "ActivityCharts.tsx — priority 'critical' + status 'blocked' (shared value); also the <0.5 success-rate bar tint." },
|
||||
{ name: "hex-f97316", value: "#f97316", comment: "ActivityCharts.tsx — priority 'high'." },
|
||||
{ name: "hex-eab308", value: "#eab308", comment: "ActivityCharts.tsx — priority 'medium'; also the 0.5-0.8 success-rate bar tint." },
|
||||
{ name: "hex-6b7280", value: "#6b7280", comment: "ActivityCharts.tsx — priority 'low' + status 'cancelled' + statusColors fallback (shared value)." },
|
||||
{ name: "hex-3b82f6", value: "#3b82f6", comment: "ActivityCharts.tsx — status 'todo' (independent from --status-task-todo which is #f59e0b)." },
|
||||
{ name: "hex-8b5cf6", value: "#8b5cf6", comment: "ActivityCharts.tsx — status 'in_progress' (independent from --status-task-in_progress which is #2563eb — flagged inconsistency, TOKEN-AUDIT.md 1.2)." },
|
||||
{ name: "hex-a855f7", value: "#a855f7", comment: "ActivityCharts.tsx — status 'in_review'." },
|
||||
{ name: "hex-10b981", value: "#10b981", comment: "ActivityCharts.tsx — status 'done'; also the >=0.8 success-rate bar tint." },
|
||||
{ name: "hex-64748b", value: "#64748b", comment: "ActivityCharts.tsx status 'backlog'; also the widely-repeated 'no project assigned' muted-slate fallback color (TOKEN-AUDIT.md 1.3) across Routines/MarkdownEditor/RoutineRunVariablesDialog/RoutineList/IssueColumns/editable-sections." },
|
||||
|
||||
// --- Project-color-fallback indigo cluster (TOKEN-AUDIT.md 1.3) ---
|
||||
{ name: "hex-6366f1", value: "#6366f1", comment: "Project-color-fallback indigo seed default (ProjectDetail/PipelineSettings/IssueProperties/NewIssueDialog) — new-project-color-picker-seed family per TOKEN-AUDIT.md 1.3." },
|
||||
|
||||
// --- Gradient tokens (verbatim, one per distinct gradient string; DESIGN.md: mint, don't collapse) ---
|
||||
{ name: "gradient-extract-1", value: "linear-gradient(180deg,rgba(255,80,80,0.12),rgba(255,255,255,0.02))", comment: "Dashboard.tsx budget-alert card gradient." },
|
||||
{ name: "gradient-extract-2", value: "linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))", comment: "Costs.tsx subtle white card gradient." },
|
||||
{ name: "gradient-extract-3", value: "radial-gradient(circle at top left,rgba(244,114,182,0.08),transparent 35%),radial-gradient(circle at bottom right,rgba(56,189,248,0.1),transparent 32%)", comment: "AccountingModelCard.tsx decorative overlay." },
|
||||
{ name: "gradient-extract-4", value: "linear-gradient(180deg,rgba(255,70,70,0.10),rgba(255,255,255,0.02))", comment: "BudgetIncidentCard.tsx incident-card gradient." },
|
||||
{ name: "gradient-extract-5", value: "radial-gradient(circle at top left,rgba(8,145,178,0.08),transparent 36%),radial-gradient(circle at bottom right,rgba(245,158,11,0.10),transparent 28%)", comment: "RunTranscriptUxLab.tsx:78 hero gradient." },
|
||||
{ name: "gradient-extract-6", value: "linear-gradient(135deg,rgba(8,145,178,0.08),transparent 28%),linear-gradient(180deg,rgba(245,158,11,0.08),transparent 40%),var(--background)", comment: "RunTranscriptUxLab.tsx:203 hero-card gradient." },
|
||||
{ name: "gradient-extract-7", value: "radial-gradient(circle at top right,rgba(255,255,255,0.22),transparent 34%),radial-gradient(circle at bottom left,rgba(255,255,255,0.08),transparent 36%)", comment: "ProfileSettings.tsx:162 decorative overlay." },
|
||||
{ name: "gradient-extract-8", value: "radial-gradient(circle at top,rgba(8,145,178,0.18),transparent 48%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,1))", comment: "InviteUxLab.tsx:510 dark hero gradient." },
|
||||
{ name: "gradient-extract-9", value: "linear-gradient(135deg,rgba(8,145,178,0.10),transparent 28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent 44%),var(--background)", comment: "IssueChatUxLab.tsx:139 + InviteUxLab.tsx:700 hero-card gradient (identical string, 2 sites)." },
|
||||
{ name: "gradient-extract-10", value: "linear-gradient(180deg,rgba(168,85,247,0.06),transparent 28%),var(--background)", comment: "IssueChatUxLab.tsx:203 + InviteUxLab.tsx:909 accent gradient (identical string, 2 sites)." },
|
||||
{ name: "gradient-extract-11", value: "linear-gradient(180deg,rgba(16,185,129,0.06),transparent 28%),var(--background)", comment: "IssueChatUxLab.tsx:226 accent gradient." },
|
||||
{ name: "gradient-extract-12", value: "linear-gradient(180deg,rgba(6,182,212,0.05),transparent 28%),var(--background)", comment: "IssueChatUxLab.tsx:263 accent gradient." },
|
||||
{ name: "gradient-extract-13", value: "linear-gradient(180deg,rgba(59,130,246,0.06),transparent 28%),var(--background)", comment: "IssueChatUxLab.tsx:294 accent gradient." },
|
||||
{ name: "gradient-extract-14", value: "linear-gradient(180deg,rgba(168,85,247,0.05),transparent 26%),var(--background)", comment: "IssueChatUxLab.tsx:315 accent gradient." },
|
||||
{ name: "gradient-extract-15", value: "linear-gradient(180deg,rgba(245,158,11,0.08),transparent 26%),var(--background)", comment: "IssueChatUxLab.tsx:339 accent gradient." },
|
||||
{ name: "gradient-extract-16", value: "linear-gradient(135deg,rgba(245,158,11,0.10),transparent 28%),linear-gradient(180deg,rgba(8,145,178,0.08),transparent 44%),var(--background)", comment: "SystemNoticeUxLab.tsx:140 hero-card gradient." },
|
||||
{ name: "gradient-extract-17", value: "linear-gradient(180deg,rgba(245,158,11,0.05),transparent 28%),var(--background)", comment: "SystemNoticeUxLab.tsx:193 accent gradient." },
|
||||
{ name: "gradient-extract-18", value: "linear-gradient(180deg,rgba(8,145,178,0.05),transparent 28%),var(--background)", comment: "SystemNoticeUxLab.tsx:225 accent gradient." },
|
||||
{ name: "gradient-extract-19", value: "linear-gradient(180deg,rgba(244,63,94,0.05),transparent 28%),var(--background)", comment: "SystemNoticeUxLab.tsx:289 accent gradient." },
|
||||
{ name: "gradient-extract-20", value: "linear-gradient(180deg,rgba(16,185,129,0.05),transparent 28%),var(--background)", comment: "SystemNoticeUxLab.tsx:331 accent gradient." },
|
||||
{ name: "gradient-extract-21", value: "linear-gradient(180deg,rgba(59,130,246,0.05),transparent 30%),var(--background)", comment: "InviteUxLab.tsx:753 accent gradient." },
|
||||
{ name: "gradient-extract-22", value: "linear-gradient(180deg,rgba(234,179,8,0.06),transparent 28%),var(--background)", comment: "InviteUxLab.tsx:807 accent gradient." },
|
||||
{ name: "gradient-extract-23", value: "linear-gradient(180deg,rgba(16,185,129,0.06),transparent 30%),var(--background)", comment: "InviteUxLab.tsx:884 accent gradient." },
|
||||
{ name: "gradient-extract-24", value: "linear-gradient(180deg,rgba(244,114,182,0.06),transparent 28%),var(--background)", comment: "InviteUxLab.tsx:921 accent gradient." },
|
||||
];
|
||||
|
||||
/**
|
||||
* Existing-token reuse map — hardcoded value -> existing index.css token,
|
||||
* used ONLY where the value exact-matches (case-insensitive) a token whose
|
||||
* value is IDENTICAL in :root and .dark (mode-independent brand tier per
|
||||
* DESIGN.md). Both matches found in this batch are status hues with no
|
||||
* `.dark` override in index.css.
|
||||
*/
|
||||
const REUSE = {
|
||||
"#2563EB": "var(--status-task-in_progress)", // == --status-agent-running, both #2563eb, mode-independent
|
||||
"#22c55e": "var(--status-task-done)",
|
||||
};
|
||||
|
||||
/**
|
||||
* SITE TABLE — [relative file path, find, replace]. Order-independent;
|
||||
* every find/replace pair is applied with a single non-global `.replace()`
|
||||
* per occurrence count noted, so duplicate literal strings within one file
|
||||
* (e.g. the three `text-[#959596]` in ActivityFeed.tsx) are handled via
|
||||
* `replaceAll` where explicitly marked.
|
||||
*/
|
||||
const SITES = [
|
||||
// ── Tailwind bracket hex-class sites ──────────────────────────────
|
||||
{
|
||||
file: "components/ActivityFeed.tsx",
|
||||
replaceAll: [
|
||||
['className="font-medium text-[#959596] group-hover:text-white"', 'className="font-medium text-(--hex-959596) group-hover:text-white"'],
|
||||
['className="ml-1 text-[#959596]"', 'className="ml-1 text-(--hex-959596)"'],
|
||||
['className="ml-1 text-[#959596] group-hover:text-white"', 'className="ml-1 text-(--hex-959596) group-hover:text-white"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/FeedCard.tsx",
|
||||
replaceAll: [
|
||||
['isMuted ? "text-muted-foreground/70" : "text-[#959596]"', 'isMuted ? "text-muted-foreground/70" : "text-(--hex-959596)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/OnboardingWizard.tsx",
|
||||
replaceAll: [
|
||||
['"hidden md:block overflow-hidden bg-[#1d1d1d] transition-[width,opacity] duration-500 ease-in-out"', '"hidden md:block overflow-hidden bg-(--hex-1d1d1d) transition-[width,opacity] duration-500 ease-in-out"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/IssueChatThread.tsx",
|
||||
replaceAll: [
|
||||
['// Liveness blue (#2563EB) for the human\'s own messages (PAP-95 rev 5).', '// Liveness blue (--status-task-in_progress) for the human\'s own messages (PAP-95 rev 5).'],
|
||||
['? "bg-[#2563EB] text-white"', '? "bg-(--status-task-in_progress) text-white"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/IssueChatThread.test.tsx",
|
||||
replaceAll: [
|
||||
['expect(bubble?.className).not.toContain("bg-[#2563EB]");', 'expect(bubble?.className).not.toContain("bg-(--status-task-in_progress)");'],
|
||||
],
|
||||
},
|
||||
|
||||
// ── OrgChart.tsx status dot color map (pure style render) ─────────
|
||||
{
|
||||
file: "pages/OrgChart.tsx",
|
||||
replaceAll: [
|
||||
['running: "#22d3ee",', 'running: "var(--hex-22d3ee)",'],
|
||||
['active: "#4ade80",', 'active: "var(--hex-4ade80)",'],
|
||||
['paused: "#facc15",\n idle: "#facc15",', 'paused: "var(--hex-facc15)",\n idle: "var(--hex-facc15)",'],
|
||||
['error: "#f87171",', 'error: "var(--hex-f87171)",'],
|
||||
['terminated: "#a3a3a3",', 'terminated: "var(--hex-a3a3a3)",'],
|
||||
['const defaultDotColor = "#a3a3a3";', 'const defaultDotColor = "var(--hex-a3a3a3)";'],
|
||||
],
|
||||
},
|
||||
|
||||
// ── ActivityCharts.tsx priority + status color maps (pure style render) ──
|
||||
{
|
||||
file: "components/ActivityCharts.tsx",
|
||||
replaceAll: [
|
||||
['critical: "#ef4444",', 'critical: "var(--hex-ef4444)",'],
|
||||
['high: "#f97316",', 'high: "var(--hex-f97316)",'],
|
||||
['medium: "#eab308",', 'medium: "var(--hex-eab308)",'],
|
||||
['low: "#6b7280",', 'low: "var(--hex-6b7280)",'],
|
||||
['todo: "#3b82f6",', 'todo: "var(--hex-3b82f6)",'],
|
||||
['in_progress: "#8b5cf6",', 'in_progress: "var(--hex-8b5cf6)",'],
|
||||
['in_review: "#a855f7",', 'in_review: "var(--hex-a855f7)",'],
|
||||
['done: "#10b981",', 'done: "var(--hex-10b981)",'],
|
||||
['blocked: "#ef4444",', 'blocked: "var(--hex-ef4444)",'],
|
||||
['cancelled: "#6b7280",', 'cancelled: "var(--hex-6b7280)",'],
|
||||
['backlog: "#64748b",', 'backlog: "var(--hex-64748b)",'],
|
||||
['backgroundColor: statusColors[s] ?? "#6b7280"', 'backgroundColor: statusColors[s] ?? "var(--hex-6b7280)"'],
|
||||
['color: statusColors[s] ?? "#6b7280"', 'color: statusColors[s] ?? "var(--hex-6b7280)"'],
|
||||
['rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444"', 'rate >= 0.8 ? "var(--hex-10b981)" : rate >= 0.5 ? "var(--hex-eab308)" : "var(--hex-ef4444)"'],
|
||||
],
|
||||
},
|
||||
|
||||
// ── Project-color-fallback pure-style-render sites (indigo #6366f1) ──
|
||||
{
|
||||
file: "pages/ProjectDetail.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: project.color ?? "#6366f1"', 'backgroundColor: project.color ?? "var(--hex-6366f1)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/PipelineSettings.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: selectedAutomationProject.color ?? "#6366f1"', 'backgroundColor: selectedAutomationProject.color ?? "var(--hex-6366f1)"'],
|
||||
['backgroundColor: project?.color ?? "#6366f1"', 'backgroundColor: project?.color ?? "var(--hex-6366f1)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/issue-properties/IssueProperties.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1"', 'backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "var(--hex-6366f1)"'],
|
||||
['backgroundColor: option.color ?? "#6366f1"', 'backgroundColor: option.color ?? "var(--hex-6366f1)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/NewIssueDialog.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: currentProject.color ?? "#6366f1"', 'backgroundColor: currentProject.color ?? "var(--hex-6366f1)"'],
|
||||
['backgroundColor: project?.color ?? "#6366f1"', 'backgroundColor: project?.color ?? "var(--hex-6366f1)"'],
|
||||
],
|
||||
},
|
||||
|
||||
// ── Project-color-fallback pure-style-render sites (slate #64748b) ──
|
||||
{
|
||||
file: "pages/Routines.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: currentProject.color ?? "#64748b"', 'backgroundColor: currentProject.color ?? "var(--hex-64748b)"'],
|
||||
['backgroundColor: project?.color ?? "#64748b"', 'backgroundColor: project?.color ?? "var(--hex-64748b)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/MarkdownEditor.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: option.projectColor ?? "#64748b"', 'backgroundColor: option.projectColor ?? "var(--hex-64748b)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/RoutineRunVariablesDialog.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: selectedProject.color ?? "#64748b"', 'backgroundColor: selectedProject.color ?? "var(--hex-64748b)"'],
|
||||
['backgroundColor: project?.color ?? "#64748b"', 'backgroundColor: project?.color ?? "var(--hex-64748b)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/RoutineList.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: project?.color ?? "#64748b"', 'backgroundColor: project?.color ?? "var(--hex-64748b)"'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/routine-sections/editable-sections.tsx",
|
||||
replaceAll: [
|
||||
['backgroundColor: currentProject.color ?? "#64748b"', 'backgroundColor: currentProject.color ?? "var(--hex-64748b)"'],
|
||||
['backgroundColor: project?.color ?? "#64748b"', 'backgroundColor: project?.color ?? "var(--hex-64748b)"'],
|
||||
],
|
||||
},
|
||||
|
||||
// ── Gradient sites (verbatim value -> --gradient-extract-N token) ─
|
||||
{
|
||||
file: "pages/Dashboard.tsx",
|
||||
replaceAll: [
|
||||
['bg-[linear-gradient(180deg,rgba(255,80,80,0.12),rgba(255,255,255,0.02))]', 'bg-(image:--gradient-extract-1)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/Costs.tsx",
|
||||
replaceAll: [
|
||||
['bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))]', 'bg-(image:--gradient-extract-2)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/AccountingModelCard.tsx",
|
||||
replaceAll: [
|
||||
['bg-[radial-gradient(circle_at_top_left,rgba(244,114,182,0.08),transparent_35%),radial-gradient(circle_at_bottom_right,rgba(56,189,248,0.1),transparent_32%)]', 'bg-(image:--gradient-extract-3)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/BudgetIncidentCard.tsx",
|
||||
replaceAll: [
|
||||
['bg-[linear-gradient(180deg,rgba(255,70,70,0.10),rgba(255,255,255,0.02))]', 'bg-(image:--gradient-extract-4)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/RunTranscriptUxLab.tsx",
|
||||
replaceAll: [
|
||||
['bg-[radial-gradient(circle_at_top_left,rgba(8,145,178,0.08),transparent_36%),radial-gradient(circle_at_bottom_right,rgba(245,158,11,0.10),transparent_28%)]', 'bg-(image:--gradient-extract-5)'],
|
||||
['bg-[linear-gradient(135deg,rgba(8,145,178,0.08),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.08),transparent_40%),var(--background)]', 'bg-(image:--gradient-extract-6)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/ProfileSettings.tsx",
|
||||
replaceAll: [
|
||||
['bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.22),transparent_34%),radial-gradient(circle_at_bottom_left,rgba(255,255,255,0.08),transparent_36%)]', 'bg-(image:--gradient-extract-7)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/InviteUxLab.tsx",
|
||||
replaceAll: [
|
||||
['bg-[radial-gradient(circle_at_top,rgba(8,145,178,0.18),transparent_48%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,1))]', 'bg-(image:--gradient-extract-8)'],
|
||||
['bg-[linear-gradient(135deg,rgba(8,145,178,0.10),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent_44%),var(--background)]', 'bg-(image:--gradient-extract-9)'],
|
||||
['bg-[linear-gradient(180deg,rgba(59,130,246,0.05),transparent_30%),var(--background)]', 'bg-(image:--gradient-extract-21)'],
|
||||
['bg-[linear-gradient(180deg,rgba(234,179,8,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-22)'],
|
||||
['bg-[linear-gradient(180deg,rgba(16,185,129,0.06),transparent_30%),var(--background)]', 'bg-(image:--gradient-extract-23)'],
|
||||
['bg-[linear-gradient(180deg,rgba(168,85,247,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-10)'],
|
||||
['bg-[linear-gradient(180deg,rgba(244,114,182,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-24)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/IssueChatUxLab.tsx",
|
||||
replaceAll: [
|
||||
['bg-[linear-gradient(135deg,rgba(8,145,178,0.10),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent_44%),var(--background)]', 'bg-(image:--gradient-extract-9)'],
|
||||
['bg-[linear-gradient(180deg,rgba(168,85,247,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-10)'],
|
||||
['bg-[linear-gradient(180deg,rgba(16,185,129,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-11)'],
|
||||
['bg-[linear-gradient(180deg,rgba(6,182,212,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-12)'],
|
||||
['bg-[linear-gradient(180deg,rgba(59,130,246,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-13)'],
|
||||
['bg-[linear-gradient(180deg,rgba(168,85,247,0.05),transparent_26%),var(--background)]', 'bg-(image:--gradient-extract-14)'],
|
||||
['bg-[linear-gradient(180deg,rgba(245,158,11,0.08),transparent_26%),var(--background)]', 'bg-(image:--gradient-extract-15)'],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "pages/SystemNoticeUxLab.tsx",
|
||||
replaceAll: [
|
||||
['bg-[linear-gradient(135deg,rgba(245,158,11,0.10),transparent_28%),linear-gradient(180deg,rgba(8,145,178,0.08),transparent_44%),var(--background)]', 'bg-(image:--gradient-extract-16)'],
|
||||
['bg-[linear-gradient(180deg,rgba(245,158,11,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-17)'],
|
||||
['bg-[linear-gradient(180deg,rgba(8,145,178,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-18)'],
|
||||
['bg-[linear-gradient(180deg,rgba(244,63,94,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-19)'],
|
||||
['bg-[linear-gradient(180deg,rgba(16,185,129,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-20)'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Allowlist — sites intentionally NOT rewritten (functional / third-party) ──
|
||||
// One entry per file; each also gets an inline
|
||||
// `/* token-extraction: allowlisted — ... */` comment injected at the site
|
||||
// (idempotent: only injected if not already present).
|
||||
const ALLOWLIST_COMMENTS = [
|
||||
{
|
||||
file: "pages/CompanyEnvironments.tsx",
|
||||
anchor: 'background: "#0a0a0a",',
|
||||
commentLine: " // token-extraction: allowlisted — xterm.js terminal theme config; functional third-party option object, not a rendered CSS value.",
|
||||
},
|
||||
{
|
||||
file: "pages/CompanySettings.tsx",
|
||||
anchor: ' <input\n type="color"',
|
||||
commentLine: " {/* token-extraction: allowlisted — <input type=\"color\"> value must be a real hex string, not a var() reference. */}",
|
||||
},
|
||||
{
|
||||
file: "components/issue-properties/IssueProperties.tsx",
|
||||
anchor: 'const [newLabelColor, setNewLabelColor] = useState("#6366f1");',
|
||||
commentLine: " // token-extraction: allowlisted — color-picker seed state, persisted into label-create payload; a var() string would break that payload.",
|
||||
},
|
||||
{
|
||||
file: "pages/CompanySkills.tsx",
|
||||
anchor: "const DISCOVERY_ACCENTS = [",
|
||||
commentLine: "// token-extraction: allowlisted — skill.color is persisted/compared JS data (SkillCreateDraft), not just a rendered value; a var() string would corrupt it.",
|
||||
},
|
||||
{
|
||||
file: "components/IssueColumns.tsx",
|
||||
anchor: 'const accentColor = projectColor ?? "#64748b";',
|
||||
commentLine: " // token-extraction: allowlisted — accentColor also feeds pickTextColorForPillBg() contrast math; a var() string can't be parsed as a hex color there.",
|
||||
},
|
||||
{
|
||||
file: "components/CompanyPatternIcon.tsx",
|
||||
anchor: "ctx.fillStyle = `rgb(${offR} ${offG} ${offB})`;",
|
||||
commentLine: " // token-extraction: allowlisted — canvas 2D fillStyle computed at runtime from numeric channel props; not a static literal.",
|
||||
},
|
||||
{
|
||||
file: "components/FileViewerSheet.tsx",
|
||||
anchor: 'isHighlighted && "bg-[var(--paperclip-code-highlight-bg,rgba(250,204,21,0.12))]",',
|
||||
commentLine: " // token-extraction: allowlisted — half-migrated var(--x, fallback) pattern; --paperclip-code-highlight-bg/-border don't exist in index.css yet. Needs human decision (see TOKEN-AUDIT.md 2) before minting, since defining the var changes a fallback-CSS-var-expression, not a plain literal swap.",
|
||||
},
|
||||
{
|
||||
file: "pages/InviteUxLab.tsx",
|
||||
anchor: ' <div className="flex items-start gap-4">\n <CompanyPatternIcon\n companyName="Acme Robotics"\n logoUrl="/api/invites/pcp_invite_test/logo"\n brandColor="#114488"\n className="h-16 w-16 rounded-none border border-zinc-800"',
|
||||
commentLine: " {/* token-extraction: allowlisted — brandColor feeds CompanyPatternIcon's hexToHue() color math via a canvas fill; demo/showcase-only prop, not a rendered CSS value. */}",
|
||||
},
|
||||
{
|
||||
file: "pages/InviteUxLab.tsx",
|
||||
anchor: ' <div className="flex items-center gap-3">\n <CompanyPatternIcon\n companyName="Acme Robotics"\n logoUrl="/api/invites/pcp_invite_test/logo"\n brandColor="#114488"\n className="h-12 w-12 rounded-none border border-zinc-800"',
|
||||
commentLine: " {/* token-extraction: allowlisted — brandColor feeds CompanyPatternIcon's hexToHue() color math via a canvas fill; demo/showcase-only prop, not a rendered CSS value. */}",
|
||||
},
|
||||
];
|
||||
|
||||
function applyReplacements(content, pairs, filePath) {
|
||||
let next = content;
|
||||
let count = 0;
|
||||
for (const [find, replace] of pairs) {
|
||||
if (next.includes(find)) {
|
||||
next = next.split(find).join(replace);
|
||||
count += 1;
|
||||
} else if (!next.includes(replace)) {
|
||||
// Neither the original nor the replacement is present — likely a
|
||||
// stale site table entry; flag loudly rather than silently no-op.
|
||||
console.warn(` ! WARNING: pattern not found (and not already applied) in ${filePath}:\n ${find.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
return { next, count };
|
||||
}
|
||||
|
||||
function injectAllowlistComment(content, anchor, commentLine, filePath) {
|
||||
if (content.includes(commentLine.trim())) return { next: content, injected: false }; // idempotent
|
||||
const idx = content.indexOf(anchor);
|
||||
if (idx === -1) {
|
||||
console.warn(` ! WARNING: allowlist anchor not found in ${filePath}:\n ${anchor}`);
|
||||
return { next: content, injected: false };
|
||||
}
|
||||
const lineStart = content.lastIndexOf("\n", idx) + 1;
|
||||
const next = content.slice(0, lineStart) + commentLine + "\n" + content.slice(lineStart);
|
||||
return { next, injected: true };
|
||||
}
|
||||
|
||||
function main() {
|
||||
let totalSitesRewritten = 0;
|
||||
let filesChanged = 0;
|
||||
const changedFiles = [];
|
||||
|
||||
for (const site of SITES) {
|
||||
const filePath = resolve(UI_SRC, site.file);
|
||||
const original = readFileSync(filePath, "utf8");
|
||||
const { next, count } = applyReplacements(original, site.replaceAll, site.file);
|
||||
if (next !== original) {
|
||||
filesChanged += 1;
|
||||
changedFiles.push(site.file);
|
||||
if (!DRY_RUN) writeFileSync(filePath, next, "utf8");
|
||||
}
|
||||
totalSitesRewritten += count;
|
||||
}
|
||||
|
||||
let allowlistInjections = 0;
|
||||
for (const entry of ALLOWLIST_COMMENTS) {
|
||||
const filePath = resolve(UI_SRC, entry.file);
|
||||
const original = readFileSync(filePath, "utf8");
|
||||
const { next, injected } = injectAllowlistComment(original, entry.anchor, entry.commentLine, entry.file);
|
||||
if (injected) {
|
||||
allowlistInjections += 1;
|
||||
if (!DRY_RUN) writeFileSync(filePath, next, "utf8");
|
||||
if (!changedFiles.includes(entry.file)) {
|
||||
filesChanged += 1;
|
||||
changedFiles.push(entry.file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── index.css token block ──────────────────────────────────────────
|
||||
const cssPath = resolve(UI_SRC, "index.css");
|
||||
const cssOriginal = readFileSync(cssPath, "utf8");
|
||||
const marker = "/* ── Extracted verbatim tokens (Phase 2, design/token-extraction) ── */";
|
||||
let cssNext = cssOriginal;
|
||||
let cssChanged = false;
|
||||
|
||||
if (!cssOriginal.includes(marker)) {
|
||||
const tokenLines = NEW_TOKENS.map((t) => ` --${t.name}: ${t.value}; /* ${t.comment} */`).join("\n");
|
||||
const block = `\n${marker}\n/* Batch 1/4: color literals only. Reused-from-existing-token sites (see\n TOKEN-AUDIT.md section 1.1) are NOT duplicated here — they reference\n --status-task-in_progress / --status-task-done directly at the call site.\n\n Allowlist (sites intentionally left as hardcoded / functional literals,\n NOT converted to tokens — each also carries an inline\n \`token-extraction: allowlisted\` comment at the site):\n - pages/CompanyEnvironments.tsx — xterm.js terminal theme config; functional JS values, third-party.\n - pages/CompanySettings.tsx — <input type="color"> value; functional form control, not a rendered value.\n - components/issue-properties/IssueProperties.tsx (newLabelColor) — color-picker seed persisted into label-create payload.\n - pages/CompanySkills.tsx (DISCOVERY_ACCENTS) — persisted/compared skill.color JS data, not just rendered.\n - components/IssueColumns.tsx (accentColor fallback) — also feeds pickTextColorForPillBg() contrast math.\n - components/CompanyPatternIcon.tsx — canvas fillStyle computed at runtime from numeric props, not a static literal.\n - components/FileViewerSheet.tsx — half-migrated var(--paperclip-code-highlight-*, fallback) pattern; needs human decision, see TOKEN-AUDIT.md section 2.\n - pages/InviteUxLab.tsx (brandColor prop, x2) — demo/showcase-only prop feeding CompanyPatternIcon's hexToHue() color math, not a rendered CSS value.\n*/\n:root {\n${tokenLines}\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-colors summary`);
|
||||
console.log(` Sites rewritten: ${totalSitesRewritten}`);
|
||||
console.log(` Component/page files changed: ${filesChanged}`);
|
||||
console.log(` Allowlist comments injected: ${allowlistInjections}`);
|
||||
console.log(` New tokens minted: ${NEW_TOKENS.length}`);
|
||||
console.log(` Existing tokens reused: ${Object.keys(REUSE).length} (${Object.values(REUSE).join(", ")})`);
|
||||
console.log(` index.css token block: ${cssChanged ? "added" : "already present (idempotent no-op)"}`);
|
||||
if (changedFiles.length) {
|
||||
console.log(`\n Changed files:`);
|
||||
for (const f of changedFiles) console.log(` - ui/src/${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,599 @@
|
||||
#!/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();
|
||||
@@ -0,0 +1,517 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* codemod-extract-sizes.mjs
|
||||
*
|
||||
* Phase 2 (extraction), Batch 3/4 of the design-token audit
|
||||
* (branch design/token-extraction). Replaces hardcoded SIZE / SPACING /
|
||||
* RADIUS / SHADOW arbitrary Tailwind bracket values 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 (all unambiguous bracket-literal Tailwind utilities —
|
||||
* like Batch 2's font-size/tracking sweep, a blanket regex is safe here
|
||||
* because none of these utility prefixes have a non-dimensional meaning):
|
||||
* w-[...] h-[...] size-[...] min-w-[...] max-w-[...] min-h-[...] max-h-[...]
|
||||
* p-[...] pt/pb/pl/pr/px/py-[...] m-[...] mt/mb/ml/mr/mx/my-[...]
|
||||
* gap-[...] gap-x-[...] gap-y-[...]
|
||||
* inset-[...] inset-x/y-[...] top/left/right/bottom-[...]
|
||||
* translate-x-[...] translate-y-[...]
|
||||
* rounded-[...] and directional rounded-t/r/b/l/tl/tr/br/bl-[...]
|
||||
* shadow-[...] ring-[...] outline-[...]
|
||||
* `!`-important-prefixed utilities (`!max-w-[90%]`) and variant/arbitrary-
|
||||
* variant prefixes (`sm:`, `dark:`, `[&>x]:`, etc.) are preserved verbatim —
|
||||
* the regex only rewrites the bracket portion itself.
|
||||
*
|
||||
* BOUNDARY GOTCHA (found + fixed during this batch's dry run): a naive
|
||||
* `\b(top|left|right|bottom)-\[...\]` regex false-positives on Tailwind's
|
||||
* OWN compound animation utilities `slide-out-to-top-[1%]` /
|
||||
* `slide-in-from-top-[1%]` (`components/ui/dialog.tsx`,
|
||||
* `components/ui/alert-dialog.tsx`) — `\b` matches at the `-to-top`
|
||||
* boundary because `-` is not a word character, so the regex would rewrite
|
||||
* only the `top-[1%]` tail of a longer utility name and silently corrupt
|
||||
* `slide-out-to-top-[1%]` into `slide-out-to-top-(--pct-1)` (which Tailwind
|
||||
* would not recognize as the `slide-out-to-top-*` animation-direction
|
||||
* utility at all — a real visual regression, not just a naming nit). Fixed
|
||||
* by requiring the utility name to start at a genuine class-token boundary
|
||||
* (preceded by whitespace, a quote character, backtick, template-literal
|
||||
* `${`, or the start of the string) rather than a bare `\b`.
|
||||
*
|
||||
* Token naming (verbatim value, no normalizing/rounding/unit-conversion):
|
||||
* --sz-<value><unit> width/height/spacing lengths — ONE shared family so
|
||||
* identical values dedupe across w/h/p/m/gap/inset/etc.
|
||||
* e.g. --sz-320: 320px; --sz-0_7rem: 0.7rem;
|
||||
* --sz-85vh: 85vh; --sz-24ch: 24ch;
|
||||
* --sz-calc-<n> calc(...)/min()/max()/clamp() forms, sequentially
|
||||
* numbered (values are not safely nameable by content
|
||||
* without ambiguity), each with an inline comment.
|
||||
* --sz-safe-<edge> bare env(safe-area-inset-<edge>) forms, verbatim.
|
||||
* --pct-<N> bare percentage values, e.g. --pct-50: 50%;
|
||||
* --pct-neg-<N> negative percentage values as they literally
|
||||
* appear in the bracket (e.g. translate-x-[-50%]),
|
||||
* e.g. --pct-neg-50: -50%; (kept as a DISTINCT token
|
||||
* from --pct-50 rather than negated at the utility
|
||||
* level, since the source bracket already carries the
|
||||
* minus sign inside the value, not as a utility
|
||||
* prefix — see Step 0 spike notes in TOKEN-AUDIT.md).
|
||||
* --rad-<value> radius/ring/outline width values (unitless numeric
|
||||
* suffix, unit implied px unless the source used a
|
||||
* different unit, in which case the unit is appended
|
||||
* to the name for disambiguation), e.g. --rad-8: 8px;
|
||||
* --shadow-extract-<n> shadow values verbatim (multi-stop shadows are not
|
||||
* nameable by content), each with an inline comment.
|
||||
* Underscore-to-space reversal applied (Tailwind's
|
||||
* own bracket-escaping convention; see Batch 1's
|
||||
* gradient gotcha, generalized here to shadows).
|
||||
*
|
||||
* CALC-SPACING GOTCHA (Batch 1's underscore lesson, generalized): Tailwind's
|
||||
* bracket escaping uses `_` for literal spaces (`calc(-50%_-_2px)`), but
|
||||
* `calc(100%-2rem)` (WITHOUT underscores, i.e. no space around the operator
|
||||
* AT ALL in the original bracket) is *also* present in this codebase and is
|
||||
* INVALID CSS once lifted verbatim into a custom property — verified via a
|
||||
* headless-browser Step 0 spike: `calc(100%-2rem)` silently drops (falls
|
||||
* back to the containing block's width) while `calc(100% - 2rem)` (spaces
|
||||
* added) computes correctly. Every calc() lifted into a token by this
|
||||
* codemod therefore has spaces normalized around its top-level `+`/`-`
|
||||
* operators (percent/length arithmetic), in addition to underscore reversal.
|
||||
*
|
||||
* Tailwind v4 paren-shorthand rewrite forms used (confirmed via mandatory
|
||||
* Step-0 syntax spike — scratch story + `pnpm build-storybook` + grep of
|
||||
* emitted CSS, then deleted before this codemod ran):
|
||||
* w-[320px] -> w-(--sz-320) width:var(--sz-320)
|
||||
* max-h-[85vh] -> max-h-(--sz-85vh) max-height:var(...)
|
||||
* p-[18px] -> p-(--sz-18) padding:var(...)
|
||||
* gap-[3px] -> gap-(--sz-3) gap:var(...)
|
||||
* rounded-[8px] -> rounded-(--rad-8) border-radius
|
||||
* rounded-br-[4px] -> rounded-br-(--rad-4) border-bottom-right-radius
|
||||
* shadow-[...] -> shadow-(--shadow-extract-N) box-shadow (bare
|
||||
* form works — no `shadow:` hint needed; verified
|
||||
* identical emitted rule to the hinted form).
|
||||
* top-[50%] -> top-(--pct-50) top:var(...)
|
||||
* translate-x-[-50%] -> translate-x-(--pct-neg-50) --tw-translate-x:var(...)
|
||||
* pb-[env(safe-area-inset-bottom)] -> pb-(--sz-safe-bottom) padding-bottom
|
||||
* w-[var(--radix-popover-trigger-width)] -> w-(--radix-popover-trigger-width)
|
||||
* (brackets that only wrap a var() reference a runtime library variable
|
||||
* directly — no new token minted, per DESIGN.md special-case guidance).
|
||||
*
|
||||
* ring-[Npx] / outline-[Npx] note: `ring`/`outline` are box-shadow/outline-
|
||||
* width utilities whose bracket form sets a WIDTH, not a generic length or
|
||||
* shadow value — and `ring`/`outline` ALSO have color-bracket forms
|
||||
* (`ring-[#hex]`). The correct paren-shorthand hint is `ring-(length:--x)` /
|
||||
* `outline-(length:--x)` (spiked and confirmed correct — a bare `ring-(--x)`
|
||||
* would be ambiguous the same way `text-(--x)` is). No ring/outline color-
|
||||
* bracket sites exist in this codebase (verified during Step 0 inventory),
|
||||
* so only the width form is handled.
|
||||
*
|
||||
* `calc(theme(spacing.N)±Mpx)` forms (components/IssueRow.tsx): theme() is a
|
||||
* Tailwind BUILD-TIME function and does not work inside a runtime CSS custom
|
||||
* property. This codemod resolves theme(spacing.N) using Tailwind v4's
|
||||
* default `--spacing: 0.25rem` base (confirmed via Step 0: no `--spacing`
|
||||
* override exists in index.css, and the ACTUAL BUILT CSS for these exact
|
||||
* classes was inspected before this codemod ran and shows
|
||||
* `padding-left:calc(.5rem - 2px)` / `calc(.25rem - 2px)` for
|
||||
* theme(spacing.2)/theme(spacing.1) respectively, and a plain
|
||||
* `margin-left:1.25rem` for the fully-constant
|
||||
* theme(spacing.3)+theme(spacing.2) expression, i.e. Tailwind pre-resolves
|
||||
* it when both operands are compile-time constants). The resolved-equivalent
|
||||
* calc() is minted as the token value (byte-equivalent computed output
|
||||
* verified via headless-browser spike, see TOKEN-AUDIT.md Batch 3 log).
|
||||
*
|
||||
* Idempotent: the FIND regex only matches the ORIGINAL bracket-literal form;
|
||||
* once rewritten the pattern no longer matches, so re-running is a no-op.
|
||||
*
|
||||
* Usage: node scripts/codemod-extract-sizes.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. No site in this batch uses
|
||||
// `\_` (escaped underscore meaning a LITERAL underscore) — verified by
|
||||
// inspection of every match — so a plain global replace is safe.
|
||||
function unescapeSpaces(value) {
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
// Ensures whitespace around top-level +/- operators inside calc()/min()/
|
||||
// max()/clamp() so the expression is valid once it's the value of a runtime
|
||||
// custom property (see CALC-SPACING GOTCHA in the header comment).
|
||||
//
|
||||
// DELIBERATELY NARROW: only rewrites a +/- whose LEFT side is a number,
|
||||
// number+unit, or a closing paren `)`, and whose RIGHT side is a number or
|
||||
// the start of a known CSS value-function call (`env(`, `var(`, `calc(`,
|
||||
// `min(`, `max(`, `clamp(`). This excludes any hyphen that sits between two
|
||||
// bare identifier characters — critical because a naive "any letter-hyphen-
|
||||
// letter" rule corrupts `env(safe-area-inset-bottom)` into
|
||||
// `env(safe - area - inset - bottom)` (caught during this batch's dry run;
|
||||
// see CALC-SPACING GOTCHA). A leading unary minus right after `(`/`,`/start
|
||||
// (e.g. `min(-40px, ...)`) is also left untouched since nothing precedes it
|
||||
// on the left to match.
|
||||
const CALC_UNIT = "(?:px|rem|em|vh|vw|dvh|dvw|svh|svw|ch|%)";
|
||||
const CALC_NUM = "[0-9]*\\.?[0-9]+";
|
||||
const CALC_FUNC = "(?:env|var|calc|min|max|clamp)\\(";
|
||||
const CALC_SPACING_RE = new RegExp(
|
||||
`(${CALC_NUM}${CALC_UNIT}|${CALC_NUM}|\\))\\s*([+-])\\s*(?=${CALC_NUM}|${CALC_FUNC})`,
|
||||
"g",
|
||||
);
|
||||
function normalizeCalcSpacing(value) {
|
||||
return value.replace(CALC_SPACING_RE, (m, left, op) => `${left} ${op} `);
|
||||
}
|
||||
|
||||
// "320" "0.7rem" "-50" etc -> safe token-name suffix. Dots become
|
||||
// underscores (matches Batch 2's `--fs-0_7rem` convention); a leading minus
|
||||
// is spelled out as `neg-` for readability.
|
||||
function safeSuffix(value) {
|
||||
let v = value;
|
||||
const negative = v.startsWith("-");
|
||||
if (negative) v = v.slice(1);
|
||||
v = v.replace(/\./g, "_");
|
||||
return negative ? `neg-${v}` : v;
|
||||
}
|
||||
|
||||
// ── Token registries ────────────────────────────────────────────────────
|
||||
const szTokens = new Map(); // --sz-* (width/height/spacing/gap/inset lengths, shared family)
|
||||
const radTokens = new Map(); // --rad-* (radius/ring-width/outline-width, shared family)
|
||||
const pctTokens = new Map(); // --pct-* / --pct-neg-*
|
||||
const shadowTokens = new Map(); // --shadow-extract-*
|
||||
let calcCounter = 0;
|
||||
let shadowCounter = 0;
|
||||
|
||||
function registerSzToken(rawValue, unit, sourceNote) {
|
||||
const value = `${rawValue}${unit}`;
|
||||
const name = `sz-${safeSuffix(rawValue)}${unit}`;
|
||||
if (!szTokens.has(name)) szTokens.set(name, { value, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
// Percentages: --pct-50 for "50", --pct-neg-50 for "-50" (kept distinct per
|
||||
// DESIGN.md Step-0 spike guidance — the minus sign is IN the bracket value).
|
||||
function registerPctToken(rawNum, sourceNote) {
|
||||
const name = `pct-${safeSuffix(rawNum)}`;
|
||||
const value = `${rawNum}%`;
|
||||
if (!pctTokens.has(name)) pctTokens.set(name, { value, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
// Radius / ring-width / outline-width family, shared. Unit is only appended
|
||||
// to the name when it's not a bare px number (px is the overwhelming
|
||||
// majority and matches Batch 1/2's bare-number convention, e.g. --fs-11).
|
||||
function registerRadToken(rawValue, unit, sourceNote) {
|
||||
const value = `${rawValue}${unit}`;
|
||||
const name = unit === "px" ? `rad-${safeSuffix(rawValue)}` : `rad-${safeSuffix(rawValue)}${unit}`;
|
||||
if (!radTokens.has(name)) radTokens.set(name, { value, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
// calc()/env()/min()/max()/clamp() forms and any other compound expression:
|
||||
// minted 1:1 per distinct (already-unescaped, space-normalized) string into
|
||||
// the --sz-* family, named sequentially (content isn't safely nameable) with
|
||||
// a comment noting origin. Reused across sites if the exact string recurs.
|
||||
const calcByValue = new Map(); // normalized value -> token name
|
||||
function registerCalcToken(normalizedValue, sourceNote) {
|
||||
if (calcByValue.has(normalizedValue)) return calcByValue.get(normalizedValue);
|
||||
calcCounter += 1;
|
||||
const name = `sz-calc-${calcCounter}`;
|
||||
szTokens.set(name, { value: normalizedValue, comment: sourceNote });
|
||||
calcByValue.set(normalizedValue, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
function registerSafeAreaToken(edge, unescapedValue, sourceNote) {
|
||||
const name = `sz-safe-${edge}`;
|
||||
if (!szTokens.has(name)) szTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
return name;
|
||||
}
|
||||
|
||||
const shadowByValue = new Map(); // unescaped shadow value -> token name
|
||||
function registerShadowToken(unescapedValue, sourceNote) {
|
||||
if (shadowByValue.has(unescapedValue)) return shadowByValue.get(unescapedValue);
|
||||
shadowCounter += 1;
|
||||
const name = `shadow-extract-${shadowCounter}`;
|
||||
shadowTokens.set(name, { value: unescapedValue, comment: sourceNote });
|
||||
shadowByValue.set(unescapedValue, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
// ── Classification of a raw bracket value ───────────────────────────────
|
||||
// Returns { tokenRef, isVarOnly } where tokenRef is the FULL replacement
|
||||
// content to place inside `(...)`, e.g. "--sz-320" or
|
||||
// "--radix-popover-trigger-width" (var-only passthrough, no new token).
|
||||
const SIMPLE_LENGTH_RE = /^(-?[0-9.]+)(px|rem|em|vh|vw|dvh|dvw|svh|svw|ch|%)$/;
|
||||
const VAR_ONLY_RE = /^var\((--[a-zA-Z0-9-]+)\)$/;
|
||||
const ENV_ONLY_RE = /^env\((safe-area-inset-[a-z]+)\)$/;
|
||||
const THEME_SPACING_RE = /theme\(spacing\.([0-9.]+)\)/g;
|
||||
|
||||
// Tailwind v4 default spacing scale: --spacing: 0.25rem (confirmed via Step 0:
|
||||
// no --spacing override exists in ui/src/index.css).
|
||||
const TAILWIND_SPACING_BASE_REM = 0.25;
|
||||
|
||||
function resolveThemeSpacing(raw) {
|
||||
// Resolves theme(spacing.N) tokens to their rem equivalents so the
|
||||
// expression can live inside a runtime CSS custom property (theme() is a
|
||||
// Tailwind build-time function and does not work at runtime).
|
||||
return raw.replace(THEME_SPACING_RE, (_m, n) => {
|
||||
const rem = Number(n) * TAILWIND_SPACING_BASE_REM;
|
||||
// Keep a leading zero (0.5rem, not .5rem) for readability; verified
|
||||
// byte-equivalent computed output vs Tailwind's own `.5rem` form.
|
||||
return `${rem}rem`;
|
||||
});
|
||||
}
|
||||
|
||||
function classifyBracketValue(raw, { kind, sourceNote }) {
|
||||
// 1) Bare var() passthrough — no new token, per DESIGN.md special case.
|
||||
const varOnly = raw.match(VAR_ONLY_RE);
|
||||
if (varOnly) return { tokenRef: varOnly[1], isVarOnly: true };
|
||||
|
||||
// 2) Bare env(safe-area-inset-*) passthrough — mint a --sz-safe-<edge> token.
|
||||
const envOnly = raw.match(ENV_ONLY_RE);
|
||||
if (envOnly) {
|
||||
const edge = envOnly[1].replace("safe-area-inset-", "");
|
||||
const name = registerSafeAreaToken(edge, `env(${envOnly[1]})`, sourceNote);
|
||||
return { tokenRef: `--${name}`, isVarOnly: false };
|
||||
}
|
||||
|
||||
// 3) Simple numeric length / percentage.
|
||||
const simple = raw.match(SIMPLE_LENGTH_RE);
|
||||
if (simple) {
|
||||
const [, num, unit] = simple;
|
||||
if (unit === "%") {
|
||||
const name = registerPctToken(num, sourceNote);
|
||||
return { tokenRef: `--${name}`, isVarOnly: false };
|
||||
}
|
||||
if (kind === "radius") {
|
||||
const name = registerRadToken(num, unit, sourceNote);
|
||||
return { tokenRef: `--${name}`, isVarOnly: false };
|
||||
}
|
||||
const name = registerSzToken(num, unit, sourceNote);
|
||||
return { tokenRef: `--${name}`, isVarOnly: false };
|
||||
}
|
||||
|
||||
// 4) Everything else: calc()/min()/max()/clamp()/env()-mixed compound
|
||||
// expressions. Reverse Tailwind's underscore-space escaping, normalize
|
||||
// calc operator spacing, resolve any theme(spacing.N) build-time calls,
|
||||
// and mint a sequential --sz-calc-N.
|
||||
let normalized = unescapeSpaces(raw);
|
||||
normalized = resolveThemeSpacing(normalized);
|
||||
normalized = normalizeCalcSpacing(normalized);
|
||||
const name = registerCalcToken(normalized, sourceNote);
|
||||
return { tokenRef: `--${name}`, isVarOnly: false };
|
||||
}
|
||||
|
||||
// ── Regexes for each utility family ──────────────────────────────────────
|
||||
// Every regex requires the utility to start at a genuine class-token
|
||||
// boundary — preceded by whitespace, a quote/backtick, template-literal
|
||||
// `${`, or the start of the string — NOT a bare `\b`, which would
|
||||
// false-positive inside compound utility names like
|
||||
// `slide-out-to-top-[1%]` (see BOUNDARY GOTCHA in the header comment).
|
||||
// Each captures: (1) optional `!important` prefix, (2) the utility name
|
||||
// (with any directional/axis suffix), (3) the raw bracket contents.
|
||||
// `:` is included because Tailwind variant prefixes (`sm:`, `dark:`,
|
||||
// `focus-visible:`, `data-[state=open]:`, etc.) always precede the utility
|
||||
// name with a colon — a safe class-token boundary, never part of a longer
|
||||
// utility's own name.
|
||||
const BOUNDARY = String.raw`(?<=^|[\s"'\`{:])`;
|
||||
|
||||
const UTILITIES = [
|
||||
// width/height/size family
|
||||
{ re: new RegExp(`${BOUNDARY}(!?)(w|h|size|min-w|max-w|min-h|max-h)-\\[([^\\]]+)\\]`, "g"), kind: "length" },
|
||||
// padding/margin family
|
||||
{ re: new RegExp(`${BOUNDARY}(!?)(p|pt|pb|pl|pr|px|py|m|mt|mb|ml|mr|mx|my)-\\[([^\\]]+)\\]`, "g"), kind: "length" },
|
||||
// gap family
|
||||
{ re: new RegExp(`${BOUNDARY}(!?)(gap|gap-x|gap-y)-\\[([^\\]]+)\\]`, "g"), kind: "length" },
|
||||
// inset / top / left / right / bottom family
|
||||
{ re: new RegExp(`${BOUNDARY}(!?)(inset-x|inset-y|inset|top|left|right|bottom)-\\[([^\\]]+)\\]`, "g"), kind: "length" },
|
||||
// translate family
|
||||
{ re: new RegExp(`${BOUNDARY}(!?)(translate-x|translate-y)-\\[([^\\]]+)\\]`, "g"), kind: "length" },
|
||||
];
|
||||
|
||||
// Radius: bare `rounded-[...]` and directional `rounded-t/r/b/l/tl/tr/bl/br-[...]`.
|
||||
// `rounded-[inherit]` is a KEYWORD (not a numeric literal) — per the batch
|
||||
// mandate, skip it (documented in the extraction log / allowlist below).
|
||||
const ROUNDED_RE = new RegExp(`${BOUNDARY}(!?)rounded(-(?:tl|tr|bl|br|t|r|b|l))?-\\[([^\\]]+)\\]`, "g");
|
||||
|
||||
// Shadow: bare `shadow-[...]`.
|
||||
const SHADOW_RE = new RegExp(`${BOUNDARY}(!?)shadow-\\[([^\\]]+)\\]`, "g");
|
||||
|
||||
// Ring width: bare `ring-[Npx]` (color-bracket forms like `ring-[#hex]` are
|
||||
// NOT touched here — this batch is size/spacing/radius/shadow only, and no
|
||||
// ring color-bracket sites exist in this codebase; verified during Step 0
|
||||
// inventory). Requires the `length:` hint per the Step-0 spike (see header).
|
||||
const RING_RE = new RegExp(`${BOUNDARY}(!?)ring-\\[([^\\]]+)\\]`, "g");
|
||||
|
||||
// Outline width (numeric only; no outline-[...] sites exist in this
|
||||
// codebase per Step 0 inventory, kept for forward-compatibility/completeness).
|
||||
const OUTLINE_RE = new RegExp(`${BOUNDARY}(!?)outline-\\[([^\\]]+)\\]`, "g");
|
||||
|
||||
function rewriteFile(filePath, relPath) {
|
||||
const original = readFileSync(filePath, "utf8");
|
||||
let content = original;
|
||||
let siteCount = 0;
|
||||
|
||||
for (const { re, kind } of UTILITIES) {
|
||||
content = content.replace(re, (match, bang, util, raw) => {
|
||||
const sourceNote = `Extracted from ${relPath} (${util}-[${raw}]).`;
|
||||
const { tokenRef } = classifyBracketValue(raw, { kind, sourceNote });
|
||||
siteCount++;
|
||||
return `${bang}${util}-(${tokenRef})`;
|
||||
});
|
||||
}
|
||||
|
||||
// Radius (needs its own hint-free paren form + inherit skip).
|
||||
content = content.replace(ROUNDED_RE, (match, bang, dir, raw) => {
|
||||
if (raw === "inherit") return match; // keyword, not a literal — skip (see log)
|
||||
const util = `rounded${dir || ""}`;
|
||||
const sourceNote = `Extracted from ${relPath} (${util}-[${raw}]).`;
|
||||
const { tokenRef } = classifyBracketValue(raw, { kind: "radius", sourceNote });
|
||||
siteCount++;
|
||||
return `${bang}${util}-(${tokenRef})`;
|
||||
});
|
||||
|
||||
// Shadow (always unescape underscores + normalize calc spacing inside any
|
||||
// embedded rgba()/hsl() var() args; bare paren form confirmed correct via
|
||||
// Step 0 spike — no `shadow:` hint needed).
|
||||
content = content.replace(SHADOW_RE, (match, bang, raw) => {
|
||||
const unescaped = unescapeSpaces(raw);
|
||||
const sourceNote = `Extracted from ${relPath} (shadow-[${raw}]).`;
|
||||
const name = registerShadowToken(unescaped, sourceNote);
|
||||
siteCount++;
|
||||
return `${bang}shadow-(--${name})`;
|
||||
});
|
||||
|
||||
// Ring width (length hint required — bare ring-(--x) is ambiguous with
|
||||
// the color-bracket form per the Step-0 spike).
|
||||
content = content.replace(RING_RE, (match, bang, raw) => {
|
||||
const sourceNote = `Extracted from ${relPath} (ring-[${raw}]).`;
|
||||
const { tokenRef, isVarOnly } = classifyBracketValue(raw, { kind: "radius", sourceNote });
|
||||
siteCount++;
|
||||
const hint = isVarOnly ? "" : "length:";
|
||||
return `${bang}ring-(${hint}${tokenRef})`;
|
||||
});
|
||||
|
||||
// Outline width (same length-hint treatment; 0 sites exist today).
|
||||
content = content.replace(OUTLINE_RE, (match, bang, raw) => {
|
||||
const sourceNote = `Extracted from ${relPath} (outline-[${raw}]).`;
|
||||
const { tokenRef, isVarOnly } = classifyBracketValue(raw, { kind: "radius", sourceNote });
|
||||
siteCount++;
|
||||
const hint = isVarOnly ? "" : "length:";
|
||||
return `${bang}outline-(${hint}${tokenRef})`;
|
||||
});
|
||||
|
||||
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 SIZE/SPACING/RADIUS/SHADOW tokens (Phase 2 Batch 3, design/token-extraction) ── */";
|
||||
let cssNext = cssOriginal;
|
||||
let cssChanged = false;
|
||||
|
||||
const anyTokens = szTokens.size || radTokens.size || pctTokens.size || shadowTokens.size;
|
||||
if (!cssOriginal.includes(marker) && anyTokens) {
|
||||
const lines = [];
|
||||
lines.push(marker);
|
||||
lines.push("/* Batch 3/4: width/height/min/max, padding/margin/gap/inset/translate,");
|
||||
lines.push(" radius, ring/outline-width, and shadow literals, verbatim (no");
|
||||
lines.push(" normalizing — the human scale-collapse decision comes later per");
|
||||
lines.push(" DESIGN.md/TOKEN-AUDIT.md). --sz-* is ONE shared family across");
|
||||
lines.push(" w/h/p/m/gap/inset/translate so identical values dedupe regardless of");
|
||||
lines.push(" which property used them. --rad-* is likewise shared across");
|
||||
// NOTE: never write a literal "*/" sequence in this prose (e.g. from a
|
||||
// "rounded-*" + "/ring" join) — it prematurely closes this CSS block
|
||||
// comment and silently corrupts everything after it (caught during this
|
||||
// batch's verification: the entire --sz-*/--rad-*/--pct-*/--shadow-*
|
||||
// :root block was being dropped from the built CSS because of exactly
|
||||
// this). Always phrase such utility-family lists with "and" instead of
|
||||
// a bare slash-adjacent asterisk.
|
||||
lines.push(" rounded, ring, and outline widths.");
|
||||
lines.push("");
|
||||
lines.push(" Allowlist (sites intentionally left as hardcoded / functional literals");
|
||||
lines.push(" or var()-only passthrough with no new token minted):");
|
||||
lines.push(" - components/ui/scroll-area.tsx (rounded-[inherit]) — CSS keyword, not a");
|
||||
lines.push(" literal value; nothing to extract.");
|
||||
lines.push(" - Bracket values that only wrap var(--radix-*-trigger-width/height) or");
|
||||
lines.push(" var(--new-issue-dialog-height) etc. are rewritten to the bare paren");
|
||||
lines.push(" form directly (e.g. w-(--radix-popover-trigger-width)) — these are");
|
||||
lines.push(" runtime library/component variables, not design values, so no new");
|
||||
lines.push(" --sz-* token is minted for them (see TOKEN-AUDIT.md extraction log");
|
||||
lines.push(" for the full site list).");
|
||||
lines.push("*/");
|
||||
lines.push(":root {");
|
||||
for (const [name, { value, comment }] of szTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of radTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of pctTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of shadowTokens) {
|
||||
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-sizes summary`);
|
||||
console.log(` Sites rewritten: ${totalSites}`);
|
||||
console.log(` Files changed: ${filesChanged}`);
|
||||
console.log(` New --sz-* tokens: ${szTokens.size}`);
|
||||
console.log(` New --rad-* tokens: ${radTokens.size}`);
|
||||
console.log(` New --pct-* tokens: ${pctTokens.size}`);
|
||||
console.log(` New --shadow-extract-*: ${shadowTokens.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();
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* codemod-extract-type.mjs
|
||||
*
|
||||
* Phase 2 (extraction), Batch 2/4 of the design-token audit
|
||||
* (branch design/token-extraction). Replaces hardcoded TYPE values —
|
||||
* arbitrary Tailwind font-size (`text-[11px]`), letter-spacing
|
||||
* (`tracking-[0.18em]`), line-height (`leading-[...]`), and raw inline
|
||||
* `fontSize` style literals — 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`.
|
||||
*
|
||||
* Unlike Batch 1's color codemod (which used a hand-audited site table to
|
||||
* avoid false-positiving on non-color hex-like strings such as issue
|
||||
* references), this batch's patterns are unambiguous: `text-[Npx]`,
|
||||
* `text-[N.Nrem]`, `tracking-[N em]`, and `leading-[...]` inside Tailwind
|
||||
* class strings, and `fontSize: "Npx"` / `fontSize: "N.Nrem"` inline-style
|
||||
* string literals, cannot mean anything other than a type-size/spacing
|
||||
* value. A blanket regex sweep is therefore safe and is used here, scoped
|
||||
* to `ui/src/components/**` and `ui/src/pages/**` only. Numeric or
|
||||
* computed `fontSize` forms (e.g. `fontSize: 12`, `fontSize: Math.round(...)`)
|
||||
* are functional (third-party config objects / runtime-computed values)
|
||||
* and are left untouched — see ALLOWLIST_NOTES below.
|
||||
*
|
||||
* Token naming (verbatim value, no normalizing):
|
||||
* --fs-<N> font-size, px values, e.g. --fs-11: 11px;
|
||||
* --fs-0_<N>rem font-size, rem values, e.g. --fs-0_7rem: 0.7rem;
|
||||
* --ls-0_<N> letter-spacing, em values, e.g. --ls-0_18: 0.18em;
|
||||
* --lh-<N> line-height (px or unitless — none found this batch)
|
||||
*
|
||||
* Tailwind v4 paren-shorthand rewrite forms used:
|
||||
* text-[Npx] -> text-(length:--fs-N) (length hint REQUIRED —
|
||||
* bare text-(--x) means color)
|
||||
* tracking-[N em] -> tracking-(--ls-0_N) (unambiguous, no hint)
|
||||
* leading-[...] -> leading-(--lh-N) (unambiguous, no hint)
|
||||
* All variant/modifier prefixes (`sm:`, `dark:`, `group-hover:`,
|
||||
* `[&>x]:`, trailing `!important` marker, etc.) are preserved verbatim —
|
||||
* the regex only rewrites the bracket portion itself.
|
||||
*
|
||||
* Idempotent: the FIND regex only matches the ORIGINAL bracket-literal
|
||||
* form (`text-[11px]` etc.); once rewritten to `text-(length:--fs-11)` the
|
||||
* pattern no longer matches, so re-running is a no-op. The inline-style
|
||||
* FIND is likewise the literal `fontSize: "11px"` string form.
|
||||
*
|
||||
* Usage: node scripts/codemod-extract-type.mjs [--check]
|
||||
* --check Report what WOULD change without writing files (dry run).
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } 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);
|
||||
}
|
||||
}
|
||||
|
||||
function tokenSuffixForPx(value) {
|
||||
// "11" -> "11", "0.65" -> "0_65"
|
||||
return value.replace(".", "_");
|
||||
}
|
||||
|
||||
function tokenSuffixForEm(value) {
|
||||
// "0.18" -> "0_18"
|
||||
return value.replace(".", "_");
|
||||
}
|
||||
|
||||
// ── Token registries (populated as sites are discovered) ───────────────
|
||||
// Map of token name (without --) -> { value, comment, kind }
|
||||
const fsTokens = new Map(); // font-size
|
||||
const lsTokens = new Map(); // letter-spacing
|
||||
const lhTokens = new Map(); // line-height
|
||||
|
||||
function registerFsToken(rawValue, unit, sourceNote) {
|
||||
const name = unit === "px" ? `fs-${tokenSuffixForPx(rawValue)}` : `fs-${tokenSuffixForPx(rawValue)}rem`;
|
||||
if (!fsTokens.has(name)) {
|
||||
fsTokens.set(name, { value: `${rawValue}${unit}`, comment: sourceNote });
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function registerLsToken(rawValue, sourceNote) {
|
||||
const name = `ls-${tokenSuffixForEm(rawValue)}`;
|
||||
if (!lsTokens.has(name)) {
|
||||
lsTokens.set(name, { value: `${rawValue}em`, comment: sourceNote });
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function registerLhToken(rawValue, sourceNote) {
|
||||
// rawValue includes unit already stripped by caller; store as given
|
||||
const safeName = rawValue.replace(/[^a-zA-Z0-9]/g, "_");
|
||||
const name = `lh-${safeName}`;
|
||||
if (!lhTokens.has(name)) {
|
||||
lhTokens.set(name, { value: rawValue, comment: sourceNote });
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
// ── Regexes ──────────────────────────────────────────────────────────
|
||||
// text-[11px], text-[0.65rem], with optional /[Npx] line-height suffix
|
||||
// (none found in this codebase, but handled for completeness/future-proofing).
|
||||
const FS_RE = /text-\[([0-9.]+)(px|rem)\](?:\/\[([0-9.]+)(px|rem)\])?/g;
|
||||
const LS_RE = /tracking-\[([0-9.]+)em\]/g;
|
||||
const LEADING_RE = /leading-\[([^\]]+)\]/g;
|
||||
const FONTSIZE_STYLE_RE = /fontSize:\s*"([0-9.]+)(px|rem)"/g;
|
||||
|
||||
function rewriteFile(filePath, relPath) {
|
||||
const original = readFileSync(filePath, "utf8");
|
||||
let content = original;
|
||||
let siteCount = 0;
|
||||
|
||||
// -- font-size Tailwind class utilities --
|
||||
content = content.replace(FS_RE, (match, num, unit, lhNum, lhUnit) => {
|
||||
const fsName = registerFsToken(num, unit, `Extracted from ${relPath} (text-[${num}${unit}]).`);
|
||||
let replacement = `text-(length:--${fsName})`;
|
||||
if (lhNum) {
|
||||
const lhName = registerLhToken(`${lhNum}${lhUnit}`, `Extracted from ${relPath} (text-[...]/[${lhNum}${lhUnit}] line-height suffix).`);
|
||||
replacement += `/(--${lhName})`;
|
||||
}
|
||||
siteCount++;
|
||||
return replacement;
|
||||
});
|
||||
|
||||
// -- letter-spacing Tailwind class utilities --
|
||||
content = content.replace(LS_RE, (match, num) => {
|
||||
const lsName = registerLsToken(num, `Extracted from ${relPath} (tracking-[${num}em]).`);
|
||||
siteCount++;
|
||||
return `tracking-(--${lsName})`;
|
||||
});
|
||||
|
||||
// -- line-height Tailwind class utilities (standalone leading-[...]) --
|
||||
content = content.replace(LEADING_RE, (match, raw) => {
|
||||
// Only rewrite numeric/unit literals (px, rem, unitless number). Skip
|
||||
// keyword forms like leading-[inherit] or var()-based (already tokenized).
|
||||
if (!/^[0-9.]+(px|rem)?$/.test(raw)) return match;
|
||||
const lhName = registerLhToken(raw, `Extracted from ${relPath} (leading-[${raw}]).`);
|
||||
siteCount++;
|
||||
return `leading-(--${lhName})`;
|
||||
});
|
||||
|
||||
// -- inline style fontSize string literals --
|
||||
content = content.replace(FONTSIZE_STYLE_RE, (match, num, unit) => {
|
||||
const fsName = registerFsToken(num, unit, `Extracted from ${relPath} (inline style fontSize: "${num}${unit}").`);
|
||||
siteCount++;
|
||||
return `fontSize: "var(--${fsName})"`;
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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 TYPE tokens (Phase 2 Batch 2, design/token-extraction) ── */";
|
||||
let cssNext = cssOriginal;
|
||||
let cssChanged = false;
|
||||
|
||||
if (!cssOriginal.includes(marker) && (fsTokens.size || lsTokens.size || lhTokens.size)) {
|
||||
const lines = [];
|
||||
lines.push(marker);
|
||||
lines.push("/* Batch 2/4: font-size + letter-spacing + line-height literals, verbatim");
|
||||
lines.push(" (no normalizing — 9/10/11/12/13/14/15px and 0.08-0.24em all stay distinct;");
|
||||
lines.push(" the human scale-collapse decision comes later per DESIGN.md/TOKEN-AUDIT.md).");
|
||||
lines.push("");
|
||||
lines.push(" Allowlist (sites intentionally left as hardcoded / functional literals,");
|
||||
lines.push(" NOT converted to tokens — each also carries an inline");
|
||||
lines.push(" `token-extraction: allowlisted` comment at the site):");
|
||||
lines.push(" - pages/CompanyEnvironments.tsx (fontSize: 12) — xterm.js terminal theme");
|
||||
lines.push(" config; functional third-party numeric option, not a rendered CSS value.");
|
||||
lines.push(" Same allowlisted object as Batch 1's color entry for this file.");
|
||||
lines.push(" - pages/CompanySkills.tsx (fontSize: Math.round(size * 0.42)) — computed at");
|
||||
lines.push(" runtime from a prop; not a static literal, nothing to extract.");
|
||||
lines.push("*/");
|
||||
lines.push(":root {");
|
||||
for (const [name, { value, comment }] of fsTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of lsTokens) {
|
||||
lines.push(` --${name}: ${value}; /* ${comment} */`);
|
||||
}
|
||||
for (const [name, { value, comment }] of lhTokens) {
|
||||
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-type summary`);
|
||||
console.log(` Sites rewritten: ${totalSites}`);
|
||||
console.log(` Files changed: ${filesChanged}`);
|
||||
console.log(` New --fs-* tokens: ${fsTokens.size}`);
|
||||
console.log(` New --ls-* tokens: ${lsTokens.size}`);
|
||||
console.log(` New --lh-* tokens: ${lhTokens.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();
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* codemod-type-ladder.mjs
|
||||
*
|
||||
* DECISION-SHEET.md B3 (user-locked, preset-tune session): collapse the
|
||||
* Batch 2 verbatim type tokens into the named ladder.
|
||||
*
|
||||
* FONT SIZES (8 --fs-* tokens -> 3 named tokens + 2 Tailwind scale classes):
|
||||
* --fs-9, --fs-10 -> --text-nano: 10px (9 -> 10 bump)
|
||||
* --fs-11, --fs-0_7rem -> --text-micro: 11px (0.7rem = 11.2px -> 11px)
|
||||
* --fs-12 -> Tailwind `text-xs` (12px exact match;
|
||||
* scale class preferred over token per DESIGN.md)
|
||||
* --fs-13 -> --text-compact: 13px
|
||||
* --fs-14, --fs-15 -> Tailwind `text-sm` (14px; 15 -> 14)
|
||||
*
|
||||
* LETTER-SPACING (9 --ls-* tokens -> 3 named steps, nearest-step mapping):
|
||||
* 0.08em, 0.1em -> --tracking-label: 0.08em
|
||||
* 0.12em, 0.14em, 0.16em -> --tracking-eyebrow: 0.14em
|
||||
* 0.18em, 0.2em, 0.22em, 0.24em -> --tracking-caps: 0.2em
|
||||
*
|
||||
* The codemod rewrites every site under ui/src (components, pages, lib,
|
||||
* context, plugins — all .ts/.tsx/.js/.jsx), replaces the --fs-* / --ls-*
|
||||
* definitions in ui/src/index.css with the named-ladder block, and fails
|
||||
* loudly if any --fs-* / --ls-* reference survives (e.g. a var(--fs-12) site,
|
||||
* which has no token replacement because that bucket maps to a Tailwind
|
||||
* scale class and would need a manual decision).
|
||||
*
|
||||
* Replacements are exact strings INCLUDING the closing paren, so prefix
|
||||
* collisions (--ls-0_1 vs --ls-0_12, --fs-1* families) cannot mis-match.
|
||||
*
|
||||
* IDEMPOTENT: a second run finds no old references and the ladder marker
|
||||
* already present in index.css, and changes nothing.
|
||||
*
|
||||
* Usage: node scripts/codemod-type-ladder.mjs
|
||||
*/
|
||||
|
||||
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 CSS_PATH = resolve(UI_SRC, "index.css");
|
||||
|
||||
// ── Site replacement map (exact strings, closing paren included) ─────────
|
||||
const SITE_MAP = new Map([
|
||||
// font sizes — Tailwind utility form (Batch 2 syntax)
|
||||
["text-(length:--fs-9)", "text-(length:--text-nano)"],
|
||||
["text-(length:--fs-10)", "text-(length:--text-nano)"],
|
||||
["text-(length:--fs-11)", "text-(length:--text-micro)"],
|
||||
["text-(length:--fs-0_7rem)", "text-(length:--text-micro)"],
|
||||
["text-(length:--fs-12)", "text-xs"],
|
||||
["text-(length:--fs-13)", "text-(length:--text-compact)"],
|
||||
["text-(length:--fs-14)", "text-sm"],
|
||||
["text-(length:--fs-15)", "text-sm"],
|
||||
// font sizes — inline-style var() form
|
||||
["var(--fs-9)", "var(--text-nano)"],
|
||||
["var(--fs-10)", "var(--text-nano)"],
|
||||
["var(--fs-11)", "var(--text-micro)"],
|
||||
["var(--fs-0_7rem)", "var(--text-micro)"],
|
||||
["var(--fs-13)", "var(--text-compact)"],
|
||||
// (var(--fs-12/14/15) intentionally absent: those buckets map to Tailwind
|
||||
// scale classes; any such site trips the leftover guard for manual review)
|
||||
// letter-spacing — Tailwind utility form
|
||||
["tracking-(--ls-0_08)", "tracking-(--tracking-label)"],
|
||||
["tracking-(--ls-0_1)", "tracking-(--tracking-label)"],
|
||||
["tracking-(--ls-0_12)", "tracking-(--tracking-eyebrow)"],
|
||||
["tracking-(--ls-0_14)", "tracking-(--tracking-eyebrow)"],
|
||||
["tracking-(--ls-0_16)", "tracking-(--tracking-eyebrow)"],
|
||||
["tracking-(--ls-0_18)", "tracking-(--tracking-caps)"],
|
||||
["tracking-(--ls-0_2)", "tracking-(--tracking-caps)"],
|
||||
["tracking-(--ls-0_22)", "tracking-(--tracking-caps)"],
|
||||
["tracking-(--ls-0_24)", "tracking-(--tracking-caps)"],
|
||||
// letter-spacing — inline-style var() form
|
||||
["var(--ls-0_08)", "var(--tracking-label)"],
|
||||
["var(--ls-0_1)", "var(--tracking-label)"],
|
||||
["var(--ls-0_12)", "var(--tracking-eyebrow)"],
|
||||
["var(--ls-0_14)", "var(--tracking-eyebrow)"],
|
||||
["var(--ls-0_16)", "var(--tracking-eyebrow)"],
|
||||
["var(--ls-0_18)", "var(--tracking-caps)"],
|
||||
["var(--ls-0_2)", "var(--tracking-caps)"],
|
||||
["var(--ls-0_22)", "var(--tracking-caps)"],
|
||||
["var(--ls-0_24)", "var(--tracking-caps)"],
|
||||
]);
|
||||
|
||||
const LADDER_MARKER = "Named type ladder (DECISION-SHEET.md B3";
|
||||
const LADDER_BLOCK = ` /* ── Named type ladder (DECISION-SHEET.md B3, preset-tune session) ──
|
||||
Collapses the 8 verbatim --fs-* font-size tokens and 9 --ls-*
|
||||
letter-spacing tokens (extracted in Batch 2 above) into named steps.
|
||||
Codemod: scripts/codemod-type-ladder.mjs. Mapping:
|
||||
9px + 10px -> --text-nano (9 -> 10 bump per locked ladder)
|
||||
11px + 0.7rem -> --text-micro (0.7rem = 11.2px -> 11px)
|
||||
12px -> Tailwind \`text-xs\` class (12px exact match; scale
|
||||
class preferred over a redundant token per DESIGN.md)
|
||||
13px -> --text-compact (PRIOR-ART named this tier "sm 13",
|
||||
but that collides with Tailwind text-sm = 14px,
|
||||
hence "compact")
|
||||
14px + 15px -> Tailwind \`text-sm\` class (14px; 15 -> 14)
|
||||
Letter-spacing, nearest-step mapping:
|
||||
0.08em, 0.1em -> --tracking-label
|
||||
0.12em, 0.14em, 0.16em -> --tracking-eyebrow
|
||||
0.18em, 0.2em, 0.22em, 0.24em -> --tracking-caps
|
||||
NOTE: sites moved to text-xs/text-sm also pick up the Tailwind scale
|
||||
line-height (text-(length:--x) set font-size only) — intentional,
|
||||
reviewed on the post-preset contact sheet. */
|
||||
--text-nano: 10px;
|
||||
--text-micro: 11px;
|
||||
--text-compact: 13px;
|
||||
--tracking-label: 0.08em;
|
||||
--tracking-eyebrow: 0.14em;
|
||||
--tracking-caps: 0.2em;`;
|
||||
|
||||
// ── Walk ui/src ───────────────────────────────────────────────────────────
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const files = [];
|
||||
walk(UI_SRC, files);
|
||||
files.sort();
|
||||
|
||||
// ── Pass 1: rewrite sites ─────────────────────────────────────────────────
|
||||
const bucketCounts = new Map();
|
||||
let filesTouched = 0;
|
||||
for (const f of files) {
|
||||
const before = readFileSync(f, "utf8");
|
||||
let after = before;
|
||||
for (const [oldStr, newStr] of SITE_MAP) {
|
||||
if (!after.includes(oldStr)) continue;
|
||||
const n = after.split(oldStr).length - 1;
|
||||
after = after.split(oldStr).join(newStr);
|
||||
bucketCounts.set(oldStr, (bucketCounts.get(oldStr) ?? 0) + n);
|
||||
}
|
||||
if (after !== before) {
|
||||
writeFileSync(f, after);
|
||||
filesTouched++;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 2: index.css — swap definitions for the ladder block ────────────
|
||||
let css = readFileSync(CSS_PATH, "utf8");
|
||||
if (!css.includes(LADDER_MARKER)) {
|
||||
const lines = css.split("\n");
|
||||
const DEF_RE = /^\s*--(?:fs-[0-9a-z_]+|ls-[0-9_]+):/;
|
||||
const firstDef = lines.findIndex((l) => DEF_RE.test(l));
|
||||
if (firstDef === -1) {
|
||||
console.error("ERROR: no --fs-* / --ls-* definitions found and ladder marker absent — index.css in unexpected state.");
|
||||
process.exit(1);
|
||||
}
|
||||
const kept = lines.filter((l) => !DEF_RE.test(l));
|
||||
// Insert the ladder block at the position of the first removed definition:
|
||||
// count how many kept lines precede the first definition line.
|
||||
let precede = 0;
|
||||
for (let i = 0; i < firstDef; i++) if (!DEF_RE.test(lines[i])) precede++;
|
||||
kept.splice(precede, 0, LADDER_BLOCK);
|
||||
css = kept.join("\n");
|
||||
writeFileSync(CSS_PATH, css);
|
||||
console.log("index.css: --fs-* / --ls-* definitions replaced with named ladder block");
|
||||
} else {
|
||||
console.log("index.css: ladder marker already present — skipped (idempotent)");
|
||||
}
|
||||
|
||||
// ── Guard: no survivors anywhere in ui/src (incl. index.css) ─────────────
|
||||
const SURVIVOR_RE = /--(?:fs-[0-9a-z_]+|ls-[0-9_]+)/;
|
||||
const survivors = [];
|
||||
for (const f of [...files, CSS_PATH]) {
|
||||
const content = readFileSync(f, "utf8");
|
||||
const lines = content.split("\n");
|
||||
lines.forEach((l, i) => {
|
||||
if (SURVIVOR_RE.test(l)) survivors.push(`${relative(REPO_ROOT, f)}:${i + 1}: ${l.trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────
|
||||
const bucketTotals = {};
|
||||
for (const [oldStr, n] of bucketCounts) {
|
||||
const target = SITE_MAP.get(oldStr);
|
||||
bucketTotals[target] = (bucketTotals[target] ?? 0) + n;
|
||||
}
|
||||
console.log("Sites rewritten per source form:");
|
||||
for (const [oldStr, n] of [...bucketCounts.entries()].sort()) {
|
||||
console.log(` ${oldStr} -> ${SITE_MAP.get(oldStr)} (${n})`);
|
||||
}
|
||||
console.log("Totals per target bucket:", JSON.stringify(bucketTotals, null, 2));
|
||||
console.log(`Files touched: ${filesTouched}`);
|
||||
|
||||
if (survivors.length > 0) {
|
||||
console.error(`\nERROR: ${survivors.length} leftover --fs-* / --ls-* reference(s) need manual review:`);
|
||||
for (const s of survivors) console.error(" " + s);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("No leftover --fs-* / --ls-* references. Done.");
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
// Tiny dependency-free static file server for the built Storybook
|
||||
// (ui/storybook-static). Used by the visual snapshot suite's webServer so we
|
||||
// don't add an http-server dependency.
|
||||
import { createServer } from "node:http";
|
||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { extname, join, normalize, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(
|
||||
fileURLToPath(new URL(".", import.meta.url)),
|
||||
"..",
|
||||
"ui",
|
||||
"storybook-static",
|
||||
);
|
||||
const port = Number(process.env.PORT ?? 6106);
|
||||
|
||||
if (!existsSync(join(root, "index.html"))) {
|
||||
console.error(`No built Storybook at ${root}. Run \`pnpm build-storybook\` first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".webm": "video/webm",
|
||||
".mp4": "video/mp4",
|
||||
};
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
||||
let filePath = normalize(join(root, decodeURIComponent(url.pathname)));
|
||||
if (!filePath.startsWith(root)) {
|
||||
res.writeHead(403).end("forbidden");
|
||||
return;
|
||||
}
|
||||
if (existsSync(filePath) && statSync(filePath).isDirectory()) {
|
||||
filePath = join(filePath, "index.html");
|
||||
}
|
||||
if (!existsSync(filePath)) {
|
||||
res.writeHead(404).end("not found");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"content-type": MIME[extname(filePath)] ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`storybook-static served at http://localhost:${port}`);
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
createReadStream,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const defaultManifestPath = join(repoRoot, "tests", "storybook-visual", "baseline-manifest.json");
|
||||
const manifestPath = resolvePath(
|
||||
process.env.STORYBOOK_VISUAL_BASELINE_MANIFEST ?? defaultManifestPath,
|
||||
);
|
||||
const defaultCacheDir = join(repoRoot, "tests", "storybook-visual", ".cache");
|
||||
const cacheDir = resolvePath(process.env.STORYBOOK_VISUAL_BASELINE_CACHE_DIR ?? defaultCacheDir);
|
||||
const defaultSnapshotDir = join(repoRoot, "tests", "storybook-visual", ".snapshots");
|
||||
const snapshotDir = resolvePath(process.env.STORYBOOK_VISUAL_SNAPSHOT_DIR ?? defaultSnapshotDir);
|
||||
|
||||
const command = process.argv[2];
|
||||
const flags = parseFlags(process.argv.slice(3));
|
||||
|
||||
try {
|
||||
if (command === "download") {
|
||||
await download();
|
||||
} else if (command === "verify") {
|
||||
await verify();
|
||||
} else if (command === "pack") {
|
||||
await pack();
|
||||
} else if (command === "upload") {
|
||||
await upload();
|
||||
} else {
|
||||
usage();
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function resolvePath(path) {
|
||||
return isAbsolute(path) ? path : resolve(repoRoot, path);
|
||||
}
|
||||
|
||||
function parseFlags(args) {
|
||||
const result = new Map();
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (!arg.startsWith("--")) {
|
||||
throw new Error(`Unexpected argument: ${arg}`);
|
||||
}
|
||||
const equalsIndex = arg.indexOf("=");
|
||||
if (equalsIndex !== -1) {
|
||||
result.set(arg.slice(2, equalsIndex), arg.slice(equalsIndex + 1));
|
||||
continue;
|
||||
}
|
||||
const key = arg.slice(2);
|
||||
const next = args[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
result.set(key, "true");
|
||||
} else {
|
||||
result.set(key, next);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage: node scripts/storybook-visual-baseline.mjs <command>
|
||||
|
||||
Commands:
|
||||
download Fetch, checksum, and unpack the manifest archive into the snapshot dir.
|
||||
verify Check the unpacked snapshot count and cached archive checksum.
|
||||
pack Create a deterministic snapshots.tgz from the snapshot dir.
|
||||
upload Upload a packed archive to S3 with immutable overwrite checks.
|
||||
|
||||
Environment:
|
||||
STORYBOOK_VISUAL_BASELINE_MANIFEST Manifest path.
|
||||
STORYBOOK_VISUAL_BASELINE_CACHE_DIR Cache path.
|
||||
STORYBOOK_VISUAL_SNAPSHOT_DIR Playwright snapshot dir.
|
||||
STORYBOOK_VISUAL_S3_URI s3://bucket/key target for upload.
|
||||
STORYBOOK_VISUAL_PUBLIC_URL Public HTTPS URL to write into manifest instructions.
|
||||
`);
|
||||
}
|
||||
|
||||
function readManifest() {
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(`Missing baseline manifest: ${manifestPath}`);
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
if (manifest.version !== 1) {
|
||||
throw new Error(`Unsupported baseline manifest version: ${manifest.version}`);
|
||||
}
|
||||
if (!Number.isInteger(manifest.snapshotCount) || manifest.snapshotCount < 0) {
|
||||
throw new Error("Manifest snapshotCount must be a non-negative integer.");
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function archivePathFor(manifest) {
|
||||
const hash = manifest.archive?.sha256;
|
||||
return join(cacheDir, "archives", `${hash || "unconfigured"}-snapshots.tgz`);
|
||||
}
|
||||
|
||||
async function download() {
|
||||
const manifest = readManifest();
|
||||
assertConfiguredArchive(manifest);
|
||||
mkdirSync(dirname(archivePathFor(manifest)), { recursive: true });
|
||||
const archivePath = archivePathFor(manifest);
|
||||
|
||||
if (!existsSync(archivePath) || sha256File(archivePath) !== manifest.archive.sha256) {
|
||||
await fetchArchive(manifest.archive.url, archivePath);
|
||||
}
|
||||
verifyArchiveFile(manifest, archivePath);
|
||||
rmSync(snapshotDir, { recursive: true, force: true });
|
||||
mkdirSync(snapshotDir, { recursive: true });
|
||||
run("tar", ["-xzf", archivePath, "-C", snapshotDir], "unpack baseline archive");
|
||||
verifySnapshotCount(manifest, snapshotDir);
|
||||
console.log(`Downloaded ${manifest.baselineId} to ${relative(repoRoot, snapshotDir)}`);
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
const manifest = readManifest();
|
||||
assertConfiguredArchive(manifest);
|
||||
const archivePath = archivePathFor(manifest);
|
||||
if (!existsSync(archivePath)) {
|
||||
throw new Error(
|
||||
`Missing cached archive ${archivePath}. Run \`pnpm storybook-visual:baseline download\` first.`,
|
||||
);
|
||||
}
|
||||
verifyArchiveFile(manifest, archivePath);
|
||||
verifySnapshotCount(manifest, snapshotDir);
|
||||
console.log(
|
||||
`Verified ${manifest.snapshotCount} snapshots for ${manifest.baselineId} in ${relative(
|
||||
repoRoot,
|
||||
snapshotDir,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function pack() {
|
||||
const sourceDir = resolvePath(flags.get("source") ?? snapshotDir);
|
||||
if (!existsSync(sourceDir)) {
|
||||
throw new Error(`Snapshot source does not exist: ${sourceDir}`);
|
||||
}
|
||||
const count = countPngFiles(sourceDir);
|
||||
if (count === 0) {
|
||||
throw new Error(`No PNG snapshots found in ${sourceDir}`);
|
||||
}
|
||||
const out = resolvePath(
|
||||
flags.get("out") ?? join(repoRoot, "tests", "storybook-visual", "baseline-review", "snapshots.tgz"),
|
||||
);
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "storybook-visual-pack-"));
|
||||
const tempArchive = join(tempDir, "snapshots.tgz");
|
||||
try {
|
||||
run(
|
||||
"tar",
|
||||
[
|
||||
"--sort=name",
|
||||
"--mtime=@0",
|
||||
"--owner=0",
|
||||
"--group=0",
|
||||
"--numeric-owner",
|
||||
"--use-compress-program=gzip -n",
|
||||
"-cf",
|
||||
tempArchive,
|
||||
"-C",
|
||||
sourceDir,
|
||||
".",
|
||||
],
|
||||
"pack deterministic baseline archive",
|
||||
);
|
||||
rmSync(out, { force: true });
|
||||
run("cp", [tempArchive, out], "write packed archive");
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
const sha256 = sha256File(out);
|
||||
const byteSize = statSync(out).size;
|
||||
const publicUrl = flags.get("public-url") ?? process.env.STORYBOOK_VISUAL_PUBLIC_URL ?? "";
|
||||
const objectKey = `baselines/storybook-visual/${sha256}/snapshots.tgz`;
|
||||
console.log(`Packed ${count} PNG snapshots into ${relative(repoRoot, out)}`);
|
||||
console.log("");
|
||||
console.log("Manifest archive update:");
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
snapshotCount: count,
|
||||
archive: {
|
||||
url: publicUrl || `https://<cloudfront-host>/${objectKey}`,
|
||||
sha256,
|
||||
byteSize,
|
||||
objectKey,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
const archive = resolvePath(flags.get("archive") ?? join(repoRoot, "tests", "storybook-visual", "baseline-review", "snapshots.tgz"));
|
||||
const s3Uri = flags.get("s3-uri") ?? process.env.STORYBOOK_VISUAL_S3_URI;
|
||||
if (!s3Uri) {
|
||||
throw new Error("Missing --s3-uri or STORYBOOK_VISUAL_S3_URI for upload.");
|
||||
}
|
||||
if (!s3Uri.startsWith("s3://")) {
|
||||
throw new Error(`Upload target must be an s3:// URI: ${s3Uri}`);
|
||||
}
|
||||
if (!existsSync(archive)) {
|
||||
throw new Error(`Archive does not exist: ${archive}`);
|
||||
}
|
||||
const sha256 = sha256File(archive);
|
||||
const { bucket, key } = parseS3Uri(s3Uri);
|
||||
const head = spawnSync(
|
||||
"aws",
|
||||
["s3api", "head-object", "--bucket", bucket, "--key", key, "--output", "json"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
if (head.status === 0) {
|
||||
const metadata = JSON.parse(head.stdout || "{}").Metadata ?? {};
|
||||
if (metadata.sha256 === sha256) {
|
||||
console.log(`Archive already exists at ${s3Uri} with matching sha256 ${sha256}.`);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Refusing to overwrite existing S3 object with different sha256: ${s3Uri}`);
|
||||
}
|
||||
run(
|
||||
"aws",
|
||||
[
|
||||
"s3",
|
||||
"cp",
|
||||
archive,
|
||||
s3Uri,
|
||||
"--metadata",
|
||||
`sha256=${sha256}`,
|
||||
"--cache-control",
|
||||
"public, max-age=31536000, immutable",
|
||||
"--content-type",
|
||||
"application/gzip",
|
||||
],
|
||||
"upload baseline archive",
|
||||
);
|
||||
console.log(`Uploaded ${basename(archive)} to ${s3Uri}`);
|
||||
}
|
||||
|
||||
function assertConfiguredArchive(manifest) {
|
||||
const archive = manifest.archive ?? {};
|
||||
if (!archive.url || !archive.sha256 || !archive.byteSize) {
|
||||
throw new Error(
|
||||
`Baseline manifest ${relative(
|
||||
repoRoot,
|
||||
manifestPath,
|
||||
)} does not point at a published archive yet. Run \`pnpm storybook-visual:baseline pack\`, upload the immutable archive, then update the manifest archive url/sha256/byteSize/snapshotCount.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchArchive(url, destination) {
|
||||
if (url.startsWith("file://")) {
|
||||
await pipeline(createReadStream(fileURLToPath(url)), createWriteStream(destination));
|
||||
return;
|
||||
}
|
||||
if (!url.startsWith("https://") && !url.startsWith("http://")) {
|
||||
throw new Error(`Unsupported archive URL: ${url}`);
|
||||
}
|
||||
const response = await fetch(url);
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to download baseline archive: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
await pipeline(response.body, createWriteStream(destination));
|
||||
}
|
||||
|
||||
function verifyArchiveFile(manifest, archivePath) {
|
||||
const actualSha = sha256File(archivePath);
|
||||
if (actualSha !== manifest.archive.sha256) {
|
||||
throw new Error(
|
||||
`Baseline checksum mismatch: expected ${manifest.archive.sha256}, got ${actualSha}`,
|
||||
);
|
||||
}
|
||||
const actualSize = statSync(archivePath).size;
|
||||
if (actualSize !== manifest.archive.byteSize) {
|
||||
throw new Error(
|
||||
`Baseline byte size mismatch: expected ${manifest.archive.byteSize}, got ${actualSize}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function verifySnapshotCount(manifest, dir) {
|
||||
const count = countPngFiles(dir);
|
||||
if (count !== manifest.snapshotCount) {
|
||||
throw new Error(
|
||||
`Baseline snapshot count mismatch: expected ${manifest.snapshotCount}, got ${count} in ${dir}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256File(path) {
|
||||
const hash = createHash("sha256");
|
||||
hash.update(readFileSync(path));
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function countPngFiles(dir) {
|
||||
if (!existsSync(dir)) return 0;
|
||||
let count = 0;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
count += countPngFiles(path);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".png")) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function parseS3Uri(uri) {
|
||||
const withoutScheme = uri.slice("s3://".length);
|
||||
const slash = withoutScheme.indexOf("/");
|
||||
if (slash === -1) throw new Error(`S3 URI must include a key: ${uri}`);
|
||||
return { bucket: withoutScheme.slice(0, slash), key: withoutScheme.slice(slash + 1) };
|
||||
}
|
||||
|
||||
function run(cmd, args, label) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, COPYFILE_DISABLE: "1" },
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to ${label}.`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user