Skip to main content

Deterministic Converter — Internals & Fragility Trace

This is the engineering trace for the deterministic (offline PHP) converter — the internals, the load-bearing heuristics, and what is fragile. It is deliberately more low-level than Conversion Architecture (which is the pipeline overview): this page exists so anyone changing class-fw-site-converter-stitch.php / -mapper.php has a map of what's already there and a register of the brittle parts before they touch a load-bearing path.

Living document

This page is kept in sync as the converter changes. The Change Log at the bottom records each structural change; the Fragility Register is updated whenever a heuristic is hardened, reverted, or replaced. Line numbers are anchors at time of writing — treat them as "search near here", not absolutes.

Files (in unysonplus/framework/extensions/site-converter/includes/):

  • class-fw-site-converter-stitch.php — the DOM→blocks recognizer engine (FW_Site_Converter_Stitch, ~18.4k lines).
  • class-fw-site-converter-mapper.php — turns recognized blocks into builder JSON nodes (rows/columns/shortcodes).
  • class-fw-site-converter-bundle.php — the import orchestrator (import_dir → reconvert → build).
  • class-fw-site-converter-sources.php — hosts the top-level build_from_html.

Pipeline — entry points​

import_dir (bundle.php:98)
→ maybe_reconvert_with_php (bundle.php:488)
→ [optional] Stitch::set_ai_structure(...) (bundle.php:565) — AI verdicts, advisory only
→ Sources::build_from_html(...) (bundle.php:576)
→ Stitch::html_to_mapping($html,…) (stitch.php:8658) ← DOM→sections entry
→ section_roots / walk_section_roots (stitch.php:9608 / 9680) — claim each section
→ collect_blocks($node,$blocks,$rules) (stitch.php:13197) ← per-child dispatcher

html_to_mapping gets the section roots, strips header/nav/footer as chrome (handled by the generated child theme, not page content), and for each root either treats it as a layout row (section_root_row, stitch.php:12740) or runs collect_blocks to decompose its children.

walk_section_roots (stitch.php:9680) is a pre-order DFS that claims every <section> and each hero <header> (no descent into a claimed node), skips <footer>/<nav>, and for a section-less <main>/<div> hero splits it into content bands via segment_bands (stitch.php:9704).

The recognizer registry​

Registration: register_recognizer($id, $priority, $match, $build) (stitch.php:9794); built-ins in register_builtin_recognizers() (stitch.php:9934). There is no register_builder in Stitch — the build callback is the 4th arg.

Dispatch (recognizers(), stitch.php:9800): built-ins are registered lazily, then sorted priority-descending. collect_blocks iterates in that order and the first match() that returns true wins — its build() runs and the loop breaks. A recognizer claims the whole subtree (the walker does not descend into a claimed node). build() may return one {t,role,…} block, a list of blocks, or null (claimed, emits nothing). Reveal/scroll animation intent (anim_intent) and text-split intent (text_split_intent) are stamped onto emitted blocks.

Registered recognizers (priority ↓)​

PriidMatchesBuilds
99pricing_table≥2 plan columns w/ price token + namepricing_table shortcode
98posts_gridblog-card grid (heading + time/byline/read-more)dynamic posts
98stepsnumbered process flowsteps
97absolute_collagerelative/min-h stage w/ ≥2 abs-positioned content cards{t:stage} + per-card blocks/skin/offsets
97timelinedated chronological entriestimeline
96decorative_scenearia-hidden/parallax stack of ≥2 imgs, no copyverbatim .sc-tw code
96progress≥2 labelled % barsprogress
95marquee_stripauto-scroll/overflow-x reelAE marquee OR verbatim code
95tabstablist bound to panelstabs
94instagram_feedgrid of IG postsinstagram
94lottielottie-player / .json srclottie
93testimonial_singlesingle quote cardtestimonial
93inline_linksinline <a> + separator stripone centered text_block
93svg_drawstroke-animated <svg>svg_draw
92testimonialsgrid of quote cardstestimonials
91image_griduniform image gridimage-grid row
91counter_gridgrid of big-number + label cellsanimated counter cells
90card_gridgrid/flex of uniform cards (Tailwind){t:row,role:columns} (icon_box/text/code cells)
89accordion≥2 <details>/aria-expanded pairsaccordion
88newsletter<form> w/ email inputnewsletter
88table<table> w/ rowstable
85card_grid_cscard grid via computed styles (non-Tailwind)columns row
84avatar_groupoverlapping round-avatar stackavatar group + rating
84icon_text_listdiv of icon + short-label rowsfeature_list
82heading_cta_rowheading + "View All →" in justify-betweeninline space-between block
82layout_rowmulti-col band that isn't a card grid (text | image hero)columns row — cells decomposed OR verbatim .sc-tw
80headingh1–h6{t:heading}
79text_liststandalone <ul>/<ol> ≥2 items (non-nav)feature_list
77stat_numberbig-display or baseline-flex stat lockup{t:heading,level:3}
76badgestructured hero chipbadge
75badge_verbatimunstructurable chipverbatim .sc-tw code
70pilloverline pill{t:text,role:overline}
60buttonbutton / CTA linkbutton_block
55button_csbutton via computed stylesbutton_block
50paragraph<p>{t:text}
45video<video> or provider <iframe>media_video (+ bg-flag logic)
40image<img>{t:image} + skinCss
35image_wrapperwrapper of a lone imageimage only
30image_overlayimage w/ overlaid UIdecompose → media_image + icon_box, else verbatim
25logo_stripstrip of logos, no headingslogo_grid

