Skip to content

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)

  1. Capture (in-page). lib/capture-section-backgrounds.js already enumerates every content <section> in the rendered page (mirroring the seed's buildSections, so DOM order + contentIndex line 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 whose id/contentIndex we're recording. We do not post-hoc join layout.json (a different crawl stage) — that would add a second artifact join and a drift surface for zero benefit.
  2. Classify (pure). lib/section-style.js — deterministic raw → bounded, Playwright-free, re-runnable over a stored capture (mirrors lib/design-derive.js). Emits only confidently-measured dimensions.
  3. Store. builds/<domain>/section-backgrounds.json, schemaVersion: 4. Each section record gains style (bounded, the contract) + _styleRaw (audit geometry, never rendered), alongside the existing tone + bg. The site-level chrome record carries chrome.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.
  4. Join (two lanes, both reuse the tone join).
  5. Build-time: lib/cms/seed-from-build.js#makeStyleLookup(capture, slug) — one lookup returning { tone, _bg, imageSide, … }, joined by section comp-* id then contentIndex/_matchPosition (blog: _blogPosition).
  6. 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, not gap-tone — gap-tone proves tone agreement across a gap, not which section a block is.
  7. Stamp. Flat scalar fields on the fcr.* block, beside block.tone — in transformer.js (build-time) and apply-section-tones.js (in-place).
  8. 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 from emitGlobalCss) — an @layer override loses to the unlayered :root defaults for custom-property values (see known-patterns "A per-site CSS override must be UNLAYERED …").
  9. Edit. Each threaded prop is (or maps onto) a top-level scalar select field 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-centerinline-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 _styleRaw geometry; lib/section-style.js classifiers + lib/section-style.validate.mjs; schemaVersion: 2 records; 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; the transformer.js / apply-section-tones.js stamps; 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 (classifyHeaderLayoutheader-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; left when media centre is left of text by ≥10% of section width, right when right by ≥10%, else omit (overlapping/concentric = ambiguous). Thresholds locked in section-style.validate.mjs.
  • Render (one public field): measured imageSide drives the existing fcr.about.imagePosition select (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.astro already render it. Stamped only on an fcr.about block already laying out a side image (imagePosition left/right); centered/none blocks (no image) are left untouched.
  • Per-site static lane deferred (Option A, 2026-07-06): the assembler-fulldev lane consumes no section-background capture today (not even tone) — 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 the noindex blocker). Until then the alternation heuristic stays as the graceful default. The classifier + makeStyleLookup are 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 computed text-align (first visible non-empty h1/h2/h3) plus the paragraph-majority text-align. Computed (used) value, so start/left/center come 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 Section primitive gains align?: left|centerdata-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; absent data-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 in transformer.js + marketing-blocks/index.ts), identity-known matches only (not gap-tone). New ALIGN_FIELD select (Auto/Left/Centre); adapters treat anything but left|center (incl. the auto neutral) as no override.
  • team-grid + checklist excluded — raw <section>, don't compose <Section>, so they can't carry data-align until 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-equal top (±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 resolved gridTemplateColumns track count when it's a CSS grid; 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): prefer gridTracks ≥ 2, else firstRow ≥ 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 SectionGrid primitive gains columns?: 2|3|4data-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 CVA size variant's grid-cols-[auto-fit…] utility (0,1,0) with no !important; absent data-columns → the auto-fit default stands (zero-regression). services-1 (fcr.service-grid) picks this up by forwarding columns to its <SectionGrid>. team-grid keeps its bespoke .team-grid-inner card grid and takes an equivalent .team-grid-inner[data-columns=…] override (the gallery-wcp --cols pattern), preserving its 2-col default + gaps. gallery already had a columns select + data-columns grid — untouched; it just becomes a measured-stamp target.
  • Threaded/stamped on fcr.{service-grid, team-grid, gallery} (COLUMNS_ELIGIBLE, kept in sync in transformer.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). New COLUMNS_FIELD select (Auto/2/3/4); adapters treat anything but 2|3|4 (incl. the auto neutral) 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 no columns field. blog-posts deferredposts-wcp hand-rolls its own 3-col grid (not SectionGrid); wiring its data-columns is 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 D1 columns=3 (rendered repeat(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 every a[href] on the page, keeping those below the header Y-band (cy > headerBottom + 40bandFloor, mirroring headerGeometry's own band ceiling so the two probes partition the pinned layer without overlap or gap) AND "floating": a Wix [id^="pinned"] ancestor, or any position: fixed|sticky ancestor found by walking up to document.body. Each surviving candidate carries text, aria, href, w, h, cx, cy, fromLeft, fromRight, fromTop, fromBottom, vw, vh, styled, hasIcon, pinned. Recorded at chrome.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" (a null/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 over raw.candidates, partitioning the pinned layer into three widget kinds by measurement, not by DOM position:
  • Scroll-to-top FAB — recognised and deliberately dropped. isScrollToTopCandidate catches 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 be styled (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), pass isCtaHref (a real navigable href, no tel:/mailto:/#/js:), and have a label of 2-30 chars 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) whose href resolves via socialPlatformOf (whatsapp, facebook, instagram, linkedin, youtube, tiktok, twitter, google-reviews, phone, email — matches the CMS SOCIAL_PLATFORMS enum), deduped by url, in DOM order. Returns { items: [{ platform, url }], position? }.
  • A social-platform href is never the CTA — classifyFloatingCta excludes any candidate socialPlatformOf recognises, so the two classifiers partition the layer disjointly by construction.
  • Corner derivation (floatingCorner, shared by both classifiers): bottom iff fromBottom <= 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); left iff fromLeft < fromRight. The social rail's position is 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): clampPosition maps 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, matching apps/cms/src/plugins/marketing-blocks/index.ts). A corner outside a block's enum omits position entirely 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 at right; the WhatsApp button (55x55, fromBottom=95) classifies into the social rail at bottom-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 return null (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.js now stamps both blocks from chrome.floatingCta / chrome.floatingSocial (stamp-always, like the header CTA — a matcher guess is not a value worth protecting). Deployed live on WCP by surgical sync-globals-to-d1.mjs stamp; no worker redeploy needed (data-only). globals-diff.js carries a floating peer, reporting live-vs-ours agree: true on both dims. Known render gap (not a measurement gap): floating-cta-wcp--right styles the right value 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.mjs locks the thresholds/enums above; the floating dimension brought the suite to 143 checks (up from 103).