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
+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>