§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:
@@ -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'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user