@12ui/design 0.2.36 → 0.2.38

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 (101) hide show
  1. package/SKILL.md +1 -1
  2. package/dist/branch-client.d.ts +12 -0
  3. package/dist/branch-client.d.ts.map +1 -1
  4. package/dist/branch-client.js.map +1 -1
  5. package/dist/branch-completion.d.ts.map +1 -1
  6. package/dist/branch-completion.js +1 -0
  7. package/dist/branch-completion.js.map +1 -1
  8. package/dist/branch-execution-types.d.ts +3 -0
  9. package/dist/branch-execution-types.d.ts.map +1 -1
  10. package/dist/branch-execution.d.ts.map +1 -1
  11. package/dist/branch-execution.js +13 -0
  12. package/dist/branch-execution.js.map +1 -1
  13. package/dist/branch-identity.d.ts +1 -1
  14. package/dist/branch-identity.js +1 -1
  15. package/dist/branch-plan.d.ts +3 -1
  16. package/dist/branch-plan.d.ts.map +1 -1
  17. package/dist/branch-plan.js +2 -0
  18. package/dist/branch-plan.js.map +1 -1
  19. package/dist/branch-run-record.d.ts +18 -0
  20. package/dist/branch-run-record.d.ts.map +1 -1
  21. package/dist/branch-run-record.js.map +1 -1
  22. package/dist/branch-winner.d.ts.map +1 -1
  23. package/dist/branch-winner.js +7 -0
  24. package/dist/branch-winner.js.map +1 -1
  25. package/dist/cli-branch-command.d.ts.map +1 -1
  26. package/dist/cli-branch-command.js +61 -7
  27. package/dist/cli-branch-command.js.map +1 -1
  28. package/dist/cli-help/design.d.ts +1 -1
  29. package/dist/cli-help/design.d.ts.map +1 -1
  30. package/dist/cli-help/design.js +1 -0
  31. package/dist/cli-help/design.js.map +1 -1
  32. package/dist/cli-help/index.d.ts +1 -1
  33. package/dist/cli-prototype-command.d.ts +3 -2
  34. package/dist/cli-prototype-command.d.ts.map +1 -1
  35. package/dist/cli-prototype-command.js +9 -3
  36. package/dist/cli-prototype-command.js.map +1 -1
  37. package/dist/cli-review-command.js +2 -2
  38. package/dist/cli-review-command.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/prototype-branch-contract.d.ts +67 -0
  44. package/dist/prototype-branch-contract.d.ts.map +1 -0
  45. package/dist/prototype-branch-contract.js +403 -0
  46. package/dist/prototype-branch-contract.js.map +1 -0
  47. package/dist/prototype-poststep.d.ts +27 -0
  48. package/dist/prototype-poststep.d.ts.map +1 -0
  49. package/dist/prototype-poststep.js +97 -0
  50. package/dist/prototype-poststep.js.map +1 -0
  51. package/dist/prototype-run-record.d.ts +92 -0
  52. package/dist/prototype-run-record.d.ts.map +1 -0
  53. package/dist/prototype-run-record.js +236 -0
  54. package/dist/prototype-run-record.js.map +1 -0
  55. package/dist/prototype-runner.d.ts +13 -1
  56. package/dist/prototype-runner.d.ts.map +1 -1
  57. package/dist/prototype-runner.js +79 -20
  58. package/dist/prototype-runner.js.map +1 -1
  59. package/open-design.json +2 -2
  60. package/package.json +1 -1
  61. package/prototype-kit/analyze.mjs +48 -6
  62. package/prototype-kit/build.mjs +249 -65
  63. package/prototype-kit/inventory.mjs +290 -2
  64. package/prototype-kit/lib/holding-composition.mjs +67 -0
  65. package/prototype-kit/lib/nav-active-state.mjs +67 -0
  66. package/prototype-kit/lib/review-framing.mjs +72 -0
  67. package/prototype-kit/lib/runtime-check.mjs +361 -28
  68. package/prototype-kit/lib/shell-consistency.mjs +68 -0
  69. package/prototype-kit/lib/shell-stitching.mjs +221 -0
  70. package/prototype-kit/runtime/proto.css +8 -3
  71. package/prototype-kit/tests/README.md +4 -1
  72. package/prototype-kit/tests/adversarial-runtime.mjs +85 -2
  73. package/prototype-kit/tests/fixtures/holding-runtime/PROVENANCE.md +22 -0
  74. package/prototype-kit/tests/fixtures/holding-runtime/band-ownership-fire.html +10 -0
  75. package/prototype-kit/tests/fixtures/holding-runtime/band-ownership-silent.html +10 -0
  76. package/prototype-kit/tests/fixtures/holding-runtime/declared-width-fire.html +11 -0
  77. package/prototype-kit/tests/fixtures/holding-runtime/declared-width-silent.html +11 -0
  78. package/prototype-kit/tests/fixtures/holding-runtime/ground-registration-mismatch-fire.html +5 -0
  79. package/prototype-kit/tests/fixtures/holding-runtime/ground-registration-mismatch-silent.html +5 -0
  80. package/prototype-kit/tests/fixtures/holding-runtime/holding-contrast-fire.html +5 -0
  81. package/prototype-kit/tests/fixtures/holding-runtime/holding-contrast-silent.html +5 -0
  82. package/prototype-kit/tests/fixtures/holding-runtime/positioned-ground-fire.html +5 -0
  83. package/prototype-kit/tests/fixtures/holding-runtime/positioned-ground-silent.html +5 -0
  84. package/prototype-kit/tests/fixtures/holding-runtime/shell-geometry-fire.html +5 -0
  85. package/prototype-kit/tests/fixtures/holding-runtime/shell-geometry-silent.html +5 -0
  86. package/prototype-kit/tests/fixtures/holding-runtime/shell-intersection-fire.html +5 -0
  87. package/prototype-kit/tests/fixtures/holding-runtime/shell-intersection-silent.html +5 -0
  88. package/prototype-kit/tests/fixtures/runtime/history.html +1 -1
  89. package/prototype-kit/tests/fixtures/shell-nav-runtime/PROVENANCE.md +6 -0
  90. package/prototype-kit/tests/fixtures/shell-nav-runtime/active-fire.html +3 -0
  91. package/prototype-kit/tests/fixtures/shell-nav-runtime/proto-manifest.json +20 -0
  92. package/prototype-kit/tests/fixtures/shell-nav-runtime/shell-fire.html +3 -0
  93. package/prototype-kit/tests/fixtures/shell-nav-runtime/shell-silent.html +3 -0
  94. package/prototype-kit/tests/fixtures/shell-nav-runtime/unlinked-silent.html +3 -0
  95. package/prototype-kit/tests/fixtures/three-shell/pages/p0-home.html +3 -3
  96. package/prototype-kit/tests/fixtures/three-shell/pages/p1-reports.html +3 -3
  97. package/prototype-kit/tests/lib/run-process.mjs +8 -2
  98. package/prototype-kit/tests/offline-check.mjs +143 -0
  99. package/prototype-kit/tests/population-sweep.mjs +26 -7
  100. package/prototype-kit/tests/rules-check.mjs +80 -0
  101. package/prototype-kit/verify-runtime.mjs +5 -1
