# Zync.is Public Marketing & Auth Site (`zync-www`) — Implementation Plan

**Spec:** docs/specs/2026-05-31-zync-www-marketing-site.md  ·  **Slug:** zync-www-marketing-site  ·  **Wave:** 3
**Depends on:** foundation-auth-rbac, foundation-design-system, foundation-monorepo, system-i18n, zync-subscription

## Goal
Build `apps/zync-www`, the public-facing Astro site at `zync.is`, covering marketing (landing, pricing) and authentication (login, signup, password reset, invite acceptance). All pages render statically (`output: 'static'`) with React islands only for interactive forms — maximising SEO crawlability and Core Web Vitals. The site is Hebrew-first (`<html dir="rtl" lang="he">`), consumes the existing `@zync/ui` primitives and the existing `apps/zync-api` auth routes, and sets no new database tables. It is entirely separate from the authenticated SPA at `app.zync.is` (`apps/zync-app`).

## Architecture
- **New app:** `apps/zync-www` — an Astro 4 app added to the Turborepo workspace. Output is a fully static build (SSG); the only client-side JS is the React form islands hydrated with `client:load`.
- **No new backend / DB:** This spec adds zero tables and zero server endpoints. All auth interaction is HTTP `fetch` from React islands to `apps/zync-api`. Cookies (`zync_session`, `zync_refresh`) are set httpOnly by the API on `Domain=.zync.is`; the island only redirects via `window.location.href = PUBLIC_APP_URL`.
- **Consumed upstream interfaces (exact names):**
  - `@zync/ui` primitives: `Button`, `Input`, `Card`, `Badge`. (Per spec, NO new primitives are created in `zync-www`; any missing primitive is added to `@zync/ui` instead.)
  - `@zync/config` Tailwind preset at `packages/config/tailwind.preset.ts` (OKLCH tokens, 8px grid, `--radius: 4px`). Imported as `@zync/config/tailwind.preset`.
  - Auth routes on `apps/zync-api` (canonical names from `foundation-auth-rbac`): `POST /api/auth/login`, `POST /api/auth/signup`, `POST /api/auth/forgot-password`, `POST /api/auth/reset-password`. Plus invite routes named by this spec on the API side: `GET /api/auth/invite/:token`, `POST /api/auth/invite/accept`.
  - Tier/price facts align with `zync-subscription` (`TenantTier`: `freelancer` / `business` / `enterprise`; Business 89 ILS/mo, Enterprise 159 ILS/mo). Prices are hardcoded constants in `src/lib/pricing.ts` — NO API call from the pricing page.
- **Endpoint reconciliation (canonical wins — transcribe these into the islands):**
  - Reset *request* page → `POST /api/auth/forgot-password` body `{ email }`, always 204 (no enumeration). (The spec prose says `POST /api/auth/reset-password` for the request page; the canonical auth-rbac route for "send me a reset link" is `forgot-password`. Use `forgot-password` for the request, `reset-password` for the confirm.)
  - Reset *confirm* page → `POST /api/auth/reset-password` body `{ token, password }`, 204 on success, 400 on expired/used token.
  - Signup body uses canonical `{ email, password, name }` shape. The business name field maps to `name` in the POST body (label remains "שם העסק").
- **Data flow per page:** static Astro shell rendered at build time → React island hydrates on load → island validates with Zod via `react-hook-form` → island POSTs to API → on success redirects to `PUBLIC_APP_URL` (or `${PUBLIC_APP_URL}/onboarding` for signup).

## Tech Stack
- **App:** `apps/zync-www` (Astro 4, `@astrojs/react` 3, React 18).
- **Forms:** `react-hook-form` 7 + `zod` 3 + `@hookform/resolvers` 3.
- **Styling:** `tailwindcss` 3 via shared `@zync/config` preset; `@zync/ui` for primitives.
- **Images:** `astro:assets` `<Image>` pipeline (AVIF → WebP → PNG).
- **Build env vars (Astro `PUBLIC_`, inlined at build):** `PUBLIC_API_BASE_URL`, `PUBLIC_APP_URL`.
- **Runtime/deploy:** static assets served from Cloudflare (no Worker runtime needed for `zync-www` itself; auth runtime lives in `apps/zync-api`). Part of Turborepo + pnpm workspace.
- **No Cloudflare bindings** consumed directly by this app (it is static).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| W1 — Scaffold | 1, 2 | `apps/zync-www/{package.json,astro.config.ts,tailwind.config.ts,tsconfig.json}`, root `pnpm-workspace.yaml`/`turbo.json` | No (foundation for all) |
| W2 — Shared shell | 3, 4 | `src/layouts/BaseLayout.astro`, `src/layouts/AuthLayout.astro`, `src/components/Nav.astro`, `src/components/Footer.astro`, `public/favicon.svg` | Layouts before pages; Nav/Footer parallel |
| W3 — Lib | 5, 6 | `src/lib/pricing.ts`, `src/lib/api.ts`, `src/lib/schemas/*.ts` | Yes (pure, no UI deps) |
| W4 — Landing | 7, 8 | `src/components/landing/*.astro`, `src/pages/index.astro`, `src/assets/*` | After W2/W3 |
| W5 — Pricing | 9, 10 | `src/components/pricing/*`, `src/pages/pricing.astro` | After W2/W3; parallel with W4 |
| W6 — Auth islands | 11, 12, 13, 14, 15 | `src/components/auth/*.tsx`, auth `src/pages/*.astro` | After W3 (schemas+api); the 5 islands parallel |
| W7 — Polish | 16, 17 | `src/pages/404.astro`, SEO/CWV verification | After all pages |

