docs(design): E2 design docs for 0.6.0 navigation refactor

Adds technical design docs for the navigation epic of the doc-centric
pivot:

- 207 — Document-centric routing (/docs, /docs/:id?mode=, /index/:store, /runs)
- 208 — Doc workspace breadcrumb (Studio > <doc> > <mode>)
- 209 — Sidebar nav rework (Home / Docs / Stores / Runs / Settings)
- 210 — Feature flag mode gating (deep-link redirect + flag exposure)

Status: Accepted on all four. Each doc spells out the contract,
alternatives considered, risks per audit dimension, and testing
strategy. Backwards-compatibility is preserved throughout: legacy
routes and pages keep working until E3/E4/E5 explicitly replace them.

Refs #207 #208 #209 #210
This commit is contained in:
Pier-Jean Malandrino 2026-04-29 17:31:41 +02:00
parent 0c09274836
commit 8234a3c23f
4 changed files with 806 additions and 0 deletions

View file

@ -0,0 +1,216 @@
# Design: Document-centric routing
- **Issue:** #207
- **Title on issue:** [FEATURE] Document-centric routing (/docs, /docs/:id?mode=, /index/:store, /runs)
- **Author:** Pier-Jean Malandrino
- **Date:** 2026-04-29
- **Status:** Accepted
- **Target milestone:** 0.6.0 — Doc-centric ingest
- **Impacted layers:** frontend: app/router · pages · shared
- **Audit dimensions likely touched:** Clean Code · Tests · Documentation
- **ADR spawned?:** no
---
## 1. Problem
The current Vue Router (`frontend/src/app/router/index.ts`) is **analysis-centric**: routes are `/studio`, `/documents`, `/search`, `/reasoning`, `/history`. The selected document is held in a Pinia store, not in the URL — so two engineers cannot share a link to "the chunks editor for doc X". This breaks the killer flow ("paste this URL → fix the chunks → re-ingest") that 0.6.0 promises.
The 0.6.0 sitemap puts the document at the centre of every URL: `/docs`, `/docs/:id?mode=ask|inspect|chunks`, plus `/index/:store` for stores and `/runs` for the run history. Mode is a query param so a doc URL stays stable across mode switches.
This issue ships the routing skeleton. The actual page contents come in E3 (`/docs` library — #211), E4 (workspace shell — #216), E5 (chunks editor — #218 onward).
## 2. Goals
- [ ] Add `/docs`, `/docs/new`, `/docs/:id`, `/index`, `/index/:store`, `/index/:store/query`, `/runs`, `/runs/:id` routes.
- [ ] On `/docs/:id`, `?mode=ask|inspect|chunks` is parsed; default = `ask`.
- [ ] Each new route renders a placeholder page (clear "Coming in 0.6.0" message) until E3/E4/E5 implement them.
- [ ] Legacy routes (`/studio`, `/documents`, `/history`, `/search`, `/reasoning`) keep working — no breaking redirect in this issue.
- [ ] Smoke test: each new route renders without error.
## 3. Non-goals
- Building the actual pages — that is E3 / E4 / E5.
- Migrating users from old routes — kept functional in parallel; deprecation comes when the new pages are ready.
- Server-side route enforcement — backend exposes its API on `/api/*`; the routing here is client-side only.
- A site map / generated nav — the sidebar nav rework is **#209**.
- Feature-flag-aware redirection (e.g. mode disabled → redirect to default) — that is **#210**.
## 4. Context & constraints
### Existing code surface
- `frontend/src/app/router/index.ts` — current Vue Router setup (history mode, lazy-loaded pages).
- `frontend/src/pages/` — existing pages (`HomePage.vue`, `StudioPage.vue`, `DocumentsPage.vue`, `HistoryPage.vue`, `SearchPage.vue`, `ReasoningPage.vue`, `SettingsPage.vue`).
- `frontend/src/app/App.vue` — shell with topbar + sidebar + `<RouterView />`.
- `frontend/src/features/feature-flags/` — flag store and `useFeatureFlag` composable.
### Hard constraints
- TypeScript strict — every new route needs a typed `name`.
- No regression on existing routes — old URLs keep returning their current pages until #211 / #216 explicitly replace them.
- Lazy loading is preserved — every new page goes through `() => import(...)`.
### Deployment modes
Same routing for both `latest-local` and `latest-remote`. No HF Space-specific concern.
## 5. Proposed design
### 5.1 Router additions
Append to `frontend/src/app/router/index.ts`:
```ts
{ path: '/docs', name: 'docs-library',
component: () => import('@/pages/DocsLibraryPage.vue') },
{ path: '/docs/new', name: 'docs-new',
component: () => import('@/pages/DocsNewPage.vue') },
{ path: '/docs/:id', name: 'doc-workspace',
component: () => import('@/pages/DocWorkspacePage.vue'),
props: route => ({ id: route.params.id, mode: parseMode(route.query.mode) }) },
{ path: '/index', name: 'stores-list',
component: () => import('@/pages/StoresListPage.vue') },
{ path: '/index/:store', name: 'store-detail',
component: () => import('@/pages/StoreDetailPage.vue'),
props: true },
{ path: '/index/:store/query', name: 'store-query',
component: () => import('@/pages/StoreQueryPage.vue'),
props: true },
{ path: '/runs', name: 'runs',
component: () => import('@/pages/RunsPage.vue') },
{ path: '/runs/:id', name: 'run-detail',
component: () => import('@/pages/RunDetailPage.vue'),
props: true },
```
### 5.2 Mode parser
A pure helper in `frontend/src/shared/routing/modes.ts`:
```ts
export type DocMode = 'ask' | 'inspect' | 'chunks'
const DEFAULT_MODE: DocMode = 'ask'
export function parseMode(raw: unknown): DocMode {
return raw === 'inspect' || raw === 'chunks' ? raw : DEFAULT_MODE
}
```
This is intentionally tiny and testable. #210 will extend it with feature-flag-aware redirection.
### 5.3 Placeholder pages
Each new page is ~30 lines of Vue: a centered card with the page title, a "Coming in 0.6.0" tagline, and a link back to home. They use the existing `useI18n()` strings under a new `comingSoon.*` namespace.
### 5.4 Router types
`frontend/src/shared/routing/names.ts` exports a typed union of route names so callers do `router.push({ name: ROUTES.DOC_WORKSPACE, params: { id } })` instead of stringly-typed names.
```ts
export const ROUTES = {
HOME: 'home',
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',
// ...legacy names kept as-is
} as const
export type RouteName = (typeof ROUTES)[keyof typeof ROUTES]
```
### 5.5 i18n
New keys under `comingSoon.*` in `frontend/src/shared/i18n.ts` (fr + en):
- `comingSoon.title`
- `comingSoon.subtitle.docsLibrary`
- `comingSoon.subtitle.docsNew`
- `comingSoon.subtitle.docWorkspace`
- `comingSoon.subtitle.stores`
- `comingSoon.subtitle.storeDetail`
- `comingSoon.subtitle.storeQuery`
- `comingSoon.subtitle.runs`
- `comingSoon.subtitle.runDetail`
- `comingSoon.backHome`
## 6. Alternatives considered
### Alternative A — Replace existing routes immediately
- **Summary:** Make `/studio` and `/documents` redirect to the new routes in this issue.
- **Why not:** The new pages do not exist yet. Redirecting now means the user lands on a "Coming soon" page where they used to have a working app.
### Alternative B — Hash-mode routing
- **Summary:** Switch to `createWebHashHistory` for the new doc-centric routes.
- **Why not:** History mode is the existing convention and SPA deep-linking still works behind Nginx (already configured). No reason to mix modes.
## 7. API & data contract
No backend changes. The routes are entirely client-side. No env vars.
### Breaking changes
None. Additive.
## 8. Risks & mitigations
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|------|-----------------|------------|--------|---------------|------------------------|
| Placeholder pages confuse users who land on them via shared links | Documentation | Medium | Low | Support tickets | Clear "Coming soon" copy + back-home link |
| Route name collisions with legacy ones | Clean Code | Low | Low | TS error / runtime warning | Use a `ROUTES` constant; legacy names kept |
| Broken nav from sidebar to legacy routes after rename | Decoupling | Low | Medium | Smoke test catches | The sidebar update is explicitly **#209**, not this issue |
## 9. Testing strategy
### Frontend — Vitest
- `app/router/router.test.ts` — every new route resolves to its component (smoke).
- `shared/routing/modes.test.ts``parseMode` returns `ask` for `undefined` / `null` / unknown values; respects `inspect` / `chunks`.
### E2E — Karate UI
Out of scope for this issue (placeholder pages only). E2E coverage lands with #211 (library) and #216 (workspace).
### Manual QA
1. Visit each new URL in the browser → "Coming soon" shell renders without 404.
2. Visit `/docs/abc?mode=chunks` → page receives `mode === 'chunks'`.
3. Visit `/docs/abc?mode=garbage` → page receives `mode === 'ask'` (default).
4. Old routes (`/studio`, `/documents`) still load their existing pages.
## 10. Rollout & observability
### Release branch
`release/0.6.0`.
### Feature flag
None. The placeholder pages are visible to anyone; they explain themselves.
### Observability
No new logs. Existing router-error handling unchanged.
### Rollback plan
Revert the router and pages — old setup is untouched.
## 11. Open questions
- Should `/docs/:id` 404 if the doc id is unknown, or render the workspace shell with an error state? **Decision for 0.6.0:** the workspace handles the "doc not found" case in #216; this issue ships the placeholder which always renders.
## 12. References
- **Issue:** https://github.com/scub-france/Docling-Studio/issues/207
- **Related issues:** #208 (breadcrumb), #209 (nav), #210 (FF mode gating), #211 (library page), #216 (workspace), #218+ (modes)
- **ADRs:** none planned
- **Project docs:**
- Architecture: `docs/architecture.md`
- Frontend conventions: `frontend/CLAUDE.md`

View file

@ -0,0 +1,180 @@
# Design: Doc workspace breadcrumb
- **Issue:** #208
- **Title on issue:** [ENHANCEMENT] Refactor breadcrumb to Studio <doc> <mode>
- **Author:** Pier-Jean Malandrino
- **Date:** 2026-04-29
- **Status:** Accepted
- **Target milestone:** 0.6.0 — Doc-centric ingest
- **Impacted layers:** frontend: shared/ui · app
- **Audit dimensions likely touched:** Clean Code · Tests · Documentation
- **ADR spawned?:** no
---
## 1. Problem
There is no breadcrumb component today. The user lands on `/docs/:id?mode=chunks` and has no orientation: which doc, which mode, how to step back. With the new doc-centric URLs introduced in #207, the user navigates between modes on the same doc — a breadcrumb anchors them in the IA.
The shape `Studio <doc title> <mode>` is the agreed pattern: each segment is a link except the last, the doc title is truncated with ellipsis (full title on hover), and the mode segment updates as the user switches tabs without a page reload.
## 2. Goals
- [ ] New `<AppBreadcrumb>` component renders three segments on doc workspace pages: `Studio <doc title> <mode>`.
- [ ] Each non-last segment is clickable: `Studio``/`, `<doc title>``/docs/:id` (default mode).
- [ ] Doc title truncates beyond ~40 chars with ellipsis; full title shown via `title` attribute on hover.
- [ ] Mode segment reflects current `?mode=` and updates without remount.
- [ ] On non-doc routes (`/`, `/runs`, `/index`), the breadcrumb is empty / hidden — no fake breadcrumbs invented.
- [ ] Component is data-driven: takes a `Crumb[]` prop so future routes can supply their own.
## 3. Non-goals
- Auto-deriving the breadcrumb from the route — for 0.6.0 the doc workspace page passes its crumbs explicitly. Auto-derivation is a follow-up if more pages need it.
- Mobile-specific breadcrumb collapsing — a single-line ellipsis is enough for the layout.
- Breadcrumbs on store / run pages — those land in 0.7.0 with their own page work.
## 4. Context & constraints
### Existing code surface
- `frontend/src/app/App.vue` — the shell. The breadcrumb slot will live in the topbar (between the logo and the right-side actions).
- `frontend/src/pages/DocWorkspacePage.vue` (created by #207, placeholder). Will be the first consumer.
- `frontend/src/shared/ui/` — home for shared UI primitives. The new component lives here.
- `frontend/src/shared/routing/names.ts` (created by #207) — typed route names.
- `frontend/src/shared/i18n.ts` — strings.
### Hard constraints
- TypeScript strict — `Crumb` is a discriminated union (link vs leaf).
- Accessibility — `<nav aria-label="breadcrumb">` + `<ol>` + `aria-current="page"` on the leaf.
- No CSS framework lock-in — uses existing token classes from `App.vue` styles, no new lib.
## 5. Proposed design
### 5.1 Component contract
`frontend/src/shared/ui/AppBreadcrumb.vue`:
```ts
type LinkCrumb = { kind: 'link'; label: string; to: RouteLocationRaw }
type LeafCrumb = { kind: 'leaf'; label: string }
type Crumb = LinkCrumb | LeafCrumb
defineProps<{ crumbs: Crumb[] }>()
```
The component renders nothing when `crumbs.length === 0`.
### 5.2 Doc workspace integration
The doc workspace page (placeholder from #207) builds its crumbs:
```ts
const crumbs = computed<Crumb[]>(() => [
{ kind: 'link', label: t('breadcrumb.studio'), to: { name: ROUTES.HOME } },
{
kind: 'link',
label: truncate(doc.value?.filename ?? '...', 40),
to: { name: ROUTES.DOC_WORKSPACE, params: { id: docId.value } },
},
{ kind: 'leaf', label: t(`breadcrumb.mode.${mode.value}`) },
])
```
A small helper `truncate(text, max)` in `shared/ui/text.ts` adds the ellipsis.
### 5.3 Slot wiring in the shell
`App.vue` reserves a slot just under the topbar:
```vue
<slot name="breadcrumb">
<AppBreadcrumb :crumbs="[]" />
</slot>
```
For pages that do not opt in (which is most of them in 0.6.0), nothing renders.
The doc workspace page provides its slot via a `<teleport to="#breadcrumb-slot">` or — simpler — the `App.vue` consults a tiny Pinia store `useBreadcrumbStore()` that pages set via `setCrumbs([...])` on mount and clear on unmount. Goes with the "data-driven" goal.
We pick the **store approach**: zero teleport, plays well with route-level transitions, and the consumer lives in the page (no shell coupling).
### 5.4 i18n
New keys under `breadcrumb.*`:
- `breadcrumb.studio`
- `breadcrumb.mode.ask`
- `breadcrumb.mode.inspect`
- `breadcrumb.mode.chunks`
## 6. Alternatives considered
### Alternative A — Auto-derive crumbs from the route
- **Summary:** A central function that maps each `RouteLocationNormalized` to a `Crumb[]`.
- **Why not:** Doc workspace needs the doc title which is async — the function would need to fetch it. Page-driven is cleaner for now; auto-derivation can layer on top later for static pages.
### Alternative B — Use the page meta object (`route.meta.breadcrumb`)
- **Summary:** Each route declares a static `meta.breadcrumb` array.
- **Why not:** Same async-doc-title problem. Plus it scatters crumb config across the router.
## 7. API & data contract
No backend change. No env vars.
### Breaking changes
None.
## 8. Risks & mitigations
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|------|-----------------|------------|--------|---------------|------------------------|
| Pages forget to clear crumbs on unmount → stale breadcrumb on next route | Clean Code | Medium | Low | Visual regression | The `useBreadcrumbStore` exposes `useCrumbs(crumbs)` composable that auto-clears on unmount via `onBeforeUnmount` |
| Long doc titles break the topbar layout | Clean Code | Low | Low | Visual regression | Truncate at 40 chars + CSS `text-overflow: ellipsis` as a belt-and-braces |
| Accessibility regression | Tests / Documentation | Low | Medium | Audit | `<nav aria-label="breadcrumb">` + `aria-current="page"` on leaf; tested |
## 9. Testing strategy
### Frontend — Vitest
- `shared/ui/AppBreadcrumb.test.ts` — renders nothing when empty; renders 3 crumbs with correct roles; `aria-current="page"` on leaf.
- `shared/ui/text.test.ts``truncate` boundary cases.
- `shared/state/breadcrumbStore.test.ts``setCrumbs` / `clear`; `useCrumbs` composable auto-clears on unmount.
### Manual QA
1. Visit `/docs/abc?mode=ask``Studio abc.pdf Ask`.
2. Switch to chunks mode (when E4 lands) → leaf updates without page flicker.
3. Visit `/runs` → no breadcrumb visible.
## 10. Rollout & observability
### Release branch
`release/0.6.0`.
### Feature flag
None.
### Observability
None.
### Rollback plan
Revert; pages stop providing crumbs and the slot stays empty.
## 11. Open questions
- Should we render the breadcrumb on `/docs/new` as `Studio Import`? **Decision:** yes, the page provides 2 crumbs (`Studio Import`). Implemented as a tiny tweak in #214.
## 12. References
- **Issue:** https://github.com/scub-france/Docling-Studio/issues/208
- **Related issues:** #207 (routing), #211 (library), #216 (workspace), #218+ (modes)
- **ADRs:** none planned
- **Project docs:** Architecture (`docs/architecture.md`), Frontend conventions (`frontend/CLAUDE.md`)

View file

@ -0,0 +1,175 @@
# Design: Navigation rework — Home / Docs / Stores / Runs / Settings
- **Issue:** #209
- **Title on issue:** [ENHANCEMENT] Rework top navigation (Home / Docs / Stores / Runs / Settings)
- **Author:** Pier-Jean Malandrino
- **Date:** 2026-04-29
- **Status:** Accepted
- **Target milestone:** 0.6.0 — Doc-centric ingest
- **Impacted layers:** frontend: shared/ui · app
- **Audit dimensions likely touched:** Clean Code · Tests · Documentation
- **ADR spawned?:** no
---
## 1. Problem
The existing nav is a **left sidebar** (`AppSidebar.vue`) with seven entries: `Home`, `Studio`, `Documents`, `Search`, `Reasoning`, `History`, `Settings`. It mirrors the current execution-centric IA: `Studio` (an analysis), `Documents` (a list), `History` (past runs).
The doc-centric pivot wants a tighter five-entry nav reflecting the new IA: **Home, Docs, Stores, Runs, Settings**. `Docs` is the new primary action (replaces `Documents` + `Studio`). `Stores` is new. `Runs` replaces `History` (same data, new framing). `Search` and `Reasoning` collapse into the doc workspace modes (#216 + #224).
The original PM sitemap called this a "top nav" — the project's actual nav is a **sidebar**. We keep the sidebar (existing convention, established interaction) and rework its entries.
## 2. Goals
- [ ] `AppSidebar.vue` shows five entries in this order: Home, Docs (★ primary), Stores, Runs, Settings.
- [ ] `Docs` visually emphasised — bold label or accent colour token.
- [ ] Active state correctly highlights `Docs` on `/docs`, `/docs/new`, `/docs/:id`.
- [ ] Active state for `Stores` on `/index`, `/index/:store`, `/index/:store/query`.
- [ ] Active state for `Runs` on `/runs`, `/runs/:id`.
- [ ] Legacy entries (`Studio`, `Documents`, `Search`, `Reasoning`, `History`) removed from the sidebar.
- [ ] Legacy URLs still functional (we did not redirect in #207).
## 3. Non-goals
- Removing the legacy *pages* — they keep working; this issue removes them only from the sidebar.
- Mobile hamburger redesign — the existing burger toggle stays.
- Sidebar collapse animation — unchanged.
- Top breadcrumb — that is **#208**.
- A "what's new" tooltip explaining the change — out of scope; release notes cover it.
## 4. Context & constraints
### Existing code surface
- `frontend/src/shared/ui/AppSidebar.vue` — the file to edit. Currently 145 lines.
- `frontend/src/app/App.vue` — embeds `<AppSidebar>`.
- `frontend/src/shared/i18n.ts` — flat keys under `nav.*`.
- `frontend/src/features/feature-flags/store.ts` — flags currently gating `Search` and `Reasoning`.
### Hard constraints
- No new dependencies.
- TypeScript strict — every nav item is typed.
- Existing burger / collapse interaction stays untouched.
## 5. Proposed design
### 5.1 Nav model
A typed array driving the render:
```ts
type NavItem = {
key: string // for v-for and active match
to: RouteLocationRaw // typed via ROUTES
labelKey: string // i18n key
iconKey: string // icon name token
primary?: boolean // visual emphasis (Docs)
matchPrefixes: string[] // prefixes for active-state match
}
const items: NavItem[] = [
{ key: 'home', to: { name: ROUTES.HOME }, labelKey: 'nav.home', iconKey: 'home', matchPrefixes: ['/'] },
{ key: 'docs', to: { name: ROUTES.DOCS_LIBRARY }, labelKey: 'nav.docs', iconKey: 'docs', primary: true, matchPrefixes: ['/docs'] },
{ key: 'stores', to: { name: ROUTES.STORES_LIST }, labelKey: 'nav.stores', iconKey: 'stores', matchPrefixes: ['/index'] },
{ key: 'runs', to: { name: ROUTES.RUNS }, labelKey: 'nav.runs', iconKey: 'runs', matchPrefixes: ['/runs'] },
{ key: 'settings', to: { name: ROUTES.SETTINGS }, labelKey: 'nav.settings', iconKey: 'settings', matchPrefixes: ['/settings'] },
]
```
### 5.2 Active match
`isActive(item, route)`:
- For `/`, the home entry is active only on exact match.
- For all other items, active when the route path **starts with** any of `item.matchPrefixes`.
- Implemented in a tiny pure helper, tested.
### 5.3 Primary emphasis
The `primary: true` item gets an extra CSS class `nav-item--primary` adding a subtle accent (left bar in the sidebar's accent colour, bolder label). No new colour tokens.
### 5.4 i18n
New keys (fr + en):
- `nav.docs`
- `nav.stores`
- `nav.runs`
Removed (or kept around for the legacy pages that still exist): `nav.studio`, `nav.documents`, `nav.history`, `nav.search`, `nav.reasoning`. We **keep** them in `i18n.ts` since the legacy pages still render headings using them. They are no longer surfaced in the sidebar.
### 5.5 Footer
The sidebar footer (OpenSearch dot, GitHub stars, version) stays. The OpenSearch dot moves to be visible regardless of `ingestion` flag — it now reflects the default store's reachability (#203's seed). For 0.6.0 we keep the existing wiring (gated by `ingestion` flag) and revisit when stores get their own page (#231 in 0.7.0).
## 6. Alternatives considered
### Alternative A — Keep all old entries, add the new ones
- **Summary:** Show 10 entries; let the user discover.
- **Why not:** Defeats the point. The pivot is about reducing cognitive load, not adding entries.
### Alternative B — Move the nav to the top bar
- **Summary:** Implement the design doc's literal "top nav".
- **Why not:** The shell is sidebar-driven; switching layout is a much bigger lift and out of scope for E2.
## 7. API & data contract
No backend change. No env vars.
### Breaking changes
None at the API level. UX-level: legacy entries disappear from the sidebar. Their URLs remain valid.
## 8. Risks & mitigations
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|------|-----------------|------------|--------|---------------|------------------------|
| Users of legacy pages think the app removed features | Documentation | Medium | Medium | Support tickets | Release notes; legacy pages still work via direct URL; eventually #211/#216 replace them entirely |
| Active state misfires because two entries match the same prefix | Clean Code | Low | Low | Visual regression | `matchPrefixes` is explicit, longest-prefix wins via simple precedence in the helper |
| Missing icons for Docs / Stores / Runs in the existing icon set | Clean Code | Medium | Low | Visible during review | Audit `iconKey` strings against the icon component before merge; fall back to a sane default if missing |
## 9. Testing strategy
### Frontend — Vitest
- `shared/ui/AppSidebar.test.ts` — renders 5 entries in order; `Docs` carries the primary class; active state correct on each `matchPrefix`.
- `shared/ui/navActive.test.ts` — pure helper unit tests over `isActive(item, path)`.
### Manual QA
1. Visit `/`, `/docs`, `/index/foo`, `/runs`, `/settings` → exactly one entry highlighted.
2. Visit `/docs/abc?mode=chunks``Docs` highlighted.
3. Visit a legacy route `/studio` → no nav entry highlighted (page still loads).
## 10. Rollout & observability
### Release branch
`release/0.6.0`.
### Feature flag
None.
### Observability
None.
### Rollback plan
Revert. The legacy nav returns; legacy pages were never broken.
## 11. Open questions
- Do we add a `Help` entry now or wait? **Decision:** wait. The five-entry nav is part of the value proposition.
## 12. References
- **Issue:** https://github.com/scub-france/Docling-Studio/issues/209
- **Related issues:** #207 (routing), #208 (breadcrumb), #210 (FF gating), #211 (library), #216 (workspace)
- **ADRs:** none planned
- **Project docs:** Architecture (`docs/architecture.md`), Frontend conventions (`frontend/CLAUDE.md`)

View file

@ -0,0 +1,235 @@
# Design: Feature flags hide entire mode tabs (and redirect deep links)
- **Issue:** #210
- **Title on issue:** [ENHANCEMENT] Feature flags hide entire mode tabs instead of routing to 404
- **Author:** Pier-Jean Malandrino
- **Date:** 2026-04-29
- **Status:** Accepted
- **Target milestone:** 0.6.0 — Doc-centric ingest
- **Impacted layers:** frontend: features/feature-flags · app/router · pages · shared · backend: api/health
- **Audit dimensions likely touched:** Clean Code · Tests · Security · Documentation
- **ADR spawned?:** no
---
## 1. Problem
Studio gates `Inspect` / `Chunks` / `Ask` per tenant via feature flags. Without explicit handling, a disabled mode is still reachable by URL — a deep link like `/docs/abc?mode=chunks` lands on a half-broken page or 404 when chunks are disabled. The user experience is "the link my colleague sent me is broken".
The fix has three pieces:
1. **Routing-level redirect**: `?mode=<disabled>` rewrites to the first enabled mode, preserving the doc id.
2. **Tab-level visibility**: when E4 builds the workspace tabs (#216), disabled modes are hidden from the tab strip.
3. **Server-side guard**: even if a client sends a request for a disabled mode, the API rejects writes (read-only is OK so the user can still view).
This issue ships **piece 1** (routing redirect) plus the **flag declarations** on `/api/health` and **frontend exposure** that pieces 2 + 3 will consume. Tab visibility (piece 2) is wired in #216. Server-side write guard (piece 3) is wired by the chunks-edit endpoints from #205's follow-up.
## 2. Goals
- [ ] `/api/health` exposes three new booleans: `inspectModeEnabled`, `chunksModeEnabled`, `askModeEnabled`.
- [ ] `useFeatureFlag('inspectMode' | 'chunksMode' | 'askMode')` returns the live value.
- [ ] Routing redirects `/docs/:id?mode=<disabled>` to the first enabled mode, in this priority: `ask``chunks``inspect`. If none are enabled, redirect to `/docs` with a flash message.
- [ ] No 404 on a disabled mode — only redirects.
- [ ] Server-side: nothing yet (the actual write endpoints land elsewhere).
- [ ] E2E (Karate UI): toggle a flag (via env var) → deep link redirects correctly.
## 3. Non-goals
- Tab strip visibility — that is **#216** (workspace).
- Server-side write rejection — that lands on the chunks-edit endpoints (#205 follow-up).
- Per-user flags (vs per-tenant) — out of scope; flags are global to the deployment.
- A flag UI to toggle modes at runtime — operator-only via env vars.
## 4. Context & constraints
### Existing code surface
- `document-parser/api/schemas.py``HealthResponse` already exposes `chunking`, `disclaimer`, `ingestion_available`, `reasoning_available`.
- `document-parser/api/health.py` — endpoint that builds `HealthResponse`.
- `document-parser/infra/settings.py` — env-var driven settings.
- `frontend/src/features/feature-flags/store.ts` — flag map + `isEnabled(name)`.
- `frontend/src/features/feature-flags/useFeatureFlag.ts` — composable.
- `frontend/src/app/router/index.ts` — Vue Router (extended in #207).
### Hexagonal Architecture constraints (backend)
The flag values are read from env vars in `infra/settings.py` (existing pattern). The API layer translates them into the `HealthResponse` DTO. No domain change.
### Hard constraints
- `/api/health` stays additive — three new fields, no breaking renames.
- Frontend behaviour falls back gracefully when fields are absent (older backend version).
- Default for all three flags = `true` (enabled). That preserves current behaviour for existing deployments.
## 5. Proposed design
### 5.1 Backend
`document-parser/infra/settings.py` adds three booleans:
```python
INSPECT_MODE_ENABLED = os.getenv("INSPECT_MODE_ENABLED", "true").lower() == "true"
CHUNKS_MODE_ENABLED = os.getenv("CHUNKS_MODE_ENABLED", "true").lower() == "true"
ASK_MODE_ENABLED = os.getenv("ASK_MODE_ENABLED", "true").lower() == "true"
```
`document-parser/api/schemas.py``HealthResponse` gains:
```python
inspect_mode_enabled: bool = True
chunks_mode_enabled: bool = True
ask_mode_enabled: bool = True
```
`document-parser/api/health.py` populates them from settings.
### 5.2 Frontend — flag store
`frontend/src/features/feature-flags/store.ts` adds three keys to the flag map:
```ts
inspectMode: this.health.inspectModeEnabled ?? true,
chunksMode: this.health.chunksModeEnabled ?? true,
askMode: this.health.askModeEnabled ?? true,
```
`useFeatureFlag('inspectMode' | 'chunksMode' | 'askMode')` returns a `ComputedRef<boolean>`. No new composable; the existing one works on these new keys.
### 5.3 Frontend — routing redirect
A pure helper `frontend/src/shared/routing/resolveMode.ts`:
```ts
export const MODE_PRIORITY: DocMode[] = ['ask', 'chunks', 'inspect']
export function resolveMode(
requested: DocMode | undefined,
enabled: Record<DocMode, boolean>,
): DocMode | null {
if (requested && enabled[requested]) return requested
return MODE_PRIORITY.find(m => enabled[m]) ?? null
}
```
Wired in the router's `beforeEach` guard for `doc-workspace`:
```ts
router.beforeEach((to) => {
if (to.name !== ROUTES.DOC_WORKSPACE) return true
const requested = parseMode(to.query.mode)
const enabled = featureStore.modeFlags() // returns Record<DocMode, boolean>
const resolved = resolveMode(requested, enabled)
if (resolved === null) {
return { name: ROUTES.DOCS_LIBRARY, query: { reason: 'no-mode-enabled' } }
}
if (resolved !== requested) {
return { ...to, query: { ...to.query, mode: resolved } }
}
return true
})
```
Pure helper is unit-testable; the `beforeEach` hook is integration-tested via `router.test.ts`.
### 5.4 i18n
A flash message displayed on `/docs` if `?reason=no-mode-enabled` is present:
- `flags.allModesDisabled` (fr + en).
The flash is rendered by `DocsLibraryPage.vue` (placeholder from #207) on mount; #211 will polish the rendering.
## 6. Alternatives considered
### Alternative A — 404 on disabled mode
- **Summary:** Show a "this mode is not available" 404.
- **Why not:** A deep link sent by a colleague becomes a dead end. Redirect is more graceful.
### Alternative B — Render the workspace and disable the tab
- **Summary:** No redirect; the workspace renders with the disabled mode replaced by a "disabled" placeholder.
- **Why not:** The URL still shows the disabled mode. A user copying it shares the same broken link.
## 7. API & data contract
### Endpoints
| Method | Path | Request | Response | Breaking? |
|--------|------|---------|----------|-----------|
| GET | `/api/health` | — | now includes `inspectModeEnabled`, `chunksModeEnabled`, `askModeEnabled` | No (additive) |
### Env vars
| Name | Default | Allowed | Notes |
|------|---------|---------|-------|
| `INSPECT_MODE_ENABLED` | `true` | `true` / `false` | gates the Inspect mode end-to-end |
| `CHUNKS_MODE_ENABLED` | `true` | `true` / `false` | gates the Chunks mode |
| `ASK_MODE_ENABLED` | `true` | `true` / `false` | gates the Ask mode |
### Breaking changes
None.
## 8. Risks & mitigations
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|------|-----------------|------------|--------|---------------|------------------------|
| All three flags off → user lands on `/docs` with a confusing flash | Clean Code | Low | Low | Visual smoke | The flash explicitly names the cause; admins should never set all three off |
| Frontend caches old flag values across navigation | Decoupling | Low | Medium | Stale UI | The flag store loads on app boot and exposes a `reload()` for support flows; revisit if needed |
| Backend default change breaks existing deployments | Security / Tests | Low | High | E2E catches | Defaults = `true` (enabled). Existing behaviour preserved unless operator opts out |
| Server-side write protection missing in 0.6.0 | Security | Medium | Medium | Manual / next audit | Documented as a follow-up; client-side gating is enough until chunks-edit endpoints land |
## 9. Testing strategy
### Backend — pytest
- `tests/test_schemas.py``HealthResponse` round-trip with the three new fields, defaults preserved.
- `tests/test_settings.py` (or equivalent) — env-var parsing for the three flags.
### Frontend — Vitest
- `shared/routing/resolveMode.test.ts` — full table over `(requested, enabled)` combinations including all-disabled, all-enabled, requested disabled, requested enabled.
- `app/router/router.test.ts``beforeEach` redirect scenarios via a mocked store.
- `features/feature-flags/store.test.ts` — three new flags exposed; falls back to `true` when health response is missing them.
### E2E — Karate UI
A focused smoke under `e2e/api/`: set `CHUNKS_MODE_ENABLED=false` in the test env, hit `/docs/abc?mode=chunks`, expect a redirect to `/docs/abc?mode=ask` (default).
### Manual QA
1. Default deploy → all three modes work.
2. Set `CHUNKS_MODE_ENABLED=false`, restart, visit `/docs/abc?mode=chunks` → redirected to `/docs/abc?mode=ask`.
3. Set all three to `false``/docs/abc?mode=ask` redirects to `/docs?reason=no-mode-enabled`.
## 10. Rollout & observability
### Release branch
`release/0.6.0`.
### Feature flag
This issue **is** the feature flag work. Defaults preserve current behaviour.
### Observability
- A single `INFO` log line on the server at boot: `feature_flags inspect=<true/false> chunks=<true/false> ask=<true/false>`.
- A `WARN` log when all three are off (operator misconfiguration smell).
### Rollback plan
Set all three env vars to `true` (or unset them) → defaults restore the old behaviour.
## 11. Open questions
- Per-tenant flags (multi-tenant deployments) — explicitly punted to post-0.6.0.
- Should the flash message be a banner or a toast? **Decision:** banner, dismissible, persistent until the user navigates away.
## 12. References
- **Issue:** https://github.com/scub-france/Docling-Studio/issues/210
- **Related issues:** #207 (routing), #216 (workspace tabs), #205 follow-up (chunks-edit endpoints + server-side guard)
- **ADRs:** none planned
- **Project docs:** Architecture (`docs/architecture.md`), Frontend conventions (`frontend/CLAUDE.md`), Backend conventions (`document-parser/CLAUDE.md`)