@@ -5,6 +5,19 @@ function errorText(error) {
5
5
  return error instanceof Error ? error.message : String(error);
6
6
  }
7
7
 
8
+ function configuredWidths(protoDir, file) {
9
+ const html = fs.readFileSync(path.join(protoDir, file), 'utf8');
10
+ const state = html.match(/<script id="proto-state" type="application\/json">([\s\S]*?)<\/script>/)?.[1];
11
+ if (!state) return [1536, 390];
12
+ try {
13
+ const cfg = JSON.parse(state);
14
+ return [...new Set([1536, 390, ...(cfg.holdingBands || []).map((band) => band.viewportWidth)])]
15
+ .filter((width) => Number.isFinite(width) && width > 0);
16
+ } catch {
17
+ return [1536, 390];
18
+ }
19
+ }
20
+
8
21
  async function smokeInteraction(page, selector, assertion) {
9
22
  const locator = page.locator(selector).first();
10
23
  if (!await locator.count()) return { present: false, ok: true };
@@ -72,48 +85,365 @@ async function checkDeepLinkBack(browser, url, pageFile) {
72
85
  }
73
86
 
74
87
  export async function checkRuntime({ browser, baseUrl, relativeProtoPath, protoDir }) {
88
+ const manifestFile = path.join(protoDir, 'proto-manifest.json');
89
+ const manifest = fs.existsSync(manifestFile)
90
+ ? JSON.parse(fs.readFileSync(manifestFile, 'utf8'))
91
+ : { units: [] };
92
+ const unitByFile = new Map((manifest.units || []).map((unit) => [`${unit.id}.html`, unit]));
75
93
  const htmlFiles = fs.readdirSync(protoDir).filter((name) => name.endsWith('.html') && name !== 'index.html').sort();
76
94
  const pages = [];
77
95
  const brokenTargets = [];
78
96
  let deepLinkFile = null;
79
97
  for (const file of htmlFiles) {
80
- const pageErrors = [];
81
- const consoleErrors = [];
82
- const page = await browser.newPage({ viewport: { width: 1536, height: 1024 } });
83
- page.on('pageerror', (error) => pageErrors.push(error.message));
84
- page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()); });
85
98
  const url = `${baseUrl}/${relativeProtoPath}/${encodeURIComponent(file)}`;