## Tasks

### Task 1: Scaffold `apps/zync-www` Astro app & wire into monorepo
**Blocks:** 2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-www/package.json`
- Create: `apps/zync-www/astro.config.ts`
- Create: `apps/zync-www/tsconfig.json`
- Create: `apps/zync-www/public/favicon.svg`
- Modify: `pnpm-workspace.yaml` (ensure `apps/*` glob covers it — only edit if not already covered)
- Modify: `turbo.json` (ensure `build`/`dev` pipeline applies to the new app — only edit if app-specific config needed)
**Steps:**
- [ ] Create `package.json` with name `@zync/zync-www`, `private: true`, scripts `dev: astro dev`, `build: astro build`, `preview: astro preview`, `check: astro check`.
- [ ] Add dependencies: `astro ^4`, `@astrojs/react ^3`, `react ^18`, `react-dom ^18`, `react-hook-form ^7`, `zod ^3`, `@hookform/resolvers ^3`.
- [ ] Add devDependencies: `@zync/config workspace:*`, `@zync/ui workspace:*`, `tailwindcss ^3`, `typescript ^5`, `@astrojs/check`, `@types/react`, `@types/react-dom`.
- [ ] Write `astro.config.ts` with `output: 'static'`, `integrations: [react()]`, and `i18n: { defaultLocale: 'he', locales: ['he'], routing: { prefixDefaultLocale: false } }`.
- [ ] Write `tsconfig.json` extending Astro strict base; enable `jsx: react-jsx`, `strict: true`.
- [ ] Add a minimal `public/favicon.svg`.
- [ ] Confirm `pnpm install` resolves the workspace and `pnpm --filter @zync/zync-www build` produces a static `dist/`.
**Schema / Interfaces:**
```ts
// apps/zync-www/astro.config.ts
import { defineConfig } from 'astro/config'
import react from '@astrojs/react'

export default defineConfig({
  output: 'static',
  integrations: [react()],
  i18n: {
    defaultLocale: 'he',
    locales: ['he'],
    routing: { prefixDefaultLocale: false },
  },
})
```
**Acceptance:**
- [ ] `pnpm --filter @zync/zync-www build` exits 0 and emits a static `dist/`.
- [ ] No `output: 'server'` / SSR adapter present.

### Task 2: Tailwind config inheriting `@zync/config` preset
**Blocks:** 3,7,9  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-www/tailwind.config.ts`
**Steps:**
- [ ] Import the shared preset and register it; no local color or spacing definitions.
- [ ] Set `content` to scan `./src/**/*.{astro,ts,tsx}`.
**Schema / Interfaces:**
```ts
// apps/zync-www/tailwind.config.ts
import preset from '@zync/config/tailwind.preset'
import type { Config } from 'tailwindcss'

