Skip to content

ADR-0003 — lib/assembler-fulldev.js design

Status: Proposed (spec — Phase 1a in flight) Date: 2026-05-03 (revised same-day after matcher-output walk-through) Deciders: Cathal Dempsey Related: ADR-0001 (fulldev as foundation), ADR-0002 (primitive fingerprint)

Revisions after matcher walk-through (2026-05-03)

Reading every matcher's extract() return shape against the original variant-picker draft surfaced six concrete corrections. They are folded into the relevant sections below; the originals are kept where they still apply. Summary:

  1. Hero matcher emits backgroundImage (full-bleed) and backgroundImages (slideshow) — never an image for side layouts. Hero-1 (text + side image) cannot fire from current matcher signals.
  2. Hero-2 (split with stats) trigger condition was invented — matcher's contentPosition ∈ {top|center|bottom|below}, no 'split'. Dropped from the variant table.
  3. About emits text (single string), not paragraphs[]. Assembler will split on \n\n boundaries — easier than a matcher rewrite, no info lost.
  4. About emits image: <url string> + separate imageAlt. Assembler wraps into { src, alt } for fulldev's content-1.
  5. Hero cta[] and phone[] are separate arrays. Assembler merges into links[], phone first.
  6. Three more patterns have no fulldev equivalent beyond the three custom variants we have: VideoGallery (1%), LocationMap (1%), FloatingSocial (3%). Total deferred custom variant count: 6.

Context

Pilots 1–5 (WCP, DP, BDA, ACM, trimtech) proved that fulldev/ui blocks plus a small set of custom variants (cta-wcp, gallery-wcp, topbar-wcp, hero-4 with scrim) can render typical FCR Wix sites with appropriate brand fidelity. Each pilot was hand-rolled — ~90 minutes per site to scrape, write content.ts, pick variants, write index.astro.

To scale to 1597 ready sites in the portfolio survey, this work needs to be programmatic. The bespoke v3 pipeline already does this for hand- authored components via lib/assembler-v3.js. lib/assembler-fulldev.js is the equivalent that emits fulldev components. Same matcher output, different rendering target.

Inputs (already produced by the existing pipeline)

The matcher pipeline currently produces, per site:

  1. Section list from lib/structure-matcher.js:
    [
      { section: { id, tag, $el }, matcher: { name, component, priority }, score, props }
      ...
    ]
    
    props is whatever the matcher's extract() function returned.
  2. theme.json from lib/theme-extractor.js — palette + fonts + dimensions.
  3. compLayout + computedLayout + siteLayout — Wix DOM layout signals.
  4. Page metadata — title, description, JSON-LD from head.html.

The fulldev assembler consumes the same inputs. No matcher rewrite required for the basic path.

Outputs

Per site, emit a complete Astro project at builds/<domain>/assembled-fulldev/:

assembled-fulldev/
├── astro.config.mjs        # tailwindcss vite plugin + image.remotePatterns
├── package.json            # astro + tailwind + fontsource + cheerio
├── tsconfig.json           # @/* paths
├── components.json         # shadcn registry config (for refresh)
├── public/
│   ├── logo.<ext>          # downloaded brand logo
│   └── assets/             # downloaded media (already produced by Phase 1)
└── src/
    ├── pages/
    │   ├── index.astro
    │   ├── <slug>.astro    # one per matched page
    │   └── ...
    ├── components/
    │   ├── ui/             # fulldev primitives (shared across all sites)
    │   └── blocks/         # fulldev block variants + our custom variants
    ├── data/
    │   └── content.ts      # extracted props as a typed export
    └── styles/
        └── global.css      # tailwind + per-site CSS vars from theme.json

The components subtree is the seed library — pulled once via scripts/install-fulldev.cjs, copied into every site, kept in sync. Custom variants (cta-wcp.astro, gallery-wcp.astro, topbar-wcp.astro) live in lib/components-v3/blocks/ in the canonical repo and are copied in alongside fulldev's.

Variant-picker logic (per pattern)

The variant-picker is the heart of the assembler. For each matched section, it picks one fulldev block (or a custom variant) and translates the props.

