diff

v2026.09.24

Reconcile a converted/built web page against its source prototype with two complementary probes — a PIXEL/layout diff (stretched images, dropped wraps, blank renders, colour flips) and a STRUCTURAL content+typography diff (dropped/mis-slotted headings, eyebrows, CTAs; rendered-face font forks). Stack-agnostic via profiles (eds | generic). Use after converting a prototype to EDS/AEM (the stardust `deploy` skill Step 10), or for any prototype↔build fidelity check; invocable as the stardust `diff` skill and from workflows.

GitHub
Install command
npx skhub add adobe/diff
Markdown
SKILL.md

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 deploy skill'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

ProbeScriptSeesBlind to
Pixel / layoutskills/diff/scripts/visual-diff.mjsstretched 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 + typeskills/diff/scripts/content-diff.mjsMISSING / 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 defaultgeometry / 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 shared defaultWaitUntil in scripts/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-until overrides all three tiers.

  • --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 as button#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→sys means 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 stardust deploy skill'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:

  1. Build/serve the decorated build page (e.g. a local QA harness, or the branch preview).
  2. visual-diff … --profile eds → fix STRETCHED/FLUSH-LEFT/SURFACE-GROUND/GAP flags (unless justified).
  3. content-diff … --profile eds → fix every 🔴; confirm 🟡/🟠.
  4. Loop until visual none/justified AND content-diff 0 structural 🔴.

Naming note: this skill ships in the stardust plugin and is invoked as the stardust diff skill. It pairs with the stardust deploy skill, whose Step 10 runs both probes as its Validate gate.

Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

Apache-2.0

Source path

plugins/stardust/skills/diff

Default branch

main

Latest commit

e26e61d

Tree SHA

b0267ec