Files
rfc-app/frontend/src/components/ProjectLayout.jsx
T
Ben Stull 9f548a340d §22 M3-backend Plan B (2/2, read path): per-project RFC serving (v0.37.0)
A second project's corpus now renders under /p/<id>/, isolated by its own
slug namespace. Completes step (1) "RFC app supports multiple projects" for
the read path.

- api.py: GET /api/projects/{pid}/rfcs (scoped catalog) + /{slug} (entry by
  (project_id,slug)), behind the §22.5 read gate. Unscoped /api/rfcs stay as
  default-project compat.
- cache.py: refresh_meta_repo iterates every projects row, mirroring each
  content_repo into cached_rfcs stamped with its project_id (_upsert_cached_rfc
  gains a project_id arg).
- frontend: api.listRFCs(projectId)/getRFC(projectId,slug); Catalog + RFCView
  pass useProjectId(); ProjectLayout's NotServedPlaceholder guard removed (every
  project serves), 404/not-readable branch kept; NotServedPlaceholder deleted.
- tests: test_project_scoped_serving.py (catalog scoping, entry isolation,
  gated 404); ProjectLayout.test updated (non-default renders). 445 backend +
  11 Vitest green; clean build.

Known limitation (next slice): write path + default-id re-stamp not yet scoped
(non-default project read-only); no live impact (no 2nd project in prod yet).
Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:45:42 -07:00

98 lines
3.6 KiB
React

// §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 <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>
)
}
// §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 (
<ProjectContext.Provider value={{ project, projectId, noun: entryNoun(project?.type) }}>
{children}
</ProjectContext.Provider>
)
}