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