@12ui/design 0.2.48 → 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 (119) hide show
  1. package/SKILL.md +1 -1
  2. package/dist/branch-page-conversion.d.ts +4 -1
  3. package/dist/branch-page-conversion.d.ts.map +1 -1
  4. package/dist/branch-page-conversion.js +27 -17
  5. package/dist/branch-page-conversion.js.map +1 -1
  6. package/dist/branch-run-record.d.ts +2 -0
  7. package/dist/branch-run-record.d.ts.map +1 -1
  8. package/dist/branch-run-record.js.map +1 -1
  9. package/dist/cli-draft-command.d.ts +11 -5
  10. package/dist/cli-draft-command.d.ts.map +1 -1
  11. package/dist/cli-draft-command.js +37 -35
  12. package/dist/cli-draft-command.js.map +1 -1
  13. package/dist/cli-help/design.d.ts +10 -0
  14. package/dist/cli-help/design.d.ts.map +1 -1
  15. package/dist/cli-help/design.js +29 -0
  16. package/dist/cli-help/design.js.map +1 -1
  17. package/dist/cli-help/index.d.ts +11 -0
  18. package/dist/cli-help/index.d.ts.map +1 -1
  19. package/dist/cli-help/index.js +18 -2
  20. package/dist/cli-help/index.js.map +1 -1
  21. package/dist/cli-next-command.d.ts.map +1 -1
  22. package/dist/cli-next-command.js +26 -14
  23. package/dist/cli-next-command.js.map +1 -1
  24. package/dist/cli-progress.d.ts +2 -0
  25. package/dist/cli-progress.d.ts.map +1 -1
  26. package/dist/cli-progress.js +2 -0
  27. package/dist/cli-progress.js.map +1 -1
  28. package/dist/cli-redesign-command.d.ts +42 -0
  29. package/dist/cli-redesign-command.d.ts.map +1 -0
  30. package/dist/cli-redesign-command.js +520 -0
  31. package/dist/cli-redesign-command.js.map +1 -0
  32. package/dist/cli-run.d.ts.map +1 -1
  33. package/dist/cli-run.js +8 -0
  34. package/dist/cli-run.js.map +1 -1
  35. package/dist/cli-usage.d.ts.map +1 -1
  36. package/dist/cli-usage.js +8 -1
  37. package/dist/cli-usage.js.map +1 -1
  38. package/dist/draft-dispatch-attempt.d.ts +0 -7
  39. package/dist/draft-dispatch-attempt.d.ts.map +1 -1
  40. package/dist/draft-dispatch-attempt.js +1 -25
  41. package/dist/draft-dispatch-attempt.js.map +1 -1
  42. package/dist/draft-identity.d.ts +2 -0
  43. package/dist/draft-identity.d.ts.map +1 -1
  44. package/dist/draft-identity.js +1 -0
  45. package/dist/draft-identity.js.map +1 -1
  46. package/dist/draft-run.d.ts +2 -0
  47. package/dist/draft-run.d.ts.map +1 -1
  48. package/dist/draft-run.js +4 -3
  49. package/dist/draft-run.js.map +1 -1
  50. package/dist/package-client.d.ts +11 -0
  51. package/dist/package-client.d.ts.map +1 -1
  52. package/dist/package-client.js.map +1 -1
  53. package/dist/package-submission.d.ts +12 -0
  54. package/dist/package-submission.d.ts.map +1 -1
  55. package/dist/package-submission.js +5 -0
  56. package/dist/package-submission.js.map +1 -1
  57. package/dist/redesign-brand-asset-inventory.d.ts +71 -0
  58. package/dist/redesign-brand-asset-inventory.d.ts.map +1 -0
  59. package/dist/redesign-brand-asset-inventory.js +318 -0
  60. package/dist/redesign-brand-asset-inventory.js.map +1 -0
  61. package/dist/redesign-capture.d.ts +57 -0
  62. package/dist/redesign-capture.d.ts.map +1 -0
  63. package/dist/redesign-capture.js +181 -0
  64. package/dist/redesign-capture.js.map +1 -0
  65. package/dist/redesign-pinned-asset.d.ts +20 -0
  66. package/dist/redesign-pinned-asset.d.ts.map +1 -0
  67. package/dist/redesign-pinned-asset.js +35 -0
  68. package/dist/redesign-pinned-asset.js.map +1 -0
  69. package/dist/redesign-plan-client.d.ts +49 -0
  70. package/dist/redesign-plan-client.d.ts.map +1 -0
  71. package/dist/redesign-plan-client.js +90 -0
  72. package/dist/redesign-plan-client.js.map +1 -0
  73. package/dist/redesign-plan-identity.d.ts +8 -0
  74. package/dist/redesign-plan-identity.d.ts.map +1 -0
  75. package/dist/redesign-plan-identity.js +16 -0
  76. package/dist/redesign-plan-identity.js.map +1 -0
  77. package/dist/redesign-plan.d.ts +30 -0
  78. package/dist/redesign-plan.d.ts.map +1 -0
  79. package/dist/redesign-plan.js +99 -0
  80. package/dist/redesign-plan.js.map +1 -0
  81. package/dist/redesign-workspace.d.ts +142 -0
  82. package/dist/redesign-workspace.d.ts.map +1 -0
  83. package/dist/redesign-workspace.js +82 -0
  84. package/dist/redesign-workspace.js.map +1 -0
  85. package/dist/run-journal-commands.d.ts.map +1 -1
  86. package/dist/run-journal-commands.js +10 -0
  87. package/dist/run-journal-commands.js.map +1 -1
  88. package/open-design.json +2 -2
  89. package/package.json +2 -2
  90. package/prototype-kit/build.mjs +9 -3
  91. package/prototype-kit/lib/action-target.mjs +26 -0
  92. package/prototype-kit/lib/local-document.mjs +16 -0
  93. package/prototype-kit/lib/runtime-check.mjs +83 -19
  94. package/prototype-kit/lib/shell-runtime-equivalence.mjs +41 -0
  95. package/prototype-kit/polish/harm.mjs +27 -5
  96. package/prototype-kit/polish/measure.mjs +101 -5
  97. package/prototype-kit/polish/overrides.mjs +32 -3
  98. package/prototype-kit/polish/polish.mjs +23 -11
  99. package/prototype-kit/polish/rules/align.mjs +73 -3
  100. package/prototype-kit/polish/rules/left-rail.mjs +2 -3
  101. package/prototype-kit/polish/rules/section-breathing.mjs +0 -1
  102. package/prototype-kit/polish/tests/fixtures/align/expect.json +1 -1
  103. package/prototype-kit/polish/tests/fixtures/align/fire.html +4 -1
  104. package/prototype-kit/polish/tests/fixtures/align/silent.html +8 -0
  105. package/prototype-kit/polish/tests/fixtures/align/zoom-constrained.html +17 -0
  106. package/prototype-kit/polish/tests/fixtures/harm/left-rail-contained.html +16 -0
  107. package/prototype-kit/polish/tests/fixtures/harm/left-rail-overflow.html +16 -0
  108. package/prototype-kit/polish/tests/fixtures/harm/nested-actions.html +6 -0
  109. package/prototype-kit/polish/tests/fixtures/harm/unidentified-parent.html +3 -0
  110. package/prototype-kit/polish/tests/fixtures/shell-seam/fire.html +1 -1
  111. package/prototype-kit/polish/tests/harm-check.mjs +131 -2
  112. package/prototype-kit/polish/tests/rules-check.mjs +7 -2
  113. package/prototype-kit/runtime/proto.js +4 -1
  114. package/prototype-kit/tests/adversarial-runtime.mjs +3 -0
  115. package/prototype-kit/tests/align-rule-check.mjs +105 -0
  116. package/prototype-kit/tests/fixtures/interaction-runtime/review-bottom-action.html +8 -0
  117. package/prototype-kit/tests/offline-check.mjs +17 -1
  118. package/prototype-kit/tests/rules-check.mjs +67 -1
  119. package/prototype-kit/tests/shell-stitching-runtime.mjs +15 -8
