# Zync OS Desktop Shell (Plan A) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship the Plan A desktop OS shell per `docs/specs/2026-07-10-zync-os-desktop-design.md` — windows/taskbar/start/tray/notification-center/command-center/Today, OS mode default at login, classic as "Login to Legacy Site" fallback, Tasks + Customers pilots, verification program green.

**Architecture:** Custom Zustand window manager rendering shared route elements per-window via a full React Router context replacement (`WindowRouter`); one extended pure-data module registry powering every OS surface; `shell_layouts` JSONB persistence with commit-point writes; URL = focused window's location with `history.state.zync` identity. Design physics live SOLELY in `.claude/skills/zc-ui-ux-designer/` (SKILL.md + references/desktop.md) — **REQUIRED READING for every UI task; the spec and this plan do NOT restate motion/shadow/material values. Deviating from the skill's tables = task rejected.**

**Tech Stack:** Vite + React 18, React Router 7, Zustand, TanStack Query, Hono (Workers), Drizzle/Neon, Playwright, vitest.

**Spec:** `docs/specs/2026-07-10-zync-os-desktop-design.md` (authoritative for every contract below; section refs = spec sections).

---

## Wave Plan

| Wave | Tasks | Files touched (create/modify roots) | Safe to parallelize? |
|------|-------|--------------------------------------|----------------------|
| 1 | T1 tokens, T2 registry-data, T3 WindowRouter spike, T4 shell store, T5 backend, T6 test scaffold | packages/ui/tokens · packages/modules · src/os/window-router · src/os/store · packages/db+zync-api · tests/e2e/os-shell+.slopgate | ✅ zero overlap |
| 2 | T7 window frame, T8 desktop surface, T9 taskbar, T10 start menu, T11 tray+notif center, T12 registry bindings, T13 command center | src/os/window/ · src/os/desktop/ · src/os/taskbar/ · src/os/start/ · src/os/panels/ · src/os/registry-os.ts · src/os/command-center/+features/search | ✅ disjoint dirs (T13 also touches features/search + components/shortcuts — no other task does) |
| 3 | T14 OsShell root + mode/chrome split + login checkbox, T15 URL↔window engine, T16 persistence client | src/main.tsx+src/os/OsShell.tsx+auth login · src/os/url-sync.ts · src/os/layout-persistence.ts | ✅ T15/T16 own files; T14 composition file listed ONLY in T14 |
| 4 | T17 Tasks pilot, T18 Customers pilot, T19 Today app, T20 AI window + Settings window | features/tasks + bindings/tasks.ts · features/customers + bindings/customers.ts · src/os/apps/today/ + bindings/today.ts · src/os/apps/ + bindings/ai.ts | ✅ disjoint (per-module binding files; aggregator frozen after T12) |
| 5 | T21 onboarding+support+telemetry, T22 visual baselines+keyframes, T23 a11y sweep, T24 perf assertions | src/os/onboarding/ · tests baselines · a11y fixes across src/os (sequential-within-task) · tests/perf | ⚠️ T23 may touch any src/os file — T21 scoped to src/os/onboarding + telemetry seam only; run T22/T24 (tests-only) parallel, T21/T23 sequential order T21→T23 |
| 6 | T25 wave-close gates (mutation sweep, vision judge, fault injection, classic regression) | test artifacts only | single task (controller) |

Wave rule from spec: **T3 is a SPIKE GATE — Wave 2+ MUST NOT start until T3 acceptance passes.** If T3 proves React Router 7 UNSAFE_ contexts unviable → STOP, escalate to user (spec §3).

---

### Task 1: Shell token pack

**Wave:** 1 · **Blocks:** all Wave 2 UI · **Blocked by:** —

**Files:**
- Modify: `packages/ui/src/tokens/index.css` — append shell token block
- Test: covered by T6 slopgate green fixture + `pnpm typecheck` + existing app build

**Contract:** Add EXACTLY the token pack from `.claude/skills/zc-ui-ux-designer/SKILL.md` §"Shell token pack" — `--dur-*` (5), `--ease-*` (3), `--shadow-window/-focused/-shell/-drag` (dark defaults + `html:not(.dark)` light overrides), `--radius-shell-frame: 8px`, `--radius-shell-panel: 12px`, `--material-shell-bg/-blur/-saturate`, `--z-desktop/-window/-shell/-shell-panel/-system-layer/-drag`. Copy values VERBATIM from the skill file — it is the single source; do not invent or "improve" values.

**Behavior:** No existing token changed/removed; existing classic UI pixel-identical (tokens are additive).

**Acceptance:**
- Run: `pnpm typecheck && pnpm --filter zync-app build`
- Expected: PASS; `grep -c 'dur-instant\|ease-settle\|shadow-window\|material-shell-bg\|radius-shell-frame' packages/ui/src/tokens/index.css` ≥ 5

- [ ] Apply token block from skill file
- [ ] Run acceptance
- [ ] Commit: `git add packages/ui/src/tokens/index.css && git commit -m "feat(os): shell motion/depth/material token pack"`

### Task 2: Module registry OS extension (pure data)

**Wave:** 1 · **Blocks:** T12, all surfaces · **Blocked by:** —

**Files:**
- Modify: `packages/modules/src/manifest.ts` — extend types + entries
- Create: `packages/modules/src/os-types.ts` — `ZyncModuleOs`, `SerializedLocation` types
- Test: `packages/modules/src/__tests__/os-manifest.test.ts`

**Contract (spec §2 — pin exactly):**
- `ZyncModuleOs` type per spec §2 code block (desktop sizes, `routing: { routePrefixes: string[]; defaultRoute: string }`, quickActions route-only, searchProvider kinds, `badge: { badgeId: string }`, widgets meta, todayContribution meta). PURE DATA — zero functions/components/React imports in `packages/modules` (broken contract otherwise).
- New manifest field `osApp?: true` — always-available authed OS apps, EXCLUDED from `TOGGLEABLE_MODULE_IDS` + `MODULE_CARD_ORDER` + dependency graph + plan gating; no enablement row. Do NOT reuse `alwaysOn`.
- New `ModuleId` values: `today`, `notifications`, `settings` (all `osApp: true`). `ai_assistant` entry unchanged, gains `os` block.
- `os` metadata filled for: tasks, customers, today, notifications, settings, ai_assistant. `routing.routePrefixes` from the REAL route table in `apps/zync-app/src/routes/index.tsx` (verify each — ModuleId ≠ route stem, e.g. time_management→/time-track).

