> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Frontend architecture

> Next.js App Router layout, the backend proxy that keeps the browser off FastAPI directly, the react-query data layer, and the brand token system.

The frontend (`/Users/rushabhpatel/Desktop/causeloop.ai/frontend`) is a single Next.js 15 / React 19 application with two distinct authenticated surfaces — the tenant console and the staff Onboarding Portal — plus a small set of public auth routes, all served from one `src/app` tree. There is no separate SPA build per surface; route groups and per-group layouts do the separation.

## App Router layout

```text theme={null}
src/app/
├── layout.tsx                    # root HTML shell: fonts, globals.css
├── providers.tsx                 # module-scoped QueryClientProvider
├── (console)/                    # tenant console — route group, no URL segment
│   ├── layout.tsx                 # auth gate + app shell (Rail, Pagebar, overlays)
│   ├── session-context.tsx        # React context carrying the resolved Session
│   ├── page.tsx                   # Home
│   ├── issues/page.tsx
│   ├── loops/page.tsx
│   ├── remediation/page.tsx
│   ├── sources/page.tsx
│   └── settings/page.tsx
├── admin/
│   ├── login/page.tsx              # public — staff login
│   └── (portal)/                   # staff Onboarding Portal — route group
│       ├── layout.tsx               # auth gate + admin shell (AdminRail, AdminPagebar)
│       ├── page.tsx
│       ├── control-plane/page.tsx
│       ├── jobs/page.tsx
│       └── tenants/{[tenantId]/page.tsx, new/page.tsx}
├── login/page.tsx                  # public — tenant + staff login (tabbed)
├── accept-invite/page.tsx          # public — invitation acceptance
└── api/
    ├── backend/[...path]/route.ts   # the single authenticated proxy to FastAPI
    └── workspaces/route.ts          # public pre-login workspace picker (own fetch, not the proxy)
```

