@12ui/design 0.2.51 → 0.2.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/SKILL.md +1 -1
  2. package/dist/branch-client.d.ts +5 -0
  3. package/dist/branch-client.d.ts.map +1 -1
  4. package/dist/branch-client.js.map +1 -1
  5. package/dist/branch-completion.d.ts.map +1 -1
  6. package/dist/branch-completion.js +7 -0
  7. package/dist/branch-completion.js.map +1 -1
  8. package/dist/branch-identity.d.ts +25 -0
  9. package/dist/branch-identity.d.ts.map +1 -1
  10. package/dist/branch-identity.js +28 -15
  11. package/dist/branch-identity.js.map +1 -1
  12. package/dist/branch-page-conversion.d.ts.map +1 -1
  13. package/dist/branch-page-conversion.js +49 -4
  14. package/dist/branch-page-conversion.js.map +1 -1
  15. package/dist/branch-plan.d.ts +4 -1
  16. package/dist/branch-plan.d.ts.map +1 -1
  17. package/dist/branch-plan.js +7 -0
  18. package/dist/branch-plan.js.map +1 -1
  19. package/dist/branch-run-record.d.ts +4 -0
  20. package/dist/branch-run-record.d.ts.map +1 -1
  21. package/dist/branch-run-record.js.map +1 -1
  22. package/dist/cli-draft-command.d.ts.map +1 -1
  23. package/dist/cli-draft-command.js +17 -1
  24. package/dist/cli-draft-command.js.map +1 -1
  25. package/dist/hosted-draft-run.d.ts +25 -1
  26. package/dist/hosted-draft-run.d.ts.map +1 -1
  27. package/dist/hosted-draft-run.js +82 -2
  28. package/dist/hosted-draft-run.js.map +1 -1
  29. package/dist/package-client.d.ts +11 -0
  30. package/dist/package-client.d.ts.map +1 -1
  31. package/dist/package-client.js.map +1 -1
  32. package/dist/package-submission.d.ts +13 -2
  33. package/dist/package-submission.d.ts.map +1 -1
  34. package/dist/package-submission.js +34 -1
  35. package/dist/package-submission.js.map +1 -1
  36. package/dist/prototype-retention.d.ts +10 -0
  37. package/dist/prototype-retention.d.ts.map +1 -0
  38. package/dist/prototype-retention.js +14 -0
  39. package/dist/prototype-retention.js.map +1 -0
  40. package/dist/prototype-revision.d.ts +1 -1
  41. package/dist/prototype-revision.js +1 -1
  42. package/dist/prototype-runner.d.ts.map +1 -1
  43. package/dist/prototype-runner.js +39 -15
  44. package/dist/prototype-runner.js.map +1 -1
  45. package/dist/review-server.d.ts.map +1 -1
  46. package/dist/review-server.js +10 -1
  47. package/dist/review-server.js.map +1 -1
  48. package/open-design.json +2 -2
  49. package/package.json +1 -1
  50. package/prototype-kit/build.mjs +54 -9
  51. package/prototype-kit/lib/action-target.mjs +34 -8
  52. package/prototype-kit/lib/runtime-check.mjs +31 -12
  53. package/prototype-kit/polish/ground-probe.mjs +4 -1
  54. package/prototype-kit/polish/measure.mjs +6 -6
  55. package/prototype-kit/polish/rules/section-breathing.mjs +461 -237
  56. package/prototype-kit/polish/tests/breathing-check.mjs +75 -27
  57. package/prototype-kit/polish/tests/fixtures/section-breathing/diagram-silent.html +20 -0
  58. package/prototype-kit/polish/tests/fixtures/section-breathing/fire.html +2 -2
  59. package/prototype-kit/polish/tests/fixtures/section-breathing/floor.html +3 -3
  60. package/prototype-kit/polish/tests/fixtures/section-breathing/no-scale-silent.html +18 -0
  61. package/prototype-kit/polish/tests/fixtures/section-breathing/rhythm-nested.html +21 -0
  62. package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-fire.html +10 -4
  63. package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-silent.html +2 -2
  64. package/prototype-kit/polish/tests/fixtures/section-breathing/silent.html +2 -2
  65. package/prototype-kit/polish/tests/fixtures/section-breathing/tight.html +4 -4
  66. package/prototype-kit/runtime/proto.js +52 -2
  67. package/prototype-kit/tests/adversarial-runtime.mjs +179 -4
  68. package/prototype-kit/tests/fixtures/blocking-runtime/broken-interaction.html +1 -0
  69. package/prototype-kit/tests/fixtures/interaction-runtime/PROVENANCE.md +5 -0
  70. package/prototype-kit/tests/fixtures/interaction-runtime/inert-field.html +23 -0
  71. package/prototype-kit/tests/fixtures/interaction-runtime/pointer-cascade.html +19 -0
  72. package/prototype-kit/tests/fixtures/multiple-trigger-runtime/probe-all.html +14 -0
  73. package/prototype-kit/tests/fixtures/nested-inert/branch.plan.json +5 -0
  74. package/prototype-kit/tests/fixtures/nested-inert/pages/p0.html +17 -0
  75. package/prototype-kit/tests/offline-check.mjs +6 -1
  76. package/prototype-kit/tests/rules-check.mjs +28 -9
  77. package/prototype-kit/verify-runtime.mjs +10 -5
  78. package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-fire.html +0 -5
  79. package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-silent.html +0 -5
  80. package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-fire.html +0 -5
  81. package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-silent.html +0 -5
@@ -1,291 +1,515 @@
1
- // section-breathing: add deliberate air at declared page and internal section boundaries while preserving the
2
- // boundary's proven solid ground. The rule remains page-atomic and the driver verifies its paint with real pixels.
1
+ // section-breathing: open only model-declared semantic boundaries.
3
2
  //
4
- // Declared facts, not page names, drive the r3 widening:
5
- // - adjacent `section[id^=viewport-]` blocks remain the page-seam source;
6
- // - a direct child `footer`, `*-faq` / `*-questions`, or full-width `*-partners` block is an internal semantic seam;
7
- // - only explicit `.decoration` leaves whose bounds touch/cross a page seam travel with the section below it;
8
- // - an explicit `dashboard` block stays silent (dense surfaces keep their deliberate compression).
3
+ // The model names the exact before/after elements, exact ground owner, and
4
+ // whether that owner may continue. This rule validates and executes that fact;
5
+ // no tag, id-pattern, size, pixel sample, or nearest-ground search may mint it.
9
6
  //
10
- // Solid page-seam paint uses an integer-offset shadow. Internal seams grow the section and its full-section solid
11
- // owner, then move the semantic block and later direct children. Gradient/image/texture grounds still abstain.
7
+ // A semantic seam's AMOUNT is the page's own rhythm, not a constant. The page
8
+ // scale T = min(largest structural gap, 2 x median structural gap) is derived
9
+ // from the vertical air the page already contains, and a seam is opened to T
10
+ // and never past it. The gap it is opened FROM is the page's real air line —
11
+ // the bottom of the lowest peer that ends above the seam — not the pairwise
12
+ // distance between the two declared elements, which reads air that is not there
13
+ // when a floating card or intro column sits inside the seam.
12
14
  export const name = 'section-breathing';