package/open-design.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "specVersion": "1.0.0",
4
4
  "name": "12ui-design",
5
5
  "title": "12UI Design",
6
- "version": "0.2.48",
6
+ "version": "0.2.49",
7
7
  "description": "Explore corpus-grounded interface directions, choose one, and convert it into production-ready responsive or native artifacts with 12UI.",
8
8
  "author": {
9
9
  "name": "Just Every",
@@ -49,7 +49,7 @@
49
49
  "command": "npx",
50
50
  "args": [
51
51
  "-y",
52
- "@12ui/design@0.2.48",
52
+ "@12ui/design@0.2.49",
53
53
  "mcp"
54
54
  ]
55
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12ui/design",
3
- "version": "0.2.48",
3
+ "version": "0.2.49",
4
4
  "type": "module",
5
5
  "description": "The canonical 12ui CLI, SDK, MCP server, and visual interface design skills",
6
6
  "main": "dist/index.js",
@@ -23,7 +23,7 @@
23
23
  "start": "node dist/cli.js",
24
24
  "test": "vitest",
25
25
  "test:run": "vitest run",
26
- "test:prototype": "node prototype-kit/polish/tests/rules-check.mjs && node prototype-kit/polish/tests/harm-check.mjs && node prototype-kit/polish/tests/breathing-check.mjs && node prototype-kit/polish/tests/report-check.mjs && node prototype-kit/tests/rules-check.mjs && node prototype-kit/tests/offline-check.mjs && node prototype-kit/tests/shell-stitching-runtime.mjs && node prototype-kit/tests/adversarial-runtime.mjs --assert",
26
+ "test:prototype": "node prototype-kit/polish/tests/rules-check.mjs && node prototype-kit/polish/tests/harm-check.mjs && node prototype-kit/polish/tests/breathing-check.mjs && node prototype-kit/polish/tests/report-check.mjs && node prototype-kit/tests/rules-check.mjs && node prototype-kit/tests/offline-check.mjs && node prototype-kit/tests/shell-stitching-runtime.mjs && node prototype-kit/tests/shell-stitching-runtime.mjs --reintroduce-defect && node prototype-kit/tests/adversarial-runtime.mjs --assert",
27
27
  "typecheck": "tsc --noEmit && tsc -p tsconfig.type-tests.json",
28
28
  "prepublishOnly": "npm run build"
29
29
  },
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { parseHTML } from 'linkedom';
6
6
  import { resolveDropdownAction } from './lib/actions.mjs';
