Files
Ekklesia/surfaces/shell/tests/shell-environment.test.ts
T
ekklesiaandClaude Opus 5.5 af31724cb4
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
feat(shell): collapsible sidebar, account menu, and phone drawer
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]>
2026-10-14 17:49:56 -05:00

126 lines
4.3 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
createBrowserHostViewport,
createMemoryHostViewport,
HOST_COMPACT_MEDIA_QUERY,
} from "../src/browser/host-viewport.js";
import {
createLocalSidebarPreference,
createMemorySidebarPreference,
SIDEBAR_PREFERENCE_KEY,
} from "../src/browser/sidebar-preference.js";
function fakeStorage(initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial));
return {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
values.set(key, value);
}),
values,
};
}
describe("the sidebar preference (this browser only, ADR 0058)", () => {
it("survives a reload: what one store writes, a new store over the same storage reads", () => {
const storage = fakeStorage();
createLocalSidebarPreference(storage).write("collapsed");
expect(storage.values.get(SIDEBAR_PREFERENCE_KEY)).toBe("collapsed");
expect(createLocalSidebarPreference(storage).read()).toBe("collapsed");
createLocalSidebarPreference(storage).write("expanded");
expect(createLocalSidebarPreference(storage).read()).toBe("expanded");
});
it("uses one namespaced key that names no person", () => {
expect(SIDEBAR_PREFERENCE_KEY).toBe("ekklesia.dashboard.sidebar");
});
it("ignores a stored value it does not recognise rather than trusting it", () => {
for (const value of ["", "COLLAPSED", "true", "{}", "collapsed "]) {
expect(
createLocalSidebarPreference(fakeStorage({ [SIDEBAR_PREFERENCE_KEY]: value })).read(),
value,
).toBeUndefined();
}
});
it("keeps working, for this page only, when storage is missing or throws", () => {
const throwing = {
getItem: () => {
throw new Error("SecurityError");
},
setItem: () => {
throw new Error("QuotaExceededError");
},
};
for (const storage of [undefined, throwing]) {
const preference = createLocalSidebarPreference(storage);
expect(preference.read()).toBeUndefined();
expect(() => preference.write("collapsed")).not.toThrow();
expect(preference.read()).toBe("collapsed");
}
});
it("has an in-memory store with the same contract, for tests and as the fallback", () => {
const preference = createMemorySidebarPreference("collapsed");
expect(preference.read()).toBe("collapsed");
preference.write("expanded");
expect(preference.read()).toBe("expanded");
expect(createMemorySidebarPreference().read()).toBeUndefined();
});
});
describe("the host viewport", () => {
it("is compact below 48em, the phone layout's breakpoint", () => {
expect(HOST_COMPACT_MEDIA_QUERY).toBe("(max-width: 47.999em)");
});
it("reads and follows a media query list", () => {
let matches = false;
const listeners = new Set<() => void>();
const mediaQuery = {
addEventListener: vi.fn((_type: "change", listener: () => void) => listeners.add(listener)),
get matches() {
return matches;
},
removeEventListener: vi.fn((_type: "change", listener: () => void) =>
listeners.delete(listener),
),
};
const matchMedia = vi.fn(() => mediaQuery);
const viewport = createBrowserHostViewport(matchMedia);
expect(matchMedia).toHaveBeenCalledWith(HOST_COMPACT_MEDIA_QUERY);
const listener = vi.fn();
const unsubscribe = viewport.subscribe(listener);
expect(viewport.isCompact()).toBe(false);
matches = true;
for (const notify of listeners) {
notify();
}
expect(listener).toHaveBeenCalledTimes(1);
expect(viewport.isCompact()).toBe(true);
unsubscribe();
expect(mediaQuery.removeEventListener).toHaveBeenCalledTimes(1);
});
it("falls back to the wide layout where media queries are unavailable", () => {
const viewport = createBrowserHostViewport(undefined);
expect(viewport.isCompact()).toBe(false);
expect(() => viewport.subscribe(() => undefined)()).not.toThrow();
});
it("has an in-memory viewport a test can resize", () => {
const viewport = createMemoryHostViewport(true);
const listener = vi.fn();
viewport.subscribe(listener);
viewport.setCompact(false);
expect(viewport.isCompact()).toBe(false);
expect(listener).toHaveBeenCalledTimes(1);
viewport.setCompact(false);
expect(listener).toHaveBeenCalledTimes(1);
});
});