# 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.** `/` 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/`, 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 `//` 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 / the module's landing view (its root) // a declared primary page ///… 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 `/` 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 `//*` (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, and an **optional** member (`navigation?: ModuleUiNavigation`). A host with addressable module pages (Ekklesia's Dashboard) supplies it. A host without them (the display host, and a standalone host without routes) omits it, and may also set it to `undefined`. The member is optional so that adding it breaks no one. Ekklesia's 0.x versioning makes patch releases compatible, and a context built before navigation existed must stay a complete, valid context that compiles unchanged. A `@ekklesia/module-ui-sdk` test holds exactly that, and fails if the member is ever made required. ```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` feature-detects it (`context.navigation?.navigate(…)`). ### 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. Every change is additive at the source level as well as at run time: existing module code, and an existing host that builds a context itself, compile and run unchanged. - **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.