Skip to content

Near-match scorecard contract (ADR-0009 §6, Slices 4 & 6)

The metric that operationalises ADR-0009's promise — a replatform "reads as the same site" — into a reproducible, unsupervised ship / hold / manual-polish verdict. It is the ship/hold gate §6 calls for and the precondition for Slice 4 (GenericSection + structural metrics as the gate) and Slice 6 (calibrate the pass rate on a 30–50-site batch).

Designed 2026-07-07 from two independent, blind parallel studies (deep-reasoner + Codex, briefed identically) that converged on ~90% of the design; this contract is the synthesis. Step 0 (the S axis) is shipped + validated on the two deployed sites — see Status.

Sibling contracts: the site-level aesthetic token contract and the per-section section-style contract define what we emit; this one defines how we measure whether it worked.


The crux call (read this first)

Three judgment calls settle the design; each was independently reached by both studies (except where noted):

  1. A measured-feature scorecard, NOT a literal screenshot diff. ADR-0009 §6 says "screenshot-diff scorecard"; taken literally that is the one thing that cannot work. A perfect replatform still diffs non-trivially against live because fonts are legally substituted (Wix private webfonts can't ship) and decorative parastorage CSS is stripped (Wix IP) — so raw pixel distance has a large, per-site-variable irreducible floor that fails good builds and passes bad ones, and it can't localise a fix to an owner. Pixels survive only as a masked, structure-first guardrail (G_det), never the primary score.

  2. Deterministic-first; the LLM never gates. A ship/hold gate must be reproducible. verify-design.js-style Gemini scoring drifts run-to-run, so it is confined to triage (ranking the manual queue, sampling deterministic/LLM disagreements, assisting batch labelling) and is excluded from the composite and the gate.

  3. Fidelity requires an INDEPENDENT re-measurement of OUR output — the load-bearing insight (deep-reasoner). The per-section records in section-backgrounds.json are the input to our render. Comparing them to what we stamped is an identity (imageSideimagePosition by construction) and measures nothing. So we run the same capture probe against our deployed page to get a symmetric section-backgrounds.ours.json and diff live-vs-ours. A tone/imageSide/columns agreement then proves the prop reached the rendered DOM and the CSS actually painted — not merely that the seed carried the value. (Codex's design compared live-capture to stored seed/assembly metadata, which cannot see render-path failures; this contract adopts the re-capture.) Step 0 immediately proved the value: it caught tone:muted→default on the garvanbay CMS home — bands that were stamped but do not paint — which a compare-to-stamp check would have scored as perfect.


1. The metric

A 0–100 Near-Match Score (100 = indistinguishable within the bounded, legally/editably allowed skeleton — not pixel-identical). Composed of three deterministic axes plus one advisory:

Axis Weight (seed) What it measures Determinism
S structural 0.50 section presence, order, band/tone sequence, image side, columns, alignment, density, GenericSection/matcher-gap rate deterministic
T theme 0.25 h1/h2/h3 size + weight + rule-applied; primary/accent/foreground/background placement; brand source/confidence; fallback flags deterministic
G_det masked perceptual 0.25 font/decoration-robust structural image diff (block-occupancy grid + low-freq hue map), per section crop deterministic (no model)
G_llm gestalt excluded from gate one rubric-locked structured call per critical page: {yes\|needs-polish\|no} + reason advisory (triage only)
composite = 0.50·S + 0.25·T + 0.25·G_det        # ranks sites; NOT the sole gate

Weights are calibration parameters (seed values above), re-fit on the batch (§4). The composite orders the queue; the gate does not trust it alone — see the hurdle+veto model in §4.

Section-weighting (§6)

Every section carries a role weight so a broken hero can't hide behind a dozen fine body sections. Roles are cheap: on our side from the canonical block type; on live from position + heading + the chrome header/footer bands.

Role Weight (seed)
hero (first content band) 1.6
header / primary CTA 1.3–1.4
footer 1.2
content / service-grid / gallery 1.0
FAQ 0.75
blog tail / ribbon strip 0.5

Unit & aggregation

section → page(viewport) → site. Desktop (1440×900) and mobile (390×844) are scored independently; the gate takes the worse viewport (mobile collapse is a real failure, not something to average away). Page = a weighted blend of its sections + page/site-global T. Site = worst-of on critical pages (home + one exemplar per distinct template signature) plus fraction-of-pages-passing, with homepage weighted 2×.


2. Per-component computation (evolve what exists, don't greenfield)

The symmetric-capture spine — S (built; cheapest; highest signal)

Run the existing lib/capture-section-backgrounds.js against both URLs:

  • LIVE Wix → section-backgrounds.json (today's artifact).
  • OURS deployed → section-backgrounds.ours.json, via the new --base <url> --out <path> flags (additive; default behaviour unchanged).

lib/structural-diff.js (pure) aligns the two and emits S. No LLM, reproducible, runs today.

Deterministic axes — build on what exists

Sub-score Source Status
S (structural sequence + per-dim agreement) new lib/structural-diff.js over the symmetric capture shipped (Step 0)
T typography (clamp-max px vs live, weight, rule-applied) lib/fidelity-harness.js#buildTypographyScorecardlib/theme-fidelity.js shipped (Step 1)
T palette (primary/accent/fg/bg placement, brand source/confidence, fallbackNavy, #000000 remnant) lib/fidelity-harness.js#buildPaletteScorecardlib/theme-fidelity.js shipped (Step 1)
G_det masked perceptual upgrade lib/fidelity-harness.js#buildVisualDelta scaffolded (raw greyscale MAD) → replace

G_det upgrade: the scaffolded delta (resize 320px, greyscale, mean-abs-diff) is too font/decoration-sensitive to threshold. Replace with a structure-first representation per section crop: (a) a coarse block-occupancy grid (luminance-variance cells → where is there stuff) and (b) a low-frequency hue/lightness map (palette-on-which-element). Diff = occupancy distance (catches layout collapse / dropped-moved blocks) + hue-placement (catches palette-on-wrong-element). Glyph shapes and decorative texture wash out at this resolution — exactly the legitimately-divergent surface we must not penalise.

Bounded LLM — G_llm (buy, capped, advisory)

One structured Gemini call per critical page (not per region — that is verify-design.js's cost model and its drift surface). Forced output {verdict, confidence, primaryReason, note}; the rubric hard-codes the legitimate-divergence carve-outs ("ignore font substitution, minor spacing, Wix-only decoration; judge whether it reads as the same business's site"). Used only for queue ranking, disagreement sampling, and labelling assistance — never the gate. Cost-bounded to the critical page set and preferentially the composite "gray band" near τ (≈ one call per site).


3. Structural sub-metrics + alignment

Sub-metrics (per matched section unless noted)

sectionCount · sectionOrder · bandTone (5-enum sequence) · imageSide · columns · align · density (when the classifier ships) · header/topbar (site-level, from chrome) · genericFallbackRate (fraction rendered by the measured GenericSection; a bounded escape hatch but every use is matcher debt — shipped Slice 4b-ii: computed Σgeneric/Σblocks from a build-time {section→matcher} sidecar (section-matchers.json) because the capture can't see block identity; null when no sidecar — unknown, never a bogus 0. GenericSection on a role-weight ≥ 1.3 region also trips the genericFallbackCritical gate veto) · matcherGap (unmatched-live drops, unmatched-ours inserts, props withheld on identity-unknown matches — shipped Slice 4a: a reported projection of debt already in S, not a second penalty. It role-weights every drop/insert and records each ours-missing dim, so debt is explicit and portfolio-rollup-able — the §5 "ceiling events are a deliverable" model extended to matcher debt).

Per-dimension agreement is omit-aware: a dim the live classifier omitted (out-of-gamut/ambiguous) is excluded from the denominator and logged as a ceiling event (§5), never a penalty.

Alignment algorithm

The live section list is noisy; ours is clean. Three stages:

  1. Junk filter (both sides). The live capture's buildSections enumeration (built for tone-seeding, where over-capture is harmless) pulls in Wix widget/chrome pseudo-sections a replatform correctly omits — a <style> block leaking CSS into textHead, social-share bars, video-player strips. These are not content the build "dropped"; counting them as drops tanks S. Filtered from both lists (conservative: social/video noise only when the section has no real heading). Step 0 filtered 6/31/9 such sections on WCP's three pages.
  2. Canonicalise (both sides). Wix emits each section as a near-duplicate pair (responsive/layer doubling): identical normalised heading + tone + style tuple, adjacent, ~50px height delta. Our clean output has no doubling, so without collapse ours looks like it dropped half its sections (garvanbay home: 6 dup pairs). Collapse adjacent same-tone-same-style pairs, including the empty-heading form (an image-based hero h1 has no text; its heading appears in the sibling welcome's textHead) — this is why the hero/welcome pair must merge rather than orphan the hero. Ribbon/spacer strips (short, heading-less) are tagged low-weight, not dropped.
  3. Heading-anchored Needleman–Wunsch global alignment. Content survives replatform 1:1, so heading text is a strong, cheap anchor that sidesteps the id/index mismatch and the Wix duplication. Substitution cost is dominated by heading similarity (Levenshtein ratio), with a textHead + style-tuple fallback when a heading is absent. Gap (insert/delete) cost is a constant; role-weighting is applied in scoring. Output: matched pairs (→ per-dim agreement), unmatched-live = dropped (role-weighted penalty — the expensive structural error), unmatched-ours = inserted (smaller penalty).

S = 100 · Σ(w_role · matchQuality) / Σ(w_role over matched+dropped+inserted), where matchQuality = 0.5·presence + 0.5·dimAgreement (half credit for being in the right place, half for looking right — a seed split, calibratable).

A known capture-noise guard baked into the diff: columns over-fires on live non-grid sections (an image|text split reports cols=2; see known-issues "columns capture over-fires"). It is a bounded prop only on service-grid/team-grid/gallery, so ours is the grid-carrier authority: if ours omits columns, the live value is capture noise → ceiling, not a miss. (The same over-fire affects imageSide on media-backed heroes; Step 0 leaves that as a penalty — the conservative direction, which favours manual review over false-ship — pending block-type awareness. See follow-ups.)


4. The gate + calibration

Hurdle + veto (not a single averaged threshold)

verdict(site):
  if any HARD-FAIL veto                                   -> HOLD   (never auto-ship)
  elif composite ≥ τ_ship AND S≥floorS AND T≥floorT AND G_det≥floorG
                                                          -> SHIP   (unsupervised)
  elif composite ≥ τ_hold                                 -> MANUAL-POLISH queue
  else                                                    -> HOLD   (degrade to house-style)

Vetoes (catastrophic regardless of other axes — averaging hides exactly the crux failure): hero/header/primary-CTA dropped or unmatched; fallbackNavy (brand identification failed); transparentBlackRemnant; GenericSection on a role-weight-≥1.3 region; mobile structural collapse.

Calibration (Slice 6) — thresholds are fit, never hand-picked

  • Labels: on the 30–50-site batch, a human 3-way verdict per site (and per critical page) — ship / needs-polish / hold for "reads as the same site" — plus a one-word reason on "no" (colour / type / layout / missing) to confirm the axes fire on the right cause.
  • Fit: a low-capacity logistic regression on {S, T, G_det} (3 features — deliberately tiny to avoid overfitting ~40 labels) predicts P(human = ship). τ_ship = the cut where precision(ship) ≥ ~0.98 on held-back folds; τ_hold separates needs-polish from no. Vetoes/floors are fit separately by precision/recall (they encode "catastrophic regardless," which a linear model smooths away).
  • Operating point: the errors are asymmetric — a false hold costs ~30–60 min of human time; a false ship costs a client seeing a wrong site + brand damage. So calibrate for high ship-precision, accept lower ship-recall (borderline → humans). As confidence grows, τ_ship can be lowered on re-calibration to shrink the manual budget.
  • Persist the fitted {weights, τ_ship, τ_hold, floors, vetoes} in a versioned calibration.json, stamped into every scorecard (calibrationVersion), so verdicts are reproducible.
  • The manual budget falls out: predicted manual-band fraction × 30–60 min = the staffing number Slice 6 must produce.

Sanity anchor: garvanbay + WCP are already eyeball-validated as good. They are the fixed points the metric must rank high before any threshold is trusted — Step 0 confirms garvanbay at S=96 and WCP at S=69.6, a sensible ship-vs-needs-polish spread. If the known-good sites don't rank high, the metric is miscalibrated, not the sites.


5. Legitimate-divergence handling

The reconciliation is structural, not a fudge factor: we compare bounded render-intent features, not pixels. Legitimately-divergent surfaces are simply not in the feature set; infidelity surfaces are.

Divergence Why it doesn't penalise
Substituted fonts T compares size/weight/scale + hierarchy, never font family or glyph; G_det blurs text regions; the LLM rubric ignores font substitution.
Stripped decorative Wix CSS S ignores decoration; G_det operates on occupancy + low-freq hue; the rubric ignores flourishes.
The house-style skeleton We only compare captured live facts quantised into the same bounded enums the skeleton renders; out-of-gamut live values are omitted → ceiling event, not a failure.
Wix widget/chrome sections The junk filter (§3) removes social bars, video players, <style>-leak pseudo-sections from both sides.

Still caught: wrong colour placement (G_det hue-map + T fallbackNavy + tone-sequence in S), wrong order (S transposition), dropped section (S role-weighted drop), flipped image side / wrong column count / wrong alignment (S per-dim disagreement).

Ceiling events are a deliverable, not an error. Omitted/out-of-gamut dimensions roll up per-site and portfolio-wide, giving the owner the ADR-0009 §3 decision input directly: a dimension that omits on many sites and correlates with human "needs-polish" is an add-a-dimension candidate; one that omits rarely is accept-the-gap; a high-value site that plateaus is manual-polish. This is how the metric "tells us where the skeleton plateaus."


6. Output artifact

builds/<domain>/structural-diff.json today (S axis); it grows into fidelity-scorecard.json (all axes + verdict). Shape:

{
  "domain": "...", "calibrationVersion": "...", "verdict": "ship|manual-polish|hold",
  "composite": 86.4, "axes": { "S": 88, "T": 95, "G_det": 79 },
  "vetoes": [], "ceilingEvents": [ { "dim": "density", "count": 4, "reason": "not-yet-shipped" } ],
  "structural": { "matcherGap": { "droppedLive": {"count":3,"weight":2}, "insertedOurs": {"count":5,"weight":2.5},
                  "propsWithheld": {"count":4}, "byRole": {"content":{"dropped":1,"inserted":5},"ribbon":{"dropped":2,"inserted":0}},
                  "genericFallbackRate": null } },   // Slice 4a; genericFallbackRate deferred → 4b
  "llm": { "verdict": "yes", "confidence": 0.8 },          // advisory
  "thresholds": { "tau_ship": 0.72, "floors": { "S": 70, "T": 80, "G_det": 55 } },
  "pages": [ { "slug": "home", "viewports": { "desktop": { "score": 87,
    "sections": [ { "role": "hero", "weight": 1.6, "matchStatus": "matched",
      "dims": { "tone": {"live":"brand","ours":"brand","agree":true}, "imageSide": {…} } },
      { "role": "content", "matchStatus": "dropped-live", "penalty": 1.0, "owner": "lib/cms/transformer.js" } ] },
    "mobile": { … } } } ],
  "manualQueue": [ { "page": "home", "section": "…", "issue": "tone-not-painting",
                    "owner": "…", "impact": 1.6, "thumbs": { "live": "…", "ours": "…" } } ]
}

Drives the gate via verdict+vetoes; drives the manual queue via manualQueue, sorted by role-weight × disagreement, each row carrying the likely owner (reuse verify-design.js's owner map) and before/after section thumbnails — so a human spends 30–60 min on the highest-impact fixes.


7. Build order

Step Adds Reuses Status
0 symmetric re-capture (--base/--out) + lib/structural-diff.jsS capture probe shipped 2026-07-07
1 T axis (lib/theme-fidelity.js) + combined lib/scorecard.js emitter (S+T, partial composite) fidelity-harness.js shipped 2026-07-07
2 G_det axis (lib/perceptual-diff.js): full-page --shots + rect.y, section-crop structure-first diff (coarse colour+luma grid), wired into scorecard.js probe shots + structural-diff#alignPage shipped 2026-07-07
3 full composite + hurdle/veto GATE (verdict); enforce the collected veto flags lib/scorecard.js (extend) shipped 2026-07-07
4a (Slice 4a) matcherGap roll-up into S (drops/inserts/props-withheld, role-weighted, per-site + per-role rollup) — the scorecard now measures matcher debt explicitly structural-diff.js ops shipped 2026-07-08
4b-i (Slice 4b-i) measured GenericSection renderer (both lanes) + closes the CMS-lane silent-drop matcher loud-report shipped 2026-07-08
4b-ii (Slice 4b-ii) genericFallbackRate sub-metric (build {section→matcher} sidecar → Σgeneric/Σblocks) + genericFallbackCritical gate veto structural-diff.js rows + scorecard-gate.js shipped 2026-07-08
5 (Slice 6) batch 30–50 sites; human labels; fit calibration.json; bounded LLM triage; set manual budget all above planned

Both lanes share the probe + structural-diff.js (lane-neutral), so the static lane is a drop-in once it consumes capture (its known deferral) — MVP on CMS, no rework.


Status

Step 0 shipped 2026-07-07. lib/capture-section-backgrounds.js gained additive --base/--out (default crawl unchanged; base stamped into output). lib/structural-diff.js (pure) implements junk-filter → canonicalise → heading-anchored NW alignment → omit-aware per-dim agreement → role-weighted S. First cross-site numbers, both against the live deployed CMS workers:

  • garvanbay (emdash.dcathal.org) home: S = 96 — 9/9 sections matched (6 Wix dup-pairs collapsed, 0 dropped), the only deductions two real tone:muted→default misses.
  • waterfordcountypainters (wcp.dcathal.org) index/gallery/commercial-painting: S = 69.6 — real findings surfaced: CTA tone:brand→default, imageSide not rendered on several content sections, an imageSide:right→left flip, a couple genuine content drops.

Step 2 shipped 2026-07-07. lib/perceptual-diff.js implements the G_det axis: the probe gained --shots <dir> (full-page screenshot per page) + rect.y (document-absolute section top); G_det reuses structural-diff#alignPage (identical junk-filter → canonicalise → NW alignment), crops each matched section from both full-page shots by rect.y/h, and compares them on a coarse 20×28 grid (per-cell RGB colour-placement 0.6 + luma-structure 0.4). The coarse grid is the mask — glyphs/decoration wash out (substituted fonts don't penalise), band placement + gross layout survive. scorecard.js now emits all three axes + a weight-re-normalised partial composite (still not a verdict — the gate is Step 3). Three-axis numbers (same-version re-capture with shots):

Site S T G_det partial composite
garvanbay (home) 96 100 80.8 93.2
WCP (index/gallery/commercial-painting) 59.9 100 70.9 72.7

G_det adds orthogonal signal: it flags the garvanbay hero (33% cell-sim) and WCP's commercial-painting FAQ (25%) as the largest visual divergences — gaps S can't see. WCP's S fell 69.6→59.9 vs Step 0 because the fresh same-version re-capture is more complete: it now measures align/columns on WCP's real service-grids (which garvanbay lacks), surfacing genuine columns:3→4 and align gaps — the first time the columns dimension is exercised against real grids.

Step 3 shipped 2026-07-07. lib/scorecard-gate.js (pure) implements the §4 hurdle+veto ladder — computeVerdict({axes, vetoes, calibration})verdict: ship|manual-polish|hold + reasons — and collectStructuralVetoes(pages) (a live hero/primary-CTA dropLive at role-weight ≥ criticalRoleWeightcriticalSectionDropped → HOLD). lib/scorecard.js enforces the collected theme flags (fallbackNavy, transparentBlackRemnant) + structural vetoes and stamps verdict/composite/thresholds/calibrationVersion into fidelity-scorecard.json. Thresholds live in versioned lib/calibration.json (calibrationVersion: seed-2026-07-07, fitted: false) — SEED values on the 0–100 composite, provisional until the Slice-6 logistic fit replaces τ_ship with a P(ship) cut: τ_ship 85, τ_hold 60, floors S≥70 / T≥80 / G_det≥55. A missing axis can never ship (the floor check fails on null → manual-polish/hold at best). Verdict contract locked by lib/scorecard-gate.validate.mjs (19 fixture checks: veto-beats-composite, floor demotion, partial-axes cap, boundary semantics, structural-veto collection). Anchor verdicts under seed thresholds: garvanbay SHIP (93.2, floors met), WCP MANUAL-POLISH (72.7, S=59.9 fails the S-floor) — the expected ship-vs-needs-polish spread. Note: an S-floor failure with composite ≥ τ_ship is arithmetically impossible under the seed weights (T,G ≤ 100 ⇒ composite ≥ 85 requires S ≥ 70) — the S-floor becomes load-bearing only if the weights are re-fit. Also: fidelity-harness.js's own standalone report now writes fidelity-harness.json (was clobbering fidelity-scorecard.json with a differently-shaped artifact).

Step 4a shipped 2026-07-08 — matcherGap roll-up into S. lib/structural-diff.js now emits a matcherGap field per page (diffPage) and site (diffSite), surfaced through lib/scorecard.js into the structural block of fidelity-scorecard.json. It is a reported projection of debt already in S (S is byte-identical before/after — the change is purely additive), NOT a second penalty: droppedLive and insertedOurs roll up the same role-weighted alignment ops S already scores, and propsWithheld counts each matched section's ours-missing dim (a live-present prop ours failed to carry — columns-na/live-omit ceilings excluded). byRole gives the per-role drop/insert breakdown (the add-a-matcher signal); genericFallbackRate stays null (deferred → Slice 4b). Locked by lib/structural-diff.validate.mjs (now 53 checks, +11). Anchor numbers stable — garvanbay SHIP (S=96, matcherGap 0/0/0, clean) · WCP MANUAL-POLISH (S=60.9, matcherGap 3 dropped w=2 / 5 inserted / 4 props-withheld, byRole content 1d·5i, ribbon 2d). The criticalSectionDropped veto (Step 3) reads the same drop rows and is unchanged — no drop lands at role-weight ≥ 1.3, so WCP stays manual-polish not hold, coherent with its debt being 1 content + 2 ribbon strips. Slice 4a is metric-only (no render, no deploy); the measured GenericSection that reduces this debt is Slice 4b.

Step 4b-ii shipped 2026-07-08 — genericFallbackRate + the GenericSection gate veto. A build-time {section→matcher} sidecar (builds/<domain>/section-matchers.json, {order,heading,matcher,type} per emitted block) is written by lib/cms/transformer.js (returns a manifest) + lib/cms/seed-from-build.js (collects per page, writes alongside seed.json) — the only record of which matcher produced each rendered block, since the capture sees painted DOM, not block identity. lib/structural-diff.js computes genericFallbackRate = Σgeneric/Σblocks straight from the build counts (the ground truth of what was emitted, not re-derived from the capture; null when no sidecar — unknown, never a bogus 0) and tags each ours row with oursMatcher via a heading-first / content-order join (makeMatcherLookup), bridged across the home/index/homepage slug aliasing (manifestForSlug). S is byte-identical — the tag and the rate are additive, so this is a projection like matcherGap, not a new penalty. lib/scorecard-gate.js#collectGenericFallbackVetoes flags a GenericSection block at role-weight ≥ criticalRoleWeight (1.3) → genericFallbackCritical → HOLD, mirroring criticalSectionDropped on the same rows; wired through lib/scorecard.js. Locked by structural-diff.validate.mjs (rate/join/rollup/alias + additive-S) and scorecard-gate.validate.mjs (veto collection). Anchors stable: garvanbay SHIP gfr=null (no sidecar — its build is on EC2), WCP MANUAL-POLISH gfr=0 (sidecar present, 0 fallback used), 22 ours rows tagged (join fired incl. the homepage after the alias fix). The nonzero rate + veto were proven end-to-end on the real WCP capture by forcing a synthetic GenericSection hero (gfr=0.031, genericFallbackCritical fires on index/hero/w=1.6), then reverting. Both genericFallbackRate and matcherGap are now live; the S axis is feature-complete for the seed batch. See known-patterns "genericFallbackRate is a build fact, not a capture measurement".

Step 1 shipped 2026-07-07. lib/theme-fidelity.js folds fidelity-harness.js's typography + palette reports into a graded 0–100 T axis (size-proximity + weight + rule-applied for h1/h2/h3; palette penalised for fallbackNavy/low-confidence/#000000 remnant, which also emit gate veto flags). lib/scorecard.js is the combined emitter → fidelity-scorecard.json with axes {S, T} + a clearly-labelled partial composite (S+T re-normalised; not a ship/hold verdict — the gate is Step 3) + collected veto flags. Both sites: T = 100 (garvanbay surface/high, WCP logo/high — no veto flags), partial composite garvanbay 97.3, WCP 79.7. T is saturated on these two well-built sites by design; it discriminates on the wider batch via the fallbackNavy veto.

Globals (chrome) reporting projection shipped 2026-07-12 pt2. lib/globals-diff.js compares the LIVE chrome vs an independent re-measurement of OUR deployed chrome (header/topbar/footer background + headerLayout / headerCta) → an additive globals block in fidelity-scorecard.json (S/T/G_det byte-identical; lib/globals-diff.validate.mjs 36 checks). It closes the "header/topbar/footer are excluded from every axis" blind spot as a REPORTING projection (like variantMismatch / the asset-manifest), realising the §3 "header/topbar (site-level, from chrome)" sub-metric in reporting mode. Every dim is null-safe (agree true|false|null; null = not compared), so on a pre-header-slice v2 capture headerLayout/headerCta report n/a and only the background/presence dims compare. On the v3 re-capture (2026-07-12 pt3) they carry real numbers: WCP headerLayout agree (split-center = split-center, our deployed header matches live); headerCta mismatch (live null — the SERVICE AREAS pill is pinned outside <header>, fast-follow #2 — vs ours SERVICE AREAS). Gate vetoes + thresholds → Slice 6.

Findings the scorecard surfaced (site-fidelity, not metric bugs)

  • garvanbay CMS home renders default where live is muted on the MANAGEMENT ACCOUNTING + Accounts Preparation service teasers — the alternating grey bands were stamped but do not paint on the deploy. (Caught only because we re-measure the deployed DOM, not the stamp.)
  • WCP CTA/brand bands render default and several WCP content sections don't render their side image (imageSide omitted vs live) — WCP was deployed as a separate worker; its D1 content likely predates/omits the measured tone/imageSide stamping.

These are inputs to the manual-polish queue, not chased in Step 0 (Step 0 is the metric, not the fixes).

Follow-ups

  • Text-contrast probe (proposed 2026-07-09, from the operator-caught hero defect). The black-subtext-on-dark-scrim hero shipped under a SHIP verdict because no axis converts "readable text over hero media" into a gate signal: G_det's coarse colour+luma grid deliberately washes glyphs out (a text line flipping white→black barely moves it), and T compares emitted tokens, not the deployed DOM's computed styles. Cheap deterministic addition: the capture already records per-section heading/para computed colour (_styleRaw .textColor, 2026-07-09) on BOTH live and ours captures — diff the classified textTone live-vs-ours per media section (a mismatch = flag), and/or a WCAG-style contrast ratio of text colour vs the section crop's mean colour under it (a sub-threshold ratio on a role-weight ≥1.3 section = veto candidate). Fold into Slice 6 calibration so thresholds are fitted, not hand-set.
  • imageSide over-fires on media-backed heroes (same class as the gated columns over-fire). Left as a penalty for now — the conservative direction — pending block-type awareness in the diff so N/A can be distinguished from a genuinely dropped image.
  • G_det outliers triaged 2026-07-08 — mixed: one genuine, two alignment mis-pairings. Cropped the flagged sections from the on-disk live/ours shots (offline, no re-crawl) and eyeballed paired live|ours crops against healthy controls (garvanbay PAYROLL sim=93 is a clean like-for-like — the crop path itself is sound). Findings:
  • WCP commercial-painting FAQ (25%) — GENUINE. Ours renders the FAQ on a dark navy background (dark-on-dark, barely legible) where live is white — a real wrong-tone/colour-placement defect (same class as the WCP tone gaps already logged; D1 content predates the tone stamping). A "Loading…" spinner in the crop hints the accordion is lazy/JS-rendered, so a minor timing component isn't excluded, but the navy-vs-white background is the dominant, genuine signal. G_det is doing its job here.
  • garvanbay hero (33%) — ALIGNMENT ARTIFACT (+ a real gap S can't see). The hero/welcome heading-dup-pair collapses to one canonical section, but the collapse keeps the welcome rect (liveH=536, intro paragraph + 3 photos) to represent role=hero — so G_det compares live's welcome band against ours' hero band (a 168px dark-blue "GARVANBAY ACCOUNTING" strip): apples-to-oranges → 33%. Underneath, there IS a genuine divergence (ours has no welcome intro+images band at all), but S scores it 1↔1 matched because the pair collapsed — so the low G is right for the wrong reason.
  • WCP content #1 (37.8) — ALIGNMENT ARTIFACT. A second live "Commercial Painting" hero-dup instance is NW-aligned to ours' partner-logo strip (two unrelated sections paired because headings/positions line up) → low sim on a w=1 role.

Determination / gate-readiness. G_det penalises real divergence correctly (FAQ), but it is more sensitive to alignPage mis-pairings than S is — S compares quantized style tokens (which can coincidentally agree on a mis-paired match) while G_det compares pixels (which won't). So enabling G_det as a gate surfaces alignment-quality problems S tolerated, and they land on high-weight roles (hero w=1.6, content w=1), materially dragging the composite. Root cause is in structural-diff.js alignPage/canonicalise (shared with S), NOT in perceptual-diff.js#cropGrid (the crop is faithful to what alignPage matched). Two mechanisms: (1) dup-pair collapse keeps the wrong rect (garvanbay: welcome rect represents hero); (2) NW alignment pairs a leftover live dup against an unrelated ours section (WCP hero-dup ↔ logo-strip). Before G_det's floor gates unsupervised, fix upstream in alignPage: prefer the hero-band rect on a hero/welcome collapse; reject/flag matches with a large height-ratio + heading dissimilarity; optionally emit a per-match confidence so G_det can N/A low-confidence pairs (a perceptual-diff-side height-ratio/confidence gate is a cheaper fallback but only masks the symptom). This dovetails with the still-missing structural-diff.js .validate.mjs below — lock the collapse/align behaviour with fixtures while fixing it. Triage script: scratchpad gdet-triage.js (reuses alignPage + comparePair, writes side-by-side crops).

RESOLVED 2026-07-08 — both alignment artifacts fixed at the root (canonicalise). Two changes in lib/structural-diff.js#canonicalise: (1) a bothEmptyDup collapse form (adjacent, same tone/style, identical normalised textHead, both headings empty) — Wix doubles image-heroes and brand ribbons with empty headings, which the prior heading-keyed detector missed, letting a leftover twin get NW-force-matched to an unrelated ours section (WCP hero-dup ↔ logo-strip); (2) anchor/visual-rect decoupling — a collapsed unit keeps its heading anchor from the heading-bearing member but its rect from the earlier (visually-first) member, so role=hero crops the hero band, not the welcome band (garvanbay). Results: garvanbay hero G 33.4→63.6 (honest hero-vs-hero; S unchanged 96, composite 93.2→94.7, still SHIP); WCP spurious match gone (ours logo-strip now a clean insertOurs), twins single-penalised (commercial-painting S 68.3→72.3, site S 59.9→60.9, composite 72.7→73.7, still MANUAL-POLISH); WCP FAQ stays 24.9 (genuine, untouched). The aligner's cost model (GAP/subCost) was deliberately not touched — the pre-pass collapse is what removes the force-match. Locked by lib/structural-diff.validate.mjs (42 checks). See known-patterns "A section-alignment collapse serves TWO axes with different needs". G_det is now gate-ready on artifact grounds; its floor still awaits the Slice-6 calibration like the other axes. Residual (separate follow-up): the hero/welcome collapse still hides a genuine intra-pair drop from BOTH S and G (garvanbay renders no welcome intro+images band; S counts it 1↔1 matched) — catching intra-collapse content drops is a distinct S improvement, not chased here. - Junk-filter misses a "Watch Now" video-chrome strip on WCP index (surfaced by Slice 4a's matcherGap, which made every S drop legible). isJunk only tags a heading-less video strip on Now Playing|Play Video; WCP's "Watch Now" strip has a heading, so it survives as a ribbon-role dropLive (w=0.5). Pre-existing and low-impact (S already counted it; matcherGap only named it), but the junk-filter regex should widen to the "Watch Now" form (still heading-gated to stay conservative). Not chased in 4a (metric-only). Same class as the deferred GenericSection decision: distinguish Wix chrome to drop from content to render. - G_det is colour+luma grid, not yet an explicit occupancy/edge term. The coarse downscale approximates the contract's "block-occupancy grid" (variance washes into cell luma) but doesn't compute a dedicated edge-density map. Adequate for MVP; add an explicit gradient/occupancy term if colour+luma proves too forgiving of layout collapse in calibration. Also no cookie-banner/fixed-chrome masking yet (the probe dismisses the consent banner and excludes header/footer, so low-risk). - T re-measures emission, not the deployed DOM. theme-fidelity.js compares our emitted theme (from theme.json) against live _raw — it checks the derive+emit pipeline, not the deployed page's computed h1/h2/h3 sizes+colours (unlike S, which re-measures the deployed output). A deployed-computed-style probe would make T consistent with the S philosophy; deferred (Step 2+). - .validate.mjs: structural-diff.js done (2026-07-08), theme-fidelity.js still missing. lib/structural-diff.validate.mjs (42 checks) now locks canonicalise (all three dup forms + anchor/visual-rect decoupling + non-mutation), align (match/drop/insert + the force-match cost model), scoreMatch (agree/disagree/ours-missing + live-omit + columns-na ceilings), isJunk, and roleOf — per the repo convention (known-patterns "Lock current behaviour as expected behaviour"). theme-fidelity.js still has no validate script; add one before its T-floor is trusted unsupervised. - Ours-side slug coverage: Step 0 captured only home/index (+2 WCP pages). The CMS deploys serve a subset of live slugs; per-site the scorecard should capture the intersection and report live-only slugs (already surfaced).