@12ui/design 0.2.44 → 0.2.45

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 (125) hide show
  1. package/README.md +7 -0
  2. package/SKILL.md +1 -1
  3. package/dist/branch-execution-types.d.ts +2 -0
  4. package/dist/branch-execution-types.d.ts.map +1 -1
  5. package/dist/branch-execution.d.ts.map +1 -1
  6. package/dist/branch-execution.js +3 -0
  7. package/dist/branch-execution.js.map +1 -1
  8. package/dist/branch-run-record.d.ts +1 -0
  9. package/dist/branch-run-record.d.ts.map +1 -1
  10. package/dist/branch-run-record.js.map +1 -1
  11. package/dist/cli-arguments.d.ts.map +1 -1
  12. package/dist/cli-arguments.js +1 -0
  13. package/dist/cli-arguments.js.map +1 -1
  14. package/dist/cli-branch-command.d.ts.map +1 -1
  15. package/dist/cli-branch-command.js +33 -2
  16. package/dist/cli-branch-command.js.map +1 -1
  17. package/dist/cli-capabilities.d.ts +1 -0
  18. package/dist/cli-capabilities.d.ts.map +1 -1
  19. package/dist/cli-capabilities.js +1 -0
  20. package/dist/cli-capabilities.js.map +1 -1
  21. package/dist/cli-help/design.d.ts +2 -2
  22. package/dist/cli-help/design.d.ts.map +1 -1
  23. package/dist/cli-help/design.js +2 -0
  24. package/dist/cli-help/design.js.map +1 -1
  25. package/dist/cli-help/index.d.ts +2 -2
  26. package/dist/cli-prototype-command.d.ts.map +1 -1
  27. package/dist/cli-prototype-command.js +14 -1
  28. package/dist/cli-prototype-command.js.map +1 -1
  29. package/dist/cli-usage.d.ts.map +1 -1
  30. package/dist/cli-usage.js +4 -3
  31. package/dist/cli-usage.js.map +1 -1
  32. package/dist/legacy-skill-catalog.d.ts.map +1 -1
  33. package/dist/legacy-skill-catalog.js +7 -0
  34. package/dist/legacy-skill-catalog.js.map +1 -1
  35. package/dist/prototype-poststep.d.ts +1 -0
  36. package/dist/prototype-poststep.d.ts.map +1 -1
  37. package/dist/prototype-poststep.js +5 -0
  38. package/dist/prototype-poststep.js.map +1 -1
  39. package/dist/prototype-process.d.ts +1 -1
  40. package/dist/prototype-process.d.ts.map +1 -1
  41. package/dist/prototype-revision.d.ts +1 -1
  42. package/dist/prototype-revision.js +1 -1
  43. package/dist/prototype-run-record.d.ts +2 -1
  44. package/dist/prototype-run-record.d.ts.map +1 -1
  45. package/dist/prototype-run-record.js +28 -6
  46. package/dist/prototype-run-record.js.map +1 -1
  47. package/dist/prototype-runner.d.ts +26 -0
  48. package/dist/prototype-runner.d.ts.map +1 -1
  49. package/dist/prototype-runner.js +55 -8
  50. package/dist/prototype-runner.js.map +1 -1
  51. package/dist/run-journal.d.ts +15 -0
  52. package/dist/run-journal.d.ts.map +1 -1
  53. package/dist/run-journal.js +9 -0
  54. package/dist/run-journal.js.map +1 -1
  55. package/open-design.json +2 -2
  56. package/package.json +2 -2
  57. package/prototype-kit/polish/ground-probe.mjs +119 -0
  58. package/prototype-kit/polish/harm.mjs +23 -0
  59. package/prototype-kit/polish/measure.mjs +333 -0
  60. package/prototype-kit/polish/overrides.mjs +55 -0
  61. package/prototype-kit/polish/polish.mjs +252 -0
  62. package/prototype-kit/polish/report.mjs +76 -0
  63. package/prototype-kit/polish/rules/align.mjs +70 -0
  64. package/prototype-kit/polish/rules/contrast.mjs +80 -0
  65. package/prototype-kit/polish/rules/escape.mjs +192 -0
  66. package/prototype-kit/polish/rules/fit-text.mjs +158 -0
  67. package/prototype-kit/polish/rules/index.mjs +61 -0
  68. package/prototype-kit/polish/rules/left-rail.mjs +77 -0
  69. package/prototype-kit/polish/rules/overlap.mjs +122 -0
  70. package/prototype-kit/polish/rules/page-cut.mjs +203 -0
  71. package/prototype-kit/polish/rules/photo-corners.mjs +38 -0
  72. package/prototype-kit/polish/rules/rhythm.mjs +148 -0
  73. package/prototype-kit/polish/rules/section-breathing.mjs +292 -0
  74. package/prototype-kit/polish/rules/shell-seam.mjs +48 -0
  75. package/prototype-kit/polish/tests/breathing-check.mjs +77 -0
  76. package/prototype-kit/polish/tests/fixtures/align/expect.json +1 -0
  77. package/prototype-kit/polish/tests/fixtures/align/fire.html +14 -0
  78. package/prototype-kit/polish/tests/fixtures/align/silent.html +28 -0
  79. package/prototype-kit/polish/tests/fixtures/contrast/expect.json +1 -0
  80. package/prototype-kit/polish/tests/fixtures/contrast/fallback.html +7 -0
  81. package/prototype-kit/polish/tests/fixtures/contrast/fire.html +9 -0
  82. package/prototype-kit/polish/tests/fixtures/contrast/silent.html +9 -0
  83. package/prototype-kit/polish/tests/fixtures/escape/expect.json +2 -0
  84. package/prototype-kit/polish/tests/fixtures/escape/fire.html +23 -0
  85. package/prototype-kit/polish/tests/fixtures/escape/silent.html +29 -0
  86. package/prototype-kit/polish/tests/fixtures/fit-text/expect.json +3 -0
  87. package/prototype-kit/polish/tests/fixtures/fit-text/fire.html +26 -0
  88. package/prototype-kit/polish/tests/fixtures/fit-text/silent.html +33 -0
  89. package/prototype-kit/polish/tests/fixtures/harm/button-overlap.html +10 -0
  90. package/prototype-kit/polish/tests/fixtures/harm/declared-gap.html +5 -0
  91. package/prototype-kit/polish/tests/fixtures/harm/offcenter.html +21 -0
  92. package/prototype-kit/polish/tests/fixtures/harm/page.html +17 -0
  93. package/prototype-kit/polish/tests/fixtures/left-rail/expect.json +1 -0
  94. package/prototype-kit/polish/tests/fixtures/left-rail/fire.html +8 -0
  95. package/prototype-kit/polish/tests/fixtures/left-rail/silent.html +8 -0
  96. package/prototype-kit/polish/tests/fixtures/overlap/expect.json +4 -0
  97. package/prototype-kit/polish/tests/fixtures/overlap/fire.html +26 -0
  98. package/prototype-kit/polish/tests/fixtures/overlap/silent.html +39 -0
  99. package/prototype-kit/polish/tests/fixtures/page-cut/expect.json +2 -0
  100. package/prototype-kit/polish/tests/fixtures/page-cut/fire.html +31 -0
  101. package/prototype-kit/polish/tests/fixtures/page-cut/silent.html +22 -0
  102. package/prototype-kit/polish/tests/fixtures/photo-corners/expect.json +1 -0
  103. package/prototype-kit/polish/tests/fixtures/photo-corners/fire.html +5 -0
  104. package/prototype-kit/polish/tests/fixtures/photo-corners/silent.html +9 -0
  105. package/prototype-kit/polish/tests/fixtures/rhythm/expect.json +6 -0
  106. package/prototype-kit/polish/tests/fixtures/rhythm/fire.html +22 -0
  107. package/prototype-kit/polish/tests/fixtures/rhythm/silent.html +27 -0
  108. package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-fire.html +5 -0
  109. package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-silent.html +5 -0
  110. package/prototype-kit/polish/tests/fixtures/section-breathing/expect.json +1 -0
  111. package/prototype-kit/polish/tests/fixtures/section-breathing/fire.html +6 -0
  112. package/prototype-kit/polish/tests/fixtures/section-breathing/floor.html +7 -0
  113. package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-fire.html +11 -0
  114. package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-silent.html +8 -0
  115. package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-fire.html +5 -0
  116. package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-silent.html +5 -0
  117. package/prototype-kit/polish/tests/fixtures/section-breathing/silent.html +6 -0
  118. package/prototype-kit/polish/tests/fixtures/section-breathing/tight.html +7 -0
  119. package/prototype-kit/polish/tests/fixtures/shell-seam/expect.json +1 -0
  120. package/prototype-kit/polish/tests/fixtures/shell-seam/fire.html +20 -0
  121. package/prototype-kit/polish/tests/fixtures/shell-seam/silent.html +21 -0
  122. package/prototype-kit/polish/tests/harm-check.mjs +71 -0
  123. package/prototype-kit/polish/tests/report-check.mjs +31 -0
  124. package/prototype-kit/polish/tests/rules-check.mjs +52 -0
  125. package/skills/12ui-design/SKILL.md +4 -0
