/** * 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 ``: `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> = 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}`; } }