Add the module navigation contract: manifest v9, module routes, return-to-origin
Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard. - Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe label, kebab-case path relative to the module root; no traversal, schemes, queries, duplicates, or nesting). Validated before activation. - Version 8 stays accepted beside version 9, each read strictly as itself (discriminated union); no released manifest is rewritten. The template moves to v9; the released Signage 0.1.5 manifest is a test fixture. - Real module routes: /<id> is the landing view, /<id>/<page> a declared page, anything beneath a page a module-owned detail route; anything else is not-found and mounts nothing. api/assets/display are never pages. - Sidebar: declared icons, pages nested under an open module, one aria-current, collapsed and drawer behaviour; v8 modules unchanged. - module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe, navigate, link, settings), refusing anything outside the module's routes; undefined on the display host. Mount ABI unchanged. - Return-to-origin: one `?return=` carrier on Settings URLs, validated by parseReturnTarget and against running modules' declared routes; Settings Back returns to exactly the originating module page, across refreshes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7447381c8f
commit
4671622b9d
@@ -0,0 +1,284 @@
|
||||
# 0063: Module navigation — manifest v9 icons and pages, real module routes, and return-to-origin
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-15
|
||||
- Extends: [0022](0022-module-ui-authoring-and-host-contract.md),
|
||||
[0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||
[0050](0050-module-database-abi-v1.md),
|
||||
[0057](0057-dashboard-urls-are-host-owned-routes.md),
|
||||
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
|
||||
[0059](0059-shared-interaction-primitives.md),
|
||||
[0061](0061-settings-hierarchy-and-module-settings-routes.md)
|
||||
- Supersedes, in part: [0027](0027-dashboard-navigation-and-flat-modules.md) (a module now declares
|
||||
its own icon and primary pages; grouping and ordering stay the administrator's)
|
||||
|
||||
## Context
|
||||
|
||||
DESIGN.md treats a module as a first-class application area:
|
||||
|
||||
- its primary pages belong in the global sidebar beneath it, with no second tab strip inside the
|
||||
module (§4.4, locked decision 11);
|
||||
- every meaningful page has a real route, so a refresh, a bookmark, Back and Forward work (§2.7,
|
||||
locked decision 12);
|
||||
- a module can deep-link to its centralized Settings page and come back to exactly the workflow
|
||||
that sent the person there (§2.7, §9.3, locked decision 13).
|
||||
|
||||
The platform had none of this:
|
||||
|
||||
- **Icons.** Every module was drawn with one generic icon. ADR 0058 left a module's own icon to
|
||||
"the module navigation contract (manifest v9)".
|
||||
- **Routes.** `/<module-id>` was a module's only route, and everything beneath it was not-found
|
||||
(ADR 0057).
|
||||
- **Navigation.** The module UI context had no navigation member. ADR 0022 deferred it because "it
|
||||
needs a design that does not exist", and modules may not bring a router (ADR 0026).
|
||||
- **Return target.** ADR 0059 built and validated `parseReturnTarget`, and ADR 0061 gave every
|
||||
module's settings `/settings/<module-id>`, but both deferred *how* a link carries a return
|
||||
target.
|
||||
|
||||
Released modules (Signage 0.1.5 among them) are packaged, signed, and installed as manifest version
|
||||
8, and every earlier manifest version was rejected outright by its successor.
|
||||
|
||||
## Decision
|
||||
|
||||
### Manifest v9: one optional `navigation` field
|
||||
|
||||
Manifest version 8's top-level object is strict, so a new field is a new version. Version 9 is
|
||||
version 8 plus one optional field:
|
||||
|
||||
```json
|
||||
"navigation": {
|
||||
"icon": "monitor-play",
|
||||
"pages": [
|
||||
{ "id": "playback", "label": "Playback", "path": "playback" },
|
||||
{ "id": "timelines", "label": "Timelines", "path": "timelines" },
|
||||
{ "id": "media", "label": "Media", "path": "media" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **`icon`** (optional) is a Lucide name from a curated set (below). Omitted, the module is drawn
|
||||
with the generic module icon.
|
||||
- **`pages`** (optional, at most 12) are the module's primary pages, in the order the sidebar shows
|
||||
them. Each page has:
|
||||
- **`id`**: stable and never displayed. Lowercase kebab-case, at most 64 characters, unique within
|
||||
the manifest. The navigation API reports it as the current page.
|
||||
- **`label`**: plain text, trimmed, 1–60 characters, with no control or line-separator characters.
|
||||
- **`path`**: relative to the module's root. One to four lowercase kebab-case segments joined by
|
||||
`/`, unique within the manifest. It may not lie beneath another page's path.
|
||||
- **What a path can never be:** a leading or trailing slash, an empty segment, `.` or `..`,
|
||||
percent-encoding, a query, a fragment, or a scheme. A declared path cannot escape `/<module-id>/`
|
||||
or name anything outside the module.
|
||||
- **Requires a UI.** `navigation` is only valid with `entrypoints.ui`.
|
||||
- **Validated before activation.** Validation lives in `@ekklesia/contracts`, so discovery,
|
||||
`pnpm modules:validate`, the packager, and package verification all refuse a bad declaration
|
||||
before the module can start.
|
||||
- **No page icons.** The collapsed sidebar shows the module item alone, so a page icon would never
|
||||
be seen.
|
||||
- **Nothing else is added.** No groups, order, Overview widgets, or settings declaration: a module
|
||||
already has settings exactly when it declares `configuration` (ADR 0029, ADR 0061).
|
||||
|
||||
`moduleManifestNavigation(manifest)` reads the declaration the same way for every version. A version
|
||||
8 manifest, and a version 9 manifest that omits the field, both have no icon and no pages.
|
||||
|
||||
### Version 8 stays accepted beside version 9
|
||||
|
||||
Until now every manifest version rejected its predecessor ("no dual acceptance": ADR 0006, ADR 0014,
|
||||
ADR 0021, ADR 0050). This decision is the first exception, deliberately:
|
||||
|
||||
- **Why.** Version 9 adds one optional field and changes the meaning of nothing a version 8 document
|
||||
says. Rejecting version 8 would stop every installed module at its next upgrade for no safety gain.
|
||||
- **One document, one version.** `manifestVersion` selects one strict schema (a discriminated union
|
||||
of 8 and 9). A document is only ever read as the version it declares. A version 8 document that
|
||||
names `navigation` is refused, never quietly read as version 9.
|
||||
- **Nothing is rewritten.** The packager still copies manifest bytes verbatim, and verification
|
||||
checks them against the package digest. Core never writes a manifest back.
|
||||
- **The template moves to version 9.** `MODULE_MANIFEST_VERSION` is now 9, the version a new module
|
||||
declares. `SUPPORTED_MODULE_MANIFEST_VERSIONS` is `[8, 9]`. Version 7 and earlier are still
|
||||
refused.
|
||||
|
||||
How long version 8 stays accepted is a later decision. It needs its own ADR.
|
||||
|
||||
### Icons: a curated Lucide set, named in the manifest, drawn by the host
|
||||
|
||||
A manifest names an icon. It never supplies one: no SVG, URL, component, or code.
|
||||
|
||||
- **The set.** `MODULE_NAVIGATION_ICONS` (`@ekklesia/contracts`) is about ninety Lucide names chosen
|
||||
for generic subjects (`calendar`, `monitor-play`, `users`, `book-open`, …). There is no entry for
|
||||
any particular module, and no module-to-icon table anywhere.
|
||||
- **Validated against the set.** A manifest's `icon` must be in it, so a typo is caught when a
|
||||
module is packaged or discovered, not drawn silently as the generic icon.
|
||||
- **Imported by name.** The Dashboard imports exactly these icons from `lucide-react`
|
||||
(`browser/module-icon.tsx`) and never bundles the library's thousands of others.
|
||||
- **One list, held together by tests.** A surface may not import `@ekklesia/contracts` (ADR 0024),
|
||||
so the shell keeps the names in a framework-free file, `@ekklesia/shell/module-icons`.
|
||||
- An `apps/api` test holds the shell's list equal to `MODULE_NAVIGATION_ICONS`, so the platform
|
||||
cannot accept an icon the Dashboard cannot draw.
|
||||
- A shell test proves every entry is the real Lucide component of that name.
|
||||
- **Growing the set is additive.** It needs a platform release, and a module that uses a new name
|
||||
declares the platform range that has it.
|
||||
- **Fallback.** A host that cannot draw a name draws the generic icon instead of failing. The
|
||||
Dashboard can draw all of them; another host might not.
|
||||
- **Decorative only.** Icons never carry meaning (ADR 0058). The module's name is always beside the
|
||||
icon or in its control's accessible name.
|
||||
|
||||
### Module routes
|
||||
|
||||
```text
|
||||
/<module-id> the module's landing view (its root)
|
||||
/<module-id>/<page-path> a declared primary page
|
||||
/<module-id>/<page-path>/… a route beneath a declared page: a detail view the module owns
|
||||
```
|
||||
|
||||
**The root stays the canonical landing view and is never itself a declared page.** A page may not
|
||||
use the root's path, so no page has two addresses, and `/<module-id>` never silently redirects.
|
||||
|
||||
- **Landing on a page.** A module that wants to land on, say, Playback renders Playback at its root
|
||||
and simply doesn't declare a Playback page.
|
||||
- **Beneath a declared page.** Any route under a page (`/signage/media/sermons/123`) mounts the
|
||||
module with that full path. Detail views therefore need no manifest or sidebar entry. Primary
|
||||
navigation and every valid internal route are deliberately different things.
|
||||
- **What a route can contain.** Segments are unreserved URL characters only (letters, digits, `-`,
|
||||
`.`, `_`, `~`), at most 8 segments and 512 characters. Nothing else is a route.
|
||||
- **Unknown paths.** A path that is neither the root nor at or beneath a declared page is the
|
||||
host's *Page not found*, and the module is not mounted. Nothing silently opens some other page.
|
||||
- **Version 8 modules.** A module that declares no pages, which includes every version 8 module, has
|
||||
its root and nothing beneath it, exactly as before.
|
||||
- **Disabled modules.** A module that is installed but not running gets *This page isn't available*
|
||||
at its root and at every path beneath it, and nothing mounts. A module that isn't installed is not
|
||||
served at all (ADR 0057).
|
||||
- **No remount.** The mounted module is keyed by identity, not path, so moving between its own pages
|
||||
never remounts it. Focus moves to the page on each move, as for any page change.
|
||||
|
||||
The API already serves the Dashboard page throughout `/<module-id>/*` (ADR 0057), so none of this
|
||||
needs a server or proxy change.
|
||||
|
||||
**The host's own namespaces are never pages.** `api`, `assets`, and `display` (`HOST_NAMESPACES`)
|
||||
are never Dashboard pages at any depth, so `/api/v1/principal` can never read as the route
|
||||
`v1/principal` of a module called `api`. Together with the Dashboard's platform pages they are
|
||||
exactly Core's `RESERVED_TOP_LEVEL_NAMES`, and an `apps/api` test holds the lists together.
|
||||
|
||||
### The sidebar
|
||||
|
||||
- **A version 9 module** is drawn with its own icon. While it is open (its root, a page, or beneath
|
||||
one), its pages appear beneath it, the same in-place expansion Settings uses (ADR 0061).
|
||||
- **The current item.** Exactly one item carries `aria-current="page"`: the open page, or the
|
||||
module itself on its landing view. A detail route marks its page.
|
||||
- **Collapsed sidebar.** It shows icons only, so the pages stay hidden. The module item is then the
|
||||
current one, named by its tooltip and accessible name.
|
||||
- **Phone drawer.** It has the identical hierarchy and closes once a page is chosen.
|
||||
- **A version 8 module** is one item with the generic icon, as before.
|
||||
- **A disabled module** is absent, as before.
|
||||
|
||||
The server sends each module's declared icon (`null` for none) and pages beside its name in
|
||||
`GET /api/v1/dashboard/navigation`, never inside the host-neutral descriptor (ADR 0026, ADR 0027).
|
||||
The shell parses both strictly.
|
||||
|
||||
### The module's navigation capability
|
||||
|
||||
`ModuleUiHostContext.navigation` (`@ekklesia/module-ui-sdk`) is a narrow host capability, not a
|
||||
router. It is `undefined` where a host has no addressable pages: the display host, and a standalone
|
||||
host without routes.
|
||||
|
||||
```ts
|
||||
interface ModuleUiNavigation {
|
||||
location(): { path: string; page: string | undefined }; // "media/sermons/123", "media"
|
||||
subscribe(listener: () => void): () => void;
|
||||
navigate(path: string, options?: { replace?: boolean }): void;
|
||||
link(path: string): { href: string; onClick(event): void };
|
||||
readonly settings: { open(): void; link(): ModuleUiLink } | undefined;
|
||||
}
|
||||
```
|
||||
|
||||
- **Relative paths only.** Every path is relative to the module's own root, and a leading `/` means
|
||||
the same as none. There is therefore no way to name another module or a platform page.
|
||||
- **Refused paths.** A path the host would not open throws `ModuleUiNavigationError`: malformed or
|
||||
escaping gives `MODULE_NAVIGATION_PATH_INVALID`, and not at or beneath a declared page gives
|
||||
`MODULE_NAVIGATION_PATH_UNKNOWN`. The error message never echoes the path.
|
||||
- **Real links.** `link()` returns a real `href`, so a link can be opened in a new tab, copied, or
|
||||
bookmarked. Its `onClick` opens the route in place on an ordinary click and leaves modified or
|
||||
middle clicks to the browser.
|
||||
- **Location updates.** `location()` returns a stable snapshot between moves. Every subscriber hears
|
||||
every move: the module's own navigation, the sidebar, Back and Forward.
|
||||
- **Settings.** `settings` is present only when the module has a Settings page this viewer may open
|
||||
(it declares configuration, it is running, and Settings lists it for them). It's a presentation
|
||||
hint: Settings enforces its own authority.
|
||||
- **What a module never gets.** No history object, no `window` access, and no way to drive the shell
|
||||
beyond its own routes. Modules still may not bring a router (ADR 0026).
|
||||
- **Version 8 modules too.** They receive navigation with only their root, so a version 8 module
|
||||
rebuilt against the new SDK can already link to its Settings page.
|
||||
|
||||
Adding a context member changes nothing about how an artifact is mounted, so the mount ABI stays at
|
||||
version 2. A module that uses `navigation` checks for it, exactly as it checks `display`.
|
||||
|
||||
### Return-to-origin: one carrier, the `return` query parameter
|
||||
|
||||
The place to come back to travels as **one query parameter on the page that was opened**:
|
||||
|
||||
```text
|
||||
/settings/signage?return=%2Fsignage%2Ftimelines
|
||||
```
|
||||
|
||||
- **It is a query, not history state,** so it survives a refresh and works in a link opened in a new
|
||||
tab. A canonical respelling of the address keeps it.
|
||||
- **The module never builds or checks it.** `navigation.settings` builds it from the module's
|
||||
current location.
|
||||
- **It is only ever read through `parseReturnTarget`** (ADR 0059): a canonical, same-origin
|
||||
Dashboard address with no query, fragment, traversal, or encoded separator. The host namespaces
|
||||
are never accepted.
|
||||
- **A module's address is also checked against what is running.** The module must be running here,
|
||||
and the route must be its root or at or beneath a declared page. Anything else is ignored, and
|
||||
Back goes to the page's own parent, Settings.
|
||||
- **Back follows it.** A Settings page's Back goes to exactly that address ("Back to Signage"), as
|
||||
a real link, never `history.back()`.
|
||||
|
||||
`HostHistory` gains `search()` so the shell can read the query. The shell reads exactly this one
|
||||
parameter from it.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Keep version 8 and add `navigation` to it.** Rejected: version 8's object is strict. Older
|
||||
platforms would refuse such a manifest with a misleading "invalid manifest". A version number
|
||||
says what a document is.
|
||||
- **Reject version 8 as earlier versions were.** Rejected: it would break every installed module
|
||||
for an optional, additive field.
|
||||
- **Any Lucide name, validated only by shape.** Rejected: a typo would silently draw the generic
|
||||
icon, and drawing any name means bundling or code-splitting the whole library. The API serves a
|
||||
closed set of files (ADR 0044), so it cannot serve a thousand chunks.
|
||||
- **Icons as SVG or URLs in the manifest.** Rejected: executable or remote presentation in an inert
|
||||
document, with an asset-trust story nobody needs.
|
||||
- **The root as a declared page, or a redirect from the root to the first page.** Rejected: either
|
||||
gives one page two addresses or surprises a bookmark.
|
||||
- **Every route in the manifest.** Rejected: record URLs (`/media/sermons/123`) cannot be listed.
|
||||
Primary navigation and valid routes are different things.
|
||||
- **A router in the module, or `window.history`.** Rejected: routing belongs to the host
|
||||
(ADR 0026), and two owners of one address bar disagree.
|
||||
- **Return target in history state, `sessionStorage`, or a path segment.** Rejected: history state
|
||||
and storage don't survive a new tab, and a path segment would collide with module routes. A query
|
||||
parameter is visible, refreshable, and validated on every read.
|
||||
- **Page icons.** Not added: the collapsed sidebar never shows pages.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Packages.** `@ekklesia/contracts` gains `module-navigation.ts`, the v8/v9 manifest union, and
|
||||
`moduleManifestNavigation`. `@ekklesia/module-ui-sdk` gains `navigation.ts` and
|
||||
`ModuleUiHostContext.navigation`. `@ekklesia/module-sdk` re-exports the navigation helpers and
|
||||
types. All changes are additive for module code. A host that builds a context itself must now say
|
||||
`navigation: undefined`.
|
||||
- **Core** publishes each module's navigation beside its name. The navigation response gains a
|
||||
`navigation` member per module.
|
||||
- **The shell** reads module routes at any depth, draws icons and pages, and supplies the
|
||||
capability. Its route type's `module` destination gains `path`.
|
||||
- **The template module** is version 9, with two pages, a detail route, and a Settings link.
|
||||
- **Signage** is unaffected until it chooses to move to version 9. Its released 0.1.5 manifest is a
|
||||
test fixture and runs in the deploy smoke.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Migrating Signage to version 9.
|
||||
- How long version 8 stays accepted.
|
||||
- Overview widgets.
|
||||
- A module reporting "not found" for its own unknown detail route back to the host.
|
||||
- Return targets for platform pages other than Settings.
|
||||
- Navigation groups and their editor (ADR 0027).
|
||||
- Growing the icon set.
|
||||
- Module-owned breadcrumbs.
|
||||
Reference in New Issue
Block a user