@12ui/design 0.2.47 → 0.2.49

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 (159) hide show
  1. package/SKILL.md +1 -1
  2. package/dist/branch-execution.js +2 -2
  3. package/dist/branch-execution.js.map +1 -1
  4. package/dist/branch-page-conversion.d.ts +4 -1
  5. package/dist/branch-page-conversion.d.ts.map +1 -1
  6. package/dist/branch-page-conversion.js +27 -17
  7. package/dist/branch-page-conversion.js.map +1 -1
  8. package/dist/branch-run-record.d.ts +2 -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-branch-command.d.ts +2 -0
  12. package/dist/cli-branch-command.d.ts.map +1 -1
  13. package/dist/cli-branch-command.js +3 -0
  14. package/dist/cli-branch-command.js.map +1 -1
  15. package/dist/cli-convert-command.js +2 -2
  16. package/dist/cli-convert-command.js.map +1 -1
  17. package/dist/cli-draft-command.d.ts +11 -5
  18. package/dist/cli-draft-command.d.ts.map +1 -1
  19. package/dist/cli-draft-command.js +37 -35
  20. package/dist/cli-draft-command.js.map +1 -1
  21. package/dist/cli-help/design.d.ts +10 -0
  22. package/dist/cli-help/design.d.ts.map +1 -1
  23. package/dist/cli-help/design.js +29 -0
  24. package/dist/cli-help/design.js.map +1 -1
  25. package/dist/cli-help/index.d.ts +21 -0
  26. package/dist/cli-help/index.d.ts.map +1 -1
  27. package/dist/cli-help/index.js +18 -2
  28. package/dist/cli-help/index.js.map +1 -1
  29. package/dist/cli-help/meta.d.ts +10 -0
  30. package/dist/cli-help/meta.d.ts.map +1 -1
  31. package/dist/cli-help/meta.js +20 -0
  32. package/dist/cli-help/meta.js.map +1 -1
  33. package/dist/cli-next-command.d.ts.map +1 -1
  34. package/dist/cli-next-command.js +26 -14
  35. package/dist/cli-next-command.js.map +1 -1
  36. package/dist/cli-progress.d.ts +2 -0
  37. package/dist/cli-progress.d.ts.map +1 -1
  38. package/dist/cli-progress.js +2 -0
  39. package/dist/cli-progress.js.map +1 -1
  40. package/dist/cli-redesign-command.d.ts +42 -0
  41. package/dist/cli-redesign-command.d.ts.map +1 -0
  42. package/dist/cli-redesign-command.js +520 -0
  43. package/dist/cli-redesign-command.js.map +1 -0
  44. package/dist/cli-run.d.ts.map +1 -1
  45. package/dist/cli-run.js +16 -0
  46. package/dist/cli-run.js.map +1 -1
  47. package/dist/cli-spend-command.d.ts +20 -0
  48. package/dist/cli-spend-command.d.ts.map +1 -0
  49. package/dist/cli-spend-command.js +46 -0
  50. package/dist/cli-spend-command.js.map +1 -0
  51. package/dist/cli-usage.d.ts.map +1 -1
  52. package/dist/cli-usage.js +9 -1
  53. package/dist/cli-usage.js.map +1 -1
  54. package/dist/conversion-receipt-store.d.ts +2 -2
  55. package/dist/conversion-receipt-store.d.ts.map +1 -1
  56. package/dist/conversion-receipt-store.js +11 -5
  57. package/dist/conversion-receipt-store.js.map +1 -1
  58. package/dist/conversion-receipt.d.ts +2 -3
  59. package/dist/conversion-receipt.d.ts.map +1 -1
  60. package/dist/conversion-receipt.js +17 -12
  61. package/dist/conversion-receipt.js.map +1 -1
  62. package/dist/draft-dispatch-attempt.d.ts +0 -7
  63. package/dist/draft-dispatch-attempt.d.ts.map +1 -1
  64. package/dist/draft-dispatch-attempt.js +1 -25
  65. package/dist/draft-dispatch-attempt.js.map +1 -1
  66. package/dist/draft-identity.d.ts +2 -0
  67. package/dist/draft-identity.d.ts.map +1 -1
  68. package/dist/draft-identity.js +1 -0
  69. package/dist/draft-identity.js.map +1 -1
  70. package/dist/draft-run.d.ts +2 -0
  71. package/dist/draft-run.d.ts.map +1 -1
  72. package/dist/draft-run.js +4 -3
  73. package/dist/draft-run.js.map +1 -1
  74. package/dist/package-client.d.ts +11 -0
  75. package/dist/package-client.d.ts.map +1 -1
  76. package/dist/package-client.js.map +1 -1
  77. package/dist/package-submission.d.ts +12 -0
  78. package/dist/package-submission.d.ts.map +1 -1
  79. package/dist/package-submission.js +5 -0
  80. package/dist/package-submission.js.map +1 -1
  81. package/dist/prototype-label-artifact.d.ts +29 -0
  82. package/dist/prototype-label-artifact.d.ts.map +1 -0
  83. package/dist/prototype-label-artifact.js +104 -0
  84. package/dist/prototype-label-artifact.js.map +1 -0
  85. package/dist/prototype-runner.d.ts +1 -0
  86. package/dist/prototype-runner.d.ts.map +1 -1
  87. package/dist/prototype-runner.js +18 -2
  88. package/dist/prototype-runner.js.map +1 -1
  89. package/dist/redesign-brand-asset-inventory.d.ts +71 -0
  90. package/dist/redesign-brand-asset-inventory.d.ts.map +1 -0
  91. package/dist/redesign-brand-asset-inventory.js +318 -0
  92. package/dist/redesign-brand-asset-inventory.js.map +1 -0
  93. package/dist/redesign-capture.d.ts +57 -0
  94. package/dist/redesign-capture.d.ts.map +1 -0
  95. package/dist/redesign-capture.js +181 -0
  96. package/dist/redesign-capture.js.map +1 -0
  97. package/dist/redesign-pinned-asset.d.ts +20 -0
  98. package/dist/redesign-pinned-asset.d.ts.map +1 -0
  99. package/dist/redesign-pinned-asset.js +35 -0
  100. package/dist/redesign-pinned-asset.js.map +1 -0
  101. package/dist/redesign-plan-client.d.ts +49 -0
  102. package/dist/redesign-plan-client.d.ts.map +1 -0
  103. package/dist/redesign-plan-client.js +90 -0
  104. package/dist/redesign-plan-client.js.map +1 -0
  105. package/dist/redesign-plan-identity.d.ts +8 -0
  106. package/dist/redesign-plan-identity.d.ts.map +1 -0
  107. package/dist/redesign-plan-identity.js +16 -0
  108. package/dist/redesign-plan-identity.js.map +1 -0
  109. package/dist/redesign-plan.d.ts +30 -0
  110. package/dist/redesign-plan.d.ts.map +1 -0
  111. package/dist/redesign-plan.js +99 -0
  112. package/dist/redesign-plan.js.map +1 -0
  113. package/dist/redesign-workspace.d.ts +142 -0
  114. package/dist/redesign-workspace.d.ts.map +1 -0
  115. package/dist/redesign-workspace.js +82 -0
  116. package/dist/redesign-workspace.js.map +1 -0
  117. package/dist/run-journal-commands.d.ts.map +1 -1
  118. package/dist/run-journal-commands.js +16 -0
  119. package/dist/run-journal-commands.js.map +1 -1
  120. package/dist/spend-client.d.ts +97 -0
  121. package/dist/spend-client.d.ts.map +1 -0
  122. package/dist/spend-client.js +31 -0
  123. package/dist/spend-client.js.map +1 -0
  124. package/dist/spend-report-lines.d.ts +4 -0
  125. package/dist/spend-report-lines.d.ts.map +1 -0
  126. package/dist/spend-report-lines.js +131 -0
  127. package/dist/spend-report-lines.js.map +1 -0
  128. package/open-design.json +2 -2
  129. package/package.json +2 -2
  130. package/prototype-kit/build.mjs +9 -3
  131. package/prototype-kit/lib/action-target.mjs +26 -0
  132. package/prototype-kit/lib/local-document.mjs +16 -0
  133. package/prototype-kit/lib/runtime-check.mjs +83 -19
  134. package/prototype-kit/lib/shell-runtime-equivalence.mjs +41 -0
  135. package/prototype-kit/polish/harm.mjs +27 -5
  136. package/prototype-kit/polish/measure.mjs +101 -5
  137. package/prototype-kit/polish/overrides.mjs +32 -3
  138. package/prototype-kit/polish/polish.mjs +23 -11
  139. package/prototype-kit/polish/rules/align.mjs +73 -3
  140. package/prototype-kit/polish/rules/left-rail.mjs +2 -3
  141. package/prototype-kit/polish/rules/section-breathing.mjs +0 -1
  142. package/prototype-kit/polish/tests/fixtures/align/expect.json +1 -1
  143. package/prototype-kit/polish/tests/fixtures/align/fire.html +4 -1
  144. package/prototype-kit/polish/tests/fixtures/align/silent.html +8 -0
  145. package/prototype-kit/polish/tests/fixtures/align/zoom-constrained.html +17 -0
  146. package/prototype-kit/polish/tests/fixtures/harm/left-rail-contained.html +16 -0
  147. package/prototype-kit/polish/tests/fixtures/harm/left-rail-overflow.html +16 -0
  148. package/prototype-kit/polish/tests/fixtures/harm/nested-actions.html +6 -0
  149. package/prototype-kit/polish/tests/fixtures/harm/unidentified-parent.html +3 -0
  150. package/prototype-kit/polish/tests/fixtures/shell-seam/fire.html +1 -1
  151. package/prototype-kit/polish/tests/harm-check.mjs +131 -2
  152. package/prototype-kit/polish/tests/rules-check.mjs +7 -2
  153. package/prototype-kit/runtime/proto.js +4 -1
  154. package/prototype-kit/tests/adversarial-runtime.mjs +3 -0
  155. package/prototype-kit/tests/align-rule-check.mjs +105 -0
  156. package/prototype-kit/tests/fixtures/interaction-runtime/review-bottom-action.html +8 -0
  157. package/prototype-kit/tests/offline-check.mjs +17 -1
  158. package/prototype-kit/tests/rules-check.mjs +67 -1
  159. package/prototype-kit/tests/shell-stitching-runtime.mjs +15 -8
