# Contract Signing Page (`/sign/{token}`) — Implementation Plan

**Spec:** docs/specs/2026-05-31-contract-signing-page.md  ·  **Slug:** contract-signing-page  ·  **Wave:** 11
**Depends on:** contracts-esignature, foundation-design-system, white-label-api

## Goal
Deliver the public, unauthenticated page where a contract recipient reviews and signs (or declines) a contract, served from `zync-www` at `zync.is/sign/{token}`. The recipient has no Zync account; the opaque token is the access capability. This spec owns the page implementation (SSR Astro host, React signing island, signature capture UX, all page states, white-label branding, locale/RTL) and two Foundation Deltas (`RATE_LIMITER_SIGN` binding and the `GET /api/sign/:token/pdf` endpoint). The data model, the `GET`/`POST /api/sign/:token` and `POST /api/sign/:token/decline` endpoints, PDF generation, and the completion flow are owned upstream by `contracts-esignature` (spec 48) and are consumed here, not rebuilt.

## Architecture
- **Host page (zync-www, Astro SSR):** `apps/zync-www/src/pages/sign/[token].astro`, `output: 'hybrid'`. Server-side it calls the existing public endpoint `GET /api/sign/:token` (owned by `contracts-esignature`) to fetch `{ contract, signatory, waitingFor?, tenantBranding }`. It resolves locale (`he`/`en`) and direction (`rtl`/`ltr`) from `Accept-Language` and `tenantBranding.locale`, applies white-label CSS overrides, selects the correct page state, and hands the data to a React island.
- **Signing island (React):** `apps/zync-www/src/islands/ContractSigningIsland.tsx`, hydrated `client:load`. Renders the active signing experience: scrollable sanitized contract HTML, Draw/Type signature tabs (`signature_pad` + self-hosted Dancing Script), name/email fields, agreement checkbox, Sign/Decline actions, decline modal. Submits to `POST /api/sign/:token` and `POST /api/sign/:token/decline`, then transitions to terminal states client-side without reload.
- **Upstream tables consumed (owned by `contracts-esignature`):** `contracts` (`title`, `content`, `status`, `signed_pdf_r2_key`, `signed_at`, `voided_at`, `void_reason`), `contract_signatories` (`token`, `token_expires_at`, `order`, `signed_at`, `declined_at`, `decline_reason`, `name`, `email`, `signature_data`, `signature_type`, `ip_address`, `user_agent`), `contract_audit_log`. No new tables are created by this spec.
- **Branding source:** `tenantBranding` is delivered inline by `GET /api/sign/:token` ( `{ logoR2Key, primaryColor, tenantName, customDomain, locale }` ). The `customDomain` flag derives from `tenant_domains` (white-label-api). Logo is read public-read from the existing `R2` binding via `logoR2Key`.
- **HTML rendering security:** contract `content` (Tiptap JSONB) is rendered to HTML via `@tiptap/html` `generateHTML(content, extensions)` then sanitized with DOMPurify before `dangerouslySetInnerHTML`, using the exact allow-list from spec 48 (shared `renderContractHTML` helper). Applies in both the SSR waiting/read-only view and the island.
- **Two Foundation Deltas this spec owns:** (1) `RATE_LIMITER_SIGN` CF native RateLimiter binding (20 req/min per IP per token) added to the API worker `wrangler.toml` and applied to `/api/sign/*`; (2) `GET /api/sign/:token/pdf` route on the API worker returning 302 to a short-TTL (15 min) R2 signed URL once `contracts.signed_pdf_r2_key` is set.

