From 339f7f40026eb6cde949bfd94270fea9c41ecd1b Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Thu, 23 Apr 2026 09:03:56 +0600 Subject: [PATCH 01/26] add Claude Design files and UI refactoring plans --- .../2026-04-23-ui-design-system-refactor.md | 161 ++ ai/tickets/ui-refactoring/DESIGN_OVERVIEW.md | 277 ++ ai/tickets/ui-refactoring/STACK_DECISION.md | 87 + ai/tickets/ui-refactoring/hl7v2-v2/README.md | 25 + .../ui-refactoring/hl7v2-v2/chats/chat1.md | 2568 +++++++++++++++++ .../project/.design-canvas.state.json | 1 + .../hl7v2-v2/project/HL7v2 Design.html | 185 ++ .../hl7v2-v2/project/HL7v2 Wireframes.html | 155 + .../hl7v2-v2/project/design-canvas.jsx | 622 ++++ .../hl7v2-v2/project/design/app.jsx | 65 + .../project/design/page-dashboard.jsx | 275 ++ .../hl7v2-v2/project/design/page-inbound.jsx | 251 ++ .../hl7v2-v2/project/design/page-simulate.jsx | 254 ++ .../project/design/page-terminology.jsx | 468 +++ .../hl7v2-v2/project/design/page-unmapped.jsx | 214 ++ .../hl7v2-v2/project/design/shell.jsx | 59 + .../hl7v2-v2/project/screens-dashboard.jsx | 303 ++ .../hl7v2-v2/project/screens-inbound.jsx | 474 +++ .../hl7v2-v2/project/screens-rest.jsx | 897 ++++++ .../hl7v2-v2/project/screens-simulate.jsx | 496 ++++ .../hl7v2-v2/project/screens-unmapped.jsx | 228 ++ .../project/screenshots/01-modal-final.png | Bin 0 -> 26516 bytes .../project/screenshots/01-modal-fixed.png | Bin 0 -> 26516 bytes .../project/screenshots/01-modal-v2.png | Bin 0 -> 26993 bytes .../01-terminology-col-filters.png | Bin 0 -> 28364 bytes .../screenshots/01-terminology-modal.png | Bin 0 -> 28364 bytes .../project/screenshots/02-modal-final.png | Bin 0 -> 26516 bytes .../project/screenshots/02-modal-fixed.png | Bin 0 -> 26516 bytes .../project/screenshots/02-modal-v2.png | Bin 0 -> 26993 bytes .../02-terminology-col-filters.png | Bin 0 -> 28421 bytes .../screenshots/02-terminology-modal.png | Bin 0 -> 26516 bytes .../project/uploads/2026-04-22_14-17.png | Bin 0 -> 124088 bytes .../project/uploads/2026-04-22_14-17_1.png | Bin 0 -> 191626 bytes .../project/uploads/2026-04-22_14-17_2.png | Bin 0 -> 207880 bytes .../project/uploads/2026-04-22_14-18.png | Bin 0 -> 57747 bytes .../project/uploads/2026-04-22_14-18_1.png | Bin 0 -> 126076 bytes .../project/uploads/2026-04-22_14-18_2.png | Bin 0 -> 176791 bytes .../project/uploads/2026-04-22_14-19.png | Bin 0 -> 208913 bytes .../project/uploads/2026-04-22_14-19_1.png | Bin 0 -> 112624 bytes .../uploads/pasted-1776857719115-0.png | Bin 0 -> 50208 bytes .../uploads/pasted-1776857753954-0.png | Bin 0 -> 53312 bytes .../uploads/pasted-1776857767017-0.png | Bin 0 -> 75079 bytes .../uploads/pasted-1776864455106-0.png | Bin 0 -> 14836 bytes .../uploads/pasted-1776864774173-0.png | Bin 0 -> 61675 bytes .../project/uploads/ui/mapping-type-ui.ts | 26 + .../project/uploads/ui/pages/accounts.ts | 445 +++ .../project/uploads/ui/pages/code-mappings.ts | 414 +++ .../project/uploads/ui/pages/mapping-tasks.ts | 416 +++ .../project/uploads/ui/pages/messages.ts | 470 +++ .../project/uploads/ui/pages/mllp-client.ts | 402 +++ .../hl7v2-v2/project/uploads/ui/pagination.ts | 99 + .../project/uploads/ui/shared-layout.ts | 424 +++ .../hl7v2-v2/project/uploads/ui/shared.ts | 30 + .../hl7v2-v2/project/wireframes.jsx | 222 ++ 54 files changed, 11013 insertions(+) create mode 100644 ai/tickets/2026-04-23-ui-design-system-refactor.md create mode 100644 ai/tickets/ui-refactoring/DESIGN_OVERVIEW.md create mode 100644 ai/tickets/ui-refactoring/STACK_DECISION.md create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/README.md create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/chats/chat1.md create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/.design-canvas.state.json create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/HL7v2 Design.html create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/HL7v2 Wireframes.html create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/design-canvas.jsx create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/design/app.jsx create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-dashboard.jsx create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-inbound.jsx create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-simulate.jsx create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-terminology.jsx create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-unmapped.jsx create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/design/shell.jsx create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screens-dashboard.jsx create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screens-inbound.jsx create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screens-rest.jsx create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screens-simulate.jsx create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screens-unmapped.jsx create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screenshots/01-modal-final.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screenshots/01-modal-fixed.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screenshots/01-modal-v2.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screenshots/01-terminology-col-filters.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screenshots/01-terminology-modal.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screenshots/02-modal-final.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screenshots/02-modal-fixed.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screenshots/02-modal-v2.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screenshots/02-terminology-col-filters.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/screenshots/02-terminology-modal.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/2026-04-22_14-17.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/2026-04-22_14-17_1.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/2026-04-22_14-17_2.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/2026-04-22_14-18.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/2026-04-22_14-18_1.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/2026-04-22_14-18_2.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/2026-04-22_14-19.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/2026-04-22_14-19_1.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/pasted-1776857719115-0.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/pasted-1776857753954-0.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/pasted-1776857767017-0.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/pasted-1776864455106-0.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/pasted-1776864774173-0.png create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/mapping-type-ui.ts create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/pages/accounts.ts create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/pages/code-mappings.ts create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/pages/mapping-tasks.ts create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/pages/messages.ts create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/pages/mllp-client.ts create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/pagination.ts create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/shared-layout.ts create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/shared.ts create mode 100644 ai/tickets/ui-refactoring/hl7v2-v2/project/wireframes.jsx 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..62a08e3e --- /dev/null +++ b/ai/tickets/2026-04-23-ui-design-system-refactor.md @@ -0,0 +1,161 @@ +# 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. 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. +- **ACK History 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 ACK tab is opened. No new resource, no processor changes. Docs: https://www.health-samurai.io/docs/aidbox/api/rest-api/history +- **Vendor htmx + Alpine outside `src/`.** Static assets live at project root under `public/vendor/`; served by a new static-file route. +- **Chrome DevTools MCP** is set up as part of this refactor 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 + +- [ ] Create `public/vendor/`; download pinned htmx 1.9.x and Alpine 3.14.x minified builds (vendored, not CDN) +- [ ] Register `GET /static/*` in `src/index.ts` serving from `public/` via `Bun.file()`; set Content-Type from extension; reject any path containing `..` +- [ ] 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 +- [ ] Add unit test in `test/unit/ui/static-route.test.ts`: real-file hit returns 200 + correct Content-Type; missing file returns 404; `../` traversal returns 400/404 +- [ ] Run validation — must pass +- [ ] Stop for user review before next task + +## Task 2: Design system stylesheet + icon sprite + +- [ ] 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` +- [ ] 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 `` +- [ ] Unit test `test/unit/ui/design-system.test.ts`: CSS string contains `--paper`, `--accent #C6532A`, and the `.card` class; `renderIcon('home')` returns expected SVG markup +- [ ] Run validation — must pass +- [ ] Stop for user review before next task + +## Task 3: App shell + unified sidebar (no page bodies changed) + +- [ ] 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.min.js`, `/static/vendor/alpine.min.js`, existing health-check IIFE from `shared-layout.ts`) + body with sidebar, main column, and `ICON_SPRITE_SVG` at the bottom +- [ ] 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) +- [ ] Extend `getNavData()` in `src/ui/shared.ts`: add `incomingTotal` (FHIR `IncomingHL7v2Message?_count=0`) alongside existing `pendingTaskCount` +- [ ] 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`) +- [ ] Migrate every existing page handler in `src/ui/pages/*.ts` (accounts, messages for both halves, mapping-tasks, code-mappings, mllp-client) to call `renderShell` instead of `renderLayout`; keep `renderLayout` exported as a thin deprecated wrapper for one release so nothing breaks mid-migration +- [ ] Update any UI unit test that asserts nav markup (`test/unit/ui/*`) for the new sidebar structure +- [ ] Run validation — must pass; manually confirm every existing page renders with the new sidebar and old body intact +- [ ] Stop for user review before next task + +## Task 4: UI architecture docs + Chrome DevTools MCP + +- [ ] 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 +- [ ] 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 +- [ ] Create `docs/developer-guide/how-to/add-ui-page.md` — recipe: page handler, route registration, sidebar entry, partial pattern, tests +- [ ] Add Chrome DevTools MCP server to `.claude/settings.json` (or `.claude/settings.local.json`) per its install docs; MCP approval is user-initiated — surface the exact approval steps in `docs/developer-guide/ui-architecture.md` +- [ ] Add one-line pointer in `CLAUDE.md` under "Code Style" → "UI conventions: see `docs/developer-guide/ui-architecture.md`" +- [ ] Run validation — must pass (typecheck is the only mechanical check for docs; no broken imports) +- [ ] Stop for user review before next task + +## Task 5: Dashboard page + scripted demo runner + +- [ ] Create `src/api/demo-scenario.ts` exporting `runDemoScenario()` that fires four MLLP messages (ADT^A01, ORU^R01 known, VXU^V04, ORU^R01 unknown) with 2s spacing, fire-and-forget (`.catch(console.error)`); sample payloads lifted from `src/ui/pages/mllp-client.ts:130-175` +- [ ] Register routes in `src/index.ts`: `POST /demo/run-scenario` (guarded by `DEMO_MODE !== "off"`, returns 202), `GET /dashboard/partials/stats`, `GET /dashboard/partials/ticker?limit=15` +- [ ] Create `src/ui/pages/dashboard.ts` with `handleDashboardPage`: hero + demo-conductor card + stats strip + live ticker per `DESIGN_OVERVIEW.md § Dashboard`; htmx `hx-trigger="every 10s"` on stats, `every 5s` on ticker; Alpine `x-data` for ticker-pause toggle +- [ ] Stats partial computes: received-today, need-mapping (`status=code_mapping_error`), errors (multi-status OR), avg-latency (from `meta.lastUpdated - date` over last 100 processed messages in-memory), worker health (new `getWorkerHealth()` in `src/workers.ts` exposing the current handle's running state) +- [ ] Move `GET /` in `src/index.ts` from `handleAccountsPage` to `handleDashboardPage`; `/accounts` stays reachable +- [ ] Unit tests for `demo-scenario.ts` (fires 4 sends in order) and both partials (happy path + empty state) +- [ ] Run validation — must pass; manual smoke: click "Run demo now", confirm 4 rows appear in the ticker within ~10s +- [ ] Stop for user review before next task + +## Task 6: Simulate Sender page + +- [ ] Create `src/ui/pages/simulate-sender.ts` with `handleSimulateSenderPage` — composer layout per `DESIGN_OVERVIEW.md § Simulate Sender` +- [ ] 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 +- [ ] Alpine: editor component (`x-data` with `raw`, `typeId`, `sender`, computed `parsed`/`hasUnknown`), overlay `
` + transparent textarea (accent segment names, warn highlight on unmapped tokens), SendCard state machine (`idle → sending → sent | held`) with elapsed tick
+- [ ] Register `GET /simulate-sender` and `POST /simulate-sender/send` in `src/index.ts`; POST handler reuses `sendMLLPMessage()` from `src/ui/pages/mllp-client.ts` and returns JSON `{status: "sent"|"held", ack: string, messageId: string}`; ACK `AA` → sent, `AE` → held
+- [ ] Add 302 redirect `/mllp-client` → `/simulate-sender`
+- [ ] Unit tests: happy-path send, held-for-mapping send (unknown code in body), MLLP-unreachable error path
+- [ ] Run validation — must pass; manual smoke in browser (pick ORU unknown template, send, see "Held for mapping" banner)
+- [ ] Stop for user review before next task
+
+## Task 7: Inbound Messages — list pane + type chips
+
+- [ ] Create `src/ui/pages/inbound.ts` with `handleInboundMessagesPage` — hero, type-chip row, two-pane layout (list card + empty-state detail card); supports `?type=&status=&batch=&selected=` URL params; when `selected` is set, pre-render detail pane server-side into `#detail`
+- [ ] Register routes: `GET /incoming-messages/partials/list?type=&status=&batch=&selected=`, `GET /incoming-messages/partials/type-chips`
+- [ ] Type-chips partial: scans `_count=500` recent `IncomingHL7v2Message`, groups by `type` in-memory, renders chip row with counts; `errors` pseudo-chip aggregates the 4 error statuses
+- [ ] Htmx wiring: row click → `hx-get="/incoming-messages/:id/partials/detail" hx-target="#detail" hx-push-url="?selected=:id"`; list auto-refresh `every 5s` guarded off by Alpine when a row is selected (no mid-edit stomp)
+- [ ] Swap `GET /incoming-messages` from `handleIncomingMessagesPage` (in `src/ui/pages/messages.ts`) to the new `handleInboundMessagesPage`; keep the Outgoing half of `messages.ts` intact
+- [ ] Update/rewrite the affected incoming-messages unit test for the new markup; add one for the type-chips partial
+- [ ] Run validation — must pass
+- [ ] Stop for user review before next task
+
+## Task 8: Inbound Messages — detail pane + 4 tabs (with Aidbox history)
+
+- [ ] **OPEN: runtime-verify** Aidbox `_history` is enabled on the dev instance before starting this task. Run `curl -u root:Vbro4upIT1 "http://localhost:8080/fhir/IncomingHL7v2Message/{any-id}/_history?_count=5"` and confirm response is a `Bundle` with `entry[]` each carrying `meta.versionId` and distinct `meta.lastUpdated`. If not, investigate the `BOX_*` toggle via Aidbox support docs before proceeding.
+- [ ] Register `GET /incoming-messages/:id/partials/detail` (shell + default `structured` tab) and `GET /incoming-messages/:id/partials/detail/:tab` (tab-specific fragment)
+- [ ] Implement 4 tab handlers: `structured` (re-parse stored `message` via `@atomic-ehr/hl7v2`, render segment mini-cards; warn-border when segment contains the problem code), `raw` (reuse `highlightHL7WithDataTooltip` from `shared-layout.ts`), `fhir` (pretty-print `entries` array; warn highlight on unresolved codings with inline `// ⚠ no LOINC mapping` comment), `acks` (fetch `/fhir/IncomingHL7v2Message/:id/_history?_count=50`, render timeline rows with `meta.lastUpdated` + status transition + error if present; infer step chip heuristically from status delta)
+- [ ] Detail header actions: "Replay" posts to existing `POST /mark-for-retry/:id`; "Map code" (visible only when `unmappedCodes?.length`) links to `/unmapped-codes?code={localCode}&sender={sender}`
+- [ ] Tab switching: `hx-get` on tab button with `hx-target="#detail-body"`; ACK tab is the only one that triggers an extra Aidbox call (lazy per user intent, per architectural decision)
+- [ ] Unit tests for each of the 4 tab handlers (happy path + an error state per tab); integration test in `test/integration/ui/` hitting `_history` against the test Aidbox for a message that's transitioned at least twice
+- [ ] Run validation — must pass; manual smoke walks all 4 tabs on a real message
+- [ ] Stop for user review before next task
+
+## Task 9: Unmapped Codes rebuild + suggestion scoring
+
+- [ ] Create `src/api/terminology-suggest.ts` exporting `suggestCodes(display, field)`: wraps existing `searchLoincCodes()` from `src/code-mapping/terminology-api.ts`; scores each result (exact-substring in display → 100; Jaro-Winkler similarity → 40–95; short-display bonus); returns top 3 `{code, display, score, system}`
+- [ ] Register `GET /api/terminology/suggest?display=&field=` route
+- [ ] Create `src/ui/pages/unmapped.ts` with `handleUnmappedCodesPage` — queue + editor split per `DESIGN_OVERVIEW.md § Unmapped Codes`; supports `?code=&sender=` pre-selection (matches the "Map code" link from Inbound)
+- [ ] Register `GET /unmapped-codes/partials/queue` and `GET /unmapped-codes/:code/partials/editor?sender=`; editor partial calls `suggestCodes()` for the pre-selected code's display
+- [ ] Queue partial: aggregates open `Task?status=requested` (existing query), regroups by `localCode + sender + field`, counts messages via `Task.input`; returns queue list HTML
+- [ ] Wire actions: Save → existing `POST /api/mapping/tasks/:id/resolve`; Skip → new `POST /unmapped-codes/:code/skip?sender=` that wraps `POST /defer/:id` per matching task; "Skip all" and "Suggest with AI" buttons rendered `disabled` with "coming soon" chip (explicit v1 non-goals)
+- [ ] Add 302 redirect `/mapping/tasks` → `/unmapped-codes`
+- [ ] Unit tests: scoring helper (known-high + known-low cases), queue partial (groups correctly), editor partial (pre-selection loads suggestions)
+- [ ] Run validation — must pass
+- [ ] Stop for user review before next task
+
+## Task 10: Terminology Map — table + filter popovers + detail
+
+- [ ] Create `src/ui/pages/terminology.ts` with `handleTerminologyPage` — KPI strip + two-pane (table + detail); supports `?q=&fhir=&sender=` URL params (multi-valued `fhir` and `sender`)
+- [ ] Register partials: `GET /terminology/partials/table?q=&fhir=&sender=` (server-filtered rows), `GET /terminology/partials/facets/fhir`, `GET /terminology/partials/facets/sender`, `GET /terminology/partials/detail/:conceptMapId/:code`
+- [ ] Facet partials: in-memory scan of all ConceptMap entries (reuse existing `listConceptMaps` + group), return `{name, count}[]` rendered as searchable multi-select list; Alpine popover (`x-on:click.outside`, `x-on:keyup.escape`) wraps them
+- [ ] Detail partial: FHIR target (split typography), local/std mapping, source panel, minimal lineage (creation time from `ConceptMap/_history?_count=1` lazy), Deprecate/Edit footer
+- [ ] KPI strip values: total mappings (sum across all maps), coverage % (processed / processed + code_mapping_error over last 30d), messages/window (`_count=0` for 30d), needs-review (literal `0` for v1 until deprecation-tracking ticket lands)
+- [ ] **OPEN:** usage count per entry (design shows `usage:4820`) — not tracked today. Render `—` for v1 with a note in `docs/developer-guide/ui-design-tokens.md`; separate ticket for denormalized counters
+- [ ] Add 302 redirect `/mapping/table` → `/terminology`
+- [ ] Unit tests for the 4 new partials; integration test hitting facet counts against the test Aidbox
+- [ ] Run validation — must pass
+- [ ] Stop for user review before next task
+
+## Task 11: Terminology Map — Add/Edit modal
+
+- [ ] Register `GET /terminology/partials/modal?mode=add` and `GET /terminology/partials/modal?mode=edit&conceptMapId=&code=` — returns modal body HTML (FHIR target select add-only, local system + code two-column, local display, search-to-map input with icon); edit mode locks target field
+- [ ] Alpine wiring: backdrop + ESC + ✕ close; submit disabled until `localCode` + `targetCode` filled
+- [ ] Submit routes to existing `POST /api/concept-maps/:id/entries` (add) or `POST /api/concept-maps/:id/entries/:code` (edit); on success: close modal, htmx-swap the table partial to refresh the row
+- [ ] Deprecate button in detail footer: `POST /api/concept-maps/:id/entries/:code/delete` (existing); confirm via native `confirm()` for v1 — follow-up to replace with inline Alpine confirm popover
+- [ ] Unit tests: modal renders in both modes, submit path succeeds, disabled-when-empty gate works
+- [ ] Run validation — must pass; manual smoke: Add a mapping via the modal, confirm it appears in the table and is retrievable via `GET /fhir/ConceptMap`
+- [ ] Stop for user review before next task
+
+## Task 12: Cleanup
+
+- [ ] Delete dead code: old `renderIncomingMessagesPage` body in `src/ui/pages/messages.ts` (keep Outgoing), legacy HTML rendering in `src/ui/pages/mapping-tasks.ts` and `src/ui/pages/code-mappings.ts`, page-render functions in `src/ui/pages/mllp-client.ts` (keep `sendMLLPMessage` + `sendMLLPTest` primitives)
+- [ ] Remove `renderLayout` + top-nav helpers from `src/ui/shared-layout.ts` once all callers are on `renderShell`; keep `highlightHL7WithDataTooltip` and the LOINC autocomplete IIFE (still used)
+- [ ] Sanity-check: every sidebar link returns 200; every redirected old URL (`/mllp-client`, `/mapping/tasks`, `/mapping/table`) 302s correctly
+- [ ] Close `ai/tickets/2026-04-22-demo-ready-ui-tier1.md`: mark tasks 3–7 as "superseded by `2026-04-23-ui-design-system-refactor.md`", leave tasks 1–2 as-is (already done)
+- [ ] End-to-end manual demo walkthrough: `/` Dashboard → Run demo → ticker shows 4 → click warn row → Inbound detail walks Structured/Raw/FHIR/ACK tabs → Map code → Unmapped pre-selected → accept top suggestion → message reprocesses → Terminology Map shows the new entry → sidebar links all still work including Accounts/Outgoing
+- [ ] Final `bun test:all`
+- [ ] Stop for user review — confirm demo walkthrough feels right
+
+---
+
+## Non-goals for v1 (tracked for follow-up)
+
+- Per-mapping usage counts / last-seen timestamps (show `—` for now)
+- Deprecate-with-review lifecycle for ConceptMap entries
+- LLM-backed "Suggest with AI" batch action
+- Sender-health page (deferred per `DESIGN_OVERVIEW.md` late-stage suggestions)
+- Per-type `SearchParameter` on `IncomingHL7v2Message` (in-memory grouping is enough for demo scale)
+- Custom `ProcessingLog` resource (Aidbox `_history` substitutes)
+- Self-hosted fonts (Google Fonts CDN for v1)
+- Re-skinning Accounts/Outgoing Messages to the warm-paper palette
diff --git a/ai/tickets/ui-refactoring/DESIGN_OVERVIEW.md b/ai/tickets/ui-refactoring/DESIGN_OVERVIEW.md
new file mode 100644
index 00000000..8f3fc6e1
--- /dev/null
+++ b/ai/tickets/ui-refactoring/DESIGN_OVERVIEW.md
@@ -0,0 +1,277 @@
+# HL7v2 UI Refactoring — Design Overview
+
+Source bundle: `hl7v2-v2/` (exported from Claude Design, 2026-04-22).
+Primary design file: `hl7v2-v2/project/HL7v2 Design.html` — pulls in the `design/*.jsx` modules (React 18 + Babel standalone, inline styles + CSS variables).
+Wireframes precursor: `hl7v2-v2/project/HL7v2 Wireframes.html` (+ `screens-*.jsx`). Not the final design; kept for history.
+User intent transcript: `hl7v2-v2/chats/chat1.md`.
+Reference snapshot of today's UI (for comparison only, not design): `hl7v2-v2/project/uploads/ui/`.
+
+## Scope decided in the transcript
+
+Five screens are in scope; **Accounts** and **Outgoing Messages** were explicitly cut:
+
+| Screen               | Variant kept  | Source                          |
+| -------------------- | ------------- | ------------------------------- |
+| Dashboard            | **B** (Demo control) | `design/page-dashboard.jsx` (`DashboardB`) |
+| Inbound Messages     | **A** (List + detail) | `design/page-inbound.jsx` (`InboundA`) |
+| Simulate Sender      | **A** (Composer) | `design/page-simulate.jsx` (`SimulateA`) |
+| Unmapped Codes       | **A** (triage inbox) | `design/page-unmapped.jsx` (`UnmappedA`) |
+| Terminology Map      | **A** (canonical ledger) | `design/page-terminology.jsx` (`TerminologyA`) |
+
+The global topbar (breadcrumbs + ⌘K search + settings + avatar) was removed in the last iteration — page `

`s carry the title. Variant A/B switcher at bottom-right is design-review scaffolding, not production. + +## Design system / visual language + +- **Palette — "warm paper"**, defined as CSS variables on `:root` in `HL7v2 Design.html`: + - Surface: `--paper #FBF8F2` (canvas), `--paper-2 #F5F0E6`, `--surface #FFFFFF` + - Text: `--ink` / `--ink-2` / `--ink-3` (primary / body / muted) + - Lines: `--line`, `--line-2` + - Accent: `--accent #C6532A` (terracotta), `--accent-soft`, `--accent-ink` + - Semantic: `--ok` green, `--warn` amber-gold, `--err` deep red, each with a `-soft` variant +- **Typography**: Inter (sans), Fraunces (serif — headings, stat numbers, italic voice moments), JetBrains Mono (codes, timestamps, HL7 segments). +- **Components** (all inline-styled; no Tailwind in the prototype): + - `.card` / `.card-head` / `.card-pad` — bordered white surfaces + - `.btn` / `.btn-primary` / `.btn-ghost` + - `.chip` / `.chip-accent` / `.chip-ok` / `.chip-warn` / `.chip-err` — status badges + - `.dot` + modifiers — colored status dots + - `.inp` — inputs; focus ring uses `--accent-soft` + - `.eyebrow` — small uppercase label above section titles + - `Icon` (`design/shell.jsx`) — inline SVG sprite at bottom of the HTML, names: `home, inbox, send, alert, map, users, out, search, settings, chev-down, chev-right, plus, check, x, filter, clock, arrow-right, play, sparkle` +- **Layout**: 252px sticky sidebar + main column, `max-width: 1760px`, generous padding (`32–56px`), 8–22px gaps. +- **Voice**: occasional serif italic quotes ("HL7v2 isn't broken — it's just lived-in"; "written once, replayed forever"). Used as empty-state / pull-quote flavor, not chrome. + +## Global navigation (`design/shell.jsx`) + +Left sidebar, two groups: + +- **Workspace**: Dashboard, Inbound Messages (shows count, e.g. `12.8k`), Simulate Sender +- **Terminology**: Unmapped Codes (count with `hot` accent color when non-zero, e.g. `17`), Terminology Map +- Footer: environment pill — green dot + `staging` + `mllp://10.1.4.22:2575`. +- Active item: white background + left accent bar. +- Brand: `h7` mark + "Inbound" + subtitle "HL7v2 · FHIR bridge". + +Counts in the nav are hints the backend should supply (inbound total + unmapped-codes pending count). + +## Common patterns to carry across pages + +- **Page shell**: `
` with 18–22px gap between sections. +- **Page hero**: `eyebrow` + `h1` (serif) + `sub`, optional right-side action buttons. +- **Card + card-head** for any grouped content. Card-sub on the right of card-head is mono/muted — used for timestamps, counts. +- **List with detail**: 2-column grid (`minmax(560px, 1fr) 1fr` or `1fr 380px`), left is a table/list, right is a sticky detail panel. Selected row gets `--paper-2` bg and left `--accent` bar. +- **Status colors**: `ok` (green), `warn` (amber, actionable), `err` (red, hard fail), `pend` (neutral gray). +- **Modal**: fixed overlay with backdrop blur, centered card (max ~620px), ESC + click-outside close, sticky header + scrollable body + sticky footer. See `TerminologyA` form. +- **Filter popovers in column headers**: searchable multi-select with checkboxes + per-option counts. Active count badge on the filter icon. See `ColHeader` / `FilterList` in `page-terminology.jsx`. +- **Live indicator**: `dot ok` with soft shadow + "auto-refresh · 5s" mono label. + +--- + +## Pages + +### 1. Dashboard — `DashboardB` (Demo conductor) + +**Purpose:** give a prospect a one-click demo story in under a minute; secondarily, show pipeline health. + +**Sections:** +1. Hero: eyebrow "Staging · scripted demo", h1 "Demo control", one-liner subtitle. +2. **Demo conductor card**: 4-step horizontal stepper (ADT^A01 → ORU^R01 → VXU^V04 → ORU unknown) with primary "Run demo now" button + "Send single" / "Reset" ghost buttons. Right-side stack. +3. **Stats strip** (horizontal card, compact): Received today (+delta), Need mapping (warn), Errors (err), Avg latency, Workers health (dots for ORU processor / BAR builder / BAR sender + "polling every 5s"). +4. **Live ticker card**: streaming feed of `{time, type, sender → note, status}` rows, auto-refresh 5s with pause. `dot accent` header dot. + +**Actions:** +- Run demo scenario (fire-and-forget a pre-recorded sequence via MLLP). +- Run single message. +- Reset demo (clear last run / stats). +- Pause ticker auto-refresh. +- Filter ticker (button in original design A — might not be needed in B). + +**Modals:** none. + +**Backend needs (ideas, not spec):** +- Current counts: received today, need mapping, errors, avg latency, last-run timestamp. +- Worker status per service (ORU/ADT/VXU processor, BAR builder, BAR sender). +- Recent message stream (last ~N rows or SSE/poll) with type, sender, subject/note, status. +- Scripted demo trigger endpoint + list of scenarios (payloads can live in backend rather than frontend). + +### 2. Inbound Messages — `InboundA` + +**Purpose:** the integration engineer's daily view — browse live traffic, drill into any message to see raw/structured/FHIR/ACK history. + +**Sections:** +1. Hero: "Inbound messages · 142 received today · 3 in triage · 2 errors" + filter/time-range/search buttons. +2. **Type filter chips** row: All, ORU^R01, ADT^A01, ADT^A08, ADT^A03, ORM^O01, BAR^P01, VXU^V04, errors — each with a count, active chip uses `chip-accent`. +3. **Two-pane split**: + - **Left — message list (card)**: columns `dot · time · type · sender · message · status-chip`, with a pinned header row and selectable rows (click → update detail pane). + - **Right — detail pane (card)**: header with status chip + type chip + message-id mono + "Replay" / "Map code" actions, then h2 `sender → receiver`, explanatory line, then **4 tabs**: + - `Structured` — collapsed segment list (MSH, PID, PV1, ORC, OBR, OBX), each a mini-card with colored left-border if the segment is the problem; warning chip in segment header; field table `120px label | 1fr mono value` with unmapped values highlighted in warn. + - `Raw HL7` — mono pre-block, segment names colored in accent, problem tokens highlighted in warn-soft. + - `FHIR resources` — rendered bundle JSON with warn highlight on unresolved codings + inline `// ⚠ no LOINC mapping` comment. + - `ACK history` — processing timeline rows `time · step chip · description · dot`. + +**Actions per message:** +- Select row to view detail +- Replay +- Map code (jump to Unmapped Codes with the code pre-filled) +- Search / filter (by type, time range, sender, status) +- Type-chip filter pills (quick filter by type) + +**Modals:** none (detail is inline, tabbed). + +**Backend needs:** +- Paginated / streaming list of messages with `time, type, sender, subject, status, id`. +- Aggregated counts per type + global counters (today, triage, errors) for hero + chips. +- Message detail: parsed segments, raw HL7 text, converted FHIR bundle, ACK/processing log with timestamps. +- Replay endpoint (re-run pipeline on this message). +- Cross-link to a code-mapping creation flow ("Map code" action). + +### 3. Simulate Sender — `SimulateA` + +**Purpose:** developer/demo tool to fire HL7v2 at the MLLP listener from the UI. + +**Sections:** +1. Hero: eyebrow "Compose & send · staging MLLP · 10.1.4.22:2575", h1, subtitle. +2. **Two-pane split**: + - **Left — editor card**: header with `message.hl7` filename, version chip, segment count chip, resolvable-codes chip (ok / warn if `UNKNOWN_TEST` or `LOCAL` detected). Body: mono textarea with line numbers, segment names highlighted accent, unmapped-code tokens highlighted warn. Footer: mono char/segment counter. + - **Right — controls stack**: + - **Quick tweaks card**: Sender select (MSH-3) — ACME_LAB / StMarys / CHILDRENS / billing; Message type select with description line (turns warn if type contains unmapped codes). + - **SendCard** (stateful): `idle | sending | sent | held | error` states. + - idle: big "Send" + "then jump to Inbound" hint + - sending: spinner button + step checklist (Open MLLP connection / Transmit / Await ACK) with elapsed timer + - sent: green "Sent · accepted" banner with ACK MSA|AA + message id; "Send another" + "jump to Inbound" + - held: amber "Held for mapping" banner with ACK MSA|AE + explanation + "see it in Unmapped codes" link + +**Prebuilt message templates** (`MESSAGE_TYPES` in source): ORU^R01 (clean), ORU^R01-unknown, ADT^A01, ADT^A08, VXU^V04, ORM^O01, BAR^P01. Each has a `build(sender)` generator. + +**Actions:** +- Pick message type / sender (regenerates body) +- Edit raw HL7 freely +- Send to MLLP listener +- Jump to Inbound / Unmapped after send + +**Modals:** none. + +**Backend needs:** +- MLLP send endpoint (takes raw HL7v2 string, returns ACK + message id). +- Optional: list of known senders from config (for the dropdown). +- Optional: saved scenarios / templates stored server-side (currently hardcoded client-side). + +### 4. Unmapped Codes — `UnmappedA` (Triage inbox) + +**Purpose:** "inbox zero" for code mappings — every unresolved code blocks messages; map once → backlog replays. + +**Sections:** +1. Hero: eyebrow "Triage · 3 codes holding 17 messages", h1, subtitle "Map once, the backlog replays automatically." Right actions: "Skip all" ghost, "Suggest with AI" sparkle. +2. **Two-pane split**: + - **Left — Queue card** (300px): rows per unmapped code = `code (mono) · msg count · sender · field`, selected gets paper-2 bg + accent left bar. + - **Right — Editor card**: + - Header: eyebrow with "Incoming code · {sender} · {field} · first seen {time}". Big mono accent code + serif italic display text. Right side: large serif count "12" + "messages waiting". + - Context panel: paper-2 box showing a sample OBX line with the code highlighted in warn + "example from MSG… · N minutes ago". + - **Suggested matches** section with sparkle eyebrow: list of `{code, display, score%, system}` rows, top row pre-selected with accent radio. Confidence bar (>80% accent, >60% warn, else muted). + - **Manual search** dashed box with search input "Search LOINC, SNOMED, or browse all…". + - **Empty state** when no suggestions: dashed placeholder message. + - Footer bar (paper-2): "Saving replays {N} queued messages and applies to future {sender} traffic." + Skip / Save mapping actions. + +**Actions:** +- Select code from queue +- Accept a suggestion (radio) +- Search/browse all code systems manually +- Save mapping → triggers backlog replay +- Skip (move to end of queue) +- Skip all +- Suggest with AI (batch-suggest across the queue) + +**Variant B** (`UnmappedB`, bulk table) exists in source if the triage inbox UX doesn't fit — has Accept / Edit per row and "Auto-map all" button. + +**Modals:** none in A (editor is a pane). B has no modals either. + +**Backend needs:** +- Pending unmapped codes list: `{local_system, code, display, sender, field, fhir_target_hint, first_seen, message_count}`. +- Per-code suggestions: fuzzy match against target code system (LOINC / SNOMED / RxNorm / etc), scored. +- Code-system search endpoint (for manual pick). +- Save mapping endpoint → creates ConceptMap entry + triggers replay of queued `IncomingHL7v2Message`s in `code_mapping_error` state. +- Skip / defer endpoints (already exist: `/defer/:id`, `/mark-for-retry/:id`). +- Optional: LLM-backed "Suggest with AI" batch endpoint. + +### 5. Terminology Map — `TerminologyA` + +**Purpose:** canonical ledger of every established local-code → FHIR-field mapping. Organized by **FHIR target** (Observation.code, Condition.code, Encounter.class, …), not by HL7 field. + +**Sections:** +1. Hero: "Terminology map · Every local code, bound to a FHIR field — written once, replayed forever." + "Add mapping" primary. +2. **KPI strip** (4 cells, no divider): Total mappings, Coverage %, Messages/mo, Needs review. +3. **Two-pane split**: + - **Left — Table card** (1fr): + - Toolbar row (paper-2): search input, "Clear N filters" pill (when filters active), `{filtered} of {total}` counter. + - Column header with **per-column filter popovers** on `FHIR target` and `Sender` (searchable multi-select with counts; ESC / click-outside close; active-count badge on the filter icon). + - Rows: `Local code (mono + display) · System chip (colored by code system) · Standard code (mono + std display) · FHIR target (split typography Resource.path) · Sender`. Deprecated mappings rendered strikethrough + muted. + - Empty state: "No mappings match your filters." + - **Right — Detail card** (380px, sticky): + - "FHIR target" section (gradient paper-2 header): split-weight label + system chip + status dot (active / deprecated / needs review). + - **Mapping body**: "Local" eyebrow + mono value + serif italic display → "maps to" divider → "Standard · {system}" eyebrow + mono accent value + std display. + - **Source** panel (paper-2, 2-col): Source (sender + HL7 field) | Last seen (relative time). + - **Lineage timeline**: Mapping created (who/when) → Backlog replayed (N messages) → Applied to {sender} since. Bullet dots. + - Footer actions: Deprecate | Edit. + +**System color coding** (mono pill): +- LOINC green, SNOMED sky, ICD-10 orange, RxNorm violet, v3 Interp amber, v3 Act pink, FHIR slate. + +**Modals:** +- **Add / Edit mapping modal** (`formMode: 'add' | 'edit'`): + - Header: title + subtitle + close ✕. In edit mode, FHIR target is locked and noted as such. + - Body: FHIR target select (add-only), Local system + Local code (two-column), Local display, Search-to-Map input (prefixed with search icon + target system label, e.g. "Search LOINC codes…"). + - Footer: muted caption ("Applies to every future message & replays the backlog automatically.") + Cancel / Create-or-Save primary. Submit disabled unless code + target filled. + - Dismiss: ESC, backdrop click, ✕, Cancel. + +**Actions:** +- Search / filter table (free-text + FHIR-target multi-select + sender multi-select) +- Select row → detail panel updates +- Add mapping (modal) +- Edit mapping (modal, target locked) +- Deprecate mapping +- Clear filters + +**Backend needs:** +- All ConceptMap entries with `{localSystem, localCode, localDisplay, stdCode, stdDisplay, stdSystem, fhirField, sender, hl7Field, usageCount, lastSeen, mappedBy, mappedOn, status}`. +- Aggregates for KPI strip: total count, coverage %, messages routed per month, review-needed count. +- Facet counts for filter popovers (per FHIR target, per sender). +- Search endpoint for target system codes inside the modal (LOINC/SNOMED/etc). +- Lineage per mapping: created event, backlog-replay event(s), usage timeline. +- Deprecate mapping endpoint. +- Create / update mapping endpoints. + +--- + +## User's late-stage design suggestions (from chat, for future consideration) + +The designer ended the session with unsolicited critique worth remembering: + +**Trim:** +- Unmapped Codes could be a saved filter on Inbound instead of a separate page. +- Simulate Sender is developer-tool-ish; maybe a global ⌘T modal rather than primary nav. +- Remove Variant A/B switcher in production. + +**Add:** +- Onboarding / "first 60 seconds" flow (currently Dashboard assumes senders are wired). +- First-class **message replay with diff** (before-mapping vs after-mapping FHIR output). +- Mapping confidence in the unmapped queue (already partially present as the suggestion scores). +- Environment promotion flow (staging → prod mapping review). +- **Sender health page** — last message, volume trend, ACK/NAK ratio, unmapped-code rate. + +**Open question:** primary persona is integration engineer (flow / replay / ACK / health) vs data steward (terminology / review / audit). Designer's lean: engineer first, steward later. + +--- + +## Integration notes for this codebase + +Reference only — not a plan. + +- Current UI lives in `src/ui/pages/*.ts` (server-rendered HTML strings). Design is pure client-side React. Matching the visual output will likely mean either: + - (a) keeping server-rendered HTML and porting styles + structure by hand (closer to today's stack), or + - (b) moving to a client-rendered React app, which is a bigger shift. +- The screens map reasonably onto existing routes: + - `/` → Dashboard (currently Accounts) + - `/messages` (or similar) → Inbound Messages + - MLLP client page → Simulate Sender + - Mapping Tasks → Unmapped Codes (A/triage version) + - Code Mappings → Terminology Map +- Accounts + Outgoing Messages: **explicitly out of scope** for this refactor. They continue to exist in the current UI; design simply doesn't cover them. +- Polling / live-refresh assumptions in the design already match the in-process pollers described in `CLAUDE.md` (5s default). No new backend concepts introduced by the design except possibly a scripted-demo endpoint and a "replay backlog on mapping save" hook (code_mapping_error auto-requeue is already there). diff --git a/ai/tickets/ui-refactoring/STACK_DECISION.md b/ai/tickets/ui-refactoring/STACK_DECISION.md new file mode 100644 index 00000000..3a48247a --- /dev/null +++ b/ai/tickets/ui-refactoring/STACK_DECISION.md @@ -0,0 +1,87 @@ +# Frontend Stack Decision + +**Decision:** htmx + Alpine.js on top of the existing server-rendered TypeScript/Bun stack. +**Rejected:** rewrite to React with JSON APIs. +**Status:** locked by user, 2026-04-23. + +## Context + +The UI refactoring ticket ships a new design (see `DESIGN_OVERVIEW.md`) for 5 pages: Dashboard, Inbound Messages, Simulate Sender, Unmapped Codes, Terminology Map. Accounts and Outgoing Messages are **out of scope** and stay as-is. Current stack: Bun + TypeScript, server-rendered HTML strings in `src/ui/pages/*.ts`, Tailwind, a handful of JSON endpoints (e.g. `/api/terminology/loinc`), one ad-hoc vanilla-JS widget (LOINC autocomplete). + +The design prototype is written in React because Claude Design outputs React. The handoff README explicitly says not to copy the prototype's internal structure — recreate the visual output in whatever fits the target codebase. + +## Complexity audit of the design + +Going through each page before picking a stack: + +| Page | Interactive surface | Needs SPA? | +|---|---|---| +| Dashboard | Auto-refreshing ticker (5s), pause toggle, "Run demo" POST | No | +| Inbound | Row → detail pane, 4 tabs in detail, type filter chips, list auto-refresh | No | +| Simulate Sender | Message-type/sender dropdowns regen body, syntax-highlighted textarea, SendCard state machine (idle/sending/sent/held) with animated checklist | No | +| Unmapped (triage) | Queue → editor, suggestion radio, manual search, Save → replay | No | +| Terminology | Table search, two column-header filter popovers (searchable multi-select, ESC/click-outside), Add/Edit modal with sticky header/footer | No | + +None of the pages has cross-cutting client state, client-side routing, or a shared store. Every "reactive" piece is local — a popover, a modal, a send-button state machine. URL is the state across pages. + +## Option A — htmx + Alpine.js (chosen) + +### Pros + +- **Matches existing style.** Every page already renders HTML strings and handles mutations with `form POST + 302`. htmx plugs straight in — `hx-get`, `hx-post`, `hx-target`, `hx-push-url`. +- **No build step added.** Two ` + + + + + + + + + + + + diff --git a/ai/tickets/ui-refactoring/hl7v2-v2/project/HL7v2 Wireframes.html b/ai/tickets/ui-refactoring/hl7v2-v2/project/HL7v2 Wireframes.html new file mode 100644 index 00000000..5872366c --- /dev/null +++ b/ai/tickets/ui-refactoring/hl7v2-v2/project/HL7v2 Wireframes.html @@ -0,0 +1,155 @@ + + + + + HL7v2 Tool — Wireframes + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + diff --git a/ai/tickets/ui-refactoring/hl7v2-v2/project/design-canvas.jsx b/ai/tickets/ui-refactoring/hl7v2-v2/project/design-canvas.jsx new file mode 100644 index 00000000..9f3fc611 --- /dev/null +++ b/ai/tickets/ui-refactoring/hl7v2-v2/project/design-canvas.jsx @@ -0,0 +1,622 @@ + +// DesignCanvas.jsx — Figma-ish design canvas wrapper +// Warm gray grid bg + Sections + Artboards + PostIt notes. +// Artboards are reorderable (grip-drag), labels/titles are inline-editable, +// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc). +// State persists to a .design-canvas.state.json sidecar via the host +// bridge. No assets, no deps. +// +// Usage: +// +// +// +// +// +// + +const DC = { + bg: '#f0eee9', + grid: 'rgba(0,0,0,0.06)', + label: 'rgba(60,50,40,0.7)', + title: 'rgba(40,30,20,0.85)', + subtitle: 'rgba(60,50,40,0.6)', + postitBg: '#fef4a8', + postitText: '#5a4a2a', + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', +}; + +// One-time CSS injection (classes are dc-prefixed so they don't collide with +// the hosted design's own styles). +if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) { + const s = document.createElement('style'); + s.id = 'dc-styles'; + s.textContent = [ + '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}', + '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}', + '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}', + '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}', + '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}', + '.dc-card{transition:box-shadow .15s,transform .15s}', + '.dc-card *{scrollbar-width:none}', + '.dc-card *::-webkit-scrollbar{display:none}', + '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}', + '.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}', + '.dc-grip:hover{background:rgba(0,0,0,.08)}', + '.dc-grip:active{cursor:grabbing}', + '.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}', + '.dc-labeltext:hover{background:rgba(0,0,0,.05)}', + '.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;', + ' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', + ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}', + '.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}', + '[data-dc-slot]:hover .dc-expand{opacity:1}', + ].join('\n'); + document.head.appendChild(s); +} + +const DCCtx = React.createContext(null); + +// ───────────────────────────────────────────────────────────── +// DesignCanvas — stateful wrapper around the pan/zoom viewport. +// Owns runtime state (per-section order, renamed titles/labels, focused +// artboard). Order/titles/labels persist to a .design-canvas.state.json +// sidecar next to the HTML. Reads go via plain fetch() so the saved +// arrangement is visible anywhere the HTML + sidecar are served together +// (omelette preview, direct link, downloaded zip). Writes go through the +// host's window.omelette bridge — editing requires the omelette runtime. +// Focus is ephemeral. +// ───────────────────────────────────────────────────────────── +const DC_STATE_FILE = '.design-canvas.state.json'; + +function DesignCanvas({ children, minScale, maxScale, style }) { + const [state, setState] = React.useState({ sections: {}, focus: null }); + // Hold rendering until the sidecar read settles so the saved order/titles + // appear on first paint (no source-order flash). didRead gates writes until + // the read settles so the empty initial state can't clobber a slow read; + // skipNextWrite suppresses the one echo-write that would otherwise follow + // hydration. + const [ready, setReady] = React.useState(false); + const didRead = React.useRef(false); + const skipNextWrite = React.useRef(false); + + React.useEffect(() => { + let off = false; + fetch('./' + DC_STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((saved) => { + if (off || !saved || !saved.sections) return; + skipNextWrite.current = true; + setState((s) => ({ ...s, sections: saved.sections })); + }) + .catch(() => {}) + .finally(() => { didRead.current = true; if (!off) setReady(true); }); + const t = setTimeout(() => { if (!off) setReady(true); }, 150); + return () => { off = true; clearTimeout(t); }; + }, []); + + React.useEffect(() => { + if (!didRead.current) return; + if (skipNextWrite.current) { skipNextWrite.current = false; return; } + const t = setTimeout(() => { + window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {}); + }, 250); + return () => clearTimeout(t); + }, [state.sections]); + + // Build registries synchronously from children so FocusOverlay can read + // them in the same render. Only direct DCSection > DCArtboard children are + // walked — wrapping them in other elements opts out of focus/reorder. + const registry = {}; // slotId -> { sectionId, artboard } + const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] } + const sectionOrder = []; + React.Children.forEach(children, (sec) => { + if (!sec || sec.type !== DCSection) return; + const sid = sec.props.id ?? sec.props.title; + if (!sid) return; + sectionOrder.push(sid); + const persisted = state.sections[sid] || {}; + const srcIds = []; + React.Children.forEach(sec.props.children, (ab) => { + if (!ab || ab.type !== DCArtboard) return; + const aid = ab.props.id ?? ab.props.label; + if (!aid) return; + registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; + srcIds.push(aid); + }); + const kept = (persisted.order || []).filter((k) => srcIds.includes(k)); + sectionMeta[sid] = { + title: persisted.title ?? sec.props.title, + subtitle: sec.props.subtitle, + slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))], + }; + }); + + const api = React.useMemo(() => ({ + state, + section: (id) => state.sections[id] || {}, + patchSection: (id, p) => setState((s) => ({ + ...s, + sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } }, + })), + setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })), + }), [state]); + + // Esc exits focus; any outside pointerdown commits an in-progress rename. + React.useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); }; + const onPd = (e) => { + const ae = document.activeElement; + if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur(); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('pointerdown', onPd, true); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('pointerdown', onPd, true); + }; + }, [api]); + + return ( + + {ready && children} + {state.focus && registry[state.focus] && ( + + )} + + ); +} + +// ───────────────────────────────────────────────────────────── +// DCViewport — transform-based pan/zoom (internal) +// +// Input mapping (Figma-style): +// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events) +// • trackpad scroll → pan (two-finger) +// • mouse wheel → zoom (notched; distinguished from trackpad scroll) +// • middle-drag / primary-drag-on-bg → pan +// +// Transform state lives in a ref and is written straight to the DOM +// (translate3d + will-change) so wheel ticks don't go through React — +// keeps pans at 60fps on dense canvases. +// ───────────────────────────────────────────────────────────── +function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { + const vpRef = React.useRef(null); + const worldRef = React.useRef(null); + const tf = React.useRef({ x: 0, y: 0, scale: 1 }); + + const apply = React.useCallback(() => { + const { x, y, scale } = tf.current; + const el = worldRef.current; + if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; + }, []); + + React.useEffect(() => { + const vp = vpRef.current; + if (!vp) return; + + const zoomAt = (cx, cy, factor) => { + const r = vp.getBoundingClientRect(); + const px = cx - r.left, py = cy - r.top; + const t = tf.current; + const next = Math.min(maxScale, Math.max(minScale, t.scale * factor)); + const k = next / t.scale; + // keep the world point under the cursor fixed + t.x = px - (px - t.x) * k; + t.y = py - (py - t.y) * k; + t.scale = next; + apply(); + }; + + // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends + // line-mode deltas (Firefox) or large integer pixel deltas with no X + // component (Chrome/Safari, typically multiples of 100/120). Trackpad + // two-finger scroll sends small/fractional pixel deltas, often with + // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch. + const isMouseWheel = (e) => + e.deltaMode !== 0 || + (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40); + + const onWheel = (e) => { + e.preventDefault(); + if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels + if (e.ctrlKey) { + // trackpad pinch (or explicit ctrl+wheel) + zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); + } else if (isMouseWheel(e)) { + // notched mouse wheel — fixed-ratio step per click + zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18)); + } else { + // trackpad two-finger scroll — pan + tf.current.x -= e.deltaX; + tf.current.y -= e.deltaY; + apply(); + } + }; + + // Safari sends native gesture* events for trackpad pinch with a smooth + // e.scale; preferring these over the ctrl+wheel fallback gives a much + // better feel there. No-ops on other browsers. Safari also fires + // ctrlKey wheel events during the same pinch — isGesturing makes + // onWheel drop those entirely so they neither zoom nor pan. + let gsBase = 1; + let isGesturing = false; + const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; }; + const onGestureChange = (e) => { + e.preventDefault(); + zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale); + }; + const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; }; + + // Drag-pan: middle button anywhere, or primary button on canvas + // background (anything that isn't an artboard or an inline editor). + let drag = null; + const onPointerDown = (e) => { + const onBg = !e.target.closest('[data-dc-slot], .dc-editable'); + if (!(e.button === 1 || (e.button === 0 && onBg))) return; + e.preventDefault(); + vp.setPointerCapture(e.pointerId); + drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY }; + vp.style.cursor = 'grabbing'; + }; + const onPointerMove = (e) => { + if (!drag || e.pointerId !== drag.id) return; + tf.current.x += e.clientX - drag.lx; + tf.current.y += e.clientY - drag.ly; + drag.lx = e.clientX; drag.ly = e.clientY; + apply(); + }; + const onPointerUp = (e) => { + if (!drag || e.pointerId !== drag.id) return; + vp.releasePointerCapture(e.pointerId); + drag = null; + vp.style.cursor = ''; + }; + + vp.addEventListener('wheel', onWheel, { passive: false }); + vp.addEventListener('gesturestart', onGestureStart, { passive: false }); + vp.addEventListener('gesturechange', onGestureChange, { passive: false }); + vp.addEventListener('gestureend', onGestureEnd, { passive: false }); + vp.addEventListener('pointerdown', onPointerDown); + vp.addEventListener('pointermove', onPointerMove); + vp.addEventListener('pointerup', onPointerUp); + vp.addEventListener('pointercancel', onPointerUp); + return () => { + vp.removeEventListener('wheel', onWheel); + vp.removeEventListener('gesturestart', onGestureStart); + vp.removeEventListener('gesturechange', onGestureChange); + vp.removeEventListener('gestureend', onGestureEnd); + vp.removeEventListener('pointerdown', onPointerDown); + vp.removeEventListener('pointermove', onPointerMove); + vp.removeEventListener('pointerup', onPointerUp); + vp.removeEventListener('pointercancel', onPointerUp); + }; + }, [apply, minScale, maxScale]); + + const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`; + return ( +
+
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// DCSection — editable title + h-row of artboards in persisted order +// ───────────────────────────────────────────────────────────── +function DCSection({ id, title, subtitle, children, gap = 48 }) { + const ctx = React.useContext(DCCtx); + const sid = id ?? title; + const all = React.Children.toArray(children); + const artboards = all.filter((c) => c && c.type === DCArtboard); + const rest = all.filter((c) => !(c && c.type === DCArtboard)); + const srcOrder = artboards.map((a) => a.props.id ?? a.props.label); + const sec = (ctx && sid && ctx.section(sid)) || {}; + + const order = React.useMemo(() => { + const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); + return [...kept, ...srcOrder.filter((k) => !kept.includes(k))]; + }, [sec.order, srcOrder.join('|')]); + + const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); + + return ( +
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
+
+ {order.map((k) => ( + ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} + onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> + ))} +
+ {rest} +
+ ); +} + +// DCArtboard — marker; rendered by DCArtboardFrame via DCSection. +function DCArtboard() { return null; } + +function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) { + const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; + const id = rawId ?? rawLabel; + const ref = React.useRef(null); + + // Live drag-reorder: dragged card sticks to cursor; siblings slide into + // their would-be slots in real time via transforms. DOM order only + // changes on drop. + const onGripDown = (e) => { + e.preventDefault(); e.stopPropagation(); + const me = ref.current; + // translateX is applied in local (pre-scale) space but pointer deltas and + // getBoundingClientRect().left are screen-space — divide by the viewport's + // current scale so the dragged card tracks the cursor at any zoom level. + const scale = me.getBoundingClientRect().width / me.offsetWidth || 1; + const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`)); + const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left })); + const slotXs = homes.map((h) => h.x); + const startIdx = order.indexOf(id); + const startX = e.clientX; + let liveOrder = order.slice(); + me.classList.add('dc-dragging'); + + const layout = () => { + for (const h of homes) { + if (h.id === id) continue; + const slot = liveOrder.indexOf(h.id); + h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`; + } + }; + + const move = (ev) => { + const dx = ev.clientX - startX; + me.style.transform = `translateX(${dx / scale}px)`; + const cur = homes[startIdx].x + dx; + let nearest = 0, best = Infinity; + for (let i = 0; i < slotXs.length; i++) { + const d = Math.abs(slotXs[i] - cur); + if (d < best) { best = d; nearest = i; } + } + if (liveOrder.indexOf(id) !== nearest) { + liveOrder = order.filter((k) => k !== id); + liveOrder.splice(nearest, 0, id); + layout(); + } + }; + + const up = () => { + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + const finalSlot = liveOrder.indexOf(id); + me.classList.remove('dc-dragging'); + me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`; + // After the settle transition, kill transitions + clear transforms + + // commit the reorder in the same frame so there's no visual snap-back. + setTimeout(() => { + for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; } + if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder); + requestAnimationFrame(() => requestAnimationFrame(() => { + for (const h of homes) h.el.style.transition = ''; + })); + }, 180); + }; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + }; + + return ( +
+
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+ +
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across +// sections, Esc or backdrop click to exit. +// ───────────────────────────────────────────────────────────── +function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { + const ctx = React.useContext(DCCtx); + const { sectionId, artboard } = entry; + const sec = ctx.section(sectionId); + const meta = sectionMeta[sectionId]; + const peers = meta.slotIds; + const aid = artboard.props.id ?? artboard.props.label; + const idx = peers.indexOf(aid); + const secIdx = sectionOrder.indexOf(sectionId); + + const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; + const goSection = (d) => { + const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length]; + const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; + if (first) ctx.setFocus(`${ns}/${first}`); + }; + + React.useEffect(() => { + const k = (e) => { + if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } + if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } + if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } + if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } + }; + document.addEventListener('keydown', k); + return () => document.removeEventListener('keydown', k); + }); + + const { width = 260, height = 480, children } = artboard.props; + const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); + React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); + const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); + + const [ddOpen, setDd] = React.useState(false); + const Arrow = ({ dir, onClick }) => ( + + ); + + // Portal to body so position:fixed is the real viewport regardless of any + // transform on DesignCanvas's ancestors (including the canvas zoom itself). + return ReactDOM.createPortal( +
ctx.setFocus(null)} + onWheel={(e) => e.preventDefault()} + style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', + fontFamily: DC.font, color: '#fff' }}> + + {/* top bar: section dropdown (left) · close (right) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/ai/tickets/ui-refactoring/hl7v2-v2/project/design/app.jsx b/ai/tickets/ui-refactoring/hl7v2-v2/project/design/app.jsx new file mode 100644 index 00000000..89b8a81c --- /dev/null +++ b/ai/tickets/ui-refactoring/hl7v2-v2/project/design/app.jsx @@ -0,0 +1,65 @@ +// App shell — routes between pages, holds variant state + +const { useState, useEffect } = React; + +const ROUTES = { + dashboard: {crumb:['Workspace','Dashboard'], variants:[['A','Overview']]}, + inbound: {crumb:['Workspace','Inbound messages'], variants:[['A','List + detail'],['B','Grouped by sender']]}, + simulate: {crumb:['Workspace','Simulate sender'], variants:[['A','Composer']]}, + unmapped: {crumb:['Terminology','Unmapped codes'], variants:[['A','Triage inbox']]}, + terminology: {crumb:['Terminology','Terminology map'],variants:[['A','Coming soon']]}, +}; + +const PAGES = { + 'dashboard:A': () => , + 'inbound:A': () => , + 'inbound:B': () => , + 'unmapped:A': () => , + 'unmapped:B': () => , + 'simulate:A': () => , + 'terminology:A': () => , +}; + +const ComingSoon = ({label}) => ( +
+
+
In design
+

