Files
Ekklesia/surfaces/shell/src/browser/ShellNavigation.tsx
T
ekklesiaandClaude Opus 5.5 4671622b9d 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]>
2026-10-15 08:21:50 -05:00

456 lines
17 KiB
TypeScript

import { designToken } from "@ekklesia/design-tokens";
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";
import {
DISCOVER_VIEW_COPY,
hasVisibleSettings,
isSettingsRoute,
navigableModuleSettings,
SETTINGS_SECTION_COPY,
settingsSectionDestination,
} from "./settings-catalog.js";
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
/**
* How a navigation surface presents itself. `collapsed` is the desktop sidebar's icon-only mode;
* `compact` is the phone drawer, which always shows labels and uses larger touch targets.
*/
export interface NavigationPresentation {
readonly collapsed: boolean;
readonly compact: boolean;
}
/** The control height for this presentation: the medium control on desktop, the large one in the
* drawer, so a fingertip has room (WCAG 2.2 §2.5.8 asks for 24px; this is 40px and 48px). */
export function controlHeight(presentation: NavigationPresentation): string {
return designToken(presentation.compact ? "control.height-lg" : "control.height-md");
}
/**
* The identity slot at the top of the navigation (ADR 0058).
*
* A neutral placeholder — a plain mark and the product's name — until the installation's own
* branding replaces it (DESIGN.md §2.1, §3.4). It is one component with no inputs on purpose: the
* branding work owns the decision of what goes here and where it comes from, and replaces this
* component's body rather than undoing a configuration model invented early. It is not a heading;
* each page's own title is. Collapsed, only the mark shows.
*/
export function ShellBrand(props: { readonly collapsed: boolean }): ReactElement {
return (
<div className="ek-shell-brand" data-testid="shell-brand">
<span aria-hidden="true" className="ek-shell-brand__mark" />
<span className={props.collapsed ? "ek-shell-visually-hidden" : undefined}>Ekklesia</span>
</div>
);
}
function NavigationItem(props: {
readonly destination: DashboardDestination;
readonly label: string;
/** 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;
readonly onNavigate?: () => void;
readonly testId: string;
}): ReactElement {
const { tooltip, triggerProps } = useNavTooltip(props.label, props.presentation.collapsed);
const style: CSSProperties = { minHeight: controlHeight(props.presentation) };
return (
<li>
<DashboardLink
className="ek-shell-nav-item"
current={props.current}
data-testid={props.testId}
history={props.history}
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
style={style}
to={props.destination}
{...triggerProps}
>
{props.icon === undefined ? undefined : typeof props.icon === "string" ? (
<ShellIcon name={props.icon} />
) : (
props.icon
)}
<span
className={
props.presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
}
data-nav-label=""
>
{props.label}
</span>
</DashboardLink>
{tooltip}
</li>
);
}
/**
* 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;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
readonly onNavigate?: () => void;
readonly labelledBy?: string;
}): ReactElement {
const { route, presentation } = props;
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
return (
<ul
aria-labelledby={props.labelledBy}
className="ek-shell-nav__group"
style={{ listStyle: "none", margin: 0, padding: 0 }}
>
{props.entries.map((entry) => {
const moduleId = entry.descriptor.id;
const resolution =
route.kind === "module" && route.moduleId === moduleId
? resolveModuleRoute(entry.pages, route.path)
: undefined;
const showPages =
resolution !== undefined && entry.pages.length > 0 && !presentation.collapsed;
const openPage = resolution?.page;
return (
<Fragment key={`${moduleId}@${entry.descriptor.version}`}>
<NavigationItem
current={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>
);
}
export interface NavigationListProps {
readonly id: string;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
/** Which Settings destinations this person may see (ADR 0061). */
readonly settings: DashboardSettings | "unavailable";
/** Called once a destination is chosen, for the drawer to close. */
readonly onNavigate?: () => void;
}
/**
* Discover in the Settings navigation: one item that opens Browse, which — while a Discover view is
* open — shows its three views beneath it. The open view carries `aria-current`; Discover itself does
* not, so exactly one item is the current page.
*/
function DiscoverNavigation(props: {
readonly id: string;
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
readonly onNavigate?: () => void;
}): ReactElement {
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
const open = props.route.kind === "discover";
const copy = SETTINGS_SECTION_COPY.discover;
return (
<>
<NavigationItem
current={false}
destination={settingsSectionDestination("discover")}
history={props.history}
icon={copy.icon}
label={copy.label}
{...onNavigate}
presentation={props.presentation}
testId="select-settings-discover"
/>
{open ? (
<li>
<ul
aria-label={copy.label}
className={
props.presentation.collapsed
? "ek-shell-nav__group"
: "ek-shell-nav__group ek-shell-nav__nested"
}
data-testid="discover-subnavigation"
style={{ listStyle: "none", margin: 0, padding: 0 }}
>
{DISCOVER_VIEWS.map((view) => (
<NavigationItem
current={props.route.kind === "discover" && props.route.view === view}
destination={{ kind: "discover", view }}
history={props.history}
icon={DISCOVER_VIEW_COPY[view].icon}
key={view}
label={DISCOVER_VIEW_COPY[view].label}
{...onNavigate}
presentation={props.presentation}
testId={`select-discover-${view}`}
/>
))}
</ul>
</li>
) : undefined}
</>
);
}
/**
* Settings, in the same sidebar as everything else (DESIGN.md §4.2, ADR 0061): one Settings item
* after the modules, which — while a Settings page is open — expands in place into the platform's
* own sections and each module's settings. The rest of the navigation stays where it was, so leaving
* Settings is one click on any other item, and the hierarchy is identical in the phone drawer.
*
* Only what the server said this person may see is listed, and nothing at all when that is nothing:
* no Settings link leads to a page of things they cannot change.
*/
function SettingsNavigation(props: {
readonly id: string;
readonly settings: DashboardSettings | "unavailable";
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
readonly onNavigate?: () => void;
}): ReactElement | null {
if (!hasVisibleSettings(props.settings) || props.settings === "unavailable") {
return null;
}
const { route, settings } = props;
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
const open = isSettingsRoute(route.kind);
const modules = navigableModuleSettings(settings);
const collapsed = props.presentation.collapsed;
const listStyle = { listStyle: "none", margin: 0, padding: 0 } as const;
const labelClass = collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label";
return (
<section data-testid="settings-navigation">
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<ul className="ek-shell-nav__group" style={listStyle}>
<NavigationItem
current={route.kind === "settings"}
destination={{ kind: "settings" }}
history={props.history}
icon="settings"
label="Settings"
{...onNavigate}
presentation={props.presentation}
testId="select-settings"
/>
</ul>
{open ? (
<div
className={collapsed ? undefined : "ek-shell-nav__nested"}
data-testid="settings-subnavigation"
>
{settings.sections.length > 0 ? (
<>
<h3 className={labelClass} id={`${props.id}-settings-platform`}>
Platform
</h3>
<ul
aria-labelledby={`${props.id}-settings-platform`}
className="ek-shell-nav__group"
style={listStyle}
>
{settings.sections.map((section) =>
section === "discover" ? (
<DiscoverNavigation
history={props.history}
id={props.id}
key={section}
{...onNavigate}
presentation={props.presentation}
route={route}
/>
) : (
<NavigationItem
current={route.kind === "settings-section" && route.section === section}
destination={settingsSectionDestination(section)}
history={props.history}
icon={SETTINGS_SECTION_COPY[section].icon}
key={section}
label={SETTINGS_SECTION_COPY[section].label}
{...onNavigate}
presentation={props.presentation}
testId={`select-settings-${section}`}
/>
),
)}
</ul>
</>
) : undefined}
{modules.length > 0 ? (
<>
<h3 className={labelClass} id={`${props.id}-settings-modules`}>
Modules
</h3>
<ul
aria-labelledby={`${props.id}-settings-modules`}
className="ek-shell-nav__group"
style={listStyle}
>
{modules.map((module) => (
<NavigationItem
current={route.kind === "module-settings" && route.moduleId === module.id}
destination={{ kind: "module-settings", moduleId: module.id }}
history={props.history}
icon="module"
key={module.id}
label={module.name}
{...onNavigate}
presentation={props.presentation}
testId={`select-module-settings-${module.id}`}
/>
))}
</ul>
</>
) : undefined}
</div>
) : undefined}
</section>
);
}
/**
* The Dashboard's one navigation hierarchy, identical in the desktop sidebar and the phone drawer
* (DESIGN.md §4.6): the Overview first, then the server's groups in the server's order, then any
* ungrouped modules. The shell sorts and filters nothing — a disabled module is absent because the
* server's navigation does not list it (ADR 0027, ADR 0028).
*
* Collapsed, group labels stay in the DOM for assistive technology and a divider stands in for them
* visually.
*/
export function NavigationList(props: NavigationListProps): ReactElement {
const { groups, ungrouped } = props.navigation;
const { collapsed } = props.presentation;
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
const empty = groups.length === 0 && ungrouped.length === 0;
return (
<nav aria-label="Dashboard" className="ek-shell-nav" id={props.id}>
<ul className="ek-shell-nav__group" style={{ listStyle: "none", margin: 0, padding: 0 }}>
<NavigationItem
current={props.route.kind === "overview"}
destination={{ kind: "overview" }}
history={props.history}
icon="overview"
label="Overview"
{...onNavigate}
presentation={props.presentation}
testId="select-overview"
/>
</ul>
{empty && !collapsed ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : undefined}
{groups.map((group) => {
const labelId = `${props.id}-group-${group.id}`;
return (
<section data-testid={`group-${group.id}`} key={group.id}>
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<h2
className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label"}
id={labelId}
>
{group.label}
</h2>
<ModuleItems
entries={group.modules}
history={props.history}
labelledBy={labelId}
{...onNavigate}
presentation={props.presentation}
route={props.route}
/>
</section>
);
})}
{ungrouped.length > 0 ? (
<section data-testid="group-ungrouped">
{groups.length > 0 ? (
<>
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<h2
className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label"}
id={`${props.id}-group-other`}
>
Other
</h2>
</>
) : undefined}
<ModuleItems
entries={ungrouped}
history={props.history}
{...(groups.length > 0 ? { labelledBy: `${props.id}-group-other` } : {})}
{...onNavigate}
presentation={props.presentation}
route={props.route}
/>
</section>
) : undefined}
<SettingsNavigation
history={props.history}
id={props.id}
{...onNavigate}
presentation={props.presentation}
route={props.route}
settings={props.settings}
/>
</nav>
);
}