# App Shell

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:** `foundation-design-system`, `foundation-auth-rbac`, `system-communications-notifications`  
**Referenced by:** All `zync-app` module specs

---

## Overview

The persistent Chrome of `app.zync.is` — sidebar navigation, header bar, global search modal, and notification dropdown. All product module pages render inside this shell.

---

## Layout Structure

```
┌─────────────────────────────────────────────────┐
│  Header (fixed top, full width)                 │
├─────────┬───────────────────────────────────────┤
│         │                                       │
│ Sidebar │  Page content (<Outlet />)            │
│         │                                       │
│         │                                       │
└─────────┴───────────────────────────────────────┘
```

Sidebar width: 240px expanded, 56px collapsed (icon-only). Transition: CSS `transition-width 200ms ease`.

---

## Sidebar Navigation

### Structure

```
Sidebar
├── Logo / tenant branding (top)
├── Nav groups (scrollable)
│   ├── Workspace
│   │   ├── Dashboard
│   │   ├── Projects
│   │   ├── Tasks
│   │   ├── Time Tracking
│   │   └── Calendar
│   ├── Business
│   │   ├── Customers
│   │   ├── Marketing
│   │   └── Support Center
│   ├── Financials
│   │   ├── Invoices           → /invoices  (expandable)
│   │   │   ├── All invoices       → /invoices
│   │   │   ├── Approvals          → /invoices/approvals   (badge: pending count, spec 155)
│   │   │   ├── Reconcile          → /invoices/reconcile   (spec 157)
│   │   │   ├── Receipts           → /receipts             (spec 179)
│   │   │   ├── Drafts & Templates → /invoices/drafts      (spec 144)
│   │   │   └── Recurring          → /invoices/recurring   (spec 53)
│   │   ├── Expenses
│   │   ├── Vendors            → /vendors  (icon: Truck, perm: expenses:read, spec 182)
│   │   ├── Contractors        → /contractors  (icon: UserCheck, perm: payouts:read)
│   │   └── Contractor Payouts
│   └── Resources
│       ├── Knowledge Base
│       └── Reports
│           ├── (financial / operations / CRM / system tiles — see spec 103)
│           ├── Analytics              → /reports/analytics  (spec 24)
│           └── Tax & Compliance (Business+)
│               ├── VAT Report          → /reports/vat
│               ├── P&L Statement       → /reports/pnl
│               ├── Cash Flow           → /reports/cashflow
│               ├── Advance Tax (מקדמות) → /reports/advance-tax
│               ├── Mas 856             → /reports/withholding
│               ├── Bituach Leumi       → /reports/bituach-leumi
│               └── Uniform Format (מבנה אחיד) → /reports/uniform-format  (spec 180)
├── ── (separator)
├── Settings (bottom)
└── User avatar + name (bottom, links to /profile)
```

### Behavior

- Active item: highlighted with `bg-bg-subtle` + `text-primary` border-left accent
- Collapsed state: icons only, no labels. Hover → tooltip with label.
- Groups: collapsible (chevron). Expanded state per group stored in `localStorage`.
- Sidebar collapse toggle: icon button at bottom of sidebar; state stored in `localStorage('sidebar_collapsed')`.
- Permission-based visibility: nav items hidden if user lacks any permission for that module (e.g. `billing:read`).
- Module-state visibility: nav items for modules disabled in `tenant_modules` are hidden entirely (same as no permission). Use `useModuleEnabled(moduleId)` to read state.
- Tier-based visibility: items for Business+ features show upgrade badge when on Freelancer tier (clickable → upgrade modal), not hidden.

**Invoices sub-nav** (expandable): the **Invoices** Financials item is the one place that owns the invoices sub-tree. App-shell is the canonical nav owner — consumer specs (155 approvals, 157 reconcile, 179 receipts, 144 drafts, 53 recurring) link into these children but do not redefine the nav. Children: All invoices (`/invoices`), Approvals (`/invoices/approvals`, badge = pending SENT count, `invoices:write`, Business+), Reconcile (`/invoices/reconcile`, `invoices:write`), Receipts (`/receipts`, `invoices:read`), Drafts & Templates (`/invoices/drafts`, `invoices:read`), Recurring (`/invoices/recurring`, Business+). The sidebar nav model permits one level of children under a Financials item (Invoices is the only item that uses it); rendered as an inline expandable group, expanded-state in `localStorage`.

**Vendors directory** (`/vendors`, icon `Truck`, permission `expenses:read`): supplier directory (spec 182) listing vendors with tax ID, withholding status, and YTD spend. Sits under the Financials group between Expenses and Contractors. Also reachable from the `/expenses` header.

**Contractors directory** (`/contractors`, icon `UserCheck`, permission `payouts:read`): contractor directory listing all contractors with status, active projects, and payout history. Sits under the Financials group alongside Contractor Payouts.

**Rationale (2026-07-13):** Align navigation permissions with canonical seeded RBAC keys and API guards; `invoices:approve` and `contractors:read` do not exist in the permission registry.

