# Build pipeline — live editing, snapshots, PDFs, web bundle

## Live editing workflow

1. Copy template to property directory, populate with content
2. Stage `output/images/` per `images.md → Image renaming and optimisation`
3. **If branded register and `<brand_dir>/DESIGN.md` contains a `## Type roles` block** — emit the role overlay into both `brochure.html` and `index.html` per **Type-role overlay generation** below. Run this **before** any snapshot capture; the overlay is part of what the user reviews in the browser, not a post-process applied to PDFs.
3b. **Doc-comment strip — run against BOTH templates.** After substitution and overlay emission, run a regex pass that removes template authoring notes (the `<!-- REPLACE … -->` markers and any demo-placeholder blocks inside comments) from both `brochure.html` and `index.html`. The single regex is `re.compile(r"<!--\s*REPLACE\s+.*?-->", re.DOTALL)`. Run it explicitly against **both** files — a strip pass that covers only the brochure leaves landing-page authoring notes rendering as visible body text whenever a comment delimiter is misplaced (the historical `page.html` template shipped this defect once already). See **Template authoring hygiene + CI lint** below for the source-side check that catches the same class of defect at template-edit time, before a property build is ever started.
4. Serve locally: `python3 -m http.server <port>` from the project root
5. Navigate Playwright to the brochure URL
6. Present to user in browser for review
7. User requests changes — iterate on content, image sizing, page balance
8. Verify every page fits A4: measure `scrollHeight` for all `.page` elements
9. Verify no 404s in the browser console
10. Repeat 6–9 until user approves the final copy
11. **Only after user approval**: render snapshots (two passes — 300 dpi to `output/`, 192 dpi to `output/.snapshots-web/`) per **Print snapshot capture** below
12. Build both PDFs (img2pdf + qpdf) per **PDF deliverable** below
13. Build the web bundle (96 dpi JPGs + bundled web PDF + zip) per **Web bundle** below
14. User downloads the web PDF via brochure.html's Download button or receives the print master separately for press/print runs

Do not capture snapshots during the editing cycle — they are expensive and invalidated by every change.

## Print snapshot capture

Every page is pre-captured as a PNG. The PNGs are used both as the print-stylesheet replacement (so the live brochure prints to a clean PDF when a user hits Cmd+P) **and** as the source images bound into the two PDF deliverables. **Run this only once, after the user approves the final version.**

**Two render passes, two DPI tiers:**

| Snapshot set | DPR | Size per page (landscape A4) | Used for |
|---|---|---|---|
| `<property_dir>/output/*-print.png` | 3.125× (300 dpi) | 3509×2481 px | print-master PDF + the print stylesheet's `.print-img` swap |
| `<property_dir>/output/.snapshots-web/*-print.png` | 2× (192 dpi) | 2246×1588 px | web PDF |

The print-tier PNGs (300 dpi) are the canonical archived snapshots — they live at the top of `output/` next to the HTML and the PDFs. The web-tier PNGs (192 dpi) are an intermediate render step and live in `output/.snapshots-web/`; they're consumed by the web-PDF build and can be deleted afterwards.

**Render via Playwright** (one script, run twice with different `SCALE`):

