feat(shell): collapsible sidebar, account menu, and phone drawer (Stage 1, ADR 0058) #104

Merged
Zayin_Admin merged 2 commits from feat/dashboard-shell-navigation into main 2026-10-14 23:05:29 +00:00
3 changed files with 48 additions and 6 deletions
Showing only changes of commit cf2f847c8c - Show all commits
@@ -169,13 +169,41 @@ describe("the browser shell reaches none of it", () => {
}); });
it("never puts a reset token in browser storage", async () => { it("never puts a reset token in browser storage", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) { // A reset token is a bearer credential for one account; the shell holds it in React state for
// the life of one form and nowhere else. The shell's one use of browser storage is the desktop
// sidebar's expanded/collapsed mode (ADR 0058), so that use is pinned exactly: `localStorage` is
// named only by the entry point, and only to hand it to the sidebar preference, whose one write
// is its own fixed key and a validated mode.
const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src"));
const writes: string[] = [];
for (const file of files) {
const code = withoutComments(file.text); const code = withoutComments(file.text);
// A reset token is a bearer credential for one account; the shell holds it in React state for
// the life of one form and nowhere else.
expect(code, file.path).not.toContain("localStorage");
expect(code, file.path).not.toContain("sessionStorage"); expect(code, file.path).not.toContain("sessionStorage");
expect(code, file.path).not.toContain("document.cookie"); expect(code, file.path).not.toContain("document.cookie");
writes.push(
...[...code.matchAll(/\.setItem\(([^)]*)\)/gu)].map(
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
),
);
if (file.path.endsWith(path.join("browser", "main.tsx"))) {
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
expect(code).toMatch(
/createLocalSidebarPreference\(\s*\(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
);
} else {
expect(code, file.path).not.toContain("localStorage");
}
}
expect(writes).toEqual([
`${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`,
]);
// The files that ever hold the token touch no storage API at all.
for (const file of files.filter((candidate) =>
["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) =>
candidate.path.endsWith(name),
),
)) {
expect(withoutComments(file.text), file.path).not.toMatch(/\b(?:setItem|getItem|Storage)\b/u);
} }
}); });
}); });
+4 -1
View File
@@ -403,7 +403,10 @@ parser is exercised as the pure function it is, including that it names no desti
no runtime assertion can make: that the identity feature names no SMTP detail, that no Ekklesia no runtime assertion can make: that the identity feature names no SMTP detail, that no Ekklesia
schema gained a token column and no identity code hashes or compares a token, that nothing schema gained a token column and no identity code hashes or compares a token, that nothing
interpolates a token, link, address, or body into a log line, that a device has no email, password, interpolates a token, link, address, or body into a log line, that a device has no email, password,
or verification concept, and that no browser source imports Better Auth or Core's mail. or verification concept, that no browser source imports Better Auth or Core's mail, and that the
shell's only use of browser storage is the sidebar's expanded/collapsed mode — `localStorage` named
only by the entry point, one write of one fixed key, and no storage API in any file that holds the
reset token ([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)).
Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an
injected in-memory provider and never touch the network. They prove the parts the protocol cannot: injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
+12 -1
View File
@@ -4,6 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js"; import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { HostApp } from "../src/browser/HostApp.js"; import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js"; import { createMemoryHostHistory } from "../src/browser/host-history.js";
import {
createLocalSidebarPreference,
SIDEBAR_PREFERENCE_KEY,
} from "../src/browser/sidebar-preference.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js"; import type { EkklesiaHostConfig } from "../src/host-protocol.js";
/** /**
@@ -99,6 +103,9 @@ async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding)
<HostApp <HostApp
config={config} config={config}
history={createMemoryHostHistory("/")} history={createMemoryHostHistory("/")}
// The production store over this browser's real storage, so the assertions below cover
// the shell's one use of it (ADR 0058) as well.
sidebarPreference={createLocalSidebarPreference(window.localStorage)}
{...(authLanding ? { authLanding } : {})} {...(authLanding ? { authLanding } : {})}
/>, />,
); );
@@ -463,7 +470,11 @@ describe("reset password", () => {
// A reset token is a bearer credential for one account. It lives in React state for the life of // A reset token is a bearer credential for one account. It lives in React state for the life of
// the form and nowhere else. // the form and nowhere else.
expect(localStorage.length).toBe(0); // The only thing this browser's storage may ever hold is the sidebar's mode (ADR 0058).
for (let index = 0; index < localStorage.length; index += 1) {
expect(localStorage.key(index)).toBe(SIDEBAR_PREFERENCE_KEY);
}
expect(JSON.stringify({ ...localStorage })).not.toContain("reset-token-abc");
expect(sessionStorage.length).toBe(0); expect(sessionStorage.length).toBe(0);
expect(text()).not.toContain("reset-token-abc"); expect(text()).not.toContain("reset-token-abc");
expect(container.innerHTML).not.toContain("reset-token-abc"); expect(container.innerHTML).not.toContain("reset-token-abc");