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 customer dashboard’s navigation is structured around the four read-only inspection tabs of the v0 surface per Customer Dashboard:
SPECTRAL├── Decisions — live feed filterable by status (GREEN / GREEN-SKIP / YELLOW / RED); per-decision detail panel├── World Model — version-scoped: context schema, configuration block, action registry, rule corpus with dual provenance├── Actions — action registry per (org, domain): per-action header (aggregation mode, rule count, content hash, deployment date), rule list with tier badges├── System Card — deployment-scoped: totals, p50/p95/p99 latency, four-state status distribution, methodology disclosure, version history└── Settings — members, API keys, integration setupThe Operations app uses a different top-level navigation (operator workflows — authoring, distillation, publication, override-pattern triage), 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 org/domain)├── login.tsx # Login route├── register.tsx # Registration route├── auth/│ ├── callback.tsx # OAuth callback — exchanges code, sets cookie│ └── accept-invite.tsx # Invite acceptance landing└── orgs/ └── $orgId/ └── domains/ └── $domainId/ ├── route.tsx # Domain shell — nav + sidebar + auth guard ├── index.tsx # Domain home (Decisions tab) ├── decisions/ # Decisions inspection tab ├── world-model/ # World Model inspection tab ├── actions/ # Action registry inspection tab ├── system-card/ # System Card inspection tab ├── members/ └── settings/Route params use the $paramName convention. Every domain route receives orgId + domainId
from the $orgId + $domainId segments via Route.useParams().
Route definition
Section titled “Route definition”// src/routes/orgs/$orgId/domains/$domainId/decisions/index.tsximport { createFileRoute } from "@tanstack/react-router";import { decisionsQueryOptions } from "@/lib/queries/decisions";
export const Route = createFileRoute("/orgs/$orgId/domains/$domainId/decisions/")({ loader: ({ context, params }) => context.queryClient.ensureQueryData(decisionsQueryOptions(params.orgId, params.domainId)), component: DecisionsPage,});
function DecisionsPage() { const { orgId, domainId } = 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 /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 + org/domain 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, useDomains, useRole } from "@/lib/auth-context";
// Current user + tokenconst { user, token, loading } = useAuth();
// Domain membership list (across orgs)const domains = useDomains();
// Role for a specific (org, domain)const { role, isOwner, isContributor } = useRole(orgId, domainId);Route-level auth guards
Section titled “Route-level auth guards”Auth guards live in route beforeLoad hooks. The domain shell route enforces “must be signed
in” for everything beneath it; per-route scope checks layer on top.
// src/routes/orgs/$orgId/domains/$domainId/route.tsximport { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/orgs/$orgId/domains/$domainId")({ beforeLoad: async ({ context, location }) => { const claims = await context.supabase.auth.getClaims(); if (!claims.data) { throw redirect({ to: "/login", search: { next: location.href } }); } }, component: DomainShell,});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>(`/orgs/${orgId}/domains/${domainId}/decisions`, { token,});
// POST with body (e.g., flag a decision for operator review)const result = await apiFetch<ResultType>( `/orgs/${orgId}/domains/${domainId}/decisions/${decisionId}/review-request`, { method: "POST", token, body: { note: "Unexpected YELLOW on standard vendor flow" }, },);Options:
| Option | Description |
|---|---|
method | HTTP method (default: GET) |
body | JSON-serializable request body |
token | Supabase access token → Authorization: Bearer <token> |
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.
useDecisions — paginated decision feed with filters
Section titled “useDecisions — paginated decision feed with filters”import { useDecisions, type DecisionFilters } from "@/lib/queries/decisions";
const { data, isPending, refetch } = useDecisions(orgId, domainId, { status: ["YELLOW", "RED"], action: "wire_transfer.release", sort_by: "request_time", sort_order: "desc", limit: 50, offset: 0,});Also exports: useDecisionDetail (audit-chain trace per decision),
useRequestOperatorReview (mutation), useMarkNoteworthy (mutation).
useWorldModel — version-scoped artifact
Section titled “useWorldModel — version-scoped artifact”import { useWorldModel } from "@/lib/queries/world-model";
const { data, isPending } = useWorldModel(orgId, domainId);Also exports: useActionRegistry, useSystemCard, useWorldModelCard.
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 requestReview = useMutation({ mutationFn: ({ decisionId, note }: { decisionId: string; note: string }) => apiFetch( `/orgs/${orgId}/domains/${domainId}/decisions/${decisionId}/review-request`, { method: "POST", token, body: { note }, }, ), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["decisions", orgId, domainId] }); },});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 domain route
Section titled “Adding a new domain route”-
Create the route file under
apps/dashboard/src/routes/orgs/$orgId/domains/$domainId/:apps/dashboard/src/routes/orgs/$orgId/domains/$domainId/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("/orgs/$orgId/domains/$domainId/my-feature/")({loader: ({ context, params }) =>context.queryClient.ensureQueryData(myFeatureQueryOptions(params.orgId, params.domainId),),component: MyFeaturePage,});function MyFeaturePage() {const { orgId, domainId } = Route.useParams();const { data, isPending } = useMyFeature(orgId, domainId);// ... render} -
Add the query module in
src/lib/queries/my-feature.ts. ExportmyFeatureQueryOptionsand auseMyFeaturehook that wrapsuseQuery(myFeatureQueryOptions(orgId, domainId))and readstokenfromuseAuth(). -
Add a nav link in the domain 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 domain 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.