`(console)` and `(portal)` are [route groups](https://nextjs.org/docs/app/building-your-application/routing/route-groups) — the parentheses keep them out of the URL — used here purely so each surface gets its own server-rendered auth gate and shell without affecting routing. A request to `/issues` never touches `admin/(portal)/layout.tsx` and vice versa.

<Note>
  Both group layouts (`src/app/(console)/layout.tsx`, `src/app/admin/(portal)/layout.tsx`) are `async` Server Components that read the relevant session cookie, call the backend's `/me` or `/admin/me` directly (server-to-server, via `BACKEND_URL`, not through the `/api/backend` proxy — there is no browser request to intercept yet), and `redirect()` to the matching login page on any failure. This is the actual authorization boundary for both surfaces: client-side checks in `session-context.tsx` exist for convenience, not enforcement.
</Note>

### Root layout and providers

`src/app/layout.tsx` is the one HTML document shell for the whole app: it loads three Google Fonts (`Bricolage_Grotesque` for display, `Public_Sans` for body, `IBM_Plex_Mono` for mono/tabular values) as CSS variables and imports `globals.css`. `suppressHydrationWarning` is set on `<html>` because the theme store (below) can add a `data-theme` attribute after mount.

`src/app/providers.tsx` creates one module-scoped `QueryClient` — deliberately outside the component so its cache survives re-renders and is shared by every `useConsoleData()` consumer instead of being reconstructed per mount. Defaults: `staleTime: 5 * 60 * 1000`, `retry: 1`, `refetchOnWindowFocus: false`. Both the console and admin layouts wrap their children in `<Providers>`.

## The `/api/backend/[...path]` proxy

`src/app/api/backend/[...path]/route.ts` is the only path from the browser to FastAPI. **The browser never calls the backend origin directly** — every `fetch()` in `src/lib/api/*.ts` targets `/api/backend/<path>` (same origin, via the `API_PREFIX` constant in `src/lib/api/client.ts`), and this one catch-all route handler forwards it server-side to `BACKEND_URL` (`src/lib/backendUrl.ts`: `process.env.CAUSELOOP_API_URL`, defaulting to `http://127.0.0.1:18000` for local hot reload).

What the proxy does on every hop, implemented as one shared `proxy()` function reused by the `GET`/`POST`/`PATCH`/`PUT`/`DELETE` exports:

<Steps>
  <Step title="Session gate">
    Checks for either `cl_admin_session` or `cl_tenant_session` (`src/lib/authCookies.ts`) on the incoming request. If neither is present and the requested path isn't in a small `PUBLIC_PATHS` allowlist (`admin/login`, `login`, `accept-invite`, the password-reset request/confirm pairs, and the three `health*` operations), it returns `401 {"detail": "no session"}` without ever reaching the backend. The health paths are public deliberately — an external deployment smoke check only needs the frontend URL, not a session.
  </Step>

  <Step title="Header sanitation">
    Copies inbound headers except a hop-by-hop set (`host`, `connection`, `content-length`) and an inbound `Forwarded`/`X-Forwarded-*`/`X-Real-Ip` set, which are stripped rather than recomputed — the only header the proxy itself derives is `x-causeloop-client-ip` (below), so the backend never sees an `X-Forwarded-For` from the proxy. Critically, it also strips **every** inbound `x-causeloop-*` header before forwarding — a client-supplied `X-CauseLoop-Tenant-Id` or similar would otherwise pass straight through and could spoof backend-trusted identity headers.
  </Step>

  <Step title="Trusted client IP injection">
    If `x-forwarded-for` is present (set by the real ingress, not the browser), the proxy parses the last hop, validates it's a real IP with Node's `net.isIP`, and sets it as `x-causeloop-client-ip`. The backend only honors this header when `CAUSELOOP_TRUST_FRONTEND_PROXY=true` (`services/api/auth/client_ip.py`) — otherwise it falls back to the raw socket peer, which in a private network is the frontend container itself.
  </Step>

  <Step title="Forward and stream back">
    `fetch(targetUrl, { method, headers, body: request.body, duplex: "half", redirect: "manual" })`. Response headers are copied with `.append` (not `.set`) specifically because `Set-Cookie` can appear more than once (e.g. login sets both the session cookie and the CSRF cookie) and `fetch`'s `Headers` doesn't comma-join it like other headers — `.set` would silently drop all but the last cookie.
  </Step>
</Steps>

A backend connection failure (refused, reset, DNS failure) is caught and turned into a stable `502 {"detail": "backend unavailable"}` instead of an opaque Next.js 500.

`src/app/api/workspaces/route.ts` is a second, narrower server route that is **not** part of the proxy: it calls `BACKEND_URL/workspaces` directly (this is the public, pre-login tenant picker used by the login page, so there's no session to gate) and reshapes the response, degrading to `{ workspaces: [], degraded: true }` on any backend error rather than surfacing a raw failure to a page a visitor hasn't even logged into yet.

## Session context

Both group layouts resolve identity server-side and pass it down as a plain value, not a client-side fetch:

* **Console**: `(console)/layout.tsx` fetches `/me` with the tenant cookie forwarded manually, builds a `Session` (`src/lib/session.ts`) from the response — `name`, `initials` (computed from `full_name`), a `WorkspaceRole` mapped from the backend's raw `permissions` list via `mapPermissionsToWorkspaceRole` (`src/lib/api/tenantAuth.ts`: `users.manage` → `Risk Admin`, `sources.manage` → `Loop Owner`, `reviews.write` → `Analyst`, else `Auditor`) — and provides it through `SessionProvider` (`session-context.tsx`), consumed client-side via `useSession()`.
* **Admin portal**: `admin/(portal)/layout.tsx` fetches `/admin/me` and passes the resulting `EmployeeIdentity` (`id`, `email`, `full_name`, `control_role`) directly as a prop to `AdminRail`, with no separate context provider.

`Session` also carries two role-rank helpers, `canReview()` (Analyst and above) and `canOperate()` (Loop Owner and above), used to gate UI actions — the backend remains authoritative for every mutation regardless of what the console shows.

## React-query data layer (`src/lib/data`, `src/lib/api`)

`src/lib/api/*.ts` is the request layer: each exported function wraps one backend operation through `apiGet`/`apiSend` (`src/lib/api/client.ts`), which prefix every call with `/api/backend` and throw a typed `ApiError` (`status`, `detail`, plus `isNotFound`/`isConflict`/`isForbidden` getters) on any non-2xx response. Mutating tenant calls go through `tenantSend()` (`src/lib/api/tenantAuth.ts`), which reads the non-httpOnly CSRF cookie (`readCsrfToken()`, `src/lib/authCookies.ts`) and attaches it as `x-csrf-token` — the backend's double-submit check (`services/api/auth/csrf.py`) rejects any mutating request where the header doesn't match the cookie.

`src/lib/data/useConsoleData.ts` is the console's single source of truth for tenant insight data. It wraps one `useQuery` (key `["tenant-insights", session.clientId]`, `queryFn: fetchClientInsights` from `src/lib/api/clientInsights.ts`, which reads `GET /insights/snapshot`) and layers three pieces of behavior on top:

1. **Expensive adaptation runs inside `select`**, not the render body (`selectClientAdapt`) — `adaptClientInsights()` (`src/lib/data/client-adapter.ts`) walks all nine insight collections and is deliberately memoized by TanStack per `(data, select)` reference pair rather than reallocated on every re-render across the console's \~10 `useConsoleData()` consumers.
2. **Live polling while generating**: `refetchInterval` returns `4000` while any collection's `status` is `"generating"`, `false` once all are settled.
3. **A four-state result union** — `loading`, `locked`, `ready` (with `data`, optional `pending: CollectionName[]` for still-generating LLM collections, and an optional `narrative`), and `error` (carrying a passthrough `refetch()`). `locked` is the state for a tenant onboarded with `share_onboarding_outputs=false` and nothing yet visible — ported from the legacy mockup's soft "dashboards populate automatically" message, distinct from `loading`, which would otherwise spin forever for that tenant.

The nine collections split into five **model collections** (`summary`, `themes`, `issues`, `severity`, `lens` — fast, deterministic) and four **LLM collections** (`fishbone`, `theme_summaries`, `narratives`, `recommendations`, plus `cause_remediation` tracked separately) that can still be `"generating"` after the model collections are ready; `useConsoleData()`'s `pending` field is exactly how a view knows to show a per-section skeleton instead of blocking the whole page.

## State stores (zustand)

`src/lib/stores/` holds four small, single-purpose [zustand](https://github.com/pmndrs/zustand) stores — no single global store, each owns one concern:

| Store           | File       | Scope                                                                                                                                                                                                             |
| --------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useUi`         | `ui.ts`    | The loop drawer (`drawer.loopId`/`tab`/`focusPath`) and the singleton overlay modal (`finding`, `plan`, `more`, or `cmdk`). `closeAll()` is the single handler wired to the window-level Escape/backdrop close.   |
| `useTheme`      | `theme.ts` | Light/dark theme. Always initializes to `"light"` (deterministic SSR/hydration) — the persisted preference (`localStorage["cl-theme"]`) is applied only client-side, from `ThemeInit`'s `useEffect`, after mount. |
| `useLens`       | `lens.ts`  | The console-wide analysis-depth lens (`hard_hard` default), session-scoped only — deliberately not persisted, unlike theme.                                                                                       |
| `useToastStore` | `toast.ts` | Transient toast notifications, auto-dismissed after 3400ms; `pushErrorToast()` is the convenience call site for mutation error handlers.                                                                          |

## Component system

`src/components/` is organized by role, not by page:

* **`shell/`** — the chrome mounted once per surface: `Rail` (left nav), `Pagebar` (top bar), `InsightsBanner`, `ThemeInit`/`ThemeToggle`, `Toaster`, `ErrorState`, `LockView`, `Skeletons`.
* **`views/`** — one component per console route (`HomeView`, `IssuesView`, `LoopsView`, `RemediationView`, `SourcesView`, `SettingsView`) plus shared sub-views like `AggregationPanel`.
* **`overlays/`** — the globally-mounted singletons driven by `useUi()`: `CommandPalette`, `LoopDrawer`, `MoreModal`, `IssueModal`, `PlanModal`. These are mounted once in `(console)/layout.tsx` rather than per-view specifically so opening one from anywhere in the console (e.g. a path-code click inside the Loops aggregation panel) always renders, regardless of which route triggered it.
* **`charts/`** — `Fishbone`, `Constellation`, `BoneChart`, `BubbleMap`, `Donut`, `DriverBars`, `EngineGraphic`, `HazardWave`, `LoopHeat`, `ScoreHist`, `Sparkline`.
* **`ui/`** — the primitive kit re-exported from `src/components/ui/index.ts`: `Avatar`, `Badge`, `BoardCard`, `Btn`, `Callout`, `Chip`, `Disclosure`, `FakeInput`, `Kpi`, `ListRow`, `LiveDot`, `Seg`, `Switch`, `Tabs`, plus non-exported overlay primitives `Modal`, `Scrim`, `TooltipLayer`.
* **`onboarding/`** — styling shared by the admin portal's tenant wizard.

## Brand token system (`src/styles/tokens.css`)

Every color, shadow, and gradient the UI uses is a CSS custom property defined once on `:root` and overridden on `[data-theme="dark"]` — components never hardcode a hex value. The palette starts from six brand hue families (`--cl-navy`, `--cl-mag-*` magenta, `--cl-peri-*` periwinkle, `--cl-amber-*`, `--cl-red-*`, `--cl-green-*`) and derives semantic tokens from them: `--bg`, `--surface`/`--surface-2`/`--surface-3`, `--ink`/`--body`/`--muted`/`--faint` (text scale), `--line`/`--line-soft`/`--line-hi` (borders), `--accent-text`/`--accent-fill`/`--accent-soft` and matching triples for `peri`/`amber`/`red`/`ok`, plus `--shadow`/`--shadow-lg`/`--inner-hi` for elevation.

Theme switching is a single attribute flip: `ThemeInit` (mounted in the console layout) applies `document.documentElement.dataset.theme = "dark"` (or deletes it for light) from `useTheme`'s `setTheme()`, driven by a persisted `localStorage` key (`cl-theme`) read once on mount — never during SSR, which is what keeps first paint and hydration in sync. `prefers-reduced-motion: reduce` disables all animations/transitions globally as a base rule, independent of theme.

Typography also flows through CSS variables set by the root layout's font loaders: `--font-display` (Bricolage Grotesque, headings), `--font-body` (Public Sans), `--font-mono` (IBM Plex Mono, used by `.mono`/`.num`/`.kicker` utility classes for tabular figures and label text).

## Related

* [Backend architecture](/architecture/backend)
* [Frontend repo tour](/onboarding/frontend-repo-tour)
* [Proxy and sessions](/api-reference/integration/proxy-and-sessions)
* [Security model](/architecture/security)