export default {
  presets: [preset],
  content: ['./src/**/*.{astro,ts,tsx}'],
} satisfies Config
```
**Acceptance:**
- [ ] No `colors`, `spacing`, or `borderRadius` keys are redefined locally (all inherited from preset).
- [ ] OKLCH tokens (`--bg`, `--surface`, `--ink`, `--accent`, `--danger`, `--radius`) resolve in built CSS.

### Task 3: `BaseLayout.astro` — global HTML/head/body shell
**Blocks:** 4,7,9,16  ·  **Blocked by:** 1,2
**Files:**
- Create: `apps/zync-www/src/layouts/BaseLayout.astro`
**Steps:**
- [ ] Emit `<html dir="rtl" lang="he">` on every page (non-negotiable RTL/i18n requirement).
- [ ] Accept props: `title: string`, `description?: string`, `robots?: 'index' | 'noindex'` (default `index`).
- [ ] Render `<title>{title}</title>`; conditionally render `<meta name="description">` when `description` provided; render `<meta name="robots" content="noindex">` when `robots === 'noindex'`.
- [ ] Include `<meta charset="utf-8">`, `<meta name="viewport" content="width=device-width, initial-scale=1">`, favicon link.
- [ ] Import the `@zync/ui` token CSS (`packages/ui/src/tokens/index.css`) so OKLCH custom properties are present; no hardcoded hex/rgb/hsl anywhere.
- [ ] Add a CSP-friendly setup: no inline event handlers, no `localStorage` for auth, no third-party script tags (preserve security cross-cutting authority).
- [ ] Provide a `<slot />` for page body.
**Schema / Interfaces:**
```ts
// Props
interface Props {
  title: string
  description?: string
  robots?: 'index' | 'noindex'
}
```
**Acceptance:**
- [ ] Every page rendered through `BaseLayout` has `dir="rtl" lang="he"` on `<html>`.
- [ ] `noindex` pages emit `<meta name="robots" content="noindex">`; `index` pages do not.
- [ ] No `localStorage`/`sessionStorage` token access anywhere in the layout.

### Task 4: `AuthLayout.astro` + `Nav.astro` + `Footer.astro`
**Blocks:** 7,11,12,13,14,15  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-www/src/layouts/AuthLayout.astro`
- Create: `apps/zync-www/src/components/Nav.astro`
- Create: `apps/zync-www/src/components/Footer.astro`
**Steps:**
- [ ] `AuthLayout.astro`: wraps `BaseLayout`; renders `min-h-screen bg-[--bg] flex flex-col items-center justify-center`; centered Zync logo above the form linking to `/`; max-width form container 400px (`bg-[--surface]`, `rounded`, `p-8`). No sidebar, no nav, no footer. Provide `<slot />`.
- [ ] `Nav.astro`: sticky, 12-col, `bg-[--surface]`, `border-b: 1px solid var(--line)`. Logo at start; links "מוצר" / "תמחור"; end actions "התחבר" (→ `/login`) and "התחל בחינם" (→ `/signup`). NO blur/frosted-glass effect. Use logical spacing (`ms-*`/`me-*`/`ps-*`/`pe-*`) only.
- [ ] `Footer.astro`: single row. Logo + legal links at start, copyright at end. No social icons, no decorative elements.
- [ ] All directional spacing logical; never `ml-*`/`mr-*`/`pl-*`/`pr-*`/`text-left`/`text-right`.
**Acceptance:**
- [ ] `AuthLayout` renders form container at exactly `max-width: 400px`, `--radius` corners, `--surface` background.
- [ ] No physical-direction Tailwind classes in any of the three files.
- [ ] Nav has no backdrop-blur / frosted-glass classes.

### Task 5: Pricing constants & API client helper
**Blocks:** 9,11,12,13,14,15  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-www/src/lib/pricing.ts`
- Create: `apps/zync-www/src/lib/api.ts`
**Steps:**
- [ ] `pricing.ts`: export `TIERS` constant with hardcoded prices. Business: monthly 89, annualPerMonth 74, annualLump 888. Enterprise: monthly 159, annualPerMonth 132, annualLump 1584. (Annual = monthly × 10; annualPerMonth = floor(annualLump / 12).)
- [ ] `api.ts`: export a typed `apiFetch(path, init)` helper that prefixes `import.meta.env.PUBLIC_API_BASE_URL`, always sends `credentials: 'include'` (so httpOnly cookies are set cross-origin), `Content-Type: application/json`, and returns `{ status, data }`. Export `APP_URL = import.meta.env.PUBLIC_APP_URL`.
- [ ] No prices are hardcoded anywhere except `pricing.ts` (anti-pattern enforcement).
**Schema / Interfaces:**
```ts
// src/lib/pricing.ts
export const TIERS = {
  business: { monthly: 89, annualPerMonth: 74, annualLump: 888 },
  enterprise: { monthly: 159, annualPerMonth: 132, annualLump: 1584 },
} as const
export type BillingPeriod = 'monthly' | 'annual'

