# Zync OS — Plan A: Foundations + Desktop Shell (design)

Slug: `zync-os-desktop` · Date: 2026-07-10 · Audience: AI coding agents (run-plan codex seats, reviewers, vision judges). Source vision: `Zync OS Design.pdf` (user-provided, 45pp). Plan series: **A = this spec** (foundations + desktop OS shell + 2 pilot modules, feature-flagged) · B = mobile OS shell + module migration sweep · C = advanced OS features + portal/staff/admin shells. Zync AI depth epic (RAG wiring, permission-filtered retrieval, tool/actions) = separate spec, designed after this one, executed after the redesign lands.

## Goal

Replace the sidebar-SaaS presentation of `apps/zync-app` with an operating-system shell: real desktop, movable windows, taskbar, start menu, system tray, notification center, command center, Today app — powered by ONE extended module registry. OS mode is the DEFAULT at login; the classic shell survives untouched as a session fallback via a "Login to Legacy Site" checkbox (and forced on mobile) — see §1 resolution precedence. Two pilot modules (Tasks, Customers) prove the window frame; everything else keeps working in classic. Premium feel is enforced by `.claude/skills/zc-ui-ux-designer` (SKILL.md + references/desktop.md) — that skill is REQUIRED READING for every UI task in the plan, and the vision-judge grades against it.

## Non-goals (Plan A)

- Mobile OS shell (home screen/drawer/shade/recents) — Plan B. The classic responsive mobile experience keeps working under the flag's classic value; the OS shell on <768px viewports renders the classic mobile layout (shell mode forced classic on mobile in Plan A).
- Migrating the other ~21 module areas into windows — Plan B (they stay classic-routed; opening them from OS surfaces deep-links into classic routes in a maximized window frame degradation is NOT attempted — see Module compatibility below).
- Virtual desktops, focus mode, lock screen, saved workspaces sharing, taskbar repositioning, auto-hide — Plan C.
- AI context-action system — separate spec. Plan A ships AI as an OS citizen only (taskbar presence, command-center entry, module/record context passed to existing ChatPanel).
- White-label/portal/staff/admin shells — Plan C.

## Current-state anchors (verified 2026-07-10)

- Shell: `apps/zync-app/src/shell/Shell.tsx` (+ `Header.tsx`, `Sidebar.tsx`, `nav-model.ts`) — classic sidebar shell, stays as-is.
- Routes: central lazy registry `apps/zync-app/src/routes/index.tsx`; React Router 7 `BrowserRouter` in `src/main.tsx`.
- Registry: `packages/modules/src/manifest.ts` + `apps/zync-app/src/components/ModuleGuard.tsx` — module ids, plan/permission gating. EXTEND, never fork.
- State: Zustand (`src/stores/*`), TanStack Query (`main.tsx`).
- Tokens: `packages/ui/src/tokens/index.css` (OKLCH, spec 114 dual theme; shadows/z exist, NO motion tokens yet).
- Palettes (two, to consolidate): `src/components/shortcuts/CommandPalette.tsx` + `src/features/search/*` (`useSearchPalette`, `useFullSearch`, `CommandModal`), light `src/shell/use-search.ts`.
- Notifications: `src/shell/NotificationDropdown.tsx`, `use-notifications.ts`, realtime seam `src/lib/realtime/useRealtime.ts`.
- AI: `src/features/ai-chat/ChatLauncher.tsx`/`ChatPanel.tsx` (floating), SSE via `useChatStream.ts`, backend `apps/zync-api/src/routes/ai-assistant/*`.
- Timer store: `src/stores/timer.ts` (taskbar chip consumes it).
- PWA: vite-plugin-pwa injectManifest, `src/sw.ts` — untouched in Plan A.

## Architecture

### 1. Shell mode flag (rollout seam)

