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

**Date:** 2026-05-31  
**Status:** Draft  
**Tier:** All tiers (recipient has no Zync account)  
**Depends on:** `contracts-esignature`, `white-label-api`, `foundation-design-system`  
**Referenced by:** `contracts-esignature`

---

## Overview

Public, unauthenticated page where a contract recipient signs (or declines) a contract. Served from `zync-www` (Astro + React island). URL: `zync.is/sign/{token}`.

Spec 48 (`contracts-esignature`) owns the data model, API endpoints, PDF generation, and completion flow. This spec owns the page implementation: UI states, UX edge cases, mobile signature experience, and the zync-www host integration.

Token uniquely identifies one signatory on one contract. Multiple signatories have different tokens for the same contract.

---

## Page: `/sign/{token}` (zync-www)

Astro page at `apps/zync-www/src/pages/sign/[token].astro`. Output: `hybrid` (SSR — token is dynamic). Fetches contract content + signatory context server-side; passes to React island.

### White-label branding

`GET /api/sign/{token}` response includes `tenantBranding`: logo R2 key (public-read), primary color, tenant name, custom domain flag. Applied same as proposal view: CSS override, footer visibility.

### Locale & Direction

The `<html>` element locale is set server-side from the signing page context. Resolution order:
1. Signatory's browser `Accept-Language` header if it matches a supported locale (`he`, `en`)
2. Tenant default locale (`tenantBranding.locale`)
3. Fallback: `'he'`

```astro
const locale = resolveLocale(Astro.request.headers.get('accept-language'), tenantBranding.locale)
const dir = locale === 'he' ? 'rtl' : 'ltr'
---
<html dir={dir} lang={locale}>
```

The signing page shows the contract body (rendered from `contracts.content`) and signature UI. Both must be RTL-aware when locale is `he`. The signature canvas (`<canvas>` for draw tab) does not require directional handling — it is a freehand drawing surface.

---

## Page States

### 1. Not Found / Invalid Token

```
[Tenant logo — if resolvable]

This signing link is invalid or has been revoked.
Please contact the sender for a new link.
```

No tenant branding if token cannot be resolved (token malformed or deleted).

### 2. Waiting for Prior Signatory

Signing order is enforced (spec 48). If a higher-priority signatory has not yet signed:

```
┌────────────────────────────────────────────────┐
│  [Tenant logo]                                 │
│                                                │
│  [Contract title]                              │
│  Waiting to sign                               │
│                                                │
│  This contract is waiting for {signatoryName}  │
│  to sign first.                                │
│                                                │
│  You will receive an email when it is your     │
│  turn to sign.                                 │
│                                                │
│  [View contract (read-only)]                   │
└────────────────────────────────────────────────┘
```

Contract content shown as read-only (scrollable). No signature controls.

### 3. Active — Ready to Sign

Full signing experience (see Signature UI below). Shown when:
- `contract_signatories.signed_at` is null
- `contract_signatories.declined_at` is null  
- Token is the lowest-order unsigned signatory (or ordering is not enforced)
- `contracts.status` is `SENT`
- `contract_signatories.token_expires_at` is in the future (token not expired)

### 4. Already Signed

```
┌────────────────────────────────────────────────┐
│  [Tenant logo]                                 │
│                                                │
│  ✓ You signed this contract                    │
│  {signedAt formatted date}                     │
│                                                │
│  [Download PDF] (if signed_pdf_r2_key set)     │
└────────────────────────────────────────────────┘
```

### 5. Declined

```
You declined to sign this contract on {date}.
[reason if provided]

Contact {tenantName} if you wish to reconsider.
```

### 6. Expired

```
This contract's signing deadline has passed.
Please contact {tenantName} for a new contract.
```

`contract_signatories.token_expires_at < now()` — CTAs hidden, content still readable.

### 7. Voided

```
This contract has been voided by {tenantName}.
```

---

## Signature UI (State 3)

React island: `apps/zync-www/src/islands/ContractSigningIsland.tsx`.

```
┌────────────────────────────────────────────────┐
│  [Tenant logo]                  Powered by Zync│
├────────────────────────────────────────────────┤
│                                                │
│  {contract_title}                              │
│  Requested by {tenantName} · Expires {token_expires_at} │
│                                                │
│  ┌──────────────────────────────────────────┐  │
│  │  [Contract HTML — scrollable viewport]  │  │
│  └──────────────────────────────────────────┘  │
│                                                │
│  ── Your Signature ─────────────────────────── │
│                                                │
│  [Draw ▾]  [Type]   tabs                       │
│                                                │
│  ┌──────────────────────────────────────────┐  │
│  │                                          │  │
│  │        [Signature canvas 400×150]        │  │
│  │                                          │  │
│  └──────────────────────────────────────────┘  │
│  [Clear]                                       │
│                                                │
│  Full name  [________________________]          │
│  Email      [________________________]          │
│                                                │
│  ☐ I have read and agree to the above contract │
│                                                │
│  [Sign Document]      [Decline to sign]        │
└────────────────────────────────────────────────┘
```

#### Draw tab

