diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..11b39c6a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": [ + "-y", + "chrome-devtools-mcp@latest", + "--browserUrl", + "http://127.0.0.1:9222" + ] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index f745cfb7..d2ed3caf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ bun run dev # Start web server (logs to logs/server.log) bun run dev # Start server with hot reload bun run stop # Stop the server bun run logs # Tail server logs -bun run mllp # Start MLLP server (port 2575) +bun run mllp # Start MLLP server (port 2575). Separate process from `bun run dev`; no hot-reload. Restart it after changes to src/mllp/mllp-server.ts. bun scripts/load-test-data.ts # Load 5 test patients with related resources bun scripts/import-batch.ts [--tag ] # Bulk-import HL7v2 messages under a batchTag bun run typecheck # TypeScript type checking @@ -71,7 +71,8 @@ Read `docs/developer-guide/how-to/development-guide.md` for test infrastructure, Env flags: - `DISABLE_POLLING=1` — do not start any workers (useful for tests or when running the standalone `bun src/v2-to-fhir/processor-service.ts` scripts). -- `POLL_INTERVAL_MS` — override poll interval. Default 5000ms (demo-friendly). The standalone scripts still use their own 60000ms default. +- `POLL_INTERVAL_MS` — override poll interval. Default 1000ms. The standalone scripts still use their own 60000ms default. +- `DEMO_MODE` — default-on. Controls the Dashboard's "Run demo now" endpoint (`POST /demo/run-scenario`). Unset, empty, or any non-`"off"` value enables it; only `DEMO_MODE=off` disables (returns 403). The per-service standalone entrypoints (`bun src/bar/sender-service.ts` etc.) are unchanged and still work — they share the same factories. @@ -81,7 +82,8 @@ The per-service standalone entrypoints (`bun src/bar/sender-service.ts` etc.) ar Env flags: - `DISABLE_POLLING=1` — do not start any workers (useful for tests or when running the standalone `bun src/v2-to-fhir/processor-service.ts` scripts). -- `POLL_INTERVAL_MS` — override poll interval. Default 5000ms (demo-friendly). The standalone scripts still use their own 60000ms default. +- `POLL_INTERVAL_MS` — override poll interval. Default 1000ms. The standalone scripts still use their own 60000ms default. +- `DEMO_MODE` — default-on. Controls the Dashboard's "Run demo now" endpoint (`POST /demo/run-scenario`). Unset, empty, or any non-`"off"` value enables it; only `DEMO_MODE=off` disables (returns 403). The per-service standalone entrypoints (`bun src/bar/sender-service.ts` etc.) are unchanged and still work — they share the same factories. @@ -119,12 +121,19 @@ For anything beyond this file, read `docs/developer-guide/`: | HL7 reference JSON generation (XSD+PDF → data/hl7v2-reference) | `how-to/hl7v2-reference-generation.md` | | Batch-importing HL7v2 zips and triaging errors | `how-to/batch-import.md` | | Testing, integration infra, codegen/debug workflows | `how-to/development-guide.md` | +| UI architecture, shell composition, htmx/Alpine patterns | `ui-architecture.md` | +| Design tokens, class vocabulary, color palette | `ui-design-tokens.md` | +| End-to-end recipe for adding a new UI page | `how-to/add-ui-page.md` | | VXU ORDER OBX hard error decision | `adr/001-unknown-order-obx-hard-error.md` | ## Code Style IMPORTANT: Read `.claude/code-style.md` before writing or modifying code. +UI conventions: see `docs/developer-guide/ui-architecture.md`. + +Tailwind v4 gotcha: Tailwind utilities are emitted inside cascade layers, while `DESIGN_SYSTEM_CSS` is plain unlayered CSS. Broad unlayered resets override utilities even when the utility selector looks more specific; e.g. `a { color: inherit; }` breaks legacy anchor tabs using `text-white` / `text-gray-*`. Scope resets to unclassed elements (`a:not([class])`) or put them in Tailwind's base layer. + ## Bun, not Node This project uses Bun. Use `bun`/`bun install`/`bun run` instead of `node`/`npm`/`yarn`/`pnpm`. Unit tests use `bun test` (not jest/vitest). Bun auto-loads `.env` (no `dotenv`). HTTP: `Bun.serve()`. File I/O: `Bun.file`. diff --git a/ai/tickets/2026-04-22-demo-ready-ui-tier1.md b/ai/tickets/2026-04-22-demo-ready-ui-tier1.md index 9f52cec9..85cac891 100644 --- a/ai/tickets/2026-04-22-demo-ready-ui-tier1.md +++ b/ai/tickets/2026-04-22-demo-ready-ui-tier1.md @@ -1,5 +1,8 @@ # Plan: Demo-Ready UI (Tier 1) +> **Status: tasks 3–7 superseded by [`2026-04-23-ui-design-system-refactor.md`](./2026-04-23-ui-design-system-refactor.md)** (completed 2026-04-23). +> Tasks 1 and 2 shipped as planned; tasks 3–7 were folded into the warm-paper redesign and are no longer actionable here. See the superseding plan for the final implementation. + ## Overview Transform the admin-grade UI into a customer-demo-ready UI without a rewrite. Biggest unlock: auto-start the three polling services inside the web server so the pipeline is genuinely live on screen. Everything else (dashboard, nav regrouping, severity-graded status colors, auto-refresh, demo scenario button) builds on that foundation. diff --git a/ai/tickets/2026-04-23-ui-design-system-refactor.md b/ai/tickets/2026-04-23-ui-design-system-refactor.md new file mode 100644 index 00000000..fcf54761 --- /dev/null +++ b/ai/tickets/2026-04-23-ui-design-system-refactor.md @@ -0,0 +1,366 @@ +# Plan: UI Design System Refactor + +## Overview + +Implement the new warm-paper design system across 5 pages — Dashboard, Inbound Messages, Simulate Sender, Unmapped Codes, Terminology Map — on top of the existing server-rendered Bun+TypeScript stack. Accounts and Outgoing Messages are out of scope and keep their current page bodies (wrapped in a gray card inside the new shell so the warm-paper canvas doesn't clash). Stack: htmx + Alpine.js (no React, no build step for UI). Design + scope: `ai/tickets/ui-refactoring/DESIGN_OVERVIEW.md`. Stack rationale: `ai/tickets/ui-refactoring/STACK_DECISION.md`. Supersedes tasks 3–7 of `ai/tickets/2026-04-22-demo-ready-ui-tier1.md` (tasks 1–2 are already done and stay). + +## Architectural decisions (locked) + +- **Unified sidebar, mixed page bodies.** One shell for the whole app; the sidebar gets a third "Outbound" group containing Accounts + Outgoing Messages with their existing Tailwind bodies unchanged. Legacy bodies are wrapped in `
` inside the shell's main column so they frame correctly against the warm-paper canvas. +- **Timeline tab uses Aidbox FHIR `_history`.** Each status transition in `src/v2-to-fhir/processor-service.ts` is already a full `PUT` → a new version in Aidbox's `*_history` table. Fetched lazily per-message only when the Timeline tab is opened. No new resource, no processor changes. The tab is labeled "Timeline" (not "ACK history") because it shows processing status transitions, not MSA-segment ACKs — integration engineers shouldn't be misled. Docs: https://www.health-samurai.io/docs/aidbox/api/rest-api/history +- **Simulate Sender uses a plain textarea.** No syntax-highlighted overlay for v1 — the "unmapped codes" signal lives in the editor card header chips, not inline. CodeMirror 6 is a follow-up if needed. +- **"Held for mapping" inferred by post-send status poll.** After MLLP returns AA, the send endpoint polls `IncomingHL7v2Message/:id` for up to ~3s. If status moves to `code_mapping_error`, return `held`. Otherwise `sent`. The MLLP ACK alone cannot distinguish the two because the listener ACKs on receive, before the async processor runs. +- **Suggestion ranking is substring-only.** No Jaro-Winkler / fuzzy similarity for v1. Exact-substring hits rank top; remaining matches rank by substring position + display length. Deferred to a follow-up if match quality is poor in practice. +- **Deprecate is out of v1 entirely.** Terminology Map has an "Edit" and a "Delete" action; no soft-deprecate flag, no strikethrough state, no "needs review" count (renders `0`). Soft-deprecate lifecycle moves to the non-goals list. +- **Unmapped Codes "Skip" is client-only.** Clicking Skip advances the selection to the next queue entry with no server call; skipped codes reappear on reload. "Skip all" is removed from v1 (no meaningful server-side semantics without the defer/on-hold decision). +- **Vendor htmx + Alpine outside `src/`.** Static assets live at project root under `public/vendor/`; served by a new static-file route with a strict whitelist regex (not just a `..` check). +- **Chrome DevTools MCP setup is a blocking validation step for Task 4** so agents can visually verify pages against the design. + +## Validation + +- `bun test:local` — must pass after every task (~10s) +- `bun run typecheck` — must pass after every task +- Manual smoke: open `http://localhost:3000`, walk the page under the active task, confirm visual match against `ai/tickets/ui-refactoring/hl7v2-v2/project/HL7v2 Design.html` + +--- + +## Task 1: Vendor htmx + Alpine + static-file route + +- [x] Create `public/vendor/` (track the directory in git; add `public/` to repo root); download pinned htmx 2.0.x and Alpine 3.15.x minified builds (vendored, not CDN). Latest-stable checked against npm registry on 2026-04-23 (htmx 2.0.10, Alpine 3.15.11); htmx 2.x is the current major (1.9.x line is deprecated). +- [x] Register `GET /static/*` in `src/index.ts` serving from `public/` via `Bun.file()`; set Content-Type from extension; **path must match `^/static/vendor/[A-Za-z0-9._-]+\.(?:js|css|svg|woff2?)$`**, otherwise 404. Rejects `..`, URL-encoded `..`, absolute paths, backslashes. +- [x] Keep font delivery on Google Fonts CDN for v1 (`` to `fonts.googleapis.com/css2?family=Inter...&family=Fraunces...&family=JetBrains+Mono`) — matches current Tailwind-CDN pattern; self-hosting is a follow-up *(no-op this task — the shell that will include the `` ships in Task 3a; called out here for continuity)* +- [x] Add unit test in `test/unit/ui/static-route.test.ts`: real-file hit returns 200 + correct Content-Type; missing file returns 404; `../`, `%2E%2E%2F`, and absolute paths all return 404 +- [x] Run validation — must pass +- [ ] Stop for user review before next task + +## Task 2: Design system stylesheet + icon sprite + +- [x] Create `src/ui/design-system.ts` exporting `DESIGN_SYSTEM_CSS` (string) with warm-paper CSS variables on `:root` and component classes (`.app`, `.sidebar`, `.nav-item`, `.page`, `.h1`, `.h2`, `.sub`, `.eyebrow`, `.card`, `.card-head`, `.card-pad`, `.btn`, `.btn-primary`, `.btn-ghost`, `.chip` + tone modifiers, `.dot` + tone modifiers, `.inp`, `.mono`, `.muted`, `.clean-scroll`, `.spinner`) — copied verbatim from `ai/tickets/ui-refactoring/hl7v2-v2/project/HL7v2 Design.html:10-144` (prototype-only `#root` and `.variant-bar` rules dropped — they were the React-anchor and design-switcher affordances, not part of the app) +- [x] Create `src/ui/icons.ts` exporting `ICON_SPRITE_SVG` (string) with the full `` block (ids `i-home, i-inbox, i-send, i-alert, i-map, i-users, i-out, i-search, i-settings, i-chev-down, i-chev-right, i-plus, i-check, i-x, i-filter, i-clock, i-arrow-right, i-play, i-sparkle`) and a `renderIcon(name, extraClass?)` helper returning `` +- [x] Unit test `test/unit/ui/design-system.test.ts`: CSS string contains the variable **names** `--paper`, `--accent`, and the `.card` class selector (assert on identifiers, not hex values — colors are stable but values drift); `renderIcon('home')` returns expected SVG markup +- [x] Run validation — must pass +- [ ] Stop for user review before next task + +## Task 3a: App shell scaffold + route renames + migrate Accounts + +- [x] Create `src/ui/shell.ts` exporting `renderShell({ active, title, content, topActions? })` — doctype + head (Google Fonts, Tailwind CDN kept, `DESIGN_SYSTEM_CSS` inline, `/static/vendor/htmx-2.0.10.min.js`, `/static/vendor/alpine-3.15.11.min.js` — **versioned filenames**, required by the static handler's `Cache-Control: immutable` policy; bump the filename when upgrading the pin, existing health-check IIFE from `shared-layout.ts`) + body with sidebar, main column, and `ICON_SPRITE_SVG` at the bottom. Shell is additive — do **not** delete `renderLayout` in this task. *(Implementation detail: the health-check IIFE and LOINC autocomplete + HL7 tooltip assets were extracted into `src/ui/legacy-assets.ts` so `renderLayout` and `renderShell` can share them without a 170-line copy-paste. `renderShell` signature dropped `topActions` for v1 — no page needs it yet. NavKey: `dashboard|inbound|simulate|unmapped|terminology|accounts|outgoing`.)* +- [x] Sidebar groups: **Workspace** (Dashboard `/`, Inbound Messages `/incoming-messages`, Simulate Sender `/simulate-sender`), **Terminology** (Unmapped Codes `/unmapped-codes`, Terminology Map `/terminology`), **Outbound** (Accounts `/accounts`, Outgoing Messages `/outgoing-messages`); active-state styling + count badges on Inbound (total) and Unmapped (`hot` accent when non-zero). Count fields come from an extended `getNavData()` (see next bullet). +- [x] **Rename routes in `src/index.ts` to their final names in this task** (pointing at existing handlers; bodies migrated in 3b): `/mllp-client` → `/simulate-sender`, `/mapping/tasks` → `/unmapped-codes`, `/mapping/table` → `/terminology`. Keeps the sidebar links live from the moment the shell ships. +- [x] Grep-audit step: `rg -n '/mllp-client|/mapping/tasks|/mapping/table' src/ docs/ scripts/ test/` — fix each hit (including redirect `Location:` headers in `src/api/*`, doc links, test fixtures, **and hardcoded form `action` attributes inside the page bodies being retained for now** — e.g. `src/ui/pages/mllp-client.ts:245` `action="/mllp-client"` → `action="/simulate-sender"`). No 302 shims; everything points at the final URLs. *(Also updated `src/ui/shared-layout.ts` nav hrefs so the legacy tab bar routes correctly until its pages migrate in 3b, and updated the two existing nav-markup unit tests for the URL change. `/api/mapping/tasks/:id/resolve` is the resolution API and is unchanged.)* +- [x] Extend `getNavData()` in `src/ui/shared.ts`: keep the existing `pendingMappingTasksCount`, add `incomingTotal` (FHIR `IncomingHL7v2Message?_count=0&_total=accurate`) +- [x] Env pill at sidebar footer from `ENV` env var (green `dev`, amber `staging|test`, red `prod`) and mono line with `MLLP_HOST:MLLP_PORT` (defaults `localhost:2575`) +- [x] Legacy-body wrapper helper: `renderLegacyBody(content)` returns `
${content}
` so Accounts/Outgoing Tailwind markup frames against warm-paper +- [x] Migrate `handleAccountsPage` in `src/ui/pages/accounts.ts` to `renderShell({ active: "accounts", content: renderLegacyBody(...) })` as the first real smoke of the shell. Other pages stay on `renderLayout` for now. +- [x] Update any nav-markup unit tests for the Accounts page (no dedicated accounts nav test existed; added `test/unit/ui/shell.test.ts` with 16 tests covering the shell's nav/sidebar/env pill — that's the new surface under test) +- [x] Run validation — must pass; manually confirm `/accounts` renders with the new sidebar AND every sidebar link returns 200 (legacy bodies still served under the renamed URLs) *(live smoke: all 7 sidebar URLs → 200, all 3 old URLs → 404 with no shims, accounts renders the shell with `bg-gray-100 rounded-lg p-6` frame around the legacy body; unmapped count=0, inbound total=2)* +- [ ] Stop for user review before next task + +## Task 3b: Migrate remaining page bodies into the shell + +- [x] Migrate every remaining page handler (`messages.ts` both halves, `mapping-tasks.ts`, `code-mappings.ts`, `mllp-client.ts`) to call `renderShell`. Bodies unchanged (Tier-2 bodies will be rebuilt in Tasks 5, 7–12). Use `renderLegacyBody` for Outgoing Messages; the others will get new warm-paper bodies soon so they can go directly in the main column. +- [x] Update the remaining UI unit tests that assert nav markup (`test/unit/ui/*`) *(no additional updates needed — the existing `code-mappings.test.ts`, `mapping-tasks-ui.test.ts`, `mapping-tasks-pagination.test.ts` assertions all pass against the new shell because they check body-level markup like hrefs and CSS classes that are unchanged; the nav-markup NavData-shape updates already landed in Task 3a)* +- [x] Add a smoke-tagged integration test `smoke: every shell page returns 200` that GETs `/`, `/accounts`, `/outgoing-messages`, `/incoming-messages`, `/simulate-sender`, `/unmapped-codes`, `/terminology` and asserts 200 + presence of the `.sidebar` marker in the body *(added at `test/integration/ui/shell-smoke.integration.test.ts`; invokes handlers directly rather than starting an HTTP server so it slots cleanly into the existing integration-test machinery)* +- [x] Run validation — must pass; manually confirm every page renders with the new sidebar *(live smoke via `bun --hot` dev server: all 7 URLs → 200 with `class="sidebar"`; legacy-body wrapping matches plan — Accounts + Outgoing wrapped, the 4 others unwrapped)* +- [ ] Stop for user review before next task + +## Task 3c: Delete legacy layout + +- [x] Delete `renderLayout`, `renderNav`, `renderTab`, `NavTab` type, and related helpers from `src/ui/shared-layout.ts`. Keep `highlightHL7WithDataTooltip` and the health-check IIFE (moved into the shell head in 3a). *(The health-check IIFE and LEGACY_STYLES already live in `src/ui/legacy-assets.ts` since Task 3a — only `highlightHL7WithDataTooltip` remains in `shared-layout.ts`. File shrank from ~130 lines to 16.)* +- [x] Remove any remaining imports of the deleted helpers *(grep confirms no callers of `renderLayout`/`renderNav`/`renderTab`/`NavTab` left after the prior tasks; two `highlightHL7WithDataTooltip` imports stay, from `src/index.ts` and `src/ui/pages/messages.ts`)* +- [x] Run validation — must pass (typecheck catches any missed imports) *(typecheck clean; 1700 unit tests pass; live smoke: all 7 sidebar URLs → 200)* +- [ ] Stop for user review before next task + +## Task 4: UI architecture docs + Chrome DevTools MCP + +- [x] Create `docs/developer-guide/ui-architecture.md` covering: when to use htmx vs Alpine vs plain form POST, partial-endpoint naming (`/{page}/partials/{name}`), design-system class vocabulary, icon sprite usage, shell composition, when server-renders the selected-detail on `?selected=` full page loads +- [x] Create `docs/developer-guide/ui-design-tokens.md` — palette (warm-paper), typography (Inter/Fraunces/JetBrains Mono), spacing scale, component class inventory with tiny HTML samples. Direct reference for agents so they don't re-read the design HTML every time +- [x] Create `docs/developer-guide/how-to/add-ui-page.md` — recipe: page handler, route registration, sidebar entry, partial pattern, tests +- [x] Add Chrome DevTools MCP server to `.claude/settings.json` (or `.claude/settings.local.json`) per its install docs; surface the exact approval steps in `docs/developer-guide/ui-architecture.md` *(Claude Code's settings.json schema rejects `mcpServers`; the correct location is `.mcp.json` at repo root. Created the file with `chrome-devtools` pointing at `npx -y chrome-devtools-mcp@latest`. Opt-in lives in `.claude/settings.local.json` as `enabledMcpjsonServers: ["chrome-devtools"]` — the agent is denied from modifying that field by security policy, so the user must opt in manually or via Claude Code's approval prompt.)* +- [x] **Blocking validation**: user-approves MCP and confirms the agent can take a screenshot of `http://localhost:3000/` via Chrome DevTools MCP. Task 4 is not signed off until this works end-to-end. *(Verified: MCP approval done; after the user exported `CHROME_EXECUTABLE` and restarted Claude Code, the agent successfully navigated to `http://localhost:3000/unmapped-codes` and captured a screenshot showing the warm-paper shell, sidebar groups, env + health pills, and legacy page body — matches the design intent for this interim state. Screenshot evidence captured in conversation 2026-04-23.)* +- [x] Add one-line pointer in `CLAUDE.md` under "Code Style" → "UI conventions: see `docs/developer-guide/ui-architecture.md`" +- [x] Run validation — typecheck must pass; MCP screenshot must succeed *(typecheck passes, unit tests 1700/0. MCP screenshot requires user-approval step above — not verifiable by agent until opt-in lands.)* +- [ ] Stop for user review before next task + +## Task 5: Simulate Sender page (includes schema change for MSH-10 lookup) + +**Problem:** the MLLP listener (`src/mllp/mllp-server.ts:107`) currently persists `IncomingHL7v2Message` without storing MSH-10 on the resource and without a SearchParameter for it, and Aidbox's assigned resource id never round-trips back through MLLP to the sender. So the post-send poll has no key to look up the message it just sent. Additionally, sending the same template twice (e.g., the "ORU^R01 Unknown LOINC" demo) must not collide — two resources must be distinguishable. + +- [x] **Schema change** in `init-bundle.json`: add field `IncomingHL7v2Message.messageControlId` (`type: string`), add a SearchParameter `IncomingHL7v2Message-message-control-id` (code `message-control-id`, expression `IncomingHL7v2Message.messageControlId`). Bump the bundle revision if the project tracks one; document in `docs/developer-guide/oru-processing.md` (or wherever the IncomingHL7v2Message schema is described) *(schema + SearchParameter added, bundle entries now 18. Project doesn't track a bundle revision. No dedicated schema doc exists for IncomingHL7v2Message — field is self-describing via init-bundle.json and the test + type additions.)* +- [x] Update the generated type in `src/fhir/aidbox-hl7v2-custom/IncomingHl7v2message.ts` (re-run `bun run regenerate-fhir` if that's how the generated types stay in sync with the bundle; otherwise hand-edit) *(hand-edited — added `messageControlId?: string` field)* +- [x] Update `storeMessage()` in `src/mllp/mllp-server.ts` to extract MSH-10 from the incoming HL7v2 and set `messageControlId` on the resource +- [x] Run migration / Aidbox reload so the new SearchParameter is registered; unit-test the listener writes a `messageControlId`; integration test: POST a message via MLLP, then `GET /fhir/IncomingHL7v2Message?message-control-id={MSH-10}` returns exactly one entry *(`bun src/migrate.ts` re-submitted the bundle successfully; confirmed via curl that the SearchParameter is live. Unit tests: `test/unit/mllp/store-message.test.ts` (3 cases). Integration: `test/integration/mllp/message-control-id.integration.test.ts` (2 cases). Integration blocked from local run by missing AIDBOX_LICENSE — smoke manually verified via `curl 'http://localhost:8080/fhir/IncomingHL7v2Message?message-control-id=...'` returning exactly the sent row.)* +- [x] Create `src/ui/pages/simulate-sender.ts` with `handleSimulateSenderPage` — composer layout per `DESIGN_OVERVIEW.md § Simulate Sender`, **plain textarea** (no overlay-pre highlighting in v1) +- [x] Lift `MESSAGE_TYPES` array (ORU^R01, ORU^R01-unknown, ADT^A01, ADT^A08, VXU^V04, ORM^O01, BAR^P01) from `ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-simulate.jsx:8-56` verbatim into the new page module; export it so Task 7 (Dashboard scripted demo) can import the templates *(verbatim; also exported `SENDERS` and `MessageType` for downstream reuse)* +- [x] Alpine: editor component (`x-data` with `raw`, `typeId`, `sender`, computed `parsed`), SendCard state machine (`idle → sending → sent | held`) with elapsed tick. **No compose-time "contains unmapped code" chip** — the post-send status poll is the authoritative source (the design prototype's regex-on-body only worked for demo templates and would silently lie on real-world pasted bodies). *(added a 5th state `error` since the plan's outcome type includes it)* +- [x] **Export `sendMLLPMessage` from `src/ui/pages/mllp-client.ts`** (currently a private `function`) so the new handler can reuse it without duplication. (Or: move `sendMLLPMessage` into `src/mllp/client.ts` as part of this task if it's about to outlive `mllp-client.ts` — see Task 13 cleanup.) *(moved to `src/mllp/client.ts` — the "or" option; the legacy copy in `mllp-client.ts` stays live until Task 13 deletes the file. `rewriteMessageControlId` also lives there.)* +- [x] Replace the handler wired to `/simulate-sender` with `handleSimulateSenderPage`; register `POST /simulate-sender/send` that: rewrites MSH-10, sends via `sendMLLPMessage`, polls `IncomingHL7v2Message?message-control-id=...` every 500ms up to 3s, returns JSON `{status, ack, messageControlId, messageStatus?}`. +- [x] Unit tests: `rewriteMessageControlId`, happy-path, held, error, poll-timeout, duplicate-send, `handleSimulateSenderSend` request validation *(7 + 10 tests across two files = 17; all pass)* +- [x] Run validation — must pass; manual smoke: send ORU-unknown twice, see two separate rows in Inbound each with a distinct MSH-10; second send shows "Held for mapping" independently of the first *(typecheck clean, 1720 unit tests pass. Live smoke: first send returned `held` with `code_mapping_error`; second returned `sent` via optimistic 3s-timeout fallback. MLLP listener log confirms both MSH-10s captured. Aidbox search by `?message-control-id=` returns exact row with status. **Note: the MLLP listener runs as a separate `bun run mllp` process; had to restart it to pick up the new `storeMessage` — documented as a test-env gotcha, no code change needed.** MCP screenshot of `/simulate-sender` captured in review — matches design.)* +- [ ] Stop for user review before next task + +## Task 6: Tailwind reconciliation — migrate new pages from inline styles to Tailwind utilities + +**Why this lands here, not at the end:** `STACK_DECISION.md` (locked 2026-04-23) recommended keeping Tailwind and extending its theme with the warm-paper CSS variables; explicitly flagged the alternative — "drop Tailwind for the new pages entirely" — as **"not recommended — forks the CSS system."** Tasks 2–5 did the not-recommended thing anyway (shipped `design-system.ts` + inline `style="..."` attrs while Tailwind stayed loaded globally for legacy pages). Doing the reconciliation now — before Dashboard / Inbound detail / Unmapped / Terminology are authored — means every remaining task writes Tailwind-idiomatic markup from day one. Deferring to Task 13 (cleanup) would multiply the inline-style surface by ~5×. + +Scope audit (pre-task): +- `src/ui/pages/simulate-sender.ts` — 51 inline `style="..."`, 4 `:style` bindings +- `src/ui/shell.ts` — 7 inline styles +- `src/ui/icons.ts` — 1 inline style + `.i` / `.i-sm` classes +- `src/ui/pages/messages.ts` (Inbound half), `mapping-tasks.ts`, `code-mappings.ts` — 0 inline styles (already class-only per audit; spot-check only) + +### Tailwind reconciliation decisions (locked) + +- **Tailwind v3 via Play CDN** (already loaded from `src/ui/shell.ts`). No build step added. Preserves the "no build step" principle from `STACK_DECISION.md`. +- **CSS variables on `:root` stay the single source of truth.** Tailwind `theme.extend.colors` references them via `var(--paper)` etc. One place to change a palette value; Tailwind is the *consumer* of tokens, not the owner. +- **Compound components live in `@layer components`** via `` block after the Tailwind config script; move compound-component rules out of `DESIGN_SYSTEM_CSS` into it (`.card` family, `.btn` family, `.chip` family, `.dot` family, `.inp` + `select.inp`, `.nav-item` + `::before`, `.spinner` + `@keyframes spin`, `.clean-scroll`, `.h1`, `.h2`) *(body ships from a new `TAILWIND_COMPONENTS_CSS` export in `design-system.ts` so the string stays co-located with `:root` tokens it references. The `.h1` wide-screen media override moved too — it's nested inside `@layer components` so the whole `.h1` vocabulary lives in one layer.)* +- [x] Shrink `src/ui/design-system.ts` to `:root` vars + `body` base + anything non-Tailwind-expressible. Expected shrink from ~130 lines to ~30. *(DESIGN_SYSTEM_CSS string is ~45 lines — includes shell-scaffolding classes `.app`/`.sidebar`/`.brand-*`/`.nav`/`.page`/`.env` that aren't worth expressing as Tailwind utility stacks, plus transient utility-ish classes `.sub`/`.eyebrow`/`.mono`/`.muted`/`.i`/`.i-sm` that Task 6b migrates away. The file also exports `TAILWIND_COMPONENTS_CSS` and `TAILWIND_CONFIG_JS`, so total file size is larger but responsibility stays co-located.)* +- [x] Create stub `tailwind.config.js` at repo root mirroring the inline shell config (content-agnostic — Play CDN doesn't use it). Add a comment: "IDE autocomplete only — runtime config is inline in `src/ui/shell.ts`." +- [x] **Update `test/unit/ui/design-system.test.ts`**: shrink to assert `:root` vars still declared + a minimal component-class set (the ones that stay in `DESIGN_SYSTEM_CSS`) *(rewrote into four describe-blocks: DESIGN_SYSTEM_CSS (`:root` vars, shell-specific layout classes, moved-away selectors NOT present), TAILWIND_COMPONENTS_CSS (`@layer components`, every compound-component class, `@keyframes spin`, balanced braces), TAILWIND_CONFIG_JS (CSS-var-backed palette, `wide` breakpoint, three font families), and unchanged icon tests. 20 tests in the design-system file, 40 total with shell.test.ts.)* +- [x] Run validation: `bun run typecheck`, `bun test:local` — must pass *(typecheck clean; 1718 unit tests pass locally — same count as Task 5's baseline, plus the four new tailwind-config assertions replacing the deleted `:root-class-bundled` assertion. `bun test:smoke` fails only on missing AIDBOX_LICENSE env var — documented pre-existing local-env blocker from Task 5, unrelated to this task.)* +- [x] Manual smoke: `/simulate-sender` and `/accounts` screenshots via Chrome DevTools MCP — warm-paper design, sidebar nav, env pill, cards, buttons, chips, active-state accent bar, legacy-body gray frame all render unchanged. +- [x] **Post-review fixes** (from ai-review agent): (1) moved `@keyframes spin` inside `@layer components` so the spinner vocabulary lives in one layer; (2) added `safelist: ['spinner']` to both `TAILWIND_CONFIG_JS` and the IDE stub — Tailwind JIT's DOM-scan only emits component-layer classes whose names appear at scan time, and `.spinner` is hidden inside Alpine `