# Global Search Completeness — Implementation Plan

**Spec:** docs/specs/2026-05-31-search-completeness.md  ·  **Slug:** search-completeness  ·  **Wave:** 4
**Depends on:** app-shell, foundation-auth-rbac, foundation-design-system, module-management

## Goal
Deliver the complete global search system for Zync.is: the `⌘K` / `Ctrl+K` command palette and the full-page `/search` results view, backed by two Hono API endpoints (`GET /api/search`, `GET /api/search/full`). Search spans all fourteen searchable entity types using Postgres full-text search (`tsvector` / `tsquery`) — no external search service. Every result is module-gated and permission-gated server-side, tenant-isolated, and rendered RTL (Hebrew placeholder `חפש...`). This extends the partial three-entity search defined in `app-shell` to the full entity set.

## Architecture
- **DB layer.** Each searchable entity table receives a `search_vector tsvector GENERATED ALWAYS AS (...) STORED` column plus a GIN index. These are ALTER-TABLE migrations against tables owned by upstream modules (`tasks`, `projects`, `customers`, `invoices`, `expenses`, `kb_articles`, `support_tickets`, `users`, `vendors`, `receipts`, `contractors`, `leads`, `proposals`, `contracts`). This spec introduces **no new tables**. Language config: `'hebrew'` for user content, `'simple'` for structured Latin fields (email, phone, invoice_number, receipt_number, tax_id).
- **Service layer.** A search service in `packages/db` (or a thin `apps/zync-api` module) builds the `tsquery` from raw input, gates by enabled modules and JWT permissions, runs one parameterized query per permitted+enabled entity, ranks within group by `ts_rank_cd`, generates `<mark>` highlights via `ts_headline`, and assembles `SearchResultGroup[]`.
- **API layer.** Two Hono routes mounted on the existing `apps/zync-api` app behind `authMiddleware` (tenant + JWT `permissions[]` + `role` extracted from session) and `rateLimit` (60 req/min/user). Reuses `tenant_modules` reads via `getEnabledModuleIds`.
- **Client layer.** `CommandModal` (from `app-shell`, wraps `Command`/cmdk) is extended to call `GET /api/search?q=&limit=5` with 200ms debounce and react-query. A new full-page route `apps/zync-app/src/routes/search.tsx` calls `GET /api/search/full`. Module-awareness for quick actions uses `useModuleStore` / `useModuleEnabled` from `module-management`. Recent searches live in `localStorage` key `zync:recent_searches`.

Upstream tables/exports consumed: `tenant_modules` (+ `tenant_modules.module_id`, `tenant_modules.enabled`, `tenant_modules.tenant_id`), `tenant_memberships`, `users`, `customers`, `roles`/`role_permissions`/`permissions` (via JWT `permissions[]`), `authMiddleware`, `requirePermission`, `rateLimit`, `tenantQuery`, `getEnabledModuleIds`, `clampLimit`, `encodeCursor`, `decodeCursor`, `hasScope`, `Command`, `Dialog`, `Badge`, `Skeleton`, `Spinner`, `EmptyState`, `useModuleStore`, `useModuleEnabled`, `ModuleId`, `cn`, design tokens (`--surface`, `--ink`, `--ink-faint`, `--accent`, `--line`, `--radius`).

## Tech Stack
- **apps/zync-api** — Hono on Cloudflare Workers; Drizzle ORM over Neon Postgres via Hyperdrive (`DB` binding). Raw `sql` template fragments for `tsvector`/`tsquery`/`ts_rank_cd`/`ts_headline` (Drizzle `sql`).
- **packages/db** — Drizzle schema additions (generated columns + indexes) and the `searchEntities` service function.
- **packages/types** — `EntityType`, `SearchResultItem`, `SearchResultGroup`, `SearchResponse`, `FullSearchResponse`.
- **apps/zync-app** — React + Vite; `@tanstack/react-query`; `cmdk` via `Command`; `react-router` `useSearchParams`; design-system primitives.
- Bindings: `DB` (Hyperdrive→Neon), `RATELIMIT_KV` / `RATE_LIMITER_AUTH`-style limiter for the 60/min cap.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — Schema | 1 | drizzle schema + migration SQL | No (foundation for all) |
| B — Types & query core | 2, 3 | `packages/types`, `packages/db` search service | 2 and 3 parallel after A |
| C — API routes | 4, 5 | `apps/zync-api` search routes | After 2,3 |
| D — Client palette | 6, 7, 8 | `apps/zync-app` CommandModal subtree | After 4 (parallel with E once types exist) |
| E — Full-page route | 9 | `apps/zync-app/src/routes/search.tsx` | After 5 |
| F — Cross-cutting glue | 10, 11 | settings/users highlight, a11y/RTL/perf hardening | After 6–9 |

