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

9.2 KiB
Raw Permalink Blame History

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. Redirectsreal HTTP 308, server-side (§5), not client-side <Navigate>.
  4. Runtime brandinghard 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 AGET /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).