7
- import { restoreActionPointerReachability } from './lib/action-target.mjs';
7
+ import { restoreActionPointerReachability, selectUniqueActionOwners } from './lib/action-target.mjs';
8
8
  import {
9
9
  HOLDING_SHELL_ATTRIBUTE,
10
10
  holdingBlockGeometry,
@@ -13,6 +13,7 @@ import {
13
13
  shellPlacementCss,
14
14
  } from './lib/holding-composition.mjs';
15
15
  import { injectReviewFraming } from './lib/review-framing.mjs';
16
+ import { removeRemoteHeadLinks } from './lib/local-document.mjs';
16
17
  import {
17
18
  isolateTransplantedShell,
18
19
  rulesFor,
@@ -156,6 +157,7 @@ const report = {
156
157
  unbound: [],
157
158
  invalidDropdowns: [],
158
159
  actionDemotions: [],
160
+ duplicateActionOwners: [],
159
161
  shellSizeDelta: [],
160
162
  shellAmbiguities: model.shellAmbiguities || [],
161
163
  suppressedShellOverlaps: [],
@@ -234,7 +236,9 @@ function actionsFor(s) {
234
236
  if (a) acts.set(c.id, a);
235
237
  else report.counts.none++;
236
238
  }
237
- return acts;
239
+ const normalized = selectUniqueActionOwners(loadDoc(s.file).document, acts);
240
+ report.duplicateActionOwners.push(...normalized.removed.map((removal) => ({ state: s.stateId, ...removal })));
241
+ return normalized.actions;
238
242
  }
239
243
 
240
244
  function bindAction(el, action) {
@@ -410,6 +414,7 @@ for (const u of units) {
410
414
  const cfg = { unit: u.id, states: u.states.map((s) => s.stateId), tokens: model.tokens, contentFile: `${u.id}.json`, activeNav: activeNav?.text || null };
411
415
  document.body.insertAdjacentHTML('beforeend', `<script id="proto-state" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script src="proto.js"></script>`);
412
416
  if (review) injectReviewFraming(document, review);
417
+ removeRemoteHeadLinks(document);
413
418
  fs.writeFileSync(path.join(outDir, outputFileOfUnit(u)), '<!doctype html>\n' + document.documentElement.outerHTML);
414
419
  fs.writeFileSync(path.join(outDir, 'content', u.id + '.json'), JSON.stringify(content, null, 1));
415
420
  report.units.push({
@@ -612,6 +617,7 @@ for (const u of units) {
612
617
  const cfg = { unit: hslug, holding: true, holdingBands: bandDeclarations, tokens: model.tokens, contentFile: null, activeNav: navMatch?.text || null };
613
618
  document.body.insertAdjacentHTML('beforeend', `<script id="proto-state" type="application/json">${JSON.stringify(cfg)}</script><script src="proto.js"></script>`);
614
619
  if (review) injectReviewFraming(document, review);
620
+ removeRemoteHeadLinks(document);
615
621
  fs.writeFileSync(path.join(outDir, outputFile), '<!doctype html>\n' + document.documentElement.outerHTML);
616
622
  }
617
623
  console.error(`holding pages: ${usedHoldings.size}`);
@@ -623,5 +629,5 @@ fs.writeFileSync(path.join(outDir, 'index.html'), `<!doctype html><meta http-equ
623
629
  const manifestHoldings = review
624
630
  ? [...usedHoldings.entries()].map(([holding, title]) => [holding.replace(/\.html$/, ''), title])
625
631
  : [...usedHoldings.entries()];
626
- fs.writeFileSync(path.join(outDir, 'proto-manifest.json'), JSON.stringify({ units: report.units, nav: navRows.map((n) => ({ text: n.text, target: n.target, holding: n.holding, rowId: n.rowId })), holdings: manifestHoldings, counts: report.counts, unbound: report.unbound, invalidDropdowns: report.invalidDropdowns, ...(report.actionDemotions.length > 0 ? { actionDemotions: report.actionDemotions } : {}), shellSizeDelta: report.shellSizeDelta, shellAmbiguities: report.shellAmbiguities, suppressedShellOverlaps: report.suppressedShellOverlaps, skippedShellTransplants: report.skippedShellTransplants, replacedShellActions: report.replacedShellActions, shellActionRemaps: report.shellActionRemaps, scriptDirections: states.map(({ stateId, scriptDirection, writingMode }) => ({ stateId, scriptDirection, writingMode })), canonicalShell: model.canonicalShell, canonicalShellPage: report.canonicalShellPage, shellConsistency: report.shellConsistency, ...(review ? { review } : {}) }, null, 1));
632
+ fs.writeFileSync(path.join(outDir, 'proto-manifest.json'), JSON.stringify({ units: report.units, nav: navRows.map((n) => ({ text: n.text, target: n.target, holding: n.holding, rowId: n.rowId })), holdings: manifestHoldings, counts: report.counts, unbound: report.unbound, invalidDropdowns: report.invalidDropdowns, ...(report.actionDemotions.length > 0 ? { actionDemotions: report.actionDemotions } : {}), ...(report.duplicateActionOwners.length > 0 ? { duplicateActionOwners: report.duplicateActionOwners } : {}), shellSizeDelta: report.shellSizeDelta, shellAmbiguities: report.shellAmbiguities, suppressedShellOverlaps: report.suppressedShellOverlaps, skippedShellTransplants: report.skippedShellTransplants, replacedShellActions: report.replacedShellActions, shellActionRemaps: report.shellActionRemaps, scriptDirections: states.map(({ stateId, scriptDirection, writingMode }) => ({ stateId, scriptDirection, writingMode })), canonicalShell: model.canonicalShell, canonicalShellPage: report.canonicalShellPage, shellConsistency: report.shellConsistency, ...(review ? { review } : {}) }, null, 1));
627
633
  console.error('counts', JSON.stringify(report.counts), 'unbound', report.unbound.length);
@@ -8,3 +8,29 @@ export function restoreActionPointerReachability(element) {
8
8
  element.setAttribute('data-proto-pointer-restored', 'true');
9
9
  return true;
10
10
  }
11
+
12
+ /**
13
+ * Give one semantic action to one rendered owner. A label response can name
14
+ * both a layout wrapper and one or more controls inside it. When descendants
15
+ * carry the same action kind, only the terminal-most elements are real action
16
+ * targets; retaining the wrapper adds a duplicate listener and an impossible
17
+ * hit-test identity. Different nested action kinds remain independent.
18
+ */
19
+ export function selectUniqueActionOwners(document, actions) {
20
+ const selected = new Map(actions);
21
+ const removed = [];
22
+ for (const [wrapperId, wrapperAction] of actions) {
23
+ const wrapper = document.getElementById(wrapperId);
24
+ if (!wrapper) continue;
25
+ const descendants = [...wrapper.querySelectorAll('[id]')].filter((candidate) => (
26
+ actions.get(candidate.id)?.action === wrapperAction.action
27
+ ));
28
+ if (descendants.length === 0) continue;
29
+ const ownerIds = descendants.filter((candidate) => ![...candidate.querySelectorAll('[id]')].some((nested) => (
30
+ actions.get(nested.id)?.action === wrapperAction.action
31
+ ))).map((candidate) => candidate.id);
32
+ selected.delete(wrapperId);
33
+ removed.push({ wrapperId, ownerIds, action: wrapperAction.action });
34
+ }
35
+ return { actions: selected, removed };
36
+ }
@@ -0,0 +1,16 @@
1
+ const REMOTE_HREF = /^(?:https?:)?\/\//iu;
2
+
3
+ /**
4
+ * Prototype files are owner-review artifacts that must open directly from
5
+ * disk. Remote head links turn an otherwise self-contained page into a noisy
6
+ * runtime failure when the review browser correctly blocks the network.
7
+ */
8
+ export function removeRemoteHeadLinks(document) {
9
+ let removed = 0;
10
+ for (const link of document.querySelectorAll('head link[href]')) {
11
+ if (!REMOTE_HREF.test(link.getAttribute('href')?.trim() ?? '')) continue;
12
+ link.remove();
13
+ removed += 1;
14
+ }
15
+ return removed;
16
+ }
@@ -1,6 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
+ import {
5
+ compareCanonicalShellSamples,
6
+ SHELL_RUNTIME_GEOMETRY_EPSILON_PX,
7
+ } from './shell-runtime-equivalence.mjs';
8
+
4
9
  function errorText(error) {
5
10
  return error instanceof Error ? error.message : String(error);
6
11
  }
@@ -21,7 +26,23 @@ function configuredWidths(protoDir, file) {
21
26
  async function smokeInteraction(page, selector, assertion) {
22
27
  const locator = page.locator(selector).first();
23
28
  if (!await locator.count()) return { present: false, ok: true };
29
+ let reviewClearanceAdded = false;
24
30
  try {
31
+ reviewClearanceAdded = await locator.evaluate((element) => {
32
+ const framing = document.getElementById('proto-review-framing');
33
+ if (!framing) return false;
34
+ const box = framing.getBoundingClientRect();
35
+ const target = element.getBoundingClientRect();
36
+ const overlaps = Math.min(box.right, target.right) - Math.max(box.left, target.left) > 0
37
+ && Math.min(box.bottom, target.bottom) - Math.max(box.top, target.top) > 0;
38
+ if (!overlaps) return false;
39
+ const clearance = document.createElement('div');
40
+ clearance.setAttribute('data-proto-runtime-clearance', 'true');
41
+ clearance.setAttribute('aria-hidden', 'true');
42
+ clearance.style.cssText = `display:block;position:relative;flex:none;width:1px;height:${box.height + 36}px;pointer-events:none`;
43
+ document.body.appendChild(clearance);
44
+ return true;
45
+ });
25
46
  await locator.scrollIntoViewIfNeeded();
26
47
  // Playwright considers an element "in view" even when a fixed review
27
48
  // panel covers every point in its box. Center it before hit-testing so
@@ -55,6 +76,10 @@ async function smokeInteraction(page, selector, assertion) {
55
76
  return { present: true, ok, point, opened };
56
77
  } catch (error) {
57
78
  return { present: true, ok: false, error: errorText(error) };
79
+ } finally {
80
+ if (reviewClearanceAdded) {
81
+ await page.locator('[data-proto-runtime-clearance="true"]').evaluateAll((nodes) => nodes.forEach((node) => node.remove())).catch(() => null);
82
+ }
58
83
  }
59
84
  }
60
85
 
@@ -286,7 +311,12 @@ export async function checkRuntime({ browser, baseUrl, relativeProtoPath, protoD
286
311
  const host = screen?.querySelector('.ly-anchor-canvas') || screen;
287
312
  const hostZoom = host ? parseFloat(getComputedStyle(host).zoom) || 1 : 1;
288
313
  const round = (value) => Math.round(value * 10) / 10;
289
- const visualSignature = async (shell, declaration) => {
314
+ const digestJson = async (value) => {
315
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
316
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
317
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
318
+ };
319
+ const visualSample = async (shell, declaration) => {
290
320
  const rootBox = shell.getBoundingClientRect();
291
321
  const markerIds = new Set(declaration.activeMarkerIds || []);
292
322
  const visualNodes = [shell, ...shell.querySelectorAll('*')]
@@ -306,10 +336,10 @@ export async function checkRuntime({ browser, baseUrl, relativeProtoPath, protoD
306
336
  label: node.getAttribute('alt') || node.getAttribute('aria-label') || '',
307
337
  source: node.getAttribute('src') || '',
308
338
  box: [
309
- round((box.left - rootBox.left) / hostZoom),
310
- round((box.top - rootBox.top) / hostZoom),
311
- round(box.width / hostZoom),
312
- round(box.height / hostZoom),
339
+ (box.left - rootBox.left) / hostZoom,
340
+ (box.top - rootBox.top) / hostZoom,
341
+ box.width / hostZoom,
342
+ box.height / hostZoom,
313
343
  ],
314
344
  style: [
315
345
  style.display, node === shell ? 'shell-root' : style.position,
@@ -319,9 +349,13 @@ export async function checkRuntime({ browser, baseUrl, relativeProtoPath, protoD
319
349
  ],
320
350
  };
321
351
  }).filter((node) => node.box[2] > 0 && node.box[3] > 0);
322
- const bytes = new TextEncoder().encode(JSON.stringify({ region: declaration.region, visualNodes }));
323
- const digest = await crypto.subtle.digest('SHA-256', bytes);
324
- return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
352
+ const appearanceNodes = visualNodes.map(({ box, ...appearance }) => appearance);
353
+ const diagnosticNodes = visualNodes.map((node) => ({ ...node, box: node.box.map(round) }));
354
+ return {
355
+ appearanceSignature: await digestJson({ region: declaration.region, visualNodes: appearanceNodes }),
356
+ diagnosticSignature: await digestJson({ region: declaration.region, visualNodes: diagnosticNodes }),
357
+ geometry: visualNodes.map((node) => node.box),
358
+ };
325
359
  };
326
360
  for (const declaration of declared.regions || []) {
327
361
  const shell = document.getElementById(declaration.shellId);
@@ -329,15 +363,16 @@ export async function checkRuntime({ browser, baseUrl, relativeProtoPath, protoD
329
363
  failures.push({ kind: 'shell-consistency', region: declaration.region, reason: 'missing-declared-shell' });
330
364
  continue;
331
365
  }
332
- const actualSignature = await visualSignature(shell, declaration);
366
+ const sample = await visualSample(shell, declaration);
333
367
  const shellBox = shell.getBoundingClientRect();
334
368
  const hostBox = host?.getBoundingClientRect() || { left: 0, top: 0 };
335
- const actualGeometry = [
336
- round((shellBox.left - hostBox.left) / hostZoom),
337
- round((shellBox.top - hostBox.top) / hostZoom),
338
- round(shellBox.width / hostZoom),
339
- round(shellBox.height / hostZoom),
369
+ const comparisonGeometry = [
370
+ (shellBox.left - hostBox.left) / hostZoom,
371
+ (shellBox.top - hostBox.top) / hostZoom,
372
+ shellBox.width / hostZoom,
373
+ shellBox.height / hostZoom,
340
374
  ];
375
+ const actualGeometry = comparisonGeometry.map(round);
341
376
  let activeMatches = true;
342
377
  if (declaration.active?.status === 'linked') {
343
378
  const marker = document.getElementById(declaration.active.markerId);
@@ -364,8 +399,11 @@ export async function checkRuntime({ browser, baseUrl, relativeProtoPath, protoD
364
399
  regions.push({
365
400
  region: declaration.region,
366
401
  shellId: declaration.shellId,
367
- actualSignature,
402
+ actualSignature: sample.diagnosticSignature,
368
403
  actualGeometry,
404
+ appearanceSignature: sample.appearanceSignature,
405
+ visualGeometry: sample.geometry,
406
+ comparisonGeometry,
369
407
  inventorySampleSignature: declaration.canonicalSignature,
370
408
  consistencyStatus: declaration.consistencyStatus || 'canonical',
371
409
  activeStatus: declaration.active?.status || 'silent',
@@ -427,23 +465,48 @@ export async function checkRuntime({ browser, baseUrl, relativeProtoPath, protoD
427
465
  .map((failure) => ({ file: page.file, width: viewport.width, ...failure })) || []
428
466
  )));
429
467
  const shellRuntimeGroups = new Map();
468
+ const shellConsistencyTolerances = [];
430
469
  for (const page of pages) for (const viewport of page.viewports) {
431
470
  if (!unitByFile.has(page.file)) continue;
432
471
  for (const region of viewport.shellState?.regions || []) {
433
472
  if (region.consistencyStatus !== 'canonical') continue;
434
473
  const key = `${viewport.width}:${viewport.shellState.activeBand}:${region.region}`;
435
474
  if (!shellRuntimeGroups.has(key)) shellRuntimeGroups.set(key, []);
436
- shellRuntimeGroups.get(key).push({ file: page.file, signature: region.actualSignature, geometry: region.actualGeometry });
475
+ shellRuntimeGroups.get(key).push({
476
+ file: page.file,
477
+ signature: region.actualSignature,
478
+ geometry: region.actualGeometry,
479
+ appearanceSignature: region.appearanceSignature,
480
+ visualGeometry: region.visualGeometry,
481
+ shellGeometry: region.comparisonGeometry,
482
+ });
437
483
  }
438
484
  }
439
485
  for (const [key, entries] of shellRuntimeGroups) {
440
- const fingerprints = new Set(entries.map((entry) => JSON.stringify([entry.signature, entry.geometry])));
441
- if (fingerprints.size > 1) shellConsistencyFailures.push({
486
+ const reference = entries[0];
487
+ const comparisons = entries.slice(1).map((entry) => compareCanonicalShellSamples(reference, entry));
488
+ const equivalent = comparisons.every((comparison) => comparison.equivalent);
489
+ const pagesInGroup = entries.map(({ file, signature, geometry }) => ({ file, signature, geometry }));
490
+ if (!equivalent) shellConsistencyFailures.push({
442
491
  kind: 'shell-consistency',
443
492
  reason: 'prototype-shell-signature-mismatch',
444
493
  group: key,
445
- pages: entries,
494
+ pages: pagesInGroup,
446
495
  });
496
+ else {
497
+ const diagnosticFingerprints = new Set(entries.map((entry) => JSON.stringify([entry.signature, entry.geometry])));
498
+ if (diagnosticFingerprints.size > 1) shellConsistencyTolerances.push({
499
+ group: key,
500
+ epsilonPx: SHELL_RUNTIME_GEOMETRY_EPSILON_PX,
501
+ maxGeometryDelta: Math.max(0, ...comparisons.map((comparison) => comparison.maxGeometryDelta || 0)),
502
+ pages: pagesInGroup,
503
+ });
504
+ }
505
+ }
506
+ for (const page of pages) for (const viewport of page.viewports) for (const region of viewport.shellState?.regions || []) {
507
+ delete region.appearanceSignature;
508
+ delete region.visualGeometry;
509
+ delete region.comparisonGeometry;
447
510
  }
448
511
  const activeNavigationFailures = pages.flatMap((page) => page.viewports.flatMap((viewport) => (
449
512
  viewport.shellState?.failures?.filter((failure) => failure.kind === 'active-navigation')
@@ -458,6 +521,7 @@ export async function checkRuntime({ browser, baseUrl, relativeProtoPath, protoD
458
521
  interactionFailures: pages.filter((page) => page.dropdown?.ok === false || page.modal?.ok === false).map((page) => page.file),
459
522
  compositionFailures,
460
523
  shellConsistencyFailures,
524
+ shellConsistencyTolerances,
461
525
  activeNavigationFailures,
462
526
  deepLinkBack,
463
527
  };
@@ -0,0 +1,41 @@
1
+ export const SHELL_RUNTIME_GEOMETRY_EPSILON_PX = 0.5;
2
+
3
+ function geometryDelta(reference, candidate) {
4
+ if (!Array.isArray(reference) || !Array.isArray(candidate) || reference.length !== candidate.length) {
5
+ return Number.POSITIVE_INFINITY;
6
+ }
7
+ let maximum = 0;
8
+ for (let index = 0; index < reference.length; index++) {
9
+ const left = reference[index];
10
+ const right = candidate[index];
11
+ if (Array.isArray(left) || Array.isArray(right)) {
12
+ const nested = geometryDelta(left, right);
13
+ if (!Number.isFinite(nested)) return nested;
14
+ maximum = Math.max(maximum, nested);
15
+ continue;
16
+ }
17
+ if (!Number.isFinite(left) || !Number.isFinite(right)) return Number.POSITIVE_INFINITY;
18
+ maximum = Math.max(maximum, Math.abs(left - right));
19
+ }
20
+ return maximum;
21
+ }
22
+
23
+ /**
24
+ * Canonical shells must retain identical visible structure and styles. Browser
25
+ * layout geometry may differ by at most half a CSS pixel per node; that absorbs
26
+ * subpixel glyph measurement noise while remaining far below displaced-shell
27
+ * defects.
28
+ */
29
+ export function compareCanonicalShellSamples(reference, candidate, epsilon = SHELL_RUNTIME_GEOMETRY_EPSILON_PX) {
30
+ if (reference.appearanceSignature !== candidate.appearanceSignature) {
31
+ return { equivalent: false, reason: 'appearance', maxGeometryDelta: null };
32
+ }
33
+ const visualDelta = geometryDelta(reference.visualGeometry, candidate.visualGeometry);
34
+ const shellDelta = geometryDelta(reference.shellGeometry, candidate.shellGeometry);
35
+ const maxGeometryDelta = Math.max(visualDelta, shellDelta);
36
+ return {
37
+ equivalent: maxGeometryDelta <= epsilon,
38
+ reason: maxGeometryDelta <= epsilon ? 'equivalent' : 'geometry',
39
+ maxGeometryDelta,
40
+ };
41
+ }
@@ -1,12 +1,17 @@
1
- // Structure the harm guard protects, computed from two measurements of the same page (before / after a rule set):
1
+ // Structure and interaction state the harm guard protects, computed from two measurements of the same page
2
+ // (before / after a rule set):
2
3
  // misaligned — a same-kind sibling pair whose left (top) edges were exactly aligned before and are not after
3
4
  // (an icon nudged out of its column, a nav label pulled off its list edge);
4
5
  // spacing — a consecutive sibling gap that changed by more than GAP_TOL px, unless one side was itself cut or
5
6
  // escaped before (a stranded footer coming home is allowed to change its gap).
7
+ // containment — an element newly leaves (or moves farther beyond) its direct parent;
8
+ // actions — a semantic action that was pointer-reachable before is no longer reachable after.
6
9
  // Owner rule (2026-08-22): a repair that moves an icon out of its column or changes sibling spacing is harm.
10
+ // Owner rule (2026-08-25): no moving rule is guard-exempt, and reachable actions may never be traded away.
7
11
  export const GAP_TOL = 6;
12
+ export const CONTAINMENT_TOL = 0.5;
8
13
 
9
- export function structureHarm(before, after, exempt = new Set(), exemptGaps = new Set()) {
14
+ export function structureHarm(before, after, declaredGaps = new Set()) {
10
15
  const colKey = (c) => `${c.axis}|${c.a}|${c.b}`;
11
16
  const beforeCols = new Set((before.columns || []).map(colKey));
12
17
  const afterCols = new Set((after.columns || []).map(colKey));
@@ -16,8 +21,25 @@ export function structureHarm(before, after, exempt = new Set(), exemptGaps = ne
16
21
  const spacing = (after.gaps || []).filter((g) => {
17
22
  const b = beforeGaps.get(`${g.axis}|${g.a}|${g.b}`);
18
23
  // a horizontal gap inside a fitted shell child is the fit's own declared change (name … chevron), not harm
19
- const declaredGap = exemptGaps.has(`${g.a}|${g.b}`) || exemptGaps.has(`${g.b}|${g.a}`);
20
- return b != null && Math.abs(g.gap - b) > GAP_TOL && !broken.has(g.a) && !broken.has(g.b) && !g.fit && !declaredGap && !(g.axis === 'h' && (exempt.has(g.a) || exempt.has(g.b)));
24
+ const declaredGap = declaredGaps.has(`${g.a}|${g.b}`) || declaredGaps.has(`${g.b}|${g.a}`);
25
+ return b != null && Math.abs(g.gap - b) > GAP_TOL && !broken.has(g.a) && !broken.has(g.b) && !g.fit && !declaredGap;
21
26
  }).map((g) => ({ ...g, before: beforeGaps.get(`${g.axis}|${g.a}|${g.b}`) }));
22
- return { misaligned, spacing };
27
+ const beforeContainment = new Map((before.containment || []).map((finding) => [`${finding.parent}|${finding.id}`, finding]));
28
+ const containment = (after.containment || []).filter((finding) => {
29
+ const baseline = beforeContainment.get(`${finding.parent}|${finding.id}`);
30
+ return Object.entries(finding.sides || {}).some(([side, px]) => px > (baseline?.sides?.[side] || 0) + CONTAINMENT_TOL);
31
+ }).map((finding) => ({
32
+ ...finding,
33
+ before: beforeContainment.get(`${finding.parent}|${finding.id}`)?.sides || {},
34
+ }));
35
+ const afterActions = new Map((after.actions || []).map((action) => [action.key, action]));
36
+ const unreachableActions = (before.actions || []).filter((action) => (
37
+ action.reachable && afterActions.get(action.key)?.reachable !== true
38
+ )).map((action) => ({
39
+ key: action.key,
40
+ id: action.id,
41
+ kind: action.kind,
42
+ after: afterActions.get(action.key) || null,
43
+ }));
44
+ return { misaligned, spacing, containment, unreachableActions };
23
45
  }
@@ -1,5 +1,6 @@
1
- // Polish metrics: deterministic, geometry-only, computed in the browser on a rendered page.
2
- // One page → { overflow, overlap, alignment, whitespace, missingText, shift } with per-finding detail.
1
+ // Polish metrics: deterministic geometry + hit-test evidence, computed in the browser on a rendered page.
2
+ // One page → { overflow, containment, actions, overlap, alignment, whitespace, missingText, shift }
3
+ // with per-finding detail.
3
4
  // Used before AND after the polish stage; the numbers are instruments, the findings are the evidence.
4
5
 
5
6
  export const NEAR = 6; // px: an edge this close (but not equal) to another is a near-miss
@@ -37,6 +38,18 @@ export function pageMetricsScript() {
37
38
  const inShell = (el) => !!el.closest('[data-proto-shell], aside, header, footer, [id*="sidebar"], [id*="-nav"]');
38
39
  const scope = sections.length ? sections : [section];
39
40
  const qsa = (sel) => scope.flatMap((s) => [...s.querySelectorAll(sel)]);
41
+ const domKey = (el) => {
42
+ if (el.id) return el.id;
43
+ const parts = [];
44
+ let node = el;
45
+ while (node?.parentElement) {
46
+ const parent = node.parentElement;
47
+ parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${[...parent.children].indexOf(node) + 1})`);
48
+ if (parent.id) return `${parent.id}>${parts.join('>')}`;
49
+ node = parent;
50
+ }
51
+ return parts.join('>');
52
+ };
40
53
  const leaves = qsa('.text, .paragraph, h1, h2, h3, h4, h5, h6, .icon, img, .image, .badge, .divider, button, .button')
41
54
  .filter((el) => visible(el) && !el.closest('#proto-overlay-root') && !el.querySelector('.text, .paragraph') && !(el.matches('button, .button') && el.closest('.proto-notice, .proto-holding-block')));
42
55
  const containers = qsa('div[id], button[id], aside[id], header[id], footer[id], nav[id], article[id], form[id], main[id]')
@@ -56,7 +69,7 @@ export function pageMetricsScript() {
56
69
  }
57
70
  return { r: 255, g: 255, b: 255, a: 1 };
58
71
  };
59
- const out = { overflow: [], overlap: [], alignment: [], whitespace: [], missingText: [], shift: [], counts: {} };
72
+ const out = { overflow: [], containment: [], actions: [], overlap: [], alignment: [], whitespace: [], missingText: [], shift: [], counts: {} };
60
73
 
61
74
  // ---- overflow / clipping / escape
62
75
  for (const el of leaves) {
@@ -88,6 +101,29 @@ export function pageMetricsScript() {
88
101
  if (cut > 1.5) out.overflow.push({ kind: 'cut', px: +cut.toFixed(1), ...box });
89
102
  }
90
103
 
104
+ // ---- direct parent containment for every positioned block/leaf. Text overflow alone cannot see a full-width
105
+ // card moved beyond its owning stack. Record existing overhangs as baseline evidence; the harm guard rejects only
106
+ // newly introduced or enlarged overhangs, so intentional source geometry remains untouched.
107
+ for (const el of qsa('[id]')) {
108
+ const parent = el.parentElement;
109
+ if (!parent || !visible(el) || !visible(parent) || parent.closest('#proto-overlay-root')) continue;
110
+ const r = el.getBoundingClientRect(); const p = parent.getBoundingClientRect();
111
+ const sides = {
112
+ left: Math.max(0, p.left - r.left),
113
+ top: Math.max(0, p.top - r.top),
114
+ right: Math.max(0, r.right - p.right),
115
+ bottom: Math.max(0, r.bottom - p.bottom),
116
+ };
117
+ const px = Math.max(sides.left, sides.top, sides.right, sides.bottom);
118
+ if (px > 1.5) out.containment.push({
119
+ id: el.id,
120
+ parent: domKey(parent),
121
+ parentId: parent.id || null,
122
+ px: +px.toFixed(2),
123
+ sides: Object.fromEntries(Object.entries(sides).filter(([, value]) => value > 1.5).map(([side, value]) => [side, +value.toFixed(2)])),
124
+ });
125
+ }
126
+
91
127
  // ---- overlap between unrelated leaves
92
128
  const L = leaves.map((el) => ({ el, r: rel(isText(el) ? tightRect(el) : el.getBoundingClientRect(), el) }));
93
129
  for (let i = 0; i < L.length; i++) for (let j = i + 1; j < L.length; j++) {
@@ -299,10 +335,70 @@ export function pageMetricsScript() {
299
335
  if (contrast < 1.25 && fg.a > 0.05) out.missingText.push({ id, kind: 'invisible', contrast: +contrast.toFixed(2), src: el.textContent.trim().slice(0, 60) });
300
336
  }
301
337
 
302
- window.scrollTo(0, 0); // effectiveBg scrolled probes into view; leave the page as found
338
+ // ---- semantic action reachability. CSS-only polish must not change which declared controls a real pointer can
339
+ // reach. Probe every action/field at its own scrolled-into-view action points, not merely the first dropdown or
340
+ // modal that the runtime smoke test happens to exercise.
341
+ {
342
+ const DECLARED_ACTION = '[data-proto-action], [data-proto-field], [data-proto-submit-for]';
343
+ const NATIVE_ACTION = 'a[href], button, input:not([type="hidden"]), select, textarea, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])';
344
+ const ACTION = `${DECLARED_ACTION}, ${NATIVE_ACTION}`;
345
+ const targets = qsa(ACTION).filter((el) => (
346
+ visible(el)
347
+ && !el.closest('#proto-overlay-root')
348
+ && !el.hasAttribute('disabled')
349
+ && el.getAttribute('aria-disabled') !== 'true'
350
+ && !el.closest('[inert]')
351
+ && !el.hasAttribute('data-proto-suppressed')
352
+ // A plain native child is the pointer surface of its declared wrapper, not a competing action. A nested
353
+ // DECLARED action remains distinct and correctly owns its own hit points.
354
+ && (el.matches(DECLARED_ACTION) || !el.closest(DECLARED_ACTION))
355
+ ));
356
+ for (let index = 0; index < targets.length; index++) {
357
+ const el = targets[index];
358
+ const kind = el.getAttribute('data-proto-action')
359
+ || (el.hasAttribute('data-proto-field') ? 'field' : null)
360
+ || (el.hasAttribute('data-proto-submit-for') ? 'submit' : null)
361
+ || el.getAttribute('role')
362
+ || el.tagName.toLowerCase();
363
+ el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
364
+ const rects = [...el.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
365
+ let point = null; let receiver = null;
366
+ for (const rect of rects) {
367
+ const insetX = Math.min(8, rect.width / 4), insetY = Math.min(8, rect.height / 4);
368
+ const points = [
369
+ [rect.left + rect.width / 2, rect.top + rect.height / 2],
370
+ [rect.left + insetX, rect.top + insetY],
371
+ [rect.right - insetX, rect.top + insetY],
372
+ [rect.left + insetX, rect.bottom - insetY],
373
+ [rect.right - insetX, rect.bottom - insetY],
374
+ ];
375
+ for (const [x, y] of points) {
376
+ if (x < 0 || y < 0 || x >= window.innerWidth || y >= window.innerHeight) continue;
377
+ const hit = document.elementFromPoint(x, y);
378
+ const owner = hit?.closest(DECLARED_ACTION) || hit?.closest(NATIVE_ACTION);
379
+ if (owner === el) { point = { x: +x.toFixed(2), y: +y.toFixed(2) }; receiver = hit; break; }
380
+ if (!receiver && hit) receiver = hit;
381
+ }
382
+ if (point) break;
383
+ }
384
+ out.actions.push({
385
+ key: el.id || `${kind}:${index}`,
386
+ id: el.id || null,
387
+ kind,
388
+ reachable: Boolean(point),
389
+ ...(point ? { point } : {}),
390
+ ...(!point && receiver ? { interceptedBy: receiver.id || receiver.tagName.toLowerCase() } : {}),
391
+ });
392
+ }
393
+ }
394
+
395
+ window.scrollTo(0, 0); // effectiveBg and action probes scroll; leave the page as found
303
396
  out.counts = {
304
397
  leaves: leaves.length, textLeaves: leaves.filter(isText).length, containers: containers.length,
305
- overflow: out.overflow.length, overlap: out.overlap.length, alignment: out.alignment.length, whitespace: out.whitespace.length,
398
+ overflow: out.overflow.length, containment: out.containment.length,
399
+ actions: out.actions.length, reachableActions: out.actions.filter((action) => action.reachable).length,
400
+ unreachableActions: out.actions.filter((action) => !action.reachable).length,
401
+ overlap: out.overlap.length, alignment: out.alignment.length, whitespace: out.whitespace.length,
306
402
  columns: out.columns.length, gaps: out.gaps.length, seams: out.seams.length, narrowSeams: out.seams.filter((x) => x.gap < 48).length, documentHeight: document.documentElement.scrollHeight, offcenter: out.offcenter.length, rail: out.rail.length, missingText: out.missingText.length, shifted: out.shift.length, maxShift: out.shift.reduce((m, s) => Math.max(m, s.d), 0),
307
403
  sectionW: sb.width, sectionH: sb.height,
308
404
  };
@@ -1,7 +1,9 @@
1
1
  // Override model shared by every polish rule and the driver.
2
- // An override is { id | sel, rule, reason, dx?, dy?, w?, h?, css?: {prop: value} }.
2
+ // An override is { id | sel, rule, reason, dx?, dy?, motion?, w?, h?, css?: {prop: value} }.
3
3
  // id → targets #id; sel → targets an arbitrary selector (holding pages have class-only nodes)
4
4
  // dx/dy → translate (does not disturb siblings; sums across rules)
5
+ // motion → `inset` means dx/dy describe the cap/evidence only; css left/top performs the nudge without a
6
+ // stacking-context-producing transform
5
7
  // w/h → width/height in px (last rule wins)
6
8
  // css → any other declarations (last rule wins per property)
7
9
 
@@ -11,8 +13,10 @@ export function mergeOverrides(list) {
11
13
  if (!o || !(o.id || o.sel)) continue;
12
14
  const key = o.sel || o.id;
13
15
  const cur = byId.get(key) || { id: o.id, sel: o.sel, dx: 0, dy: 0, css: {}, rules: [] };
14
- cur.dx += o.dx || 0;
15
- cur.dy += o.dy || 0;
16
+ if (o.motion !== 'inset') {
17
+ cur.dx += o.dx || 0;
18
+ cur.dy += o.dy || 0;
19
+ }
16
20
  if (o.w != null) cur.w = o.w;
17
21
  if (o.h != null) cur.h = o.h;
18
22
  Object.assign(cur.css, o.css || {});
@@ -40,6 +44,31 @@ export function overridesToCss(merged) {
40
44
  return lines.join('\n') + '\n';
41
45
  }
42
46
 
47
+ // Canonical shell regions are a cross-page contract. A page-local rule cannot safely mutate a shell descendant, the
48
+ // shell root, or one of its ancestors: any of those changes can make one page's shell signature diverge from peers.
49
+ // Partition in the browser because selectors and ancestry are facts of the rendered page.
50
+ export function partitionProtectedShellOverridesScript() {
51
+ return ({ overrides, shellIds }) => {
52
+ const shells = (shellIds || []).map((id) => document.getElementById(id)).filter(Boolean);
53
+ if (!shells.length) return { allowed: overrides, protected: [] };
54
+ const allowed = []; const protectedOverrides = [];
55
+ for (const override of overrides) {
56
+ let targets = [];
57
+ try {
58
+ targets = override.id ? [document.getElementById(override.id)].filter(Boolean) : [...document.querySelectorAll(override.sel)];
59
+ } catch {
60
+ protectedOverrides.push(override);
61
+ continue;
62
+ }
63
+ const touchesShell = targets.some((target) => shells.some((shell) => (
64
+ target === shell || target.contains(shell) || shell.contains(target)
65
+ )));
66
+ (touchesShell ? protectedOverrides : allowed).push(override);
67
+ }
68
+ return { allowed, protected: protectedOverrides };
69
+ };
70
+ }
71
+
43
72
  function round(n) { return Math.round(n * 100) / 100; }
44
73
 
45
74
  // In-browser: (re)place a <style id="proto-polish"> with the given css text.