This PR fixes the Stage 1 UX defects found in the hand review of the assembled Dashboard (desktop expanded, desktop collapsed, and iPhone/Safari). It is a shell, navigation, and Settings composition pass, plus moving the existing display pairing into Settings → Displays. No Signage change, no manifest change, no authentication change, and no new backend API. The decisions are recorded in ADR 0064, which supersedes parts of ADR 0058 and ADR 0061.
1. Discover: one page, two children
Before
After
Discover's page
/settings/discover respelled to /settings/discover/browse
/settings/discover is Discover's own page, Discover modules
Beneath Discover
Browse, Installed, Updates, each with its own icon
Installed, Updates, text only
/settings/discover/browse
canonical
alias only: read, then respelled in place to /settings/discover (keeps ?return=, adds no history entry), never linked
Collapsed rail
Discover plus three child icons
Discover only, current for every Discover page
The hierarchy is now explicit for every parent: Discover, Settings, and a module with pages.
A guide line runs down from under the parent's icon.
Children are indented past the parent's label.
Children are text only, with a smaller control on desktop and the large touch target in the drawer.
Expandable parents carry a disclosure chevron. It rotates over a motion token, so it doesn't rotate under reduced motion.
Current (selected fill, bold, aria-current) is a different state from parent of current (bold, accent icon, no fill), so the two never both look selected.
The drawer uses the same hierarchy as the sidebar.
2. The sidebar is navigation only; pairing lives in Settings → Displays
The Display devices block (heading, code field, Find display, the list) is gone from both the sidebar and the drawer. Its working functionality moved to /settings/displays, which is now the first real platform Settings page:
a header with Add display;
Paired displays: a card per screen with its name, a Paired or Revoked badge, its paired date, and a coarse "last seen", plus Revoke;
an Add display dialog: code → Find display → "A display is asking to show Signage" → display name → Pair display. The new display appears without a refresh, and the page shows a success notice;
the existing product dialog before revoking;
empty, loading, failed (with Retry), and not-permitted states.
It uses the same endpoints, the same core.devices.manage authorization, and the same platform display identity. The client now also reads createdAt and lastSeenAt, which /api/v1/devices already returned. "Last seen" is deliberately coarse, because the server throttles it to roughly 15 minutes, and the page never claims a screen is online.
3. Initial focus
Nothing on page load or navigation focuses a form field. The structural fix is that no text field exists outside the explicit Add display flow. Focus enters the code field only after Add display is chosen, moves to the name field once a display is found, and returns to Add display when the dialog closes. The button focuses itself before opening the dialog, because Safari doesn't focus clicked buttons. Without that, focus restoration had nothing to return to.
Headless Chromium didn't reproduce the focus grab on the old code (focus stayed on body), so I couldn't confirm which browser behaviour caused it on the review device. The fix doesn't depend on it.
4. Collapsed icon alignment
Root cause:DashboardLink set justify-content: flex-start inline, which outranked the stylesheet's collapsed justify-content: center. Measured before the fix: 1px to the left of every glyph and 26px to its right, in a 47px box. The toggle also used a different geometry.
Fix:
Link layout and the current-page look moved into the ek-shell-link class.
Every collapsed item is one control.height-md square, display: flex and centred with auto margins, with no padding or gap.
The rail scrolls without a visible scrollbar, so a scrollbar can't take width from one side.
Measured after the fix: every item, the toggle, the brand mark, and the account trigger share one centre (x=35.5px), with each glyph 0px off centre horizontally and vertically. Tooltips sit 8px to the right, vertically aligned, on hover and on keyboard focus. There are no per-icon offsets.
5. /settings is an overview
It is still a real page, not a redirect. It no longer repeats the sidebar's list of sections:
Card
Shows
Links
Displays
"N displays paired" and which was most recently seen
Manage displays, or Add a display when none are paired
Modules
"N modules installed", and whether all are running or how many are turned off or can't start
Manage modules, and settings per running module
General, Branding, System
one restrained line each (e.g. Using the default name and colours)
Open …
The Displays card reads GET /api/v1/devices and the Modules card reads GET /api/v1/module-lifecycle; both endpoints already existed. Each is read only when the server says the person may see that section. Lifecycle requires the same permission as Discover. A refusal shows a quiet "Couldn't check …" line.
6. Phone header
Header: a sticky <header> holds the menu trigger (48px) and the page name. The name is aria-hidden, because the page's <h1> is the accessible title. Wide screens are unchanged.
Safe areas:viewport-fit=cover is set on the Dashboard page. The header, page, drawer, and a landscape-phone sidebar pad by env(safe-area-inset-*).
Viewport height:min-height is 100dvh, falling back to 100vh.
Scroll offset:
Root cause: focusing <main> after a page change scrolled its top to the viewport top, under the header.
Fix: the shell now focuses with preventScroll and starts each new page at its top.
Drawer: closing it returns focus to the trigger without scrolling (previously 1200px → 774px).
Scrolled-into-view elements:scroll-padding-top keeps them below the header.
Margins: the phone page margin is spacing.lg.
The drawer's focus trap, Escape, focus return, inert, Back and Forward are unchanged and were re-verified.
7. Appearance radio, fixed in the shared primitive
Root cause: the radios were native and tinted with accent-color: accent.base. Each browser picks its own contrasting mark for a tinted control, and on the light theme's pale teal that came out as a charcoal ring. The shared Checkbox used the same technique.
Fix:@ekklesia/ui's stylesheet now draws .ek-checkbox__input and a new .ek-radio__input from tokens:
Unselected:border.control ring on surface.
Selected:accent.strong ring, surface gap, and accent.strong dot. The checkbox fills with accent.strong and draws a surface check.
Hover:border.strong.
Disabled:state.disabled-opacity.
Forced colours: the system colours.
The Appearance page uses the shared radio class; there are no page-specific colours. No Radio component was added: ADR 0039 adds one only once two consumers need it.
Visual check: verified in Light, Dark, System resolving to Light, and System resolving to Dark, plus hover, focus-visible, and disabled.
8. Placeholders
General, Branding, System, Installed, and Updates remain placeholders. They are unchanged apart from Discover's own placeholder moving to /settings/discover.
9. Routes
All Stage 1 routes still work. Verified by tests and in the browser:
deep-link refresh;
Back and Forward;
module settings return targets;
unknown routes showing Page not found;
disabled modules showing as unavailable.
Server-side authorization is untouched.
Product-doc conflict to resolve outside the repo
../ROADMAP.md §5.3 and ../DESIGN.md §9.4, §12.1, and decision 98 still say /settings/discover/browse is canonical, and that /settings/discover resolves to Browse. This PR follows your review direction, and ADR 0064 records the change. Those two files aren't in this repository, so I haven't edited them.
@ekklesia/ui and the 0.2.6 release
This PR changes @ekklesia/ui's stylesheet. The change is additive and has no API change: the .ek-radio__input class is new, and the Checkbox looks different when checked. If #111 (0.2.6) merges first, this lands in the next patch; if this merges first, it rides in 0.2.6. Either way it is compatible.
Tests
New:surfaces/shell/tests/displays-settings.test.tsx, replacing device-panel.test.tsx. It covers the list, empty and refused states, the Add display flow focusing each step, Cancel, focus return, and revocation behind a dialog.
settings-navigation.test.tsx:
the overview, and which data each card reads for whom;
Discover as the canonical page with no Browse child;
the alias respelled with ?return= kept;
the parent, child, and disclosure markers;
the collapsed rail showing Discover only;
no pairing block in the sidebar or drawer;
no field focused on arrival;
the collapsed geometry left to the stylesheet;
the sticky phone header, the drawer from it, and focus return.
dashboard-routes.test.ts: the canonical /settings/discover, its children, and the alias.
host-app.test.tsx: the shell no longer requests /api/v1/devices on every page.
packages/ui/tests/tokens.test.ts: no accent-color; the selected state uses accent.strong; the pill radius token; disabled and forced-colour rules.
scripts/lib/deploy-smoke-browser.mjs: checks the sidebar is navigation only and that Displays focuses nothing on arrival, that Add display focuses the code field only once asked, Discover at /settings/discover, the /browse alias, and the phone header after scrolling.
Verification
pnpm verify: passed (format, lint, boundaries, typecheck, every package's tests, module and migration validation, identity schema, build). The shell has 485 tests in 24 files and @ekklesia/ui has 64.
pnpm test:package-release: passed. The train is reproducible and the standalone consumer proof passed.
EKKLESIA_SMOKE_TOPOLOGY=split pnpm deploy:smoke (real image, real Chromium): passed, 417 ok and 0 failed, including the new Settings → Displays, Discover, alias, and phone-header checks.
pnpm deploy:smoke (same-origin): passed, 49 ok and 0 failed.
Not run: pnpm test:migrations:integration and pnpm test:package-publication. No API, migration, or publication code changed.
Visual pass (real Chromium, against a mock-API harness of this branch)
Screenshots were captured for each area below; I can attach them on request.
Wide desktop, expanded: the Settings overview, Discover, Installed, Updates, Displays, the Add display steps, Appearance, and a v9 module page.
Wide desktop, collapsed: Overview, Signage, Settings, General, and Discover-while-on-Installed. The tooltip is checked on hover and on keyboard focus, along with the measured centring above.
Tablet (834px): the sidebar layout, with no overflow.
Phone (390×844 @3x), Light and Dark: Settings, Displays, Discover, and Appearance on arrival. On every page scrollY is 0, the header is at the top, content starts below the 61px header, and there is no horizontal overflow.
Phone after scrolling: the trigger stays at the top. The drawer opens from it, keeps focus inside it, and Escape returns focus without scrolling. Navigating from the drawer lands at the top with focus on <main>. Back and Forward work.
Radios: Light, Dark, System→Light, and System→Dark, with hover, focus-visible, and disabled.
Reduced motion: the disclosure and sidebar transitions are 0s, compared with 0.16s normally.
Deferred
A routable /settings/displays/add for modules to deep-link to.
Rename, rooms or locations, and history for displays.
Real General, Branding, System, Installed, and Updates pages, and richer overview cards with them.
Signage consuming platform displays.
Found during this work
Zayin/Signage#24: Signage 0.1.5's Displays panel tells administrators to pair under "Display devices in the Dashboard sidebar", which this PR removes. Pairing itself is unaffected.
This PR fixes the Stage 1 UX defects found in the hand review of the assembled Dashboard (desktop expanded, desktop collapsed, and iPhone/Safari). It is a shell, navigation, and Settings composition pass, plus moving the existing display pairing into Settings → Displays. **No Signage change, no manifest change, no authentication change, and no new backend API.** The decisions are recorded in **ADR 0064**, which supersedes parts of ADR 0058 and ADR 0061.
## 1. Discover: one page, two children
| | Before | After |
| --- | --- | --- |
| Discover's page | `/settings/discover` respelled to `/settings/discover/browse` | **`/settings/discover`** is Discover's own page, *Discover modules* |
| Beneath Discover | Browse, Installed, Updates, each with its own icon | **Installed, Updates**, text only |
| `/settings/discover/browse` | canonical | **alias only**: read, then respelled in place to `/settings/discover` (keeps `?return=`, adds no history entry), never linked |
| Collapsed rail | Discover plus three child icons | **Discover only**, current for every Discover page |
The hierarchy is now explicit for every parent: Discover, Settings, and a module with pages.
- A guide line runs down from under the parent's icon.
- Children are indented past the parent's label.
- Children are text only, with a smaller control on desktop and the large touch target in the drawer.
- Expandable parents carry a disclosure chevron. It rotates over a motion token, so it doesn't rotate under reduced motion.
- **Current** (selected fill, bold, `aria-current`) is a different state from **parent of current** (bold, accent icon, no fill), so the two never both look selected.
- The drawer uses the same hierarchy as the sidebar.
## 2. The sidebar is navigation only; pairing lives in Settings → Displays
The *Display devices* block (heading, code field, Find display, the list) is gone from both the sidebar and the drawer. Its working functionality moved to `/settings/displays`, which is now the first real platform Settings page:
- a header with **Add display**;
- **Paired displays:** a card per screen with its name, a *Paired* or *Revoked* badge, its paired date, and a coarse "last seen", plus **Revoke**;
- an **Add display** dialog: code → **Find display** → "A display is asking to show Signage" → display name → **Pair display**. The new display appears without a refresh, and the page shows a success notice;
- the existing product dialog before revoking;
- empty, loading, failed (with Retry), and not-permitted states.
It uses the same endpoints, the same `core.devices.manage` authorization, and the same platform display identity. The client now also reads `createdAt` and `lastSeenAt`, which `/api/v1/devices` already returned. "Last seen" is deliberately coarse, because the server throttles it to roughly 15 minutes, and the page never claims a screen is online.
## 3. Initial focus
Nothing on page load or navigation focuses a form field. The structural fix is that no text field exists outside the explicit Add display flow. Focus enters the code field only after **Add display** is chosen, moves to the name field once a display is found, and returns to **Add display** when the dialog closes. The button focuses itself before opening the dialog, because Safari doesn't focus clicked buttons. Without that, focus restoration had nothing to return to.
Headless Chromium didn't reproduce the focus grab on the old code (focus stayed on `body`), so I couldn't confirm which browser behaviour caused it on the review device. The fix doesn't depend on it.
## 4. Collapsed icon alignment
**Root cause:** `DashboardLink` set `justify-content: flex-start` inline, which outranked the stylesheet's collapsed `justify-content: center`. Measured before the fix: 1px to the left of every glyph and 26px to its right, in a 47px box. The toggle also used a different geometry.
**Fix:**
- Link layout and the current-page look moved into the `ek-shell-link` class.
- Every collapsed item is one `control.height-md` square, `display: flex` and centred with auto margins, with no padding or gap.
- The rail scrolls without a visible scrollbar, so a scrollbar can't take width from one side.
**Measured after the fix:** every item, the toggle, the brand mark, and the account trigger share one centre (x=35.5px), with each glyph 0px off centre horizontally and vertically. Tooltips sit 8px to the right, vertically aligned, on hover and on keyboard focus. There are no per-icon offsets.
## 5. `/settings` is an overview
It is still a real page, not a redirect. It no longer repeats the sidebar's list of sections:
| Card | Shows | Links |
| --- | --- | --- |
| Displays | "N displays paired" and which was most recently seen | *Manage displays*, or *Add a display* when none are paired |
| Modules | "N modules installed", and whether all are running or how many are turned off or can't start | *Manage modules*, and *<Module> settings* per running module |
| General, Branding, System | one restrained line each (e.g. *Using the default name and colours*) | *Open …* |
The Displays card reads `GET /api/v1/devices` and the Modules card reads `GET /api/v1/module-lifecycle`; both endpoints already existed. Each is read only when the server says the person may see that section. Lifecycle requires the same permission as Discover. A refusal shows a quiet "Couldn't check …" line.
## 6. Phone header
- **Header:** a sticky `<header>` holds the menu trigger (48px) and the page name. The name is `aria-hidden`, because the page's `<h1>` is the accessible title. Wide screens are unchanged.
- **Safe areas:** `viewport-fit=cover` is set on the Dashboard page. The header, page, drawer, and a landscape-phone sidebar pad by `env(safe-area-inset-*)`.
- **Viewport height:** `min-height` is `100dvh`, falling back to `100vh`.
- **Scroll offset:**
- *Root cause:* focusing `<main>` after a page change scrolled its top to the viewport top, under the header.
- *Fix:* the shell now focuses with `preventScroll` and starts each new page at its top.
- *Drawer:* closing it returns focus to the trigger without scrolling (previously 1200px → 774px).
- *Scrolled-into-view elements:* `scroll-padding-top` keeps them below the header.
- **Margins:** the phone page margin is `spacing.lg`.
The drawer's focus trap, Escape, focus return, `inert`, Back and Forward are unchanged and were re-verified.
## 7. Appearance radio, fixed in the shared primitive
**Root cause:** the radios were native and tinted with `accent-color: accent.base`. Each browser picks its own contrasting mark for a tinted control, and on the light theme's pale teal that came out as a charcoal ring. The shared `Checkbox` used the same technique.
**Fix:** `@ekklesia/ui`'s stylesheet now draws `.ek-checkbox__input` and a new `.ek-radio__input` from tokens:
- **Unselected:** `border.control` ring on `surface`.
- **Selected:** `accent.strong` ring, `surface` gap, and `accent.strong` dot. The checkbox fills with `accent.strong` and draws a `surface` check.
- **Hover:** `border.strong`.
- **Disabled:** `state.disabled-opacity`.
- **Forced colours:** the system colours.
The Appearance page uses the shared radio class; there are no page-specific colours. No `Radio` component was added: ADR 0039 adds one only once two consumers need it.
**Visual check:** verified in Light, Dark, System resolving to Light, and System resolving to Dark, plus hover, focus-visible, and disabled.
## 8. Placeholders
General, Branding, System, Installed, and Updates remain placeholders. They are unchanged apart from Discover's own placeholder moving to `/settings/discover`.
## 9. Routes
All Stage 1 routes still work. Verified by tests and in the browser:
- deep-link refresh;
- Back and Forward;
- module settings return targets;
- unknown routes showing *Page not found*;
- disabled modules showing as unavailable.
Server-side authorization is untouched.
## Product-doc conflict to resolve outside the repo
`../ROADMAP.md` §5.3 and `../DESIGN.md` §9.4, §12.1, and decision 98 still say `/settings/discover/browse` is canonical, and that `/settings/discover` resolves to Browse. This PR follows your review direction, and ADR 0064 records the change. Those two files aren't in this repository, so I haven't edited them.
## `@ekklesia/ui` and the 0.2.6 release
This PR changes `@ekklesia/ui`'s stylesheet. The change is additive and has no API change: the `.ek-radio__input` class is new, and the `Checkbox` looks different when checked. If #111 (0.2.6) merges first, this lands in the next patch; if this merges first, it rides in 0.2.6. Either way it is compatible.
## Tests
- **New:** `surfaces/shell/tests/displays-settings.test.tsx`, replacing `device-panel.test.tsx`. It covers the list, empty and refused states, the Add display flow focusing each step, Cancel, focus return, and revocation behind a dialog.
- **`settings-navigation.test.tsx`:**
- the overview, and which data each card reads for whom;
- Discover as the canonical page with no Browse child;
- the alias respelled with `?return=` kept;
- the parent, child, and disclosure markers;
- the collapsed rail showing Discover only;
- no pairing block in the sidebar or drawer;
- no field focused on arrival;
- the collapsed geometry left to the stylesheet;
- the sticky phone header, the drawer from it, and focus return.
- **`dashboard-routes.test.ts`:** the canonical `/settings/discover`, its children, and the alias.
- **`host-app.test.tsx`:** the shell no longer requests `/api/v1/devices` on every page.
- **`packages/ui/tests/tokens.test.ts`:** no `accent-color`; the selected state uses `accent.strong`; the pill radius token; disabled and forced-colour rules.
- **`scripts/lib/deploy-smoke-browser.mjs`:** checks the sidebar is navigation only and that Displays focuses nothing on arrival, that Add display focuses the code field only once asked, Discover at `/settings/discover`, the `/browse` alias, and the phone header after scrolling.
## Verification
- `pnpm verify`: passed (format, lint, boundaries, typecheck, every package's tests, module and migration validation, identity schema, build). The shell has 485 tests in 24 files and `@ekklesia/ui` has 64.
- `pnpm test:package-release`: passed. The train is reproducible and the standalone consumer proof passed.
- `EKKLESIA_SMOKE_TOPOLOGY=split pnpm deploy:smoke` (real image, real Chromium): passed, 417 ok and 0 failed, including the new Settings → Displays, Discover, alias, and phone-header checks.
- `pnpm deploy:smoke` (same-origin): passed, 49 ok and 0 failed.
- Not run: `pnpm test:migrations:integration` and `pnpm test:package-publication`. No API, migration, or publication code changed.
## Visual pass (real Chromium, against a mock-API harness of this branch)
Screenshots were captured for each area below; I can attach them on request.
- **Wide desktop, expanded:** the Settings overview, Discover, Installed, Updates, Displays, the Add display steps, Appearance, and a v9 module page.
- **Wide desktop, collapsed:** Overview, Signage, Settings, General, and Discover-while-on-Installed. The tooltip is checked on hover and on keyboard focus, along with the measured centring above.
- **Tablet (834px):** the sidebar layout, with no overflow.
- **Phone (390×844 @3x), Light and Dark:** Settings, Displays, Discover, and Appearance on arrival. On every page `scrollY` is 0, the header is at the top, content starts below the 61px header, and there is no horizontal overflow.
- **Phone after scrolling:** the trigger stays at the top. The drawer opens from it, keeps focus inside it, and Escape returns focus without scrolling. Navigating from the drawer lands at the top with focus on `<main>`. Back and Forward work.
- **Radios:** Light, Dark, System→Light, and System→Dark, with hover, focus-visible, and disabled.
- **Reduced motion:** the disclosure and sidebar transitions are 0s, compared with 0.16s normally.
## Deferred
- A routable `/settings/displays/add` for modules to deep-link to.
- Rename, rooms or locations, and history for displays.
- Real General, Branding, System, Installed, and Updates pages, and richer overview cards with them.
- Signage consuming platform displays.
## Found during this work
- **Zayin/Signage#24:** Signage 0.1.5's Displays panel tells administrators to pair under "Display devices in the Dashboard sidebar", which this PR removes. Pairing itself is unaffected.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The assembled Stage 1 Dashboard was reviewed by hand on a desktop, with
the sidebar collapsed, and on an iPhone. This fixes what that review
found, and records the decisions in ADR 0064:
- Discover is its own page at /settings/discover, with Installed and
Updates beneath it as children. There is no Browse item any more.
/settings/discover/browse is still read, as an alias, and is respelled
in place. Parents and children now read as a hierarchy: a guide line,
text-only children, a disclosure chevron, and parent and current
states that look different.
- The sidebar is navigation only. Display pairing moves from the sidebar
and drawer to Settings -> Displays: the paired displays, a deliberate
Add display dialog, and revocation. Nothing takes focus on arrival;
the code field is focused only once Add display is chosen.
- Collapsed icons are centred. Nav-link layout moves from inline styles
to the shell stylesheet, and every collapsed item is one square
centred on the rail.
- /settings is an overview of the installation: displays paired,
modules installed and running, and a line for each other area. It
reads only existing endpoints.
- Phones get a sticky, safe-area-aware header holding the menu trigger
and the page name. New pages start at the top, and focus no longer
scrolls content under the header.
- @ekklesia/ui draws checkbox and radio indicators from tokens rather
than accent-color, so the selected state is the strong accent in
Light and Dark alike.
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.
This PR fixes the Stage 1 UX defects found in the hand review of the assembled Dashboard (desktop expanded, desktop collapsed, and iPhone/Safari). It is a shell, navigation, and Settings composition pass, plus moving the existing display pairing into Settings → Displays. No Signage change, no manifest change, no authentication change, and no new backend API. The decisions are recorded in ADR 0064, which supersedes parts of ADR 0058 and ADR 0061.
1. Discover: one page, two children
/settings/discoverrespelled to/settings/discover/browse/settings/discoveris Discover's own page, Discover modules/settings/discover/browse/settings/discover(keeps?return=, adds no history entry), never linkedThe hierarchy is now explicit for every parent: Discover, Settings, and a module with pages.
aria-current) is a different state from parent of current (bold, accent icon, no fill), so the two never both look selected.2. The sidebar is navigation only; pairing lives in Settings → Displays
The Display devices block (heading, code field, Find display, the list) is gone from both the sidebar and the drawer. Its working functionality moved to
/settings/displays, which is now the first real platform Settings page:It uses the same endpoints, the same
core.devices.manageauthorization, and the same platform display identity. The client now also readscreatedAtandlastSeenAt, which/api/v1/devicesalready returned. "Last seen" is deliberately coarse, because the server throttles it to roughly 15 minutes, and the page never claims a screen is online.3. Initial focus
Nothing on page load or navigation focuses a form field. The structural fix is that no text field exists outside the explicit Add display flow. Focus enters the code field only after Add display is chosen, moves to the name field once a display is found, and returns to Add display when the dialog closes. The button focuses itself before opening the dialog, because Safari doesn't focus clicked buttons. Without that, focus restoration had nothing to return to.
Headless Chromium didn't reproduce the focus grab on the old code (focus stayed on
body), so I couldn't confirm which browser behaviour caused it on the review device. The fix doesn't depend on it.4. Collapsed icon alignment
Root cause:
DashboardLinksetjustify-content: flex-startinline, which outranked the stylesheet's collapsedjustify-content: center. Measured before the fix: 1px to the left of every glyph and 26px to its right, in a 47px box. The toggle also used a different geometry.Fix:
ek-shell-linkclass.control.height-mdsquare,display: flexand centred with auto margins, with no padding or gap.Measured after the fix: every item, the toggle, the brand mark, and the account trigger share one centre (x=35.5px), with each glyph 0px off centre horizontally and vertically. Tooltips sit 8px to the right, vertically aligned, on hover and on keyboard focus. There are no per-icon offsets.
5.
/settingsis an overviewIt is still a real page, not a redirect. It no longer repeats the sidebar's list of sections:
The Displays card reads
GET /api/v1/devicesand the Modules card readsGET /api/v1/module-lifecycle; both endpoints already existed. Each is read only when the server says the person may see that section. Lifecycle requires the same permission as Discover. A refusal shows a quiet "Couldn't check …" line.6. Phone header
<header>holds the menu trigger (48px) and the page name. The name isaria-hidden, because the page's<h1>is the accessible title. Wide screens are unchanged.viewport-fit=coveris set on the Dashboard page. The header, page, drawer, and a landscape-phone sidebar pad byenv(safe-area-inset-*).min-heightis100dvh, falling back to100vh.<main>after a page change scrolled its top to the viewport top, under the header.preventScrolland starts each new page at its top.scroll-padding-topkeeps them below the header.spacing.lg.The drawer's focus trap, Escape, focus return,
inert, Back and Forward are unchanged and were re-verified.7. Appearance radio, fixed in the shared primitive
Root cause: the radios were native and tinted with
accent-color: accent.base. Each browser picks its own contrasting mark for a tinted control, and on the light theme's pale teal that came out as a charcoal ring. The sharedCheckboxused the same technique.Fix:
@ekklesia/ui's stylesheet now draws.ek-checkbox__inputand a new.ek-radio__inputfrom tokens:border.controlring onsurface.accent.strongring,surfacegap, andaccent.strongdot. The checkbox fills withaccent.strongand draws asurfacecheck.border.strong.state.disabled-opacity.The Appearance page uses the shared radio class; there are no page-specific colours. No
Radiocomponent was added: ADR 0039 adds one only once two consumers need it.Visual check: verified in Light, Dark, System resolving to Light, and System resolving to Dark, plus hover, focus-visible, and disabled.
8. Placeholders
General, Branding, System, Installed, and Updates remain placeholders. They are unchanged apart from Discover's own placeholder moving to
/settings/discover.9. Routes
All Stage 1 routes still work. Verified by tests and in the browser:
Server-side authorization is untouched.
Product-doc conflict to resolve outside the repo
../ROADMAP.md§5.3 and../DESIGN.md§9.4, §12.1, and decision 98 still say/settings/discover/browseis canonical, and that/settings/discoverresolves to Browse. This PR follows your review direction, and ADR 0064 records the change. Those two files aren't in this repository, so I haven't edited them.@ekklesia/uiand the 0.2.6 releaseThis PR changes
@ekklesia/ui's stylesheet. The change is additive and has no API change: the.ek-radio__inputclass is new, and theCheckboxlooks different when checked. If #111 (0.2.6) merges first, this lands in the next patch; if this merges first, it rides in 0.2.6. Either way it is compatible.Tests
surfaces/shell/tests/displays-settings.test.tsx, replacingdevice-panel.test.tsx. It covers the list, empty and refused states, the Add display flow focusing each step, Cancel, focus return, and revocation behind a dialog.settings-navigation.test.tsx:?return=kept;dashboard-routes.test.ts: the canonical/settings/discover, its children, and the alias.host-app.test.tsx: the shell no longer requests/api/v1/deviceson every page.packages/ui/tests/tokens.test.ts: noaccent-color; the selected state usesaccent.strong; the pill radius token; disabled and forced-colour rules.scripts/lib/deploy-smoke-browser.mjs: checks the sidebar is navigation only and that Displays focuses nothing on arrival, that Add display focuses the code field only once asked, Discover at/settings/discover, the/browsealias, and the phone header after scrolling.Verification
pnpm verify: passed (format, lint, boundaries, typecheck, every package's tests, module and migration validation, identity schema, build). The shell has 485 tests in 24 files and@ekklesia/uihas 64.pnpm test:package-release: passed. The train is reproducible and the standalone consumer proof passed.EKKLESIA_SMOKE_TOPOLOGY=split pnpm deploy:smoke(real image, real Chromium): passed, 417 ok and 0 failed, including the new Settings → Displays, Discover, alias, and phone-header checks.pnpm deploy:smoke(same-origin): passed, 49 ok and 0 failed.pnpm test:migrations:integrationandpnpm test:package-publication. No API, migration, or publication code changed.Visual pass (real Chromium, against a mock-API harness of this branch)
Screenshots were captured for each area below; I can attach them on request.
scrollYis 0, the header is at the top, content starts below the 61px header, and there is no horizontal overflow.<main>. Back and Forward work.Deferred
/settings/displays/addfor modules to deep-link to.Found during this work
🤖 Generated with Claude Code