- **OS mode is the DEFAULT** (user decision 2026-07-10): logging in lands in the desktop shell. The login form gains a "Login to Legacy Site" checkbox (unchecked by default, `text-sm`, below the primary CTA) — checked → that session runs the classic shell (session-scoped override, sessionStorage; NOT a persisted preference). Classic is a temporary fallback and may be removed at launch — build nothing new that deepens classic dependency.
- User preference `ui_shell: 'classic' | 'os'` still exists (same persistence path as `ui_theme` — user preferences PATCH) as the durable escape for users who always want legacy; default `'os'`. Resolution precedence: session override (login checkbox / `?shell=` param) > tenant `force_shell` (admin-settable, nullable) > user pref > default `os`.
- `src/main.tsx` route split: mount one authenticated gate above shell-mode resolution, then render `<Shell/>` (classic) or `<OsShell/>`. Keep protected children unmounted during session bootstrap. Redirect a missing or terminally expired session to `/login?redirect=<encoded path+query+hash>`. Both shells share the SAME lazy route elements — classic mounts them under `<Outlet/>`, OS mounts them inside windows (see 4). Use an authenticated parent route ending in `/*` so descendant module routes remain matched. No route element is duplicated.
- Recover an expired access cookie through one deduplicated `POST /api/auth/refresh`, then retry the failed session bootstrap once. Treat a failed refresh or a second 401 as terminal: clear client query/session state and redirect. Never apply generic transient retry to a 401.
- Mode resolution also forces `classic` when viewport <768px or pointer coarse-only (Plan A desktop-only rule).
- Kill switch: `?shell=classic` query param overrides for one session (support escape hatch).
- **Chrome boundary (`AuthenticatedAppChrome`)**: today's `main.tsx` mounts globals OUTSIDE `<Shell/>` — `CommandPalette`, `ShortcutHelpOverlay`, `ChatLauncher`, `SubscriptionBanners`, shortcut provider. These MUST move inside a mode-aware `AuthenticatedAppChrome` component that renders the classic set OR the OS set — never both. Per mode: command palette (classic keeps current until command-center consolidation lands, then shared), ChatLauncher (classic only — OS uses the AI window), shortcut registrations (OS registers its keyboard map, classic keeps existing; no double-binding — one provider, mode-keyed map), toast position adapter, SubscriptionBanners (both modes; OS renders them as a system notification + tray badge, not a top bar). Classic smoke test asserts the classic set unchanged.

### 2. Module registry extension (`packages/modules`)