## Tasks

### Task 1: Add `search_vector` generated columns + GIN indexes to all 14 entity tables
**Blocks:** 3, 4, 5  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/*.ts` (add the generated column + index to each entity's Drizzle table)
- Create: `packages/db/drizzle/<timestamp>_search_vectors.sql` (raw migration; generated `tsvector` columns are expressed as raw SQL)
**Steps:**
- [ ] For each of the 14 tables below, add `search_vector tsvector GENERATED ALWAYS AS (...) STORED` and a `USING GIN (search_vector)` index. Drizzle cannot natively express `GENERATED ALWAYS AS (tsvector)`, so author these as a raw SQL migration and mirror the column in the Drizzle schema with `.generatedAlwaysAs(sql\`...\`)` (or a custom column) so the type is visible to the ORM.
- [ ] Ensure `kb_articles.content_text` (plain-text article body, Markdown/HTML stripped) is populated on article save — coordinate with `kb-module`; if the column is absent, add `content_text TEXT` and backfill. The search vector reads `content_text`, not raw body.
- [ ] `receipts.receipt_number` and `invoices.invoice_number` are NULL while `DRAFT`; the `coalesce(..., '')` in each generated expression keeps the column valid — do not add NOT NULL.
- [ ] Run the migration against a Neon branch and confirm each GIN index is created and `EXPLAIN` shows an index scan for an `@@` query.
**Schema / Interfaces:**
```sql
-- 5.1 tasks
ALTER TABLE tasks
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(title, '')), 'A') ||
      setweight(to_tsvector('hebrew', coalesce(description, '')), 'B')
    ) STORED;
CREATE INDEX idx_tasks_search ON tasks USING GIN (search_vector);

-- 5.2 projects
ALTER TABLE projects
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(name, '')), 'A') ||
      setweight(to_tsvector('hebrew', coalesce(description, '')), 'B')
    ) STORED;
CREATE INDEX idx_projects_search ON projects USING GIN (search_vector);

-- 5.3 customers
ALTER TABLE customers
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(name, '')), 'A') ||
      setweight(to_tsvector('simple', coalesce(email, '')), 'B') ||
      setweight(to_tsvector('simple', coalesce(phone, '')), 'C')
    ) STORED;
CREATE INDEX idx_customers_search ON customers USING GIN (search_vector);

-- 5.4 invoices (invoice_number only; customer name via JOIN)
ALTER TABLE invoices
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('simple', coalesce(invoice_number, '')), 'A')
    ) STORED;
CREATE INDEX idx_invoices_search ON invoices USING GIN (search_vector);

-- 5.5 expenses
ALTER TABLE expenses
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(description, '')), 'A') ||
      setweight(to_tsvector('hebrew', coalesce(vendor, '')), 'B')
    ) STORED;
CREATE INDEX idx_expenses_search ON expenses USING GIN (search_vector);

-- 5.6 kb_articles
ALTER TABLE kb_articles
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(title, '')), 'A') ||
      setweight(to_tsvector('hebrew', coalesce(content_text, '')), 'B')
    ) STORED;
CREATE INDEX idx_kb_articles_search ON kb_articles USING GIN (search_vector);

-- 5.7 support_tickets
ALTER TABLE support_tickets
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(subject, '')), 'A')
    ) STORED;
CREATE INDEX idx_support_tickets_search ON support_tickets USING GIN (search_vector);

-- 5.8 users (team members; query joins tenant_memberships)
ALTER TABLE users
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(full_name, '')), 'A') ||
      setweight(to_tsvector('simple', coalesce(email, '')), 'B')
    ) STORED;
CREATE INDEX idx_users_search ON users USING GIN (search_vector);

-- 5.9 vendors
ALTER TABLE vendors
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(name, '')), 'A') ||
      setweight(to_tsvector('simple', coalesce(tax_id, '')), 'B') ||
      setweight(to_tsvector('simple', coalesce(email, '')), 'C') ||
      setweight(to_tsvector('simple', coalesce(phone, '')), 'C')
    ) STORED;
CREATE INDEX idx_vendors_search ON vendors USING GIN (search_vector);

-- 5.10 receipts (receipt_number only; customer name via JOIN)
ALTER TABLE receipts
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('simple', coalesce(receipt_number, '')), 'A')
    ) STORED;
CREATE INDEX idx_receipts_search ON receipts USING GIN (search_vector);

-- 5.11 contractors
ALTER TABLE contractors
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(name, '')), 'A') ||
      setweight(to_tsvector('simple', coalesce(tax_id, '')), 'B') ||
      setweight(to_tsvector('simple', coalesce(email, '')), 'C')
    ) STORED;
CREATE INDEX idx_contractors_search ON contractors USING GIN (search_vector);

-- 5.12 leads
ALTER TABLE leads
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(name, '')), 'A') ||
      setweight(to_tsvector('hebrew', coalesce(company, '')), 'B') ||
      setweight(to_tsvector('simple', coalesce(email, '')), 'C')
    ) STORED;
CREATE INDEX idx_leads_search ON leads USING GIN (search_vector);

-- 5.13 proposals (title only; customer name via JOIN)
ALTER TABLE proposals
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(name, '')), 'A')
    ) STORED;
CREATE INDEX idx_proposals_search ON proposals USING GIN (search_vector);

-- 5.14 contracts (title only; customer name via JOIN)
ALTER TABLE contracts
  ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
      setweight(to_tsvector('hebrew', coalesce(title, '')), 'A')
    ) STORED;
CREATE INDEX idx_contracts_search ON contracts USING GIN (search_vector);
```
**Acceptance:**
- [ ] All 14 `search_vector` columns exist as `STORED` generated columns; all 14 GIN indexes exist.
- [ ] `kb_articles.content_text` is present and populated on article save.
- [ ] Inserting a row populates `search_vector` automatically (no trigger needed); `EXPLAIN` on `search_vector @@ to_tsquery('hebrew', 'x:*')` uses the GIN index.

### Task 2: Search types in `@zync/types`
**Blocks:** 3, 4, 5, 6, 9  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/search.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define and export `EntityType`, `SearchResultItem`, `SearchResultGroup`, `SearchResponse`, `FullSearchResponse` exactly as below.
- [ ] Export an ordered `ENTITY_PRIORITY: EntityType[]` and a `ENTITY_LABELS: Record<EntityType,string>` (display names: Tasks, Projects, Customers, Invoices, Receipts, Expenses, Vendors, Leads, Proposals, Contracts, Contractors, KB Articles, Support Tickets, Team Members) used for group ordering tie-breaks and group `label`.
**Schema / Interfaces:**
```typescript
export type EntityType =
  | 'task' | 'project' | 'customer' | 'invoice' | 'receipt' | 'expense'
  | 'vendor' | 'lead' | 'proposal' | 'contract' | 'contractor'
  | 'kb_article' | 'support_ticket' | 'team_member';

export interface SearchResultItem {
  id: string;                    // entity UUID
  type: EntityType;
  label: string;                 // "Task: {title}", "Invoice #INV-001", etc.
  primary: string;               // task title, customer name, ...
  secondary: string | null;      // project name, email, vendor, ...
  badge: string | null;          // status string if applicable
  url: string;                   // in-app path e.g. "/tasks/{uuid}"
  highlight: string | null;      // snippet with matched term in <mark>…</mark>
}

export interface SearchResultGroup {
  type: EntityType;
  label: string;                 // "Tasks", "Customers", ...
  items: SearchResultItem[];
  total: number;                 // total matches (may exceed items.length)
}

export interface SearchResponse {
  query: string;
  groups: SearchResultGroup[];   // ordered by result count desc, ties by ENTITY_PRIORITY
  total_groups: number;          // total groups with results (may exceed groups.length)
}

export interface FullSearchResponse {
  query: string;
  type: EntityType | null;       // echoes requested type filter
  groups: SearchResultGroup[];
  next_cursor: string | null;    // null when no more results
  has_more: boolean;
}

// Tie-break + display order (§2.4)
export const ENTITY_PRIORITY: EntityType[] = [
  'task','project','customer','invoice','receipt','expense','vendor',
  'lead','proposal','contract','contractor','kb_article','support_ticket','team_member',
];
```
**Acceptance:**
- [ ] `import { SearchResponse, FullSearchResponse, EntityType } from '@zync/types'` type-checks.

### Task 3: Search service core in `@zync/db`
**Blocks:** 4, 5  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/db/src/search/build-tsquery.ts`
- Create: `packages/db/src/search/entity-queries.ts`
- Create: `packages/db/src/search/search-service.ts`
- Modify: `packages/db/src/index.ts` (export `searchEntities`, `buildTsquery`, `ENTITY_MODULE_GATE`, `ENTITY_PERMISSION`)
**Steps:**
- [ ] Implement `buildTsquery(raw)` exactly per §6.1: strip `! & | ( ) : * ' " \`, split on whitespace, append `:*` prefix to each token, join with ` & `. Return `''` if no tokens (caller treats empty as no-match).
- [ ] Define `ENTITY_MODULE_GATE: Record<EntityType, ModuleId | 'system'>` per §1: task→`tasks`, project→`projects`, customer→`customers`, invoice→`invoices`, receipt→`invoices`, expense→`expenses`, vendor→`vendors`, lead→`marketing`, proposal→`proposals`, contract→`contracts`, contractor→`contractors`, kb_article→`kb`, support_ticket→`crm`, team_member→`system`.
- [ ] Define `ENTITY_PERMISSION: Record<EntityType,string>` per §9: task→`tasks:read`, project→`projects:read`, customer→`customers:read`, invoice→`invoices:read`, receipt→`invoices:read`, expense→`expenses:read`, vendor→`expenses:read`, lead→`marketing:read`, proposal→`marketing:read`, contract→`contracts:read`, contractor→`payouts:read`, kb_article→`kb:read`, support_ticket→`tickets:read`, team_member→`users:read`.
- [ ] For each entity implement a query builder returning `{ items, total }` for a given `(tenantId, tsquery, limit, offset)`. Each query: filters `tenant_id = $tenantId`; matches `search_vector @@ to_tsquery('hebrew', $tsquery)` (use `'hebrew'` regconfig for the query parse — prefix tokens are language-agnostic for `:*`); orders by `ts_rank_cd(search_vector, to_tsquery(...)) DESC`; computes `total` via a `COUNT(*)` over the same predicate. Build `label`, `primary`, `secondary`, `badge`, `url`, `highlight` per §2.6–§2.8.
- [ ] JOIN-augmented entities (invoice, receipt, proposal, contract) additionally OR-match `to_tsvector('hebrew', c.name) @@ to_tsquery('hebrew', $tsquery)` via `JOIN customers c ON <entity>.customer_id = c.id`.
- [ ] Team members: `JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $tenantId`.
- [ ] Implement `searchEntities({ db, tenantId, userId, role, permissions, query, limit, types?, cursor? })`:
  - Build `enabledSet` from `getEnabledModuleIds(db, tenantId)` then `enabledSet.add('system')` (§6.2).
  - For each candidate entity: skip if its `ENTITY_MODULE_GATE` ∉ `enabledSet`; skip if `ENTITY_PERMISSION` ∉ `permissions` (§9 — skip, never 403).
  - CONTRACTOR (`role === 'CONTRACTOR'`): only `task` is searchable, and tasks get the extra predicate `AND EXISTS (SELECT 1 FROM task_assignees ta WHERE ta.task_id = t.id AND ta.user_id = $userId)`. All other entities are skipped unconditionally for contractors (§6.3 visibility table).
  - KB Articles: for `VIEWER`/`CONTRACTOR` add `AND ka.published = true`; OWNER/ADMIN/MEMBER see drafts too.
  - Run permitted entity queries (parallelizable), build `SearchResultGroup[]`, drop empty groups.
  - Order groups by `total` desc, ties by `ENTITY_PRIORITY` index (§2.4).
  - Generate `highlight` via `ts_headline('hebrew', coalesce(title,''), query, 'StartSel=<mark>, StopSel=</mark>, MaxWords=10, MinWords=5, ShortWord=2')`; fall back to description/content field only when the title did not match (§6.5).
- [ ] Cursor: `encodeCursor`/`decodeCursor` over the `{entity_type}:{offset}` string (base64). Used only in `type`-scoped full search.
**Schema / Interfaces:**
```typescript
export function buildTsquery(raw: string): string;

export const ENTITY_MODULE_GATE: Record<EntityType, string>;
export const ENTITY_PERMISSION: Record<EntityType, string>;

export interface SearchParams {
  db: Db;
  tenantId: string;
  userId: string;
  role: string;                  // JWT role e.g. 'OWNER' | 'CONTRACTOR'
  permissions: string[];         // JWT permissions[]
  query: string;                 // raw user query (already length-validated)
  limit: number;                 // per-group cap
  types?: EntityType[] | null;   // null/undefined => all permitted+enabled
  cursor?: string | null;        // only honored when exactly one type
}

export async function searchEntities(
  p: SearchParams,
): Promise<{ groups: SearchResultGroup[]; totalGroups: number; nextCursor: string | null; hasMore: boolean }>;
```
**Acceptance:**
- [ ] `buildTsquery('inv (foo)')` returns `'inv:* & foo:*'`; injection chars are stripped.
- [ ] A CONTRACTOR querying returns only assigned-task results; no other entity group appears.
- [ ] An entity whose module is disabled OR whose permission is absent never appears in `groups`.
- [ ] Every item has correct `url` per §2.8 and `label` per §1.

### Task 4: `GET /api/search` palette endpoint
**Blocks:** 6  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/search.ts`
- Modify: `apps/zync-api/src/index.ts` (mount route)
**Steps:**
- [ ] Mount under `authMiddleware` (extracts `tenantId`, `userId`, `role`, `permissions[]` from session/JWT).
- [ ] Apply `rateLimit` keyed by user: 60 req/min → `429 { error: 'rate_limited' }`.
- [ ] Validate `q` (Zod): required, after trim 2–200 chars. `< 2` → `400 { error: 'query_too_short' }`; `> 200` → `400 { error: 'query_too_long' }`. Missing/invalid JWT → `401 { error: 'unauthenticated' }` (handled by `authMiddleware`).
- [ ] Parse `limit`: default 5, `clampLimit(limit, 1, 10)`.
- [ ] Call `searchEntities({ ..., limit, types: null })`.
- [ ] Build `SearchResponse`: `groups` (count-desc, tie ENTITY_PRIORITY), `total_groups`. Return all groups with results (client trims to 3 + "Show more"); do not pre-truncate groups server-side beyond `limit` items each.
**Schema / Interfaces:**
```
GET /api/search?q={query}&limit={n}   // limit default 5, max 10, per-group
-> 200 SearchResponse
   400 { error: 'query_too_short' | 'query_too_long' }
   401 { error: 'unauthenticated' }
   429 { error: 'rate_limited' }
```
**Acceptance:**
- [ ] `q` of 1 char → 400 `query_too_short`; `q` of 201 chars → 400 `query_too_long`.
- [ ] 61st request within a minute from one user → 429 `rate_limited`.
- [ ] Response groups ordered by `total` desc; each group ≤ `limit` items; disabled-module/unpermitted entities absent.

### Task 5: `GET /api/search/full` full-results endpoint
**Blocks:** 9  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-api/src/routes/search.ts` (add `/full` handler)
**Steps:**
- [ ] Same auth + rate-limit + `q` validation as Task 4.
- [ ] Parse `type` (optional `EntityType`; reject unknown values with 400 — reuse Zod enum); `limit` default 20, `clampLimit(limit, 1, 50)`; `cursor` optional opaque base64.
- [ ] If `type` set: `searchEntities({ ..., types: [type], limit, cursor })` → at most one group; honor cursor pagination per-type; compute `next_cursor` and `has_more`.
- [ ] If `type` omitted: `searchEntities({ ..., types: null, limit })` → all permitted+enabled groups, each up to `limit` items; `next_cursor = null`, `has_more = false` (All mode is not paginated — client paginates via per-type tabs, §10 / §4.2).
- [ ] Return `FullSearchResponse` echoing `type`.
**Schema / Interfaces:**
```
GET /api/search/full?q={query}&type={entityType}&cursor={cursor}&limit={n}
   // limit default 20, max 50; cursor base64 {entity_type}:{offset}; cursor only valid when type set
-> 200 FullSearchResponse
   400 { error: 'query_too_short' | 'query_too_long' | 'invalid_type' }
   401 / 429 as Task 4
```
**Acceptance:**
- [ ] `type=task` returns exactly one group (or zero); paging with returned `next_cursor` advances offset stably; `has_more=false` on last page.
- [ ] No `type` returns every entity-type group with results, `next_cursor=null`, `has_more=false`.

### Task 6: Extend `CommandModal` data layer (react-query + debounce + recent searches)
**Blocks:** 7, 8  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-app/src/components/CommandModal.tsx` (from app-shell)
- Create: `apps/zync-app/src/features/search/useSearchPalette.ts`
- Create: `apps/zync-app/src/features/search/recentSearches.ts`
**Steps:**
- [ ] Controlled input debounced 200ms → `debouncedQuery`.
- [ ] react-query: key `['search','palette', debouncedQuery]`, `enabled: debouncedQuery.length >= 2`, `staleTime: 0`, `gcTime: 30_000`, `queryFn` → `apiFetch<SearchResponse>('/api/search?q='+encodeURIComponent(debouncedQuery)+'&limit=5')`. Min length 2 (below → empty-query state).
- [ ] `recentSearches.ts`: read/write `localStorage` key `zync:recent_searches` (array of strings, most-recent first). `pushRecent(q)`: prepend, dedupe (remove earlier same string), cap 5. `getRecent()`: parse safely (return `[]` on malformed JSON).
- [ ] On every successful search (`q ≥ 2`, API returned), `pushRecent(query)`. Do NOT push when navigating via "Show more" (§2.5).
**Schema / Interfaces:**
```typescript
export function useSearchPalette(query: string): {
  data: SearchResponse | undefined; isLoading: boolean; debouncedQuery: string;
};
export function getRecent(): string[];
export function pushRecent(q: string): void; // prepend, dedupe, cap 5
```
**Acceptance:**
- [ ] No request fires for queries under 2 chars; requests are debounced 200ms.
- [ ] After a successful search, the query appears first in `zync:recent_searches`, deduped, capped at 5.
- [ ] "Show more" navigation does not write to recent searches.

### Task 7: `CommandModal` UI — quick actions, recent searches, result groups, show-more, empty state
**Blocks:** 11  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/src/components/CommandModal.tsx`
- Create: `apps/zync-app/src/features/search/QuickActions.tsx`
- Create: `apps/zync-app/src/features/search/RecentSearches.tsx`
- Create: `apps/zync-app/src/features/search/SearchGroups.tsx`
- Create: `apps/zync-app/src/features/search/ResultRow.tsx`
**Steps:**
- [ ] Component tree per §7.1: `CommandModal` (cmdk `Command` root, modal overlay) → `CommandInput` (placeholder `חפש...`) → `CommandList` → `QuickActions` + `RecentSearches` + `SearchGroups` + `ShowMoreRow` + `EmptyState`.
- [ ] Empty-query state (`query < 2`): render `RecentSearches` (last 5 from localStorage, clock icon; click populates input + fires search) — omit the section entirely if no recents (no empty-state message). `QuickActions` always shown.
- [ ] `QuickActions`: module-gated via `useModuleEnabled` — New Task (`tasks`→`/tasks/new`), New Invoice (`invoices`→`/invoices/new`), New Customer (`customers`→`/customers/new`); `+` icon in `--accent`. Always shown (also above results when query active, §2.3).
- [ ] Querying state: while loading show a skeleton row per previously-known group (`Skeleton`), or a single centered `Spinner` on first load. Quick actions remain visible.
- [ ] `SearchGroups`: group by entity type; header label `--ink-faint`, 12px, uppercase; up to 5 results per group. Show at most 3 groups before the "Show more" row; pick the 3 by `total` desc, ties by ENTITY_PRIORITY (§2.4).
- [ ] `ResultRow` (§2.6): 16×16 icon (`--ink-faint`); primary label 14px `--ink`; secondary 12px `--ink-faint`, truncate at 200px; optional 12px status `Badge` (per §2.7). Row height 40px; hover/focus bg `--surface`; selected-row bg `oklch(91% 0.012 58)`. Render `highlight` (`<mark>` background `oklch(90% 0.08 90)`, no radius) when present, sanitized to allow only `<mark>`/`</mark>`.
- [ ] `ShowMoreRow`: always last when there are results; label `Show all results for "{query}" →`; Enter/click → navigate `/search?q={query}` (do not push recent here).
- [ ] `EmptyState`: when total across all groups is 0, render "Nothing matched that." (no icon, no CTA) in place of all groups.
- [ ] Per-entity secondary label + badge mapping per §2.7 (values come from API `secondary`/`badge`; this is render-only).
**Acceptance:**
- [ ] With `tasks` disabled, the "New Task" quick action is hidden client-side.
- [ ] At most 3 groups render before "Show more"; groups chosen by total desc with priority tie-break.
- [ ] Zero results renders exactly "Nothing matched that." with no CTA.
- [ ] `<mark>` highlight renders with the soft-yellow background and no border-radius.

### Task 8: `CommandModal` keyboard, focus-trap, and a11y
**Blocks:** 11  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/src/components/CommandModal.tsx`
**Steps:**
- [ ] Global hotkey: `⌘K` / `Ctrl+K` opens; `Escape` closes (§2.1). `↑`/`↓` navigate items; `Enter` activates selected; `Tab` moves between groups (cmdk handles list nav; wire group traversal).
- [ ] Modal: overlay `rgba(0,0,0,0.4)`; surface `var(--surface)`, border `var(--line)`, radius `var(--radius)` (4px), max-width 600px, max-height 480px (scrollable `CommandList`). `role="dialog"`, `aria-modal="true"`; focus trapped while open; focus returns to trigger on close.
- [ ] Respect `prefers-reduced-motion`: disable open/close transition animation when set.
**Acceptance:**
- [ ] `⌘K`/`Ctrl+K` opens, `Escape` closes, focus is trapped, and returns to the trigger on close.
- [ ] Dialog exposes `role="dialog"` + `aria-modal="true"`; arrow keys move selection; Enter navigates.
- [ ] With reduced-motion on, no open/close animation plays.

### Task 9: Full search results page `/search`
**Blocks:** 11  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/routes/search.tsx`
- Create: `apps/zync-app/src/features/search/useFullSearch.ts`
- Modify: `apps/zync-app/src/router.tsx` (register `/search`)
**Steps:**
- [ ] Accessible to all authenticated users. Read `q` and `type` via `useSearchParams`. On mount fire `GET /api/search/full?q=...`; type-tab change refetches with `&type=...`; load-more appends `&cursor=...`.
- [ ] Layout (§3.2–§3.3): page max-width 800px centered. Search input full-width, 48px tall, `--surface` bg, `--radius` 4px border `var(--line)`, placeholder `חפש...`, autofocus on load. Clearing input shows recent searches (reuse `getRecent`). Update `q=` via `replaceState` on input change (§3.4).
- [ ] Type tabs: horizontal scrollable 32px pills; "All" always first; active tab `--accent` bg + white text; inactive `--surface` border + `--ink` text. Module-disabled entity tabs hidden (via `useModuleStore`). Team Members never shown as its own tab (only inside "All").
- [ ] Each group renders as a card (`--surface` bg, 1px `var(--line)` border, `--radius`); header = uppercase label + `(count)`, 12px `--ink-faint`, 16px padding. "All" view: up to 20 items per group. Specific type tab: all results with cursor-based load-more (button "טען עוד", secondary variant, 32px, centered) — not infinite scroll.
- [ ] Empty states (§3.3): no results anywhere OR specific tab with no results → centered "Nothing matched that." `--ink-faint` 16px, no icon, no CTA.
- [ ] Document title: `חיפוש: "{query}" — Zync`.
- [ ] URL state: `q`/`type` in query string; browser back/forward restores state (§3.4).
**Schema / Interfaces:**
```typescript
export function useFullSearch(q: string, type: EntityType | null): {
  groups: SearchResultGroup[]; nextCursor: string | null; hasMore: boolean;
  isLoading: boolean; loadMore: () => void; // loadMore valid only when a type tab is active
};
```
**Acceptance:**
- [ ] `/search?q=alpha&type=task` pre-selects the Tasks tab and lists task results with a working "טען עוד" load-more.
- [ ] Disabled-module tabs are hidden; no Team Members tab exists; team members appear only under "All".
- [ ] Back/forward restores `q` and `type`; input edits use `replaceState`.
- [ ] Document title equals `חיפוש: "{query}" — Zync`.

### Task 10: Team-member result target — honor `?highlight=` on `/settings/users`
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-app/src/routes/settings/users.tsx` (owned by `settings-module`; coordinate)
**Steps:**
- [ ] Team Member results use `url = /settings/users?highlight={id}` (§2.8). Read `highlight` from query params; scroll the matching member row into view and apply a brief highlight animation (respect `prefers-reduced-motion` — no animation, just scroll + static emphasis when reduced).
- [ ] If `settings-module` is not yet built in this wave, leave a guarded no-op that reads the param and is safe when the row is absent; the search `url` value itself is produced by Task 3 and must not change.
**Acceptance:**
- [ ] Navigating to `/settings/users?highlight={uuid}` scrolls the target row into view and briefly emphasizes it (static emphasis under reduced-motion).

### Task 11: Cross-cutting hardening — RTL, perf, security
**Blocks:** —  ·  **Blocked by:** 7, 8, 9
**Files:**
- Modify: `apps/zync-app/src/features/search/*`, `apps/zync-api/src/routes/search.ts`
**Steps:**
- [ ] RTL: all palette and page text render right-to-left; input placeholder `חפש...`; group headers, result rows, tabs, and load-more mirror correctly under `dir="rtl"`. Use logical CSS properties; verify with the RTL layout system.
- [ ] Security: `highlight` HTML is server-generated via `ts_headline` with only `<mark>`/`</mark>` selectors — sanitize on the client to allow exactly those tags (strip everything else) before any `dangerouslySetInnerHTML`. Never interpolate raw user query into SQL — all queries are parameterized; `buildTsquery` strips tsquery metacharacters (§6.1). Confirm CSP allows no inline script from search render.
- [ ] Permissions/tenant isolation: every entity query carries `tenant_id = $tenantId`; unpermitted/disabled entities skipped (never 403); CONTRACTOR restrictions enforced server-side unconditionally (§6.3, §10). Add a test asserting a CONTRACTOR cannot retrieve unassigned tasks or any non-task entity.
- [ ] Performance: confirm GIN index scans (Task 1 EXPLAIN); per-entity queries run concurrently; debounce 200ms on palette; `staleTime:0`/`gcTime:30_000` for palette, no cache shared with full-page search (§7.2).
**Acceptance:**
- [ ] Palette and `/search` render correctly RTL with the Hebrew placeholder.
- [ ] Client sanitizes `highlight` to `<mark>` only; no other HTML survives.
- [ ] CONTRACTOR search returns only assigned tasks and no other entity (server-enforced test passes).
- [ ] Each entity search uses its GIN index (verified via EXPLAIN).