86
- try {
87
- await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 });
88
- const boundRaw = await page.evaluate(() => document.documentElement.dataset.protoBound || null);
89
- const bound = boundRaw ? JSON.parse(boundRaw) : null;
90
- const duplicateIds = await page.locator('[id]').evaluateAll((nodes) => {
91
- const counts = new Map();
92
- for (const node of nodes) counts.set(node.id, (counts.get(node.id) || 0) + 1);
93
- return [...counts].filter(([, count]) => count > 1).map(([id, count]) => ({ id, count }));
94
- });
95
- const targets = await page.locator('[data-proto-action="navigate"]').evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-proto-target')));
96
- for (const target of targets.filter(Boolean)) {
97
- const [targetFile, hash] = target.split('#');
98
- if (!deepLinkFile && targetFile === file && hash) deepLinkFile = file;
99
- const targetPath = path.join(protoDir, targetFile);
100
- if (!fs.existsSync(targetPath)) brokenTargets.push({ page: file, target, reason: 'missing file' });
101
- else if (hash && !fs.readFileSync(targetPath, 'utf8').includes(`id="${hash}"`)) brokenTargets.push({ page: file, target, reason: 'missing anchor' });
99
+ const viewports = [];
100
+ let bound = null;
101
+ let duplicateIds = [];
102
+ let dropdown = { present: false, ok: true };
103
+ let modal = { present: false, ok: true };
104
+ const expectedUnit = unitByFile.get(file) || null;
105
+ for (const width of configuredWidths(protoDir, file)) {
106
+ const pageErrors = [];
107
+ const consoleErrors = [];
108
+ const page = await browser.newPage({ viewport: { width, height: 1024 } });
109
+ page.on('pageerror', (error) => pageErrors.push(error.message));
110
+ page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()); });
111
+ try {
112
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 });
113
+ await page.evaluate(() => document.fonts.ready);
114
+ const boundRaw = await page.evaluate(() => document.documentElement.dataset.protoBound || null);
115
+ if (width === 1536) bound = boundRaw ? JSON.parse(boundRaw) : null;
116
+ const composition = await page.evaluate(() => {
117
+ const state = document.getElementById('proto-state');
118
+ const cfg = state ? JSON.parse(state.textContent || '{}') : {};
119
+ if (!cfg.holding) return { holding: false, ok: true };
120
+ const roots = [...document.querySelectorAll('.ly-band[data-ly-band]')];
121
+ const active = roots.length > 0
122
+ ? roots.filter((root) => {
123
+ const box = root.getBoundingClientRect();
124
+ return getComputedStyle(root).display !== 'none' && box.width > 0 && box.height > 0;
125
+ })
126
+ : [document.body];
127
+ const activeRoot = active.length === 1 ? active[0] : null;
128
+ const activeKey = roots.length === 0 ? 'native' : activeRoot?.getAttribute('data-ly-band') || null;
129
+ const declared = (cfg.holdingBands || []).find((band) => band.key === activeKey) || null;
130
+ const surfaces = activeRoot ? [...activeRoot.querySelectorAll('[data-proto-holding-surface="true"]')] : [];
131
+ const surface = surfaces.length === 1 ? surfaces[0] : null;
132
+ const screen = activeRoot?.querySelector('.generated-screen') || (activeRoot === document.body ? document.body : null);
133
+ const emittedGroundRegistration = screen?.getAttribute('data-ly-ground-registration') || null;
134
+ const declaredGroundRegistration = declared?.groundRegistration || null;
135
+ const groundRegistrationMismatch = !!declared
136
+ && emittedGroundRegistration !== declaredGroundRegistration;
137
+ const shellIntersections = [];
138
+ const shellGeometryMismatches = [];
139
+ let hostGeometryMismatch = null;
140
+ if (activeRoot && surface && declared) {
141
+ const holdingBox = surface.getBoundingClientRect();
142
+ const host = activeRoot.querySelector(`[data-proto-holding-band="${CSS.escape(activeKey)}"]`);
143
+ const hostBox = host?.getBoundingClientRect() || null;
144
+ const screenBox = screen?.getBoundingClientRect() || null;
145
+ const hostZoom = host ? parseFloat(getComputedStyle(host).zoom) || 1 : 1;
146
+ const hasDeclaredHostOrigins = Array.isArray(declared.hostOrigins);
147
+ const hostOrigin = (declared.hostOrigins || [])
148
+ .find((sample) => sample.viewportWidth === window.innerWidth)?.origin || null;
149
+ if (hasDeclaredHostOrigins && !hostOrigin) {
150
+ hostGeometryMismatch = { reason: `missing-declared-origin-at-${window.innerWidth}` };
151
+ }
152
+ if (hostBox && screenBox && hostOrigin) {
153
+ const [hostLeft, hostTop] = hostOrigin;
154
+ const deltas = {
155
+ left: Math.abs(hostBox.left - (screenBox.left + hostLeft * hostZoom)),
156
+ top: Math.abs(hostBox.top - (screenBox.top + hostTop * hostZoom)),
157
+ };
158
+ if (Object.values(deltas).some((delta) => delta > 1)) hostGeometryMismatch = { deltas };
159
+ }
160
+ const declaredBoxes = new Map((declared.shellBoxes || []).map((entry) => [entry.id, entry.box]));
161
+ for (const id of declared.shellIds || []) {
162
+ const shell = document.getElementById(id);
163
+ if (!shell || !activeRoot.contains(shell)) {
164
+ shellIntersections.push({ shellId: id, reason: 'missing-declared-shell' });
165
+ continue;
166
+ }
167
+ const shellBox = shell.getBoundingClientRect();
168
+ const expected = declaredBoxes.get(id);
169
+ if (expected && screenBox && hostOrigin) {
170
+ const [x, y, width, height] = expected;
171
+ const [hostLeft, hostTop] = hostOrigin;
172
+ const deltas = {
173
+ left: Math.abs(shellBox.left - (screenBox.left + (hostLeft + x) * hostZoom)),
174
+ top: Math.abs(shellBox.top - (screenBox.top + (hostTop + y) * hostZoom)),
175
+ width: Math.abs(shellBox.width - width * hostZoom),
176
+ height: Math.abs(shellBox.height - height * hostZoom),
177
+ };
178
+ if (Object.values(deltas).some((delta) => delta > 1)) {
179
+ shellGeometryMismatches.push({ shellId: id, deltas });
180
+ }
181
+ }
182
+ const overlapWidth = Math.min(holdingBox.right, shellBox.right) - Math.max(holdingBox.left, shellBox.left);
183
+ const overlapHeight = Math.min(holdingBox.bottom, shellBox.bottom) - Math.max(holdingBox.top, shellBox.top);
184
+ if (overlapWidth > 0.5 && overlapHeight > 0.5) {
185
+ shellIntersections.push({ shellId: id, overlapWidth, overlapHeight });
186
+ }
187
+ }
188
+ }
189
+ let donorGroundActive = false;
190
+ let donorGround = null;
191
+ if (activeRoot && declared?.groundRegistration === 'positioned-scene') {
192
+ if (!screen) donorGroundActive = true;
193
+ else {
194
+ const style = getComputedStyle(screen);
195
+ const before = getComputedStyle(screen, '::before');
196
+ const after = getComputedStyle(screen, '::after');
197
+ const paints = (pseudo) => !['none', 'normal'].includes(pseudo.content)
198
+ && pseudo.display !== 'none';
199
+ donorGroundActive = style.backgroundImage !== 'none' || paints(before) || paints(after);
200
+ donorGround = {
201
+ backgroundImage: style.backgroundImage === 'none' ? 'none' : 'painted',
202
+ before: before.content,
203
+ after: after.content,
204
+ };
205
+ }
206
+ }
207
+ const parseRgb = (value) => {
208
+ const match = String(value).match(/rgba?\((\d+(?:\.\d+)?)[, ]+(\d+(?:\.\d+)?)[, ]+(\d+(?:\.\d+)?)/);
209
+ return match ? match.slice(1, 4).map(Number) : null;
210
+ };
211
+ const luminance = (value) => {
212
+ const channels = parseRgb(value);
213
+ if (!channels) return null;
214
+ const linear = channels.map((channel) => {
215
+ const normalized = channel / 255;
216
+ return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4;
217
+ });
218
+ return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
219
+ };
220
+ const contrast = (foreground, background) => {
221
+ const left = luminance(foreground);
222
+ const right = luminance(background);
223
+ if (left === null || right === null) return 0;
224
+ return (Math.max(left, right) + 0.05) / (Math.min(left, right) + 0.05);
225
+ };
226
+ const surfaceColor = screen ? getComputedStyle(screen).backgroundColor : null;
227
+ const title = surface?.querySelector('.proto-holding-title');
228
+ const body = surface?.querySelector('.proto-holding-sub');
229
+ const contrastRatios = {
230
+ title: title && surfaceColor ? contrast(getComputedStyle(title).color, surfaceColor) : 0,
231
+ body: body && surfaceColor ? contrast(getComputedStyle(body).color, surfaceColor) : 0,
232
+ };
233
+ const holdingContrastFailure = declared?.groundRegistration === 'positioned-scene' && !!surface
234
+ && (contrastRatios.title < 4.5 || contrastRatios.body < 4.5);
235
+ const failures = [];
236
+ if (active.length !== 1) failures.push('active-band-count');
237
+ if (!declared) failures.push('undeclared-active-band');
238
+ if (surfaces.length !== 1) failures.push('holding-surface-count');
239
+ if (shellIntersections.length > 0) failures.push('shell-intersection');
240
+ if (hostGeometryMismatch || shellGeometryMismatches.length > 0) failures.push('shell-geometry-mismatch');
241
+ if (groundRegistrationMismatch) failures.push('ground-registration-mismatch');
242
+ if (donorGroundActive) failures.push('donor-ground-active');
243
+ if (holdingContrastFailure) failures.push('holding-contrast');
244
+ return {
245
+ holding: true,
246
+ ok: failures.length === 0,
247
+ activeBandCount: active.length,
248
+ activeBand: activeKey,
249
+ holdingSurfaceCount: surfaces.length,
250
+ shellIntersections,
251
+ shellGeometryMismatches,
252
+ hostGeometryMismatch,
253
+ emittedGroundRegistration,
254
+ declaredGroundRegistration,
255
+ groundRegistrationMismatch,
256
+ donorGroundActive,
257
+ donorGround,
258
+ contrastRatios,
259
+ failures,
260
+ };
261
+ });
262
+ const shellState = await page.evaluate(async (expected) => {
263
+ if (!expected?.shellBands?.length) return { applicable: false, ok: true, failures: [] };
264
+ const roots = [...document.querySelectorAll('.ly-band[data-ly-band]')];
265
+ const visibleRoots = roots.length > 0
266
+ ? roots.filter((root) => {
267
+ const box = root.getBoundingClientRect();
268
+ return getComputedStyle(root).display !== 'none' && box.width > 0 && box.height > 0;
269
+ })
270
+ : [document.body];
271
+ const activeRoot = visibleRoots.length === 1 ? visibleRoots[0] : null;
272
+ const activeBand = roots.length === 0 ? 'native' : activeRoot?.getAttribute('data-ly-band') || null;
273
+ const declared = expected.shellBands.find((band) => band.key === activeBand) || null;
274
+ const failures = [];
275
+ const regions = [];
276
+ if (!activeRoot || !declared) {
277
+ failures.push({ kind: 'shell-consistency', reason: !activeRoot ? 'active-band-count' : 'undeclared-active-band' });
278
+ return { applicable: true, ok: false, activeBand, failures, regions };
279
+ }
280
+ const screen = activeRoot.querySelector('.generated-screen') || (activeRoot === document.body ? activeRoot : null);
281
+ const host = screen?.querySelector('.ly-anchor-canvas') || screen;
282
+ const hostZoom = host ? parseFloat(getComputedStyle(host).zoom) || 1 : 1;
283
+ const round = (value) => Math.round(value * 10) / 10;
284
+ const visualSignature = async (shell, declaration) => {
285
+ const rootBox = shell.getBoundingClientRect();
286
+ const markerIds = new Set(declaration.activeMarkerIds || []);
287
+ const visualNodes = [shell, ...shell.querySelectorAll('*')]
288
+ .filter((node) => {
289
+ for (const id of markerIds) if (node.id === id || node.closest(`#${CSS.escape(id)}`)) return false;
290
+ return true;
291
+ })
292
+ .map((node) => {
293
+ const box = node.getBoundingClientRect();
294
+ const style = getComputedStyle(node);
295
+ return {
296
+ tag: node.tagName.toLowerCase(),
297
+ cls: typeof node.className === 'string'
298
+ ? node.className.split(/\s+/).filter((name) => name && !name.startsWith('proto-')).join(' ')
299
+ : '',
300
+ text: node.children.length === 0 ? (node.textContent || '').replace(/\s+/g, ' ').trim() : '',
301
+ label: node.getAttribute('alt') || node.getAttribute('aria-label') || '',
302
+ source: node.getAttribute('src') || '',
303
+ box: [
304
+ round((box.left - rootBox.left) / hostZoom),
305
+ round((box.top - rootBox.top) / hostZoom),
306
+ round(box.width / hostZoom),
307
+ round(box.height / hostZoom),
308
+ ],
309
+ style: [
310
+ style.display, node === shell ? 'shell-root' : style.position,
311
+ style.fontFamily, style.fontSize, style.lineHeight,
312
+ style.borderTopWidth, style.borderRadius,
313
+ style.opacity, style.objectFit,
314
+ ],
315
+ };
316
+ }).filter((node) => node.box[2] > 0 && node.box[3] > 0);
317
+ const bytes = new TextEncoder().encode(JSON.stringify({ region: declaration.region, visualNodes }));
318
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
319
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
320
+ };
321
+ for (const declaration of declared.regions || []) {
322
+ const shell = document.getElementById(declaration.shellId);
323
+ if (!shell || !activeRoot.contains(shell)) {
324
+ failures.push({ kind: 'shell-consistency', region: declaration.region, reason: 'missing-declared-shell' });
325
+ continue;
326
+ }
327
+ const actualSignature = await visualSignature(shell, declaration);
328
+ const shellBox = shell.getBoundingClientRect();
329
+ const hostBox = host?.getBoundingClientRect() || { left: 0, top: 0 };
330
+ const actualGeometry = [
331
+ round((shellBox.left - hostBox.left) / hostZoom),
332
+ round((shellBox.top - hostBox.top) / hostZoom),
333
+ round(shellBox.width / hostZoom),
334
+ round(shellBox.height / hostZoom),
335
+ ];
336
+ let activeMatches = true;
337
+ if (declaration.active?.status === 'linked') {
338
+ const marker = document.getElementById(declaration.active.markerId);
339
+ const nav = document.getElementById(declaration.active.navId);
340
+ if (!marker || !nav || !shell.contains(marker) || !shell.contains(nav)) activeMatches = false;
341
+ else {
342
+ const markerBox = marker.getBoundingClientRect();
343
+ const navBox = nav.getBoundingClientRect();
344
+ const overlap = Math.min(markerBox.right, navBox.right) - Math.max(markerBox.left, navBox.left);
345
+ const overlapRatio = overlap / Math.max(1, Math.min(markerBox.width, navBox.width));
346
+ activeMatches = overlapRatio >= 0.7
347
+ && markerBox.top >= navBox.bottom - 2
348
+ && markerBox.top - navBox.bottom <= 32;
349
+ }
350
+ if (!activeMatches) failures.push({
351
+ kind: 'active-navigation',
352
+ region: declaration.region,
353
+ reason: 'marker-target-mismatch',
354
+ navText: declaration.active.navText,
355
+ navId: declaration.active.navId,
356
+ markerId: declaration.active.markerId,
357
+ });
358
+ }
359
+ regions.push({
360
+ region: declaration.region,
361
+ shellId: declaration.shellId,
362
+ actualSignature,
363
+ actualGeometry,
364
+ inventorySampleSignature: declaration.canonicalSignature,
365
+ consistencyStatus: declaration.consistencyStatus || 'canonical',
366
+ activeStatus: declaration.active?.status || 'silent',
367
+ activeMatches,
368
+ });
369
+ }
370
+ return { applicable: true, ok: failures.length === 0, activeBand, failures, regions };
371
+ }, expectedUnit);
372
+ if (width === 1536) {
373
+ duplicateIds = await page.locator('[id]').evaluateAll((nodes) => {
374
+ const counts = new Map();
375
+ for (const node of nodes) counts.set(node.id, (counts.get(node.id) || 0) + 1);
376
+ return [...counts].filter(([, count]) => count > 1).map(([id, count]) => ({ id, count }));
377
+ });
378
+ const targets = await page.locator('[data-proto-action="navigate"]').evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-proto-target')));
379
+ for (const target of targets.filter(Boolean)) {
380
+ const [targetFile, hash] = target.split('#');
381
+ if (!deepLinkFile && targetFile === file && hash) deepLinkFile = file;
382
+ const targetPath = path.join(protoDir, targetFile);
383
+ if (!fs.existsSync(targetPath)) brokenTargets.push({ page: file, target, reason: 'missing file' });
384
+ else if (hash && !fs.readFileSync(targetPath, 'utf8').includes(`id="${hash}"`)) brokenTargets.push({ page: file, target, reason: 'missing anchor' });
385
+ }
386
+ dropdown = await smokeInteraction(page, '[data-proto-action="dropdown"]', '.proto-menu');
387
+ modal = await smokeInteraction(page, '[data-proto-action="modal"]', '.proto-modal');
388
+ }
389
+ viewports.push({ width, ok: pageErrors.length === 0 && composition.ok && shellState.ok, pageErrors, consoleErrors, composition, shellState });
390
+ } catch (error) {
391
+ viewports.push({ width, ok: false, pageErrors, consoleErrors, loadError: errorText(error), composition: null });
392
+ } finally {
393
+ await page.close();
102
394
  }
