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:
- Hero matcher emits
backgroundImage(full-bleed) andbackgroundImages(slideshow) — never animagefor side layouts. Hero-1 (text + side image) cannot fire from current matcher signals. - Hero-2 (split with stats) trigger condition was invented —
matcher's
contentPosition∈ {top|center|bottom|below}, no'split'. Dropped from the variant table. - About emits
text(single string), notparagraphs[]. Assembler will split on\n\nboundaries — easier than a matcher rewrite, no info lost. - About emits
image: <url string>+ separateimageAlt. Assembler wraps into{ src, alt }for fulldev's content-1. - Hero
cta[]andphone[]are separate arrays. Assembler merges intolinks[], phone first. - 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:
- Section list from
lib/structure-matcher.js:propsis whatever the matcher'sextract()function returned. theme.jsonfromlib/theme-extractor.js— palette + fonts + dimensions.compLayout+computedLayout+siteLayout— Wix DOM layout signals.- 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)~~ |
— | dropped — contentPosition 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:
(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.
Gallery¶
Matcher emits: { images: [{src, alt, caption?}], heading?, subtext?, cta? }. Always gallery-wcp (our custom variant). Translation: rename images → items.
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.
Header¶
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.
Footer¶
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:
lib/matchers/ServiceGrid.jsandInfoCards.js:- Emit
items[].image.width,items[].image.height,items[].image.area -
Emit
items[].image.isIcon(heuristic from dimensions + transparent background) -
lib/matchers/Hero.js: -
Emit
imageLuminance(0–1) for any hero image — used for scrim intensity default -
lib/matchers/Header.js: - Emit nav grouping hints (or assembler infers from URL prefix)
These are additive — matchers already produce most of what we need.
Open questions¶
-
Matchers that produce undefined / partial output. Today some matchers may return undefined for certain props (e.g.
Hero.jsmay skipbackgroundImagewhencomputedLayoutis missing). The assembler needs deterministic fallbacks. Probably anassertPropsstep that validates required fields and degrades gracefully. -
Multi-page sites. Phase 1 produces multiple
<slug>.body.htmlfiles per site (one per page). The assembler needs to run per-page and emit one<slug>.astroper page. Header/Footer/TopBar are shared across pages — should they be in a layout component or re-emitted per page? Recommend a singleBaseLayout.astrothat reads fromcontent.ts, header/footer wired once. -
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.
-
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. -
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.jsonfor one site has the data we need to populateglobal.csscleanly. Patchtheme-extractor.jsif not. (Phase 1c) - Decide: emit
content.ts(typed) or inline props inindex.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 frompilots/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