# Public API Docs UI — Implementation Plan

**Spec:** docs/specs/2026-05-31-public-api-docs.md  ·  **Slug:** public-api-docs  ·  **Wave:** 11
**Depends on:** foundation-design-system, white-label-api, zync-www-marketing-site

## Goal
Ship public developer documentation for the Zync Tenant Public API, hosted at `docs.zync.is/api`, as static Astro pages inside the existing `apps/zync-www` site. The docs are generated at build time from the OpenAPI 3.1 schema that the `apps/zync-public-api` Worker already produces via `@hono/zod-openapi`, guaranteeing docs and implementation never drift. The plan also adds a publicly readable `GET /api/openapi.json` schema endpoint, the CORS configuration on `api.zync.is` required for the in-browser "Try it" feature, a per-endpoint code-example switcher (cURL / JavaScript fetch / Node.js), a localStorage-backed "Try it" runner that fires requests directly to `api.zync.is`, a webhook event-catalog page, and a static changelog.

## Architecture
This spec adds **no tables** and **no `/v1/**` route handlers** — those are owned upstream by `tenant-public-api` (the `apps/zync-public-api` Worker, exported package `@zync/public-api` / `zync-public-api`). This plan consumes them:

- **Schema source of truth:** `apps/zync-public-api` defines every route with `@hono/zod-openapi` and already serves `GET /v1/openapi.json` (a complete OpenAPI 3.1 document). We expose the same document publicly at **`GET /api/openapi.json`** (no auth) and import the generator at build time so the docs site renders from it.
- **Build-time schema acquisition:** the docs build imports the OpenAPI document **directly from the `apps/zync-public-api` workspace package** (compile-time import of the `openapi.ts` builder), NOT a network fetch of a live deployment. This removes a deploy-order dependency and makes the docs build hermetic. The fetched-at-build-time language in the spec is satisfied by an in-repo import.
- **Documented surface (upstream, referenced — not redefined):** endpoints `GET /v1/customers`, `GET /v1/customers/:id`, `POST /v1/customers`, `PATCH /v1/customers/:id`, `GET /v1/invoices`, `GET /v1/invoices/:id`, `POST /v1/invoices`, `PATCH /v1/invoices/:id/status`, `GET /v1/tasks`, `GET /v1/tasks/:id`, `POST /v1/tasks`, `PATCH /v1/tasks/:id`, `GET /v1/events`; types `CustomerObject`, `InvoiceObject`, `InvoiceLineObject`, `TaskObject`, `EventObject`, `ApiError`, `PaginatedResponse`, `PaginationParams`, `ApiScope`, `InvoiceStatus`; the scope matrix; the cursor pagination envelope (`data` / `next_cursor` / `has_more`); the error envelope and HTTP status table.
- **Webhook catalog source (upstream, referenced):** the full event catalog and the `webhook_endpoints` / `webhook_deliveries` tables from `white-label-api`, plus the `X-Zync-Signature` (`sha256=HMAC_SHA256(secret, "${timestamp}.${body}")`), `X-Zync-Timestamp`, `X-Zync-Event`, `X-Zync-Delivery` headers and the receiver-side HMAC verification + replay-protection reference code.
- **Hosting integration:** the docs live in `apps/zync-www` (the Astro site from `zync-www-marketing-site`) under `src/pages/docs/api/`, reusing its `BaseLayout`, `@zync/config` Tailwind preset, and `@zync/ui` primitives (`Button`, `Input`, `Card`, `Badge`). A new `DocsLayout.astro` provides the two-pane (sticky left nav + endpoint detail) shell.
- **Try it data flow:** browser → reads prefixed API key from `localStorage` → `fetch('https://api.zync.is/v1/...', { headers: { Authorization: 'Bearer <key>' } })` directly (no proxy). This only works because of the CORS task below.

