§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:
Ben Stull
2026-06-04 05:58:59 -07:00
parent 0252e40527
commit 999c4b65ef
39 changed files with 798 additions and 99 deletions
+78
View File
@@ -23,6 +23,84 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.35.0 — 2026-06-04
**Minor (breaking) — §22 M3 frontend: `/p/<project>/` routing, runtime
branding (the `VITE_APP_NAME` hard cut), the deployment directory + project
switcher, and server-side 308 redirects off the old corpus-root URLs.
Completes the runtime-config cut 0.33.0 began.**
Added:
- **`DeploymentProvider`** (`src/context/DeploymentProvider.jsx`) — boots
`GET /api/deployment` once and provides `{ name, tagline,
defaultProjectId, projects[], loading }` to the tree. The neutral
`brandTitle()` fallback (`'RFC'`) paints during the pre-fetch frame.
- **`/p/:projectId/*` routing** with the generic `/e/<slug>` entry segment
(§22.10). **`ProjectLayout`** (`src/components/ProjectLayout.jsx`) fetches
`GET /api/projects/:id`, applies the project's `theme` as `:root` CSS
custom-property overrides (reset on switch/unmount so accents never bleed),
provides `ProjectContext`, and sets the tab title to the project name.
- **The §4 guard** — `ProjectLayout` renders the corpus only for the
corpus-served (default) project; any other id renders a "content not yet
served" placeholder (`NotServedPlaceholder`), so per-project serving (the
next backend slice, Plan B) can land without a wrong-content footgun.
- **Deployment directory** at `/` (`Directory.jsx`) — renders the
caller-visible projects as cards when **2+** are visible; the **N=1** case
redirects straight into the single project (`/p/<id>/`), preserving OHM's
"land in the corpus" UX. A **project switcher** (`ProjectSwitcher.jsx`)
rides deployment chrome when 2+ projects are visible.
- **Entry-noun terminology** (RFC / Spec / Feature) driven by `project.type`
(§22.4a); the route segment stays the generic `/e/`.
- **`GET /api/deployment`** now returns **`default_project_id`** — the
corpus-served project the frontend guard keys on.
- **Server-side 308 redirects** (`app/api_deployment.py`): `GET /rfc/<slug>`,
`/rfc/<slug>/pr/<n>`, and `/proposals/<n>` permanently redirect to
`/p/<default>/e/<slug>[…]` / `/p/<default>/proposals/<n>` (§5/§22.10), so
external "RFC-0001"-style links and bookmarks keep working.
Breaking:
- **`VITE_APP_NAME` is removed.** The build no longer reads it and no longer
fails without it; the deployment name comes from the registry
(`deployment.name` in `projects.yaml`) served at runtime via
`GET /api/deployment`. The same build now serves any deployment. The
build-time `%VITE_APP_NAME%` HTML token and the `inject-app-name` Vite
plugin are gone; `index.html` ships a static `<title>RFC</title>` and JS
sets the real title after config loads.
- **Entry/proposal URLs moved under `/p/<project>/`.** The old SPA routes
`/rfc/:slug`, `/rfc/:slug/pr/:n`, `/proposals/:n` are removed from the SPA
and served as backend 308s instead — so nginx must route `/rfc/` and
`/proposals/` to the backend rather than the SPA `index.html`.
Scope boundary (informational, not breaking): RFC *data* is still served
unscoped for the default project this slice — per-project corpus serving is
the next backend slice (Plan B). Non-default projects show the placeholder.
Upgrade steps:
1. **MUST** update nginx: add `location /rfc/` and `location /proposals/`
blocks that `proxy_pass` to the backend, **before** the SPA `location /`
fallback. The framework's `deploy/nginx/ohm.wiggleverse.org.conf` (and
`testing/web.nginx.conf`) already carry them; a deployment running a custom
vhost MUST add them, or old corpus-root URLs 404 against the SPA instead of
redirecting.
2. **MUST** ensure the registry `projects.yaml` `deployment.name` is set — it
is now the header brand and tab title (already required since 0.33.0).
`tagline` shows on the directory.
3. **SHOULD** remove `VITE_APP_NAME` from `frontend/.env`; it is no longer
read (no error if left — simply ignored).
4. **MUST** rebuild the frontend and deploy. Verify: the header shows the
deployment name from `/api/deployment`; `/` lands in your project (N=1) or
shows the directory (2+); an old `/rfc/<slug>` URL **308**-redirects to
`/p/<default>/e/<slug>`; `/api/health` is green.
5. **MAY** note: the Tier-1 Playwright e2e for these flows lands once the
Tier-1 Docker stack seeds a registry repo + `projects.yaml` (`REGISTRY_REPO`
is unset in `testing/.env.tier1` today, so the dockerized backend can't boot
there post-0.33.0). This slice is covered by Vitest unit tests
(`DeploymentProvider`, `ProjectLayout` theme/guard, `Directory`) + the
backend redirect tests (`backend/tests/test_api_deployment.py`).
## 0.34.0 — 2026-06-04
**Minor — containerize rfc-app for per-PR preview environments (flotilla
+1 -1
View File
@@ -1 +1 @@
0.34.0
0.35.0
+3 -2
View File
@@ -148,8 +148,9 @@ def make_router(
# (super-draft) RFC. Reuses the #12 invite flow (api_invitations above)
# on accept; lands the request + owner notifications via §15 notify.
router.include_router(api_contributions.make_router())
# §22.9 (M3): runtime deployment + per-project config (replaces VITE_APP_NAME).
router.include_router(api_deployment.make_router())
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
# VITE_APP_NAME) + the old-URL 308 redirects.
router.include_router(api_deployment.make_router(config))
# ---------------------------------------------------------------
# §17: /api/health — unauthenticated post-flight probe.
+38 -4
View File
@@ -1,9 +1,14 @@
"""§22.9 runtime deployment/project config (replaces VITE_APP_NAME).
"""§22.9 runtime deployment/project config (replaces VITE_APP_NAME) + §22.10
old-URL 308 redirects.
GET /api/deployment — the deployment name/tagline + the projects the caller can
see (§22.5: gated filtered by membership, unlisted omitted from enumeration).
see (§22.5: gated filtered by membership, unlisted omitted from enumeration),
plus the corpus-served `default_project_id` the M3-frontend guard keys on.
GET /api/projects/:id — one project's runtime config + optional theme overlay,
gated behind the §22.5 read gate (404 for a non-member of a gated project).
GET /rfc/{slug}, /proposals/{n} — §22.10 server-side 308s onto the new
`/p/<default>/…` routes (the SPA no longer owns these paths; nginx proxies them
to the backend instead of serving index.html).
"""
from __future__ import annotations
@@ -11,11 +16,13 @@ import json
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from . import auth, db
from . import auth, db, projects as projects_mod
from .config import Config
def make_router() -> APIRouter:
def make_router(config: Config) -> APIRouter:
router = APIRouter()
@router.get("/api/deployment")
@@ -38,6 +45,11 @@ def make_router() -> APIRouter:
return {
"name": (dep["name"] if dep else "") or "",
"tagline": (dep["tagline"] if dep else "") or "",
# §22.10 / M3-frontend guard contract: which project the backend
# serves the corpus for (the default, until Plan B serves per
# project). The frontend renders corpus routes only for this id and
# shows a "content not yet served" placeholder for any other.
"default_project_id": projects_mod.resolved_default_id(config),
"projects": projects,
}
@@ -69,4 +81,26 @@ def make_router() -> APIRouter:
"theme": cfg.get("theme") or {},
}
# §22.10 / §5 — server-side 308s off the old corpus-root URLs onto the
# `/p/<default>/…` routes. 308 (not 301/302) preserves method + body and
# is permanent, so external "RFC-0001" links and bookmarks land correctly.
# nginx routes /rfc/ and /proposals/ to the backend so these are reached
# before the SPA's index.html fallback.
@router.get("/rfc/{slug}")
async def redirect_old_rfc(slug: str) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config)
return RedirectResponse(url=f"/p/{default_id}/e/{slug}", status_code=308)
@router.get("/rfc/{slug}/pr/{pr_number}")
async def redirect_old_rfc_pr(slug: str, pr_number: int) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config)
return RedirectResponse(
url=f"/p/{default_id}/e/{slug}/pr/{pr_number}", status_code=308
)
@router.get("/proposals/{pr_number}")
async def redirect_old_proposal(pr_number: int) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config)
return RedirectResponse(url=f"/p/{default_id}/proposals/{pr_number}", status_code=308)
return router
+35
View File
@@ -78,6 +78,41 @@ def test_projects_id_unknown_returns_404_for_anon(app_with_fake_gitea):
assert client.get("/api/projects/nope").status_code == 404
def test_deployment_includes_default_project_id(app_with_fake_gitea):
# §22.10 / M3-frontend guard contract: the frontend learns which project
# is the corpus-served (default) one from the deployment payload.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
body = client.get("/api/deployment").json()
assert body["default_project_id"] == "default"
def test_rfc_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
# §22.10 / §5: old corpus-root /rfc/<slug> → 308 /p/<default>/e/<slug>.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/rfc/human", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/e/human"
def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
# The old per-RFC PR deep link is preserved too.
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/rfc/human/pr/7", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/e/human/pr/7"
def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/proposals/42", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/proposals/42"
def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
+22
View File
@@ -128,6 +128,28 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# §22.10: the old corpus-root URLs (/rfc/<slug>, /rfc/<slug>/pr/<n>,
# /proposals/<n>) are 308-redirected to the project-scoped /p/<default>/…
# routes by the backend, so route them to FastAPI rather than serving the
# SPA index.html. Must precede the `location /` SPA fallback.
location /rfc/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /proposals/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA fallback — any non-asset path falls back to index.html so
# React Router can take over.
location / {
+4 -9
View File
@@ -4,15 +4,10 @@
# and dev (`npm run dev`) time. Real `.env` files are gitignored; only this
# `.env.example` is committed.
# The user-visible name of this deployment. Used as the browser tab title,
# the header brand, and the landing page H1. Required — the framework
# ships no default on purpose so each deployment names itself. If unset,
# `npm run build` fails with a clear message.
#
# Examples:
# VITE_APP_NAME=Wiggleverse RFC
# VITE_APP_NAME=Wiggleverse Open Human Model
VITE_APP_NAME=
# §22.9 (M3, v0.35.0): the deployment name is NO LONGER a build-time var.
# It comes from the registry (`projects.yaml` `deployment.name`) and is served
# at runtime by GET /api/deployment. VITE_APP_NAME has been removed — set the
# name in your registry repo instead, and the same build serves any deployment.
# Optional contact line shown on the /beta-pending page when a deployment
# is in private-beta mode (i.e. the backend's `allowed_emails` table has
+3 -1
View File
@@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_NAME%</title>
<!-- §22.9: neutral static title; JS sets document.title from runtime
config (deployment/project name) once /api/deployment resolves. -->
<title>RFC</title>
</head>
<body>
<div id="root"></div>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.32.0",
"version": "0.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.32.0",
"version": "0.34.0",
"dependencies": {
"@amplitude/unified": "^1.1.9",
"@codemirror/commands": "^6.10.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.34.0",
"version": "0.35.0",
"type": "module",
"scripts": {
"dev": "vite",
+73 -12
View File
@@ -3,6 +3,12 @@ import { Routes, Route, Link, Navigate, useLocation, useNavigate, useSearchParam
import { getMe, subscribeToNotifications } from './api'
import { anonymize, EVENTS, identify, track } from './lib/analytics'
import { useLastState } from './lib/useLastState'
import { brandTitle } from './lib/brand'
import { entryPath, proposalPath } from './lib/entryPaths'
import { useDeployment } from './context/DeploymentProvider'
import ProjectLayout from './components/ProjectLayout.jsx'
import Directory from './components/Directory.jsx'
import ProjectSwitcher from './components/ProjectSwitcher.jsx'
import Catalog from './components/Catalog.jsx'
import Inbox from './components/Inbox.jsx'
import RFCView from './components/RFCView.jsx'
@@ -52,6 +58,9 @@ export default function App() {
const [identifyReady, setIdentifyReady] = useState(false)
const navigate = useNavigate()
const location = useLocation()
// §22.9 — runtime deployment config (name for the brand, default project id
// for building corpus links this slice; see DeploymentProvider).
const deployment = useDeployment()
// #28 Parts 23: the LinkedText create/contribute affordances route via
// query params so they need no prop-threading from deep in a comment
// list. `?propose=<term>` opens the propose modal pre-filled;
@@ -77,6 +86,16 @@ export default function App() {
track(EVENTS.PAGE_VIEWED, { path: location.pathname })
}, [location.pathname, location.search])
// §22.9 — tab title from runtime config. On deployment-chrome routes the
// title is the deployment name; under a `/p/<project>/` route ProjectLayout
// owns it (the project name), so skip those here.
useEffect(() => {
if (deployment.loading) return
if (!location.pathname.startsWith('/p/')) {
document.title = brandTitle(deployment.name)
}
}, [location.pathname, deployment.loading, deployment.name])
// v0.15.0 + #21 Part C — bind the authenticated user id AND
// durable user properties to the analytics session when sign-in
// lands; reset on sign-out (viewer flips to null). The wrapper
@@ -167,12 +186,16 @@ export default function App() {
// Churn never toasts; structural toasts only when it lands on
// a slug the user is currently viewing (URL match).
const isPersonal = payload.category === 'personal-direct'
const onCurrentSlug = payload.rfc_slug && window.location.pathname.includes(`/rfc/${payload.rfc_slug}`)
// §22.10: entry URLs are now /p/<project>/e/<slug>; match + link on the
// generic /e/<slug> segment (default project is the served corpus).
const onCurrentSlug = payload.rfc_slug && window.location.pathname.includes(`/e/${payload.rfc_slug}`)
if (isPersonal || onCurrentSlug) {
showToast({
summary: payload.summary,
category: payload.category,
link: payload.rfc_slug ? `/rfc/${payload.rfc_slug}` : null,
link: payload.rfc_slug && deployment.defaultProjectId
? entryPath(deployment.defaultProjectId, payload.rfc_slug)
: null,
})
}
},
@@ -182,7 +205,7 @@ export default function App() {
},
})
return close
}, [me?.authenticated])
}, [me?.authenticated, deployment.defaultProjectId])
if (loading) {
return <div className="boot">Loading</div>
@@ -205,7 +228,13 @@ export default function App() {
<div className="app">
<header className="app-header">
<div className="app-brand">
<Link to="/">{import.meta.env.VITE_APP_NAME}</Link>
{/* §22.9: deployment name from runtime config (replaces the
build-time VITE_APP_NAME); neutral 'RFC' during the pre-fetch
paint via brandTitle(). */}
<Link to="/">{brandTitle(deployment.name)}</Link>
{/* §22.10: project switcher — deployment chrome; renders only when
2+ projects are visible to the caller. */}
<ProjectSwitcher />
</div>
<div className="header-right">
{/* §14.3: the persistent About link. One word, no badge, no
@@ -308,8 +337,17 @@ export default function App() {
{isAdmin && (
<Route path="/admin/*" element={<AdminWithSidebar viewer={viewer} />} />
)}
<Route path="*" element={
<>
{/* §22.10: the deployment landing at `/` — redirect into the single
visible project (N=1, OHM's "land in the corpus" UX) or show the
directory when 2+ are visible. */}
<Route path="/" element={<DeploymentLanding />} />
{/* §22.10: per-project subtree. ProjectLayout fetches the project,
applies its theme, provides ProjectContext, and guards the corpus
(served only for the corpus-served default; others get a
placeholder). The generic `/e/` segment carries every entry type;
the noun ("RFC"/"Spec"/"Feature") is a type-driven label. */}
<Route path="/p/:projectId/*" element={
<ProjectLayout>
<Catalog
viewer={viewer}
onProposeRFC={() => setProposeOpen(true)}
@@ -317,14 +355,17 @@ export default function App() {
/>
<main className="main-pane">
<Routes>
<Route path="/" element={<Welcome viewer={viewer} />} />
<Route path="/rfc/:slug" element={<RFCView viewer={viewer} />} />
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
<Route path="" element={<Welcome viewer={viewer} />} />
<Route path="e/:slug" element={<RFCView viewer={viewer} />} />
<Route path="e/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
<Route path="proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes>
</main>
</>
</ProjectLayout>
} />
{/* Any other path (incl. the retired bare-slug corpus URLs that
somehow reach the SPA) lands on the deployment landing. */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
{(proposeOpen || proposeParam != null) && viewer && (
@@ -336,7 +377,7 @@ export default function App() {
setProposeOpen(false)
clearParams('propose')
setCatalogVersion(v => v + 1)
navigate(`/proposals/${pr_number}`)
navigate(proposalPath(deployment.defaultProjectId, pr_number))
}}
/>
)}
@@ -356,6 +397,26 @@ export default function App() {
)
}
function DeploymentLanding() {
// §22.10 + design decision 2 — N=1 lands in the single visible project so
// OHM's "land in the corpus" UX is preserved; the directory appears only
// when 2+ projects are visible to the caller. Visibility is per-caller
// (§22.5), so the unlisted projects never count toward the directory.
const { projects, defaultProjectId, loading } = useDeployment()
if (loading) {
return <main className="chrome-pane"><div className="boot">Loading</div></main>
}
if (projects.length === 1) {
return <Navigate to={`/p/${projects[0].id}/`} replace />
}
if (projects.length === 0 && defaultProjectId) {
// No enumerable projects but a default exists (e.g. an anon hitting a
// deployment whose only project is unlisted-but-default) — land there.
return <Navigate to={`/p/${defaultProjectId}/`} replace />
}
return <Directory />
}
function PolicyShell({ children }) {
// §14.5 / §14.6 policy pages reuse the chrome-pane shape so they
// render full-width without the catalog rail. The components inside
+13
View File
@@ -169,6 +169,19 @@ export async function clearPasscode() {
return jsonOrThrow(res)
}
// ── §22.9/§22.10 (M3): runtime deployment + per-project config ────────────
//
// Replaces the build-time VITE_APP_NAME. `getDeployment` is fetched once on
// boot (DeploymentProvider): { name, tagline, default_project_id, projects[] }.
// `getProject` drives ProjectLayout's per-project chrome + theme.
export async function getDeployment() {
return jsonOrThrow(await fetch('/api/deployment'))
}
export async function getProject(projectId) {
return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
}
export async function listRFCs() {
return jsonOrThrow(await fetch('/api/rfcs'))
}
+7 -5
View File
@@ -23,10 +23,12 @@ import { useEffect, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { acceptInvitation, previewInvitation } from '../api'
import { EVENTS, identify, track } from '../lib/analytics'
import { entryPath, useProjectId } from '../lib/entryPaths'
export default function AcceptInvitation({ viewer }) {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const pid = useProjectId()
const token = searchParams.get('token') || ''
const [preview, setPreview] = useState(null)
@@ -75,7 +77,7 @@ export default function AcceptInvitation({ viewer }) {
rfc_slug: result.rfc_slug,
role_in_rfc: result.role_in_rfc || preview?.role_in_rfc,
})
navigate(`/rfc/${result.rfc_slug}`)
navigate(entryPath(pid, result.rfc_slug))
} catch (err) {
setAcceptError(err.message || 'Could not accept invitation.')
} finally {
@@ -134,7 +136,7 @@ export default function AcceptInvitation({ viewer }) {
The owner of <strong>{rfc_title}</strong> revoked this invitation.
Ask them to re-issue it if you should still have access.
</p>
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
<p><Link to={entryPath(pid, rfc_slug)}>Read the RFC anyway</Link></p>
</div>
)
}
@@ -146,7 +148,7 @@ export default function AcceptInvitation({ viewer }) {
This invitation to <strong>{rfc_title}</strong> has expired. Ask
the RFC's owner to issue a fresh one.
</p>
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
<p><Link to={entryPath(pid, rfc_slug)}>Read the RFC anyway</Link></p>
</div>
)
}
@@ -156,7 +158,7 @@ export default function AcceptInvitation({ viewer }) {
<h1>Already accepted</h1>
<p>
You've already accepted this invitation. You can{' '}
<Link to={`/rfc/${rfc_slug}`}>open {rfc_title}</Link> now.
<Link to={entryPath(pid, rfc_slug)}>open {rfc_title}</Link> now.
</p>
</div>
)
@@ -200,7 +202,7 @@ export default function AcceptInvitation({ viewer }) {
</button>
</p>
<p>
<Link to={`/rfc/${rfc_slug}`}>or just read the RFC without accepting</Link>
<Link to={entryPath(pid, rfc_slug)}>or just read the RFC without accepting</Link>
</p>
</div>
)
+4 -2
View File
@@ -28,6 +28,7 @@ import {
unretireRFC,
} from '../api.js'
import { EVENTS, track } from '../lib/analytics.js'
import { entryPath, useProjectId } from '../lib/entryPaths'
// v0.17.0 — roadmap item #16. The max length the backend enforces
// (Pydantic body bound + `invites.CUSTOM_MESSAGE_MAX_LENGTH`); kept
@@ -716,6 +717,7 @@ function AllowlistTab() {
function GraduationTab() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const pid = useProjectId()
useEffect(() => {
listGraduationQueue()
@@ -743,7 +745,7 @@ function GraduationTab() {
<ul className="grad-queue">
{data.ready.map(item => (
<li key={item.slug}>
<Link to={`/rfc/${item.slug}`} className="grad-queue-link">
<Link to={entryPath(pid, item.slug)} className="grad-queue-link">
<strong>{item.title}</strong>
<span className="muted"> owners: {item.owners.join(', ')}</span>
</Link>
@@ -758,7 +760,7 @@ function GraduationTab() {
<ul className="grad-queue">
{data.blocked.map(item => (
<li key={item.slug}>
<Link to={`/rfc/${item.slug}`} className="grad-queue-link">
<Link to={entryPath(pid, item.slug)} className="grad-queue-link">
<strong>{item.title}</strong>
<span className="muted">
{' — '}
+5 -1
View File
@@ -12,17 +12,21 @@
// ask-the-operator line.
import { Link } from 'react-router-dom'
import { useDeployment } from '../context/DeploymentProvider'
import { brandTitle } from '../lib/brand'
export default function BetaPending({ viewer }) {
const contact = import.meta.env.VITE_BETA_CONTACT || ''
const isPending = viewer?.permission_state === 'pending'
// §22.9: deployment name from runtime config (was VITE_APP_NAME).
const { name } = useDeployment()
return (
<div className="beta-pending">
<div className="beta-pending-inner">
<h1>
{isPending
? 'Your request is in review.'
: `${import.meta.env.VITE_APP_NAME} is in private Beta.`}
: `${brandTitle(name)} is in private Beta.`}
</h1>
{isPending ? (
<>
+4 -2
View File
@@ -9,6 +9,7 @@
import { useEffect, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { listRFCs, listProposals } from '../api'
import { entryPath, proposalPath, useProjectId } from '../lib/entryPaths'
const STATE_CHIPS = [
{ id: 'super-draft', label: 'Super-draft' },
@@ -30,6 +31,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
const [activeChips, setActiveChips] = useState(new Set())
const [pendingOpen, setPendingOpen] = useState(true)
const { slug, prNumber } = useParams()
const pid = useProjectId()
useEffect(() => {
listRFCs().then(d => setRfcs(d.items)).catch(() => setRfcs([]))
@@ -103,7 +105,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
return (
<Link
key={r.slug}
to={`/rfc/${r.slug}`}
to={entryPath(pid, r.slug)}
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
>
<div className="row-top">
@@ -131,7 +133,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
{proposals.map(p => (
<Link
key={p.pr_number}
to={`/proposals/${p.pr_number}`}
to={proposalPath(pid, p.pr_number)}
className={`pending-row ${String(prNumber) === String(p.pr_number) ? 'active' : ''}`}
>
<div>{p.title.replace(/^Propose:\s*/, '')}</div>
+30
View File
@@ -0,0 +1,30 @@
// §22.10 (M3) — the deployment directory at `/`. Renders the caller-visible
// projects (from /api/deployment) as cards linking into each project's
// `/p/<id>/` home. App's DeploymentLanding only mounts this when 2+ projects
// are visible; the N=1 case redirects straight into the single project so
// OHM's "land in the corpus" UX is preserved.
import { Link } from 'react-router-dom'
import { useDeployment } from '../context/DeploymentProvider'
import { entryNoun } from './ProjectLayout.jsx'
export default function Directory() {
const { name, tagline, projects } = useDeployment()
return (
<main className="chrome-pane">
<div className="directory">
<h1>{name}</h1>
{tagline && <p className="directory-tagline">{tagline}</p>}
<ul className="directory-list">
{projects.map(p => (
<li key={p.id} className="directory-card">
<Link to={`/p/${p.id}/`}>
<span className="directory-card-name">{p.name}</span>
<span className="directory-card-type">{entryNoun(p.type)}s</span>
</Link>
</li>
))}
</ul>
</div>
</main>
)
}
@@ -0,0 +1,45 @@
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
// Mocked first so Directory (and ProjectLayout, which it imports entryNoun
// from) resolve the deployment hook and api without a live fetch.
let mockProjects = []
vi.mock('../api', () => ({ getProject: vi.fn() }))
vi.mock('../context/DeploymentProvider', () => ({
useDeployment: () => ({ name: 'Wiggleverse', tagline: 'a directory of projects', projects: mockProjects, defaultProjectId: null }),
}))
import Directory from './Directory.jsx'
function renderDir(projects) {
mockProjects = projects
return render(<MemoryRouter><Directory /></MemoryRouter>)
}
describe('Directory', () => {
it('renders a card per visible project with the type-driven noun + link', () => {
renderDir([
{ id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' },
{ id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' },
])
const ohm = screen.getByText('Open Human Model').closest('a')
expect(ohm).toHaveAttribute('href', '/p/ohm/')
const ecomm = screen.getByText('Ecomm').closest('a')
expect(ecomm).toHaveAttribute('href', '/p/ecomm/')
// bdd → "Features", document → "RFCs"
expect(screen.getByText('RFCs')).toBeInTheDocument()
expect(screen.getByText('Features')).toBeInTheDocument()
})
it('renders the deployment name and tagline', () => {
renderDir([{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }])
expect(screen.getByText('Wiggleverse')).toBeInTheDocument()
expect(screen.getByText('a directory of projects')).toBeInTheDocument()
})
it('renders an empty list without crashing when no projects are visible', () => {
renderDir([])
expect(screen.getByText('Wiggleverse')).toBeInTheDocument()
})
})
+7 -5
View File
@@ -17,6 +17,7 @@ import {
markNotificationRead,
markNotificationsReadByFilter,
} from '../api.js'
import { entryPath, entryPrPath, useProjectId } from '../lib/entryPaths'
import './Inbox.css'
const CATEGORIES = [
@@ -228,11 +229,12 @@ function ContributionRequestRow({ item, onMarkRead }) {
}
function InboxRow({ item, onClick, onMarkRead, onClose }) {
const pid = useProjectId()
if (item.event_kind === 'contribution_request_on_pending_rfc') {
return <ContributionRequestRow item={item} onMarkRead={onMarkRead} />
}
const unread = !item.read_at
const target = deepLink(item)
const target = deepLink(item, pid)
const handle = async () => {
await onClick(item)
if (target) onClose?.()
@@ -277,10 +279,10 @@ function InboxRow({ item, onClick, onMarkRead, onClose }) {
)
}
function deepLink(item) {
if (item.rfc_slug && item.pr_number) return `/rfc/${item.rfc_slug}/pr/${item.pr_number}`
if (item.rfc_slug && item.branch_name) return `/rfc/${item.rfc_slug}?branch=${item.branch_name}`
if (item.rfc_slug) return `/rfc/${item.rfc_slug}`
function deepLink(item, pid) {
if (item.rfc_slug && item.pr_number) return entryPrPath(pid, item.rfc_slug, item.pr_number)
if (item.rfc_slug && item.branch_name) return `${entryPath(pid, item.rfc_slug)}?branch=${item.branch_name}`
if (item.rfc_slug) return entryPath(pid, item.rfc_slug)
return ''
}
+7 -1
View File
@@ -8,12 +8,18 @@
// the framework source stays brand-neutral.
import { Link } from 'react-router-dom'
import { useDeployment } from '../context/DeploymentProvider'
import { brandTitle } from '../lib/brand'
export default function Landing() {
// §22.9: deployment name from runtime config (was VITE_APP_NAME). The
// subtitle/pitch/deck/attribution below are still OHM-specific copy — lifting
// those into deployment config is the separate §19.2/landing.json slice.
const { name } = useDeployment()
return (
<div className="landing">
<div className="landing-inner">
<h1>{import.meta.env.VITE_APP_NAME}</h1>
<h1>{brandTitle(name)}</h1>
<p className="subtitle">
An open dictionary of the words humans and machines need to agree on.
</p>
+3 -1
View File
@@ -27,8 +27,10 @@
// pass segments).
import { Link } from 'react-router-dom'
import { entryPath, useProjectId } from '../lib/entryPaths'
export default function LinkedText({ segments, text, viewer, canCreate }) {
const pid = useProjectId()
if (!Array.isArray(segments) || segments.length === 0) {
return <>{text ?? ''}</>
}
@@ -43,7 +45,7 @@ export default function LinkedText({ segments, text, viewer, canCreate }) {
<a
key={i}
className="rfc-autolink"
href={`/rfc/${seg.slug}`}
href={entryPath(pid, seg.slug)}
title={seg.title ? `RFC: ${seg.title}` : undefined}
>
{seg.label}
+5 -1
View File
@@ -86,8 +86,12 @@ import {
} from '../api'
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
import { EVENTS, track } from '../lib/analytics'
import { useDeployment } from '../context/DeploymentProvider'
import { brandTitle } from '../lib/brand'
export default function Login() {
// §22.9: deployment name from runtime config (was VITE_APP_NAME).
const { name: deploymentName } = useDeployment()
// Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after
// verify) one of: 'capture-profile' (pending user), 'offer-passcode'
// (no passcode yet), or straight to "/". 'set-passcode' is reached
@@ -591,7 +595,7 @@ export default function Login() {
{step === 'capture-profile' && (
<form onSubmit={submitProfile}>
<p className="otc-hint">
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
You're signed in. {brandTitle(deploymentName)} is in private
beta — tell us a bit about yourself and an admin will review
your request.
</p>
@@ -0,0 +1,18 @@
// §22.10 (M3) — the guard placeholder. A project that exists in the registry
// but whose corpus the backend does not yet serve (everything except the
// corpus-served default, until Plan B lands per-project RFC serving) renders
// this instead of mislabeled default-project content.
export default function NotServedPlaceholder({ project }) {
const name = project?.name || 'This project'
return (
<main className="chrome-pane">
<div className="welcome">
<h1>{name}</h1>
<p>
This project is registered, but its content isn&apos;t being served
here yet. Per-project content arrives in a later release.
</p>
</div>
</main>
)
}
@@ -18,6 +18,7 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { entryPath, useProjectId } from '../lib/entryPaths'
import {
getNotificationPreferences,
setNotificationPreferences,
@@ -611,6 +612,7 @@ function QuietHoursSection() {
function WatchesSection() {
const [watches, setWatches] = useState(null)
const [updating, setUpdating] = useState({})
const pid = useProjectId()
useEffect(() => { listWatches().then(r => setWatches(r.items || [])) }, [])
@@ -651,7 +653,7 @@ function WatchesSection() {
{watches.map(w => (
<tr key={w.rfc_slug}>
<td>
<Link to={`/rfc/${w.rfc_slug}`}>{w.rfc_title || w.rfc_slug}</Link>
<Link to={entryPath(pid, w.rfc_slug)}>{w.rfc_title || w.rfc_slug}</Link>
</td>
<td>
<select
+8 -6
View File
@@ -23,11 +23,13 @@ import {
withdrawPR,
} from '../api'
import { EVENTS, track } from '../lib/analytics'
import { entryPath, entryPrPath, useProjectId } from '../lib/entryPaths'
import LinkedText from './LinkedText'
export default function PRView({ viewer }) {
const { slug, prNumber: prNumberParam } = useParams()
const navigate = useNavigate()
const pid = useProjectId()
const prNumber = Number(prNumberParam)
const [pr, setPR] = useState(null)
@@ -101,13 +103,13 @@ export default function PRView({ viewer }) {
setError(null)
try {
const { resolution_branch } = await startResolutionBranch(slug, prNumber)
navigate(`/rfc/${slug}?branch=${encodeURIComponent(resolution_branch)}`)
navigate(`${entryPath(pid, slug)}?branch=${encodeURIComponent(resolution_branch)}`)
} catch (e) {
setError(e.message)
} finally {
setActing(null)
}
}, [slug, prNumber, navigate])
}, [slug, prNumber, navigate, pid])
const startHeaderEdit = useCallback(() => {
setDraftTitle(pr?.title || '')
@@ -188,9 +190,9 @@ export default function PRView({ viewer }) {
<div className="pr-header">
<div className="pr-header-left">
<div className="pr-breadcrumb">
<a href={`/rfc/${slug}`}>{pr.rfc_id || pr.slug}</a>
<a href={entryPath(pid, slug)}>{pr.rfc_id || pr.slug}</a>
<span className="breadcrumb-sep"></span>
<a href={`/rfc/${slug}?branch=${encodeURIComponent(pr.head_branch)}`}>{pr.head_branch}</a>
<a href={`${entryPath(pid, slug)}?branch=${encodeURIComponent(pr.head_branch)}`}>{pr.head_branch}</a>
<span className="breadcrumb-sep"></span>
<strong>PR #{pr.pr_number}</strong>
</div>
@@ -244,8 +246,8 @@ export default function PRView({ viewer }) {
<StateBanner state={pr.state} mergedAt={pr.merged_at} closedAt={pr.closed_at} />
{(supersedes || supersededBy) && (
<div className="pr-supersedes">
{supersedes && <>Supersedes <a href={`/rfc/${slug}/pr/${supersedes}`}>#{supersedes}</a></>}
{supersededBy && <>Closed by <a href={`/rfc/${slug}/pr/${supersededBy}`}>#{supersededBy}</a></>}
{supersedes && <>Supersedes <a href={entryPrPath(pid, slug, supersedes)}>#{supersedes}</a></>}
{supersededBy && <>Closed by <a href={entryPrPath(pid, slug, supersededBy)}>#{supersededBy}</a></>}
</div>
)}
<div className="pr-counts">
+99
View File
@@ -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&apos;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>
)
}
@@ -0,0 +1,58 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import ProjectLayout from './ProjectLayout.jsx'
vi.mock('../api', () => ({ getProject: vi.fn() }))
vi.mock('../context/DeploymentProvider', () => ({
useDeployment: () => ({ defaultProjectId: 'default', name: 'Dep', projects: [] }),
}))
import { getProject } from '../api'
function renderAt(path) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/p/:projectId/*" element={
<ProjectLayout><div data-testid="corpus">CORPUS</div></ProjectLayout>
} />
</Routes>
</MemoryRouter>,
)
}
describe('ProjectLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
document.documentElement.style.removeProperty('--c-accent')
})
it('renders the corpus children for the corpus-served default project', async () => {
getProject.mockResolvedValue({ id: 'default', name: 'OHM', type: 'document', theme: {} })
renderAt('/p/default/')
expect(await screen.findByTestId('corpus')).toBeInTheDocument()
})
it('applies the project theme to :root and resets it on unmount', async () => {
getProject.mockResolvedValue({ id: 'default', name: 'OHM', type: 'document', theme: { accent: '#123456' } })
const { unmount } = renderAt('/p/default/')
await waitFor(() =>
expect(document.documentElement.style.getPropertyValue('--c-accent')).toBe('#123456'))
unmount()
expect(document.documentElement.style.getPropertyValue('--c-accent')).toBe('')
})
it('shows the not-served placeholder for a non-default (Plan-B) project', async () => {
getProject.mockResolvedValue({ id: 'other', name: 'Ecomm', type: 'bdd', theme: {} })
renderAt('/p/other/')
expect(await screen.findByText(/isn.t being served here yet/i)).toBeInTheDocument()
expect(screen.queryByTestId('corpus')).not.toBeInTheDocument()
})
it('shows a not-found message when the project 404s', async () => {
getProject.mockRejectedValue(Object.assign(new Error('nope'), { status: 404 }))
renderAt('/p/ghost/')
expect(await screen.findByText(/Not found/i)).toBeInTheDocument()
})
})
@@ -0,0 +1,33 @@
// §22.10 (M3) — the project switcher in deployment chrome (header). A plain
// <select> of the caller-visible projects; choosing one navigates to its
// `/p/<id>/` home. Rendered only when 2+ projects are visible (a single
// project needs no switcher). The current project is read from the URL.
import { useNavigate, useLocation } from 'react-router-dom'
import { useDeployment } from '../context/DeploymentProvider'
export default function ProjectSwitcher() {
const { projects } = useDeployment()
const navigate = useNavigate()
// The switcher renders in deployment chrome (the header), above the route
// tree, so it reads the active project from the path rather than useParams.
const { pathname } = useLocation()
const match = pathname.match(/^\/p\/([^/]+)/)
const projectId = match ? match[1] : ''
if (!projects || projects.length < 2) return null
return (
<select
className="project-switcher"
aria-label="Switch project"
value={projectId || ''}
onChange={e => navigate(`/p/${e.target.value}/`)}
>
{/* When not inside a project (e.g. the directory), show a neutral head. */}
{!projectId && <option value="" disabled>Projects</option>}
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
)
}
+3 -1
View File
@@ -12,6 +12,7 @@ import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { renderMarkdown } from '../lib/sanitizeHtml'
import { getProposal, mergeProposal, declineProposal, withdrawProposal } from '../api'
import { entryPath, useProjectId } from '../lib/entryPaths'
export default function ProposalView({ viewer, onChange }) {
const { prNumber } = useParams()
@@ -21,6 +22,7 @@ export default function ProposalView({ viewer, onChange }) {
const [declineOpen, setDeclineOpen] = useState(false)
const [declineComment, setDeclineComment] = useState('')
const navigate = useNavigate()
const pid = useProjectId()
function refresh() {
setData(null); setError(null)
@@ -39,7 +41,7 @@ export default function ProposalView({ viewer, onChange }) {
try {
const { slug } = await mergeProposal(prNumber)
onChange?.()
navigate(`/rfc/${slug}`)
navigate(entryPath(pid, slug))
} catch (e) {
setError(e.message)
setActing(false)
+8 -6
View File
@@ -46,6 +46,7 @@ import GraduateDialog from './GraduateDialog.jsx'
import InvitationsModal from './InvitationsModal.jsx'
import { claimOwnership, retireRFC, unretireRFC } from '../api'
import { EVENTS, track } from '../lib/analytics'
import { entryPath, entryPrPath, useProjectId } from '../lib/entryPaths'
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
const MANUAL_DEBOUNCE_MS = 800
@@ -68,6 +69,7 @@ export default function RFCView({ viewer }) {
const { slug } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const pid = useProjectId()
const branchParam = searchParams.get('branch') || 'main'
@@ -178,11 +180,11 @@ export default function RFCView({ viewer }) {
try {
const res = await unretireRFC(slug)
getRFC(slug).then(setEntry).catch(() => {})
if (res?.state) navigate(`/rfc/${slug}`)
if (res?.state) navigate(entryPath(pid, slug))
} catch (err) {
setActionError(err.message)
}
}, [slug, navigate])
}, [slug, navigate, pid])
// Load main view + branch view whenever slug/branch changes.
useEffect(() => {
@@ -634,7 +636,7 @@ export default function RFCView({ viewer }) {
{openPRForBranch && (
<a
className="btn-link"
href={`/rfc/${slug}/pr/${openPRForBranch.pr_number}`}
href={entryPrPath(pid, slug, openPRForBranch.pr_number)}
title="View the open PR for this branch"
>
PR #{openPRForBranch.pr_number}
@@ -667,7 +669,7 @@ export default function RFCView({ viewer }) {
const result = await claimOwnership(slug)
if (result?.noop) return
if (result?.pr_number) {
navigate(`/rfc/${slug}/pr/${result.pr_number}`)
navigate(entryPrPath(pid, slug, result.pr_number))
}
} catch (err) {
setClaimError(err.message)
@@ -928,7 +930,7 @@ export default function RFCView({ viewer }) {
onClose={() => setShowPRModal(false)}
onOpened={(prNumber) => {
setShowPRModal(false)
navigate(`/rfc/${slug}/pr/${prNumber}`)
navigate(entryPrPath(pid, slug, prNumber))
}}
/>
)}
@@ -980,7 +982,7 @@ export default function RFCView({ viewer }) {
// Refresh main view so the new open PR surfaces in the
// breadcrumb meta count immediately.
getRFCMain(slug).then(setMainView).catch(() => {})
navigate(`/rfc/${slug}/pr/${prNumber}`)
navigate(entryPrPath(pid, slug, prNumber))
}}
/>
)}
@@ -0,0 +1,62 @@
// §22.9 (M3) runtime deployment config, replacing the build-time
// VITE_APP_NAME. Fetches GET /api/deployment once on boot and provides it to
// the whole tree:
//
// { name, tagline, defaultProjectId, projects[], loading }
//
// `projects` is the per-caller-visible set (§22.5: public + member-gated;
// unlisted omitted). `defaultProjectId` is the corpus-served project the
// M3-frontend guard keys on. During the pre-fetch paint, consumers fall back
// to the neutral brandTitle() default ('RFC') never a hardcoded deployment
// name.
import { createContext, useContext, useEffect, useState } from 'react'
import { getDeployment } from '../api'
const DeploymentContext = createContext({
name: '',
tagline: '',
defaultProjectId: null,
projects: [],
loading: true,
})
export function useDeployment() {
return useContext(DeploymentContext)
}
export function DeploymentProvider({ children }) {
const [state, setState] = useState({
name: '',
tagline: '',
defaultProjectId: null,
projects: [],
loading: true,
})
useEffect(() => {
let cancelled = false
getDeployment()
.then(d => {
if (cancelled) return
setState({
name: d.name || '',
tagline: d.tagline || '',
defaultProjectId: d.default_project_id || null,
projects: Array.isArray(d.projects) ? d.projects : [],
loading: false,
})
})
.catch(() => {
// A failed deployment fetch must not wedge the app: fall back to the
// neutral brand and an empty directory so the shell still paints.
if (!cancelled) setState(s => ({ ...s, loading: false }))
})
return () => { cancelled = true }
}, [])
return (
<DeploymentContext.Provider value={state}>
{children}
</DeploymentContext.Provider>
)
}
@@ -0,0 +1,45 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { DeploymentProvider, useDeployment } from './DeploymentProvider'
vi.mock('../api', () => ({ getDeployment: vi.fn() }))
import { getDeployment } from '../api'
function Probe() {
const { name, defaultProjectId, projects, loading } = useDeployment()
return (
<div>
<span data-testid="loading">{String(loading)}</span>
<span data-testid="name">{name}</span>
<span data-testid="default">{defaultProjectId || ''}</span>
<span data-testid="count">{projects.length}</span>
</div>
)
}
describe('DeploymentProvider', () => {
beforeEach(() => vi.clearAllMocks())
it('provides fetched deployment config', async () => {
getDeployment.mockResolvedValue({
name: 'Wiggleverse',
tagline: 'a directory',
default_project_id: 'ohm',
projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }],
})
render(<DeploymentProvider><Probe /></DeploymentProvider>)
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
expect(screen.getByTestId('name').textContent).toBe('Wiggleverse')
expect(screen.getByTestId('default').textContent).toBe('ohm')
expect(screen.getByTestId('count').textContent).toBe('1')
})
it('falls back gracefully when the fetch fails (empty, not wedged)', async () => {
getDeployment.mockRejectedValue(new Error('boom'))
render(<DeploymentProvider><Probe /></DeploymentProvider>)
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
expect(screen.getByTestId('name').textContent).toBe('')
expect(screen.getByTestId('count').textContent).toBe('0')
})
})
+32
View File
@@ -0,0 +1,32 @@
// §22.10 — project-scoped path builders. After M3 every entry/proposal link
// lives under `/p/<project>/…`. Until Plan B serves multiple corpora, that
// project id is the deployment's corpus-served default for chrome surfaces, or
// the contextual project when a component renders inside a project subtree
// (ProjectContext). Components build links via these helpers so the later
// per-project-serving slice flips them in one place.
import { useProject } from '../components/ProjectLayout.jsx'
import { useDeployment } from '../context/DeploymentProvider'
export function entryPath(pid, slug) {
return `/p/${pid}/e/${slug}`
}
export function entryPrPath(pid, slug, prNumber) {
return `/p/${pid}/e/${slug}/pr/${prNumber}`
}
export function proposalPath(pid, prNumber) {
return `/p/${pid}/proposals/${prNumber}`
}
export function projectHome(pid) {
return `/p/${pid}/`
}
// The project id a component should build links against: the contextual
// project when inside a `/p/:projectId/*` subtree, else the deployment default.
export function useProjectId() {
const ctx = useProject()
const { defaultProjectId } = useDeployment()
return (ctx && ctx.projectId) || defaultProjectId
}
+4 -1
View File
@@ -2,13 +2,16 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { DeploymentProvider } from './context/DeploymentProvider'
import './styles/tokens.css'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
<DeploymentProvider>
<App />
</DeploymentProvider>
</BrowserRouter>
</React.StrictMode>,
)
+4 -1
View File
@@ -10,6 +10,7 @@
// to the analytics-category list in v0.15.0.
import { useNavigate, Link } from 'react-router-dom'
import { useDeployment } from '../context/DeploymentProvider'
const COOKIES = [
{
@@ -29,7 +30,9 @@ const COOKIES = [
export default function Cookies() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_COOKIES_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
// §22.9: deployment name from runtime config (was VITE_APP_NAME).
const { name } = useDeployment()
const appName = name || 'this deployment'
return (
<div className="policy-page">
+4 -1
View File
@@ -18,11 +18,14 @@
// complaint. Each deployment's content repo can carry a fuller version.
import { useNavigate, Link } from 'react-router-dom'
import { useDeployment } from '../context/DeploymentProvider'
export default function Privacy() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_PRIVACY_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
// §22.9: deployment name from runtime config (was VITE_APP_NAME).
const { name } = useDeployment()
const appName = name || 'this deployment'
return (
<div className="policy-page">
+8 -32
View File
@@ -1,40 +1,16 @@
import { defineConfig, loadEnv } from 'vite'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// In dev, the frontend runs on Vite's port and proxies the API
// (and /auth/*, /api/webhooks/*) to the FastAPI process.
// (and /auth/*) to the FastAPI process.
//
// VITE_APP_NAME is the user-visible name of this deployment — used in
// the browser tab title, the header brand, and the landing page H1.
// The framework intentionally ships no default: every deployment must
// supply its own name via `frontend/.env` so a fresh build never ships
// the wrong brand. See `frontend/.env.example`.
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const appName = env.VITE_APP_NAME
if (!appName || !appName.trim()) {
throw new Error(
'VITE_APP_NAME is required but not set. Copy frontend/.env.example to ' +
'frontend/.env and set VITE_APP_NAME=... before building. The framework ' +
'ships no default on purpose — each deployment names itself.'
)
}
// §22.9 (M3): the deployment name is NO LONGER a build-time value. It comes
// from GET /api/deployment at runtime (see src/context/DeploymentProvider.jsx),
// so a build needs no VITE_APP_NAME — the same bundle serves any deployment.
// The old build-time `throw` + `%VITE_APP_NAME%` HTML injection are removed.
export default defineConfig(() => {
return {
plugins: [
react(),
// Build-time HTML token substitution: `%VITE_APP_NAME%` inside
// `index.html` becomes the configured name. Vite already does this
// for env vars referenced as `%VITE_*%` in HTML, but we wire it
// explicitly so the failure mode (token left in title) is loud.
{
name: 'inject-app-name',
transformIndexHtml(html) {
return html.replace(/%VITE_APP_NAME%/g, appName)
},
},
],
plugins: [react()],
server: {
port: 5173,
proxy: {
+3
View File
@@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
// Use the automatic JSX runtime in the test transform so components (and
// tests) need no `import React` — matching the app build's plugin-react.
esbuild: { jsx: 'automatic', jsxImportSource: 'react' },
test: {
environment: 'jsdom',
globals: true,
+16
View File
@@ -19,6 +19,22 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# §22.10: old corpus-root URLs are 308'd to /p/<default>/… by the backend,
# so route them to FastAPI rather than serving the SPA index.html.
location /rfc/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /proposals/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}