@12ui/design 0.2.48 → 0.2.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/SKILL.md +1 -1
  2. package/dist/cli-draft-command.d.ts +11 -5
  3. package/dist/cli-draft-command.d.ts.map +1 -1
  4. package/dist/cli-draft-command.js +37 -35
  5. package/dist/cli-draft-command.js.map +1 -1
  6. package/dist/cli-help/design.d.ts +10 -0
  7. package/dist/cli-help/design.d.ts.map +1 -1
  8. package/dist/cli-help/design.js +29 -0
  9. package/dist/cli-help/design.js.map +1 -1
  10. package/dist/cli-help/index.d.ts +11 -0
  11. package/dist/cli-help/index.d.ts.map +1 -1
  12. package/dist/cli-help/index.js +18 -2
  13. package/dist/cli-help/index.js.map +1 -1
  14. package/dist/cli-next-command.d.ts.map +1 -1
  15. package/dist/cli-next-command.js +26 -14
  16. package/dist/cli-next-command.js.map +1 -1
  17. package/dist/cli-progress.d.ts +2 -0
  18. package/dist/cli-progress.d.ts.map +1 -1
  19. package/dist/cli-progress.js +2 -0
  20. package/dist/cli-progress.js.map +1 -1
  21. package/dist/cli-redesign-command.d.ts +42 -0
  22. package/dist/cli-redesign-command.d.ts.map +1 -0
  23. package/dist/cli-redesign-command.js +469 -0
  24. package/dist/cli-redesign-command.js.map +1 -0
  25. package/dist/cli-run.d.ts.map +1 -1
  26. package/dist/cli-run.js +8 -0
  27. package/dist/cli-run.js.map +1 -1
  28. package/dist/cli-usage.d.ts.map +1 -1
  29. package/dist/cli-usage.js +8 -1
  30. package/dist/cli-usage.js.map +1 -1
  31. package/dist/draft-dispatch-attempt.d.ts +0 -7
  32. package/dist/draft-dispatch-attempt.d.ts.map +1 -1
  33. package/dist/draft-dispatch-attempt.js +1 -25
  34. package/dist/draft-dispatch-attempt.js.map +1 -1
  35. package/dist/draft-identity.d.ts +2 -0
  36. package/dist/draft-identity.d.ts.map +1 -1
  37. package/dist/draft-identity.js +1 -0
  38. package/dist/draft-identity.js.map +1 -1
  39. package/dist/draft-run.d.ts +2 -0
  40. package/dist/draft-run.d.ts.map +1 -1
  41. package/dist/draft-run.js +4 -3
  42. package/dist/draft-run.js.map +1 -1
  43. package/dist/redesign-capture.d.ts +49 -0
  44. package/dist/redesign-capture.d.ts.map +1 -0
  45. package/dist/redesign-capture.js +135 -0
  46. package/dist/redesign-capture.js.map +1 -0
  47. package/dist/redesign-plan-client.d.ts +32 -0
  48. package/dist/redesign-plan-client.d.ts.map +1 -0
  49. package/dist/redesign-plan-client.js +80 -0
  50. package/dist/redesign-plan-client.js.map +1 -0
  51. package/dist/redesign-plan-identity.d.ts +8 -0
  52. package/dist/redesign-plan-identity.d.ts.map +1 -0
  53. package/dist/redesign-plan-identity.js +15 -0
  54. package/dist/redesign-plan-identity.js.map +1 -0
  55. package/dist/redesign-plan.d.ts +30 -0
  56. package/dist/redesign-plan.d.ts.map +1 -0
  57. package/dist/redesign-plan.js +99 -0
  58. package/dist/redesign-plan.js.map +1 -0
  59. package/dist/redesign-workspace.d.ts +116 -0
  60. package/dist/redesign-workspace.d.ts.map +1 -0
  61. package/dist/redesign-workspace.js +157 -0
  62. package/dist/redesign-workspace.js.map +1 -0
  63. package/dist/run-journal-commands.d.ts.map +1 -1
  64. package/dist/run-journal-commands.js +10 -0
  65. package/dist/run-journal-commands.js.map +1 -1
  66. package/open-design.json +2 -2
  67. package/package.json +2 -2
  68. package/prototype-kit/build.mjs +9 -3
  69. package/prototype-kit/lib/action-target.mjs +26 -0
  70. package/prototype-kit/lib/local-document.mjs +16 -0
  71. package/prototype-kit/lib/runtime-check.mjs +83 -19
  72. package/prototype-kit/lib/shell-runtime-equivalence.mjs +41 -0
  73. package/prototype-kit/polish/harm.mjs +27 -5
  74. package/prototype-kit/polish/measure.mjs +101 -5
  75. package/prototype-kit/polish/overrides.mjs +32 -3
  76. package/prototype-kit/polish/polish.mjs +23 -11
  77. package/prototype-kit/polish/rules/align.mjs +73 -3
  78. package/prototype-kit/polish/rules/left-rail.mjs +2 -3
  79. package/prototype-kit/polish/rules/section-breathing.mjs +0 -1
  80. package/prototype-kit/polish/tests/fixtures/align/expect.json +1 -1
  81. package/prototype-kit/polish/tests/fixtures/align/fire.html +4 -1
  82. package/prototype-kit/polish/tests/fixtures/align/silent.html +8 -0
  83. package/prototype-kit/polish/tests/fixtures/align/zoom-constrained.html +17 -0
  84. package/prototype-kit/polish/tests/fixtures/harm/left-rail-contained.html +16 -0
  85. package/prototype-kit/polish/tests/fixtures/harm/left-rail-overflow.html +16 -0
  86. package/prototype-kit/polish/tests/fixtures/harm/nested-actions.html +6 -0
  87. package/prototype-kit/polish/tests/fixtures/harm/unidentified-parent.html +3 -0
  88. package/prototype-kit/polish/tests/fixtures/shell-seam/fire.html +1 -1
  89. package/prototype-kit/polish/tests/harm-check.mjs +131 -2
  90. package/prototype-kit/polish/tests/rules-check.mjs +7 -2
  91. package/prototype-kit/runtime/proto.js +4 -1
  92. package/prototype-kit/tests/adversarial-runtime.mjs +3 -0
  93. package/prototype-kit/tests/align-rule-check.mjs +105 -0
  94. package/prototype-kit/tests/fixtures/interaction-runtime/review-bottom-action.html +8 -0
  95. package/prototype-kit/tests/offline-check.mjs +17 -1
  96. package/prototype-kit/tests/rules-check.mjs +67 -1
  97. package/prototype-kit/tests/shell-stitching-runtime.mjs +15 -8
