@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.
- package/SKILL.md +1 -1
- package/dist/cli-draft-command.d.ts +11 -5
- package/dist/cli-draft-command.d.ts.map +1 -1
- package/dist/cli-draft-command.js +37 -35
- package/dist/cli-draft-command.js.map +1 -1
- package/dist/cli-help/design.d.ts +10 -0
- package/dist/cli-help/design.d.ts.map +1 -1
- package/dist/cli-help/design.js +29 -0
- package/dist/cli-help/design.js.map +1 -1
- package/dist/cli-help/index.d.ts +11 -0
- package/dist/cli-help/index.d.ts.map +1 -1
- package/dist/cli-help/index.js +18 -2
- package/dist/cli-help/index.js.map +1 -1
- package/dist/cli-next-command.d.ts.map +1 -1
- package/dist/cli-next-command.js +26 -14
- package/dist/cli-next-command.js.map +1 -1
- package/dist/cli-progress.d.ts +2 -0
- package/dist/cli-progress.d.ts.map +1 -1
- package/dist/cli-progress.js +2 -0
- package/dist/cli-progress.js.map +1 -1
- package/dist/cli-redesign-command.d.ts +42 -0
- package/dist/cli-redesign-command.d.ts.map +1 -0
- package/dist/cli-redesign-command.js +469 -0
- package/dist/cli-redesign-command.js.map +1 -0
- package/dist/cli-run.d.ts.map +1 -1
- package/dist/cli-run.js +8 -0
- package/dist/cli-run.js.map +1 -1
- package/dist/cli-usage.d.ts.map +1 -1
- package/dist/cli-usage.js +8 -1
- package/dist/cli-usage.js.map +1 -1
- package/dist/draft-dispatch-attempt.d.ts +0 -7
- package/dist/draft-dispatch-attempt.d.ts.map +1 -1
- package/dist/draft-dispatch-attempt.js +1 -25
- package/dist/draft-dispatch-attempt.js.map +1 -1
- package/dist/draft-identity.d.ts +2 -0
- package/dist/draft-identity.d.ts.map +1 -1
- package/dist/draft-identity.js +1 -0
- package/dist/draft-identity.js.map +1 -1
- package/dist/draft-run.d.ts +2 -0
- package/dist/draft-run.d.ts.map +1 -1
- package/dist/draft-run.js +4 -3
- package/dist/draft-run.js.map +1 -1
- package/dist/redesign-capture.d.ts +49 -0
- package/dist/redesign-capture.d.ts.map +1 -0
- package/dist/redesign-capture.js +135 -0
- package/dist/redesign-capture.js.map +1 -0
- package/dist/redesign-plan-client.d.ts +32 -0
- package/dist/redesign-plan-client.d.ts.map +1 -0
- package/dist/redesign-plan-client.js +80 -0
- package/dist/redesign-plan-client.js.map +1 -0
- package/dist/redesign-plan-identity.d.ts +8 -0
- package/dist/redesign-plan-identity.d.ts.map +1 -0
- package/dist/redesign-plan-identity.js +15 -0
- package/dist/redesign-plan-identity.js.map +1 -0
- package/dist/redesign-plan.d.ts +30 -0
- package/dist/redesign-plan.d.ts.map +1 -0
- package/dist/redesign-plan.js +99 -0
- package/dist/redesign-plan.js.map +1 -0
- package/dist/redesign-workspace.d.ts +116 -0
- package/dist/redesign-workspace.d.ts.map +1 -0
- package/dist/redesign-workspace.js +157 -0
- package/dist/redesign-workspace.js.map +1 -0
- package/dist/run-journal-commands.d.ts.map +1 -1
- package/dist/run-journal-commands.js +10 -0
- package/dist/run-journal-commands.js.map +1 -1
- package/open-design.json +2 -2
- package/package.json +2 -2
- package/prototype-kit/build.mjs +9 -3
- package/prototype-kit/lib/action-target.mjs +26 -0
- package/prototype-kit/lib/local-document.mjs +16 -0
- package/prototype-kit/lib/runtime-check.mjs +83 -19
- package/prototype-kit/lib/shell-runtime-equivalence.mjs +41 -0
- package/prototype-kit/polish/harm.mjs +27 -5
- package/prototype-kit/polish/measure.mjs +101 -5
- package/prototype-kit/polish/overrides.mjs +32 -3
- package/prototype-kit/polish/polish.mjs +23 -11
- package/prototype-kit/polish/rules/align.mjs +73 -3
- package/prototype-kit/polish/rules/left-rail.mjs +2 -3
- package/prototype-kit/polish/rules/section-breathing.mjs +0 -1
- package/prototype-kit/polish/tests/fixtures/align/expect.json +1 -1
- package/prototype-kit/polish/tests/fixtures/align/fire.html +4 -1
- package/prototype-kit/polish/tests/fixtures/align/silent.html +8 -0
- package/prototype-kit/polish/tests/fixtures/align/zoom-constrained.html +17 -0
- package/prototype-kit/polish/tests/fixtures/harm/left-rail-contained.html +16 -0
- package/prototype-kit/polish/tests/fixtures/harm/left-rail-overflow.html +16 -0
- package/prototype-kit/polish/tests/fixtures/harm/nested-actions.html +6 -0
- package/prototype-kit/polish/tests/fixtures/harm/unidentified-parent.html +3 -0
- package/prototype-kit/polish/tests/fixtures/shell-seam/fire.html +1 -1
- package/prototype-kit/polish/tests/harm-check.mjs +131 -2
- package/prototype-kit/polish/tests/rules-check.mjs +7 -2
- package/prototype-kit/runtime/proto.js +4 -1
- package/prototype-kit/tests/adversarial-runtime.mjs +3 -0
- package/prototype-kit/tests/align-rule-check.mjs +105 -0
- package/prototype-kit/tests/fixtures/interaction-runtime/review-bottom-action.html +8 -0
- package/prototype-kit/tests/offline-check.mjs +17 -1
- package/prototype-kit/tests/rules-check.mjs +67 -1
- 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
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
323
|
-
const
|
|
324
|
-
return
|
|
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
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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({
|
|
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
|
|
441
|
-
|
|
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:
|
|
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
|
|
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,
|
|
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 =
|
|
20
|
-
return b != null && Math.abs(g.gap - b) > GAP_TOL && !broken.has(g.a) && !broken.has(g.b) && !g.fit && !declaredGap
|
|
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
|
-
|
|
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
|
|
2
|
-
// One page → { overflow, overlap, alignment, whitespace, missingText, shift }
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
6
|
-
// harm guard (reject any rule that adds
|
|
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
|
|
77
|
-
const st = structureHarm(before, after,
|
|
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 =
|
|
97
|
-
row.rules[rule.name] = {
|
|
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
|
-
|
|
163
|
-
|
|
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) {
|