// src/lib/api.ts
export const APP_URL = import.meta.env.PUBLIC_APP_URL as string
const BASE = import.meta.env.PUBLIC_API_BASE_URL as string
export async function apiFetch<T = unknown>(
  path: string,
  init?: RequestInit,
): Promise<{ status: number; data: T }> {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    credentials: 'include',
    headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
  })
  let data: T
  try { data = (await res.json()) as T } catch { data = undefined as T }
  return { status: res.status, data }
}
```
**Acceptance:**
- [ ] `apiFetch` always uses `credentials: 'include'`.
- [ ] No numeric price literal appears in any component file — only in `pricing.ts`.

### Task 6: Zod form schemas (all auth forms)
**Blocks:** 11,12,13,14,15  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-www/src/lib/schemas/login.ts`
- Create: `apps/zync-www/src/lib/schemas/signup.ts`
- Create: `apps/zync-www/src/lib/schemas/reset-request.ts`
- Create: `apps/zync-www/src/lib/schemas/reset-confirm.ts`
- Create: `apps/zync-www/src/lib/schemas/invite.ts`
**Steps:**
- [ ] Transcribe each schema verbatim with Hebrew error messages (below).
- [ ] Export inferred input types for each (`LoginInput`, `SignupInput`, etc.).
**Schema / Interfaces:**
```ts
// login.ts
import { z } from 'zod'
export const loginSchema = z.object({
  email: z.string().email('כתובת דוא"ל לא תקינה'),
  password: z.string().min(1, 'נדרשת סיסמה'),
})
export type LoginInput = z.infer<typeof loginSchema>

// signup.ts
export const signupSchema = z.object({
  businessName: z.string().min(2, 'שם העסק חייב להכיל לפחות 2 תווים').max(100),
  email: z.string().email('כתובת דוא"ל לא תקינה'),
  password: z.string().min(8, 'הסיסמה חייבת להכיל לפחות 8 תווים'),
})
export type SignupInput = z.infer<typeof signupSchema>

// reset-request.ts
export const resetRequestSchema = z.object({
  email: z.string().email('כתובת דוא"ל לא תקינה'),
})
export type ResetRequestInput = z.infer<typeof resetRequestSchema>

// reset-confirm.ts
export const resetConfirmSchema = z.object({
  password: z.string().min(8, 'הסיסמה חייבת להכיל לפחות 8 תווים'),
  confirm: z.string(),
}).refine(d => d.password === d.confirm, {
  message: 'הסיסמאות אינן תואמות',
  path: ['confirm'],
})
export type ResetConfirmInput = z.infer<typeof resetConfirmSchema>

// invite.ts
export const inviteNewUserSchema = z.object({
  fullName: z.string().min(2, 'נדרש שם מלא'),
  password: z.string().min(8, 'הסיסמה חייבת להכיל לפחות 8 תווים'),
})
export type InviteNewUserInput = z.infer<typeof inviteNewUserSchema>
export const inviteExistingUserSchema = z.object({
  password: z.string().min(1, 'נדרשת סיסמה'),
})
export type InviteExistingUserInput = z.infer<typeof inviteExistingUserSchema>
```
**Acceptance:**
- [ ] All five schema files compile; each exports its inferred type.
- [ ] Hebrew error messages match the spec verbatim.

### Task 7: Landing components (`Hero`, `FeatureSection`, `PricingPreview`, `PricingCard`)
**Blocks:** 8  ·  **Blocked by:** 3,4
**Files:**
- Create: `apps/zync-www/src/components/landing/Hero.astro`
- Create: `apps/zync-www/src/components/landing/FeatureSection.astro`
- Create: `apps/zync-www/src/components/landing/PricingPreview.astro`
- Create: `apps/zync-www/src/components/landing/PricingCard.astro`
- Create: `apps/zync-www/src/assets/hero-screenshot.png` (placeholder asset ≤ 2000px wide)
- Create: `apps/zync-www/src/assets/feature-1.png`, `feature-2.png`, `feature-3.png` (placeholder assets ≤ 2000px wide)
**Steps:**
- [ ] `Hero.astro`: asymmetric 12-col. H1 "ניהול פרויקטים שמבין אותך" at `col-start-2 col-end-8`; body (two sentences) at `col-start-2 col-end-7`; single CTA "התחל בחינם" → `/signup` (use `@zync/ui` `Button`). Screenshot `<Image>` at `col-start-8 col-end-12`, `loading="eager"`, declared `widths={[640,1024,1440]}` and `formats={['avif','webp','png']}`, meaningful Hebrew `alt`, declared width/height to prevent CLS. 48px top / 96px bottom padding. No secondary CTA. No gradient overlay.
- [ ] `FeatureSection.astro`: props `{ heading, body, image, imageSide: 'start' | 'end', badge?, cta? }`. Renders alternating screenshot/text layout per the three wireframes. Section-3 variant supports a badge ("תוכנית עסקי ומעלה", `--accent-soft` bg / `--accent` text via `@zync/ui` `Badge`) and a single CTA. Body ≤ two sentences, no bullet lists, no decorative icons. Feature images use `widths={[480,800]}`, `loading="lazy"`.
- [ ] `PricingCard.astro`: props `{ name, priceLabel, ctaLabel, ctaHref, badge?, highlight? }`; uses `@zync/ui` `Card` (`--radius: 4px`); Business card `border-color: var(--accent)`.
- [ ] `PricingPreview.astro`: H2 "בחר תוכנית" at `col-start-2 col-end-8`; three `PricingCard`s (Freelancer "חינם לנצח"; Business badge "הכי פופולרי", "89 ₪/חודש"; Enterprise "צרו קשר"); trailing link "לפרטים מלאים" → `/pricing`.
- [ ] All pure Astro — zero client-side JS in these components.
**Acceptance:**
- [ ] Hero image is the LCP candidate (`loading="eager"`, preload emitted by Astro pipeline).
- [ ] Exactly one CTA per section; no logo strip, no bento grid, no gradient, no decorative icons.
- [ ] Grid columns use the RTL-relative positions from the spec.

