Frontend half of §22 M3, paired with the M3-backend spec. /p/<project>/ routing (BrowserRouter + nested Routes + DeploymentProvider/ProjectLayout), N=1 redirect, the VITE_APP_NAME->runtime hard cut (brandTitle fallback), per-project theme overlay, real server-side 308 redirects, deployment directory + project switcher. Scope boundary: corpus calls stay unscoped (default project only) with a guard placeholder for non-served projects; second-project content is gated on Plan B. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.2 KiB
M3-frontend — §22 multi-project: routing, runtime branding, directory, switcher — design
Date: 2026-06-03
Slice: §22 M3 (frontend half). Pairs with M3-backend (2026-06-03-m3-backend-design.md) which ships the data spine, registry mirror, and the two runtime-config APIs this slice consumes.
Status: design, pending plan. Implementation is gated on M3-backend Plan A's APIs (see §7).
Goal
Ship the §22.9/§22.10 frontend: /p/<project>/ routing, the deployment directory + project switcher, the VITE_APP_NAME→runtime-config hard cut, per-project theme overlay, and real 308 redirects off the old corpus-root URLs. After this slice the framework presents deployment chrome (directory/switcher/brand) over project chrome (catalog/entry view), branded entirely at runtime.
Honest scope boundary. The RFC data calls stay unscoped (/api/rfcs/...) this slice, so a project's actual corpus renders only for the backend's corpus-served (default) project. A second project's entries (the bdd "planner") need the backend to serve per-project RFCs — that is M3-backend Plan B, not this slice. M3-frontend therefore delivers the shell: directory, switcher, runtime branding, theme, /p/<default>/ fully working, and the 308s. projectId is threaded through context so a later slice swaps unscoped calls for scoped ones with minimal churn.
Decisions (from brainstorming)
- Routing architecture — keep
<BrowserRouter>+ nested<Routes>(no migration to a data-router). Add aDeploymentProvidercontext (boots/api/deployment) and aProjectLayout/ProjectProviderfor the/p/:projectId/*subtree (/api/projects/:id). Extract the catalog+main-pane composition out ofApp.jsx(480 lines) intoProjectLayout. - N=1 landing — when exactly one project is visible to the caller,
/redirects to/p/<that-id>/; the<Directory>renders only when 2+ are visible. Preserves OHM's "land in the corpus" UX; the directory appears when a second public project exists. (Visibility is per-caller, §22.5; theunlistedplanner never counts toward the directory.) - Redirects — real HTTP 308, server-side (§5), not client-side
<Navigate>. - Runtime branding — hard cut: remove
VITE_APP_NAMEfrom the build; name/tagline come from/api/deploymentat runtime;brandTitle()(from M3-0) is the neutral'RFC'fallback during the pre-fetch paint. - The guard — a non-corpus-served
projectIdrenders a deliberate "content not yet served" placeholder, never mislabeled default-project content (decouples this slice from Plan B without a wrong-content footgun). - Philosophy stays deployment-level this slice —
/api/projects/:id(per the M3-backend spec) returns no philosophy pointer, so the per-project philosophy split (§14.1) is deferred until the backend serves one.
1. Routing & layout
main.jsx keeps <BrowserRouter>. New route table in App.jsx:
| Path | Renders |
|---|---|
/ |
<DeploymentLanding> — if exactly one visible project → <Navigate replace to="/p/<id>/">; else <Directory> (cards from /api/deployment) |
/p/:projectId/* |
<ProjectLayout> (fetch /api/projects/:id, apply theme, provide ProjectContext) wrapping the Catalog left pane + nested routes below |
/p/:projectId/ |
catalog home (today's <Welcome>) |
/p/:projectId/e/:slug |
<RFCView> — generic /e/ segment; noun ("RFC/Spec/Feature") from project.type |
/p/:projectId/e/:slug/pr/:prNumber |
<PRView> |
/p/:projectId/proposals/:prNumber |
<ProposalView> |
/login, /docs/*, /admin/*, /privacy, /cookies, /settings/*, /invitations/accept, /invites/claim |
unchanged (top-level, full-width chrome) |
- Old
/rfc/:slug,/proposals/:nare removed from the SPA — served as 308s (§5), so they never reach the router. <ProjectLayout>owns the per-project chrome and the guard (§4): corpus routes render only when the project is corpus-served; otherwise a placeholder.
2. Runtime branding (the hard cut)
DeploymentProviderfetchesGET /api/deploymentonce on boot (alongsidegetMe()); provides{ name, tagline, projects[] }via context.- Replace the 6
import.meta.env.VITE_APP_NAMEreads with the context value wrapped inbrandTitle(deployment?.name)(neutral'RFC'fallback during pre-fetch):App.jsx:208(header brand),Landing.jsx:16,BetaPending.jsx:25,Login.jsx:594,pages/Cookies.jsx:32,pages/Privacy.jsx:25.
- Tab title: drop the build-time
%VITE_APP_NAME%token;index.htmlships static<title>RFC</title>; JS setsdocument.titleafter config loads (ProjectLayout→ project name; deployment chrome → deployment name). vite.config.js: remove theVITE_APP_NAMEbuild-timethrowand theinject-app-nameplugin. A build no longer needs the env var (the actual hard cut).
3. Theme overlay
ProjectLayout applies project.theme by setting CSS custom properties on document.documentElement (e.g. style.setProperty('--c-accent', theme.accent)), riding the existing tokens.css :root variable system. Properties are reapplied on project switch and reset on unmount so one project's accent never bleeds into deployment chrome or another project.
4. Chrome split + the guard
- Deployment chrome: header brand (→ deployment name), a project switcher dropdown (visible projects from
/api/deployment), and<Directory>at/. - Project chrome: catalog, entry view — under
ProjectLayout, scoped to one project. - Breadcrumb (§8.1): gains a leading project segment with the type-driven noun (e.g.
OHM / Human › main); threaded in fromProjectContextinto the existing inline breadcrumb markup inRFCView.jsx/PRView.jsx(no component extraction —RFCViewis 1260 lines; full extraction is out of scope). - The guard:
ProjectLayoutrenders the catalog/entry routes only for the corpus-served (default) project; any otherprojectIdrenders a "content not yet served" placeholder. Contract detail to settle at implementation time (against Plan A/B): how the frontend learns which project is corpus-served — a flag on/api/projects/:id, or matching the deployment's default id. Deliberately not over-specified now.
5. 308 redirects (server-side — coordination with M3-backend)
- FastAPI:
GET /rfc/{slug}→ 308/p/{default_id}/e/{slug};GET /proposals/{n}→ 308/p/{default_id}/proposals/{n}.default_idfrom config (DEFAULT_PROJECT_ID; post-restamp = the project's real id). - nginx (
testing/web.nginx.conffor Tier-1, and the proddeploy/nginx/*.conf): route/rfc/and/proposals/to the backend (proxy_pass) instead oftry_files → index.html. - This is the backend/ops layer (it needs
DEFAULT_PROJECT_ID, backend-owned). Coordination point: either the parallel M3-backend session adds it, or this slice contributes the small backend route + nginx rule. Owner to be assigned during planning.
6. Testing
- Vitest unit:
DeploymentProviderfallback viabrandTitle; themesetPropertyapply/reset; N=1 redirect logic; the guard placeholder; the directory render with 0/1/2+ visible projects. - Playwright e2e (M3-0 Tier-1 harness): N=1
/→/p/<id>/redirect; directory with 2+ public projects; 308 from/rfc/<slug>→/p/<id>/e/<slug>; branding from/api/deployment; theme accent applied; switcher navigation; non-served project placeholder. e2e lands once M3-backend Plan A (registry + the two APIs) is in the Tier-1 stack (the seeded Gitea needs aREGISTRY_REPO+projects.yaml).
7. Dependencies & sequencing
- Design: now (unblocked).
- Implementation gated on: M3-backend Plan A —
GET /api/deployment,GET /api/projects/:id(api_deployment.py, not yet built). - Planner's actual content gated on: M3-backend Plan B — per-project RFC serving + scoped frontend calls (a follow-on slice that relaxes the §4 guard).
- 308 piece: needs the backend route + nginx rule (§5).
- Tier-1 e2e: needs Plan A's registry + APIs wired into the M3-0 Docker stack.
8. File-touch summary
New (frontend): src/context/DeploymentProvider.jsx, src/components/ProjectLayout.jsx (+ ProjectContext), src/components/Directory.jsx, src/components/ProjectSwitcher.jsx, src/components/NotServedPlaceholder.jsx; src/api.js additions (getDeployment, getProject).
Modified (frontend): App.jsx (route table, brand), main.jsx (provider wrap), index.html (static title), vite.config.js (drop VITE_APP_NAME), the 6 brand-read files, RFCView.jsx/PRView.jsx (breadcrumb project segment), tokens.css (no change expected; theme is applied via JS).
Server (coordination): a FastAPI redirect route + nginx /rfc/ /proposals/ rule (§5).
9. Versioning
Per the 2026-06-03 decision (bump per breaking slice), M3-frontend's VITE_APP_NAME removal is a build-surface change deployments must act on (set up the registry / runtime config). It rides the same pre-1.0 minor + CHANGELOG upgrade-steps as the M3-backend cut if they land together, or carries its own upgrade-steps block if separate. VERSION + frontend/package.json#version move together (§20).