Files
Ekklesia/surfaces/shell/src/dashboard-routes.ts
T
ekklesiaandClaude Opus 5.5 cd0287537e
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
Add the account area and account-synced Appearance preferences
Stage 1 PR 6 (ADR 0062). /account shows who is signed in; /account/appearance
chooses System, Light, or Dark and reduced motion (System, Reduce, Allow).

- Core: ekklesia_core_user_preference, one typed, CHECK-constrained,
  nullable column per preference (null = never chosen), migration
  0008_user_preferences (additive), @ekklesia/core/account store, and the
  authenticated-baseline permission core.account.self.manage.
- API: GET /api/v1/account (name, email, preferences) and partial
  PATCH /api/v1/account/preferences, self-only by construction: no user id
  is accepted anywhere; trusted Origin and body limit on the mutation.
- Shell: the account control shows name, email, and initials (never the
  unvetted image URL) and offers Account, Appearance, Sign out; native
  radio groups apply a choice at once through the ADR 0060 controller and
  save it; the server's answer wins and a failed save reverts.
- First paint: a classic, same-origin, render-blocking appearance.js in
  <head> applies a two-word localStorage cache before paint (no inline
  script, CSP unchanged). The account replaces the cache on every load,
  sign-out clears it, and other tabs follow via the storage event.
- Deploy smoke: real-Chromium checks for light/dark, OS emulation,
  overrides, reload persistence, pre-paint, Shadow DOM modules, reduced
  motion, the display staying light, and phone width; the browser
  container gets a 512 MiB /dev/shm so a long session no longer fails
  requests with ERR_INSUFFICIENT_RESOURCES.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:56:50 -05:00

218 lines
8.9 KiB
TypeScript

