Design System
A USWDS-aligned design system built with CUBE CSS methodology. Semantic
@@ -212,7 +212,7 @@ designSystem.get('/:slug', async (c) => {
Typography
@@ -409,7 +409,7 @@ designSystem.get('/:slug', async (c) => {
Tokens
@@ -485,7 +485,7 @@ designSystem.get('/:slug', async (c) => {
Compositions
@@ -534,7 +534,7 @@ designSystem.get('/:slug', async (c) => {
Rules
@@ -561,7 +561,7 @@ designSystem.get('/:slug', async (c) => {
Base Classes
@@ -798,7 +798,7 @@ designSystem.get('/:slug', async (c) => {
Data Visualizations
@@ -907,7 +907,7 @@ designSystem.get('/:slug', async (c) => {
Layout
@@ -1255,7 +1255,7 @@ designSystem.get('/:slug', async (c) => {
{meta.name}
diff --git a/src/entrypoints/app/routes/catalog/experiments.tsx b/src/entrypoints/app/routes/catalog/experiments.tsx
index 7aff40960..5ecbdb0c7 100644
--- a/src/entrypoints/app/routes/catalog/experiments.tsx
+++ b/src/entrypoints/app/routes/catalog/experiments.tsx
@@ -159,7 +159,7 @@ experiments.get('/', async (c) => {
Experiments
@@ -241,7 +241,7 @@ experiments.get('/:kind', async (c) => {
{
{
{
Experiment Not Found
@@ -463,7 +463,7 @@ experiments.get('/:kind/:slug', async (c) => {
@@ -479,7 +479,7 @@ experiments.get('/:kind/:slug', async (c) => {
Run Not Found
diff --git a/src/entrypoints/app/routes/catalog/index.tsx b/src/entrypoints/app/routes/catalog/index.tsx
index e991b40de..a69545398 100644
--- a/src/entrypoints/app/routes/catalog/index.tsx
+++ b/src/entrypoints/app/routes/catalog/index.tsx
@@ -55,7 +55,7 @@ catalog.get('/', async (c) => {
Catalog
diff --git a/src/entrypoints/app/routes/catalog/personas.tsx b/src/entrypoints/app/routes/catalog/personas.tsx
index 8a74c7e11..c0044e276 100644
--- a/src/entrypoints/app/routes/catalog/personas.tsx
+++ b/src/entrypoints/app/routes/catalog/personas.tsx
@@ -27,7 +27,7 @@ personas.get('/', async (c) => {
Personas
@@ -66,7 +66,7 @@ personas.get('/:id', async (c) => {
{
{
User Stories
@@ -118,7 +118,7 @@ stories.get('/:slug', async (c) => {
{
{
Project Walkthrough
@@ -180,7 +180,7 @@ walkthrough.get('/:slug', async (c) => {
Page Not Found
@@ -247,7 +247,7 @@ walkthrough.get('/:slug', async (c) => {
Promise
accessStore?: AccessStore
+ /** Resolves owner/slug from route context. Routes use
+ * project-scoped URL shapes (/:owner/:slug/forms/...). */
+ resolveOwnerSlug?: (c: Context) => { owner: string; slug: string }
+ /** Resolves specs for a project by owner/slug. */
+ getSpecsByProject?: (
+ owner: string,
+ slug: string,
+ ref?: string,
+ ) => Promise
+ /** Resolves project info for a specId. Used by My Sessions to build project-scoped URLs. */
+ resolveProjectForSpec?: (
+ specId: string,
+ ) => Promise<{ owner: string; slug: string } | null>
+ /** Gets the display name for a project by slug. Used in project-scoped mode for the page title. */
+ getProjectName?: (slug: string) => string | null
}
const MAIN_BRANCH = 'main'
-/**
- * Produce the path prefix for form URLs on a given branch. Main uses the
- * bare `/forms/:specId` shape; other branches get the `/branches/:branch`
- * infix.
- */
-function formPathPrefix(specId: string, branch: string): string {
- return branch === MAIN_BRANCH
- ? `/forms/${specId}`
- : `/forms/${specId}/branches/${branch}`
+function projectFormPathPrefix(
+ owner: string,
+ slug: string,
+ branch: string,
+): string {
+ const base = `/${owner}/${slug}/forms`
+ return branch === MAIN_BRANCH ? base : `${base}/branches/${branch}`
}
function isPreview(branch: string): boolean {
@@ -132,178 +147,276 @@ export function createFormRouter(deps: FormRouterDeps) {
getEditHref,
getSourcePdf,
getFieldMapping,
+ resolveOwnerSlug,
+ getSpecsByProject,
+ resolveProjectForSpec,
+ getProjectName,
} = deps
const forms = new Hono()
+ /**
+ * Resolve specs and URL prefix from the request context. Owner/slug come
+ * from route params and URLs use the `/:owner/:slug/forms` shape.
+ * Only available when the router is created in project-scoped mode
+ * (resolveOwnerSlug + getSpecsByProject provided).
+ */
+ async function resolveFormContext(c: Context): Promise<{
+ specs: ResolvedSpecs
+ prefix: string
+ branch: string
+ owner: string
+ slug: string
+ } | null> {
+ if (!resolveOwnerSlug || !getSpecsByProject) return null
+ const branch = readBranch(c)
+ const { owner, slug } = resolveOwnerSlug(c)
+ const specs = await getSpecsByProject(owner, slug, branch)
+ if (!specs) return null
+ return {
+ specs,
+ prefix: projectFormPathPrefix(owner, slug, branch),
+ branch,
+ owner,
+ slug,
+ }
+ }
+
// All form routes require authentication
forms.use('*', requireAuth(deps.accessStore))
- // Forms index
- forms.get('/', async (c) => {
- const allSpecs = await listSpecs()
- return c.html(
-
-
+ ,
+ )
+ })
+ }
- // My sessions
- forms.get('/sessions', async (c) => {
- const user = c.get('user')
- if (!user) return c.text('Unauthorized', 401)
- const sessions = sessionGateway.listByOwner(user.login)
- const active = sessions.filter((s) => s.status === 'active')
- const submitted = sessions.filter((s) => s.status === 'submitted')
- // Resolve titles up front so the JSX below can stay synchronous.
- const uniqueSpecIds = [...new Set(sessions.map((s) => s.specId))]
- const titlesEntries = await Promise.all(
- uniqueSpecIds.map(async (specId) => {
- const specs = await getSpecs(specId)
- return [specId, specs?.formSpec.title ?? specId] as const
- }),
- )
- const titles = new Map(titlesEntries)
- return c.html(
-
-
- ,
- )
- })
+ // My sessions — only in legacy mode; project-scoped mode
+ // will be updated in Task 5.
+ if (!resolveOwnerSlug) {
+ forms.get('/sessions', async (c) => {
+ const user = c.get('user')
+ if (!user) return c.text('Unauthorized', 401)
+ const sessions = sessionGateway.listByOwner(user.login)
+ const active = sessions.filter((s) => s.status === 'active')
+ const submitted = sessions.filter((s) => s.status === 'submitted')
+ // Resolve titles up front so the JSX below can stay synchronous.
+ const uniqueSpecIds = [...new Set(sessions.map((s) => s.specId))]
+ const titlesEntries = await Promise.all(
+ uniqueSpecIds.map(async (specId) => {
+ const specs = await getSpecs(specId)
+ return [specId, specs?.formSpec.title ?? specId] as const
+ }),
+ )
+ const titles = new Map(titlesEntries)
+ // Resolve project context for each spec so active session links
+ // can use project-scoped URLs.
+ const projectEntries = resolveProjectForSpec
+ ? await Promise.all(
+ uniqueSpecIds.map(async (specId) => {
+ const project = await resolveProjectForSpec(specId)
+ return [specId, project] as const
+ }),
+ )
+ : []
+ const projectMap = new Map(projectEntries)
+ return c.html(
+
+
+ ,
+ )
+ })
+ }
// -----------------------------------------------------------------
- // Handlers — parameterized on branch. Two sub-paths mount each one:
- // 1. /:specId/... (main branch — the legacy URL shape)
- // 2. /:specId/branches/:branch/...
- // Both call into these handlers with `readBranch(c)` returning the
- // resolved branch name. The preview banner is rendered whenever the
- // branch is non-main.
+ // Handlers — parameterized on branch. Routes use the project-scoped
+ // URL shape /:owner/:slug/forms/... with `readBranch(c)` returning
+ // the resolved branch name. The preview banner is rendered whenever
+ // the branch is non-main.
// -----------------------------------------------------------------
async function handleLanding(c: Context) {
- const branch = readBranch(c)
- const specId = c.req.param('specId')
- if (!specId) return c.notFound()
- const specs = await getSpecs(specId, branch)
- if (!specs) return c.notFound()
- const prefix = formPathPrefix(specs.dataSpec.id, branch)
+ const ctx = await resolveFormContext(c)
+ if (!ctx) return c.notFound()
+ const { specs, prefix, branch } = ctx
return c.html(
{previewBannerFor(branch, specs.sha, getEditHref, specs.dataSpec.id)}
+ {ctx.owner && ctx.slug && (
+
+ )}
+ {ctx.owner && ctx.slug && (
+
+ )}
,
)
}
async function handleCreateSession(c: Context) {
- const branch = readBranch(c)
- const specId = c.req.param('specId')
- if (!specId) return c.notFound()
- const specs = await getSpecs(specId, branch)
- if (!specs) return c.notFound()
+ const ctx = await resolveFormContext(c)
+ if (!ctx) return c.notFound()
+ const { specs, prefix } = ctx
const user = c.get('user')
if (!user) return c.text('Unauthorized', 401)
const session = sessionGateway.createSession(
@@ -312,17 +425,14 @@ export function createFormRouter(deps: FormRouterDeps) {
user.login,
specs.sha,
)
- const prefix = formPathPrefix(specs.dataSpec.id, branch)
return c.redirect(resolveUrl(`${prefix}/sessions/${session.id}/pages/0`))
}
async function handleRenderPage(c: Context) {
- const branch = readBranch(c)
- const specId = c.req.param('specId')
+ const ctx = await resolveFormContext(c)
const sessionId = c.req.param('sessionId')
- if (!specId || !sessionId) return c.notFound()
- const specs = await getSpecs(specId, branch)
- if (!specs) return c.notFound()
+ if (!ctx || !sessionId) return c.notFound()
+ const { specs, prefix, branch } = ctx
const user = c.get('user')
if (!user) return c.text('Unauthorized', 401)
const session = sessionGateway.getSession(sessionId)
@@ -331,7 +441,6 @@ export function createFormRouter(deps: FormRouterDeps) {
const pageIndex = Number(c.req.param('pageIndex'))
const resolved = resolveFormSpec(specs.formSpec, specs.dataSpec)
if (pageIndex < 0 || pageIndex >= resolved.pages.length) return c.notFound()
- const prefix = formPathPrefix(specs.dataSpec.id, branch)
const prev = findPrevPage(resolved, pageIndex, session.fields)
const prevUrl =
prev !== null
@@ -344,8 +453,26 @@ export function createFormRouter(deps: FormRouterDeps) {
conversationGateway &&
fillingAgent
return c.html(
-
+
{previewBannerFor(branch, specs.sha, getEditHref, specs.dataSpec.id)}
+ {ctx.owner && ctx.slug && (
+
+ )}
+ {ctx.owner && ctx.slug && (
+
+ )}
{showChatToggle && (