Files
cyclone/docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md
T

363 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.