103
- const dropdown = await smokeInteraction(page, '[data-proto-action="dropdown"]', '.proto-menu');
104
- const modal = await smokeInteraction(page, '[data-proto-action="modal"]', '.proto-modal');
105
- pages.push({ file, ok: pageErrors.length === 0 && dropdown.ok && modal.ok, bound, duplicateIds, pageErrors, consoleErrors, dropdown, modal });
106
- } catch (error) {
107
- pages.push({ file, ok: false, bound: null, pageErrors, consoleErrors, loadError: errorText(error) });
108
- } finally {
109
- await page.close();
110
395
  }
396
+ const pageErrors = viewports.flatMap((viewport) => viewport.pageErrors || []);
397
+ const consoleErrors = viewports.flatMap((viewport) => viewport.consoleErrors || []);
398
+ const loadError = viewports.find((viewport) => viewport.loadError)?.loadError;
399
+ pages.push({
400
+ file,
401
+ ok: viewports.every((viewport) => viewport.ok) && dropdown.ok && modal.ok,
402
+ bound,
403
+ duplicateIds,
404
+ pageErrors,
405
+ consoleErrors,
406
+ ...(loadError ? { loadError } : {}),
407
+ dropdown,
408
+ modal,
409
+ viewports,
410
+ });
111
411
  }
