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:
co-authored by
Claude Opus 5.5
parent
7447381c8f
commit
4671622b9d
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user