Skip to content

ADR-0009 — Target output: measured near-match over an editable canonical skeleton

Status: Accepted Date: 2026-07-06 Deciders: Cathal Dempsey Consulted: two independent parallel architecture studies this session — Fable ("measured-token hybrid") and Codex ("faithful-layout reconstruction"), each briefed identically and blind to the other. Related: ADR-0001 (fulldev canonical blocks), ADR-0002 (house-style acceptance — this ADR raises its fidelity bar), ADR-0003 (assembler-fulldev), ADR-0004 (CMS adapters wrap the canonical blocks), ADR-0007 (section tone — the mechanism this generalizes), ADR-0008 (per-site CMS worker fleet).

Context

The replatform's core promise is that a client's new site looks like their original. A side-by-side of current output (waterfordcountypainters) shows it does not near-match: the pipeline collapses each page into a fixed vocabulary of canonical fulldev blocks that impose a shared house style, discarding the original's layout, bands, and spacing.

That house style was a deliberate ADR-0002 choice (scale over fidelity for the tier-1 long tail). The owner has now raised the fidelity bar to near-match (2026-07-06), which revises ADR-0002. Two further drivers landed at the same time: a goal of clean semantic HTML + external CSS output, and incoming Wix API access for content/media.

One constraint shapes the whole decision: self-service editing is a hard requirement — site owners edit their own sites through the CMS fleet (ADR-0008). The render must therefore map to a stable, editable schema (emdash Portable Text over a fixed block vocabulary), which bounds how far toward per-site bespoke layout we can go.

Both studies converge on the direction and split on one thing: how much of the canonical-block renderer stays as the default.

Decision

Adopt measured near-match over an editable canonical skeleton — the converged direction, with the renderer fork resolved toward evolving the canonical blocks because self-service is a hard requirement.

1. Fidelity bar (revises ADR-0002)

