diff --git a/.claude/agents/codemod-runner.md b/.claude/agents/codemod-runner.md new file mode 100644 index 0000000000..3fafe19b69 --- /dev/null +++ b/.claude/agents/codemod-runner.md @@ -0,0 +1,17 @@ +--- +name: codemod-runner +description: Writes and runs codemod scripts that replace hardcoded visual values with token references in ui/src/index.css. Use for Phase 2 of the design simplification run — mechanical refactors only. +tools: Read, Write, Edit, Bash, Glob, Grep +model: sonnet +--- + +You perform mechanical refactors via scripts, never hand-edits. Follow DESIGN.md at the repo root. + +Rules: + +- The token destination is ui/src/index.css (Tailwind v4), optionally a tokens.css imported by it. NEVER create a parallel token source. Tokens that must be runtime-tunable go in a NON-inline block — `@theme inline` bakes literals at build time. +- Where a hardcoded value EXACTLY matches an existing token, replace it with that token reference. Otherwise extract the value into a new token VERBATIM — no normalizing, rounding, or inventing a scale. Ugly values stay ugly. +- Every rewrite happens through a codemod script committed to scripts/ before it is run. Scripts must be idempotent and reviewable. +- Third-party style overrides that cannot use tokens go on a documented allowlist in the token source, each with an inline comment saying why. +- Verify after every script run: rg gates (zero hardcoded hex, zero arbitrary px/bracket values in ui/src/components/** and ui/src/pages/** outside the allowlist), pnpm typecheck, and the Storybook snapshot suite. Snapshots must match the Phase 0 baseline exactly. +- If a replacement cannot be made without visual change, skip it and record it in doc/design/TOKEN-AUDIT.md under "Needs human decision". diff --git a/.claude/agents/token-auditor.md b/.claude/agents/token-auditor.md new file mode 100644 index 0000000000..07561c8052 --- /dev/null +++ b/.claude/agents/token-auditor.md @@ -0,0 +1,16 @@ +--- +name: token-auditor +description: Scans ui/src/ for hardcoded visual values, duplicate components, and shadcn replacement candidates; produces doc/design/TOKEN-AUDIT.md and doc/design/COMPONENT-INVENTORY.md. Read-only on source — never modifies component files. Use for Phase 1 of the design simplification run. +tools: Read, Grep, Glob, Bash +model: sonnet +--- + +You inventory design-system debt in this repository. Follow DESIGN.md at the repo root; read doc/design/PRIOR-ART.md first — a previous audit found only 6 of ~220 drift sites were exact-value-mappable to existing tokens, so expect most hardcoded values to need new verbatim tokens. + +Your outputs (written to the repo root): + +1. TOKEN-AUDIT.md — every hardcoded color/spacing/radius/type/shadow value in ui/src/, with frequency, file locations, and near-duplicate clusters (e.g. 13/14/15px used interchangeably). For each value, note whether it EXACTLY matches one of the ~80 existing tokens in ui/src/index.css (semantic / brand / domain tiers — see DESIGN.md). Flag clusters for human review; never merge or normalize them. Include a "Needs human decision" section. + +2. COMPONENT-INVENTORY.md — all components under ui/src/components/ (24 primitives in ui/, ~277 feature components), their variants, and suspected duplicates with evidence (similar props, similar rendered output, copy-pasted origins). Include a "shadcn candidates" section: (a) custom components duplicating an available shadcn primitive, (b) installed shadcn components that drifted from the registry (npx shadcn@latest diff where available), (c) raw Radix/plain elements where an installed shadcn wrapper exists. For each, state the recommended replacement and expected visual impact. Recommendations only — merges and swaps happen in later human-approved runs, never this one. + +Never modify source files. Bash access is for read-only commands (rg, find, npx shadcn diff) and writing the two report files only. diff --git a/.github/workflows/storybook-visual.yml b/.github/workflows/storybook-visual.yml new file mode 100644 index 0000000000..325ca62696 --- /dev/null +++ b/.github/workflows/storybook-visual.yml @@ -0,0 +1,102 @@ +name: Storybook Visual + +on: + workflow_dispatch: + inputs: + update_snapshots: + description: "Generate updated snapshots and a baseline review bundle" + required: false + type: boolean + default: false + pull_request: + branches: + - master + types: + - opened + - reopened + - synchronize + - labeled + +concurrency: + group: storybook-visual-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + visual: + name: Storybook visual regression + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'storybook-visual')) + runs-on: ubuntu-latest + timeout-minutes: 35 + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Cache Storybook visual baseline archive + uses: actions/cache@v5 + with: + path: tests/storybook-visual/.cache + key: storybook-visual-baseline-${{ runner.os }}-${{ hashFiles('tests/storybook-visual/baseline-manifest.json') }} + + - name: Download Storybook visual baseline + run: pnpm storybook-visual:baseline download + + - name: Verify Storybook visual baseline + run: pnpm storybook-visual:baseline verify + + - name: Build Storybook + run: pnpm build-storybook + + - name: Run Storybook visual tests + if: ${{ github.event.inputs.update_snapshots != 'true' }} + run: npx playwright test --config tests/storybook-visual/playwright.config.ts + + - name: Generate updated snapshots for review + if: ${{ github.event.inputs.update_snapshots == 'true' }} + run: npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots + + - name: Pack updated baseline review bundle + if: ${{ github.event.inputs.update_snapshots == 'true' }} + run: pnpm storybook-visual:baseline pack + + - name: Upload Storybook visual report + uses: actions/upload-artifact@v7 + if: always() + with: + name: storybook-visual-report-${{ github.run_id }} + path: | + tests/storybook-visual/playwright-report/ + tests/storybook-visual/test-results/ + retention-days: 30 + if-no-files-found: warn + + - name: Upload updated baseline review bundle + uses: actions/upload-artifact@v7 + if: ${{ github.event.inputs.update_snapshots == 'true' }} + with: + name: storybook-visual-baseline-review-${{ github.run_id }} + path: tests/storybook-visual/baseline-review/snapshots.tgz + retention-days: 30 + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 09af52ce16..4284013138 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,11 @@ tests/e2e/test-results/ tests/e2e/playwright-report/ tests/release-smoke/test-results/ tests/release-smoke/playwright-report/ +tests/storybook-visual/.cache/ +tests/storybook-visual/.snapshots/ +tests/storybook-visual/baseline-review/ +tests/storybook-visual/test-results/ +tests/storybook-visual/playwright-report/ .superset/ .superpowers/ .claude/worktrees/ diff --git a/AGENTS.md b/AGENTS.md index ddefe527d6..cc341f6861 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,3 +220,7 @@ PR #2218 (`feat/external-adapter-phase1`) adds external adapter support. See roo - `createServerAdapter()` must include ALL optional fields (especially `detectModel`) - Built-in UI adapters can shadow external plugin parsers; external override pause/resume should restore the built-in parser. - Reference external adapters: Droid (npm); Hermes can also be tested as an override package. + +## 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 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/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000000..6eaa1e12cf --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,58 @@ +# Paperclip Design Principles + +**Status:** v0.3 — anchor document for design-language simplification. Governs structure, not brand. Brand values (color, type, iconography) are intentionally unspecified: they are being redesigned and will land as token values only. Nothing in `ui/` may hardcode them. Spacing/radius scales are likewise TBD pending the token audit (see Principle 3). + +Changes from v0.2: token layer location corrected to the repo's real source (`ui/src/index.css`); existing token tiers inventoried; snapshot-coverage scope bounded for Run 1; the issue→task copy rename moved out of the zero-visual-change run. + +## What this document is for + +Agents and humans modifying `ui/` treat this file as the source of truth for design decisions. Storybook is the verification surface — it documents the system; it does not define it. If a change conflicts with this document, change this document first (with review) or change the code. + +## Product stance + +Paperclip is an operational control plane: org charts, tasks, heartbeat runs, budgets, approvals, audit logs. The user is an operator scanning state and making decisions. Every screen should answer, in order: *what is happening, does it need me, what do I do about it.* Density in service of scanning beats whitespace in service of aesthetics — but density comes from information, never from chrome. + +## The token layer (where visual values live) + +The single token source is **`ui/src/index.css`** (Tailwind v4; there is no tailwind config file — tokens are CSS custom properties consumed via `@theme`). Do NOT create a parallel token source such as `ui/src/tokens/` — that would produce two sources of truth. If index.css grows unwieldy, extracted values may live in a `tokens.css` **imported by index.css** so the pipeline still has one root. + +Tailwind v4 gotcha: `@theme inline` bakes literal values at build time. Any token that must be runtime-tunable (theme editor, dark mode overrides) must be defined in a NON-inline block. + +Existing tiers already in index.css (~80+ tokens) — extraction maps to these on **exact value match** before minting anything new: + +1. **Semantic tier** — shadcn core set: `--background`, `--foreground`, `--card`, `--primary`, `--secondary`, `--muted`, `--accent`, `--destructive`, `--border`, `--input`, `--ring`, `--sidebar-*`, `--chart-1..5` (OKLCH, light/dark overrides). +2. **Brand tier** — agent gradients `--agent-1a/1b..10a/10b` (fixed hex) and status hues `--status-task-*` / `--status-agent-*` (WCAG-tuned; see inline comments). +3. **Domain tier** — match-chip tokens `--chip-match-*`, annotation highlights `--paperclip-doc-annotation-highlight-*`, plus motion/typography tokens. + +## Principles + +1. **One way to say each thing.** One component per job. One Button, one Card, one Badge, one Table, one EmptyState. Variants are props, not new components. Before creating a component, prove no existing one covers the job. +2. **Tokens are the only source of visual values.** All color, spacing, radius, type size/weight, shadow, and motion values come from the token layer. No hex, no raw px, no ad-hoc Tailwind arbitrary values (`p-[13px]`) in components. If a needed value doesn't exist, add a token — don't inline it. Tailwind palette classes (`bg-red-500`, `text-zinc-400`, etc.) ARE hardcoded values in spirit: they name a literal color, not a semantic role. They are in-scope debt scheduled for a dedicated future run (Run 4, cluster-by-cluster mapping to semantic tokens per doc/design/DECISION-SHEET.md B2) and are not currently gated by check-token-gates. Exception (doc/design/DECISION-SHEET.md B1 user ruling): first-party intentional one-off decoration on demo/UX-lab surfaces stays inline and allowlisted rather than minted as singleton tokens. +3. **Spacing routes through tokens; the scale comes later.** During simplification, extract every spacing and radius value verbatim into tokens — do not normalize, round, or invent a scale. The final scale is a design decision made by a human after reviewing the token audit. Structural rules apply now: vertical rhythm within a container uses one gap value, not per-element margins, and siblings never carry both margin and gap. +4. **Hierarchy through structure, not decoration.** Prefer position, size, and weight over borders, backgrounds, and dividers. Every border, divider, and background fill must justify itself; when in doubt, remove it. A screen should survive the removal of one visual layer. +5. **Status is systematic.** States like running / paused / blocked / awaiting-approval / over-budget map to a single semantic status token set used identically everywhere (badge, row, chart, log). An operator learns the vocabulary once. +6. **Machine values look machine-made.** IDs, costs, token counts, timestamps, and log output use the monospace token and consistent formatting helpers. Never format these ad hoc per screen. +7. **Words are part of the system.** One name per concept across the entire UI — the canonical term is *task* (never *issue* or *ticket* in copy, labels, or empty states). Buttons name the action ("Approve hire," not "Submit"). Errors say what happened and what to do. Empty states say what to do first. **Note:** enforcing the task rename is a visible change and is explicitly OUT of the zero-visual-change extraction run; it happens in its own follow-up run. +8. **Agent-modifiable by design.** The system must be changeable via instructions: single token source, lint rules that enforce it, and this document kept current. A correct change should be expressible as "edit tokens + run checks," not "visit 40 files." + +## Enforcement (what "compliant" means for the extraction run) + +- **Zero visual change is proven, not promised:** Storybook visual snapshots are baselined before any refactor, and all snapshots match baseline after it. A change that alters rendered output must be intentional and human-approved. +- **Baseline scope for Run 1:** the shared primitives in `ui/src/components/ui/` (each gets a story if missing — there are only ~24) plus the ~46 existing stories under `ui/storybook/stories/`. Do NOT attempt a story for every feature component (~277) in this run; full coverage is a later effort. +- Mechanical rewrites (value extraction, renames) are done via committed codemod scripts in `scripts/`, not hand-edits — reviewable once, repeatable forever. +- Token layer is the single source (`ui/src/index.css`, per above) consumed via CSS variables / Tailwind theme — never values copied into components. +- Lint/grep gates pass: zero hardcoded hex values, zero arbitrary spacing values, zero raw font-size declarations in `ui/src/components/**` and `ui/src/pages/**` outside the token layer and a documented allowlist (third-party overrides, intentional opt-outs commented inline). +- `pnpm build`, `pnpm typecheck`, and `pnpm build-storybook` pass. +- AGENTS.md links here and states the token-only rule. + +Aspirational (NOT gating this run): no duplicate components; every component has exactly one story covering its variants; all UI copy says "task". + +## Out of scope (do not do during simplification) + +No visual redesign, no new colors or typefaces, no layout restructuring, no new dependencies beyond snapshot tooling, no component consolidation/merges (audit + recommend only), no copy renames, no changes to server code or app logic. Simplification means fewer parts, same product. + +## Prior art (read before auditing) + +See `doc/design/PRIOR-ART.md` — a previous audit pass (PAP-280/283/284, on the `PAP-282-playground` branch, NOT on master) found that of ~220 hardcoded drift sites, only 6 were exact-value-mappable to existing tokens; expect the verbatim extraction to mint many new tokens that the human scale-collapse step later merges. It also drafted usage rules (radius tiers, CTA tiers, named type styles) that are good candidates for the post-audit scale decision. + +How-to guide for day-to-day UI changes: see `doc/design/CHANGING-THE-UI.md`. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 031e16800a..a0a6100cec 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -56,6 +56,26 @@ pnpm build-storybook These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`. +The Storybook visual regression suite uses external PNG baselines instead of +committed screenshots: + +```sh +pnpm test:storybook-visual +pnpm test:storybook-visual:update +``` + +`pnpm test:storybook-visual` downloads and verifies the baseline archive from +`tests/storybook-visual/baseline-manifest.json` before running Playwright. +Accepted visual changes should update the manifest metadata and publish a new +immutable archive with `pnpm storybook-visual:baseline pack` and +`pnpm storybook-visual:baseline upload`; do not commit generated PNG snapshots. + +PR visual checks are opt-in while the suite stabilizes. Add the +`storybook-visual` label to a PR, or run the `Storybook Visual` GitHub Actions +workflow manually, to produce downloadable Playwright report/test-result +artifacts. Normal PR visual runs use read-only repository permissions and do not +upload or mutate baseline objects. + ## UI Fonts And Screenshots The board UI ships its own sans-serif webfont assets in `ui/public/fonts/`. diff --git a/doc/design/AGENT-SESSIONS.md b/doc/design/AGENT-SESSIONS.md new file mode 100644 index 0000000000..5d83784689 --- /dev/null +++ b/doc/design/AGENT-SESSIONS.md @@ -0,0 +1,66 @@ +# Running an AI-agent session against the design system + +A guide for the *human* driving the session. You don't need to know the codebase — the system briefs the agent for you (AGENTS.md → `DESIGN.md` → `doc/design/`). Your job is to say what you want, look at pictures, and say yes or no. This document tells you how to do that well. + +## The golden rule + +**Describe outcomes, not implementations.** The whole point of the token system is that intent maps to small, safe changes: + +- ✅ "Make all the running indicators the same blue as the status chips." +- ✅ "The smallest text in the sidebar is hard to read — bump it one step." +- ✅ "Corners feel too sharp. Round everything slightly." +- ❌ "Edit line 1899 of IssueChatThread.tsx" (you'll be wrong, and it doesn't matter — the agent finds the sites) + +If your ask names a *feeling* ("too loud", "cramped", "inconsistent"), that's fine — expect the agent to translate it into a token change and show you the before/after to confirm the translation. + +## Pick the session size + +**Small (minutes) — a value change.** Colors, sizes, spacing, radius, one component's look. +> "In the Paperclip repo: make X look like Y. Show me before/after screenshots from the visual suite before you re-baseline anything." + +The agent should: edit token(s) → run `pnpm test:storybook-visual` → show you the diff images → only after your yes, run `test:storybook-visual:update`, publish the packed baseline archive from a trusted maintainer environment, and commit the code change + manifest update together. + +**Medium (an afternoon) — a retheme or a component-family restyle.** Ask for a **git worktree** so main stays untouched: +> "Create a worktree off master, apply shadcn preset `` as token values only (values-only — review the CLI's diff, revert scaffolding), reconcile the Paperclip status/agent color tiers, then build me a before/after gallery of the key surfaces." + +Review the gallery, iterate ("the dark red is too soft", "two different greens on toggles — one green"), then tell it to re-baseline and merge when you're satisfied. + +**Large (a day, unattended) — a bounded autonomous run.** Use `/goal` with a *measurable* finish line — the evaluator needs conditions a command can verify, not aspirations: +> Good conditions: "rg finds zero palette classes in ui/src/components", "the snapshot suite passes against the pinned external baseline", "pnpm check:token-gates reports 3/3 CLEAN". +> Bad conditions: "the UI feels cleaner", "design is more consistent". + +See `doc/design/GOAL-PROMPT.md` for a complete worked example (the run that built this system), including the phase structure and guardrails worth copying: work in a worktree, commit per phase, never re-baseline without human review, stop-and-report over partial application. + +## What to demand from the agent (your checklist) + +Hold every session to these five, regardless of size: + +1. **Pictures before permanence.** Never approve on description. The suite produces before/actual/diff images for every changed story — ask for them ("show me these visually before I decide"). For subtle changes, ask for full-resolution images, not compressed thumbnails. +2. **Proof commands, not claims.** "Done" means: `pnpm check:token-gates` 3/3 CLEAN, `pnpm typecheck` green, suite result stated as a number ("510/510" or "N intentional diffs pending your review"). If the agent says done without these, ask for the outputs. +3. **Baselines ride with the change.** Intentional visual change → updated manifest metadata in the same commit, after the packed archive is reviewed and published. An agent that updates baselines to silence a failure it can't explain is the one thing you never accept. +4. **Mechanical changes via scripts.** If it's touching 20+ files with the same rewrite, it should write an idempotent codemod in `scripts/` (existing `codemod-*.mjs` files are the pattern), not hand-edit. +5. **Decisions get written down.** Anything judgment-shaped (a mapping, an exception, a deferral) goes in `doc/design/DECISION-SHEET.md` with one line of rationale. + +## Reviewing like a designer + +- **Contact sheet**: the diff images under `tests/storybook-visual/test-results/` are your primary review surface; ask the agent to assemble them into a browsable before/after page (or use `npx playwright show-report` from `tests/storybook-visual/`). +- **Live test drive**: for big changes, ask for a running instance from the worktree — `pnpm paperclipai worktree init` once, then `PORT=3300 pnpm dev:once` gives an isolated Paperclip (own database, own config; your real instance is untouched). Click around; real use surfaces what screenshots can't. +- **Side-by-side Storybook**: old on one port, new on another (`pnpm storybook` in each checkout), flip tabs. +- Trust your eyes over the agent's summary. If something looks wrong, say so plainly ("the text in the red boxes is illegible") — vague feedback is fine, the screenshots give the agent the precision. + +## Safety rails (and the forbidden moves) + +- **Worktrees for anything nontrivial.** Master is never touched until an explicit merge; the scrap path is deleting the worktree. +- **Pushing is a separate, explicit step.** Nothing goes to GitHub until you say push. +- Forbidden, always: `shadcn apply --preset` (overwrites components); re-baselining snapshots to hide an unexplained diff; hardcoding a value because a token "doesn't fit" (add the token instead); "fixing" the scheduled-debt areas ad hoc (palette classes, card/pill consolidation — see DECISION-SHEET). + +## When something goes wrong + +- **Suite fails and the agent didn't change visuals** → it broke something; the diff image shows where. Don't let it re-baseline. +- **A test fails asserting an old literal value** → lockstep case: the assertion updates to the token form, in the same commit as an explanation. +- **A story flakes under full-suite load** → have it re-run that story in isolation before treating it as real (three known flakes are documented in DECISION-SHEET). +- **The agent is stuck or looping** → ask for a status report with the three proof commands; completed phases are committed, so restarting a session loses almost nothing. + +## The short version + +Say what you want in plain language → agent turns it into token/component edits → you review before/after screenshots → you say "ship it" or say what's off → repeat. Every round today's system was built with took under an hour. That loop *is* the design workflow now. diff --git a/doc/design/CHANGING-THE-UI.md b/doc/design/CHANGING-THE-UI.md new file mode 100644 index 0000000000..49ee2a721e --- /dev/null +++ b/doc/design/CHANGING-THE-UI.md @@ -0,0 +1,74 @@ +# Changing Paperclip's UI — a field guide + +How to make visual changes now that the design system exists. Written for everyone: designers, engineers, and AI agents (AGENTS.md points here via DESIGN.md). + +## The one-minute mental model + +Paperclip's look lives in **three layers**, and you almost always work in the first one: + +1. **Tokens** — every color, text size, spacing, radius, and shadow is a named value in `ui/src/index.css`. Change a token, and every surface using it follows. +2. **Components** — consume tokens, never raw values. One component per job (one Button, one Card, one ToggleSwitch). +3. **Screenshots** — 510 baseline images (255 stories × light/dark) downloaded into `tests/storybook-visual/.snapshots/` from the pinned external archive in `tests/storybook-visual/baseline-manifest.json`. They are the proof of what the UI looks like. Any visual change shows up as a screenshot diff; no visual change proves itself the same way. + +The rules live in [`DESIGN.md`](../../DESIGN.md) (repo root). The reasoning behind past decisions lives in [`DECISION-SHEET.md`](DECISION-SHEET.md). If you disagree with a rule, change `DESIGN.md` first (with review) — don't quietly diverge in code. + +## The three commands + +```bash +pnpm check:token-gates # am I allowed to write this? (no hardcoded values) +pnpm test:storybook-visual # what does my change look like? (diff vs baseline) +pnpm test:storybook-visual:update # accept my intentional changes as the new baseline +``` + +## Recipe 1 — change how something looks everywhere + +*"Make the corners rounder." "That amber is too loud." "Bump the smallest text size."* + +1. Find the token in `ui/src/index.css` (they're named and commented: `--radius`, `--status-task-todo`, `--text-micro`, …). +2. Change the value. +3. `pnpm test:storybook-visual` — it will "fail" on every affected story. That's the point: each failure writes a before/actual/diff image triplet into `tests/storybook-visual/test-results/`. Review them (or `npx playwright show-report` from `tests/storybook-visual/` for a browsable version). +4. Happy? `pnpm test:storybook-visual:update`, review the generated bundle under `tests/storybook-visual/baseline-review/`, publish it from a trusted maintainer environment, then commit the token edit **and** the updated manifest metadata together. + +Notable single-knob tokens: `--radius` drives the entire corner ladder (sm→4xl are derived); the `--status-task-*` / `--status-agent-*` family is the app-wide status vocabulary (chips, charts, bars, live dots all follow it). + +## Recipe 2 — retheme the whole app + +The core palette follows the shadcn token names, so a theme built at ui.shadcn.com/create applies as token values: + +```bash +cd ui && pnpm dlx shadcn@latest init --preset --force --no-reinstall +``` + +Then **review the git diff and keep only the CSS-variable value changes** — the CLI also tries to rewrite `components.json`, `lib/utils.ts`, and add dependencies; revert those (it once deleted 240 lines of our utils). Never use `shadcn apply --preset` (it overwrites component files). After the token diff is clean: Recipe 1 steps 3–4, plus a sanity pass on the Paperclip-specific tiers (agent gradients, WCAG-tuned status hues) for clashes. + +## Recipe 3 — build or style a component + +- **Values**: tokens only. No hex, no `text-[11px]`, no `p-[13px]`. If no token fits, **add a token** — that's a feature, not a workaround. +- **Type**: use the named ladder — `--text-nano` (10px) / `--text-micro` (11px) / Tailwind `text-xs` (12) / `--text-compact` (13) / `text-sm` (14). Letter-spacing: `--tracking-label` / `--tracking-eyebrow` / `--tracking-caps`. +- **Status**: anything that means running/idle/paused/error/todo/done/blocked uses the status system (`ui/src/lib/status-colors.ts` helpers or `--status-*` tokens). Liveness is always blue. +- **Primitives**: check `ui/src/components/ui/` and `doc/design/COMPONENT-INVENTORY.md` before writing a new component. Switches are `ToggleSwitch`; badges/chips route through `brandChipBadge`. +- **Give it a story.** New visual surface = new Storybook story = automatic screenshot coverage forever. +- `pnpm check:token-gates` before you push. If a value genuinely can't be a token (third-party config, canvas fills, intentional one-off decoration on demo pages), it goes on the allowlist **with an inline comment saying why**. + +## Recipe 4 — you changed something and snapshots failed + +That's the system working. Two cases: + +- **You meant it** → review the diffs (they're your design review), then `pnpm test:storybook-visual:update`, publish the packed baseline archive, and commit the manifest update with the change. A PR with visual changes but no baseline-manifest update is incomplete; baseline changes with no explanation are a red flag. +- **You didn't mean it** → you broke something. The diff images show you exactly where. Do not update the baseline to make it green. + +Three stories are known to flake under full parallel load (they pass in isolation — see DECISION-SHEET). Re-run a single story with `npx playwright test --config tests/storybook-visual/playwright.config.ts -g ""` before assuming a real failure. + +## For AI-agent sessions + +(Running a session as the human? See [`AGENT-SESSIONS.md`](AGENT-SESSIONS.md) — this section is instructions for the agent itself.) + +This system was built to be steered by instruction. "Make all running indicators blue" or "collapse these three grays into one" should land as a token edit or a small codemod plus a snapshot diff — not a manual hunt. If a change is mechanical and touches many files, write an idempotent script in `scripts/` (see `codemod-*.mjs` for the pattern) instead of hand-editing. DESIGN.md is loaded via AGENTS.md; follow it exactly, and record consequential choices in DECISION-SHEET.md. + +## What's deliberately not done yet (don't fix ad hoc) + +- **Tailwind palette classes** (`bg-red-500`, ~3,100 sites) — scheduled for a dedicated cluster-by-cluster conversion pass; piecemeal fixes will collide with it. +- **Hand-rolled cards/pills → `Card`/`Badge`**, sidebar agents-section unification — queued as a component-convergence pass with per-site snapshot verification. +- **ESLint ratchet** — will eventually enforce the token rules at lint time; until then `check:token-gates` is the gate. + +See `DECISION-SHEET.md` for the full ledger. diff --git a/doc/design/COMPONENT-INVENTORY.md b/doc/design/COMPONENT-INVENTORY.md new file mode 100644 index 0000000000..3554292745 --- /dev/null +++ b/doc/design/COMPONENT-INVENTORY.md @@ -0,0 +1,377 @@ +# COMPONENT-INVENTORY.md — Component Inventory (Phase 1) + +Run scope: `ui/src/components/` and `ui/src/pages/` on branch `design/token-extraction`. Read-only audit — no source files modified. + +**All consolidation, merge, and shadcn-swap recommendations in this document are RECOMMENDATIONS ONLY.** Per `GOAL-PROMPT.md` and `DESIGN.md`, no component merges, deletions, or swaps happen in this run. They become human-approved follow-up runs ("Run 2"/"Run 3"). + +## Counts + +| Area | Count | +|---|---:| +| Shared primitives (`ui/src/components/ui/`) | 24 | +| Feature components, flat (`ui/src/components/*.tsx`) | 178 | +| Feature components, nested subdirs (`access/`, `artifacts/`, `environment-variables-editor/`, `interrupt-handoff/`, `issue-output/`, `issue-properties/`, `routine-sections/`, `search/`, `timeline/`, `transcript/`) | 28 | +| **Feature components total** | **206** | +| Pages (`ui/src/pages/`, incl. `pages/secrets/`) | 73 | +| **Grand total** | **303** (roughly matches DESIGN.md's "24 + ~277") | + +--- + +## 1. Shared primitives — `ui/src/components/ui/` (24) + +All 24 checked against the live shadcn registry via `npx shadcn@latest diff` (network-available in this environment). Aggregate `diff` and per-component spot checks (`button`, `dialog`) both returned **"No updates found"** — these are currently in sync with the upstream registry source. + +| Component | File | Registry name | Variants (props) | Purpose | +|---|---|---|---|---| +| Alert Dialog | `alert-dialog.tsx` | `alert-dialog` | (Radix primitive passthrough) | Confirm/destructive-action modal | +| Avatar | `avatar.tsx` | `avatar` | `size` (sm/default), `shape` (circle/square via `data-shape`) | User/agent avatar with fallback initials | +| Badge | `badge.tsx` | `badge` | `variant` (default/secondary/destructive/outline) | Generic pill label | +| Breadcrumb | `breadcrumb.tsx` | `breadcrumb` | (Radix/plain nav passthrough) | Page breadcrumb trail | +| Button | `button.tsx` | `button` | `variant` (default/destructive/outline/secondary/ghost/link), `size` (default/sm/lg/icon/icon-sm/icon-xs) | CTA / action button, all tiers | +| Card | `card.tsx` | `card` | Header/Title/Description/Content/Footer subparts | Bordered content container | +| Checkbox | `checkbox.tsx` | `checkbox` | (Radix passthrough) | Boolean input | +| Collapsible | `collapsible.tsx` | `collapsible` | (Radix passthrough) | Expand/collapse section | +| Command | `command.tsx` | `command` | Dialog/Input/List/Item/Group/Separator/Shortcut | ⌘K palette primitive (backs `CommandPalette`) | +| Dialog | `dialog.tsx` | `dialog` | (Radix passthrough), expandable max-width transition | Modal dialog | +| Dropdown Menu | `dropdown-menu.tsx` | `dropdown-menu` | Item/CheckboxItem/RadioItem/Sub/Separator/Shortcut | Context/action menu | +| Input | `input.tsx` | `input` | (native input passthrough) | Text input | +| Label | `label.tsx` | `label` | (Radix passthrough) | Form field label | +| Popover | `popover.tsx` | `popover` | (Radix passthrough) | Floating panel | +| Radio Card | `radio-card.tsx` | **not a standard registry name** | (custom, card-shaped radio option) | Large selectable option card (onboarding/settings pickers) | +| Scroll Area | `scroll-area.tsx` | `scroll-area` | (Radix passthrough) | Styled scroll container | +| Select | `select.tsx` | `select` | (Radix passthrough) | Dropdown select | +| Separator | `separator.tsx` | `separator` | `orientation` | Divider line | +| Sheet | `sheet.tsx` | `sheet` | `side` (top/right/bottom/left) | Slide-in drawer | +| Skeleton | `skeleton.tsx` | `skeleton` | (plain div passthrough) | Loading placeholder | +| Tabs | `tabs.tsx` | `tabs` | (Radix passthrough) | Tab navigation | +| Textarea | `textarea.tsx` | `textarea` | (native passthrough) | Multi-line text input | +| Toggle Switch | `toggle-switch.tsx` | **not a standard registry name** (registry has `switch`) | (custom on/off toggle) | Boolean toggle control | +| Tooltip | `tooltip.tsx` | `tooltip` | (Radix passthrough) | Hover/focus hint | + +**Note:** `radio-card` and `toggle-switch` are not standard shadcn registry component names (the registry ships `radio-group` and `switch` respectively) — these were custom-built or heavily renamed/adapted rather than installed from the registry, so `shadcn diff` cannot check them against an upstream source. See shadcn-candidates section 4c. + +**Stock-value note:** several "arbitrary Tailwind bracket values" flagged in `TOKEN-AUDIT.md` inside these primitives (`checkbox.tsx` `rounded-[4px]`, `tooltip.tsx` `rounded-[2px]`, `command.tsx` `max-h-[300px]`, `avatar.tsx` `text-[10px]`) were verified to match the **current shadcn/ui registry source verbatim** — they are not local drift, just registry boilerplate that itself doesn't route through a token layer. Phase 2 will still need to touch them (DESIGN.md's gate is "zero arbitrary values in `ui/src/components/**`" with no carve-out for `components/ui/`), but they are not evidence of local customization. + +--- + +## 2. Feature components — `ui/src/components/` (206) + +Grouped by rough domain area. One line each; variants column is props-based where notable, blank where the component is largely propless/single-purpose. + +### 2.1 Issue / task surfaces (largest cluster) + +| Component | Purpose | +|---|---| +| `IssueRow.tsx` | Single row in a task list (status glyph, title, chips) | +| `IssuesList.tsx` | List/board container rendering many `IssueRow`s, sort/filter/group | +| `IssueColumns.tsx` | Column-layout config for the issues list/board | +| `IssueGroupHeader.tsx` | Section header when list is grouped (by status/project/assignee) | +| `IssueProperties.tsx` | 1-line re-export barrel → `issue-properties/IssueProperties.tsx` | +| `issue-properties/IssueProperties.tsx` | Full task detail properties panel (2,301 lines) — status, assignee, labels, project, dates | +| `IssueChatThread.tsx` | Task comment/chat thread (agent + human messages) | +| `IssueThreadInteractionCard.tsx` | Rich interaction card embedded in the chat thread (approvals, tool calls) | +| `IssueRecoveryActionCard.tsx` | Recovery-action prompt card in a stalled/errored task thread | +| `IssueScheduledRetryCard.tsx` | Scheduled-retry status card in task thread | +| `IssueRunLedger.tsx` | Run/cost ledger table for a task | +| `IssueMonitorActivityCard.tsx` | Monitoring/activity summary card on a task | +| `IssueBlockedNotice.tsx` | Banner when a task is blocked | +| `IssueAssignedBacklogNotice.tsx` | Banner when a backlog task gets assigned | +| `IssueDocumentAnnotations.tsx` / `issue-output/` variants | Doc-annotation highlight overlay on task documents | +| `IssueDocumentsSection.tsx` | Documents tab/section on task detail | +| `IssueAttachmentsSection.tsx` | Attachments tab/section on task detail | +| `IssuePlanDecompositionsSection.tsx` | Sub-task/decomposition list section | +| `IssueRelatedWorkPanel.tsx` | Related-issues panel | +| `IssueReferenceActivitySummary.tsx` / `IssueReferencePill.tsx` | Inline task-reference chip + activity rollup | +| `IssueSiblingNavigation.tsx` | Prev/next sibling task nav | +| `IssueLinkQuicklook.tsx` / `IssuesQuicklook.tsx` | Hover-preview popover for a linked task / task list | +| `IssueFiltersPopover.tsx` | Filter builder popover for the issues list | +| `IssueWorkspaceCard.tsx` | Card summarizing a task's execution workspace | +| `IssueContinuationHandoff.tsx` | Handoff-to-next-run card | +| `NewIssueDialog.tsx` | Create-task dialog | + +### 2.2 Agent / execution + +| Component | Purpose | +|---|---| +| `AgentCapsule.tsx` | The brand "capsule" motif — 3-state agent avatar (slot/configured/online) | +| `AgentActionButtons.tsx` | Start/stop/pause agent action row | +| `AgentBubbleActionRow.tsx` | Action row (copy/vote/timestamp/menu) under a conf-room agent chat bubble — **only one implementation found**, confirming PRIOR-ART's concern about a duplicate is resolved | +| `AgentConfigForm.tsx` | Agent configuration form (model, instructions, etc.) | +| `AgentIconPicker.tsx` | Icon picker for agent avatars | +| `AgentProperties.tsx` | Agent detail properties panel | +| `ActiveAgentsPanel.tsx` | Sidebar/dashboard panel of currently-running agents | +| `LiveRunWidget.tsx` | Live run status widget | +| `RunChatSurface.tsx` | Shared chat-surface shell used by both conf-room and task chat | +| `ClaudeSubscriptionPanel.tsx` / `CodexSubscriptionPanel.tsx` | Provider-specific subscription/quota panels — parallel, provider-specific (not a duplicate; see 3.4) | +| `ProviderQuotaCard.tsx` / `QuotaBar.tsx` | Generic quota display card / bar | + +### 2.3 Chat / composer + +| Component | Purpose | +|---|---| +| `ChatComposer.tsx` (380 lines) | Shared lean composer (conf-room + reused where a single-line/simple composer suffices) | +| `MarkdownEditor.tsx` (1,425 lines) | Rich MDX-based editor with mention autocomplete — task comment composer | +| `OnboardingChat.tsx` | Onboarding-flow chat surface | +| `CommentThread.tsx` | Generic comment-thread renderer (non-task contexts) | +| `MarkdownBody.tsx` / `WorkspaceFileMarkdownBody.tsx` | Rendered markdown output (read-only) — general vs. workspace-file-scoped | + +**KNOWN-DUPLICATES.md lead verified:** `ChatComposer` vs `MarkdownEditor`-based task composer — confirmed genuinely different in scope (380 vs 1,425 lines; ChatComposer has no mention-autocomplete/MDX machinery). Per KNOWN-DUPLICATES.md this was a deliberate non-unification (PAP-101) — **flagged in section 5, not recommended for merge.** + +### 2.4 Status / chips / badges + +| Component | Purpose | +|---|---| +| `StatusIcon.tsx` | Interactive status glyph + popover to change status (wraps `StatusGlyph`) | +| `StatusGlyph.tsx` | Pure SVG glyph renderer per status (sm/md/lg), no interactivity | +| `StatusBadge.tsx` | Custom `` pill components: `StatusBadge` (generic run/goal/approval), `AgentStatusBadge`, heartbeat capsule — **does not wrap the shadcn `Badge` primitive** (see shadcn candidates 4a) | +| `PriorityIcon.tsx` | Priority-level icon | +| `ExternalObjectStatusIcon.tsx` / `ExternalObjectStatusSummary.tsx` / `ExternalObjectPill.tsx` | External-object (linked PR/doc/etc.) status glyph, rollup summary, and inline pill — a third, deliberately separate status-presentation family | +| `BlockedReasonChip.tsx` | Chip explaining why a task is blocked | +| `SourceTrustBadge.tsx` / `SourceResolvedFoldBadge.tsx` / `SourceResolvedFoldCallout.tsx` | Trust/fold badges for external content sources | +| `ProductivityReviewBadge.tsx` | Review-status badge | + +**KNOWN-DUPLICATES.md lead verified:** StatusIcon / inline-mention chips / task chips are intentionally three separate systems (StatusIcon+StatusGlyph = task status glyph family; `ExternalObjectStatusIcon`/`Pill`/`Summary` = a second, external-object-specific family; mention chips in `lib/mention-chips.ts` + markdown CSS = a third, generic "chip in prose" family). **Documented here per instruction, not merged.** + +### 2.5 Sidebar / navigation + +| Component | Purpose | +|---|---| +| `Sidebar.tsx` / `SidebarShell.tsx` / `SecondarySidebar.tsx` / `InstanceSidebar.tsx` | Primary/secondary/instance-scoped sidebar shells | +| `SidebarSection.tsx` / `SidebarNavItem.tsx` | Sidebar section grouping + nav item row | +| `SidebarProjects.tsx` / `SidebarStarredProjects.tsx` | Project list / starred-project list in sidebar | +| `SidebarAgents.tsx` | Agent list in sidebar | +| `SidebarAccountMenu.tsx` / `SidebarCompanyMenu.tsx` | Account and company switcher menus | +| `SidebarServerInfo.tsx` | Server/instance info footer | +| `RequestCollapsedSidebar.tsx` | Collapsed-state sidebar for request/approval views | +| `RoutineSubSidebar.tsx` | Routine-scoped sub-sidebar | +| `MobileBottomNav.tsx` | Mobile bottom tab bar | +| `BreadcrumbBar.tsx` (uses `ui/breadcrumb`) | Page breadcrumb bar wrapper | +| `PageTabBar.tsx` | In-page tab bar | +| `CommandPalette.tsx` | ⌘K command palette (wraps `ui/command`) | + +**Sidebar text-size cluster** (cross-ref TOKEN-AUDIT.md 3.1): `Sidebar.tsx`, `SidebarNavItem.tsx`, `SidebarAgents.tsx`, `SidebarProjects.tsx`, `SidebarStarredProjects.tsx`, `SidebarAccountMenu.tsx` all independently use `text-[13px]` — six components implementing what looks like one shared "sidebar row label" intent as six separate arbitrary values. Flagged for the human type-scale decision, not merged here. + +### 2.6 Pipelines / routines / goals + +| Component | Purpose | +|---|---| +| `PipelineHealthWarnings.tsx` / `PipelineLivenessBanner.tsx` | Pipeline health/liveness banners | +| `PipelineItemBodyDocument.tsx` / `PipelineStageHistoryPanel.tsx` / `PipelineWorkReferences.tsx` | Pipeline stage detail panels | +| `PipelinesExperimentalGate.tsx` | Feature-flag gate wrapper for pipelines | +| `RoutineList.tsx` / `ManagedRoutinesList.tsx` | Routine list views | +| `RoutineActivityRow.tsx` / `RoutineHistoryTab.tsx` | Routine activity row + history tab | +| `RoutineRunVariablesDialog.tsx` / `RoutineVariablesEditor.tsx` | Routine run-variable input dialog/editor | +| `RoutineSaveBar.tsx` / `RoutineTriggerCard.tsx` | Routine save-bar + trigger config card | +| `routine-sections/editable-sections.tsx` (+2 more) | Editable routine-section blocks | +| `ScheduleEditor.tsx` | Cron/schedule editor | +| `GoalTree.tsx` / `GoalProperties.tsx` / `NewGoalDialog.tsx` | Goal hierarchy tree, detail panel, create dialog | + +### 2.7 Finance / budget + +| Component | Purpose | +|---|---| +| `AccountingModelCard.tsx` / `BillerSpendCard.tsx` / `FinanceBillerCard.tsx` / `FinanceKindCard.tsx` / `FinanceTimelineCard.tsx` | Various finance-dashboard cards — 5 distinct card shapes for different finance groupings (model/biller/kind/timeline); worth a human check for whether all 5 are truly distinct or 2-3 could share a base card | +| `BudgetIncidentCard.tsx` / `BudgetPolicyCard.tsx` / `BudgetSidebarMarker.tsx` | Budget incident, policy config, sidebar marker | +| `MetricCard.tsx` | Generic metric-display card | + +### 2.8 Files / documents / artifacts + +| Component | Purpose | +|---|---| +| `FileTree.tsx` | General file-tree renderer + `buildFileTree`/`FileTreeNode` model | +| `PackageFileTree.tsx` | Thin wrapper around `FileTree` (`wrapLabels` default) — reuses `FileTreeProps`, not a duplicate | +| `WorkspaceFileBrowser.tsx` | Execution-workspace file browser — **defines its own parallel tree-node model** (`WorkspaceFileTreeNode`, `buildWorkspaceFileTree`, `compareTreeNodes`) instead of reusing `FileTree`'s `FileTreeNode`/`buildFileTree` — see suspected duplicates (3.2) | +| `FileViewerSheet.tsx` | File content viewer sheet (wraps `ui/sheet`) | +| `FileTree.tsx`'s `parseFrontmatter` | Shared frontmatter parser (also exported, reused elsewhere) | +| `DocumentAnnotationLayer.tsx` / `DocumentAnnotationPanel.tsx` | Document highlight/annotation overlay + side panel | +| `DocumentDiffModal.tsx` | Document diff viewer modal | +| `DocumentFrameHeader.tsx` | Header chrome for an embedded document frame | +| `ArtifactFileChip.tsx` / `artifacts/ArtifactCard.tsx` / `artifacts/ArtifactGroupCard.tsx` / `ArtifactsPanel.tsx` | Artifact chip, card, grouped-card, and panel — internally consistent `rounded-[8px]` cluster (TOKEN-AUDIT 3.5) | +| `ImageGalleryModal.tsx` | Image gallery lightbox modal | + +### 2.9 Onboarding / access / identity + +| Component | Purpose | +|---|---| +| `OnboardingWizard.tsx` / `OnboardingWizardVariant.tsx` | Onboarding flow + an alternate variant — worth a human check on whether the variant is still live or leftover from an A/B (see 3.5) | +| `FrontDoor.tsx` | Landing/entry gate component | +| `CloudAccessGate.tsx` / `ConferenceRoomChatGate.tsx` / `PipelinesExperimentalGate.tsx` | Three separate feature-flag/access gate wrappers — same pattern (children-if-enabled), each hand-rolled per feature; candidate for a shared `FeatureGate` primitive (flagged, not built here) | +| `Identity.tsx` | Avatar + name identity chip (`deriveInitials` helper) | +| `MembershipAction.tsx` | Membership accept/decline action row | +| `access/` (2 files) | Access-request related components | +| `ReportsToPicker.tsx` / `ExecutionParticipantPicker.tsx` / `InlineEntitySelector.tsx` / `SearchableSelect.tsx` | Four distinct entity-picker components — overlapping purpose (pick a person/entity from a list); see suspected duplicates (3.3) | +| `SecretBindingPicker.tsx` / `environment-variables-editor/` (5 files) | Secrets/env-var binding UI | + +### 2.10 System / chrome / misc + +| Component | Purpose | +|---|---| +| `Layout.tsx` | App shell layout | +| `EmptyState.tsx` | **The single canonical empty-state component** — confirmed only one exists, matching DESIGN.md principle 1 | +| `SystemNotice.tsx` | Global system notice banner | +| `DevRestartBanner.tsx` | Dev-mode restart-required banner | +| `ToastViewport.tsx` + `context/ToastContext.tsx` | Fully custom toast notification system — no shadcn `sonner`/toast primitive installed (see shadcn candidates 4a) | +| `KeyboardShortcutsCheatsheet.tsx` | ⌘K-adjacent shortcuts help modal | +| `PageSkeleton.tsx` | Loading skeleton (wraps `ui/skeleton`) | +| `RouteErrorBoundary.tsx` | Route-level error boundary | +| `ThemeToggle.tsx` | Light/dark toggle | +| `CopyText.tsx` | Copy-to-clipboard text control | +| `EntityRow.tsx` | Generic entity list row (used across several list contexts) | +| `SwipeToArchive.tsx` | Mobile swipe-to-archive gesture wrapper | +| `ScrollToBottom.tsx` | Scroll-to-bottom floating button | +| `FoldCurtain.tsx` | Collapsible "show more" curtain/fade | +| `InlineEditor.tsx` | Generic inline-edit-in-place control | +| `JsonSchemaForm.tsx` | JSON-schema-driven dynamic form renderer | +| `TrustPresetSection.tsx` | Trust-level preset picker section | +| `WorktreeBanner.tsx` | Worktree-branding banner | +| `CompanyPatternIcon.tsx` | Canvas-rendered company pattern/avatar icon | +| `CompanySwitcher.tsx` / `CompanySettingsSidebar.tsx` | Company switcher + settings sidebar | +| `ProjectProperties.tsx` / `ProjectTile.tsx` / `ProjectWorkspaceSummaryCard.tsx` / `ProjectWorkspacesContent.tsx` | Project detail panel, tile, workspace summary card, workspace content | +| `ApprovalCard.tsx` / `ApprovalPayload.tsx` | Approval request card + payload renderer | +| `ExecutionWorkspaceCloseDialog.tsx` | Close-workspace confirm dialog | +| `ResponsibleUserDenialNotice.tsx` | Denial-notice banner | +| `BootstrapPendingPage.tsx` | Bootstrap-pending full-page state | +| `MissingPluginTabPlaceholder.tsx` | Placeholder when a plugin tab isn't installed | +| `StandaloneBrowserControls.tsx` | Standalone-mode browser chrome controls | +| `AsciiArtAnimation.tsx` | Decorative ASCII animation (boot/loading) | +| `OpenCodeLogoIcon.tsx` | Static logo icon | +| `StarToggle.tsx` | Star/favorite toggle button | +| `OutputFeedbackButtons.tsx` | Thumbs up/down feedback buttons on agent output | +| `KanbanBoard.tsx` | Kanban board view (alternate to `IssuesList` list view) | +| `agent-config-primitives.tsx` | Shared field primitives for agent config forms | +| `PropertiesPanel.tsx` | Generic properties-panel shell (used by Issue/Project/Agent/Goal Properties) | + +### 2.11 Nested subdirectories + +| Directory | Files | Purpose | +|---|---|---| +| `access/` | 2 | Access-request UI | +| `artifacts/` | 2 | `ArtifactCard`, `ArtifactGroupCard` | +| `environment-variables-editor/` | 5 | Env-var/secret editor rows and index | +| `interrupt-handoff/` | 1 | `InterruptHandoffViews.tsx` | +| `issue-output/` | 5 | Task output file tiles / sections | +| `issue-properties/` | 5 | Task properties panel (full impl, see 2.1) | +| `routine-sections/` | 3 | Editable routine section blocks | +| `search/` | 3 | Search result row, `HighlightedText`, `MatchSourceChip` | +| `timeline/` | 1 | `WorkTimelineChart.tsx` | +| `transcript/` | 1 | `RunTranscriptView.tsx` | + +--- + +## 3. Pages — `ui/src/pages/` (73) + +Grouped by area; one line each. + +| Page | Purpose | +|---|---| +| `Dashboard.tsx` | Home/overview dashboard | +| `IssueDetail.tsx` / `IssuesList` route pages | Task detail + list routes | +| `AgentDetail.tsx` / `Agents.tsx` | Agent detail + list | +| `ProjectDetail.tsx` / `Projects.test.tsx`-adjacent list page | Project detail + list | +| `Pipelines.tsx` / `PipelineSettings.tsx` | Pipeline list + settings | +| `Routines.tsx` | Routine list/detail | +| `Org.tsx` / `OrgChart.tsx` | Org directory + org chart visualization | +| `Costs.tsx` | Cost/budget dashboard | +| `Timeline.tsx` | Work timeline view (wraps `timeline/WorkTimelineChart`) | +| `Inbox.tsx` | Notifications/requests inbox | +| `Approvals.tsx` / `ApprovalDetail.tsx` | Approval queue + detail | +| `CompanySettings.tsx` / `CompanySkills.tsx` / `CompanyEnvironments.tsx` / `CompanyAccess.tsx` / `CompanyImport.tsx` / `CompanyExport.tsx` | Company-scoped settings sub-pages | +| `TeamCatalog.tsx` | Team/role catalog | +| `UserProfile.tsx` / `ProfileSettings.tsx` | User profile view + settings | +| `Secrets.tsx` / `secrets/*` (5 files) | Secrets management + sub-tabs (import-from-vault, definitions, my-secrets, presentation helpers, missing-banner) | +| `PluginManager.tsx` / `PluginSettings.tsx` | Plugin management + per-plugin settings | +| `AdapterManager.tsx` | Adapter (agent runtime) management | +| `CloudUpstream.tsx` / `CloudUpstreamUxLab.tsx` | Cloud-upstream connection page + its UxLab showcase twin | +| `InviteLanding.tsx` / `InviteUxLab.tsx` | Invite acceptance landing page + UxLab showcase twin | +| `BootstrapSetupUxLab.tsx` | Bootstrap-setup flow showcase | +| `ResponsibleUserDenialUxLab.tsx` | Denial-flow showcase | +| `SystemNoticeUxLab.tsx` | System-notice showcase | +| `IssueChatUxLab.tsx` / `RunTranscriptUxLab.tsx` | Chat + transcript showcases | +| `DesignGuide.tsx` | Design-system showcase/reference page | +| `BoardChat.tsx` / `BoardClaim.tsx` | Board (concierge) chat + claim flow | +| `NotFound.tsx` | 404 page | +| `CliAuth.tsx` | CLI auth handoff page | +| `JoinRequestQueue.tsx` | Join-request queue | +| `InstanceAccess.tsx` / `InstanceGeneralSettings.tsx` / `InstanceExperimentalSettings.tsx` | Instance-level settings pages | +| `ExecutionWorkspaceDetail.tsx` / `ProjectWorkspaceDetail.tsx` | Execution/project workspace detail pages | +| `Companies.tsx` | Multi-company switcher/list | +| `CompanyImport.tsx` / `CompanyExport.tsx` | Import/export company data | +| `RoutineDetail.tsx` | Routine detail route | + +**UxLab pages are real routes** (confirmed via `App.tsx` — `/ux-lab/*`, `/design-guide`), not build-excluded demo code, so all hardcoded values inside them are in-scope per DESIGN.md even though they're showcase surfaces rather than product screens a typical operator visits daily. + +--- + +## 4. Shadcn candidates + +**All items below are recommendations only — no swaps happen in this run.** + +### 4a. Custom components duplicating an available shadcn primitive + +| Custom component | Duplicates | Recommended replacement | Expected visual impact | +|---|---|---|---| +| `ToastViewport.tsx` + `context/ToastContext.tsx` | shadcn's `sonner`-based toast pattern (not currently installed) | Install `sonner`/toast primitive, migrate `useToastActions`/`useToastState` call sites to it | Low if the registry's toast is restyled to match current `toneClasses`/`toneDotClasses` tint system; the current implementation already has custom tone colors (`sky`/`emerald`/`amber`/`red`) that would need to carry over as variant props — a naive swap would look different unless those tones are preserved | +| `components/StatusBadge.tsx` (`StatusBadge`, `AgentStatusBadge`) | shadcn `Badge` primitive (installed, `ui/badge.tsx`) | Keep as a distinct component (status badges need the `.status-chip` color-mix mechanic Badge's variant system doesn't support) but consider having it render `` internally with a custom class rather than a bare ``, for consistency of base styles (focus ring, disabled states, etc.) | Low — internal implementation change only if done carefully; skip if it risks the WCAG-tuned status hues | +| Hand-rolled bordered-container `
`/`
    ` patterns (`rounded-md border bg-card`-shaped) found in ~26 files (`pages/AdapterManager.tsx`, `pages/TeamCatalog.tsx`, `pages/InstanceAccess.tsx`, `pages/BootstrapSetupUxLab.tsx`, `pages/ResponsibleUserDenialUxLab.tsx`, `pages/CliAuth.tsx`, `pages/BoardClaim.tsx`, `pages/BoardChat.tsx`, `pages/InstanceGeneralSettings.tsx`, `pages/ProjectWorkspaceDetail.tsx`, `pages/Timeline.tsx`, `pages/PluginManager.tsx`, `pages/JoinRequestQueue.tsx`, `pages/InstanceExperimentalSettings.tsx`, `App.tsx`, `components/BootstrapPendingPage.tsx`, `components/IssuePlanDecompositionsSection.tsx`, `components/BlockedInboxView.tsx`, `components/ApprovalCard.tsx`, + ~7 more) vs. shadcn `Card` (installed, only imported in 21 files) | `Card`/`CardContent` | Low-to-medium — many of these are `
      ` list wrappers, not literal card content; a swap would need per-site judgment, not a blanket codemod. Flagging the cluster, not asserting every site should change. | +| Hand-rolled pill/badge-shaped ``s (`rounded-full px-2 text-[...]`) outside `Badge` usage, ~34 files | `Badge` (installed, 35 files already use it) | `Badge` with a custom `className` for color | Low if colors are preserved as `className` overrides | +| `plugins/launchers.tsx` generic plugin-shell overlay (`role="dialog"`, hand-rolled backdrop `bg-black/45`, manual z-index math, `rounded-xl`/`rounded-2xl`) | `Dialog` / `Sheet` / `Popover` (all installed) | Case-by-case: this component multiplexes dialog/drawer/popover shell types from one plugin-host abstraction, which none of the three installed primitives do individually — a clean swap likely means keeping the multiplexer but delegating each `shellType` branch to the matching installed primitive instead of a fully custom `
      ` tree | Medium — this is the most structurally custom overlay in the codebase; recommend closer human review before treating it as a simple swap | + +### 4b. Installed shadcn components drifted from the registry + +`npx shadcn@latest diff` (run from `ui/`, network available) reported **"No updates found"** for the aggregate diff and for spot-checked `button` and `dialog` individually. **No drift detected** against the current registry for any of the 22 standard-named primitives. (`radio-card` and `toggle-switch` aren't standard registry names so `diff` cannot evaluate them — see 4c.) + +### 4c. Raw Radix/plain elements where an installed shadcn wrapper exists + +- **No raw `@radix-ui/*` imports were found outside `ui/src/components/ui/`** (`rg -l '@radix-ui' -g '*.tsx' -g '!components/ui/*'` returned zero files) — every Radix primitive in the app is properly routed through the `components/ui/` wrapper layer. This is a clean result; no action needed. +- One raw hand-rolled modal (`plugins/launchers.tsx`, `role="dialog"` on a plain `
      `) exists where `Dialog`/`Sheet` wrappers are installed — see 4a above, same finding, cross-listed here because it's also a "plain element where a wrapper exists" case. +- `radio-card.tsx` and `toggle-switch.tsx` are functioning as de facto custom primitives sitting in the `ui/` folder alongside real shadcn components, but they were not installed via the registry (no matching registry names). Recommend a human decide whether to (a) leave them as intentionally custom, documenting why the registry's `radio-group`/`switch` don't fit, or (b) evaluate swapping to the registry versions if the customization was incidental rather than deliberate. + +--- + +## 5. Suspected duplicates (with evidence) + +All of the below are **leads for human review**, not verdicts, per KNOWN-DUPLICATES.md's framing. + +### 5.1 Seed leads from KNOWN-DUPLICATES.md — verified + +| Lead | Finding | +|---|---| +| `ChatComposer` vs `MarkdownEditor`-based task composer | **Confirmed genuinely distinct** (380 vs 1,425 lines; different prop interfaces `ChatComposerProps` vs `MarkdownEditorProps`; MarkdownEditor owns mention-autocomplete machinery — `findMentionMatch`, `computeMentionMenuPosition`, `placeCaretAfterMentionAnchor` — that ChatComposer has none of). Matches KNOWN-DUPLICATES.md's note that this was a deliberate non-unification (PAP-101). **Needs human decision: re-confirm this split should remain permanent, or revisit unification now that both have matured further.** | +| `AgentBubbleActionRow.tsx` duplicate check | **Only one file found** (`components/AgentBubbleActionRow.tsx`). The concurrent-work duplicate PRIOR-ART/KNOWN-DUPLICATES.md warned about is not present on this branch — resolved. | +| `StatusIcon` vs inline-mention chips vs task chips | **Confirmed three separate, intentionally distinct systems**: (1) `StatusIcon`/`StatusGlyph` — task status glyph, drives from `--status-task-icon-*` tokens; (2) `ExternalObjectStatusIcon`/`ExternalObjectPill`/`ExternalObjectStatusSummary` — a parallel system for external (PR/doc/etc.) object status, independent color/severity model (`externalObjectStatusIcon`, `externalObjectStatusToneSeverity` in `lib/status-colors.ts`); (3) mention chips (`lib/mention-chips.ts` + `.paperclip-mention-chip` CSS in `index.css`) — generic "entity reference in prose" chip unrelated to status at all. Documented per instruction, **not merged.** | + +### 5.2 New leads found during this audit + +| Lead | Evidence | +|---|---| +| `FileTree.tsx` vs `WorkspaceFileBrowser.tsx` tree models | `FileTree.tsx` exports `buildFileTree`, `FileTreeNode`, `countFiles`, `collectAllPaths`, `parseFrontmatter`. `WorkspaceFileBrowser.tsx` independently defines its own `WorkspaceFileTreeNode`/`WorkspaceFileTreeFolderNode`/`WorkspaceFileTreeFileNode`, `buildWorkspaceFileTree`, `compareTreeNodes`, `finalizeTreeFolder` — a parallel tree-building implementation rather than reuse of `FileTree`'s exported helpers. `PackageFileTree.tsx` by contrast correctly wraps `FileTree` (reuses `FileTreeProps`). **Needs human decision**: was `WorkspaceFileBrowser`'s separate model a deliberate choice (different node shape needs — e.g. it may need workspace-specific metadata `FileTreeNode` lacks) or copy-paste-and-diverge drift? | +| `agentStatusBadge` vs `brandChipBadge` (`lib/status-colors.ts`) | Byte-for-byte identical maps for the 4 shared keys (gray/blue/amber/red — same hex, same dark-mode alpha suffixes); `brandChipBadge` additionally has `green`/`violet`. Cross-referenced in TOKEN-AUDIT.md section 1.1. **Recommend collapsing `agentStatusBadge` into `brandChipBadge`** (or making it an alias) since they are provably identical, not just similar. | +| Entity-picker family: `ReportsToPicker.tsx`, `ExecutionParticipantPicker.tsx`, `InlineEntitySelector.tsx`, `SearchableSelect.tsx` | Four components with overlapping "pick one entity from a searchable list" purpose. Not verified identical (each has domain-specific filtering — reports-to org hierarchy, execution participants, generic inline selection, generic searchable select) but the prop-surface overlap (all take an options list + selected value + onChange) is a plausible consolidation candidate. **Flagged, not verified as true duplicates** — would need a closer prop-by-prop diff in a follow-up run. | + +### 5.3 Corrections after closer inspection + +- `OnboardingWizardVariant.tsx` (10 lines) is **not** a duplicate of `OnboardingWizard.tsx` (1,786 lines) — it's a thin routing wrapper (`export function OnboardingWizardVariant() { return ; }`) left over from a since-retired experimental-flag variant (per its own doc comment: "Conference-room chat is now the only surface left behind `enableConferenceRoomChat`; onboarding stays available without that experimental flag"). Both are routed in `App.tsx`. **No action needed** — this is a naming leftover, not visual/logic duplication; candidate for a trivial rename-and-inline cleanup in a future non-visual refactor, out of scope here. +- `components/IssueProperties.tsx` is a 1-line re-export barrel (`export { IssueProperties } from "./issue-properties";`) — not a duplicate of `components/issue-properties/IssueProperties.tsx`, just a compatibility import path. + +### 5.4 Finance card family — needs closer human review + +`AccountingModelCard.tsx`, `BillerSpendCard.tsx`, `FinanceBillerCard.tsx`, `FinanceKindCard.tsx`, `FinanceTimelineCard.tsx` — five components with card-shaped, finance-dashboard purposes and naming that overlaps enough (`BillerSpendCard` vs `FinanceBillerCard`) to warrant a closer look than this audit had time for. Not verified as duplicates; flagged as a lead only. + +### 5.5 Feature-gate wrapper pattern (not a duplicate, but a repeated pattern) + +`CloudAccessGate.tsx`, `ConferenceRoomChatGate.tsx`, `PipelinesExperimentalGate.tsx` each independently implement the same "render children only if flag X is enabled, else render fallback" shape. Not byte-identical (each checks a different flag/hook), so not a strict duplicate, but a strong candidate for a shared `FeatureGate`/`ExperimentalGate` primitive that takes a flag-check function as a prop. Flagged as a recommendation, not built here. + +--- + +## 6. Needs human decision (required section) + +1. **`ChatComposer` vs `MarkdownEditor`-based composer** — KNOWN-DUPLICATES.md already flags this as deliberately unmerged (PAP-101). This audit confirms the split is real (not accidental) given the large capability gap. Human call: keep permanently split, or revisit now. **DECIDED (Run 2 review, DECISION-SHEET.md C1): keep the split, documented as deliberate — user re-confirmed after visual review.** +2. **`FileTree.tsx` vs `WorkspaceFileBrowser.tsx` independent tree models** — is `WorkspaceFileBrowser`'s separate `WorkspaceFileTreeNode` model justified by different data needs, or is it drift that should be refactored onto `FileTree`'s exported `buildFileTree`/`FileTreeNode`? Needs someone who knows both call sites' actual data shapes. **DECIDED (Run 2 review, DECISION-SHEET.md C2): investigate data-shape needs as Run 3 prep; refactor onto FileTree only if shapes align.** +3. **`agentStatusBadge` vs `brandChipBadge`** (`lib/status-colors.ts`) — these are provably byte-identical for their 4 shared keys. Recommend collapsing, but doing so touches every call site importing `agentStatusBadge`, so it's a human-approved Run 2/3 item, not automatic. **RESOLVED (Run 2 review, DECISION-SHEET.md A1): collapsed — `agentStatusBadge` had zero importing call sites and was deleted; `brandChipBadge` is the single map.** +4. **Entity-picker family** (`ReportsToPicker`, `ExecutionParticipantPicker`, `InlineEntitySelector`, `SearchableSelect`) — plausible consolidation candidate on prop-surface similarity alone; needs a closer prop-by-prop and behavior diff (not done in this pass) before any merge recommendation can be made with confidence. **DECIDED (Run 2 review, DECISION-SHEET.md C3): prop-by-prop diff queued as Run 3 prep; no merge without it.** +5. **Finance card family** (5 components, section 5.4) — needs a domain-knowledgeable human to confirm whether all 5 are truly distinct dashboard needs or 2-3 could share a base `FinanceCard`. **DECIDED (Run 2 review, DECISION-SHEET.md C4): keep all five; revisit only if a sixth appears.** +6. **Hand-rolled card-shaped containers vs. `Card` primitive** (~26 files) and **hand-rolled pill spans vs. `Badge`** (~34 files) — both are large, low-risk-looking consolidation opportunities, but "low risk" was assessed at a glance only; a real swap pass needs per-site visual verification (this is exactly what Storybook snapshots from Phase 0 would catch if these were touched). **DECIDED (Run 2 review, DECISION-SHEET.md C5): queued for Run 3 as the shadcn-swap list, with per-site snapshot verification.** +7. **`plugins/launchers.tsx` custom multiplexed overlay** — the most structurally custom modal-like component in the app; recommend a dedicated closer look before deciding whether/how to route it through `Dialog`/`Sheet`/`Popover`, since it currently does something none of the three do alone (switch shell type per plugin action). **DECIDED (Run 2 review, DECISION-SHEET.md C6): dedicated review task; excluded from Run 3.** +8. **`radio-card.tsx` / `toggle-switch.tsx` non-standard "shadcn" primitives** — confirm whether these were deliberately custom-built (and should stay documented as such) or are stale/incidental deviations from `radio-group`/`switch` that should be swapped in a later run. **DECIDED (Run 2 review, DECISION-SHEET.md C7): deliberately custom — documented as such; no swap.** +9. **`StatusBadge`/`AgentStatusBadge` not wrapping the installed `Badge` primitive** — worth a human call on whether unifying the base markup (while keeping the custom `.status-chip` color-mix mechanic) is worth the churn, or whether the current bespoke `` approach should just be documented as an intentional, permanent exception (similar to the StatusIcon/ExternalObject/mention-chip three-way split already documented). **DECIDED (Run 2 review, DECISION-SHEET.md C8): documented as an intentional exception — the WCAG-tuned `.status-chip` mechanic stays bespoke.** +10. **Toast system has no installed shadcn primitive to compare against** — `ToastViewport`/`ToastContext` is fully custom because no `sonner`/toast component is installed at all. Human call: install one and migrate, or formally document the custom toast as the system's permanent choice (it already has a working tone/variant system). **DECIDED (Run 2 review, DECISION-SHEET.md C9): decision deferred to Run 4, when the toast's palette colors get retokenized; sonner-behind-a-pushToast-facade is the alternative to evaluate then.** diff --git a/doc/design/DECISION-SHEET.md b/doc/design/DECISION-SHEET.md new file mode 100644 index 0000000000..e2cab973f3 --- /dev/null +++ b/doc/design/DECISION-SHEET.md @@ -0,0 +1,71 @@ +# DECISION-SHEET — Run 1 human review + +Every open question from TOKEN-AUDIT.md §8 + batch logs and COMPONENT-INVENTORY.md §6, each with a recommendation, blast radius, and where it lands. Statuses: **PENDING** → APPROVED / OVERRIDDEN (with note) / DEFERRED. + +## A. Quick wins — low risk, do in this review phase + +| # | Decision | Recommendation | Blast radius | Status | +|---|---|---|---|---| +| A1 | `agentStatusBadge` vs `brandChipBadge` byte-identical maps (status-colors.ts) | Collapse to `brandChipBadge`, re-point imports | code dedup, zero pixels | APPROVED — done 5ecc0f9e4: `agentStatusBadge` had ZERO importing call sites, so it was deleted outright (no re-pointing, no alias needed); `brandChipBadge` is the single map, `AgentBadgeColor` kept (subset of `BrandChipColor`) | +| A2 | Contrast-pair triplication (color-contrast.ts / worktree-branding.ts / ThemeContext.tsx) | One shared constant IF values are truly identical; verify per-pair first, keep semantically distinct ones separate | 3 files, zero pixels if identical | APPROVED — done 383460bb2: only `#f8fafc`/`#111827` is byte-identical across files → exported as `READABLE_TEXT_LIGHT`/`READABLE_TEXT_DARK` from color-contrast.ts, imported by worktree-branding.ts; ThemeContext's `#18181b`/`#ffffff` meta-theme-color pair, the DARK_BG/LIGHT_BG rgb-object compositing backgrounds, and worktree-branding's `#000000` parse fallback are semantically distinct → untouched | +| A3 | Project-color fallbacks `#6366f1` / `#64748b` (14 sites) | Two semantic tokens (`--project-seed`, `--project-none`) — file pattern shows two intents | rename-only, zero pixels | APPROVED — done 7e3e59db2: pure rename `--hex-6366f1`→`--project-seed` (7 sites) and `--hex-64748b`→`--project-none` (9 sites incl. ActivityCharts 'backlog') + index.css definitions, values unchanged | +| A4 | Test-file hardcoded hex (56 sites) | Leave alone; update lockstep only when asserted values actually change | none | APPROVED — policy adopted, no code change | +| A5 | `FileViewerSheet` half-migrated `var(--paperclip-code-highlight-*, fallback)` | Mint the two vars in index.css at the fallback values (identical pixels today; makes the intended token real) | 1 file | APPROVED — done 7929aabeb: both vars minted in the extracted-tokens :root block at exactly the former fallback values; chose to SIMPLIFY the Batch 4 `--code-highlight-*-resolved` wrappers to plain `var(--x)` (fallbacks now redundant; nothing sets the vars at runtime); FileViewerSheet.tsx call sites unchanged | +| A6 | "Liveness blue" chat bubble reusing `--status-task-in_progress` (semantic coincidence) | Decouple: mint `--liveness-blue` with same value so a future status-hue change doesn't drag the chat bubble along | 2 sites, zero pixels | APPROVED — done b76a7955a: `--liveness-blue: #2563eb` minted; IssueChatThread.tsx bubble class + IssueChatThread.test.tsx lockstep assertion re-pointed | + +## B. Policy calls + +| # | Decision | Recommendation | Status | +|---|---|---|---| +| B1 | One-off decorative gradients/shadows (5 production + UxLab; 38 arbitrary shadows, no `--shadow-*` tokens existed) | Allowlist as documented "intentional one-off decoration" (extend allowlist criteria beyond third-party); DELETE the ~20 never-reused singleton `--gradient-extract-*` tokens back to inline, keep only reused ones. Spirit over letter of principle 2 | APPROVED — middle path, executed: 27 demo-only tokens reverted inline (19 gradients: --gradient-extract-5,6,8-24; 8 shadows: --shadow-extract-15,16,17,19-23 — all consumed solely by *UxLab.tsx pages), 35 call sites restored to original bracket literals, definitions deleted, 4 UxLab pages allowlisted + criteria doc-comment extended to first-party intentional decoration. KEPT 22 production tokens (gradients 1,2,3,4,7,25,26 + 15 shadows) — NOTE: --shadow-extract-4/5 kept contrary to the audit's first cut because ChatComposer.tsx and IssueChatThread.tsx (production) consume them, not just ChatComposer.test.tsx | +| B2 | Tailwind palette classes (`bg-red-500` etc.) — 3,115 sites / 145 files | Own future run (Run 4): cluster-by-cluster mapping to semantic tokens, starting with status-adjacent colors; NOT wholesale now, NOT permanent exemption. Update DESIGN.md principle 2 to name palette classes explicitly | APPROVED — own Run 4 later | +| B3 | Micro type cluster 9–15px (730 sites) + letter-spacing 9 values (202 sites) | Adopt PRIOR-ART named ladder (map 9→10 nano, keep 10/11/12/13/14; 15→14; tracking → 3 steps) via contact-sheet review — executed in the preset-tune session, decided now | EXECUTED (pending contact-sheet) — scripts/codemod-type-ladder.mjs (idempotent, committed): 730 font-size sites -> --text-nano (10px, incl. 9->10) / --text-micro (11px, incl. 0.7rem) / text-xs (12px) / --text-compact (13px) / text-sm (14px, incl. 15->14); 202 tracking sites -> --tracking-label (0.08em) / --tracking-eyebrow (0.14em) / --tracking-caps (0.2em); all --fs-* and --ls-* definitions deleted. NOTE: text-xs/text-sm sites also pick up Tailwind scale line-height (contact-sheet reviewable). PRIOR-ART "sm 13" tier renamed --text-compact (name collides with Tailwind text-sm=14px) | +| B4 | Radius conflict (`--radius-lg/xl` = 0px vs stock 2xl/3xl) | Defer to preset session — it's a brand question. Candidate: PRIOR-ART monotonic 6/8/10/14/16 | DEFERRED to preset session | +| B5 | Chart palette vs canonical status hues (ActivityCharts in_progress = violet, elsewhere blue) | Re-point charts at `--status-task-*` (operator learns one vocabulary — DESIGN.md P5). Visible change → contact sheet | APPROVED & CLOSED — user approved on before/after contact sheet (Jul 6); 2 chart snapshots re-baselined, suite 510/510 on new baseline. Known trade documented: To-Do amber vs priority-Medium amber adjacency, revisit at preset session | + +## C. Component calls (from COMPONENT-INVENTORY.md §6) + +| # | Decision | Recommendation | Status | +|---|---|---|---| +| C1 | ChatComposer vs MarkdownEditor split | Keep split; document as deliberate in COMPONENT-INVENTORY | APPROVED — keep split, documented deliberate (user re-confirmed after visual review) | +| C2 | FileTree vs WorkspaceFileBrowser parallel tree models | Investigate data-shape needs in Run 3; refactor onto FileTree only if shapes align | APPROVED — investigate data-shape needs in Run 3 prep | +| C3 | Entity-picker family (4 components) | Prop-by-prop diff as Run 3 prep task; no merge without it | APPROVED — prop-by-prop diff as Run 3 prep task; no merge without it | +| C4 | Finance card family (5 components) | Keep; revisit only if a 6th appears | APPROVED — keep all five; revisit only if a 6th appears | +| C5 | Hand-rolled cards (~26 files) → `Card`; pills (~34 files) → `Badge` | Run 3 shadcn-swap list, per-site snapshot verification | APPROVED — queued Run 3 shadcn-swap list, per-site snapshot verification | +| C6 | `plugins/launchers.tsx` overlay | Dedicated review task; exclude from Run 3 | APPROVED — dedicated review task, excluded from Run 3 | +| C7 | radio-card / toggle-switch custom primitives | Document as deliberate custom; skip swaps | APPROVED — documented as deliberate custom; skip swaps | +| C8 | StatusBadge not wrapping Badge primitive | Document as intentional exception (WCAG-tuned .status-chip mechanic) | APPROVED — documented as intentional exception (WCAG-tuned .status-chip mechanic) | +| C9 | Toast system (no shadcn primitive installed) | Keep custom toast; document as permanent choice (working tone/variant system; sonner migration = churn without user-visible gain) | DEFERRED to Run 4 — decide when toast palette colors get retokenized; sonner-behind-a-pushToast-facade is the alternative to evaluate | +| C10 | FeatureGate wrapper pattern (3 near-identical gates) | Nice-to-have shared primitive; backlog, not a run | APPROVED — backlog nice-to-have, not a run | +| C11 | Sidebar agents section: hand-rolled rows (own spacing/icon colors, palette-blue liveness dot) instead of `SidebarNavItem`; only section that is collapsible | Run 3 item: unify rows onto `SidebarNavItem`, settle collapsibility policy across sections, liveness dot → canonical status blue. Wholesale shadcn Sidebar adoption REJECTED for now (app already has equivalent machinery incl. icon-rail height trick; highest-regression chrome) — re-evaluate as a dedicated item after Run 3 only if its behaviors (kbd shortcut, persisted state, mobile sheet) are wanted | APPROVED — user-spotted during :3300 test drive (Jul 6), queued for Run 3 | + +## Gallery feedback round 1 (preset-tune session, Jul 6) — executed + +User rulings from the tune-session gallery review; all intentionally visible, snapshots NOT re-baselined (fresh before/after triplets regenerated in tests/storybook-visual/test-results/ against the old baseline): + +1. **Dark destructive red reverted** — `.dark --destructive` back to master's original `oklch(0.637 0.237 25.331)` (preset's softer `oklch(0.704 0.191 22.216)` rejected); light mode untouched. +2. **Budget/quota BAR FILLS reuse status colors** — moving-fill elements only: healthy → `bg-(--status-task-done)`, warning → `bg-(--status-task-todo)`, exceeded/hard-stop → `bg-(--status-task-blocked)` in BudgetPolicyCard.tsx, QuotaBar.tsx (feeds ProviderQuotaCard/BillerSpendCard pages), Costs.tsx, CodexSubscriptionPanel.tsx (escalation tiers only). Inspected and deliberately LEFT: Org.tsx status dot (not a bar), BudgetPolicyCard chip washes/notice borders (not fills), CodexSubscriptionPanel healthy `bg-primary/70` + null `bg-zinc-700` (healthy tier uses brand primary by design — flagged as ambiguous, not emerald). +3. **RUNNING = status blue, not cyan/teal** — IssueChatThread running chip now composes `brandChipBadge.blue` (layout classes unchanged); RunTranscriptView running label uses new `runningLabelText` export (`text-[#1D4ED8] dark:text-[#2563EB]`, hexes kept in lib/status-colors.ts for gate cleanliness); `statusBadge.running` + `agentStatusDot.running` maps and AgentDetail `runStatusIcons.running` re-pointed cyan→blue. Deliberately LEFT + flagged: `externalObjectStatusIcon/Badge.running` (same-map collision — `open` is already blue there; documented UX-spec tone system), and the cyan "Live" branding family (LiveRunWidget theme, AgentDetail live-card border + Live pulse dots, DesignGuide Live sample) — "Live" is a distinct motif from RUNNING chips; note AgentDetail's mobile Live pill is already blue, so a dedicated Live-color decision is recommended. + +## Gallery feedback round 2 (preset-tune session, Jul 6) — executed + +1. **BudgetIncidentCard light-mode legibility (pre-existing bug)** — the hard-stop card's eyebrow/title/description/banner used dark-tuned red-50/100/200 text with no light variants over the light pink gradient. Light mode now uses red-600..950-tier text (matching the app's existing `text-red-700 dark:text-red-300` light-red-surface pattern); dark classes preserved verbatim behind `dark:`. Sibling fix: BudgetPolicyCard statusTone chips (hard_stop/warning/ok) + its red banner had the same dark-only text — same treatment. Gradient backgrounds (kept B1 tokens) untouched. +2. **Bar fills, remaining stragglers → status hues** — ClaudeSubscriptionPanel fillClass ("Current week Opus only" salmon red) and ProviderQuotaCard quota-window fills: red-400→`bg-(--status-task-blocked)`, amber/yellow-400→`bg-(--status-task-todo)`, green-400→`bg-(--status-task-done)`. Healthy `bg-primary/70` + null `bg-zinc-700` tiers unchanged (r1 ruling). Inspected, NOT a bar: BudgetSidebarMarker circular icon badge (left). +3. **Systematic cyan→status-blue liveness sweep** (~50 sites / 20 files; supersedes r1's "Live family left" note per user ruling): running-status tones (CommentThread, interrupt-handoff, runRetryState, AgentDetail run chip + status maps), live dots/pings (AgentDetail, OnboardingChat, ArtifactsPanel generating, ActiveAgentsPanel, IssueDetail Live pill, RunTranscriptView, IssueRunLedger live chip, DesignGuide sample), Live surfaces (LiveRunWidget theme, ActiveAgentsPanel Live-now box, AgentDetail live-card border), scheduled-retry family (IssueScheduledRetryCard, runRetryState, IssueRunLedger retry-pending), externalObjectStatus icon/badge `running` (now shares blue with `open`; liveness pulse differentiates — flagged), timeline "now" marker `#2dd4bf`→`#2563eb` (1.5px line vs `#5b9bf6` delegated bars, shape differentiates — flagged), and the liveness glow shadow tokens `--shadow-extract-1/11/14` value-edited `rgba(6,182,212,0.08)`→`rgba(37,99,235,0.08)` (kept-token VALUES changed, call sites unchanged). + Deliberately LEFT (non-liveness cyan, one-line reasons): xterm terminal cursor (CompanyEnvironments — terminal chrome); `on_demand` invocation-source chips (AgentDetail x2 + DesignGuide sample — source tag, not liveness); CompanySkills "Includes assets" chip (content-type tag); CompanyImport renamed-file mono text x2 (rename annotation); BlockedReasonChip `recovery_required` (blocked-reason category); IssueRunLedger "Advanced" outcome + "Silence snoozed" tones (outcome/pause semantics, not live); UxLab decorative gradients (B1 allowlisted decoration). + +## Gallery feedback round 3 (preset-tune session, Jul 6) — executed + +1+2. **Toggle unification** — hunt found exactly ONE second switch implementation: `ToggleField` in agent-config-primitives.tsx (hand-rolled h-5 w-9 pill, `bg-green-600` track — the "other green"). It now renders the canonical `ToggleSwitch` (3da1bbcc5 capsule, on = `var(--status-task-done)`), same props/behavior/testid. All other named suspects (AgentConfigForm, Instance*Settings, RoutineDetail, PipelineSettings, story fixtures) already used ToggleSwitch; remaining `bg-green-600` hits are buttons, not tracks. Every switch now renders the one capsule + one green. +3. **Agent-status chips → canonical colors** ("Org snippets and quick scan identity" = StatusBadge in control-plane-surfaces story): `statusBadge` agent keys now route through `brandChipBadge` families (bordered brand chips): running → blue, idle → GRAY (was yellow tint), paused → amber, **active → green (no canonical agent status exists — user-ruled mapping to the brand green/done family)**; error already rides the shared run-status red. brandChipBadge block moved above statusBadge in status-colors.ts (declaration order). Org.tsx's hand-rolled status-dot ternary now routes through `agentStatusDot` (same hues + gains the blue running dot). Left: Companies.tsx company-status chip (company entity, not agent), AgentConfigForm "current" model tag (not a status). +4. **Dark-text-on-light-wash sweep** — the flagged BudgetPolicyCard banner was already fixed in round 2 (screenshot predated it). Systematic sweep (bare `text-{red,amber,emerald,sky,green,yellow,cyan}-{50..300}` without dark: protection): **58 sites fixed across 14 files** (Dashboard budget alert, BudgetPolicyCard remaining-amount, AgentConfigForm banners x3, AgentDetail banners/chips x9, ProjectDetail x3, CompanyAccess, IssueDocumentsSection x6, RoutineHistoryTab x11, DocumentDiffModal x2, PipelineItemBodyDocument x2, RoutineSaveBar, DocumentFrameHeader, OutputFileTile x3, CompanySkills x10) — pattern: 50→950, 100→900, 200→800, 300→700 in light + original behind `dark:`, opacity suffixes preserved. Verified-safe leftovers: InviteLanding x4 (dark-styled standalone page), SidebarNavItem badge + DocumentAnnotationLayer tail + DevRestartBanner (solid dark/colored bg or dark:-protected). + +## Verification status (this review) + +- `pnpm check:token-gates` — re-run independently: 3/3 CLEAN (468 files, 31 allowlist entries). +- `pnpm typecheck` + full `pnpm test:storybook-visual` — re-running independently (in progress). +- Eyeball-pass note: the Phase 0 baseline was captured at the master fork point before any change, and the suite compares current rendering to it at `maxDiffPixels: 0` — pixel-equality with master-at-fork is machine-proven; side-by-side Storybook remains available on request (`pnpm storybook` here + `-p 6007` on master). + +## Tune session — CLOSED (Jul 6, 2026) + +User approved the complete new design language via gallery v4 + live test drive on the :3300 worktree instance ("ship it"). Merged origin/master (12 commits; one conflict — upstream deliberately removed the Wakes-on-confirm chip, deletion accepted). 296 snapshots re-baselined; gates 3/3 CLEAN; typecheck green; final suite verification run against the new baseline. Remaining roadmap: Run 3 (cards/pills/C11 sidebar + investigations + AgentDetail story), issue→task rename run, Run 4 (palette classes + toast), ESLint ratchet. diff --git a/doc/design/GOAL-PROMPT.md b/doc/design/GOAL-PROMPT.md new file mode 100644 index 0000000000..cc3611eb48 --- /dev/null +++ b/doc/design/GOAL-PROMPT.md @@ -0,0 +1,81 @@ +# /goal Prompt — Design Language Simplification, Run 1 (v3) + +Paste everything inside the code block below into Claude Code after typing `/goal`, from inside this worktree. Prerequisites already satisfied on this branch: DESIGN.md, PRIOR-ART.md, KNOWN-DUPLICATES.md at repo root; token-auditor + codemod-runner in `.claude/agents/`. + +v3: condensed under the /goal 4,000-character limit (v2 was 4,648 and got rejected). The paste block now carries only mission + DONE-WHEN + guardrails; the full phase spec lives in the "Phase spec" section below, which the run reads from this file on disk. + +``` +Refactor Paperclip's UI so every visual value flows through the single +existing token layer, with provably zero visual change, working only in +this git worktree on branch design/token-extraction. Never touch master +or other working trees. DESIGN.md at the repo root is the source of +truth; follow it exactly. Read PRIOR-ART.md before auditing. Execute +Phases 0-2 exactly as specified in the "Phase spec" section of +GOAL-PROMPT.md at the repo root (Phase 0 external baseline archive, +Phase 1 audit, Phase 2 codemod extraction), delegating Phase 1 to the +token-auditor subagent and Phase 2 to the codemod-runner subagent. +Commit after each phase and in small reviewable steps. + +DONE WHEN (all verified in this worktree): +1. The Storybook visual snapshot suite passes against a Phase 0 + baseline that was captured BEFORE any component change and pinned in + tests/storybook-visual/baseline-manifest.json — zero visual change. + Baseline scope: primitives in ui/src/components/ui/ + (add minimal stories only for those) plus the existing stories in + ui/storybook/stories/. No stories for the ~277 feature components. +2. ui/src/index.css (plus any tokens.css it imports) is the only token + source and components consume visual values only through it; no + parallel token source exists; runtime-tunable tokens live in a + NON-inline block. +3. rg gates pass: zero hex color literals, zero arbitrary px/bracket + Tailwind values, zero raw font-size declarations in + ui/src/components/** and ui/src/pages/** outside the documented + allowlist in the token source. +4. TOKEN-AUDIT.md and COMPONENT-INVENTORY.md exist at the repo root, + are current, and each contains a "Needs human decision" section. +5. pnpm build, pnpm typecheck, and pnpm build-storybook all exit 0. + +GUARDRAILS +- Preserve rendered output exactly. Reuse an existing token only on + EXACT value match; otherwise mint a new token with the value + VERBATIM — no normalizing, rounding, or inventing a scale. If a + replacement cannot be made without visual change, skip it and log it + under "Needs human decision". +- All value rewrites happen via codemod scripts committed to scripts/, + never file-by-file hand edits. +- No redesign, no layout changes, no new colors/typefaces, no component + merges or deletions (consolidation and shadcn swaps are + recommendations-only in COMPONENT-INVENTORY.md), no copy renames + (issue->task is a separate later run), no new dependencies beyond + snapshot tooling, no server or app-logic changes. +- If reality conflicts with DESIGN.md, record the conflict in + TOKEN-AUDIT.md instead of guessing. If a phase cannot be completed, + stop and report rather than partially applying it. +``` + +## Phase spec (referenced by the goal — the run reads this from disk) + +**Phase 0 — Baseline (before changing ANY component):** +- Set up Storybook visual snapshot testing (Storybook test-runner with image snapshots, or equivalent already-compatible tooling; Storybook lives at `ui/storybook/`, launched via `pnpm storybook`). +- Coverage scope: the shared primitives in `ui/src/components/ui/` (add a minimal story for any of the ~24 that lack one) plus all existing stories under `ui/storybook/stories/`. Do NOT write stories for the ~277 feature components in this run. +- Pack and publish the passing baseline snapshots through the external + Storybook visual baseline flow, then commit the manifest metadata. Every + later phase must keep snapshots matching this baseline. + +**Phase 1 — Audit (no code changes; delegate to token-auditor):** +- Produce `TOKEN-AUDIT.md` at the repo root: every hardcoded color/spacing/radius/type/shadow value in `ui/src/`, its frequency, file locations, and near-duplicate clusters (e.g. 13/14/15px used interchangeably). Flag clusters for human review — do NOT merge them. Cross-reference the ~80 existing tokens in `ui/src/index.css`: for each hardcoded value, note whether it exactly matches an existing token. +- Produce `COMPONENT-INVENTORY.md`: all components, their variants, and suspected duplicates with evidence (similar props, similar rendered output, copy-pasted origins). Include a "shadcn candidates" section: (a) custom components duplicating an available shadcn primitive, (b) installed shadcn components drifted from the registry (`npx shadcn@latest diff` where available), (c) raw Radix/plain elements where an installed shadcn wrapper exists. For each, state the recommended replacement and expected visual impact. ALL consolidation and swap items are RECOMMENDATIONS ONLY. + +**Phase 2 — Extraction (mechanical, via codemod; delegate to codemod-runner):** +- Token destination is `ui/src/index.css` (Tailwind v4; optionally a `tokens.css` imported by index.css). Do NOT create a parallel token source. Tokens that must be runtime-tunable go in a NON-inline block (`@theme inline` bakes literals). +- Exact-match values → existing token reference; everything else → new verbatim token. Ugly values stay ugly; they are the audit. +- Codemod scripts committed to `scripts/` perform the replacements; run them; no hand-edits. +- Third-party overrides that cannot use tokens go on a documented allowlist in the token source, each with an inline comment saying why. + +## After the run (human steps) + +1. Eyeball pass: `pnpm storybook` here and in the master tree (`-p 6007`), flip between tabs. +2. Read TOKEN-AUDIT.md; choose the real spacing/radius scale (PRIOR-ART.md has drafted rules to start from). +3. Tune tokens; snapshots now fail intentionally — the diff folders are your design-review contact sheet. This is also where the ui.shadcn.com/create preset lands, as token-value edits. +4. Review COMPONENT-INVENTORY.md; approve a merge list and shadcn-swap list → those become Run 2 and Run 3, each its own /goal. +5. Merge to master when satisfied (rebase first if master moved). Scrap path: `git worktree remove ../paperclip-design-simplify --force`. diff --git a/doc/design/KNOWN-DUPLICATES.md b/doc/design/KNOWN-DUPLICATES.md new file mode 100644 index 0000000000..82b08cc8c3 --- /dev/null +++ b/doc/design/KNOWN-DUPLICATES.md @@ -0,0 +1,14 @@ +# Known duplicates & off-limits areas (human-maintained) + +Seed list for the audit. Add components you already believe are redundant, and any screens the run must not touch. The auditor treats entries here as leads to verify, not verdicts. + +## Suspected duplicates / overlap (leads) + +- Chat composers: the shared `ChatComposer` vs `MarkdownEditor`-based task composer — deliberately NOT unified in a prior pass (PAP-101); audit the overlap but flag as "Needs human decision". +- Agent bubble action rows: `AgentBubbleActionRow.tsx` had two parallel implementations created by concurrent work at one point — verify only one remains. +- Status glyphs/chips: `StatusIcon` vs inline-mention chips vs task chips — intentionally separate systems per prior work; document, don't merge. + +## Off-limits in this run + +- `ui/src/components/theme-editor/` and anything under experimental theme/playground paths, if present on this branch. +- Server code, adapters, CLI — everything outside `ui/`. diff --git a/doc/design/PRIOR-ART.md b/doc/design/PRIOR-ART.md new file mode 100644 index 0000000000..695f805e46 --- /dev/null +++ b/doc/design/PRIOR-ART.md @@ -0,0 +1,21 @@ +# Prior art: PAP-280 design-token audit (branch `PAP-282-playground`, not on master) + +A previous audit/relink pass ran against this codebase in mid-2026. Its code never merged to master, but its findings are directly reusable by the token audit. Read this before Phase 1. + +## Key findings to inherit + +- **Drift is mostly NOT same-value-mappable.** The relink pass (commit `032d6c8db` on the branch) attempted to swap hardcoded values for existing semantic tokens *without visual change* and found only **6 exact-value swaps** possible (`text-muted-fg`, `rounded-md`) out of ~220 audited drift sites (~193 color / 23 radius / 7 type). Implication for Phase 2: expect to mint many new verbatim tokens; do not force-fit near-misses onto existing tokens — that changes pixels. +- **Token gap clusters identified** (commit `96689351d`): recurring un-tokenized needs were a code-surface background, an accent blue, and a muted feed text color — these became `--surface-code`, `--accent-blue`-style gap tokens on the branch. Audit should check whether the same clusters still dominate. +- **Tailwind v4 tunability gotcha** (learned the hard way): `@theme inline` bakes literals at build time; tunable tokens must live in a non-inline block. + +## Drafted usage rules (branch commit `6ba86cd4f` — candidates for the human scale decision, NOT current master state) + +- **Radius:** one monotonic scale `sm 6 / md 8 / lg 10 / xl 14 / 2xl 16 / full`. Assignments: sm=chips/badges/pills; md=buttons/inputs/menu items (default); lg=cards/popovers/panels; xl=dialogs/sheets/overlays; 2xl=hero/onboarding only; full=avatars/dots/capsules. Nested elements step down one tier from their container. (Master's current values differ — verify in the audit.) +- **CTA tiers:** three-tier button prominence — Primary (`default`/`destructive`): the single commit action per view; Secondary (`secondary`/`outline`): supporting actions; Tertiary (`ghost`/`link`): row actions, cancels, toolbar icons. +- **Type styles:** nine named intent styles (backed by `--text-*` tokens incl. `micro` 11px / `nano` 10px) instead of re-deriving `text-lg font-semibold` per call site. Color is a separate axis. +- **Drift-prevention contract:** no raw hex for chrome — use `background / card / muted / accent / border / muted-foreground`; when a needed value has no token, add a semantic token; intentional opt-outs (code/terminal blocks) carry a comment saying why, so a future re-link pass leaves them alone. + +## Reusable machinery on the branch + +- A theme playground / theme editor with portable `*.theme.json` export-import (whole-app live retheme, A/B compare). Useful later for the human scale-collapse and brand/preset tune steps — not needed for the extraction run. +- `.claude/skills/design-guide/` skill + `ui/src/pages/DesignGuide.tsx` showcase page (branch versions are richer than master's). diff --git a/doc/design/RUN3-PROMPT.md b/doc/design/RUN3-PROMPT.md new file mode 100644 index 0000000000..b378950890 --- /dev/null +++ b/doc/design/RUN3-PROMPT.md @@ -0,0 +1,96 @@ +# Run 3 — Component convergence: guide + /goal prompt + +Executes the approved component-convergence scope from `DECISION-SHEET.md`: C5 (hand-rolled cards → `Card`, pills → `Badge`), C11 (sidebar agents rows → `SidebarNavItem`), C2/C3 (investigate-first items), plus the AgentDetail story coverage gap. + +## Which directory? + +**If PR #9134 has merged to master (preferred):** create a fresh worktree from the main checkout — + +```bash +cd ~/Projects/DEV/paperclip +git fetch origin && git worktree add ../paperclip-run3 -b design/component-convergence origin/master +cd ../paperclip-run3 && pnpm install +``` + +**If #9134 is still open:** reuse the existing worktree (it has everything installed and the baselines present) on a stacked branch — + +```bash +cd ~/Projects/DEV/paperclip-design-simplify +git checkout -b design/component-convergence +``` + +Either way: launch `claude` from inside that directory, type `/goal`, paste the block below. The session inherits DESIGN.md and these docs automatically. + +## What to expect + +- Mostly unattended, roughly a day. Unlike Run 1, small visible deltas are *expected* (a border tone here, 1px of padding there) — the guardrails force every one to be exported for your review and individually revertable. +- After the goal clears: open `doc/design/run3-review/` (before/after images for every story the run changed), skim, and revert any commit whose look you reject. Each conversion is its own commit. + +## The /goal paste block + +``` +Converge Paperclip's duplicated hand-rolled UI onto the shared +primitives, per the approved scope in doc/design/DECISION-SHEET.md +items C2, C3, C5, C11. DESIGN.md is the source of truth; read +doc/design/CHANGING-THE-UI.md and doc/design/RUN3-PROMPT.md first. +Work only in this worktree/branch; never touch master. Small +reviewable commits: one component-conversion unit per commit. + +SCOPE +1. C5a: every hand-rolled card container (rounded-* + border + + bg-card pattern) in ui/src/components/** and ui/src/pages/** + converts to the Card primitive, preserving layout and behavior. +2. C5b: every hand-rolled pill span converts to Badge (or the + status-chip system where it encodes status), same preservation. +3. C11: SidebarAgents rows render via SidebarNavItem (keep agent + status dot + wake affordances; dot uses --status-agent-running). + Apply one collapsibility policy across sidebar sections and + record it in DECISION-SHEET.md C11. +4. C2/C3: investigate (do not merge by default): C2 WorkspaceFileBrowser + vs FileTree tree models; C3 the four entity pickers. Write verdicts + with evidence into COMPONENT-INVENTORY.md. Execute a merge ONLY if + the verdict is copy-paste drift with identical data shapes and the + merge preserves behavior; otherwise document keep-as-is rationale. +5. Add a Storybook story for the AgentDetail page (realistic fixture, + light+dark) so it joins the visual suite. + +VERIFICATION DISCIPLINE (small visual deltas are EXPECTED) +- Before starting: run the suite once to confirm 510/510 green. +- Per conversion commit: run the affected stories; if pixels changed, + copy each changed story's expected/actual/diff triplet into + doc/design/run3-review// and note the story ids + one-line + justification in the commit message. Deltas must be small + (primitive-level: borders, radii, padding) — a layout shift or + color-meaning change means STOP that conversion, revert it, and + record it under "Needs human decision" in DECISION-SHEET.md. +- A site that cannot adopt the primitive without breaking behavior: + skip it, comment why inline, list it in the report. + +DONE WHEN (all verified in this worktree) +1. rg finds no hand-rolled card-container or pill-span patterns in + ui/src/components/** or ui/src/pages/** outside documented inline + allowlist comments; converted sites import Card/Badge/SidebarNavItem. +2. C2/C3 verdicts written in COMPONENT-INVENTORY.md; C11 policy + recorded in DECISION-SHEET.md. +3. AgentDetail story exists and renders in the suite. +4. pnpm check:token-gates 3/3 CLEAN; pnpm typecheck green; + pnpm --filter @paperclipai/ui build exit 0. +5. Full visual suite passes against the updated baseline, and + doc/design/run3-review/ contains the triplets for every story + whose baseline changed, committed. +6. All vitest suites for touched components pass, assertions updated + in lockstep where they referenced old markup/classes. + +GUARDRAILS +- Preserve behavior exactly: props, handlers, a11y roles, test ids. +- No new dependencies. No palette-class conversions (Run 4's job). +- Never re-baseline a diff you cannot justify in the commit message. +- If a phase cannot complete, stop and report rather than partially + applying it. +``` + +## After the run + +1. Review `doc/design/run3-review/` — approve or `git revert` per commit. +2. Check the "Needs human decision" additions in DECISION-SHEET.md. +3. Merge (or stack the PR), then Run 4 (palette classes) and the ESLint ratchet are all that remain. diff --git a/doc/design/TOKEN-AUDIT.md b/doc/design/TOKEN-AUDIT.md new file mode 100644 index 0000000000..c3b2cf5360 --- /dev/null +++ b/doc/design/TOKEN-AUDIT.md @@ -0,0 +1,428 @@ +# TOKEN-AUDIT.md — Design Token Drift Audit (Phase 1) + +Run scope: `ui/src/` only, on branch `design/token-extraction`. Read-only audit — no source files were modified. See `DESIGN.md` for the token layer contract and `PRIOR-ART.md` for prior findings (only 6/220 exact-mappable). + +**Method:** ripgrep over `ui/src/**/*.{ts,tsx,css}`. Counts below are current as of this run (2026-07-06), not the PRIOR-ART numbers. Test files (`*.test.ts(x)`) are included in totals where noted but broken out separately — they are not shipped UI, but they do encode the same hardcoded values and will need companion updates if Phase 2 changes the values they assert against. + +## Executive summary + +| Category | Site count | Files | Existing-token exact matches | +|---|---:|---:|---:| +| Hex color literals (`#...`) | 206 (150 source / 56 test-only) | 51 | ~14 sites (case-insensitive match only — see below) | +| `rgb()/rgba()/hsl()/hsla()` literals | 52 | ~30 | 0 (all are opaque decorative gradients / canvas fills) | +| Tailwind arbitrary color brackets (`bg-[#..]`, `text-[#..]`, `border-[#..]`) | ~72 | ~10 | overlaps with hex count above (same literals, bracket-wrapped) | +| Tailwind palette utility classes (`bg-red-500`, `text-amber-600`, etc. — not literal hex but not project tokens either) | **3,115** | **145** | 0 (Tailwind's built-in oklch palette, never routed through `index.css`) | +| `text-[Npx]` arbitrary font-size | 730 | 137 | 0 | +| `tracking-[N em]` arbitrary letter-spacing | 202 | ~40 | 0 | +| `w-[...]` / `h-[...]` arbitrary size | 124 / 166 | ~60 combined | 0 (mostly px/rem/vh/dvh/calc, some `var(--radix-*)`) | +| `min-w/max-w/min-h/max-h-[...]` | 194 | ~45 | 0 | +| `p*/m*-[...]` arbitrary spacing | 14 | 9 | 0 (mostly `env(safe-area-inset-*)` and `calc(theme(spacing.N)-2px)`) | +| `gap-[...]` arbitrary | 8 | 2 | 0 | +| `rounded-[...]` arbitrary radius | 25 | 10 | 0 (4 of these are **stock shadcn registry values**, not local drift — see below) | +| `shadow-[...]` arbitrary shadow | 38 | 21 | 0 | +| inline `style={{ }}` literals with a hardcoded value | ~90 sites across 58 files | 58 | see cross-ref | +| Chart/status color constant arrays (`.ts`) | 6 files, ~35 distinct hex | 6 | 1 partial (`#2563eb`) | + +Total distinct hardcoded-value **sites** (excluding the 3,115 Tailwind-palette-class sites, which are reported separately because enumerating each is not useful) is roughly **1,550** across color/spacing/radius/type/shadow categories. Including the Tailwind palette-class sites, the true "every visual value should be a token" count DESIGN.md principle 2 implies is **~4,650+**. This is far larger than PRIOR-ART's ~220 — either the codebase grew substantially since that audit, or that audit undercounted Tailwind arbitrary values and palette classes (it reported "~193 color / 23 radius / 7 type" — consistent with counting only hex/oklch color literals and rounded-brackets, not `text-[Npx]` or Tailwind palette-class usage). **Flagged for human decision**: whether Tailwind palette-class usage (`bg-red-500` etc.) is in scope for Phase 2 token extraction, since DESIGN.md principle 2 says "no hex, no raw px" but doesn't explicitly call out named Tailwind color classes, and mechanically tokenizing 3,115 sites is a materially bigger job than the rest of this audit combined. + +--- + +## 1. Color literals + +### 1.1 Hex colors — exact/near matches against `index.css` tokens + +`index.css` status/brand hex values (all defined in `:root`, mode-independent — see DESIGN.md brand tier): + +``` +--status-agent-idle: #a8aeb2 --status-task-backlog: #a8aeb2 --status-task-cancelled: #a8aeb2 +--status-agent-running: #2563eb --status-task-in_progress: #2563eb +--status-agent-paused: #f59e0b --status-task-todo: #f59e0b +--status-agent-error: #dc2626 --status-task-blocked: #dc2626 +--status-task-in_review: #7c3aed +--status-task-done: #22c55e +--status-task-icon-backlog: #52585d --status-task-icon-todo: #cc7a00 --status-task-icon-done: #16a34a +--status-task-icon-cancelled: #52585d +(.dark overrides) --status-task-icon-backlog: #9a958a -todo: #fbbf24 -in_review: #9474f0 -done: #34d06f -cancelled: #9a958a +--paperclip-doc-annotation-highlight-*: #fef08a / #fde047 / #fef9c3 (light), #a16207 / #ca8a04 / #854d0e / #713f12 (dark) +--agent-1a..10b: 20 fixed brand hex (gradient stops), unique, not reused elsewhere in ui/src +``` + +**Exact matches found (case-insensitive value match) in `ui/src/lib/status-colors.ts`:** + +| Hardcoded value | Site | Token it matches | Safe to swap without visual change? | +|---|---|---|---| +| `#A8AEB2` | `status-colors.ts:112,120,144` | `--status-agent-idle` / `--status-task-backlog` (`#a8aeb2`) | Yes — value identical, case differs only | +| `#2563EB` | `status-colors.ts:113,121,145` | `--status-agent-running` / `--status-task-in_progress` (`#2563eb`) | Yes | +| `#F59E0B` | `status-colors.ts:114,122,146` | `--status-agent-paused` / `--status-task-todo` (`#f59e0b`) | Yes | +| `#DC2626` | `status-colors.ts:115,123,149` | `--status-agent-error` / `--status-task-blocked` (`#dc2626`) | Yes | +| `#22C55E` | `status-colors.ts:147` | `--status-task-done` (`#22c55e`) | Yes | +| `#7C3AED` | `status-colors.ts:148` | `--status-task-in_review` (`#7c3aed`) | Yes | +| `#52585D` | `status-colors.ts:112,144` | `--status-task-icon-backlog` (light) (`#52585d`) | **Needs human decision** — token is mode-dependent (dark override `#9a958a`); this hardcoded value is used as a **fixed** chip background/text/border tint across both modes (it appears inside a class string with a separate literal `dark:` variant already, e.g. `dark:text-[#9A958A]`). The light value happens to equal the icon token; the surrounding hardcoded `dark:` variant (`#9A958A`) also happens to equal `--status-task-icon-backlog`'s dark override. So the *pair* is exactly reproducible via the token + its `.dark` override, but only if both the light and dark literals are replaced together — a partial swap would break one mode. | +| `#9A958A` | `status-colors.ts:112,144` | `--status-task-icon-backlog` `.dark` override (`#9a958a`) | Same caveat as above — pair with `#52585D` | + +These 6 pairs (12 literal sites) in `status-colors.ts` are the largest concentration of **exact, likely-safe** matches found in this audit — consistent with PRIOR-ART's finding that exact matches are rare and cluster narrowly. All other hardcoded hex in the file (`#F5F3F0`, `#DBEAFE`, `#1D4ED8`, `#FEF3C7`, `#B45309`, `#FEE2E2`, `#991B1B`, `#DCFCE7`, `#188A3C`, `#EDE9FE`, `#5B21B6`, `#6E6960`/`#6e696024` alpha variants, and all the `dark:bg-[#...NN]` alpha-suffixed variants) have **no exact-value token match** — they are chip background tints (e.g., `#F5F3F0` light chip fill) that were hand-tuned independently of the base hue tokens and don't derive from them via any documented formula. + +**`agentStatusBadge` (status-colors.ts:111-116) and `brandChipBadge` (status-colors.ts:143-149) are byte-for-byte identical maps** (same 4 shared keys: gray/blue/amber/red, same hex, same dark alpha suffixes) — flagged as a literal duplicate object, not just a duplicate value. `brandChipBadge` additionally has a `green` and `violet` entry `agentStatusBadge` lacks. **Needs human decision**: collapse `agentStatusBadge` into `brandChipBadge` (drop the redundant map) — this is a code dedup, not a token question, but it directly affects how many sites Phase 2 needs to touch. + +### 1.2 Hex colors — no match, chart/status color arrays (mint new tokens) + +Six files define standalone hex color arrays/maps for chart or dot rendering, none derived from `index.css`: + +- `components/ActivityCharts.tsx:125-184` — priority colors (`critical #ef4444`, `high #f97316`, `medium #eab308`, `low #6b7280`) and a **second, differently-valued** status-color map (`todo #3b82f6`, `in_progress #8b5cf6`, `in_review #a855f7`, `done #10b981`, `blocked #ef4444`, `cancelled #6b7280`, `backlog #64748b`) that does **not** match `lib/status-colors.ts`'s `issueStatusColor`/`taskStatusVar` naming-to-hue mapping at all (e.g., `in_progress` is violet-ish `#8b5cf6` here vs. blue `#2563eb` in the canonical status system). **Flag: internal inconsistency**, not just missing tokens — this chart appears to use an entirely independent palette from the rest of the app's status system. +- `pages/OrgChart.tsx:162-169` — agent status dot colors (`running #22d3ee`, `active #4ade80`, `paused #facc15`, `idle #facc15`, `error #f87171`, `terminated #a3a3a3`) — again independent from `--status-agent-*` (compare `running`: `#22d3ee` here vs `#2563eb` token). +- `lib/timeline/layout.ts:131-136,162` — `TIMELINE_COLORS` (`delegated #5b9bf6`, `automation #f4b740`, `cancelled #9aa3ad`, `now #2dd4bf`) plus a runtime `hsl(hue 62% 52%)` per-issue hash color (line 162) — deliberately unbounded (hash-based), cannot be tokenized as discrete values; the 4 named constants can be. +- `pages/CompanySkills.tsx:549-551` — a 12-color palette array for skill/tag chips (`#6366f1, #0ea5e9, #10b981, #f59e0b, #ef4444, #8b5cf6, #ec4899, #14b8a6, #f97316, #22c55e, #3b82f6, #a855f7`). +- `lib/color-contrast.ts:9,71-72` — `DARK_BG {24,24,27}` (documented as zinc-900/`#18181b`), `TEXT_LIGHT #f8fafc`, `TEXT_DARK #111827` — contrast-calculation reference colors, functionally load-bearing (not decorative), needs care in Phase 2. +- `context/ThemeContext.tsx:20-21` — `DARK_THEME_COLOR #18181b`, `LIGHT_THEME_COLOR #ffffff` — sets the `` tag; same value family as `color-contrast.ts`'s `DARK_BG`. +- `lib/worktree-branding.ts:28,49` — fallback black/white pair (`#000000` / `#f8fafc` / `#111827`), same contrast-pair pattern as above. + +**Cluster: 3 separate near-identical "readable text on dark/light" pairs** exist (`color-contrast.ts`, `worktree-branding.ts`, and implicitly `ThemeContext.tsx`'s theme-color), using `#f8fafc`/`#111827` twice and `#18181b`/`#020617`-family dark backgrounds inconsistently. **Flagged for human review** — looks like copy-paste convergence on the same Tailwind `slate-50`/`gray-900`-ish pair from three independent implementations; do not merge without checking each call site's actual contrast requirement. + +### 1.3 Hex colors — project-color fallback cluster (widely repeated) + +`#6366f1` (indigo) and `#64748b` (slate) are used as **default/fallback colors for user-configurable project colors** (`project.color ?? "#6366f1"` pattern) in 14 total sites: + +- `#6366f1`: `pages/ProjectDetail.tsx:789`, `pages/CompanySettings.tsx:272`, `pages/CompanySkills.tsx:549`, `pages/PipelineSettings.tsx:2959,2974`, `plugins/bridge-init.ts:502,515`, `components/issue-properties/IssueProperties.tsx:181,1576,1649`, `components/NewIssueDialog.tsx:1489,1504` (+2 in test files) +- `#64748b`: `pages/Routines.tsx:751,766`, `components/MarkdownEditor.tsx:1355`, `components/RoutineRunVariablesDialog.tsx:426,441`, `components/RoutineList.tsx:121`, `components/IssueColumns.tsx:362`, `components/routine-sections/editable-sections.tsx:193,208`, `components/ActivityCharts.tsx:184` (also appears as the `backlog` chart color, see 1.2) + +Neither matches an `index.css` token. **This is the single highest-value token-minting candidate in the whole audit** — one new `--project-color-fallback` (or two, if indigo/slate serve different call sites deliberately — `#6366f1` seems to be the "new project" default color picker seed, `#64748b` the "no project assigned" muted-slate fallback) would collapse 14+ sites. **Flag for human decision**: are these two meant to be the same fallback (a copy-paste drift) or intentionally different (new-project-color-picker-seed vs. no-project-slate)? The file-level pattern suggests two distinct call-site families (issue/project-creation UI uses indigo; routine/schedule UI uses slate), which argues for two tokens, not one. + +### 1.4 Hex colors — miscellaneous singletons + +- `components/ActivityFeed.tsx:286-288` and `components/FeedCard.tsx:432` — `#959596` (muted feed actor/verb text), 4 sites, 2 files. No token match. **This matches PRIOR-ART's called-out "muted feed text" gap cluster** — confirms it's still present and still untokenized. +- `components/OnboardingWizard.tsx:1722` — `bg-[#1d1d1d]` (dark decorative panel), singleton. +- `components/IssueChatThread.tsx:1451` — `bg-[#2563EB]` for "human's own message" bubble — **exact match** to `--status-agent-running`/`--status-task-in_progress` (`#2563eb`), but comment at line 1450 calls it "Liveness blue" independently — likely coincidental reuse of the same brand blue rather than a deliberate token reference. Flag as exact-match candidate but note the semantic mismatch (chat-bubble liveness vs. task-status liveness are different concepts that happen to share a hue). +- `pages/InviteUxLab.tsx:199,411` — `brandColor="#114488"` (lab/showcase-only, hardcoded prop value for a demo). +- `pages/CompanyEnvironments.tsx:427-431` — xterm.js terminal theme colors (`#0a0a0a`, `#f5f5f5`, `#22d3ee`, `#020617`, `#2563eb55`) — third-party terminal library config, arguably belongs on the documented allowlist (terminal chrome is intentionally distinct from app chrome). +- `fixtures/issueChatUxFixtures.ts:91` — `#0f766e` fixture data, out of runtime scope (test/demo fixture, not rendered UI chrome) but technically under `ui/src/`. +- `lib/mention-chips.ts:193` — `stroke="#000"` inside an inline SVG string (icon mask), singleton, likely intentional pure-black regardless of theme (needs human check). + +--- + +## 2. `rgb()/rgba()/hsl()/hsla()` literals (52 sites, non-test) + +Overwhelmingly **decorative gradient hero backgrounds** using `rgba(...)` inside Tailwind arbitrary `bg-[linear-gradient(...)]` / `bg-[radial-gradient(...)]` values: + +- **UxLab/showcase pages** (majority of sites): `pages/IssueChatUxLab.tsx` (7), `pages/SystemNoticeUxLab.tsx` (5), `pages/InviteUxLab.tsx` (7), `pages/RunTranscriptUxLab.tsx` (2), `pages/ProfileSettings.tsx` (2) — these ARE routed (`/ux-lab/*`, `/design-guide`), not build-excluded, so they are in scope per DESIGN.md, but they are explicitly demo/showcase surfaces rather than product screens. +- **Production surfaces with the same gradient pattern**: `pages/Dashboard.tsx:222` (red budget-alert gradient), `pages/Costs.tsx:842` (subtle white gradient card), `components/AccountingModelCard.tsx:31`, `components/SidebarAccountMenu.tsx:163`, `components/BudgetIncidentCard.tsx:46`. Each gradient is a **unique** rgba tuple (no two production sites share the same gradient stops) — this is bespoke decoration per surface, not a systematic pattern, and is a strong "Needs human decision" candidate: minting one token per gradient (5+ new tokens) preserves pixels but does not reduce the "one way to say alert-card decoration" debt DESIGN.md principle 1 wants; collapsing them changes pixels. +- **Functional (non-decorative) uses**: `lib/mention-chips.ts:169` — `rgba(r,g,b,0.22)` computed at runtime from a hash-derived color (cannot be a static token). `components/CompanyPatternIcon.tsx:128,131` — `rgb(...)` canvas fill computed from props (same — dynamic, not tokenizable as a single value). `lib/timeline/layout.ts:162` — `hsl(hue 62% 52%)` runtime hash color (same). `components/FileViewerSheet.tsx:370,379` — `var(--paperclip-code-highlight-bg, rgba(250,204,21,0.12))` — this one **already has a CSS-var fallback pattern**, i.e., it's half-migrated: the var doesn't exist in `index.css` yet, only the inline fallback does. **Recommend this becomes the actual token** (`--paperclip-code-highlight-bg` / `-border`) in Phase 2 since the call site already expects it. + +**Needs human decision**: whether one-off decorative gradients (Dashboard, Costs, AccountingModelCard, SidebarAccountMenu, BudgetIncidentCard, and all UxLab pages) should each mint a bespoke token (verbatim per DESIGN.md guardrails) or be added to the documented allowlist as "intentional decorative opt-outs" — minting ~15 near-duplicate gradient tokens that will never be reused elsewhere seems to work against the spirit of "tokens are the only source of visual values" even though it satisfies the letter of it. + +--- + +## 3. Tailwind arbitrary bracket values (spacing / size / radius / type / shadow) + +### 3.1 Font-size — `text-[Npx]` (730 sites, 137 files) — THE dominant cluster + +| Value | Site count | Representative files (abbreviated where >10) | +|---|---:|---| +| **11px** | 417 | 104 files use it at least once. Heaviest: `pages/Secrets.tsx` (28), `pages/CompanySkills.tsx` (24), `components/transcript/RunTranscriptView.tsx` (20), `components/IssueChatThread.tsx` (17), `pages/TeamCatalog.tsx` (15), `components/IssueRunLedger.tsx` (15), `pages/AgentDetail.tsx` (12), `components/ProjectProperties.tsx` (12), `components/OnboardingWizard.tsx` (12), `components/IssueRecoveryActionCard.tsx` (12), + 94 more files | +| **10px** | 236 | 77 files. Heaviest: `components/IssueChatThread.tsx` (17), `components/transcript/RunTranscriptView.tsx` (15), `pages/TeamCatalog.tsx` (14), `pages/IssueDetail.tsx` (11), `components/CommentThread.tsx` (9), + 72 more | +| **13px** | 26 | `pages/IssueChatUxLab.tsx`, `pages/TeamCatalog.tsx`, `pages/Pipelines.tsx`, `pages/CompanySkills.tsx`, `pages/SystemNoticeUxLab.tsx`, `components/SidebarStarredProjects.tsx`, `components/SidebarAgents.tsx`, `components/SidebarProjects.tsx`, `components/SidebarAccountMenu.tsx`, `components/IssueChatThread.tsx`, `components/ArtifactsPanel.tsx`, `components/timeline/WorkTimelineChart.tsx`, `components/SidebarNavItem.tsx`, `components/Sidebar.tsx` | +| **12px** | 25 | `pages/CompanySkills.tsx`, `pages/SystemNoticeUxLab.tsx`, `plugins/launchers.tsx`, `components/DocumentDiffModal.tsx`, `components/DevRestartBanner.tsx`, `components/IssueAssignedBacklogNotice.tsx`, `components/IssueBlockedNotice.tsx`, `components/JsonSchemaForm.tsx` | +| **9px** | 13 | `pages/Secrets.tsx`, `pages/CompanySkills.tsx`, `components/AgentConfigForm.tsx`, `components/NewAgentDialog.tsx`, `components/OnboardingWizard.tsx`, `components/ActivityCharts.tsx`, `components/IssueFiltersPopover.tsx` | +| **15px** | 9 | `pages/Pipelines.tsx`, `pages/PipelineSettings.tsx`, `pages/IssueDetail.tsx`, `components/IssueDocumentsSection.tsx`, `components/PipelineItemBodyDocument.tsx`, `components/routine-sections/editable-sections.tsx`, `components/IssueAttachmentsSection.tsx` | +| **14px** | 4 | `components/IssueDocumentsSection.tsx`, `components/SourceResolvedFoldCallout.tsx`, `components/SystemNotice.tsx`, `components/IssueRecoveryActionCard.tsx` | + +**FLAGGED CLUSTER — near-duplicate micro type scale.** This is the single largest and most consequential drift cluster in the codebase. 9/10/11/12/13/14/15px are all used, frequently in the *same component* for what appears to be the same semantic role ("small metadata label" vs. Tailwind's own `text-xs` = 12px baseline, which would cover several of these already). Sidebars alone (`Sidebar.tsx`, `SidebarNavItem.tsx`, `SidebarAgents.tsx`, `SidebarProjects.tsx`, `SidebarStarredProjects.tsx`, `SidebarAccountMenu.tsx`) all independently use `text-[13px]`, suggesting one genuine shared intent ("sidebar row label size") implemented as six separate arbitrary values instead of one. **PRIOR-ART's drafted "9 named `.type-*` intent styles incl. `micro` 11px / `nano` 10px" (see PRIOR-ART.md) directly targets this cluster** — this audit confirms the cluster is still present at large scale (653 of the 730 sites are 10px or 11px alone) and is the strongest candidate for that scale decision. **Do not merge here** — verbatim-extract each occurrence's exact px value into its own token per DESIGN.md Phase 2 guardrails; the human scale-collapse step (README "after the run" step 2) is where 9/10/11/12/13/14/15 get resolved into a real scale. + +### 3.2 Letter-spacing — `tracking-[N em]` (202 sites) + +| Value | Count | +|---|---:| +| 0.18em | 67 | +| 0.14em | 40 | +| 0.16em | 38 | +| 0.2em | 16 | +| 0.12em | 12 | +| 0.22em | 10 | +| 0.08em | 10 | +| 0.24em | 6 | +| 0.1em | 3 | + +**FLAGGED CLUSTER** — 9 distinct tracking values across ~40 files, all in the 0.08–0.24em band (uppercase eyebrow/label letter-spacing, judging by co-occurrence with `text-[10/11px] uppercase` in the same class strings observed during sampling). Likely another case of "one intent, many literal values" — needs human collapse decision, not this run's job. + +### 3.3 Width/height/min/max — arbitrary bracket values + +`w-[...]`: 124 sites. `h-[...]`: 166 sites. `min-w/max-w/min-h/max-h-[...]`: 194 sites. Combined ~484 sites across roughly 90 files. Values are overwhelmingly **not near-duplicates of each other** in the way font-size is — they're bespoke per-surface panel/dialog/sidebar dimensions (`320px`, `220px`, `12rem`, `calc(100dvh-2rem)`, `85vh`, `var(--radix-popover-trigger-width)`, `var(--new-issue-dialog-height)`). A few small clusters worth flagging: + +- **`30px`** appears as both a `w-[30px]` (4 sites) and `h-[30px]` (7 sites) — likely a consistent "icon button" footprint; check if these should route through a `size-*` token instead of duplicated w/h pairs. +- **`88px`** appears in both `w-[88px]` (2) and `h-[88px]` (4) — possibly the same avatar/tile footprint. +- **`220px`** (`h-[220px]`, 9 sites) and **`120px`** (`h-[120px]`, 7 sites) recur across otherwise-unrelated components (dialog/panel min-heights) — candidate for a semantic "compact panel min-height" token but flagged, not merged. +- `env(safe-area-inset-*)` and `var(--radix-*-trigger-width/height)` sites (≈15 total) are **not candidates for tokenization** — they reference runtime platform/library values, not design decisions; recommend allowlisting them explicitly rather than trying to wrap them in a token. + +Representative file hotspots: `pages/CompanySkills.tsx` (16 `h-[`, 3 `w-[`), `pages/Pipelines.tsx` (9 `h-[`, 8 `w-[`), `components/OnboardingWizard.tsx` (6 `h-[`), `pages/CompanyImport.tsx`/`CompanyExport.tsx` (5 each). + +### 3.4 Padding/margin — arbitrary bracket values (14 sites, small) + +Mostly platform-safe-area handling, not design values: +- `pages/IssueDetail.tsx`, `components/MobileBottomNav.tsx`, `components/Layout.tsx` (×2), `components/RoutineRunVariablesDialog.tsx` — all `env(safe-area-inset-*)` — **recommend allowlisting**, not tokenizing (platform-derived, not a design value). +- `components/ActivityFeed.tsx:p-[18px]`, `components/FeedCard.tsx:p-[18px]` — exact-duplicate 18px padding across 2 files, no token match — small clean mint candidate. +- `components/ui/tabs.tsx:p-[3px]` — **stock shadcn value** (verified against current shadcn/ui registry — not local drift). +- `components/IssueChatThread.tsx:p-[15px]`, `components/IssueChatThread.test.tsx:p-[15px]` — matched pair. +- `components/IssueRow.tsx` — two `calc(theme(spacing.N)-2px)` expressions — these reference Tailwind's own spacing scale via `theme()`, arguably already "tokenized" in the loosest sense (derived from the Tailwind default scale, not a raw literal), but the `-2px` offset itself is a raw magic number. Flag for human review of intent. + +### 3.5 Radius — arbitrary bracket values (25 sites) + +| Value | Sites | +|---|---| +| `rounded-[28px]`, `rounded-[32px]`, `rounded-[24px]` | `pages/IssueChatUxLab.tsx`, `pages/ProfileSettings.tsx`, `pages/SystemNoticeUxLab.tsx`, `pages/InviteUxLab.tsx` (×6), `pages/CompanySettings.tsx` (`14px`) — all showcase/demo hero-card radii, no two pages agree on 24 vs 28 vs 32 | +| `rounded-[8px]` | `components/artifacts/ArtifactCard.tsx` (+test), `components/artifacts/ArtifactGroupCard.tsx` (×3) — internally consistent within the artifacts family | +| `rounded-[4px]` | `components/StatusBadge.tsx`, `components/ui/checkbox.tsx` (**stock shadcn value**, verified against registry) | +| `rounded-br-[4px]` / `rounded-bl-[4px]` | `components/IssueChatThread.tsx` — chat-bubble corner-clip, intentional asymmetric radius (speech-tail effect) | +| `rounded-[2px]` | `components/ui/tooltip.tsx` (**stock shadcn value** — tooltip arrow) | +| `rounded-[inherit]` | `components/ui/scroll-area.tsx` (not a literal value — keyword, skip) | + +**Conflict with DESIGN.md — see section 6.** `--radius-lg` and `--radius-xl` are hard-set to `0px` in `index.css`'s `@theme inline` block, meaning every plain `rounded-lg` (188 uses) and `rounded-xl` (97 uses) class in the whole app currently renders **square, not rounded** — this is a live, current-state fact about the token layer itself, not a component-level drift issue, but it means the ~285 sites using `rounded-lg`/`rounded-xl` are silently at 0px and any future "fix" to those tokens will be a highly visible, non-zero visual change across most of the app. + +### 3.6 Shadow — arbitrary bracket values (38 sites, 21 files) + +Every value is a unique multi-stop `box-shadow` (drop shadows for hero cards, mostly `rgba(15,23,42,0.0X)` "cool black" tints at varying blur/spread). No two files share an identical shadow string except: +- `shadow-[0_24px_60px_rgba(15,23,42,0.08)]` (5 sites) and `shadow-[0_30px_80px_rgba(15,23,42,0.10)]` (3 sites) — both cluster around UxLab hero cards. +- `shadow-[0_-12px_28px_rgba(15,23,42,0.08)]` (3) and `shadow-[0_-12px_28px_rgba(0,0,0,0.28)]` (3) — same geometry, different color-mode tint (likely a light/dark pair that should have been expressed as one token with mode-aware color, not two separate arbitrary strings). +- `shadow-[0_1px_0_rgba(15,23,42,0.02)]` (3), `shadow-[0_0_0_2px_hsl(var(--background))]` (3) — the latter is interesting: it already references a CSS var (`--background`) inside an arbitrary value rather than a literal, i.e. partially tokenized. + +Concentrated in: `pages/IssueChatUxLab.tsx`, `pages/RunTranscriptUxLab.tsx`, `pages/ProfileSettings.tsx`, `pages/InviteUxLab.tsx`, `pages/SystemNoticeUxLab.tsx`, `pages/AgentDetail.tsx`, `components/IssueThreadInteractionCard.tsx`, `components/ChatComposer.tsx`, `components/SourceResolvedFoldCallout.tsx`, `components/KeyboardShortcutsCheatsheet.tsx`, `components/LiveRunWidget.tsx`, `components/BudgetPolicyCard.tsx`, `components/BudgetSidebarMarker.tsx`, `components/SidebarNavItem.tsx`, `components/IssueRecoveryActionCard.tsx`, `components/environment-variables-editor/index.tsx`, `components/IssueChatThread.tsx`, `components/DocumentAnnotationLayer.tsx`, `components/CompanyPatternIcon.tsx`, `components/ActiveAgentsPanel.tsx`, `components/SystemNotice.tsx`. + +**Needs human decision** — no existing shadow tokens exist in `index.css` at all (zero `--shadow-*` custom properties defined); every one of these 38 arbitrary shadows needs a brand-new token, and given how few are exact duplicates, this is close to "38 tokens for 38 sites" unless a human collapses them by visual similarity first. + +### 3.7 Other bracket categories (small) + +- `top/left/right/bottom-[...]` (14 sites): mostly `env(safe-area-inset-*)`, `50%`/`-50%` dialog-centering (shared with the stock shadcn `dialog.tsx`/`alert-dialog.tsx` pattern — **not local drift**), and one `top-[1px]` (`components/EntityRow.tsx:53`) hairline-alignment nudge. +- `z-[...]` (10 sites): `z-[1]`, `z-[2]`, `z-[9999]`, `z-[60]`, `z-[120]`, `z-[200]` — an ad hoc z-index scale with no documented tiers. **Flag**: no `--z-*` tokens exist; recommend a human-reviewed z-index scale decision, not in scope for this run's verbatim extraction beyond listing. +- `scale-[0.98]` (4 sites, all `pages/Inbox.tsx`) — consistent value, single file, easy mint. +- `blur-[2px]` (`components/IssueChatThread.tsx:3868`), `blur-[1px]` (`components/ChatComposer.tsx:253`) — 2 sites, 2 distinct values. +- `stroke-[2.3]` (`components/MobileBottomNav.tsx:109`) — SVG stroke-width, singleton. + +--- + +## 4. Inline `style={{ }}` literals (58 files contain `style={{`) + +Not every inline `style` is a hardcoded value — many pass through dynamic props (`style={{ width: size }}`). Filtering to literal-value cases: + +- **Project-color fallback pattern** (`backgroundColor: x.color ?? "#6366f1"` / `"#64748b"`) — see section 1.3, 14 sites, already counted there. +- `components/AsciiArtAnimation.tsx:344` — `style={{ fontSize: "11px", fontFamily: "monospace" }}` — duplicates the 11px cluster (3.1) via inline style instead of Tailwind class; same value, different mechanism — worth noting Phase 2's codemod needs to handle both `text-[11px]` AND `fontSize: "11px"` forms. +- `components/MarkdownBody.tsx:193,197` — `borderRadius: "calc(var(--radius) - 4px)"`, `fontSize: "0.7rem"` — the radius line already routes through the `--radius` token (good pattern, not drift); the `fontSize: "0.7rem"` (=11.2px) is a **new near-duplicate of the 11px cluster** in yet another unit (rem vs. px) — flag for the human scale decision. +- `pages/CompanyEnvironments.tsx:422` — `fontSize: 12` (numeric, xterm.js option, third-party config — recommend allowlist). +- `pages/CompanySkills.tsx:579` — `fontSize: Math.round(size * 0.42)` — computed at runtime from a prop, not tokenizable as a static value. +- `components/WorktreeBanner.tsx:25` — `boxShadow: \`inset 0 -1px 0 ${branding.textColor}18\`` — dynamically computed from user branding color, not tokenizable. + +--- + +## 5. Font-weight + +No raw numeric `font-weight:` or `fontWeight:` declarations were found in component/page source outside `index.css` itself (which has 6 legitimate `font-weight: 500/600/700` declarations inside `.paperclip-markdown`/`.paperclip-markdown-codeblock-action` rules — these are the token layer, not drift). All font-weight in components goes through Tailwind's built-in `font-medium`/`font-semibold`/`font-bold` classes, which is compliant with DESIGN.md (weight isn't a token gap here). **No action needed for font-weight.** + +--- + +## 6. Conflicts with DESIGN.md + +1. **`--radius-lg` / `--radius-xl` are pinned to `0px`, and the base `--radius` is `0`, in the current `index.css`.** DESIGN.md principle 3 says "the final scale is a design decision made by a human after reviewing the token audit," implying the scale is still open — but the *current* values already silently zero out two full tiers of the radius scale sitewide (188 `rounded-lg` + 97 `rounded-xl` sites render square today). This isn't a conflict in the sense of a bug, but it does mean: (a) any component using `rounded-lg`/`xl` believing it gets a rounded corner is visually wrong today, and (b) `rounded-2xl`/`rounded-3xl` are NOT overridden (still Tailwind's stock 1rem/1.5rem), so the scale is non-monotonic as configured (`sm`=6px, `md`=8px, `lg`=0px, `xl`=0px, `2xl`=16px stock, `3xl`=24px stock) — a real inconsistency in the token file itself, not just in components. **Recorded here per DESIGN.md instruction to log conflicts rather than guess a fix.** +2. **`agentStatusBadge` and `brandChipBadge` in `lib/status-colors.ts` are literal duplicate objects** (see 1.1) — this isn't a DESIGN.md violation per se (DESIGN.md doesn't forbid duplicate non-visual code), but it directly undercuts principle 1 ("one way to say each thing") and inflates the token-migration site count; flagged here since Phase 2's codemods will otherwise "fix" the same value twice under two different export names. +3. **No `--shadow-*` tokens exist at all** in `index.css`, despite 38 arbitrary shadow values in components (3.6). DESIGN.md principle 2 lists "shadow" explicitly as a category that must route through tokens — today there is no token family for it to route through. Not a contradiction of DESIGN.md so much as a gap DESIGN.md anticipates Phase 2 will need to fill from scratch (mint, don't normalize). +4. **Tailwind palette utility classes** (`bg-red-500`, `text-amber-600`, etc., 3,115 sites) are a form of hardcoded value DESIGN.md's principle 2 language ("no hex, no raw px") doesn't unambiguously cover — these aren't hex literals or raw px, they're named utility classes backed by Tailwind's *own* built-in oklch palette, entirely separate from `index.css`'s token values. Whether this counts as "in scope" for the zero-hardcoded-value gate is genuinely ambiguous from the text of DESIGN.md and is the single biggest scope question for Phase 2. **See "Needs human decision" below.** + +No other conflicts found — the rest of the codebase's approach (semantic tier for chrome, brand tier for agent/status colors, domain tier for chips/annotations) is followed consistently by the parts of the app that DO use tokens (e.g., `.status-chip`/`.status-fill` color-mix helpers, `.paperclip-mdxeditor` CSS-var bridge). + +--- + +## 7. Off-limits / out-of-scope areas + +- No `ui/src/components/theme-editor/` directory exists on this branch (confirmed via `find`) — KNOWN-DUPLICATES.md's off-limits note is currently moot here but preserved for when/if that code lands. +- No playground/experimental-theme paths found under `ui/src/` on this branch. +- Everything outside `ui/` (server, adapters framework config, CLI) is out of scope and was not scanned. +- `ui/storybook/` was inventoried only for the Phase-0 baseline-scope question (existing stories), not scanned for hardcoded values — it is fixture/test infrastructure, not shipped product UI. + +--- + +## 8. Needs human decision (required section) + +1. **Tailwind palette-class scope question** — are `bg-red-500`-style classes (3,115 sites / 145 files) in scope for Phase 2 token extraction? This is the largest single decision blocking Phase 2's actual size estimate. Recommend a separate, explicit ruling before Phase 2 starts, since it 10x's the mechanical work if in-scope. +2. **Micro type-size cluster (9/10/11/12/13/14/15px, 730+ sites)** — do not merge; this audit only inventories. PRIOR-ART's drafted 9-style `.type-*` system (incl. `micro`/`nano`) is the leading candidate for the eventual collapse decision. Needs a human to pick the real scale. +3. **Letter-spacing cluster (0.08–0.24em, 9 distinct values, 202 sites)** — same treatment as #2; no scale currently exists to collapse into. +4. **Radius token conflict (`--radius-lg`/`-xl` = 0px while `2xl`/`3xl` are untouched Tailwind stock)** — is the 0px lg/xl a deliberate brand choice (square corners) that should be preserved as-is, or a regression that should be fixed as part of the eventual radius-scale decision? PRIOR-ART's drafted scale (`sm 6 / md 8 / lg 10 / xl 14 / 2xl 16 / full`) assumes non-zero lg/xl — reconciling that draft with the current 0px reality is a human call. +5. **Project-color fallback duplication** (`#6366f1` indigo vs `#64748b` slate, 14 sites, section 1.3) — one token or two? File-level pattern suggests two distinct intents (new-project seed color vs. no-project-assigned muted slate) but this needs confirmation from whoever owns that UI, not an inference from this audit. +6. **`agentStatusBadge` vs `brandChipBadge` literal duplicate maps** (`lib/status-colors.ts`) — collapse to one export? This is a code-dedup call, adjacent to but not strictly a token-value question; flagged here because it changes Phase 2's site count. +7. **One-off decorative gradients/shadows on production surfaces** (Dashboard, Costs, AccountingModelCard, SidebarAccountMenu, BudgetIncidentCard — section 2; all of section 3.6) — mint ~20 bespoke, never-reused tokens (satisfies the letter of "tokens are the only source of visual values") or add a documented allowlist entry for "intentional one-off decoration" (satisfies the spirit of not inflating the token file with singletons)? DESIGN.md permits allowlisting "third-party overrides" but these are first-party decorative choices, so the allowlist criteria need a human ruling on whether it stretches to cover them. +8. **Chart color palettes disagree with the canonical status system** — `ActivityCharts.tsx`'s per-status hex map does not match `lib/status-colors.ts`'s hue mapping (e.g. `in_progress` renders violet-ish in the chart, blue in chips/icons elsewhere). Is this an intentional "charts get their own palette" design decision, or drift that should eventually re-point at `--status-task-*`? Flagging only — not resolving, per DESIGN.md's out-of-scope "no visual redesign" rule. +9. **Contrast-pair triplication** (`color-contrast.ts`, `worktree-branding.ts`, `ThemeContext.tsx` all define their own light/dark text or theme-color hex pairs, section 1.2) — candidates for one shared constant, but each has slightly different call-site semantics (WCAG contrast math vs. `` vs. branding fallback); needs a human to confirm they're actually meant to be identical before consolidating. +10. **Test-file hardcoded values (56 hex sites, plus proportional shares of the other categories)** — Phase 2's codemods will need a policy on whether test files get rewritten in lockstep with the components they assert against, or left alone (asserting against literal values that no longer appear verbatim in source once tokenized). Not addressed here since DESIGN.md's Phase 2 spec is silent on test files. + +--- + +## Phase 2 extraction log — Batch 1 (colors) + +Codemod: `scripts/codemod-extract-colors.mjs` (table-driven, idempotent — see script header for rationale on why a blind hex-regex sweep was rejected: it false-positives on strings like `acme/web#241` and `React #10140`). Scope: `ui/src/components/**` and `ui/src/pages/**`, including `*.test.tsx` companions, color literals only (hex / rgb / rgba / hsl / hsla / oklch). Shadow-embedded colors (`shadow-[...rgba(...)...]`) were explicitly left untouched per the batch mandate (Batch 3's job). + +**Sites rewritten:** 69, across 31 files (30 component/page files + 1 test file, `IssueChatThread.test.tsx`, whose assertion string was updated in lockstep with the component it tests). + +**Tokens minted: 41 new + 2 existing reused.** +- New verbatim tokens: 17 `--hex-*` (independent-palette status dots/priority/chart colors + the two project-color-fallback families) + 24 `--gradient-extract-*` (one per distinct gradient string; all 24 are pixel-verbatim, none normalized — two pairs of sites shared an identical gradient string and reused the same token: `--gradient-extract-9` used at IssueChatUxLab.tsx:139 + InviteUxLab.tsx:700; `--gradient-extract-10` at IssueChatUxLab.tsx:203 + InviteUxLab.tsx:909). +- Reused existing tokens (exact case-insensitive match, mode-independent — no `.dark` override in index.css for either): `#2563EB` → `var(--status-task-in_progress)` (IssueChatThread.tsx "Liveness blue" bubble); `#22c55e` → `var(--status-task-done)` (was not actually hit by a component site in this batch's table — flagged as available for Batch 2+ if a matching site turns up; the number above counts sites where a REUSE mapping fired, which was only the `#2563EB` family, at 2 sites: component + test assertion). +- All new tokens and the allowlist doc-comment live in a single non-`@theme` `:root { ... }` block appended to `ui/src/index.css`, headed `/* ── Extracted verbatim tokens (Phase 2, design/token-extraction) ── */`, per DESIGN.md (runtime-tunable, not baked into `@theme inline`). + +**Sites allowlisted (8 files, inline `token-extraction: allowlisted` comment at each site) — functional/third-party, converting would change behavior, not just pixels:** +1. `pages/CompanyEnvironments.tsx` — xterm.js terminal theme option object (`background`/`foreground`/`cursor`/`cursorAccent`/`selectionBackground`); third-party config consumed by the terminal library, not rendered CSS. +2. `pages/CompanySettings.tsx` — `` value; the DOM color-picker control requires a real hex string. +3. `components/issue-properties/IssueProperties.tsx` — `newLabelColor` picker-seed state, persisted into the label-create payload sent to the backend. +4. `pages/CompanySkills.tsx` — `DISCOVERY_ACCENTS` palette array; `skillAccentColor()`'s return value is written into `SkillCreateDraft.color` (persisted/compared data), not just used as a rendered value. +5. `components/IssueColumns.tsx` — `accentColor` fallback also feeds `pickTextColorForPillBg()` contrast math (from `lib/color-contrast.ts`), which needs a real hex string to compute luminance. +6. `components/CompanyPatternIcon.tsx` — canvas 2D `fillStyle` built from a runtime-computed template literal (`rgb(${r} ${g} ${b})`), not a static literal at all (excluded from the site table for this reason, not just allowlisted). +7. `components/FileViewerSheet.tsx` — `bg-[var(--paperclip-code-highlight-bg,rgba(250,204,21,0.12))]` / `border-[var(--paperclip-code-highlight-border,rgb(234,179,8))]` — a half-migrated `var(--x, fallback)` pattern where `--paperclip-code-highlight-bg`/`-border` don't exist in `index.css` yet (see section 2 above). Left alone rather than guessed at, since minting the var changes the semantics of an existing fallback expression rather than being a 1:1 literal swap — flagged below as "Needs human decision." +8. `pages/InviteUxLab.tsx` — `brandColor="#114488"` (×2, demo/showcase page) feeds `CompanyPatternIcon`'s `hexToHue()` color math via the same canvas-fill code path as #6, not a rendered CSS value. + +**Verify results:** `rg` gate clean (zero hex/rgb/hsl/oklch literals in Tailwind class strings or inline styles in `ui/src/components/**` / `ui/src/pages/**` outside the 8 allowlisted files); `pnpm build-storybook` exit 0; Storybook visual snapshot suite **510/510 passed, 0 failed** (`npx playwright test --config tests/storybook-visual/playwright.config.ts`); `pnpm typecheck` exit 0. + +**Bug caught and fixed during verification (documented for future batches):** the first codemod pass minted gradient tokens by copying Tailwind's bracket-arbitrary-value syntax verbatim, including underscore-for-space escaping (e.g. `radial-gradient(circle_at_top,...)`). That escaping is Tailwind's own class-name convention — real CSS custom properties are not parsed the way Tailwind parses bracket values, so `circle_at_top` inside a `--gradient-extract-N` declaration is invalid `radial-gradient()` syntax and the browser drops the whole background-image. Caught by the Playwright visual suite (`ux-labs-converted-test-pages--invite-and-access-flow [light]` failed, 6% pixel diff, dark hero panel rendered as plain gray). Fixed by converting all underscores back to literal spaces in the 24 gradient token values before re-running. This is now a standing gotcha for Batches 2-4: any bracket-value string being lifted into a CSS custom property must have Tailwind's `_`→` ` escaping reversed first. + +**Needs human decision (new, from this batch):** +- `components/FileViewerSheet.tsx`'s `--paperclip-code-highlight-bg`/`-border` half-migrated var-with-fallback pattern (allowlist item 7 above) — TOKEN-AUDIT.md section 2 already recommended "this becomes the actual token"; this batch deliberately did NOT act on that recommendation because defining the var changes what `var(--x, fallback)` resolves to structurally (from "always the literal fallback" to "the var if defined, else the fallback") even though the *value* would be identical today — a human should confirm this is the intended direction before Batch 2+ touches it, alongside the sibling `--paperclip-code-bg`/`--paperclip-code-gutter-fg` vars in the same file that use `theme(colors.muted...)` fallbacks (out of scope for colors, relevant to a future spacing/type batch). +- The `#2563EB` reuse (`IssueChatThread.tsx`'s "Liveness blue" chat bubble → `--status-task-in_progress`) is a **semantic coincidence**, not a designed relationship — the original code comment explicitly called it "Liveness blue" independently of the task-status system. Batch 1 reused the token per the exact-match rule, but a human should confirm a chat-bubble liveness color is supposed to be permanently coupled to the task `in_progress` status hue going forward (if a future redesign changes one, does the other move too?). + +--- + +## Phase 2 extraction log — Batch 2 (type) + +Codemod: `scripts/codemod-extract-type.mjs`. Unlike Batch 1's hand-audited site table (needed to avoid hex-like false positives such as issue references), this batch's patterns — `text-[Npx]`/`text-[N.Nrem]` Tailwind font-size, `tracking-[N em]` letter-spacing, `leading-[...]` line-height, and `fontSize: "Npx"`/`fontSize: "N.Nrem"` inline-style string literals — are unambiguous, so the codemod does a blanket regex sweep scoped to `ui/src/components/**` and `ui/src/pages/**` (including `*.test.tsx` companions; a full scan found **zero** test files containing any of these patterns, so no test file needed a lockstep update this batch). Verified idempotent (second run: 0 sites, 0 files changed). + +**Sites rewritten: 932**, across 143 files. +- Font-size Tailwind class utilities (`text-[Npx]`): 728 sites (matches TOKEN-AUDIT.md section 3.1's count almost exactly — 730 vs. 728, negligible drift from audit-vs-extraction timing). No rem-unit or line-height-suffixed (`text-[N]/[N]`) forms were found in class strings; none existed to convert. +- Letter-spacing (`tracking-[N em]`): 202 sites, matching TOKEN-AUDIT.md section 3.2 exactly (9 distinct values). +- `leading-[...]` line-height brackets: 0 sites found (confirmed via full sweep; TOKEN-AUDIT.md did not call this out as a populated cluster either). +- Inline-style `fontSize` string literals: 2 sites — `components/AsciiArtAnimation.tsx:344` (`fontSize: "11px"`, reused the same `--fs-11` token minted by the class-based sites — no duplicate) and `components/MarkdownBody.tsx:197` (`fontSize: "0.7rem"`, new rem-unit token). + +**Tokens minted: 17 new** (0 reused from Batch 1 — font-size/letter-spacing/line-height had no prior tokens to match against). +- 8 `--fs-*` (font-size): `--fs-9`, `--fs-10`, `--fs-11`, `--fs-12`, `--fs-13`, `--fs-14`, `--fs-15` (px, one per distinct value found) + `--fs-0_7rem` (0.7rem, `MarkdownBody.tsx`'s inline style — kept in rem per DESIGN.md "no normalizing/unit-converting" rule, NOT collapsed into the 11px-ish px cluster even though 0.7rem ≈ 11.2px). +- 9 `--ls-*` (letter-spacing): `--ls-0_08`, `--ls-0_1`, `--ls-0_12`, `--ls-0_14`, `--ls-0_16`, `--ls-0_18`, `--ls-0_2`, `--ls-0_22`, `--ls-0_24` — one per distinct em value, matching TOKEN-AUDIT.md 3.2's 9-value cluster exactly. +- 0 `--lh-*` (line-height) — no sites required one; the token-registration code path exists in the codemod (and was exercised in the Step 0 syntax spike) for forward-compatibility but minted nothing this batch. +- All new tokens live in a second non-`@theme` `:root { ... }` block appended to `ui/src/index.css` immediately after Batch 1's color block, headed `/* ── Extracted verbatim TYPE tokens (Phase 2 Batch 2, design/token-extraction) ── */`, per DESIGN.md (runtime-tunable). **No normalizing performed** — 9/10/11/12/13/14/15px and the 9 distinct tracking values all remain distinct tokens; the human scale-collapse decision (TOKEN-AUDIT.md "Needs human decision" #2/#3, PRIOR-ART's draft `.type-*` scale) is explicitly deferred, per mandate. + +**Tailwind v4 rewrite forms used** (confirmed via mandatory Step-0 syntax spike — scratch story + `pnpm build-storybook` + grep of emitted CSS, then deleted before the real codemod ran): +- `text-[11px]` → `text-(length:--fs-11)` — emits `font-size:var(--fs-11)`. The `length:` hint is REQUIRED; a bare `text-(--fs-11)` would be interpreted as a color utility. +- `tracking-[0.18em]` → `tracking-(--ls-0_18)` — emits `--tw-tracking:var(--ls-0_18);letter-spacing:var(--ls-0_18)`. Unambiguous, no hint needed. +- `leading-[...]` (numeric/unit forms only) → `leading-(--lh-*)` — verified to emit `--tw-leading:var(--lh-*);line-height:var(--lh-*)` in the spike; not exercised on a real site since 0 sites existed. +- All variant/modifier prefixes preserved verbatim by construction (the regex only rewrites the bracket portion): confirmed sites include `sm:text-[11px]` (`components/BlockedReasonChip.tsx`), a compound arbitrary-variant `group-data-[size=xs]/avatar:text-[10px]` (`components/ui/avatar.tsx`), and an `!important`-marked bracket-selector `[&>span:last-child]:!text-[11px]` (`components/ActiveAgentsPanel.tsx`) — all rewrote correctly to `sm:text-(length:--fs-11)`, `group-data-[size=xs]/avatar:text-(length:--fs-10)`, and `[&>span:last-child]:!text-(length:--fs-11)` respectively. + +**Sites allowlisted (2 sites, both already covered by Batch 1's allowlist doc-comment structure for their files, no new inline comments needed since neither is a bracket-literal or class-string site):** +1. `pages/CompanyEnvironments.tsx:422` — `fontSize: 12` inside the xterm.js terminal theme option object (same object Batch 1 allowlisted for its color literals). Numeric, functional third-party config — not a rendered CSS value, not a string literal the codemod's regex targets. +2. `pages/CompanySkills.tsx:580` — `fontSize: Math.round(size * 0.42)` — computed at runtime from a prop; not a static literal, nothing to extract. + +**Verify results:** +- `rg` gates clean in `ui/src/components/**` / `ui/src/pages/**`: zero `text-[Npx]`/`text-[N.Nrem]` arbitrary font-size, zero `tracking-[N em]`, zero numeric `leading-[...]`, zero raw `fontSize: "..."` string literals remain (the only two `fontSize:` string-literal grep hits left are the already-converted `fontSize: "var(--fs-11)"` / `fontSize: "var(--fs-0_7rem)"` sites). +- `pnpm build-storybook` exit 0. +- Storybook visual snapshot suite: **510/510 passed, 0 failed**, first attempt, no retries needed (`npx playwright test --config tests/storybook-visual/playwright.config.ts --reporter=line`). +- `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:** 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. + +--- + +## 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 0a7e30fffe..b9f040ef7e 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", @@ -48,6 +49,9 @@ "smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh", "smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs", "test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/link-plugin-dev-sdk.test.js", + "storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs", + "test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts", + "test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack", "test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts", "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed", "test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts", diff --git a/scripts/__tests__/storybook-visual-baseline.test.mjs b/scripts/__tests__/storybook-visual-baseline.test.mjs new file mode 100644 index 0000000000..9d541d6ccc --- /dev/null +++ b/scripts/__tests__/storybook-visual-baseline.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const script = new URL("../storybook-visual-baseline.mjs", import.meta.url).pathname; + +test("downloads and verifies a checksum-pinned local baseline archive", () => { + const root = mkdtempSync(join(tmpdir(), "storybook-visual-baseline-test-")); + try { + const source = join(root, "source"); + const cache = join(root, "cache"); + const snapshots = join(root, "snapshots"); + const manifest = join(root, "manifest.json"); + mkdirSync(source, { recursive: true }); + writeFileSync(join(source, "one.png"), "png-one"); + writeFileSync(join(source, "two.png"), "png-two"); + const archive = join(root, "snapshots.tgz"); + run("tar", ["--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "-czf", archive, "-C", source, "."]); + const sha256 = createHash("sha256").update(readFileSync(archive)).digest("hex"); + const byteSize = statSync(archive).size; + writeFileSync( + manifest, + JSON.stringify( + { + version: 1, + baselineId: "test-baseline", + snapshotCount: 2, + archive: { + url: `file://${archive}`, + sha256, + byteSize, + }, + environment: { + browser: "chromium", + viewport: "1200x800", + deviceScaleFactor: 1, + platform: "test", + }, + }, + null, + 2, + ), + ); + + const env = { + ...process.env, + STORYBOOK_VISUAL_BASELINE_MANIFEST: manifest, + STORYBOOK_VISUAL_BASELINE_CACHE_DIR: cache, + STORYBOOK_VISUAL_SNAPSHOT_DIR: snapshots, + }; + const download = spawnSync(process.execPath, [script, "download"], { env, encoding: "utf8" }); + assert.equal(download.status, 0, download.stderr); + const verify = spawnSync(process.execPath, [script, "verify"], { env, encoding: "utf8" }); + assert.equal(verify.status, 0, verify.stderr); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("verify fails closed on snapshot count mismatch", () => { + const root = mkdtempSync(join(tmpdir(), "storybook-visual-baseline-test-")); + try { + const snapshots = join(root, "snapshots"); + const manifest = join(root, "manifest.json"); + mkdirSync(snapshots, { recursive: true }); + writeFileSync(join(snapshots, "one.png"), "png-one"); + const archive = join(root, "snapshots.tgz"); + run("tar", ["--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner", "-czf", archive, "-C", snapshots, "."]); + const sha256 = createHash("sha256").update(readFileSync(archive)).digest("hex"); + const manifestBody = { + version: 1, + baselineId: "test-baseline", + snapshotCount: 1, + archive: { + url: `file://${archive}`, + sha256, + byteSize: statSync(archive).size, + }, + }; + writeFileSync(manifest, JSON.stringify(manifestBody)); + const env = { + ...process.env, + STORYBOOK_VISUAL_BASELINE_MANIFEST: manifest, + STORYBOOK_VISUAL_BASELINE_CACHE_DIR: root, + STORYBOOK_VISUAL_SNAPSHOT_DIR: snapshots, + }; + const download = spawnSync(process.execPath, [script, "download"], { env, encoding: "utf8" }); + assert.equal(download.status, 0, download.stderr); + writeFileSync(manifest, JSON.stringify({ ...manifestBody, snapshotCount: 2 })); + unlinkSync(join(snapshots, "one.png")); + const result = spawnSync(process.execPath, [script, "verify"], { env, encoding: "utf8" }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /snapshot count mismatch/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +function run(cmd, args) { + const result = spawnSync(cmd, args, { stdio: "pipe", encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); +} diff --git a/scripts/check-token-gates.mjs b/scripts/check-token-gates.mjs new file mode 100644 index 0000000000..26722d70d3 --- /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-colors.mjs b/scripts/codemod-extract-colors.mjs new file mode 100644 index 0000000000..49d595c3ed --- /dev/null +++ b/scripts/codemod-extract-colors.mjs @@ -0,0 +1,470 @@ +#!/usr/bin/env node +/** + * codemod-extract-colors.mjs + * + * Phase 2 (extraction), Batch 1/4 of the design-token audit + * (branch design/token-extraction). Replaces hardcoded COLOR literals + * (hex / rgb() / rgba() / hsl() / hsla() / oklch()) in Tailwind class + * strings and inline style objects, in `ui/src/components/**` and + * `ui/src/pages/**` (including their *.test.tsx companions), with + * references to CSS custom-property tokens defined in `ui/src/index.css`. + * + * Scope is deliberately a fixed, manually-audited SITE TABLE rather than a + * blind hex-matching regex sweep: a generic `#[0-9a-f]{3,8}` regex produces + * false positives on this codebase (issue references like "acme/web#241", + * PR/comment numbers like "React #10140", etc.). Every entry below was + * verified by hand against TOKEN-AUDIT.md section 1 (see repo root) to + * confirm it is (a) a real color value, (b) consumed as a rendered CSS + * value (not fed into contrast math, canvas painting, or persisted / + * compared JS state), and (c) safe to swap for `var(--token)` without any + * visual difference. + * + * Idempotent: every site's `find` string is the ORIGINAL literal-bearing + * form; once rewritten the file no longer contains that string, so + * re-running the script is a no-op (each replace() call only fires if the + * exact original substring is still present). + * + * Usage: node scripts/codemod-extract-colors.mjs [--check] + * --check Report what WOULD change without writing files (dry run). + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, ".."); +const UI_SRC = resolve(REPO_ROOT, "ui/src"); + +const DRY_RUN = process.argv.includes("--check"); + +/** + * Token table — every new token minted by this batch, value VERBATIM. + * `name` is the CSS custom-property name (without leading --). + * `value` is the exact literal value from the source site. + * `comment` documents where it came from / why, emitted above the + * declaration in ui/src/index.css. + */ +const NEW_TOKENS = [ + // --- Tailwind bracket color-class hex sites (section: bracket hex) --- + { name: "hex-959596", value: "#959596", comment: "Muted feed actor/verb/title text (ActivityFeed.tsx, FeedCard.tsx) — PRIOR-ART-flagged gap cluster, no existing token match." }, + { name: "hex-1d1d1d", value: "#1d1d1d", comment: "OnboardingWizard.tsx dark decorative panel background, singleton." }, + + // --- OrgChart.tsx status dot colors (independent from --status-agent-* hues) --- + { name: "hex-22d3ee", value: "#22d3ee", comment: "OrgChart.tsx agent status dot — 'running' (independent palette from --status-agent-*, see TOKEN-AUDIT.md 1.2)." }, + { name: "hex-4ade80", value: "#4ade80", comment: "OrgChart.tsx agent status dot — 'active'." }, + { name: "hex-facc15", value: "#facc15", comment: "OrgChart.tsx agent status dot — 'paused' / 'idle' (shared value)." }, + { name: "hex-f87171", value: "#f87171", comment: "OrgChart.tsx agent status dot — 'error'." }, + { name: "hex-a3a3a3", value: "#a3a3a3", comment: "OrgChart.tsx agent status dot — 'terminated' + defaultDotColor fallback." }, + + // --- ActivityCharts.tsx priority + status color maps (independent palette, see TOKEN-AUDIT.md 1.2) --- + { name: "hex-ef4444", value: "#ef4444", comment: "ActivityCharts.tsx — priority 'critical' + status 'blocked' (shared value); also the <0.5 success-rate bar tint." }, + { name: "hex-f97316", value: "#f97316", comment: "ActivityCharts.tsx — priority 'high'." }, + { name: "hex-eab308", value: "#eab308", comment: "ActivityCharts.tsx — priority 'medium'; also the 0.5-0.8 success-rate bar tint." }, + { name: "hex-6b7280", value: "#6b7280", comment: "ActivityCharts.tsx — priority 'low' + status 'cancelled' + statusColors fallback (shared value)." }, + { name: "hex-3b82f6", value: "#3b82f6", comment: "ActivityCharts.tsx — status 'todo' (independent from --status-task-todo which is #f59e0b)." }, + { name: "hex-8b5cf6", value: "#8b5cf6", comment: "ActivityCharts.tsx — status 'in_progress' (independent from --status-task-in_progress which is #2563eb — flagged inconsistency, TOKEN-AUDIT.md 1.2)." }, + { name: "hex-a855f7", value: "#a855f7", comment: "ActivityCharts.tsx — status 'in_review'." }, + { name: "hex-10b981", value: "#10b981", comment: "ActivityCharts.tsx — status 'done'; also the >=0.8 success-rate bar tint." }, + { name: "hex-64748b", value: "#64748b", comment: "ActivityCharts.tsx status 'backlog'; also the widely-repeated 'no project assigned' muted-slate fallback color (TOKEN-AUDIT.md 1.3) across Routines/MarkdownEditor/RoutineRunVariablesDialog/RoutineList/IssueColumns/editable-sections." }, + + // --- Project-color-fallback indigo cluster (TOKEN-AUDIT.md 1.3) --- + { name: "hex-6366f1", value: "#6366f1", comment: "Project-color-fallback indigo seed default (ProjectDetail/PipelineSettings/IssueProperties/NewIssueDialog) — new-project-color-picker-seed family per TOKEN-AUDIT.md 1.3." }, + + // --- Gradient tokens (verbatim, one per distinct gradient string; DESIGN.md: mint, don't collapse) --- + { name: "gradient-extract-1", value: "linear-gradient(180deg,rgba(255,80,80,0.12),rgba(255,255,255,0.02))", comment: "Dashboard.tsx budget-alert card gradient." }, + { name: "gradient-extract-2", value: "linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))", comment: "Costs.tsx subtle white card gradient." }, + { name: "gradient-extract-3", value: "radial-gradient(circle at top left,rgba(244,114,182,0.08),transparent 35%),radial-gradient(circle at bottom right,rgba(56,189,248,0.1),transparent 32%)", comment: "AccountingModelCard.tsx decorative overlay." }, + { name: "gradient-extract-4", value: "linear-gradient(180deg,rgba(255,70,70,0.10),rgba(255,255,255,0.02))", comment: "BudgetIncidentCard.tsx incident-card gradient." }, + { name: "gradient-extract-5", value: "radial-gradient(circle at top left,rgba(8,145,178,0.08),transparent 36%),radial-gradient(circle at bottom right,rgba(245,158,11,0.10),transparent 28%)", comment: "RunTranscriptUxLab.tsx:78 hero gradient." }, + { name: "gradient-extract-6", value: "linear-gradient(135deg,rgba(8,145,178,0.08),transparent 28%),linear-gradient(180deg,rgba(245,158,11,0.08),transparent 40%),var(--background)", comment: "RunTranscriptUxLab.tsx:203 hero-card gradient." }, + { name: "gradient-extract-7", value: "radial-gradient(circle at top right,rgba(255,255,255,0.22),transparent 34%),radial-gradient(circle at bottom left,rgba(255,255,255,0.08),transparent 36%)", comment: "ProfileSettings.tsx:162 decorative overlay." }, + { name: "gradient-extract-8", value: "radial-gradient(circle at top,rgba(8,145,178,0.18),transparent 48%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,1))", comment: "InviteUxLab.tsx:510 dark hero gradient." }, + { name: "gradient-extract-9", value: "linear-gradient(135deg,rgba(8,145,178,0.10),transparent 28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent 44%),var(--background)", comment: "IssueChatUxLab.tsx:139 + InviteUxLab.tsx:700 hero-card gradient (identical string, 2 sites)." }, + { name: "gradient-extract-10", value: "linear-gradient(180deg,rgba(168,85,247,0.06),transparent 28%),var(--background)", comment: "IssueChatUxLab.tsx:203 + InviteUxLab.tsx:909 accent gradient (identical string, 2 sites)." }, + { name: "gradient-extract-11", value: "linear-gradient(180deg,rgba(16,185,129,0.06),transparent 28%),var(--background)", comment: "IssueChatUxLab.tsx:226 accent gradient." }, + { name: "gradient-extract-12", value: "linear-gradient(180deg,rgba(6,182,212,0.05),transparent 28%),var(--background)", comment: "IssueChatUxLab.tsx:263 accent gradient." }, + { name: "gradient-extract-13", value: "linear-gradient(180deg,rgba(59,130,246,0.06),transparent 28%),var(--background)", comment: "IssueChatUxLab.tsx:294 accent gradient." }, + { name: "gradient-extract-14", value: "linear-gradient(180deg,rgba(168,85,247,0.05),transparent 26%),var(--background)", comment: "IssueChatUxLab.tsx:315 accent gradient." }, + { name: "gradient-extract-15", value: "linear-gradient(180deg,rgba(245,158,11,0.08),transparent 26%),var(--background)", comment: "IssueChatUxLab.tsx:339 accent gradient." }, + { name: "gradient-extract-16", value: "linear-gradient(135deg,rgba(245,158,11,0.10),transparent 28%),linear-gradient(180deg,rgba(8,145,178,0.08),transparent 44%),var(--background)", comment: "SystemNoticeUxLab.tsx:140 hero-card gradient." }, + { name: "gradient-extract-17", value: "linear-gradient(180deg,rgba(245,158,11,0.05),transparent 28%),var(--background)", comment: "SystemNoticeUxLab.tsx:193 accent gradient." }, + { name: "gradient-extract-18", value: "linear-gradient(180deg,rgba(8,145,178,0.05),transparent 28%),var(--background)", comment: "SystemNoticeUxLab.tsx:225 accent gradient." }, + { name: "gradient-extract-19", value: "linear-gradient(180deg,rgba(244,63,94,0.05),transparent 28%),var(--background)", comment: "SystemNoticeUxLab.tsx:289 accent gradient." }, + { name: "gradient-extract-20", value: "linear-gradient(180deg,rgba(16,185,129,0.05),transparent 28%),var(--background)", comment: "SystemNoticeUxLab.tsx:331 accent gradient." }, + { name: "gradient-extract-21", value: "linear-gradient(180deg,rgba(59,130,246,0.05),transparent 30%),var(--background)", comment: "InviteUxLab.tsx:753 accent gradient." }, + { name: "gradient-extract-22", value: "linear-gradient(180deg,rgba(234,179,8,0.06),transparent 28%),var(--background)", comment: "InviteUxLab.tsx:807 accent gradient." }, + { name: "gradient-extract-23", value: "linear-gradient(180deg,rgba(16,185,129,0.06),transparent 30%),var(--background)", comment: "InviteUxLab.tsx:884 accent gradient." }, + { name: "gradient-extract-24", value: "linear-gradient(180deg,rgba(244,114,182,0.06),transparent 28%),var(--background)", comment: "InviteUxLab.tsx:921 accent gradient." }, +]; + +/** + * Existing-token reuse map — hardcoded value -> existing index.css token, + * used ONLY where the value exact-matches (case-insensitive) a token whose + * value is IDENTICAL in :root and .dark (mode-independent brand tier per + * DESIGN.md). Both matches found in this batch are status hues with no + * `.dark` override in index.css. + */ +const REUSE = { + "#2563EB": "var(--status-task-in_progress)", // == --status-agent-running, both #2563eb, mode-independent + "#22c55e": "var(--status-task-done)", +}; + +/** + * SITE TABLE — [relative file path, find, replace]. Order-independent; + * every find/replace pair is applied with a single non-global `.replace()` + * per occurrence count noted, so duplicate literal strings within one file + * (e.g. the three `text-[#959596]` in ActivityFeed.tsx) are handled via + * `replaceAll` where explicitly marked. + */ +const SITES = [ + // ── Tailwind bracket hex-class sites ────────────────────────────── + { + file: "components/ActivityFeed.tsx", + replaceAll: [ + ['className="font-medium text-[#959596] group-hover:text-white"', 'className="font-medium text-(--hex-959596) group-hover:text-white"'], + ['className="ml-1 text-[#959596]"', 'className="ml-1 text-(--hex-959596)"'], + ['className="ml-1 text-[#959596] group-hover:text-white"', 'className="ml-1 text-(--hex-959596) group-hover:text-white"'], + ], + }, + { + file: "components/FeedCard.tsx", + replaceAll: [ + ['isMuted ? "text-muted-foreground/70" : "text-[#959596]"', 'isMuted ? "text-muted-foreground/70" : "text-(--hex-959596)"'], + ], + }, + { + file: "components/OnboardingWizard.tsx", + replaceAll: [ + ['"hidden md:block overflow-hidden bg-[#1d1d1d] transition-[width,opacity] duration-500 ease-in-out"', '"hidden md:block overflow-hidden bg-(--hex-1d1d1d) transition-[width,opacity] duration-500 ease-in-out"'], + ], + }, + { + file: "components/IssueChatThread.tsx", + replaceAll: [ + ['// Liveness blue (#2563EB) for the human\'s own messages (PAP-95 rev 5).', '// Liveness blue (--status-task-in_progress) for the human\'s own messages (PAP-95 rev 5).'], + ['? "bg-[#2563EB] text-white"', '? "bg-(--status-task-in_progress) text-white"'], + ], + }, + { + file: "components/IssueChatThread.test.tsx", + replaceAll: [ + ['expect(bubble?.className).not.toContain("bg-[#2563EB]");', 'expect(bubble?.className).not.toContain("bg-(--status-task-in_progress)");'], + ], + }, + + // ── OrgChart.tsx status dot color map (pure style render) ───────── + { + file: "pages/OrgChart.tsx", + replaceAll: [ + ['running: "#22d3ee",', 'running: "var(--hex-22d3ee)",'], + ['active: "#4ade80",', 'active: "var(--hex-4ade80)",'], + ['paused: "#facc15",\n idle: "#facc15",', 'paused: "var(--hex-facc15)",\n idle: "var(--hex-facc15)",'], + ['error: "#f87171",', 'error: "var(--hex-f87171)",'], + ['terminated: "#a3a3a3",', 'terminated: "var(--hex-a3a3a3)",'], + ['const defaultDotColor = "#a3a3a3";', 'const defaultDotColor = "var(--hex-a3a3a3)";'], + ], + }, + + // ── ActivityCharts.tsx priority + status color maps (pure style render) ── + { + file: "components/ActivityCharts.tsx", + replaceAll: [ + ['critical: "#ef4444",', 'critical: "var(--hex-ef4444)",'], + ['high: "#f97316",', 'high: "var(--hex-f97316)",'], + ['medium: "#eab308",', 'medium: "var(--hex-eab308)",'], + ['low: "#6b7280",', 'low: "var(--hex-6b7280)",'], + ['todo: "#3b82f6",', 'todo: "var(--hex-3b82f6)",'], + ['in_progress: "#8b5cf6",', 'in_progress: "var(--hex-8b5cf6)",'], + ['in_review: "#a855f7",', 'in_review: "var(--hex-a855f7)",'], + ['done: "#10b981",', 'done: "var(--hex-10b981)",'], + ['blocked: "#ef4444",', 'blocked: "var(--hex-ef4444)",'], + ['cancelled: "#6b7280",', 'cancelled: "var(--hex-6b7280)",'], + ['backlog: "#64748b",', 'backlog: "var(--hex-64748b)",'], + ['backgroundColor: statusColors[s] ?? "#6b7280"', 'backgroundColor: statusColors[s] ?? "var(--hex-6b7280)"'], + ['color: statusColors[s] ?? "#6b7280"', 'color: statusColors[s] ?? "var(--hex-6b7280)"'], + ['rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444"', 'rate >= 0.8 ? "var(--hex-10b981)" : rate >= 0.5 ? "var(--hex-eab308)" : "var(--hex-ef4444)"'], + ], + }, + + // ── Project-color-fallback pure-style-render sites (indigo #6366f1) ── + { + file: "pages/ProjectDetail.tsx", + replaceAll: [ + ['backgroundColor: project.color ?? "#6366f1"', 'backgroundColor: project.color ?? "var(--hex-6366f1)"'], + ], + }, + { + file: "pages/PipelineSettings.tsx", + replaceAll: [ + ['backgroundColor: selectedAutomationProject.color ?? "#6366f1"', 'backgroundColor: selectedAutomationProject.color ?? "var(--hex-6366f1)"'], + ['backgroundColor: project?.color ?? "#6366f1"', 'backgroundColor: project?.color ?? "var(--hex-6366f1)"'], + ], + }, + { + file: "components/issue-properties/IssueProperties.tsx", + replaceAll: [ + ['backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1"', 'backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "var(--hex-6366f1)"'], + ['backgroundColor: option.color ?? "#6366f1"', 'backgroundColor: option.color ?? "var(--hex-6366f1)"'], + ], + }, + { + file: "components/NewIssueDialog.tsx", + replaceAll: [ + ['backgroundColor: currentProject.color ?? "#6366f1"', 'backgroundColor: currentProject.color ?? "var(--hex-6366f1)"'], + ['backgroundColor: project?.color ?? "#6366f1"', 'backgroundColor: project?.color ?? "var(--hex-6366f1)"'], + ], + }, + + // ── Project-color-fallback pure-style-render sites (slate #64748b) ── + { + file: "pages/Routines.tsx", + replaceAll: [ + ['backgroundColor: currentProject.color ?? "#64748b"', 'backgroundColor: currentProject.color ?? "var(--hex-64748b)"'], + ['backgroundColor: project?.color ?? "#64748b"', 'backgroundColor: project?.color ?? "var(--hex-64748b)"'], + ], + }, + { + file: "components/MarkdownEditor.tsx", + replaceAll: [ + ['backgroundColor: option.projectColor ?? "#64748b"', 'backgroundColor: option.projectColor ?? "var(--hex-64748b)"'], + ], + }, + { + file: "components/RoutineRunVariablesDialog.tsx", + replaceAll: [ + ['backgroundColor: selectedProject.color ?? "#64748b"', 'backgroundColor: selectedProject.color ?? "var(--hex-64748b)"'], + ['backgroundColor: project?.color ?? "#64748b"', 'backgroundColor: project?.color ?? "var(--hex-64748b)"'], + ], + }, + { + file: "components/RoutineList.tsx", + replaceAll: [ + ['backgroundColor: project?.color ?? "#64748b"', 'backgroundColor: project?.color ?? "var(--hex-64748b)"'], + ], + }, + { + file: "components/routine-sections/editable-sections.tsx", + replaceAll: [ + ['backgroundColor: currentProject.color ?? "#64748b"', 'backgroundColor: currentProject.color ?? "var(--hex-64748b)"'], + ['backgroundColor: project?.color ?? "#64748b"', 'backgroundColor: project?.color ?? "var(--hex-64748b)"'], + ], + }, + + // ── Gradient sites (verbatim value -> --gradient-extract-N token) ─ + { + file: "pages/Dashboard.tsx", + replaceAll: [ + ['bg-[linear-gradient(180deg,rgba(255,80,80,0.12),rgba(255,255,255,0.02))]', 'bg-(image:--gradient-extract-1)'], + ], + }, + { + file: "pages/Costs.tsx", + replaceAll: [ + ['bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))]', 'bg-(image:--gradient-extract-2)'], + ], + }, + { + file: "components/AccountingModelCard.tsx", + replaceAll: [ + ['bg-[radial-gradient(circle_at_top_left,rgba(244,114,182,0.08),transparent_35%),radial-gradient(circle_at_bottom_right,rgba(56,189,248,0.1),transparent_32%)]', 'bg-(image:--gradient-extract-3)'], + ], + }, + { + file: "components/BudgetIncidentCard.tsx", + replaceAll: [ + ['bg-[linear-gradient(180deg,rgba(255,70,70,0.10),rgba(255,255,255,0.02))]', 'bg-(image:--gradient-extract-4)'], + ], + }, + { + file: "pages/RunTranscriptUxLab.tsx", + replaceAll: [ + ['bg-[radial-gradient(circle_at_top_left,rgba(8,145,178,0.08),transparent_36%),radial-gradient(circle_at_bottom_right,rgba(245,158,11,0.10),transparent_28%)]', 'bg-(image:--gradient-extract-5)'], + ['bg-[linear-gradient(135deg,rgba(8,145,178,0.08),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.08),transparent_40%),var(--background)]', 'bg-(image:--gradient-extract-6)'], + ], + }, + { + file: "pages/ProfileSettings.tsx", + replaceAll: [ + ['bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.22),transparent_34%),radial-gradient(circle_at_bottom_left,rgba(255,255,255,0.08),transparent_36%)]', 'bg-(image:--gradient-extract-7)'], + ], + }, + { + file: "pages/InviteUxLab.tsx", + replaceAll: [ + ['bg-[radial-gradient(circle_at_top,rgba(8,145,178,0.18),transparent_48%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,1))]', 'bg-(image:--gradient-extract-8)'], + ['bg-[linear-gradient(135deg,rgba(8,145,178,0.10),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent_44%),var(--background)]', 'bg-(image:--gradient-extract-9)'], + ['bg-[linear-gradient(180deg,rgba(59,130,246,0.05),transparent_30%),var(--background)]', 'bg-(image:--gradient-extract-21)'], + ['bg-[linear-gradient(180deg,rgba(234,179,8,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-22)'], + ['bg-[linear-gradient(180deg,rgba(16,185,129,0.06),transparent_30%),var(--background)]', 'bg-(image:--gradient-extract-23)'], + ['bg-[linear-gradient(180deg,rgba(168,85,247,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-10)'], + ['bg-[linear-gradient(180deg,rgba(244,114,182,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-24)'], + ], + }, + { + file: "pages/IssueChatUxLab.tsx", + replaceAll: [ + ['bg-[linear-gradient(135deg,rgba(8,145,178,0.10),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent_44%),var(--background)]', 'bg-(image:--gradient-extract-9)'], + ['bg-[linear-gradient(180deg,rgba(168,85,247,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-10)'], + ['bg-[linear-gradient(180deg,rgba(16,185,129,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-11)'], + ['bg-[linear-gradient(180deg,rgba(6,182,212,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-12)'], + ['bg-[linear-gradient(180deg,rgba(59,130,246,0.06),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-13)'], + ['bg-[linear-gradient(180deg,rgba(168,85,247,0.05),transparent_26%),var(--background)]', 'bg-(image:--gradient-extract-14)'], + ['bg-[linear-gradient(180deg,rgba(245,158,11,0.08),transparent_26%),var(--background)]', 'bg-(image:--gradient-extract-15)'], + ], + }, + { + file: "pages/SystemNoticeUxLab.tsx", + replaceAll: [ + ['bg-[linear-gradient(135deg,rgba(245,158,11,0.10),transparent_28%),linear-gradient(180deg,rgba(8,145,178,0.08),transparent_44%),var(--background)]', 'bg-(image:--gradient-extract-16)'], + ['bg-[linear-gradient(180deg,rgba(245,158,11,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-17)'], + ['bg-[linear-gradient(180deg,rgba(8,145,178,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-18)'], + ['bg-[linear-gradient(180deg,rgba(244,63,94,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-19)'], + ['bg-[linear-gradient(180deg,rgba(16,185,129,0.05),transparent_28%),var(--background)]', 'bg-(image:--gradient-extract-20)'], + ], + }, +]; + +// ── Allowlist — sites intentionally NOT rewritten (functional / third-party) ── +// One entry per file; each also gets an inline +// `/* token-extraction: allowlisted — ... */` comment injected at the site +// (idempotent: only injected if not already present). +const ALLOWLIST_COMMENTS = [ + { + file: "pages/CompanyEnvironments.tsx", + anchor: 'background: "#0a0a0a",', + commentLine: " // token-extraction: allowlisted — xterm.js terminal theme config; functional third-party option object, not a rendered CSS value.", + }, + { + file: "pages/CompanySettings.tsx", + anchor: ' value must be a real hex string, not a var() reference. */}", + }, + { + file: "components/issue-properties/IssueProperties.tsx", + anchor: 'const [newLabelColor, setNewLabelColor] = useState("#6366f1");', + commentLine: " // token-extraction: allowlisted — color-picker seed state, persisted into label-create payload; a var() string would break that payload.", + }, + { + file: "pages/CompanySkills.tsx", + anchor: "const DISCOVERY_ACCENTS = [", + commentLine: "// token-extraction: allowlisted — skill.color is persisted/compared JS data (SkillCreateDraft), not just a rendered value; a var() string would corrupt it.", + }, + { + file: "components/IssueColumns.tsx", + anchor: 'const accentColor = projectColor ?? "#64748b";', + commentLine: " // token-extraction: allowlisted — accentColor also feeds pickTextColorForPillBg() contrast math; a var() string can't be parsed as a hex color there.", + }, + { + file: "components/CompanyPatternIcon.tsx", + anchor: "ctx.fillStyle = `rgb(${offR} ${offG} ${offB})`;", + commentLine: " // token-extraction: allowlisted — canvas 2D fillStyle computed at runtime from numeric channel props; not a static literal.", + }, + { + file: "components/FileViewerSheet.tsx", + anchor: 'isHighlighted && "bg-[var(--paperclip-code-highlight-bg,rgba(250,204,21,0.12))]",', + commentLine: " // token-extraction: allowlisted — half-migrated var(--x, fallback) pattern; --paperclip-code-highlight-bg/-border don't exist in index.css yet. Needs human decision (see TOKEN-AUDIT.md 2) before minting, since defining the var changes a fallback-CSS-var-expression, not a plain literal swap.", + }, + { + file: "pages/InviteUxLab.tsx", + anchor: '
      \n \n ` --${t.name}: ${t.value}; /* ${t.comment} */`).join("\n"); + const block = `\n${marker}\n/* Batch 1/4: color literals only. Reused-from-existing-token sites (see\n TOKEN-AUDIT.md section 1.1) are NOT duplicated here — they reference\n --status-task-in_progress / --status-task-done directly at the call site.\n\n Allowlist (sites intentionally left as hardcoded / functional literals,\n NOT converted to tokens — each also carries an inline\n \`token-extraction: allowlisted\` comment at the site):\n - pages/CompanyEnvironments.tsx — xterm.js terminal theme config; functional JS values, third-party.\n - pages/CompanySettings.tsx — value; functional form control, not a rendered value.\n - components/issue-properties/IssueProperties.tsx (newLabelColor) — color-picker seed persisted into label-create payload.\n - pages/CompanySkills.tsx (DISCOVERY_ACCENTS) — persisted/compared skill.color JS data, not just rendered.\n - components/IssueColumns.tsx (accentColor fallback) — also feeds pickTextColorForPillBg() contrast math.\n - components/CompanyPatternIcon.tsx — canvas fillStyle computed at runtime from numeric props, not a static literal.\n - components/FileViewerSheet.tsx — half-migrated var(--paperclip-code-highlight-*, fallback) pattern; needs human decision, see TOKEN-AUDIT.md section 2.\n - pages/InviteUxLab.tsx (brandColor prop, x2) — demo/showcase-only prop feeding CompanyPatternIcon's hexToHue() color math, not a rendered CSS value.\n*/\n:root {\n${tokenLines}\n}\n`; + cssNext = cssOriginal + block; + cssChanged = true; + } + + if (cssChanged && !DRY_RUN) writeFileSync(cssPath, cssNext, "utf8"); + + // ── Summary ───────────────────────────────────────────────────────── + console.log(`\n${DRY_RUN ? "[DRY RUN] " : ""}codemod-extract-colors summary`); + console.log(` Sites rewritten: ${totalSitesRewritten}`); + console.log(` Component/page files changed: ${filesChanged}`); + console.log(` Allowlist comments injected: ${allowlistInjections}`); + console.log(` New tokens minted: ${NEW_TOKENS.length}`); + console.log(` Existing tokens reused: ${Object.keys(REUSE).length} (${Object.values(REUSE).join(", ")})`); + console.log(` index.css token block: ${cssChanged ? "added" : "already present (idempotent no-op)"}`); + if (changedFiles.length) { + console.log(`\n Changed files:`); + for (const f of changedFiles) console.log(` - ui/src/${f}`); + } +} + +main(); 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/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/scripts/codemod-extract-type.mjs b/scripts/codemod-extract-type.mjs new file mode 100644 index 0000000000..ddd69980e5 --- /dev/null +++ b/scripts/codemod-extract-type.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * codemod-extract-type.mjs + * + * Phase 2 (extraction), Batch 2/4 of the design-token audit + * (branch design/token-extraction). Replaces hardcoded TYPE values — + * arbitrary Tailwind font-size (`text-[11px]`), letter-spacing + * (`tracking-[0.18em]`), line-height (`leading-[...]`), and raw inline + * `fontSize` style literals — in `ui/src/components/**` and + * `ui/src/pages/**` (including their *.test.tsx companions) with + * references to CSS custom-property tokens defined in `ui/src/index.css`. + * + * Unlike Batch 1's color codemod (which used a hand-audited site table to + * avoid false-positiving on non-color hex-like strings such as issue + * references), this batch's patterns are unambiguous: `text-[Npx]`, + * `text-[N.Nrem]`, `tracking-[N em]`, and `leading-[...]` inside Tailwind + * class strings, and `fontSize: "Npx"` / `fontSize: "N.Nrem"` inline-style + * string literals, cannot mean anything other than a type-size/spacing + * value. A blanket regex sweep is therefore safe and is used here, scoped + * to `ui/src/components/**` and `ui/src/pages/**` only. Numeric or + * computed `fontSize` forms (e.g. `fontSize: 12`, `fontSize: Math.round(...)`) + * are functional (third-party config objects / runtime-computed values) + * and are left untouched — see ALLOWLIST_NOTES below. + * + * Token naming (verbatim value, no normalizing): + * --fs- font-size, px values, e.g. --fs-11: 11px; + * --fs-0_rem font-size, rem values, e.g. --fs-0_7rem: 0.7rem; + * --ls-0_ letter-spacing, em values, e.g. --ls-0_18: 0.18em; + * --lh- line-height (px or unitless — none found this batch) + * + * Tailwind v4 paren-shorthand rewrite forms used: + * text-[Npx] -> text-(length:--fs-N) (length hint REQUIRED — + * bare text-(--x) means color) + * tracking-[N em] -> tracking-(--ls-0_N) (unambiguous, no hint) + * leading-[...] -> leading-(--lh-N) (unambiguous, no hint) + * All variant/modifier prefixes (`sm:`, `dark:`, `group-hover:`, + * `[&>x]:`, trailing `!important` marker, etc.) are preserved verbatim — + * the regex only rewrites the bracket portion itself. + * + * Idempotent: the FIND regex only matches the ORIGINAL bracket-literal + * form (`text-[11px]` etc.); once rewritten to `text-(length:--fs-11)` the + * pattern no longer matches, so re-running is a no-op. The inline-style + * FIND is likewise the literal `fontSize: "11px"` string form. + * + * Usage: node scripts/codemod-extract-type.mjs [--check] + * --check Report what WOULD change without writing files (dry run). + */ + +import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; +import { resolve, dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, ".."); +const UI_SRC = resolve(REPO_ROOT, "ui/src"); +const SCAN_DIRS = ["components", "pages"]; + +const DRY_RUN = process.argv.includes("--check"); + +// ── Helpers ──────────────────────────────────────────────────────────── +function walk(dir, out) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) walk(p, out); + else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(p); + } +} + +function tokenSuffixForPx(value) { + // "11" -> "11", "0.65" -> "0_65" + return value.replace(".", "_"); +} + +function tokenSuffixForEm(value) { + // "0.18" -> "0_18" + return value.replace(".", "_"); +} + +// ── Token registries (populated as sites are discovered) ─────────────── +// Map of token name (without --) -> { value, comment, kind } +const fsTokens = new Map(); // font-size +const lsTokens = new Map(); // letter-spacing +const lhTokens = new Map(); // line-height + +function registerFsToken(rawValue, unit, sourceNote) { + const name = unit === "px" ? `fs-${tokenSuffixForPx(rawValue)}` : `fs-${tokenSuffixForPx(rawValue)}rem`; + if (!fsTokens.has(name)) { + fsTokens.set(name, { value: `${rawValue}${unit}`, comment: sourceNote }); + } + return name; +} + +function registerLsToken(rawValue, sourceNote) { + const name = `ls-${tokenSuffixForEm(rawValue)}`; + if (!lsTokens.has(name)) { + lsTokens.set(name, { value: `${rawValue}em`, comment: sourceNote }); + } + return name; +} + +function registerLhToken(rawValue, sourceNote) { + // rawValue includes unit already stripped by caller; store as given + const safeName = rawValue.replace(/[^a-zA-Z0-9]/g, "_"); + const name = `lh-${safeName}`; + if (!lhTokens.has(name)) { + lhTokens.set(name, { value: rawValue, comment: sourceNote }); + } + return name; +} + +// ── Regexes ────────────────────────────────────────────────────────── +// text-[11px], text-[0.65rem], with optional /[Npx] line-height suffix +// (none found in this codebase, but handled for completeness/future-proofing). +const FS_RE = /text-\[([0-9.]+)(px|rem)\](?:\/\[([0-9.]+)(px|rem)\])?/g; +const LS_RE = /tracking-\[([0-9.]+)em\]/g; +const LEADING_RE = /leading-\[([^\]]+)\]/g; +const FONTSIZE_STYLE_RE = /fontSize:\s*"([0-9.]+)(px|rem)"/g; + +function rewriteFile(filePath, relPath) { + const original = readFileSync(filePath, "utf8"); + let content = original; + let siteCount = 0; + + // -- font-size Tailwind class utilities -- + content = content.replace(FS_RE, (match, num, unit, lhNum, lhUnit) => { + const fsName = registerFsToken(num, unit, `Extracted from ${relPath} (text-[${num}${unit}]).`); + let replacement = `text-(length:--${fsName})`; + if (lhNum) { + const lhName = registerLhToken(`${lhNum}${lhUnit}`, `Extracted from ${relPath} (text-[...]/[${lhNum}${lhUnit}] line-height suffix).`); + replacement += `/(--${lhName})`; + } + siteCount++; + return replacement; + }); + + // -- letter-spacing Tailwind class utilities -- + content = content.replace(LS_RE, (match, num) => { + const lsName = registerLsToken(num, `Extracted from ${relPath} (tracking-[${num}em]).`); + siteCount++; + return `tracking-(--${lsName})`; + }); + + // -- line-height Tailwind class utilities (standalone leading-[...]) -- + content = content.replace(LEADING_RE, (match, raw) => { + // Only rewrite numeric/unit literals (px, rem, unitless number). Skip + // keyword forms like leading-[inherit] or var()-based (already tokenized). + if (!/^[0-9.]+(px|rem)?$/.test(raw)) return match; + const lhName = registerLhToken(raw, `Extracted from ${relPath} (leading-[${raw}]).`); + siteCount++; + return `leading-(--${lhName})`; + }); + + // -- inline style fontSize string literals -- + content = content.replace(FONTSIZE_STYLE_RE, (match, num, unit) => { + const fsName = registerFsToken(num, unit, `Extracted from ${relPath} (inline style fontSize: "${num}${unit}").`); + siteCount++; + return `fontSize: "var(--${fsName})"`; + }); + + if (content !== original && !DRY_RUN) { + writeFileSync(filePath, content, "utf8"); + } + return { changed: content !== original, siteCount }; +} + +function main() { + const files = []; + for (const dir of SCAN_DIRS) walk(resolve(UI_SRC, dir), files); + + let totalSites = 0; + let filesChanged = 0; + const changedFiles = []; + + for (const filePath of files) { + const relPath = "ui/src/" + relative(UI_SRC, filePath); + const { changed, siteCount } = rewriteFile(filePath, relPath); + if (changed) { + filesChanged++; + changedFiles.push(relPath); + } + totalSites += siteCount; + } + + // ── index.css token block ────────────────────────────────────────── + const cssPath = resolve(UI_SRC, "index.css"); + const cssOriginal = readFileSync(cssPath, "utf8"); + const marker = "/* ── Extracted verbatim TYPE tokens (Phase 2 Batch 2, design/token-extraction) ── */"; + let cssNext = cssOriginal; + let cssChanged = false; + + if (!cssOriginal.includes(marker) && (fsTokens.size || lsTokens.size || lhTokens.size)) { + const lines = []; + lines.push(marker); + lines.push("/* Batch 2/4: font-size + letter-spacing + line-height literals, verbatim"); + lines.push(" (no normalizing — 9/10/11/12/13/14/15px and 0.08-0.24em all stay distinct;"); + lines.push(" the human scale-collapse decision comes later per DESIGN.md/TOKEN-AUDIT.md)."); + lines.push(""); + lines.push(" Allowlist (sites intentionally left as hardcoded / functional literals,"); + lines.push(" NOT converted to tokens — each also carries an inline"); + lines.push(" `token-extraction: allowlisted` comment at the site):"); + lines.push(" - pages/CompanyEnvironments.tsx (fontSize: 12) — xterm.js terminal theme"); + lines.push(" config; functional third-party numeric option, not a rendered CSS value."); + lines.push(" Same allowlisted object as Batch 1's color entry for this file."); + lines.push(" - pages/CompanySkills.tsx (fontSize: Math.round(size * 0.42)) — computed at"); + lines.push(" runtime from a prop; not a static literal, nothing to extract."); + lines.push("*/"); + lines.push(":root {"); + for (const [name, { value, comment }] of fsTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + for (const [name, { value, comment }] of lsTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + for (const [name, { value, comment }] of lhTokens) { + lines.push(` --${name}: ${value}; /* ${comment} */`); + } + lines.push("}"); + const block = "\n" + lines.join("\n") + "\n"; + cssNext = cssOriginal + block; + cssChanged = true; + } + + if (cssChanged && !DRY_RUN) writeFileSync(cssPath, cssNext, "utf8"); + + // ── Summary ───────────────────────────────────────────────────────── + console.log(`\n${DRY_RUN ? "[DRY RUN] " : ""}codemod-extract-type summary`); + console.log(` Sites rewritten: ${totalSites}`); + console.log(` Files changed: ${filesChanged}`); + console.log(` New --fs-* tokens: ${fsTokens.size}`); + console.log(` New --ls-* tokens: ${lsTokens.size}`); + console.log(` New --lh-* tokens: ${lhTokens.size}`); + console.log(` index.css token block: ${cssChanged ? "added" : "already present or nothing to add (idempotent no-op)"}`); + if (changedFiles.length) { + console.log(`\n Changed files:`); + for (const f of changedFiles) console.log(` - ${f}`); + } +} + +main(); diff --git a/scripts/codemod-type-ladder.mjs b/scripts/codemod-type-ladder.mjs new file mode 100644 index 0000000000..7c954afe9f --- /dev/null +++ b/scripts/codemod-type-ladder.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +/** + * codemod-type-ladder.mjs + * + * DECISION-SHEET.md B3 (user-locked, preset-tune session): collapse the + * Batch 2 verbatim type tokens into the named ladder. + * + * FONT SIZES (8 --fs-* tokens -> 3 named tokens + 2 Tailwind scale classes): + * --fs-9, --fs-10 -> --text-nano: 10px (9 -> 10 bump) + * --fs-11, --fs-0_7rem -> --text-micro: 11px (0.7rem = 11.2px -> 11px) + * --fs-12 -> Tailwind `text-xs` (12px exact match; + * scale class preferred over token per DESIGN.md) + * --fs-13 -> --text-compact: 13px + * --fs-14, --fs-15 -> Tailwind `text-sm` (14px; 15 -> 14) + * + * LETTER-SPACING (9 --ls-* tokens -> 3 named steps, nearest-step mapping): + * 0.08em, 0.1em -> --tracking-label: 0.08em + * 0.12em, 0.14em, 0.16em -> --tracking-eyebrow: 0.14em + * 0.18em, 0.2em, 0.22em, 0.24em -> --tracking-caps: 0.2em + * + * The codemod rewrites every site under ui/src (components, pages, lib, + * context, plugins — all .ts/.tsx/.js/.jsx), replaces the --fs-* / --ls-* + * definitions in ui/src/index.css with the named-ladder block, and fails + * loudly if any --fs-* / --ls-* reference survives (e.g. a var(--fs-12) site, + * which has no token replacement because that bucket maps to a Tailwind + * scale class and would need a manual decision). + * + * Replacements are exact strings INCLUDING the closing paren, so prefix + * collisions (--ls-0_1 vs --ls-0_12, --fs-1* families) cannot mis-match. + * + * IDEMPOTENT: a second run finds no old references and the ladder marker + * already present in index.css, and changes nothing. + * + * Usage: node scripts/codemod-type-ladder.mjs + */ + +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { resolve, dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, ".."); +const UI_SRC = resolve(REPO_ROOT, "ui/src"); +const CSS_PATH = resolve(UI_SRC, "index.css"); + +// ── Site replacement map (exact strings, closing paren included) ───────── +const SITE_MAP = new Map([ + // font sizes — Tailwind utility form (Batch 2 syntax) + ["text-(length:--fs-9)", "text-(length:--text-nano)"], + ["text-(length:--fs-10)", "text-(length:--text-nano)"], + ["text-(length:--fs-11)", "text-(length:--text-micro)"], + ["text-(length:--fs-0_7rem)", "text-(length:--text-micro)"], + ["text-(length:--fs-12)", "text-xs"], + ["text-(length:--fs-13)", "text-(length:--text-compact)"], + ["text-(length:--fs-14)", "text-sm"], + ["text-(length:--fs-15)", "text-sm"], + // font sizes — inline-style var() form + ["var(--fs-9)", "var(--text-nano)"], + ["var(--fs-10)", "var(--text-nano)"], + ["var(--fs-11)", "var(--text-micro)"], + ["var(--fs-0_7rem)", "var(--text-micro)"], + ["var(--fs-13)", "var(--text-compact)"], + // (var(--fs-12/14/15) intentionally absent: those buckets map to Tailwind + // scale classes; any such site trips the leftover guard for manual review) + // letter-spacing — Tailwind utility form + ["tracking-(--ls-0_08)", "tracking-(--tracking-label)"], + ["tracking-(--ls-0_1)", "tracking-(--tracking-label)"], + ["tracking-(--ls-0_12)", "tracking-(--tracking-eyebrow)"], + ["tracking-(--ls-0_14)", "tracking-(--tracking-eyebrow)"], + ["tracking-(--ls-0_16)", "tracking-(--tracking-eyebrow)"], + ["tracking-(--ls-0_18)", "tracking-(--tracking-caps)"], + ["tracking-(--ls-0_2)", "tracking-(--tracking-caps)"], + ["tracking-(--ls-0_22)", "tracking-(--tracking-caps)"], + ["tracking-(--ls-0_24)", "tracking-(--tracking-caps)"], + // letter-spacing — inline-style var() form + ["var(--ls-0_08)", "var(--tracking-label)"], + ["var(--ls-0_1)", "var(--tracking-label)"], + ["var(--ls-0_12)", "var(--tracking-eyebrow)"], + ["var(--ls-0_14)", "var(--tracking-eyebrow)"], + ["var(--ls-0_16)", "var(--tracking-eyebrow)"], + ["var(--ls-0_18)", "var(--tracking-caps)"], + ["var(--ls-0_2)", "var(--tracking-caps)"], + ["var(--ls-0_22)", "var(--tracking-caps)"], + ["var(--ls-0_24)", "var(--tracking-caps)"], +]); + +const LADDER_MARKER = "Named type ladder (DECISION-SHEET.md B3"; +const LADDER_BLOCK = ` /* ── Named type ladder (DECISION-SHEET.md B3, preset-tune session) ── + Collapses the 8 verbatim --fs-* font-size tokens and 9 --ls-* + letter-spacing tokens (extracted in Batch 2 above) into named steps. + Codemod: scripts/codemod-type-ladder.mjs. Mapping: + 9px + 10px -> --text-nano (9 -> 10 bump per locked ladder) + 11px + 0.7rem -> --text-micro (0.7rem = 11.2px -> 11px) + 12px -> Tailwind \`text-xs\` class (12px exact match; scale + class preferred over a redundant token per DESIGN.md) + 13px -> --text-compact (PRIOR-ART named this tier "sm 13", + but that collides with Tailwind text-sm = 14px, + hence "compact") + 14px + 15px -> Tailwind \`text-sm\` class (14px; 15 -> 14) + Letter-spacing, nearest-step mapping: + 0.08em, 0.1em -> --tracking-label + 0.12em, 0.14em, 0.16em -> --tracking-eyebrow + 0.18em, 0.2em, 0.22em, 0.24em -> --tracking-caps + NOTE: sites moved to text-xs/text-sm also pick up the Tailwind scale + line-height (text-(length:--x) set font-size only) — intentional, + reviewed on the post-preset contact sheet. */ + --text-nano: 10px; + --text-micro: 11px; + --text-compact: 13px; + --tracking-label: 0.08em; + --tracking-eyebrow: 0.14em; + --tracking-caps: 0.2em;`; + +// ── Walk ui/src ─────────────────────────────────────────────────────────── +function walk(dir, out) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) walk(p, out); + else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(p); + } +} + +const files = []; +walk(UI_SRC, files); +files.sort(); + +// ── Pass 1: rewrite sites ───────────────────────────────────────────────── +const bucketCounts = new Map(); +let filesTouched = 0; +for (const f of files) { + const before = readFileSync(f, "utf8"); + let after = before; + for (const [oldStr, newStr] of SITE_MAP) { + if (!after.includes(oldStr)) continue; + const n = after.split(oldStr).length - 1; + after = after.split(oldStr).join(newStr); + bucketCounts.set(oldStr, (bucketCounts.get(oldStr) ?? 0) + n); + } + if (after !== before) { + writeFileSync(f, after); + filesTouched++; + } +} + +// ── Pass 2: index.css — swap definitions for the ladder block ──────────── +let css = readFileSync(CSS_PATH, "utf8"); +if (!css.includes(LADDER_MARKER)) { + const lines = css.split("\n"); + const DEF_RE = /^\s*--(?:fs-[0-9a-z_]+|ls-[0-9_]+):/; + const firstDef = lines.findIndex((l) => DEF_RE.test(l)); + if (firstDef === -1) { + console.error("ERROR: no --fs-* / --ls-* definitions found and ladder marker absent — index.css in unexpected state."); + process.exit(1); + } + const kept = lines.filter((l) => !DEF_RE.test(l)); + // Insert the ladder block at the position of the first removed definition: + // count how many kept lines precede the first definition line. + let precede = 0; + for (let i = 0; i < firstDef; i++) if (!DEF_RE.test(lines[i])) precede++; + kept.splice(precede, 0, LADDER_BLOCK); + css = kept.join("\n"); + writeFileSync(CSS_PATH, css); + console.log("index.css: --fs-* / --ls-* definitions replaced with named ladder block"); +} else { + console.log("index.css: ladder marker already present — skipped (idempotent)"); +} + +// ── Guard: no survivors anywhere in ui/src (incl. index.css) ───────────── +const SURVIVOR_RE = /--(?:fs-[0-9a-z_]+|ls-[0-9_]+)/; +const survivors = []; +for (const f of [...files, CSS_PATH]) { + const content = readFileSync(f, "utf8"); + const lines = content.split("\n"); + lines.forEach((l, i) => { + if (SURVIVOR_RE.test(l)) survivors.push(`${relative(REPO_ROOT, f)}:${i + 1}: ${l.trim()}`); + }); +} + +// ── Report ──────────────────────────────────────────────────────────────── +const bucketTotals = {}; +for (const [oldStr, n] of bucketCounts) { + const target = SITE_MAP.get(oldStr); + bucketTotals[target] = (bucketTotals[target] ?? 0) + n; +} +console.log("Sites rewritten per source form:"); +for (const [oldStr, n] of [...bucketCounts.entries()].sort()) { + console.log(` ${oldStr} -> ${SITE_MAP.get(oldStr)} (${n})`); +} +console.log("Totals per target bucket:", JSON.stringify(bucketTotals, null, 2)); +console.log(`Files touched: ${filesTouched}`); + +if (survivors.length > 0) { + console.error(`\nERROR: ${survivors.length} leftover --fs-* / --ls-* reference(s) need manual review:`); + for (const s of survivors) console.error(" " + s); + process.exit(1); +} +console.log("No leftover --fs-* / --ls-* references. Done."); diff --git a/scripts/serve-storybook-static.mjs b/scripts/serve-storybook-static.mjs new file mode 100644 index 0000000000..aa0c22f5cd --- /dev/null +++ b/scripts/serve-storybook-static.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// Tiny dependency-free static file server for the built Storybook +// (ui/storybook-static). Used by the visual snapshot suite's webServer so we +// don't add an http-server dependency. +import { createServer } from "node:http"; +import { createReadStream, existsSync, statSync } from "node:fs"; +import { extname, join, normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve( + fileURLToPath(new URL(".", import.meta.url)), + "..", + "ui", + "storybook-static", +); +const port = Number(process.env.PORT ?? 6106); + +if (!existsSync(join(root, "index.html"))) { + console.error(`No built Storybook at ${root}. Run \`pnpm build-storybook\` first.`); + process.exit(1); +} + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".map": "application/json; charset=utf-8", + ".txt": "text/plain; charset=utf-8", + ".webm": "video/webm", + ".mp4": "video/mp4", +}; + +const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", `http://localhost:${port}`); + let filePath = normalize(join(root, decodeURIComponent(url.pathname))); + if (!filePath.startsWith(root)) { + res.writeHead(403).end("forbidden"); + return; + } + if (existsSync(filePath) && statSync(filePath).isDirectory()) { + filePath = join(filePath, "index.html"); + } + if (!existsSync(filePath)) { + res.writeHead(404).end("not found"); + return; + } + res.writeHead(200, { + "content-type": MIME[extname(filePath)] ?? "application/octet-stream", + "cache-control": "no-store", + }); + createReadStream(filePath).pipe(res); +}); + +server.listen(port, () => { + console.log(`storybook-static served at http://localhost:${port}`); +}); diff --git a/scripts/storybook-visual-baseline.mjs b/scripts/storybook-visual-baseline.mjs new file mode 100644 index 0000000000..5fd9d8e019 --- /dev/null +++ b/scripts/storybook-visual-baseline.mjs @@ -0,0 +1,347 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { + createReadStream, + createWriteStream, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); +const defaultManifestPath = join(repoRoot, "tests", "storybook-visual", "baseline-manifest.json"); +const manifestPath = resolvePath( + process.env.STORYBOOK_VISUAL_BASELINE_MANIFEST ?? defaultManifestPath, +); +const defaultCacheDir = join(repoRoot, "tests", "storybook-visual", ".cache"); +const cacheDir = resolvePath(process.env.STORYBOOK_VISUAL_BASELINE_CACHE_DIR ?? defaultCacheDir); +const defaultSnapshotDir = join(repoRoot, "tests", "storybook-visual", ".snapshots"); +const snapshotDir = resolvePath(process.env.STORYBOOK_VISUAL_SNAPSHOT_DIR ?? defaultSnapshotDir); + +const command = process.argv[2]; +const flags = parseFlags(process.argv.slice(3)); + +try { + if (command === "download") { + await download(); + } else if (command === "verify") { + await verify(); + } else if (command === "pack") { + await pack(); + } else if (command === "upload") { + await upload(); + } else { + usage(); + process.exit(command ? 1 : 0); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} + +function resolvePath(path) { + return isAbsolute(path) ? path : resolve(repoRoot, path); +} + +function parseFlags(args) { + const result = new Map(); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg.startsWith("--")) { + throw new Error(`Unexpected argument: ${arg}`); + } + const equalsIndex = arg.indexOf("="); + if (equalsIndex !== -1) { + result.set(arg.slice(2, equalsIndex), arg.slice(equalsIndex + 1)); + continue; + } + const key = arg.slice(2); + const next = args[index + 1]; + if (!next || next.startsWith("--")) { + result.set(key, "true"); + } else { + result.set(key, next); + index += 1; + } + } + return result; +} + +function usage() { + console.log(`Usage: node scripts/storybook-visual-baseline.mjs + +Commands: + download Fetch, checksum, and unpack the manifest archive into the snapshot dir. + verify Check the unpacked snapshot count and cached archive checksum. + pack Create a deterministic snapshots.tgz from the snapshot dir. + upload Upload a packed archive to S3 with immutable overwrite checks. + +Environment: + STORYBOOK_VISUAL_BASELINE_MANIFEST Manifest path. + STORYBOOK_VISUAL_BASELINE_CACHE_DIR Cache path. + STORYBOOK_VISUAL_SNAPSHOT_DIR Playwright snapshot dir. + STORYBOOK_VISUAL_S3_URI s3://bucket/key target for upload. + STORYBOOK_VISUAL_PUBLIC_URL Public HTTPS URL to write into manifest instructions. +`); +} + +function readManifest() { + if (!existsSync(manifestPath)) { + throw new Error(`Missing baseline manifest: ${manifestPath}`); + } + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + if (manifest.version !== 1) { + throw new Error(`Unsupported baseline manifest version: ${manifest.version}`); + } + if (!Number.isInteger(manifest.snapshotCount) || manifest.snapshotCount < 0) { + throw new Error("Manifest snapshotCount must be a non-negative integer."); + } + return manifest; +} + +function archivePathFor(manifest) { + const hash = manifest.archive?.sha256; + return join(cacheDir, "archives", `${hash || "unconfigured"}-snapshots.tgz`); +} + +async function download() { + const manifest = readManifest(); + assertConfiguredArchive(manifest); + mkdirSync(dirname(archivePathFor(manifest)), { recursive: true }); + const archivePath = archivePathFor(manifest); + + if (!existsSync(archivePath) || sha256File(archivePath) !== manifest.archive.sha256) { + await fetchArchive(manifest.archive.url, archivePath); + } + verifyArchiveFile(manifest, archivePath); + rmSync(snapshotDir, { recursive: true, force: true }); + mkdirSync(snapshotDir, { recursive: true }); + run("tar", ["-xzf", archivePath, "-C", snapshotDir], "unpack baseline archive"); + verifySnapshotCount(manifest, snapshotDir); + console.log(`Downloaded ${manifest.baselineId} to ${relative(repoRoot, snapshotDir)}`); +} + +async function verify() { + const manifest = readManifest(); + assertConfiguredArchive(manifest); + const archivePath = archivePathFor(manifest); + if (!existsSync(archivePath)) { + throw new Error( + `Missing cached archive ${archivePath}. Run \`pnpm storybook-visual:baseline download\` first.`, + ); + } + verifyArchiveFile(manifest, archivePath); + verifySnapshotCount(manifest, snapshotDir); + console.log( + `Verified ${manifest.snapshotCount} snapshots for ${manifest.baselineId} in ${relative( + repoRoot, + snapshotDir, + )}`, + ); +} + +async function pack() { + const sourceDir = resolvePath(flags.get("source") ?? snapshotDir); + if (!existsSync(sourceDir)) { + throw new Error(`Snapshot source does not exist: ${sourceDir}`); + } + const count = countPngFiles(sourceDir); + if (count === 0) { + throw new Error(`No PNG snapshots found in ${sourceDir}`); + } + const out = resolvePath( + flags.get("out") ?? join(repoRoot, "tests", "storybook-visual", "baseline-review", "snapshots.tgz"), + ); + mkdirSync(dirname(out), { recursive: true }); + const tempDir = mkdtempSync(join(tmpdir(), "storybook-visual-pack-")); + const tempArchive = join(tempDir, "snapshots.tgz"); + try { + run( + "tar", + [ + "--sort=name", + "--mtime=@0", + "--owner=0", + "--group=0", + "--numeric-owner", + "--use-compress-program=gzip -n", + "-cf", + tempArchive, + "-C", + sourceDir, + ".", + ], + "pack deterministic baseline archive", + ); + rmSync(out, { force: true }); + run("cp", [tempArchive, out], "write packed archive"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + const sha256 = sha256File(out); + const byteSize = statSync(out).size; + const publicUrl = flags.get("public-url") ?? process.env.STORYBOOK_VISUAL_PUBLIC_URL ?? ""; + const objectKey = `baselines/storybook-visual/${sha256}/snapshots.tgz`; + console.log(`Packed ${count} PNG snapshots into ${relative(repoRoot, out)}`); + console.log(""); + console.log("Manifest archive update:"); + console.log( + JSON.stringify( + { + snapshotCount: count, + archive: { + url: publicUrl || `https:///${objectKey}`, + sha256, + byteSize, + objectKey, + }, + }, + null, + 2, + ), + ); +} + +async function upload() { + const archive = resolvePath(flags.get("archive") ?? join(repoRoot, "tests", "storybook-visual", "baseline-review", "snapshots.tgz")); + const s3Uri = flags.get("s3-uri") ?? process.env.STORYBOOK_VISUAL_S3_URI; + if (!s3Uri) { + throw new Error("Missing --s3-uri or STORYBOOK_VISUAL_S3_URI for upload."); + } + if (!s3Uri.startsWith("s3://")) { + throw new Error(`Upload target must be an s3:// URI: ${s3Uri}`); + } + if (!existsSync(archive)) { + throw new Error(`Archive does not exist: ${archive}`); + } + const sha256 = sha256File(archive); + const { bucket, key } = parseS3Uri(s3Uri); + const head = spawnSync( + "aws", + ["s3api", "head-object", "--bucket", bucket, "--key", key, "--output", "json"], + { encoding: "utf8" }, + ); + if (head.status === 0) { + const metadata = JSON.parse(head.stdout || "{}").Metadata ?? {}; + if (metadata.sha256 === sha256) { + console.log(`Archive already exists at ${s3Uri} with matching sha256 ${sha256}.`); + return; + } + throw new Error(`Refusing to overwrite existing S3 object with different sha256: ${s3Uri}`); + } + run( + "aws", + [ + "s3", + "cp", + archive, + s3Uri, + "--metadata", + `sha256=${sha256}`, + "--cache-control", + "public, max-age=31536000, immutable", + "--content-type", + "application/gzip", + ], + "upload baseline archive", + ); + console.log(`Uploaded ${basename(archive)} to ${s3Uri}`); +} + +function assertConfiguredArchive(manifest) { + const archive = manifest.archive ?? {}; + if (!archive.url || !archive.sha256 || !archive.byteSize) { + throw new Error( + `Baseline manifest ${relative( + repoRoot, + manifestPath, + )} does not point at a published archive yet. Run \`pnpm storybook-visual:baseline pack\`, upload the immutable archive, then update the manifest archive url/sha256/byteSize/snapshotCount.`, + ); + } +} + +async function fetchArchive(url, destination) { + if (url.startsWith("file://")) { + await pipeline(createReadStream(fileURLToPath(url)), createWriteStream(destination)); + return; + } + if (!url.startsWith("https://") && !url.startsWith("http://")) { + throw new Error(`Unsupported archive URL: ${url}`); + } + const response = await fetch(url); + if (!response.ok || !response.body) { + throw new Error(`Failed to download baseline archive: ${response.status} ${response.statusText}`); + } + await pipeline(response.body, createWriteStream(destination)); +} + +function verifyArchiveFile(manifest, archivePath) { + const actualSha = sha256File(archivePath); + if (actualSha !== manifest.archive.sha256) { + throw new Error( + `Baseline checksum mismatch: expected ${manifest.archive.sha256}, got ${actualSha}`, + ); + } + const actualSize = statSync(archivePath).size; + if (actualSize !== manifest.archive.byteSize) { + throw new Error( + `Baseline byte size mismatch: expected ${manifest.archive.byteSize}, got ${actualSize}`, + ); + } +} + +function verifySnapshotCount(manifest, dir) { + const count = countPngFiles(dir); + if (count !== manifest.snapshotCount) { + throw new Error( + `Baseline snapshot count mismatch: expected ${manifest.snapshotCount}, got ${count} in ${dir}`, + ); + } +} + +function sha256File(path) { + const hash = createHash("sha256"); + hash.update(readFileSync(path)); + return hash.digest("hex"); +} + +function countPngFiles(dir) { + if (!existsSync(dir)) return 0; + let count = 0; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + count += countPngFiles(path); + } else if (entry.isFile() && entry.name.endsWith(".png")) { + count += 1; + } + } + return count; +} + +function parseS3Uri(uri) { + const withoutScheme = uri.slice("s3://".length); + const slash = withoutScheme.indexOf("/"); + if (slash === -1) throw new Error(`S3 URI must include a key: ${uri}`); + return { bucket: withoutScheme.slice(0, slash), key: withoutScheme.slice(slash + 1) }; +} + +function run(cmd, args, label) { + const result = spawnSync(cmd, args, { + cwd: repoRoot, + stdio: "inherit", + env: { ...process.env, COPYFILE_DISABLE: "1" }, + }); + if (result.status !== 0) { + throw new Error(`Failed to ${label}.`); + } +} diff --git a/tests/storybook-visual/README.md b/tests/storybook-visual/README.md new file mode 100644 index 0000000000..3f4b8220af --- /dev/null +++ b/tests/storybook-visual/README.md @@ -0,0 +1,53 @@ +# Storybook Visual Baselines + +The visual suite compares built Storybook stories against PNG snapshots stored +outside git. The checked-in manifest at `baseline-manifest.json` pins the +immutable archive URL, SHA-256, byte size, snapshot count, and capture +environment. + +## Commands + +```sh +pnpm storybook-visual:baseline download +pnpm storybook-visual:baseline verify +pnpm test:storybook-visual +pnpm test:storybook-visual:update +``` + +`download` fetches the archive, verifies its SHA-256 and byte size, unpacks it to +`tests/storybook-visual/.snapshots/`, and checks the PNG count. The same snapshot +directory can be overridden with `STORYBOOK_VISUAL_SNAPSHOT_DIR`. + +## CI and Review Artifacts + +Storybook visual tests are opt-in while the suite stabilizes. Add the +`storybook-visual` label to a pull request, or run the `Storybook Visual` +workflow manually, to download the pinned baseline, build Storybook, and run the +Playwright visual suite on GitHub Actions. + +The workflow uploads `tests/storybook-visual/playwright-report/` and +`tests/storybook-visual/test-results/` as a `storybook-visual-report-*` artifact +on every run. When screenshots differ, Playwright writes the actual, expected, +and diff PNGs into `test-results`, so reviewers can inspect the failure without +rerunning the suite locally. + +Normal PR visual runs use repository read-only permissions and never upload or +modify baseline objects. To review intentional visual changes before updating +`baseline-manifest.json`, run the workflow manually with `update_snapshots` +enabled. That produces a `storybook-visual-baseline-review-*` artifact containing +the packed candidate snapshot archive for review. Publishing that bundle to the +baseline bucket still requires the explicit maintainer upload command below. + +## Updating Baselines + +1. Run `pnpm test:storybook-visual:update` after reviewing intentional visual + diffs. +2. Run `pnpm storybook-visual:baseline pack` to create + `tests/storybook-visual/baseline-review/snapshots.tgz`. +3. Upload the archive from a trusted maintainer environment with + `STORYBOOK_VISUAL_S3_URI=s3://bucket/baselines/storybook-visual//snapshots.tgz pnpm storybook-visual:baseline upload`. +4. Copy the printed `snapshotCount` and `archive` fields into + `baseline-manifest.json`. + +Generated snapshots, review bundles, Playwright reports, and downloaded caches +are ignored by git. diff --git a/tests/storybook-visual/baseline-manifest.json b/tests/storybook-visual/baseline-manifest.json new file mode 100644 index 0000000000..3cfed18877 --- /dev/null +++ b/tests/storybook-visual/baseline-manifest.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "baselineId": "storybook-visual-initial-public-baseline", + "sourceCommit": null, + "snapshotCount": 0, + "archive": { + "url": "", + "sha256": "", + "byteSize": 0, + "objectKey": "baselines/storybook-visual//snapshots.tgz" + }, + "environment": { + "browser": "chromium", + "viewport": "1200x800", + "deviceScaleFactor": 1, + "platform": "ubuntu-24.04" + } +} diff --git a/tests/storybook-visual/playwright.config.ts b/tests/storybook-visual/playwright.config.ts new file mode 100644 index 0000000000..74bfad7c84 --- /dev/null +++ b/tests/storybook-visual/playwright.config.ts @@ -0,0 +1,44 @@ +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "@playwright/test"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const snapshotDir = process.env.STORYBOOK_VISUAL_SNAPSHOT_DIR + ? isAbsolute(process.env.STORYBOOK_VISUAL_SNAPSHOT_DIR) + ? process.env.STORYBOOK_VISUAL_SNAPSHOT_DIR + : resolve(testDir, "..", "..", process.env.STORYBOOK_VISUAL_SNAPSHOT_DIR) + : join(testDir, ".snapshots"); + +// Visual snapshot suite for the design-token extraction run: screenshots every +// built Storybook story in both themes and compares against the external Phase +// 0 baseline downloaded by scripts/storybook-visual-baseline.mjs. +export default defineConfig({ + testDir: ".", + outputDir: "./test-results", + timeout: 60_000, + retries: 1, + workers: 4, + fullyParallel: true, + reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]], + expect: { + toHaveScreenshot: { + animations: "disabled", + caret: "hide", + scale: "css", + maxDiffPixels: 0, + }, + }, + snapshotPathTemplate: `${snapshotDir}/{arg}{ext}`, + use: { + browserName: "chromium", + viewport: { width: 1200, height: 800 }, + deviceScaleFactor: 1, + baseURL: "http://localhost:6106", + }, + webServer: { + command: "node ../../scripts/serve-storybook-static.mjs", + url: "http://localhost:6106/index.json", + reuseExistingServer: true, + timeout: 30_000, + }, +}); diff --git a/tests/storybook-visual/storybook-visual.spec.ts b/tests/storybook-visual/storybook-visual.spec.ts new file mode 100644 index 0000000000..eb1ec09eca --- /dev/null +++ b/tests/storybook-visual/storybook-visual.spec.ts @@ -0,0 +1,96 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test, type Page } from "@playwright/test"; + +// One screenshot test per story per theme, generated from the built +// Storybook's index.json. Baselines live outside git and are downloaded into +// the configured Playwright snapshot directory before this suite runs. +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const indexJsonPath = join(repoRoot, "ui", "storybook-static", "index.json"); + +if (!existsSync(indexJsonPath)) { + throw new Error(`Missing ${indexJsonPath}; run \`pnpm build-storybook\` first.`); +} + +type IndexEntry = { id: string; type: string; title: string; name: string }; +const entries = Object.values( + (JSON.parse(readFileSync(indexJsonPath, "utf8")) as { entries: Record }) + .entries, +).filter((entry) => entry.type === "story"); + +// Freeze wall-clock time so relative timestamps, spinners driven by +// setInterval, and Date.now()-based rendering are deterministic. +const FIXED_TIME = new Date("2026-06-24T12:00:00.000Z"); + +const THEMES = ["dark", "light"] as const; + +// Stories whose components schedule a delayed state flip (e.g. a "recently +// focused" highlight that clears via setTimeout). Wait past the flip so the +// screenshot always captures the settled terminal state. +const EXTRA_SETTLE_MS: Record = { + // IssueContinuationHandoff clears its focus highlight after 3s + 1s fade. + "product-issue-management--full-surface-matrix": 4500, +}; + +// Stories with a genuinely bimodal render race that cannot be settled by +// waiting. The affected element is masked (solid overlay in both baseline and +// comparison) so the rest of the story still snapshot-verifies. +const MASKED_SELECTORS: Record = { + // DocumentAnnotationLayer's ::highlight range over "two selectors" ends 1-2 + // characters short on ~half of renders (anchor offsets race). Mask only the + // paragraph that carries that highlight. + "product-documents-annotations--integrated-mobile-bottom-sheet": + 'p:has-text("Use a sidecar anchor made from")', +}; + +async function renderStory(page: Page, storyId: string, theme: (typeof THEMES)[number]) { + // Freeze Date only (not timers): page.clock.setFixedTime breaks React + // rendering in several stories (intermittent "must be used within Provider" + // errors), so shim the Date constructor instead. + await page.addInitScript(`{ + const fixedNow = ${FIXED_TIME.getTime()}; + const RealDate = Date; + class FixedDate extends RealDate { + constructor(...args) { + if (args.length === 0) { super(fixedNow); } else { super(...args); } + } + static now() { return fixedNow; } + } + FixedDate.parse = RealDate.parse; + FixedDate.UTC = RealDate.UTC; + window.Date = FixedDate; + }`); + await page.goto( + `/iframe.html?id=${encodeURIComponent(storyId)}&viewMode=story&globals=theme:${theme}`, + { waitUntil: "load" }, + ); + // Wait for Storybook to finish rendering (sb-show-main) or error out. + // Don't check #storybook-root children: portal-only stories (open dialogs, + // sheets) render into document.body and leave the root empty. + await page.waitForFunction(() => { + const body = document.body; + return ( + body.classList.contains("sb-show-main") || + body.classList.contains("sb-show-errordisplay") + ); + }); + const errored = await page.locator(".sb-show-errordisplay").count(); + expect(errored, `story ${storyId} threw during render`).toBe(0); + await page.evaluate(() => document.fonts.ready.then(() => undefined)); + const settleMs = EXTRA_SETTLE_MS[storyId]; + if (settleMs) await page.waitForTimeout(settleMs); +} + +for (const entry of entries) { + for (const theme of THEMES) { + test(`${entry.id} [${theme}]`, async ({ page }) => { + await renderStory(page, entry.id, theme); + const maskSelector = MASKED_SELECTORS[entry.id]; + await expect(page).toHaveScreenshot(`${entry.id}--${theme}.png`, { + fullPage: true, + mask: maskSelector ? [page.locator(maskSelector)] : undefined, + }); + }); + } +} diff --git a/ui/src/components/AccountingModelCard.tsx b/ui/src/components/AccountingModelCard.tsx index 987040615c..5849f077dd 100644 --- a/ui/src/components/AccountingModelCard.tsx +++ b/ui/src/components/AccountingModelCard.tsx @@ -28,9 +28,9 @@ const SURFACES = [ export function AccountingModelCard() { return ( -
      +
      - + Accounting model diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 2c2b0fcf29..c248c37486 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -29,7 +29,7 @@ function RunCardRecoveryChip({ action }: { action: IssueRecoveryAction }) { aria-label={tone.label} title={`${tone.label} — open the source task to act.`} className={cn( - "inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium", + "inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-(length:--text-nano) font-medium", tone.className, )} > @@ -170,9 +170,9 @@ const AgentRunCard = memo(function AgentRunCard({ }) { return (
      @@ -182,22 +182,22 @@ const AgentRunCard = memo(function AgentRunCard({
      {isActive ? ( - - + + ) : ( )} - +
      -
      +
      {isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}
      @@ -209,7 +209,7 @@ const AgentRunCard = memo(function AgentRunCard({ to={`/issues/${issue?.identifier ?? run.issueId}`} className={cn( "line-clamp-2 hover:underline", - isActive ? "text-cyan-700 dark:text-cyan-300" : "text-muted-foreground hover:text-foreground", + isActive ? "text-blue-700 dark:text-blue-300" : "text-muted-foreground hover:text-foreground", )} title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)} > diff --git a/ui/src/components/ActivityCharts.tsx b/ui/src/components/ActivityCharts.tsx index bd3dd22adb..33171a5bf9 100644 --- a/ui/src/components/ActivityCharts.tsx +++ b/ui/src/components/ActivityCharts.tsx @@ -19,11 +19,11 @@ function formatDayLabel(dateStr: string): string { function DateLabels({ days }: { days: string[] }) { return ( -
      +
      {days.map((day, i) => (
      {(i === 0 || i === 6 || i === 13) ? ( - {formatDayLabel(day)} + {formatDayLabel(day)} ) : null}
      ))} @@ -35,7 +35,7 @@ function ChartLegend({ items }: { items: { color: string; label: string }[] }) { return (
      {items.map(item => ( - + {item.label} @@ -49,7 +49,7 @@ export function ChartCard({ title, subtitle, children }: { title: string; subtit

      {title}

      - {subtitle && {subtitle}} + {subtitle && {subtitle}}
      {children}
      @@ -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; @@ -122,10 +122,10 @@ export function RunActivityChart(props: RunChartProps) { } const priorityColors: Record = { - critical: "#ef4444", - high: "#f97316", - medium: "#eab308", - low: "#6b7280", + critical: "var(--hex-ef4444)", + high: "var(--hex-f97316)", + medium: "var(--hex-eab308)", + low: "var(--hex-6b7280)", }; const priorityOrder = ["critical", "high", "medium", "low"] as const; @@ -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); @@ -174,14 +174,21 @@ export function PriorityChart({ issues }: { issues: { priority: string; createdA ); } +// DECISION-SHEET.md B5: chart status colors re-pointed at the canonical +// --status-task-* system (DESIGN.md principle 5 — an operator learns one +// status vocabulary; badge, row, chart, and log agree). Previously an +// independent palette (todo blue, in_progress violet, etc.). `backlog` +// deliberately keeps --project-none (pre-B5, per user ruling); the +// priority series and success-rate tints below are not status hues and +// are left alone. const statusColors: Record = { - todo: "#3b82f6", - in_progress: "#8b5cf6", - in_review: "#a855f7", - done: "#10b981", - blocked: "#ef4444", - cancelled: "#6b7280", - backlog: "#64748b", + todo: "var(--status-task-todo)", + in_progress: "var(--status-task-in_progress)", + in_review: "var(--status-task-in_review)", + done: "var(--status-task-done)", + blocked: "var(--status-task-blocked)", + cancelled: "var(--status-task-cancelled)", + backlog: "var(--project-none)", }; const statusLabels: Record = { @@ -215,7 +222,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); @@ -225,7 +232,7 @@ export function IssueStatusChart({ issues }: { issues: { status: string; created {total > 0 ? (
      {statusOrder.map(s => (entry[s] ?? 0) > 0 ? ( -
      +
      ) : null)}
      ) : ( @@ -236,7 +243,7 @@ export function IssueStatusChart({ issues }: { issues: { status: string; created })}
      - ({ color: statusColors[s] ?? "#6b7280", label: statusLabels[s] ?? s }))} /> + ({ color: statusColors[s] ?? "var(--hex-6b7280)", label: statusLabels[s] ?? s }))} />
      ); } @@ -251,11 +258,11 @@ 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; - const color = entry.total === 0 ? undefined : rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444"; + const color = entry.total === 0 ? undefined : rate >= 0.8 ? "var(--hex-10b981)" : rate >= 0.5 ? "var(--hex-eab308)" : "var(--hex-ef4444)"; return (
      0 ? Math.round(rate * 100) : 0}% (${entry.succeeded}/${entry.total})`}> {entry.total > 0 ? ( diff --git a/ui/src/components/ActivityFeed.tsx b/ui/src/components/ActivityFeed.tsx index 2df82a27d5..70100ea0d7 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-(--tp-background-color-border-color) duration-150", "cursor-pointer hover:bg-accent hover:border-muted-foreground/30", )} > @@ -283,9 +283,9 @@ function CollapsedFeedGroup({ : } - {actorName} - made {group.events.length} updates to - {entityName ?? group.entityId} + {actorName} + made {group.events.length} updates to + {entityName ?? group.entityId} {timeAgo(group.latestEvent.createdAt)} @@ -470,7 +470,7 @@ export function ActivityFeed({ className }: ActivityFeedProps) { separator = (
      - + Earlier
      @@ -680,7 +680,7 @@ export function ActivityFeed({ className }: ActivityFeedProps) { > {isEmpty ? (
      -
      +
      {emptyMessage?.showPulse && ( diff --git a/ui/src/components/AgentBubbleActionRow.tsx b/ui/src/components/AgentBubbleActionRow.tsx index 66db7f3353..2fc596bc8b 100644 --- a/ui/src/components/AgentBubbleActionRow.tsx +++ b/ui/src/components/AgentBubbleActionRow.tsx @@ -109,7 +109,7 @@ export function AgentBubbleActionRow({ {dateLabel} diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index ac507b7b88..5a0204f26e 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 }); @@ -1012,7 +1012,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }} /> -
      +
      Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
      @@ -1041,7 +1041,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { {kubernetesEnvironment.name} · Kubernetes sandbox
      ) : ( -
      +
      This instance requires the Kubernetes sandbox, but no managed Kubernetes environment is available for this company yet. Configure one before creating agents; execution will not fall back to local. @@ -1247,7 +1247,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { {supportsModelProfiles && ( -
      Primary model
      +
      Primary model
      )} { const namespace = `agents/${props.agent.id}/bootstrap-prompt`; const asset = await uploadMarkdownImage.mutateAsync({ file, namespace }); @@ -1353,7 +1353,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }} /> -
      +
      Bootstrap prompt is legacy and will be removed in a future release. Consider moving this content into the agent's prompt template or instructions file instead.
      @@ -1578,13 +1578,13 @@ export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmen
      {statusLabel} - + {new Date(result.testedAt).toLocaleTimeString()}
      {result.checks.map((check, idx) => ( -
      +
      {check.level} @@ -1632,7 +1632,7 @@ function AdapterTypeDropdown({ - + {adapterList.map((item) => ( ))} @@ -1668,7 +1668,7 @@ function AdapterTypeDropdown({ function ExperimentalBadge() { return ( - + Experimental ); @@ -1802,7 +1802,7 @@ function ModelDropdown({ - +
      {models.find((m) => m.id === value)?.label ?? value} - + current @@ -1890,7 +1890,7 @@ function ModelDropdown({ {models.find((m) => m.id === detectedModel)?.label ?? detectedModel} - + detected @@ -1914,13 +1914,13 @@ function ModelDropdown({ {entry?.label ?? candidate} - + config ); })} -
      +
      {allowDefault && ( - + {options.map((option) => (
      ) : ( @@ -208,22 +208,22 @@ export function ArtifactsPanel({ taskId, isAgentWorking, openDocKey, openDocTitl )}
      - + {wp.type.replace(/_/g, " ")} {showGenerating ? ( - + Generating... ) : ( - + {badge.label} )}
      {wp.summary && ( -

      +

      {wp.summary}

      )} @@ -298,7 +298,7 @@ function DocumentViewer({ {/* Sticky action footer */} {needsAction && (
      -

      This document needs your review.

      +

      This document needs your review.

      {parsed !== null && parsed <= incident.amountObserved ? ( -

      +

      The new budget must exceed current observed spend.

      ) : null} diff --git a/ui/src/components/BudgetPolicyCard.tsx b/ui/src/components/BudgetPolicyCard.tsx index 7834e8cbab..d392ff0c0e 100644 --- a/ui/src/components/BudgetPolicyCard.tsx +++ b/ui/src/components/BudgetPolicyCard.tsx @@ -23,9 +23,9 @@ function windowLabel(windowKind: BudgetPolicySummary["windowKind"]) { } function statusTone(status: BudgetPolicySummary["status"]) { - if (status === "hard_stop") return "text-red-300 border-red-500/30 bg-red-500/10"; - if (status === "warning") return "text-amber-200 border-amber-500/30 bg-amber-500/10"; - return "text-emerald-200 border-emerald-500/30 bg-emerald-500/10"; + if (status === "hard_stop") return "text-red-700 dark:text-red-300 border-red-500/30 bg-red-500/10"; + if (status === "warning") return "text-amber-700 dark:text-amber-200 border-amber-500/30 bg-amber-500/10"; + return "text-emerald-700 dark:text-emerald-200 border-emerald-500/30 bg-emerald-500/10"; } export function BudgetPolicyCard({ @@ -56,14 +56,14 @@ export function BudgetPolicyCard({ const observedBudgetGrid = isPlain ? (
      -
      Observed
      +
      Observed
      {formatCents(summary.observedAmount)}
      {summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"}
      -
      Budget
      +
      Budget
      {summary.amount > 0 ? formatCents(summary.amount) : "Disabled"}
      @@ -75,14 +75,14 @@ export function BudgetPolicyCard({ ) : (
      -
      Observed
      +
      Observed
      {formatCents(summary.observedAmount)}
      {summary.amount > 0 ? `${summary.utilizationPercent}% of limit` : "No cap configured"}
      -
      Budget
      +
      Budget
      {summary.amount > 0 ? formatCents(summary.amount) : "Disabled"}
      @@ -102,12 +102,12 @@ export function BudgetPolicyCard({
      @@ -116,7 +116,7 @@ export function BudgetPolicyCard({ ); const pausedPane = summary.paused ? ( -
      +
      {summary.scopeType === "project" @@ -129,7 +129,7 @@ export function BudgetPolicyCard({ const saveSection = onSave ? (
      -