# Universal Drill-Down Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make every interactive surface in the Cyclone UI drillable — click any entity reference (claim, patient, provider, payer, batch, ack, activity event) to open a contextual view (full-record drawer or cross-reference peek modal).

**Architecture:** Hybrid modal pattern: right-side `DrillDrawer` for full-entity records (claim, remit, batch, provider, ack), centered `PeekModal` for cross-references (payer, validation rule). A `DrillStackProvider` (zustand-backed) owns the ephemeral peek stack and enforces a max-2-level nesting rule (one drawer + one peek). Drawers sync to URL (`?{entity}={id}`); peeks don't. Hover-reveal affordance (pointer + accent tint + trailing chevron via `::after`) on every clickable cell. 1 new backend endpoint (`/api/payers/{payer_id}/summary`) + 1 extended endpoint (`/api/providers/{npi}` gains `recent_claims[]` + `recent_activity[]`).

**Tech Stack:** React 18, Radix Dialog (existing), zustand (existing), TanStack Query (existing), Tailwind (existing), Vitest + React Testing Library (existing), FastAPI + SQLAlchemy (existing, backend), pytest (existing, backend).

**Spec:** `docs/superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md` — read fully before starting any phase.

**Worktree setup (one-time):**

```bash
cd /Users/openclaw/dev/cyclone
git worktree add .worktrees/universal-drilldown -b universal-drilldown main
cd .worktrees/universal-drilldown
npm install
# backend deps unchanged
```

All commits happen in this worktree. Merge each PR to main via fast-forward when its phase ends.

---

## Phase 1 — Foundation + 3 surfaces + 1 backend

**Closes smoke steps:** 4 (Dashboard KPI navigation), 5 (Dashboard Top providers drill), 6 (Dashboard Recent denials drill), and PR1 of the §4 phasing.

### Task 1.1: DrillStackProvider (zustand store + React context)

**Files:**
- Create: `src/components/drill/DrillStackProvider.tsx`
- Create: `src/components/drill/DrillStackProvider.test.tsx`

The provider owns the ephemeral peek stack (the URL-backed drawer is owned per-page). Single zustand store keyed by the `DrillStackProvider` component so test isolation is straightforward.

- [ ] **Step 1: Write the failing test**

```tsx
// src/components/drill/DrillStackProvider.test.tsx
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import {
  DrillStackProvider,
  useDrillStack,
} from "@/components/drill/DrillStackProvider";

function wrapper({ children }: { children: React.ReactNode }) {
  return <DrillStackProvider>{children}</DrillStackProvider>;
}

describe("DrillStackProvider", () => {
  it("starts with an empty stack", () => {
    const { result } = renderHook(() => useDrillStack(), { wrapper });
    expect(result.current.stack).toEqual([]);
    expect(result.current.openPeek).toBeInstanceOf(Function);
    expect(result.current.closeTop).toBeInstanceOf(Function);
  });

  it("openPeek pushes one entry; closeTop pops it", () => {
    const { result } = renderHook(() => useDrillStack(), { wrapper });
    act(() => result.current.openPeek({ kind: "payer", payerId: "SKCO0" }));
    expect(result.current.stack).toEqual([
      { kind: "payer", payerId: "SKCO0" },
    ]);
    act(() => result.current.closeTop());
    expect(result.current.stack).toEqual([]);
  });

  it("caps the stack at 2 levels (peek over peek is rejected)", () => {
    const { result } = renderHook(() => useDrillStack(), { wrapper });
    act(() => result.current.openPeek({ kind: "payer", payerId: "A" }));
    // The hook only governs peeks; a drawer at the bottom is owned by
    // the page (URL-backed). For this unit test we simulate "drawer
    // present" via the provider's `hasDrawer` prop and assert peek+peek
    // becomes just the most recent peek.
    act(() => result.current.openPeek({ kind: "rule", rule: "R050" }));
    expect(result.current.stack).toHaveLength(1);
    expect(result.current.stack[0]).toEqual({ kind: "rule", rule: "R050" });
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npm test -- DrillStackProvider.test.tsx`
Expected: FAIL with "Cannot find module".

- [ ] **Step 3: Implement DrillStackProvider**

```tsx
// src/components/drill/DrillStackProvider.tsx
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { create } from "zustand";

export type PeekPayload =
  | { kind: "payer"; payerId: string }
  | { kind: "rule"; rule: string };

interface DrillState {
  stack: PeekPayload[];
  openPeek: (p: PeekPayload) => void;
  closeTop: () => void;
  closeAll: () => void;
}

// One zustand store per provider instance (factory) so multiple
// providers (e.g. in tests) don't share state.
function makeStore() {
  return create<DrillState>((set) => ({
    stack: [],
    openPeek: (p) =>
      set((s) => ({
        // Cap at 2 levels total: one drawer + one peek. When called and
        // the stack already has one peek, replace it.
        stack: s.stack.length >= 1 ? [p] : [p],
      })),
    closeTop: () => set((s) => ({ stack: s.stack.slice(0, -1) })),
    closeAll: () => set({ stack: [] }),
  }));
}

type StoreApi = ReturnType<typeof makeStore>;

const Ctx = createContext<StoreApi | null>(null);

export function DrillStackProvider({ children }: { children: ReactNode }) {
  // useMemo so the store instance is stable across renders.
  const store = useMemo(makeStore, []);
  return <Ctx.Provider value={store}>{children}</Ctx.Provider>;
}

export function useDrillStack() {
  const store = useContext(Ctx);
  if (!store) throw new Error("useDrillStack must be used within DrillStackProvider");
  // Subscribe to just `stack` so consumers re-render only on stack
  // changes (not on every state update).
  const stack = store((s) => s.stack);
  return {
    stack,
    openPeek: store.getState().openPeek,
    closeTop: store.getState().closeTop,
    closeAll: store.getState().closeAll,
  };
}
```

Note: `zustand` is already a dependency (`zustand` 4.5.x in package.json). The `create` import path is `import { create } from "zustand"` for v4 — confirm with `cat node_modules/zustand/package.json | grep version` if unsure.

- [ ] **Step 4: Run test to verify it passes**

Run: `npm test -- DrillStackProvider.test.tsx`
Expected: 3 tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/components/drill/DrillStackProvider.tsx src/components/drill/DrillStackProvider.test.tsx
git commit -m "feat(drill): DrillStackProvider — zustand-backed peek stack with 2-level cap"
```

### Task 1.2: DrillableCell (hover affordance wrapper)

**Files:**
- Create: `src/components/drill/DrillableCell.tsx`
- Create: `src/components/drill/DrillableCell.test.tsx`

- [ ] **Step 1: Write the failing test**

```tsx
// src/components/drill/DrillableCell.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { DrillableCell } from "@/components/drill/DrillableCell";