@@ -1,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.
@@ -2,8 +2,8 @@
2
2
  // Polish stage: deterministic visual repair of a built prototype, one page at a time (pages are independent → parallel).
3
3
  // Usage: node polish/polish.mjs <protoDir> --out <outDir> [--inventory inventory.json --model model.json] [--rules a,b] [--concurrency 4] [--report polish-report.json]
4
4
  //
5
- // For every real page: measure → run rules (in-browser, geometry-only) → apply overrides live → re-measure →
6
- // harm guard (reject any rule that adds overlap/clipping/cut or moves content beyond its declared cap) →
5
+ // For every real page: measure → run rules in-browser → apply overrides live → re-measure →
6
+ // harm guard (reject any rule that adds geometry/containment harm, loses a reachable action, or exceeds its cap) →
7
7
  // write polish/<unit>.css + <link> into the page copy. Nothing is redrawn; only per-id CSS overrides are emitted.
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
@@ -11,7 +11,7 @@ import { chromium } from '@playwright/test';
11
11
  import { startStaticServer } from '../lib/static-server.mjs';
12
12
  import { measurePage, sourceItemsFor, settleFonts } from './measure.mjs';
13
13
  import { loadRules } from './rules/index.mjs';
14
- import { applyOverridesScript, overridesToCss, mergeOverrides } from './overrides.mjs';
14
+ import { applyOverridesScript, overridesToCss, mergeOverrides, partitionProtectedShellOverridesScript } from './overrides.mjs';
15
15
  import { structureHarm } from './harm.mjs';
16
16
  import { probeSeams } from './ground-probe.mjs';
17
17
  import { pageRuleRecords, summarizePolishReport } from './report.mjs';