HTML5 Canvas with touch support. Uses `signature_pad` library (MIT). Touch events: `touchstart`, `touchmove`, `touchend` mapped to pointer coords. Canvas adapts to container width; height fixed at 150px. HiDPI: canvas rendered at `devicePixelRatio` scale, CSS size 100%×150px.

Mobile note: canvas size constrained to viewport width minus padding. On small screens (<375px): height reduced to 120px.

#### Type tab

User types their name. Rendered in cursive font (Dancing Script, loaded via `@fontsource/dancing-script` — self-hosted, no Google Fonts CDN). Preview updates live as user types. On submit: canvas is drawn from the typed name using `ctx.fillText(name, ...)` at 36px cursive. Canvas exported as `data:image/png;base64,...` same path as Draw tab.

#### Name and email fields

Pre-filled from `contract_signatories.name` and `contract_signatories.email` (from API response). Editable — recipient may correct their displayed name.

#### Validation (client-side, before POST)

- Agreement checkbox must be ticked
- Name field must be non-empty
- Signature must be present (Draw: at least one stroke; Type: name field non-empty)

#### Submit

1. Client POSTs `POST /api/sign/{token}` with body per spec 48: `{ signature_data, signature_type, name, email }`. (`signature_data`: base64 PNG data URL; `signature_type`: `"drawn"` or `"typed"`.)
2. On success: page transitions to **Already Signed** state (no reload). Show:
   ```
   ✓ Your signature has been recorded.
   {name} · {timestamp}
   ```
3. On error (409 already signed, 410 expired, 422 validation): inline error banner; form remains active.

#### Decline flow

"Decline to sign" opens a modal:

```
┌──────────────────────────────────────┐
│  Decline to sign                     │
│                                      │
│  Reason (optional):                  │
│  [_________________________________] │
│  [_________________________________] │
│                                      │
│  [Cancel]    [Confirm Decline]       │
└──────────────────────────────────────┘
```

On confirm: `POST /api/sign/{token}/decline` with `{ reason? }`. On success: page transitions to **Declined** state.

---

## API Endpoints (spec 48 owns these — listed for reference)

```
GET  /api/sign/:token          → contract content + signatory context (no auth)
                                  returns: {
                                    contract: { title, content (HTML), signingDeadline, status },
                                    signatory: { name, email, order, signedAt, declinedAt },
                                    waitingFor: { name }?,   // prior unsigned signatory if any
                                    tenantBranding: { logoR2Key, primaryColor, tenantName, customDomain }
                                  }

POST /api/sign/:token          → submit signature (no auth)
                                  body: { signature_data, signature_type, name, email } (spec 48 contract — snake_case)

POST /api/sign/:token/decline  → decline to sign (no auth)
                                  body: { reason? }
```

All public. No auth middleware.

Rate limiting: 20 req/min per IP per token (CF native RateLimiter binding `RATE_LIMITER_SIGN`).

---

## Post-Completion: PDF Download

Once `contracts.signed_pdf_r2_key` is set (async PDF generation per spec 48):

- **Signed state** shows "Download signed PDF" link → `GET /api/contracts/{id}/pdf` (requires portal auth or signed-URL mechanism)
- For unauthenticated recipients: `GET /api/sign/{token}/pdf` → returns 302 to R2 signed URL (short TTL: 15 min). Spec 48 should add this endpoint.
- All signatories + tenant OWNER receive the PDF by email (spec 48 completion flow).

---

## Foundation Deltas

**New rate limiter binding:** `RATE_LIMITER_SIGN` — CF native RateLimiter, 20 req/min per IP per token. Add to wrangler.toml following pattern of `RATE_LIMITER_EXPENSE_UPLOAD`.

**New zync-www route:** `apps/zync-www/src/pages/sign/[token].astro` — requires `output: 'hybrid'` in `astro.config.mjs` (change owned by spec 22; referenced here for clarity).

**New API endpoint on spec 48:** `GET /api/sign/:token/pdf` — returns 302 to R2 signed URL for signed PDF (unauthenticated, token-based access; only available after `signed_pdf_r2_key` is set).

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Hosted in zync-www | Not app.zync.is | Public no-auth; consistent with `/p/{token}`, `/f/{slug}`, `/c/{token}` — all public surfaces on zync-www |
| `signature_pad` library | MIT, ~7KB | Small, no dependencies, touch-native, canvas-based — battle-tested for signature capture |
| Type-tab font self-hosted | `@fontsource/dancing-script` | Avoids Google Fonts CDN privacy concern + CSP; font hashed by Vite — no preload/CDN mismatch |
| HiDPI canvas scaling | `devicePixelRatio` at capture | Prevents blurry signatures on retina displays; PNG stored at full resolution |
| PDF download for unauthenticated recipients | Token-based `GET /api/sign/:token/pdf` | Signatories have no Zync account; can't use portal auth. Short TTL signed URL protects from link sharing |
| Waiting state shows contract (read-only) | Contract visible but no signature controls | Reduces confusion — recipient can read what they're waiting to sign; UX consistency with signed state |
