Files
Ekklesia/surfaces/shell/tests/settings-navigation.test.tsx
T
ekklesiaandClaude Opus 5.5 405e509bcb
CI / verify (pull_request) Successful in 1m13s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m22s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
Fix the Stage 1 Dashboard UX defects found in assembled review
The assembled Stage 1 Dashboard was reviewed by hand on a desktop, with
the sidebar collapsed, and on an iPhone. This fixes what that review
found, and records the decisions in ADR 0064:

- Discover is its own page at /settings/discover, with Installed and
  Updates beneath it as children. There is no Browse item any more.
  /settings/discover/browse is still read, as an alias, and is respelled
  in place. Parents and children now read as a hierarchy: a guide line,
  text-only children, a disclosure chevron, and parent and current
  states that look different.
- The sidebar is navigation only. Display pairing moves from the sidebar
  and drawer to Settings -> Displays: the paired displays, a deliberate
  Add display dialog, and revocation. Nothing takes focus on arrival;
  the code field is focused only once Add display is chosen.
- Collapsed icons are centred. Nav-link layout moves from inline styles
  to the shell stylesheet, and every collapsed item is one square
  centred on the rail.
- /settings is an overview of the installation: displays paired,
  modules installed and running, and a line for each other area. It
  reads only existing endpoints.
- Phones get a sticky, safe-area-aware header holding the menu trigger
  and the page name. New pages start at the top, and focus no longer
  scrolls content under the header.
- @ekklesia/ui draws checkbox and radio indicators from tokens rather
  than accent-color, so the selected state is the strong accent in
  Light and Dark alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:37:32 -05:00

724 lines
28 KiB
TypeScript

