# Project Template Gallery — Implementation Plan

**Spec:** docs/specs/2026-06-01-project-templates.md  ·  **Slug:** project-templates  ·  **Wave:** 6
**Depends on:** foundation-auth-rbac, projects-module, tasks-detail-communication

## Goal
Deliver reusable project templates: tenants define predefined task/phase/milestone/checklist structures plus billing pre-fill, then apply a template when creating a project to auto-generate its tasks. Includes a template gallery UI (system + tenant templates), a preview modal, an apply-to-new-project flow that computes absolute task dates from relative offsets, and a "save existing project as template" path. Reduces setup time for recurring project types (Brand Identity, Web App MVP, Monthly Retainer, etc.).

## Architecture
Two new tables — `project_templates` (system templates have `tenant_id IS NULL`; tenant templates are scoped) and `project_template_tasks` (ordered task blueprints with relative date offsets, checklist JSON, tags, and `assigned_role`). The apply path runs inside a single DB transaction that calls the upstream `createProject` (projects-module) and `createTask` (tasks-board-engine) helpers, mapping each template task to a concrete task. Relative day offsets (`relative_start_days`/`relative_due_days`) become absolute dates via `start_date + offset`; `assigned_role` resolves to the first active project/tenant member holding that role; `tags` become `task_labels` rows (PK `(task_id, label)` from tasks-board-engine); `checklist_items` and `phase` land on new `tasks` columns.

Consumes upstream: `tenants(id)`, `users(id)`, `projects` (+ `createProject`), `tasks` (+ `createTask`, `task_labels`), `authMiddleware`, `requirePermission`, `tenantQuery`, `buildPaginated`, `Pagination`, and the `projects:write` / `projects:read` permission scopes. UI consumes design-system primitives (`Card`, `Dialog`, `Button`, `Sheet`, `Input`, `Select`, `Badge`, `EmptyState`, `Form`, `useDirection`, `LocaleProvider`/`translations`).

**Cross-module column ownership (build-blocking — resolved here):** the `tasks` schema deltas `start_date DATE` and `is_milestone BOOLEAN NOT NULL DEFAULT false` are needed by THIS spec's apply path and project-templates (wave 6) builds BEFORE project-gantt (wave 8) which also lists them. project-templates therefore OWNS and declares these two columns plus its own `checklist_items` and `phase`. project-gantt must reference them (or declare idempotently with `IF NOT EXISTS`) and only own its remaining deltas (`end_date`, `parent_task_id`, `depends_on_task_ids`, the GIN index). Migrations here use `ADD COLUMN IF NOT EXISTS` to stay collision-safe.

## Tech Stack
- **apps/zync-api** (Hono on Cloudflare Workers): REST routes under `/api/project-templates`, `/api/projects/:id/save-as-template`, `/api/projects/from-template`; transactional apply service; Zod request schemas; Drizzle queries via Neon Postgres over Hyperdrive.
- **packages/db** (Drizzle): table definitions + migration SQL + seed for system templates.
- **apps/zync-app** (Vite + React): `/projects/templates` gallery route, preview modal, apply wizard step, "save as template" action; TanStack Query hooks; Zustand not required (server-state only).
- **Cloudflare bindings:** `DB` (Hyperdrive→Neon). No new bindings.
- **Libraries:** `date-fns` (`addDays`), `zod`, `@tanstack/react-query`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 6.1 Schema | 1, 2 | packages/db schema + migrations | No (foundation for all) |
| 6.2 Seed | 3 | packages/db seed | After 6.1 |
| 6.3 API core | 4, 5, 6 | zync-api routes, services, validation | After 6.1; 4/5/6 partly parallel |
| 6.4 Apply/save | 7, 8 | zync-api apply + save-as-template | After 4, 5 |
| 6.5 UI | 9, 10, 11, 12 | zync-app gallery, preview, apply wizard, save action | After 6.3/6.4; 9–12 parallel |
| 6.6 i18n + a11y | 13 | locale strings, a11y pass | After 9–12 |