@@ -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>
@@ -4,13 +4,29 @@ import { createHash } from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
+ import { parseHTML } from 'linkedom';
7
8
  import { reviewFramingMarkup } from '../lib/review-framing.mjs';
9
+ import { removeRemoteHeadLinks } from '../lib/local-document.mjs';
8
10
  import { buildPrototype } from './lib/run-process.mjs';
9
11
 
10
12
  const testsDir = path.dirname(new URL(import.meta.url).pathname);
11
13
  const kitDir = path.resolve(testsDir, '..');
12
14
  const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'proto-kit-offline-'));
13
15
 
16
+ const localDocument = parseHTML([
17
+ '<html><head>',
18
+ '<link rel="preconnect" href="https://fonts.example">',
19
+ '<link rel="stylesheet" href="//cdn.example/font.css">',
20
+ '<link rel="stylesheet" href="proto.css">',
21
+ '</head></html>',
22
+ ].join('')).document;
23
+ assert.equal(removeRemoteHeadLinks(localDocument), 2, 'must fire: direct-file prototypes remove remote head links');
24
+ assert.deepEqual(
25
+ [...localDocument.querySelectorAll('head link')].map((link) => link.getAttribute('href')),
26
+ ['proto.css'],
27
+ 'must stay silent: prototype-local stylesheets survive offline sanitization',
28
+ );
29
+
14
30
  function buildFixture(name) {
15
31
  const runDir = path.join(testsDir, 'fixtures', name);
16
32
  return buildPrototype({ kitDir, run: { runDir }, runWorkDir: path.join(scratch, name) });
@@ -44,7 +60,7 @@ const outputDigest = (directory) => {
44
60
  };
45
61
  assert.equal(
46
62
  outputDigest(threeShell.protoDir),
47
- '0c2147df5bc6552016cc278faec7abaabecbc7f5945cad7e1ac9b1df6e554403',
63
+ '0a24a0c220106a1592598f1ad11685081337cb60d3044953e56e11e239dbc8bd',
48
64
  'must stay silent: review support leaves the accepted shell/navigation complete output byte-identical',
49
65
  );
50
66
  const threeShellModel = JSON.parse(fs.readFileSync(threeShell.modelFile, 'utf8'));
@@ -2,11 +2,13 @@
2
2
  import assert from 'node:assert/strict';
3
3
  import { parseHTML } from 'linkedom';
4
4
  import { resolveDropdownAction, suppressDeterministicAction, usableDropdownOptions } from '../lib/actions.mjs';
5
- import { restoreActionPointerReachability } from '../lib/action-target.mjs';
5
+ import { restoreActionPointerReachability, selectUniqueActionOwners } from '../lib/action-target.mjs';
6
+ import { checkAlignRule } from './align-rule-check.mjs';
6
7
  import { declaredActiveMarkers, linkedNavForUnit, markerOverlapsNav } from '../lib/nav-active-state.mjs';
7
8
  import { matchPlannedPage } from '../lib/navigation.mjs';
8
9
  import { needsCanonicalShell, selectCanonicalShellPage } from '../lib/shell-consistency.mjs';
9
10
  import { isolateTransplantedShell, shellElementsForSourceId } from '../lib/shell-identity.mjs';
11
+ import { compareCanonicalShellSamples, SHELL_RUNTIME_GEOMETRY_EPSILON_PX } from '../lib/shell-runtime-equivalence.mjs';
10
12
  import { stitchUnitShells } from '../lib/shell-stitching.mjs';
11
13
  import { compareCanonicalShells, isWithinShell, qualifiesShellCandidate, selectOneShellPerRegion, shouldSuppressDroppedShell, shouldTransplantShell } from '../lib/shells.mjs';
12
14
  import { slugText } from '../lib/text.mjs';
@@ -69,6 +71,68 @@ const reachableBefore = reachableDropdown.outerHTML;
69
71
  assert.equal(restoreActionPointerReachability(reachableDropdown), false, 'must stay silent: an already reachable dropdown target is unchanged');
70
72
  assert.equal(reachableDropdown.outerHTML, reachableBefore);
71
73
 
74
+ const repeatedDropdown = { action: 'dropdown', options: ['All opportunities', 'High intent', 'Low intent'] };
75
+ const distinctDropdown = { action: 'dropdown', options: ['All countries', 'Australia', 'Canada'] };
76
+ const landsFilterDocument = parseHTML(`<div id="filters"><button id="filter-1"></button><button id="filter-2"></button><button id="filter-3"></button><button id="filter-4"></button><button id="filter-5"></button><button id="filter-6"></button></div>`).document;
77
+ const landsFilterActions = new Map([
78
+ ['filters', repeatedDropdown],
79
+ ['filter-1', repeatedDropdown],
80
+ ['filter-2', distinctDropdown],
81
+ ['filter-3', distinctDropdown],
82
+ ['filter-4', distinctDropdown],
83
+ ['filter-5', distinctDropdown],
84
+ ['filter-6', distinctDropdown],
85
+ ]);
86
+ const uniqueLandsFilters = selectUniqueActionOwners(landsFilterDocument, landsFilterActions);
87
+ assert.deepEqual(uniqueLandsFilters.removed, [{ wrapperId: 'filters', ownerIds: ['filter-1', 'filter-2', 'filter-3', 'filter-4', 'filter-5', 'filter-6'], action: 'dropdown' }], 'must fire: the six-child Ghost Sitemap filter shape gives the wrapper action to its terminal controls');
88
+ assert.deepEqual([...uniqueLandsFilters.actions.keys()], ['filter-1', 'filter-2', 'filter-3', 'filter-4', 'filter-5', 'filter-6'], 'must stay silent: every native filter control remains independently actionable');
89
+ const landsModalDocument = parseHTML('<div id="submit"><button id="submit-button"></button></div>').document;
90
+ const landsModalActions = new Map([
91
+ ['submit', { action: 'modal', modal: { title: 'Analyze my site?', confirm: 'Analyze my site' } }],
92
+ ['submit-button', { action: 'modal', modal: { title: 'Analyze my site?', confirm: 'Analyze my site' } }],
93
+ ]);
94
+ const uniqueLandsModal = selectUniqueActionOwners(landsModalDocument, landsModalActions);
95
+ assert.deepEqual(uniqueLandsModal.removed, [{ wrapperId: 'submit', ownerIds: ['submit-button'], action: 'modal' }], 'must fire: the One Question modal wrapper yields to its terminal button');
96
+ assert.deepEqual([...uniqueLandsModal.actions.keys()], ['submit-button']);
97
+ const territoryFilterDocument = parseHTML('<aside id="filter-bar"><button id="filter-a"></button><button id="filter-b"></button><button id="filter-c"></button><button id="filter-d"></button></aside>').document;
98
+ const territoryFilterActions = new Map([
99
+ ['filter-bar', { action: 'dropdown', options: ['All', 'Open', 'Closed'] }],
100
+ ['filter-a', { action: 'dropdown', options: ['All territories', 'East', 'West'] }],
101
+ ['filter-b', { action: 'dropdown', options: ['All visits', 'High', 'Low'] }],
102
+ ['filter-c', { action: 'dropdown', options: ['All claims', 'Claimed', 'Open'] }],
103
+ ['filter-d', { action: 'dropdown', options: ['Sort', 'Visits', 'Fit'] }],
104
+ ]);
105
+ const uniqueTerritoryFilters = selectUniqueActionOwners(territoryFilterDocument, territoryFilterActions);
106
+ assert.deepEqual(uniqueTerritoryFilters.removed, [{ wrapperId: 'filter-bar', ownerIds: ['filter-a', 'filter-b', 'filter-c', 'filter-d'], action: 'dropdown' }], 'must fire: the Territory Map four-button aside yields to every terminal dropdown even when their option payloads differ');
107
+ assert.deepEqual([...uniqueTerritoryFilters.actions.keys()], ['filter-a', 'filter-b', 'filter-c', 'filter-d']);
108
+ const distinctNestedDocument = parseHTML('<div id="parent"><button id="child"></button></div>').document;
109
+ const distinctNestedActions = new Map([
110
+ ['parent', { action: 'dropdown', options: ['One', 'Two', 'Three'] }],
111
+ ['child', { action: 'modal', modal: { title: 'Child' } }],
112
+ ]);
113
+ const uniqueDistinctNested = selectUniqueActionOwners(distinctNestedDocument, distinctNestedActions);
114
+ assert.deepEqual([...uniqueDistinctNested.actions.keys()], ['parent', 'child'], 'must stay silent: distinct nested actions retain the runtime child-wins contract');
115
+
116
+ const shellSample = ({ visualOffset = 0, shellOffset = 0, appearanceSignature = 'same' } = {}) => ({
117
+ appearanceSignature,
118
+ visualGeometry: [[0, 0, 390, 72], [20 + visualOffset, 20, 100, 28]],
119
+ shellGeometry: [shellOffset, 0, 390, 72],
120
+ });
121
+ assert.equal(SHELL_RUNTIME_GEOMETRY_EPSILON_PX, 0.5, 'fixture contract: canonical shell geometry tolerance is half a CSS pixel');
122
+ const subpixelShellComparison = compareCanonicalShellSamples(shellSample(), shellSample({ visualOffset: 0.4 }));
123
+ assert.equal(subpixelShellComparison.equivalent, true, 'must stay silent: a sub-half-pixel glyph measurement drift is visually equivalent');
124
+ assert.ok(Math.abs(subpixelShellComparison.maxGeometryDelta - 0.4) < 1e-9, 'fixture contract: the comparator reports the measured subpixel drift');
125
+ assert.deepEqual(compareCanonicalShellSamples(shellSample(), shellSample({ visualOffset: 24 })), {
126
+ equivalent: false,
127
+ reason: 'geometry',
128
+ maxGeometryDelta: 24,
129
+ }, 'must fire: the b13-class displaced header descendant remains a canonical-shell mismatch');
130
+ assert.deepEqual(compareCanonicalShellSamples(shellSample(), shellSample({ appearanceSignature: 'changed' })), {
131
+ equivalent: false,
132
+ reason: 'appearance',
133
+ maxGeometryDelta: null,
134
+ }, 'must fire: geometry tolerance never conceals structural or style differences');
135
+
72
136
  const headerNav = { containerId: 'nav', region: 'header', semantic: true, items: 8, share: 0.8, box: [0, 40, 1536, 76] };
73
137
  const announcement = { containerId: 'announcement', region: 'header', semantic: false, items: 3, share: 1, box: [0, 0, 1536, 40] };
74
138
  const footer = { containerId: 'footer', region: 'footer', semantic: true, items: 5, share: 0.8, box: [0, 900, 1536, 124] };
@@ -205,4 +269,6 @@ assert.equal(fireResult.bands[0].regions[0].active.navText, 'Menu');
205
269
  assert.equal(fireResult.bands[0].regions[0].shellId, isolatedHeader.id);
206
270
  assert.equal(fireResult.bands[0].regions[0].active.markerId, isolatedMarker.id);
207
271
 
272
+ checkAlignRule();
273
+
208
274
  process.stdout.write('rules: ok\n');
@@ -8,9 +8,9 @@ import { checkRuntime } from '../lib/runtime-check.mjs';
8
8
  import { startStaticServer } from '../lib/static-server.mjs';
9
9
  import { buildPrototype } from './lib/run-process.mjs';
10
10
 
11
- const expectFailure = process.argv.includes('--expect-failure');
12
- const unexpectedArgs = process.argv.slice(2).filter((arg) => arg !== '--expect-failure');
13
- if (unexpectedArgs.length > 0) throw new Error(`usage: shell-stitching-runtime.mjs [--expect-failure]`);
11
+ const reintroduceDefect = process.argv.includes('--reintroduce-defect');
12
+ const unexpectedArgs = process.argv.slice(2).filter((arg) => arg !== '--reintroduce-defect');
13
+ if (unexpectedArgs.length > 0) throw new Error(`usage: shell-stitching-runtime.mjs [--reintroduce-defect]`);
14
14
 
15
15
  const testsDir = path.dirname(new URL(import.meta.url).pathname);
16
16
  const kitDir = path.resolve(testsDir, '..');
@@ -49,6 +49,13 @@ try {
49
49
  bandHeight(inventory, 'p2-canvas-control', 'mobile-390'),
50
50
  ], [900, 1400], 'fixture contract: the portrait control has a substantially taller canvas');