Target structural near-match — a side-by-side reads as the same site: section order, background bands, palette applied to the right elements, typography scale, image placement, column counts, density. Explicitly not a pixel/DOM clone — that is legally impossible (parastorage CSS is Wix IP; Wix private webfonts can't ship) and forfeits the unsupervised-scale property the business rests on.

2. Rendering — the fork, resolved: evolve, don't rebuild

Keep the canonical block library as the editable semantic skeleton (this is what preserves the CMS fixed-schema → self-service). Close the fidelity gap with measured, bounded parameters, generalizing ADR-0007's tone mechanism to every design dimension:

  • Site-level design tokens → generated per-site external CSS. Execute ADR-0002's deferred "path 1" portfolio-wide: lift the hard-coded aesthetic (radius, button shape/border, spacing scale, heading accents, shadows) out of primitives/blocks into CSS custom properties, with today's fulldev look as the fallback default, not the design. emitGlobalCss grows into an emitSiteCss populated from capture.
  • Section-level captured style → bounded enum/quantized props stamped into block data (alignment, image side, column count, density, header/topbar treatment) and threaded to blocks as props — the ADR-0007 tone slice repeated per dimension, joined by the comp-id/heading/positional strategy already built in apply-section-tones.js.
  • A measured GenericSection fallback for unmatched sections: lays out captured text/images in captured order/alignment/tone, loud-reporting every use (ADR-0004) so it doubles as a matcher-gap detector. This is the bounded "reconstruction" element — confined to the residue and to shapes that still map back to an editable block.

Reconciliation principle (write it into every slice): fidelity comes from measured parameters; scale comes from bounded contracts. Every captured value is clamped/quantized into an enum or scale the render lane styles exactly once (ADR-0007's "the capture lane must never emit anything else"). Raw computed-style passthrough would recreate per-site CSS hackery with extra steps and break editability; bounded capture keeps 1800 sites one codebase and keeps every block editable.

3. Where reconstruction is capped by editability

Faithful per-page layout reconstruction (Codex's Layout-IR → semantic-HTML renderer) is not adopted as the default: a bespoke per-site layout has no stable schema for self-service editing, which would force per-site adapter forks and break the CMS fleet. Reconstruction is bounded to what maps back to an editable block/prop. The scorecard decides the ceiling — where measured-tokens-over-skeleton plateaus below near-match on the sample, we either (a) add a new bounded dimension/variant, or (b) accept the gap for the long-tail tier / route high-value sites to manual polish. We do not escalate to unbounded per-site reconstruction for self-service sites.

4. Ingestion — two planes

  • Content plane: Wix API is the content/media source of truth where available (cleaner D1 seed than scraped HTML; clean R2 image provenance per ADR-0005). DOM scrape remains the section-segmentation backbone the matchers run on.
  • Design plane: Playwright rendered capture is the layout/design source of truth (extend capture-layout.js + capture-section-backgrounds.js into per-section style records + site design tokens). The API gives content, never the rendered layout — so capture stays mandatory. Augment, don't replace.

5. Rendering philosophy

Semantic landmarks (<header>/<nav>/<main>/<section>/<footer>, heading hierarchy, JSON-LD) are the SEO-real target — enforce/audit them (cheap, real). Astro already emits external (not inline) CSS. Keep Tailwind for internal structural plumbing, but move the entire themable/design surface into CSS custom properties + generated per-site CSS targeting the existing data-slot/data-tone attribute contract in a higher cascade layer (@layer site > @layer blocks, native in Tailwind v4). This gives per-site design variation with no markup changes, cleanly overridable (no !important wars), identical across both lanes. Do not rewrite the ~46 canonical blocks to utility-free hand-authored CSS — a rewrite + dual-lane regression for ≈zero SEO gain (class names carry no SEO weight).

6. Scale / QA

A screenshot-diff scorecard (per page/viewport, section-weighted: header/hero/footer/CTA above deep body sections) becomes the unsupervised ship/hold gate. Tiered fidelity: automated near-match for the long tail; a manual-polish budget (~30–60 min/site) for high-value or high-diff sites. Prove on a 30–50-site batch — the measured pass rate, not opinion, sets thresholds and the human-touch budget.

Metric design → docs/pipeline/near-match-scorecard-contract.md (2026-07-07, synthesised from two blind parallel studies). It refines "screenshot-diff" into a measured-feature scorecard: a literal pixel diff can't gate (substituted fonts + stripped Wix decoration give a large per-site irreducible floor), so the gate is three deterministic axes — S structural (an independent re-measurement of our deployed output vs live, not a compare-to-stamp), T theme, G_det masked perceptual — under a hurdle+veto model with thresholds fit on the batch; LLM judgment is triage-only, never the gate. Step 0 (the S axis) is shipped: garvanbay S=96, WCP S=69.6.

Alternatives considered

  • Faithful-layout reconstruction as the default renderer (Codex). Highest fidelity ceiling, but forfeits the CMS self-service model (no stable schema), discards the matcher-as-renderer investment, and turns every build failure into a unique failure — the exact unsupervised-scale bottleneck. Rejected as default given self-service is a hard requirement; retained as the conceptual cap for the editable residue, and re-openable per-tier if self-service is ever dropped (e.g. a purely-static long-tail tier).
  • Extend the fixed block library with more discrete variants. Rejected — discrete variants can't span a continuous design space; the fingerprint lives in hard-coded utility compositions, so 40 heroes still impose a look. Continuous measured params are the fix.
  • Copy the original site's CSS. Illegal (Wix parastorage IP). Off the table; measured-value CSS generation (facts about the client's own design) is the legal substitute.

Consequences

Positive

  • Preserves the CMS fleet + fixed schema (self-service intact), the matchers, the capture lane, theme extractor, R2 pipeline — nearly all existing investment. Both lanes keep one shared renderer + CSS generator.
  • Graceful degradation: worst case for any site is today's house-style output plus a loud scorecard report — never worse.
  • Legally clean; weeks of incremental, individually-shippable slices, not a rewrite.

Negative / risks

  • Fidelity is ceilinged by what measured params + an editable schema can express. Layout-diverse sites won't all reach near-match; they degrade to house-style + manual polish (the scorecard quantifies how many).
  • Capture robustness across Wix template diversity is the concentrated risk — expect a clean measured pipeline on ~70–85% of sites initially, tuning upward.
  • Per-site generated CSS is more than a house-style system (accepted cost of fidelity). The near-match promise must be sold as tiered, not universal.

Slice tracker (each shippable)

# Slice Status
1 Tokenize the aesthetic surface (ADR-0002 path 1, portfolio-wide) → CSS vars, current values as defaults; zero visual change. Done 2026-07-06--ring-width, --btn-h/px/gap, --site-shadow-* emitted from emitGlobalCss; zero-change verified by declaration-level CSS diff. Contract: docs/pipeline/aesthetic-token-contract.md. (--hero-radius skipped — stale audit item; decorative ring-2/ring-12 left literal to preserve pixels.)
2a Emit/consume half — emitSiteCss + the design.json bounded-token contract (btn-radius, component radius, section rhythm, elevation) + wire both lanes; --btn-radius tokenized (default 4px == prior literal). Network-free; pure lib/design-derive.js + validate script. Done 2026-07-06emitSiteCss(design) appends an unlayered :root override after emitGlobalCss (source-order wins; an @layer override would lose to the unlayered defaults). Zero visual change absent design.json: declaration-level diff shows only --btn-radius default + two 4pxvar(--btn-radius) swaps (Slice-1 bar). Both lanes verified. scripts/emit-design-json.js derives design.json from stored _raw (empty on pre-2b data → no override).
2b Design-token capture half — extend Playwright capture() to record real button/card borderRadius + boxShadow; re-crawl garvanbay/WCP → real design.json; both lanes emit real per-site overrides. Done 2026-07-06capture() now records borderRadius/borderTopLeftRadius/boxShadow (element-level computed styles, solid). Re-crawl: garvanbay → --btn-radius:0px (square, faithful to the primary "Book an appointment" CTA), --radius:0rem, flat shadows; WCP → --btn-radius:9999px (pill, measured 50px), --radius:0rem, flat. Both lanes verified (CMS emit-theme-css.mjs run; per-site emit.js CSS logic reproduced) — override :root appends after the default, source-order wins. emitSiteCss(null)===''; garvanbay derived theme byte-identical ex-_raw; validate 28/28. Section-band rhythm deferred (see 2b-band below) — section:nth-of-type(2) is a trust ribbon, not a content band, on WCP (67px); the effective-band measure is gated behind a content-band height floor so a ribbon can't emit a bogus --section-py. garvanbay's 2nd section IS a 536px band → 45px → normal (== default, no-op).
2b-band Section-band rhythm + per-section style records/classifiers (alignment, image side, columns, density, header/topbar) into capture-section-backgrounds.js. Split from 2b: has no consumer until Slice 3, and robust representative content-band selection (vs ribbons) is the same classification work. Capture the sectionBand diagnostic (recorded now, sectionHeight+paddingTop); build the classifier when Slice 3 defines the block-prop shape it feeds. In progressimageSide + alignment + columns capture+classifier done 2026-07-07 (in-page _styleRaw geometry — media/text bboxes for imageSide, heading+paragraph text-align for alignment, densest even-width first-row grid for columns — in capture-section-backgrounds.js, schemaVersion: 2 records; pure lib/section-style.js + validate 36/36). density/header + representative-band selection still planned. Full 5-dim contract: docs/pipeline/section-style-contract.md.
3 Thread section-style props (assembler translate + CMS seed transformer + block Props). Start with the loudest gemini-diff items: header/topbar treatment, alignment, image side, columns, density. In progressimageSide + alignment + columns threaded end-to-end on the CMS lane (imageSide+align 2026-07-07; columns 2026-07-07). makeStyleLookup; type-gated stamps in transformer.js + apply-section-tones.js, identity-known matches only (not gap-tone). imageSide → existing imagePosition; alignment → new Section data-align sink; columns → new SectionGrid data-columns sink (services-1) + team-grid own-grid data-columns (gallery already had it), COLUMNS_ELIGIBLE = service-grid/team-grid/gallery, columns select. team-grid + checklist migrated to compose <Section> (columns prerequisite) — minimal-wrap, zero-regression, screenshot-verified live. All deployed + screenshot-verified on the garvanbay demo (imageSide 4/5 corrected; alignment content-2 centered; columns render surface proven via temp D1=3 → repeat(3,1fr) then reverted — garvanbay's team-grid is gap-tone so the automatic stamp is a faithful no-op there). Per-site static lane deferred (Option A) — see known-issues. Hero dims shipped 2026-07-09 (operator-report driven): scrim (live effective-dim: dark overlays above media × effective media opacity over a dark underlay — Wix dims via image-at-.5-over-black, see known-patterns) + textTone (heading colour vs palette → white/primary/accent/foreground) → classifyScrim/classifyTextTone (+24 validate checks), stamped on fcr.hero only via both join paths; hero-4 gained data-text-tone variants, a white-subtext default (fixes the black-subtext-over-scrim defect), and a none scrim tier; both new fields are operator-visible selects. First production run of the committed sync-style-to-d1.mjs (promoted from the scratchpad pattern). garvanbay G_det 88.3→90.8, composite 91.3→92.0, SHIP held. WCP brought onto the full section-style pipeline 2026-07-10 (regen+patch): fresh live re-capture → seed regen through the current pipeline (columns/align/tone/scrim/imageSide + 3b71607 cta-body) → grafted (zero divergence) → redeployed (2358615b) + D1 reseeded. Service-grid columns=3 now renders (scorecard-confirmed); composite 73.7→76.3, S 60.9→65.5, G_det 73.1→74 — still MANUAL-POLISH. Hero side-image variant + WCP image migration shipped 2026-07-10 pt4: the WCP service-page heroes rendered full-bleed dark — NOT a Hero.js bug (the matcher is geometry-blind in the seed path) but a layout-from-capture gap. makeStyleLookup now exposes media; the transformer routes fcr.hero to a reworked hero-1 (text+side image, no scrim) when imageSide && !media, else keeps hero-4; 10 service heroes converged. Separately, WCP's seed had never been image-migrated (raw /assets/ 404s) — ran transform-seed-images.mjs (342 refs → r2:, images already in R2) + reseeded; that surfaced+fixed an object-src crash in LogoStrip/Gallery/Footer adapters (resolveImageRefgetImageSrc). WCP worker 6f2cb2cf; composite 76.3→79.0, S 65.5→70.8 (S-floor cleared), still MANUAL-POLISH below τ_ship (gallery Wix-Pro-Gallery case S=44.4). See known-patterns "The capture plane — not the matcher — is the authority for a hero's full-bleed-vs-side layout" + "An item-image adapter must resolve via getImageSrc". Header layout shipped 2026-07-12 (CMS lane): a bounded layout enum on header-wcpstacked-center (default = today, garvanbay) · split-center (nav flanks a centered logo + optional CTA pill, WCP) — measured from the live header geometry (chrome.headerRaw → pure classifyHeaderLayout, since the matcher is geometry-blind in the seed path, same authority split as the hero variant), threaded transformer→schema→adapter→block, stamped by seed-from-build from the capture. WCP redeployed (worker b4e7a6a4) + surgical ec_globals header patch (layout:split-center + SERVICE AREAS CTA); garvanbay stacked byte-identical (stacked branch verbatim). inline-left (fulldev header-1) recognised by the classifier, routing deferred. inline-left routing SHIPPED 2026-07-21 (the full chrome slice — see below and known-issues "chromeDisagree adjudicated"). See known-patterns "The capture plane is the authority for header layout too". Hero variant shipped 2026-07-12 pt2 (CMS lane): a first-class measured variant enum on fcr.hero (carousel|video|full-bleed|side-image|text, lib/section-style.js#classifyHeroVariant) derived at the seed join from already-persisted bounded capture values (bg.media/imageSide/assets.{slides,videos} — no re-crawl), retiring the brittle imageSide && !media inference in both transformer.js and the CMS Hero.astro adapter; render byte-identical (zero-regression diff = only variant added), carousel/video routing proven locally. Capability #1 build-now half; assets (video migration #7 + carousel frames) stay Slice 5. See known-patterns "A dimension whose inputs are already-BOUNDED capture values derives at the JOIN". TopBar socials fixed 2026-07-12 pt2: a portfolio-wide urlhref field-name mismatch (translate.js#translateTopBar) had left every site's topbar social links empty; one-line reconcile, both lanes. Globals into the scorecard shipped 2026-07-12 pt2 (identify half of capability #2): lib/globals-diff.js — an additive, reporting-only chrome (header/topbar/footer) live-vs-ours projection in fidelity-scorecard.json (S/T/G_det byte-identical, 36 validate checks); null-safe on pre-header-slice captures (headerLayout/headerCtan/a until a v3 re-capture). v3 re-capture + three-half landing 2026-07-12 pt3: a single v3 re-capture of WCP + garvanbay (section-background capture only — it reads theme.json, never re-derives brand, so the brand-nondeterminism landmine never fires) unlocked all three deferred halves at once. (a) Topbar email/location — new chrome.topbarContact extractor (post-JS #pinnedTopCenter contact run; the zero-height container hid it from the bg probe) + fill-if-empty overlay in seed-from-build; deployed to WCP live D1 via surgical ec_globals read-mutate-write (never a reseed — that would revert the capture-invisible SERVICE AREAS CTA, fast-follow #2), rendered-verified (phone·email·location matching live); garvanbay overlay a faithful no-op. (b) Hero variant real measurement — on v3 assets, WCP home = carousel (8 slides; v2 read full-bleed), 10 services = side-image, 13 locations = full-bleed; committed into the WCP seed (24 heroes, surgical, render byte-identical until Slice-5 assets). (c) Globals headerLayout/headerCta — real live-vs-ours numbers now surface: headerLayout agree (split=split, our deployed header matches live), headerCta mismatch (fast-follow #2). See known-patterns "A zero-height pinned container hides its measurable children from the bg probe". Header-CTA auto-detection shipped 2026-07-12 pt4 (fast-follow #2): the header-scoped CTA probe missed WCP's SERVICE AREAS pill because Wix pins it OUTSIDE <header> (in pinnedTopRight). A capture-plane pinned-layer walker now gathers outside-<header> button-styled anchors in the header Y-band (chrome.headerRaw.ctaCandidates) and a pure classifyHeaderCta (lib/section-style.js) picks the winner — guarded by button-styling (separates the pill from unstyled topbar contact links), a nav-row floor (cy ≥ navTop, excludes the topbar row — proven on garvanbay, whose styled "Book an appointment" topbar pill is correctly rejected → garvanbay stays headerCta:null), and a header-band ceiling (excludes hero/floating buttons). Href same-origin-normalised so live (absolute) + ours (relative) compare equal. Two payoffs, one detector: WCP's CTA now auto-detects (matches the committed seed → reproducible-from-capture, not manual) AND the globals headerCta mismatch closes → agree (both live+ours measure SERVICE AREAS → /service-areas; garvanbay null, faithful). CMS lane; S/T/G byte-identical (chrome-additive, reporting-only); +17 validate checks. See known-patterns "A pinned-outside-<header> button IS the header CTA". Header-CTA reproducibility CLOSED 2026-07-13 (the pt4 claim was premature): pt4's detector had only ever been run against a live browser session — the persisted section-backgrounds.json carried no ctaCandidates on any of its 24 pages, so a regen still emitted ctaText: "" and WCP was not safely re-seedable (caught by the pt6 graft check, which correctly refused the write). Re-captured WCP live → ctaCandidates+headerCta on 24/24; the regen now produces SERVICE AREAS → /service-areas from measurement, and the live-D1 stamp came back a no-op (live and regen independently agree — that no-op is the proof). Committed seed corrected. Generalises to a rule for every capture-plane slice: a new in-page probe leaves every existing capture artifact in the fleet stale — re-run and persist the capture, then assert the field is IN the stored JSON. See known-patterns "A capture-plane detector is not shipped until the CAPTURE has been RE-RUN and PERSISTED". Floating dimension shipped 2026-07-13 pt3 (CMS lane) — and it CORRECTED the wiki rather than confirming it. The filed premise ("live is faithful; the matcher guesses a generic default where the capture plane should be measuring WCP's WhatsApp floating button") was inverted. Measuring the live Wix DOM found three floating widgets, not one — an ENQUIRE → /contact-us pill (pinnedMiddleRight, 179×45), a WhatsApp button (pinnedBottomRight, 55×55) and an "Up" scroll-to-top — and showed the regen was the faithful side: FloatingCTA.js had ENQUIRE → /contact-us right all along, and only FloatingSocial.js's hardcoded position: 'right' was genuinely wrong (live measures bottom-right). Live D1's WhatsApp-as-CTA + a phantom facebook/youtube rail came from an untracked hand-written .tmp/*.sql patch, not from any producer — so the fix was to correct live toward measurement, not to teach the pipeline to reproduce it. Shipped: a capture-plane floatingGeometry walker (chrome.floatingRaw, raw geometry only) that partitions Wix's pinned layer with the header probe at the header band (header CTA above, floating below), plus pure classifyFloatingCta / classifyFloatingSocial (lib/section-style.js) that partition the floating set by href class — a social platform is never the CTA; a bare / href is a scroll-to-top, recognised and deliberately dropped (no CMS home) — with position derived from measured viewport-corner distances and clamped to each block's own schema enum (they differ), omitting the field entirely when unmappable. Live WCP now measures ENQUIRE@right + whatsapp@bottom-right on 24/24 pages from the persisted artifact; garvanbay measures 0 candidates → null on both (faithful). Delivered by surgical sync-globals-to-d1.mjs stamp (2 blocks patched, 7 preserved — the operator's native block and the migrated r2: logo refs survive); no worker redeploy needed (data-only — the render path was already in the bundle). Deployed WCP renders the ENQUIRE pill + a WhatsApp rail, the phantom social rail is gone, 0 loud-fail placeholders. The proof is a no-op: an independent re-measurement of our deployed chrome now agrees with live on both floating dims (globals-diff floating peer: agree: true, disagreements 0). Zero-regression proven by A/B (regen with the change stashed vs applied): garvanbay byte-identical on pages AND globals; WCP byte-identical on pages, globals changed only in the floating dimension. +40 validate checks (103 → 143). See known-patterns "Wix's pinned layer holds SEVERAL unrelated widgets" + "A hand-written D1 patch is a FABRICATION with a production address".

Capture-staleness guard shipped alongside (the generalisable half). schemaVersion had been written into every capture artifact since v2 and no consumer had ever read it. seed-from-build.js#CAPTURE_REQUIREMENTS now has each capture-fed dimension declare the schemaVersion that introduced its probe and the chrome key that probe is guaranteed to emit (guaranteed even when it finds nothing — the key's presence is the freshness signal, because a null can't distinguish "ran, found nothing" from "never ran"). captureStampable() skips any dimension whose input is absent, with a loud warning, never stamping from a fabricated null and never aborting. Proven against the pre-slice v3 artifact: it refused the floating dimension by name while still stamping header/topbar (which v3 satisfies). This closes the fleet-wide silent-staleness risk the pt2 handover flagged as priority 2. See known-patterns "A new in-page probe makes EVERY existing capture artifact stale".

Nav dimension shipped 2026-07-13 pt4 (CMS lane) — and it was a FABRICATION, not a gap. Every WCP menu item navigated to the homepage. Root cause was one line in lib/matchers/Header.js: const href = $label.attr('href') || '/'. On live, FARMING/COMMERCIAL/RESIDENTIAL are dropdown toggles with no <a> at all (closest('a') === null) and their children are JS-mounted — invisible to the static body.html (grep the child slugs in the crawled HTML: zero hits). The matcher saw a label with no href and invented one; aria-current="page" then lit all of them at once. The render was already correctheader-wcp.astro routes an item with children to a <button> trigger, and garvanbay's live More overflow (href "" + 3 subItems) is production proof the dropdown path works; the toggles only fell into the <a href="/"> branch because subItems was empty. Shipped: a capture-plane chrome.navRaw probe (schemaVersion 4 → 5) measuring the post-JS header — href: null for a toggle (never /), parent↔child grouping by DOM containment (URL-prefix grouping provably fails: WCP's RESIDENTIAL → roof/dry-verge/tarmac share no prefix) — plus a pure classifyNav (lib/section-style.js, +24 checks → 159) and a seed-from-build overlay that REPLACES rather than fills-if-empty (a fabricated / reads as "already set"; fill-if-empty would have preserved the bug) and patches in place by label, never inserting/deleting, so each row's _key survives. Registered in CAPTURE_REQUIREMENTS, so a pre-v5 artifact is skipped with a loud warning — garvanbay is exactly that case and its regenerated header is byte-identical (zero-regression). Deployed WCP renders 3 <button> toggles + all 10 dropdown children; only HOME carries href="/". Static lane NOT fixed (translate.js still uses groupNavByPrefix()) — see known-issues. See known-patterns "A matcher that defaults a missing href to / FABRICATES a link".

Topbar palette reached the CMS lane 2026-07-13 pt4 (portfolio-wide). topbar-wcp.astro styles itself from var(--topbar-bg, #5DC8E8); the static lane passed the measured value, the CMS adapter passed nothing — so every CMS site in the fleet rendered the same FCR-typical cyan strip regardless of brand (WCP's live topbar is grey #e8e6e6, and its theme had measured that correctly all along). Fixed in the shared theme emitter — emitGlobalCss now emits --topbar-bg/--topbar-text from theme.colors.topStripBg/topStripText, omitting each when the theme has no value so the component fallback still applies (never fabricate a colour). Both lanes inherit it; garvanbay's emitted CSS gains only those two lines (byte-identical otherwise). See known-patterns "A CSS custom property that only ONE lane passes…".

fcr.usp-bar icon cards fixed 2026-07-13 pt4 — the pt6 split rule was wrong. fromUspBar split on imagery (if (props.logos?.length) return fromLogoStrip(...)), so an icon + text CARD — which carries both — took the image branch and had its text destroyed, silently (the src was present, so nothing loud-failed). WCP's home carries four (vat.png → "VAT Registered", shield.png → "CRO No. 633944 Fully insured", …). Corrected to split on TEXT (a band whose items carry words is a ribbon/card band whatever imagery it also has; only a band with no text is a true logo strip — the real 11-logo client strips still route there). fcr.usp-bar gains an imageUrl field (schema → transformer → adapter → block → FIELD_MIGRATIONS), because the live icons are client-uploaded images, not named glyphs — pt6 had dropped them rather than give them a home. See known-patterns "One emitter, two item shapes" (Correction).

Chrome dims (footer.bg + header.layout/cta/bg) SHIPPED 2026-07-21 — the full chromeDisagree slice, producer + render + fleet re-crawl, banking SHIP 2→15 / chromeDisagree 33→7 (7 remaining are deferred media-backed chrome). Details, per-dim proofs, and the transparentBlackRemnant veto narrowing are in known-issues "chromeDisagree adjudicated"; commits 9bae0ca·55176a8·52764f7·a87f062·b77c5f0. Remaining: density + blog-posts columns; globals gate/thresholds (Slice 6); the floating CTA's render treatment — the measured enum right is correct, but floating-cta-wcp.astro's --right styles it as a rotated vertical tab (transform: rotate(-90deg)) while live's ENQUIRE is a horizontal pill (measured 179×45, un-rotated). A render-lane block-design gap, not a measurement one; the scroll-to-top FAB likewise has no CMS home (recognised + dropped). The 7-Year Guarantee ribbon's inline-SVG icons have no CMS home either (Wix vector art — an IP + design decision, see known-issues). | | 4a | Structural-fidelity metrics → the scorecard as the ship/hold gate (Steps 0–3: S/T/G_det axes + hurdle/veto gate) plus the matcherGap roll-up into S. Metric spec: near-match-scorecard-contract.md. | Done 2026-07-08 — scorecard Step 0 (S axis) shipped 2026-07-07: symmetric re-capture (capture-section-backgrounds.js --base/--out) + pure lib/structural-diff.js (junk-filter → canonicalise Wix dup-pairs → heading-anchored NW alignment → omit-aware per-dim → role-weighted S). Validated live: garvanbay S=96, WCP S=69.6. Step 1 (2026-07-07): T (theme) axis (lib/theme-fidelity.js, wraps fidelity-harness) + combined emitter lib/scorecard.jsfidelity-scorecard.json (axes {S,T} + partial composite + collected veto flags); both sites T=100. Step 2 (2026-07-07): G_det (masked-perceptual) axis (lib/perceptual-diff.js; probe --shots + rect.y; section-crop coarse colour+luma grid, glyphs/decoration wash out) wired into scorecard.js. Three-axis: garvanbay S96/T100/G81 (composite 93.2), WCP S60/T100/G71 (72.7) — G_det flags garvanbay hero + WCP FAQ as top visual divergences; WCP's real service-grids surface genuine columns/align gaps. Step 3 (2026-07-07): hurdle/veto GATE — pure lib/scorecard-gate.js (§4 ladder: veto→hold; composite≥τ_ship + all floors→ship; ≥τ_hold→manual-polish; else hold) + versioned lib/calibration.json (SEED, fitted:false: τ_ship 85 / τ_hold 60 / floors S70·T80·G55; Slice 6 fits them); vetoes enforced: fallbackNavy, transparentBlackRemnant, criticalSectionDropped (hero/CTA dropLive at weight≥1.3); missing axis can never ship. Verdict contract locked by scorecard-gate.validate.mjs (19 checks). Anchor verdicts: garvanbay SHIP, WCP MANUAL-POLISH (S-floor). Alignment robustness (2026-07-08): triaged G_det outliers → root cause was structural-diff.js#canonicalise, not the crop; fixed with a bothEmptyDup collapse form (Wix doubles empty-heading image-heroes/ribbons the heading-keyed detector missed → a leftover twin force-matched an unrelated ours section) + anchor/visual-rect decoupling (collapse keeps the heading anchor for S but the earlier member's rect for G's crop). garvanbay hero G 33.4→63.6 (S/verdict unchanged), WCP spurious match gone (S 59.9→60.9), genuine outliers preserved; locked by lib/structural-diff.validate.mjs (42 checks). G_det now gate-ready on artifact grounds. matcherGap roll-up shipped 2026-07-08 (Slice 4a): structural-diff.js emits per-page (diffPage) + per-site (diffSite) matcher debt — droppedLive/insertedOurs/propsWithheld, role-weighted, with a byRole rollup — surfaced through scorecard.js into fidelity-scorecard.json's structural block. It is a reported projection of debt already in S (S byte-identical before/after — additive only), NOT a second penalty; the criticalSectionDropped veto reads the same drop rows unchanged. genericFallbackRate stays null (→ 4b). Validate now 53 checks (+11). Anchors stable: garvanbay SHIP (matcherGap 0/0/0), WCP MANUAL-POLISH (3 dropped w=2 / 5 inserted / 4 props-withheld, byRole content 1d·5i, ribbon 2d). The measured GenericSection renderer that reduces this debt is Slice 4b. | | 4b | Split 2026-07-08 into 4b-i (renderer) + 4b-ii (metric)genericFallbackRate can't be computed until GenericSection exists to be counted, so the renderer ships first (same move as 4a/4b, inverted). Scope: docs/pipeline/scope-2026-07-08-slice-4b.md. | Split | | 4b-i | Measured GenericSection fallback renderer (both lanes) for unmatched Wix sections — an always-matches lowest-score matcher (lib/matchers/GenericSection.js, score < About's 0.15 floor) + a new canonical block composing Section/SectionProse/SectionGrid + CMS schema/adapter/dispatcher + static translator; lays out captured text/images in captured order/tone/align, loud-reports every use (ADR-0004); closes the CMS-lane silent-drop (lib/cms/transformer.js:388 drops matcher:null with no warning). genericFallbackRate stays null. | Done 2026-07-08 — both lanes; matcher score 0.05 < About's 0.15 floor (zero-regression: WCP seed 0 generic-section, block-type distribution unchanged; live garvanbay home unchanged post-deploy). Live render proof on the demo worker (throwaway scratch page → heading/prose align=center, tone=muted band, columns=2 image grid; reverted). Surfaced a pre-existing CMS FloatingCTA/Social silent-drop (known-issues). See scope-2026-07-08-slice-4b.md + known-patterns "An always-matches fallback matcher is zero-regression …". | | 4b-ii | genericFallbackRate sub-metric — build-time {sectionId→matcher} sidecar (order+heading join, mirroring makeStyleLookup) → joined in structural-diff.js → nonzero rate in rollupMatcherGap; gate veto for GenericSection on a role-weight ≥ 1.3 region; flip structural-diff.validate.mjs line 161. | Done 2026-07-08 — build sidecar builds/<domain>/section-matchers.json ({order,heading,matcher,type} per emitted block) from transformer.js+seed-from-build.js; structural-diff.js computes genericFallbackRate = Σgeneric/Σblocks straight from the build counts (null when no sidecar — unknown, never a bogus 0) and tags each ours row with oursMatcher via a heading-first/content-order join (S byte-identical — additive tag only). Gate veto collectGenericFallbackVetoes (GenericSection at role-weight ≥ criticalRoleWeightgenericFallbackCritical → HOLD, mirroring criticalSectionDropped), wired into scorecard.js. Validate flipped + fixtures (structural-diff now with rate/join/veto/alias checks, scorecard-gate veto checks). Anchors stable: garvanbay SHIP gfr=null (no sidecar), WCP MANUAL-POLISH gfr=0 (sidecar, no fallback used), 22 rows tagged. Rate>0 + veto proven end-to-end on real WCP capture by forcing a synthetic GenericSection hero (gfr=0.031, veto fires). Bug caught in verify: seed keys the homepage home, capture keys it index — homepage silently dropped the join → fixed with the same home/index/homepage alias tolerance makeStyleLookup uses (manifestForSlug), locked by fixture. | | 5 | Hero asset plane + Wix API content plane (media, business info, blog) with provenance in-data. | Carousel frames done 2026-07-12 pt4 (Slice 5a, capture-enumerate — Wix API deferred, greenfield). The measured carousel variant collapsed to its single poster because the count-only capture never recorded the frame URLs (slideshowUrls: ""). A --slides-gated page-level captureHeroSlides (capture-section-backgrounds.js, ported from capture-layout.js#captureSlides) enumerates the real hero-slideshow frames — re-activating the lazy-mounted slideshow after the crawl's full-page scroll (else 1/8), advancing via the working arrows, emitting the /assets/images/ local-path form the migration accepts — and attaches them to the hero section's assets.slideUrls, joined onto fcr.hero.slideshowUrls by makeStyleLookup+transformer exactly like variant/style (reproducible-from-capture). Migrated to r2: by transform-seed-images#migrateSlideshowUrls. WCP home renders its 8-frame carousel LIVE (8/8 enumerated, 7 uploaded + 1 idempotent to R2, surgical D1 slideshowUrls patch — no reseed, no worker redeploy: hero-4.astro's rotating carousel was already in the bundle); slides-dropped/media-missing/image-unmigrated cleared (only video-unmigrated remains → 5b); garvanbay no-op (no slideshow hero). See known-patterns "Enumerating a lazy-mounted Wix hero carousel". Hero VIDEO — DONE 2026-07-12 pt5 (Slice 5b), acquisition AND render. Acquisition went through the Wix API (ADR-0010 slice B), retiring the URL-reconstruction approach: scrape id → API MP4 url → R2, with the API's real filename as the key stem + x-amz-meta-original-name (provenance sourced, not derived). Render closed the gap ADR-0010 flagged as "getting an asset ≠ rendering it": the live hero was measured (Playwright: a playing <video>, readyState 4, currentTime advancing) and the video turned out to BE slide 8 of the 8-frame carousel — Wix paints a video slide as its poster still until the player mounts, which is why our capture enumerated it as an image. So the fix is a link, not a new field: the video's ref replaces its own poster at its own index (8 slides stay 8), and hero-4 renders any video-extension slide as <video> (a video slide also holds for its own duration, not a 4.5s image slot). The poster↔video join is by media id via the API's posters[], run through the same lossy normaliseStem that produced the R2 keys (ADR-0005 §3 — a naive _- swap silently misses). Pipeline-driven + idempotent (transform-seed-images#linkVideoSlide), so future sites get it automatically. WCP home plays the video LIVE (worker 48441217, surgical D1 slideshowUrls patch); garvanbay renders hero-4 with zero slide-branch usage → provably inert. See known-patterns "A Wix hero carousel's video slide is captured as its POSTER". Gallery-video bucket — RENDER/PRODUCER shipped, ACQUISITION blocked 2026-07-15 (Slice 5c). The 13 fcr.gallery videos (ADR-0011 Slice 1c videoDebt, the largest render bucket) got the full CODE path, A/B-proven zero-regression + reproducible-from-capture: videos multiline field on fcr.gallery (schema), per-section emit in makeStyleLookup freshness-gated on the v8 mediaRefs.sectionAttribution key, transformer stamp, gallery-wcp.astro <video controls> strip below the grid (inert when absent), adapter parse, migrateGalleryVideos hook. NOT deployed — a real stop: the gallery videos are Wix VOD "channel videos" (tokenized HLS via _api/vod/public/lists), NOT Media Manager assets, so the ADR-0010 by-id pool can't acquire them (0/13). Seed graft reverted (no dead <video> shipped); videoDebt unchanged. Acquisition is ADR-0010 Slice F — BLOCKED AT SOURCE 2026-07-16 pt2, not merely Planned: the VOD API 403s for the live site's own player (0 <video> mount, 0 HLS, reproduced 5 ways), so the gallery ×13 and the fcr.about ×11 bucket — ~24 of the 27 — are behind one account-side question (the Wix Video app's status), not behind three independent render slices. See ADR-0010's fourth amendment. See known-issues "Gallery bucket … acquisition BLOCKED on Wix VOD" + known-patterns "A Wix gallery's VIDEOS are VOD-channel HLS". Remaining: the Wix API content plane (business info/blog, ADR-0010 slices C/D) stays Planned. | | 6 | Batch a 30–50-site sample; measure unsupervised pass rate; tune thresholds; set the manual-exception policy. Calibration method (logistic fit on human ship/needs-polish/hold labels; high ship-precision operating point): near-match-scorecard-contract.md §4. | Instrument done 2026-07-17; corpus + labels remain the gate. The five steps are: pick 30–50 sites → batch-build (EC2, run-fulldev-batch.sh) → scorecard each → human ship/needs-polish/hold labels → logistic fit. Steps 2+4 need EC2 runtime + human time (not startable from a 2-build local tree). Steps 3+5 (pure code) shipped + validated: scripts/batch-scorecard.mjs (per-site scorecards → feature-matrix.json, loud-skips partials) + lib/calibration-fit.js (logistic P(ship), τ_ship@precision≥0.98 on held-out folds, τ_hold@Youden, floors/vetoes fit separately, manual-budget) + scripts/fit-calibration.mjs (CLI; refuses to overwrite calibration.json), locked by lib/calibration-fit.validate.mjs. Production calibration.json untouched (fitted:false). Anchors are both veto-held → zero ship-labels locally, so the corpus (not a 2-site fit) is mandatory. Labelling harness added 2026-07-21 (scripts/make-label-sheet.mjs → seeded calibration/labels.csv in the fit-calibration format + a review.html per-site punch-list, 18-check selftest) so the human-label step is tooled; and the null-axis composite bug fixed (composite() requires ≥2 axes — a mesh 0-section site no longer reads a flattering composite 95.5/100; the fit was already protected via missingAxes skip). See known-issues "Slice 6 instrument" + "Mesh prevalence". |

Capability process + two reporting detectors (2026-07-11)

The reference sites are worked as capability forcing-functions, not per-site polish: a WCP audit produces a classified capability backlog (capability-backlog.md). It confirmed the scorecard machinery is blind by construction to two failure modes it was never built to see — mis-renders (a section matched but rendered the wrong variant) and asset-drops (a non-image asset never captured/migrated). Two reporting-mode detectors now close them, both additive (S byte-identical): variant-mismatch (structural-diff.js variantMismatch bucket → scorecard.js) counts decisive-dim disagrees on critical-role sections; asset-manifest (lib/cms/asset-manifest.js + capture assets + seed-from-build asset-manifest.json sidecar) records expected-vs-supplied assets + migration status. Their gate vetoes + thresholds are deferred to Slice 6 calibration, and promoting hero-variant/scrim/textTone to scored DIMS lands there too (scoring them moves S). Commit 6bc7a95.

When to revisit

  • Scorecard shows measured-tokens plateau well below near-match for a large fraction → reconsider a bounded Layout-IR reconstruction for the static long-tail tier (where self-service isn't required).
  • Self-service is dropped for a tier → Codex's faithful-reconstruction renderer re-opens for that tier.
  • Wix API turns out to expose usable layout/structure signals → fold into the design plane.

References

  • Parallel studies this session: Fable ("measured-token hybrid"), Codex ("faithful-layout reconstruction") — both advised pursue, moderated.
  • ADR-0001/0002/0003/0004/0007/0008.
  • Memory: feedback_screenshot_approach (DOM extraction, clients want identical sites), feedback_visual_variation_as_props, feedback_primitive_fingerprint, feedback_no_wix_css_copy.