call_to_action (pri 81) is intentionally unregistered/disabled (stitch.php:10286) — its is_cta_band/cta_build helpers stay dormant.

The structural build path — and the current gap​

Multi-column layout is not handled by the generic fallback; it is handled by specific recognizers: image_grid(91), counter_grid(91), card_grid(90), card_grid_cs(85), and the general layout_row(82). A section whose children form a grid/flex row that matches one of these gets real columns. Everything else falls through.

The generic fallback (collect_blocks, stitch.php:13274–13325)​

When no recognizer claims a node, the fallback does not reconstruct layout — it descent-flattens: it recurses into the wrapper and re-tests its children against the recognizers, carrying only a fixed set of the wrapper's own signals onto the leaf blocks it produced:

  • vertical margin → mtAdd/mbAdd (stitch.php:13280);
  • inherited text-align + max-w … mx-auto measure → align/wrapMaxW (stitch.php:13301);
  • data-aos reveal intent → anim (stitch.php:13316).

If the subtree produced nothing, salvage_dropped (stitch.php:13342) rescues a real content <img>/own text or records the node as decorative in the drop log. There is no generic single-child wrapper collapse (no "fold this meaningless wrapper's bg/border/padding onto its only child").

layout_row already recurses — but keeps media-only cells verbatim​

layout_row(82) → layout_cols (stitch.php:12876) is the key finding. It already:

  • computes per-column widths from col-span-N, arbitrary w-[Npx] (layout_px_fractions, stitch.php:12850), or even division;
  • decomposes a content cell (heading/prose) by re-entering collect_blocks on it (stitch.php:12920) — so a hero's text column becomes special_heading + text + button, not one opaque code block;
  • decomposes an image composite (photo + floating badge) into media_image + icon_box (stitch.php:12939).

The gap: a media-only cell — a column whose sole content is a <video> / lone <img> / SVG with no heading and no real prose — fails the cell_is_decomposable gate and falls to the verbatim .sc-tw code block path (stitch.php:12947–12950). That is why a two-column hero like a 2-column-grid hero capture / a 2-column-grid hero capture (text left, shaped video right) renders its video column as a code_block(<video>) instead of a proper, editable media_video carrying its mask/shape as scoped CSS. Closing this is the current work (see Change Log).

Display / structure detectors already present​

Helperstitch.phpReadsOutput
section_is_multicol_grid($el)9008grid-cols-[2-9] class or computed display:grid + ≥2 tracksbool
grid_col_count($grid)18012widest grid-cols-N → computed grid-template-columns → card-cell fallbackint cols
is_layout_row($el)12774Tailwind grid / computed grid tracks / desktop flex-rowbool
detect_section_bg_video($node)9026bails on multi-col grid; else inset/cover/fullscreen signalsbg-video descriptor or []
is_absolute_collage($el)9816relative/min-h stage + ≥2 abs-positioned cardsbool (+ collage_offsets 9846)
is_content_band / is_decor_layer9758 / 9748heading/media/text presence vs absolute + short textbool

Computed-style accessor: sc_css($el,$prop) (stitch.php:4966) reads one property from the captured data-sc-cs attribute (e.g. sc_css($node,'display'), 'grid-template-columns', 'flex-direction'). data-sc-cs is a whitelisted subset of computed props — some captures omit position/z-index. There is no reusable "full {prop=>val} map" method yet (a local closure exists at stitch.php:2083).

structure_summary($html) (stitch.php:9495) is the compact DOM view fed to the AI classifier — per section: index, sig, heading, dollars[], hasPeriod, hasFeatureList, videos[{rounded,inColumn,cover,bleed}], bands. AI verdicts are advisory and only correct two ambiguous calls (pricing-vs-stats, video background-vs-content).

Mapper builder API​

There are two row models. A recursive layout walker should target the hybrid flexbox one (it represents flex direction / wrap / grid-N faithfully and nests arbitrarily).

Classic (section → columns; no row node type):

  • n_section($cls,$id,$css,$items,$fullwidth) (mapper.php:1880) — $items are column nodes.
  • n_column($width,$items,$cls='',$resp=[]) (mapper.php:1968) — $width is a fraction slug (1_1, 1_2, 1_3, 5_12…). Columns nest columns via _items (no wrapper row).

