# Zync OS — Plan B: Mobile OS Shell + Module Migration Sweep (design)

Slug: `zync-os-mobile` · Date: 2026-07-11 · Audience: AI coding agents (run-plan codex seats, reviewers, vision judges). Source vision: `Zync OS Design.pdf` (pp. 3, 16–22, 43 Phase 3–4). Predecessor: Plan A (`docs/specs/2026-07-10-zync-os-desktop-design.md`, slug `zync-os-desktop`) — **LANDED on master 2026-07-10 (merge `a37d90d1`, 25/25 tasks)**; this spec builds on the LANDED code (anchors below verified against it), never on Plan A prose where the two differ. Successor: Plan C (`zync-os-advanced`).

## Goal

Two deliverables, one plan:

1. **Mobile OS shell** (`MobileShell`): home screen with icon grid + dock + widgets, app drawer, full-screen app frames with OS-style Back/Home/Recents navigation, recent-apps card switcher, notification shade, bottom sheets — per `references/mobile.md` physics. Replaces the landed "mobile forces classic" rule: mobile OS is the DEFAULT on mobile devices, same precedence chain as desktop.
2. **Module migration sweep**: every remaining module area gains `os` manifest metadata + app-side bindings so it opens windowed on desktop AND full-screen on mobile. After the sweep, the "no-os-metadata → classic deep-link" degradation path has ZERO remaining **manifest-backed in-scope** occupants (`/inventory` and the §2 UNOWNED allowlist intentionally remain on classic fallback; classic shell survives as the legacy fallback only).

Premium feel is enforced by `.claude/skills/zc-ui-ux-designer` (SKILL.md + `references/mobile.md`; desktop.md for sweep windows) — REQUIRED READING for every UI task; vision judge grades against it. This spec does not restate motion/material/gesture values.

## Non-goals (Plan B)

- App folders (desktop + mobile home) — Plan C.
- Virtual desktops, saved workspaces, focus mode, lock screen, taskbar position, activity center, tenant switcher — Plan C.
- Portal/staff/admin shells, white-label shell theming — Plan C.
- AI context-action system — separate spec (mobile ships the AI app full-screen hosting existing `ChatPanel`).
- Share-target / camera-capture PWA extensions — deferred (existing upload flows work inside apps).
- New push infrastructure — already shipped (`packages/notifications`, `sw.ts` push handler, `usePushSubscription`); Plan B deep-links notification taps through §5 and FIXES the service-worker cache-isolation defect (§8 — in scope because Plan B owns the mobile/PWA surface).
- Module content REDESIGN. The sweep wraps existing (already responsive) module screens in the correct shell and fixes shell-contract violations. Rebuilding tables-as-cards per module is NOT in scope beyond what shared primitives provide (§7).

## Current-state anchors (verified against LANDED master, 2026-07-11)

- Shell code: `apps/zync-app/src/os/` — `window-router.tsx`, `os-shell-store.ts`, `url-sync.ts`, `shell-layout-schema.ts`, `shell-mode.ts`, `registry-os.ts`, `registry-selectors.ts`, `command-registry.ts`, `wm-geometry.ts`, `OsShell.tsx`, `Announcer.tsx`, surfaces under `desktop/ taskbar/ panels/ window/ onboarding/`, bindings `bindings/{tasks,customers,today,notifications,settings,ai}.ts`.
- OS contract type is **`ZyncModuleOs` in `packages/modules/src/manifest.ts`** (there is NO `os-types.ts`). Landed shape (verbatim — extend THIS, never invent fields):

```ts
export interface ZyncModuleOs {
  desktop: { minWidth: number; minHeight: number; supportsMultipleInstances: boolean }
  routing: { routePrefixes: string[]; defaultRoute: string }
  quickActions: readonly { id: string; label: string; route: string }[]
  badge?: { badgeId: string }
  widgets?: readonly { id: string; title: string }[]
  search?: { provider: 'route' | 'module' }
}
```

