Known patterns¶
What we've learned about Wix → static replatform that must be folded into the pipeline so we don't relearn it on every site.
Each pattern: how it manifests, where it bites, and where the fix lives (or
needs to live) in the codebase. When lib/structure-gap.js flags a
mystery section, check this list before debugging from scratch.
When you spot a new pattern: add an entry here in the same session you fix it. A shipped fix without a catalogued pattern is half the value.
Hero / above-the-fold¶
Wix slideshow with no visible "next" button (auto-rotates)¶
Bites: capture-layout.js's slide enumerator clicked a next-button selector and returned only the active slide. Hero rendered as a single static frame.
Fix: lib/capture-layout.js#captureSlides — three-pass:
1. Query every <img> / [style*="background-image"] / <video> inside the
slideshow root. Wix often pre-renders all slides as siblings, just toggling
visibility — picks them up in one shot.
2. Try the next-button click flow (works on older Wix templates).
3. Auto-rotation watcher: poll the active slide for ~20s, collect new URLs
as the slideshow advances itself.
Status: shipped 2026-05-09.
Hero heading wrapped in <h3> / <h4> instead of <h1> / <h2>¶
Bites: Wix editors choose heading element by visual style, not semantics.
Hero matcher checked only h1/h2 → empty <h1></h1> slot on 4/5 garvanbay
service pages. Silent regression — the build emitted broken pages until
Gemini caught it.
Fix: lib/matchers/Hero.js#extract — heading lookup is `h1 > h2 > h3 > h4
large-font-p`. Subtext also looks at h4.
Status: shipped 2026-05-08 after Gemini verify-design pass surfaced it.
Trust ribbon overlapping hero (USPBar inside <header>)¶
Bites: Wix wraps the trust strip ("7-Year Guarantee / Fully insured /
Reliable") inside the <header> element on wfpainters subpages. Our
buildSections blanket-excluded sections under <header> to avoid pulling
in nav links → trust ribbon never enumerated → Hero matcher rendered
without it and Gemini flagged "missing-ribbon-bar" / "missing-feature-bar"
across multiple subpages.
Compounded by Hero matcher's strict position === 0 rule: once the trust
ribbon was enumerated, it took position 0 and the actual hero fell to
position 1 — Hero rejected, CTAStrip won the fallback.
Fix:
- lib/assembler-fulldev.js#buildSections (and lib/structure-gap.js):
allow <section> inside <header> when the section has at least one
<p> outside any <nav> descendant. Pure-nav sections still skipped.
- lib/matchers/Hero.js#match relaxed to position <= 1 so a trust strip
at 0 doesn't force-skip the real hero.
- lib/assembler-fulldev/emit.js#hoistHeroRibbon cross-entry pass: USPBar
with ≤4 short items adjacent to the hero (±3 entries) gets hoisted into
hero.props.ribbon and the standalone entry is marked
droppedAsDuplicate. Both directions are evaluated; the candidate with
the shortest total item-text wins (the ribbon is typically a 3-item
strip, ~50 chars total; longer USPBars like a 4-item trust-badge block
are page sections, not ribbons).
- lib/components-v3/blocks/hero-4.astro renders the ribbon as a
separate full-width brand-coloured strip immediately below the
<Section>, not as a bottom-overlay on the hero media. Live FCR
pattern (confirmed via placement-check.js on wfpainters): the strip
is its own row beneath the hero, not layered over it.
emit.js iterations now also filter entry.droppedAsDuplicate (was only
filtered by variant-picker before backfills ran).
Status: shipped 2026-05-09.
Single-instance dedupe — earlier position always wins for Hero¶
Bites: Relaxing Hero's position rule (===0 → <=1) for the trust-ribbon
fix unintentionally let a second section win Hero on garvanbay service
pages — the page-intro section at position 1 has heading + image bg too,
and often outscored the real hero on text signals (e.g. h2 vs styled-<p>
heading proxy). Two hero-4 components stacked, often the wrong one
showing first. Gemini scored these pages 1/10.
Fix: lib/assembler-fulldev/dedupe.js adds a SINGLE_INSTANCE_MATCHERS
set (Hero for now). Pass 0 sorts duplicates by _matchPosition first,
score only as a position-tie breaker — for Hero, the canonical rule is
"first prominent section is the hero", and an intro section deeper in
the page never wins, no matter how many h2s and images it has.
The loser entry's content still surfaces: re-match callback runs against a filtered matcher list (offending matcher excluded) so e.g. the /payroll intro that lost Hero falls to About → content-1.
lib/assembler-fulldev.js exposes matchers + a buildCtx helper from
loadLayoutFromDomain so the re-match callback can rebuild a section's
ctx using its remembered position.
After this fix, Gemini scores on garvanbay rebounded: home 1→5, payroll 4→5, management-accounting 1→4, company-secretarial 2→7.
Status: shipped 2026-05-09.
Page-aware hero overlay (single image vs slideshow / video)¶
Bites: subpage heroes use a single static image — needs a strong dark overlay so white heading text reads. Homepage heroes use slideshow / video which have their own visual dimming — strong overlay over those reads as a murky black band.
Fix: lib/assembler-fulldev/translate.js#translateHero infers
scrimIntensity: 'light' when video or slideshow >1, 'strong' for single
image, 'medium' as fallback. lib/components-v3/blocks/hero-4.astro
swaps three opacity tiers.
Status: shipped 2026-05-09.
Background video extraction without component render¶
Bites: Hero matcher captured backgroundVideo correctly — hero-4
component only ever rendered <Image>. Video silently dropped.
Fix: lib/components-v3/blocks/hero-4.astro branches on slides /
backgroundVideo / image priority. Video uses autoplay muted loop
playsinline with image.src as poster.
Status: shipped 2026-05-09.
The capture plane — not the matcher — is the authority for a hero's full-bleed-vs-side layout¶
Bites: WCP's 11 service-page heroes are a light text + side-image section on
live, but rendered as a full-bleed dark hero-4. The obvious framing ("teach
Hero.js to distinguish a full-bleed background from a side image with bbox geometry")
is a trap: the matcher runs geometry-blind in the CMS seed path
(seed-from-build.js builds ctx with compLayout: new Map(), computedLayout: null),
so Hero.js cannot measure a side image there — it just promotes the first <img>
to backgroundImage, and fromHero defaults scrim=strong → dark. Meanwhile the
capture plane already measured it: section-backgrounds.json carries
bg.media=false + style.imageSide=right for those exact sections, joined per-block
by makeStyleLookup. The design signal existed; the hero layout just never read it.
Fix: decide the hero layout from the capture, not the matcher. makeStyleLookup
exposes media; the transformer stamps imageSide+scrim:none on fcr.hero only when
imageSide && !media (a non-media section with a measured side image), routing the CMS
Hero.astro adapter to hero-1 (text+side image, no scrim). Genuine media heroes
(bg.media, or a measured scrim) keep hero-4. hero-1.astro (previously unused in
both lanes) was reworked to mirror content-2's proven SectionSplit layout. 10
service heroes converge (WCP composite 76.3→79.0, S 65.5→70.8, S-floor cleared);
garvanbay's media heroes get 0 stamps (SHIP untouched — code inert on media heroes).
Detection rule: when a fidelity bug looks like "the matcher extracted the wrong thing", first check whether the design plane (capture) already measured the right thing — the matcher's job is content + block identity; the ADR-0009 capture is the authority for layout/style. A "matcher-level fix" for a dimension the capture already quantizes is usually the wrong layer (and impossible where the matcher is geometry-blind).
Status: shipped 2026-07-10 pt4 (ADR-0009 hero variant; CMS lane — static lane deferred, same Option A trigger as imageSide/columns).
The capture plane is the authority for header layout too — the same geometry-blind-seed-path trap, and a header CTA can be pinned OUTSIDE <header>¶
Bites: WCP's live header is a split-center layout — nav items flank a
centered logo on one row (HOME FARMING COMMERCIAL | logo | RESIDENTIAL GALLERY
CONTACT US + a SERVICE AREAS pill) — but ours rendered the FCR-typical
stacked-center (header-wcp's only layout: logo above, nav below, garvanbay's
shape). The obvious framing ("teach Header.js to emit the layout from
logoPosition") is the exact hero trap one level up: Header.js#logoPosition is
measured from Playwright computed layout, which is null in the CMS seed path
(seed-from-build.js builds ctx geometry-blind), and even when present
logoPosition is center for both stacked and split — the discriminator is
where the nav sits relative to the logo, which the matcher never measures. Two
further gotchas: (1) the header CTA (SERVICE AREAS) is a real <a href> but Wix
pins it outside the <header> element (el.closest('header') is null), so a
header-scoped probe correctly finds nothing — the trimtech "pinned-layer children
escape coverage" lesson, at the header; (2) the nav items are Wix
StylableHorizontalMenu <li data-testid="menuItemDepth0">, not plain anchors,
so an <a>-only nav scan under-counts them.
Fix: measure the header layout on the capture plane, same rail as the
section dims. Raw geometry in-page (chrome.headerRaw: logo bbox, nav-item
centroids, navTop) → pure node-side lib/section-style.js#classifyHeaderLayout
→ bounded enum stacked-center | split-center | inline-left | null
(logo above the nav row → stacked; logo in-row + nav flanking a centered logo →
split; logo left + nav right → inline-left; too thin → null → the block's
stacked-center default stands, zero-regression). seed-from-build stamps it
onto the fcr.header global from the homepage chrome; a bounded layout prop
on header-wcp renders it. To keep the other consumer byte-identical when a
shared block grows a variant, keep the default branch verbatim and gate only
the new branch behind the enum — proven here by diffing the stacked markup old vs
new (the only textual delta was the ternary )}, which emits no HTML; garvanbay
render unchanged).
Detection rule: any global/chrome layout question (header stacked-vs-split,
topbar pinned-vs-static, footer columns) is capture-plane work, not matcher work —
the matcher is geometry-blind in the seed path and its per-element signals
(logoPosition) often can't separate the layouts anyway. Collect raw bboxes
in-page, classify to a bounded enum node-side, verify on the rendered artifact
(screenshot the deployed header), and when a value the extractor should reach
lives outside the expected element (Wix pins chrome siblings), return null
rather than fabricate — the gap is a named fast-follow, not a guess.
Status: shipped 2026-07-12 (ADR-0009 Slice 3 header dimension; CMS lane —
classifyHeaderLayout + header-wcp layout/cta, WCP worker b4e7a6a4).
Header-CTA auto-detection (pinned outside <header>) + topbar field extraction +
inline-left routing + globals-into-the-scorecard are named fast-follows
(known-issues).
A pinned-outside-<header> button IS the header CTA — button-styling + a nav-row floor separate it from the topbar contact strip¶
Bites: WCP's SERVICE AREAS header CTA is a real <a href> but Wix pins it
OUTSIDE <header> (in pinnedTopRight, a sibling of the topbar strip), so the
header-scoped probe (header.querySelectorAll) returned null — the CTA had to be
typed in by hand, and the globals scorecard read a false mismatch (live
headerCta:null vs ours SERVICE AREAS; the live pill exists, the probe just
couldn't reach it). The tempting guard — "exclude anything in a pinnedTop*
container as topbar" — is a trap: the CTA pill lives in pinnedTopRight, the
SAME pinnedTop* family as the contact strip (pinnedTopCenter). And the
<header> bounding box spans the whole chrome (topbar row + nav row), so the
Y-band alone can't separate the pill (cy 108) from the topbar phone/email links
(cy 26).
Fix: measure it on the capture plane like the other chrome dims — gather
outside-<header> anchors in the header Y-band in-page
(chrome.headerRaw.ctaCandidates, each with a button-styled flag) and pick the
winner in a pure node-side classifyHeaderCta. Two geometric guards do the
separating a container-id can't: button-styling (opaque bg OR a visible
border — the pill has a white fill + 2px brand border + 50px radius; topbar
contact links are transparent) splits the CTA from text links; a nav-row
floor (cy ≥ navTop − pad) excludes the topbar row above the nav —
decisively proven on garvanbay, whose topbar carries a styled "Book an
appointment" pill that the floor correctly rejects (cy 30 < navTop 198) so
garvanbay stays headerCta:null; a header-band ceiling (cy ≤ headerBottom +
pad) excludes hero/floating buttons below. Href is same-origin-normalised
in-page (new URL(h, location.href) → pathname when internal) so the live
absolute URL and our relative one compare equal in the scorecard AND our rendered
link stays same-origin. One detector, two payoffs: the CTA auto-detects (feeds
the seed-from-build header overlay, reproducible-from-capture) AND the globals
headerCta mismatch flips to agree.
Detection rule: a chrome element pinned outside its semantic parent
(el.closest('header') null) is the trimtech "pinned-layer children escape
coverage" shape — don't identify it by the pinned CONTAINER (Wix reuses
pinnedTop* for both the topbar and a header CTA); identify it by what it IS
(button-styled) and WHERE in the header band it sits (nav row, not the topbar row
above). A styled button in the topbar is a topbar CTA, not a header CTA — the
row it sits in decides, not the styling alone. And a "live-vs-ours mismatch" on a
field OUR side measures but the LIVE probe can't reach is a probe reach gap,
not a real divergence — fix the probe, don't accept the mismatch.
Status: shipped 2026-07-12 pt4 (header fast-follow #2; classifyHeaderCta +
ctaCandidates gather in capture-section-backgrounds.js; CMS lane; verified on
live WCP (detects SERVICE AREAS → /service-areas) + garvanbay (null,
styled-topbar-CTA rejected) + globals mismatch closed → agree; +17 validate
checks). Stacked-center CTA render + per-site static lane deferred (known-issues).
Wix's pinned layer holds SEVERAL unrelated widgets — partition it by the header band and by href class, never by container shape¶
Bites: [id^="pinned"] is not one thing. Live WCP puts four distinct
widgets in it: the topbar contact run (pinnedTopCenter), the header CTA pill
(pinnedTopRight), a floating ENQUIRE pill (pinnedMiddleRight), and a
WhatsApp button + an "Up" scroll-to-top sharing pinnedBottomRight. The
static-HTML matchers try to tell them apart by container shape —
FloatingCTA.js fires on "a pinned container with exactly one link",
FloatingSocial.js on "a pinned container with a whatsapp/facebook link" — which
works on WCP only by luck, and hardcodes position: 'right' because static HTML
has no geometry. Get the partition wrong and a social button lands in the CTA
slot (which is exactly what a hand-written D1 patch then enshrined).
Fix: two probes split the pinned layer at the header band, as mirror
images that must stay in step:
- headerGeometry claims candidates inside the band (cy ≤ headerBottom + 40)
→ the header CTA.
- floatingGeometry claims those below it (cy > headerBottom + 40)
→ the floating widgets.
Within the floating set, partition by href class, not by geometry or container:
a known social platform (socialPlatformOf) is a social item and never the CTA;
a bare / or # href (or Up/Top text) is a scroll-to-top and is recognised
and deliberately dropped (it has no CMS home — without the test its / href reads
as a CTA to the homepage); what remains, button-styled with a navigable href, is the
CTA. Position comes from measured viewport-corner distances, clamped to each block's
own enum.
Two traps built into the probe:
- The pinned container is zero-height (children absolutely positioned) — the same
trap that hid the topbar contact run from the bg probe. Measure the children,
never the box.
- "Floating" is position: fixed|sticky or a [id^="pinned"] ancestor. Neither
test alone is sufficient across Wix templates.
Detection rule: when two matchers can both fire on the same DOM subtree and only a container's shape separates them, they are one dimension that hasn't been measured yet. A matcher that is right "by construction" on your one reference site is a matcher that is guessing.
Status: shipped 2026-07-13 pt3 (floatingGeometry + classifyFloatingCta /
classifyFloatingSocial; schemaVersion 4; live WCP ENQUIRE@right +
whatsapp@bottom-right, garvanbay 0 candidates → null/faithful; +40 validate checks;
globals-diff floating peer reports live-vs-ours agree: true on both).
A dimension whose inputs are already-BOUNDED capture values derives at the JOIN — no re-crawl (unlike a raw-geometry dimension)¶
Bites: the hero variant (carousel / video / full-bleed / side-image / text)
is a design dimension like scrim or imageSide, but its inputs — the full-bleed
bg.media flag, the measured imageSide, and the expected asset COUNTS
(assets.{slides,videos}) — are all already classified and persisted in
section-backgrounds.json. So unlike classifyScrim / classifyImageSide
(which read RAW geometry — _styleRaw, scrimAlpha — that only exists in-page
and MUST be computed at capture time), classifyHeroVariant is a pure function
of persisted BOUNDED values → it re-derives at the seed join
(seed-from-build.js#makeStyleLookup) over a stored capture with no re-crawl.
That let the whole capability ship and verify against the existing v2 captures,
sidestepping the re-crawl brand-nondeterminism landmine (known-issues).
Detection rule: before adding a new classifier to
capture-section-backgrounds.js (which forces a re-crawl to land its output),
check whether its inputs are already in the stored capture as bounded values. If
so, derive it at the JOIN instead — network-free, verifiable on existing
artifacts. Only dimensions that need raw geometry / computed-style (not
persisted) must live in the capture. And when a measured enum retires an
inference (here imageSide && !media → variant), it's zero-regression iff the
enum reproduces the inference's routing for every shipped case AND the render
decision that could DROP content (media-vs-text) stays on hasMedia, not the new
enum — so a mis-measured text variant on a hero that carries an image never
suppresses the image.
Status: shipped 2026-07-12 pt2 (hero variant; classifyHeroVariant in
lib/section-style.js, derived in seed-from-build.js#makeStyleLookup, stamped
in lib/cms/transformer.js, routed in CMS Hero.astro; carousel/video render
proven locally, zero-regression diff on garvanbay + WCP seeds).
Enumerating a lazy-mounted Wix hero carousel — a measured shape and its ASSET payload are decoupled; three traps between the frame URLs and R2¶
Bites: ADR-0009 measures a hero variant: carousel from the slide COUNT
(resolveSectionAssets counts <li>/slide elements), but rendering the carousel
needs the actual frame URLs — which the count-only capture never recorded, so
slideshowUrls stayed "" and the measured carousel collapsed to its single
poster. Recovering the 8 WCP frames hit three traps, each of which silently
yields the wrong result rather than an error:
- Lazy-mount. The slideshow virtualizes to ~2 mounted
<img>at a time — a single in-page pass gets 1-2 of 8. Frames only appear as you advance, so it needs the interaction/rotation passes (ported fromcapture-layout.js#captureSlides), which are page-level (clicks + waits) and can't live inside apage.evaluate. - De-activation by the shared crawl's full-page scroll. The section-bg crawl
scrolls the whole page (to mount lazy widgets) then back to top — which leaves
the hero slideshow with its "next" arrow PRESENT but INERT (clicks don't
advance → recovers 1/8).
scrollIntoViewon the slideshow + a settle re-activates it → 8/8. Compounding it: the wrong control ([data-testid="nextButton"]) matches FIRST and clicks-without-advancing then vanishes; the[aria-label*="next"]/[class*="next"]arrows are the ones that actually advance — so selector ORDER matters, and the control must be RE-QUERIED each iteration (Wix re-mounts the arrow → a cached handle goes stale). - Migration skips absolute URLs. The enumerated frames are absolute
static.wixstatic.com/media/…URLs, buttransform-seed-images#isAlreadyMigratedtreats ANYhttps://as external → skips it → the frame never reaches R2 (would_upload: 0). Emit the/assets/images/<basename>local-path form (mirroringHero.js#slideshowImages) so the migration recognises, downloads, and uploads it.
Fix: a --slides-gated page-level captureHeroSlides (OFF by default — the
rotation poll adds ~15-25s, unwanted on the always-run scorecard capture), scoped
to the first wixui-slideshow (the hero; a reviews carousel further down is
bg.media:false so it's excluded by construction), re-activates the slideshow,
advances via the working arrows with a rotation top-up toward the measured count
(loud-reports a shortfall — never pads), and emits /assets/images/ refs deduped
by media id. Attached to the hero content section's assets.slideUrls, joined by
makeStyleLookup onto fcr.hero.slideshowUrls exactly like variant/style (so
it's reproducible-from-capture, not a manual stamp), migrated to r2: by
transform-seed-images#migrateSlideshowUrls. hero-4.astro's rotating-carousel
renderer (slides>video>image priority) was already capable — the whole gap was
frame DATA, not render code.
Detection rule: when a MEASURED shape (carousel/video) renders as its fallback
(single poster), the shape and its ASSET payload are decoupled — check whether the
asset half was CAPTURED (URLs) or only COUNTED. Enumerating an interactive Wix
widget needs page-level passes AND a re-activation after any full-page scroll;
verify the recovered count against the measured expectation and loud-report a
shortfall. And a data migration that string-matches https:// as "external" will
silently skip a freshly-captured LIVE URL — feed it the pipeline's local-path form.
Status: shipped 2026-07-12 pt4 (ADR-0009 Slice 5a carousel frames;
captureHeroSlides in capture-section-backgrounds.js behind --slides,
slideUrls join in seed-from-build#makeStyleLookup + transformer.js; CMS
lane. WCP home renders the 8-frame carousel LIVE — 8/8 enumerated, 7 uploaded + 1
idempotent to R2, slides-dropped/media-missing cleared; garvanbay no-op; join
reproducibility proven. Hero video (video.wixstatic.com) migration is Slice 5b.).
A Wix hero carousel's VIDEO slide is captured as its POSTER — so the video is already IN the slide list; link it, don't add a field¶
Bites: WCP's hero video was acquired (ADR-0010 slice B) and still didn't play:
hero-4 renders slides > video, and the home hero is a carousel, so the 8 stills
won. The tempting reads are both wrong. "The video is a background BEHIND the
carousel" → then it would need a z-layer and the carousel would cover it. "Add a
videoSlideIndex field" → new schema surface for something the data already
encodes. What live actually does (measured, not inferred — Playwright on the
live page: one <video> inside the slideshow, readyState 4, paused:false,
currentTime advancing) is simpler: the video IS one of the slides. Wix paints
a video slide as its poster still (<videoId>f000.jpg) until the player mounts
— which is exactly why our arrow-advance frame enumeration captured it as an
ordinary image. The poster was sitting in our 8 frames all along, at index 7.
Fix: the slide list is an ordered ref list, so the video rides in it at its
own position: replace the poster ref with the video's r2:…mp4 ref at the same
index (8 slides stay 8, order preserved, no new field, editor-visible), and have
hero-4 render any video-extension slide as <video> and everything else as
<img>. Two details that matter: (1) the poster↔video join is by media id via
the API's own posters[] (Wix names them <videoId>f000.jpg), and the id must be
run through the SAME normaliseStem that produced the R2 key — the key stem is
a lossy normalisation (ADR-0005 §3: _79 decodes to q, eating two chars), so a
naive _→- swap silently fails to match; (2) a video slide must hold for its
own duration — a 23s clip in a 4.5s image slot is its own fidelity bug (live gives
it the full run: measured currentTime well past 4.5s while active).
Detection rule: when an acquired asset still doesn't render, ask what the live
platform actually does with it and measure that on the live page — don't design
from the data shape. A carousel that "has a video somewhere" usually has the video
AS a frame, and its poster is already in your captured frames (same media id,
f000 suffix). And when a shared canonical block grows a new branch, prove the
other consumer never enters it (garvanbay renders hero-4 with zero
hero-4-slides → the change is inert for it) rather than reasoning that it's safe.
Status: shipped 2026-07-12 pt5 (ADR-0009 Slice 5b + ADR-0010 slice B;
linkVideoSlide in transform-seed-images.mjs, video-slide branch + per-slide
dwell in hero-4.astro; WCP home plays the video LIVE, worker 48441217).
A zero-height pinned container hides its measurable children from the bg probe — measure the topbar contact RUN, not the box; and land it by read-mutate-write, never a reseed¶
Bites: WCP's topbar (phone | email | location) is Wix's #pinnedTopCenter,
but the container renders at height 0 (its children are absolutely
positioned) — so resolveSectionBg($pinned) read source: zero-size and the
topbar looked empty/absent to capture. Compounding it: only the phone
survives in the static body.html; the email (a mailto: link) and
location (a text run) are JS-rendered, so the matcher (off static HTML) left
fcr.topbar.email/.location empty and the deployed topbar rendered phone-only.
Two blind spots hid the data in plain sight — the container measures zero-height
(bg/bbox probe blind) and the run is post-JS-only (static matcher blind). This
is the header-layout authority lesson one strip up: a global/chrome field the
matcher can't reach is capture-plane work.
Fix: measure it on the post-JS capture plane, reading the measurable
children, not the zero-height box. chrome.topbarContact
(capture-section-backgrounds.js) scopes to #pinnedTopCenter, takes the
mailto: href for email, and segments the visible run on | classifying each
piece (email / phone / location) — an additive peer of chrome.headerRaw that
leaves the bg-only chrome.topbar untouched (globals-diff.js still reads it).
seed-from-build overlays it fill-if-empty after the header overlay, so a
matcher-found or operator value is never clobbered — proven a faithful no-op on
garvanbay (its matcher already had email/location → topbar byte-identical)
while WCP gained both. Landing corollary — surgical read-mutate-write, never a
reseed: the regenerated seed's header CTA is empty (WCP's SERVICE AREAS
pill is pinned OUTSIDE <header>, invisible to the capture — fast-follow #2), so
a full reseed would have reverted the manually-set CTA. Instead: read the
LIVE ec_globals.content, mutate only the topbar's two fields, write the whole
array back — every other block (header CTA, native operator block) preserved
verbatim. Rendered-artifact verified on the deployed topbar.
Detection rule: a chrome region that reads zero-size/absent from a
background or bbox probe is not necessarily absent — a Wix pinned strip
collapses its container to zero-height while its children paint; probe the
children's rects and text, not the wrapper. And when a render slot the block
already supports arrives empty, check whether it's a capture-plane miss
(JS-rendered, invisible to the static matcher) before assuming the slot is
broken — measure it post-JS, don't touch the renderer. When you then land a
surgical global fix, read LIVE and mutate in place: a regen/reseed can silently
revert a capture-invisible manually-set field (the same reseed-revert
landmine as "Fixing a silently-broken sync is destructive when the two stores
have diverged", now triggered by a field the capture can't re-derive).
Status: shipped 2026-07-12 pt3 (topbar fast-follow #1; topbarContact in
capture-section-backgrounds.js, fill-if-empty overlay in seed-from-build.js,
deployed to WCP live D1 via surgical ec_globals read-mutate-write + rendered
verified). Unlocked by the single v3 re-capture that also landed hero-variant
real measurement + globals headerLayout live-vs-ours numbers.
Sections / matchers¶
TeamGrid false-positives on services¶
Bites: TeamGrid matcher fired at priority 75 on any section with 2+ content images and matching paragraph count. wfpainters services (farm painting, commercial cleaning, roof painting, tarmac restoration) hit this signal and rendered as 4 team-member cards instead of a service grid.
Fix: lib/matchers/TeamGrid.js#match now requires ≥ 2 paragraph /
heading text-starts to match a name pattern (FIRST_NAME_RE) AND the matched
text is ≥ 6 chars AND contains a space. "We specialise…" / "When it comes…"
/ "Bring faded driveways…" all start with a single capitalized word that
fails the space + length filter. Real team bios ("Tom Holmes BBS, FCCA",
"Cathy Dunne, Senior Accountant") pass.
When TeamGrid bows out, ServiceGrid (priority 70 — same shape, no
name signal required) takes over and routes to features-3.astro.
Status: shipped 2026-05-09.
Checklist sections (heading + ✔-prefixed bullets)¶
Bites: USPBar/InfoCards both partially-matched and rendered nearly empty content (heading-only, or paired bullets as title+description cards).
Fix: lib/matchers/Checklist.js (priority 75 — beats both). Detects 50%+
of paragraphs starting with [✔✓☑], strips prefix, emits one item per
bullet. Renders via dedicated lib/components-v3/blocks/checklist.astro
(borderless inline list).
Status: shipped 2026-05-08.
Dedupe ignored matchers without heading/text props¶
Bites: Wix nests sections (outer wrapper + inner content with same data).
Dedupe's signature rule used matcher | heading | text — fine for About,
CTAStrip, FAQ, Hero. But USPBar / Checklist / Gallery extract returns
{items} with no heading field, so the signature was empty and dedupe
skipped. wfpainters homepage was emitting two features-1 (USPBar) blocks
for the same trust-badge content.
Fix: lib/assembler-fulldev/dedupe.js#sigBody falls back to
items.slice(0,3).map(it.text || it.title).join('|') when no heading /
text. Stable across matchers that emit lists.
Status: shipped 2026-05-09.
TeamGrid heading regex eats credentials¶
Bites: First version matched Tom Holmes but stopped before
BBS, FCCA because [A-Z][a-z]+ doesn't allow an internal capital
(McNamara) and the credentials clause wanted \s+ not ,.
Fix: lib/matchers/TeamGrid.js#FIRST_NAME_RE accepts \p{L}'’- chars
within a name word and trailing [\s,]+[A-Z]{2,} groups for credential
strings (2+ caps rules out "S" from "Senior").
Status: shipped 2026-05-08.
TeamGrid DOM-order ≠ visual order (seniority)¶
Bites: Wix uses absolute positioning so DOM-order doesn't track visual order. Garvanbay's about page rendered Tom → Cathy → John → Orla, live shows Tom → John → Cathy → Orla.
Fix: Sort items by numeric prefix in image alt (001.jpg, 002.jpg
…) — FCR upload-order convention tracks seniority. Items without a numeric
prefix go last.
Better fix queued: sort by image y-position from per-page
capture-layout.js output. General solution; needs assembler-fulldev to
load <slug>.layout.json (currently only homepage is captured).
Status: alt-numeric shipped 2026-05-08; layout-y queued.
Multi-line address in Contact section¶
Bites: Live FCR contact pages render the address as a single <p> with
<br>-separated lines. Default cleanText flattens to one string, losing
the line breaks; the contact slot showed "Garvanbay Accounting 26A Parnell
Street Dungarvan Waterford Ireland" run-on.
Fix: lib/matchers/Contact.js#extract walks the address <p>'s child
nodes preserving <br> separators, yields addressLines: string[].
Translator emits structured <div class="contact-info-name"> + lines.
Status: shipped 2026-05-09.
Contact form recaptcha + Wix-internal hidden fields¶
Bites: Contact matcher surfaced every <input> including
g-recaptcha-response, hidden honeypots, and Wix-mangled names like
textarea_comp-l6.... The form rendered with junk fields.
Fix: lib/assembler-fulldev/translate.js#translateContact filters by
name regex (drop recaptcha/honeypot/empty-name/hidden/checkbox), maps
common field names → human labels (First name / Last name / Email /
Message). Falls back to a sane 4-field shape if extraction is too sparse.
Status: shipped 2026-05-09.
Phone/email as plain text instead of tel: / mailto:¶
Bites: Some FCR contact pages render contact info as styled rich-text
("Phone: 058 89555") not anchors. Contact matcher only pulled
<a href="tel:"> so phone went missing.
Fix: lib/matchers/Contact.js#extract — fallback regex scan for
IE-style phone patterns and email addresses in paragraph text when the
anchor pass yields zero.
Status: shipped 2026-05-09.
Map address fallback from footer¶
Bites: LocationMap matcher pulls address from the section's first
short <p> — flaky because the address often isn't in the section's prose
at all. Wix mapSrc is a parastorage URL that won't load outside Wix.
Fix: lib/assembler-fulldev/emit.js#applyCrossEntryBackfills looks up
the footer's "Address" column and joins the non-link entries to backfill
the LocationMap's address prop. Map-wcp embeds Google Maps via
maps.google.com/maps?q=...&output=embed (no API key).
Status: shipped 2026-05-09.
A Wix wrapper can hold TWO bands — so a section's FIRST heading is not its own, and the second band has no block at all¶
Bites: WCP's home fcr.faq carried the cta ribbon's copy as its heading
("Ready to transform your property? Call or email us anytime…") instead of the
live FAQ's own "Waterford County Painters FAQ". FAQ.extract read
$el.find('h1, h2').first() — reasonable, and wrong here, because Wix put the
cta band and the FAQ band inside one <section> (comp-lyzpdyil, plus its
nested twin comp-lyzscb43): two h2s, one accordion, no boundary between
them. This is the extraction-side twin of "A comp-id join is only as honest as
Wix's WRAPPER STRUCTURE" (below) — the same wrapper that poisoned the tone join
poisons the heading, one layer up.
Two traps in the obvious remedies:
- "Take the accordion's own heading" — the heading is not inside the
accordion subtree on any measured section, and it is not a DOM sibling
either (Wix's nesting), so
prevAll()finds nothing. Document order is the only usable proximity. - "Prefer the heading that says FAQ" — lexical, so it dies on any site whose FAQ band isn't titled "FAQ" (garvanbay's reads "Frequently asked questions"), and it silently mis-fires wherever a later heading happens to contain the word.
Fix: take the last h1/h2 that precedes the accordion in document order;
fall back to the historical .first() when there is no accordion (the h3
strategy) or nothing precedes it. Structural, not lexical. Blast radius measured
before shipping, across both reference sites: 45 sections unchanged, 0 declines,
2 changed — both the home wrapper double, which dedupe collapses to one block.
garvanbay seed: 0 diffs.
The half that is NOT an extraction bug — and it is the bigger one. There is no
<section> for the cta band at all, so our pipeline emits one block for a
two-band wrapper and the cta band has no home. Fixing the heading therefore
unmasked the loss: before, the cta copy was visible only because it was squatting
in the FAQ's heading slot ("Removing a duplicate can UNMASK a data-loss bug", below
— same family, different cause). The real remedy is segmentation — one source
section → two blocks, the SPLIT_TRANSLATORS shape translateAbout already uses
for About→content+gallery — not a bigger heading heuristic.
Detection rule: when a block carries a heading that belongs to the adjacent band, don't reach for a smarter heading picker — ask how many bands the section holds. If the answer is two, the heading is a symptom and the missing block is the defect. And check what the wrong value was accidentally surfacing before you correct it: a wrong field in the right place is often the only reason some content renders at all.
Status: heading fix shipped 2026-07-16 pt2 (headingBeforeAccordion in
lib/matchers/FAQ.js; both lanes — 47 static-lane FAQ sections translate clean,
garvanbay byte-identical). The cta-band segmentation gap is open (known-issues).
An h2 is not a band — measure the DEFECT (orphaned copy), not the SHAPE, and ask the PAGE not the section¶
Bites: measuring how many sections carry a two-band wrapper (the entry above),
to decide whether the fix is a matcher special-case or a buildSections change.
The obvious detector — a section with ≥2 distinct h2s — is wrong three
different ways, and each wrong version returns a confident number.
- Wix marks CARD titles as
h2. WCP's 4-card USP ribbon and garvanbay's 3-card article-teaser feed each give every card anh2. To the DOM they are indistinguishable from a genuine two-band wrapper — same nesting, same tag. No structural test separates them, so don't look for one: the question is not what the markup looks like, it is whether the copy survives into a block's props. A card title lands inprops.items[]; WCP's cta ribbon lands nowhere. Measure the defect, not its silhouette. - Section-scoped orphan-checking INVENTS the defect. Asking "is this h2 in
this section's props" reports garvanbay's home FAQ as a two-band wrapper —
its teaser h2s really are absent from the FAQ block's props. They are not
orphaned:
buildSections'post-list-pro-gallerybranch enumerates that band separately andBlogPostsemits it. Copy has a home on the PAGE or it does not; a section cannot answer. - Bidirectional substring matching HIDES the defect. Once the haystack is
every block on the page,
n.includes(p)lets any short prop ("24/7","Call") that happens to sit inside a longh2mark it homed. WCP's known orphan flipped tookand both sites reported zero. Containment must be one-way: some prop carries the whole heading (p.includes(n)).
Faults 1 and 2 inflate the count; fault 3 zeroes it. Only the third is dangerous, because "no problem here" is the answer nobody re-checks — the empty-pool lesson from ADR-0010's second amendment, in a different costume. It was caught only by the positive control (the known WCP case must be detected), which is the whole argument for running a detector against a case you already know the answer to before you believe it on cases you don't.
And the measurement paid off in a direction nobody scoped: the two-band SHAPE
is 2/2 sites (both home, both FAQ-paired) while the DEFECT is 1/2. The
variable is not whether Wix bundles two bands into one <section> — it always
does — but whether the second band has an independent enumeration path.
buildSections already grants one band type (blog) exactly that. The prevalence
question ("quirk or pattern?") stayed unanswered at n=2; the architecture
question got an answer anyway.
Status: scripts/measure-two-band-wrappers.js (2026-07-17), read-only, runs
the real seed lane. node scripts/measure-two-band-wrappers.js --all.
An honest re-attribution can LOWER a score — check WHICH row moved before calling it a regression¶
Bites: the FAQ-heading fix above made WCP home's S fall 81.6 → 79.6, on a page that had objectively improved (the FAQ band went from navy-with-the-wrong- heading to white-with-the-right-one, and an independent re-capture of the deployed page confirmed it). Read as a scalar, the slice looks like a regression.
It isn't. The aligner is heading-similarity-driven, so before the fix our FAQ
block — whose heading was the cta copy — paired with live's cta row, leaving
live's FAQ row unmatched → dropLive faq (weight 0.75). After, our FAQ
pairs with live's FAQ row correctly, leaving live's cta band unmatched →
dropLive content (weight 1.0). One drop either way; the score fell because the
drop is now attributed to the heavier, and true, role.
Kin to "A veto must fire on the ABSENCE of a thing, not on a metric's failure to PAIR it" (below) and to ADR-0011's "Slice 1 will make the numbers WORSE" — but with a wrinkle worth keeping separate: there, the site was unchanged and the instrument stopped lying. Here the site changed for the better AND the instrument stopped being fooled, in the same move.
Detection rule: when a fidelity axis moves the wrong way after a fix, diff the
rows, not the number — kind, role, weight, and what paired with what. A
drop that changes identity (faq → content) is a re-attribution, not a
regression, and the honest score is the lower one. Never reconcile it by relaxing a
threshold; the row list is the evidence.
Status: measured 2026-07-16 pt2 (WCP home; drop row verbatim
{kind:"dropLive", role:"content", weight:1, heading:"Ready to transform your property?…"}).
One emitter, two item shapes — a matcher→emitter map that points TWO matchers at one emitter silently destroys the odd shape out (and the loud-fail placeholder is the SYMPTOM, not the bug)¶
Bites: lib/cms/transformer.js mapped USPBar: fromLogoStrip ("USPBar is a
trust strip — render via the logo-strip variant"). It reads reasonable, and it was
wrong for the ~half of USPBars that carry no logos. The two matchers emit different
item shapes:
LogoStrip.extract → { logos: [{src, alt}] } image-shaped
USPBar.extract → { items: [{icon, text}], logos? } TEXT-shaped (+ optional logos)
fromLogoStrip coalesces props.logos || props.items and then reads l.src off
whatever it got. For a text USPBar that fallback hands it {icon, text} items, each
of which becomes {src: '', alt: '', href: ''} — the text is dropped on the floor
and the empty src renders a logo? loud-fail. On WCP: 237 empty items → 276
placeholders across all 24 pages, while live shows a 67px band of pure text
("7-Year Guarantee · Fully insured · Reliable & Long Lasting Results", zero images).
The static lane never had it — translateUSPBar maps to features-1 with the text
intact, so the two lanes had silently diverged on the same matcher.
Fix: split the emitter by what the section actually carries, not by what the
matcher is nominally "like": logos present → fcr.logo-strip; text items →
fcr.usp-bar (a new text-bearing block; the standalone form of the ribbon hero-4
already hoists). Never let a shape-specific emitter accept a foreign shape through an
|| fallback — make the fallback reject, or give the other shape its own emitter.
Detection rule (two, both cheap):
1. The placeholder is a symptom — find the PRODUCER before theorising. A wall of
logo?/img? loud-fails invites the story "the capture missed the images"
(it invited exactly that here, and the wrong diagnosis got written into the wiki).
The build's section-matchers.json sidecar names the matcher behind every
emitted block — one lookup showed 94 of 105 "logo strips" were USPBar, and the
whole capture-gap theory evaporated. Ask which matcher produced this block?
before asking why is its image missing?
2. Audit any matcher→emitter map for many-to-one entries. Two matchers pointing at
one emitter is only safe if their extract() shapes are identical. Diff the shapes;
if they differ, the emitter is silently coercing one of them. An emitter that reads
a field the other matcher never emits (l.src on a {icon,text} item) is a
guaranteed data-loss path, and it fails invisibly — the block still renders, just
empty. Cross-check the OTHER lane's translator: if it maps the same matcher
somewhere else entirely (features-1 vs logo-strip), that disagreement is the tell.
Status: shipped 2026-07-12 pt6 (fromUspBar in lib/cms/transformer.js, new
canonical usp-bar.astro + fcr.usp-bar type/adapter/dispatcher; WCP ui-missing
276 → 39, all 237 logo? gone, real logo strips preserved; worker 19a8b763,
pages-only D1 write).
Correction (2026-07-13 pt4): the split rule above — "logos present → logo-strip; text items → usp-bar" — is WRONG, and it left half the bug live. It was written as
if (props.logos?.length) return fromLogoStrip(...), so imagery wins. That is exactly backwards for the commonest band of all: an icon + text CARD. WCP's home carries four (vat.png→ "VAT Registered",shield.png→ "CRO No. 633944 Fully insured", …). They have both logos and text, so the logo branch took them,fromLogoStripkept only{src, alt}— and every card's text was destroyed again, this time without even a placeholder to betray it (thesrcwas present, so nothing loud-failed; it just rendered a bare row of icons where live shows icon-over-label cards). The operator found it by looking at the page; no instrument did.The correct rule: split on TEXT, not on imagery. A band whose items carry words is a ribbon or a card band, whatever imagery it also has. Only a band with no text at all is a true logo strip — WCP's real 11-logo client strip has zero text and still routes there correctly. The icon travels with the text as an
imageUrl(the live icons are client-uploaded images, not named glyphs — see the note below).Why the original was drawn: the pt6 session was staring at 237 text-only USPBars and fixed exactly that case. "Logos present → it really is a logo strip" is a reasonable read of a two-way split — but the shapes are not two, they are three: text-only, image-only, and both. A binary split on the presence of the rarer field silently mis-files the overlap. When you split an emitter by "what the section carries", enumerate the overlap case explicitly, and pick the field whose presence is decisive (text) over the one that is merely possible (images).
Also corrected:
iconis not the right sink for a scraped icon. pt6 seticon: ''deliberately, reasoning that the matcher'siconis an image URL while theIconprimitive takes names (ADR-0004's static map), so a URL there would loud-fail. That reasoning is right and the conclusion — drop it — was wrong: it silently lost the client's artwork. The fix is a separateimageUrlfield rendered as an<img>, withiconleft for genuine named glyphs. A field with no home is a missing field, not a field to discard.Still open: WCP's other band (the
7-Year Guaranteeribbon) has icons on live too — but they are inline<svg>vector art, not images. Copying Wix's vector art is the parastorage-IP problem (memoryno-wix-css-copy); mapping them onto named lucide glyphs is a design decision, not a mechanical one. That band renders text-only today. See known-issues.Shipped 2026-07-13 pt4 (worker
51891396):fromUspBarsplits on text;fcr.usp-bargainsimageUrl(schema → transformer → adapter →usp-bar.astro→transform-seed-imagesFIELD_MIGRATIONS). All four cards render text + icon from R2; the 11-logo client strips are unmoved.
An always-matches fallback matcher is zero-regression iff its score sits strictly below every other matcher's minimum positive score — and closing its silent-drop with a loud-warn exposes adjacent drops¶
Bites: ADR-0009 Slice 4b-i adds a lowest-priority GenericSection matcher that
must catch every otherwise-unmatched section (to close the silent-drop) WITHOUT
stealing any section a real matcher would claim. Priority alone can't guarantee
this — structure-matcher.js#matchSection uses priority only as a score
tie-breaker, so a low-priority matcher with a high match() score still wins.
The real guarantee is the score gap: GenericSection.match returns a constant
0.05, and no other matcher returns a positive score below About's 0.15
last-resort floor (every specific matcher scores ≥ 0.4; About's floors are
0.15/0.2/0.4). So the only sections GenericSection can claim are those where
every other matcher returned exactly 0 — the previously-dropped set. Proven on
WCP (24 pages: 0 generic-section blocks, block-type distribution unchanged) —
GenericSection is a faithful no-op on a site whose sections are all already
matched.
Fix: for any always-matches fallback, set its score to a constant strictly
below the minimum positive score any other matcher can emit, and confirm no
matcher returns a value in the open gap (0, fallbackScore]. Then the fallback
provably claims only the all-zero set (zero-regression by arithmetic, not by
testing). Don't rely on priority for last-resort ordering — it's a tie-breaker,
not a gate.
Corollary — closing one silent-drop with a loud-warn exposes adjacent drops on
the same path. Adding the ⚠ warn to transformer.js's two previously-silent
continues (ADR-0004) immediately surfaced a pre-existing silent drop the same
branch was hiding: FloatingCTA/FloatingSocial match on every page but have no
CMS PT translator, so the CMS lane was dropping floating rails entirely (48
warns/WCP run). Expect this — the loud-report you add for your target drop lights
up its neighbours; triage them (here: a deferred known-issue) rather than
narrowing the warn back to silence.
Detection rule: when adding a catch-all to a scored dispatcher, the
zero-regression proof is arithmetic — is the fallback's score strictly below every
other producer's minimum positive score, with an empty gap? If yes it can only
claim the currently-unclaimed set. And when you make a silent drop loud, read the
full warn output on a real site: a drop-closing change is also a drop-detector
for everything else falling through the same branch. (Render-surface proof for a
no-op-on-samples fallback: since GenericSection claimed nothing on either sample
site, it was proven by forcing a throwaway block through the live path and
reverting — but note a new block type needs a worker redeploy first or the
forced instance hits MissingBlock; cf. "A measured dimension whose value equals
the block default … prove the render surface another way".)
Status: shipped 2026-07-08 (ADR-0009 Slice 4b-i; lib/matchers/GenericSection.js
score 0.05, lib/cms/transformer.js fromGenericSection + loud-warns,
lib/assembler-fulldev/translate.js#translateGenericSection,
packages/components-v3/…/blocks/generic-section.astro). Live-proven on the
garvanbay demo, reverted.
Components / pipeline¶
A composite from a SINGLE surviving axis is that axis, not a composite — and null-vs-0 makes the failure shape-shift¶
Bites: the near-match composite re-normalises over the axes present, so with
S and G_det both null it emitted composite = T — a mesh 0-section site
(renders empty chrome, S+G_det unmeasurable) read composite 95.5 / 100, a
flattering row for a broken site. The gate still refused to ship it (the S
floor fails on null), but a config/queue reader trusts the composite number, and
fit-calibration would too if the row ever reached it. Worse, the failure
shape-shifts on page count: a 1-page mesh site scores S=null (bluestars),
a multi-page one scores S=0 (sweeney) — so a detector keyed on "S is null"
silently misses half the class.
Fix: composite() (lib/scorecard-gate.js) requires ≥2 axes; fewer
returns null and the gate holds honestly. The 2-of-3 partial (e.g. no --shots
→ G_det null) is untouched — it stays a legitimate ranking number the gate still
can't ship on. batch-scorecard already excludes any missingAxes row before
composite, so the fit was never fed a mesh site; this only kills the misleading
per-site number. Detection rule: when a metric is a weighted mean over
"present" inputs, decide the MINIMUM inputs below which the mean is meaningless
and return null there — a mean of one number is that number wearing the mean's
name. And when you count a defect class, count it by the pipeline's own signal
(here the S-axis alignment structural.pages[].rows, which reads 0 for BOTH the
null and the 0 shape), never by a proxy that one shape evades.
Status: shipped 2026-07-21 (composite() ≥2-axes rule + honest hold reason,
+4 gate-validate checks; scripts/measure-mesh-prevalence.mjs counts the class
by the S-axis input with positive+negative controls — 3/41 corpus, 7.3%).
A nested/doubled Wix /media/ URL mangles when split on the FIRST /media/ — split on the LAST, and a < N length guard won't catch a protocol-slug¶
Bites: lib/dom-pipeline.js self-hosts wixstatic images by taking the media
id from imgUrl.split('/media/')[1]. On sreenanandcompany.ie a nested/doubled
URL appeared in the crawled HTML —
https://static.wixstatic.com/media/https://static.wixstatic.com/media/<id>.jpg
— so split('/media/')[1] returned https://static.wixstatic.com/media/<id>.jpg,
whose first path segment is https:. The id-normalise
.replace(/[^a-zA-Z0-9._~-]/g, '_') turned the : into _ → media id https_,
which the mediaId.length < 5 guard waved through (it is length 6) → the ref
/assets/images/https_.jpg was baked into body.html and carried into the seed,
where transform-seed-images finally 403'd fetching
static.wixstatic.com/media/https_.jpg. The loud 403 was correct — but it fired
one stage too late, on a value produced two stages upstream.
Fix: split on the last /media/ (parts[parts.length - 1]) — the
innermost segment is the real id, and a normal single-/media/ URL is a no-op
(byte-identical) — plus a ^https?[:_]-prefix guard that skips a residual
protocol-mangle loudly instead of baking it. Extracted to a pure
lib/wix-media-ref.js#deriveWixMediaRef + wix-media-ref.validate.mjs.
Detection rule: a .split(sep)[1] that assumes the separator appears once is
a latent bug the moment the payload can contain the separator (a URL nested in a
URL); take the last occurrence when the innermost is the one you want. And a
length/character guard tuned to reject garbage (< 5) won't reject plausible
garbage (https_) — guard on the value's SHAPE (does a real id start with a
protocol?), not just its size. Recovery for a value baked upstream is a re-run of
the producing stage (--force crawl), not the consuming one.
Status: shipped 2026-07-21 (dom-pipeline wired to deriveWixMediaRef;
sreenanandcompany box re-crawl confirmation pending).
A fidelity metric that scores presence + dims is blind to mis-renders and non-image asset-drops — close them with additive reporting projections, never by moving the score¶
Bites: the near-match scorecard vetoes on absence (section drop → criticalSectionDropped;
unmatched → genericFallbackCritical; missing image → loud-fail). But a section that
matched and rendered the WRONG variant banks a large presence share, and its dim
disagrees cost ≈0 — matcherGap.propsWithheld counts only ours-missing, never
disagree. And a non-image asset dropped (hero video, collapsed carousel) degrades
to a poster/single frame a screenshot can't distinguish from success. Both are silent by
construction: the pt4 WCP hero mis-render was caught by operator report, not the metric.
Fix: add the missing signal as an additive reporting projection — never by
changing the scored dims, which moves S and breaks the byte-identical invariant every
scorecard slice holds (see "A diagnostic that projects debt already inside a score must
NOT re-penalise"). variantMismatch (lib/structural-diff.js) counts decisive-dim
disagrees on critical-role sections; the asset-manifest.json sidecar
(lib/cms/asset-manifest.js + capture assets + seed-from-build) records
expected-vs-supplied asset counts + migration status. Both report now; gate vetoes +
thresholds wait for Slice 6 calibration. Scope trap: only dims already in the scored
DIMS (tone, imageSide) can be counted without moving S — promoting hero-variant/
scrim/textTone to scored dims is itself a calibrated change, not free.
Detection rule: when a fidelity bug "looks obvious" but the metric never flagged it, classify it — absence (already caught), wrong-value (variant-mismatch), or missing-asset (asset-manifest). A new blind spot gets a new additive projection, not a rescore. And the metric that catches YOUR OWN silent degradation is the whole point of re-measuring the deployed output.
Status: shipped 2026-07-11 (reporting mode; commit 6bc7a95). Full backlog +
process: capability-backlog.md.
An item-image adapter must resolve via getImageSrc (ImageField-shaped), not scalar-only resolveImageRef — a migrated repeater image arrives object-shaped¶
Bites: reseeding WCP with r2:-migrated images crashed every page with
TypeError: ref.startsWith is not a function at resolveImageRef, truncating the SSR
response mid-render (HTTP 200, ~13 KB, body cut off right after the header). Root cause:
transform-seed-images.mjs's sibling shape rewrites repeater item images
(fcr.logo-strip/gallery/footer items[].src) to an object {src, originalName},
but the LogoStrip/Gallery/Footer adapters called resolveImageRef(item.src) —
which is scalar-only (ref.startsWith(...)) and throws on an object. The
top-level-field adapters (Hero/About/ServiceGrid/TeamGrid) already used the
shape-agnostic getImageSrc and were fine; these three were the stragglers.
Fix: every adapter that reads a possibly-migrated image field must resolve it with
getImageSrc (packages/components-v3/src/lib/get-image-src.ts), which accepts the
ADR-0005 ImageField (scalar string OR {src, originalName?, alt?}) and funnels
through resolveImageRef internally. Use resolveImageRef only on a value known
to be a scalar ref. Swapped all three adapters — this also pre-empts garvanbay's latent
copy (its seed has 7 of the same unflattened objects that would crash identically once
surfaced/redeployed).
Detection rule (loud-report exposes adjacent drops): a whole-page SSR truncation
with a 200 status = an exception thrown mid-stream; wrangler tail names the throwing
function in one shot (here resolveImageRef ← LogoStrip). And when a data migration
changes a field's shape, grep every consumer for the scalar-only resolver before
reseeding — a shape the top-level adapters tolerate can still crash a repeater adapter
that skipped the shape-agnostic helper.
Status: shipped 2026-07-10 pt4 (Footer/Gallery/LogoStrip adapters → getImageSrc).
An image field that never renders on-page is still a shipped image field — the migration walker and the resolver must cover the INVISIBLE ones too¶
Bites: fcr.business.imageUrl is the og:image + LocalBusiness JSON-LD
image — the picture every link preview and Google rich result uses, and the one
image field that is never painted on a page. It sat as a raw
/assets/logos/… path (dead on an R2-served worker) through every image
migration, every loud-fail sweep, and a placeholder hunt that drove the visible
placeholders to zero — because nothing that looks at pages can see it. Two
independent misses, both caused by the field being invisible:
transform-seed-images.mjs'sFIELD_MIGRATIONStable had nofcr.businessentry, so the walker never touched it.Base.astrofed the field straight intonew URL(...)with nogetImageSrc— the only image consumer in the CMS lane resolving nothing. So even a correctly-migrated value would have emitted garbage (r2:…is not a URL; the object shape stringifies to[object Object]).
The asset was in R2 the whole time, under the very key the header logo already
used — so the reconciliation's referenced-but-missing bucket was the only
instrument that could see the bug, and it is reporting-only.
Fix: FIELD_MIGRATIONS['fcr.business'] = [{ path: ['imageUrl'], shape:
'object' }]; Base.astro resolves via getImageSrc for both og:image and the
JSON-LD image. Idempotent — the field hashed to the existing R2 object (0
uploads), which is itself the proof that the asset was never missing.
Detection rule: enumerate image fields from the schema, not from what you
can see on a rendered page. Any field feeding <meta>, JSON-LD, a sitemap, or a
feed is invisible to placeholder sweeps, screenshot diffs, and the whole
loud-fail apparatus — it can only be caught by a data-side reconciliation. When
rendered-placeholders is 0 but referenced-but-missing is not, believe the
latter.
Status: shipped 2026-07-13. referenced-but-missing 1 → 0 on WCP.
Floating CTA + Floating Social — components built¶
Bites originally: Both translators emitted deferred passthroughs referencing component files that didn't exist; build failed with rollup import errors as soon as the matchers fired (wfpainters homepage).
Fix:
- lib/components-v3/blocks/floating-cta-wcp.astro — pinned-corner pill
button. position: 'bottom-right' for tel/mailto links, 'right' for
enquire-page links (vertical side-rail rotated 90°). Hidden below 640px
to avoid colliding with sticky-mobile-cta.
- lib/components-v3/blocks/floating-social-wcp.astro — vertical icon
stack pinned to viewport edge. Maps platform names (whatsapp / facebook
/ google-reviews / etc.) to lucide icons. Same mobile-hide rule.
- lib/assembler-fulldev/translate.js — translators emit real components
with cleaned items (filter empty url) and infer position from href shape.
Status: shipped 2026-05-09.
Map mapSrc points at parastorage¶
Bites: Wix's mapSrc extracted from iframes is a Wix-internal URL that
won't resolve outside their CDN.
Fix: Ignore extracted mapSrc; embed Google Maps via the no-key
maps.google.com/maps?q=ADDRESS&output=embed URL using the address text.
Status: shipped 2026-05-09.
Phase 1 captures desktop viewport only — mobile-only widgets miss¶
Bites: Wix mobile-only QuickActionBar (sticky bottom phone+email
strip) renders only at mobile widths. Phase 1 captures at 1440x900 →
QuickActionBar never enters our DOM extract.
Fix: Synthesise the bar from the TopBar entry's contact data.
lib/assembler-fulldev/emit.js injects <StickyMobileCta phone email />
at </body> whenever phone or email exists. lib/components-v3/blocks/sticky-mobile-cta.astro
hidden above 640px, reserves 3.25rem body padding-bottom on mobile.
Status: shipped 2026-05-08.
Multi-tone heading colour spans¶
Bites: Wix lets editors recolour individual words inside a heading via
inline <span style="color: #...">. Plain-text cleanText loses that;
"GARVANBAY ACCOUNTING" rendered as a single colour instead of the live
yellow-green + cyan two-tone.
Fix: lib/matchers/About.js#extractHeadingHtml walks heading children
preserving color / font-weight / font-style inline styles, sanitises
URL/parens/angle-brackets to block XSS, drops unstyled wrappers. Translator
prefers headingHtml over escaped plain text.
Status: shipped 2026-05-08.
Service-section accent leaking onto Hero / welcome / CTA-strip¶
Bites: [data-slot="section-prose"] :is(h1,h2,h3)::before/::after rule
applied to every section-prose, so once garvanbay's
--service-accent-above-display flipped to block, a yellow line
appeared above Hero, content-1, and CTA-strip headings too.
Fix: lib/assembler-fulldev/theme.js scopes accent rules to
.content-2 only. Service-heading-color rule too.
Status: shipped 2026-05-08.
Subpage routing — emit per-slug page + content files¶
Bites: First subpage build clobbered the homepage's index.astro and
content.ts.
Fix: lib/assembler-fulldev.js threads slug through meta.
lib/assembler-fulldev/emit.js writes src/pages/<slug>.astro and
src/data/content-<slug>.ts (homepage + slug='index'/'homepage' stay at
index.astro + content.ts).
Status: shipped 2026-05-08.
Cross-entry backfills must run before content.ts is serialized¶
Bites: Map address backfill mutated layoutMap inside
emitIndexAstro, which runs after emitContentFile. The mutation
never reached the runtime data.
Fix: lib/assembler-fulldev/emit.js#applyCrossEntryBackfills lifted
out and called at the top of emitProject, before any file writes. Any
future cross-entry prop derivation should go in there.
Status: shipped 2026-05-09.
Primitive emits nothing for unrecognised input (silent-fail class)¶
Bites: primitives that should always render visible content can silently emit nothing on missing/unmatched input. The whole class:
Iconwithimport.meta.glob("node_modules/lucide-static/icons/*.svg")— when consumed via Vite alias (nonode_modulesadjacent to the primitive file), the glob resolves to an empty set with no build error. Every name → no match → silent emit. Manifested asarrow-rightfailing across every ServiceGrid tile + phone/mail icons missing from StickyMobileCta.Imagewith{src && (...)}— silently emits nothing when src is missing. Manifested as TeamGrid Dave card with no avatar (no img element, no broken-image indicator, no operator signal).LogoImage,AvatarImage— same conditional-render pattern.Ratingwith{rating && (...)}— silent on0,NaN, malformed numerics.
Fix: any primitive that's expected to render something visible
must render a loud-fail placeholder + log a console warning when
input is missing/unrecognised. Pattern matches MissingBlock at the
dispatcher layer:
{Comp && <Comp ... />}
{isUnresolved && (
<span class="ui-missing-icon" role="img" ...>{placeholderText}</span>
)}
Plus console.warn in the frontmatter.
Detection rule: when reading any primitive, ask "what does this render given input it doesn't recognise?" If the answer is "nothing" and the primitive isn't structurally optional, it's a silent-fail bug.
Status: shipped 2026-05-09 — Icon (rewritten to explicit static map, see ADR-0004), Image, LogoImage, AvatarImage, Rating. Apply this discipline upward when any new primitive lands or when a layer above (matchers, schema validators) comes into focus.
Workspace-package utility classes need an explicit Tailwind @source¶
Bites: Tailwind v4 (@tailwindcss/vite) auto-detects its content root
at the consuming app (apps/cms) and ignores node_modules.
@fcr/components-v3 is reached via a node_modules symlink, so any
utility class that appears only in the component library is never
generated — the class lands in the HTML with data-* attributes intact
but no CSS rule, so it silently doesn't paint. Same root shape as the
Icon-glob silent-fail above (a node_modules/Vite-alias boundary breaking
a scan) — one layer up, at the CSS build.
Manifested on Slice 2b: section tone bands (bg-muted/bg-secondary/
bg-foreground/plain bg-primary, only in section.astro) rendered
white. bg-card/bg-destructive were also silently dropped. Classes that
also appear in apps/cms source (bg-background, bg-accent,
bg-primary/85 scrims) generated fine — which is what masks the gap.
Fix: emit @source "<rel-path-to-packages/components-v3/src>"; into
the Tailwind entry (global.css). Because global.css is regenerated
each prebuild, the directive is emitted from emitGlobalCss(theme, {
sources }) (commit 13deb1b), not hand-added. The @source path is
relative to the CSS file, so each consumer (CMS vs per-site static build,
different output depth) must pass its own — the per-site lane still needs
it (known-issues).
Detection rule — verify render via the built artifact, not the wiring.
The data path and HTML threading all looked correct on inspection; only a
production npm run build + grep of the emitted CSS (.bg-muted{…}
present? note Tailwind groups selectors: .bg-muted,.bg-muted\/50{…})
revealed the gap. For any "does this style/class actually apply?" question,
build and grep the CSS — a class in the HTML proves nothing about whether
a rule exists for it.
Status: fixed for CMS 2026-07-05 (13deb1b, ADR-0007). Per-site
assembler-fulldev/emit.js carries the same latent gap — see known-issues.
A per-site CSS override must be UNLAYERED to beat an unlayered :root default — @layer loses¶
Bites: ADR-0009 §5 frames per-site design variation as living in a
"higher cascade layer (@layer site > @layer blocks)". Taken literally for
the token values, that's a trap. emitGlobalCss emits its aesthetic-token
defaults (--radius, --btn-radius, --section-py, --site-shadow-*) in an
unlayered :root {}. In the CSS cascade, an unlayered declaration beats
any @layer — so an @layer site { :root { --radius: … } } override would
silently lose to the default and paint nothing. The classes/vars would all be
present in the emitted CSS; only a computed-value check would reveal the no-op
(same "verify via the built artifact, not the wiring" discipline as the shadow
@theme inline self-reference trap in aesthetic-token-contract.md).
Fix: emitSiteCss emits a plain (unlayered) :root {} appended AFTER
emitGlobalCss's output. Same selector, same specificity, same (unlayered)
layer → source order decides, so the later site block wins cleanly with no
!important. Both lanes concatenate emitGlobalCss(...) + emitSiteCss(design).
// theme.js — the override rides source order, not a cascade layer
const css = emitGlobalCss(theme, opts) + emitSiteCss(design);
// emitSiteCss returns '' for absent/empty design → byte-identical to before.
Detection rule: when a generated CSS var override "does nothing", check
whether the thing it's overriding is declared unlayered while the override
sits in an @layer. Custom-property values follow the same layer-precedence
rules as any declaration: unlayered > any named layer, regardless of source
order or specificity. @layer site is the right home for block-targeting
rules (data-slot/data-tone selectors, later slices) — not for :root token
values that must beat an unlayered default. For those, later + unlayered wins.
Status: shipped 2026-07-06 (ADR-0009 Slice 2a, commit dec213b;
emitSiteCss in lib/assembler-fulldev/theme.js).
An unlayered universal reset in an Astro component style silently disables every utility class — the evil twin of "unlayered beats @layer"¶
Bites: apps/cms/src/layouts/Base.astro carried a hand-rolled reset in its
<style is:global>: *, *::before, *::after { box-sizing: border-box; margin: 0;
padding: 0; }. Astro component styles are unlayered, and unlayered beats any
@layer regardless of order — so this one line nullified every Tailwind
padding/margin utility across the whole CMS lane. The utilities were all in the
built CSS (.px-3{padding-inline:calc(var(--spacing)*3)} present, @layer
utilities), the classes were all in the HTML, and the computed padding was still
0. Manifested as the operator-reported header nav defects (items run together,
zero item padding) and a visibly squashed hero — misdiagnosable as a block-CSS bug
for hours. The entry above is the same law used deliberately (an unlayered
site override outranking layered defaults); this is the law firing by accident.
Fix: delete the hand-rolled reset. Tailwind v4's preflight ships the identical
box-sizing/margin/padding reset in @layer base, correctly ranked under
utilities — the manual copy added nothing except the unlayered footgun. Fixed for
the CMS lane 2026-07-08; scorecard re-run confirmed no regression (G_det actually
rose 87.8→88.3 — the restored utilities moved the render closer to live).
Detection rule: when a utility class is in the markup AND its rule is in the
built CSS but the computed value is still the reset value, don't stare at the
block — enumerate the rules that match the element and their layer
(rule.parentRule.name walk in the console): any *-selector rule reporting
UNLAYERED after the utilities is the killer. Grep the repo for *::before
resets whenever adopting a Tailwind-layered design surface — every Astro
<style is:global> is unlayered and outranks all of it.
Status: shipped 2026-07-08 pt6 (Base.astro reset removed; nav metrics
tokenized on header-wcp — --nav-font-size/--nav-item-px/--nav-row-h,
FCR-typical defaults measured on live garvanbay).
The local emdash admin is unreachable while the Access adapter is configured — run dev with CF_ACCESS_TEAM_DOMAIN= (empty) and enter via the dev-bypass URL¶
Bites: with auth: access(...) configured (which astro.config.mjs does
unconditionally — the team domain has a non-empty default), emdash only injects the
external-auth routes: injectBuiltinAuthRoutes (passkey and /_emdash/api/auth/
dev-bypass) is skipped entirely. But in DEV the middleware still demands passkey
auth for /_emdash/admin. Net: the login page redirect-loops forever
(/login → auth/mode → 302 /admin → /login …) and there is no door — the
dev-bypass 404s. Easy to misread as a session/cookie bug.
Fix: the config already gates on the env var — set it EMPTY to disable the Access adapter locally, which flips auth to passkey mode and injects dev-bypass:
cd apps/cms && CF_ACCESS_TEAM_DOMAIN= npm run dev
# then authenticate the browser via:
# http://localhost:4321/_emdash/api/auth/dev-bypass?redirect=/_emdash/admin
astro dev reads the miniflare D1 (.wrangler/state/v3/d1/*), not data.db
and not remote — see "astro dev reads a miniflare D1". A fresh miniflare D1
lands on the admin's first-run /setup screen; "Include sample content" applies
the baked seed (.emdash/seed.json, regenerate with scripts/emit-seed.mjs).
Detection rule: an admin login page that bounces 200 /login → 302 /admin
forever in the dev log = auth-mode mismatch (mode endpoint says external,
middleware wants passkey), not a broken session. Check whether the external-auth
adapter is active in dev before debugging cookies.
Status: captured 2026-07-08 pt6 (header/nav slice, part A diagnosis).
"Saved ✓" that never persists — emdash 0.9.0 admin fires two racing PUTs per Block-Kit save and the stale one wins¶
Bites: editing any fcr.* block's fields through the emdash admin's Block-Kit
dialog (pages AND globals) reports "Saved ✓", bumps updated_at, and persists
nothing. One Save click fires two concurrent PUTs with different payloads
(explicit save + autosave, serialized from doc snapshots one edit apart); on a
drafts-enabled collection both create draft revisions and the stale one takes
the row's draft_revision_id (the fresh edit survives only as an orphaned
revision); on a no-drafts collection both write the content column and the stale
one lands last — total silent loss. The operator experience is "the menu isn't
editable"; the WS-3 "edit → publish → live" success was the race landing
fresh-last that day. Direct API PUTs (curl, scripts) persist fine — the server
path is sound; the client races itself, and the server compounds it by not
serializing concurrent draft-creates (skipRevision only updates in-place when a
draftRevisionId already exists — never true for two racers on a clean row).
Fix (pending upstream — the 2026-07-09 upgrade did NOT clear it): the
platform was upgraded 0.9.0 → 0.28.1 (1.0.0 on npm is an accidental
reverted publish, not the head). Upstream had closed our bug (#1158) as "fixed
by #1119" (0.15.0) — but the race reproduced on 0.28.1: two divergent PUTs
per save-click, and in 1 of 2 UI trials the stale one took the draft pointer
(edit orphaned, gone after reload). The client's two unserialized mutations and
the server's non-transactional draft-pointer write are unchanged 0.9.0→0.28.1;
there is no autosave-disable option. File the repro against #1158. Harm
reduction shipped 2026-07-08 pt6: the globals collection declares
supports: ["drafts","revisions"] (seed-from-build.js + committed seeds + live
_emdash_collections) so a lost edit is at least a recoverable revision, not
gone. Content fixes meanwhile ship via surgical D1 patch, not the admin.
Detection rule: "saved but not persisted" + updated_at moving = count the
revisions created per save-click and check which one the draft pointer references.
Two revisions in the same second, pointer on the edit-less one → this race. And
when diagnosing an admin bug, test the API directly before blaming the schema or
the renderer — a clean direct PUT localises the fault to the client in one move.
Status: diagnosed 2026-07-08 pt6 (local repro, pages + globals); upgrade slice ran 2026-07-09 (→0.28.1) and the race persists — blocked on a real upstream fix. See known-issues "Header / nav menu".
The biggest version number on npm may not be the head — verify dist-tags and sibling-package lockstep before targeting a major¶
Bites: the emdash upgrade slice was scoped in the wiki as "0.9.0 → 1.0.0"
because emdash@1.0.0 exists on npm and reads like the obvious target. It is an
accidental publish: a changesets/workspace-cycle bug escalated a minor to a
major and CI released every package as 1.0.0 (2026-04-27); upstream reverted the
same day (PR #796), added CI guards that fail on any non-private 1.x, and
continued at 0.8.0 → 0.28.x. Three tells, all checkable in two npm view
commands before any scoping: (1) dist-tags.latest was 0.28.1, NOT 1.0.0 — the
registry's own head pointer disagreed with the max version; (2) the sibling
package we depend on (@emdash-cms/cloudflare) had no 1.0.0 at all, and its
latest pinned emdash@0.28.1 exactly (lockstep family — mixed versions can't
even install coherently); (3) 1.0.0's publish timestamp predates 0.8.0's.
Installing 1.0.0 would have meant April-era code plus phantom peer-deps, sold to
ourselves as "the newest release".
Fix: before locking an upgrade target, run npm view <pkg> dist-tags
versions time and npm view <sibling> dist-tags dependencies for every
lockstep sibling. Target = the dist-tag head that exists coherently across the
whole family; pin lockstep families exactly (no ^) so a future install
can't half-upgrade them.
Detection rule: max(versions) > dist-tags.latest is a red flag, not a
bonus — check the publish timestamp and the repo's release/tag history before
believing a version that outranks its own latest tag. And any monorepo family
where one package pins another with an exact version is lockstep: verify the
target version exists for every member, or the upgrade is unschedulable at that
number.
Status: applied 2026-07-09 (emdash upgrade slice; target corrected
0.9.0 → 0.28.1 during scoping, exact pins in apps/cms/package.json).
A Wix site-level API call takes wix-site-id ONLY — adding wix-account-id returns HTTP 400, and a Wix HTML error means "fix the request", not "fix the key"¶
Bites: the first GET https://www.wixapis.com/site-media/v1/files call
(Media Manager listFiles, ADR-0010 spike) returned an HTML 400 Bad
Request — a Wix.com-branded page, NOT a JSON API error — which reads like a
malformed URL, not an auth problem. The API key + site id were correct; the
request just carried BOTH wix-site-id and wix-account-id headers. Site-level
Wix APIs reject the account-id header: the identical call with wix-site-id
alone returned 200 files=94.
Fix: site-level calls (site-media, business-info, blog) send
Authorization: <key> + wix-site-id: <siteId> only. Account-level calls
(e.g. Query Sites) send wix-account-id instead — never both on one call. And
read the error SHAPE: a Wix HTML error page (vs a JSON {message}) is a
gateway-level malformed-request signal (bad header/param) — "fix the request
shape"; a genuine auth failure returns JSON 401/403 — "fix the credentials".
(The account-level Query Sites also needs the "Get Sites List" scope, absent on a
media-only key → 403; not needed when the site id is already known.)
Detection rule: when a REST call fails, branch on the body format before blaming credentials — HTML/gateway page ⇒ malformed request (headers, params, URL); JSON error object ⇒ the API reached its handler (auth/permission/validation you can read). Isolate by stripping to the bare endpoint + one header at a time.
Status: captured 2026-07-12 pt4 (Wix API media spike; ADR-0010). Auth shape baked into the client contract there.
A Wix media listFiles returns ONE FOLDER, not the library — the POOL is searchFiles, and a truncated pool reports PRESENT assets as MISSING¶
Bites: ADR-0010's spike enumerated WCP's media with GET
/site-media/v1/files and got 94 images — which looked like the library and
happened to answer the spike's questions correctly (the hero video and all 7
carousel frames sit in the root folder, so the by-id match hit 7/7). It is not the
library. That endpoint lists a single folder, defaulting to media-root, and a
real FCR site files its media into folders (Commercial Painting, Residital,
Repair, logos, Misc, …). The true WCP library is 392 files. The error is
silent and it fails in the dangerous direction: an "is this asset at source?"
check answered from the root folder confidently reports a present asset as
missing — the first asset-reconciliation run declared 91 of 94 library files
"never pulled", which was nonsense.
Fix: the media POOL is POST /site-media/v1/files/search (body
{mediaTypes:[…], paging:{limit,cursor}}, cursor at pagingMetadata.cursors.next,
stop on hasNext:false) — it spans folders. Keep listFiles only for a deliberate
single-folder listing. Everything that reasons about "what exists at source" (the
video lookup, the reconciliation) reads searchFiles.
Detection rule: before trusting a third-party "list" endpoint as an enumeration of everything, check whether it is scoped (folder / collection / namespace) and whether the API has a separate search verb — a list that returns a plausible-looking count is the easiest kind of truncation to miss. Cross-check the count against an independent lower bound you already hold (we had 144 R2 objects for a "94-file" library — the contradiction is what exposed it). A spike that gets the right answer from a wrong pool is still a wrong pool.
Status: captured 2026-07-12 pt5 (ADR-0010 amendment; searchFiles in
apps/cms/scripts/wix-api.mjs; both consumers switched).
SUPERSEDED 2026-07-13 pt3 — "the POOL is searchFiles" is FALSE on the second
site. See the next entry. The reasoning above still holds; the conclusion was
one site's worth of evidence.
A media-pool enumeration derived from ONE site is a HYPOTHESIS — and an EMPTY pool is the lie that reads as success¶
Bites: the entry above corrected listFiles → searchFiles after WCP showed
listFiles was root-folder-scoped (94 of 392 files — a 4× under-report). Correct
diagnosis, correct fix, and a contract that broke the instant a second site was
added. garvanbay:
searchFiles |
listFiles (root) |
folders | |
|---|---|---|---|
| WCP | 393 | 94 | 7 |
| garvanbay | 0 | 41 | 0 (flat library) |
garvanbay returns zero from searchFiles — it is absent from Wix's media
search index (an older site). Not an auth failure: the call 200s, the key has
access, the root listing works. So WCP alone "proves" search ⊃ list; garvanbay alone
would "prove" list ⊃ search. Neither is the pool.
And the failure mode is inverted from the first one, which is what makes it worse.
A truncated pool reports present assets as MISSING — loud, alarming, investigated.
An empty pool reports zero asset debt — it certifies the site clean. Nobody
audits an instrument that says everything is fine. Had we reconciled garvanbay from
searchFiles, we would have shipped a report saying it had no asset gaps, and
believed it, because it was the answer we were hoping for.
Fix: mediaPool() in apps/cms/scripts/wix-api.mjs — the union of
searchFiles ∪ listFiles(root) ∪ listFiles(every folder), deduped by media id,
returning {files, sources, warnings} and warning loudly when the two
enumerations disagree. asset-reconcile.mjs records wixPoolSources +
wixPoolWarnings in the report, so an under-reported pool can never be read as
"no debt". Verified: WCP 393 (union == search, folder walk confirms it — no
regression), garvanbay 41 (union == list, rescued from 0).
Detection rule: any contract of the form "X is the complete enumeration" derived from a single reference site is a hypothesis. Before locking it, ask what the other site would say — and if you only have one site, write the contract as a union or a cross-check, not a pick. The tell that you have this bug: an enumeration that returns a plausible zero. A zero is a claim, and it is the one claim that never gets challenged, because it looks like there is nothing to investigate.
Status: found + fixed 2026-07-13 pt3, immediately on bringing the second site onto the API — which is exactly when a one-site contract is cheapest to falsify.
A Wix gallery's VIDEOS are VOD-channel HLS, not Media Manager assets — the by-id pool can't see them, and the captured URL is token-gated¶
Bites: the gallery-video render slice measured WCP's 13 gallery videos cleanly
(v8 per-section attribution, mediaRefs.videos[].sectionId) and stamped the refs
into fcr.gallery.videos — then every one reported video-not-in-wix-pool, on
a site whose Media Manager pool holds 46 videos. Not one of the 13 matched any
id form in the pool (file id, media.video.id, or posters[].id) — and neither did
the hero id, in the capture's id form. The obvious inference ("the pool enumeration
is still incomplete — a fourth amendment to mediaPool") is wrong: these
videos are not Media Manager assets at all. They are Wix VOD "channel videos" — a
separate Wix product. The live network trace settles it: the player enumerates them
from GET /_api/vod/public/v3-to-v2/public/lists/<channelId>?media_type=secure_video
(items carry source_url: null — there is no plain mp4), gets a JWT from
GET /_api/v1/access-tokens, and streams tokenized HLS
(repackager.wixmp.com/.../master.m3u8?token=<JWT> + .ts segments). The captured
mp4-shaped URL (gcp-repackager.wixmp.com/.../,360p,480p,/mp4/file.mp4) 400s/403s on
direct fetch even with a browser UA + live-page Referer — the token is required and
short-lived. So the FCR content plane has three distinct Wix media surfaces, not
two: Media Manager images, Media Manager videos (mediaPool, ADR-0010), and VOD
channels (a different API + HLS delivery).
Fix (deferred as its own capability): acquiring VOD-channel videos needs a
VOD-list-API enumeration → per-video access token → HLS→mp4 remux (ffmpeg) → R2
upload — a new pipeline and an ADR-0010 amendment, not a mediaPool tweak. Until it
lands, migrateGalleryVideos routes each ref through the Media Manager
migrateVideoRef path, which honestly reports video-not-in-wix-pool and leaves the
raw line recorded (never fabricates a URL, never ships a dead <video> — the
producer/render half is inert without acquired refs). The render + producer + schema
+ adapter shipped and are reproducible-from-capture; only acquisition is blocked.
Detection rule: a Wix widget whose assets don't appear in the Media Manager pool
is a signal it belongs to a different Wix product, not that the pool is truncated
again (the pool-truncation lessons above condition you to reach for a pool fix — resist
it). Confirm by reading the live network trace, not by re-deriving the pool: the
_api/<product>/... path names the product (here _api/vod/...), and source_url:
null + a ?token= HLS request means the asset is streamed, not downloadable. And a
"measured but un-acquirable" asset is a legitimate split point — ship the measurement
and render (proven, inert), defer acquisition, and never stamp a dead ref into a
committed seed to make the numbers move.
Status: found 2026-07-15 (gallery-video slice; render/producer/schema/adapter shipped, VOD acquisition deferred — see known-issues "WCP gallery videos are VOD-channel HLS" + ADR-0010).
Before scoping an ACQUISITION pipeline, measure that the SOURCE still serves the asset — the live platform's own player can be broken¶
Bites: ADR-0010 Slice F was scoped to acquire WCP's 13 gallery videos:
enumerate the VOD channel → mint a per-video token → pull the HLS → remux → R2.
Every step of that plan presumes the VOD API answers. It does not. Measured
2026-07-16 on the live site: every _api/vod/.../lists/<channelId> and
.../play/<id> call returns 403 "Internal server error of auth middleware",
zero repackager/.m3u8/.ts requests ever fire, and zero <video>
elements ever mount. The widget still paints its poster and a "Play Video" button,
so the page looks like it has video — the 403s are in the console, not on the
canvas. The 07-15 entry above describes that same endpoint returning items
(source_url: null), so this is a change in the source, not a re-reading of it.
The consequence is bigger than one slice: acquisition, a channel-id producer, and
the ffmpeg remux are all worthless while the endpoint 403s, and the same wall
covers the fcr.about ×11 bucket (its player fires the same call, gets the same
403). ~24 of WCP's 27 missing videos sit behind one account-side question.
And it puts a question mark over the debt itself: videoDebt counts videos
live references. If live cannot serve them to a visitor either, a replatform that
omits them may be faithful to what a visitor sees — the veto may be measuring
references, not renders.
The instrument nearly told three different wrong stories first, and each was
plausible enough to have been written down:
1. Headless detection — the signed ctToken literally embeds
HeadlessChrome/149…. Discriminator: re-run with a real Chrome UA → still 403.
2. Cookie consent — the banner's own text warns that declining functional
cookies "may disrupt the website experience", and the first probes never
accepted it. Discriminator: accept, re-run → still 403.
3. The wrong tab — getByText(/^videos$/i).first() clicked the first of four
category galleries, so a screenshot showed IMAGES still active and the player
apparently idle. Discriminator: enumerate all four, click each.
Only after all three were eliminated (headed browser, real UA, consent accepted, every tab) does "the source is broken" survive.
Detection rule: an acquisition plan's first step is not "how do I fetch it" but
"does the source still serve it?" — measure playback on the live page (mount,
readyState, network) before designing the pipeline. And when a probe reports a
uniform failure, spend the cheap discriminators on your own instrument before
believing the finding: UA, consent, and did you click the thing you think you
clicked are each one run, and each can manufacture a confident wrong story. A
screenshot is what catches the third one — it is the only probe that sees what you
did rather than what you meant.
Status: measured 2026-07-16 pt2 (5 runs: headless/headed × default/real UA × consent declined/accepted × all 4 tabs). Slice F blocked at source; owner checking the Wix Video app's status — see known-issues.
A set-difference is not a number until you have measured the INTERSECTION¶
Bites: known-issues recorded "live WCP references 27 distinct video ids (so of
the pool's 46, 19 are library cruft)". The 19 is 46 − 27, and that subtraction
is only meaningful if the 27 are a subset of the 46. Measured 2026-07-16: the
intersection is zero — the sets are disjoint. The 27 referenced videos are all
VOD (absent from the Media Manager pool); the pool's 46 are all unreferenced. So
"19 cruft" described nothing real, and it had been read forward as if it did.
The tell was available and unread: the same entry notes the hero "does not appear
in the pool in the capture's id form" — i.e. the one video known to be in the pool
also failed the join. A join that misses your positive control is not a join.
(The hero is in the pool as 8b4be4_a0c791…; it reached R2 via its poster id,
not its video id — so the capture's video refs and the pool's ids were never
comparable spaces at all.)
Detection rule: before publishing |A| − |B| as a count of anything, measure
A ∩ B and check it against a positive control — an element you know is in both.
If the control misses, the join is broken and every number derived from it is
decoration. Kin to "A reconciliation is only as honest as its JOIN KEY" (above);
that entry is about the join being wrong, this one is about a number computed as
though a join had happened when none ever did.
Status: corrected 2026-07-16 pt2 (re-measured with a widened net — every string at any depth in every pool record, the bare 32-hex tail, plus a raw substring sweep: still 0/13).
A reconciliation is only as honest as its JOIN KEY — and an "empty field" is not a "broken render"¶
Bites: the asset-reconciliation (what did we pull and never show?) produced three numbers in a row that were confidently wrong, each for a different reason, and each looked plausible enough to report:
- Wrong join key. "At source, never pulled: 91 of 94" — because it matched Wix
files by display name (
IMG-20260702-WA0028.jpg) while our R2 objects storeoriginal-nameas the media id (8b4be4_2517…~mv2.jpg). Two identity spaces that never meet. (And the ids don't compare literally either — the R2 key stem is a lossy normalisation, so the join must run throughnormaliseStem.) - Truncated source set. Even repaired, the pool was the root folder only — see
the
listFilesentry above. - Proxy metric instead of the real one. "Empty image slots: 349" — counting
every empty image field in the seed. But a hero with no
videoUrland a text-only About are structurally optional and paint nothing (ADR-0004: loud-fail is for unintended absence). The number bore no relation to what a visitor sees.
Fix: join on the identity the data actually carries (media id, through the same
normaliser that produced the key), enumerate the source set with the endpoint that
spans it, and — for "what is broken on the page?" — measure the rendered output,
not a seed-side proxy: the loud-fail primitives emit ui-missing-{icon,image,logo,…}
classes, so counting those in the served HTML is ground truth (276 real
placeholders across WCP's 24 pages). Restrict the seed-side "empty slot" count to
repeater items (_key-bearing), where an item exists solely to show an image
and emptiness is always a gap.
Detection rule: a diagnostic that reports a big, surprising number about your own pipeline should be audited in both directions before it is believed — sample its "missing" set and check it really is missing, and sample the other set and check it really is accounted for. The bug is usually the join key or the scope of the source set, not the subject. And the same rule as the scorecard: for anything of the form "what does a visitor actually see?", re-measure the deployed artifact — never infer it from the inputs (cf. "Measuring fidelity means re-measuring the deployed OUTPUT").
Status: captured 2026-07-12 pt5 (apps/cms/scripts/asset-reconcile.mjs; all
three errors caught and corrected before the numbers were reported).
An id the DESTINATION assigned is an INSTALL fact, not a site fact — resolve by name at run time, and key on a name the PLATFORM owns¶
The join-key rule above is about reconciling two datasets. This is its write-side twin, and it is louder: a stale id does not fail, it succeeds against the wrong object.
The bmparts enrichment chain (ADR-0012 pilot) addressed six posts by number — 14796
(header template), 14818/14819 (Woo templates), 14808 (homepage), 14793/14794
(thumbnail attachments). Every one was correct — on that install. WordPress assigns
post ids at creation, so they describe one box, not the site. On a second site each
number addresses something else, and these scripts call update_post_meta(): no error,
no placeholder, no loud-fail — the wrong post is silently rewritten and you find out by
looking at the page. The whole class is invisible to a lint, a syntax check, and a dry
run that only prints what it would do to id N.
Config is the WRONG fix, and it is the tempting one. The ids do not exist at the
moment a config is written — the deploy creates them. A config key for header_template_id
is a promise the operator cannot keep on a fresh box; it just moves the staleness
somewhere with a nicer name.
Resolve at run time by name — the contract ADR-0012 already sets for the product loader ("resolving URLs by exact name at run time — never cached slugs"):
| thing | key | why that key |
|---|---|---|
| Elementor template | _elementor_template_type (header/footer/product/product-archive) |
Elementor owns it. Exactly one per type. |
| static front page | get_option('page_on_front') |
WordPress's own answer to the question. |
| uploaded image | filename | comes from the source catalog (Ecwid names images by media id), so it survives a rebuild; the attachment id does not. |
The sub-rule is the part worth keeping: key on a name the PLATFORM owns, not one you
author. The first pass planned to join templates on post_title — Site Header,
{brand} Product Archive. Plausible, and wrong: Site Header is authored by no script
in the toolkit, so it is a name we would have to keep in sync forever and cannot assert.
Measuring the live box (wp post meta get <id> _elementor_template_type) surfaced a key
Elementor maintains itself, which also dropped the brand name back out of a script that
had only needed it to spell a title. One wp command replaced a design.
Fail closed. Every lookup exits non-zero on a miss or an ambiguity (0 templates =
the deploy has not run; 2+ = refuse to pick). Nothing falls back to an id. The negative
test is the useful one and it is free: ask for single-product — the Pro 3.35 name Pro 4
renamed (playbook §2) — and the resolver must stop, not quietly match nothing.
Proving a hoist is a no-op: substitute the values back. For a refactor that replaces literals with lookups, the cheap proof is mechanical — reverse-substitute the config values into the refactored file and diff against the pristine original. Anything that appears is an unintended change. Here it reduced five files to "only the docstrings differ", and the resolver half got the stronger version: a read-only harness against live WP re-derived all 6 ids the hardcoded numbers had. The proof is a no-op — same standard as the capture lane's stamps.
Status: shipped 2026-07-16 (replatform-woo/scripts/bmparts/_resolve.php); all six
ids gone from the chain, 6/6 re-derived on the live box, fail path proven.
section:nth-of-type(2) is often a trust ribbon, not a content band — gate rhythm measures on section height¶
Bites: ADR-0009 Slice 2b needs the effective section-band vertical rhythm
(the raw Wix <section> is padding:0; whitespace lives on an inner
spacer/wrapper). The theme-extractor samples section:nth-of-type(2) — a
selector chosen for typography (any text-bearing section works for
font/colour). But that section is not guaranteed to be a representative
content band. On WCP it is the 67px trust ribbon ("7-Year Guarantee / Fully
insured / Reliable"); measuring its content inset (19px) and bucketing it as
--section-py: tight would emit a bogus site-wide rhythm override off a
thin strip — element-level measures (button/card borderRadius, boxShadow)
are solid, but this derived geometric measure is fragile across Wix template
diversity (the concentrated capture risk ADR-0009 names).
Fix: lib/theme-extractor.js#bandPaddingTop gates on a content-band
height floor (secRect.height < 200 → null) so a ribbon/thin strip never
emits a sectionBand.paddingTop; absent → deriveSectionPy omits the token →
the house-style rhythm default stands (graceful). The section height is still
recorded (sectionBand.sectionHeight) for the deferred tuning pass. This
distinguishes the two sample sites correctly: garvanbay's 2nd section is a real
536px band (→ 45px → normal, matches default), WCP's is the 67px ribbon (→
omitted). Robust representative-band selection (find the real content
band, not just gate a bad one) is deferred to the section-classifier slice
(2b-band) — it is the same classification work Slice 3 needs.
Detection rule: any per-site design token derived from a positional
selector (nth-of-type, "first section", "second child") rather than a
classified element is suspect — the position that reliably carries one signal
(typography) can be the wrong element for another (rhythm). Gate with a
sanity floor and defer robust selection to the classifier; verify the derived
token against what the element actually is (probe its height/role), not just
that a number came out.
Status: shipped 2026-07-06 (ADR-0009 Slice 2b; bandPaddingTop gate in
lib/theme-extractor.js, deriveSectionPy reads sectionBand.paddingTop in
lib/design-derive.js).
Measured image-side beats the alternation heuristic — and verify a geometry classifier's orientation against a live screenshot¶
Bites: image side (which side the photo sits on in a text+image section)
was guessed, never measured. The per-site assembler used a zig-zag
alternation (variant-picker.js sets reverse on every 2nd content-2);
the CMS seed used the About matcher's || 'left' fallback (uniform). Both are
phase-blind: the alternation can start on the wrong side, so the whole page
reads offset-by-one from live, and the fallback is uniformly wrong. On the
garvanbay deployed demo, 4 of 5 home service teasers were mirrored vs live
(emdash.dcathal.org showed PAYROLL image-left; live shows image-right).
Fix: measure it in-page — the largest qualifying side image's centre vs the
text-column centre (lib/section-style.js#classifyImageSide, ADR-0009 Slice
2b-band), stamped onto the existing editable imagePosition field. Live
getBoundingClientRect is viewport-truth even under Wix absolute positioning,
which is exactly why it must be measured on the rendered page, not parsed from
stored HTML.
Detection rule: an alternating layout that reads offset-by-one from live
is a heuristic that guessed the phase, not a values bug — the fix is to
measure, not to flip the seed offset. And because left/right is trivially
invertible and a mirrored render still looks plausible, verify a geometry
classifier's orientation against a live screenshot before trusting or
deploying it. (This session nearly mis-read a correct classifier as inverted;
one side-by-side screenshot of live vs the classifier's output settled it — same
"verify via the rendered artifact, not the wiring" discipline as the @source
and unlayered-CSS traps.)
Status: shipped 2026-07-07 (ADR-0009 Slice 3, imageSide; CMS lane).
Wix dims a hero by rendering the IMAGE at opacity<1 over black — an overlay probe must compute effective media opacity up the ancestor chain, and a wrong bounded value is worse than none¶
Bites: the hero scrim dimension (ADR-0009) measures how much the live site
dims its hero so hero-4 can reproduce it. The obvious probe — translucent dark
layers stacked ABOVE the media — read alpha=0 on every garvanbay page, and the
first stamp shipped scrim=none fleet-wide. The scorecard immediately pushed
back: book-an-appointment G_det jumped +23 (genuinely unscrimmed on live) but
every subpage hero DROPPED ~20 points of similarity — live subpage heroes ARE
dimmed, by a mechanism the probe couldn't see: <wow-image> wrapper at
opacity: 0.5 over an opaque black underlay (the image is translucent, not
the overlay), with the inner <img> the probe samples at opacity 1.
Fix: two-part effective-dim estimate in resolveSectionBg's media branch:
passthru = effectiveMediaOpacity × Π(1−αᵢ dark overlays), where effective
media opacity is the product of computed opacity from the media element up
the ancestor chain to the section (the .5 lives on a wrapper), counted only
when the under-colour is dark (over white, low opacity brightens — skip).
scrimAlpha = 1 − passthru → classifyScrim buckets (none <.15 / light <.50 /
medium <.66 / strong). Re-measured: subpages 0.5→medium, home 0.24→light,
book-an-appointment 0→none. Re-stamped via sync-style-to-d1.mjs; G_det
88.3→90.8, composite 91.3→92.0, all three verdict-relevant renders converged.
Detection rule: when a measured bounded dimension ships and the perceptual axis moves in BOTH directions (some pages up, some down), the measure is half-right — one mechanism captured, a sibling mechanism missed. Enumerate how the platform can produce the same visual effect (overlay div / translucent media over dark / gradient background-image / baked-dark asset) and check which one each regressing page uses before trusting the value. The scorecard's per-page G list is the instrument: a fidelity metric that can catch YOUR OWN measurement bug is the whole point of re-measuring the deployed output.
Status: shipped 2026-07-09 (hero-dims slice; effective-opacity probe in
lib/capture-section-backgrounds.js, classifyScrim/classifyTextTone in
lib/section-style.js, 24 new validate checks).
imagePosition/tone and per-block content props live in D1, NOT baked — a content-only fidelity fix is a D1 UPDATE, not a rebuild¶
Bites: the theme (global.css) is baked at build time and needs a worker
redeploy to change — but per-block content fields (imagePosition, tone, text,
image URLs) are read from remote D1 at request time. Reaching for
npm run deploy to ship a content-prop fix is a needless rebuild; worse,
reaching for the full reseed (reseed-d1-from-json.mjs) rewrites every page
+ globals from seed.json, reverting any admin-diverged content. On the
garvanbay demo the live D1 home carried a 10th native Portable-Text block the
9-block committed seed lacked — a blind reseed would have dropped operator
content.
Fix: for a surgical content fix, read the live D1 row, mutate the one field,
write the single row back (UPDATE ec_pages SET content=json(...) WHERE
slug=...) — preserving every other block. No rebuild (content isn't baked); no
reseed (no revert-risk). This session flipped 4 home imagePosition values live
this way; all 10 blocks preserved, verified by re-screenshot.
Detection rule: before shipping a fix, ask "is this baked (theme /
global.css / @source) or D1 content (block fields)?" — if D1 content, a
targeted UPDATE beats a rebuild and sidesteps the reseed revert-landmine
(see "Fixing a silently-broken sync is destructive when the two stores have
diverged"). A reseed is only safe after diffing seed vs live and reconciling the
seed up from live first.
Status: shipped 2026-07-07 (ADR-0009 Slice 3, imageSide deploy).
A measured section-style prop overrides a block's hard-coded alignment via attribute-specificity — not by editing each block¶
Bites: the alignment dimension (ADR-0009) has to sit on the shared Section
primitive so every block can carry it, but the canonical blocks already hard-code
their alignment — cta-wcp centres (text-center/items-center/justify-center),
services-1/faqs-1/content-2 default left, content-1 centres via a centered
prop. Ripping those out per-block to feed a single alignment system is a big,
regression-prone edit across ~30 blocks; leaving them means a new align prop would
have to fight them.
Fix: add align?: left|center to Section → emit data-align, and drive it with
unlayered global CSS keyed on the attribute:
[data-slot="section"][data-align="center"] [data-slot="section-prose"]{text-align:center}
(+ content align-items, actions justify-content; left mirror). That descendant
selector is specificity (0,3,0), which beats the blocks' Tailwind utilities
(text-center etc., 0,1,0) with no !important — so a measured value wins,
and absent data-align the block's own default stands untouched (zero-regression).
The blocks need only forward the prop (align={align}); their hard-coded classes
become the no-attribute default. A block's own <p>-level rule (content-1's
[&_p]:text-left, 0,2,0) still beats the inherited prose text-align — so a
centred heading with left body copy stays that way, which is what live shows.
Detection rule: when a new shared-primitive prop must override behaviour that
individual consumers hard-code, prefer an attribute + a more-specific global rule
over editing every consumer. Rank the specificities (attribute-descendant 0,3,0 >
single utility 0,1,0 > but < a direct child rule the consumer sets on the leaf) and
let the cascade do the override — then verify the computed value on the built page
(same "verify via the artifact" discipline as the unlayered-CSS and @source traps),
because a class being present proves nothing about which rule won.
Status: shipped 2026-07-07 (ADR-0009 alignment; data-align in
packages/components-v3/…/ui/section/section.astro).
A style dimension that adds NEW render code needs a worker redeploy + a surgical multi-row D1 patch — imageSide's D1-only UPDATE doesn't generalize¶
Bites: imageSide shipped with a single-row D1 UPDATE and no redeploy — it
reused an existing rendered field (imagePosition) so the deployed code already knew
how to paint it. alignment doesn't: it adds a new render sink (Section
data-align + CSS), new adapter threading, and a new field — so the deployed worker
must be rebuilt + redeployed and the live D1 rows patched with the new align
values. Two moving parts, not one. And the reseed shortcut is still barred: garvanbay's
live home carries a 10th native block the seed lacks (a blind reseed drops it).
Fix: (1) npm run deploy (rebuilds the worker with the new render code — the new
Base.*.css uploads as a static asset); (2) a surgical per-page patch that reads
each live ec_pages row, sets align on the matching blocks, and writes the row back
whole. Match blocks by (_type + normalised heading) with order-consumption, NOT by
_key — emdash assigns its own random keys, the seed's are deterministic, they never
collide (same divergence as the globals ULID trap). Every non-matching block (the
native paragraph, blog-posts, hero) is preserved untouched; block counts stay identical
per page (home 10→10). Verified live: computed text-align matches data-align on
every eligible block; content-2 teasers centred to match live garvanbay.
Detection rule: before deploying a captured-style fix, ask "does this dimension reuse an already-rendered field, or add new render code?" Field-reuse → D1 UPDATE only. New render code → redeploy the worker too. Either way the D1 write is a field-merge onto live rows keyed by a stable natural key (type+heading), never a reseed, whenever live has diverged from seed.
Status: shipped 2026-07-07 (ADR-0009 alignment deploy; one-time patch logic — match-by-type+heading, preserve-native — should graduate from the scratchpad to a committed fleet tool, see known-issues).
A whole-content fleet regen+patch is a full reseed (not a surgical field patch) — but only after the graft-divergence check proves live has no operator surplus¶
Bites: the surgical-D1-patch patterns above (sync-style-to-d1.mjs, match-by-
type+heading, preserve-native) are for a field-level fix that must preserve
admin-diverged content. A whole-content regen (re-crawl + seed-from-build.js →
new/changed blocks: split→gallery, cta bodies, section-style across many blocks) is a
different shape — you want to replace every page's content wholesale, which is what
reseed-d1-from-json.mjs does (DELETE+INSERT by slug per page, UPDATE globals by
slug). Reaching for the surgical patcher here is the wrong tool (it merges fields, it
doesn't restructure). But a blind reseed is the garvanbay nav-wipe landmine if live
has diverged.
Fix: gate the full reseed on a graft-divergence check run before any write:
dump live D1 (ec_pages + ec_globals) and diff it structurally against the regen
seed — per-page block-count + surplus-type check (does any live page have more of
a block type than the regen? → operator surplus to preserve) and a globals compare
(is the regen site global thinner than live's header/topbar/footer? → the nav-wipe).
A fleet site that was reseeded and never persistently operator-edited (the emdash
save race blocks edits — see "Saved ✓ that never persists") comes back clean —
zero page divergence, globals match — so the full reseed is safe with no graft needed.
WCP 2026-07-10: 24/24 pages block-count-identical, site global identical (header 6
items both), slugs align (home present, no orphans) → straight reseed. Also verify
regen page slugs match live slugs (seed-from-build remaps index→home; a slug
mismatch orphans a live row and serves stale).
Detection rule: classify the delivery by shape first — field-level fix that must preserve divergence → surgical patch; whole-content regen → full reseed. Either way the divergence check is the non-negotiable gate, and its clean/dirty result — not an assumption about whether the site was edited — decides whether a reseed is safe. A never-edited fleet site is clean by verification, not by faith.
Status: shipped 2026-07-10 (ADR-0009 WCP regen+patch; graft-diff of live D1 vs
builds/waterfordcountypainters.ie/seed.json, clean → reseed-d1-from-json.mjs).
Deploying a newly-registered site-global is a surgical ec_globals append, not a re-crawl — when the deployed worker already carries the render path¶
Bites: closing an already build-verified CMS silent-drop still needs the new
global rows to reach live D1. The FloatingCTA/Social translators were registered in
GLOBAL_TRANSLATORS and committed, but the overlays stayed absent live because the two
fcr.floating-cta/fcr.floating-social globals weren't in WCP's ec_globals. The
wiki's "regenerate the seed" path needs the site's build inputs (<slug>.body.html +
captures) local — but builds/* is gitignored, a fresh checkout doesn't have them, and
WCP's last regen came from a live re-crawl (the brand-nondeterminism landmine). A
full re-crawl to deliver two cosmetic globals is disproportionate.
Fix: two questions decide the deploy shape. (1) Does the deployed worker already
render this _type? Floating adapters + the Base.astro globals.find(b => b._type
=== "fcr.floating-*") lookup were already in the shipped build (6f2cb2cf), and
globals are read from D1 at request time — so no rebuild. (2) Are the build
inputs local for a faithful regen? No → derive the global from the live site
(the real URLs the extraction would yield — the pinned WhatsApp StylableButton → CTA,
the facebook/youtube rail → social, matching the topbar's empty-URL socials), run the
graft-divergence check on the live site row (byte-identical to the committed seed →
clean/never-edited), and append the new blocks with UPDATE ec_globals SET
content=json(<array>) WHERE slug='site' (the content array is exactly what
Base.astro searches by _type; order is irrelevant). Reconcile the committed seed so
a future full reseed can't drop them. Verified live with no rebuild — overlays serve on
every page including subpages.
Detection rule: to deploy a held/newly-registered global, don't reach for a re-crawl
or a full reseed first — ask render-path-present? (yes → D1-only write, same as
"content props live in D1, NOT baked"; no → redeploy first) then inputs-local? (no →
derive-from-live + graft-check + surgical append beats a re-crawl for a small,
well-understood global). The graft check on the single site row is the same
non-negotiable gate as a full reseed, just scoped to one row. This is the deploy path
for the next held globals capability too (header/logo, backlog #2).
Status: shipped 2026-07-11 pt2 (WCP FloatingCTA/Social deploy; capability-backlog #3;
surgical ec_globals append, seed reconciled).
Migrating a raw-<section> block into shared <Section> — minimal-wrap, and give a heterogeneous dimension per-block sinks, not one universal one¶
Bites: the columns dimension (ADR-0009) needed two raw-<section> blocks
(team-grid, checklist) to compose the shared <Section> primitive so they
could carry data-slot/tone/columns. Two traps: (1) a thorough migration
(adopt SectionProse, route the grid through the shared SectionGrid, drop the
bespoke inner) changes the block's rendered look — SectionGrid's default is
auto-fit minmax(), NOT team-grid's hard-coded 2-col; SectionProse restyles a
heading the block coloured itself; <Section>'s width (80rem) ≠ checklist's 56rem.
Each is a live regression on every page that already uses the block, across both
lanes (CMS + per-site assembler share the canonical file). (2) columns looked like
one universal SectionGrid data-columns sink, but the grid-shaped blocks are
heterogeneous: services-1 uses SectionGrid, team-grid hand-rolls
.team-grid-inner, gallery-wcp hand-rolls .gallery-grid, posts-wcp hand-rolls
its own — one shared sink only cleanly covers services-1.
Fix: minimal-wrap — swap only the root <section> for <Section tone align
…> (which now owns padding/width), and keep each block's bespoke inner markup
(.team-grid-inner, .checklist-inner at its own 56rem, its own primary-colour
prose). Remove only the block's outer padding (Section provides it). The block
gains data-slot/tone capability with ~zero visual delta. For the heterogeneous
render dimension, give each block its own data-columns hook on its own grid
(the gallery-wcp [data-columns]{--cols} pattern), and add data-columns to the
shared SectionGrid only for the blocks that actually use it (services-1). One
enum, many sinks. Verified by reading the computed grid-template-columns on the
built/deployed page (620px 620px = 2-col default preserved; 400px×3 when a value is
set), per the "verify via the artifact" discipline — a class/attr being present
proves nothing about which rule won.
Detection rule: before migrating a bespoke block onto a shared primitive, list what the primitive would change (default grid template, prose/heading styling, max-width, padding) and preserve each unless the change is the intent. Prefer wrapping over adopting sub-components. And when a "shared" dimension meets blocks that each hand-roll their layout, expect N sinks keyed on one enum, not one sink — audit every grid-shaped consumer before assuming reuse.
Status: shipped 2026-07-07 (ADR-0009 columns; team-grid/checklist migrated,
SectionGrid + team-grid data-columns).
A measured dimension whose value equals the block default (or whose only eligible block is gap-tone-matched) is a faithful no-op — prove the render surface another way¶
Bites: ADR-0009 columns shipped fully wired, but garvanbay — the only deployed
demo — has no service-grid/gallery, and its one team-grid (/about, empty
block heading) matches its captured section via gap-tone. Geometric props
(imageSide/columns) are correctly withheld on gap-tone (it proves tone agreement
across a gap, not which section a block is). So the automatic columns stamp is a
faithful no-op on garvanbay (and its team measures 2 = its default anyway). The
done-criterion "deployed demo renders measured columns" was therefore unmeetable on
this site for a legitimate reason — not a bug, but it silently leaves the live
render surface unproven if you just declare victory on the build.
Fix: separate the two halves. The measure→stamp half is proven by the capture
(team=2, FAQs=3/4) + the classifier validate + the type-gated stamp logic. The
render half is proven live by temporarily forcing a value through the real
path — a surgical D1 UPDATE set the block's columns=3, the deployed worker
rendered grid-template-columns: repeat(3,1fr) (adapter → data-columns → CSS), and
it was reverted immediately (throwaway proof, not a shipped value). Surface the
no-op to the user rather than papering the criterion.
Detection rule: when a measured dimension's value on the sample site equals the block's default, or the only eligible block matches via gap-tone/positional-guess, the deploy will look identical — that's zero-regression, not verification. Prove the render path end-to-end by forcing a non-default value through it once (and revert), and defer "first automatic live instance" to a site that actually carries a distinguishing measured value.
Status: shipped 2026-07-07 (ADR-0009 columns deploy; garvanbay render-surface proof via temp D1, reverted).
Measuring fidelity means re-measuring the deployed OUTPUT — comparing a capture to what we stamped is an identity that measures nothing¶
Bites: the per-section capture records (section-backgrounds.json:
tone/imageSide/align/columns) are the input to our render — we stamp
imageSide onto imagePosition by construction. So a fidelity check that
compares the capture to the value we stamped always agrees and proves
nothing (did the prop reach the DOM? did the CSS paint? did mobile collapse? —
all invisible). This is the trap the near-match scorecard (ADR-0009 §6) had to
avoid, and the reason a literal "did we stamp it" check would have reported a
perfect score on a visibly-wrong page.
Fix: run the same capture probe against our deployed output
(capture-section-backgrounds.js --base <deployed-url> --out …ours.json) to get
an independent section-backgrounds.ours.json, then diff live-record vs
ours-record (lib/structural-diff.js). Only an independent re-measurement of
the rendered page proves the value survived. Proof: on the first run this caught
tone:muted→default on garvanbay's deployed CMS home — alternating grey bands
that were stamped in the seed but do not paint on the live worker — which a
compare-to-stamp check scores as 100%.
Corollary — canonicalise + junk-filter before diffing a clean replatform
against a live Wix page. Wix emits each section as a near-duplicate pair
(responsive/layer doubling: same heading+tone+style, ~50px height delta) and
enumerates widget/chrome pseudo-sections (a <style> block leaking CSS into
textHead, social-share bars, video-player strips). Our clean output has
neither, so a naive diff reports the replatform "dropped" half its sections.
Collapse adjacent same-tone-same-style pairs (incl. the empty-heading hero/welcome
form) and filter the junk on both sides first, or the score is dominated by
capture noise (garvanbay home: 6 dup-pairs collapsed; WCP: 6/31/9 junk sections
filtered per page — S went 43.7→69.6 once filtered).
Detection rule: when validating a captured-then-stamped dimension, ask "am I
comparing against an independent re-measurement of the rendered output, or
against the value I stamped?" If the latter, it's an identity — re-measure the
deployed page. Same "verify via the built/deployed artifact, not the wiring"
discipline as the @source and unlayered-CSS traps above.
Status: shipped 2026-07-07 (ADR-0009 §6 near-match scorecard Step 0;
lib/structural-diff.js + capture-section-backgrounds.js --base/--out;
spec docs/pipeline/near-match-scorecard-contract.md).
The symmetric re-capture probe must parse every colour space OUR lane paints in — an rgb-only parser reads oklch fills as page-white and fabricates a render bug¶
Bites: the ours-capture reported tone: default on garvanbay's home service
teasers while live read muted — recorded as "stamped muted doesn't paint on
the deployed worker" and chased across two sessions as a render bug (pt5 ruled
out the @source gap and D1 divergence, then concluded "the measured tone isn't
reaching the rendered DOM"). The DOM was painting correctly the whole time:
data-tone="muted" present, .bg-muted{background-color:var(--muted)} in the
deployed CSS, computed background oklch(0.96 0 0) ≈ #f2f2f2 vs live's
#f3f3f3 — visually identical bands. The instrument was blind: computed
non-legacy colours serialize in their declared space per CSS Color 4
(Chromium returns the literal string oklch(0.96 0 0)), and resolveSectionBg's
parseCssColor matched only rgba?(…) → null → "no fill" → the walk fell
through to ancestor/page white → default. The blindness is asymmetric:
live Wix pages paint in legacy rgb (parse fine); OUR replatformed pages paint
via oklch tokens (all invisible) — so every live↔ours disagreement blamed our
render, never the probe. _styleRaw.textColor: {heading: null, para: null} on
ours-captures was the corroborating tell — same parser, same blindness,
silently degrading textTone verification too.
Fix: keep the rgb fast path (live captures byte-identical) and normalise
everything else through a 1×1 canvas — fillStyle = str; fillRect; getImageData
resolves any browser-understood colour (oklch / oklab / color() / color-mix) to
rgba bytes; a sentinel fillStyle detects invalid input (an unparseable colour
leaves fillStyle unchanged). One definition covers all four call sites
(section bg, ancestor walk, heading/para colour). Re-measured: home teasers
#f2f2f2 → muted, home S 83.5→84.6; the fix also exposed a real divergence
the blindness had been hiding (book-an-appointment's hand-crafted "Our Services"
grid carries a muted band live doesn't have — S 39.7→37.5, an accepted
divergence on that deliberately-rebuilt page).
Detection rule: when a live↔ours diff reports a whole class of styling
(every tone, every text colour) as never landing on ours while the deployed
HTML/CSS demonstrably carries it, suspect the instrument before the render:
print the raw computed strings the probe consumes. Any parser in the capture
lane (colour, geometry, fonts) must be tested against our own lane's
serialization, not just the source platform's — a parser proven on Wix
captures proves nothing about ours-captures. And nulls in secondary captured
fields (textColor) are the cheap tell that a shared parser is failing
upstream of the field you're actually debugging.
Status: shipped 2026-07-10 (parseCssColor canvas normalization in
lib/capture-section-backgrounds.js; scorecard SHIP held at 92.2, all four
validate suites green).
A hurdle+floor gate can carry an arithmetically dead floor — check each floor's reachability against the composite weights¶
Bites: the scorecard gate (Step 3) ships composite ≥ τ_ship AND S≥70 AND
T≥80 AND G_det≥55. Under the seed weights (S 0.5 / T 0.25 / G 0.25, axes
capped at 100), composite ≥ 85 already implies S ≥ 70 — with T and G
maxed, S below 70 can't reach an 85 composite. So the S-floor can never be the
demoting cause; a reader who assumes "the S-floor protects against structural
failure at high composite" is trusting a check that never fires. Worse, it's
latently live: a Slice-6 weight re-fit (e.g. S 0.4) silently activates it —
behaviour changes without anyone touching the floor. A validate fixture written
naively ("S below floor with composite ≥ τ_ship → demoted") is unwritable,
which is how this was caught.
Fix: when setting a hurdle (composite threshold) plus per-axis floors, do
the arithmetic per floor: max composite achievable with this axis pinned at
floor − ε and every other axis at 100. If that's below the hurdle, the floor
is dead under current weights — document it where the thresholds live (noted in
near-match-scorecard-contract.md Step 3 status for lib/calibration.json) so
a re-fit knows it's flipping a dormant check on, and write the validate
fixtures against floors that are live (T and G_det here).
Detection rule: a gate fixture you cannot construct (no input satisfies "passes hurdle, fails this floor") means the check is arithmetically dead, not that the fixture is wrong. Surface it — dead checks that activate on a parameter re-fit are silent behaviour changes waiting in the config.
Status: shipped 2026-07-07 (ADR-0009 §6 scorecard Step 3;
lib/scorecard-gate.js, lib/scorecard-gate.validate.mjs).
A section-alignment collapse serves TWO axes with different needs — anchor for S, visual rect for G — and Wix's both-empty dup form is easy to miss¶
Bites: structural-diff.js#canonicalise collapses Wix's responsive
section-doubling so a clean replatform doesn't look like it "dropped" half its
sections. The G_det (perceptual) axis reuses that same alignPage to know which
sections to crop and compare. Two latent traps surfaced when G_det's outliers
were triaged (garvanbay hero scored a false 33% sim, WCP a false 37.8%):
-
The collapse optimised the retained member for the wrong axis. A collapsed unit carries two independent concerns: the anchor (heading/textHead/tone/ style) drives NW substitution cost for S, so keeping the heading-bearing member is right; the rect drives G_det's crop, so it must be the visually first member. For garvanbay's hero/welcome
emptyDupcollapse the code kept the welcome member for BOTH — so G_det cropped live's welcome band (intro + 3 photos) and compared it to ours' hero band (a thin blue title strip): apples-to-oranges → false 33%. S was blind to it (tone/style tuples agreed, q=1). Fix: decouple them — keep the anchor member's fields but overriderectto the earlier member's ({ ...anchor, rect: prev.rect }). Hero G 33.4→63.6 (an honest score: the residual reflects a real hero-background difference, which G should show); garvanbay S unchanged at 96. -
The dup detector missed the both-empty-heading form. It handled
headingDup(both members carry the same heading) andemptyDup(one empty image-h1 + a heading sibling whose text appears in the empty one's textHead), but NOT both members empty — exactly how Wix doubles image-heroes and brand ribbons (WCP's twin "Commercial Painting" heroes, "Schedule a site visit" / "Ask about flexible" ribbons). A missed twin doesn't just cost S a spurious drop — NW then force-matches the leftover twin to whatever ours section is next (a bad match at subCost ~0.9 still beats two gaps at 2×GAP=1.4), pairing WCP's hero-dup against ours' logo-strip → false 37.8% G. Fix: addbothEmptyDup— both headings empty + identical normalisedtextHead+ same tone/style. This is what actually prevents the spurious force-match (the canonicalise pre-pass, not the aligner's cost model — which is left untouched): the leftover twin is removed before alignment, so ours' logo-strip becomes a cleaninsertOurs. Also single-penalises S for a genuinely-dropped twin instead of double (WCP commercial-painting S 68.3→72.3).
Detection rule: when one pure pass feeds two axes/consumers, list what each needs from the retained object — they can diverge (text anchor vs pixel rect), and optimising the pass for one silently degrades the other. And any dup/dedup detector keyed on "a heading matches" has a blind spot for heading-less duplicates; enumerate the empty-heading case explicitly (image heroes, icon ribbons, logo strips) or a whole class of Wix doubling slips through. Verify via the re-scored artifact, not the wiring: the fix was proven by re-running S+G+gate on both anchors (garvanbay inert where expected, WCP improved, genuine outliers — WCP's navy FAQ at 24.9% — preserved), per the "verify via the artifact" discipline.
Status: shipped 2026-07-08 (ADR-0009 §6 scorecard; bothEmptyDup +
anchor/visual-rect decoupling in lib/structural-diff.js#canonicalise, locked by
lib/structural-diff.validate.mjs — 42 checks). Unblocks G_det as a gate input.
A diagnostic that projects debt already inside a score must NOT re-penalise — surface it, don't subtract it twice¶
Bites: ADR-0009 §3 lists matcherGap (dropped-live / inserted-ours /
props-withheld) as a structural sub-metric to "fold into S". Taken literally —
adding a matcherGap penalty term to S — that is double-jeopardy: S's
role-weighted alignment already puts every dropLive and insertOurs in its
denominator (a drop contributes 0 to the numerator, a hard penalty) and every
ours-missing dim already lowers a matched section's quality. Subtracting a
second matcherGap term would penalise the same events twice, silently changing S
and invalidating the calibration anchors (garvanbay S=96 / WCP S=60.9) and the
42 locked fixtures — for zero new signal, because the signal is already in S.
Fix: matcherGap is a reported projection, computed from the same
alignment rows S scores, emitted as a sibling field — S is byte-identical
before and after (a declaration-level check: re-score the anchors, confirm the
number didn't move). What the projection adds is not score but legibility:
role-weighted totals + a byRole drop/insert breakdown make matcher debt
explicit and portfolio-rollup-able (the §5 "ceiling events are a deliverable"
model — which section-types the pipeline systematically loses is the
add-a-matcher decision input for Slice 6). The existing criticalSectionDropped
veto reads the same drop rows and is left untouched — one drop set, one veto,
one projection, no re-penalty.
Detection rule: when a spec says "fold metric X into score S", first ask
"is X already inside S?" If the events X counts already move S (drops in the
denominator, disagreements in the quality term), X is a diagnostic view of S,
not an input to it — emit it alongside and prove S is unchanged. A metric that is
both a score input and a reported breakdown of that same score is
double-counting. Reserve genuine new penalty terms for signals S does not yet
see (e.g. genericFallbackRate from a build-time matcher sidecar — a fact the
capture-only S axis is blind to; deferred to Slice 4b).
Status: shipped 2026-07-08 (ADR-0009 §6 scorecard Slice 4a; matcherGap in
lib/structural-diff.js#{summariseMatcherGap,rollupMatcherGap} → lib/scorecard.js,
locked by lib/structural-diff.validate.mjs — now 53 checks).
genericFallbackRate is a build fact, not a capture measurement — compute it from the build sidecar counts; join to the capture only to attribute a role for the veto (and bridge the home/index slug alias or the homepage silently drops out)¶
Bites: ADR-0009 Slice 4b-ii needs "the fraction of sections rendered by the
measured GenericSection." The tempting shape — re-derive it from the ours
capture — is wrong twice over: (1) the capture sees painted DOM, not which
matcher produced a block, so it fundamentally can't tell a GenericSection band
from a real one; (2) the S axis is a symmetric-capture diff, and threading a
count through it invites the compare-to-stamp identity trap. The rate is a build
fact: at seed time we know exactly which matcher emitted each block. The only
thing the capture is needed for is the veto — "GenericSection on a
role-weight ≥ 1.3 region" needs the role, which lives on the scored alignment
rows, so the matcher identity must be joined onto the ours sections. That join
has a landmine: the seed keys the homepage home (its pageId), but the capture
may key it index (garvanbay crawls as home, WCP as index) — so a naive
manifest[slug] lookup silently drops the homepage, the single highest-weight
page (2× + hero-bearing). A GenericSection hero there would escape both the
rate and the veto, and the anchors would still look green (neither uses
GenericSection), so the gap ships invisibly.
Fix: emit a build-time {section→matcher} sidecar (section-matchers.json,
{order,heading,matcher,type} per emitted block) from the transformer where
entry.matcher.name + the block are both in scope; compute
genericFallbackRate = Σgeneric/Σblocks directly from the sidecar counts
(null when no sidecar — unknown, never a bogus 0, mirroring the deferred-null
contract). Join the sidecar to the ours capture only to tag each row's
oursMatcher (heading-first, content-order fallback — the same join
makeStyleLookup proved), and bridge the home/index/homepage alias both ways
(manifestForSlug, same alias set as makeStyleLookup). The veto then reads the
tagged rows exactly as criticalSectionDropped reads drop rows. Keep the whole
thing additive: S must be byte-identical before/after (the tag and rate are
a projection, not a penalty — same discipline as the Slice-4a matcherGap).
Detection rule: when a metric is "how often did our pipeline do X", ask
"is X a build fact (known at emit time) or a capture measurement (needs the
rendered DOM)?" Compute build facts from the build, not by reverse-engineering the
capture — and if you must join build identity onto capture rows, enumerate the
page-key aliasing (home/index/homepage) or the highest-weight page slips the join.
Prove the nonzero/veto path by forcing a synthetic value through the real join
(doctor one sidecar block to GenericSection, confirm rate>0 + veto fires, revert)
— on sample sites where the fallback is a faithful no-op, that is the only way to
exercise the live path (cf. "A measured dimension whose value equals the block
default … prove the render surface another way").
Status: shipped 2026-07-08 (ADR-0009 Slice 4b-ii; section-matchers.json from
lib/cms/{transformer,seed-from-build}.js, genericFallbackCounts /
makeMatcherLookup / manifestForSlug / rollup in lib/structural-diff.js,
collectGenericFallbackVetoes in lib/scorecard-gate.js, locked by both
.validate.mjs files). Alias bug caught during verification, not review.
Re-seeding a rebuilt CMS seed needs the AUTHORITATIVE basename→R2-URL map from object metadata — a fresh matcher rebuild loses image resolution, and the R2 key is a lossy hash you can't recompute¶
Bites: re-running seed-from-build.js to fix a matcher bug regenerates the seed
with raw Wix image paths (/assets/images/c00d38_…~mv2.jpg) — but the deployed D1
+ renderer use resolved R2 URLs (https://pub-…r2.dev/garvanbay/c00d38-05b…).
Reseeding the raw-path seed breaks every image. You cannot recompute the R2 key from
the basename: it is a lossy transform (ADR-0005 §3 _20/URL-encode ambiguity —
e.g. c00d38_4a54dd77… → key c00d38j54dd77…, c00d38_3d05… → c00d38-05…). And
block-identity pairing (match rebuilt↔deployed blocks by heading, copy the URL) is too
lossy — it only recovers images on blocks that still pair, missing every new/changed
block. Worse, the images a dropped section referenced were never uploaded to R2
at all (garvanbay had only 16 R2 objects for ~40+ seed image refs), so no map can
resolve them — they have no working URL to point at.
Fix: build the basename→URL map from R2 object metadata
(x-amz-meta-original-name, set on every upload per ADR-0005) — list the bucket
under the site prefix, HeadObject each key, map original-name → <PUBLIC_MEDIA_BASE>/<key>.
This is the only complete, authoritative source. Apply it to the rebuilt seed;
whatever isn't in R2 (images that were never seeded/uploaded) gets stripped to
text-only if the block is text-led (an About intro band renders fine without an
image) or re-sourced from an in-R2 sibling (e.g. a new service-grid card reuses the
same service's home-teaser image). Then verify zero raw /assets/ paths remain
before reseeding — a raw path left in is a guaranteed broken image.
Detection rule: before reseeding a regenerated CMS seed, diff its image scheme against the deployed D1's. If they differ (raw vs resolved), you need a resolution step, and the authoritative map is R2 metadata — NOT the seed, NOT block-pairing, NOT a recomputed key. If an image basename isn't in R2, it was never uploaded; don't fabricate a URL — strip to text-only or reuse a real sibling, and never leave a raw path.
Status: applied 2026-07-08 pt5 (garvanbay end-to-end: scripts/.tmp/r2-map.mjs
built the map from R2 metadata; 44 refs resolved, 6 intro bands text-only, booking
cards re-sourced from home teasers, 0 raw paths, 0 broken images). The R2-metadata map
builder should graduate from the scratchpad to a committed helper when the next
site is re-seeded from a rebuild.
A regenerated seed silently loses everything the generator cannot derive — diff against BOTH the committed seed AND live D1, then graft, before any delivery¶
Bites: (2026-07-10, recovering the crashed teaser-CTA slice) a fresh
seed-from-build.js run produced a valid, warning-free garvanbay seed that had
silently lost three classes of underivable content:
- A hand-crafted page.
book-an-appointment's live page is a Wix Bookings widget —buildSectionsenumerates zero content sections, so the page emits empty with no warning. GenericSection can't catch it: the fallback claims unmatchedsection-tag entries, and here there are no entries at all to claim. The page's content (hero + 5-service grid + enquiry CTA) was hand-crafted in the pt5 pass and exists only in the old seed. - Measured stamps. 7 heroes regressed
scrimto the single-image'strong'heuristic because the localsection-backgrounds.jsonpredated the effective-opacity probe;textTonevanished entirely (it existed only in live D1 — no seed ever carried it). A stale capture input degrades silently to heuristics; nothing flags it. - Admin-native blocks. The two live empty-paragraph
blockentries (home, management-accounting) aren't in any build artifact.
The image-URL loss (the R2-map entry above) is a fourth member of the same family. Common shape: the regen output is valid and quiet; the loss is only visible in a diff against what's actually deployed.
Fix: after any seed regen, diff per-page block composition AND field values
(_key-stripped) against both the committed seed and a fresh live D1
dump; graft what the generator can't derive (hand-crafted pages, measured
stamps from their stamps artifact, native blocks, resolved image URLs). Then
deliver via diff-gated surgical per-page UPDATE by slug — never a reseed.
Reconcile-script shape: scripts/.tmp/reconcile-seed.mjs (2026-07-10); promote
to a committed tool on its next use (same promotion contract as
sync-style-to-d1.mjs).
Detection rule: a regenerated page with 0 blocks and 0 warnings means its
live source is app-widget-shaped (no <section>s) — check the old seed for
hand-crafted content before believing the page is empty. And any field whose
value comes from a measured stamp (scrim/textTone/tone) must match the stamps
artifact after regen; a mismatch means the capture input is stale, not that the
site changed.
Status: applied 2026-07-10 (crash-recovery delivery, commit 3b71607;
Time Travel bookmark taken before the 7-page D1 patch).
The graft check is a TWO-WAY diff — live can be STALE, not just surplus¶
Bites: the graft check above exists to stop a regen from destroying what only live has. That framing makes it easy to read every live↔regen difference as "live is right, the regen would regress it" — and to graft live's value through without asking which side is actually faithful. On 2026-07-13 the WCP globals diff carried four differences and they pointed in both directions:
fcr.floating-cta/fcr.floating-social/ a nativeblock— live is right, the regen would regress them (the classic surplus case).CORRECTION 2026-07-13 pt3 — the floating two-thirds of that line was WRONG, and it is kept here because the error is the lesson. Measuring the live Wix DOM showed the regen was the faithful side on both floating blocks: live carries an
ENQUIRE → /contact-uspill (which the matcher had right) and a WhatsApp social button, and no social rail at all. Live D1's values came from an untracked hand-written.tmp/*.sqlpatch. Only the nativeblockwas genuine surplus. So this very entry — written to warn against assuming a direction — assumed a direction, on the two rows nobody had measured. A third bucket was missing from the taxonomy: not surplus, not staleness, but fabrication. See "A hand-written D1 patch is a FABRICATION with a production address".fcr.topbar.socials— live was WRONG:url: ""on both entries, and the TopBar adapter filters empty urls (.filter(s => s.url && s.platform)), so WCP's deployed topbar had been rendering zero social icons while the live Wix site shows Facebook + YouTube. The regen had the real URLs. The pt2url→hrefreconcile had landed in the code and the committed seed, but the values were never pushed to the deployed D1 — a fix that "shipped" into an artifact nobody redeployed.
Fix: treat each differing field as a question — which side did a measurement
produce, and which side is a stale write? — not as a direction. A field where the
regen carries a measured/extracted value and live carries an empty string is
almost always live being stale, not the regen being lossy. Landed via
sync-globals-to-d1.mjs (below).
Detection rule: an empty value on live opposite a populated one in the
regen is the tell. Surplus looks like live having MORE (a native block, a richer
label); staleness looks like live having LESS ("", [], a default). Grafting
"live wins" uniformly silently preserves the bug.
Status: found + fixed 2026-07-13 (WCP topbar socials now render live).
A hand-written D1 patch is a FABRICATION with a production address — and the wiki will promote it to ground truth¶
Bites: on 2026-07-11 a session registered fcr.floating-cta / fcr.floating-social
as globals and needed values to deploy. Nothing measured them, so it typed them in
(apps/cms/scripts/.tmp/wcp-add-floating-globals.sql, untracked, no git history):
a WhatsApp deep-link as the CTA, and the topbar's facebook+youtube copy-pasted into a
floating social rail. Both were wrong — live WCP has an ENQUIRE → /contact-us
pill as its CTA, a WhatsApp button as a separate bottom-right social button, and no
social rail at all. The same file also wrote the socials: url: "" that a later
session spent time diagnosing as a threading bug.
Then the damage compounded, and this is the part worth learning:
- The values reached live D1, so they became "what the site serves".
- The next graft check saw regen ≠ live and — with no measurement to arbitrate — recorded live as "faithful" and the regen as the thing that "would overwrite" it.
known-issues.mdpublished that table, and a follow-on session scoped a whole new capture dimension against it, reasoning forward from an inverted premise. The filed goal ("teach the capture plane to reproduce live's WhatsApp CTA") was the exact opposite of the truth (the matcher already had it right; live needed correcting).
A fabricated value in production is worse than a missing one: a missing value is loud (ADR-0004), a fabricated one is indistinguishable from a measurement — it has the same shape, the same address, and it accretes authority every time a document cites it.
Fix:
- A value written to live must be traceable to a producer — a matcher, a classifier,
a capture. If you cannot name the producer, you are fabricating. Say so, loudly, in the
same commit.
- The committed tools exist for exactly this: sync-globals-to-d1.mjs /
sync-style-to-d1.mjs take a stamps file that should be generated from the pipeline
artifact (node -e '…read seed…' > stamps.json), never hand-typed. A .tmp/*.sql
file you wrote by hand is the smell.
- When a dimension has no producer yet, the honest move is to leave the block absent
(loud-fail / no render) until one exists — not to fill it with a plausible guess so the
deploy looks finished.
Detection rule: in a live-vs-regen diff, before labelling a column "faithful", ask
what measured this? for each side. Three buckets, not two: surplus (live has
more — an operator authored it), staleness (live has less — ""/[]/a default), and
fabrication (live has something plausible that nothing ever measured). Fabrication is
the only one that looks exactly like success. The tell is provenance, not shape — so go
find the producer, and if the trail ends at a .tmp file or a chat scrollback, it is
fabrication.
Status: found + fixed 2026-07-13 pt3. Live WCP corrected toward measurement; the
floating dimension now has a real producer (capture probe + classifyFloatingCta /
classifyFloatingSocial); globals-diff.js grew a floating peer so the scorecard can
see this class of divergence instead of it hiding in an untracked SQL file.
A new in-page probe makes EVERY existing capture artifact stale — so declare what each dimension needs and refuse to stamp without it¶
Bites: the sibling of "a capture-plane detector is not shipped until the capture has
been re-run" (below) — that entry says re-run the capture; this one says make the code
notice when you didn't. Every capture-plane slice that adds a probe leaves every
existing section-backgrounds.json in the fleet one version behind, and the failure is
silent by construction: the classifier reads a field that isn't there, dutifully returns
null, the overlay reads that null, and the regen writes an empty value over a good
one. Nothing in the pipeline noticed. schemaVersion had been written into the artifact
since v2 and no consumer had ever read it — it was a comment with a colon in it.
Fix: lib/cms/seed-from-build.js#CAPTURE_REQUIREMENTS — each capture-fed dimension
declares the schemaVersion that introduced its probe and the chrome key that probe is
guaranteed to emit. captureStampable() checks both and returns the set of dimensions
safe to stamp; anything missing is skipped with a loud warning, never stamped from a
fabricated null, and never fatal (ADR-0004: warn, don't abort). Proven by running a
regen against the pre-slice v3 artifact: it refused the floating dimension by name and
still stamped header/topbar (which v3 satisfies) — surgical, not a blanket refusal.
The key-presence subtlety: gate on the presence of the key, not the truthiness of
the value. A probe must emit its chrome key even when it finds nothing
(chrome.floatingRaw = { candidates: [] }), because a null/absent value cannot
distinguish "the probe ran and found nothing" (garvanbay: no floating widgets — a
faithful null) from "the probe never ran" (a stale artifact). Collapsing those two is
the whole bug. Dimensions whose probe legitimately emits null when empty (e.g.
topbarContact) fall back to the version gate alone and are marked nullable.
Detection rule: adding a field to a capture artifact is a schema migration of every
artifact in the fleet. Bump schemaVersion, add a CAPTURE_REQUIREMENTS row in the same
commit, and re-capture the reference sites before believing anything downstream. If a new
dimension "works" without a re-capture, you are reading a stale file.
Status: shipped 2026-07-13 pt3 (schemaVersion 3 → 4, guard + the floating dimension that forced it).
A capture-plane detector is not shipped until the CAPTURE has been RE-RUN and PERSISTED¶
Bites: pt4 built the pinned-outside-<header> CTA detector, verified it
against live WCP (it correctly found SERVICE AREAS → /service-areas), and
recorded the CTA as "auto-detected — reproducible-from-capture, not manual". Two
sessions later the graft check found the regen emitting ctaText: "": the
detector had only ever run against a live browser session, never against a
persisted artifact. The stored section-backgrounds.json was captured mid-pt4 —
it carried the slice-5a carousel slideUrls but no ctaCandidates on any of
its 24 pages, so classifyHeaderCta was doing exactly its job and returning
null from an input that had no candidates in it. The classifier was right; the
artifact was old. A reseed would have silently reverted the live CTA.
Fix: a capture-plane dimension has two halves that ship separately — the
in-page probe (new fields in the capture record) and the node-side classifier —
and the pipeline reads the stored record, never the live page. So "verified
live" proves only the classifier. The done-criterion is: re-run the capture,
then assert the new field exists in the stored JSON, then prove the regen
reproduces the value. Re-captured 2026-07-13 → ctaCandidates + headerCta on
24/24 pages → the regen produces SERVICE AREAS on its own, and the D1 stamp
came back a no-op (live and the regen independently agree — that no-op is
the proof).
Detection rule: if a dimension's value can only be demonstrated by pointing a
browser at the live site, it is not in the pipeline yet. Grep the committed
capture artifact for the field before believing a detector shipped —
ctaCandidates: MISSING across every page is a one-command check. (Contrast the
sibling pattern "A dimension whose inputs are already-BOUNDED capture values
derives at the JOIN": that kind needs no re-crawl precisely because its inputs
are already in the record. A raw-geometry dimension like this one always does.)
Status: closed 2026-07-13. The stale-artifact half is the general risk —
any capture-plane slice that ships a new in-page probe leaves every existing
section-backgrounds.json in the fleet one version behind.
One source section rendering as several blocks: the continuation dispatches on the RENDERED component, not the matcher name¶
Bites: translateAbout splits a 3+-image About section into content-1/2
plus gallery-wcp (splitIndex 0/1…). The CMS transformer dispatches
translators by MATCHER name, so a split continuation re-ran fromAbout on the
gallery half — emitting a duplicate heading/body text block per half (or, before
the fix, dropping the half entirely).
Fix: lib/cms/transformer.js SPLIT_TRANSLATORS — entries with
splitIndex > 0 dispatch on entry.rendered.component (gallery-wcp →
fromGallery) and are fed rendered.props only (merging the matcher props
back in would re-attach the heading/text the first half already carries).
Unmapped continuations warn + drop loudly (ADR-0004), never silently duplicate.
This is what finally lets the home welcome-section image trio (the old "image
deficit" entry) reach the CMS as a real fcr.gallery block.
Detection rule: whenever a translator returns an array (one section → N rendered blocks), audit every consumer keyed by matcher name — the continuation halves are a different component wearing the same matcher, and matcher-keyed dispatch will double the text or drop the media.
Status: shipped 2026-07-10 (commit 3b71607; designed in the crashed
2026-07-09 pt3 session).
Re-matching a single-instance-dedupe loser must re-run dup-collapse — Wix responsive-doubles produce TWO losers that both re-match to duplicate blocks¶
Bites: the Hero single-instance dedupe re-match (falling a losing branded band to
About so its content survives — see "Single-instance dedupe — earlier position always
wins for Hero") interacts badly with Wix responsive-doubling: Wix emits the welcome
band twice, so BOTH copies lose Hero and BOTH get re-matched to fcr.about — yielding
two identical About blocks (same heading + same body). The normal dup-collapse
(dedupe.js#sigBody) ran before the re-match assigned the new matcher, so it never
saw them as About dups. Manifested on the garvanbay end-to-end pass: home rendered the
"GARVANBAY ACCOUNTING" welcome paragraph twice (a visible demo flaw); 4 exact dups
across home/accountspreparation/management-accounting/payroll.
Fix (interim): a post-process pass over the seed collapsing exact-adjacent
same-_type+heading+body blocks. Proper fix: in the dedupe pipeline, after the
single-instance pass swaps a loser's matcher/props, re-run the dup-collapse (or
re-key sigBody on the new matcher) so re-matched duplicates collapse in-pipeline, not
via a downstream seed patch. Lives in lib/assembler-fulldev/dedupe.js (shared by both
lanes) — so fixing it there fixes the static lane too.
Detection rule: any dedupe/collapse that keys on a matcher/type computed before a later pass can change that key has a blind spot — the later pass (here: re-match) can create fresh duplicates the earlier collapse already ran past. Re-run the collapse after any pass that mutates the key it collapses on.
Status: interim seed post-process applied 2026-07-08 pt5; pipeline fix (re-collapse after re-match) is an open follow-up (known-issues "garvanbay end-to-end pass").
Adapter silently filters incomplete items via .filter(x => x?.src)¶
Bites: CMS adapters defensively filter out items missing required
fields, before the canonical primitive sees them. Manifested in
LogoStrip and Gallery adapters as items with no src being
silently dropped — the operator never saw the gap.
// Wrong — silently drops the operator's data
const logos = (node.items ?? [])
.filter(l => l?.src)
.map(l => ({ src: l.src!, alt: l.alt ?? "" }));
// Right — pass through, let the primitive's loud-fail fire
const logos = (node.items ?? [])
.map(l => ({ src: l?.src, alt: l?.alt ?? "" }));
Fix: adapters pass through. Loud-fail belongs at the primitive layer, not in defensive adapter logic.
Detection rule: any .filter(x => x?.<required-field>) in an
adapter is suspect. The adapter shouldn't be the gatekeeper for what
the primitive renders.
Status: shipped 2026-05-09 — LogoStrip and Gallery adapters de-filtered. Re-check any new adapter for this pattern.
A grid with NO imagery is a TEXT grid — decide media presence per BLOCK, not per item, or a faithful link/text grid renders as a wall of loud-fails¶
Bites: WCP's /service-areas rendered 27 img? loud-fails and
/accessibility-statement 12. The instinct (again) is "the capture lost the
images". It hadn't: live /service-areas "CLICK AN AREA" is a link grid — 9
items of title + href with zero images on the live page — and
/accessibility-statement is an InfoCards block of text cards (title +
description). Our extraction was faithful. services-1.astro simply rendered
<TileMedia><Image {...image}/></TileMedia> unconditionally, so every
legitimately image-less card tripped the Image primitive's (correct, deliberate)
loud-fail. The adapter was already doing the right thing (image: src ? {...} :
undefined) — the block ignored it.
Fix: the media decision belongs to the BLOCK, not the item:
const hasMedia = (items ?? []).some((it) => it?.image?.src)
…
{hasMedia && <TileMedia><Image {...image} /></TileMedia>}
- no item has an image → a text/link grid → render no media slot at all
- some item has an image → an image grid → keep the slot, so an item that is missing one still loud-fails — that gap is real and an operator must see it
This preserves the loud-fail exactly where it earns its keep (a service teaser that lost its photo) while not inventing one where the design has no imagery. It is the same law as "Avatar-conditional vs loud-fail" (below) — unintended absence is loud, structural absence is guarded — but the scope of "structural" here is the block, which is the bit that's easy to get wrong: judged per-item, every empty card looks like a defect.
Detection rule: before silencing a loud-fail, ask whether the LIVE section has
the thing at all. If the answer is "none of them do", the absence is structural and
the guard belongs at the container level; if it's "some do", keep the loud-fail — a
blanket per-item {image && …} short-circuit would hide the real gaps (exactly the
short-circuit deliberately removed from team-grid.astro). And prove the retained
signal still fires: force a mixed grid through the real path (blank ONE image of
three, confirm the placeholder appears, revert).
Status: shipped 2026-07-13 pt7 (hasMedia guard in
packages/components-v3/…/blocks/services-1.astro; WCP ui-missing 39 → 0 across
all 24 pages, mixed-grid loud-fail proven live and reverted; garvanbay unaffected —
its grids carry images, so hasMedia is true and the markup is byte-identical).
Avatar-conditional vs loud-fail (intentional absence vs missing input)¶
Bites: loud-fail discipline says "missing input → visible placeholder". But for structurally optional fields, that produces false-positive noise — every Reviews block rendered 4 red "missing avatar" placeholders because the CMS schema legitimately has no headshot field.
Fix: the canonical block guards optional fields conditionally so the primitive isn't called when absence is intended:
{item?.image?.src && (
<ItemMedia>
<Avatar>
<AvatarImage {...item.image} />
</Avatar>
</ItemMedia>
)}
Compare with the inverse pattern in team-grid.astro — we removed
the {image?.src && ...} short-circuit there. TeamGrid items should
have an image; a missing one is a real gap. Reviews items don't
have one in the schema; a missing one is structurally correct.
Detection rule: a missing field is loud-fail if the schema declares it and the operator should know it's missing. It's structurally optional if the schema doesn't declare it — guard at the canonical level.
Status: shipped 2026-05-09 — reviews-1.astro avatar conditional,
team-grid.astro short-circuit removed. See ADR-0004 for the broader
loud-fail discipline.
Workspace-package path alias: literal-vs-regex trap¶
Bites: apps/cms/astro.config.mjs and tsconfig.json had literal
Vite/TS aliases for @/lib/utils mapped to
packages/components-v3/src/lib/utils.ts (the only helper at the
time). Adding a second helper at the same dir (e.g.
resolve-image-ref.ts) → import fails to resolve, falls through to
the app-side @/* catch-all, looks for a non-existent app-side file.
Build error: Rollup failed to resolve import @/lib/<new-helper>.
Fix: Switch literal aliases to regex patterns the moment a second
helper lands. Mirrors the existing @/components/ui/* pattern in the
same files.
// astro.config.mjs vite alias — before
{ find: "@/lib/utils", replacement: `${PKG_SRC}/lib/utils.ts` }
// after
{ find: /^@\/lib\/(.+)$/, replacement: `${PKG_SRC}/lib/$1.ts` }
// tsconfig.json paths — before
"@/lib/utils": ["../../packages/components-v3/src/lib/utils.ts"]
// after
"@/lib/*": ["../../packages/components-v3/src/lib/*"]
Detection rule: any literal-string alias for a single file in a workspace-shared package is suspect — it works for one helper, breaks the second. Default to regex from the start when you set up workspace package aliases.
Status: shipped 2026-05-10 (commit 9c99bd2 — Slice 1 of ADR-0005).
Lock current behaviour as expected behaviour (don't sneak fixes into tests)¶
Bites: when a normaliser, parser, or matcher has a known imperfect
behaviour that's documented in an ADR — e.g. ADR-0005 §3's
Test_20cm.jpg → test-cm.jpg false-positive (the _20 literal-vs-
URL-encode ambiguity decodes a literal _20 as %20) — the temptation
when writing a test is to assert the correct behaviour and quietly
"fix" the case. That's a silent ADR amendment.
Fix: the test asserts the current documented behaviour, including known imperfections. If the imperfection should be fixed, that's an ADR amendment + a test update, not a sneaky one-line change.
// In upload-image.validate.mjs — Test_20cm.jpg locks the §3 false
// positive as expected. Fixing the literal-vs-URL-encode ambiguity
// is an ADR amendment, not a Slice 2 change.
['Test_20cm.jpg', 'test-cm', '.jpg'],
Detection rule: if a test you're about to write asserts behaviour that diverges from what the ADR / spec / pattern entry documents, stop. Either the doc is wrong (open the doc-update first) or the test is wrong. Don't paper over the gap with an aspirational test.
Status: shipped 2026-05-10 (Slice 2 of ADR-0005, validation script).
Lib doesn't quite match ADR — post-process to match the spec¶
Bites: an ADR §3 specifies the contract for a transformation
("non-[a-z0-9] runs collapse to -"). The lib chosen to do most of
the work — transliteration.slugify() — does most of it but leaves
underscores intact and doesn't collapse all-non-alphanumeric input to
empty. Two divergences from the spec.
Fix: keep the lib for the heavy work (transliteration, diacritic-folding, the part you don't want to write) and add a tiny deterministic post-process to enforce the ADR contract. Don't swap libs; don't relax the ADR.
let stem = slugify(input, { lowercase: true, separator: '-' });
// slugify keeps underscores and doesn't collapse all-non-alphanumeric
// to empty; ADR-0005 §3 specifies "non-[a-z0-9] runs" — post-process
// to match the spec.
stem = stem.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (!stem) stem = 'image';
Detection rule: when reading a lib's output against an ADR's documented expected output, check every case the ADR enumerates. A single divergence is a post-process fix. Multiple divergences is a "wrong lib" signal — switch libs before the post-process accumulates.
Status: shipped 2026-05-10 (Slice 2 of ADR-0005,
apps/cms/scripts/upload-image.mjs#normaliseStem).
Architectural fix fails for a reason orthogonal to the hypothesis — re-diagnose¶
Bites: a hypothesis is formed ("throughput is HTTP/1.1 connection-pool
contention") and an architectural fix is queued ("switch to HTTP/2"). The
fix fails for a reason that has nothing to do with the hypothesis ("R2's
S3-compatible endpoint doesn't accept HTTP/2 — ERR_HTTP2_ERROR: Protocol
error"). The temptation: revert and try the next item on the options list.
That's a guess on top of an unconfirmed hypothesis.
Fix: when an architectural fix fails for a reason orthogonal to what it was meant to test, the hypothesis is neither confirmed nor falsified. Re-diagnose before queuing another change. Otherwise you're picking option B because A was unavailable, not because the data points at B.
Today's run: - Hypothesis: HTTP/1.1 pool contention → fix: HTTP/2 handler. - HTTP/2 fails because R2 S3 API doesn't speak it. Hypothesis not tested. - Wrong move: pivot to "skip-HEAD on fresh uploads" because it's next on the list. Right move: surface, re-think. - Re-diagnosis surfaced two findings (corpus is 6 MB total → not bandwidth- bound; sequential RTT 125ms × 50 concurrency theoretical = 200 PUT/sec ceiling vs measured 43 → ~22% of theoretical) that pointed to a different experiment (concurrency sweep, see next entry).
Detection rule: before reverting an architectural fix and trying the next one, ask: "did this attempt actually test the hypothesis I was holding?" If the answer is no, the next move is re-diagnosis, not the next item on the option list.
Status: shipped 2026-05-11 (Slice 4 of ADR-0005, throughput
investigation; see docs/pipeline/session-2026-05-11-handover.md).
Workspace-package exports map: literal-file vs wildcard trap¶
Bites: packages/components-v3/package.json declared an exports
map with "./lib/utils": "./src/lib/utils.ts" — a literal pin for
the only helper that existed at the time. Adding a second helper
(get-image-src.ts, resolve-image-ref.ts) and importing it as
@fcr/components-v3/lib/get-image-src from a CMS adapter resolves
fine in Vite (the regex alias ^@fcr/components-v3/lib/(.+)$ →
.../lib/$1.ts matches), but astro check (TSC) fails because
TypeScript walks the package's exports map first and the
literal-pinned path doesn't match. The error surfaces as
ts(2307): Cannot find module '@fcr/components-v3/lib/<helper>' or
its corresponding type declarations.
This is the package.json-side counterpart to the 2026-05-10 known-patterns entry "Workspace-package path alias: literal-vs-regex trap". That earlier entry covers Vite/TS aliases. This one covers the npm exports-map subresolver. Both fail the same way: a literal single-file pin works for one helper, breaks the second.
Fix: Switch the literal entry to a wildcard the moment a second helper lands. Mirrors the Vite/TS-alias fix.
// packages/components-v3/package.json — before
"exports": {
"./lib/utils": "./src/lib/utils.ts"
}
// after
"exports": {
"./lib/*": "./src/lib/*.ts"
}
Detection rule: any ./lib/<single-name> literal in a workspace
package's exports map is suspect — it works for one helper, breaks
the second. Default to wildcards in exports from the start when
seeding a workspace package's lib directory. Same wildcard
discipline applies to ./components/<name> and ./blocks/<name>
mappings in the same file.
Status: shipped 2026-05-12 (Slice 3 of ADR-0005, package exports wildcard added).
Parameter sweep distinguishes hypotheses a single data point can't¶
Bites: a single throughput measurement (43 PUT/sec at concurrency=50) fits multiple hypotheses simultaneously: H1 (rate-limited remote), H2 (under-tuned client), H3 (network/source-IP path), H4 (HEAD-before-PUT doubles round trips). No additional single measurement at the same concurrency can distinguish them — they all predict the same point.
Fix: sweep across the variable the hypotheses make different predictions about. The curve shape, not any single point, is the diagnostic.
Today's sweep at concurrency = [50, 100, 200, 500, 1000]:
concurrency PUT/sec proj(min for 290k)
50 58.2 83.1
100 68.0 71.0
200 47.2 102.4 ← retry-induced dip
500 55.3 87.5
1000 59.1 81.7
Flat with noise → H1 confirmed (per-token or per-account rate-limit), H2/H3/H4 ruled out as the binding ceiling. Five data points told a story no single point could.
Curve-shape decoder: - Flat across the sweep → remote-imposed ceiling (rate-limit, capacity) - Linear scaling → client under-tuned, more headroom available - Climbs then plateaus → mixed (client under-tuned to a remote ceiling) - Climbs then degrades → connection-pool / TCP / resource exhaustion - Drops at one point then recovers → likely retry behaviour from a triggered limit
Detection rule: at a hypothesis-distinguishing decision point, prefer a sweep to a single-point retest. The compute cost is N× the single-point cost; the diagnostic value is much greater than N×. Five minutes of parameter-sweep saves an hour of guess-and-check.
Status: shipped 2026-05-11 (Slice 4 of ADR-0005, throughput
investigation, apps/cms/scripts/.tmp/sweep-throughput.mjs).
emdash seed targets local data.db, not remote D1¶
Bites: after editing apps/cms/seed/seed.json (Slice 3 image
transformer or any direct content edit), running npm run seed
(which calls emdash seed) reports success — but the deployed
Worker still serves stale content. The deployed CMS reads from the
remote D1 bound to the Worker; emdash seed writes to a local
data.db SQLite that's only relevant in dev. The two stores can
drift indefinitely with no warning.
This surfaced post-Path-A on 2026-05-12 when the deployed CMS rendered an empty hero despite seed.json carrying the rich content. Looked like an adapter bug; was an unsynced data store.
Fix: apps/cms/scripts/reseed-d1-from-json.mjs — reads
seed.json, generates DELETE-by-slug-then-INSERT SQL per page (to
avoid the unique (slug, locale) constraint when ids drift between
runs), upserts globals by id, applies via wrangler d1 execute
--remote --file. Idempotent.
Detection rule: any change to apps/cms/seed/seed.json that's
meant to be visible on the deployed Worker needs
node apps/cms/scripts/reseed-d1-from-json.mjs applied to remote
D1. The npm run seed script is for local-dev only — treat its
success message as orthogonal to "the deployed site has my changes".
Future fix candidate: patch emdash itself so emdash seed
accepts a --remote flag and applies the same pattern; would fold
the operational gotcha back into the standard command. Out of
scope for now — single-line wrapper script handles it.
Status: shipped 2026-05-12 (commit 7a5ffa3).
astro dev reads a miniflare D1, NOT data.db either — a THIRD store¶
Bites: the store-drift problem has three tiers, not two. emdash
seed writes apps/cms/data.db; the deployed Worker reads remote D1
(the entry above); and astro dev (via @astrojs/cloudflare) reads
a local miniflare D1 under apps/cms/.wrangler/state/v3/d1/*.sqlite
— a third store that emdash seed never touches. On 2026-07-04 (pt2)
every CMS page rendered its empty-state in local dev — getEmDashEntry
returned no content — even though data.db was fully seeded (verified
via node:sqlite). Globals/header/footer still rendered, so it looked
like a page-only bug; the real cause was the empty miniflare D1
(confirmed: wrangler d1 execute DB --local --command "SELECT ... FROM
ec_pages" returned 0 rows).
Fix / how to verify content in local dev: seed the miniflare D1
directly, then curl —
wrangler d1 execute DB --local --file <reseed.sql> (same DELETE-by-
slug + INSERT SQL that reseed-d1-from-json.mjs generates for remote,
but binding DB, flag --local). A running astro dev picks up the
new rows live, no restart. The deployed Worker needs no equivalent —
remote D1 already holds the content.
Detection rule: "every page renders empty-state in local dev but
the deployed site is fine" = the dev-runtime D1 is unseeded, not a
render bug. Three stores, three seed steps: emdash seed → data.db
(dev-only, and NOT even what astro dev reads); --local execute →
miniflare D1 (what astro dev reads); reseed-d1-from-json.mjs →
remote D1 (deployed). See the entry above for the remote half.
Status: captured 2026-07-04 (pt2, slug-routing slice verification).
Icon static-map gaps surface as truncated placeholder text — scan the deployed HTML to enumerate them¶
Bites: the static Icon map (ADR-0004) renders a loud-fail
placeholder for any unmapped name, and the placeholder's visible text
is String(name).slice(0, 8). So a missing circle-check — the
default checklist / feature bullet, defaulted portfolio-wide in
translate.js and lib/cms/transformer.js — showed on the live page
as the literal text circle-c, which reads like corrupt content,
not a missing icon. The map had been grown reactively (one name at a
time as a block happened to use it), so the default bullet icon itself
was never registered and every checklist across every site rendered the
red placeholder.
Fix: enumerate the whole gap set empirically rather than
one-at-a-time — curl every deployed page and
grep -oE 'missing icon: [^"<]+' | sort | uniq -c. That surfaced 5
unmapped icons at once (circle-check 78, menu 20, linkedin 20,
clock 20, search 14). Register the lucide-static ones (verify the
.svg exists in the package first), add aliases where the CMS enum
name differs from the glyph (email→mail, twitter→X post-rebrand).
Additive-only edits can't regress already-resolving icons.
Detection rule: literal short lowercase-hyphen text on a rendered
page (circle-c, menu, arrow-ri) that looks like a truncated
identifier = an unmapped Icon, not content. And when you fix one, run
the deployed-HTML missing icon: scan to catch the rest in the same
pass — the map drifts incomplete because it's grown reactively. Shared
primitive: scaffold.js copyTree's icon.astro into per-site builds,
so the map is one source for both the CMS and the assembler.
Status: shipped 2026-07-04 (pt2, commit fa92cd6). linkedin closed
pt4 (commit 3069ee4) — simple-icons drops it (trademark) and lucide-static
has no brand glyphs, so the official mark is vendored locally as a
fill-style linkedin.svg in the Icon dir and mapped in SIMPLE. Pattern for
any brand glyph the icon packages omit: drop a fill-style SVG next to
icon.astro, import it, map it — Astro compiles the local .svg as a
component just like the package ones. Portfolio was placeholder-free after
this. (google-reviews still glyphless but not firing — see known-issues.)
Phase 3 regression check — deployed-output comparison, not just file-level shared-surface¶
Bites: the implementation-slice protocol's Phase 3 regression check says "list the consumers, exercise one of each, confirm no visual regression." The temptation is to interpret this at the file level: "did my slice touch shared code? if no, declare N/A."
Slice 3 of ADR-0005 on 2026-05-12 declared Phase 3 N/A — strictly true (no shared canonical block surface was touched; only a new helper file with no preexisting consumers, plus widening the package exports map). But the user reported afterwards that the deployed CMS "doesn't look anything like our prior migration or the original site." The pre-existing visual divergence was 9 hand-rolled adapters violating ADR-0004's Path A pattern — and Slice 3 didn't cause the divergence, but it made it newly visible by fixing the image-404s that previously dominated the page's broken-ness.
Fix: Phase 3 regression check expands to include a deployed
output sanity check: render the deployed page after the slice
lands, eyeball or programmatically diff it against the canonical
"good" output (the per-site assembled build at
master.<domain>-fulldev.pages.dev or live as a baseline). If the
deployed output materially differs from baseline in ways the
slice's scope wouldn't predict, surface to the user — even if no
shared code was touched.
The strict file-level check is necessary but not sufficient. The two together are the contract.
Detection rule: before declaring Phase 3 N/A, ask: "would a user opening the deployed page after this slice notice anything different that I didn't intend?" If yes — or if pre-existing brokenness was just newly exposed by your fix — that's still in scope for surfacing.
Status: captured 2026-05-12. The 2026-05-12 Path A enforcement slice was the followup that fixed the pre-existing divergence surfaced by this gap.
emdash is headless — all visual behaviour is FCR component code¶
Bites: it's tempting to expect the CMS to "handle" layout, image
placement, or which block renders. emdash does none of that. It stores
the block-type field values (heading, imageUrl, imagePosition:
"left") and provides the editor UI — nothing more. It has no concept of
what "left" means visually. imagePosition sat in the data doing nothing
because no renderer honored it.
Fix / mental model: every visual decision — layout, image side,
which canonical block a type maps to — lives in our code: the FCR block
components (content-1/content-2), the adapters
(apps/cms/src/components/fcr-blocks/*), and the Section primitives.
When something renders wrong, the fix is on our side, never an emdash
setting.
Detection rule: if a schema field (like imagePosition) exists but
"doesn't do anything", the renderer isn't consuming it yet — that's an
adapter/canonical change, not a CMS config.
Status: framing established 2026-07-04 (WS-3 spike).
emdash media_picker stores a URL string — image fields must be scalar, not objects¶
Bites: ADR-0005 stored block image fields as { src, originalName }
objects. The emdash editor declared them as scalar text_input, which
can't display an object → the field showed blank; the operator
couldn't see or edit the image. Switching to the native media_picker
element doesn't change this on its own — the picker also stores a plain
URL string (per @emdash-cms/admin BlockKitMediaPickerField:
"interchangeable with text_input").
Fix: operator-editable image fields = media_picker element + scalar
URL-string data. Migrate legacy objects → resolved URL string
(r2:<key> → PUBLIC_MEDIA_BASE/<key>), provenance to R2 metadata. See
ADR-0005 "Option A" followup + migrate-about-imageurl-to-string.mjs.
Detection rule: any emdash editor field showing blank over data that renders fine on the page = a data-shape/editor-type mismatch (object vs scalar). The editor field type dictates the data shape.
Status: shipped 2026-07-04 (fcr.about; commit 8ffcf5f). Extended
2026-07-04 (pt3) to all top-level image fields — fcr.hero.imageUrl,
fcr.cta-strip.imageUrl, fcr.header.logoUrl — via the generalized
migrate-block-image-fields-to-string.mjs (commit 8a51bc5).
Repeater-item images stay open (Block-Kit repeater sub-fields are
scalar-only). Adapters needed no change — getImageSrc was already
shape-agnostic, so the flatten couldn't regress rendering.
Reseeding a global must UPDATE by slug — emdash's ULID id never matches the seed id¶
Bites: reseed-d1-from-json.mjs's globals path did
INSERT INTO ec_globals (id, content) … ON CONFLICT(id) DO UPDATE. But
emdash creates the globals row with a ULID id (e.g.
01KR6MXEWE…), while seed.json's global carries id: "site". The two
never collide, so ON CONFLICT(id) never fired the UPDATE — every reseed
INSERTed a junk id="site", slug=NULL, status=draft row and left the
real published row untouched. getEmDashEntry("globals","site") reads
by slug, so the site kept serving the stale globals content; the reseed
looked like it worked (exit 0, "changes: N") but silently no-op'd the
globals. Invisible for months because getImageSrc tolerates the stale
object-shaped image field — surfaced 2026-07-04 (pt3) only when the header
logoUrl migration to a scalar string didn't take on the live row.
Fix: globals reseed = UPDATE ec_globals SET content=json(…) WHERE
slug=<slug>. Hits the row the site actually serves and preserves its
id + revision links. (Pages were never affected — their reseed already
does DELETE-by-slug then INSERT, which is id-independent.) Junk rows a
prior run created must be deleted out-of-band
(DELETE FROM ec_globals WHERE id='site' AND slug IS NULL).
Detection rule: an INSERT … ON CONFLICT(<key>) upsert only works if
the seed's <key> equals the value the CMS assigned. When the CMS
generates its own ids (ULIDs) and the seed hard-codes a human id, upsert-
by-id is a silent no-op-or-duplicate. Match on the stable natural key
(slug) the read path uses, not the surrogate id. A reseed reporting
success is orthogonal to "the deployed globals changed" — verify the row
count and shape (SELECT id, slug, status FROM ec_globals), same
discipline as the "emdash seed → local data.db" gotchas above.
Status: shipped 2026-07-04 (pt3, commit 1d31300).
Fixing a silently-broken sync is destructive when the two stores have diverged¶
Bites: the corollary to the entry above, and it bit hard the same
session. Because the globals reseed had never actually written the live
row (the id-vs-slug bug), the live globals were the original rich seed
(8-item nav menu, topbar, 3 footer columns) — and seed.json's globals
had drifted to a thin skeleton (header with 0 menu items) that nobody
noticed because the broken sync never applied it. The moment the sync was
fixed to UPDATE-by-slug and re-run, it did the thing it never used to do:
it faithfully wrote the stale seed over the good live content. Half the
site (menu/topbar/footer) vanished. The bug fix was correct; running it
against a diverged seed was the damage.
Fix / recovery: emdash keeps a revisions table
(id, collection, entry_id, data, author_id, created_at); the entry's
live_revision_id still pointed at the pre-clobber revision (an in-place
UPDATE … SET content doesn't repoint it). Recovery: pull
revisions.data for that id (shape { content: [...blocks] }), reconcile
any stale sub-values (the old revision's header logo was the original
/assets/… local path → re-point to the migrated R2 URL), write it back
into both D1 and seed.json so the seed is the source of truth again.
No redeploy — data-only.
Detection rule: before running a sync/migration/reseed that writes one store from another, ask "have these two diverged, and which is actually canonical right now?" A sync you just repaired is exactly when the source may be staler than the destination — dry-run and diff the payload against live before applying. And when a write-from-seed can overwrite operator/live content, the seed must be reconciled up from live first (or the write scoped to only the fields you intend to change). This is the concrete form of the standing "reseed reverts admin-only edits / seed-vs-D1 source of truth" known-issue.
Status: recovered 2026-07-04 (pt3, commit 8ce82af); rich globals
restored from revisions, seed.json reconciled up from live.
SectionSplit needs a container-query context — the CMS <main> must be an @container¶
Bites: content-2 / SectionSplit render side-by-side via
@5xl:grid-cols-2 — a container query, not a viewport media query.
It only becomes two columns inside a sized @container ancestor. The
per-site assembler provides that via its section-provider wrapper; the
CMS rendered <main><slot/></main> with no container, so every split
stayed single-column — a side image fell full-width below the text even
though the content-2/section-split/order-2 classes were all present
in the HTML.
Fix: apps/cms/src/layouts/Base.astro <main class="@container/section-provider">.
The classes being in the DOM is not enough — the container context has to
exist for the query to match.
Detection rule: when a canonical block's responsive layout "does nothing" in the CMS but works in the assembler, check whether the CMS replicates the assembler's wrapper context (section-provider / @container), not just the block call. Container queries fail silently without an ancestor container.
Status: shipped 2026-07-04 (commit 63beb5f).
Native Portable Text types (block / code / image) need FcrBlocks renderers¶
Bites: the emdash editor lets an operator insert native blocks —
paragraph/heading/quote/lists (all _type: "block"), _type: "code",
_type: "image". The dispatcher only knew fcr.*, so any native
insert (even an empty paragraph left behind while editing) tripped the
loud-fail "Missing renderer" banner on the live page.
Fix: render the native types — Prose.astro (block → emdash
PortableText, empty skipped), CodeBlock.astro, ImageBlock.astro
(native image via asset.url, loud-fail if none). Register under
non-fcr.* keys so check-renderers (which only enforces fcr.*)
ignores them. The image renderer doubles as the operator's toolbar
image-insert path.
Detection rule: any editor that exposes native block insertion needs the dispatcher to cover the native PT types, not just the plugin's custom types. New emdash block type in the toolbar → new renderer or a banner.
Status: shipped 2026-07-04 (commits 25112bc, 5394c81).
Cloudflare Access roleMapping: {} crashes One-time-PIN logins¶
Bites: the access() adapter's resolveRoleFromGroups does
if (!config.roleMapping) return defaultRole; for (const g of groups)….
An empty object {} is truthy, so it skips the early return and
iterates identity.groups — which is undefined for One-time-PIN
(OTP carries no groups) → TypeError: groups is not iterable →
emdash returns "Authentication failed" after the user passed the
Access doorman. Cost a deploy to diagnose (via wrangler tail).
Fix: omit roleMapping entirely unless the IdP actually returns
groups. Related security note: don't pair a broad defaultRole with
syncRoles: true — that silently re-promotes demoted users on every
login (retroactive privilege escalation; flagged by the commit security
review, narrowed to defaultRole: 30, no syncRoles).
Detection rule: "Authentication failed" after the Access prompt =
the worker rejected a valid Access JWT; wrangler tail shows the real
throw. Don't pass empty config objects to provider adapters.
Status: shipped 2026-07-04 (see ADR-0006; commits a41544c, dde9cf2).
The .output transcript of a background subagent is 0 bytes until it completes¶
Bites: diagnosing a hung/slow background agent from its transcript file size is unreliable — the JSONL is only flushed on completion, so it reads 0 bytes the whole time it runs. On 2026-07-04 a Fable pressure-test run was wrongly inferred to be a lane cap from an hour-long 0-byte file; the lane was healthy (a 5-second health check confirmed it), the run was just a genuine one-off hang.
Fix / detection rule: judge a background agent by the completion
notification, not file size. For Codex use codex-companion.mjs status
<taskId> (real phase + elapsed). Never cat the .output file to check
progress — it's the full transcript and overflows context. Degrade lanes
on confirmed failure, not inferred.
Status: captured 2026-07-04.
Wix serves the page description in og:description, not meta[name=description]¶
Bites: seed-from-build.js read the page description from
meta[name="description"] in the captured <slug>.head.html. Wix's
rendered head has no plain meta description — the copy lives in
og:description (and the JSON-LD). So the lookup returned '' and
every CMS page shipped a blank seo_description while the live site
had real, keyword-rich descriptions. The crawl wasn't losing the data;
the transform was reading the wrong tag. Surfaced 2026-07-05 when a
side-by-side vs live showed the emdash build had titles but no meta
descriptions at all.
Fix: fall back meta[name=description] → og:description in the
extractor. All 24 wfpainters pages went 0 → non-empty. Same tag applies
to the render layer — the head already carried og:description, only the
plain meta description and downstream fields were empty.
Detection rule: when a Wix-sourced SEO field comes through blank but the live site clearly has it, check whether Wix put it in an OG/Twitter tag or JSON-LD instead of the vanilla meta tag. The static head snapshot carries the OG + JSON-LD payload even when the plain meta tag is absent.
Status: shipped 2026-07-05.
Regenerate structured data (JSON-LD) from extracted fields — don't re-ship the crawled block¶
Context: the crawled LocalBusiness JSON-LD is captured intact in
<slug>.head.html, so re-emitting it verbatim is tempting and cheap. But
the Wix block carries an empty url, a stale wixstatic image path,
and Wix-specific @id anchors — shipping it as-is re-publishes off-domain,
broken structured data and isn't operator-editable.
Pattern: flatten the crawled NAP into a structured fcr.business
global block (name / telephone / PostalAddress parts / image / sameAs),
register it in the marketing-blocks plugin so the globals admin accepts
and edits it, and have Base.astro rebuild clean on-domain JSON-LD
from those fields at render time (url: Astro.url.origin, absolute
image). The crawled block is the extraction source, never the shipped
artifact. Chosen over verbatim-carry on 2026-07-05.
Detection rule: any captured third-party metadata destined for our output that embeds the source's own URLs/ids is a regenerate candidate, not a copy candidate — re-shipping it leaks the source domain.
Status: shipped 2026-07-05 (fcr.business + Base.astro JSON-LD).
emdash template's static route files ship hardcoded demo SEO — audit them on onboard¶
Bites: apps/cms/src/pages/index.astro (a static route that takes
precedence over the [slug].astro dispatcher) rendered the real CMS home
content but passed a hardcoded description="Build products people
actually want. The all-in-one platform for modern teams." to Base —
the emdash scaffold's demo string. It never read page.data.seo_description
the way [slug].astro does. Invisible until the live homepage was diffed
after the SEO fix — subpages had real descriptions, the homepage had the
template's marketing copy.
Fix: read page.data.seo_description and pass it as description,
matching [slug].astro.
Detection rule: static route files (index.astro, contact.astro,
pricing.astro) bypass the dynamic dispatcher and can retain scaffold
defaults. When onboarding a site, diff every static route's head against
a dynamic one — divergence in og/description/title wiring means a
template leftover.
Status: shipped 2026-07-05.
Wix transform-suffixed image paths carry the clean media id as the final segment; logos are local-only¶
Bites: transform-seed-images.mjs's basename deriver matched only
/assets/images/<one-segment>$, so it returned null — "cannot derive
basename", field skipped, ref 404s on the deployed worker — for two shapes:
- Wix transform/srcset paths —
/assets/images/<id>~mv2.jpg,h_244,q_90,enc_avif,quality_auto/<id>~mv2.jpg. The embedded slash (transform segment) defeats a single-segment match. But the final path component is the clean wixstatic media id — takesplit('/').pop()after stripping?#. - FCR logos —
/assets/logos/<Name>_logo.png. Clean path, but under/assets/logos/, not/assets/images/. Logos are FCR build assets, not wixstatic media — there is nostatic.wixstatic.com/media/<name>to download; the local build dir (builds/<domain>.ie/public/assets/logos/) is the only source. Because the logo doubles asog:image+ JSON-LDimage, this shape's failure breaks link previews + structured-data image even when every SEO tag is present.
Fix: resolveAssetSource returns { basename, subdir, isWixMedia } —
subdir from /assets/(images|logos)/, basename = final segment (drops
any transform tail), isWixMedia = subdir === 'images'.
ensureLocalFile(basename, dir, isWixMedia) loud-fails for a non-wix asset
whose local file is missing rather than 404'ing wixstatic. --selftest
locks the shapes network-free.
Detection rule: a deriver that anchors on a single trailing segment
under one fixed dir is suspect — Wix serves the same image at a clean path
and a transform-suffixed path, and FCR-generated assets (logos, and
still-open /assets/videos/*.mp4) live under sibling dirs with no
wixstatic fallback. Enumerate the --dry "cannot derive basename" set
against a real seed before assuming coverage.
Status: shipped 2026-07-05 pt2 (commit 77bdb33). Video shape
(/assets/videos/) still open — see known-issues.
Correction (2026-07-13): "logos are local-only" is false — they are in the
client's Wix library; what's missing is the ID, not the asset. The
derivation rule above stands (you cannot reconstruct a wixstatic URL from
/assets/logos/<Name>_logo.png), but the inference drawn from it — that the
logo has no wixstatic source — is wrong, and it sent a session looking for a
file to upload when the asset was already there. WCP's searchFiles pool holds
Waterford County Painters_logo (8b4be4_4651bc7c…~mv2.png, 1267×898). The
reason the deriver can't resolve it is that the scraper saved the logo under its
display name (URL-encoded: Waterford_20County_20Painters_logo.png) instead
of its media id — every other image keeps the id, which is exactly what makes
the id-join work. So the correct statement is: a logo path carries a name, not
an id, so the by-id plane can't see it — and the API can still find it by name.
Consequence for ADR-0010 slice C: logo provenance is recoverable from the API
(real filename + dimensions), it just can't ride the media-id join like the rest.
Swapping the CMS demo tenant — reseed D1 AND rebuild (theme is build-baked); pin the restore SHA¶
Bites (two):
- The 2026-07-05 handover's restore recipe was
git checkout apps/cms/seed/seed.json. But the wfpainters swap had been committed (HEADa7a3bbf) with a clean working tree — sogit checkout <file>restores it from HEAD, i.e. wfpainters, the opposite of the intent. A file-restore recipe only reaches a prior state if that state is HEAD; when the thing you want to undo is committed, pin the parent SHA (git checkout 8ce82af -- apps/cms/seed/seed.json). - A tenant swap is not a D1-data-only change.
global.cssis emitted at build time byprebuild(emit-theme-css.mjs) frombuilds/<seed.meta.name>/theme.json. Reseed D1 alone and the worker serves the new tenant's content with the old tenant's palette/fonts. The rebuild+deploy re-bakes the theme.
Also: reseed-d1-from-json.mjs only DELETE-by-slug's the slugs it
re-inserts, so swapping between sites with disjoint slugs (garvanbay 10 vs
wfpainters 24, only home shared) leaves the old site's pages as orphans —
clear ec_pages fully first.
Detection rule: any "restore/undo via git checkout <file>" recipe is
correct only if the target state is HEAD — pin the SHA otherwise. And any
CMS change that should alter appearance (not just content) needs the
build step, because the theme is a build artifact, not runtime data.
Status: captured 2026-07-05 pt2 (commit 77bdb33).
Provisioning a fresh tenant D1 — schema is created by a human /_emdash login, not a migrate command¶
Bites: a brand-new wrangler d1 create'd database is empty — no
ec_pages/ec_globals. reseed-d1-from-json.mjs does raw
INSERT INTO ec_pages (…, content, title, seo_description, …) and fails on a
schemaless DB. There is no emdash remote-migrate command: system tables
come from a Kysely migration runner, but the content tables are created
dynamically by SchemaRegistry.createContentTable only when a seed is
applied (node_modules/emdash/…/schema/registry.ts), and the per-collection
field columns (content/title/seo_description) are ALTER TABLE ADD COLUMNs
driven by the seed's collection defs. So a hand-written CREATE TABLE ec_pages
would still miss the field columns.
Fix / sequence for onboarding a new per-site worker:
1. wrangler d1 create <db> → record database_id into sites/<site>.jsonc.
2. Deploy the worker so its DB binding points at the empty D1.
3. Reach an /_emdash route once and log in (Cloudflare Access OTP). The
emdash runtime init runs runMigrations + auto-seed on an empty DB
(emdash-runtime.ts → applySeed, astro/middleware.mjs:806-808), creating
the tables and the correct field columns. A bare public request instead
302s to /_emdash/admin/setup. Either path needs an /_emdash hit, which on
the deployed worker is behind Access — so a human login is the bootstrap.
4. SITE=<site> node scripts/reseed-d1-from-json.mjs now succeeds.
Detection rule: "reseed fails with no such table / no column named content"
on a new tenant = the schema was never bootstrapped. There is no headless
remote-migrate; either drive the /_emdash login (Slice 3 automation target) or
reproduce runMigrations+applySeed against remote D1 yourself. A fresh D1 is
not ready for reseed until a seed has been applied through the runtime.
Status: captured 2026-07-06 (ADR-0008 Slice 2, waterfordcountypainters onboarding).
emdash bakes the seed from virtual:emdash/seed — a per-site fleet must generate .emdash/seed.json¶
Bites: emdash embeds the seed at build time via a Vite virtual module
(generateSeedModule, emdash/dist/astro/index.mjs:1241) that reads, in
precedence order: (1) .emdash/seed.json → (2) package.json#emdash.seed →
(3) seed/seed.json. The runtime auto-seed imports the same baked module
(loadSeed → import("virtual:emdash/seed"), load-…mjs:9). There is no
emdash({ seed }) option — it's filesystem discovery. So every per-site worker
baked whatever package.json#emdash.seed pointed at (garvanbay), and a fresh
tenant's first-login auto-seed applied the wrong site's content (the WCP D1
came up garvanbay; had to be cleared + reseeded by hand).
Fix: apps/cms/scripts/emit-seed.mjs (prebuild step) copies the active
site's seed/<SITE>.json → .emdash/seed.json (precedence slot #1, gitignored),
so both the build bake and the runtime auto-seed use the right tenant. Garvanbay
is unaffected (same content, now via .emdash/ instead of the package.json
pointer). Verified via the built artifact: a SITE=waterfordcountypainters build
has zero garvanbay strings in dist/ (per the "verify the bake, not the
wiring" rule — same discipline as the Tailwind @source gap).
Detection rule: any emdash per-tenant deployment that relies on auto-seed
must generate .emdash/seed.json per site — a static package.json#emdash.seed
bakes one tenant's content into every worker. emit-seed.mjs runs in prebuild
alongside emit-theme-css/emit-wrangler; all three read the active SITE via
scripts/lib/resolve-site.mjs.
Also (Slice 1 finding): the seed file path is an implicit contract beyond
the CMS build scripts — lib/capture-section-backgrounds.js and
lib/cms/apply-section-tones.js read apps/cms/seed/<site>.json too. Renaming
seed.json → <site>.json had to sweep lib/ as well; any future seed-layout
change must check both trees.
Status: shipped 2026-07-06 (ADR-0008 Slice 2, emit-seed.mjs).
CMS seed extraction — matcher text lives in entry.props, not the translated slot¶
Bites: lib/cms/seed-from-build.js builds a CMS seed by running the
assembler matchers then transformLayoutMap (lib/cms/transformer.js). The
transform read block props as entry.rendered?.props || entry.props — preferring
the fulldev-translated props. But the fulldev translators
(lib/assembler-fulldev/translate.js) move every text field into a slot HTML
string (translateHero → slot: '<h1>…</h1>', etc.), leaving
rendered.props structure-only. So every heading/body/subtext read ''
and the CMS pages rendered structure with no text (waterfordcountypainters,
2026-07-06). The matcher's extract output (entry.props) had the text all along.
garvanbay never hit this — its seed was reconciled from live/revisions, not
generated through this path.
Fix: sections merge { ...rendered.props, ...entry.props } (matcher extract
wins; translated props fill normalised extras like merged links, items,
columns). Globals (Header/TopBar/Footer) keep the translated-props preference
(they need the normalised menus/contact/columns shapes). transformer.js
is imported only by seed-from-build.js, so the per-site build lane can't
regress.
Detection rule: CMS pages render block structure but blank text = the seed
transform is reading the translated slot string instead of the matcher's
extract() fields. The text ground truth is entry.props, not entry.rendered.
Status: shipped 2026-07-06.
Block image fields must be scalar resolved-URL strings at render — objects abort SSR; flatten repeater items too¶
Bites: transform-seed-images.mjs rewrites image refs into the ADR-0005 §6
object shape { src: "r2:<key>", originalName } — for top-level fields
(imageUrl/logoUrl) AND repeater items' src. But the render path expects a
scalar string: a raw-<img> adapter calls resolveImageRef(), which does
ref.startsWith('r2:') — on an object that throws, and the exception aborts
the whole page SSR. Manifested on waterfordcountypainters (2026-07-06): every
page rendered blank body / the themed 404, globals-only, after the image
transform+reseed. garvanbay dodged it because its home has no item-images and its
top-level fields were already flattened by the 2026-07-04 media_picker
migration.
Fix: flatten { src, originalName } → resolve(src) (a PUBLIC_MEDIA_BASE
URL string) for every image key including repeater items —
migrate-block-image-fields-to-string.mjs now walks imageUrl/logoUrl/src
comprehensively (the earlier "items not touched" note was an editor-only concern;
rendering needs strings everywhere). SITE-aware via the fleet resolver, idempotent.
Onboarding pipeline order (any new CMS site): seed-from-build.js →
transform-seed-images.mjs (§6 objects) → migrate-block-image-fields-to-string.mjs
(flatten to strings) → reseed-d1-from-json.mjs. Skip the flatten and the site
SSR-crashes to a blank/404 body even though the data looks present in D1.
Detection rule: a CMS page that 404s / renders globals-only after an image
transform, with valid content in D1, = an object-shaped image field reaching
resolveImageRef. Grep the seed for "src": { / "imageUrl": { — any image key
whose value is an object is a latent SSR crash. Better long-term fix (deferred):
have transform-seed-images emit resolved strings directly, retiring the
separate flatten step.
Status: shipped 2026-07-06 (ADR-0008 WCP onboarding).
A wrong winner in a RANKER may be a POOL rule, not a score bug — and the fix the wiki proposes can regress the very site the rule exists to protect¶
Bites: (2026-07-13 pt4) known-issues recorded WCP's brand nondeterminism as "the ranker isn't purely score-ordered and a transient surface signal can displace a stable logo-derived brand", with a fix candidate: "make the ranker prefer a high-confidence logo cluster over a lower-scored surface candidate". Both halves are dangerous.
The observation was right — #092b57 (score 9.22) beat #183a6f (score 16.97).
The inference — "so the ranker is buggy, make logo win" — was wrong twice over:
- It is not a ranking bug.
deriveBrandPalettedeliberately gives painted-SURFACE families priority over logo-only families, and score orders only within that pool. The rule exists because a logo's dominant colour is often just an accent: garvanbay's dominant logo cluster is yellow-green (#cdd707, 54.5% of the mark) and its family outscores the cyan 17.49–17.07, yet the brand primary is the cyan#2aa1dbpainted on the button. The surface preference is the only thing getting garvanbay right. - The proposed fix would invert garvanbay. "Prefer the dominant logo" and "prefer the top score" both hand garvanbay its accent colour. The adversarial case was not hypothetical — it was the other reference site, sitting in the repo.
What was actually wrong: the surface preference was an absolute override, so it also
fired in a case it was never designed for — when the two "competing" families are the same
colour. WCP's two navies are hueDist 2.71° and rgbDist 32 apart: one brand navy
that the clusterer had split into two families. A 9-point family deposed a 17-point one purely
because it was painted.
Fix: make the override a two-condition tie-break — a surface family may depose the
score leader only if it is (a) a genuinely DIFFERENT colour (hueDist ≥ 25° between families)
and (b) actually PAINTED (≥ ROLE_WEIGHT['button-bg'] of fill evidence). Otherwise the top
scorer wins. When the leader is itself a surface family the challenger is the leader, the
gap is 0, the tie-break declines, and nothing changes — so the common case is untouched, and
the tie-break can only ever depose, never block. It fails safe.
Two traps found while fixing it, both worth carrying forward:
- Do NOT discriminate on rgbDist/lightness ("is one a shade of the other"). It runs backwards on the real data: garvanbay's DOM-must-win pair is 41.3 apart, WCP's logo-must-win pair only 32.0. Δlightness is 0.080 vs 0.077 — indistinguishable. Hue between families is the signal (136.8° vs 2.7° — a 50× separation).
- Do NOT use a score MARGIN.
logoAgreementawards +4 to whichever family agrees with the logo — so the logo family gets a bonus precisely when the logo colour differs from the painted brand colour, the exact case the gate exists for. Strip garvanbay's incidental cyan logo clusters (an ordinary single-colour logo) and its cyan surface drops 17.07 → 13.07 while the yellow-green holds 17.49: any margin under ~4.5 flips it. The floor must be absolute, never relative.
Residual (not fixed, deliberately): clusterFamilies takes the first matching family,
not the nearest, and logo candidates are concat-ed last — so the strongest signal
(the logo, weight 7, and the only one that unlocks the permissive logoInvolved && hd ≤ 12
merge) can never seed a cluster. That is what split WCP's one navy in two. Sorting
candidates by weight before clustering merges them correctly — but then repScore ranks
ctaAccent (4) above logo-cluster (2.5) and the same wrong hex becomes the family's
representative, so the clustering fix cannot ship alone; it forces a repScore change
whose blast radius is every site's emitted hex. Reopen with a corpus (Slice 6's 30–50-site
batch), not with two sites.
Locked by lib/brand-palette.validate.mjs (11 checks, incl. the determinism assertion:
deleting the transient ctaAccent from WCP's sample must yield a byte-identical palette).
Detection rule: before "fixing" a ranker that picked the lower score, read the selection code and find out whether the ordering is even the mechanism. A deliberate priority rule that misfires looks exactly like a sorting bug — and the difference decides whether your fix repairs one site or breaks another.
A CSS custom property that only ONE lane passes means every site on the OTHER lane silently gets the hardcoded fallback¶
Bites: (2026-07-13 pt4) topbar-wcp.astro styles itself from var(--topbar-bg, #5DC8E8).
The static lane passes the measured value; the CMS lane's TopBar.astro adapter passed
zero colour values. So every CMS site in the fleet rendered the same FCR-typical cyan
strip regardless of brand — WCP's live topbar is light grey (#e8e6e6), and its own theme had
measured that correctly. The operator's report was "the top strip is the wrong colour — it's
garvanbay's", which is exactly what a shared cyan fallback looks like from the outside.
Fix: emit the vars from emitGlobalCss (the theme emitter both lanes already share),
not from an adapter prop — --topbar-bg / --topbar-text from theme.colors.topStripBg /
topStripText, and emit nothing when the theme has no value so the component's own
fallback still applies (never emit null, never fabricate a colour).
Detection rule: a component fallback (var(--x, <literal>)) is a silent default, and a
default that is only overridden on one lane is a lane-wide bug wearing a plausible colour. When
you add a themed custom property, grep both consumers for who supplies it — and prefer the
shared emitter over per-adapter props, so a new lane cannot forget.
A matcher that defaults a missing href to / FABRICATES a link — a dropdown TOGGLE has no href on purpose¶
Bites: (2026-07-13 pt4) every WCP menu item went to the homepage. lib/matchers/Header.js
read const href = $label.attr('href') || '/'. On live, FARMING / COMMERCIAL /
RESIDENTIAL are not anchors at all — they are dropdown toggles (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 up all of them at once, because / matches the homepage.
The render was already correct. header-wcp.astro routes an item with children to a
<button> trigger; the toggles only fell into the <a href="/"> branch because subItems was
empty. This was 100% a data-plane fabrication — the same shape as the hand-written D1
patch, produced by a matcher instead of a human.
Fix: a capture-plane probe (chrome.navRaw, schemaVersion 4→5) measuring the post-JS
header: href: null for a toggle (never /), children grouped by DOM containment —
URL-prefix grouping provably fails (WCP's RESIDENTIAL → roof / dry-verge / tarmac share no
prefix). The seed overlay REPLACES rather than fills-if-empty, because a fabricated /
reads as "already set" and fill-if-empty would preserve the bug.
Detection rule: || '/', || '#', || '' on an extracted href is a fabrication site.
Absent is a measurement, not a gap to paper over — emit null and let the render decide
what a link-less item is (ADR-0004).
pgrep cannot see node.exe under git-bash on Windows — poll the ARTIFACT, never the process¶
Bites: (2026-07-13 pt4) a long capture was waited on with
until ! pgrep -f "capture-section-backgrounds"; do sleep 20; done. pgrep never matches a
Windows node.exe, so the loop exited immediately, and the next 20 minutes of analysis ran
against the previous section-backgrounds.json — same schemaVersion, same page count, real
data, hours stale. It was then copied onward to section-backgrounds.live.json, propagating
it. Caught only by checking the file's mtime against its capturedAt.
Fix: wait on a condition the artifact itself asserts —
until node -e "const c=require('./out.json'); process.exit(c.schemaVersion===5 && Object.keys(c.pages).length>=25 ? 0 : 1)"; do sleep 20; done — or on the harness's own
background-task completion signal. Both can be trusted; a process-name grep cannot.
Detection rule: this is the stale artifact family again (ADR-0010's empty pool, the hand-written SQL, the un-persisted detector). A liveness check that can silently answer "done" when it means "I can't see it" is indistinguishable from success. Make the freshness signal come from the thing you actually care about.
The near-match scorecard is BLIND to content destroyed INSIDE a matched block — it scores section PRESENCE, not fidelity¶
Bites: (2026-07-13 pt4) a clean-room-rebuilt WCP scored S 72.5 / T 100 / G_det 72 →
composite 79.3, MANUAL-POLISH, no vetoes, with globals 7/7 agree and 0 loud-fail
placeholders on all 25 pages. The operator looked at the deployed site and said 4/10, and
listed four real defects — every one of which the scorecard is structurally unable to see:
| defect | why the scorecard cannot see it |
|---|---|
| topbar rendered in another brand's cyan | header/topbar/footer are excluded from the scored axes by construction |
globals reported topbar agree |
live measured null, ours measured null — a null-vs-null match checks nothing |
| 4 icon+text cards rendered as a bare logo strip (text destroyed) | the section exists on both sides, so S counts it PRESENT; content lost inside a matched block is invisible |
every nav item linked to / |
nav/chrome is not an axis at all |
| 45 of 46 videos never migrated | asset reconciliation is reporting-only and gates nothing |
The instruments were green because they measure section presence, tone and coarse perceptual similarity — not whether the block says what live says. "200 OK, zero placeholders, globals agree" was reported as success over a visibly broken page.
Detection rule: the scorecard is a regression detector, not a fidelity oracle. Never report a verdict as "the site is good" — report it as "no structural regression". Before claiming a site is shippable, look at the rendered page (or have the operator look). The axes' blind spots are enumerable and now are: chrome, intra-block content, assets, links. Closing them is real Slice-6 work, not a tuning pass.
A junk FILTER that condemns a section on ANY match will delete real content — gate it on the thing that makes a section content¶
Bites: (2026-07-14) structural-diff.js#isJunk had three rules. Two were gated on "the
section has no real heading"; the style-leakage rule was not, and fired on any section whose
text contained a CSS rule. Wix injects per-comp SVG colour styles into ordinary content bands,
so live's real "Our Commercial Repair Services Include:" section — a heading-bearing services
band present on every WCP service page — was deleted from the live side. Our faithful copy of
it then had nothing to pair with and scored as insertedOurs. 42 real live sections were being
filtered; 27 of a reported 51 "sections we over-produce" were phantom. The function's own
comment promised "a genuine section is never removed" — the rule that broke it was the one the
comment didn't cover.
Fix: gate every rule on the same content signal (a real heading). A pure Wix <style>
pseudo-section has no heading and is still filtered; a content band with a style tag leaked into
its text is content.
Detection rule: a filter is a silent deleter, and an asymmetric one (here: 9 filtered on live, 0 on ours) manufactures phantom diffs in whichever direction it leans. When a diff says "we produce N things the source doesn't have", check the filter on the source side before believing the producer is at fault — and check the filter's rules are gated consistently. A filter whose rules disagree about what makes something junk is a filter with a hole in it.
Wix's responsive DOUBLE is a NESTED WRAPPER, not an adjacent sibling — and collapsing it by matcher NAME cannot work¶
Bites: (2026-07-14) /commercial-repairs rendered the heading "Commercial Repairs" three
times (hero + cta-strip + about); live renders it once. Across WCP this over-production was the
largest structural drag.
The mechanism the wiki recorded was WRONG, and implementing it would have collapsed zero pairs.
known-issues said the doubles are two adjacent siblings that diverge because the Hero
single-instance pass re-matches the loser to another matcher. Measured: every double is a
nested ancestor/descendant pair — an outer <section> wrapping an inner one with byte-identical
text — 67/67 on WCP, 20/20 on garvanbay, zero siblings. And the re-match never fires on that
page: the inner half matches CTAStrip natively, because ctx.position differs between the two
halves and Hero gates on position <= 1, so the outer wins Hero at pos 1 and the inner falls
through at pos 2.
Why the original was drawn: the symptom is identical either way (two blocks, different matchers, same content), and the re-match is a real mechanism that really does swap a loser's matcher — it just isn't what happens here. The observation ("duplicates survive because the dedupe keys on matcher name") was right; the causal story was invented around it.
Fix: collapse on content signature + DOM ancestry, independent of matcher name, as a pass
after the existing matcher-keyed passes. Ancestor + identical full text ⇒ the outer holds nothing
but the inner ⇒ it is a wrapper, not a second band — so a legitimately-repeated band (never nested
inside its own twin) cannot be eaten. Keep the earlier _matchPosition, which for a nested double
is the outer half and is the Hero winner.
Detection rule: before implementing a wiki-prescribed fix, verify its stated mechanism against the DOM, not just its conclusion. Here the conclusion was right and the mechanism was fiction; had the fix been written to the mechanism (sibling adjacency, post-re-match) it would have matched nothing and "proved" the diagnosis wrong.
A veto must fire on the ABSENCE of a thing, not on a metric's failure to PAIR it¶
Bites: (2026-07-14) removing 23 genuinely-duplicated blocks made the scorecard worse:
five WCP pages flipped to HOLD on criticalSectionDropped — "the hero was dropped" — while
all five demonstrably render <h1> + subtext + CTA. The alignment is 1:1 and
heading-similarity-driven; live carries two rows bearing the page title (an uncollapsed hero
double), so our single hero block pairs with the exact heading match (a content row) and live's
hero row — whose "heading" is the section's whole concatenated text — is left unmatched. The
duplicate had been absorbing that mis-pairing; deleting it exposed it.
The site got better and the verdict got worse. A veto that fires on a section we visibly render is not measuring what it claims to.
Fix: a critical role is dropped only if our page hasn't got one. collectStructuralVetoes
now checks the page's oursMatcher tags for the role's producing matcher (hero→Hero,
cta→CTAStrip) and suppresses the veto when we render one. It fails closed: a genuinely
absent hero still vetoes, and when the build sidecar is missing (all rows untagged, we cannot prove
we rendered one) the veto still fires. Locked by +10 checks in scorecard-gate.validate.mjs
(19 → 29), including "a real drop still vetoes" and "no sidecar still vetoes".
Detection rule: distinguish "the thing is missing" from "the metric could not pair the thing". Any veto keyed on an unmatched row is keyed on the aligner's behaviour, not on reality — cross-check it against something the aligner cannot influence (here: does our side render a block of that role at all?) before letting it hold a release.
Removing a duplicate can UNMASK a data-loss bug the duplicate was accidentally covering¶
Bites: (2026-07-14) collapsing WCP's nested wrapper doubles removed 23 spurious blocks — and
/thank-you-contact-form lost the line "We'll respond to your request shortly.". It was not the
collapse's fault: Hero.extract() sources subtext only from $el.find('p, h4'), and that hero band
is <h1> + <h2> with no <p> at all, so subheading had always been empty. The phantom
duplicate block had been accidentally surfacing that <h2>. A bug was masking a bug, and the
fix for one revealed the other — as a regression.
Fix (and its safety gate): fall back to the heading level below the one that supplied the heading — but only when the section contains exactly ONE of them. A hero's subtitle is a single line; several same-level headings are an item list, not a subtitle (ungated, the fallback hoists a trust-badge item — "Fair Pricing" — into the homepage hero). Measured: fills exactly 1 WCP hero, changes 0 on garvanbay.
Detection rule: when a de-duplication removes blocks, diff the surviving CONTENT, not just the block count. "22 of 23 removals lose zero content" is only reassuring if you checked the 23rd. And when a fix produces a regression, ask whether the thing you removed was load-bearing by accident before assuming the fix is wrong.
CRITICALITY and VISUAL WEIGHT are two different concepts — sharing one number makes a gate that CANNOT FAIL¶
Bites: (2026-07-14) a site the owner rated 4/10 scored MANUAL-POLISH with zero vetoes. The defect he named — the FAQ rendering navy where live is white — was already measured, nineteen times, and reported as zero:
variantMismatch.count (the HEADLINE) : 0
variantMismatch.items (the DETAIL) : 38 (x19 faq tone, x14 ribbon tone, x5 content tone)
structural-diff tags a mis-render critical iff ROLE_WEIGHT >= criticalRoleWeight (1.3). But
ROLE_WEIGHT answers "how much does this section contribute to S?" — hero 1.6, cta 1.3,
content 1.0, faq 0.75, ribbon 0.5. So faq / ribbon / content / blog sit below the bar and can
never be critical BY ARITHMETIC. The owner's entire complaint list lived in the set of things the
gate was mathematically incapable of failing on.
"How much does this matter visually" and "should a defect here hold the release" are not the same question. Conflating them produced a gate that could only fail on heroes and CTAs.
Fix: the veto uses neither role nor weight — only SYSTEMATICNESS. The same
(role, dim, live→ours) disagreement across ≥N sections and ≥M pages is a broken PRODUCER:
one bug, N symptoms. A single wrong section is noise (a matcher edge case, an operator override); 19
pages of the same wrong FAQ is a producer emitting a wrong value at scale — and that is exactly what
a client sees. The guards matter as much as the rule: many sections on one page is a page bug,
not a producer bug, and must not veto.
Detection rule: when a gate never fires, check whether it CAN. Multiply out the thresholds against the actual weights before believing a clean verdict. A number that is doing two jobs is doing at least one of them badly — and the measurement is usually fine; it is the roll-up that lies.
The instrument that ALREADY EXISTS but was never wired in — look before you build¶
Bites: (2026-07-14) faced with a defect class no axis could see (content destroyed inside a
matched block), the reflex was to design a new instrument — a vision model. Wrong.
lib/verify-content.js (10 KB) already detected exactly that class and had simply never been
connected to the scorecard. Likewise globals-diff (chrome) and asset-manifest (45 of 46 videos
missing) were already computing — both literally "mode": "reporting-only".
Four of the owner's five defects were already measured somewhere in the repo. The problem was never perception. It was gating and aggregation.
Why it was never wired in — the reusable lesson: verify-content.js had its own bespoke
aligner. Two instruments that align sections differently cannot be reconciled: they disagree about
which sections matched, so their findings cannot be attributed to the same row. The fix was to
reuse structural-diff#alignPage — then C and S agree by construction. A detector that
re-implements a shared primitive will not land, however good it is.
Detection rule: before designing a new instrument, grep for the one that already exists. Then
ask why it is not wired in — the answer is usually a contract mismatch (a second aligner, a
different key, its own crawl), and fixing that is far cheaper than a new instrument. Corollary: an
instrument that does its own crawl cannot join a pipeline whose axes are pure over stored
artifacts — move the signal into the capture instead.
A bounded dimension that does not MOVE THE RENDER is a silently dead contract¶
Bites: (2026-07-14) cta-wcp.astro accepts tone, passes it to <Section tone={tone}>, and
then hard-paints an inline background:
An inline background shorthand beats any tone-driven stylesheet rule. All 19 WCP cta-strips
already carry tone: "brand" — and the deployed ribbon still measures wrong. The value in the
data never reaches the paint. The capture measures the dimension, the seed carries it, the gate
scores it, and it does nothing.
This is the load-bearing objection to any config-writing agent (ADR-0011): it would see the wrong
ribbon, propose set tone=brand, apply it, observe no change, and propose the identical stamp
forever — each one logged as an applied fix. A config-writing agent cannot fix a
threading/override bug and cannot detect that it is looking at one.
Detection rule: ADR-0009's whole premise is "fidelity comes from measured parameters" — which
silently assumes every parameter actually paints. Nothing checks that. For every
(block, bounded-field, value), assert the rendered DOM/pixels change. Until that audit exists,
a dimension's presence in the schema is not evidence that it works.
The lever audit: prove every bounded select-value MOVES THE RENDER — and expect its first convictions to include the INSTRUMENT's own blind spots¶
Bites: (2026-07-14, ADR-0011 Slice 1d) The audit (apps/cms/scripts/lever-audit.mjs +
apps/cms/src/pages/lever-audit.astro, DEV-only) renders every (block, top-level select, value)
the schema declares — derived from the schema via the _definition introspection export, so a new
lever is audited automatically — through the REAL path (FcrBlocks → adapter → canonical), and
asserts each lever's values do not all produce an identical computed paint/layout signature. DOM
claims (classes, data-*) are deliberately NOT in the signature: data-tone changing while the
pixels don't is precisely the failure it exists to catch.
First run convicted six levers; four were real, in THREE distinct mechanisms:
- cta-strip.tone — inline style beats tone (the founding case, see the dead-contract entry);
- checklist.tone + team-grid.tone — the adapter never passed the field (schema declares it,
canonical honors it, the middle layer drops it);
- blog-posts.tone — a scoped (UNLAYERED) block style (.posts-wcp { background-color: … })
beat the layered tone utilities (the "unlayered beats @layer" trap, applied to a block's own CSS).
And two were the audit convicting its own blind spots — check the instrument before believing it:
- hero.scrim/hero.textTone "dead" because the FIXTURE routed to hero-1 (base carried
imageSide, variant unset → legacy inference → the hero that takes no scrim BY DESIGN) — pin
per-lever fixtures to the block variant that IMPLEMENTS the lever;
- hero.imageSide "dead" twice: the signature had no media-x field (a side flip moves ONLY where
the media sits), and the audit page lacked the @container/section-provider context (splits
render stacked outside a sized container — the SectionSplit pattern — so left/right moves nothing).
A degenerate cell (fixture didn't render) FAILS the audit rather than producing a verdict, and the
allowlist is two-way: an allowlisted lever that measures alive also fails (stale allowlist).
Also measured: the Image primitive silently kills the SSR stream on a data: URI src (the page
truncates at the first section-media, 200 logged, no error anywhere) — fixture images must be
production-shaped refs (r2:/URL paths), which is also the more honest fixture.
A comp-id join is only as honest as Wix's WRAPPER STRUCTURE — guard the id-join with heading/text consistency¶
Bites: (2026-07-14, ADR-0011 Slice 1d) All 20 WCP fcr.faq blocks carried tone: "brand",
_bg: #1e73be — the cta ribbon's paint — while the capture measured every live FAQ band white.
The capture was right; the SEED was wrong; and the join key was the liar. In the static crawl, one
Wix wrapper (comp-mi7ft7iu7) spans BOTH the cta ribbon and the FAQ band, so the build hung the
FAQ block on that wrapper's id — and the live capture enumerates the same id as the (headless) cta
band. comp-mi7ft7j05, the live FAQ section's id, does not exist in the static DOM at all. The
id-join then stamped the ribbon's tone onto every FAQ, systematically (one wrapper pattern, N
pages), and the wrongly-stamped tone was a WORKING lever — the block faithfully painted the navy
the data told it to. Producer chain: wrapper-id → mis-join → wrong stamp → correct render of a
wrong value.
The guard (makeStyleLookup): a join — id or positional — is accepted only when the block's
extracted heading doesn't CONTRADICT the capture section: agree when the capture section's heading
contains/is contained by the block's, else when the section's textHead (its opening text)
contains the block heading — a section's own heading surfaces in its text prefix (the headless
service-page hero case), while a WRONG section's text doesn't carry the block's heading at all. A
side with nothing measurable is unguardable and passes (never punish an unmeasured value). A
guarded-out join is rescued by heading lookup, else REFUSED loudly — no stamp beats a wrong stamp.
Calibrate the guard against the false-conviction case before shipping it: the first draft
preferred a unique-heading match over any headless id-join and promptly re-joined the (correctly
paired, headless-capture-side) service heroes onto same-heading about bands — textHead containment
is what separates "headless but right" from "headless and wrong". A/B regen proof: garvanbay 0
value diffs; WCP exactly 19 FAQ blocks × {tone, _bg, align}, nothing else.
A presence dim computed from two blind probes FABRICATES agreement — false === false is not fidelity¶
Bites: (2026-07-14, ADR-0011 Slice 1c) globals-diff's topbar.present reported agree: true
on a site whose deployed topbar had rendered in another client's cyan — one of the "7/7 chrome
dims agree". Both sides computed present: false, and equality was scored as agreement. But
neither false was a measurement: the LIVE probe measured Wix's pinned container
(zero-height → 'zero-size'), and the OURS probe selected [id="pinnedTopCenter"] — a Wix-only
id our lane never emits — so ours had no record at all. Two probes, blind for two different
reasons, agreeing in their shared blind spot. This is the same correlated-blindness shape as
globals reporting the topbar null vs null — but worse, because it laundered the blindness
into a positive comparable dim that inflated the agree count.
The rule: a dim may only compare when both sides carry a POSITIVE measurement, and
"measured absence" must be distinguishable from "probe never ran / probe cannot see this lane".
The fix shape (v7 topbarStrip): the probe always emits its key ({found:false} = ran, found
nothing — comparable data; absent key = stale — agree: null, excluded from the rollup), it knows
both lanes' idioms (Wix pinned-layer walk AND our .topbar band — a lane-specific selector on
a symmetric probe is a requirement, not a smell; cf. the oklch-parser entry), and derived
booleans like "present" are computed from the measured record, never from the absence of one.
Audit trigger: any diff dim whose two inputs can BOTH be produced by failure paths — if
nothing === nothing can reach agree: true, it will, on exactly the site where it matters.
The "used on live" asset census is the RENDERED DOM — the media pool over-counts, the per-section census under-counts, and the veto needs neither¶
Bites: (2026-07-14, ADR-0011 Slice 1c) "45 of 46 videos missing is a veto, full stop" was
unimplementable from every existing instrument: the media POOL (ADR-0010 mediaPool) holds 46
videos but mixes live-rendered assets with library cruft nobody ships (never separated —
gating on it manufactures debt); the per-section capture assets.videos count is a lazy-mount
floor that read 0 across all 25 WCP pages while the live site visibly plays video; and
asset-manifest.json is written pre-migration (63 raw refs on the SHIP-grade reference — a
veto would false-HOLD everything; see known-issues).
The instrument that works: the capture is already a rendered-DOM Playwright crawl, so a
page-level harvest rides it for free — every video URL/id the rendered page references, in two
tiers kept separate (dom: mounted <video>/<source>; markup: the serialized DOM incl. lazy
player configs — on live Wix the players never mount pre-interaction, so the markup tier is the
one that sees them, through gcp-repackager.wixmp.com prefixes). First run measured the split
the pool never had: live WCP references 27 of the pool's 46 (19 = cruft), ours references 1.
Two disciplines make it veto-grade: count-based per page, no identity join (a live Wix media
id and our r2 key share no derivable key — claim "fewer videos than live", nothing stronger), and
floor honesty (host-less /file.mp4 JSON-escape tails are junk-filtered per side; "0 found"
means "none observable", so the veto fires on measured SHORTFALL and never credits absence).
A video's SECTION HOME is its POSTER id, not its mp4 URL — Wix keeps the URLs in page-level script blobs, so containment attribution over the serialized DOM finds NOTHING¶
Bites: the video-segmentation slice (2026-07-15) attributed the mediaRefs harvest to
sections by containment — dom-tier element ancestry plus a per-section serialized-DOM scan
for the mp4-URL regex, deepest section first. Measured result on the fresh WCP capture: 0 of
36 refs attributed; all page-level. Both containment joins are structurally blind here: 0
<video> elements mount pre-interaction (the dom tier is empty), and every mp4 URL lives in
page-level script blobs (wix-warmup-data + inline player configs) — outside every
enumerated section, which is exactly why the page-level harvest could see them (the entry
above) and a section-scoped scan cannot.
The join that fires: the player paints the video's POSTER inside its section —
<videoId>f000.jpg, the same poster identity the carousel video-slide link uses
(linkVideoSlide). Measured on live /roof-painting before building anything: the media id
appears in exactly one section subtree (as a poster <img>), while the mp4 URL scores 2
page-level script hits and 0 section hits. So the capture gained a second pass: a
still-unattributed ref whose id has the distinctive Wix media-id shape (6hex_16-64hex —
guarded, or a short generic id like file.mp4 would substring-match everywhere) is homed
to the deepest enumerated section whose outerHTML carries the id; attributedBy:
'containment' | 'media-id' records which pass homed each ref. Result: 28/28 real refs
attributed (all media-id), 0 page-level; garvanbay 0 videos, faithful no-op.
Detection rule: when a containment join over the serialized DOM attributes nothing, don't
widen the regex — ask what identity the platform paints inside the section and join on
that. On Wix it is the media id via the poster. And keep the null honest: a ref that neither
pass can home stays sectionId: null (a measured page-level residue), never fabricated to the
nearest section.
Status: shipped 2026-07-15 (capture schemaVersion 7→8; lib/video-segmentation.js +
.validate.mjs 18 checks; consumer gates on version + the sectionAttribution evidence key
itself — no CAPTURE_REQUIREMENTS row, because nothing stamps from it). Report:
builds/<domain>/video-segmentation.json.
A provenance getter that FALLS BACK to a display label fabricates provenance — gate on filename-grade, return null loudly¶
Bites: wix-api.mjs#originalNameOf fell back to file.displayName when the record had no
media.*.filename — and for 19 of WCP's 46 videos that field holds the literal folder
label Misc. Migrating on it would have collided 19 files onto misc-<hash>.mp4 stems with
x-amz-meta-original-name: Misc (provenance destroyed at the moment it was being "preserved"),
and the name-based pool join in transform-seed-images would have matched "Misc" === "Misc"
across unrelated files.
Fix: explicit field precedence; displayName accepted only when it looks like a
filename (carries an extension); otherwise null — and the consumer says so
(transform-seed-images warns "using DERIVED basename … as provenance" before falling back).
Derived provenance is honest as long as it is labelled; a plausible wrong string never is.
Same family as "A reconciliation is only as honest as its JOIN KEY": the API record does not
always carry the field you assume, and the failure is a plausible value, not an error.
Status: shipped 2026-07-15 (originalNameOf filename-grade gate + selftest checks; the
known-issues precondition on any video migration is closed).
Embedded-app content lives in the APP's OWN API, not the page — harvest its public token from the page source (third media/content surface this week)¶
Seen: bmpartsandtools.ie (Wix chrome + embedded Ecwid store), 2026-07-15, the ADR-0012 pilot.
Three content classes were invisible to the DOM crawl and to the structure map: category
descriptions (the owner's "we lost this text" — it renders inside the Ecwid iframe),
category images, and the FAQ tabs beyond the SSR-active one (Wix FAQ app loads per-tab
via /_api/faq-server/v2/question-entries/query).
Rule: when a captured page embeds a third-party app (Ecwid, Wix FAQ, VOD player — same shape
as the 07-15 pt2 VOD finding), the missing content is reachable through the app's own API,
and the credential is usually already in the page source: Ecwid storefronts embed a read-only
public_… token; https://app.ecwid.com/api/v3/<storeId>/… then serves categories (name,
parentId, description, originalImageUrl) and products (sku, categoryIds). Intercepting the
app's network calls in Playwright (click the tabs, capture faq-server responses) beats
selector archaeology every time.
Corollary (join key): the Ecwid tree's identity is id/parentId — names carry " / " as
literal text ("Door Receivers / Keeps"), so any consumer that splits path strings on /
fabricates hierarchy (loader did; three-feed saga in the ADR-0012 pilot's known-issues entry).
Where the recipes live: ADR-0012 §Context lists the seven WordPress/Elementor-lane platform
gotchas from the same pilot; the executable scripts are wordpress/scripts/bmparts/* (separate
repo).
A vendor API is defined by its RESOURCE LIST, not its URL path or product name — read the endpoints before you believe the label¶
Seen: 2026-07-17. A "Wix CMS" API (.../business-solutions/cms/) was pointed at the portfolio
in the hope it would unblock the missing gallery videos. It cannot: "CMS" is Wix's rebrand of the
Content Manager, and the actual resource list is data collections (/wix-data/v2/... —
Data Items, Collection Management, Operations, External DBs). There is no media/video/VOD endpoint
anywhere in it. The videos stay behind the VOD 403 (ADR-0010 fourth amendment).
Rule: the tell for what an API does is its resource enumeration, never its path segment or marketing name — the same discipline as the VOD finding (read the live network trace, not the widget's appearance), arriving from the opposite direction. This one turned out empty for FCR by construction: the Wix Data APIs require the site's code editor to be enabled, and the portfolio is brochureware built without it — zero collections to read. Recorded as DROPPED, not deleted (ADR-0010 fifth amendment + Slice G), so the "new CMS API!" reflex doesn't re-chase it. Reopens only if FCR onboards code-editor sites.
A calibration instrument is buildable AHEAD of its corpus — but a precision-first ship cut needs ≥1 SHIP-labelled positive, and veto-held reference sites give ZERO¶
Seen: 2026-07-17, ADR-0009 Slice 6. The fit (scorecard axes → thresholds) needs a 30–50-site
built+labelled batch that does not exist locally (2 builds). Rather than fake it or fit on two
sites, the corpus-INDEPENDENT half shipped: the aggregator (scripts/batch-scorecard.mjs) and the
fitter (lib/calibration-fit.js + scripts/fit-calibration.mjs), validated on synthetic
separable data + a positive control that reads the two known-good anchors (the §4 sanity
anchor — the reader is validated even though the fit can't run).
Two rules bank from it. (1) Instrument-before-corpus is legitimate and de-risks the
expensive lane — write and positive-control the code now so that when the labelled batch lands,
fitting is one command; keep production calibration.json at fitted:false and have the CLI
refuse to overwrite it (promotion is a reviewed step). (2) A precision-first P(ship) cut is
un-fittable without ship positives — and both local anchors are veto-held (garvanbay
criticalSectionDropped, WCP videoDebt), so they supply zero ship labels. The corpus is
mandatory not merely for sample size but because the fit has nothing positive to learn from; the
fitter proves this by refusing (loud warnings, fitted:false, non-zero exit) on the 2-anchor run.
A local HTTP probe is only as honest as the PORT'S OWNER — assert responder identity, never a status code¶
Seen: 2026-07-18, batch-runner Step 0. Probing our freshly-built worker on
127.0.0.1:8787 returned a plausible JSON 403 ("protected by Cloudflare Access —
authenticate through the protected domain") that survived TWO rebuilds with the
Access adapter progressively stripped out — because the responder was a
leftover workerd from a different repo (quick-spike) sharing the port. The
string existed nowhere in this workspace; two builds were misdiagnosed before
Get-NetTCPConnection named the owner. Same family as the pgrep entry: a
probe that can be answered by the wrong producer is indistinguishable from
success.
Rules (all encoded in scripts/run-cms-batch.mjs):
- Before booting a local server, assert the port answers NOTHING (any HTTP
response = refuse loudly, naming the interception risk).
- After serving, assert responder identity from content — the site's own
title must appear in the served HTML. A 200 alone proves only that someone
answered. (Limit: chrome renders the title even over an empty body — it is
an identity check, not a content check; sweeneyofwexford proved that.)
- Two adjacent gotchas from the same spike: wrangler dev -c <file> BYPASSES
the Astro adapter's .wrangler/deploy/config.json redirect (it then tries to
bundle src/worker.ts and dies on astro: virtuals — plain wrangler dev
only); and an EMPTY local D1 302s every public route to
/_emdash/admin/setup — that redirect is the "not seeded yet" signature,
and migrations run on first REQUEST, not on boot.
A batch whose failure MODE migrates upstream over time is the ENVIRONMENT dying, not the sites — and every "ok" from the degradation window is poisoned¶
Bites: corpus run 1 (2026-07-18) ended 8/42 ok, and the 34 failures read
at first like corpus data — capture robustness, dead sites, matcher gaps. They
were none of those. Read in sequence, the failure stage marched steadily
upstream: sites 1–8 clean → cms-build timeout (spawnSync npm ETIMEDOUT,
bmac) → theme-extractor exit-1s (bray/brian/bright/browns) → instant
discover failures for the entire tail ("No site …"), plus one raw ✗ spawn.
Interleaved with the failures, "successful" stages ballooned: discover
1s→30–71s, one crawl to 40 min. That signature — later-and-cheaper stages
failing as time passes, successes slowing — is a box-side resource dying
progressively, and treating the rows as per-site evidence would have written
34 fictional site failures into the calibration story. Root cause was the
box's network: networkd declared ens5 Failed under load at 16:32, the DHCP
lease lapsed ~17:30 (scope doc §2026-07-19 incident).
The diagnosis chain, each step cheap and reusable:
- journalctl --list-boots first: the "crashed" box had ONE continuous
boot until IT's stop/start — so it never crashed, never OOM'd; something
around it died. This one command killed the OOM/disk/CPU theories.
- sshd bot-noise silence is a free network-cut timestamp: internet
background scans hit a public box every minute or two; their last log line
(17:30:07) dates the inbound cut to the minute, no monitoring required.
- Overnight cron durations discriminate starvation from network death: a
~1s timer job still taking ~1s all night proves CPU/memory were fine and
isolates the loss to connectivity.
Fix (box-side, both live): a networkd self-heal cron (ping the metadata IP
every 5 min, restart networkd on failure, log to
/var/log/networkd-selfheal.log) and /swapfile2 added to fstab (a reboot
had silently dropped 6 G swap → 2 G — an unasserted box fact, same disease as
the unasserted baked seed).
Detection rule: when a batch report shows failures whose stage drifts upstream over the run's timeline, suspect the runner's environment before any per-site theory — and treat every stage that "succeeded" during the degradation window as poisoned even though its status says ok: it passed through a dying resource, and presence-based skips will happily resume on top of it. Force-redo those stages; the proof it mattered here was braylaunderette re-crawling in 31.8s vs 858s degraded. Corollary: a batch's failure rows are only corpus data when the environment was healthy — an incident's rows must be excluded from any robustness/prevalence read.
Correction (2026-07-20): the detection rule above is NOT sufficient, and
applied on its own it misdiagnoses. Run 2 (2026-07-19) produced the same
signature — 2/42 ok, a wall of upstream discover failures across the whole
tail — and the environment was fine. journalctl --list-boots shows one
continuous boot from 07-19 13:51 to 07-20 08:09 (the box only stopped when IT
took it down for the RAM resize), no networkd/ens5 errors in the window, and
no OOM events in July at all. The cause was code: quick-crawl navigated
the page to /sitemap.xml, Wix redirects that path on sites without one, the
swallowed failure stayed in flight, and the nav-crawl fallback then collided
with it (Navigation to <home> is interrupted by another navigation to
<sitemap>). Deterministic on sitemap presence — which is exactly why it looked
environmental: it hit most of the tail at once. Fixed in db1bff8; the same
run redone 2026-07-20 scored 41/42.
The original run-1 diagnosis stands — that network death was real and well-evidenced. What was wrong was the inference that the signature implies it. Mass upstream failure means "something common to all sites", and the environment is only one candidate; a shared code path is another.
The discriminator, one grep, no box access: ask whether any OTHER
network-dependent stage SUCCEEDED in the same minutes. On run 2 the log shows
images ✓ (28.9s) and images ✓ (30.1s) — that stage downloads assets from
static.wixstatic.com over the public internet — interleaved with the
discover ✗ wall. Network egress was demonstrably working while the
network-shaped stage failed, which sends you to the code and not to the box.
Run this before --list-boots: it is cheaper and it fails fast in the
direction the 07-19 chain cannot.
Status: run 1 lost to the genuine incident; run 2 (2026-07-19) lost to the
quick-crawl bug above, NOT to the box; run 3 (2026-07-20) clean at 41/42 on
the resized 8 GB host. Watchdog + fstab swap live but never fired and remain
untested — no failure has recurred for them to catch.
A reference site can be blind to a whole defect class BY COINCIDENCE — a green dim on the tuning site proves nothing about the portfolio¶
chromeDisagree fired on 33 of 38 corpus sites, and the standing hypothesis
was that a defect at 87% uniform prevalence must be an instrument artifact
(the top-bar-missing crop precedent). It was not. The dominant component —
footer.bg, 27 of 38 — is a missing producer: footer-wcp.astro reads
var(--footer-bg, …), emitGlobalCss never emits --footer-bg, and
theme.json#colors.footerBg is null on every site. Every CMS site in the
fleet paints a white footer regardless of brand.
It survived a month of scorecard work because WCP's live footer is
genuinely white, so the reference site reported footer 7/7 agree — a true
agreement that carried zero information about the other 37 sites. The dim
was green for a reason unrelated to the code being correct.
This is the ADR-0010 second-amendment shape a third time (searchFiles ⊃
listFiles "proved" on WCP, inverted by garvanbay). The generalisation:
A dim that agrees on the reference site is evidence only if the reference site's value could have DISAGREED. Before trusting a green dim portfolio-wide, ask what the reference measures on each side — if both sides land on the same value for a reason the code doesn't control (white footer, absent CTA, no slideshow), the dim is untested, not passing. Prefer a site whose live value is unusual as the control for a chrome dimension.
Corollary — a composite veto hides its own prevalence ranking.
chromeDisagree is one name over four dims (footer.bg 27 · header.layout
17 · header.cta 10 · header.bg 7). Read as one veto it looked like a
single artifact worth one adjudication; read per-dim it is three known-shaped
producer gaps with wildly different payoffs, and the fix ladder shows each one
alone clears only 2–3 sites while all four clear 16. Roll a veto up for
gating; never rank work from the rolled-up name — go back to
globals.summary.disagreements (or the equivalent per-dim detail) before
scoping. Same lesson as ADR-0011's variantMismatch.count: 0 over 38 items,
arriving from the opposite direction: there the roll-up hid defects, here it
hid their shape.
A veto built to catch a PARSE bug false-fires once the value becomes measurable¶
transparentBlackRemnant vetoed any headerBg/footerBg/background equal to
#000000. It was written (with the deriver's alpha guard, same commit) to catch
the old bug where a transparent rgba(0,0,0,0) chrome fill parsed as opaque
black — at a time when no code legitimately measured a black chrome bg, so
#000000 in those fields could only mean the bug. The moment a new producer
(pickCoverBgEl, 2026-07-21) started measuring genuinely-black headers/footers,
the veto false-fired on real data — plus a −20 T penalty — on exactly the sites
the producer existed to help (shinnersfinancial's live header AND footer are
#000000).
When you add a producer that makes a previously-impossible value possible, grep for every rule that treated that value as a bug signature. A veto, assertion, or
=== SENTINELcheck encodes "this value can only mean the bug" — an assumption that silently expires when the value becomes legitimately measurable. The real guard against the parse bug lives at the deriver (transparent →null); the downstream veto was a redundant echo that outlived its premise. Fix: narrow the check to the field where the value is still bug-shaped (a black page canvas is still suspect; black chrome is not), don't blanket-remove — and confirm zero existing-corpus rows move (none did: the fields were all null before the producer).
Out-of-scope / open questions¶
Why Choose Us section absent from captured DOM¶
Bites: wfpainters live shows "Why Choose Us" content; nothing in our
captured DOM matches the wording. Either Wix lazy-renders it client-side
after Phase 1's load event, or it's ascii-encoded inside a sub-tree we're
not visiting.
Status: open. Need to: 1. Confirm presence on live (manual eyeball) 2. If present in source, find what tag wraps it and why our enumeration misses it 3. If JS-rendered, decide whether to wait longer in Phase 1 or add a second enumeration pass
Booking — Cal.com Teams require paid plan¶
Bites: chosen direction was Cal.com self-host for portfolio. Cal.com free tier doesn't support Teams (the multi-tenant primitive).
Status: deferred. Open alternatives — self-host Cal.com (AGPLv3 free
on our infra), schedule-x + custom backend, lightweight request form. See
memory/project_booking_solution.md.
Bottom CTA bar styling — bg/text per-site¶
Bites: Live garvanbay's mobile QuickActionBar is black/white. Other FCR sites may use brand colours. We hardcode black/white as portfolio default.
Status: open. Per-site override via theme.json field would generalise.
verify-design --selector body only sees above-the-fold¶
Bites: Gemini run only checks the hero region; below-the-fold issues
slip through. placement-check.js does full-page but is heavier.
Status: open. Either run multi-region per page (header / hero / each content section / FAQ / footer) or extend verify-design to scroll-capture.