```javascript
const { chromium } = require('playwright');
const path = require('path');

const HTML = process.argv[2];        // path to brochure.html
const OUT_DIR = process.argv[3];     // output directory for PNGs
const SCALE = parseFloat(process.argv[4] || '3.125');  // 2 for web, 3.125 for print

(async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext({
    viewport: { width: 1123, height: 794 },          // 794 × 1123 if portrait
    deviceScaleFactor: SCALE,
  });
  const page = await context.newPage();
  await page.goto(`file://${HTML}`, { waitUntil: 'networkidle' });

  // Suppress screen-only chrome before snapshotting.
  // The template has @media print { .download-bar { display: none } } so the
  // bar disappears under Cmd+P, but Playwright's element.screenshot() captures
  // the SCREEN render — @media print does not fire. Without this style-tag
  // injection, the floating "Download PDF" button (and any future screen-only
  // overlay) leaks into every per-page snapshot and into both PDFs.
  // KEEP this in any custom render script — it is load-bearing.
  await page.addStyleTag({ content: `
    .download-bar { display: none !important; }
  ` });

  await page.evaluate(() =>
    Promise.all(Array.from(document.images, img =>
      img.complete ? null : new Promise(r => { img.onload = img.onerror = r; }))));

  const pages = await page.$$('section.page');
  for (let i = 0; i < pages.length; i++) {
    const label = await pages[i].getAttribute('data-screen-label');
    const filename = i === 0
      ? 'cover-print.png'
      : /back/i.test(label || '') ? 'backpage-print.png' : `page${i+1}-print.png`;
    await pages[i].scrollIntoViewIfNeeded();
    await pages[i].screenshot({ path: path.join(OUT_DIR, filename), type: 'png' });
  }
  await browser.close();
})();
```

Run it once for each tier:

```bash
node render-snapshots.js <property_dir>/output/brochure.html <property_dir>/output/                    3.125
node render-snapshots.js <property_dir>/output/brochure.html <property_dir>/output/.snapshots-web/    2
```

For a 16-page folio, each pass produces `cover-print.png`, `page2-print.png` through `page15-print.png`, and `backpage-print.png` — sixteen files in each directory.

**Print CSS**: `@page { size: A4 landscape; margin: 0 }` (or `portrait` if overridden). The print stylesheet has two paths, both still needed:

1. **Snapshot path (primary)** — when a `.print-img` has a non-empty `src`, the rule `.page:has(.print-img[src]:not([src=""])) > *:not(.print-img) { display: none }` hides everything else and the snapshot fills the page at `z-index: 9999`. The 300 dpi snapshots are referenced by `.print-img src=""` so the live DOM print path also gets full-quality output if the user hits Cmd+P.
2. **Live-DOM fallback** — if a snapshot is missing or empty, the page prints the live DOM. The folio is forced visible by overriding `.folio` with `z-index: 100`, `bottom: 6mm`, and **dropping `backdrop-filter`** (Chromium's PDF engine strips it, making the pill effectively invisible against dark imagery). The pill background falls back to a solid `rgba(252, 251, 248, 0.95)` for light pages and `rgba(6, 68, 68, 0.78)` for `.folio-dark` pages. The same `z-index` lift is applied to `.back-foot`.

**Why both paths matter**: users sometimes hit Cmd+P before snapshots are captured, or the snapshot pipeline fails silently. The fallback guarantees the folio still prints. The `.print-img` elements use `opacity: 0; width: 1px; height: 1px` on screen (not `display: none` — that prevents image loading in some browsers).

## PDF deliverable

The brochure ships as `brochure.html` plus **two PDFs**, both in `<property_dir>/output/`:

- `<property_slug>-brochure-print.pdf` — **print master** (300 dpi, ~65–100 MB for a 16-page folio). Canonical archive artefact, suitable for sending to commercial print houses.
- `<property_slug>-brochure-web.pdf` — **web/digital deliverable** (192 dpi, ~20–35 MB). What gets emailed, attached, and downloaded from the web bundle.

Both are image-only — no fonts embedded, no Ghostscript anywhere in the chain. See **a4-print-documents → PDF deliverables** for the full rationale on why image-only is the right answer.

### Build steps

Build both grades from the per-page snapshots in `output/` and `output/.snapshots-web/`, then linearize via qpdf. The print master embeds the PNG snapshots verbatim; the web grade re-encodes them as JPEG quality 85 before binding, so the deliverable that goes out to email and CDN hosting is materially smaller:

```bash
# Print master from 300 dpi PNGs (in output/)
cd <property_dir>/output/
img2pdf --pagesize 297mmx210mm \
        --title "<Property Name> · <address line 1>" \
        --author "<Agent Name>" \
        --output /tmp/pre-linearize.pdf \
        cover-print.png page2-print.png page3-print.png page4-print.png \
        page5-print.png page6-print.png page7-print.png page8-print.png \
        page9-print.png page10-print.png page11-print.png page12-print.png \
        page13-print.png page14-print.png page15-print.png backpage-print.png
qpdf --linearize --object-streams=disable \
     /tmp/pre-linearize.pdf \
     <property_slug>-brochure-print.pdf

# Web PDF from 192 dpi PNGs → JPEG q=85 (in output/.snapshots-web/)
# img2pdf preserves JPEG bytes verbatim, so re-encoding once up front is the only quality step.
cd <property_dir>/output/.snapshots-web/
for png in *-print.png; do
  magick "$png" -quality 85 -strip -interlace none -colorspace sRGB "${png%.png}.jpg"
done
img2pdf --pagesize 297mmx210mm \
        --title "<Property Name> · <address line 1>" \
        --author "<Agent Name>" \
        --output /tmp/pre-linearize.pdf \
        cover-print.jpg page2-print.jpg … backpage-print.jpg
qpdf --linearize --object-streams=disable \
     /tmp/pre-linearize.pdf \
     <property_dir>/output/<property_slug>-brochure-web.pdf

