Fix the Stage 1 Dashboard UX defects found in assembled review
CI / verify (pull_request) Successful in 1m13s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m22s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s

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]>
This commit is contained in:
ekklesia
2026-10-15 20:37:32 -05:00
co-authored by Claude Opus 5.5
parent 186668915b
commit 405e509bcb
31 changed files with 2346 additions and 611 deletions
+284 -7
View File
@@ -1,4 +1,8 @@
import { designToken } from "@ekklesia/design-tokens";
import { SHELL_ICON_SIZE } from "./shell-icons.js";
/** The phone header's own height: one large control plus a little room above and below it. */
const MOBILE_BAR_HEIGHT = `calc(${designToken("control.height-lg")} + 2 * ${designToken("spacing.xs")})`;
/**
* The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover and
@@ -23,6 +27,28 @@ export const SHELL_STYLESHEET = `
width: 1px;
}
/* The authenticated shell's frame. dvh where the browser has it, so a phone's collapsing browser
chrome never leaves the page a toolbar's height too tall. */
.ek-shell-root {
min-height: 100vh;
min-height: 100dvh;
}
/* The page's own region. A phone gets a slimmer margin than a desktop, and on a device with a notch
or a home indicator the page's edges keep clear of them. */
.ek-shell-main {
padding: ${designToken("spacing.xl")};
padding-bottom: calc(${designToken("spacing.xl")} + env(safe-area-inset-bottom, 0px));
padding-right: calc(${designToken("spacing.xl")} + env(safe-area-inset-right, 0px));
}
.ek-shell-root[data-layout="compact"] .ek-shell-main {
padding: ${designToken("spacing.lg")};
padding-bottom: calc(${designToken("spacing.lg")} + env(safe-area-inset-bottom, 0px));
padding-left: calc(${designToken("spacing.lg")} + env(safe-area-inset-left, 0px));
padding-right: calc(${designToken("spacing.lg")} + env(safe-area-inset-right, 0px));
}
.ek-shell-sidebar {
background: ${designToken("background.canvas")};
border-right: ${designToken("border.width")} solid ${designToken("border.divider")};
@@ -33,6 +59,10 @@ export const SHELL_STYLESHEET = `
height: 100vh;
height: 100dvh;
padding: ${designToken("spacing.md")};
/* A landscape phone wide enough for the sidebar puts its left edge beside the notch, and its
account area above the home indicator. */
padding-bottom: calc(${designToken("spacing.md")} + env(safe-area-inset-bottom, 0px));
padding-left: calc(${designToken("spacing.md")} + env(safe-area-inset-left, 0px));
position: sticky;
top: 0;
transition: width ${designToken("motion.duration-short")} ease;
@@ -59,6 +89,17 @@ export const SHELL_STYLESHEET = `
overflow-y: auto;
}
/* The rail is exactly as wide as its items. A scrollbar would take its width from one side and push
every icon off centre, so the rail scrolls without one (wheel, touch, and keyboard focus still
scroll it). */
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__scroll {
scrollbar-width: none;
}
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__scroll::-webkit-scrollbar {
display: none;
}
.ek-shell-sidebar__bottom {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
padding-top: ${designToken("spacing.sm")};
@@ -114,11 +155,49 @@ export const SHELL_STYLESHEET = `
padding-inline-start: ${designToken("spacing.xs")};
}
/* A parent's children — a module's pages, Discover's Installed and Updates (ADR 0064). The guide
line runs down from beneath the parent's icon, and the children's text starts past the parent's
label, so the relationship reads at a glance. */
.ek-shell-nav__children {
border-inline-start: ${designToken("border.width")} solid ${designToken("border.divider")};
display: grid;
gap: ${designToken("spacing.2xs")};
margin: ${designToken("spacing.2xs")} 0 ${designToken("spacing.xs")}
calc(${designToken("control.padding-inline")} + ${SHELL_ICON_SIZE / 2}px);
padding: 0 0 0 ${designToken("spacing.lg")};
}
.ek-shell-nav__divider {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
margin: 0 ${designToken("spacing.xs")};
}
/* A navigation link (DashboardLink). Its layout and open-page look live here, never inline, so
the collapsed rail below can override them (ADR 0064). */
.ek-shell-link {
justify-content: flex-start;
text-decoration: none;
width: 100%;
}
.ek-shell-link[aria-current="page"],
.ek-shell-link[aria-current="page"]:hover:not(:disabled) {
background: ${designToken("state.selected-background")};
color: ${designToken("state.selected-foreground")};
font-weight: ${designToken("typography.weight-bold")};
}
/* The parent of the open page: named in the primary colour, its icon in the accent, and no fill —
the fill belongs to the open page alone. */
.ek-shell-link[data-nav-ancestor] {
color: ${designToken("text.primary")};
font-weight: ${designToken("typography.weight-bold")};
}
.ek-shell-link[data-nav-ancestor] > svg {
color: ${designToken("accent.strong")};
}
.ek-shell-nav-item {
box-sizing: border-box;
gap: ${designToken("spacing.sm")};
@@ -131,16 +210,40 @@ export const SHELL_STYLESHEET = `
flex: 0 0 auto;
}
.ek-shell-nav-item[data-nav-level="child"]:not([aria-current="page"]) {
font-weight: ${designToken("typography.weight-regular")};
}
.ek-shell-nav-item__label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ek-shell-nav-item__disclosure {
color: ${designToken("text.muted")};
display: inline-flex;
flex: 0 0 auto;
transition: transform ${designToken("motion.duration-short")} ease;
}
.ek-shell-nav-item__disclosure[data-open="true"] {
transform: rotate(90deg);
}
/* Collapsed, every item is the same square, centred on the rail, with its glyph centred in it —
whatever its level, and whatever the expanded layout's padding, gap, or alignment would say. */
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-nav-item {
/* A block-level flex box, so the auto margins centre it on the rail. */
display: flex;
gap: 0;
height: ${designToken("control.height-md")};
justify-content: center;
padding-inline: 0;
margin-inline: auto;
padding: 0;
width: ${designToken("control.height-md")};
}
.ek-shell-settings-link {
@@ -183,6 +286,142 @@ export const SHELL_STYLESHEET = `
font-size: ${designToken("typography.size-sm")};
}
/* Settings → Displays: one card per paired screen, wrapping onto two lines on a phone. */
.ek-shell-display-list {
display: grid;
gap: ${designToken("spacing.sm")};
list-style: none;
margin: 0;
padding: 0;
}
.ek-shell-display-card {
align-items: center;
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.md")};
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")} ${designToken("spacing.md")};
padding: ${designToken("spacing.md")};
}
.ek-shell-display-card__icon {
align-items: center;
background: ${designToken("background.inset")};
border-radius: ${designToken("radius.md")};
color: ${designToken("text.secondary")};
display: inline-flex;
flex: 0 0 auto;
height: ${designToken("control.height-md")};
justify-content: center;
width: ${designToken("control.height-md")};
}
.ek-shell-display-card__copy {
display: grid;
flex: 1 1 12rem;
gap: ${designToken("spacing.2xs")};
min-width: 0;
}
.ek-shell-display-card__title {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")};
}
.ek-shell-display-card__name {
color: ${designToken("text.primary")};
font-weight: ${designToken("typography.weight-bold")};
overflow-wrap: anywhere;
}
.ek-shell-display-card__facts {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
}
/* The Settings overview (ADR 0064): what state the installation is in, a card per area. */
.ek-shell-overview {
display: grid;
gap: ${designToken("spacing.md")};
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
}
.ek-shell-overview--compact {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
}
.ek-shell-summary {
background: ${designToken("background.surface")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.lg")};
box-shadow: ${designToken("shadow.sm")};
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.sm")};
grid-template-rows: auto 1fr auto;
min-width: 0;
padding: ${designToken("spacing.lg")};
}
.ek-shell-summary__heading {
align-items: center;
color: ${designToken("text.secondary")};
display: flex;
font-size: ${designToken("typography.size-sm")};
font-weight: ${designToken("typography.weight-medium")};
gap: ${designToken("spacing.sm")};
margin: 0;
}
.ek-shell-summary__status {
color: ${designToken("text.primary")};
font-size: ${designToken("typography.size-lg")};
font-weight: ${designToken("typography.weight-bold")};
margin: 0;
}
.ek-shell-summary--compact .ek-shell-summary__status {
font-size: ${designToken("typography.size-md")};
font-weight: ${designToken("typography.weight-medium")};
}
.ek-shell-summary__detail {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
}
.ek-shell-summary__links {
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.xs")} ${designToken("spacing.md")};
list-style: none;
margin: 0;
padding: 0;
}
.ek-shell-summary__link {
border-radius: ${designToken("radius.sm")};
color: ${designToken("accent.strong")};
font-size: ${designToken("typography.size-sm")};
font-weight: ${designToken("typography.weight-medium")};
text-decoration: none;
}
.ek-shell-summary__link:hover {
text-decoration: underline;
}
.ek-shell-summary__link:focus-visible {
outline: ${designToken("state.focus-visible-width")} solid ${designToken("accent.focus")};
outline-offset: ${designToken("state.focus-visible-offset")};
}
.ek-shell-tooltip {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
@@ -314,12 +553,16 @@ export const SHELL_STYLESHEET = `
color: ${designToken("state.selected-foreground")};
}
.ek-shell-choice__input {
accent-color: ${designToken("accent.base")};
flex: 0 0 auto;
height: 1.25rem;
/* The radio itself is @ekklesia/ui's shared indicator (ek-radio__input), larger here because the
whole row is the target and the radio is its one visual anchor. */
.ek-shell-choice .ek-shell-choice__input {
--ek-choice-size: 1.25rem;
margin: 0.125rem 0 0;
width: 1.25rem;
}
/* The whole row carries the focus ring (above), so the radio inside it draws none of its own. */
.ek-shell-choice .ek-shell-choice__input:focus-visible {
outline: none;
}
.ek-shell-choice__copy {
@@ -350,8 +593,39 @@ export const SHELL_STYLESHEET = `
font-size: ${designToken("typography.size-xs")};
}
/* The phone header (ADR 0064): the menu trigger and the open page's name, kept at the top while the
page scrolls, below the status bar or notch (viewport-fit=cover). Not a desktop top bar: it
holds nothing else. */
.ek-shell-mobile-bar {
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")} 0;
align-items: center;
background: ${designToken("background.canvas")};
border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")};
box-sizing: border-box;
display: flex;
gap: ${designToken("spacing.sm")};
min-height: calc(${MOBILE_BAR_HEIGHT} + env(safe-area-inset-top, 0px));
padding: calc(${designToken("spacing.xs")} + env(safe-area-inset-top, 0px))
calc(${designToken("spacing.sm")} + env(safe-area-inset-right, 0px))
${designToken("spacing.xs")}
calc(${designToken("spacing.sm")} + env(safe-area-inset-left, 0px));
position: sticky;
top: 0;
z-index: 10;
}
.ek-shell-mobile-bar__title {
color: ${designToken("text.primary")};
font-size: ${designToken("typography.size-md")};
font-weight: ${designToken("typography.weight-medium")};
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Something scrolled into view — a focused field, a fragment — lands below the header, not under it. */
html:has(.ek-shell-mobile-bar) {
scroll-padding-top: calc(${MOBILE_BAR_HEIGHT} + env(safe-area-inset-top, 0px) + ${designToken("spacing.sm")});
}
.ek-shell-drawer-backdrop {
@@ -372,6 +646,9 @@ export const SHELL_STYLESHEET = `
max-width: 85vw;
outline: none;
padding: ${designToken("spacing.md")};
padding-bottom: calc(${designToken("spacing.md")} + env(safe-area-inset-bottom, 0px));
padding-left: calc(${designToken("spacing.md")} + env(safe-area-inset-left, 0px));
padding-top: calc(${designToken("spacing.md")} + env(safe-area-inset-top, 0px));
position: fixed;
width: 20rem;
z-index: 41;