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