9f548a340d
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>
98 lines
3.6 KiB
React
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'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>
|
|
)
|
|
}
|