feat(shell): Settings hierarchy, Discover, and /settings/<module-id>
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m29s
CI / image-build (pull_request) Successful in 1m4s
CI / deploy-smoke (same-origin) (pull_request) Successful in 54s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m56s

The Settings foundation (ADR 0061):

- URL model. /settings is a real landing page. The platform sections
  are /settings/general, /branding, /displays, and /system. Module
  lifecycle is Discover: /settings/discover/browse, /installed, and
  /updates. /settings/discover is respelled in place to Browse's one
  canonical address. A module's settings are /settings/<module-id>,
  never nested under Discover. Deeper Settings addresses are
  reserved. "Modules" stays the user-facing word: "Discover modules",
  "Installed modules", "Module updates".
- Collisions. Core's one reserved list gains the Settings section
  names (branding, discover, displays, general, system) as a second
  documented group, so no module id can shadow a section; `modules` is
  an ordinary id. apps/api tests hold it equal to the shell's sections.
- Navigation. One Settings item in the same sidebar expands in place
  into Platform and Modules (and Discover's three views) while in
  Settings, and the phone drawer has the same hierarchy. The rest of
  the navigation stays.
- Permissions. GET /api/v1/dashboard/settings evaluates, through
  Core's evaluator, which destinations the viewer may see:
  - Displays: core.devices.manage
  - Discover: core.modules.lifecycle.manage
  - a module's settings: core.modules.configuration.manage, for each
    module that declares configuration (ADR 0029)
  - General, Branding, System: the platform owner, until each has its
    own permission
  These are presentation hints; every data route keeps its own
  enforcement. With nothing visible, there is no Settings link.
- Pages are honest placeholders with Back to Settings. A disabled
  module's settings say it is turned off; a section or module the
  person cannot see says so without detail; an unknown name is not
  found; a failed load withholds the link and offers Retry.
- The deploy smoke opens Settings by deep link, refreshes a section,
  enters Discover, opens the module's settings, uses the phone
  drawer, and returns to the Dashboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-14 22:22:50 -05:00
co-authored by Claude Opus 5.5
parent 34692ac05f
commit 89ef4b5ff6
30 changed files with 2343 additions and 50 deletions
+199 -1
View File
@@ -1,10 +1,23 @@
import { designToken } from "@ekklesia/design-tokens";
import type { CSSProperties, ReactElement } from "react";
import type { DashboardDestination, DashboardRoute } from "../dashboard-routes.js";
import type { DashboardNavigation, DashboardNavigationEntry } from "../host-protocol.js";
import type {
DashboardNavigation,
DashboardNavigationEntry,
DashboardSettings,
} from "../host-protocol.js";
import { DashboardLink } from "./DashboardLink.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";
/**
@@ -121,10 +134,187 @@ export interface NavigationListProps {
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
@@ -203,6 +393,14 @@ export function NavigationList(props: NavigationListProps): ReactElement {
/>
</section>
) : undefined}
<SettingsNavigation
history={props.history}
id={props.id}
{...onNavigate}
presentation={props.presentation}
route={props.route}
settings={props.settings}
/>
</nav>
);
}