{label}

+
Wireframe locked · hi-fi in the next cut. Click the other nav items to see what's ready.
+
+
+); + +const App = () => { + const [route, setRoute] = useState(() => localStorage.getItem('hl7-route') || 'dashboard'); + const [variant, setVariant] = useState(() => JSON.parse(localStorage.getItem('hl7-variants') || '{}')); + + useEffect(() => { localStorage.setItem('hl7-route', route); }, [route]); + useEffect(() => { localStorage.setItem('hl7-variants', JSON.stringify(variant)); }, [variant]); + + const v = variant[route] || 'A'; + const Page = PAGES[`${route}:${v}`] || PAGES[`${route}:A`]; + const meta = ROUTES[route]; + + return ( +
+ +
+ +
+ + {meta.variants.length > 1 && ( +
+ Variant + {meta.variants.map(([id, label]) => ( + + ))} +
+ )} +
+ ); +}; + +ReactDOM.createRoot(document.getElementById('root')).render(); diff --git a/ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-dashboard.jsx b/ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-dashboard.jsx new file mode 100644 index 00000000..525171b1 --- /dev/null +++ b/ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-dashboard.jsx @@ -0,0 +1,275 @@ +// Dashboard — 2 variants. Calm, warm-paper palette. Follows wireframe layouts. + +// Small primitives used across the page +const Stat = ({label, value, delta, tone, last}) => ( +
+
{label}
+
+
{value}
+ {delta &&
{delta}
} +
+
+); + +const Spark = ({highlight}) => { + const pts = [4,6,5,8,7,10,9,12,11,14,13,16,19,17,22,18,15,20,24,21,26,23,28,25]; + const max = Math.max(...pts); + const w = 560, h = 90; + const step = w / (pts.length-1); + const path = pts.map((p,i)=>`${i===0?'M':'L'} ${(i*step).toFixed(1)} ${(h - (p/max)*h*0.8 - 8).toFixed(1)}`).join(' '); + const area = path + ` L ${w} ${h} L 0 ${h} Z`; + return ( + + + + + + + + + + {highlight && pts.map((p,i) => i===pts.length-3 && ( + + ))} + + ); +}; + +// Pipeline step (hero flow diagram) +const PipeStep = ({label, sub, count, active}) => ( +
+
{count}
+
{label}
+
{sub}
+
+); +const Arrow = () => ( + + + +); + +const TickerRow = ({time, type, note, status, first}) => ( +
+ {time} + {type} + {note} + + {status==='ok' && processed} + {status==='warn' && needs mapping} + {status==='err' && error} + {status==='pend' && pending} + +
+); + +// ── Variant A: Overview — "A message's journey" hero + stats + ticker +const DashboardA = () => ( +
+
+
+
Staging · last refresh 2s ago
+

Good morning, Kyrylo.

+
Everything's flowing. 3 codes are waiting on a decision before tomorrow's replay.
+
+
+ + +
+
+ + {/* Pipeline hero */} +
+
+ A message's journey + — from sender to FHIR, today +
+
+ + + + + + + + + +
+
+ workers healthy + · + 3 messages routed to triage + · + 2 conversion errors — see below +
+
+ + {/* Stats strip */} +
+ + + + + +
+ +
+
+ +
+ {/* Live ticker */} +
+
+ + Live ticker + auto-refresh · 5s + +
+ {[ + ['14:19:46','ORU^R01','ACME_LAB → unknown LOINC — routed to triage','warn'], + ['14:19:44','ORU^R01','ACME_LAB → processed (3 observations)','ok'], + ['14:19:40','VXU^V04','CHILDRENS → immunization CVX 88','ok'], + ['14:19:38','ADT^A01','St.Marys → admit · patient P12345','ok'], + ['14:19:34','ADT^A08','St.Marys → demographics updated','ok'], + ['14:19:28','ORM^O01','ACME_LAB → order filled','ok'], + ['14:19:22','BAR^P01','billing → conversion error','err'], + ['14:19:18','ORU^R01','ACME_LAB → potassium 4.2 mmol/L','ok'], + ['14:19:12','ADT^A03','St.Marys → discharge · encounter closed','ok'], + ].map((r,i) => )} +
+ + {/* Right rail */} +
+
+
+
Needs a decision
+
3 codes are holding 17 messages.
+
Map them once, the backlog replays automatically.
+
+ {[ + ['UNKNOWN_TEST', 'ACME_LAB · OBX-3', 12], + ['DC-HOME-HEALTH', 'St.Marys · PV1-36', 4], + ['STAT-AMB', 'billing · ACC-6', 1], + ].map((r,i) => ( +
+ {r[0]} + {r[1]} + {r[2]} msg +
+ ))} +
+ +
+
+ +
+
Active senders24h
+ {[ + ['ACME_LAB', '84', 100, 'ok'], + ['St.Marys Hospital', '32', 38, 'ok'], + ['CHILDRENS', '18', 22, 'ok'], + ['billing', '8', 10, 'warn'], + ].map((s,i) => ( +
+ + {s[0]} + {s[1]} +
+
+
+
+ ))} +
+ +
+
+ "HL7v2 isn't broken — it's just lived-in. We built this for the team cleaning up after it." +
+
Product principles · 01
+
+
+
+
+); + +// ── Variant B: Demo conductor — wireframe V2 layout, hi-fi +const DemoStep = ({n, label, sub, accent}) => ( +
+
{n}
+
{label}
+
{sub}
+
+); + +const DashboardB = () => ( +
+
+
Staging · scripted demo
+

Demo control

+
One click runs a full HL7v2 scenario end-to-end — so any prospect sees the whole story in under a minute.
+
+ + {/* Hero — demo conductor */} +
+
+
+
Run scripted demo in 4 steps
+
2s spacing between sends · last run 4 minutes ago · all green
+
+ +
+ +
+ +
+ +
+
+
+ +
+ + +
+
+
+
+ + {/* Stats + Live ticker */} +
+ + + + +
+
+ {[['ORU processor', true], ['BAR builder', true], ['BAR sender', true]].map(([n,on],i) => ( + + {n} + + ))} +
+
workers · polling every 5s
+
+
+ +
+
+ + Live ticker + auto-refresh · 5s — pause +
+ {[ + ['14:19:46','ORU^R01','ACME_LAB → unknown LOINC — routed to triage','warn'], + ['14:19:44','ORU^R01','ACME_LAB → processed (3 observations)','ok'], + ['14:19:40','VXU^V04','CHILDRENS → immunization CVX 88','ok'], + ['14:19:38','ADT^A01','St.Marys → admit · patient P12345','ok'], + ['14:19:36','—','polling services started','pend'], + ['14:19:34','ADT^A08','St.Marys → demographics updated','ok'], + ['14:19:22','BAR^P01','billing → conversion error','err'], + ].map((r,i) => )} +
+
+); + +Object.assign(window, { DashboardA, DashboardB }); diff --git a/ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-inbound.jsx b/ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-inbound.jsx new file mode 100644 index 00000000..ca1f9c99 --- /dev/null +++ b/ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-inbound.jsx @@ -0,0 +1,251 @@ +// Inbound Messages — 2 variants, warm-paper palette + +const MessageRow = ({time, type, sender, note, status, selected, onClick, first}) => ( +
+ + {time} + {type} + {sender} + {note} + + {status==='ok' && processed} + {status==='warn' && needs mapping} + {status==='err' && error} + +
+); + +// ── Variant A: List + detail pane (tabbed) +const InboundA = () => { + const [sel, setSel] = React.useState(2); + const [tab, setTab] = React.useState('structured'); + + const rows = [ + ['14:21:58','ORU^R01','ACME_LAB','patient TEST-0041 · glucose 96 mg/dL','ok'], + ['14:21:54','ADT^A08','St.Marys','demographics update · MRN 00088412','ok'], + ['14:21:51','ORU^R01','ACME_LAB','UNKNOWN_TEST — no LOINC mapping','warn'], + ['14:21:47','ADT^A01','CHILDRENS','admit · encounter created','ok'], + ['14:21:44','BAR^P01','billing','account opened · $1,240.00','ok'], + ['14:21:41','ORM^O01','ACME_LAB','order filled','ok'], + ['14:21:38','ORU^R01','ACME_LAB','potassium 4.2 mmol/L','ok'], + ['14:21:33','ADT^A03','St.Marys','discharge · encounter closed','ok'], + ['14:21:28','VXU^V04','CHILDRENS','CVX 88 Influenza 2026','ok'], + ['14:21:22','ORU^R01','—','MSH-3 lookup failed','err'], + ]; + + return ( +
+
+
+

Inbound messages

