stardust:diff — prototype ↔ build reconcile
Two probes that compare a source prototype against a built page. They catch disjoint failure classes — run BOTH; either alone gives a false "looks fine".
Both are framework-agnostic Playwright probes that compare two rendered URLs by
computed style + DOM (not pixels). All stack-specific language lives in a
profile (--profile eds|generic); the comparison logic is generic.
When to use
- After converting a prototype to EDS (the stardust
deployskill's Step 10) — use--profile eds. - Any "does the build match the design?" check between two rendered URLs (a Figma export vs a React build, a legacy page vs a rebuild) — use
--profile generic. - Inside a conversion/QA workflow as the validation gate (see Workflow use).
Not for: a single static file with no JS decoration (use the build/harness URL so components are decorated — a raw .plain.html has no roles to classify).
The two probes
| Probe | Script | Sees | Blind to |
|---|---|---|---|
| Pixel / layout | skills/diff/scripts/visual-diff.mjs | stretched images, dropped max-width wraps, blank renders, surface/ground colour flips, image-count gaps | "right text, wrong slot"; a dropped CTA (full pixels, plausible colours → no flag) |
| Structural content + type | skills/diff/scripts/content-diff.mjs | MISSING / ROLE-SWAPPED headings·eyebrows·CTAs, invented/dropped body copy, rendered-FACE font forks (width probe); dropped placeholder/aria-label/title values, missing/wrong/moved icons — on the main root AND the header/footer chrome roots by default | geometry / layout regressions |
content-diff extracts an ordered, role-classified inventory (heading / eyebrow /
cta+href / body) from each root — the --main root(s) plus, by default, the header
and footer chrome roots — classifying by computed style + tag so the prototype's DOM
and the built DOM compare symmetrically, then diffs them root by root; a second inventory per
root carries the placeholder / aria-label / title values and the icons.
Run it
# Prereq 0: playwright importable from the project root — probe
# node -e "import('playwright').then(()=>process.exit(0))"
# and re-install (npm i -D playwright --no-save --legacy-peer-deps) on failure:
# a --no-save install from extract is PRUNED by any later real npm i
# (extract SKILL.md § Setup). Run the copied scripts from the project, not the plugin.
# Copy the WHOLE skills/diff/scripts/ dir: content-diff imports its local diff-profiles.mjs
# AND content-inventory.mjs. (The deploy gates #93/#94 now use their OWN synced copies in
# skills/deploy/scripts/ — A6/A2 are independent of this skill; the two copies must stay in
# sync until the diff-skill abrasion PR consolidates them.)
# Prereq: a RENDERABLE source. Static → serve from its own dir (python3 -m http.server).
# The build URL must be the DECORATED page (live/preview or a local harness), not raw markup.
# ONE server, ONE port — probe before starting one (curl is always present, lsof is not):
curl -sI localhost:8791/ | head -1 # 200/404 = something serves the port; no line = free
curl -sI localhost:8791/<prototype>.html | head -1 # 200 = it serves YOUR dir: reuse it
command -v lsof >/dev/null && lsof -nP -iTCP:8791 -sTCP:LISTEN # optional: names the pid
# Nothing answered → start yours. Answers but not your file → a foreign server: never kill
# a listener you did not start; prefer a per-project port — a stale server from another
# project makes both probes measure a foreign page. `lsof … || echo free` is not a probe —
# without lsof it prints "free" beside a live listener (recorded: a second server on the
# same port died at once and the round chased 404s).
PROTO="http://localhost:8791/<prototype>.html"
BUILD="https://<branch>--<repo>--<owner>.aem.page/<path>" # or http://localhost:3000/<harness>
# 1. PIXEL/layout
node skills/diff/scripts/visual-diff.mjs "$PROTO" "$BUILD" --profile eds --sections ".hero"
# 2. STRUCTURAL content + type
node skills/diff/scripts/content-diff.mjs "$PROTO" "$BUILD" --profile eds # --json dumps both inventories
Flags (both tools): --profile eds|generic (default eds), --width <px> (default 1280),
--main <selector[,selector…]> (content root; content-diff defaults from the profile and takes a
comma-separated list — every root is inventoried and diffed on its own and each finding line
carries its root as [root]; visual-diff takes one selector, default main), content-diff only:
--chrome / --no-chrome (default on — the header and footer roots, resolved as the first
<header>/[role=banner] and <footer>/[role=contentinfo] outside the main root(s), are
compared beside it; a side lacking one is measured as empty there and the root line says so),
plus the live-target set (shared engine: scripts/live-session.mjs — every context sends the
real-Chrome UA and the standard Chrome request headers; the UA alone still 403s on
Akamai-class bot management):
-
--ua <string>— user agent override (default: real-Chrome desktop UA). -
--wait-until <state>— goto wait override. Default rule (one shareddefaultWaitUntilinscripts/live-session.mjs), decided per URL side, three tiers:- localhost/127.0.0.1 →
networkidle(local prototypes / harnesses, unchanged); - EDS build/preview origins — hostnames ending in
.aem.page,.aem.live,.hlx.page,.hlx.live→networkidle(they decorate asynchronously and reliably reach networkidle; measuring at domcontentloaded reads the pre-decoration DOM — flaky false reds / FONT FORK on deploy Step 10); - all other live http(s) →
domcontentloaded(live sites with analytics beacons never reach networkidle).
--wait-untiloverrides all three tiers. - localhost/127.0.0.1 →
-
--dismiss [sel,...]— dismiss overlays on both sides: cookie consent (clicked, not removed) AND timed marketing/newsletter modals, plus optional extra site-specific selectors; the mouse is parked afterwards. -
--headed— escalation for bot-managed sites: headed stealth real Chrome. -
--locale <tag>— pin Accept-Language + context locale (geo-redirecting sites capture a different locale per run otherwise).
visual-diff also: --out <dir>, --sections a,b (per-section screenshots).
A bot-management challenge/blocked interstitial on either navigation fails LOUD with
exit 3 — it is never measured as the source. Escalate with --headed; if that is still
blocked, the site needs crawl.mjs-class capture and the check cannot run headless.
A plain (non-challenge) HTTP error on either side — e.g. a 404 build side, normal on aem.page before preview propagation — is NOT fatal: the probe logs a loud warning, measures the error page, and the flags (BLANK RENDER / content asymmetry) carry the signal with exit 0. That is the probes' advisory contract: 0 = ran (flags advisory), 1 = probe error, 3 = bot challenge.
Reading content-diff
- 🔴 MISSING CTA / HEADING / EYEBROW — real dropped content. FIX. A missing eyebrow is most often a segmentation drop where the eyebrow precedes its heading; a missing CTA means the component never rendered the link. These are exactly what the pixel probe cannot see.
- 🔴 ROLE SWAP — same text under a different role (body painted as eyebrow, eyebrow folded into a teaser). FIX the component's node segmentation.
- 🔴 MISSING PLACEHOLDER / ARIA-LABEL, MISSING ICON, ICON DIFF on an interactive element (an input, a button, a link or inside one) — a dropped localized search placeholder, a share link without its aria-label or icon, a wrong flag in a locale link, an empty icon box. FIX. Outside interactive elements the same findings are 🟡.
- 🟡 MISSING TITLE — always 🟡: a title tooltip is not read by most assistive tech and is commonly dropped by design. 🟡 ICON MOVED — the same icon (glyph code point / file name / svg signature) at another anchor, paired by identity and document order after the anchor passes (typically a text-less
<button>host that anchors asbutton#n): the icon is present, CONFIRM its placement. 🟡 ICON KIND (glyph vs svg vs image — the pixel probe judges) and every EXTRA. - Every finding line names its root —
🔴 MISSING PLACEHOLDER [header]: …— and the report prints one block per root (root "main",root "header", …; a root a side lacks is measured as empty there and the line says which side). - 🟡 MISSING BODY / EXTRA — body prose dropped, or build copy with no source. Usually a placeholder→real-copy rewrite. CONFIRM intended; don't blindly "fix".
- 🟠 FONT FORK — matched lines whose rendered FACE differs (width probe, never
document.fonts.check).source X→sysmeans the prototype named font X but never loaded it and fell back to system — the build self-hosting the intended fallback is then CORRECT, not a bug. All forked lines are grouped into one advisory. - Known limitation — node-granularity JOIN/SPLIT reads as 🔴 (#87). When the source renders one text run as N sibling nodes and the build renders the same text as ONE node (or vice versa — e.g. three fact chips vs one combined chip span), the diff currently reports MISSING + ROLE SWAP + EXTRA for what is a non-defect. Until concat-matching lands (a source node that is a substring of a same-region build node → 🟡 JOIN/SPLIT advisory), verify a 🔴 whose texts concatenate into an EXTRA finding's text before treating it as dropped content — confirmed-justified is a pass.
Pass bar: visual red flags none/justified AND content-diff 0 structural 🔴 (🟡/🟠 confirmed intended). Re-run BOTH after each fix.
Profiles
skills/diff/scripts/diff-profiles.mjs holds them. A profile supplies the source/target labels,
per-flag remediation hints, the font-delta threshold, the default content-root
selector, and the eyebrow classifier thresholds. The engines carry no stack
strings.
eds(default) — Edge Delivery / DA remediation language + the stardustdeployskill's finding numbers.generic— neutral source/build language for any stack.
Add a profile by copying generic in diff-profiles.mjs and editing hints.
Shared engine + the in-loop sibling
The structural probe's classifier + differ live in skills/diff/scripts/content-inventory.mjs
(and a synced copy in skills/deploy/scripts/content-inventory.mjs that the deploy gates import
locally so they don't depend on this skill — keep the two copies in sync until consolidated).
They measure with the same instrument as two gates of the stardust deploy skill:
section-schema.mjs (the pre-code ENCODE/DECODE contract, deploy #93) and block-roundtrip.mjs
(the in-loop per-block gate, deploy #94 — the same inventory diff, run per block at authoring time
against a local decorate() harness, no DA needed, exit-code gated). Run the in-loop gate while
converting; run THIS skill's two probes as the final post-deploy proof. A defect first found here
that the in-loop gate passed = the delivery pipeline reshaped the content in transport — fix the
block's flattened-shape fallback, not the authoring.
Workflow use
Call both scripts in a validation phase and gate on the output. The
the stardust deploy skill's conversion workflow Validate phase runs both after building
a local harness; mirror that:
- Build/serve the decorated build page (e.g. a local QA harness, or the branch preview).
visual-diff … --profile eds→ fix STRETCHED/FLUSH-LEFT/SURFACE-GROUND/GAP flags (unless justified).content-diff … --profile eds→ fix every 🔴; confirm 🟡/🟠.- Loop until visual none/justified AND content-diff 0 structural 🔴.
Naming note: this skill ships in the
stardustplugin and is invoked as the stardustdiffskill. It pairs with the stardustdeployskill, whose Step 10 runs both probes as its Validate gate.