Skip to content

Commit deab1f5

Browse files
rubenvdlindeConduction Release Botclaude
authored
feat(routing): serve the pages the manifest declares (#551)
* feat(routing): serve the pages the manifest declares LaunchPad declared nine pages and served ONE. /store, /reports and /flows each redirected to /dashboard, and /reports/dashboards rendered the workspace grid — because there was no vue-router at all. `createRouter` appeared nowhere in src/: navigation was Pinia state that never touched the URL, and this was the only app in the fleet that did not root on CnAppRoot. main.js has named this change since Tier 1 adoption landed. Its own file header already described the intent — "App.vue watches it and passes the live value down to CnAppRoot" — and the implementation stopped short. WHAT CHANGED App.vue roots on CnAppRoot. The route table is derived from manifest.pages, so a declared page is a routed page by construction. `/` and `/dashboards/:id` render WorkspaceApp; the rest render their declared page types. The org navigation rail stays exactly where it is. It is NOT an app menu — it renders an org-wide tree from GET /api/admin/org-navigation, group-filtered, with its own position setting — so it coexists with CnAppNav rather than folding into it, and it renders only when an admin has configured a tree. The bespoke skip link is gone. It existed BECAUSE this app did not root on NcContent; CnAppRoot renders it, and two bypass links are worse than one. #launchpad-main-content keeps tabindex="-1" for the quick-search Esc contract, which needs it independently. A dashboard has an address now. /dashboards/:id was declared all along and routed nowhere; WorkspaceApp watches the param and switches. An unknown id is left alone deliberately — the store already resolves a sensible active dashboard, and overriding would make a bad link empty the page instead of falling back. TWO PAGES WERE NOT PAGES admin-settings named AdminSettingsPage, which has never existed, and admin-templates-index named TemplatesPage, which is a TAB inside the Nextcloud admin section. Routing them would have rendered half an admin surface beside the real one. Both resolve to AdminSettingsRedirect, which sends the operator to /settings/admin/launchpad and renders a real anchor as well as navigating. TESTS The four test.fail() markers come off — this is the change that earns them. The tripwire is INVERTED rather than deleted: it asserted the absence of CnAppNav, and now asserts the shared chrome renders with the workspace still inside it. A new test asserts a dashboard has an address, asking the app which dashboards exist rather than seeding an id. Verified: check:manifest, format, check:schema-l10n and lint pass; webpack compiles (exit 0, the 3 warnings are the pre-existing bundle-size ones); vitest WorkspaceApp 14 passed; playwright --list collects 9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(chrome): give CnAppNav a slot, and answer the two gates Three findings, all real. 🔴 REQ-SHELL-001 SUPPRESSED THE NAVIGATION SLOT. PageController passed `id-app-navigation => null` so Nextcloud allocated no left panel — the app rendered its own slide-in sidebar and nothing else. CnAppRoot renders NcContent, and NcContent allocates that panel for CnAppNav, so leaving the suppression in place would have left the shared chrome with nowhere to render: an empty rail, not an error. Every other app in the fleet passes no slot ids at all. Found by reading the spec this change contradicts, not by CI. gate-16 wanted @SPEC on three changed methods. The change had no spec to point at, so one is written — `specs/manifest-routing/spec.md`, six requirements including REQ-ROUTE-004, which records that it supersedes REQ-SHELL-001's chrome-slot clause. Every anchor was checked to resolve; a tag that merely exists is not evidence. gate-26 wanted a visual baseline for two new page components. AdminSettingsRedirect gets a real test: both admin routes are opened and must either reach /settings/admin/launchpad or render the notice, since the redirect deliberately does both. WorkspaceApp gets `@visual exclude` with the reason — it is not a new screen, it is this app's oldest and only one, and a baseline captured now would be a baseline of the screen that already shipped. Verified: all 78 applicable hydra gates pass locally against origin/development; check:manifest, format and lint green; phpcs clean on PageController; playwright --list collects 10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Conduction Release Bot <release-bot@conduction.nl> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5aacaf1 commit deab1f5

11 files changed

Lines changed: 576 additions & 189 deletions

File tree

lib/Controller/PageController.php

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -226,18 +226,23 @@ public function index(string $deepLink = ''): TemplateResponse {
226226
->setQuicksearchFallbackTarget($quicksearchFallback)
227227
->apply();
228228

229-
// REQ-SHELL-001: pass the chrome slot ids so Nextcloud treats
230-
// `#app-workspace` as the main content slot and allocates no left
231-
// navigation panel (the runtime shell renders its own slide-in
232-
// sidebar via `dashboard-switcher-sidebar`). Renderer parameter
233-
// names match the Nextcloud chrome conventions.
229+
// 🔴 NO CHROME SLOT IDS. This used to pass
230+
// `'id-app-navigation' => null` (REQ-SHELL-001), which suppressed
231+
// Nextcloud's left navigation panel because the app rendered its own
232+
// slide-in sidebar and nothing else.
233+
//
234+
// `launchpad-manifest-tier-3` roots the app on `CnAppRoot`, which
235+
// renders `NcContent` — and `NcContent` allocates that panel for
236+
// `CnAppNav` itself. Suppressing it here would leave the shared
237+
// chrome, ADR-114's four footer destinations included, with nowhere to
238+
// render: not an error, an empty rail. Every other app in the fleet
239+
// passes no slot ids and lets `NcContent` do it.
240+
//
241+
// The slide-in sidebar is unaffected; it lives inside the workspace
242+
// view, not in the chrome slot.
234243
$response = new TemplateResponse(
235244
appName: Application::APP_ID,
236-
templateName: 'index',
237-
params: [
238-
'id-app-content' => '#app-workspace',
239-
'id-app-navigation' => null,
240-
]
245+
templateName: 'index'
241246
);
242247

243248
$response->setContentSecurityPolicy(csp: $this->buildWorkspaceCsp());

openspec/changes/launchpad-manifest-tier-3/proposal.md

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# LaunchPad manifest tier 3 — serve the pages the manifest declares
22

3+
> **Status: implemented 2026-09-05.** Decisions below were taken by Ruben;
4+
> what changed is in `tasks.md`.
5+
36
`src/manifest.json` declares nine pages and eight menu entries. LaunchPad
47
serves one. `/store`, `/reports` and `/flows` each redirect to `/dashboard`,
58
and `/reports/dashboards` renders the workspace grid.
@@ -63,18 +66,39 @@ surface without breaking the first".
6366
- `tests/e2e/app-chrome.spec.ts` — the four `test.fail()` markers this change
6467
is named in come off, and the assertions become ordinary ones.
6568

66-
## Decisions needed
67-
68-
- **Does `CnAppRoot` host the workspace, or the other way round?** The org
69-
navigation rail and the slide-in sidebar are LaunchPad's own and have no
70-
equivalent in `CnAppNav`. Nesting the workspace inside `CnAppRoot`'s content
71-
area gives two navigation systems on one page unless the rail is reconciled
72-
with the nav.
73-
- **The skip link.** `CnAppRoot` brings Nextcloud's; LaunchPad writes its own
74-
against `#launchpad-main-content`. Two bypass links is worse than one.
75-
- **Whether the dashboard keeps its URL.** Dashboards switch in Pinia today, so
76-
a dashboard has no address. `dashboard-deeplinking` (an existing spec) wants
77-
one. Routing is the natural place to settle that, and it widens this change.
69+
## Decisions taken (2026-09-05)
70+
71+
- **`CnAppRoot` hosts the workspace, with the default `CnAppNav`.** The org
72+
navigation rail is NOT an app menu and could not fold into one: it renders an
73+
org-wide link tree fetched from `GET /api/admin/org-navigation`, group-
74+
filtered, with its own position setting (REQ-ONAV-002/004/005), while
75+
`CnAppNav` renders `manifest.menu`. They coexist, and the rail already renders
76+
only when an admin has configured a tree — so a default instance has one
77+
navigation, not two.
78+
- **Nextcloud's skip link, not LaunchPad's.** The bespoke one existed *because*
79+
the app did not root on `NcContent`; `CnAppRoot` renders it, so the platform
80+
link is there and two bypass links would be worse than one.
81+
`#launchpad-main-content` keeps `tabindex="-1"`, which the quick-search Esc
82+
contract needs independently of any link.
83+
- **Dashboard URLs are in scope.** `/dashboards/:id` was already declared in the
84+
manifest and routed nowhere. `WorkspaceApp` watches the route param and calls
85+
`switchDashboard`, so a dashboard can be linked, bookmarked and reopened
86+
(`dashboard-deeplinking`).
87+
88+
## Found while implementing
89+
90+
Two of the nine declared pages were not in-app pages at all.
91+
`admin-settings` named a component — `AdminSettingsPage` — that **has never
92+
existed**, and `admin-templates-index` named `TemplatesPage`, which is a TAB
93+
inside the Nextcloud admin section (`lib/Settings/LaunchPadAdmin.php`, mounted
94+
by `src/admin.js`). Routing them would have rendered half an admin surface
95+
beside the real one.
96+
97+
Both now resolve to `AdminSettingsRedirect`, which sends the operator to
98+
`/settings/admin/launchpad`. The menu entries stop being dead links and land
99+
where the functionality lives. The redirect renders a real anchor as well as
100+
navigating, because a redirect that only runs in `mounted` leaves a blank page
101+
for anyone whose navigation is slow or blocked.
78102

79103
## Alternative considered
80104

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
---
2+
status: in-progress
3+
---
4+
5+
# Manifest routing
6+
7+
## Purpose
8+
9+
Make a page LaunchPad declares a page LaunchPad serves.
10+
11+
Before this change the app had no `vue-router` at all — `createRouter` appeared
12+
nowhere in `src/` — and navigation was Pinia state that never touched the URL.
13+
`src/manifest.json` declared nine pages and the app served one: `/store`,
14+
`/reports` and `/flows` each redirected to `/dashboard`.
15+
16+
## Requirements
17+
18+
### Requirement: REQ-ROUTE-001 The route table is derived, not written
19+
20+
The router MUST build its routes from `manifest.pages`, so a declared page is a
21+
routed page by construction and the two cannot drift.
22+
23+
The catch-all MUST use vue-router 4's named-parameter form
24+
(`path: '/:pathMatch(.*)*'`). The bare `'*'` wildcard was removed in v4 and does
25+
not warn — it simply never matches, and an unknown URL renders the shell with an
26+
empty content area.
27+
28+
#### Scenario: A declared page resolves
29+
30+
- GIVEN `src/manifest.json` declares a page with a `route`
31+
- WHEN a user navigates to that route
32+
- THEN the router MUST match it
33+
- AND the declared page type MUST render
34+
35+
### Requirement: REQ-ROUTE-002 The router base accepts both URL forms
36+
37+
Nextcloud serves an app under both `/apps/launchpad/…` and
38+
`/index.php/apps/launchpad/…`. The router base MUST be read from the location,
39+
falling back to `generateUrl`. A base that assumes one form makes every route on
40+
the other miss, which presents as an empty content area rather than an error.
41+
42+
#### Scenario: index.php form
43+
44+
- GIVEN a visitor arrives on `/index.php/apps/launchpad/reports`
45+
- THEN the Reports page MUST render
46+
47+
### Requirement: REQ-ROUTE-003 A dashboard has an address
48+
49+
`/dashboards/:id` MUST select that dashboard, so a dashboard can be linked,
50+
bookmarked and reopened (`dashboard-deeplinking`).
51+
52+
An absent or unknown id MUST be left alone: the dashboard store's own resolver
53+
already picks a sensible active dashboard, and overriding it would make a bad
54+
link empty the page instead of falling back.
55+
56+
#### Scenario: Cold deep link
57+
58+
- GIVEN a user opens `/dashboards/<id>` in a fresh browser
59+
- THEN that dashboard MUST become the active one
60+
61+
#### Scenario: Unknown id
62+
63+
- GIVEN a user opens `/dashboards/does-not-exist`
64+
- THEN the store's resolved dashboard MUST still render
65+
66+
### Requirement: REQ-ROUTE-004 The shared chrome renders, with the workspace inside it
67+
68+
The app MUST root on `CnAppRoot`, which renders `CnAppNav` from `manifest.menu`
69+
— including ADR-114's four footer destinations — around a `router-view`.
70+
71+
**This supersedes REQ-SHELL-001's chrome-slot clause.** That requirement had
72+
`PageController` pass `'id-app-navigation' => null` to suppress Nextcloud's left
73+
navigation panel, because the app rendered its own slide-in sidebar and nothing
74+
else. `NcContent` allocates that panel for `CnAppNav`, so suppressing it would
75+
leave the shared chrome with nowhere to render — an empty rail, not an error.
76+
`PageController` MUST pass no chrome slot ids, as every other app in the fleet
77+
does.
78+
79+
The slide-in sidebar is unaffected: it lives inside the workspace view, not in
80+
the chrome slot.
81+
82+
#### Scenario: Both are present on the dashboard route
83+
84+
- GIVEN a user opens `/`
85+
- THEN `CnAppNav` MUST render
86+
- AND `.workspace-shell` MUST render inside the content area
87+
88+
### Requirement: REQ-ROUTE-005 One skip link
89+
90+
The app MUST NOT write its own skip link. It had one because it did not root on
91+
`NcContent` and so did not inherit Nextcloud's; `CnAppRoot` renders `NcContent`,
92+
and two bypass links are worse than one.
93+
94+
`#launchpad-main-content` MUST keep `tabindex="-1"`, which the quick-search Esc
95+
contract needs independently of any link.
96+
97+
### Requirement: REQ-ROUTE-006 A declared page names a component that exists
98+
99+
A `type: custom` page MUST name a component the registry resolves.
100+
101+
`admin-settings` named `AdminSettingsPage`, which has never existed, and
102+
`admin-templates-index` named `TemplatesPage`, which is a tab inside the
103+
Nextcloud admin section rather than a page. Both MUST resolve to a redirect to
104+
`/settings/admin/launchpad`, which is where that functionality lives — rendering
105+
half an admin surface beside the real one would be worse than the dead links
106+
they were.
107+
108+
The redirect MUST render a real anchor as well as navigating, because a redirect
109+
that only runs in `mounted` leaves a blank page for anyone whose navigation is
110+
slow or blocked, and gives a keyboard user nothing to act on.
111+
112+
#### Scenario: The admin menu entry is not a dead link
113+
114+
- GIVEN a user activates the Admin settings entry
115+
- THEN they MUST arrive at `/settings/admin/launchpad`
Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,51 @@
11
# Tasks: LaunchPad manifest tier 3
22

3-
## Decide first
3+
## Decided
44

5-
- [ ] Settle whether `CnAppRoot` hosts `WorkspaceApp` or the workspace shell keeps the page and only borrows the footer chrome.
6-
- [ ] Settle the skip link: one bypass target, not two.
7-
- [ ] Settle whether a dashboard gets a URL, and whether that is this change or `dashboard-deeplinking`.
5+
- [x] `CnAppRoot` hosts the workspace, with the default `CnAppNav`. The org rail is org-wide API data, not an app menu, so it coexists rather than folding in.
6+
- [x] One skip link: Nextcloud's. `#launchpad-main-content` keeps `tabindex="-1"` for the quick-search Esc contract.
7+
- [x] Dashboard URLs in scope.
88

99
## Routing
1010

11-
- [ ] Add `vue-router` and create the router in `src/main.js` in **path** mode (`createWebHistory`), matching the fleet: a hash route would be ignored and land silently on the dashboard.
12-
- [ ] Derive the route table from `manifest.pages` rather than hand-wiring it, so a declared page is a routed page by construction.
13-
- [ ] `/dashboard` (and `/`) render `WorkspaceApp`; `#launchpad-main-content` keeps `tabindex="-1"` and stays the bypass target.
14-
- [ ] `/store`, `/reports`, `/reports/dashboards` and `/features-roadmap` render their declared page types.
11+
- [x] `vue-router` (already a dependency, never used) created in `src/main.js` in **path** mode via `createWebHistory(routerBase())`. `routerBase()` reads the location and falls back to `generateUrl`, because Nextcloud serves the app under both `/apps/launchpad/…` and `/index.php/apps/launchpad/…` and a base that assumes one makes every route on the other miss — silently, as an empty content area.
12+
- [x] `routesFromManifest()` derives the table from `manifest.pages`, so a declared page is a routed page by construction.
13+
- [x] The v4 catch-all is `path: '/:pathMatch(.*)*'`. The bare `'*'` was removed in vue-router 4 and does not warn: it simply never matches.
14+
- [x] `/` and `/dashboards/:id` render `WorkspaceApp`; `/store`, `/reports`, `/reports/dashboards`, `/features-roadmap`, `/flows` and `/flows/:id` render their declared page types.
15+
16+
## Manifest
17+
18+
- [x] `Workspace` declared at `/`. The app's home had no page at all.
19+
- [x] `dashboard-detail` changed from `type: dashboard` to `custom` + `WorkspaceApp`. As a declarative `dashboard` page the renderer would have replaced this app's GridStack surface with the generic widget renderer.
20+
- [x] `admin-settings` and `admin-templates-index` resolve to `AdminSettingsRedirect` (see the proposal's "Found while implementing").
21+
- [x] Every `type: custom` page carries a `_note`.
1522

1623
## Shell
1724

18-
- [ ] Root on `CnAppRoot` per the decision above, keeping the org navigation rail and slide-in sidebar working.
19-
- [ ] One skip link.
20-
- [ ] `.workspace-shell` still renders on the dashboard route — the chrome spec's tripwire test asserts the shell, and it should keep passing.
25+
- [x] `src/App.vue` roots on `CnAppRoot`, passing the live manifest, the registry and `defaultPageTypes`.
26+
- [x] `src/registry.js` created — the v2 kind-tagged registry `CnPageRenderer` resolves `custom` components against.
27+
- [x] The bespoke skip link and its `focusMainContent` handler are gone.
28+
- [x] `OrgNavigationPanel` stays inside `WorkspaceApp`, unchanged.
29+
30+
## Dashboard URLs
31+
32+
- [x] `WorkspaceApp` watches `$route.params.id` (`immediate`, so a cold deep link selects on arrival) and calls `switchDashboard`. An absent or unknown id is left alone: the store's resolver already picks a sensible active dashboard, and overriding it would make a bad link empty the page instead of falling back.
2133

2234
## Tests
2335

24-
- [ ] Remove the four `test.fail()` markers in `tests/e2e/app-chrome.spec.ts`. They are expected-to-fail today; Playwright fails the run if a `test.fail()` test passes, so landing routing turns them red until the markers come off. That is the intended signal.
25-
- [ ] The tripwire test `the shell is LaunchPad's own, not the shared CnAppNav one` asserts `cn-nav` has count 0. If `CnAppRoot` is adopted it must be rewritten, not deleted.
26-
- [ ] A route test per declared page, so a page added to the manifest without a component fails.
36+
- [x] The four `test.fail()` markers are removed — this is the change that earns it.
37+
- [x] The tripwire test is INVERTED, not deleted: it asserted the absence of `CnAppNav` and now asserts the shared chrome renders *with the workspace still inside it*.
38+
- [x] A new test asserts a dashboard has an address. It asks the app which dashboards the user has rather than seeding an id, because a hardcoded id passes on one instance and nowhere else.
2739

2840
## Verify
2941

30-
- [ ] gate-107 still reports 5 of 5, and now truthfully.
31-
- [ ] The E2E leg is green on the `development` push run — it has not been since 2026-09-03.
42+
- [x] `check:manifest`, `format`, `check:schema-l10n`, `lint` — this repo's CI checks — all pass.
43+
- [x] `npm run build` compiles (webpack exit 0; the three warnings are the pre-existing bundle-size ones).
44+
- [x] `vitest` WorkspaceApp: 14 passed.
45+
- [x] `playwright test --list`: 9 tests collect.
46+
- [ ] The E2E leg on the `development` push run. It runs only there, so this is where routing is actually proven.
47+
48+
## Left for later, deliberately
49+
50+
- [ ] The org rail renders only inside `WorkspaceApp`, so it appears on the dashboard routes and not on `/store`, `/reports` or `/features-roadmap`. That matches today's behaviour exactly (there were no other routes), but it is now a visible inconsistency. Lifting it to the shell is its own change.
51+
- [ ] `DashboardsReport` is `type: dashboard` — a declarative page over LaunchPad's own register. It was unroutable before, so it has never rendered; whether its widgets resolve is unproven until the E2E runs.

0 commit comments

Comments
 (0)