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 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 connection

The Operations app uses a different top-level navigation (operator workflows, not scan flow), 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 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().

// src/routes/workspace/$workspaceId/changesets/index.tsx
import { 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.


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 /api/v1/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 + workspace memberships
token: string | null; // Current Supabase access_token for API calls
loading: boolean;
signOut: () => Promise<void>;
refresh: () => Promise<void>;
};
import { useAuth, useWorkspaces, useRole } from "@/lib/auth-context";
// Current user + token
const { user, token, loading } = useAuth();
// Workspace membership list
const workspaces = useWorkspaces();
// Role for a specific workspace
const { role, isOwner, isContributor } = useRole(workspaceId);

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

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>("/api/v1/workspaces/{id}/agents", {
token,
workspaceId,
});
// POST with body
const result = await apiFetch<ResultType>("/api/v1/workspaces/{id}/scans", {
method: "POST",
token,
workspaceId,
body: { scan_mode: "fast" },
});

Options:

OptionDescription
methodHTTP method (default: GET)
bodyJSON-serializable request body
tokenSupabase access token → Authorization: Bearer <token>
workspaceIdWorkspace UUID → X-Workspace-Id header
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.

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

import { useChangeSets } from "@/lib/queries/change-sets";
const { data, isPending, refetch } = useChangeSets(workspaceId);

Also exports: useChangeSetDetail, useAcceptChangeSet, useApplyChangeSet, useRejectChangeSet (mutations), useFailureClusters.

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.


  1. 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)
  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("/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
    }
  3. Add the query module in src/lib/queries/my-feature.ts. Export myFeatureQueryOptions and a useMyFeature hook that wraps useQuery(myFeatureQueryOptions(workspaceId)) and reads token from useAuth().

  4. Add a nav link in the workspace 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 workspace 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.