Hybrid "Div" (the real flex/grid primitive):

  • n_flexbox($items,$over=[]) (mapper.php:2006) — display (flex/grid/block), direction {base,md,lg}, gap, justify_content, align_items, wrap, grid_columns; per-cell width preset, flex_grow, align_self, order. Nests n_flexbox inside n_flexbox (see the stage builder, mapper.php:8807).

Fraction mapping: frac12($n) (mapper.php:2567) — 12-grid span → slug (4→1_3, 6→1_2, 8→2_3, 12→1_1). Inverse slug_to_span (mapper.php:2120). Flexbox uses a different vocabulary: flex_width_preset($n) (mapper.php:2062) → plain twelfth string ('4'=⅓). A grid whose N does not divide 12 (5, 7…) switches the row to display:grid + grid_columns=N (mapper.php:9886).

register_builder roles (mapper.php:8649 — this API does exist in the Mapper): heading, text, button, code (universal fallback → structural flexbox mirror or code_block), video, image, badge, avatar, newsletter, floating_card, stage (full-width absolute-collage).

Leaf shortcodes (all {type:'simple',shortcode:'…'}): n_text→text_block, n_heading→special_heading, n_media_image→media_image, n_video→media_video, n_icon_box→icon_box, n_button→button, n_code→code_block, plus counter, feature_list, accordion, table, gallery, posts, testimonials, divider.

Never-dropped CSS: apply_hifi_base($node,$cs,$already) (mapper.php:1009) appends a specificity-0 :where(selector){…} rule of every unmapped computed property to the node's custom_css (the selector token is rewritten to the node's scoped class at render). Page-level CSS with no per-node home goes to main_style → #main (mapper.php:8852). Box skins: apply_card_box / apply_card_box_inner (mapper.php:1931 / 1952), or the preferred portable path register_box_preset → boxp-<slug> (mapper.php:172).

Structural Pattern Catalog — AI generators​

Premise (validated by a corpus audit, 2026-09-09): AI website builders are template engines, so their output is formulaic. The same section skeleton repeats within a generator and many archetypes are universal across generators. Therefore every converter fix must key off a structural PATTERN, not the site in front of you — a fix that recognises "a section whose backdrop is a viewport-covering <video>" covers the primary capture and every future hero-video site; a fix that special-cases the primary capture is a band-aid. Corpus sampled: Generator A (the primary capture, a second capture), Generator B ×4, Generator C ×6, Generator D, Generator E ×4.

What is UNIVERSAL vs GENERATOR-SPECIFIC​