## Tech Stack
- **App:** `apps/zync-www` (Astro 4 `output: 'static'`, `@astrojs/react` islands) — new `docs/api/` page tree + `DocsLayout`.
- **Worker:** `apps/zync-public-api` (Hono + `@hono/zod-openapi` on Cloudflare Workers) — add public `GET /api/openapi.json` route and CORS middleware.
- **Packages:** `@zync/ui` (primitives), `@zync/config` (Tailwind preset). Schema imported from the `apps/zync-public-api` package's `openapi.ts`.
- **Libraries:** `@hono/zod-openapi` (existing), `hono/cors` middleware, a lightweight syntax highlighter at build time (`shiki`, run in the Astro build only — zero client JS for highlighting), `react` / `react-dom` for the single "Try it" island.
- **Cloudflare bindings:** none new. (`api.zync.is` Worker already exists with `RATELIMIT_KV`.)
- **Subdomain/routing:** `docs.zync.is` served by `apps/zync-www` (static asset routing); the `/api` path prefix scopes the API docs.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — Worker schema + CORS | 1, 2 | `apps/zync-public-api/src/*` | Yes (1 and 2 independent) |
| B — Docs shell + schema loader | 3, 4 | `apps/zync-www/src/layouts`, `src/lib/openapi` | After A (4 imports schema); 3 parallel with A |
| C — Rendering primitives | 5, 6, 7 | `apps/zync-www/src/components/docs/*` | After B |
| D — Static & resource pages | 8, 9, 10 | `apps/zync-www/src/pages/docs/api/**` | After C (parallel among themselves) |
| E — Try it + webhooks + changelog | 11, 12, 13 | `apps/zync-www/src/components/docs`, `pages/docs/api` | After C; 11/12/13 parallel |
| F — SEO, nav wiring, acceptance | 14 | `apps/zync-www/src/**`, marketing nav | Last |

## Tasks