**Behavior:** Modules settings page (`/settings/modules`) shows ZERO new cards; module toggle/enablement behavior unchanged; typecheck across all consumers green.

**Acceptance:**
- Run: `pnpm --filter @zync/modules test && pnpm typecheck`
- Expected: PASS — test asserts osApp ids excluded from `TOGGLEABLE_MODULE_IDS`/`MODULE_CARD_ORDER`, every `os.routing.routePrefixes` entry startsWith `/`, manifest JSON-serializable (`JSON.parse(JSON.stringify(...))` deep-equals)

- [ ] Write failing tests (exclusion, serializability, prefix shape)
- [ ] Implement types + entries
- [ ] Run acceptance
- [ ] Commit: `git add packages/modules/src && git commit -m "feat(os): registry OS extension + osApp identity"`

### Task 3: WindowRouter SPIKE (GATE — blocks Wave 2)

**Wave:** 1 · **Blocks:** ALL Wave 2+ · **Blocked by:** —

**Files:**
- Create: `apps/zync-app/src/os/window-router.tsx` — full router-context replacement
- Create: `apps/zync-app/src/os/window-context.ts` — `WindowContext` (instanceId, moduleId, location, setTitle, hasUnsavedState flag seam)
- Test: `apps/zync-app/src/os/__tests__/window-router.test.tsx`

**Contract (spec §3 — the highest-risk seam):**
- `WindowRouter({ window: OsWindow, onNavigate: (loc: SerializedLocation, action: 'push'|'replace'|'go', delta?: number) => void, children })` — provides `UNSAFE_LocationContext` (full `Location` built from `window.location` + stable synthetic `key`) and `UNSAFE_NavigationContext` with window-scoped `Navigator`: `push`, `replace`, `go(delta)` (per-window stack, cap 50), `createHref`.
- Inside it, ALL of these behave window-scoped with UNCHANGED module code: `useNavigate`, `<Link>`, `<Navigate>`, `useLocation`, `useSearchParams` (getter AND setter), `navigate(-1)`, nested `<Routes>`.
- `useBlocker` unsupported — assert it throws a descriptive error in dev.
- `SerializedLocation = { pathname, search, hash }` from `packages/modules/src/os-types.ts`; `state`/`key` never round-trip.

**Behavior:** Fixtures render a probe component using each hook under `<WindowRouter>` + `<Routes location>`; navigation calls arrive at `onNavigate` with correct action + location; browser `window.history` NEVER touched by in-window navigation (spy asserts zero calls); nested `<Routes>` resolve relative to window location.

**Acceptance (SPIKE GATE):**
- Run: `pnpm --filter zync-app exec vitest run src/os/__tests__/window-router.test.tsx`
- Expected: PASS — one test per hook fixture (8 minimum), including `useSearchParams` setter and `navigate(-1)` stack pop. FAIL → STOP RUN, escalate to user (spec falsifier).

- [ ] Write failing fixture tests (all 8 hook cases + history-untouched spy + useBlocker throw)
- [ ] Implement WindowRouter
- [ ] Run acceptance
- [ ] Commit: `git add apps/zync-app/src/os && git commit -m "feat(os): WindowRouter full router-context replacement (spike gate)"`

### Task 4: OS shell store

**Wave:** 1 · **Blocks:** all Wave 2 · **Blocked by:** —

**Files:**
- Create: `apps/zync-app/src/os/os-shell-store.ts` — Zustand store
- Create: `apps/zync-app/src/os/wm-geometry.ts` — pure functions (snap rects, clamps, cell math)
- Test: `apps/zync-app/src/os/__tests__/os-shell-store.test.ts`, `__tests__/wm-geometry.test.ts`

**Contract (spec §3 types verbatim):** `OsWindow`, `OsShellState` exactly per spec §3 code block (array-order z, `focusedId`, `desktopIcons`, `pinnedTaskbar`, `widgets`, `panels` max-one-true). Actions: `openWindow(moduleId, location?, launchOrigin?)` (respects `supportsMultipleInstances` + 12-cap toast seam + focuses existing single-instance), `closeWindow`, `focusWindow` (array reorder + focusedId), `minimize/restore/maximize/snap(zone)/unsnap` (preSnapRect semantics per spec), `moveWindow/resizeWindow` (transient, no commit), `commitLayout()` (the ONLY persistence trigger seam — spec §6 commit points), `resetLayout(defaults)`. `wm-geometry`: `snapZoneForPointer(pt, viewport)` (12px edges, corners, 100ms dwell handled by caller), `rectForZone(zone, viewport)`, `clampWindowRect` (titlebar ≥24px visible, rubber-band math distance/3).

**Behavior:** Focus moves window to array end; minimized keeps array position; open beyond cap 12 → no window added, `onCapHit` callback fires; single-instance reopen focuses + navigates instead of duplicating; panels mutually exclusive.

**Acceptance:**
- Run: `pnpm --filter zync-app exec vitest run src/os/__tests__/os-shell-store.test.ts src/os/__tests__/wm-geometry.test.ts`
- Expected: PASS — z-order/focus/snap/cap/panel-exclusivity/preSnapRect cases covered

- [ ] Write failing tests
- [ ] Implement store + geometry
- [ ] Run acceptance
- [ ] Commit: `git add apps/zync-app/src/os && git commit -m "feat(os): shell store + window-manager geometry"`

### Task 5: Backend — shell_layouts + prefs

**Wave:** 1 · **Blocks:** T16 · **Blocked by:** —

