# Print Layouts — Implementation Plan

**Spec:** docs/specs/2026-06-01-print-layouts.md  ·  **Slug:** print-layouts  ·  **Wave:** 15
**Depends on:** contracts-esignature, invoices-core, proposal-pdf-export

## Goal
Deliver browser-native `@media print` CSS so users can `Ctrl+P` / `window.print()` invoice detail pages, public proposal previews, and customer AR-aging statement pages directly from the browser — producing clean A4 hard copy without a server roundtrip. This is distinct from the HTML-to-PDF Worker (`GET /api/invoices/:id/html`, contract/proposal PDF render) used for archival snapshots. No new tables, no new API endpoints, no schema delta — the work is print CSS plus wiring stable `data-*` print-target attributes onto existing components.

## Architecture
Three documents are made printable, each by co-locating an `@media print` block with the component/page that renders it and tagging print targets with explicit `data-*` attributes (never class names — classes get renamed in refactors):

1. **Invoice detail** — `apps/zync-app/src/features/invoices/` route `/invoices/:id` (the `InvoiceDetail` page from `invoices-core`, spec 15). Renders inside the app `Shell` (sidebar + header, `app-shell` spec). The Shell's sidebar and header get `data-sidebar` / `data-topbar`; the invoice page's action toolbar gets `data-invoice-actions`; the preview panel root carries `data-invoice-status="{InvoiceStatus}"` driving a PAID/VOID watermark; the line-items table gets `data-line-items-table`; the totals block gets `data-invoice-totals`.
2. **Proposal public preview** — `/p/:token` on **zync-www (Astro)** (`proposal-pdf-export` spec 159/178). Public page already exposes a "Print / Save as PDF" footer button calling `window.print()`. Footer action bar gets `data-proposal-footer-actions`; each rendered section gets `data-proposal-section`; the `line_items` pricing table gets `data-proposal-pricing`.
3. **Customer statement** — `/customers/:id/statement` in `apps/zync-app/src/features/reports/` (the AR-aging statement view, `ar-aging-report` spec 34). Reuses the shared Shell `data-sidebar`/`data-topbar`; statement toolbar gets `data-statement-actions`; each aging row gets `data-statement-row`.

Print CSS consumes existing design tokens `--success` and `--danger` (foundation-design-system, OKLCH) via `color-mix(in oklch, …)` for translucent watermarks. RTL is inherited from `<html dir="rtl">` (system-i18n / rtl-hebrew-ui) — only the watermark `content` strings are localised for Hebrew. Logical CSS properties (`padding-inline-*`) already used by tables make column-order reversal automatic under RTL; no extra print+RTL overrides for layout.

Upstream interfaces consumed (exact names): `InvoiceStatus` (`@zync/types`) enum values `PAID` / `VOID` etc.; `InvoiceObject` / `InvoiceLineObject` (rendered fields); `tenant_settings.invoice_footer_text` (payment terms/instructions, spec 125) already rendered by the invoice preview panel; Shell layout component (`app-shell`). No upstream exports are modified — only DOM attributes are added to their rendered output and CSS blocks are appended.

## Tech Stack
- **apps/zync-app** (Vite + React, react-router v7, Cloudflare Workers): invoice detail page, customer statement page, and the global `Shell` component. CSS via the project's global stylesheet + component-level CSS (Tailwind preset `packages/config/tailwind.preset.ts`; raw `@media print`/`@page` written in a global print stylesheet imported once at app entry, since `@page` and pseudo-element watermarks are not expressible as Tailwind utilities).
- **apps/zync-www** (Astro, Cloudflare Workers): public proposal preview `/p/:token` print CSS, co-located in the proposal preview template/component.
- No new packages, no new Cloudflare bindings, no DB. ESLint guards apply: `no-hardcoded-colors` (use `--success`/`--danger` tokens only), `no-raw-html-in-pages`. CSP: pure CSS — no inline scripts added; `window.print()` for the proposal button is already part of the proposal page's bundled script.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — App-shell print targets | 1 | `apps/zync-app/src/components/Shell.tsx` (or equivalent) | Yes |
| B — Global app print stylesheet | 2 | `apps/zync-app/src/styles/print.css`, app entry | Yes (after A defines attrs) |
| C — Invoice page wiring | 3 | invoice detail page + toolbar + totals + line-items components | Yes |
| D — Customer statement wiring | 4 | statement page + toolbar + rows | Yes |
| E — Proposal public print (Astro) | 5 | zync-www proposal preview template | Yes (independent of A–D) |
| F — RTL watermark localisation | 6 | `apps/zync-app/src/styles/print.css` (+ zync-www proposal CSS) | After B, E |
| G — Print verification (manual + e2e) | 7 | Playwright spec (zync-app) | After C, D, F |