+
142 received today · 3 in triage · 2 errors
+
+
+ + + +
+
+ +
+ {[['All','142',true],['ORU^R01','84'],['ADT^A01','18'],['ADT^A08','14'],['ADT^A03','6'],['ORM^O01','8'],['BAR^P01','6'],['VXU^V04','6'],['errors','2','err']].map((t,i) => ( + + {t[0]} {t[1]} + + ))} +
+ +
+ {/* LEFT: list */} +
+
+ All messages + streaming · 14:21:58 +
+
+ + time + type + sender + message + status +
+ {rows.map((r,i) => ( + setSel(i)}/> + ))} +
+ + {/* RIGHT: detail */} +
+
+
+ needs mapping + ORU^R01 + MSG1776853125726 · 14:21:51 +
+ + +
+
+
ACME_LAB → ACME_HOSP
+
OBX-3 code UNKNOWN_TEST has no LOINC mapping — routed to triage.
+ +
+ {[['structured','Structured'],['raw','Raw HL7'],['fhir','FHIR resources'],['acks','ACK history']].map(([k,l]) => ( + + ))} +
+
+ +
+ {tab==='structured' && ( +
+ {[ + {seg:'MSH', desc:'Message header', fields:[['Sending app','ACME_LAB'],['Receiving','ACME_HOSP'],['Timestamp','20260422 14:21:51'],['Type','ORU^R01'],['Version','2.5.1']]}, + {seg:'PID', desc:'Patient identification', fields:[['MRN','TEST-0041'],['Name','TESTPATIENT GAMMA'],['DOB','1990-12-25'],['Sex','M']]}, + {seg:'OBR', desc:'Observation request', fields:[['Placer','ORD003'],['Filler','FIL003'],['Panel','CHEM7 · CHEMISTRY PANEL']]}, + {seg:'OBX', desc:'Observation result', warn:'UNKNOWN_TEST has no LOINC mapping', fields:[['Type','NM'],['Code','UNKNOWN_TEST^Unknown Lab Test^LOCAL', true],['Value','123'],['Units','mg/dL'],['Ref range','70–200'],['Status','F']]}, + ].map((s,i) => ( +
+
+ {s.seg} + {s.desc} + {s.warn && {s.warn}} +
+
+ {s.fields.map((f,fi) => ( + +
{f[0]}
+
{f[1]}
+
+ ))} +
+
+ ))} +
+ )} + + {tab==='raw' && ( +
+MSH|^~\&|ACME_LAB|ACME_HOSP|EMR|DEST|20260422142151|ORU^R01|MSG1776853125726|P|2.5.1{'\n'}
+PID|1||TEST-0041^^^HOSPITAL^MR||TESTPATIENT^GAMMA||19901225|M{'\n'}
+PV1|1|O|LAB||||||||||||||||||VN125726{'\n'}
+ORC|RE|ORD003|FIL003{'\n'}
+OBR|1|ORD003|FIL003|CHEM7^CHEMISTRY PANEL^LOCAL|||20260422142154{'\n'}
+OBX|1|NM|UNKNOWN_TEST^Unknown Lab Test^LOCAL||123|mg/dL|70-200|||F|
+              
+ )} + + {tab==='fhir' && ( +
+{`{
+  "resourceType": "Bundle",
+  "type": "message",
+  "entry": [
+    { "resource": { "resourceType": "Patient",
+        "identifier": [{ "value": "TEST-0041" }],
+        "name": [{ "family": "TESTPATIENT", "given": ["GAMMA"] }],
+        "gender": "male", "birthDate": "1990-12-25" }},
+    { "resource": { "resourceType": "Observation",
+        "status": "final",
+        "code": {`}
+        {`
+          "coding": [{ "system": "LOCAL", "code": "UNKNOWN_TEST" }]
+          // ⚠ no LOINC mapping — add one to finalize`}
+        {`
+        },
+        "valueQuantity": { "value": 123, "unit": "mg/dL" }
+    }}
+  ]
+}`}
+              
+ )} + + {tab==='acks' && ( +
+ {[ + ['14:21:51.142', 'MLLP connect', 'localhost:2575', 'ok'], + ['14:21:51.188', 'Parsed', '6 segments · 34 fields', 'ok'], + ['14:21:51.212', 'Terminology', 'UNKNOWN_TEST · no LOINC', 'warn'], + ['14:21:51.218', 'Routed', '→ triage queue', 'warn'], + ['14:21:51.224', 'ACK sent', 'MSA|AE — held for mapping', 'warn'], + ].map((r,i) => ( +
+ {r[0]} + {r[1]} + {r[2]} +
+ ))} +
+ )} +
+
+
+
+ ); +}; + +// ── Variant B: Grouped by sender (timeline feel) +const InboundB = () => ( +
+
+

Inbound messages

+
Grouped by sender · live from MLLP listener
+
+ +
+ {[['All','142',true],['ACME_LAB','84'],['St.Marys','32'],['CHILDRENS','18'],['billing','8']].map((s,i) => ( + + ))} +
+ +
+ + {[ + {sender:'ACME_LAB', sub:'Laboratory results · ORU^R01 · MLLP 10.4.2.11:2575', stats:['84 today', '100% ACK', 'p50 38ms'], + messages:[ + {time:'14:21:58', type:'ORU^R01', subject:'TEST-0041', note:'glucose 96 mg/dL · 7 observations', status:'ok'}, + {time:'14:21:51', type:'ORU^R01', subject:'TEST-0039', note:'UNKNOWN_TEST code — routed to triage', status:'warn'}, + {time:'14:21:41', type:'ORM^O01', subject:'TEST-0038', note:'order filled · CHEM7 panel', status:'ok'}, + {time:'14:21:38', type:'ORU^R01', subject:'TEST-0037', note:'potassium 4.2 mmol/L · 3 observations', status:'ok'}, + ]}, + {sender:'St.Marys Hospital', sub:'Admissions · ADT family · MLLP 10.4.2.19:2575', stats:['32 today', '100% ACK', 'p50 44ms'], + messages:[ + {time:'14:21:54', type:'ADT^A08', subject:'00088412', note:'demographics updated · phone changed', status:'ok'}, + {time:'14:21:33', type:'ADT^A03', subject:'00088401', note:'discharge · encounter closed', status:'ok'}, + {time:'14:21:18', type:'ADT^A08', subject:'00088392', note:'demographics updated', status:'ok'}, + ]}, + {sender:'CHILDRENS', sub:'Admits + immunizations · ADT + VXU', stats:['18 today', '100% ACK'], + messages:[ + {time:'14:21:47', type:'ADT^A01', subject:'PED-0412', note:'admit · pediatrics ward', status:'ok'}, + {time:'14:21:28', type:'VXU^V04', subject:'PED-0401', note:'CVX 88 · Influenza 2026', status:'ok'}, + ]}, + ].map((g,gi) => ( +
+
+ + {g.sender} + {g.sub} +
+ {g.stats.map((s,i) => {s})} +
+
+ {g.messages.map((m,mi) => ( +
+ + {m.time} + {m.type} + {m.subject} + {m.note} + + {m.status==='ok' && processed} + {m.status==='warn' && needs mapping} + +
+ ))} +
+ ))} +
+); + +Object.assign(window, { InboundA, InboundB }); diff --git a/ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-simulate.jsx b/ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-simulate.jsx new file mode 100644 index 00000000..d533487c --- /dev/null +++ b/ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-simulate.jsx @@ -0,0 +1,254 @@ +// Simulate Sender — raw HL7 composer, message-type driven + +const { useState: useSimState } = React; + +// Message types with a built-in sample payload each. The "ORU-unknown" entry is +// the triage-story variant; it reads naturally as "Lab result · unknown code" +// and makes the unmapped-codes demo obvious without a separate toggle. +const MESSAGE_TYPES = [ + {id:'ORU^R01', label:'ORU^R01', desc:'Lab result · maps cleanly', tone:'ok', build:(sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||ORU^R01|MSG1776853125726|P|2.5.1`, + `PID|1||TEST-0041^^^HOSPITAL^MR||TESTPATIENT^GAMMA||19901225|M`, + `PV1|1|O|LAB||||||||||||||||||VN125726`, + `ORC|RE|ORD003|FIL003`, + `OBR|1|ORD003|FIL003|CHEM7^CHEMISTRY PANEL^LOCAL|||20260422142154`, + `OBX|1|NM|2345-7^Glucose [Mass/volume]^LOINC||96|mg/dL|70-200|||F|`, + ]}, + {id:'ORU^R01-unknown', label:'ORU^R01 · unknown code', desc:'Lab result · contains a code with no LOINC mapping', tone:'warn', build:(sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||ORU^R01|MSG1776853125726|P|2.5.1`, + `PID|1||TEST-0041^^^HOSPITAL^MR||TESTPATIENT^GAMMA||19901225|M`, + `PV1|1|O|LAB||||||||||||||||||VN125726`, + `ORC|RE|ORD003|FIL003`, + `OBR|1|ORD003|FIL003|CHEM7^CHEMISTRY PANEL^LOCAL|||20260422142154`, + `OBX|1|NM|UNKNOWN_TEST^Unknown Lab Test^LOCAL||123|mg/dL|70-200|||F|`, + ]}, + {id:'ADT^A01', label:'ADT^A01', desc:'Admit patient', tone:'ok', build:(sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||ADT^A01|MSG1776853125726|P|2.5.1`, + `EVN|A01|20260422142151`, + `PID|1||P12345^^^HOSPITAL^MR||DOE^JANE||19850707|F`, + `PV1|1|I|ICU^1^A||||123456^SMITH^JOHN^^^DR|||CAR`, + ]}, + {id:'ADT^A08', label:'ADT^A08', desc:'Update patient info', tone:'ok', build:(sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||ADT^A08|MSG1776853125726|P|2.5.1`, + `EVN|A08|20260422142151`, + `PID|1||00088412^^^HOSPITAL^MR||GARCIA^MARIA||19910304|F|||123 PINE ST^^AUSTIN^TX^78701`, + `PV1|1|I|MED^2^B`, + ]}, + {id:'VXU^V04', label:'VXU^V04', desc:'Immunization update · CVX-coded', tone:'ok', build:(sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||VXU^V04|MSG1776853125726|P|2.5.1`, + `PID|1||PED-0412^^^HOSPITAL^MR||CHEN^LUCAS||20190511|M`, + `ORC|RE||12345^PEDCLINIC`, + `RXA|0|1|20260422|20260422|88^Influenza, unspecified formulation^CVX|0.5|mL||00^new immunization record|`, + ]}, + {id:'ORM^O01', label:'ORM^O01', desc:'Order message', tone:'ok', build:(sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||ORM^O01|MSG1776853125726|P|2.5.1`, + `PID|1||TEST-0042^^^HOSPITAL^MR||TESTPATIENT^DELTA||19800615|F`, + `ORC|NW|ORD004|||SC||^^^20260422142151^^R`, + `OBR|1|ORD004||CBC^COMPLETE BLOOD COUNT^LOCAL|||20260422142154`, + ]}, + {id:'BAR^P01', label:'BAR^P01', desc:'Billing account add', tone:'ok', build:(sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||BAR^P01|MSG1776853125726|P|2.5.1`, + `EVN|P01|20260422142151`, + `PID|1||P12345^^^HOSPITAL^MR||DOE^JANE||19850707|F`, + `PV1|1|I|MED^2^B`, + `ACC|20260422|AUTO|12345|NONE`, + ]}, +]; + +const SimulateA = () => { + const [typeId, setTypeId] = useSimState('ORU^R01-unknown'); + const [sender, setSender] = useSimState('ACME_LAB'); + + const def = MESSAGE_TYPES.find(t => t.id === typeId) || MESSAGE_TYPES[0]; + const [raw, setRaw] = useSimState(def.build(sender).join('\n')); + + // Refresh buffer whenever the dropdowns change + React.useEffect(() => { + const d = MESSAGE_TYPES.find(t => t.id === typeId) || MESSAGE_TYPES[0]; + setRaw(d.build(sender).join('\n')); + }, [typeId, sender]); + + const parsed = raw.split('\n').filter(Boolean).map((line) => { + const [seg] = line.split('|'); + return { seg, line }; + }); + const hasUnknown = /UNKNOWN_TEST|\^LOCAL/.test(raw); + + const renderLine = (line, i) => { + const m = line.match(/^([A-Z0-9]{2,3})(\|.*)?$/); + if (!m) return
{line || '\u00A0'}
; + const [, seg, tail=''] = m; + const highlighted = tail.split(/(UNKNOWN_TEST\^[^|]*\^LOCAL)/).map((part, pi) => + part.startsWith('UNKNOWN_TEST') + ? {part} + : {part} + ); + return ( +
+ {i+1} + + {seg} + {highlighted} + +
+ ); + }; + + return ( +
+
+
+
Compose & send · staging MLLP · 10.1.4.22:2575
+

Simulate sender

+
Pick a message type, tweak the text, fire it at the listener. Pairs with Inbound to show the whole loop.
+
+
+ +
+ {/* LEFT — the HL7 buffer */} +
+
+ message.hl7 + HL7v2 · 2.5.1 + {parsed.length} segments + {hasUnknown + ? contains unmapped code + : all codes resolvable} +
+ +
+
+              {parsed.map((p, i) => renderLine(p.line, i))}
+              {parsed.length === 0 && Empty — paste or type HL7v2 here.}
+            
+ +
+
+ + +
+ +
+ +
    + ${renderMessageList(listItems)} +
+

Total: ${messages.length} messages

`; + + return renderLayout( + "Outgoing Messages", + renderNav("outgoing", navData), + content, + ); +} + +function renderIncomingMessagesPage( + navData: NavData, + messages: IncomingHL7v2Message[], + statusFilter?: string, +): string { + const getStatusBadgeClass = (status: string | undefined) => { + switch (status) { + case "processed": + return "bg-green-100 text-green-800"; + case "warning": + return "bg-amber-100 text-amber-800"; + case "parsing_error": + return "bg-red-100 text-red-800"; + case "conversion_error": + return "bg-red-100 text-red-800"; + case "code_mapping_error": + return "bg-yellow-100 text-yellow-800"; + case "sending_error": + return "bg-orange-100 text-orange-800"; + case "deferred": + return "bg-gray-100 text-gray-600"; + default: + return "bg-blue-100 text-blue-800"; + } + }; + + const formatStatusLabel = (status: string) => { + const labels: Record = { + parsing_error: "Parsing Error", + conversion_error: "Conversion Error", + code_mapping_error: "Code Mapping Error", + sending_error: "Sending Error", + deferred: "Deferred", + }; + return labels[status] ?? status.charAt(0).toUpperCase() + status.slice(1); + }; + + const listItems: MessageListItem[] = messages.map((msg) => ({ + id: msg.id ?? "", + statusBadge: { + text: formatStatusLabel(msg.status || "received"), + class: getStatusBadgeClass(msg.status), + }, + meta: [ + msg.type, + msg.patient?.reference?.replace("Patient/", "") || "-", + msg.meta?.lastUpdated + ? new Date(msg.meta.lastUpdated).toLocaleString() + : "-", + ], + hl7Message: msg.message, + error: msg.error, + bundle: msg.bundle, + retryUrl: + (msg.status === "parsing_error" || msg.status === "conversion_error" || msg.status === "code_mapping_error" || msg.status === "sending_error" || msg.status === "warning" || msg.status === "deferred") && msg.id + ? `/mark-for-retry/${msg.id}` + : undefined, + unmappedCodes: msg.status === "code_mapping_error" ? msg.unmappedCodes : undefined, + })); + + const statuses = ["received", "processed", "warning", "parsing_error", "conversion_error", "code_mapping_error", "sending_error", "deferred"]; + + const content = ` +

Inbound Messages

+ +
+
+ + All + + ${statuses + .map( + (s) => ` + + ${formatStatusLabel(s)} + + `, + ) + .join("")} +
+
+ +
+
+ +
    + ${renderMessageList(listItems)} +
+

Total: ${messages.length} messages

`; + + return renderLayout( + "Inbound Messages", + renderNav("incoming", navData), + content, + ); +} diff --git a/ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/pages/mllp-client.ts b/ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/pages/mllp-client.ts new file mode 100644 index 00000000..3bf94a53 --- /dev/null +++ b/ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/pages/mllp-client.ts @@ -0,0 +1,402 @@ +/** + * MLLP Client UI Module + * + * Displays the MLLP Test Client page. + */ + +import * as net from "node:net"; +import { wrapWithMLLP, VT, FS, CR } from "../../mllp/mllp-server"; +import { renderNav, renderLayout, type NavData } from "../shared-layout"; +import { htmlResponse, getNavData } from "../shared"; + +// ============================================================================ +// Types (internal) +// ============================================================================ + +interface MLLPClientState { + host: string; + port: number; + message: string; + response?: string; + error?: string; + sent?: boolean; +} + +// ============================================================================ +// Handler Functions (exported) +// ============================================================================ + +export async function handleMLLPClientPage(): Promise { + const navData = await getNavData(); + return htmlResponse(renderMLLPClientPage(navData)); +} + +export async function sendMLLPTest(req: Request): Promise { + const formData = await req.formData(); + const host = (formData.get("host") as string) || "localhost"; + const port = parseInt((formData.get("port") as string) || "2575", 10); + const rawMessage = (formData.get("message") as string) || ""; + + // Normalize line endings to \r (HL7v2 standard) + const message = rawMessage.replace(/\r\n/g, "\r").replace(/\n/g, "\r"); + + const state: MLLPClientState = { host, port, message: rawMessage }; + + try { + const response = await sendMLLPMessage(host, port, message); + state.response = response; + state.sent = true; + } catch (error) { + state.error = error instanceof Error ? error.message : "Unknown error"; + } + + const navData = await getNavData(); + return htmlResponse(renderMLLPClientPage(navData, state)); +} + +// ============================================================================ +// Service Functions (internal) +// ============================================================================ + +/** + * Send HL7v2 message via MLLP protocol and wait for ACK + */ +function sendMLLPMessage( + host: string, + port: number, + message: string, +): Promise { + return new Promise((resolve, reject) => { + const client = net.createConnection({ host, port }, () => { + client.write(wrapWithMLLP(message)); + }); + + const timeout = setTimeout(() => { + client.destroy(); + reject(new Error("Connection timeout (10s)")); + }, 10000); + + let buffer = Buffer.alloc(0); + + client.on("data", (data) => { + buffer = Buffer.concat([buffer, data]); + + // Look for MLLP framing + const startIndex = buffer.indexOf(VT); + if (startIndex === -1) return; + + for (let i = startIndex + 1; i < buffer.length - 1; i++) { + if (buffer[i] === FS && buffer[i + 1] === CR) { + const response = buffer.subarray(startIndex + 1, i).toString("utf-8"); + clearTimeout(timeout); + client.end(); + resolve(response); + return; + } + } + }); + + client.on("error", (err) => { + clearTimeout(timeout); + reject(new Error(`Connection failed: ${err.message}`)); + }); + + client.on("close", () => { + clearTimeout(timeout); + }); + }); +} + +// ============================================================================ +// Rendering Functions (internal) +// ============================================================================ + +function renderMLLPClientPage( + navData: NavData, + state: MLLPClientState = { host: "localhost", port: 2575, message: "" }, +): string { + const now = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14); + const nowDate = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 8); + const msgId = Date.now(); + const vnSuffix = Date.now().toString().slice(-6); + + const sampleMessageGroups = [ + { + type: "ADT", + label: "ADT (Admit/Discharge/Transfer)", + messages: [ + { + name: "ADT^A01 (Admit - Simple)", + message: `MSH|^~\\&|SENDING_APP|SENDING_FAC|RECEIVING_APP|RECEIVING_FAC|${now}||ADT^A01|MSG${msgId}|P|2.4\rEVN|A01|${now}\rPID|1||12345^^^HOSPITAL^MR||Smith^John^A||19800101|M|||123 Main St^^Anytown^CA^12345||555-555-5555\rPV1|1|I|ICU^101^A|E|||12345^Jones^Mary^A|||MED||||1|||12345^Jones^Mary^A|IN||||||||||||||||||||||||||${now}`, + }, + { + name: "ADT^A01 (Admit - Full)", + message: `MSH|^~\\&|SENDER|FACILITY|RECEIVER|DEST|${now}||ADT^A01^ADT_A01|MSG${msgId}|P|2.5.1|||AL|AL\rEVN|A01|${now}|||OPERATOR\rPID|1||P12345^^^HOSPITAL^MR||Smith^John^Robert||19850315|M|||123 Main St^^Anytown^CA^12345^USA||^PRN^PH^^1^555^1234567|^WPN^PH^^1^555^9876543||M||P12345\rPV1|1|I|WARD1^ROOM1^BED1||||123^ATTENDING^DOCTOR|||MED||||ADM|||||VN001|||||||||||||||||||||||||||${now}\rNK1|1|Smith^Jane||456 Oak St^^Othertown^CA^54321^USA|^PRN^PH^^1^555^5551234||||||||||||||||||||||||||||||||\rDG1|1||I10^Essential Hypertension^ICD10||${nowDate}|||||||||||001^PHYSICIAN^DIAGNOSING\rAL1|1|DA|PCN^Penicillin^RXNORM|SV|Rash||\rIN1|1|BCBS^Blue Cross Blue Shield||Blue Cross||||GRP001|Blue Cross Group|||20230101|20231231||HMO||SEL|||||||||||||||||||POL123`, + }, + { + name: "ADT^A08 (Update)", + message: `MSH|^~\\&|SENDING_APP|SENDING_FAC|RECEIVING_APP|RECEIVING_FAC|${now}||ADT^A08|MSG${msgId}|P|2.4\rEVN|A08|${now}\rPID|1||12345^^^HOSPITAL^MR||Smith^John^A||19800101|M|||456 New St^^Newtown^CA^54321||555-555-1234`, + }, + ], + }, + { + type: "BAR", + label: "BAR (Billing Account Record)", + messages: [ + { + name: "BAR^P01 (Add Account)", + message: `MSH|^~\\&|BILLING|HOSPITAL|RECEIVER|FAC|${now}||BAR^P01|MSG${msgId}|P|2.5\rEVN|P01|${now}\rPID|1||MRN12345||Doe^Jane^M||19850315|F\rPV1|1|O|CLINIC^201||||||12345^Smith^Robert|||||||||||ACCT001`, + }, + ], + }, + { + type: "ORM", + label: "ORM (Orders)", + messages: [ + { + name: "ORM^O01 (Order)", + message: `MSH|^~\\&|ORDER_SYS|HOSPITAL|LAB|LAB_FAC|${now}||ORM^O01|MSG${msgId}|P|2.4\rPID|1||PAT001^^^HOSP^MR||Johnson^Mary||19900520|F\rORC|NW|ORD001||||||||||12345^Doctor^Test\rOBR|1|ORD001||CBC^Complete Blood Count^L|||${now}`, + }, + ], + }, + { + type: "ORU", + label: "ORU (Observation Results)", + messages: [ + { + name: "ORU^R01 (Lab Result, Inline LOINC)", + message: `MSH|^~\\&|LAB|HOSPITAL|EMR|DEST|${now}||ORU^R01|MSG${msgId}|P|2.5.1\rPID|1||TEST-0001^^^HOSPITAL^MR||TESTPATIENT^ALPHA||20000101|M\rPV1|1|O|LAB||||||||||||||||VN${vnSuffix}\rORC|RE|ORD001|FIL001\rOBR|1|ORD001|FIL001|LAB100^METABOLIC PANEL^LOCAL|||${now}|||||||||PROV001^TEST^PROVIDER||||||${now}||Lab|F\rOBX|1|NM|2823-3^Potassium^LN||4.2|mmol/L|3.5-5.5||||F|||${now}\rOBX|2|NM|2951-2^Sodium^LN||140|mmol/L|136-145||||F|||${now}\rOBX|3|NM|2160-0^Creatinine^LN||1.1|mg/dL|0.7-1.3||||F|||${now}\rNTE|1|L|All results within normal limits.`, + }, + { + name: "ORU^R01 (Lab Result, Known LOINC)", + message: `MSH|^~\\&|ACME_LAB|ACME_HOSP|EMR|DEST|${now}||ORU^R01|MSG${msgId}|P|2.5.1\rPID|1||TEST-0002^^^HOSPITAL^MR||TESTPATIENT^BETA||19850515|F\rPV1|1|O|LAB||||||||||||||||VN${vnSuffix}\rORC|RE|ORD002|FIL002\rOBR|1|ORD002|FIL002|CHEM7^CHEMISTRY PANEL^LOCAL|||${now}|||||||||PROV002^LAB^DOCTOR||||||${now}||Lab|F\rOBX|1|NM|K_SERUM^Potassium [Serum/Plasma]^LOCAL||4.5|mmol/L|3.5-5.5||||F|||${now}\rOBX|2|NM|NA_SERUM^Sodium [Serum/Plasma]^LOCAL||142|mmol/L|136-145||||F|||${now}\rOBX|3|NM|GLU_FASTING^Glucose Fasting^LOCAL||95|mg/dL|70-100||||F|||${now}\rNTE|1|L|Local codes used - LOINC mapping required.`, + }, + { + name: "ORU^R01 (Lab Result, Unknown LOINC)", + message: `MSH|^~\\&|ACME_LAB|ACME_HOSP|EMR|DEST|${now}||ORU^R01|MSG${msgId}|P|2.5.1\rPID|1||TEST-0003^^^HOSPITAL^MR||TESTPATIENT^GAMMA||19901225|M\rPV1|1|O|LAB||||||||||||||||VN${vnSuffix}\rORC|RE|ORD003|FIL003\rOBR|1|ORD003|FIL003|CHEM7^CHEMISTRY PANEL^LOCAL|||${now}|||||||||PROV003^LAB^DOCTOR||||||${now}||Lab|F\rOBX|1|NM|UNKNOWN_TEST^Unknown Lab Test^LOCAL||123|units|0-200||||F|||${now}\rNTE|1|L|This code has no LOINC mapping in ConceptMap.`, + }, + ], + }, + { + type: "VXU", + label: "VXU (Vaccination Update)", + messages: [ + { + name: "VXU^V04 (v2.8.2, COVID-19 + Influenza)", + message: `MSH|^~\\&|EHR_APP|CLINIC_A^54321|IIS_RECV|STATE_DOH|${now}||VXU^V04^VXU_V04|VXU${now}-001|P|2.8.2|||AL|AL|||||Z32^CDCPHINVS\rPID|1||PAT100^^^CLINIC_A^MR||TESTPATIENT^DELTA^M^^^L||20100615|M||2054-5^Black or African American^CDCREC|100 ELM ST^^PORTLAND^OR^97201^USA||^PRN^PH^^^503^5550100\rPD1|||CLINIC_A^54321^L|||||02^Reminder/Recall - any method^HL70215\rNK1|1|TESTPATIENT^ALICE^L|MTH^Mother^HL70063|100 ELM ST^^PORTLAND^OR^97201^USA|^PRN^PH^^^503^5550101\rORC|RE||IMM${now}-001^CLINIC_A||||||${nowDate}|||5678^PROVIDER^SARAH^J^^^MD^NPI^L|||CLINIC_A^54321^L\rRXA|0|1|${nowDate}|${nowDate}|207^COVID-19 mRNA, LNP-S, PF, 30 mcg/0.3 mL dose^CVX|0.3|mL^milliliter^UCUM||00^New immunization record^NIP001|5678^PROVIDER^SARAH^J^^^MD^NPI^L|^^^CLINIC_A^54321^L||||LOT12345|20271231|PFR^Pfizer^MVX|||CP|A\rRXR|IM^Intramuscular^HL70162|LD^Left Deltoid^HL70163\rOBX|1|CE|64994-7^Vaccine funding program eligibility category^LN|1|V02^VFC eligible - Medicaid^HL70064||||||F\rOBX|2|CE|69764-9^Document type^LN|2|253088698300026411121116^COVID-19 Vaccine^cdcgs1vis||||||F\rOBX|3|TS|29768-9^Date vaccine information statement published^LN|2|20230806||||||F\rOBX|4|TS|29769-7^Date vaccine information statement presented^LN|2|${nowDate}||||||F\rORC|RE||IMM${now}-002^CLINIC_A||||||${nowDate}|||5678^PROVIDER^SARAH^J^^^MD^NPI^L|||CLINIC_A^54321^L\rRXA|0|1|${nowDate}|${nowDate}|158^Influenza, injectable, quadrivalent^CVX|0.5|mL^milliliter^UCUM||00^New immunization record^NIP001|5678^PROVIDER^SARAH^J^^^MD^NPI^L|^^^CLINIC_A^54321^L||||FLULOT789|20270601|SKB^GlaxoSmithKline^MVX|||CP|A\rRXR|IM^Intramuscular^HL70162|RD^Right Deltoid^HL70163\rOBX|1|CE|64994-7^Vaccine funding program eligibility category^LN|1|V02^VFC eligible - Medicaid^HL70064||||||F`, + }, + { + name: "VXU^V04 (v2.5.1, Full with PD1/NK1/OBX)", + message: `MSH|^~\\&|EMR_SYS|SAMPLE_CLINIC^99999|STATE_IIS|STATE_DOH|20260211103000-0800||VXU^V04^VXU_V04|VXU20260211-00001|P|2.5.1|||AL|AL|||||Z32^CDCPHINVS\rPID|1||PAT200^^^SAMPLE_CLINIC^MR||TESTPATIENT^ECHO^A^^^L||20141023|F||2106-3^White^CDCREC|500 MAPLE AVE^^ANYTOWN^WA^98000^USA||^PRN^PH^^^555^5550200|^NET^Internet^test@example.com||S\rPD1|||SAMPLE_CLINIC^99999^L|||||02^Reminder/Recall - any method^HL70215|||N^No^HL70136\rNK1|1|TESTPATIENT^FRANK^B|FTH^Father^HL70063|500 MAPLE AVE^^ANYTOWN^WA^98000^USA|^PRN^PH^^^555^5550201\rORC|RE||IMM20260211-990011^SAMPLE_CLINIC||||||20260211|||9876^DOCTOR^LISA^M^^^MD^NPI^L|||SAMPLE_CLINIC^99999^L\rRXA|0|1|20260211|20260211|207^COVID-19 mRNA, LNP-S, PF, 30 mcg/0.3 mL dose^CVX|0.3|mL^milliliter^UCUM||00^New immunization record^NIP001|9876^DOCTOR^LISA^M^^^MD^NPI^L|^^^SAMPLE_CLINIC^99999^L||||SAMPLELT456|20270131|PFR^Pfizer^MVX|||CP|A\rRXR|IM^Intramuscular^HL70162|RA^Right Arm^HL70163\rOBX|1|CE|64994-7^Vaccine funding program eligibility category^LN|1|V02^VFC eligible - Medicaid^HL70064||||||F\rOBX|2|CE|69764-9^Document type^LN|2|253088698300026411121116^COVID-19 Vaccine^cdcgs1vis||||||F\rOBX|3|TS|29768-9^Date vaccine information statement published^LN|2|20230806||||||F\rOBX|4|TS|29769-7^Date vaccine information statement presented^LN|2|20260211||||||F`, + }, + { + name: "VXU^V04 (Broken - SNOMED in RXA, missing dates)", + message: `MSH|^~\\&|TEST_APP|TEST_CLINIC|||20231005162929.774+0000||VXU^V04^VXU_V04|MSG0000020000001|P|2.5.1\rPID|1||PAT300^^^^FI||TESTPATIENT^ZETA||20000101|U||2076-8^Native Hawaiian or Other Pacific Islander^HL70005|100 TEST ST^^ANYTOWN^CA^99999^USA|||||||||||U^Unknown^HL70189\rPV1|1|R||||||||||||||||||||||||||||||||||||||||||20230906050813\rRXA|0|1|||1119349007^COVID-19 mRNA vaccine^SCT|40 mg|||||||||1|20190815|^Generic\rRXR|78421000^ID (Intradermal) Route^HL70162|368209003^Left Deltoid (Upper arm)^HL70163`, + }, + ], + }, + ]; + + const content = ` +
+

Simulate Sender

+
+ Send HL7v2 messages via MLLP protocol +
+
+ + ${ + state.error + ? ` +
+
+ + + + Error +
+

${state.error}

+
+ ` + : "" + } + + ${ + state.sent && state.response + ? ` +
+
+ + + + Message Sent Successfully +
+
+

ACK Response:

+
${state.response.replace(/\r/g, "\n")}
+
+
+ ` + : "" + } + +
+
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + +
+
+ +
+ +
+

Use \\r for segment separators or paste multi-line message

+
+ +
+ + +
+
+
+ +
+
+

Sample Messages

+
+ ${sampleMessageGroups + .map( + (group) => ` +
+ ${group.label} +
+ ${group.messages + .map( + (sample) => ` + + `, + ) + .join("")} +
+
+ `, + ) + .join("")} +
+
+ +
+

MLLP Protocol Info

+
+

Start Block: VT (0x0B)

+

End Block: FS + CR (0x1C 0x0D)

+

Default Port: 2575

+
+
+ +
+

Start MLLP Server

+
bun run mllp
+
+
+
+ + `; + + return renderLayout( + "Simulate Sender", + renderNav("mllp-client", navData), + content, + ); +} diff --git a/ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/pagination.ts b/ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/pagination.ts new file mode 100644 index 00000000..6d9f7126 --- /dev/null +++ b/ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/pagination.ts @@ -0,0 +1,99 @@ +export const PAGE_SIZE = 50; + +export interface PaginationData { + currentPage: number; + total: number; + totalPages: number; +} + +export type FilterParams = Record; + +export interface PaginationOptions { + pagination: PaginationData; + baseUrl: string; + filterParams?: FilterParams; +} + +export function calculateTotalPages(total: number): number { + return Math.ceil(total / PAGE_SIZE); +} + +export function parsePageParam(searchParams: URLSearchParams): number { + return parseInt(searchParams.get("_page") || "1", 10); +} + +export function clampPage(page: number, totalPages: number): number { + if (isNaN(page) || page < 1) return 1; + if (totalPages <= 0) return 1; + if (page > totalPages) return totalPages; + return page; +} + +export function createPagination( + rawPage: number, + total: number, +): PaginationData { + const totalPages = calculateTotalPages(total); + return { + currentPage: clampPage(rawPage, totalPages), + total, + totalPages, + }; +} + +function buildUrl( + baseUrl: string, + page: number, + filterParams?: FilterParams, +): string { + const params = new URLSearchParams(); + params.set("_page", String(page)); + if (filterParams) { + for (const [key, value] of Object.entries(filterParams)) { + if (value) { + params.set(key, value); + } + } + } + return `${baseUrl}?${params.toString()}`; +} + +export function renderPaginationControls(options: PaginationOptions): string { + const { pagination, baseUrl, filterParams } = options; + const { currentPage, totalPages } = pagination; + + if (totalPages <= 1) return ""; + + const isFirstPage = currentPage === 1; + const isLastPage = currentPage === totalPages; + + const disabledClass = + "px-3 py-1.5 rounded-lg text-sm font-medium bg-gray-100 text-gray-400 cursor-not-allowed"; + const enabledClass = + "px-3 py-1.5 rounded-lg text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300"; + + const firstButton = isFirstPage + ? `First` + : `First`; + + const prevButton = isFirstPage + ? `Prev` + : `Prev`; + + const nextButton = isLastPage + ? `Next` + : `Next`; + + const lastButton = isLastPage + ? `Last` + : `Last`; + + return ` +
+ ${firstButton} + ${prevButton} + ${currentPage} / ${totalPages} + ${nextButton} + ${lastButton} +
`; +} diff --git a/ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/shared-layout.ts b/ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/shared-layout.ts new file mode 100644 index 00000000..656d3596 --- /dev/null +++ b/ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/shared-layout.ts @@ -0,0 +1,424 @@ +/** + * Shared layout components for UI pages + */ + +import { getHighlightStyles, highlightHL7Message } from "@atomic-ehr/hl7v2/src/hl7v2/highlight"; + +export function highlightHL7WithDataTooltip( + message: string | undefined, +): string { + const html = highlightHL7Message(message); + return html.replace(/\btitle="/g, 'data-tooltip="'); +} + +export type NavTab = + | "accounts" + | "outgoing" + | "incoming" + | "mllp-client" + | "mapping-tasks" + | "code-mappings"; + +export interface NavData { + pendingMappingTasksCount: number; +} + +interface NavTabDef { + id: NavTab; + href: string; + label: string; + badge?: number; +} + +function getEnvLabel(): string { + return process.env.ENV || "dev"; +} + +function renderTab(tab: NavTabDef, active: NavTab): string { + const isActive = active === tab.id; + const classes = isActive + ? "border-blue-500 text-blue-600 font-semibold" + : "border-transparent text-gray-600 hover:text-gray-800"; + const badge = + tab.badge && tab.badge > 0 + ? `${tab.badge}` + : ""; + return `${tab.label}${badge}`; +} + +export function renderNav(active: NavTab, navData: NavData): string { + // Order by demo flow: inbound pipeline first (messages arrive → simulator → + // remediation), then outbound (accounts → BAR messages), then reference. + // The "data direction" story lives in the dashboard pipeline diagram, not + // here — nav is just navigation. + const tabs: NavTabDef[] = [ + { id: "incoming", href: "/incoming-messages", label: "Inbound Messages" }, + { id: "mllp-client", href: "/mllp-client", label: "Simulate Sender" }, + { + id: "mapping-tasks", + href: "/mapping/tasks", + label: "Unmapped Codes", + badge: navData.pendingMappingTasksCount, + }, + { id: "accounts", href: "/accounts", label: "Accounts" }, + { id: "outgoing", href: "/outgoing-messages", label: "Outgoing Messages" }, + { id: "code-mappings", href: "/mapping/table", label: "Terminology Map" }, + ]; + + const tabsHtml = tabs.map((tab) => renderTab(tab, active)).join(""); + + const env = getEnvLabel(); + const envClass = + env === "prod" || env === "production" + ? "bg-red-100 text-red-700" + : env === "staging" || env === "test" + ? "bg-amber-100 text-amber-700" + : "bg-gray-100 text-gray-600"; + + const statusCluster = ` +
+ ${env} + + + Aidbox + +
`; + + return ` + `; +} + +export function renderLayout( + title: string, + nav: string, + content: string, +): string { + return ` + + + + + ${title} + + + + + ${nav} +
+ ${content} +
+ + +`; +} diff --git a/ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/shared.ts b/ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/shared.ts new file mode 100644 index 00000000..3c0193ff --- /dev/null +++ b/ai/tickets/ui-refactoring/hl7v2-v2/project/uploads/ui/shared.ts @@ -0,0 +1,30 @@ +/** + * Shared utilities for UI route handlers + */ + +import type { Task } from "../fhir/hl7-fhir-r4-core/Task"; +import { aidboxFetch, type Bundle } from "../aidbox"; +import type { NavData } from "./shared-layout"; +import { MAPPING_TYPES } from "../code-mapping/mapping-types"; + +export function htmlResponse(html: string): Response { + return new Response(html, { headers: { "Content-Type": "text/html" } }); +} + +export function redirectResponse(location: string): Response { + return new Response(null, { status: 302, headers: { Location: location } }); +} + +export async function getPendingTasksCount(): Promise { + const mappingTypes = Object.keys(MAPPING_TYPES); + const codeParam = mappingTypes.join(","); + const bundle = await aidboxFetch>( + `/fhir/Task?code=${codeParam}&status=requested&_count=0&_total=accurate`, + ); + return bundle.total || 0; +} + +export async function getNavData(): Promise { + const pendingMappingTasksCount = await getPendingTasksCount(); + return { pendingMappingTasksCount }; +} diff --git a/ai/tickets/ui-refactoring/hl7v2-v2/project/wireframes.jsx b/ai/tickets/ui-refactoring/hl7v2-v2/project/wireframes.jsx new file mode 100644 index 00000000..248cacac --- /dev/null +++ b/ai/tickets/ui-refactoring/hl7v2-v2/project/wireframes.jsx @@ -0,0 +1,222 @@ +// Wireframe primitives — sketchy low-fi + one accent. Kept tiny & composable. + +// --- Base tokens --- +const WF = { + ink: '#1e1a17', + ink2: '#4a4239', + ink3: '#8a8176', + paper: '#fdfcf9', + paper2: '#f4f1ea', + line: '#2c2620', + lineLight: 'rgba(44,38,32,0.25)', + lineMid: 'rgba(44,38,32,0.55)', + muted: 'rgba(44,38,32,0.12)', + mutedBg: 'rgba(44,38,32,0.04)', + // accent is resolved from CSS var so Tweaks can swap it + accent: 'var(--wf-accent, #d9623f)', + accentSoft: 'var(--wf-accent-soft, #f6d3c6)', + hand: '"Caveat", "Patrick Hand", "Comic Sans MS", cursive', + sans: '"Kalam", "Caveat", system-ui, sans-serif', + mono: '"JetBrains Mono", ui-monospace, Menlo, monospace', +}; + +// inject shared wireframe css once +if (typeof document !== 'undefined' && !document.getElementById('wf-styles')) { + const s = document.createElement('style'); + s.id = 'wf-styles'; + s.textContent = ` + @import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Kalam:wght@300;400;700&family=Patrick+Hand&family=JetBrains+Mono:wght@400;500&display=swap'); + .wf { font-family: ${WF.sans}; color: ${WF.ink}; background: ${WF.paper}; } + .wf-hand { font-family: ${WF.hand}; } + .wf-mono { font-family: ${WF.mono}; } + .wf-h1 { font-family: ${WF.hand}; font-weight: 700; font-size: 32px; letter-spacing: -.5px; line-height: 1; } + .wf-h2 { font-family: ${WF.hand}; font-weight: 600; font-size: 22px; line-height: 1.05; } + .wf-h3 { font-family: ${WF.hand}; font-weight: 600; font-size: 17px; letter-spacing: .2px; } + .wf-label { font-family: ${WF.hand}; font-weight: 500; font-size: 14px; color: ${WF.ink2}; } + .wf-body { font-family: ${WF.sans}; font-weight: 400; font-size: 13px; line-height: 1.35; } + .wf-tiny { font-family: ${WF.sans}; font-weight: 400; font-size: 11px; color: ${WF.ink3}; } + .wf-note { font-family: ${WF.hand}; color: ${WF.ink3}; font-size: 14px; } + /* rough border using double-box-shadow trick */ + .wf-box { border: 1.5px solid ${WF.line}; border-radius: 6px; background: ${WF.paper}; position: relative; } + .wf-box-soft { border: 1.25px solid ${WF.lineMid}; border-radius: 5px; background: ${WF.paper}; } + .wf-box-dashed { border: 1.5px dashed ${WF.lineMid}; border-radius: 6px; } + .wf-chip { display: inline-flex; align-items: center; gap: 4px; border: 1.25px solid ${WF.line}; border-radius: 999px; padding: 2px 9px; font-family: ${WF.hand}; font-size: 13px; background: ${WF.paper}; } + .wf-chip-accent { background: ${WF.accentSoft}; border-color: ${WF.accent}; color: ${WF.ink}; } + .wf-chip-ghost { border-style: dashed; color: ${WF.ink3}; } + .wf-hscroll::-webkit-scrollbar { height: 9px; -webkit-appearance: none; } + .wf-hscroll::-webkit-scrollbar-track { background: ${WF.mutedBg}; border: 1px dashed ${WF.lineLight}; border-radius: 5px; } + .wf-hscroll::-webkit-scrollbar-thumb { background: ${WF.line}; border-radius: 5px; border: 1.5px solid ${WF.paper}; } + .wf-hscroll::-webkit-scrollbar-thumb:hover { background: ${WF.ink2}; } + .wf-hscroll { scrollbar-width: thin; scrollbar-color: ${WF.line} ${WF.mutedBg}; } + .wf-btn { display: inline-flex; align-items: center; gap: 6px; border: 1.5px solid ${WF.line}; padding: 6px 12px; border-radius: 6px; font-family: ${WF.hand}; font-size: 15px; background: ${WF.paper}; cursor: pointer; } + .wf-btn-accent { background: ${WF.accent}; color: white; border-color: ${WF.ink}; box-shadow: 2px 2px 0 ${WF.ink}; } + .wf-accent-ink { color: ${WF.accent}; } + .wf-accent-bg { background: ${WF.accentSoft}; } + .wf-accent-border { border-color: ${WF.accent}; } + .wf-dashed { border: 1.5px dashed ${WF.lineLight}; } + .wf-scrib { background-image: repeating-linear-gradient(115deg, transparent 0 6px, ${WF.lineLight} 6px 7px); } + .wf-under { background-image: linear-gradient(${WF.accent}, ${WF.accent}); background-repeat: no-repeat; background-size: 100% 4px; background-position: 0 95%; padding-bottom: 1px; } + .wf-tab { font-family: ${WF.hand}; font-size: 15px; padding: 7px 12px 8px; color: ${WF.ink2}; cursor: pointer; position: relative; } + .wf-tab-on { color: ${WF.ink}; } + .wf-tab-on::after { content: ''; position: absolute; left: 6px; right: 6px; bottom: -2px; height: 3px; background: ${WF.accent}; border-radius: 2px; } + .wf-divider { height: 1.25px; background: ${WF.lineLight}; } + .wf-vdivider { width: 1.25px; background: ${WF.lineLight}; } + .wf-dot { width: 7px; height: 7px; border-radius: 99px; display: inline-block; } + .wf-pulse { position: relative; } + .wf-pulse::after { content:''; position:absolute; inset:-3px; border-radius:99px; border: 1.5px solid ${WF.accent}; animation: wfPulse 1.6s ease-out infinite; } + @keyframes wfPulse { 0%{transform:scale(.7);opacity:.8} 100%{transform:scale(1.6);opacity:0} } + .wf-scroll-hint { background: repeating-linear-gradient(90deg, ${WF.lineLight} 0 8px, transparent 8px 14px); height: 1.5px; } + .wf-ph-line { height: 8px; border-radius: 2px; background: ${WF.muted}; } + .wf-skel { background: ${WF.mutedBg}; border-radius: 3px; } + .wf-strike { text-decoration: line-through; color: ${WF.ink3}; } + .wf-arrow { color: ${WF.accent}; } + .wf-grid-dots { background-image: radial-gradient(${WF.lineLight} 1px, transparent 1px); background-size: 14px 14px; } + input.wf-input, textarea.wf-input, .wf-input { font-family: ${WF.mono}; font-size: 12px; background: ${WF.paper}; border: 1.25px solid ${WF.lineMid}; border-radius: 4px; padding: 5px 8px; width: 100%; color: ${WF.ink}; } + .wf-field { display: flex; flex-direction: column; gap: 3px; } + .wf-kbd { font-family: ${WF.mono}; font-size: 10px; border: 1px solid ${WF.lineMid}; border-bottom-width: 2px; padding: 1px 4px; border-radius: 3px; color: ${WF.ink2}; } + .wf-squiggle { border-bottom: 2.5px solid ${WF.accent}; border-radius: 50%; height: 0; width: 100%; } + `; + document.head.appendChild(s); +} + +// ── Tiny primitives ───────────────────────────────────────── +const Row = ({children, style, ...p}) =>
{children}
; +const Col = ({children, style, ...p}) =>
{children}
; +const Sp = ({h=8, w=0}) =>
; + +// rough horizontal scribble used where real content would go +const Scribble = ({w='70%', thickness=6, style}) => ( +
+); + +// sketchy arrow (single svg, jittery path) +const Arrow = ({w=60, h=14, dir='right', color}) => { + const c = color || WF.accent; + const pts = dir === 'right' + ? `M2,${h/2} C ${w*0.3},${h*0.2} ${w*0.5},${h*0.85} ${w-8},${h/2}` + : `M${w-2},${h/2} C ${w*0.7},${h*0.2} ${w*0.5},${h*0.85} 8,${h/2}`; + const head = dir === 'right' + ? `M${w-8},${h/2-5} L${w-2},${h/2} L${w-8},${h/2+5}` + : `M8,${h/2-5} L2,${h/2} L8,${h/2+5}`; + return ( + + + + + ); +}; + +// hand-drawn checkbox/circle/etc icons +const Ico = ({name, size=14, color}) => { + const c = color || WF.ink; + const s = size; + const common = { width: s, height: s, stroke: c, fill: 'none', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }; + const svgs = { + check: , + x: , + warn: , + info: , + dot: , + search: , + plus: , + chev: , + chevD: , + play: , + clock: , + grip: , + up: , + down: , + refresh: , + bolt: , + link: , + filter: , + pause: , + }; + return svgs[name] || null; +}; + +// top app nav (all screens) +const AppNav = ({active='dashboard', showGroups=true}) => { + const tabs = showGroups ? [ + {id:'dashboard', label:'Home', group:null}, + {id:'inbound', label:'Inbound Messages', group:'inbound'}, + {id:'simulate', label:'Simulate Sender', group:'inbound'}, + {id:'unmapped', label:'Unmapped Codes', group:'inbound', badge:'3'}, + {id:'accounts', label:'Accounts', group:'outbound'}, + {id:'outgoing', label:'Outgoing Messages', group:'outbound'}, + {id:'terminology', label:'Terminology Map', group:'ref'}, + ] : [ + {id:'inbound', label:'Inbound Messages'}, + {id:'simulate', label:'Simulate Sender'}, + {id:'unmapped', label:'Unmapped Codes', badge:'3'}, + {id:'accounts', label:'Accounts'}, + {id:'outgoing', label:'Outgoing Messages'}, + {id:'terminology', label:'Terminology Map'}, + ]; + return ( +
+
+
h7
+ hl7.demo +
+
+ {tabs.map((t,i) => { + const groupBreak = showGroups && i>0 && tabs[i-1].group !== t.group && t.group; + return ( + + {groupBreak &&
} +
+ {t.label} + {t.badge && {t.badge}} +
+ + ); + })} +
+ + DEV + + + Aidbox + + +
+ ); +}; + +// Page shell (nav + content + optional title/subtitle) +const Screen = ({nav='dashboard', title, subtitle, right, showNav=true, navGroups=true, children, pad=24, bg}) => ( + + {showNav && } + {(title || subtitle || right) && ( + + + {title &&
{title}
} + {subtitle &&
{subtitle}
} + + {right} +
+ )} +
{children}
+ +); + +// Status chip presets +const StatusChip = ({kind, children}) => { + const map = { + ok: {bg:'#e3f3e8', fg:'#1f6a3a', bd:'#7fbf9a', ico:'check'}, + warn: {bg:'#fef3d6', fg:'#7a5a0a', bd:'#e0b85a', ico:'warn'}, + err: {bg:'#fce1dc', fg:'#8a2a1a', bd:'#d07a6a', ico:'x'}, + info: {bg:'#e0ebf8', fg:'#2a4a7a', bd:'#8aa7cc', ico:'info'}, + pend: {bg:'#ece8e0', fg:'#5a4a2a', bd:'#b5a98f', ico:'clock'}, + accent: {bg:'var(--wf-accent-soft)', fg:WF.ink, bd:'var(--wf-accent)', ico:'bolt'}, + }; + const s = map[kind] || map.info; + return ( + + {children} + + ); +}; + +Object.assign(window, { WF, Row, Col, Sp, Scribble, Arrow, Ico, AppNav, Screen, StatusChip }); From a258338b11f632a1b62fd6416c25b0879c5eec67 Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Thu, 23 Apr 2026 09:20:59 +0600 Subject: [PATCH 02/26] minor changes to ticket 2026-04-23-ui-design-system-refactor.md --- .../2026-04-23-ui-design-system-refactor.md | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/ai/tickets/2026-04-23-ui-design-system-refactor.md b/ai/tickets/2026-04-23-ui-design-system-refactor.md index 62a08e3e..7b1449b7 100644 --- a/ai/tickets/2026-04-23-ui-design-system-refactor.md +++ b/ai/tickets/2026-04-23-ui-design-system-refactor.md @@ -42,7 +42,8 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound - [ ] 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) - [ ] Extend `getNavData()` in `src/ui/shared.ts`: add `incomingTotal` (FHIR `IncomingHL7v2Message?_count=0`) alongside existing `pendingTaskCount` - [ ] 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`) -- [ ] Migrate every existing page handler in `src/ui/pages/*.ts` (accounts, messages for both halves, mapping-tasks, code-mappings, mllp-client) to call `renderShell` instead of `renderLayout`; keep `renderLayout` exported as a thin deprecated wrapper for one release so nothing breaks mid-migration +- [ ] Migrate every existing page handler in `src/ui/pages/*.ts` (accounts, messages for both halves, mapping-tasks, code-mappings, mllp-client) to call `renderShell` instead of `renderLayout`; delete `renderLayout` and the legacy top-nav helpers from `src/ui/shared-layout.ts` in the same commit (no gradual migration — project isn't in production) +- [ ] Rename routes in `src/index.ts` to their final names, pointing at the existing handlers (bodies rebuilt in Tasks 5, 9, 10): `/mllp-client` → `/simulate-sender`, `/mapping/tasks` → `/unmapped-codes`, `/mapping/table` → `/terminology` - [ ] Update any UI unit test that asserts nav markup (`test/unit/ui/*`) for the new sidebar structure - [ ] Run validation — must pass; manually confirm every existing page renders with the new sidebar and old body intact - [ ] Stop for user review before next task @@ -57,9 +58,19 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound - [ ] Run validation — must pass (typecheck is the only mechanical check for docs; no broken imports) - [ ] Stop for user review before next task -## Task 5: Dashboard page + scripted demo runner +## Task 5: Simulate Sender page -- [ ] Create `src/api/demo-scenario.ts` exporting `runDemoScenario()` that fires four MLLP messages (ADT^A01, ORU^R01 known, VXU^V04, ORU^R01 unknown) with 2s spacing, fire-and-forget (`.catch(console.error)`); sample payloads lifted from `src/ui/pages/mllp-client.ts:130-175` +- [ ] Create `src/ui/pages/simulate-sender.ts` with `handleSimulateSenderPage` — composer layout per `DESIGN_OVERVIEW.md § Simulate Sender` +- [ ] 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 6 can import the templates for the scripted demo +- [ ] Alpine: editor component (`x-data` with `raw`, `typeId`, `sender`, computed `parsed`/`hasUnknown`), overlay `
` + transparent textarea (accent segment names, warn highlight on unmapped tokens), SendCard state machine (`idle → sending → sent | held`) with elapsed tick
+- [ ] Replace the handler wired to `/simulate-sender` (renamed in Task 3) with `handleSimulateSenderPage`; register `POST /simulate-sender/send` reusing `sendMLLPMessage()` from `src/ui/pages/mllp-client.ts` and returning JSON `{status: "sent"|"held", ack: string, messageId: string}` (ACK `AA` → sent, `AE` → held)
+- [ ] Unit tests: happy-path send, held-for-mapping send (unknown code in body), MLLP-unreachable error path
+- [ ] Run validation — must pass; manual smoke in browser (pick ORU unknown template, send, see "Held for mapping" banner)
+- [ ] Stop for user review before next task
+
+## Task 6: Dashboard page + scripted demo runner
+
+- [ ] Create `src/api/demo-scenario.ts` exporting `runDemoScenario()` that fires four MLLP messages (ADT^A01, ORU^R01 known, VXU^V04, ORU^R01 unknown) with 2s spacing, fire-and-forget (`.catch(console.error)`); import the templates from `src/ui/pages/simulate-sender.ts`'s exported `MESSAGE_TYPES` (shipped in Task 5)
 - [ ] Register routes in `src/index.ts`: `POST /demo/run-scenario` (guarded by `DEMO_MODE !== "off"`, returns 202), `GET /dashboard/partials/stats`, `GET /dashboard/partials/ticker?limit=15`
 - [ ] Create `src/ui/pages/dashboard.ts` with `handleDashboardPage`: hero + demo-conductor card + stats strip + live ticker per `DESIGN_OVERVIEW.md § Dashboard`; htmx `hx-trigger="every 10s"` on stats, `every 5s` on ticker; Alpine `x-data` for ticker-pause toggle
 - [ ] Stats partial computes: received-today, need-mapping (`status=code_mapping_error`), errors (multi-status OR), avg-latency (from `meta.lastUpdated - date` over last 100 processed messages in-memory), worker health (new `getWorkerHealth()` in `src/workers.ts` exposing the current handle's running state)
@@ -68,17 +79,6 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound
 - [ ] Run validation — must pass; manual smoke: click "Run demo now", confirm 4 rows appear in the ticker within ~10s
 - [ ] Stop for user review before next task
 
-## Task 6: Simulate Sender page
-
-- [ ] Create `src/ui/pages/simulate-sender.ts` with `handleSimulateSenderPage` — composer layout per `DESIGN_OVERVIEW.md § Simulate Sender`
-- [ ] 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
-- [ ] Alpine: editor component (`x-data` with `raw`, `typeId`, `sender`, computed `parsed`/`hasUnknown`), overlay `
` + transparent textarea (accent segment names, warn highlight on unmapped tokens), SendCard state machine (`idle → sending → sent | held`) with elapsed tick
-- [ ] Register `GET /simulate-sender` and `POST /simulate-sender/send` in `src/index.ts`; POST handler reuses `sendMLLPMessage()` from `src/ui/pages/mllp-client.ts` and returns JSON `{status: "sent"|"held", ack: string, messageId: string}`; ACK `AA` → sent, `AE` → held
-- [ ] Add 302 redirect `/mllp-client` → `/simulate-sender`
-- [ ] Unit tests: happy-path send, held-for-mapping send (unknown code in body), MLLP-unreachable error path
-- [ ] Run validation — must pass; manual smoke in browser (pick ORU unknown template, send, see "Held for mapping" banner)
-- [ ] Stop for user review before next task
-
 ## Task 7: Inbound Messages — list pane + type chips
 
 - [ ] Create `src/ui/pages/inbound.ts` with `handleInboundMessagesPage` — hero, type-chip row, two-pane layout (list card + empty-state detail card); supports `?type=&status=&batch=&selected=` URL params; when `selected` is set, pre-render detail pane server-side into `#detail`