@@ -0,0 +1,292 @@
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.
3
+ //
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).
9
+ //
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.
12
+ export const name = 'section-breathing';
13
+ export const maxShift = 192;
14
+ export const movesBlocks = true;
15
+ export const phase = 'post';
16
+ export const verifyPaint = true;
17
+ export const recordsCensus = true;
18
+ export const params = {
19
+ FLOOR: 32,
20
+ CAP: 64,
21
+ MIN_TARGET: 48,
22
+ MAX_TARGET: 192,
23
+ COVER: 0.8,
24
+ TIGHT_GAP: 32,
25
+ SEMANTIC_MAX_GAP: 32,
26
+ SEMANTIC_WIDTH: 0.8,
27
+ PREVIOUS_BLOCK_WIDTH: 0.5,
28
+ };
29
+
30
+ export function browserFn() {
31
+ return ({ params }) => {
32
+ const P = Object.assign({
33
+ FLOOR: 32, CAP: 64, MIN_TARGET: 48, MAX_TARGET: 192, COVER: 0.8,
34
+ TIGHT_GAP: 32, SEMANTIC_MAX_GAP: 32, SEMANTIC_WIDTH: 0.8, PREVIOUS_BLOCK_WIDTH: 0.5,
35
+ }, params || {});
36
+ const out = [];
37
+ const visible = (el) => {
38
+ const s = getComputedStyle(el);
39
+ if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false;
40
+ const r = el.getBoundingClientRect();
41
+ return r.width > 0 && r.height > 0;
42
+ };
43
+ const alphaOf = (color) => {
44
+ const m = color && color.match(/^rgba?\(([^)]+)\)$/);
45
+ if (!m) return color === 'transparent' ? 0 : 1;
46
+ const p = m[1].split(/[, /]+/).filter(Boolean).map(Number);
47
+ return p.length > 3 ? p[3] : 1;
48
+ };
49
+ const groundClass = (style, el) => {
50
+ if (['IMG', 'PICTURE', 'VIDEO'].includes(el.tagName)) return 'image';
51
+ const img = style.backgroundImage;
52
+ if (/repeating-(?:linear|radial|conic)-gradient\(/.test(img)) return 'texture';
53
+ if (/(?:linear|radial|conic)-gradient\(/.test(img)) return 'gradient';
54
+ if (img !== 'none') return style.backgroundRepeat === 'no-repeat' ? 'image' : 'texture';
55
+ return alphaOf(style.backgroundColor) > 0.01 ? 'solid' : 'transparent';
56
+ };
57
+ const INK = '.text, .paragraph, h1, h2, h3, h4, h5, h6, .icon, img, .image, button, .button';
58
+ const median = (v) => {
59
+ const s = [...v].sort((a, b) => a - b); const m = Math.floor(s.length / 2);
60
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
61
+ };
62
+ const ceil8 = (v) => Math.ceil(v / 8) * 8;
63
+ const sections = [...document.querySelectorAll('section[id^="viewport-"]')]
64
+ .filter((s) => /^viewport-\d+-[A-Za-z0-9]+$/.test(s.id) && visible(s))
65
+ .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
66
+ if (!sections.length) return out;
67
+
68
+ const validSolidOwner = (o, section, edge = null) => {
69
+ if (!o) return { ok: false, reason: 'ground-owner-unproven' };
70
+ const sr = section.getBoundingClientRect();
71
+ if (o.gc !== 'solid') return { ok: false, reason: `${o.gc}-ground (experimental class, abstain)` };
72
+ if (Math.abs(o.rect.left - sr.left) > 0.75 || Math.abs(o.rect.right - sr.right) > 0.75) {
73
+ return { ok: false, reason: 'ground-does-not-own-full-seam' };
74
+ }
75
+ if (edge && (edge === 'bottom' ? Math.abs(o.rect.bottom - sr.bottom) : Math.abs(o.rect.top - sr.top)) > 0.75) {
76
+ return { ok: false, reason: 'ground-does-not-touch-edge' };
77
+ }
78
+ const cs = o.cs;
79
+ if (cs.boxShadow !== 'none' || cs.clipPath !== 'none' || cs.maskImage !== 'none') {
80
+ return { ok: false, reason: 'owner-has-shadow-clip-or-mask' };
81
+ }
82
+ if (/var\(/.test(cs.backgroundColor) || alphaOf(cs.backgroundColor) < 0.999) {
83
+ return { ok: false, reason: 'transparent-or-unresolved-solid' };
84
+ }
85
+ if (!o.el.id) return { ok: false, reason: 'owner-has-no-id' };
86
+ return { ok: true, owner: o.el, color: cs.backgroundColor };
87
+ };
88
+ const ownerAt = (section, edge) => {
89
+ const sr = section.getBoundingClientRect(); const y = edge === 'bottom' ? sr.bottom - 1 : sr.top + 1;
90
+ const cands = [section, ...section.querySelectorAll('*')].flatMap((el) => {
91
+ if (!visible(el)) return [];
92
+ const r = el.getBoundingClientRect();
93
+ if (!(r.top <= y && r.bottom >= y) || r.width < sr.width * P.COVER) return [];
94
+ const cs = getComputedStyle(el); const gc = groundClass(cs, el);
95
+ if (gc === 'transparent') return [];
96
+ const cls = typeof el.className === 'string' ? el.className : '';
97
+ if (['IMG', 'PICTURE', 'VIDEO'].includes(el.tagName) && /(?:^|\s)(?:decoration|icon|logo|badge|photo|image)(?:\s|$)/.test(cls)) return [];
98
+ return [{ el, rect: r, area: r.width * r.height, gc, cs }];
99
+ }).sort((a, b) => a.area - b.area || String(a.el.id).localeCompare(String(b.el.id)));
100
+ return cands[0] || null;
101
+ };
102
+ const fullSectionOwner = (section) => {
103
+ const sr = section.getBoundingClientRect();
104
+ const cands = [section, ...section.querySelectorAll('*')].flatMap((el) => {
105
+ if (!visible(el)) return [];
106
+ const r = el.getBoundingClientRect();
107
+ 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 [];
108
+ const cs = getComputedStyle(el); const gc = groundClass(cs, el);
109
+ if (gc === 'transparent') return [];
110
+ return [{ el, rect: r, area: r.width * r.height, gc, cs }];
111
+ }).sort((a, b) => a.area - b.area || String(a.el.id).localeCompare(String(b.el.id)));
112
+ return validSolidOwner(cands[0] || null, section);
113
+ };
114
+ const inkEdge = (section, edge) => {
115
+ const sr = section.getBoundingClientRect();
116
+ const ink = [...section.querySelectorAll(INK)].filter(visible).map((el) => el.getBoundingClientRect());
117
+ if (!ink.length) return null;
118
+ return edge === 'bottom' ? Math.max(...ink.map((r) => r.bottom)) - sr.top : Math.min(...ink.map((r) => r.top)) - sr.top;
119
+ };
120
+ const directChildOf = (section, el) => {
121
+ let child = el;
122
+ while (child.parentElement && child.parentElement !== section) child = child.parentElement;
123
+ return child.parentElement === section ? child : null;
124
+ };
125
+ const attachmentsAt = (section, seamY) => {
126
+ const cands = [...section.querySelectorAll('.decoration[id]')].filter(visible).filter((el) => {
127
+ const r = el.getBoundingClientRect();
128
+ return r.top < seamY && r.bottom >= seamY - 0.75 && r.width < section.getBoundingClientRect().width * P.COVER;
129
+ });
130
+ return cands.filter((el) => !cands.some((other) => other !== el && el.contains(other))).map((el) => {
131
+ const r = el.getBoundingClientRect();
132
+ return { el, topOffset: +(r.top - seamY).toFixed(2), bottomOffset: +(r.bottom - seamY).toFixed(2) };
133
+ });
134
+ };
135
+
136
+ const viewportSeams = [];
137
+ for (let i = 1; i < sections.length; i++) {
138
+ const before = sections[i - 1], after = sections[i];
139
+ const br = before.getBoundingClientRect(), ar = after.getBoundingClientRect();
140
+ const lastInk = inkEdge(before, 'bottom'), firstInk = inkEdge(after, 'top');
141
+ if (lastInk == null || firstInk == null) continue;
142
+ const gap = (br.height - lastInk) + (ar.top - br.bottom) + firstInk;
143
+ if (gap < 0) continue;
144
+ viewportSeams.push({ kind: 'viewport', before, after, section: before, gap });
145
+ }
146
+ const target = viewportSeams.length === 1
147
+ ? viewportSeams[0].gap + P.FLOOR
148
+ : viewportSeams.length
149
+ ? Math.max(P.MIN_TARGET, Math.min(P.MAX_TARGET, ceil8(median(viewportSeams.map((s) => s.gap)) + P.FLOOR)))
150
+ : null;
151
+ const viewportPlans = viewportSeams.map((s) => {
152
+ const raw = Math.round(Math.min(P.CAP, Math.max(0, target - s.gap)));
153
+ const tightScaled = raw > 0 && raw < P.FLOOR && s.gap < P.TIGHT_GAP;
154
+ const addition = tightScaled ? P.FLOOR : raw;
155
+ if (addition < P.FLOOR) {
156
+ return { ...s, addition, declined: addition > 0 ? 'below the 32 px perceptibility floor' : 'already at the page rhythm target' };
157
+ }
158
+ const tryOwner = (section, edge) => {
159
+ const valid = validSolidOwner(ownerAt(section, edge), section, edge);
160
+ return valid.ok ? { ...valid, side: edge === 'bottom' ? 'outgoing' : 'incoming' } : valid;
161
+ };
162
+ const outgoing = tryOwner(s.before, 'bottom'); const ground = outgoing.ok ? outgoing : tryOwner(s.after, 'top');
163
+ const seamY = s.after.getBoundingClientRect().top;
164
+ return {
165
+ ...s, addition, tightScaled, ground,
166
+ diagnostics: outgoing.ok ? null : { outgoing: outgoing.reason, incoming: ground.reason },
167
+ attachments: attachmentsAt(s.before, seamY),
168
+ };
169
+ });
170
+
171
+ const semanticPlans = [];
172
+ for (const section of sections) {
173
+ const sr = section.getBoundingClientRect(); const children = [...section.children].filter(visible);
174
+ const ground = fullSectionOwner(section);
175
+ const semantic = children.flatMap((el) => {
176
+ const id = el.id.toLowerCase(); const r = el.getBoundingClientRect();
177
+ const fact = el.tagName === 'FOOTER' ? 'footer'
178
+ : /(?:^|[-_])(?:faq|questions)(?:[-_]|$)/.test(id) ? 'questions'
179
+ : /(?:^|[-_])partners(?:[-_]|$)/.test(id) ? 'partners' : null;
180
+ return fact && el.id && r.width >= sr.width * P.SEMANTIC_WIDTH ? [{ el, fact, rect: r }] : [];
181
+ }).sort((a, b) => a.rect.top - b.rect.top);
182
+ for (const candidate of semantic) {
183
+ const eligible = children.filter((el) => {
184
+ if (el === candidate.el || el === ground.owner || !el.id) return false;
185
+ const r = el.getBoundingClientRect();
186
+ const fullSection = r.top <= sr.top + 0.75 && r.bottom >= sr.bottom - 0.75;
187
+ return !fullSection && r.width >= sr.width * P.PREVIOUS_BLOCK_WIDTH && r.bottom <= candidate.rect.top + 0.75;
188
+ }).sort((a, b) => b.getBoundingClientRect().bottom - a.getBoundingClientRect().bottom);
189
+ const before = eligible[0]; if (!before) continue;
190
+ const br = before.getBoundingClientRect(); const gap = candidate.rect.top - br.bottom;
191
+ if (gap < 0) continue;
192
+ // Moving a full-width semantic block deliberately changes its vertical relation to every substantial direct
193
+ // block before it (including unequal-height columns). Declare that exact target relation; unrelated sibling
194
+ // gaps remain protected by the harm guard.
195
+ const beforePeers = children.filter((el) => {
196
+ if (el === candidate.el || el === ground.owner || !el.id) return false;
197
+ const r = el.getBoundingClientRect();
198
+ const fullSection = r.top <= sr.top + 0.75 && r.bottom >= sr.bottom - 0.75;
199
+ return !fullSection && r.bottom <= candidate.rect.top + 0.75;
200
+ }).map((el) => el.id);
201
+ const dense = [before.id, candidate.el.id].some((id) => /(?:^|[-_])dashboard(?:[-_]|$)/.test(id.toLowerCase()));
202
+ const addition = ground.ok && !dense && gap < P.SEMANTIC_MAX_GAP ? P.FLOOR : 0;
203
+ semanticPlans.push({
204
+ kind: 'semantic', section, before, beforePeers, after: candidate.el, fact: candidate.fact, gap,
205
+ addition, ground,
206
+ declined: !ground.ok ? ground.reason
207
+ : dense ? 'dense dashboard boundary stays tight'
208
+ : addition ? null : 'semantic boundary already has at least 32 px',
209
+ });
210
+ }
211
+ }
212
+
213
+ const censusRow = (p) => ({
214
+ kind: p.kind, section: p.section.id, before: p.before.id, after: p.after.id, fact: p.fact || null,
215
+ gap: +p.gap.toFixed(1), addition: p.addition, declined: p.declined || null,
216
+ tightScaled: p.tightScaled || false,
217
+ attachments: (p.attachments || []).map((a) => a.el.id),
218
+ 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,
219
+ });
220
+ if (P.census) return [...viewportPlans, ...semanticPlans].map(censusRow);
221
+
222
+ const admittedViewport = viewportPlans.filter((p) => !p.declined);
223
+ const admittedSemantic = semanticPlans.filter((p) => !p.declined);
224
+ const admitted = [...admittedViewport, ...admittedSemantic];
225
+ if (!admitted.length || admitted.some((p) => !p.ground?.ok)) return out;
226
+
227
+ const sectionShift = new Map(sections.map((s) => [s.id, 0]));
228
+ const sectionGrowth = new Map(sections.map((s) => [s.id, 0]));
229
+ const childShift = new Map();
230
+ const sectionIndex = new Map(sections.map((s, i) => [s.id, i]));
231
+ for (const p of admittedSemantic) {
232
+ sectionGrowth.set(p.section.id, sectionGrowth.get(p.section.id) + p.addition);
233
+ const tr = p.after.getBoundingClientRect();
234
+ for (const child of [...p.section.children].filter(visible)) {
235
+ if (child.id && child.getBoundingClientRect().top >= tr.top - 0.75) childShift.set(child.id, (childShift.get(child.id) || 0) + p.addition);
236
+ }
237
+ }
238
+ for (const p of admittedViewport) {
239
+ for (let i = sectionIndex.get(p.after.id); i < sections.length; i++) sectionShift.set(sections[i].id, sectionShift.get(sections[i].id) + p.addition);
240
+ }
241
+
242
+ for (const p of admittedViewport) {
243
+ const dy = p.ground.side === 'outgoing' ? p.addition : -p.addition;
244
+ const br = p.before.getBoundingClientRect();
245
+ const attachments = p.attachments.map((a) => ({ id: a.el.id, topOffset: a.topOffset, bottomOffset: a.bottomOffset }));
246
+ out.push({
247
+ id: p.ground.owner.id, rule: 'section-breathing',
248
+ 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})`,
249
+ css: { 'box-shadow': `0 ${dy}px 0 0 ${p.ground.color}` },
250
+ meta: { seam: {
251
+ kind: p.kind, before: p.before.id, after: p.after.id, gap: +p.gap.toFixed(1), baseStripHeight: 0,
252
+ addition: p.addition, color: p.ground.color, side: p.ground.side, owner: p.ground.owner.id,
253
+ attachments, seamTop: br.bottom + window.scrollY + sectionShift.get(p.before.id) + sectionGrowth.get(p.before.id),
254
+ } },
255
+ });
256
+ for (const a of p.attachments) {
257
+ const direct = directChildOf(p.before, a.el); const inherited = direct ? (childShift.get(direct.id) || 0) : 0;
258
+ const travel = p.addition + sectionGrowth.get(p.before.id) - inherited;
259
+ if (travel) out.push({ id: a.el.id, rule: 'section-breathing', reason: `declared decoration travels ${travel} px with ${p.after.id}`, dy: travel });
260
+ }
261
+ }
262
+ for (const p of admittedSemantic) {
263
+ out.push({
264
+ id: p.after.id, rule: 'section-breathing',
265
+ reason: `${p.fact} seam ${p.before.id}→${p.after.id}: gap ${p.gap.toFixed(0)} + ${p.addition} px`, dy: p.addition,
266
+ meta: { seam: {
267
+ kind: p.kind, fact: p.fact, before: p.before.id, after: p.after.id, gap: +p.gap.toFixed(1),
268
+ baseStripHeight: +p.gap.toFixed(2), addition: p.addition, color: p.ground.color, side: 'internal',
269
+ owner: p.ground.owner.id, attachments: [], beforePeers: p.beforePeers,
270
+ } },
271
+ });
272
+ }
273
+ for (const [id, dy] of childShift) {
274
+ const targetOwnShift = admittedSemantic.filter((p) => p.after.id === id).reduce((n, p) => n + p.addition, 0);
275
+ const remainder = dy - targetOwnShift;
276
+ if (remainder) out.push({ id, rule: 'section-breathing', reason: `block follows ${remainder} px of semantic air above it`, dy: remainder });
277
+ }
278
+ for (const section of sections) {
279
+ const growth = sectionGrowth.get(section.id); if (!growth) continue;
280
+ const sr = section.getBoundingClientRect(); const owner = admittedSemantic.find((p) => p.section === section).ground.owner;
281
+ const or = owner.getBoundingClientRect();
282
+ out.push({ id: owner.id, rule: 'section-breathing', reason: `solid section ground grows ${growth} px`, h: or.height + growth });
283
+ out.push({ id: section.id, rule: 'section-breathing', reason: `section grows ${growth} px for semantic air`, h: sr.height + growth });
284
+ }
285
+ 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 });
286
+ const total = admitted.reduce((n, p) => n + p.addition, 0);
287
+ const canvas = document.querySelector('#page-flow') || document.querySelector('.ly-anchor-canvas');
288
+ const canvasH = canvas ? canvas.getBoundingClientRect().height : document.documentElement.scrollHeight;
289
+ out.push({ sel: '#page-flow, .ly-anchor-canvas', rule: 'section-breathing', reason: `canvas +${total} px`, css: { height: `${(canvasH + total).toFixed(2)}px` } });
290
+ return out;
291
+ };
292
+ }
@@ -0,0 +1,48 @@
1
+ // shell-seam: transplanted shell children that do not fit the page's own host box.
2
+ //
3
+ // Mechanism (frontier lane C, 34 of 66 population runs): build.mjs transplants the canonical shell's CHILDREN into the
4
+ // page's OWN host box; the children keep canonical geometry. On Lands the canonical rail is 258 px and the review page
5
+ // hosts it in 221 px, so the "New study" button (214 px) crossed its own rail by 17 px.
6
+ //
7
+ // History, on the record: the first version of this rule grew the host to the canonical box (and, with no slack,
8
+ // grew it into the content gutter). The owner rejected that — it squeezed the gutter 51 → 13 px and read worse, not
9
+ // better ("I would reduce the button size"). build.mjs now fits the children to the host at build time
10
+ // (`fitShellChildren`: width clamp with canonical insets, bottom re-anchoring), so this rule is a BACKSTOP that must
11
+ // be content-conserving:
12
+ // * it only acts on a transplanted child that still spills past the host box (live geometry, ≥ 2 px);
13
+ // * it shrinks that child (`w`, keeping its left edge and its canonical right inset) or lifts it back above the
14
+ // host's bottom edge (`dy`); it never resizes the host, never moves page content, never touches the gutter;
15
+ // * with the upstream fit in place it is expected to stay silent (fixture `silent.html` pins exactly that).
16
+ export const name = 'shell-seam';
17
+ export const maxShift = 40; // a lifted footer may travel this far; width clamps move nothing
18
+
19
+ export function browserFn() {
20
+ return () => {
21
+ const MIN = 2;
22
+ const out = [];
23
+ const visible = (el) => { const s = getComputedStyle(el); if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
24
+ for (const host of document.querySelectorAll('[data-proto-shell]')) {
25
+ if (!visible(host)) continue;
26
+ const hr = host.getBoundingClientRect();
27
+ const handled = new Set();
28
+ // parents first so a child that travels with its parent is not handled twice
29
+ const kids = [...host.querySelectorAll('[id]')].filter(visible).sort((a, b) => a.id.length - b.id.length);
30
+ for (const k of kids) {
31
+ if ([...handled].some((id) => k.id.startsWith(id + '-'))) continue;
32
+ const r = k.getBoundingClientRect();
33
+ const overX = r.right - hr.right, overY = r.bottom - hr.bottom;
34
+ const isTextLeaf = k.classList.contains('text') || k.classList.contains('paragraph') || /^H[1-6]$/.test(k.tagName);
35
+ if (overX >= MIN && r.left < hr.right - 24 && !isTextLeaf) { // a nowrap label cannot shrink by width — fit-text's job
36
+ // shrink: keep the left edge, land the right edge at the host's right minus a small inset
37
+ const inset = Math.min(8, Math.max(0, hr.right - r.right + overX));
38
+ const w = hr.right - inset - r.left;
39
+ if (w >= 24 && w < r.width - 0.5) { out.push({ id: k.id, rule: 'shell-seam', reason: `child ${r.width.toFixed(1)}→${w.toFixed(1)} (spilled ${overX.toFixed(1)}px past the host)`, w }); handled.add(k.id); }
40
+ } else if (overY >= MIN) {
41
+ const dy = -(overY + Math.min(8, Math.max(0, hr.height - r.height)));
42
+ if (-dy <= 40) { out.push({ id: k.id, rule: 'shell-seam', reason: `child ${overY.toFixed(1)}px below the host → lift`, dy }); handled.add(k.id); }
43
+ }
44
+ }
45
+ }
46
+ return out;
47
+ };
48
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // section-breathing: floor + background-integrity pairs.
3
+ // floor three solid sections, gaps ~42 / ~102 → target ceil8(median+32) = 104: seam 1 gets +62, seam 2 (+2) is DECLINED
4
+ // probe on the fire fixture the pixel probe accepts the accepted seam (9/9 probes in the owner's colour, document
5
+ // grew by exactly the addition); with a deliberately wrong owner colour it must fail with 'coverage'
6
+ import path from 'node:path';
7
+ import { chromium } from '@playwright/test';
8
+ import * as rule from '../rules/section-breathing.mjs';
9
+ import { applyOverridesScript, overridesToCss, mergeOverrides } from '../overrides.mjs';
10
+ import { probeSeams } from '../ground-probe.mjs';
11
+ const here = path.dirname(new URL(import.meta.url).pathname);
12
+ const browser = await chromium.launch();
13
+ const page = await browser.newPage({ viewport: { width: 1536, height: 1024 } });
14
+ let failures = 0;
15
+ const check = (name, ok, detail) => { if (!ok) failures++; console.log(`${ok ? 'ok ' : 'FAIL'} section-breathing/${name}: ${detail}`); };
16
+ const seamMeta = (overrides) => overrides.filter((o) => o.meta?.seam).map((o) => o.meta.seam);
17
+ // floor
18
+ await page.goto('file://' + path.join(here, 'fixtures/section-breathing/floor.html'), { waitUntil: 'load' });
19
+ const floor = await page.evaluate(rule.browserFn(), { metrics: {}, params: rule.params });
20
+ const seams = seamMeta(floor);
21
+ check('floor declines the 2 px seam, keeps the 62 px seam', seams.length === 1 && seams[0].addition === 62 && seams[0].before === 'viewport-1-a', JSON.stringify(seams));
22
+ // tight-gap scale: a 30 px page seam whose rhythm delta is only 18 px still receives the perceptible 32 px floor
23
+ await page.goto('file://' + path.join(here, 'fixtures/section-breathing/tight.html'), { waitUntil: 'load' });
24
+ const tight = await page.evaluate(rule.browserFn(), { metrics: {}, params: { ...rule.params, census: true } });
25
+ const tightPlan = tight.find((s) => s.before === 'viewport-3-a' && s.after === 'viewport-4-a');
26
+ check('tight page seam scales to the 32 px perceptibility floor', tightPlan?.tightScaled && tightPlan.addition === 32, JSON.stringify(tightPlan));
27
+ // probe: accepted seam on the fire fixture
28
+ await page.goto('file://' + path.join(here, 'fixtures/section-breathing/fire.html'), { waitUntil: 'load' });
29
+ const base = await page.evaluate(() => document.documentElement.scrollHeight);
30
+ const fire = await page.evaluate(rule.browserFn(), { metrics: {}, params: rule.params });
31
+ await page.evaluate(applyOverridesScript(), overridesToCss(mergeOverrides(fire)));
32
+ const good = await probeSeams(page, seamMeta(fire), base);
33
+ check('probe accepts a solid seam (9 probes, height +32)', good.ok && good.probes === 9 && good.documentHeight === base + 32, JSON.stringify({ ok: good.ok, probes: good.probes, h: [base, good.documentHeight], failures: good.failures.slice(0, 2) }));
34
+ // probe: sabotage — claim a different colour than the one painted → coverage failure on every probe
35
+ const bad = await probeSeams(page, seamMeta(fire).map((s) => ({ ...s, color: 'rgb(0, 90, 200)' })), base);
36
+ check('probe rejects a seam whose pixels are not the declared ground', !bad.ok && bad.failures.every((f) => f.kind === 'coverage' || f.kind === 'floor-exposure') && bad.failures.length === 9, JSON.stringify({ ok: bad.ok, n: bad.failures.length, kinds: [...new Set(bad.failures.map((f) => f.kind))] }));
37
+
38
+ // attachment pair: only a declared decoration touching the seam travels with the following section
39
+ await page.goto('file://' + path.join(here, 'fixtures/section-breathing/attachment-fire.html'), { waitUntil: 'load' });
40
+ const attachmentBase = await page.evaluate(() => document.documentElement.scrollHeight);
41
+ const attachmentFire = await page.evaluate(rule.browserFn(), { metrics: {}, params: rule.params });
42
+ const attachmentMove = attachmentFire.find((o) => o.id === 'viewport-1-a-fruit');
43
+ await page.evaluate(applyOverridesScript(), overridesToCss(mergeOverrides(attachmentFire)));
44
+ const attachmentProbe = await probeSeams(page, seamMeta(attachmentFire), attachmentBase);
45
+ check('boundary-hugging decoration travels and keeps its seam attachment', attachmentMove?.dy === 32 && seamMeta(attachmentFire)[0]?.attachments?.[0]?.id === 'viewport-1-a-fruit' && attachmentProbe.ok, JSON.stringify({ move: attachmentMove, ground: attachmentProbe }));
46
+ await page.goto('file://' + path.join(here, 'fixtures/section-breathing/attachment-silent.html'), { waitUntil: 'load' });
47
+ const attachmentSilent = await page.evaluate(rule.browserFn(), { metrics: {}, params: rule.params });
48
+ check('ordinary boundary image is not reclassified as decoration', !attachmentSilent.some((o) => o.id === 'viewport-1-a-product-image'), JSON.stringify(attachmentSilent.filter((o) => o.id === 'viewport-1-a-product-image')));
49
+
50
+ // semantic pair: direct declared FAQ/footer boundaries breathe; a dense dashboard boundary stays silent
51
+ await page.goto('file://' + path.join(here, 'fixtures/section-breathing/semantic-fire.html'), { waitUntil: 'load' });
52
+ const semanticBase = await page.evaluate(() => document.documentElement.scrollHeight);
53
+ const semantic = await page.evaluate(rule.browserFn(), { metrics: {}, params: rule.params });
54
+ const semanticMerged = mergeOverrides(semantic);
55
+ await page.evaluate(applyOverridesScript(), overridesToCss(semanticMerged));
56
+ const semanticProbe = await probeSeams(page, seamMeta(semantic), semanticBase);
57
+ const faq = semanticMerged.find((o) => o.id === 'viewport-1-a-faq');
58
+ const footer = semanticMerged.find((o) => o.id === 'viewport-1-a-footer');
59
+ const section = semanticMerged.find((o) => o.id === 'viewport-1-a');
60
+ check('declared FAQ and footer boundaries each gain 32 px', faq?.dy === 32 && footer?.dy === 64 && section?.h === 1088 && faq?.meta?.seam?.beforePeers?.includes('viewport-1-a-comparison-aside') && semanticProbe.ok, JSON.stringify({ faq, footer, section, ground: semanticProbe }));
61
+ await page.goto('file://' + path.join(here, 'fixtures/section-breathing/semantic-silent.html'), { waitUntil: 'load' });
62
+ const semanticSilent = await page.evaluate(rule.browserFn(), { metrics: {}, params: rule.params });
63
+ check('dense dashboard footer boundary stays tight', semanticSilent.length === 0, JSON.stringify(semanticSilent));
64
+
65
+ // shadow-gradient pair: the measured neutral monotone fade is tolerated; a hard coloured band still fails
66
+ for (const [fixture, expected] of [['shadow-fire.html', true], ['shadow-silent.html', false]]) {
67
+ await page.goto('file://' + path.join(here, 'fixtures/section-breathing', fixture), { waitUntil: 'load' });
68
+ const shadowBase = await page.evaluate(() => document.documentElement.scrollHeight);
69
+ const shadow = await page.evaluate(rule.browserFn(), { metrics: {}, params: rule.params });
70
+ await page.evaluate(applyOverridesScript(), overridesToCss(mergeOverrides(shadow)));
71
+ const verdict = await probeSeams(page, seamMeta(shadow), shadowBase);
72
+ const ok = expected ? verdict.ok && verdict.toleratedShadowGradients > 0 : !verdict.ok && verdict.failures.some((f) => f.kind === 'coverage');
73
+ check(expected ? 'neutral monotone shadow fade is tolerated' : 'hard coloured seam band stays rejected', ok, JSON.stringify(verdict));
74
+ }
75
+ await browser.close();
76
+ if (failures) { console.log(`${failures} failure(s)`); process.exit(1); }
77
+ console.log('section-breathing pairs green');
@@ -0,0 +1 @@
1
+ [{"id":"viewport-1-a-cards-card-2","dy":-1.02},{"id":"viewport-1-a-cards-card-3","dy":-2.05}]
@@ -0,0 +1,14 @@
1
+ <!doctype html><meta charset="utf-8"><style>
2
+ *{margin:0;padding:0;box-sizing:border-box} body{font-family:system-ui}
3
+ section{position:relative;width:1536px;height:1024px;background:#fff}
4
+ .row{position:relative;display:flex;gap:24px;margin:100px 0 0 100px}
5
+ .card{position:relative;width:300px;height:200px;background:#f3f3f3;border:1px solid #ddd;border-radius:12px}
6
+ .text{position:relative;display:block;font-size:16px;line-height:20px;margin:20px 0 0 20px}
7
+ </style>
8
+ <section id="viewport-1-a">
9
+ <div id="viewport-1-a-cards" class="row">
10
+ <div id="viewport-1-a-cards-card-1" class="card"><span id="viewport-1-a-cards-card-1-text" class="text">Alpha</span></div>
11
+ <div id="viewport-1-a-cards-card-2" class="card" style="margin-top:1.02px"><span id="viewport-1-a-cards-card-2-text" class="text">Beta</span></div>
12
+ <div id="viewport-1-a-cards-card-3" class="card" style="margin-top:2.05px"><span id="viewport-1-a-cards-card-3-text" class="text">Gamma</span></div>
13
+ </div>
14
+ </section>
@@ -0,0 +1,28 @@
1
+ <!doctype html><meta charset="utf-8"><style>
2
+ *{margin:0;padding:0;box-sizing:border-box} body{font-family:system-ui}
3
+ section{position:relative;width:1536px;height:1024px;background:#fff}
4
+ .row{position:relative;display:flex;gap:24px;margin:100px 0 0 100px}
5
+ .card{position:relative;width:300px;height:200px;background:#f3f3f3;border:1px solid #ddd;border-radius:12px}
6
+ .text{position:relative;display:block;font-size:16px;line-height:20px;margin:20px 0 0 20px}
7
+ .big{font-size:28px;line-height:34px}
8
+ </style>
9
+ <section id="viewport-1-a">
10
+ <!-- equal tops: nothing to snap; a deliberate 24px step and a different-kind pair must stay untouched -->
11
+ <div id="viewport-1-a-cards" class="row">
12
+ <div id="viewport-1-a-cards-card-1" class="card"><span id="viewport-1-a-cards-card-1-text" class="text">Alpha</span><span id="viewport-1-a-cards-card-1-value" class="text big" style="margin-top:3px">42</span></div>
13
+ <div id="viewport-1-a-cards-card-2" class="card"><span id="viewport-1-a-cards-card-2-text" class="text">Beta</span></div>
14
+ <div id="viewport-1-a-cards-card-3" class="card" style="margin-top:24px"><span id="viewport-1-a-cards-card-3-text" class="text">Gamma</span></div>
15
+ </div>
16
+ <!-- left-aligned nav list: right edges 5 px apart are a near-miss, but the items belong to a left column → no snap -->
17
+ <div id="viewport-1-a-nav" style="position:absolute;left:0;top:400px;width:240px;height:200px">
18
+ <div id="viewport-1-a-nav-item-1" class="text" style="position:absolute;left:30px;top:0;width:99px;height:18px;margin:0">Overview</div>
19
+ <div id="viewport-1-a-nav-item-2" class="text" style="position:absolute;left:30px;top:40px;width:104px;height:18px;margin:0">Proposals</div>
20
+ <div id="viewport-1-a-nav-item-3" class="text" style="position:absolute;left:30px;top:80px;width:100px;height:18px;margin:0">Streams</div>
21
+ </div>
22
+ <!-- rows 1 px apart whose labels + icons already form exact columns: snapping the ROW boxes would break the ink → no snap -->
23
+ <div id="viewport-1-a-list" style="position:absolute;left:400px;top:400px;width:300px;height:200px">
24
+ <div id="viewport-1-a-list-row-1" style="position:absolute;left:10px;top:0;width:200px;height:24px"><span id="viewport-1-a-list-row-1-icon" class="icon" style="position:absolute;left:0;top:4px;width:16px;height:16px;background:#888"></span><span id="viewport-1-a-list-row-1-text" class="text" style="position:absolute;left:28px;top:2px;margin:0;width:100px">Alpha</span></div>
25
+ <div id="viewport-1-a-list-row-2" style="position:absolute;left:11.1px;top:40px;width:200px;height:24px"><span id="viewport-1-a-list-row-2-icon" class="icon" style="position:absolute;left:-1.1px;top:4px;width:16px;height:16px;background:#888"></span><span id="viewport-1-a-list-row-2-text" class="text" style="position:absolute;left:26.9px;top:2px;margin:0;width:100px">Beta</span></div>
26
+ <div id="viewport-1-a-list-row-3" style="position:absolute;left:10px;top:80px;width:200px;height:24px"><span id="viewport-1-a-list-row-3-icon" class="icon" style="position:absolute;left:0;top:4px;width:16px;height:16px;background:#888"></span><span id="viewport-1-a-list-row-3-text" class="text" style="position:absolute;left:28px;top:2px;margin:0;width:100px">Gamma</span></div>
27
+ </div>
28
+ </section>
@@ -0,0 +1,7 @@
1
+ <!doctype html><meta charset="utf-8"><style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui}section{position:relative;width:1536px;height:1024px;background:#959595}.text{position:relative;display:block;margin:40px;font-size:20px}</style>
2
+ <script id="proto-state" type="application/json">{"tokens":{"headingColor":"rgb(30, 30, 29)"}}</script>
3
+ <section id="viewport-1-a">
4
+ <!-- unreachable: a saturated yellow on MID GREY — neither darkening to the floor nor lightening to the ceiling reaches 3:1
5
+ within its hue, so the rule falls back and says so -->
6
+ <p id="viewport-1-a-orange-grey" class="text" style="color:#ffe600">Yellow on mid grey</p>
7
+ </section>
@@ -0,0 +1,9 @@
1
+ <!doctype html><meta charset="utf-8"><style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui}section{position:relative;width:1536px;height:1024px;background:#fbf6ee}.text{position:relative;display:block;margin:40px;font-size:20px}</style>
2
+ <script id="proto-state" type="application/json">{"tokens":{"headingColor":"rgb(30, 30, 29)","bodyColor":"rgb(60, 60, 60)"}}</script>
3
+ <section id="viewport-1-a">
4
+ <!-- fire: a declared brand orange on cream (≈2.5:1) darkens WITHIN its hue to a readable orange — not to black -->
5
+ <p id="viewport-1-a-orange" class="text" style="color:#e68000">Give your time. Strengthen our city.</p>
6
+ <!-- fire: a holding title painted near-white on a light page (1.06:1): a neutral keeps its (zero) saturation → grey -->
7
+ <h1 class="proto-holding-title text" style="color:rgb(247,248,248)">Changelog</h1>
8
+ <span id="viewport-1-a-fine" class="text" style="color:rgb(30,30,30)">Readable copy</span>
9
+ </section>
@@ -0,0 +1,9 @@
1
+ <!doctype html><meta charset="utf-8"><style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui}section{position:relative;width:1536px;height:1024px;background:#fbf6ee}.text{position:relative;display:block;margin:40px;font-size:20px}.hero{position:absolute;left:0;top:0;width:800px;height:400px;background-image:linear-gradient(#234,#123)}</style>
2
+ <script id="proto-state" type="application/json">{"tokens":{"headingColor":"rgb(30, 30, 29)"}}</script>
3
+ <section id="viewport-1-a">
4
+ <div id="viewport-1-a-hero" class="hero"></div>
5
+ <!-- light text over an image-backed hero is design; an already-readable declared brand colour (#a05600, 4.6:1) is untouched -->
6
+ <h1 id="viewport-1-a-hero-heading" class="text" style="position:absolute;left:40px;top:40px;color:rgb(250,250,250)">Over the photo</h1>
7
+ <span id="viewport-1-a-brand" class="text" style="margin-top:500px;color:#a05600">Already readable orange</span>
8
+ <span id="viewport-1-a-body" class="text" style="color:rgb(40,40,40)">Readable copy</span>
9
+ </section>
@@ -0,0 +1,2 @@
1
+ [{"id":"viewport-1-a-header-search","dy":-26},
2
+ {"id":"viewport-1-a-card","w":222}]
@@ -0,0 +1,23 @@
1
+ <!doctype html><meta charset="utf-8"><style>
2
+ *{margin:0;padding:0;box-sizing:border-box} body{font-family:Arial,Helvetica,sans-serif}
3
+ section{position:relative;width:1536px;height:800px;background:#fff}
4
+ .text,h1,h2,h3{position:relative;display:block;white-space:pre;margin:0}
5
+ .card{position:absolute;background:#ffffff;border:1px solid #dfe3e8;border-radius:12px}
6
+ .icon{position:absolute;background:#9aa7b4;border-radius:3px}
7
+ </style>
8
+ <section id="viewport-1-a">
9
+
10
+ <!-- 1. canonical header band is 60px tall; the transplanted search pill keeps the donor page's y=52 -->
11
+ <header id="viewport-1-a-header" data-proto-shell style="position:absolute;left:0;top:0;width:1536px;height:60px;background:#0a1928">
12
+ <p id="viewport-1-a-header-brand" class="text" style="position:absolute;left:32px;top:22px;width:120px;height:16px;font-size:14px;line-height:16px;color:#e6edf4">Kestrel</p>
13
+ <div id="viewport-1-a-header-search" style="position:absolute;left:600px;top:52px;width:280px;height:30px">
14
+ <p id="viewport-1-a-header-search-text" class="text" style="position:absolute;left:240px;top:10px;width:30px;height:10px;font-size:10px;line-height:10px;color:#8ea3b8">⌘ K</p>
15
+ </div>
16
+ </header>
17
+
18
+ <!-- 2. a card whose label is wider than the card, with open space to its right → grow the card -->
19
+ <div id="viewport-1-a-card" class="card" style="left:200px;top:200px;width:200px;height:120px">
20
+ <p id="viewport-1-a-card-text" class="text" style="position:absolute;left:16px;top:20px;width:200px;height:19px;font-size:16px;line-height:19px;color:#1b2530">Deployment health overview</p>
21
+ </div>
22
+
23
+ </section>
@@ -0,0 +1,29 @@
1
+ <!doctype html><meta charset="utf-8"><style>
2
+ *{margin:0;padding:0;box-sizing:border-box} body{font-family:Arial,Helvetica,sans-serif}
3
+ section{position:relative;width:1536px;height:600px;background:#fff}
4
+ .text,h1,h2,h3{position:relative;display:block;white-space:pre;margin:0}
5
+ .card{position:absolute;background:#ffffff;border:1px solid #dfe3e8;border-radius:12px}
6
+ .panel{position:absolute;background:#12212f}
7
+ </style>
8
+ <section id="viewport-1-a">
9
+
10
+ <!-- near threshold: the label sits ~1.8px proud of its pill; that is glyph bearing, not a defect -->
11
+ <div id="viewport-1-a-pill" class="card" style="left:80px;top:60px;width:72px;height:24px">
12
+ <p id="viewport-1-a-pill-text" class="text" style="position:absolute;left:8px;top:5px;width:60px;height:14px;font-size:12px;line-height:14px;color:#1b2530">In review 24</p>
13
+ </div>
14
+
15
+ <!-- a display line deliberately overhanging its photo panel, with a neighbour right beside it and no slack
16
+ inside: nothing can be grown, moved or shrunk by 6% → leave it exactly as designed -->
17
+ <div id="viewport-1-a-photo" class="panel" style="left:100px;top:200px;width:200px;height:160px">
18
+ <h2 id="viewport-1-a-photo-heading" style="position:absolute;left:0;top:50px;width:200px;height:44px;font-size:36px;line-height:44px;color:#fff">Night market</h2>
19
+ </div>
20
+ <div id="viewport-1-a-photo-next" class="panel" style="left:310px;top:200px;width:200px;height:160px"></div>
21
+
22
+ <!-- already owned by page-cut: this row is off the canvas as well as out of its footer plate -->
23
+ <footer id="viewport-1-a-footer" data-proto-shell class="panel" style="left:0;top:480px;width:1536px;height:120px">
24
+ <div id="viewport-1-a-footer-legal" style="position:absolute;left:80px;top:132px;width:900px;height:14px">
25
+ <p id="viewport-1-a-footer-legal-text" class="text" style="position:absolute;left:0;top:0;width:420px;height:14px;font-size:12px;line-height:14px;color:#8ea3b8">© 2024 Kestrel Systems. All rights reserved.</p>
26
+ </div>
27
+ </footer>
28
+
29
+ </section>
@@ -0,0 +1,3 @@
1
+ [{"id":"viewport-1-a-metric-text","css":{"letter-spacing":"-0.192px"}},
2
+ {"id":"viewport-1-a-title-heading","css":{"font-size":"30.72px"}},
3
+ {"id":"viewport-1-a-note","h":53}]
@@ -0,0 +1,26 @@
1
+ <!doctype html><meta charset="utf-8"><style>
2
+ *{margin:0;padding:0;box-sizing:border-box} body{font-family:Arial,Helvetica,sans-serif}
3
+ section{position:relative;width:1536px;height:1024px;background:#fff}
4
+ .fx-col{display:flex;flex-direction:column;align-items:flex-start}
5
+ .fx-abs{position:relative}
6
+ .text,h1,h2,h3{position:relative;display:block;white-space:pre;margin:0}
7
+ .card{position:relative;background:#f6f7f9;border:1px solid #e3e6ea;border-radius:12px}
8
+ </style>
9
+ <section id="viewport-1-a">
10
+
11
+ <!-- A: label a few px wider than its slot, parent exactly as wide (no free space) → tighten tracking -->
12
+ <div id="viewport-1-a-metric" class="fx-col" style="position:absolute;left:80px;top:260px;width:180px;height:60px">
13
+ <p id="viewport-1-a-metric-text" class="text" style="width:180px;height:19px;font-size:16px;line-height:19px">Monthly recurring revenue</p>
14
+ </div>
15
+
16
+ <!-- B: too wide for tracking alone, inside 6% → trim the font -->
17
+ <div id="viewport-1-a-title" class="fx-col" style="position:absolute;left:80px;top:400px;width:199px;height:60px">
18
+ <h2 id="viewport-1-a-title-heading" style="width:199px;height:40px;font-size:32px;line-height:40px">DASHBOARD</h2>
19
+ </div>
20
+
21
+ <!-- C: wrapped paragraph clipped by a card that is the last thing in its column (real slack below) -->
22
+ <div id="viewport-1-a-note" class="card" style="position:absolute;left:80px;top:560px;width:360px;height:44px;overflow:hidden;padding:12px">
23
+ <p id="viewport-1-a-note-text" class="text" style="width:336px;height:auto;font-size:15px;line-height:20px;white-space:normal">Wellness exam completed. Mild gingivitis noted and discussed a dental cleaning at the next visit.</p>
24
+ </div>
25
+
26
+ </section>