@12ui/design 0.2.43 → 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 (151) hide show
  1. package/README.md +17 -5
  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 +41 -2
  16. package/dist/cli-branch-command.js.map +1 -1
  17. package/dist/cli-capabilities.d.ts +2 -1
  18. package/dist/cli-capabilities.d.ts.map +1 -1
  19. package/dist/cli-capabilities.js +2 -1
  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 +17 -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/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/legacy-skill-catalog.d.ts.map +1 -1
  36. package/dist/legacy-skill-catalog.js +7 -0
  37. package/dist/legacy-skill-catalog.js.map +1 -1
  38. package/dist/prototype-gates.d.ts +8 -0
  39. package/dist/prototype-gates.d.ts.map +1 -1
  40. package/dist/prototype-gates.js +39 -5
  41. package/dist/prototype-gates.js.map +1 -1
  42. package/dist/prototype-poststep.d.ts +1 -0
  43. package/dist/prototype-poststep.d.ts.map +1 -1
  44. package/dist/prototype-poststep.js +10 -0
  45. package/dist/prototype-poststep.js.map +1 -1
  46. package/dist/prototype-process.d.ts +1 -1
  47. package/dist/prototype-process.d.ts.map +1 -1
  48. package/dist/prototype-revision.d.ts +2 -0
  49. package/dist/prototype-revision.d.ts.map +1 -0
  50. package/dist/prototype-revision.js +2 -0
  51. package/dist/prototype-revision.js.map +1 -0
  52. package/dist/prototype-run-record.d.ts +6 -1
  53. package/dist/prototype-run-record.d.ts.map +1 -1
  54. package/dist/prototype-run-record.js +113 -9
  55. package/dist/prototype-run-record.js.map +1 -1
  56. package/dist/prototype-runner.d.ts +30 -1
  57. package/dist/prototype-runner.d.ts.map +1 -1
  58. package/dist/prototype-runner.js +69 -16
  59. package/dist/prototype-runner.js.map +1 -1
  60. package/dist/run-journal.d.ts +21 -1
  61. package/dist/run-journal.d.ts.map +1 -1
  62. package/dist/run-journal.js +19 -2
  63. package/dist/run-journal.js.map +1 -1
  64. package/open-design.json +2 -2
  65. package/package.json +2 -2
  66. package/prototype-kit/build.mjs +86 -43
  67. package/prototype-kit/lib/actions.mjs +48 -0
  68. package/prototype-kit/lib/shell-identity.mjs +139 -0
  69. package/prototype-kit/lib/shell-stitching.mjs +70 -29
  70. package/prototype-kit/polish/ground-probe.mjs +119 -0
  71. package/prototype-kit/polish/harm.mjs +23 -0
  72. package/prototype-kit/polish/measure.mjs +333 -0
  73. package/prototype-kit/polish/overrides.mjs +55 -0
  74. package/prototype-kit/polish/polish.mjs +252 -0
  75. package/prototype-kit/polish/report.mjs +76 -0
  76. package/prototype-kit/polish/rules/align.mjs +70 -0
  77. package/prototype-kit/polish/rules/contrast.mjs +80 -0
  78. package/prototype-kit/polish/rules/escape.mjs +192 -0
  79. package/prototype-kit/polish/rules/fit-text.mjs +158 -0
  80. package/prototype-kit/polish/rules/index.mjs +61 -0
  81. package/prototype-kit/polish/rules/left-rail.mjs +77 -0
  82. package/prototype-kit/polish/rules/overlap.mjs +122 -0
  83. package/prototype-kit/polish/rules/page-cut.mjs +203 -0
  84. package/prototype-kit/polish/rules/photo-corners.mjs +38 -0
  85. package/prototype-kit/polish/rules/rhythm.mjs +148 -0
  86. package/prototype-kit/polish/rules/section-breathing.mjs +292 -0
  87. package/prototype-kit/polish/rules/shell-seam.mjs +48 -0
  88. package/prototype-kit/polish/tests/breathing-check.mjs +77 -0
  89. package/prototype-kit/polish/tests/fixtures/align/expect.json +1 -0
  90. package/prototype-kit/polish/tests/fixtures/align/fire.html +14 -0
  91. package/prototype-kit/polish/tests/fixtures/align/silent.html +28 -0
  92. package/prototype-kit/polish/tests/fixtures/contrast/expect.json +1 -0
  93. package/prototype-kit/polish/tests/fixtures/contrast/fallback.html +7 -0
  94. package/prototype-kit/polish/tests/fixtures/contrast/fire.html +9 -0
  95. package/prototype-kit/polish/tests/fixtures/contrast/silent.html +9 -0
  96. package/prototype-kit/polish/tests/fixtures/escape/expect.json +2 -0
  97. package/prototype-kit/polish/tests/fixtures/escape/fire.html +23 -0
  98. package/prototype-kit/polish/tests/fixtures/escape/silent.html +29 -0
  99. package/prototype-kit/polish/tests/fixtures/fit-text/expect.json +3 -0
  100. package/prototype-kit/polish/tests/fixtures/fit-text/fire.html +26 -0
  101. package/prototype-kit/polish/tests/fixtures/fit-text/silent.html +33 -0
  102. package/prototype-kit/polish/tests/fixtures/harm/button-overlap.html +10 -0
  103. package/prototype-kit/polish/tests/fixtures/harm/declared-gap.html +5 -0
  104. package/prototype-kit/polish/tests/fixtures/harm/offcenter.html +21 -0
  105. package/prototype-kit/polish/tests/fixtures/harm/page.html +17 -0
  106. package/prototype-kit/polish/tests/fixtures/left-rail/expect.json +1 -0
  107. package/prototype-kit/polish/tests/fixtures/left-rail/fire.html +8 -0
  108. package/prototype-kit/polish/tests/fixtures/left-rail/silent.html +8 -0
  109. package/prototype-kit/polish/tests/fixtures/overlap/expect.json +4 -0
  110. package/prototype-kit/polish/tests/fixtures/overlap/fire.html +26 -0
  111. package/prototype-kit/polish/tests/fixtures/overlap/silent.html +39 -0
  112. package/prototype-kit/polish/tests/fixtures/page-cut/expect.json +2 -0
  113. package/prototype-kit/polish/tests/fixtures/page-cut/fire.html +31 -0
  114. package/prototype-kit/polish/tests/fixtures/page-cut/silent.html +22 -0
  115. package/prototype-kit/polish/tests/fixtures/photo-corners/expect.json +1 -0
  116. package/prototype-kit/polish/tests/fixtures/photo-corners/fire.html +5 -0
  117. package/prototype-kit/polish/tests/fixtures/photo-corners/silent.html +9 -0
  118. package/prototype-kit/polish/tests/fixtures/rhythm/expect.json +6 -0
  119. package/prototype-kit/polish/tests/fixtures/rhythm/fire.html +22 -0
  120. package/prototype-kit/polish/tests/fixtures/rhythm/silent.html +27 -0
  121. package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-fire.html +5 -0
  122. package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-silent.html +5 -0
  123. package/prototype-kit/polish/tests/fixtures/section-breathing/expect.json +1 -0
  124. package/prototype-kit/polish/tests/fixtures/section-breathing/fire.html +6 -0
  125. package/prototype-kit/polish/tests/fixtures/section-breathing/floor.html +7 -0
  126. package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-fire.html +11 -0
  127. package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-silent.html +8 -0
  128. package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-fire.html +5 -0
  129. package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-silent.html +5 -0
  130. package/prototype-kit/polish/tests/fixtures/section-breathing/silent.html +6 -0
  131. package/prototype-kit/polish/tests/fixtures/section-breathing/tight.html +7 -0
  132. package/prototype-kit/polish/tests/fixtures/shell-seam/expect.json +1 -0
  133. package/prototype-kit/polish/tests/fixtures/shell-seam/fire.html +20 -0
  134. package/prototype-kit/polish/tests/fixtures/shell-seam/silent.html +21 -0
  135. package/prototype-kit/polish/tests/harm-check.mjs +71 -0
  136. package/prototype-kit/polish/tests/report-check.mjs +31 -0
  137. package/prototype-kit/polish/tests/rules-check.mjs +52 -0
  138. package/prototype-kit/tests/adversarial-runtime.mjs +16 -1
  139. package/prototype-kit/tests/fixtures/blocking-runtime/broken-interaction.html +7 -0
  140. package/prototype-kit/tests/fixtures/four-dropdown/branch.plan.json +29 -0
  141. package/prototype-kit/tests/fixtures/four-dropdown/pages/p2-stream-proposals.html +10 -0
  142. package/prototype-kit/tests/fixtures/four-dropdown/pages/p4-live-stream-results.html +7 -0
  143. package/prototype-kit/tests/fixtures/shell-stitching-runtime/PROVENANCE.md +22 -0
  144. package/prototype-kit/tests/fixtures/shell-stitching-runtime/branch.plan.json +40 -0
  145. package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p0-donor.html +38 -0
  146. package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p1-id-collision.html +38 -0
  147. package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p2-canvas-control.html +39 -0
  148. package/prototype-kit/tests/offline-check.mjs +1 -1
  149. package/prototype-kit/tests/rules-check.mjs +69 -3
  150. package/prototype-kit/tests/shell-stitching-runtime.mjs +116 -0
  151. package/skills/12ui-design/SKILL.md +4 -0