## Tasks

### Task 1: Add print-target data attributes to the app Shell (sidebar + header)
**Blocks:** 2, 3, 4  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-app/src/components/Shell.tsx` (the `app-shell` Shell layout: sidebar + header + `<Outlet/>`)
**Steps:**
- [ ] Add `data-sidebar` attribute to the root element of the sidebar navigation region.
- [ ] Add `data-topbar` attribute to the root element of the header/top navigation bar.
- [ ] Do NOT change any visual/layout class — attributes are inert at screen media; they exist purely as `@media print` selectors.
- [ ] Confirm the attributes sit on the outermost element of each region so `display:none` hides the whole chrome block including nested controls (search modal trigger, notification dropdown, tenant switcher).
**Acceptance:**
- [ ] Rendered app DOM has exactly one `[data-sidebar]` and one `[data-topbar]` element.
- [ ] Screen rendering is visually unchanged (attributes carry no screen styling).

### Task 2: Create the global app print stylesheet (page setup + chrome hiding + invoice watermark + break rules)
**Blocks:** 3, 4, 6, 7  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/styles/print.css`
- Modify: app entry (`apps/zync-app/src/main.tsx` or `index.css`) to `import './styles/print.css'` exactly once
**Steps:**
- [ ] Write the `@media print` rules below verbatim, using OKLCH design tokens only (`--success`, `--danger`) — no hex/rgb (satisfies `no-hardcoded-colors`).
- [ ] Hide app chrome: `[data-sidebar]`, `[data-topbar]`, `[data-invoice-actions]`, `[data-statement-actions]` → `display: none !important;`.
- [ ] Set `@page { size: A4; margin: 2cm; }` (A4 — IL primary market; matches PDF Worker, spec 15).
- [ ] Add PAID (green diagonal) and VOID (red diagonal) `::after` watermarks keyed on `[data-invoice-status="PAID"]` / `[data-invoice-status="VOID"]`.
- [ ] Add `page-break-inside: avoid` for line-item rows, the invoice totals block, and statement rows.
- [ ] Import the stylesheet ONCE at app entry so it applies to every route (invoice detail and statement share chrome rules).
**Schema / Interfaces:**
```css
/* apps/zync-app/src/styles/print.css */
@media print {
  /* hide app chrome (Shell + page action bars) */
  [data-sidebar],
  [data-topbar],
  [data-invoice-actions],
  [data-statement-actions] {
    display: none !important;
  }

  /* A4 page setup; 2cm margin suppresses browser-injected URL/date */
  @page {
    size: A4;
    margin: 2cm;
  }

  /* invoice status watermark — PAID (green diagonal) */
  [data-invoice-status="PAID"]::after {
    content: "PAID";
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%) rotate(-45deg);
    font-size: 8rem;
    font-weight: 700;
    color: color-mix(in oklch, var(--success) 20%, transparent);
    pointer-events: none;
    z-index: 9999;
  }

  /* invoice status watermark — VOID (red diagonal), same positioning */
  [data-invoice-status="VOID"]::after {
    content: "VOID";
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%) rotate(-45deg);
    font-size: 8rem;
    font-weight: 700;
    color: color-mix(in oklch, var(--danger) 20%, transparent);
    pointer-events: none;
    z-index: 9999;
  }

  /* line items table: never split a row across pages */
  [data-line-items-table] tr {
    page-break-inside: avoid;
  }

  /* totals block: keep together */
  [data-invoice-totals] {
    page-break-inside: avoid;
  }

  /* customer statement rows: never split across pages */
  [data-statement-row] {
    page-break-inside: avoid;
  }
}
```
**Acceptance:**
- [ ] `import './styles/print.css'` appears exactly once at app entry; no other route imports it.
- [ ] Lint passes (`no-hardcoded-colors` — only `var(--success)`/`var(--danger)` used).
- [ ] In print preview of any app route, sidebar and topbar are absent and page is A4 with 2cm margins.