Extend the existing manifest entry type with an OS presentation contract (all optional — a module without `os` metadata simply doesn't appear on OS surfaces yet):

Split in two layers — **`packages/modules` stays PURE DATA (JSON-serializable, React-free); all functions/components live in the app-side companion registry** `apps/zync-app/src/os/registry-os.ts`, keyed by module id. A cheap agent putting a function or component into `packages/modules` is a broken contract.

```ts
// packages/modules — pure data, JSON-serializable. NO functions, NO components, NO QueryKey values.
type ZyncModuleOs = {
  desktop?: {
    defaultSize: { width: number; height: number };
    minSize?: { width: number; height: number };
    supportsMultipleInstances?: boolean;   // default false
  };
  routing: {
    routePrefixes: string[];      // e.g. time_management → ['/time-track']; ModuleId does NOT equal route stem — this field is the ONLY route-ownership source
    defaultRoute: string;         // window opens here when launched without a deep link
  };
  quickActions?: { id: string; labelKey: string; icon: string /* lucide name */; route: string /* deep-link route ONLY — commands resolved app-side by action id */ }[];
  searchProvider?: { kinds: string[] };     // record kinds this module answers in command center (existing search API)
  badge?: { badgeId: string };              // resolved app-side to a query subscription
  widgets?: { id: string; size: 'S'|'M'|'L'; titleKey: string }[];   // component resolved app-side by widget id
  todayContribution?: { id: string; order: number };                  // component resolved app-side
};

// apps/zync-app/src/os/registry-os.ts — app-side companion, one entry per module with os metadata:
type OsModuleBindings = {
  moduleId: ModuleId;
  titleFromRoute?: (location: SerializedLocation) => string | undefined; // window subtitle (e.g. customer name)
  badgeQuery?: { queryKey: readonly unknown[]; select: (data: unknown) => number }; // MUST reuse an existing query factory's key — never a bespoke fetch
  widgetComponents?: Record<string, React.LazyExoticComponent<any>>;
  todayComponent?: React.LazyExoticComponent<any>;
  keepAlive?: boolean;            // exempt from minimized-unmount (default false)
};
```

- Wave 1 ships compile-time fixtures for Tasks + Customers + Notifications bindings (type-checked examples the sweep tasks copy). `ModuleGuard` permission logic (`moduleGuardId` = existing module id) reused verbatim for surface filtering.
- **OS-app identity (Today, Notifications, Settings)**: these get real `ModuleId` entries in `MODULE_MANIFEST` with a new manifest field `osApp: true` — meaning: always available to authenticated users (no tenant enablement row, no plan gating, no dependency graph participation), user-launchable on OS surfaces, and EXCLUDED from `TOGGLEABLE_MODULE_IDS` + `MODULE_CARD_ORDER` (never appear in `/settings/modules`). Do NOT reuse `alwaysOn` (its semantic today = system-hidden, never user-visible — opposite of an OS app). Zero server-side change — `osApp` modules never touch module-enablement storage, preserving §7's complete-list guarantee. `ai_assistant` keeps its existing manifest entry unchanged.

- Registry powers ALL OS surfaces from one source: desktop icons, start menu, taskbar pinning, command center apps section, widget gallery, badges, quick actions (PDF requirement: no per-surface nav structures). Permission/plan filtering happens in the registry selector BEFORE any surface renders (`Users must never see inaccessible apps`).
- Plan A fills `os` metadata for: Tasks, Customers, Today, Zync AI, Notifications, Settings (opens classic settings in a window-framed iframe-free route — settings routes are already self-contained pages). All other modules: no `os` block → visible in command center as classic deep-links only (opens classic shell route in same tab, flag persists — honest degradation, no half-windows).
- Component refs in the registry are lazy imports registered app-side (`apps/zync-app/src/os/registry-os.ts`) keyed by module id — `packages/modules` stays React-free (it currently is; keep it).

### 3. Window manager (`apps/zync-app/src/os/`)

New Zustand store `os-shell-store.ts` (single store, persisted partially — see 6):

```ts
type SerializedLocation = { pathname: string; search: string; hash: string };
// Location.state and Location.key are NOT captured/persisted. Modules must not depend on location.state
// inside windows — Tasks' modal-background pattern (location.state.background) is normalized to
// full-page detail rendering in OS mode (pilot task pins this). Search params ARE first-class (filters live in `search`).

type OsWindow = {
  instanceId: string;          // nanoid
  moduleId: string;
  location: SerializedLocation; // module-internal location (pathname+search+hash)
  title: string;               // live, module-reported via useWindowTitle()
  rect: { x: number; y: number; w: number; h: number };
  state: 'normal' | 'minimized' | 'maximized' | 'snapped';
  snapZone?: 'left' | 'right' | 'tl' | 'tr' | 'bl' | 'br';
  preSnapRect?: Rect;          // restore target when un-snapping/un-maximizing
  launchOrigin?: { x: number; y: number }; // animation origin, transient (not persisted)
};
type OsShellState = {
  windows: OsWindow[];         // ARRAY ORDER = z-order (last = top). No per-window zIndex field.
  focusedId: string | null;
  desktopIcons: { moduleId: string; cell: number }[];
  pinnedTaskbar: string[];     // moduleIds
  widgets: { widgetId: string; moduleId: string; cell: number }[];
  panels: { start: boolean; tray: boolean; notifications: boolean; commandCenter: boolean }; // max one true — opening one closes others
};
```

- Focus = move window to array end + set focusedId. All z from array index. Each window container `isolation: isolate` (zc-ui-ux-designer depth law).
- Windows render module content via a `WindowRouter`: MemoryRouter-per-window is REJECTED (breaks deep links); the window renders the shared route elements via `<Routes location={window.location}>` inside a full replacement router context. **`WindowRouter` is a COMPLETE React Router context implementation, not a `useNavigate` patch** — it provides `UNSAFE_LocationContext` (full `Location` from `window.location` + stable `key`) and `UNSAFE_NavigationContext` with a window-scoped `Navigator`: `push`, `replace`, `go(delta)` (per-window history stack, capped 50), `createHref`. That makes ALL of these work unchanged inside windows: `useNavigate`, `<Link>`, `<Navigate>`, `useLocation`, `useSearchParams` (setter included), `navigate(-1)`, nested `<Routes>`. Navigation blockers (`useBlocker`) are NOT supported in Plan A — windows never block close (modules persist state instead; enforced in pilot review). **Wave-1 SPIKE GATE**: a `WindowRouter` test harness with fixtures for each of the above hooks + `<Navigate>` + nested `<Routes>` + Tasks' `useSearchParams` filters + the Tasks modal-background normalization MUST be green before ANY other OS UI task starts. If the spike proves React Router 7's UNSAFE_ contexts unviable, escalate to user before proceeding (fallback candidates exist but are a scope decision).
- Multiple instances: allowed only when registry `supportsMultipleInstances` (Tasks: false, Customers: true — two customer profiles side-by-side is the PDF's own example). Same TanStack Query cache shared across instances (query keys are route-derived; no per-window cache). **Multi-instance invariant**: a module declaring `supportsMultipleInstances` MUST hold no module-global mutable UI state — all UI state instance-scoped (component/window context) or in the route-keyed query cache. Customers satisfies this today (verified: no module-global Zustand store); the invariant is a review gate for any future module flipping the flag.
- Window lifecycle motions, drag/snap/resize physics, keyboard map: EXACTLY per `zc-ui-ux-designer/references/desktop.md` tables (single source — this spec does not restate them).
- Module error boundary per window (existing ErrorBoundary pattern); crash card actions: Reload app (remount), Reset app state (clear module store + refetch), Close. Shell-root boundary → safe screen (skill doc).

### 4. Desktop surfaces

All surfaces consume the registry selector + os-shell-store only. Components under `apps/zync-app/src/os/` (new): `Desktop`, `DesktopIcon`, `WidgetFrame`, `AppWindow`, `WindowTitleBar`, `Taskbar`, `TaskbarApp`, `StartMenu`, `SystemTray`, `NotificationCenter`, `CommandCenter`, `SnapPreview`, `TimerChip`. Geometry, materials, motion per skill references — each component's spec-level behavior is the skill's table row + these contracts:

- **Taskbar**: pinned = `pinnedTaskbar`; running derived from `windows`; badge counts from registry `badge.queryKey` via `useQuery` subscription (shared cache, zero extra fetches). Timer chip from `stores/timer.ts` (running → chip visible, click → tray panel timer section).
- **Start menu**: pinned grid (user-ordered, drag persists), all-apps by registry category, recent apps from usage log (localStorage ring buffer, 20 entries), footer = user menu actions reused from `Header.tsx` menu.
- **Notification center**: reuses `use-notifications.ts` data + realtime invalidation; adds grouping (by module), mark-all, per-row deep-link open (routes through window-open command). The classic `NotificationDropdown` stays for classic shell — both consume the same hooks; ONLY presentation differs.
- **System toasts**: OS-shell toast layer replaces `packages/ui` toast POSITION for shell mode (bottom-end near tray, per skill); module-emitted toasts route through the same `toast()` API — presentation adapter only, call sites untouched.
- **Command center**: consolidation task — one implementation replacing `CommandPalette.tsx` AND `features/search/CommandModal.tsx` (delete `shell/use-search.ts` duplicate path after). Sections: apps (registry), records (existing search API via `useFullSearch` seam), commands (`>` prefix: window management, create actions from registry quickActions, theme, shell mode), recent. Serves BOTH shells (classic keeps Ctrl+K behavior) — this is the one Plan A change that intentionally touches classic UX (strict superset of both old palettes; acceptance: every capability of both preserved, enumerated in task contract).
- **Today app**: new module (`todayContribution` aggregator) — window shows contribution sections from registered modules (Tasks due, unread notifications, running timer, pinned records), each section a lazy component consuming that module's existing queries. NO new backend endpoints in Plan A (compose existing queries only). Desktop default layout: Today auto-opens maximized on first OS-shell login (once, dismissible).
- **Zync AI surface**: registry entry (`ai_assistant`) → taskbar pin + command center + dock; opening it focuses a right-side snapped window hosting existing `ChatPanel` (not the floating bubble — `ChatLauncher` suppressed in OS mode). Context passing: the window context exposes `{ moduleId, location }` of the focused window; ChatPanel receives it as `context` prop and prepends a system-line "user is viewing <module>/<route>" to the next message (existing API accepts messages; no backend change). Falsifier: if ChatPanel API can't take a context prop cleanly, task adds an optional prop — never a fork.

### 5. URL ↔ window contract (THE rule — agents must not invent)

- The browser URL ALWAYS equals the focused window's location. Focus change → `history.replaceState` (no history entry). In-window navigation → `history.pushState` (creates entries).
- **Every history entry OS mode writes carries `history.state.zync = { shell: 'os', instanceId, moduleId }`** (routeKey implicit in the URL). Back/forward (`popstate`) resolves by precedence: (1) `state.zync.instanceId` matches an open window → focus it and set its location to the entry's URL; (2) no instanceId match (window closed) → route-ownership match via registry `routePrefixes` → open/focus owning module at that URL; (3) no owner → classic-fallback navigation (below). Longest-prefix matching is ONLY the cold-load fallback for entries without `state.zync` — never the primary mechanism (two Customers windows share a prefix; instanceId disambiguates).
- Deep link entry (fresh load or external): resolve via registry `routePrefixes` → if restored layout contains a window whose location matches, focus it; else open a new window for the owning module at that URL. Unknown/no-os-metadata module → render classic shell for that navigation (mode override for the session, banner offering return).
- Desktop visible (no windows / all minimized) → URL is `/desktop`. Login/app entry at `/` MUST canonicalize to `/desktop` after OS mode resolves; classic mode keeps `/`. Panels (start/tray/notifications/command) NEVER touch the URL. `/desktop` exists ONLY in OS mode — any classic-mode entry to `/desktop` (kill-switch `?shell=classic`, classic fallback, mode flip) rewrites to `/` before the classic router sees it (classic `routes/index.tsx` has no `/desktop` match; without the rewrite the escape hatch 404s). NEVER add an index redirect from `/` to itself: it shadows the classic empty-path module route and leaves the content outlet blank. Rationale: one mode-aware normalization seam preserves both canonical URLs without route conflicts.
- Two browser tabs = two independent OS sessions sharing persisted layout at load time only; no cross-tab window sync in Plan A (BroadcastChannel deferred; falsifier: if users report tab-fights over layout saves, Plan B adds tab-election).
- Route-addressability acceptance (PDF): `/customers/:id`, `/tasks/:id` open correct app+record from cold URL in OS mode.

### 6. Persistence

- New table `shell_layouts` (Drizzle, `packages/db`): `(tenant_id, user_id, device_class, payload jsonb, version int, updated_at)` PK `(tenant_id, user_id, device_class)`. `device_class = 'desktop'` in Plan A ('mobile' reserved). Payload = versioned JSON: `{ v: 1, desktopIcons, pinnedTaskbar, widgets, windows: PersistedWindow[] }` — windows persisted WITHOUT transient fields (launchOrigin, title). RLS/tenant-guard per `tenant-guards.ts` house pattern; audit-log on write per project law.
- API: `GET/PUT /api/shell/layout?device=desktop` (Hono, zod-validated, `requireAuth`, rate-limited like preferences).
- **Persist only at commit points** — drag END, resize END, open/close/minimize/maximize/snap settle, pin/order change, widget/icon move drop. NEVER per-frame or per-store-mutation (drag emits 60 mutations/s; persisting them is a perf and corruption bug). One `commitLayout()` seam in the store; PUT debounced 2s after last commit.
- Payload envelope: `{ v: 1, writer: sessionId, committedAt: ISO, data: {...} }`, zod-parsed on every read; parse failure → discard + defaults + toast (error table). `sessionId` = per-tab nanoid (sessionStorage).
- Conflict rule (deterministic, no "best-effort"): PUT carries `version`; 409 → GET server copy → if server `committedAt` > local last commit, server wins (apply server layout unless user has uncommitted local changes newer than server's `committedAt`, in which case local wins and re-PUTs with the fresh version). No prompt — layout is low-stakes; last committed wins, ties broken by server copy.
- localStorage mirror (`zync.shell.<tenantId>.<userId>.desktop`) written at the same commit points (not per-mutation) → instant boot from cache. Initial server hydration MUST NOT replace any window state after the current session has made its first local commit; subsequent server reconciliation occurs only through the versioned PUT conflict rule.
- Reset: "Reset desktop layout" in start-menu footer settings → DELETE row + clear mirror + rebuild defaults from registry (role-based default icon set per PDF: enabled modules ∩ permissions, ordered by category).

### 7. Backend surface (complete list — nothing else changes server-side)

1. `shell_layouts` table + migration (house migration protocol, zc-dba law).
2. `GET/PUT /api/shell/layout` routes + queries in `packages/db/src/queries/shell-layouts.ts`.
3. User preference key `ui_shell` added to existing preferences schema/PATCH allowlist.
4. Tenant setting `force_shell` (nullable enum) in tenant settings schema + admin route allowlist.
Everything else in Plan A is frontend-only. PDF's "do not rewrite the server architecture" honored.

### 8. Performance budget (numbers, enforced by tests)

- Budget is SPLIT: **shell chrome** (wallpaper, taskbar, icons, empty window frames) interactive ≤400ms from cached layout; **restored window content** may skeleton (Loading Law) — module data/chunks never gate shell interactivity. Playwright CDP 4x-throttle asserts chrome-interactive ≤800ms.
- Window open animation: p95 frame time ≤24ms during `window-open` on 4x throttle with 3 windows open (trace evidence attached to task); zero long-task >100ms during the animation.
- Open windows cap: 12 (soft — opening #13 toasts "close something"; PDF lists no cap, we set one; falsifier: telemetry shows real users hitting it).
- Minimized windows unmount their React tree after 60s minimized (state survives in module stores/query cache; remount on restore ≤200ms, skeleton allowed). Guards: NEVER unmount while the window has an in-flight mutation, an open dialog, or reports `hasUnsavedState` (window context flag modules may set); `keepAlive: true` binding exempts a module entirely. Pilots: Tasks false, Customers false.
- Module chunks stay lazy (existing route-level splitting reused as window-level splitting — same imports). Start-menu focus, hover, or pointer-down on Settings preloads only the Settings shell and default page; click still opens/focuses the window synchronously. This intent preload MUST NOT eagerly load other modules.

## Design language binding

- EVERY UI task: REQUIRED READING = `.claude/skills/zc-ui-ux-designer/SKILL.md` + `references/desktop.md`. Task prompts carry the specific doctrine sections they implement; reviewer + vision judge reject against the skill. New tokens (motion/shadow/material/z from the skill's token pack) land in `packages/ui/src/tokens/index.css` in Wave 1 BEFORE any consumer.
- zc-ui-dev remains law for module content; the OS amendment is scoped to `[data-shell]` (skill §3).

## Verification program (adapted from multideal 29-regression-suite — 8 layers)

Suite: `apps/zync-app/tests/e2e/os-shell/` + `playwright.os.config.ts` (webServer: vite preview; auth via existing e2e session pattern; NO live-dev dependency for the suite). Wave 1 scaffolds helpers/config/CI BEFORE any UI task (same-task-ships-tests law: every UI task's Files list includes its `<NN>-*.spec.ts`).

1. **Write-time (slopgate)**: OS rule pack — no duration/easing literals outside tokens; no `transition` during drag handlers; `data-fx` required on elements matching shell-effect selectors; no `z-index:` literals in `src/os/**`; no second `backdrop-filter` recipe. Green/red fixtures per existing `.slopgate/` convention.
2. **Behavioral (Playwright)**: helpers `expectAnimated` (rAF-samples computed transform/opacity — triggers effect itself, throws on zero elements/samples, messages prefixed `[fx:<id>]`), `expectOneShot`, `expectReducedMotionStatic`, `expectSettleCurve` (midpoint-progress sampling distinguishes `--ease-settle` from linear), `expectZeroCls` (layout-shift sampling during effect), plus WM interaction probes: real `mouse.down/move/up` drag-snap into each zone, z-order after focus click, minimize→restore state round-trip, keyboard-only window management sweep, URL-contract assertions (focus→replaceState, back→focus-not-navigate), persistence round-trip (mutate layout → reload → assert), multi-instance isolation (two Customers windows, different records, no cross-bleed).
3. **Test validity (red-proof)**: every new spec MUST fail against merge-base tree with head tests overlaid (`[fx:*]`-tagged assertion failure required — untagged failures/no-tests-found don't count) and pass on head; reviewer seat executes, both logs attached as evidence. Traceability matrix per task: acceptance bullet → test name, reviewer-verified.
4. **Mutation (KILL recipes)**: each spec exports a KILL (CSS override for CSS effects; fresh-context `addInitScript` for JS/store-driven behaviors, e.g. stub the snap-zone arming or store reorder); `mutation-audit.ts` sweep: 100% of KILLed items must fail. Local one-command + non-blocking CI job.
5. **Visual baselines**: `toHaveScreenshot` RTL+LTR × dark+light for: empty desktop, focused+unfocused window pair, start menu, tray panel, notification center, command center, Today, snap preview armed. Masked dynamics, seeded data, bundled fonts. Baseline updates need explicit PR justification.
6. **Perceptual evidence**: per effect — Playwright video + 3 paused keyframes (0/~40/~80% via `animation.currentTime`) × RTL/LTR into `test-results/os-shell/<fx-id>/`; JS-driven (FLIP/store) effects: video frames via ffmpeg extraction. Completeness script fails on missing artifacts. State-pair screenshots (before/after snap, focus pair) captured for judge.
7. **Vision judge (codex, NEVER Claude/self)**: `gpt-5.4-mini`/low sweeps all items' keyframes via `cdx exec -i`; fix-needed/uncertain re-judged `gpt-5.5`/low (policy benchmarked in multideal 2026-07-10, 5/5 planted defects). Judge prompt = item's acceptance + zc-ui-ux-designer doctrine (focal, shadow recipes, material singularity, motion origin, RTL, text legibility, contrast) + MUST-verdict format (pass / fix-needed + top defects). Verdict table = program gate artifact; fix-needed → back to codex coder seat.
8. **Post-run fault-injection (controller)**: after harness completes, session controller breaks ≥5 random effects at source, asserts suite catches each, reverts; plus WM faults (disable store reorder on focus, disable snap arming). Any miss → suite fix task, audit redrawn.

**Gate tiering (which layers run when)** — running all 8 per task would stall the run and invite artifact-gaming:
- **Every OS task (blocking)**: layers 1–4 (slopgate, behavioral specs for that task's items, red-proof, KILL recipe authored) + type/lint/unit.
- **Surface-completing tasks (blocking)**: + layer 5 baselines + layer 6 artifacts for that surface's effects.
- **Wave close (blocking, controller-run)**: mutation sweep (layer 4 audit across wave), layer 7 vision-judge verdict table over the wave's keyframes, layer 8 fault-injection.
Judge/fault-injection are MILESTONE gates, never per-PR; per-task evidence stays deterministic and cheap.

CI: `os-regression` job in existing workflow on the self-hosted runner (`--workers=2`, idempotent browser install); merge gate = local PR-review skill checklist extended with `pnpm --filter zync-app run test:e2e:os` when the PR touches `src/os|packages/ui/src/tokens|tests/e2e/os-shell`.

## Accessibility contract (screen-reader + focus model)

- NO `role="application"` anywhere (kills screen-reader virtual cursor). Desktop = `role="main"`; each window = labelled `role="region"` with `aria-label` = window title; taskbar = `role="toolbar"` of buttons (`aria-pressed` for focused app); start menu / tray / notification center / command center = `role="dialog"` with focus trap + focus-return to the invoking control on close; desktop icons + taskbar apps + start grid = roving tabindex (arrow keys move, Tab exits the group).
- `aria-live="polite"` shell announcer (single visually-hidden region): announces window open/close/minimize/restore/focus ("Tasks window, focused"), snap results ("Snapped left"), toast summaries, layout reset. NEVER announce per-frame drag positions.
- Focus rules: opening a window moves DOM focus to its first focusable (or the frame itself, `tabindex="-1"`); closing returns focus to the launching control if it still exists, else the taskbar button, else desktop; minimizing focuses the taskbar button; keyboard window-cycle overlay is itself focus-trapped.
- All WM operations reachable keyboard-only (map in desktop.md) — layer-2 probe sweeps this; axe-core scan of every shell surface added to the behavioral suite (no serious/critical violations).

## Usability & support mitigations (OS paradigm risk)

- First-run onboarding: one-time 4-step coach-mark tour (taskbar, start, windows, command center) on first OS login; skippable; never repeats.
- Escape hatches always visible: "Reset desktop layout" + "Switch to classic view" both in start-menu footer AND in the shell-crash safe card; `?shell=classic` documented for support.
- "Show open windows" command in command center (lists windows, Enter focuses) — the lost-window rescue.
- Diagnostic copy button (safe card + tray) copies shell state summary (mode, window count, layout version, last error) for support tickets.
- Telemetry counters (existing analytics seam, fire-and-forget): layout_reset, window_cap_hit, classic_escape_used, deep_link_unowned, shell_crash. Reviewed before Plan B widens rollout.

## Error handling (layer table)

| Failure | Surface |
|---|---|
| Module render crash | in-window error card (Reload / Reset state / Close) |
| Shell store corruption (bad persisted payload) | zod-parse on load; invalid → discard payload, default layout, toast "layout was reset" |
| Layout PUT 409/5xx | silent retry w/ backoff; persistent failure → tray warning badge, local mirror keeps working |
| Deep link to unauthorized module | classic 403 page inside window frame is WRONG — registry filter means window never opens; navigate to `/desktop` + toast |
| Query/network loss | existing offline handling per module; tray connectivity indicator |
| Missing/expired session | shared pre-shell gate silently refreshes once; terminal failure clears client state and redirects to login without mounting protected queries |
| Shell root crash | safe screen (skill doc) |

## Testing strategy summary

Unit: store logic (z-order, focus, snap rects, URL matcher) — vitest, pure functions extracted. Integration: WindowRouter harness (the Wave-1 spike fixtures stay as the permanent regression suite). E2E: verification program above. Type gate + lint + existing project gates throughout.

## Acceptance criteria (Plan A gate)

1. Default login → desktop with icons/taskbar/start/tray; "Login to Legacy Site" checked → classic shell unchanged (route-level smoke on classic suite stays green). Mobile (<768px) still resolves classic regardless.
2. Tasks + Customers open in movable/resizable/snappable/minimizable windows; Customers supports 2 instances; all WM motions per skill tables with `data-fx` + green regression tests.
3. URL contract holds (focus/replace, back/focus, deep links, `/desktop`).
4. Layout persists across reload + across browser (server round-trip), reset works.
5. Command center consolidation: both legacy palettes' capabilities preserved (enumerated checklist), single implementation, works in BOTH shells.
6. Notification center + toasts + badges live on registry/shared queries.
7. Today opens with live contributions; AI opens as snapped window w/ context line.
8. Verification program: suite green, mutation sweep 100%, vision-judge verdict table all-pass, fault-injection audit log clean, CI job proven red-then-green on a scratch PR.
9. Perf budget assertions green under throttle.
10. RTL + dark/light complete on every shell surface (baselines committed).
11. Reduced-motion + reduced-transparency + keyboard-only management verified.
12. Zero classic-shell regressions (existing e2e suites green).
13. Accessibility contract holds: roles/announcer/focus-return per section above, axe-core clean on all shell surfaces.
14. WindowRouter spike gate passed (all router-hook fixtures green) before Wave 2 started — evidenced in run log.

## Architecture decisions (settled — falsifier each)

- Put authentication above shell selection, not inside either shell. Rationale: both classic and OS are protected presentations of the same tenant app; allowing OS chrome to mount without a session produces an unusable desktop and repeated unauthorized requests.

- Custom WM over library: no production React WM exists; premium physics live in owned details. Falsifier: a maintained lib covering snap/tile/RTL/a11y appears.
- Shared route elements rendered per-window via `<Routes location>` + navigation adapter, NOT MemoryRouter-per-window and NOT iframe: deep links + code-sharing win. Falsifier: adapter proves leaky on Tasks pilot → escalate before Wave 3.
- Array-order z model: eliminates z-wars; `isolation: isolate` fences module z tokens. Falsifier: a legitimate always-on-top need (screen-share overlay) appears — Plan C concern.
- JSONB blob layout over relational rows: layout is read/written atomically by one user; rows add migration surface for zero query benefit. Falsifier: cross-device partial sync requirement.
- OS default at login + "Login to Legacy Site" session fallback (USER decision 2026-07-10, supersedes classic-default): desktop-first launch posture; classic survives only as fallback and may be cut at launch. Mobile still forces classic until Plan B. Falsifier: support volume from OS-default forces a flip back before Plan B.
- 12-window soft cap + 60s minimized unmount: perf floor beats unbounded ambition. Falsifier: telemetry.
- Command center serves both shells: one palette codebase; classic gains, doesn't lose (superset acceptance).

## Out of scope pointers

Plan B consumes: `device_class='mobile'` slot, registry `mobile` presentation field (reserved, unimplemented), skill `references/mobile.md` (already written). Plan C consumes: virtual desktops (array-of-window-arrays extension noted in store design — do NOT pre-build), saved workspaces, focus mode, lock screen, taskbar position, always-on-top. AI depth spec consumes: window context payload `{ moduleId, location }` (already exposed).
