§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:
@@ -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
|
||||
|
||||
+3
-2
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
|
||||
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.34.0",
|
||||
"version": "0.35.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+73
-12
@@ -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=<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
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
{' — '}
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user