describe("DrillableCell", () => {
  it("renders children, applies hover affordance classes, calls onClick", () => {
    const onClick = vi.fn();
    render(
      <DrillableCell onClick={onClick}>
        <span>CLM-114</span>
      </DrillableCell>,
    );
    const btn = screen.getByRole("button");
    expect(btn).toHaveClass("drillable");
    fireEvent.click(btn);
    expect(onClick).toHaveBeenCalledOnce();
  });

  it("disabled state hides affordance and blocks click", () => {
    const onClick = vi.fn();
    render(
      <DrillableCell onClick={onClick} disabled>
        <span>unavailable</span>
      </DrillableCell>,
    );
    const btn = screen.getByRole("button");
    expect(btn).toBeDisabled();
    expect(btn).not.toHaveClass("drillable");
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npm test -- DrillableCell.test.tsx`
Expected: FAIL with "Cannot find module".

- [ ] **Step 3: Implement DrillableCell**

```tsx
// src/components/drill/DrillableCell.tsx
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";

interface Props {
  children: ReactNode;
  onClick: () => void;
  disabled?: boolean;
  /** Optional aria-label; defaults to the visible text content. */
  ariaLabel?: string;
}

/**
 * Wrap any clickable cell with hover-reveal affordance:
 *   cursor: pointer + accent background tint + trailing "›" chevron,
 *   applied via the `drillable` class on hover (see `src/index.css`
 *   in Task 1.4).
 *
 * Renders as a <button> so it gets keyboard activation (Enter/Space)
 * for free. Disabled cells render a plain span without the affordance.
 */
export function DrillableCell({ children, onClick, disabled, ariaLabel }: Props) {
  if (disabled) {
    return <span className="text-muted-foreground">{children}</span>;
  }
  return (
    <button
      type="button"
      onClick={onClick}
      aria-label={ariaLabel}
      className={cn(
        "drillable",
        "inline-flex items-center gap-0 rounded-sm border-0 bg-transparent p-0 text-left",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
      )}
    >
      {children}
    </button>
  );
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npm test -- DrillableCell.test.tsx`
Expected: 2 tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/components/drill/DrillableCell.tsx src/components/drill/DrillableCell.test.tsx
git commit -m "feat(drill): DrillableCell — hover-reveal button wrapper"
```

### Task 1.3: PeekModal (centered Radix Dialog)

**Files:**
- Create: `src/components/drill/PeekModal.tsx`
- Create: `src/components/drill/PeekModal.test.tsx`

- [ ] **Step 1: Write the failing test**

```tsx
// src/components/drill/PeekModal.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PeekModal } from "@/components/drill/PeekModal";

describe("PeekModal", () => {
  it("renders title and body when open; close button fires onClose", () => {
    const onClose = vi.fn();
    render(
      <PeekModal
        open
        onClose={onClose}
        eyebrow="Payer"
        title="CO Medicaid"
      >
        <p>1,247 claims</p>
      </PeekModal>,
    );
    expect(screen.getByText("Payer")).toBeInTheDocument();
    expect(screen.getByText("CO Medicaid")).toBeInTheDocument();
    expect(screen.getByText("1,247 claims")).toBeInTheDocument();
    fireEvent.click(screen.getByRole("button", { name: /close/i }));
    expect(onClose).toHaveBeenCalledOnce();
  });

  it("renders nothing when closed", () => {
    const { container } = render(
      <PeekModal open={false} onClose={() => {}} title="hidden">
        <p>should not appear</p>
      </PeekModal>,
    );
    expect(container).toBeEmptyDOMElement();
  });

  it("esc key closes", () => {
    const onClose = vi.fn();
    render(
      <PeekModal open onClose={onClose} title="t">
        <p>x</p>
      </PeekModal>,
    );
    fireEvent.keyDown(document.body, { key: "Escape" });
    expect(onClose).toHaveBeenCalledOnce();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npm test -- PeekModal.test.tsx`
Expected: FAIL with "Cannot find module".

- [ ] **Step 3: Implement PeekModal**

```tsx
// src/components/drill/PeekModal.tsx
import { Dialog, DialogContent } from "@/components/ui/dialog";
import type { ReactNode } from "react";

interface Props {
  open: boolean;
  onClose: () => void;
  eyebrow?: string;
  title: string;
  children: ReactNode;
}

/**
 * Centered peek modal — used for cross-reference drills (payer,
 * validation rule, etc.). Smaller than the right-side Drawer
 * (max-width: 480px); closes on Esc, backdrop click, and the X button.
 * No keyboard j/k nav — single record.
 */
export function PeekModal({ open, onClose, eyebrow, title, children }: Props) {
  return (
    <Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
      <DialogContent
        className="max-w-[480px] w-[90vw]"
        aria-describedby={undefined}
      >
        {eyebrow ? (
          <div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
            {eyebrow}
          </div>
        ) : null}
        <h2 className="text-[18px] font-semibold tracking-tight">{title}</h2>
        <div className="mt-2">{children}</div>
      </DialogContent>
    </Dialog>
  );
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npm test -- PeekModal.test.tsx`
Expected: 3 tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/components/drill/PeekModal.tsx src/components/drill/PeekModal.test.tsx
git commit -m "feat(drill): PeekModal — centered Radix Dialog with eyebrow + title"
```

### Task 1.4: Hover affordance CSS + App-level integration

**Files:**
- Modify: `src/index.css` (append `.drillable` block at end)
- Modify: `src/App.tsx` (wrap routes in `<DrillStackProvider>`)

- [ ] **Step 1: Append hover affordance CSS**

Edit `src/index.css`. Find the last line (the file ends with `@tailwind utilities;` or similar). After it, append:

```css
/* Universal drill-down affordance — applied by DrillableCell. */
.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;
}
```

- [ ] **Step 2: Wrap App routes in DrillStackProvider**

In `src/App.tsx`, find the root JSX returned by the App component (likely a `<BrowserRouter>` or `<QueryClientProvider>` wrapping routes). Wrap the existing tree with `<DrillStackProvider>`:

```tsx
// src/App.tsx — add this import at the top
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";

// Then wrap the existing return. Example before:
//   return <BrowserRouter>...</BrowserRouter>;
// After:
//   return (
//     <DrillStackProvider>
//       <BrowserRouter>...</BrowserRouter>
//     </DrillStackProvider>
//   );
```

- [ ] **Step 3: Verify app still boots + existing tests pass**

Run: `npm test -- --run`
Expected: All 249+ frontend tests still green. (If a test broke because it expected a specific root DOM shape, the fix is in that test, not here.)

Run: `npm run typecheck`
Expected: 0 errors.

- [ ] **Step 4: Smoke the app at runtime**

Open `http://localhost:5173` in browser (frontend dev server is already running from the prior session, or restart with `npm run dev`). No visible change expected — the primitives exist but no page uses them yet. Verify no console errors.

- [ ] **Step 5: Commit**

```bash
git add src/index.css src/App.tsx
git commit -m "feat(drill): hover affordance CSS + App wrapped in DrillStackProvider"
```

### Task 1.5: Backend — `/api/payers/{payer_id}/summary`

**Files:**
- Modify: `backend/src/cyclone/api_routers/payers.py` (or wherever `/api/config/payers` lives — find with `grep -rn 'payers/{payer_id}' backend/src/cyclone/api_routers/`)
- Create: `backend/tests/test_payer_summary.py`

- [ ] **Step 1: Write the failing test**

```python
# backend/tests/test_payer_summary.py
from fastapi.testclient import TestClient

def test_payer_summary_happy_path(client: TestClient, seeded_db):
    """Seeded db has at least one claim for CO Medicaid (payer_id='SKCO0')."""
    resp = client.get("/api/payers/SKCO0/summary")
    assert resp.status_code == 200
    data = resp.json()
    assert data["payer_id"] == "SKCO0"
    assert "claim_count" in data
    assert "billed_total" in data
    assert "received_total" in data
    assert "denial_rate" in data
    assert data["claim_count"] >= 1


def test_payer_summary_unknown_payer_returns_404(client: TestClient):
    resp = client.get("/api/payers/DOES_NOT_EXIST/summary")
    assert resp.status_code == 404


def test_payer_summary_caches_then_invalidates(client: TestClient, seeded_db):
    """A second call within 60s returns the cached payload."""
    resp1 = client.get("/api/payers/SKCO0/summary")
    resp2 = client.get("/api/payers/SKCO0/summary")
    assert resp1.json() == resp2.json()
```

The `client` and `seeded_db` fixtures already exist in `backend/tests/conftest.py` (per the SP3-SP20 spec pattern). If `seeded_db` doesn't exist yet, write a minimal one in this test file that ingests one 837 + one 835 from `backend/tests/fixtures/minimal_837p.txt` and `minimal_835.txt`.

- [ ] **Step 2: Run test to verify it fails**

Run: `cd backend && .venv/bin/pytest tests/test_payer_summary.py -v`
Expected: FAIL with 404 (endpoint doesn't exist).

- [ ] **Step 3: Implement the endpoint**

Find the existing payers router (likely `backend/src/cyclone/api_routers/payers.py`). Add:

```python
from functools import lru_cache
from time import monotonic

_SUMMARY_TTL_S = 60.0
_summary_cache: dict[str, tuple[float, dict]] = {}

@router.get("/api/payers/{payer_id}/summary")
def get_payer_summary(payer_id: str, store = Depends(get_store)) -> dict:
    now = monotonic()
    cached = _summary_cache.get(payer_id)
    if cached and (now - cached[0]) < _SUMMARY_TTL_S:
        return cached[1]

    claims = store.iter_claims(payer_id=payer_id)
    remits = store.iter_remittances(payer_id=payer_id)
    if not claims and not remits:
        raise HTTPException(404, f"Payer {payer_id} not found")

    billed = sum(c.billed_amount for c in claims)
    received = sum(c.received_amount for c in claims)
    denied = sum(1 for c in claims if c.status == "denied")
    denial_rate = (denied / len(claims)) if claims else 0.0

    provider_counts: dict[str, int] = {}
    for c in claims:
        npi = c.provider_npi
        if npi:
            provider_counts[npi] = provider_counts.get(npi, 0) + 1
    top_providers = [
        {"npi": npi, "count": count}
        for npi, count in sorted(provider_counts.items(), key=lambda kv: -kv[1])[:5]
    ]

    payload = {
        "payer_id": payer_id,
        "name": claims[0].payer_name if claims else (remits[0].payer_name if remits else payer_id),
        "claim_count": len(claims),
        "billed_total": billed,
        "received_total": received,
        "denial_rate": denial_rate,
        "top_providers": top_providers,
    }
    _summary_cache[payer_id] = (now, payload)
    return payload
```

- [ ] **Step 4: Register pubsub invalidation**

In the file that wires the EventBus (`backend/src/cyclone/api.py` lifespan or `backend/src/cyclone/pubsub.py`), register listeners that invalidate the cache:

```python
# In the lifespan handler or wherever subscribers are wired
event_bus.subscribe("claim_written", lambda evt: _summary_cache.pop(_payer_id_from_claim(evt), None))
event_bus.subscribe("remittance_written", lambda evt: _summary_cache.pop(_payer_id_from_remit(evt), None))
```

Adjust the exact event payload shape to match the existing pubsub events (read `backend/src/cyclone/pubsub.py` to confirm — likely has `entity_id` and a `payload` dict carrying the payer_id).

- [ ] **Step 5: Run test to verify it passes**

Run: `cd backend && .venv/bin/pytest tests/test_payer_summary.py -v`
Expected: 3 tests pass.

- [ ] **Step 6: Commit**

```bash
git add backend/src/cyclone/api_routers/payers.py backend/src/cyclone/pubsub.py backend/tests/test_payer_summary.py
git commit -m "feat(api): GET /api/payers/{payer_id}/summary with 60s cache + pubsub invalidation"
```

### Task 1.6: Backend — extend `/api/providers/{npi}` response

**Files:**
- Modify: the file containing `GET /api/providers/{npi}` (likely `backend/src/cyclone/api_routers/providers.py` or similar — find with `grep -rn 'providers/{npi}' backend/src/`)
- Modify: `src/types/index.ts` (extend `Provider` interface)
- Create: `backend/tests/test_provider_extended_response.py`

- [ ] **Step 1: Write the failing test**

```python
# backend/tests/test_provider_extended_response.py
from fastapi.testclient import TestClient

def test_provider_detail_includes_recent_claims(client: TestClient, seeded_db):
    """The extended response gains a recent_claims array (top 10)."""
    npi = "1881068062"  # Montrose from config/payers.yaml
    resp = client.get(f"/api/config/providers/{npi}")
    assert resp.status_code == 200
    data = resp.json()
    assert "recent_claims" in data
    assert isinstance(data["recent_claims"], list)
    assert len(data["recent_claims"]) <= 10

def test_provider_detail_includes_recent_activity(client: TestClient, seeded_db):
    npi = "1881068062"
    resp = client.get(f"/api/config/providers/{npi}")
    assert resp.status_code == 200
    data = resp.json()
    assert "recent_activity" in data
    assert isinstance(data["recent_activity"], list)
    assert len(data["recent_activity"]) <= 10

def test_provider_detail_backwards_compat(client: TestClient, seeded_db):
    """All SP9 fields still present; new arrays don't break the contract."""
    npi = "1881068062"
    resp = client.get(f"/api/config/providers/{npi}")
    data = resp.json()
    for key in ("npi", "name", "taxId", "address", "city", "state", "zip", "phone", "claimCount", "outstandingAr"):
        assert key in data, f"missing field {key}"
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd backend && .venv/bin/pytest tests/test_provider_extended_response.py -v`
Expected: FAIL with "KeyError: recent_claims" or 422 (response validation).

- [ ] **Step 3: Extend the endpoint**

In the providers router handler, after the existing provider lookup, append:

```python
# After: provider = store.get_provider(npi) ... return provider_dict
recent_claims = sorted(
    store.iter_claims(provider_npi=npi),
    key=lambda c: c.submission_date,
    reverse=True,
)[:10]
recent_activity = sorted(
    store.iter_activity(provider_npi=npi),
    key=lambda a: a.ts,
    reverse=True,
)[:10]

return {
    **provider_dict,
    "recent_claims": [_claim_summary(c) for c in recent_claims],
    "recent_activity": [_activity_summary(a) for a in recent_activity],
}
```

The `_claim_summary` and `_activity_summary` helpers project the full ORM models into the slim `ClaimSummary` and `ActivityEvent` shapes the UI already consumes (look at `src/types/index.ts` for the field names — `id`, `submissionDate`, `billedAmount`, etc.).

- [ ] **Step 4: Extend the frontend type**

In `src/types/index.ts`, add to the `Provider` interface:

```ts
export interface Provider {
  // ...existing fields...
  recent_claims?: ClaimSummary[];     // populated by extended /api/config/providers/{npi}
  recent_activity?: ActivityEvent[];  // populated by extended /api/config/providers/{npi}
}
```

- [ ] **Step 5: Run tests**

Run: `cd backend && .venv/bin/pytest tests/test_provider_extended_response.py -v`
Expected: 3 tests pass.

Run: `npm run typecheck`
Expected: 0 errors.

- [ ] **Step 6: Commit**

```bash
git add backend/src/cyclone/api_routers/providers.py backend/tests/test_provider_extended_response.py src/types/index.ts
git commit -m "feat(api): extend /api/config/providers/{npi} with recent_claims + recent_activity"
```

### Task 1.7: Frontend — PayerPeekContent + api.getPayerSummary hook

**Files:**
- Create: `src/hooks/usePayerSummary.ts`
- Modify: `src/lib/api.ts` (add `getPayerSummary` method)
- Create: `src/components/drill/PayerPeekContent.tsx`
- Create: `src/components/drill/PayerPeekContent.test.tsx`

- [ ] **Step 1: Add api.getPayerSummary**

In `src/lib/api.ts`, after the existing `getProvider` method, add:

```ts
export interface PayerSummary {
  payer_id: string;
  name: string;
  claim_count: number;
  billed_total: number;
  received_total: number;
  denial_rate: number;
  top_providers: Array<{ npi: string; count: number }>;
}

export async function getPayerSummary(payerId: string): Promise<PayerSummary> {
  if (!isConfigured) throw notConfiguredError();
  const res = await fetch(`${BASE_URL}/api/payers/${encodeURIComponent(payerId)}/summary`);
  if (!res.ok) throw await asApiError(res);
  return res.json();
}
```

Look at the existing `getProvider` / `asApiError` pattern in the file and match it exactly — the snippet above is illustrative.

- [ ] **Step 2: Add usePayerSummary hook**

```ts
// src/hooks/usePayerSummary.ts
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";

export function usePayerSummary(payerId: string | null) {
  return useQuery({
    queryKey: ["payer-summary", payerId],
    queryFn: () => api.getPayerSummary(payerId as string),
    enabled: payerId !== null,
    staleTime: 60 * 1000,
    retry: 1,
  });
}
```

- [ ] **Step 3: Write the failing PayerPeekContent test**

```tsx
// src/components/drill/PayerPeekContent.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { PayerPeekContent } from "@/components/drill/PayerPeekContent";

describe("PayerPeekContent", () => {
  it("renders loading skeleton while fetching", () => {
    render(<PayerPeekContent payerId="SKCO0" loading />);
    // The Skeleton component is the existing one; check that numbers
    // aren't rendered yet.
    expect(screen.queryByText(/claims/i)).not.toBeInTheDocument();
  });

  it("renders summary stats when data loads", () => {
    render(
      <PayerPeekContent
        payerId="SKCO0"
        loading={false}
        data={{
          payer_id: "SKCO0",
          name: "CO Medicaid",
          claim_count: 1247,
          billed_total: 548000,
          received_total: 521000,
          denial_rate: 0.042,
          top_providers: [{ npi: "1881068062", count: 184 }],
        }}
      />,
    );
    expect(screen.getByText("CO Medicaid")).toBeInTheDocument();
    expect(screen.getByText(/1,247/)).toBeInTheDocument();
    expect(screen.getByText("$548,000")).toBeInTheDocument();
    expect(screen.getByText("4.2%")).toBeInTheDocument();
    // "View all claims" link goes to /claims?payer=SKCO0
    const link = screen.getByRole("link", { name: /view all claims/i });
    expect(link).toHaveAttribute("href", "/claims?payer=SKCO0");
  });
});
```

- [ ] **Step 4: Implement PayerPeekContent**

```tsx
// src/components/drill/PayerPeekContent.tsx
import { Link } from "react-router-dom";
import { Skeleton } from "@/components/ui/skeleton";
import { fmt } from "@/lib/format";
import { usePayerSummary } from "@/hooks/usePayerSummary";
import type { PayerSummary } from "@/lib/api";

interface Props {
  payerId: string;
}

/**
 * Peek body for a payer — shows aggregate stats. Uses usePayerSummary
 * to fetch; the parent PeekModal owns open/close.
 */
export function PayerPeekContent({ payerId }: Props) {
  const { data, isLoading } = usePayerSummary(payerId);
  if (isLoading || !data) {
    return (
      <div className="space-y-2">
        <Skeleton variant="row" />
        <Skeleton variant="row" />
        <Skeleton variant="row" />
      </div>
    );
  }
  return <Loaded payer={data} />;
}

function Loaded({ payer }: { payer: PayerSummary }) {
  return (
    <div className="space-y-4">
      <div className="grid grid-cols-2 gap-3">
        <Stat label="Claims" value={fmt.num(payer.claim_count)} />
        <Stat label="Denial rate" value={fmt.pct(payer.denial_rate)} />
        <Stat label="Billed" value={fmt.usd(payer.billed_total)} accent="accent" />
        <Stat label="Received" value={fmt.usd(payer.received_total)} accent="success" />
      </div>
      {payer.top_providers.length > 0 ? (
        <div>
          <div className="eyebrow mb-1.5">Top providers</div>
          <ul className="text-[12.5px] space-y-1">
            {payer.top_providers.slice(0, 3).map((p) => (
              <li key={p.npi} className="flex justify-between">
                <span className="mono">{p.npi}</span>
                <span className="mono text-muted-foreground">{fmt.num(p.count)} claims</span>
              </li>
            ))}
          </ul>
        </div>
      ) : null}
      <Link
        to={`/claims?payer=${encodeURIComponent(payer.payer_id)}`}
        className="text-[12.5px] text-accent hover:underline"
      >
        View all claims →
      </Link>
    </div>
  );
}

function Stat({
  label,
  value,
  accent,
}: {
  label: string;
  value: string;
  accent?: "accent" | "success" | "warning";
}) {
  const color =
    accent === "success"
      ? "text-[hsl(var(--success))]"
      : accent === "warning"
      ? "text-[hsl(var(--warning))]"
      : accent === "accent"
      ? "text-accent"
      : "text-foreground";
  return (
    <div>
      <div className="eyebrow">{label}</div>
      <div className={`display mono text-[16px] mt-1 ${color}`}>{value}</div>
    </div>
  );
}
```

- [ ] **Step 5: Run test to verify it passes**

Run: `npm test -- PayerPeekContent.test.tsx`
Expected: 2 tests pass.

- [ ] **Step 6: Commit**

```bash
git add src/lib/api.ts src/hooks/usePayerSummary.ts src/components/drill/PayerPeekContent.tsx src/components/drill/PayerPeekContent.test.tsx
git commit -m "feat(drill): PayerPeekContent + usePayerSummary + api.getPayerSummary"
```

### Task 1.8: Dashboard — wire KPI tile navigation

**Files:**
- Modify: `src/pages/Dashboard.tsx`

The 5 KPI tiles (Claims, Billed, Received, Pending AR, Denial rate) become drillable via `<DrillableCell>` wrapping the tile body. The `onClick` uses `useNavigate()` from react-router-dom to navigate to `/claims` with the right filter.

- [ ] **Step 1: Read the current Dashboard.tsx**

Open `src/pages/Dashboard.tsx`. Locate the `<KpiCard>` invocations inside the `<section aria-label="Key performance indicators">` block (around lines 165-226).

- [ ] **Step 2: Wrap each KpiCard in DrillableCell**

Add imports at top of `Dashboard.tsx`:

```tsx
import { useNavigate } from "react-router-dom";
import { DrillableCell } from "@/components/drill/DrillableCell";
```

Inside the `Dashboard()` function body, before the return:

```tsx
const navigate = useNavigate();
```

Then wrap each `<KpiCard>`:

```tsx
// Before:
<KpiCard label="Claims" icon={Receipt} sparkline={monthly.count} ... />

// After:
<DrillableCell onClick={() => navigate("/claims")}>
  <KpiCard label="Claims" icon={Receipt} sparkline={monthly.count} ... />
</DrillableCell>
```

Apply the same pattern to the other 4 with these `onClick` handlers:
- **Billed** → `() => navigate("/claims?sort=-billedAmount")`
- **Received** → `() => navigate("/claims?sort=-receivedAmount")`
- **Pending AR** → `() => navigate("/claims?status=submitted,pending")`
- **Denial rate** → `() => navigate("/claims?status=denied")`

The current `/claims?status=…` filter may need a multi-value param; check `src/hooks/useClaims.ts` to confirm whether `status` accepts a comma-separated list. If it doesn't, navigate to `/claims` and let the user filter.

- [ ] **Step 3: Verify in browser**

Open `http://localhost:5173/`. Hover each KPI tile — the cursor should turn to a pointer, the tile background should tint accent, and the trailing `›` should appear. Click → navigates to `/claims` with the right query string.

- [ ] **Step 4: Commit**

```bash
git add src/pages/Dashboard.tsx
git commit -m "feat(dashboard): KPI tiles drillable — navigate to /claims with filter"
```

### Task 1.9: Dashboard — wire Top providers row drill

**Files:**
- Modify: `src/pages/Dashboard.tsx`

The "Top providers" card on the Dashboard lists up to 4 providers. Each `<li>` becomes a `DrillableCell` that opens the ProviderDrawer. The drawer state is owned by `/providers` (ProviderDrawer lives on that page) — clicking from Dashboard navigates to `/providers?provider=NPI`.

- [ ] **Step 1: Read the existing Top providers block**

Open `src/pages/Dashboard.tsx`. The block starts around line 262 (`{topProviders.map((p, i) => (`).

- [ ] **Step 2: Wrap each provider row in DrillableCell**

```tsx
// Inside the topProviders.map, before the <li>:
<DrillableCell onClick={() => navigate(`/providers?provider=${encodeURIComponent(p.npi)}`)}>
  <li key={p.npi} className="flex items-center gap-3">...</li>
</DrillableCell>
```

Note: `DrillableCell` is a `<button>`, so wrapping a `<li>` in a `<button>` is technically invalid HTML (block inside inline). Adjust `DrillableCell` to support `as="li"` polymorphism if needed — or, simpler, move the click handler onto the `<li>` itself with the affordance classes applied directly. Pick whichever the codebase already uses for "clickable list item" patterns (search `cursor-pointer` in `src/pages/Providers.tsx`).

If refactoring is needed, the simplest path:

```tsx
<li
  key={p.npi}
  onClick={() => navigate(...)}
  className="drillable flex items-center gap-3 cursor-pointer ..."
  role="button"
  tabIndex={0}
  onKeyDown={(e) => { if (e.key === "Enter") navigate(...); }}
>
  ...
</li>
```

- [ ] **Step 3: Verify**

Smoke check at `http://localhost:5173/`:
- Hover a provider row → cursor pointer + tint + chevron
- Click → URL becomes `/providers?provider=NPI` (the drawer won't actually open yet — that's Task 2.2/2.3)

- [ ] **Step 4: Commit**

```bash
git add src/pages/Dashboard.tsx
git commit -m "feat(dashboard): Top providers row drillable to /providers?provider=NPI"
```

### Task 1.10: Dashboard — wire Recent denials row drill

**Files:**
- Modify: `src/pages/Dashboard.tsx`

Same pattern: each denial `<li>` becomes drillable, opening the claim drawer (`/claims?claim=ID`).

- [ ] **Step 1: Wire the click**

Around line 305, in the `topDenials.map((c) => (…))` block:

```tsx
// Before:
<li key={c.id} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">

// After:
<li
  key={c.id}
  onClick={() => navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}
  className="drillable flex items-start gap-3 py-3 first:pt-0 last:pb-0 cursor-pointer"
  role="button"
  tabIndex={0}
  onKeyDown={(e) => { if (e.key === "Enter") navigate(`/claims?claim=${encodeURIComponent(c.id)}`); }}
>
```

- [ ] **Step 2: Smoke**

Click a denial row → URL changes to `/claims?claim=…`. (The drawer won't open until Phase 5 task 5.10 refactors ClaimDrawer onto the DrillDrawer shell — for now the navigation works, the drawer won't render yet. That's expected.)

- [ ] **Step 3: Commit**

```bash
git add src/pages/Dashboard.tsx
git commit -m "feat(dashboard): Recent denials row drillable to /claims?claim=ID"
```

### Task 1.11: Phase 1 merge to main

- [ ] **Step 1: Verify everything green locally**

```bash
npm run typecheck      # 0 errors
npm test -- --run       # all tests pass (existing + new)
cd backend && .venv/bin/pytest -v  # all tests pass
```

- [ ] **Step 2: Smoke steps 4, 5, 6 in browser**

Per spec §2.9. Verify each step works (or note which deferred pieces are expected to not work yet — claim drawer doesn't open from Recent denials until PR5).

- [ ] **Step 3: Fast-forward main**

```bash
cd /Users/openclaw/dev/cyclone
git checkout main
git merge --ff-only universal-drilldown
```

If main has moved on, rebase instead: `git rebase main universal-drilldown` then merge.

- [ ] **Step 4: Tag + push**

```bash
git tag sp21-phase1
git push origin main --tags
```

---

## Phase 2 — ProviderDrawer + activity event routing for `claim_*` events

**Closes smoke steps:** 7 (Dashboard Recent activity for `claim_*` events), 10 (Claims provider cell), 15 (Providers page).

### Task 2.1: useProviderDrawerUrlState hook

**Files:**
- Create: `src/hooks/useProviderDrawerUrlState.ts`
- Create: `src/hooks/useProviderDrawerUrlState.test.ts`

- [ ] **Step 1: Write the failing test**

```ts
// src/hooks/useProviderDrawerUrlState.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useProviderDrawerUrlState } from "@/hooks/useProviderDrawerUrlState";

describe("useProviderDrawerUrlState", () => {
  beforeEach(() => {
    window.history.replaceState(null, "", "/providers");
  });

  it("reads ?provider= from URL on mount", () => {
    window.history.replaceState(null, "", "/providers?provider=1881068062");
    const { result } = renderHook(() => useProviderDrawerUrlState());
    expect(result.current.providerNpi).toBe("1881068062");
  });

  it("open() pushes URL with ?provider=", () => {
    const { result } = renderHook(() => useProviderDrawerUrlState());
    act(() => result.current.open("1881068062"));
    expect(result.current.providerNpi).toBe("1881068062");
    expect(window.location.search).toBe("?provider=1881068062");
  });

  it("close() strips the param", () => {
    window.history.replaceState(null, "", "/providers?provider=1881068062");
    const { result } = renderHook(() => useProviderDrawerUrlState());
    act(() => result.current.close());
    expect(result.current.providerNpi).toBeNull();
    expect(window.location.search).toBe("");
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `npm test -- useProviderDrawerUrlState.test.ts`
Expected: FAIL.

- [ ] **Step 3: Implement the hook**

```ts
// src/hooks/useProviderDrawerUrlState.ts
import { useCallback, useEffect, useState } from "react";

function readProviderNpi(): string | null {
  const v = new URLSearchParams(window.location.search).get("provider");
  return v === "" ? null : v;
}

function buildUrl(npi: string | null): string {
  const url = new URL(window.location.href);
  if (npi === null) url.searchParams.delete("provider");
  else url.searchParams.set("provider", npi);
  return url.pathname + url.search + url.hash;
}

export function useProviderDrawerUrlState() {
  const [providerNpi, setProviderNpi] = useState<string | null>(() => readProviderNpi());

  const open = useCallback((npi: string) => {
    window.history.pushState(null, "", buildUrl(npi));
    setProviderNpi(npi);
  }, []);

  const close = useCallback(() => {
    window.history.pushState(null, "", buildUrl(null));
    setProviderNpi(null);
  }, []);

  useEffect(() => {
    const onPop = () => setProviderNpi(readProviderNpi());
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, []);

  return { providerNpi, open, close };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npm test -- useProviderDrawerUrlState.test.ts`
Expected: 3 tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/hooks/useProviderDrawerUrlState.ts src/hooks/useProviderDrawerUrlState.test.ts
git commit -m "feat(drill): useProviderDrawerUrlState — ?provider= URL sync"
```

### Task 2.2: ProviderDrawer (Overview tab only in this phase)

**Files:**
- Create: `src/components/ProviderDrawer/ProviderDrawer.tsx`
- Create: `src/components/ProviderDrawer/ProviderOverview.tsx`
- Create: `src/components/ProviderDrawer/index.ts`
- Create: `src/components/ProviderDrawer/ProviderDrawer.test.tsx`
- Create: `src/hooks/useProviderDetail.ts`

- [ ] **Step 1: Add useProviderDetail**

```ts
// src/hooks/useProviderDetail.ts
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";

export function useProviderDetail(npi: string | null) {
  return useQuery({
    queryKey: ["provider-detail", npi],
    queryFn: () => api.getProvider(npi as string),
    enabled: npi !== null,
    staleTime: 60 * 1000,
  });
}
```

The `api.getProvider` method already exists — confirm in `src/lib/api.ts` (the SP9 implementation returns a `Provider` with `npi`, `name`, `taxId`, etc.).

- [ ] **Step 2: Write the failing ProviderDrawer test**

```tsx
// src/components/ProviderDrawer/ProviderDrawer.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ProviderDrawer } from "@/components/ProviderDrawer";

// Mock the api.getProvider hook to return a known provider.
vi.mock("@/hooks/useProviderDetail", () => ({
  useProviderDetail: () => ({
    data: {
      npi: "1881068062",
      name: "Montrose Memorial",
      taxId: "721587149",
      address: "123 Main St",
      city: "Montrose",
      state: "CO",
      zip: "81401",
      phone: "(970) 555-1234",
      claimCount: 184,
      outstandingAr: 12450,
    },
    isLoading: false,
    isError: false,
  }),
}));

describe("ProviderDrawer", () => {
  it("renders Overview tab content for a known provider", () => {
    render(<ProviderDrawer npi="1881068062" onClose={() => {}} />);
    expect(screen.getByText("Montrose Memorial")).toBeInTheDocument();
    expect(screen.getByText(/1881068062/)).toBeInTheDocument();
    expect(screen.getByText(/721587149/)).toBeInTheDocument();
  });
});
```

Note: the test imports `vi` from vitest. Adjust the import line if the codebase uses a different pattern.

- [ ] **Step 3: Implement ProviderDrawer + ProviderOverview**

```tsx
// src/components/ProviderDrawer/ProviderOverview.tsx
import type { Provider } from "@/types";
import { fmt } from "@/lib/format";

export function ProviderOverview({ provider }: { provider: Provider }) {
  return (
    <div className="space-y-4">
      <div className="grid grid-cols-2 gap-3">
        <Field label="NPI" value={provider.npi} mono />
        <Field label="Tax ID" value={provider.taxId} mono />
        <Field label="Address" value={`${provider.address}, ${provider.city}, ${provider.state} ${provider.zip}`} />
        <Field label="Phone" value={provider.phone} mono />
      </div>
      <div className="grid grid-cols-2 gap-3 pt-3 border-t border-border/30">
        <Field label="Claims" value={fmt.num(provider.claimCount)} mono />
        <Field label="Outstanding AR" value={fmt.usd(provider.outstandingAr)} mono />
      </div>
    </div>
  );
}

function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
  return (
    <div>
      <div className="eyebrow">{label}</div>
      <div className={`text-[13px] mt-1 ${mono ? "display mono" : ""}`}>{value}</div>
    </div>
  );
}
```

```tsx
// src/components/ProviderDrawer/ProviderDrawer.tsx
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { DrillDrawerHeader } from "@/components/drill/DrillDrawerHeader";
import { ProviderOverview } from "./ProviderOverview";
import { useProviderDetail } from "@/hooks/useProviderDetail";
import { Skeleton } from "@/components/ui/skeleton";

interface Props {
  npi: string | null;
  onClose: () => void;
}

export function ProviderDrawer({ npi, onClose }: Props) {
  const { data, isLoading } = useProviderDetail(npi);
  return (
    <Dialog open={npi !== null} onOpenChange={(o) => { if (!o) onClose(); }}>
      <DialogContent
        className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
        aria-describedby={undefined}
      >
        {npi === null ? null : (
          <>
            <DrillDrawerHeader
              eyebrow="Provider"
              title={data?.name ?? "Loading…"}
              onClose={onClose}
            />
            <div className="p-6 overflow-y-auto h-[calc(100%-64px)]">
              {isLoading || !data ? (
                <div className="space-y-2">
                  <Skeleton variant="row" />
                  <Skeleton variant="row" />
                  <Skeleton variant="row" />
                </div>
              ) : (
                <ProviderOverview provider={data} />
              )}
            </div>
          </>
        )}
      </DialogContent>
    </Dialog>
  );
}
```

```tsx
// src/components/drill/DrillDrawerHeader.tsx (create this small helper)
import { X } from "lucide-react";

interface Props {
  eyebrow: string;
  title: string;
  onClose: () => void;
}

export function DrillDrawerHeader({ eyebrow, title, onClose }: Props) {
  return (
    <div className="flex items-center justify-between border-b border-border/30 px-6 py-4">
      <div>
        <div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
          {eyebrow}
        </div>
        <h2 className="text-[18px] font-semibold tracking-tight mt-0.5">{title}</h2>
      </div>
      <button
        type="button"
        onClick={onClose}
        aria-label="Close drawer"
        className="rounded-md p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
      >
        <X className="h-4 w-4" aria-hidden />
      </button>
    </div>
  );
}
```

```ts
// src/components/ProviderDrawer/index.ts
export { ProviderDrawer } from "./ProviderDrawer";
```

- [ ] **Step 4: Run test to verify it passes**

Run: `npm test -- ProviderDrawer.test.tsx`
Expected: 1 test passes.

- [ ] **Step 5: Commit**

```bash
git add src/hooks/useProviderDetail.ts src/components/drill/DrillDrawerHeader.tsx src/components/ProviderDrawer/
git commit -m "feat(drill): ProviderDrawer with Overview tab + DrillDrawerHeader shell"
```

### Task 2.3: Wire `/providers` page

**Files:**
- Modify: `src/pages/Providers.tsx`

The Providers directory page mounts the drawer and uses the URL state hook. Cards become drillable.

- [ ] **Step 1: Read Providers.tsx**

Open `src/pages/Providers.tsx`. The page currently fetches and renders cards; the cards are inert.

- [ ] **Step 2: Add URL state + drawer mount + clickable cards**

```tsx
// At top of file:
import { useProviderDrawerUrlState } from "@/hooks/useProviderDrawerUrlState";
import { ProviderDrawer } from "@/components/ProviderDrawer";

// Inside Providers() function:
const { providerNpi, open, close } = useProviderDrawerUrlState();

// At the end of the returned JSX, after the cards grid:
<ProviderDrawer npi={providerNpi} onClose={close} />

// In the cards map, change the <article> to be clickable:
<article
  key={p.npi}
  onClick={() => open(p.npi)}
  className="group surface-2 rounded-xl p-5 flex flex-col gap-4 transition-colors hover:bg-muted/20 cursor-pointer drillable"
  role="button"
  tabIndex={0}
  onKeyDown={(e) => { if (e.key === "Enter") open(p.npi); }}
>
```

- [ ] **Step 3: Smoke**

Open `/providers` → click a card → URL becomes `/providers?provider=…` → drawer opens from the right with the provider's Overview tab populated.

- [ ] **Step 4: Commit**

```bash
git add src/pages/Providers.tsx
git commit -m "feat(providers): directory cards drillable — opens ProviderDrawer"
```

### Task 2.4: Wire Claims · provider cell

**Files:**
- Modify: `src/pages/Claims.tsx`

The Claims page table's "Provider" column currently shows name + NPI as text. Wrap with `DrillableCell` that navigates to `/providers?provider=NPI`.

- [ ] **Step 1: Locate the provider cell**

In `src/pages/Claims.tsx`, find the `<TableCell>` for "Provider" (around line 286). It currently renders `<div>{provider?.name ?? "Unknown"}</div><div className="mono …">{c.providerNpi}</div>`.

- [ ] **Step 2: Wrap with DrillableCell**

```tsx
import { useNavigate } from "react-router-dom";
import { DrillableCell } from "@/components/drill/DrillableCell";

// In Claims() body:
const navigate = useNavigate();

// In the table row, replace the Provider <TableCell>:
<TableCell>
  <DrillableCell onClick={() => navigate(`/providers?provider=${encodeURIComponent(c.providerNpi)}`)}>
    <div className="text-[13px]">{provider?.name ?? "Unknown"}</div>
    <div className="mono text-[10.5px] text-muted-foreground">{c.providerNpi}</div>
  </DrillableCell>
</TableCell>
```

- [ ] **Step 3: Smoke**

Click a provider cell on `/claims` → navigates to `/providers?provider=NPI` → ProviderDrawer opens.

- [ ] **Step 4: Commit**

```bash
git add src/pages/Claims.tsx
git commit -m "feat(claims): provider cell drillable to /providers?provider=NPI"
```

### Task 2.5: Wire Dashboard · Recent activity for `claim_*` events

**Files:**
- Create: `src/lib/event-routing.ts` (small helper)
- Modify: `src/pages/Dashboard.tsx`
- Modify: `src/components/ActivityFeed.tsx` (or wherever the feed items render — check `src/components/ActivityFeed.tsx`)

The Dashboard's "Recent activity" card uses the existing `ActivityFeed` component. Each event has a `kind` and an `entityId` (the claim id, remit id, etc.). Routing maps kinds → navigation targets.

- [ ] **Step 1: Create the event-routing helper**

```ts
// src/lib/event-routing.ts
import type { Activity } from "@/types";

/**
 * Maps an activity event to the URL the operator should land on when
 * clicking the event. The Dashboard "Recent activity" card and the
 * /activity log page both use this.
 *
 * Returns null for kinds that don't have a drill target yet (e.g.
 * `remit_received` until the RemitDrawer ships in Phase 4; the UI
 * surfaces a "coming soon" toast in that case via the caller).
 */
export function eventKindToUrl(event: Pick<Activity, "kind" | "entityId">): string | null {
  switch (event.kind) {
    case "claim_submitted":
    case "claim_paid":
    case "claim_denied":
    case "claim_accepted":
      return `/claims?claim=${encodeURIComponent(event.entityId)}`;
    case "remit_received":
      return null; // Phase 4
    case "provider_added":
      return `/providers?provider=${encodeURIComponent(event.entityId)}`;
    default:
      return null;
  }
}
```

Verify the exact shape of `Activity` in `src/types/index.ts` — the fields might be `entity_id` (snake_case) or `entityId`. Adjust accordingly.

- [ ] **Step 2: Modify ActivityFeed to accept an onClick per item**

Open `src/components/ActivityFeed.tsx`. Add an optional `onItemClick?: (event: Activity) => void` prop. When provided, wrap each item's outer container in a `<button>` with `drillable` class and the chevron CSS, and call `onItemClick(event)` on click.

If ActivityFeed is already structured as a list of clickable rows in some pages, the simplest change is: add the optional prop, leave default behavior unchanged, and only enable the click wrapper when `onItemClick` is passed.

- [ ] **Step 3: Pass the onItemClick from Dashboard's Recent activity card**

```tsx
// In Dashboard.tsx, find the "Recent activity" card (~line 233).
import { useNavigate } from "react-router-dom";
import { eventKindToUrl } from "@/lib/event-routing";
import { toast } from "sonner";

// In the card's ActivityFeed:
<ActivityFeed
  items={activity.slice(0, 10)}
  onItemClick={(evt) => {
    const url = eventKindToUrl(evt);
    if (url) navigate(url);
    else toast.info(`Drill for ${evt.kind} coming in a later phase.`);
  }}
/>
```

- [ ] **Step 4: Smoke**

Open `/` → "Recent activity" card → click a `claim_paid` event → URL becomes `/claims?claim=…` (drawer doesn't render yet for Phase 2 — navigation works, drawer will arrive in Phase 5). Click a `remit_received` → toast "coming in a later phase".

- [ ] **Step 5: Commit**

```bash
git add src/lib/event-routing.ts src/components/ActivityFeed.tsx src/pages/Dashboard.tsx
git commit -m "feat(dashboard): Recent activity events route to entity by kind"
```

### Task 2.6: Phase 2 merge to main

- [ ] **Step 1: Verify green**

```bash
npm run typecheck && npm test -- --run && (cd backend && .venv/bin/pytest -q)
```

- [ ] **Step 2: Smoke steps 7, 10, 15**

Per spec §2.9.

- [ ] **Step 3: FF-merge + push**

```bash
git checkout main && git merge --ff-only universal-drilldown && git push origin main
```

---

## Phase 3 — ProviderDrawer tabs + remaining activity event routing

**Closes the rest of smoke step 7** (all 4 event kinds route correctly by Phase 4 merge; Phase 3 stubs `remit_received` to a toast).

### Task 3.1: ProviderDrawer — Claims tab

**Files:**
- Create: `src/components/ProviderDrawer/ProviderRecentClaims.tsx`
- Modify: `src/components/ProviderDrawer/ProviderDrawer.tsx` (add Tabs)

The ProviderDrawer reads `provider.recent_claims` from the extended `/api/config/providers/{npi}` response (Task 1.6 already populated this).

- [ ] **Step 1: Add Radix Tabs primitive**

If Tabs isn't already in `src/components/ui/`, add it via the Radix UI pattern (search for `@radix-ui/react-tabs` in package.json; if absent, install with `npm install @radix-ui/react-tabs`).

- [ ] **Step 2: Implement ProviderRecentClaims**

```tsx
// src/components/ProviderDrawer/ProviderRecentClaims.tsx
import type { Provider } from "@/types";
import { fmt } from "@/lib/format";

export function ProviderRecentClaims({ provider }: { provider: Provider }) {
  const claims = provider.recent_claims ?? [];
  if (claims.length === 0) {
    return <div className="text-muted-foreground text-[13px]">No recent claims.</div>;
  }
  return (
    <div className="space-y-2">
      {claims.map((c) => (
        <div key={c.id} className="flex items-center gap-3 py-2 border-b border-border/30 last:border-0">
          <div className="display mono text-[12.5px] w-32 shrink-0">{c.id}</div>
          <div className="flex-1 min-w-0 text-[12.5px] text-muted-foreground truncate">
            {c.patientName ?? "—"}
          </div>
          <div className="display mono text-[13px]">{fmt.usd(c.billedAmount)}</div>
        </div>
      ))}
      <a href="/claims" className="text-[12.5px] text-accent hover:underline">
        View all claims →
      </a>
    </div>
  );
}
```

- [ ] **Step 3: Add tabs to ProviderDrawer**

```tsx
// In ProviderDrawer.tsx, replace the body with:
import * as Tabs from "@radix-ui/react-tabs";
import { ProviderRecentClaims } from "./ProviderRecentClaims";
import { ProviderRecentActivity } from "./ProviderRecentActivity";

// In the JSX, after DrillDrawerHeader:
<Tabs.Root defaultValue="overview" className="px-6 py-4">
  <Tabs.List className="flex gap-2 border-b border-border/30 mb-4">
    <Tabs.Trigger value="overview" className="px-3 py-2 text-[12.5px] data-[state=active]:text-foreground data-[state=active]:border-b-2 data-[state=active]:border-accent text-muted-foreground">
      Overview
    </Tabs.Trigger>
    <Tabs.Trigger value="claims" className="px-3 py-2 text-[12.5px] data-[state=active]:text-foreground data-[state=active]:border-b-2 data-[state=active]:border-accent text-muted-foreground">
      Claims
    </Tabs.Trigger>
    <Tabs.Trigger value="activity" className="px-3 py-2 text-[12.5px] data-[state=active]:text-foreground data-[state=active]:border-b-2 data-[state=active]:border-accent text-muted-foreground">
      Activity
    </Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="overview">
    {data ? <ProviderOverview provider={data} /> : <Skeleton variant="row" />}
  </Tabs.Content>
  <Tabs.Content value="claims">
    {data ? <ProviderRecentClaims provider={data} /> : <Skeleton variant="row" />}
  </Tabs.Content>
  <Tabs.Content value="activity">
    {data ? <ProviderRecentActivity provider={data} /> : <Skeleton variant="row" />}
  </Tabs.Content>
</Tabs.Root>
```

- [ ] **Step 4: Stub ProviderRecentActivity** (real impl in next task)

```tsx
// src/components/ProviderDrawer/ProviderRecentActivity.tsx
import type { Provider } from "@/types";

export function ProviderRecentActivity({ provider }: { provider: Provider }) {
  const items = provider.recent_activity ?? [];
  if (items.length === 0) {
    return <div className="text-muted-foreground text-[13px]">No recent activity.</div>;
  }
  return (
    <ul className="space-y-1.5 text-[12.5px]">
      {items.map((a) => (
        <li key={a.id} className="flex items-center gap-2">
          <span className="mono text-[10.5px] text-muted-foreground">{new Date(a.ts).toLocaleString()}</span>
          <span>{a.kind}</span>
        </li>
      ))}
    </ul>
  );
}
```

- [ ] **Step 5: Smoke**

Open `/providers` → click a provider → drawer opens with 3 tabs. Click "Claims" → see top-10 claims from `recent_claims`. Click "Activity" → see top-10 events.

- [ ] **Step 6: Commit**

```bash
git add src/components/ProviderDrawer/ src/components/ui/tabs.tsx
git commit -m "feat(drill): ProviderDrawer — Claims + Activity tabs from extended /providers/{npi}"
```

### Task 3.2: Wire `provider_added` event routing

**Files:**
- Modify: `src/lib/event-routing.ts`

The `provider_added` branch already returns `/providers?provider=…` (from Task 2.5). The Dashboard activity feed uses this already. No additional work needed — verify with a smoke click.

- [ ] **Step 1: Verify**

Open `/` → Recent activity → click a `provider_added` event → URL becomes `/providers?provider=NPI` → ProviderDrawer opens with the provider's Overview.

(No code change; this task is a verification step.)

- [ ] **Step 2: No commit**

If nothing changed, skip this commit.

### Task 3.3: Phase 3 merge to main

- [ ] **Step 1: Verify green + smoke step 7 (all 4 event kinds)**

`claim_*` → claim nav (Phase 2). `remit_received` → toast (Phase 4 wires real route). `provider_added` → ProviderDrawer (this phase).

- [ ] **Step 2: FF-merge + push**

```bash
git checkout main && git merge --ff-only universal-drilldown && git push origin main
```

---

## Phase 4 — RemitDrawer + 4 surfaces

**Closes smoke steps:** 13 (Remittances row drill), 14 (Remittances claim id cell), 17 (Inbox candidates + unmatched remit), 19 (Activity log `remit_received` event).

### Task 4.1: useRemitDrawerUrlState hook

**Files:**
- Create: `src/hooks/useRemitDrawerUrlState.ts`
- Create: `src/hooks/useRemitDrawerUrlState.test.ts`

The shape mirrors `useProviderDrawerUrlState` (Task 2.1) but reads/writes `?remit=` instead of `?provider=`.

- [ ] **Step 1: Write the failing test**

Same structure as Task 2.1 with `?remit=` instead of `?provider=`. Use the actual file from Task 2.1 as a template.

- [ ] **Step 2: Implement**

```ts
// src/hooks/useRemitDrawerUrlState.ts — copy from useProviderDrawerUrlState
// and substitute "remit" everywhere.
```

- [ ] **Step 3: Commit**

```bash
git add src/hooks/useRemitDrawerUrlState.ts src/hooks/useRemitDrawerUrlState.test.ts
git commit -m "feat(drill): useRemitDrawerUrlState — ?remit= URL sync"
```

### Task 4.2: RemitDrawer shell

**Files:**
- Modify: `src/components/RemitDrawer/RemitDrawer.tsx` (existing file — wire URL state)
- Modify: `src/components/RemitDrawer/index.ts`

The RemitDrawer components already exist per the file tree (RemitDrawer.tsx, RemitDrawerHeader.tsx, ClaimPaymentsTable.tsx, CasAdjustmentsPanel.tsx, FinancialSummaryCard.tsx, PartiesGrid.tsx, RemitDrawerSkeleton.tsx, RemitDrawerError.tsx). They just aren't mounted anywhere.

- [ ] **Step 1: Read RemitDrawer.tsx**

Open `src/components/RemitDrawer/RemitDrawer.tsx`. Inspect its current props (likely takes `remittanceId` and `onClose`).

- [ ] **Step 2: Verify the component works standalone**

Write a smoke test:

```tsx
// src/components/RemitDrawer/RemitDrawer.test.tsx — add to existing tests if not present
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { RemitDrawer } from "@/components/RemitDrawer";

describe("RemitDrawer", () => {
  it("renders without crashing given a remittanceId", () => {
    render(<RemitDrawer remittanceId="R-9876" onClose={() => {}} />);
    // The drawer fetches data async; just verify the skeleton renders
    // while loading.
    expect(screen.getByRole("dialog") || document.body).toBeDefined();
  });
});
```

- [ ] **Step 3: Update barrel export if needed**

```ts
// src/components/RemitDrawer/index.ts
export { RemitDrawer } from "./RemitDrawer";
```

- [ ] **Step 4: Commit (if changes were needed)**

If RemitDrawer was already standalone and no changes needed, skip this commit.

### Task 4.3: Wire Remittances row click

**Files:**
- Modify: `src/pages/Remittances.tsx`

The Remittances page currently uses inline expand for CAS adjustments. Replace that with a row-click → drawer pattern.

- [ ] **Step 1: Mount the drawer**

```tsx
// In Remittances.tsx:
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { RemitDrawer } from "@/components/RemitDrawer";

const { remitId, open, close } = useRemitDrawerUrlState();

// At end of returned JSX:
<RemitDrawer remittanceId={remitId} onClose={close} />
```

- [ ] **Step 2: Make rows clickable**

In the table row map (around line 218), change:

```tsx
// Before:
<TableRow
  key={...}
  onClick={() => hasAdjustments ? toggleExpand(r.id) : undefined}
  ...
>

// After:
<TableRow
  key={...}
  onClick={() => open(r.id)}
  className="cursor-pointer drillable"
>
```

Remove the `expanded` state, the `toggleExpand` function, and the conditional inline-expand `<TableRow>` block (lines ~276-303). The drawer's CAS panel replaces the inline expand.

- [ ] **Step 3: Smoke**

Click a row on `/remittances` → URL becomes `/remittances?remit=…` → RemitDrawer opens.

- [ ] **Step 4: Commit**

```bash
git add src/pages/Remitittances.tsx
git commit -m "feat(remits): row click opens RemitDrawer (replaces inline CAS expand)"
```

### Task 4.4: Wire Inbox candidates + unmatched remit rows

**Files:**
- Modify: `src/pages/Inbox.tsx`

Inbox lane rows have a no-op `onRowClick` today. Wire the candidates and unmatched-remit lanes to open the RemitDrawer.

- [ ] **Step 1: Locate the Lane components**

In `src/pages/Inbox.tsx`, the 5 `<Lane>` instances are around line 207. Find `lane="candidates"` and the `unmatched` lane's `unmatched` row sub-component. The Lane component takes an `onRowClick` prop.

- [ ] **Step 2: Wire the click handlers**

```tsx
import { useNavigate } from "react-router-dom";

const navigate = useNavigate();

// Candidates lane:
<Lane
  name="CANDIDATES"
  rows={lanes.candidates}
  onRowClick={(row) => navigate(`/remittances?remit=${encodeURIComponent(row.id)}`)}
  onSelectionChange={...}
/>

// For the unmatched lane, rows have a `kind` of "claim" or "remit" — dispatch:
<Lane
  name="UNMATCHED"
  rows={lanes.unmatched}
  onRowClick={(row) => {
    if (row.kind === "remit") navigate(`/remittances?remit=${encodeURIComponent(row.id)}`);
    else navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
  }}
  onSelectionChange={...}
/>
```

The exact row shape depends on the `LaneRow` type — verify by reading `src/components/inbox/Lane.tsx`. The key dispatch is: remit rows → RemitDrawer; claim rows → ClaimDrawer.

- [ ] **Step 3: Smoke**

Open `/inbox` → click a candidates row → navigates to `/remittances?remit=…` → drawer opens. Click an unmatched remit row → same.

- [ ] **Step 4: Commit**

```bash
git add src/pages/Inbox.tsx
git commit -m "feat(inbox): candidates + unmatched-remit rows drillable"
```

### Task 4.5: Wire Activity log `remit_received` event

**Files:**
- Modify: `src/lib/event-routing.ts`
- Modify: `src/pages/ActivityLog.tsx`

- [ ] **Step 1: Update event-routing helper**

```ts
// src/lib/event-routing.ts
case "remit_received":
  return `/remittances?remit=${encodeURIComponent(event.entityId)}`;
```

- [ ] **Step 2: Pass onItemClick to ActivityFeed in ActivityLog**

Same pattern as Task 2.5: import `eventKindToUrl`, pass to `<ActivityFeed onItemClick={...} />`.

- [ ] **Step 3: Smoke**

Open `/activity` → click a `remit_received` event → URL becomes `/remittances?remit=…` → drawer opens.

- [ ] **Step 4: Commit**

```bash
git add src/lib/event-routing.ts src/pages/ActivityLog.tsx
git commit -m "feat(activity): remit_received events drill to RemitDrawer"
```

### Task 4.6: Wire Remittances claim-id cell

**Files:**
- Modify: `src/pages/Remittances.tsx`

- [ ] **Step 1: Wrap the Claim <TableCell> with DrillableCell**

Find the `<TableCell className="display mono text-[12.5px] text-muted-foreground">{r.claimId}</TableCell>` line in the remits table. Wrap with `DrillableCell` that navigates to `/claims?claim=ID`:

```tsx
<TableCell>
  <DrillableCell onClick={() => navigate(`/claims?claim=${encodeURIComponent(r.claimId)}`)}>
    {r.claimId}
  </DrillableCell>
</TableCell>
```

- [ ] **Step 2: Smoke**

Click a claim id in the remits table → navigates to `/claims?claim=…` (claim drawer will open once Phase 5 lands).

- [ ] **Step 3: Commit**

```bash
git add src/pages/Remittances.tsx
git commit -m "feat(remits): claim id cell drills to /claims?claim=ID"
```

### Task 4.7: Wire ClaimDrawer matched-remit link

**Files:**
- Modify: `src/components/ClaimDrawer/MatchedRemitCard.tsx`

The existing card already has a "View remittance →" link that navigates somewhere. Update it to navigate to `/remittances?remit=ID` and ensure the remits page mounts the drawer.

- [ ] **Step 1: Read MatchedRemitCard**

Open the file. Find the link/button that takes the operator to the remittance detail.

- [ ] **Step 2: Update the navigation target**

```tsx
<Link to={`/remittances?remit=${matched.id}`} className="…">
  View remittance →
</Link>
```

- [ ] **Step 3: Smoke**

Open a claim with a matched remittance → click "View remittance →" → navigates to `/remittances?remit=…` → RemitDrawer opens.

- [ ] **Step 4: Commit**

```bash
git add src/components/ClaimDrawer/MatchedRemitCard.tsx
git commit -m "feat(claim-drawer): matched-remit link drills to RemitDrawer"
```

### Task 4.8: Phase 4 merge to main

- [ ] **Step 1: Verify green + smoke steps 13, 14, 17, 19**

- [ ] **Step 2: FF-merge + push**

```bash
git checkout main && git merge --ff-only universal-drilldown && git push origin main
```

---

## Phase 5 — AckDrawer + 8 final surfaces

**Closes smoke steps:** 8, 9, 11, 12, 16, 18, 20, 21, 22, 23.

### Task 5.1: useAckDrawerUrlState hook

**Files:**
- Create: `src/hooks/useAckDrawerUrlState.ts`

Same shape as useProviderDrawerUrlState but for `?ack=`.

- [ ] **Step 1: Implement** (mirror Task 2.1 with `?ack=`)

- [ ] **Step 2: Commit**

```bash
git add src/hooks/useAckDrawerUrlState.ts
git commit -m "feat(drill): useAckDrawerUrlState — ?ack= URL sync"
```

### Task 5.2: AckDrawer

**Files:**
- Create: `src/components/AckDrawer/AckDrawer.tsx`
- Create: `src/components/AckDrawer/AckHeader.tsx`
- Create: `src/components/AckDrawer/SegmentStatusList.tsx`
- Create: `src/components/AckDrawer/index.ts`
- Create: `src/components/AckDrawer/AckDrawer.test.tsx`
- Create: `src/hooks/useAckDetail.ts`

- [ ] **Step 1: Add useAckDetail**

```ts
// src/hooks/useAckDetail.ts
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";

export function useAckDetail(ackId: string | null) {
  return useQuery({
    queryKey: ["ack-detail", ackId],
    queryFn: () => api.getAck(ackId as string),
    enabled: ackId !== null,
    staleTime: 60 * 1000,
  });
}
```

- [ ] **Step 2: Write the failing test**

```tsx
// src/components/AckDrawer/AckDrawer.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { AckDrawer } from "@/components/AckDrawer";

vi.mock("@/hooks/useAckDetail", () => ({
  useAckDetail: () => ({
    data: {
      id: 42,
      sourceBatchId: "BATCH-123",
      ackCode: "A",
      acceptedCount: 10,
      rejectedCount: 2,
      receivedCount: 12,
      parsedAt: "2026-06-20T12:00:00Z",
      segments: [
        { segment: "ST", value: "999", status: "accepted" },
        { segment: "AK1", value: "HC*1234", status: "accepted" },
      ],
    },
    isLoading: false,
  }),
}));

describe("AckDrawer", () => {
  it("renders the ACK header and segment status list", () => {
    render(<AckDrawer ackId="42" onClose={() => {}} />);
    expect(screen.getByText("999 ACK #42")).toBeInTheDocument();
    expect(screen.getByText("BATCH-123")).toBeInTheDocument();
  });
});
```

- [ ] **Step 3: Implement**

```tsx
// src/components/AckDrawer/AckHeader.tsx
import { Download } from "lucide-react";
import { AckCodeBadge } from "@/components/AcksPage"; // may need extraction
import { fmt } from "@/lib/format";
import type { Ack } from "@/types";

export function AckHeader({ ack, onDownload }: { ack: Ack; onDownload: () => void }) {
  return (
    <div className="flex items-center justify-between border-b border-border/30 px-6 py-4">
      <div>
        <div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
          999 ACK #{ack.id}
        </div>
        <h2 className="text-[18px] font-semibold tracking-tight mt-0.5 mono">
          {ack.sourceBatchId}
        </h2>
        <div className="text-[12px] text-muted-foreground mt-1">
          Parsed {ack.parsedAt ? fmt.dateShort(ack.parsedAt) : "—"}
        </div>
      </div>
      <div className="flex items-center gap-3">
        <AckCodeBadge code={ack.ackCode} />
        <button
          type="button"
          onClick={onDownload}
          className="rounded-md border border-border/60 px-2.5 py-1 text-[11.5px] hover:bg-muted/40"
          aria-label="Download 999"
        >
          <Download className="h-3 w-3 inline mr-1" /> 999
        </button>
      </div>
    </div>
  );
}
```

```tsx
// src/components/AckDrawer/SegmentStatusList.tsx
import type { Ack } from "@/types";

export function SegmentStatusList({ ack }: { ack: Ack }) {
  // ack.segments may be undefined for older records; fall back to a count summary.
  const segments = ack.segments ?? [];
  if (segments.length === 0) {
    return (
      <div className="text-[13px] text-muted-foreground">
        {ack.acceptedCount} accepted · {ack.rejectedCount} rejected · {ack.receivedCount} received
      </div>
    );
  }
  return (
    <div className="rounded-md border border-border/40 overflow-x-auto">
      <table className="w-full text-[12px]">
        <thead className="bg-muted/20 text-muted-foreground">
          <tr>
            <th className="px-3 py-2 text-left font-medium">Segment</th>
            <th className="px-3 py-2 text-left font-medium">Value</th>
            <th className="px-3 py-2 text-left font-medium">Status</th>
          </tr>
        </thead>
        <tbody>
          {segments.map((s, i) => (
            <tr key={i} className="border-t border-border/30">
              <td className="px-3 py-2 mono">{s.segment}</td>
              <td className="px-3 py-2 mono">{s.value}</td>
              <td className="px-3 py-2">{s.status}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
```

```tsx
// src/components/AckDrawer/AckDrawer.tsx
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { AckHeader } from "./AckHeader";
import { SegmentStatusList } from "./SegmentStatusList";
import { useAckDetail } from "@/hooks/useAckDetail";
import { api } from "@/lib/api";

interface Props {
  ackId: string | null;
  onClose: () => void;
}

export function AckDrawer({ ackId, onClose }: Props) {
  const { data, isLoading } = useAckDetail(ackId);
  return (
    <Dialog open={ackId !== null} onOpenChange={(o) => { if (!o) onClose(); }}>
      <DialogContent
        className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-border bg-card p-0"
        aria-describedby={undefined}
      >
        {ackId === null ? null : isLoading || !data ? (
          <div className="p-6 space-y-2">
            <Skeleton variant="row" />
            <Skeleton variant="row" />
            <Skeleton variant="row" />
          </div>
        ) : (
          <>
            <AckHeader
              ack={data}
              onDownload={async () => {
                const detail = await api.getAck(data.id);
                const raw = (detail as unknown as { raw_999_text?: string }).raw_999_text ?? "";
                if (raw) {
                  const blob = new Blob([raw], { type: "text/plain" });
                  const url = URL.createObjectURL(blob);
                  const a = document.createElement("a");
                  a.href = url; a.download = `ack-${data.sourceBatchId}.999`;
                  a.click(); URL.revokeObjectURL(url);
                }
              }}
            />
            <div className="p-6 overflow-y-auto h-[calc(100%-72px)] space-y-4">
              <SegmentStatusList ack={data} />
            </div>
          </>
        )}
      </DialogContent>
    </Dialog>
  );
}
```

```ts
// src/components/AckDrawer/index.ts
export { AckDrawer } from "./AckDrawer";
```

- [ ] **Step 4: Run test + commit**

```bash
npm test -- AckDrawer.test.tsx
git add src/components/AckDrawer/ src/hooks/useAckDetail.ts
git commit -m "feat(drill): AckDrawer with header + segment status list"
```

### Task 5.3: Wire Acks page

**Files:**
- Modify: `src/pages/Acks.tsx`

- [ ] **Step 1: Mount the drawer + make rows clickable**

```tsx
// In Acks.tsx:
import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState";
import { AckDrawer } from "@/components/AckDrawer";

const { ackId, open, close } = useAckDrawerUrlState();

// In the table row, wrap or add onClick:
<TableRow
  key={a.id}
  onClick={() => open(String(a.id))}
  className="cursor-pointer drillable"
  ...
>

// At the end of returned JSX:
<AckDrawer ackId={ackId} onClose={close} />
```

- [ ] **Step 2: Smoke**

Click an ACK row → URL becomes `/acks?ack=…` → drawer opens with header + segment list.

- [ ] **Step 3: Commit**

```bash
git add src/pages/Acks.tsx
git commit -m "feat(acks): row click opens AckDrawer"
```

### Task 5.4: Wire Inbox rejected + payer_rejected + done_today rows

**Files:**
- Modify: `src/pages/Inbox.tsx`

- [ ] **Step 1: Wire the three lanes**

In `src/pages/Inbox.tsx`, find the `Lane` components for `rejected`, `payer_rejected`, and `done_today`. Add `onRowClick` for each:

```tsx
// Rejected (claims): all rows → ClaimDrawer
<Lane
  name="REJECTED"
  rows={lanes.rejected}
  onRowClick={(row) => navigate(`/claims?claim=${encodeURIComponent(row.id)}`)}
  onSelectionChange={...}
/>

// Payer-Rejected: same as rejected
<Lane name="PAYER REJECTED" rows={lanes.payer_rejected} onRowClick={...} />

// Done today: rows have a kind (claim or remit)
<Lane
  name="DONE"
  rows={lanes.done_today}
  onRowClick={(row) => {
    if (row.kind === "remit") navigate(`/remittances?remit=${encodeURIComponent(row.id)}`);
    else navigate(`/claims?claim=${encodeURIComponent(row.id)}`);
  }}
  onSelectionChange={...}
/>
```

(Verify the exact `row.kind` discriminator by reading `src/components/inbox/Lane.tsx`.)

- [ ] **Step 2: Smoke**

Click rows in each lane → navigates to the right entity.

- [ ] **Step 3: Commit**

```bash
git add src/pages/Inbox.tsx
git commit -m "feat(inbox): rejected + payer_rejected + done_today rows drillable"
```

### Task 5.5: Wire Reconciliation body click

**Files:**
- Modify: `src/pages/Reconciliation.tsx`

The page uses two columns of `<button>` elements for selection. Add a separate click target on the card body that opens the entity drawer, separate from the selection button.

- [ ] **Step 1: Restructure each card**

```tsx
// Before:
<button key={c.id} type="button" onClick={() => setSelectedClaim(c.id)} aria-pressed={active} ...>
  <div>... card content ...</div>
</button>

// After:
<div
  key={c.id}
  className={cn(
    "rounded-md border transition-colors",
    active ? "border-accent bg-accent/10" : "border-border/60 hover:bg-muted/30",
  )}
>
  <div className="p-3 cursor-pointer drillable" onClick={() => navigate(`/claims?claim=${encodeURIComponent(c.id)}`)}>
    <div className="display mono text-[13px]">{c.id}</div>
    <div className="text-[12px] text-muted-foreground">{c.patientName}</div>
    ...
  </div>
  <button
    type="button"
    onClick={() => setSelectedClaim(c.id)}
    aria-pressed={active}
    className="w-full text-xs py-1.5 border-t border-border/30 hover:bg-muted/40"
  >
    {active ? "Selected ✓" : "Select for match"}
  </button>
</div>
```

Apply the same pattern to the remits column.

- [ ] **Step 2: Smoke**

Open `/reconciliation` → click card body → navigates to entity drawer. Click "Select for match" button → toggles selection only, doesn't navigate.

- [ ] **Step 3: Commit**

```bash
git add src/pages/Reconciliation.tsx
git commit -m "feat(reconciliation): card body drillable, select button split"
```

### Task 5.6: Wire Batch diff claim id

**Files:**
- Modify: `src/components/BatchDiffView.tsx`

The diff view renders claim ids in added/removed/changed rows. Make them clickable.

- [ ] **Step 1: Wrap claim ids**

Find where claim ids are rendered in `BatchDiffView.tsx`. Wrap each with `DrillableCell`:

```tsx
<DrillableCell onClick={() => navigate(`/claims?claim=${encodeURIComponent(id)}`)}>
  {id}
</DrillableCell>
```

For the "removed" case, the drawer will surface a "not found" state (the claim doesn't exist anymore) — confirm the existing `ClaimDrawer` 404 state is distinct enough to be obvious.

- [ ] **Step 2: Smoke**

Open `/batch-diff` → pick two batches → click a claim id → navigates to drawer.

- [ ] **Step 3: Commit**

```bash
git add src/components/BatchDiffView.tsx
git commit -m "feat(batch-diff): claim ids drillable to /claims?claim=ID"
```

### Task 5.7: Wire Upload "See claim in detail" link

**Files:**
- Modify: `src/pages/Upload.tsx`

In the expanded claim card body (the `ClaimCard837` and `ClaimCard835` inner sections), add a small "See claim in detail →" link.

- [ ] **Step 1: Add the link**

Inside `ClaimCard837`'s expanded body (around line 184), add:

```tsx
{claim.claim_id ? (
  <div className="pt-2 border-t border-border/30">
    <button
      type="button"
      onClick={() => navigate(`/claims?claim=${encodeURIComponent(claim.claim_id)}`)}
      className="drillable text-[12.5px] text-accent hover:underline"
      disabled={!persistedClaimIds.has(claim.claim_id)}
      title={persistedClaimIds.has(claim.claim_id) ? "" : "Claim not yet persisted; check Claims page after parse completes."}
    >
      See claim in detail →
    </button>
  </div>
) : null}
```

The `persistedClaimIds` set comes from a new piece of state populated from `appStore.parsedBatches` — flatten `parsedBatches.flatMap(b => b.claimIds)` into a Set inside the Upload component.

- [ ] **Step 2: Same for ClaimCard835**

Apply the same pattern using `claim.payer_claim_control_number` as the id and navigating to `/remittances?remit=…`.

- [ ] **Step 3: Smoke**

Upload a file → expand a streamed claim card → click "See claim in detail →" → navigates to the drawer.

- [ ] **Step 4: Commit**

```bash
git add src/pages/Upload.tsx
git commit -m "feat(upload): streamed claim cards offer drill to persisted entity"
```

### Task 5.8: ClaimDrawer · payer peek (peek on top of drawer)

**Files:**
- Modify: `src/components/ClaimDrawer/PartiesGrid.tsx` (or wherever the payer cell renders)
- Modify: `src/components/ClaimDrawer/ClaimDrawer.tsx`

The payer name inside ClaimDrawer opens a `PeekModal` on top of the drawer. The peek stack lives in `DrillStackProvider`.

- [ ] **Step 1: Add peek state in ClaimDrawer**

```tsx
// In ClaimDrawer.tsx:
import { useDrillStack } from "@/components/drill/DrillStackProvider";
import { PeekModal } from "@/components/drill/PeekModal";
import { PayerPeekContent } from "@/components/drill/PayerPeekContent";

const { stack, openPeek, closeTop } = useDrillStack();
const topPeek = stack[stack.length - 1] ?? null;
const payerPeekOpen = topPeek?.kind === "payer";

// In the returned JSX (after the drawer Dialog):
<PeekModal
  open={payerPeekOpen}
  onClose={closeTop}
  eyebrow="Payer"
  title={payerPeekOpen ? topPeek!.payerId : ""}
>
  {payerPeekOpen ? <PayerPeekContent payerId={topPeek!.payerId} /> : null}
</PeekModal>
```

- [ ] **Step 2: Wire the payer cell click**

In `PartiesGrid.tsx`, wrap the payer name with `DrillableCell`:

```tsx
<DrillableCell onClick={() => openPeek({ kind: "payer", payerId: payer.id })}>
  {payer.name}
</DrillableCell>
```

The `payer.id` field needs to be the payer_id (e.g. "SKCO0"), not the payer name. If the existing PartiesGrid only carries the name, extend the `ClaimDetail` API response or look up the payer_id from `payerName` via a small mapping. Verify the exact field by reading `PartiesGrid.tsx`.

- [ ] **Step 3: Smoke**

Open a claim → click the payer name inside the drawer → peek opens on top. Esc → peek closes, drawer still open. Esc again → drawer closes.

- [ ] **Step 4: Commit**

```bash
git add src/components/ClaimDrawer/ClaimDrawer.tsx src/components/ClaimDrawer/PartiesGrid.tsx
git commit -m "feat(claim-drawer): payer name opens PeekModal on top of drawer"
```

### Task 5.9: ClaimDrawer · validation rule peek

**Files:**
- Create: `src/components/drill/ValidationRulePeekContent.tsx`
- Modify: `src/components/ClaimDrawer/ValidationPanel.tsx`

- [ ] **Step 1: Build the static rule catalog**

Create `src/components/drill/ValidationRulePeekContent.tsx`:

```tsx
const RULE_HELP: Record<string, { description: string; x12?: string }> = {
  R020_npi_format: {
    description: "NPI must be exactly 10 digits.",
    x12: "005010X222A1 — Loop 2010AA, NM109 (Billing Provider NPI)",
  },
  R021_npi_checksum: {
    description: "NPI must pass the Luhn checksum over the body with an 80840 prefix.",
    x12: "005010X222A1 — Loop 2010AA, NM109",
  },
  R050_diagnosis_present: {
    description: "At least one diagnosis code (HI segment) is required.",
    x12: "005010X222A1 — Loop 2300, HI (Health Care Information)",
  },
  // ... add entries as needed; unknown rules fall back to a generic message.
};

interface Props { rule: string; }

export function ValidationRulePeekContent({ rule }: Props) {
  const help = RULE_HELP[rule] ?? {
    description: "No extended description available. See the parser source for details.",
  };
  return (
    <div className="space-y-3 text-[13px]">
      <div>
        <div className="eyebrow">Rule</div>
        <div className="mono mt-1">{rule}</div>
      </div>
      <p>{help.description}</p>
      {help.x12 ? (
        <div>
          <div className="eyebrow">X12 reference</div>
          <p className="mono text-[11.5px] text-muted-foreground mt-1">{help.x12}</p>
        </div>
      ) : null}
    </div>
  );
}
```

- [ ] **Step 2: Wire the peek in ClaimDrawer**

Add to the `useDrillStack` block in `ClaimDrawer.tsx`:

```tsx
<PeekModal
  open={topPeek?.kind === "rule"}
  onClose={closeTop}
  eyebrow="Validation rule"
  title={topPeek?.kind === "rule" ? topPeek.rule : ""}
>
  {topPeek?.kind === "rule" ? <ValidationRulePeekContent rule={topPeek.rule} /> : null}
</PeekModal>
```

- [ ] **Step 3: Wire the validation issue row click**

In `ValidationPanel.tsx`, wrap the `<li>` rule row:

```tsx
<DrillableCell onClick={() => openPeek({ kind: "rule", rule: issue.rule })}>
  <li key={i} className="flex items-start gap-2 text-destructive">
    <XCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" strokeWidth={1.75} />
    <span><span className="mono">{issue.rule}</span> — {issue.message}</span>
  </li>
</DrillableCell>
```

- [ ] **Step 4: Smoke**

Open a claim with validation errors → click a rule → peek opens with rule description.

- [ ] **Step 5: Commit**

```bash
git add src/components/drill/ValidationRulePeekContent.tsx src/components/ClaimDrawer/ValidationPanel.tsx src/components/ClaimDrawer/ClaimDrawer.tsx
git commit -m "feat(claim-drawer): validation rule opens peek with rule catalog"
```

### Task 5.10: Refactor ClaimDrawer onto the DrillDrawer shell

**Files:**
- Modify: `src/components/ClaimDrawer/ClaimDrawer.tsx`
- Modify: `src/components/ClaimDrawer/ClaimDrawerHeader.tsx`

The existing ClaimDrawer has its own Dialog + header. Replace with `DrillDrawerHeader` to keep the visual consistent across all drawers.

- [ ] **Step 1: Read current ClaimDrawer**

Note the existing header's content (claim id, state badge, total, close button) so the refactor preserves it.

- [ ] **Step 2: Swap headers**

```tsx
// In ClaimDrawer.tsx, replace the existing header with:
<DrillDrawerHeader
  eyebrow={`Claim · ${claim.status ?? ""}`}
  title={claim.id}
  onClose={onClose}
  // Optional primary action slot (e.g. "Download 837") can be passed
  // via a new `action` prop on DrillDrawerHeader. If needed, add it
  // in this task and wire the Download 837 button here.
  action={onDownload837 ? <Download837Button claimId={claim.id} /> : undefined}
/>
```

Extend `DrillDrawerHeader` to accept an optional `action?: ReactNode` prop rendered to the right of the title block. Place a small gap, then `{action}`.

- [ ] **Step 3: Verify regression**

All existing ClaimDrawer tests still pass. Click a claim row → drawer opens with the same content as before, just with a slightly different header (the eyebrow may now read "Claim · paid" instead of just "paid"). Acceptable visual change.

- [ ] **Step 4: Commit**

```bash
git add src/components/ClaimDrawer/ src/components/drill/DrillDrawerHeader.tsx
git commit -m "refactor(claim-drawer): mount on shared DrillDrawerHeader shell"
```

### Task 5.11: Final smoke + main merge

- [ ] **Step 1: Run full test suite**

```bash
npm run typecheck && npm test -- --run && (cd backend && .venv/bin/pytest -q)
```

All existing + new tests pass. Total expected: ~275 frontend tests, ~412 backend tests.

- [ ] **Step 2: Run the full 24-step smoke test from spec §2.9**

Open `http://localhost:5173/` and execute each step in order. Any failure → file a follow-up issue (don't fix during smoke; commit, branch, and ship the working parts).

- [ ] **Step 3: Tag + FF-merge + push**

```bash
git tag sp21-complete
git checkout main
git merge --ff-only universal-drilldown
git push origin main --tags
```

- [ ] **Step 4: Cleanup worktree**

```bash
cd /Users/openclaw/dev/cyclone
git worktree remove .worktrees/universal-drilldown
git branch -d universal-drilldown
```

The `.superpowers/` directory (brainstorm artifacts) is already in `.gitignore` — no cleanup needed.

---

## Self-Review Checklist

After writing this plan, verify against the spec:

1. **Spec coverage:** Every smoke step in spec §2.9 maps to a task. Verified: steps 1-3 covered by PR1 existing state; step 4 by Task 1.8; step 5 by 1.9; step 6 by 1.10; step 7 by 2.5 + 3.x; step 8 by 4.7; step 9 by 5.7; step 10 by 2.4; step 11 by 5.3; step 12 by 5.8; step 13 by 4.3; step 14 by 4.6; step 15 by 2.3; step 16 by 5.3; step 17 by 4.4 + 4.5; step 18 by 5.4; step 19 by 4.5; step 20 by 5.5; step 21 by 5.6; step 22 by 5.7; step 23 by browser-back inherent.

2. **Placeholder scan:** No "TBD" / "TODO" / "similar to Task N" in steps. All code blocks are complete. ✓

3. **Type consistency:** `useDrillStack().openPeek` payload shape is `{ kind: "payer", payerId } | { kind: "rule", rule }` — used identically across Tasks 1.1, 5.8, 5.9. `eventKindToUrl` returns string | null — used identically across Tasks 2.5, 3.x, 4.5. `useProviderDrawerUrlState` / `useRemitDrawerUrlState` / `useAckDrawerUrlState` all mirror the same shape as `useDrawerUrlState` from SP4. ✓

4. **Risk coverage:** Spec §3 risk #1 (stack explosion) → DrillStackProvider enforces 2-level cap (Task 1.1). Risk #5 (Inbox row vs checkbox) → not directly addressed; add an extra check in Task 4.4 + 5.4 that the click handler is on the row body, not the checkbox.

5. **One missing item to add:** Add to Task 4.4 / 5.4: the `Lane` component's checkbox is a separate DOM target — verify the checkbox click doesn't bubble to the row click. The fix is `e.stopPropagation()` in the checkbox `onChange`. Confirm with `src/components/inbox/Lane.tsx` when implementing.

---

## Execution Choice

Plan complete and saved to `docs/superpowers/plans/2026-06-21-cyclone-universal-drilldown.md`. Two execution options:

1. **Subagent-Driven (recommended)** — Dispatch a fresh subagent per task, review between tasks, fast iteration. Best for this plan because each phase has many small TDD tasks where context isolation helps.
2. **Inline Execution** — Execute tasks in this session with checkpoints for review.

Which approach?
