diff --git a/docs/superpowers/specs/2026-06-03-m3-frontend-design.md b/docs/superpowers/specs/2026-06-03-m3-frontend-design.md new file mode 100644 index 0000000..b9aa088 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-m3-frontend-design.md @@ -0,0 +1,85 @@ +# 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//` 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//` 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 `` + nested `` (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//`; the `` 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 ``. +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 ``. New route table in `App.jsx`: + +| Path | Renders | +| --- | --- | +| `/` | `` — if exactly one visible project → ``; else `` (cards from `/api/deployment`) | +| `/p/:projectId/*` | `` (fetch `/api/projects/:id`, apply theme, provide `ProjectContext`) wrapping the Catalog left pane + nested routes below | +| `/p/:projectId/` | catalog home (today's ``) | +| `/p/:projectId/e/:slug` | `` — generic `/e/` segment; noun ("RFC/Spec/Feature") from `project.type` | +| `/p/:projectId/e/:slug/pr/:prNumber` | `` | +| `/p/:projectId/proposals/:prNumber` | `` | +| `/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. +- `` 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 `RFC`; 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 `` 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//` redirect; directory with 2+ public projects; **308** from `/rfc/` → `/p//e/`; 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).