Files
rfc-app/docs/superpowers/specs/2026-06-03-m3-frontend-design.md
Ben Stull 87ddb845f4 docs(design): M3-frontend design — routing, runtime branding, directory/switcher, 308s (§22)
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>
2026-06-03 23:59:17 -07:00

86 lines
9.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).