# App Shell — Implementation Plan

**Spec:** docs/specs/2026-05-30-app-shell.md  ·  **Slug:** app-shell  ·  **Wave:** 3
**Depends on:** foundation-auth-rbac, foundation-design-system, system-communications-notifications

## Goal
Build the persistent chrome of `app.zync.is` — a React Router layout route (`Shell`) wrapping every protected page with a collapsible sidebar, a fixed header, a global command-palette search (`⌘K`), a tenant switcher, and a notification dropdown. The shell renders module pages via `<Outlet />`, enforces session presence, and drives nav visibility from RBAC permissions, `tenant_modules` state, and subscription tier. It also delivers route-level code-splitting and a CI-enforced bundle budget so the initial shell payload stays under 150 kB gzip.

## Architecture
The shell is a `zync-app` layout route. `Shell` reads the session from the auth store (`GET /api/auth/me` cache, populated at app init), redirects to `/login` when absent, and renders `<Sidebar>` + `<Header>` + `<Suspense><Outlet/></Suspense>`.

- **Sidebar** renders a static declarative nav model (`NAV_MODEL`). Each item is filtered through three gates already exported upstream: permission presence (`session.permissions`), module-enabled state (`useModuleEnabled(moduleId)` from module-management), and tier (`useTierGate(minimum)` from foundation-auth-rbac — Business+ items show an upgrade badge rather than being hidden). The Invoices item owns the one expandable sub-tree. Collapse + per-group expand state persist in `localStorage`.
- **Header** hosts the sidebar toggle, the global-search trigger button, the notification bell, and the user/avatar dropdown. It uses design-system primitives `Button`, `Avatar`, `DropdownMenu`, `Popover`, `Command`, `Sheet`, `Tooltip`.
- **Tenant switcher** consumes a NEW endpoint `GET /api/auth/memberships` (filtered to active memberships) and the existing `POST /api/auth/switch-tenant`. On switch it clears tenant-scoped client state and full-reloads to `/dashboard`.
- **Global search** consumes a NEW endpoint `GET /api/search?q=` backed by Postgres full-text search (`to_tsvector`/`plainto_tsquery`), tenant-isolated, with each entity source independently guarded so the route degrades gracefully across build waves (see Task 9).
- **Notification dropdown** consumes the EXISTING notifications endpoints from system-communications-notifications: `GET /api/notifications`, `POST /api/notifications/read-all`, `PATCH /api/notifications/:id/read`, reading from the existing `notifications` table (`title_key`/`body_key`/`params`/`entity_type`/`entity_id`/`read_at`). Unread count refreshes via 30s polling now; the Durable-Object WebSocket push channel (real-time-infrastructure, wave 5) wires in additively later.

**No new tables.** App-shell only reads existing upstream tables: `notifications`, `user_preferences` (`sidebar_collapsed`), `tenant_memberships` + `tenants` + `roles` (switcher), `customers` (search, the one entity reliably present at wave 3). Search of later-wave tables (`tasks`, `invoices`, `tickets`, `kb_articles`, `projects`) is added behind table-existence guards.

## Tech Stack
- **App:** `apps/zync-app` (Vite + React + React Router v6, Cloudflare Workers static assets).
- **API:** `apps/zync-api` (Hono on Workers) for `GET /api/search` and `GET /api/auth/memberships`.
- **Packages consumed:** `@zync/ui` (Button, Avatar, DropdownMenu, Popover, Command, Sheet, Tooltip, Skeleton, Badge), `@zync/auth` (`requirePermission`, `requireTier`, `safeRedirect`, `authMiddleware`, `useTierGate`), `@zync/db` (`tenantQuery`, `createDb`), `@zync/types` (`SessionPayload`, `TenantTier`, `NotificationType`).
- **Module gating:** `useModuleEnabled`, `MODULE_BY_ID` from module-management.
- **Bindings:** `DB` (Hyperdrive→Neon Postgres) for search + memberships; `KV` for the user-version cache already in auth middleware. No new bindings.
- **Libraries:** `cmdk` (via `Command` primitive), `@tanstack/react-query` (memberships cache, notifications polling), `lucide-react` (nav icons: `Truck`, `UserCheck`, etc.).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — Nav model + routing skeleton | 1, 2 | `nav-model.ts`, `router.tsx`, `Shell.tsx` | 1 then 2 |
| B — Sidebar + Header chrome | 3, 4, 5 | `Sidebar.tsx`, `Header.tsx`, `UserMenu.tsx` | 3 ‖ 4 ‖ 5 after A |
| C — Tenant switcher | 6, 7 | `memberships` route, `TenantSwitcher.tsx` | 7 after 6 |
| D — Search | 8, 9, 10 | `search` route, `CommandModal.tsx` | 9 then 8 ‖ 10 |
| E — Notification dropdown | 11 | `NotificationDropdown.tsx` | after B |
| F — Build config + a11y/RTL hardening | 12, 13 | `vite.config.ts`, all shell components | 12 ‖ 13 after B–E |

