Skip to content

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.


Section titled “Appendix 1 — Rail order, footer convention, badge formula”
  1. Inbox (/inbox) — always visible
  2. Action items (/action-items) — always visible (pre-S7: route stub returning empty state)
  3. Analytics (/analytics) — visible-but-locked when no analytics.team/analytics.org cap; pre-S17/S18a/S19: route stub
  4. Customers (/customers) — always visible
  5. Audit log (/audit) — visible when audit.view/audit.full; pre-S20: route stub

1px divider

  1. NotificationBell — bell icon, badge top-right, opens Notifications drawer (S3)

(footer, pinned to bottom)

  1. Help — opens Help drawer (S4)
  2. Settings (/settings) — opens Settings page (S21a)
  3. Avatar / role switcher — Clerk user button + role badge (clerk/sup/admin)
  • Rail destinations: HIDDEN when cap is missing (matches app-shell.jsx:RAIL_DESTINATIONS filter !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 LockedSection pattern in pages/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).

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 rail destination: 3px orange left border (#FD5C01), full-height, on the rail item. Matches app-shell.jsx.

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.

  • 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

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();
});
});

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 drawer
  • Esc — close any open drawer/modal/palette; if no overlay open, deselect / clear focus
  • G I — go to Inbox
  • G A — go to Action items
  • G C — go to Customers
  • G S — go to Settings

Inbox group (cockpit + Action items):

  • J — focus next row
  • K — focus previous row
  • Enter — open focused row in detail / Review
  • S — snooze focused row
  • E — 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 mode
  • V — toggle Conservative ↔ Kanban view

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.
  • Esc and Tab fire normally (allow drawer dismiss / focus traversal).
  • Compound G-prefix shortcuts: G itself is suppressed, but G+letter doesn’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.

Pattern (apply to S3, S4, S6, S8, S12 drawers/modals):

  1. On open: initial focus moves to first interactive element (close button OR primary action).
  2. Tab cycles within the drawer; Shift+Tab reverses.
  3. Esc dismisses; focus restores to the element that opened the drawer (document.activeElement snapshot at open-time).
  4. 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.

  • ↑ / ↓ — move focus across rail destinations
  • Enter / Space — activate focused destination
  • Rail destinations are part of the global Tab cycle (positioned after <main> content for screen-reader logical flow).

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”
apps/webapp/src/components/ui/LockedSection.tsx
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 lock icon) 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 children provided, render greyed/disabled

Used in S21a/S21b for tier-locked settings sections.

apps/webapp/src/components/ui/LockedControl.tsx
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-describedby points to tooltip

Used in S8 (clerk view of bulk-resolve), S22 (clerk view of reassign), S20 (non-audit.full view of full-team filters).

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.


  1. Land this doc as docs/phase2-design-spec.md (this PR).
  2. Land Option D (P0 hotfix) — independent of design spec.
  3. Land S0b (caps) — independent of design spec.
  4. Land S1 (AppShell + RailNav) — REFERENCES this doc; rail order, lock rendering, keyboard handlers.
  5. 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.