Files
cyclone/.superpowers/skills/cyclone-frontend-page/SKILL.md
T

11 KiB

name, description
name description
cyclone-frontend-page Cyclone React page conventions (TanStack Query, use<X> hook, drawer, URL state, .test.tsx sibling, Layout / PageHeader / Sidebar). Use when: adding a new page, refactoring an existing one, or wiring a drawer into a page.

cyclone-frontend-page

Cyclone pages live at src/pages/<Name>.tsx: a use<X> hook in src/hooks/use<X>.ts does the fetching (and optionally the live-tail subscription), <Layout> + <PageHeader> + <Sidebar> (src/components/) provide the app shell, and any right-side detail (claim, remittance) lives in src/components/<DrawerName>/ whose open/close state is mirrored to the URL via useDrawerUrlState. This skill codifies the conventions so additions stay consistent with the eleven pages already shipped.

As of this writing: 11 pages under src/pages/ (9 of 11 with a *.test.tsx sibling — Dashboard, Upload, and BatchDiff are not yet covered; be the first when you refactor them), ~30 hooks under src/hooks/, and 2 drawer modules at src/components/{ClaimDrawer,RemitDrawer}/. The next increment is SP22.

When to use

  • Adding a new page. Mounting a new screen in src/pages/ — you need the Layout + PageHeader + table shape, the use<X> hook split, and the route registration point in src/App.tsx.
  • Refactoring an existing page. Splitting a 600-line page, swapping a manual fetch for a hook, or moving in-component state into the URL — load this skill to confirm the destination shape.
  • Wiring a drawer. Adding a new right-side detail drawer (e.g. BatchDrawer, ActivityDrawer) — you need useDrawerUrlState for URL-driven open/close, the src/components/<DrawerName>/ folder layout, and the deep-link contract so ?claim=… / ?remit=… round-trips.
  • Sharing state via URL. Persisting filter / page / drawer state across reloads — confirm the useDrawerUrlState / useRemitDrawerUrlState hook pair is the right tool before reaching for useState + history.

Conventions

  1. Page shape. Every page in src/pages/<Name>.tsx exports a function <Name>() (most pages use a named export — see src/pages/Claims.tsx:51, Remittances.tsx:62, Dashboard.tsx:68; src/pages/Inbox.tsx:40 is the lone default export). The app shell is provided by the <Layout> route wrapper in src/App.tsx:30. Each page sets a <PageHeader> (src/components/PageHeader.tsx:18) and renders a table, list, or KPI grid. Sidebar nav is mounted once by <Layout> at src/components/Sidebar.tsx.
  2. Data hook. Each page pairs with a use<X> hook in src/hooks/use<X>.ts (e.g. ClaimsuseClaims, RemittancesuseRemittances, AcksuseAcks). The hook returns { data, isLoading, isError, error, refetch } from TanStack Query's useQuery (see src/hooks/useClaims.ts:24-31, useRemittances.ts:21-25). Pages never call fetch or @/lib/api directly — the hook is the boundary so the page is testable with vi.mock("@/lib/api", ...).
  3. Live tail. Pages with live data compose three hooks in order: the use<X> initial fetch, useTailStream(resource) (src/hooks/useTailStream.ts:80 — opens the NDJSON stream, drives the backoff/stall state machine), and useMergedTail(resource, baseItems, filterFn?) (src/hooks/useMergedTail.ts:25 — merges snapshot + tail, dedup'd by id). The full wiring lives at src/pages/Claims.tsx:85-89 and Remittances.tsx:81-83. The streaming subscription belongs on the page, not in the data hook — hoisting it couples the lifecycle to whoever mounts use<X>.
  4. Drawer. Right-side detail drawers live in src/components/<DrawerName>/ (currently ClaimDrawer/, RemitDrawer/) with a barrel index.ts (src/components/ClaimDrawer/index.ts:1-14). The drawer is wired to URL state via useDrawerUrlState() for ?claim=… (src/hooks/useDrawerUrlState.ts) or useRemitDrawerUrlState() for ?remit=… (src/hooks/useRemitDrawerUrlState.ts). Open state is driven by the URL, not a useState flag, so deep-links round-trip.
  5. Tests. Every page gets a src/pages/<Name>.test.tsx sibling (e.g. Claims.test.tsx, Remittances.test.tsx, Batches.test.tsx). Every hook gets a src/hooks/use<X>.test.ts sibling (e.g. useClaims.test.ts, useRemittances.test.ts). The shared setup — // @vitest-environment happy-dom, IS_REACT_ACT_ENVIRONMENT = true, QueryClient provider, vi.mock("@/lib/api", ...) — is documented in cyclone-tests; mirror src/pages/Claims.test.tsx:1-30 for the canonical page-test shape.
  6. UI primitives. Use Radix-backed components from src/components/ui/ (button.tsx, dialog.tsx, table.tsx, select.tsx, pagination.tsx, empty-state.tsx, error-state.tsx, filter-chips.tsx, skeleton.tsx, input.tsx, label.tsx, card.tsx, badge.tsx, skip-link.tsx, claim-state-badge.tsx). Don't pull in a new UI library without discussion — every primitive here is already consumed by at least one shipped page.
  7. Routing. Pages register their route in src/App.tsx as a <Route path="<name>" element={<<Name>> />} /> inside the <Layout> element wrapper (src/App.tsx:30-44). Currently every page is a static import; switch to React.lazy(() => import(...)) only if a page grows heavy (large parse/EDI libs, chart code) and the import cost shows up in the bundle report.

Patterns

Claims.tsx-style page (Layout + PageHeader + table + drawer)

Canonical page shape — composes the data hook, the tail triplet, and useDrawerUrlState for the ?claim=… deep-link. See src/pages/Claims.tsx:51-200 for the full file.

import { useMemo, useState } from "react";
import { Table, TableBody, TableRow, /* … */ } from "@/components/ui/table";
import { PageHeader } from "@/components/PageHeader";
import { ClaimDrawer } from "@/components/ClaimDrawer";
import { useClaims } from "@/hooks/useClaims";
import { useDrawerUrlState } from "@/hooks/useDrawerUrlState";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { TailStatusPill } from "@/components/TailStatusPill";

export function Claims() {
  const [status, setStatus] = useState<ClaimStatus | null>(null);
  const params = useMemo(() => ({ status, limit: 25, offset: 0 }), [status]);

  const { data } = useClaims(params);
  const { status: tailStatus, lastEventAt, forceReconnect } = useTailStream("claims");
  const items = useMergedTail("claims", data?.items ?? [], (c) => !status || c.status === status);
  const { claimId, openClaim, closeClaim } = useDrawerUrlState();

  return (
    <>
      <PageHeader
        eyebrow="Inbox"
        title="Claims"
        status={<TailStatusPill status={tailStatus} lastEventAt={lastEventAt} onReconnect={forceReconnect} />}
      />
      <Table>
        <TableBody>
          {items.map((c) => (
            <TableRow key={c.id} onClick={() => openClaim(c.id)}>{/* …cells… */}</TableRow>
          ))}
        </TableBody>
      </Table>
      <ClaimDrawer claimId={claimId} claims={items} onClose={closeClaim} onNavigate={openClaim} />
    </>
  );
}

use<X> data hook (TanStack Query)

Pattern from src/hooks/useClaims.ts:24-67 and useRemittances.ts:21-65. Returns a stable shape so the page treats all data hooks uniformly. The tail subscription lives on the page (see Convention 3), not here.

import { useQuery } from "@tanstack/react-query";
import { api, type ListClaimsParams, type PaginatedResponse } from "@/lib/api";
import type { Claim } from "@/types";

export function useClaims(params: ListClaimsParams) {
  return useQuery<PaginatedResponse<Claim>>({
    queryKey: ["claims", params],
    queryFn: () => api.listClaims<Claim>(params),
    enabled: api.isConfigured,
    // …in-memory fallback when !api.isConfigured…
  });
}

Drawer component with useDrawerUrlState

Pattern from src/components/ClaimDrawer/ClaimDrawer.tsx:1-50 + src/hooks/useDrawerUrlState.ts. The drawer takes claimId as a prop (driven by the URL), renders nothing when null, and uses useDrawerKeyboard for j/k navigation + Escape to close. The page that mounts it controls the URL — openClaim("abc") sets ?claim=abc, removing the param closes the drawer, and reload preserves the state.

import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useClaimDetail } from "@/hooks/useClaimDetail";
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";