@@ -106,10 +106,9 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound
 - [ ] Create `src/api/terminology-suggest.ts` exporting `suggestCodes(display, field)`: wraps existing `searchLoincCodes()` from `src/code-mapping/terminology-api.ts`; scores each result (exact-substring in display → 100; Jaro-Winkler similarity → 40–95; short-display bonus); returns top 3 `{code, display, score, system}`
 - [ ] Register `GET /api/terminology/suggest?display=&field=` route
 - [ ] Create `src/ui/pages/unmapped.ts` with `handleUnmappedCodesPage` — queue + editor split per `DESIGN_OVERVIEW.md § Unmapped Codes`; supports `?code=&sender=` pre-selection (matches the "Map code" link from Inbound)
-- [ ] Register `GET /unmapped-codes/partials/queue` and `GET /unmapped-codes/:code/partials/editor?sender=`; editor partial calls `suggestCodes()` for the pre-selected code's display
+- [ ] Replace the handler wired to `/unmapped-codes` (renamed in Task 3) with `handleUnmappedCodesPage`; register `GET /unmapped-codes/partials/queue` and `GET /unmapped-codes/:code/partials/editor?sender=`; editor partial calls `suggestCodes()` for the pre-selected code's display
 - [ ] Queue partial: aggregates open `Task?status=requested` (existing query), regroups by `localCode + sender + field`, counts messages via `Task.input`; returns queue list HTML
 - [ ] Wire actions: Save → existing `POST /api/mapping/tasks/:id/resolve`; Skip → new `POST /unmapped-codes/:code/skip?sender=` that wraps `POST /defer/:id` per matching task; "Skip all" and "Suggest with AI" buttons rendered `disabled` with "coming soon" chip (explicit v1 non-goals)
-- [ ] Add 302 redirect `/mapping/tasks` → `/unmapped-codes`
 - [ ] Unit tests: scoring helper (known-high + known-low cases), queue partial (groups correctly), editor partial (pre-selection loads suggestions)
 - [ ] Run validation — must pass
 - [ ] Stop for user review before next task
@@ -117,12 +116,11 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound
 ## Task 10: Terminology Map — table + filter popovers + detail
 
 - [ ] Create `src/ui/pages/terminology.ts` with `handleTerminologyPage` — KPI strip + two-pane (table + detail); supports `?q=&fhir=&sender=` URL params (multi-valued `fhir` and `sender`)
-- [ ] Register partials: `GET /terminology/partials/table?q=&fhir=&sender=` (server-filtered rows), `GET /terminology/partials/facets/fhir`, `GET /terminology/partials/facets/sender`, `GET /terminology/partials/detail/:conceptMapId/:code`
+- [ ] Replace the handler wired to `/terminology` (renamed in Task 3) with `handleTerminologyPage`; register partials: `GET /terminology/partials/table?q=&fhir=&sender=` (server-filtered rows), `GET /terminology/partials/facets/fhir`, `GET /terminology/partials/facets/sender`, `GET /terminology/partials/detail/:conceptMapId/:code`
 - [ ] Facet partials: in-memory scan of all ConceptMap entries (reuse existing `listConceptMaps` + group), return `{name, count}[]` rendered as searchable multi-select list; Alpine popover (`x-on:click.outside`, `x-on:keyup.escape`) wraps them
 - [ ] Detail partial: FHIR target (split typography), local/std mapping, source panel, minimal lineage (creation time from `ConceptMap/_history?_count=1` lazy), Deprecate/Edit footer
 - [ ] KPI strip values: total mappings (sum across all maps), coverage % (processed / processed + code_mapping_error over last 30d), messages/window (`_count=0` for 30d), needs-review (literal `0` for v1 until deprecation-tracking ticket lands)
 - [ ] **OPEN:** usage count per entry (design shows `usage:4820`) — not tracked today. Render `—` for v1 with a note in `docs/developer-guide/ui-design-tokens.md`; separate ticket for denormalized counters
-- [ ] Add 302 redirect `/mapping/table` → `/terminology`
 - [ ] Unit tests for the 4 new partials; integration test hitting facet counts against the test Aidbox
 - [ ] Run validation — must pass
 - [ ] Stop for user review before next task
@@ -140,8 +138,7 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound
 ## Task 12: Cleanup
 
 - [ ] Delete dead code: old `renderIncomingMessagesPage` body in `src/ui/pages/messages.ts` (keep Outgoing), legacy HTML rendering in `src/ui/pages/mapping-tasks.ts` and `src/ui/pages/code-mappings.ts`, page-render functions in `src/ui/pages/mllp-client.ts` (keep `sendMLLPMessage` + `sendMLLPTest` primitives)
-- [ ] Remove `renderLayout` + top-nav helpers from `src/ui/shared-layout.ts` once all callers are on `renderShell`; keep `highlightHL7WithDataTooltip` and the LOINC autocomplete IIFE (still used)
-- [ ] Sanity-check: every sidebar link returns 200; every redirected old URL (`/mllp-client`, `/mapping/tasks`, `/mapping/table`) 302s correctly
+- [ ] Sanity-check: every sidebar link returns 200 (old URLs `/mllp-client`, `/mapping/tasks`, `/mapping/table` were renamed in Task 3, not redirected — no 302s to verify)
 - [ ] Close `ai/tickets/2026-04-22-demo-ready-ui-tier1.md`: mark tasks 3–7 as "superseded by `2026-04-23-ui-design-system-refactor.md`", leave tasks 1–2 as-is (already done)
 - [ ] End-to-end manual demo walkthrough: `/` Dashboard → Run demo → ticker shows 4 → click warn row → Inbound detail walks Structured/Raw/FHIR/ACK tabs → Map code → Unmapped pre-selected → accept top suggestion → message reprocesses → Terminology Map shows the new entry → sidebar links all still work including Accounts/Outgoing
 - [ ] Final `bun test:all`

From 8dd1f3c827a98eb6bd1b1ea8879740c45231225c Mon Sep 17 00:00:00 2001
From: Sergey Zaborovsky 
Date: Thu, 23 Apr 2026 10:27:26 +0600
Subject: [PATCH 03/26] clarify details in
 2026-04-23-ui-design-system-refactor.md

---
 .../2026-04-23-ui-design-system-refactor.md   | 167 ++++++++++++------
 1 file changed, 112 insertions(+), 55 deletions(-)

diff --git a/ai/tickets/2026-04-23-ui-design-system-refactor.md b/ai/tickets/2026-04-23-ui-design-system-refactor.md
index 7b1449b7..2b81b0a6 100644
--- a/ai/tickets/2026-04-23-ui-design-system-refactor.md
+++ b/ai/tickets/2026-04-23-ui-design-system-refactor.md
@@ -2,14 +2,19 @@
 
 ## 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. 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).
