Files
Ron a1e3ca5bf5 chore(webui): integrate Storybook + design-system catalog (Epic phase 1) (#7750)
* chore(webui): integrate Storybook + design-system catalog (Epic phase 1)

Storybook 10 (@storybook/react-vite, pnpm) wired to app.css + a light/dark
data-theme toolbar; ~33 stories across Primitives / Components / Composites /
Icons / Tokens; shared withRouter/withQueryClient decorators + react-query cache
seeding; vitest split (pnpm test node-only, pnpm test:storybook in headless
Chromium); @storybook/addon-mcp.

Recreated as a clean linear branch off current main (crates/product path),
superseding #7039 which had become an unmergeable stacked/merge-commit tangle.

Verified: typecheck, lint:conventions, test:storybook (103), node test (1355),
build-storybook — all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(webui): address design-system catalog review feedback

Review follow-ups for Storybook + design-system catalog (Epic phase 1):

- CI: run the Storybook story suite (headless Chromium) + build-storybook in
  the frontend code_style job so the browser suite can no longer regress
  silently behind the `test` / `test:storybook` split (High).
- a11y: enforce `a11y: error` in test:storybook. Fix a nameless <Select> and
  an unresolved `aria-controls` target; scope only `color-contrast` as a
  documented Phase-3 exception (known `--v2-*` token-palette AA shortfalls).
- Security: gate `@storybook/addon-mcp` behind `STORYBOOK_MCP=1`, so no
  unauthenticated `/mcp` endpoint ships in the committed config by default.
- Deps: pin the new Storybook/Playwright packages to exact versions (match the
  existing convention); drop the unused `@vitest/coverage-v8`.
- Stories: stub `fetch` for PairingWebCodePanel so the production caller gets
  deterministic pending-code / error responses and never hits a real backend;
  reuse the canonical `SelectMenuOption` type and exported `iconNames`; type
  the trace-credits fixture; sync the Modal `open` control; refresh token color
  values when the theme toolbar changes.
- vite: stop the Storybook browser server from proxying `/assets` to an absent
  backend — serve the real logo from `public/` (fixes ECONNREFUSED / broken
  logo in the catalog).
- docs: trim duplicated component API facts from the design-system README
  (one canonical home: component source + Storybook).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ci): resolve the Storybook Playwright cache path + harden the fetch stub handoff

CI (`fast-checks` -> `ws12_workflow_contracts`):

- The Playwright browser-cache key spelled `crates/product/ironclaw_webui/
  frontend/pnpm-lock.yaml` as a flat literal. `validate_webui_frontend_sites`
  sanctions that literal in exactly one shape (a `cache-dependency-path` line
  twinned with its nested-wildcard sibling), so a `hashFiles(...)` argument is
  the WS10 regression the pin exists to catch. Build the path from the
  already-resolved `WEBUI_FRONTEND_DIR` (`scripts/ci/crate-dir.sh`) via
  `format()` instead — `env` is available to `steps.<*>.with`.

Review follow-ups:

- `withStubbedFetch` shared one global "a stub is installed" guard across
  decorator instances. Switching stories renders the incoming decorator before
  React runs the outgoing one's cleanup, so the new story ran on the OLD
  story's routes and the outgoing cleanup then restored the real `fetch`
  underneath it — a live backend call from a story (e.g. minting a real pairing
  code). Each instance now owns its stub: install whenever `window.fetch` is
  not this instance's own, carry the real `fetch` forward through `__original`
  so stubs never chain-wrap, and restore only while still the owner.
  Regression test: `storybook-decorators.test.tsx` drives the swap through
  React (distinct component types, so it is a real unmount+mount) and asserts
  the second story is served its own routes, that no story request reaches the
  real `fetch`, that an unmatched URL still falls through, and that the last
  owner restores. Red against the previous implementation.

- `MintError` covered the failed bootstrap status but never invoked `renew`.
  Add a `play` that waits for the alert, clicks `pairing-new-code`, and asserts
  the panel returns to alert + enabled retry — counting the stubbed mint POSTs
  so it pins the retry PATH, not just a re-render.

Verified: `pnpm test:storybook` 104/104, `pnpm lint` (conventions + tsc),
`pnpm test` adds 1 passing test with no new failures (16 pre-existing local
failures are a Node 26 vs required 22.22 happy-dom mismatch, green on CI),
`python3 scripts/ci/test_ws12_workflow_contracts.py` 94/94.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(webui): report Storybook theme-persist failures and make the fetch stub hermetic by default

Follow-ups to the approach-audit run against 6a380c8.

Theme persistence swallowed its cause (`.storybook/preview.tsx`). The empty
`catch` discarded every `localStorage.setItem` failure, leaving no diagnostic.
The write stays non-fatal — `data-theme` on <html> is what app.css keys its
token sets off, so a story is themed correctly whether or not the persist
succeeds, and the persist can fail legitimately (sandboxed iframe, browser
blocking storage in a third-party frame). What changes is that the cause is now
reported via `console.warn` instead of dropped, so a real storage fault is
visible rather than looking like a successful persist.

The fetch stub let unmatched requests reach the network
(`storybook-decorators.tsx`). Passing unmatched requests through to the real
`fetch` meant a future story that forgot a route would silently acquire live
backend access — exactly what the pairing stories document must never happen
(a real pairing code minted from a shared/deployed catalog). Unmatched requests
now reject with an error naming the method and URL and pointing at the fix;
`{ passthrough: true }` is the explicit opt-in for a story that genuinely needs
the network. Hermeticity is now a property of the harness rather than a
convention each new story has to remember.