## Tech Stack
- **Apps:** `apps/zync-www` (Astro + React islands, `output: 'hybrid'`), `apps/zync-api` (Hono on Cloudflare Workers — hosts the new public PDF endpoint and the rate limiter binding).
- **Packages:** `@zync/ui` (rich-editor `extensions` export for `generateHTML`; design tokens / `cn`), `@zync/types` (shared response types), `@zync/db` + Drizzle (read-only queries against `contracts` / `contract_signatories` for the PDF endpoint).
- **Libraries:** `signature_pad` (MIT) for canvas capture; `@fontsource/dancing-script` (self-hosted, no Google Fonts CDN — CSP-safe) for the Type tab; `dompurify` + `@tiptap/html` for safe HTML rendering.
- **Cloudflare bindings:** `RATE_LIMITER_SIGN` (new, native RateLimiter), `R2` (existing — logo + signed PDF), `DB`/Hyperdrive (existing — read for PDF endpoint).
- **Cross-cutting:** CSP forbids external font/script CDNs (font self-hosted, Vite-hashed); RTL when locale `he`; `prefers-reduced-motion` respected on state transitions; aria roles on tabs, canvas, modal, and error banners; timing is not security-sensitive here (token entropy + expiry guard access).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11.a Foundation deltas | 1, 2 | `apps/zync-api/wrangler.toml`, `apps/zync-api/src/routes/sign.ts`, `apps/zync-api/src/middleware/rate-limit-sign.ts` | Task 1 and Task 2 parallel |
| 11.b Shared rendering + types | 3 | `apps/zync-www/src/lib/contract-render.ts`, `apps/zync-www/src/lib/sign-locale.ts`, `packages/types/src/sign.ts` | After 1–2; parallel with each other internally |
| 11.c Astro host + states | 4, 5 | `apps/zync-www/src/pages/sign/[token].astro`, `apps/zync-www/src/components/sign/*` | After 3 |
| 11.d Signing island | 6, 7, 8 | `apps/zync-www/src/islands/ContractSigningIsland.tsx`, signature sub-components | After 3; island can build alongside 4–5 |
| 11.e Wiring + a11y/RTL + verify | 9, 10 | island + astro + styles | After 4–8 |

## Tasks