rm /tmp/pre-linearize.pdf
rm -rf <property_dir>/output/.snapshots-web/   # optional cleanup
```

`297mmx210mm` is **landscape** A4. For portrait flip to `210mmx297mm`. img2pdf preserves the embedded PNG bytes verbatim — no re-encode, no quality loss. qpdf's `--object-streams=disable` is required because the PDF 1.4 linearization profile (which we want for maximum viewer compatibility) is incompatible with object streams.

### Verification — three commands, all must pass for both files

```bash
qpdf --check <output>.pdf      # → "No syntax or stream encoding errors found"
pdfinfo  <output>.pdf | grep Optimized   # → "Optimized: yes"
pdffonts <output>.pdf | wc -l            # → 2 (header rows only; no font entries)
```

A typical 16-page folio ships with the print master at **65–100 MB** (PNG-embedded) and the web PDF at **7–15 MB** (JPEG-embedded at q=85). If the print master lands under ~40 MB, the snapshot DPR is wrong (likely rendered at 2× instead of 3.125×). If the web PDF exceeds ~20 MB, the JPEG re-encode was skipped (bind step ran against `.png` instead of `.jpg`) or the snapshot DPR drifted to 3.125× by accident.

The web PDF defaults to JPEG-embedded; PNG-embedded for the web grade is **opt-in only**, for one-pagers or report-style brochures where small-text crispness matters more than wire size. For the standard photographic property folio, JPEG q=85 is visually indistinguishable from PNG at typical screen DPRs and is roughly 3–5× smaller. The print master never flips to JPEG — its audience archives for press runs.

### When to (re)generate

Generate the PDF after every approved layout change. Do not let the PDF drift behind the HTML.

## Web bundle — self-contained zip for hosting

In addition to the print-quality artefacts in `output/`, the brochure ships a separate **self-contained web bundle** under `output/web/`, then zipped to `output/<slug>-web.zip`. This is what a property micro-site or marketing portal hosts publicly. It is intentionally smaller — the canonical `output/` photos and snapshots are sized for archival print quality, which is wasteful when the brochure is being delivered as a one-time HTML page on a web page.

The web bundle is a parallel directory with the same on-disk shape as `output/` but with smaller assets:

```
output/web/
  brochure.html                      # identical content; .print-img src attributes cleared (see below)
  index.html                          # companion landing page (mandatory in bundle)
  <slug>-brochure.pdf                # identical bytes to <slug>-brochure-web.pdf at the property level — simpler name inside the bundle since there's only one PDF here
  images/
    <slug>-NN.webp                   # web-tier per-slot encodings (see table below)
    qr-video.png, qr-listing.png
    <brand>-logo-light.png
```

The bundle deliberately does **not** include the per-page JPG snapshots (`cover-print.jpg` etc). They duplicate the brochure content already embedded in `<slug>-brochure.pdf` at smaller cost-to-quality. Operators who want printed output download the PDF from the bundle; operators who view `brochure.html` in a browser and hit Cmd+P get the live-DOM print path (see `Print snapshot capture → Live-DOM fallback`). Shipping both was the historical defect that pushed a 16-page folio bundle past 50 MB.

### Web-tier image encoding

The same per-slot tier idea as the digital floor in `images.md`, but with smaller widths and slightly lower quality — sized for screen viewing at 100% zoom, not for print sharpness.

| Slot type | Web tier (long edge / quality) | Typical file size |
|---|---|---|
| Hero / cover / banner / feature / drone aerial / front elevation / outdoor full-bleed | **1300 px @ q82** | 80–300 KB |
| Story photo (in-spread half-page) | **1100 px @ q80** | 60–250 KB |
| Gallery thumb / strip / image-led grid cell | **800 px @ q76** | 25–80 KB |
| **Floor plan** (PNG output, line art) | **PNG source copied unchanged; JPG source converted to PNG at build time** — `magick <slug>-floorplan.jpg <slug>-floorplan.png` then drop the `.jpg`. The brochure templates reference `images/<slug>-floorplan.png` and the contract is PNG-only on disk; JPEG compression artefacts on thin lines and small text are unacceptable for floorplans. | matches PNG output (often 500 KB – 1 MB) |
| Site plan (raster line art) | **1100 px @ q80** WebP if not already PNG; if PNG, copy unchanged | 30–60 KB or source size |

A 27-image folio typically lands at **~2.5 MB** of web photographs, with the floor plan adding **0.5–1.0 MB** on top because line art is preserved at source resolution. Total web image weight is typically 3.0–3.5 MB.

**Floor-plan rule, repeated for emphasis.** Line-art PNG floor plans are **never re-encoded** for the web bundle — copy the source file as-is. Browsers render PNG line art crisply at any zoom level; converting to WebP introduces visible artefacts on text and rules even at q90+. The bundle gains a few hundred KB but the trade-off is on the right side.

### Clear `.print-img` src in the web copy

The canonical `output/brochure.html` carries `<img class="print-img" src="cover-print.png">` references that power the snapshot path in the print stylesheet (Cmd+P → embedded PNG fills the page). The web bundle does **not** include those PNGs — and does not derive smaller JPEG copies either, because the PDF embedded in the bundle already covers the "I want a printable artefact" need. Instead, the web copy of `brochure.html` has its `.print-img` src attributes cleared so the print stylesheet drops to the live-DOM fallback path:

```python
# Edit the web copy of brochure.html only — leave the canonical output/brochure.html unchanged.
import re
with open("output/web/brochure.html") as f:
    s = f.read()
# Clear every .print-img src — the print stylesheet's :has(.print-img[src]:not([src=""]))
# selector then fails, and the @media print live-DOM path renders the page directly.
s = re.sub(
    r'(<img\s+class="print-img"[^>]*?\s+)src="[^"]*"',
    r'\1src=""',
    s,
)
with open("output/web/brochure.html", "w") as f:
    f.write(s)