Regression tests extend `storybook-decorators.test.tsx`: the existing
story-swap test now asserts an unmatched request rejects and never reaches the
real fetch, and a second test pins the opt-in — declared routes still win over
the network, and only uncovered URLs fall through.

Verified: `pnpm test:storybook` 104/104 (unchanged — no story was relying on
network fall-through, so the stricter default breaks nothing), `pnpm lint`
(conventions + tsc), `pnpm test` 1341 passing / +1 test with no new failures
(the 16 local failures are the pre-existing Node 26 vs required 22.22
happy-dom mismatch, green on CI).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(webui): install the Storybook fetch stub on commit and match a Request's own method

Two follow-ups from the CodeRabbit re-review of b8238341.

Install moved out of the render phase. The stub was assigned to `window.fetch`
during render, but a render can be abandoned — interrupted, suspended, or
thrown out — without ever committing, and an abandoned render schedules no
cleanup. That stranded a stub on `window.fetch` with nothing left to remove it.
The install now runs in a layout effect, and `<Story />` is withheld until the
stub is live so a story that fetches on mount still cannot observe the real
`fetch` (the reason the install sat in render to begin with). The layout effect
lands the second render synchronously before paint, so there is no flicker, and
cleanup ordering now falls out naturally: the outgoing story's cleanup runs
before the incoming story's install, with the ownership check retained.

`Request.method` is now honored. `fetch(new Request(url, { method: "POST" }))`
carries its method on the Request rather than on `init`, so reading only `init`
classified it as a GET — matching the wrong route, or none. Resolution order is
`init.method`, then `Request.method`, then `GET`.

Regression tests: an uncommitted render (`renderToStaticMarkup`, which runs the
render phase and stops) must leave `window.fetch` untouched; and a POST
`Request` must select the POST route while a bare `Request` still selects the
GET one. Both verified red against the previous implementation.

Verified: `pnpm test:storybook` 104/104 — withholding the story for one commit
breaks no play function; `pnpm lint` (conventions + tsc); `pnpm test` 1343
passing / +2 tests, with the same 16 pre-existing local failures from the
Node 26 vs required 22.22 happy-dom mismatch (green on CI).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(webui): drop a stray sed backup committed alongside the fetch-stub fix

`storybook-decorators.tsx.tmp` was a `sed -i.tmp` backup left over from
verifying that the new regression tests fail against the previous
implementation. It was byte-identical to `storybook-decorators.tsx` and got
swept into 0502c1ae by a `git add -A`. Removing it — the canonical home for the
decorator is `storybook-decorators.tsx` alone.

No behavior change: nothing imported the duplicate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(webui): drop concrete extension names from the design-system stories

The merge queue ejected this PR three times. Both required checks — `Code
Style` (via `Reborn CLI smoke tests`) and `Tests (Reborn)` (via the
`architecture-misc` crate bucket) — failed on the same gate:
`reborn_generic_code_names_no_concrete_extension`
(`ironclaw_architecture_tests::reborn_extension_specificity`).

That gate scans the WebUI frontend `src` tree for `.ts`/`.tsx` naming a
concrete extension, vendor, or vendor host, deriving the forbidden vocabulary
from the package manifests under `crates/extensions/packages/` and
`tests/fixtures/extensions/`. Four story fixtures tripped it:

- `pairing-web-code-panel.stories.tsx` — "telegram"
- `flow-list.stories.tsx` — "slack", "telegram"
- `input.stories.tsx` — "acme" (from the `acme-messenger` test fixture package)

None is pre-existing debt, so the fix is the code, not the allowlist (raising
`WS0_EXTENSION_SPECIFICITY_ALLOWLIST_BASELINE` is a reviewed decision reserved
for existing debt, and the ratchet only shrinks). The names were arbitrary demo
strings: the pairing panel is extension-agnostic and its stub routes key off
whatever id the story picks, so it now uses a fictional "example-chat" with an
`example.test` deep link; the FlowList copy says "a chat app or email"; and the
Input placeholder is "Research workspace".

Why CI never caught this on the PR: the change is frontend-only, so
`has_code=false` and the Rust jobs are path-filtered out — every PR run shows
them as `skipping`. The merge queue runs the full suite against PR-merged-into-
main, which is the first place the gate executes. `is_test_source_path` exempts
`.test.`/`.spec.` and `test-utils`/`test_support` paths but not `.stories.`, so
story files are scanned as product code. Widening that exemption would be a
gate change needing its own review, so this fixes the fixtures instead.

Verified: no Rust toolchain is available on this machine, so the gate's term
derivation and scan were reproduced in Python
(`derive_forbidden_terms` + `scan_dir` semantics, deliberately
over-approximating the term set) — 603 terms derived, zero hits in the three
files. `pnpm test:storybook` 104/104 and `pnpm lint` (conventions + tsc) stay
green.

Not addressed here: `Reborn user-sandbox Docker tests` also failed in the third
queue attempt (`sweeper_removes_managed_egress_bundle_after_worker_disappears`)
but passed in the second, and is unrelated to this PR — an intermittent
real-Docker egress-bundle sweeper test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 04:57:42 +00:00
..