From 6a930d57b11abca80ee24362bef568f0b92bcbe0 Mon Sep 17 00:00:00 2001 From: Samraaj Bath Date: Fri, 3 Jul 2026 23:31:07 -0700 Subject: [PATCH] Section decomposition: recursive descent with semantic band detection detectSections gave up on real pages: swallowed everything, a single oversized-wrapper expansion pass required >=3 candidates, and a 62px navbar failed the 64px height bar - so nearly every clone shipped as one section and one monolithic component. Rewritten around a shared recursive core (detectSectionNodes): containers covering >50% of the page are wrappers to descend into; a split is accepted only when >=2 stacked bands tile >=80% of the container (side-by-side columns and overlays never split; truly single-band pages legally stay 1). Semantic tags get a 24px bar with nav-evidence detection for thin bars. The validator's section list and the emitted section components now consume the same root set, so gate-5 bands and sections/*.tsx files always agree. Naming is evidence-based and deterministic: navbar/header/hero (only when actually near the top), recipe names (logo-cloud, product-grid...), heading slugs, contact/media, positional fallback. Frozen-capture probes: 1 -> 6..17 sections across the reference runs, with the old whole-page "hero-section" monoliths splitting into honest bands. 231 tests pass (13 new), typecheck clean. Co-Authored-By: Claude Fable 5 --- compiler/src/generate/sectionSplit.ts | 75 ++++---- compiler/src/infer/sections.ts | 241 ++++++++++++++++---------- compiler/test/sections.test.ts | 238 +++++++++++++++++++++++++ 3 files changed, 422 insertions(+), 132 deletions(-) create mode 100644 compiler/test/sections.test.ts diff --git a/compiler/src/generate/sectionSplit.ts b/compiler/src/generate/sectionSplit.ts index f209ca7..5835654 100644 --- a/compiler/src/generate/sectionSplit.ts +++ b/compiler/src/generate/sectionSplit.ts @@ -15,6 +15,7 @@ import type { IR, IRNode } from "../normalize/ir.js"; import { isTextChild } from "../normalize/ir.js"; import type { RecipeCandidate, RecipeKind, RecipeReport } from "../infer/recipes.js"; +import { detectSectionNodes, heroLikeHeader } from "../infer/sections.js"; import { subtreeSignature } from "../site/sharedLayout.js"; export type SectionPlan = { @@ -23,7 +24,7 @@ export type SectionPlan = { }; const MIN_SECTION_H = 56; -const MAX_SECTIONS = 24; +const MAX_SECTIONS = 32; function box(n: IRNode, cw: number): { width: number; height: number } | undefined { return n.bboxByVp[cw] ?? Object.values(n.bboxByVp)[0]; @@ -31,15 +32,9 @@ function box(n: IRNode, cw: number): { width: number; height: number } | undefin function yOf(n: IRNode, cw: number): number { return (n.bboxByVp[cw] ?? Object.values(n.bboxByVp)[0])?.y ?? 0; } -function visible(n: IRNode, cw: number): boolean { - return !!(n.visibleByVp[cw] ?? Object.values(n.visibleByVp)[0]); -} function elementChildren(n: IRNode): IRNode[] { return n.children.filter((c): c is IRNode => !isTextChild(c)); } -function significantChildren(n: IRNode, cw: number): IRNode[] { - return elementChildren(n).filter((c) => visible(c, cw) && (box(c, cw)?.height ?? 0) >= MIN_SECTION_H); -} function subtreeHasTag(n: IRNode, tag: string, depth = 4): boolean { if (depth < 0) return false; @@ -225,18 +220,11 @@ function recipeFallbackSections(ir: IR, recipes?: RecipeReport): { sections: IRN export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan { const cw = ir.doc.canonicalViewport; - // Descend through the wrapper chain (single significant child) to the container - // whose children are the actual sections. - let node = ir.root; - for (let i = 0; i < 10; i++) { - const sig = significantChildren(node, cw); - if (sig.length >= 2) break; - if (sig.length === 1) { node = sig[0]!; continue; } - const kids = elementChildren(node); - if (kids.length === 1) { node = kids[0]!; continue; } - break; - } - // Exclude any child that is part of a REPEATED run (≥3 same-signature siblings): that's + const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0; + // The shared recursive band decomposition (infer/sections) — the same roots the + // section gate validates, so each emitted file corresponds to a gate section. + const bands = detectSectionNodes(ir).filter((n) => n.id !== ir.root.id); + // Exclude any band that is part of a REPEATED run (≥3 same-signature siblings): that's // a component cluster (a card/logo grid), which component extraction should turn into a // `.map()` over a data array — not a wall of near-identical "section" files. Only the // distinct, one-off blocks become sections. @@ -246,29 +234,14 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan { return list.filter((s) => (count.get(subtreeSignature(s)) ?? 0) < 3); }; - let candidates = significantChildren(node, cw); - let sections = distinctOf(candidates); - const fallback = recipeFallbackSections(ir, recipes); - // If the container yields too few sections, a dominant child is a wrapper (e.g.
) - // holding the real sections — expand such oversized children into their own significant - // children, keeping the other siblings (e.g. a sibling