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,113 @@
|
||||
/**
|
||||
* Where a mounted module UI is in the Dashboard, and how it moves (ADR 0063).
|
||||
*
|
||||
* A **host capability**, not a router. The host owns the address bar, the sidebar, and every
|
||||
* platform route; a module owns the pages beneath its own root. So a module never touches
|
||||
* `window.location` or `window.history`, never spells a platform route, and never builds another
|
||||
* module's address: it names paths **relative to its own root**, and the host maps them to real,
|
||||
* refreshable, bookmarkable Dashboard addresses — or refuses them.
|
||||
*
|
||||
* Paths:
|
||||
*
|
||||
* - `""` is the module's root, its landing view.
|
||||
* - `"timelines"` is a declared page; `"media/sermons/123"` is a route **beneath** the declared page
|
||||
* `media`, which a module may use for detail views without listing every record in its manifest.
|
||||
* - A leading `/` means the same thing as none: `"/timelines"` is still this module's `timelines`.
|
||||
* There is no way to name anything outside this module.
|
||||
* - Segments are unreserved URL characters only (letters, digits, `-`, `.`, `_`, `~`), never `.` or
|
||||
* `..`, never empty; no query, fragment, scheme, or percent-encoding.
|
||||
*
|
||||
* A path the host would not open — malformed, or outside the module's root and its declared pages —
|
||||
* is refused with a {@link ModuleUiNavigationError} rather than silently opening something else.
|
||||
*/
|
||||
export interface ModuleUiNavigation {
|
||||
/** Where this module is now. */
|
||||
location(): ModuleUiLocation;
|
||||
/** Notifies after the location changes — a link, Back or Forward, or a refresh of the sidebar.
|
||||
* Returns an unsubscribe. The module stays mounted while it moves between its own pages. */
|
||||
subscribe(listener: () => void): () => void;
|
||||
/**
|
||||
* Opens one of this module's own routes. `replace` corrects the current history entry instead of
|
||||
* adding one — for a route the module just respelled, not for ordinary navigation.
|
||||
*
|
||||
* @throws {ModuleUiNavigationError} for a path this module may not open.
|
||||
*/
|
||||
navigate(path: string, options?: ModuleUiNavigateOptions): void;
|
||||
/**
|
||||
* A link to one of this module's own routes, for a real `<a>`: `href` is the host's address for it,
|
||||
* so the link can be opened in a new tab, copied, or bookmarked, and `onClick` opens it in place on
|
||||
* an ordinary click while leaving a modified or middle click to the browser.
|
||||
*
|
||||
* @throws {ModuleUiNavigationError} for a path this module may not open.
|
||||
*/
|
||||
link(path: string): ModuleUiLink;
|
||||
/**
|
||||
* This module's page in the Dashboard's centralized Settings, or `undefined` when there is none for
|
||||
* this viewer — the module declares no configuration, or the viewer may not change it. A
|
||||
* presentation hint like {@link ModuleUiPermissions}: Settings enforces its own authority.
|
||||
*
|
||||
* Opening it carries the module's current location as the place to come back to, so Settings'
|
||||
* Back returns exactly here. The host validates that destination; the module neither builds nor
|
||||
* checks it.
|
||||
*/
|
||||
readonly settings: ModuleUiSettingsLocation | undefined;
|
||||
}
|
||||
|
||||
export interface ModuleUiLocation {
|
||||
/** This module's route, relative to its root: `""`, `"timelines"`, `"media/sermons/123"`. Never a
|
||||
* leading or trailing `/`. */
|
||||
readonly path: string;
|
||||
/** The declared page that route is, or lies beneath; `undefined` at the module's root. */
|
||||
readonly page: string | undefined;
|
||||
}
|
||||
|
||||
export interface ModuleUiNavigateOptions {
|
||||
readonly replace?: boolean;
|
||||
}
|
||||
|
||||
/** What a link's click handler reads: satisfied by a DOM `MouseEvent` and by React's synthetic one. */
|
||||
export interface ModuleUiLinkClickEvent {
|
||||
readonly button: number;
|
||||
readonly metaKey: boolean;
|
||||
readonly ctrlKey: boolean;
|
||||
readonly shiftKey: boolean;
|
||||
readonly altKey: boolean;
|
||||
readonly defaultPrevented: boolean;
|
||||
preventDefault(): void;
|
||||
}
|
||||
|
||||
export interface ModuleUiLink {
|
||||
readonly href: string;
|
||||
onClick(event: ModuleUiLinkClickEvent): void;
|
||||
}
|
||||
|
||||
export interface ModuleUiSettingsLocation {
|
||||
/** Opens this module's Settings page, remembering where the module was. */
|
||||
open(): void;
|
||||
/** The same, as a link: its `href` includes the place to return to, so it also works in a new tab. */
|
||||
link(): ModuleUiLink;
|
||||
}
|
||||
|
||||
export type ModuleUiNavigationErrorCode =
|
||||
/** Not a path this contract can express: absolute, traversing, malformed, or out of bounds. */
|
||||
| "MODULE_NAVIGATION_PATH_INVALID"
|
||||
/** A well-formed path that is neither the module's root nor a declared page or beneath one. */
|
||||
| "MODULE_NAVIGATION_PATH_UNKNOWN";
|
||||
|
||||
const navigationErrorMessages: Readonly<Record<ModuleUiNavigationErrorCode, string>> =
|
||||
Object.freeze({
|
||||
MODULE_NAVIGATION_PATH_INVALID: "A module asked to open a path outside its own routes.",
|
||||
MODULE_NAVIGATION_PATH_UNKNOWN: "A module asked to open a page it does not declare.",
|
||||
});
|
||||
|
||||
/** Carries a stable code and a fixed message — never the path the module asked for. */
|
||||
export class ModuleUiNavigationError extends Error {
|
||||
readonly code: ModuleUiNavigationErrorCode;
|
||||
|
||||
constructor(code: ModuleUiNavigationErrorCode) {
|
||||
super(navigationErrorMessages[code]);
|
||||
this.name = "ModuleUiNavigationError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user