```

The `.print-img` element on screen is `opacity: 0; width: 1px; height: 1px` regardless of `src`, so clearing the attribute has zero visible effect — only the print stylesheet branches differently. Operators who want a printable PDF download `<slug>-brochure.pdf` directly from the bundle.

### Copy the web PDF into the bundle

The web PDF (`<slug>-brochure-web.pdf` at the property level) is also placed inside the bundle, named simply `<slug>-brochure.pdf` to match `index.html` and `brochure.html` link conventions (where there's only one PDF in scope, no `-web` suffix is needed):

```bash
cp output/<slug>-brochure-web.pdf output/web/<slug>-brochure.pdf
```

### Write the `images.json` sidecar

After the web bundle's `images/` is populated and the brochure HTML is copied in, write the sidecar file at **both** `output/images.json` and `output/web/images.json`. The contents are byte-identical — the canonical archive copy and the bundle copy. Source-of-truth is the slot-verification dict already in memory; never re-derive from disk.

Full schema, closed-set room vocabulary, slot → room mapping, and emission recipe live in [`images.md → images.json sidecar`](images.md). The downstream `:Listing` curator reads the bundle copy at `<accountDir>/sites/<slug>/images.json` after `publish-site` lands the tree.

The strip-unreferenced-images pass below scans `output/web/images/` only — `images.json` lives at the bundle root and is unaffected.

### Strip unreferenced images

Every image in `output/web/images/` must be reachable from at least one HTML surface in the bundle (`brochure.html`, the companion landing page, and `og.html` if present). The build accumulates working images during editing — operator drops a hero, re-numbers a sequence, swaps an image and forgets to delete the prior version — and without the strip step the bundle ships every accumulated file. Typical bloat on a re-iterated 16-page folio is 20–40 unreferenced `.webp` files at ~500 KB each, which is what pushes a "30–50 MB target" bundle to 80–120 MB.

```bash
cd output/web

# Collect every image actually referenced by the bundle's HTML surfaces.
# `cat *.html` catches brochure.html, the companion landing page, and og.html
# without needing to know the landing-page filename in advance.
referenced=$(cat *.html 2>/dev/null \
  | grep -oE 'images/[A-Za-z0-9_.-]+\.(webp|jpg|jpeg|png|svg)' \
  | sort -u)