### Task 8: Landing page (`/`) assembly + SEO
**Blocks:** 16  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-www/src/pages/index.astro`
**Steps:**
- [ ] Compose `BaseLayout` (title `Zync — ניהול פרויקטים לעצמאיים ועסקים בישראל`, description per spec, `robots: index`) → `Nav` → `Hero` → three `FeatureSection`s (sections 1–3 per spec, section 3 = API/Integrations with Business badge + CTA "צפו בתיעוד ה-API" → `/pricing`) → `PricingPreview` → `Footer`.
- [ ] Zero React islands on this page (fully static, zero JS).
**Schema / Interfaces:**
```html
<title>Zync — ניהול פרויקטים לעצמאיים ועסקים בישראל</title>
<meta name="description" content="Zync מאחדת משימות, לקוחות, חיובים וזמן בממשק אחד. לעצמאיים ועסקים קטנים. חינם לצמיתות." />
```
**Acceptance:**
- [ ] Built `/index.html` contains the full marketing copy (no JS-gated content) — verifiable by viewing source.
- [ ] No `client:*` directive present on the landing page.
- [ ] `<meta name="robots">` is `index` (or absent → defaults index).

### Task 9: Pricing static components (`TierCard`, `ComparisonTable`, `PricingFAQ`)
**Blocks:** 10  ·  **Blocked by:** 3,4,5
**Files:**
- Create: `apps/zync-www/src/components/pricing/TierCard.astro`
- Create: `apps/zync-www/src/components/pricing/ComparisonTable.astro`
- Create: `apps/zync-www/src/components/pricing/PricingFAQ.astro`
**Steps:**
- [ ] `TierCard.astro`: uses `@zync/ui` `Card`. Props per tier from the spec table (display name, monthly/annual price labels, badge, CTA label + action, team members). Business: badge "הכי פופולרי", `border-color: var(--accent)`. Enterprise CTA → `mailto:sales@zync.is`; Freelancer/Business CTA → `/signup`. Renders BOTH monthly and annual price strings in `data-*` attributes (or both spans, one hidden) so `PricingToggle` can swap them with zero API call.
- [ ] `ComparisonTable.astro`: full-width feature matrix exactly per spec (groups: ניהול ליבה, פיננסים, צוות, אינטגרציות ו-API, תמיכה). Checkmarks are plain Unicode "✓" / dash "—" — no icon library. Include an `id` anchor on the "אינטגרציות ו-API" row group so the landing "צפו בתיעוד ה-API" link can deep-link.
- [ ] `PricingFAQ.astro`: 5 items using native `<details>`/`<summary>` (no JS accordion). Questions/answers transcribed verbatim from spec (trial 14 ימים, שדרוג, ביטול, ארגוני vs White Label, אמצעי תשלום). Include the White Label mention here only.
- [ ] All pure Astro.
**Acceptance:**
- [ ] FAQ is native `<details>`/`<summary>`; no accordion JS.
- [ ] Comparison checkmarks are Unicode only; no icon library imported.
- [ ] White Label appears only in the FAQ/comparison "בנפרד" cell, never as a 4th card.

### Task 10: `PricingToggle` island + pricing page (`/pricing`) + SEO
**Blocks:** 16  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-www/src/components/pricing/PricingToggle.tsx`
- Create: `apps/zync-www/src/pages/pricing.astro`
**Steps:**
- [ ] `PricingToggle.tsx`: React island holding `billingPeriod: 'monthly' | 'annual'` local state. On toggle, swaps displayed prices by toggling the visible price span / updating `aria-pressed`. Reads constants from `src/lib/pricing.ts`; NO API call. Toggle label "שנתי — חיסכון של חודשיים". Accessible: the toggle is a real `role="group"` of two `button`/radio controls with `aria-pressed`/`aria-checked`, keyboard operable, and honours `prefers-reduced-motion` (no animated transitions when reduced).
- [ ] `pricing.astro`: `BaseLayout` (title `תמחור — Zync`, description per spec, `robots: index`) → `Nav` → H1 "תמחור" (`col-start-2 col-end-8`) + subline "ללא כרטיס אשראי. ביטול בכל עת." → `PricingToggle` (`client:load`) wrapping the three `TierCard`s → `ComparisonTable` → `PricingFAQ` → `Footer`.
- [ ] Card content is fully present in static HTML; the island only switches which price string is visible.
**Schema / Interfaces:**
```html
<title>תמחור — Zync</title>
<meta name="description" content="פרילנסר, עסקי וארגוני. החל מחינם לצמיתות. ראה מה כלול בכל תוכנית." />
```
**Acceptance:**
- [ ] All tier/price content is in static HTML (crawlable) before hydration.
- [ ] Toggle makes zero network requests.
- [ ] Toggle is keyboard-operable with correct ARIA pressed/checked state and respects `prefers-reduced-motion`.

