1A. Reusable layouts (closed set — every screen composes exactly one)
Owner rule: no hand-built pages. Every routed screen in the app — staff, member, public, auth,
kiosk, including the long tail covered in research/ux-screens-longtail.md — is one shell +
one page layout with its slots filled. A screen that does not fit a layout is a signal to fix
the screen, not to add a twelfth layout; adding one needs an owner decision. Layouts own geometry,
responsive behaviour, loading/error/empty switching, and focus/landmarks; screens own data and
copy. Layouts contain zero gating logic — callers pass already-filtered tabs/nav/actions.
1A.0 Shared slot types
Section titled “1A.0 Shared slot types”type Action = { label: string; href?: string; onClick?: () => void; icon?: IconName; pending?: boolean; pendingLabel?: string; disabled?: boolean; disabledReason?: string; // required when disabled tone?: "default" | "danger"; external?: boolean; confirms?: boolean }; // confirms → label gets "…"type SecondaryAction = Action | { label: string; menu: Action[] };type Tab = { key: string; label: string; href: string; count?: number }; // gated tabs are omitted, never disabledtype Stat = { label: string; value: number | string; of?: number; progress?: number; // 0–1 tone?: "neutral" | "attention" | "danger" | "success"; href?: string }; // tone colours the number onlytype CalloutProps = { size: "banner" | "panel" | "card" | "inline"; tone: "warn" | "danger" | "success"; lead: string; body?: string; action?: Action }; // ≤1 per screentype Identity = { kind: "date"; date: string } | { kind: "image"; src: string } | { kind: "icon"; icon: IconName; tone: Tone } | { kind: "avatar"; name: string; seed: string };type QueryState = { status: "loading" | "error" | "ready"; error?: string; onRetry?: () => void };type EmptyProps = { message: string; action?: Action }; // P17 dashed cardtype PageHeaderProps = { eyebrow?: string; title: string; subtitle?: string; meta?: ReactNode; primary?: Action; secondary?: SecondaryAction; overflow?: Action[] };Breakpoints used below: 375 (phone), 768 (tablet portrait), 1280 (desktop). Gutters
16 / 24 / 36. Content max-width 1120 (Inventory/Reports/Audit may opt into wide = full).
L1. AppShell — sidebar + content (R1–R10, R16–R17)
Section titled “L1. AppShell — sidebar + content (R1–R10, R16–R17)”AppShell({ variant: "staff" | "member"; workspace: { name: string; subtitle: string /* role or membership status */; avatar: Identity; menu?: Action[]; locked?: boolean /* single-tenant: static card, no chevron */ }; search?: { label: "Search and commands"; shortcut: "mod+k"; onOpen: () => void }; nav: Array<{ label?: string /* section header; omit for flat */; caption?: string; items: Array<{ key: string; label: string; href: string; icon: IconName; badge?: number; badgeLabel?: string /* accessible-name suffix */; children?: Array<{ key; label; href }> }>; more?: Array<…same item shape…> }>; activeKey: string; pinned?: Array<SidebarSection>; // bottom block: Settings family, Platform footerLinks?: Action[]; // "View public page ↗" user: { name: string; role?: string; menu: Action[]; onSignOut: () => void }; compactNav: "drawer" | "bottom-tabs"; // bottom-tabs only when ≤5 top-level items status?: ReactNode; // JobTray row children: ReactNode; // exactly one page layout (L2–L6)})| 375 | 768 | 1280 |
|---|---|---|
56 px top bar (menu, space name, search icon, avatar) or 64 px bottom tabs when compactNav="bottom-tabs"; drawer full-height 85 vw |
same top bar; drawer 320 | fixed 264 sidebar, own scroll region between switcher and pinned block |
Used by: every staff tab (§2), the member area (S16). Not used by public, auth, kiosk.
L2. DashboardLayout — greeting header + stat row + main + right rail (R1, R2)
Section titled “L2. DashboardLayout — greeting header + stat row + main + right rail (R1, R2)”DashboardLayout({ header: PageHeaderProps & { eyebrow: string /* date or greeting */ }; callout?: CalloutProps; progress?: ReactNode; // setup checklist card stats: Stat[]; // renders ≤4; rest go to overflow overflowStats?: { label: "Everything else"; stats: Stat[] }; main: Array<{ title: string; action?: Action; content: ReactNode /* ListRow[] */; empty: EmptyProps }>; rail?: { title: string; items: ActivityItem[]; seeAll?: Action } ; // omit → main goes full width state: QueryState;})| 375 | 768 | 1280 |
|---|---|---|
| stats 2×2; rail below main, 3 items | stats 2×2 (4-up ≥900); rail below main | stats 4-up; main 1fr + rail 260, gap 28 |
Used by: staff Home (S1), member Home (S16), organizations home (governance-only), Platform dashboard.
L3. ListLayout — header + callout + stats/tabs + filters + list/table + pagination (R2, R4)
Section titled “L3. ListLayout — header + callout + stats/tabs + filters + list/table + pagination (R2, R4)”ListLayout({ header: PageHeaderProps; callout?: CalloutProps; stats?: Stat[]; // ≤4, only when they summarise or act as filters tabs?: Tab[]; activeTab?: string; // filter tabs with counts (P8) filters?: { search?: { label: string; value: string; onChange }; controls?: ReactNode /* sort, saved views, column picker */; chips?: FilterChip[]; onClear?: () => void }; body: { kind: "rows"; rows: ReactNode /* ListRow[] */ } | { kind: "table"; table: ReactNode /* DataTable */ }; bulkBar?: { count: number; actions: Action[]; onClear: () => void }; empty: EmptyProps; filteredEmpty?: EmptyProps; pagination?: { page: number; pageSize: number; count: number; onChange }; rail?: ReactNode; // optional activity rail (Events) detail?: ReactNode; // a DetailLayout presentation="drawer" state: QueryState;})| 375 | 768 | 1280 |
|---|---|---|
| tabs scroll; filters collapse to search + “Filters” sheet; tables → stacked cards; bulk bar fixed bottom | tables keep ≤5 priority columns, rest via column picker | full table/rows; optional rail 260 |
Used by: Requests, Direct handout, Inventory, Members, Staff & roles, Events, Bookings, Machines index (rows→cards), Audit log, Inbox, Payments, Ledger, Transfers, Stocktake, To buy, Repair shelf, Containers, Categories, Warranties, Devices, Member cards, Accountability, Organizations, Organized events, Delivery log, Email templates, public events list (inside L9).
L4. DetailLayout — back + identity + title/status/meta + actions + underline tabs + main/aside (R7–R10)
Section titled “L4. DetailLayout — back + identity + title/status/meta + actions + underline tabs + main/aside (R7–R10)”DetailLayout({ presentation: "page" | "drawer"; // drawer: 640 (default) | 720 (wide); full-screen <768 back: { label: string; href: string }; // "← All events" identity: Identity; title: string; status?: StatusPill; meta?: Array<string | ReactNode>; actions?: { primary?: Action & { targetsTab?: string } /* auto-hidden when targetsTab === activeTab */; secondary?: SecondaryAction; overflow?: Action[] }; banner?: CalloutProps; // e.g. read-only/terminal state tabs?: Tab[]; activeTab?: string; // URL-driven (?tab= or subpath) main: ReactNode; aside?: ReactNode; // aside 280: "At a glance", CTA card footer?: { primary?: Action; secondary?: Action[] }; // sticky; drawers only danger?: DangerZoneProps[]; // rendered last in main state: QueryState;})| 375 | 768 | 1280 |
|---|---|---|
| identity 40, title 24; actions → full-width primary + ⋯; tabs scroll with fades; aside below main; drawer full-screen | aside below main; drawer full-screen | main 1fr + aside 280 gap 24; tab hairline full-bleed |
Used by: Request detail (drawer), Inventory item (drawer), Machine type page, Machine detail (drawer, wide), Event detail, Series detail, Bookable space, Member detail (drawer), Role detail, Report viewer (main = report, aside = report list on the left variant asideSide="start").
Part of Space Works UX spec, published from docs/ux-spec.md. Edit it there.