| name | deploy |
| description | 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/. |
| license | Apache-2.0 |
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 :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.
- 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.
- 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):
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.
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:
const html = await resp.text();
el.className = name;
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:
{
"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:
- 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).
- 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 ::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:
( cd samples/<proto> && python3 -m http.server 8765 & )
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:
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:
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:
header { min-height: 98px; background: var(--brand-ground); }
@media (width <= 767px) { header { min-height: 102px; } }
@media (width <= 480px) { header { min-height: 120px; } }
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 author never added to the
foundation (var(--navy-700) in a gradient, var(--accent-2) in a hover). A referenced-but-undefined
custom property silently invalidates the WHOLE declaration — background: linear-gradient(var(--navy) 0%, var(--navy-700) 100%)
with --navy-700 undefined drops the entire background and the element falls back (a navy card renders
light), with no error and no lint flag. Gate it mechanically after the foundation and before deploy:
comm -23 <(grep -rhoE 'var\(--[a-z0-9-]+\)' blocks/**/*.css | sed 's/var(//;s/)//' | sort -u) \
<(grep -oE '\--[a-z0-9-]+' styles/styles.css | sort -u)
Any line printed is a token a block uses that :root doesn't define — add it to the foundation :root.
Favicon — ship the site's icon (the ONE permitted head.html addition).
Extract captures the source site's favicon at
stardust/current/assets/favicon.<ext>; the deployed EDS site must serve it:
- Copy it to the repo root as
favicon.<ext>, preserving the format. A
favicon.ico is served automatically at /favicon.ico — nothing else
needed.
- When the format is NOT
.ico (svg/png), add exactly ONE line to
head.html: <link rel="icon" href="/favicon.<ext>">. This favicon link
is the only head.html edit this skill ever makes — the font ban (Step 4,
anti-pattern #10) stands untouched.
- Sandboxed/app runs (the
_eds/ bundle contract): write the file to
_eds/code/favicon.<ext> instead — the host publisher pushes it with the
code tree and injects the head.html link deterministically.
If extract captured no favicon, skip this step — never invent one. A
re-run of bootstrap-authorkit.mjs (the documented recovery path)
preserves this favicon link: portIn re-injects any existing
<link rel="icon"> line after overwriting head.html.
4. Self-host fonts and minimize CLS — never put font loads in head.html
Four principles, applied in this order on every project:
0. Ship an @font-face for EVERY named family — not just the body face (#65). Prototypes name a display face AND a body face (--display: "Hebden Incised", …; --body: "Lekton", …). If you self-host only the body font, every heading/numeral/title whose stack names the un-shipped display family silently falls back to Times New Roman/Arial (generic serif/sans) — invisible to size/color checks (the glyphs differ but the metrics match; only the FONT MISMATCH probe flag #66 or an eyeball catches it). Distinct from #11/#22 (wrong weight) and #30 (opsz): here the family is NAMED but NEVER SHIPPED. For each quoted family in --display/--body/any heading stack, self-host a matching @font-face (download + commit the woff2 under styles/fonts/, reference root-relative — never the prototype's brand-CDN origin, #44). Checklist: grep every quoted family in styles.css's font stacks against the @font-face { font-family } names declared — any unmatched name is a silent fallback.
Then four principles, applied in this order on every project:
1. Leave head.html untouched. No font lines, period.
No Google Fonts <link>. No CDN <link rel="stylesheet"> for type. No <style> blocks declaring @font-face. No <link rel="preload" as="font"> either — even self-hosted preloads belong out of head.html. The browser will fetch the woff2 it needs as soon as styles/styles.css parses; the body { arial } / body.session { var(--font-body) } split (principle 3) eliminates the CLS that preloading is normally meant to prevent. All @font-face declarations live in styles/styles.css.
2. Self-host EVERY brand face — including proprietary ones — and emit a licensing alert (#80).
Inspect the prototype to identify each font family and its license:
- SIL OFL 1.1 (Inter, JetBrains Mono, Fraunces, Roboto, Open Sans, IBM Plex, Source Sans, etc.) → self-host. License permits redistribution, including embedding on the served domain.
- Apache 2.0 (some Google Fonts) → self-host.
- Proprietary commercial (Pangram Pangram, Adobe Fonts / Typekit, Monotype, foundry-direct) → self-host anyway for fidelity, BUT raise a LICENSING ALERT (#80). The DEFAULT is brand-faithful: a converted/presales page that silently degrades the brand display face to Arial reads as broken to the client (this is exactly what a stakeholder notices first). So lift the prototype's actual webfonts — if the prototype ships
.otf/.ttf (claude-design/stardust prototypes usually do, under assets/fonts/), convert them to latin woff2 with fontTools (f.flavor='woff2'; f.save(...), ~30–60 KB each) and declare them in styles/styles.css exactly like an OFL face. Because they're proprietary you MUST surface the licensing obligation in THREE places so it can't ship unnoticed:
- a banner comment at the top of
styles/styles.css (⚠️ FONT LICENSING REQUIRED BEFORE GOING LIVE + foundry per family + "do not publish to aem.live until the webfont/embedding license is confirmed");
- a
styles/fonts/LICENSING.md file (table: file → family → foundry → status, plus the remove-and-fall-back instructions);
- the conversion log, AND your hand-off message to the user.
Document the remove path: if licensing can't be confirmed, delete the
.woff2 + their @font-face rules and the stacks fall back to the metric-matched system fallback (principle 3/4). This is the inverse of the old "keep CDN / accept Arial" guidance — prefer fidelity + a loud alert over a silent generic fallback. (Only keep a CDN load when the prototype itself loads from an Adobe/Typekit CDN AND you cannot obtain the font files — then document the CDN coupling + CLS cost.)
For OFL fonts, fetch latin-subset variable woff2 files. The fastest reliable source is jsDelivr's @fontsource-variable/<name> packages:
mkdir -p styles/fonts
curl -sSL -o styles/fonts/<name>-variable.woff2 \
"https://cdn.jsdelivr.net/npm/@fontsource-variable/<name>@latest/files/<name>-latin-wght-normal.woff2"
curl -sSL -o styles/fonts/<name>-italic-variable.woff2 \
"https://cdn.jsdelivr.net/npm/@fontsource-variable/<name>@latest/files/<name>-latin-wght-italic.woff2"
Latin-only variable woff2 is typically 30–60 KB per file, weights 100–900 included.
Match the axes the prototype loads — incl. optical size (#30). Check the prototype's Google Fonts <link> URL. If it requests an opsz (optical-size) axis — e.g. Source+Serif+4:opsz,wght@8..60,400;… — the default @fontsource-variable/<name> file (<name>-latin-wght-normal.woff2) is wght-only (one fixed optical master) and headings will render subtly off (heavier/different letterforms at large sizes). Fetch the opsz file instead (carries both wght + opsz, ~2× the bytes); font-optical-sizing: auto (the CSS default) then tracks the size:
curl -sSL -o styles/fonts/<name>-opsz.woff2 \
"https://cdn.jsdelivr.net/npm/@fontsource-variable/<name>@latest/files/<name>-latin-opsz-normal.woff2"
More generally: self-host the variant whose axes match what the prototype loaded (wght-only vs opsz; italic if used).
Non-variable fonts (#11). Many Google fonts ship only as named static weights — no variable axis (e.g. Barlow, Barlow Condensed, Anton). For these, @fontsource-variable/<name> does NOT exist; use the static @fontsource/<name> package and fetch each weight you actually use:
curl -sSL -o styles/fonts/<name>-700.woff2 \
"https://cdn.jsdelivr.net/npm/@fontsource/<name>@latest/files/<name>-latin-700-normal.woff2"
Static @fontsource packages also do not publish a "Fallback" @font-face, so you must compute the metric-override values yourself (principle 3) from the woff2 with fonttools:
from fontTools.ttLib import TTFont
f = TTFont("styles/fonts/<name>-400.woff2"); upm=f['head'].unitsPerEm; hhea=f['hhea']; os2=f['OS/2']
arial = dict(upm=2048, xavg=904)
size_adjust = (os2.xAvgCharWidth/upm) / (arial['xavg']/arial['upm'])
adj = upm*size_adjust
print(f"size-adjust:{size_adjust*100:.2f}% ascent-override:{hhea.ascent/adj*100:.2f}% "
f"descent-override:{abs(hhea.descent)/adj*100:.2f}% line-gap-override:{hhea.lineGap/adj*100:.2f}%")
Apply those to the system-font override @font-face (renamed "Arial" / "Times New Roman") exactly as in principle 3.
3. Body.session pattern with a metric-matched fallback @font-face.
The brand font must NOT render at first paint. Default to a metric-matched system font; switch to the brand font once decorateSession() (in scripts/ak.js) adds body.session. The recipe:
@font-face {
font-family: "<Brand>";
src: url("/styles/fonts/<brand>-variable.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: "Arial";
src: local("Arial");
size-adjust: <X>%;
ascent-override: <Y>%;
descent-override: <Z>%;
line-gap-override: 0%;
}
:root {
--font-body: "<Brand>", arial, sans-serif;
}
body { font-family: arial, sans-serif; }
body.session { font-family: var(--font-body); }
Keep body VISIBLE — never gate display on body.appear (#40). The font gate is the body → body.session font swap above; ak.js adds body.session. Do NOT port the aem-boilerplate pattern body { display: none } body.appear { display: block } into the foundation: the static-chrome runtime never adds appear, so every OFF-pipeline render (the Local-QA harness, the visual-diff probe) stays permanently hidden/zero-height — and a blank page silently passes most checks. (The probe now emits a BLANK RENDER red flag as a backstop, but the foundation must not introduce the gate in the first place.)
The metric-override values come from the @fontsource-variable/<name> package's published calibration — fetch their CSS:
curl -s "https://cdn.jsdelivr.net/npm/@fontsource-variable/<name>@latest/index.css" \
| grep -A 6 "Fallback"
Each fontsource package publishes a <Name> Fallback @font-face with size-adjust, ascent-override, and descent-override values. Lift those three numbers verbatim and apply them to a local-system @font-face renamed to the system font ("Arial", "Times New Roman", "Courier New").
The CLS chain that results:
- Initial paint:
body { font-family: arial, sans-serif; } — renders the metric-adjusted local Arial because the override @font-face named "Arial" wins over the OS Arial. Line box already matches the brand font's metrics.
- Session activates:
body.session switches to var(--font-body). Brand woff2 is still loading; Arial renders in its place with matching metrics. Zero shift.
- Brand font loads: swaps in. Zero shift because metrics already match.
4. Match the fallback family to the brand font's classification.
Use the SAME class of typeface for the fallback so visual rhythm is preserved during the load:
- Sans-serif brand → fallback
arial, sans-serif. Override @font-face "Arial" with local("Arial").
- Serif brand → fallback
times, "Times New Roman", serif. Override @font-face "Times New Roman" with local("Times New Roman").
- Monospace brand → fallback
"Courier New", courier, monospace. Override @font-face "Courier New" with local("Courier New"). (Note: skipping monospace metric-matching is acceptable when the mono font is only used in small eyebrows/labels — CLS impact is negligible. Document the choice in the conversion log.)
Never substitute classifications (don't match a serif brand to Arial; don't match a sans brand to Times). Even with metric overrides, character widths and rhythm differ enough that the visible shift is jarring.
Classification includes WIDTH — a condensed/narrow display face needs a condensed fallback, never plain Arial (#80). "Sans→Arial" is only right for a normal-width sans. A narrow/condensed display face (PP Formula Narrow, Bebas Neue, Oswald, Barlow/Archivo Condensed, Anton) falling back to plain Arial is a width-class mismatch: Arial runs ~15–20% wider with different letterforms, so headings lose the condensed character and wrap differently — a silent divergence the eye catches even when sizes/weights/tracking match exactly (a CardValet pass shipped PP Formula→Arial; the width probe showed Arial 975 vs PP Formula 839 for the same H1 string). When the condensed brand face is self-hosted (principle 2, now the default) this only bites if the webfont is blocked, but the fallback must STILL preserve width: put a condensed system/free face ahead of arial in the stack — "<Brand>", "Arial Narrow", arial, sans-serif (system Arial Narrow is present on macOS/Windows but NOT Android/Linux, so for guaranteed coverage self-host a free OFL condensed analog — Oswald / Barlow Semi Condensed / Archivo Narrow). Same logic for an extended/wide brand face. Quick check during foundation: for every --*-font-family token whose first face is condensed, confirm the final non-sans-serif fallback is also condensed.
Self-host the prototype's INTENDED fallback, not its accidental system render — and verify with a width probe, never document.fonts.check (#77). Prototypes routinely load zero @font-face and name a proprietary brand font first (--display: "Bellfort", "Bebas Neue", system-ui). On any machine missing the brand font the prototype silently renders system-ui — so its on-screen display face is an accident of the viewing machine, not the design intent. Do NOT match that accident (don't set EDS --display to system-ui because "that's what the proto shows"). The prototype's OWN stack documents the intent: self-host the first redistributable fallback (OFL/Apache — e.g. Bebas Neue) so EDS ships the condensed display face the design wants; keep proprietary families documented in the conversion log. Verify what actually rendered with a width probe — document.fonts.check('24px "X"') returns true for any family name the page references, installed or not, so it produces false "fonts match" reads. Instead measure: a span at font-family:"X",monospace whose width equals a known-absent name's width means X fell back (absent); a distinct width means X is really rendering. (A beermaker pass set --display to Bebas Neue via a fonts.check false-positive — the width probe later showed the proto actually renders system-ui, but Bebas Neue was still correct as the documented intended fallback.)
Multiple display families (#12). A brand may use several families — e.g. Barlow (body) + Barlow Condensed + Barlow Semi Condensed (display). The body.session pattern only gates the body family; display families referenced directly by class (headings, eyebrows) load with font-display: swap and aren't fully CLS-eliminated. Define each as a :root token (--font-cond, --font-semi) and reference it per-block on the elements that use it. Fully metric-matching every display family is optional polish — for display text used sparingly (eyebrows, big condensed headings) the CLS impact is small; document the trade-off in the conversion log rather than over-engineering it. But when a display family is used in an ABOVE-THE-FOLD heading (the hero <h1>, an LCP title), metric-match it too — compute its size-adjust/ascent-override/descent-override from the woff2 (the same fonttools recipe as #11) and put a dedicated fallback @font-face SECOND in that family's stack: --hero: "Lilita One", "Lilita One Fallback", …. The fallback family MUST have its own name — do NOT reuse the renamed "Arial" face the body match already defines (#11), since that carries the body font's metrics, not the display font's. (Note: in practice the dominant first-section CLS is usually the late header box, #81, not the display-font swap — fix the header reservation first, then metric-match above-fold display faces to zero the remainder.)
Match the prototype's effective weight (#22). A single-weight display font (e.g. Anton, ships only 400) often appears bolder in the prototype than its one weight: a bare <h1>/<h2> inherits the browser-default heading weight (700), and the browser faux-bolds the 400-only face. If your foundation sets h1,h2,h3 { font-weight: 400 }, headings render visibly lighter than the prototype. Set the weight the prototype actually shows (often 700) so the faux-bold matches — don't assume "one weight in the file ⇒ font-weight: 400".
5. Lean on EDS button conventions — DO NOT manufacture button anchors in block JS
The EDS link decorator in scripts/ak.js (decorateButton()) automatically applies button classes when authors wrap a link in inline emphasis. This runs during page boot, AFTER block JS. Block JS just needs to clone the cell anchor as-is.
Author markup → auto-applied class:
| Author markup | Class applied | Visual |
|---|
<strong><a> | .btn.btn-primary | wavelength fill, dark text |
<em><a> | .btn.btn-secondary | transparent + outline (color-aware) |
<em><strong><a> | .btn.btn-accent | canvas fill on dark surfaces |
<del><a> | .btn.btn-negative | rare; destructive |
+ <u> inside any | adds .btn-outline | transparent variant |
| 2+ buttons in same parent | parent gets .btn-group | flex with gap |
Add to styles/styles.css (the project's brand button system):
a.btn {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 16px 26px;
font-size: 12px;
font-weight: var(--weight-bold);
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid transparent;
transition: background 0.25s var(--ease-out), color 0.25s var(--ease-out), border-color 0.25s var(--ease-out);
}
a.btn-primary { background: var(--color-wavelength); color: var(--color-ink-rich); border-color: var(--color-wavelength); }
a.btn-primary:hover { background: var(--color-canvas); border-color: var(--color-canvas); }
a.btn-secondary { background: transparent; color: currentcolor; border-color: rgb(255 255 255 / 40%); }
a.btn-secondary:hover { border-color: currentcolor; background: rgb(255 255 255 / 5%); }
main .section:not(.dark, .closing, .hero, .team) a.btn-secondary { border-color: var(--color-rule-strong); color: var(--color-ink-rich); }
main .section:not(.dark, .closing, .hero, .team) a.btn-secondary:hover { border-color: var(--color-ink-rich); }
a.btn-primary::after, a.btn-accent::after { content: "→"; font-weight: 600; transition: transform 0.3s var(--ease-out); }
a.btn-primary:hover::after, a.btn-accent:hover::after { transform: translateX(4px); }
.btn-group { display: inline-flex; flex-wrap: wrap; gap: 16px; align-items: center; }
Surface-aware variants: scope to the BLOCK class, not just the section (#41). When a button/link/text treatment differs on dark vs light surfaces, the prototype's dark-surface cue (e.g. .hero, .cta-dark) becomes a block class after conversion — a <div class="hero"> nested inside the <div class="section">. So an override written as main .section.hero a.btn-secondary never matches (the .hero is one level below .section), and the on-dark CTA silently renders dark-on-dark. Scope on-dark overrides to BOTH: main .section.dark a.btn-secondary, main .hero a.btn-secondary { … }. QA any block on a dark background for secondary/ghost-CTA contrast (light outline + light text) — the button "exists" in metrics, so only contrast/eyeball catches this.
Block JS pattern — just clone the cell:
const ctaCell = rows[N]?.firstElementChild;
if (ctaCell && ctaCell.querySelector('a')) {
const actions = document.createElement('div');
actions.className = 'actions';
[...ctaCell.childNodes].forEach((n) => actions.append(n.cloneNode(true)));
container.append(actions);
}
DO NOT manufacture anchors with cta.className = 'btn-loud' or inject custom SVG arrows. The global ::after arrow + the convention's class system handle 95% of cases.
Block CSS pattern — only override what's actually different:
The closing block's CTA is slightly larger than the global default. That's a legitimate override:
.closing .actions a.btn-primary { padding: 22px 32px; font-size: 13px; }
Three lines. Targets the global class, not a custom one. This is the entire "blocks slightly augment defaults" pattern.
When NOT to use the convention:
Some links are NOT buttons. Examples:
- A wavelength-underlined text link in a section footer ("How we work →"). It's a styled text link, not a chip.
- Whole-card anchors on tile grids (
<a class="tile">…</a>). The whole tile is the click target.
- Channel values in a closing CTA (
<a href="tel:…">801-363-0101</a>). It's a value, not a CTA.
mailto: / tel: links inside prose.
For these: the author leaves the <a> as a plain anchor in content (no <strong> / <em> wrap), and the owning block styles it with per-block CSS. The convention is for buttons; if it's not a button, don't apply it.
Multi-variant button systems (#25). The strong/em convention only names three slots (primary / secondary / accent). When a prototype has more context-specific variants than that — e.g. JFK's .btn--accent (yellow), .btn--primary (blue), .btn--ghost (outline), .btn--onblue (white-on-blue) — author emphasis can't express them. Don't force it: lift the prototype's full .btn + variant system into styles/styles.css, author the CTAs as plain <a> in content, and have each block apply the right btn btn--<variant> class to the cloned anchor (the block knows its section's variant). This is the same "if it doesn't fit, style it" escape hatch, applied at the button-system level rather than per-link.
6. Static header + footer fragments
Header and footer are static fragments — extracted verbatim from the prototype with their full DOM and styles intact. No EDS authoring, no block JS parsing. They are stored in fragments/header.html and fragments/footer.html at the repo root and committed to GitHub as code.
Fragment file format:
<style>
.utility { background: var(--color-ink); ... }
nav.topnav { position: sticky; ... }
.nav-inner { ... }
@media (max-width: 900px) { ... }
</style>
<div class="utility">...</div>
<nav class="topnav">...</nav>
No <!DOCTYPE>, no <html>, no <body> wrapper. Just the raw <style> + DOM. The EDS runtime injects it directly into the page's <header> or <footer> element via innerHTML.
Extraction rules:
- Copy the header DOM (utility bar + topnav) from any prototype — it's shared across all pages.
- Copy the footer DOM from any prototype — also shared.
- For each fragment, collect the relevant CSS rules from the prototype's
_tokens.css and any page <style> blocks. Include all responsive breakpoints.
3b. Lift the chrome element's OWN box styles (#31) — margin, padding, border set on the prototype's <header>/<footer> element itself — onto header.header / footer.footer, not just the inner content styles. The fragment injects into the EDS <header>/<footer>, which sit OUTSIDE <main>, so the gap between the last section and the footer comes entirely from the footer's own top margin (e.g. footer { margin-top: 72px }). Easy to miss: the inner content looks right while the footer sits flush against the last block.
- Scope the CSS inside a
<style> tag at the top of the fragment file.
- Rewrite relative asset paths (e.g.
../assets/logo.png) to fully-qualified URLs on the code origin: https://main--<repo>--<owner>.aem.page/path/to/asset.
- Rewrite relative link hrefs (e.g.
donate.html) to root-relative paths (e.g. /donate) matching how EDS serves pages.
Loading mechanism: scripts/postlcp.js fetches fragments/header.html and fragments/footer.html from the code origin (codeBase) and injects via innerHTML. On branch-hosted pages (<branch>--repo--org.aem.live), relative paths resolve to the correct branch automatically.
Fragments cannot run JavaScript. Because the fragment is injected via innerHTML, any <script> inside it is inert — the prototype's header JS (mobile-menu toggle, scroll-state shadow, dropdown logic) will NOT run. Re-implement interactive chrome as CSS-only:
- Mobile menu / drawer → checkbox-hack: a visually-hidden
<input type="checkbox" id="mnav-toggle"> as the first element, <label for="mnav-toggle"> for the open button and the close button and a full-screen scrim label, and CSS #mnav-toggle:checked ~ #mnav { … } to drive the open/close state and the panel transform.
- Scroll-state shadow / sticky color change → drop it (keep a static border), or use a CSS scroll-driven approach where supported. Don't try to reattach JS to the fragment.
- Forms / inline
on* handlers (#20) → EDS's delivered CSP is script-src 'nonce-…' 'strict-dynamic' 'unsafe-inline'; with strict-dynamic, 'unsafe-inline' is ignored, so inline onsubmit/onclick never fire. A real <form> would then submit and reload the page. Render such controls non-submitting: a <div> wrapper (no <form>) with <button type="button">. (Same root cause as the no-<script> rule — fragments are inert.)
- Document anything you dropped in the conversion log.
Footer reconciliation (see #4). The AuthorKit lazy.js also tries to load the footer as a block (utils/footer.js → loadBlock(footer)), which collides with the static footer fragment and renders an error box. Make sure the Runtime-bootstrap edit removing that import has been applied.
Fragment root class (#26). postlcp.js sets the host element's class to header / footer. If the prototype's chrome styling is keyed to a different root class (e.g. JFK's .utilnav / .site-footer), wrap the fragment content in a <div class="<that-class>"> so the lifted root selector matches — header.header / footer.footer is only the host. (Don't nest another <header>/<footer> inside the host element; use a <div>.)
header: off / footer: off: To suppress header/footer on a specific page, add a metadata block with header: off or footer: off. The loader checks getMetadata('header') / getMetadata('footer') before fetching.
7. Blocks (parallel agents)
Dispatch one agent per page-archetype cluster (utility pages, services, case studies, etc.). Each agent owns a non-overlapping set of new blocks and content pages. Three to four parallel agents is the sweet spot.
The brief template:
Per the project's locked direction: each prototype <section> becomes its own EDS block. Lift the prototype's <style> for that section verbatim, scope it under the block class (.block-name .x instead of section.x .y), and rebuild the prototype's DOM through a decorate(block) function that consumes EDS table-block input.
You own: prototypes [list], content pages [list], sections [list].
Existing blocks — REUSE, do not recreate: [list with one-line authoring shape per block].
Brand tokens are global in styles/styles.css; do not redefine.
Round-trip contract (#93/#94): the page's authored rows AND your block's decode are both written from stardust/eds-schema/<page>.json (roles + repeat units — Step 2b); cite the schema path in the block JSDoc. After writing each block, run node skills/deploy/scripts/block-roundtrip.mjs "<protoURL>" content/<page>.html --blocks <name> — the block is NOT done until it exits 0 (0 structural 🔴).
Section layout — reproduce the prototype's max-width container (#13): if the prototype section wraps its content in a centered max-width container (<div class="wrap"> / .container / .inner), your block MUST recreate it — build the content into a .wrap div (block.replaceChildren(wrap)), so the colored/section background bleeds full-width but the content stays within the page max-width. Only render content edge-to-edge where the prototype section itself is full-bleed (no inner wrapper). Getting this wrong is invisible at ≤1440px and only shows at wide viewports.
Images — <image-slot> placeholders (#2): claude-design prototypes use <image-slot> custom elements as image drop-targets; there are usually NO real image assets. Treat each image as an optional authored cell holding a <picture>/<img> (const pic = cell.querySelector('picture, img'); if (pic) …). When the cell is empty, fall back to the prototype's background treatment (e.g. dark --ink, or a placeholder rectangle) via the block CSS so the section still looks right with no image. Leave image cells EMPTY in the authoring snippet.
Scroll-reveal / JS-hidden content (#14): if the prototype hides content behind a class an inline <script> toggles on scroll (.reveal { opacity:0 } + an IntersectionObserver that adds .in), do NOT lift the opacity:0 — the prototype script does not run in EDS, so the content would be permanently invisible. Render it visible; drop the reveal (keep only hover/:hover transitions). Honor prefers-reduced-motion.
Interactive / component-driven sections (#17): when a section is driven by a component (state, a list loop like <sc-for>, conditionals like <sc-if>, {{ }} bindings, a data-count counter, a tab/selector), split it: data → authorable rows (one row per list item, with the item's fields as cells) and behavior → block JS. Unlike static fragments, block JS runs — so decorate() is the right place to wire click handlers, an IntersectionObserver count-up, tab switching, etc. Render the default/active state in markup; drive the rest from JS-held local state. {{ }}/<sc-for>/<sc-if> are NOT EDS syntax — read them as "loop these rows" / "show one state".
Buttons: do NOT manufacture button anchors. Author CTAs as <strong><a> (primary) or <em><a> (secondary) in the content page; in block JS, clone the cell's child nodes into a .actions wrapper. Block CSS only overrides global button styles when something is genuinely different (e.g. larger size). Text links with flourish (wavelength underline) are NOT buttons — leave as plain <a> and style per-block.
EDS block convention: each block at blocks/<name>/<name>.{js,css}. JS exports default async function decorate(block). Block input is <div class="block-name"><div>row<div>cell</div></div>…</div>. CSS scoped under .block-name. Inline SVG markup per-block (no shared utility). Honor prefers-reduced-motion.
EDS content page format: NO <head> element (project head.html is injected by EDS), empty <header></header>/<footer></footer>, each top-level <div> inside <main> is one section containing one block, no <style>/<script>/section-metadata, fully-qualified image URLs.
Done criteria: [list of paths]. Return a list of new blocks + one-line summary per page.
Agents do not need to coordinate on shared blocks — the brief tells them which existing blocks to reuse.
8. Block JS scaffold
function text(cell) { return cell ? cell.textContent.trim() : ''; }
function pic(cell) { return cell ? cell.querySelector('picture, img') : null; }
export default async function decorate(block) {
const rows = [...block.children];
if (!rows.length) return;
}
Prove each block's round-trip IN THE LOOP — per block, before deploy (#94). Step 10's content-diff is the post-deploy proof; it must not be where defects are FOUND. After writing a block (and its authored rows), run the harness round-trip:
node skills/deploy/scripts/block-roundtrip.mjs \
"http://localhost:8791/<prototype>.html" content/<page>.html --blocks <name>
It decorates the authored content locally with the block's own JS+CSS (the render-harness technique — no DA, no dev server), extracts the role inventory from the decorated section AND the matching prototype section with the SAME classifier as content-diff (skills/diff/scripts/content-inventory.mjs), and diffs them. Pass --map <name>=<selector> when the prototype section class differs from the block name; --styles/--blocks-dir when the repo layout isn't eds/- or root-level. Exit 2 on any structural 🔴 (MISSING CTA/HEADING/EYEBROW, ROLE SWAP) or any decorate error — fix the decode (or the authored rows) and re-run; the block is done when it exits 0. Font forks are deliberately NOT checked here (the harness renders local fonts; faces are Step 4 + Step 10's business). A decorate errors line means the block threw mid-run or its JS never installed (the harness INLINES block JS, so a module-scope import cannot resolve — inline the helper, or verify that one block via the dev-server harness + Step 10); either way the raw rows can false-match the prototype, so these fail the gate on their own. Template-slotted blocks (#95) still run the gate: it catches slot-fill mistakes and authored-row drift.
Copy set for the playwright ESM rule: when copying these gates into the project (extract SKILL.md § Setup — bundled scripts must run from the project so import 'playwright' resolves), copy both skills/deploy/scripts/ and skills/diff/scripts/ preserving the directory layout — section-schema.mjs and block-roundtrip.mjs import ../../diff/scripts/{diff-profiles,content-inventory}.mjs.
Lead/hero blocks: query content, don't hard-index rows (#42). A hero that reads rows[3]=headline, rows[4]=lede, rows[5]=CTA breaks the moment the content shape differs — and the mandatory-metadata / single-<h1> SEO rework (#34/#35) actively consolidates the headline + lede + CTAs into ONE cell, so the fixed indices come back undefined and the hero .wrap (the LCP element and the only <h1>) renders EMPTY with no error. Decorate lead blocks by querying (block.querySelector('h1,h2'); link-bearing <p> = CTAs; picture from anywhere) so they tolerate BOTH the rich multi-row shape and the consolidated single-cell shape. Disambiguate eyebrow vs lede by ORDER/length, not "first <p>" (#51): both are link-free <p>s, so "first link-free paragraph = lede" swaps them (the short eyebrow comes first). The canonical lead order is eyebrow → heading → lede: the eyebrow is the short/uppercase line before the heading; the lede is the sentence-length <p> after it. Local-QA check: after decoration, assert the hero's inner wrap is non-empty and contains the <h1>.
When cloning a cell into your OWN heading element, UNWRAP the cell's heading first (#55). If you build a live <h1> and clone a source cell's childNodes into it, and that cell wraps its text in its own <h1> (which #35/#42 actively encourage), you get <h1><h1>…</h1></h1> — a duplicate heading and a doubled font cascade. Always const inner = cell.querySelector('h1,h2,h3,h4,h5,h6') || cell; then clone inner.childNodes. Local-QA: exactly one <h1>, and 0 descendant headings inside the live headline.
Marker injection must be IDEMPOTENT (#70). When decorate() PREPENDS a fixed decorative marker to authored heading/link text (a glyph ▶, a badge, a CH NN number), first STRIP a matching leading occurrence from the cloned text node — firstText.textContent = firstText.textContent.replace(/^\s*▶\s*/, '') — because the author (or the #34/#35 SEO content rebuild) may already type it, so the marker doubles (▶ ▶ Latest signal). Apply the SAME strip at EVERY place the block injects that marker (section titles AND card links), not just some. (The visual-diff text-match catches a doubled glyph X X ….)
Carousel slide segmentation is HEADING-boundary driven and ORDER-AGNOSTIC (#69). Segment slides ONLY on the heading boundary — one heading opens one slide (a leading <picture> before the first heading may open slide 0). Fold EVERYTHING between two headings into the open slide regardless of authored order (eyebrow/label may come AFTER the heading, not before): first non-link text run = eyebrow, links = CTAs, extra text = description. Never let a post-heading text node open a NEW slide (that steals the heading slot and the CTAs never attach — symptom: N+2 jumbled slides with 0 CTAs). Local-QA: rendered slide count == authored heading count AND each slide's CTA count == authored.
Split/photo-overlay segmentation: BUFFER the eyebrow that PRECEDES its heading (#76). Heading-boundary segmentation (#52/#69/#73) opens a new group ON the heading — but in split panels and photo-overlay halves the eyebrow is the small label ABOVE the title, so it arrives BEFORE the heading and a "start collecting after the heading" loop drops it entirely. Worse, the body line after the heading then falls into the now-empty eyebrow slot (inheriting its accent/uppercase paint) and the next group's eyebrow bleeds into the prior group's teaser. Fix: keep a pendingEyebrow — any text seen before a heading is buffered and attached to the group that heading opens; once a group already has its body/CTA, further bare text re-buffers as the NEXT group's pending eyebrow (not this group's teaser). This complements #69 (carousel eyebrow may come AFTER the heading): don't hard-assume either order — buffer pre-heading text, classify post-heading text by what's already filled. The content is all in the DOM, so a count check passes while the render is scrambled — Local-QA: assert each group's eyebrow/heading/body/CTA land in their OWN slots (eyebrow text ≠ body text), not just that N groups exist. Hit on beermaker the-people.