### Task 1: Add `RATE_LIMITER_SIGN` binding and apply it to `/api/sign/*`
**Blocks:** 2  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-api/wrangler.toml`
- Create: `apps/zync-api/src/middleware/rate-limit-sign.ts`
- Modify: `apps/zync-api/src/types/env.ts` (or the existing `Env` interface for the API worker)
**Steps:**
- [ ] Add a CF native RateLimiter binding `RATE_LIMITER_SIGN` to `apps/zync-api/wrangler.toml`, following the existing `RATE_LIMITER_EXPENSE_UPLOAD` pattern, configured to 20 requests per 60-second period.
- [ ] Add `RATE_LIMITER_SIGN: RateLimit` to the worker `Env` type.
- [ ] Create `rateLimitSign` middleware that derives the rate-limit key as `${clientIp}:${token}` (client IP from `CF-Connecting-IP`; `token` from the route param) and calls `env.RATE_LIMITER_SIGN.limit({ key })`. On `success === false`, return HTTP 429 with `{ error: 'rate_limited' }` and a `Retry-After` header.
- [ ] Export the middleware for mounting on all `/api/sign/*` routes.
**Schema / Interfaces:**
```toml
# apps/zync-api/wrangler.toml
[[unsafe.bindings]]
name = "RATE_LIMITER_SIGN"
type = "ratelimit"
namespace_id = "1101"           # unique namespace id; do not collide with existing limiters
simple = { limit = 20, period = 60 }
```
```ts
// apps/zync-api/src/middleware/rate-limit-sign.ts
import type { MiddlewareHandler } from 'hono'
export const rateLimitSign: MiddlewareHandler<{ Bindings: Env }> = async (c, next) => {
  const ip = c.req.header('CF-Connecting-IP') ?? 'unknown'
  const token = c.req.param('token') ?? 'none'
  const { success } = await c.env.RATE_LIMITER_SIGN.limit({ key: `${ip}:${token}` })
  if (!success) {
    return c.json({ error: 'rate_limited' }, 429, { 'Retry-After': '60' })
  }
  await next()
}
```
**Acceptance:**
- [ ] `RATE_LIMITER_SIGN` resolves at runtime; 21 requests within 60s from one IP+token returns 429 on the 21st.
- [ ] Limiter key is per IP **per token** (two different tokens from the same IP have independent budgets).

### Task 2: Add public `GET /api/sign/:token/pdf` endpoint (302 → R2 signed URL)
**Blocks:** 9  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/routes/sign.ts` (the existing public sign router owned by `contracts-esignature`; this spec adds one route + applies `rateLimitSign`)
**Steps:**
- [ ] Mount `rateLimitSign` (Task 1) on the `/api/sign/*` router group so `GET`, `POST`, `POST /decline`, and the new `/pdf` route are all covered.
- [ ] Implement `GET /api/sign/:token/pdf` with NO auth middleware. Look up the signatory by `contract_signatories.token` (plaintext UUID, indexed via `idx_signatories_token`) and join its `contracts` row.
- [ ] If the token does not resolve → 404 `{ error: 'not_found' }`. If `token_expires_at < now()` → 410 `{ error: 'expired' }`. If `contracts.signed_pdf_r2_key` is null (PDF not yet generated) → 409 `{ error: 'pdf_not_ready' }`.
- [ ] Otherwise create a short-TTL (15 minutes) R2 signed/presigned URL for `signed_pdf_r2_key` and return HTTP 302 with `Location` set to that URL.
- [ ] Write a `contract_audit_log` row (`event = 'downloaded'`, `actor_type = 'signatory'`, `actor_id = signatory.email`, `ip_address`, `user_agent`) before redirecting.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/routes/sign.ts  (route added to existing public router; no auth)
// Consumes upstream tables owned by contracts-esignature:
//   contract_signatories(token TEXT UNIQUE, token_expires_at TIMESTAMPTZ, contract_id UUID, email TEXT)
//   contracts(id UUID, signed_pdf_r2_key TEXT)
//   contract_audit_log(contract_id UUID, event TEXT, actor_type TEXT, actor_id TEXT, ip_address TEXT, user_agent TEXT)
sign.get('/:token/pdf', async (c) => {
  const token = c.req.param('token')
  const row = await getSignatoryWithContractByToken(c.env, token) // { signatory, contract } | null
  if (!row) return c.json({ error: 'not_found' }, 404)
  if (new Date(row.signatory.token_expires_at) < new Date()) return c.json({ error: 'expired' }, 410)
  if (!row.contract.signed_pdf_r2_key) return c.json({ error: 'pdf_not_ready' }, 409)
  const url = await signR2Url(c.env.R2, row.contract.signed_pdf_r2_key, 15 * 60) // 15-min TTL
  await appendContractAudit(c.env, {
    contract_id: row.contract.id, event: 'downloaded', actor_type: 'signatory',
    actor_id: row.signatory.email, ip_address: c.req.header('CF-Connecting-IP') ?? null,
    user_agent: c.req.header('User-Agent') ?? null,
  })
  return c.redirect(url, 302)
})
```
**Acceptance:**
- [ ] Unauthenticated request with a valid signed token returns 302 to an R2 URL that expires in ≤15 minutes.
- [ ] Returns 404 (bad token), 410 (expired), 409 (`signed_pdf_r2_key` null) for the respective conditions.
- [ ] An audit row with `event='downloaded'`, `actor_type='signatory'` is written per successful download.

### Task 3: Shared response types, locale resolver, and safe HTML renderer
**Blocks:** 4, 5, 6  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/sign.ts`
- Modify: `packages/types/src/index.ts` (re-export)
- Create: `apps/zync-www/src/lib/sign-locale.ts`
- Create: `apps/zync-www/src/lib/contract-render.ts`
**Steps:**
- [ ] Define the `GET /api/sign/:token` response type (`SignPageData`) and the `SignSubmitBody` / `SignDeclineBody` request types in `packages/types/src/sign.ts`, matching the spec 48 wire contract verbatim (snake_case request bodies; nested camelCase response objects as the signing-page spec specifies). Export from the package index.
- [ ] Implement `resolveLocale(acceptLanguage: string | null, tenantLocale: 'he' | 'en' | null): 'he' | 'en'` in `sign-locale.ts`: parse `Accept-Language`, return the first supported match (`he`, `en`); else `tenantLocale`; else `'he'`. Add `directionFor(locale): 'rtl' | 'ltr'` (`he` → `rtl`).
- [ ] Implement `renderContractHTML(content)` in `contract-render.ts` using `@tiptap/html` `generateHTML(content, extensions)` (extensions imported from `@zync/ui/rich-editor`) then `DOMPurify.sanitize` with the exact allow-list from spec 48. Add the DOMPurify hook restricting `img src` to the tenant R2 domain.
**Schema / Interfaces:**
```ts
// packages/types/src/sign.ts
export type SignContractStatus = 'DRAFT' | 'SENT' | 'VIEWED' | 'SIGNED' | 'VOIDED'

export interface SignTenantBranding {
  logoR2Key: string | null
  primaryColor: string | null
  tenantName: string
  customDomain: boolean
  locale: 'he' | 'en' | null
}

export interface SignPageData {
  contract: {
    title: string
    content: unknown            // Tiptap JSONB document
    signingDeadline: string     // ISO; from contract_signatories.token_expires_at
    status: SignContractStatus
  }
  signatory: {
    name: string
    email: string
    order: number
    signedAt: string | null
    declinedAt: string | null
  }
  waitingFor?: { name: string } // prior unsigned signatory, if signing order not yet reached
  tenantBranding: SignTenantBranding
}

export interface SignSubmitBody {
  signature_data: string        // data:image/png;base64,...
  signature_type: 'drawn' | 'typed'
  name: string
  email: string
}

export interface SignDeclineBody {
  reason?: string
}
```
```ts
// apps/zync-www/src/lib/sign-locale.ts
export function resolveLocale(acceptLanguage: string | null, tenantLocale: 'he' | 'en' | null): 'he' | 'en'
export function directionFor(locale: 'he' | 'en'): 'rtl' | 'ltr'
```
```ts
// apps/zync-www/src/lib/contract-render.ts
export function renderContractHTML(content: unknown): string // generateHTML + DOMPurify (spec-48 allow-list)
```
**Acceptance:**
- [ ] `resolveLocale('en-US,en;q=0.9', 'he')` → `'en'`; `resolveLocale(null, null)` → `'he'`; unsupported header falls back to tenant then `'he'`.
- [ ] `renderContractHTML` strips `<script>`, `onerror`/`onload`/`onclick`, and any non-allow-listed tag/attr; `img src` outside the tenant R2 domain is dropped.

### Task 4: Astro host page `sign/[token].astro` — fetch, locale, branding, state selection
**Blocks:** 9  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-www/src/pages/sign/[token].astro`
- Verify: `apps/zync-www/astro.config.mjs` has `output: 'hybrid'` (change owned by zync-www-marketing-site; assert it is present, do not duplicate config ownership)
**Steps:**
- [ ] In frontmatter, read `Astro.params.token`. Server-side fetch `GET /api/sign/:token` (absolute API origin from env). On network/parse failure or non-2xx that is not a known state, render the **Invalid Token** state with no branding.
- [ ] Compute `locale = resolveLocale(Astro.request.headers.get('accept-language'), tenantBranding.locale)` and `dir = directionFor(locale)`; set `<html dir={dir} lang={locale}>`.
- [ ] Apply white-label branding: inject a scoped CSS custom property `--brand-primary: {primaryColor}` and render the tenant logo from `logoR2Key` (public-read R2 URL) when resolvable; hide the "Powered by Zync" footer only when `customDomain === true`.
- [ ] Determine the page state from the response (see Task 5 decision table) and render the corresponding component; for the **Active** state, mount `<ContractSigningIsland client:load … />` passing `token`, `SignPageData`, `locale`, `dir`.
- [ ] Set a strict CSP meta/header consistent with zync-www (no external font/script origins; self-hosted Dancing Script only).
**Schema / Interfaces:**
```astro
---
// apps/zync-www/src/pages/sign/[token].astro   (output: hybrid → SSR)
import { resolveLocale, directionFor } from '../../lib/sign-locale'
import type { SignPageData } from '@zync/types'
import ContractSigningIsland from '../../islands/ContractSigningIsland.tsx'
import { resolveSignState } from '../../components/sign/resolve-state'

const { token } = Astro.params
let data: SignPageData | null = null
try { const r = await fetch(`${import.meta.env.API_ORIGIN}/api/sign/${token}`); if (r.ok) data = await r.json() } catch {}
const branding = data?.tenantBranding ?? null
const locale = resolveLocale(Astro.request.headers.get('accept-language'), branding?.locale ?? null)
const dir = directionFor(locale)
const state = resolveSignState(data) // 'invalid' | 'waiting' | 'active' | 'signed' | 'declined' | 'expired' | 'voided'
---
<html dir={dir} lang={locale}>
  <!-- branding CSS var + logo; state-specific render; island only for 'active' -->
</html>
```
**Acceptance:**
- [ ] Visiting `/sign/{validActiveToken}` server-renders the island with correct `dir`/`lang`.
- [ ] Branding logo + primary color applied; "Powered by Zync" hidden iff `customDomain` is true.
- [ ] Bad/deleted token renders Invalid state with no tenant branding.

### Task 5: Page-state components (Invalid, Waiting, Signed, Declined, Expired, Voided) + state resolver
**Blocks:** 9  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-www/src/components/sign/resolve-state.ts`
- Create: `apps/zync-www/src/components/sign/InvalidState.astro`
- Create: `apps/zync-www/src/components/sign/WaitingState.astro`
- Create: `apps/zync-www/src/components/sign/SignedState.astro`
- Create: `apps/zync-www/src/components/sign/DeclinedState.astro`
- Create: `apps/zync-www/src/components/sign/ExpiredState.astro`
- Create: `apps/zync-www/src/components/sign/VoidedState.astro`
- Create: `apps/zync-www/src/components/sign/ContractBody.astro` (read-only sanitized HTML viewport, RTL-aware)
**Steps:**
- [ ] Implement `resolveSignState(data)` precedence: null data → `invalid`; `contract.status === 'VOIDED'` → `voided`; `signatory.declinedAt` set → `declined`; `signatory.signedAt` set → `signed`; `token_expires_at < now()` (via `contract.signingDeadline`) → `expired`; `waitingFor` present → `waiting`; `contract.status === 'SENT'` (or `VIEWED`) and not yet at this signatory's turn handled by `waitingFor` → otherwise `active`.
- [ ] `ContractBody.astro` renders `renderContractHTML(content)` into a scrollable viewport via `set:html`; container `dir={dir}`, `tabindex="0"`, `role="region"`, `aria-label` localized "Contract content".
- [ ] **Invalid:** localized "This signing link is invalid or has been revoked." No branding.
- [ ] **Waiting:** logo, contract title, "Waiting to sign", "This contract is waiting for {waitingFor.name} to sign first.", "You will receive an email when it is your turn to sign.", plus read-only `ContractBody` (no signature controls).
- [ ] **Signed:** "✓ You signed this contract", formatted `signedAt`, and a "Download signed PDF" link to `GET /api/sign/{token}/pdf` shown only when `signed_pdf_r2_key` is set (presence inferred from API — render link, endpoint returns 409 if not ready).
- [ ] **Declined:** "You declined to sign this contract on {date}.", optional reason, "Contact {tenantName} if you wish to reconsider."
- [ ] **Expired:** "This contract's signing deadline has passed. Please contact {tenantName} for a new contract." Content still readable via `ContractBody`; CTAs hidden.
- [ ] **Voided:** "This contract has been voided by {tenantName}."
- [ ] All copy localized he/en; dates formatted per locale (IL `DD/MM/YYYY` for `he`).
**Schema / Interfaces:**
```ts
// apps/zync-www/src/components/sign/resolve-state.ts
import type { SignPageData } from '@zync/types'
export type SignState =
  | 'invalid' | 'waiting' | 'active' | 'signed' | 'declined' | 'expired' | 'voided'
export function resolveSignState(data: SignPageData | null): SignState
```
**Acceptance:**
- [ ] Each of the 7 states renders its spec-defined copy; Waiting and Expired still show read-only contract content.
- [ ] State precedence is correct: a voided contract shows Voided even if the signatory had previously signed; declined beats active; expired beats active.

### Task 6: `ContractSigningIsland` — shell, contract viewport, fields, validation
**Blocks:** 7, 8, 9  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-www/src/islands/ContractSigningIsland.tsx`
- Create: `apps/zync-www/src/islands/sign/SignFields.tsx`
- Create: `apps/zync-www/src/islands/sign/sign-validation.ts`
**Steps:**
- [ ] Build the island shell: header (logo + "Powered by Zync" unless `customDomain`), contract title, "Requested by {tenantName} · Expires {signingDeadline}", scrollable sanitized contract HTML (`renderContractHTML`, `dangerouslySetInnerHTML`, `dir`-aware), "Your Signature" section, signature tabs slot (Task 7), `SignFields`, agreement checkbox, action buttons.
- [ ] `SignFields`: Full name + Email inputs pre-filled from `signatory.name` / `signatory.email`, editable. Localized labels; inputs honor `dir`.
- [ ] Agreement checkbox: localized "I have read and agree to the above contract", `aria-describedby` ties to validation message.
- [ ] `sign-validation.ts`: `validateSign({ agreed, name, hasSignature })` → returns field-level errors; rules: agreement must be ticked, name non-empty, signature present (Draw: ≥1 stroke; Type: name non-empty).
- [ ] Manage island state machine: `active` → (submit) `submitting` → `signed` | `error`; `active` → (decline confirm) `declining` → `declined` | `error`. Transitions respect `prefers-reduced-motion` (no animated transition when reduced).
**Schema / Interfaces:**
```ts
// apps/zync-www/src/islands/ContractSigningIsland.tsx
import type { SignPageData } from '@zync/types'
export interface ContractSigningIslandProps {
  token: string
  data: SignPageData
  locale: 'he' | 'en'
  dir: 'rtl' | 'ltr'
}
export default function ContractSigningIsland(props: ContractSigningIslandProps): JSX.Element
```
```ts
// apps/zync-www/src/islands/sign/sign-validation.ts
export interface SignValidationInput { agreed: boolean; name: string; hasSignature: boolean }
export interface SignValidationErrors { agreed?: string; name?: string; signature?: string }
export function validateSign(input: SignValidationInput): SignValidationErrors
```
**Acceptance:**
- [ ] Sign button is disabled / blocked until agreement ticked, name non-empty, and a signature exists.
- [ ] Name/email pre-fill from API response and remain editable.
- [ ] Contract HTML renders sanitized; `<script>` in content never executes.

### Task 7: Signature capture — Draw and Type tabs (HiDPI canvas, self-hosted cursive font)
**Blocks:** 8, 9  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-www/src/islands/sign/SignaturePad.tsx`
- Create: `apps/zync-www/src/islands/sign/SignatureTabs.tsx`
- Create: `apps/zync-www/src/islands/sign/typed-signature.ts`
- Modify: `apps/zync-www/package.json` (add `signature_pad`, `@fontsource/dancing-script`)
**Steps:**
- [ ] Add deps `signature_pad` (MIT) and `@fontsource/dancing-script`. Import the font CSS so Vite self-hosts & hashes it (no Google Fonts CDN — CSP-safe).
- [ ] `SignatureTabs`: two tabs "Draw" / "Type" with `role="tablist"`, each tab `role="tab"` + `aria-selected`, panels `role="tabpanel"`; keyboard arrow navigation between tabs.
- [ ] **Draw tab (`SignaturePad`):** wrap `signature_pad` on a `<canvas>`. Canvas adapts to container width; CSS height 150px (120px when viewport <375px). Render backing store at `devicePixelRatio` scale (HiDPI); scale the 2D context so strokes are crisp; PNG exported at full backing resolution. Touch via `touchstart`/`touchmove`/`touchend` mapped to pointer coords (handled by `signature_pad`). "Clear" button resets. Expose `isEmpty()` and `toDataURL('image/png')`.
- [ ] **Type tab (`typed-signature.ts`):** live preview of the typed name in Dancing Script (36px cursive). On submit, draw the typed name onto an offscreen canvas via `ctx.fillText(name, …)` at 36px cursive and export `data:image/png;base64,...` — identical output path to Draw.
- [ ] Provide a unified `getSignature(): { signature_data: string; signature_type: 'drawn' | 'typed'; hasSignature: boolean }` consumed by the island. (`signature_type` values match spec 48 CHECK: `'drawn'` | `'typed'`.)
- [ ] Canvas element has `role="img"` + localized `aria-label` "Signature drawing area"; the Type tab is the keyboard-accessible alternative (a11y: typed path satisfies non-drawing users).
**Schema / Interfaces:**
```ts
// apps/zync-www/src/islands/sign/SignaturePad.tsx
export interface SignatureHandle {
  isEmpty(): boolean
  clear(): void
  toDataURL(): string        // 'data:image/png;base64,...' at devicePixelRatio scale
}
// apps/zync-www/src/islands/sign/typed-signature.ts
export function renderTypedSignature(name: string): string // offscreen canvas → PNG data URL, 36px Dancing Script
// unified accessor returned by SignatureTabs:
export interface SignatureResult { signature_data: string; signature_type: 'drawn' | 'typed'; hasSignature: boolean }
```
**Acceptance:**
- [ ] Draw tab captures touch + mouse strokes; "Clear" empties it; export is non-blurry on a 2× DPR display.
- [ ] Type tab live-previews cursive and exports a PNG identical in format to Draw (`data:image/png;base64,…`).
- [ ] No request to fonts.googleapis.com / fonts.gstatic.com (font self-hosted, Vite-hashed); CSP not violated.

### Task 8: Submit + Decline flows (network, terminal-state transitions, error banners)
**Blocks:** 9  ·  **Blocked by:** 6, 7
**Files:**
- Modify: `apps/zync-www/src/islands/ContractSigningIsland.tsx`
- Create: `apps/zync-www/src/islands/sign/DeclineModal.tsx`
- Create: `apps/zync-www/src/islands/sign/sign-api.ts`
**Steps:**
- [ ] `sign-api.ts`: `submitSignature(token, SignSubmitBody)` → `POST /api/sign/:token`; `declineSignature(token, SignDeclineBody)` → `POST /api/sign/:token/decline`. Return discriminated results carrying HTTP status for 409/410/422 handling.
- [ ] **Submit:** on click, run `validateSign`; if valid, gather `getSignature()` + name/email and POST. On success: transition island to **Already Signed** terminal view (no reload) showing "✓ Your signature has been recorded." + `{name} · {timestamp}`. On 409 (already signed) / 410 (expired) / 422 (validation): show an inline error banner (`role="alert"`, localized) and keep the form active.
- [ ] **Decline:** "Decline to sign" opens `DeclineModal` (`role="dialog"`, `aria-modal="true"`, focus-trapped, Escape closes, `prefers-reduced-motion` honored) with an optional reason textarea, Cancel + Confirm Decline. On confirm: `POST /api/sign/:token/decline` with `{ reason? }`; on success transition to **Declined** terminal view; on error show inline banner.
- [ ] Disable action buttons during in-flight requests; surface a spinner consistent with design tokens.
**Schema / Interfaces:**
```ts
// apps/zync-www/src/islands/sign/sign-api.ts
import type { SignSubmitBody, SignDeclineBody } from '@zync/types'
export type SignApiResult =
  | { ok: true; signedAt: string }
  | { ok: false; status: 409 | 410 | 422 | number; error: string }
export function submitSignature(token: string, body: SignSubmitBody): Promise<SignApiResult>
export function declineSignature(token: string, body: SignDeclineBody): Promise<{ ok: boolean; status: number }>
```
**Acceptance:**
- [ ] Successful sign transitions to the recorded-signature view without a page reload.
- [ ] 409/410/422 each render a localized inline `role="alert"` banner and leave the form usable.
- [ ] Decline modal is focus-trapped, Escape-dismissable, and confirming transitions to the Declined view.

### Task 9: White-label, RTL/Hebrew, and CSP integration wiring
**Blocks:** 10  ·  **Blocked by:** 2, 4, 5, 8
**Files:**
- Modify: `apps/zync-www/src/pages/sign/[token].astro`
- Modify: `apps/zync-www/src/islands/ContractSigningIsland.tsx`
- Create: `apps/zync-www/src/styles/sign.css`
**Steps:**
- [ ] Thread `primaryColor` through CSS var `--brand-primary` to buttons/accents in both the Astro states and the island (no hardcoded colors — use tokens / the brand var).
- [ ] Confirm logo is loaded only from the tenant's public-read R2 URL derived from `logoR2Key`; never inline remote arbitrary origins (CSP `img-src`).
- [ ] Ensure every text node is localized he/en and the whole surface mirrors under `dir="rtl"` (logical CSS properties / margins; the signature `<canvas>` itself is exempt — freehand surface, no direction).
- [ ] Wire the **Signed** state "Download signed PDF" link to `GET /api/sign/{token}/pdf` (Task 2); handle its 409 `pdf_not_ready` gracefully (show "PDF is being prepared" instead of a broken link).
- [ ] Set/verify CSP header for the route: `script-src 'self'`, `style-src 'self'`, `font-src 'self'`, `img-src 'self' {tenant R2 domain}`, `connect-src 'self' {API origin}`; no Google Fonts.
**Acceptance:**
- [ ] Under Hebrew locale the page is fully RTL and all copy is Hebrew; under English it is LTR.
- [ ] Brand primary color and logo render; no hardcoded hex colors in the sign surface.
- [ ] CSP blocks any external font/script; signed-PDF link works once the PDF exists and degrades cleanly before that.

### Task 10: Accessibility, reduced-motion, mobile, and end-to-end verification
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Modify: `apps/zync-www/src/islands/ContractSigningIsland.tsx`
- Modify: `apps/zync-www/src/islands/sign/SignatureTabs.tsx`
- Modify: `apps/zync-www/src/styles/sign.css`
**Steps:**
- [ ] Audit aria roles: tablist/tab/tabpanel on signature tabs; `role="img"` + label on canvas; `role="dialog"`/`aria-modal` on decline modal; `role="alert"` on error banners; `role="region"` + label on contract viewport. Verify logical tab order and visible focus rings.
- [ ] Honor `prefers-reduced-motion: reduce` — disable state-transition and modal animations.
- [ ] Mobile: canvas width = viewport minus padding; height 150px (120px <375px); buttons reachable; touch signing works.
- [ ] End-to-end manual verification across all 7 states (Invalid, Waiting, Active, Signed, Declined, Expired, Voided) using fixture tokens; confirm 20 req/min limiter (Task 1) and the PDF 302 redirect (Task 2).
- [ ] Run the workspace lint rules (`no-hardcoded-colors`, `no-hardcoded-spacing`, `no-raw-html-in-pages` exception via sanitized `renderContractHTML`) and fix violations.
**Acceptance:**
- [ ] Keyboard-only user can switch tabs, type a signature, tick agreement, sign, and decline.
- [ ] `prefers-reduced-motion` removes animations; mobile canvas resizes correctly at <375px.
- [ ] All 7 page states verified end-to-end; rate limiter and PDF redirect behave per Tasks 1–2.
