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

1. **Routing architecture** — keep `<BrowserRouter>` + nested `<Routes>` (no migration to a data-router). Add a `DeploymentProvider` context (boots `/api/deployment`) and a `ProjectLayout`/`ProjectProvider` for the `/p/:projectId/*` subtree (`/api/projects/:id`). Extract the catalog+main-pane composition out of `App.jsx` (480 lines) into `ProjectLayout`.
2. **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; the `unlisted` planner never counts toward the directory.)
3. **Redirects** — **real HTTP 308**, server-side (§5), not client-side `<Navigate>`.
4. **Runtime branding** — **hard cut**: remove `VITE_APP_NAME` from the build; name/tagline come from `/api/deployment` at runtime; `brandTitle()` (from M3-0) is the neutral `'RFC'` fallback during the pre-fetch paint.
5. **The guard** — a non-corpus-served `projectId` renders a deliberate "content not yet served" placeholder, never mislabeled default-project content (decouples this slice from Plan B without a wrong-content footgun).
6. **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/:n` are **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)

- **`DeploymentProvider`** fetches `GET /api/deployment` once on boot (alongside `getMe()`); provides `{ name, tagline, projects[] }` via context.
- Replace the **6** `import.meta.env.VITE_APP_NAME` reads with the context value wrapped in `brandTitle(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.html` ships static `<title>RFC</title>`; JS sets `document.title` after config loads (`ProjectLayout` → project name; deployment chrome → deployment name).
- **`vite.config.js`:** remove the `VITE_APP_NAME` build-time `throw` and the `inject-app-name` plugin. 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 from `ProjectContext` into the existing inline breadcrumb markup in `RFCView.jsx`/`PRView.jsx` (no component extraction — `RFCView` is 1260 lines; full extraction is out of scope).
- **The guard:** `ProjectLayout` renders the catalog/entry routes only for the corpus-served (default) project; any other `projectId` renders 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_id` from config (`DEFAULT_PROJECT_ID`; post-restamp = the project's real id).
- **nginx** (`testing/web.nginx.conf` for Tier-1, and the prod `deploy/nginx/*.conf`): route `/rfc/` and `/proposals/` to the backend (`proxy_pass`) instead of `try_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:** `DeploymentProvider` fallback via `brandTitle`; theme `setProperty` apply/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 a `REGISTRY_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).