## Tasks

### Task 1: Declarative nav model
**Blocks:** 2, 3  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/shell/nav-model.ts`
**Steps:**
- [ ] Define a `NavItem` type and a static `NAV_MODEL` array of groups matching the spec's sidebar tree exactly (Workspace / Business / Financials / Resources).
- [ ] Each item declares: `label` (i18n key), `to` (route), `icon` (lucide name), optional `permission` (RBAC key), optional `moduleId` (for `useModuleEnabled`), optional `minTier` (`TenantTier`), optional `children` (one level, Invoices only), optional `badge` (e.g. `'invoices_pending'`).
- [ ] Encode the Invoices sub-tree children: All invoices (`/invoices`, perm `invoices:read`), Approvals (`/invoices/approvals`, perm `invoices:write`, `minTier: 'business'`, `badge: 'invoices_pending'`), Reconcile (`/invoices/reconcile`, perm `invoices:write`), Receipts (`/receipts`, perm `invoices:read`), Drafts & Templates (`/invoices/drafts`, perm `invoices:read`), Recurring (`/invoices/recurring`, perm `invoices:read`, `minTier: 'business'`).
- [ ] Encode Vendors (`/vendors`, icon `Truck`, perm `expenses:read`) and Contractors (`/contractors`, icon `UserCheck`, perm `payouts:read`) under Financials between Expenses and Contractor Payouts.
- [ ] Omit customer payment-plan navigation until `/billing/plans` is implemented; `/settings/plan` remains tenant subscription management.
- [ ] Under Resources → Reports: Analytics (`/reports/analytics`, perm `reports:read`) and a "Tax & Compliance" labeled subsection with `minTier: 'business'` covering `/reports/vat`, `/reports/pnl`, `/reports/cashflow`, `/reports/advance-tax`, `/reports/withholding`, `/reports/bituach-leumi`, `/reports/uniform-format`.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/shell/nav-model.ts
import type { TenantTier } from '@zync/types'

export interface NavItem {
  label: string                 // i18n key, e.g. 'nav.dashboard'
  to: string                    // route path, e.g. '/dashboard'
  icon: string                  // lucide-react icon name
  permission?: string           // RBAC key gating visibility, e.g. 'billing:read'
  moduleId?: string             // MODULE_BY_ID key; hidden when module disabled
  minTier?: TenantTier          // Business+ items show upgrade badge, not hidden
  badge?: 'invoices_pending'    // dynamic count badge key
  children?: NavItem[]          // exactly one level; Invoices only
}

export interface NavGroup {
  label: string                 // i18n key, e.g. 'nav.group.financials'
  items: NavItem[]
}

export const NAV_MODEL: NavGroup[]
```
**Acceptance:**
- [ ] `NAV_MODEL` enumerates every item in the spec's sidebar tree with correct route, icon, permission, module, and tier metadata.
- [ ] Only the Invoices item carries `children`; depth never exceeds one level.

