@12ui/design 0.2.48 → 0.2.50

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 (97) hide show
  1. package/SKILL.md +1 -1
  2. package/dist/cli-draft-command.d.ts +11 -5
  3. package/dist/cli-draft-command.d.ts.map +1 -1
  4. package/dist/cli-draft-command.js +37 -35
  5. package/dist/cli-draft-command.js.map +1 -1
  6. package/dist/cli-help/design.d.ts +10 -0
  7. package/dist/cli-help/design.d.ts.map +1 -1
  8. package/dist/cli-help/design.js +29 -0
  9. package/dist/cli-help/design.js.map +1 -1
  10. package/dist/cli-help/index.d.ts +11 -0
  11. package/dist/cli-help/index.d.ts.map +1 -1
  12. package/dist/cli-help/index.js +18 -2
  13. package/dist/cli-help/index.js.map +1 -1
  14. package/dist/cli-next-command.d.ts.map +1 -1
  15. package/dist/cli-next-command.js +26 -14
  16. package/dist/cli-next-command.js.map +1 -1
  17. package/dist/cli-progress.d.ts +2 -0
  18. package/dist/cli-progress.d.ts.map +1 -1
  19. package/dist/cli-progress.js +2 -0
  20. package/dist/cli-progress.js.map +1 -1
  21. package/dist/cli-redesign-command.d.ts +42 -0
  22. package/dist/cli-redesign-command.d.ts.map +1 -0
  23. package/dist/cli-redesign-command.js +469 -0
  24. package/dist/cli-redesign-command.js.map +1 -0
  25. package/dist/cli-run.d.ts.map +1 -1
  26. package/dist/cli-run.js +8 -0
  27. package/dist/cli-run.js.map +1 -1
  28. package/dist/cli-usage.d.ts.map +1 -1
  29. package/dist/cli-usage.js +8 -1
  30. package/dist/cli-usage.js.map +1 -1
  31. package/dist/draft-dispatch-attempt.d.ts +0 -7
  32. package/dist/draft-dispatch-attempt.d.ts.map +1 -1
  33. package/dist/draft-dispatch-attempt.js +1 -25
  34. package/dist/draft-dispatch-attempt.js.map +1 -1
  35. package/dist/draft-identity.d.ts +2 -0
  36. package/dist/draft-identity.d.ts.map +1 -1
  37. package/dist/draft-identity.js +1 -0
  38. package/dist/draft-identity.js.map +1 -1
  39. package/dist/draft-run.d.ts +2 -0
  40. package/dist/draft-run.d.ts.map +1 -1
  41. package/dist/draft-run.js +4 -3
  42. package/dist/draft-run.js.map +1 -1
  43. package/dist/redesign-capture.d.ts +49 -0
  44. package/dist/redesign-capture.d.ts.map +1 -0
  45. package/dist/redesign-capture.js +135 -0
  46. package/dist/redesign-capture.js.map +1 -0
  47. package/dist/redesign-plan-client.d.ts +32 -0
  48. package/dist/redesign-plan-client.d.ts.map +1 -0
  49. package/dist/redesign-plan-client.js +80 -0
  50. package/dist/redesign-plan-client.js.map +1 -0
  51. package/dist/redesign-plan-identity.d.ts +8 -0
  52. package/dist/redesign-plan-identity.d.ts.map +1 -0
  53. package/dist/redesign-plan-identity.js +15 -0
  54. package/dist/redesign-plan-identity.js.map +1 -0
  55. package/dist/redesign-plan.d.ts +30 -0
  56. package/dist/redesign-plan.d.ts.map +1 -0
  57. package/dist/redesign-plan.js +99 -0
  58. package/dist/redesign-plan.js.map +1 -0
  59. package/dist/redesign-workspace.d.ts +116 -0
  60. package/dist/redesign-workspace.d.ts.map +1 -0
  61. package/dist/redesign-workspace.js +157 -0
  62. package/dist/redesign-workspace.js.map +1 -0
  63. package/dist/run-journal-commands.d.ts.map +1 -1
  64. package/dist/run-journal-commands.js +10 -0
  65. package/dist/run-journal-commands.js.map +1 -1
  66. package/open-design.json +2 -2
  67. package/package.json +2 -2
  68. package/prototype-kit/build.mjs +9 -3
  69. package/prototype-kit/lib/action-target.mjs +26 -0
  70. package/prototype-kit/lib/local-document.mjs +16 -0
  71. package/prototype-kit/lib/runtime-check.mjs +83 -19
  72. package/prototype-kit/lib/shell-runtime-equivalence.mjs +41 -0
  73. package/prototype-kit/polish/harm.mjs +27 -5
  74. package/prototype-kit/polish/measure.mjs +101 -5
  75. package/prototype-kit/polish/overrides.mjs +32 -3
  76. package/prototype-kit/polish/polish.mjs +23 -11
  77. package/prototype-kit/polish/rules/align.mjs +73 -3
  78. package/prototype-kit/polish/rules/left-rail.mjs +2 -3
  79. package/prototype-kit/polish/rules/section-breathing.mjs +0 -1
  80. package/prototype-kit/polish/tests/fixtures/align/expect.json +1 -1
  81. package/prototype-kit/polish/tests/fixtures/align/fire.html +4 -1
  82. package/prototype-kit/polish/tests/fixtures/align/silent.html +8 -0
  83. package/prototype-kit/polish/tests/fixtures/align/zoom-constrained.html +17 -0
  84. package/prototype-kit/polish/tests/fixtures/harm/left-rail-contained.html +16 -0
  85. package/prototype-kit/polish/tests/fixtures/harm/left-rail-overflow.html +16 -0
  86. package/prototype-kit/polish/tests/fixtures/harm/nested-actions.html +6 -0
  87. package/prototype-kit/polish/tests/fixtures/harm/unidentified-parent.html +3 -0
  88. package/prototype-kit/polish/tests/fixtures/shell-seam/fire.html +1 -1
  89. package/prototype-kit/polish/tests/harm-check.mjs +131 -2
  90. package/prototype-kit/polish/tests/rules-check.mjs +7 -2
  91. package/prototype-kit/runtime/proto.js +4 -1
  92. package/prototype-kit/tests/adversarial-runtime.mjs +3 -0
  93. package/prototype-kit/tests/align-rule-check.mjs +105 -0
  94. package/prototype-kit/tests/fixtures/interaction-runtime/review-bottom-action.html +8 -0
  95. package/prototype-kit/tests/offline-check.mjs +17 -1
  96. package/prototype-kit/tests/rules-check.mjs +67 -1
  97. package/prototype-kit/tests/shell-stitching-runtime.mjs +15 -8
@@ -1,14 +1,46 @@
1
1
  // align: snap near-miss edges between same-kind siblings (≤ NEAR px apart) onto the group's modal edge.
2
2
  // Fires on: three cards whose tops are 0, 1.02 and 2.05 px → the two strays move to 0.
3
- // Silent on: edges that are equal, or further apart than NEAR (deliberate offsets), or different kinds.
3
+ // Silent on: edges that are equal, further apart than NEAR (deliberate offsets), different kinds, or elements whose
4
+ // before geometry declares vertical centering inside their immediate parent / containing control.
4
5
  export const name = 'align';
