// §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' 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 [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
Loading…
} if (status === 'notfound') { return (

Not found.

There is no such project here, or you don't have access to it.

) } // §22.4 (Plan B): every registry project now serves its own corpus, so // there is no "not served" guard — a readable project renders its children. return ( {children} ) }