Skip to content

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.

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 disabled
type Stat = { label: string; value: number | string; of?: number; progress?: number; // 0–1
tone?: "neutral" | "attention" | "danger" | "success"; href?: string }; // tone colours the number only
type CalloutProps = { size: "banner" | "panel" | "card" | "inline"; tone: "warn" | "danger" | "success";
lead: string; body?: string; action?: Action }; // ≤1 per screen
type 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 card
type 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.