51
51
 
52
+ if (reintroduceDefect) {
53
+ const collisionFile = path.join(built.protoDir, 'id-collision.html');
54
+ const collisionHtml = fs.readFileSync(collisionFile, 'utf8');
55
+ const displaced = '<style id="reintroduced-b13-defect">@media(max-width:600px){[data-proto-shell-source-id="mobile-390-header-brand"]{transform:translateX(24px)!important}}</style>';
56
+ fs.writeFileSync(collisionFile, collisionHtml.replace('</head>', `${displaced}</head>`));
57
+ }
58
+
52
59
  server = await startStaticServer(runWorkDir);
53
60
  browser = await chromium.launch();
54
61
  const audit = await checkRuntime({
@@ -77,11 +84,11 @@ try {
77
84
  assert.equal(collision1536.actualSignature, donor1536.actualSignature, 'must stay silent: distinct native identities render the same canonical header');
78
85
 
79
86
  const groups = audit.shellConsistencyFailures.map((failure) => failure.group);
80
- if (expectFailure) {
81
- assert.deepEqual(groups, ['390:mobile-390:header'], 'pre-fix evidence must isolate one canonical collision to the mobile header');
82
- assert.notEqual(collision390.actualSignature, donor390.actualSignature, 'must fire before the fix: recipient offsets alter transplanted donor descendants');
87
+ if (reintroduceDefect) {
88
+ assert.deepEqual(groups, ['390:mobile-390:header'], 'reintroduced b13 evidence must isolate one canonical collision to the mobile header');
89
+ assert.notEqual(collision390.actualSignature, donor390.actualSignature, 'must fire with the defect present: a 24px recipient displacement alters transplanted donor descendants');
83
90
  const failure = audit.shellConsistencyFailures[0];
84
- assert.equal(failure.reason, 'prototype-shell-signature-mismatch', 'pre-fix evidence is the rendered canonical-shell mismatch');
91
+ assert.equal(failure.reason, 'prototype-shell-signature-mismatch', 'reintroduced evidence is the rendered canonical-shell mismatch');
85
92
  assert.deepEqual(failure.pages, [
86
93
  { file: 'donor.html', signature: donor390.actualSignature, geometry: donor390.actualGeometry },
87
94
  { file: 'id-collision.html', signature: collision390.actualSignature, geometry: collision390.actualGeometry },
@@ -92,7 +99,7 @@ try {
92
99
  }
93
100
 
94
101
  process.stdout.write(JSON.stringify({
95
- expectation: expectFailure ? 'pre-fix-failure' : 'post-fix-pass',
102
+ expectation: reintroduceDefect ? 'reintroduced-defect-failure' : 'post-fix-pass',
96
103
  groups,
97
104
  mobile: {
98
105
  donor: donor390.actualSignature,