@@ -59,6 +59,9 @@ export async function polishPrototype({
59
59
  try {
60
60
  const sectionIds = model ? unit.states.map((s) => model.states.find((st) => st.stateId === s)?.sectionId).filter(Boolean) : [];
61
61
  const sourceItems = inventory && sectionIds.length ? sourceItemsFor(inventory, sectionIds) : null;
62
+ const protectedShellIds = [...new Set((unit.shellBands || []).flatMap((band) => (
63
+ (band.regions || []).filter((region) => (region.consistencyStatus || 'canonical') === 'canonical').map((region) => region.shellId)
64
+ )).filter(Boolean))];
62
65
  await page.goto(`${base}/${unit.id}.html`, { waitUntil: 'networkidle', timeout: 30_000 });
63
66
  await settleFonts(page);
64
67
  const before = await measurePage(page, { sourceItems });
@@ -69,15 +72,15 @@ export async function polishPrototype({
69
72
  const harmed = (after, ruleSet) => {
70
73
  const newOf = (kind) => (after.overflow.filter((o) => o.kind === kind).length) - (before.overflow.filter((o) => o.kind === kind).length);
71
74
  const cap = Math.max(0, ...ruleSet.map((p) => p.rule.maxShift || 0));
72
- // rules that declare block moves (left-rail) change the horizontal gaps beside the rows they move by design
73
- const exempt = new Set(ruleSet.filter((p) => p.rule.movesBlocks).flatMap((p) => p.overrides.map((o) => o.id)));
74
75
  // A spacing rule may change only the exact before->after gaps it declares in seam metadata. Every other
75
76
  // vertical sibling gap remains protected by the ordinary harm guard.
76
- const exemptGaps = new Set(ruleSet.flatMap((p) => p.overrides.map((o) => o.meta?.seam).filter(Boolean).flatMap((s) => (s.beforePeers || [s.before]).map((beforeId) => `${beforeId}|${s.after}`))));
77
- const st = structureHarm(before, after, exempt, exemptGaps);
77
+ const declaredGaps = new Set(ruleSet.filter((p) => p.rule.name === 'section-breathing').flatMap((p) => p.overrides.map((o) => o.meta?.seam).filter(Boolean).flatMap((s) => (s.beforePeers || [s.before]).map((beforeId) => `${beforeId}|${s.after}`))));
78
+ const st = structureHarm(before, after, declaredGaps);
78
79
  return {
79
80
  misaligned: st.misaligned.length > 0,
80
81
  spacing: st.spacing.length > 0,
82
+ containment: st.containment.length > 0,
83
+ 'reachable-actions': st.unreachableActions.length > 0,
81
84
  overlap: after.counts.overlap - before.counts.overlap > HARM.newOverlap,
82
85
  escape: newOf('escape') > HARM.newEscape,
83
86
  cut: newOf('cut') > HARM.newCut,
@@ -92,9 +95,17 @@ export async function polishPrototype({
92
95
  const proposals = [];
93
96
  for (const rule of ruleSet) {
94
97
  const proposed = await page.evaluate(rule.browserFn(), { metrics, params: rule.params || {} });
98
+ const partition = protectedShellIds.length
99
+ ? await page.evaluate(partitionProtectedShellOverridesScript(), { overrides: proposed, shellIds: protectedShellIds })
100
+ : { allowed: proposed, protected: [] };
95
101
  const cap = rule.maxShift ?? 0; // each override is held to ITS rule's declared cap
96
- const found = proposed.filter((o) => Math.abs(o.dx || 0) <= cap + 0.01 && Math.abs(o.dy || 0) <= cap + 0.01);
97
- row.rules[rule.name] = { proposed: proposed.length, overCap: proposed.length - found.length };
102
+ const found = partition.allowed.filter((o) => Math.abs(o.dx || 0) <= cap + 0.01 && Math.abs(o.dy || 0) <= cap + 0.01);
103
+ row.rules[rule.name] = {
104
+ proposed: proposed.length,
105
+ overCap: partition.allowed.length - found.length,
106
+ ...(partition.protected.length ? { protectedShell: partition.protected.length } : {}),
107
+ };
108
+ if (partition.protected.length) row.rejected.push({ rule: rule.name, harm: ['canonical-shell'] });
98
109
  if (rule.recordsCensus) {
99
110
  row.rules[rule.name].census = await page.evaluate(rule.browserFn(), {
100
111
  metrics,
@@ -159,8 +170,9 @@ export async function polishPrototype({
159
170
  row.ground = verdict;
160
171
  if (!verdict.ok) {
161
172
  row.rejected.push({ rule: rule.name, harm: verdict.failures.map((f) => 'ground-' + f.kind) });
162
- active = active.filter((a) => a !== p);
163
- result = active.length ? await evaluate(active) : null;
173
+ const final = await accept(active.filter((a) => a !== p));
174
+ active = final.active;
175
+ result = final.result;
164
176
  }
165
177
  }
166
178
  if (result) {