Hero

Matcher emits: { heading, subtext?, cta?, phone?, backgroundVideo?, backgroundImages?, backgroundImage?, overlayOpacity?, contentPosition? }.

Variant Picks if Translation
hero-4 + scrim (full-bleed bg with text overlay) backgroundImage OR backgroundImages OR backgroundVideo image: { src: backgroundImage \|\| backgroundImages[0], alt: heading }; links: [...phone, ...cta]; slot title from heading, subtext from subtext
hero-3 (centered text only) none of the above links: [...phone, ...cta]; slot title + subtext
~~hero-1 (text + side image)~~ matcher does not currently emit a side-image signal deferred until Hero.js adds an image field for non-full-bleed cases
~~hero-2 (split with stats)~~ droppedcontentPosition values are top \| center \| bottom \| below; no split

Scrim intensity (hero-4 prop): drive from Hero.js imageLuminance signal (NEW — to be added in Phase 3). Default medium for missing signal. Mapped to from-black/30 via-black/50 to-black/70 (medium) / from-black/10 via-black/30 to-black/50 (light) / from-black/40 via-black/60 to-black/80 (heavy).

About / Content

Matcher emits: { heading, text, image?: <url string>, imageAlt?, images?: [{src, alt}], imagePosition?, video? }.

Variant Picks if Translation
content-1 (text + image split) image OR images.length >= 1 image: { src: image \|\| images[0].src, alt: imageAlt \|\| images[0].alt }; slot from heading + paragraphs
content-1 (no image) no image and no images same component; image undefined; slot only
content-2 (full-width prose) text.split(/\n\n/).length >= 4 and no image slot prose, no media

Paragraph splitting: matcher emits text as a single string. Assembler splits on \n\n boundaries to produce paragraph items. The slot content becomes:

<h2>{heading}</h2>
{paragraphs.map(p => <p>{p}</p>)}