export function ClaimDrawer({ claimId, claims, onClose, onNavigate, onToggleHelp }) {
  const open = claimId !== null;
  const { data, isLoading, isError, error } = useClaimDetail(claimId);
  useDrawerKeyboard({ open, claims, onClose, onNavigate, onToggleHelp });
  if (!open) return null;
  return (
    <Dialog open onOpenChange={(o) => !o && onClose()}>
      <DialogContent>{/* header + body panels from useClaimDetail… */}</DialogContent>
    </Dialog>
  );
}

Anti-patterns

  • Don't fetch from inside a page component. All API access goes through the use<X> hook in src/hooks/use<X>.ts. Pages that reach for fetch(...) directly can't be tested with vi.mock("@/lib/api", ...) and split the data lifecycle across files. The hook returns { data, isLoading, isError, error, refetch } so the page is a pure renderer.
  • Don't open a drawer via local component state. A const [open, setOpen] = useState(false) for a drawer breaks deep-links and reload-restore. Use useDrawerUrlState() (claim) or useRemitDrawerUrlState() (remit) so the URL is the single source of truth.
  • Don't put domain logic in JSX. Conditional renderings, table sorting, and KPI math all belong in the use<X> hook or a pure helper under src/lib/ (e.g. src/lib/format.ts for currency / date formatting). JSX is for layout; mixing in items.filter(...).sort(...) inline is hard to test and hides behavior from the hook.
  • Don't call useTailStream from inside a use<X> hook. The streaming subscription belongs on the page (see src/pages/Claims.tsx:85-89). Hoisting it into useClaims couples the open/close lifecycle of the page to whoever mounts the hook, and breaks the one-resource-one-page ownership that the backoff/stall state machine assumes.
  • Don't import a new UI library to render a button, modal, or table. Radix-backed primitives in src/components/ui/ already cover every widget the shipped pages use. Reach for a new library only after the primitive gap is real and the proposal is in a spec / PR.
  • cyclone-tail — load for any live-data page (Claims, Remittances, ActivityLog). Documents the useTailStream + useMergedTail triplet, the <TailStatusPill> wiring, and the 30s stall threshold (STALL_TIMEOUT_MS = 30_000 at useTailStream.ts:53).
  • cyclone-api-router — load when a page is calling a new HTTP endpoint. Documents api_routers/<topic>.py vs. inline api.py registration and the response / error-envelope shapes.
  • cyclone-tests — load when adding the *.test.tsx sibling. Documents the // @vitest-environment happy-dom setup, IS_REACT_ACT_ENVIRONMENT = true, the @testing-library/react vs. createRoot+Probe rendering styles, and the vi.mock("@/lib/api") convention.
  • cyclone-edi — load when a page renders parsed 837P / 835 / 999 / 270 / 271 / 277CA / TA1 content (ServiceLinesTable, CAS panels, ValidationPanel). Documents the parser modules, the R-coded validator rules, and the CAS / CARC / NPI / EIN helpers.