@@ -0,0 +1,333 @@
1
+ // Polish metrics: deterministic, geometry-only, computed in the browser on a rendered page.
2
+ // One page → { overflow, overlap, alignment, whitespace, missingText, shift } with per-finding detail.
3
+ // Used before AND after the polish stage; the numbers are instruments, the findings are the evidence.
4
+
5
+ export const NEAR = 6; // px: an edge this close (but not equal) to another is a near-miss
6
+ export const EXACT = 0.75; // px: closer than this counts as aligned
7
+
8
+ // Runs inside the page. `opts.sourceItems` = inventory items of the source state(s) (id → {x,y,w,h,text}).
9
+ export function pageMetricsScript() {
10
+ return ({ NEAR, EXACT, sourceItems, sectionSelector }) => {
11
+ const sections = [...document.querySelectorAll(sectionSelector || 'section[id^="viewport-"]')];
12
+ const section = sections[0] || document.body;
13
+ const sb = section.getBoundingClientRect();
14
+ const secOf = (el) => el.closest('section[id^="viewport-"]') || section;
15
+ const relTo = (r, s) => { const b = s.getBoundingClientRect(); return { x: r.left - b.left, y: r.top - b.top, w: r.width, h: r.height, r: r.right - b.left, b: r.bottom - b.top }; };
16
+ const rel = (r, el) => relTo(r, el ? secOf(el) : section);
17
+ const textRect = (el) => { const range = document.createRange(); range.selectNodeContents(el); const rr = range.getBoundingClientRect(); return rr.width > 0 ? rr : el.getBoundingClientRect(); };
18
+ // The Range rect is the LINE box (ascent+descent), 6–8 px taller than the declared box: two leaves with a real
19
+ // 3 px gap read as a 3.5 px overlap. Collision tests use box ∩ line box (rhythm lane, 80 of 106 findings were leading).
20
+ const tightRect = (el) => { const b = el.getBoundingClientRect(); const t = textRect(el); return { left: Math.max(b.left, t.left), right: Math.min(b.right, t.right), top: Math.max(b.top, t.top), bottom: Math.min(b.bottom, t.bottom) }; };
21
+ const clipRectOf = (el) => { // intersection of all overflow-clipping ancestors up to the section
22
+ let a = el.parentElement; let c = null; const s = secOf(el);
23
+ while (a && a !== s) { const o = getComputedStyle(a).overflow; if (o !== 'visible') { const r = a.getBoundingClientRect(); c = c ? { left: Math.max(c.left, r.left), top: Math.max(c.top, r.top), right: Math.min(c.right, r.right), bottom: Math.min(c.bottom, r.bottom) } : { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; } a = a.parentElement; }
24
+ return c;
25
+ };
26
+ const kindOf = (el) => isText(el) ? 'text' : (el.tagName === 'BUTTON' || el.classList.contains('button')) ? 'button' : el.classList.contains('icon') ? 'icon' : (el.tagName === 'IMG' || el.classList.contains('image')) ? 'image' : el.classList.contains('badge') ? 'badge' : el.classList.contains('divider') ? 'divider' : 'box';
27
+ const sameKind = (A, B) => { const ka = kindOf(A.el), kb = kindOf(B.el); if (ka !== kb) return false; if (ka === 'text') return Math.abs(parseFloat(getComputedStyle(A.el).fontSize) - parseFloat(getComputedStyle(B.el).fontSize)) < 1 && Math.abs(A.r.h - B.r.h) < 3; if (ka === 'box') return Math.abs(A.r.h - B.r.h) <= 4 || Math.abs(A.r.w - B.r.w) <= 4; return Math.abs(A.r.h - B.r.h) < 3; };
28
+ const imageBacked = (el, r) => { let a = el.parentElement; while (a) { if (getComputedStyle(a).backgroundImage !== 'none') return true; a = a.parentElement; } const s = secOf(el); for (const im of s.querySelectorAll('img, .image, .decoration, .chart, svg, canvas, video')) { const ir = im.getBoundingClientRect(); if (ir.width && ir.height && Math.min(ir.right, r.right) - Math.max(ir.left, r.left) > 0 && Math.min(ir.bottom, r.bottom) - Math.max(ir.top, r.top) > 0) return true; } return false; };
29
+ const visible = (el) => {
30
+ const cs = getComputedStyle(el);
31
+ if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) return false;
32
+ const r = el.getBoundingClientRect();
33
+ return r.width > 0 && r.height > 0;
34
+ };
35
+ const isText = (el) => el.classList.contains('text') || el.classList.contains('paragraph') || /^H[1-6]$/.test(el.tagName);
36
+ const isLeaf = (el) => isText(el) || el.classList.contains('icon') || el.tagName === 'IMG' || el.classList.contains('image') || el.classList.contains('badge') || el.classList.contains('divider');
37
+ const inShell = (el) => !!el.closest('[data-proto-shell], aside, header, footer, [id*="sidebar"], [id*="-nav"]');
38
+ const scope = sections.length ? sections : [section];
39
+ const qsa = (sel) => scope.flatMap((s) => [...s.querySelectorAll(sel)]);
40
+ const leaves = qsa('.text, .paragraph, h1, h2, h3, h4, h5, h6, .icon, img, .image, .badge, .divider, button, .button')
41
+ .filter((el) => visible(el) && !el.closest('#proto-overlay-root') && !el.querySelector('.text, .paragraph') && !(el.matches('button, .button') && el.closest('.proto-notice, .proto-holding-block')));
42
+ const containers = qsa('div[id], button[id], aside[id], header[id], footer[id], nav[id], article[id], form[id], main[id]')
43
+ .filter((el) => visible(el) && !el.closest('#proto-overlay-root'));
44
+ const parseRgb = (s) => { const m = s && s.match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(',').map(parseFloat); return { r: p[0], g: p[1], b: p[2], a: p[3] === undefined ? 1 : p[3] }; };
45
+ const lum = (c) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b); };
46
+ // what is actually painted under the text: walk the hit-test stack at the text centre
47
+ const effectiveBg = (el) => {
48
+ el.scrollIntoView({ block: 'center', inline: 'nearest' });
49
+ const r = el.getBoundingClientRect();
50
+ const stack = document.elementsFromPoint(Math.min(window.innerWidth - 1, r.left + r.width / 2), Math.min(window.innerHeight - 1, r.top + Math.min(r.height / 2, 8)));
51
+ for (const e of stack) {
52
+ if (e.closest('.proto-notice, #proto-overlay-root, .proto-toast')) continue; // prototype chrome floats above the page; it is not the background
53
+ if (e === el || e.contains(el)) { const c0 = parseRgb(getComputedStyle(e).backgroundColor); if (e !== el && c0 && c0.a > 0.5) return c0; continue; }
54
+ if (e.tagName === 'IMG' || e.tagName === 'svg' || e.tagName === 'CANVAS' || e.tagName === 'VIDEO' || getComputedStyle(e).backgroundImage !== 'none') return null; // image-backed
55
+ const c = parseRgb(getComputedStyle(e).backgroundColor); if (c && c.a > 0.5) return c;
56
+ }
57
+ return { r: 255, g: 255, b: 255, a: 1 };
58
+ };
59
+ const out = { overflow: [], overlap: [], alignment: [], whitespace: [], missingText: [], shift: [], counts: {} };
60
+
61
+ // ---- overflow / clipping / escape
62
+ for (const el of leaves) {
63
+ if (!isText(el)) continue;
64
+ const sec = secOf(el); const secB = sec.getBoundingClientRect();
65
+ const er = el.getBoundingClientRect(); const tr = textRect(el);
66
+ const r = rel(er, el); const t = relTo(tr, sec);
67
+ const box = { id: el.id, text: (el.textContent || '').trim().slice(0, 80), x: +r.x.toFixed(1), y: +r.y.toFixed(1), w: +r.w.toFixed(1), h: +r.h.toFixed(1) };
68
+ // spill: the text's real line boxes extend beyond the element's own box (a nowrap label longer than its slot)
69
+ const spillX = Math.max(tr.right - er.right, er.left - tr.left);
70
+ if (spillX > 1.5) out.overflow.push({ kind: 'spill', px: +spillX.toFixed(1), ...box });
71
+ // clipped: an overflow-hiding ancestor cuts the text
72
+ const clip = clipRectOf(el);
73
+ if (clip) { const cp = Math.max(tr.right - clip.right, clip.left - tr.left, tr.bottom - clip.bottom, clip.top - tr.top); if (cp > 1.5) out.overflow.push({ kind: 'clipped', px: +cp.toFixed(1), ...box }); }
74
+ // escape: text box leaves an opaque ancestor (card) or the section itself
75
+ let a = el.parentElement, esc = null;
76
+ while (a && a !== sec) {
77
+ const cs2 = getComputedStyle(a); const bg = parseRgb(cs2.backgroundColor);
78
+ if ((bg && bg.a > 0.5) || parseFloat(cs2.borderTopWidth) > 0) {
79
+ const ar = rel(a.getBoundingClientRect(), el);
80
+ const over = Math.max(t.r - ar.r, ar.x - t.x, t.b - ar.b, ar.y - t.y);
81
+ if (over > 1.5) esc = { kind: 'escape', px: +over.toFixed(1), container: a.id, ...box };
82
+ break;
83
+ }
84
+ a = a.parentElement;
85
+ }
86
+ if (esc) out.overflow.push(esc);
87
+ const cut = Math.max(t.r - secB.width, t.b - secB.height, -t.x, -t.y);
88
+ if (cut > 1.5) out.overflow.push({ kind: 'cut', px: +cut.toFixed(1), ...box });
89
+ }
90
+
91
+ // ---- overlap between unrelated leaves
92
+ const L = leaves.map((el) => ({ el, r: rel(isText(el) ? tightRect(el) : el.getBoundingClientRect(), el) }));
93
+ for (let i = 0; i < L.length; i++) for (let j = i + 1; j < L.length; j++) {
94
+ const A = L[i], B = L[j];
95
+ if (A.el.contains(B.el) || B.el.contains(A.el) || secOf(A.el) !== secOf(B.el)) continue;
96
+ const ix = Math.min(A.r.r, B.r.r) - Math.max(A.r.x, B.r.x), iy = Math.min(A.r.b, B.r.b) - Math.max(A.r.y, B.r.y);
97
+ if (ix > 1 && iy > 3 && ix * iy > 24) {
98
+ // decorations/images behind text are design (hero text over image); only text×text, text×icon, icon×icon count
99
+ const kA = kindOf(A.el), kB = kindOf(B.el);
100
+ const textText = kA === 'text' && kB === 'text';
101
+ const textIcon = (kA === 'text' && kB === 'icon') || (kA === 'icon' && kB === 'text');
102
+ // a control (button) landing on text from another parent — a wrapped "Sign in" over a hero eyebrow (owner r4)
103
+ const textButton = (kA === 'text' && kB === 'button') || (kA === 'button' && kB === 'text');
104
+ if (!(textText || textIcon || textButton)) continue;
105
+ if (textIcon && A.el.parentElement === B.el.parentElement) continue; // icon + label inside one pill is design
106
+ if (textButton && (A.el.contains(B.el) || B.el.contains(A.el) || A.el.parentElement === B.el.parentElement)) continue;
107
+ out.overlap.push({ a: A.el.id, b: B.el.id, area: +(ix * iy).toFixed(0), ix: +ix.toFixed(1), iy: +iy.toFixed(1), ta: (A.el.textContent || '').trim().slice(0, 40), tb: (B.el.textContent || '').trim().slice(0, 40) });
108
+ }
109
+ }
110
+
111
+ // ---- alignment near-misses: siblings under the same parent, and "peer" containers in a row
112
+ const byParent = new Map();
113
+ for (const el of [...leaves, ...containers]) {
114
+ const p = el.parentElement; if (!p) continue;
115
+ if (!byParent.has(p)) byParent.set(p, []);
116
+ byParent.get(p).push(el);
117
+ }
118
+ const pushAlign = (axis, a, b, d) => out.alignment.push({ axis, a: a.id, b: b.id, delta: +d.toFixed(2) });
119
+ for (const [, kids] of byParent) {
120
+ const ks = kids.filter((k) => !k.classList.contains('decoration')).map((k) => ({ el: k, r: rel(k.getBoundingClientRect(), k) }));
121
+ if (ks.length < 2) continue;
122
+ for (let i = 0; i < ks.length; i++) for (let j = i + 1; j < ks.length; j++) {
123
+ const A = ks[i], B = ks[j];
124
+ if (!sameKind(A, B)) continue;
125
+ // vertical stack → compare left edges; horizontal row → compare tops
126
+ const stacked = A.r.b <= B.r.y + 1 || B.r.b <= A.r.y + 1;
127
+ const sideBySide = A.r.r <= B.r.x + 1 || B.r.r <= A.r.x + 1;
128
+ if (stacked && !sideBySide) {
129
+ const dl = Math.abs(A.r.x - B.r.x), dr = Math.abs(A.r.r - B.r.r);
130
+ if (dl > EXACT && dl <= NEAR) pushAlign('left', A.el, B.el, dl);
131
+ else if (dr > EXACT && dr <= NEAR && dl > NEAR) pushAlign('right', A.el, B.el, dr);
132
+ } else if (sideBySide && !stacked) {
133
+ const dt = Math.abs(A.r.y - B.r.y), db = Math.abs(A.r.b - B.r.b);
134
+ const cy = Math.abs((A.r.y + A.r.h / 2) - (B.r.y + B.r.h / 2));
135
+ if (dt > EXACT && dt <= NEAR) pushAlign('top', A.el, B.el, dt);
136
+ else if (db > EXACT && db <= NEAR && dt > NEAR) pushAlign('bottom', A.el, B.el, db);
137
+ else if (cy > EXACT && cy <= NEAR && dt > NEAR && db > NEAR && Math.abs(A.r.h - B.r.h) < 2 * NEAR) pushAlign('center', A.el, B.el, cy);
138
+ }
139
+ }
140
+ }
141
+
142
+ // ---- whitespace: gaps between consecutive stacked siblings; "almost the same" gaps are noise
143
+ for (const [p, kids] of byParent) {
144
+ const ks = kids.filter((k) => !k.classList.contains('decoration')).map((k) => ({ el: k, r: rel(k.getBoundingClientRect(), k) })).sort((a, b) => a.r.y - b.r.y);
145
+ const gaps = [];
146
+ for (let i = 1; i < ks.length; i++) {
147
+ const prev = ks[i - 1], cur = ks[i];
148
+ if (!sameKind(prev, cur)) continue;
149
+ const stacked = prev.r.b <= cur.r.y + 1 && Math.min(prev.r.r, cur.r.r) - Math.max(prev.r.x, cur.r.x) > 0;
150
+ if (stacked) gaps.push({ a: prev.el.id, b: cur.el.id, gap: +(cur.r.y - prev.r.b).toFixed(2) });
151
+ }
152
+ if (gaps.length < 2) continue;
153
+ // cluster gaps: two gaps in the same stack within NEAR of each other but not equal → inconsistent rhythm
154
+ for (let i = 0; i < gaps.length; i++) for (let j = i + 1; j < gaps.length; j++) {
155
+ const d = Math.abs(gaps[i].gap - gaps[j].gap);
156
+ if (d > EXACT && d <= NEAR) out.whitespace.push({ parent: p.id, g1: gaps[i], g2: gaps[j], delta: +d.toFixed(2) });
157
+ }
158
+ }
159
+
160
+ // ---- structure the harm guard protects: exactly-aligned same-kind sibling pairs ("columns": an icon column, a
161
+ // label column, a card row's top edge) and every consecutive sibling gap (any kind — the rail/content gutter is a
162
+ // gap between two siblings). A repair that breaks a column or changes a gap by more than a hair is harm.
163
+ out.columns = []; out.gaps = [];
164
+ const columnPairs = (ks) => {
165
+ for (let i = 0; i < ks.length; i++) for (let j = i + 1; j < ks.length; j++) {
166
+ const A = ks[i], B = ks[j];
167
+ if (!sameKind(A, B)) continue;
168
+ const stacked = A.r.b <= B.r.y + 1 || B.r.b <= A.r.y + 1;
169
+ const sideBySide = A.r.r <= B.r.x + 1 || B.r.r <= A.r.x + 1;
170
+ if (stacked && !sideBySide && Math.abs(A.r.x - B.r.x) <= EXACT) out.columns.push({ axis: 'left', a: A.el.id, b: B.el.id });
171
+ else if (sideBySide && !stacked && Math.abs(A.r.y - B.r.y) <= EXACT) out.columns.push({ axis: 'top', a: A.el.id, b: B.el.id });
172
+ }
173
+ };
174
+ // cousins too: the icons of a nav list each live in their own row — the column is across the rows' parent
175
+ const byGrand = new Map();
176
+ for (const el of leaves) { const g = el.parentElement?.parentElement; if (!g || !el.id) continue; if (!byGrand.has(g)) byGrand.set(g, []); byGrand.get(g).push(el); }
177
+ for (const [, cousins] of byGrand) {
178
+ const ks = cousins.filter((k) => !k.classList.contains('decoration')).map((k) => ({ el: k, r: rel(k.getBoundingClientRect(), k) }));
179
+ const parents = new Set(ks.map((k) => k.el.parentElement)); if (parents.size < 2) continue;
180
+ columnPairs(ks.filter((k) => kindOf(k.el) === 'icon' || kindOf(k.el) === 'text'));
181
+ }
182
+ for (const [p, kids] of byParent) {
183
+ const ks = kids.filter((k) => !k.classList.contains('decoration')).map((k) => ({ el: k, r: rel(k.getBoundingClientRect(), k) }));
184
+ columnPairs(ks);
185
+ const byY = [...ks].sort((a, b) => a.r.y - b.r.y);
186
+ for (let i = 1; i < byY.length; i++) { const P = byY[i - 1], C = byY[i]; if (P.r.b <= C.r.y + 1 && Math.min(P.r.r, C.r.r) - Math.max(P.r.x, C.r.x) > 0) out.gaps.push({ axis: 'v', a: P.el.id, b: C.el.id, gap: +(C.r.y - P.r.b).toFixed(2) }); }
187
+ const byX = [...ks].sort((a, b) => a.r.x - b.r.x);
188
+ const fitHost = p.closest ? p.closest('[data-proto-fit]') : null; const fh = fitHost ? fitHost.id : null;
189
+ for (let i = 1; i < byX.length; i++) { const P = byX[i - 1], C = byX[i]; if (P.r.r <= C.r.x + 1 && Math.min(P.r.b, C.r.b) - Math.max(P.r.y, C.r.y) > 0) out.gaps.push({ axis: 'h', a: P.el.id, b: C.el.id, gap: +(C.r.x - P.r.r).toFixed(2), fit: fh }); }
190
+ }
191
+
192
+ // ---- fitted shell children: does the inner ink still honour the donor's alignment relation? (build.mjs marks
193
+ // every clamped container with data-proto-fit = centered | left | right). Measured live on the painted ink:
194
+ // centered → |leftInset − rightInset| ≤ 8 px right → right inset ≤ 12 px left → left inset ≤ 12 px or a
195
+ // flanking sibling box touches the ink (an avatar beside a name). Anything else is an off-center finding.
196
+ out.offcenter = [];
197
+ for (const host of qsa('[data-proto-fit]')) {
198
+ if (!visible(host)) continue;
199
+ const rel = host.getAttribute('data-proto-fit');
200
+ const hr = host.getBoundingClientRect();
201
+ const ink = [...host.querySelectorAll('.text, .paragraph, .icon, h1, h2, h3, h4, h5, h6, button > span')].filter((el) => visible(el) && (el.classList.contains('icon') || (el.textContent || '').trim()));
202
+ if (!ink.length) continue;
203
+ const boxes = host.querySelectorAll('[id]');
204
+ const rects = ink.map((el) => (el.tagName === 'SPAN' && !el.classList.contains('icon')) ? textRect(el) : el.getBoundingClientRect());
205
+ // contiguous groups along x (gap ≤ 16), the same notion the build used
206
+ const sorted = rects.map((r, i) => ({ r, el: ink[i] })).sort((a, b) => a.r.left - b.r.left);
207
+ const groups = []; for (const it of sorted) { const g = groups[groups.length - 1]; if (g && it.r.left - Math.max(...g.map((x) => x.r.right)) <= 16) g.push(it); else groups.push([it]); }
208
+ for (const g of groups) {
209
+ const left = Math.min(...g.map((x) => x.r.left)), right = Math.max(...g.map((x) => x.r.right));
210
+ const top = Math.min(...g.map((x) => x.r.top)), bottom = Math.max(...g.map((x) => x.r.bottom));
211
+ // flanking sibling boxes (badges, avatars) in the same band move the anchor, as in the build
212
+ let la = hr.left, ra = hr.right;
213
+ for (const b of boxes) { if (b === host || g.some((x) => b === x.el || b.contains(x.el))) continue; const br = b.getBoundingClientRect(); if (!br.width || br.top >= bottom || br.bottom <= top) continue; if (br.right <= left + 1) la = Math.max(la, br.right); if (br.left >= right - 1) ra = Math.min(ra, br.left); }
214
+ const li = left - la, ri = ra - right;
215
+ const expL = parseFloat(host.getAttribute('data-proto-fit-left')), expR = parseFloat(host.getAttribute('data-proto-fit-right'));
216
+ let bad = false;
217
+ const partial = host.hasAttribute('data-proto-fit-partial');
218
+ // a partial re-centre (ink stopped at the box edge) is judged as 'no worse than the donor', not as centred
219
+ if (rel === 'centered') bad = groups.length === 1 && (partial ? (Number.isFinite(expL) && Number.isFinite(expR) && Math.abs(li - ri) > Math.abs(expL - expR) + 2) : Math.abs(li - ri) > 8);
220
+ else if (rel === 'right') bad = g === groups[groups.length - 1] && (Number.isFinite(expR) ? Math.abs(ri - expR) > 6 : ri > 12); // the donor's right inset, kept
221
+ else if (rel === 'left') bad = g === groups[0] && (Number.isFinite(expL) ? li - expL > 6 : (li > 12 && la === hr.left && ri < li)); // the donor's left inset, kept
222
+ if (bad) out.offcenter.push({ id: host.id, relation: rel, leftInset: +li.toFixed(1), rightInset: +ri.toFixed(1), ink: g.map((x) => x.el.id || x.el.tagName).slice(0, 3) });
223
+ }
224
+ }
225
+
226
+ // ---- rail raggedness: rows of the page's top-level blocks whose left edge is 2–48 px off the dominant left inset
227
+ // (design-quality metric; the left-rail rule drives it down; a rise is harm)
228
+ out.rail = [];
229
+ {
230
+ const INK = '.text, .paragraph, h1, h2, h3, h4, h5, h6, .icon, img, .image, .badge, button, .button';
231
+ const paintedBox = (el) => { const cs = getComputedStyle(el); const bg = parseRgb(cs.backgroundColor); return (bg && bg.a > 0.5) || parseFloat(cs.borderLeftWidth) > 0; };
232
+ for (const sec of scope) {
233
+ const sb = sec.getBoundingClientRect();
234
+ const edgeOf = (el) => { const r = el.getBoundingClientRect(); let left = paintedBox(el) ? r.left : Infinity, right = paintedBox(el) ? r.right : -Infinity; for (const k of [...(el.matches(INK) ? [el] : []), ...el.querySelectorAll(INK)]) { if (!visible(k)) continue; const kr = k.getBoundingClientRect(); if (kr.left - sb.left <= 2) continue; left = Math.min(left, kr.left); right = Math.max(right, kr.right); } for (const k of el.querySelectorAll('[id]:not(.text):not(.icon):not(img):not(button)')) { if (!visible(k) || !paintedBox(k)) continue; const kr = k.getBoundingClientRect(); if (kr.left - sb.left <= 2 || kr.width < 40) continue; left = Math.min(left, kr.left); right = Math.max(right, kr.right); } return Number.isFinite(left) ? { left: left - sb.left, right: right - sb.left } : null; };
235
+ const rows = [];
236
+ for (const block of sec.children) {
237
+ if (!block.id || !visible(block) || block.closest('#proto-overlay-root')) continue;
238
+ const be = edgeOf(block); if (!be) continue;
239
+ let kids = [...block.children].filter((k) => k.id && visible(k));
240
+ if (kids.length === 1 && kids[0].children.length > 1) kids = [...kids[0].children].filter((k) => k.id && visible(k));
241
+ if (!kids.length) kids = [block];
242
+ for (const k of kids) { const e = edgeOf(k); if (!e) continue; if (e.left <= 2) continue; if ((e.right - e.left) < 0.6 * sb.width && Math.abs(e.left - (sb.width - e.right)) <= 24) continue; rows.push({ id: k.id, edge: e.left, blockEdge: be.left, block: block.id }); }
243
+ }
244
+ if (rows.length < 3) continue;
245
+ const sorted = [...rows].sort((a, b) => a.edge - b.edge); const clusters = [];
246
+ for (const r of sorted) { const c = clusters[clusters.length - 1]; if (c && r.edge - c[0].edge <= 4) c.push(r); else clusters.push([r]); }
247
+ clusters.sort((a, b) => new Set(b.map((r) => r.block)).size - new Set(a.map((r) => r.block)).size || b.length - a.length);
248
+ const edges = clusters[0].map((r) => r.edge).sort((a, b) => a - b); const rail = edges[Math.floor((edges.length - 1) / 2)];
249
+ for (const r of rows) { const d = Math.abs(rail - r.edge); if (d >= 2 && d <= 48 && !(r.edge - r.blockEdge >= 12 && Math.abs(r.blockEdge - rail) < 2)) out.rail.push({ id: r.id, edge: +r.edge.toFixed(1), rail: +rail.toFixed(1) }); }
250
+ }
251
+ }
252
+
253
+ // ---- explicit plan seams (stacked pages): ink gap across each adjacent pair of viewport sections
254
+ out.seams = [];
255
+ {
256
+ const INK = '.text, .paragraph, h1, h2, h3, h4, h5, h6, .icon, img, .image, button, .button';
257
+ const secs = [...scope].filter((s) => visible(s)).sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
258
+ for (let i = 1; i < secs.length; i++) {
259
+ const A = secs[i - 1], B = secs[i]; const ar = A.getBoundingClientRect(), br = B.getBoundingClientRect();
260
+ const aInk = [...A.querySelectorAll(INK)].filter(visible).map((e) => e.getBoundingClientRect().bottom), bInk = [...B.querySelectorAll(INK)].filter(visible).map((e) => e.getBoundingClientRect().top);
261
+ if (!aInk.length || !bInk.length) continue;
262
+ out.seams.push({ before: A.id, after: B.id, gap: +(Math.min(...bInk) - Math.max(...aInk)).toFixed(1), dom: +(br.top - ar.bottom).toFixed(1) });
263
+ }
264
+ }
265
+
266
+ // ---- missing / invisible text vs source
267
+ const builtText = new Map();
268
+ for (const el of qsa('.text, .paragraph, h1, h2, h3, h4, h5, h6')) if (el.id) builtText.set(el.id, el);
269
+ const replacedShell = new Set([...document.querySelectorAll('[data-proto-shell]')].map((el) => el.id));
270
+ if (sourceItems) {
271
+ for (const [id, src] of Object.entries(sourceItems)) {
272
+ if (!src.text) continue;
273
+ if ((src.chain || []).some((c) => replacedShell.has(c))) continue; // the canonical shell replaced this page's own shell copy
274
+ const el = builtText.get(id) || document.getElementById(id);
275
+ if (!el) { out.missingText.push({ id, kind: 'absent', src: src.text.slice(0, 60) }); continue; }
276
+ const txt = (el.textContent || '').trim();
277
+ if (!txt) { out.missingText.push({ id, kind: 'empty', src: src.text.slice(0, 60) }); continue; }
278
+ if (!visible(el)) { out.missingText.push({ id, kind: 'hidden', src: src.text.slice(0, 60) }); continue; }
279
+ const cs = getComputedStyle(el); const fg = parseRgb(cs.color); const bg = effectiveBg(el);
280
+ if (fg && bg && !imageBacked(el, el.getBoundingClientRect())) {
281
+ const l1 = lum(fg), l2 = lum(bg); const contrast = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
282
+ if (contrast < 1.25 && fg.a > 0.05) out.missingText.push({ id, kind: 'invisible', contrast: +contrast.toFixed(2), src: src.text.slice(0, 60) });
283
+ }
284
+ const r = rel(el.getBoundingClientRect(), el);
285
+ // top-left corner, not the centre: a width-only repair (fit-text) is not content moving
286
+ const dx = r.x - src.x, dy = r.y - src.y;
287
+ const d = Math.hypot(dx, dy);
288
+ if (d > 1) out.shift.push({ id, dx: +dx.toFixed(1), dy: +dy.toFixed(1), d: +d.toFixed(1) });
289
+ }
290
+ }
291
+ // standalone invisible-text check (no source needed)
292
+ for (const [id, el] of builtText) {
293
+ if (sourceItems && sourceItems[id]) continue;
294
+ if (!visible(el) || !(el.textContent || '').trim()) continue;
295
+ const fg = parseRgb(getComputedStyle(el).color); if (!fg) continue;
296
+ if (imageBacked(el, el.getBoundingClientRect())) continue;
297
+ const bg = effectiveBg(el); if (!bg) continue; const l1 = lum(fg), l2 = lum(bg);
298
+ const contrast = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
299
+ if (contrast < 1.25 && fg.a > 0.05) out.missingText.push({ id, kind: 'invisible', contrast: +contrast.toFixed(2), src: el.textContent.trim().slice(0, 60) });
300
+ }
301
+
302
+ window.scrollTo(0, 0); // effectiveBg scrolled probes into view; leave the page as found
303
+ out.counts = {
304
+ leaves: leaves.length, textLeaves: leaves.filter(isText).length, containers: containers.length,
305
+ overflow: out.overflow.length, overlap: out.overlap.length, alignment: out.alignment.length, whitespace: out.whitespace.length,
306
+ columns: out.columns.length, gaps: out.gaps.length, seams: out.seams.length, narrowSeams: out.seams.filter((x) => x.gap < 48).length, documentHeight: document.documentElement.scrollHeight, offcenter: out.offcenter.length, rail: out.rail.length, missingText: out.missingText.length, shifted: out.shift.length, maxShift: out.shift.reduce((m, s) => Math.max(m, s.d), 0),
307
+ sectionW: sb.width, sectionH: sb.height,
308
+ };
309
+ return out;
310
+ };
311
+ }
312
+
313
+ // Source geometry for a state from inventory.json: id → {x,y,w,h,text}
314
+ export function sourceItemsFor(inventory, stateIds) {
315
+ const map = {};
316
+ for (const page of inventory.pages) for (const sec of page.sections) {
317
+ if (!stateIds.includes(sec.sectionId)) continue;
318
+ for (const it of sec.items) if (it.id && (it.cls.includes('text') || /^h[1-6]$/.test(it.tag))) map[it.id] = { x: it.x, y: it.y, w: it.w, h: it.h, text: it.text, chain: it.chain };
319
+ }
320
+ return map;
321
+ }
322
+
323
+ // Wait until webfonts have actually swapped in (document.fonts.ready resolves before late swaps on some pages —
324
+ // 4/249 pages measured differently run-to-run until this settle was added).
325
+ export async function settleFonts(page, ms = 250) {
326
+ await page.evaluate(() => document.fonts.ready);
327
+ await page.waitForFunction(() => document.fonts.status === 'loaded' && [...document.fonts].every((f) => f.status !== 'loading'));
328
+ await page.waitForTimeout(ms);
329
+ }
330
+
331
+ export async function measurePage(page, { sourceItems = null, sectionSelector = null } = {}) {
332
+ return page.evaluate(pageMetricsScript(), { NEAR, EXACT, sourceItems, sectionSelector });
333
+ }
@@ -0,0 +1,55 @@
1
+ // Override model shared by every polish rule and the driver.
2
+ // An override is { id | sel, rule, reason, dx?, dy?, w?, h?, css?: {prop: value} }.
3
+ // id → targets #id; sel → targets an arbitrary selector (holding pages have class-only nodes)
4
+ // dx/dy → translate (does not disturb siblings; sums across rules)
5
+ // w/h → width/height in px (last rule wins)
6
+ // css → any other declarations (last rule wins per property)
7
+
8
+ export function mergeOverrides(list) {
9
+ const byId = new Map();
10
+ for (const o of list) {
11
+ if (!o || !(o.id || o.sel)) continue;
12
+ const key = o.sel || o.id;
13
+ const cur = byId.get(key) || { id: o.id, sel: o.sel, dx: 0, dy: 0, css: {}, rules: [] };
14
+ cur.dx += o.dx || 0;
15
+ cur.dy += o.dy || 0;
16
+ if (o.w != null) cur.w = o.w;
17
+ if (o.h != null) cur.h = o.h;
18
+ Object.assign(cur.css, o.css || {});
19
+ cur.rules.push(o.rule + (o.reason ? `: ${o.reason}` : ''));
20
+ if (o.meta && !cur.meta) cur.meta = o.meta;
21
+ byId.set(key, cur);
22
+ }
23
+ return [...byId.values()];
24
+ }
25
+
26
+ const esc = (id) => id.replace(/([^\w-])/g, '\\$1');
27
+
28
+ export function overridesToCss(merged) {
29
+ const lines = ['/* proto polish: deterministic per-id overrides; every line names the rule that produced it */'];
30
+ for (const o of merged) {
31
+ const decl = [];
32
+ if (Math.abs(o.dx) > 0.01 || Math.abs(o.dy) > 0.01) decl.push(`transform:translate(${round(o.dx)}px,${round(o.dy)}px)`);
33
+ if (o.w != null) decl.push(`width:${round(o.w)}px`);
34
+ if (o.h != null) decl.push(`height:${round(o.h)}px`);
35
+ for (const [k, v] of Object.entries(o.css)) decl.push(`${k}:${v}`);
36
+ if (!decl.length) continue;
37
+ lines.push(`/* ${o.rules.join(' | ')} */`);
38
+ lines.push(`${o.sel || '#' + esc(o.id)}{${decl.map((d) => d + '!important').join(';')}}`);
39
+ }
40
+ return lines.join('\n') + '\n';
41
+ }
42
+
43
+ function round(n) { return Math.round(n * 100) / 100; }
44
+
45
+ // In-browser: (re)place a <style id="proto-polish"> with the given css text.
46
+ export function applyOverridesScript() {
47
+ return (css) => {
48
+ let style = document.getElementById('proto-polish');
49
+ if (!style) { style = document.createElement('style'); style.id = 'proto-polish'; document.head.appendChild(style); }
50
+ style.textContent = css;
51
+ // force layout
52
+ void document.body.offsetHeight;
53
+ return true;
54
+ };
55
+ }
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ // Polish stage: deterministic visual repair of a built prototype, one page at a time (pages are independent → parallel).
3
+ // Usage: node polish/polish.mjs <protoDir> --out <outDir> [--inventory inventory.json --model model.json] [--rules a,b] [--concurrency 4] [--report polish-report.json]
4
+ //
5
+ // For every real page: measure → run rules (in-browser, geometry-only) → apply overrides live → re-measure →
6
+ // harm guard (reject any rule that adds overlap/clipping/cut or moves content beyond its declared cap) →
7
+ // write polish/<unit>.css + <link> into the page copy. Nothing is redrawn; only per-id CSS overrides are emitted.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { chromium } from '@playwright/test';
11
+ import { startStaticServer } from '../lib/static-server.mjs';
12
+ import { measurePage, sourceItemsFor, settleFonts } from './measure.mjs';
13
+ import { loadRules } from './rules/index.mjs';
14
+ import { applyOverridesScript, overridesToCss, mergeOverrides } from './overrides.mjs';
15
+ import { structureHarm } from './harm.mjs';
16
+ import { probeSeams } from './ground-probe.mjs';
17
+ import { pageRuleRecords, summarizePolishReport } from './report.mjs';
18
+
19
+ const HARM = { newOverlap: 0, newEscape: 0, newCut: 0, newClipped: 0 };
20
+
21
+ export async function polishPrototype({
22
+ protoDir,
23
+ outDir,
24
+ inventoryFile = path.join(protoDir, '..', 'inventory.json'),
25
+ modelFile = path.join(protoDir, '..', 'model.json'),
26
+ reportFile = path.join(outDir, 'polish-report.json'),
27
+ ruleNames = [],
28
+ concurrency = 4,
29
+ log = () => {},
30
+ }) {
31
+ protoDir = path.resolve(protoDir);
32
+ outDir = path.resolve(outDir);
33
+ inventoryFile = path.resolve(inventoryFile);
34
+ modelFile = path.resolve(modelFile);
35
+ reportFile = path.resolve(reportFile);
36
+ if (protoDir === outDir) {
37
+ throw new Error('Polish source and output directories must be different.');
38
+ }
39
+ fs.rmSync(outDir, { recursive: true, force: true });
40
+ fs.cpSync(protoDir, outDir, { recursive: true });
41
+ fs.mkdirSync(path.join(outDir, 'polish'), { recursive: true });
42
+ const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'proto-manifest.json'), 'utf8'));
43
+ const inventory = fs.existsSync(inventoryFile) ? JSON.parse(fs.readFileSync(inventoryFile, 'utf8')) : null;
44
+ const model = fs.existsSync(modelFile) ? JSON.parse(fs.readFileSync(modelFile, 'utf8')) : null;
45
+ const rules = await loadRules(ruleNames);
46
+ const server = await startStaticServer(path.dirname(outDir));
47
+ const base = `${server.baseUrl}/${path.basename(outDir)}`;
48
+ const browser = await chromium.launch();
49
+ const report = {
50
+ version: 1,
51
+ policy: 'retained-v1',
52
+ rules: rules.map((rule) => rule.name),
53
+ pages: [],
54
+ };
55
+
56
+ async function polishPage(unit) {
57
+ const page = await browser.newPage({ viewport: { width: 1536, height: 1024 } });
58
+ const row = { unit: unit.id, rules: {}, accepted: [], rejected: [], overrides: 0 };
59
+ try {
60
+ const sectionIds = model ? unit.states.map((s) => model.states.find((st) => st.stateId === s)?.sectionId).filter(Boolean) : [];
61
+ const sourceItems = inventory && sectionIds.length ? sourceItemsFor(inventory, sectionIds) : null;
62
+ await page.goto(`${base}/${unit.id}.html`, { waitUntil: 'networkidle', timeout: 30_000 });
63
+ await settleFonts(page);
64
+ const before = await measurePage(page, { sourceItems });
65
+ row.before = before.counts;
66
+ // phase 1: geometry rules propose against the pristine page; phase 2 ('post') rules — colour — run against the
67
+ // geometry-polished DOM, because where a block ENDS UP decides what is painted under it (a legal row lifted back
68
+ // into a navy footer must not have been darkened for the white it used to sit on).
69
+ const harmed = (after, ruleSet) => {
70
+ const newOf = (kind) => (after.overflow.filter((o) => o.kind === kind).length) - (before.overflow.filter((o) => o.kind === kind).length);
71
+ const cap = Math.max(0, ...ruleSet.map((p) => p.rule.maxShift || 0));
72
+ // rules that declare block moves (left-rail) change the horizontal gaps beside the rows they move by design
73
+ const exempt = new Set(ruleSet.filter((p) => p.rule.movesBlocks).flatMap((p) => p.overrides.map((o) => o.id)));
74
+ // A spacing rule may change only the exact before->after gaps it declares in seam metadata. Every other
75
+ // vertical sibling gap remains protected by the ordinary harm guard.
76
+ const exemptGaps = new Set(ruleSet.flatMap((p) => p.overrides.map((o) => o.meta?.seam).filter(Boolean).flatMap((s) => (s.beforePeers || [s.before]).map((beforeId) => `${beforeId}|${s.after}`))));
77
+ const st = structureHarm(before, after, exempt, exemptGaps);
78
+ return {
79
+ misaligned: st.misaligned.length > 0,
80
+ spacing: st.spacing.length > 0,
81
+ overlap: after.counts.overlap - before.counts.overlap > HARM.newOverlap,
82
+ escape: newOf('escape') > HARM.newEscape,
83
+ cut: newOf('cut') > HARM.newCut,
84
+ clipped: newOf('clipped') > HARM.newClipped,
85
+ invisible: after.counts.missingText > before.counts.missingText,
86
+ offcenter: (after.counts.offcenter || 0) > (before.counts.offcenter || 0),
87
+ rail: (after.counts.rail || 0) > (before.counts.rail || 0),
88
+ shift: after.counts.maxShift - before.counts.maxShift > cap + 0.5,
89
+ };
90
+ };
91
+ const propose = async (ruleSet, metrics) => {
92
+ const proposals = [];
93
+ for (const rule of ruleSet) {
94
+ const proposed = await page.evaluate(rule.browserFn(), { metrics, params: rule.params || {} });
95
+ const cap = rule.maxShift ?? 0; // each override is held to ITS rule's declared cap
96
+ const found = proposed.filter((o) => Math.abs(o.dx || 0) <= cap + 0.01 && Math.abs(o.dy || 0) <= cap + 0.01);
97
+ row.rules[rule.name] = { proposed: proposed.length, overCap: proposed.length - found.length };
98
+ if (rule.recordsCensus) {
99
+ row.rules[rule.name].census = await page.evaluate(rule.browserFn(), {
100
+ metrics,
101
+ params: { ...(rule.params || {}), census: true },
102
+ });
103
+ }
104
+ if (found.length) proposals.push({ rule, overrides: found });
105
+ }
106
+ return proposals;
107
+ };
108
+ const evaluate = async (set) => {
109
+ const merged = mergeOverrides(set.flatMap((p) => p.overrides));
110
+ await page.evaluate(applyOverridesScript(), overridesToCss(merged));
111
+ const after = await measurePage(page, { sourceItems });
112
+ return { merged, after };
113
+ };
114
+ // greedy acceptance with harm guard: apply all, then drop the rule whose removal helps most until clean
115
+ const accept = async (active) => {
116
+ let result = active.length ? await evaluate(active) : null;
117
+ let guard = result ? harmed(result.after, active) : null;
118
+ while (result && Object.values(guard).some(Boolean) && active.length) {
119
+ let best = null;
120
+ for (let i = 0; i < active.length; i++) {
121
+ const trial = active.filter((_, j) => j !== i);
122
+ const r = trial.length ? await evaluate(trial) : { merged: [], after: before };
123
+ const g = trial.length ? harmed(r.after, trial) : {};
124
+ const score = Object.values(g).filter(Boolean).length;
125
+ if (!best || score < best.score) best = { i, score, r, g };
126
+ }
127
+ row.rejected.push({ rule: active[best.i].rule.name, harm: Object.keys(guard).filter((k) => guard[k]) });
128
+ active = active.filter((_, j) => j !== best.i);
129
+ result = active.length ? best.r : null;
130
+ guard = active.length ? best.g : null;
131
+ }
132
+ return { active, result };
133
+ };
134
+ const geometry = rules.filter((r) => r.phase !== 'post');
135
+ const post = rules.filter((r) => r.phase === 'post');
136
+ let active = await propose(geometry, before);
137
+ let { active: kept, result } = await accept(active);
138
+ active = kept;
139
+ if (post.length) {
140
+ if (result) await page.evaluate(applyOverridesScript(), overridesToCss(result.merged)); // polished DOM in place
141
+ const mid = result ? result.after : before;
142
+ const postProposals = await propose(post, mid);
143
+ if (postProposals.length) {
144
+ const r2 = await accept([...active, ...postProposals]);
145
+ active = r2.active; result = r2.result;
146
+ }
147
+ }
148
+ // background-integrity: a rule that extends a ground (section-breathing) must prove, on real pixels, that every
149
+ // accepted seam is painted in the owner's colour (coverage) and the page floor never shows (floor exposure), and
150
+ // that the document grew by exactly the accepted additions. Any failure rejects that rule on this page.
151
+ for (const rule of rules.filter((r) => r.verifyPaint)) {
152
+ const p = active.find((a) => a.rule === rule); if (!p) continue;
153
+ // baseline height = everything else applied, without this rule (page-cut may legitimately have grown the canvas)
154
+ await page.evaluate(applyOverridesScript(), overridesToCss(mergeOverrides(active.filter((a) => a !== p).flatMap((a) => a.overrides))));
155
+ const baseHeight = await page.evaluate(() => document.documentElement.scrollHeight);
156
+ await page.evaluate(applyOverridesScript(), overridesToCss(mergeOverrides(active.flatMap((a) => a.overrides))));
157
+ const seams = p.overrides.map((o) => o.meta?.seam).filter(Boolean);
158
+ const verdict = await probeSeams(page, seams, baseHeight);
159
+ row.ground = verdict;
160
+ if (!verdict.ok) {
161
+ row.rejected.push({ rule: rule.name, harm: verdict.failures.map((f) => 'ground-' + f.kind) });
162
+ active = active.filter((a) => a !== p);
163
+ result = active.length ? await evaluate(active) : null;
164
+ }
165
+ }
166
+ if (result) {
167
+ // make sure the live DOM reflects the final accepted set
168
+ result = await evaluate(active);
169
+ row.accepted = active.map((p) => ({ rule: p.rule.name, overrides: p.overrides.length }));
170
+ row.after = result.after.counts;
171
+ row.overrides = result.merged.length;
172
+ const css = overridesToCss(result.merged);
173
+ fs.writeFileSync(path.join(outDir, 'polish', `${unit.id}.css`), css);
174
+ const htmlFile = path.join(outDir, `${unit.id}.html`);
175
+ let html = fs.readFileSync(htmlFile, 'utf8');
176
+ if (!html.includes(`polish/${unit.id}.css`)) html = html.replace('</head>', `<link rel="stylesheet" href="polish/${unit.id}.css" data-proto-polish>\n</head>`);
177
+ fs.writeFileSync(htmlFile, html);
178
+ fs.writeFileSync(path.join(outDir, 'polish', `${unit.id}.json`), JSON.stringify({ unit: unit.id, accepted: row.accepted, rejected: row.rejected, overrides: result.merged }, null, 1));
179
+ } else {
180
+ row.after = row.before;
181
+ }
182
+ } catch (error) {
183
+ row.error = error.message;
184
+ row.accepted = [];
185
+ row.overrides = 0;
186
+ row.after = row.before;
187
+ fs.rmSync(path.join(outDir, 'polish', `${unit.id}.css`), { force: true });
188
+ fs.rmSync(path.join(outDir, 'polish', `${unit.id}.json`), { force: true });
189
+ fs.copyFileSync(
190
+ path.join(protoDir, `${unit.id}.html`),
191
+ path.join(outDir, `${unit.id}.html`),
192
+ );
193
+ } finally {
194
+ row.ruleRecords = pageRuleRecords(row, report.rules);
195
+ await page.close();
196
+ }
197
+ const reverted = row.ruleRecords
198
+ .filter((record) => record.status === 'reverted')
199
+ .map((record) => `${record.rule}:${(record.reasons || []).join('+') || 'guard'}`);
200
+ log(`${unit.id}: ${row.overrides} overrides, fired=[${row.ruleRecords.filter((record) => record.status === 'fired').map((record) => record.rule).join(',')}] reverted=[${reverted.join(',')}]${row.error ? ` ERROR ${row.error}` : ''}`);
201
+ return row;
202
+ }
203
+
204
+ // worker pool over independent pages
205
+ // real pages + holding pages (holdings have no source state; rules that need one simply find nothing)
206
+ const queue = [...manifest.units, ...(manifest.holdings || []).map(([slug, title]) => ({ id: slug, states: [], holding: true, title }))];
207
+ const workers = Array.from({ length: Math.max(1, concurrency) }, async () => {
208
+ while (queue.length) {
209
+ const unit = queue.shift();
210
+ report.pages.push(await polishPage(unit));
211
+ }
212
+ });
213
+ await Promise.all(workers);
214
+ report.pages.sort((a, b) => a.unit.localeCompare(b.unit));
215
+ await browser.close();
216
+ await server.close();
217
+ report.summary = summarizePolishReport(report);
218
+ fs.writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`);
219
+ return report;
220
+ }
221
+
222
+ if (process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname) {
223
+ const argv = process.argv.slice(2);
224
+ if (!argv[0]) throw new Error('usage: polish.mjs <protoDir> --out <outDir> [--rules a,b] [--concurrency n]');
225
+ const value = (flag, fallback) => (
226
+ argv.includes(flag) ? argv[argv.indexOf(flag) + 1] : fallback
227
+ );
228
+ const protoDir = path.resolve(argv[0]);
229
+ const outDir = path.resolve(value('--out', `${protoDir}-polished`));
230
+ const reportFile = path.resolve(value('--report', path.join(outDir, 'polish-report.json')));
231
+ const ruleNames = value('--rules', '').split(',').filter(Boolean);
232
+ const concurrency = Number(value('--concurrency', 4));
233
+ const report = await polishPrototype({
234
+ protoDir,
235
+ outDir,
236
+ inventoryFile: value('--inventory', path.join(protoDir, '..', 'inventory.json')),
237
+ modelFile: value('--model', path.join(protoDir, '..', 'model.json')),
238
+ reportFile,
239
+ ruleNames,
240
+ concurrency,
241
+ log: (message) => process.stderr.write(`${message}\n`),
242
+ });
243
+ const sum = (k) => report.pages.reduce((n, p) => n + (p[k === 'before' ? 'before' : 'after']?.overflow || 0), 0);
244
+ console.log(JSON.stringify({
245
+ pages: report.pages.length,
246
+ overrides: report.pages.reduce((n, page) => n + page.overrides, 0),
247
+ rejected: report.summary.reverted,
248
+ pageErrors: report.summary.pageErrors,
249
+ overflowBefore: sum('before'),
250
+ overflowAfter: sum('after'),
251
+ }));
252
+ }