+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.
-- **ACK History 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 ACK tab is opened. No new resource, no processor changes. Docs: https://www.health-samurai.io/docs/aidbox/api/rest-api/history
-- **Vendor htmx + Alpine outside `src/`.** Static assets live at project root under `public/vendor/`; served by a new static-file route.
-- **Chrome DevTools MCP** is set up as part of this refactor so agents can visually verify pages against the design.
+- **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 @@ -21,10 +26,10 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound ## Task 1: Vendor htmx + Alpine + static-file route -- [ ] Create `public/vendor/`; download pinned htmx 1.9.x and Alpine 3.14.x minified builds (vendored, not CDN) -- [ ] Register `GET /static/*` in `src/index.ts` serving from `public/` via `Bun.file()`; set Content-Type from extension; reject any path containing `..` +- [ ] Create `public/vendor/` (track the directory in git; add `public/` to repo root); download pinned htmx 1.9.x and Alpine 3.14.x minified builds (vendored, not CDN) +- [ ] 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. - [ ] 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 -- [ ] Add unit test in `test/unit/ui/static-route.test.ts`: real-file hit returns 200 + correct Content-Type; missing file returns 404; `../` traversal returns 400/404 +- [ ] 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 - [ ] Run validation — must pass - [ ] Stop for user review before next task @@ -32,20 +37,37 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound - [ ] 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` - [ ] 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 `` -- [ ] Unit test `test/unit/ui/design-system.test.ts`: CSS string contains `--paper`, `--accent #C6532A`, and the `.card` class; `renderIcon('home')` returns expected SVG markup +- [ ] 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 - [ ] Run validation — must pass - [ ] Stop for user review before next task -## Task 3: App shell + unified sidebar (no page bodies changed) +## Task 3a: App shell scaffold + route renames + migrate Accounts -- [ ] 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.min.js`, `/static/vendor/alpine.min.js`, existing health-check IIFE from `shared-layout.ts`) + body with sidebar, main column, and `ICON_SPRITE_SVG` at the bottom -- [ ] 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) -- [ ] Extend `getNavData()` in `src/ui/shared.ts`: add `incomingTotal` (FHIR `IncomingHL7v2Message?_count=0`) alongside existing `pendingTaskCount` +- [ ] 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.min.js`, `/static/vendor/alpine.min.js`, 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. +- [ ] 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). +- [ ] **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. +- [ ] 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. +- [ ] Extend `getNavData()` in `src/ui/shared.ts`: keep the existing `pendingMappingTasksCount`, add `incomingTotal` (FHIR `IncomingHL7v2Message?_count=0&_total=accurate`) - [ ] 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`) -- [ ] Migrate every existing page handler in `src/ui/pages/*.ts` (accounts, messages for both halves, mapping-tasks, code-mappings, mllp-client) to call `renderShell` instead of `renderLayout`; delete `renderLayout` and the legacy top-nav helpers from `src/ui/shared-layout.ts` in the same commit (no gradual migration — project isn't in production) -- [ ] Rename routes in `src/index.ts` to their final names, pointing at the existing handlers (bodies rebuilt in Tasks 5, 9, 10): `/mllp-client` → `/simulate-sender`, `/mapping/tasks` → `/unmapped-codes`, `/mapping/table` → `/terminology` -- [ ] Update any UI unit test that asserts nav markup (`test/unit/ui/*`) for the new sidebar structure -- [ ] Run validation — must pass; manually confirm every existing page renders with the new sidebar and old body intact +- [ ] Legacy-body wrapper helper: `renderLegacyBody(content)` returns `
${content}
` so Accounts/Outgoing Tailwind markup frames against warm-paper +- [ ] 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. +- [ ] Update any nav-markup unit tests for the Accounts page +- [ ] 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) +- [ ] Stop for user review before next task + +## Task 3b: Migrate remaining page bodies into the shell + +- [ ] 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–11). Use `renderLegacyBody` for Outgoing Messages; the others will get new warm-paper bodies soon so they can go directly in the main column. +- [ ] Update the remaining UI unit tests that assert nav markup (`test/unit/ui/*`) +- [ ] 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 +- [ ] Run validation — must pass; manually confirm every page renders with the new sidebar +- [ ] Stop for user review before next task + +## Task 3c: Delete legacy layout + +- [ ] 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). +- [ ] Remove any remaining imports of the deleted helpers +- [ ] Run validation — must pass (typecheck catches any missed imports) - [ ] Stop for user review before next task ## Task 4: UI architecture docs + Chrome DevTools MCP @@ -53,29 +75,46 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound - [ ] 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 - [ ] 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 - [ ] Create `docs/developer-guide/how-to/add-ui-page.md` — recipe: page handler, route registration, sidebar entry, partial pattern, tests -- [ ] Add Chrome DevTools MCP server to `.claude/settings.json` (or `.claude/settings.local.json`) per its install docs; MCP approval is user-initiated — surface the exact approval steps in `docs/developer-guide/ui-architecture.md` +- [ ] 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` +- [ ] **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. - [ ] Add one-line pointer in `CLAUDE.md` under "Code Style" → "UI conventions: see `docs/developer-guide/ui-architecture.md`" -- [ ] Run validation — must pass (typecheck is the only mechanical check for docs; no broken imports) +- [ ] Run validation — typecheck must pass; MCP screenshot must succeed - [ ] Stop for user review before next task -## Task 5: Simulate Sender page +## 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. -- [ ] Create `src/ui/pages/simulate-sender.ts` with `handleSimulateSenderPage` — composer layout per `DESIGN_OVERVIEW.md § Simulate Sender` +- [ ] **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) +- [ ] 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) +- [ ] Update `storeMessage()` in `src/mllp/mllp-server.ts` to extract MSH-10 from the incoming HL7v2 and set `messageControlId` on the resource +- [ ] 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 +- [ ] 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) - [ ] 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 6 can import the templates for the scripted demo -- [ ] Alpine: editor component (`x-data` with `raw`, `typeId`, `sender`, computed `parsed`/`hasUnknown`), overlay `
` + transparent textarea (accent segment names, warn highlight on unmapped tokens), SendCard state machine (`idle → sending → sent | held`) with elapsed tick
-- [ ] Replace the handler wired to `/simulate-sender` (renamed in Task 3) with `handleSimulateSenderPage`; register `POST /simulate-sender/send` reusing `sendMLLPMessage()` from `src/ui/pages/mllp-client.ts` and returning JSON `{status: "sent"|"held", ack: string, messageId: string}` (ACK `AA` → sent, `AE` → held)
-- [ ] Unit tests: happy-path send, held-for-mapping send (unknown code in body), MLLP-unreachable error path
-- [ ] Run validation — must pass; manual smoke in browser (pick ORU unknown template, send, see "Held for mapping" banner)
+- [ ] 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).
+- [ ] **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 12 cleanup.)
+- [ ] Replace the handler wired to `/simulate-sender` with `handleSimulateSenderPage`; register `POST /simulate-sender/send` that:
+  1. **Rewrite MSH-10 to a fresh unique id** in the outbound raw HL7v2 (`SIM-${Date.now()}-${crypto.randomUUID().slice(0,8)}` or equivalent). This is the cornerstone that lets the user resend the same demo template back-to-back and still poll for the specific send. Helper: `rewriteMessageControlId(raw, newId)` with a unit test.
+  2. Sends the rewritten raw HL7 via `sendMLLPMessage`; capture ACK
+  3. **Polls `IncomingHL7v2Message?message-control-id={newId}&_elements=status&_count=1` every 500ms for up to 3s**, stopping when status is `processed`, `warning`, `code_mapping_error`, or any `*_error`. The first poll may return 0 entries if the listener hasn't written yet — treat 0-entry response as "keep polling," not as error.
+  4. Returns JSON `{status: "sent"|"held"|"error", ack: string, messageControlId: string, messageStatus?: string}`. `held` when post-send status is `code_mapping_error`. `error` when status is any `*_error` or when MLLP send threw. `sent` otherwise (including timeout — optimistic: message was received, processing just hasn't caught up).
+- [ ] Unit tests: `rewriteMessageControlId` replaces only MSH-10 (segment-delimiter / field-delimiter safe); happy-path send (mocked poll → `processed`); held-for-mapping send (mocked poll → `code_mapping_error`); MLLP-unreachable error path; poll-timeout falls back to `sent`; **duplicate-send test** — call the endpoint twice with the same template body, assert two distinct `messageControlId`s come back and both look up distinct rows
+- [ ] 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
 - [ ] Stop for user review before next task
 
 ## Task 6: Dashboard page + scripted demo runner
 
 - [ ] Create `src/api/demo-scenario.ts` exporting `runDemoScenario()` that fires four MLLP messages (ADT^A01, ORU^R01 known, VXU^V04, ORU^R01 unknown) with 2s spacing, fire-and-forget (`.catch(console.error)`); import the templates from `src/ui/pages/simulate-sender.ts`'s exported `MESSAGE_TYPES` (shipped in Task 5)
-- [ ] Register routes in `src/index.ts`: `POST /demo/run-scenario` (guarded by `DEMO_MODE !== "off"`, returns 202), `GET /dashboard/partials/stats`, `GET /dashboard/partials/ticker?limit=15`
-- [ ] Create `src/ui/pages/dashboard.ts` with `handleDashboardPage`: hero + demo-conductor card + stats strip + live ticker per `DESIGN_OVERVIEW.md § Dashboard`; htmx `hx-trigger="every 10s"` on stats, `every 5s` on ticker; Alpine `x-data` for ticker-pause toggle
-- [ ] Stats partial computes: received-today, need-mapping (`status=code_mapping_error`), errors (multi-status OR), avg-latency (from `meta.lastUpdated - date` over last 100 processed messages in-memory), worker health (new `getWorkerHealth()` in `src/workers.ts` exposing the current handle's running state)
+- [ ] Register routes in `src/index.ts`: `POST /demo/run-scenario` (guarded by `DEMO_MODE !== "off"`, returns 202), `GET /dashboard/partials/stats`, `GET /dashboard/partials/ticker?limit=15`. **`DEMO_MODE` semantics**: default-on; endpoint is enabled when the env var is unset, empty, or any non-`"off"` string; only `DEMO_MODE=off` disables. Document alongside `DISABLE_POLLING` / `POLL_INTERVAL_MS` in `CLAUDE.md`'s env-flags section.
+- [ ] Create `src/ui/pages/dashboard.ts` with `handleDashboardPage`: hero + demo-conductor card + stats strip + live ticker per `DESIGN_OVERVIEW.md § Dashboard`; htmx `hx-trigger="every 10s"` on stats, `hx-trigger="every 5s"` on ticker. **No pause toggle in v1** — ticker always auto-refreshes; the design's pause button is dropped (the reliable pattern — always-poll + Alpine cancels via `x-on:htmx:before-request` — is viable, but with a single-user demo the feature earns little; revisit if the refresh becomes genuinely disruptive). Moved to non-goals.
+- [ ] Stats partial queries (all per-request, no caching):
+  - received-today: `IncomingHL7v2Message?_lastUpdated=gt{today-ISO}&_count=0&_total=accurate`
+  - need-mapping: `IncomingHL7v2Message?status=code_mapping_error&_count=0&_total=accurate`
+  - errors: multi-status OR on the 3 hard-error statuses, `_count=0&_total=accurate`
+  - avg-latency: fetch last 100 processed messages (`_sort=-_lastUpdated&_count=100&status=processed`), compute mean of `meta.lastUpdated - date` in-request
+  - worker health: `getWorkerHealth()` in `src/workers.ts` returns `{ oruProcessor, barBuilder, barSender }` each `"up" | "down" | "disabled"`. When `DISABLE_POLLING=1` or handle is null, returns all `"disabled"` (don't crash)
 - [ ] Move `GET /` in `src/index.ts` from `handleAccountsPage` to `handleDashboardPage`; `/accounts` stays reachable
-- [ ] Unit tests for `demo-scenario.ts` (fires 4 sends in order) and both partials (happy path + empty state)
+- [ ] Unit tests for `demo-scenario.ts` (fires 4 sends in order) and both partials (happy path + empty state + worker-disabled state)
 - [ ] Run validation — must pass; manual smoke: click "Run demo now", confirm 4 rows appear in the ticker within ~10s
 - [ ] Stop for user review before next task
 
@@ -92,55 +131,68 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound
 
 ## Task 8: Inbound Messages — detail pane + 4 tabs (with Aidbox history)
 
-- [ ] **OPEN: runtime-verify** Aidbox `_history` is enabled on the dev instance before starting this task. Run `curl -u root:Vbro4upIT1 "http://localhost:8080/fhir/IncomingHL7v2Message/{any-id}/_history?_count=5"` and confirm response is a `Bundle` with `entry[]` each carrying `meta.versionId` and distinct `meta.lastUpdated`. If not, investigate the `BOX_*` toggle via Aidbox support docs before proceeding.
+- [ ] **OPEN: runtime-verify** Aidbox `_history` is enabled AND versioning is not disabled on the `IncomingHL7v2Message` attribute definition. Run `curl -u root:$SECRET "http://localhost:8080/fhir/IncomingHL7v2Message/{any-id}/_history?_count=5"` and confirm response is a `Bundle` with `entry[]` each carrying `meta.versionId` and distinct `meta.lastUpdated`. If only one version comes back for a known-multi-update message, check Aidbox's `Attribute` definition for the resource and ensure `versioning` is not `disabled-on-resource`.
 - [ ] Register `GET /incoming-messages/:id/partials/detail` (shell + default `structured` tab) and `GET /incoming-messages/:id/partials/detail/:tab` (tab-specific fragment)
-- [ ] Implement 4 tab handlers: `structured` (re-parse stored `message` via `@atomic-ehr/hl7v2`, render segment mini-cards; warn-border when segment contains the problem code), `raw` (reuse `highlightHL7WithDataTooltip` from `shared-layout.ts`), `fhir` (pretty-print `entries` array; warn highlight on unresolved codings with inline `// ⚠ no LOINC mapping` comment), `acks` (fetch `/fhir/IncomingHL7v2Message/:id/_history?_count=50`, render timeline rows with `meta.lastUpdated` + status transition + error if present; infer step chip heuristically from status delta)
-- [ ] Detail header actions: "Replay" posts to existing `POST /mark-for-retry/:id`; "Map code" (visible only when `unmappedCodes?.length`) links to `/unmapped-codes?code={localCode}&sender={sender}`
-- [ ] Tab switching: `hx-get` on tab button with `hx-target="#detail-body"`; ACK tab is the only one that triggers an extra Aidbox call (lazy per user intent, per architectural decision)
-- [ ] Unit tests for each of the 4 tab handlers (happy path + an error state per tab); integration test in `test/integration/ui/` hitting `_history` against the test Aidbox for a message that's transitioned at least twice
+- [ ] Implement 4 tab handlers:
+  - `structured` — re-parse stored `message` via `@atomic-ehr/hl7v2`, render segment mini-cards; warn-border when segment contains the problem code
+  - `raw` — reuse `highlightHL7WithDataTooltip` from `shared-layout.ts`
+  - `fhir` — pretty-print `entries` array; warn highlight on unresolved codings with inline `// ⚠ no LOINC mapping` comment. Empty-state card when `entries` is absent (parsing_error / conversion_error messages)
+  - `timeline` — fetch `/fhir/IncomingHL7v2Message/:id/_history?_count=50`, render timeline rows `{meta.lastUpdated, statusChip, error?}`. **Filter out consecutive versions where `status` and `error` are both unchanged** (those are entries-only PUTs from the processor and would clutter the timeline). Infer step chip heuristically from status delta (received→processed, *→code_mapping_error, etc.)
+- [ ] User-facing tab labels: `Structured`, `Raw HL7`, `FHIR resources`, `Timeline` (not "ACK history")
+- [ ] Detail header actions: "Replay" `hx-post`s to existing `POST /mark-for-retry/:id` with `hx-target="#detail" hx-swap="outerHTML"`; "Map code" (visible only when `unmappedCodes?.length`) links to `/unmapped-codes?code={encodeURIComponent(localCode)}&sender={encodeURIComponent(sender)}`
+- [ ] **Make `/mark-for-retry/:id` htmx-aware**: when request header `HX-Request: true`, respond with the refreshed detail-pane HTML (reuse the detail-partial handler) and set `HX-Trigger: message-replayed` so the list pane can listen and refresh itself via `hx-trigger="message-replayed from:body"`. Non-htmx callers (existing Inbound "Retry" form posts) keep the `302 → /incoming-messages` behavior — branch on the header, don't break the old path.
+- [ ] Tab switching: `hx-get` on tab button with `hx-target="#detail-body"`; Timeline tab is the only one that triggers an extra Aidbox call (lazy per user intent, per architectural decision)
+- [ ] Unit tests for each of the 4 tab handlers (happy path + an error state per tab, including the entries-absent FHIR tab state); integration test in `test/integration/ui/` hitting `_history` against the test Aidbox for a message that's transitioned at least twice
 - [ ] Run validation — must pass; manual smoke walks all 4 tabs on a real message
 - [ ] Stop for user review before next task
 
-## Task 9: Unmapped Codes rebuild + suggestion scoring
+## Task 9: Unmapped Codes rebuild + substring suggestion scoring
 
-- [ ] Create `src/api/terminology-suggest.ts` exporting `suggestCodes(display, field)`: wraps existing `searchLoincCodes()` from `src/code-mapping/terminology-api.ts`; scores each result (exact-substring in display → 100; Jaro-Winkler similarity → 40–95; short-display bonus); returns top 3 `{code, display, score, system}`
+- [ ] Create `src/api/terminology-suggest.ts` exporting `suggestCodes(display, field)`: wraps existing `searchLoincCodes()` from `src/code-mapping/terminology-api.ts`. **Substring-only scoring for v1** — no Jaro-Winkler: exact-substring match in display → 100; case-insensitive token hit → 70; otherwise 40. Return top 3 `{code, display, score, system}`. If match quality proves weak in practice, a JW / fuzzy pass is a follow-up.
 - [ ] Register `GET /api/terminology/suggest?display=&field=` route
 - [ ] Create `src/ui/pages/unmapped.ts` with `handleUnmappedCodesPage` — queue + editor split per `DESIGN_OVERVIEW.md § Unmapped Codes`; supports `?code=&sender=` pre-selection (matches the "Map code" link from Inbound)
-- [ ] Replace the handler wired to `/unmapped-codes` (renamed in Task 3) with `handleUnmappedCodesPage`; register `GET /unmapped-codes/partials/queue` and `GET /unmapped-codes/:code/partials/editor?sender=`; editor partial calls `suggestCodes()` for the pre-selected code's display
+- [ ] Replace the handler wired to `/unmapped-codes` with `handleUnmappedCodesPage`; register `GET /unmapped-codes/partials/queue` and `GET /unmapped-codes/:code/partials/editor?sender=`; editor partial calls `suggestCodes()` for the pre-selected code's display
+- [ ] **URL-encoding discipline**: `localCode` values can contain `^`, `/`, `:` (e.g. `UNKNOWN_TEST^LOCAL`). Every outbound link, `hx-get`, and `hx-post` that interpolates `localCode` must wrap it in `encodeURIComponent(...)`; every server handler must `decodeURIComponent(req.params.code)` (matches the existing pattern in `src/api/concept-map-entries.ts:97`). Add one unit test that round-trips a `^`-containing localCode through the `/unmapped-codes/:code/partials/editor` partial.
 - [ ] Queue partial: aggregates open `Task?status=requested` (existing query), regroups by `localCode + sender + field`, counts messages via `Task.input`; returns queue list HTML
-- [ ] Wire actions: Save → existing `POST /api/mapping/tasks/:id/resolve`; Skip → new `POST /unmapped-codes/:code/skip?sender=` that wraps `POST /defer/:id` per matching task; "Skip all" and "Suggest with AI" buttons rendered `disabled` with "coming soon" chip (explicit v1 non-goals)
-- [ ] Unit tests: scoring helper (known-high + known-low cases), queue partial (groups correctly), editor partial (pre-selection loads suggestions)
+- [ ] Actions:
+  - Save → existing `POST /api/mapping/tasks/:id/resolve`
+  - **Skip** → **client-only Alpine action** that advances the queue selection to the next entry. No server call. Skipped codes reappear on reload.
+  - **Skip all** → **removed from v1** (no Alpine button, no endpoint). Hero's right-side action slot keeps only the "Suggest with AI" ghost button rendered `disabled` with "coming soon" chip.
+- [ ] Unit tests: scoring helper (exact substring, token hit, miss cases), queue partial (groups correctly), editor partial (pre-selection loads suggestions), Alpine skip test (either an Alpine unit test if tooling exists, or a DOM assertion that the Skip button has no `hx-*` attributes and only `x-on:click` wiring)
 - [ ] Run validation — must pass
 - [ ] Stop for user review before next task
 
 ## Task 10: Terminology Map — table + filter popovers + detail
 
 - [ ] Create `src/ui/pages/terminology.ts` with `handleTerminologyPage` — KPI strip + two-pane (table + detail); supports `?q=&fhir=&sender=` URL params (multi-valued `fhir` and `sender`)
-- [ ] Replace the handler wired to `/terminology` (renamed in Task 3) with `handleTerminologyPage`; register partials: `GET /terminology/partials/table?q=&fhir=&sender=` (server-filtered rows), `GET /terminology/partials/facets/fhir`, `GET /terminology/partials/facets/sender`, `GET /terminology/partials/detail/:conceptMapId/:code`
+- [ ] Replace the handler wired to `/terminology` with `handleTerminologyPage`; register partials: `GET /terminology/partials/table?q=&fhir=&sender=` (server-filtered rows), `GET /terminology/partials/facets/fhir`, `GET /terminology/partials/facets/sender`, `GET /terminology/partials/detail/:conceptMapId/:code`
+- [ ] **URL-encoding discipline** (same as Task 9): every `hx-get`/`hx-post`/link interpolating `:code` must `encodeURIComponent(localCode)` and server handlers must `decodeURIComponent(req.params.code)`. Add a unit test for `/terminology/partials/detail/:conceptMapId/:code` with a `^`-containing localCode.
 - [ ] Facet partials: in-memory scan of all ConceptMap entries (reuse existing `listConceptMaps` + group), return `{name, count}[]` rendered as searchable multi-select list; Alpine popover (`x-on:click.outside`, `x-on:keyup.escape`) wraps them
-- [ ] Detail partial: FHIR target (split typography), local/std mapping, source panel, minimal lineage (creation time from `ConceptMap/_history?_count=1` lazy), Deprecate/Edit footer
-- [ ] KPI strip values: total mappings (sum across all maps), coverage % (processed / processed + code_mapping_error over last 30d), messages/window (`_count=0` for 30d), needs-review (literal `0` for v1 until deprecation-tracking ticket lands)
-- [ ] **OPEN:** usage count per entry (design shows `usage:4820`) — not tracked today. Render `—` for v1 with a note in `docs/developer-guide/ui-design-tokens.md`; separate ticket for denormalized counters
+- [ ] Detail partial: FHIR target (split typography), local/std mapping, source panel, minimal lineage (**creation time from the current resource's `meta.createdAt`**, NOT `_history?_count=1` which would return the most recent version). Footer actions: **Edit** and **Delete** (no Deprecate).
+- [ ] **No deprecated-state rendering.** No strikethrough. All rows are treated as active.
+- [ ] KPI strip values: total mappings (sum across all maps); coverage % (processed / (processed + code_mapping_error) **all-time**, two `_count=0&_total=accurate` queries, no time filter); messages/window (replace with total processed messages count, `IncomingHL7v2Message?status=processed&_count=0&_total=accurate`); needs-review (literal `0` for v1).
+- [ ] **No usage column in v1.** Design shows `usage:4820` per entry but we don't track this today and v1 only produces ~4 demo messages (would render 0/1 everywhere and undersell the feature). Render no usage field at all — don't leave a `—` placeholder either. Moved to non-goals with the implementation approach documented.
 - [ ] Unit tests for the 4 new partials; integration test hitting facet counts against the test Aidbox
 - [ ] Run validation — must pass
 - [ ] Stop for user review before next task
 
-## Task 11: Terminology Map — Add/Edit modal
+## Task 11: Terminology Map — Add/Edit modal + Delete
 
 - [ ] Register `GET /terminology/partials/modal?mode=add` and `GET /terminology/partials/modal?mode=edit&conceptMapId=&code=` — returns modal body HTML (FHIR target select add-only, local system + code two-column, local display, search-to-map input with icon); edit mode locks target field
-- [ ] Alpine wiring: backdrop + ESC + ✕ close; submit disabled until `localCode` + `targetCode` filled
-- [ ] Submit routes to existing `POST /api/concept-maps/:id/entries` (add) or `POST /api/concept-maps/:id/entries/:code` (edit); on success: close modal, htmx-swap the table partial to refresh the row
-- [ ] Deprecate button in detail footer: `POST /api/concept-maps/:id/entries/:code/delete` (existing); confirm via native `confirm()` for v1 — follow-up to replace with inline Alpine confirm popover
-- [ ] Unit tests: modal renders in both modes, submit path succeeds, disabled-when-empty gate works
-- [ ] Run validation — must pass; manual smoke: Add a mapping via the modal, confirm it appears in the table and is retrievable via `GET /fhir/ConceptMap`
+- [ ] Alpine wiring: backdrop + ESC + ✕ close; **submit disabled until all required fields filled**: in `add` mode — `fhirTarget`, `localSystem`, `localCode`, `targetCode`; in `edit` mode — `localCode` + `targetCode` (target is locked). `localDisplay` is optional.
+- [ ] Submit routes to existing `POST /api/concept-maps/:id/entries` (add) or `POST /api/concept-maps/:id/entries/:code` (edit); on success: refreshed table + modal closes (see next bullet for how)
+- [ ] **Make `handleAddEntry` / `handleUpdateEntry` / `handleDeleteEntry` htmx-aware** (`src/api/concept-map-entries.ts`): when `req.headers.get('HX-Request') === 'true'`, respond with the refreshed table-partial HTML (call the same renderer used by `GET /terminology/partials/table`, applying the current `q/fhir/sender` filters from form data or query string) and set response header `HX-Trigger: concept-map-entry-saved` (or `concept-map-entry-deleted`). Non-htmx callers (tests, direct form posts) keep the existing `302 → /mapping/table?conceptMapId=...` behavior — branch on the header, do not break the legacy path. Client: modal wrapper uses `@concept-map-entry-saved.window="open=false"` (Alpine) to close; table-partial swap happens via `hx-target="#terminology-table" hx-swap="outerHTML"` on the form. This is the idiomatic htmx pattern (`HX-Trigger` for cross-component side-effects, fragment response for the visible update).
+- [ ] **Delete button** in detail footer: `hx-post` to `POST /api/concept-maps/:id/entries/:code/delete` (existing) with `hx-target="#terminology-table" hx-swap="outerHTML"`; uses the same htmx-aware branch. `hx-confirm="Delete this mapping?"` for v1 — follow-up to replace with an inline Alpine confirm popover. **Button is labeled "Delete", not "Deprecate"** — see Overview.
+- [ ] Unit tests: modal renders in both modes; submit path succeeds for both legacy (302) and htmx (partial + `HX-Trigger`) branches; disabled-when-empty gate works for all required fields; delete round-trips through the htmx branch
+- [ ] Run validation — must pass; manual smoke: Add a mapping via the modal, confirm it appears in the table and is retrievable via `GET /fhir/ConceptMap`; Delete a mapping, confirm the row disappears
 - [ ] Stop for user review before next task
 
 ## Task 12: Cleanup
 
-- [ ] Delete dead code: old `renderIncomingMessagesPage` body in `src/ui/pages/messages.ts` (keep Outgoing), legacy HTML rendering in `src/ui/pages/mapping-tasks.ts` and `src/ui/pages/code-mappings.ts`, page-render functions in `src/ui/pages/mllp-client.ts` (keep `sendMLLPMessage` + `sendMLLPTest` primitives)
-- [ ] Sanity-check: every sidebar link returns 200 (old URLs `/mllp-client`, `/mapping/tasks`, `/mapping/table` were renamed in Task 3, not redirected — no 302s to verify)
+- [ ] Delete dead code: old `renderIncomingMessagesPage` body in `src/ui/pages/messages.ts` (keep Outgoing), legacy HTML rendering in `src/ui/pages/mapping-tasks.ts` and `src/ui/pages/code-mappings.ts`, page-render functions in `src/ui/pages/mllp-client.ts`. If `sendMLLPMessage` is still in `mllp-client.ts` at this point, **move it to `src/mllp/client.ts`** and delete `mllp-client.ts` entirely (keep `sendMLLPTest` if any test still uses it; otherwise delete it too).
+- [ ] Re-run the `rg` grep-audit from Task 3a to confirm no stale references remain to `/mllp-client`, `/mapping/tasks`, `/mapping/table`, `renderLayout`, or `renderNav`
 - [ ] Close `ai/tickets/2026-04-22-demo-ready-ui-tier1.md`: mark tasks 3–7 as "superseded by `2026-04-23-ui-design-system-refactor.md`", leave tasks 1–2 as-is (already done)
-- [ ] End-to-end manual demo walkthrough: `/` Dashboard → Run demo → ticker shows 4 → click warn row → Inbound detail walks Structured/Raw/FHIR/ACK tabs → Map code → Unmapped pre-selected → accept top suggestion → message reprocesses → Terminology Map shows the new entry → sidebar links all still work including Accounts/Outgoing
+- [ ] End-to-end manual demo walkthrough: `/` Dashboard → Run demo → ticker shows 4 → click warn row → Inbound detail walks Structured/Raw/FHIR/Timeline tabs → Map code → Unmapped pre-selected → accept top suggestion → message reprocesses → Terminology Map shows the new entry → sidebar links all still work including Accounts/Outgoing (framed in gray card)
 - [ ] Final `bun test:all`
 - [ ] Stop for user review — confirm demo walkthrough feels right
 
@@ -148,11 +200,16 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound
 
 ## Non-goals for v1 (tracked for follow-up)
 
-- Per-mapping usage counts / last-seen timestamps (show `—` for now)
-- Deprecate-with-review lifecycle for ConceptMap entries
+- **Per-mapping usage counts / last-seen timestamps.** Design shows `usage:4820` per entry but we don't track this today. Recommended implementation when picked up: add `IncomingHL7v2Message.appliedMappings: [{conceptMapId, localCode, localSystem, targetCode}]` + SearchParameter `applied-mapping-code`; each ConceptMap-using resolver (`observation-code-resolver`, `pv1-encounter` patient-class, `orc-servicerequest`) returns what it applied; processor-service stamps the final message; Terminology table queries `IncomingHL7v2Message?applied-mapping-code={code}&_count=0&_total=accurate` per row (batch with OR for pagination). Rejected alternatives: (a) counter on ConceptMap entry — write amplification and contention; (b) compute from `entries[].coding` — collides across maps and double-counts inline LOINC resolutions.
+- **Soft-deprecate lifecycle for ConceptMap entries** (no `status` field, no strikethrough, no "needs review" counter — v1 treats everything as active; Delete is the only removal path)
+- **Syntax-highlighted composer in Simulate Sender** (CodeMirror 6 or overlay-pre textarea — v1 is plain textarea)
+- **Compose-time "contains unmapped code" chip in Simulate Sender** — the design prototype's regex was tied to demo-template literals (`LOCAL`, `UNKNOWN_TEST`) and would mislead on real bodies. The post-send status poll surfaces the honest answer; follow-up could add a real debounced server-side code-extract + ConceptMap check if the product needs pre-send warnings.
+- **Jaro-Winkler / fuzzy similarity scoring** for suggestions (v1 is substring-only)
+- **"Skip all" in Unmapped Codes** (v1 Skip is client-only next-item; no bulk action)
+- **Ticker pause toggle on Dashboard** — single-user demo context makes the auto-refresh unobtrusive; the always-poll + Alpine `x-on:htmx:before-request` guard pattern is viable if we want it back later
 - LLM-backed "Suggest with AI" batch action
 - Sender-health page (deferred per `DESIGN_OVERVIEW.md` late-stage suggestions)
 - Per-type `SearchParameter` on `IncomingHL7v2Message` (in-memory grouping is enough for demo scale)
 - Custom `ProcessingLog` resource (Aidbox `_history` substitutes)
 - Self-hosted fonts (Google Fonts CDN for v1)
-- Re-skinning Accounts/Outgoing Messages to the warm-paper palette
+- Re-skinning Accounts/Outgoing Messages to the warm-paper palette (they sit in a gray-card frame inside the shell for now)

From 0751d2cf2c342ac95f5244f3c1dc9ea3aafef38f Mon Sep 17 00:00:00 2001
From: Sergey Zaborovsky 
Date: Thu, 23 Apr 2026 10:39:49 +0600
Subject: [PATCH 04/26] impl task 1

---
 .../2026-04-23-ui-design-system-refactor.md   |  12 +-
 public/vendor/alpine-3.15.11.min.js           |   5 +
 public/vendor/htmx-2.0.10.min.js              |   1 +
 src/index.ts                                  |   6 +
 src/ui/static.ts                              |  43 ++++++
 test/unit/ui/static-route.test.ts             | 126 ++++++++++++++++++
 6 files changed, 187 insertions(+), 6 deletions(-)
 create mode 100644 public/vendor/alpine-3.15.11.min.js
 create mode 100644 public/vendor/htmx-2.0.10.min.js
 create mode 100644 src/ui/static.ts
 create mode 100644 test/unit/ui/static-route.test.ts

diff --git a/ai/tickets/2026-04-23-ui-design-system-refactor.md b/ai/tickets/2026-04-23-ui-design-system-refactor.md
index 2b81b0a6..99f24669 100644
--- a/ai/tickets/2026-04-23-ui-design-system-refactor.md
+++ b/ai/tickets/2026-04-23-ui-design-system-refactor.md
@@ -26,11 +26,11 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound
 
 ## Task 1: Vendor htmx + Alpine + static-file route
 
-- [ ] Create `public/vendor/` (track the directory in git; add `public/` to repo root); download pinned htmx 1.9.x and Alpine 3.14.x minified builds (vendored, not CDN)
-- [ ] 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.
-- [ ] 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
-- [ ] 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
-- [ ] Run validation — must pass
+- [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
@@ -43,7 +43,7 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound
 
 ## Task 3a: App shell scaffold + route renames + migrate Accounts
 
-- [ ] 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.min.js`, `/static/vendor/alpine.min.js`, 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.
+- [ ] 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.
 - [ ] 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).
 - [ ] **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.
 - [ ] 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.
diff --git a/public/vendor/alpine-3.15.11.min.js b/public/vendor/alpine-3.15.11.min.js
new file mode 100644
index 00000000..ab30be7a
--- /dev/null
+++ b/public/vendor/alpine-3.15.11.min.js
@@ -0,0 +1,5 @@
+(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function qe(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function Ke(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(In)}}function In(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{qe()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(Ke);t._x_cleanups?.length;)t._x_cleanups.pop()()}var le=new MutationObserver(pe),ue=!1;function ut(){le.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ue=!0}function fe(){kn(),le.disconnect(),ue=!1}var lt=[];function kn(){let t=le.takeRecords();lt.push(()=>t.length>0&&pe(t));let e=lt.length;queueMicrotask(()=>{if(lt.length===e)for(;lt.length>0;)lt.shift()()})}function m(t){if(!ue)return t();fe();let e=t();return ut(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function lr(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message}
+
+${r?'Expression: "'+r+`"
+
+`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return ur(...t)}var ur=()=>{};function fr(t){ur=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>nt(u,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):l.then(u=>{ft(i,u,c,s,r)}).catch(u=>nt(u,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&it?l.apply(o,s):l}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(qn(n,r)).sort(Kn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function qn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function Kn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function E(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ``;
 
-  return renderLayout("Accounts", renderNav("accounts", navData), content);
+  return renderShell({
+    active: "accounts",
+    title: "Accounts",
+    content: renderLegacyBody(content),
+    navData,
+  });
 }
diff --git a/src/ui/pages/code-mappings.ts b/src/ui/pages/code-mappings.ts
index 57488456..58cffd52 100644
--- a/src/ui/pages/code-mappings.ts
+++ b/src/ui/pages/code-mappings.ts
@@ -121,7 +121,7 @@ function buildFilterUrl(typeFilter: MappingTypeFilter, conceptMapId?: string | n
     params.set("conceptMapId", conceptMapId);
   }
   const paramStr = params.toString();
-  return `/mapping/table${paramStr ? `?${paramStr}` : ""}`;
+  return `/terminology${paramStr ? `?${paramStr}` : ""}`;
 }
 
 /**
@@ -141,7 +141,7 @@ export function renderCodeMappingsPage(
   selectedMappingType: MappingTypeName | null,
 ): string {
   const searchUrlBase = selectedConceptMapId
-    ? `/mapping/table?conceptMapId=${selectedConceptMapId}${typeFilter !== "all" ? `&type=${typeFilter}` : ""}`
+    ? `/terminology?conceptMapId=${selectedConceptMapId}${typeFilter !== "all" ? `&type=${typeFilter}` : ""}`
     : "";
 
   const content = `
@@ -235,7 +235,7 @@ export function renderCodeMappingsPage(
         

Total: ${pagination.total} mappings

${renderPaginationControls({ pagination, - baseUrl: "/mapping/table", + baseUrl: "/terminology", filterParams: { conceptMapId: selectedConceptMapId, search, ...(typeFilter !== "all" ? { type: typeFilter } : {}) }, })}
diff --git a/src/ui/pages/mapping-tasks.ts b/src/ui/pages/mapping-tasks.ts index e7be69f9..612ef773 100644 --- a/src/ui/pages/mapping-tasks.ts +++ b/src/ui/pages/mapping-tasks.ts @@ -161,7 +161,7 @@ function buildFilterUrl(status: "requested" | "completed", typeFilter: MappingTy if (typeFilter !== "all") { params.set("type", typeFilter); } - return `/mapping/tasks?${params.toString()}`; + return `/unmapped-codes?${params.toString()}`; } /** @@ -222,7 +222,7 @@ export function renderMappingTasksPage(

Total: ${pagination.total} tasks

${renderPaginationControls({ pagination, - baseUrl: "/mapping/tasks", + baseUrl: "/unmapped-codes", filterParams: { status: statusFilter, ...(typeFilter !== "all" ? { type: typeFilter } : {}) }, })}
`; diff --git a/src/ui/pages/messages.ts b/src/ui/pages/messages.ts index 11bcc5eb..dfc47e2d 100644 --- a/src/ui/pages/messages.ts +++ b/src/ui/pages/messages.ts @@ -236,7 +236,7 @@ function renderMessageList(items: MessageListItem[]): string { ${code.localDisplay ? `(${escapeHtml(code.localDisplay)})` : ""} ${code.localSystem ? `- ${escapeHtml(code.localSystem)}` : ""} - View Tasks → + View Tasks → `, ) diff --git a/src/ui/pages/mllp-client.ts b/src/ui/pages/mllp-client.ts index 3bf94a53..c03381db 100644 --- a/src/ui/pages/mllp-client.ts +++ b/src/ui/pages/mllp-client.ts @@ -242,7 +242,7 @@ function renderMLLPClientPage(
-
+
diff --git a/src/ui/shared-layout.ts b/src/ui/shared-layout.ts index 656d3596..b100cfe9 100644 --- a/src/ui/shared-layout.ts +++ b/src/ui/shared-layout.ts @@ -1,8 +1,21 @@ /** - * Shared layout components for UI pages + * Shared layout components for UI pages (legacy Tailwind layout). + * + * This file is scheduled for removal once every page body migrates to the + * warm-paper shell in `src/ui/shell.ts`. Keep new work pointed at the shell; + * only edit here to keep legacy pages functional until their migration lands. */ -import { getHighlightStyles, highlightHL7Message } from "@atomic-ehr/hl7v2/src/hl7v2/highlight"; +import { highlightHL7Message } from "@atomic-ehr/hl7v2/src/hl7v2/highlight"; +import { + LEGACY_STYLES, + HEALTH_CHECK_SCRIPT, + HL7_TOOLTIP_SCRIPT, + LOINC_AUTOCOMPLETE_SCRIPT, +} from "./legacy-assets"; +import type { NavData } from "./shared"; + +export type { NavData }; export function highlightHL7WithDataTooltip( message: string | undefined, @@ -19,10 +32,6 @@ export type NavTab = | "mapping-tasks" | "code-mappings"; -export interface NavData { - pendingMappingTasksCount: number; -} - interface NavTabDef { id: NavTab; href: string; @@ -53,16 +62,16 @@ export function renderNav(active: NavTab, navData: NavData): string { // here — nav is just navigation. const tabs: NavTabDef[] = [ { id: "incoming", href: "/incoming-messages", label: "Inbound Messages" }, - { id: "mllp-client", href: "/mllp-client", label: "Simulate Sender" }, + { id: "mllp-client", href: "/simulate-sender", label: "Simulate Sender" }, { id: "mapping-tasks", - href: "/mapping/tasks", + href: "/unmapped-codes", label: "Unmapped Codes", badge: navData.pendingMappingTasksCount, }, { id: "accounts", href: "/accounts", label: "Accounts" }, { id: "outgoing", href: "/outgoing-messages", label: "Outgoing Messages" }, - { id: "code-mappings", href: "/mapping/table", label: "Terminology Map" }, + { id: "code-mappings", href: "/terminology", label: "Terminology Map" }, ]; const tabsHtml = tabs.map((tab) => renderTab(tab, active)).join(""); @@ -79,7 +88,7 @@ export function renderNav(active: NavTab, navData: NavData): string {
${env} - + Aidbox
`; @@ -107,80 +116,7 @@ export function renderLayout( ${title} - + ${nav} @@ -188,236 +124,9 @@ export function renderLayout( ${content}
`; diff --git a/src/ui/shared.ts b/src/ui/shared.ts index 3c0193ff..5255f46e 100644 --- a/src/ui/shared.ts +++ b/src/ui/shared.ts @@ -4,9 +4,13 @@ import type { Task } from "../fhir/hl7-fhir-r4-core/Task"; import { aidboxFetch, type Bundle } from "../aidbox"; -import type { NavData } from "./shared-layout"; import { MAPPING_TYPES } from "../code-mapping/mapping-types"; +export interface NavData { + pendingMappingTasksCount: number; + incomingTotal: number; +} + export function htmlResponse(html: string): Response { return new Response(html, { headers: { "Content-Type": "text/html" } }); } @@ -24,7 +28,17 @@ export async function getPendingTasksCount(): Promise { return bundle.total || 0; } +export async function getIncomingMessagesTotal(): Promise { + const bundle = await aidboxFetch>( + "/fhir/IncomingHL7v2Message?_count=0&_total=accurate", + ); + return bundle.total || 0; +} + export async function getNavData(): Promise { - const pendingMappingTasksCount = await getPendingTasksCount(); - return { pendingMappingTasksCount }; + const [pendingMappingTasksCount, incomingTotal] = await Promise.all([ + getPendingTasksCount(), + getIncomingMessagesTotal(), + ]); + return { pendingMappingTasksCount, incomingTotal }; } diff --git a/src/ui/shell.ts b/src/ui/shell.ts new file mode 100644 index 00000000..8e230d99 --- /dev/null +++ b/src/ui/shell.ts @@ -0,0 +1,212 @@ +/** + * Warm-paper app shell. Wraps page bodies in a sidebar + main column + * that matches `ai/tickets/ui-refactoring/hl7v2-v2/project/HL7v2 Design.html`. + * + * `renderShell` is the replacement for the legacy `renderLayout`; during the + * migration both coexist. `renderLegacyBody` frames a Tailwind body inside a + * gray card so the warm-paper canvas doesn't clash with pages whose markup + * hasn't been rebuilt yet. + * + * The design prototype includes a `.topbar` (crumb + search + avatar); the + * shell intentionally omits it. Pages render their own hero rows inside + * `content` — each page owns its crumb + actions per DESIGN_OVERVIEW. If a + * cross-page search lands, it belongs in the sidebar, not a duplicated topbar. + */ + +import { DESIGN_SYSTEM_CSS } from "./design-system"; +import { ICON_SPRITE_SVG, renderIcon, type IconName } from "./icons"; +import type { NavData } from "./shared"; +import { + LEGACY_STYLES, + HEALTH_CHECK_SCRIPT, + HL7_TOOLTIP_SCRIPT, + LOINC_AUTOCOMPLETE_SCRIPT, +} from "./legacy-assets"; +import { escapeHtml } from "../utils/html"; + +export type NavKey = + | "dashboard" + | "inbound" + | "simulate" + | "unmapped" + | "terminology" + | "accounts" + | "outgoing"; + +export interface ShellOptions { + active: NavKey; + title: string; + content: string; + navData: NavData; +} + +interface NavLink { + key: NavKey; + href: string; + label: string; + icon: IconName; + count?: number; + hot?: boolean; +} + +interface NavGroup { + label: string; + links: NavLink[]; +} + +function buildNavGroups(navData: NavData): NavGroup[] { + return [ + { + label: "Workspace", + links: [ + { key: "dashboard", href: "/", label: "Dashboard", icon: "home" }, + { + key: "inbound", + href: "/incoming-messages", + label: "Inbound Messages", + icon: "inbox", + count: navData.incomingTotal, + }, + { key: "simulate", href: "/simulate-sender", label: "Simulate Sender", icon: "send" }, + ], + }, + { + label: "Terminology", + links: [ + { + key: "unmapped", + href: "/unmapped-codes", + label: "Unmapped Codes", + icon: "alert", + count: navData.pendingMappingTasksCount, + hot: navData.pendingMappingTasksCount > 0, + }, + { key: "terminology", href: "/terminology", label: "Terminology Map", icon: "map" }, + ], + }, + { + label: "Outbound", + links: [ + { key: "accounts", href: "/accounts", label: "Accounts", icon: "users" }, + { key: "outgoing", href: "/outgoing-messages", label: "Outgoing Messages", icon: "out" }, + ], + }, + ]; +} + +function renderNavLink(link: NavLink, active: NavKey): string { + const activeClass = link.key === active ? " active" : ""; + const count = + link.count !== undefined + ? `${link.count}` + : ""; + return `${renderIcon(link.icon)}${link.label}${count}`; +} + +function renderNavGroup(group: NavGroup, active: NavKey, isFirst: boolean): string { + const labelStyle = isFirst ? ' style="padding-top:0"' : ""; + const links = group.links.map((link) => renderNavLink(link, active)).join(""); + return `${links}`; +} + +interface EnvInfo { + label: string; + tone: "ok" | "warn" | "err"; +} + +function getEnvInfo(): EnvInfo { + const raw = (process.env.ENV || "dev").toLowerCase(); + if (raw === "prod" || raw === "production") { + return { label: raw, tone: "err" }; + } + if (raw === "staging" || raw === "test") { + return { label: raw, tone: "warn" }; + } + return { label: raw, tone: "ok" }; +} + +// Two independent signals live in the sidebar footer: +// 1. ENV tone (ok/warn/err) + label — static, from process env at render time. +// 2. Aidbox health (up/down) + "Aidbox" label — updated by HEALTH_CHECK_SCRIPT. +// They must not share elements: the poller would otherwise stomp the env label +// and its color would override the env tone via data-attribute CSS specificity. +function renderEnvPill(): string { + const env = getEnvInfo(); + const host = escapeHtml(process.env.MLLP_HOST || "localhost"); + const port = escapeHtml(process.env.MLLP_PORT || "2575"); + const envLabel = escapeHtml(env.label); + return ` +
+
+
+ + ${envLabel} +
+ mllp://${host}:${port} +
+ + Aidbox +
+
+
`; +} + +function renderSidebar(active: NavKey, navData: NavData): string { + const groups = buildNavGroups(navData); + const nav = groups + .map((group, index) => renderNavGroup(group, active, index === 0)) + .join(""); + + return ` + `; +} + +export function renderShell(opts: ShellOptions): string { + const sidebar = renderSidebar(opts.active, opts.navData); + const googleFonts = "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=JetBrains+Mono:wght@400;500&display=swap"; + + return ` + + + + + ${escapeHtml(opts.title)} + + + + + + + + + + +
+ ${sidebar} +
+
${opts.content}
+
+
+ ${ICON_SPRITE_SVG} + + +`; +} + +export function renderLegacyBody(content: string): string { + return `
${content}
`; +} diff --git a/test/unit/ui/code-mappings.test.ts b/test/unit/ui/code-mappings.test.ts index 3ade61da..1c7f9762 100644 --- a/test/unit/ui/code-mappings.test.ts +++ b/test/unit/ui/code-mappings.test.ts @@ -280,7 +280,7 @@ describe("renderCodeMappingsPage", () => { mock.module("../../../src/aidbox", () => createMockAidbox()); const { renderCodeMappingsPage } = await import("../../../src/ui/pages/code-mappings"); - const navData = { pendingMappingTasksCount: 0 }; + const navData = { pendingMappingTasksCount: 0, incomingTotal: 0 }; const html = renderCodeMappingsPage( navData, [], @@ -303,7 +303,7 @@ describe("renderCodeMappingsPage", () => { mock.module("../../../src/aidbox", () => createMockAidbox()); const { renderCodeMappingsPage } = await import("../../../src/ui/pages/code-mappings"); - const navData = { pendingMappingTasksCount: 0 }; + const navData = { pendingMappingTasksCount: 0, incomingTotal: 0 }; const html = renderCodeMappingsPage( navData, [], @@ -318,14 +318,14 @@ describe("renderCodeMappingsPage", () => { ); // The active filter should have the blue background class - expect(html).toMatch(/href="\/mapping\/table\?type=observation-code-loinc"[^>]*class="[^"]*bg-blue-600[^"]*"/); + expect(html).toMatch(/href="\/terminology\?type=observation-code-loinc"[^>]*class="[^"]*bg-blue-600[^"]*"/); }); test("includes mapping type badge in sender dropdown", async () => { mock.module("../../../src/aidbox", () => createMockAidbox()); const { renderCodeMappingsPage } = await import("../../../src/ui/pages/code-mappings"); - const navData = { pendingMappingTasksCount: 0 }; + const navData = { pendingMappingTasksCount: 0, incomingTotal: 0 }; const conceptMaps = [ { id: "cm-1", displayName: "ACME_LAB|ACME_HOSP", mappingType: "observation-code-loinc" as const, targetSystem: "http://loinc.org" }, { id: "cm-2", displayName: "OTHER_LAB|OTHER_HOSP", mappingType: "obr-status" as const, targetSystem: "http://hl7.org/fhir/diagnostic-report-status" }, diff --git a/test/unit/ui/mapping-tasks-pagination.test.ts b/test/unit/ui/mapping-tasks-pagination.test.ts index 6dde516a..2e11e71d 100644 --- a/test/unit/ui/mapping-tasks-pagination.test.ts +++ b/test/unit/ui/mapping-tasks-pagination.test.ts @@ -205,7 +205,7 @@ describe("Pagination Utilities", () => { test("preserves filter params in pagination links", () => { const html = renderPaginationControls({ pagination: { currentPage: 2, total: 150, totalPages: 3 }, - baseUrl: "/mapping/tasks", + baseUrl: "/unmapped-codes", filterParams: { status: "requested" }, }); diff --git a/test/unit/ui/mapping-tasks-ui.test.ts b/test/unit/ui/mapping-tasks-ui.test.ts index 08d246a0..41716c54 100644 --- a/test/unit/ui/mapping-tasks-ui.test.ts +++ b/test/unit/ui/mapping-tasks-ui.test.ts @@ -124,6 +124,7 @@ function createCompletedLoincTask(): Task { const mockNavData: NavData = { pendingMappingTasksCount: 10, + incomingTotal: 0, }; const mockPagination: PaginationData = { @@ -432,8 +433,8 @@ describe("renderMappingTasksPage", () => { ); // Status tabs should preserve type filter - expect(html).toContain('href="/mapping/tasks?status=requested&type=obr-status"'); - expect(html).toContain('href="/mapping/tasks?status=completed&type=obr-status"'); + expect(html).toContain('href="/unmapped-codes?status=requested&type=obr-status"'); + expect(html).toContain('href="/unmapped-codes?status=completed&type=obr-status"'); }); test("omits type parameter in URLs when filter is 'all'", () => { @@ -448,7 +449,7 @@ describe("renderMappingTasksPage", () => { // Should not have type=all in URLs expect(html).not.toContain("type=all"); - expect(html).toContain('href="/mapping/tasks?status=requested"'); + expect(html).toContain('href="/unmapped-codes?status=requested"'); }); test("renders error message when provided", () => { diff --git a/test/unit/ui/shell.test.ts b/test/unit/ui/shell.test.ts new file mode 100644 index 00000000..dd809b1b --- /dev/null +++ b/test/unit/ui/shell.test.ts @@ -0,0 +1,231 @@ +import { describe, test, expect } from "bun:test"; +import { renderShell, renderLegacyBody, type NavKey } from "../../../src/ui/shell"; +import type { NavData } from "../../../src/ui/shared"; + +const navData: NavData = { + pendingMappingTasksCount: 3, + incomingTotal: 42, +}; + +describe("renderShell", () => { + test("marks the requested sidebar key as active", () => { + const html = renderShell({ + active: "accounts", + title: "Accounts", + content: "

body

", + navData, + }); + + expect(html).toMatch(/class="nav-item active"[^>]*href="\/accounts"/); + expect(html).not.toMatch(/class="nav-item active"[^>]*href="\/"/); + }); + + test("wires all three sidebar groups with the correct hrefs", () => { + const html = renderShell({ + active: "dashboard", + title: "Dashboard", + content: "", + navData, + }); + + const expectedLinks: Array<[NavKey, string, string]> = [ + ["dashboard", "/", "Dashboard"], + ["inbound", "/incoming-messages", "Inbound Messages"], + ["simulate", "/simulate-sender", "Simulate Sender"], + ["unmapped", "/unmapped-codes", "Unmapped Codes"], + ["terminology", "/terminology", "Terminology Map"], + ["accounts", "/accounts", "Accounts"], + ["outgoing", "/outgoing-messages", "Outgoing Messages"], + ]; + for (const [, href, label] of expectedLinks) { + expect(html).toContain(`href="${href}"`); + expect(html).toContain(label); + } + + expect(html).toContain(">Workspace<"); + expect(html).toContain(">Terminology<"); + expect(html).toContain(">Outbound<"); + }); + + test("shows inbound total count without a hot class", () => { + const html = renderShell({ active: "dashboard", title: "D", content: "", navData }); + expect(html).toMatch(/href="\/incoming-messages"[^<]*[^>]*>.*?42<\/span>/s); + }); + + test("applies hot modifier on unmapped count when non-zero", () => { + const html = renderShell({ active: "dashboard", title: "D", content: "", navData }); + expect(html).toContain('3'); + }); + + test("omits hot modifier on unmapped when count is zero", () => { + const html = renderShell({ + active: "dashboard", + title: "D", + content: "", + navData: { pendingMappingTasksCount: 0, incomingTotal: 0 }, + }); + expect(html).toContain('0'); + expect(html).not.toContain(''); + }); + + test("embeds the page title", () => { + const html = renderShell({ + active: "dashboard", + title: "My Page Title", + content: "", + navData, + }); + expect(html).toContain("My Page Title"); + }); + + test("embeds the provided body content inside the page column", () => { + const html = renderShell({ + active: "dashboard", + title: "D", + content: '

hi

', + navData, + }); + expect(html).toContain('

hi

'); + }); + + test("loads vendored htmx and alpine from the static route", () => { + const html = renderShell({ active: "dashboard", title: "D", content: "", navData }); + expect(html).toContain('src="/static/vendor/htmx-2.0.10.min.js"'); + expect(html).toContain('src="/static/vendor/alpine-3.15.11.min.js"'); + }); + + test("keeps Tailwind CDN so legacy-body pages still render", () => { + const html = renderShell({ active: "dashboard", title: "D", content: "", navData }); + expect(html).toContain("cdn.tailwindcss.com"); + }); + + test("embeds the icon sprite at the end of the body", () => { + const html = renderShell({ active: "dashboard", title: "D", content: "", navData }); + expect(html).toContain('id="i-home"'); + expect(html).toContain('id="i-inbox"'); + }); + + test("env pill defaults to ok tone when ENV is unset", () => { + const originalEnv = process.env.ENV; + delete process.env.ENV; + try { + const html = renderShell({ active: "dashboard", title: "D", content: "", navData }); + expect(html).toContain(''); + } finally { + if (originalEnv !== undefined) process.env.ENV = originalEnv; + } + }); + + test("env pill uses warn tone for staging", () => { + const originalEnv = process.env.ENV; + process.env.ENV = "staging"; + try { + const html = renderShell({ active: "dashboard", title: "D", content: "", navData }); + expect(html).toContain(''); + expect(html).toContain(">staging<"); + } finally { + process.env.ENV = originalEnv ?? ""; + if (originalEnv === undefined) delete process.env.ENV; + } + }); + + test("env pill uses err tone for prod", () => { + const originalEnv = process.env.ENV; + process.env.ENV = "prod"; + try { + const html = renderShell({ active: "dashboard", title: "D", content: "", navData }); + expect(html).toContain(''); + } finally { + process.env.ENV = originalEnv ?? ""; + if (originalEnv === undefined) delete process.env.ENV; + } + }); + + test("env tone dot is not attached to the health poller", () => { + const originalEnv = process.env.ENV; + process.env.ENV = "prod"; + try { + const html = renderShell({ active: "dashboard", title: "D", content: "", navData }); + expect(html).not.toMatch(/]*data-health-dot/); + } finally { + process.env.ENV = originalEnv ?? ""; + if (originalEnv === undefined) delete process.env.ENV; + } + }); + + test("health dot and health label are separate elements from env tone and env label", () => { + const originalEnv = process.env.ENV; + process.env.ENV = "prod"; + try { + const html = renderShell({ active: "dashboard", title: "D", content: "", navData }); + expect(html).toMatch(/<\/span>/); + expect(html).toMatch(/]*data-health-label[^>]*>Aidbox<\/span>/); + expect(html).not.toMatch(/data-health-label[^>]*>prod { + const html = renderShell({ + active: "dashboard", + title: '', + content: "", + navData, + }); + expect(html).not.toContain(""); + expect(html).toContain("<script>alert(1)</script>"); + }); + + test("escapes MLLP_HOST from the environment", () => { + const originalHost = process.env.MLLP_HOST; + process.env.MLLP_HOST = '">'; + try { + const html = renderShell({ active: "dashboard", title: "D", content: "", navData }); + expect(html).not.toContain('">'); + expect(html).toContain(""><img src=x>"); + } finally { + process.env.MLLP_HOST = originalHost ?? ""; + if (originalHost === undefined) delete process.env.MLLP_HOST; + } + }); + + test("env pill shows mllp endpoint from env vars with defaults", () => { + const originalHost = process.env.MLLP_HOST; + const originalPort = process.env.MLLP_PORT; + delete process.env.MLLP_HOST; + delete process.env.MLLP_PORT; + try { + const html = renderShell({ active: "dashboard", title: "D", content: "", navData }); + expect(html).toContain("mllp://localhost:2575"); + } finally { + if (originalHost !== undefined) process.env.MLLP_HOST = originalHost; + if (originalPort !== undefined) process.env.MLLP_PORT = originalPort; + } + }); + + test("env pill respects custom MLLP_HOST and MLLP_PORT", () => { + const originalHost = process.env.MLLP_HOST; + const originalPort = process.env.MLLP_PORT; + process.env.MLLP_HOST = "10.1.4.22"; + process.env.MLLP_PORT = "9999"; + try { + const html = renderShell({ active: "dashboard", title: "D", content: "", navData }); + expect(html).toContain("mllp://10.1.4.22:9999"); + } finally { + process.env.MLLP_HOST = originalHost ?? ""; + process.env.MLLP_PORT = originalPort ?? ""; + if (originalHost === undefined) delete process.env.MLLP_HOST; + if (originalPort === undefined) delete process.env.MLLP_PORT; + } + }); +}); + +describe("renderLegacyBody", () => { + test("wraps content in a gray card frame", () => { + expect(renderLegacyBody("x")).toBe( + '
x
', + ); + }); +}); From 8680c166f664d8ada20799a99b5300859065fc6c Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Thu, 23 Apr 2026 11:09:41 +0600 Subject: [PATCH 07/26] impl task 3b --- .../2026-04-23-ui-design-system-refactor.md | 8 +- src/ui/pages/accounts.ts | 3 +- src/ui/pages/code-mappings.ts | 13 ++-- src/ui/pages/mapping-tasks.ts | 13 ++-- src/ui/pages/messages.ts | 24 +++--- src/ui/pages/mllp-client.ts | 13 ++-- src/ui/shared-layout.ts | 3 + .../ui/shell-smoke.integration.test.ts | 75 +++++++++++++++++++ test/unit/ui/mapping-tasks-ui.test.ts | 2 +- 9 files changed, 118 insertions(+), 36 deletions(-) create mode 100644 test/integration/ui/shell-smoke.integration.test.ts diff --git a/ai/tickets/2026-04-23-ui-design-system-refactor.md b/ai/tickets/2026-04-23-ui-design-system-refactor.md index aed6b101..4cbb8962 100644 --- a/ai/tickets/2026-04-23-ui-design-system-refactor.md +++ b/ai/tickets/2026-04-23-ui-design-system-refactor.md @@ -57,10 +57,10 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound ## Task 3b: Migrate remaining page bodies into the shell -- [ ] 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–11). Use `renderLegacyBody` for Outgoing Messages; the others will get new warm-paper bodies soon so they can go directly in the main column. -- [ ] Update the remaining UI unit tests that assert nav markup (`test/unit/ui/*`) -- [ ] 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 -- [ ] Run validation — must pass; manually confirm every page renders with the new sidebar +- [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–11). 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 diff --git a/src/ui/pages/accounts.ts b/src/ui/pages/accounts.ts index 5cad6856..77e7bdb6 100644 --- a/src/ui/pages/accounts.ts +++ b/src/ui/pages/accounts.ts @@ -9,9 +9,8 @@ import type { Condition } from "../../fhir/hl7-fhir-r4-core/Condition"; import type { Procedure } from "../../fhir/hl7-fhir-r4-core/Procedure"; import { aidboxFetch, getResources, type Bundle } from "../../aidbox"; import { parsePageParam, createPagination, PAGE_SIZE, renderPaginationControls, type PaginationData } from "../pagination"; -import type { NavData } from "../shared-layout"; import { renderShell, renderLegacyBody } from "../shell"; -import { htmlResponse, redirectResponse, getNavData } from "../shared"; +import { htmlResponse, redirectResponse, getNavData, type NavData } from "../shared"; // ============================================================================ // Types (internal) diff --git a/src/ui/pages/code-mappings.ts b/src/ui/pages/code-mappings.ts index 58cffd52..a4c61310 100644 --- a/src/ui/pages/code-mappings.ts +++ b/src/ui/pages/code-mappings.ts @@ -31,8 +31,8 @@ import { renderPaginationControls, type PaginationData, } from "../pagination"; -import { renderNav, renderLayout, type NavData } from "../shared-layout"; -import { htmlResponse, getNavData } from "../shared"; +import { renderShell } from "../shell"; +import { htmlResponse, getNavData, type NavData } from "../shared"; // ============================================================================ // Helper Functions (exported for testing) @@ -252,11 +252,12 @@ export function renderCodeMappingsPage( } `; - return renderLayout( - "Terminology Map", - renderNav("code-mappings", navData), + return renderShell({ + active: "terminology", + title: "Terminology Map", content, - ); + navData, + }); } /** diff --git a/src/ui/pages/mapping-tasks.ts b/src/ui/pages/mapping-tasks.ts index 612ef773..a1f15cab 100644 --- a/src/ui/pages/mapping-tasks.ts +++ b/src/ui/pages/mapping-tasks.ts @@ -8,8 +8,8 @@ import type { Task } from "../../fhir/hl7-fhir-r4-core/Task"; import { aidboxFetch, type Bundle } from "../../aidbox"; import { escapeHtml } from "../../utils/html"; import { parsePageParam, createPagination, PAGE_SIZE, renderPaginationControls, type PaginationData } from "../pagination"; -import { renderNav, renderLayout, type NavData } from "../shared-layout"; -import { htmlResponse, getNavData } from "../shared"; +import { renderShell } from "../shell"; +import { htmlResponse, getNavData, type NavData } from "../shared"; import { MAPPING_TYPES, type MappingTypeName, isMappingTypeName, sourceLabel, targetLabel } from "../../code-mapping/mapping-types"; import { getValidValuesWithDisplay } from "../../code-mapping/mapping-type-options"; import { getMappingTypeShortLabel } from "../mapping-type-ui"; @@ -227,11 +227,12 @@ export function renderMappingTasksPage( })}
`; - return renderLayout( - "Unmapped Codes", - renderNav("mapping-tasks", navData), + return renderShell({ + active: "unmapped", + title: "Unmapped Codes", content, - ); + navData, + }); } /** diff --git a/src/ui/pages/messages.ts b/src/ui/pages/messages.ts index dfc47e2d..a66020c0 100644 --- a/src/ui/pages/messages.ts +++ b/src/ui/pages/messages.ts @@ -13,8 +13,8 @@ import type { import type { Patient } from "../../fhir/hl7-fhir-r4-core/Patient"; import { aidboxFetch, getResources, type Bundle } from "../../aidbox"; import { escapeHtml } from "../../utils/html"; -import { renderNav, renderLayout, type NavData } from "../shared-layout"; -import { htmlResponse, redirectResponse, getNavData } from "../shared"; +import { renderShell, renderLegacyBody } from "../shell"; +import { htmlResponse, redirectResponse, getNavData, type NavData } from "../shared"; import { PAGE_SIZE } from "../pagination"; // ============================================================================ @@ -382,11 +382,12 @@ function renderOutgoingMessagesPage(

Total: ${messages.length} messages

`; - return renderLayout( - "Outgoing Messages", - renderNav("outgoing", navData), - content, - ); + return renderShell({ + active: "outgoing", + title: "Outgoing Messages", + content: renderLegacyBody(content), + navData, + }); } const INCOMING_STATUSES = [ @@ -660,9 +661,10 @@ function renderIncomingMessagesPage(

Total: ${messages.length} messages

`; - return renderLayout( - "Inbound Messages", - renderNav("incoming", navData), + return renderShell({ + active: "inbound", + title: "Inbound Messages", content, - ); + navData, + }); } diff --git a/src/ui/pages/mllp-client.ts b/src/ui/pages/mllp-client.ts index c03381db..fe8adcb0 100644 --- a/src/ui/pages/mllp-client.ts +++ b/src/ui/pages/mllp-client.ts @@ -6,8 +6,8 @@ import * as net from "node:net"; import { wrapWithMLLP, VT, FS, CR } from "../../mllp/mllp-server"; -import { renderNav, renderLayout, type NavData } from "../shared-layout"; -import { htmlResponse, getNavData } from "../shared"; +import { renderShell } from "../shell"; +import { htmlResponse, getNavData, type NavData } from "../shared"; // ============================================================================ // Types (internal) @@ -394,9 +394,10 @@ function renderMLLPClientPage( } `; - return renderLayout( - "Simulate Sender", - renderNav("mllp-client", navData), + return renderShell({ + active: "simulate", + title: "Simulate Sender", content, - ); + navData, + }); } diff --git a/src/ui/shared-layout.ts b/src/ui/shared-layout.ts index b100cfe9..f5289800 100644 --- a/src/ui/shared-layout.ts +++ b/src/ui/shared-layout.ts @@ -24,6 +24,7 @@ export function highlightHL7WithDataTooltip( return html.replace(/\btitle="/g, 'data-tooltip="'); } +/** @deprecated Use NavKey from `./shell`. Deleted in Task 3c. */ export type NavTab = | "accounts" | "outgoing" @@ -55,6 +56,7 @@ function renderTab(tab: NavTabDef, active: NavTab): string { return `${tab.label}${badge}`; } +/** @deprecated Use `renderShell` from `./shell`. Deleted in Task 3c. */ export function renderNav(active: NavTab, navData: NavData): string { // Order by demo flow: inbound pipeline first (messages arrive → simulator → // remediation), then outbound (accounts → BAR messages), then reference. @@ -104,6 +106,7 @@ export function renderNav(active: NavTab, navData: NavData): string { `; } +/** @deprecated Use `renderShell` from `./shell`. Deleted in Task 3c. */ export function renderLayout( title: string, nav: string, diff --git a/test/integration/ui/shell-smoke.integration.test.ts b/test/integration/ui/shell-smoke.integration.test.ts new file mode 100644 index 00000000..890abdc1 --- /dev/null +++ b/test/integration/ui/shell-smoke.integration.test.ts @@ -0,0 +1,75 @@ +/** + * Smoke test: every page handler returns a 200 response that embeds the new + * warm-paper shell (identified by the `.sidebar` marker). Exercises each + * handler against the test Aidbox — catches regressions where a page hasn't + * been migrated to `renderShell`, or where the shell import was broken by a + * refactor. + */ +import { describe, test, expect } from "bun:test"; +import { handleAccountsPage } from "../../../src/ui/pages/accounts"; +import { + handleIncomingMessagesPage, + handleOutgoingMessagesPage, +} from "../../../src/ui/pages/messages"; +import { handleMappingTasksPage } from "../../../src/ui/pages/mapping-tasks"; +import { handleCodeMappingsPage } from "../../../src/ui/pages/code-mappings"; +import { handleMLLPClientPage } from "../../../src/ui/pages/mllp-client"; + +interface Route { + path: string; + label: string; + call: () => Promise; +} + +const ROUTES: Route[] = [ + { + // TODO(task-6): swap to handleDashboardPage once `/` moves off Accounts. + path: "/", + label: "Dashboard (currently Accounts)", + call: () => handleAccountsPage(new Request("http://localhost:3000/")), + }, + { + path: "/accounts", + label: "Accounts", + call: () => handleAccountsPage(new Request("http://localhost:3000/accounts")), + }, + { + path: "/outgoing-messages", + label: "Outgoing Messages", + call: () => + handleOutgoingMessagesPage(new Request("http://localhost:3000/outgoing-messages")), + }, + { + path: "/incoming-messages", + label: "Inbound Messages", + call: () => + handleIncomingMessagesPage(new Request("http://localhost:3000/incoming-messages")), + }, + { + path: "/simulate-sender", + label: "Simulate Sender", + call: () => handleMLLPClientPage(), + }, + { + path: "/unmapped-codes", + label: "Unmapped Codes", + call: () => + handleMappingTasksPage(new Request("http://localhost:3000/unmapped-codes")), + }, + { + path: "/terminology", + label: "Terminology Map", + call: () => handleCodeMappingsPage(new Request("http://localhost:3000/terminology")), + }, +]; + +describe("smoke: every shell page returns 200", () => { + for (const route of ROUTES) { + test(`${route.path} (${route.label})`, async () => { + const response = await route.call(); + expect(response.status).toBe(200); + const body = await response.text(); + expect(body).toContain('class="sidebar"'); + }); + } +}); diff --git a/test/unit/ui/mapping-tasks-ui.test.ts b/test/unit/ui/mapping-tasks-ui.test.ts index 41716c54..b59b4e28 100644 --- a/test/unit/ui/mapping-tasks-ui.test.ts +++ b/test/unit/ui/mapping-tasks-ui.test.ts @@ -17,7 +17,7 @@ import { type MappingTypeFilter, } from "../../../src/ui/pages/mapping-tasks"; import { getMappingTypeShortLabel } from "../../../src/ui/mapping-type-ui"; -import type { NavData } from "../../../src/ui/shared-layout"; +import type { NavData } from "../../../src/ui/shared"; import type { PaginationData } from "../../../src/ui/pagination"; import { MAPPING_TYPES } from "../../../src/code-mapping/mapping-types"; From 49114c0743f6312704e73e56e321fe7c0a6e9169 Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Thu, 23 Apr 2026 11:13:22 +0600 Subject: [PATCH 08/26] impl task 3c --- .../2026-04-23-ui-design-system-refactor.md | 7 +- src/ui/legacy-assets.ts | 10 +- src/ui/shared-layout.ts | 128 +----------------- 3 files changed, 13 insertions(+), 132 deletions(-) diff --git a/ai/tickets/2026-04-23-ui-design-system-refactor.md b/ai/tickets/2026-04-23-ui-design-system-refactor.md index 4cbb8962..2da04d41 100644 --- a/ai/tickets/2026-04-23-ui-design-system-refactor.md +++ b/ai/tickets/2026-04-23-ui-design-system-refactor.md @@ -65,9 +65,9 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound ## Task 3c: Delete legacy layout -- [ ] 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). -- [ ] Remove any remaining imports of the deleted helpers -- [ ] Run validation — must pass (typecheck catches any missed imports) +- [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 @@ -190,6 +190,7 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound ## Task 12: Cleanup - [ ] Delete dead code: old `renderIncomingMessagesPage` body in `src/ui/pages/messages.ts` (keep Outgoing), legacy HTML rendering in `src/ui/pages/mapping-tasks.ts` and `src/ui/pages/code-mappings.ts`, page-render functions in `src/ui/pages/mllp-client.ts`. If `sendMLLPMessage` is still in `mllp-client.ts` at this point, **move it to `src/mllp/client.ts`** and delete `mllp-client.ts` entirely (keep `sendMLLPTest` if any test still uses it; otherwise delete it too). +- [ ] Rename `src/ui/shared-layout.ts` → `src/ui/hl7-display.ts` (only `highlightHL7WithDataTooltip` remains after Task 3c; the old filename is now misleading). Update the two importers (`src/index.ts`, `src/ui/pages/messages.ts`). Add a unit test at the new location: input with `title="..."` → output with `data-tooltip="..."`, HL7 segment markup preserved. - [ ] Re-run the `rg` grep-audit from Task 3a to confirm no stale references remain to `/mllp-client`, `/mapping/tasks`, `/mapping/table`, `renderLayout`, or `renderNav` - [ ] Close `ai/tickets/2026-04-22-demo-ready-ui-tier1.md`: mark tasks 3–7 as "superseded by `2026-04-23-ui-design-system-refactor.md`", leave tasks 1–2 as-is (already done) - [ ] End-to-end manual demo walkthrough: `/` Dashboard → Run demo → ticker shows 4 → click warn row → Inbound detail walks Structured/Raw/FHIR/Timeline tabs → Map code → Unmapped pre-selected → accept top suggestion → message reprocesses → Terminology Map shows the new entry → sidebar links all still work including Accounts/Outgoing (framed in gray card) diff --git a/src/ui/legacy-assets.ts b/src/ui/legacy-assets.ts index 721bcc2d..fba2b3ea 100644 --- a/src/ui/legacy-assets.ts +++ b/src/ui/legacy-assets.ts @@ -1,9 +1,9 @@ // Styles and scripts carried from the original Tailwind-based layout. -// Both `renderLayout` (legacy) and `renderShell` (new) embed these so page -// bodies that still rely on HL7 highlighting, LOINC autocomplete, or the -// Aidbox health dot continue to work during the refactor. Once every page -// body is rebuilt against the warm-paper design, the pieces tied to -// Tailwind markup (tooltip CSS, LOINC dropdown CSS) can retire. +// `renderShell` embeds these so page bodies that still rely on HL7 +// highlighting, LOINC autocomplete, or the Aidbox health dot continue to +// work during the refactor. Once every page body is rebuilt against the +// warm-paper design, the pieces tied to Tailwind markup (tooltip CSS, +// LOINC dropdown CSS) can retire. import { getHighlightStyles } from "@atomic-ehr/hl7v2/src/hl7v2/highlight"; diff --git a/src/ui/shared-layout.ts b/src/ui/shared-layout.ts index f5289800..e1f578ae 100644 --- a/src/ui/shared-layout.ts +++ b/src/ui/shared-layout.ts @@ -1,21 +1,12 @@ /** - * Shared layout components for UI pages (legacy Tailwind layout). + * HL7 highlight helper used by page bodies that render the raw message. * - * This file is scheduled for removal once every page body migrates to the - * warm-paper shell in `src/ui/shell.ts`. Keep new work pointed at the shell; - * only edit here to keep legacy pages functional until their migration lands. + * Historically this file also held the Tailwind `renderLayout` / `renderNav` + * scaffolding. Those were replaced by `./shell` and deleted in Task 3c; only + * the highlight helper remains. */ import { highlightHL7Message } from "@atomic-ehr/hl7v2/src/hl7v2/highlight"; -import { - LEGACY_STYLES, - HEALTH_CHECK_SCRIPT, - HL7_TOOLTIP_SCRIPT, - LOINC_AUTOCOMPLETE_SCRIPT, -} from "./legacy-assets"; -import type { NavData } from "./shared"; - -export type { NavData }; export function highlightHL7WithDataTooltip( message: string | undefined, @@ -23,114 +14,3 @@ export function highlightHL7WithDataTooltip( const html = highlightHL7Message(message); return html.replace(/\btitle="/g, 'data-tooltip="'); } - -/** @deprecated Use NavKey from `./shell`. Deleted in Task 3c. */ -export type NavTab = - | "accounts" - | "outgoing" - | "incoming" - | "mllp-client" - | "mapping-tasks" - | "code-mappings"; - -interface NavTabDef { - id: NavTab; - href: string; - label: string; - badge?: number; -} - -function getEnvLabel(): string { - return process.env.ENV || "dev"; -} - -function renderTab(tab: NavTabDef, active: NavTab): string { - const isActive = active === tab.id; - const classes = isActive - ? "border-blue-500 text-blue-600 font-semibold" - : "border-transparent text-gray-600 hover:text-gray-800"; - const badge = - tab.badge && tab.badge > 0 - ? `${tab.badge}` - : ""; - return `${tab.label}${badge}`; -} - -/** @deprecated Use `renderShell` from `./shell`. Deleted in Task 3c. */ -export function renderNav(active: NavTab, navData: NavData): string { - // Order by demo flow: inbound pipeline first (messages arrive → simulator → - // remediation), then outbound (accounts → BAR messages), then reference. - // The "data direction" story lives in the dashboard pipeline diagram, not - // here — nav is just navigation. - const tabs: NavTabDef[] = [ - { id: "incoming", href: "/incoming-messages", label: "Inbound Messages" }, - { id: "mllp-client", href: "/simulate-sender", label: "Simulate Sender" }, - { - id: "mapping-tasks", - href: "/unmapped-codes", - label: "Unmapped Codes", - badge: navData.pendingMappingTasksCount, - }, - { id: "accounts", href: "/accounts", label: "Accounts" }, - { id: "outgoing", href: "/outgoing-messages", label: "Outgoing Messages" }, - { id: "code-mappings", href: "/terminology", label: "Terminology Map" }, - ]; - - const tabsHtml = tabs.map((tab) => renderTab(tab, active)).join(""); - - const env = getEnvLabel(); - const envClass = - env === "prod" || env === "production" - ? "bg-red-100 text-red-700" - : env === "staging" || env === "test" - ? "bg-amber-100 text-amber-700" - : "bg-gray-100 text-gray-600"; - - const statusCluster = ` -
- ${env} - - - Aidbox - -
`; - - return ` - `; -} - -/** @deprecated Use `renderShell` from `./shell`. Deleted in Task 3c. */ -export function renderLayout( - title: string, - nav: string, - content: string, -): string { - return ` - - - - - ${title} - - - - - ${nav} -
- ${content} -
- - -`; -} From bf8b7f89e210a5d4018495f6ec8e195885a8e4d7 Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Thu, 23 Apr 2026 11:30:10 +0600 Subject: [PATCH 09/26] impl task 4 --- .mcp.json | 13 ++ CLAUDE.md | 5 + .../2026-04-23-ui-design-system-refactor.md | 14 +- docs/developer-guide/how-to/add-ui-page.md | 166 +++++++++++++++++ docs/developer-guide/ui-architecture.md | 174 ++++++++++++++++++ docs/developer-guide/ui-design-tokens.md | 156 ++++++++++++++++ 6 files changed, 521 insertions(+), 7 deletions(-) create mode 100644 .mcp.json create mode 100644 docs/developer-guide/how-to/add-ui-page.md create mode 100644 docs/developer-guide/ui-architecture.md create mode 100644 docs/developer-guide/ui-design-tokens.md diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..91127b07 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": [ + "-y", + "chrome-devtools-mcp@latest", + "--executablePath", + "${CHROME_EXECUTABLE}" + ] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index f745cfb7..b67ba625 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,12 +119,17 @@ 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`. + ## 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-23-ui-design-system-refactor.md b/ai/tickets/2026-04-23-ui-design-system-refactor.md index 2da04d41..4cd79221 100644 --- a/ai/tickets/2026-04-23-ui-design-system-refactor.md +++ b/ai/tickets/2026-04-23-ui-design-system-refactor.md @@ -72,13 +72,13 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound ## Task 4: UI architecture docs + Chrome DevTools MCP -- [ ] 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 -- [ ] 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 -- [ ] Create `docs/developer-guide/how-to/add-ui-page.md` — recipe: page handler, route registration, sidebar entry, partial pattern, tests -- [ ] 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` -- [ ] **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. -- [ ] Add one-line pointer in `CLAUDE.md` under "Code Style" → "UI conventions: see `docs/developer-guide/ui-architecture.md`" -- [ ] Run validation — typecheck must pass; MCP screenshot must succeed +- [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) diff --git a/docs/developer-guide/how-to/add-ui-page.md b/docs/developer-guide/how-to/add-ui-page.md new file mode 100644 index 00000000..3aecbd27 --- /dev/null +++ b/docs/developer-guide/how-to/add-ui-page.md @@ -0,0 +1,166 @@ +# Add a new UI page + +End-to-end recipe for adding a new page to the warm-paper UI. Background: [`../ui-architecture.md`](../ui-architecture.md); class vocabulary: [`../ui-design-tokens.md`](../ui-design-tokens.md). + +## 1. Create the page module + +Each page lives at `src/ui/pages/{slug}.ts` and owns its route handler plus any partial handlers. + +```typescript +// src/ui/pages/senders.ts +import { renderShell } from "../shell"; +import { htmlResponse, getNavData } from "../shared"; +import { renderIcon } from "../icons"; +import { escapeHtml } from "../../utils/html"; + +export async function handleSendersPage(req: Request): Promise { + const url = new URL(req.url); + const selected = url.searchParams.get("selected"); + + const navData = await getNavData(); + const senders = await loadSenders(); + const selectedDetail = selected ? await loadSender(selected) : null; + + const content = ` + ${renderHero()} +
+
+ ${renderSenderList(senders, selected)} +
+
+ ${selectedDetail ? renderSenderDetail(selectedDetail) : renderEmptyDetail()} +
+
+ `; + + return renderShell({ + active: "senders", // must be a NavKey (extend src/ui/shell.ts) + title: "Senders", + content, + navData, + }); +} + +function renderHero(): string { + return ` +
+
Workspace
+

Senders

+

Every HL7v2 source that has pushed a message.

+
+ `; +} +``` + +Keep the handler under 80 lines. Extract render helpers (`renderSenderCard`, `renderSenderList`) into the same module. Heavy data-fetching helpers belong in a separate service file, not in the page module. + +Always pipe user-controlled strings through `escapeHtml` from `src/utils/html.ts` before interpolating into the template — URL params, form values, and Aidbox resource fields are all untrusted from the shell's perspective. + +### Two-pane (list + detail) pattern + +For pages with a selection URL param (`?selected=`), the same render helpers serve both the initial full page load (server-renders the detail into `#detail`) and the htmx partial swap on row click. Don't duplicate the rendering: + +```typescript +// Shared renderer — single source of truth +function renderSenderDetail(sender: Sender): string { /* ... */ } + +// Partial handler returns just the fragment +export async function handleSenderDetailPartial(req: Request & { params: { id: string } }): Promise { + const sender = await loadSender(req.params.id); + return htmlResponse(renderSenderDetail(sender)); +} +``` + +List rows wire into htmx with: + +```html + + ${escapeHtml(sender.name)} + +``` + +## 2. Register the route + +In [`src/index.ts`](../../src/index.ts), add the route alongside the existing UI routes: + +```typescript +import { handleSendersPage } from "./ui/pages/senders"; + +// ... +routes: { + // ... + "/senders": handleSendersPage, + "/senders/partials/list": handleSendersListPartial, + // ... +} +``` + +Routes for partials use the `/{page}/partials/{name}` convention. Method-specific handlers go in an object: `"/senders": { GET: handlePage, POST: createSender }`. + +## 3. Add a sidebar entry + +In [`src/ui/shell.ts`](../../src/ui/shell.ts): + +1. Extend `NavKey` with the new key: `| "senders"`. +2. Add the link under the right group in `buildNavGroups`. Choose `Workspace` / `Terminology` / `Outbound` based on the page's role. Pick an icon from the existing sprite — add a new `` to `src/ui/icons.ts` only if none fit. +3. Optionally add a count: if the sidebar should show a badge, extend `NavData` in [`src/ui/shared.ts`](../../src/ui/shared.ts) and fetch the count in `getNavData()`. + +Run the existing shell tests (`bun test test/unit/ui/shell.test.ts`) — the "all expected links render" test will catch a typo. + +## 4. Partial endpoints + +If the page has fragments that update in place (list refresh, tab swap, detail pane), register them next to the page handler and return raw HTML without the shell: + +```typescript +export async function handleSendersListPartial(req: Request): Promise { + const url = new URL(req.url); + const filter = url.searchParams.get("filter"); + const senders = await loadSenders(filter); + return htmlResponse(renderSendersList(senders)); +} +``` + +Client-side, use htmx to swap: + +```html +
+ +
+``` + +For two-pane layouts, make the detail-pane partial bookmark-safe by also server-rendering it when `?selected=` is present on the page's initial GET. + +## 5. Interactivity rules of thumb + +- **Form mutation that reloads the page → plain ``** + `redirectResponse("/senders")`. +- **Fragment swap after mutation → dual-mode handler**. Branch on `req.headers.get("HX-Request") === "true"`; when true, return the refreshed fragment + `HX-Trigger` header; when false, return a 302. +- **Client-only state (popover, tabs, disabled-until-filled) → Alpine**. `x-data`, `x-on:click.outside`, `x-bind:disabled`. Never Alpine-fetch — use htmx. +- **Auto-refresh lists → `hx-trigger="every 5s"`** guarded by an Alpine flag when the user has a row selected. + +## 6. Tests + +Minimum coverage: + +- **Unit test at `test/unit/ui/{slug}.test.ts`**: render the page with a fake `NavData` and assert: the page title is in the output, the sidebar key is marked active, and at least one page-specific marker is present (hero heading, card title). Avoid brittle snapshots. +- **If the page has partials**: unit test each partial's happy path + empty state. Partial handlers should be pure-ish (take params, fetch data, return HTML) — mock only the data layer. +- **Integration test at `test/integration/ui/{slug}.integration.test.ts`** (optional but recommended): exercise the handler against the real test Aidbox with seed data. Tag the smoke-worthy case with a name starting `smoke: ` so it joins `bun test:smoke`. + +## 7. Accessibility + responsive + +- Every icon-only control needs `aria-label` on the control (the `` is `aria-hidden` by default). +- Use semantic elements: `` +- Vendored htmx and Alpine with `defer` +- Sidebar with three groups (Workspace / Terminology / Outbound), env pill, health dot +- Icon sprite at the end of `` so every `renderIcon(name)` call works without a per-page import + +There is **no topbar** on the shell. Pages render their own hero row (title + actions + chips) inside `content`. A single shared topbar with search + avatar was considered and dropped — every page's top row is meaningfully different, and a common header would have been noise. + +**Legacy-body wrapping.** Pages whose markup is still Tailwind (Accounts, Outgoing Messages) wrap their body in `renderLegacyBody(content)` so the gray card frames the Tailwind palette against warm paper. New pages render directly into the main column. + +## When to use htmx vs Alpine vs plain form POST + +Decision order: **form POST → htmx → Alpine**. Prefer the simplest layer. + +- **Plain ``** — user mutates server state; post-action state is a full page refresh. Existing BAR send, Account create, Task defer all follow this pattern. Keep using it unless you need a fragment swap. +- **htmx** — you need to update a slice of the page without full reload. Typical: auto-refresh lists (`hx-trigger="every 5s"`), fragment swaps after save (`HX-Trigger` header fires a client event → another element listens via `hx-trigger="x from:body"`), URL-param navigation with detail-pane swap (`hx-push-url="?selected=:id"`). +- **Alpine** — purely client-side UI state: popover open/closed, tab currently active, a textarea's edit buffer before save, disabling a submit button until required fields are filled. Do not use Alpine to fetch data — that's htmx's job. + +A handler can be dual-mode. When a mutation endpoint needs to serve both a legacy form POST (302) and an htmx fragment swap, branch on `req.headers.get("HX-Request")`: + +```typescript +if (req.headers.get("HX-Request") === "true") { + return htmlResponse(refreshedFragment, { + headers: { "HX-Trigger": "concept-map-entry-saved" }, + }); +} +return redirectResponse("/terminology"); +``` + +## Partial endpoints + +URL convention: `/{page}/partials/{name}`. + +Examples: +- `GET /incoming-messages/partials/list` — refresh the message list fragment +- `GET /incoming-messages/partials/type-chips` — refresh the per-type chip counts +- `GET /incoming-messages/:id/partials/detail` — server-render the detail pane +- `GET /incoming-messages/:id/partials/detail/:tab` — tab-specific fragment +- `GET /terminology/partials/facets/fhir` — filter popover contents + +Rules: +- Partials return `text/html` with no doctype, no ``, no sidebar — just the fragment. +- Partials that read query params should share their logic with the page handler (one function returns HTML, page wraps in `renderShell`, partial returns bare). Don't duplicate the rendering. +- Partial URLs should be bookmark-safe where possible (same query string as the page) so that a raw `curl` returns something useful during debugging. + +## `?selected=` full-page-load pattern + +Two-pane layouts (Inbound Messages, Terminology Map, Unmapped Codes) keep the selected entity in the URL: + +- `?selected={id}` — pre-render the detail pane server-side on full page load so deep links work. +- Row click → `hx-get="/{page}/:id/partials/detail" hx-target="#detail" hx-push-url="?selected=:id"` — subsequent navigation swaps the fragment and updates the URL without reloading the page. +- List auto-refresh (`hx-trigger="every 5s"`) should be guarded by Alpine when a row is selected so the user's edit context isn't clobbered mid-type. + +## Design-system classes + +Full class vocabulary with samples: [`ui-design-tokens.md`](ui-design-tokens.md). In short: + +- Layout: `.app`, `.sidebar`, `.main`, `.page`, `.card` + `.card-head` + `.card-pad` — the shell emits the `.app > .sidebar + .main > .page` structure; page bodies only render inside `.page` and never re-apply `.app` / `.sidebar` / `.main` +- Type: `.h1` (serif hero), `.h2` (serif section), `.sub` (body), `.eyebrow` (small caps label), `.muted`, `.mono` +- Buttons: `.btn`, `.btn-primary`, `.btn-ghost` +- Chips: `.chip` + `.chip-ok|chip-warn|chip-err|chip-accent` for tone +- Dots: `.dot` + `.ok|warn|err|accent` +- Forms: `.inp` (also `.inp.mono` for hex/code inputs) +- Utilities: `.clean-scroll`, `.spinner` + +Every class is defined in [`src/ui/design-system.ts`](../../src/ui/design-system.ts). Do not introduce ad-hoc inline styles for the same visual — add a new class there, or use the existing one. + +## Icon sprite + +Every icon comes from the shared sprite embedded by the shell. Use [`renderIcon`](../../src/ui/icons.ts): + +```typescript +import { renderIcon } from "../icons"; + +renderIcon("inbox") // default size (16px) +renderIcon("plus", "i-sm") // 13px variant +``` + +Available icons: `home`, `inbox`, `send`, `alert`, `map`, `users`, `out`, `search`, `settings`, `chev-down`, `chev-right`, `plus`, `check`, `x`, `filter`, `clock`, `arrow-right`, `play`, `sparkle`. Adding a new icon = append a `` to `ICON_SPRITE_SVG` and add the name to `ICON_NAMES`; a unit test catches drift. + +## Chrome DevTools MCP + +Chrome DevTools MCP lets the agent drive a headless browser against the running dev server — take screenshots, read console logs, inspect the DOM. Useful when a review needs to verify a page visually against the design. + +**Setup (one-time, user approval required):** + +**Prerequisites.** + +- Chrome or Chromium installed on the host. On NixOS the `chrome-devtools-mcp` server does not auto-discover the Nix-store path — install `google-chrome` or `chromium` via home-manager / system packages and set `CHROME_EXECUTABLE` below. +- `CHROME_EXECUTABLE` env var exported before launching Claude Code. Pick the path that fits your machine: + - Debian/Ubuntu: `export CHROME_EXECUTABLE=/usr/bin/google-chrome-stable` + - macOS: `export CHROME_EXECUTABLE="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"` + - NixOS (home-manager user install): `export CHROME_EXECUTABLE=$(which google-chrome-stable)` + - Generic fallback: `export CHROME_EXECUTABLE=$(command -v google-chrome-stable || command -v google-chrome || command -v chromium)` + +1. Project-wide server definition lives at `.mcp.json` (committed, already in place) and reads `${CHROME_EXECUTABLE}` from the environment: + + ```json + { + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": [ + "-y", + "chrome-devtools-mcp@latest", + "--executablePath", + "${CHROME_EXECUTABLE}" + ] + } + } + } + ``` + + Keeping the path out of `.mcp.json` itself means teammates and CI can point at different Chrome installs without editing a committed file. If `CHROME_EXECUTABLE` is unset, the MCP server refuses to launch — the error is clear and the fix is to export the var. + +2. To opt in, the user approves the server in Claude Code (interactive prompt on first run) **or** adds `"enabledMcpjsonServers": ["chrome-devtools"]` to `.claude/settings.local.json`. The agent cannot flip this toggle itself — it's a deliberate security boundary. +3. First approval downloads the package via `npx`; expect a 10–30s delay. If the tools don't appear after approval, restart Claude Code. +4. Verify: ask the agent to take a screenshot of `http://localhost:3000/`. Once approval lands, `mcp__chrome-devtools__*` tools become available — prompt "Use Chrome DevTools MCP to capture a screenshot of the dashboard." If the screenshot succeeds, MCP is live. + +When reviewing a UI change, the agent should screenshot the affected page before and after the change and compare against `ai/tickets/ui-refactoring/hl7v2-v2/project/HL7v2 Design.html`. + +## File layout + +``` +src/ui/ +├── shell.ts # renderShell, renderLegacyBody, NavKey +├── design-system.ts # DESIGN_SYSTEM_CSS +├── icons.ts # ICON_SPRITE_SVG, ICON_NAMES, renderIcon +├── legacy-assets.ts # CSS + scripts inherited from the Tailwind layout +├── shared.ts # htmlResponse, redirectResponse, getNavData, NavData +├── shared-layout.ts # highlightHL7WithDataTooltip only (historical name; renamed to hl7-display.ts in Task 12) +├── static.ts # /static/* route +├── pagination.ts # shared pagination helpers +└── pages/ # one file per route +``` + +When you add a new module under `src/ui/`, keep its responsibility tight. A page module should export one handler per route it owns plus a small set of test-only exports (see the existing `mapping-tasks.ts` pattern). Shared helpers go in `shared.ts`; shared styling in `design-system.ts`. + +## Common gotchas + +- **Forgot `escapeHtml`.** Any user-controlled string interpolated into a template must go through `escapeHtml` from `src/utils/html.ts` — including values read from `URL.searchParams`, form POST bodies, and Aidbox resources. XSS leaks otherwise. +- **`renderShell` called without `navData`.** TypeScript catches this, but the error message is about an object shape mismatch — the fix is always `const navData = await getNavData()` before the render. +- **Two sidebar entries with the same NavKey.** The shell picks the first match; the second is silently inactive. `NavKey` is a string union — adding a new page means both extending the union *and* listing the entry in `buildNavGroups`. +- **`hot` count modifier is opt-in.** `NavLink.hot: true` paints the count badge accent; without it, a non-zero count renders neutral. Use it to signal "the user should notice this" (e.g. unmapped codes pending) — not just "count > 0." +- **Chrome DevTools MCP silently inactive.** If an approval round-tripped but screenshots still error, restart Claude Code; MCP servers are loaded at session start. diff --git a/docs/developer-guide/ui-design-tokens.md b/docs/developer-guide/ui-design-tokens.md new file mode 100644 index 00000000..1033a58f --- /dev/null +++ b/docs/developer-guide/ui-design-tokens.md @@ -0,0 +1,156 @@ +# UI Design Tokens + +Reference for the warm-paper design system. Source of truth is [`src/ui/design-system.ts`](../../src/ui/design-system.ts) — this file paraphrases it for quick lookup, with samples agents can copy directly. The design prototype lives at `ai/tickets/ui-refactoring/hl7v2-v2/project/HL7v2 Design.html`; don't re-read it on every task — use this file. + +## Palette + +All colors are CSS custom properties declared on `:root`. Reference them as `var(--paper)` etc., not as hex. + +| Token | Value | Role | +|------------------|------------|------| +| `--paper` | `#FBF8F2` | Canvas background (app shell). | +| `--paper-2` | `#F5F0E6` | Sidebar fill, hover states on paper. | +| `--surface` | `#FFFFFF` | Cards, inputs, elevated surfaces. | +| `--ink` | `#1F1A15` | Primary text. | +| `--ink-2` | `#5A4F43` | Body text. | +| `--ink-3` | `#968B7D` | Muted / captions. | +| `--line` | `#E8E0D0` | Hairline borders. | +| `--line-2` | `#D8CCB4` | Stronger dividers, focus rings. | +| `--accent` | `#C6532A` | Terracotta — primary buttons, active-nav rail, "hot" counts. | +| `--accent-soft` | `#F6E3D8` | Accent chip backgrounds, focus glow. | +| `--accent-ink` | `#8A3014` | Accent hover, accent text on soft bg. | +| `--ok` | `#3F8A5C` | Success dot/chip foreground. | +| `--ok-soft` | `#E3F1E6` | Success chip background. | +| `--warn` | `#A37319` | Warning dot/chip foreground. | +| `--warn-soft` | `#F5ECCF` | Warning chip background. | +| `--err` | `#A84428` | Error dot/chip foreground. | +| `--err-soft` | `#F5DFD5` | Error chip background. | + +Rules: +- Never introduce a new hex literal in a page body. Add a token first if a new tone is needed. +- Dark text always uses `--ink`, `--ink-2`, or `--ink-3`; never `#000` / gray literals. +- Chips, dots, and the active-nav rail are the only places the accent color appears. Don't paint headings, body text, or borders with it — that breaks the attention hierarchy. + +## Typography + +Three font stacks, all loaded from Google Fonts in the shell: + +- `var(--sans)` → Inter (400/500/600/700). Default for body, nav, buttons, chips. +- `var(--serif)` → Fraunces (400/500/600 with optical sizing). Used for `.h1` and `.h2` only — these establish the warm-paper editorial feel. +- `var(--mono)` → JetBrains Mono (400/500). IDs, codes, MLLP endpoints, chip labels. + +Type scale (from `.h1` down) is defined by `.h1`, `.h2`, `.sub`, `.eyebrow`. Everything else inherits the shell's 13.5px default. + +## Component classes + +### Layout + +```html +
+ +
+
+ +
+
+
+``` + +### Typography + +```html +
Workspace
+

Inbound Messages

+

Every HL7v2 message we've received, with processing state.

+

Filters

+No tag +MSH|^~\&|... +``` + +### Cards + +```html +
+
+ Recent activity + updated 2s ago +
+
+ +
+
+``` + +Variants: use `card-pad` alone (no head) when the card is a plain padded surface. + +### Buttons + +```html + + + + +``` + +- `.btn` is the base (paper-surface background, 1px line). +- `.btn-primary` is the accent terracotta — at most **one primary per pane**. +- `.btn-ghost` drops the surface; use for tertiary actions in hero rows. +- `disabled` attribute dims to 50% opacity and blocks the pointer. + +### Chips + +```html +5 +4 unmapped +processed +warning +error +``` + +Chips are always mono and always one line — for counts, status labels, and code identifiers. + +### Dots + +```html + + + + + +``` + +6×6 circle, inline-flex. Pair with text for legends (env pill, health indicator, per-row status). + +### Forms + +```html + + + + +``` + +Focus ring uses `--accent-soft` + `--accent`. Don't override. + +### Utilities + +- `.muted` — set color to `--ink-3`. +- `.mono` — apply mono stack (inherits size; add `font-size` inline if needed). +- `.clean-scroll` — thin, accent-tinted scrollbars; use on scroll containers inside cards. +- `.spinner` — 14×14 spinning ring; drop inside a button when a long-running action is in flight. +- `.i`, `.i-sm` — SVG icon sizes (16px, 13px). See `renderIcon` in `src/ui/icons.ts`. + +## Spacing + +The design uses a loose ~4px grid. Tailwind's `gap-4`/`p-6` rhythm carries over. The `.page` container sets 32px vertical gaps between cards (26px on ≥1600px via the media query in `design-system.ts`). Prefer spacing via `.page`'s flex gap rather than per-card margins — consistency beats one-off fixes. + +## Env pill (sidebar footer) + +Two independent signals stacked in one card: + +1. **Env tone dot + label** — `class="dot ok|warn|err"` next to uppercase env name (`DEV` / `STAGING` / `PROD`). Static; derived from `ENV` env var at render time. +2. **Health dot + "Aidbox" label** — `data-health-dot` + `data-health-label`. Updated every 10s by the health-check script in `legacy-assets.ts`. + +Keep them on separate elements. An early version of the sidebar shared one dot for both signals; the health poller overwrote the env label every 10s and the health-state CSS silently won over the env tone via specificity, so prod ran green. See `src/ui/shell.ts#renderEnvPill` for the current separation. From db4a1c0bb39674ece472f125f18bbead9eb8ad23 Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Thu, 23 Apr 2026 11:50:01 +0600 Subject: [PATCH 10/26] impl task 5 --- CLAUDE.md | 2 +- .../2026-04-23-ui-design-system-refactor.md | 26 +- init-bundle.json | 27 + .../IncomingHl7v2message.ts | 1 + src/index.ts | 11 +- src/mllp/client.ts | 93 +++ src/mllp/mllp-server.ts | 19 +- src/ui/pages/mllp-client.ts | 4 +- src/ui/pages/simulate-sender.ts | 536 ++++++++++++++++++ .../message-control-id.integration.test.ts | 72 +++ .../ui/shell-smoke.integration.test.ts | 4 +- test/unit/mllp/client.test.ts | 67 +++ test/unit/mllp/store-message.test.ts | 53 ++ test/unit/ui/simulate-sender.test.ts | 196 +++++++ 14 files changed, 1085 insertions(+), 26 deletions(-) create mode 100644 src/mllp/client.ts create mode 100644 src/ui/pages/simulate-sender.ts create mode 100644 test/integration/mllp/message-control-id.integration.test.ts create mode 100644 test/unit/mllp/client.test.ts create mode 100644 test/unit/mllp/store-message.test.ts create mode 100644 test/unit/ui/simulate-sender.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index b67ba625..31d8fa32 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 diff --git a/ai/tickets/2026-04-23-ui-design-system-refactor.md b/ai/tickets/2026-04-23-ui-design-system-refactor.md index 4cd79221..a6124587 100644 --- a/ai/tickets/2026-04-23-ui-design-system-refactor.md +++ b/ai/tickets/2026-04-23-ui-design-system-refactor.md @@ -85,21 +85,17 @@ Implement the new warm-paper design system across 5 pages — Dashboard, Inbound **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. -- [ ] **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) -- [ ] 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) -- [ ] Update `storeMessage()` in `src/mllp/mllp-server.ts` to extract MSH-10 from the incoming HL7v2 and set `messageControlId` on the resource -- [ ] 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 -- [ ] 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) -- [ ] 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 6 can import the templates for the scripted demo -- [ ] 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). -- [ ] **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 12 cleanup.) -- [ ] Replace the handler wired to `/simulate-sender` with `handleSimulateSenderPage`; register `POST /simulate-sender/send` that: - 1. **Rewrite MSH-10 to a fresh unique id** in the outbound raw HL7v2 (`SIM-${Date.now()}-${crypto.randomUUID().slice(0,8)}` or equivalent). This is the cornerstone that lets the user resend the same demo template back-to-back and still poll for the specific send. Helper: `rewriteMessageControlId(raw, newId)` with a unit test. - 2. Sends the rewritten raw HL7 via `sendMLLPMessage`; capture ACK - 3. **Polls `IncomingHL7v2Message?message-control-id={newId}&_elements=status&_count=1` every 500ms for up to 3s**, stopping when status is `processed`, `warning`, `code_mapping_error`, or any `*_error`. The first poll may return 0 entries if the listener hasn't written yet — treat 0-entry response as "keep polling," not as error. - 4. Returns JSON `{status: "sent"|"held"|"error", ack: string, messageControlId: string, messageStatus?: string}`. `held` when post-send status is `code_mapping_error`. `error` when status is any `*_error` or when MLLP send threw. `sent` otherwise (including timeout — optimistic: message was received, processing just hasn't caught up). -- [ ] Unit tests: `rewriteMessageControlId` replaces only MSH-10 (segment-delimiter / field-delimiter safe); happy-path send (mocked poll → `processed`); held-for-mapping send (mocked poll → `code_mapping_error`); MLLP-unreachable error path; poll-timeout falls back to `sent`; **duplicate-send test** — call the endpoint twice with the same template body, assert two distinct `messageControlId`s come back and both look up distinct rows -- [ ] 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 +- [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 6 can import the templates for the scripted demo *(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 12 cleanup.) *(moved to `src/mllp/client.ts` — the "or" option; the legacy copy in `mllp-client.ts` stays live until Task 12 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: Dashboard page + scripted demo runner diff --git a/init-bundle.json b/init-bundle.json index e7cea580..7bf1569e 100644 --- a/init-bundle.json +++ b/init-bundle.json @@ -107,6 +107,15 @@ "max": "1", "type": [{ "code": "string" }] }, + { + "id": "IncomingHL7v2Message.messageControlId", + "path": "IncomingHL7v2Message.messageControlId", + "short": "HL7v2 MSH-10 — unique message control id from the sender", + "definition": "MSH-10 of the incoming HL7v2 message. Captured by the MLLP listener on receive so callers (e.g., Simulate Sender) can look up the resource by the id they sent, independent of Aidbox's assigned resource id.", + "min": 0, + "max": "1", + "type": [{ "code": "string" }] + }, { "id": "IncomingHL7v2Message.unmappedCodes", "path": "IncomingHL7v2Message.unmappedCodes", @@ -608,6 +617,24 @@ "expression": "IncomingHL7v2Message.batchTag" } }, + { + "request": { + "method": "PUT", + "url": "SearchParameter/IncomingHL7v2Message-message-control-id" + }, + "resource": { + "resourceType": "SearchParameter", + "id": "IncomingHL7v2Message-message-control-id", + "url": "http://example.org/SearchParameter/IncomingHL7v2Message-message-control-id", + "name": "message-control-id", + "status": "active", + "description": "Search IncomingHL7v2Message by MSH-10 (messageControlId) captured at MLLP ingress", + "code": "message-control-id", + "base": ["IncomingHL7v2Message"], + "type": "string", + "expression": "IncomingHL7v2Message.messageControlId" + } + }, { "request": { "method": "PUT", diff --git a/src/fhir/aidbox-hl7v2-custom/IncomingHl7v2message.ts b/src/fhir/aidbox-hl7v2-custom/IncomingHl7v2message.ts index 35445195..a40132cf 100644 --- a/src/fhir/aidbox-hl7v2-custom/IncomingHl7v2message.ts +++ b/src/fhir/aidbox-hl7v2-custom/IncomingHl7v2message.ts @@ -23,6 +23,7 @@ export interface IncomingHL7v2Message extends DomainResource { date?: string; error?: string; message: string; + messageControlId?: string; patient?: Reference<"Patient">; sendingApplication?: string; sendingFacility?: string; diff --git a/src/index.ts b/src/index.ts index c4694098..34adb53e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,10 @@ import { handleUpdateEntry, handleDeleteEntry, } from "./api/concept-map-entries"; -import { handleMLLPClientPage, sendMLLPTest } from "./ui/pages/mllp-client"; +import { + handleSimulateSenderPage, + handleSimulateSenderSend, +} from "./ui/pages/simulate-sender"; import { handleTaskResolution } from "./api/mapping-tasks"; import { searchLoincCodes, validateLoincCode } from "./code-mapping/terminology-api"; import { processNextMessage as processNextV2ToFhirMessage } from "./v2-to-fhir/processor-service"; @@ -57,8 +60,10 @@ Bun.serve({ "/unmapped-codes": handleMappingTasksPage, "/terminology": handleCodeMappingsPage, "/simulate-sender": { - GET: handleMLLPClientPage, - POST: sendMLLPTest, + GET: handleSimulateSenderPage, + }, + "/simulate-sender/send": { + POST: handleSimulateSenderSend, }, // ========================================================================= diff --git a/src/mllp/client.ts b/src/mllp/client.ts new file mode 100644 index 00000000..43521c04 --- /dev/null +++ b/src/mllp/client.ts @@ -0,0 +1,93 @@ +import * as net from "node:net"; +import { wrapWithMLLP, VT, FS, CR } from "./mllp-server"; + +/** + * Send an HL7v2 message via MLLP and resolve with the listener's ACK body. + * + * Split out of the original UI-scoped mllp-client.ts so non-UI callers (the + * Simulate Sender handler, future automation) can reuse the transport. + */ +export function sendMLLPMessage( + host: string, + port: number, + message: string, +): Promise { + return new Promise((resolve, reject) => { + const client = net.createConnection({ host, port }, () => { + client.write(wrapWithMLLP(message)); + }); + + const timeout = setTimeout(() => { + client.destroy(); + reject(new Error("Connection timeout (10s)")); + }, 10000); + + let buffer = Buffer.alloc(0); + + client.on("data", (data) => { + buffer = Buffer.concat([buffer, data]); + + const startIndex = buffer.indexOf(VT); + if (startIndex === -1) return; + + for (let i = startIndex + 1; i < buffer.length - 1; i++) { + if (buffer[i] === FS && buffer[i + 1] === CR) { + const response = buffer.subarray(startIndex + 1, i).toString("utf-8"); + clearTimeout(timeout); + client.end(); + resolve(response); + return; + } + } + }); + + client.on("error", (err) => { + clearTimeout(timeout); + reject(new Error(`Connection failed: ${err.message}`)); + }); + + client.on("close", () => { + clearTimeout(timeout); + }); + }); +} + +/** + * Replace MSH-10 (message control id) in a raw HL7v2 message. + * + * Preserves the rest of the MSH segment and every following segment byte-for-byte. + * Accepts either `\r`, `\n`, or `\r\n` segment delimiters and a `|` field delimiter + * in MSH (the latter is the HL7v2 hard rule — MSH-1 is always `|`). + * + * Returns the message unchanged if no MSH segment is found. Adds an empty MSH-10 + * slot if the segment is shorter than 10 fields (rare; still produces valid HL7v2). + */ +export function rewriteMessageControlId(raw: string, newId: string): string { + const segmentDelimiter = /\r\n|\r|\n/g; + const segments = raw.split(segmentDelimiter); + const separators = raw.match(segmentDelimiter) ?? []; + + const mshIndex = segments.findIndex((seg) => seg.startsWith("MSH")); + if (mshIndex === -1) return raw; + + const mshSegment = segments[mshIndex]; + if (mshSegment === undefined) return raw; + segments[mshIndex] = replaceMshControlId(mshSegment, newId); + + const pieces: string[] = [segments[0] ?? ""]; + for (let i = 1; i < segments.length; i++) { + pieces.push(separators[i - 1] ?? "\r"); + pieces.push(segments[i] ?? ""); + } + return pieces.join(""); +} + +function replaceMshControlId(mshSegment: string, newId: string): string { + const fields = mshSegment.split("|"); + // Pad to at least 10 fields so indexing is safe. + while (fields.length <= 9) { + fields.push(""); + } + fields[9] = newId; + return fields.join("|"); +} diff --git a/src/mllp/mllp-server.ts b/src/mllp/mllp-server.ts index c6699dda..4c8869d3 100644 --- a/src/mllp/mllp-server.ts +++ b/src/mllp/mllp-server.ts @@ -14,12 +14,20 @@ interface IncomingHL7v2Message { status: string; sendingApplication?: string; sendingFacility?: string; + messageControlId?: string; +} + +interface MSHFields { + messageType: string; + sendingApplication?: string; + sendingFacility?: string; + messageControlId?: string; } /** * Extract MSH fields from HL7v2 message */ -function extractMSHFields(hl7Message: string): { messageType: string; sendingApplication?: string; sendingFacility?: string } { +function extractMSHFields(hl7Message: string): MSHFields { const lines = hl7Message.split(/\r?\n|\r/); const mshLine = lines.find((line) => line.startsWith("MSH")); if (!mshLine) return { messageType: "UNKNOWN" }; @@ -31,11 +39,13 @@ function extractMSHFields(hl7Message: string): { messageType: string; sendingApp // fields[2] = MSH-3 (Sending Application) // fields[3] = MSH-4 (Sending Facility) // fields[8] = MSH-9 (Message Type) + // fields[9] = MSH-10 (Message Control ID) const messageType = (fields[8] || "UNKNOWN").replace("^", "_"); // e.g., ADT^A01 -> ADT_A01 const sendingApplication = fields[2] || undefined; const sendingFacility = fields[3] || undefined; + const messageControlId = fields[9] || undefined; - return { messageType, sendingApplication, sendingFacility }; + return { messageType, sendingApplication, sendingFacility, messageControlId }; } /** @@ -105,7 +115,7 @@ export function wrapWithMLLP(message: string): Buffer { * Store incoming HL7v2 message in Aidbox */ export async function storeMessage(hl7Message: string): Promise { - const { messageType, sendingApplication, sendingFacility } = extractMSHFields(hl7Message); + const { messageType, sendingApplication, sendingFacility, messageControlId } = extractMSHFields(hl7Message); const resource: IncomingHL7v2Message = { resourceType: "IncomingHL7v2Message", @@ -115,6 +125,7 @@ export async function storeMessage(hl7Message: string): Promise { status: "received", sendingApplication, sendingFacility, + messageControlId, }; await aidboxFetch("/fhir/IncomingHL7v2Message", { @@ -122,7 +133,7 @@ export async function storeMessage(hl7Message: string): Promise { body: JSON.stringify(resource), }); - console.log(`[MLLP] Stored message of type: ${messageType} from ${sendingApplication || "unknown"}/${sendingFacility || "unknown"}`); + console.log(`[MLLP] Stored message of type: ${messageType} from ${sendingApplication || "unknown"}/${sendingFacility || "unknown"} (MSH-10=${messageControlId || ""})`); } export type StoreMessageFn = (hl7Message: string) => Promise; diff --git a/src/ui/pages/mllp-client.ts b/src/ui/pages/mllp-client.ts index fe8adcb0..bef859fd 100644 --- a/src/ui/pages/mllp-client.ts +++ b/src/ui/pages/mllp-client.ts @@ -1,7 +1,9 @@ /** * MLLP Client UI Module * - * Displays the MLLP Test Client page. + * @deprecated Superseded by `src/ui/pages/simulate-sender.ts` (Task 5) and + * `src/mllp/client.ts` for the transport. This file is orphaned — no route + * or caller uses it. Deleted in Task 12 along with the old page body. */ import * as net from "node:net"; diff --git a/src/ui/pages/simulate-sender.ts b/src/ui/pages/simulate-sender.ts new file mode 100644 index 00000000..454587da --- /dev/null +++ b/src/ui/pages/simulate-sender.ts @@ -0,0 +1,536 @@ +/** + * Simulate Sender page. + * + * Composer for HL7v2 messages: pick a message type template, tweak the raw + * text, fire it at the local MLLP listener. After the ACK comes back, polls + * IncomingHL7v2Message by MSH-10 to surface the post-send processor verdict + * (sent / held for mapping / error). + */ + +import { aidboxFetch, type Bundle } from "../../aidbox"; +import type { IncomingHL7v2Message } from "../../fhir/aidbox-hl7v2-custom"; +import { sendMLLPMessage, rewriteMessageControlId } from "../../mllp/client"; +import { renderShell } from "../shell"; +import { htmlResponse, getNavData } from "../shared"; +import { escapeHtml } from "../../utils/html"; + +// ============================================================================ +// Message templates +// ============================================================================ +// Lifted verbatim from ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-simulate.jsx:8-56. +// Exported so the Task 6 scripted demo can reuse the same samples without +// duplicating them. + +export interface MessageType { + id: string; + label: string; + desc: string; + tone: "ok" | "warn"; + build: (sender: string) => string[]; +} + +export const MESSAGE_TYPES: MessageType[] = [ + { + id: "ORU^R01", + label: "ORU^R01", + desc: "Lab result · maps cleanly", + tone: "ok", + build: (sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||ORU^R01|MSG1776853125726|P|2.5.1`, + `PID|1||TEST-0041^^^HOSPITAL^MR||TESTPATIENT^GAMMA||19901225|M`, + `PV1|1|O|LAB||||||||||||||||||VN125726`, + `ORC|RE|ORD003|FIL003`, + `OBR|1|ORD003|FIL003|CHEM7^CHEMISTRY PANEL^LOCAL|||20260422142154`, + `OBX|1|NM|2345-7^Glucose [Mass/volume]^LOINC||96|mg/dL|70-200|||F|`, + ], + }, + { + id: "ORU^R01-unknown", + label: "ORU^R01 · unknown code", + desc: "Lab result · contains a code with no LOINC mapping", + tone: "warn", + build: (sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||ORU^R01|MSG1776853125726|P|2.5.1`, + `PID|1||TEST-0041^^^HOSPITAL^MR||TESTPATIENT^GAMMA||19901225|M`, + `PV1|1|O|LAB||||||||||||||||||VN125726`, + `ORC|RE|ORD003|FIL003`, + `OBR|1|ORD003|FIL003|CHEM7^CHEMISTRY PANEL^LOCAL|||20260422142154`, + `OBX|1|NM|UNKNOWN_TEST^Unknown Lab Test^LOCAL||123|mg/dL|70-200|||F|`, + ], + }, + { + id: "ADT^A01", + label: "ADT^A01", + desc: "Admit patient", + tone: "ok", + build: (sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||ADT^A01|MSG1776853125726|P|2.5.1`, + `EVN|A01|20260422142151`, + `PID|1||P12345^^^HOSPITAL^MR||DOE^JANE||19850707|F`, + `PV1|1|I|ICU^1^A||||123456^SMITH^JOHN^^^DR|||CAR`, + ], + }, + { + id: "ADT^A08", + label: "ADT^A08", + desc: "Update patient info", + tone: "ok", + build: (sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||ADT^A08|MSG1776853125726|P|2.5.1`, + `EVN|A08|20260422142151`, + `PID|1||00088412^^^HOSPITAL^MR||GARCIA^MARIA||19910304|F|||123 PINE ST^^AUSTIN^TX^78701`, + `PV1|1|I|MED^2^B`, + ], + }, + { + id: "VXU^V04", + label: "VXU^V04", + desc: "Immunization update · CVX-coded", + tone: "ok", + build: (sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||VXU^V04|MSG1776853125726|P|2.5.1`, + `PID|1||PED-0412^^^HOSPITAL^MR||CHEN^LUCAS||20190511|M`, + `ORC|RE||12345^PEDCLINIC`, + `RXA|0|1|20260422|20260422|88^Influenza, unspecified formulation^CVX|0.5|mL||00^new immunization record|`, + ], + }, + { + id: "ORM^O01", + label: "ORM^O01", + desc: "Order message", + tone: "ok", + build: (sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||ORM^O01|MSG1776853125726|P|2.5.1`, + `PID|1||TEST-0042^^^HOSPITAL^MR||TESTPATIENT^DELTA||19800615|F`, + `ORC|NW|ORD004|||SC||^^^20260422142151^^R`, + `OBR|1|ORD004||CBC^COMPLETE BLOOD COUNT^LOCAL|||20260422142154`, + ], + }, + { + id: "BAR^P01", + label: "BAR^P01", + desc: "Billing account add", + tone: "ok", + build: (sender) => [ + `MSH|^~\\&|${sender}|${sender}_FACILITY|ACME_HOSP|DEST|20260422142151||BAR^P01|MSG1776853125726|P|2.5.1`, + `EVN|P01|20260422142151`, + `PID|1||P12345^^^HOSPITAL^MR||DOE^JANE||19850707|F`, + `PV1|1|I|MED^2^B`, + `ACC|20260422|AUTO|12345|NONE`, + ], + }, +]; + +export const SENDERS = ["ACME_LAB", "StMarys", "CHILDRENS", "billing"] as const; + +// ============================================================================ +// Send endpoint +// ============================================================================ + +const POLL_INTERVAL_MS = 500; +const POLL_TIMEOUT_MS = 3000; + +export type SendOutcome = "sent" | "held" | "error"; + +export interface SendResult { + status: SendOutcome; + ack: string; + messageControlId: string; + messageStatus?: string; + error?: string; +} + +// HL7v2 2.5.1 caps MSH-10 (ST) at 20 characters. Format: `SIM-` (4) + +// base36 epoch-ms (~8) + `-` (1) + 4 hex random = 17 chars. Keeps outgoing +// messages spec-compliant for any downstream receiver that checks length. +function newMessageControlId(): string { + const epoch = Date.now().toString(36); + const suffix = crypto.randomUUID().replace(/-/g, "").slice(0, 4); + return `SIM-${epoch}-${suffix}`; +} + +async function pollForStatus( + messageControlId: string, +): Promise { + const query = `/fhir/IncomingHL7v2Message?message-control-id=${encodeURIComponent(messageControlId)}&_elements=status&_count=1`; + const deadline = Date.now() + POLL_TIMEOUT_MS; + + const terminalStatuses = new Set([ + "processed", + "warning", + "code_mapping_error", + "parsing_error", + "conversion_error", + "sending_error", + ]); + + while (Date.now() < deadline) { + const bundle = await aidboxFetch>(query); + const status = bundle.entry?.[0]?.resource?.status; + if (status && terminalStatuses.has(status)) { + return status; + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + return undefined; +} + +function outcomeFromStatus(status: string | undefined): SendOutcome { + if (status === "code_mapping_error") return "held"; + if (status && status.endsWith("_error")) return "error"; + return "sent"; +} + +export async function sendSimulateMessage(raw: string): Promise { + const messageControlId = newMessageControlId(); + const rewritten = rewriteMessageControlId(raw, messageControlId); + const normalized = rewritten.replace(/\r\n/g, "\r").replace(/\n/g, "\r"); + + const host = process.env.MLLP_HOST || "localhost"; + const port = parseInt(process.env.MLLP_PORT || "2575", 10); + + let ack: string; + try { + ack = await sendMLLPMessage(host, port, normalized); + } catch (error) { + return { + status: "error", + ack: "", + messageControlId, + error: error instanceof Error ? error.message : String(error), + }; + } + + // Polling is best-effort — the ACK already confirms the listener received + // the message. If Aidbox is unreachable mid-poll, we still report the send + // as accepted and let the Inbound page surface the real status later. + let messageStatus: string | undefined; + try { + messageStatus = await pollForStatus(messageControlId); + } catch (error) { + console.error( + `[simulate-sender] poll failed for ${messageControlId}:`, + error instanceof Error ? error.message : error, + ); + } + + return { + status: outcomeFromStatus(messageStatus), + ack, + messageControlId, + messageStatus, + }; +} + +export async function handleSimulateSenderSend(req: Request): Promise { + let body: { raw?: unknown }; + try { + body = (await req.json()) as { raw?: unknown }; + } catch { + return Response.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400 }, + ); + } + + const raw = typeof body.raw === "string" ? body.raw : ""; + if (!raw.trim()) { + return Response.json( + { status: "error", error: "Empty HL7v2 message" }, + { status: 400 }, + ); + } + + const result = await sendSimulateMessage(raw); + return Response.json(result); +} + +// ============================================================================ +// Page handler +// ============================================================================ + +export async function handleSimulateSenderPage(): Promise { + const navData = await getNavData(); + return htmlResponse( + renderShell({ + active: "simulate", + title: "Simulate Sender", + content: renderSimulateBody(), + navData, + }), + ); +} + +function renderSimulateBody(): string { + const typesJson = escapeHtml(JSON.stringify( + MESSAGE_TYPES.map(({ id, label, desc, tone, build }) => ({ + id, + label, + desc, + tone, + template: build("__SENDER__"), + })), + )); + const sendersJson = escapeHtml(JSON.stringify(SENDERS)); + + return ` +
+ ${renderHero()} +
+ ${renderEditorCard()} +
+ ${renderTweaksCard()} + ${renderSendCard()} +
+
+
+ ${renderSimulateScript()} + `; +} + +function renderHero(): string { + return ` +
+
Compose & send · MLLP
+

Simulate Sender

+
Pick a message type, tweak the text, fire it at the listener. Pairs with Inbound to show the whole loop.
+
+ `; +} + +function renderEditorCard(): string { + return ` +
+
+ message.hl7 + HL7v2 · 2.5.1 + +
+ +
+ pipe-delimited · CR or LF endings ok + chars · segments +
+
+ `; +} + +function renderTweaksCard(): string { + return ` +
+
Quick tweaks
+
+
+ + +
+
+ + +
+ + +
+
+
+
+ `; +} + +function renderSendCard(): string { + return ` +
+ + + + + + + + + +
+ `; +} + +function renderSimulateScript(): string { + // Alpine factory — registered globally so the templates above can x-data="simulateEditor(...)". + // The `types` arg has each template pre-built with __SENDER__ placeholder so swapping senders + // is a pure string replace on the client with no server round-trip. + return ` + + `; +} diff --git a/test/integration/mllp/message-control-id.integration.test.ts b/test/integration/mllp/message-control-id.integration.test.ts new file mode 100644 index 00000000..5accf2f3 --- /dev/null +++ b/test/integration/mllp/message-control-id.integration.test.ts @@ -0,0 +1,72 @@ +/** + * Integration test: the MLLP listener stores MSH-10 as messageControlId + * and the corresponding SearchParameter makes it queryable by + * `?message-control-id=`. + * + * Covers both the schema change in init-bundle.json (new field + new + * SearchParameter) and the listener-side write in src/mllp/mllp-server.ts. + */ +import { describe, test, expect } from "bun:test"; +import { aidboxFetch } from "../helpers"; +import { storeMessage } from "../../../src/mllp/mllp-server"; +import type { IncomingHL7v2Message } from "../../../src/fhir/aidbox-hl7v2-custom"; + +interface Bundle { + resourceType: "Bundle"; + entry?: Array<{ resource: T }>; + total?: number; +} + +function uniqueControlId(label: string): string { + return `TEST-${label}-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; +} + +describe("IncomingHL7v2Message message-control-id search", () => { + test("storeMessage persists MSH-10 and SearchParameter finds it by that id", async () => { + const controlId = uniqueControlId("roundtrip"); + const hl7 = [ + `MSH|^~\\&|ACME_LAB|ACME_LAB_FACILITY|ACME_HOSP|DEST|20260422142151||ADT^A01|${controlId}|P|2.5.1`, + `EVN|A01|20260422142151`, + `PID|1||P12345^^^HOSPITAL^MR||DOE^JANE||19850707|F`, + ].join("\r"); + + await storeMessage(hl7); + + const bundle = await aidboxFetch>( + `/fhir/IncomingHL7v2Message?message-control-id=${encodeURIComponent(controlId)}`, + ); + const hits = bundle.entry ?? []; + expect(hits.length).toBe(1); + expect(hits[0]?.resource.messageControlId).toBe(controlId); + expect(hits[0]?.resource.type).toBe("ADT_A01"); + }); + + test("two sends with distinct MSH-10 produce two distinct, retrievable resources", async () => { + const idA = uniqueControlId("dup-a"); + const idB = uniqueControlId("dup-b"); + const baseSegments = [ + `PID|1||P-DUP^^^HOSPITAL^MR||DUPLICATE^TEST||19901225|M`, + ]; + + const mkMessage = (controlId: string) => + [ + `MSH|^~\\&|ACME_LAB|ACME_LAB_FACILITY|ACME_HOSP|DEST|20260422142151||ADT^A01|${controlId}|P|2.5.1`, + ...baseSegments, + ].join("\r"); + + await storeMessage(mkMessage(idA)); + await storeMessage(mkMessage(idB)); + + const resultA = await aidboxFetch>( + `/fhir/IncomingHL7v2Message?message-control-id=${encodeURIComponent(idA)}`, + ); + const resultB = await aidboxFetch>( + `/fhir/IncomingHL7v2Message?message-control-id=${encodeURIComponent(idB)}`, + ); + expect(resultA.entry?.length).toBe(1); + expect(resultB.entry?.length).toBe(1); + expect(resultA.entry?.[0]?.resource.id).not.toBe( + resultB.entry?.[0]?.resource.id, + ); + }); +}); diff --git a/test/integration/ui/shell-smoke.integration.test.ts b/test/integration/ui/shell-smoke.integration.test.ts index 890abdc1..fd243b8b 100644 --- a/test/integration/ui/shell-smoke.integration.test.ts +++ b/test/integration/ui/shell-smoke.integration.test.ts @@ -13,7 +13,7 @@ import { } from "../../../src/ui/pages/messages"; import { handleMappingTasksPage } from "../../../src/ui/pages/mapping-tasks"; import { handleCodeMappingsPage } from "../../../src/ui/pages/code-mappings"; -import { handleMLLPClientPage } from "../../../src/ui/pages/mllp-client"; +import { handleSimulateSenderPage } from "../../../src/ui/pages/simulate-sender"; interface Route { path: string; @@ -48,7 +48,7 @@ const ROUTES: Route[] = [ { path: "/simulate-sender", label: "Simulate Sender", - call: () => handleMLLPClientPage(), + call: () => handleSimulateSenderPage(), }, { path: "/unmapped-codes", diff --git a/test/unit/mllp/client.test.ts b/test/unit/mllp/client.test.ts new file mode 100644 index 00000000..3e99dc0c --- /dev/null +++ b/test/unit/mllp/client.test.ts @@ -0,0 +1,67 @@ +import { describe, test, expect } from "bun:test"; +import { rewriteMessageControlId } from "../../../src/mllp/client"; + +const crlf = (segments: string[]) => segments.join("\r"); +const lf = (segments: string[]) => segments.join("\n"); + +describe("rewriteMessageControlId", () => { + test("replaces MSH-10 in a CR-delimited message", () => { + const raw = crlf([ + "MSH|^~\\&|APP|FAC|RCV|FAC|20260422142151||ADT^A01|ORIG-ID|P|2.5.1", + "PID|1||12345", + ]); + const out = rewriteMessageControlId(raw, "NEW-ID"); + expect(out).toBe( + crlf([ + "MSH|^~\\&|APP|FAC|RCV|FAC|20260422142151||ADT^A01|NEW-ID|P|2.5.1", + "PID|1||12345", + ]), + ); + }); + + test("preserves LF delimiters when present", () => { + const raw = lf([ + "MSH|^~\\&|APP|FAC|RCV|FAC|20260422142151||ADT^A01|ORIG|P|2.5.1", + "PID|1||12345", + ]); + const out = rewriteMessageControlId(raw, "NEW"); + expect(out).toContain("\n"); + expect(out).not.toContain("\r"); + expect(out).toContain("|ADT^A01|NEW|P|2.5.1"); + }); + + test("preserves CRLF delimiters when present", () => { + const raw = "MSH|^~\\&|A|B|C|D|T||ADT^A01|ORIG|P|2.5.1\r\nPID|1"; + const out = rewriteMessageControlId(raw, "NEW"); + expect(out).toBe("MSH|^~\\&|A|B|C|D|T||ADT^A01|NEW|P|2.5.1\r\nPID|1"); + }); + + test("leaves other segments byte-for-byte untouched", () => { + const raw = crlf([ + "MSH|^~\\&|APP|FAC|RCV|FAC|T||ORU^R01|ORIG|P|2.5.1", + "PID|1||P12345^^^HOSPITAL^MR||DOE^JANE||19850707|F", + "OBX|1|NM|UNKNOWN_TEST^Unknown^LOCAL||123|mg/dL|||||F", + ]); + const out = rewriteMessageControlId(raw, "NEW"); + expect(out.split("\r")[1]).toBe("PID|1||P12345^^^HOSPITAL^MR||DOE^JANE||19850707|F"); + expect(out.split("\r")[2]).toBe("OBX|1|NM|UNKNOWN_TEST^Unknown^LOCAL||123|mg/dL|||||F"); + }); + + test("appends empty fields when MSH is shorter than 10 fields", () => { + const raw = "MSH|^~\\&|APP|FAC|RCV|FAC|T"; + const out = rewriteMessageControlId(raw, "NEW"); + expect(out).toBe("MSH|^~\\&|APP|FAC|RCV|FAC|T|||NEW"); + }); + + test("returns input unchanged when MSH segment is absent", () => { + const raw = "PID|1||12345\rOBX|1|NM|test"; + expect(rewriteMessageControlId(raw, "NEW")).toBe(raw); + }); + + test("does not alter field-delimiter characters like ^ inside message-type MSH-9", () => { + const raw = "MSH|^~\\&|APP|FAC|RCV|FAC|T||ORU^R01|ORIG|P|2.5.1"; + const out = rewriteMessageControlId(raw, "NEW"); + expect(out.split("|")[8]).toBe("ORU^R01"); + expect(out.split("|")[9]).toBe("NEW"); + }); +}); diff --git a/test/unit/mllp/store-message.test.ts b/test/unit/mllp/store-message.test.ts new file mode 100644 index 00000000..dc7677fb --- /dev/null +++ b/test/unit/mllp/store-message.test.ts @@ -0,0 +1,53 @@ +import { describe, test, expect, mock, beforeEach } from "bun:test"; + +let capturedBody: string | undefined; + +mock.module("../../../src/aidbox", () => ({ + aidboxFetch: async (_path: string, init?: RequestInit) => { + capturedBody = typeof init?.body === "string" ? init.body : undefined; + return { id: "generated-id" }; + }, +})); + +const { storeMessage } = await import("../../../src/mllp/mllp-server"); + +beforeEach(() => { + capturedBody = undefined; +}); + +describe("storeMessage", () => { + test("captures MSH-10 as messageControlId on the stored resource", async () => { + const hl7 = [ + "MSH|^~\\&|ACME_LAB|ACME_LAB_FACILITY|ACME_HOSP|DEST|T||ORU^R01|MSG-42|P|2.5.1", + "PID|1||P12345", + ].join("\r"); + + await storeMessage(hl7); + + expect(capturedBody).toBeDefined(); + const resource = JSON.parse(capturedBody!); + expect(resource.messageControlId).toBe("MSG-42"); + expect(resource.type).toBe("ORU_R01"); + expect(resource.sendingApplication).toBe("ACME_LAB"); + expect(resource.sendingFacility).toBe("ACME_LAB_FACILITY"); + expect(resource.status).toBe("received"); + }); + + test("stores undefined messageControlId when MSH-10 is empty", async () => { + const hl7 = [ + "MSH|^~\\&|ACME_LAB|FAC|RCV|DEST|T||ADT^A01||P|2.5.1", + "PID|1||P12345", + ].join("\r"); + + await storeMessage(hl7); + const resource = JSON.parse(capturedBody!); + expect(resource.messageControlId).toBeUndefined(); + }); + + test("stores undefined messageControlId when MSH segment is missing", async () => { + await storeMessage("PID|1||P12345\rOBX|1|NM|test"); + const resource = JSON.parse(capturedBody!); + expect(resource.messageControlId).toBeUndefined(); + expect(resource.type).toBe("UNKNOWN"); + }); +}); diff --git a/test/unit/ui/simulate-sender.test.ts b/test/unit/ui/simulate-sender.test.ts new file mode 100644 index 00000000..ece3c664 --- /dev/null +++ b/test/unit/ui/simulate-sender.test.ts @@ -0,0 +1,196 @@ +import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; +// Grab the real implementation BEFORE mock.module replaces the module, so the +// mock factory below can delegate to it. If we imported inside the factory, +// we'd hit a cycle. +import { rewriteMessageControlId as realRewrite } from "../../../src/mllp/client"; + +let mockedMLLPSend: (host: string, port: number, message: string) => Promise; +let mockedAidboxFetch: (path: string, init?: RequestInit) => Promise; +let mllpSendCalls: string[]; +let aidboxFetchCalls: string[]; + +mock.module("../../../src/mllp/client", () => ({ + sendMLLPMessage: (host: string, port: number, message: string) => + mockedMLLPSend(host, port, message), + rewriteMessageControlId: realRewrite, +})); + +mock.module("../../../src/aidbox", () => ({ + aidboxFetch: (path: string, init?: RequestInit) => mockedAidboxFetch(path, init), +})); + +// Import AFTER mocks so the module picks up the stubs. +const { sendSimulateMessage, handleSimulateSenderSend } = await import( + "../../../src/ui/pages/simulate-sender" +); + +const SAMPLE_RAW = [ + "MSH|^~\\&|ACME_LAB|ACME_LAB_FACILITY|ACME_HOSP|DEST|T||ORU^R01|ORIG|P|2.5.1", + "PID|1||P12345", +].join("\n"); + +function jsonRequest(body: unknown): Request { + return new Request("http://localhost:3000/simulate-sender/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + mllpSendCalls = []; + aidboxFetchCalls = []; + mockedMLLPSend = async (_host, _port, message) => { + mllpSendCalls.push(message); + return "MSH|^~\\&|AIDBOX|AIDBOX|T||ACK|1|P|2.4\rMSA|AA|ORIG"; + }; + mockedAidboxFetch = async () => ({ entry: [] }); +}); + +afterEach(() => { + mllpSendCalls = []; + aidboxFetchCalls = []; +}); + +describe("sendSimulateMessage", () => { + test("happy path — status 'processed' maps to 'sent'", async () => { + mockedAidboxFetch = async (path) => { + aidboxFetchCalls.push(path); + return { entry: [{ resource: { status: "processed" } }] }; + }; + + const result = await sendSimulateMessage(SAMPLE_RAW); + expect(result.status).toBe("sent"); + expect(result.messageStatus).toBe("processed"); + expect(result.messageControlId).toMatch(/^SIM-[0-9a-z]+-[a-f0-9]{4}$/); + // HL7v2 2.5.1 caps MSH-10 (ST) at 20 chars. Keep the generator under that. + expect(result.messageControlId.length).toBeLessThanOrEqual(20); + expect(result.ack).toContain("MSA|AA"); + }); + + test("poll-error doesn't fail the send — falls back to 'sent' + undefined messageStatus", async () => { + mockedAidboxFetch = async () => { + throw new Error("Aidbox unreachable"); + }; + + const result = await sendSimulateMessage(SAMPLE_RAW); + expect(result.status).toBe("sent"); + expect(result.messageStatus).toBeUndefined(); + expect(result.ack).toContain("MSA|AA"); + }); + + test("code_mapping_error maps to 'held'", async () => { + mockedAidboxFetch = async () => ({ + entry: [{ resource: { status: "code_mapping_error" } }], + }); + + const result = await sendSimulateMessage(SAMPLE_RAW); + expect(result.status).toBe("held"); + expect(result.messageStatus).toBe("code_mapping_error"); + }); + + test("other *_error statuses map to 'error'", async () => { + mockedAidboxFetch = async () => ({ + entry: [{ resource: { status: "parsing_error" } }], + }); + + const result = await sendSimulateMessage(SAMPLE_RAW); + expect(result.status).toBe("error"); + expect(result.messageStatus).toBe("parsing_error"); + }); + + test("MLLP failure short-circuits with 'error' status and empty ack", async () => { + mockedMLLPSend = async () => { + throw new Error("Connection failed: ECONNREFUSED"); + }; + + const result = await sendSimulateMessage(SAMPLE_RAW); + expect(result.status).toBe("error"); + expect(result.ack).toBe(""); + expect(result.error).toContain("ECONNREFUSED"); + expect(result.messageControlId).toMatch(/^SIM-[0-9a-z]+-[a-f0-9]{4}$/); + }); + + test("poll timeout (no terminal status within budget) optimistically falls back to 'sent'", async () => { + // Always return 'received' — never reaches terminal state. + mockedAidboxFetch = async () => ({ + entry: [{ resource: { status: "received" } }], + }); + + const result = await sendSimulateMessage(SAMPLE_RAW); + expect(result.status).toBe("sent"); + expect(result.messageStatus).toBeUndefined(); + }, 10000); + + test("duplicate send of the same template yields distinct messageControlIds", async () => { + mockedAidboxFetch = async () => ({ + entry: [{ resource: { status: "processed" } }], + }); + + const [a, b] = await Promise.all([ + sendSimulateMessage(SAMPLE_RAW), + sendSimulateMessage(SAMPLE_RAW), + ]); + + expect(a.messageControlId).not.toBe(b.messageControlId); + expect(mllpSendCalls.length).toBe(2); + expect(mllpSendCalls[0]).toContain(a.messageControlId); + expect(mllpSendCalls[1]).toContain(b.messageControlId); + // Neither outbound carries the original MSG1776853125726 id. + expect(mllpSendCalls[0]).not.toContain("|ORIG|"); + expect(mllpSendCalls[1]).not.toContain("|ORIG|"); + }); + + test("polls IncomingHL7v2Message by message-control-id", async () => { + mockedAidboxFetch = async (path) => { + aidboxFetchCalls.push(path); + return { entry: [{ resource: { status: "processed" } }] }; + }; + + const result = await sendSimulateMessage(SAMPLE_RAW); + expect(aidboxFetchCalls[0]).toContain( + `/fhir/IncomingHL7v2Message?message-control-id=${encodeURIComponent(result.messageControlId)}`, + ); + expect(aidboxFetchCalls[0]).toContain("_count=1"); + expect(aidboxFetchCalls[0]).toContain("_elements=status"); + }); +}); + +describe("handleSimulateSenderSend", () => { + test("rejects empty body with 400", async () => { + mockedAidboxFetch = async () => ({ + entry: [{ resource: { status: "processed" } }], + }); + const response = await handleSimulateSenderSend(jsonRequest({ raw: "" })); + expect(response.status).toBe(400); + const body = (await response.json()) as { status: string; error?: string }; + expect(body.status).toBe("error"); + expect(body.error).toContain("Empty"); + }); + + test("rejects non-JSON body with 400", async () => { + const request = new Request("http://localhost:3000/simulate-sender/send", { + method: "POST", + body: "not json", + }); + const response = await handleSimulateSenderSend(request); + expect(response.status).toBe(400); + }); + + test("returns send result as JSON on success", async () => { + mockedAidboxFetch = async () => ({ + entry: [{ resource: { status: "processed" } }], + }); + + const response = await handleSimulateSenderSend(jsonRequest({ raw: SAMPLE_RAW })); + expect(response.status).toBe(200); + const body = (await response.json()) as { + status: string; + messageControlId: string; + ack: string; + }; + expect(body.status).toBe("sent"); + expect(body.messageControlId).toMatch(/^SIM-[0-9a-z]+-[a-f0-9]{4}$/); + expect(body.ack).toContain("MSA"); + }); +}); From 965144f95fb398cacf87b192fafe2c2241540c80 Mon Sep 17 00:00:00 2001 From: Sergey Zaborovsky Date: Thu, 23 Apr 2026 12:24:31 +0600 Subject: [PATCH 11/26] small improvements for Simulate Sender page --- docs/developer-guide/ui-architecture.md | 1 + src/ui/pages/simulate-sender.ts | 319 +++++++++++++----------- 2 files changed, 172 insertions(+), 148 deletions(-) diff --git a/docs/developer-guide/ui-architecture.md b/docs/developer-guide/ui-architecture.md index 667fa791..595af634 100644 --- a/docs/developer-guide/ui-architecture.md +++ b/docs/developer-guide/ui-architecture.md @@ -172,3 +172,4 @@ When you add a new module under `src/ui/`, keep its responsibility tight. A page - **Two sidebar entries with the same NavKey.** The shell picks the first match; the second is silently inactive. `NavKey` is a string union — adding a new page means both extending the union *and* listing the entry in `buildNavGroups`. - **`hot` count modifier is opt-in.** `NavLink.hot: true` paints the count badge accent; without it, a non-zero count renders neutral. Use it to signal "the user should notice this" (e.g. unmapped codes pending) — not just "count > 0." - **Chrome DevTools MCP silently inactive.** If an approval round-tripped but screenshots still error, restart Claude Code; MCP servers are loaded at session start. +- **Alpine `:style` string replaces the static `style` attribute.** `:style="'color:var(--warn)'"` (string) overwrites the element's entire static `style="..."` — any `margin`, `padding`, `font-size` you set inline will silently disappear. Use the object form instead: `:style="{ color: ... }"` **merges** with the static style. Verify with `getComputedStyle` if a visual change doesn't apply. diff --git a/src/ui/pages/simulate-sender.ts b/src/ui/pages/simulate-sender.ts index 454587da..41c4efe5 100644 --- a/src/ui/pages/simulate-sender.ts +++ b/src/ui/pages/simulate-sender.ts @@ -17,118 +17,133 @@ import { escapeHtml } from "../../utils/html"; // ============================================================================ // Message templates // ============================================================================ -// Lifted verbatim from ai/tickets/ui-refactoring/hl7v2-v2/project/design/page-simulate.jsx:8-56. -// Exported so the Task 6 scripted demo can reuse the same samples without -// duplicating them. +// Restored from `main` branch's MLLP test client so the samples keep their +// clinical detail and realistic sender identities; grouped by message type +// for a more usable - - -
-
- - -
- - -
+
Sample message
+
+ + +
+ +
@@ -371,12 +398,12 @@ function renderSendCard(): string {
+
+
+ drag-drop files, folders, or a .zip anywhere on the page +
+ +
+
+ `; +} + function renderHero(): string { return `
Compose & send · MLLP
-

Simulate Sender

-
Pick a message type, tweak the text, fire it at the listener. Pairs with Inbound to show the whole loop.
+
+
+

Simulate Sender

+
Pick a message type, tweak the text, fire it at the listener. Pairs with Inbound to show the whole loop.
+
+ + +
+
`; } @@ -326,9 +628,9 @@ function renderEditorCard(): string { return `
- message.hl7 - HL7v2 · 2.5.1 - + + HL7v2 · +