docs: add live-data UX section + aesthetic acceptance checks

- Replace 7.6 (skeleton) with hairline + scanline-shimmer design that
  matches the existing precision-instrument voice (Cabinet Grotesk + Geist
  Mono, true-black + electric-blue, hairline chrome, radial light + grain)
- Add 7.7 Live-data UX patterns: skeleton variants, EmptyState primitive,
  ErrorState primitive, Layout-level refetch indicator, Active filter chips,
  newly-streamed row highlight, status color alignment, pagination footer
- Add 13.1 visual / aesthetic acceptance checks
- Voice decision: stay the course — additive only, no new accent / typeface /
  layout primitive / light theme
This commit is contained in:
Tyler
2026-06-19 18:36:39 -06:00
parent 9e34f22cb3
commit 078de28cc4
@@ -248,7 +248,194 @@ Each page gains: `<Skeleton />` rows during `isLoading`, an error block with "Re
### 7.6 UI primitive: `src/components/ui/skeleton.tsx`
~10 lines, shadcn-style: `<div className={cn('animate-pulse rounded-md bg-muted', className)} />`. No new dep.
A reusable row-shaped skeleton block. Voice: hairline + a slow horizontal scanline gradient (not the generic "pulse the whole block gray" pattern). Renders three bars (id / name / amount) sized to match the typography of the row it stands in for. ~25 lines, no new dep.
```tsx
// Sketch — not the final implementation
export function Skeleton({ className, variant = "row" }: Props) {
return (
<div
className={cn(
"relative overflow-hidden rounded-md bg-muted/30",
// hairline border at 0.4 opacity to echo the rest of the chrome
"ring-1 ring-inset ring-border/40",
className
)}
>
<div
className="absolute inset-0 -translate-x-full animate-[shimmer_1.6s_infinite]"
style={{
background:
"linear-gradient(90deg, transparent 0%, hsl(var(--muted-foreground) / 0.08) 50%, transparent 100%)",
}}
/>
{variant === "row" ? (
<div className="grid grid-cols-12 gap-3 p-4">
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-3 h-3 bg-muted/60 rounded" />
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-2 h-3 bg-muted/60 rounded ml-auto" />
<div className="col-span-1 h-3 bg-muted/60 rounded ml-auto" />
</div>
) : null}
</div>
);
}
```
The `shimmer` keyframe is added to `tailwind.config.js` next to the existing `fade-in` keyframes. No external library.
### 7.7 Live-data UX patterns
The aesthetic is already committed (Cabinet Grotesk + Geist Mono, true-black ground, electric-blue accent, hairline chrome, radial top-right light, fine grain). This sub-project does not introduce a new accent color, a new typeface, a light theme, or a new layout primitive. The additions below are *only* the live-data UX patterns needed to make the page refactors feel as precise as the data they carry.
#### 7.7.1 Loading — three skeleton variants
Pages render 35 `Skeleton` rows (or cards, or KPI tiles) on `isLoading` so the page doesn't pop. Skeleton uses the row-shaped variant (above) inside table surfaces, the `card` variant inside the Provider grid, and the existing `.surface` + `display` text treatment for the page header (which renders immediately, not behind a skeleton).
#### 7.7.2 Empty state
Replace the existing generic "No claims yet." text with a consistent empty state primitive: a hairline-circle icon (1.5px stroke), an all-caps eyebrow (`text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground`), and a one-line message. The eyebrow uses the *page subject* so it reads as an instrument label: e.g., "Claims · inbox idle" / "Remittances · awaiting first 835" / "Activity · log idle" / "Providers · directory empty". No apologetic language, no illustrations.
```tsx
function EmptyState({ eyebrow, message }: { eyebrow: string; message: string }) {
return (
<div className="flex flex-col items-center justify-center gap-2.5 py-14">
<div className="h-10 w-10 rounded-full ring-1 ring-inset ring-border/60 flex items-center justify-center text-muted-foreground">
<CircleDashed className="h-4 w-4" strokeWidth={1.5} />
</div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{eyebrow}
</div>
<div className="text-sm text-muted-foreground/80">{message}</div>
</div>
);
}
```
`CircleDashed` from `lucide-react`.
#### 7.7.3 Error state
A bordered block at the top of the page content (not a full-page takeover — the user should still be able to navigate away). `border-destructive/30` on a `.surface` background; a destructive-tinted `AlertCircle` icon, the error message in `text-sm`, and a `Retry` button on the right. A sonner toast surfaces the same error at the bottom-right (existing Toaster config keeps this). Network errors get a more verbose message ("Can't reach the backend at `http://127.0.0.1:8000`. Is the FastAPI server running?").
```tsx
function ErrorState({ error, onRetry }: { error: Error; onRetry: () => void }) {
return (
<div className="surface rounded-xl border border-destructive/30 p-4 flex items-center gap-3">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" strokeWidth={1.75} />
<div className="flex-1 min-w-0">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive/80">
Fetch failed
</div>
<div className="text-sm text-foreground/90 truncate">{error.message}</div>
</div>
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
);
}
```
#### 7.7.4 Refetch indicator (Layout-level)
A 1px hairline at the very top of `<main>` that animates in (left → right, accent color, 400ms) whenever *any* query is fetching in the background (e.g., after `invalidateQueries` post-parse). Uses `useIsFetching()` from `@tanstack/react-query`. Implemented as a fixed-position element inside `Layout.tsx`, not page-level — so every page benefits without per-page plumbing.
```tsx
// In Layout.tsx
const isFetching = useIsFetching();
return (
<div className="relative min-h-screen z-10">
{isFetching > 0 ? (
<div className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none">
<div
className="h-full w-1/3 bg-accent animate-[scan_1.2s_ease-in-out_infinite]"
style={{ boxShadow: "0 0 8px hsl(var(--accent) / 0.5)" }}
/>
</div>
) : null}
<Sidebar />
<main className="md:pl-60"></main>
</div>
);
```
The `scan` keyframe is added to `tailwind.config.js`. The element is hairline-thin so it never competes with the content; the only motion on the page during a background refetch is this 1px sliver.
#### 7.7.5 Active filter chips (Claims page)
The existing `<Select>` controls stay (they set the values). Above the table, a row of read-only pills makes the *active* filter set scannable at a glance. Each pill: `border-border/60` background, `text-xs`, a small "×" on the right that clears the filter. A "Clear all" link on the right of the chip row clears every active filter. The chips sit on the same `surface` card as the Selects, separated by a 1px hairline.
```tsx
function FilterChips({
active,
onRemove,
onClearAll,
}: {
active: { key: string; label: string }[];
onRemove: (key: string) => void;
onClearAll: () => void;
}) {
if (active.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-2 mt-4 pt-4 border-t border-border/40">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Active
</span>
{active.map((c) => (
<span
key={c.key}
className="inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-muted/30 pl-2.5 pr-1 py-0.5 text-xs"
>
<span className="text-muted-foreground">{c.key}:</span>
<span className="font-medium">{c.label}</span>
<button
type="button"
onClick={() => onRemove(c.key)}
className="ml-0.5 h-4 w-4 rounded-sm hover:bg-muted flex items-center justify-center"
aria-label={`Remove ${c.key} filter`}
>
<X className="h-3 w-3" strokeWidth={1.75} />
</button>
</span>
))}
{active.length > 1 ? (
<button
type="button"
onClick={onClearAll}
className="ml-auto text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
) : null}
</div>
);
}
```
#### 7.7.6 Newly-streamed row highlight (Tables)
When react-query invalidates after a successful parse, the affected tables briefly highlight the *new* rows with a 2-second accent-tinted background fade. Implementation: each row that arrived in the most recent parse carries a `data-newer-than={parsedAt}` attribute; CSS animates a one-shot `bg-accent/10` to `bg-transparent` over 2s. This is the visual confirmation that "the parse made it into the list" without being noisy.
```tsx
// in TableRow wrapper
<tr
data-newer-than={isNewRow ? parsedAt : undefined}
className="data-[newer-than]:animate-[row-flash_2s_ease-out]"
/>
```
The `row-flash` keyframe: `from { background-color: hsl(var(--accent) / 0.1); } to { background-color: transparent; }`. Lives in `tailwind.config.js`.
#### 7.7.7 Status color alignment (no new colors)
The new mapper output is `draft|submitted|accepted|denied|paid|pending` for 837P claims and `received|posted|reconciled` for 835 remittances — exactly what the existing `ClaimStatusBadge` and `RemitStatusBadge` already key on. The palette is **unchanged**: success (green) for `accepted`/`paid`/`reconciled`, accent (blue) for `submitted`/`received`, warning (amber) for `pending`/`posted`, destructive (red) for `denied`, muted for `draft`. No new tokens, no new semantics.
#### 7.7.8 Pagination footer (replaces the placeholder)
For list endpoints with `total > limit`, the existing page footer grows a paginator: `` `1` `2` `3``N` ``, plus the `returned` / `total` / `has_more` summary on the right. Style: `text-xs` with `num` on the numbers, the active page gets `bg-muted/60` + a left-aligned 1px accent line (echoing the sidebar `nav-active` treatment). The component is a small new primitive `src/components/ui/pagination.tsx` (~40 lines).
## 8. Docs
@@ -372,6 +559,18 @@ cd backend && .venv/bin/python -m cyclone.cli parse-837 tests/fixtures/co_medica
- [ ] Upload page streams a 2-claim CO fixture; navigating to `/claims` shows the same 2 claims.
- [ ] Filtering by `status=submitted` on `/api/claims` returns the right subset; UI shows the same.
- [ ] `GET /api/claims` with `Accept: application/x-ndjson` returns valid NDJSON.
- [ ] Restarting the backend wipes the in-memory store; the UI shows "no claims yet" on first refresh.
- [ ] Restarting the backend wipes the in-memory store; the UI shows an empty state on first refresh.
- [ ] All 4 reference notes exist under `docs/reference/` and the root `README.md` is rewritten.
- [ ] The frontend still works without a backend (`VITE_API_BASE_URL` empty) — pages fall back to the zustand sample store via the existing `data` adapter.
### 13.1 Visual / aesthetic acceptance checks
- [ ] **Skeleton** uses the scanline-shimmer pattern, not a generic `animate-pulse` on a flat gray block.
- [ ] **Empty state** on every refactored page uses the `EmptyState` primitive with an all-caps instrument-label eyebrow (e.g., "Claims · inbox idle"), not a plain text line.
- [ ] **Error state** on every refactored page uses the `ErrorState` primitive with a destructive-tinted hairline border, an `AlertCircle` icon, the error message, and a `Retry` button — not a full-page takeover.
- [ ] **Refetch indicator** at the top of `<main>` appears (1px hairline, accent color, scanning left-to-right) when *any* query is fetching in the background and disappears when the fetch completes.
- [ ] **Active filter chips** appear on the Claims page above the table whenever a filter is set, and the `×` button on each chip clears that single filter.
- [ ] **Newly-streamed row highlight** flashes the new rows in any list for ~2 seconds after a successful parse (accent-tinted background fade to transparent).
- [ ] **Status badges** for the new mapper output (`draft|submitted|accepted|denied|paid|pending` and `received|posted|reconciled`) use the *existing* palette and existing `ClaimStatusBadge` / `RemitStatusBadge` components — no new colors, no new components.
- [ ] **Pagination footer** on list pages uses the new `Pagination` primitive with ` 1 2 3 … N ` and the `returned / total / has_more` summary; active page echoes the sidebar `nav-active` 1px accent line.
- [ ] **No new accent color, no new typeface, no new layout primitive, no light theme** — the page refactors and the new UX patterns sit inside the existing aesthetic voice.