/**
* The Dashboard's URL space (ADR 0057): what each Dashboard path means.
*
* Framework-free and free of browser globals, because two programs read it. The shell parses the
* address bar with it, and `apps/api` serves the Dashboard page at the platform pages it lists, so a
* refreshed or bookmarked deep link reaches the shell instead of a `404`.
*
* Module pages and platform pages share one top-level namespace:
*
* ```text
* / Overview
* /settings Settings
* /settings/<section> a platform Settings section: general, branding, displays, system
* /settings/discover/<view> finding, installing, and managing modules: browse, installed, updates
* /settings/<module-id> a module's settings (and later its own deeper settings pages)
* /account your account
* /account/<section> a personal settings page: appearance
* /<module-id>[/…] a module's page, and later its own sub-pages
* ```
*
* The server serves the page at `/<module-id>` only for a module that is actually installed with a
* Dashboard UI, and Core refuses any module whose id is a platform-owned name (`RESERVED_MODULE_IDS`
* in `@ekklesia/core/modules`), so a module page can never shadow a platform page and an arbitrary
* root path never becomes one.
*/
/**
* The Dashboard's own top-level pages besides the Overview. Reserved whole, including paths no page
* uses yet, so adding a Settings or Account page needs no server or proxy change. Each is also a
* reserved module id in Core; `apps/api` tests hold the two lists together.
*/
export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const);
/**
* The platform's own Settings sections, in their navigation order (ADR 0061). Every other name under
* `/settings/` is a module's settings, at `/settings/<module-id>`. Module lifecycle — finding,
* installing, turning on and off, updating — is Discover, at `/settings/discover/…`; configuring a
* module is its own `/settings/<module-id>`. Each section name is also a reserved module id in Core,
* so no module can take one, and `apps/api` tests hold the two lists together.
*/
export const SETTINGS_SECTIONS = Object.freeze([
"general",
"branding",
"displays",
"discover",
"system",
] as const);
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
/** The sections that are one page each. Discover is a group of views instead. */
export type SettingsPageSection = Exclude<SettingsSection, "discover">;
/**
* The account area's personal settings pages, in navigation order (ADR 0062). Personal, never
* church administration: that is Settings. They live beneath `/account/`, so none can collide with a
* module id and none is reserved.
*/
export const ACCOUNT_SECTIONS = Object.freeze(["appearance"] as const);
export type AccountSection = (typeof ACCOUNT_SECTIONS)[number];
/**
* Discover's views, in navigation order: Browse (find and install modules), Installed (see and
* manage installed modules), and Updates (module and platform updates). Browse is where Discover
* opens. They live beneath `/settings/discover/`, so none can collide with a module id.
*/
export const DISCOVER_VIEWS = Object.freeze(["browse", "installed", "updates"] as const);
export type DiscoverView = (typeof DISCOVER_VIEWS)[number];
/** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */
export type DashboardRoute =
| { readonly kind: "overview" }
| { readonly kind: "module"; readonly moduleId: string }
| { readonly kind: "settings" }
| { readonly kind: "settings-section"; readonly section: SettingsPageSection }
| { readonly kind: "discover"; readonly view: DiscoverView }
| { readonly kind: "module-settings"; readonly moduleId: string }
| { readonly kind: "account" }
| { readonly kind: "account-section"; readonly section: AccountSection }
| { readonly kind: "not-found" };
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */
export type DashboardDestination = Exclude<DashboardRoute, { readonly kind: "not-found" }>;
const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
const settingsSections: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
const discoverViews: ReadonlySet<string> = new Set(DISCOVER_VIEWS);
const accountSections: ReadonlySet<string> = new Set(ACCOUNT_SECTIONS);
function isSettingsPageSection(value: string): value is SettingsPageSection {
return value !== "discover" && settingsSections.has(value);
}
function isDiscoverView(value: string): value is DiscoverView {
return discoverViews.has(value);
}
function isAccountSection(value: string): value is AccountSection {
return accountSections.has(value);
}
function decodeSegment(segment: string): string | undefined {
try {
const decoded = decodeURIComponent(segment);
return decoded.length > 0 ? decoded : undefined;
} catch {
return undefined;
}
}
/**
* Reads a path into a route.
*
* A single top-level segment that is not a platform page names a module's page. The id is only
* decoded here, never validated against a pattern: the shell compares it with the navigation the
* server resolved, and an id that matches no entry is an unavailable module, not a malformed URL.
*
* Everything below `/<module-id>/` belongs to that module's own pages (`/signage/timelines`), which
* need a contract that does not exist yet, so it is `not-found` for now rather than silently showing
* the module's first page. Settings and Account pages are likewise `not-found` until they exist. One
* trailing slash is tolerated, as a person typing a URL expects; the shell then replaces it with the
* canonical spelling.
*/
export function parseDashboardPath(pathname: string): DashboardRoute {
if (pathname === "/") {
return { kind: "overview" };
}
const segments = pathname.split("/");
if (segments[0] !== "") {
return { kind: "not-found" };
}
const meaningful = segments.at(-1) === "" ? segments.slice(1, -1) : segments.slice(1);
const first = meaningful[0] === undefined ? undefined : decodeSegment(meaningful[0]);
if (first === "settings") {
return parseSettingsPath(meaningful.slice(1));
}
if (first === "account") {
return parseAccountPath(meaningful.slice(1));
}
if (first === undefined || platformPages.has(first)) {
return { kind: "not-found" };
}
if (meaningful.length === 1) {
return { kind: "module", moduleId: first };
}
return { kind: "not-found" };
}
/**
* Reads what follows `/settings`. One more segment is a platform section or, failing that, a
* module's settings. `discover` is entered at Browse: `/settings/discover` reads as Browse, whose
* canonical spelling is `/settings/discover/browse`, so the shell respells it in place and there is
* one Browse address rather than two. Anything deeper is reserved for later Settings pages and is
* `not-found` until they exist, rather than silently showing their parent.
*/
function parseSettingsPath(rest: readonly string[]): DashboardRoute {
if (rest.length === 0) {
return { kind: "settings" };
}
const name = rest[0] === undefined ? undefined : decodeSegment(rest[0]);
if (name === undefined) {
return { kind: "not-found" };
}
if (name === "discover") {
if (rest.length === 1) {
return { kind: "discover", view: "browse" };
}
const view = rest[1] === undefined ? undefined : decodeSegment(rest[1]);
return rest.length === 2 && view !== undefined && isDiscoverView(view)
? { kind: "discover", view }
: { kind: "not-found" };
}
if (rest.length !== 1) {
return { kind: "not-found" };
}
return isSettingsPageSection(name)
? { kind: "settings-section", section: name }
: { kind: "module-settings", moduleId: name };
}
/** Reads what follows `/account`: the account page itself, or one of its personal settings pages.
* Anything else is `not-found` until it exists. */
function parseAccountPath(rest: readonly string[]): DashboardRoute {
if (rest.length === 0) {
return { kind: "account" };
}
const name = rest[0] === undefined ? undefined : decodeSegment(rest[0]);
return rest.length === 1 && name !== undefined && isAccountSection(name)
? { kind: "account-section", section: name }
: { kind: "not-found" };
}
/** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its
* own path segment. */
export function dashboardPath(route: DashboardDestination): string {
switch (route.kind) {
case "overview":
return "/";
case "module":
return `/${encodeURIComponent(route.moduleId)}`;
case "settings":
return "/settings";
case "settings-section":
return `/settings/${route.section}`;
case "discover":
return `/settings/discover/${route.view}`;
case "module-settings":
return `/settings/${encodeURIComponent(route.moduleId)}`;
case "account":
return "/account";
case "account-section":
return `/account/${route.section}`;
}
}