| name | heater-appb-web-frontend |
| description | Use when working in HEATER's web/ frontend — Next.js 16 / React 19 / Tailwind v4 pages, wiring a page to /api/* data, pnpm build/gen:api, page titles, PlayerLink/PlayerDialog, viewer team display, slot ordering, or hooking a page into Bubba. Also when a page refetches in a loop, shows the wrong tab title, or type errors appear after backend changes. |
App B Web Frontend (web/) — Next 16 / React 19 / Tailwind v4 / pnpm 10
First rule (web/AGENTS.md): this is NOT the Next.js in your training data. Read the relevant guide in web/node_modules/next/dist/docs/ before writing Next-specific code. Verify from web/: pnpm build (the gate — web has no test suite; CI only checks type-gen sync), pnpm exec tsc --noEmit, pnpm run lint. pnpm is v10 — pnpm 9 misparses pnpm-workspace.yaml's ignoredBuiltDependencies (broke CI until ci.yml:227 pinned 10).
Data wiring pattern (copy /matchup)
- Types come from
pnpm gen:api → src/lib/api/generated.ts (never hand-edit; regen after backend schema changes).
src/lib/<page>-data.ts exposes fetch<Page>() = liveOrMock(liveFetcher, mockFetcher) (src/lib/api/live.ts:13-19; NEXT_PUBLIC_HEATER_LIVE=1 selects live; live errors PROPAGATE — no catch — so the state machine sees them).
apiGet/apiPost (src/lib/api/client.ts) hit same-origin /api/* (server-side rewrite, next.config.ts:3-13 → HEATER_API_BASE) and attach the Clerk Bearer token via authToken() — which awaits window.Clerk.loaded (client.ts:30-54); do not bypass it or hard-loads 401-bounce (the M-2/M-3 incident).
- Page:
const fetcher = useMemo(() => () => fetchX(args), [args]); const { state, retry } = usePageData(fetcher); — the fetcher MUST be identity-stable (use-page-data.ts:47); an inline arrow re-created per render = infinite refetch loop.
usePageData (src/lib/use-page-data.ts:56-119) states: loading / error / empty / loaded + locked (402 → paywall) + unlinked (409 TEAM_NOT_LINKED → "link your team"). Render ALL of them. Dev shortcut: ?state=loading|error|empty.
- On
loaded it publishes {pageId, data} to BubbaContext (line 92) — pageId from window.location.pathname, avoiding next/navigation's static-generation bailout. Wiring usePageData = Bubba context for free.
Canonical components & conventions
- PlayerLink/PlayerDialog — the ONE player card. Keyed by
mlbId, fetch-on-open, skeletonDetail fallback when live detail is missing; PlayerLink sets data-bubba-tag="name|mlbId" (Bubba's click-to-tag). Every player name/row goes through it.
- Titles: per-route server segment
layout.tsx with export const metadata = { title: "Matchup" }; root template "HEATER — %s" (app/layout.tsx:29). Failure story (M-4): a client-side DocumentTitle hack set titles after hydration — wrong title on hard load/SSR; deleted in favor of layout metadata. Don't reintroduce client title-setting.
- Slot order:
src/lib/slot-order.ts (SLOT_ORDER lines 17-34, IL_ALIASES) mirrors the backend's order — matchup + optimizer + start-sit must sort identically; order by the ASSIGNED slot (currentSlot), not the recommended position (LineupTable slotKey fix: a 2nd catcher belongs under Util/BN, not a second "C").
- Viewer team:
src/lib/viewer-team.ts module cache (default "Team Hickey", updated by fetchMyTeam from the API's identity-resolved team). Never hardcode a team name in a page (the pre-f2decf7 Standings/Trades bug).
- Design tokens:
src/lib/tokens.ts (COLORS, TIERS, MLB.headshot/teamLogo) + src/lib/motion.ts (EASE_SNAP, staggers, useCountUp) + Tailwind @theme in app/globals.css. No raw hex; no recharts — viz is hand-rolled SVG in components/viz/.
- Route crossfade lives in
app/template.tsx (reduced-motion aware).
When NOT to apply
Backend contract design → heater-appb-endpoint-slice / contracts-openapi. Bubba composer internals → heater-appb-bubba-ai. The Streamlit UI (Combustion lock etc.) is out of scope for App B work.