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
+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);
}