# Walk images/ and remove anything not in $referenced. Brand assets
# (logos, QR PNGs) are referenced by the templates and survive.
removed=0
for f in images/*; do
  if ! echo "$referenced" | grep -qxF "$f"; then
    rm -f "$f"
    removed=$((removed + 1))
  fi
done
echo "[strip] removed $removed unreferenced files from output/web/images/"
```

The strip is a closed-set operation: two agents producing the bundle for the same property emit byte-identical surviving image sets. Run it before the zip step; a bundle carrying unreferenced images is a build defect, not "extra-safe" packaging.

### Bundling

After populating `output/web/`, zip it with `-X` (strip extra attributes) and exclude any `.DS_Store` / AppleDouble files:

```bash
cd output && rm -f <slug>-web.zip
( cd web && zip -r -X "../<slug>-web.zip" . -x "*.DS_Store" "._*" )
```

A typical 16-page folio web zip is **30–50 MB**, dominated by the embedded web PDF. The `output/web/` directory remains on disk alongside the zip — it's the unzipped form of the same bundle, useful for direct upload to a static-site host.

### Smoke-test before declaring done

Serve the unzipped bundle from an isolated temp directory and verify every referenced asset returns 200:

```bash
TMP=/tmp/web-test && rm -rf $TMP && mkdir -p $TMP
cd $TMP && unzip -q /path/to/<slug>-web.zip
python3 -m http.server 8765 &
for f in brochure.html index.html images.json images/<slug>-01.webp images/<brand>-logo-light.png <slug>-brochure.pdf; do
  echo "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8765/$f) $f"
done
```

The brochure should render the same as the canonical preview, just lighter on the wire. If any 404 appears, the web copy is missing a referenced file — fix and re-zip.

### What stays in `output/` vs. what moves to `output/web/`

| File | `output/` (canonical archive) | `output/web/` (web bundle) |
|---|---|---|
| `brochure.html` | full-resolution images, `.png` snapshot refs | identical structure, `.print-img` srcs cleared (live-DOM print fallback) |
| `index.html` | — | ✓ companion landing page; see `index-landing.md` |
| `<slug>-brochure-print.pdf` | ✓ canonical print master (50–80 MB, 300 dpi) | — (too large to bundle) |
| `<slug>-brochure-web.pdf` | ✓ web/digital deliverable (7–15 MB, 192 dpi JPEG-embedded) | — copied into bundle as `<slug>-brochure.pdf` |
| `<slug>-brochure.pdf` | — | ✓ identical bytes to `-web.pdf`; matches index.html / brochure.html link |
| `cover-print.png … backpage-print.png` | ✓ 300 dpi PNG (~4–7 MB each) — canonical snapshots | — bundle ships the PDF instead of per-page JPGs |
| `images/<slug>-NN.webp` | full-quality per Render-slot table | web-tier per the web table above |
| `images.json` | ✓ canonical sidecar (slot, room, alt, caption per image) — written after slot verification | ✓ byte-identical copy at bundle root; consumed by `:Listing` curator after publish |
| `images/qr-*.png`, `images/<brand>-logo-*.png` | ✓ | ✓ (copied unchanged — already small) |
| `.snapshots-web/*-print.png` | ✓ intermediate (192 dpi PNGs used by the web-PDF build; deletable after) | — |

The convention: `output/` is the canonical archive for the property's lifetime. `output/web/` is a derived bundle, regenerable any time from the canonical artefacts.

## Brand-token override emission (cascade-safe, two-template, two-vocabulary)

Branded register replaces palette and type tokens in `:root`. The override is emitted as a `<style id="brand-tokens">…</style>` block at the **`BRAND_TOKENS_OVERRIDE_SLOT`** sentinel in **both** the brochure template (`template.html`) and the companion landing page (`index.html`). The sentinel sits immediately before the closing `}` of each `:root` rule — placing the override after the template's own declarations is what keeps the brand values winning the cascade. See SKILL.md hard rule "Brand-token overrides MUST land at the BRAND_TOKENS_OVERRIDE_SLOT sentinel".

### Two vocabularies (today)

The brochure and the landing page use independent token vocabularies. Until vocabulary unification lands (see follow-up below), the brand-tokens block must include **both** sets of token names so that whichever template the block lands in, the right values bind.

| Concept | `template.html` token | `index.html` token |
|---|---|---|
| Ivory page background | `--paper-25` | `--paper` |
| Slightly darker cream surface | `--paper-50` | `--paper-2` |
| Warm cream / banded surface | `--paper-100` | (no equivalent — omit) |
| Pure white | `--paper-0` | (no equivalent — omit) |
| Body ink | `--ink` | `--ink` (shared name) |
| Secondary ink | (no equivalent — omit) | `--ink-2` |
| Bronze accent | `--gold-700` | `--gold` |
| Antique gold rule / softer accent | `--gold-500` | `--gold-soft` |
| Champagne flourish | `--gold-300` | (no equivalent — omit) |
| Display serif family | `--serif` | `--serif` (shared name) |
| Body serif family | `--body-serif` | (no equivalent — omit) |
| Sans family | `--sans` | (no equivalent — omit) |

Pick one brand-pack value per concept, emit **both** token names in the same block. Example for a brand whose accent is brand-green `#02843E`:

```html
<style id="brand-tokens">
:root {
  /* template.html vocabulary */
  --paper-25: #FFFFFF;
  --paper-50: #F4F1EA;
  --paper-100: #EFE7D8;
  --gold-700: #02843E;
  --gold-500: #4FB371;
  --gold-300: #C9E8D4;
  --ink: #323636;
  --serif: 'Lora', 'Cormorant Garamond', serif;
  --body-serif: 'Lora', serif;
  --sans: 'Inter', sans-serif;

  /* index.html vocabulary — same colour values, different token names */
  --paper: #FFFFFF;
  --paper-2: #F4F1EA;
  --gold: #02843E;
  --gold-soft: #4FB371;
  --ink-2: #2A3036;
}
</style>
```

The same block is substituted at the sentinel in **both** templates. A block that only carries the `template.html` vocabulary leaves the landing page rendering Premium silently — the substitution gate passes and the snapshot capture proceeds. The defect is invisible until visual review.

### Follow-up — vocabulary unification (long-term)

The dual-emission above is a tactical fix for the divergence between the two templates' `:root` vocabularies. The long-term fix is to **unify the landing page on `template.html`'s tiered vocabulary** (`--paper-0 / -25 / -50 / -100`, `--gold-300 / -500 / -700`, `--ink` plus a `--ink-2` if needed) so every brand block is authored once instead of twice. Until that refactor lands, the dual-emission contract above is structurally required.

## Template authoring hygiene + CI lint

The doc-comment strip in step 3b of the workflow above catches template authoring notes **at property-build time** — but a template that ships with a misplaced `<!-- … -->` delimiter is a source-side defect that should be caught at template-edit time, before any property build runs against it. The historical `page.html` template carried a duplicated authoring note where one copy sat inside a proper comment and the other copy sat outside any delimiters; browsers rendered the duplicate as visible body text on every property built from that template.

Add a CI step (or a `make check-templates` recipe at the plugin root) that parses every `references/*.html` and asserts:

```python
import re
from pathlib import Path

REFERENCES = Path("references")
TEMPLATES = list(REFERENCES.glob("*.html"))           # template.html, index.html
COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)

LEAKED_PHRASES = [
    "terminate this outer comment",
    "REPLACE markers, use prose",
    "delimiter pair",
    "the bug that prompted this note",
]

defects = []

for tpl in TEMPLATES:
    text = tpl.read_text()

    # 1. Every <!-- must be closed by --> within the file.
    if text.count("<!--") != text.count("-->"):
        defects.append(f"{tpl}: unbalanced HTML comment delimiters")

    # 2. Strip every well-formed comment, then any LEAKED_PHRASES that
    # remain are authoring notes sitting outside comments — body-text
    # defects waiting to ship.
    outside_comments = COMMENT_RE.sub("", text)
    for phrase in LEAKED_PHRASES:
        if phrase in outside_comments:
            defects.append(f"{tpl}: authoring-note phrase {phrase!r} found outside any HTML comment")

    # 3. `{{ token }}` placeholders are fine inside slot positions but
    # an example placeholder appearing inside a code-block in body text
    # (e.g. an explanation of how to use the template) is a leak.
    # Surface as a soft warning — operator decides.
    leftover_examples = re.findall(r"\{\{\s*example[_a-z]*\s*\}\}", outside_comments)
    if leftover_examples:
        defects.append(f"{tpl}: example placeholders leaked outside comments: {leftover_examples}")

if defects:
    print("Template lint failed:")
    for d in defects:
        print(f"  - {d}")
    raise SystemExit(1)
```

Either check catches the page.html-class leak at edit time, before it ships to a property build. The lint runs against the source-side `references/*.html` only — the property-build copies under `<property_dir>/output/` are derived artefacts and are protected by step 3b above instead.

## Type-role overlay generation

If the brand pack's `DESIGN.md` contains a `## Type roles` block (declared per `references/registers.md → Type-role overrides`), parse it and emit a single `<style id="brand-type-roles">…</style>` block at the `<!-- BRAND_TYPE_ROLES_OVERRIDE_SLOT -->` sentinel immediately before the closing `</style>` of the template's main stylesheet. The override CSS is generated **mechanically** from the recipe map below — the build does not invent selectors, it emits the documented ones using values from the DESIGN doc.

Two agents running this build on different days for the same brand pack produce byte-identical overlays. That is the contract.

The role-overlay step runs **before** snapshot capture (step 10 in the live-editing workflow), not after. The overlay is part of the rendered HTML the user reviews, not a post-process applied to PDFs.

### Recipe map — reference implementation

```python
ROLE_RECIPES = {
    'display-headline-style': {
        'normal': [
            '.cover-title, .opener-title, .ed-headline, .chapter-head h3, '
            '.feature-page h3, .gallery-head h3, .siteplan-page h3, .toc-title, '
            '.back-headline, .mi-head h2, .mi-head h3 '
            '{ font-style: normal !important; }',
        ],
    },
    'display-em-style': {
        # Affirmative "use the display family's natural italic cut". Emits no
        # override (the template default at :root already sets em runs to italic
        # via font-style inheritance). Authors should prefer this for any brand
        # whose display family carries a real italic (Playfair, Cormorant, Lora,
        # EB Garamond, etc.) AND whose site visibly uses italic emphasis. The
        # "default" value in registers.md required OMITTING the role entirely,
        # which made the safe choice silent and the destructive choices (weight-
        # shift, normal) the only affirmative ones in the example block — a known
        # foot-gun. Setting `italic` explicitly closes that.
        'italic': [],
        'weight-shift': [
            '.cover-title em, .opener-title em, .ed-headline em, .toc-title em, '
            '.back-headline em, .mi-head em '
            '{ font-style: normal !important; font-weight: 500 !important; }',
        ],
        'normal': [
            '.cover-title em, .opener-title em, .ed-headline em, .toc-title em, '
            '.back-headline em, .mi-head em '
            '{ font-style: normal !important; }',
        ],
    },
    'display-letterspacing': {
        # tuned to family: Cormorant=wide (0.18em), humanist sans=medium (0.10em),
        # geometric sans=tight (0.04em). Applies only to the two largest display lines.
        'medium': [
            '.cover-title { letter-spacing: 0.10em !important; text-indent: 0.10em !important; }',
            '.opener-title { letter-spacing: -0.012em !important; }',
        ],
        'tight': [
            '.cover-title { letter-spacing: 0.04em !important; text-indent: 0.04em !important; }',
            '.opener-title { letter-spacing: -0.02em !important; }',
        ],
    },
    'deck-family': {
        'sans': [
            '.ed-deck, .ed-deck em, .feature-deck, .gallery-deck, .siteplan-deck '
            '{ font-family: var(--sans) !important; font-style: normal !important; }',
        ],
    },
    'deck-case': {
        'sentence': [
            '.ed-deck, .feature-deck, .gallery-deck, .siteplan-deck '
            '{ letter-spacing: 0.02em !important; text-transform: none !important; }',
            '.ed-deck em { font-weight: 500 !important; }',
        ],
    },
    'subtitle-family': {
        'sans-tracked-caps': [
            '.opener-title em, .cover-subtitle, .opener-spec '
            '{ font-family: var(--sans) !important; font-style: normal !important; }',
            '.opener-spec em { font-style: normal !important; font-weight: 500 !important; }',
        ],
        'sans': [
            '.opener-title em, .cover-subtitle, .opener-spec '
            '{ font-family: var(--sans) !important; font-style: normal !important; '
            'letter-spacing: 0.04em !important; text-transform: none !important; }',
        ],
    },
    'caption-family': {
        'sans': [
            '.ed-caption, .ed-caption strong, .caption, .caption-pill, .caption-text, '
            '.img-caption, .photo-caption, .intermission .caption-pill '
            '{ font-family: var(--sans) !important; font-style: normal !important; }',
        ],
    },
    'caption-em-style': {
        'weight-shift': [
            '.ed-caption em, .caption em, .caption-text em, .img-caption em, '
            '.photo-caption em, .intermission .caption-pill em '
            '{ font-style: normal !important; font-weight: 500 !important; }',
        ],
    },
    'dropcap': {
        'ink': [
            '.ed-body.dropcap::first-letter { color: var(--ink) !important; }',
        ],
        'ink-bold-sans': [
            '.ed-body.dropcap::first-letter { '
            'font-family: var(--sans) !important; font-style: normal !important; '
            'font-weight: 700 !important; color: var(--ink) !important; }',
        ],
        'hidden': [
            '.ed-body.dropcap::first-letter { '
            'font-family: var(--sans) !important; font-style: normal !important; '
            'font-size: inherit !important; font-weight: 400 !important; '
            'line-height: inherit !important; color: inherit !important; '
            'float: none !important; padding: 0 !important; }',
        ],
    },
    'hairline-rules': {
        'eyebrow-only': [
            '.opener .ed-rule, .opener-text .ed-rule, .opener-body .ed-rule, '
            '.feature-page .ed-rule, .gallery-head .ed-rule, .siteplan-page .ed-rule, '
            '.chapter-head .ed-rule { display: none !important; }',
        ],
        'hidden': [
            '.ed-rule, .toc-rule { display: none !important; }',
        ],
    },
    'body-em-style': {
        'weight-shift': [
            '.ed-body em, .opener-body em, .opener-text em, .gallery-cell em, '
            '.siteplan-panel em, .spec-list li em, .mi-row em, .floorplan-lead em, '
            '.floorplan-sub em, .epc-caption em '
            '{ font-style: normal !important; font-weight: 500 !important; }',
        ],
    },
    'folio-em-style': {
        'weight-shift': [
            '.folio em, .folio .folio-mid em '
            '{ font-style: normal !important; font-weight: 500 !important; '
            'font-family: var(--sans) !important; }',
        ],
    },
    'backpage-em-style': {
        'weight-shift': [
            '.back-headline em, .back-tagline em, .back-foot em, .back-contact-grid em, '
            '.back-rule em { font-style: normal !important; font-weight: 500 !important; }',
        ],
    },
}


def emit_overlay(role_values: dict[str, str]) -> str:
    """Compose the <style id='brand-type-roles'> block from a {role: value} dict.

    role_values is parsed from the DESIGN.md `## Type roles` block — either a
    role/value table or a flat `role: value` line list. Missing roles inherit
    the template default (no CSS emitted). Unknown roles raise ValueError so
    they surface during the build, not silently in the rendered brochure.

    DARK_SURFACE_PROTECTION and STRUCTURAL_CLAMP_LOOSEN are emitted FIRST and
    UNCONDITIONALLY whenever any role values are declared. They are the
    foundation of any branded re-skin; see "Always-emit recipes" above.
    """
    if not role_values:
        return ""  # No role block declared — emit nothing.
    lines = ["<style id=\"brand-type-roles\">"]
    lines.append("  /* === DARK SURFACE PROTECTION === */")
    lines.extend(DARK_SURFACE_PROTECTION)
    lines.append("  /* === STRUCTURAL CLAMP LOOSEN === */")
    lines.extend(STRUCTURAL_CLAMP_LOOSEN)
    for role, value in role_values.items():
        if role not in ROLE_RECIPES:
            raise ValueError(f"Unknown type role: {role}")
        if value not in ROLE_RECIPES[role]:
            raise ValueError(f"Unknown value '{value}' for role '{role}'")
        lines.append(f"  /* === ROLE: {role}: {value} === */")
        lines.extend(ROLE_RECIPES[role][value])
    lines.append("</style>")
    return "\n".join(lines)
