Prima Get an API key

Module kit / Foundations

React app skeleton

_shared/react-app-skeleton

Kind shared
Applies to frontend
State written
Stack vite-6, react-19, typescript, tailwind-3.4, react-router-7, axios-1, vitest-2, playwright-1
Depends on _shared/design-system-and-tokens
Reference implementation
  • alchemy_labs/frontend/vite.config.ts
  • alchemy_labs/frontend/src/App.tsx
  • alchemy_labs/frontend/src/components/AppShell.tsx
  • alchemy_labs/frontend/src/components/nav.tsx
  • alchemy_labs/frontend/src/contexts/AuthContext.tsx
  • alchemy_labs/frontend/src/services/api.ts
  • alchemy_labs/frontend/src/services/authedFetch.ts
  • alchemy_labs/frontend/src/services/authSession.ts
  • alchemy_labs/frontend/src/config/surface.ts
  • alchemy_labs/frontend/tailwind.config.js
Measured on alchemy_labs feat/service-desk b1089b6 2026-09-11
Version 1

Purpose

A single-page React app that a signed-in person opens in a browser: one shell frames every private page, one navigation model decides what the shell shows, one HTTP client carries the session and the workspace on every call, and one route guard decides whether this account may be here at all. The invariant that makes it trustworthy is that the browser never decides anything on its own: the shell hides what GET /api/auth/me did not grant, the client retries a 401 exactly once through a single shared refresh, and every write goes to a real endpoint whose answer is rendered as copy.

This skeleton is what every SPA surface in the kit is built on. It makes the identity-auth foundation visible (sign-in, /me, surfaces, workspace bootstrap) and is the frame that every domain's ui/ recipe assumes when it says "inside the shell".

When to use

Use it when a recipe says surface: SPA, when the reader is signed in and stays on one origin for a session, when there are more than a handful of screens, or when the page needs live state (tables that refetch, forms that save, a slide-over that talks to an agent). Trigger phrases: "admin page", "dashboard", "settings screen", "a page inside the shell", "the merchant sees a table of".

Do not use it for a public page that a visitor reads once (marketing site, docs, a portal without a session): that is _shared/astro-site-skeleton. Do not use it for a widget that lives on a customer's own site (hosted form, booking shell, review overlay): those are backend-served or iframed, and the customer's page never holds a session. Do not use it for the chat surface, which has its own package and register (prima-ui-package in ui-design-system).

Stack assumptions