## Tasks

### Task 1: Create `project_templates` and `project_template_tasks` tables
**Blocks:** 2, 3, 4, 5, 6, 7, 8  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/project-templates.ts`
- Modify: `packages/db/src/schema/index.ts` (export new tables)
**Steps:**
- [ ] Define `projectTemplates` Drizzle table matching the DDL below.
- [ ] Define `projectTemplateTasks` Drizzle table matching the DDL below.
- [ ] Add the composite index `idx_template_tasks_template` on `(template_id, position)`.
- [ ] Export both from the schema barrel; add inferred row types `ProjectTemplate`, `NewProjectTemplate`, `ProjectTemplateTask`, `NewProjectTemplateTask`.
**Schema / Interfaces:**
```sql
CREATE TABLE project_templates (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID REFERENCES tenants(id) ON DELETE CASCADE,
    -- NULL = system template (global, read-only, available to all tenants)
  name            TEXT NOT NULL,
  description     TEXT,
  category        TEXT CHECK (category IN ('design','development','marketing','consulting','other')),
  estimated_days  INTEGER,
  billing_type    TEXT CHECK (billing_type IN ('fixed','hourly','retainer')),
  thumbnail_emoji TEXT DEFAULT '📋',
  is_public       BOOLEAN NOT NULL DEFAULT false,
  usage_count     INTEGER NOT NULL DEFAULT 0,
  created_by      UUID REFERENCES users(id),
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE project_template_tasks (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  template_id         UUID NOT NULL REFERENCES project_templates(id) ON DELETE CASCADE,
  title               TEXT NOT NULL,
  description         TEXT,
  phase               TEXT,
  position            INTEGER NOT NULL DEFAULT 0,
  relative_start_days INTEGER,
  relative_due_days   INTEGER,
  is_milestone        BOOLEAN NOT NULL DEFAULT false,
  checklist_items     JSONB NOT NULL DEFAULT '[]'::jsonb,   -- [{ "text": "...", "required": true }]
  estimated_hours     NUMERIC(6,2),
  assigned_role       TEXT CHECK (assigned_role IN ('MEMBER','ADMIN','CONTRACTOR')),
  tags                TEXT[]
);

CREATE INDEX idx_template_tasks_template ON project_template_tasks(template_id, position);
```
**Acceptance:**
- [ ] `pnpm --filter @zync/db build` succeeds; tables generate in migration output.
- [ ] All FKs are UUID→UUID; `category`/`billing_type`/`assigned_role` enforce CHECK enums verbatim.

### Task 2: `tasks` schema deltas for applied template fields
**Blocks:** 7  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/migrations/<ts>_project_templates.sql` (or Drizzle-generated migration)
- Modify: `packages/db/src/schema/tasks.ts` (add `start_date`, `is_milestone`, `phase`, `checklist_items` columns to existing `tasks` table)
**Steps:**
- [ ] Add the four columns to the existing `tasks` Drizzle table definition (do not redefine the table).
- [ ] Emit migration SQL using `ADD COLUMN IF NOT EXISTS` so project-gantt's later `is_milestone`/`start_date` redeclaration cannot double-add.
- [ ] Document in the migration header that project-templates OWNS `tasks.start_date` and `tasks.is_milestone`; project-gantt references them.
**Schema / Interfaces:**
```sql
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS checklist_items JSONB NOT NULL DEFAULT '[]'::jsonb;
  -- [{ "text": "...", "required": bool, "done": bool }] — inline checklist on task detail (spec 12)
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS phase TEXT;
  -- optional phase/section label; groups tasks under a header in list/Gantt views
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS start_date DATE;
  -- owned here; consumed by project-gantt (wave 8) as Gantt bar start
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS is_milestone BOOLEAN NOT NULL DEFAULT false;
  -- owned here; consumed by project-gantt (wave 8) for diamond markers
```
**Acceptance:**
- [ ] Migration applies cleanly on a fresh DB and is idempotent on re-run.
- [ ] Applying project-gantt's migration afterward does not error (no duplicate-column failure).

### Task 3: Seed system templates (`tenant_id = NULL`)
**Blocks:** 9  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/seed/project-templates.seed.ts`
- Modify: `packages/db/src/seed/index.ts` (register seed)
**Steps:**
- [ ] Insert seven system templates with `tenant_id = NULL`, `created_by = NULL`, `is_public = true`, correct `category`, `estimated_days`, `thumbnail_emoji`, and `usage_count = 0`.
- [ ] For each, insert its `project_template_tasks` rows with sensible `phase`, `position`, `relative_start_days`/`relative_due_days`, `is_milestone`, and `estimated_hours`. Task counts must match the table below.
- [ ] Make the seed idempotent (skip insert if a system template with the same `name` and `tenant_id IS NULL` already exists).
**Schema / Interfaces:**
| Name | category | estimated_days | tasks | emoji |
|------|----------|----------------|-------|-------|
| Brand Identity | design | 30 | 8 | 🎨 |
| Web App MVP | development | 60 | 12 | 💻 |
| Marketing Campaign | marketing | 14 | 8 | 📣 |
| Website Redesign | design | 45 | 10 | 🖥️ |
| Monthly Retainer | consulting | NULL (recurring; billing_type='retainer') | 3 | 📋 |
| Product Launch | marketing | 90 | 15 | 🚀 |
| Client Onboarding | consulting | 7 | 5 | 🤝 |
**Acceptance:**
- [ ] Seed run produces exactly 7 system templates and the listed task counts.
- [ ] Re-running the seed does not duplicate rows.

### Task 4: Template + template-task CRUD routes
**Blocks:** 7, 8, 9, 10  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/project-templates/index.ts`
- Create: `apps/zync-api/src/routes/project-templates/templates.repo.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount router)
**Steps:**
- [ ] Implement the eight CRUD endpoints below using `authMiddleware` + `requirePermission`.
- [ ] List query: `WHERE tenant_id = :tenantId OR tenant_id IS NULL`, ordered system-first then by `usage_count DESC, name ASC`; support `category` and `q` (name ILIKE) filters and pagination via `buildPaginated`.
- [ ] Detail returns the template plus its ordered `project_template_tasks`.
- [ ] Mutation guard: PATCH/DELETE on a template and all `/tasks` sub-mutations reject when `tenant_id IS NULL` (system template, read-only) OR `tenant_id != caller tenant` → 403.
- [ ] All write bodies validated by Zod schemas (Task 6).
- [ ] All DB access goes through the repo (`tenantQuery`), never raw Drizzle from the route (lint rule `no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```
GET    /api/project-templates                 → list (system + tenant's own); requires projects:read
POST   /api/project-templates                 → create from scratch;          requires projects:write
GET    /api/project-templates/:id             → detail + tasks;               requires projects:read
PATCH  /api/project-templates/:id             → edit (tenant-owned only);     requires projects:write
DELETE /api/project-templates/:id             → delete (tenant-owned only);   requires projects:write
GET    /api/project-templates/:id/tasks       → list template tasks;          requires projects:read
POST   /api/project-templates/:id/tasks       → add task;                     requires projects:write
PATCH  /api/project-templates/:id/tasks/:tid  → edit task;                    requires projects:write
DELETE /api/project-templates/:id/tasks/:tid  → remove task;                  requires projects:write
```
```ts
export interface ProjectTemplateDetail extends ProjectTemplate { tasks: ProjectTemplateTask[] }
export function listTemplates(db: Db, tenantId: string, opts: { category?: string; q?: string; page: number; pageSize: number }): Promise<PaginatedResponse<ProjectTemplate>>
export function getTemplate(db: Db, templateId: string, tenantId: string): Promise<ProjectTemplate | null> // returns row if tenant-owned OR system (tenant_id IS NULL)
export function getTemplateTasks(db: Db, templateId: string): Promise<ProjectTemplateTask[]> // ordered by position
export function createTemplate(db: Db, tenantId: string, userId: string, input: CreateTemplateInput): Promise<ProjectTemplate>
export function updateTemplate(db: Db, templateId: string, tenantId: string, patch: UpdateTemplateInput): Promise<ProjectTemplate>
export function deleteTemplate(db: Db, templateId: string, tenantId: string): Promise<void>
export function addTemplateTask(db: Db, templateId: string, tenantId: string, input: TemplateTaskInput): Promise<ProjectTemplateTask>
export function updateTemplateTask(db: Db, templateId: string, taskId: string, tenantId: string, patch: Partial<TemplateTaskInput>): Promise<ProjectTemplateTask>
export function removeTemplateTask(db: Db, templateId: string, taskId: string, tenantId: string): Promise<void>
export function incrementTemplateUsage(db: Db, templateId: string): Promise<void> // UPDATE ... SET usage_count = usage_count + 1
```
**Acceptance:**
- [ ] System templates appear in list for every tenant; PATCH/DELETE on a system template returns 403.
- [ ] A tenant cannot read/edit another tenant's template (404/403).
- [ ] List supports `?category=` and `?q=` filters and returns `PaginatedResponse`.

### Task 5: Role resolution helper
**Blocks:** 7  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/projects/resolve-member-role.ts`
**Steps:**
- [ ] Implement `resolveFirstMemberWithRole(tx, tenantId, role)` returning the `user_id` of the first active member holding the given role, or `null` if none.
- [ ] Resolve against `tenant_memberships` (role on the tenant) joined to `users` filtered to active users; deterministic ordering (`users.created_at ASC`) so the result is stable.
- [ ] Map template `assigned_role` values (`MEMBER`/`ADMIN`/`CONTRACTOR`) to the corresponding membership role; return `null` for an unmatched role rather than throwing.
**Schema / Interfaces:**
```ts
export function resolveFirstMemberWithRole(tx: Db, tenantId: string, role: 'MEMBER' | 'ADMIN' | 'CONTRACTOR'): Promise<string | null>
```
**Acceptance:**
- [ ] Returns a stable `user_id` when a matching active member exists; `null` otherwise (task left unassigned, no throw).

### Task 6: Zod validation schemas
**Blocks:** 4, 7, 8  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/src/routes/project-templates/schemas.ts`
**Steps:**
- [ ] Define and export the Zod schemas below (route handlers must validate every body — lint rule `require-zod-validation-in-routes`).
- [ ] `checklist_items` validates each entry as `{ text: string; required: boolean }`.
- [ ] `category`, `billing_type`, `assigned_role` use enum members matching the CHECK constraints exactly.
**Schema / Interfaces:**
```ts
export const checklistItemSchema = z.object({ text: z.string().min(1), required: z.boolean().default(false) })
export const templateTaskInputSchema = z.object({
  title: z.string().min(1),
  description: z.string().optional(),
  phase: z.string().optional(),
  position: z.number().int().min(0).default(0),
  relative_start_days: z.number().int().nullable().optional(),
  relative_due_days: z.number().int().nullable().optional(),
  is_milestone: z.boolean().default(false),
  checklist_items: z.array(checklistItemSchema).default([]),
  estimated_hours: z.number().nonnegative().nullable().optional(),
  assigned_role: z.enum(['MEMBER', 'ADMIN', 'CONTRACTOR']).nullable().optional(),
  tags: z.array(z.string()).default([]),
})
export const createTemplateSchema = z.object({
  name: z.string().min(1),
  description: z.string().optional(),
  category: z.enum(['design', 'development', 'marketing', 'consulting', 'other']).optional(),
  estimated_days: z.number().int().positive().nullable().optional(),
  billing_type: z.enum(['fixed', 'hourly', 'retainer']).nullable().optional(),
  thumbnail_emoji: z.string().optional(),
  is_public: z.boolean().default(false),
  tasks: z.array(templateTaskInputSchema).default([]),
})
export const updateTemplateSchema = createTemplateSchema.partial().omit({ tasks: true })
export const saveAsTemplateSchema = z.object({
  name: z.string().min(1),
  description: z.string().optional(),
  category: z.enum(['design', 'development', 'marketing', 'consulting', 'other']).optional(),
})
export const fromTemplateSchema = z.object({
  template_id: z.string().uuid(),
  project: z.object({
    name: z.string().min(1),
    customer_id: z.string().uuid().nullable().optional(),
    start_date: z.string(), // ISO date
    billing_type: z.enum(['fixed', 'hourly', 'retainer']),
  }).passthrough(),
})
export type CreateTemplateInput = z.infer<typeof createTemplateSchema>
export type UpdateTemplateInput = z.infer<typeof updateTemplateSchema>
export type TemplateTaskInput = z.infer<typeof templateTaskInputSchema>
export type CreateProjectFromTemplateInput = z.infer<typeof fromTemplateSchema>
```
**Acceptance:**
- [ ] Invalid bodies (bad enum, missing `name`, malformed checklist) return 400 with field errors.

### Task 7: `createProjectFromTemplate` apply service + route
**Blocks:** 11  ·  **Blocked by:** 2, 4, 5, 6
**Files:**
- Create: `apps/zync-api/src/routes/projects/from-template.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount `POST /api/projects/from-template`)
**Steps:**
- [ ] Load template via `getTemplate` (404 if not visible to tenant) and its tasks via `getTemplateTasks`.
- [ ] Open a single `db.transaction`. Inside: call upstream `createProject(tx, projectData)` (projects-module) with tenant + `billing_type` + `start_date` pre-filled.
- [ ] For each template task, compute `start_date = addDays(projectStart, relative_start_days)` and `due_date = addDays(projectStart, relative_due_days)` when offsets are non-null; leave null otherwise.
- [ ] Resolve `assignee_id` via `resolveFirstMemberWithRole(tx, tenantId, assigned_role)` (null when no offset/role).
- [ ] **Resolve required NOT NULL task fields the spec pseudo-code omits:** set `reporter_id` = acting user id (from `authMiddleware` session); set `status_id` = the project's default status — since a freshly created project has no project-scoped `task_statuses`, fall back to the tenant-global default status set (`project_id IS NULL`), choosing the first non-terminal status by `position` (BACKLOG). Also set `priority` to its default (`'medium'`) and `position` from the template task's `position`.
- [ ] Call upstream `createTask(tx, {...})` per template task with: `project_id`, `tenant_id`, `title`, `description`, `phase`, `start_date`, `due_date`, `is_milestone`, `checklist_items`, `estimated_hours`, `assignee_id`, `reporter_id`, `status_id`, `position`.
- [ ] For each `tag` in `templateTask.tags`, insert a `task_labels` row `{ task_id, label }` (PK `(task_id, label)`), de-duplicating.
- [ ] After the loop, `incrementTemplateUsage(templateId)` within the same transaction; return the created project.
- [ ] Endpoint requires `projects:write`; validate body with `fromTemplateSchema`.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/routes/projects/from-template.ts
export async function createProjectFromTemplate(
  db: Db,
  templateId: string,
  projectData: CreateProjectFromTemplateInput['project'],
  tenantId: string,
  actingUserId: string,
): Promise<Project>
// POST /api/projects/from-template
// body: { template_id, project: { name, customer_id?, start_date, billing_type, ... } }
// Requires: projects:write
```
Per-task date computation: `addDays(new Date(project.start_date), relative_*_days)` via `date-fns`. `status_id` resolution: `SELECT id FROM task_statuses WHERE tenant_id = :t AND project_id IS NULL AND is_terminal = false ORDER BY position ASC LIMIT 1`.
**Acceptance:**
- [ ] Applying a system template creates a project plus one task per template task in one transaction (all-or-nothing).
- [ ] Computed task `start_date`/`due_date` equal `project.start_date + relative offset`; null offsets → null dates.
- [ ] Every created task has non-null `status_id` and `reporter_id`; tasks with `assigned_role` resolving to a member are assigned, others unassigned.
- [ ] `tags` become `task_labels` rows; template `usage_count` increments by 1.

### Task 8: `POST /api/projects/:id/save-as-template`
**Blocks:** 12  ·  **Blocked by:** 4, 6
**Files:**
- Create: `apps/zync-api/src/routes/projects/save-as-template.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount route)
**Steps:**
- [ ] Load the project (404 if not in tenant) and its tasks ordered by `position`.
- [ ] In a transaction: create a `project_templates` row (`tenant_id` = caller tenant, `created_by` = acting user, `billing_type` from project, `is_public = false`, `category` from body) and one `project_template_tasks` row per task.
- [ ] Convert absolute task dates to relative offsets from `project.start_date`: `relative_start_days = differenceInDays(task.start_date, project.start_date)`, `relative_due_days = differenceInDays(task.due_date, project.start_date)` (null when the task date is null).
- [ ] Carry over `title`, `description`, `phase`, `position`, `estimated_hours`, `is_milestone`, `checklist_items`, and `tags` (read from `task_labels` for that task).
- [ ] Endpoint requires `projects:write`; validate body with `saveAsTemplateSchema`.
**Schema / Interfaces:**
```ts
export async function saveProjectAsTemplate(
  db: Db, projectId: string, tenantId: string, userId: string,
  input: { name: string; description?: string; category?: string },
): Promise<ProjectTemplate>
// POST /api/projects/:id/save-as-template  body: { name, description, category }  Requires: projects:write
```
**Acceptance:**
- [ ] A new tenant-owned template appears in "My Templates" reflecting the project's current task structure.
- [ ] Relative offsets round-trip: applying the saved template to a project starting on the original date reproduces the original dates.

### Task 9: Template gallery page (`/projects/templates`)
**Blocks:** 13  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-app/src/pages/projects/TemplatesGalleryPage.tsx`
- Create: `apps/zync-app/src/features/project-templates/useTemplates.ts`
- Create: `apps/zync-app/src/features/project-templates/TemplateCard.tsx`
- Modify: `apps/zync-app/src/router.tsx` (add route, OWNER/ADMIN-gated for management actions)
**Steps:**
- [ ] `useTemplates` / `useTemplate` TanStack Query hooks hitting the CRUD routes.
- [ ] Render two sections: "System Templates" (`tenant_id IS NULL`) first, then "My Templates"; show task count and `~{estimated_days} days` per `TemplateCard`.
- [ ] Toolbar: category filter (`Select`), search (`Input`, debounced → `q`), "+ New template" (OWNER/ADMIN only).
- [ ] Card actions: system → `[Use] [Preview]`; tenant → `[Use] [Edit] [Delete]`. Wire Use→apply wizard (Task 11), Preview→modal (Task 10), Edit→template editor sheet, Delete→confirm `Dialog`.
- [ ] Use `EmptyState` (from error-empty-states) when "My Templates" is empty.
- [ ] Cards are keyboard-operable: each action is a real `Button`; card is reachable by Tab; visible focus ring (no hover-only actions).
**Acceptance:**
- [ ] System templates render before tenant templates; counts and durations correct.
- [ ] Edit/Delete actions are absent on system templates; "+ New template" hidden for non-OWNER/ADMIN.
- [ ] Filter + search narrow the list; empty "My Templates" shows the empty state.

### Task 10: Template preview modal
**Blocks:** 13  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/project-templates/TemplatePreviewModal.tsx`
**Steps:**
- [ ] On open, fetch template detail + tasks; group tasks by `phase`, ordered by `position`.
- [ ] Render each phase as a section header with day range derived from min `relative_start_days` / max `relative_due_days`; show each task with its duration and milestones (`📍`) distinctly.
- [ ] Header shows name, `{n} tasks · ~{estimated_days} days`, and a `[Use template]` button → apply wizard (Task 11).
- [ ] Use `Dialog` with `role="dialog"`, `aria-modal="true"`, labelled by the template name, focus trapped on open, focus restored on close, Escape closes.
**Acceptance:**
- [ ] Modal lists all template tasks grouped by phase with correct day ranges and milestone markers.
- [ ] Dialog traps focus, is Escape-dismissable, and restores focus to the trigger.

### Task 11: Apply-template wizard step (create project from template)
**Blocks:** 13  ·  **Blocked by:** 7, 10
**Files:**
- Create: `apps/zync-app/src/features/project-templates/ApplyTemplateForm.tsx`
- Modify: `apps/zync-app/src/pages/projects/NewProjectWizard.tsx` (add "Start from template" step)
**Steps:**
- [ ] Add a "Start from template" step to the new-project wizard; selecting a template pre-fills project name, `customer` (Select), and `billing_type` from the template.
- [ ] Add a required `start_date` input; live-preview the generated tasks with computed dates (`start_date + relative_*_days`), editable before confirm.
- [ ] On confirm, POST `/api/projects/from-template` with `{ template_id, project: {...} }`; on success navigate to the new project; invalidate templates + projects queries.
- [ ] Surface validation/permission errors via `toast`; disable confirm while pending.
**Acceptance:**
- [ ] Selecting a template pre-fills fields; changing `start_date` recomputes the previewed task dates.
- [ ] Confirm creates the project and tasks and routes to the project detail page.

### Task 12: "Save as template" action on project detail
**Blocks:** 13  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/features/project-templates/SaveAsTemplateDialog.tsx`
- Modify: `apps/zync-app/src/pages/projects/ProjectDetailPage.tsx` (add action to header menu)
**Steps:**
- [ ] Add "Save as template" to the project header actions (visible with `projects:write`).
- [ ] Dialog collects `name`, `description`, `category`; on submit POST `/api/projects/:id/save-as-template`; on success toast + invalidate templates query.
- [ ] `Dialog` a11y: modal role, focus trap, Escape close, labelled form fields via `FormLabel`.
**Acceptance:**
- [ ] Saving creates a tenant template that appears in the gallery's "My Templates" section.

### Task 13: i18n strings + RTL/a11y pass
**Blocks:** —  ·  **Blocked by:** 9, 10, 11, 12
**Files:**
- Modify: `packages/i18n/src/locales/en.json`, `packages/i18n/src/locales/he.json` (add `projectTemplates.*` keys)
- Modify: gallery/preview/apply/save components to consume `translations` + `useDirection`
**Steps:**
- [ ] Add English + Hebrew strings for all template UI copy (section titles, button labels, filters, empty state, dialog titles, validation messages).
- [ ] Ensure gallery grid, preview phases, and dialogs honor `useDirection()` (logical properties, no hard-coded left/right) so Hebrew renders RTL.
- [ ] Respect `prefers-reduced-motion` for any card/modal transitions (no non-essential animation when reduced).
- [ ] Verify no hard-coded colors/spacing/radii (lint rules `no-hardcoded-colors`, `no-hardcoded-spacing`, `no-radius-ladder`); all surfaces use design tokens.
**Acceptance:**
- [ ] Switching locale to `he-IL` renders the gallery and dialogs RTL with translated copy.
- [ ] a11y: modals trap focus and are Escape-dismissable; cards/actions are keyboard-operable; design-token lint rules pass.