### Task 11: `LoginForm` island + login page (`/login`)
**Blocks:** —  ·  **Blocked by:** 4,5,6
**Files:**
- Create: `apps/zync-www/src/components/auth/LoginForm.tsx`
- Create: `apps/zync-www/src/pages/login.astro`
**Steps:**
- [ ] `LoginForm.tsx`: `react-hook-form` + `zodResolver(loginSchema)`, `defaultValues { email:'', password:'' }`. Uses `@zync/ui` `Input` + `Button`. Password field has a functional show/hide toggle (Unicode `◎`/`◉` or minimal SVG, `type="button"`, `aria-label` Hebrew, `aria-pressed`) — not decorative. "שכחת?" link → `/reset-password`. Footer link "אין לך חשבון? התחל בחינם" → `/signup`.
- [ ] Submit → `apiFetch('/api/auth/login', { method:'POST', body: JSON.stringify({ email, password }) })`. On 200: `window.location.href = APP_URL`. On 401: banner "דוא\"ל או סיסמה שגויים" (no field-level attribution — no enumeration). On lockout/429: banner "החשבון נעול זמנית. נסה שוב עוד כמה דקות." On network error: banner "שגיאת תקשורת. נסה שוב."
- [ ] Banner is a `role="alert"` region above the form; field validation errors render inline beneath fields in `color: var(--danger)`.
- [ ] Submit button shows "..." and is `disabled` while in-flight. No skeletons.
- [ ] `login.astro`: `AuthLayout` (title `כניסה — Zync`, `robots: noindex`) wrapping `<LoginForm client:load />`.
**Schema / Interfaces:**
```
POST /api/auth/login
Body: { email: string, password: string }
200 → sets httpOnly cookies zync_session, zync_refresh (Domain=.zync.is) → redirect APP_URL
401 → { error: 'invalid_credentials' } (generic banner, no field attribution)
```
**Acceptance:**
- [ ] Wrong credentials show a single generic banner, never a field-specific error.
- [ ] Password toggle is keyboard-reachable with Hebrew `aria-label` and `aria-pressed`.
- [ ] Page is `noindex`; no `localStorage` token handling.