PatternFamiliesDeterministic DOM signatureVerdict
Semantic band = <section> (often id=)Generator A, Generator B, Generator C, Generator D<section> stamp padding:*px 0px/py-*UNIVERSAL (Generator E excepted)
Full-bleed backdrop = positioned wrapper holding a lone object-cover <img>/<video>Generator A, Generator B, Generator C, Generator Dchild is absolute inset-0/computed position:absolute + lone object-cover media, stamp border-radius:0px and NO transform, text-free; sibling gradient overlayUNIVERSAL — key recogniser to build by GEOMETRY not name
Card / feature gridGenerator A, Generator B, Generator C, Generator Dcontainer computed display:grid + grid-cols-NUNIVERSAL (handled: card_grid/card_grid_cs)
Decorative absolute layer (NOT a background)Generator A, Generator C, Generator B`position:absolutefixed + blob/rounded-/blur-/rotate-*/transform:matrixorborder-radius:50%` orb, empty/short
Single desktop nav; mobile menu is React state (not in DOM)Generator B, Generator C, Generator Done `<nav class="hidden (mdlg):flex">+ a(md
Split/duplicated desktop nav in DOMGenerator C (a split-nav variant)two <nav class="hidden md:flex … flex-1"> in one <header>GENERATOR-SPECIFIC
Colours are oklch()/oklab()Generator A onlystamp oklch(L C H / A) fillsGENERATOR-SPECIFIC (all others resolve to rgb(); hsl() only in the stylesheet). Confirms the oklch normalisers in norm_box_color/rgb_triplet/build_box_presets $norm are an Generator A need, harmless elsewhere.
Bespoke canvas/parallax art pageGenerator Ecustom .par/mask classes, <canvas>, transform:matrix3d; no <section>/<ul>/<form>GENERATOR-SPECIFIC — treat as non-mappable

Generator A skeleton (the target generator)​

Delivery: the real site is the /api/preview?category=&slug= document; an Generator A /preview/... page is just a Next.js shell that iframes that endpoint (capture the /api/preview URL). Ordered sections the generator emits, with the vocabulary the recogniser can key on:

  1. Fixed decorative stack (body-level, pre-nav): .preloader · .grain-overlay (SVG-noise data-URI, position:fixed) · .ambient-orbs → N× .amb-orb (border-radius:50%, position:absolute, transform:matrix) · .cursor/.cursor-trail. → drop.
  2. <nav id="mainNav"> — fixed flex bar: .nav-logo + one <ul class="nav-links"> (the ONLY real menu) + one .btn-liquid CTA ("Inquire"). Source labels are correct (Philosophy/Pillars/Projects/Contact).
  3. hero (.hero, display:flex;center;height:100vh) — .video-portal (position:absolute, border-radius:0, lone <video object-fit:cover>) = the true full-bleed background; .atmospheric-mask (blob radius + transform:matrix + a second copy of the video) = decorative lens; .light-corridor gradient wash; .hero-content (eyebrow / <h1> with <em> / subtitle / .btn-liquid+.btn-ghost).
  4. manifesto — 2-col grid (text | .manifesto-metrics = 4× .metric-orb blob cards) + .ecology-ticker marquee.
  5. pillars — grid repeat(4,1fr) of .pillar-card blob cards + .data-strip/.data-row (repeat(5,1fr)) stats.
  6. projects — .projects-track flex carousel of .project-card; each card's "image" is a background-image:radial-gradient(oklch…) on .project-bg (absolute inset-0), not real media.
  7. philosophy — grid 2fr 3fr: position:sticky left column + numbered .phil-line-item timeline.
  8. contact-section — a REAL .contact-form (display:grid) with 3 .input-shell (text/email/textarea)
    • submit .btn-liquid — NOT a lone CTA button.
  9. footer — logo + copy + .footer-links. .section-divider (radial-gradient hairline) sits between most bands.

Signature idioms: (a) the Generator A blob shape — a 4-value slash border-radius: X% X% X% X% / X% … — marks buttons, metric-orbs, pillar-cards, project-cards AND the decorative lens (a deterministic fingerprint); (b) text is shattered into per-word <span class="breeze-word"> (a JS animation artifact) — treat these spans as transparent and reassemble their text WITH the inter-span spaces/separators.

Confirmed defect roots (the primary capture, all pattern-level)​

  1. Hero backdrop video → two inline tiles. detect_section_bg_video (stitch.php:9647) requires an inset-0/w-full h-full/bg-name class OR self-absolute position, and its name test excludes portal. Generator A's backdrop is .video-portal with only computed position:absolute (inset via stylesheet, not the stamp) → no bleed signal fires → falls to inline tiles. Fix must be structural (the name exclusion was already tried and reverted — see Fragility Register): promote an absolute/fixed, text-free, lone autoplay muted video wrapper with border-radius≈0 and no transform to the section background, regardless of class name; keep the multicol-grid gate and the page-fixed gate. (Capture enrichment — stamp object-fit
    • a rect-coverage flag — would make it bulletproof; capture.mjs is Session B's lane.)
  2. Duplicated / mislabeled nav — FIXED (menu accumulation). Not a detection bug: 46 stale converter menus had accumulated and the last-converted site (another site) held the primary location. Fixed by tagging converter menus (FW_Site_Converter_Menus::MENU_META) and purging the previous conversion's menus in cleanup_previous_conversion (see Change Log). Residual (header lane): the primary capture's own menu still includes the LOGO anchor + the INQUIRE CTA as items (source nav has 6 anchors) — exclude logo + trailing CTA.
  3. Mashed eyebrow (WORDTWOWORDTHREE WORDFOURWORDFIVE (words + separators run together)) — .breeze-word per-word spans reassembled without the inter-span whitespace and · separators. Pattern fix: when joining inline animation-span text, preserve a single separating space and any separator glyph.
  4. Selected Works dark-on-dark titles — the project cards' "images" are radial-gradient placeholders on .project-bg; the card renders dark and the title text is also dark. Contrast handling, not a dropped image.

Prioritized pattern-based fix plan​

#Pattern fixWhereLaneStatus
P1Structural full-bleed backdrop media recogniser (geometry, not name) + same-src duplicate-parallax dropstitch.php detect_section_bg_video PASS 1ADONE + verified (site-converter 1.8.69; corpus +1, 0 regressions)
P2Animation-span (.breeze-word) split-text reassemblystitch.php collapse_word_split_spans pre-passADONE + verified (1.8.69)
P3Reconversion purges previous conversion's nav menusmenus.php + bundle.phpADONE + verified (1.8.68)
P4Multi-field contact form (<div class="contact-form"> / <form>) → newsletter shortcode, not a lone buttonstitch.php is_newsletter_formADONE + verified (1.8.70; structure unchanged, contrast net-0)
P5Capture stamp enrichment: object-fit + rect-coverage flag for positioned media (makes P1 bulletproof)capture.mjsBproposed to B
P6Header/menu excludes the logo anchor + trailing CTA buttonstitch.php header/menu detectionBflagged to B
P7Dark timeline/project cards keep their fill + light title (contrast)stitch detect_timeline_design + mapper n_timelineADONE + verified (1.8.71)

Fragility Register​

The parts most likely to break site-to-site or bite a future change. Read this before touching a load-bearing path.

Load-bearing class-name heuristics (brittle by nature)​

Wherestitch.phpRisk
detect_section_bg_video name sniffs~9032Author explicitly flagged *-portal name matching as brittle (it hit legit full-bleed videos); now gated on grid structure (section_is_multicol_grid) instead — keep it structural, don't reintroduce name sniffs.
is_decor_layer9748absolute/fixed + text-length heuristic — a content overlay with little text can be mis-dropped.
section_center flex-col+items-center string tests~8846class-substring matching; computed flex-direction/justify would be sturdier.
is_absolute_collage relative / min-h-[ sniff9816class-name signal for the stage; a computed position:relative+min-height read is the fallback but the class path fires first.
pervasive object-cover/inset-0/w-full h-full/absolute matchingvideo + bg detectorsthe whole full-bleed-vs-contained decision leans on these tokens; non-Tailwind sources miss them.

Instrumentation the author left to find where these drop look-carrying classes: class_is_significant (stitch.php:13427), build_class_coverage_report (13449), build_drop_report (13394), conv_debug_record / conv_dropped_diff (mapper.php:257/279).

Reverted experiments (do not re-attempt without reading the note)​

  • Routing a layout_row cell through section_root_row — tried and reverted (08-render-audit §8.34): claiming a cell as a row made a photo-grid capture 2,255px shorter but dropped two images including the hero background photo (16→12 images). The height "gain" was content loss. The note lives inline at stitch.php:12916. The section-root row case (§8.30) is unaffected and stays.
  • Portal/shell name-based exclusion of bg videos — regressed a 2-column-grid hero capture (bgv 0) because portal was too broad and a bespoke capture uses [mask-image]. Replaced with the section_is_multicol_grid structural gate.
  • AI writing a whole stylesheet — made the two engines conflict (both producing CSS). Scoped the AI back to mapping-only; the deterministic engine authors all CSS.

Known limitations (accepted, tracked)​

  • Offline .sc-tw Tailwind reproducer is incomplete — missing h-* / object-fit / aspect / arbitrary values, so the upload path deliberately does not mirror the JS preferVerbatim guard (note at stitch.php:8756). Verbatim cells are lower fidelity offline than through the capture service.
  • dedupe_repeated_app (stitch.php:9624) is a workaround for captures that serialize the SPA multiple times — the real fix belongs in the capture, not here.
  • WebGL/#gl scenes with no naming hint are deliberately not guessed (stitch.php:9418) — left to the AI tier.
  • grid-cols-1 with no wider responsive override must be rejected as a stack, else grid_col_count's card-cell fallback over-claims columns (hardening note at stitch.php:12790).
  • Page-wide FIXED VIDEO backdrops (a single position:fixed full-viewport <video> behind every section, content scrolling over it — a fixed-video capture's div.video-portal). UnysonPlus backgrounds are per-section, and the Background-Pro video layer has no fixed mode (only IMAGE has attachment: fixed → background-attachment:fixed; CSS can't pin a <video>). Today detect_section_bg_video treated position:fixed like absolute and promoted the video to the FIRST section's (scrolling) background. Resolved for the full-viewport case (Option A, shipped): a full-viewport position:fixed video is detected (detect_page_fixed_video) and routed to the Site Background's fixed video layer, rendered once by the theme, with a deferral gate (el_is_page_fixed_layer) so the section detector no longer claims it. Still open (case B): a fixed FLOATING video portal (positioned/sized/masked, not full-viewport — a fixed-video capture) needs a fixed-positioned media_video preserving its geometry + mask (see Change Log).

Scorer blind-spots corrected (context for regressions)​

The kit scorer (score.mjs + import-site.php) historically scored 100 while whole photo bands went missing, and only ever flagged a missing backdrop (never a wrongly-added one). Current guards: srcImages media-retention denominator; wrongBg (a hero bg video that is actually a rounded content panel); srcHeroVideo gated by $is_grid2 so a 2-column grid hero is never scored as a missed backdrop.

Real-Chrome render + lazy images (2026-09-10). When the harness moved to REAL Chrome (so H.264 hero videos decode instead of reading as black — the channel:'chrome' fix), a latent scorer gap surfaced: real Chrome STRICTLY honours loading="lazy", but score.mjs measured after a fixed ~1.3s settle without scrolling, so below-the-fold lazy <img>s never loaded and media_retention FALSE-dropped to 0 (a lazy-image capture: 4 lazy section images, 0 loaded at 1.3s, 3/4 loaded after one scroll pass — the bundled Chromium the baseline was first captured with had loaded them eagerly, hiding the gap). Fix: score.mjs now scrolls the page top-to-bottom-and-back to trigger lazy images before measure(). Lesson for any renderer swap: re-save the baseline under the new engine — a diff across two different render engines conflates converter changes with rendering-behaviour changes (this produced one false media_retention −100 in the P1–P7 corpus run that was a pure scorer artifact, not a converter regression).

Change Log​

Newest first. Each entry = one structural change to the deterministic converter.

  • 2026-09-10 — Full-bleed SPLIT HERO spans the viewport, not the global container (site-converter 1.8.75). A full-VIEWPORT section (min-h-screen/h-screen/min-height:100vh) laid out as a MULTI-COLUMN grid with NO inner content cap (w-full grid grid-cols-2) is meant to span the viewport — its columns are true viewport fractions (~50vw text + ~50vw media). The converter was boxing it to the theme's global container (~1280px), so each column shrank to ~624px and a large FLUID heading (e.g. a 144px Oswald title correctly reproduced) needed ~756px and OVERFLOWED its column, spilling across the midline and OVERLAPPING the media column (the hero title-overlaps-image bug). Root cause was two-layered: map_container_width(0) → Inherit → global container; AND the mapper's flexbox content-width push fell through container_width_px(custom 100%)=0 to the SITE container width and pinned the flexbox at ~1248px. Fix: is_fullbleed_split_hero($node) (stitch) detects the pattern (gated on min-h-screen + section_is_multicol_grid + no inner cap, so a normal w-full content section — nearly every Tailwind section is w-full — or a single-column centred hero is untouched); the section then gets is_fullwidth=true + a 100% container_width, and the mapper pushes a 100% content_width onto its flexbox children instead of the site container. Verified: the text column goes 624→936px, the title fits (right edge 780 inside its 936 column, no overflow) and the media sits cleanly beside it; 17-site hero-heavy regression unchanged vs baseline — container/structure/spacing/height_sanity all 100, no per-site change ≥3.
  • 2026-09-09 — P7: dark timeline/project cards keep their fill + light title (site-converter 1.8.71). Generator A's Selected Works is a carousel of dark .project-cards (fill oklch(0.15), near-white title). It maps to the timeline shortcode, which defaults to a LIGHT card — so on the converted dark-theme site the near-white heading colour landed on a white card and the titles rendered white-on-white (invisible; the descriptions, on the shortcode's dark default text colour, stayed readable — hence only the titles vanished). detect_timeline_design now reads the FIRST entry card's own computed background-color (descending one boxed level if the entry wrapper is bare); when it is a real DARK solid fill it carries card_bg plus the entry heading's title_color and first paragraph's text_color (only the LIGHT ones), and n_timeline emits them into the timeline Styling group as compact {predefined,custom} colours. Gated on a dark card fill, so an ordinary light-card timeline is untouched (no default change) — e.g. the primary capture's OWN .phil-line-item philosophy timeline (transparent entries) is unaffected. Verified: the four Selected Works cards render dark with legible near-white titles (The Verdant Tower / Dune Meridian / Aquifer House / The Hollow City); 19-site regression unchanged vs baseline (structure 100, no per-site change ≥3).
  • 2026-09-09 — P4: non-<form> contact forms recognised (site-converter 1.8.70). AI builders (Generator A) build the contact block as a <div class="contact-form"> holding <input>/<textarea> + a submit <button>, with NO <form> wrapper. is_newsletter_form required $tag === 'form', so the whole block was dropped and only its submit BUTTON kept (the primary capture's contact section rendered as a lone "Transmit Inquiry" button). It now also accepts a NON-form container that is FORM-NAMED (contact-form/signup-form/… class) or holds ≥2 controls together with a submit control (login/password + search still rejected; a textarea-only message box counts). The existing newsletter_build (already container-agnostic) then carries name/email placeholders, show_name, and the submit label/colours. The block maps to the always-available core newsletter shortcode — chosen over the forms/contact-forms contact_form (a form-builder value shape that is fragile to emit and whose extension isn't active by default). Limitation: newsletter has no free-text field, so the message <textarea> is dropped (name + email + button render — a large improvement over a dropped form; upgrading to contact_form once the form-builder value shape is safe is a follow-up). Verified: the primary capture contact renders Your Name + Email Address + Transmit Inquiry; 19-site regression: structure/container/spacing/media/height all 100 (no structural regression), contrast net-0 vs baseline (the small per-site contrast dips faithfully reproduce the source's subtle placeholder-on-dark fields). NOTE for whoever runs the harness: the score harness's import-site.php imports pages WITHOUT import_dir's cleanup_previous_conversion, so nav menus ACCUMULATE across a multi-site regression run and the last site's menu can show on an earlier site — a TEST-HARNESS artifact only (the real admin Convert path runs import_dir and purges correctly, verified). Consider having the harness reset menus between sites.
  • 2026-09-09 — P1: structural (geometry-based) full-bleed backdrop-video recogniser + duplicate-parallax drop (site-converter 1.8.69). Generator A wraps the hero background video in .video-portal — the SAME class it uses for its decorative lens — with only computed position:absolute (inset comes from the stylesheet, not the data-sc-cs stamp) and NO bleed utility class, so detect_section_bg_video's class/name tests never fired and BOTH videos fell through to inline tiles (the primary capture's hero rendered as two black boxes). Because a *-portal NAME exclusion was already tried and reverted (Fragility Register), the fix is STRUCTURAL: PASS 1 now also accepts a $geom_bleed layer — an absolute/fixed, text-free, LONE autoplay muted video wrapper with SQUARE corners (border-radius < 24px, matching the PASS-2 rounded-content threshold) and NO transform — as a backdrop regardless of class name. The blob lens (%-radius + transform:matrix) is excluded by shape; this is the same idiom Generator B/Generator C/Generator D express as absolute inset-0 object-cover (already caught by the class test). A second pass then drops any remaining same-src absolute lone-video layer in the section (Generator A stamps the clip twice for a parallax copy) so the hero shows just the one background. Verified: the primary capture hero renders the full-bleed video with content over it; 19-site video-corpus regression = corpus overall +1 (99→100), every bg-video-sensitive site (a bespoke capture, a capped-width hero capture, the soft-masked-layer capture, a 2-column-grid hero capture, a 2-column-grid hero capture, a fixed-video capture, nox, a shaped-video capture, …) 100 with bgv 100, zero regressions (no per-site change ≥3).
  • 2026-09-09 — P2: de-animate word/char SPLIT-TEXT before mapping (site-converter 1.8.69). Animation builders shatter a heading/eyebrow into per-word <span>s so each word can be tweened (Generator A .breeze-word; GSAP/Framer SplitText .word/.char). Those inline-block spans read to the converter as SEPARATE blocks and stacked vertically (the primary capture's eyebrow rendered one word per line: Word / Word / · / Word …). New collapse_word_split_spans pre-pass (run in html_to_mapping right after load_dom) unwraps them back to text — STRUCTURAL, not name-based: it fires only on a container that DIRECTLY holds ≥3 short (≤24-char) single-token <span>s and NO other element child, so a real mixed line with <em>/<a>/<br> is never touched. The inter-word whitespace and · separators are already text nodes, so they survive → one overline (Word One · Word Two · Word Three). Verified on the primary capture; same 19-site regression stayed at corpus overall 100, 0 regressions (headings/eyebrows across the sample unaffected).
  • 2026-09-09 — Reconversion now purges the previous conversion's NAV MENUS (site-converter 1.8.68). Every conversion builds a "<Title> Header" nav menu and assigns it to the theme's primary location; nothing ever removed the previous site's menu, so a shared install had accumulated 46 menus and the front page (the primary capture) was rendering another site's doubled CHRONICLES/STANCES/LANDSCAPES/ARMORY menu — the last-converted site had quietly stolen the primary slot. This was mis-read as a nav detection bug; it was menu accumulation, the same class of leak as the revision/attachment/child-theme bloat already handled by cleanup_previous_conversion. Fix: FW_Site_Converter_Menus stamps every menu it creates/reuses with term-meta MENU_META (_fw_sc_menu); FW_Site_Converter_Bundle::cleanup_previous_conversion purges all tagged menus on a DIFFERENT-site conversion (via wp_delete_nav_menu, which also clears the stale nav_menu_locations theme_mod), before the current import rebuilds its own — mirroring the deferred prev-theme purge. Same-site reconvert is untouched (menu reused by name). One-time cleanup of the 44 legacy menus done on localhost root; primary rebound to The Line Header — the nav now renders the correct PHILOSOPHY/PILLARS/PROJECTS/CONTACT. (Residual, header lane: the primary capture's own menu still includes the logo anchor + INQUIRE CTA as items — the builder captured all 6 header anchors.)
  • 2026-09-09 — Converted DARK sites rendered WHITE — root-caused + fixed at two layers. A converted dark site stored its dark --site-bg-color correctly, but the rendered body was white. Root cause: the mapper's cs_decls (mapper.php) split a computed-style string on ; with a naive explode(';'), which truncated a background-image:url("data:image/svg+xml;base64,…") data-URI at the ; inside it and mashed the following transform/transition declarations into an unterminated url("… with a dangling (. Once the asset-optimizer combined the stylesheets, that dangling ( made the CSS tokenizer swallow every following {/} — including the theme's :root{--site-bg-color:…} block — so the var was present in the file but dropped by the browser (body fell back to white). Fixed at BOTH layers: (1) generation — new cs_split() (mapper.php) splits declarations ;-safely, never inside quotes or url(...)/calc(...) parens, so the data-URI stays whole; (2) defence — FW_AO_Minifier::close_unbalanced() (asset-optimizer) closes a dangling string/paren/bracket PER FILE before combining, so one malformed source can never corrupt the whole bundle (with a 17-assertion regression test). Verified: a 2-column-grid hero capture body rgb(255,255,255) → rgb(3,6,9), --site-bg-color now #030609. Also: the converter now calls unysonplus_hf_regenerate_css() after a programmatic Theme-Settings import (else the cached generated CSS kept stale defaults), and stops emitting a fragile prose /* … */ comment into generated CSS.
  • 2026-09-09 — Option A: page-wide fixed video → site-level fixed background layer (shipped). For a genuine position:fixed FULL-VIEWPORT video behind all content: (1) Background-Pro's video layer gained a position key (scroll|fixed); (2) the parent theme renders a fixed full-viewport <video> once on wp_body_open — unysonplus_render_site_bg_video (theme layout.php), self-contained inline markup (deliberately NOT via the generated/combined CSS, so it can't be lost to CSS caching); (3) the converter detector detect_page_fixed_video (stitch.php) recognises a full-viewport position:fixed video wrapper and writes it to general_layout/site_background.video (fixed) in the {url,attachment_id} shape so localize_media() sideloads it; (4) a deferral gate in detect_section_bg_video (el_is_page_fixed_layer) so a page-fixed video is never also promoted to a section background. Verified: a full-viewport fixed site-bg video renders as .site-bg-video{position:fixed;inset:0;z-index:-1} with the <video> behind content.
  • 2026-09-09 — Case B: fixed FLOATING video portals (a fixed-video capture) → fixed-positioned media_video (shipped). Not every "fixed video" is a full-viewport backdrop. a fixed-video capture's .video-portal is position:fixed; top:50%; right:5%; width:clamp(280px,35vw,520px); height:clamp(380px,55vh,720px); border-radius; radial mask — a masked, positioned floating video that stays fixed on scroll but is NOT a background. Previously detect_page_bg_video claimed it and made it the first section's full-bleed 100vh background (blowing it up and dropping the mask/geometry). Now fixed_layer_geometry($el) (reading the portal's geometry from the .video-portal stylesheet rule via el_style_props, not just data-sc-cs) distinguishes a FLOATING portal (has offsets / non-full size) from a full-bleed backdrop: a floating one is emitted as a fixed-positioned media_video CONTENT block (prepended to section 0, position:fixed scoped Custom CSS carrying top/right/transform/clamp-size/border-radius/mask/filter) instead of a section background; a full-bleed video still becomes the section bg as before. Gated on a FRAMED portal (border-radius present): a floating video is only pinned as a portal when it is a defined CARD — the fixed-video capture's rounded .video-portal. A large, UNFRAMED, softly-masked decorative layer (a soft-masked-layer capture's .visual-breach: 55vw×80vh, z-index:0, pointer-events:none, radial fade, no radius) stays on the full-bleed section-bg path, where the harness reads its hero as a media-hero — without the gate it regressed that site's spacing (spc 100→0, a media=true hero lost). Verified: a fixed-video capture's portal renders position:fixed at the top-right (~35vw), radial-masked, over the dark page; the soft-masked-layer capture unchanged.
  • 2026-09-09 — Lone-video column cell → contained media_video with carried shape (shipped, regression-clean). layout_cols (stitch.php) now decomposes a lone-video cell (cell_is_lone_video — one self-hosted <video>, no heading/prose, no content image) into a real, editable media_video instead of a verbatim .sc-tw code block the offline path can't size. The clip's shape is carried as scoped card_css by media_shape_css — border-radius (+overflow), clip-path, mask, opacity, scale, and a filter COMPOSED from Tailwind utilities (tw_filter_from_classes: contrast-125 saturate-50 → contrast(1.25) saturate(.5), replacing Tailwind's unresolved var(--tw-…) form). Stylesheet- defined effects (a custom .radial-portal mask, mix-blend-screen) are read by reusing bg_video_effect_css against the cached source HTML (self::$cur_html). The video is forced bg=off (a grid-column clip is never the section backdrop). Verified: a 2-column-grid hero capture 100 (was rendering a blank code_block); every shaped-video content site (build-products, human-centric, kinetic-fashion, a photo-content capture, a rounded-video-card capture, a shaped-video capture, a shaped-video capture, a fixed-video capture) stayed 100; corpus overall 99, bg_media/structure/verbatim/media_retention all 100.
  • 2026-09-09 — classify_display + collapse_transparent_wrappers primitives (added, not yet wired). classify_display($el, &$meta) (stitch.php, near section_is_multicol_grid) returns leaf | grid | flex-row | flex-col | block from the CAPTURED computed display / grid-template-columns / flex-direction first, Tailwind tokens second; $meta carries cols, regular (a grid with template-areas / col-span / dense auto-flow is IRREGULAR), gap. collapse_transparent_wrappers($el) descends single-child, box-free wrappers (div-soup) to the first meaningful node (wrapper_has_own_box guards against skipping a card or a real layout band). Verified on real DOMs: the 2-column-grid hero captures → grid cols=2; a capped-width hero capture → flex-col stack; a bespoke capture → block; no false collapse on 2-child bands. These are the reusable structure-detector primitives; the generic recursive walker will consume them next.
  • 2026-09-09 — Grid-structure gate for hero videos (shipped, regression-clean). section_is_multicol_grid($el) (stitch.php:9008) + an early gate in detect_section_bg_video (stitch.php:9034): a grid-cols-[2-9] / computed-2-track hero no longer promotes its video to a full-bleed section background — the video is column content. Matching $is_grid2 exclusion in the scorer's srcHeroVideo (import-site.php:87). Verified: the 2-column-grid hero captures / a capped-width hero capture / a bespoke capture all 100, no regressions across the 58-site corpus (avg ~99.2).