**Files:**
- Create: migration via house protocol (zc-dba law) — `shell_layouts` table
- Create: `packages/db/src/queries/shell-layouts.ts`
- Create: `apps/zync-api/src/routes/shell/layout.ts` (mount in `apps/zync-api/src/index.ts`)
- Modify: user-preferences schema/PATCH allowlist — add `ui_shell: 'classic'|'os'` (default `'os'`)
- Modify: tenant settings schema + admin allowlist — add `force_shell: 'classic'|'os'|null`
- Test: `apps/zync-api/test/shell-layout.test.ts`

**Contract (spec §6/§7):**
- Table: `(tenant_id, user_id, device_class, payload jsonb, version int, updated_at)` PK `(tenant_id, user_id, device_class)`; tenant-guard per `packages/db/src/queries/tenant-guards.ts` house pattern; audit-log on write.
- Routes: `GET /api/shell/layout?device=desktop` → `{ payload, version } | 404`; `PUT` body `{ payload, version }` zod-validated (envelope `{ v: 1, writer, committedAt, data }`), stale version → 409 `{ current: { payload, version } }`; `DELETE` → reset. `requireAuth`, rate-limited like preferences.
- device_class enum: `'desktop'` (accept `'mobile'` in schema, reserved).

**Behavior:** Cross-tenant read/write impossible (tenant predicate both layers); version increments server-side on successful PUT; oversized payload (>64KB) → 413.

**Acceptance:**
- Run: `pnpm --filter zync-api test -- shell-layout`
- Expected: PASS — round-trip, 409 on stale version, 404 empty, tenant isolation (forged tenant id → no row), 413 oversize

- [ ] Write failing route tests
- [ ] Migration + queries + routes + pref keys
- [ ] Run acceptance
- [ ] Commit: `git add packages/db apps/zync-api && git commit -m "feat(os): shell_layouts persistence + ui_shell/force_shell prefs"`

### Task 6: Verification scaffold

**Wave:** 1 · **Blocks:** all Wave 2 test authoring · **Blocked by:** —

**Files:**
- Create: `apps/zync-app/playwright.os.config.ts` — webServer vite preview, existing e2e auth pattern, projects: ltr-dark, rtl-dark, ltr-light, rtl-light
- Create: `apps/zync-app/tests/e2e/os-shell/helpers/fx.ts` — `expectAnimated`, `expectOneShot`, `expectReducedMotionStatic`, `expectSettleCurve`, `expectZeroCls` (contracts below)
- Create: `apps/zync-app/tests/e2e/os-shell/helpers/kill.ts` — KILL-recipe registry type + loader
- Create: `apps/zync-app/tests/e2e/os-shell/mutation-audit.ts` — sweep runner
- Create: slopgate OS rule pack + green/red fixtures per existing `.slopgate/` convention
- Modify: CI workflow — `os-regression` job (self-hosted runner, `--workers=2`, path-filtered `src/os|packages/ui/src/tokens|tests/e2e/os-shell`)
- Test: scaffold self-test `tests/e2e/os-shell/00-scaffold.spec.ts`

**Contract (spec Verification §1-4):**
- `expectAnimated(page, fxId, { property: 'transform'|'opacity', trigger: () => Promise<void> })` — rAF-samples COMPUTED values on `[data-fx="<fxId>"]`; throws on zero matched elements or zero samples; failure messages prefixed `[fx:<fxId>]`. `expectSettleCurve` distinguishes `--ease-settle` from linear by midpoint progress. NEVER assert class presence.
- Slopgate rules: no duration/easing literals outside tokens in `src/os/**` + `[data-shell]` styles; no `transition` inside pointer-drag handlers; `data-fx` required on shell-effect selectors; no `z-index:` literals in `src/os/**`; no second `backdrop-filter` recipe; amendment-table scope from SKILL.md §machine-checkable table (`[data-shell]`/`[data-module-content]`).
- KILL registry: each future spec exports `{ fxId, kill: { css?: string, initScript?: string } }`; `mutation-audit.ts` applies each kill in a fresh context, runs the owning spec, requires FAIL.

