Merge pull request 'Stage 1: module navigation contract — manifest v9, module routes, return-to-origin' (#110) from feat/module-navigation-contract into main
CI / verify (push) Successful in 1m13s
CI / infrastructure-smoke (push) Successful in 43s
CI / package-release (push) Successful in 48s
CI / image-build (push) Successful in 33s
CI / migration-smoke (push) Successful in 2m26s
CI / deploy-smoke (same-origin) (push) Successful in 59s
CI / package-publication (push) Successful in 1m2s
CI / deploy-smoke (split) (push) Successful in 3m28s

Reviewed-on: #110
Reviewed-by: Joey <[email protected]>
This commit was merged in pull request #110.
This commit is contained in:
2026-10-15 14:32:49 +00:00
64 changed files with 4245 additions and 371 deletions
+13 -2
View File
@@ -532,8 +532,10 @@ reported `blocked · configuration_missing · bucket` — never imported, never
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
manifest is version 8 after ADR 0050 added the optional database ABI declaration. Version 8 is a
hard cutover; a version 7 source module or package must be updated and rebuilt.
manifest was version 8 after ADR 0050 added the optional database ABI declaration, a hard cutover
that requires a version 7 source module or package to be updated and rebuilt. Version 9 (ADR 0063)
adds optional module navigation, and is the first version that keeps its predecessor accepted:
released version 8 modules run unchanged.
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
no key management, so a `secret` key's value is supplied by the deployment
@@ -951,6 +953,15 @@ and updated (`/settings/discover/browse`, `/installed`, `/updates`); configuring
Settings page. A person sees only what the server says they may administer, and no Settings link at all
when that is nothing. The pages are placeholders until each section is built.
**Modules are first-class parts of the Dashboard**
([ADR 0063](docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). A module
declares its Lucide icon and its primary pages in manifest version 9, and the sidebar shows those
pages beneath it while it is open. Every page has a real address: `/signage/timelines`, and routes
beneath a page such as `/media/sermons/123`. A refresh, a bookmark, and Back and Forward all stay put.
A module moves between its own pages, and opens its Settings page, through a narrow host navigation
capability. Settings' Back returns to exactly the module page that opened it. Released manifest
version 8 modules keep working unchanged, with the generic icon and a single page.
**Your account** is at the bottom of the sidebar, showing your name, email, and initials
([ADR 0062](docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)).
`/account` shows who you are signed in as. On `/account/appearance` you choose **System, Light, or
+31 -5
View File
@@ -21,7 +21,11 @@ import {
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import { describeMountableModules, type HostModuleUiDescriptor } from "./module-ui-inventory.js";
import {
describeMountableModules,
type HostModuleUiDescriptor,
type MountableModuleDescription,
} from "./module-ui-inventory.js";
export const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
const DASHBOARD_GROUPS_PATH = `${DASHBOARD_NAVIGATION_PATH}/groups`;
@@ -41,6 +45,20 @@ const DASHBOARD_PLACEMENTS_PATH = `${DASHBOARD_NAVIGATION_PATH}/placements`;
export interface DashboardNavigationModuleView {
readonly name: string;
readonly module: HostModuleUiDescriptor;
/**
* What the module declares about its own pages (manifest v9, ADR 0063): the Lucide icon the
* Dashboard draws it with (`null` for the generic one) and its primary pages, in declared order —
* empty for a single-page module, which every manifest v8 module is. The module's identity, never
* its placement: where it sits in the sidebar stays the administrator's (ADR 0027).
*/
readonly navigation: {
readonly icon: string | null;
readonly pages: readonly {
readonly id: string;
readonly label: string;
readonly path: string;
}[];
};
}
export interface DashboardNavigationGroupView {
@@ -203,10 +221,18 @@ export function createDashboardRoutes(options: CreateDashboardRoutesOptions): Ho
configuration,
descriptors.map((entry) => ({ id: entry.descriptor.id, ...entry })),
);
const present = (entry: {
readonly descriptor: HostModuleUiDescriptor;
readonly name: string;
}): DashboardNavigationModuleView => ({ module: entry.descriptor, name: entry.name });
const present = (entry: MountableModuleDescription): DashboardNavigationModuleView => ({
module: entry.descriptor,
name: entry.name,
navigation: {
icon: entry.navigation.icon ?? null,
pages: entry.navigation.pages.map((page) => ({
id: page.id,
label: page.label,
path: page.path,
})),
},
});
return c.json(
{
groups: view.groups.map((group) => ({
+10 -1
View File
@@ -1,4 +1,8 @@
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
import type {
EkklesiaPrincipal,
ModuleNavigationDeclaration,
PermissionDeclaration,
} from "@ekklesia/contracts";
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
import type {
ModuleUiPublicationRegistry,
@@ -196,6 +200,10 @@ export interface MountableModuleDescription {
* Dashboard owns (ADR 0027). Deliberately absent from `HostModuleUiDescriptor`, so the
* host-neutral contract a standalone host consumes stays free of presentation concerns. */
readonly name: string;
/** The module's own declared icon and primary pages (manifest v9, ADR 0063); empty for one that
* declares none. Presentation identity like `name`, and kept beside the descriptor for the same
* reason. */
readonly navigation: ModuleNavigationDeclaration;
}
/**
@@ -216,6 +224,7 @@ export async function describeMountableModules(
registry.list().map(async (published) => ({
descriptor: await describeModule(evaluator, principal, published, registry),
name: published.name,
navigation: published.navigation,
})),
);
}
+22 -1
View File
@@ -43,7 +43,13 @@ import {
DashboardHostConfigurationError,
createDashboardHostRoutes,
} from "../src/dashboard-host-routes.js";
import { DASHBOARD_PLATFORM_PAGES, SETTINGS_SECTIONS } from "@ekklesia/shell/dashboard-routes";
import { MODULE_NAVIGATION_ICONS } from "@ekklesia/contracts";
import {
DASHBOARD_PLATFORM_PAGES,
HOST_NAMESPACES,
SETTINGS_SECTIONS,
} from "@ekklesia/shell/dashboard-routes";
import { DASHBOARD_MODULE_ICON_NAMES } from "@ekklesia/shell/module-icons";
import {
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
MODULE_DISPLAY_PAGE_PREFIX,
@@ -417,6 +423,14 @@ describe("the platform's reserved top-level names (ADR 0057)", () => {
}
});
it("are exactly the Dashboard's platform pages plus the host namespaces it never treats as pages (ADR 0063)", () => {
// With module routes nested beneath `/<module-id>/`, the shell must know every other top-level
// name: `/api/v1/principal` must never read as the route `v1/principal` of a module called `api`.
expect([...DASHBOARD_PLATFORM_PAGES, ...HOST_NAMESPACES].sort()).toEqual([
...RESERVED_TOP_LEVEL_NAMES,
]);
});
it("include exactly the Settings sections the shell interprets, so /settings/<module-id> never collides (ADR 0061)", () => {
expect([...SETTINGS_SECTIONS].sort()).toEqual([...RESERVED_SETTINGS_NAMES]);
for (const section of SETTINGS_SECTIONS) {
@@ -630,3 +644,10 @@ describe("Dashboard host boundaries", () => {
}
});
});
describe("module navigation icons (ADR 0063)", () => {
it("are the same set in the manifest contract and in the Dashboard that draws them", () => {
// The platform cannot accept an icon the Dashboard cannot draw, nor draw one it would refuse.
expect([...DASHBOARD_MODULE_ICON_NAMES]).toEqual([...MODULE_NAVIGATION_ICONS]);
});
});
+74 -2
View File
@@ -55,6 +55,8 @@ interface ModuleFixture {
readonly id: string;
readonly name: string;
readonly withUi?: boolean;
/** Present, the fixture is a manifest v9 module declaring this navigation (ADR 0063). */
readonly navigation?: unknown;
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
@@ -78,8 +80,9 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
},
id: fixture.id,
manifestVersion: 8,
manifestVersion: fixture.navigation === undefined ? 8 : 9,
name: fixture.name,
...(fixture.navigation === undefined ? {} : { navigation: fixture.navigation }),
version: "1.0.0",
}),
);
@@ -170,6 +173,14 @@ function signedIn(): EkklesiaPrincipal {
interface NavigationEntry {
readonly name: string;
readonly navigation: {
readonly icon: string | null;
readonly pages: readonly {
readonly id: string;
readonly label: string;
readonly path: string;
}[];
};
readonly module: {
readonly id: string;
readonly version: string;
@@ -284,7 +295,68 @@ describe("Dashboard navigation — reading", () => {
ui: { url: "/api/v1/module-ui/livestream/1.0.0" },
version: "1.0.0",
});
expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name"]);
expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name", "navigation"]);
// A module that declares no navigation — this manifest v8 fixture — is drawn with the generic
// icon and has its root as its only page (ADR 0063). Never a group or an order.
expect(livestream?.navigation).toEqual({ icon: null, pages: [] });
});
it("carries a manifest v9 module's own icon and pages beside its descriptor, in declared order", async () => {
const { app } = await install({
fixtures: [
{
id: "signage",
name: "Signage",
navigation: {
icon: "monitor-play",
pages: [
{ id: "playback", label: "Playback", path: "playback" },
{ id: "timelines", label: "Timelines", path: "timelines" },
{ id: "media", label: "Media", path: "media" },
],
},
},
{ id: "calendar", name: "Calendar" },
],
principal: signedIn(),
});
const navigation = await navigationOf(app);
const signage = navigation.ungrouped.find((entry) => entry.module.id === "signage");
const calendar = navigation.ungrouped.find((entry) => entry.module.id === "calendar");
expect(signage?.navigation).toEqual({
icon: "monitor-play",
pages: [
{ id: "playback", label: "Playback", path: "playback" },
{ id: "timelines", label: "Timelines", path: "timelines" },
{ id: "media", label: "Media", path: "media" },
],
});
// The host-neutral descriptor is untouched by navigation: it lives beside it, never inside.
expect(Object.keys(signage?.module ?? {}).sort()).toEqual(
Object.keys(calendar?.module ?? {}).sort(),
);
// A manifest v8 module beside it keeps the generic icon and its single root page.
expect(calendar?.navigation).toEqual({ icon: null, pages: [] });
});
it("refuses at discovery a v9 module whose navigation is unsafe, so it never reaches the sidebar", async () => {
const { app } = await install({
fixtures: [
{ id: "calendar", name: "Calendar" },
{
id: "escape",
name: "Escape",
navigation: { pages: [{ id: "out", label: "Out", path: "../settings" }] },
},
],
principal: signedIn(),
}).catch((error: unknown) => ({ app: undefined, error }));
// Discovery is atomic (ADR 0005): an invalid manifest fails the whole inventory rather than
// quietly dropping one module, exactly as for any other manifest error.
expect(app).toBeUndefined();
});
it("agrees exactly with the generic inventory about what is mountable", async () => {
+25 -2
View File
@@ -1,5 +1,7 @@
import {
isVersionCompatible,
type ModuleNavigationDeclaration,
moduleManifestNavigation,
type PermissionDeclaration,
type PermissionId,
} from "@ekklesia/contracts";
@@ -24,10 +26,18 @@ export interface PublishableModuleUi {
*
* Carried here so a host can render something a person recognizes instead of a module id. It is
* presentation *identity*, not presentation *placement*: a module says what it is called, while
* where it appears in a Dashboard is configuration an administrator owns. Nothing about grouping,
* ordering, or navigation belongs on this record.
* where it appears in a Dashboard is configuration an administrator owns. Nothing about grouping
* or ordering belongs on this record.
*/
readonly name: string;
/**
* What the module declares about its own pages (manifest v9, ADR 0063): the Lucide icon it is
* drawn with and its primary pages, in its declared order. Like `name`, this is the module's own
* identity — what it is and which pages it has — never where it sits in the Dashboard, which stays
* the administrator's (ADR 0027). Empty for a manifest v8 module and for one that declares none:
* the generic icon and a single root page.
*/
readonly navigation: ModuleNavigationDeclaration;
readonly artifactPath: string;
/**
* The module's own manifest-declared permissions, carried here so a publisher can compute the
@@ -44,6 +54,18 @@ export interface PublishableModuleUi {
readonly permissions: readonly PermissionDeclaration[];
}
/** A fresh, deeply frozen copy, so a caller holding a record never reaches the manifest graph. */
function frozenNavigation(navigation: ModuleNavigationDeclaration): ModuleNavigationDeclaration {
return Object.freeze({
...(navigation.icon === undefined ? {} : { icon: navigation.icon }),
pages: Object.freeze(
navigation.pages.map((page) =>
Object.freeze({ id: page.id, label: page.label, path: page.path }),
),
),
});
}
/**
* One installed module whose dedicated full-viewport display artifact may be published.
*
@@ -233,6 +255,7 @@ export function createModuleUiPublicationRegistry(
artifactPath,
moduleId: module.manifest.id,
name: module.manifest.name,
navigation: frozenNavigation(moduleManifestNavigation(module.manifest)),
// Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding
// this record can never reach the manifest object graph itself.
permissions: Object.freeze([...module.manifest.permissions]),
+7 -2
View File
@@ -219,20 +219,25 @@ describe("Dashboard grouping never becomes module structure", () => {
}
});
it("keeps the publication record free of any grouping, ordering, or navigation field", async () => {
it("keeps the publication record free of any grouping or ordering field", async () => {
const registry = createModuleUiPublicationRegistry(await discoverFixtures());
const published = registry.resolve("livestream", "1.0.0");
// The record a host mounts from carries identity, the validated artifact, declared permissions,
// and the module's own name — never a group, an order, a parent, or a folder.
// the module's own name, and its own declared navigation — never a group, an order, a parent,
// or a folder. Navigation is what the module *is* (its icon and pages, ADR 0063); where it sits
// stays the administrator's (ADR 0027).
expect(Object.keys(published ?? {}).sort()).toEqual([
"artifactPath",
"moduleId",
"name",
"navigation",
"permissions",
"version",
]);
expect(Object.keys(published?.navigation ?? {}).sort()).toEqual(["pages"]);
expect(published?.navigation.pages).toEqual([]);
});
it("takes the display name from the module's own manifest, not from any folder", async () => {
+107 -1
View File
@@ -1,6 +1,8 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { moduleManifestNavigation } from "@ekklesia/contracts";
import { afterEach, describe, expect, it } from "vitest";
import {
discoverModules,
@@ -1220,3 +1222,107 @@ describe("browser contribution artifact resolution", () => {
});
});
});
describe("manifest v8 and v9 side by side (ADR 0063)", () => {
const releasedSignageManifest = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"packages",
"contracts",
"tests",
"fixtures",
"released-v8-signage-0.1.5.module.json",
);
it("discovers the released Signage 0.1.5 module exactly as published, with no navigation", async () => {
const modulesDirectory = await createModulesDirectory();
const manifest = JSON.parse(await readFile(releasedSignageManifest, "utf8")) as Record<
string,
unknown
>;
await createModule(modulesDirectory, "signage", {
extraFiles: {
"dist/display/index.js": "export default {};\n",
"dist/schema/index.js": "export {};\n",
"dist/ui/index.js": "export default {};\n",
"drizzle/meta/_journal.json": JSON.stringify({
dialect: "postgresql",
entries: [],
version: "7",
}),
},
manifest,
packageJson: {
ekklesia: { layer: "module" },
name: "@zayin/signage",
type: "module",
version: manifest.version,
},
serverSource: 'export default { id: "signage", version: "0.1.5" };',
});
const [signage] = await discoverModules({
moduleSdkVersion: "0.2.5",
modulesDirectory,
platformVersion: "0.2.5",
});
expect(signage?.manifest.manifestVersion).toBe(8);
expect(signage?.entrypoints.ui).toBeDefined();
expect(moduleManifestNavigation(signage?.manifest ?? { manifestVersion: 8 })).toEqual({
pages: [],
});
// Its settings, which Settings → Modules lists (ADR 0061), are read exactly as before.
expect(signage?.manifest.configuration.map((declaration) => declaration.key)).toEqual(
(manifest.configuration as { key: string }[]).map((declaration) => declaration.key),
);
});
it("discovers a manifest v9 module with its declared icon and pages", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "media", {
extraFiles: { "dist/ui/index.js": "export default {};\n" },
manifest: {
...manifestFor("media"),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
manifestVersion: 9,
navigation: {
icon: "images",
pages: [
{ id: "sermons", label: "Sermons", path: "sermons" },
{ id: "library", label: "Library", path: "library" },
],
},
},
});
const [media] = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(media?.manifest.manifestVersion).toBe(9);
const navigation = moduleManifestNavigation(media?.manifest ?? { manifestVersion: 9 });
expect(navigation.icon).toBe("images");
expect(navigation.pages.map((page) => page.path)).toEqual(["sermons", "library"]);
// Deeply frozen like the rest of a discovered manifest.
expect(Object.isFrozen(navigation.pages)).toBe(true);
});
it("refuses a manifest v9 module whose navigation escapes its own namespace", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "media", {
extraFiles: { "dist/ui/index.js": "export default {};\n" },
manifest: {
...manifestFor("media"),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
manifestVersion: 9,
navigation: { pages: [{ id: "out", label: "Out", path: "../settings" }] },
},
});
expect(issueCodes(await discoveryError(modulesDirectory))).toEqual(["MODULE_MANIFEST_INVALID"]);
});
});
@@ -2,6 +2,7 @@
- Status: Accepted
- Date: 2026-08-21
- Navigation member decided by: [0063](0063-module-navigation-contract-manifest-v9.md)
- Extends: [0021](0021-modules-as-hostable-products.md)
## Context
@@ -2,6 +2,7 @@
- Status: Accepted
- Date: 2026-08-22
- Module icon and primary pages superseded by: [0063](0063-module-navigation-contract-manifest-v9.md) — a module now declares its own icon and pages; groups and ordering stay the administrator's
- Extends: [0021](0021-modules-as-hostable-products.md),
[0026](0026-minimal-ekklesia-module-ui-host.md)
@@ -2,6 +2,7 @@
- Status: Accepted
- Date: 2026-10-07
- Manifest version acceptance extended by: [0063](0063-module-navigation-contract-manifest-v9.md) — version 8 stays accepted beside version 9
- Supersedes (partial): [0016](0016-module-database-access.md) — for modules that declare ABI v1,
only its definition of the module database service as `Pick<NodePgDatabase, …>`, a facade over
Core's Drizzle instance, and its rule that every module receives the same `services` object.
@@ -2,6 +2,7 @@
- Status: Accepted
- Date: 2026-10-14
- Module-declared icons and pages decided by: [0063](0063-module-navigation-contract-manifest-v9.md)
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
[0027](0027-dashboard-navigation-and-flat-modules.md),
[0039](0039-shared-design-tokens-and-elements.md),
@@ -2,6 +2,7 @@
- Status: Accepted
- Date: 2026-10-15
- Return-target carrier decided by: [0063](0063-module-navigation-contract-manifest-v9.md)
- Extends: [0034](0034-host-owned-browser-authentication.md),
[0039](0039-shared-design-tokens-and-elements.md),
[0040](0040-module-stylesheets-and-artifact-isolation.md),
@@ -2,6 +2,7 @@
- Status: Accepted
- Date: 2026-10-15
- Module → Settings return link decided by: [0063](0063-module-navigation-contract-manifest-v9.md)
- Extends: [0027](0027-dashboard-navigation-and-flat-modules.md),
[0028](0028-module-operational-lifecycle.md),
[0029](0029-module-configuration-and-secrets.md),
@@ -0,0 +1,290 @@
# 0063: Module navigation — manifest v9 icons and pages, real module routes, and return-to-origin
- Status: Accepted
- Date: 2026-10-15
- Extends: [0022](0022-module-ui-authoring-and-host-contract.md),
[0026](0026-minimal-ekklesia-module-ui-host.md),
[0050](0050-module-database-abi-v1.md),
[0057](0057-dashboard-urls-are-host-owned-routes.md),
[0058](0058-dashboard-shell-navigation-and-lucide-icons.md),
[0059](0059-shared-interaction-primitives.md),
[0061](0061-settings-hierarchy-and-module-settings-routes.md)
- Supersedes, in part: [0027](0027-dashboard-navigation-and-flat-modules.md) (a module now declares
its own icon and primary pages; grouping and ordering stay the administrator's)
## Context
DESIGN.md treats a module as a first-class application area:
- its primary pages belong in the global sidebar beneath it, with no second tab strip inside the
module (§4.4, locked decision 11);
- every meaningful page has a real route, so a refresh, a bookmark, Back and Forward work (§2.7,
locked decision 12);
- a module can deep-link to its centralized Settings page and come back to exactly the workflow
that sent the person there (§2.7, §9.3, locked decision 13).
The platform had none of this:
- **Icons.** Every module was drawn with one generic icon. ADR 0058 left a module's own icon to
"the module navigation contract (manifest v9)".
- **Routes.** `/<module-id>` was a module's only route, and everything beneath it was not-found
(ADR 0057).
- **Navigation.** The module UI context had no navigation member. ADR 0022 deferred it because "it
needs a design that does not exist", and modules may not bring a router (ADR 0026).
- **Return target.** ADR 0059 built and validated `parseReturnTarget`, and ADR 0061 gave every
module's settings `/settings/<module-id>`, but both deferred *how* a link carries a return
target.
Released modules (Signage 0.1.5 among them) are packaged, signed, and installed as manifest version
8, and every earlier manifest version was rejected outright by its successor.
## Decision
### Manifest v9: one optional `navigation` field
Manifest version 8's top-level object is strict, so a new field is a new version. Version 9 is
version 8 plus one optional field:
```json
"navigation": {
"icon": "monitor-play",
"pages": [
{ "id": "playback", "label": "Playback", "path": "playback" },
{ "id": "timelines", "label": "Timelines", "path": "timelines" },
{ "id": "media", "label": "Media", "path": "media" }
]
}
```
- **`icon`** (optional) is a Lucide name from a curated set (below). Omitted, the module is drawn
with the generic module icon.
- **`pages`** (optional, at most 12) are the module's primary pages, in the order the sidebar shows
them. Each page has:
- **`id`**: stable and never displayed. Lowercase kebab-case, at most 64 characters, unique within
the manifest. The navigation API reports it as the current page.
- **`label`**: plain text, trimmed, 1–60 characters, with no control or line-separator characters.
- **`path`**: relative to the module's root. One to four lowercase kebab-case segments joined by
`/`, unique within the manifest. It may not lie beneath another page's path.
- **What a path can never be:** a leading or trailing slash, an empty segment, `.` or `..`,
percent-encoding, a query, a fragment, or a scheme. A declared path cannot escape `/<module-id>/`
or name anything outside the module.
- **Requires a UI.** `navigation` is only valid with `entrypoints.ui`.
- **Validated before activation.** Validation lives in `@ekklesia/contracts`, so discovery,
`pnpm modules:validate`, the packager, and package verification all refuse a bad declaration
before the module can start.
- **No page icons.** The collapsed sidebar shows the module item alone, so a page icon would never
be seen.
- **Nothing else is added.** No groups, order, Overview widgets, or settings declaration: a module
already has settings exactly when it declares `configuration` (ADR 0029, ADR 0061).
`moduleManifestNavigation(manifest)` reads the declaration the same way for every version. A version
8 manifest, and a version 9 manifest that omits the field, both have no icon and no pages.
### Version 8 stays accepted beside version 9
Until now every manifest version rejected its predecessor ("no dual acceptance": ADR 0006, ADR 0014,
ADR 0021, ADR 0050). This decision is the first exception, deliberately:
- **Why.** Version 9 adds one optional field and changes the meaning of nothing a version 8 document
says. Rejecting version 8 would stop every installed module at its next upgrade for no safety gain.
- **One document, one version.** `manifestVersion` selects one strict schema (a discriminated union
of 8 and 9). A document is only ever read as the version it declares. A version 8 document that
names `navigation` is refused, never quietly read as version 9.
- **Nothing is rewritten.** The packager still copies manifest bytes verbatim, and verification
checks them against the package digest. Core never writes a manifest back.
- **The template moves to version 9.** `MODULE_MANIFEST_VERSION` is now 9, the version a new module
declares. `SUPPORTED_MODULE_MANIFEST_VERSIONS` is `[8, 9]`. Version 7 and earlier are still
refused.
How long version 8 stays accepted is a later decision. It needs its own ADR.
### Icons: a curated Lucide set, named in the manifest, drawn by the host
A manifest names an icon. It never supplies one: no SVG, URL, component, or code.
- **The set.** `MODULE_NAVIGATION_ICONS` (`@ekklesia/contracts`) is about ninety Lucide names chosen
for generic subjects (`calendar`, `monitor-play`, `users`, `book-open`, …). There is no entry for
any particular module, and no module-to-icon table anywhere.
- **Validated against the set.** A manifest's `icon` must be in it, so a typo is caught when a
module is packaged or discovered, not drawn silently as the generic icon.
- **Imported by name.** The Dashboard imports exactly these icons from `lucide-react`
(`browser/module-icon.tsx`) and never bundles the library's thousands of others.
- **One list, held together by tests.** A surface may not import `@ekklesia/contracts` (ADR 0024),
so the shell keeps the names in a framework-free file, `@ekklesia/shell/module-icons`.
- An `apps/api` test holds the shell's list equal to `MODULE_NAVIGATION_ICONS`, so the platform
cannot accept an icon the Dashboard cannot draw.
- A shell test proves every entry is the real Lucide component of that name.
- **Growing the set is additive.** It needs a platform release, and a module that uses a new name
declares the platform range that has it.
- **Fallback.** A host that cannot draw a name draws the generic icon instead of failing. The
Dashboard can draw all of them; another host might not.
- **Decorative only.** Icons never carry meaning (ADR 0058). The module's name is always beside the
icon or in its control's accessible name.
### Module routes
```text
/<module-id> the module's landing view (its root)
/<module-id>/<page-path> a declared primary page
/<module-id>/<page-path>/… a route beneath a declared page: a detail view the module owns
```
**The root stays the canonical landing view and is never itself a declared page.** A page may not
use the root's path, so no page has two addresses, and `/<module-id>` never silently redirects.
- **Landing on a page.** A module that wants to land on, say, Playback renders Playback at its root
and simply doesn't declare a Playback page.
- **Beneath a declared page.** Any route under a page (`/signage/media/sermons/123`) mounts the
module with that full path. Detail views therefore need no manifest or sidebar entry. Primary
navigation and every valid internal route are deliberately different things.
- **What a route can contain.** Segments are unreserved URL characters only (letters, digits, `-`,
`.`, `_`, `~`), at most 8 segments and 512 characters. Nothing else is a route.
- **Unknown paths.** A path that is neither the root nor at or beneath a declared page is the
host's *Page not found*, and the module is not mounted. Nothing silently opens some other page.
- **Version 8 modules.** A module that declares no pages, which includes every version 8 module, has
its root and nothing beneath it, exactly as before.
- **Disabled modules.** A module that is installed but not running gets *This page isn't available*
at its root and at every path beneath it, and nothing mounts. A module that isn't installed is not
served at all (ADR 0057).
- **No remount.** The mounted module is keyed by identity, not path, so moving between its own pages
never remounts it. Focus moves to the page on each move, as for any page change.
The API already serves the Dashboard page throughout `/<module-id>/*` (ADR 0057), so none of this
needs a server or proxy change.
**The host's own namespaces are never pages.** `api`, `assets`, and `display` (`HOST_NAMESPACES`)
are never Dashboard pages at any depth, so `/api/v1/principal` can never read as the route
`v1/principal` of a module called `api`. Together with the Dashboard's platform pages they are
exactly Core's `RESERVED_TOP_LEVEL_NAMES`, and an `apps/api` test holds the lists together.
### The sidebar
- **A version 9 module** is drawn with its own icon. While it is open (its root, a page, or beneath
one), its pages appear beneath it, the same in-place expansion Settings uses (ADR 0061).
- **The current item.** Exactly one item carries `aria-current="page"`: the open page, or the
module itself on its landing view. A detail route marks its page.
- **Collapsed sidebar.** It shows icons only, so the pages stay hidden. The module item is then the
current one, named by its tooltip and accessible name.
- **Phone drawer.** It has the identical hierarchy and closes once a page is chosen.
- **A version 8 module** is one item with the generic icon, as before.
- **A disabled module** is absent, as before.
The server sends each module's declared icon (`null` for none) and pages beside its name in
`GET /api/v1/dashboard/navigation`, never inside the host-neutral descriptor (ADR 0026, ADR 0027).
The shell parses both strictly.
### The module's navigation capability
`ModuleUiHostContext.navigation` (`@ekklesia/module-ui-sdk`) is a narrow host capability, not a
router, and an **optional** member (`navigation?: ModuleUiNavigation`). A host with addressable
module pages (Ekklesia's Dashboard) supplies it. A host without them (the display host, and a
standalone host without routes) omits it, and may also set it to `undefined`.
The member is optional so that adding it breaks no one. Ekklesia's 0.x versioning makes patch
releases compatible, and a context built before navigation existed must stay a complete, valid
context that compiles unchanged. A `@ekklesia/module-ui-sdk` test holds exactly that, and fails if
the member is ever made required.
```ts
interface ModuleUiNavigation {
location(): { path: string; page: string | undefined }; // "media/sermons/123", "media"
subscribe(listener: () => void): () => void;
navigate(path: string, options?: { replace?: boolean }): void;
link(path: string): { href: string; onClick(event): void };
readonly settings: { open(): void; link(): ModuleUiLink } | undefined;
}
```
- **Relative paths only.** Every path is relative to the module's own root, and a leading `/` means
the same as none. There is therefore no way to name another module or a platform page.
- **Refused paths.** A path the host would not open throws `ModuleUiNavigationError`: malformed or
escaping gives `MODULE_NAVIGATION_PATH_INVALID`, and not at or beneath a declared page gives
`MODULE_NAVIGATION_PATH_UNKNOWN`. The error message never echoes the path.
- **Real links.** `link()` returns a real `href`, so a link can be opened in a new tab, copied, or
bookmarked. Its `onClick` opens the route in place on an ordinary click and leaves modified or
middle clicks to the browser.
- **Location updates.** `location()` returns a stable snapshot between moves. Every subscriber hears
every move: the module's own navigation, the sidebar, Back and Forward.
- **Settings.** `settings` is present only when the module has a Settings page this viewer may open
(it declares configuration, it is running, and Settings lists it for them). It's a presentation
hint: Settings enforces its own authority.
- **What a module never gets.** No history object, no `window` access, and no way to drive the shell
beyond its own routes. Modules still may not bring a router (ADR 0026).
- **Version 8 modules too.** They receive navigation with only their root, so a version 8 module
rebuilt against the new SDK can already link to its Settings page.
Adding a context member changes nothing about how an artifact is mounted, so the mount ABI stays at
version 2. A module that uses `navigation` feature-detects it (`context.navigation?.navigate(…)`).
### Return-to-origin: one carrier, the `return` query parameter
The place to come back to travels as **one query parameter on the page that was opened**:
```text
/settings/signage?return=%2Fsignage%2Ftimelines
```
- **It is a query, not history state,** so it survives a refresh and works in a link opened in a new
tab. A canonical respelling of the address keeps it.
- **The module never builds or checks it.** `navigation.settings` builds it from the module's
current location.
- **It is only ever read through `parseReturnTarget`** (ADR 0059): a canonical, same-origin
Dashboard address with no query, fragment, traversal, or encoded separator. The host namespaces
are never accepted.
- **A module's address is also checked against what is running.** The module must be running here,
and the route must be its root or at or beneath a declared page. Anything else is ignored, and
Back goes to the page's own parent, Settings.
- **Back follows it.** A Settings page's Back goes to exactly that address ("Back to Signage"), as
a real link, never `history.back()`.
`HostHistory` gains `search()` so the shell can read the query. The shell reads exactly this one
parameter from it.
## Alternatives
- **Keep version 8 and add `navigation` to it.** Rejected: version 8's object is strict. Older
platforms would refuse such a manifest with a misleading "invalid manifest". A version number
says what a document is.
- **Reject version 8 as earlier versions were.** Rejected: it would break every installed module
for an optional, additive field.
- **Any Lucide name, validated only by shape.** Rejected: a typo would silently draw the generic
icon, and drawing any name means bundling or code-splitting the whole library. The API serves a
closed set of files (ADR 0044), so it cannot serve a thousand chunks.
- **Icons as SVG or URLs in the manifest.** Rejected: executable or remote presentation in an inert
document, with an asset-trust story nobody needs.
- **The root as a declared page, or a redirect from the root to the first page.** Rejected: either
gives one page two addresses or surprises a bookmark.
- **Every route in the manifest.** Rejected: record URLs (`/media/sermons/123`) cannot be listed.
Primary navigation and valid routes are different things.
- **A router in the module, or `window.history`.** Rejected: routing belongs to the host
(ADR 0026), and two owners of one address bar disagree.
- **Return target in history state, `sessionStorage`, or a path segment.** Rejected: history state
and storage don't survive a new tab, and a path segment would collide with module routes. A query
parameter is visible, refreshable, and validated on every read.
- **Page icons.** Not added: the collapsed sidebar never shows pages.
## Consequences
- **Packages.** `@ekklesia/contracts` gains `module-navigation.ts`, the v8/v9 manifest union, and
`moduleManifestNavigation`. `@ekklesia/module-ui-sdk` gains `navigation.ts` and
`ModuleUiHostContext.navigation`. `@ekklesia/module-sdk` re-exports the navigation helpers and
types. Every change is additive at the source level as well as at run time: existing module code,
and an existing host that builds a context itself, compile and run unchanged.
- **Core** publishes each module's navigation beside its name. The navigation response gains a
`navigation` member per module.
- **The shell** reads module routes at any depth, draws icons and pages, and supplies the
capability. Its route type's `module` destination gains `path`.
- **The template module** is version 9, with two pages, a detail route, and a Settings link.
- **Signage** is unaffected until it chooses to move to version 9. Its released 0.1.5 manifest is a
test fixture and runs in the deploy smoke.
## Deferred
- Migrating Signage to version 9.
- How long version 8 stays accepted.
- Overview widgets.
- A module reporting "not found" for its own unknown detail route back to the host.
- Return targets for platform pages other than Settings.
- Navigation groups and their editor (ADR 0027).
- Growing the icon set.
- Module-owned breadcrumbs.
+1
View File
@@ -71,3 +71,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0060: Light, Dark, and System appearance; reduced motion; and one radius scale](0060-light-dark-system-theme-and-motion.md) — Accepted
- [0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<module-id>`](0061-settings-hierarchy-and-module-settings-routes.md) — Accepted
- [0062: The account area, and appearance preferences that belong to the account](0062-account-area-and-synced-appearance-preferences.md) — Accepted
- [0063: Module navigation — manifest v9 icons and pages, real module routes, and return-to-origin](0063-module-navigation-contract-manifest-v9.md) — Accepted
+11 -1
View File
@@ -614,7 +614,17 @@ Core one typed column per preference and served only to their owner (`GET /api/v
`PATCH /api/v1/account/preferences`, `core.account.self.manage`), so they follow the person to every
device. A small same-origin script in the page's `<head>` applies this browser's cached copy before
first paint, and the account's answer replaces it on every load
([ADR 0062](decisions/0062-account-area-and-synced-appearance-preferences.md)). `pnpm boundaries` opens
([ADR 0062](decisions/0062-account-area-and-synced-appearance-preferences.md)). A module is a
first-class application area. Manifest version 9 declares its Lucide icon, from a curated set the
Dashboard imports by name, and its primary pages. The sidebar nests those pages beneath it while it is
open. Routes go to the module's root, to a declared page, or to anything beneath a page; anything
else under a module is not-found and mounts nothing. The host's `api`, `assets`, and `display`
namespaces are never pages. The mounted module receives `context.navigation`, a narrow capability to
read its location, move among its own routes, make real links, and open its Settings page. The
Settings URL carries `?return=` with where the module was; it is validated against the running
modules and their declared routes, and Back returns there. Manifest version 8 stays accepted beside
version 9, read strictly as itself
([ADR 0063](decisions/0063-module-navigation-contract-manifest-v9.md)). `pnpm boundaries` opens
exactly two shared roles to modules **by name**, and pins both design packages beneath everything
else. The Dashboard and display install the defaults at their document root; custom properties
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
+14
View File
@@ -1165,6 +1165,13 @@ How this installation presents its mountable modules
"api": { "baseUrl": "/api/v1/modules/livestream" },
"realtime": { "baseUrl": "/api/v1/module-realtime/livestream" },
"permissions": ["livestream.streams.read"]
},
"navigation": {
"icon": "video",
"pages": [
{ "id": "streams", "label": "Streams", "path": "streams" },
{ "id": "archive", "label": "Archive", "path": "archive" }
]
}
}
]
@@ -1174,6 +1181,13 @@ How this installation presents its mountable modules
}
```
`navigation` is what the module itself declares in manifest version 9
([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)): the Lucide
icon the Dashboard draws it with, and its primary pages in declared order. It is
`{ "icon": null, "pages": [] }` for a module that declares none, which every manifest version 8
module is: the generic icon and a single root page. It sits beside the host-neutral descriptor,
never inside it, and says nothing about where the module is placed.
### Navigation versus inventory
They are deliberately separate, and this route is layered **on top of** the inventory rather than
+23
View File
@@ -416,6 +416,29 @@ holding the channel permission may receive. If it intentionally includes a signe
URL remains a bearer capability and is forwarded byte-for-byte; fixed transport errors never reflect
payloads, URLs, permissions, runtime exceptions, paths, or stacks.
### A module's navigation reaches only its own pages, and return links only real Dashboard pages
Module navigation ([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md))
is inert data plus a narrow host capability:
- **The manifest supplies no executable or remote presentation.** A module's `navigation` is a
Lucide name from a curated set (never an SVG, a URL, or code) and page paths that are lowercase
kebab-case segments beneath its own root. Traversal, schemes, queries, and percent-encoding are
refused before the module can start.
- **The module gets no history object, router, or `window`.** It asks to move to a path relative to
its own root. The host refuses anything that is not its root, or at or beneath a page it declares,
so no module can navigate the Dashboard into another module or a platform page.
- **The host's own namespaces are never pages.** `/api/…`, `/assets/…`, and `/display/…` are never
read as a module route, a page, or a return target.
- **`?return=` is untrusted input.** It is only ever read through `parseReturnTarget`, which accepts
a canonical same-origin Dashboard address and nothing else. A module address must also name a
running module and one of its routes. Anything else is ignored, and Back is a real link to that
validated address, never `history.back()`.
None of this is a security boundary between modules: module UI is trusted code in the host's page
(see above). It keeps honest mistakes and hostile links from turning Dashboard navigation into an
open redirect or an API link.
### Client-side permission checks are not a security boundary
A host may tell a module UI what permissions it believes the viewer holds
+27
View File
@@ -412,6 +412,33 @@ pre-paint appearance cache
one write of each fixed key, one removal of the appearance key, and no storage API in any file that
holds the reset token.
Module navigation
([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)) is proven at
each layer:
- `packages/contracts/tests/module-navigation.test.ts` covers manifest v9: icons, pages, order, every
unsafe path, duplicates, nesting, and bounds. It also accepts the byte-exact released Signage 0.1.5
manifest (`tests/fixtures/`) as version 8 with no navigation, and refuses `navigation` in a
version 8 document.
- `core/tests/module-discovery.test.ts` discovers that released v8 module and a v9 module side by
side, and refuses an escaping page path.
- `apps/api/tests/dashboard-routes.test.ts` serves a v9 module's icon and pages beside its
descriptor, and `{ icon: null, pages: [] }` for v8.
- `apps/api/tests/dashboard-host-routes.test.ts` holds the shell's icon names equal to the contract's
set, and the host namespaces plus platform pages equal to Core's reserved top-level names.
- `surfaces/shell/tests/module-navigation.test.tsx` mounts real module artifacts through the loader's
Shadow DOM boundary. It covers:
- roots, declared pages, detail routes, unknown pages, and disabled modules;
- Back and Forward and refresh with no remount;
- the sidebar's icons and nested pages, collapsed, and in the drawer;
- the navigation capability, including refused paths;
- module → Settings → Back to the exact page, and every unsafe return target falling back.
- `templates/module/tests/module-ui.test.ts` renders the v9 template's pages from the host's
location.
- The deploy smoke's real Chromium drives the v9 template through its pages, a deep link, refresh,
Back and Forward, the drawer, and module → Settings → Back. With
`EKKLESIA_SMOKE_MODULE_RELEASE_DIR` it runs the released v8 Signage package as well.
The account area
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)) is
proven at each layer:
+122 -21
View File
@@ -463,7 +463,7 @@ without executing module code:
```json
{
"manifestVersion": 8,
"manifestVersion": 9,
"id": "calendar",
"name": "Calendar",
"version": "0.1.0",
@@ -522,6 +522,13 @@ without executing module code:
"description": "Renders an upcoming-events slide for a signage timeline."
}
],
"navigation": {
"icon": "calendar",
"pages": [
{ "id": "events", "label": "Events", "path": "events" },
{ "id": "rooms", "label": "Rooms", "path": "rooms" }
]
},
"entrypoints": {
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js",
@@ -548,18 +555,24 @@ visible to whoever holds `core.modules.configuration.manage`
([ADR 0061](../architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)).
Module lifecycle — installing, turning on and off, updating — is the platform's Settings → Discover, not
yours to build. Do not build a second settings system inside your module; link to your Settings page
instead once the module navigation contract can carry a return link. A provided capability
instead, through `context.navigation.settings` (see [Module navigation](#module-navigation)), which
brings the person back to exactly the page they left. A provided capability
has its own exact semantic version because its public contract can evolve independently from the
module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible.
`manifestVersion: 8` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 7 manifest, and any other version, fails validation outright rather than being silently
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
Already-built version 7 packages must be rebuilt. Version 7 added the optional `contributions` array
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
contributes nothing changes only the version literal; every browser artifact remains independently
optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
`manifestVersion` (`packages/contracts/src/module-manifest.ts`) is required and selects exactly one
strict schema: **9**, the current version and what a new module declares, or **8**, which stays
accepted so released modules keep working unchanged
([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). Version 9 is
version 8 plus the optional `navigation` field; a version 8 manifest that names `navigation` is
refused, never read as version 9. To adopt navigation, change `8` to `9` and add the field — nothing
else changes. Version 7 and every other version fail validation outright; there is no compatibility
adapter or automatic migration, and already-built version 7 packages must be rebuilt. Version 7
added the optional `contributions` array
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)), and every browser
artifact remains independently optional
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
@@ -591,9 +604,9 @@ Lucide is Ekklesia's icon vocabulary
UI needs icons, depend on `lucide-react` and import each icon by name, rather than drawing your own
SVGs (module `.svg` presentation assets are rejected by `pnpm boundaries` anyway). Treat icons as
decorative: give every control a text name, visible or visually hidden, and keep the icon
`aria-hidden`. The Dashboard's navigation currently shows every module with one generic icon; a
module will declare its own navigation icon through the module navigation contract (manifest v9), not
yet.
`aria-hidden`. The icon the **Dashboard's sidebar** draws for your module is different: you name it
in your manifest (`navigation.icon`), and the Dashboard draws it — see
[Module navigation](#module-navigation).
### Your module's display name
@@ -606,8 +619,9 @@ your package name or module id.
Production" — are presentation configuration an administrator owns, stored by the platform and
changed at any time without touching your module
([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)). There is no
manifest field for a folder, a group, an order, an icon, or a category, and adding one would make a
rendering preference into module structure.
manifest field for a folder, a group, an order, or a category, and adding one would make a rendering
preference into module structure. What you *do* declare is what your module is: its icon and its
primary pages ([Module navigation](#module-navigation)).
That separation also means grouping never affects you. Two modules sharing a folder have no
relationship: no dependency, no ordering, no shared anything. If your module genuinely needs another
@@ -624,9 +638,9 @@ symlink resolution. Modules with no tables omit `entrypoints.schema`; modules wi
history omit `entrypoints.migrations`; modules may omit `ui`, `display`, or both.
Unknown manifest properties are still rejected. A `dashboard`, `surface`, `client`, `web`, or
`renderers` key fails validation — and the manifest
deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard
organization is presentation configuration owned by the Dashboard, never module architecture.
`renderers` key fails validation — and the manifest deliberately has no group, folder, parent,
menu, or ordering field. Dashboard organization is presentation configuration owned by the
Dashboard, never module architecture. `navigation` declares only your module's own icon and pages.
Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets.
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
@@ -706,7 +720,8 @@ artifact is the role selection.
### What the host gives you
`mount` receives exactly six members, and nothing else is available:
`mount` receives these members, and nothing else is available. `navigation` is optional: a host that
has no addressable pages leaves it out, so feature-detect it.
| Member | What it is |
| --- | --- |
@@ -716,10 +731,12 @@ artifact is the role selection.
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
| `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) |
| `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) |
| `display` | Your display page's URL, or `undefined` when you publish none |
| `navigation` | Where your module is, and how it moves between its own pages and to its Settings page — see [Module navigation](#module-navigation). Optional: absent on the display host |
Navigation, module configuration, organization context, and diagnostics reporting are **not** in the
contract. They need designs that do not exist, and a speculative field frozen into a public contract
is harder to remove than to add.
Module configuration, organization context, and diagnostics reporting are **not** in the contract.
They need designs that do not exist, and a speculative field frozen into a public contract is harder
to remove than to add.
The host **theme** is deliberately not a context member either, and that is not a gap: design tokens
reach your artifact as inherited CSS custom properties rather than as data (ADR 0039). You import
@@ -727,6 +744,90 @@ reach your artifact as inherited CSS custom properties rather than as data (ADR
root. Nothing is negotiated at run time, so there is no token map to keep in sync and no host that
can forget to pass one.
### Module navigation
Your module is a first-class part of the Dashboard
([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). Declare its
icon and primary pages in the manifest (version 9):
```json
"navigation": {
"icon": "monitor-play",
"pages": [
{ "id": "playback", "label": "Playback", "path": "playback" },
{ "id": "timelines", "label": "Timelines", "path": "timelines" },
{ "id": "media", "label": "Media", "path": "media" }
]
}
```
**The icon.**
- `icon` is a Lucide name from `MODULE_NAVIGATION_ICONS` (`@ekklesia/contracts`, re-exported by
`@ekklesia/module-sdk`), a curated set of generic subjects. A name outside it fails validation, so
you'll know at packaging time.
- Omit it and the Dashboard draws its generic module icon.
- Never ship an SVG or a URL for it.
**The pages.**
- `pages` are your primary pages, at most 12, in the order the sidebar shows them beneath your
module, while your module is open.
- `id` is stable and never shown.
- `label` is what a person reads (1–60 characters).
- `path` is relative to your module's root: one to four lowercase kebab-case segments, such as
`timelines` or `media/library`.
- Paths must be unique, and one page may not sit beneath another's path.
- Don't add a tab bar of your own for these. The sidebar is your primary navigation (DESIGN.md
§4.4). Tabs inside one resource are still fine.
**Your routes.**
| Address | What it is |
| --- | --- |
| `/<module-id>` | Your landing view. Never a declared page, so no page has two addresses. To open on, say, Playback, render it at your root and don't declare it. |
| `/<module-id>/<page-path>` | A declared page. |
| `/<module-id>/<page-path>/…` | Anything beneath a declared page: your detail views (`media/sermons/123`), with no manifest entry. |
| anything else under your id | The Dashboard's *Page not found*. Your UI isn't mounted for it. |
A refresh, a bookmark, and Back and Forward all land on the same route.
**Moving around: `context.navigation`.** Never touch `window.location` or `window.history`, and
never build a Dashboard URL yourself:
```tsx
const { navigation } = context; // optional: absent on the display host
navigation?.location(); // { path: "media/sermons/123", page: "media" }
navigation?.subscribe(() => rerender()); // every move: yours, the sidebar's, Back and Forward
navigation?.navigate("timelines"); // or { replace: true } to respell the current entry
const link = navigation?.link("media/sermons/123");
<a href={link.href} onClick={link.onClick}>Sermon</a> // a real link; new tabs still work
```
- **Paths are relative to your root**, and a leading `/` means the same thing.
- **Refused paths throw.** A path that names anything outside your root, or a page you don't
declare, throws `ModuleUiNavigationError` (`MODULE_NAVIGATION_PATH_INVALID` or
`MODULE_NAVIGATION_PATH_UNKNOWN`). There is no way to navigate into another module.
- **You stay mounted.** Your UI is not remounted as it moves between its own pages. Subscribe and
re-render from `location()`, which returns the same object until something moves.
**Your Settings page.** If your module declares `configuration`, its settings live at
`/settings/<module-id>` (ADR 0061). `navigation.settings` opens that page, or `undefined` means this
viewer can't:
```tsx
navigation?.settings?.open(); // or navigation.settings.link() for a real <a>
```
The host carries where your module is as the place to come back to
(`/settings/<module-id>?return=%2F<module-id>%2Ftimelines`). Settings' Back then returns to exactly
that page, after refresh too. You never build or validate the return address: the host does,
accepting only real pages of running modules on this Dashboard.
**Version 8 modules.** A module still on version 8 keeps working unchanged: the generic icon, its root
as its only page, and settings detected from `configuration` as before. Rebuilt against the new SDK,
it also receives `navigation`, with only its root, and can link to its Settings page.
### Never build your own API URL
Ask the host for a path relative to your own module's API root:
+1
View File
@@ -9,6 +9,7 @@ export * from "./module-http.js";
export * from "./module-lifecycle.js";
export * from "./module-manifest.js";
export * from "./module-migration.js";
export * from "./module-navigation.js";
export * from "./module-realtime.js";
export * from "./module-runtime.js";
export * from "./module-schema-metadata.js";
+84 -17
View File
@@ -15,6 +15,11 @@ import {
type ModuleContributionDeclaration,
moduleContributionDeclarationSchema,
} from "./module-contribution.js";
import {
EMPTY_MODULE_NAVIGATION,
type ModuleNavigationDeclaration,
moduleNavigationDeclarationSchema,
} from "./module-navigation.js";
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
@@ -130,9 +135,21 @@ function reportDuplicates(
}
/**
* The one manifest shape Core accepts. Version 7's strict top-level object rejects `database`, so
* the static ABI declaration is an incompatible shape change and the canonical manifest moves to
* `manifestVersion: 8` without silently reinterpreting version 7.
* The manifest shapes Core accepts: **version 8 and version 9**, each read strictly as itself.
*
* Version 7's strict top-level object rejected `database`, so that ABI declaration moved the
* manifest to version 8 (ADR 0050). Version 8's strict object likewise rejects `navigation`, so
* module-declared Dashboard navigation — an icon and primary pages (ADR 0063) — is version 9.
*
* Unlike every earlier step, **version 8 stays accepted beside version 9** (ADR 0063). Released
* modules were packaged, verified, and installed as version 8, and the navigation version adds one
* optional field and changes the meaning of nothing a version 8 document says. Requiring every
* installation's modules to be rebuilt just because Core gained navigation would break working
* churches for no safety gain. The rule that made single-version acceptance matter still holds
* exactly: `manifestVersion` selects one strict schema, so a document is only ever read as the one
* version it declares — a version 8 document that names `navigation` is refused, never quietly
* reinterpreted as version 9 — and nothing ever rewrites a released manifest, its package bytes, or
* its digest.
*
* Configuration is declared **statically, in this inert document**, rather than registered by
* executable module code, for one decisive reason: the platform validates a module's configuration
@@ -141,17 +158,15 @@ function reportDuplicates(
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
* importing a single line of module code, exactly as they always have.
*
* Version 7, and every other version, fails validation: there is no compatibility adapter, no
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
* (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 7
* by version 8 (ADR 0050). One manifest document can therefore never be read as two different
* versions. A version 7 package must be rebuilt; omitting `database` selects the legacy facade.
* Every other version fails validation: there is no conversion and no guessing. Version 7 and
* earlier must be rebuilt; omitting `database` selects the legacy facade.
*/
export const MODULE_MANIFEST_VERSION = 8;
export const SUPPORTED_MODULE_MANIFEST_VERSIONS = Object.freeze([8, 9] as const);
export const moduleManifestSchema = z
.object({
manifestVersion: z.literal(MODULE_MANIFEST_VERSION),
/** The newest manifest version: what a new module declares, and what the template uses. */
export const MODULE_MANIFEST_VERSION = 9;
const manifestFields = {
id: moduleIdSchema,
name: z.string().trim().min(1).max(120),
description: z.string().trim().min(1).max(500).optional(),
@@ -176,9 +191,15 @@ export const moduleManifestSchema = z
* never learns what a contribution renders. A module that contributes nothing omits the field. */
contributions: z.array(moduleContributionDeclarationSchema).default(() => []),
entrypoints: entrypointsSchema,
})
.strict()
.superRefine((manifest, context) => {
};
const manifestFieldsObject = z.object(manifestFields);
/** The fields every supported version shares, after parsing. */
type SharedManifestFields = z.output<typeof manifestFieldsObject>;
/** The cross-field rules every supported version obeys, identically. */
function refineManifest(manifest: SharedManifestFields, context: z.RefinementCtx): void {
reportDuplicates(
manifest.capabilities.map((capability) => capability.id),
["capabilities"],
@@ -305,8 +326,7 @@ export const moduleManifestSchema = z
) {
context.addIssue({
code: "custom",
message:
"The display UI must use a different path from every other configured entrypoint.",
message: "The display UI must use a different path from every other configured entrypoint.",
path: ["entrypoints", "display", "artifact"],
});
}
@@ -352,7 +372,52 @@ export const moduleManifestSchema = z
});
}
}
}
const moduleManifestV8Schema = z
.object({ manifestVersion: z.literal(8), ...manifestFields })
.strict()
.superRefine(refineManifest);
const moduleManifestV9Schema = z
.object({
manifestVersion: z.literal(9),
...manifestFields,
/** The module's Dashboard navigation (ADR 0063): its Lucide icon and primary pages. Omitted,
* the module is drawn with the generic icon and has one page, its root — exactly as a version 8
* module. Only meaningful for a module with a primary UI. */
navigation: moduleNavigationDeclarationSchema.optional(),
})
.strict()
.superRefine((manifest, context) => {
refineManifest(manifest, context);
if (manifest.navigation !== undefined && manifest.entrypoints.ui === undefined) {
context.addIssue({
code: "custom",
message: "Navigation can only be declared by a module with a primary UI (entrypoints.ui).",
path: ["navigation"],
});
}
});
export const moduleManifestSchema = z.discriminatedUnion("manifestVersion", [
moduleManifestV8Schema,
moduleManifestV9Schema,
]);
/**
* The navigation a manifest declares, normalized: version 8 declares none, so it gets no icon and
* no pages, exactly like a version 9 manifest that omits the field. Read through this rather than
* from the manifest, so no consumer branches on the version.
*/
export function moduleManifestNavigation(manifest: {
readonly manifestVersion: number;
readonly navigation?: ModuleNavigationDeclaration | undefined;
}): ModuleNavigationDeclaration {
return manifest.manifestVersion === 9 && manifest.navigation !== undefined
? manifest.navigation
: EMPTY_MODULE_NAVIGATION;
}
export function parseModuleManifest(input: unknown): ModuleManifest {
return moduleManifestSchema.parse(input);
@@ -364,6 +429,8 @@ export function safeParseModuleManifest(input: unknown) {
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
export type ModuleManifestV8 = z.output<typeof moduleManifestV8Schema>;
export type ModuleManifestV9 = z.output<typeof moduleManifestV9Schema>;
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
/** A module manifest's `contributions` array entry — the same `ModuleContributionDeclaration` shape
* Core's publication registry consumes (`packages/contracts/src/module-contribution.ts`). */
+242
View File
@@ -0,0 +1,242 @@
import { z } from "zod";
/**
* A module's Dashboard navigation (manifest v9, ADR 0063): the icon it is drawn with, and the
* meaningful primary pages it contributes to the global sidebar beneath its own entry.
*
* Inert data, like every other manifest field. The host resolves the icon and draws the pages; a
* manifest never supplies executable icon code, a URL, or a route outside its own namespace.
*/
/**
* The Lucide icons a module may declare (DESIGN.md locked decision 94, ADR 0058), by their stable
* Lucide names.
*
* A curated set rather than "any Lucide name", for two reasons. The host imports exactly these by
* name, so the Dashboard never bundles the whole library to honour one manifest. And a manifest is
* validated against it, so a typo is caught when a module is packaged or discovered instead of
* silently drawing the generic icon. Growing the set is an additive platform change: the shell's
* tests hold its icon map equal to this list, so a name cannot be added here without the Dashboard
* being able to draw it.
*
* Generic subjects only. There is no "signage" or "calendar" entry tied to a particular module: a
* module chooses what represents it.
*/
export const MODULE_NAVIGATION_ICONS = Object.freeze([
"activity",
"archive",
"baby",
"bell",
"book-open",
"book-open-text",
"bookmark",
"briefcase",
"building",
"bus",
"calendar",
"calendar-check",
"calendar-days",
"camera",
"car",
"chart-column",
"chart-pie",
"church",
"clipboard-check",
"clipboard-list",
"clock",
"coffee",
"contact",
"credit-card",
"database",
"door-open",
"file-text",
"files",
"film",
"flag",
"folder",
"gift",
"globe",
"graduation-cap",
"hand-coins",
"hand-heart",
"handshake",
"headphones",
"heart",
"heart-handshake",
"house",
"image",
"images",
"inbox",
"key-round",
"landmark",
"layout-grid",
"library",
"list-checks",
"list-todo",
"mail",
"map",
"map-pin",
"megaphone",
"message-circle",
"message-square",
"mic",
"monitor",
"monitor-play",
"music",
"newspaper",
"notebook",
"package",
"palette",
"party-popper",
"phone",
"presentation",
"printer",
"projector",
"radio",
"receipt",
"school",
"send",
"shield",
"shopping-bag",
"sparkles",
"star",
"sun",
"tablet-smartphone",
"tag",
"tent",
"ticket",
"trophy",
"truck",
"tv",
"user-check",
"user-round",
"users",
"users-round",
"utensils",
"video",
"wallet",
"wrench",
] as const);
export type ModuleNavigationIcon = (typeof MODULE_NAVIGATION_ICONS)[number];
const navigationIcons: ReadonlySet<string> = new Set(MODULE_NAVIGATION_ICONS);
export function isModuleNavigationIcon(value: unknown): value is ModuleNavigationIcon {
return typeof value === "string" && navigationIcons.has(value);
}
/** The most primary pages one module may contribute. A sidebar entry with more than this is a
* module that needs its own information architecture, not a longer list. */
export const MODULE_NAVIGATION_PAGES_MAX = 12;
export const MODULE_NAVIGATION_PAGE_LABEL_MAX_LENGTH = 60;
export const MODULE_NAVIGATION_PAGE_PATH_MAX_SEGMENTS = 4;
const PAGE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
const PATH_SEGMENT_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is refused.
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u;
/**
* A page path, relative to the module's own root: one to four lowercase kebab-case segments joined
* by `/` — `timelines`, `media/library`. There is no leading slash, no empty segment, no `.` or
* `..`, no percent-encoding, no query, and no scheme, so a declared path can never escape
* `/<module-id>/` or name anything but a page of this module. The module root itself is not a page
* path: it is the module's landing view (ADR 0063).
*/
export function isModuleNavigationPagePath(value: unknown): value is string {
if (typeof value !== "string" || value.length === 0 || value.length > 120) {
return false;
}
const segments = value.split("/");
return (
segments.length <= MODULE_NAVIGATION_PAGE_PATH_MAX_SEGMENTS &&
segments.every((segment) => PATH_SEGMENT_PATTERN.test(segment))
);
}
export const moduleNavigationPageSchema = z
.object({
/** Stable, never displayed: what the host and the module call this page. Renaming a label never
* changes it, and the module's navigation API reports it as the current page. */
id: z.string().max(64).regex(PAGE_ID_PATTERN),
/** What a person reads in the sidebar. Plain text, trimmed and bounded. */
label: z
.string()
.trim()
.min(1)
.max(MODULE_NAVIGATION_PAGE_LABEL_MAX_LENGTH)
.refine((label) => !CONTROL_CHARACTER_PATTERN.test(label), {
message: "A page label must not contain control characters.",
}),
path: z.string().refine(isModuleNavigationPagePath, {
message:
"A page path must be one to four lowercase kebab-case segments relative to the module root.",
}),
})
.strict();
export const moduleNavigationDeclarationSchema = z
.object({
/** The Lucide icon this module is drawn with. Omitted, the host draws its generic module icon. */
icon: z
.custom<ModuleNavigationIcon>((value) => isModuleNavigationIcon(value), {
message: "The navigation icon must be one of the Lucide names in MODULE_NAVIGATION_ICONS.",
})
.optional(),
/** Primary pages, in the order the sidebar shows them. */
pages: z
.array(moduleNavigationPageSchema)
.max(MODULE_NAVIGATION_PAGES_MAX)
.default(() => []),
})
.strict()
.superRefine((navigation, context) => {
const ids = new Set<string>();
const paths = new Set<string>();
navigation.pages.forEach((page, index) => {
if (ids.has(page.id)) {
context.addIssue({
code: "custom",
message: `${page.id} is declared more than once.`,
path: ["pages", index, "id"],
});
}
ids.add(page.id);
if (paths.has(page.path)) {
context.addIssue({
code: "custom",
message: "Two pages must not declare the same path.",
path: ["pages", index, "path"],
});
}
paths.add(page.path);
});
// A page beneath another would make "which page is open" ambiguous for every path under both,
// and the sidebar's list is flat. Deeper routes belong to a page, not to a second page.
navigation.pages.forEach((page, index) => {
if (
navigation.pages.some((other) => other !== page && page.path.startsWith(`${other.path}/`))
) {
context.addIssue({
code: "custom",
message: "A page path must not lie beneath another declared page's path.",
path: ["pages", index, "path"],
});
}
});
});
export type ModuleNavigationPage = Readonly<z.output<typeof moduleNavigationPageSchema>>;
/** A module's navigation as every consumer reads it, parsed or deeply frozen alike. */
export interface ModuleNavigationDeclaration {
readonly icon?: ModuleNavigationIcon | undefined;
readonly pages: readonly ModuleNavigationPage[];
}
/** What a module declared, as every consumer reads it: no icon and no pages for a module that
* declares no navigation — every manifest-v8 module, and a v9 module that omits the field. */
export const EMPTY_MODULE_NAVIGATION: ModuleNavigationDeclaration = Object.freeze({
pages: Object.freeze([]),
});
@@ -0,0 +1,161 @@
{
"manifestVersion": 8,
"id": "signage",
"name": "Signage",
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
"version": "0.1.5",
"compatibility": {
"platform": "^0.2.0",
"moduleSdk": "^0.2.0"
},
"database": {
"abi": 1
},
"capabilities": [
{
"id": "signage.content-providers",
"version": "1.0.0"
},
{
"id": "signage.slide-renderer",
"version": "1.0.0"
},
{
"id": "signage.playback",
"version": "1.0.0"
},
{
"id": "signage.displays",
"version": "1.0.0"
}
],
"permissions": [
{
"id": "signage.view",
"baseline": "restricted",
"description": "View Signage timelines, images, channels, provider health, and display health."
},
{
"id": "signage.assets.manage",
"baseline": "restricted",
"description": "Upload, rename, expire, and delete Signage's own native images."
},
{
"id": "signage.timelines.manage",
"baseline": "restricted",
"description": "Create, edit, reorder, duplicate, schedule, and delete saved timelines."
},
{
"id": "signage.playback.control",
"baseline": "restricted",
"description": "Activate timelines and operate live playback transport for a channel group."
},
{
"id": "signage.displays.manage",
"baseline": "restricted",
"description": "Create channel groups and audience channels, and assign or rename viewers."
},
{
"id": "signage.display.view",
"baseline": "restricted",
"description": "Render the Signage full-viewport display and report its own health."
}
],
"configuration": [
{
"key": "timezone",
"type": "string",
"default": "America/Chicago",
"minLength": 1,
"maxLength": 64,
"description": "IANA time zone this installation's Signage schedules are written and displayed in."
},
{
"key": "nativeImageDurationSeconds",
"type": "integer",
"default": 7,
"minimum": 1,
"maximum": 3600,
"description": "How long a native Signage image is shown when no slot override applies."
},
{
"key": "crossfadeMilliseconds",
"type": "integer",
"default": 1000,
"minimum": 0,
"maximum": 10000,
"description": "How long a display fades an incoming slide over the outgoing one."
},
{
"key": "defaultBackgroundMode",
"type": "enum",
"values": ["blurred", "solid", "black"],
"default": "blurred",
"description": "How a native image that is not 16:9 is backed when a slot says nothing."
},
{
"key": "defaultBackgroundColor",
"type": "string",
"default": "#000000",
"minLength": 7,
"maxLength": 7,
"description": "The solid background colour used when the background mode is solid."
},
{
"key": "defaultBackgroundDarkened",
"type": "boolean",
"default": false,
"description": "Whether a native image's background is darkened behind the foreground by default."
},
{
"key": "defaultBlurPixels",
"type": "integer",
"default": 20,
"minimum": 0,
"maximum": 120,
"description": "Blur radius applied to a blurred native-image background."
},
{
"key": "providerTimeoutMilliseconds",
"type": "integer",
"default": 2000,
"minimum": 100,
"maximum": 15000,
"description": "How long Signage waits for one content-provider call before treating it as failed."
},
{
"key": "playbackHistoryLimit",
"type": "integer",
"default": 50,
"minimum": 2,
"maximum": 500,
"description": "How many resolved selections per channel group are retained as playback history."
},
{
"key": "viewerHeartbeatWriteSeconds",
"type": "integer",
"default": 30,
"minimum": 5,
"maximum": 600,
"description": "Shortest interval between two persisted heartbeat writes for one steady viewer."
}
],
"storage": {
"buckets": [
{
"id": "native-images",
"description": "Archived original uploads and generated renditions for Signage's native images."
}
]
},
"entrypoints": {
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js",
"migrations": "./drizzle",
"ui": "./dist/ui/index.js",
"display": {
"artifact": "./dist/display/index.js",
"permission": "signage.display.view"
}
}
}
@@ -7,6 +7,7 @@ import {
MODULE_DEPENDENCY_KINDS,
MODULE_MANIFEST_VERSION,
MODULE_RUNTIME_STATES,
SUPPORTED_MODULE_MANIFEST_VERSIONS,
MODULE_STORAGE_BLOCKED_REASON,
packageRelativePathSchema,
parseModuleManifest,
@@ -256,15 +257,17 @@ describe("module manifest contract", () => {
expect(safeParseModuleManifest(version6Manifest).success).toBe(false);
});
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
expect(MODULE_MANIFEST_VERSION).toBe(8);
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 7, 9, "8", 8.5, null]) {
it("accepts exactly versions 8 and 9, each as itself, so no document is read as two versions", () => {
expect(MODULE_MANIFEST_VERSION).toBe(9);
expect(SUPPORTED_MODULE_MANIFEST_VERSIONS).toEqual([8, 9]);
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 7, 10, "8", "9", 8.5, null]) {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
}
expect(
safeParseModuleManifest({ ...validManifest(), manifestVersion: MODULE_MANIFEST_VERSION })
.success,
).toBe(true);
for (const manifestVersion of SUPPORTED_MODULE_MANIFEST_VERSIONS) {
const parsed = safeParseModuleManifest({ ...validManifest(), manifestVersion });
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.manifestVersion).toBe(manifestVersion);
}
});
it("defaults contributions to an empty list for a module that contributes nothing", () => {
@@ -0,0 +1,241 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
EMPTY_MODULE_NAVIGATION,
isModuleNavigationIcon,
isModuleNavigationPagePath,
MODULE_NAVIGATION_ICONS,
MODULE_NAVIGATION_PAGES_MAX,
moduleManifestNavigation,
parseModuleManifest,
safeParseModuleManifest,
} from "../src/index.js";
/**
* Manifest v9's Dashboard navigation (ADR 0063), and the guarantee that released manifest v8
* modules keep working unchanged.
*/
const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
function v9Manifest(navigation?: unknown): Record<string, unknown> {
return {
manifestVersion: 9,
id: "signage",
name: "Signage",
version: "0.2.0",
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
...(navigation === undefined ? {} : { navigation }),
};
}
const pages = [
{ id: "playback", label: "Playback", path: "playback" },
{ id: "timelines", label: "Timelines", path: "timelines" },
{ id: "media", label: "Media", path: "media" },
];
function issues(input: unknown): string[] {
const parsed = safeParseModuleManifest(input);
return parsed.success
? []
: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
}
describe("manifest v9 navigation", () => {
it("accepts a single-page v9 module with no navigation at all: the generic icon, one root page", () => {
const manifest = parseModuleManifest(v9Manifest());
expect(manifest.manifestVersion).toBe(9);
expect(moduleManifestNavigation(manifest)).toEqual({ pages: [] });
expect(moduleManifestNavigation(manifest).icon).toBeUndefined();
});
it("accepts a multi-page module with a Lucide icon, keeping pages in declared order", () => {
const manifest = parseModuleManifest(v9Manifest({ icon: "monitor-play", pages }));
const navigation = moduleManifestNavigation(manifest);
expect(navigation.icon).toBe("monitor-play");
expect(navigation.pages.map((page) => page.id)).toEqual(["playback", "timelines", "media"]);
// Reversed in the manifest, reversed in the result: order is the author's, never re-sorted.
const reversed = moduleManifestNavigation(
parseModuleManifest(v9Manifest({ pages: [...pages].reverse() })),
);
expect(reversed.pages.map((page) => page.id)).toEqual(["media", "timelines", "playback"]);
});
it("accepts an icon on its own, and pages on their own", () => {
expect(moduleManifestNavigation(parseModuleManifest(v9Manifest({ icon: "calendar" })))).toEqual(
{
icon: "calendar",
pages: [],
},
);
expect(
moduleManifestNavigation(parseModuleManifest(v9Manifest({ pages }))).icon,
).toBeUndefined();
});
it("accepts multi-segment page paths", () => {
expect(
issues(v9Manifest({ pages: [{ id: "library", label: "Library", path: "media/library" }] })),
).toEqual([]);
});
it.each([
["an unknown icon", { icon: "not-an-icon" }],
["a PascalCase component name", { icon: "MonitorPlay" }],
["an icon as markup", { icon: "<svg onload=alert(1)>" }],
["an icon as a URL", { icon: "https://cdn.example.test/icon.svg" }],
["an icon as an object", { icon: { svg: "<path/>" } }],
])("refuses %s", (_label, navigation) => {
expect(issues(v9Manifest(navigation))).not.toEqual([]);
});
it.each([
["an empty path", ""],
["the root as a page", "/"],
["a leading slash", "/timelines"],
["a trailing slash", "timelines/"],
["an empty segment", "media//library"],
["traversal", "../calendar"],
["an inner traversal", "media/../../calendar"],
["a dot segment", "./timelines"],
["an absolute URL", "https://evil.example.test/x"],
["a protocol-relative URL", "//evil.example.test"],
["a scheme", "javascript:alert(1)"],
["a query", "timelines?x=1"],
["a fragment", "timelines#top"],
["percent-encoding", "media%2Flibrary"],
["a backslash", "media\\library"],
["uppercase", "Timelines"],
["whitespace", "time lines"],
["too many segments", "a/b/c/d/e"],
])("refuses a page path with %s", (_label, pagePath) => {
expect(isModuleNavigationPagePath(pagePath)).toBe(false);
expect(
issues(v9Manifest({ pages: [{ id: "page", label: "Page", path: pagePath }] })),
).not.toEqual([]);
});
it.each([
["an uppercase id", { id: "Playback", label: "Playback", path: "playback" }],
["an id with a slash", { id: "media/library", label: "Library", path: "library" }],
["an empty label", { id: "playback", label: " ", path: "playback" }],
["an overlong label", { id: "playback", label: "x".repeat(61), path: "playback" }],
[
"a control character in a label",
{ id: "playback", label: "Play\u0007back", path: "playback" },
],
["a line separator in a label", { id: "playback", label: "Play\u2028back", path: "playback" }],
["an unknown page key", { id: "playback", label: "Playback", path: "playback", icon: "tv" }],
["an executable field", { id: "playback", label: "Playback", path: "playback", onClick: "x" }],
])("refuses a page with %s", (_label, page) => {
expect(issues(v9Manifest({ pages: [page] }))).not.toEqual([]);
});
it("refuses duplicate page ids and duplicate page paths, naming the duplicate", () => {
expect(
issues(
v9Manifest({
pages: [
{ id: "media", label: "Media", path: "media" },
{ id: "media", label: "More media", path: "more-media" },
],
}),
),
).toEqual(["navigation.pages.1.id: media is declared more than once."]);
expect(
issues(
v9Manifest({
pages: [
{ id: "media", label: "Media", path: "media" },
{ id: "library", label: "Library", path: "media" },
],
}),
),
).toEqual(["navigation.pages.1.path: Two pages must not declare the same path."]);
});
it("refuses a page beneath another page, which would make the open page ambiguous", () => {
expect(
issues(
v9Manifest({
pages: [
{ id: "media", label: "Media", path: "media" },
{ id: "library", label: "Library", path: "media/library" },
],
}),
),
).toEqual([
"navigation.pages.1.path: A page path must not lie beneath another declared page's path.",
]);
// A shared prefix that is not a parent segment is unrelated.
expect(
issues(
v9Manifest({
pages: [
{ id: "media", label: "Media", path: "media" },
{ id: "media-library", label: "Library", path: "media-library" },
],
}),
),
).toEqual([]);
});
it("bounds the number of pages", () => {
const many = Array.from({ length: MODULE_NAVIGATION_PAGES_MAX + 1 }, (_, index) => ({
id: `page-${index}`,
label: `Page ${index}`,
path: `page-${index}`,
}));
expect(issues(v9Manifest({ pages: many }))).not.toEqual([]);
expect(issues(v9Manifest({ pages: many.slice(0, MODULE_NAVIGATION_PAGES_MAX) }))).toEqual([]);
});
it("refuses navigation for a module with no primary UI, and unknown navigation keys", () => {
const noUi = {
...v9Manifest({ icon: "calendar" }),
entrypoints: { server: "./dist/server/index.js" },
};
expect(issues(noUi)).toEqual([
"navigation: Navigation can only be declared by a module with a primary UI (entrypoints.ui).",
]);
expect(issues(v9Manifest({ groups: ["media"] }))).not.toEqual([]);
expect(issues(v9Manifest({ widgets: [] }))).not.toEqual([]);
});
it("offers a curated, sorted, duplicate-free set of Lucide names, all in Lucide's kebab-case form", () => {
expect([...MODULE_NAVIGATION_ICONS]).toEqual([...MODULE_NAVIGATION_ICONS].sort());
expect(new Set(MODULE_NAVIGATION_ICONS).size).toBe(MODULE_NAVIGATION_ICONS.length);
for (const icon of MODULE_NAVIGATION_ICONS) {
expect(icon).toMatch(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u);
expect(isModuleNavigationIcon(icon)).toBe(true);
}
expect(isModuleNavigationIcon("signage")).toBe(false);
expect(Object.isFrozen(MODULE_NAVIGATION_ICONS)).toBe(true);
});
});
describe("released manifest v8 modules", () => {
it("still accepts the released Signage 0.1.5 manifest exactly as published", async () => {
const published = JSON.parse(
await readFile(path.join(fixtures, "released-v8-signage-0.1.5.module.json"), "utf8"),
) as Record<string, unknown>;
const manifest = parseModuleManifest(published);
expect(manifest.manifestVersion).toBe(8);
expect(manifest.id).toBe("signage");
// The settings Signage declares are what Settings reads (ADR 0061), unchanged by v9.
expect(manifest.configuration.length).toBeGreaterThan(0);
// A v8 module declares no navigation: the generic icon and one root page.
expect(moduleManifestNavigation(manifest)).toBe(EMPTY_MODULE_NAVIGATION);
expect("navigation" in manifest).toBe(false);
});
it("never reads navigation into a version 8 document: one that names it is refused, not upgraded", () => {
const v8WithNavigation = { ...v9Manifest({ icon: "tv" }), manifestVersion: 8 };
expect(safeParseModuleManifest(v8WithNavigation).success).toBe(false);
});
});
+5
View File
@@ -60,6 +60,11 @@ export {
type ModuleConfigurationValue,
type ModuleHttpMethod,
type ModuleManifest,
moduleManifestNavigation,
MODULE_NAVIGATION_ICONS,
type ModuleNavigationDeclaration,
type ModuleNavigationIcon,
type ModuleNavigationPage,
type ModuleRealtimeChannel,
type ModuleRealtimeChannelDefinitionInput,
type ModuleRealtimeChannelRegistrar,
+16 -3
View File
@@ -1,4 +1,5 @@
import type { ModuleUiContributions } from "./contribution.js";
import type { ModuleUiNavigation } from "./navigation.js";
import type { ModuleUiRealtimeClient } from "./realtime.js";
/**
@@ -55,9 +56,10 @@ export type ModuleUiPrincipal =
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
*
* This is deliberately the smallest set that proves the architecture rather than everything a real
* screen will eventually want. Navigation, module configuration, organization/platform context, and
* diagnostics reporting are **not** here: each needs a design that does not exist, and a speculative
* field frozen into a public contract is harder to remove than to add. See ADR 0022.
* screen will eventually want. Module configuration, organization/platform context, and diagnostics
* reporting are **not** here: each needs a design that does not exist, and a speculative field
* frozen into a public contract is harder to remove than to add. See ADR 0022. Navigation arrived
* once its design did (ADR 0063).
*
* The host **theme** is deliberately absent for a different reason. Design tokens reach a module as
* inherited CSS custom properties, not as data (ADR 0039): the host installs the values at its own
@@ -102,6 +104,17 @@ export interface ModuleUiHostContext {
* resolves `api.request`.
*/
readonly contributions: ModuleUiContributions;
/**
* Where this module is and how it moves between its own pages, and to its Settings page
* (ADR 0063). See {@link ModuleUiNavigation}.
*
* **Optional**, so adding it broke no existing host: a context built before navigation existed
* is still a complete context. A host with addressable module pages — Ekklesia's Dashboard —
* supplies it; a host without them — the full-viewport display host, which shows one screen, or a
* standalone host without routes — omits it (or sets it to `undefined`). A module feature-detects
* it: `context.navigation?.navigate("timelines")`.
*/
readonly navigation?: ModuleUiNavigation | undefined;
}
export interface ModuleUiDisplayLocation {
+1
View File
@@ -3,4 +3,5 @@ export * from "./define-module-ui.js";
export * from "./host-context.js";
export * from "./json.js";
export * from "./module-ui.js";
export * from "./navigation.js";
export * from "./realtime.js";
+113
View File
@@ -0,0 +1,113 @@
/**
* Where a mounted module UI is in the Dashboard, and how it moves (ADR 0063).
*
* A **host capability**, not a router. The host owns the address bar, the sidebar, and every
* platform route; a module owns the pages beneath its own root. So a module never touches
* `window.location` or `window.history`, never spells a platform route, and never builds another
* module's address: it names paths **relative to its own root**, and the host maps them to real,
* refreshable, bookmarkable Dashboard addresses — or refuses them.
*
* Paths:
*
* - `""` is the module's root, its landing view.
* - `"timelines"` is a declared page; `"media/sermons/123"` is a route **beneath** the declared page
* `media`, which a module may use for detail views without listing every record in its manifest.
* - A leading `/` means the same thing as none: `"/timelines"` is still this module's `timelines`.
* There is no way to name anything outside this module.
* - Segments are unreserved URL characters only (letters, digits, `-`, `.`, `_`, `~`), never `.` or
* `..`, never empty; no query, fragment, scheme, or percent-encoding.
*
* A path the host would not open — malformed, or outside the module's root and its declared pages —
* is refused with a {@link ModuleUiNavigationError} rather than silently opening something else.
*/
export interface ModuleUiNavigation {
/** Where this module is now. */
location(): ModuleUiLocation;
/** Notifies after the location changes — a link, Back or Forward, or a refresh of the sidebar.
* Returns an unsubscribe. The module stays mounted while it moves between its own pages. */
subscribe(listener: () => void): () => void;
/**
* Opens one of this module's own routes. `replace` corrects the current history entry instead of
* adding one — for a route the module just respelled, not for ordinary navigation.
*
* @throws {ModuleUiNavigationError} for a path this module may not open.
*/
navigate(path: string, options?: ModuleUiNavigateOptions): void;
/**
* A link to one of this module's own routes, for a real `<a>`: `href` is the host's address for it,
* so the link can be opened in a new tab, copied, or bookmarked, and `onClick` opens it in place on
* an ordinary click while leaving a modified or middle click to the browser.
*
* @throws {ModuleUiNavigationError} for a path this module may not open.
*/
link(path: string): ModuleUiLink;
/**
* This module's page in the Dashboard's centralized Settings, or `undefined` when there is none for
* this viewer — the module declares no configuration, or the viewer may not change it. A
* presentation hint like {@link ModuleUiPermissions}: Settings enforces its own authority.
*
* Opening it carries the module's current location as the place to come back to, so Settings'
* Back returns exactly here. The host validates that destination; the module neither builds nor
* checks it.
*/
readonly settings: ModuleUiSettingsLocation | undefined;
}
export interface ModuleUiLocation {
/** This module's route, relative to its root: `""`, `"timelines"`, `"media/sermons/123"`. Never a
* leading or trailing `/`. */
readonly path: string;
/** The declared page that route is, or lies beneath; `undefined` at the module's root. */
readonly page: string | undefined;
}
export interface ModuleUiNavigateOptions {
readonly replace?: boolean;
}
/** What a link's click handler reads: satisfied by a DOM `MouseEvent` and by React's synthetic one. */
export interface ModuleUiLinkClickEvent {
readonly button: number;
readonly metaKey: boolean;
readonly ctrlKey: boolean;
readonly shiftKey: boolean;
readonly altKey: boolean;
readonly defaultPrevented: boolean;
preventDefault(): void;
}
export interface ModuleUiLink {
readonly href: string;
onClick(event: ModuleUiLinkClickEvent): void;
}
export interface ModuleUiSettingsLocation {
/** Opens this module's Settings page, remembering where the module was. */
open(): void;
/** The same, as a link: its `href` includes the place to return to, so it also works in a new tab. */
link(): ModuleUiLink;
}
export type ModuleUiNavigationErrorCode =
/** Not a path this contract can express: absolute, traversing, malformed, or out of bounds. */
| "MODULE_NAVIGATION_PATH_INVALID"
/** A well-formed path that is neither the module's root nor a declared page or beneath one. */
| "MODULE_NAVIGATION_PATH_UNKNOWN";
const navigationErrorMessages: Readonly<Record<ModuleUiNavigationErrorCode, string>> =
Object.freeze({
MODULE_NAVIGATION_PATH_INVALID: "A module asked to open a path outside its own routes.",
MODULE_NAVIGATION_PATH_UNKNOWN: "A module asked to open a page it does not declare.",
});
/** Carries a stable code and a fixed message — never the path the module asked for. */
export class ModuleUiNavigationError extends Error {
readonly code: ModuleUiNavigationErrorCode;
constructor(code: ModuleUiNavigationErrorCode) {
super(navigationErrorMessages[code]);
this.name = "ModuleUiNavigationError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
}
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
type ModuleUiHostContext,
type ModuleUiNavigation,
ModuleUiNavigationError,
NO_MODULE_UI_CONTRIBUTIONS,
} 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("keeps a context built before navigation existed a complete, valid context", () => {
// Exactly the seven members every host supplied before ADR 0063, with no `navigation` member at
// all. This must keep compiling unchanged: `tsc` over this file is the compatibility proof, and
// `satisfies` makes the check exact rather than structural-by-accident.
const preNavigationContext = {
api: { request: async () => new Response(null, { status: 204 }) },
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: undefined,
moduleId: "announcements",
permissions: { has: () => false },
principal: { kind: "anonymous" },
realtime: { subscribe: () => ({ close: () => undefined }) },
} satisfies ModuleUiHostContext;
const asContext: ModuleUiHostContext = preNavigationContext;
expect("navigation" in asContext).toBe(false);
expect(asContext.navigation?.location().path).toBeUndefined();
});
it("lets a host supply navigation, omit it, or set it to undefined, and a module feature-detect it", () => {
const navigation: ModuleUiNavigation = {
link: () => ({ href: "/m/x", onClick: () => undefined }),
location: () => ({ page: "x", path: "x" }),
navigate: () => undefined,
settings: undefined,
subscribe: () => () => undefined,
};
const where = (context: Pick<ModuleUiHostContext, "navigation">): string =>
context.navigation?.location().path ?? "no navigation";
expect(where({ navigation })).toBe("x");
expect(where({})).toBe("no navigation");
expect(where({ navigation: undefined })).toBe("no navigation");
// Present, it must be the real capability — not any object.
// @ts-expect-error a navigation member must satisfy ModuleUiNavigation
expect(where({ navigation: { location: () => "x" } })).toBeDefined();
});
});
+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(
+4
View File
@@ -23,6 +23,10 @@
"./display-assets": {
"types": "./dist/display-assets.d.ts",
"import": "./dist/display-assets.js"
},
"./module-icons": {
"types": "./dist/module-icons.d.ts",
"import": "./dist/module-icons.js"
}
},
"scripts": {
+8 -2
View File
@@ -3,7 +3,6 @@ import type {
CSSProperties,
FocusEventHandler,
KeyboardEventHandler,
MouseEvent,
MouseEventHandler,
ReactElement,
ReactNode,
@@ -57,7 +56,14 @@ const currentStyle: CSSProperties = {
};
/** A click the browser should handle itself: a new tab or window, a download, or another button. */
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
export function isBrowserHandledClick(event: {
readonly defaultPrevented: boolean;
readonly button: number;
readonly metaKey: boolean;
readonly ctrlKey: boolean;
readonly shiftKey: boolean;
readonly altKey: boolean;
}): boolean {
return (
event.defaultPrevented ||
event.button !== 0 ||
+127 -18
View File
@@ -20,7 +20,14 @@ import {
signInWithEmail,
signOutHost,
} from "../auth-protocol.js";
import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js";
import {
type DashboardRoute,
dashboardPath,
parseDashboardPath,
returnTargetFromSearch,
} from "../dashboard-routes.js";
import { resolveModuleRoute } from "../module-navigation.js";
import { parseReturnTarget } from "../return-target.js";
import {
bootstrapHost,
type DashboardNavigationEntry,
@@ -42,6 +49,7 @@ import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { createModuleUiNavigation } from "./module-ui-navigation.js";
import { ResetPasswordForm } from "./ResetPasswordForm.js";
import { ShellLayout } from "./ShellLayout.js";
import { resolveSettingsPage, type SettingsPage, settingsPageTitle } from "./settings-catalog.js";
@@ -51,6 +59,7 @@ import {
SettingsHomePage,
SettingsLoadFailedPage,
SettingsSectionPage,
type SettingsReturn,
SettingsUnavailablePage,
} from "./SettingsPages.js";
import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js";
@@ -194,19 +203,20 @@ function SettingsPageView(props: {
readonly page: SettingsPage;
readonly history: HostHistory;
readonly onRetry: () => void;
readonly returnTo: SettingsReturn;
}): ReactElement {
const { page, history } = props;
const { page, history, returnTo } = props;
switch (page.kind) {
case "home":
return <SettingsHomePage history={history} settings={page.settings} />;
case "section":
return <SettingsSectionPage history={history} section={page.section} />;
return <SettingsSectionPage history={history} returnTo={returnTo} section={page.section} />;
case "discover":
return <DiscoverPage history={history} view={page.view} />;
return <DiscoverPage history={history} returnTo={returnTo} view={page.view} />;
case "module":
return <ModuleSettingsPage history={history} module={page.module} />;
return <ModuleSettingsPage history={history} module={page.module} returnTo={returnTo} />;
case "unavailable":
return <SettingsUnavailablePage history={history} reason={page.reason} />;
return <SettingsUnavailablePage history={history} reason={page.reason} returnTo={returnTo} />;
case "load-failed":
return <SettingsLoadFailedPage onRetry={props.onRetry} />;
case "not-found":
@@ -220,6 +230,74 @@ function routeKey(route: DashboardRoute): string {
return route.kind === "not-found" ? "not-found" : dashboardPath(route);
}
/**
* A `?return=` value the Settings pages may use (ADR 0063), or `undefined`. `parseReturnTarget`
* accepts only a canonical same-origin Dashboard address; a module's address is additionally checked
* against the modules actually running here and the routes each declares, so Back never points at a
* module that is not running or at a page it does not have.
*/
function usableReturnTarget(
raw: string | undefined,
entries: readonly DashboardNavigationEntry[],
): string | undefined {
const destination = parseReturnTarget(raw);
if (destination === undefined) {
return undefined;
}
if (destination.kind !== "module") {
return raw;
}
const entry = entries.find((candidate) => candidate.descriptor.id === destination.moduleId);
return entry !== undefined && resolveModuleRoute(entry.pages, destination.path) !== undefined
? raw
: undefined;
}
/**
* One mounted module UI and the navigation capability it is handed (ADR 0063). The capability is
* created once per mount and reads the address bar live, so it is the same object for the module's
* whole life however often it moves between its pages, and the frame never remounts because of it.
*/
function MountedModule(props: {
readonly bootstrap: HostBootstrap;
readonly config: EkklesiaHostConfig;
readonly entry: DashboardNavigationEntry;
readonly history: HostHistory;
readonly importModule?: ModuleUiImporter;
readonly onAuthorizationResponse: (status: 401 | 403) => void;
}): ReactElement {
const { bootstrap, entry, history } = props;
const settingsRef = useRef(bootstrap.settings);
settingsRef.current = bootstrap.settings;
const moduleId = entry.descriptor.id;
const navigation = useMemo(
() =>
createModuleUiNavigation({
hasSettings: () => {
const settings = settingsRef.current;
return (
settings !== "unavailable" &&
settings.modules.some((module) => module.id === moduleId && module.available)
);
},
history,
moduleId,
pages: entry.pages,
}),
[entry.pages, history, moduleId],
);
return (
<ModuleUiFrame
config={props.config}
descriptor={entry.descriptor}
{...(props.importModule ? { importModule: props.importModule } : {})}
navigation={navigation}
onAuthorizationResponse={props.onAuthorizationResponse}
principal={bootstrap.principal}
/>
);
}
function HostMessage(props: {
readonly children: string;
readonly testId: string;
@@ -299,8 +377,11 @@ export function HostApp(props: HostAppProps): ReactElement {
const sidebarPreference = props.sidebarPreference ?? fallbackPreference;
const appearance = props.appearance ?? fallbackAppearance;
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
const readPathname = useCallback(() => history.pathname(), [history]);
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
// The whole address, so a change of query alone (a new place to return to) re-renders too.
const readAddress = useCallback(() => `${history.pathname()}${history.search()}`, [history]);
const address = useSyncExternalStore(subscribe, readAddress, readAddress);
const pathname = address.split("?")[0] ?? "/";
const search = address.slice(pathname.length);
const route = useMemo(() => parseDashboardPath(pathname), [pathname]);
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
@@ -364,9 +445,11 @@ export function HostApp(props: HostAppProps): ReactElement {
// history entry, so what is bookmarked or shared next is the one canonical address.
useEffect(() => {
if (route.kind !== "not-found" && dashboardPath(route) !== pathname) {
history.replace(dashboardPath(route));
// The query is kept: it is where to return to (ADR 0063), and respelling the path must not
// lose it.
history.replace(`${dashboardPath(route)}${search}`);
}
}, [history, pathname, route]);
}, [history, pathname, route, search]);
useEffect(() => {
mountedRef.current = true;
@@ -522,6 +605,12 @@ export function HostApp(props: HostAppProps): ReactElement {
state.status === "ready" && route.kind === "module"
? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId)
: undefined;
// Which of the open module's own routes this is (ADR 0063): its root, a declared page, or a route
// beneath one. Anything else is the host's not-found, and the module is not mounted for it.
const openModuleRoute =
openEntry !== undefined && route.kind === "module"
? resolveModuleRoute(openEntry.pages, route.path)
: undefined;
const settingsPage =
state.status === "ready" &&
(route.kind === "settings" ||
@@ -542,7 +631,13 @@ export function HostApp(props: HostAppProps): ReactElement {
: route.kind === "overview"
? "Overview"
: route.kind === "module"
? (openEntry?.name ?? "This page isn’t available")
? openEntry === undefined
? "This page isn’t available"
: openModuleRoute === undefined
? "Page not found"
: openModuleRoute.page === undefined
? openEntry.name
: `${openModuleRoute.page.label} · ${openEntry.name}`
: route.kind === "account"
? "Account"
: route.kind === "account-section"
@@ -675,7 +770,16 @@ export function HostApp(props: HostAppProps): ReactElement {
viewport={viewport}
>
{settingsPage !== undefined ? (
<SettingsPageView history={history} onRetry={retryBootstrap} page={settingsPage} />
<SettingsPageView
history={history}
onRetry={retryBootstrap}
page={settingsPage}
returnTo={{
moduleName: (moduleId) =>
allEntries(bootstrap).find((entry) => entry.descriptor.id === moduleId)?.name,
target: usableReturnTarget(returnTargetFromSearch(search), allEntries(bootstrap)),
}}
/>
) : route.kind === "account" || route.kind === "account-section" ? (
bootstrap.account === "unavailable" ? (
<AccountLoadFailedPage onRetry={retryBootstrap} />
@@ -698,17 +802,22 @@ export function HostApp(props: HostAppProps): ReactElement {
) : route.kind === "overview" ? (
<OverviewPage />
) : route.kind === "module" ? (
openEntry ? (
<ModuleUiFrame
openEntry === undefined ? (
// Installed but not running, or not known at all: never mounted, whatever the path.
<ModuleUnavailablePage history={history} />
) : openModuleRoute === undefined ? (
<NotFoundPage history={history} />
) : (
<MountedModule
bootstrap={bootstrap}
config={config}
descriptor={openEntry.descriptor}
entry={openEntry}
history={history}
{...(importModule ? { importModule } : {})}
// Keyed by module identity, not path: moving between its own pages never remounts it.
key={moduleKey(openEntry)}
onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal}
/>
) : (
<ModuleUnavailablePage history={history} />
)
) : (
<NotFoundPage history={history} />
+8 -3
View File
@@ -1,6 +1,6 @@
import { designToken } from "@ekklesia/design-tokens";
import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host";
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
import type { ModuleUiNavigation, ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
import { type ReactElement, useEffect, useRef, useState } from "react";
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../module-context.js";
@@ -11,6 +11,9 @@ export interface ModuleUiFrameProps {
readonly config: EkklesiaHostConfig;
readonly descriptor: HostModuleUiDescriptor;
readonly principal: ModuleUiPrincipal;
/** The module's navigation capability (ADR 0063). Must be stable for the module's life: a new one
* remounts the module. */
readonly navigation?: ModuleUiNavigation;
/** Host-internal session signal. It never enters the module-facing context. */
readonly onAuthorizationResponse?: (status: 401 | 403) => void;
/** Test-only seam, forwarded to the generic loader so a test can hold an import pending and prove
@@ -52,7 +55,8 @@ export interface ModuleUiFrameProps {
export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
const containerRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<FrameStatus>("loading");
const { config, descriptor, importModule, onAuthorizationResponse, principal } = props;
const { config, descriptor, importModule, navigation, onAuthorizationResponse, principal } =
props;
useEffect(() => {
const container = containerRef.current;
@@ -81,6 +85,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
descriptor,
principal,
...(importModule ? { importModule } : {}),
...(navigation ? { navigation } : {}),
...(onAuthorizationResponse
? { onAuthorizationResponse: reportAuthorizationResponse }
: {}),
@@ -114,7 +119,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
// already in progress, and a module that fails to clean up must not take the shell with it.
void mounted?.unmount().catch(() => undefined);
};
}, [config, descriptor, importModule, onAuthorizationResponse, principal]);
}, [config, descriptor, importModule, navigation, onAuthorizationResponse, principal]);
return (
<section
+33 -10
View File
@@ -28,13 +28,31 @@ import { ShellIcon, type ShellIconName } from "./shell-icons.js";
const BACK_TO_SETTINGS: DashboardDestination = { kind: "settings" };
/** Back plus title for a page inside Settings (DESIGN.md §4.8). Back goes to Settings; once the
* module navigation contract carries a return target, a validated one takes precedence. */
function settingsBack(history: HostHistory, returnTarget?: unknown) {
/**
* Where a Settings page was opened from (ADR 0063): the raw `?return=` value, judged by
* `parseReturnTarget` inside `dashboardBack`, and the names of the modules a person might be going
* back to, so Back can say "Back to Signage".
*/
export interface SettingsReturn {
readonly target: unknown;
readonly moduleName: (moduleId: string) => string | undefined;
}
/** Back plus title for a page inside Settings (DESIGN.md §4.8). Back goes to Settings — or, when a
* module opened this page, to exactly the page it was opened from, if that is a real Dashboard page.
* Never the browser's own `history.back()`. */
function settingsBack(history: HostHistory, returnTo?: SettingsReturn) {
return dashboardBack(history, {
fallback: BACK_TO_SETTINGS,
label: (destination) => (destination.kind === "settings" ? "Back to Settings" : "Back"),
returnTarget,
label: (destination) => {
if (destination.kind === "settings") {
return "Back to Settings";
}
const name =
destination.kind === "module" ? returnTo?.moduleName(destination.moduleId) : undefined;
return name === undefined ? "Back" : `Back to ${name}`;
},
returnTarget: returnTo?.target,
});
}
@@ -147,6 +165,7 @@ const SECTION_NOT_YET: Readonly<Record<SettingsPageSection, string>> = Object.fr
export function DiscoverPage(props: {
readonly history: HostHistory;
readonly view: DiscoverView;
readonly returnTo?: SettingsReturn;
}): ReactElement {
const copy = DISCOVER_VIEW_COPY[props.view];
return (
@@ -155,7 +174,7 @@ export function DiscoverPage(props: {
style={{ display: "grid", gap: designToken("spacing.xl") }}
>
<PageHeader
back={settingsBack(props.history)}
back={settingsBack(props.history, props.returnTo)}
description={copy.description}
eyebrow="Discover"
title={copy.title}
@@ -169,6 +188,7 @@ export function DiscoverPage(props: {
export function SettingsSectionPage(props: {
readonly history: HostHistory;
readonly section: SettingsPageSection;
readonly returnTo?: SettingsReturn;
}): ReactElement {
const copy = SETTINGS_SECTION_COPY[props.section];
return (
@@ -177,7 +197,7 @@ export function SettingsSectionPage(props: {
style={{ display: "grid", gap: designToken("spacing.xl") }}
>
<PageHeader
back={settingsBack(props.history)}
back={settingsBack(props.history, props.returnTo)}
description={copy.description}
title={copy.label}
/>
@@ -186,15 +206,17 @@ export function SettingsSectionPage(props: {
);
}
/** `/settings/<module-id>`: a running module's settings, not built yet. */
/** `/settings/<module-id>`: a running module's settings, not built yet. A module opens it through its
* navigation capability, which carries where it was so Back returns exactly there (ADR 0063). */
export function ModuleSettingsPage(props: {
readonly history: HostHistory;
readonly module: DashboardSettingsModule;
readonly returnTo?: SettingsReturn;
}): ReactElement {
return (
<div data-testid="module-settings" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<PageHeader
back={settingsBack(props.history)}
back={settingsBack(props.history, props.returnTo)}
description={`How ${props.module.name} works for your church.`}
eyebrow="Settings"
title={props.module.name}
@@ -217,12 +239,13 @@ export type SettingsUnavailableReason = "no-access" | "module-disabled";
export function SettingsUnavailablePage(props: {
readonly history: HostHistory;
readonly reason: SettingsUnavailableReason;
readonly returnTo?: SettingsReturn;
}): ReactElement {
const disabled = props.reason === "module-disabled";
return (
<div data-testid={disabled ? "settings-module-unavailable" : "settings-no-access"}>
<PageHeader
back={settingsBack(props.history)}
back={settingsBack(props.history, props.returnTo)}
description={
disabled
? "The module these settings belong to is turned off. Its settings come back when it is turned on again."
+61 -10
View File
@@ -1,12 +1,14 @@
import { designToken } from "@ekklesia/design-tokens";
import type { CSSProperties, ReactElement } from "react";
import { type CSSProperties, Fragment, type ReactElement } from "react";
import type { DashboardDestination, DashboardRoute } from "../dashboard-routes.js";
import type {
DashboardNavigation,
DashboardNavigationEntry,
DashboardSettings,
} from "../host-protocol.js";
import { moduleDestination, resolveModuleRoute } from "../module-navigation.js";
import { DashboardLink } from "./DashboardLink.js";
import { ModuleIcon } from "./module-icon.js";
import type { HostHistory } from "./host-history.js";
import { useNavTooltip } from "./NavTooltip.js";
import { DISCOVER_VIEWS } from "../dashboard-routes.js";
@@ -56,7 +58,8 @@ export function ShellBrand(props: { readonly collapsed: boolean }): ReactElement
function NavigationItem(props: {
readonly destination: DashboardDestination;
readonly label: string;
readonly icon: ShellIconName;
/** A shell icon by name, a module's own icon, or none — a module's pages are text beneath it. */
readonly icon?: ShellIconName | ReactElement;
readonly current: boolean;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
@@ -77,7 +80,11 @@ function NavigationItem(props: {
to={props.destination}
{...triggerProps}
>
{props.icon === undefined ? undefined : typeof props.icon === "string" ? (
<ShellIcon name={props.icon} />
) : (
props.icon
)}
<span
className={
props.presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
@@ -92,6 +99,17 @@ function NavigationItem(props: {
);
}
/**
* The modules in one navigation group, each with its own icon (manifest v9, ADR 0063).
*
* While a module is open, its declared primary pages appear beneath it, in its declared order
* (DESIGN.md §4.4) — the same in-place expansion Settings uses (ADR 0061), so the rest of the
* navigation stays where it was and one item carries `aria-current`: the open page, or the module
* itself on its landing view. A route beneath a page (a detail view) marks that page. Collapsed,
* the sidebar shows icons only, so the pages stay hidden and the module item is the current one; its
* tooltip and accessible name are the module's name. A module that declares no pages — every
* manifest v8 module — is one item, exactly as before.
*/
function ModuleItems(props: {
readonly entries: readonly DashboardNavigationEntry[];
readonly route: DashboardRoute;
@@ -100,6 +118,8 @@ function ModuleItems(props: {
readonly onNavigate?: () => void;
readonly labelledBy?: string;
}): ReactElement {
const { route, presentation } = props;
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
return (
<ul
aria-labelledby={props.labelledBy}
@@ -108,20 +128,51 @@ function ModuleItems(props: {
>
{props.entries.map((entry) => {
const moduleId = entry.descriptor.id;
const resolution =
route.kind === "module" && route.moduleId === moduleId
? resolveModuleRoute(entry.pages, route.path)
: undefined;
const showPages =
resolution !== undefined && entry.pages.length > 0 && !presentation.collapsed;
const openPage = resolution?.page;
return (
<Fragment key={`${moduleId}@${entry.descriptor.version}`}>
<NavigationItem
current={props.route.kind === "module" && props.route.moduleId === moduleId}
destination={{ kind: "module", moduleId }}
current={resolution !== undefined && (openPage === undefined || !showPages)}
destination={moduleDestination(moduleId)}
history={props.history}
// Every module gets the one generic icon until modules can declare their own
// (ADR 0058); there is no table of known modules.
icon="module"
key={`${moduleId}@${entry.descriptor.version}`}
icon={<ModuleIcon icon={entry.icon} />}
label={entry.name}
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
presentation={props.presentation}
{...onNavigate}
presentation={presentation}
testId={`select-${moduleId}`}
/>
{showPages ? (
<li>
<ul
aria-label={entry.name}
className="ek-shell-nav__group ek-shell-nav__nested"
data-testid={`module-subnavigation-${moduleId}`}
// The shared nested indentation (as Settings uses) comes from the class; only the
// list marker is reset here.
style={{ listStyle: "none" }}
>
{entry.pages.map((page) => (
<NavigationItem
current={openPage?.id === page.id}
destination={moduleDestination(moduleId, page.path)}
history={props.history}
key={page.id}
label={page.label}
{...onNavigate}
presentation={presentation}
testId={`select-${moduleId}-${page.id}`}
/>
))}
</ul>
</li>
) : undefined}
</Fragment>
);
})}
</ul>
+14 -4
View File
@@ -17,7 +17,11 @@
export interface HostHistory {
/** The current path, without query or fragment. */
pathname(): string;
/** Opens a new page, adding a history entry. Opening the page already showing adds none. */
/** The current query, `""` or starting with `?`. The shell reads exactly one thing from it: the
* place to return to (`?return=…`, ADR 0063). */
search(): string;
/** Opens a new page, adding a history entry. `path` may carry a query. Opening the address already
* showing adds none. */
push(path: string): void;
/** Corrects the current entry in place, adding none — for a non-canonical spelling of the same
* page. */
@@ -28,7 +32,7 @@ export interface HostHistory {
/** The parts of a `Window` the browser history needs, passed in by the entry point. */
export interface HostHistoryWindow {
readonly location: Pick<Location, "pathname">;
readonly location: Pick<Location, "pathname" | "search">;
readonly history: Pick<History, "pushState" | "replaceState">;
addEventListener(type: "popstate", listener: () => void): void;
removeEventListener(type: "popstate", listener: () => void): void;
@@ -47,8 +51,9 @@ export function createBrowserHostHistory(view: HostHistoryWindow): HostHistory {
};
return {
pathname: () => view.location.pathname,
search: () => view.location.search,
push(path) {
if (path === view.location.pathname) {
if (path === `${view.location.pathname}${view.location.search}`) {
return;
}
view.history.pushState(null, "", path);
@@ -98,7 +103,12 @@ export function createMemoryHostHistory(initialPath = "/"): MemoryHostHistory {
get index() {
return index;
},
pathname: () => entries[index] ?? "/",
pathname: () => (entries[index] ?? "/").split("?")[0] ?? "/",
search: () => {
const entry = entries[index] ?? "/";
const query = entry.indexOf("?");
return query === -1 ? "" : entry.slice(query);
},
push(path) {
if (path === entries[index]) {
return;
+228
View File
@@ -0,0 +1,228 @@
import {
Activity,
Archive,
Baby,
Bell,
BookOpen,
BookOpenText,
Bookmark,
Briefcase,
Building,
Bus,
Calendar,
CalendarCheck,
CalendarDays,
Camera,
Car,
ChartColumn,
ChartPie,
Church,
ClipboardCheck,
ClipboardList,
Clock,
Coffee,
Contact,
CreditCard,
Database,
DoorOpen,
FileText,
Files,
Film,
Flag,
Folder,
Gift,
Globe,
GraduationCap,
HandCoins,
HandHeart,
Handshake,
Headphones,
Heart,
HeartHandshake,
House,
Image,
Images,
Inbox,
KeyRound,
Landmark,
LayoutGrid,
Library,
ListChecks,
ListTodo,
Mail,
Map as MapIcon,
MapPin,
Megaphone,
MessageCircle,
MessageSquare,
Mic,
Monitor,
MonitorPlay,
Music,
Newspaper,
Notebook,
Package,
Palette,
PartyPopper,
Phone,
Presentation,
Printer,
Projector,
Radio,
Receipt,
School,
Send,
Shield,
ShoppingBag,
Sparkles,
Star,
Sun,
TabletSmartphone,
Tag,
Tent,
Ticket,
Trophy,
Truck,
Tv,
UserCheck,
UserRound,
Users,
UsersRound,
Utensils,
Video,
Wallet,
Wrench,
type LucideIcon,
} from "lucide-react";
import type { ReactElement } from "react";
import { type DashboardModuleIconName, isDashboardModuleIconName } from "../module-icons.js";
import { ShellIcon } from "./shell-icons.js";
/**
* The Lucide components for the icons a module may declare (manifest v9, ADR 0063), by name.
*
* Each icon is imported by name, so the Dashboard bundles exactly this curated set and never the
* whole library. Typed against `DASHBOARD_MODULE_ICON_NAMES`, so the map can neither miss a name
* the platform accepts nor carry one it does not; the shell's tests prove each entry is a real Lucide
* component.
*/
export const MODULE_ICON_COMPONENTS: Readonly<Record<DashboardModuleIconName, LucideIcon>> =
Object.freeze({
activity: Activity,
archive: Archive,
baby: Baby,
bell: Bell,
"book-open": BookOpen,
"book-open-text": BookOpenText,
bookmark: Bookmark,
briefcase: Briefcase,
building: Building,
bus: Bus,
calendar: Calendar,
"calendar-check": CalendarCheck,
"calendar-days": CalendarDays,
camera: Camera,
car: Car,
"chart-column": ChartColumn,
"chart-pie": ChartPie,
church: Church,
"clipboard-check": ClipboardCheck,
"clipboard-list": ClipboardList,
clock: Clock,
coffee: Coffee,
contact: Contact,
"credit-card": CreditCard,
database: Database,
"door-open": DoorOpen,
"file-text": FileText,
files: Files,
film: Film,
flag: Flag,
folder: Folder,
gift: Gift,
globe: Globe,
"graduation-cap": GraduationCap,
"hand-coins": HandCoins,
"hand-heart": HandHeart,
handshake: Handshake,
headphones: Headphones,
heart: Heart,
"heart-handshake": HeartHandshake,
house: House,
image: Image,
images: Images,
inbox: Inbox,
"key-round": KeyRound,
landmark: Landmark,
"layout-grid": LayoutGrid,
library: Library,
"list-checks": ListChecks,
"list-todo": ListTodo,
mail: Mail,
map: MapIcon,
"map-pin": MapPin,
megaphone: Megaphone,
"message-circle": MessageCircle,
"message-square": MessageSquare,
mic: Mic,
monitor: Monitor,
"monitor-play": MonitorPlay,
music: Music,
newspaper: Newspaper,
notebook: Notebook,
package: Package,
palette: Palette,
"party-popper": PartyPopper,
phone: Phone,
presentation: Presentation,
printer: Printer,
projector: Projector,
radio: Radio,
receipt: Receipt,
school: School,
send: Send,
shield: Shield,
"shopping-bag": ShoppingBag,
sparkles: Sparkles,
star: Star,
sun: Sun,
"tablet-smartphone": TabletSmartphone,
tag: Tag,
tent: Tent,
ticket: Ticket,
trophy: Trophy,
truck: Truck,
tv: Tv,
"user-check": UserCheck,
"user-round": UserRound,
users: Users,
"users-round": UsersRound,
utensils: Utensils,
video: Video,
wallet: Wallet,
wrench: Wrench,
});
/**
* A module's icon: the Lucide icon it declared, or the generic module icon when it declared none or
* one this Dashboard cannot draw. Decorative, like every navigation icon (ADR 0058): the module's
* name is always beside it or in its control's accessible name.
*/
export function ModuleIcon(props: {
readonly icon: string | undefined;
readonly size?: number;
}): ReactElement {
if (!isDashboardModuleIconName(props.icon)) {
return <ShellIcon name="module" {...(props.size === undefined ? {} : { size: props.size })} />;
}
const Icon = MODULE_ICON_COMPONENTS[props.icon];
return (
<Icon
aria-hidden="true"
data-icon={`module:${props.icon}`}
focusable="false"
size={props.size ?? 20}
strokeWidth={1.75}
/>
);
}
@@ -0,0 +1,153 @@
import {
type ModuleUiLink,
type ModuleUiLinkClickEvent,
type ModuleUiLocation,
type ModuleUiNavigation,
ModuleUiNavigationError,
type ModuleUiSettingsLocation,
} from "@ekklesia/module-ui-sdk";
import {
type DashboardDestination,
dashboardPath,
dashboardPathReturningTo,
parseDashboardPath,
} from "../dashboard-routes.js";
import {
type DashboardModulePage,
moduleDestination,
moduleRouteFor,
resolveModuleRoute,
} from "../module-navigation.js";
import { isBrowserHandledClick } from "./DashboardLink.js";
import type { HostHistory } from "./host-history.js";
export interface CreateModuleUiNavigationOptions {
readonly moduleId: string;
/** The module's declared primary pages; empty for a single-page module. */
readonly pages: readonly DashboardModulePage[];
readonly history: HostHistory;
/** Whether this module has a Settings page this viewer may open (ADR 0061) — read each time it is
* asked, so a Settings view that finishes loading after the module mounted is still honoured. */
readonly hasSettings: () => boolean;
}
/**
* The Dashboard's implementation of a module's navigation capability (ADR 0063).
*
* A narrow handle over the shell's own history, scoped to one module. It can only ever produce
* addresses beneath `/<module-id>/` — a declared page or a route beneath one — or this module's
* Settings page; it hands the module no history object, no router, and no way to name another
* module or platform page. The module stays mounted as it moves between its pages: the shell keys
* the mounted frame by module identity, not by path, and this reports each move to subscribers.
*/
export function createModuleUiNavigation(
options: CreateModuleUiNavigationOptions,
): ModuleUiNavigation {
const { history, moduleId, pages } = options;
const currentPath = (): string | undefined => {
const route = parseDashboardPath(history.pathname());
return route.kind === "module" && route.moduleId === moduleId ? route.path : undefined;
};
const locate = (path: string): ModuleUiLocation =>
Object.freeze({ page: resolveModuleRoute(pages, path)?.page?.id, path });
// One frozen snapshot per distinct location, so `location()` is stable between moves — what a
// React external-store read needs.
let snapshot = locate(currentPath() ?? "");
const refresh = (): boolean => {
const path = currentPath();
if (path === undefined || path === snapshot.path) {
return false;
}
snapshot = locate(path);
return true;
};
// One subscription to the address bar, shared by every listener the module registers, and the
// path they were last told about — so every listener hears every move exactly once, however
// often the module reads `location()` in between.
const listeners = new Set<() => void>();
let stopWatching: (() => void) | undefined;
let notifiedPath = snapshot.path;
const notify = (): void => {
refresh();
if (snapshot.path === notifiedPath) {
return;
}
notifiedPath = snapshot.path;
for (const listener of [...listeners]) {
listener();
}
};
const routeFor = (path: unknown): string => {
const route = moduleRouteFor(pages, path);
if (route === "invalid") {
throw new ModuleUiNavigationError("MODULE_NAVIGATION_PATH_INVALID");
}
if (route === "unknown") {
throw new ModuleUiNavigationError("MODULE_NAVIGATION_PATH_UNKNOWN");
}
return dashboardPath(moduleDestination(moduleId, route.path));
};
const linkTo = (href: string): ModuleUiLink =>
Object.freeze({
href,
onClick(event: ModuleUiLinkClickEvent) {
if (isBrowserHandledClick(event)) {
return;
}
event.preventDefault();
history.push(href);
},
});
const settingsDestination: DashboardDestination = { kind: "module-settings", moduleId };
const settingsHref = (): string => {
// Read the address bar now: a module with no listener of its own may have moved since.
refresh();
return dashboardPathReturningTo(
settingsDestination,
moduleDestination(moduleId, snapshot.path),
);
};
const settings: ModuleUiSettingsLocation = Object.freeze({
link: () => linkTo(settingsHref()),
open: () => history.push(settingsHref()),
});
return Object.freeze({
link: (path: string) => linkTo(routeFor(path)),
location: () => {
refresh();
return snapshot;
},
navigate(path: string, navigateOptions?: { readonly replace?: boolean }) {
const href = routeFor(path);
if (navigateOptions?.replace === true) {
history.replace(href);
} else {
history.push(href);
}
},
get settings() {
return options.hasSettings() ? settings : undefined;
},
subscribe(listener: () => void) {
listeners.add(listener);
if (listeners.size === 1) {
stopWatching = history.subscribe(notify);
}
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
stopWatching?.();
stopWatching = undefined;
}
};
},
});
}
+92 -10
View File
@@ -15,7 +15,8 @@
* /settings/<module-id> a module's settings (and later its own deeper settings pages)
* /account your account
* /account/<section> a personal settings page: appearance
* /<module-id>[/…] a module's page, and later its own sub-pages
* /<module-id> a module's landing view
* /<module-id>/<path> one of its declared pages, or a route beneath one (ADR 0063)
* ```
*
* The server serves the page at `/<module-id>` only for a module that is actually installed with a
@@ -31,6 +32,15 @@
*/
export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const);
/**
* The host's top-level namespaces that are **not** Dashboard pages: the API, served assets, and the
* display host. No address beneath them is ever a Dashboard page or module route, so nothing — a
* typed URL, a link, or a return target (ADR 0063) — can make the Dashboard treat `/api/…` as a page
* to open or link to. Together with {@link DASHBOARD_PLATFORM_PAGES} these are exactly Core's
* reserved top-level names (`RESERVED_TOP_LEVEL_NAMES`); `apps/api` tests hold the lists together.
*/
export const HOST_NAMESPACES = Object.freeze(["api", "assets", "display"] as const);
/**
* The platform's own Settings sections, in their navigation order (ADR 0061). Every other name under
* `/settings/` is a module's settings, at `/settings/<module-id>`. Module lifecycle — finding,
@@ -72,7 +82,10 @@ export type DiscoverView = (typeof DISCOVER_VIEWS)[number];
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
export type DashboardRoute =
| { readonly kind: "overview" }
| { readonly kind: "module"; readonly moduleId: string }
/** `path` is relative to the module's root: `""` for the landing view, `"timelines"`,
* `"media/sermons/123"`. Whether the module has such a page is the module's declaration's to say,
* so the shell resolves it against the navigation, not here (ADR 0063). */
| { readonly kind: "module"; readonly moduleId: string; readonly path: string }
| { readonly kind: "settings" }
| { readonly kind: "settings-section"; readonly section: SettingsPageSection }
| { readonly kind: "discover"; readonly view: DiscoverView }
@@ -85,6 +98,7 @@ export type DashboardRoute =
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
const hostNamespaces: ReadonlySet<string> = new Set(HOST_NAMESPACES);
const settingsSections: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
const discoverViews: ReadonlySet<string> = new Set(DISCOVER_VIEWS);
@@ -102,6 +116,40 @@ function isAccountSection(value: string): value is AccountSection {
return accountSections.has(value);
}
/** The most segments, and characters, a module route may have beneath its root. Generous for detail
* routes (`media/sermons/123`), bounded so a URL cannot carry anything else. */
export const MODULE_ROUTE_MAX_SEGMENTS = 8;
export const MODULE_ROUTE_MAX_LENGTH = 512;
const MODULE_ROUTE_SEGMENT_PATTERN = /^[A-Za-z0-9._~-]+$/u;
/**
* Reads a module-relative route (ADR 0063): `""` for the root, otherwise segments of unreserved URL
* characters joined by `/`, none of them `.` or `..`. One leading `/` is the root's own spelling and
* is dropped; nothing else is normalized. Anything else — a scheme, a query, a fragment, an empty
* segment, percent-encoding, a backslash, traversal — is `undefined`, so a route can only ever name a
* place inside its own module.
*/
export function parseModuleRoute(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const path = value.startsWith("/") ? value.slice(1) : value;
if (path === "") {
return "";
}
if (path.length > MODULE_ROUTE_MAX_LENGTH) {
return undefined;
}
const segments = path.split("/");
return segments.length <= MODULE_ROUTE_MAX_SEGMENTS &&
segments.every(
(segment) =>
segment !== "." && segment !== ".." && MODULE_ROUTE_SEGMENT_PATTERN.test(segment),
)
? path
: undefined;
}
function decodeSegment(segment: string): string | undefined {
try {
const decoded = decodeURIComponent(segment);
@@ -118,9 +166,10 @@ function decodeSegment(segment: string): string | undefined {
* decoded here, never validated against a pattern: the shell compares it with the navigation the
* server resolved, and an id that matches no entry is an unavailable module, not a malformed URL.
*
* Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`), which
* need a contract that does not exist yet, so it is `not-found` for now rather than silently showing
* the module's first page. Settings and Account pages are likewise `not-found` until they exist. One
* Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`, ADR
* 0063): read here as a module-relative route, and resolved by the shell against the pages the module
* declares, so a route no page owns is `not-found` there rather than silently showing the module's
* first page. Settings and Account pages beyond those that exist are `not-found` too. One
* trailing slash is tolerated, as a person typing a URL expects; the shell then replaces it with the
* canonical spelling.
*/
@@ -140,14 +189,18 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
if (first === "account") {
return parseAccountPath(meaningful.slice(1));
}
if (first === undefined || platformPages.has(first)) {
if (first === undefined || platformPages.has(first) || hostNamespaces.has(first)) {
return { kind: "not-found" };
}
if (meaningful.length === 1) {
return { kind: "module", moduleId: first };
}
const rest = meaningful.slice(1);
// `/signage//` is not `/signage/`: only one trailing slash is tolerated, and an empty segment
// anywhere beneath a module is not a route.
if (rest.some((segment) => segment === "")) {
return { kind: "not-found" };
}
const path = parseModuleRoute(rest.join("/"));
return path === undefined ? { kind: "not-found" } : { kind: "module", moduleId: first, path };
}
/**
* Reads what follows `/settings`. One more segment is a platform section or, failing that, a
@@ -200,7 +253,9 @@ export function dashboardPath(route: DashboardDestination): string {
case "overview":
return "/";
case "module":
return `/${encodeURIComponent(route.moduleId)}`;
return route.path === ""
? `/${encodeURIComponent(route.moduleId)}`
: `/${encodeURIComponent(route.moduleId)}/${route.path}`;
case "settings":
return "/settings";
case "settings-section":
@@ -215,3 +270,30 @@ export function dashboardPath(route: DashboardDestination): string {
return `/account/${route.section}`;
}
}
/**
* The one carrier for "where to come back to" (ADR 0063): a `return` query parameter on the page
* that was opened, holding the Dashboard address of the page that opened it —
* `/settings/signage?return=%2Fsignage%2Ftimelines`. A query rather than history state, so it
* survives a refresh and a link opened in a new tab; it is only ever read through
* `parseReturnTarget`, so nothing but a real same-origin Dashboard page is ever used from it.
*/
export const RETURN_TARGET_PARAMETER = "return";
/** `destination`'s address, carrying `returnTo` as the place to come back to. */
export function dashboardPathReturningTo(
destination: DashboardDestination,
returnTo: DashboardDestination,
): string {
const query = new URLSearchParams({ [RETURN_TARGET_PARAMETER]: dashboardPath(returnTo) });
return `${dashboardPath(destination)}?${query.toString()}`;
}
/** The raw, unvalidated return target in a query string, for `parseReturnTarget` to judge. */
export function returnTargetFromSearch(search: string): string | undefined {
try {
return new URLSearchParams(search).get(RETURN_TARGET_PARAMETER) ?? undefined;
} catch {
return undefined;
}
}
+50 -4
View File
@@ -6,6 +6,11 @@ import {
} from "@ekklesia/design-tokens";
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js";
import {
type DashboardModulePage,
isDashboardModulePage,
isModuleIconName,
} from "./module-navigation.js";
/**
* How this shell reaches the Ekklesia API.
@@ -640,6 +645,11 @@ export function moduleDisplayDescriptorPath(moduleId: string): string {
export interface DashboardNavigationEntry {
readonly name: string;
readonly descriptor: HostModuleUiDescriptor;
/** The Lucide name the module declared (manifest v9, ADR 0063), or `undefined` for the generic
* module icon. */
readonly icon: string | undefined;
/** Its primary pages, in its declared order; empty for a single-page module. */
readonly pages: readonly DashboardModulePage[];
}
export interface DashboardNavigationGroup {
@@ -662,12 +672,48 @@ export interface DashboardNavigation {
readonly ungrouped: readonly DashboardNavigationEntry[];
}
function parseNavigationEntry(entry: unknown): DashboardNavigationEntry {
const name = nestedString(entry, "name");
if (name === undefined || !isRecord(entry)) {
/** Strict: the module's declared icon (`null` for none) and pages, each in the manifest's grammar,
* with no repeated page id or path. */
function parseModuleNavigation(value: unknown): {
readonly icon: string | undefined;
readonly pages: readonly DashboardModulePage[];
} {
if (
!hasExactKeys(value, ["icon", "pages"]) ||
(value.icon !== null && !isModuleIconName(value.icon)) ||
!Array.isArray(value.pages) ||
value.pages.length > 12 ||
!value.pages.every(isDashboardModulePage)
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ descriptor: parseDescriptor(entry.module), name });
const pages = value.pages as readonly DashboardModulePage[];
if (
new Set(pages.map((page) => page.id)).size !== pages.length ||
new Set(pages.map((page) => page.path)).size !== pages.length
) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
icon: value.icon === null ? undefined : (value.icon as string),
pages: Object.freeze(
pages.map((page) => Object.freeze({ id: page.id, label: page.label, path: page.path })),
),
});
}
function parseNavigationEntry(entry: unknown): DashboardNavigationEntry {
const name = nestedString(entry, "name");
if (name === undefined || !hasExactKeys(entry, ["module", "name", "navigation"])) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
const navigation = parseModuleNavigation(entry.navigation);
return Object.freeze({
descriptor: parseDescriptor(entry.module),
icon: navigation.icon,
name,
pages: navigation.pages,
});
}
/** Strict, total validation, exactly like the inventory parser: a malformed group or entry fails
+7
View File
@@ -5,6 +5,7 @@ import {
type ModuleUiApiClient,
type ModuleUiContributions,
type ModuleUiHostContext,
type ModuleUiNavigation,
type ModuleUiPrincipal,
type ModuleUiRealtimeClient,
type ModuleUiRealtimeError,
@@ -272,6 +273,9 @@ export interface CreateModuleUiContextOptions {
/** Test-only seam, forwarded to the generic contribution loader so a test can drive a contributed
* renderer without a real network. Production composition leaves it unset. */
readonly importModule?: (url: string) => Promise<unknown>;
/** The module's navigation capability (ADR 0063), from a host whose pages are addressable — the
* Dashboard. Omitted, the context omits the member, as it did before navigation existed. */
readonly navigation?: ModuleUiNavigation;
}
/**
@@ -356,6 +360,9 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo
: Object.freeze({ url: resolveApiUrl(options.config, options.descriptor.displayPageUrl) }),
moduleId: options.descriptor.id,
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
// Only a host with addressable module pages supplies navigation (ADR 0063); the display host
// shows one screen and omits the member entirely, exactly as before navigation existed.
...(options.navigation === undefined ? {} : { navigation: options.navigation }),
principal: options.principal,
realtime: createModuleRealtimeClient(options.config, options.descriptor),
});
+115
View File
@@ -0,0 +1,115 @@
/**
* The Lucide icons the Dashboard can draw for a module (manifest v9, ADR 0063), by Lucide name.
*
* The same curated set `@ekklesia/contracts` validates manifests against (`MODULE_NAVIGATION_ICONS`).
* A surface may not import that package — its type graph is the server's (ADR 0024) — so the list is
* repeated here, and `apps/api` tests hold the two equal: the platform cannot accept an icon the
* Dashboard cannot draw. The browser's map from these names to Lucide components is
* `browser/module-icon.tsx`, which imports exactly these icons and no others.
*
* Framework-free and exported on its own subpath, so the server's parity test reads the list without
* importing React.
*/
export const DASHBOARD_MODULE_ICON_NAMES = Object.freeze([
"activity",
"archive",
"baby",
"bell",
"book-open",
"book-open-text",
"bookmark",
"briefcase",
"building",
"bus",
"calendar",
"calendar-check",
"calendar-days",
"camera",
"car",
"chart-column",
"chart-pie",
"church",
"clipboard-check",
"clipboard-list",
"clock",
"coffee",
"contact",
"credit-card",
"database",
"door-open",
"file-text",
"files",
"film",
"flag",
"folder",
"gift",
"globe",
"graduation-cap",
"hand-coins",
"hand-heart",
"handshake",
"headphones",
"heart",
"heart-handshake",
"house",
"image",
"images",
"inbox",
"key-round",
"landmark",
"layout-grid",
"library",
"list-checks",
"list-todo",
"mail",
"map",
"map-pin",
"megaphone",
"message-circle",
"message-square",
"mic",
"monitor",
"monitor-play",
"music",
"newspaper",
"notebook",
"package",
"palette",
"party-popper",
"phone",
"presentation",
"printer",
"projector",
"radio",
"receipt",
"school",
"send",
"shield",
"shopping-bag",
"sparkles",
"star",
"sun",
"tablet-smartphone",
"tag",
"tent",
"ticket",
"trophy",
"truck",
"tv",
"user-check",
"user-round",
"users",
"users-round",
"utensils",
"video",
"wallet",
"wrench",
] as const);
export type DashboardModuleIconName = (typeof DASHBOARD_MODULE_ICON_NAMES)[number];
const iconNames: ReadonlySet<string> = new Set(DASHBOARD_MODULE_ICON_NAMES);
export function isDashboardModuleIconName(value: unknown): value is DashboardModuleIconName {
return typeof value === "string" && iconNames.has(value);
}
+99
View File
@@ -0,0 +1,99 @@
import { type DashboardDestination, parseModuleRoute } from "./dashboard-routes.js";
/**
* Resolving a module's own routes (ADR 0063), free of React and browser globals.
*
* A module declares its primary pages; the shell opens:
*
* - its **root**, `/<module-id>`, which is its landing view and never itself a declared page, so no
* page has two addresses;
* - each **declared page**, `/<module-id>/<page-path>`;
* - any route **beneath** a declared page, `/<module-id>/<page-path>/…`, which the module owns —
* detail views (`media/sermons/123`) need no manifest entry and no sidebar entry.
*
* Everything else under a module is the host's `not-found`, and the module is not mounted for it:
* an undeclared path never silently opens some other page. A module that declares no pages — every
* manifest v8 module — has its root and nothing else, exactly as before.
*/
export interface DashboardModulePage {
readonly id: string;
readonly label: string;
/** Relative to the module root: `timelines`, `media/library`. */
readonly path: string;
}
/** Where a route lands inside its module: the declared page it is or lies beneath, `undefined` at the
* root. `undefined` as a whole means the module has no such route. */
export interface ModuleRouteResolution {
readonly page: DashboardModulePage | undefined;
}
export function resolveModuleRoute(
pages: readonly DashboardModulePage[],
path: string,
): ModuleRouteResolution | undefined {
if (path === "") {
return { page: undefined };
}
const page = pages.find(
(candidate) => path === candidate.path || path.startsWith(`${candidate.path}/`),
);
return page === undefined ? undefined : { page };
}
/** `path` as one of this module's routes the shell would open, or `undefined`. Accepts the same
* spellings `parseModuleRoute` does: an optional leading `/`, unreserved segments, no traversal. */
export function moduleRouteFor(
pages: readonly DashboardModulePage[],
path: unknown,
): { readonly path: string; readonly resolution: ModuleRouteResolution } | "invalid" | "unknown" {
const route = parseModuleRoute(path);
if (route === undefined) {
return "invalid";
}
const resolution = resolveModuleRoute(pages, route);
return resolution === undefined ? "unknown" : { path: route, resolution };
}
export function moduleDestination(moduleId: string, path = ""): DashboardDestination {
return { kind: "module", moduleId, path };
}
const PAGE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
const PAGE_PATH_SEGMENT_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const ICON_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is refused.
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u;
/** The manifest's page grammar (`@ekklesia/contracts`), re-checked on what the server sends. */
export function isDashboardModulePage(value: unknown): value is DashboardModulePage {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
if (keys.length !== 3 || keys[0] !== "id" || keys[1] !== "label" || keys[2] !== "path") {
return false;
}
const { id, label, path } = record;
return (
typeof id === "string" &&
id.length <= 64 &&
PAGE_ID_PATTERN.test(id) &&
typeof label === "string" &&
label.trim().length > 0 &&
label.length <= 60 &&
!CONTROL_CHARACTER_PATTERN.test(label) &&
typeof path === "string" &&
path.length <= 120 &&
path.split("/").length <= 4 &&
path.split("/").every((segment) => PAGE_PATH_SEGMENT_PATTERN.test(segment))
);
}
/** A syntactically valid Lucide name. Whether the Dashboard can draw it is the icon map's question:
* a name it does not know is drawn as the generic module icon, never refused. */
export function isModuleIconName(value: unknown): value is string {
return typeof value === "string" && value.length <= 64 && ICON_NAME_PATTERN.test(value);
}
+118 -12
View File
@@ -6,6 +6,10 @@ import { describe, expect, it } from "vitest";
import {
ACCOUNT_SECTIONS,
DASHBOARD_PLATFORM_PAGES,
dashboardPathReturningTo,
HOST_NAMESPACES,
parseModuleRoute,
returnTargetFromSearch,
dashboardPath,
parseDashboardPath,
DISCOVER_VIEWS,
@@ -27,13 +31,25 @@ describe("parseDashboardPath", () => {
});
it("names a module page by its id at the top level, tolerating one trailing slash", () => {
expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" });
expect(parseDashboardPath("/signage")).toEqual({
kind: "module",
moduleId: "signage",
path: "",
});
expect(parseDashboardPath("/signage/")).toEqual({
kind: "module",
moduleId: "signage",
path: "",
});
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people", path: "" });
});
it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => {
expect(parseDashboardPath("/sign%61ge")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/sign%61ge")).toEqual({
kind: "module",
moduleId: "signage",
path: "",
});
});
it("never reads a platform page as a module page", () => {
@@ -65,7 +81,9 @@ describe("parseDashboardPath", () => {
"/accounts",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual(
pathname === "/accounts" ? { kind: "module", moduleId: "accounts" } : { kind: "not-found" },
pathname === "/accounts"
? { kind: "module", moduleId: "accounts", path: "" }
: { kind: "not-found" },
);
}
});
@@ -76,10 +94,13 @@ describe("parseDashboardPath", () => {
"//",
"//signage",
"/signage//",
// A module's own sub-pages need a contract that does not exist yet (ADR 0057).
"/signage/timelines",
"/signage/timelines/abc",
"/signage/a//b",
"/%E0%A4%A",
// The host's non-Dashboard namespaces are never pages, however deep (ADR 0063).
"/api",
"/api/v1/principal",
"/assets/ekklesia-dashboard/app.js",
"/display/signage",
// Deeper Settings pages are reserved for later (ADR 0061).
"/settings/displays/add",
"/settings/signage/timelines",
@@ -166,27 +187,112 @@ describe("Settings routes (ADR 0061)", () => {
describe("dashboardPath", () => {
it("spells each route canonically, with no implementation prefix", () => {
expect(dashboardPath({ kind: "overview" })).toBe("/");
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/signage");
expect(dashboardPath({ kind: "module", moduleId: "signage", path: "" })).toBe("/signage");
});
it("encodes a module id so it can never escape its own segment", () => {
expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/a%2F..%2Fb");
expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({
expect(dashboardPath({ kind: "module", moduleId: "a/../b", path: "" })).toBe("/a%2F..%2Fb");
expect(
parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b", path: "" })),
).toEqual({
kind: "module",
moduleId: "a/../b",
path: "",
});
});
it("round-trips through the parser", () => {
for (const route of [
{ kind: "overview" } as const,
{ kind: "module", moduleId: "example" } as const,
{ kind: "module", moduleId: "example", path: "" } as const,
{ kind: "module", moduleId: "example", path: "records" } as const,
{ kind: "module", moduleId: "media", path: "sermons/2026-10-11_service~1.mp4" } as const,
]) {
expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
}
});
});
describe("module routes (ADR 0063)", () => {
it("reads everything beneath a module's root as that module's own route", () => {
expect(parseDashboardPath("/signage/timelines")).toEqual({
kind: "module",
moduleId: "signage",
path: "timelines",
});
expect(parseDashboardPath("/signage/timelines/")).toEqual({
kind: "module",
moduleId: "signage",
path: "timelines",
});
expect(parseDashboardPath("/media/sermons/123")).toEqual({
kind: "module",
moduleId: "media",
path: "sermons/123",
});
});
it("spells a module route canonically, its path beneath the encoded module id", () => {
expect(dashboardPath({ kind: "module", moduleId: "signage", path: "timelines" })).toBe(
"/signage/timelines",
);
expect(dashboardPath({ kind: "module", moduleId: "signage", path: "" })).toBe("/signage");
});
it.each([
["", ""],
["/", ""],
["timelines", "timelines"],
["/timelines", "timelines"],
["media/sermons/123", "media/sermons/123"],
["a.b_c~d-e", "a.b_c~d-e"],
])("accepts the module route %j as %j", (input, expected) => {
expect(parseModuleRoute(input)).toBe(expected);
});
it.each([
"../calendar",
"media/../../settings",
"./timelines",
"media/.",
"//evil.example.test",
"https://evil.example.test",
"javascript:alert(1)",
"timelines?x=1",
"timelines#top",
"media%2F..%2Fapi",
"media\\library",
"media//library",
"timelines/",
"time lines",
"a/b/c/d/e/f/g/h/i",
`${"a".repeat(513)}`,
])("refuses the module route %j", (input) => {
expect(parseModuleRoute(input)).toBeUndefined();
});
it("refuses anything that is not a string", () => {
for (const value of [undefined, null, 1, ["timelines"], { path: "timelines" }]) {
expect(parseModuleRoute(value)).toBeUndefined();
}
});
it("names exactly the host's non-Dashboard namespaces", () => {
expect(HOST_NAMESPACES).toEqual(["api", "assets", "display"]);
});
it("carries a return target on its own query parameter, readable only as a raw value", () => {
const href = dashboardPathReturningTo(
{ kind: "module-settings", moduleId: "signage" },
{ kind: "module", moduleId: "signage", path: "timelines" },
);
expect(href).toBe("/settings/signage?return=%2Fsignage%2Ftimelines");
expect(returnTargetFromSearch("?return=%2Fsignage%2Ftimelines")).toBe("/signage/timelines");
expect(returnTargetFromSearch("")).toBeUndefined();
expect(returnTargetFromSearch("?other=1")).toBeUndefined();
});
});
describe("the route locator is published for the API that serves the page", () => {
it("is a narrow package subpath, not the shell root barrel", async () => {
const manifest = JSON.parse(
@@ -255,6 +255,8 @@ describe("display host — full-viewport boundary", () => {
"principal",
"realtime",
]);
// A display is one screen with no pages: the display host omits navigation (ADR 0063).
expect(seenContext && "navigation" in seenContext).toBe(false);
expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" });
expect(isolatedText(query("display-module-container"))).toBe("permitted");
expect(
@@ -352,6 +354,7 @@ describe("display host — full-viewport boundary", () => {
version: "1.2.0",
},
name: "Announcements",
navigation: { icon: null, pages: [] },
},
],
});
+1
View File
@@ -52,6 +52,7 @@ function entry(id: string, name: string, version = "0.1.0", permissions: readonl
version,
},
name,
navigation: { icon: null, pages: [] },
};
}
+33 -2
View File
@@ -65,7 +65,7 @@ describe("the browser host history", () => {
const view = {
addEventListener: vi.fn(),
history: { pushState: vi.fn(), replaceState: vi.fn() },
location: { pathname: "/" },
location: { pathname: "/", search: "" },
removeEventListener: vi.fn(),
};
const history = createBrowserHostHistory(view);
@@ -87,7 +87,7 @@ describe("the browser host history", () => {
const view = {
addEventListener: vi.fn(),
history: { pushState: vi.fn(), replaceState: vi.fn() },
location: { pathname: "/" },
location: { pathname: "/", search: "" },
removeEventListener: vi.fn(),
};
const history = createBrowserHostHistory(view);
@@ -100,6 +100,37 @@ describe("the browser host history", () => {
});
});
describe("the query, which carries only where to return to (ADR 0063)", () => {
it("reads the browser's query, and pushes an address that carries one", () => {
const view = {
addEventListener: vi.fn(),
history: { pushState: vi.fn(), replaceState: vi.fn() },
location: { pathname: "/settings/signage", search: "?return=%2Fsignage" },
removeEventListener: vi.fn(),
};
const history = createBrowserHostHistory(view);
expect(history.search()).toBe("?return=%2Fsignage");
// The address already showing, query included, adds no entry; the same path with another query
// is another address.
history.push("/settings/signage?return=%2Fsignage");
expect(view.history.pushState).not.toHaveBeenCalled();
history.push("/settings/signage");
expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/settings/signage");
});
it("keeps the query beside the path in memory, as the browser does", () => {
const history = createMemoryHostHistory("/settings/signage?return=%2Fsignage%2Fmedia");
expect(history.pathname()).toBe("/settings/signage");
expect(history.search()).toBe("?return=%2Fsignage%2Fmedia");
history.push("/signage/media");
expect(history.search()).toBe("");
history.go(-1);
expect(history.search()).toBe("?return=%2Fsignage%2Fmedia");
});
});
describe("the in-memory host history", () => {
it("behaves like the browser's: push truncates forward entries, go steps within bounds", () => {
const history = createMemoryHostHistory("/");
+20 -4
View File
@@ -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: [] },
},
],
},
@@ -571,6 +584,9 @@ describe("module UI host context", () => {
"realtime",
]);
expect(context.moduleId).toBe("example");
// Navigation comes only from a host with addressable pages (ADR 0063). Without it the member is
// omitted, so the context is exactly the shape it was before navigation existed.
expect("navigation" in context).toBe(false);
expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" });
});
@@ -53,6 +53,7 @@ function entry(id: string, name: string, version = "0.1.0") {
version,
},
name,
navigation: { icon: null, pages: [] },
};
}
+9 -1
View File
@@ -179,7 +179,15 @@ describe("the shell builds no module knowledge into its bundle", () => {
// mean the shell had an opinion about an arrangement it must only render.
// Deliberately distinctive terms: a generic word like "media" appears inside React's own
// bundled source and would make this assertion meaningless rather than strict.
for (const term of ["planning", "sunday-production", "livestream", "calendar", "ungrouped-"]) {
// "calendar" is no longer one of them: it is a generic Lucide icon a module may declare
// (ADR 0063), so the bundle names it on purpose.
for (const term of [
"planning",
"sunday-production",
"livestream",
"youth-ministry",
"ungrouped-",
]) {
expect(bundle.toLowerCase(), term).not.toContain(term);
}
});
@@ -0,0 +1,590 @@
import {
MODULE_UI_ABI_VERSION,
type ModuleUiHostContext,
type ModuleUiNavigation,
ModuleUiNavigationError,
} from "@ekklesia/module-ui-sdk";
import * as lucide from "lucide-react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js";
import { createMemoryHostViewport } from "../src/browser/host-viewport.js";
import { MODULE_ICON_COMPONENTS } from "../src/browser/module-icon.js";
import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js";
import type { DashboardSettings } from "../src/host-protocol.js";
import { DASHBOARD_MODULE_ICON_NAMES } from "../src/module-icons.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* Module navigation (manifest v9, ADR 0063): a module's own icon and primary pages in the global
* sidebar, its real nested routes, the navigation capability a mounted module is handed through the
* real mounting boundary, and module → Settings → back to exactly where it was — beside a
* manifest v8 module that keeps behaving as it always has.
*/
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
});
function entry(
id: string,
name: string,
navigation: { icon: string | null; pages: { id: string; label: string; path: string }[] } = {
icon: null,
pages: [],
},
) {
return {
module: {
api: { baseUrl: `/api/v1/modules/${id}` },
contributions: [],
display: null,
id,
permissions: [],
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
ui: { url: `/api/v1/module-ui/${id}/0.1.0` },
version: "0.1.0",
},
name,
navigation,
};
}
const SIGNAGE_PAGES = [
{ id: "playback", label: "Playback", path: "playback" },
{ id: "timelines", label: "Timelines", path: "timelines" },
{ id: "media", label: "Media", path: "media" },
];
/** A manifest v9 module with an icon and three pages, and a manifest v8 module with neither. */
const NAVIGATION = {
groups: [],
ungrouped: [
entry("signage", "Signage", { icon: "monitor-play", pages: SIGNAGE_PAGES }),
entry("calendar", "Calendar"),
],
};
/** Signage declares configuration and the viewer may change it; Calendar has no settings; Archive
* is installed but turned off, so it is in Settings but not in the navigation. */
const SETTINGS: DashboardSettings = {
modules: [
{ available: false, id: "archive", name: "Archive" },
{ available: true, id: "signage", name: "Signage" },
],
sections: [],
};
interface Mounts {
imported: string[];
mounted: string[];
unmounted: string[];
contexts: Map<string, ModuleUiHostContext>;
}
async function render(
path: string,
options: { readonly history?: MemoryHostHistory; readonly compact?: boolean } = {},
) {
const history = options.history ?? createMemoryHostHistory(path);
const titles: string[] = [];
const mounts: Mounts = { contexts: new Map(), imported: [], mounted: [], unmounted: [] };
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return Response.json({ kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return Response.json(NAVIGATION);
}
if (url.endsWith("/api/v1/dashboard/settings")) {
return Response.json(SETTINGS);
}
return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}) as unknown as typeof fetch;
await act(async () => {
root.render(
<HostApp
config={{ apiBaseUrl, fetch: fetchImplementation }}
history={history}
importModule={async (url: string) => {
const moduleId = /module-ui\/([a-z-]+)\//u.exec(url)?.[1] ?? "unknown";
mounts.imported.push(moduleId);
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id: moduleId,
mount(element: Element, context: ModuleUiHostContext) {
mounts.contexts.set(moduleId, context);
const show = (): void => {
element.textContent = `${moduleId}:${context.navigation?.location().path ?? "-"}`;
};
show();
const unsubscribe = context.navigation?.subscribe(show);
mounts.mounted.push(moduleId);
return () => {
unsubscribe?.();
mounts.unmounted.push(moduleId);
};
},
version: "0.1.0",
},
};
}}
setDocumentTitle={(title) => titles.push(title)}
sidebarPreference={createMemorySidebarPreference()}
viewport={createMemoryHostViewport(options.compact ?? false)}
/>,
);
});
return { history, mounts, titles };
}
function query(testId: string): HTMLElement | null {
return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
}
function required(testId: string): HTMLElement {
const element = query(testId);
if (!element) {
throw new Error(`missing ${testId}`);
}
return element;
}
async function click(element: Element, init: MouseEventInit = {}): Promise<void> {
await act(async () => {
element.dispatchEvent(
new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init }),
);
});
}
/** What the mounted module rendered, read through its isolated (Shadow DOM) root. */
function moduleText(): string {
const host = container.querySelector("[data-ekklesia-module-ui]");
return host?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? "";
}
const current = () =>
[...container.querySelectorAll('nav [aria-current="page"]')].map(
(element) => (element as HTMLElement).dataset.testid,
);
function navigationOf(mounts: Mounts, moduleId = "signage"): ModuleUiNavigation {
const navigation = mounts.contexts.get(moduleId)?.navigation;
if (!navigation) {
throw new Error(`no navigation for ${moduleId}`);
}
return navigation;
}
describe("a manifest v9 module's routes", () => {
it("opens the module root as its landing view, with its pages beneath it in the sidebar", async () => {
const { mounts, titles } = await render("/signage");
expect(mounts.mounted).toEqual(["signage"]);
expect(moduleText()).toBe("signage:");
expect(titles.at(-1)).toBe("Signage");
expect(current()).toEqual(["select-signage"]);
const pages = [...required("module-subnavigation-signage").querySelectorAll("a")];
expect(pages.map((link) => link.textContent)).toEqual(["Playback", "Timelines", "Media"]);
expect(pages.map((link) => link.getAttribute("href"))).toEqual([
"/signage/playback",
"/signage/timelines",
"/signage/media",
]);
expect(required("module-subnavigation-signage").getAttribute("aria-label")).toBe("Signage");
});
it("opens a declared page directly from its deep link, as a refresh or a bookmark does", async () => {
const { mounts, titles } = await render("/signage/timelines");
expect(mounts.mounted).toEqual(["signage"]);
expect(navigationOf(mounts).location()).toEqual({ page: "timelines", path: "timelines" });
expect(moduleText()).toBe("signage:timelines");
expect(current()).toEqual(["select-signage-timelines"]);
expect(titles.at(-1)).toBe("Timelines · Signage");
});
it("opens a route beneath a declared page — a detail view — marking its page", async () => {
const { mounts } = await render("/signage/media/sermons/2026-10-11");
expect(navigationOf(mounts).location()).toEqual({
page: "media",
path: "media/sermons/2026-10-11",
});
expect(current()).toEqual(["select-signage-media"]);
});
it("is not found at a path no declared page owns, and never mounts the module for it", async () => {
for (const path of ["/signage/nope", "/signage/timeline", "/signage/Timelines"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
const { mounts, titles } = await render(path);
expect(query("host-not-found"), path).not.toBeNull();
expect(mounts.imported, path).toEqual([]);
expect(titles.at(-1), path).toBe("Page not found");
expect(current(), path).toEqual([]);
}
});
it("moves between its own pages without remounting, and Back and Forward walk them", async () => {
const { history, mounts } = await render("/signage");
await click(required("select-signage-timelines"));
expect(history.entries).toEqual(["/signage", "/signage/timelines"]);
expect(moduleText()).toBe("signage:timelines");
await click(required("select-signage-media"));
await act(async () => history.go(-1));
expect(moduleText()).toBe("signage:timelines");
expect(current()).toEqual(["select-signage-timelines"]);
await act(async () => history.go(-1));
expect(moduleText()).toBe("signage:");
await act(async () => history.go(2));
expect(moduleText()).toBe("signage:media");
// One import and one mount for the whole walk: the module was never torn down.
expect(mounts.imported).toEqual(["signage"]);
expect(mounts.mounted).toEqual(["signage"]);
expect(mounts.unmounted).toEqual([]);
});
it("moves focus to the page when the module moves to another of its pages", async () => {
await render("/signage");
const main = required("host-main-region");
(document.activeElement as HTMLElement | null)?.blur();
await click(required("select-signage-playback"));
expect(document.activeElement).toBe(main);
});
it("keeps the exact page across a refresh", async () => {
const history = createMemoryHostHistory("/signage");
await render("/signage", { history });
await click(required("select-signage-media"));
await act(async () => {
root.unmount();
});
root = createRoot(container);
const { mounts } = await render("/signage/media");
expect(navigationOf(mounts).location().path).toBe("media");
expect(current()).toEqual(["select-signage-media"]);
});
});
describe("a released manifest v8 module", () => {
it("is one page, its root, with the generic icon and no sub-navigation", async () => {
const { mounts } = await render("/calendar");
expect(mounts.mounted).toEqual(["calendar"]);
expect(moduleText()).toBe("calendar:");
expect(current()).toEqual(["select-calendar"]);
expect(query("module-subnavigation-calendar")).toBeNull();
expect(required("select-calendar").querySelector("svg")?.dataset.icon).toBe("module");
});
it("is not found beneath its root, exactly as before", async () => {
const { mounts } = await render("/calendar/events/456");
expect(query("host-not-found")).not.toBeNull();
expect(mounts.imported).toEqual([]);
});
it("still receives navigation — its root, and no Settings link when it has no settings", async () => {
const { mounts } = await render("/calendar");
const navigation = navigationOf(mounts, "calendar");
expect(navigation.location()).toEqual({ page: undefined, path: "" });
expect(navigation.settings).toBeUndefined();
expect(() => navigation.navigate("events")).toThrowError(
expect.objectContaining({ code: "MODULE_NAVIGATION_PATH_UNKNOWN" }),
);
});
});
describe("modules that are not running", () => {
it("says a turned-off module is unavailable at its root and beneath it, mounting nothing", async () => {
for (const path of ["/archive", "/archive/timelines", "/archive/a/b"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
const { mounts } = await render(path);
expect(query("host-module-unavailable"), path).not.toBeNull();
expect(mounts.imported, path).toEqual([]);
// A module that is not running is absent from the navigation.
expect(query("select-archive"), path).toBeNull();
}
});
});
describe("the sidebar", () => {
it("draws a module's declared Lucide icon, and the generic icon for one that declares none", async () => {
await render("/");
expect(required("select-signage").querySelector("svg")?.dataset.icon).toBe(
"module:monitor-play",
);
expect(required("select-signage").querySelector("svg")?.getAttribute("aria-hidden")).toBe(
"true",
);
expect(required("select-calendar").querySelector("svg")?.dataset.icon).toBe("module");
});
it("shows a module's pages only while that module is open", async () => {
await render("/");
expect(query("module-subnavigation-signage")).toBeNull();
await click(required("select-signage"));
expect(query("module-subnavigation-signage")).not.toBeNull();
await click(required("select-calendar"));
expect(query("module-subnavigation-signage")).toBeNull();
});
it("collapsed, hides the pages and marks the module itself, named for assistive technology", async () => {
await render("/signage/timelines");
await click(container.querySelector('[aria-label="Collapse navigation"]') as Element);
expect(query("module-subnavigation-signage")).toBeNull();
expect(current()).toEqual(["select-signage"]);
const label = required("select-signage").querySelector("[data-nav-label]");
expect(label?.textContent).toBe("Signage");
expect(label?.className).toBe("ek-shell-visually-hidden");
});
it("uses the identical hierarchy in the phone drawer, which closes once a page is chosen", async () => {
const { history } = await render("/signage", { compact: true });
await click(required("mobile-navigation-trigger"));
const drawer = required("navigation-drawer");
const pages = [
...drawer.querySelectorAll('[data-testid="module-subnavigation-signage"] a'),
] as HTMLElement[];
expect(pages.map((link) => link.textContent)).toEqual(["Playback", "Timelines", "Media"]);
for (const link of pages) {
expect(link.style.minHeight).toBe("var(--ekklesia-control-height-lg)");
}
await click(pages[1] as HTMLElement);
expect(history.pathname()).toBe("/signage/timelines");
expect(query("navigation-drawer")).toBeNull();
});
});
describe("the module navigation capability", () => {
it("navigates to the module's own routes, adding or replacing a history entry", async () => {
const { history, mounts } = await render("/signage");
const navigation = navigationOf(mounts);
await act(async () => navigation.navigate("timelines"));
expect(history.entries).toEqual(["/signage", "/signage/timelines"]);
await act(async () => navigation.navigate("/media/sermons/9"));
expect(history.pathname()).toBe("/signage/media/sermons/9");
expect(navigation.location()).toEqual({ page: "media", path: "media/sermons/9" });
await act(async () => navigation.navigate("media", { replace: true }));
expect(history.entries).toEqual(["/signage", "/signage/timelines", "/signage/media"]);
await act(async () => navigation.navigate(""));
expect(navigation.location()).toEqual({ page: undefined, path: "" });
expect(mounts.mounted).toEqual(["signage"]);
});
it.each([
["../calendar", "MODULE_NAVIGATION_PATH_INVALID"],
["timelines/../../calendar", "MODULE_NAVIGATION_PATH_INVALID"],
["//evil.example.test", "MODULE_NAVIGATION_PATH_INVALID"],
["https://evil.example.test/", "MODULE_NAVIGATION_PATH_INVALID"],
["javascript:alert(1)", "MODULE_NAVIGATION_PATH_INVALID"],
["timelines?x=1", "MODULE_NAVIGATION_PATH_INVALID"],
["timelines#top", "MODULE_NAVIGATION_PATH_INVALID"],
["nope", "MODULE_NAVIGATION_PATH_UNKNOWN"],
["timeline", "MODULE_NAVIGATION_PATH_UNKNOWN"],
])("refuses %j — no other module, platform page, or undeclared page", async (path, code) => {
const { history, mounts } = await render("/signage");
const navigation = navigationOf(mounts);
expect(() => navigation.navigate(path)).toThrowError(expect.objectContaining({ code }));
expect(() => navigation.link(path)).toThrowError(ModuleUiNavigationError);
expect(history.entries).toEqual(["/signage"]);
});
it("names nothing it refused in its error", async () => {
const { mounts } = await render("/signage");
const failure = (() => {
try {
navigationOf(mounts).navigate("../calendar?token=secret");
} catch (error) {
return error as Error;
}
return undefined;
})();
expect(failure).toBeInstanceOf(ModuleUiNavigationError);
expect(`${failure?.message}${failure?.stack}`).not.toMatch(/calendar|secret/u);
});
it("makes real links: in place on an ordinary click, to the browser with a modifier", async () => {
const { history, mounts } = await render("/signage");
const link = navigationOf(mounts).link("timelines");
expect(link.href).toBe("/signage/timelines");
const ordinary = {
altKey: false,
button: 0,
ctrlKey: false,
defaultPrevented: false,
metaKey: false,
preventDefault: vi.fn(),
shiftKey: false,
};
await act(async () => link.onClick(ordinary));
expect(ordinary.preventDefault).toHaveBeenCalledOnce();
expect(history.pathname()).toBe("/signage/timelines");
const newTab = { ...ordinary, metaKey: true, preventDefault: vi.fn() };
await act(async () => navigationOf(mounts).link("media").onClick(newTab));
expect(newTab.preventDefault).not.toHaveBeenCalled();
expect(history.pathname()).toBe("/signage/timelines");
});
it("reports moves made by the host — the sidebar, Back — to the mounted module", async () => {
const { history, mounts } = await render("/signage");
const listener = vi.fn();
navigationOf(mounts).subscribe(listener);
await click(required("select-signage-playback"));
await act(async () => history.go(-1));
expect(listener).toHaveBeenCalledTimes(2);
});
});
describe("module → Settings → back to exactly where it was", () => {
it("opens the module's Settings page carrying its current page as the place to return to", async () => {
const { history, mounts } = await render("/signage/timelines");
const settings = navigationOf(mounts).settings;
expect(settings?.link().href).toBe("/settings/signage?return=%2Fsignage%2Ftimelines");
await act(async () => settings?.open());
expect(history.entries).toEqual([
"/signage/timelines",
"/settings/signage?return=%2Fsignage%2Ftimelines",
]);
expect(query("module-settings")).not.toBeNull();
const back =
required("module-settings").querySelector<HTMLAnchorElement>(".ek-page-header__back");
expect(back?.getAttribute("href")).toBe("/signage/timelines");
expect(back?.textContent).toContain("Back to Signage");
await click(back as HTMLAnchorElement);
expect(history.pathname()).toBe("/signage/timelines");
expect(current()).toEqual(["select-signage-timelines"]);
});
it("returns to where the module is now, even if it never read its location since moving", async () => {
const { history, mounts } = await render("/signage");
const settings = navigationOf(mounts).settings;
mounts.contexts.get("signage")?.navigation?.subscribe(() => undefined)();
await act(async () => history.push("/signage/media"));
expect(settings?.link().href).toBe("/settings/signage?return=%2Fsignage%2Fmedia");
});
it("keeps the place to return to across a refresh of the Settings page", async () => {
await render("/settings/signage?return=%2Fsignage%2Fmedia%2Fsermons%2F9");
const back = required("module-settings").querySelector(".ek-page-header__back");
expect(back?.getAttribute("href")).toBe("/signage/media/sermons/9");
expect(back?.textContent).toContain("Back to Signage");
});
it("keeps it when the Settings address is respelled", async () => {
const { history } = await render("/settings/signage/?return=%2Fsignage%2Fmedia");
expect(history.entries).toEqual(["/settings/signage?return=%2Fsignage%2Fmedia"]);
});
it.each([
["another origin", "https://evil.example.test/signage"],
["a protocol-relative URL", "//evil.example.test"],
["a script", "javascript:alert(1)"],
["the API", "/api/v1/principal"],
["a page the module does not declare", "/signage/nope"],
["a module that is not running", "/archive"],
["a module that does not exist", "/nowhere"],
["traversal", "/signage/../api"],
["a nested query", "/signage?return=https://evil.example.test"],
])("ignores a return target naming %s, going back to Settings instead", async (_label, target) => {
await render(`/settings/signage?return=${encodeURIComponent(target)}`);
const back = required("module-settings").querySelector(".ek-page-header__back");
expect(back?.getAttribute("href")).toBe("/settings");
expect(back?.textContent).toContain("Back to Settings");
});
it("offers no Settings link to a module whose settings this viewer cannot open", async () => {
const { mounts } = await render("/calendar");
expect(navigationOf(mounts, "calendar").settings).toBeUndefined();
});
});
describe("module icons", () => {
it("maps exactly the names the platform accepts, each to a real Lucide component", () => {
expect(Object.keys(MODULE_ICON_COMPONENTS).sort()).toEqual(
[...DASHBOARD_MODULE_ICON_NAMES].sort(),
);
const exports = lucide as unknown as Record<string, unknown>;
for (const name of DASHBOARD_MODULE_ICON_NAMES) {
const pascal = name
.split("-")
.map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`)
.join("");
expect(MODULE_ICON_COMPONENTS[name], name).toBe(exports[pascal]);
}
});
it("draws the generic icon for a name this Dashboard cannot draw", async () => {
NAVIGATION.ungrouped.push(entry("press", "Press", { icon: "not-yet-an-icon", pages: [] }));
try {
await render("/");
expect(required("select-press").querySelector("svg")?.dataset.icon).toBe("module");
} finally {
NAVIGATION.ungrouped.pop();
}
});
});
+34 -4
View File
@@ -7,8 +7,16 @@ import { parseReturnTarget } from "../src/return-target.js";
describe("parseReturnTarget", () => {
it("accepts a Dashboard page on this origin, as its canonical destination", () => {
expect(parseReturnTarget("/")).toEqual({ kind: "overview" });
expect(parseReturnTarget("/signage")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseReturnTarget("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseReturnTarget("/signage")).toEqual({
kind: "module",
moduleId: "signage",
path: "",
});
expect(parseReturnTarget("/signage/")).toEqual({
kind: "module",
moduleId: "signage",
path: "",
});
// Settings pages are Dashboard pages too (ADR 0061).
expect(parseReturnTarget("/settings")).toEqual({ kind: "settings" });
expect(parseReturnTarget("/settings/signage")).toEqual({
@@ -59,13 +67,29 @@ describe("parseReturnTarget", () => {
"/signage?x=1",
"/signage#top",
"/api/v1/principal",
"/api/auth/sign-out",
"/assets/ekklesia-dashboard/app.js",
"/display/signage",
"/settings/displays/add",
"/signage/timelines",
"/signage//",
"/signage/a//b",
]) {
expect(parseReturnTarget(value), value).toBeUndefined();
}
});
it("accepts a module's own deeper route, whose page the host then checks (ADR 0063)", () => {
expect(parseReturnTarget("/signage/timelines")).toEqual({
kind: "module",
moduleId: "signage",
path: "timelines",
});
expect(parseReturnTarget("/media/sermons/123")).toEqual({
kind: "module",
moduleId: "media",
path: "sermons/123",
});
});
});
describe("dashboardBack", () => {
@@ -84,7 +108,13 @@ describe("dashboardBack", () => {
});
it("falls back to the page's own parent when the target is missing or unsafe", () => {
for (const returnTarget of [undefined, "https://evil.example/", "//evil.example", "/nope/x"]) {
for (const returnTarget of [
undefined,
"https://evil.example/",
"//evil.example",
"/api/v1/principal",
"/signage//",
]) {
const history = createMemoryHostHistory("/calendar");
const back = dashboardBack(history, {
fallback: { kind: "overview" },
@@ -53,6 +53,7 @@ function entry(id: string, name: string) {
version: "0.1.0",
},
name,
navigation: { icon: null, pages: [] },
};
}
@@ -60,6 +60,7 @@ function entry(id: string, name: string, version = "0.1.0") {
version,
},
name,
navigation: { icon: null, pages: [] },
};
}
+4 -2
View File
@@ -9,11 +9,13 @@
"types": []
},
"include": [
"src/index.ts",
"src/dashboard-assets.ts",
"src/dashboard-routes.ts",
"src/display-assets.ts",
"src/host-protocol.ts",
"src/module-context.ts"
"src/index.ts",
"src/module-context.ts",
"src/module-icons.ts",
"src/module-navigation.ts"
]
}
+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 (
<Panel description="Everything this module knows about." title="Example module UI">
{recordCount === undefined ? (
<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
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);
}
+116 -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,
@@ -65,6 +65,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 +237,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);