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
+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 };
}