13
15
  export const maxShift = 192;
14
16
  export const phase = 'post';
15
17
  export const verifyPaint = true;
16
18
  export const recordsCensus = true;
17
19
  export const params = {
18
- FLOOR: 32,
19
- CAP: 64,
20
- MIN_TARGET: 48,
21
- MAX_TARGET: 192,
22
- COVER: 0.8,
23
- TIGHT_GAP: 32,
24
- SEMANTIC_MAX_GAP: 32,
25
- SEMANTIC_WIDTH: 0.8,
26
- PREVIOUS_BLOCK_WIDTH: 0.5,
20
+ FLOOR: 32, CAP: 64, MIN_TARGET: 48, MAX_TARGET: 192,
21
+ TIGHT_GAP: 32, SEMANTIC_MAX_GAP: 32,
22
+ // Page-rhythm model (semantic seams only; viewport seams keep FLOOR/MIN_TARGET/MAX_TARGET).
23
+ PERCEPTIBILITY_FLOOR: 8,
24
+ SCOPE_WIDTH_FRACTION: 0.4,
25
+ STRUCTURAL_MIN_INK: 2,
26
+ // A group whose stacked children share no common edge and vary wildly in width is a
27
+ // drawing (a metro map, a scatter), not a stack. Air inserted into it pulls the drawing apart.
28
+ DIAGRAM_MIN_CHILDREN: 4,
29
+ DIAGRAM_MAX_ALIGN_FRACTION: 0.5,
30
+ DIAGRAM_MIN_WIDTH_CV: 0.6,
31
+ DIAGRAM_EDGE_TOLERANCE: 4,
27
32
  };
33
+ export const SECTION_BOUNDARY_DECLARATION_REVISION = 'section-boundary-v1';
28
34
 
29
35
  export function browserFn() {
30
36
  return ({ params }) => {
37
+ const RULE = 'section-breathing';
38
+ const REVISION = 'section-boundary-v1';
31
39
  const P = Object.assign({
32
- FLOOR: 32, CAP: 64, MIN_TARGET: 48, MAX_TARGET: 192, COVER: 0.8,
33
- TIGHT_GAP: 32, SEMANTIC_MAX_GAP: 32, SEMANTIC_WIDTH: 0.8, PREVIOUS_BLOCK_WIDTH: 0.5,
40
+ FLOOR: 32, CAP: 64, MIN_TARGET: 48, MAX_TARGET: 192,
41
+ TIGHT_GAP: 32, SEMANTIC_MAX_GAP: 32,
42
+ PERCEPTIBILITY_FLOOR: 8,
43
+ SCOPE_WIDTH_FRACTION: 0.4,
44
+ STRUCTURAL_MIN_INK: 2,
45
+ DIAGRAM_MIN_CHILDREN: 4,
46
+ DIAGRAM_MAX_ALIGN_FRACTION: 0.5,
47
+ DIAGRAM_MIN_WIDTH_CV: 0.6,
48
+ DIAGRAM_EDGE_TOLERANCE: 4,
34
49
  }, params || {});
35
50
  const out = [];
36
51
  const visible = (el) => {
37
- const s = getComputedStyle(el);
38
- if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false;
39
- const r = el.getBoundingClientRect();
40
- return r.width > 0 && r.height > 0;
52
+ if (!el) return false;
53
+ const style = getComputedStyle(el); const rect = el.getBoundingClientRect();
54
+ return style.display !== 'none' && style.visibility !== 'hidden'
55
+ && Number.parseFloat(style.opacity) !== 0 && rect.width > 0 && rect.height > 0;
41
56
  };
42
57
  const alphaOf = (color) => {
43
- const m = color && color.match(/^rgba?\(([^)]+)\)$/);
44
- if (!m) return color === 'transparent' ? 0 : 1;
45
- const p = m[1].split(/[, /]+/).filter(Boolean).map(Number);
46
- return p.length > 3 ? p[3] : 1;
58
+ const match = color && color.match(/^rgba?\(([^)]+)\)$/);
59
+ if (!match) return color === 'transparent' ? 0 : 1;
60
+ const parts = match[1].split(/[, /]+/).filter(Boolean).map(Number);
61
+ return parts.length > 3 ? parts[3] : 1;
47
62
  };
