Section decomposition: recursive descent with semantic band detection

detectSections gave up on real pages: <body> 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 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-03 23:31:07 -07:00
co-authored by Claude Fable 5
parent d3ec154bae
commit 6a930d57b1
3 changed files with 422 additions and 132 deletions
+28 -43
View File
@@ -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,30 +234,15 @@ 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. <main>)
// holding the real sections — expand such oversized children into their own significant
// children, keeping the other siblings (e.g. a sibling <footer>). Gated on <3 so a page
// that already splits cleanly is untouched.
if (sections.length < 3) {
const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
const expanded: IRNode[] = [];
for (const c of candidates) {
const inner = significantChildren(c, cw);
const h = box(c, cw)?.height ?? 0;
if (inner.length >= 3 && pageH > 0 && h >= pageH * 0.4) expanded.push(...inner);
else expanded.push(c);
}
candidates = expanded;
sections = distinctOf(candidates);
}
let sections = distinctOf(bands);
let recipeNameHints = new Map<string, string>();
if (sections.length < 3 && fallback.sections.length >= 3) {
if (sections.length < 3) {
const fallback = recipeFallbackSections(ir, recipes);
if (fallback.sections.length >= 3) {
sections = fallback.sections;
recipeNameHints = fallback.names;
}
}
const roots = new Map<string, string>();
if (sections.length < 3 || sections.length > MAX_SECTIONS) return { roots };
@@ -280,6 +253,10 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
return n === 1 ? base : `${base}${n}`;
};
// Hero must actually start near the top of the page; without the gate a
// mid-page band inherits the name when the true hero was excluded (e.g. as a
// repeated run), which is worse than an honest content-derived name.
const heroMaxY = Math.max(900, pageH * 0.25);
let heroAssigned = false;
sections.forEach((sec, i) => {
const isLast = i === sections.length - 1;
@@ -287,18 +264,26 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
let name: string;
if (sec.tag === "footer" || (isLast && looksLikeNav(sec, cw) === false && subtreeHasTag(sec, "a") && (box(sec, cw)?.height ?? 0) < 700)) {
name = sec.tag === "footer" ? "Footer" : (titleText(sec) ? slugWords(titleText(sec)) + "Section" : "Footer");
} else if (i === 0 && looksLikeNav(sec, cw)) {
} else if (sec.tag === "nav"
|| (i === 0 && looksLikeNav(sec, cw) && !heroLikeHeader(sec, cw))
// a thin fixed bar wrapping the real <nav> (often a styled <div>, sorted after
// the hero it overlays) is still the navbar
|| (yOf(sec, cw) <= 160 && (box(sec, cw)?.height ?? 0) <= 160 && subtreeHasTag(sec, "nav"))) {
name = "Navbar";
} else if (sec.tag === "header") {
} else if (sec.tag === "header" && !heroLikeHeader(sec, cw)) {
// a <header> carrying the page h1 at real height is the hero, not chrome
name = "Header";
} else if (!heroAssigned) {
} else if (!heroAssigned && yOf(sec, cw) <= heroMaxY) {
heroAssigned = true;
name = "HeroSection";
} else if (recipeName) {
name = recipeName;
} else {
const slug = slugWords(titleText(sec));
name = slug ? `${slug}Section` : `Section${i + 1}`;
name = slug ? `${slug}Section`
: subtreeHasTag(sec, "form", 6) ? "ContactSection"
: subtreeHasTag(sec, "video", 6) || subtreeHasTag(sec, "iframe", 6) ? "MediaSection"
: `Section${i + 1}`;
}
roots.set(sec.id, dedupe(identName(name)));
});
+150 -83
View File
@@ -4,10 +4,19 @@ import { round } from "../util/canonical.js";
/**
* Deterministic section detection. Splits the page into visually coherent
* top-level blocks using semantic tags and geometry (we do not rely on class
* names — those are intentionally dropped from the IR). Sections are metadata:
* stable ids + per-viewport bboxes for the layout/section gate. Role guesses are
* advisory and never affect fidelity.
* top-level bands using semantic tags and geometry (we do not rely on class
* names — those are intentionally dropped from the IR).
*
* The core is a recursive descent: a container that covers most of the page
* (<body> → <div id=root> → <main>) is a WRAPPER, not a band — descend into it
* and re-collect among its children, repeating until every candidate is
* band-like. A descent only replaces the container when its children actually
* tile it (stacked full-width blocks with near-complete coverage), so a page
* that truly is one tall band legally stays a single section.
*
* Sections are metadata: stable ids + per-viewport bboxes for the layout/section
* gate, and the shared root set for section-per-file emission (sectionSplit).
* Role guesses are advisory and never affect fidelity.
*/
export type Section = {
@@ -19,104 +28,162 @@ export type Section = {
};
const SEMANTIC = new Set(["header", "nav", "main", "section", "article", "footer", "aside"]);
const MIN_BAND_H = 64;
const MIN_SEMANTIC_H = 24; // a 62px navbar is still the navbar
const MIN_BAND_W_FRAC = 0.55; // a band spans most of the viewport
const WRAPPER_FRAC = 0.5; // taller than this fraction of the page → wrapper, try descending
const MIN_CHILD_COVERAGE = 0.8; // children must tile the container to replace it
const MAX_STACK_OVERLAP = 0.3; // consecutive bands may overlap at most this much of the smaller
const MAX_BANDS_PER_SPLIT = 20; // a split into more pieces than this is fragmentation, not bands
const MAX_DEPTH = 12;
type Cand = { node: IRNode; path: string; y: number; width: number; height: number };
type Cand = { node: IRNode; y: number; width: number; height: number };
type Ctx = { cw: number; pageH: number };
/** The ordered section-root nodes of the page (top to bottom). Shared by the
* validator (detectSections) and the generator's section splitter, so the gate's
* section list and the emitted section components describe the same bands.
* Falls back to `[ir.root]` when the page has no decomposable structure. */
export function detectSectionNodes(ir: IR): IRNode[] {
const cw = ir.doc.canonicalViewport;
const ctx: Ctx = { cw, pageH: ir.doc.perViewport[cw]?.scrollHeight ?? 0 };
const bands = bandsOf(ir.root, 0, ctx);
// Stable sort by top edge; ties keep document order (a fixed nav stays before
// the hero it overlays).
bands.sort((a, b) => a.y - b.y);
// Nested wrappers can repeat the same box from disjoint subtrees — keep the first.
const final: Cand[] = [];
for (const c of bands) {
if (!final.some((f) => Math.abs(f.y - c.y) < 4 && Math.abs(f.height - c.height) < 4)) final.push(c);
}
if (final.length === 0) return [ir.root];
return final.map((c) => c.node);
}
export function detectSections(ir: IR): Section[] {
const cw = ir.doc.canonicalViewport;
const vpWidth = cw;
const candidates: Cand[] = [];
const walk = (node: IRNode, path: string): void => {
const bbox = node.bboxByVp[cw];
const visible = node.visibleByVp[cw];
if (bbox && visible) {
const isSemantic = SEMANTIC.has(node.tag);
const isBigBlock = bbox.width >= vpWidth * 0.55 && bbox.height >= 64;
const display = node.computedByVp[cw]?.display ?? "";
const blockish = !/^(inline|inline-block|none)$/.test(display);
if ((isSemantic || isBigBlock) && blockish) {
candidates.push({ node, path, y: bbox.y, width: bbox.width, height: bbox.height });
}
}
for (const c of node.children) {
if (isTextChild(c)) continue;
walk(c, `${path}/${c.id}`);
}
};
walk(ir.root, ir.root.id);
// Outermost wins: drop candidates nested inside another candidate.
const byShallow = candidates.slice().sort((a, b) => a.path.split("/").length - b.path.split("/").length);
let kept: Cand[] = [];
for (const c of byShallow) {
if (kept.some((k) => c.path.startsWith(k.path + "/"))) continue;
kept.push(c);
}
// If the only survivor is a giant wrapper, expand into its section-like children.
const pageHeight = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
kept = expandOversized(kept, vpWidth, pageHeight, cw);
// Sort by Y, then assign ids/roles.
kept.sort((a, b) => a.y - b.y || b.height - a.height);
// Deduplicate near-identical y/height wrappers (keep the first/shallowest).
const final: Cand[] = [];
for (const c of kept) {
const dup = final.find((f) => Math.abs(f.y - c.y) < 4 && Math.abs(f.height - c.height) < 4);
if (!dup) final.push(c);
}
return final.map((c, i) => ({
const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
const nodes = detectSectionNodes(ir);
const roles = guessRoles(nodes, cw, pageH);
return nodes.map((node, i) => ({
id: `section-${String(i + 1).padStart(3, "0")}`,
nodeId: c.node.id,
role: guessRole(c.node, i, final.length),
nodeId: node.id,
role: roles[i]!,
order: i,
bboxByVp: bboxesFor(c.node),
bboxByVp: bboxesFor(node),
}));
}
function expandOversized(cands: Cand[], vpWidth: number, pageHeight: number, cw: number): Cand[] {
if (cands.length > 2) return cands;
const threshold = Math.max(pageHeight * 0.55, 1200);
/** Landmark evidence that a bar is site chrome: an ARIA landmark role on the node
* itself, or a <nav> within a shallow wrapper chain. Lets a thin fixed bar (often a
* 62px styled <div> around the real <nav>) clear the semantic height bar. */
export function navEvidence(node: IRNode): boolean {
const role = node.attrs.role ?? "";
if (role === "banner" || role === "navigation") return true;
return subtreeHas(node, (n) => n.tag === "nav" || n.attrs.role === "navigation", 3);
}
function candOf(node: IRNode, ctx: Ctx): Cand | null {
const bbox = node.bboxByVp[ctx.cw];
if (!bbox || !node.visibleByVp[ctx.cw]) return null;
const display = node.computedByVp[ctx.cw]?.display ?? "";
if (/^(inline|inline-block|none)$/.test(display)) return null;
if (bbox.width < ctx.cw * MIN_BAND_W_FRAC) return null;
const minH = SEMANTIC.has(node.tag) || navEvidence(node) ? MIN_SEMANTIC_H : MIN_BAND_H;
if (bbox.height < minH) return null;
return { node, y: bbox.y, width: bbox.width, height: bbox.height };
}
/** Collect the band candidates among `container`'s children. A band-sized child is
* kept; a page-covering child is descended into (its children replace it only when
* they tile it — see acceptSplit); anything else is a transparent wrapper we look
* through. Output is in document order. */
function bandsOf(container: IRNode, depth: number, ctx: Ctx): Cand[] {
if (depth > MAX_DEPTH) return [];
const out: Cand[] = [];
for (const c of cands) {
if (c.height < threshold) { out.push(c); continue; }
const inner: Cand[] = [];
const collect = (node: IRNode, path: string, depth: number): void => {
if (depth > 6) return;
for (const ch of node.children) {
if (isTextChild(ch)) continue;
const bbox = ch.bboxByVp[cw];
if (bbox && ch.visibleByVp[cw] && bbox.width >= vpWidth * 0.5 && bbox.height >= 64) {
inner.push({ node: ch, path: `${path}/${ch.id}`, y: bbox.y, width: bbox.width, height: bbox.height });
} else {
collect(ch, `${path}/${ch.id}`, depth + 1);
for (const child of container.children) {
if (isTextChild(child)) continue;
const cand = candOf(child, ctx);
if (!cand) {
out.push(...bandsOf(child, depth + 1, ctx));
continue;
}
if (ctx.pageH > 0 && cand.height > ctx.pageH * WRAPPER_FRAC) {
const inner = bandsOf(child, depth + 1, ctx);
if (acceptSplit(inner, cand)) {
out.push(...inner);
continue;
}
}
};
collect(c.node, c.path, 0);
// outermost wins within inner
const byShallow = inner.slice().sort((a, b) => a.path.split("/").length - b.path.split("/").length);
const keptInner: Cand[] = [];
for (const ic of byShallow) {
if (keptInner.some((k) => ic.path.startsWith(k.path + "/"))) continue;
keptInner.push(ic);
}
if (keptInner.length >= 3) out.push(...keptInner);
else out.push(c);
out.push(cand);
}
return out;
}
function guessRole(node: IRNode, index: number, total: number): string {
if (node.tag === "header") return "header";
if (node.tag === "nav") return "nav";
/** May `inner` replace its containing candidate? Only when it reads as a stack of
* bands: at least two, not a fragmentation explosion, vertically stacked (side-by-side
* columns or overlaid layers must not be split), and tiling the container with
* near-complete coverage so no substantial content is silently dropped. */
function acceptSplit(inner: Cand[], parent: Cand): boolean {
if (inner.length < 2 || inner.length > MAX_BANDS_PER_SPLIT) return false;
const sorted = inner.slice().sort((a, b) => a.y - b.y);
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]!, cur = sorted[i]!;
const overlap = prev.y + prev.height - cur.y;
if (overlap > Math.min(prev.height, cur.height) * MAX_STACK_OVERLAP) return false;
}
let covered = 0, cursor = -Infinity;
for (const c of sorted) {
const top = Math.max(c.y, cursor), bot = c.y + c.height;
if (bot > top) covered += bot - top;
cursor = Math.max(cursor, bot);
}
return covered >= parent.height * MIN_CHILD_COVERAGE;
}
function subtreeHas(node: IRNode, pred: (n: IRNode) => boolean, depth = 6): boolean {
if (depth < 0) return false;
for (const c of node.children) {
if (isTextChild(c)) continue;
if (pred(c) || subtreeHas(c, pred, depth - 1)) return true;
}
return false;
}
/** A <header> that carries the page's h1 and real height is the hero band, not
* site chrome — "header → chrome" only holds for the thin bar variant. */
export function heroLikeHeader(node: IRNode, cw: number): boolean {
const h = node.bboxByVp[cw]?.height ?? 0;
return h >= 200 && subtreeHas(node, (n) => n.tag === "h1");
}
/** Advisory roles, one pass top-to-bottom: tag evidence first, then the first
* near-top content band claims "hero" (once), then content evidence. */
function guessRoles(nodes: IRNode[], cw: number, pageH: number): string[] {
let heroClaimed = false;
return nodes.map((node, index) => {
if (node.tag === "nav") return "navbar";
if (node.tag === "header") {
if (!heroLikeHeader(node, cw)) return "header";
heroClaimed = true;
return "hero";
}
if (node.tag === "footer") return "footer";
if (node.tag === "aside") return "aside";
if (node.tag === "main") return "main";
if (index === 0) return "hero";
if (index === total - 1) return "footer";
const bbox = node.bboxByVp[cw];
const h = bbox?.height ?? 0, y = bbox?.y ?? 0;
// thin top bar without the <nav> tag (first band, or a fixed bar with landmark evidence)
if (h <= 140 && y <= 160 && (index === 0 || navEvidence(node))) return "navbar";
if (!heroClaimed && (bbox?.y ?? 0) <= Math.max(900, pageH * 0.25)) {
heroClaimed = true;
return "hero";
}
if (index === nodes.length - 1) return "footer";
if (subtreeHas(node, (n) => n.tag === "form")) return "contact";
if (subtreeHas(node, (n) => n.tag === "video" || n.tag === "iframe")) return "media";
return "section";
});
}
function bboxesFor(node: IRNode): Section["bboxByVp"] {
+238
View File
@@ -0,0 +1,238 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { detectSections, detectSectionNodes } from "../src/infer/sections.js";
import { planSections } from "../src/generate/sectionSplit.js";
import type { IR, IRNode, IRChild } from "../src/normalize/ir.js";
const CW = 1280;
type Box = { x?: number; y: number; width?: number; height: number };
/** An element node at the canonical viewport (ids assigned later in pre-order). */
function el(tag: string, box: Box, children: IRChild[] = [], display = "block"): IRNode {
return {
id: "",
tag,
attrs: {},
visibleByVp: { [CW]: true },
bboxByVp: { [CW]: { x: box.x ?? 0, y: box.y, width: box.width ?? CW, height: box.height } },
computedByVp: { [CW]: { display } },
children,
};
}
function text(t: string): IRChild {
return { text: t };
}
/** Wrap children in a <body> root and assign stable pre-order ids (n0, n1, …). */
function page(children: IRNode[], pageH: number): IR {
const root = el("body", { y: 0, height: pageH }, children);
let i = 0;
const assign = (n: IRNode): void => {
n.id = `n${i++}`;
for (const c of n.children) if ((c as IRNode).tag) assign(c as IRNode);
};
assign(root);
return {
doc: {
sourceUrl: "https://example.test/", title: "Fixture", lang: "en", charset: "UTF-8",
metaViewport: "width=device-width, initial-scale=1",
viewports: [CW], sampleViewports: [CW], canonicalViewport: CW,
perViewport: { [CW]: { scrollHeight: pageH, scrollWidth: CW, htmlBg: "", bodyBg: "", bodyColor: "", bodyFont: "" } },
nodeCount: i, keyframes: [],
},
root,
};
}
/** A content band with a heading (so section naming has honest evidence). Bands get
* `variant` extra paragraphs so adjacent one-off sections differ structurally — the
* repeated-run filter (≥3 identical signatures → component cluster) must not fire. */
function band(y: number, height: number, heading: string, variant = 0): IRNode {
const extras: IRNode[] = [];
for (let k = 0; k <= variant % 7; k++) {
extras.push(el("p", { y: y + 80 + k * 30, height: 24, x: 120, width: 600 }, [text(`Body copy ${k}`)]));
}
return el("section", { y, height }, [
el("h2", { y: y + 24, height: 40, x: 120, width: 600 }, [text(heading)]),
...extras,
]);
}
/** navbar(62) + hero(800) + 3 bands + footer(400), flat under body. */
function sixBandPage(): IR {
const nav = el("nav", { y: 0, height: 62 });
const hero = el("section", { y: 62, height: 800 }, [
el("h1", { y: 120, height: 60, x: 120, width: 900 }, [text("Ship faster with widgets")]),
]);
const b1 = band(862, 600, "Trusted by teams", 0);
const b2 = band(1462, 700, "Everything you need", 1);
const b3 = band(2162, 500, "What customers say", 2);
const footer = el("footer", { y: 2662, height: 400 }, [
el("a", { y: 2700, height: 20, x: 120, width: 200, }, [text("Privacy")], "inline"),
]);
return page([nav, hero, b1, b2, b3, footer], 3062);
}
/** Same bands, but body > div#root > (nav + main(13 bands) + footer) — the real-site shape. */
function wrappedPage(bandCount = 13): IR {
const nav = el("nav", { y: 0, height: 62 });
const bandH = 900;
const bands: IRNode[] = [];
for (let k = 0; k < bandCount; k++) {
bands.push(k === 0
? el("section", { y: 62, height: bandH }, [el("h1", { y: 100, height: 60, x: 120, width: 900 }, [text("Design your future")])])
: band(62 + k * bandH, bandH, `Feature area ${k}`, k));
}
const mainH = bandCount * bandH;
const main = el("main", { y: 62, height: mainH }, bands);
const footer = el("footer", { y: 62 + mainH, height: 400 });
const pageH = 62 + mainH + 400;
const wrapper = el("div", { y: 0, height: pageH }, [nav, main, footer]);
return page([wrapper], pageH);
}
describe("section decomposition (recursive descent)", () => {
it("splits navbar + hero + 3 bands + footer into 6 sections", () => {
const ir = sixBandPage();
const sections = detectSections(ir);
assert.equal(sections.length, 6);
assert.equal(sections[0]!.role, "navbar");
assert.equal(sections[1]!.role, "hero");
assert.equal(sections[5]!.role, "footer");
// the 62px navbar (below the old 64px bar) is still the navbar
assert.equal(sections[0]!.bboxByVp[CW]!.height, 62);
});
it("descends body > div > main wrappers to the real bands", () => {
const ir = wrappedPage(13);
const sections = detectSections(ir);
assert.equal(sections.length, 15); // nav + 13 bands + footer
assert.equal(sections[0]!.role, "navbar");
assert.equal(sections[1]!.role, "hero");
assert.equal(sections[14]!.role, "footer");
// no wrapper survives as a section
const tags = new Set(detectSectionNodes(ir).map((n) => n.tag));
assert.ok(!tags.has("main") && !tags.has("body") && !tags.has("div"));
});
it("sections tile the page top-to-bottom without gaps or overlaps", () => {
const ir = wrappedPage(13);
const boxes = detectSections(ir).map((s) => s.bboxByVp[CW]!).sort((a, b) => a.y - b.y);
const pageH = ir.doc.perViewport[CW]!.scrollHeight;
let cursor = 0;
for (const b of boxes) {
assert.ok(Math.abs(b.y - cursor) <= 8, `gap/overlap at y=${b.y} (expected ~${cursor})`);
cursor = b.y + b.height;
}
assert.ok(pageH - cursor <= 8, `uncovered tail: ${pageH - cursor}px`);
});
it("keeps a single-band page as one section (degenerate stays legal)", () => {
const inner = el("div", { y: 100, height: 300, x: 320, width: 640 });
const only = el("div", { y: 0, height: 800 }, [inner]);
const ir = page([only], 800);
const sections = detectSections(ir);
assert.equal(sections.length, 1);
});
it("does not split side-by-side columns or overlaid layers", () => {
// two full-width overlapping layers inside a page-covering wrapper
const a = el("div", { y: 100, height: 1900 });
const b = el("div", { y: 100, height: 1900 });
const wrapper = el("div", { y: 100, height: 1900 }, [a, b]);
const nav = el("nav", { y: 0, height: 100 });
const ir = page([nav, wrapper], 2000);
const sections = detectSections(ir);
assert.equal(sections.length, 2); // nav + the wrapper, unsplit
});
it("does not descend when children leave large coverage gaps", () => {
// one small band inside a page-covering container: splitting would drop content
const lone = band(0, 300, "Only child");
const container = el("div", { y: 0, height: 2000 }, [lone]);
const ir = page([container], 2000);
assert.equal(detectSections(ir).length, 1);
});
it("keeps a 62px fixed div-wrapped navbar as its own band (nav evidence beats the 64px bar)", () => {
// real-site shape: hero at y=0, a thin fixed bar (styled div, real <nav> nested
// narrow inside) floating over it
const navInner = el("nav", { y: 12, height: 62, x: 700, width: 454 });
const bar = el("div", { y: 12, height: 62 }, [el("div", { y: 12, height: 62 }, [navInner])]);
const hero = el("section", { y: 0, height: 800 }, [
el("h1", { y: 200, height: 60, x: 120, width: 900 }, [text("We recruit designers")]),
]);
const b1 = band(800, 700, "Our mission", 1);
const b2 = band(1500, 900, "Services", 2);
const footer = el("footer", { y: 2400, height: 300 });
const ir = page([hero, bar, b1, b2, footer], 2700);
const sections = detectSections(ir);
assert.equal(sections.length, 5);
assert.equal(sections[0]!.role, "hero"); // y=0 sorts before the bar at y=12
assert.equal(sections[1]!.role, "navbar");
const names = [...planSections(ir).roots.values()];
assert.ok(names.includes("Navbar"), `expected Navbar in ${names.join(", ")}`);
});
it("is deterministic: same capture, byte-identical sections", () => {
const a = JSON.stringify(detectSections(wrappedPage(13)));
const b = JSON.stringify(detectSections(wrappedPage(13)));
assert.equal(a, b);
});
});
describe("section component planning (emission roots + names)", () => {
it("names one component per band: Navbar, HeroSection, content sections, Footer", () => {
const ir = sixBandPage();
const plan = planSections(ir);
const names = [...plan.roots.values()];
assert.equal(names.length, 6);
assert.equal(names[0], "Navbar");
assert.equal(names[1], "HeroSection");
assert.equal(names[5], "Footer");
assert.ok(names.includes("TrustedByTeamsSection"));
assert.ok(names.includes("WhatCustomersSaySection"));
assert.equal(new Set(names).size, 6, "names are unique");
});
it("plans a component per band through body > div > main wrappers", () => {
const plan = planSections(wrappedPage(13));
assert.equal(plan.roots.size, 15);
const names = [...plan.roots.values()];
assert.equal(names[0], "Navbar");
assert.equal(names[1], "HeroSection");
assert.equal(names[14], "Footer");
});
it("falls back to evidence names for bands without headings", () => {
const nav = el("nav", { y: 0, height: 62 });
const hero = el("section", { y: 62, height: 800 }, [
el("h1", { y: 120, height: 60, x: 120, width: 900 }, [text("Hello")]),
]);
const formBand = el("section", { y: 862, height: 500 }, [
el("form", { y: 900, height: 200, x: 320, width: 640 }),
]);
const mediaBand = el("section", { y: 1362, height: 500 }, [
el("video", { y: 1400, height: 400, x: 160, width: 960 }),
]);
const footer = el("footer", { y: 1862, height: 300 });
const ir = page([nav, hero, formBand, mediaBand, footer], 2162);
const names = [...planSections(ir).roots.values()];
assert.ok(names.includes("ContactSection"), `expected ContactSection in ${names.join(", ")}`);
assert.ok(names.includes("MediaSection"), `expected MediaSection in ${names.join(", ")}`);
});
it("leaves a page with too few bands unsplit (no monolithic 'hero' misnomer)", () => {
const only = el("div", { y: 0, height: 800 }, [el("div", { y: 100, height: 300, x: 320, width: 640 })]);
const plan = planSections(page([only], 800));
assert.equal(plan.roots.size, 0);
});
it("is deterministic across runs", () => {
const a = JSON.stringify([...planSections(wrappedPage(13)).roots.entries()]);
const b = JSON.stringify([...planSections(wrappedPage(13)).roots.entries()]);
assert.equal(a, b);
});
});