shape
Component as a folder
Each file has one job; useState in .tsx is a lint error.
apps/ui
The UI is a Vite + React SPA with a typed OpenAPI client: fast local feedback and a compile-time contract with the API. Architecture rules keep feature folders small enough for humans and agents to change safely.
Vite
local feedback loop
React 19
SPA shell
OpenAPI
server contract
A production-shaped SPA. Architecture rules (component anatomy, queries vs stores, OpenAPI client) keep features from turning into 600-line .tsx blobs as the codebase grows.
flowchart LR page["MyPage.tsx<br/>pure JSX, no state"] hook["MyPage.hooks.ts<br/>useState · useEffect · useCallback"] types["MyPage.types.ts<br/>IMyPageView (hook's return shape)"] query["my-feature.queries.ts<br/>TanStack Query"] store["my-feature.store.ts<br/>Zustand (UI state)"] page --> hook hook --> query hook --> store hook -.->|returns| types page -.->|reads| types
Each UI feature folder splits into role-specific files: a pure-JSX
.tsx renders what its hook returns; a .hooks.ts owns
all React hooks plus the calls into TanStack Query and Zustand; a
.types.ts declares the view-object shape the component reads.
Components never touch queries, stores, or env directly.
Components only ever see the view object from their hook. They never read TanStack Query directly, never read Zustand directly, never read import.meta.env directly. That’s what makes any component trivially testable.
shape
Each file has one job; useState in .tsx is a lint error.
state
Server state and client state stay in separate buckets.
contract
Wrong paths and body shapes fail the typecheck.
tokens
You own primitives in components/ui while theme tokens stay centralized.
tests
Playwright hits the running API directly; there is no mock layer.
agents
The folder anatomy is enforced before review, not remembered by convention.
Every authenticated route renders inside AppShell: a brand-marked left sidebar (AppSidebar with NavLink + aria-[current=page]: Tailwind active styling), a sticky header (account switcher · notification bell · theme toggle · logout), and the page content. On mobile the sidebar collapses into a Sheet drawer triggered from the header.
SettingsPage ships with an explicit “placeholder, fill this in” copy block so a fork knows the page is wired into the nav but the form is yours to write.
A page or component folder always looks like:
Stories ship 1:1 with the components and run under a global theme decorator (@storybook/addon-themes wired in .storybook/preview.tsx), so every story has a light/dark toggle in the Storybook toolbar with no per-story plumbing.
bun run new:component <Name> writes this anatomy. bun run new:feature <name> writes a feature scaffold.
*.queries.ts with TanStack Query.
*.store.ts with Zustand for modals, drawers, and step indexes.
*.hooks.ts with React Hook Form and Zod.
*.hooks.ts returns IXxxView; no extra store.
If you can’t tell which bucket something belongs to, that’s almost always a sign the boundary is wrong; not a need for a fifth bucket.
The API publishes /swagger/json. bun run generate:api reads it and emits the typed client. From there apiClient.GET("/api/v1/users/me") autocompletes the path and types the response. Drift between server and client becomes a compile error, not a runtime 500.
See OpenAPI client.
unit
Vitest + Testing Library for hooks, utilities, and schemas.
component
Render the component and its hook together, not a mocked UI stub.
e2e
Playwright runs against the API and UI from Compose.
visual
Per-platform baselines catch layout drift before release.
See Testing.
The component anatomy is held in place by @boring-stack-pkg/eslint-plugin-react-component-architecture. TanStack Query cache consistency on *.queries.ts is enforced by @boring-stack-pkg/eslint-plugin-tanstack-query-cache; static translation keys by @boring-stack-pkg/eslint-plugin-i18n-keys. Those sit alongside the shared plugin family. See Lint as the contract for the full inventory.
apps/ui on GitHub. Start in src/features/ for the feature shape; src/lib/api/ for the typed client.