// §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}
)
}