Frontend architecture
The admin dashboard lives inside the main server repo at tappass/frontend/. Not a separate repo — it ships alongside the server so API contracts can’t drift.
| Layer | What we use |
|---|---|
| Framework | React 19 + React Router 7 |
| Language | TypeScript (strict) |
| Bundler | Vite 6 |
| Styling | Tailwind CSS 4 (via @tailwindcss/vite, no tailwind.config.ts) |
| UI primitives | Radix UI (dialog, tabs, tooltip, select, …) |
| Icons | lucide-react |
| Charts | Recharts |
| HTTP | Native fetch wrapped in src/lib/api.ts |
| Analytics | PostHog |
No Redux, no Zustand, no React Query. State is kept small and local.
Folder structure
Section titled “Folder structure”frontend/├── src/│ ├── pages/ # Top-level route components — one file per route│ ├── components/│ │ ├── ui/ # Radix + Tailwind primitives (button, card, badge, …)│ │ ├── Layout.tsx # Sidebar + top bar wrapper│ │ ├── ConnectAgentGuide.tsx # Framework logos + integration steps│ │ └── ...│ ├── lib/│ │ ├── api.ts # fetch wrapper, handles auth + errors│ │ └── utils.ts # cn(), date helpers, etc.│ ├── styles/│ │ └── globals.css # Tailwind @theme + CSS variables│ ├── App.tsx # Router│ └── main.tsx # Entry├── index.html├── vite.config.ts└── package.jsonThe one-shell pattern
Section titled “The one-shell pattern”Layout.tsx is the single shell. Every page renders inside it. The shell owns:
- Collapsible sidebar (240px expanded / 56px collapsed)
- Top bar with environment indicator + user menu
- Theme state (light/dark via
data-themeondocumentElement) - Responsive mobile menu
Pages receive no props — they fetch their own data via src/lib/api.ts.
State philosophy
Section titled “State philosophy”- No global store. Each page fetches its own data on mount.
- URL is state. Search params carry filters, pagination, selected agent, etc.
useSearchParams()from React Router 7. - Local component state for ephemeral UI (open/closed, hover).
useStateis enough. - Context for cross-cutting concerns only. Currently just theme. We refuse to add a context for “the current user” because that’s just
localStorage + useEffect.
If you catch yourself reaching for Zustand, the page is probably doing too much. Split it.
Routing
Section titled “Routing”React Router 7 declarative. Routes list in App.tsx:
<Routes> <Route path="/" element={<Layout />}> <Route index element={<Overview />} /> <Route path="agents" element={<Agents />} /> <Route path="agents/:id" element={<AgentDetail />} /> <Route path="sessions" element={<Sessions />} /> <Route path="audit-trail" element={<AuditTrail />} /> <Route path="playground" element={<Playground />} /> <Route path="settings/*" element={<Settings />} /> <Route path="copilot" element={<Copilot />} /> </Route> <Route path="login" element={<Login />} /></Routes>Shipped pages: Overview, Agents, Pipelines, Sessions, AuditTrail, Copilot, Playground, Settings.
Parked (don’t add without product sign-off): Incidents, Compliance, Integrations, Federation.
Styling conventions
Section titled “Styling conventions”- All colours go through CSS variables in
globals.css(HSL-based) — see the file for the palette. - Tailwind utilities for almost everything; bespoke CSS is a smell.
- Card corner radius:
rounded-xl(12px). Button radius:rounded-lg(8px). Icon container radius:rounded-lg. - Hover motion:
hover:-translate-y-0.5+ shadow bump. Don’t over-animate. - Dark mode uses system preference by default; users can override in the top bar menu.
The in-product Getting Started page is the reference for our visual language. When in doubt, match it.
API integration
Section titled “API integration”Every call goes through src/lib/api.ts:
export async function api<T>( path: string, init?: RequestInit,): Promise<T> { const response = await fetch(`/api/v1${path}`, { ...init, credentials: "include", headers: { "Content-Type": "application/json", ...init?.headers, }, }); if (!response.ok) throw await apiError(response); return response.json();}- Authentication is cookie-based (SSO session), not token-based — so
credentials: "include"is the default. - Errors are thrown as typed
ApiErrorobjects with.status,.code,.message. - 401 triggers a redirect to
/login; the page is never rendered with stale data.
When to use Radix primitives
Section titled “When to use Radix primitives”Always, for: dialogs, tabs, tooltips, select, dropdowns, toggles, popovers.
Rewrite only when: accessibility requirements Radix can’t meet (rare), or the primitive is trivially simple (a badge — we have ui/badge.tsx).
Testing
Section titled “Testing”Currently sparse. If you’re adding tests, put them next to the component as Component.test.tsx and use Vitest. Long-term we want visual regression (Playwright), but that’s not shipped yet.
Building
Section titled “Building”From tappass/frontend/:
npm cinpm run dev # http://localhost:5173 with HMRnpm run build # outputs to dist/The build output is served by the Python server at / — the FastAPI app has a static-file mount. No separate deploy pipeline.
Things we deliberately do NOT use
Section titled “Things we deliberately do NOT use”- Next.js. We don’t have server-side rendering needs; Vite is faster for a dashboard.
- Tailwind plugins for animations / forms. Tailwind 4 + Radix covers it.
- CSS-in-JS. Makes styling hard to grep.
- Form libraries (Formik, React Hook Form). The forms we have are small; native state + validation is enough.
- Component libraries with opinions (MUI, Chakra). Radix + Tailwind gives us full control.