feat(shell): collapsible sidebar, account menu, and phone drawer
CI / verify (pull_request) Failing after 57s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / package-release (pull_request) Skipped
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped

The Dashboard's shell now follows DESIGN.md §4 (ADR 0058):

- A collapsible left sidebar replaces the header-and-column layout. It
  is icons and labels when expanded and icons only when collapsed; each
  collapsed item keeps its name, visually hidden, and shows a tooltip
  on hover and on keyboard focus (WCAG 2.2 §1.4.13). The Overview comes
  first, then the server's groups and modules. There is no top bar.
- The expanded/collapsed choice is remembered in this browser's
  localStorage, behind a SidebarPreference interface that a synced
  store can replace later.
- An account menu at the bottom of the sidebar follows the WAI-ARIA menu
  button pattern and offers only Sign out, which now lives there rather
  than in the header.
- On phones (below 48em) the same navigation opens in a modal drawer:
  the page behind it is inert and focus is trapped inside it. Escape,
  its close button, and the backdrop close it and return focus to the
  menu button. Choosing a page closes it and focuses the page. Changing
  layout keeps the route, the desktop mode, and the mounted module.
- Lucide (lucide-react 1.48.0, shell only) is Ekklesia's icon
  vocabulary. Every module shows one generic icon until manifest v9
  lets modules declare their own; there is no per-module icon map.
