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
+7 -3
View File
@@ -69,9 +69,13 @@ pnpm modules:pack --module templates/module --target any --out <directory> \
--source-revision "$(git rev-parse HEAD)"
```
The module owns its feature screens and display renderer. Do not add platform navigation, the
containing shell, global theme, authentication screens, or top-level routing; those belong to
surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
The module owns its feature screens and display renderer. Its manifest (version 9) declares its
sidebar icon and two primary pages, Records and About. Its UI renders whichever page
`context.navigation.location()` names, links with `navigation.link()`, shows a record's detail at
`records/<id>` beneath the Records page, and opens its Settings page with `navigation.settings`
([ADR 0063](../../docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). Do
not add a tab bar for those pages, the containing shell, global theme, authentication screens, or
top-level routing, and never touch `window.history`; those belong to surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
hardcodes Ekklesia routes. The display permission is enforced by the host before it serves the page
or descriptor; `context.permissions` remains a presentation-only hint.
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
+16 -1
View File
@@ -1,5 +1,5 @@
{
"manifestVersion": 8,
"manifestVersion": 9,
"id": "example",
"name": "Example Module",
"description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.",
@@ -48,6 +48,21 @@
"description": "Renders this module's record summary inside a host module's own screen."
}
],
"navigation": {
"icon": "clipboard-list",
"pages": [
{
"id": "records",
"label": "Records",
"path": "records"
},
{
"id": "about",
"label": "About",
"path": "about"
}
]
},
"entrypoints": {
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js",
+170 -19
View File
@@ -1,24 +1,64 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { EmptyState, LoadingState, Panel } from "@ekklesia/ui";
import { type ReactElement, useEffect, useState } from "react";
import type {
ModuleUiHostContext,
ModuleUiLocation,
ModuleUiNavigation,
} from "@ekklesia/module-ui-sdk";
import { Button, EmptyState, LoadingState, PageHeader, Panel } from "@ekklesia/ui";
import { type ReactElement, useCallback, useEffect, useState, useSyncExternalStore } from "react";
/**
* The template's example screen. It is deliberately tiny, and deliberately host-neutral: it reads
* everything it needs from the host context and hardcodes no URL, no origin, and no global.
* The template's example screens. Deliberately small, and deliberately host-neutral: everything
* comes from the host context, and no URL, origin, route, or global is hardcoded.
*
* The module declares two primary pages in its manifest (`navigation.pages`, manifest v9,
* ADR 0063): **Records** and **About**. The Dashboard draws them in its sidebar beneath this
* module, so the module renders no tab bar of its own — it renders whichever page the host says is
* open (`context.navigation.location()`), and moves with `navigation.link()` and
* `navigation.navigate()` instead of touching `window.history`. A record's own page,
* `records/<id>`, lies beneath the Records page: a detail route, which needs no manifest entry.
*
* Presentation comes from the platform's shared elements and this module's own stylesheet
* (`./styles.css`, declared through `defineModuleUi({ styles })`), so it matches every other
* Ekklesia screen without copying a single colour value (ADR 0039, ADR 0040).
*/
export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
const [recordCount, setRecordCount] = useState<number | undefined>(undefined);
// Presentation-only gating. The server decides what actually happens; this only decides what the
// viewer is offered. See docs/development/security.md.
const canRead = context.permissions.has("example.records.read");
interface ExampleRecord {
readonly id: string;
readonly value: string;
}
const NO_LOCATION: ModuleUiLocation = Object.freeze({ page: undefined, path: "" });
/** Where the host says this module is. A host without addressable pages — a standalone host —
* supplies no navigation, and the module shows its landing view. */
function useModuleLocation(navigation: ModuleUiNavigation | undefined): ModuleUiLocation {
const subscribe = useCallback(
(listener: () => void) => navigation?.subscribe(listener) ?? (() => undefined),
[navigation],
);
const read = useCallback(() => navigation?.location() ?? NO_LOCATION, [navigation]);
return useSyncExternalStore(subscribe, read, read);
}
/** A link to one of this module's own pages: a real `<a>`, opened in place by the host. */
function ModuleLink(props: {
readonly navigation: ModuleUiNavigation;
readonly to: string;
readonly children: string;
readonly testId: string;
}): ReactElement {
const link = props.navigation.link(props.to);
return (
<a className="example-link" data-testid={props.testId} href={link.href} onClick={link.onClick}>
{props.children}
</a>
);
}
function useRecords(context: ModuleUiHostContext, enabled: boolean) {
const [records, setRecords] = useState<readonly ExampleRecord[] | undefined>(undefined);
useEffect(() => {
if (!canRead) {
if (!enabled) {
return;
}
let cancelled = false;
@@ -28,20 +68,40 @@ export function ExampleApp({ context }: { readonly context: ModuleUiHostContext
void context.api
.request("records")
.then((response) => (response.ok ? response.json() : []))
.then((records: unknown) => {
.then((body: unknown) => {
if (!cancelled) {
setRecordCount(Array.isArray(records) ? records.length : 0);
setRecords(
Array.isArray(body)
? body.filter(
(record): record is ExampleRecord =>
typeof record === "object" &&
record !== null &&
typeof (record as ExampleRecord).id === "string" &&
typeof (record as ExampleRecord).value === "string",
)
: [],
);
}
})
.catch(() => {
if (!cancelled) {
setRecordCount(0);
setRecords([]);
}
});
return () => {
cancelled = true;
};
}, [canRead, context.api]);
}, [context.api, enabled]);
return records;
}
export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement {
// Presentation-only gating. The server decides what actually happens; this only decides what the
// viewer is offered. See docs/development/security.md.
const canRead = context.permissions.has("example.records.read");
const { navigation } = context;
const location = useModuleLocation(navigation);
const records = useRecords(context, canRead);
if (!canRead) {
return (
@@ -52,15 +112,106 @@ export function ExampleApp({ context }: { readonly context: ModuleUiHostContext
);
}
if (navigation !== undefined && location.page === "about") {
return (
<div className="example-page" data-testid="example-about">
<PageHeader description="How this example module is put together." title="About" />
<Panel>
<p className="example-text">
This module declares two pages, Records and About, in its manifest. The Dashboard shows
them in its own navigation, so the module never draws a tab bar of its own.
</p>
</Panel>
</div>
);
}
if (navigation !== undefined && location.page === "records") {
const recordId = location.path.startsWith("records/")
? location.path.slice("records/".length)
: undefined;
if (recordId !== undefined) {
const record = records?.find((candidate) => candidate.id === recordId);
return (
<div className="example-page" data-testid="example-record">
<PageHeader
back={{
href: navigation.link("records").href,
label: "Back to Records",
onBack: () => navigation.navigate("records"),
}}
title="Record"
/>
{records === undefined ? (
<LoadingState label="Loading record…" />
) : record === undefined ? (
<EmptyState description="It may have been removed." title="This record isn't here" />
) : (
<Panel>
<p className="example-text" data-testid="example-record-value">
{record.value}
</p>
</Panel>
)}
</div>
);
}
return (
<div className="example-page" data-testid="example-records">
<PageHeader description="Everything this module knows about." title="Records" />
{records === undefined ? (
<LoadingState label="Loading records…" />
) : records.length === 0 ? (
<EmptyState
description="Records appear here once there are any."
title="No records yet"
/>
) : (
<Panel>
<ul className="example-list">
{records.map((record) => (
<li key={record.id}>
<ModuleLink
navigation={navigation}
testId={`example-record-link-${record.id}`}
to={`records/${encodeURIComponent(record.id)}`}
>
{record.value}
</ModuleLink>
</li>
))}
</ul>
</Panel>
)}
</div>
);
}
const settings = navigation?.settings;
return (
<Panel description="Everything this module knows about." title="Example module UI">
{recordCount === undefined ? (
<Panel
actions={
settings === undefined ? undefined : (
<Button data-testid="example-open-settings" onClick={() => settings.open()}>
Settings
</Button>
)
}
description="Everything this module knows about."
title="Example module UI"
>
{records === undefined ? (
<LoadingState label="Loading records…" />
) : (
<div className="example-summary">
<div className="example-summary" data-testid="example-home">
<p className="example-summary__count" data-testid="record-count">
{`${recordCount} record(s)`}
{`${records.length} record(s)`}
</p>
{navigation === undefined ? undefined : (
<ModuleLink navigation={navigation} testId="example-open-records" to="records">
View records
</ModuleLink>
)}
</div>
)}
</Panel>
+27
View File
@@ -29,3 +29,30 @@
font-variant-numeric: tabular-nums;
margin: 0;
}
.example-page {
display: grid;
gap: var(--ekklesia-spacing-lg);
}
.example-text {
color: var(--ekklesia-text-primary);
margin: 0;
}
.example-list {
display: grid;
gap: var(--ekklesia-spacing-xs);
list-style: none;
margin: 0;
padding: 0;
}
.example-link {
color: var(--ekklesia-accent-strong);
}
.example-link:focus-visible {
outline: var(--ekklesia-state-focus-visible-width) solid var(--ekklesia-accent-focus);
outline-offset: var(--ekklesia-state-focus-visible-offset);
}
+117 -1
View File
@@ -2,7 +2,7 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseModuleManifest } from "@ekklesia/module-sdk";
import { moduleManifestNavigation, parseModuleManifest } from "@ekklesia/module-sdk";
import { buildModuleUi } from "@ekklesia/module-ui-build";
import {
type ModuleUiApiClient,
@@ -58,6 +58,7 @@ function recordingHost(options: {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: undefined,
moduleId: "example",
navigation: undefined,
api,
principal: { kind: "anonymous" },
permissions: { has: () => options.canRead },
@@ -65,6 +66,56 @@ function recordingHost(options: {
};
}
/** A host navigation capability the test drives: the pages this module declares, a location the
* host can move, and a record of every place the module asked to go. */
function fakeNavigation(initial: string) {
const pages = ["records", "about"];
let path = initial;
const listeners = new Set<() => void>();
const pageOf = (route: string) =>
pages.find((page) => route === page || route.startsWith(`${page}/`));
let snapshot = { page: pageOf(path), path };
const visited: string[] = [];
const goTo = (next: string): void => {
path = next;
snapshot = { page: pageOf(path), path };
for (const listener of [...listeners]) {
listener();
}
};
const navigation = {
goTo,
link: (to: string) => ({
href: `/example/${to}`,
onClick: (event: { preventDefault(): void }) => {
event.preventDefault();
visited.push(to);
goTo(to);
},
}),
location: () => snapshot,
navigate: (to: string) => {
visited.push(to);
goTo(to);
},
settings: {
link: () => ({ href: "/settings/example", onClick: () => undefined }),
open: () => {
navigation.settingsOpened += 1;
},
},
settingsOpened: 0,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
visited,
};
return navigation;
}
async function flush(): Promise<void> {
// Let React commit and the awaited fetch microtasks settle.
for (let index = 0; index < 5; index += 1) {
@@ -187,6 +238,71 @@ describe("module UI build pipeline", () => {
container.remove();
});
it("declares two primary pages and an icon in its manifest v9 navigation (ADR 0063)", async () => {
const manifest = parseModuleManifest(
JSON.parse(await readFile(path.join(moduleDirectory, "ekklesia.module.json"), "utf8")),
);
expect(manifest.manifestVersion).toBe(9);
expect(moduleManifestNavigation(manifest)).toEqual({
icon: "clipboard-list",
pages: [
{ id: "records", label: "Records", path: "records" },
{ id: "about", label: "About", path: "about" },
],
});
});
it("renders the page its host says is open, and moves only through the host's navigation", async () => {
const namespace = (await import(artifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity);
const container = document.createElement("div");
document.body.append(container);
const navigation = fakeNavigation("");
const host = {
...recordingHost({
canRead: true,
records: [{ id: "a1", value: "Hello, one" }],
requested: [],
resolve: () => undefined,
}),
navigation,
};
const unmount = await definition.mount(container, host);
await flush();
// The landing view links to a page by its host-made address, and offers Settings.
const toRecords = container.querySelector<HTMLAnchorElement>(
"[data-testid='example-open-records']",
);
expect(toRecords?.getAttribute("href")).toBe("/example/records");
container.querySelector<HTMLButtonElement>("[data-testid='example-open-settings']")?.click();
expect(navigation.settingsOpened).toBe(1);
toRecords?.click();
await flush();
expect(navigation.visited).toEqual(["records"]);
expect(container.querySelector("[data-testid='example-records']")).not.toBeNull();
const toRecord = container.querySelector<HTMLAnchorElement>(
"[data-testid='example-record-link-a1']",
);
expect(toRecord?.getAttribute("href")).toBe("/example/records/a1");
toRecord?.click();
await flush();
expect(container.querySelector("[data-testid='example-record-value']")?.textContent).toBe(
"Hello, one",
);
navigation.goTo("about");
await flush();
expect(container.querySelector("[data-testid='example-about']")).not.toBeNull();
// The module never touched the browser's own history.
expect(window.location.pathname).toBe("/");
await (unmount as () => Promise<void>)();
container.remove();
});
it("mounts the display artifact through the same ABI, then unmounts it", async () => {
const namespace = (await import(displayArtifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity);