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
@@ -463,7 +463,7 @@ without executing module code:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": 8,
|
||||
"manifestVersion": 9,
|
||||
"id": "calendar",
|
||||
"name": "Calendar",
|
||||
"version": "0.1.0",
|
||||
@@ -522,6 +522,13 @@ without executing module code:
|
||||
"description": "Renders an upcoming-events slide for a signage timeline."
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
"icon": "calendar",
|
||||
"pages": [
|
||||
{ "id": "events", "label": "Events", "path": "events" },
|
||||
{ "id": "rooms", "label": "Rooms", "path": "rooms" }
|
||||
]
|
||||
},
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
@@ -548,18 +555,24 @@ visible to whoever holds `core.modules.configuration.manage`
|
||||
([ADR 0061](../architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)).
|
||||
Module lifecycle — installing, turning on and off, updating — is the platform's Settings → Discover, not
|
||||
yours to build. Do not build a second settings system inside your module; link to your Settings page
|
||||
instead once the module navigation contract can carry a return link. A provided capability
|
||||
instead, through `context.navigation.settings` (see [Module navigation](#module-navigation)), which
|
||||
brings the person back to exactly the page they left. A provided capability
|
||||
has its own exact semantic version because its public contract can evolve independently from the
|
||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||
may be absent; if it is installed, its capability version must be compatible.
|
||||
|
||||
`manifestVersion: 8` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 7 manifest, and any other version, fails validation outright rather than being silently
|
||||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||
Already-built version 7 packages must be rebuilt. Version 7 added the optional `contributions` array
|
||||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
|
||||
contributes nothing changes only the version literal; every browser artifact remains independently
|
||||
optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||
`manifestVersion` (`packages/contracts/src/module-manifest.ts`) is required and selects exactly one
|
||||
strict schema: **9**, the current version and what a new module declares, or **8**, which stays
|
||||
accepted so released modules keep working unchanged
|
||||
([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). Version 9 is
|
||||
version 8 plus the optional `navigation` field; a version 8 manifest that names `navigation` is
|
||||
refused, never read as version 9. To adopt navigation, change `8` to `9` and add the field — nothing
|
||||
else changes. Version 7 and every other version fail validation outright; there is no compatibility
|
||||
adapter or automatic migration, and already-built version 7 packages must be rebuilt. Version 7
|
||||
added the optional `contributions` array
|
||||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)), and every browser
|
||||
artifact remains independently optional
|
||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
||||
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
||||
@@ -591,9 +604,9 @@ Lucide is Ekklesia's icon vocabulary
|
||||
UI needs icons, depend on `lucide-react` and import each icon by name, rather than drawing your own
|
||||
SVGs (module `.svg` presentation assets are rejected by `pnpm boundaries` anyway). Treat icons as
|
||||
decorative: give every control a text name, visible or visually hidden, and keep the icon
|
||||
`aria-hidden`. The Dashboard's navigation currently shows every module with one generic icon; a
|
||||
module will declare its own navigation icon through the module navigation contract (manifest v9), not
|
||||
yet.
|
||||
`aria-hidden`. The icon the **Dashboard's sidebar** draws for your module is different: you name it
|
||||
in your manifest (`navigation.icon`), and the Dashboard draws it — see
|
||||
[Module navigation](#module-navigation).
|
||||
|
||||
### Your module's display name
|
||||
|
||||
@@ -606,8 +619,9 @@ your package name or module id.
|
||||
Production" — are presentation configuration an administrator owns, stored by the platform and
|
||||
changed at any time without touching your module
|
||||
([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)). There is no
|
||||
manifest field for a folder, a group, an order, an icon, or a category, and adding one would make a
|
||||
rendering preference into module structure.
|
||||
manifest field for a folder, a group, an order, or a category, and adding one would make a rendering
|
||||
preference into module structure. What you *do* declare is what your module is: its icon and its
|
||||
primary pages ([Module navigation](#module-navigation)).
|
||||
|
||||
That separation also means grouping never affects you. Two modules sharing a folder have no
|
||||
relationship: no dependency, no ordering, no shared anything. If your module genuinely needs another
|
||||
@@ -624,9 +638,9 @@ symlink resolution. Modules with no tables omit `entrypoints.schema`; modules wi
|
||||
history omit `entrypoints.migrations`; modules may omit `ui`, `display`, or both.
|
||||
|
||||
Unknown manifest properties are still rejected. A `dashboard`, `surface`, `client`, `web`, or
|
||||
`renderers` key fails validation — and the manifest
|
||||
deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard
|
||||
organization is presentation configuration owned by the Dashboard, never module architecture.
|
||||
`renderers` key fails validation — and the manifest deliberately has no group, folder, parent,
|
||||
menu, or ordering field. Dashboard organization is presentation configuration owned by the
|
||||
Dashboard, never module architecture. `navigation` declares only your module's own icon and pages.
|
||||
|
||||
Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets.
|
||||
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
|
||||
@@ -706,7 +720,7 @@ artifact is the role selection.
|
||||
|
||||
### What the host gives you
|
||||
|
||||
`mount` receives exactly six members, and nothing else is available:
|
||||
`mount` receives exactly eight members, and nothing else is available:
|
||||
|
||||
| Member | What it is |
|
||||
| --- | --- |
|
||||
@@ -716,10 +730,12 @@ artifact is the role selection.
|
||||
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
|
||||
| `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) |
|
||||
| `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) |
|
||||
| `display` | Your display page's URL, or `undefined` when you publish none |
|
||||
| `navigation` | Where your module is, and how it moves between its own pages and to its Settings page — see [Module navigation](#module-navigation). `undefined` on the display host |
|
||||
|
||||
Navigation, module configuration, organization context, and diagnostics reporting are **not** in the
|
||||
contract. They need designs that do not exist, and a speculative field frozen into a public contract
|
||||
is harder to remove than to add.
|
||||
Module configuration, organization context, and diagnostics reporting are **not** in the contract.
|
||||
They need designs that do not exist, and a speculative field frozen into a public contract is harder
|
||||
to remove than to add.
|
||||
|
||||
The host **theme** is deliberately not a context member either, and that is not a gap: design tokens
|
||||
reach your artifact as inherited CSS custom properties rather than as data (ADR 0039). You import
|
||||
@@ -727,6 +743,90 @@ reach your artifact as inherited CSS custom properties rather than as data (ADR
|
||||
root. Nothing is negotiated at run time, so there is no token map to keep in sync and no host that
|
||||
can forget to pass one.
|
||||
|
||||
### Module navigation
|
||||
|
||||
Your module is a first-class part of the Dashboard
|
||||
([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). Declare its
|
||||
icon and primary pages in the manifest (version 9):
|
||||
|
||||
```json
|
||||
"navigation": {
|
||||
"icon": "monitor-play",
|
||||
"pages": [
|
||||
{ "id": "playback", "label": "Playback", "path": "playback" },
|
||||
{ "id": "timelines", "label": "Timelines", "path": "timelines" },
|
||||
{ "id": "media", "label": "Media", "path": "media" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**The icon.**
|
||||
|
||||
- `icon` is a Lucide name from `MODULE_NAVIGATION_ICONS` (`@ekklesia/contracts`, re-exported by
|
||||
`@ekklesia/module-sdk`), a curated set of generic subjects. A name outside it fails validation, so
|
||||
you'll know at packaging time.
|
||||
- Omit it and the Dashboard draws its generic module icon.
|
||||
- Never ship an SVG or a URL for it.
|
||||
|
||||
**The pages.**
|
||||
|
||||
- `pages` are your primary pages, at most 12, in the order the sidebar shows them beneath your
|
||||
module, while your module is open.
|
||||
- `id` is stable and never shown.
|
||||
- `label` is what a person reads (1–60 characters).
|
||||
- `path` is relative to your module's root: one to four lowercase kebab-case segments, such as
|
||||
`timelines` or `media/library`.
|
||||
- Paths must be unique, and one page may not sit beneath another's path.
|
||||
- Don't add a tab bar of your own for these. The sidebar is your primary navigation (DESIGN.md
|
||||
§4.4). Tabs inside one resource are still fine.
|
||||
|
||||
**Your routes.**
|
||||
|
||||
| Address | What it is |
|
||||
| --- | --- |
|
||||
| `/<module-id>` | Your landing view. Never a declared page, so no page has two addresses. To open on, say, Playback, render it at your root and don't declare it. |
|
||||
| `/<module-id>/<page-path>` | A declared page. |
|
||||
| `/<module-id>/<page-path>/…` | Anything beneath a declared page: your detail views (`media/sermons/123`), with no manifest entry. |
|
||||
| anything else under your id | The Dashboard's *Page not found*. Your UI isn't mounted for it. |
|
||||
|
||||
A refresh, a bookmark, and Back and Forward all land on the same route.
|
||||
|
||||
**Moving around: `context.navigation`.** Never touch `window.location` or `window.history`, and
|
||||
never build a Dashboard URL yourself:
|
||||
|
||||
```tsx
|
||||
const { navigation } = context; // undefined on the display host
|
||||
navigation?.location(); // { path: "media/sermons/123", page: "media" }
|
||||
navigation?.subscribe(() => rerender()); // every move: yours, the sidebar's, Back and Forward
|
||||
navigation?.navigate("timelines"); // or { replace: true } to respell the current entry
|
||||
const link = navigation?.link("media/sermons/123");
|
||||
<a href={link.href} onClick={link.onClick}>Sermon</a> // a real link; new tabs still work
|
||||
```
|
||||
|
||||
- **Paths are relative to your root**, and a leading `/` means the same thing.
|
||||
- **Refused paths throw.** A path that names anything outside your root, or a page you don't
|
||||
declare, throws `ModuleUiNavigationError` (`MODULE_NAVIGATION_PATH_INVALID` or
|
||||
`MODULE_NAVIGATION_PATH_UNKNOWN`). There is no way to navigate into another module.
|
||||
- **You stay mounted.** Your UI is not remounted as it moves between its own pages. Subscribe and
|
||||
re-render from `location()`, which returns the same object until something moves.
|
||||
|
||||
**Your Settings page.** If your module declares `configuration`, its settings live at
|
||||
`/settings/<module-id>` (ADR 0061). `navigation.settings` opens that page, or `undefined` means this
|
||||
viewer can't:
|
||||
|
||||
```tsx
|
||||
navigation?.settings?.open(); // or navigation.settings.link() for a real <a>
|
||||
```
|
||||
|
||||
The host carries where your module is as the place to come back to
|
||||
(`/settings/<module-id>?return=%2F<module-id>%2Ftimelines`). Settings' Back then returns to exactly
|
||||
that page, after refresh too. You never build or validate the return address: the host does,
|
||||
accepting only real pages of running modules on this Dashboard.
|
||||
|
||||
**Version 8 modules.** A module still on version 8 keeps working unchanged: the generic icon, its root
|
||||
as its only page, and settings detected from `configuration` as before. Rebuilt against the new SDK,
|
||||
it also receives `navigation`, with only its root, and can link to its Settings page.
|
||||
|
||||
### Never build your own API URL
|
||||
|
||||
Ask the host for a path relative to your own module's API root:
|
||||
|
||||
Reference in New Issue
Block a user