diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index b9a0aed..e7453cf 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,10 +1,10 @@
import { useEffect, useRef, useState } from 'react'
-import { Routes, Route, Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
+import { Routes, Route, Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
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 { entryPath, proposalPath, DEFAULT_COLLECTION } from './lib/entryPaths'
import { useDeployment } from './context/DeploymentProvider'
import ProjectLayout from './components/ProjectLayout.jsx'
import Directory from './components/Directory.jsx'
@@ -360,10 +360,21 @@ export default function App() {
/>
- } />
- } />
- } />
- setCatalogVersion(v => v + 1)} />} />
+ {/* §22 three-tier (C3.7): the project landing redirects into
+ its sole/default collection (S1: the `default` one). */}
+ } />
+ {/* Backcompat: the shipped v0.35.0 corpus URLs without a
+ /c// segment redirect into the default
+ collection, so old bookmarks keep working. */}
+ } />
+ } />
+ } />
+ {/* Collection-scoped corpus. Serving stays project-scoped in
+ S1 (collection = default); collection-aware serving is S2. */}
+ } />
+ } />
+ } />
+ setCatalogVersion(v => v + 1)} />} />
@@ -403,6 +414,28 @@ export default function App() {
)
}
+// §22 three-tier — corpus redirects mounted under /p/:projectId/*.
+// C3.7: the project landing skips the (single-collection) directory and lands in
+// the project's default collection.
+function DefaultCollectionRedirect() {
+ const { projectId } = useParams()
+ return
+}
+
+// Backcompat for the shipped v0.35.0 corpus URLs that lacked the
+// /c// segment: redirect into the default collection, preserving any
+// query string (e.g. ?branch=).
+function LegacyCorpusRedirect({ kind }) {
+ const { projectId, slug, prNumber } = useParams()
+ const { search } = useLocation()
+ const base = `/p/${projectId}/c/${DEFAULT_COLLECTION}`
+ let to = `${base}/`
+ if (kind === 'entry') to = `${base}/e/${slug}`
+ else if (kind === 'entryPr') to = `${base}/e/${slug}/pr/${prNumber}`
+ else if (kind === 'proposal') to = `${base}/proposals/${prNumber}`
+ return
+}
+
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
diff --git a/frontend/src/lib/entryPaths.js b/frontend/src/lib/entryPaths.js
index 840bf59..15c5f53 100644
--- a/frontend/src/lib/entryPaths.js
+++ b/frontend/src/lib/entryPaths.js
@@ -1,22 +1,29 @@
-// §22.10 — project-scoped path builders. After M3 every entry/proposal link
-// lives under `/p//…`. 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.
+// §22 three-tier — project + collection-scoped path builders. The canonical
+// entry route now carries the collection segment: `/p//c//…`.
+// In S1 each project has a single (default) collection and serving stays
+// project-scoped, so the builders emit the default collection segment; the
+// collection-aware link layer (named collections) lands in S2. Components build
+// links via these helpers so that flip happens in one place.
import { useProject } from '../components/ProjectLayout.jsx'
import { useDeployment } from '../context/DeploymentProvider'
-export function entryPath(pid, slug) {
- return `/p/${pid}/e/${slug}`
+// The default collection id (migration 029 seeds one per project at this id).
+export const DEFAULT_COLLECTION = 'default'
+
+export function entryPath(pid, slug, cid = DEFAULT_COLLECTION) {
+ return `/p/${pid}/c/${cid}/e/${slug}`
}
-export function entryPrPath(pid, slug, prNumber) {
- return `/p/${pid}/e/${slug}/pr/${prNumber}`
+export function entryPrPath(pid, slug, prNumber, cid = DEFAULT_COLLECTION) {
+ return `/p/${pid}/c/${cid}/e/${slug}/pr/${prNumber}`
}
-export function proposalPath(pid, prNumber) {
- return `/p/${pid}/proposals/${prNumber}`
+export function proposalPath(pid, prNumber, cid = DEFAULT_COLLECTION) {
+ return `/p/${pid}/c/${cid}/proposals/${prNumber}`
+}
+
+export function collectionHome(pid, cid = DEFAULT_COLLECTION) {
+ return `/p/${pid}/c/${cid}/`
}
export function projectHome(pid) {
diff --git a/frontend/src/lib/entryPaths.test.js b/frontend/src/lib/entryPaths.test.js
new file mode 100644
index 0000000..5cbd7e9
--- /dev/null
+++ b/frontend/src/lib/entryPaths.test.js
@@ -0,0 +1,36 @@
+// §22 three-tier — the canonical corpus path now carries the /c//
+// segment. These builders default to the project's `default` collection (S1).
+import { describe, it, expect } from 'vitest'
+import {
+ entryPath, entryPrPath, proposalPath, collectionHome, projectHome, DEFAULT_COLLECTION,
+} from './entryPaths.js'
+
+describe('entryPaths — collection-scoped corpus URLs', () => {
+ it('entryPath defaults to the default collection segment', () => {
+ expect(entryPath('ohm', 'human')).toBe('/p/ohm/c/default/e/human')
+ })
+
+ it('entryPath honours an explicit collection id', () => {
+ expect(entryPath('ohm', 'login', 'features')).toBe('/p/ohm/c/features/e/login')
+ })
+
+ it('entryPrPath carries the collection segment', () => {
+ expect(entryPrPath('ohm', 'human', 7)).toBe('/p/ohm/c/default/e/human/pr/7')
+ })
+
+ it('proposalPath carries the collection segment', () => {
+ expect(proposalPath('ohm', 42)).toBe('/p/ohm/c/default/proposals/42')
+ })
+
+ it('collectionHome targets the collection root', () => {
+ expect(collectionHome('ohm')).toBe('/p/ohm/c/default/')
+ })
+
+ it('projectHome stays at the project root (redirects into the collection)', () => {
+ expect(projectHome('ohm')).toBe('/p/ohm/')
+ })
+
+ it('exposes the default collection id constant', () => {
+ expect(DEFAULT_COLLECTION).toBe('default')
+ })
+})