Section-style contract (ADR-0009 Slices 2b-band + 3)¶
The per-section design dimensions of the canonical block library — the things that vary section-to-section on a Wix page and must read as the same site after replatform: text alignment, image side, column count, density, and header/topbar treatment. This is the section-level counterpart to the site-level aesthetic token contract (ADR-0009 Slice 1/2a): that one lifts the site-wide aesthetic into CSS vars; this one carries per-section bounded style from live capture onto each block.
It generalises ADR-0007's tone mechanism to every design dimension. tone
proved the whole rail (capture → classify → store → join → stamp → render →
edit); each new dimension rides the identical rail.
Governing principle (ADR-0009): fidelity from measured parameters, scale from bounded contracts. Every captured value is clamped/quantized into a small enum or scale the render lane styles exactly once — never raw computed-style passthrough (that recreates per-site CSS hackery and breaks CMS editability). An unmeasurable/ambiguous dimension is omitted (→ no prop stamped → the block's own default stands; zero-regression), never fabricated (ADR-0004 loud-fail).
The rail (one record, one lookup, join once — stamp many)¶
- Capture (in-page).
lib/capture-section-backgrounds.jsalready enumerates every content<section>in the rendered page (mirroring the seed'sbuildSections, so DOM order +contentIndexline up). It measures raw per-section geometry/computed-style in-page — child bounding rects are viewport-truth even under Wix absolute positioning, and are pre-joined by construction to the section whoseid/contentIndexwe're recording. We do not post-hoc joinlayout.json(a different crawl stage) — that would add a second artifact join and a drift surface for zero benefit. - Classify (pure).
lib/section-style.js— deterministicraw → bounded, Playwright-free, re-runnable over a stored capture (mirrorslib/design-derive.js). Emits only confidently-measured dimensions. - Store.
builds/<domain>/section-backgrounds.json,schemaVersion: 4. Each section record gainsstyle(bounded, the contract) +_styleRaw(audit geometry, never rendered), alongside the existingtone+bg. The site-levelchromerecord carrieschrome.floatingRaw = { candidates: [...] }— always emitted, even with zero candidates, because the key's presence (not its length) is the freshness signal for whether a capture predates the floating-geometry walker. - Join (two lanes, both reuse the tone join).
- Build-time:
lib/cms/seed-from-build.js#makeStyleLookup(capture, slug)— one lookup returning{ tone, _bg, imageSide, … }, joined by sectioncomp-*id thencontentIndex/_matchPosition(blog:_blogPosition). - In-place:
lib/cms/apply-section-tones.js#alignBlocks— the 5-strategy cascade (heading → text-containment → blog-tag → gap-tone → positional). Geometric props (imageSide, …) stamp on identity-known matches only, notgap-tone— gap-tone proves tone agreement across a gap, not which section a block is. - Stamp. Flat scalar fields on the
fcr.*block, besideblock.tone— intransformer.js(build-time) andapply-section-tones.js(in-place). - Render. Reuse an existing sink where one exists; otherwise a new CVA
variant or a
data-*attribute + generated CSS. CSS layer rule: new per-section rules are unlayered ([data-slot="section"][data-*="…"]/ block-class-scoped var rules fromemitGlobalCss) — an@layeroverride loses to the unlayered:rootdefaults for custom-property values (see known-patterns "A per-site CSS override must be UNLAYERED …"). - Edit. Each threaded prop is (or maps onto) a top-level scalar
selectfield in the emdash marketing-blocks schema — self-service intact.
The six dimensions¶
| Dimension | Bounded values | Raw signal | Render sink | Editable field | Status |
|---|---|---|---|---|---|
| imageSide | left · right (else omit = stacked/none) |
largest qualifying side media rect vs text-column bbox, section-relative (in-page) | existing content-2 reverse via fcr.about.imagePosition |
existing imagePosition select |
Shipped 2026-07-06 |
| alignment | left · center |
heading (then paragraph-majority) computed text-align |
new data-align + unlayered global CSS on [data-slot="section"] |
new align select |
Shipped 2026-07-07 |
| columns | 2 · 3 · 4 |
densest even-width first-row grid: CSS grid gridTemplateColumns track count, else first-row same-top child cluster |
SectionGrid data-columns (services-1) + team-grid own-grid data-columns (gallery already has columns) |
columns select (service-grid, team-grid; gallery own) |
Shipped 2026-07-07 |
| density | sm · default · lg |
per-section effective band padding (top inset to first content child), gated h≥200, ≥4px |
existing <Section size> → --section-py |
density select |
Planned |
| header/topbar | layout stacked-center·split-center (·inline-left recognised, unrouted); position static·pinned; overlay solid·transparent-over-hero |
logo bbox vs nav-item centroids + navTop (chrome.headerRaw, in-page) → classifyHeaderLayout; position/overlay: computed position, bg alpha, hero-underlap |
layout: header-wcp layout prop (a bounded variant on the one block — NOT header-wcp-vs-header-1 block-switching) + header CTA; position/overlay render deferred |
globals fields (layout select + header CTA; site-level, not per-block) |
Layout shipped 2026-07-12 (CMS lane); position/overlay Planned |
| floating | CTA label/href/optional position (right·bottom-right·bottom-left); social items[{platform,url}]/optional position (right·left·bottom-right) |
pinned/fixed/sticky candidate geometry below the header band (chrome.floatingRaw, in-page) → classifyFloatingCta/classifyFloatingSocial |
floating-cta-wcp / floating-social-wcp (site-level pill + rail; both already styled every enum value) |
globals fields (site-level, not per-block) | Shipped 2026-07-13 (CMS lane); CTA right orientation is a render follow-up |
Notes on the hard dimensions:
- header/topbar is site-level, not a per-section prop — one header per site;
a per-block field would be a lie in the UI. Bounded as three orthogonal
binaries (layout / position / overlay) rather than one combined enum, which is
what keeps it boundable. transparent-over-hero is classify-and-report only
— canonical headers have no hero-underlap mode; building one is a scope change
(un-defer trigger: ≥3 sites in a built cohort classify transparent).
- floating is site-level, not a per-section prop — one floating layer per
site (pinned CTA pill + social rail), same reasoning as header/topbar. It is
partitioned from header/topbar at capture time by Y-band: headerGeometry
claims candidates INSIDE the header band (the header CTA), floatingGeometry
claims those BELOW it (cy > headerBottom + 40). The two guards are mirror
images and must stay in step — a change to one's band boundary without the
other reopens double-counting or a gap.
- team-grid and checklist were raw <section> — migrated to compose
<Section> 2026-07-07 (columns slice) so they gain data-slot/tone/align
capability; team-grid additionally carries columns on its own card grid.
Minimal-wrap migration (Section owns padding; each block keeps its bespoke inner
markup — team-grid's .team-grid-inner, checklist's 56rem .checklist-inner +
primary-colour prose) so it's zero-regression, verified on the live CMS demo.
tone/align are not yet stamped on these two (checklist keeps .checklist-prose,
not SectionProse, so align has nothing to target; team-grid's heading is still
the adapter's external <h2>) — capability only, until a future dimension wires
them.
Representative content-band selection (for site-level defaults)¶
Some site-level defaults (e.g. the site-wide --section-py rhythm) need one
representative content band, not a positional guess. section:nth-of-type(2) is
often a trust ribbon (67px on WCP), which poisons the measure. The classifier
picks a real band node-side over the captured section records: filter
(h≥~200, not media-backed, no hero h1, has an h2/h3 + real prose, not a
logo/ribbon strip) → score (CTA, prose length, repeated items, plausible
height) → highest wins, null-if-none (omit + report). Provenance recorded
in-data (representativeBand: { id, contentIndex, heading, score }). Built with
the density slice — imageSide classifies every section independently and needs
no representative band.
Slice split¶
- 2b-band (capture + pure classification): in-page
_styleRawgeometry;lib/section-style.jsclassifiers +lib/section-style.validate.mjs;schemaVersion: 2records; representative-band selection (with density). Testable from stored artifacts, zero render risk. The floating dimension (chrome.floatingRaw,classifyFloatingCta/classifyFloatingSocial,schemaVersion: 4) added its capture + classifiers here and went all the way through Slice 3 in the same slice — its render surfaces and globals fields already existed; only the measurement feeding them was missing. - Slice 3 (thread + edit):
makeStyleLookup; thetransformer.js/apply-section-tones.jsstamps; block Props + adapter forwarding + any new Section/SectionGrid render surface; emdash fields; the team-grid/checklist<Section>migration.
Sequencing: one dimension driven end-to-end first, then fan out.
Order: imageSide → alignment → columns → density → header/topbar.
imageSide + alignment + columns shipped 2026-07-07; hero dims (scrim +
textTone, media-backed sections only, stamped on fcr.hero — see
lib/section-style.js#classifyScrim/#classifyTextTone and known-patterns
"Wix dims a hero by rendering the IMAGE at opacity<1 over black") shipped
2026-07-09; the header/topbar layout sub-dimension shipped 2026-07-12
(classifyHeaderLayout → header-wcp layout prop; see known-patterns "The
capture plane is the authority for header layout too") out of strict order,
driven by the capability-backlog #2 audit; density is next (with the
header/topbar position + overlay sub-dimensions still Planned). The hero
variant dimension (carousel|video|full-bleed|side-image|text,
lib/section-style.js#classifyHeroVariant) shipped 2026-07-12 pt2 — a
hero-specific dimension (like scrim/textTone, not a per-section row in the
table above) that UNLIKE the others derives at the seed join from
already-bounded persisted capture values (bg.media / imageSide /
assets.{slides,videos} — no re-crawl), retiring the brittle imageSide &&
!media hero-routing inference; see known-patterns "A dimension whose inputs are
already-BOUNDED capture values derives at the JOIN". The floating dimension
(classifyFloatingCta/classifyFloatingSocial, chrome.floatingRaw,
schemaVersion: 4) shipped end-to-end 2026-07-13 — capture, classifiers,
seed stamp, and live render on WCP (the render surfaces + globals fields already
existed; the measurement feeding them did not).
imageSide (first shipped dimension) — specifics¶
- Capture (
capture-section-backgrounds.js#resolveSectionStyle, in-page): the largest visible IMG/VIDEO/bg-image child that covers≥15%of the section area but≤70%of section width (wider = a full-bleed band, tone's territory) →_styleRaw.media {x,w,h,areaFrac,widthFrac}; the union bbox of visible headings/paragraphs →_styleRaw.text {x,w}. Section-relative px. - Classify (
section-style.js#classifyImageSide): media-centre vs text-centre;leftwhen media centre is left of text by≥10%of section width,rightwhen right by≥10%, else omit (overlapping/concentric = ambiguous). Thresholds locked insection-style.validate.mjs. - Render (one public field): measured
imageSidedrives the existingfcr.about.imagePositionselect (left → content-2 reverse=true,right → reverse=false), superseding the prior matcher/alternation default. No new schema field, no new CSS —About.astro/content-2.astroalready render it. Stamped only on anfcr.aboutblock already laying out a side image (imagePositionleft/right);centered/noneblocks (no image) are left untouched. - Per-site static lane deferred (Option A, 2026-07-06): the
assembler-fulldevlane consumes no section-background capture today (not eventone) — image side there is the variant-picker zig-zag alternation heuristic. Threading measured styling into the static lane is folded into the future slice that brings all captured styling (tone + style dims) to that lane. Reopen trigger: first per-site static launch (same trigger as thenoindexblocker). Until then the alternation heuristic stays as the graceful default. The classifier +makeStyleLookupare lane-agnostic, so that lane is a drop-in.
alignment (second shipped dimension) — specifics¶
- Capture (
capture-section-backgrounds.js#resolveSectionStyle, in-page):_styleRaw.align = { heading, paraMajority }— the prominent heading's computedtext-align(first visible non-empty h1/h2/h3) plus the paragraph-majoritytext-align. Computed (used) value, sostart/left/centercome through, not the authored value the stored HTML omits. - Classify (
section-style.js#classifyAlign): heading decides (a centred heading over left body still reads centred — mirrors content-1); paragraph majority is the fallback only when the heading isn't a bounded value.normAlign:center→center,left|start→left, everything else (right/justify/end/empty) → omit. Locked in the validate script. - Render — a NEW universal sink (unlike imageSide's field reuse): the shared
Sectionprimitive gainsalign?: left|center→data-align+ six unlayered global rules ([data-slot="section"][data-align=…] [data-slot="section-{prose,content,actions}"]) centring/left-aligning prose text, content items, and the action row. The attribute selector (0,3,0) overrides each block's hard-coded alignment (0,1,0 Tailwind utilities: cta-wcp/services centre or left by default) — so a measured value wins with no!important; absentdata-align→ block default stands (zero-regression). A block's own<p>-level override (e.g. content-1's[&_p]:text-left, 0,2,0) still beats the inherited prose text-align, intended. - Threaded onto 5 canonical blocks (content-1/2, cta-wcp, services-1, faqs-1)
and forwarded by 4 adapters; stamped on
fcr.{about,cta-strip,service-grid,faq}(ALIGN_ELIGIBLE, kept in sync intransformer.js+marketing-blocks/index.ts), identity-known matches only (not gap-tone). NewALIGN_FIELDselect (Auto/Left/Centre); adapters treat anything butleft|center(incl. theautoneutral) as no override. - team-grid + checklist excluded — raw
<section>, don't compose<Section>, so they can't carrydata-alignuntil the<Section>migration (a prerequisite for columns/density too).
columns (third shipped dimension) — specifics¶
- Capture (
capture-section-backgrounds.js#resolveSectionStyle, in-page):_styleRaw.columns = { gridTracks, firstRow }. Scan every descendant with ≥2 visible children whose top row clusters at a near-equaltop(±8px) of similar-width items (wMax/wMin ≤ 1.6, each ≥ 12% of section width — filters nav/button rows); keep the widest such row.gridTracks= that container's resolvedgridTemplateColumnstrack count when it's a CSSgrid;firstRow= the cluster count (the flex/absolute fallback Wix usually needs). Section-scoped: columns only ever stamps on the grid block joined to THIS section, so the densest even-width row here is that block's own grid. - Classify (
section-style.js#classifyColumns): prefergridTracks ≥ 2, elsefirstRow ≥ 2; omit below 2 (single column → block default stands); clamp 4+ → 4 (the widest bounded grid). Locked in the validate script. - Render — two sinks (one shared primitive, one own-grid). The shared
SectionGridprimitive gainscolumns?: 2|3|4→data-slot="section-grid"+data-columns+ three unlayered[data-slot=section-grid][data-columns=…]rules (grid-template-columns: repeat(N, minmax(0,1fr)), mobile-collapse to 2 then 1). The attribute selector (0,2,0) beats the CVAsizevariant'sgrid-cols-[auto-fit…]utility (0,1,0) with no!important; absentdata-columns→ the auto-fit default stands (zero-regression).services-1(fcr.service-grid) picks this up by forwardingcolumnsto its<SectionGrid>. team-grid keeps its bespoke.team-grid-innercard grid and takes an equivalent.team-grid-inner[data-columns=…]override (the gallery-wcp--colspattern), preserving its 2-col default + gaps. gallery already had acolumnsselect +data-columnsgrid — untouched; it just becomes a measured-stamp target. - Threaded/stamped on
fcr.{service-grid, team-grid, gallery}(COLUMNS_ELIGIBLE, kept in sync intransformer.js+apply-section-tones.js+marketing-blocks/index.ts), identity-known matches only (not gap-tone) — a geometric prop needs to know which section a block is. Stored as a string (matches the emdash select and gallery's existing string columns). NewCOLUMNS_FIELDselect (Auto/2/3/4); adapters treat anything but2|3|4(incl. theautoneutral) as no override. - checklist excluded from columns — a single-column list, not a 2/3/4 grid;
it gets the
<Section>migration (tone capability) but nocolumnsfield. blog-posts deferred —posts-wcphand-rolls its own 3-col grid (notSectionGrid); wiring itsdata-columnsis a separate follow-up. - garvanbay note (measured no-op): garvanbay has no service-grid/gallery, and
its one team-grid (
/about, empty heading) matches via gap-tone → columns correctly withheld. So the measured stamp is a faithful no-op on garvanbay (its team measures 2 = its default anyway). The render surface was proven live end-to-end by a temporary D1columns=3(renderedrepeat(3,1fr)), then reverted. First automatic live columns awaits a site with an identity-matched grid.
floating (sixth dimension, capture + classify only) — specifics¶
- Capture (
capture-section-backgrounds.js#floatingGeometry, in-page): walks everya[href]on the page, keeping those below the header Y-band (cy > headerBottom + 40—bandFloor, mirroringheaderGeometry's own band ceiling so the two probes partition the pinned layer without overlap or gap) AND "floating": a Wix[id^="pinned"]ancestor, or anyposition: fixed|stickyancestor found by walking up todocument.body. Each surviving candidate carriestext, aria, href, w, h, cx, cy, fromLeft, fromRight, fromTop, fromBottom, vw, vh, styled, hasIcon, pinned. Recorded atchrome.floatingRaw = { candidates: [...] }— always emitted, even with an empty array, because the key's presence (not its length) is the freshness signal a consumer uses to tell "the probe ran and found nothing" apart from "the probe never ran" (anull/absent field can't distinguish those, and that ambiguity is exactly what let a stale artifact silently revert the header CTA for two sessions). - Classify (
section-style.js#classifyFloatingCta/#classifyFloatingSocial): pure functions overraw.candidates, partitioning the pinned layer into three widget kinds by measurement, not by DOM position: - Scroll-to-top FAB — recognised and deliberately dropped.
isScrollToTopCandidatecatches a bare//#-prefixed href or a scroll-labelled text run. It has no CMS home; without this test its bare/href would misread as a CTA to the homepage. - CTA pill (
classifyFloatingCta): must bestyled(button-look), pass the scroll-to-top test, not resolve to a known social platform (socialPlatformOf— the exact conflation a hand-written D1 patch made on WCP, treating a WhatsApp button as the CTA), passisCtaHref(a real navigable href, notel:/mailto:/#/js:), and have a label of2-30chars that isn't a login/account string. Ties among survivors break on largest area (w*h) — a CTA pill is a wide text button, a social icon is a small square. Returns{ label, href, position? }. - Social rail (
classifyFloatingSocial): every candidate (minus scroll-to-top) whosehrefresolves viasocialPlatformOf(whatsapp,facebook,instagram,linkedin,youtube,tiktok,twitter,google-reviews,phone,email— matches the CMSSOCIAL_PLATFORMSenum), deduped by url, in DOM order. Returns{ items: [{ platform, url }], position? }. - A social-platform href is never the CTA —
classifyFloatingCtaexcludes any candidatesocialPlatformOfrecognises, so the two classifiers partition the layer disjointly by construction. - Corner derivation (
floatingCorner, shared by both classifiers):bottomifffromBottom <= vh * FLOATING_BOTTOM_FRAC(0.25— the bottom quarter of the viewport reads as a "bottom-" corner; a mid-edge rail like WCP's ENQUIRE pill sits higher and reads as a plain side);leftifffromLeft < fromRight. The social rail'spositionis the majority* corner across its members (a vertical rail shares one corner), ties falling to the first member's corner in DOM order (counts.get(k) > bestN, strict>so an earlier corner wins a tie). - Bounded, per-BLOCK enum (never a shared one):
clampPositionmaps an unmappable/derived corner through each block's own schema enum —FLOATING_CTA_POSITIONS = ['right', 'bottom-right', 'bottom-left']for the CTA pill,FLOATING_SOCIAL_POSITIONS = ['right', 'left', 'bottom-right']for the social rail (the two enums deliberately differ, matchingapps/cms/src/plugins/marketing-blocks/index.ts). A corner outside a block's enum omitspositionentirely so that block's own default stands — the governing bounded-contract rule (never emit a value the render lane doesn't style), applied per block rather than per dimension. - Live WCP anchor values (the fixture, measured 1440x900): the ENQUIRE pill
(179x45,
fromBottom=328) classifies as the CTA atright; the WhatsApp button (55x55,fromBottom=95) classifies into the social rail atbottom-right; the "Up" scroll-to-top FAB (47x47) is recognised and dropped by both classifiers. garvanbay measures zero floating candidates on its capture — both classifiers correctly returnnull(faithful, not a bug). - Render — SHIPPED end-to-end (CMS lane), 2026-07-13 pt3. The render surfaces
already existed (
floating-cta-wcp.astro/floating-social-wcp.astro, both styling every value in their enums), and the globals fields already existed (fcr.floating-cta/fcr.floating-social). What was missing was the measurement feeding them.seed-from-build.jsnow stamps both blocks fromchrome.floatingCta/chrome.floatingSocial(stamp-always, like the header CTA — a matcher guess is not a value worth protecting). Deployed live on WCP by surgicalsync-globals-to-d1.mjsstamp; no worker redeploy needed (data-only).globals-diff.jscarries a floating peer, reporting live-vs-oursagree: trueon both dims. Known render gap (not a measurement gap):floating-cta-wcp--rightstyles therightvalue as a rotated vertical tab, while live's ENQUIRE is a horizontal pill (measured 179x45, un-rotated). See known-issues "Floating dimension — two render-lane follow-ups". - Contract tests:
lib/section-style.validate.mjslocks the thresholds/enums above; the floating dimension brought the suite to 143 checks (up from 103).