### Task 2: Lazy router with route-level code splitting
**Blocks:** 3  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/router.tsx`
- Modify: `apps/zync-app/src/main.tsx`
**Steps:**
- [ ] Build a React Router v6 `createBrowserRouter` tree with `Shell` as the layout route wrapping all protected routes via `<Outlet />`.
- [ ] Lazy-load every feature directory under `src/features/*` with `React.lazy(() => import('./features/<name>'))` — one `lazy()` per feature (invoices, reports, proposals, kb, contracts, customers, projects, tasks, expenses, calendar, settings, etc.).
- [ ] Wrap the `<Outlet />` in `<Suspense fallback={<AppSkeleton />}>` using the design-system `Skeleton` primitive.
- [ ] Public routes (`/login`, `/signup`, `/invite`) live outside `Shell`.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/router.tsx
const InvoiceRoutes   = lazy(() => import('./features/invoices'))
const ReportsRoutes   = lazy(() => import('./features/reports'))
// ...one lazy() per src/features/* directory
// <Route element={<Shell/>}>
//   <Route path="/invoices/*" element={<InvoiceRoutes/>} />
//   ...
// </Route>
```
**Acceptance:**
- [ ] Every top-level feature route is a distinct `lazy()` import; none are statically imported into the shell bundle.
- [ ] Navigating to an unbuilt feature shows the `AppSkeleton` fallback, never a blank screen.

### Task 3: Shell layout route + session guard
**Blocks:** 11, 12, 13  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/shell/Shell.tsx`
- Create: `apps/zync-app/src/shell/AppSkeleton.tsx`
**Steps:**
- [ ] `Shell` reads the session from the auth store (hydrated from `GET /api/auth/me`). If no session, `Navigate` to `/login` preserving the attempted path as `?redirect=` (server validates it with upstream `safeRedirect`).
- [ ] Render a CSS grid: fixed-top `<Header>` full width, `<Sidebar>` in the start column, page content (`<Outlet/>`) in the main column.
- [ ] Trigger the notification unread-count query (Task 11) and the memberships query (Task 6) on mount.
- [ ] Provide a `ShellContext` exposing `sidebarCollapsed`, `setSidebarCollapsed`, and `mobileSidebarOpen` so Header's toggle and Sidebar share state.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/shell/Shell.tsx
export function Shell(): JSX.Element  // layout route element; redirects unauthenticated users
interface ShellContextValue {
  sidebarCollapsed: boolean
  setSidebarCollapsed: (v: boolean) => void
  mobileSidebarOpen: boolean
  setMobileSidebarOpen: (v: boolean) => void
}
```
**Acceptance:**
- [ ] Unauthenticated load redirects to `/login?redirect=<path>`; authenticated load renders header + sidebar + outlet.
- [ ] The grid uses logical/inline CSS (no physical left/right) so it mirrors under `dir="rtl"`.

### Task 4: Sidebar component
**Blocks:** 12, 13  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/shell/Sidebar.tsx`
- Create: `apps/zync-app/src/shell/use-sidebar-state.ts`
**Steps:**
- [ ] Render `NAV_MODEL` groups. For each item compute visibility: hidden if `permission` set and not in `session.permissions`; hidden if `moduleId` set and `useModuleEnabled(moduleId)` is false; if `minTier` set and `useTierGate(minTier).allowed` is false, render the item with an upgrade `Badge` and route the click to `useTierGate().upgrade()` instead of navigation.
- [ ] Active item: apply `bg-hover` + `text-accent` and a `border-inline-start` accent (logical, not `border-left`) — RTL-safe.
- [ ] Collapsed state (56px, icon-only) vs expanded (240px) driven by `useSidebarState`, persisted in `localStorage('sidebar_collapsed')`; collapsed items show a `Tooltip` with the label on hover.
- [ ] Groups are collapsible via chevron; per-group expanded state persisted in `localStorage('sidebar_groups')` (object keyed by group label).
- [ ] Invoices renders as an inline expandable group (its `children`), expanded-state persisted in `localStorage('sidebar_groups')` under its own key.
- [ ] Sidebar top: tenant logo/name (clickable → tenant switcher when multi-tenant; static when single-tenant). Use `tenant.logo_url` only on `/portal/:tenantSlug` or when `tenant.white_label_active`; otherwise the Zync logo. Bottom: separator, Settings link, "Create new workspace" link for single-tenant users, user avatar + name linking to `/profile`, and the collapse toggle button.
- [ ] Wrap the nav in `<nav aria-label="Primary">`; mark the active item with `aria-current="page"`.
- [ ] The 200ms `transition-[width]` collapse animation must be disabled under `@media (prefers-reduced-motion: reduce)`.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/shell/use-sidebar-state.ts
export function useSidebarState(): {
  collapsed: boolean
  toggleCollapsed: () => void
  expandedGroups: Record<string, boolean>
  toggleGroup: (key: string) => void
}
```
**Acceptance:**
- [ ] An item is hidden when the user lacks its permission OR its module is disabled; shown with an upgrade badge when tier-gated below `minTier`.
- [ ] Collapse and group-expand state survive a full page reload (localStorage).
- [ ] Active item uses `border-inline-start` (verified to flip side under `dir="rtl"`); collapse transition is suppressed under `prefers-reduced-motion`.

### Task 5: Header component
**Blocks:** 12, 13  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/shell/Header.tsx`
- Create: `apps/zync-app/src/shell/UserMenu.tsx`
**Steps:**
- [ ] Render: sidebar toggle (hamburger `Button` variant `ghost` size `icon`), logo, global-search trigger button, notification bell, user menu.
- [ ] Sidebar toggle: on desktop calls `setSidebarCollapsed(!collapsed)`; on mobile (< 768px) calls `setMobileSidebarOpen(true)` to open the overlay `Sheet` sidebar.
- [ ] Search trigger: a `Button` showing localized "Search…" plus a `⌘K` `Badge`; click opens `CommandModal` (Task 10). Bind global `⌘K`/`Ctrl+K` keydown to open it.
- [ ] Notification bell: `Button` with a `Badge` showing unread count (hidden when 0); click opens `NotificationDropdown` (Task 11).
- [ ] `UserMenu`: `DropdownMenu` anchored to `Avatar` (initials fallback). Items: name + role header, Profile (`/profile`), Settings (`/settings`), "Switch workspace" (only when `memberships.length > 1`, opens `TenantSwitcher`), Logout (`POST /api/auth/logout` then redirect `/login`).
- [ ] Header is `<header role="banner">`; the search trigger has an accessible label; the bell button's `aria-label` includes the unread count.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/shell/Header.tsx
export function Header(): JSX.Element
// apps/zync-app/src/shell/UserMenu.tsx
export function UserMenu(): JSX.Element
```
**Acceptance:**
- [ ] `⌘K`/`Ctrl+K` opens the command modal from any shell page.
- [ ] "Switch workspace" appears only for multi-tenant users; Logout clears the session and routes to `/login`.

### Task 6: `GET /api/auth/memberships` endpoint
**Blocks:** 7  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/src/routes/auth/memberships.ts`
- Modify: `apps/zync-api/src/routes/auth/index.ts`
**Steps:**
- [ ] Add an authenticated route (behind `authMiddleware`) returning the caller's tenant memberships, joined across `tenant_memberships` + `tenants` + `roles`.
- [ ] Filter to `tenant_memberships.status = 'active'` (exclude `frozen`; the membership status enum is `('active','frozen')` per foundation-auth-rbac), and include the tenant's `tier` and `logo_url`.
- [ ] Use the `tenantQuery`/`createDb` data layer (never raw Drizzle from the route, per `no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```ts
// GET /api/auth/memberships  (NEW — owned by app-shell)
// Auth: zync_session cookie
// Response: TenantMembershipSummary[]
interface TenantMembershipSummary {
  tenantId: string
  tenantSlug: string
  tenantName: string
  logoUrl: string | null
  role: string          // role name within that tenant
  tier: TenantTier
  status: 'active'      // frozen/pending excluded server-side
}
```
Source query (canonical Postgres):
```sql
SELECT t.id   AS tenant_id,
       t.slug AS tenant_slug,
       t.name AS tenant_name,
       t.logo_url,
       r.name AS role,
       t.tier,
       tm.status
FROM tenant_memberships tm
JOIN tenants t ON t.id = tm.tenant_id
JOIN roles   r ON r.id = tm.role_id
WHERE tm.user_id = $1
  AND tm.status = 'active';
```
**Acceptance:**
- [ ] Frozen and non-active memberships are excluded from the response.
- [ ] Response carries `tier` and `logoUrl` for each membership so the switcher renders role + tier without further calls.

### Task 7: Tenant switcher
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/shell/TenantSwitcher.tsx`
- Create: `apps/zync-app/src/shell/use-memberships.ts`
**Steps:**
- [ ] `useMemberships` fetches `GET /api/auth/memberships` once via React Query with a 5-minute stale time; cached for both the user menu and the sidebar popover.
- [ ] `TenantSwitcher` is a `Popover` (or `Command` list) listing memberships: checkmark on the active tenant (`session.tid`), role label per tenant, client-side search filter (no API call), and a "Create new workspace" → `/onboarding/new-tenant` footer link.
- [ ] On selection: `POST /api/auth/switch-tenant` with `{ tenantId }` (existing endpoint). On success, perform state isolation (Task — inline): remove all `localStorage` keys prefixed `zync_tenant_`, call `queryClient.clear()`, clear `sessionStorage`; PRESERVE global keys (theme, language). Then `window.location.assign('/dashboard')` for a full reload (not SPA navigation) so stale module state cannot leak across tenants.
- [ ] Single-tenant users: render the sidebar tenant name as non-clickable; hide "Switch workspace" in the user menu; still show "Create new workspace" at the sidebar bottom.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/shell/use-memberships.ts
export function useMemberships(): {
  memberships: TenantMembershipSummary[]
  isLoading: boolean
}
// apps/zync-app/src/shell/TenantSwitcher.tsx
export function TenantSwitcher(props: { open: boolean; onOpenChange: (o: boolean) => void }): JSX.Element
```
**Acceptance:**
- [ ] Switching tenants clears `zync_tenant_*` localStorage, the React Query cache, and `sessionStorage`, while theme and language survive, then hard-reloads to `/dashboard`.
- [ ] The switcher is hidden/non-interactive for single-tenant users.

### Task 8: `GET /api/search` route handler
**Blocks:** 10  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-api/src/routes/search.ts`
- Modify: `apps/zync-api/src/routes/index.ts`
**Steps:**
- [ ] Add an authenticated route `GET /api/search?q=:query` (behind `authMiddleware`); reject blank `q` with an empty grouped response.
- [ ] For each searchable entity, run an independently-guarded query (Task 9) and assemble the grouped response; max 5 results per group.
- [ ] EVERY query MUST be tenant-isolated: include `WHERE tenant_id = $1` (the session's `tid`) — no cross-tenant leakage (security requirement, spec line 232). Use `tenantQuery` so tenant scoping is enforced by the data layer, not hand-written per call.
- [ ] Validate `q` with zod (`require-zod-validation-in-routes`); cap length (e.g. 200 chars).
**Schema / Interfaces:**
```ts
// GET /api/search?q=:query  (NEW — owned by app-shell)
// Auth: zync_session cookie; tenant-isolated
interface SearchResult { id: string; label: string; description?: string; url: string }
interface SearchResponse {
  tasks: SearchResult[]
  projects: SearchResult[]
  invoices: SearchResult[]
  customers: SearchResult[]
  tickets: SearchResult[]
  articles: SearchResult[]
}
```
**Acceptance:**
- [ ] Every entity query includes `tenant_id = session.tid`; a request from tenant A never returns tenant B rows.
- [ ] Each group is capped at 5 results; blank/oversized `q` is rejected by zod.

### Task 9: Wave-aware FTS source registry (graceful degradation)
**Blocks:** 8  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/src/search/sources.ts`
- Create: `apps/zync-api/migrations/<ts>_search_gin_indexes.sql`
**Steps:**
- [ ] Define a `SearchSource` registry: one entry per entity (`tasks`, `projects`, `invoices`, `customers`, `tickets`, `articles`) describing its table, searched columns, and a result mapper to `SearchResult` (with `url`).
- [ ] Each source query uses Postgres FTS: `to_tsvector('simple', <cols>) @@ plainto_tsquery('simple', $q)`, scoped by `tenant_id`, `LIMIT 5`. Use the `'simple'` config (no stemming) so Hebrew tokens match literally.
- [ ] Build-order degradation: each source is invoked inside a guard that catches `undefined_table` (Postgres SQLSTATE `42P01`) and returns `[]`. At wave 3 only `customers` (wave 2) is reliably present; `tasks`/`projects` may or may not exist (same/later wave); `invoices`/`tickets`/`articles` arrive in later waves. The route assembles whatever sources resolve and returns empty arrays for the rest — entities light up additively as their modules land, with NO code change to the shell.
- [ ] Add a migration creating GIN indexes for the FTS expressions on tables that exist at this wave (at minimum `customers`); document that each later module's own migration adds its GIN index when its table is created. Never ship an FTS query with no index story.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/search/sources.ts
interface SearchSource {
  key: keyof SearchResponse
  query: (db: Db, tenantId: string, q: string) => Promise<SearchResult[]>  // guarded; returns [] if table absent
}
export const SEARCH_SOURCES: SearchSource[]
export async function runSearch(db: Db, tenantId: string, q: string): Promise<SearchResponse>
```
Customers GIN index (canonical Postgres — the one source guaranteed present at wave 3):
```sql
CREATE INDEX IF NOT EXISTS idx_customers_fts
  ON customers
  USING GIN (to_tsvector('simple', coalesce(name,'') || ' ' || coalesce(email,'') || ' ' || coalesce(company,'')));
```
**Acceptance:**
- [ ] With only `customers` present, `GET /api/search` returns populated `customers` and empty arrays for every other group — no 500, no missing-table error.
- [ ] Each FTS query is backed by a GIN index (customers now; later tables via their own module migrations).

### Task 10: CommandModal (global search UI)
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/shell/CommandModal.tsx`
- Create: `apps/zync-app/src/shell/use-global-search.ts`
**Steps:**
- [ ] Build `CommandModal` on the design-system `Command` primitive (wraps `cmdk`). Open state controlled by Header (`⌘K`/`Ctrl+K` or trigger click).
- [ ] `useGlobalSearch` debounces the input 200ms, then queries `GET /api/search?q=` via React Query; maps the `SearchResponse` into `Command` groups (Tasks, Projects, Invoices, Customers, Tickets, KB articles), each capped at 5, with per-entity result display per the spec table.
- [ ] Keyboard navigation: arrow keys move selection, Enter navigates to the result `url` and closes the modal (handled by `cmdk`/`Command`). Empty groups are omitted.
- [ ] Show a `Skeleton`/loading state while the query is in flight and a "No results" empty line when all groups are empty.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/shell/use-global-search.ts
export function useGlobalSearch(query: string): {
  results: SearchResponse | undefined
  isLoading: boolean
}
// apps/zync-app/src/shell/CommandModal.tsx
export function CommandModal(props: { open: boolean; onOpenChange: (o: boolean) => void }): JSX.Element
```
**Acceptance:**
- [ ] Typing debounces 200ms before hitting the API; results render grouped by entity, max 5 per group.
- [ ] Arrow keys + Enter navigate to a result and close the modal; the modal is keyboard-operable end to end.

### Task 11: Notification dropdown
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/shell/NotificationDropdown.tsx`
- Create: `apps/zync-app/src/shell/use-notifications.ts`
**Steps:**
- [ ] `useNotifications` polls `GET /api/notifications` (existing endpoint) every 30s via React Query (`refetchInterval: 30_000`) and exposes the list plus `unreadCount` for the Header badge.
- [ ] `NotificationDropdown` renders inside a `Popover` anchored to the bell: title "Notifications" + "Mark all read" button (`POST /api/notifications/read-all`), then the last 20 notifications grouped Today / Earlier.
- [ ] Each item: type icon, localized title via `t(notification.title_key, notification.params)`, body via `t(notification.body_key, notification.params)` truncated to 2 lines (`body_key` may be null), relative timestamp. Unread items get `bg-hover`.
- [ ] Clicking an item navigates to the entity from `notification.entity_type` + `entity_id` and marks it read (`PATCH /api/notifications/:id/read`).
- [ ] Empty state: localized "No notifications yet".
- [ ] WebSocket push hook-point: expose a stable `pushNotification(n)` mutator on the query cache as a documented seam so real-time-infrastructure (wave 5) can call it; until then the 30s poll is the sole refresh path. The dropdown is fully functional on polling alone — the seam adds no incomplete code path.
- [ ] Dropdown list uses `role="menu"`/`role="menuitem"` semantics from `Popover`; "Mark all read" is a labeled `Button`.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/shell/use-notifications.ts
export function useNotifications(): {
  notifications: NotificationItem[]
  unreadCount: number
  markAllRead: () => Promise<void>
  markRead: (id: string) => Promise<void>
  pushNotification: (n: NotificationItem) => void   // WS seam for real-time-infrastructure
}
interface NotificationItem {
  id: string
  type: NotificationType        // canonical union from @zync/types (spec 97)
  titleKey: string
  bodyKey: string | null
  params: Record<string, string>
  entityType: string | null
  entityId: string | null
  readAt: string | null
  createdAt: string
}
```
**Acceptance:**
- [ ] Unread count refreshes at least every 30s via polling; badge hides at 0.
- [ ] Titles/bodies render through `t(key, params)` (never pre-rendered text); clicking an item navigates to its entity and marks it read.

### Task 12: Vite build config + bundle budget CI gate
**Blocks:** —  ·  **Blocked by:** 3, 4, 5, 10, 11
**Files:**
- Modify: `apps/zync-app/vite.config.ts`
- Create: `apps/zync-app/scripts/check-bundle-budget.mjs`
- Modify: `apps/zync-app/package.json` (build script chains the budget check)
**Steps:**
- [ ] Configure `build.rollupOptions.output.manualChunks` exactly: `recharts: ['recharts']`, `tiptap: ['@tiptap/core','@tiptap/react','@tiptap/starter-kit']`, `pdfjs: ['pdfjs-dist']`, `vendor: ['react','react-dom','react-router-dom']`.
- [ ] Add `check-bundle-budget.mjs` that reads gzip sizes from the build output and FAILS (non-zero exit) on: shell chunk ≥ 150 kB gzip, recharts chunk ≥ 120 kB gzip, tiptap chunk ≥ 80 kB gzip, ANY chunk ≥ 250 kB gzip (hard cap).
- [ ] Wire the budget check into the build/CI pipeline so a budget breach fails CI.
**Schema / Interfaces:**
```ts
// apps/zync-app/vite.config.ts (excerpt)
build: { rollupOptions: { output: { manualChunks: {
  recharts: ['recharts'],
  tiptap:   ['@tiptap/core', '@tiptap/react', '@tiptap/starter-kit'],
  pdfjs:    ['pdfjs-dist'],
  vendor:   ['react', 'react-dom', 'react-router-dom'],
} } } }
```
**Acceptance:**
- [ ] A shell chunk over 150 kB gzip, or any chunk over 250 kB gzip, fails the build/CI step.
- [ ] `recharts`/`tiptap`/`pdfjs` resolve to their own chunks and are absent from the initial shell payload.

### Task 13: A11y, RTL & reduced-motion hardening pass
**Blocks:** —  ·  **Blocked by:** 3, 4, 5, 7, 10, 11
**Files:**
- Modify: `apps/zync-app/src/shell/Sidebar.tsx`
- Modify: `apps/zync-app/src/shell/Header.tsx`
- Modify: `apps/zync-app/src/shell/NotificationDropdown.tsx`
- Modify: `apps/zync-app/src/shell/CommandModal.tsx`
**Steps:**
- [ ] Landmarks: `<header role="banner">`, sidebar `<nav aria-label="Primary">`, main content region; active nav item `aria-current="page"`.
- [ ] All physical-direction styles use logical properties (`border-inline-start`, `ms-*`/`me-*`/`ps-*`/`pe-*`) so the shell mirrors correctly under `dir="rtl"` (Hebrew first-tier); verify the active-item accent flips to the inline-start edge.
- [ ] Every interactive control has an accessible name (search trigger, bell with unread count, hamburger, avatar menu); focus order is logical; focus is trapped within open `Dialog`/`Sheet`/`Command` overlays and restored on close (provided by Radix primitives).
- [ ] All shell transitions (sidebar `transition-[width]` 200ms, dropdown open/close) are disabled under `@media (prefers-reduced-motion: reduce)`.
- [ ] Mobile (< 768px): sidebar is hidden by default and opens as an overlay `Sheet` on hamburger tap; tapping the scrim closes it.
**Acceptance:**
- [ ] Keyboard-only operation reaches and activates every shell control; overlays trap and restore focus.
- [ ] Under `dir="rtl"` the layout mirrors and the active-item accent sits on the inline-start edge; under `prefers-reduced-motion: reduce` no shell animation plays.
