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.

-

Read the RFC anyway

+

Read the RFC anyway

) } @@ -156,7 +158,7 @@ export default function AcceptInvitation({ viewer }) {

Already accepted

You've already accepted this invitation. You can{' '} - open {rfc_title} now. + open {rfc_title} now.

) @@ -200,7 +202,7 @@ export default function AcceptInvitation({ viewer }) {

- or just read the RFC without accepting + or just read the RFC without accepting

) diff --git a/frontend/src/components/Admin.jsx b/frontend/src/components/Admin.jsx index 94cdc7d..a9a83b1 100644 --- a/frontend/src/components/Admin.jsx +++ b/frontend/src/components/Admin.jsx @@ -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() {