§22 M3 frontend: /p/<project>/ routing, runtime branding, directory, 308s (v0.35.0)

Implements the M3-frontend slice of the §22 multi-project track, per
docs/superpowers/specs/2026-06-03-m3-frontend-design.md (design merged in
#10). Completes the runtime-config cut 0.33.0 (M3-backend Plan A) began.

Frontend:
- DeploymentProvider boots GET /api/deployment → {name, tagline,
  defaultProjectId, projects}; brandTitle() neutral 'RFC' pre-fetch fallback.
- /p/:projectId/* routing with generic /e/<slug> segment. ProjectLayout
  fetches /api/projects/:id, applies per-project theme (reset on switch),
  provides ProjectContext, guards the corpus (served only for the default;
  others get NotServedPlaceholder — decouples this slice from Plan B).
- Directory at / (2+ projects) with N=1 redirect into the single project;
  ProjectSwitcher in deployment chrome; entry-noun by project type.
- VITE_APP_NAME hard cut: removed from vite.config + index.html; the 6 brand
  reads now use deployment.name via context; static <title>RFC</title> + JS
  document.title. Internal /rfc·/proposals links → /p/<project>/e|proposals
  via lib/entryPaths.

Backend:
- GET /api/deployment returns default_project_id (the guard contract).
- Server-side 308s: /rfc/<slug>, /rfc/<slug>/pr/<n>, /proposals/<n> →
  /p/<default>/… . nginx (testing + prod) routes /rfc/ and /proposals/ to
  the backend.

Tests: 3 new backend redirect/deployment tests (438 pass); Vitest unit for
DeploymentProvider, ProjectLayout (theme/guard/404), Directory (11 pass);
clean build with no VITE_APP_NAME. Playwright e2e deferred until Tier-1 seeds
a registry (see CHANGELOG 0.35.0 step 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-04 05:58:59 -07:00
parent 0252e40527
commit 999c4b65ef
39 changed files with 798 additions and 99 deletions
+99
View File
@@ -0,0 +1,99 @@
// §22.10 (M3) — the per-project layout mounted at /p/:projectId/*.
//
// Responsibilities:
// - fetch GET /api/projects/:id (404 → "not found / no access")
// - apply the project's theme as :root CSS custom-property overrides,
// reset on unmount/switch so one project's accent never bleeds into
// deployment chrome or another project (§22.9)
// - set the tab title to the project name
// - provide ProjectContext { project, projectId, served, noun }
// - the §4 guard: render the served corpus (children) only for the
// corpus-served (default) project; any other id gets a "content not yet
// served" placeholder (the per-project RFC serving is Plan B, a later
// slice). This decouples M3-frontend from Plan B without ever showing
// default-project content under the wrong project's chrome.
import { createContext, useContext, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { getProject } from '../api'
import { brandTitle } from '../lib/brand'
import { useDeployment } from '../context/DeploymentProvider'
import NotServedPlaceholder from './NotServedPlaceholder.jsx'
const ProjectContext = createContext(null)
export function useProject() {
return useContext(ProjectContext)
}
// §22.4a — the entry noun the chrome shows around a slug, driven by project
// type. The route segment stays the generic /e/; only the label varies.
export function entryNoun(type) {
if (type === 'specification') return 'Spec'
if (type === 'bdd') return 'Feature'
return 'RFC'
}
// Which theme keys map onto which tokens.css custom properties. Kept small and
// explicit (§22.9 ships `accent`); grows as the per-project theme surface does.
const THEME_TOKENS = {
accent: '--c-accent',
accentStrong: '--c-accent-strong',
}
export default function ProjectLayout({ children }) {
const { projectId } = useParams()
const deployment = useDeployment()
const [project, setProject] = useState(null)
const [status, setStatus] = useState('loading') // loading | ready | notfound
useEffect(() => {
let cancelled = false
setStatus('loading')
getProject(projectId)
.then(p => { if (!cancelled) { setProject(p); setStatus('ready') } })
.catch(() => { if (!cancelled) { setProject(null); setStatus('notfound') } })
return () => { cancelled = true }
}, [projectId])
// Theme overlay — apply then reset (cleanup runs on project change/unmount).
useEffect(() => {
const theme = project?.theme
if (!theme) return undefined
const root = document.documentElement
const applied = []
for (const [key, value] of Object.entries(theme)) {
const prop = THEME_TOKENS[key]
if (prop && value) {
root.style.setProperty(prop, value)
applied.push(prop)
}
}
return () => { applied.forEach(prop => root.style.removeProperty(prop)) }
}, [project])
// Tab title — project name (deployment chrome owns the deployment name).
useEffect(() => {
if (project?.name) document.title = brandTitle(project.name)
}, [project])
if (status === 'loading') {
return <main className="chrome-pane"><div className="boot">Loading</div></main>
}
if (status === 'notfound') {
return (
<main className="chrome-pane">
<div className="welcome">
<h1>Not found.</h1>
<p>There is no such project here, or you don&apos;t have access to it.</p>
</div>
</main>
)
}
const served = projectId === deployment.defaultProjectId
return (
<ProjectContext.Provider value={{ project, projectId, served, noun: entryNoun(project?.type) }}>
{served ? children : <NotServedPlaceholder project={project} />}
</ProjectContext.Provider>
)
}