### Task 12: `SignupForm` island + signup page (`/signup`)
**Blocks:** —  ·  **Blocked by:** 4,5,6
**Files:**
- Create: `apps/zync-www/src/components/auth/SignupForm.tsx`
- Create: `apps/zync-www/src/pages/signup.astro`
**Steps:**
- [ ] `SignupForm.tsx`: `react-hook-form` + `zodResolver(signupSchema)`. Fields: "שם העסק" (businessName), "דוא\"ל" (email), "סיסמה (לפחות 8 תווים)" (password with show/hide toggle). Terms line beneath button: "בלחיצה על 'יצירת חשבון' אתה מסכים לתנאי השירות ולמדיניות הפרטיות". Footer link "יש לך חשבון? כנס" → `/login`.
- [ ] Submit → `apiFetch('/api/auth/signup', { method:'POST', body: JSON.stringify({ name: businessName, email, password }) })` (maps businessName → canonical `name`). On 201/200: `window.location.href = `${APP_URL}/onboarding``. On 409: banner "כתובת הדוא\"ל כבר רשומה. רצית להתחבר?" + link to `/login`. On 422: render `fields` errors inline. On network error: banner "שגיאת תקשורת. נסה שוב."
- [ ] In-flight: button "..." + `disabled`. Banner `role="alert"`.
- [ ] `signup.astro`: `AuthLayout` (title `יצירת חשבון — Zync`, `robots: noindex`) wrapping `<SignupForm client:load />`.
**Schema / Interfaces:**
```
POST /api/auth/signup
Body (canonical): { name: string, email: string, password: string }
Success (201/200) → sets httpOnly cookies → redirect ${APP_URL}/onboarding
409 → { error: 'email_already_registered' }
422 → { error: 'validation_error', fields: {...} }
```
**Acceptance:**
- [ ] POST body uses canonical key `name` (mapped from the "שם העסק" field).
- [ ] Duplicate email shows the banner with a `/login` link.
- [ ] Redirect target is `${APP_URL}/onboarding`. Page is `noindex`.

### Task 13: `ResetRequestForm` island + reset request page (`/reset-password`)
**Blocks:** —  ·  **Blocked by:** 4,5,6
**Files:**
- Create: `apps/zync-www/src/components/auth/ResetRequestForm.tsx`
- Create: `apps/zync-www/src/pages/reset-password/index.astro`
**Steps:**
- [ ] `ResetRequestForm.tsx`: `react-hook-form` + `zodResolver(resetRequestSchema)`. Single email field. Heading "איפוס סיסמה" + helper "הזן את הדוא\"ל שלך ונשלח לך קישור." Back link "חזרה להתחברות" → `/login`.
- [ ] Submit → `apiFetch('/api/auth/forgot-password', { method:'POST', body: JSON.stringify({ email }) })`. API always responds 204 (no enumeration). On any non-network response, replace the form with success message "אם כתובת הדוא\"ל רשומה, שלחנו לך קישור לאיפוס. בדוק את תיבת הדואר." On network error: banner "שגיאת תקשורת. נסה שוב."
- [ ] `reset-password/index.astro`: `AuthLayout` (title `איפוס סיסמה — Zync`, `robots: noindex`) wrapping `<ResetRequestForm client:load />`.
**Schema / Interfaces:**
```
POST /api/auth/forgot-password   (canonical auth-rbac route for "send reset link")
Body: { email: string }
Response: 204 always (no email enumeration)
```
**Acceptance:**
- [ ] Same success message shown regardless of whether the email exists (no enumeration).
- [ ] Calls `/api/auth/forgot-password`, not `/api/auth/reset-password`. Page is `noindex`.

### Task 14: `ResetConfirmForm` island + reset confirm page (`/reset-password/confirm`)
**Blocks:** —  ·  **Blocked by:** 4,5,6
**Files:**
- Create: `apps/zync-www/src/components/auth/ResetConfirmForm.tsx`
- Create: `apps/zync-www/src/pages/reset-password/confirm.astro`
**Steps:**
- [ ] `ResetConfirmForm.tsx`: on mount, read `token` from `window.location.search`. If absent → render expired state "הקישור לא תקף או פג תוקפו. ניתן לבקש קישור חדש." + button → `/reset-password`. Otherwise render the form (`react-hook-form` + `zodResolver(resetConfirmSchema)`): "סיסמה חדשה (לפחות 8 תווים)" + "אימות סיסמה", both with show/hide toggles. Heading "בחירת סיסמה חדשה".
- [ ] Submit → `apiFetch('/api/auth/reset-password', { method:'POST', body: JSON.stringify({ token, password }) })`. On 204/200: `window.location.href = APP_URL`. On 400/410: switch to the expired state.
- [ ] `reset-password/confirm.astro`: `AuthLayout` (title `איפוס סיסמה — Zync`, `robots: noindex`) wrapping `<ResetConfirmForm client:load />`.
**Schema / Interfaces:**
```
POST /api/auth/reset-password   (canonical auth-rbac route for "set new password with token")
Body: { token: string, password: string }
Success (204/200) → cookies set → redirect APP_URL
Failure (400 invalid_token / 410 token_expired) → show expired state
```
**Acceptance:**
- [ ] Missing/invalid token renders the expired state with a `/reset-password` button.
- [ ] Token is read client-side from the URL; never sent to any analytics/log. Page is `noindex`.

