Phase 2 Design Spec — App Shell + Surrounds
Status: Required pre-S1 specs (per /autoplan design review 2026-04-29).
Source UX: /tmp/ordermatic-ux-handoff/ordermatic-ux/project/{app.html,feature-map.html,pages/*.jsx}.
Plan: ~/.claude/plans/ordermatic-phase2-app-shell-and-surrounds.md.
This is the equivalent of Phase 1’s PR #731 (state matrix + a11y + responsive specs). It must land before S1 (AppShell + RailNav) starts. ~3 hours of design work; prevents ~3 weeks of QA-driven rework.
Appendix 1 — Rail order, footer convention, badge formula
Section titled “Appendix 1 — Rail order, footer convention, badge formula”Rail order (top to bottom)
Section titled “Rail order (top to bottom)”- Inbox (
/inbox) — always visible - Action items (
/action-items) — always visible (pre-S7: route stub returning empty state) - Analytics (
/analytics) — visible-but-locked when noanalytics.team/analytics.orgcap; pre-S17/S18a/S19: route stub - Customers (
/customers) — always visible - Audit log (
/audit) — visible whenaudit.view/audit.full; pre-S20: route stub
1px divider
- NotificationBell — bell icon, badge top-right, opens Notifications drawer (S3)
(footer, pinned to bottom)
- Help — opens Help drawer (S4)
- Settings (
/settings) — opens Settings page (S21a) - Avatar / role switcher — Clerk user button + role badge (clerk/sup/admin)
Locked-vs-hidden policy
Section titled “Locked-vs-hidden policy”- Rail destinations: HIDDEN when cap is missing (matches
app-shell.jsx:RAIL_DESTINATIONSfilter!d.cap || can(role, d.cap)). Rationale: rail real estate is precious; locked rail icons add noise without informing. - Settings nav items: VISIBLE-BUT-LOCKED with a lock glyph (matches existing
LockedSectionpattern inpages/settings.jsx). Rationale: settings is exploratory; users need to know what’s possible. - Inline controls (e.g., bulk-resolve button for clerks): VISIBLE-BUT-LOCKED via
<LockedControl>(Appendix 4).
Notification badge formula
Section titled “Notification badge formula”Decision: mentions_unread + approvals_pending + sla_breaches — sourced from /api/notifications (notification-state, role-aware).
NOT needs_fix + failed work-item-state counts. Rationale: the rail badge represents “things that want my attention,” not “things in my queue.” The cockpit’s existing list-grouped urgency counts (needs_fix / failed / processing) remain in the cockpit body, separate from the rail bell badge.
Active indicator
Section titled “Active indicator”Active rail destination: 3px orange left border (#FD5C01), full-height, on the rail item. Matches app-shell.jsx.
Pulse state
Section titled “Pulse state”Bell pulses (CSS animation) when notifPulse=true (any new unread item arrived since last drawer open). One-shot animation, ~600ms, then static until next new item.
Badge formatting
Section titled “Badge formatting”- 0 → hide badge entirely
- 1-99 → show number
- ≥100 → show
99+
Appendix 2 — State matrix per Part A surface
Section titled “Appendix 2 — State matrix per Part A surface”Each Part A stack’s PR must include a state-rendering test (or note “n/a”) for every cell below.
Legend: ✓ = renders, n/a = not applicable, TBD = needs spec before stack starts.
| Stack | Loading | Empty | Error | Partial | Success | Locked | Overflow |
|---|---|---|---|---|---|---|---|
| #722 Cockpit (already shipped) | skeleton rows | “Inbox zero” copy | toast on poll fail | partial filtered | row appears | n/a (clerk-only) | scroll |
| S0b caps | n/a | n/a | n/a | n/a | n/a | n/a | n/a |
| S1 RailNav | shimmer 200ms | n/a (always populated) | rail loads even if some routes 404 | n/a | active border | hide non-cap dests | n/a |
| S2 ⌘K palette | spinner in input | “No matches” empty state | “Search failed, try again” | partial results streamed | result list | locked dests filtered out | scroll list, max 10 visible |
| S3 NotificationsDrawer | skeleton list 5 rows | “All caught up” + check icon | “Couldn’t load notifications” + retry button | per-tab partial (Mentions OK, Approvals timeout) | grouped-by-day | n/a | scroll |
| S4 HelpDrawer | n/a (static) | n/a | “Status unavailable” if /api/status fails |
n/a | drawer slides in | n/a | scroll on long shortcut list |
| S5 Toast | n/a | n/a | n/a | n/a | toast slides + auto-dismisses 5s | n/a | stack max 3, FIFO drop |
| S6 Upload modal | per-file progress bar | “Drag PDF here” empty dropzone | “Upload failed (3 files)” inline + retry | “5 of 7 uploaded” with per-row state | “Uploaded” + queue summary | n/a | per-file rows, scroll |
| S7 Action Items | skeleton list | “No items need action” empty | per-section error inline | filter-empty per section | grouped by uxState | n/a | scroll |
| S8 BulkResolveModal | submit button spinner | n/a (entered with selection) | “Failed: 3 of 12” with itemized retry | partial-success with itemized result | “12 resolved” + close | clerk: “Ask supervisor” lock | scroll itemized result list |
| S20 Audit page | skeleton table 10 rows | “No audit events match” | “Filter query failed” | filter narrowing live | sortable rows | non-audit.full: filter scope tightens |
infinite scroll, page=50 |
| S21a Settings shell + Personal | skeleton sections | n/a (static) | per-form error inline | per-section saved/saving state | “Saved” inline | locked sections show admin avatar | scroll |
| S21b-* (per section, 7 sub-stacks) | per-form skeleton | n/a | per-form inline | per-form saved indicator | per-form saved | LockedSection composite | scroll |
| S22 Conservative + Kanban + DetailDrawer | skeleton kanban columns / table rows | “No items in this view” | “Load failed” per column | DnD reassigning state | DnD lands → row moves | n/a (sup+ only) | column scroll, virtualize at 100+ |
| S23 Inbox-zero | n/a | self IS the empty state | n/a | n/a | celebrate animation 600ms | n/a | n/a |
State-render test pattern
Section titled “State-render test pattern”Each stack PR includes Vitest unit tests that render each state and assert visible content:
describe('S3 NotificationsDrawer states', () => { it.each([ ['loading', { isLoading: true }, /skeleton/i], ['empty', { items: [] }, /all caught up/i], ['error', { error: 'timeout' }, /couldn't load/i], ['success', { items: [...] }, /yesterday/i], ])('renders %s state correctly', (label, props, expectedText) => { render(<NotificationsDrawer {...props} />); expect(screen.getByText(expectedText)).toBeInTheDocument(); });});Appendix 3 — Keyboard map
Section titled “Appendix 3 — Keyboard map”Global shortcuts (canonical — adopted from pages/help.jsx:HELP_SHORTCUTS lines 61-92)
Section titled “Global shortcuts (canonical — adopted from pages/help.jsx:HELP_SHORTCUTS lines 61-92)”Anywhere group:
⌘K/Ctrl+K— open Command Palette (S2)?(Shift+/) — open Help drawer (S4)⌘/— toggle Help drawerEsc— close any open drawer/modal/palette; if no overlay open, deselect / clear focusG I— go to InboxG A— go to Action itemsG C— go to CustomersG S— go to Settings
Inbox group (cockpit + Action items):
J— focus next rowK— focus previous rowEnter— open focused row in detail / ReviewS— snooze focused rowE— archive focused row (single or selected batch)X— toggle select on focused row
Review group (post-Phase 1, included for completeness):
Tab— move between sections (left rail / center / right doc viewer)⌘Enter— submit⌘→/⌘←— next / prev item in queue⌘K— open ERP search modal
Team group (Sup+ only — S22):
A— toggle assign-to-me⌘B— toggle bulk-select modeV— toggle Conservative ↔ Kanban view
Suppression rules
Section titled “Suppression rules”When document.activeElement is INPUT, TEXTAREA, SELECT, or [contenteditable=true]:
- Single-letter shortcuts (J, K, S, E, X, A, V) are suppressed.
- Modifier-key shortcuts (
⌘K,⌘/,⌘Enter,⌘→,⌘B) fire normally. EscandTabfire normally (allow drawer dismiss / focus traversal).- Compound
G-prefix shortcuts: G itself is suppressed, butG+letterdoesn’t fire when input is focused.
This pattern is already in place at apps/webapp/src/components/inbox/InboxPage.tsx:210 (if (target.closest('input, textarea, select')) return) — generalize as a useGlobalKeyboardShortcuts hook in S1.
Focus traps per drawer/modal
Section titled “Focus traps per drawer/modal”Pattern (apply to S3, S4, S6, S8, S12 drawers/modals):
- On open: initial focus moves to first interactive element (close button OR primary action).
Tabcycles within the drawer;Shift+Tabreverses.Escdismisses; focus restores to the element that opened the drawer (document.activeElementsnapshot at open-time).- Click outside drawer (on backdrop): drawer dismisses, focus restores to invoker.
Implementation: lean on radix-ui/react-dialog or equivalent — don’t roll our own focus trap.
S1 RailNav internal keyboard
Section titled “S1 RailNav internal keyboard”↑/↓— move focus across rail destinationsEnter/Space— activate focused destination- Rail destinations are part of the global Tab cycle (positioned after
<main>content for screen-reader logical flow).
Appendix 4 — Lock primitive taxonomy
Section titled “Appendix 4 — Lock primitive taxonomy”Three primitives, distinct contexts, distinct rendering rules.
<LockedRailDest> — N/A (we hide rail destinations per Appendix 1)
Section titled “<LockedRailDest> — N/A (we hide rail destinations per Appendix 1)”We chose HIDE-when-cap-missing for the rail (matches app-shell.jsx). This primitive is not built in Part A.
<LockedSection> — generalize existing pages/settings.jsx component
Section titled “<LockedSection> — generalize existing pages/settings.jsx component”interface LockedSectionProps { title: string; // section name, e.g. "ERP connection" reason?: string; // optional, e.g. "Workspace settings" cap: InboxCapability; // the cap that would unlock this adminUser?: User | null; // for "Ask {name}" composer children?: React.ReactNode; // optional preview content (greyed)}Rendering:
- Title visible (greyed, ~60% opacity)
- Lock glyph (lucide
lockicon) inline with title - “Ask your admin” composer below title — shows admin avatar (from org membership lookup) + a small “Request access” button that creates an audit-logged access request (Part B work — for now, button mailto’s the admin)
- If
childrenprovided, render greyed/disabled
Used in S21a/S21b for tier-locked settings sections.
<LockedControl> — new primitive
Section titled “<LockedControl> — new primitive”interface LockedControlProps { cap: InboxCapability; tooltip?: string; // override default children: React.ReactNode; // the control (button, etc.)}Rendering:
- Wraps a button or interactive control
- Greys it (opacity 0.5,
cursor: not-allowed) - Tooltip on hover:
tooltip || "Requires {cap} — ask your admin" - Click is intercepted; shows tooltip on click instead of executing
- Accessible:
aria-disabled="true",aria-describedbypoints to tooltip
Used in S8 (clerk view of bulk-resolve), S22 (clerk view of reassign), S20 (non-audit.full view of full-team filters).
Server-side enforcement reminder
Section titled “Server-side enforcement reminder”These UI primitives are defensive, not authoritative. Every endpoint behind a cap MUST also enforce server-side per Lock L2 (server-side cap enforcement contract). The UI primitives prevent confused users; the server enforcement prevents IDOR.
Implementation order
Section titled “Implementation order”- Land this doc as
docs/phase2-design-spec.md(this PR). - Land Option D (P0 hotfix) — independent of design spec.
- Land S0b (caps) — independent of design spec.
- Land S1 (AppShell + RailNav) — REFERENCES this doc; rail order, lock rendering, keyboard handlers.
- Subsequent Part A stacks (S2/S3/S4/…) — each PR’s tests assert their state-matrix row.
Open spec questions (for follow-up if needed)
Section titled “Open spec questions (for follow-up if needed)”- Q1: ⌘K palette result truncation. Max 10 visible per Appendix 2; how does it group by destination type (routes vs actions vs items)?
- Q2: Toast stack overflow behavior. FIFO drop oldest, OR stack visually but limit announcements to 3?
- Q3: Notifications drawer “Mentions” tab pre-S14. Hide tab entirely, OR show with permanent “Coming soon” banner?
- Q4: S22 DnD performance ceiling. Virtualize Kanban at 100+ cards or accept jank up to 2k?
These don’t block S1 but should be answered as their respective stacks open PRs.