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:
ekklesia
2026-10-15 08:21:50 -05:00
co-authored by Claude Opus 5.5
parent 7447381c8f
commit 4671622b9d
69 changed files with 4221 additions and 372 deletions
+179
View File
@@ -395,6 +395,185 @@ check(
"widening the window brings the sidebar back on the same page",
);
// --- Module navigation (ADR 0063): pages in the sidebar, real routes, module → Settings → back ---
if (openedModuleId) {
const moduleId = openedModuleId;
const declared =
await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/dashboard/navigation`)}, {
credentials: "include",
}).then((response) => response.json()).then((navigation) => {
const entries = [...navigation.ungrouped, ...navigation.groups.flatMap((group) => group.modules)];
return entries.find((entry) => entry.module.id === ${JSON.stringify(moduleId)})?.navigation ?? null;
})`);
const inModule = (inner) =>
`document.querySelector("[data-ekklesia-module-ui]")?.shadowRoot?.querySelector(${JSON.stringify(inner)})`;
const moduleMounted = `${selector("module-container")}?.childElementCount > 0 && !${selector("module-error")}`;
const isCurrent = (testId) => `${selector(testId)}?.getAttribute("aria-current") === "page"`;
if (declared === null) {
check(false, "the navigation view describes the opened module");
} else if (declared.pages.length === 0) {
// A manifest v8 module (or a v9 one with no pages): exactly as before.
await navigate(`${dashboardOrigin}/${moduleId}`);
check(
await waitFor(
`${moduleMounted} && ${isCurrent(`select-${moduleId}`)} && !${selector(`module-subnavigation-${moduleId}`)} && ${selector(`select-${moduleId}`)}?.querySelector("svg")?.dataset.icon === ${JSON.stringify(declared.icon === null ? "module" : `module:${declared.icon}`)}`,
"the single-page module with its icon and no sub-navigation",
),
`${moduleId} declares no pages: one sidebar item with its icon, mounted at its root`,
);
await navigate(`${dashboardOrigin}/${moduleId}/definitely-not-a-page`);
check(
await waitFor(
`${selector("host-not-found")} && !(${selector("module-container")}?.childElementCount > 0)`,
"not-found beneath a single-page module",
),
`an address beneath ${moduleId}'s root is not found, and mounts nothing`,
);
} else {
const [first, second] = declared.pages;
const pagePath = (page) => `/${moduleId}/${page.path}`;
await navigate(`${dashboardOrigin}/${moduleId}`);
check(
await waitFor(
`${moduleMounted} && ${isCurrent(`select-${moduleId}`)} && ${selector(`module-subnavigation-${moduleId}`)}?.querySelectorAll("a").length === ${declared.pages.length} && ${selector(`select-${moduleId}`)}?.querySelector("svg")?.dataset.icon === ${JSON.stringify(declared.icon === null ? "module" : `module:${declared.icon}`)}`,
"the module's pages beneath it in the sidebar",
),
`${moduleId}'s declared icon and ${declared.pages.length} pages appear beneath it while it is open`,
);
await evaluate(
`window.__ekklesiaModuleRoot = document.querySelector("[data-ekklesia-module-ui]")`,
);
await evaluate(`${selector(`select-${moduleId}-${first.id}`)}.click()`);
check(
await waitFor(
`location.pathname === ${JSON.stringify(pagePath(first))} && ${isCurrent(`select-${moduleId}-${first.id}`)} && document.querySelector("[data-ekklesia-module-ui]") === window.__ekklesiaModuleRoot`,
"the first page, without remounting the module",
),
`choosing ${first.label} opens ${pagePath(first)} in place, marks it, and keeps the module mounted`,
);
if (second) {
await navigate(`${dashboardOrigin}${pagePath(second)}`);
check(
await waitFor(
`location.pathname === ${JSON.stringify(pagePath(second))} && ${moduleMounted} && ${isCurrent(`select-${moduleId}-${second.id}`)}`,
"a declared page from its deep link",
),
`a deep link to ${pagePath(second)} opens that page (what a refresh or bookmark does)`,
);
await evaluate("history.back()");
check(
await waitFor(
`location.pathname === ${JSON.stringify(pagePath(first))} && ${isCurrent(`select-${moduleId}-${first.id}`)} && ${moduleMounted}`,
"Back to the previous page",
),
"Back returns to the previously open page",
);
await evaluate("history.forward()");
check(
await waitFor(
`location.pathname === ${JSON.stringify(pagePath(second))} && ${isCurrent(`select-${moduleId}-${second.id}`)}`,
"Forward to the next page",
),
"Forward returns to it again",
);
}
await navigate(`${dashboardOrigin}/${moduleId}/definitely-not-a-page`);
check(
await waitFor(
`${selector("host-not-found")} && !(${selector("module-container")}?.childElementCount > 0)`,
"not-found for an undeclared page",
),
`an undeclared page under ${moduleId} is not found, and mounts nothing`,
);
if (moduleId === "example") {
// The template's own pages: a detail route beneath Records, and its Settings link.
await navigate(`${dashboardOrigin}/example/records`);
await waitFor(
inModule('[data-testid^="example-record-link-"]'),
"a record link inside the module",
);
await evaluate(`${inModule('[data-testid^="example-record-link-"]')}.click()`);
check(
await waitFor(
`location.pathname.startsWith("/example/records/") && ${isCurrent("select-example-records")} && ${inModule('[data-testid="example-record-value"]')}`,
"the record's detail route",
),
"a detail route beneath a declared page opens inside the module, its page marked",
);
await navigate(`${dashboardOrigin}/example`);
await waitFor(
inModule('[data-testid="example-open-settings"]'),
"the module's Settings button",
);
await evaluate(`${inModule('[data-testid="example-open-settings"]')}.click()`);
check(
await waitFor(
`location.pathname === "/settings/example" && location.search === "?return=%2Fexample" && ${selector("module-settings")}`,
"the module's Settings page, carrying where it came from",
),
"the module opens its centralized Settings page through its navigation capability",
);
}
// Settings → Back to exactly the module page that sent the person there, across a refresh.
await navigate(
`${dashboardOrigin}/settings/${moduleId}?return=${encodeURIComponent(pagePath(first))}`,
);
check(
await waitFor(
`${selector("module-settings")}?.querySelector(".ek-page-header__back")?.getAttribute("href") === ${JSON.stringify(pagePath(first))}`,
"Back to the originating module page",
),
"after a refresh, Settings' Back still names the exact module page it was opened from",
);
await evaluate(`${selector("module-settings")}.querySelector(".ek-page-header__back").click()`);
check(
await waitFor(
`location.pathname === ${JSON.stringify(pagePath(first))} && ${moduleMounted} && ${isCurrent(`select-${moduleId}-${first.id}`)}`,
"the originating module page",
),
`Back from Settings returns to exactly ${pagePath(first)}`,
);
await navigate(
`${dashboardOrigin}/settings/${moduleId}?return=${encodeURIComponent("https://evil.example.test/")}`,
);
check(
await waitFor(
`${selector("module-settings")}?.querySelector(".ek-page-header__back")?.getAttribute("href") === "/settings"`,
"an unsafe return target ignored",
),
"an off-site return target is ignored: Back goes to Settings",
);
// The phone drawer: the identical hierarchy, closing once a page is chosen.
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 2,
height: 844,
mobile: true,
width: 390,
});
await navigate(`${dashboardOrigin}/${moduleId}`);
await waitFor(selector("mobile-navigation-trigger"), "the phone navigation trigger");
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
await waitFor(
`${selector("navigation-drawer")}?.querySelector('[data-testid="module-subnavigation-${moduleId}"]')`,
"the module's pages in the drawer",
);
await evaluate(`${selector(`select-${moduleId}-${first.id}`)}.click()`);
check(
await waitFor(
`location.pathname === ${JSON.stringify(pagePath(first))} && !${selector("navigation-drawer")} && ${moduleMounted}`,
"a page chosen from the drawer",
),
"the phone drawer shows the module's pages, and closes once one is chosen",
);
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 1,
height: 1080,
mobile: false,
width: 1920,
});
}
}
// --- Settings (ADR 0061): a deep link, a refresh, Discover, a module's settings, the drawer -------
await navigate(`${dashboardOrigin}/settings`);
check(