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
@@ -78,7 +78,13 @@ const navigationPayload = {
|
||||
{
|
||||
id: "media",
|
||||
label: "Media",
|
||||
modules: [{ module: inventoryPayload.modules[0], name: "Example Module" }],
|
||||
modules: [
|
||||
{
|
||||
module: inventoryPayload.modules[0],
|
||||
name: "Example Module",
|
||||
navigation: { icon: null, pages: [] },
|
||||
},
|
||||
],
|
||||
order: 10,
|
||||
},
|
||||
],
|
||||
@@ -95,6 +101,7 @@ const navigationPayload = {
|
||||
version: "2.0.0",
|
||||
},
|
||||
name: "Calendar",
|
||||
navigation: { icon: null, pages: [] },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -111,7 +118,10 @@ const exampleDescriptor: HostModuleUiDescriptor = {
|
||||
};
|
||||
|
||||
function navigationWithModule(module: unknown): unknown {
|
||||
return { groups: [], ungrouped: [{ module, name: "Example Module" }] };
|
||||
return {
|
||||
groups: [],
|
||||
ungrouped: [{ module, name: "Example Module", navigation: { icon: null, pages: [] } }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("host protocol — API origin (ADR 0045)", () => {
|
||||
@@ -257,9 +267,11 @@ describe("host protocol — Dashboard navigation", () => {
|
||||
|
||||
expect(navigation.groups.map((group) => [group.id, group.label])).toEqual([["media", "Media"]]);
|
||||
expect(navigation.groups[0]?.modules).toEqual([
|
||||
{ descriptor: exampleDescriptor, name: "Example Module" },
|
||||
{ descriptor: exampleDescriptor, icon: undefined, name: "Example Module", pages: [] },
|
||||
]);
|
||||
expect(navigation.ungrouped).toEqual([
|
||||
{ descriptor: calendarDescriptor, icon: undefined, name: "Calendar", pages: [] },
|
||||
]);
|
||||
expect(navigation.ungrouped).toEqual([{ descriptor: calendarDescriptor, name: "Calendar" }]);
|
||||
});
|
||||
|
||||
it("rejects a malformed navigation document rather than rendering half of it", () => {
|
||||
@@ -281,6 +293,7 @@ describe("host protocol — Dashboard navigation", () => {
|
||||
ui: { url: "https://evil.example.test/x.js" },
|
||||
},
|
||||
name: "Example",
|
||||
navigation: { icon: null, pages: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -556,7 +569,7 @@ describe("module UI host context", () => {
|
||||
expect(context.display?.url).toBe("https://api.example.test/display/example");
|
||||
});
|
||||
|
||||
it("exposes exactly the seven contract members and the browser principal", () => {
|
||||
it("exposes exactly the eight contract members and the browser principal", () => {
|
||||
const { implementation } = recordingFetch();
|
||||
|
||||
const context = contextFor(implementation);
|
||||
@@ -566,11 +579,14 @@ describe("module UI host context", () => {
|
||||
"contributions",
|
||||
"display",
|
||||
"moduleId",
|
||||
"navigation",
|
||||
"permissions",
|
||||
"principal",
|
||||
"realtime",
|
||||
]);
|
||||
expect(context.moduleId).toBe("example");
|
||||
// Navigation comes only from a host with addressable pages (ADR 0063); this one supplies none.
|
||||
expect(context.navigation).toBeUndefined();
|
||||
expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user