§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,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))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user