```


### Always-emit recipes — dark-surface protection, light-surface data protection, structural clamps

Three sections of the overlay are emitted **whenever the brand pack's `## Type roles` block is present**, regardless of which roles the operator declared. They are the foundation of any branded re-skin — without them the role API's targeted overrides can blow up adjacent template assumptions.

#### Dark-surface protection (fixed exclusion list)

The role API's color rules deliberately stop short of selectors that sit on dark surfaces — the toc-image caption pill, intermission caption pills, the spread-6 chapter-head overlay on a dark photo, the back-page masthead and tagline, and the back-page contact-grid name lines. The brand-token swap will have remapped `--gold-700` (the template's italic-emphasis color) to the brand's accent — typically navy or a deep brand colour. On a light surface, that's correct. On a dark surface, navy on dark is unreadable.

The recipe is a **fixed exclusion list**, not a heuristic. Ship it as part of the overlay:

```python
DARK_SURFACE_PROTECTION = [
    # Body text on dark surfaces — keep paper-light regardless of brand-token swap.
    '.toc-image .caption, .toc-image .caption *, '
    '.intermission .caption-pill, .intermission .caption-pill *, '
    '.spread-6 .top-image .overlay, .spread-6 .top-image .overlay *, '
    '.back-headline, .back-tagline, .back-foot, .back-contact-grid, .back-rule '
    '{ color: var(--paper-25) !important; }',
    # Emphasis on dark surfaces — use the brand accent (--gold-300) which the
    # brand-tokens block typically maps to the brand's bright/highlight colour.
    '.toc-image .caption em, '
    '.intermission .caption-pill em, '
    '.spread-6 .top-image .overlay em, '
    '.spread-6 .top-image .overlay .ed-headline em, '
    '.back-headline em, '
    '.back-tagline em, '
    '.back-contact-grid .cell .name em, '
    '.back-foot em '
    '{ color: var(--gold-300) !important; }',
]
```