### Task 3: Wire invoice detail page print targets (status, actions, line-items, totals)
**Blocks:** 7  ·  **Blocked by:** 1, 2
**Files:**
- Modify: invoice detail page in `apps/zync-app/src/features/invoices/` (the `/invoices/:id` `InvoiceDetail` page, invoices-core spec 15) and its child components (action toolbar, preview panel, line-items table, totals block)
**Steps:**
- [ ] On the invoice preview-panel root element, set `data-invoice-status={invoice.status}` where `invoice.status` is the `InvoiceStatus` value from `InvoiceObject`. The watermark only renders for `PAID` and `VOID`; other statuses produce no `::after` (selectors only match those two values).
- [ ] Add `data-invoice-actions` to the action button bar (Send, Edit, Download PDF, Record payment, Open/Print, etc.) so the whole bar is hidden in print.
- [ ] Add `data-line-items-table` to the `<table>` element rendering line items (description, qty, unit price, amount).
- [ ] Add `data-invoice-totals` to the subtotal / per-rate tax / total block.
- [ ] Confirm the printed body shows: tenant logo + name + contact email + phone, invoice number, issue date, due date, customer name + billing address, line items, subtotal/tax-per-rate/total, and payment terms/instructions from `tenant_settings.invoice_footer_text` (already rendered by the preview panel; no new data fetch).
- [ ] Use logical CSS properties (`padding-inline-start`/`-end`) on table cells if any inline padding is added, so RTL column order reverses automatically.
**Acceptance:**
- [ ] `Ctrl+P` on a `PAID` invoice shows a green diagonal "PAID" watermark; on a `VOID` invoice a red diagonal "VOID"; on a `DRAFT`/`SENT`/`TAX_ISSUED` invoice no watermark.
- [ ] Action toolbar, sidebar, and topbar are absent from print output.
- [ ] No line-item row or the totals block is split across a page boundary.
- [ ] Printed document contains logo, business contact, invoice number, dates, customer billing address, line items, tax breakdown, total, and footer payment terms.

### Task 4: Wire customer statement page print targets (actions + rows)
**Blocks:** 7  ·  **Blocked by:** 1, 2
**Files:**
- Modify: customer statement page in `apps/zync-app/src/features/reports/` (`/customers/:id/statement`, the AR-aging statement view, ar-aging-report spec 34) and its toolbar + row components
**Steps:**
- [ ] Add `data-statement-actions` to the statement page's action/toolbar region (export, print, date-range controls) so it is hidden in print.
- [ ] Add `data-statement-row` to each aging row element so rows are not split across pages.
- [ ] Confirm the statement reuses the Shell, so `data-sidebar`/`data-topbar` from Task 1 already hide app chrome — no extra attributes needed for chrome.
- [ ] No data changes — statement is an aggregated read; print only restyles existing DOM.
**Acceptance:**
- [ ] `Ctrl+P` on `/customers/:id/statement` hides sidebar, topbar, and statement action bar.
- [ ] No statement row is split across a page boundary.
- [ ] Page prints A4 with 2cm margins (inherited from the global print stylesheet, Task 2).

