# 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 `<PeekModal>` ad-hoc. Mitigation: the `<DrillStackMount>` portal at the root of `App.tsx` is the only place peeks render; mounting `<PeekModal>` 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 `<DrillStackMount>` 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 `<DrillStackMount>` 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.
