Frontend
The dashboard (apps/dashboard/) is a TanStack Start application — Vite + TanStack Router + TanStack
Query + TanStack Form — running as an SPA with server functions reserved for the auth path. It
talks to the FastAPI backend through the apiFetch client and uses @supabase/supabase-js
getClaims() for session handling, per ADR-050.
Project shape
Section titled “Project shape”apps/dashboard/ is an independent Vite build. There is no shared frontend code beyond explicit
workspace packages under packages/. Build orchestration runs through turbo (per ADR-050 D7);
local dev runs via pnpm dev for the full stack or pnpm dev:dashboard for the dashboard alone.
apps/dashboard/├── vite.config.ts # Vite + TanStack Start plugin├── app.config.ts # Start config — SPA default; ssr: false├── src/│ ├── client.tsx # Client entry — hydrates the router│ ├── router.tsx # Router instance + QueryClient binding│ ├── routes/ # File-based routes (see below)│ ├── server/ # Server functions (auth path only)│ ├── lib/│ │ ├── api.ts # apiFetch client│ │ ├── supabase.ts # @supabase/supabase-js browser client│ │ ├── auth-context.tsx # Auth state + getClaims()│ │ └── queries/ # TanStack Query hooks│ └── components/└── .env.local # VITE_* env vars (generated by tools/dev/start.sh)The architecture validator enforces that routes live under apps/<app>/src/routes/ and follow the
file-based router conventions (per ADR-050 D8).
Design system
Section titled “Design system”Both apps/dashboard/ and apps/operations/ share the same visual language — dark surface, calm
density, monospaced data. New components consume the tokens below directly; do not introduce
parallel palettes or typefaces without a system-level update.
Color palette
Section titled “Color palette”| Token | Hex | Use |
|---|---|---|
| Background | #0F1117 | Application background |
| Surface | #1A1D27 | Cards, panels, raised surfaces |
| Border | #2A2D3A | Dividers, hairlines, low-emphasis borders |
| Primary accent (Spectral blue) | #4F7EF7 | Primary actions, focus rings, key data highlights |
| Success | #22C55E | Confirmations, healthy states |
| Warning | #F59E0B | Soft alerts, attention-required states |
| Critical | #EF4444 | Failures, destructive confirmations |
| Text primary | #F1F5F9 | Headings, primary copy |
| Text secondary | #94A3B8 | Secondary copy, helper text |
| Text muted | #475569 | De-emphasized labels, disabled states |
Typography
Section titled “Typography”- UI copy: Inter
- Headlines: Inter SemiBold
- Data and metrics: JetBrains Mono — every numeric readout, identifier, hash, or column of scores renders in monospace so column alignment and scan-ability hold without manual tabular hacks.
Motion
Section titled “Motion”Motion exists to communicate state change, not to decorate. The defaults:
- Numeric readouts count up from zero on first paint.
- Bars, progress meters, and spectrum strips animate left-to-right.
- Standard transition is 200 ms; longer transitions need a reason.
- Background-process indicators pulse rather than spin — pulse signals “still working,” spin signals “blocked.”
Navigation structure
Section titled “Navigation structure”The dashboard’s customer-facing navigation is structured around the user’s path through a scan, not the underlying engineering decomposition:
SPECTRAL├── Scan — entry point: launch scans, view history├── Dashboard — control plane: briefing, supervisor, KPIs, convergence, strategies, status├── Results│ ├── Evaluation — per-agent rubric panels + case explorer│ ├── Failures — clustered root causes + causal chain│ ├── Experiments — tournament leaderboard│ └── Deploy — promotion gate + holdout validation└── Setup — onboarding wizard + OTel connectionThe Operations app uses a different top-level navigation (operator workflows, not scan flow), but inherits the same palette, typography, and motion rules.
Routing
Section titled “Routing”Routes are file-based under src/routes/. Each route module exports a Route constant created
with createFileRoute; the router generates a typed route tree at build time and at dev-time via
the Vite plugin.
apps/dashboard/src/routes/├── __root.tsx # Root route — mounts AuthProvider, QueryClientProvider├── index.tsx # Root redirect (→ login or last workspace)├── login.tsx # Login route├── register.tsx # Registration route├── auth/│ ├── callback.tsx # OAuth callback — exchanges code, sets cookie│ └── accept-invite.tsx # Invite acceptance landing└── workspace/ ├── new.tsx # Workspace creation └── $workspaceId/ ├── route.tsx # Workspace shell — nav + sidebar + auth guard ├── index.tsx # Workspace home ├── agent/ ├── changesets/ ├── framework/ ├── reports/ ├── otel/ ├── members/ └── settings/Route params use the $paramName convention. Every workspace route receives workspaceId from
the $workspaceId segment via Route.useParams().
Route definition
Section titled “Route definition”// src/routes/workspace/$workspaceId/changesets/index.tsximport { createFileRoute } from "@tanstack/react-router";import { changeSetsQueryOptions } from "@/lib/queries/change-sets";
export const Route = createFileRoute("/workspace/$workspaceId/changesets/")({ loader: ({ context, params }) => context.queryClient.ensureQueryData(changeSetsQueryOptions(params.workspaceId)), component: ChangeSetsPage,});
function ChangeSetsPage() { const { workspaceId } = Route.useParams(); // ... render}Loaders prime the TanStack Query cache so the route renders without a loading flash on
first paint. Components read the same data via useQuery and stay live across refetches.
Auth flow
Section titled “Auth flow”Authentication uses @supabase/supabase-js with the PKCE flow and getClaims() for JWT
claim extraction, per ADR-039 D5 and
ADR-050 D3.
Browser client (src/lib/supabase.ts)
Section titled “Browser client (src/lib/supabase.ts)”import { createClient } from "@supabase/supabase-js";
export const supabase = createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_ANON_KEY, { auth: { flowType: "pkce", persistSession: true, autoRefreshToken: true, }, cookieOptions: { domain: "runspectral.com", // eTLD+1 — shared across app./ops./codex. sameSite: "lax", secure: true, }, },);The runspectral.com cookie scope (per ADR-050 D5) lets a single Supabase session carry across
app.runspectral.com, ops.runspectral.com, and codex.runspectral.com without re-auth.
AuthProvider (src/lib/auth-context.tsx)
Section titled “AuthProvider (src/lib/auth-context.tsx)”AuthProvider mounts under the root route and exposes the session-derived state the rest of
the app reads:
- On mount, calls
supabase.auth.getClaims()to extract verified JWT claims (signature checked locally against the cached JWKS — no per-request round-trip). - Exchanges the access token for a Spectral user profile via
GET /api/v1/auth/me. - Subscribes to
onAuthStateChangeto handle token refresh and sign-out events (skipsINITIAL_SESSIONto avoid a brief null-flash on load).
The context exposes:
type AuthState = { user: UserProfile | null; // Profile + workspace memberships token: string | null; // Current Supabase access_token for API calls loading: boolean; signOut: () => Promise<void>; refresh: () => Promise<void>;};Consuming auth state
Section titled “Consuming auth state”import { useAuth, useWorkspaces, useRole } from "@/lib/auth-context";
// Current user + tokenconst { user, token, loading } = useAuth();
// Workspace membership listconst workspaces = useWorkspaces();
// Role for a specific workspaceconst { role, isOwner, isContributor } = useRole(workspaceId);Route-level auth guards
Section titled “Route-level auth guards”Auth guards live in route beforeLoad hooks. The workspace shell route enforces “must be
signed in” for everything beneath it; per-route scope checks layer on top.
// src/routes/workspace/$workspaceId/route.tsximport { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/workspace/$workspaceId")({ beforeLoad: async ({ context, location }) => { const claims = await context.supabase.auth.getClaims(); if (!claims.data) { throw redirect({ to: "/login", search: { next: location.href } }); } }, component: WorkspaceShell,});Operations app — Pattern A middleware
Section titled “Operations app — Pattern A middleware”apps/operations/ adds a Pattern A JWKS-local auth middleware (per ADR-050 D6) in front of every
route. It validates the JWT against Supabase JWKS in-process, checks OPERATIONS_SCOPES, and
gates the SPA before any route loader runs. A contract test enforces parity with the FastAPI auth
check (per ADR-039 D4a) so the three verifiers — FastAPI, Operations Start, and the Codex Pages
Function — accept and reject the same tokens.
The apiFetch client (src/lib/api.ts)
Section titled “The apiFetch client (src/lib/api.ts)”apiFetch is the single HTTP client for all FastAPI calls. It is a thin wrapper around fetch
that handles headers and error parsing. Per ADR-034,
the frontend never calls PostgREST or supabase.from(...) — all data flows
Frontend → FastAPI → Postgres, and a lint rule rejects supabase-js data-access imports
from the frontend packages.
import { apiFetch } from "@/lib/api";
// GET with authconst data = await apiFetch<MyType>("/api/v1/workspaces/{id}/agents", { token, workspaceId,});
// POST with bodyconst result = await apiFetch<ResultType>("/api/v1/workspaces/{id}/scans", { method: "POST", token, workspaceId, body: { scan_mode: "fast" },});Options:
| Option | Description |
|---|---|
method | HTTP method (default: GET) |
body | JSON-serializable request body |
token | Supabase access token → Authorization: Bearer <token> |
workspaceId | Workspace UUID → X-Workspace-Id header |
signal | AbortSignal for request cancellation |
API_BASE defaults to http://localhost:8000 and is overridden by VITE_API_URL in
production builds.
Errors are thrown as Error with the detail field from the RFC 9457 error response body.
TanStack Query’s retry/backoff and error states consume those errors directly.
Query patterns
Section titled “Query patterns”Data fetching lives in src/lib/queries/. Each query module exports:
- A
queryOptionsfactory — the canonical query key + fetcher pair, reusable across loaders and components. - One or more hooks —
useQuery/useMutationwrappers that readtokenfromuseAuth()and callapiFetchunderneath.
The queryOptions pattern lets a route loader prime the cache and the page component read it
back without duplicating the query key.
useTraces — paginated trace list with filters
Section titled “useTraces — paginated trace list with filters”import { useTraces, type TraceFilters } from "@/lib/queries/traces";
const { data, isPending, refetch } = useTraces(workspaceId, { agent_name: "prior-auth", sort_by: "created_at", sort_order: "desc", limit: 50, offset: 0,});Also exports: useTraceDetail, useSubmitFeedback (mutation).
useChangeSets — changeset list
Section titled “useChangeSets — changeset list”import { useChangeSets } from "@/lib/queries/change-sets";
const { data, isPending, refetch } = useChangeSets(workspaceId);Also exports: useChangeSetDetail, useAcceptChangeSet, useApplyChangeSet,
useRejectChangeSet (mutations), useFailureClusters.
Mutations
Section titled “Mutations”Mutations follow the standard TanStack Query shape — call the API via apiFetch, invalidate the
relevant query keys on success, surface errors to the caller.
const accept = useMutation({ mutationFn: ({ changeSetId }: { changeSetId: string }) => apiFetch(`/api/v1/workspaces/${workspaceId}/changesets/${changeSetId}/accept`, { method: "POST", token, workspaceId, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["changeSets", workspaceId] }); },});Forms use TanStack Form (per ADR-050 D1). It plays cleanly with TanStack Query mutations and keeps validation co-located with the form definition. Use the same form factory across the app — do not hand-roll controlled inputs alongside form state.
Adding a new workspace route
Section titled “Adding a new workspace route”-
Create the route file under
apps/dashboard/src/routes/workspace/$workspaceId/:apps/dashboard/src/routes/workspace/$workspaceId/my-feature/├── index.tsx # List page└── $itemId.tsx # Detail page (if needed) -
Define the route with
createFileRoute. Read params viaRoute.useParams():import { createFileRoute } from "@tanstack/react-router";import { myFeatureQueryOptions } from "@/lib/queries/my-feature";export const Route = createFileRoute("/workspace/$workspaceId/my-feature/")({loader: ({ context, params }) =>context.queryClient.ensureQueryData(myFeatureQueryOptions(params.workspaceId)),component: MyFeaturePage,});function MyFeaturePage() {const { workspaceId } = Route.useParams();const { data, isPending } = useMyFeature(workspaceId);// ... render} -
Add the query module in
src/lib/queries/my-feature.ts. ExportmyFeatureQueryOptionsand auseMyFeaturehook that wrapsuseQuery(myFeatureQueryOptions(workspaceId))and readstokenfromuseAuth(). -
Add a nav link in the workspace shell sidebar so users can discover the page.
-
Add the AC integration test. Per the project DoD, every path between contexts needs an integration test. A new workspace route generally hits a FastAPI endpoint that hits Postgres — the integration test exercises that path end-to-end.
Environment variables
Section titled “Environment variables”Vite exposes only VITE_*-prefixed env vars to the browser bundle.
| Variable | Description | Default |
|---|---|---|
VITE_API_URL | FastAPI backend base URL | http://localhost:8000 |
VITE_SUPABASE_URL | Supabase project URL | from .env |
VITE_SUPABASE_ANON_KEY | Supabase publishable key | from .env |
In local development these are set in apps/dashboard/.env.local (generated by
tools/dev/start.sh). Server-only secrets (anything not safe to ship to the browser) live
outside the VITE_* namespace and are read by server functions only.