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 |
|
| 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:
| Key | Storage | Written by | Cleared by | Lifetime |
|---|---|---|---|---|
accessToken, refreshToken | localStorage | storeTokens in src/config/authConfig.ts | clearAuthStorage | until logout or a failed refresh |
access_token, refresh_token, idToken | localStorage (legacy names, read as fallback) | older code | clearAuthStorage | same |
user | localStorage (token mode only) | ApiService.login | clearAuthStorage | same |
currentWorkspaceId | localStorage | workspace switch, 409 bootstrap | clearAuthStorage | same |
mockUserEmail | localStorage | dev mock login only | clearAuthStorage | dev only |
alchemy-theme | localStorage | ThemeProvider (always light) | never by logout | permanent |
alchemy_consent | localStorage | ConsentProvider | never by logout | permanent |
redirectPath | sessionStorage | public/404.html | RedirectHandler in App.tsx on first render | one 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:
| Route | Used by | Parsed |
|---|---|---|
POST /api/auth/login | AuthContext.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/me | AuthContext.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/refresh | refreshAccessToken in authSession.ts (bare axios, no interceptors) | { access_token, refresh_token? }; any failure resolves to null |
POST /api/auth/logout | ApiService.logout | ignored; storage is cleared in finally |
POST /api/auth/dev-login | AuthContext on localhost in a dev build only | same 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/bootstrap | the 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
- Browser loads
index.html; the only inline script appends${VITE_API_URL}/static/scribe.js;src/index.tsxinitialises Sentry when a DSN is baked and renders<App />into#rootunderReact.StrictMode. AuthProvidermounts: if an access token exists it callsGET /api/auth/meand buildsuserfrom the answer (surfaces included); in cookie mode with no token it tries one refresh; in a dev build onlocalhostwith no token it callsdev-login; otherwiseloadingbecomesfalsewithuser = null.WorkspaceProvidermounts: skips on public paths (/embed/,/f/,/book/,/public/), otherwise loads the user's workspaces and selectscurrentWorkspaceIdwhen it is stored and still a member.Routesresolves the path. Every page is alazy(() => import(...))behind oneSuspensewithPageLoader; public routes render bare, private routes render<PrivateRoute>{page}</PrivateRoute>, admin routes<AdminRoute>, desk routesDeskEmployeeRouteorDeskAgentRoute.PrivateRoute(src/components/PrivateRoute.tsx): whileloading, a spinner; with nouser,Navigateto/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.- The page renders
<Navigation>{content}</Navigation>(58 pages atb1089b6);Navigation.tsxis a thin delegator toAppShell, so every private page is framed by the same three-column shell without a central layout route. AppShellcomputesactiveDomain(pathname)fromDOMAINS(longest matching path prefix), filters the rail byhiddenandadminOnly(admins and the public demo account see the agency group), filters the contextual column byemployeeOnlythroughuseDeskEmployee(), and mountsPrimaSlideOver, the workspace switcher and the Cmd-K palette (paletteOpen, line 52).- A page action calls a method on the
apisingleton 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 viauseToast(), inline error copy, or a refetch). - Logout:
clearAuthStorage()removes only auth keys, deletes the axios default header, setsuser = null; consent and theme survive.
Invariants
- No private page renders outside
<Navigation>; there is no second shell.AdminNavigation.tsxexists atb1089b6and is scheduled for retirement, not for reuse. DOMAINSinnav.tsxis 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 someDomain.pathsstill renders but highlights Home.GET /api/auth/mesurfacesis the authorisation contract.isAdminandroleare labels for the UI. Absent or emptysurfacesmeans deny.- Exactly one refresh round trip per burst of 401s (
refreshPromiseinauthSession.ts); the refresh request goes throughbareAxiosand is never intercepted; any/auth/*URL is excluded from the retry branch (isAuthPath). - A 409
no_workspaceis meant to bootstrap once per request (_wsBootstrapflag) and never loop; at fd8558f the interceptor reads a key the server does not send, so the invariant holds only once the client readsdata.errorordata.details.error(_shared/workspace-scopingGotchas, 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, andscripts/build-frontend-staging.shrefuses 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 inauthConfig.ts). - Theme is light and stays light:
ThemeProviderremoves.darkfrom<html>on mount (ThemeContext.tsxline 29) andtoggleThemeis a no-op; everydark:variant in the codebase is dead. - Colour, radius, font and shadow come from
tailwind.config.jsby class name. A hex literal insrc/is a defect (130 files still carry#6844C4atb1089b6; 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 throughactiveDomain()and assert group order (vitest run src/components/__tests__/nav.test.tspassed 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 stubfetchor 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 undernode --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.tsline 5 re-exportstokensfrom./tokens, so the "zero consumers" oftokens.tsis 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 aspurple.DEFAULTandprimary.500;Button.tsxprimary variant is one of them (bg-[#6844C4]). Fix: D1a's grep-replace tobg-purple/bg-primary-500; a new app starts with zero. - 2026-09-11 (measured):
ThemeProviderlocks light and clears.dark; everydark:class is unreachable. Writing newdark:variants is wasted work and a false promise in a screenshot. - 2026-09-11 (measured,
scripts/build-frontend-staging.shlines 90 to 94): cookie mode cannot bootstrap a session cross-origin because the password login inAuthContext.tsxposts withoutwithCredentials; the staging build refusesVITE_USE_COOKIE_AUTH=true. Leave it off until the login call is fixed. - 2026-09-11 (measured):
src/services/authSession.tsis named that way because the repo's.gitignorehas a*token*rule; a file namedauthTokens.tswould be silently untracked. Do not rename it. - 2026-09-11 (measured,
AuthContext.tsxlines 279 to 327 andapi.tslines 48 to 111): there are two 401 interceptors, one on the globalaxiosinstance installed byAuthProviderand one on theapisingleton. Both go through the same single-flightrefreshAccessToken, 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.tsxis an orphan;/dashboardrendersHome.tsx. A recipe that says "the dashboard" meansHome. - 2026-09-11 (measured,
vite.config.tslines 33 to 44): the reference resolves a client component monorepo through aliases to../external/<submodule>; a build on a clone without--recurse-submodulesfails at alias resolution. A new app has no such alias; the atoms it needs are inalchemy-ui. - 2026-09-11 (rendering trap,
index.html): the<html>element carries an inlinebackground-colorand atheme-colormeta with the brand purple so the first paint before CSS loads is not white. Keep both when copyingindex.html, and keep them equal topurple.DEFAULT. - 2026-09-11 (rendering trap): pages that render their own sticky header (the form editor) must be listed in
AppShell'sisFullBleedregex 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.tslines 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 behindVITE_SENTRY_DSN,createRoot, StrictMode.frontend/src/App.tsx: lazy page imports (44 to 171),EmbedRedirectandRedirectHandler(177 to 198), the provider tree (200 to 213), the route table (214 to 1003), legacy redirects at the end.frontend/src/components/PrivateRoute.tsxlines 27 to 89:SURFACE_HOME, the redirect-with-destination, the not-permitted screen.frontend/src/components/AdminRoute.tsx:isAdmingate, bounce to/.frontend/src/components/Navigation.tsx: the delegator toAppShell.frontend/src/components/AppShell.tsx:AppShell(34),AppShellBody(45 onward,isFullBleedat 61), rail at 157, contextual column at 230, palette at 372.frontend/src/components/nav.tsx:Domaintype (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),verifyTokenwithsurfaces(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.tslines 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(ToastProvider103,useToast165).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.shanddeploy/infomaniak/Caddyfile.stagingfor 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.
npm ci && npm run build: exits 0;dist/index.htmlexists;grep -rl "localhost:8000" dist/assetsprints nothing whenVITE_API_URLwas set;grep -rl "$VITE_API_URL" dist/assetsprints at least one chunk.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.VITE_SURFACE=nope npm run devthen open the app: the console showsVITE_SURFACE=nope is not a known surfaceand nothing renders.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).grep -rli "6844c4" src | wc -lon a new app:0. On the reference atb1089b6:130(the D1a baseline).grep -rl "<Navigation>" src/pages src/components | wc -lequals the number of private pages;grep -rl "AppShell" src | wc -lshowsNavigation.tsxandAppShell.tsxonly, plus tests.- 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 onePOST /api/auth/refreshfollowed by the original request repeated once with a newAuthorizationheader; (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. - Screenshot diff:
/design-systemat 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). - Serving:
curl -sI https://<spa-host>/assets/<any chunk>showscache-control: public, max-age=31536000, immutable;curl -sI https://<spa-host>/loginshowsno-cache;curl -s https://<spa-host>/api/anythingreturns 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.