### Task 5: Add proposal public preview print CSS + data attributes (zync-www / Astro)
**Blocks:** 7  ·  **Blocked by:** —
**Files:**
- Modify: the public proposal preview template/component on zync-www serving `/p/:token` (`apps/zync-www/...` proposal preview, proposal-pdf-export spec 159)
**Steps:**
- [ ] Add `data-proposal-footer-actions` to the footer action bar containing the "Print / Save as PDF" and "Accept this proposal" buttons.
- [ ] Add `data-proposal-section` to each rendered proposal section element (`cover`, `scope`, `deliverables`, `timeline`, `line_items`, `terms`, `custom` from `proposals.content.sections[]`).
- [ ] Add `data-proposal-pricing` to the `line_items` pricing table so it is kept together.
- [ ] Co-locate the `@media print` block below in the proposal preview component's scoped styles (Astro `<style>` or the component CSS for that page) — NOT in the zync-app global stylesheet (different app).
- [ ] Verify the existing "Print / Save as PDF" button calls `window.print()` (already specified by proposal-pdf-export); no new script — preserves CSP (no new inline handlers beyond the existing bundled one).
**Schema / Interfaces:**
```css
/* zync-www proposal preview — co-located @media print */
@media print {
  [data-proposal-footer-actions] {
    display: none !important;
  }

  @page {
    size: A4;
    margin: 2cm;
  }

  /* keep each section on one page where possible */
  [data-proposal-section] {
    page-break-inside: avoid;
  }

  /* keep the pricing table together */
  [data-proposal-pricing] {
    page-break-inside: avoid;
  }
}
```
**Acceptance:**
- [ ] Clicking "Print / Save as PDF" on `/p/:token` opens the print dialog with footer action bar hidden.
- [ ] Each proposal section and the pricing table avoid mid-section/mid-table page breaks.
- [ ] Output is A4 with 2cm margins; no app chrome (public page has none).

### Task 6: Localise watermark text for Hebrew (RTL) tenants
**Blocks:** 7  ·  **Blocked by:** 2, 5
**Files:**
- Modify: `apps/zync-app/src/styles/print.css`
**Steps:**
- [ ] Append RTL watermark overrides keyed on `html[dir="rtl"]` so Hebrew tenants print "שולם" (PAID) and "מבוטל" (VOID) instead of English.
- [ ] Rely on inherited `<html dir="rtl">` (set by system-i18n / rtl-hebrew-ui) — add NO layout overrides; logical properties already reverse column order.
- [ ] Do not duplicate watermark positioning — only the `content` string changes; the LTR rule from Task 2 supplies position/transform/color.
**Schema / Interfaces:**
```css
/* apps/zync-app/src/styles/print.css — RTL watermark text */
@media print {
  html[dir="rtl"] [data-invoice-status="PAID"]::after {
    content: "שולם";
  }
  html[dir="rtl"] [data-invoice-status="VOID"]::after {
    content: "מבוטל";
  }
}
```
**Acceptance:**
- [ ] With `html[dir="rtl"]`, a PAID invoice prints the watermark "שולם"; a VOID invoice prints "מבוטל".
- [ ] Watermark keeps the green/red diagonal styling (color + rotation inherited from Task 2 rule).
- [ ] Line-items table columns appear right-to-left under RTL (Amount on the left, Description on the right) with no extra print CSS.

### Task 7: Print verification (manual checklist + Playwright e2e for chrome hiding)
**Blocks:** —  ·  **Blocked by:** 3, 4, 5, 6
**Files:**
- Create: `apps/zync-app/e2e/print-layouts.spec.ts`
**Steps:**
- [ ] Write a Playwright test that loads `/invoices/:id` for a PAID invoice, emulates print media (`page.emulateMedia({ media: 'print' })`), and asserts `[data-sidebar]`, `[data-topbar]`, `[data-invoice-actions]` are not visible while `[data-line-items-table]` and `[data-invoice-totals]` are visible.
- [ ] Add a case asserting the `[data-invoice-status="PAID"]` element renders a visible `::after` watermark under print media, and a DRAFT invoice does not.
- [ ] Add a case for `/customers/:id/statement`: under print media, `[data-statement-actions]`, `[data-sidebar]`, `[data-topbar]` are hidden; `[data-statement-row]` elements visible.
- [ ] Add an RTL case: set `<html dir="rtl">`, print media, PAID invoice → computed `::after` content resolves to "שולם".
- [ ] Document the manual proposal check (zync-www `/p/:token` `Ctrl+P` → footer hidden, A4) as a comment block in the spec file since the public Astro page is a separate app deployment.
- [ ] Run the e2e suite and confirm all assertions pass.
**Acceptance:**
- [ ] `apps/zync-app/e2e/print-layouts.spec.ts` passes: chrome hidden, watermarks correct per status, statement rows intact, RTL watermark text Hebrew.
- [ ] Manual verification recorded: RTL invoice print shows columns right-to-left (Amount → Description).
- [ ] No regression: screen rendering of all three pages unchanged (data attributes inert outside `@media print`).