- The identity slot is a neutral placeholder with no inputs, waiting
  for branding (#101).

The tooltip, menu, and drawer stay shell-local until a second consumer
needs them (ADR 0039). The deploy smoke now signs out through the
account menu, and drives the drawer at phone size in a real browser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-14 17:49:56 -05:00
co-authored by Claude Opus 5.5
parent f3b7e19651
commit af31724cb4
24 changed files with 2559 additions and 189 deletions
@@ -0,0 +1,208 @@
import { designToken } from "@ekklesia/design-tokens";
import type { CSSProperties, ReactElement } from "react";
import type { DashboardDestination, DashboardRoute } from "../dashboard-routes.js";
import type { DashboardNavigation, DashboardNavigationEntry } from "../host-protocol.js";
import { DashboardLink } from "./DashboardLink.js";
import type { HostHistory } from "./host-history.js";
import { useNavTooltip } from "./NavTooltip.js";
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
/**
* How a navigation surface presents itself. `collapsed` is the desktop sidebar's icon-only mode;
* `compact` is the phone drawer, which always shows labels and uses larger touch targets.
*/
export interface NavigationPresentation {
readonly collapsed: boolean;
readonly compact: boolean;
}
/** The control height for this presentation: the medium control on desktop, the large one in the
* drawer, so a fingertip has room (WCAG 2.2 §2.5.8 asks for 24px; this is 40px and 48px). */
export function controlHeight(presentation: NavigationPresentation): string {
return designToken(presentation.compact ? "control.height-lg" : "control.height-md");
}
/**
* The identity slot at the top of the navigation (ADR 0058).
*
* A neutral placeholder — a plain mark and the product's name — until the installation's own
* branding replaces it (DESIGN.md §2.1, §3.4). It is one component with no inputs on purpose: the
* branding work owns the decision of what goes here and where it comes from, and replaces this
* component's body rather than undoing a configuration model invented early. It is not a heading;
* each page's own title is. Collapsed, only the mark shows.
*/
export function ShellBrand(props: { readonly collapsed: boolean }): ReactElement {
return (
<div className="ek-shell-brand" data-testid="shell-brand">
<span aria-hidden="true" className="ek-shell-brand__mark" />
<span className={props.collapsed ? "ek-shell-visually-hidden" : undefined}>Ekklesia</span>
</div>
);
}
function NavigationItem(props: {
readonly destination: DashboardDestination;
readonly label: string;
readonly icon: ShellIconName;
readonly current: boolean;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
readonly onNavigate?: () => void;
readonly testId: string;
}): ReactElement {
const { tooltip, triggerProps } = useNavTooltip(props.label, props.presentation.collapsed);
const style: CSSProperties = { minHeight: controlHeight(props.presentation) };
return (
<li>
<DashboardLink
className="ek-shell-nav-item"
current={props.current}
data-testid={props.testId}
history={props.history}
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
style={style}
to={props.destination}
{...triggerProps}
>
<ShellIcon name={props.icon} />
<span
className={
props.presentation.collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"
}
data-nav-label=""
>
{props.label}
</span>
</DashboardLink>
{tooltip}
</li>
);
}
function ModuleItems(props: {
readonly entries: readonly DashboardNavigationEntry[];
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
readonly onNavigate?: () => void;
readonly labelledBy?: string;
}): ReactElement {
return (
<ul
aria-labelledby={props.labelledBy}
className="ek-shell-nav__group"
style={{ listStyle: "none", margin: 0, padding: 0 }}
>
{props.entries.map((entry) => {
const moduleId = entry.descriptor.id;
return (
<NavigationItem
current={props.route.kind === "module" && props.route.moduleId === moduleId}
destination={{ kind: "module", moduleId }}
history={props.history}
// Every module gets the one generic icon until modules can declare their own
// (ADR 0058); there is no table of known modules.
icon="module"
key={`${moduleId}@${entry.descriptor.version}`}
label={entry.name}
{...(props.onNavigate ? { onNavigate: props.onNavigate } : {})}
presentation={props.presentation}
testId={`select-${moduleId}`}
/>
);
})}
</ul>
);
}
export interface NavigationListProps {
readonly id: string;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly history: HostHistory;
readonly presentation: NavigationPresentation;
/** Called once a destination is chosen, for the drawer to close. */
readonly onNavigate?: () => void;
}
/**
* The Dashboard's one navigation hierarchy, identical in the desktop sidebar and the phone drawer
* (DESIGN.md §4.6): the Overview first, then the server's groups in the server's order, then any
* ungrouped modules. The shell sorts and filters nothing — a disabled module is absent because the
* server's navigation does not list it (ADR 0027, ADR 0028).
*
* Collapsed, group labels stay in the DOM for assistive technology and a divider stands in for them
* visually.
*/
export function NavigationList(props: NavigationListProps): ReactElement {
const { groups, ungrouped } = props.navigation;
const { collapsed } = props.presentation;
const onNavigate = props.onNavigate ? { onNavigate: props.onNavigate } : {};
const empty = groups.length === 0 && ungrouped.length === 0;
return (
<nav aria-label="Dashboard" className="ek-shell-nav" id={props.id}>
<ul className="ek-shell-nav__group" style={{ listStyle: "none", margin: 0, padding: 0 }}>
<NavigationItem
current={props.route.kind === "overview"}
destination={{ kind: "overview" }}
history={props.history}
icon="overview"
label="Overview"
{...onNavigate}
presentation={props.presentation}
testId="select-overview"
/>
</ul>
{empty && !collapsed ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : undefined}
{groups.map((group) => {
const labelId = `${props.id}-group-${group.id}`;
return (
<section data-testid={`group-${group.id}`} key={group.id}>
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<h2
className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label"}
id={labelId}
>
{group.label}
</h2>
<ModuleItems
entries={group.modules}
history={props.history}
labelledBy={labelId}
{...onNavigate}
presentation={props.presentation}
route={props.route}
/>
</section>
);
})}
{ungrouped.length > 0 ? (
<section data-testid="group-ungrouped">
{groups.length > 0 ? (
<>
{collapsed ? <div aria-hidden="true" className="ek-shell-nav__divider" /> : undefined}
<h2
className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav__group-label"}
id={`${props.id}-group-other`}
>
Other
</h2>
</>
) : undefined}
<ModuleItems
entries={ungrouped}
history={props.history}
{...(groups.length > 0 ? { labelledBy: `${props.id}-group-other` } : {})}
{...onNavigate}
presentation={props.presentation}
route={props.route}
/>
</section>
) : undefined}
</nav>
);
}