From be634249877d9290b111a62a1d27c82955134acf Mon Sep 17 00:00:00 2001 From: scotttong Date: Mon, 6 Jul 2026 15:15:08 -0700 Subject: [PATCH] refactor(ui): extract size/spacing/radius/shadow values to tokens (Phase 2 batch 3/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 3/4 of the design-token extraction (design/token-extraction). Replaces hardcoded Tailwind arbitrary-bracket values for width/height/min/max, padding/margin/gap/inset/translate, radius (incl. directional), ring/outline width, and box-shadow in ui/src/components/** and ui/src/pages/** (including *.test.tsx companions) with references to new CSS custom-property tokens in ui/src/index.css. Zero visual change, proven by the Phase 0 Storybook snapshot baseline. Counts: - 407 sites rewritten across 140 files (127 component/page + 13 test files updated in lockstep with the components they assert against). - 163 new tokens minted: 127 --sz-* (one shared family across width/height/spacing/gap/inset/translate lengths, including 42 --sz-calc-N compound calc()/min()/max()/clamp() expressions and 2 --sz-safe-* env(safe-area-inset-*) wrappers), 8 --rad-* (shared across radius/ring-width/outline-width), 5 --pct-* (incl. --pct-neg-50 kept distinct per the source bracket's own negative sign), 23 --shadow-extract-* (38 sites, deduped by exact value). - 11 var()-only passthrough sites (--radix-*-trigger-width/height, --new-issue-dialog-height) rewritten to the bare paren form with no new token minted, per DESIGN.md's runtime-library-variable special case. - 3 sites resolved Tailwind's build-time calc(theme(spacing.N)-Mpx) into a byte-equivalent runtime calc() (components/IssueRow.tsx), verified via headless-browser computed-style comparison against the pre-change built CSS. - 2 sites skipped/allowlisted: rounded-[inherit] (CSS keyword, not a literal) and drop-shadow-[...] (different utility than this batch's shadow-[...] mandate) — logged as a Needs-human-decision item. Three gotchas found and fixed during verification (all documented in the codemod header and TOKEN-AUDIT.md's Batch 3 log): a boundary regex bug that false-positived inside Tailwind's own slide-out-to-top-[1%] compound utility, a calc()-operator-spacing bug that could corrupt env(safe-area-inset-*) identifiers, and a generated-CSS-comment bug where a literal "*/" substring in prose silently dropped the entire token block from the compiled CSS (root cause of an initial 222-test snapshot failure, fully resolved to 510/510 passing). Verify: rg gates clean, pnpm build-storybook exit 0, Storybook visual snapshot suite 510/510 (509 clean + 1 known-benign flaky retry), pnpm typecheck exit 0, codemod re-run confirmed idempotent. Co-Authored-By: Claude Fable 5 --- TOKEN-AUDIT.md | 46 ++ scripts/codemod-extract-sizes.mjs | 517 ++++++++++++++++++ ui/src/components/ActiveAgentsPanel.tsx | 4 +- ui/src/components/ActivityCharts.tsx | 10 +- ui/src/components/ActivityFeed.tsx | 4 +- ui/src/components/AgentConfigForm.tsx | 14 +- ui/src/components/BlockedInboxView.tsx | 10 +- ui/src/components/BudgetPolicyCard.tsx | 2 +- ui/src/components/BudgetSidebarMarker.tsx | 2 +- ui/src/components/ChatComposer.test.tsx | 8 +- ui/src/components/ChatComposer.tsx | 8 +- ui/src/components/CommentThread.tsx | 2 +- ui/src/components/CompanySwitcher.tsx | 2 +- ui/src/components/DocumentAnnotationLayer.tsx | 2 +- ui/src/components/DocumentAnnotationPanel.tsx | 4 +- ui/src/components/DocumentDiffModal.tsx | 2 +- ui/src/components/EntityRow.tsx | 2 +- .../components/ExecutionParticipantPicker.tsx | 2 +- .../ExecutionWorkspaceCloseDialog.tsx | 2 +- ui/src/components/ExternalObjectPill.tsx | 4 +- ui/src/components/FeedCard.tsx | 2 +- ui/src/components/FileViewerSheet.test.tsx | 2 +- ui/src/components/FileViewerSheet.tsx | 4 +- ui/src/components/FrontDoor.tsx | 2 +- ui/src/components/ImageGalleryModal.tsx | 2 +- ui/src/components/InlineEntitySelector.tsx | 2 +- ui/src/components/IssueAttachmentsSection.tsx | 2 +- ui/src/components/IssueBlockedNotice.tsx | 2 +- ui/src/components/IssueChatThread.test.tsx | 14 +- ui/src/components/IssueChatThread.tsx | 16 +- ui/src/components/IssueColumns.tsx | 2 +- ui/src/components/IssueDocumentsSection.tsx | 4 +- .../components/IssueFiltersPopover.test.tsx | 2 +- ui/src/components/IssueFiltersPopover.tsx | 2 +- .../IssuePlanDecompositionsSection.tsx | 2 +- ui/src/components/IssueRecoveryActionCard.tsx | 2 +- ui/src/components/IssueReferencePill.tsx | 2 +- ui/src/components/IssueRow.tsx | 4 +- ui/src/components/IssueScheduledRetryCard.tsx | 2 +- .../IssueSiblingNavigation.test.tsx | 2 +- ui/src/components/IssueSiblingNavigation.tsx | 2 +- .../components/IssueThreadInteractionCard.tsx | 6 +- ui/src/components/JsonSchemaForm.tsx | 6 +- ui/src/components/KanbanBoard.tsx | 6 +- .../KeyboardShortcutsCheatsheet.tsx | 2 +- ui/src/components/Layout.tsx | 6 +- ui/src/components/LiveRunWidget.tsx | 4 +- ui/src/components/MarkdownBody.test.tsx | 2 +- ui/src/components/MarkdownEditor.test.tsx | 2 +- ui/src/components/MarkdownEditor.tsx | 4 +- ui/src/components/MembershipAction.tsx | 4 +- ui/src/components/MobileBottomNav.tsx | 6 +- ui/src/components/NewAgentDialog.tsx | 4 +- ui/src/components/NewGoalDialog.tsx | 4 +- ui/src/components/NewIssueDialog.test.tsx | 4 +- ui/src/components/NewIssueDialog.tsx | 8 +- ui/src/components/NewProjectDialog.tsx | 8 +- ui/src/components/OnboardingChat.tsx | 2 +- ui/src/components/OnboardingWizard.tsx | 14 +- ui/src/components/PageSkeleton.tsx | 2 +- .../components/PipelineItemBodyDocument.tsx | 4 +- ui/src/components/PropertiesPanel.tsx | 2 +- ui/src/components/QuotaBar.tsx | 2 +- ui/src/components/RoutineHistoryTab.tsx | 4 +- .../RoutineRunVariablesDialog.test.tsx | 6 +- .../components/RoutineRunVariablesDialog.tsx | 4 +- ui/src/components/RoutineSubSidebar.tsx | 2 +- ui/src/components/ScheduleEditor.tsx | 8 +- ui/src/components/ScrollToBottom.tsx | 4 +- ui/src/components/SearchableSelect.tsx | 4 +- ui/src/components/SidebarAccountMenu.test.tsx | 2 +- ui/src/components/SidebarAccountMenu.tsx | 2 +- ui/src/components/SidebarCompanyMenu.tsx | 2 +- ui/src/components/SidebarNavItem.tsx | 6 +- .../components/SourceResolvedFoldCallout.tsx | 2 +- ui/src/components/StatusBadge.tsx | 2 +- ui/src/components/SystemNotice.tsx | 2 +- ui/src/components/WorkspaceFileBrowser.tsx | 8 +- .../artifacts/ArtifactCard.test.tsx | 2 +- ui/src/components/artifacts/ArtifactCard.tsx | 2 +- .../artifacts/ArtifactGroupCard.tsx | 6 +- .../SecretPicker.tsx | 2 +- .../environment-variables-editor/index.tsx | 2 +- .../InterruptHandoffViews.tsx | 4 +- .../issue-properties/IssueProperties.tsx | 6 +- .../issue-properties/external-object-rows.tsx | 2 +- .../issue-properties/relation-controls.tsx | 4 +- .../routine-sections/editable-sections.tsx | 4 +- ui/src/components/search/SearchResultRow.tsx | 4 +- .../components/timeline/WorkTimelineChart.tsx | 4 +- .../transcript/RunTranscriptView.tsx | 2 +- ui/src/components/ui/alert-dialog.tsx | 2 +- ui/src/components/ui/badge.tsx | 2 +- ui/src/components/ui/button.tsx | 2 +- ui/src/components/ui/checkbox.tsx | 2 +- ui/src/components/ui/command.tsx | 2 +- ui/src/components/ui/dialog.tsx | 2 +- ui/src/components/ui/dropdown-menu.tsx | 4 +- ui/src/components/ui/input.tsx | 2 +- ui/src/components/ui/scroll-area.tsx | 2 +- ui/src/components/ui/select.tsx | 6 +- ui/src/components/ui/tabs.tsx | 6 +- ui/src/components/ui/textarea.tsx | 2 +- ui/src/components/ui/tooltip.tsx | 2 +- ui/src/index.css | 186 +++++++ ui/src/pages/Activity.tsx | 2 +- ui/src/pages/AgentDetail.tsx | 12 +- ui/src/pages/Agents.test.tsx | 2 +- ui/src/pages/Agents.tsx | 4 +- ui/src/pages/BoardChat.tsx | 6 +- ui/src/pages/CompanyEnvironments.tsx | 6 +- ui/src/pages/CompanyExport.tsx | 10 +- ui/src/pages/CompanyImport.tsx | 12 +- ui/src/pages/CompanySettings.tsx | 2 +- ui/src/pages/CompanySettingsPluginPage.tsx | 2 +- ui/src/pages/CompanySkills.tsx | 36 +- ui/src/pages/DashboardLive.tsx | 2 +- ui/src/pages/DesignGuide.tsx | 4 +- ui/src/pages/Inbox.tsx | 8 +- ui/src/pages/InstanceExperimentalSettings.tsx | 2 +- ui/src/pages/InviteUxLab.tsx | 18 +- ui/src/pages/IssueChatLongThreadPerf.tsx | 2 +- ui/src/pages/IssueChatUxLab.tsx | 4 +- ui/src/pages/IssueDetail.test.tsx | 2 +- ui/src/pages/IssueDetail.tsx | 16 +- ui/src/pages/OrgChart.tsx | 2 +- ui/src/pages/PipelineSettings.tsx | 6 +- ui/src/pages/Pipelines.tsx | 30 +- ui/src/pages/PluginManager.tsx | 2 +- ui/src/pages/PluginPage.tsx | 2 +- ui/src/pages/PluginSettings.tsx | 2 +- ui/src/pages/ProfileSettings.tsx | 4 +- ui/src/pages/Routines.tsx | 4 +- ui/src/pages/RunTranscriptUxLab.tsx | 16 +- ui/src/pages/Search.tsx | 2 +- ui/src/pages/Secrets.tsx | 8 +- ui/src/pages/SystemNoticeUxLab.tsx | 10 +- ui/src/pages/TeamCatalog.tsx | 8 +- ui/src/pages/Timeline.tsx | 4 +- ui/src/pages/UserProfile.tsx | 2 +- .../pages/secrets/ImportFromVaultDialog.tsx | 4 +- .../pages/secrets/SetMyUserSecretDialog.tsx | 2 +- .../secrets/UserSecretDefinitionsTab.tsx | 2 +- 143 files changed, 1079 insertions(+), 330 deletions(-) create mode 100644 scripts/codemod-extract-sizes.mjs diff --git a/TOKEN-AUDIT.md b/TOKEN-AUDIT.md index ad5dfca5a8..36a977f17f 100644 --- a/TOKEN-AUDIT.md +++ b/TOKEN-AUDIT.md @@ -316,3 +316,49 @@ Codemod: `scripts/codemod-extract-type.mjs`. Unlike Batch 1's hand-audited site - Codemod re-run confirmed idempotent: second invocation reports 0 sites rewritten, 0 files changed, token block already present. **Needs human decision:** none new from this batch beyond the already-logged #2 (micro type-size cluster) and #3 (letter-spacing cluster) in section 8 above — this batch's 17 minted tokens are exactly the verbatim inventory those two items describe, now materialized as CSS custom properties ready for a human to collapse into a real scale. + +--- + +## Phase 2 extraction log — Batch 3 (sizes/spacing/radius/shadows) + +Codemod: `scripts/codemod-extract-sizes.mjs`. Blanket regex sweep (like Batch 2 — these bracket-utility families are unambiguous), scoped to `ui/src/components/**` and `ui/src/pages/**` including `*.test.tsx` companions. Covers `w-[...] h-[...] size-[...] min-w/max-w/min-h/max-h-[...]`, `p*/m*-[...]`, `gap-[...]`/`gap-x/y-[...]`, `inset/inset-x/inset-y/top/left/right/bottom-[...]`, `translate-x/y-[...]`, `rounded-[...]` (incl. directional `rounded-t/r/b/l/tl/tr/bl/br-[...]`), `shadow-[...]`, `ring-[...]`, `outline-[...]`. Verified idempotent (second run: 0 sites, 0 files changed). + +**Sites rewritten: 407**, across 140 files (127 component/page files + 13 test files rewritten in lockstep: `ChatComposer.test.tsx`, `FileViewerSheet.test.tsx`, `IssueChatThread.test.tsx`, `IssueFiltersPopover.test.tsx`, `IssueSiblingNavigation.test.tsx`, `MarkdownBody.test.tsx`, `MarkdownEditor.test.tsx`, `NewIssueDialog.test.tsx`, `RoutineRunVariablesDialog.test.tsx`, `SidebarAccountMenu.test.tsx`, `artifacts/ArtifactCard.test.tsx`, `Agents.test.tsx`, `IssueDetail.test.tsx`). + +**Tokens minted: 163 new** (127 `--sz-*` + 8 `--rad-*` + 5 `--pct-*` + 23 `--shadow-extract-*`; 0 reused from Batches 1-2 — no existing size/radius/shadow tokens existed to match against, and DESIGN.md/TOKEN-AUDIT.md section 6.3 already noted zero `--shadow-*` tokens existed pre-Phase-2). +- **`--sz-*` (127 tokens)** — ONE shared family across width/height/min/max, padding/margin, gap, inset/top/left/right/bottom, and translate, so identical literal values dedupe regardless of which property used them (e.g. a `220px` used as both `h-[220px]` and `w-[220px]` in different files collapses to one `--sz-220px`). Includes: + - 85 simple numeric length tokens (`--sz-320px`, `--sz-18rem`, `--sz-85vh`, `--sz-24ch`, etc. — px/rem/em/vh/vw/dvh/dvw/ch units, verbatim, no rounding). + - 42 `--sz-calc-N` tokens for `calc()`/`min()`/`max()`/`clamp()` compound expressions (sequentially numbered since content isn't safely nameable); 2 sites reused an existing `--sz-calc-N` where the exact string recurred (`min(calc(100dvh - 2rem),42rem)`-style forms did not recur verbatim, but `theme(spacing.N)` resolution below did feed distinct calcs — no cross-file duplicate calc strings were found this batch). + - 2 `--sz-safe-*` tokens (`--sz-safe-top`, `--sz-safe-bottom`) for bare `env(safe-area-inset-*)` forms with no arithmetic — `Layout.tsx`, `MobileBottomNav.tsx`. `env()` forms MIXED into a `calc()`/`min()`/`max()` (e.g. `calc(5rem+env(safe-area-inset-bottom))`, `max(1rem,env(safe-area-inset-top))`) were folded into the `--sz-calc-N` family instead, verbatim (`env()` is valid inside a runtime custom property — confirmed in the Step 0 spike). +- **`--rad-*` (8 tokens)** — shared across radius, ring-width, and outline-width (`--rad-2` through `--rad-32`, bare-number naming to match Batch 1/2's `--fs-11`-style convention since every site here used px). Two directional-radius sites (`IssueChatThread.tsx`'s `rounded-br-[4px]`/`rounded-bl-[4px]` speech-tail corners) reuse the same `--rad-4` token minted from `StatusBadge.tsx`'s bare `rounded-[4px]`. +- **`--pct-*` (5 tokens)** — `--pct-50`, `--pct-72`, `--pct-85`, `--pct-90` (bare percentages) + `--pct-neg-50` (kept as a DISTINCT token from `--pct-50`, not a negated reference, because the source bracket already carries the minus sign inside the value itself — `translate-x-[-50%]`, not `-translate-x-[50%]` — confirmed via the Step 0 spike that both the `-utility-(--x)` prefix-negation form AND a literal negative-value token both compile correctly, and the codebase's actual sites are all the latter form). +- **`--shadow-extract-*` (23 tokens)** for 38 sites (dedup: `--shadow-extract-15` = `0 24px 60px rgba(15,23,42,0.08)` reused at 5 sites; `--shadow-extract-17` = `0 30px 80px rgba(15,23,42,0.10)` reused at 3 sites — matching the two clusters TOKEN-AUDIT.md section 3.6 flagged). Underscore-to-space reversal applied to every shadow value per Batch 1's gradient gotcha, generalized (`shadow-[0_0_0_2px_hsl(var(--background))]` → `0 0 0 2px hsl(var(--background))`). Two sites are `dark:` variant pairs sharing the same base shadow token family but pointing at different tokens (`ChatComposer.tsx`/`IssueChatThread.tsx`/`ChatComposer.test.tsx`: light `shadow-(--shadow-extract-4)`, `dark:shadow-(--shadow-extract-5)`). +- All new tokens live in a third non-`@theme` `:root { ... }` block appended to `ui/src/index.css` immediately after Batch 2's type block, headed `/* ── Extracted verbatim SIZE/SPACING/RADIUS/SHADOW tokens (Phase 2 Batch 3, design/token-extraction) ── */`, per DESIGN.md (runtime-tunable). + +**var()-only passthrough (11 sites, 0 new tokens minted per DESIGN.md's special case)** — bracket values that only wrap a runtime library/component variable are rewritten straight to the bare paren form with no token mint: `w-[var(--radix-popover-trigger-width)]` → `w-(--radix-popover-trigger-width)` (`SearchableSelect.tsx` ×1, `OnboardingWizard.tsx` ×1, `AgentConfigForm.tsx` ×3), `h-[var(--radix-select-trigger-height)]` / `min-w-[var(--radix-select-trigger-width)]` → same form (`ui/select.tsx` ×2), `h-[var(--new-issue-dialog-height)]` / `max-h-[var(--new-issue-dialog-height)]` → same form (`NewIssueDialog.tsx` ×2, `NewIssueDialog.test.tsx` ×2 in lockstep). + +**`theme(spacing.N)` resolution (`components/IssueRow.tsx`, 3 sites)** — `theme()` is a Tailwind build-time function and does not work inside a runtime CSS custom property. Resolved using Tailwind v4's default `--spacing: 0.25rem` base (confirmed no `--spacing` override exists in `index.css`, and the BUILT CSS for these exact classes was inspected before the codemod ran: `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). Minted as `--sz-calc-11: calc(0.5rem - 2px)`, `--sz-calc-12: calc(0.25rem - 2px)`, `--sz-calc-13: calc(0.75rem + 0.5rem)` — byte-equivalent computed output verified via headless-browser spike (`6px`/`4px`/`1.25rem` respectively, matching the pre-codemod built CSS exactly). + +**Sites allowlisted / intentionally skipped (2 items):** +1. `components/ui/scroll-area.tsx` — `rounded-[inherit]` is a CSS **keyword**, not a literal value; the codemod explicitly detects and skips it (no rewrite, no token). Documented in the `index.css` block comment. +2. `components/CompanyPatternIcon.tsx` — `drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]` is a **different Tailwind utility** (`drop-shadow`, a CSS `filter` function) than the batch mandate's `shadow-[...]` (`box-shadow`) pattern; left untouched as out of the batch's explicit scope (TOKEN-AUDIT.md section 3.6 also only inventoried `shadow-[...]`, not `drop-shadow-[...]`) — flagged here for a future batch or human decision on whether `drop-shadow` should be folded into the same `--shadow-extract-*` family or get its own. + +**BOUNDARY BUG caught and fixed during this batch (documented for future batches):** the first codemod pass used a bare `\b` word-boundary regex for `top/left/right/bottom-[...]`, which false-positived 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 since `-` is not a word character, so the regex silently corrupted `slide-out-to-top-[1%]` into `slide-out-to-top-(--pct-1)`, which Tailwind does not recognize as the `slide-out-to-top-*` utility at all (a real, silent visual/behavioral regression, not just a naming nit — caught by manual inspection before the Playwright run, not by the snapshot suite itself, since the corrupted form simply drops the animation rather than changing a static rendered pixel in a way the frozen-time/reduced-motion snapshot harness would catch). Fixed by requiring the utility name to start at a genuine class-token boundary (preceded by whitespace, a quote/backtick, template-literal `${`, colon `:` for variant prefixes, or start-of-string) rather than a bare `\b`. + +**CALC-SPACING BUG caught and fixed during this batch (second gotcha, generalizing Batch 1's underscore lesson):** two distinct spacing problems in calc() expressions, both caught via the Step 0 spike and a subsequent live headless-browser check: +1. `calc(100%-2rem)`-style brackets (NO space around the operator at all, not even underscore-escaped) are syntactically INVALID once lifted verbatim into a CSS custom property — a headless-browser test confirmed `calc(100%-2rem)` silently drops (computed value falls back to the containing block's own size) while `calc(100% - 2rem)` computes correctly. Fixed with a `normalizeCalcSpacing()` helper that inserts spacing around top-level `+`/`-` operators. +2. The first version of that helper was too broad (matched any letter-hyphen-letter sequence) and corrupted `env(safe-area-inset-bottom)` into `env(safe - area - inset - bottom)` inside `calc(1.5rem+5rem+env(safe-area-inset-bottom))` — caught via manual `grep`/inspection of the generated token block before the Playwright run (the malformed token would have made the whole value invalid, again a silent drop rather than a pixel diff the snapshot suite would flag directly, though it likely would have surfaced as a layout diff on `ScrollToBottom.tsx`'s stories). Fixed by narrowing the operator-spacing regex to only fire when the LEFT side is a number/number-with-unit/closing-paren and the 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 sitting between two bare identifier characters. + +**COMMENT-SYNTAX BUG caught and fixed during this batch (third gotcha, new category — a lesson for future codemods' generated CSS comments, not the token values themselves):** the codemod's own generated `index.css` doc-comment originally read "`--rad-* is likewise shared across rounded-*/ring/outline widths.`" — the literal two-character sequence `*/` inside "rounded-\*/ring" prematurely closed the enclosing CSS block comment, and everything from that point to the real intended `*/` several lines later was parsed as raw (garbled, non-declaration) CSS. This didn't just corrupt the comment — it desynced the CSS parser badly enough that the ENTIRE Batch 3 `:root { ... }` token block (all 163 tokens) was silently dropped from the compiled Storybook CSS, while the hundreds of `var(--sz-*)`/`var(--rad-*)` REFERENCES in component class names still compiled fine (Tailwind generates utility classes independent of whether the referenced custom property is actually declared anywhere) — so every `--sz-*`/`--rad-*`/`--pct-*`/`--shadow-extract-*` reference resolved to nothing (`unset`, i.e. `0` for lengths), which is what produced the initial 222-test Playwright failure (all length/radius/shadow values silently collapsing to their initial/zero value). Caught by: (a) the Playwright run failing far more broadly than a mechanical rename should cause, (b) isolating a single-file change that passed in isolation but failed in the full batch, (c) directly querying `getComputedStyle(...).marginTop` in a live headless-browser session against the built Storybook static output, which showed `0px` instead of the expected `7px`, and (d) a manual `/*`/`*/` balance count over the generated CSS block, which found the block was one `*/` short of balanced. Fixed by rephrasing the comment to avoid a bare `*/`-forming substring ("rounded, ring, and outline widths" instead of "rounded-\*/ring/outline widths") and adding an inline guard-comment in the codemod script itself warning future editors never to let generated CSS-comment prose contain a literal `*/` sequence. + +**Verify results:** +- `rg` gates clean in `ui/src/components/**` / `ui/src/pages/**`: zero remaining `w-[...]/h-[...]/size-[...]/min-w-[...]/max-w-[...]/min-h-[...]/max-h-[...]`, zero `p*/m*-[...]`, zero `gap-[...]`/`gap-x/y-[...]`, zero `inset/inset-x/inset-y/top/left/right/bottom-[...]` (except Tailwind's own `slide-out-to-top-[1%]`/`slide-in-from-top-[1%]` compound utilities, which are NOT this batch's target pattern), zero `translate-x/y-[...]`, zero `rounded*-[...]` (except the documented `rounded-[inherit]` keyword skip), zero `shadow-[...]` (except the out-of-scope `drop-shadow-[...]` noted above), zero `ring-[...]`, zero `outline-[...]`. +- `pnpm build-storybook` exit 0. +- Storybook visual snapshot suite: **510/510 passed** on the final verification run (509 passed clean + 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, exit code 0). An earlier run with the COMMENT-SYNTAX BUG present failed 222/510 (all attributable to the single root cause above, not 222 independent regressions); after the fix, first clean re-run passed 510/510 with zero retries needed, and the final verification run (rebuilt from scratch a second time) reproduced the single known-benign flaky retry only. +- `pnpm typecheck` exit 0. +- Codemod re-run confirmed idempotent: second invocation reports 0 sites rewritten, 0 files changed, token block already present. + +**Needs human decision (new, from this batch):** +- **`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. diff --git a/scripts/codemod-extract-sizes.mjs b/scripts/codemod-extract-sizes.mjs new file mode 100644 index 0000000000..666c641e8a --- /dev/null +++ b/scripts/codemod-extract-sizes.mjs @@ -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- 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- calc(...)/min()/max()/clamp() forms, sequentially + * numbered (values are not safely nameable by content + * without ambiguity), each with an inline comment. + * --sz-safe- bare env(safe-area-inset-) forms, verbatim. + * --pct- bare percentage values, e.g. --pct-50: 50%; + * --pct-neg- 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- 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- 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- 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(); diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 00faf586ec..df978f7d38 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -170,9 +170,9 @@ const AgentRunCard = memo(function AgentRunCard({ }) { return (
diff --git a/ui/src/components/ActivityCharts.tsx b/ui/src/components/ActivityCharts.tsx index ccbadb8e0e..519f841d82 100644 --- a/ui/src/components/ActivityCharts.tsx +++ b/ui/src/components/ActivityCharts.tsx @@ -19,7 +19,7 @@ function formatDayLabel(dateStr: string): string { function DateLabels({ days }: { days: string[] }) { return ( -
+
{days.map((day, i) => (
{(i === 0 || i === 6 || i === 13) ? ( @@ -96,7 +96,7 @@ export function RunActivityChart(props: RunChartProps) { return (
-
+
{days.map(day => { const entry = grouped.get(day) ?? { date: day, succeeded: 0, failed: 0, other: 0, total: 0 }; const total = entry.total; @@ -148,7 +148,7 @@ export function PriorityChart({ issues }: { issues: { priority: string; createdA return (
-
+
{days.map(day => { const entry = grouped.get(day)!; const total = Object.values(entry).reduce((a, b) => a + b, 0); @@ -215,7 +215,7 @@ export function IssueStatusChart({ issues }: { issues: { status: string; created return (
-
+
{days.map(day => { const entry = grouped.get(day)!; const total = Object.values(entry).reduce((a, b) => a + b, 0); @@ -251,7 +251,7 @@ export function SuccessRateChart(props: RunChartProps) { return (
-
+
{days.map(day => { const entry = grouped.get(day) ?? { date: day, succeeded: 0, failed: 0, other: 0, total: 0 }; const rate = entry.total > 0 ? entry.succeeded / entry.total : 0; diff --git a/ui/src/components/ActivityFeed.tsx b/ui/src/components/ActivityFeed.tsx index 2e8796a40d..ef452283bf 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-[calc(100%-1.5rem)] md:w-[calc(100%-0.75rem)] items-center gap-2 rounded-lg border bg-card p-[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-[background-color,border-color] duration-150", "cursor-pointer hover:bg-accent hover:border-muted-foreground/30", )} > @@ -680,7 +680,7 @@ export function ActivityFeed({ className }: ActivityFeedProps) { > {isEmpty ? (
-
+
{emptyMessage?.showPulse && ( diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 0399f4530e..c52a10d546 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -983,7 +983,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { value={eff("identity", "capabilities", props.agent.capabilities ?? "") ?? ""} onChange={(v) => mark("identity", "capabilities", v || null)} placeholder="Describe what this agent can do..." - contentClassName="min-h-[44px] text-sm font-mono" + contentClassName="min-h-(--sz-44px) text-sm font-mono" imageUploadHandler={async (file) => { const asset = await uploadMarkdownImage.mutateAsync({ file, @@ -1004,7 +1004,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")} placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." - contentClassName="min-h-[88px] text-sm font-mono" + contentClassName="min-h-(--sz-88px) text-sm font-mono" imageUploadHandler={async (file) => { const namespace = `agents/${props.agent.id}/prompt-template`; const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); @@ -1345,7 +1345,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { mark("adapterConfig", "bootstrapPromptTemplate", v || undefined) } placeholder="Optional initial setup prompt for the first run" - contentClassName="min-h-[44px] text-sm font-mono" + contentClassName="min-h-(--sz-44px) text-sm font-mono" imageUploadHandler={async (file) => { const namespace = `agents/${props.agent.id}/bootstrap-prompt`; const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); @@ -1632,7 +1632,7 @@ function AdapterTypeDropdown({ - + {adapterList.map((item) => ( - +
); })} -
+
{allowDefault && ( - + {options.map((option) => ( - + Companies {sidebarCompanies.map((company) => ( diff --git a/ui/src/components/DocumentAnnotationLayer.tsx b/ui/src/components/DocumentAnnotationLayer.tsx index 7aab5ab415..0f449f3fb9 100644 --- a/ui/src/components/DocumentAnnotationLayer.tsx +++ b/ui/src/components/DocumentAnnotationLayer.tsx @@ -454,7 +454,7 @@ export function DocumentAnnotationLayer({ : isStale ? "bg-yellow-200 outline outline-2 outline-dashed outline-offset-0 outline-yellow-700/65 dark:bg-yellow-600 dark:outline-yellow-200/70" : isFocused - ? "bg-yellow-300 outline outline-2 outline-offset-0 outline-yellow-700/85 shadow-[0_0_0_1px_var(--color-background)] dark:bg-yellow-500 dark:outline-yellow-200/85" + ? "bg-yellow-300 outline outline-2 outline-offset-0 outline-yellow-700/85 shadow-(--shadow-extract-6) dark:bg-yellow-500 dark:outline-yellow-200/85" : "bg-yellow-200 dark:bg-yellow-600", )} style={{ diff --git a/ui/src/components/DocumentAnnotationPanel.tsx b/ui/src/components/DocumentAnnotationPanel.tsx index ed93771ef2..3244c3d6dc 100644 --- a/ui/src/components/DocumentAnnotationPanel.tsx +++ b/ui/src/components/DocumentAnnotationPanel.tsx @@ -71,7 +71,7 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) { Comments on {props.documentKey} revision {props.documentRevisionNumber} @@ -91,7 +91,7 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) { aria-label={`Annotations for ${props.documentKey.toUpperCase()}, revision ${props.documentRevisionNumber}`} data-testid="document-annotation-panel" className={cn( - "isolate flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-none border border-border bg-popover text-popover-foreground shadow-xl", + "isolate flex h-full max-h-(--sz-80vh) w-(--sz-360px) shrink-0 flex-col overflow-hidden rounded-none border border-border bg-popover text-popover-foreground shadow-xl", props.className, )} style={props.desktopWidth ? { width: props.desktopWidth, maxWidth: props.desktopWidth } : undefined} diff --git a/ui/src/components/DocumentDiffModal.tsx b/ui/src/components/DocumentDiffModal.tsx index cf46edffbc..85341fecc1 100644 --- a/ui/src/components/DocumentDiffModal.tsx +++ b/ui/src/components/DocumentDiffModal.tsx @@ -85,7 +85,7 @@ export function DocumentDiffModal({ return ( - +
diff --git a/ui/src/components/EntityRow.tsx b/ui/src/components/EntityRow.tsx index bc88e971ec..abc734280d 100644 --- a/ui/src/components/EntityRow.tsx +++ b/ui/src/components/EntityRow.tsx @@ -50,7 +50,7 @@ export function EntityRow({
{identifier && ( - + {identifier} )} diff --git a/ui/src/components/ExecutionParticipantPicker.tsx b/ui/src/components/ExecutionParticipantPicker.tsx index 0c2202d2ce..7ee0111ea3 100644 --- a/ui/src/components/ExecutionParticipantPicker.tsx +++ b/ui/src/components/ExecutionParticipantPicker.tsx @@ -105,7 +105,7 @@ export function ExecutionParticipantPicker({ > {values.length > 0 ? ( - + {values.map(participantLabel).join(", ")} ) : ( diff --git a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx index 17e4e11f97..27b79b7f7a 100644 --- a/ui/src/components/ExecutionWorkspaceCloseDialog.tsx +++ b/ui/src/components/ExecutionWorkspaceCloseDialog.tsx @@ -89,7 +89,7 @@ export function ExecutionWorkspaceCloseDialog({ { if (!closeWorkspace.isPending) onOpenChange(nextOpen); }}> - + {actionLabel} diff --git a/ui/src/components/ExternalObjectPill.tsx b/ui/src/components/ExternalObjectPill.tsx index e3d4d844fc..fed1276a0d 100644 --- a/ui/src/components/ExternalObjectPill.tsx +++ b/ui/src/components/ExternalObjectPill.tsx @@ -134,7 +134,7 @@ export function ExternalObjectPill({ tone.split(" ").filter((c) => c.startsWith("text-")).join(" "), overlay, interactive - && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring", + && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring", className, ); const titleAttr = sourceSummary @@ -149,7 +149,7 @@ export function ExternalObjectPill({ sizeClassName="h-3 w-3" label={`${providerLabel}: ${statusLabel}`} /> - {valueLabel} + {valueLabel} ); const countSuffix = typeof sourceCount === "number" && sourceCount > 1 ? ( diff --git a/ui/src/components/FeedCard.tsx b/ui/src/components/FeedCard.tsx index 7806fe85c6..44c8cc8e5b 100644 --- a/ui/src/components/FeedCard.tsx +++ b/ui/src/components/FeedCard.tsx @@ -436,7 +436,7 @@ export function FeedCard({
{ it("reserves metadata row height while file details load", () => { const markup = renderToStaticMarkup(); - expect(markup).toContain("min-h-[18px]"); + expect(markup).toContain("min-h-(--sz-18px)"); expect(markup).toContain("Loading file details"); }); }); diff --git a/ui/src/components/FileViewerSheet.tsx b/ui/src/components/FileViewerSheet.tsx index 0fb572fb28..0f116b1a05 100644 --- a/ui/src/components/FileViewerSheet.tsx +++ b/ui/src/components/FileViewerSheet.tsx @@ -223,7 +223,7 @@ export function FileViewerMetadataRow({ state: FileViewerUrlState | null; }) { return ( -
+
{resolvedResource ? ( <> {resolvedResource.previewKind ? {resolvedResource.previewKind} : null} @@ -734,7 +734,7 @@ export function FileViewerSheet({ return ( +

Welcome to Paperclip diff --git a/ui/src/components/ImageGalleryModal.tsx b/ui/src/components/ImageGalleryModal.tsx index 0d0d1f994b..72e0fafd80 100644 --- a/ui/src/components/ImageGalleryModal.tsx +++ b/ui/src/components/ImageGalleryModal.tsx @@ -93,7 +93,7 @@ export function ImageGalleryModal({ > {/* Top bar */}
- + {filename}
diff --git a/ui/src/components/InlineEntitySelector.tsx b/ui/src/components/InlineEntitySelector.tsx index 58fea66970..316899d16b 100644 --- a/ui/src/components/InlineEntitySelector.tsx +++ b/ui/src/components/InlineEntitySelector.tsx @@ -131,7 +131,7 @@ export const InlineEntitySelector = forwardRef { event.preventDefault(); diff --git a/ui/src/components/IssueAttachmentsSection.tsx b/ui/src/components/IssueAttachmentsSection.tsx index 55b5b8ea31..fa8dafee14 100644 --- a/ui/src/components/IssueAttachmentsSection.tsx +++ b/ui/src/components/IssueAttachmentsSection.tsx @@ -134,7 +134,7 @@ function MarkdownAttachmentCard({

Could not load markdown preview.

) : ( - + {data ?? ""} diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx index b7f79832a9..5ad15e5688 100644 --- a/ui/src/components/IssueBlockedNotice.tsx +++ b/ui/src/components/IssueBlockedNotice.tsx @@ -189,7 +189,7 @@ export function IssueBlockedNotice({ className="inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15" > {blocker.identifier ?? blocker.id.slice(0, 8)} - + {blocker.title} {recoveryAction ? : null} diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 2f8e6e4357..ebe5e2d8aa 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -347,7 +347,7 @@ describe("IssueChatThread", () => { const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null; expect(viewport).not.toBeNull(); expect(viewport?.className).not.toContain("overflow-y-auto"); - expect(viewport?.className).not.toContain("max-h-[70vh]"); + expect(viewport?.className).not.toContain("max-h-(--sz-70vh)"); act(() => { root.unmount(); @@ -1754,8 +1754,8 @@ describe("IssueChatThread", () => { ); expect(bubble).toBeDefined(); expect(bubble?.textContent).toContain("Here is my agent reply."); - expect(bubble?.className).toContain("max-w-[calc(100%-0.5rem)]"); - expect(bubble?.className).toContain("sm:max-w-[85%]"); + expect(bubble?.className).toContain("max-w-(--sz-calc-7)"); + expect(bubble?.className).toContain("sm:max-w-(--pct-85)"); // Neutral, not the human liveness-blue bubble. expect(bubble?.className).not.toContain("bg-(--status-task-in_progress)"); @@ -2647,19 +2647,19 @@ describe("IssueChatThread", () => { const dock = container.querySelector('[data-testid="issue-chat-composer-dock"]') as HTMLDivElement | null; expect(dock).not.toBeNull(); expect(dock?.className).toContain("sticky"); - expect(dock?.className).toContain("bottom-[calc(env(safe-area-inset-bottom)+20px)]"); + expect(dock?.className).toContain("bottom-(--sz-calc-8)"); expect(dock?.className).toContain("z-20"); const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null; expect(composer).not.toBeNull(); expect(composer?.className).toContain("rounded-md"); expect(composer?.className).not.toContain("rounded-lg"); - expect(composer?.className).toContain("p-[15px]"); + expect(composer?.className).toContain("p-(--sz-15px)"); const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null; - expect(editor?.dataset.contentClassName).toContain("max-h-[28dvh]"); + expect(editor?.dataset.contentClassName).toContain("max-h-(--sz-28dvh)"); expect(editor?.dataset.contentClassName).toContain("overflow-y-auto"); - expect(editor?.dataset.contentClassName).not.toContain("min-h-[72px]"); + expect(editor?.dataset.contentClassName).not.toContain("min-h-(--sz-72px)"); expect(editor?.dataset.fileDropTarget).toBe("parent"); act(() => { diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index d7f040c51d..f72b5dcf3a 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -228,7 +228,7 @@ const IssueChatCtx = createContext({ successfulRunHandoff: null, }); -const AGENT_COMMENT_BUBBLE_WIDTH_CLASS = "max-w-[calc(100%-0.5rem)] sm:max-w-[85%]"; +const AGENT_COMMENT_BUBBLE_WIDTH_CLASS = "max-w-(--sz-calc-7) sm:max-w-(--pct-85)"; export type IssueChatRunFinalizationAction = { id: "cancel" | "done"; @@ -1426,7 +1426,7 @@ function IssueChatUserMessage({ void onDeleteComment?.(commentId); }; const messageBody = ( -
+
{resolvedAuthorName} @@ -1441,7 +1441,7 @@ function IssueChatUserMessage({ "min-w-0 max-w-full overflow-hidden break-all rounded-2xl px-4 py-2.5", // Tail-hugging corner: flatten the bottom corner nearest the avatar so // the bubble points at it (bottom-right for the right-aligned human). - isCurrentUser ? "rounded-br-[4px]" : "rounded-bl-[4px]", + isCurrentUser ? "rounded-br-(--rad-4)" : "rounded-bl-(--rad-4)", queued ? "bg-amber-50/80 dark:bg-amber-500/10" : deleted @@ -2676,7 +2676,7 @@ function IssueChatMetadataRow({ return (
- + {icon}
{children}
@@ -3852,9 +3852,9 @@ const IssueChatComposer = forwardRef {coachVisible && plainNameCandidate ? ( @@ -4928,7 +4928,7 @@ export function IssueChatThread({
- +
diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 09effd65e1..4abf2de64e 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -762,7 +762,7 @@ export function IssueDocumentsSection({ const documentBodyShellClassName = "mt-3"; const documentBodyPaddingClassName = ""; - const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-(length:--fs-15) leading-7"; + const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-(--sz-220px) text-(length:--fs-15) leading-7"; const toggleFoldedDocument = (key: string) => { setFoldedDocumentKeys((current) => current.includes(key) @@ -850,7 +850,7 @@ export function IssueDocumentsSection({ placeholder="Markdown body" bordered={false} className="bg-transparent" - contentClassName="min-h-[220px] text-(length:--fs-15) leading-7" + contentClassName="min-h-(--sz-220px) text-(length:--fs-15) leading-7" mentions={mentions} imageUploadHandler={imageUploadHandler} onSubmit={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })} diff --git a/ui/src/components/IssueFiltersPopover.test.tsx b/ui/src/components/IssueFiltersPopover.test.tsx index 74a40304e4..4fe6a11c12 100644 --- a/ui/src/components/IssueFiltersPopover.test.tsx +++ b/ui/src/components/IssueFiltersPopover.test.tsx @@ -73,7 +73,7 @@ describe("IssueFiltersPopover", () => { const popoverContent = container.querySelector("[data-testid='popover-content']"); expect(popoverContent).not.toBeNull(); expect(popoverContent?.className).toContain("overflow-y-auto"); - expect(popoverContent?.className).toContain("max-h-[min(80vh,42rem)]"); + expect(popoverContent?.className).toContain("max-h-(--sz-calc-9)"); const layoutGrid = Array.from(popoverContent?.querySelectorAll("div") ?? []).find((element) => element.className.includes("md:grid-cols-3"), diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 71db9ce2a0..52619c07c5 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -132,7 +132,7 @@ export function IssueFiltersPopover({
diff --git a/ui/src/components/IssuePlanDecompositionsSection.tsx b/ui/src/components/IssuePlanDecompositionsSection.tsx index 4b0789cd92..ee9babea48 100644 --- a/ui/src/components/IssuePlanDecompositionsSection.tsx +++ b/ui/src/components/IssuePlanDecompositionsSection.tsx @@ -142,7 +142,7 @@ export function IssuePlanDecompositionsSection({ {child.identifier ?? child.id.slice(0, 8)} - + {child.title} diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index 2486584979..7a0f8d902a 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -388,7 +388,7 @@ export function IssueRecoveryActionCard({ data-recovery-state={cardState} data-recovery-kind={action.kind} className={cn( - "relative w-full overflow-hidden rounded-lg border text-sm shadow-[0_1px_0_rgba(15,23,42,0.02)]", + "relative w-full overflow-hidden rounded-lg border text-sm shadow-(--shadow-extract-8)", tone.containerClass, className, )} diff --git a/ui/src/components/IssueReferencePill.tsx b/ui/src/components/IssueReferencePill.tsx index 90ddd25e13..1e23262212 100644 --- a/ui/src/components/IssueReferencePill.tsx +++ b/ui/src/components/IssueReferencePill.tsx @@ -20,7 +20,7 @@ export function IssueReferencePill({ const classNames = cn( "paperclip-mention-chip paperclip-mention-chip--issue", "inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline", - issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring", + issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring", strikethrough && "opacity-60 line-through decoration-muted-foreground", className, ); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 4b06587a2e..41072ff903 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -122,7 +122,7 @@ export function IssueRow({ className={cn( "group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1", selected ? "hover:bg-transparent" : "hover:bg-accent/50", - checklistCurrentStep ? "border-l-2 border-l-primary bg-primary/5 pl-[calc(theme(spacing.2)-2px)] sm:pl-[calc(theme(spacing.1)-2px)]" : null, + checklistCurrentStep ? "border-l-2 border-l-primary bg-primary/5 pl-(--sz-calc-11) sm:pl-(--sz-calc-12)" : null, className, )} > @@ -137,7 +137,7 @@ export function IssueRow({ {issue.title}{titleSuffix} {checklistDependencyChips ? ( - + {checklistDependencyChips} ) : null} diff --git a/ui/src/components/IssueScheduledRetryCard.tsx b/ui/src/components/IssueScheduledRetryCard.tsx index bd6eef0c3e..79c1f6812e 100644 --- a/ui/src/components/IssueScheduledRetryCard.tsx +++ b/ui/src/components/IssueScheduledRetryCard.tsx @@ -145,7 +145,7 @@ export function IssueScheduledRetryCard({ )} - + {retryNow.isPending ? "Promoting scheduled retry" : isSuccessTransient diff --git a/ui/src/components/IssueSiblingNavigation.test.tsx b/ui/src/components/IssueSiblingNavigation.test.tsx index abcbdab325..972469d8eb 100644 --- a/ui/src/components/IssueSiblingNavigation.test.tsx +++ b/ui/src/components/IssueSiblingNavigation.test.tsx @@ -105,7 +105,7 @@ describe("IssueSiblingNavigation", () => { expect(links[0].className).toContain("rounded-lg"); expect(links[0].className).toContain("hover:bg-accent/50"); - expect(links[0].className).toContain("focus-visible:ring-[3px]"); + expect(links[0].className).toContain("focus-visible:ring-(length:--rad-3)"); expect(node.querySelector(".truncate")?.textContent).toBe("Previous sibling title"); }); diff --git a/ui/src/components/IssueSiblingNavigation.tsx b/ui/src/components/IssueSiblingNavigation.tsx index f5d2e7e480..7585e94771 100644 --- a/ui/src/components/IssueSiblingNavigation.tsx +++ b/ui/src/components/IssueSiblingNavigation.tsx @@ -60,7 +60,7 @@ function SiblingLink({ issueQuicklookAlign={direction === "previous" ? "start" : "end"} aria-label={`${ariaDirection}: ${identifier} - ${issue.title}`} className={cn( - "group min-w-0 rounded-lg border border-border bg-card px-3 py-2.5 text-left no-underline transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring", + "group min-w-0 rounded-lg border border-border bg-card px-3 py-2.5 text-left no-underline transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring", direction === "next" && "sm:text-right", className, )} diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 42557394f6..71116131d8 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -671,7 +671,7 @@ function QuestionOptionButton({ role={selectionMode === "single" ? "radio" : "checkbox"} aria-checked={selected} className={cn( - "w-full rounded-sm border px-4 py-3 text-left transition-colors outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", + "w-full rounded-sm border px-4 py-3 text-left transition-colors outline-none focus-visible:border-ring focus-visible:ring-(length:--rad-3) focus-visible:ring-ring/50", selected ? "border-sky-500/80 bg-sky-500/10 text-sky-950 dark:border-sky-400/80 dark:bg-sky-400/15 dark:text-sky-50" : "border-border/70 bg-transparent text-foreground hover:border-sky-500/70 hover:bg-sky-500/10 dark:hover:border-sky-400/70 dark:hover:bg-sky-400/10", @@ -858,7 +858,7 @@ function AskUserQuestionsCard({ {questions.map((question, index) => (
@@ -908,7 +908,7 @@ function AskUserQuestionsCard({ id={`${interaction.id}-${question.id}-other`} aria-expanded={otherActiveQuestions[question.id] === true} className={cn( - "text-sm font-medium underline underline-offset-4 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", + "text-sm font-medium underline underline-offset-4 transition-colors outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring/50", otherActiveQuestions[question.id] ? "text-sky-700 hover:text-sky-800 dark:text-sky-300 dark:hover:text-sky-200" : "text-muted-foreground hover:text-foreground", diff --git a/ui/src/components/JsonSchemaForm.tsx b/ui/src/components/JsonSchemaForm.tsx index 5ef8e90c2e..ced0eec9f8 100644 --- a/ui/src/components/JsonSchemaForm.tsx +++ b/ui/src/components/JsonSchemaForm.tsx @@ -597,7 +597,7 @@ const SecretField = React.memo(({ onChange={(e) => onChange(e.target.value)} placeholder={String(defaultValue ?? "")} disabled={disabled} - className="min-h-[140px] pr-10 font-mono text-xs" + className="min-h-(--sz-140px) pr-10 font-mono text-xs" aria-invalid={!!error} /> ) : ( @@ -614,7 +614,7 @@ const SecretField = React.memo(({ readOnly placeholder={String(defaultValue ?? "")} disabled={disabled} - className="min-h-[140px] pr-10 font-mono text-xs italic text-muted-foreground" + className="min-h-(--sz-140px) pr-10 font-mono text-xs italic text-muted-foreground" aria-invalid={!!error} /> )} @@ -837,7 +837,7 @@ const StringField = React.memo(({ onChange={(e) => onChange(e.target.value)} placeholder={String(defaultValue ?? "")} disabled={disabled} - className="min-h-[100px]" + className="min-h-(--sz-100px)" aria-invalid={!!error} /> ) : ( diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx index 70b2a8d9b4..34014270cb 100644 --- a/ui/src/components/KanbanBoard.tsx +++ b/ui/src/components/KanbanBoard.tsx @@ -153,7 +153,7 @@ function KanbanColumn({
+
{(!isEmpty || isOver) && ( @@ -188,7 +188,7 @@ function KanbanColumn({
diff --git a/ui/src/components/KeyboardShortcutsCheatsheet.tsx b/ui/src/components/KeyboardShortcutsCheatsheet.tsx index 10a6032682..94d8a6f061 100644 --- a/ui/src/components/KeyboardShortcutsCheatsheet.tsx +++ b/ui/src/components/KeyboardShortcutsCheatsheet.tsx @@ -64,7 +64,7 @@ const sections: ShortcutSection[] = [ function KeyCap({ children }: { children: string }) { return ( - + {children} ); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 8619a3247a..0b9c336b82 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -498,7 +498,7 @@ export function Layout() {
@@ -591,7 +591,7 @@ export function Layout() { // changes (e.g. switching skill-detail tabs) don't widen/shift // when the vertical scrollbar appears or disappears (PAP-10907). isMobile - ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" + ? "overflow-visible pb-(--sz-calc-14)" : "overflow-auto [scrollbar-gutter:stable]", )} > diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx index a83173a02c..8174c54d16 100644 --- a/ui/src/components/LiveRunWidget.tsx +++ b/ui/src/components/LiveRunWidget.tsx @@ -89,7 +89,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) { if (runs.length === 0) return null; return ( -
+
Live Runs @@ -143,7 +143,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
-
+
{ expect(html).toContain('data-workspace-file-link="true"'); expect(html).toContain('data-workspace-file-path="videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4"'); expect(html).toContain("videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4"); - expect(html).not.toContain("max-w-[38ch]"); + expect(html).not.toContain("max-w-(--sz-38ch)"); expect(html).not.toContain("paperclip-markdown-issue-ref"); expect(html).not.toContain('href="/issues/PAP-10306"'); }); diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 82bfeca97b..c710f00b33 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -890,7 +890,7 @@ describe("MarkdownEditor", () => { const options = Array.from(menu.querySelectorAll('button[type="button"]')); expect(options).toHaveLength(12); - expect(menu.className).toContain("max-h-[208px]"); + expect(menu.className).toContain("max-h-(--sz-208px)"); expect(menu.className).toContain("overflow-y-auto"); expect(menu.style.touchAction).toBe("pan-y"); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 2a38b2110e..007436474a 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -1140,7 +1140,7 @@ export const MarkdownEditor = forwardRef } }} className={cn( - "min-h-[12rem] w-full resize-none bg-transparent px-3 pb-3 pt-2 font-mono text-sm leading-6 outline-none", + "min-h-(--sz-12rem) w-full resize-none bg-transparent px-3 pb-3 pt-2 font-mono text-sm leading-6 outline-none", contentClassName, )} /> @@ -1306,7 +1306,7 @@ export const MarkdownEditor = forwardRef
{label} diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx index ca1c7a0f53..ae79790050 100644 --- a/ui/src/components/MobileBottomNav.tsx +++ b/ui/src/components/MobileBottomNav.tsx @@ -60,7 +60,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) { return (