Skip to content
GitHub
Developer

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.


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).


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.

TokenHexUse
Background#0F1117Application background
Surface#1A1D27Cards, panels, raised surfaces
Border#2A2D3ADividers, hairlines, low-emphasis borders
Primary accent (Spectral blue)#4F7EF7Primary actions, focus rings, key data highlights
Success#22C55EConfirmations, healthy states
Warning#F59E0BSoft alerts, attention-required states
Critical#EF4444Failures, destructive confirmations
Text primary#F1F5F9Headings, primary copy
Text secondary#94A3B8Secondary copy, helper text
Text muted#475569De-emphasized labels, disabled states
  • 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 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.”

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 setup

The 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.


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().

// src/routes/orgs/$orgId/domains/$domainId/decisions/index.tsx
import { 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.


Authentication uses @supabase/supabase-js with the PKCE flow and getClaims() for JWT claim extraction, per ADR-039 D5 and ADR-050 D3.

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 mounts under the root route and exposes the session-derived state the rest of the app reads:

  1. On mount, calls supabase.auth.getClaims() to extract verified JWT claims (signature checked locally against the cached JWKS — no per-request round-trip).
  2. Exchanges the access token for a Spectral user profile via GET /auth/me.
  3. Subscribes to onAuthStateChange to handle token refresh and sign-out events (skips INITIAL_SESSION to 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>;
};
import { useAuth, useDomains, useRole } from "@/lib/auth-context";
// Current user + token
const { 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);

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.tsx
import { 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,
});

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.


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 auth
const 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:

OptionDescription
methodHTTP method (default: GET)
bodyJSON-serializable request body
tokenSupabase access token → Authorization: Bearer <token>
signalAbortSignal 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.


Data fetching lives in src/lib/queries/. Each query module exports:

  • A queryOptions factory — the canonical query key + fetcher pair, reusable across loaders and components.
  • One or more hooks — useQuery / useMutation wrappers that read token from useAuth() and call apiFetch underneath.

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).

import { useWorldModel } from "@/lib/queries/world-model";
const { data, isPending } = useWorldModel(orgId, domainId);

Also exports: useActionRegistry, useSystemCard, useWorldModelCard.

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.


  1. 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)
  2. Define the route with createFileRoute. Read params via Route.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
    }
  3. Add the query module in src/lib/queries/my-feature.ts. Export myFeatureQueryOptions and a useMyFeature hook that wraps useQuery(myFeatureQueryOptions(orgId, domainId)) and reads token from useAuth().

  4. Add a nav link in the domain shell sidebar so users can discover the page.

  5. 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.


Vite exposes only VITE_*-prefixed env vars to the browser bundle.

VariableDescriptionDefault
VITE_API_URLFastAPI backend base URLhttp://localhost:8000
VITE_SUPABASE_URLSupabase project URLfrom .env
VITE_SUPABASE_ANON_KEYSupabase publishable keyfrom .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.