The selector list is closed. Two agents producing brochures for the same brand pack emit byte-identical dark-surface blocks. If the template adds a new dark-surface region in a future revision, this list (in `build.md`) is the authoritative update site.

#### Light-surface data protection (fixed selector list)

The template uses `var(--gold-700)` both for genuine accents (em emphasis, eyebrow rules, particulars numbering) **and** for data-value treatment in the Material Information rows, opener meta row, EPC chart rows, floorplan caption, and the spec list. Premium binds `--gold-700` to a muted bronze (`#7A6235`) so the data cells read as subtle accents on cream. Branded binds `--gold-700` to whatever the brand's primary accent is — for some brands a saturated green or red — and every data cell turns into a hyperlink-looking blob.

The light-surface twin of dark-surface protection forces data-cell text back to `var(--ink)` (and the folio strip to `var(--slate-600)`), regardless of which brand token swap happens.

```python
LIGHT_SURFACE_DATA_PROTECTION = [
    # Material info, opener meta, EPC data values, floorplan caption, spec list —
    # always read as ink, never brand-accent.
    '.mi-row .v, .mi-row .v em, '
    '.opener-meta-row .v, .opener-meta-row .v em, '
    '.epc-row .v, .epc-row .v em, '
    '.spec-list li, .spec-list li em, .spec-list li strong, '
    '.floorplan-sub, .floorplan-sub em '
    '{ color: var(--ink) !important; }',
    # Folio strip — muted slate, never full brand colour.
    '.folio, .folio em, .folio-mid, .folio-mid em '
    '{ color: var(--slate-600) !important; }',
    # Particulars numbering keeps the brand accent intentionally; the existing
    # `.spec-list li .num` rule already scopes that to the number span only —
    # no override needed here.
]
```

