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
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:
co-authored by
Claude Opus 5.5
parent
f3b7e19651
commit
af31724cb4
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Whether the desktop sidebar is expanded or collapsed (ADR 0058).
|
||||
*
|
||||
* A preference of **this browser on this device**, not of the person: a narrow laptop and a wide
|
||||
* office monitor reasonably want different answers, and DESIGN.md's synced personal preferences
|
||||
* (appearance, motion) belong to the account area, which does not exist yet. The shell reads and
|
||||
* writes it only through this interface, so a later store — synced, or per person — replaces the
|
||||
* implementation without touching a component.
|
||||
*/
|
||||
export type SidebarMode = "expanded" | "collapsed";
|
||||
|
||||
export interface SidebarPreference {
|
||||
/** The remembered mode, or `undefined` when nothing usable was ever stored. */
|
||||
read(): SidebarMode | undefined;
|
||||
write(mode: SidebarMode): void;
|
||||
}
|
||||
|
||||
/** One namespaced key. Names no person and holds nothing but the mode. */
|
||||
export const SIDEBAR_PREFERENCE_KEY = "ekklesia.dashboard.sidebar";
|
||||
|
||||
function isSidebarMode(value: unknown): value is SidebarMode {
|
||||
return value === "expanded" || value === "collapsed";
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's `localStorage`, handed in by the entry point (the only file that touches `window`).
|
||||
*
|
||||
* Storage can be missing, blocked, full, or throw on every access (a private window, a sandboxed
|
||||
* frame, site data cleared or disabled), and none of that may break the Dashboard. Every access is
|
||||
* guarded, and the last mode chosen on this page is still remembered in memory, so the toggle keeps
|
||||
* working and only persistence is lost. A stored value that is not exactly one of the two modes is
|
||||
* ignored rather than trusted.
|
||||
*/
|
||||
export function createLocalSidebarPreference(
|
||||
storage: Pick<Storage, "getItem" | "setItem"> | undefined,
|
||||
): SidebarPreference {
|
||||
let remembered: SidebarMode | undefined;
|
||||
return {
|
||||
read() {
|
||||
if (remembered !== undefined) {
|
||||
return remembered;
|
||||
}
|
||||
try {
|
||||
const stored = storage?.getItem(SIDEBAR_PREFERENCE_KEY);
|
||||
return isSidebarMode(stored) ? stored : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
write(mode) {
|
||||
remembered = mode;
|
||||
try {
|
||||
storage?.setItem(SIDEBAR_PREFERENCE_KEY, mode);
|
||||
} catch {
|
||||
// Persistence is a convenience; the in-memory mode above still holds for this page.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Nothing persisted: for tests, and for a host composed without browser storage. */
|
||||
export function createMemorySidebarPreference(initial?: SidebarMode): SidebarPreference {
|
||||
let mode = initial;
|
||||
return {
|
||||
read: () => mode,
|
||||
write(next) {
|
||||
mode = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user