**Behavior:** Red slopgate fixtures actually trip each rule; `00-scaffold.spec.ts` proves helpers throw on missing `data-fx` and pass on a seeded reference animation page.

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/00-scaffold.spec.ts --config playwright.os.config.ts` + slopgate fixture check command per `.slopgate/` convention
- Expected: PASS both; each red fixture rejected, each green accepted

- [ ] FIRST: check active PostToolUse/commit hooks — whitelist `.slopgate` red-fixture paths in hook config BEFORE writing deliberate-violation fixtures (learned rule: fixture-violations-blocked-by-edit-hooks)
- [ ] Write scaffold self-test (failing)
- [ ] Implement helpers/config/rules/CI job
- [ ] Run acceptance
- [ ] Commit: `git add apps/zync-app/playwright.os.config.ts apps/zync-app/tests .slopgate .github && git commit -m "feat(os): verification scaffold — fx helpers, slopgate pack, mutation harness, CI"`

### Task 7: Window frame + lifecycle + drag/snap/resize

**Wave:** 2 · **Blocks:** T14, T17-T20 · **Blocked by:** T1, T3, T4, T6

**Files:**
- Create: `apps/zync-app/src/os/window/AppWindow.tsx`, `WindowTitleBar.tsx`, `SnapPreview.tsx`, `useWindowDrag.ts`, `useWindowResize.ts`, `window-motions.css`
- Test: `apps/zync-app/tests/e2e/os-shell/01-window-frame.spec.ts`, `02-drag-snap.spec.ts` + unit tests for drag hooks

**Contract:** REQUIRED READING: skill SKILL.md (motion doctrine, direct-manipulation physics, depth model) + references/desktop.md §Window frame, §Window lifecycle motion (the data-fx table — implement EVERY row: window-open/close/minimize/restore/maximize/focus/snap + snap-preview), §Drag/snap/tile. `AppWindow({ window: OsWindow })` renders `[data-shell]` frame → `WindowRouter` → `[data-module-content]` mount with `isolation: isolate` + module error boundary (crash card: Reload / Reset state / Close). A11y: `role="region"` + `aria-label` = title, controls labelled, `tabindex="-1"` frame focus target (spec Accessibility contract). Respond-first law: store commits state same frame; animations decorate.

**Behavior:** All motions per skill tables (durations/easings/origins/data-fx ids verbatim from desktop.md); drag 1:1 `transition: none` rAF `translate3d`; snap zones arm at 12px + 100ms dwell → preview → settle FLIP; resize 8px bands/16px corners, min 320×240; rubber-band above y=0; unfocused/focused shadow crossfade via pseudo-element.

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/01-window-frame.spec.ts tests/e2e/os-shell/02-drag-snap.spec.ts --config playwright.os.config.ts`
- Expected: PASS — `[fx:*]` assertions for every lifecycle row, real mouse drag-snap into each zone, z-order-after-focus-click, keyboard map (Ctrl+Shift+arrows/M/Q, Ctrl+`), reduced-motion static; specs export KILL recipes

- [ ] Write failing specs (red-proof protocol: reviewer runs vs merge-base)
- [ ] Implement frame + motions + drag/snap/resize
- [ ] Run acceptance
- [ ] Commit: `git add apps/zync-app/src/os/window apps/zync-app/tests/e2e/os-shell && git commit -m "feat(os): window frame, lifecycle motions, drag/snap/resize"`

### Task 8: Desktop surface

**Wave:** 2 · **Blocks:** T14 · **Blocked by:** T1, T2, T4, T6

**Files:**
- Create: `apps/zync-app/src/os/desktop/Desktop.tsx`, `DesktopIcon.tsx`, `WidgetFrame.tsx`, `Wallpaper.tsx`, `useIconDrag.ts`
- Test: `apps/zync-app/tests/e2e/os-shell/03-desktop.spec.ts`

**Contract:** desktop.md §Desktop surface (88×92 grid, 40px icons, label pill over image wallpaper, marquee select, icon drag lift 1.05 + FLIP settle, context menu, widget sizes S/M/L, widget chrome opaque). Icons from registry selector (permission-filtered, spec §2); positions from store `desktopIcons`; roving tabindex + Enter opens (spec Accessibility). Wallpaper per SKILL.md §Chrome typography (flat `--bg` + radial accent ≤6%; tenant override seam). Widgets render app-side lazy components from T12 bindings — this task ships the FRAME + drag; a placeholder-free demo widget comes from T19 (Today) — desktop renders empty widget state until then.

**Behavior:** Marquee selection, drop-to-nearest-free-cell, invalid drop springs back, `data-fx="icon-drag"`; double-click/Enter open through store `openWindow` with launchOrigin = icon center.

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/03-desktop.spec.ts --config playwright.os.config.ts`
- Expected: PASS — icon drag persists cell (store assertion), marquee selects, open-from-icon fires with origin, RTL project mirrors grid flow

- [ ] Failing specs → implement → acceptance
- [ ] Commit: `git add apps/zync-app/src/os/desktop apps/zync-app/tests/e2e/os-shell && git commit -m "feat(os): desktop surface — icons, marquee, widgets frame"`

### Task 9: Taskbar

**Wave:** 2 · **Blocks:** T14 · **Blocked by:** T1, T2, T4, T6

**Files:**
- Create: `apps/zync-app/src/os/taskbar/Taskbar.tsx`, `TaskbarApp.tsx`, `TimerChip.tsx`, `TaskbarClock.tsx`
- Test: `apps/zync-app/tests/e2e/os-shell/04-taskbar.spec.ts`

**Contract:** desktop.md §Taskbar (48px, material, content order, 40×40 buttons, running underline indicator, hover preview cards 200ms dwell, click focus/restore/minimize semantics, multi-instance count chip + popover, context menu, press 0.97). Pinned from store; running derived from windows; badges via T12 `badgeQuery` subscriptions (shared cache — ZERO bespoke fetches); TimerChip consumes `src/stores/timer.ts`. A11y: `role="toolbar"`, `aria-pressed` focused app, roving tabindex.

**Behavior:** Minimize animates toward REAL button rect (button exposes rect via registry of refs); clock tabular-nums; tray icon section renders health/jobs stubs OFF (tray panel content is T11's — taskbar only hosts the toggle buttons).

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/04-taskbar.spec.ts --config playwright.os.config.ts`
- Expected: PASS — click semantics (focus→minimize toggle), underline on running, badge renders from seeded query cache, minimize-to-button-rect `[fx:window-minimize]` (coordinates asserted within button bounds)

- [ ] Failing specs → implement → acceptance
- [ ] Commit: `git add apps/zync-app/src/os/taskbar apps/zync-app/tests/e2e/os-shell && git commit -m "feat(os): taskbar"`

### Task 10: Start menu

**Wave:** 2 · **Blocks:** T14 · **Blocked by:** T1, T2, T4, T6

**Files:**
- Create: `apps/zync-app/src/os/start/StartMenu.tsx`, `PinnedGrid.tsx`, `AllApps.tsx`, `StartFooter.tsx`
- Test: `apps/zync-app/tests/e2e/os-shell/05-start-menu.spec.ts`

**Contract:** desktop.md §Start menu (geometry/motion/data-fx="start-open", search field routes to command center, pinned 4-col 96×80 tiles user-ordered drag-persisted, All-apps category list 20ms stagger cap 6, footer user/settings/lock/sign-out reusing `Header.tsx` menu actions). Registry-filtered BEFORE render. `role="dialog"` focus trap + return (spec Accessibility). Footer carries "Reset desktop layout" + "Switch to classic view" (spec Usability).

**Behavior:** Opens from Start button anchored bottom-start, closes on Esc/outside; tile drag reorders + `commitLayout()`.

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/05-start-menu.spec.ts --config playwright.os.config.ts`
- Expected: PASS — permission-hidden module absent, tile reorder persists, focus trap + return verified, `[fx:start-open]`

- [ ] Failing specs → implement → acceptance
- [ ] Commit: `git add apps/zync-app/src/os/start apps/zync-app/tests/e2e/os-shell && git commit -m "feat(os): start menu"`

### Task 11: Tray panel + notification center + toasts

**Wave:** 2 · **Blocks:** T14 · **Blocked by:** T1, T2, T4, T6

**Files:**
- Create: `apps/zync-app/src/os/panels/TrayPanel.tsx`, `NotificationCenter.tsx`, `OsToaster.tsx`
- Test: `apps/zync-app/tests/e2e/os-shell/06-tray-notifications.spec.ts`

**Contract:** desktop.md §System tray panel & notification center (360px, slide+fade 200ms, sections; toast lifecycle in-from-taskbar-corner/5s pause-on-hover/exit-toward-stack, `data-fx="toast-in"`, clear-all stagger). Data: reuse `use-notifications.ts` + `useRealtime` seams UNCHANGED (spec §4 — presentation-only fork from classic `NotificationDropdown`, which stays). OsToaster = position/presentation adapter over existing `toast()` API — call sites untouched. Rows: group by module, mark-read, deep-link open through store `openWindow`. `role="dialog"` + aria-live announcements via T14's announcer seam (emit through a callback prop until T14 lands; wire-up asserted in T14).

**Behavior:** Bell badge count live from shared query; row click opens owning module window at deep link; clear-all staggers out 20ms cap 6.

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/06-tray-notifications.spec.ts --config playwright.os.config.ts`
- Expected: PASS — toast in/pause/exit `[fx:toast-in]`, group/mark-all, deep-link-open action calls `openWindow` with correct location

- [ ] Failing specs → implement → acceptance
- [ ] Commit: `git add apps/zync-app/src/os/panels apps/zync-app/tests/e2e/os-shell && git commit -m "feat(os): tray panel, notification center, OS toasts"`

### Task 12: App-side registry bindings

**Wave:** 2 · **Blocks:** T9 badges (soft — T9 seeds cache directly in tests), T17-T20 · **Blocked by:** T2

**Files:**
- Create: `apps/zync-app/src/os/bindings/tasks.ts`, `bindings/customers.ts`, `bindings/notifications.ts`, `bindings/settings.ts`, `bindings/today.ts`, `bindings/ai.ts` — one `OsModuleBindings` per module (wave-4 tasks later edit ONLY their own binding file — never the aggregator)
- Create: `apps/zync-app/src/os/registry-os.ts` — aggregator importing the six binding files
- Create: `apps/zync-app/src/os/registry-selectors.ts` — permission/plan-filtered surface selectors
- Test: `apps/zync-app/src/os/__tests__/registry-os.test.ts`

**Contract (spec §2):** `OsModuleBindings` type verbatim (titleFromRoute, badgeQuery reusing EXISTING query factories' keys, widgetComponents, todayComponent, keepAlive). Entries: tasks, customers, today, notifications, settings, ai_assistant — each binding file COMPLETE for all fields whose target components already exist; `today.ts`/`ai.ts` ship data fields now, component refs added by T19/T20 in those files only (aggregator untouched after this task). Selectors: `useOsApps()` (registry ∩ enabled ∩ permissions via existing `ModuleGuard` logic), `useOsAppsByCategory()`, `resolveRouteOwner(location) → moduleId | null` (routePrefixes longest-prefix), `defaultDesktopLayout(user)` (role-based icon set, spec §6 reset).

**Behavior:** Compile-time fixtures for Tasks/Customers/Notifications prove binding shape; `resolveRouteOwner('/time-track/x')` style mismatch cases covered; unauthorized module absent from every selector output.

**Acceptance:**
- Run: `pnpm --filter zync-app exec vitest run src/os/__tests__/registry-os.test.ts && pnpm typecheck`
- Expected: PASS — owner resolution table-driven test, permission filtering, badgeQuery keys match existing factories (import-equality assertion)

- [ ] Failing tests → implement → acceptance
- [ ] Commit: `git add apps/zync-app/src/os && git commit -m "feat(os): app-side registry bindings + surface selectors"`

### Task 13: Command center consolidation

**Wave:** 2 · **Blocks:** T14 · **Blocked by:** T1, T2, T6

**Files:**
- Create: `apps/zync-app/src/os/command-center/CommandCenter.tsx`, `command-registry.ts`
- Modify: `apps/zync-app/src/components/shortcuts/CommandPalette.tsx` → replaced by shared implementation (delete after parity)
- Modify: `apps/zync-app/src/features/search/CommandModal.tsx` → replaced (delete after parity)
- Delete: `apps/zync-app/src/shell/use-search.ts` (duplicate path — spec §4)
- Test: `apps/zync-app/tests/e2e/os-shell/07-command-center.spec.ts` + classic-shell palette regression spec

**Contract:** ONE implementation serving BOTH shells (desktop.md §Command center geometry/motion; classic mounts it at current Ctrl+K binding). Sections: apps (T12 selectors), records (existing `useFullSearch` seam UNCHANGED), commands (`>` prefix: window management verbs, registry quickActions, theme, shell-mode switch), recent. **Superset acceptance: enumerate EVERY capability of both legacy palettes in the spec file as a checklist test — each must pass in both shells** (spec §4 explicitly authorizes this classic-UX touch).

**Behavior:** `role="dialog"` trap/return; Enter opens app/record — OS mode via `openWindow`, classic via navigate; 640px top-third 140ms open `data-fx="command-open"`.

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/07-command-center.spec.ts --config playwright.os.config.ts && pnpm --filter zync-app test:e2e -- --grep "command palette"`
- Expected: PASS — parity checklist green in classic AND os projects; `use-search.ts` gone; no orphan imports (`pnpm typecheck`)

- [ ] Enumerate legacy capabilities (read both implementations) → failing parity specs
- [ ] Implement + swap call sites + delete duplicates
- [ ] Run acceptance
- [ ] Commit: `git add -A apps/zync-app/src apps/zync-app/tests && git commit -m "feat(os): unified command center, legacy palettes consolidated"`

### Task 14: OsShell root + mode resolution + chrome split + login checkbox

**Wave:** 3 · **Blocks:** T15-T25 · **Blocked by:** T7-T13

**Files:**
- Create: `apps/zync-app/src/os/OsShell.tsx` — composition root (Desktop, windows layer, Taskbar, panels, CommandCenter, OsToaster, announcer)
- Create: `apps/zync-app/src/os/shell-mode.ts` — resolution precedence
- Create: `apps/zync-app/src/os/Announcer.tsx` — single aria-live region (spec Accessibility)
- Modify: `apps/zync-app/src/main.tsx` — `AuthenticatedAppChrome` boundary (spec §1: palette/launcher/shortcuts/toasts/banners per mode; ChatLauncher suppressed in OS)
- Create: `apps/zync-app/src/features/auth/AuthenticatedGate.tsx`, `apps/zync-app/src/features/auth/session-client.ts`; modify `apps/zync-app/src/hooks/useSession.ts` — keep both shells unmounted during bootstrap, silently refresh once through a deduplicated client, clear client state and redirect on terminal failure, and use the authenticated `/*` parent route required by descendant routes.
- Modify: login form — "Login to Legacy Site" checkbox (session-scoped classic override)
- Test: `apps/zync-app/test/auth-session-recovery.test.ts`, `apps/zync-app/test/authenticated-gate.test.ts`, `apps/zync-app/test/authenticated-routing-source.test.ts`
- Test: `apps/zync-app/tests/e2e/os-shell/08-shell-mode.spec.ts`

**Contract (spec §1):** Resolution precedence EXACT: session override (login checkbox / `?shell=` param) > tenant `force_shell` > user pref `ui_shell` > default `'os'`; viewport <768px or coarse-only pointer → forced classic. Mode-aware URL normalization sends OS `/` to `/desktop` and classic `/desktop` to `/`; NEVER self-redirect `/` (spec §5). Boot sequence per desktop.md §Boot (cached-layout immediate render, staggered window restore, ≤400ms ramp, NEVER splash on return). Shell-root error boundary → safe card (Reload / Reset layout / Sign out / diagnostic copy). Announcer API: `announce(message)` consumed by store events (open/close/minimize/focus/snap/toasts).

**Behavior:** Checkbox unchecked (default) → OS shell; checked → classic, no persistence beyond session; classic set of chrome globals byte-identical when classic resolved (smoke test); no double shortcut binding (one provider, mode-keyed map). Missing or terminally expired sessions redirect with the encoded return path before protected children mount; 401 is never retried as a generic transient failure.

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/08-shell-mode.spec.ts --config playwright.os.config.ts && pnpm --filter zync-app test:e2e -- --grep "classic shell smoke"`
- Expected: PASS — precedence matrix test (8 combos), legacy checkbox honored, mobile viewport forces classic, `/`→`/desktop` in OS, `/desktop`→`/` in classic, no root self-redirect, boot-from-cache timing assertion, announcer emits on open/focus/minimize

- [ ] Failing specs → implement → acceptance
- [ ] Commit: `git add apps/zync-app/src apps/zync-app/tests && git commit -m "feat(os): OsShell root, mode resolution, chrome split, legacy-login checkbox"`

### Task 15: URL ↔ window engine

**Wave:** 3 · **Blocks:** T17-T20 · **Blocked by:** T14

**Files:**
- Create: `apps/zync-app/src/os/url-sync.ts` — history integration engine
- Test: `apps/zync-app/src/os/__tests__/url-sync.test.ts` + `tests/e2e/os-shell/09-url-contract.spec.ts`

**Contract (spec §5 verbatim — agents must NOT invent):** URL always = focused window's location; focus → `replaceState`; in-window nav → `pushState`; every OS entry carries `history.state.zync = { shell: 'os', instanceId, moduleId }`; popstate precedence instanceId-match → route-owner (T12 `resolveRouteOwner`) → prefix fallback (stateless entries only); deep-link cold entry per spec; `/desktop` when no windows; panels never touch URL; two tabs independent (no BroadcastChannel).

**Behavior:** Unit tests drive a mock history through the full matrix: focus-switch chains, back into closed-window entry (reopens via owner), back into other-window entry (focuses, no navigate), multi-instance same-prefix disambiguation via instanceId, `/customers/:id` + `/tasks/:id` cold-load acceptance (spec route-addressability).

**Acceptance:**
- Run: `pnpm --filter zync-app exec vitest run src/os/__tests__/url-sync.test.ts && pnpm --filter zync-app exec playwright test tests/e2e/os-shell/09-url-contract.spec.ts --config playwright.os.config.ts`
- Expected: PASS — full popstate precedence matrix, replaceState-on-focus spy counts, cold deep links open correct app+record

- [ ] Failing matrix tests → implement → acceptance
- [ ] Commit: `git add apps/zync-app/src/os apps/zync-app/tests && git commit -m "feat(os): URL-window contract engine (history.state.zync)"`

### Task 16: Layout persistence client

**Wave:** 3 · **Blocks:** T21 · **Blocked by:** T5, T14

**Files:**
- Create: `apps/zync-app/src/os/layout-persistence.ts`
- Test: `apps/zync-app/src/os/__tests__/layout-persistence.test.ts` + persistence round-trip in `tests/e2e/os-shell/10-persistence.spec.ts`

**Contract (spec §6):** Subscribes ONLY to `commitLayout()` events; envelope `{ v: 1, writer: sessionId (per-tab, sessionStorage), committedAt, data }`; zod-parse on every read, invalid → discard + defaults + "layout was reset" toast; localStorage mirror key `zync.shell.<tenantId>.<userId>.desktop` written at commits; PUT debounce 2s; 409 flow = deterministic conflict rule verbatim from spec §6; retry/backoff + tray warning badge on persistent failure; reset = DELETE + clear mirror + `defaultDesktopLayout()`.

**Behavior:** Boot: mirror → instant layout; server GET reconciles via conflict rule. NO write during drag (test asserts zero writes between pointerdown and pointerup).

**Acceptance:**
- Run: `pnpm --filter zync-app exec vitest run src/os/__tests__/layout-persistence.test.ts && pnpm --filter zync-app exec playwright test tests/e2e/os-shell/10-persistence.spec.ts --config playwright.os.config.ts`
- Expected: PASS — commit-point-only writes, 409 both branches (server-newer / local-newer), corrupt-payload reset path, e2e mutate→reload→assert round-trip

- [ ] Failing tests → implement → acceptance
- [ ] Commit: `git add apps/zync-app/src/os apps/zync-app/tests && git commit -m "feat(os): layout persistence — commit points, envelope, conflict rule"`

### Task 17: Tasks pilot

**Wave:** 4 · **Blocks:** — · **Blocked by:** T15

**Files:**
- Modify: Tasks feature — modal-background normalization (location.state.background pattern → full-page detail inside windows, spec §3)
- Modify: `apps/zync-app/src/os/bindings/tasks.ts` ONLY (never registry-os.ts) — finalize titleFromRoute
- Test: `apps/zync-app/tests/e2e/os-shell/11-tasks-pilot.spec.ts`

**Contract:** Tasks opens windowed at `defaultRoute`; ALL internal navigation window-scoped (list→detail→edit, filters via search params); no `location.state` dependency remains (grep-gated); quickActions ("New task") open window at create route; badge = existing due-count query key.

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/11-tasks-pilot.spec.ts --config playwright.os.config.ts && grep -rn "state.background" apps/zync-app/src/features/tasks | wc -l`
- Expected: PASS + grep count 0 — full task CRUD inside window, URL follows focus, deep link `/tasks/:id` cold-opens windowed

- [ ] Failing pilot spec → normalize + bind → acceptance
- [ ] Commit: `git add apps/zync-app/src apps/zync-app/tests && git commit -m "feat(os): Tasks pilot windowed"`

### Task 18: Customers pilot (multi-instance)

**Wave:** 4 · **Blocks:** — · **Blocked by:** T15

**Files:**
- Modify: `apps/zync-app/src/os/bindings/customers.ts` ONLY — titleFromRoute = customer name (`supportsMultipleInstances` lives in manifest data, T2)
- Modify: Customers feature ONLY if a module-global mutable UI state violation surfaces (spec §3 invariant — verified none today)
- Test: `apps/zync-app/tests/e2e/os-shell/12-customers-pilot.spec.ts`

**Contract:** Two simultaneous Customers windows on different records: zero cross-bleed (forms, filters, detail state independent); shared query cache intact (same record in two windows stays consistent); URL follows focused instance; instanceId disambiguation on back (T15 matrix already covers engine — this spec covers real module).

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/12-customers-pilot.spec.ts --config playwright.os.config.ts`
- Expected: PASS — dual-instance isolation, edit-in-A-not-in-B form state, focus/URL switching

- [ ] Failing spec → bind/fix → acceptance
- [ ] Commit: `git add apps/zync-app/src apps/zync-app/tests && git commit -m "feat(os): Customers pilot multi-instance"`

### Task 19: Today app

**Wave:** 4 · **Blocks:** — · **Blocked by:** T15

**Files:**
- Create: `apps/zync-app/src/os/apps/today/TodayApp.tsx` + section components + one desktop widget (S) proving the widget pipeline
- Modify: `apps/zync-app/src/os/bindings/today.ts` ONLY — component refs + contributions
- Test: `apps/zync-app/tests/e2e/os-shell/13-today.spec.ts`

**Contract (spec §4):** Aggregates registered `todayContribution` components (Tasks due, unread notifications, running timer, pinned records) — each consumes that module's EXISTING queries; ZERO new backend endpoints. Auto-opens maximized on first OS login (once, dismissible — persisted flag in layout payload). Sections skeleton per Loading Law; empty states designed.

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/13-today.spec.ts --config playwright.os.config.ts`
- Expected: PASS — contributions render from seeded caches, first-login auto-open once, widget renders on desktop

- [ ] Failing spec → implement → acceptance
- [ ] Commit: `git add apps/zync-app/src apps/zync-app/tests && git commit -m "feat(os): Today app + first widget"`

### Task 20: AI window + Settings window

**Wave:** 4 · **Blocks:** — · **Blocked by:** T15

**Files:**
- Create: `apps/zync-app/src/os/apps/AiWindow.tsx` — hosts existing `ChatPanel`
- Modify: `apps/zync-app/src/features/ai-chat/ChatPanel.tsx` — optional `context?: { moduleId: string; location: SerializedLocation }` prop ONLY (spec §4 falsifier: never fork)
- Modify: `apps/zync-app/src/os/bindings/ai.ts` ONLY — AiWindow component ref (settings complete since T12)
- Test: `apps/zync-app/tests/e2e/os-shell/14-ai-settings.spec.ts`

**Contract:** Opening AI focuses right-side snapped window with ChatPanel (floating ChatLauncher suppressed in OS — asserted); context prop prepends "user is viewing <module><path>" system line to next message (client-side, no backend change). Settings opens classic settings routes windowed (self-contained pages, spec §2).

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/14-ai-settings.spec.ts --config playwright.os.config.ts`
- Expected: PASS — AI opens snapped-right, context line present in outbound payload (network intercept), no floating bubble in OS mode, settings pages functional in window

- [ ] Failing spec → implement → acceptance
- [ ] Commit: `git add apps/zync-app/src apps/zync-app/tests && git commit -m "feat(os): AI window surface + Settings windowed"`

### Task 21: Onboarding, escape hatches, telemetry

**Wave:** 5 · **Blocks:** T25 · **Blocked by:** T16, T17-T20

**Files:**
- Create: `apps/zync-app/src/os/onboarding/CoachMarks.tsx`
- Modify: safe card + tray — diagnostic copy button; "Show open windows" command into command-registry
- Modify: telemetry seam — counters `layout_reset`, `window_cap_hit`, `classic_escape_used`, `deep_link_unowned`, `shell_crash`
- Test: `apps/zync-app/tests/e2e/os-shell/15-onboarding-support.spec.ts`

**Contract (spec Usability & support):** 4-step coach-mark tour (taskbar/start/windows/command-center) first OS login, skippable, never repeats (flag in layout payload); escape hatches in start footer + safe card; telemetry fire-and-forget via existing analytics seam.

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/15-onboarding-support.spec.ts --config playwright.os.config.ts`
- Expected: PASS — tour once-only, show-open-windows focuses, counters fire on triggers (intercept)

- [ ] Failing spec → implement → acceptance
- [ ] Commit: `git add apps/zync-app/src apps/zync-app/tests && git commit -m "feat(os): onboarding tour, support hatches, telemetry"`

### Task 22: Visual baselines + perceptual artifacts

**Wave:** 5 · **Blocks:** T25 · **Blocked by:** T17-T20

**Files:**
- Create: `apps/zync-app/tests/e2e/os-shell/16-baselines.spec.ts` + committed `toHaveScreenshot` baselines
- Create: `apps/zync-app/tests/e2e/os-shell/17-keyframes.spec.ts` — keyframe/video artifact generation
- Create: `apps/zync-app/tests/e2e/os-shell/artifact-completeness.ts`

**Contract (spec Verification §5-6):** Baselines RTL+LTR × dark+light for the 8 listed surfaces (empty desktop, window pair, start, tray, notification center, command center, Today, snap preview armed); masked dynamics, seeded data, bundled fonts. Keyframes: 0/~40/~80% per data-fx effect × RTL/LTR into `test-results/os-shell/<fx-id>/`; JS-driven effects via video frame extraction; completeness script fails on ANY missing artifact for the data-fx inventory (enumerated from slopgate scan).

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/16-baselines.spec.ts tests/e2e/os-shell/17-keyframes.spec.ts --config playwright.os.config.ts && node apps/zync-app/tests/e2e/os-shell/artifact-completeness.ts`
- Expected: PASS all three; artifact tree complete for every data-fx id

- [ ] Implement → acceptance
- [ ] Commit: `git add apps/zync-app/tests && git commit -m "test(os): visual baselines + perceptual keyframe artifacts"`

### Task 23: Accessibility sweep

**Wave:** 5 · **Blocks:** T25 · **Blocked by:** T21 (ordering within wave — may touch shared src/os files)

**Files:**
- Modify: any `src/os/**` component failing the contract (fix in place)
- Test: `apps/zync-app/tests/e2e/os-shell/18-a11y.spec.ts`

**Contract (spec Accessibility contract — full section):** axe-core scan every shell surface (no serious/critical); roles per spec (no role="application", regions, toolbar, dialogs); roving tabindex groups; announcer coverage (open/close/minimize/restore/focus/snap/toast/reset); focus-return matrix (close→launcher, minimize→taskbar button, panel close→invoker); full keyboard-only WM session (open→move→snap→minimize→restore→close without pointer).

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/18-a11y.spec.ts --config playwright.os.config.ts`
- Expected: PASS — axe clean, focus matrix green, keyboard-only session completes

- [ ] Failing sweep spec → fix components → acceptance
- [ ] Commit: `git add apps/zync-app/src apps/zync-app/tests && git commit -m "fix(os): accessibility contract sweep"`

### Task 24: Performance budget assertions

**Wave:** 5 · **Blocks:** T25 · **Blocked by:** T17-T20

**Files:**
- Create: `apps/zync-app/tests/e2e/os-shell/19-perf.spec.ts`
- Modify: `src/os/**` ONLY if a budget fails (fix, don't relax budget)

**Contract (spec §8):** CDP 4x-throttle: chrome-interactive ≤800ms from cached layout; `window-open` p95 frame ≤24ms + zero long-task >100ms with 3 windows; minimized-unmount guards honored (in-flight mutation blocks unmount — test with a held mutation); restore-remount ≤200ms.

**Acceptance:**
- Run: `pnpm --filter zync-app exec playwright test tests/e2e/os-shell/19-perf.spec.ts --config playwright.os.config.ts`
- Expected: PASS with trace evidence attached to test-results

- [ ] Implement assertions → fix any breach → acceptance
- [ ] Commit: `git add apps/zync-app/tests apps/zync-app/src && git commit -m "test(os): performance budget assertions"`

### Task 25: Wave-close program gates (controller task)

**Wave:** 6 · **Blocks:** — (terminal) · **Blocked by:** T21-T24

**Files:**
- Test artifacts only: mutation sweep log, vision-judge verdict table, fault-injection audit log, classic regression run

**Contract (spec Verification tiering — milestone gates):**
1. `mutation-audit.ts` full sweep — 100% of KILLed items fail their spec.
2. Vision judge: `cdx exec -i <keyframes>` — `gpt-5.4-mini`/low sweep all data-fx items, `gpt-5.5`/low re-judge fix-needed; judge prompt = item acceptance + zc-ui-ux-designer doctrine; verdict table committed as run artifact; any fix-needed → dispatch fix task + re-judge.
3. Fault injection: break ≥5 random effects at source + 2 WM faults (disable focus reorder, disable snap arming); suite must catch each; revert; any miss → suite-fix task + redraw.
4. Classic regression: full existing e2e suites green; CI `os-regression` proven red-then-green on scratch PR.

**Acceptance:**
- Run: mutation sweep command + judge script + existing `pnpm test:e2e` suites
- Expected: mutation 100%, verdict table all-pass, fault log 7/7 caught, classic suites green — all four artifacts attached

- [ ] Execute gates in order, fix-loop until clean
- [ ] Commit artifacts: `git add docs/plans/audit && git commit -m "chore(os): Plan A program gate artifacts"`

---

## Decision enumeration

- No OPEN gated records. The one graph-affecting decision (OS-default + legacy-login checkbox) was resolved by the user 2026-07-10 and is baked into spec §1 / T14. T3 spike failure = runtime escalation (spec falsifier), not a pre-flight gate.
- Run-level: `base_branch: master`, `preset: codex` already in session meta. Landing: house default (orchestrator merges after gates per project practice).

## Self-review notes

- Spec coverage: §1→T14, §2→T2+T12, §3→T3+T4+T7, §4→T8-T13+T19+T20, §5→T15, §6→T5+T16, §7→T5, §8→T24, Verification→T6+per-task specs+T22+T25, Accessibility→T7-T14 inline + T23, Usability→T21, pilots→T17+T18. Acceptance criteria 1-14 each map to ≥1 task acceptance.
- Same-wave file overlap: RESOLVED structurally — per-module binding files (`src/os/bindings/*.ts`); each wave-4 task edits only its own file; aggregator `registry-os.ts` frozen after T12. Zero same-wave same-file pairs remain (checked every wave).
- Body-bloat: no implementation bodies; T1 is the only near-literal task and its source is the skill file (pointer, not paste).
