From 999c4b65ef786d086b13ff074da41ca70c65832e Mon Sep 17 00:00:00 2001
From: Ben Stull
Date: Thu, 4 Jun 2026 05:58:59 -0700
Subject: [PATCH] =?UTF-8?q?=C2=A722=20M3=20frontend:=20/p//=20rou?=
=?UTF-8?q?ting,=20runtime=20branding,=20directory,=20308s=20(v0.35.0)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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/ 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 RFC + JS
document.title. Internal /rfc·/proposals links → /p//e|proposals
via lib/entryPaths.
Backend:
- GET /api/deployment returns default_project_id (the guard contract).
- Server-side 308s: /rfc/, /rfc//pr/, /proposals/ →
/p//… . 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)
---
CHANGELOG.md | 78 +++++++++++++++
VERSION | 2 +-
backend/app/api.py | 5 +-
backend/app/api_deployment.py | 42 +++++++-
backend/tests/test_api_deployment.py | 35 +++++++
deploy/nginx/ohm.wiggleverse.org.conf | 22 +++++
frontend/.env.example | 13 +--
frontend/index.html | 4 +-
frontend/package-lock.json | 4 +-
frontend/package.json | 2 +-
frontend/src/App.jsx | 85 +++++++++++++---
frontend/src/api.js | 13 +++
frontend/src/components/AcceptInvitation.jsx | 12 ++-
frontend/src/components/Admin.jsx | 6 +-
frontend/src/components/BetaPending.jsx | 6 +-
frontend/src/components/Catalog.jsx | 6 +-
frontend/src/components/Directory.jsx | 30 ++++++
frontend/src/components/Directory.test.jsx | 45 +++++++++
frontend/src/components/Inbox.jsx | 12 ++-
frontend/src/components/Landing.jsx | 8 +-
frontend/src/components/LinkedText.jsx | 4 +-
frontend/src/components/Login.jsx | 6 +-
.../src/components/NotServedPlaceholder.jsx | 18 ++++
.../src/components/NotificationSettings.jsx | 4 +-
frontend/src/components/PRView.jsx | 14 +--
frontend/src/components/ProjectLayout.jsx | 99 +++++++++++++++++++
.../src/components/ProjectLayout.test.jsx | 58 +++++++++++
frontend/src/components/ProjectSwitcher.jsx | 33 +++++++
frontend/src/components/ProposalView.jsx | 4 +-
frontend/src/components/RFCView.jsx | 14 +--
frontend/src/context/DeploymentProvider.jsx | 62 ++++++++++++
.../src/context/DeploymentProvider.test.jsx | 45 +++++++++
frontend/src/lib/entryPaths.js | 32 ++++++
frontend/src/main.jsx | 5 +-
frontend/src/pages/Cookies.jsx | 5 +-
frontend/src/pages/Privacy.jsx | 5 +-
frontend/vite.config.js | 40 ++------
frontend/vitest.config.js | 3 +
testing/web.nginx.conf | 16 +++
39 files changed, 798 insertions(+), 99 deletions(-)
create mode 100644 frontend/src/components/Directory.jsx
create mode 100644 frontend/src/components/Directory.test.jsx
create mode 100644 frontend/src/components/NotServedPlaceholder.jsx
create mode 100644 frontend/src/components/ProjectLayout.jsx
create mode 100644 frontend/src/components/ProjectLayout.test.jsx
create mode 100644 frontend/src/components/ProjectSwitcher.jsx
create mode 100644 frontend/src/context/DeploymentProvider.jsx
create mode 100644 frontend/src/context/DeploymentProvider.test.jsx
create mode 100644 frontend/src/lib/entryPaths.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec6edab..a0326ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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//` 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/` 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//`), 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/`,
+ `/rfc//pr/`, and `/proposals/` permanently redirect to
+ `/p//e/[…]` / `/p//proposals/` (§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 `RFC` and JS
+ sets the real title after config loads.
+- **Entry/proposal URLs moved under `/p//`.** 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/` URL **308**-redirects to
+ `/p//e/`; `/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
diff --git a/VERSION b/VERSION
index 85e60ed..7b52f5e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.34.0
+0.35.0
diff --git a/backend/app/api.py b/backend/app/api.py
index 880d891..7333aa3 100644
--- a/backend/app/api.py
+++ b/backend/app/api.py
@@ -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.
diff --git a/backend/app/api_deployment.py b/backend/app/api_deployment.py
index d53dc4e..1406619 100644
--- a/backend/app/api_deployment.py
+++ b/backend/app/api_deployment.py
@@ -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//…` 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//…` 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
diff --git a/backend/tests/test_api_deployment.py b/backend/tests/test_api_deployment.py
index ea72f94..e0ea774 100644
--- a/backend/tests/test_api_deployment.py
+++ b/backend/tests/test_api_deployment.py
@@ -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/ → 308 /p//e/.
+ 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
diff --git a/deploy/nginx/ohm.wiggleverse.org.conf b/deploy/nginx/ohm.wiggleverse.org.conf
index 23b4eb9..6a21d0e 100644
--- a/deploy/nginx/ohm.wiggleverse.org.conf
+++ b/deploy/nginx/ohm.wiggleverse.org.conf
@@ -128,6 +128,28 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ # §22.10: the old corpus-root URLs (/rfc/, /rfc//pr/,
+ # /proposals/) are 308-redirected to the project-scoped /p//…
+ # 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 / {
diff --git a/frontend/.env.example b/frontend/.env.example
index 9176ea9..0f74d1e 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -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
diff --git a/frontend/index.html b/frontend/index.html
index 46112f8..a1db713 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,9 @@
- %VITE_APP_NAME%
+
+ RFC
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3ed41d7..38a071e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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",
diff --git a/frontend/package.json b/frontend/package.json
index bfdf271..fe16667 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
- "version": "0.34.0",
+ "version": "0.35.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 353c412..b39c2f7 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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 2–3: the LinkedText create/contribute affordances route via
// query params so they need no prop-threading from deep in a comment
// list. `?propose=` 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//` 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//e/; match + link on the
+ // generic /e/ 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
Loading…
@@ -205,7 +228,13 @@ export default function App() {
- {import.meta.env.VITE_APP_NAME}
+ {/* §22.9: deployment name from runtime config (replaces the
+ build-time VITE_APP_NAME); neutral 'RFC' during the pre-fetch
+ paint via brandTitle(). */}
+ {brandTitle(deployment.name)}
+ {/* §22.10: project switcher — deployment chrome; renders only when
+ 2+ projects are visible to the caller. */}
+
{/* §14.3: the persistent About link. One word, no badge, no
@@ -308,8 +337,17 @@ export default function App() {
{isAdmin && (
} />
)}
-
+ {/* §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. */}
+ } />
+ {/* §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. */}
+ setProposeOpen(true)}
@@ -317,14 +355,17 @@ export default function App() {
/>
- } />
- } />
- } />
- setCatalogVersion(v => v + 1)} />} />
+ } />
+ } />
+ } />
+ setCatalogVersion(v => v + 1)} />} />
- >
+
} />
+ {/* Any other path (incl. the retired bare-slug corpus URLs that
+ somehow reach the SPA) lands on the deployment landing. */}
+ } />
{(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
Loading…
+ }
+ if (projects.length === 1) {
+ return
+ }
+ 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
+ }
+ return
+}
+
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
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 6d25944..bebdc97 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -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'))
}
diff --git a/frontend/src/components/AcceptInvitation.jsx b/frontend/src/components/AcceptInvitation.jsx
index 19c5aa3..62f2ddf 100644
--- a/frontend/src/components/AcceptInvitation.jsx
+++ b/frontend/src/components/AcceptInvitation.jsx
@@ -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 {rfc_title} revoked this invitation.
Ask them to re-issue it if you should still have access.
-
Read the RFC anyway
+
Read the RFC anyway
)
}
@@ -146,7 +148,7 @@ export default function AcceptInvitation({ viewer }) {
This invitation to {rfc_title} has expired. Ask
the RFC's owner to issue a fresh one.
@@ -131,7 +133,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
{proposals.map(p => (
{p.title.replace(/^Propose:\s*/, '')}
diff --git a/frontend/src/components/Directory.jsx b/frontend/src/components/Directory.jsx
new file mode 100644
index 0000000..b63662f
--- /dev/null
+++ b/frontend/src/components/Directory.jsx
@@ -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//` 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 (
+
+
+
{name}
+ {tagline &&
{tagline}
}
+
+ {projects.map(p => (
+
+
+ {p.name}
+ {entryNoun(p.type)}s
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/src/components/Directory.test.jsx b/frontend/src/components/Directory.test.jsx
new file mode 100644
index 0000000..3aff06c
--- /dev/null
+++ b/frontend/src/components/Directory.test.jsx
@@ -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()
+}
+
+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()
+ })
+})
\ No newline at end of file
diff --git a/frontend/src/components/Inbox.jsx b/frontend/src/components/Inbox.jsx
index 50911ec..98211b0 100644
--- a/frontend/src/components/Inbox.jsx
+++ b/frontend/src/components/Inbox.jsx
@@ -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
}
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 ''
}
diff --git a/frontend/src/components/Landing.jsx b/frontend/src/components/Landing.jsx
index 47af6b9..4ed85df 100644
--- a/frontend/src/components/Landing.jsx
+++ b/frontend/src/components/Landing.jsx
@@ -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 (
-
{import.meta.env.VITE_APP_NAME}
+
{brandTitle(name)}
An open dictionary of the words humans and machines need to agree on.