diff --git a/AGENTS.md b/AGENTS.md index de96a4170e..cc341f6861 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,4 +223,4 @@ PR #2218 (`feat/external-adapter-phase1`) adds external adapter support. See roo ## Design system -`DESIGN.md` at the repo root is the source of truth for UI design decisions. The token-only rule applies to all `ui/` changes: every color, spacing, radius, type, shadow, and motion value comes from the token layer in `ui/src/index.css` — no hex, raw px, or arbitrary Tailwind bracket values in components. +`DESIGN.md` at the repo root is the source of truth for UI design decisions. The token-only rule applies to all `ui/` changes: every color, spacing, radius, type, shadow, and motion value in `ui/src/components/**` and `ui/src/pages/**` comes from the token layer in `ui/src/index.css` — no hex, raw px, arbitrary Tailwind bracket values, or raw `font-size`/`fontSize` declarations in components, outside the documented allowlist in `ui/src/index.css`. Run `pnpm check:token-gates` (`scripts/check-token-gates.mjs`) before committing UI changes — it fails on any violation not covered by that allowlist. diff --git a/TOKEN-AUDIT.md b/TOKEN-AUDIT.md index 36a977f17f..c3b2cf5360 100644 --- a/TOKEN-AUDIT.md +++ b/TOKEN-AUDIT.md @@ -362,3 +362,67 @@ Codemod: `scripts/codemod-extract-sizes.mjs`. Blanket regex sweep (like Batch 2 - **`drop-shadow-[...]` vs `shadow-[...]` token family** (`components/CompanyPatternIcon.tsx`, 1 site) — should `drop-shadow` values share the `--shadow-extract-*` family this batch minted, or get their own `--drop-shadow-extract-*` family? Left untouched this batch since TOKEN-AUDIT.md section 3.6 only inventoried `box-shadow` (`shadow-[...]`) sites, and the mission mandate for this batch was explicitly `shadow-[...]` only. - **`theme(spacing.N)` resolution direction** (`components/IssueRow.tsx`) — this batch resolved `calc(theme(spacing.N)±Mpx)` to its Tailwind-v4-default-scale rem equivalent (verbatim, byte-equivalent verified) rather than leaving it as a build-time-only expression. If a future change to Tailwind's `--spacing` base ever happens, these 3 tokens will silently stop tracking that base (since they're now baked rem literals, not `theme()` calls) — a human should confirm this is the intended tradeoff, or decide these 3 sites should instead reference the Tailwind spacing scale by a different mechanism. - **`--pct-neg-50` vs prefix-negation form** — this batch mints distinct positive/negative percentage tokens (`--pct-50` / `--pct-neg-50`) rather than using Tailwind's `-utility-(--x)` prefix-negation shorthand, because every negative-percentage site in this codebase already carries the minus sign inside the bracket value itself (`translate-x-[-50%]`), not as a separate utility-level negation (`-translate-x-[50%]`). A human doing the eventual scale-collapse pass should be aware both forms exist in Tailwind v4 and this codebase consistently uses the former. + +--- + +## Phase 2 extraction log — Batch 4 (final sweep + gates) + +Codemod: `scripts/codemod-extract-misc.mjs`. Blanket regex sweep (same style as Batches 2-3 — these remaining bracket-utility families are unambiguous), scoped to `ui/src/components/**` and `ui/src/pages/**` including `*.test.tsx` companions. Covers `grid-cols-[...]`/`grid-rows-[...]`, `transition-[...]`, `z-[...]`, `scale-[...]`, `ease-[...]`, `align-[...]`, `stroke-[...]`, `blur-[...]`/`backdrop-blur-[...]`, `drop-shadow-[...]`, `bg|text|border-[var(--x)]` (with and without a `,fallback`), and — discovered during this batch's own bracket sweep, not in the original task list — 2 `bg-[linear-gradient(...)]` sites that Batch 1's hand-audited color-literal table did not catch (see "scope correction" below). Verified idempotent (second run: 0 sites, 0 files changed). + +**MANDATORY STEP 0 SYNTAX SPIKE (scratch stories + `pnpm build-storybook` + grep of emitted CSS, deleted before the real codemod ran):** every paren form in the batch mandate was confirmed byte-equivalent before any component was touched: +- `grid-cols-(--gtc-N)` with a multi-part track list containing `minmax()` → `grid-template-columns:var(--gtc-N)`. Confirmed. +- `transition-(--tp-N)` → `transition-property:var(--tp-N)` (plus the timing-function/duration vars Tailwind always emits alongside). Confirmed it sets `transition-property`, not some other longhand. +- `z-(--z-N)` → `z-index:var(--z-N)`. Confirmed. +- `scale-(--s-N)` → `scale:var(--s-N)`. Confirmed. +- `ease-(--e-N)` → `transition-timing-function:var(--e-N)` (and `--tw-ease`). Confirmed. +- `align-(--va-N)` → `vertical-align:var(--va-N)`. Confirmed. +- `stroke-(length:--sw-N)` → `stroke-width:var(--sw-N)`. Confirmed the `length:` hint is REQUIRED — a bare `stroke-(--x)` would be ambiguous with the `stroke` COLOR utility, same reasoning as Batch 3's `ring`/`outline` hint requirement. +- `backdrop-blur-(--blur-N)` → `--tw-backdrop-blur:blur(var(--blur-N))`. Confirmed (spiked separately after discovering the batch's 2 real sites are `backdrop-blur-[...]`, not bare `blur-[...]` — see gotcha below). +- Fallback-comma paren form (`bg-(--x,fallback)`): **NOT SUPPORTED.** Tailwind v4's paren shorthand only accepts a single custom-property reference (optionally with a `type:` hint); a literal comma-separated fallback inside the parens does not parse as a fallback expression. Per the mission's fallback plan, a wrapper token (`--code-highlight-bg-resolved: var(--paperclip-code-highlight-bg, rgba(250, 204, 21, 0.12));`) was minted instead and referenced via the plain `bg-(--code-highlight-bg-resolved)` form — confirmed this compiles to `background-color:var(--code-highlight-bg-resolved)`. +- Plugin 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`): **DEAD CLASSES, zero rendered CSS.** Grepping the built `storybook-static` CSS for any of these class names (in escaped or unescaped form) returns nothing — not even the base `animate-in` machinery they depend on. Confirmed via `package.json`/`node_modules` inspection that the `tw-animate-css` plugin is not installed anywhere in this repo, and `ui/src/index.css` defines no matching `@utility` overrides. These utilities have had zero visual effect since before this run started; nothing to tokenize without changing (from nothing to something) a currently inert class, which is out of this batch's zero-visual-change mandate. Left untouched, allowlisted with the reasoning above. +- `theme(colors.muted.DEFAULT)` / `theme(colors.muted.foreground)` fallback resolution: inspected the ALREADY-BUILT `ui/storybook-static/assets/*.css` before writing the codemod (per the mission's instruction to check build output first, since `theme()` is a build-time function) — confirmed byte-for-byte that Tailwind compiles `bg-[var(--paperclip-code-bg,theme(colors.muted.DEFAULT))]` to `background-color:var(--paperclip-code-bg,var(--muted))` and the gutter-fg sibling to `color:var(--paperclip-code-gutter-fg,var(--muted-foreground))`. Minted `--code-bg-resolved: var(--paperclip-code-bg, var(--muted));` and `--code-gutter-fg-resolved: var(--paperclip-code-gutter-fg, var(--muted-foreground));` reproducing that exact resolved form. + +**Sites rewritten: 198**, across 84 files (79 component/page files + 3 test files rewritten in lockstep — `IssueDocumentAnnotations.test.tsx` (`z-[60]` → `z-(--z-60)`, 2 assertion strings), `MarkdownBody.test.tsx` (`align-[-0.125em]` → `align-(--va-0_125em)`, 3 assertion strings), `SidebarShell.test.tsx` (`transition-[width]` → `transition-(--tp-width)`, 1 negative assertion) — plus `AGENTS.md`/`package.json`/`ui/src/index.css` for the gate script wiring and doc updates). + +**Tokens minted: 96 new** (58 `--gtc-*` + 3 `--gtr-*` + 19 `--tp-*` + 6 `--z-*` + 1 `--s-*` + 1 `--e-*` + 1 `--va-*` + 1 `--sw-*` + 2 `--blur-*` + 1 `--drop-shadow-extract-*` + 4 `*-resolved` wrapper tokens + 2 `--gradient-extract-*` continuing Batch 1's counter as `-25`/`-26`; 0 reused from Batches 1-3 — no existing token in any prior family matched these new value shapes). +- **`--gtc-*` (58) / `--gtr-*` (3)** — grid-template-columns/rows track lists, verbatim (underscore-to-space reversal applied per Batch 1's gotcha), sequentially numbered (content like `minmax(0,1fr)_auto` isn't safely nameable). Deduped on exact string match: e.g. `--gtr-2`/`--gtr-3` (`1fr`/`0fr`) each reused across both `CompanySkills.tsx` `expanded`/collapsed sites. One pre-existing source oddity preserved verbatim, not "fixed": `pages/Costs.tsx`'s `grid-cols-[1.3fr,1fr]`/`[1.25fr,0.95fr]`/`[1.2fr,0.95fr]` use a literal COMMA between track values (not the standard space-separated `grid-template-columns` syntax) — confirmed via the built CSS that this is exactly what ships today (`grid-template-columns:1.3fr,1fr`), so the token values (`--gtc-31`/`-32`/`-33`) preserve the comma byte-for-byte rather than "correcting" it to a space, per DESIGN.md's no-normalizing rule. +- **`--tp-*` (19)** — `transition-property` lists, slugged from the comma-joined property names (e.g. `--tp-width-background-color: width,background-color;`), one token per distinct property-list string. +- **`--z-*` (6)** — `--z-1`, `--z-2`, `--z-60`, `--z-120`, `--z-200`, `--z-9999`, bare numeric values matching TOKEN-AUDIT.md section 3.7's ad hoc z-index inventory exactly. Still no z-index TIER/scale decision made (per that section's original flag) — these are 6 independent verbatim values, not a scale. +- **`--s-0_98`, `--e-cubic-bezier-0_16-1-0_3-1`, `--va-0_125em`, `--sw-2_3`** — one token each (scale/ease/vertical-align/stroke-width all had exactly one distinct value in scope). +- **`--blur-1px` / `--blur-2px`** — 2 sites, 2 distinct values (`ChatComposer.tsx`'s `backdrop-blur-[1px]`, `IssueChatThread.tsx`'s `backdrop-blur-[2px]`); confirmed both are `backdrop-blur`, not bare `blur` (see gotcha below). +- **`--drop-shadow-extract-1`** — `CompanyPatternIcon.tsx`'s `drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]`, its own family per Batch 3's logged "needs human decision" (kept separate from `--shadow-extract-*`, which is `box-shadow`; `drop-shadow` is a `filter` function, a different CSS property). +- **4 `*-resolved` wrapper tokens** (`--code-bg-resolved`, `--code-highlight-bg-resolved`, `--code-gutter-fg-resolved`, `--code-highlight-border-resolved`) — all four `FileViewerSheet.tsx` half-migrated `var(--paperclip-code-*, fallback)` sites Batch 1 explicitly deferred as "needs human decision" (see Batch 1's log above) are now resolved: the `--paperclip-code-highlight-bg`/`-border` sites keep their literal rgba/rgb fallback verbatim; the `--paperclip-code-bg`/`-gutter-fg` sites' `theme(colors.muted...)` fallbacks are resolved to the build's own `var(--muted)`/`var(--muted-foreground)` equivalent (see Step 0 spike above). The inline `token-extraction: allowlisted` comment at the site and Batch 1's allowlist doc-comment entry for this file were both updated to note the resolution rather than silently deleting the historical note. +- **`--gradient-extract-25` / `-26`** — SCOPE CORRECTION found during this batch's own bracket sweep, not called out in the original task list: `components/SidebarAccountMenu.tsx` and `pages/ProfileSettings.tsx` each had one `bg-[linear-gradient(...)]` site using `hsl(var(--primary))`/`color-mix(in_oklab,...)` CSS-native color functions rather than raw hex/rgb literals — which is exactly why Batch 1's hand-audited color-literal table (built to avoid false-positiving on issue references like `acme/web#241`) did not catch them; they were never scanned as "color literals" because they contain no hex/rgb/hsl numeric literal, only `var()`/`color-mix()` references. They ARE still value-bearing gradient brackets by gate 2's definition (a CSS value function), so this batch mints 2 more tokens CONTINUING Batch 1's `--gradient-extract-*` counter (picking up at 25, not restarting at 1) rather than creating a parallel family. +- All new tokens live in a fourth non-`@theme` `:root { ... }` block appended to `ui/src/index.css` immediately after Batch 3's block, headed `/* ── Extracted verbatim MISC tokens (Phase 2 Batch 4, design/token-extraction) ── */`, per DESIGN.md (runtime-tunable). + +**GOTCHA — `backdrop-blur-[...]` vs bare `blur-[...]` boundary:** the task's "any `blur-[...]`" language and TOKEN-AUDIT.md section 3.7's inventory both describe the 2 sites as `blur-[Npx]`, but the actual class names are `backdrop-blur-[1px]`/`backdrop-blur-[2px]` (a `filter: backdrop-filter` utility, not the plain `filter: blur()` utility). A bare `blur-\[...\]` regex anchored at Batch 3's class-token BOUNDARY does not match `backdrop-blur-[...]` at all, since `blur` there doesn't start at a boundary (it's preceded by `backdrop-`, not whitespace/quote/colon) — caught immediately in this batch's dry run (0 blur sites found on the first pass despite 2 being expected from the inventory) rather than silently mis-tokenizing. Fixed by matching `(backdrop-blur|blur)-\[...\]` as two alternatives sharing one token family, confirmed both compile correctly in the Step 0 spike. + +**Sites allowlisted (added to the canonical machine-readable ALLOWLIST block in `ui/src/index.css`, consolidating and reformatting the prior per-batch prose lists into `* allow — ` lines for `scripts/check-token-gates.mjs` to parse):** +1. `components/ui/dialog.tsx` / `components/ui/alert-dialog.tsx` — `zoom-in-[0.97]`/`zoom-out-[0.97]`/`slide-in-from-top-[1%]`/`slide-out-to-top-[1%]` (plus `animate-in`/`animate-out`/`fade-in-0`/`fade-out-0`): dead/no-op classes, no `tw-animate-css` plugin installed, confirmed via built-CSS grep (see Step 0 spike above). +2. `components/ProjectWorkspaceSummaryCard.tsx` (`min-[420px]:`) / `components/FileTree.tsx` (`max-[480px]:`, 2 sites) — arbitrary BREAKPOINT VARIANTS, not values; the variant position cannot reference a CSS custom property (Tailwind resolves variants at build time), so there is nothing to tokenize without changing Tailwind's own variant syntax. Distinct from `rounded-[inherit]` (Batch 3, a keyword) but the same category of "syntactically cannot hold a token reference." +3. **18 `*.test.tsx` files, 43 hex-literal sites (new policy ruling this batch, resolving TOKEN-AUDIT.md section 8 item 10):** `IssueFiltersPopover.test.tsx`, `IssueProperties.test.tsx`, `MarkdownBody.test.tsx`, `MarkdownEditor.test.tsx`, `NewIssueDialog.test.tsx`, `ProjectTile.test.tsx`, `RoutineRunVariablesDialog.test.tsx`, `SidebarCompanyMenu.test.tsx`, `SidebarProjects.test.tsx`, `SidebarStarredProjects.test.tsx`, `CompanyEnvironments.test.tsx`, `ExecutionWorkspaceDetail.test.tsx`, `InviteLanding.test.tsx`, `IssueDetail.test.tsx`, `ProjectDetail.test.tsx`, `ProjectWorkspaceDetail.test.tsx`, `Projects.test.tsx`, `Routines.test.tsx`. Every site is MOCK PROP / MOCK API-RESPONSE DATA (`color: "#hex"` label/project/routine fixtures, `brandColor`/`companyBrandColor` company fixtures, an xterm.js theme-mock assertion mirroring the already-allowlisted `CompanyEnvironments.tsx` production config) — none are Tailwind class strings or CSS-in-JS style declarations. This is the test-file counterpart of the exact category Batch 1 already allowlisted in production source (persisted/functional data, not a rendered CSS value); rewriting them to `var()` strings would make mock fixtures look unlike real API responses without touching a single rendered pixel. Ruling: test-fixture prop/mock-data hex literals are allowlisted as a standing category, one line per file for reviewability. +4. **3 gate-1 false positives found and fixed in the gate script itself, not allowlisted (no real hex color involved):** `ExternalObjectPill.test.tsx`/`IssueProperties.test.tsx`'s `"acme/web#241"`-style issue-reference strings were initially flagged by a naive hex regex (`#241` is 3 valid hex digits) — the exact false-positive shape Batch 1's own color codemod header already documented. Fixed with a negative lookbehind requiring a hex color's `#` not be immediately preceded by an identifier character or `/` (a real CSS color is always preceded by a delimiter — quote, colon, paren, comma, whitespace, backtick, or string start — never glued directly to a slash-path or word). + +**Gate script: `scripts/check-token-gates.mjs`** (new, Part C of this batch). Implements the three DONE-WHEN gates over `ui/src/components/**` and `ui/src/pages/**`: +- **Gate 1 (color literals):** hex colors (`#fff`/`#ffffff`/`#ffffffff`) and `rgb()`/`rgba()`/`hsl()`/`hsla()`/`oklch()` calls with a LITERAL first argument (not `var(...)`) — so `hsl(var(--primary)/0.16)` passes, `rgba(0,0,0,0.5)` fails. +- **Gate 2 (arbitrary bracket values):** flags `word-[content]` where `content` carries a rendered value (digits+CSS-unit, bare hex, or a CSS value function — `calc`/`min`/`max`/`clamp`/`var`/`env`/`linear-gradient`/`radial-gradient`/`conic-gradient`/`cubic-bezier`/`rgba?`/`hsla?`/`oklch`/`color-mix`). **Selector/variant brackets are excluded BY DEFINITION** (documented in the script's header, matching the mission's Part B instruction), recognized structurally two ways: (a) known variant-keyword prefixes (`data`, `has`, `aria`, `supports`, `group-data`, `group-has-data`, `group-aria`, `peer-data`, `peer-aria`, `in`, `not`), and (b) any bracket immediately followed by `:` (the structural signature of a breakpoint/arbitrary-variant prefix like `max-[480px]:hidden`, since a value-bearing utility bracket is never itself followed by another `:`-prefixed segment). `rounded-[inherit]` is not flagged (a bare keyword, not a unit/hex/function shape) and is separately allowlisted per Batch 3's precedent for defense-in-depth. +- **Gate 3 (raw font-size):** `text-[Npx]`/`text-[N.Nrem]` class brackets, plus `fontSize: "N..."` / `font-size: "N..."` string-literal declarations that BEGIN with a digit — deliberately excluding `fontSize: "var(--fs-11)"`-style token references (the desired post-extraction form) from matching as a violation. +- **Allowlist parsing:** reads `* allow — ` lines from the canonical ALLOWLIST block appended to the end of `ui/src/index.css` (added this batch, consolidating the Batches 1-3 free-prose lists into this one machine-readable format while leaving the original prose blocks in place as historical narrative). A path suppresses a violation if the violating file's path CONTAINS the allowlisted path as a substring. +- Exit code 0 with a per-gate summary when clean; exit code 1 listing every violation (file:line + snippet), grouped by gate, otherwise. +- Wired as `pnpm check:token-gates` (`"check:token-gates": "node scripts/check-token-gates.mjs"` in the root `package.json`). + +**Verify results:** +- Own full bracket sweep (`rg`-based, matching the gate script's boundary logic) over `ui/src/components/**`/`ui/src/pages/**`: only selector/variant brackets (`data-[...]`, `group-data-[...]`, `has-[...]`, `supports-[...]`, etc.) and the documented allowlisted exceptions (`rounded-[inherit]`, `max-[480px]`/`min-[420px]`, the 4 dead tw-animate-css classes) remain. +- `node scripts/check-token-gates.mjs` → **exit 0**, "All gates clean" (Gate 1/2/3 all CLEAN; 468 files scanned; 31 allowlist entries loaded; 71 allowlisted issues correctly skipped rather than flagged). +- `pnpm build-storybook` → exit 0. +- Storybook visual snapshot suite: **510/510 passed** (`npx playwright test --config tests/storybook-visual/playwright.config.ts --reporter=line`) — 509 passed clean on the first attempt + 1 known-benign flaky retry (`product-documents-annotations--integrated-desktop-open [dark]`, the exact flake called out in the batch mandate as expected-benign) passed on Playwright's automatic retry. +- `pnpm typecheck` (from `ui/`) → exit 0. +- Codemod re-run confirmed idempotent: second invocation reports 0 sites rewritten, 0 files changed, token block already present. +- Spot-checked 3 lockstep test-file rewrites directly with `npx vitest run` (`IssueDocumentAnnotations.test.tsx`, `MarkdownBody.test.tsx`, `SidebarShell.test.tsx`): 67/67 tests passed. + +**AGENTS.md:** the existing "Design system" section (added before Phase 2 began) was extended with one sentence naming `pnpm check:token-gates` as the required pre-commit check and clarifying the rule's exact scope (`ui/src/components/**`/`ui/src/pages/**`, allowlist-exempted) — kept to the section's existing brief tone, no new section added. + +**Remaining out-of-scope debt (explicitly NOT addressed by this run, flagged for whoever scopes the next one):** +- **`ui/src/lib/`, `ui/src/context/`, `ui/src/plugins/`** were never in scope for any of the 4 batches (DESIGN.md/GOAL-PROMPT.md's Phase 2 spec and every batch mandate scoped strictly to `ui/src/components/**` and `ui/src/pages/**`). A quick count at the end of this batch: **36 hex-literal sites** remain in `lib`/`context`/`plugins` combined (`lib/color-contrast.ts`, `lib/worktree-branding.ts`, `context/ThemeContext.tsx` and others — the same "contrast-pair triplication" TOKEN-AUDIT.md section 8 item 9 already flagged). These are helper/logic modules, not component render code, so they were correctly out of this run's gate — but they are real remaining hardcoded-value debt in `ui/src/` overall, and DESIGN.md's "single token source" principle would eventually want them re-pointed at the same tokens too. +- **The Tailwind-palette-class scope question** (TOKEN-AUDIT.md section 8 item 1: `bg-red-500`-style classes, ~3,115 sites / 145 files) was never resolved by any batch and remains exactly as open as it was after Phase 1 — none of Batches 1-4 touched Tailwind's own built-in palette utilities (only literal hex/rgb/bracket-arbitrary values were extracted). This is still the single largest scope decision a human needs to make before any further token-extraction work: whether Tailwind's own oklch palette counts as "hardcoded" under DESIGN.md principle 2, or is considered part of the token system already (Tailwind's palette is itself a fixed design-token set, just not `index.css`'s). +- The micro type-size cluster (9-15px, TOKEN-AUDIT.md section 8 item 2), letter-spacing cluster (item 3), and radius 0px/lg/xl conflict (item 4) are all still open — this run intentionally minted verbatim tokens for every distinct value without collapsing any of them into a real scale, per DESIGN.md's explicit "ugly values stay ugly" instruction. The human scale-collapse decision (GOAL-PROMPT.md "after the run" step 2) is unchanged by Batch 4. diff --git a/package.json b/package.json index 9c6eeb4d37..c32a633fb5 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "release:rollback": "./scripts/rollback-latest.sh", "release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs", "check:tokens": "node scripts/check-forbidden-tokens.mjs", + "check:token-gates": "node scripts/check-token-gates.mjs", "check:no-git-push": "node scripts/check-no-git-push.mjs", "test:check-no-git-push": "node --test scripts/check-no-git-push.test.mjs", "test:hermes-gateway-smoke": "node --test scripts/smoke/hermes-gateway-smoke.test.mjs", diff --git a/scripts/check-token-gates.mjs b/scripts/check-token-gates.mjs new file mode 100644 index 0000000000..3f6432da9f --- /dev/null +++ b/scripts/check-token-gates.mjs @@ -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 — + * 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 — " 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 = /(? 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(); diff --git a/scripts/codemod-extract-misc.mjs b/scripts/codemod-extract-misc.mjs new file mode 100644 index 0000000000..f5a02b2795 --- /dev/null +++ b/scripts/codemod-extract-misc.mjs @@ -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- / --gtr- (track lists, + * deduped on exact string match) + * transition-[...] -> --tp- (property lists, + * deduped on exact string match) + * z-[...] -> --z- (bare z-index scale values) + * scale-[...] -> --s- + * ease-[cubic-bezier(...)] -> --e- + * align-[...] -> --va- (vertical-align) + * stroke-[...] -> --sw- (SVG stroke-WIDTH, + * requires the `length:` hint) + * blur-[...] -> --blur- + * drop-shadow-[...] -> --drop-shadow-extract- (its own + * family, separate from Batch 3's + * --shadow-extract-* box-shadow + * family — filter vs. box-shadow are + * different CSS properties, see + * TOKEN-AUDIT.md Batch 3 "Needs human + * decision") + * bg-[linear|radial|conic-gradient(...)] -> --gradient-extract-, + * CONTINUING Batch 1's counter (not + * restarting it) - 2 sites that use + * hsl(var(...))/color-mix(...) + * CSS-native color functions rather + * than raw hex/rgb literals, so + * Batch 1's hand-audited color table + * did not catch them even though + * they are the same gradient-bracket + * shape. + * bg|text|border-[var(--x)] -> bare paren passthrough, no new + * (no fallback) token minted (same rule as Batch + * 3's var()-only case) + * bg|text|border-[var(--x,fallback)] -> mints a --*-resolved wrapper token + * (fallback form) whose value is `var(--x, fallback)` + * verbatim (paren-with-fallback-comma + * does not parse in Tailwind v4 — + * confirmed in the Step 0 spike -- + * so the fallback expression itself + * must live in the CSS token, not in + * the utility). theme(colors.a.b) + * fallbacks are resolved to their + * BUILT-CSS equivalent var(...) form + * first (inspected from + * storybook-static output before + * writing this codemod - theme() is + * a Tailwind build-time function). + * + * NOT rewritten (documented, not a bug): + * - Selector/variant brackets (data-[...], group-data-[...], has-[...], + * aria-[...], supports-[...], etc.) are CSS selector conditions, not + * visual values - out of scope for this codemod BY DEFINITION (see + * check-token-gates.mjs header for the same distinction, gate 2). + * - tw-animate/animate-plugin arbitrary utilities (zoom-in-[0.97], + * zoom-out-[0.97], slide-in-from-top-[1%], slide-out-to-top-[1%]) plus + * their siblings (animate-in, animate-out, fade-in-0, fade-out-0): + * confirmed via the Step 0 spike that NONE of these compile to any CSS + * at all in this repo's build (no tw-animate-css plugin is installed, + * and no @utility overrides exist in index.css) - grep of the built + * storybook-static CSS shows zero occurrences of "animate-in", "zoom", + * "slide-in-from-top", etc. These are dead, no-op class names with zero + * rendered visual value today, so there is nothing to tokenize; touching + * them would not preserve OR change any pixel. Left untouched and + * allowlisted (third-party plugin syntax, retained for whenever + * tw-animate-css is actually installed) rather than silently deleted, + * since deleting dead classes is itself a (no-op but non-mechanical) + * edit outside this batch's mandate. + * - max-[480px] / min-[420px] breakpoint variants and rounded-[inherit]: + * documented allowlist entries, not code the codemod touches (variant + * position cannot reference a CSS custom property; `inherit` is a + * keyword). See ALLOWLIST block in index.css. + * + * Idempotent: the FIND regexes only match the ORIGINAL bracket-literal form; + * once rewritten the pattern no longer matches, so re-running is a no-op. + * + * Usage: node scripts/codemod-extract-misc.mjs [--check] + * --check Report what WOULD change without writing files (dry run). + */ + +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { resolve, dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, ".."); +const UI_SRC = resolve(REPO_ROOT, "ui/src"); +const SCAN_DIRS = ["components", "pages"]; + +const DRY_RUN = process.argv.includes("--check"); + +// ── Helpers ──────────────────────────────────────────────────────────── +function walk(dir, out) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) walk(p, out); + else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(p); + } +} + +// Tailwind bracket-escaping uses `_` for literal spaces; reverse it before +// writing into a real CSS custom property value (Batch 1's gradient gotcha, +// generalized in Batch 3, applies again here for grid track lists / calc +// expressions embedded in transition/ease values). +function unescapeSpaces(value) { + return value.replace(/_/g, " "); +} + +// "320" "0.7rem" "-50" "auto_minmax(0,1fr)" etc -> safe token-name suffix. +// Dots become underscores, spaces/commas/parens/percent become hyphens, +// collapsed and trimmed (matches Batch 1-3's `--fs-0_7rem`-style convention +// for numeric suffixes; grid/transition/ease values use a slug instead since +// their content isn't a single number). +function slugify(value) { + return value + .trim() + .replace(/_/g, " ") + .replace(/\./g, "_") + .replace(/[^a-zA-Z0-9_]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .toLowerCase(); +} + +// ── Token registries ──────────────────────────────────────────────────── +const gtcTokens = new Map(); // --gtc- grid-template-columns +const gtrTokens = new Map(); // --gtr- grid-template-rows +const tpTokens = new Map(); // --tp- transition-property lists +const zTokens = new Map(); // --z- +const sTokens = new Map(); // --s- scale +const eTokens = new Map(); // --e- ease / timing-function +const vaTokens = new Map(); // --va- vertical-align +const swTokens = new Map(); // --sw- stroke-width +const blurTokens = new Map(); // --blur- +const dropShadowTokens = new Map(); // --drop-shadow-extract- +const resolvedVarTokens = new Map(); // --*-resolved wrapper tokens (fallback var() forms) +// --gradient-extract- continuing Batch 1's family/counter (these 2 sites +// use hsl(var(...))/color-mix(...) CSS-native color functions rather than +// raw hex/rgb literals, which is why Batch 1's hand-audited color-literal +// table did not catch them - they're still a value-bearing gradient bracket, +// in scope for this batch's "remaining bracket utilities" sweep). +const gradientTokens = new Map(); + +let gtcCounter = 0; +let gtrCounter = 0; +let dropShadowCounter = 0; +// Batch 1 minted --gradient-extract-1 through --gradient-extract-24; this +// batch's new gradient sites continue that numbering, not restart it. +let gradientCounter = 24; + +const gtcByValue = new Map(); +function registerGtcToken(unescapedValue, sourceNote) { + if (gtcByValue.has(unescapedValue)) return gtcByValue.get(unescapedValue); + gtcCounter += 1; + const name = `gtc-${gtcCounter}`; + gtcTokens.set(name, { value: unescapedValue, comment: sourceNote }); + gtcByValue.set(unescapedValue, name); + return name; +} + +const gtrByValue = new Map(); +function registerGtrToken(unescapedValue, sourceNote) { + if (gtrByValue.has(unescapedValue)) return gtrByValue.get(unescapedValue); + gtrCounter += 1; + const name = `gtr-${gtrCounter}`; + gtrTokens.set(name, { value: unescapedValue, comment: sourceNote }); + gtrByValue.set(unescapedValue, name); + return name; +} + +function registerTpToken(unescapedValue, sourceNote) { + const slug = slugify(unescapedValue.replace(/,/g, " ")); + const name = `tp-${slug}`; + if (!tpTokens.has(name)) tpTokens.set(name, { value: unescapedValue, comment: sourceNote }); + return name; +} + +function registerZToken(rawNum, sourceNote) { + const name = `z-${rawNum}`; + if (!zTokens.has(name)) zTokens.set(name, { value: rawNum, comment: sourceNote }); + return name; +} + +function registerSToken(unescapedValue, sourceNote) { + const slug = slugify(unescapedValue); + const name = `s-${slug}`; + if (!sTokens.has(name)) sTokens.set(name, { value: unescapedValue, comment: sourceNote }); + return name; +} + +function registerEToken(unescapedValue, sourceNote) { + const slug = slugify(unescapedValue); + const name = `e-${slug}`; + if (!eTokens.has(name)) eTokens.set(name, { value: unescapedValue, comment: sourceNote }); + return name; +} + +function registerVaToken(unescapedValue, sourceNote) { + const slug = slugify(unescapedValue); + const name = `va-${slug}`; + if (!vaTokens.has(name)) vaTokens.set(name, { value: unescapedValue, comment: sourceNote }); + return name; +} + +function registerSwToken(unescapedValue, sourceNote) { + const slug = slugify(unescapedValue); + const name = `sw-${slug}`; + if (!swTokens.has(name)) swTokens.set(name, { value: unescapedValue, comment: sourceNote }); + return name; +} + +function registerBlurToken(unescapedValue, sourceNote) { + const slug = slugify(unescapedValue); + const name = `blur-${slug}`; + if (!blurTokens.has(name)) blurTokens.set(name, { value: unescapedValue, comment: sourceNote }); + return name; +} + +const gradientByValue = new Map(); +function registerGradientToken(unescapedValue, sourceNote) { + if (gradientByValue.has(unescapedValue)) return gradientByValue.get(unescapedValue); + gradientCounter += 1; + const name = `gradient-extract-${gradientCounter}`; + gradientTokens.set(name, { value: unescapedValue, comment: sourceNote }); + gradientByValue.set(unescapedValue, name); + return name; +} + +const dropShadowByValue = new Map(); +function registerDropShadowToken(unescapedValue, sourceNote) { + if (dropShadowByValue.has(unescapedValue)) return dropShadowByValue.get(unescapedValue); + dropShadowCounter += 1; + const name = `drop-shadow-extract-${dropShadowCounter}`; + dropShadowTokens.set(name, { value: unescapedValue, comment: sourceNote }); + dropShadowByValue.set(unescapedValue, name); + return name; +} + +// Wrapper tokens for the var(--x, fallback) forms that can't use the +// paren-with-fallback-comma shorthand (confirmed unsupported in the Step 0 +// spike). theme(colors.a.b) fallbacks are resolved to the equivalent +// var(--token) form the Tailwind build already resolves them to today +// (inspected in ui/storybook-static/assets/*.css before writing this +// codemod - theme() is a Tailwind build-time function and cannot appear +// inside a runtime custom property). +const THEME_COLOR_MAP = { + "theme(colors.muted.DEFAULT)": "var(--muted)", + "theme(colors.muted.foreground)": "var(--muted-foreground)", +}; + +function resolveThemeColor(raw) { + let out = raw; + for (const [from, to] of Object.entries(THEME_COLOR_MAP)) { + out = out.split(from).join(to); + } + return out; +} + +function registerResolvedVarToken(varName, fallbackRaw, sourceNote) { + // varName like "--paperclip-code-highlight-bg" -> token name + // "code-highlight-bg-resolved" (strip the leading "--paperclip-" prefix + // for readability, matching the mission's suggested name for the first + // site; other vars in the same family follow the same convention). + const bare = varName.replace(/^--paperclip-/, "").replace(/^--/, ""); + const name = `${bare}-resolved`; + const fallback = resolveThemeColor(fallbackRaw); + const value = `var(${varName}, ${fallback})`; + if (!resolvedVarTokens.has(name)) resolvedVarTokens.set(name, { value, comment: sourceNote }); + return name; +} + +// ── Regexes ────────────────────────────────────────────────────────────── +// Every regex requires the utility to start at a genuine class-token +// boundary (preceded by whitespace, a quote/backtick, template-literal `${`, +// or start-of-string) - see Batch 3's BOUNDARY GOTCHA. `:` is included for +// variant prefixes (`data-[state=open]:`, `sm:`, etc.). +const BOUNDARY = String.raw`(?<=^|[\s"'\`{:])`; + +const GRID_COLS_RE = new RegExp(`${BOUNDARY}(!?)grid-cols-\\[([^\\]]+)\\]`, "g"); +const GRID_ROWS_RE = new RegExp(`${BOUNDARY}(!?)grid-rows-\\[([^\\]]+)\\]`, "g"); +const TRANSITION_RE = new RegExp(`${BOUNDARY}(!?)transition-\\[([^\\]]+)\\]`, "g"); +const Z_RE = new RegExp(`${BOUNDARY}(!?)z-\\[([0-9]+)\\]`, "g"); +const SCALE_RE = new RegExp(`${BOUNDARY}(!?)scale-\\[([^\\]]+)\\]`, "g"); +const EASE_RE = new RegExp(`${BOUNDARY}(!?)ease-\\[([^\\]]+)\\]`, "g"); +const ALIGN_RE = new RegExp(`${BOUNDARY}(!?)align-\\[([^\\]]+)\\]`, "g"); +const STROKE_RE = new RegExp(`${BOUNDARY}(!?)stroke-\\[([^\\]]+)\\]`, "g"); +// NOTE: negative lookbehind isn't reliably portable across regex engines at +// the boundary position used elsewhere, so drop-shadow is matched with its +// own explicit prefix (drop-shadow-) which never collides with bare blur-. +// Matches both bare `blur-[...]` and `backdrop-blur-[...]` (the latter's +// `backdrop-` prefix means `blur` doesn't start at a class-token BOUNDARY, +// so it needs its own alternative rather than relying on the shared +// boundary-anchored pattern). +const BLUR_RE = new RegExp(`${BOUNDARY}(!?)(backdrop-blur|blur)-\\[([^\\]]+)\\]`, "g"); +const DROP_SHADOW_RE = new RegExp(`${BOUNDARY}(!?)drop-shadow-\\[([^\\]]+)\\]`, "g"); + +// bg-[linear-gradient(...)] / bg-[radial-gradient(...)] / bg-[conic-gradient(...)] +// - continuing Batch 1's --gradient-extract-* family (see registerGradientToken). +// Matched separately from the generic VAR_* regexes below since these are not +// var() passthrough; they need the `image:` paren hint (Batch 1 convention). +const GRADIENT_RE = new RegExp( + `${BOUNDARY}(!?)bg-\\[((?:linear|radial|conic)-gradient\\([^\\]]*\\))\\]`, + "g", +); + +// bg|text|border-[var(--x)] (no fallback) and bg|text|border-[var(--x,fallback)] +// (with fallback). The fallback variant's raw capture includes everything up +// to the matching `)]` - since none of these fallback values contain nested +// brackets, a simple `[^\]]+` capture is safe (verified by inspection of all +// matching sites in this batch). +const VAR_NOFALLBACK_RE = new RegExp( + `${BOUNDARY}(!?)(bg|text|border)-\\[var\\((--[a-zA-Z0-9-]+)\\)\\]`, + "g", +); +const VAR_FALLBACK_RE = new RegExp( + `${BOUNDARY}(!?)(bg|text|border)-\\[var\\((--[a-zA-Z0-9-]+),([^\\]]+)\\)\\]`, + "g", +); + +function rewriteFile(filePath, relPath) { + const original = readFileSync(filePath, "utf8"); + let content = original; + let siteCount = 0; + + content = content.replace(GRID_COLS_RE, (match, bang, raw) => { + const unescaped = unescapeSpaces(raw); + const sourceNote = `Extracted from ${relPath} (grid-cols-[${raw}]).`; + const name = registerGtcToken(unescaped, sourceNote); + siteCount++; + return `${bang}grid-cols-(--${name})`; + }); + + content = content.replace(GRID_ROWS_RE, (match, bang, raw) => { + const unescaped = unescapeSpaces(raw); + const sourceNote = `Extracted from ${relPath} (grid-rows-[${raw}]).`; + const name = registerGtrToken(unescaped, sourceNote); + siteCount++; + return `${bang}grid-rows-(--${name})`; + }); + + content = content.replace(TRANSITION_RE, (match, bang, raw) => { + const unescaped = unescapeSpaces(raw); + const sourceNote = `Extracted from ${relPath} (transition-[${raw}]).`; + const name = registerTpToken(unescaped, sourceNote); + siteCount++; + return `${bang}transition-(--${name})`; + }); + + content = content.replace(Z_RE, (match, bang, raw) => { + const sourceNote = `Extracted from ${relPath} (z-[${raw}]).`; + const name = registerZToken(raw, sourceNote); + siteCount++; + return `${bang}z-(--${name})`; + }); + + content = content.replace(SCALE_RE, (match, bang, raw) => { + const unescaped = unescapeSpaces(raw); + const sourceNote = `Extracted from ${relPath} (scale-[${raw}]).`; + const name = registerSToken(unescaped, sourceNote); + siteCount++; + return `${bang}scale-(--${name})`; + }); + + content = content.replace(EASE_RE, (match, bang, raw) => { + const unescaped = unescapeSpaces(raw); + const sourceNote = `Extracted from ${relPath} (ease-[${raw}]).`; + const name = registerEToken(unescaped, sourceNote); + siteCount++; + return `${bang}ease-(--${name})`; + }); + + content = content.replace(ALIGN_RE, (match, bang, raw) => { + const unescaped = unescapeSpaces(raw); + const sourceNote = `Extracted from ${relPath} (align-[${raw}]).`; + const name = registerVaToken(unescaped, sourceNote); + siteCount++; + return `${bang}align-(--${name})`; + }); + + content = content.replace(STROKE_RE, (match, bang, raw) => { + const unescaped = unescapeSpaces(raw); + const sourceNote = `Extracted from ${relPath} (stroke-[${raw}]).`; + const name = registerSwToken(unescaped, sourceNote); + siteCount++; + // stroke-WIDTH requires the `length:` hint (bare stroke-(--x) is + // ambiguous with the stroke-COLOR utility) - confirmed in Step 0 spike. + return `${bang}stroke-(length:--${name})`; + }); + + content = content.replace(DROP_SHADOW_RE, (match, bang, raw) => { + const unescaped = unescapeSpaces(raw); + const sourceNote = `Extracted from ${relPath} (drop-shadow-[${raw}]).`; + const name = registerDropShadowToken(unescaped, sourceNote); + siteCount++; + return `${bang}drop-shadow-(--${name})`; + }); + + // Gradient bg-[...] brackets must be rewritten BEFORE the generic var() + // passthrough regexes below (a gradient value can itself contain + // `var(--x)` sub-expressions, e.g. hsl(var(--primary)), which the generic + // VAR_NOFALLBACK_RE must not also try to match against the outer bg-[...]). + content = content.replace(GRADIENT_RE, (match, bang, raw) => { + const unescaped = unescapeSpaces(raw); + const sourceNote = `Extracted from ${relPath} (bg-[${raw}]).`; + const name = registerGradientToken(unescaped, sourceNote); + siteCount++; + return `${bang}bg-(image:--${name})`; + }); + + content = content.replace(BLUR_RE, (match, bang, util, raw) => { + const unescaped = unescapeSpaces(raw); + const sourceNote = `Extracted from ${relPath} (${util}-[${raw}]).`; + const name = registerBlurToken(unescaped, sourceNote); + siteCount++; + return `${bang}${util}-(--${name})`; + }); + + // var(--x, fallback) forms MUST be rewritten before the no-fallback form + // (which would otherwise partially match the `var(--x` prefix of a + // fallback expression and corrupt it - the fallback regex requires a + // literal comma so there's no real ambiguity, but ordering fallback-first + // keeps the intent explicit and avoids relying on regex engine match order). + content = content.replace(VAR_FALLBACK_RE, (match, bang, util, varName, fallbackRaw) => { + const sourceNote = `Extracted from ${relPath} (${util}-[var(${varName},${fallbackRaw})]).`; + const name = registerResolvedVarToken(varName, fallbackRaw, sourceNote); + siteCount++; + return `${bang}${util}-(--${name})`; + }); + + content = content.replace(VAR_NOFALLBACK_RE, (match, bang, util, varName) => { + siteCount++; + // Bare var() passthrough - no new token minted, per DESIGN.md/Batch 3's + // special case for runtime library/component variables. Here the vars + // already exist as first-class design tokens in index.css (--chip-match-*), + // so this is a pure syntax modernization, not a token mint. + return `${bang}${util}-(${varName})`; + }); + + if (content !== original && !DRY_RUN) { + writeFileSync(filePath, content, "utf8"); + } + return { changed: content !== original, siteCount }; +} + +function main() { + const files = []; + for (const dir of SCAN_DIRS) walk(resolve(UI_SRC, dir), files); + files.sort(); + + let totalSites = 0; + let filesChanged = 0; + const changedFiles = []; + + for (const filePath of files) { + const relPath = "ui/src/" + relative(UI_SRC, filePath); + const { changed, siteCount } = rewriteFile(filePath, relPath); + if (changed) { + filesChanged++; + changedFiles.push(relPath); + } + totalSites += siteCount; + } + + // ── index.css token block ────────────────────────────────────────── + const cssPath = resolve(UI_SRC, "index.css"); + const cssOriginal = readFileSync(cssPath, "utf8"); + const marker = "/* ── Extracted verbatim MISC tokens (Phase 2 Batch 4, design/token-extraction) ── */"; + let cssNext = cssOriginal; + let cssChanged = false; + + const anyTokens = + gtcTokens.size || + gtrTokens.size || + tpTokens.size || + zTokens.size || + sTokens.size || + eTokens.size || + vaTokens.size || + swTokens.size || + blurTokens.size || + dropShadowTokens.size || + resolvedVarTokens.size || + gradientTokens.size; + + if (!cssOriginal.includes(marker) && anyTokens) { + const lines = []; + lines.push(marker); + lines.push("/* Batch 4/4 (final sweep): grid track lists, transition-property"); + lines.push(" lists, z-index, scale, easing, vertical-align, stroke-width, blur,"); + lines.push(" drop-shadow, and half-migrated var(x, fallback) color forms,"); + lines.push(" verbatim (no normalizing - the human scale-collapse decision comes"); + lines.push(" later per DESIGN.md/TOKEN-AUDIT.md). --gtc-* and --gtr-* and --tp-* are"); + lines.push(" sequentially numbered / slugged since their content (track lists,"); + lines.push(" property lists) is not safely nameable by value alone."); + lines.push(""); + lines.push(" drop-shadow (a CSS filter function) gets its own"); + lines.push(" --drop-shadow-extract-* family, kept separate from Batch 3's"); + lines.push(" --shadow-extract-* box-shadow family per that batch's logged"); + lines.push(" human-decision note."); + lines.push(""); + lines.push(" *-resolved wrapper tokens hold a verbatim var(--x, fallback)"); + lines.push(" expression for sites where Tailwind v4's paren-with-fallback-comma"); + lines.push(" shorthand does not parse (confirmed unsupported in this batch's"); + lines.push(" Step 0 syntax spike). theme(colors.a.b) fallbacks were resolved to"); + lines.push(" the equivalent var(--token) form the Tailwind build already"); + lines.push(" compiles them to today (inspected byte-for-byte from the built"); + lines.push(" storybook-static CSS before this codemod ran: theme(colors.muted.DEFAULT)"); + lines.push(" -> var(--muted), theme(colors.muted.foreground) -> var(--muted-foreground))."); + lines.push(""); + lines.push(" --gradient-extract-* here CONTINUES Batch 1's counter (Batch 1 minted"); + lines.push(" 1 through 24) rather than restarting it - these 2 sites use"); + lines.push(" hsl(var(...))/color-mix(...) CSS-native color functions rather than raw"); + lines.push(" hex/rgb literals, which is why Batch 1's hand-audited color-literal"); + lines.push(" table did not catch them, but they are still value-bearing gradient"); + lines.push(" brackets in scope for this batch's final sweep."); + lines.push(""); + lines.push(" Allowlist (sites intentionally left as-is - see ALLOWLIST doc-comment"); + lines.push(" at the end of this file for the machine-readable list consumed by"); + lines.push(" scripts/check-token-gates.mjs):"); + lines.push(" allow ui/src/components/ui/dialog.tsx — tw-animate-css plugin utilities (zoom-in-[0.97] etc.) are dead/no-op classes today (plugin not installed, verified via built-CSS grep); nothing to tokenize without visually changing a currently-inert class"); + lines.push(" allow ui/src/components/ui/alert-dialog.tsx — same tw-animate-css dead-class situation as dialog.tsx"); + lines.push("*/"); + lines.push(":root {"); + for (const [name, { value, comment }] of gtcTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + for (const [name, { value, comment }] of gtrTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + for (const [name, { value, comment }] of tpTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + for (const [name, { value, comment }] of zTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + for (const [name, { value, comment }] of sTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + for (const [name, { value, comment }] of eTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + for (const [name, { value, comment }] of vaTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + for (const [name, { value, comment }] of swTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + for (const [name, { value, comment }] of blurTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + for (const [name, { value, comment }] of dropShadowTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + for (const [name, { value, comment }] of resolvedVarTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + for (const [name, { value, comment }] of gradientTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + lines.push("}"); + const block = "\n" + lines.join("\n") + "\n"; + cssNext = cssOriginal + block; + cssChanged = true; + } + + if (cssChanged && !DRY_RUN) writeFileSync(cssPath, cssNext, "utf8"); + + // ── Summary ───────────────────────────────────────────────────────── + console.log(`\n${DRY_RUN ? "[DRY RUN] " : ""}codemod-extract-misc summary`); + console.log(` Sites rewritten: ${totalSites}`); + console.log(` Files changed: ${filesChanged}`); + console.log(` New --gtc-*: ${gtcTokens.size}`); + console.log(` New --gtr-*: ${gtrTokens.size}`); + console.log(` New --tp-*: ${tpTokens.size}`); + console.log(` New --z-*: ${zTokens.size}`); + console.log(` New --s-*: ${sTokens.size}`); + console.log(` New --e-*: ${eTokens.size}`); + console.log(` New --va-*: ${vaTokens.size}`); + console.log(` New --sw-*: ${swTokens.size}`); + console.log(` New --blur-*: ${blurTokens.size}`); + console.log(` New --drop-shadow-extract-*: ${dropShadowTokens.size}`); + console.log(` New *-resolved wrapper: ${resolvedVarTokens.size}`); + console.log(` New --gradient-extract-* (continuing Batch 1): ${gradientTokens.size}`); + console.log(` index.css token block: ${cssChanged ? "added" : "already present or nothing to add (idempotent no-op)"}`); + if (changedFiles.length) { + console.log(`\n Changed files:`); + for (const f of changedFiles) console.log(` - ${f}`); + } +} + +main(); diff --git a/ui/src/components/ActivityFeed.tsx b/ui/src/components/ActivityFeed.tsx index ef452283bf..47f6026614 100644 --- a/ui/src/components/ActivityFeed.tsx +++ b/ui/src/components/ActivityFeed.tsx @@ -268,7 +268,7 @@ function CollapsedFeedGroup({ onClick={() => setExpanded((e) => !e)} data-fc="card" className={cn( - "group ml-3 mr-3 md:ml-0 my-2 flex w-(--sz-calc-1) md:w-(--sz-calc-2) items-center gap-2 rounded-lg border bg-card p-(--sz-18px) text-left text-xs transition-[background-color,border-color] duration-150", + "group ml-3 mr-3 md:ml-0 my-2 flex w-(--sz-calc-1) md:w-(--sz-calc-2) items-center gap-2 rounded-lg border bg-card p-(--sz-18px) text-left text-xs transition-(--tp-background-color-border-color) duration-150", "cursor-pointer hover:bg-accent hover:border-muted-foreground/30", )} > diff --git a/ui/src/components/BudgetPolicyCard.tsx b/ui/src/components/BudgetPolicyCard.tsx index 60ca347903..3bce22391a 100644 --- a/ui/src/components/BudgetPolicyCard.tsx +++ b/ui/src/components/BudgetPolicyCard.tsx @@ -102,7 +102,7 @@ export function BudgetPolicyCard({
(fu {isDragOver && canAttach ? (
diff --git a/ui/src/components/ClaudeSubscriptionPanel.tsx b/ui/src/components/ClaudeSubscriptionPanel.tsx index 43ea876f51..a3da73746b 100644 --- a/ui/src/components/ClaudeSubscriptionPanel.tsx +++ b/ui/src/components/ClaudeSubscriptionPanel.tsx @@ -127,7 +127,7 @@ export function ClaudeSubscriptionPanel({
diff --git a/ui/src/components/CodexSubscriptionPanel.tsx b/ui/src/components/CodexSubscriptionPanel.tsx index 39375404a8..bef8f302c0 100644 --- a/ui/src/components/CodexSubscriptionPanel.tsx +++ b/ui/src/components/CodexSubscriptionPanel.tsx @@ -148,7 +148,7 @@ function QuotaWindowRow({ window }: { window: QuotaWindow }) {
diff --git a/ui/src/components/CompanyPatternIcon.tsx b/ui/src/components/CompanyPatternIcon.tsx index c680becb31..1856da482d 100644 --- a/ui/src/components/CompanyPatternIcon.tsx +++ b/ui/src/components/CompanyPatternIcon.tsx @@ -209,7 +209,7 @@ export function CompanyPatternIcon({
)} {!logo && ( - + {initial} )} diff --git a/ui/src/components/DocumentAnnotationLayer.tsx b/ui/src/components/DocumentAnnotationLayer.tsx index 0f449f3fb9..23fc1ba6b3 100644 --- a/ui/src/components/DocumentAnnotationLayer.tsx +++ b/ui/src/components/DocumentAnnotationLayer.tsx @@ -470,7 +470,7 @@ export function DocumentAnnotationLayer({
) : null}