(The pilot used <ul><li> because it had a hand-curated items[] array; the matcher doesn't produce that structure.)

Video case: if matcher emits video, embed it as a <video> slot element. content-1 doesn't take a video prop, so the video goes inline in the slot beside the heading.

ServiceGrid / Features

Matcher emits: { services: [{ title, href, image?: <url>, description? }], heading?, variant? }.

Variant Picks if Translation
features-3 (image-top cards) services[].image present AND (Phase 3) image.area > 50000 items[].image: { src: services[i].image, alt: services[i].title }; items[].href, title, description direct
features-1 (icon-tile cards) services[].image absent OR (Phase 3) image.isIcon true items[].icon from a default mapping or services[i].icon (Phase 3)
services-1 (image-grid with tagline) services.length >= 6 and all have images as features-3 but mapped to services-1's prop shape

Phase 1 default: if image is present, pick features-3; if not, pick features-1 with a default 'circle-check' icon. This works for the pilots; refines in Phase 3.

InfoCards uses the same picker but matcher emits cards: [{ title, text }] — no images, no icons. Always picks features-1 with default icons. Phase 3 should add icon extraction to InfoCards.js.

Signals needed (NEW — Phase 3): services[].image.width, image.height, image.area, image.isIcon (heuristic: small area + transparent bg + few colors). And for InfoCards: cards[].icon (extracted from preceding <svg> or icon-shaped <img>).

FAQ

Always faqs-1. Single variant covers the pattern. Translation is direct: matcher's { items: [{ question, answer }] } → fulldev's { items: [{ title: question, description: answer }] }.

Testimonials / Reviews

Matcher emits: { heading, reviews: [{ name, text, date?, rating? }] } — note: no role field (the ACM pilot had role; matcher doesn't produce it).

Always reviews-1. Translation: - matcher reviews[i] → fulldev items[i] = { title: shortTitle(reviews[i].text), description: reviews[i].text, rating: reviews[i].rating ?? 5, item: { title: reviews[i].name, description: reviews[i].date ?? '' } } - shortTitle() = first 6–8 words of text + ellipsis - description field on item carries the date (or empty); if Testimonials.js later emits a role, prefer that.

CTAStrip

Always cta-wcp (our custom variant). fulldev's CTAs all force a testimonial item; cta-wcp is the plain version. Translation is direct.

LogoStrip

logos-1, logos-2, or logos-3 based on count + sizing. To be detailed when first encountered.

TopBar

Always topbar-wcp (our custom variant). Signals: phone, email, location, socials[]. fulldev's banner-1 is too narrow for this.

Matcher emits: { images: [{src, alt, caption?}], heading?, subtext?, cta? }. Always gallery-wcp (our custom variant). Translation: rename imagesitems.

VideoGallery

Matcher emits: { heading, videos }. fulldev has no equivalent. Deferred custom variant: video-gallery-wcp.astro. 1% of portfolio, not blocking.

LocationMap

Matcher emits: { heading, mapSrc, address }. fulldev has no equivalent. Deferred custom variant: map-wcp.astro — minimal iframe wrapper. <1% of portfolio.

FloatingSocial

Matcher emits: { items, position }. fulldev has no equivalent. Deferred custom variant: port from existing components/FloatingSocial.astro. 3% of portfolio.

ContactForm

Always contact-1. Translation: matcher's form fields → fulldev's inputs[]. Form submit goes to a per-site backend (Cloudflare Worker or similar) — out of scope for the assembler itself.

Always header-1. Translation: matcher's nav → fulldev's menus[] (grouping logic: nav items with shared URL prefix become a dropdown, e.g. /farm-painting, /farm-cleaning → "Farm" dropdown).

Signal needed (NEW): the matcher should emit nav grouping hints, or the assembler can group by URL prefix as a heuristic.

Always footer-1. Translation: matcher's footer columns → fulldev's menus[].

Custom variant routing summary

Pattern Default Custom variant Status
Hero hero-3 \| hero-4 (fulldev) hero-4 ships with our scrim patch done
About content-1 (fulldev) n/a
Features (ServiceGrid/InfoCards) features-1 \| features-3 (fulldev) n/a
FAQ faqs-1 (fulldev) n/a
Testimonials reviews-1 (fulldev) n/a
CTA cta-wcp always done
Gallery gallery-wcp always done
TopBar topbar-wcp always done
ContactForm contact-1 (fulldev) n/a
LogoStrip logos-1 \| logos-2 \| logos-3 (fulldev) n/a
Header header-1 (fulldev) n/a
Footer footer-1 (fulldev) n/a
VideoGallery video-gallery-wcp deferred (1%)
LocationMap map-wcp deferred (<1%)
FloatingSocial floating-social-wcp (port) deferred (3%)

Three custom variants exist; three deferred. When a deferred pattern first appears in a real site, port from components/ and add to lib/components-v3/blocks/.

Theme-token emission

theme-extractor.js produces theme.json with palette + fonts. The assembler converts that to src/styles/global.css:

:root {
  --primary: <theme.colors.primary in oklch>;
  --primary-foreground: <derived contrasting color>;
  --secondary: <theme.colors.secondary>;
  --accent: <theme.colors.accent>;
  --background: oklch(1 0 0);
  --foreground: <derived from theme.colors.text>;
  ...

  --font-sans: <theme.fonts.body>, ui-sans-serif, system-ui, sans-serif;
  --font-heading: <theme.fonts.heading>, <theme.fonts.body>, sans-serif;

  --section-py: <derived from layout density>;
}

Color conversion: hex → oklch via a small conversion function. fulldev primitives expect oklch tokens. Implement in lib/components-v3/utils/color.js.

Font emission: import '@fontsource/<font>/400.css' etc. for any recognised Google Font. Falls back to system fonts if the font is a Wix private webfont (cannot ship per feedback_no_wix_css_copy.md).

Visual variation props (per feedback_visual_variation_as_props.md): - scrimIntensity for hero — driven by image luminance - density — driven by theme-extractor.js layout signals (--section-py value) - These become fulldev block props passed by the assembler

Scaffolding strategy

Option A — full project per site. Each site has its own package.json, node_modules, dist. Used by the pilots so far. Simplest but heaviest on disk.

Option B — shared scaffold + per-site src/. A single pilots/_shared/ project with shared node_modules, and per-site directories that only contain src/pages/index.astro, src/data/content.ts, src/styles/global.css. Building each site cd's into _shared/ with a --config flag pointing at the per-site sources. Less disk, faster builds, but more complex.

Recommend A initially, switch to B if disk becomes a problem at 100+ sites.

Implementation phasing

To avoid building 1500 LOC of speculation:

Phase 1 — single-site reproduction. Make the assembler produce the exact files that match pilots/wcp-fulldev/ from the matcher output. WCP is already in the bespoke pipeline; if node lib/assembler-fulldev.js waterfordcountypainters.ie homepage produces a deploy that visually matches master.wcp-fulldev.pages.dev, the basic path works.

Phase 2 — add the next 4 sites. Reproduce DP, BDA, ACM, trimtech. Different verticals, different palettes, different variant picks.

Phase 3 — variant-picker robustness. Run on 20 random sites from the survey, fix the variants that get picked wrong. This is where the visualSignals additions to matchers happen.

Phase 4 — batch processing. Add CLI to process N sites at a time, drive Cloudflare Pages deploy, generate a side-by-side report.

Matcher changes required (Phase 3)

The variant-picker needs signals the matchers don't currently emit:

  1. lib/matchers/ServiceGrid.js and InfoCards.js:
  2. Emit items[].image.width, items[].image.height, items[].image.area
  3. Emit items[].image.isIcon (heuristic from dimensions + transparent background)

  4. lib/matchers/Hero.js:

  5. Emit imageLuminance (0–1) for any hero image — used for scrim intensity default

  6. lib/matchers/Header.js:

  7. Emit nav grouping hints (or assembler infers from URL prefix)

These are additive — matchers already produce most of what we need.

Open questions

  1. Matchers that produce undefined / partial output. Today some matchers may return undefined for certain props (e.g. Hero.js may skip backgroundImage when computedLayout is missing). The assembler needs deterministic fallbacks. Probably an assertProps step that validates required fields and degrades gracefully.

  2. Multi-page sites. Phase 1 produces multiple <slug>.body.html files per site (one per page). The assembler needs to run per-page and emit one <slug>.astro per page. Header/Footer/TopBar are shared across pages — should they be in a layout component or re-emitted per page? Recommend a single BaseLayout.astro that reads from content.ts, header/footer wired once.

  3. Forks-per-site rate beyond 5 sites. ADR-0001 said revisit if

    20% need a fork. Currently 0/sites 2–4. Worth a check after Phase 3 against 20 random sites.

  4. Production/SSR vs. static build. Astrowind required SSR for some features; fulldev is fully static. Confirm this still holds for our pages. If yes, deploy is a simple npx wrangler pages deploy dist.

  5. Image hosting. Currently pilots reference wixstatic.com URLs directly. Phase 1 already downloads images to public/assets/; the assembler should rewrite all URLs to local paths and pass through Astro's image optimisation.

Pre-implementation checklist

  • Walk one full matcher output and confirm the variant-picker table covers every section type. Done 2026-05-03 — six corrections folded into this ADR.
  • Confirm theme.json for one site has the data we need to populate global.css cleanly. Patch theme-extractor.js if not. (Phase 1c)
  • Decide: emit content.ts (typed) or inline props in index.astro? content.ts — easier to diff, easier for human edits, easier for EmDash adapter.
  • Decide: split About paragraphs in matcher or assembler? Assembler — split on \n\n, no matcher rewrite needed.
  • Stub lib/components-v3/blocks/ with the 3 custom variants copied in from pilots/trimtech-fulldev/src/components/blocks/. (Phase 1b)

References

  • ADR-0001 (component library foundation)
  • ADR-0002 (primitive neutrality)
  • Pilots: pilots/{wcp,dp,bda,acm,trimtech}-fulldev/
  • Matchers: lib/matchers/
  • Existing assembler: lib/assembler-v3.js
  • Theme extraction: lib/theme-extractor.js