### Task 1: Public OpenAPI schema endpoint (`GET /api/openapi.json`)
**Blocks:** 4  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-public-api/src/routes/openapi-public.ts`
- Modify: `apps/zync-public-api/src/index.ts`
- Modify: `apps/zync-public-api/src/openapi.ts` (export the document builder for reuse)
**Steps:**
- [ ] In `openapi.ts`, ensure the OpenAPI 3.1 document builder is exported as a pure function `buildOpenApiDocument()` returning the full `OpenAPIObject` (it already backs `GET /v1/openapi.json`). Do not duplicate route definitions — reuse the registered `@hono/zod-openapi` app's `getOpenAPI31Document(...)` output.
- [ ] Set document `info.title = "Zync Public API"`, `info.version = "1.0.0"`, `servers = [{ url: "https://api.zync.is" }]`, and a top-level `security` scheme `bearerApiKey` (HTTP bearer, `bearerFormat: "zyk_live_<key>"`) so docs render the `Authorization: Bearer {api_key}` requirement.
- [ ] Add a route handler `GET /api/openapi.json` that returns `buildOpenApiDocument()` as JSON with `Content-Type: application/json`. **No auth middleware** is applied to this route — the schema is public per the spec ("Publicly accessible (no auth required to read the schema)").
- [ ] Register this route OUTSIDE the global `/v1/**` auth middleware group so the bearer-key middleware never runs for it.
- [ ] Set `Cache-Control: public, max-age=300` on the response.
**Schema / Interfaces:**
```ts
// apps/zync-public-api/src/openapi.ts
export function buildOpenApiDocument(): import('@hono/zod-openapi').OpenAPIObject;

// route: GET /api/openapi.json  → 200 application/json (full OpenAPI 3.1 doc), no auth
```
**Acceptance:**
- [ ] `curl https://api.zync.is/api/openapi.json` (no Authorization header) returns `200` with a valid OpenAPI 3.1 document covering all 13 `/v1` endpoints.
- [ ] The same request to `/v1/openapi.json` behaviour is unchanged.
- [ ] No `RATELIMIT_KV` counter is incremented for `/api/openapi.json` (it is outside the `/v1` middleware).

### Task 2: CORS for `docs.zync.is` on `api.zync.is`
**Blocks:** 11  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-public-api/src/index.ts`
**Steps:**
- [ ] Add `hono/cors` middleware scoped to `/v1/**` (and `/api/openapi.json`) that allows the `docs.zync.is` origin so the "Try it" feature can call the API from the browser.
- [ ] Allow `Authorization` and `Content-Type` request headers; allow methods `GET, POST, PATCH, OPTIONS`.
- [ ] Allowed origins: `https://docs.zync.is` exactly (do NOT use `*` — the API accepts credentials/bearer keys; an explicit allow-list is the security-cross-cutting requirement). Optionally also allow `http://localhost:4321` only when `ENVIRONMENT !== 'production'` for local docs dev.
- [ ] Expose the rate-limit response headers to JS: `Access-Control-Expose-Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After`.
- [ ] Ensure CORS runs BEFORE the bearer-auth middleware so `OPTIONS` preflight returns `204` without an API key.
- [ ] Set `Access-Control-Max-Age: 86400` to cache preflight.
**Schema / Interfaces:**
```ts
import { cors } from 'hono/cors'
app.use('/v1/*', cors({
  origin: (o, c) => o === 'https://docs.zync.is'
    || (c.env.ENVIRONMENT !== 'production' && o === 'http://localhost:4321') ? o : '',
  allowMethods: ['GET', 'POST', 'PATCH', 'OPTIONS'],
  allowHeaders: ['Authorization', 'Content-Type'],
  exposeHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset', 'Retry-After'],
  maxAge: 86400,
}))
```
**Acceptance:**
- [ ] A browser `fetch` from `https://docs.zync.is` to `https://api.zync.is/v1/customers` with a valid `Authorization: Bearer` header succeeds (no CORS error).
- [ ] A preflight `OPTIONS /v1/customers` with `Origin: https://docs.zync.is` returns `204` with the expected `Access-Control-Allow-*` headers and no body.
- [ ] A request with `Origin: https://evil.example.com` does not receive an `Access-Control-Allow-Origin` matching that origin.

### Task 3: Docs layout shell (`DocsLayout.astro`)
**Blocks:** 5, 8, 9, 10, 12, 13  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-www/src/layouts/DocsLayout.astro`
- Create: `apps/zync-www/src/components/docs/SideNav.astro`
- Create: `apps/zync-www/src/components/docs/DocsTopbar.astro`
**Steps:**
- [ ] Build a two-pane layout: sticky collapsible left `SideNav` + main content slot, matching the spec's ASCII layout (header `Zync API Docs … v1.0 [API]`).
- [ ] `SideNav` sections (sticky, collapsible): **Top** — Overview, Authentication, Errors; **Resources** — Customers, Invoices, Time Entries, Expenses, Projects; **Bottom** — Webhooks, Changelog. Each links to its `/docs/api/*` route; highlight the active route via `Astro.url.pathname`.
- [ ] Reuse `BaseLayout.astro` from `zync-www` for `<head>`, token CSS (`packages/ui/src/tokens/index.css`), and the `@zync/config` Tailwind preset. No hardcoded hex/rgb/hsl; use OKLCH tokens only. Spacing on the 8px grid (8/16/24/32/48/64/96). Corners use `rounded` (`--radius: 4px`) only — no radius ladder.
- [ ] **Directionality:** developer docs are English and code-heavy; render the docs section as `dir="ltr" lang="en"` (override the site default `dir="rtl"`), set on the `DocsLayout` wrapper. This is a localized, documented exception — do not apply `dir="rtl"` blindly to code blocks. (RTL parent config for the marketing/auth pages is untouched.)
- [ ] A11y: `SideNav` is a `<nav aria-label="API documentation">` with a single `<ul>`/`<li>` list; the active link carries `aria-current="page"`. The two-pane region uses landmark roles (`<nav>` + `<main>`). Provide a "Skip to content" link as the first focusable element. Respect `prefers-reduced-motion` on the collapse animation (no transition when reduced).
- [ ] Topbar shows the API version (read from the loaded schema `info.version`) and a link back to `zync.is`.
**Acceptance:**
- [ ] `DocsLayout` renders a sticky left nav and a content slot; the active nav item is visually marked and carries `aria-current="page"`.
- [ ] No physical-direction Tailwind utilities (`ml-*`/`mr-*`/`pl-*`/`pr-*`/`text-left`/`text-right`) appear; logical properties only.
- [ ] No hardcoded color or off-grid spacing values; Stylelint design-token rules pass.

### Task 4: Build-time OpenAPI schema loader + typed model
**Blocks:** 5, 6, 7, 8, 9, 11  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-www/src/lib/openapi/load-schema.ts`
- Create: `apps/zync-www/src/lib/openapi/model.ts`
**Steps:**
- [ ] `load-schema.ts` imports `buildOpenApiDocument()` from the `apps/zync-public-api` workspace package and returns the parsed OpenAPI 3.1 object at build time (compile-time import — no network fetch). Add the package as a `devDependency` (`@zync/public-api` workspace).
- [ ] `model.ts` normalizes the raw OpenAPI document into a render-friendly model: an array of `DocResource` (Customers, Invoices, Time Entries, Expenses, Projects) each with an ordered list of `DocEndpoint` extracted from `paths` + operations.
- [ ] For each `DocEndpoint` derive: HTTP `method`, `path`, `summary`/`description`, required `scope` (from the operation's `security`/`x-required-scope` extension — add `x-required-scope` in Task 1's document if not already present, sourced from the scope matrix), query/path params (name, type, required, description), request body schema, response schema(s) with example JSON, and the error-response code table.
- [ ] Resolve `$ref` component schemas so each endpoint carries a fully inlined example object (for `CustomerObject`, `InvoiceObject`, `TaskObject`, `EventObject`, `PaginatedResponse<T>` envelope).
- [ ] Group resources/endpoints in the canonical order: customers → invoices → tasks(→ "Time Entries"/"Expenses"/"Projects" pages are documented even when the live API surface is customers/invoices/tasks/events; see Task 10).
**Schema / Interfaces:**
```ts
export interface DocParam { name: string; in: 'query' | 'path'; type: string; required: boolean; description: string }
export interface DocResponse { status: number; description: string; exampleJson: string | null }
export interface DocEndpoint {
  method: 'GET' | 'POST' | 'PATCH';
  path: string;                 // e.g. '/v1/customers'
  summary: string;
  description: string;
  scope: string;                // e.g. 'customers:read'
  params: DocParam[];
  requestBodyExample: string | null;
  responses: DocResponse[];     // includes success + documented errors
}
export interface DocResource { slug: string; title: string; endpoints: DocEndpoint[] }
export function loadApiDocsModel(): { version: string; resources: DocResource[] };
```
**Acceptance:**
- [ ] `loadApiDocsModel()` returns a model with every `/v1` operation from the schema, each carrying its required scope, params, and at least one example response.
- [ ] The build fails loudly (throws) if the imported schema is missing `paths` or `info.version` — no silent empty docs.

### Task 5: Endpoint documentation component
**Blocks:** 8, 9  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-www/src/components/docs/EndpointDoc.astro`
- Create: `apps/zync-www/src/components/docs/ParamTable.astro`
- Create: `apps/zync-www/src/components/docs/SchemaBlock.astro`
**Steps:**
- [ ] `EndpointDoc.astro` accepts a `DocEndpoint` and renders the spec's endpoint pattern in order: (1) **Method + Path** badge (e.g. `GET /v1/customers`); (2) **Description** (1–3 sentences); (3) **Auth** line — `Authorization: Bearer {api_key}` + the required scope rendered as a `<Badge>` (e.g. `customers:read`); (4) **Query params / Request body** via `ParamTable`; (5) **Response schema** via `SchemaBlock` (collapsible `<details>` when the JSON exceeds ~20 lines); (6) **Error responses** table of HTTP code + meaning.
- [ ] `ParamTable.astro` renders columns: Name, Type, Required, Description (semantic `<table>` with `<thead>`/`<th scope="col">`).
- [ ] `SchemaBlock.astro` renders example JSON in a `<pre><code>` block, syntax-highlighted at build time via `shiki` (no client JS). Long examples wrapped in native `<details>`/`<summary>` ("Show response").
- [ ] Method badge colors map to semantic tokens (GET→accent, POST→success, PATCH→warning) using existing OKLCH tokens only.
- [ ] Slot in the `CodeExample` switcher (Task 6) and the `TryIt` island mount point (Task 11) after the auth section.
**Acceptance:**
- [ ] Rendering an endpoint produces the six-part pattern with a scope badge and an error-code table.
- [ ] Long response examples are collapsed behind a native `<details>` with no JavaScript.
- [ ] Param tables are real `<table>` elements with header cells scoped for screen readers.

### Task 6: Code-example switcher (cURL / JavaScript / Node.js)
**Blocks:** 8, 9  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-www/src/lib/openapi/codegen.ts`
- Create: `apps/zync-www/src/components/docs/CodeExample.astro`
**Steps:**
- [ ] `codegen.ts` generates three ready-to-copy snippets per `DocEndpoint` **from the schema** (do not hand-author per endpoint): `curl`, `javascript` (browser `fetch`), `node` (Node 18+ global `fetch`).
- [ ] Each snippet targets `https://api.zync.is{path}`, includes `Authorization: Bearer zyk_live_...` and `Content-Type: application/json`, and embeds a representative request body (from `requestBodyExample`) for POST/PATCH; query params shown for list endpoints.
- [ ] cURL example must match the spec literally in shape, e.g.:
  ```
  curl -X GET https://api.zync.is/v1/customers \
    -H "Authorization: Bearer zyk_live_..." \
    -H "Content-Type: application/json"
  ```
- [ ] `CodeExample.astro` renders a tab/segmented control `[cURL] [JavaScript] [Node.js]`. Tabs are pure CSS (radio-input + `:checked` sibling reveal) so the switcher needs **zero client JS**. Each panel is `shiki`-highlighted at build.
- [ ] Add a "Copy" button per panel; the copy interaction is the only JS in this component (tiny inline `<script>` using `navigator.clipboard`), progressively enhanced (panel still readable without JS).
- [ ] Tabs are keyboard accessible (radio group), with `aria-label` on the tablist and visible focus ring.
**Acceptance:**
- [ ] Each endpoint shows three correct snippets; switching tabs requires no network and (for switching itself) no JS.
- [ ] The cURL snippet for `GET /v1/customers` matches the spec's literal form.
- [ ] Copy button copies the active snippet; with JS disabled the snippets are still fully visible.

### Task 7: Overview, Authentication, and Errors content
**Blocks:** 14  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-www/src/components/docs/OverviewContent.astro`
- Create: `apps/zync-www/src/components/docs/AuthContent.astro`
- Create: `apps/zync-www/src/components/docs/ErrorsTable.astro`
**Steps:**
- [ ] `OverviewContent`: product intro + quick-start — base URL `https://api.zync.is/v1/`, versioning policy (`v1` in path, 12-month deprecation window), a 3-step quick start (create a Business+ key in `/settings/api-keys` → set `Authorization: Bearer` → call `GET /v1/customers`), and the snake_case field-casing note.
- [ ] `AuthContent`: document the API key format (`zyk_live_{random32}`, 42 chars, shown once, SHA-256 stored), the `Authorization: Bearer zyk_live_<key>` header, the Business+ tier gate (Freelancer → `403 tier_required`), the scope matrix table (every endpoint → required scope), the "write implies read" rule, key creation pointer to the `/settings/api-keys` UI (white-label-api), and the rate-limit contract (100 req/min/key; `X-RateLimit-Limit/Remaining/Reset` headers; `429 rate_limited` with `retry_after`).
- [ ] `ErrorsTable`: transcribe the HTTP status code table and error-code list, plus the `ApiError` envelope (`error`, `message`, optional `field` / `required` / `minimum_tier` / `retry_after`) and the labelled example bodies (401/403-scope/403-tier/404/422/429).
- [ ] All scope strings rendered as `<Badge>`; all tables are semantic `<table>`s with scoped header cells.
**Schema / Interfaces:**
```ts
// Documented (upstream type — referenced, not redefined):
interface ApiError { error: string; message: string; field?: string; required?: string; minimum_tier?: string; retry_after?: number }
// Scope matrix rows (rendered as a table):
// GET /v1/customers→customers:read · POST /v1/customers→customers:write · PATCH /v1/customers/:id→customers:write
// GET /v1/invoices→invoices:read · POST /v1/invoices→invoices:write · PATCH /v1/invoices/:id/status→invoices:write
// GET /v1/tasks→tasks:read · POST /v1/tasks→tasks:write · PATCH /v1/tasks/:id→tasks:write · GET /v1/events→events:read
```
**Acceptance:**
- [ ] Overview shows base URL, versioning, quick start, and snake_case note.
- [ ] Authentication page shows the key format, tier gate, full scope matrix, and rate-limit headers.
- [ ] Errors page reproduces the full HTTP status table and the `ApiError` envelope with example bodies.

### Task 8: Static top-level pages (Overview, Authentication, Errors)
**Blocks:** 14  ·  **Blocked by:** 3, 5, 6, 7
**Files:**
- Create: `apps/zync-www/src/pages/docs/api/index.astro`
- Create: `apps/zync-www/src/pages/docs/api/authentication.astro`
- Create: `apps/zync-www/src/pages/docs/api/errors.astro`
**Steps:**
- [ ] Each page wraps its content component in `DocsLayout` and sets the active nav item.
- [ ] `index.astro` (`/docs/api`) renders `OverviewContent`; `authentication.astro` renders `AuthContent`; `errors.astro` renders `ErrorsTable`.
- [ ] Per-page SEO `<title>` + `<meta name="description">`; these pages are `index` (public docs, crawlable).
**Acceptance:**
- [ ] `/docs/api`, `/docs/api/authentication`, `/docs/api/errors` build statically and render their content.
- [ ] Each has a unique `<title>` and meta description and is not `noindex`.

### Task 9: Resource endpoint pages (Customers, Invoices, Tasks)
**Blocks:** 14  ·  **Blocked by:** 4, 5, 6
**Files:**
- Create: `apps/zync-www/src/pages/docs/api/customers.astro`
- Create: `apps/zync-www/src/pages/docs/api/invoices.astro`
- Create: `apps/zync-www/src/pages/docs/api/tasks.astro`
**Steps:**
- [ ] Each page calls `loadApiDocsModel()`, selects its `DocResource`, and renders one `EndpointDoc` per endpoint in order.
- [ ] `customers.astro` documents `GET /v1/customers`, `GET /v1/customers/:id`, `POST /v1/customers`, `PATCH /v1/customers/:id` — including the `search`/`status`/`limit`/`cursor` query params, `CustomerObject` response, `CreateCustomerBody`/`UpdateCustomerBody` request bodies, the `422` validation errors, and the `409 conflict` on archiving a customer with open invoices.
- [ ] `invoices.astro` documents `GET /v1/invoices`, `GET /v1/invoices/:id`, `POST /v1/invoices`, `PATCH /v1/invoices/:id/status` — including `InvoiceObject` + `InvoiceLineObject` (monetary values as decimal strings), the `InvoiceStatus` enum, the state-transition table (DRAFT→SENT→APPROVED/REJECTED→…→TAX_ISSUED→PAID), VAT-stamping at DRAFT→SENT, the `422 invalid_transition` / `422 tax_issued_immutable` errors, and the credit-note out-of-scope note.
- [ ] `tasks.astro` documents `GET /v1/tasks`, `GET /v1/tasks/:id`, `POST /v1/tasks`, `PATCH /v1/tasks/:id` — including `TaskObject` (`description_text` is plain-text extraction; raw Tiptap JSONB not exposed), the priority enum, `reporter_id = key.created_by`, `source = 'api'`, and `422 no_statuses_configured`.
- [ ] Each page injects the `TryIt` island mount point (Task 11) per endpoint and the `CodeExample` switcher (Task 6).
- [ ] SEO: `index`, unique titles/descriptions per resource.
**Acceptance:**
- [ ] Each resource page lists exactly its endpoints with params, request/response examples, and error tables drawn from the schema.
- [ ] Invoice page renders the full state-transition table and decimal-string monetary fields.
- [ ] Task page documents `description_text` (not raw JSON) and `source = 'api'`.

### Task 10: Time Entries, Expenses, Projects pages + Events documentation
**Blocks:** 14  ·  **Blocked by:** 3, 5
**Files:**
- Create: `apps/zync-www/src/pages/docs/api/time-entries.astro`
- Create: `apps/zync-www/src/pages/docs/api/expenses.astro`
- Create: `apps/zync-www/src/pages/docs/api/projects.astro`
**Steps:**
- [ ] The spec's left-nav lists Time Entries, Expenses, and Projects as resource pages, but the v1 public API surface (per `tenant-public-api`) does not yet expose `/v1/time-entries`, `/v1/expenses`, or `/v1/projects` endpoints. Render each of these three pages as a **"Coming in a future API version"** notice: a short description of the resource, a `<Badge>`-marked "Planned" status, and a link to the Changelog. Do NOT fabricate endpoints that the schema does not contain.
- [ ] The `events:read` surface (`GET /v1/events`) is documented on the **Webhooks** page (Task 12), since the webhook delivery audit log is the `events` resource — add a cross-link from `time-entries`/`expenses`/`projects` only if relevant (it is not).
- [ ] Each page wraps in `DocsLayout`, sets the active nav item, and is `index`-crawlable with a unique title/description.
**Acceptance:**
- [ ] `/docs/api/time-entries`, `/docs/api/expenses`, `/docs/api/projects` build and render a "Planned" notice — no invented endpoints.
- [ ] No page references an operation absent from `loadApiDocsModel()`.

### Task 11: "Try it" React island (localStorage key, direct-to-API)
**Blocks:** 14  ·  **Blocked by:** 2, 4
**Files:**
- Create: `apps/zync-www/src/components/docs/TryIt.tsx`
**Steps:**
- [ ] Build a single React island (`client:load`) mounted once per endpoint, receiving the `DocEndpoint` (method, path, params, request-body shape) as serialized props.
- [ ] Render an inline form per the spec: an `API Key: [____] [Save]` field, one input per documented query/path param (and a JSON body editor for POST/PATCH), and a `[Send Request]` button. Reuse `@zync/ui` `Input` and `Button`.
- [ ] **API key storage:** persist the key in `localStorage` under a prefixed key `zync_api_docs_key` (single shared key across endpoints). "Save" writes it; on mount, prefill from `localStorage`. The key is sent ONLY in the `Authorization: Bearer` header to `api.zync.is` — never to any other origin, never logged, never put in the URL.
- [ ] On **Send**, `fetch('https://api.zync.is' + resolvedPath + queryString, { method, headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' }, body })` — directly to the API (no proxy; relies on Task 2 CORS). Read `X-RateLimit-Remaining`/`Reset` from the response (exposed via CORS) and display them.
- [ ] Render the response: HTTP status line + pretty-printed JSON body in a `<pre>`. Show a clear message for `401`/`403`/`429` mapping to the documented error codes.
- [ ] Security: mark the API-key input `type="password"` with a show/hide toggle (functional, not decorative); never echo the key into the rendered request preview beyond a masked prefix; do not auto-submit. Respect `prefers-reduced-motion` for any spinner. Add `aria-live="polite"` on the response region so screen readers announce results, and `aria-busy` while in flight.
- [ ] Guard against XSS: render the response strictly as text inside `<pre>` (no `dangerouslySetInnerHTML`).
**Acceptance:**
- [ ] Saving a key persists it across page reloads (localStorage), and it is sent only to `api.zync.is` in the `Authorization` header.
- [ ] A live `GET /v1/customers` from the docs page returns and renders the JSON response and the rate-limit remaining count.
- [ ] The key input is masked by default; the response region is `aria-live="polite"`; no key value appears in the request-preview text.

### Task 12: Webhook catalog page
**Blocks:** 14  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-www/src/pages/docs/api/webhooks.astro`
- Create: `apps/zync-www/src/lib/openapi/webhook-catalog.ts`
- Create: `apps/zync-www/src/components/docs/WebhookEvent.astro`
**Steps:**
- [ ] `webhook-catalog.ts` enumerates every outbound event from the `white-label-api` event catalog with a representative payload example: Time (`timer.started`, `timer.stopped`, `timer.auto_paused`); CRM (`lead.created`, `lead.stage_updated`, `proposal.viewed`, `proposal.accepted`); Projects (`project.created`, `project.status_changed`); Tasks (`task.created`, `task.assigned`, `task.completed`); Support (`ticket.created`, `ticket.replied`, `ticket.resolved`); Finance (`invoice.proforma_approved`, `invoice.issued`, `invoice.paid`, `invoice.overdue`, `retainer.depleted`); Expenses (`expense.submitted`, `expense.approved`); Payouts (`payout.generated`); Billing (`payment.completed`, `payment.failed`); Users (`user.invited`, `role.updated`); Tenant (`tenant.provisioned`); Calendar (`calendar.booking_created`).
- [ ] `WebhookEvent.astro` renders per event: name, "Fired when …" description, and the JSON payload envelope `{ event, tenantId, data: {...}, timestamp }`. Show `invoice.paid` in the spec's exact form (`invoiceId`, `invoiceNumber`, `amount`, `currency`, `paidAt`).
- [ ] Document the delivery headers from `white-label-api`: `X-Zync-Signature: sha256=HMAC_SHA256(secret, "${timestamp}.${body}")`, `X-Zync-Timestamp` (unix seconds, in the signature), `X-Zync-Event`, `X-Zync-Delivery` (UUID; retries reuse it, "Redeliver" mints a new one).
- [ ] Document the **HMAC verification reference** (receiver-side) and **replay protection** (reject when `|now - X-Zync-Timestamp| > 300s`; dedupe `X-Zync-Delivery` within the window; verify HMAC last). Reproduce the spec's `verifyZyncWebhook` reference snippet using `timingSafeEqual` (security cross-cutting — keep the timing-safe comparison; do NOT replace it with `===`).
- [ ] Document `GET /v1/events` (the webhook delivery audit log, scope `events:read`): `EventObject` shape, `status` enum (`pending`/`delivered`/`failed`), the `endpoint_url` truncation-to-domain rule, and that the payload is not returned.
- [ ] Highlight code via `shiki` at build; `index`-crawlable; unique SEO title/description.
**Schema / Interfaces:**
```ts
// Documented (upstream type — referenced, not redefined):
interface EventObject {
  id: string; event_type: string; status: 'pending' | 'delivered' | 'failed';
  endpoint_url: string; attempt: number; response_status: number | null;
  delivered_at: string | null; created_at: string;
}
// Verification reference (transcribed from white-label-api; timing-safe compare required):
function verifyZyncWebhook(body: string, signature: string, timestamp: string, secret: string): boolean {
  const expectedSig = 'sha256=' + hmacSHA256(secret, `${timestamp}.${body}`)
  return timingSafeEqual(signature, expectedSig)
}
```
**Acceptance:**
- [ ] The page lists every event from the catalog grouped by category with payload examples.
- [ ] The HMAC verification example uses `timingSafeEqual` and documents the 300-second replay window + delivery-ID dedupe.
- [ ] `GET /v1/events` is documented with `EventObject` and the `endpoint_url` truncation rule.

### Task 13: Changelog page
**Blocks:** 14  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-www/src/pages/docs/api/changelog.astro`
- Create: `apps/zync-www/src/content/api-changelog.md`
**Steps:**
- [ ] Store the changelog as a static markdown file (`api-changelog.md`), versioned in the repo — there is no changelog table.
- [ ] Seed it with the spec's entries: `## v1.0.0 — 2026-06-01` (Initial public API release: customers, invoices, time-entries, expenses, projects) and the example `## v1.0.1 — 2026-06-15` (added `GET /api/projects/:id/time-summary`).
- [ ] `changelog.astro` renders the markdown (Astro content/markdown import) inside `DocsLayout` with the Changelog nav item active; entries newest-first.
- [ ] `index`-crawlable; unique SEO title/description.
**Acceptance:**
- [ ] `/docs/api/changelog` renders the markdown entries newest-first.
- [ ] Adding a new `## vX.Y.Z — date` heading to the markdown file appears on the page after rebuild with no code change.

### Task 14: Nav wiring, SEO, sitemap, and acceptance pass
**Blocks:** —  ·  **Blocked by:** 8, 9, 10, 11, 12, 13
**Files:**
- Modify: `apps/zync-www/src/components/Nav.astro`
- Modify: `apps/zync-www/astro.config.ts`
- Modify: `apps/zync-www/src/pages/docs/api/index.astro` (canonical/og)
**Steps:**
- [ ] Wire the marketing site's `Section 3 — API & Integrations` CTA ("צפו בתיעוד ה-API") and any nav "API"/"מפתחים" link to point at `/docs/api`.
- [ ] Ensure `docs.zync.is/api` resolves to `apps/zync-www`'s `/docs/api` route (document the host routing: `docs.zync.is` is served by the same static deployment; `/api` path prefix scopes API docs). Add `docs.zync.is` to the site's allowed hosts / `site` config if a sitemap is generated.
- [ ] Add canonical URLs and Open Graph tags for the docs pages; ensure all docs pages are `index` (public) while the existing auth pages remain `noindex` (do not regress the marketing site's robots policy).
- [ ] Verify the docs section's `dir="ltr" lang="en"` override does not leak into the RTL marketing/auth pages.
- [ ] Run an a11y check (axe/pa11y) on each docs route: landmarks present, tables have header scopes, the API-key input has an associated `<label>`, color contrast meets WCAG AA in both themes, focus order is logical, the skip link works.
**Acceptance:**
- [ ] Every `/docs/api/*` route is reachable from the `SideNav` and from the marketing site's API CTA.
- [ ] All docs pages are `index`; auth pages remain `noindex`.
- [ ] `pnpm --filter zync-www build` produces static HTML for all ten docs routes with zero client JS except the single `TryIt` island and the copy buttons.
- [ ] pa11y reports no WCAG 2.1 AA violations on the Overview, Authentication, and a resource page.