Same closed-list contract as the dark-surface block: two agents emit byte-identical output for the same brand. If a future template revision adds a new data-cell region, the entry is added here. A cleaner long-term fix is to split `--gold-700` into `--accent-strong` (em, eyebrow rules, `.num`) and `--data-text` (the selectors above, defaulting to `--ink`) so Branded only swaps `--accent-strong`; until that token split lands, this override block is structurally mandatory in Branded runs, not optional polish.

#### Structural clamp loosen

The template's chapter-heading grid rows are sized for the Cormorant Garamond display setup (italic, weight 400, letter-spacing tuned for didone). When the role overlay swaps display family, weight, or letter-spacing, headings can grow a few millimetres beyond what the fixed-height rows accommodate — and the template's `overflow: hidden` on those rows clips the descenders. The proposal author's term for this is "structural-element clamps that need to be loosened together with the type-role overlay."

The recipe is bounded: only the known fixed-height heading rows in spreads 2 and 3 are loosened, and only to `minmax(<original>, max-content)`. A heading that already fits the original budget renders identically; a heading that needs a few extra mm no longer clips.

```python
STRUCTURAL_CLAMP_LOOSEN = [
    # spread-3: chapter heading row fixed at 60mm in the template.
    # Loosen to "60mm but grow if needed" — image cells in the second row
    # absorb the shrinkage when the heading takes extra height.
    '.spread-3 { grid-template-rows: minmax(60mm, max-content) 1fr !important; }',
    '.spread-3 .head { overflow: visible !important; }',
    # spread-2: bottom strip fixed at 56mm. Same treatment.
    '.spread-2 { grid-template-rows: 1fr minmax(56mm, max-content) !important; }',
    # Other spreads use auto 1fr or auto auto already — no loosening needed.
]
```

If a future template revision adds another fixed-height heading row, the entry is added here. The substitute step's `emit_overlay()` always emits these three sections first, then the per-role recipes.


### Where the overlay lands

The template's main `<style>` ends with the sentinel line:

```html
/* BRAND_TYPE_ROLES_OVERRIDE_SLOT — branded register may inject role overrides here */
</style>
```

The build replaces that sentinel comment with the emitted `<style id="brand-type-roles">…</style>` block (or with nothing if no role values are declared). The original `<style>` continues to its closing `</style>` immediately after. Both `brochure.html` and the landing page (`page.html` / `index.html`) carry the sentinel and receive the same overlay — the role API is global to the property's output.

### Verification

After the overlay is emitted, run a quick check:

```bash
grep -c 'id="brand-type-roles"' output/brochure.html output/web/page.html
# both must return 1 if the brand pack declared roles, 0 otherwise.
```

A branded run whose DESIGN.md declares `## Type roles` but whose rendered HTML lacks `id="brand-type-roles"` is **shipped broken** — the overlay step was skipped or failed silently.

