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
@@ -29,14 +29,15 @@ import {
|
||||
HostProtocolError,
|
||||
loadHostPrincipal,
|
||||
} from "../host-protocol.js";
|
||||
import { DashboardLink } from "./DashboardLink.js";
|
||||
import { DevicePanel } from "./DevicePanel.js";
|
||||
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js";
|
||||
import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js";
|
||||
import { LoginForm } from "./LoginForm.js";
|
||||
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
||||
import { ResetPasswordForm } from "./ResetPasswordForm.js";
|
||||
import { ShellLayout } from "./ShellLayout.js";
|
||||
import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js";
|
||||
import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js";
|
||||
|
||||
type AuthenticatedPrincipal = Extract<
|
||||
@@ -122,77 +123,26 @@ export interface HostAppProps {
|
||||
/** Names the browser tab after the open page. Supplied by the entry point, which owns `document`;
|
||||
* left unset, the shell names nothing. */
|
||||
readonly setDocumentTitle?: (title: string) => void;
|
||||
/**
|
||||
* Whether the page is laid out for a phone (ADR 0058). The entry point passes the browser's media
|
||||
* query; left unset, the wide layout. Must be a stable reference.
|
||||
*/
|
||||
readonly viewport?: HostViewport;
|
||||
/**
|
||||
* Where the desktop sidebar's expanded or collapsed mode is remembered (ADR 0058). The entry point
|
||||
* passes this browser's storage; left unset, the mode lasts only as long as this component.
|
||||
*/
|
||||
readonly sidebarPreference?: SidebarPreference;
|
||||
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's own chrome, expressed in the platform's global semantic tokens rather than in literal
|
||||
* colours (ADR 0039). Nothing here is a brand decision any more: changing
|
||||
* `--ekklesia-background-page` at the host root changes the Dashboard and every mounted module
|
||||
* artifact together, because custom properties inherit through the artifacts' isolated roots.
|
||||
* The host's full-page messages (loading, failure, sign-out), in the platform's semantic tokens
|
||||
* (ADR 0039). The signed-in Dashboard's own frame is `ShellLayout` (ADR 0058).
|
||||
*/
|
||||
const shellStyles = {
|
||||
actions: { display: "flex", gap: designToken("spacing.sm"), justifyContent: "center" },
|
||||
aside: {
|
||||
background: designToken("background.canvas"),
|
||||
borderRight: `${designToken("border.width")} solid ${designToken("border.divider")}`,
|
||||
boxSizing: "border-box",
|
||||
minWidth: "15rem",
|
||||
overflowY: "auto",
|
||||
padding: designToken("spacing.xl"),
|
||||
width: "15rem",
|
||||
},
|
||||
brand: { alignItems: "center", display: "flex", gap: designToken("spacing.sm") },
|
||||
brandMark: {
|
||||
background: designToken("accent.base"),
|
||||
borderRadius: designToken("radius.md"),
|
||||
boxShadow: `inset 0 0 0 ${designToken("border.width")} ${designToken("accent.active")}`,
|
||||
display: "block",
|
||||
height: designToken("spacing.xl"),
|
||||
width: designToken("spacing.xl"),
|
||||
},
|
||||
groupLabel: {
|
||||
color: designToken("text.muted"),
|
||||
fontSize: designToken("typography.size-xs"),
|
||||
letterSpacing: "0.05em",
|
||||
margin: `0 0 ${designToken("spacing.xs")}`,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
header: {
|
||||
alignItems: "center",
|
||||
background: designToken("background.surface"),
|
||||
borderBottom: `${designToken("border.width")} solid ${designToken("border.divider")}`,
|
||||
display: "flex",
|
||||
fontWeight: designToken("typography.weight-bold"),
|
||||
gridColumn: "1 / -1",
|
||||
justifyContent: "space-between",
|
||||
padding: `${designToken("spacing.md")} ${designToken("spacing.xl")}`,
|
||||
},
|
||||
heading: {
|
||||
fontSize: designToken("typography.size-lg"),
|
||||
fontWeight: designToken("typography.weight-bold"),
|
||||
margin: 0,
|
||||
},
|
||||
layout: {
|
||||
background: designToken("background.page"),
|
||||
color: designToken("text.primary"),
|
||||
display: "grid",
|
||||
fontFamily: designToken("typography.family-sans"),
|
||||
gridTemplateColumns: "15rem minmax(0, 1fr)",
|
||||
gridTemplateRows: "auto minmax(0, 1fr)",
|
||||
minHeight: "100vh",
|
||||
minWidth: 0,
|
||||
width: "100%",
|
||||
},
|
||||
main: {
|
||||
boxSizing: "border-box",
|
||||
minWidth: 0,
|
||||
// A programmatic focus target after a page change (never in the tab order), not a control.
|
||||
outline: "none",
|
||||
overflowX: "auto",
|
||||
padding: designToken("spacing.xl"),
|
||||
},
|
||||
message: {
|
||||
color: designToken("text.secondary"),
|
||||
display: "grid",
|
||||
@@ -217,24 +167,6 @@ function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry
|
||||
];
|
||||
}
|
||||
|
||||
function ModuleLink(props: {
|
||||
readonly entry: DashboardNavigationEntry;
|
||||
readonly history: HostHistory;
|
||||
readonly route: DashboardRoute;
|
||||
}): ReactElement {
|
||||
const moduleId = props.entry.descriptor.id;
|
||||
return (
|
||||
<DashboardLink
|
||||
current={props.route.kind === "module" && props.route.moduleId === moduleId}
|
||||
data-testid={`select-${moduleId}`}
|
||||
history={props.history}
|
||||
to={{ kind: "module", moduleId }}
|
||||
>
|
||||
{props.entry.name}
|
||||
</DashboardLink>
|
||||
);
|
||||
}
|
||||
|
||||
/** A stable identity for "which page is showing", so a non-canonical spelling of the same page is
|
||||
* not a page change. */
|
||||
function routeKey(route: DashboardRoute): string {
|
||||
@@ -310,6 +242,11 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const shownRouteRef = useRef<string | undefined>(undefined);
|
||||
const { config, history, importModule, setDocumentTitle } = props;
|
||||
// Fallbacks for a composition that supplies neither; created once, so they stay stable.
|
||||
const [fallbackViewport] = useState(() => createBrowserHostViewport(undefined));
|
||||
const [fallbackPreference] = useState(() => createMemorySidebarPreference());
|
||||
const viewport = props.viewport ?? fallbackViewport;
|
||||
const sidebarPreference = props.sidebarPreference ?? fallbackPreference;
|
||||
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
|
||||
const readPathname = useCallback(() => history.pathname(), [history]);
|
||||
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
|
||||
@@ -619,88 +556,37 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
}
|
||||
|
||||
const { bootstrap } = state;
|
||||
const entries = allEntries(bootstrap);
|
||||
const { groups, ungrouped } = bootstrap.navigation;
|
||||
|
||||
return (
|
||||
<div data-testid="host-shell" style={shellStyles.layout}>
|
||||
<header style={shellStyles.header}>
|
||||
<div style={shellStyles.brand}>
|
||||
<span aria-hidden="true" style={shellStyles.brandMark} />
|
||||
<h1 style={shellStyles.heading}>Ekklesia</h1>
|
||||
</div>
|
||||
<Button data-testid="host-logout" onClick={() => void signOut()} size="sm">
|
||||
Sign out
|
||||
</Button>
|
||||
</header>
|
||||
<aside style={shellStyles.aside}>
|
||||
<DevicePanel config={config} />
|
||||
<nav aria-label="Dashboard" style={{ display: "grid", gap: designToken("spacing.xl") }}>
|
||||
<DashboardLink
|
||||
current={route.kind === "overview"}
|
||||
data-testid="select-overview"
|
||||
history={history}
|
||||
to={{ kind: "overview" }}
|
||||
>
|
||||
Overview
|
||||
</DashboardLink>
|
||||
{entries.length === 0 && groups.length === 0 ? (
|
||||
<p data-testid="host-empty" style={{ color: designToken("text.secondary"), margin: 0 }}>
|
||||
No installed module provides a user interface.
|
||||
</p>
|
||||
) : undefined}
|
||||
{groups.map((group) => (
|
||||
<section data-testid={`group-${group.id}`} key={group.id}>
|
||||
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
|
||||
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||
{group.modules.map((entry) => (
|
||||
<ModuleLink
|
||||
entry={entry}
|
||||
history={history}
|
||||
key={moduleKey(entry)}
|
||||
route={route}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{ungrouped.length > 0 ? (
|
||||
<section data-testid="group-ungrouped">
|
||||
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
|
||||
<div style={{ display: "grid", gap: "0.125rem" }}>
|
||||
{ungrouped.map((entry) => (
|
||||
<ModuleLink
|
||||
entry={entry}
|
||||
history={history}
|
||||
key={moduleKey(entry)}
|
||||
route={route}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : undefined}
|
||||
</nav>
|
||||
</aside>
|
||||
<main aria-label={pageTitle} ref={mainRef} style={shellStyles.main} tabIndex={-1}>
|
||||
{route.kind === "overview" ? (
|
||||
<OverviewPage />
|
||||
) : route.kind === "module" ? (
|
||||
openEntry ? (
|
||||
<ModuleUiFrame
|
||||
config={config}
|
||||
descriptor={openEntry.descriptor}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
key={moduleKey(openEntry)}
|
||||
onAuthorizationResponse={onAuthorizationResponse}
|
||||
principal={bootstrap.principal}
|
||||
/>
|
||||
) : (
|
||||
<ModuleUnavailablePage history={history} />
|
||||
)
|
||||
<ShellLayout
|
||||
config={config}
|
||||
history={history}
|
||||
mainRef={mainRef}
|
||||
navigation={bootstrap.navigation}
|
||||
onSignOut={() => void signOut()}
|
||||
pageTitle={pageTitle}
|
||||
route={route}
|
||||
sidebarPreference={sidebarPreference}
|
||||
viewport={viewport}
|
||||
>
|
||||
{route.kind === "overview" ? (
|
||||
<OverviewPage />
|
||||
) : route.kind === "module" ? (
|
||||
openEntry ? (
|
||||
<ModuleUiFrame
|
||||
config={config}
|
||||
descriptor={openEntry.descriptor}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
key={moduleKey(openEntry)}
|
||||
onAuthorizationResponse={onAuthorizationResponse}
|
||||
principal={bootstrap.principal}
|
||||
/>
|
||||
) : (
|
||||
<NotFoundPage history={history} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<ModuleUnavailablePage history={history} />
|
||||
)
|
||||
) : (
|
||||
<NotFoundPage history={history} />
|
||||
)}
|
||||
</ShellLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user