# Deploy

> Convert per-page styled HTML prototypes into Edge Delivery Services blocks and content pages, then deploy via DA.

- Skill: `adobe/deploy` (Agent Skill, multi-file: 11 files)
- Install (CLI): `npx skillmds@latest add adobe/deploy`
- Raw SKILL.md: https://api.skillmd.com/api/skills/adobe/deploy/raw
- Safety review: CAUTION (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend, Design & Media, DevOps & Infra, Deployment & Release, Frontend Frameworks
- Tags: Aem, Aem Boilerplate, Authorkit, Block Conversion, Da Deployment, Edge Delivery Services, Eds, Html Prototypes
- License: Apache-2.0
- Author: Adobe (https://skillmd.com/u/adobe), verified publisher
- Updated: 2026-07-06
- Page: https://skillmd.com/skills/adobe/deploy

---


# stardust:deploy — prototypes → EDS/AEM

## When to use

The user has:
1. **Per-page styled HTML prototypes** — one file per page, each carrying its own CSS. Accept any of these shapes:
   - **Single-file with inline `<style>`** and `:root` tokens + semantic `<section class="…">` (e.g. stardust output, or claude-design "Stardust"/Mobirise/Relume-style pages). Easiest — convert directly.
   - **External per-page `.css`** (the `<style>` lives in a sibling stylesheet). Read the linked CSS the same way you'd read an inline `<style>`.
   - **`<x-dc>` document-content with everything inline-styled** (per-element `style="…"`). Harder — you must lift inline styles into a scoped block stylesheet.
   - **React/JSX prototypes** (an HTML shell that mounts `.jsx` components at runtime). **Pre-render to static HTML first** (run it, or screenshot + read the JSX to reconstruct the DOM); you cannot decorate a shell that has no server-rendered `<main>`.
   The prototypes typically live under `stardust/prototypes/**` or a `samples/<Name>/` folder — don't hard-code the path; discover them.
2. An EDS project at the repo root — `blocks/`, `styles/`, `scripts/`, `head.html`, plus existing blocks (`fragment`, `section-metadata`). If the project is **vanilla `aem-boilerplate`** rather than the AuthorKit runtime this skill assumes, run the **Runtime bootstrap** below first.
3. A goal to convert: prototypes → authorable EDS blocks + EDS content pages under `content/**`.

If the user has prototypes but no EDS scaffolding, stop and ask whether to bootstrap. If they have EDS but no prototypes, this skill doesn't apply.

## Runtime bootstrap (vanilla aem-boilerplate targets)

This skill's runtime is the **AuthorKit** runtime (`ak.js` page boot, `postlcp.js` static header/footer fragments, `body.session` font gating, `decorateSession()`). The conversion steps below assume those files exist. They are **NOT** in this skill folder — they live in the canonical AuthorKit repo (`github.com/aemsites/author-kit`). (The `sanitise.js` DA-write helper is bundled with this skill at `skills/deploy/scripts/sanitise.js`; it is not part of the ported runtime.)

If the target project is a **vanilla `aem-boilerplate`** (it has `scripts/aem.js` + `scripts/scripts.js` and `header`/`footer` blocks, but no `ak.js`/`postlcp.js`), port the runtime before Step 1.

**Automate it (#6):** run `node skills/deploy/scripts/bootstrap-authorkit.mjs --target . [--from-sibling <dir> | --ref <gitref>]`. The script does the entire port below — copies the PORT-IN set, removes the boilerplate set, applies AND verifies both mandatory edits (failing loud instead of the usual silent footer-error-box), patches the `body.appear` blank-render gate, and writes `.eslintignore`. Two source modes:
- **`--from-sibling <dir>` (preferred in a multi-site repo):** copy the runtime from another EDS project in the workspace that is already bootstrapped (has `scripts/ak.js` with both edits). Offline, deterministic, and parity-safe with a known-good *deployed* runtime — no re-fetch, no re-patch risk per site. This is the right default when several sites share one repo (e.g. 8 subfolder sites).
- **`--ref <gitref>`:** fetch a **pinned** author-kit ref tarball and port from it. Pin a real commit/tag (the script's `AUTHORKIT_REF` constant or `--ref`), **not** a tracking branch — author-kit's runtime has drifted (static-fragment → block-based header/footer), so an unpinned port can silently change what you get. If the fetched runtime no longer matches the static-fragment model the steps below describe (`loadStaticFragment`, `postlcp.js` injecting `fragments/{header,footer}.html` via `innerHTML`), pin an older known-good ref or use `--from-sibling`.

The manual manifest (what the script does, for reference / hand-porting). Fetch the author-kit tarball and copy:

**Port in (from author-kit):**
```
scripts/ak.js scripts/scripts.js scripts/postlcp.js scripts/lazy.js scripts/utils/*
tools/**                      # da/da.js (+ sidekick, quick-edit, scheduler — keep so lazy.js/scripts.js imports resolve). sanitise.js is bundled with this skill, not ported.
deps/**                       # rum.js + lit (head.html loads deps/rum.js)
head.html                     # AuthorKit head: loads ak.js + scripts.js + styles.css + deps/rum.js
blocks/fragment blocks/section-metadata
.hlxignore
```

**Remove (boilerplate the AuthorKit runtime replaces):**
```
scripts/aem.js scripts/delayed.js          # replaced by ak.js + lazy.js
blocks/header blocks/footer                 # replaced by static fragments/{header,footer}.html (Step 6)
blocks/cards blocks/columns blocks/widget   # unused demo blocks (delete to avoid stale aem.js imports)
styles/fonts.css styles/lazy-styles.css     # @font-face goes in styles.css; AuthorKit head loads only styles.css
```

**After porting, two runtime edits are mandatory (do BOTH — they're halves of one change):**
1. **`scripts/lazy.js` (#4):** the stock AuthorKit `lazy.js` lazy-loads `utils/footer.js`, which does `loadBlock(footer)` and collides with the static footer fragment (renders a visible "Error" box, since there is no `blocks/footer`). **Delete the `import('./utils/footer.js')…` line.**
2. **`scripts/postlcp.js` (#21):** deleting `utils/footer.js` also removed the only code that set the `<footer>`'s class. Without it, the fragment's own root selector (`footer.footer { background: … }`) never matches and any styling on the fragment ROOT (background/padding/color) silently no-ops. In `loadStaticFragment`, set the class before injecting:
   ```js
   const html = await resp.text();
   el.className = name;          // so header.header / footer.footer match
   el.innerHTML = html;
   ```
   This bug is invisible when the footer happens to match the body background; it bites the moment a fragment has its own background (e.g. a yellow footer).

When starting a NEW conversion in a repo where a sibling site is already bootstrapped, port from that sibling (`bootstrap-authorkit.mjs --from-sibling <dir>`) — it already carries both edits and matches a known-good deployed runtime. Otherwise port from a **pinned** author-kit ref, not a tracking branch (the runtime drifts).

**Lint mismatch (#6).** The AuthorKit runtime is authored for `@adobe/eslint-config-helix`; a boilerplate project lints with `airbnb-base`, so `npm run lint` will throw thousands of errors on the vendored runtime + minified `deps/`. Treat the runtime as vendored — add to `.eslintignore`:
```
deps/
scripts/ak.js
scripts/lazy.js
scripts/postlcp.js
scripts/scripts.js
scripts/utils/
tools/
blocks/fragment/
samples            # reference prototypes, not project code
```
Your generated blocks + `styles/styles.css` still lint clean under airbnb (expand any single-line multi-declaration CSS rules the prototype used). Alternatively, adopt the author-kit `eslint.config.js` (helix) wholesale.

## Playwright re-probe (run before anything that renders)

`--no-save` playwright installs from earlier phases are pruned by any later
real `npm i` — including this skill's own bootstrap adding a devDependency
(extract SKILL.md § Setup → `--no-save` installs are ephemeral). Before the
Local-QA harness, the computed-layout gate, or any probe below, verify
`node -e "import('playwright').then(()=>process.exit(0))"` from the project
root and re-install (`npm i -D playwright --no-save --legacy-peer-deps`) on
failure.

## Runtime-detection probe (run before Step 1 — write `stardust/runtime-contract.json`)

Two EDS runtimes look identical from the content side but need different generated code, and a wrong assumption here is **silent and sitewide**. Before converting anything, inspect the repo — `scripts/ak.js` vs `scripts/aem.js`, how `loadBlock` wraps blocks, what the button decorator emits — and record the answers:

```json
{
  "runtime": "authorkit | vanilla-eds",
  "blockWrapperClass": "none | block",
  "buttonClasses": ".btn / .btn-primary / .btn-group | .button / .button-container",
  "fragmentScriptPolicy": "inert-innerHTML | executed",
  "emptySectionCollapse": true
}
```

Block CSS/JS generation and the Local-QA harness read this contract instead of assuming a runtime. The two costliest wrong guesses:
- **`blockWrapperClass`** — AuthorKit's `loadBlock` (`ak.js`) only sets `data-block-name`; it **never adds a `.block` class** (blocks sit inside `.block-content` as `<div class="<name> <variant>">`). CSS must scope `.<name> …`, **never `.<name>.block`** — that selector silently never matches, grids fall back to `display: block`, and every grid section stacks single-column ("mobile layout on desktop") while typography still looks fine. Confirm by asserting a grid container computes `display: grid` in a headless render.
- **`buttonClasses`** — the AuthorKit decorator emits `a.btn` / `.btn-primary` / `.btn-secondary` inside `p.btn-group`, NOT the stock EDS `.button` / `.button-container`. Style the wrong family and every CTA ships as a bare unstyled link.

When `emptySectionCollapse` is true (the page-metadata block leaves an empty padded section after its content is consumed into `<head>`), add `main .section:empty { display: none }` to the foundation — or an empty ~88px band sits between the header and the first real section.

## Deploy (DA Source API, from a local agent)

**Steps 1–9 are the conversion methodology**; deploy is the one transport-specific step. From a local agent (Claude Code / CLI), each converted page deploys headlessly:

| Stage | How |
|---|---|
| Code | `git push` the branch → AEM Code Sync builds it |
| Sanitise | `skills/deploy/scripts/sanitise.js` — run it before the write (DA corrupts raw UTF-8) |
| Content write | DA Source API: `PUT admin.da.live/source/<org>/<repo>/<path>.html` (multipart, field name **`data`**, `type=text/html`) |
| Make live | `POST admin.hlx.page/preview/<org>/<repo>/<branch>/<path>` (then optionally `/live/...`) |
| Auth | IMS token (`DA_TOKEN`) — see the `da-content` / `da-auth` skills |

The content payload is a **body fragment** (see Step 9). The deploy needs the **code branch pushed to GitHub** so the branch preview (`<branch>--<repo>--<org>.aem.page`) renders with your blocks. See `da-deploy-protocol.md` for the full curl contract.

**For more than a few pages, use the bundled driver instead of a hand-rolled loop (#4).** `node skills/deploy/scripts/deploy-batch.mjs --org <org> --repo <repo> --branch <branch> --content content [--concurrency 4] [--no-publish]` runs `PUT → preview → live` across a content tree with bounded concurrency, a **persistent ledger** (`content/.deploy-ledger.json`) so a re-run **skips pages already live** and only re-drives FAILs, capped-backoff retries on `000/429/5xx`, an **append-only** log (survives a restart), and a delivered-`.plain.html` check before flipping a page to `live` (admin 200 ≠ delivered). It's idempotent — safe to Ctrl-C and re-run, which is the documented recovery for a transient-blip half-deploy. A serial hand-rolled bash loop that truncates its own log on restart is the anti-pattern this replaces.

**Per-page atomic delivery contract.** A page is `deployed` only when the full chain passes, in order: sanitise-wrapped file (`scripts/sanitise.js`) → `PUT` (multipart field `data`, `type=text/html`) → `POST /preview/` → `POST /live/` → **GET the rendered `.plain.html` and assert**: HTTP 200, the `<body>` wrapper intact, exactly one `<h1>`, zero `about:error`, no `/img/` srcs — plus, when key facts are declared for the site (#86 — `DESIGN.json.extensions.metadata.keyFacts[]`, written by `direct`; skip the gate and note the skip when the field is absent), grep the RAW full-page HTML (not the rendered DOM) for each fact string on the pages that carry them. Only then flip the page's ledger entry to `deployed` — never on the POST codes (admin 200 ≠ delivered).

**A `.plain.html` pass is NOT a layout pass — add one computed-style assertion (#the silent-failure guard).** The text-level asserts above are all satisfied while the page renders as a single stacked column, because the AuthorKit `.<name>.block` scoping bug (see `blockWrapperClass` in the runtime contract) makes every grid fall back to `display: block` *with the typography still correct*. This shipped green on a real e2e site. So the contract's final gate is a **headless computed-style check on the delivered live URL** (not `.plain.html`): load the page in a headless browser and assert, for the first page of each template, that every block whose CSS declares a grid/flex layout **computes `display: grid`/`flex` (not `block`)**, `main .section` count > 0, blocks are decorated (`data-block-name` present), zero `pageerror`, zero broken images. A block that should grid but computes `block` fails the page — do not flip it to `deployed`. This is the assertion `blockWrapperClass` in the runtime contract calls for; the atomic contract is where it must actually run, once per template. Two field-decodes worth pinning: a **burst of `PUT` 400s is a malformed path, not rate limiting** — lowercase every segment, never a double slash (`content//…` 400s the PUT while preview/live still 200), no trailing `-`/`_` on a segment; and **write long loops to a bash script file with absolute binary paths** (`/usr/bin/curl`, the full `node` path) — zsh drops PATH inside `while`/`for` in some contexts, and the resulting `command not found` burst mimics a transport failure.

**Token hygiene (#16).** The IMS token typically lives in repo `.env` as `DA_TOKEN`. Before the first commit, make sure `.gitignore` excludes `.env`, `.env.*`, and `qa/` (the local QA harness) **on the branch you'll branch tests from** — otherwise every test subbranch re-exposes the token. Keep `samples/` out of commits too. Dev tokens last ~24h; a `401` with an empty body means expired → refresh and retry (the write is idempotent).

**DA_TOKEN lifecycle — preflight and re-check, never fail pages on it.** At setup, preflight the token: decode the JWT `exp` claim when present (base64-decode the middle segment) and smoke-test ONE authenticated DA call before any batch. **Re-check before each long batch** — a token fresh at setup can expire mid-run. On a `401` mid-batch: checkpoint the ledger (the batch driver's persistent ledger already records per-page state), stop the batch, and halt with a single actionable instruction — "DA_TOKEN expired; refresh it in `.env` and re-run the same command (the ledger skips delivered pages)" — instead of letting every remaining page fail red. Token expiry is the one credential failure the agent cannot self-recover; it is a legitimate hard stop even in a hands-off run.

## The one rule that drives everything else

**One prototype `<section>` = one EDS block — as the SAFE DEFAULT.** Don't abstract speculatively, and don't extract "patterns" across prototypes unless sections are genuinely the same pattern. This default exists because each section's bespoke CSS can't be wrongly shared; violating it casually cost full resets (see ANTI-PATTERNS).

**The one deliberate exception — collapse SAME-PATTERN sections into one block + VARIANT classes.** When two or more sections are the same content pattern (card grids, prose/CTA bands, quotes, accordions) differing only in skin, emit ONE canonical block (`cards`, `text`, `quote`, `accordion`) and put each section's look behind a variant class (`class="cards brands"`), brand styling in the variant CSS. The block JS stays generic (classify cells by content); only the CSS differs per variant. This is the David's-Model library win (small, reusable, variant-driven — not 20 bespoke names) and is proven to preserve fidelity. Keep genuinely-unique sections (a hero, a countdown widget) bespoke. Budget for it: variant CSS is careful work and some grids are count-specific.

The prototype is the visual spec. The block exists to AUTHOR its content — see **The ENCODE contract** below for what well-authored content looks like, and ANTI-PATTERNS for how a block must defensively PARSE it.

## Output you will produce

For a typical 5–10 page site:

- **One block per distinct prototype section.** A 5-page site with 6 sections each → ~12–18 blocks (some are reused across pages, e.g. `closing`).
- **One EDS content page per prototype page.** Same number of pages.
- **Nav + footer fragments** at `content/fragments/{nav,footer}.html` (the navigation lives in `nav.html`, not `header.html`).
- **Updated `styles/styles.css`** with brand tokens lifted from the prototype's `:root`, a reset, the EDS section scaffold, and a global button system (see "Lean on EDS button conventions" below). Nothing more.
- **No shared utility modules.** No wave systems. No section-metadata style classes. No motion library. The prototype already encodes these per-section; keep them inside the owning block.

## The ENCODE contract — what well-authored content looks like

The ANTI-PATTERNS below are the **decode** side: a block must parse robustly whatever DA hands it. This is the **encode** side: what the content page should EMIT in the first place. One principle drives all of it:

> **Decoration that must survive DA rides a semantic inline tag — never a class, never an invented delimiter.** DA strips `<span>` and author classes from block cells, but PRESERVES `<strong>`, `<em>`, `<code>`, `<a>`, `<picture>`/`<img>`.

- **Accent / emphasis → `<em>`, never `<span class="em">`** — DA strips the span and the accent is silently lost. Ensure the block CSS targets BOTH `em` and `.em`.
- **Key facts must live in SERVER-RENDERED content, never solely in chrome fragments (#86).** The static header/footer fragments are client-injected (`postlcp.js` `innerHTML` after first paint), so anything that exists only there — a trust fact line ("Open source · Apache 2.0 · built by X"), pricing, contact facts — is INVISIBLE to non-rendering crawlers and AI bots on every page. If a fact matters for SEO/LLM answerability, author it in page content (a fact panel, an install-section sentence, the metadata description); the fragment copy is presentation, not the crawlable source of truth. Verify by grepping the RAW served HTML (no JS) for the key-facts list — the rendered DOM check passes either way and hides the failure.
- **Sub-fields → a leading preserved tag, never an in-band delimiter.** Don't invent `Step|Title`, `flag :: desc`, `name|tag` micro-syntax (authors must learn it). Lead the cell with the field's tag — kicker → `<strong>`, code/flag/path → `<code>` — and the block reads the leading tag as the term, the rest as the value. (A block MAY still parse a delimiter as a back-compat fallback — that's decode, not what you emit.)
- **No raw presentational HTML in content.** No `<sup>` (move unit superscript into the block — split `185+` into number + generated `<sup>`); don't use `<br>` for layout (a deliberate editorial line break via Shift+Enter is fine — it's not "exposed code").
- **Grouped item sets → one row per item.** A band of N similar units (metrics, stats, feature cards) is ONE row per unit, its parts as flat siblings in that cell — not one cell per atom (loses which label pairs with which number) and not all-in-one-cell. The block segments per row.
- **Lists / FAQ → rows, not nested lists or one blob.** An accordion/FAQ is a head cell then one row per Q/A (question cell + answer cell). David's-Model #5.
- **Section head → DEFAULT CONTENT, not a block row.** The eyebrow/heading/lede that sits *above* a repeating block (cards, metrics, FAQ, insights) is prose, not part of the block's structure — author it as **default content** in the section, before the block, so DA and `.plain.html` keep it out of the block table (David's #1). The block **reabsorbs** it at decorate time (see "Section heads" below), so this is a pure markup/authoring win with **zero pixel change**. (NOT for a genuine widget whose rows ARE its structure, e.g. countdown.)
- **Buttons → one emphasis axis:** primary `<strong><a>`, secondary `<em><a>`; never `<strong><em><a>`. (See "Lean on EDS button conventions".)
- **Headings → real outline, no level jumps:** one `<h1>`; section titles `<h2>`; card/sub titles `<h3>` (never skip to `<h4>`). Canonicalising a prototype's card `<h4>` to `<h3>` is correct.
- **Metadata stays name/value** (config only — David's #14); never name/value for semantic content.

### Section heads: default content the block reabsorbs (zero pixel change)

A head-bearing block (one with a section eyebrow/heading/lede above repeating units) should NOT carry that head as block rows. Author the head as **default content** in the section, before the block; the block table holds only the repeating units. Then the block **reabsorbs** the head so the decorated DOM — and every pixel — is identical to the old in-table form:

- On `decorate(block)`, read the section's leading default-content wrapper, build the SAME `.section-head` the block used to build from its first rows, and **remove the wrapper**. Result: identical decorated DOM; CSS untouched.
- **The head wrapper is a sibling of the block's SECTION-LEVEL wrapper, not of the block, and its class varies by runtime.** Use `block.closest('.block-content')?.previousElementSibling` and match BOTH `.default-content` (AuthorKit runtime) **and** `.default-content-wrapper` (vanilla EDS). `block.previousElementSibling` alone is `null` (the block is nested in `.block-content`).
- Keep the old in-table head (leading non-unit rows) as a **back-compat fallback**.
- **Verify zero change:** diff the *decorated* block `outerHTML` (ids/`media_<hash>` normalised) old-vs-new — it must be byte-identical, with 0 default-content wrappers left after decorate.

A FOUC is possible (head paints as default content, then reabsorbs); the final layout is identical — watch CLS only if default-content margins differ markedly from the head's.

### Images: editorial → authored content, decorative → CSS only

**Any raster/brand image that carries meaning is EDITORIAL and must be authorable content** — including hero / feature / CTA-band backgrounds that sit *behind* text and a scrim (the author swaps them per campaign; they're the page's most important visual). For editorial images:

1. Upload the binary to DA: `PUT https://admin.da.live/source/{org}/{repo}/media/<scope>/<file>` (multipart, field name **`data`**, correct `Content-Type`; upload the JPG/PNG/webp source only — the pipeline generates responsive variants).
2. Author a single `<img src="https://content.da.live/{org}/{repo}/media/<scope>/<file>" alt="…">` in the cell (branch-independent; the pipeline emits the responsive `<picture>`). **Never** a repo-relative `/img/…` in content (→ `<img src="about:error">`). **Never** bake imagery into block JS by index (`CARD_IMAGES`/`LOGOS`) — that isn't authorable.
3. The block renders the authored `<img>` into a background LAYER (`.hero-bg` / `.card-media` / `.text-media`); the scrim/gradient is a CSS `::before` OVER it. Keep a fixed CSS asset as the no-image fallback.

**Decorative = CSS only** applies to image-LESS treatments (gradients, scrims, textures, solid washes) and to genuinely fixed brand assets referenced as **CSS backgrounds** root-relative (`/img/<brand>/…` — browser-fetched, never ingested, so no `about:error` and no upload).

**The check that catches the #1 mistake:** after preview, grep the delivered `.plain.html` for the expected `<img>`+alt count. "It renders" hides CSS-background images — they're absent from `.plain.html`, carry no alt, and are neither authorable nor AI/SEO-visible.

**When the image src is a SOURCE/external URL (migration), verify it resolves BEFORE you author it.** Re-using an image straight off the source page is the most common way to ship `<img src="about:error">`: the preview ingester fetches the authored URL, and if that fetch fails the delivered image is `about:error` — silent, since the page still renders. So verify every authored `<img>` whose src points at the source/CDN — **but the verification fetch must match how extract reached the origin, and a failure is not automatically "omit".**

**Verify with the recorded fetch technique, not a bare curl (#2 — bot-managed origins).** A large fraction of real migration targets sit behind Akamai / Cloudflare / Imperva / F5, which 403 a plain `curl` (and headless requests) while serving the same asset fine to a real browser. A blanket "curl it; if not 200, omit" therefore **strips every real brand image** off an entire bot-walled site — the exact failure the quality bar forbids. Instead: read `_crawl-log.json#discovery.fetchTechnique`. When it is `headed-chrome`, verify image URLs with an **in-page fetch from the open browser context** (`page.evaluate(async u => (await fetch(u)).status, url)`) — that inherits the JA3/H2 fingerprint and cookies (per `extract/reference/playwright-recipe.md` § Bot-management → Sub-resource fetches); a bare `curl`/`request` will falsely report the asset broken. Only when `fetchTechnique` is plain may a bare `curl -s -o /dev/null -w '%{http_code}' <url>` stand in.

**Distinguish 403-bot-wall from 404-missing, and prefer rehost over omit.** A `403`/`401` from a CDN means *blocked-when-hotlinked*, NOT *missing* — and even if the URL would 200 to the ingester now, hotlinking the source CDN from the delivery host is fragile (it can 403 cross-origin at preview time, yielding `about:error`). So the default remediation for a `403`/blocked captured image is **download-and-rehost**, not omit: extract already saved a local copy under `stardust/current/assets/media/` via the in-page fetch — upload it to DA (`PUT …/source/{org}/{repo}/media/<scope>/<file>`) and author the `content.da.live` URL (see Images, above). **Omit only on a true `404` (asset genuinely gone) after attempting the rendition/delimiter repairs below** — and never substitute a generic logo/placeholder (`…-logo…`) as if it were editorial. Two real failure signatures where the asset exists — fix the URL, don't drop it: (1) **wrong rendition variant** — the page exposes only a derivative that 404s while a sibling resolves (e.g. a portrait's `…/4x3/768/…` 404 vs `…/original/768/…` 200) → rewrite to the resolver; (2) **missing query delimiter** — `…/<id>&wid=600&hei=…` with no `?` makes `<id>&wid=…` a bogus id → 403 → repair the first `&` after the id to `?`.

**`content.da.live` media URLs are auth-gated — don't anon-curl them.** Once you've rehosted to DA, the recommended `https://content.da.live/{org}/{repo}/media/…` src returns **`401` to an anonymous `curl`** even though it is correct and the preview pipeline ingests it fine. Do not treat that `401` as "broken" and omit. The correct verification for an already-rehosted/DA-hosted image is **post-preview**: grep the delivered `.plain.html` for `about:error` (must be 0) and assert the expected `<img>`/alt count — see `da-deploy-protocol.md` step 3b. Exempt `content.da.live` (and `admin.da.live`) URLs from the pre-author 200-check entirely.

## Steps

### 1. Audit (light)

**First, normalize the input to static HTML.** If a prototype is React/JSX (an HTML shell that mounts `.jsx` into `#root`), **pre-render it to static HTML** before auditing (#24). The reliable recipe:
```bash
# 1. serve the prototype's OWN folder with a plain static server (NOT file:// —
#    babel-standalone XHRs the .jsx and file:// CORS-blocks it; the aem dev
#    server CSP-blocks the inline scripts too).
( cd samples/<proto> && python3 -m http.server 8765 & )
# 2. load in Playwright (React/babel load from unpkg — needs internet), wait for
#    mount, capture #root's innerHTML, save it for the block agents to read:
#    page.goto('http://localhost:8765/<file>.html'); waitForTimeout(4000);
#    fs.writeFileSync('samples/<proto>/_rendered.html', root.innerHTML)
```
From there it converts like an external-CSS prototype (semantic classes + the prototype's `.css`). If it's `<x-dc>` document-content, the sections are still `<section>`/`<div>` elements; just expect inline `style="…"` instead of a `<style>` block. The rest of this skill assumes a static `<main>` exists.

**View behind routing / sign-in (#27).** If the view you want is not the default render (e.g. a signed-in dashboard behind a sign-on flow), **seed the app's persisted state before it boots** rather than capturing the landing page. Many prototypes persist their route to `localStorage`: `page.addInitScript(() => localStorage.setItem('<key>', JSON.stringify({page:'dashboard', user:'Alex'})))` then navigate. Generic alternatives: drive the UI to the view (fill + submit the sign-on form, then capture) or set the router hash/URL. Capture `#root` for the view you actually intend to convert.

Read every prototype's `<main>` markup (skip the `<style>` for now) and produce a per-page section list:

```
home: hero, work, approach, team, clients, closing
approach: approach-hero, manifesto, tenets-detailed, cadence, closing
team: team-hero, team-roster, work-style, recent, careers, closing
…
```

A useful pattern: dispatch the `Explore` subagent at thoroughness=quick with this exact ask. You don't need a 22-pattern punch list — you need filenames + section names. **Resist the urge to "find shared patterns."** Pattern reuse will emerge organically when two sections turn out to be byte-identical.

**Fingerprint per-instance variation BEFORE writing block code (#90).** A section-name list is
copy-level; it does NOT reveal that instances *inside* a repeated group look different — an active
filter chip vs its outline siblings, a filled accent CTA among outline CTAs, image cards vs
image-less title-cards. Those are the details a copy-driven conversion silently flattens (a whole
grid of identical cards, one CTA styled like the rest), and the mandatory gates (one `<h1>`, grids
compute `grid`) still pass. So run the proactive probe up front:
`node skills/deploy/scripts/style-fingerprint.mjs "file://<abs>/<proto>.html"`. For every group of
sibling instances it clusters each instance by a COMBINED signature — computed **style-delta**
(`background/border/color/background-image/weight/align`) AND **structural** (`hasImg`, `hasSvg`,
child count) — and reports any group with >1 cluster as a **candidate** per-instance variation for
the owning block to reproduce. It is advisory: it will also flag legitimate variation (a footer with
one bold link among plain ones), so filter false positives with judgment — but never flatten a real
variant (an active chip, an accent CTA, an image-less card) just because the block treats siblings uniformly.
The structural half is load-bearing: image-vs-image-less cards (and any `:has()`/`:not()`-driven
variant) share the same top-level computed style, so a style-only probe misses them — include the
structural signals. The manifest becomes the block author's checklist; this is the pre-block
complement to Step 10's post-deploy `content-diff` (which catches the same class of miss too late).

### 2. Decide names + reuse — LOCK BEFORE WRITING ANY CODE

Naming rules:
- Block name = the prototype's `<section class="X">` value, kebab-cased (`hero`, `work`, `closing`, `approach`).
- **Never name a block after a reserved EDS class** (#15). `section`, `default-content`, `block-content`, `wrap`, and `button` are used by the runtime's section/decoration DOM — a block named `section` collides with `<div class="section">` and breaks decoration. When the prototype's section class is generic/reserved (Festool uses `class="section"` twice), derive a semantic name from the section's `data-screen-label` / intent instead (`new-products`, `discover`) and carry any modifier like `tinted` as a block variant.
- When the same section appears on multiple pages with identical visual treatment, build ONE block and use it everywhere. The classic example: `closing` CTA at the end of every page.
- When a section appears on multiple pages but looks different (e.g. home `hero` vs case-study `case-hero` vs service `service-hero`), they are different blocks. Prefix with the page archetype.
- When two sections within one prototype share the same visual treatment but different copy (e.g. case-study `discovery` and `decisions` are both 2-col prose with eyebrow + headline), it is fine to merge into one block (`case-prose-2col`) with a single text variant cell ("tinted" / "default"). Use your judgment.

**Scale the naming ceremony to the number of pages.** For a **single-page** conversion where each `<section class="X">` has a self-evident, unique name (`hero`, `quick`, `used`, `stats`…), there are no cross-page reuse decisions to make — just lock `block name = section class` and proceed; don't pepper the user with questions. The questions below matter for **multi-page** sites, where the same-looking section recurs and you must decide reuse vs. archetype-prefixing.

**Surface 3–5 naming questions to the user before writing any block code (multi-page sites):**
- "What's the home hero called? `hero`?"
- "Are the closing CTAs across all pages identical? Same `closing` block?"
- "Should case-study discovery/decisions/solutions be one block or three?"
- "Is the per-service hero distinct from the home hero? Build `service-hero` separately?"

Lock the answers in writing (in `stardust/eds-conversion-log.md` or similar). This is the single highest-leverage step in the whole process.

### 2b. Section schema + decode tier — close the round-trip BEFORE writing code (#93, #95)

The dropped-CTA / role-swap / flattened-variant class has ONE root cause: the authored rows (ENCODE) and the block's `decorate()` (DECODE) are written independently and hoped to be inverses. Two moves close the loop up front; the in-loop `block-roundtrip` gate (#94, Step 8) then proves it closed.

**Emit the section schema — the shared ENCODE/DECODE contract (#93).** Once names are locked, generate the per-section contract both sides are written FROM:

```bash
node skills/deploy/scripts/section-schema.mjs "http://localhost:8791/<prototype>.html" \
  --out stardust/eds-schema/<page>.json
```

Per section it emits the ordered role-classified inventory (heading / eyebrow / cta+href / body — the SAME classifier `content-diff` and `block-roundtrip` measure with, from `skills/diff/scripts/content-inventory.mjs`) and the repeating-unit groups (count + per-unit composition: headings/ctas/imgs/textRuns, uniform or not). Use it on both sides:

- **ENCODE**: one row per repeat unit, fields in schema order; every schema item appears in the authored content. An item you deliberately drop is a decision recorded in the conversion log — never an accident.
- **DECODE**: the block's JSDoc cites its section's schema path; `decorate()` classifies exactly the roles the schema lists, and the schema's unit count is the post-decorate count assertion (#48/#52).

Cross-check `repeats[].uniform` against the #90 fingerprint: `uniform: false` means a per-instance variant (active chip, accent CTA, image-less card) the block must reproduce, not flatten.

**Pick the decode tier per section — template-slotted vs reconstructive (#95).** Reconstruction is where decode bugs live, so only reconstruct where authors need the structural freedom:

- **Template-slotted (fidelity by construction).** For fixed-composition sections whose structure never changes at authoring time (a bespoke hero, a cinematic band, a stat/countdown composition): `decorate()` holds the prototype section's inner DOM verbatim as a template literal and SLOTS the authored values into it by role — eyebrow text into the template's eyebrow node, heading into the `<h1>`, each CTA's text+href, the authored `<picture>` into the media slot. The decorated DOM ships byte-equal to the prototype, so the segmentation-bug class (#48/#52/#56/#76) cannot occur. Editors still own every line of copy — the content page is unchanged and server-rendered (this is NOT client-injected chrome; #86 doesn't bite). Structure edits need a developer: the right trade for sections whose structure nobody edits.
- **Reconstructive (authorable structure).** For repeating/data sections where authors add/remove units (cards, FAQs, listings, menus): classify + segment defensively per #48/#50/#52 — and let the schema + round-trip gate carry the burden of proof.

Record the tier per block in the conversion log. Default: template-slotted for bespoke one-offs, reconstructive for repeat groups.

### 3. Foundation

Update `styles/styles.css` to the following — and ONLY the following:

- Lift `:root` tokens verbatim from the prototype's `<style>` (colors, fonts, type scale, weights, tracking, layout, motion easing).
- Document reset (box-sizing, margin reset, scroll-behavior, body font + bg, ::selection, img defaults, button reset). **The `img` reset MUST be `img { display: block; max-width: 100%; height: auto; }` (#36).** EDS's media pipeline emits `<img>` with `width`/`height` attributes; without `height: auto` a width constraint stretches the image vertically (a landscape 1920×1258 rendered 677×1258). The bug is invisible on the prototype (raw `<img>`, no attrs) — it only appears post-pipeline.
- A minimal EDS section scaffold:
  ```css
  main .section { display: block; }
  main .section > .default-content,
  main .section > .block-content { display: block; }
  main > div, .has-template, div[data-status] { display: none; }
  ```
- A global button system (see next section). This is the one place per-block CSS does NOT own its paint — buttons are site-wide and convention-driven.
- **Reserve the static header's height — or the late fragment injection shifts the first section → CLS (#81).** `postlcp.js` injects `fragments/header.html` AFTER first paint (it's a deferred fetch). In the common layout where the header sits in flow ABOVE the first section (full-bleed hero *below* the nav), the hero therefore renders at `y=0`, then jumps DOWN by the header's height the instant the fragment lands — a large layout shift the browser attributes to the hero block (a real page measured **CLS 0.143, ~0.13 of it the hero**; metric-matched fonts do NOT fix it because the cause is the header box appearing, not a font swap). Reserve the header's rendered height on the **bare `<header>` element** in `styles/styles.css` (it applies before the fragment loads — `decorateHeader()` sets the class early, but styling the bare element covers the pre-class sliver too), with responsive `min-height` matching the fragment per breakpoint and the chrome's own `background` so any reserve-vs-actual delta is invisible:
  ```css
  header { min-height: 98px; background: var(--brand-ground); }   /* desktop nav+banner height */
  @media (width <= 767px) { header { min-height: 102px; } }
  @media (width <= 480px) { header { min-height: 120px; } }       /* banner wraps to 2 lines */
  ```
  Make the header's height **deterministic** so the reserved value actually matches: keep the reservation breakpoints in sync with the fragment's, and avoid nav-link *wrap zones* (e.g. extend the burger/hamburger breakpoint so the inline links can't wrap to a second row at awkward widths). Reserve slightly OVER the natural height (a few px) so you never under-reserve and shift — on a dark/uniform ground the small gap is invisible; a `header:empty` page (`header: off`) removes the element so the reservation is moot. **The footer needs NO reservation** — it's below the fold, so its late injection shifts nothing above it. Verify with a CLS probe (Playwright `PerformanceObserver({type:'layout-shift'})`) that **delays the woff2/fragment fetches** to reproduce the slow-network swap PSI measures — a fast localhost load hides the shift.

That's it. No section-style classes. No motion primitives. No utility classes beyond the button system.

`scripts/scripts.js` stays minimal — only the page boot. No reveal-on-scroll. No marquee init. No header scroll-state. Per-block animation is owned by per-block CSS.

**Token-completeness gate — every `var(--x)` a block references MUST be defined in `:root` (#91).**
Lifting a section's CSS into a block routinely drags in a token the block a

…(truncated)
