This is Stage 1 PR 7, the module navigation contract. Modules get a declared Lucide icon and primary pages in the global sidebar, real nested routes, a host navigation capability, and module → Settings → Back to the exact origin. Released manifest v8 modules keep working unchanged. Decisions are recorded in ADR 0063.
This PR is contract and foundation only. Signage is untouched and not migrated. It adds no Overview widgets, Discover features, settings forms, Displays work, or group editor.
⚠️ One precedent change for review: v8 stays accepted beside v9
Every previous manifest bump (v1→…→v8) rejected its predecessor outright: "no dual acceptance", per ADRs 0006, 0014, 0021 and 0050. You asked for v8 compatibility, so v8 and v9 are both accepted.
manifestVersion selects one strict schema, via a discriminated union.
A document is only ever read as the version it declares. A v8 document that names navigation is refused, never upgraded.
No released manifest, package byte, or digest is rewritten.
v7 and earlier are still refused.
How long v8 stays accepted is left to a future ADR.
v9 is the right version number: v8's top-level object is strict, so a new field is a version bump by the repository's own rule.
icon (optional): one of MODULE_NAVIGATION_ICONS, a curated set of 93 generic Lucide names. Omitted, the module gets the generic module icon.
pages (optional): at most 12, in declared order.
id: kebab-case, stable, unique.
label: trimmed, 1–60 characters, no control or line-separator characters.
path: 1–4 lowercase kebab-case segments relative to the module root. It can't contain a leading or trailing slash, an empty segment, . or .., percent-encoding, a query, a fragment or a scheme, and can't be a duplicate, sit beneath another page, or be the root.
navigation is only valid with entrypoints.ui. It is validated by discovery, modules:validate, the packager and package verification before activation.
No page icons. The collapsed sidebar only ever shows the module item, so page icons would never be seen.
No settings declaration.configuration already answers whether a module has settings (ADR 0061).
No widgets or groups.
Icons
Validated against the list. Manifests are checked against the curated set, so typos are caught at pack and discovery time.
Imported by name. The shell imports exactly those icons from lucide-react (browser/module-icon.tsx); the full library of about 6,300 icons is never bundled.
One list, held together by tests. Surfaces can't import @ekklesia/contracts (ADR 0024), so the shell keeps the names in a framework-free subpath, @ekklesia/shell/module-icons.
An apps/api test holds that copy equal to the contracts list.
A shell test proves each entry is the real Lucide component.
Fallback. A name a host can't draw falls back to the generic icon.
Decorative only. Icons are always aria-hidden, with the name in the label or tooltip.
There is no module → icon table anywhere.
Routes
/<id> landing view (never itself a declared page → no duplicate identities, no redirect)
/<id>/<page> declared page
/<id>/<page>/… anything beneath a page: module-owned detail routes, e.g. /media/sermons/123
anything else host "Page not found"; the module is not mounted
Refresh, deep links, and Back/Forward all land on the same route. Moving between a module's own pages never remounts it, because the frame is keyed by module identity.
v8 modules: root only. Anything deeper is not-found, exactly as before.
Disabled modules: "unavailable" at the root and every subpath, and nothing mounts.
Uninstalled modules: not served (unchanged).
New guard:api, assets and display (HOST_NAMESPACES) are never pages at any depth. Without it, nested module routes would have let /api/v1/principal parse as a route of a module called api, and so be accepted as a return target. A test holds DASHBOARD_PLATFORM_PAGES + HOST_NAMESPACES equal to Core's RESERVED_TOP_LEVEL_NAMES.
Sidebar
v9: the declared icon. Pages are nested beneath the module while it's open, using the same in-place expansion and indentation as Settings.
Current item: exactly one aria-current="page". It's the open page, or the module itself on its landing view; a detail route marks its page.
Collapsed: pages are hidden and the module item is current, named by its tooltip and accessible name.
Phone drawer: the identical hierarchy with 48px targets, closing once a page is chosen.
v8: the generic icon and a single item.
Disabled modules: absent.
The server sends navigation: { icon, pages } beside each module's name in /api/v1/dashboard/navigation, never inside the host-neutral descriptor.
SDK: context.navigation (@ekklesia/module-ui-sdk)
interfaceModuleUiNavigation{location():{path: string;page: string|undefined};subscribe(listener):()=>void;navigate(path,{replace?}?):void;link(path):{href;onClick(event)};// real <a>; modified/middle clicks left to the browser
readonlysettings:{open();link()}|undefined;}
Relative paths only. Paths are module-relative, and a leading / means the same as none.
Refused paths throwModuleUiNavigationError, with a code and a message that never echoes the path:
malformed or escaping paths give MODULE_NAVIGATION_PATH_INVALID;
undeclared pages give MODULE_NAVIGATION_PATH_UNKNOWN.
Cross-module navigation is impossible. No history object, window access or router is exposed.
Where it's present: it's an optional member (navigation?), so an existing hand-built context compiles unchanged. The Dashboard supplies it, the display host omits it, and modules feature-detect it. v8 modules get navigation too, with only their root.
No ABI change. The mount ABI stays at 2; this is an additive context member.
Return-to-origin: one carrier
The return destination travels in a query parameter on the Settings URL:
/settings/signage?return=%2Fsignage%2Ftimelines
navigation.settings builds it from the module's current location, so module authors never build or validate it.
It survives a refresh, a new tab, and a canonical respelling.HostHistory gains search() to support this.
a module address must also name a running module and one of its declared routes.
Back reads "Back to Signage" and is a real link to the validated address. It never uses history.back(). An invalid target falls back to Settings.
Public package changes (no version bumps, as the release process does those separately)
@ekklesia/contracts: the v8/v9 manifest union; module-navigation.ts (MODULE_NAVIGATION_ICONS, page and navigation schemas, moduleManifestNavigation); MODULE_MANIFEST_VERSION is now 9; SUPPORTED_MODULE_MANIFEST_VERSIONS is [8, 9].
@ekklesia/module-ui-sdk:navigation.ts (types and ModuleUiNavigationError), plus an optionalModuleUiHostContext.navigation?. This is source-compatible: a context built before navigation existed still compiles unchanged, and a test proves it. Hosts without pages, including the display host, omit it, and modules feature-detect it.
@ekklesia/module-sdk: re-exports the navigation helpers and types.
Unchanged:module-ui-host, module-pack and ui.
Private: Core and the API publish and serve navigation. The shell adds routes, the sidebar, the capability, and the ./module-icons subpath. The template module moves to v9 with two pages, a detail route, and a Settings button.
Compatibility correction (c78f131)
navigation was first a required member (navigation: ModuleUiNavigation | undefined), which would have broken any existing hand-built context at compile time. That isn't allowed for a 0.x patch-compatible change. It is now navigation?: ModuleUiNavigation | undefined:
Old contexts compile. A context without the member still satisfies ModuleUiHostContext. The navigation: undefined lines this PR had added to existing hosts and tests are reverted, so those files are byte-identical to main again.
A test guards it. A module-ui-sdk test type-checks an exact pre-navigation context with satisfies. Making the member required again fails 5 type checks, confirmed by mutation.
Runtime and security are unchanged. The display host now omits the member rather than setting it to undefined.
After the change: verify, package release and package publication all pass again.
Tests
Contracts:
v9 single- and multi-page manifests, the icon and its fallback, invalid icons (unknown, PascalCase, markup, URL, object), and 18 unsafe path forms;
malformed ids and labels, duplicate ids and paths, nesting, the bound, and navigation without a UI;
deterministic order; exactly versions 8 and 9 accepted;
the released Signage 0.1.5 manifest, byte-identical to the one inside the published package (SHA-256 checked), accepted as v8 with no navigation; a v8 document carrying navigation refused.
Core: discovery of the released v8 Signage module and a v9 module side by side, and refusal of an escaping page.
API:
the navigation view carries the v9 icon and pages, and {icon:null, pages:[]} for v8;
an unsafe v9 manifest fails discovery;
icon parity with the shell; host-namespace parity with Core's reserved names.
Shell (module-navigation.test.tsx, 44 tests), with real artifacts mounted through the loader's Shadow DOM boundary:
routing: root, declared page, detail route, and unknown pages (including near-misses); v8 root-only; disabled modules at every depth;
history: Back/Forward/refresh with one mount and no remount; focus moving on a page change;
sidebar: v9 icon, v8 generic icon, pages only while open, collapsed state, the drawer, and an unknown icon falling back;
SDK: navigate (add or replace), 9 refused paths with no leak, real links with plain vs modified clicks, every subscriber notified on host-driven moves;
Settings: module → Settings → Back to the exact page, refresh, respelling, and 9 unsafe return targets falling back.
Also updated: route and return-target tests (the host namespace, empty segments, deep routes); history query tests; SDK error tests; the template v9 manifest and page rendering through the real built artifact.
Results
TURBO_FORCE=true pnpm verify: passed. contracts 286, core 1194, api 573, shell 470, module-ui-sdk 38, template 22, plus every package.
pnpm infrastructure:config and pnpm deploy:config: passed.
Deploy smoke, same-origin: passed.
Deploy smoke, split, with the v9 template, in real Chromium: passed, 397 checks. All five browser passes ran the new steps:
sidebar sub-navigation, and a page change without a remount;
a direct deep link, Back and Forward;
an undeclared page not found, and the detail route;
module → Settings via the capability, then Settings Back to the exact origin after a refresh;
an off-site return ignored, and the phone drawer.
Deploy smoke, split, with the real released Signage 0.1.5 (manifest v8) package: passed, 340 checks. It covers v8 behaviour (generic or own icon, no sub-navigation, not-found beneath the root), plus the display, pairing, kiosk and offline scenarios that guard Stage 0 safety.
Visual check (headless Chromium 151):
light and dark at 1280px, focus visible on a nested page;
the collapsed sidebar;
a 390px phone drawer (no overflow, 48px targets);
the Settings return flow.
This caught one bug: the nested pages weren't indented, fixed in ae360ef.
Deferred
Migrating Signage to v9.
When v8 stops being accepted (its own ADR).
Overview widgets.
A module reporting not-found for its own detail routes.
Return targets for platform pages beyond Settings.
This is Stage 1 PR 7, **the module navigation contract**. Modules get a declared Lucide icon and primary pages in the global sidebar, real nested routes, a host navigation capability, and module → Settings → Back to the exact origin. Released manifest v8 modules keep working unchanged. Decisions are recorded in [ADR 0063](docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md).
This PR is contract and foundation only. Signage is untouched and not migrated. It adds no Overview widgets, Discover features, settings forms, Displays work, or group editor.
## ⚠️ One precedent change for review: v8 stays accepted beside v9
Every previous manifest bump (v1→…→v8) rejected its predecessor outright: "no dual acceptance", per ADRs 0006, 0014, 0021 and 0050. You asked for v8 compatibility, so **v8 and v9 are both accepted**.
- `manifestVersion` selects one strict schema, via a discriminated union.
- A document is only ever read as the version it declares. A v8 document that names `navigation` is refused, never upgraded.
- No released manifest, package byte, or digest is rewritten.
- v7 and earlier are still refused.
- How long v8 stays accepted is left to a future ADR.
v9 is the right version number: v8's top-level object is strict, so a new field is a version bump by the repository's own rule.
## Manifest v9
v9 is exactly v8 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): one of `MODULE_NAVIGATION_ICONS`, a curated set of 93 generic Lucide names. Omitted, the module gets the generic module icon.
- **`pages`** (optional): at most 12, in declared order.
- `id`: kebab-case, stable, unique.
- `label`: trimmed, 1–60 characters, no control or line-separator characters.
- `path`: 1–4 lowercase kebab-case segments relative to the module root. It can't contain a leading or trailing slash, an empty segment, `.` or `..`, percent-encoding, a query, a fragment or a scheme, and can't be a duplicate, sit beneath another page, or be the root.
- `navigation` is only valid with `entrypoints.ui`. It is validated by discovery, `modules:validate`, the packager and package verification before activation.
- **No page icons.** The collapsed sidebar only ever shows the module item, so page icons would never be seen.
- **No settings declaration.** `configuration` already answers whether a module has settings (ADR 0061).
- **No widgets or groups.**
## Icons
- **Validated against the list.** Manifests are checked against the curated set, so typos are caught at pack and discovery time.
- **Imported by name.** The shell imports exactly those icons from `lucide-react` (`browser/module-icon.tsx`); the full library of about 6,300 icons is never bundled.
- **One list, held together by tests.** Surfaces can't import `@ekklesia/contracts` (ADR 0024), so the shell keeps the names in a framework-free subpath, `@ekklesia/shell/module-icons`.
- An `apps/api` test holds that copy equal to the contracts list.
- A shell test proves each entry is the real Lucide component.
- **Fallback.** A name a host can't draw falls back to the generic icon.
- **Decorative only.** Icons are always `aria-hidden`, with the name in the label or tooltip.
- There is no module → icon table anywhere.
## Routes
```text
/<id> landing view (never itself a declared page → no duplicate identities, no redirect)
/<id>/<page> declared page
/<id>/<page>/… anything beneath a page: module-owned detail routes, e.g. /media/sermons/123
anything else host "Page not found"; the module is not mounted
```
- Refresh, deep links, and Back/Forward all land on the same route. Moving between a module's own pages **never remounts** it, because the frame is keyed by module identity.
- **v8 modules:** root only. Anything deeper is not-found, exactly as before.
- **Disabled modules:** "unavailable" at the root and every subpath, and nothing mounts.
- **Uninstalled modules:** not served (unchanged).
- **New guard:** `api`, `assets` and `display` (`HOST_NAMESPACES`) are never pages at any depth. Without it, nested module routes would have let `/api/v1/principal` parse as a route of a module called `api`, and so be accepted as a return target. A test holds `DASHBOARD_PLATFORM_PAGES + HOST_NAMESPACES` equal to Core's `RESERVED_TOP_LEVEL_NAMES`.
## Sidebar
- **v9:** the declared icon. Pages are nested beneath the module while it's open, using the same in-place expansion and indentation as Settings.
- **Current item:** exactly one `aria-current="page"`. It's the open page, or the module itself on its landing view; a detail route marks its page.
- **Collapsed:** pages are hidden and the module item is current, named by its tooltip and accessible name.
- **Phone drawer:** the identical hierarchy with 48px targets, closing once a page is chosen.
- **v8:** the generic icon and a single item.
- **Disabled modules:** absent.
- The server sends `navigation: { icon, pages }` beside each module's name in `/api/v1/dashboard/navigation`, never inside the host-neutral descriptor.
## SDK: `context.navigation` (`@ekklesia/module-ui-sdk`)
```ts
interface ModuleUiNavigation {
location(): { path: string; page: string | undefined };
subscribe(listener): () => void;
navigate(path, { replace? }?): void;
link(path): { href; onClick(event) }; // real <a>; modified/middle clicks left to the browser
readonly settings: { open(); link() } | undefined;
}
```
- **Relative paths only.** Paths are module-relative, and a leading `/` means the same as none.
- **Refused paths throw** `ModuleUiNavigationError`, with a code and a message that never echoes the path:
- malformed or escaping paths give `MODULE_NAVIGATION_PATH_INVALID`;
- undeclared pages give `MODULE_NAVIGATION_PATH_UNKNOWN`.
- **Cross-module navigation is impossible.** No history object, `window` access or router is exposed.
- **Where it's present:** it's an optional member (`navigation?`), so an existing hand-built context compiles unchanged. The Dashboard supplies it, the display host omits it, and modules feature-detect it. v8 modules get navigation too, with only their root.
- **No ABI change.** The mount ABI stays at 2; this is an additive context member.
## Return-to-origin: one carrier
The return destination travels in a query parameter on the Settings URL:
```text
/settings/signage?return=%2Fsignage%2Ftimelines
```
- `navigation.settings` builds it from the module's current location, so module authors never build or validate it.
- **It survives a refresh, a new tab, and a canonical respelling.** `HostHistory` gains `search()` to support this.
- **It is validated twice:**
- `parseReturnTarget` accepts canonical same-origin Dashboard addresses only;
- a module address must also name a running module and one of its declared routes.
- Back reads "Back to Signage" and is a real link to the validated address. It never uses `history.back()`. An invalid target falls back to Settings.
## Public package changes (no version bumps, as the release process does those separately)
- **`@ekklesia/contracts`:** the v8/v9 manifest union; `module-navigation.ts` (`MODULE_NAVIGATION_ICONS`, page and navigation schemas, `moduleManifestNavigation`); `MODULE_MANIFEST_VERSION` is now 9; `SUPPORTED_MODULE_MANIFEST_VERSIONS` is `[8, 9]`.
- **`@ekklesia/module-ui-sdk`:** `navigation.ts` (types and `ModuleUiNavigationError`), plus an **optional** `ModuleUiHostContext.navigation?`. This is source-compatible: a context built before navigation existed still compiles unchanged, and a test proves it. Hosts without pages, including the display host, omit it, and modules feature-detect it.
- **`@ekklesia/module-sdk`:** re-exports the navigation helpers and types.
- **Unchanged:** `module-ui-host`, `module-pack` and `ui`.
- **Private:** Core and the API publish and serve navigation. The shell adds routes, the sidebar, the capability, and the `./module-icons` subpath. The template module moves to v9 with two pages, a detail route, and a Settings button.
## Compatibility correction (`c78f131`)
`navigation` was first a required member (`navigation: ModuleUiNavigation | undefined`), which would have broken any existing hand-built context at compile time. That isn't allowed for a 0.x patch-compatible change. It is now `navigation?: ModuleUiNavigation | undefined`:
- **Old contexts compile.** A context without the member still satisfies `ModuleUiHostContext`. The `navigation: undefined` lines this PR had added to existing hosts and tests are reverted, so those files are byte-identical to `main` again.
- **A test guards it.** A `module-ui-sdk` test type-checks an exact pre-navigation context with `satisfies`. Making the member required again fails 5 type checks, confirmed by mutation.
- **Runtime and security are unchanged.** The display host now omits the member rather than setting it to `undefined`.
- **After the change:** verify, package release and package publication all pass again.
## Tests
- **Contracts:**
- v9 single- and multi-page manifests, the icon and its fallback, invalid icons (unknown, PascalCase, markup, URL, object), and 18 unsafe path forms;
- malformed ids and labels, duplicate ids and paths, nesting, the bound, and navigation without a UI;
- deterministic order; exactly versions 8 and 9 accepted;
- **the released Signage 0.1.5 manifest, byte-identical to the one inside the published package (SHA-256 checked)**, accepted as v8 with no navigation; a v8 document carrying `navigation` refused.
- **Core:** discovery of the released v8 Signage module and a v9 module side by side, and refusal of an escaping page.
- **API:**
- the navigation view carries the v9 icon and pages, and `{icon:null, pages:[]}` for v8;
- an unsafe v9 manifest fails discovery;
- icon parity with the shell; host-namespace parity with Core's reserved names.
- **Shell (`module-navigation.test.tsx`, 44 tests), with real artifacts mounted through the loader's Shadow DOM boundary:**
- **routing:** root, declared page, detail route, and unknown pages (including near-misses); v8 root-only; disabled modules at every depth;
- **history:** Back/Forward/refresh with one mount and no remount; focus moving on a page change;
- **sidebar:** v9 icon, v8 generic icon, pages only while open, collapsed state, the drawer, and an unknown icon falling back;
- **SDK:** navigate (add or replace), 9 refused paths with no leak, real links with plain vs modified clicks, every subscriber notified on host-driven moves;
- **Settings:** module → Settings → Back to the exact page, refresh, respelling, and 9 unsafe return targets falling back.
- **Also updated:** route and return-target tests (the host namespace, empty segments, deep routes); history query tests; SDK error tests; the template v9 manifest and page rendering through the real built artifact.
### Results
- `TURBO_FORCE=true pnpm verify`: **passed**. contracts 286, core 1194, api 573, shell 470, module-ui-sdk 38, template 22, plus every package.
- `pnpm test:package-release`: **passed**.
- `pnpm test:package-publication`: **passed**.
- `pnpm test:migrations:integration`: **passed**, 428 tests.
- `pnpm infrastructure:config` and `pnpm deploy:config`: **passed**.
- Deploy smoke, same-origin: **passed**.
- Deploy smoke, split, with the v9 template, in real Chromium: **passed**, 397 checks. All five browser passes ran the new steps:
- sidebar sub-navigation, and a page change without a remount;
- a direct deep link, Back and Forward;
- an undeclared page not found, and the detail route;
- module → Settings via the capability, then Settings Back to the exact origin after a refresh;
- an off-site return ignored, and the phone drawer.
- Deploy smoke, split, with **the real released Signage 0.1.5 (manifest v8) package**: **passed**, 340 checks. It covers v8 behaviour (generic or own icon, no sub-navigation, not-found beneath the root), plus the display, pairing, kiosk and offline scenarios that guard Stage 0 safety.
**Visual check** (headless Chromium 151):
- light and dark at 1280px, focus visible on a nested page;
- the collapsed sidebar;
- a 390px phone drawer (no overflow, 48px targets);
- the Settings return flow.
This caught one bug: the nested pages weren't indented, fixed in `ae360ef`.
## Deferred
- Migrating Signage to v9.
- When v8 stops being accepted (its own ADR).
- Overview widgets.
- A module reporting not-found for its own detail routes.
- Return targets for platform pages beyond Settings.
- Navigation groups and their editor.
- Growing the icon set.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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]>
ModuleUiHostContext.navigation is now `navigation?: ModuleUiNavigation |
undefined`, so the public context change is source-compatible: a context built
before navigation existed (without the member) still compiles unchanged, as
0.x patch releases require. The Dashboard supplies navigation; hosts without
addressable pages, including the display host, omit the member; modules
feature-detect it. Existing hosts' tests are restored to their pre-PR shape,
and a module-ui-sdk test proves a pre-navigation context still satisfies
ModuleUiHostContext (and fails if the member is ever made required).
Runtime behaviour, manifest v8/v9 compatibility, and security rules are
unchanged. ADR 0063 and the module development guide say so.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
This is Stage 1 PR 7, the module navigation contract. Modules get a declared Lucide icon and primary pages in the global sidebar, real nested routes, a host navigation capability, and module → Settings → Back to the exact origin. Released manifest v8 modules keep working unchanged. Decisions are recorded in ADR 0063.
This PR is contract and foundation only. Signage is untouched and not migrated. It adds no Overview widgets, Discover features, settings forms, Displays work, or group editor.
⚠️ One precedent change for review: v8 stays accepted beside v9
Every previous manifest bump (v1→…→v8) rejected its predecessor outright: "no dual acceptance", per ADRs 0006, 0014, 0021 and 0050. You asked for v8 compatibility, so v8 and v9 are both accepted.
manifestVersionselects one strict schema, via a discriminated union.navigationis refused, never upgraded.v9 is the right version number: v8's top-level object is strict, so a new field is a version bump by the repository's own rule.
Manifest v9
v9 is exactly v8 plus one optional field:
icon(optional): one ofMODULE_NAVIGATION_ICONS, a curated set of 93 generic Lucide names. Omitted, the module gets the generic module icon.pages(optional): at most 12, in declared order.id: kebab-case, stable, unique.label: trimmed, 1–60 characters, no control or line-separator characters.path: 1–4 lowercase kebab-case segments relative to the module root. It can't contain a leading or trailing slash, an empty segment,.or.., percent-encoding, a query, a fragment or a scheme, and can't be a duplicate, sit beneath another page, or be the root.navigationis only valid withentrypoints.ui. It is validated by discovery,modules:validate, the packager and package verification before activation.configurationalready answers whether a module has settings (ADR 0061).Icons
lucide-react(browser/module-icon.tsx); the full library of about 6,300 icons is never bundled.@ekklesia/contracts(ADR 0024), so the shell keeps the names in a framework-free subpath,@ekklesia/shell/module-icons.apps/apitest holds that copy equal to the contracts list.aria-hidden, with the name in the label or tooltip.Routes
api,assetsanddisplay(HOST_NAMESPACES) are never pages at any depth. Without it, nested module routes would have let/api/v1/principalparse as a route of a module calledapi, and so be accepted as a return target. A test holdsDASHBOARD_PLATFORM_PAGES + HOST_NAMESPACESequal to Core'sRESERVED_TOP_LEVEL_NAMES.Sidebar
aria-current="page". It's the open page, or the module itself on its landing view; a detail route marks its page.navigation: { icon, pages }beside each module's name in/api/v1/dashboard/navigation, never inside the host-neutral descriptor.SDK:
context.navigation(@ekklesia/module-ui-sdk)/means the same as none.ModuleUiNavigationError, with a code and a message that never echoes the path:MODULE_NAVIGATION_PATH_INVALID;MODULE_NAVIGATION_PATH_UNKNOWN.windowaccess or router is exposed.navigation?), so an existing hand-built context compiles unchanged. The Dashboard supplies it, the display host omits it, and modules feature-detect it. v8 modules get navigation too, with only their root.Return-to-origin: one carrier
The return destination travels in a query parameter on the Settings URL:
navigation.settingsbuilds it from the module's current location, so module authors never build or validate it.HostHistorygainssearch()to support this.parseReturnTargetaccepts canonical same-origin Dashboard addresses only;history.back(). An invalid target falls back to Settings.Public package changes (no version bumps, as the release process does those separately)
@ekklesia/contracts: the v8/v9 manifest union;module-navigation.ts(MODULE_NAVIGATION_ICONS, page and navigation schemas,moduleManifestNavigation);MODULE_MANIFEST_VERSIONis now 9;SUPPORTED_MODULE_MANIFEST_VERSIONSis[8, 9].@ekklesia/module-ui-sdk:navigation.ts(types andModuleUiNavigationError), plus an optionalModuleUiHostContext.navigation?. This is source-compatible: a context built before navigation existed still compiles unchanged, and a test proves it. Hosts without pages, including the display host, omit it, and modules feature-detect it.@ekklesia/module-sdk: re-exports the navigation helpers and types.module-ui-host,module-packandui../module-iconssubpath. The template module moves to v9 with two pages, a detail route, and a Settings button.Compatibility correction (
c78f131)navigationwas first a required member (navigation: ModuleUiNavigation | undefined), which would have broken any existing hand-built context at compile time. That isn't allowed for a 0.x patch-compatible change. It is nownavigation?: ModuleUiNavigation | undefined:ModuleUiHostContext. Thenavigation: undefinedlines this PR had added to existing hosts and tests are reverted, so those files are byte-identical tomainagain.module-ui-sdktest type-checks an exact pre-navigation context withsatisfies. Making the member required again fails 5 type checks, confirmed by mutation.undefined.Tests
navigationrefused.{icon:null, pages:[]}for v8;module-navigation.test.tsx, 44 tests), with real artifacts mounted through the loader's Shadow DOM boundary:Results
TURBO_FORCE=true pnpm verify: passed. contracts 286, core 1194, api 573, shell 470, module-ui-sdk 38, template 22, plus every package.pnpm test:package-release: passed.pnpm test:package-publication: passed.pnpm test:migrations:integration: passed, 428 tests.pnpm infrastructure:configandpnpm deploy:config: passed.Visual check (headless Chromium 151):
This caught one bug: the nested pages weren't indented, fixed in
ae360ef.Deferred
🤖 Generated with Claude Code