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:
authored and GitHub committed 2026-07-07 16:22:16 -05:00
1 parent bb87047fe6
commit c07e650cd7
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);
}
+315
View File
@@ -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();
+470
View File
@@ -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();
+599
View File
@@ -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();
+517
View File
@@ -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();
+239
View File
@@ -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();
+197
View File
@@ -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.");
+67
View File
@@ -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}`);
});
+347
View File
@@ -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}.`);
}
}