5
6
  export const maxShift = 6;
6
7
 
7
8
  export function browserFn() {
8
9
  return ({ metrics }) => {
10
+ // Population evidence (75 pages, 158 accepted align rule-parts; owner 2026-08-26): among the 14 vertical moves
11
+ // measurable inside declared buttons/circles/badges/chips, the centered cluster's inset imbalance is 0–1.024 px
12
+ // and the next observed value is 2.048 px. Keep the threshold between those clusters.
13
+ const CENTERED_INSET_TOLERANCE = 1.1;
14
+ const CENTERED_CONTAINER_SELECTOR = 'button, .button, .badge, .chip, .circle, [role="button"], [data-proto-action]';
9
15
  const groups = new Map(); // parent|axis → [{id, value}]
10
16
  const axisOf = { left: 'x', right: 'r', top: 'y', bottom: 'b', center: 'cy' };
11
17
  const rectOf = (el) => { const r = el.getBoundingClientRect(); const s = el.closest('section[id^="viewport-"]') || document.body; const b = s.getBoundingClientRect(); return { x: r.left - b.left, y: r.top - b.top, r: r.right - b.left, b: r.bottom - b.top, cy: r.top - b.top + r.height / 2 }; };
18
+ const verticalCenterContainer = (el) => {
19
+ const r = el.getBoundingClientRect();
20
+ const parent = el.parentElement;
21
+ if (!parent || !r.width || !r.height) return null;
22
+ const contains = (c) => c.left <= r.left + 0.25 && c.right >= r.right - 0.25
23
+ && c.top <= r.top + 0.25 && c.bottom >= r.bottom - 0.25;
24
+ // The relationship needs a declared control/container fact. A generic flex parent or painted surface is not
25
+ // enough: both occur around legitimate edge rows in the population and would turn this guard into suppression.
26
+ const ancestor = parent.closest(CENTERED_CONTAINER_SELECTOR);
27
+ const candidates = [...parent.children]
28
+ .filter((candidate) => candidate !== el && candidate.matches(CENTERED_CONTAINER_SELECTOR))
29
+ .map((candidate) => ({ el: candidate, rect: candidate.getBoundingClientRect() }))
30
+ .concat(ancestor ? [{ el: ancestor, rect: ancestor.getBoundingClientRect() }] : [])
31
+ .filter((candidate) => candidate.el !== el && contains(candidate.rect))
32
+ .sort((a, b) => a.rect.width * a.rect.height - b.rect.width * b.rect.height);
33
+ const container = candidates[0]?.el;
34
+ if (!container) return null;
35
+ const c = container.getBoundingClientRect();
36
+ if (!contains(c)) return null;
37
+ const topInset = r.top - c.top;
38
+ const bottomInset = c.bottom - r.bottom;
39
+ return topInset >= -0.5 && bottomInset >= -0.5
40
+ && Math.abs(topInset - bottomInset) <= CENTERED_INSET_TOLERANCE
41
+ ? container
42
+ : null;
43
+ };
12
44
  // an element that already belongs to a left (top) column is left-aligned by design: never snap its RIGHT (bottom)
13
45
  // edge — that is what moved the Lands "Proposals" nav item 5 px out of its icon column (owner, 2026-08-22)
14
46
  const leftAligned = new Set(), topAligned = new Set();
@@ -41,6 +73,38 @@ export function browserFn() {
41
73
  }
42
74
  return false;
43
75
  };
76
+ // Unlike transform, positioned insets do not create a stacking context around a container's independently
77
+ // z-indexed descendants. Preserve the target's positioning mode and existing inset, and stay silent when a static
78
+ // target cannot be nudged without changing layout semantics.
79
+ const insetNudge = (el, horizontal, visualDelta) => {
80
+ const style = getComputedStyle(el);
81
+ if (style.position === 'static') return null;
82
+ const primary = horizontal ? 'left' : 'top';
83
+ const opposite = horizontal ? 'right' : 'bottom';
84
+ const dimension = horizontal ? 'width' : 'height';
85
+ const rect = el.getBoundingClientRect();
86
+ const layoutSpan = Number.parseFloat(style[dimension]);
87
+ const visualSpan = horizontal ? rect.width : rect.height;
88
+ const scale = layoutSpan > 0 && visualSpan > 0 ? visualSpan / layoutSpan : 1;
89
+ const d = visualDelta / scale;
90
+ const current = Number.parseFloat(style[primary]);
91
+ const anchored = Number.parseFloat(style[opposite]);
92
+ // With both insets resolved, moving only one inset resizes the box. Width preservation has an explicit
93
+ // zoom/constrained fixture. For vertical movement, shift both insets in opposite directions instead: that keeps
94
+ // the declared height relationship without publishing an unproven height:...!important lock.
95
+ const constrainedPosition = style.position === 'absolute' || style.position === 'fixed';
96
+ const doublyConstrained = constrainedPosition && Number.isFinite(current) && Number.isFinite(anchored) && Number.isFinite(layoutSpan);
97
+ const preserveSize = horizontal && doublyConstrained
98
+ ? { [dimension]: `${+layoutSpan.toFixed(2)}px` }
99
+ : {};
100
+ if (!horizontal && doublyConstrained) return {
101
+ [primary]: `${+(current + d).toFixed(2)}px`,
102
+ [opposite]: `${+(anchored - d).toFixed(2)}px`,
103
+ };
104
+ if (Number.isFinite(current)) return { ...preserveSize, [primary]: `${+(current + d).toFixed(2)}px` };
105
+ if (Number.isFinite(anchored)) return { ...preserveSize, [opposite]: `${+(anchored - d).toFixed(2)}px` };
106
+ return { [primary]: `${+d.toFixed(2)}px` };
107
+ };
44
108
  const out = [];
45
109
  for (const [key, g] of groups) {
46
110
  const axis = key.split('|')[1];
@@ -57,9 +121,15 @@ export function browserFn() {
57
121
  for (const [id, v] of cluster) {
58
122
  const d = anchor - v;
59
123
  if (Math.abs(d) < 0.5 || Math.abs(d) > 6) continue;
60
- if (breaksColumn(document.getElementById(id), axis)) continue;
124
+ const target = document.getElementById(id);
125
+ if (breaksColumn(target, axis)) continue;
61
126
  const horizontal = axis === 'left' || axis === 'right';
62
- out.push({ id, rule: 'align', reason: `${axis} ${v.toFixed(1)}→${anchor}`, dx: horizontal ? d : 0, dy: horizontal ? 0 : d });
127
+ // Top/bottom/center edge repair cannot trade away a declared centre relationship. Aligning the containing
128
+ // control itself remains available; nudging only its centered child does not.
129
+ if (!horizontal && verticalCenterContainer(target)) continue;
130
+ const css = insetNudge(target, horizontal, d);
131
+ if (!css) continue;
132
+ out.push({ id, rule: 'align', reason: `${axis} ${v.toFixed(1)}→${anchor}`, dx: horizontal ? d : 0, dy: horizontal ? 0 : d, motion: 'inset', css });
63
133
  }
64
134
  }
65
135
  i = j + 1;
@@ -14,11 +14,10 @@
14
14
  // rows far from the rail (> 48 px: a second column, a grid), indented sub-items (a row ≥ 12 px right of its
15
15
  // own block's edge while that block already sits on the rail), and pages with fewer than 3 ink rows.
16
16
  // maxShift 48 is the declared magnitude; the harm guard still rejects the rule on any page where a snap adds an
17
- // overlap, cut, escape, broken column or off-center fit. Horizontal gaps adjacent to a moved row are this rule's own
18
- // declared change (movesBlocks), not spacing harm.
17
+ // overlap, cut, escape, parent-containment breach, broken column, changed sibling gap, or off-center fit. No moving
18
+ // rule is exempt from the shared guard.
19
19
  export const name = 'left-rail';
20
20
  export const maxShift = 48;
21
- export const movesBlocks = true;
22
21
  export const params = { SNAP_MAX: 48, SNAP_MIN: 2, CLUSTER: 4, INDENT: 12, CENTER_TOL: 24 };
23
22
 
24
23
  export function browserFn() {
@@ -11,7 +11,6 @@
11
11
  // owner, then move the semantic block and later direct children. Gradient/image/texture grounds still abstain.
12
12
  export const name = 'section-breathing';
13
13
  export const maxShift = 192;
14
- export const movesBlocks = true;
15
14
  export const phase = 'post';
16
15
  export const verifyPaint = true;
17
16
  export const recordsCensus = true;
@@ -1 +1 @@
1
- [{"id":"viewport-1-a-cards-card-2","dy":-1.02},{"id":"viewport-1-a-cards-card-3","dy":-2.05}]
1
+ [{"id":"viewport-1-a-cards-card-2","dy":0,"css":{"top":"-1.02px"}},{"id":"viewport-1-a-cards-card-3","dy":0,"css":{"top":"-2.05px"}}]
@@ -4,11 +4,14 @@ section{position:relative;width:1536px;height:1024px;background:#fff}
4
4
  .row{position:relative;display:flex;gap:24px;margin:100px 0 0 100px}
5
5
  .card{position:relative;width:300px;height:200px;background:#f3f3f3;border:1px solid #ddd;border-radius:12px}
6
6
  .text{position:relative;display:block;font-size:16px;line-height:20px;margin:20px 0 0 20px}
7
+ .action{position:absolute;right:16px;bottom:16px;z-index:100;width:96px;height:32px}
8
+ .canvas-sibling{position:absolute;left:100px;top:100px;z-index:50;width:948px;height:204px;background:rgba(255,255,255,.001)}
7
9
  </style>
8
10
  <section id="viewport-1-a">
9
11
  <div id="viewport-1-a-cards" class="row">
10
12
  <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>
13
+ <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><button id="viewport-1-a-cards-card-2-action" class="action" data-proto-action="modal">Open</button></div>
12
14
  <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
15
  </div>
16
+ <div class="canvas-sibling"></div>
14
17
  </section>
@@ -25,4 +25,12 @@ section{position:relative;width:1536px;height:1024px;background:#fff}
25
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
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
27
  </div>
28
+ <!-- Owner-found must-stay-silent pair: the icons' top edges differ by 2 px, but each icon is vertically centered
29
+ inside its containing button. Align must preserve that stronger relation instead of snapping their top edges. -->
30
+ <div id="viewport-1-a-centered-controls" style="position:absolute;left:760px;top:400px;width:116px;height:44px">
31
+ <button id="viewport-1-a-centered-controls-button-1" class="button" style="position:absolute;left:0;top:0;width:40px;height:40px;border:0;border-radius:20px;background:#dfeacc"></button>
32
+ <span id="viewport-1-a-centered-controls-icon-1" class="icon" style="position:absolute;left:12px;top:12px;width:16px;height:16px;background:#56714a"></span>
33
+ <div id="viewport-1-a-centered-controls-circle-2" class="circle" style="position:absolute;left:72px;top:0;width:44px;height:44px;border-radius:22px;background:#dfeacc"></div>
34
+ <span id="viewport-1-a-centered-controls-icon-2" class="icon" style="position:absolute;left:86px;top:14px;width:16px;height:16px;background:#56714a"></span>
35
+ </div>
28
36
  </section>
@@ -0,0 +1,17 @@
1
+ <!doctype html><meta charset="utf-8"><style>
2
+ *{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui}.zoom{zoom:.75}.card,.box{background:#f3f3f3;border:1px solid #ddd}.text{position:absolute;left:12px;top:12px}.row{position:relative;display:flex;gap:20px;margin:80px}.card{position:relative;width:240px;height:100px}.stack{position:relative;width:1536px;height:500px}.box{position:absolute;right:100px;height:60px}
3
+ </style>
4
+ <div class="zoom"><section id="viewport-1-a" style="position:relative;width:1536px;height:300px"><div id="viewport-1-a-row" class="row">
5
+ <div id="viewport-1-a-card-1" class="card"><span id="viewport-1-a-card-1-text" class="text">One</span></div>
6
+ <div id="viewport-1-a-card-2" class="card" style="margin-top:2px"><span id="viewport-1-a-card-2-text" class="text">Two</span></div>
7
+ <div id="viewport-1-a-card-3" class="card" style="margin-top:4px"><span id="viewport-1-a-card-3-text" class="text">Three</span></div>
8
+ </div></section></div>
9
+ <section id="viewport-2-a" class="stack">
10
+ <div id="viewport-2-a-box-1" class="box" style="left:100px;top:80px"><span id="viewport-2-a-box-1-text" class="text">One</span></div>
11
+ <div id="viewport-2-a-box-2" class="box" style="left:102px;top:180px"><span id="viewport-2-a-box-2-text" class="text">Two</span></div>
12
+ <div id="viewport-2-a-box-3" class="box" style="left:104px;top:280px"><span id="viewport-2-a-box-3-text" class="text">Three</span></div>
13
+ </section>
14
+ <section id="viewport-3-a" style="position:relative;width:600px;height:120px">
15
+ <div id="viewport-3-a-box-1" class="box" style="position:absolute;left:80px;top:20px;bottom:20px;width:100px;height:auto"><span id="viewport-3-a-box-1-text" class="text">Anchor</span></div>
16
+ <div id="viewport-3-a-box-2" class="box" style="position:absolute;left:220px;top:22px;bottom:18px;width:100px;height:auto"><span id="viewport-3-a-box-2-text" class="text">Stray</span></div>
17
+ </section>
@@ -0,0 +1,16 @@
1
+ <!doctype html><meta charset="utf-8"><style>
2
+ *{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui}section{position:relative;width:1536px;height:1024px}.reference,.inbox{position:absolute;left:0;width:1536px}.text{position:absolute;white-space:nowrap}.inbox{left:64px;width:440px;height:490px}.inbox .text{left:0;width:400px;height:44px;padding:12px;background:#f5f5f2;border:1px solid #ddd}
3
+ </style><section id="viewport-1-a">
4
+ <div id="viewport-1-a-reference-1" class="reference" style="top:20px;height:40px"><p id="viewport-1-a-reference-1-text" class="text" style="left:80px;top:0">Inbox</p></div>
5
+ <div id="viewport-1-a-reference-2" class="reference" style="top:70px;height:40px"><p id="viewport-1-a-reference-2-text" class="text" style="left:80px;top:0">Assigned</p></div>
6
+ <div id="viewport-1-a-reference-3" class="reference" style="top:120px;height:40px"><p id="viewport-1-a-reference-3-text" class="text" style="left:80px;top:0">Resolved</p></div>
7
+ <div id="viewport-1-a-inbox" class="inbox" style="top:190px">
8
+ <button id="viewport-1-a-inbox-row-1" class="text" data-proto-action="navigate" style="top:0">Action one</button>
9
+ <button id="viewport-1-a-inbox-row-2" class="text" data-proto-action="navigate" style="top:60px">Action two</button>
10
+ <button id="viewport-1-a-inbox-row-3" class="text" data-proto-action="navigate" style="top:120px">Action three</button>
11
+ <button id="viewport-1-a-inbox-row-4" class="text" data-proto-action="navigate" style="top:180px">Action four</button>
12
+ <button id="viewport-1-a-inbox-row-5" class="text" data-proto-action="navigate" style="top:240px">Action five</button>
13
+ <button id="viewport-1-a-inbox-row-6" class="text" data-proto-action="navigate" style="top:300px">Action six</button>
14
+ <button id="viewport-1-a-inbox-row-7" class="text" data-proto-action="navigate" style="top:360px">Action seven</button>
15
+ </div>
16
+ </section>
@@ -0,0 +1,16 @@
1
+ <!doctype html><meta charset="utf-8"><style>
2
+ *{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui}section{position:relative;width:1536px;height:1024px}.reference,.inbox{position:absolute;left:0;width:1536px}.text{position:absolute;white-space:nowrap}.inbox{left:64px;width:400px;height:490px}.inbox .text{left:0;width:400px;height:44px;padding:12px;background:#f5f5f2;border:1px solid #ddd}
3
+ </style><section id="viewport-1-a">
4
+ <div id="viewport-1-a-reference-1" class="reference" style="top:20px;height:40px"><p id="viewport-1-a-reference-1-text" class="text" style="left:80px;top:0">Inbox</p></div>
5
+ <div id="viewport-1-a-reference-2" class="reference" style="top:70px;height:40px"><p id="viewport-1-a-reference-2-text" class="text" style="left:80px;top:0">Assigned</p></div>
6
+ <div id="viewport-1-a-reference-3" class="reference" style="top:120px;height:40px"><p id="viewport-1-a-reference-3-text" class="text" style="left:80px;top:0">Resolved</p></div>
7
+ <div id="viewport-1-a-inbox" class="inbox" style="top:190px">
8
+ <button id="viewport-1-a-inbox-row-1" class="text" data-proto-action="navigate" style="top:0">Action one</button>
9
+ <button id="viewport-1-a-inbox-row-2" class="text" data-proto-action="navigate" style="top:60px">Action two</button>
10
+ <button id="viewport-1-a-inbox-row-3" class="text" data-proto-action="navigate" style="top:120px">Action three</button>
11
+ <button id="viewport-1-a-inbox-row-4" class="text" data-proto-action="navigate" style="top:180px">Action four</button>
12
+ <button id="viewport-1-a-inbox-row-5" class="text" data-proto-action="navigate" style="top:240px">Action five</button>
13
+ <button id="viewport-1-a-inbox-row-6" class="text" data-proto-action="navigate" style="top:300px">Action six</button>
14
+ <button id="viewport-1-a-inbox-row-7" class="text" data-proto-action="navigate" style="top:360px">Action seven</button>
15
+ </div>
16
+ </section>
@@ -0,0 +1,6 @@
1
+ <!doctype html><meta charset="utf-8"><style>
2
+ *{box-sizing:border-box}section{position:relative;width:1536px;height:1024px}#viewport-1-a-outer,#viewport-1-a-stacked{position:absolute;left:100px;width:240px;height:80px;background:#ddd}#viewport-1-a-outer{top:100px}#viewport-1-a-inner{position:absolute;inset:0;width:100%;height:100%;z-index:2}#viewport-1-a-stacked{top:220px}#viewport-1-a-stacked-button{width:100%;height:100%}
3
+ </style><section id="viewport-1-a">
4
+ <div id="viewport-1-a-outer" role="button" data-proto-action="modal" tabindex="0"><button id="viewport-1-a-inner" data-proto-action="dropdown">Inner action</button></div>
5
+ <div id="viewport-1-a-stacked" data-proto-action="modal"><button id="viewport-1-a-stacked-button">Plain pointer surface</button></div>
6
+ </section>
@@ -0,0 +1,3 @@
1
+ <!doctype html><meta charset="utf-8"><style>
2
+ *{margin:0;padding:0;box-sizing:border-box}section{position:relative;width:1536px;height:1024px}.wrapper{position:absolute;left:100px;top:100px;width:400px;height:100px}.row{position:absolute;left:0;top:0;width:400px;height:60px;background:#eee}
3
+ </style><section id="viewport-1-a"><div class="wrapper"><div id="viewport-1-a-row" class="row">Row</div></div></section>
@@ -3,7 +3,7 @@
3
3
  section{position:relative;width:1536px;height:1024px;background:#fff}
4
4
  .rail{position:absolute;left:0;top:0;width:220px;height:1024px;background:#f6f4f0;padding:24px 0 0 16px}
5
5
  .btn{position:relative;width:214px;height:43px;background:#245;border-radius:8px;margin-top:40px}
6
- .btn .text{position:absolute;left:0;top:12px;width:214px;text-align:center;color:#fff;font-size:15px}
6
+ .btn .text{position:absolute;left:0;top:12px;width:100%;text-align:center;color:#fff;font-size:15px}
7
7
  .nav{position:relative;margin-top:24px;font-size:15px;width:99px;height:18px}
8
8
  .foot{position:absolute;left:16px;top:1040px;width:120px;height:14px;font-size:11px;color:#666}
9
9
  .content{position:absolute;left:270px;top:40px;width:1200px;height:900px;background:#fff;border:1px solid #ddd}
@@ -2,10 +2,15 @@
2
2
  // Paired cases for the structure harm guard (harm.mjs): a synthetic override that breaks an icon column or collapses
3
3
  // the rail/content gutter MUST be flagged; a rhythm-sized nudge (≤ 6 px) on a gap MUST NOT.
4
4
  import path from 'node:path';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
5
7
  import { chromium } from '@playwright/test';
6
8
  import { measurePage } from '../measure.mjs';
7
- import { applyOverridesScript } from '../overrides.mjs';
9
+ import { applyOverridesScript, mergeOverrides, overridesToCss, partitionProtectedShellOverridesScript } from '../overrides.mjs';
8
10
  import { structureHarm } from '../harm.mjs';
11
+ import * as align from '../rules/align.mjs';
12
+ import * as leftRail from '../rules/left-rail.mjs';
13
+ import { polishPrototype } from '../polish.mjs';
9
14
  const here = path.dirname(new URL(import.meta.url).pathname);
10
15
  const browser = await chromium.launch();
11
16
  const page = await browser.newPage({ viewport: { width: 1536, height: 1024 } });
@@ -29,7 +34,7 @@ const gapBefore = await measurePage(page, {});
29
34
  await page.evaluate(applyOverridesScript(), '#viewport-1-a-footer{transform:translate(0,32px)!important}');
30
35
  const gapAfter = await measurePage(page, {});
31
36
  const undeclaredGap = structureHarm(gapBefore, gapAfter);
32
- const declaredGap = structureHarm(gapBefore, gapAfter, new Set(), new Set(['viewport-1-a-content|viewport-1-a-footer']));
37
+ const declaredGap = structureHarm(gapBefore, gapAfter, new Set(['viewport-1-a-content|viewport-1-a-footer']));
33
38
  const okDeclaredGap = undeclaredGap.spacing.length > 0 && declaredGap.spacing.length === 0;
34
39
  if (!okDeclaredGap) failures++;
35
40
  console.log(`${okDeclaredGap ? 'ok ' : 'FAIL'} harm/declared-gap: undeclared=${undeclaredGap.spacing.length} declared=${declaredGap.spacing.length}`);
@@ -47,6 +52,130 @@ const pairs = mo.overlap.map((o) => [o.a, o.b].sort().join('|'));
47
52
  const okBtn = pairs.length === 1 && pairs[0] === 'viewport-1-a-hero-eyebrow|viewport-1-a-hero-stray';
48
53
  if (!okBtn) failures++;
49
54
  console.log(`${okBtn ? 'ok ' : 'FAIL'} overlap/button-over-text: ${JSON.stringify(pairs)} (expected eyebrow|stray only)`);
55
+ // The page-gallery failure class: a positioned card is not a stacking context until align adds transform. Its high-z
56
+ // action can initially paint over a canvas sibling, then becomes trapped underneath it. The hit-test guard must catch
57
+ // that legacy emission; align's current inset emission must preserve reachability and contain no transform.
58
+ await page.goto('file://' + path.join(here, 'fixtures/align/fire.html'), { waitUntil: 'load' });
59
+ const alignBefore = await measurePage(page, {});
60
+ const alignOverrides = await page.evaluate(align.browserFn(), { metrics: alignBefore, params: align.params });
61
+ const protectedAlign = await page.evaluate(partitionProtectedShellOverridesScript(), {
62
+ overrides: alignOverrides,
63
+ shellIds: ['viewport-1-a-cards'],
64
+ });
65
+ const legacyAlignCss = alignOverrides.map((o) => `#${o.id}{transform:translate(${o.dx || 0}px,${o.dy || 0}px)!important}`).join('\n');
66
+ await page.evaluate(applyOverridesScript(), legacyAlignCss);
67
+ const alignLegacyAfter = await measurePage(page, {});
68
+ const legacyAlignHarm = structureHarm(alignBefore, alignLegacyAfter);
69
+ const fixedAlignCss = overridesToCss(mergeOverrides(alignOverrides));
70
+ await page.evaluate(applyOverridesScript(), fixedAlignCss);
71
+ const alignFixedAfter = await measurePage(page, {});
72
+ const fixedAlignHarm = structureHarm(alignBefore, alignFixedAfter);
73
+ const okAlignHit = alignBefore.counts.reachableActions === 1
74
+ && alignLegacyAfter.counts.reachableActions === 0
75
+ && legacyAlignHarm.unreachableActions.length === 1
76
+ && alignFixedAfter.counts.reachableActions === 1
77
+ && fixedAlignHarm.unreachableActions.length === 0
78
+ && !fixedAlignCss.includes('transform:')
79
+ && !fixedAlignCss.includes('height:')
80
+ && protectedAlign.allowed.length === 0
81
+ && protectedAlign.protected.length === 2;
82
+ if (!okAlignHit) failures++;
83
+ console.log(`${okAlignHit ? 'ok ' : 'FAIL'} harm/align-hit-test: legacy reachable ${alignBefore.counts.reachableActions}→${alignLegacyAfter.counts.reachableActions}, fixed ${alignBefore.counts.reachableActions}→${alignFixedAfter.counts.reachableActions}, fixed transform=${fixedAlignCss.includes('transform:')}, canonical-shell blocked=${protectedAlign.protected.length}`);
84
+ // Insets use layout coordinates, while alignment findings use visual coordinates. Under CSS zoom the emitted inset
85
+ // must divide by the visual scale, and a left+right constrained target must retain its used width.
86
+ await page.goto('file://' + path.join(here, 'fixtures/align/zoom-constrained.html'), { waitUntil: 'load' });
87
+ const scaledBefore = await measurePage(page, {});
88
+ const scaledWidthsBefore = await page.evaluate(() => ['viewport-2-a-box-2', 'viewport-2-a-box-3'].map((id) => document.getElementById(id).getBoundingClientRect().width));
89
+ const verticalHeightsBefore = await page.evaluate(() => ['viewport-3-a-box-1', 'viewport-3-a-box-2'].map((id) => document.getElementById(id).getBoundingClientRect().height));
90
+ const scaledOverrides = await page.evaluate(align.browserFn(), { metrics: scaledBefore, params: align.params });
91
+ const scaledCss = overridesToCss(mergeOverrides(scaledOverrides));
92
+ await page.evaluate(applyOverridesScript(), scaledCss);
93
+ const scaledAfter = await measurePage(page, {});
94
+ const scaledWidthsAfter = await page.evaluate(() => ['viewport-2-a-box-2', 'viewport-2-a-box-3'].map((id) => document.getElementById(id).getBoundingClientRect().width));
95
+ const verticalHeightsAfter = await page.evaluate(() => ['viewport-3-a-box-1', 'viewport-3-a-box-2'].map((id) => document.getElementById(id).getBoundingClientRect().height));
96
+ const verticalTop = await page.evaluate(() => ['viewport-3-a-box-1', 'viewport-3-a-box-2'].map((id) => document.getElementById(id).getBoundingClientRect().top));
97
+ const zoomTop = await page.evaluate(() => ['viewport-1-a-card-1', 'viewport-1-a-card-2', 'viewport-1-a-card-3'].map((id) => document.getElementById(id).getBoundingClientRect().top));
98
+ const constrained = scaledOverrides.filter((override) => override.id?.startsWith('viewport-2-a-box-') && override.id !== 'viewport-2-a-box-1');
99
+ const verticalConstrained = scaledOverrides.find((override) => override.id === 'viewport-3-a-box-2');
100
+ const okScaledAlign = Math.max(...zoomTop) - Math.min(...zoomTop) < 0.2
101
+ && scaledWidthsAfter.every((width, index) => Math.abs(width - scaledWidthsBefore[index]) < 0.2)
102
+ && constrained.length === 2
103
+ && constrained.every((override) => override.css.width)
104
+ && verticalConstrained?.css.top === '20px'
105
+ && verticalConstrained?.css.bottom === '20px'
106
+ && verticalConstrained?.css.height == null
107
+ && !scaledCss.includes('height:')
108
+ && Math.max(...verticalTop) - Math.min(...verticalTop) < 0.2
109
+ && verticalHeightsAfter.every((height, index) => Math.abs(height - verticalHeightsBefore[index]) < 0.2)
110
+ && structureHarm(scaledBefore, scaledAfter).unreachableActions.length === 0;
111
+ if (!okScaledAlign) failures++;
112
+ console.log(`${okScaledAlign ? 'ok ' : 'FAIL'} align/zoom-constrained: zoom top spread=${(Math.max(...zoomTop) - Math.min(...zoomTop)).toFixed(2)} width deltas=${scaledWidthsAfter.map((width, index) => (width - scaledWidthsBefore[index]).toFixed(2)).join(',')} vertical top spread=${(Math.max(...verticalTop) - Math.min(...verticalTop)).toFixed(2)} height lock=${scaledCss.includes('height:')}`);
113
+ // Owner-found centering pair: measurement must expose the tempting 2 px top-edge near miss, while align stays silent
114
+ // because each icon is vertically centered in its containing circle button.
115
+ await page.goto('file://' + path.join(here, 'fixtures/align/silent.html'), { waitUntil: 'load' });
116
+ const centeredBefore = await measurePage(page, {});
117
+ const centeredIds = new Set(['viewport-1-a-centered-controls-icon-1', 'viewport-1-a-centered-controls-icon-2']);
118
+ const centeredNearMiss = centeredBefore.alignment.some((pair) => pair.axis === 'top' && centeredIds.has(pair.a) && centeredIds.has(pair.b));
119
+ const centeredOverrides = await page.evaluate(align.browserFn(), { metrics: centeredBefore, params: align.params });
120
+ const centeredTouched = centeredOverrides.filter((override) => centeredIds.has(override.id));
121
+ const okCenteredAlign = centeredNearMiss && centeredTouched.length === 0;
122
+ if (!okCenteredAlign) failures++;
123
+ console.log(`${okCenteredAlign ? 'ok ' : 'FAIL'} align/centered-control-silent: near-miss=${centeredNearMiss} overrides=${centeredTouched.length}`);
124
+ // The autopilot-inbox failure shape: the actual left-rail rule moves seven full-width rows beyond their direct parent.
125
+ // A wider parent is the paired healthy case: the same seven proposals remain contained and the guard stays silent.
126
+ for (const [fixture, expectedHarm] of [['left-rail-overflow.html', 7], ['left-rail-contained.html', 0]]) {
127
+ await page.goto('file://' + path.join(here, 'fixtures/harm', fixture), { waitUntil: 'load' });
128
+ const railBefore = await measurePage(page, {});
129
+ const railOverrides = await page.evaluate(leftRail.browserFn(), { metrics: railBefore, params: leftRail.params });
130
+ await page.evaluate(applyOverridesScript(), overridesToCss(mergeOverrides(railOverrides)));
131
+ const railAfter = await measurePage(page, {});
132
+ const railHarm = structureHarm(railBefore, railAfter);
133
+ const okRail = railOverrides.length === 7 && railHarm.containment.length === expectedHarm;
134
+ if (!okRail) failures++;
135
+ console.log(`${okRail ? 'ok ' : 'FAIL'} harm/${fixture.replace('.html', '')}: proposals=${railOverrides.length} containment=${railHarm.containment.length}`);
136
+ }
137
+ // Exercise the production acceptance loop as well as the pure comparator: the overflow case must publish unchanged
138
+ // with left-rail reverted, while the contained twin accepts all seven moves.
139
+ {
140
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), '12ui-polish-harm-'));
141
+ try {
142
+ for (const [fixture, status, accepted] of [['left-rail-overflow.html', 'reverted', 0], ['left-rail-contained.html', 'fired', 7]]) {
143
+ const stem = fixture.replace('.html', '');
144
+ const source = path.join(root, `${stem}-source`); const out = path.join(root, `${stem}-out`);
145
+ fs.mkdirSync(source);
146
+ fs.copyFileSync(path.join(here, 'fixtures/harm', fixture), path.join(source, `${stem}.html`));
147
+ fs.writeFileSync(path.join(source, 'proto-manifest.json'), JSON.stringify({ units: [{ id: stem, states: [] }], holdings: [] }));
148
+ const report = await polishPrototype({ protoDir: source, outDir: out, ruleNames: ['left-rail'], concurrency: 1 });
149
+ const record = report.pages[0].ruleRecords.find((entry) => entry.rule === 'left-rail');
150
+ const okAcceptance = record?.status === status && record.accepted === accepted
151
+ && (status !== 'reverted' || record.reasons?.includes('containment'));
152
+ if (!okAcceptance) failures++;
153
+ console.log(`${okAcceptance ? 'ok ' : 'FAIL'} harm/${stem}-acceptance: status=${record?.status} accepted=${record?.accepted} reasons=${record?.reasons?.join(',') || 'none'}`);
154
+ }
155
+ } finally {
156
+ fs.rmSync(root, { recursive: true, force: true });
157
+ }
158
+ }
159
+ // Interaction identity is the nearest interactive ancestor, not merely DOM containment. An outer action completely
160
+ // covered by a nested action is unreachable even though element.contains(elementFromPoint(...)) would be true.
161
+ await page.goto('file://' + path.join(here, 'fixtures/harm/nested-actions.html'), { waitUntil: 'load' });
162
+ const nestedActions = await measurePage(page, {});
163
+ const nestedById = new Map(nestedActions.actions.map((action) => [action.id, action.reachable]));
164
+ const okNested = nestedById.get('viewport-1-a-outer') === false
165
+ && nestedById.get('viewport-1-a-inner') === true
166
+ && nestedById.get('viewport-1-a-stacked') === true
167
+ && !nestedById.has('viewport-1-a-stacked-button');
168
+ if (!okNested) failures++;
169
+ console.log(`${okNested ? 'ok ' : 'FAIL'} harm/nested-action-identity: outer=${nestedById.get('viewport-1-a-outer')} inner=${nestedById.get('viewport-1-a-inner')} wrapper-over-native=${nestedById.get('viewport-1-a-stacked')}`);
170
+ // An ordinary un-IDed layout wrapper still owns containment and cannot be a blind spot.
171
+ await page.goto('file://' + path.join(here, 'fixtures/harm/unidentified-parent.html'), { waitUntil: 'load' });
172
+ const anonymousBefore = await measurePage(page, {});
173
+ await page.evaluate(applyOverridesScript(), '#viewport-1-a-row{transform:translate(20px,0)!important}');
174
+ const anonymousAfter = await measurePage(page, {});
175
+ const anonymousHarm = structureHarm(anonymousBefore, anonymousAfter);
176
+ const okAnonymous = anonymousHarm.containment.length === 1 && anonymousHarm.containment[0].id === 'viewport-1-a-row';
177
+ if (!okAnonymous) failures++;
178
+ console.log(`${okAnonymous ? 'ok ' : 'FAIL'} harm/unidentified-parent: containment=${anonymousHarm.containment.length}`);
50
179
  // contrast refinement: declared brand colour keeps its hue (orange → readable orange, not black); readable declared
51
180
  // colour untouched; a hue that cannot reach the threshold falls back and records the fact
52
181
  {
@@ -5,6 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { chromium } from '@playwright/test';
7
7
  import { measurePage } from '../measure.mjs';
8
+ import { structureHarm } from '../harm.mjs';
8
9
  import { loadRules, ALL } from '../rules/index.mjs';
9
10
  import { applyOverridesScript, overridesToCss, mergeOverrides } from '../overrides.mjs';
10
11
 
@@ -31,8 +32,12 @@ for (const rule of rules) {
31
32
  const merged = mergeOverrides(found);
32
33
  await page.evaluate(applyOverridesScript(), overridesToCss(merged));
33
34
  const after = await measurePage(page, {});
34
- harm = after.counts.overlap > before.counts.overlap || after.overflow.filter((o) => o.kind !== 'spill').length > before.overflow.filter((o) => o.kind !== 'spill').length;
35
- detail += `; overlap ${before.counts.overlap}→${after.counts.overlap}, overflow ${before.counts.overflow}→${after.counts.overflow}, align ${before.counts.alignment}→${after.counts.alignment}, ws ${before.counts.whitespace}→${after.counts.whitespace}`;
35
+ const structural = structureHarm(before, after);
36
+ harm = after.counts.overlap > before.counts.overlap
37
+ || after.overflow.filter((o) => o.kind !== 'spill').length > before.overflow.filter((o) => o.kind !== 'spill').length
38
+ || structural.containment.length > 0
39
+ || structural.unreachableActions.length > 0;
40
+ detail += `; overlap ${before.counts.overlap}→${after.counts.overlap}, overflow ${before.counts.overflow}→${after.counts.overflow}, containment ${before.counts.containment}→${after.counts.containment} (new ${structural.containment.length}), reachable ${before.counts.reachableActions}→${after.counts.reachableActions} (lost ${structural.unreachableActions.length}), align ${before.counts.alignment}→${after.counts.alignment}, ws ${before.counts.whitespace}→${after.counts.whitespace}`;
36
41
  const expectFile = path.join(dir, 'expect.json');
37
42
  if (fs.existsSync(expectFile)) {
38
43
  const expect = JSON.parse(fs.readFileSync(expectFile, 'utf8'));
@@ -10,7 +10,10 @@
10
10
 
11
11
  // ---- content layer: replace text nodes from content/<state>.json (or content-<set>/)
12
12
  async function hydrate() {
13
- if (!cfg.contentFile) return;
13
+ // Direct-file review already carries the same baked-in copy. Browsers do
14
+ // not permit fetch(file://...), and attempting it emits a console error
15
+ // even when the rejected promise is caught.
16
+ if (!cfg.contentFile || location.protocol === 'file:') return;
14
17
  const dir = contentSet ? `content-${contentSet}` : 'content';
15
18
  try {
16
19
  const res = await fetch(`${dir}/${cfg.contentFile}`, { cache: 'no-store' });
@@ -130,6 +130,7 @@ const interactionPage = (file) => interactionRuntime.pages.find((page) => page.f
130
130
  const interactionReachability = {
131
131
  occludedExactFailure: interactionPage('occluded-saved-journeys.html')?.dropdown,
132
132
  ordinaryControl: interactionPage('ordinary-dropdown.html')?.dropdown,
133
+ reviewBottomControl: interactionPage('review-bottom-action.html')?.modal,
133
134
  };
134
135
 
135
136
  const blockingScratch = fs.mkdtempSync(path.join(os.tmpdir(), 'prototype-blocking-runtime-'));
@@ -194,6 +195,8 @@ if (process.argv.includes('--assert')) {
194
195
  assert.equal(interactionReachability.occludedExactFailure?.ok, true, 'must fire: the exact Saved Journeys dropdown is scrolled clear of the fixed review framing before pointer hit-testing');
195
196
  assert.ok(interactionReachability.occludedExactFailure?.point?.y < 700, 'must fire: the exact initially occluded target is measured outside the review panel');
196
197
  assert.equal(interactionReachability.ordinaryControl?.ok, true, 'must stay silent: an ordinary already-visible dropdown remains pointer reachable');
198
+ assert.equal(interactionReachability.reviewBottomControl?.ok, true, 'must fire: a bottom-edge action receives temporary scroll clearance from fixed review chrome before hit-testing');
199
+ assert.ok(interactionReachability.reviewBottomControl?.point?.y < 800, 'must fire: the bottom-edge action is measured above the review panel');
197
200
  assert.notEqual(blockingVerification.status, 0, 'must fire: a claimed dropdown with broken pointer wiring still refuses publication');
198
201
  assert.match(blockingVerification.stderr, /Prototype runtime gate failed: 1 page\(s\) with broken dropdown\/modal interaction/, 'must fire: the refusal names the genuinely blocking interaction gate');
199
202
  assert.deepEqual(blockingAudit.interactionFailures, ['broken-interaction.html'], 'must stay silent: the blocking fixture identifies only the broken page');
@@ -0,0 +1,105 @@
1
+ import assert from 'node:assert/strict';
2
+ import * as align from '../polish/rules/align.mjs';
3
+
4
+ const rect = (left, top, width, height) => ({ left, top, width, height, right: left + width, bottom: top + height });
5
+
6
+ export function checkAlignRule() {
7
+ const previousDocument = globalThis.document;
8
+ const previousGetComputedStyle = globalThis.getComputedStyle;
9
+ const elements = new Map();
10
+ const section = { getBoundingClientRect: () => rect(0, 0, 1536, 1024) };
11
+ const defaultStyle = {
12
+ position: 'static', left: 'auto', right: 'auto', top: 'auto', bottom: 'auto', width: 'auto', height: 'auto',
13
+ backgroundColor: 'rgba(0, 0, 0, 0)', backgroundImage: 'none',
14
+ borderTopWidth: '0px', borderRightWidth: '0px', borderBottomWidth: '0px', borderLeftWidth: '0px',
15
+ };
16
+ const element = (id, box, style = {}, { tagName = 'DIV', classes = [] } = {}) => {
17
+ const classSet = new Set(classes);
18
+ const el = {
19
+ id, tagName, parentElement: null, children: [], box, style: { ...defaultStyle, ...style },
20
+ classList: { contains: (name) => classSet.has(name) },
21
+ getBoundingClientRect: () => el.box,
22
+ closest: (selector) => selector.startsWith('section') ? section : null,
23
+ querySelectorAll: () => [],
24
+ contains: (candidate) => candidate === el,
25
+ matches: (selector) => (tagName === 'BUTTON' && selector.includes('button'))
26
+ || (classSet.has('button') && selector.includes('.button'))
27
+ || (classSet.has('badge') && selector.includes('.badge'))
28
+ || (classSet.has('chip') && selector.includes('.chip'))
29
+ || (classSet.has('circle') && selector.includes('.circle')),
30
+ };
31
+ elements.set(id, el);
32
+ return el;
33
+ };
34
+ const parent = (id, box, children) => {
35
+ const el = element(id, box);
36
+ el.children = children;
37
+ for (const child of children) child.parentElement = el;
38
+ return el;
39
+ };
40
+ const run = (alignment) => align.browserFn()({ metrics: { alignment, columns: [] } });
41
+
42
+ globalThis.document = { getElementById: (id) => elements.get(id) || null, body: section };
43
+ globalThis.getComputedStyle = (el) => el.style;
44
+ try {
45
+ const card1 = element('card-1', rect(0, 0, 100, 80), { position: 'relative', top: '0px', height: '80px' });
46
+ const card2 = element('card-2', rect(120, 1.02, 100, 80), { position: 'relative', top: '1.02px', height: '80px' });
47
+ const card3 = element('card-3', rect(240, 2.05, 100, 80), { position: 'relative', top: '2.05px', height: '80px' });
48
+ parent('stray-row', rect(0, 0, 400, 180), [card1, card2, card3]);
49
+ const fire = run([
50
+ { axis: 'top', a: 'card-1', b: 'card-2', delta: 1.02 },
51
+ { axis: 'top', a: 'card-1', b: 'card-3', delta: 2.05 },
52
+ ]);
53
+ assert.deepEqual(fire.map((override) => override.id), ['card-2', 'card-3'], 'must fire: genuine strays in an edge row still snap');
54
+
55
+ elements.clear();
56
+ const button1 = element('centered-button-1', rect(0, 0, 40, 40), { backgroundColor: 'rgb(220, 230, 210)' }, { tagName: 'BUTTON', classes: ['button'] });
57
+ const icon1 = element('centered-icon-1', rect(12, 12, 16, 16), { position: 'absolute', top: '12px', height: '16px' }, { classes: ['icon'] });
58
+ const button2 = element('centered-circle-2', rect(72, 0, 44, 44), { backgroundColor: 'rgb(220, 230, 210)' }, { classes: ['circle'] });
59
+ const icon2 = element('centered-icon-2', rect(86, 14, 16, 16), { position: 'absolute', top: '14px', height: '16px' }, { classes: ['icon'] });
60
+ parent('centered-controls', rect(0, 0, 116, 44), [button1, icon1, button2, icon2]);
61
+ const silent = run([{ axis: 'top', a: 'centered-icon-1', b: 'centered-icon-2', delta: 2 }]);
62
+ assert.deepEqual(silent, [], 'must stay silent: a centered icon keeps its button/circle relationship');
63
+
64
+ elements.clear();
65
+ const anchorButton = element('anchor-button', rect(0, 0, 40, 40), {}, { tagName: 'BUTTON', classes: ['button'] });
66
+ const centeredAnchor = element('centered-anchor-icon', rect(12, 12, 16, 16), { position: 'absolute', top: '12px', height: '16px' }, { classes: ['icon'] });
67
+ const ordinaryStray = element('ordinary-stray-icon', rect(80, 14, 16, 16), { position: 'absolute', top: '14px', height: '16px' }, { classes: ['icon'] });
68
+ parent('mixed-controls', rect(0, 0, 116, 44), [anchorButton, centeredAnchor, ordinaryStray]);
69
+ assert.deepEqual(
70
+ run([{ axis: 'top', a: 'centered-anchor-icon', b: 'ordinary-stray-icon', delta: 2 }]).map((override) => override.id),
71
+ ['ordinary-stray-icon'],
72
+ 'must fire: an uncontained stray still snaps to a centered anchor',
73
+ );
74
+
75
+ elements.clear();
76
+ const boundaryAnchor = element('boundary-anchor-icon', rect(12, 12, 16, 16), { position: 'absolute', top: '12px', height: '16px' }, { classes: ['icon'] });
77
+ const boundaryTarget = element('boundary-target-icon', rect(86, 14, 16, 16), { position: 'absolute', top: '14px', height: '16px' }, { classes: ['icon'] });
78
+ const boundaryButton = element('boundary-button', rect(72, 0, 44, 45.024), {}, { tagName: 'BUTTON', classes: ['button'] });
79
+ parent('boundary-controls', rect(0, 0, 116, 46), [boundaryAnchor, boundaryButton, boundaryTarget]);
80
+ assert.deepEqual(
81
+ run([{ axis: 'top', a: 'boundary-anchor-icon', b: 'boundary-target-icon', delta: 2 }]),
82
+ [],
83
+ 'must stay silent: 1.024px inset imbalance is inside the measured centered cluster',
84
+ );
85
+
86
+ boundaryButton.box = rect(72, 0, 44, 46.048);
87
+ const outsideBoundary = run([{ axis: 'top', a: 'boundary-anchor-icon', b: 'boundary-target-icon', delta: 2 }]);
88
+ assert.deepEqual(
89
+ outsideBoundary.map((override) => override.id),
90
+ ['boundary-target-icon'],
91
+ 'must fire: 2.048px inset imbalance is outside the measured centered cluster',
92
+ );
93
+
94
+ elements.clear();
95
+ const box1 = element('constrained-box-1', rect(80, 20, 100, 80), { position: 'absolute', top: '20px', bottom: '20px', width: '100px', height: '80px' });
96
+ const box2 = element('constrained-box-2', rect(220, 22, 100, 80), { position: 'absolute', top: '22px', bottom: '18px', width: '100px', height: '80px' });
97
+ parent('constrained-row', rect(0, 0, 600, 120), [box1, box2]);
98
+ const constrained = run([{ axis: 'top', a: 'constrained-box-1', b: 'constrained-box-2', delta: 2 }]);
99
+ assert.equal(constrained.length, 1, 'must fire: a genuine constrained vertical stray still snaps');
100
+ assert.deepEqual(constrained[0].css, { top: '20px', bottom: '20px' }, 'must stay silent: align shifts paired insets and does not freeze height');
101
+ } finally {
102
+ globalThis.document = previousDocument;
103
+ globalThis.getComputedStyle = previousGetComputedStyle;
104
+ }
105
+ }
@@ -0,0 +1,8 @@
1
+ <!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="../../../runtime/proto.css"><style>
2
+ html,body{margin:0;width:1536px;height:1024px}.page{position:relative;width:1536px;height:1024px}.proto-review-framing{position:fixed;right:18px;bottom:18px;z-index:2147483646;width:430px;height:170px;background:#14161a}.control{position:absolute;left:1246px;top:921px;width:215px;height:59px}
3
+ </style></head><body>
4
+ <aside id="proto-review-framing" class="proto-review-framing">Prototype review</aside>
5
+ <main class="page"><button id="review-bottom-modal" class="control" data-proto-action="modal" data-proto-modal='{"title":"Analyze my site?","body":"Confirm analysis.","confirm":"Analyze my site"}'>Analyze my site</button></main>
6
+ <script id="proto-state" type="application/json">{"unit":"review-bottom-action","tokens":{},"contentFile":null}</script>
7
+ <script src="../../../runtime/proto.js"></script>
8
+ </body></html>