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,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