**Rationale (2026-07-13):** Billing/payment-plan navigation stays hidden until the canonical `/billing/plans` product surface is implemented; tenant subscription management remains under `/settings/plan`.

**Analytics** (under Reports): the custom analytics dashboards live at `/reports/analytics` (spec 24, `reports-analytics`). It sits under the Reports group — the route's only owner — and is no longer duplicated as a standalone Workspace item (which previously had no route annotation).

**Tax & Compliance** (under Reports): the seven statutory report routes (`/reports/vat`, `/reports/pnl`, `/reports/cashflow`, `/reports/advance-tax`, `/reports/withholding`, `/reports/bituach-leumi`, `/reports/uniform-format`) are gated to the **Business+** tier and grouped as a labeled "Tax & Compliance" subsection. See spec 103 (`reports-navigation-hub`) for the hub tiles, and specs 170/171/175/180 for the report definitions.

### White-label: logo

`app.zync.is/portal/:tenantSlug` uses tenant's logo from `tenant.logo_url`. Main app uses Zync logo unless `tenant.white_label_active = true`.

---

## Header

```
┌────────────────────────────────────────────────────────────────────┐
│  [☰] [Logo]     [Global Search Cmd+K]     [🔔 3] [Avatar ▾]       │
└────────────────────────────────────────────────────────────────────┘
```

### Components

**Sidebar toggle (mobile + collapsed state)**  
Hamburger icon. On mobile (< 768px): overlay sidebar behavior. On desktop: toggles collapsed.

**Global search trigger**  
Button showing "Search..." + `⌘K` badge. Opens `CommandModal`.

**Notification bell**  
Bell icon. Badge shows unread count (hidden when 0). Click → `NotificationDropdown`.

**User menu (Avatar dropdown)**  
- Avatar (initials fallback if no photo)
- Name + role
- Actions: Profile, Settings, Switch tenant (if multi-tenant), Logout

---

## Tenant Switcher

Users who belong to multiple tenants can switch between them without logging out. The switcher is accessible from the User menu and from a dedicated popover triggered by clicking the tenant name/logo in the sidebar.

### Entry points

1. **User menu → "Switch workspace"** — appears only when `user.tenantMemberships.length > 1`
2. **Sidebar top area** — tenant name displayed as a clickable element; click opens the switcher popover

### Switcher popover UI

```
┌──────────────────────────────────────────────────────────┐
│  Switch workspace                                        │
│                                                          │
│  ┌──────────────────────────────────────────────────┐    │
│  │ 🔍 Search workspaces...                          │    │
│  └──────────────────────────────────────────────────┘    │
│                                                          │
│  ✓  Acme Design Studio        (OWNER)   [active]         │
│     Cohen & Partners          (MEMBER)                   │
│     Freelance — Gil Shapira   (ADMIN)                    │
│                                                          │
│  ──────────────────────────────────────────────────────  │
│  [+ Create new workspace]                                │
└──────────────────────────────────────────────────────────┘
```

- Checkmark on currently active tenant
- Role shown per tenant
- Search filters list (client-side, no API call — list is fetched once on load)
- "Create new workspace" → `/onboarding/new-tenant`

### Switch flow

On tenant selection:
1. `POST /api/auth/switch-tenant` with `{ tenantId }` (existing endpoint in spec 5)
2. Server validates user has active membership in target tenant
3. Issues new JWT with `tid = targetTenantId`; rotates cookies
4. Client: full page reload to `/dashboard` of new tenant context
   - Full reload (not SPA navigation) prevents stale module state from previous tenant leaking into the new session

### State isolation on switch

On switch, the following client-side state is cleared before reload:
- `localStorage` keys prefixed with `zync_tenant_` (tenant-scoped UI state: sidebar collapse, filter preferences)
- React Query cache (cleared via `queryClient.clear()` before redirect)
- `sessionStorage` items

Global keys (not tenant-scoped: theme, language preference) are preserved.

### Multi-tenant membership data

Fetched once on app init and cached in React Query with a 5-minute stale time:

```ts
// GET /api/auth/memberships
// Response: [{
//   tenantId, tenantSlug, tenantName, logoUrl, role, tier, status
// }]
// Filtered to: status = 'active', excludes pending_approval and frozen
```

The switcher list is rendered from this cached response. On JWT switch, `memberships` is refetched.

### Single-tenant users

If the user belongs to only one tenant, "Switch workspace" is hidden from the user menu. The sidebar tenant name is still shown but not clickable (no switcher popover). "Create new workspace" link is shown at the bottom of the sidebar for single-tenant users who may want to create an additional workspace.

---

## Global Search Modal (CommandModal)

Uses `Command` primitive from design system (wraps `cmdk`). Triggered by `⌘K` / `Ctrl+K` or clicking the search bar.

### Search behavior

Input: debounced 200ms → `GET /api/search?q=...&tenant=...`

### Searchable entities