- Modules WITH `os` today: `tasks` (`/tasks`), `customers` (`/customers`), `ai_assistant` (owns `/settings/ai`, `/admin/ai`), + osApps `today` (owns `/`, `/dashboard`, `/my-work`), `notifications` (owns `/notifications`, `/profile/notifications`), `settings` (owns `/settings`).
- Shell mode: `shell-mode.ts` — `resolveShellMode()` returns `'classic'` whenever `viewportWidth < 768 || coarsePointerOnly` (the line this plan replaces); precedence otherwise `sessionOverride ?? tenantForce ?? userPreference ?? 'os'`; session key `zync.shell.override`; `?shell=` param; `rewriteClassicDesktopPath('/desktop' → '/')`.
- Persistence: migration `0069_shell_layouts` — `shell_layouts (tenant_id, user_id, device_class) PK, payload jsonb, version int`; `user_preferences.ui_shell` default `'os'`; `tenant_settings.force_shell` nullable. API: `GET/PUT/DELETE /api/shell/layout?device=` (`apps/zync-api/src/routes/shell-layout.ts`) — **landed route HARD-REJECTS `device !== 'desktop'` (400) and validates its OWN strict envelope `{ v: 1, writer, committedAt, data: { desktopIcons, pinnedTaskbar, widgets, windows } }` with a SEPARATE zod copy that has NO `onboarding` field**; the client zod (`apps/zync-app/src/os/shell-layout-schema.ts`, flat shape WITH `onboarding`) has drifted from it — §6 unifies. Landed localStorage mirror key is GLOBAL `zync.shell.layout` (`OsShell.tsx` `LAYOUT_CACHE_KEY`) — not principal-partitioned; §6 fixes.
- Route table: `apps/zync-app/src/routes/index.tsx` (prefixes in §2 table). PWA: vite-plugin-pwa injectManifest → `src/sw.ts` (precache, nav fallback, **`/api/*` NetworkFirst shared `api-cache` — the §8 defect**, `files-cache` for PDFs/R2, push handler, `/offline.html`); `useServiceWorker` update flow; `components/pwa/` classic mobile chrome (stays for classic shell).

## Architecture

### 1. Shell mode × device class (supersedes the landed `resolveShellMode` mobile rule)

- `device_class: 'desktop' | 'mobile'` resolved at boot in `shell-mode.ts`: mobile ⇔ `viewportWidth < 768 || coarsePointerOnly` (the SAME predicate that today forces classic — reused as the class fork; constant and media-query logic stay). PWA standalone display-mode does NOT change class.
- User pref `ui_shell_device: 'auto' | 'desktop' | 'mobile'` (default `'auto'`; new `user_preferences` column, same PATCH path as `ui_shell`, CHECK-constrained) — tablet override, surfaced in Interface Settings. Non-auto wins over detection.
- `resolveShellMode` precedence UNCHANGED (session > tenant force_shell > ui_shell > `'os'`), now applied per class: `os`+desktop → `OsShell`; `os`+mobile → `MobileShell` (NEW); `classic` → classic responsive (both classes). The `viewportWidth < DESKTOP_MIN_WIDTH || coarsePointerOnly → 'classic'` early-return is DELETED. "Login to Legacy Site" checkbox and `?shell=classic` work on mobile unchanged.
- Resize/orientation crossing the boundary mid-session does NOT hot-swap shells; one-time toast "Reload to switch layout" + Reload action. Telemetry counter `shell_boundary_crossed`.
- `AuthenticatedAppChrome` third mode: mobile-OS set = command center (full-screen variant), shade, toasts (top, under status strip), NO ChatLauncher, no advertised shortcut map (hardware-keyboard combos stay registered).

### 2. Registry: `mobile` field + route-ownership table (pinned HERE, not delegated)

Extend the landed `ZyncModuleOs` (additive, pure data — same JSON-serializable law):

```ts
mobile?: {
  presentation: 'fullscreen'   // sole value in Plan B; widen ONLY when a real second presentation ships (no speculative variants)
  showInAppDrawer?: boolean    // default true
  dockDefault?: boolean        // default false; default dock = first 4 dockDefault modules in manifest order
  homeDefault?: boolean        // seeds default home grid (permission-filtered, same rule as desktop default icons)
}
```

**Route-ownership table (NORMATIVE — the audit task VERIFIES this table against the live route registry and nav groupings; it may ESCALATE a conflict, never re-decide):**

