From b6ca171209aba3f2e2dc5b677132c71d8b381612 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 21 Jun 2026 11:16:30 -0600 Subject: [PATCH] =?UTF-8?q?spec(SP21):=20universal=20drilldown=20design=20?= =?UTF-8?q?=E2=80=94=20drawers=20+=20peek=20modals=20everywhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-21-cyclone-universal-drilldown-design.md | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md diff --git a/docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md b/docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md new file mode 100644 index 0000000..a07fe77 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md @@ -0,0 +1,360 @@ +# 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) +- 4 new backend read endpoints (`/api/providers/{npi}/claims`, `/api/providers/{npi}/activity`, `/api/payers/{payer_id}/summary`, `/api/activity/{event_id}`); 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 / Claims / Activity. | +| `AckDrawer.tsx` | New full-entity drawer for `/api/activity/{event_id}`? No — for `/api/acks/{id}` (reuses existing `GET /api/acks/{id}`). | +| `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: name, claim count, billed, received, denial rate, "view all claims →" link. | +| `ActivityPeekContent.tsx` | Activity event peek body: kind, timestamp, entity id link, optional payload preview (e.g. claim ids touched). | + +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 | `PeekModal(claim)` | top peek | +| Reconciliation · remit card body | body | `PeekModal(remit)` | top peek | +| 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:** + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/api/providers/{npi}/claims` | Paginated claims for a provider. Mirrors `/api/claims` filter shape but pre-scoped to the provider. Limit/offset/sort/order all honored. | +| `GET` | `/api/providers/{npi}/activity` | Paginated activity for a provider. Mirrors `/api/activity` filter shape pre-scoped to the provider. | +| `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. | +| `GET` | `/api/activity/{event_id}` | One activity event with its full payload. Used by the ActivityPeekContent. | + +**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. +- `GET /api/config/payers/{payer_id}` — already exists (SP9). Used by the payer peek header. +- `GET /api/acks/{id}` — already exists (SP3). Used by `AckDrawer`. + +**Implementation notes:** + +- Add the four new handlers in `backend/src/cyclone/api_routers/` (split: providers gets two new methods, payers gets one, activity gets one). +- Payer summary cache lives in a module-level `lru_cache` with a TTL guard (60s); invalidated on a `claim_written` / `remittance_written` pubsub event. +- All four new endpoints inherit the existing security middleware stack (SP19): `127.0.0.1` bind, body limit, rate limit, security headers. + +### 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 page's `useDrawerUrlState` hook handles its own `?{entity}=` param. Opening a different entity-type drawer from inside a page navigates to the page that owns that entity-type drawer (e.g. clicking a payer name on `/claims` opens a payer peek — no nav; but clicking a claim id on `/remittances` opens the claim drawer, which is the `/claims` page's drawer — this case keeps the operator on `/remittances` and uses a global drawer pattern). To keep things simple in v1: **all entity drawers mount from the page that owns them**, and clicking a cross-page entity from inside another page **navigates to that entity's owning page with `?{entity}=` set**. The page transition is a soft fade (no full reload). 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 +│ │ ├── ActivityPeekContent.tsx +│ │ ├── DrillStackProvider.test.tsx +│ │ ├── DrillableCell.test.tsx +│ │ ├── PeekModal.test.tsx +│ │ └── index.ts # barrel export +│ ├── ProviderDrawer/ # NEW +│ │ ├── ProviderDrawer.tsx +│ │ ├── ProviderOverview.tsx +│ │ ├── ProviderClaims.tsx # paginated mini-table +│ │ ├── ProviderActivity.tsx # paginated mini-feed +│ │ └── index.ts +│ ├── RemitDrawer/ # NEW (some files exist per tree) +│ │ ├── 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 +│ ├── useProviderClaims.ts # NEW +│ ├── useProviderActivity.ts # NEW +│ ├── usePayerSummary.ts # NEW +│ ├── useActivityEvent.ts # NEW +│ └── useProviderDetail.ts # NEW (wraps existing api.getProvider) +├── lib/ +│ └── api.ts # +api.getProviderClaims, getProviderActivity, getPayerSummary, getActivityEvent +└── pages/ # wire drills per the drill map; replace ?claim= etc. state hooks with the shared useDrillStack hooks + ├── 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 +- `ActivityPeekContent.test.tsx` — empty payload, claim_* payload, remit_received payload +- Page tests (per-page drill map coverage): one test per drill target verifying the click routes correctly + +**Backend (pytest):** + +- `test_provider_claims_endpoint` — filters correctly, paginates, 404 on unknown NPI +- `test_provider_activity_endpoint` — filters by kind, paginates, 404 on unknown NPI +- `test_payer_summary_endpoint` — happy path, cache hit/miss, invalidation on `claim_written` +- `test_activity_event_endpoint` — happy path, 404 on unknown event id + +**Target counts:** ~25 new frontend tests, ~6 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) → claim peek modal opens. +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. The existing ClaimDrawer spec already redacts patient names from logs but not from URL. Mitigation: when navigating from a patient cell, hash the name (sha256 first 8 chars) before putting it in the URL; the Claims page filter accepts the hash and looks up by hash. Alternative: just use the full name in URL — Cyclone is local-only (127.0.0.1) per project context. Going with the full name in URL for v1; flag for SP22 if multi-operator expansion happens. +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 one smoke step): + 1. **PR1:** `DrillStackProvider` + `DrillableCell` + `PeekModal` primitives. Wire Dashboard KPI navigation + Dashboard Top providers drill. Backend: `/api/payers/{payer_id}/summary`. (Smoke steps 4-6.) + 2. **PR2:** `ProviderDrawer` (full drawer, Overview tab only). Wire Dashboard Recent denials, Dashboard Recent activity, Providers page, Claims provider cell. Backend: `/api/providers/{npi}/claims`, `/api/providers/{npi}/activity`. (Smoke steps 7, 10, 15.) + 3. **PR3:** `RemitDrawer`. Wire Remittances row click, Inbox candidates + unmatched remit rows, Activity `remit_received` events, Remittances claim-id cell, ClaimDrawer matched-remit link. (Smoke steps 13, 14, 17, 19.) + 4. **PR4:** `AckDrawer`. Wire Acks row, ClaimDrawer payer peek. Backend: `/api/activity/{event_id}` (also used by step 5). (Smoke steps 12, 16.) + 5. **PR5:** Wire the remaining surfaces: Activity log event click (PR4's backend), Inbox rejected + payer_rejected + done_today rows, Reconciliation body click, Batch diff claim id, Upload "See claim in detail" link. Refactor existing `ClaimDrawer` to use `DrillDrawer` shell. (Smoke steps 8, 9, 11, 18, 20, 21, 22, 23.) +- Merge to main after final PR + smoke pass; cleanup worktree + branch.