Skill Store 33

Deploy

Convert per-page styled HTML prototypes (stardust under stardust/prototypes/**, or claude-design…

by adobe · Apache-2.0

The skill

Convert per-page styled HTML prototypes (stardust under stardust/prototypes/**, or claude-design / Mobirise / Relume / Lovable / v0 / Figma-derived pages, or JSX prototypes pre-rendered to HTML, often under samples/) into Edge Delivery Services (EDS / AEM) blocks and content pages, then deploy via DA. Each prototype section becomes one EDS block; the prototype's per-section CSS becomes that block's CSS scoped under the block class. Use when the user wants to lift styled per-page HTML prototypes into a working EDS site under blocks/ and content/.

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 — vanilla aem-boilerplate (github.com/adobe/aem-boilerplate): scripts/aem.js + scripts/scripts.js, blocks/ with header/footer/fragment, styles/styles.css + styles/fonts.css, head.html. This is the ONLY runtime this skill targets — no runtime files are ever ported, vendored, or edited.
  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 scaffold from adobe/aem-boilerplate (use the template as-is; the conversion never modifies scripts/aem.js). If they have EDS but no prototypes, this skill doesn't apply.

Flow guard (stardust projects). When stardust/state.json exists but has no flow and the ask is a migration (a URL plus "migrate" / "to EDS" / "re-platform"), stop before Step 1: print the master skill's two-flow table (skills/stardust/SKILL.md § Two migration flows) and hand back to its routing, which stamps flow (skills/stardust/reference/state-machine.md § Flow keys). Hand-authored prototypes with no state.json are unaffected — that is this skill's standalone use.

Target runtime — vanilla aem-boilerplate (what the generated code can rely on)

The stock boilerplate provides everything the conversion needs; the runtime is never modified. The load chain (head.html → scripts.js → loadEager → loadLazy → loadDelayed) gives you:

  • Section DOM: each main > div becomes <div class="section">; runs of default content are wrapped in div.default-content-wrapper; each block table gets a div.<name>-wrapper around <div class="<name> block" data-block-name="<name>">, and the section gains .<name>-container. Sections are hidden (data-section-status + inline display:none) until loaded — undecorated-content flash is handled by the runtime, not by foundation CSS.
  • Cell normalization (wrapTextNodes, in decorateBlock — #104): any block cell whose FIRST element child is not in P/PRE/UL/OL/PICTURE/TABLE/H1–6 — or that leads with a <picture> followed by anything else — gets its ENTIRE content folded into one <p> before your decorate() runs. A media-led mixed cell (<img> + <h3> + <p>) therefore arrives as a single wrapper <p>; a collector reading cell.children sees ONE node and silently drops everything after the image. Decode with the wrapper-expanding collector (Step 8, #62/#104).
  • Body gate: styles.css ships body { display: none } + body.appear { display: block }; loadEager() adds appear after decorateMain(). This gate is CORRECT — keep it. Any off-pipeline render (harness, probes) must load the real scripts/scripts.js so the gate is satisfied; a blank render means the runtime never booted, not that the gate should be removed.
  • Buttons: decorateButtons() (in scripts.js) buttonizes ONLY author-formatted links — see Step 5 for the emitted class family.
  • Chrome: header/footer BLOCKS (loaded by loadLazy) fetch authored fragment documents — /nav and /footer by default, overridable per page via nav/footer metadata. Block JS runs, so interactive chrome (hamburger, dropdowns) is real JS. See Step 6.
  • Fonts: @font-face lives in styles/fonts.css, loaded by loadFonts() (eagerly on desktop / repeat views via a fonts-loaded session flag, always in loadLazy); styles.css carries the metric-matched fallback faces. See Step 4.
  • Auto-blocking hook: buildAutoBlocks() in scripts.js is project-owned — the home for D1 auto-blocks (video/embed URLs, fragment links).
  • Lint: generated blocks and styles lint under the project's own config; there is no vendored runtime to exempt. Do not create an .eslintignore for runtime files. Know the rules before writing: lines ≤ 100 chars (max-len was 91 of 100 hits in a recorded run), CSS custom properties kebab-case; fix a hit with a targeted edit, never by rewriting the file (six block files were rewritten for line length); never --fix a file another agent is editing. When DESIGN.json extensions.breakpoints.target is set, npm run lint includes breakpoint-lint.mjs (replica handoff contract § Boilerplate lint): every @media width and JS threshold sits on a target step.
  • Experience Workspace instrumentation (da.live canvas "quick-edit" — the inline editor your blocks must survive). The workspace stamps data-prose-index on every OUTERMOST h1–h6 / p / ol / ul / pre / blockquote of the authored document, data-image-index on every img and data-block-index on every block div, swaps that instrumented HTML into document.body, and re-runs the page's own loadPage() — so every block's decorate() runs over the instrumented markup. In the workspace every block cell contains a <p> (prose2aem copies cell innerHTML; the published pipeline unwraps single-paragraph cells to bare text and the runtime's wrapTextNodes re-wraps them, so decorate() sees a <p> in both worlds). Afterwards ONLY elements that still carry their data-prose-index become editors (querySelector('[data-prose-index="N"]').replaceWith(editor)); nothing repairs a lost index. A text your block rebuilt from textContent/innerHTML, synthesized, or retagged is silently uneditable. Contract + gate: Step 8 § Experience Workspace editability contract (EW1–EW10).

The boilerplate itself drifts (e.g. current main emits p.button-wrapper; older clones emit p.button-container and buttonize bare links). Never assume — the Runtime-detection probe below records what THIS target actually does, from its own scripts.js/aem.js.

Playwright re-probe (run before anything that renders)

--no-save playwright installs from earlier phases are pruned by any later real npm i — including any setup step 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)

Boilerplate clones drift (button classes, wrapper names, buttonization rules differ across vintages), and a wrong assumption here is silent and sitewide. Before converting anything, read the TARGET's own scripts/scripts.js + scripts/aem.js — what the button decorator emits and requires, how decorateBlock wraps blocks — and record the answers:

{
  "runtime": "vanilla-eds",
  "blockWrapperClass": "block",
  "buttonClasses": ".button / .button.primary / .button.secondary / .button.accent, in p.button-wrapper",
  "buttonization": "formatted-only | bare-links-too",
  "fragmentScriptPolicy": "inert-innerHTML",
  "emptySectionCollapse": true
}

Block CSS/JS generation and the Local-QA harness read this contract instead of assuming. The values above are current adobe/aem-boilerplate main; the two known drift axes to verify per target:

  • buttonClasses — current main emits a.button (+ .primary/.secondary/.accent) inside p.button-wrapper; older clones emit p.button-container, and some buttonize a bare <a> alone in a paragraph (buttonization: bare-links-too) while current main requires authored <strong>/<em>. Style the wrong container class and spacing/group layout silently breaks; assume the wrong buttonization rule and plain text links ship as buttons (or CTAs ship as bare links).
  • blockWrapperClass — decorateBlock adds .block + data-block-name and wraps the block in div.<name>-wrapper (section gains .<name>-container). Scope block CSS under .<name> (the class every vintage sets); confirm empirically by asserting a grid container computes display: grid in a headless render — a wrong scoping guess makes every grid fall back to display: block ("mobile layout on desktop") while typography still looks fine.

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
Localize links skills/deploy/scripts/localize-links.mjs --source-host <live-host> [--redirects stardust/redirects.tsv] — after EVERY generator, before EVERY write, over the WHOLE tree (below)
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 (and on a 401 — the DA_TOKEN lifecycle rule below: a 401 that persists halts the batch with exit 3 and the exact re-run command, never a per-page FAIL), 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: localize-links.mjs run over the content tree (then --check exit 0 — no source-host href left whose target exists locally) → davids-model-lint.mjs exit 0 (0 🔴 — the content-structure gate) → 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. → AI-readability gate (#100): node skills/deploy/scripts/ai-readability.mjs --origin <live> <paths> — the checker score (served words ÷ rendered-DOM words, landmarks ignored) with fragments credited must be ≥ 98 on the published page; record the strict score and the per-block served gap in the gate report (reference/ai-readability.md). Only then flip the page's ledger entry to deployed — never on the POST codes (admin 200 ≠ delivered).

Link localization is a pipeline stage, not an afterthought. Generators carry captured hrefs as fully-qualified source-domain URLs (the D4 "opaque tokens" rule), which sends visitors back to the live site for pages that exist on the new origin. D4 is a capture-fidelity rule for media and external targets; it is never a delivery rule for internal links. The pipeline owns a URL map (every served path in the content tree + stardust/redirects.tsv entries) and localize-links.mjs rewrites, idempotently, every source-host <a href> whose path resolves in the map to the root-relative URL EDS serves (eds-path.mjs: no .html; a folder index at /x/, a leaf at /x/y), query and fragment preserved — and normalizes root-relative internal hrefs the same way. Everything else stays absolute: genuinely external targets and not-yet-migrated pages are the honest integration boundary, and the tool lists them. Re-run it over the WHOLE tree after every wave: earlier waves' pages gain newly valid internal targets only when a later wave ships them. davids-model-lint.mjs --source-host <live-host> flags any remaining localizable link as a 🟡 LOCALIZE advisory (advisory in this release; the fix is the stage, never a hand edit).

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 a block-CSS scoping mistake (a selector keyed to a wrapper class the target runtime doesn't emit — 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 — and every visible loaded image renders non-zero: clientWidth > 0 (#122). Loaded ≠ rendered: an <img> with naturalWidth > 0 can still render 0×0 (recorded: a flex item whose width derives from the image while the image's max-width: 100% derives from the item — circular sizing collapses to zero), and both the .plain.html checks and a naturalWidth === 0 broken-image probe miss it. 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 and .env.* on the branch you'll branch tests from — otherwise every test subbranch re-exposes the token (the master skill's Setup step 6 writes the managed block; the local harness and pre-renders live under stardust/.work/, which stardust/.gitignore already excludes). 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, do not read the first one as expiry — a valid token can be answered 401 under a concurrent burst (recorded: the whole first burst of a media batch got 401 on a token with hours of validity left, and a lone sequential PUT went straight through). Both shipped drivers apply the rule themselves — da-media-upload.mjs (media) and deploy-batch.mjs (pages): a JWT past its exp exits 3 before any request; a 401 from the admin API (the media PUT; the page PUT, preview and live) retries like a 429 — the uploader's first upload additionally runs alone to prove the token; a 401 that still persists through its retries on ANY file or page halts the batch with exit 3 — ledger checkpointed, in-flight work finished, the rest not attempted, one actionable stderr line with the exact re-run command (HALT <path> 401 after <n> attempts — refresh DA_TOKEN and re-run: …; the same command resumes from the ledger). A 401 is never a per-file or per-page FAIL: an expired token once left an 894-row batch failing file by file for 5.5 hours. Token source per driver: both drivers take both flags — --token-env <NAME> (the environment variable holding the token, default DA_TOKEN) or --token-file <path> (read once at start, trimmed; never both — for a harness that hands the token over as a file rather than a variable; a recorded hands-off run had the token in a file while the uploader read DA_TOKEN only); the value is never printed, never in the re-run command and never written to a ledger or log, and every expiry or halt line names the variable or file to refresh (in file mode the re-run command carries no <var>=<fresh token> prefix). A hand-rolled loop (never for more than a few pages — above) follows the same shape: checkpoint the ledger, retry the 401 like a 429, and halt with a single actionable instruction only once a lone sequential request still gets 401. 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 distinct visual PATTERN = one EDS block — and a section with NO pattern is NOT a block at all. The content structure that lands in DA must follow David's Model (davids-model.md, bundled with this skill — the 15 rules mapped to this skill's contracts; cited as D#N throughout). Its first rule shapes everything here:

  • D1 — blocks aren't ideal for authoring. A block is a table an author must maintain. A section whose content is plain prose — heading, paragraphs, an image, CTAs, with no repeating units and no bespoke interactive structure — is authored as DEFAULT CONTENT in its own section, never wrapped in a block. Its skin rides a minimal section-metadata style value (see Step 3); the section's semantics stay native <h2>/<p>/<picture>/<a>. Never create a text/heading/image block around bare default content — that is the D1 anti-pattern verbatim.
  • Blocks are for structure default content can't express: repeating units (cards, FAQ, logos, team), genuinely bespoke compositions (a countdown, a stat band, a cinematic hero), and interactive components. For those, one distinct prototype pattern = one block. Don't abstract speculatively, and don't extract "patterns" across prototypes unless sections are genuinely the same pattern — each pattern's bespoke CSS can't be wrongly shared; violating this 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 (D9: 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 PATTERN (D1/D9). A 5-page site with 6 sections each → typically ~8–14 blocks: prose bands land as default content, same-pattern sections share one block + variants, and only genuinely bespoke sections get their own block.
  • One EDS content page per prototype page. Same number of pages.
  • Nav + footer documents at content/nav.html and content/footer.html — authored content, deployed and published like any page, fetched by the stock header/footer blocks (D12: chrome is the canonical fragment use case).
  • Per-site blocks/header + blocks/footer CSS/JS reproducing the prototype's chrome (see Step 6).
  • Updated styles/styles.css with brand tokens lifted from the prototype's :root, a reset, the EDS section scaffold, a global button system (see "Lean on EDS button conventions" below), and the styles for the few section-metadata style values default-content sections use. Nothing more.
  • No shared utility modules. No wave systems. No motion library. The prototype already encodes these per-section; keep them inside the owning block. Section-metadata style values stay a SMALL closed set (dark, tinted, …) used only by default-content sections — blocks keep painting their own sections.

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 — and it is where David's Model is enforced (davids-model.md for the full rule-by-rule mapping; node skills/deploy/scripts/davids-model-lint.mjs content/ is the mechanical gate — every page must exit 0 before any DA write). 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>.

Structural rules (the lint's 🔴 tier):

  • No nested block tables, ever (D2). A block cell never contains another block. Shared/repeated content is a fragment link or an auto-blocked URL; complex nested visuals (tabs, accordions) are modeled as sections and combined client-side.

  • No row/column spans beyond the block-name header (D3), and blocks stay ≤ 4 columns (D10) — wider means the content was fragmented in a way that breaks default-content semantics. Exception: a genuine data table.

  • Fully-qualified URLs in authored content (D4) — for MEDIA and EXTERNAL targets. Authors treat URLs as opaque tokens; code extracts pathnames. (Image-src specifics: see Images below.) Internal links to pages in the migration set are root-relative (/path, extensionless; a trailing slash only on a folder index): D4 is a capture-fidelity rule, never a delivery rule — localize-links.mjs (§ Deploy) rewrites captured source-host hrefs whose target exists locally and reports the ones that don't.

  • No HTML, CSS, or JSON visible as TEXT in any cell (D15). A <tag>, a { } binding, or a CSS rule appearing as author-visible text is a modeling mistake — code lives in the repo, not in documents. This includes inline-script text a scraper lifted as copy (try { window.X.wcm… }, campaign tokens like SPOFFCAR_PARTNER): a live-DOM importer reading textContent picks up <script> bodies and analytics tokens as paragraphs, and a keep-first-N cap then keeps the junk and drops the real sentence (recorded, two sections). Every text-collection step in an importer needs a standing exclusion list applied BEFORE any cap; the lint flags window./try {/function ( as text 🔴 and ALL_CAPS_TOKEN lookalikes 🟡.

  • Video/embed URLs are auto-blocked, not authored as blocks (D1). A YouTube/Vimeo/embed URL alone on its line stays a plain link in content; buildAutoBlocks() in scripts.js turns it into the embed at decorate time. Never make an author build an embed/video block table around a URL.

  • Alt-text carries the image description only (D13) — never data a block parses.

  • 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 live in SERVER-RENDERED page content, never solely in chrome or a fragment (#86, D12). /nav, /footer and fragment links are fetched client-side, so copy that exists only there is invisible to non-rendering crawlers; author citable facts on the page and verify by grepping the RAW served HTML. Full reasoning, the checker formula and the chrome trade-off: reference/ai-readability.md.

  • decorate() adds no words to the DOM (#100). Every visible string is authored; loop clones are presentational (no text, alt="", no href); listings are document-first (authored rows, index for non-text and top-up); generated text only for runtime values, allowlisted by block + string. Adobe's AI Content Visibility Checker scores served words ÷ rendered words — words the block adds are the whole defect, hidden text and chrome are not (reference/ai-readability.md).

  • 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").

  • Whitespace-only content never survives the pipeline (#112) — <p>&nbsp;</p> spacer paragraphs and trailing <br>&nbsp; are dropped. When the SOURCE renders real line boxes from them (AEM-classic richtext does), model those heights as block CSS (padding/margin), never as authored whitespace — the drop is silent and only a pixel probe sees the missing height.

  • Never author <hr> — it is the SECTION DELIMITER (#119). In DA/EDS authoring an <hr> splits the document into sections; authored inside a section it silently fractures that section into several, and every downstream section selector and style breaks. For a visible horizontal rule, author an empty section carrying a section-metadata style value and draw the line in CSS (a border or ::after). The lint blocks it (🔴).

  • 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 (D5). An accordion/FAQ is a head cell then one row per Q/A (question cell + answer cell). Simple inferred lists (related articles) may be one cell holding the links; code pulls the links.

  • Section head → DEFAULT CONTENT, not a block row (D1). 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. 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 → the emphasis convention (D6): primary <strong><a>, secondary <em><a>, high-impact accent <em><strong><a> (use sparingly — one per band at most). Size/color come from the block/section context, never from author choices; needing more than these three slots means a design-system decision leaked to authors (see "Lean on EDS button conventions" for the >3-variant escape hatch).

  • 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 (D14) — config only, mapping to data-/<meta>; never name/value for displayed content (a heading, an image, body copy modeled as a value cell is the lint's 🔴).

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:

  • First choice — style the head IN PLACE, no JS at all. The runtime gives the section a .<name>-container class, so the head is directly addressable: .cards-container .default-content-wrapper { … }. When the prototype's head sits visually OUTSIDE the block's grid (the common case), this is the whole implementation — no reabsorption needed.
  • Reabsorb only when the head must live INSIDE the block's layout container (e.g. the head is a grid cell of the same grid as the units). On decorate(block), read the section's leading default-content wrapper, build the SAME .section-head layout the block used to build from its first rows — MOVING the wrapper's children into it (head.append(...wrapper.childNodes); they are already inline-editable as default content and a rebuild from textContent kills that — EW8) — and remove the emptied wrapper. The head wrapper is a sibling of the block's .<name>-wrapper, not of the block itself: use block.parentElement.previousElementSibling, match .default-content-wrapper (block.previousElementSibling alone is null — the block is nested in its wrapper).
  • Keep the old in-table head (leading non-unit rows) as a defensive decode 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). The shipped uploader does this for a whole folder or manifest and prints the URL to author per file — DA_TOKEN=… node skills/deploy/scripts/da-media-upload.mjs --org <org> --repo <repo> --scope <name> --dir stardust/current/assets/media (--scope is the folder UNDER media/ — never media/<name>: the path would double to media/media/<name>, the script refuses it, a recorded run uploaded and deleted 242 files that way; --manifest <json> for the extract media list; --help for the rest): it skips non-images, refuses the unsafe SVGs below, keeps a resumable ledger, tells a source 403 from a 404, and proves the token with its first upload run alone (a 401 retries like a 429; only one that persists there halts, with the re-run instruction). Never hand-roll the PUT loop (one recorded run wrote a 112-file upload script from scratch). SVGs: pure-vector AND small (#99). An authored SVG that embeds raster data (<image>/data:image base64 behind a pattern fill — common for exported award badges/logos) makes the preview of EVERY page that references it fail 409 "error from content-bus", with no per-asset error. So does an SVG over ~40KB (a hard pipeline limit — even pure-vector; also surfaces only at preview, never at upload). Extract the embedded raster (data:image/png;base64,… → .png) or rasterize the oversized vector, and author the PNG; small pure-vector SVGs (logos, icons) ingest fine. The lint flags authored .svg media URLs as an advisory. When a preview 409 has no obvious culprit, run the two-step diagnosis in da-deploy-protocol.md step 3a (known-good doc to the same path, then the per-image SVG sweep) instead of dead-ending on the opaque error.
  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. Never copy the pipeline's fallback <img src> into a CSS background (#110) — it carries width=750 (the smallest rendition), so a full-bleed layer renders soft at any hero width; rewrite the width param (width=2000) first. <picture>-rendered images are unaffected (the browser picks a real rendition).
  4. The <picture> wrapper adds an inline baseline descender (#111): the pipeline's <p><picture>… leaves the image inline on the text baseline — each image paragraph measures +6/+7px vs a bare-<img> source, systematic and invisible to text gates. line-height: 0 on the image paragraph restores parity.

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.

Rehost from the CAPTURED src — never a reconstructed/guessed URL (#118). Asset CDNs (commerce catalogs especially) answer HTTP 200 with a GENERIC FALLBACK image for a wrong path — nothing errors, and the wrong pixels ship (recorded: six product images uploaded from guessed catalog URLs; the CDN served the same placeholder for all six, every status green). Always fetch from the exact URL string the lift/extract recorded (the capture map / stardust/current/assets/ inventory), and before uploading, diff the fetched asset's dimensions and byte size against the captured copy — on a fallback-serving CDN a 200 alone proves nothing.

Distinguish 403-bot-wall from 404-missing, and prefer rehost over omit (da-media-upload.mjs --manifest applies exactly this rule per entry whose local file is absent: FAIL … source 403 (bot wall … never omit) vs FAIL … source 404 (missing at origin …)). 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

Use this skill

Connect your agent to the Skill Store MCP server and ask for it by ref: adobe/deploy-82949649492.

Download SKILL.md · Markdown version

Details

Publisher
adobe
Category
Software Development › Frontend & UI
License
Apache-2.0
Updated
2026-10-17

References

More from adobe