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
@@ -1,4 +1,5 @@
|
||||
import type { ModuleUiContributions } from "./contribution.js";
|
||||
import type { ModuleUiNavigation } from "./navigation.js";
|
||||
import type { ModuleUiRealtimeClient } from "./realtime.js";
|
||||
|
||||
/**
|
||||
@@ -55,9 +56,10 @@ export type ModuleUiPrincipal =
|
||||
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
|
||||
*
|
||||
* This is deliberately the smallest set that proves the architecture rather than everything a real
|
||||
* screen will eventually want. Navigation, module configuration, organization/platform context, and
|
||||
* diagnostics reporting are **not** here: each needs a design that does not exist, and a speculative
|
||||
* field frozen into a public contract is harder to remove than to add. See ADR 0022.
|
||||
* screen will eventually want. Module configuration, organization/platform context, and diagnostics
|
||||
* reporting are **not** here: each needs a design that does not exist, and a speculative field
|
||||
* frozen into a public contract is harder to remove than to add. See ADR 0022. Navigation arrived
|
||||
* once its design did (ADR 0063).
|
||||
*
|
||||
* The host **theme** is deliberately absent for a different reason. Design tokens reach a module as
|
||||
* inherited CSS custom properties, not as data (ADR 0039): the host installs the values at its own
|
||||
@@ -102,6 +104,15 @@ export interface ModuleUiHostContext {
|
||||
* resolves `api.request`.
|
||||
*/
|
||||
readonly contributions: ModuleUiContributions;
|
||||
/**
|
||||
* Where this module is and how it moves between its own pages, and to its Settings page
|
||||
* (ADR 0063). See {@link ModuleUiNavigation}.
|
||||
*
|
||||
* `undefined` where the host has no addressable pages for it — the full-viewport display host,
|
||||
* which shows one screen, and any standalone host without routes. A module that offers
|
||||
* navigation checks for it, exactly as it checks `display`.
|
||||
*/
|
||||
readonly navigation: ModuleUiNavigation | undefined;
|
||||
}
|
||||
|
||||
export interface ModuleUiDisplayLocation {
|
||||
|
||||
@@ -3,4 +3,5 @@ export * from "./define-module-ui.js";
|
||||
export * from "./host-context.js";
|
||||
export * from "./json.js";
|
||||
export * from "./module-ui.js";
|
||||
export * from "./navigation.js";
|
||||
export * from "./realtime.js";
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId,
|
||||
navigation: undefined,
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => canPublish },
|
||||
@@ -214,6 +215,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: { url: "https://announcements.example.org/display" },
|
||||
moduleId,
|
||||
navigation: undefined,
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => canPublish },
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type ModuleUiHostContext,
|
||||
type ModuleUiNavigation,
|
||||
ModuleUiNavigationError,
|
||||
} from "../src/index.js";
|
||||
|
||||
/** The navigation capability's public surface (ADR 0063). Its behaviour is the host's: the
|
||||
* Dashboard's implementation is proven in `surfaces/shell/tests/module-navigation.test.tsx`. */
|
||||
describe("module UI navigation contract", () => {
|
||||
it("reports a refused path with a stable code and a fixed message that never echoes the path", () => {
|
||||
for (const code of [
|
||||
"MODULE_NAVIGATION_PATH_INVALID",
|
||||
"MODULE_NAVIGATION_PATH_UNKNOWN",
|
||||
] as const) {
|
||||
const error = new ModuleUiNavigationError(code);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.name).toBe("ModuleUiNavigationError");
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.stack).toBe(`ModuleUiNavigationError: ${error.message}`);
|
||||
expect(error.message).not.toMatch(/[/:?#]/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("makes navigation a context member a host without pages leaves undefined", () => {
|
||||
// A type-level contract: a context may say it has no navigation, as the display host does, and
|
||||
// a module reads it through an optional check.
|
||||
const describe = (context: Pick<ModuleUiHostContext, "navigation">): string =>
|
||||
context.navigation?.location().path ?? "no navigation";
|
||||
const navigation: ModuleUiNavigation = {
|
||||
link: () => ({ href: "/m/x", onClick: () => undefined }),
|
||||
location: () => ({ page: "x", path: "x" }),
|
||||
navigate: () => undefined,
|
||||
settings: undefined,
|
||||
subscribe: () => () => undefined,
|
||||
};
|
||||
expect(describe({ navigation: undefined })).toBe("no navigation");
|
||||
expect(describe({ navigation })).toBe("x");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user