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
+20 -9
View File
@@ -147,6 +147,13 @@ async function render(
});
}
/** Sign-out lives in the account menu at the bottom of the sidebar (ADR 0058). */
async function openAccountMenu(): Promise<void> {
await act(async () => {
(query("host-account-menu") as HTMLButtonElement).click();
});
}
function text(): string {
return container.textContent ?? "";
}
@@ -391,7 +398,7 @@ describe("Ekklesia shell — browser authentication", () => {
await signInPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
expect(query("host-account-menu")).not.toBeNull();
});
it("clears the password while post-sign-in principal resolution is still pending", async () => {
@@ -424,7 +431,7 @@ describe("Ekklesia shell — browser authentication", () => {
await principalPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
expect(query("host-account-menu")).not.toBeNull();
});
it("submits from the password field with the Enter key", async () => {
@@ -454,7 +461,7 @@ describe("Ekklesia shell — browser authentication", () => {
await signInPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
expect(query("host-account-menu")).not.toBeNull();
});
it("re-resolves principal and navigation after login without a page reload", async () => {
@@ -467,7 +474,7 @@ describe("Ekklesia shell — browser authentication", () => {
await submitLogin();
expect(query("host-login")).toBeNull();
expect(query("host-logout")).not.toBeNull();
expect(query("host-account-menu")).not.toBeNull();
expect(isolatedText(query("module-container"))).toBe("example mounted");
expect(importer).toHaveBeenCalledOnce();
expect(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([
@@ -524,7 +531,7 @@ describe("Ekklesia shell — browser authentication", () => {
await act(async () => {
(retry as HTMLButtonElement).click();
});
expect(query("host-logout")).not.toBeNull();
expect(query("host-account-menu")).not.toBeNull();
});
it("fails safely on a malformed successful principal response and recovers on retry", async () => {
@@ -555,7 +562,7 @@ describe("Ekklesia shell — browser authentication", () => {
(retry as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
expect(query("host-account-menu")).not.toBeNull();
});
});
@@ -610,6 +617,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
}),
);
await openAccountMenu();
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
@@ -668,6 +676,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
if (!lateRequest) {
throw new Error("module context was not captured");
}
await openAccountMenu();
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
@@ -707,6 +716,7 @@ describe("Ekklesia shell — logout and session expiration", () => {
});
await render({ apiBaseUrl, fetch: platform.fetch }, async () => artifact("example", "0.1.0"));
await openAccountMenu();
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
@@ -1024,9 +1034,10 @@ describe("Ekklesia shell — mount lifecycle", () => {
const aside = shell.querySelector("aside") as HTMLElement;
const main = shell.querySelector("main") as HTMLElement;
const frame = query("module-container") as HTMLElement;
expect(shell.style.gridTemplateColumns).toBe("15rem minmax(0, 1fr)");
expect(shell.style.gridTemplateRows).toBe("auto minmax(0, 1fr)");
expect(aside.style.width).toBe("15rem");
// The sidebar sizes its own track (ADR 0058); the page track can shrink to nothing but never
// pushes the sidebar.
expect(shell.style.gridTemplateColumns).toBe("auto minmax(0, 1fr)");
expect(aside.style.width).toBe("16rem");
expect(main.style.minWidth).toBe("0");
expect(main.style.overflowX).toBe("auto");
expect(frame.style.minWidth).toBe("0");