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
80headingh1h6{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 colosseum / orbital-horizon (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_texttext_block, n_headingspecial_heading, n_media_imagemedia_image, n_videomedia_video, n_icon_boxicon_box, n_buttonbutton, n_codecode_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_presetboxp-<slug> (mapper.php:172).

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 hygge_haus 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 orbital-horizon (bgv 0) because portal was too broad and anime 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 — lumina-arctic's div.video-portal). UnysonPlus backgrounds are per-section, and the Background-Pro video layer has no fixed mode (only IMAGE has attachment: fixedbackground-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 — lumina-arctic) 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.

Change Log

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

  • 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) defenceFW_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: colosseum 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_openunysonplus_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.
  • Open — case B: fixed FLOATING video portals (lumina-arctic). Not every "fixed video" is a full-viewport backdrop. lumina-arctic's .video-portal is position:fixed; top:50%; right:5%; width:clamp(280px,35vw,520px); height:clamp(380px,55vh,720px); border-radius:28px 8px 28px 8px; mask-image:radial-gradient(...) — a masked, positioned floating video that stays fixed on scroll but is NOT a background. Option A's detector CORRECTLY skips it (not full-viewport). Handling it needs a fixed-positioned media_video that preserves the portal's exact geometry (top/right/transform/ clamp size) + border-radius + mask as scoped position:fixed custom CSS — a per-element floating layer, not a site background. Deferred (distinct, narrower feature).
  • 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-50contrast(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: colosseum 100 (was rendering a blank code_block); every shaped-video content site (build-products, human-centric, kinetic-fashion, national-geographic, nox-liquid, reactive-forest, terraform, lumina-arctic) 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: colosseum/orbital → grid cols=2; apple-vision → flex-col stack; anime → 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: colosseum / orbital-horizon / apple-vision-pro / anime all 100, no regressions across the 58-site corpus (avg ~99.2).