Stage 1 PR 5: the Settings shell and routing hierarchy, including the corrected module-management model (Discover). Decisions are recorded in ADR 0061.
This PR covers routing, navigation, permission-aware visibility, and honest placeholders only. No section's real controls are built: no Discover catalog, installation, lifecycle or updates; no Displays, Branding or System pages; no module configuration screens.
Settings hierarchy
/settings Settings landing page (a real page, not a redirect)
/settings/general General
/settings/branding Branding
/settings/displays Displays
/settings/discover → respelled in place to Browse (one canonical address)
/settings/discover/browse Discover modules
/settings/discover/installed Installed modules
/settings/discover/updates Module updates
/settings/system System
/settings/<module-id> that module's settings, e.g. /settings/signage
/settings/<module-id>/… reserved for deeper module settings (not found until built)
/settings/discover/… is module lifecycle. /settings/<module-id> is configuring that module. There is no /settings/modules, and no /settings/modules/<id>.
"Modules" stays the user-facing word. "Discover" is a navigation destination; its pages say "Discover modules", "Installed modules", and "Module updates".
Navigation
One Settings item in the same sidebar, after the modules and above the account area.
While you are in Settings, it expands in place into Platform (General, Branding, Displays, Discover, System) and Modules (each module's settings). Discover shows Browse, Installed and Updates beneath it.
The rest of the navigation stays, so leaving Settings is one click.
The phone drawer has the identical hierarchy. Exactly one item carries aria-current="page".
Reserved ids
Core's one reserved list (core/src/modules/reserved-ids.ts) now has two documented groups:
Group
Names
Top-level (/<name>)
account, api, assets, display, settings
Settings sections (/settings/<name>)
branding, discover, displays, general, system
Discovery refuses these ids (MODULE_ID_RESERVED).
modules is an ordinary id again.
Look-alikes are accepted: general-info, discovery, display-board, systems.
Discover's views live under /settings/discover/, so they aren't reserved.
apps/api tests hold Core's Settings group equal to the shell's SETTINGS_SECTIONS.
Permission behaviour
What the shell knew before this PR: signed-in plus an opaque user id, and each module's own permission hints. Nothing about platform permissions.
A new GET /api/v1/dashboard/settings evaluates, through Core's one evaluator, which destinations the viewer may see. Reading it requires core.modules.ui.read, like the navigation.
Destination
Shown to
Displays
core.devices.manage
Discover
core.modules.lifecycle.manage
A module's settings
core.modules.configuration.manage, for each module whose manifest declares configuration (ADR 0029). Disabled modules are flagged unavailable.
General, Branding, System
platform owner only, until each has its own permission
⚠️ Interim rule for review: General, Branding and System have no permission yet, because nothing in them exists. Rather than invent three speculative permissions, they're shown only to the platform owner, who already holds every permission. Each gains a delegable permission when it's built.
These are presentation hints, not authority. A test proves the data behind each section is still 403 without its permission.
An evaluation failure is a 503, never an empty answer.
With nothing visible, there's no Settings link.
If the Settings view fails to load, the link is withheld and Settings pages say so, with Retry. The rest of the Dashboard keeps working.
Has settings? Use the manifest's existing configuration declaration (Signage declares 10 keys). No manifest v9.
What each address shows
A destination the viewer may see shows its page.
A section or module the viewer can't see shows These settings aren't available, without saying why.
A disabled module shows This module is turned off. It does not redirect.
An unknown name shows Page not found.
Every page is a placeholder: Back to Settings (PageHeader), title, description, and Not available yet. There are no fake fields.
Copy note
You suggested "Modules add capabilities to Ekklesia…". Hard-coding the product name conflicts with DESIGN.md locked decision 1 and ADR 0046, so the Browse page says "Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses."
Public API / package changes
None. Core, the API, and the shell are private, and no @ekklesia/* public package changed. The only module-visible effect is the five newly reserved ids.
Tests
Core: reserved groups, all ten names refused, look-alikes (including modules) accepted.
API (dashboard-settings-routes.test.ts, new):
nothing visible without permissions;
anonymous gets 403;
the owner sees every section and module;
Displays, Discover and module settings are each gated by exactly their permission, with disabled modules flagged;
General, Branding and System are owner-only;
evaluation failure gives 503;
data routes are still 403 regardless of hints.
API (dashboard-host-routes.test.ts): reserved-name parity.
Shell routes: landing page, each section, Discover views plus canonical entry, /settings/<id> (including /settings/modules as an ordinary id), deeper paths not found, canonical spelling.
scripts/smoke-deploy.sh, both same-origin and split: passed. The split run includes the new Settings browser steps in all five browser passes.
Not run: test:package-release and test:package-publication. No public package changed; CI runs them regardless.
Visual check
Headless Chromium, light and dark, 1280×860:
Settings landing page
Discover → Browse, with nested views and the active state
module settings
Also checked at 390×820 in dark: the phone drawer on Settings → Displays.
Deferred
The real General, Branding, Displays, Discover (catalog, install, lifecycle, updates) and System pages, each with its own permission.
Platform-update permission.
Module settings screens and deeper pages.
A module linking to its settings with a validated return target (module navigation contract / manifest v9). Back currently goes to Settings, though the validator already accepts /signage-style targets.
Moving the transitional device panel into Settings → Displays.
The account area.
DESIGN.md is unchanged. §9.3's conceptual path "Settings → Modules → Signage → Settings" now reads as "Settings → Modules group → Signage". You may want to update that wording yourself.
Stage 1 PR 5: **the Settings shell and routing hierarchy**, including the corrected module-management model (**Discover**). Decisions are recorded in [ADR 0061](docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md).
This PR covers routing, navigation, permission-aware visibility, and honest placeholders only. No section's real controls are built: no Discover catalog, installation, lifecycle or updates; no Displays, Branding or System pages; no module configuration screens.
## Settings hierarchy
```text
/settings Settings landing page (a real page, not a redirect)
/settings/general General
/settings/branding Branding
/settings/displays Displays
/settings/discover → respelled in place to Browse (one canonical address)
/settings/discover/browse Discover modules
/settings/discover/installed Installed modules
/settings/discover/updates Module updates
/settings/system System
/settings/<module-id> that module's settings, e.g. /settings/signage
/settings/<module-id>/… reserved for deeper module settings (not found until built)
```
- `/settings/discover/…` is **module lifecycle**. `/settings/<module-id>` is **configuring that module**. There is no `/settings/modules`, and no `/settings/modules/<id>`.
- "Modules" stays the user-facing word. "Discover" is a navigation destination; its pages say "Discover modules", "Installed modules", and "Module updates".
**Navigation**
- One **Settings** item in the same sidebar, after the modules and above the account area.
- While you are in Settings, it expands in place into **Platform** (General, Branding, Displays, Discover, System) and **Modules** (each module's settings). Discover shows Browse, Installed and Updates beneath it.
- The rest of the navigation stays, so leaving Settings is one click.
- The phone drawer has the identical hierarchy. Exactly one item carries `aria-current="page"`.
## Reserved ids
Core's one reserved list (`core/src/modules/reserved-ids.ts`) now has two documented groups:
| Group | Names |
| --- | --- |
| Top-level (`/<name>`) | `account`, `api`, `assets`, `display`, `settings` |
| Settings sections (`/settings/<name>`) | `branding`, `discover`, `displays`, `general`, `system` |
- Discovery refuses these ids (`MODULE_ID_RESERVED`).
- `modules` is an ordinary id again.
- Look-alikes are accepted: `general-info`, `discovery`, `display-board`, `systems`.
- Discover's views live under `/settings/discover/`, so they aren't reserved.
- `apps/api` tests hold Core's Settings group equal to the shell's `SETTINGS_SECTIONS`.
## Permission behaviour
What the shell knew before this PR: signed-in plus an opaque user id, and each module's own permission hints. Nothing about platform permissions.
A new **`GET /api/v1/dashboard/settings`** evaluates, through Core's one evaluator, which destinations the viewer may see. Reading it requires `core.modules.ui.read`, like the navigation.
| Destination | Shown to |
| --- | --- |
| Displays | `core.devices.manage` |
| Discover | `core.modules.lifecycle.manage` |
| A module's settings | `core.modules.configuration.manage`, for each module whose manifest declares `configuration` (ADR 0029). Disabled modules are flagged unavailable. |
| General, Branding, System | **platform owner only**, until each has its own permission |
- **⚠️ Interim rule for review:** General, Branding and System have no permission yet, because nothing in them exists. Rather than invent three speculative permissions, they're shown only to the platform owner, who already holds every permission. Each gains a delegable permission when it's built.
- These are **presentation hints, not authority**. A test proves the data behind each section is still `403` without its permission.
- An evaluation failure is a `503`, never an empty answer.
- With nothing visible, there's **no Settings link**.
- If the Settings view fails to load, the link is withheld and Settings pages say so, with Retry. The rest of the Dashboard keeps working.
- **Has settings?** Use the manifest's existing `configuration` declaration (Signage declares 10 keys). No manifest v9.
## What each address shows
- A destination the viewer may see shows its page.
- A section or module the viewer can't see shows *These settings aren't available*, without saying why.
- A disabled module shows *This module is turned off*. It does not redirect.
- An unknown name shows *Page not found*.
Every page is a placeholder: Back to Settings (`PageHeader`), title, description, and *Not available yet*. There are no fake fields.
## Copy note
You suggested "Modules add capabilities to **Ekklesia**…". Hard-coding the product name conflicts with DESIGN.md locked decision 1 and ADR 0046, so the Browse page says "Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses."
## Public API / package changes
**None.** Core, the API, and the shell are private, and no `@ekklesia/*` public package changed. The only module-visible effect is the five newly reserved ids.
## Tests
- **Core:** reserved groups, all ten names refused, look-alikes (including `modules`) accepted.
- **API (`dashboard-settings-routes.test.ts`, new):**
- nothing visible without permissions;
- anonymous gets `403`;
- the owner sees every section and module;
- Displays, Discover and module settings are each gated by exactly their permission, with disabled modules flagged;
- General, Branding and System are owner-only;
- evaluation failure gives `503`;
- data routes are still `403` regardless of hints.
- **API (`dashboard-host-routes.test.ts`):** reserved-name parity.
- **Shell routes:** landing page, each section, Discover views plus canonical entry, `/settings/<id>` (including `/settings/modules` as an ordinary id), deeper paths not found, canonical spelling.
- **Shell protocol:** settings fetched alongside the navigation; failure tolerated; strict parsing.
- **Shell `settings-navigation.test.tsx` (new, 17 tests):**
- the landing page;
- each section with Back and no fake controls;
- Discover canonical entry with no extra history entry, and the three views;
- no-access state;
- module settings in all four states;
- nav expanding only inside Settings, and leaving it;
- permission-filtered nav;
- no link with nothing visible;
- load failure with Retry;
- Back/Forward and refresh;
- focus moving between Settings pages;
- the phone drawer: same hierarchy, 48px targets, closes on choice;
- page titles.
- **Existing tests** updated for the extra bootstrap request and for `/settings` now being a page.
- **Deploy smoke (real Chromium, split topology):**
- deep link to `/settings`;
- choose Displays, then refresh;
- enter Discover and land at `/settings/discover/browse`;
- open `/settings/<module>`;
- phone drawer, choose System;
- widen, then return to the Overview with the Settings sub-navigation gone.
### Results (on the pushed commit)
- `TURBO_FORCE=true pnpm verify` (no Turbo cache, see #105): passed. shell 337, api 532, core 1161, root 165, every package.
- `pnpm infrastructure:config` and `pnpm deploy:config`: passed.
- `pnpm test:migrations:integration`: passed, 401 tests.
- `scripts/smoke-deploy.sh`, both same-origin and `split`: passed. The split run includes the new Settings browser steps in all five browser passes.
- Not run: `test:package-release` and `test:package-publication`. No public package changed; CI runs them regardless.
### Visual check
Headless Chromium, light and dark, 1280×860:
- Settings landing page
- Discover → Browse, with nested views and the active state
- module settings
Also checked at 390×820 in dark: the phone drawer on Settings → Displays.
## Deferred
- The real General, Branding, Displays, Discover (catalog, install, lifecycle, updates) and System pages, each with its own permission.
- Platform-update permission.
- Module settings screens and deeper pages.
- A module linking to its settings with a validated return target (module navigation contract / manifest v9). Back currently goes to Settings, though the validator already accepts `/signage`-style targets.
- Moving the transitional device panel into Settings → Displays.
- The account area.
`DESIGN.md` is unchanged. §9.3's conceptual path "Settings → Modules → Signage → Settings" now reads as "Settings → Modules group → Signage". You may want to update that wording yourself.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Stage 1 PR 5: the Settings shell and routing hierarchy, including the corrected module-management model (Discover). Decisions are recorded in ADR 0061.
This PR covers routing, navigation, permission-aware visibility, and honest placeholders only. No section's real controls are built: no Discover catalog, installation, lifecycle or updates; no Displays, Branding or System pages; no module configuration screens.
Settings hierarchy
/settings/discover/…is module lifecycle./settings/<module-id>is configuring that module. There is no/settings/modules, and no/settings/modules/<id>.Navigation
aria-current="page".Reserved ids
Core's one reserved list (
core/src/modules/reserved-ids.ts) now has two documented groups:/<name>)account,api,assets,display,settings/settings/<name>)branding,discover,displays,general,systemMODULE_ID_RESERVED).modulesis an ordinary id again.general-info,discovery,display-board,systems./settings/discover/, so they aren't reserved.apps/apitests hold Core's Settings group equal to the shell'sSETTINGS_SECTIONS.Permission behaviour
What the shell knew before this PR: signed-in plus an opaque user id, and each module's own permission hints. Nothing about platform permissions.
A new
GET /api/v1/dashboard/settingsevaluates, through Core's one evaluator, which destinations the viewer may see. Reading it requirescore.modules.ui.read, like the navigation.core.devices.managecore.modules.lifecycle.managecore.modules.configuration.manage, for each module whose manifest declaresconfiguration(ADR 0029). Disabled modules are flagged unavailable.403without its permission.503, never an empty answer.configurationdeclaration (Signage declares 10 keys). No manifest v9.What each address shows
Every page is a placeholder: Back to Settings (
PageHeader), title, description, and Not available yet. There are no fake fields.Copy note
You suggested "Modules add capabilities to Ekklesia…". Hard-coding the product name conflicts with DESIGN.md locked decision 1 and ADR 0046, so the Browse page says "Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses."
Public API / package changes
None. Core, the API, and the shell are private, and no
@ekklesia/*public package changed. The only module-visible effect is the five newly reserved ids.Tests
modules) accepted.dashboard-settings-routes.test.ts, new):403;503;403regardless of hints.dashboard-host-routes.test.ts): reserved-name parity./settings/<id>(including/settings/modulesas an ordinary id), deeper paths not found, canonical spelling.settings-navigation.test.tsx(new, 17 tests):/settingsnow being a page./settings;/settings/discover/browse;/settings/<module>;Results (on the pushed commit)
TURBO_FORCE=true pnpm verify(no Turbo cache, see #105): passed. shell 337, api 532, core 1161, root 165, every package.pnpm infrastructure:configandpnpm deploy:config: passed.pnpm test:migrations:integration: passed, 401 tests.scripts/smoke-deploy.sh, both same-origin andsplit: passed. The split run includes the new Settings browser steps in all five browser passes.test:package-releaseandtest:package-publication. No public package changed; CI runs them regardless.Visual check
Headless Chromium, light and dark, 1280×860:
Also checked at 390×820 in dark: the phone drawer on Settings → Displays.
Deferred
/signage-style targets.DESIGN.mdis unchanged. §9.3's conceptual path "Settings → Modules → Signage → Settings" now reads as "Settings → Modules group → Signage". You may want to update that wording yourself.🤖 Generated with Claude Code
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]>