import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js";
import { createMemoryHostViewport } from "../src/browser/host-viewport.js";
import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js";
import type { DashboardSettings } from "../src/host-protocol.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* The Settings shell (ADR 0061): its landing page, the platform sections and Discover, each
* module's settings, and which of them the server said this person may see.
*/
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
});
function entry(id: string, name: string) {
return {
module: {
api: { baseUrl: `/api/v1/modules/${id}` },
contributions: [],
display: null,
id,
permissions: [],
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
ui: { url: `/api/v1/module-ui/${id}/0.1.0` },
version: "0.1.0",
},
name,
navigation: { icon: null, pages: [] },
};
}
const NAVIGATION = {
groups: [],
ungrouped: [entry("signage", "Signage"), entry("calendar", "Calendar")],
};
/** An administrator who may see everything: every platform section, two modules' settings, and a
* disabled module (`archive`) whose settings exist but are not navigable. Calendar declares no
* settings, so it is a known module with no settings page. */
const ADMINISTRATOR: DashboardSettings = {
modules: [
{ available: false, id: "archive", name: "Archive" },
{ available: true, id: "signage", name: "Signage" },
],
sections: ["general", "branding", "displays", "discover", "system"],
};
const DEVICES = [
{
createdAt: "2026-10-01T09:00:00.000Z",
id: "device-1",
lastSeenAt: new Date(Date.now() - 5 * 60_000).toISOString(),
name: "Foyer TV",
permissions: ["signage.display"],
revoked: false,
},
{
createdAt: "2026-08-01T09:00:00.000Z",
id: "device-2",
name: "Old hall screen",
permissions: ["signage.display"],
revoked: true,
},
];
const LIFECYCLE = [
{
configured: { enabled: true, explicit: false },
id: "signage",
restartRequired: false,
runtime: { state: "running" },
version: "0.1.5",
},
{
configured: { enabled: false, explicit: true },
id: "archive",
restartRequired: false,
runtime: { state: "disabled" },
version: "1.0.0",
},
];
/** Lets pending requests settle, so the overview's summaries have answered. */
async function settle(): Promise<void> {
await act(async () => {
for (let index = 0; index < 5; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
interface Rendered {
readonly history: MemoryHostHistory;
readonly calls: string[];
readonly titles: string[];
}
async function render(
path: string,
options: {
readonly settings?: DashboardSettings | "fails";
readonly compact?: boolean;
readonly collapsed?: boolean;
readonly history?: MemoryHostHistory;
readonly devices?: unknown[];
} = {},
): Promise<Rendered> {
const history = options.history ?? createMemoryHostHistory(path);
const calls: string[] = [];
const titles: string[] = [];
let settingsAttempts = 0;
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
calls.push(new URL(url).pathname);
if (url.endsWith("/api/v1/principal")) {
return Response.json({ kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return Response.json(NAVIGATION);
}
if (url.endsWith("/api/v1/dashboard/settings")) {
settingsAttempts += 1;
const settings = options.settings ?? ADMINISTRATOR;
return settings === "fails" && settingsAttempts === 1
? Response.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, { status: 503 })
: Response.json(settings === "fails" ? ADMINISTRATOR : settings);
}
// The existing data the Settings overview reads (ADR 0064); a 403 for anyone it would refuse.
const visible = options.settings ?? ADMINISTRATOR;
const sections = visible === "fails" ? ADMINISTRATOR.sections : visible.sections;
if (url.endsWith("/api/v1/devices")) {
return sections.includes("displays")
? Response.json({ devices: options.devices ?? DEVICES })
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
if (url.endsWith("/api/v1/module-lifecycle")) {
return sections.includes("discover")
? Response.json({ modules: LIFECYCLE })
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}) as unknown as typeof fetch;
await act(async () => {
root.render(
<HostApp
config={{ apiBaseUrl, fetch: fetchImplementation }}
history={history}
importModule={async () => ({
default: {
abi: MODULE_UI_ABI_VERSION,
id: "signage",
mount(element: Element, context: ModuleUiHostContext) {
element.textContent = `${context.moduleId} mounted`;
},
version: "0.1.0",
},
})}
setDocumentTitle={(title) => titles.push(title)}
sidebarPreference={createMemorySidebarPreference(
options.collapsed ? "collapsed" : undefined,
)}
viewport={createMemoryHostViewport(options.compact ?? false)}
/>,
);
});
return { calls, history, titles };
}
function query(testId: string): HTMLElement | null {
return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
}
function required(testId: string): HTMLElement {
const element = query(testId);
if (!element) {
throw new Error(`missing ${testId}`);
}
return element;
}
async function click(element: Element): Promise<void> {
await act(async () => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, cancelable: true }));
});
}
const current = () =>
[...container.querySelectorAll('nav [aria-current="page"]')].map(
(element) => (element as HTMLElement).dataset.testid,
);
describe("the Settings landing page", () => {
it("is a real page, not a redirect: an overview of the installation, not a copy of the sidebar", async () => {
const { history, titles, calls } = await render("/settings");
await settle();
expect(history.entries).toEqual(["/settings"]);
const home = required("settings-home");
expect(home.querySelector("h1")?.textContent).toBe("Settings");
// Each area is a summary of its state, read from what already exists (ADR 0064).
expect(required("settings-summary-displays-status").textContent).toBe("1 display paired");
expect(required("settings-summary-displays").textContent).toContain(
"Foyer TV was last seen within the last hour.",
);
expect(required("settings-summary-modules-status").textContent).toBe("2 modules installed");
expect(required("settings-summary-modules").textContent).toContain("1 turned off");
expect(required("settings-summary-branding-status").textContent).toBe(
"Using the default name and colours",
);
expect(calls).toContain("/api/v1/devices");
expect(calls).toContain("/api/v1/module-lifecycle");
// The one link that changes each area, rather than the sidebar's list again.
expect(
[...home.querySelectorAll('[data-testid^="settings-home-"]')].map((link) =>
link.getAttribute("href"),
),
).toEqual([
"/settings/displays",
"/settings/discover/installed",
"/settings/signage",
"/settings/general",
"/settings/branding",
"/settings/system",
]);
// Not one navigation item per section, the way the sidebar lists them.
expect(home.querySelector(".ek-shell-settings-link")).toBeNull();
// A disabled module's settings are not offered.
expect(query("settings-home-module-archive")).toBeNull();
expect(
[...container.querySelectorAll("main h2")].map((heading) => heading.textContent),
).toEqual(["Displays", "Modules", "General", "Branding", "System"]);
// Nothing on it takes focus by itself.
expect(document.activeElement?.tagName).not.toBe("INPUT");
expect(titles.at(-1)).toBe("Settings");
expect(current()).toEqual(["select-settings"]);
});
it("summarises only what this person may see, and asks for nothing else", async () => {
const { calls } = await render("/settings", {
settings: { modules: [{ available: true, id: "signage", name: "Signage" }], sections: [] },
});
await settle();
expect(query("settings-summary-displays")).toBeNull();
expect(required("settings-summary-modules-status").textContent).toBe("Module settings");
expect(calls).not.toContain("/api/v1/devices");
expect(calls).not.toContain("/api/v1/module-lifecycle");
});
it("says plainly when nothing is paired yet, and when a summary cannot be read", async () => {
await render("/settings", { devices: [] });
await settle();
expect(required("settings-summary-displays-status").textContent).toBe("No displays paired yet");
expect(required("settings-home-displays").textContent).toBe("Add a display");
});
it("says plainly when there is nothing for this person to change", async () => {
await render("/settings", { settings: { modules: [], sections: [] } });
expect(query("settings-home")?.textContent).toContain(
"There are no settings for you to change",
);
});
});
describe("the platform sections", () => {
it("gives each a page of its own, with Back to Settings and an honest placeholder", async () => {
for (const section of ["general", "branding", "system"] as const) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
const { titles } = await render(`/settings/${section}`);
const page = required(`settings-section-${section}`);
expect(page.querySelector("h1")?.textContent, section).toBeTruthy();
expect(page.textContent, section).toContain("Not available yet");
// No fake controls.
expect(page.querySelector("input, select, textarea, form"), section).toBeNull();
const back = page.querySelector(".ek-page-header__back");
expect(back?.getAttribute("href"), section).toBe("/settings");
expect(back?.textContent, section).toContain("Back to Settings");
expect(current(), section).toEqual([`select-settings-${section}`]);
expect(titles.at(-1), section).toMatch(/ settings$/u);
}
});
it("opens Discover itself at /settings/discover, as the place modules are found", async () => {
const { history, titles } = await render("/settings/discover");
expect(history.entries).toEqual(["/settings/discover"]);
expect(query("settings-discover-browse")?.querySelector("h1")?.textContent).toBe(
"Discover modules",
);
expect(query("settings-discover-browse")?.textContent).toContain(
"Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.",
);
expect(titles.at(-1)).toBe("Discover modules");
// Discover is the current page; it is not a parent of some separate Browse page.
expect(current()).toEqual(["select-settings-discover"]);
expect(query("select-settings-discover")?.getAttribute("href")).toBe("/settings/discover");
expect(query("select-discover-browse")).toBeNull();
});
it("respells the earlier /settings/discover/browse in place, adding no history entry", async () => {
const { history } = await render("/settings/discover/browse?return=%2Fsignage");
expect(history.entries).toEqual(["/settings/discover?return=%2Fsignage"]);
expect(query("settings-discover-browse")).not.toBeNull();
expect(current()).toEqual(["select-settings-discover"]);
// Nothing links to the alias.
expect(container.querySelector('a[href^="/settings/discover/browse"]')).toBeNull();
});
it("has Installed and Updates — and no Browse — beneath Discover, each speaking of modules", async () => {
const { history } = await render("/settings/discover");
const children = required("discover-subnavigation");
const views = [...children.querySelectorAll("a")];
expect(views.map((view) => view.textContent)).toEqual(["Installed", "Updates"]);
expect(views.map((view) => view.getAttribute("href"))).toEqual([
"/settings/discover/installed",
"/settings/discover/updates",
]);
// Children: text only, marked as children, and listed beneath Discover by name.
expect(children.getAttribute("aria-label")).toBe("Discover");
expect(children.className).toContain("ek-shell-nav__children");
for (const view of views) {
expect(view.dataset.navLevel).toBe("child");
expect(view.querySelector("svg")).toBeNull();
}
expect(required("select-settings-discover-disclosure").dataset.open).toBe("true");
await click(required("select-discover-installed"));
expect(query("settings-discover-installed")?.querySelector("h1")?.textContent).toBe(
"Installed modules",
);
// The child is the current page and Discover is marked as its parent, never also current.
expect(current()).toEqual(["select-discover-installed"]);
expect(required("select-settings-discover").dataset.navAncestor).toBe("");
await click(required("select-discover-updates"));
expect(query("settings-discover-updates")?.querySelector("h1")?.textContent).toBe(
"Module updates",
);
await click(required("select-settings-discover"));
expect(current()).toEqual(["select-settings-discover"]);
expect(required("select-settings-discover").dataset.navAncestor).toBeUndefined();
expect(history.entries).toEqual([
"/settings/discover",
"/settings/discover/installed",
"/settings/discover/updates",
"/settings/discover",
]);
});
it("shows Discover closed, with no children, while another Settings page is open", async () => {
await render("/settings/general");
expect(query("discover-subnavigation")).toBeNull();
expect(required("select-settings-discover-disclosure").dataset.open).toBe("false");
});
it("shows only the parent Discover item in the collapsed rail, current for every Discover page", async () => {
for (const path of ["/settings/discover", "/settings/discover/installed"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render(path, { collapsed: true });
expect(query("discover-subnavigation"), path).toBeNull();
expect(query("select-discover-installed"), path).toBeNull();
expect(current(), path).toEqual(["select-settings-discover"]);
// No disclosure chevron in the rail: it has no room for the children it would announce.
expect(query("select-settings-discover-disclosure"), path).toBeNull();
}
});
it("says a section is unavailable to someone who may not see it, rather than showing it", async () => {
await render("/settings/discover/installed", {
settings: { modules: [], sections: ["displays"] },
});
expect(query("settings-no-access")).not.toBeNull();
expect(query("settings-discover-installed")).toBeNull();
expect(query("select-settings-discover")).toBeNull();
});
});
describe("module settings", () => {
it("opens a running module's settings at /settings/<module-id>, as a placeholder", async () => {
const { titles } = await render("/settings/signage");
const page = required("module-settings");
expect(page.querySelector("h1")?.textContent).toBe("Signage");
expect(page.textContent).toContain("Not available yet");
expect(page.querySelector("input, select, textarea, form")).toBeNull();
expect(titles.at(-1)).toBe("Signage settings");
expect(current()).toEqual(["select-module-settings-signage"]);
});
it("says a disabled module's settings are turned off, rather than redirecting", async () => {
const { history } = await render("/settings/archive");
expect(query("settings-module-unavailable")?.textContent).toContain(
"This module is turned off",
);
expect(history.entries).toEqual(["/settings/archive"]);
expect(query("select-module-settings-archive")).toBeNull();
});
it("says a known module's settings aren't available when there are none for this person", async () => {
await render("/settings/calendar");
expect(query("settings-no-access")).not.toBeNull();
});
it("is not found for a name that is no module", async () => {
for (const path of ["/settings/definitely-not-a-module", "/settings/modules"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render(path);
expect(query("host-not-found"), path).not.toBeNull();
}
});
});
describe("Settings in the navigation", () => {
it("is one item after the modules, expanding into Platform and Modules only inside Settings", async () => {
const { history } = await render("/signage");
const settingsLink = required("select-settings");
expect(settingsLink.getAttribute("href")).toBe("/settings");
expect(query("settings-subnavigation")).toBeNull();
const links = [...container.querySelectorAll("nav a")].map((link) => link.textContent);
expect(links).toEqual(["Overview", "Signage", "Calendar", "Settings"]);
await click(settingsLink);
expect(history.entries).toEqual(["/signage", "/settings"]);
const sub = required("settings-subnavigation");
expect([...sub.querySelectorAll("h3")].map((heading) => heading.textContent)).toEqual([
"Platform",
"Modules",
]);
expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([
"General",
"Branding",
"Displays",
"Discover",
"System",
"Signage",
]);
// The rest of the navigation stays, so leaving Settings is one click.
await click(required("select-signage"));
expect(query("settings-subnavigation")).toBeNull();
expect(history.entries).toEqual(["/signage", "/settings", "/signage"]);
});
it("shows only the sections and module settings this person may see", async () => {
await render("/settings", {
settings: {
modules: [{ available: true, id: "signage", name: "Signage" }],
sections: ["displays"],
},
});
const sub = required("settings-subnavigation");
expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([
"Displays",
"Signage",
]);
});
it("is not offered at all to someone with nothing to change", async () => {
await render("/", { settings: { modules: [], sections: [] } });
expect(query("select-settings")).toBeNull();
});
it("is withheld when Settings cannot be loaded, whose pages say so and retry", async () => {
const { calls } = await render("/settings", { settings: "fails" });
expect(query("select-settings")).toBeNull();
expect(query("settings-load-failed")).not.toBeNull();
// The rest of the Dashboard still works.
expect(query("select-signage")).not.toBeNull();
const retry = [...required("settings-load-failed").querySelectorAll("button")].find(
(button) => button.textContent === "Retry",
) as HTMLButtonElement;
await act(async () => {
retry.click();
});
expect(query("settings-home")).not.toBeNull();
expect(calls.filter((path) => path === "/api/v1/dashboard/settings")).toHaveLength(2);
});
it("keeps the exact Settings page across a refresh, and Back and Forward walk it", async () => {
const history = createMemoryHostHistory("/settings");
await render("/settings", { history });
await click(required("select-settings-branding"));
await click(required("select-module-settings-signage"));
await act(async () => {
history.go(-1);
});
expect(query("settings-section-branding")).not.toBeNull();
await act(async () => {
history.go(1);
});
expect(query("module-settings")).not.toBeNull();
// A refresh is the same address, rendered from scratch.
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render("/settings/signage", { history: createMemoryHostHistory(history.pathname()) });
expect(query("module-settings")).not.toBeNull();
expect(current()).toEqual(["select-module-settings-signage"]);
});
it("moves focus to the page when moving between two Settings pages", async () => {
await render("/settings/general");
const main = required("host-main-region");
await click(required("select-settings-branding"));
expect(document.activeElement).toBe(main);
expect(main.getAttribute("aria-label")).toBe("Branding settings");
});
it("uses the same hierarchy in the phone drawer, which closes once a page is chosen", async () => {
const { history } = await render("/settings", { compact: true });
await click(required("mobile-navigation-trigger"));
const drawer = required("navigation-drawer");
expect(
[...drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')].map(
(link) => link.textContent,
),
).toEqual(["General", "Branding", "Displays", "Discover", "System", "Signage"]);
for (const link of drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')) {
expect((link as HTMLElement).style.minHeight).toBe("var(--ekklesia-control-height-lg)");
}
await click(required("select-settings-system"));
expect(query("navigation-drawer")).toBeNull();
expect(history.entries).toEqual(["/settings", "/settings/system"]);
expect(query("settings-section-system")).not.toBeNull();
});
});
describe("the sidebar is navigation only (ADR 0064)", () => {
it("has no display-pairing block, expanded or in the phone drawer", async () => {
await render("/settings");
const sidebar = required("host-sidebar");
expect(query("host-devices")).toBeNull();
expect(sidebar.querySelector("input, form")).toBeNull();
expect(sidebar.textContent).not.toMatch(/display devices|pairing code/iu);
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render("/settings", { compact: true });
await click(required("mobile-navigation-trigger"));
const drawer = required("navigation-drawer");
expect(drawer.querySelector("input, form")).toBeNull();
expect(drawer.textContent).not.toMatch(/display devices|pairing code/iu);
});
it("offers pairing under Settings → Displays instead, without focusing anything on arrival", async () => {
for (const path of ["/settings", "/settings/displays", "/signage", "/"]) {
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render(path);
await settle();
expect(document.activeElement?.tagName, path).not.toBe("INPUT");
expect(container.querySelector("input"), path).toBeNull();
if (path === "/settings/displays") {
expect(required("settings-section-displays").querySelector("h1")?.textContent).toBe(
"Displays",
);
}
}
// Moving to Displays from the navigation moves focus to the page, never into a field.
await act(async () => {
root.unmount();
});
root = createRoot(container);
await render("/settings");
await click(required("select-settings-displays"));
await settle();
expect(document.activeElement).toBe(required("host-main-region"));
expect(required("add-display").textContent).toContain("Add display");
});
});
describe("collapsed navigation geometry (ADR 0064)", () => {
it("leaves every item's layout to the stylesheet, so the rail can centre each glyph", async () => {
await render("/settings/general", { collapsed: true });
const sidebar = required("host-sidebar");
expect(sidebar.dataset.sidebar).toBe("collapsed");
const items = [...sidebar.querySelectorAll<HTMLElement>("nav a")];
expect(items.map((item) => item.dataset.testid)).toEqual([
"select-overview",
"select-signage",
"select-calendar",
"select-settings",
"select-settings-general",
"select-settings-branding",
"select-settings-displays",
"select-settings-discover",
"select-settings-system",
"select-module-settings-signage",
]);
for (const item of items) {
// One class carries the geometry; no inline layout can outrank the collapsed rule.
expect(item.classList.contains("ek-shell-nav-item"), item.dataset.testid).toBe(true);
expect(item.style.justifyContent, item.dataset.testid).toBe("");
expect(item.style.width, item.dataset.testid).toBe("");
expect(item.style.padding, item.dataset.testid).toBe("");
// Its name stays for assistive technology; only one glyph is drawn.
expect(item.querySelectorAll("svg"), item.dataset.testid).toHaveLength(1);
expect(item.querySelector(".ek-shell-visually-hidden")?.textContent).toBeTruthy();
}
const stylesheet = [...container.querySelectorAll("style")]
.map((style) => style.textContent)
.join("\n");
const collapsedRule =
/\.ek-shell-sidebar\[data-sidebar="collapsed"\] \.ek-shell-nav-item \{([^}]*)\}/u.exec(
stylesheet,
)?.[1];
expect(collapsedRule).toContain("justify-content: center");
expect(collapsedRule).toContain("margin-inline: auto");
expect(collapsedRule).toContain("padding: 0");
expect(collapsedRule).toContain("width: var(--ekklesia-control-height-md)");
expect(collapsedRule).toContain("height: var(--ekklesia-control-height-md)");
});
});
describe("the phone header (ADR 0064)", () => {
it("is a sticky header holding the menu trigger and the open page's name", async () => {
await render("/settings/displays", { compact: true });
const header = required("mobile-header");
expect(header.tagName).toBe("HEADER");
expect(header.className).toBe("ek-shell-mobile-bar");
expect(header.contains(required("mobile-navigation-trigger"))).toBe(true);
// A visual echo of the page title; the page's own h1 is what assistive technology reads.
const title = required("mobile-header-title");
expect(title.textContent).toBe("Displays settings");
expect(title.getAttribute("aria-hidden")).toBe("true");
const trigger = required("mobile-navigation-trigger");
expect(trigger.style.minHeight).toBe("var(--ekklesia-control-height-lg)");
expect(trigger.style.minWidth).toBe("var(--ekklesia-control-height-lg)");
const stylesheet = [...container.querySelectorAll("style")]
.map((style) => style.textContent)
.join("\n");
const rule = /\.ek-shell-mobile-bar \{([^}]*)\}/u.exec(stylesheet)?.[1];
expect(rule).toContain("position: sticky");
expect(rule).toContain("top: 0");
expect(rule).toContain("env(safe-area-inset-top, 0px)");
});
it("follows the open page, and the drawer still opens from it and returns focus to it", async () => {
await render("/settings", { compact: true });
expect(required("mobile-header-title").textContent).toBe("Settings");
await click(required("mobile-navigation-trigger"));
const drawer = required("navigation-drawer");
expect(document.activeElement).toBe(required("navigation-drawer-close"));
await act(async () => {
drawer.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
});
expect(query("navigation-drawer")).toBeNull();
expect(document.activeElement).toBe(required("mobile-navigation-trigger"));
await click(required("mobile-navigation-trigger"));
await click(required("select-settings-discover"));
expect(query("navigation-drawer")).toBeNull();
expect(required("mobile-header-title").textContent).toBe("Discover modules");
expect(document.activeElement).toBe(required("host-main-region"));
});
});