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:
@@ -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 3–5 `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.
|
||||
|
||||
Reference in New Issue
Block a user