112
412
  let deepLinkBack = { present: false, ok: true };
113
413
  if (deepLinkFile) {
114
414
  const result = await checkDeepLinkBack(browser, `${baseUrl}/${relativeProtoPath}/${encodeURIComponent(deepLinkFile)}`, deepLinkFile);
115
415
  deepLinkBack = { ...result, file: deepLinkFile };
116
416
  }
417
+ const compositionFailures = pages.flatMap((page) => page.viewports.flatMap((viewport) => (
418
+ viewport.composition?.failures?.map((failure) => ({ file: page.file, width: viewport.width, failure })) || []
419
+ )));
420
+ const shellConsistencyFailures = pages.flatMap((page) => page.viewports.flatMap((viewport) => (
421
+ viewport.shellState?.failures?.filter((failure) => failure.kind === 'shell-consistency')
422
+ .map((failure) => ({ file: page.file, width: viewport.width, ...failure })) || []
423
+ )));
424
+ const shellRuntimeGroups = new Map();
425
+ for (const page of pages) for (const viewport of page.viewports) {
426
+ if (!unitByFile.has(page.file)) continue;
427
+ for (const region of viewport.shellState?.regions || []) {
428
+ if (region.consistencyStatus !== 'canonical') continue;
429
+ const key = `${viewport.width}:${viewport.shellState.activeBand}:${region.region}`;
430
+ if (!shellRuntimeGroups.has(key)) shellRuntimeGroups.set(key, []);
431
+ shellRuntimeGroups.get(key).push({ file: page.file, signature: region.actualSignature, geometry: region.actualGeometry });
432
+ }
433
+ }
434
+ for (const [key, entries] of shellRuntimeGroups) {
435
+ const fingerprints = new Set(entries.map((entry) => JSON.stringify([entry.signature, entry.geometry])));
436
+ if (fingerprints.size > 1) shellConsistencyFailures.push({
437
+ kind: 'shell-consistency',
438
+ reason: 'prototype-shell-signature-mismatch',
439
+ group: key,
440
+ pages: entries,
441
+ });
442
+ }
443
+ const activeNavigationFailures = pages.flatMap((page) => page.viewports.flatMap((viewport) => (
444
+ viewport.shellState?.failures?.filter((failure) => failure.kind === 'active-navigation')
445
+ .map((failure) => ({ file: page.file, width: viewport.width, ...failure })) || []
446
+ )));
117
447
  return {
118
448
  pages,
119
449
  pageErrors: pages.reduce((sum, page) => sum + page.pageErrors.length + (page.loadError ? 1 : 0), 0),
@@ -121,6 +451,9 @@ export async function checkRuntime({ browser, baseUrl, relativeProtoPath, protoD
121
451
  duplicateIds: pages.reduce((sum, page) => sum + (page.duplicateIds?.length || 0), 0),
122
452
  brokenTargets,
123
453
  interactionFailures: pages.filter((page) => page.dropdown?.ok === false || page.modal?.ok === false).map((page) => page.file),
454
+ compositionFailures,
455
+ shellConsistencyFailures,
456
+ activeNavigationFailures,
124
457
  deepLinkBack,
125
458
  };
126
459
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The one policy owner for selecting a prototype-wide shell donor.
3
+ *
4
+ * Analysis has already established which regions are genuine repeated shells.
5
+ * Inventory supplies the measured band/region declarations. The donor is one
6
+ * real page, never a synthetic mixture: prefer the page covering the greatest
7
+ * number of declared band/region pairs, then the page that already won the
8
+ * most canonical-region comparisons, then the approved source page, and
9
+ * finally stable plan/state order.
10
+ */
11
+
12
+ function pageStates(states, pageId) {
13
+ return states.filter((state) => state.pageId === pageId);
14
+ }
15
+
16
+ function measuredCoverage(page, regions) {
17
+ return (page.bands || []).reduce((total, band) => total + (band.shells || [])
18
+ .filter((shell) => regions.has(shell.region)).length, 0);
19
+ }
20
+
21
+ export function selectCanonicalShellPage({ pages, states, canonicalShell }) {
22
+ const regions = new Set(Object.keys(canonicalShell || {}));
23
+ if (regions.size === 0) return null;
24
+ const winners = Object.values(canonicalShell || {}).map((shell) => shell.stateId);
25
+ const candidates = pages.flatMap((page, pageOrder) => {
26
+ const ownedStates = pageStates(states, page.pageId);
27
+ if (ownedStates.length === 0) return [];
28
+ const stateOrder = Math.min(...ownedStates.map((state) => states.indexOf(state)));
29
+ return [{
30
+ pageId: page.pageId,
31
+ stateId: ownedStates[0].stateId,
32
+ coverage: measuredCoverage(page, regions),
33
+ canonicalWins: winners.filter((stateId) => ownedStates.some((state) => state.stateId === stateId)).length,
34
+ isSource: ownedStates.some((state) => state.isSource),
35
+ pageIndex: Number.isFinite(page.planPage?.index) ? page.planPage.index : pageOrder,
36
+ stateOrder,
37
+ }];
38
+ });
39
+ candidates.sort((left, right) => right.coverage - left.coverage
40
+ || right.canonicalWins - left.canonicalWins
41
+ || Number(right.isSource) - Number(left.isSource)
42
+ || left.pageIndex - right.pageIndex
43
+ || left.stateOrder - right.stateOrder
44
+ || left.pageId.localeCompare(right.pageId));
45
+ return candidates[0] || null;
46
+ }
47
+
48
+ export function shellForBand(page, bandKey, region) {
49
+ return page?.bands?.find((band) => band.key === bandKey)?.shells
50
+ ?.find((shell) => shell.region === region) || null;
51
+ }
52
+
53
+ export function needsCanonicalShell(recipient, canonical) {
54
+ if (!canonical) return false;
55
+ if (!recipient) return true;
56
+ return recipient.signature !== canonical.signature;
57
+ }
58
+
59
+ export function canonicalShellRule() {
60
+ return [
61
+ 'most declared band-region pairs',
62
+ 'most existing canonical-region wins',
63
+ 'approved source page',
64
+ 'plan order',
65
+ 'state order',
66
+ 'page id',
67
+ ];
68
+ }