Pinned as measured in alchemy_labs/frontend/package.json at b1089b6 (2026-09-11): vite ^6.0.3 with @vitejs/plugin-react ^4.3.4 (line 92; the plugin's current major, 6.x, requires Vite 8 and npm refuses to resolve it beside vite@6, so the pair is vite@6 with plugin-react@4, measured 2026-09-12 by the rebuild), react ^19.1.1, react-dom ^19.1.1, react-router-dom ^7.8.2, typescript ^4.9.5 (Labs' own pin, not the rule: a new app pins TypeScript 5.x, because @types/react 19 serves its ts5.0/ fallback to any compiler at or below 5.0 and tsc 4.9.5 cannot type-check @prima-modules/alchemy-ui against it; run 2026-09-12 with Labs' own 4.9.5 binary over the package, the first atom fails with TS2339: Property 'div' does not exist on type 'JSX.IntrinsicElements', while the package type-checks under its 5.9.3, packages/alchemy-ui/package.json), tailwindcss ^3.4.17 with @tailwindcss/forms and @tailwindcss/typography, axios ^1.11.0, vitest ^2.1.5 with jsdom ^25, @testing-library/react ^16.3.0, @playwright/test ^1.42.1, lucide-react for icons, @headlessui/react for the few overlays the atoms do not cover.

Foundations assumed: _shared/design-system-and-tokens (S1) for the token file and the component vocabulary. The default design system for this skeleton is alchemy-ui (decision D1b, 2026-09-11): the 19 atoms and 5 molecules under src/components/alchemy-ui/, imported through the barrel src/components/alchemy-ui/index.ts (144 importing files at b1089b6). The token source is frontend/tailwind.config.js and nothing else (decision D1a, 2026-09-11): a new app copies that file, does not copy alchemy-ui/tokens.ts (retired), and writes colour, radius, font and shadow as Tailwind classes or the CSS custom properties the config emits.

Build: npm run build is tsc --noEmit && vite build; a type error blocks the bundle on purpose. Vite's test block is in the same vite.config.ts (environment: 'jsdom', setupFiles: './src/setupTests.ts', globals: true, coverage thresholds 50 percent on statements, branches, functions and lines).

Data-fetch style: the axios singleton exported from src/services/api.ts (export default new ApiService(), 76 importing files at b1089b6) for JSON calls; authedFetch from src/services/authedFetch.ts (3 importers) when the call streams, uploads, or needs Response semantics. A raw fetch or a bare axios.get in a page is a documented exception, never a default.

Environment names, all baked at build time and all public: VITE_API_URL (no trailing slash; callers write ${API_URL}/api/...; falls back to http://localhost:8000 when unset, which the staging Caddy turns into a readable 404 on /api/*), VITE_SURFACE (one of labs, prima_web, prima_chat, prima_dev; default labs; an unknown value throws at module load in src/config/surface.ts), VITE_USE_MOCK_AUTH (dev only; vite build throws in vite.config.ts lines 10 to 17 when it is set outside mode development), VITE_USE_COOKIE_AUTH (default off; see Gotchas), VITE_ENVIRONMENT and VITE_SENTRY_DSN (optional; a DSN is the only credential-shaped value that may ever be in a VITE_* variable, and the staging build script refuses even that in the env file). Nothing else that looks like a key goes in a VITE_* variable: every one of them is string-inlined into the public bundle.

Copy language: German du-Form for the merchant surface (Laden..., Dein Überblick über den Workspace in nav.tsx), English for agency and admin surfaces and for every technical error copy.

Data model

Client state lives in the provider tree, mounted in this order in src/App.tsx (function App(), line 200): ErrorBoundary > ThemeProvider (light, locked) > Router > ConsentProvider > AuthProvider > WorkspaceProvider > ConnectionProvider > PrimaProvider > ToastProvider > Suspense > Routes. Order matters: WorkspaceProvider reads useAuth(), AppShell reads both plus usePrima().

AuthContext holds user: User | null and loading. User is { userId, email, name, emailVerified, isAdmin, clientStatus?, actingEmail?, surfaces: string[] } (src/contexts/AuthContext.tsx lines 24 to 46). surfaces defaults to [] so a user object built from a response without grants opens nothing. The context value also exposes surface: BUILD_SURFACE (compile-time) and hasSurface: boolean.

WorkspaceContext holds currentWorkspace, currentUserRole (owner | admin | member | viewer | guest | desk_requester), workspaces[], isLoadingWorkspaces, and the helpers switchWorkspace, refreshWorkspaces, createWorkspace, hasPermission, isWorkspaceAdmin, isWorkspaceOwner.

Browser storage, token mode (the default), with lifetime:

KeyStorageWritten byCleared byLifetime
accessToken, refreshTokenlocalStoragestoreTokens in src/config/authConfig.tsclearAuthStorageuntil logout or a failed refresh
access_token, refresh_token, idTokenlocalStorage (legacy names, read as fallback)older codeclearAuthStoragesame
userlocalStorage (token mode only)ApiService.loginclearAuthStoragesame
currentWorkspaceIdlocalStorageworkspace switch, 409 bootstrapclearAuthStoragesame
mockUserEmaillocalStoragedev mock login onlyclearAuthStoragedev only
alchemy-themelocalStorageThemeProvider (always light)never by logoutpermanent
alchemy_consentlocalStorageConsentProvidernever by logoutpermanent
redirectPathsessionStoragepublic/404.htmlRedirectHandler in App.tsx on first renderone navigation

In cookie mode (VITE_USE_COOKIE_AUTH=true) the access and refresh token live only in module memory (memoryTokenStore in authConfig.ts) and nothing auth-related is written to localStorage.

Types for a page live next to it as types.ts or inside the service module that owns the endpoint; the skeleton itself exports Surface (src/config/surface.ts) and Domain, DomainId, DomainGroup (src/components/nav.tsx).

API surface

Backend contracts the skeleton consumes, with the envelope it parses:

RouteUsed byParsed
POST /api/auth/loginAuthContext.login{ access_token, refresh_token, user: { id, email, name, is_admin, client_status, surfaces } }; on error error.response.data.error becomes the thrown message
GET /api/auth/meAuthContext.verifyToken on mount and after refresh{ id, email, first_name, last_name, username, is_verified, is_admin, client_status, acting_email, surfaces[] }; surfaces is the authorisation contract
POST /api/auth/refreshrefreshAccessToken in authSession.ts (bare axios, no interceptors){ access_token, refresh_token? }; any failure resolves to null
POST /api/auth/logoutApiService.logoutignored; storage is cleared in finally
POST /api/auth/dev-loginAuthContext on localhost in a dev build onlysame shape as login; the branch is dead code in a production bundle
POST /api/auth/ws-ticket?purpose=...any WebSocket consumer{ ticket, expires_in }; the JWT never goes in a WebSocket URL
POST /api/workspaces/bootstrapthe 409 branch of the api.ts response interceptor{ id }, stored as currentWorkspaceId, then the original request is retried once; the branch is written for a detail key the server does not send, so it does not fire at fd8558f (see _shared/workspace-scoping Gotchas, 2026-09-11)

Error envelopes rendered as copy: a FastAPI { detail } (string or object), the Labs { error, code, request_id, timestamp }, and the middleware { error, message }; _shared/error-envelope says which applies where. The two the skeleton handles itself: 401 (one refresh, one retry, then clearAuthStorage() and window.location.href = '/login' unless the current path is public) and 409 with detail.error === 'no_workspace' (bootstrap, retry once, then reject; measured 2026-09-11 at fd8558f the Labs handler emits error and details and never detail, so the condition is false on every real 409 and the bootstrap never runs, _shared/workspace-scoping Gotchas).

Headers on every intercepted request (api.ts lines 32 to 45): Authorization: Bearer <access token> when one exists, X-Workspace-ID: <currentWorkspaceId> when one is stored. The backend re-validates the workspace header against the user's memberships; the header is a hint, not a grant.

Same-tab events: alchemy:auth-changed (AUTH_CHANGED_EVENT, authSession.ts line 60) fires on setTokens and clearAuthStorage so WorkspaceContext reloads without polling localStorage.

Application logic

  1. Browser loads index.html; the only inline script appends ${VITE_API_URL}/static/scribe.js; src/index.tsx initialises Sentry when a DSN is baked and renders <App /> into #root under React.StrictMode.
  2. AuthProvider mounts: if an access token exists it calls GET /api/auth/me and builds user from the answer (surfaces included); in cookie mode with no token it tries one refresh; in a dev build on localhost with no token it calls dev-login; otherwise loading becomes false with user = null.
  3. WorkspaceProvider mounts: skips on public paths (/embed/, /f/, /book/, /public/), otherwise loads the user's workspaces and selects currentWorkspaceId when it is stored and still a member.
  4. Routes resolves the path. Every page is a lazy(() => import(...)) behind one Suspense with PageLoader; public routes render bare, private routes render <PrivateRoute>{page}</PrivateRoute>, admin routes <AdminRoute>, desk routes DeskEmployeeRoute or DeskAgentRoute.
  5. PrivateRoute (src/components/PrivateRoute.tsx): while loading, a spinner; with no user, Navigate to /login?redirect=<path+search> so the destination survives a magic-link round trip; with a user but !hasSurface, the "This account can't be used here" screen listing the surfaces the account does hold; otherwise the page.
  6. The page renders <Navigation>{content}</Navigation> (58 pages at b1089b6); Navigation.tsx is a thin delegator to AppShell, so every private page is framed by the same three-column shell without a central layout route.
  7. AppShell computes activeDomain(pathname) from DOMAINS (longest matching path prefix), filters the rail by hidden and adminOnly (admins and the public demo account see the agency group), filters the contextual column by employeeOnly through useDeskEmployee(), and mounts PrimaSlideOver, the workspace switcher and the Cmd-K palette (paletteOpen, line 52).
  8. A page action calls a method on the api singleton or a domain service built on it; the request interceptor adds the two headers; the response interceptor handles 401 and 409 as in API surface; the page renders the outcome (toast via useToast(), inline error copy, or a refetch).
  9. Logout: clearAuthStorage() removes only auth keys, deletes the axios default header, sets user = null; consent and theme survive.

Invariants

  • No private page renders outside <Navigation>; there is no second shell. AdminNavigation.tsx exists at b1089b6 and is scheduled for retirement, not for reuse.
  • DOMAINS in nav.tsx is the only IA. The rail, the contextual column, the palette, the active state and the Home tiles all derive from it; a route that is not in some Domain.paths still renders but highlights Home.
  • GET /api/auth/me surfaces is the authorisation contract. isAdmin and role are labels for the UI. Absent or empty surfaces means deny.
  • Exactly one refresh round trip per burst of 401s (refreshPromise in authSession.ts); the refresh request goes through bareAxios and is never intercepted; any /auth/* URL is excluded from the retry branch (isAuthPath).
  • A 409 no_workspace is meant to bootstrap once per request (_wsBootstrap flag) and never loop; at fd8558f the interceptor reads a key the server does not send, so the invariant holds only once the client reads data.error or data.details.error (_shared/workspace-scoping Gotchas, 2026-09-11).
  • No mutation without an endpoint: a button that changes state calls a service method that exists; optimistic UI without a mutator is a defect.
  • VITE_* values are public. A credential in one is a leak, and scripts/build-frontend-staging.sh refuses the env file when it finds one.
  • A production bundle cannot contain the mock login (build-time throw in vite.config.ts, module-load throw in authConfig.ts).
  • Theme is light and stays light: ThemeProvider removes .dark from <html> on mount (ThemeContext.tsx line 29) and toggleTheme is a no-op; every dark: variant in the codebase is dead.
  • Colour, radius, font and shadow come from tailwind.config.js by class name. A hex literal in src/ is a defect (130 files still carry #6844C4 at b1089b6; D1a's branch replaces them).

Frontend

The rendering is the shell, so every SPA recipe's tree starts here:

<html lang="en"> (index.html, no analytics tag, scribe.js appended by the inline script)
  #root
    ErrorBoundary
      ThemeProvider (light)
        Router
          ConsentProvider > AuthProvider > WorkspaceProvider > ConnectionProvider > PrimaProvider > ToastProvider
            RedirectHandler (sessionStorage.redirectPath, once)
            ConsentBanner
            Suspense fallback=PageLoader ("Laden...")
              Routes
                Route public        -> <Page/>
                Route private       -> PrivateRoute > <Page/>
                Route admin         -> AdminRoute > <Page/>
                <Page/>
                  Navigation = AppShell
                    OnboardingProvider (coachmarks portal)
                      nav.rail  68 px   [data-onboard=rail]  group headers from GROUP_ORDER, one icon per visible Domain, workspace switcher, "Zeig mir die App", logout
                      aside.contextual 224 px, hidden below md   active.label, items filtered by employeeOnly
                      main  max-w-[1100px] (full-bleed for /forms/new, /forms/edit/*, /admin/forms/distillation)
                        {children}
                      PrimaSlideOver (mounted once)
                      CommandPalette (Cmd-K / Ctrl-K, Escape closes)
                    Toast.viewport (bottom, from ToastProvider)

The shell exists as code only in Labs (frontend/src/components/AppShell.tsx, 512 lines at fd8558f); neither @prima-modules/alchemy-ui nor a demo in this kit carries it yet (the appshell-navigation demo is RENDERING.md milestone M2, planned). Until M2 lands, the tree above is the specification and a new app writes its own AppShell.tsx to it: the 68 px rail, the 224 px contextual column hidden below md, main at max-w-[1100px], the palette on Cmd-K with Escape closing it; the 2026-09-12 rebuild did exactly that and the walk-through under Acceptance checks passed against it.

Tokens used by the shell, all from tailwind.config.js: purple scale with DEFAULT #6844C4 for the active rail item and primary actions, ink, ink-60, ink-40 for text, paper for the page ground, surface for cards, line for hairlines, shadow-card, shadow-lift, shadow-pop, font-sans (ES Rebond Grotesque with Karla fallback), rounded-pill, animate-fade-up, animate-scale-in. Atoms keep the pill radius as a ratio of height (about 0.4085: rounded-[16px] at h-10, rounded-[20px] at h-12, rounded-[26px] at h-16, Button.tsx lines 31 to 38).

States every page inside the shell must render: loading (the atom Spinner or Skeleton, never a blank main), empty (a sentence in the user's words plus the primary action, du-Form on merchant pages), error (the envelope's message as copy plus a retry), and the shell's own !hasSurface screen (English, lists the other surfaces). Responsive: the contextual column disappears below md (768 px); the rail stays; content is fluid to 1100 px. A screenshot reference for the shell is the /design-system route (DesignSystemDemo.tsx, public, no session needed) and the recipe-level PNG each domain ships.

Security & tenancy

What the browser may hold: an access token and a refresh token (localStorage in token mode, memory in cookie mode), the user's own profile, the current workspace id, consent and theme. Never an API key, never a signing key, never a third-party credential; those stay behind the backend and are reached by endpoints that take the session.

Origin rules: the API is a different origin (VITE_API_URL), reached with Authorization: Bearer, and withCredentials is off by default so the public-embed CORS (which must not allow credentials) is untouched. Cookie mode flips withCredentials on for authenticated calls only. The SPA host answers /api/* with a 404 and a sentence, so a bundle built without VITE_API_URL fails loudly instead of parsing HTML as JSON.

Surface gating: VITE_SURFACE is compiled into the bundle and compared with /me.surfaces (hasSurfaceAccess). This is a UX gate; the security boundary is the backend's router mount (mount_router(app, router, surfaces=...) and assert_every_route_gated in alchemy_labs/backend/api/surface_gate.py), which refuses the same request regardless of what the bundle shows.

Hidden by CSS versus enforced: hidden, adminOnly and employeeOnly in DOMAINS remove entries from the rail and column; the routes stay mounted and the pages behind them bounce (AdminRoute to /, desk routes to /desk) and the APIs answer 403 or 404. Nothing in the nav model is load-bearing.

Fail direction: a missing or malformed surfaces array denies. A failed refresh logs out. A 409 bootstrap that fails rejects the original call. Rate limiting is the backend's; the client does not retry on 429.

Attacker-controlled inputs the skeleton touches: ?redirect= on /login (the login page forwards it as next on the magic-link request; the backend is the place that validates it is a relative path), sessionStorage.redirectPath from 404.html (consumed once, navigated with replace), and the token fragment on /auth/callback (consumed by that page only).

Tests

Layout at b1089b6: vitest runs everything under src/**/*.test.ts(x) and src/**/__tests__/ with jsdom, globals and src/setupTests.ts (which imports @testing-library/jest-dom/vitest); Playwright runs frontend/e2e/*.spec.ts against PLAYWRIGHT_BASE_URL (default http://localhost:3000, starts npm run dev only for local targets, five device projects).

What the skeleton itself ships and a new app copies:

  • src/components/__tests__/nav.test.ts: 64 table-driven cases that resolve every real route through activeDomain() and assert group order (vitest run src/components/__tests__/nav.test.ts passed 64 of 64 on 2026-09-11, 1.4 s). Add a row for every route a new page introduces.
  • src/test-utils/index.tsx: renderWithProviders(ui, { authValue, initialRoute }), renderWithAuth, renderWithoutAuth, and fixture objects; jsdom render checks stub fetch or axios and assert the rendered copy per state (loading, empty, error, populated).
  • src/services/__tests__/: pure helpers of a service (envelope parsing, date formatting) as plain unit tests. Pure helpers with no DOM and no React can also run under node --test; the repo does not do that yet.
  • e2e/auth.spec.ts, e2e/critical-flows.spec.ts, e2e/forms-gate.spec.ts, e2e/commerce-gate.spec.ts, e2e/staging-smoke.spec.ts: the Playwright gate. A user-facing recipe adds one spec that signs in, walks its page's states and asserts the visible outcome of one write.

Known gap, dated 2026-09-11: src/__tests__/integration/auth.integration.test.tsx is written against jest.mock and its assertions are commented out; it is a template, not a gate. Do not copy it; write vitest vi.mock tests.

Deploy & ops

Build target: Vite static bundle in frontend/dist/ (index.html, assets/* with content hashes, verbatim public/). scripts/build-frontend-staging.sh is the reference build: checks the working tree, loads frontend/.env.staging via --mode staging, refuses mock auth, cookie auth and credential-shaped VITE_* values, runs npm ci and vite build --mode staging, then greps the chunks for VITE_API_URL and for localhost:8000 (must be absent) and tars dist/.

Env baked at build: VITE_API_URL, VITE_SURFACE, VITE_ENVIRONMENT, VITE_USE_MOCK_AUTH=false, VITE_USE_COOKIE_AUTH=false. One bundle per surface and per environment; a bundle is never re-pointed at runtime.

Serving (the estate's SPA host, alchemy_labs/deploy/infomaniak/Caddyfile.staging at b1089b6): releases land in /opt/alchemy/labs-web/releases/r<ts>/ (scripts/ship-frontend-staging.sh line 75, REL="r$(date +%Y%m%d%H%M%S)"; the Caddyfile's own comment at lines 178 to 179 still says <git-sha>, which is stale) and current is a relative symlink swapped atomically; Caddy mounts the parent directory and serves root * /srv/labs-web/current; /assets/* gets Cache-Control: public, max-age=31536000, immutable, everything else no-cache so a stale index.html never pins a deleted chunk; /api/* answers 404 with a sentence; the SPA fallback is try_files to index.html. Rollback is re-pointing the symlink at the previous release. The full ship path is _shared/cloudflare-worker-and-estate-deploy.

CSP and CORS entries the backend must carry for this bundle: the SPA origin in the API's CORS allow-list with credentials off (token mode); the API origin reachable from the SPA for fetch and XMLHttpRequest; wss: to the API origin for ticketed WebSockets; the fonts are self-hosted from public/fonts/, Karla is loaded from Google Fonts in index.html.

Gotchas

  • 2026-09-11 (measured at b1089b6): alchemy-ui/index.ts line 5 re-exports tokens from ./tokens, so the "zero consumers" of tokens.ts is zero consumers of the values; the barrel line has to go in the same change that deletes the file or the build breaks. Fix: D1a's branch removes both.
  • 2026-09-11 (measured): 130 files under src/ contain #6844C4 (case-insensitive) despite the token existing as purple.DEFAULT and primary.500; Button.tsx primary variant is one of them (bg-[#6844C4]). Fix: D1a's grep-replace to bg-purple / bg-primary-500; a new app starts with zero.
  • 2026-09-11 (measured): ThemeProvider locks light and clears .dark; every dark: class is unreachable. Writing new dark: variants is wasted work and a false promise in a screenshot.
  • 2026-09-11 (measured, scripts/build-frontend-staging.sh lines 90 to 94): cookie mode cannot bootstrap a session cross-origin because the password login in AuthContext.tsx posts without withCredentials; the staging build refuses VITE_USE_COOKIE_AUTH=true. Leave it off until the login call is fixed.
  • 2026-09-11 (measured): src/services/authSession.ts is named that way because the repo's .gitignore has a *token* rule; a file named authTokens.ts would be silently untracked. Do not rename it.
  • 2026-09-11 (measured, AuthContext.tsx lines 279 to 327 and api.ts lines 48 to 111): there are two 401 interceptors, one on the global axios instance installed by AuthProvider and one on the api singleton. Both go through the same single-flight refreshAccessToken, so a burst still costs one refresh, but a new app installs one interceptor, on the singleton, and calls /auth/* through bare axios only.
  • 2026-09-11 (measured): Dashboard.tsx is an orphan; /dashboard renders Home.tsx. A recipe that says "the dashboard" means Home.
  • 2026-09-11 (measured, vite.config.ts lines 33 to 44): the reference resolves a client component monorepo through aliases to ../external/<submodule>; a build on a clone without --recurse-submodules fails at alias resolution. A new app has no such alias; the atoms it needs are in alchemy-ui.
  • 2026-09-11 (rendering trap, index.html): the <html> element carries an inline background-color and a theme-color meta with the brand purple so the first paint before CSS loads is not white. Keep both when copying index.html, and keep them equal to purple.DEFAULT.
  • 2026-09-11 (rendering trap): pages that render their own sticky header (the form editor) must be listed in AppShell's isFullBleed regex or the shell's padding double-pads them and the sticky bar sits inset.

Reference implementation

Branch that ships: alchemy_labs feat/service-desk at b1089b6 (2026-09-11). main is behind it (decision D7 asks for a fast-forward before wave 0). Paths are relative to the repo root.

  • frontend/vite.config.ts lines 1 to 78: mock-auth build assertion (10 to 17), aliases (33 to 44), server and preview ports, the vitest block (56 to 70).
  • frontend/index.html: head metas, self-hosted fonts plus Karla, the scribe loader with %VITE_API_URL% interpolation, no analytics tags.
  • frontend/src/index.tsx: Sentry init behind VITE_SENTRY_DSN, createRoot, StrictMode.
  • frontend/src/App.tsx: lazy page imports (44 to 171), EmbedRedirect and RedirectHandler (177 to 198), the provider tree (200 to 213), the route table (214 to 1003), legacy redirects at the end.
  • frontend/src/components/PrivateRoute.tsx lines 27 to 89: SURFACE_HOME, the redirect-with-destination, the not-permitted screen.
  • frontend/src/components/AdminRoute.tsx: isAdmin gate, bounce to /.
  • frontend/src/components/Navigation.tsx: the delegator to AppShell.
  • frontend/src/components/AppShell.tsx: AppShell (34), AppShellBody (45 onward, isFullBleed at 61), rail at 157, contextual column at 230, palette at 372.
  • frontend/src/components/nav.tsx: Domain type (47 to 70), DOMAINS (77 to 347), GROUP_ORDER (349), activeDomain (367 to 382), DomainIcon (384 onward).
  • frontend/src/contexts/AuthContext.tsx: User (24 to 46), mount logic (78 to 116), verifyToken with surfaces (118 to 154), login (156 to 221), logout (248), refreshToken (256), the global interceptor (279 to 327), memoised value (329 to 343).
  • frontend/src/config/surface.ts: SURFACES, BUILD_SURFACE, hasSurfaceAccess.
  • frontend/src/config/authConfig.ts: the two flags, assertAuthConfig, memoryTokenStore, getAccessToken, storeTokens, clearTokens.
  • frontend/src/services/authSession.ts: bareAxios (36), AUTH_CHANGED_EVENT (60), isAuthPath (78), clearAuthStorage (95), refreshAccessToken (145).
  • frontend/src/services/api.ts: constructor with both interceptors (18 to 111), auth methods (111 onward), export default new ApiService() (663).
  • frontend/src/services/authedFetch.ts lines 27 to 42.
  • frontend/src/contexts/WorkspaceContext.tsx: the workspace model and the public-path skip list.
  • frontend/src/components/alchemy-ui/index.ts, atoms/, molecules/Toast.tsx (ToastProvider 103, useToast 165).
  • frontend/tailwind.config.js: the token source (D1a).
  • frontend/src/components/__tests__/nav.test.ts, frontend/src/test-utils/index.tsx, frontend/src/setupTests.ts, frontend/playwright.config.ts, frontend/e2e/.
  • scripts/build-frontend-staging.sh and deploy/infomaniak/Caddyfile.staging for the build and serve path.

Deviations of the reference from this recipe, all dated 2026-09-11: the hex literals (130 files) and tokens.ts still exist pending D1a; two 401 interceptors instead of one; AdminNavigation.tsx still present; the jest-style integration test; Dashboard.tsx orphaned; the demo account's agency visibility is keyed on an email literal in AppShell.tsx rather than on a surface grant.

Acceptance checks

Run from alchemy_labs/frontend at the commit named in measured_on, or from a new app built to this recipe. Expected observations are exact.

  1. npm ci && npm run build: exits 0; dist/index.html exists; grep -rl "localhost:8000" dist/assets prints nothing when VITE_API_URL was set; grep -rl "$VITE_API_URL" dist/assets prints at least one chunk.
  2. VITE_USE_MOCK_AUTH=true npx vite build: exits non-zero with the message beginning [auth] VITE_USE_MOCK_AUTH must NOT be enabled in a production build.
  3. VITE_SURFACE=nope npm run dev then open the app: the console shows VITE_SURFACE=nope is not a known surface and nothing renders.
  4. npx vitest run src/components/__tests__/nav.test.ts: 64 passed (or the new app's count, which equals the number of rows in the table plus the group-order case).
  5. grep -rli "6844c4" src | wc -l on a new app: 0. On the reference at b1089b6: 130 (the D1a baseline).
  6. grep -rl "<Navigation>" src/pages src/components | wc -l equals the number of private pages; grep -rl "AppShell" src | wc -l shows Navigation.tsx and AppShell.tsx only, plus tests.
  7. Browser walk-through with the dev server and a real backend: (a) open a private route signed out, observe the URL becomes /login?redirect=<that route>; sign in, observe you land on that route inside the three-column shell; (b) with the network tab open, delete the access token in localStorage and click any item in the contextual column, observe one POST /api/auth/refresh followed by the original request repeated once with a new Authorization header; (c) sign in as an account without this build's surface, observe the "This account can't be used here" screen and no rail; (d) press Cmd-K, observe the palette; press Escape, observe it closes; (e) resize to 700 px, observe the contextual column is gone and the rail remains.
  8. Screenshot diff: /design-system at 1280 px against the S1 rendering reference; the atoms' pill radius reads 16, 20 and 26 px at the three button sizes (measure in the inspector).
  9. Serving: curl -sI https://<spa-host>/assets/<any chunk> shows cache-control: public, max-age=31536000, immutable; curl -sI https://<spa-host>/login shows no-cache; curl -s https://<spa-host>/api/anything returns 404 with the sentence naming the API host.

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.