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
+187
View File
@@ -0,0 +1,187 @@
import {
type KeyboardEvent,
type ReactElement,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { useNavTooltip } from "./NavTooltip.js";
import { controlHeight, type NavigationPresentation } from "./ShellNavigation.js";
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
export interface AccountMenuProps {
readonly presentation: NavigationPresentation;
readonly onSignOut: () => void;
}
interface AccountAction {
readonly id: string;
readonly label: string;
readonly icon: ShellIconName;
readonly testId: string;
readonly run: () => void;
}
/**
* The signed-in person's account control, at the bottom of the navigation (DESIGN.md §4.5).
*
* A product-owned menu button — never browser UI — following the WAI-ARIA menu button pattern: the
* trigger says it opens a menu and whether it is open; opening moves focus to the first item (or
* the last, from ArrowUp); arrow keys, Home, and End move between items; Escape closes it and
* returns focus to the trigger; Tab or a pointer press outside closes it and leaves focus where it
* went.
*
* It offers **only actions that work today** — Sign out. Profile, personal preferences, and
* appearance belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages; until then there
* is no dead link to `/account`. The shell knows the person only by an opaque user id
* (`/api/v1/principal`), which is never shown, so the control names the area rather than the person.
*/
export function AccountMenu(props: AccountMenuProps): ReactElement {
const [open, setOpen] = useState(false);
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
const wrapperRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const menuId = useId();
const triggerId = useId();
const { collapsed } = props.presentation;
const { tooltip, triggerProps } = useNavTooltip("Your account", collapsed && !open);
const close = useCallback((returnFocus: boolean) => {
setOpen(false);
if (returnFocus) {
triggerRef.current?.focus();
}
}, []);
const actions: readonly AccountAction[] = [
{
icon: "signOut",
id: "sign-out",
label: "Sign out",
run: () => {
setOpen(false);
props.onSignOut();
},
testId: "host-logout",
},
];
const openMenu = (focus: "first" | "last"): void => {
setInitialFocus(focus);
setOpen(true);
};
useEffect(() => {
if (!open) {
return;
}
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
(initialFocus === "last" ? items.at(-1) : items[0])?.focus();
}, [initialFocus, open]);
// A press anywhere outside the menu and its trigger dismisses it, leaving focus alone.
useEffect(() => {
const owner = wrapperRef.current?.ownerDocument;
if (!open || !owner) {
return;
}
const dismiss = (event: Event): void => {
if (!(event.target instanceof Node) || !wrapperRef.current?.contains(event.target)) {
setOpen(false);
}
};
owner.addEventListener("pointerdown", dismiss);
return () => owner.removeEventListener("pointerdown", dismiss);
}, [open]);
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
const index = items.indexOf(event.target as HTMLButtonElement);
const focusAt = (next: number): void => {
event.preventDefault();
items[(next + items.length) % items.length]?.focus();
};
if (event.key === "Escape") {
// Only the menu closes: a drawer around it stays open.
event.preventDefault();
event.stopPropagation();
close(true);
} else if (event.key === "ArrowDown") {
focusAt(index + 1);
} else if (event.key === "ArrowUp") {
focusAt(index - 1);
} else if (event.key === "Home") {
focusAt(0);
} else if (event.key === "End") {
focusAt(items.length - 1);
} else if (event.key === "Tab") {
setOpen(false);
}
};
return (
<div ref={wrapperRef}>
{open ? (
<div
aria-labelledby={triggerId}
className="ek-shell-menu"
data-testid="host-account-menu-list"
id={menuId}
onKeyDown={onMenuKeyDown}
role="menu"
>
{actions.map((action, index) => (
<button
className="ek-button ek-focusable ek-button--quiet ek-shell-menu__item"
data-testid={action.testId}
key={action.id}
onClick={action.run}
ref={(element) => {
itemRefs.current[index] = element;
}}
role="menuitem"
style={{ minHeight: controlHeight(props.presentation) }}
tabIndex={-1}
type="button"
>
<ShellIcon name={action.icon} />
{action.label}
</button>
))}
</div>
) : undefined}
<button
aria-controls={open ? menuId : undefined}
aria-expanded={open}
aria-haspopup="menu"
className="ek-button ek-focusable ek-button--quiet ek-shell-nav-item"
data-testid="host-account-menu"
id={triggerId}
onClick={() => (open ? close(false) : openMenu("first"))}
ref={triggerRef}
style={{ minHeight: controlHeight(props.presentation), width: "100%" }}
type="button"
{...triggerProps}
onKeyDown={(event) => {
triggerProps.onKeyDown(event);
if (event.key === "ArrowDown") {
event.preventDefault();
openMenu("first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
openMenu("last");
}
}}
>
<ShellIcon name="account" />
<span className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"}>
Your account
</span>
</button>
{tooltip}
</div>
);
}
+35 -9
View File
@@ -1,5 +1,13 @@
import { designToken } from "@ekklesia/design-tokens";
import type { CSSProperties, MouseEvent, ReactElement, ReactNode } from "react";
import type {
CSSProperties,
FocusEventHandler,
KeyboardEventHandler,
MouseEvent,
MouseEventHandler,
ReactElement,
ReactNode,
} from "react";
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
import type { HostHistory } from "./host-history.js";
@@ -10,6 +18,16 @@ export interface DashboardLinkProps {
/** Marks the link as the page currently open, for assistive technology and visually. */
readonly current?: boolean;
readonly variant?: "navigation" | "action";
/** Called after an in-place navigation, including to the page already open — for a drawer that
* closes once a destination is chosen. Not called for a click the browser handles itself. */
readonly onNavigate?: () => void;
readonly className?: string;
readonly style?: CSSProperties;
readonly onBlur?: FocusEventHandler<HTMLAnchorElement>;
readonly onFocus?: FocusEventHandler<HTMLAnchorElement>;
readonly onKeyDown?: KeyboardEventHandler<HTMLAnchorElement>;
readonly onMouseEnter?: MouseEventHandler<HTMLAnchorElement>;
readonly onMouseLeave?: MouseEventHandler<HTMLAnchorElement>;
readonly "data-testid"?: string;
}
@@ -55,26 +73,34 @@ function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
export function DashboardLink(props: DashboardLinkProps): ReactElement {
const href = dashboardPath(props.to);
const variant = props.variant ?? "navigation";
const baseStyle =
variant === "action"
? { textDecoration: "none" }
: props.current
? currentStyle
: navigationStyle;
return (
<a
aria-current={props.current ? "page" : undefined}
className={variantClass[variant]}
className={
props.className ? `${variantClass[variant]} ${props.className}` : variantClass[variant]
}
data-testid={props["data-testid"]}
href={href}
onBlur={props.onBlur}
onClick={(event) => {
if (isBrowserHandledClick(event)) {
return;
}
event.preventDefault();
props.history.push(href);
props.onNavigate?.();
}}
style={
variant === "action"
? { textDecoration: "none" }
: props.current
? currentStyle
: navigationStyle
}
onFocus={props.onFocus}
onKeyDown={props.onKeyDown}
onMouseEnter={props.onMouseEnter}
onMouseLeave={props.onMouseLeave}
style={props.style ? { ...baseStyle, ...props.style } : baseStyle}
>
{props.children}
</a>
+49 -163
View File
@@ -29,14 +29,15 @@ import {
HostProtocolError,
loadHostPrincipal,
} from "../host-protocol.js";
import { DashboardLink } from "./DashboardLink.js";
import { DevicePanel } from "./DevicePanel.js";
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
import type { HostHistory } from "./host-history.js";
import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js";
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { ResetPasswordForm } from "./ResetPasswordForm.js";
import { ShellLayout } from "./ShellLayout.js";
import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js";
import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js";
type AuthenticatedPrincipal = Extract<
@@ -122,77 +123,26 @@ export interface HostAppProps {
/** Names the browser tab after the open page. Supplied by the entry point, which owns `document`;
* left unset, the shell names nothing. */
readonly setDocumentTitle?: (title: string) => void;
/**
* Whether the page is laid out for a phone (ADR 0058). The entry point passes the browser's media
* query; left unset, the wide layout. Must be a stable reference.
*/
readonly viewport?: HostViewport;
/**
* Where the desktop sidebar's expanded or collapsed mode is remembered (ADR 0058). The entry point
* passes this browser's storage; left unset, the mode lasts only as long as this component.
*/
readonly sidebarPreference?: SidebarPreference;
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter;
}
/**
* The shell's own chrome, expressed in the platform's global semantic tokens rather than in literal
* colours (ADR 0039). Nothing here is a brand decision any more: changing
* `--ekklesia-background-page` at the host root changes the Dashboard and every mounted module
* artifact together, because custom properties inherit through the artifacts' isolated roots.
* The host's full-page messages (loading, failure, sign-out), in the platform's semantic tokens
* (ADR 0039). The signed-in Dashboard's own frame is `ShellLayout` (ADR 0058).
*/
const shellStyles = {
actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" },
aside: {
background: designToken("background.canvas"),
borderRight: `${designToken("border.width")} solid ${designToken("border.divider")}`,
boxSizing: "border-box",
minWidth: "15rem",
overflowY: "auto",
padding: designToken("spacing.xl"),
width: "15rem",
},
brand: { alignItems: "center", display: "flex", gap: designToken("spacing.sm") },
brandMark: {
background: designToken("accent.base"),
borderRadius: designToken("radius.md"),
boxShadow: `inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")}`,
display: "block",
height: designToken("spacing.xl"),
width: designToken("spacing.xl"),
},
groupLabel: {
color: designToken("text.muted"),
fontSize: designToken("typography.size-xs"),
letterSpacing: "0.05em",
margin: `0 0 ${designToken("spacing.xs")}`,
textTransform: "uppercase",
},
header: {
alignItems: "center",
background: designToken("background.surface"),
borderBottom: `${designToken("border.width")} solid ${designToken("border.divider")}`,
display: "flex",
fontWeight: designToken("typography.weight-bold"),
gridColumn: "1 / -1",
justifyContent: "space-between",
padding: `${designToken("spacing.md")} ${designToken("spacing.xl")}`,
},
heading: {
fontSize: designToken("typography.size-lg"),
fontWeight: designToken("typography.weight-bold"),
margin: 0,
},
layout: {
background: designToken("background.page"),
color: designToken("text.primary"),
display: "grid",
fontFamily: designToken("typography.family-sans"),
gridTemplateColumns: "15rem minmax(0, 1fr)",
gridTemplateRows: "auto minmax(0, 1fr)",
minHeight: "100vh",
minWidth: 0,
width: "100%",
},
main: {
boxSizing: "border-box",
minWidth: 0,
// A programmatic focus target after a page change (never in the tab order), not a control.
outline: "none",
overflowX: "auto",
padding: designToken("spacing.xl"),
},
message: {
color: designToken("text.secondary"),
display: "grid",
@@ -217,24 +167,6 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
];
}
function ModuleLink(props: {
readonly entry: DashboardNavigationEntry;
readonly history: HostHistory;
readonly route: DashboardRoute;
}): ReactElement {
const moduleId = props.entry.descriptor.id;
return (
<DashboardLink
current={props.route.kind === "module" && props.route.moduleId === moduleId}
data-testid={`select-${moduleId}`}
history={props.history}
to={{ kind: "module", moduleId }}
>
{props.entry.name}
</DashboardLink>
);
}
/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is
* not a page change. */
function routeKey(route: DashboardRoute): string {
@@ -310,6 +242,11 @@ export function HostApp(props: HostAppProps): ReactElement {
const mainRef = useRef<HTMLElement>(null);
const shownRouteRef = useRef<string | undefined>(undefined);
const { config, history, importModule, setDocumentTitle } = props;
// Fallbacks for a composition that supplies neither; created once, so they stay stable.
const [fallbackViewport] = useState(() => createBrowserHostViewport(undefined));
const [fallbackPreference] = useState(() => createMemorySidebarPreference());
const viewport = props.viewport ?? fallbackViewport;
const sidebarPreference = props.sidebarPreference ?? fallbackPreference;
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
const readPathname = useCallback(() => history.pathname(), [history]);
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
@@ -619,88 +556,37 @@ export function HostApp(props: HostAppProps): ReactElement {
}
const { bootstrap } = state;
const entries = allEntries(bootstrap);
const { groups, ungrouped } = bootstrap.navigation;
return (
<div data-testid="host-shell" style={shellStyles.layout}>
<header style={shellStyles.header}>
<div style={shellStyles.brand}>
<span aria-hidden="true" style={shellStyles.brandMark} />
<h1 style={shellStyles.heading}>Ekklesia</h1>
</div>
<Button data-testid="host-logout" onClick={() => void signOut()} size="sm">
Sign out
</Button>
</header>
<aside style={shellStyles.aside}>
<DevicePanel config={config} />
<nav aria-label="Dashboard" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<DashboardLink
current={route.kind === "overview"}
data-testid="select-overview"
history={history}
to={{ kind: "overview" }}
>
Overview
</DashboardLink>
{entries.length === 0 && groups.length === 0 ? (
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
No installed module provides a user interface.
</p>
) : undefined}
{groups.map((group) => (
<section data-testid={`group-${group.id}`} key={group.id}>
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
<div style={{ display: "grid", gap: "0.125rem" }}>
{group.modules.map((entry) => (
<ModuleLink
entry={entry}
history={history}
key={moduleKey(entry)}
route={route}
/>
))}
</div>
</section>
))}
{ungrouped.length > 0 ? (
<section data-testid="group-ungrouped">
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
<div style={{ display: "grid", gap: "0.125rem" }}>
{ungrouped.map((entry) => (
<ModuleLink
entry={entry}
history={history}
key={moduleKey(entry)}
route={route}
/>
))}
</div>
</section>
) : undefined}
</nav>
</aside>
<main aria-label={pageTitle} ref={mainRef} style={shellStyles.main} tabIndex={-1}>
{route.kind === "overview" ? (
<OverviewPage />
) : route.kind === "module" ? (
openEntry ? (
<ModuleUiFrame
config={config}
descriptor={openEntry.descriptor}
{...(importModule ? { importModule } : {})}
key={moduleKey(openEntry)}
onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal}
/>
) : (
<ModuleUnavailablePage history={history} />
)
<ShellLayout
config={config}
history={history}
mainRef={mainRef}
navigation={bootstrap.navigation}
onSignOut={() => void signOut()}
pageTitle={pageTitle}
route={route}
sidebarPreference={sidebarPreference}
viewport={viewport}
>
{route.kind === "overview" ? (
<OverviewPage />
) : route.kind === "module" ? (
openEntry ? (
<ModuleUiFrame
config={config}
descriptor={openEntry.descriptor}
{...(importModule ? { importModule } : {})}
key={moduleKey(openEntry)}
onAuthorizationResponse={onAuthorizationResponse}
principal={bootstrap.principal}
/>
) : (
<NotFoundPage history={history} />
)}
</main>
</div>
<ModuleUnavailablePage history={history} />
)
) : (
<NotFoundPage history={history} />
)}
</ShellLayout>
);
}
+122
View File
@@ -0,0 +1,122 @@
import {
type FocusEvent,
type KeyboardEvent,
type MouseEvent,
type ReactElement,
useCallback,
useEffect,
useRef,
useState,
} from "react";
/** How long a tooltip stays after the pointer leaves its control, so the pointer can cross the gap
* onto the tooltip itself (WCAG 2.2 §1.4.13, "hoverable"). */
const HIDE_DELAY_MS = 150;
interface Anchor {
readonly left: number;
readonly top: number;
}
/** The handlers a control spreads onto itself. Typed loosely enough for a link or a button. */
export interface NavTooltipTriggerProps {
onBlur(event: FocusEvent<HTMLElement>): void;
onFocus(event: FocusEvent<HTMLElement>): void;
onKeyDown(event: KeyboardEvent<HTMLElement>): void;
onMouseEnter(event: MouseEvent<HTMLElement>): void;
onMouseLeave(event: MouseEvent<HTMLElement>): void;
}
/**
* The visible name of an icon-only control in the collapsed sidebar (ADR 0058).
*
* The control's accessible name is its own text, kept in the DOM and visually hidden, so the
* tooltip is a **visual** duplicate of that name: it is `aria-hidden`, and a screen reader hears the
* name once. It meets WCAG 2.2 §1.4.13 for content on hover or focus: it appears on keyboard focus as
* well as on hover (nothing is hover-only), Escape dismisses it without moving focus, the pointer can
* move onto it without it vanishing, and it stays until focus or the pointer leaves.
*
* Positioned `fixed` beside the control, from the control's own rectangle, so a scrolling sidebar
* cannot clip it. Disabled, it renders nothing and changes nothing: an expanded sidebar already
* shows every label.
*
* Deliberately shell-local. ADR 0039 adds an element to `@ekklesia/ui` once two independent
* consumers need it; when a module needs a tooltip, this is the shape to promote.
*/
export function useNavTooltip(
label: string,
enabled: boolean,
): { readonly triggerProps: NavTooltipTriggerProps; readonly tooltip: ReactElement | null } {
const [anchor, setAnchor] = useState<Anchor | undefined>(undefined);
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const cancelHide = useCallback(() => {
if (hideTimer.current !== undefined) {
clearTimeout(hideTimer.current);
hideTimer.current = undefined;
}
}, []);
const hide = useCallback(() => {
cancelHide();
setAnchor(undefined);
}, [cancelHide]);
const show = useCallback(
(element: HTMLElement) => {
cancelHide();
const rect = element.getBoundingClientRect();
setAnchor({ left: rect.right + 8, top: rect.top + rect.height / 2 });
},
[cancelHide],
);
useEffect(() => {
if (!enabled) {
hide();
}
}, [enabled, hide]);
useEffect(() => cancelHide, [cancelHide]);
const triggerProps: NavTooltipTriggerProps = {
onBlur: hide,
onFocus: (event) => {
if (enabled) {
show(event.currentTarget);
}
},
onKeyDown: (event) => {
if (event.key === "Escape" && anchor !== undefined) {
// Only this tooltip is dismissed; the event goes no further.
event.stopPropagation();
hide();
}
},
onMouseEnter: (event) => {
if (enabled) {
show(event.currentTarget);
}
},
onMouseLeave: () => {
cancelHide();
hideTimer.current = setTimeout(hide, HIDE_DELAY_MS);
},
};
const tooltip =
enabled && anchor !== undefined ? (
<span
aria-hidden="true"
className="ek-shell-tooltip"
data-testid="nav-tooltip"
onMouseEnter={cancelHide}
onMouseLeave={hide}
style={{ left: anchor.left, top: anchor.top }}
>
{label}
</span>
) : null;
return { tooltip, triggerProps };
}
+378
View File
@@ -0,0 +1,378 @@
import { designToken } from "@ekklesia/design-tokens";
import {
type KeyboardEvent,
type ReactElement,
type ReactNode,
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { DashboardRoute } from "../dashboard-routes.js";
import type { DashboardNavigation, EkklesiaHostConfig } from "../host-protocol.js";
import { AccountMenu } from "./AccountMenu.js";
import { DevicePanel } from "./DevicePanel.js";
import type { HostHistory } from "./host-history.js";
import type { HostViewport } from "./host-viewport.js";
import { useNavTooltip } from "./NavTooltip.js";
import {
controlHeight,
NavigationList,
type NavigationPresentation,
ShellBrand,
} from "./ShellNavigation.js";
import { ShellIcon } from "./shell-icons.js";
import { SHELL_STYLESHEET } from "./shell-styles.js";
import type { SidebarMode, SidebarPreference } from "./sidebar-preference.js";
const SIDEBAR_NAV_ID = "ekklesia-sidebar-navigation";
const DRAWER_ID = "ekklesia-navigation-drawer";
const DRAWER_NAV_ID = "ekklesia-drawer-navigation";
const SIDEBAR_WIDTH: Record<SidebarMode, string> = { collapsed: "4.5rem", expanded: "16rem" };
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export interface ShellLayoutProps {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly viewport: HostViewport;
readonly sidebarPreference: SidebarPreference;
readonly mainRef: RefObject<HTMLElement | null>;
readonly pageTitle: string | undefined;
readonly onSignOut: () => void;
readonly children: ReactNode;
}
function SidebarToggle(props: {
readonly mode: SidebarMode;
readonly onToggle: () => void;
}): ReactElement {
const label = props.mode === "expanded" ? "Collapse navigation" : "Expand navigation";
const { tooltip, triggerProps } = useNavTooltip(label, props.mode === "collapsed");
return (
<>
<button
aria-controls={SIDEBAR_NAV_ID}
aria-expanded={props.mode === "expanded"}
aria-label={label}
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
data-testid="sidebar-toggle"
onClick={props.onToggle}
style={{ minHeight: controlHeight({ collapsed: false, compact: false }) }}
type="button"
{...triggerProps}
>
<ShellIcon name={props.mode === "expanded" ? "collapseSidebar" : "expandSidebar"} />
</button>
{tooltip}
</>
);
}
/**
* The desktop and tablet sidebar (DESIGN.md §4.2): identity at the top, the navigation, and the
* account area anchored at the bottom. Collapsed, it is icons only, each named by a tooltip.
*/
function Sidebar(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly mode: SidebarMode;
readonly onToggle: () => void;
readonly onSignOut: () => void;
}): ReactElement {
const presentation: NavigationPresentation = {
collapsed: props.mode === "collapsed",
compact: false,
};
return (
<aside
className="ek-shell-sidebar"
data-sidebar={props.mode}
data-testid="host-sidebar"
style={{ width: SIDEBAR_WIDTH[props.mode] }}
>
<div className="ek-shell-sidebar__top">
<ShellBrand collapsed={presentation.collapsed} />
<SidebarToggle mode={props.mode} onToggle={props.onToggle} />
</div>
<div className="ek-shell-sidebar__scroll">
<NavigationList
history={props.history}
id={SIDEBAR_NAV_ID}
navigation={props.navigation}
presentation={presentation}
route={props.route}
/>
{/* The transitional device panel (ADR 0036) needs room for its form; collapsed, expanding
the sidebar reveals it. Stage 1 moves it to Settings → Displays. */}
{presentation.collapsed ? undefined : <DevicePanel config={props.config} />}
</div>
<div className="ek-shell-sidebar__bottom">
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} />
</div>
</aside>
);
}
/**
* The phone navigation (DESIGN.md §4.6): the same hierarchy, current page, and account area as the
* sidebar, in a modal drawer.
*
* It is a real modal: `role="dialog"` with `aria-modal`, the page behind it `inert`, and Tab and
* Shift+Tab kept inside it here as well, for a browser that does not yet honour `inert`. Opening
* moves focus to its close button; Escape, the close button, and the backdrop dismiss it and the
* layout returns focus to the trigger.
*/
function NavigationDrawer(props: {
readonly config: EkklesiaHostConfig;
readonly history: HostHistory;
readonly navigation: DashboardNavigation;
readonly route: DashboardRoute;
readonly onDismiss: () => void;
readonly onNavigate: () => void;
readonly onSignOut: () => void;
}): ReactElement {
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const presentation: NavigationPresentation = { collapsed: false, compact: true };
useEffect(() => {
closeRef.current?.focus();
}, []);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === "Escape") {
event.preventDefault();
props.onDismiss();
return;
}
if (event.key !== "Tab" || !panelRef.current) {
return;
}
const focusable = [...panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)];
const first = focusable[0];
const last = focusable.at(-1);
const active = panelRef.current.ownerDocument.activeElement;
if (event.shiftKey && (active === first || !panelRef.current.contains(active))) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && (active === last || !panelRef.current.contains(active))) {
event.preventDefault();
first?.focus();
}
};
return (
<>
<div
aria-hidden="true"
className="ek-shell-drawer-backdrop"
data-testid="navigation-drawer-backdrop"
onClick={props.onDismiss}
/>
<div
aria-label="Navigation"
aria-modal="true"
className="ek-shell-drawer"
data-testid="navigation-drawer"
id={DRAWER_ID}
onKeyDown={onKeyDown}
ref={panelRef}
role="dialog"
>
<div className="ek-shell-sidebar__top">
<ShellBrand collapsed={false} />
<button
aria-label="Close navigation"
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
data-testid="navigation-drawer-close"
onClick={props.onDismiss}
ref={closeRef}
style={{
minHeight: controlHeight(presentation),
minWidth: controlHeight(presentation),
}}
type="button"
>
<ShellIcon name="closeNavigation" />
</button>
</div>
<div className="ek-shell-sidebar__scroll">
<NavigationList
history={props.history}
id={DRAWER_NAV_ID}
navigation={props.navigation}
onNavigate={props.onNavigate}
presentation={presentation}
route={props.route}
/>
<DevicePanel config={props.config} />
</div>
<div className="ek-shell-sidebar__bottom">
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} />
</div>
</div>
</>
);
}
function routeKey(route: DashboardRoute): string {
return route.kind === "module" ? `module:${route.moduleId}` : route.kind;
}
/**
* The authenticated Dashboard's frame (ADR 0058): a collapsible sidebar on wide screens, a drawer
* on phones, and the page.
*
* The page's `<main>` keeps one position in the tree whichever layout is showing, so changing
* layout — rotating a tablet, resizing a window — never remounts the open module. The route comes
* from the URL (ADR 0057) and the desktop mode from the sidebar preference, so neither is touched by
* a layout change; only an open drawer is dropped, and focus that was inside it lands on the page.
* There is no application top bar: on a phone the one control above the page is the menu trigger.
*/
export function ShellLayout(props: ShellLayoutProps): ReactElement {
const { viewport, sidebarPreference, mainRef, route } = props;
const subscribe = useCallback((listener: () => void) => viewport.subscribe(listener), [viewport]);
const readCompact = useCallback(() => viewport.isCompact(), [viewport]);
const compact = useSyncExternalStore(subscribe, readCompact, readCompact);
const [mode, setMode] = useState<SidebarMode>(() => sidebarPreference.read() ?? "expanded");
const [drawerOpen, setDrawerOpen] = useState(false);
const pendingFocus = useRef<"trigger" | "main" | undefined>(undefined);
const triggerRef = useRef<HTMLButtonElement>(null);
const previousCompact = useRef(compact);
const previousRoute = useRef(routeKey(route));
const toggleMode = (): void => {
const next: SidebarMode = mode === "expanded" ? "collapsed" : "expanded";
setMode(next);
sidebarPreference.write(next);
};
const closeDrawer = useCallback((focus: "trigger" | "main") => {
pendingFocus.current = focus;
setDrawerOpen(false);
}, []);
// A layout change drops an open drawer. Focus inside a drawer or sidebar that just went away
// would otherwise fall to the document; it lands on the page instead.
useLayoutEffect(() => {
if (previousCompact.current === compact) {
return;
}
previousCompact.current = compact;
if (drawerOpen) {
closeDrawer("main");
return;
}
const owner = mainRef.current?.ownerDocument;
if (owner && (owner.activeElement === null || owner.activeElement === owner.body)) {
mainRef.current?.focus();
}
}, [closeDrawer, compact, drawerOpen, mainRef]);
// Back and Forward close the drawer too; the shell moves focus to the new page itself.
useEffect(() => {
const key = routeKey(route);
if (previousRoute.current !== key) {
previousRoute.current = key;
setDrawerOpen(false);
}
}, [route]);
useEffect(() => {
if (drawerOpen || pendingFocus.current === undefined) {
return;
}
const target = pendingFocus.current === "trigger" ? triggerRef.current : mainRef.current;
pendingFocus.current = undefined;
target?.focus();
}, [drawerOpen, mainRef]);
const showDrawer = compact && drawerOpen;
const pageInert = showDrawer ? true : undefined;
return (
<div
data-layout={compact ? "compact" : "wide"}
data-testid="host-shell"
style={{
display: compact ? "block" : "grid",
gridTemplateColumns: compact ? undefined : "auto minmax(0, 1fr)",
minHeight: "100vh",
minWidth: 0,
width: "100%",
}}
>
<style>{SHELL_STYLESHEET}</style>
{compact ? undefined : (
<Sidebar
config={props.config}
history={props.history}
mode={mode}
navigation={props.navigation}
onSignOut={props.onSignOut}
onToggle={toggleMode}
route={route}
/>
)}
<div style={{ minWidth: 0 }}>
{compact ? (
<div className="ek-shell-mobile-bar" inert={pageInert}>
<button
aria-controls={DRAWER_ID}
aria-expanded={showDrawer}
aria-label="Open navigation"
className="ek-button ek-focusable ek-button--quiet ek-icon-button"
data-testid="mobile-navigation-trigger"
onClick={() => setDrawerOpen(true)}
ref={triggerRef}
style={{
minHeight: controlHeight({ collapsed: false, compact: true }),
minWidth: controlHeight({ collapsed: false, compact: true }),
}}
type="button"
>
<ShellIcon name="openNavigation" size={24} />
</button>
</div>
) : undefined}
<main
aria-label={props.pageTitle}
data-testid="host-main-region"
inert={pageInert}
ref={mainRef}
style={{
boxSizing: "border-box",
minWidth: 0,
// A programmatic focus target after a page change (never in the tab order).
outline: "none",
overflowX: "auto",
padding: designToken("spacing.xl"),
}}
tabIndex={-1}
>
{props.children}
</main>
</div>
{showDrawer ? (
<NavigationDrawer
config={props.config}
history={props.history}
navigation={props.navigation}
onDismiss={() => closeDrawer("trigger")}
onNavigate={() => closeDrawer("main")}
onSignOut={props.onSignOut}
route={route}
/>
) : undefined}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,63 @@
/**
* Whether the Dashboard is laid out for a phone (ADR 0058).
*
* Below the breakpoint the sidebar becomes a drawer with the same navigation; at and above it the
* collapsible sidebar is shown, tablets included (DESIGN.md §4.6–4.7). The shell reads the answer
* through this interface rather than from `window.matchMedia`, so the entry point stays the only
* file that touches a browser global and every responsive behaviour is testable by resizing an
* in-memory viewport.
*/
export interface HostViewport {
isCompact(): boolean;
subscribe(listener: () => void): () => void;
}
/** `em` in a media query is the browser's initial font size, so the breakpoint follows a person's
* own text-size setting: 48em is 768px at the default 16px. */
export const HOST_COMPACT_MEDIA_QUERY = "(max-width: 47.999em)";
type MediaQueryListLike = Pick<MediaQueryList, "matches"> & {
addEventListener(type: "change", listener: () => void): void;
removeEventListener(type: "change", listener: () => void): void;
};
/** The browser's media query, handed in by the entry point. Without one, the wide layout. */
export function createBrowserHostViewport(
matchMedia: ((query: string) => MediaQueryListLike) | undefined,
): HostViewport {
const mediaQuery = matchMedia?.(HOST_COMPACT_MEDIA_QUERY);
return {
isCompact: () => mediaQuery?.matches ?? false,
subscribe(listener) {
mediaQuery?.addEventListener("change", listener);
return () => mediaQuery?.removeEventListener("change", listener);
},
};
}
export interface MemoryHostViewport extends HostViewport {
setCompact(compact: boolean): void;
}
export function createMemoryHostViewport(compact = false): MemoryHostViewport {
let current = compact;
const listeners = new Set<() => void>();
return {
isCompact: () => current,
setCompact(next) {
if (next === current) {
return;
}
current = next;
for (const listener of [...listeners]) {
listener();
}
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
+27 -1
View File
@@ -5,6 +5,8 @@ import { consumeAuthLanding } from "../auth-protocol.js";
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
import { HostApp } from "./HostApp.js";
import { createBrowserHostHistory } from "./host-history.js";
import { createBrowserHostViewport } from "./host-viewport.js";
import { createLocalSidebarPreference } from "./sidebar-preference.js";
/**
* The one place the shell touches a browser global.
@@ -31,6 +33,22 @@ const setDocumentTitle = (title: string): void => {
document.title = title;
};
/** The phone-layout media query and this browser's storage, for the sidebar (ADR 0058). Merely
* reading `localStorage` throws where site data is blocked; the shell then remembers the sidebar's
* mode for this page only. */
const viewport = createBrowserHostViewport(
typeof window.matchMedia === "function" ? (query) => window.matchMedia(query) : undefined,
);
const sidebarPreference = createLocalSidebarPreference(
(() => {
try {
return window.localStorage;
} catch {
return undefined;
}
})(),
);
const container = document.getElementById("ekklesia-host");
if (!container) {
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
@@ -66,9 +84,17 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
config={config}
history={history}
setDocumentTitle={setDocumentTitle}
sidebarPreference={sidebarPreference}
viewport={viewport}
/>
) : (
<HostApp config={config} history={history} setDocumentTitle={setDocumentTitle} />
<HostApp
config={config}
history={history}
setDocumentTitle={setDocumentTitle}
sidebarPreference={sidebarPreference}
viewport={viewport}
/>
);
}
@@ -0,0 +1,58 @@
import {
Blocks,
CircleUserRound,
LayoutDashboard,
type LucideIcon,
LogOut,
Menu,
PanelLeftClose,
PanelLeftOpen,
X,
} from "lucide-react";
import type { ReactElement } from "react";
/**
* Every icon the shell uses, imported from Lucide in this one file (ADR 0058).
*
* Lucide is Ekklesia's icon vocabulary. Importing each icon by name keeps the bundle to exactly the
* icons named here, and keeping them in one place keeps the vocabulary reviewable: an icon is chosen
* for a meaning, once, rather than per call site.
*
* There is deliberately **no per-module entry**. Every module is drawn with the one generic
* {@link SHELL_ICONS.module} until modules can declare their own icon through the module navigation
* contract; a table of known module ids here would be a second, host-owned answer to a question the
* module should own.
*/
export const SHELL_ICONS = Object.freeze({
account: CircleUserRound,
closeNavigation: X,
collapseSidebar: PanelLeftClose,
expandSidebar: PanelLeftOpen,
module: Blocks,
openNavigation: Menu,
overview: LayoutDashboard,
signOut: LogOut,
} satisfies Record<string, LucideIcon>);
export type ShellIconName = keyof typeof SHELL_ICONS;
/**
* A decorative icon. Always hidden from assistive technology: every control that shows one also
* has a text name, visible or not, so an icon never carries meaning on its own. The stroke follows
* the surrounding text colour, so icons take their colour from the design tokens like text does.
*/
export function ShellIcon(props: {
readonly name: ShellIconName;
readonly size?: number;
}): ReactElement {
const Icon = SHELL_ICONS[props.name];
return (
<Icon
aria-hidden="true"
data-icon={props.name}
focusable="false"
size={props.size ?? 20}
strokeWidth={1.75}
/>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { designToken } from "@ekklesia/design-tokens";
/**
* The Dashboard shell's own stylesheet (ADR 0058): what inline styles cannot express — hover,
* `:focus-visible`, a reduced-motion override — for the shell's navigation, account menu, tooltip,
* and drawer.
*
* Every value is a design token (ADR 0039), never a literal colour. Every class is `ek-shell-`
* prefixed, so nothing here can collide with `@ekklesia/ui`'s `ek-` elements, and it is installed
* into the shell's own document only: each module artifact still mounts in its own isolated root
* (ADR 0040), which this stylesheet cannot reach.
*/
export const SHELL_STYLESHEET = `
.ek-shell-visually-hidden {
border: 0;
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
.ek-shell-sidebar {
background: ${designToken("background.canvas")};
border-right: ${designToken("border.width")} solid ${designToken("border.divider")};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${designToken("spacing.md")};
height: 100vh;
height: 100dvh;
padding: ${designToken("spacing.md")};
position: sticky;
top: 0;
transition: width 160ms ease;
}
.ek-shell-sidebar__top {
align-items: center;
display: flex;
gap: ${designToken("spacing.sm")};
justify-content: space-between;
min-height: ${designToken("control.height-md")};
}
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-sidebar__top {
flex-direction: column;
}
.ek-shell-sidebar__scroll {
display: grid;
flex: 1 1 auto;
align-content: start;
gap: ${designToken("spacing.xl")};
min-height: 0;
overflow-y: auto;
}
.ek-shell-sidebar__bottom {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
padding-top: ${designToken("spacing.sm")};
position: relative;
}
.ek-shell-brand {
align-items: center;
color: ${designToken("text.primary")};
display: flex;
font-size: ${designToken("typography.size-md")};
font-weight: ${designToken("typography.weight-bold")};
gap: ${designToken("spacing.sm")};
min-width: 0;
}
.ek-shell-brand__mark {
background: ${designToken("accent.base")};
border-radius: ${designToken("radius.md")};
box-shadow: inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")};
display: block;
flex: 0 0 auto;
height: ${designToken("spacing.xl")};
width: ${designToken("spacing.xl")};
}
.ek-shell-nav,
.ek-shell-nav__group {
display: grid;
gap: ${designToken("spacing.2xs")};
}
.ek-shell-nav {
gap: ${designToken("spacing.lg")};
}
.ek-shell-nav__group-label {
color: ${designToken("text.muted")};
font-size: ${designToken("typography.size-xs")};
font-weight: ${designToken("typography.weight-medium")};
letter-spacing: 0.05em;
margin: 0 0 ${designToken("spacing.2xs")};
padding-inline: ${designToken("control.padding-inline")};
text-transform: uppercase;
}
.ek-shell-nav__divider {
border-top: ${designToken("border.width")} solid ${designToken("border.divider")};
margin: 0 ${designToken("spacing.xs")};
}
.ek-shell-nav-item {
box-sizing: border-box;
gap: ${designToken("spacing.sm")};
height: auto;
justify-content: flex-start;
text-align: start;
}
.ek-shell-nav-item svg {
flex: 0 0 auto;
}
.ek-shell-nav-item__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ek-shell-sidebar[data-sidebar="collapsed"] .ek-shell-nav-item {
justify-content: center;
padding-inline: 0;
}
.ek-shell-tooltip {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.sm")};
box-shadow: ${designToken("shadow.md")};
color: ${designToken("text.primary")};
font-size: ${designToken("typography.size-xs")};
font-weight: ${designToken("typography.weight-medium")};
padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")};
position: fixed;
transform: translateY(-50%);
white-space: nowrap;
z-index: 30;
}
.ek-shell-menu {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.md")};
box-shadow: ${designToken("shadow.md")};
bottom: calc(100% + ${designToken("spacing.xs")});
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.2xs")};
left: 0;
margin: 0;
min-width: 12rem;
padding: ${designToken("spacing.xs")};
position: absolute;
z-index: 20;
}
.ek-shell-menu__caption {
color: ${designToken("text.muted")};
font-size: ${designToken("typography.size-xs")};
margin: 0;
padding: ${designToken("spacing.2xs")} ${designToken("spacing.sm")};
}
.ek-shell-menu__item {
justify-content: flex-start;
width: 100%;
}
.ek-shell-mobile-bar {
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")} 0;
}
.ek-shell-drawer-backdrop {
background: ${designToken("background.overlay")};
inset: 0;
position: fixed;
z-index: 40;
}
.ek-shell-drawer {
background: ${designToken("background.canvas")};
box-shadow: ${designToken("shadow.lg")};
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${designToken("spacing.md")};
inset: 0 auto 0 0;
max-width: 85vw;
outline: none;
padding: ${designToken("spacing.md")};
position: fixed;
width: 20rem;
z-index: 41;
}
@media (prefers-reduced-motion: reduce) {
.ek-shell-sidebar {
transition: none;
}
}
`;
@@ -0,0 +1,70 @@
/**
* Whether the desktop sidebar is expanded or collapsed (ADR 0058).
*
* A preference of **this browser on this device**, not of the person: a narrow laptop and a wide
* office monitor reasonably want different answers, and DESIGN.md's synced personal preferences
* (appearance, motion) belong to the account area, which does not exist yet. The shell reads and
* writes it only through this interface, so a later store — synced, or per person — replaces the
* implementation without touching a component.
*/
export type SidebarMode = "expanded" | "collapsed";
export interface SidebarPreference {
/** The remembered mode, or `undefined` when nothing usable was ever stored. */
read(): SidebarMode | undefined;
write(mode: SidebarMode): void;
}
/** One namespaced key. Names no person and holds nothing but the mode. */
export const SIDEBAR_PREFERENCE_KEY = "ekklesia.dashboard.sidebar";
function isSidebarMode(value: unknown): value is SidebarMode {
return value === "expanded" || value === "collapsed";
}
/**
* The browser's `localStorage`, handed in by the entry point (the only file that touches `window`).
*
* Storage can be missing, blocked, full, or throw on every access (a private window, a sandboxed
* frame, site data cleared or disabled), and none of that may break the Dashboard. Every access is
* guarded, and the last mode chosen on this page is still remembered in memory, so the toggle keeps
* working and only persistence is lost. A stored value that is not exactly one of the two modes is
* ignored rather than trusted.
*/
export function createLocalSidebarPreference(
storage: Pick<Storage, "getItem" | "setItem"> | undefined,
): SidebarPreference {
let remembered: SidebarMode | undefined;
return {
read() {
if (remembered !== undefined) {
return remembered;
}
try {
const stored = storage?.getItem(SIDEBAR_PREFERENCE_KEY);
return isSidebarMode(stored) ? stored : undefined;
} catch {
return undefined;
}
},
write(mode) {
remembered = mode;
try {
storage?.setItem(SIDEBAR_PREFERENCE_KEY, mode);
} catch {
// Persistence is a convenience; the in-memory mode above still holds for this page.
}
},
};
}
/** Nothing persisted: for tests, and for a host composed without browser storage. */
export function createMemorySidebarPreference(initial?: SidebarMode): SidebarPreference {
let mode = initial;
return {
read: () => mode,
write(next) {
mode = next;
},
};
}