# Sub-project 21 — Universal Drill-Down: Design Spec **Date:** 2026-06-21 **Status:** Draft (awaiting user review) **Branch:** `universal-drilldown` **Aesthetic direction:** Reuse the existing dashboard / drawer aesthetic (Radix Dialog + dim overlay, accent-tinted hover affordance). No new visual tokens. ## 1. Scope Every interactive surface in the Cyclone UI becomes drillable. Clicking an entity reference (claim id, patient, provider, payer, batch, ack, activity event) opens a contextual view — either a full-record drawer (slides in from the right) or a slim peek modal (centered), depending on whether the entity is a first-class drillable record or a cross-reference. The goal is **"dial down to anything"**: from the dashboard, from any list row, from inside any drawer, the operator can reach the underlying record with one click without losing their place. **In scope:** - New `PeekModal` primitive (centered modal) for cross-reference drills - Reuse / refactor of the existing right-side `DrillDrawer` pattern for full-entity records - New `DrillStackProvider` context to manage a max-2-level stack (one drawer + one peek) - Hover-reveal affordance (pointer + accent tint + trailing chevron) on every clickable cell - Per-surface drill map (every click target → its destination) - 1 new backend read endpoint (`/api/payers/{payer_id}/summary`) plus an extended response shape on the existing `/api/providers/{npi}` (adds `recent_claims[]` and `recent_activity[]` arrays). The rest of the work is frontend wiring. **Out of scope (deferred):** - **Patient peek modal** — patient names are PHI; a peek would surface sensitive fields. Patient-name clicks instead navigate to `/claims?patient=…` filtered list (no new endpoint needed; uses existing `/api/claims?patient_name=…`). - **Write actions from peeks** — peeks are read-only. Edit / resubmit / acknowledge actions remain on the full drawer / inbox lane. - **Mobile bottom-sheet variant** — desktop-only for v1. Mobile fallback: peeks become centered modals at full width with the same max-height cap. - **Customizing peek fields** — peek content is fixed per surface. The existing drawer already covers "I want everything." - **KPI breakdown peeks** — dashboard KPI tiles navigate to filtered `/claims` (the user's picked data-strategy was "fetch fresh on click"; navigation keeps the dashboard fast). - **Auto-suggest next drill** — no "you might also want to see…" hints. ## 2. Locked decisions ### 2.1 Component decomposition | Component | Role | |---|---| | `DrillDrawer.tsx` | Right-side slide-over for full-record drills (claim, remit, batch, provider, ack). Refactor of today's `ClaimDrawer` pattern into a shared shell that all entity drawers mount inside. | | `DrillDrawerHeader.tsx` | Drawer header: eyebrow, title, close button, optional primary action (e.g. "Download 837" on the ClaimDrawer). | | `PeekModal.tsx` | Centered Radix Dialog for cross-reference drills (payer, activity event, validation issue, single-row tables like Inbox rows, Batch diff rows). Smaller (≤ 480px), no keyboard j/k nav (single record), closes on Esc + click-outside. | | `PeekModalHeader.tsx` | Eyebrow + title + close button. | | `DrillStackProvider.tsx` | React context + zustand-backed store. Owns the stack of `[{ kind: 'drawer' | 'peek', key: string, payload?: unknown }]`. Exposes `open()`, `closeTop()`, `closeAll()`. Enforces max 2 levels. | | `DrillableCell.tsx` | Tiny wrapper that adds the hover-reveal affordance (pointer + accent tint + trailing chevron) and the click handler. Used inside tables, KPI tiles, activity feeds. | | `ProviderDrawer.tsx` | New full-entity drawer for `/api/providers/{npi}`. Tabs: Overview (NPI + address + counts) / Claims (top 10 recent claims + "View all →" link) / Activity (top 10 recent activity + "View all →" link). | | `AckDrawer.tsx` | New full-entity drawer for `/api/acks/{id}` (reuses the existing `GET /api/acks/{id}` endpoint). | | `RemitDrawer.tsx` | New full-entity drawer for `/api/remittances/{id}`. Already documented as "deferred to a follow-up" in the SP4 claim-drawer spec; SP21 ships it. | | `useDrillStack.ts` | Hook wrapper around `DrillStackProvider`'s store, with selector helpers for "is this drawer open?", "open this drawer", "the active peek payload". | | `PayerPeekContent.tsx` | Payer peek body (backend-fed): name, claim count, billed, received, denial rate, "view all claims →" link. | | `ValidationRulePeekContent.tsx` | Validation-rule peek body (static lookup, no backend call): rule code, severity, full description, X12 spec reference if any. Opened from ClaimDrawer validation issue rows. | All live under `src/components/drill/` (new shared folder) plus `src/components/ProviderDrawer/`, `src/components/AckDrawer/`, `src/components/RemitDrawer/`. ### 2.2 Drill map (per surface) The complete click → destination table. Each row is one click target on one surface. "Stack position" describes where the destination lives in the drill stack at the moment of the click. | Surface | Click target | Destination | Stack position | |---|---|---|---| | Dashboard · KPI: Claims | tile | navigate → `/claims` | — | | Dashboard · KPI: Billed | tile | navigate → `/claims?sort=-billedAmount` | — | | Dashboard · KPI: Received | tile | navigate → `/claims?sort=-receivedAmount` | — | | Dashboard · KPI: Pending AR | tile | navigate → `/claims?status=submitted,pending` | — | | Dashboard · KPI: Denial rate | tile | navigate → `/claims?status=denied` | — | | Dashboard · Top providers | any row | `ProviderDrawer(?provider=NPI)` | top drawer | | Dashboard · Recent denials | any row | `DrillDrawer(claim, ?claim=ID)` | top drawer | | Dashboard · Recent activity | any event | routed by event kind: `claim_*` → claim drawer, `remit_received` → remit drawer, `provider_added` → provider drawer | top drawer | | Claims · table row | row body | `DrillDrawer(claim, ?claim=ID)` | top drawer | | Claims · claim id cell | id | `DrillDrawer(claim, ?claim=ID)` | top drawer | | Claims · patient cell | name | navigate → `/claims?patient=NAME` | — | | Claims · provider cell | provider name + NPI | `ProviderDrawer(?provider=NPI)` | top drawer | | Claims · payer cell | payer name | `PeekModal(payer)` | top peek | | Remittances · table row | row body | `RemitDrawer(?remit=ID)` (replaces the inline CAS expand) | top drawer | | Remittances · claim id cell | id | `DrillDrawer(claim, ?claim=ID)` | top drawer | | Remittances · payer cell | payer name | `PeekModal(payer)` | top peek | | Batches · table row | row body | `BatchDetail` drawer (?batch=ID) | top drawer | | Providers · directory card | card body | `ProviderDrawer(?provider=NPI)` | top drawer | | Activity log · event row | row body | routed by event kind (same as Dashboard · Recent activity) | top drawer | | 999 ACKs · table row | row body | `AckDrawer(?ack=ID)` | top drawer | | 999 ACKs · Download 999 button | button | (unchanged — file download) | — | | Inbox · Rejected row | row body | `DrillDrawer(claim, ?claim=ID)` | top drawer | | Inbox · Payer-Rejected row | row body | `DrillDrawer(claim, ?claim=ID)` | top drawer | | Inbox · Candidates row | row body | `RemitDrawer(?remit=ID)` | top drawer | | Inbox · Unmatched claim row | row body | `DrillDrawer(claim, ?claim=ID)` | top drawer | | Inbox · Unmatched remit row | row body | `RemitDrawer(?remit=ID)` | top drawer | | Inbox · Done today row | row body | routed by row kind (claim vs remit) | top drawer | | Reconciliation · claim button | button | (unchanged — select for match) | — | | Reconciliation · claim card body | body | `DrillDrawer(claim, ?claim=ID)` | top drawer (navigates to `/claims?claim=ID`) | | Reconciliation · remit card body | body | `RemitDrawer(?remit=ID)` | top drawer (navigates to `/remittances?remit=ID`) | | Batch diff · claim id (added) | id | `DrillDrawer(claim, ?claim=ID)` | top drawer | | Batch diff · claim id (removed) | id | `DrillDrawer(claim, ?claim=ID)` (will 404 if removed — show distinct not-found state) | top drawer | | Batch diff · claim id (changed) | id | `DrillDrawer(claim, ?claim=ID)` | top drawer | | Upload · streamed claim card | header / chevron | (unchanged — inline expand) | — | | Upload · streamed claim card | "See claim in detail →" link (new) | `DrillDrawer(claim, ?claim=ID)` (only enabled if the claim id is in the persisted batch list, else disabled with tooltip) | top drawer | | ClaimDrawer · payer name (inside drawer) | payer name | `PeekModal(payer)` | peek on top of drawer | | ClaimDrawer · provider name (inside drawer) | provider name | `ProviderDrawer(?provider=NPI)` (replaces drawer) | top drawer (replaces) | | ClaimDrawer · matched-remit id | id | `RemitDrawer(?remit=ID)` (replaces drawer) | top drawer (replaces) | | ClaimDrawer · validation issue row | rule code | `PeekModal(validation issue)` | peek on top of drawer | **Nesting rules** (enforced by `DrillStackProvider`): 1. Max 2 levels: one drawer at the bottom + at most one peek on top. Opening a third drill replaces the top peek (same kind) or pops the peek (different kind). 2. Clicking inside a peek to open a drawer: closes the peek first, opens the drawer (no drawer-over-peek). 3. Esc closes the top entry (peek or drawer). Browser back closes the top drawer (URL pops). Browser back while only peeks are open: no-op (peek stack isn't URL-persisted). ### 2.3 Backend endpoints **New endpoint:** | Method | Path | Purpose | |---|---|---| | `GET` | `/api/payers/{payer_id}/summary` | Aggregate snapshot for a payer: `{ payer_id, name, claim_count, billed_total, received_total, denial_rate, top_providers: [{npi, count}] }`. Computed on read from the existing `claims` + `remittances` tables. Cached for 60s; invalidated on `claim_written` / `remittance_written` pubsub events. | **Extended endpoint (existing, response shape widened):** | Method | Path | Change | |---|---|---| | `GET` | `/api/providers/{npi}` | Response gains `recent_claims: ClaimSummary[]` (top 10 by `submission_date DESC`) and `recent_activity: ActivityEvent[]` (top 10 by `ts DESC`). Backwards-compatible: existing clients ignore the new arrays. | **Reused as-is:** - `GET /api/claims/{id}` — already exists (SP4). Used by `DrillDrawer(claim)`. - `GET /api/remittances/{id}` — already exists (SP3). Used by `RemitDrawer`. - `GET /api/batches/{id}` — already exists. Used by `BatchDetail`. - `GET /api/providers/{npi}` — already exists (SP9). Used by `ProviderDrawer` Overview tab AND Claims / Activity tabs (via the newly extended arrays). - `GET /api/config/payers/{payer_id}` — already exists (SP9). Used by the payer peek header (the payer name). - `GET /api/acks/{id}` — already exists (SP3). Used by `AckDrawer`. **Implementation notes:** - Add the one new handler in `backend/src/cyclone/api_routers/payers.py`. - Payer summary cache lives in a module-level `lru_cache` with a TTL guard (60s); invalidated on a `claim_written` / `remittance_written` pubsub event. - The new endpoint inherits the existing security middleware stack (SP19): `127.0.0.1` bind, body limit, rate limit, security headers. - The extended `/api/providers/{npi}` response is bounded — only 10 + 10 entries per category — so the existing payload cap (per SP19 body limit, default 50 MB) is comfortably respected. ### 2.4 Visual design tokens No new tokens. Reuse: - `--surface` / `--surface-dim` for drawer + dim overlay - `--accent` for hover tint (the same `--accent` used by today's selected-row indicator + nav-active) - `border-border/60` for the peek modal border - Existing `display mono` / `eyebrow` typography for peek headers - `animate-fade-in` / `animate-fade-in-up` for peek/drawer enter Peek modal sizing: ``` max-width: 480px (or 90vw on narrow viewports) padding: 20px border: 1px solid var(--border) shadow: shadow-2xl border-radius: rounded-xl (matches today's Radix Dialog) ``` Hover affordance CSS: ```css .drillable { cursor: pointer; transition: background-color 120ms ease; } .drillable:hover { background-color: hsl(var(--accent) / 0.08); } .drillable:hover::after { content: "›"; margin-left: 6px; color: hsl(var(--accent)); font-weight: 600; } ``` The trailing chevron is injected via `::after` so `DrillableCell` doesn't need to compose a child element. Disabled cells (e.g. the "See claim in detail" link in Upload when the claim isn't persisted) drop the affordance and the click handler. ### 2.5 Interaction patterns **Open (drawer):** - Click target → URL updates to `?{entity}={id}` via `history.pushState` (or `replaceState` if same entity type, to avoid history pollution). - Drawer slides in from right, 240ms ease-out (same as today's ClaimDrawer). - Background page dims to 60% opacity. **Open (peek):** - Click target → zustand stack updated; peek fades in centered, 180ms. - Background dims to 75% (more aggressive than the drawer's 60%, to differentiate). - No URL change (peek stack is ephemeral). **Open (peek on top of drawer):** - Click target inside drawer body → zustand stack gains one entry. - Peek renders above drawer; drawer stays in place, dims to 75%. - Drawer's existing keyboard nav (j/k, ?) is suspended while a peek is on top. **Close:** - Esc → pops the top stack entry. If peek was top, peek closes (drawer underneath is interactive again). If only a drawer, URL strips `?{entity}=`. - Click on dim overlay → same as Esc. - Browser back → pops URL drawer only (no peek history). **Loading:** - Drawer: existing `Skeleton` + `ErrorState` patterns reused. - Peek: a 6-line shimmer skeleton, max 280px height. Fetch errors collapse the peek and show an inline error toast (peek is small enough that an inline state would look broken). **Errors:** - 404 on a drawer → existing `ErrorState` "not found" branch (per drawer component). - 404 on a peek → toast: "Couldn't find that record. It may have been removed." and peek auto-closes after 1.5s. - Network error → toast with Retry button. **Keyboard:** - Drawer nav: j/k, ?, esc (unchanged from today). - Peek nav: esc, Tab cycles focusable elements inside the peek body. No j/k (single record, no list). - Global `?` cheatsheet: extended to list peek keyboard hints (currently lists only drawer hints). ### 2.6 URL state contract - `/claims` — list, no drawer. - `/claims?claim=CLM-114` — list with drawer for CLM-114. - `/remittances?remit=R-9876` — list with remit drawer. - `/providers?provider=1881068062` — directory with provider drawer. - `/batches?batch=BATCH-…` — list with batch drawer. - `/acks?ack=42` — list with ack drawer. - `/activity?event=evt-uuid` — list with activity event drawer. Each entity-drawer type has its own URL state hook following the existing per-page pattern (`useDrawerUrlState` for Claims, `useBatchDrawerUrlState` for Batches, plus a new `useProviderDrawerUrlState` for Providers, `useRemitDrawerUrlState` for Remittances, `useAckDrawerUrlState` for Acks). All hooks read & write the same `?{entity}={id}` convention. **Each drawer is mounted by the page that owns it** — clicking a cross-page entity from inside another page navigates to that entity's owning page with `?{entity}={id}` set (soft fade, no full reload). The opening source's filter state (e.g. `/remittances?status=posted`) is preserved when navigating back via browser-back. Peeks never trigger navigation. ### 2.7 Frontend file layout ``` src/ ├── components/ │ ├── drill/ # NEW shared folder │ │ ├── DrillDrawer.tsx │ │ ├── DrillDrawerHeader.tsx │ │ ├── PeekModal.tsx │ │ ├── PeekModalHeader.tsx │ │ ├── DrillStackProvider.tsx │ │ ├── DrillStackMount.tsx # the actual portal mount + z-index mgmt │ │ ├── DrillableCell.tsx │ │ ├── PayerPeekContent.tsx │ │ ├── ValidationRulePeekContent.tsx │ │ ├── DrillStackProvider.test.tsx │ │ ├── DrillableCell.test.tsx │ │ ├── PeekModal.test.tsx │ │ └── index.ts # barrel export │ ├── ProviderDrawer/ # NEW │ │ ├── ProviderDrawer.tsx │ │ ├── ProviderOverview.tsx │ │ ├── ProviderRecentClaims.tsx # top-10 mini-table (data from extended /providers/{npi}) │ │ ├── ProviderRecentActivity.tsx # top-10 mini-feed (data from extended /providers/{npi}) │ │ └── index.ts │ ├── RemitDrawer/ # NEW │ │ ├── RemitDrawer.tsx │ │ ├── RemitHeader.tsx │ │ ├── ClaimPaymentsTable.tsx │ │ ├── CasAdjustmentsPanel.tsx │ │ ├── RemitPartiesGrid.tsx │ │ └── index.ts │ ├── AckDrawer/ # NEW │ │ ├── AckDrawer.tsx │ │ ├── AckHeader.tsx │ │ ├── SegmentStatusList.tsx │ │ └── index.ts │ └── ClaimDrawer/ # existing — refactor header to use DrillDrawerHeader ├── hooks/ │ ├── useDrillStack.ts # NEW (zustand-backed ephemeral peek stack) │ ├── usePayerSummary.ts # NEW │ ├── useProviderDrawerUrlState.ts # NEW (?provider=) │ ├── useRemitDrawerUrlState.ts # NEW (?remit=) │ └── useAckDrawerUrlState.ts # NEW (?ack=) ├── lib/ │ └── api.ts # +api.getPayerSummary(id); /api/providers/{npi} response widened in backend └── pages/ # wire drills per the drill map; refactor existing useDrawerUrlState usages to follow the per-drawer convention ├── Claims.tsx ├── Remittances.tsx ├── Dashboard.tsx ├── Batches.tsx ├── Providers.tsx ├── Acks.tsx ├── ActivityLog.tsx ├── Reconciliation.tsx ├── BatchDiff.tsx ├── Upload.tsx └── Inbox.tsx ``` ### 2.8 Testing targets **Frontend (Vitest + React Testing Library):** - `DrillStackProvider.test.tsx` — max-2-level rule, peek-over-drawer, drawer-over-peek prevention, esc closes top, closeAll resets stack - `DrillableCell.test.tsx` — renders chevron on hover, fires onClick, disabled state hides chevron + blocks click - `PeekModal.test.tsx` — opens via context, closes on esc, closes on click-outside, 404 → toast + auto-close, network error → retry toast - `ProviderDrawer.test.tsx` — Overview tab shows NPI + address + counts; Claims tab paginates; Activity tab paginates; j/k nav (if claim ids in scope) - `RemitDrawer.test.tsx` — header summary, claim payments table, CAS panel, payer cell opens payer peek - `AckDrawer.test.tsx` — header (source batch, ack code), per-segment status list, download button (unchanged) - `PayerPeekContent.test.tsx` — loading skeleton, populated peek, "view all claims" link href - `ValidationRulePeekContent.test.tsx` — known rule (R050_diagnosis_present) renders full description + X12 reference; unknown rule shows fallback copy - Page tests (per-page drill map coverage): one test per drill target verifying the click routes correctly **Backend (pytest):** - `test_payer_summary_endpoint` — happy path, cache hit/miss, invalidation on `claim_written` - `test_provider_detail_extended_response` — existing fields unchanged; new `recent_claims` + `recent_activity` arrays populated (10 each, sorted); empty arrays when the provider has no claims/activity - `test_provider_detail_extended_response_backwards_compat` — clients that ignore the new arrays don't break; old fields identical to SP9 response shape **Target counts:** ~25 new frontend tests, ~3 new backend tests. All 249 existing frontend tests + 409 existing backend tests must stay green. ### 2.9 Smoke test (end-of-SP21) 1. Start backend (`backend/.venv/bin/python -m cyclone serve`). 2. Start frontend (`npm run dev`). 3. Open `http://localhost:5173/`. 4. **Dashboard:** click a KPI tile → page navigates to `/claims` with the right filter. 5. **Dashboard:** click a top-providers row → provider drawer opens from the right. 6. **Dashboard:** click a recent-denials row → claim drawer opens. 7. **Dashboard:** click a recent-activity event of kind `claim_paid` → claim drawer opens for the underlying claim. 8. **Claims:** click a claim row → claim drawer opens (already worked, regression check). 9. **Claims:** hover the patient name cell → cursor + tint + chevron appear. Click → navigates to `/claims?patient=NAME`. 10. **Claims:** hover the provider cell → affordance. Click → provider drawer opens. 11. **Claims:** hover the payer cell → affordance. Click → payer peek modal opens (centered). 12. **Claims:** with the claim drawer open, click the payer name inside the drawer → peek opens on top of the drawer. Esc → peek closes; drawer still there. Esc again → drawer closes. 13. **Remittances:** click a remit row → remit drawer opens (was: inline expand only). 14. **Remittances:** click the claim id cell → claim drawer opens. 15. **Providers:** click a card → provider drawer opens. 16. **Acks:** click a row → ack drawer opens (was: only the Download button worked). 17. **Activity log:** click a `remit_received` event → remit drawer opens. 18. **Inbox:** click a rejected row → claim drawer opens (was: no-op). 19. **Inbox:** click a candidates row → remit drawer opens (was: no-op). 20. **Reconciliation:** click the body of an unmatched claim (not the select button) → navigates to `/claims?claim=ID` with the claim drawer open. 21. **Batch diff:** click an added claim id → claim drawer opens. 22. **Upload:** click a streamed claim's "See claim in detail" link → claim drawer opens (if persisted). 23. Browser back from any drawer state → drawer closes. 24. Commit empty `smoke: end-to-end SP21 (universal drilldown) flow passes`. ## 3. Risk areas 1. **Stack explosion.** Without an enforcement point, it's easy to write code that opens a drawer from inside a peek from inside a drawer. The `DrillStackProvider` API only exposes `open` (which respects the max-2 rule) and `closeTop`. There's no `openOnTopOf` — the stack is implicit from context. Risk: future contributors might bypass the provider and mount `` ad-hoc. Mitigation: the `` portal at the root of `App.tsx` is the only place peeks render; mounting `` outside it renders nothing. 2. **URL vs ephemeral state confusion.** Drawers persist in URL; peeks don't. If a user opens a peek, copies the URL, and pastes it later, the peek is gone. Acceptable trade-off — peeks are exploratory; drawers are shareable. 3. **Cross-page drawer navigation.** Clicking a claim id on `/remittances` navigates to `/claims?claim=…`. The user lands on the claims page, not on the remittances page with the claim drawer overlaid. This is intentional — the claim drawer lives on `/claims`. Side effect: the user's remittances filter context is lost on nav. Mitigation: the nav is a soft fade + the URL preserves `?status=` etc. on `/claims` so the user can navigate back with browser back. 4. **Drawer nav state across page navigation.** When the user clicks a claim id on `/remittances`, lands on `/claims?claim=…`, and presses browser back, they go back to `/remittances` (the remits filter is restored). The claim drawer closes. Tested in smoke step 23. 5. **Inbox row click vs checkbox click.** Inbox rows have multi-select checkboxes. Row body click opens the drawer; checkbox click toggles selection. The `DrillableCell` wrapper goes around the row body, NOT the checkbox. Tested in `Inbox.test.tsx`. 6. **Payer summary cache staleness.** The 60s cache + pubsub invalidation is best-effort. Worst case: a payer summary is up to 60s stale after a `claim_written`. Acceptable for v1. 7. **Patient name in URL.** Navigating to `/claims?patient=John%20Doe` puts the patient name in the URL bar. Cyclone is local-only (127.0.0.1) per project context, so this is acceptable for v1. Flagged for SP22 if multi-operator / networked expansion happens. The existing PII scrubber (SP18) only scrubs log lines, not URL bars. 8. **Drawer dim overlay z-index.** With a peek over a drawer, three z-index layers: page (z=0), drawer overlay (z=40), peek overlay (z=50). The `` portal renders peeks in a dedicated DOM node at `z-50`; the existing drawer overlays at `z-40` continue to work. Tested in `PeekModal.test.tsx` (asserts peek is rendered above drawer overlay). ## 4. Migration / rollout - Single feature branch `universal-drilldown` cut from `main`. - Worktree at `.worktrees/universal-drilldown/`, following SP1-3 pattern. - DB migrations: **none** (uses existing tables: `claims`, `remittances`, `batches`, `activity_events`, `providers`, `payers`, `acks`). - No new dependencies (Radix Dialog + zustand already in `package.json`). - Phasing within the branch (each PR is shippable independently, each closes its slice of smoke steps): 1. **PR1 — Foundation primitives + first 3 surfaces:** `DrillStackProvider` + `DrillableCell` + `PeekModal` primitives, the App-level `` portal, the hover affordance CSS, the extended `/api/providers/{npi}` response. Wire Dashboard KPI navigation + Dashboard Top providers drill + Dashboard Recent denials drill. Backend: `/api/payers/{payer_id}/summary` (the actual provider-overview data lives in the extended `/api/providers/{npi}` response — no separate provider endpoints needed). (Smoke steps 4-6.) 2. **PR2 — `ProviderDrawer` + activity event routing:** Build the ProviderDrawer (Overview tab only in this PR; Claims/Activity tabs come in PR3 with the recent_claims/recent_activity payload rendering). Wire Dashboard Recent activity for `claim_*` events (the activity → entity mapping lives in a small `eventKindToEntity()` helper introduced here). Wire Providers directory page + Claims provider cell. (Smoke steps 7, 10, 15.) 3. **PR3 — `ProviderDrawer` tabs + remaining event routing:** Add the Claims and Activity tabs to ProviderDrawer, using the extended `/api/providers/{npi}` payload. Wire Dashboard Recent activity for `remit_received` and `provider_added` event kinds (still routed via the PR2 `eventKindToEntity()` helper; `provider_added` routes to ProviderDrawer, `remit_received` will route to the PR4 RemitDrawer — until PR4 lands it shows a graceful "Remit drawer coming in PR4" toast). (Smoke step 7 finishes — all 4 event kinds route correctly by PR4 merge.) 4. **PR4 — `RemitDrawer` + 4 surfaces:** Build the RemitDrawer. Wire Remittances row click (replaces inline CAS expand), Inbox candidates + unmatched remit rows, Activity log `remit_received` event click, Remittances claim-id cell, ClaimDrawer matched-remit link. Now `eventKindToEntity()`'s `remit_received` branch resolves to a real drawer. (Smoke steps 13, 14, 17, 19.) 5. **PR5 — `AckDrawer` + final 8 surfaces:** Build the AckDrawer. Wire Acks row, Inbox rejected + payer_rejected + done_today rows, Reconciliation body click, Batch diff claim id, Upload "See claim in detail" link, ClaimDrawer payer peek (`PayerPeekContent`), ClaimDrawer validation-rule peek (`ValidationRulePeekContent`), refactor existing `ClaimDrawer` to use the `DrillDrawer` shell + `DrillDrawerHeader`. (Smoke steps 8, 9, 11, 12, 16, 18, 20, 21, 22, 23.) - Merge to main after final PR + smoke pass; cleanup worktree + branch.