| Route prefix | Owner (ModuleId) |
|---|---|
| `/tasks` | tasks (landed) |
| `/customers` | customers (landed) |
| `/projects` | projects |
| `/calendar` | calendar |
| `/time-track`, `/time/approvals` | time_management (exact `/time` is a `Navigate` redirect alias → `/time-track`, NOT an owned prefix — but `/time/approvals` is a live non-redirect route and IS owned) |
| `/invoices`, `/receipts`, `/payments` | invoices |
| `/expenses`, `/vendors` | expenses |
| `/crm` | crm |
| `/marketing`, `/proposals`, `/contracts` | marketing |
| `/kb` | kb |
| `/reports`, `/analytics` | reports |
| `/contractors`, `/payouts` | contractor_payouts |
| `/integrations`, `/profile` | settings (osApp) |
| `/`, `/dashboard`, `/my-work` | today (landed) |
| `/notifications` | notifications (landed; `/profile/notifications` is a redirect alias → `/settings/notifications`, canonical owner settings — NOT in this table) |
| `/settings` | settings (landed) |
| `/settings/ai`, `/admin/ai` | ai_assistant (landed — longest-prefix beats settings' `/settings` and the `/admin` allowlist entry) |
| billing module | `routePrefixes: []`, `defaultRoute: '/settings/billing'` — opens as a settings deep link, no prefix ownership |

`UNOWNED_ROUTE_PREFIXES` allowlist (exported const in `registry-os.ts`): auth/onboarding routes, `/portal`, `/contractor-portal`, `/admin` (EXCEPT `/admin/ai` — owned, longest-prefix), `/design-system`, `/search` (OS mode maps the `/search` deep link to opening the command center over home/desktop), `/offline.html`, **`/inventory`** (module has no manifest id yet — pending inventory-management plan; classic fallback until that plan registers it and moves the prefix into the table).
- **Redirect aliases resolve BEFORE ownership** (canonical-path rule): `/dashboard` → `/` (today) and `/profile/notifications` → `/settings/notifications` (settings) are router `Navigate` redirects — ownership applies to the post-redirect canonical path. The manifest task REMOVES `/profile/notifications` from the landed notifications `routePrefixes` (it is an alias, not a canonical route).
- **Completeness invariant:** vitest test statically imports the route registry and fails on any top-level prefix that is neither owned by exactly one module nor allowlisted (redirect-only paths exempt). Doubly-owned → fail. Longest-prefix wins at resolution time.
- **Single-owner file rule:** ALL manifest `os` metadata for the sweep is written by ONE Wave-1 task (`manifest.ts` is monolithic — parallel cluster tasks MUST NOT touch it). Same task adds `mobile` blocks to already-landed entries and the aggregator imports in `registry-os.ts`; both files then freeze for the plan. Cluster tasks own only their disjoint `bindings/<module>.ts` + module route dirs + specs.

### 3. Mobile shell store (`apps/zync-app/src/os/mobile/mobile-shell-store.ts`)

New Zustand store — desktop `os-shell-store` is NOT reused (rect/z/snap vs stack/suspend are disjoint state machines). Registry, bindings, url-sync engine, layout-persistence client, and `shell-layout-schema.ts` ARE reused.

```ts
type MobileApp = {
  instanceId: string
  moduleId: string
  location: SerializedLocation   // same type + state/key exclusion as landed desktop store
  title: string
  suspendedAt: number | null
}
type MobileShellState = {
  stack: MobileApp[]             // MRU, last = foreground; ONE instance per moduleId (mobile ignores supportsMultipleInstances)
  foregroundId: string | null    // null = home
  homeIcons: { moduleId: string; cell: number }[]
  dock: string[]                 // max 4
  widgets: { widgetId: string; moduleId: string; cell: number }[]
  panels: { shade: boolean; drawer: boolean; recents: boolean; commandCenter: boolean }  // max one true
}
```

- Actions: `openApp(moduleId, location?)`, `goHome()`, `goBack()`, `dismissApp(instanceId)`, `dismissAll()`, `commitLayout()` (commit points: icon/widget/dock drop only; stack never persisted).
- **Unified discard-guard contract (`canDiscard`)** — single predicate in `apps/zync-app/src/os/guards.ts`, shared by desktop AND mobile (desktop close/minimize-unmount adopts it in the same task; Plan C reuses it for workspace-apply / module-disable / tenant-switch / desktop-delete):
  `canDiscard(instanceId) → { ok: true } | { ok: false; reason: 'mutation-in-flight' | 'dirty-form' | 'keep-alive' }`
  **Signal plane (pinned — instances have no dirty/mutation state today, and NO shared mutation hook exists; dependency direction matters because `packages/ui` cannot import app files):**
  - **Context lives in `packages/ui`**: new `packages/ui/src/shell/blocker-context.ts` exports `ShellBlockerContext: React.Context<{ registerBlocker(reason: 'mutation-in-flight' | 'dirty-form' | 'keep-alive'): () => void } | null>` (default `null` = no-op). zync-app PROVIDES the value: WindowRouter (windows) and MobileAppFrame (apps) wrap module content in a provider that bridges to the registry below. Dependency-safe: ui defines the seam, app supplies the implementation — ui never imports app code.
  - `guards.ts` owns a module-level blocker registry `Map<instanceId, Set<Blocker>>`, `Blocker = { reason }`; `registerBlocker` returns a release fn, auto-released on effect cleanup/unmount.
  - **`'dirty-form'`**: two wirings. (1) `packages/ui` also exports hook `useShellBlocker(reason: BlockerReason, active: boolean): void` (registers while `active`, releases on false/unmount; no-op outside a provider); the shared `Form`/dialog layer calls it from React-Hook-Form dirty state. (2) Ad-hoc dirty editors (local `isDirty`/`unsaved` state outside the shared Form) are AUDITED by task (a) (grep work-list) and each cluster's sweep wires them via `useShellBlocker('dirty-form', isDirty)` — added to the per-module DONE definition (§7 item 2).
  - **`'mutation-in-flight'` — wrapper + sweep (no shared hook exists today; ~268 direct `useMutation()` calls across ~121 files):** new `packages/ui/src/shell/use-app-mutation.ts` exporting `useAppMutation` — identical signature to TanStack `useMutation`, plus auto `registerBlocker('mutation-in-flight')` for each in-flight mutation via `ShellBlockerContext` (no-op outside a shell instance). Sweep task: mechanical codemod of ALL direct `useMutation` call sites in `apps/zync-app/src` to `useAppMutation` (import + identifier rename only; options untouched), enforced by an ESLint `no-restricted-imports` rule banning `useMutation` from `@tanstack/react-query` in `apps/zync-app/src/**` (wrapper file exempt). Runs as its own Wave-1 task; grep-gated zero direct call sites.
  - **`'keep-alive'`**: NOT a manifest flag — module code registers it through the same context (e.g. the active-timer effect while a timer runs). No new manifest field.
  - Precedence when multiple: `mutation-in-flight` > `dirty-form` > `keep-alive`. Unit matrix: register/release/unmount-cleanup/precedence/no-op-outside-provider.
  Policy: `ok:false` blocks silent discard everywhere. Recents swipe-dismiss / close-all on a blocked app open a confirmation sheet naming the app and reason ("Discard unsaved changes?") — confirm proceeds, cancel keeps it. `mutation-in-flight` can never be force-discarded (confirm disabled until settled). LRU-unmount (cap eviction) skips blocked apps entirely.
- Suspension: mounted-app cap **6**; beyond, LRU suspended apps unmount (guards above; unmounted stay in `stack` as icon+title cards, remount at `location`). **Cap exhaustion (every eviction candidate blocked):** the open/remount is REJECTED — current state retained, toast names the blocking apps, `app_cap_hit` incremented. Unit-tested in the store matrix (same rule in Plan C's desktop store).
- App frame renders module content through the SAME `WindowRouter` inside `MobileAppFrame` (`isolation: isolate`, module error boundary, full-frame crash card: Reload / Reset state / Close).

### 4. Mobile surfaces

Components under `apps/zync-app/src/os/mobile/`: `MobileShell`, `MobileHome`, `MobileAppIcon`, `MobileDock`, `AppDrawer`, `MobileAppFrame`, `MobileNavigationBar`, `RecentApps`, `NotificationShade`, `MobileTopStrip`. **`BottomSheet` lives in `packages/ui`** (`packages/ui/src/sheet/BottomSheet`) — the §7 dialog adapter renders it, and ui cannot import app code (same dependency rule as §3); shell surfaces consume it from there. Companion context also in ui: `ShellPresentationContext` (`'desktop' | 'mobile-frame'`, default `'desktop'`) — MobileAppFrame provides `'mobile-frame'`; the dialog adapter reads it for `'auto'`. All geometry/motion/gesture physics per `references/mobile.md`. Surface contracts on top:

- **Home**: icons from the SAME permission/plan-filtered selectors (`registry-selectors.ts`); top strip = tenant mark + search pill (opens command center) + bell/count; dock; widgets share desktop widget components at mobile spans.
- **App drawer**: full registry list (alphabetical + `navGroup` groups), local search filter, badges via landed `badge.badgeId` seam, long-press quick actions (landed `quickActions` — route-only). Drawer button twin for the swipe-up gesture.
- **Navigation bar**: Back · Home · Recents, 48px targets, inside `MobileAppFrame` only. Back = per-app history pop; at app root → `goHome()`. Browser back = SAME semantics via §5. Never binds screen-edge swipes.
- **Recents**: MRU cards, live subtree if mounted else icon+title, swipe-up dismiss through `canDiscard` (§3), tap = `app-open` expansion from card rect, "Close all" (guarded, lists blockers).
- **Notification shade**: pull from top strip or bell tap. Top→bottom: quick-settings chips (theme, language; availability/sync chips ONLY if the audit confirms an existing seam, else omitted), running timer chip (stores/timer.ts) with stop, notifications grouped by module (existing `use-notifications.ts` + realtime seams, presentation-only), swipe-row-dismiss, mark-all. Row tap deep-links via `openApp`.
- **Command center**: SAME implementation as landed desktop (`command-registry.ts` + panels), full-screen presentation variant; Enter routes through `openApp`. No third implementation.
- **Bottom sheets**: `BottomSheet` primitive per mobile.md detents; consumed by the dialog adapter (§7) and shade/quick-action surfaces.
- **System toasts**: top, below strip, full-width minus 16px margins; same `toast()` adapter seam (position variant only).

### 5. URL ↔ app-stack contract (extends landed url-sync — same engine, mobile branch)

- `url-sync.ts` gains a mobile mode (mode passed at init): popstate precedence and route-owner resolution identical; "focus window" verb becomes "foreground app".
- **History state shape (extends the LANDED wrapper — the `zync` envelope stays; the inner union gains `surface`):**

```ts
// landed: interface OsHistoryState { zync?: { shell: 'os'; instanceId?; moduleId? } }
type OsHistoryState = { zync?:
  | { shell: 'os'; surface: 'desktop' | 'home' }            // no foreground window/app (replaces bare {shell:'os'} desktopState)
  | { shell: 'os'; instanceId: string; moduleId: string }   // foreground app/window (landed, unchanged)
}
```

  Desktop's `desktopState()` migrates to `{ zync: { shell:'os', surface:'desktop' } }` in the same task; `hasOsIdentity` narrowing extended for `surface`.
- URL = foreground app's location. `goHome()` → `pushState(homeState, '', '/home')`; if the top entry is already `/home`, `replaceState` instead (no consecutive home entries). Foregrounding from recents/home → `pushState` the app's location. In-app nav → `pushState`. Panels NEVER touch the URL.
- popstate matrix: `surface:'home'` → foreground=null, show home. `instanceId` matching a live app → foreground at entry URL; dead instanceId → route-owner reopen; no owner → classic fallback (landed rule). NO zync state (pre-OS entry) → route-owner resolution on pathname.
- **Cold-entry seeding:** deep-link cold entry `replaceState`s the current entry with the opened app's state; a `/home` entry is NOT synthesized beneath it — browser back at a cold-entry app root exits the site (standard PWA behavior; the nav-bar Home button is the in-app path home). Back-returns-to-home applies only to warm navigation where a real `/home` entry exists.
- `/home` is the mobile twin of `/desktop`: OS-only; `rewriteClassicDesktopPath` generalizes to both aliases (`/desktop`|`/home` → `/` in classic; cross-rewrite between device classes in OS mode).
- Deep link cold entry (incl. push tap via `extractNotificationTargetUrl`): resolve owner via `routePrefixes` → open full-screen at URL. Unowned → classic fallback for the session + `deep_link_unowned` counter.
- Route-addressability acceptance: `/customers/:id`, `/tasks/:id`, `/invoices/:id` cold-open the correct full-screen app.

### 6. Persistence (mobile slot + schema unification — one Wave-1 task)

The landed state is DRIFTED (anchors above): the server route validates its own strict zod copy (envelope `{ v: 1, writer, committedAt, data }`, desktop-only, NO `onboarding` in `data`) while the client schema is a flat shape WITH `onboarding`. This task unifies:

- **Single shared schema source**: payload zod moves to `packages/modules/src/shell-layout-schema.ts` (pure data + zod — fits the modules package law), imported by BOTH `apps/zync-api/src/routes/shell-layout.ts` and zync-app; the app file becomes a re-export. Envelope stays `{ v: 1, writer, committedAt, data }` (versioning key = envelope `v`).
- **Per-device `data` schemas**: desktop = landed fields (`windows`, `desktopIcons`, `pinnedTaskbar`, `widgets`) **+ `onboarding`** (ends the drift — server accepts what the client already models); mobile = `{ homeIcons, dock, widgets, onboarding? }` (coach-mark flag, §usability). Both `.strict()`.
- **Route accepts `device ∈ {'desktop','mobile'}`** (the landed `!== 'desktop' → 400` guard widens; unknown device still 400), dispatching to the matching `data` schema. Same 409 optimistic-concurrency rule.
- **localStorage mirrors principal-keyed**: `zync.shell.<tenantId>.<userId>.<deviceClass>`; the landed GLOBAL `zync.shell.layout` key is migrated (read-once → rewrite under new key) then deleted — cross-user leak on shared devices otherwise.
- No stack persistence: only `homeIcons`/`dock`/`widgets` are stored. **Reload at an app URL cold-opens that app via §5 cold-entry (stack of one)** — "no stack persistence" means the multi-app stack is not restored, NOT that reload lands on home. Commit-point-only writes / reset flow as landed desktop. Desktop and mobile rows independent (PK separates).

### 7. Module migration sweep

Wave-1 tasks (strict order):
- **(a) Audit task** — verify §2 ownership table against the live route registry + per-module violation counts (`location.state`, `document.title`, `window.history`, classic-chrome assumptions); output = cluster partition + violation work-list. Table conflicts → ESCALATE, never re-decide.
- **(b) Manifest task** (single owner, §2) — all `os` + `mobile` metadata, aggregator imports, ownership invariant test.
- **(a2) Skill-alignment task** — amend `references/mobile.md` where its presentation doctrine mentions registry-level `"sheet"` apps: registry presentation is fullscreen-only in Plan B; sheet language applies to DIALOG adaptation (task (c)) only. Doctrine and spec must not disagree.
- **(c) Dialog adapter task** — `packages/ui` Dialog/Modal gains `presentation?: 'sheet' | 'modal' | 'auto'` (default `'auto'` = BottomSheet when `ShellPresentationContext` is `'mobile-frame'` (§4 — sheet + context both live in `packages/ui`), modal elsewhere). Same task greps ALL destructive-confirmation call sites (confirm/danger variants, delete/void/discard actions — complete grep-derived list, no-exceptions sweep) and annotates them `presentation="modal"` explicitly. Non-destructive call sites untouched.

Then per-cluster sweep tasks (disjoint files). DONE definition per module:
1. Binding file complete (`titleFromRoute`; badge/widget/today contributions ONLY where the query already exists — no new endpoints).
2. Shell-contract violations fixed: zero `location.state` deps (grep-gated), zero direct `window.history`/`document.title` writes, zero classic-chrome assumptions; ad-hoc dirty editors from the audit work-list wired via `useShellBlocker('dirty-form', …)` (§3).
3. Playwright spec: windowed (desktop project) + full-screen (mobile project) open, core list→detail→mutate flow, URL contract, RTL smoke.
4. Vision-judge keyframes: app-open (mobile) + window-open (desktop) — wave-close batch.

Excluded: `/portal/*`, `/contractor-portal/*`, auth/onboarding, `/admin/*`, `/design-system`. Classic shell stays fully functional; classic e2e suites stay green (per-cluster acceptance).

### 8. PWA correctness + performance budget

**Service-worker cache isolation (BLOCKER fix, own task, lands before mobile OS ships as default):** landed `sw.ts` caches every authenticated `/api/*` response in one shared URL-keyed `api-cache` — cross-user leak on shared devices and stale-tenant leak after tenant switch. Fix contract:
- `/api/*` runtime caching REMOVED (NetworkOnly). Offline UX = module-level offline states + `/offline.html` nav fallback (unchanged). No API allowlist ships in Plan B (no endpoint is user-independent today); reintroducing any requires explicit allowlist + partition key.
- **Authenticated file caching REMOVED too**: the `files-cache` route (same-origin `/files/*`, R2 hosts, `.pdf`) serves principal-scoped documents — purge-on-logout cannot cover crash/expiry/abandoned sessions, so authed files are NetworkOnly.
- **`static-assets-v1` is ALSO a leak** (landed route caches EVERY `request.destination === 'image'|'font'` CacheFirst — including `/files/*` images, API-served images, avatars, R2 URLs). Replacement: CacheFirst ONLY for same-origin requests under the build-asset prefixes (`/assets/`, `/icons/`, `/fonts/` — the precache/public set), new cache name `static-assets-v2`; every other image/font (cross-origin, `/api/*`, `/files/*`, R2) is NetworkOnly.
- `activate` handler deletes legacy `api-cache`, `files-cache`, AND `static-assets-v1`. App→SW message `{type:'purge-user-caches'}` (replied with an ack via MessageChannel — Plan C awaits it on tenant switch) deletes any residual user-scoped caches; sent on logout (wired now).
- e2e: login user A → fetch API data + open a PDF + load a protected image (avatar) → logout → offline → assert NONE is served from cache; plus intercepted tenant-switch variant.

Budgets (enforced): home interactive ≤400ms cached (CDP 4x-throttle ≤800ms), widgets/badges skeleton — never gate; app-open expansion starts same frame as tap, p95 frame ≤24ms under 4x throttle, never waits for data; remount-on-reopen ≤300ms to first paint; native scroll physics ONLY (slopgate rule); `MobileShell` lazy-split from `OsShell` in `main.tsx` (build-artifact assertion: no WM code in mobile chunk, no mobile chunk in a desktop session).

## Design language binding

- EVERY mobile UI task: REQUIRED READING = SKILL.md + `references/mobile.md`; sweep window tasks add `references/desktop.md`. Task prompts carry the doctrine sections they implement; reviewer + vision judge reject against the skill.
- No new tokens expected; any genuinely new value lands in `packages/ui/src/tokens/index.css` FIRST. New `data-fx` ids per mobile.md (`app-open`, `recents-dismiss`, `shade-pull`, `sheet-settle`, `drawer-open`; `icon-drag` reused).

## Verification program (landed program, mobile-extended)

Same 8 layers + gate tiering + suite root. Wave-1 scaffold deltas:

- Playwright projects: `mobile-ltr-dark`, `mobile-rtl-dark`, `mobile-ltr-light`, `mobile-rtl-light` (Pixel-7-class 412×915, `hasTouch, isMobile`) + **WebKit iPhone-class projects `mobile-webkit-ltr-dark`, `mobile-webkit-rtl-light`** (iPhone 14 descriptor) — history/back matrix, sheet detents, safe-area assertions run on WebKit too.
- **iOS evidence beyond Playwright** (WebKit ≠ iOS standalone): manual device checklist as a named acceptance artifact (recorded in run log; user executes on a real iPhone): A2HS install, standalone launch → home, safe-areas, back-swipe inside app frame, push permission + delivery (iOS 16.4+ PWA), offline relaunch. Named executable web checks replace any vague "Lighthouse pass": manifest zod-validation test (name/icons/display/start_url/theme_color), SW registration + update-prompt e2e, offline nav-fallback e2e.
- Gesture probes: real `page.touchscreen` sequences — icon drag, shade pull to detents + velocity flick, recents swipe-dismiss above/below threshold + guarded-app confirm sheet, sheet detent drag, rubber-band. `expectSettleCurve`/`expectAnimated` reused.
- Slopgate additions (path glob widened to `src/os/mobile/**`): JS scroll-physics reimplementation rejected; `data-gesture` attr requires paired `data-gesture-button` (button-twin law).
- Visual baselines (4 Chromium mobile projects; WebKit projects run behavioral-only — engine AA differences make image baselines flaky): home, drawer, app frame (Tasks), recents ×3, shade both detents, sheet 50/92, command center full-screen, empty home, guarded-dismiss confirm sheet.
- Vision judge: mobile keyframes vs SKILL.md + mobile.md (codex models, NEVER Claude).
- Sweep clusters: layers 1–4 per task; baselines only for Time Tracking + Expenses (the PDF's named mobile validators); judge flags another module → add its baseline.

## Accessibility contract (mobile deltas; landed contract still applies)

- Home/drawer/dock/recents: roving tabindex; full hardware-keyboard operation (Enter opens, arrows move, Del removes-from-home with announcer confirm).
- `MobileAppFrame` labelled `role="region"`; nav bar `role="toolbar"`; shade/drawer/recents/sheets `role="dialog"` focus-trapped + focus-return; recents cards focusable, Delete = dismiss (guard-aware).
- All gestures have visible button twins (slopgate-enforced). Touch targets ≥44px (axe + probe).
- Announcer reused: app open/close/suspend, shade open, dismiss-all, guard-blocked dismissal.
- axe-core on every mobile surface (no serious/critical), both `dir` values.

## Usability & support mitigations

- First-run coach marks: 3 steps (drawer, nav bar, shade) — once, skippable, flag in mobile layout payload.
- Escape hatches: "Switch to classic view" + "Reset mobile home screen" in shade quick settings and shell-crash safe card; `?shell=classic` works.
- Telemetry counters (existing seam): landed counters + `shell_boundary_crossed`, `app_cap_hit`, `mobile_gesture_vs_button`.
- Diagnostic copy button in shade (same payload as desktop tray).

## Error handling (deltas from landed table)

| Failure | Surface |
|---|---|
| App render crash | full-frame error card (Reload / Reset state / Close) — shell + nav bar alive |
| Mobile layout payload corrupt | zod discard → default home + toast |
| Offline | SW offline fallback; shade offline chip; module offline states (API cache removed — §8) |
| Deep link to unauthorized module | never opens (registry filter); home + toast |
| Boundary-crossing resize | toast + Reload action |
| Guarded app dismiss | confirm sheet (dirty) / confirm disabled until settled (mutation) — never silent loss |

## Testing strategy summary

Unit: mobile store (stack MRU, cap+guards, panels exclusivity, `canDiscard` matrix), url-sync mobile branch (home push/replace dedup, back-from-home, per-app pop, surface-state popstate), drawer filter, manifest ownership invariant. Integration: dialog→sheet adapter fixture incl. `presentation="modal"` passthrough. E2E: program above + per-cluster specs + SW cache-isolation specs. Type/lint/existing gates throughout.

## Acceptance criteria (Plan B gate)

1. Mobile login (OS mode) → home with permission-filtered icons/dock/widgets; "Login to Legacy Site" → classic unchanged; `ui_shell_device` override honored.
2. Apps open full-screen from icon/drawer/dock/recents/notification/deep-link with FLIP expansion; Back/Home/Recents per contract INCLUDING browser back, on Chromium AND WebKit projects; recents preserves state for mounted apps, remounts unmounted at correct location; guarded dismissal shows confirm sheet; mutation-in-flight cannot be discarded.
3. URL contract holds (discriminated `OsHistoryState` incl. `/home` surface entries + dedup, popstate matrix, cold deep links, push-tap).
4. Mobile layout persists independently of desktop; reset works.
5. Shade: grouped notifications + deep-link + timer chip + quick settings; toasts per spec.
6. Sweep: every in-scope module opens windowed + full-screen with core flow green; ownership invariant test green; zero `location.state` deps in swept modules; dialog adapter live, destructive confirms explicitly modal, other call sites untouched.
7. Command center serves desktop OS, mobile OS, and classic from one implementation.
8. Verification: suite green incl. WebKit projects + gesture probes; mutation sweep 100%; vision-judge all-pass; fault-injection clean; classic suites green; iOS manual checklist recorded.
9. Perf budgets green under throttle; chunk isolation proven by build assertion.
10. RTL + dark/light + reduced-motion + reduced-transparency on every mobile surface; a11y contract green.
11. SW cache isolation: `/api/*` + authed-file + authed-image runtime caches gone (build-asset allowlist only), legacy caches deleted on activate, purge-on-logout wired, cross-user offline e2e proves no leakage; PWA named checks green (manifest validation, SW update flow, offline fallback).
12. Schema unification: one shared payload schema imported by server route AND client; `device=mobile` accepted; server accepts `onboarding`; principal-keyed localStorage mirrors with legacy-key migration; mutation-wrapper sweep grep-gated zero direct `useMutation` in zync-app.

## Architecture decisions (settled — falsifier each)

- Separate `MobileShell` + store sharing registry/router/url-sync/persistence/schema: window vs stack are disjoint state machines. Falsifier: >30% verbatim shell-code duplication → Plan C's `packages/os-shell` extraction absorbs it.
- One instance per module on mobile. Falsifier: tablet side-by-side demand → post-C.
- Home is a real history entry with consecutive-dedup. Falsifier: history-pollution telemetry.
- `presentation: 'fullscreen'` only — no speculative `'sheet'` variant (YAGNI; nothing in B or C consumes it). Falsifier: real lightweight-app need → additive union widening then.
- Dialog adaptation in the shared primitive with explicit `presentation` prop + audited destructive-annotation sweep: the primitive never infers destructiveness. Falsifier: >3 modules need bespoke sheet behavior → registry presentation hints.
- Boot-time shell selection, no live resize handoff. Falsifier: `shell_boundary_crossed` telemetry.
- Sweep = wrap + fix violations, not redesign. Falsifier: vision judge repeatedly fails a module's content → targeted content task per finding.
- `/api/*` runtime caching removed rather than partitioned: no current endpoint is safe to share, partition keys (tenant+user+locale) rot silently, module offline states already exist. Falsifier: a real offline-first requirement → explicit allowlist + partition design as its own spec.
- Route-ownership table pinned in spec (product decision), audit only verifies: delegation of ownership to a coding agent is delegated product design. Falsifier: audit finds a prefix whose nav group contradicts the table → escalation, user decides.

## Out of scope pointers

Plan C consumes: folders, shared shell-core extraction (`packages/os-shell`), portal/staff/admin shells, focus/lock/activity/tenant-switch (tenant-switch calls §8's purge message), white-label shell, `canDiscard` reuse. AI depth spec consumes: app context payload `{ moduleId, location }` (MobileAppFrame exposes the same shape as AppWindow).
