Add the module navigation contract: manifest v9, module routes, return-to-origin

Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard.

- Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated
  MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe
  label, kebab-case path relative to the module root; no traversal, schemes,
  queries, duplicates, or nesting). Validated before activation.
- Version 8 stays accepted beside version 9, each read strictly as itself
  (discriminated union); no released manifest is rewritten. The template
  moves to v9; the released Signage 0.1.5 manifest is a test fixture.
- Real module routes: /<id> is the landing view, /<id>/<page> a declared
  page, anything beneath a page a module-owned detail route; anything else
  is not-found and mounts nothing. api/assets/display are never pages.
- Sidebar: declared icons, pages nested under an open module, one
  aria-current, collapsed and drawer behaviour; v8 modules unchanged.
- module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe,
  navigate, link, settings), refusing anything outside the module's routes;
  undefined on the display host. Mount ABI unchanged.
- Return-to-origin: one `?return=` carrier on Settings URLs, validated by
  parseReturnTarget and against running modules' declared routes; Settings
  Back returns to exactly the originating module page, across refreshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-15 08:21:50 -05:00
co-authored by Claude Opus 5.5
parent 7447381c8f
commit 4671622b9d
69 changed files with 4221 additions and 372 deletions
+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."
+65 -16
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}
>
<ShellIcon name={props.icon} />
{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,49 @@ 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 (
<NavigationItem
current={props.route.kind === "module" && props.route.moduleId === moduleId}
destination={{ kind: "module", 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}`}
label={entry.name}
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
presentation={props.presentation}
testId={`select-${moduleId}`}
/>
<Fragment key={`${moduleId}@${entry.descriptor.version}`}>
<NavigationItem
current={resolution !== undefined && (openPage === undefined || !showPages)}
destination={moduleDestination(moduleId)}
history={props.history}
icon={<ModuleIcon icon={entry.icon} />}
label={entry.name}
{...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}`}
style={{ listStyle: "none", margin: 0, padding: 0 }}
>
{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,13 +189,17 @@ 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" };
}
return { kind: "not-found" };
const path = parseModuleRoute(rest.join("/"));
return path === undefined ? { kind: "not-found" } : { kind: "module", moduleId: first, path };
}
/**
@@ -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 carries `navigation: undefined`. */
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 supplies none.
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(
@@ -251,10 +251,13 @@ describe("display host — full-viewport boundary", () => {
"contributions",
"display",
"moduleId",
"navigation",
"permissions",
"principal",
"realtime",
]);
// A display is one screen with no pages: the display host offers no navigation (ADR 0063).
expect(seenContext?.navigation).toBeUndefined();
expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" });
expect(isolatedText(query("display-module-container"))).toBe("permitted");
expect(
@@ -352,6 +355,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("/");
+21 -5
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: [] },
},
],
},
@@ -556,7 +569,7 @@ describe("module UI host context", () => {
expect(context.display?.url).toBe("https://api.example.test/display/example");
});
it("exposes exactly the seven contract members and the browser principal", () => {
it("exposes exactly the eight contract members and the browser principal", () => {
const { implementation } = recordingFetch();
const context = contextFor(implementation);
@@ -566,11 +579,14 @@ describe("module UI host context", () => {
"contributions",
"display",
"moduleId",
"navigation",
"permissions",
"principal",
"realtime",
]);
expect(context.moduleId).toBe("example");
// Navigation comes only from a host with addressable pages (ADR 0063); this one supplies none.
expect(context.navigation).toBeUndefined();
expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" });
});
@@ -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"
]
}