### Task 15: `InviteAcceptForm` island + invite accept page (`/invite/accept`)
**Blocks:** —  ·  **Blocked by:** 4,5,6
**Files:**
- Create: `apps/zync-www/src/components/auth/InviteAcceptForm.tsx`
- Create: `apps/zync-www/src/pages/invite/accept.astro`
**Steps:**
- [ ] `InviteAcceptForm.tsx`: on mount, read `token` from URL, then `apiFetch('/api/auth/invite/' + token)`. Branch on response:
  - 404/410 → invalid/expired state: "ההזמנה לא תקפה או פגה תוקפה. פנה למנהל הארגון לקבלת הזמנה חדשה."
  - 200 `{ email, tenantName, inviterName, isNewUser }` → render header "הצטרפות ל-{tenantName}" + "הוזמנת על ידי {inviterName}"; email field pre-filled and read-only.
- [ ] New user (`isNewUser === true`): `zodResolver(inviteNewUserSchema)` — fields "שמך המלא" + "בחר סיסמה" (toggle). Submit → `apiFetch('/api/auth/invite/accept', { method:'POST', body: JSON.stringify({ token, fullName, password }) })`.
- [ ] Existing user (`isNewUser === false`): `zodResolver(inviteExistingUserSchema)` — "כניסה לאישור ההזמנה" + password field (toggle). Submit → `apiFetch('/api/auth/invite/accept', { method:'POST', body: JSON.stringify({ token, password }) })`.
- [ ] On 200: `window.location.href = APP_URL`. On 401 (existing wrong password): inline/banner "סיסמה שגויה". On 410: switch to expired state.
- [ ] In-flight: button "..." + `disabled`. Banner `role="alert"`.
- [ ] `invite/accept.astro`: `AuthLayout` (title `הזמנה לצוות — Zync`, `robots: noindex`) wrapping `<InviteAcceptForm client:load />`.
**Schema / Interfaces:**
```
GET /api/auth/invite/:token
Success (200): { email, tenantName, inviterName, isNewUser: boolean }
404 → { error: 'not_found' }   410 → { error: 'expired' }

POST /api/auth/invite/accept
Body (new user):      { token, fullName, password }
Body (existing user): { token, password }
Success (200) → cookies set → redirect APP_URL
401 → { error: 'invalid_password' }   410 → { error: 'token_expired' }
```
**Acceptance:**
- [ ] Email field is pre-filled from invite metadata and read-only (not editable).
- [ ] New-user vs existing-user sub-state is chosen by `isNewUser` from the GET response.
- [ ] Page is `noindex`; token read client-side only.

### Task 16: Global 404 page
**Blocks:** 17  ·  **Blocked by:** 8,10
**Files:**
- Create: `apps/zync-www/src/pages/404.astro`
**Steps:**
- [ ] Static `BaseLayout` page (`robots: noindex`) with `Nav` + a centered "404" message in Hebrew + a CTA link back to `/`. Use logical spacing; OKLCH tokens only.
**Acceptance:**
- [ ] `dist/404.html` is generated by the static build.
- [ ] Page renders with RTL layout and no JS island.

### Task 17: SEO / RTL / Core Web Vitals verification pass
**Blocks:** —  ·  **Blocked by:** 8,10,11,12,13,14,15,16
**Files:**
- Modify: any page where `<title>`/`robots`/`<Image>` dimensions need correction (no new files)
**Steps:**
- [ ] Verify the SEO table: `/` and `/pricing` are `index` with descriptions; all auth pages + 404 are `noindex` with the exact titles from the spec.
- [ ] Verify every `<Image>` declares width+height (or aspect ratio) — CLS guard. Hero is `loading="eager"`; all others `loading="lazy"`; formats AVIF→WebP→PNG.
- [ ] Confirm hero preload (`<link rel="preload" as="image" type="image/avif">`) is emitted in `<head>` for `/`.
- [ ] Grep the built `src` for banned patterns: hex/rgb/hsl colors, `ml-`/`mr-`/`pl-`/`pr-`/`text-left`/`text-right`, `rounded-sm|md|lg|xl`, off-grid spacing, `localStorage`/`sessionStorage`, any price literal outside `pricing.ts`, gradient/blur classes, more than one CTA per section.
- [ ] Confirm landing page emits zero `client:*` directives; the only islands are the 5 auth forms + `PricingToggle`.
**Acceptance:**
- [ ] Lighthouse (or equivalent) targets: LCP ≤ 2.5s, CLS ≤ 0.1, INP ≤ 200ms on `/`.
- [ ] No banned pattern found by grep across `apps/zync-www/src`.
- [ ] Every page's `<html>` is `dir="rtl" lang="he"`; every auth/404 page is `noindex`; `/` and `/pricing` are `index` with descriptions.