| Entity | Fields searched | Result display |
|--------|----------------|----------------|
| Tasks | title, description | Title + project + status badge |
| Projects | name, customer name | Name + customer |
| Invoices | invoice number, customer name | Number + amount + status |
| Customers | name, email, company | Name + email |
| Support tickets | title, message content | Title + status |
| KB articles | title, content excerpt | Title + section |

Results grouped by entity type. Max 5 per group. Keyboard nav: arrow keys + Enter to navigate.

### Search API

```
GET /api/search?q=:query
```

Uses Postgres full-text search (`to_tsvector` + `plainto_tsquery`). Each tenant's data searched in isolation (all queries include `tenant_id`).

Response:
```ts
{
  tasks: SearchResult[]
  projects: SearchResult[]
  invoices: SearchResult[]
  customers: SearchResult[]
  tickets: SearchResult[]
  articles: SearchResult[]
}
interface SearchResult { id: string; label: string; description?: string; url: string }
```

---

## Notification Dropdown

Triggered by bell icon in header. Renders as a `Popover` anchored to the bell.

Content:
- "Notifications" title + "Mark all read" button
- Scrollable list of last 20 notifications
- Each item: icon (by type), title, body (truncated 2 lines), relative timestamp
- Unread items: `bg-bg-subtle` highlight
- Click item: navigate to entity (`notification.entity_type` + `entity_id`), mark read
- Empty state: "No notifications yet"

Unread count refreshed: WebSocket push from Durable Object + 30s polling fallback.

---

## Persistent State

| State | Storage | Scope |
|-------|---------|-------|
| Sidebar collapsed | `localStorage('sidebar_collapsed')` | Per browser |
| Sidebar group expanded/collapsed | `localStorage('sidebar_groups')` | Per browser |
| Task board filters | URL search params (`?status=...&project=...`) | Per navigation |
| Active tenant | JWT payload | Session |

No server-side persistence for shell state (localStorage is sufficient and avoids API calls on every navigation).

---

## Mobile (< 768px)

- Sidebar hidden by default, overlay on hamburger tap
- Tap outside sidebar → closes
- Bottom nav bar (optional — v1 may skip in favor of overlay sidebar)

---

## Route Structure (`zync-app/src/routes/`)

```ts
// Root layout
<Shell>  ← sidebar + header
  <Outlet /> ← all protected routes
</Shell>
```

Shell component:
- Reads session from store
- Redirects to `/login` if no session
- Loads user notification count
- Renders sidebar + header + outlet

---

## Build Configuration (Vite)

Vite configuration handles code splitting, bundle size monitoring, and lazy-loading patterns for the app shell.

### Route-Level Code Splitting

Every top-level route must be lazy-loaded. The initial bundle (shell + auth + router) must remain under **150 kB gzip**. Heavy modules (report widgets, chart libraries, rich text editors) must not load until their route is visited.

```ts
// src/router.tsx — all routes lazy-loaded
import { lazy, Suspense } from 'react'

const InvoiceRoutes    = lazy(() => import('./features/invoices'))
const ReportsRoutes    = lazy(() => import('./features/reports'))
const ProposalEditor   = lazy(() => import('./features/proposals/ProposalEditor'))
const KbEditor         = lazy(() => import('./features/kb/KbEditor'))
const ContractsEditor  = lazy(() => import('./features/contracts/ContractsEditor'))
// Pattern: every feature in src/features/* is a separate lazy() call
```

```ts
// Shell wraps all routes in Suspense with a skeleton fallback
<Suspense fallback={<AppSkeleton />}>
  <Routes>
    <Route path="/invoices/*" element={<InvoiceRoutes />} />
    <Route path="/reports/*"  element={<ReportsRoutes />} />
    {/* ... */}
  </Routes>
</Suspense>
```

**Bundle budget enforcement in vite.config.ts:**

```ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          recharts:  ['recharts'],
          tiptap:    ['@tiptap/core', '@tiptap/react', '@tiptap/starter-kit'],
          pdfjs:     ['pdfjs-dist'],
          vendor:    ['react', 'react-dom', 'react-router-dom'],
        },
      },
    },
  },
})
```

**Budget gates (CI):**
- Shell chunk: < 150 kB gzip
- Recharts chunk: < 120 kB gzip (loaded only on /reports)
- Tiptap chunk: < 80 kB gzip (loaded only on proposal/contract/kb routes)
- Any chunk: < 250 kB gzip (hard cap — CI fails above this)

Monitor with: `npx vite-bundle-visualizer` (dev only, not in CI)

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Shell pattern | React Router layout route | Persistent shell across navigations without unmount |
| Shell state | localStorage | No API latency on shell render; user-specific not tenant-specific |
| Search | Postgres FTS | No additional infra; adequate for v1 data volumes |
| Notification delivery | WS push + polling fallback | WS via Durable Objects (shared with tasks); polling catches drops |
| Mobile sidebar | Overlay | Simpler than bottom nav; consistent with desktop behavior |
