stardust:deploy — prototypes → EDS/AEM
When to use
The user has:
- 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:roottokens + 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-elementstyle="…"). Harder — you must lift inline styles into a scoped block stylesheet.- React/JSX prototypes (an HTML shell that mounts
.jsxcomponents 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 understardust/prototypes/**or asamples/<Name>/folder — don't hard-code the path; discover them.
- Single-file with inline
- An EDS project at the repo root —
blocks/,styles/,scripts/,head.html, plus existing blocks (fragment,section-metadata). If the project is vanillaaem-boilerplaterather than the AuthorKit runtime this skill assumes, run the Runtime bootstrap below first. - 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 (hasscripts/ak.jswith 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'sAUTHORKIT_REFconstant 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.jsinjectingfragments/{header,footer}.htmlviainnerHTML), 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):
scripts/lazy.js(#4): the stock AuthorKitlazy.jslazy-loadsutils/footer.js, which doesloadBlock(footer)and collides with the static footer fragment (renders a visible "Error" box, since there is noblocks/footer). Delete theimport('./utils/footer.js')…line.scripts/postlcp.js(#21): deletingutils/footer.jsalso 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. InloadStaticFragment, set the class before injecting:
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).const html = await resp.text(); el.className = name; // so header.header / footer.footer match el.innerHTML = html;
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:
{
"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'sloadBlock(ak.js) only setsdata-block-name; it never adds a.blockclass (blocks sit inside.block-contentas<div class="<name> <variant>">). CSS must scope.<name> …, never.<name>.block— that selector silently never matches, grids fall back todisplay: block, and every grid section stacks single-column ("mobile layout on desktop") while typography still looks fine. Confirm by asserting a grid container computesdisplay: gridin a headless render.buttonClasses— the AuthorKit decorator emitsa.btn/.btn-primary/.btn-secondaryinsidep.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 innav.html, notheader.html). - Updated
styles/styles.csswith 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 BOTHemand.em. - Key facts must live in SERVER-RENDERED content, never solely in chrome fragments (#86). The static header/footer fragments are client-injected (
postlcp.jsinnerHTMLafter 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|tagmicro-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 — split185+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.htmlkeep 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-headthe 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')?.previousElementSiblingand match BOTH.default-content(AuthorKit runtime) and.default-content-wrapper(vanilla EDS).block.previousElementSiblingalone isnull(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:
- Upload the binary to DA:
PUT https://admin.da.live/source/{org}/{repo}/media/<scope>/<file>(multipart, field namedata, correctContent-Type; upload the JPG/PNG/webp source only — the pipeline generates responsive variants). - 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. - The block renders the authored
<img>into a background LAYER (.hero-bg/.card-media/.text-media); the scrim/gradient is a CSS::beforeOVER 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:
# 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, andbuttonare used by the runtime's section/decoration DOM — a block namedsectioncollides with<div class="section">and breaks decoration. When the prototype's section class is generic/reserved (Festool usesclass="section"twice), derive a semantic name from the section'sdata-screen-label/ intent instead (new-products,discover) and carry any modifier liketintedas 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:
closingCTA at the end of every page. - When a section appears on multiple pages but looks different (e.g. home
herovs case-studycase-herovs serviceservice-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
discoveryanddecisionsare 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
closingblock?" - "Should case-study discovery/decisions/solutions be one block or three?"
- "Is the per-service hero distinct from the home hero? Build
service-heroseparately?"
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:
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
:roottokens 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
imgreset MUST beimg { display: block; max-width: 100%; height: auto; }(#36). EDS's media pipeline emits<img>withwidth/heightattributes; withoutheight: autoa 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:
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.jsinjectsfragments/header.htmlAFTER 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 aty=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 instyles/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 responsivemin-heightmatching the fragment per breakpoint and the chrome's ownbackgroundso any reserve-vs-actual delta is invisible:
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; aheader { 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 */header:emptypage (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 (PlaywrightPerformanceObserver({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)