48
- const groundClass = (style, el) => {
49
- if (['IMG', 'PICTURE', 'VIDEO'].includes(el.tagName)) return 'image';
50
- const img = style.backgroundImage;
51
- if (/repeating-(?:linear|radial|conic)-gradient\(/.test(img)) return 'texture';
52
- if (/(?:linear|radial|conic)-gradient\(/.test(img)) return 'gradient';
53
- if (img !== 'none') return style.backgroundRepeat === 'no-repeat' ? 'image' : 'texture';
54
- return alphaOf(style.backgroundColor) > 0.01 ? 'solid' : 'transparent';
55
- };
56
- const INK = '.text, .paragraph, h1, h2, h3, h4, h5, h6, .icon, img, .image, button, .button';
57
- const median = (v) => {
58
- const s = [...v].sort((a, b) => a - b); const m = Math.floor(s.length / 2);
59
- return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
60
- };
61
- const ceil8 = (v) => Math.ceil(v / 8) * 8;
62
63
  const sections = [...document.querySelectorAll('section[id^="viewport-"]')]
63
- .filter((s) => /^viewport-\d+-[A-Za-z0-9]+$/.test(s.id) && visible(s))
64
- .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
65
- if (!sections.length) return out;
66
-
67
- const validSolidOwner = (o, section, edge = null) => {
68
- if (!o) return { ok: false, reason: 'ground-owner-unproven' };
69
- const sr = section.getBoundingClientRect();
70
- if (o.gc !== 'solid') return { ok: false, reason: `${o.gc}-ground (experimental class, abstain)` };
71
- if (Math.abs(o.rect.left - sr.left) > 0.75 || Math.abs(o.rect.right - sr.right) > 0.75) {
72
- return { ok: false, reason: 'ground-does-not-own-full-seam' };
73
- }
74
- if (edge && (edge === 'bottom' ? Math.abs(o.rect.bottom - sr.bottom) : Math.abs(o.rect.top - sr.top)) > 0.75) {
75
- return { ok: false, reason: 'ground-does-not-touch-edge' };
76
- }
77
- const cs = o.cs;
78
- if (cs.boxShadow !== 'none' || cs.clipPath !== 'none' || cs.maskImage !== 'none') {
79
- return { ok: false, reason: 'owner-has-shadow-clip-or-mask' };
80
- }
81
- if (/var\(/.test(cs.backgroundColor) || alphaOf(cs.backgroundColor) < 0.999) {
82
- return { ok: false, reason: 'transparent-or-unresolved-solid' };
83
- }
84
- if (!o.el.id) return { ok: false, reason: 'owner-has-no-id' };
85
- return { ok: true, owner: o.el, color: cs.backgroundColor };
64
+ .filter((section) => /^viewport-\d+-[A-Za-z0-9]+$/.test(section.id) && visible(section))
65
+ .sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
66
+ const sectionIndex = new Map(sections.map((section, index) => [section, index]));
67
+ const declarations = [...document.querySelectorAll('[data-ly-section-boundary="before"]')].filter(visible);
68
+ const INK_SELECTOR = '.text,.paragraph,h1,h2,h3,h4,h5,h6,.icon,img,.image,button,.button';
69
+ const solidGround = (element) => {
70
+ const style = getComputedStyle(element);
71
+ return style.backgroundImage === 'none'
72
+ && alphaOf(style.backgroundColor) >= 0.999
73
+ && !/var\(/.test(style.backgroundColor)
74
+ && style.clipPath === 'none' && style.maskImage === 'none';
86
75
  };
87
- const ownerAt = (section, edge) => {
88
- const sr = section.getBoundingClientRect(); const y = edge === 'bottom' ? sr.bottom - 1 : sr.top + 1;
89
- const cands = [section, ...section.querySelectorAll('*')].flatMap((el) => {
90
- if (!visible(el)) return [];
91
- const r = el.getBoundingClientRect();
92
- if (!(r.top <= y && r.bottom >= y) || r.width < sr.width * P.COVER) return [];
93
- const cs = getComputedStyle(el); const gc = groundClass(cs, el);
94
- if (gc === 'transparent') return [];
95
- const cls = typeof el.className === 'string' ? el.className : '';
96
- if (['IMG', 'PICTURE', 'VIDEO'].includes(el.tagName) && /(?:^|\s)(?:decoration|icon|logo|badge|photo|image)(?:\s|$)/.test(cls)) return [];
97
- return [{ el, rect: r, area: r.width * r.height, gc, cs }];
98
- }).sort((a, b) => a.area - b.area || String(a.el.id).localeCompare(String(b.el.id)));
99
- return cands[0] || null;
76
+ // Leaf ink: an ink element that contains no ink of its own. Counting leaves rather than
77
+ // nodes is what keeps label-to-title line leading inside a card from reading as page air.
78
+ const leafInk = (element) => [...element.querySelectorAll(INK_SELECTOR)].filter(visible)
79
+ .filter((node) => ![...node.querySelectorAll(INK_SELECTOR)].some(visible)).length
80
+ || (element.matches(INK_SELECTOR) ? 1 : 0);
81
+ const stackedChildren = (element) => [...element.children]
82
+ .filter((child) => visible(child) && child.id)
83
+ .sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
84
+ // Consecutive vertical air inside one container. Children that overlap vertically are one
85
+ // run (side-by-side columns), so a gap is page air rather than a column edge.
86
+ const airRuns = (element) => {
87
+ const children = stackedChildren(element);
88
+ const runs = [];
89
+ let bottom = null; let previous = null;
90
+ for (const child of children) {
91
+ const rect = child.getBoundingClientRect();
92
+ if (bottom === null) { bottom = rect.bottom; previous = child; }
93
+ else {
94
+ const gap = rect.top - bottom;
95
+ if (gap > -0.5) { runs.push({ before: previous, after: child, gap, y: bottom }); previous = child; }
96
+ bottom = Math.max(bottom, rect.bottom);
97
+ }
98
+ }
99
+ return runs;
100
100
  };
101
- const fullSectionOwner = (section) => {
102
- const sr = section.getBoundingClientRect();
103
- const cands = [section, ...section.querySelectorAll('*')].flatMap((el) => {
104
- if (!visible(el)) return [];
105
- const r = el.getBoundingClientRect();
106
- if (r.left > sr.left + 0.75 || r.right < sr.right - 0.75 || r.top > sr.top + 0.75 || r.bottom < sr.bottom - 0.75) return [];
107
- const cs = getComputedStyle(el); const gc = groundClass(cs, el);
108
- if (gc === 'transparent') return [];
109
- return [{ el, rect: r, area: r.width * r.height, gc, cs }];
110
- }).sort((a, b) => a.area - b.area || String(a.el.id).localeCompare(String(b.el.id)));
111
- return validSolidOwner(cands[0] || null, section);
101
+ // The page's own spacing scale, from every structural gap at every nesting depth.
102
+ // Structural = the container spans a real share of the page AND both sides carry ink.
103
+ const pageScaleTarget = () => {
104
+ const pageWidth = Math.max(0, ...sections.map((section) => section.getBoundingClientRect().width));
105
+ const gaps = [];
106
+ const walk = (element, depth) => {
107
+ if (depth > 6) return;
108
+ const rect = element.getBoundingClientRect();
109
+ if (rect.width >= P.SCOPE_WIDTH_FRACTION * pageWidth) {
110
+ for (const run of airRuns(element)) {
111
+ if (leafInk(run.before) >= P.STRUCTURAL_MIN_INK && leafInk(run.after) >= P.STRUCTURAL_MIN_INK) {
112
+ gaps.push(run.gap);
113
+ }
114
+ }
115
+ }
116
+ for (const child of stackedChildren(element)) walk(child, depth + 1);
117
+ };
118
+ for (const section of sections) walk(section, 1);
119
+ if (!gaps.length) return { target: null, gaps: 0, median: null, largest: null };
120
+ const sorted = [...gaps].sort((left, right) => left - right);
121
+ const median = sorted.length % 2
122
+ ? sorted[(sorted.length - 1) / 2]
123
+ : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2;
124
+ const largest = sorted[sorted.length - 1];
125
+ return { target: Math.min(largest, 2 * median), gaps: gaps.length, median, largest };
112
126
  };
113
- const inkEdge = (section, edge) => {
114
- const sr = section.getBoundingClientRect();
115
- const ink = [...section.querySelectorAll(INK)].filter(visible).map((el) => el.getBoundingClientRect());
116
- if (!ink.length) return null;
117
- return edge === 'bottom' ? Math.max(...ink.map((r) => r.bottom)) - sr.top : Math.min(...ink.map((r) => r.top)) - sr.top;
127
+ // The page's real air line at a seam: the bottom of the lowest peer that ends above the cut.
128
+ // The declared `before` is only one candidate; a floating note card or an intro column that
129
+ // outlives it is what the eye actually reads the air from.
130
+ const realAirLine = (group, after) => {
131
+ const cut = after.getBoundingClientRect().top;
132
+ const peers = [...group.children].filter((child) => child !== after && visible(child) && child.id)
133
+ .filter((child) => child.getBoundingClientRect().bottom <= cut + 0.5);
134
+ if (!peers.length) return null;
135
+ const anchor = peers.reduce((lowest, child) => (
136
+ child.getBoundingClientRect().bottom > lowest.getBoundingClientRect().bottom ? child : lowest
137
+ ));
138
+ return { anchor, peers, gap: cut - anchor.getBoundingClientRect().bottom };
118
139
  };
119
- const directChildOf = (section, el) => {
120
- let child = el;
121
- while (child.parentElement && child.parentElement !== section) child = child.parentElement;
122
- return child.parentElement === section ? child : null;
140
+ // A group whose stacked children share no common left/right/centre edge and whose widths vary
141
+ // wildly is a drawing, not a stack: its vertical gaps carry meaning and must not be opened.
142
+ const diagramLike = (element) => {
143
+ const runs = airRuns(element);
144
+ if (!runs.length) return false;
145
+ const stacked = [runs[0].before, ...runs.map((run) => run.after)];
146
+ if (stacked.length < P.DIAGRAM_MIN_CHILDREN) return false;
147
+ const boxes = stacked.map((child) => child.getBoundingClientRect());
148
+ const largestCluster = (values) => {
149
+ const buckets = new Map();
150
+ for (const value of values) {
151
+ const key = Math.round(value / P.DIAGRAM_EDGE_TOLERANCE);
152
+ buckets.set(key, (buckets.get(key) || 0) + 1);
153
+ }
154
+ return Math.max(...buckets.values());
155
+ };
156
+ const alignFraction = Math.max(
157
+ largestCluster(boxes.map((box) => box.left)),
158
+ largestCluster(boxes.map((box) => box.right)),
159
+ largestCluster(boxes.map((box) => (box.left + box.right) / 2)),
160
+ ) / boxes.length;
161
+ const widths = boxes.map((box) => box.width);
162
+ const mean = widths.reduce((sum, width) => sum + width, 0) / widths.length;
163
+ if (!(mean > 0.5)) return false;
164
+ const widthCv = Math.sqrt(widths.reduce((sum, width) => sum + (width - mean) ** 2, 0) / widths.length) / mean;
165
+ return alignFraction < P.DIAGRAM_MAX_ALIGN_FRACTION && widthCv >= P.DIAGRAM_MIN_WIDTH_CV;
123
166
  };
124
- const attachmentsAt = (section, seamY) => {
125
- const cands = [...section.querySelectorAll('.decoration[id]')].filter(visible).filter((el) => {
126
- const r = el.getBoundingClientRect();
127
- return r.top < seamY && r.bottom >= seamY - 0.75 && r.width < section.getBoundingClientRect().width * P.COVER;
128
- });
129
- return cands.filter((el) => !cands.some((other) => other !== el && el.contains(other))).map((el) => {
130
- const r = el.getBoundingClientRect();
131
- return { el, topOffset: +(r.top - seamY).toFixed(2), bottomOffset: +(r.bottom - seamY).toFixed(2) };
132
- });
167
+ // Air inserted strictly inside a container is painted by that container's own background, so a
168
+ // seam below the section's direct children needs no new ownership proof — only a solid enclosing
169
+ // ground that already spans the group and already continues past the cut.
170
+ const enclosingPainter = (group, after) => {
171
+ const groupRect = group.getBoundingClientRect();
172
+ const cut = after.getBoundingClientRect().top;
173
+ for (let node = group; node; node = node.parentElement) {
174
+ if (solidGround(node)) {
175
+ const rect = node.getBoundingClientRect();
176
+ if (rect.left <= groupRect.left + 0.75 && rect.right >= groupRect.right - 0.75
177
+ && rect.top <= cut - 0.75 && rect.bottom >= cut + 0.75) return node;
178
+ }
179
+ if (node === group.closest('section[id^="viewport-"]')) break;
180
+ }
181
+ return null;
133
182
  };
134
-
135
- const viewportSeams = [];
136
- for (let i = 1; i < sections.length; i++) {
137
- const before = sections[i - 1], after = sections[i];
138
- const br = before.getBoundingClientRect(), ar = after.getBoundingClientRect();
139
- const lastInk = inkEdge(before, 'bottom'), firstInk = inkEdge(after, 'top');
140
- if (lastInk == null || firstInk == null) continue;
141
- const gap = (br.height - lastInk) + (ar.top - br.bottom) + firstInk;
142
- if (gap < 0) continue;
143
- viewportSeams.push({ kind: 'viewport', before, after, section: before, gap });
144
- }
145
- const target = viewportSeams.length === 1
146
- ? viewportSeams[0].gap + P.FLOOR
147
- : viewportSeams.length
148
- ? Math.max(P.MIN_TARGET, Math.min(P.MAX_TARGET, ceil8(median(viewportSeams.map((s) => s.gap)) + P.FLOOR)))
149
- : null;
150
- const viewportPlans = viewportSeams.map((s) => {
151
- const raw = Math.round(Math.min(P.CAP, Math.max(0, target - s.gap)));
152
- const tightScaled = raw > 0 && raw < P.FLOOR && s.gap < P.TIGHT_GAP;
153
- const addition = tightScaled ? P.FLOOR : raw;
154
- if (addition < P.FLOOR) {
155
- return { ...s, addition, declined: addition > 0 ? 'below the 32 px perceptibility floor' : 'already at the page rhythm target' };
183
+ const scale = pageScaleTarget();
184
+ const selectorValue = (value) => window.CSS?.escape
185
+ ? CSS.escape(value) : value.replace(/(["\\])/g, '\\$1');
186
+ const refusal = (after, reason, extra = {}) => ({
187
+ kind: after.matches('section[id^="viewport-"]') ? 'viewport' : 'semantic',
188
+ section: (after.closest('section[id^="viewport-"]') || after).id || null,
189
+ before: after.dataset.lySectionBefore || null,
190
+ after: after.id || null,
191
+ fact: after.dataset.lySectionFact || null,
192
+ gap: null, addition: 0, declared: true, declined: reason,
193
+ pageScale: scale.target == null ? null : {
194
+ target: +scale.target.toFixed(1), median: +scale.median.toFixed(1),
195
+ largest: +scale.largest.toFixed(1), structuralGaps: scale.gaps,
196
+ },
197
+ ground: { reason }, ...extra,
198
+ });
199
+ const validate = (after) => {
200
+ if (after.dataset.lySectionBoundaryRevision !== REVISION) {
201
+ return { ok: false, census: refusal(after, 'section-boundary-stale') };
156
202
  }
157
- const tryOwner = (section, edge) => {
158
- const valid = validSolidOwner(ownerAt(section, edge), section, edge);
159
- return valid.ok ? { ...valid, side: edge === 'bottom' ? 'outgoing' : 'incoming' } : valid;
203
+ if (!after.id) return { ok: false, census: refusal(after, 'section-boundary-after-unidentified') };
204
+ const beforeId = after.dataset.lySectionBefore || '';
205
+ const ownerKey = after.dataset.lySectionOwner || '';
206
+ const before = beforeId ? document.getElementById(beforeId) : null;
207
+ const owners = ownerKey
208
+ ? [...document.querySelectorAll(`[data-ly-section-ground-owner="${selectorValue(ownerKey)}"]`)] : [];
209
+ if (!before) return { ok: false, census: refusal(after, 'section-boundary-before-missing') };
210
+ if (owners.length !== 1) return {
211
+ ok: false,
212
+ census: refusal(after, owners.length ? 'section-boundary-owner-ambiguous' : 'section-boundary-owner-missing'),
160
213
  };
161
- const outgoing = tryOwner(s.before, 'bottom'); const ground = outgoing.ok ? outgoing : tryOwner(s.after, 'top');
162
- const seamY = s.after.getBoundingClientRect().top;
214
+ if (after.dataset.lySectionOpenability === 'not-openable') {
215
+ return { ok: false, census: refusal(after, 'declared-not-openable', { before: before.id }) };
216
+ }
217
+ if (after.dataset.lySectionOpenability !== 'openable'
218
+ || after.dataset.lySectionContinuation !== 'owner-fill') {
219
+ return { ok: false, census: refusal(after, 'section-boundary-continuation-invalid', { before: before.id }) };
220
+ }
221
+ const owner = owners[0];
222
+ const afterSection = after.matches('section[id^="viewport-"]') ? after : after.closest('section[id^="viewport-"]');
223
+ const beforeSection = before.matches('section[id^="viewport-"]') ? before : before.closest('section[id^="viewport-"]');
224
+ const kind = after.matches('section[id^="viewport-"]') ? 'viewport' : 'semantic';
225
+ if (!afterSection || !beforeSection) return { ok: false, census: refusal(after, 'section-boundary-outside-viewport') };
226
+ let group = null; let painter = null;
227
+ if (kind === 'viewport') {
228
+ if (sectionIndex.get(afterSection) !== sectionIndex.get(beforeSection) + 1) {
229
+ return { ok: false, census: refusal(after, 'section-boundary-not-adjacent', { before: before.id }) };
230
+ }
231
+ } else {
232
+ // A semantic seam is air between two siblings. The pair may sit at any depth inside the
233
+ // section, not only among its direct children, provided the group they share is enclosed
234
+ // by ground that already paints the cut (see enclosingPainter).
235
+ if (afterSection !== beforeSection || after.parentElement !== before.parentElement) {
236
+ return { ok: false, census: refusal(after, 'section-boundary-crosses-owner-section', { before: before.id }) };
237
+ }
238
+ group = after.parentElement;
239
+ if (group !== afterSection && !afterSection.contains(group)) {
240
+ return { ok: false, census: refusal(after, 'section-boundary-outside-viewport', { before: before.id }) };
241
+ }
242
+ if (diagramLike(group)) {
243
+ return { ok: false, census: refusal(after, 'declared-inside-diagram-group', { before: before.id }) };
244
+ }
245
+ if (group !== afterSection) {
246
+ painter = enclosingPainter(group, after);
247
+ if (!painter) {
248
+ return { ok: false, census: refusal(after, 'nested-seam-ground-unpainted', { before: before.id }) };
249
+ }
250
+ // The painter supplies the paint proof; the declared owner still bounds WHERE the model
251
+ // authorised air, so the painter must lie inside that ground — by nesting or by covering.
252
+ const ownerBox = owner.getBoundingClientRect();
253
+ const painterBox = painter.getBoundingClientRect();
254
+ const insideOwner = owner === painter || owner.contains(painter)
255
+ || (ownerBox.left <= painterBox.left + 0.75 && ownerBox.right >= painterBox.right - 0.75
256
+ && ownerBox.top <= painterBox.top + 0.75 && ownerBox.bottom >= painterBox.bottom - 0.75);
257
+ if (!insideOwner) {
258
+ return { ok: false, census: refusal(after, 'nested-seam-ground-outside-owner', { before: before.id }) };
259
+ }
260
+ }
261
+ }
262
+ if (!(owner.contains(afterSection) || afterSection.contains(owner))) {
263
+ return { ok: false, census: refusal(after, 'section-boundary-owner-disconnected', { before: before.id }) };
264
+ }
265
+ // A nested seam's ground is the enclosing painter, already proven solid and already spanning
266
+ // the cut, so the section-scale owner geometry below does not apply to it.
267
+ if (painter) {
268
+ return {
269
+ ok: true, kind, section: afterSection, group, painter, before, after, owner, ownerKey,
270
+ color: getComputedStyle(painter).backgroundColor, fact: after.dataset.lySectionFact || null,
271
+ execution: 'group-shift', ownerChildren: [],
272
+ };
273
+ }
274
+ const ownerStyle = getComputedStyle(owner);
275
+ const ownerRect = owner.getBoundingClientRect();
276
+ const sectionRect = afterSection.getBoundingClientRect();
277
+ const ownerIsAfter = owner === after;
278
+ const fullWidth = ownerRect.left <= sectionRect.left + 0.75
279
+ && ownerRect.right >= sectionRect.right - 0.75;
280
+ const coversSection = fullWidth && ownerRect.top <= sectionRect.top + 0.75
281
+ && ownerRect.bottom >= sectionRect.bottom - 0.75;
282
+ if (!(ownerIsAfter ? fullWidth : coversSection)) {
283
+ return { ok: false, census: refusal(after, ownerIsAfter
284
+ ? 'declared-owner-not-full-width' : 'declared-owner-does-not-cover-section', { before: before.id }) };
285
+ }
286
+ const alpha = alphaOf(ownerStyle.backgroundColor);
287
+ const solid = ownerStyle.backgroundImage === 'none'
288
+ && alpha >= (ownerIsAfter ? 0.01 : 0.999)
289
+ && !/var\(/.test(ownerStyle.backgroundColor);
290
+ // owner-fill never stretches raster/image/texture owners. Unsupported
291
+ // continuation programs refuse this region explicitly.
292
+ if (!solid) return { ok: false, census: refusal(after, 'declared-owner-fill-unverifiable', { before: before.id }) };
293
+ if (ownerStyle.clipPath !== 'none' || ownerStyle.maskImage !== 'none') {
294
+ return { ok: false, census: refusal(after, 'declared-owner-clipped-or-masked', { before: before.id }) };
295
+ }
296
+ const ownerChildren = ownerIsAfter ? [...owner.children].filter(visible) : [];
297
+ if (ownerIsAfter && (!ownerChildren.length || ownerChildren.some((child) => !child.id))) {
298
+ return { ok: false, census: refusal(after, 'declared-owner-children-unidentified', { before: before.id }) };
299
+ }
163
300
  return {
164
- ...s, addition, tightScaled, ground,
165
- diagnostics: outgoing.ok ? null : { outgoing: outgoing.reason, incoming: ground.reason },
166
- attachments: attachmentsAt(s.before, seamY),
301
+ ok: true, kind, section: afterSection, group: group || afterSection, painter: null,
302
+ before, after, owner, ownerKey,
303
+ color: ownerStyle.backgroundColor, fact: after.dataset.lySectionFact || null,
304
+ execution: ownerIsAfter ? 'owner-leading-air' : 'section-shift', ownerChildren,
167
305
  };
168
- });
169
-
170
- const semanticPlans = [];
171
- for (const section of sections) {
172
- const sr = section.getBoundingClientRect(); const children = [...section.children].filter(visible);
173
- const ground = fullSectionOwner(section);
174
- const semantic = children.flatMap((el) => {
175
- const id = el.id.toLowerCase(); const r = el.getBoundingClientRect();
176
- const fact = el.tagName === 'FOOTER' ? 'footer'
177
- : /(?:^|[-_])(?:faq|questions)(?:[-_]|$)/.test(id) ? 'questions'
178
- : /(?:^|[-_])partners(?:[-_]|$)/.test(id) ? 'partners' : null;
179
- return fact && el.id && r.width >= sr.width * P.SEMANTIC_WIDTH ? [{ el, fact, rect: r }] : [];
180
- }).sort((a, b) => a.rect.top - b.rect.top);
181
- for (const candidate of semantic) {
182
- const eligible = children.filter((el) => {
183
- if (el === candidate.el || el === ground.owner || !el.id) return false;
184
- const r = el.getBoundingClientRect();
185
- const fullSection = r.top <= sr.top + 0.75 && r.bottom >= sr.bottom - 0.75;
186
- return !fullSection && r.width >= sr.width * P.PREVIOUS_BLOCK_WIDTH && r.bottom <= candidate.rect.top + 0.75;
187
- }).sort((a, b) => b.getBoundingClientRect().bottom - a.getBoundingClientRect().bottom);
188
- const before = eligible[0]; if (!before) continue;
189
- const br = before.getBoundingClientRect(); const gap = candidate.rect.top - br.bottom;
190
- if (gap < 0) continue;
191
- // Moving a full-width semantic block deliberately changes its vertical relation to every substantial direct
192
- // block before it (including unequal-height columns). Declare that exact target relation; unrelated sibling
193
- // gaps remain protected by the harm guard.
194
- const beforePeers = children.filter((el) => {
195
- if (el === candidate.el || el === ground.owner || !el.id) return false;
196
- const r = el.getBoundingClientRect();
197
- const fullSection = r.top <= sr.top + 0.75 && r.bottom >= sr.bottom - 0.75;
198
- return !fullSection && r.bottom <= candidate.rect.top + 0.75;
199
- }).map((el) => el.id);
200
- const dense = [before.id, candidate.el.id].some((id) => /(?:^|[-_])dashboard(?:[-_]|$)/.test(id.toLowerCase()));
201
- const addition = ground.ok && !dense && gap < P.SEMANTIC_MAX_GAP ? P.FLOOR : 0;
202
- semanticPlans.push({
203
- kind: 'semantic', section, before, beforePeers, after: candidate.el, fact: candidate.fact, gap,
204
- addition, ground,
205
- declined: !ground.ok ? ground.reason
206
- : dense ? 'dense dashboard boundary stays tight'
207
- : addition ? null : 'semantic boundary already has at least 32 px',
208
- });
306
+ };
307
+ const inkEdge = (section, edge) => {
308
+ const sectionRect = section.getBoundingClientRect();
309
+ const ink = [...section.querySelectorAll('.text,.paragraph,h1,h2,h3,h4,h5,h6,.icon,img,.image,button,.button')]
310
+ .filter(visible).map((element) => element.getBoundingClientRect());
311
+ if (!ink.length) return null;
312
+ return edge === 'bottom'
313
+ ? Math.max(...ink.map((rect) => rect.bottom)) - sectionRect.top
314
+ : Math.min(...ink.map((rect) => rect.top)) - sectionRect.top;
315
+ };
316
+ const valid = []; const census = [];
317
+ for (const after of declarations) {
318
+ const declaration = validate(after);
319
+ if (!declaration.ok) { census.push(declaration.census); continue; }
320
+ let gap;
321
+ if (declaration.kind === 'viewport') {
322
+ const beforeRect = declaration.before.getBoundingClientRect();
323
+ const afterRect = declaration.after.getBoundingClientRect();
324
+ const lastInk = inkEdge(declaration.before, 'bottom');
325
+ const firstInk = inkEdge(declaration.after, 'top');
326
+ if (lastInk == null || firstInk == null) {
327
+ census.push(refusal(after, 'section-boundary-ink-unmeasurable', { before: declaration.before.id }));
328
+ continue;
329
+ }
330
+ gap = (beforeRect.height - lastInk) + (afterRect.top - beforeRect.bottom) + firstInk;
331
+ } else {
332
+ // Measure the air the page actually shows at this seam, not the distance between the two
333
+ // declared boxes: a floating card or an intro column that outlives `before` is what the
334
+ // eye reads the air from, and it is what the inserted band will actually sit under.
335
+ const air = realAirLine(declaration.group, declaration.after);
336
+ if (!air) {
337
+ census.push(refusal(after, 'section-boundary-air-unmeasurable', { before: declaration.before.id }));
338
+ continue;
339
+ }
340
+ gap = air.gap;
341
+ declaration.airAnchor = air.anchor;
342
+ declaration.airPeers = air.peers;
343
+ }
344
+ if (!Number.isFinite(gap) || gap < 0) {
345
+ census.push(refusal(after, 'section-boundary-gap-invalid', { before: declaration.before.id, gap }));
346
+ continue;
209
347
  }
348
+ valid.push({ ...declaration, gap });
210
349
  }
211
-
212
- const censusRow = (p) => ({
213
- kind: p.kind, section: p.section.id, before: p.before.id, after: p.after.id, fact: p.fact || null,
214
- gap: +p.gap.toFixed(1), addition: p.addition, declined: p.declined || null,
215
- tightScaled: p.tightScaled || false,
216
- attachments: (p.attachments || []).map((a) => a.el.id),
217
- ground: p.ground ? (p.ground.ok ? { side: p.ground.side || 'internal', color: p.ground.color, owner: p.ground.owner.id } : (p.diagnostics || { reason: p.ground.reason })) : null,
350
+ const viewport = valid.filter((row) => row.kind === 'viewport');
351
+ const viewportGaps = viewport.map((row) => row.gap).sort((a, b) => a - b);
352
+ const medianGap = viewportGaps.length % 2
353
+ ? viewportGaps[Math.floor(viewportGaps.length / 2)]
354
+ : viewportGaps.length ? (viewportGaps[viewportGaps.length / 2 - 1] + viewportGaps[viewportGaps.length / 2]) / 2 : null;
355
+ const viewportTarget = viewport.length === 1 ? viewport[0].gap + P.FLOOR
356
+ : viewport.length ? Math.max(P.MIN_TARGET, Math.min(P.MAX_TARGET, Math.ceil((medianGap + P.FLOOR) / 8) * 8)) : null;
357
+ const plans = valid.map((row) => {
358
+ if (row.kind !== 'viewport') {
359
+ // Page-rhythm model: bring an under-scale seam up to what this page already does elsewhere,
360
+ // and never invent an amount the page has not itself used. addition = max(0, T - gap).
361
+ const floor = P.PERCEPTIBILITY_FLOOR;
362
+ const addition = scale.target == null ? 0
363
+ : +Math.min(P.CAP, Math.max(0, scale.target - row.gap)).toFixed(1);
364
+ const capped = scale.target != null && scale.target - row.gap > P.CAP;
365
+ const declined = scale.target == null ? 'page has no structural gap to set a scale from'
366
+ : addition < floor
367
+ ? (addition > 0 ? `below the ${floor} px perceptibility floor`
368
+ : 'already at the page rhythm target')
369
+ : null;
370
+ census.push({
371
+ kind: row.kind, section: row.section.id, group: row.group?.id || row.section.id,
372
+ before: row.before.id, airAnchor: row.airAnchor?.id || null, after: row.after.id,
373
+ fact: row.fact, gap: +row.gap.toFixed(1), addition, capped, declared: true, declined,
374
+ pageScale: scale.target == null ? null : {
375
+ target: +scale.target.toFixed(1), median: +scale.median.toFixed(1),
376
+ largest: +scale.largest.toFixed(1), structuralGaps: scale.gaps,
377
+ },
378
+ ground: { owner: row.ownerKey, color: row.color, continuation: 'owner-fill', execution: row.execution },
379
+ });
380
+ return { ...row, addition, capped, declined };
381
+ }
382
+ const raw = Math.round(Math.min(P.CAP, Math.max(0, viewportTarget - row.gap)));
383
+ const tightScaled = raw > 0 && raw < P.FLOOR && row.gap < P.TIGHT_GAP;
384
+ const addition = tightScaled ? P.FLOOR : raw;
385
+ const declined = addition < P.FLOOR
386
+ ? (addition > 0 ? 'below the 32 px perceptibility floor'
387
+ : 'already at the page rhythm target')
388
+ : null;
389
+ census.push({
390
+ kind: row.kind, section: row.section.id, before: row.before.id, after: row.after.id,
391
+ fact: row.fact, gap: +row.gap.toFixed(1), addition, tightScaled, declared: true, declined,
392
+ ground: { owner: row.ownerKey, color: row.color, continuation: 'owner-fill', execution: row.execution },
393
+ });
394
+ return { ...row, addition, tightScaled, declined };
218
395
  });
219
- if (P.census) return [...viewportPlans, ...semanticPlans].map(censusRow);
220
-
221
- const admittedViewport = viewportPlans.filter((p) => !p.declined);
222
- const admittedSemantic = semanticPlans.filter((p) => !p.declined);
223
- const admitted = [...admittedViewport, ...admittedSemantic];
224
- if (!admitted.length || admitted.some((p) => !p.ground?.ok)) return out;
225
-
226
- const sectionShift = new Map(sections.map((s) => [s.id, 0]));
227
- const sectionGrowth = new Map(sections.map((s) => [s.id, 0]));
396
+ if (P.census) return census;
397
+ const admitted = plans.filter((plan) => !plan.declined);
398
+ if (!admitted.length) return out;
399
+ const sectionShift = new Map(sections.map((section) => [section.id, 0]));
400
+ const sectionGrowth = new Map(sections.map((section) => [section.id, 0]));
401
+ const ownerGrowth = new Map();
402
+ const containerGrowth = new Map();
228
403
  const childShift = new Map();
229
- const sectionIndex = new Map(sections.map((s, i) => [s.id, i]));
230
- for (const p of admittedSemantic) {
231
- sectionGrowth.set(p.section.id, sectionGrowth.get(p.section.id) + p.addition);
232
- const tr = p.after.getBoundingClientRect();
233
- for (const child of [...p.section.children].filter(visible)) {
234
- if (child.id && child.getBoundingClientRect().top >= tr.top - 0.75) childShift.set(child.id, (childShift.get(child.id) || 0) + p.addition);
404
+ for (const plan of admitted.filter((row) => row.kind === 'semantic')) {
405
+ sectionGrowth.set(plan.section.id, sectionGrowth.get(plan.section.id) + plan.addition);
406
+ if (plan.owner !== plan.section && plan.section.contains(plan.owner) && plan.owner.id) {
407
+ ownerGrowth.set(plan.owner.id, (ownerGrowth.get(plan.owner.id) || 0) + plan.addition);
408
+ }
409
+ if (plan.execution === 'owner-leading-air') {
410
+ for (const child of plan.ownerChildren) {
411
+ childShift.set(child.id, (childShift.get(child.id) || 0) + plan.addition);
412
+ }
413
+ } else {
414
+ // Everything at or below the cut inside the seam's own group moves down; every container
415
+ // between that group and the section grows by the same amount so nothing is clipped.
416
+ const afterTop = plan.after.getBoundingClientRect().top;
417
+ for (const child of [...plan.group.children].filter(visible)) {
418
+ if (child.id && child.getBoundingClientRect().top >= afterTop - 0.75) {
419
+ childShift.set(child.id, (childShift.get(child.id) || 0) + plan.addition);
420
+ }
421
+ }
422
+ for (let node = plan.group; node && node !== plan.section; node = node.parentElement) {
423
+ if (node.id) containerGrowth.set(node.id, (containerGrowth.get(node.id) || 0) + plan.addition);
424
+ }
235
425
  }
236
426
  }
237
- for (const p of admittedViewport) {
238
- for (let i = sectionIndex.get(p.after.id); i < sections.length; i++) sectionShift.set(sections[i].id, sectionShift.get(sections[i].id) + p.addition);
239
- }
240
-
241
- for (const p of admittedViewport) {
242
- const dy = p.ground.side === 'outgoing' ? p.addition : -p.addition;
243
- const br = p.before.getBoundingClientRect();
244
- const attachments = p.attachments.map((a) => ({ id: a.el.id, topOffset: a.topOffset, bottomOffset: a.bottomOffset }));
427
+ for (const plan of admitted.filter((row) => row.kind === 'viewport')) {
428
+ for (let cursor = sectionIndex.get(plan.after); cursor < sections.length; cursor += 1) {
429
+ sectionShift.set(sections[cursor].id, sectionShift.get(sections[cursor].id) + plan.addition);
430
+ }
245
431
  out.push({
246
- id: p.ground.owner.id, rule: 'section-breathing',
247
- reason: `viewport seam ${p.before.id}→${p.after.id}: gap ${p.gap.toFixed(0)} + ${p.addition} px, solid ${p.ground.color} extended (${p.ground.side})`,
248
- css: { 'box-shadow': `0 ${dy}px 0 0 ${p.ground.color}` },
432
+ id: plan.before.id, rule: RULE,
433
+ reason: `declared viewport boundary ${plan.before.id}→${plan.after.id}: +${plan.addition} px`,
434
+ css: { 'box-shadow': `0 ${plan.addition}px 0 0 ${plan.color}` },
249
435
  meta: { seam: {
250
- kind: p.kind, before: p.before.id, after: p.after.id, gap: +p.gap.toFixed(1), baseStripHeight: 0,
251
- addition: p.addition, color: p.ground.color, side: p.ground.side, owner: p.ground.owner.id,
252
- attachments, seamTop: br.bottom + window.scrollY + sectionShift.get(p.before.id) + sectionGrowth.get(p.before.id),
436
+ kind: 'viewport', before: plan.before.id, after: plan.after.id,
437
+ gap: +plan.gap.toFixed(1), baseStripHeight: 0, addition: plan.addition,
438
+ color: plan.color, side: 'outgoing', owner: plan.ownerKey, attachments: [],
439
+ seamTop: plan.before.getBoundingClientRect().bottom + window.scrollY,
253
440
  } },
254
441
  });
255
- for (const a of p.attachments) {
256
- const direct = directChildOf(p.before, a.el); const inherited = direct ? (childShift.get(direct.id) || 0) : 0;
257
- const travel = p.addition + sectionGrowth.get(p.before.id) - inherited;
258
- if (travel) out.push({ id: a.el.id, rule: 'section-breathing', reason: `declared decoration travels ${travel} px with ${p.after.id}`, dy: travel });
259
- }
260
442
  }
261
- for (const p of admittedSemantic) {
443
+ for (const plan of admitted.filter((row) => row.kind === 'semantic')) {
444
+ // The probe strip is anchored to the real air line, so the pixels verified are the pixels the
445
+ // addition actually inserts — not a band that still contains the peer it was measured past.
446
+ const airAnchor = plan.airAnchor || plan.before;
447
+ if (plan.execution === 'owner-leading-air') {
448
+ const firstChild = [...plan.ownerChildren]
449
+ .sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top)[0];
450
+ const baseStripHeight = firstChild.getBoundingClientRect().top
451
+ - airAnchor.getBoundingClientRect().bottom;
452
+ out.push({
453
+ id: firstChild.id, rule: RULE,
454
+ reason: `declared ${plan.fact || 'section'} boundary ${plan.before.id}→${plan.after.id}: +${plan.addition} px inside owner`,
455
+ dy: plan.addition,
456
+ meta: { seam: {
457
+ kind: 'semantic', fact: plan.fact, before: airAnchor.id, after: firstChild.id,
458
+ declaredBefore: plan.before.id, declaredAfter: plan.after.id, gap: +plan.gap.toFixed(1),
459
+ baseStripHeight: +baseStripHeight.toFixed(2), addition: plan.addition,
460
+ color: plan.color, side: 'owner-leading', owner: plan.ownerKey, attachments: [],
461
+ } },
462
+ });
463
+ continue;
464
+ }
465
+ const beforePeers = [...plan.group.children].filter((child) => child.id
466
+ && child !== plan.after && child !== plan.owner
467
+ && child.getBoundingClientRect().bottom <= plan.after.getBoundingClientRect().top + 0.75)
468
+ .map((child) => child.id);
262
469
  out.push({
263
- id: p.after.id, rule: 'section-breathing',
264
- reason: `${p.fact} seam ${p.before.id}→${p.after.id}: gap ${p.gap.toFixed(0)} + ${p.addition} px`, dy: p.addition,
470
+ id: plan.after.id, rule: RULE,
471
+ reason: `declared ${plan.fact || 'section'} boundary ${plan.before.id}→${plan.after.id}: +${plan.addition} px`,
472
+ dy: plan.addition,
265
473
  meta: { seam: {
266
- kind: p.kind, fact: p.fact, before: p.before.id, after: p.after.id, gap: +p.gap.toFixed(1),
267
- baseStripHeight: +p.gap.toFixed(2), addition: p.addition, color: p.ground.color, side: 'internal',
268
- owner: p.ground.owner.id, attachments: [], beforePeers: p.beforePeers,
474
+ kind: 'semantic', fact: plan.fact, before: airAnchor.id, after: plan.after.id,
475
+ declaredBefore: plan.before.id,
476
+ gap: +plan.gap.toFixed(1), baseStripHeight: +plan.gap.toFixed(2), addition: plan.addition,
477
+ color: plan.color, side: 'internal', owner: plan.ownerKey, attachments: [], beforePeers,
269
478
  } },
270
479
  });
271
480
  }
272
481
  for (const [id, dy] of childShift) {
273
- const targetOwnShift = admittedSemantic.filter((p) => p.after.id === id).reduce((n, p) => n + p.addition, 0);
274
- const remainder = dy - targetOwnShift;
275
- if (remainder) out.push({ id, rule: 'section-breathing', reason: `block follows ${remainder} px of semantic air above it`, dy: remainder });
482
+ const own = admitted.filter((plan) => plan.kind === 'semantic'
483
+ && (plan.after.id === id || (plan.execution === 'owner-leading-air'
484
+ && [...plan.ownerChildren].sort((left, right) => left.getBoundingClientRect().top
485
+ - right.getBoundingClientRect().top)[0]?.id === id)))
486
+ .reduce((total, plan) => total + plan.addition, 0);
487
+ if (dy - own) out.push({ id, rule: RULE, reason: 'block follows declared semantic air', dy: dy - own });
276
488
  }
277
489
  for (const section of sections) {
278
- const growth = sectionGrowth.get(section.id); if (!growth) continue;
279
- const sr = section.getBoundingClientRect(); const owner = admittedSemantic.find((p) => p.section === section).ground.owner;
280
- const or = owner.getBoundingClientRect();
281
- out.push({ id: owner.id, rule: 'section-breathing', reason: `solid section ground grows ${growth} px`, h: or.height + growth });
282
- out.push({ id: section.id, rule: 'section-breathing', reason: `section grows ${growth} px for semantic air`, h: sr.height + growth });
490
+ const growth = sectionGrowth.get(section.id);
491
+ if (growth) out.push({ id: section.id, rule: RULE, reason: `section grows ${growth} px for declared semantic air`, h: section.getBoundingClientRect().height + growth });
283
492
  }
284
- for (const [id, dy] of sectionShift) if (dy) out.push({ id, rule: 'section-breathing', reason: `section follows ${dy} px of added air above it`, dy });
285
- const total = admitted.reduce((n, p) => n + p.addition, 0);
493
+ for (const [id, growth] of ownerGrowth) {
494
+ const owner = document.getElementById(id);
495
+ if (owner) out.push({ id, rule: RULE, reason: `declared ground owner continues ${growth} px`, h: owner.getBoundingClientRect().height + growth });
496
+ }
497
+ for (const [id, growth] of containerGrowth) {
498
+ if (ownerGrowth.has(id)) continue;
499
+ const container = document.getElementById(id);
500
+ if (container) out.push({ id, rule: RULE, reason: `container grows ${growth} px for nested declared air`, h: container.getBoundingClientRect().height + growth });
501
+ }
502
+ for (const [id, dy] of sectionShift) if (dy) out.push({ id, rule: RULE, reason: `section follows ${dy} px of declared air`, dy });
503
+ const total = admitted.reduce((sum, plan) => sum + plan.addition, 0);
286
504
  const canvas = document.querySelector('#page-flow') || document.querySelector('.ly-anchor-canvas');
287
- const canvasH = canvas ? canvas.getBoundingClientRect().height : document.documentElement.scrollHeight;
288
- out.push({ sel: '#page-flow, .ly-anchor-canvas', rule: 'section-breathing', reason: `canvas +${total} px`, css: { height: `${(canvasH + total).toFixed(2)}px` } });
505
+ const expectedDocumentHeight = Math.max(
506
+ document.documentElement.scrollHeight,
507
+ canvas ? canvas.getBoundingClientRect().bottom + window.scrollY + total : 0,
508
+ );
509
+ for (const override of out) {
510
+ if (override.meta?.seam) override.meta.seam.expectedDocumentHeight = expectedDocumentHeight;
511
+ }
512
+ if (canvas) out.push({ sel: '#page-flow, .ly-anchor-canvas', rule: RULE, reason: `canvas +${total} px`, css: { height: `${(canvas.getBoundingClientRect().height + total).toFixed(2)}px` } });
289
513
  return out;
290
514
  };
291
515
  }