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
@@ -0,0 +1,198 @@
import type {
DashboardDestination,
DiscoverView,
SettingsPageSection,
SettingsSection,
} from "../dashboard-routes.js";
import type { DashboardSettings, DashboardSettingsModule } from "../host-protocol.js";
import type { ShellIconName } from "./shell-icons.js";
/**
* How each platform Settings section is named and described to a person (ADR 0061). One table, so
* the navigation, the Settings landing page, and each section's own page say the same thing. Plain
* words — never "Core", "platform module", or another implementation term (DESIGN.md §2.3).
*/
export const SETTINGS_SECTION_COPY: Readonly<
Record<
SettingsSection,
{ readonly label: string; readonly description: string; readonly icon: ShellIconName }
>
> = Object.freeze({
branding: {
description: "Your church's name, logo, and colours.",
icon: "settingsBranding",
label: "Branding",
},
displays: {
description: "Screens paired with this installation.",
icon: "settingsDisplays",
label: "Displays",
},
general: {
description: "How this installation works for everyone.",
icon: "settingsGeneral",
label: "General",
},
discover: {
description: "Find, install, and manage the modules your church uses.",
icon: "settingsDiscover",
label: "Discover",
},
system: {
description: "Version, health, and diagnostics for this installation.",
icon: "settingsSystem",
label: "System",
},
});
/**
* Discover's views (ADR 0061). "Discover" is where module lifecycle lives in the navigation; inside
* it the pages speak of modules — "Discover modules", "Installed modules", "Module updates" —
* because Modules is the product's word for them.
*/
export const DISCOVER_VIEW_COPY: Readonly<
Record<
DiscoverView,
{
readonly label: string;
readonly title: string;
readonly description: string;
readonly notYet: string;
readonly icon: ShellIconName;
}
>
> = Object.freeze({
browse: {
description:
"Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.",
icon: "discoverBrowse",
label: "Browse",
notYet: "Finding and installing modules will happen here.",
title: "Discover modules",
},
installed: {
description: "The modules installed for your church.",
icon: "discoverInstalled",
label: "Installed",
notYet:
"Seeing your installed modules, turning them on or off, inspecting them, and removing them will happen here.",
title: "Installed modules",
},
updates: {
description: "Updates for your modules and for this installation.",
icon: "discoverUpdates",
label: "Updates",
notYet: "Checking for and applying module and platform updates will happen here.",
title: "Module updates",
},
});
/** Where a platform section's navigation item leads: its page, or — for Discover — Browse. */
export function settingsSectionDestination(section: SettingsSection): DashboardDestination {
return section === "discover"
? { kind: "discover", view: "browse" }
: { kind: "settings-section", section };
}
/** The module settings a person can open from navigation: running modules only. A disabled
* module's settings are not offered, though its address still says it is unavailable. */
export function navigableModuleSettings(
settings: DashboardSettings | "unavailable",
): readonly DashboardSettingsModule[] {
return settings === "unavailable" ? [] : settings.modules.filter((module) => module.available);
}
/** Whether Settings has anything to offer this person. With nothing, the Settings link is not
* shown at all, so nobody is shown administration they cannot carry out (DESIGN.md §9.2). */
export function hasVisibleSettings(settings: DashboardSettings | "unavailable"): boolean {
return (
settings !== "unavailable" &&
(settings.sections.length > 0 || navigableModuleSettings(settings).length > 0)
);
}
export function isSettingsRoute(kind: string): boolean {
return (
kind === "settings" ||
kind === "settings-section" ||
kind === "discover" ||
kind === "module-settings"
);
}
/** What a Settings address shows this person. */
export type SettingsPage =
| { readonly kind: "home"; readonly settings: DashboardSettings }
| { readonly kind: "section"; readonly section: SettingsPageSection }
| { readonly kind: "discover"; readonly view: DiscoverView }
| { readonly kind: "module"; readonly module: DashboardSettingsModule }
| { readonly kind: "unavailable"; readonly reason: "no-access" | "module-disabled" }
| { readonly kind: "load-failed" }
| { readonly kind: "not-found" };
/**
* Decides what a Settings address shows (ADR 0061), from what the server said this person may see:
*
* - a section or module they may see shows its page;
* - a disabled module's settings say it is turned off — never a silent redirect;
* - a section they may not see, or a module they know of but may not configure (or that has no
* settings), says the settings aren't available, without saying which;
* - a name that is no module they know of is not found.
*/
export function resolveSettingsPage(
route:
| { readonly kind: "settings" }
| { readonly kind: "settings-section"; readonly section: SettingsPageSection }
| { readonly kind: "discover"; readonly view: DiscoverView }
| { readonly kind: "module-settings"; readonly moduleId: string },
settings: DashboardSettings | "unavailable",
knownModuleIds: ReadonlySet<string>,
): SettingsPage {
if (settings === "unavailable") {
return { kind: "load-failed" };
}
if (route.kind === "settings") {
return { kind: "home", settings };
}
if (route.kind === "discover") {
return settings.sections.includes("discover")
? { kind: "discover", view: route.view }
: { kind: "unavailable", reason: "no-access" };
}
if (route.kind === "settings-section") {
return settings.sections.includes(route.section)
? { kind: "section", section: route.section }
: { kind: "unavailable", reason: "no-access" };
}
const module = settings.modules.find((candidate) => candidate.id === route.moduleId);
if (module) {
return module.available
? { kind: "module", module }
: { kind: "unavailable", reason: "module-disabled" };
}
return knownModuleIds.has(route.moduleId)
? { kind: "unavailable", reason: "no-access" }
: { kind: "not-found" };
}
/** The browser tab's name for a Settings page. */
export function settingsPageTitle(page: SettingsPage): string {
switch (page.kind) {
case "home":
return "Settings";
case "section":
return `${SETTINGS_SECTION_COPY[page.section].label} settings`;
case "discover":
return DISCOVER_VIEW_COPY[page.view].title;
case "module":
return `${page.module.name} settings`;
case "unavailable":
return page.reason === "module-disabled"
? "This module is turned off"
: "These settings aren't available";
case "load-failed":
return "Settings could not be loaded";
case "not-found":
return "Page not found";
}
}