feat(#207): document-centric routing skeleton

Adds the routing scaffold for the doc-centric pivot. Each new route
renders a placeholder page until E3/E4/E5 implement them; legacy
routes (/studio, /documents, /history, /search, /reasoning) keep
working in parallel.

New routes (Vue Router, history mode):
  /docs                   library (placeholder, #211)
  /docs/new               import (placeholder, #214)
  /docs/:id?mode=         workspace (placeholder, #216)
  /index                  stores list (placeholder, 0.7.0)
  /index/:store           store detail (placeholder)
  /index/:store/query     RAG playground (placeholder)
  /runs                   run history (placeholder)
  /runs/:id               run detail (placeholder)

Mode parsing
- shared/routing/modes.ts: DocMode union ('ask'|'inspect'|'chunks'),
  parseMode() returns the default ('ask') for missing or unknown
  values. #210 will layer feature-flag-aware redirection on top.

Route names
- shared/routing/names.ts: ROUTES typed const so callers do
  router.push({ name: ROUTES.DOC_WORKSPACE, ... }) instead of
  stringly-typed names.

Pages
- ComingSoonShell shared component: card + back-home link, themed
  with existing CSS tokens.
- 8 thin placeholder pages (one per new route) that compose the shell
  and forward route params.
- i18n keys under comingSoon.* added in fr + en.

Tests
- shared/routing/modes.test.ts (10 cases): isDocMode + parseMode +
  ALL_MODES invariants.
- app/router/router.test.ts (5 cases): every doc-centric route
  resolves to a component, legacy routes still work, doc workspace
  receives id and parsed mode as props, unknown mode falls back to
  ask, unknown path redirects to home.

Routes table extracted to routes.ts so tests build a router with
createMemoryHistory() (no window required) instead of needing the
production createWebHistory() router.

Refs #207
This commit is contained in:
Pier-Jean Malandrino 2026-04-29 17:38:19 +02:00
parent 621a9e3fce
commit f10cd01594
16 changed files with 578 additions and 54 deletions

View file

@ -1,59 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('../../pages/HomePage.vue'),
},
{
path: '/studio',
name: 'studio',
component: () => import('../../pages/StudioPage.vue'),
},
{
path: '/history',
name: 'history',
component: () => import('../../pages/HistoryPage.vue'),
},
{
path: '/documents',
name: 'documents',
component: () => import('../../pages/DocumentsPage.vue'),
},
{
path: '/search',
name: 'search',
component: () => import('../../pages/SearchPage.vue'),
},
{
// Reasoning-trace tunnel. Route is always registered; the page shows
// an empty state when the `reasoning` feature flag is off (same pattern
// as /search does for ingestion).
path: '/reasoning',
name: 'reasoning',
component: () => import('../../pages/ReasoningPage.vue'),
},
{
// Deep-link into a specific document's reasoning workspace, e.g. shared
// by Peter to a teammate.
path: '/reasoning/:docId',
name: 'reasoning-doc',
component: () => import('../../pages/ReasoningPage.vue'),
props: true,
},
{
path: '/settings',
name: 'settings',
component: () => import('../../pages/SettingsPage.vue'),
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
redirect: '/',
},
]
import { routes } from './routes'
export { routes }
export const router = createRouter({
history: createWebHistory(),

View file

@ -0,0 +1,81 @@
import { createMemoryHistory, createRouter } from 'vue-router'
import { describe, expect, it } from 'vitest'
import { routes } from './routes'
import { ROUTES } from '../../shared/routing/names'
/**
* Router test uses `createMemoryHistory` so we don't need `window`
* (vitest defaults to the node environment for performance). The
* production router builds the same `routes` table on top of
* `createWebHistory` in `index.ts`.
*/
const buildRouter = () => createRouter({ history: createMemoryHistory(), routes })
describe('router', () => {
it('resolves every 0.6.0 doc-centric route to a component', () => {
const router = buildRouter()
const cases: Array<{ path: string; name: string }> = [
{ path: '/docs', name: ROUTES.DOCS_LIBRARY },
{ path: '/docs/new', name: ROUTES.DOCS_NEW },
{ path: '/docs/abc', name: ROUTES.DOC_WORKSPACE },
{ path: '/index', name: ROUTES.STORES_LIST },
{ path: '/index/foo', name: ROUTES.STORE_DETAIL },
{ path: '/index/foo/query', name: ROUTES.STORE_QUERY },
{ path: '/runs', name: ROUTES.RUNS },
{ path: '/runs/run-42', name: ROUTES.RUN_DETAIL },
]
for (const c of cases) {
const resolved = router.resolve(c.path)
expect(resolved.name, `route ${c.path}`).toBe(c.name)
expect(resolved.matched.length, `route ${c.path} has a component`).toBeGreaterThan(0)
}
})
it('keeps legacy routes functional', () => {
const router = buildRouter()
expect(router.resolve('/').name).toBe(ROUTES.HOME)
expect(router.resolve('/studio').name).toBe(ROUTES.STUDIO)
expect(router.resolve('/documents').name).toBe(ROUTES.DOCUMENTS)
expect(router.resolve('/history').name).toBe(ROUTES.HISTORY)
expect(router.resolve('/search').name).toBe(ROUTES.SEARCH)
expect(router.resolve('/reasoning').name).toBe(ROUTES.REASONING)
expect(router.resolve('/reasoning/abc').name).toBe(ROUTES.REASONING_DOC)
expect(router.resolve('/settings').name).toBe(ROUTES.SETTINGS)
})
it('passes id and parsed mode to the doc workspace as props', () => {
const router = buildRouter()
const route = router.resolve({
name: ROUTES.DOC_WORKSPACE,
params: { id: 'abc' },
query: { mode: 'chunks' },
})
const propsFn = route.matched[0]?.props as
| { default?: (r: typeof route) => unknown }
| undefined
const computed = (propsFn?.default ?? (() => null))(route) as { id: string; mode: string }
expect(computed.id).toBe('abc')
expect(computed.mode).toBe('chunks')
})
it('falls back to ask when mode is unknown', () => {
const router = buildRouter()
const route = router.resolve({
name: ROUTES.DOC_WORKSPACE,
params: { id: 'abc' },
query: { mode: 'garbage' },
})
const propsFn = route.matched[0]?.props as
| { default?: (r: typeof route) => unknown }
| undefined
const computed = (propsFn?.default ?? (() => null))(route) as { mode: string }
expect(computed.mode).toBe('ask')
})
it('redirects unknown paths to /', () => {
const router = buildRouter()
const resolved = router.resolve('/nope/this/does/not/exist')
expect(resolved.matched[0]?.redirect).toBeDefined()
})
})

View file

@ -0,0 +1,125 @@
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router'
import { parseMode } from '../../shared/routing/modes'
import { ROUTES } from '../../shared/routing/names'
/**
* Route table used by the production router and by tests.
*
* Lives in its own file so importing the router definition doesn't
* trigger `createWebHistory()` (which needs `window` and breaks the
* default node-environment vitest tests). Tests build a router with
* `createMemoryHistory()` over this same table.
*/
export const routes: RouteRecordRaw[] = [
// ---------------------------------------------------------------------------
// Legacy routes — kept functional during the 0.6.0 transition.
// ---------------------------------------------------------------------------
{
path: '/',
name: ROUTES.HOME,
component: () => import('../../pages/HomePage.vue'),
},
{
path: '/studio',
name: ROUTES.STUDIO,
component: () => import('../../pages/StudioPage.vue'),
},
{
path: '/history',
name: ROUTES.HISTORY,
component: () => import('../../pages/HistoryPage.vue'),
},
{
path: '/documents',
name: ROUTES.DOCUMENTS,
component: () => import('../../pages/DocumentsPage.vue'),
},
{
path: '/search',
name: ROUTES.SEARCH,
component: () => import('../../pages/SearchPage.vue'),
},
{
// Reasoning-trace tunnel. Route is always registered; the page shows
// an empty state when the `reasoning` feature flag is off (same pattern
// as /search does for ingestion).
path: '/reasoning',
name: ROUTES.REASONING,
component: () => import('../../pages/ReasoningPage.vue'),
},
{
// Deep-link into a specific document's reasoning workspace, e.g. shared
// by Peter to a teammate.
path: '/reasoning/:docId',
name: ROUTES.REASONING_DOC,
component: () => import('../../pages/ReasoningPage.vue'),
props: true,
},
{
path: '/settings',
name: ROUTES.SETTINGS,
component: () => import('../../pages/SettingsPage.vue'),
},
// ---------------------------------------------------------------------------
// 0.6.0 — Document-centric routes (#207). Placeholder pages until E3/E4/E5
// implement them; the legacy routes above keep working in parallel.
// ---------------------------------------------------------------------------
{
path: '/docs',
name: ROUTES.DOCS_LIBRARY,
component: () => import('../../pages/DocsLibraryPage.vue'),
},
{
path: '/docs/new',
name: ROUTES.DOCS_NEW,
component: () => import('../../pages/DocsNewPage.vue'),
},
{
path: '/docs/:id',
name: ROUTES.DOC_WORKSPACE,
component: () => import('../../pages/DocWorkspacePage.vue'),
props: (route: RouteLocationNormalized) => ({
id: String(route.params.id),
mode: parseMode(route.query.mode),
}),
},
{
path: '/index',
name: ROUTES.STORES_LIST,
component: () => import('../../pages/StoresListPage.vue'),
},
{
path: '/index/:store',
name: ROUTES.STORE_DETAIL,
component: () => import('../../pages/StoreDetailPage.vue'),
props: true,
},
{
path: '/index/:store/query',
name: ROUTES.STORE_QUERY,
component: () => import('../../pages/StoreQueryPage.vue'),
props: true,
},
{
path: '/runs',
name: ROUTES.RUNS,
component: () => import('../../pages/RunsPage.vue'),
},
{
path: '/runs/:id',
name: ROUTES.RUN_DETAIL,
component: () => import('../../pages/RunDetailPage.vue'),
props: true,
},
// ---------------------------------------------------------------------------
// 404 — must come last.
// ---------------------------------------------------------------------------
{
path: '/:pathMatch(.*)*',
name: ROUTES.NOT_FOUND,
redirect: '/',
},
]

View file

@ -0,0 +1,27 @@
<template>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.docWorkspace')"
:hint="hint"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../shared/i18n'
import { type DocMode } from '../shared/routing/modes'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
/**
* Doc workspace placeholder. Receives the doc id (from `:id` path param)
* and the resolved mode (from `?mode=` query param, parsed by the
* router via `parseMode`). The full workspace is built by #216 (E4)
* on top of #218-#224 (E5 chunks editor).
*/
const props = defineProps<{ id: string; mode: DocMode }>()
const { t } = useI18n()
const hint = computed(() => t('comingSoon.hint.docWorkspace', { id: props.id, mode: props.mode }))
</script>

View file

@ -0,0 +1,14 @@
<template>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.docsLibrary')"
/>
</template>
<script setup lang="ts">
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const { t } = useI18n()
</script>

View file

@ -0,0 +1,11 @@
<template>
<ComingSoonShell :title="t('comingSoon.title')" :subtitle="t('comingSoon.subtitle.docsNew')" />
</template>
<script setup lang="ts">
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const { t } = useI18n()
</script>

View file

@ -0,0 +1,20 @@
<template>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.runDetail')"
:hint="hint"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const props = defineProps<{ id: string }>()
const { t } = useI18n()
const hint = computed(() => t('comingSoon.hint.runDetail', { id: props.id }))
</script>

View file

@ -0,0 +1,11 @@
<template>
<ComingSoonShell :title="t('comingSoon.title')" :subtitle="t('comingSoon.subtitle.runs')" />
</template>
<script setup lang="ts">
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const { t } = useI18n()
</script>

View file

@ -0,0 +1,20 @@
<template>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.storeDetail')"
:hint="hint"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const props = defineProps<{ store: string }>()
const { t } = useI18n()
const hint = computed(() => t('comingSoon.hint.storeDetail', { store: props.store }))
</script>

View file

@ -0,0 +1,20 @@
<template>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.storeQuery')"
:hint="hint"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const props = defineProps<{ store: string }>()
const { t } = useI18n()
const hint = computed(() => t('comingSoon.hint.storeQuery', { store: props.store }))
</script>

View file

@ -0,0 +1,11 @@
<template>
<ComingSoonShell :title="t('comingSoon.title')" :subtitle="t('comingSoon.subtitle.stores')" />
</template>
<script setup lang="ts">
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const { t } = useI18n()
</script>

View file

@ -19,6 +19,26 @@ const messages: Messages = {
// Top bar
'topbar.newAnalysis': 'Nouvelle analyse',
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
'comingSoon.title': 'Bientôt disponible',
'comingSoon.subtitle.docsLibrary':
"La bibliothèque de documents arrive avec la 0.6.0. Vous y verrez l'état du cycle de vie de chaque document, ses stores et ses dernières mises à jour.",
'comingSoon.subtitle.docsNew':
"L'import multi-fichiers (drop d'un dossier ou sélection multiple) arrive avec la 0.6.0.",
'comingSoon.subtitle.docWorkspace':
"L'espace de travail document (Inspect / Chunks / Ask) arrive avec la 0.6.0.",
'comingSoon.subtitle.stores': 'La liste des stores arrive avec la 0.6.0.',
'comingSoon.subtitle.storeDetail':
'La vue détaillée du store (documents présents, état par store) arrive avec la 0.6.0.',
'comingSoon.subtitle.storeQuery': 'Le playground de requête RAG arrive avec la 0.6.0.',
'comingSoon.subtitle.runs': "L'historique des runs (audit / debug) arrive avec la 0.6.0.",
'comingSoon.subtitle.runDetail': "Le détail d'un run arrive avec la 0.6.0.",
'comingSoon.hint.docWorkspace': 'doc {id} · mode {mode}',
'comingSoon.hint.storeDetail': 'store {store}',
'comingSoon.hint.storeQuery': 'store {store}',
'comingSoon.hint.runDetail': 'run {id}',
'comingSoon.backHome': "Retour à l'accueil",
// Home
'home.title': 'Docling Studio',
'home.subtitle':
@ -287,6 +307,26 @@ const messages: Messages = {
'topbar.newAnalysis': 'New analysis',
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
'comingSoon.title': 'Coming soon',
'comingSoon.subtitle.docsLibrary':
'The document library lands with 0.6.0. It will show every document with its lifecycle state, the stores it lives in, and when it was last updated.',
'comingSoon.subtitle.docsNew':
'Multi-file import (drop a folder or pick multiple files) lands with 0.6.0.',
'comingSoon.subtitle.docWorkspace':
'The doc workspace (Inspect / Chunks / Ask) lands with 0.6.0.',
'comingSoon.subtitle.stores': 'The stores list lands with 0.6.0.',
'comingSoon.subtitle.storeDetail':
'The store detail view (docs present, per-store state) lands with 0.6.0.',
'comingSoon.subtitle.storeQuery': 'The RAG query playground lands with 0.6.0.',
'comingSoon.subtitle.runs': 'The runs history (audit / debug) lands with 0.6.0.',
'comingSoon.subtitle.runDetail': 'Run detail lands with 0.6.0.',
'comingSoon.hint.docWorkspace': 'doc {id} · mode {mode}',
'comingSoon.hint.storeDetail': 'store {store}',
'comingSoon.hint.storeQuery': 'store {store}',
'comingSoon.hint.runDetail': 'run {id}',
'comingSoon.backHome': 'Back to home',
'home.title': 'Docling Studio',
'home.subtitle':
'Analyze, explore and validate the structure of your PDF documents with Docling.',

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { ALL_MODES, DEFAULT_MODE, isDocMode, parseMode } from './modes'
describe('isDocMode', () => {
it.each(['ask', 'inspect', 'chunks'])('accepts %s', (value) => {
expect(isDocMode(value)).toBe(true)
})
it.each([undefined, null, '', 'foo', 42, {}, []])('rejects %s', (value) => {
expect(isDocMode(value)).toBe(false)
})
})
describe('parseMode', () => {
it('returns the default for missing or unknown values', () => {
expect(parseMode(undefined)).toBe(DEFAULT_MODE)
expect(parseMode(null)).toBe(DEFAULT_MODE)
expect(parseMode('garbage')).toBe(DEFAULT_MODE)
expect(parseMode(['chunks'])).toBe(DEFAULT_MODE) // arrays not accepted
})
it.each(['ask', 'inspect', 'chunks'] as const)('respects %s', (mode) => {
expect(parseMode(mode)).toBe(mode)
})
})
describe('ALL_MODES', () => {
it('lists every mode exactly once', () => {
expect(new Set(ALL_MODES).size).toBe(ALL_MODES.length)
expect(ALL_MODES).toContain('ask')
expect(ALL_MODES).toContain('inspect')
expect(ALL_MODES).toContain('chunks')
})
})

View file

@ -0,0 +1,24 @@
/**
* Doc workspace mode parsing.
*
* The doc workspace at `/docs/:id` exposes three modes via the `?mode=`
* query param. Anything missing or unknown resolves to the default,
* `ask`, so a malformed URL never produces a broken page.
*
* #210 layers feature-flag-aware redirection on top: if the requested
* mode is disabled for the current tenant, the router replaces it with
* the first enabled mode (priority `ask` > `chunks` > `inspect`).
*/
export type DocMode = 'ask' | 'inspect' | 'chunks'
export const DEFAULT_MODE: DocMode = 'ask'
export const ALL_MODES: readonly DocMode[] = ['ask', 'inspect', 'chunks'] as const
export function isDocMode(value: unknown): value is DocMode {
return value === 'ask' || value === 'inspect' || value === 'chunks'
}
export function parseMode(raw: unknown): DocMode {
return isDocMode(raw) ? raw : DEFAULT_MODE
}

View file

@ -0,0 +1,35 @@
/**
* Canonical route name constants typed to keep callers honest.
*
* Use:
*
* router.push({ name: ROUTES.DOC_WORKSPACE, params: { id } })
*
* instead of stringly-typed names. Adding a route requires touching
* exactly two places: the router definition and this file.
*/
export const ROUTES = {
// Existing legacy routes — kept until E3/E4/E5 replace them.
HOME: 'home',
STUDIO: 'studio',
HISTORY: 'history',
DOCUMENTS: 'documents',
SEARCH: 'search',
REASONING: 'reasoning',
REASONING_DOC: 'reasoning-doc',
SETTINGS: 'settings',
NOT_FOUND: 'not-found',
// 0.6.0 — Document-centric routes (#207).
DOCS_LIBRARY: 'docs-library',
DOCS_NEW: 'docs-new',
DOC_WORKSPACE: 'doc-workspace',
STORES_LIST: 'stores-list',
STORE_DETAIL: 'store-detail',
STORE_QUERY: 'store-query',
RUNS: 'runs',
RUN_DETAIL: 'run-detail',
} as const
export type RouteName = (typeof ROUTES)[keyof typeof ROUTES]

View file

@ -0,0 +1,101 @@
<template>
<div class="coming-soon">
<div class="coming-soon__card">
<div class="coming-soon__badge">0.6.0</div>
<h1 class="coming-soon__title">{{ title }}</h1>
<p class="coming-soon__subtitle">{{ subtitle }}</p>
<p v-if="hint" class="coming-soon__hint">{{ hint }}</p>
<RouterLink :to="{ name: ROUTES.HOME }" class="coming-soon__back">
{{ t('comingSoon.backHome') }}
</RouterLink>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from '../i18n'
import { ROUTES } from '../routing/names'
defineProps<{
/** Page title shown as `<h1>`. Typically the i18n-resolved page name. */
title: string
/** One-sentence description of what the page will do once shipped. */
subtitle: string
/** Optional secondary hint (e.g. issue link, ETA). */
hint?: string
}>()
const { t } = useI18n()
</script>
<style scoped>
.coming-soon {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 48px);
padding: 2rem;
}
.coming-soon__card {
max-width: 28rem;
text-align: center;
padding: 2.5rem 2rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.coming-soon__badge {
display: inline-block;
margin-bottom: 1rem;
padding: 0.25rem 0.6rem;
background: var(--color-accent-soft, #eff6ff);
color: var(--color-accent, #1d4ed8);
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
}
.coming-soon__title {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text, #111);
}
.coming-soon__subtitle {
margin: 0 0 1rem;
font-size: 1rem;
color: var(--color-text-muted, #6b7280);
line-height: 1.5;
}
.coming-soon__hint {
margin: 0 0 1.5rem;
font-size: 0.875rem;
color: var(--color-text-muted, #6b7280);
}
.coming-soon__back {
display: inline-block;
padding: 0.5rem 1rem;
background: transparent;
color: var(--color-accent, #1d4ed8);
border: 1px solid var(--color-accent, #1d4ed8);
border-radius: 6px;
font-size: 0.875rem;
text-decoration: none;
transition:
background-color 0.15s,
color 0.15s;
}
.coming-soon__back:hover {
background: var(--color-accent, #1d4ed8);
color: #fff;
}
</style>