@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.
- package/SKILL.md +1 -1
- package/dist/branch-page-conversion.d.ts +4 -1
- package/dist/branch-page-conversion.d.ts.map +1 -1
- package/dist/branch-page-conversion.js +27 -17
- package/dist/branch-page-conversion.js.map +1 -1
- package/dist/branch-run-record.d.ts +2 -0
- package/dist/branch-run-record.d.ts.map +1 -1
- package/dist/branch-run-record.js.map +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 +520 -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/package-client.d.ts +11 -0
- package/dist/package-client.d.ts.map +1 -1
- package/dist/package-client.js.map +1 -1
- package/dist/package-submission.d.ts +12 -0
- package/dist/package-submission.d.ts.map +1 -1
- package/dist/package-submission.js +5 -0
- package/dist/package-submission.js.map +1 -1
- package/dist/redesign-brand-asset-inventory.d.ts +71 -0
- package/dist/redesign-brand-asset-inventory.d.ts.map +1 -0
- package/dist/redesign-brand-asset-inventory.js +318 -0
- package/dist/redesign-brand-asset-inventory.js.map +1 -0
- package/dist/redesign-capture.d.ts +57 -0
- package/dist/redesign-capture.d.ts.map +1 -0
- package/dist/redesign-capture.js +181 -0
- package/dist/redesign-capture.js.map +1 -0
- package/dist/redesign-pinned-asset.d.ts +20 -0
- package/dist/redesign-pinned-asset.d.ts.map +1 -0
- package/dist/redesign-pinned-asset.js +35 -0
- package/dist/redesign-pinned-asset.js.map +1 -0
- package/dist/redesign-plan-client.d.ts +49 -0
- package/dist/redesign-plan-client.d.ts.map +1 -0
- package/dist/redesign-plan-client.js +90 -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 +16 -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 +142 -0
- package/dist/redesign-workspace.d.ts.map +1 -0
- package/dist/redesign-workspace.js +82 -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
|
@@ -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) {
|
|
@@ -1,14 +1,46 @@
|
|
|
1
1
|
// align: snap near-miss edges between same-kind siblings (≤ NEAR px apart) onto the group's modal edge.
|
|
2
2
|
// Fires on: three cards whose tops are 0, 1.02 and 2.05 px → the two strays move to 0.
|
|
3
|
-
// Silent on: edges that are equal,
|
|
3
|
+
// Silent on: edges that are equal, further apart than NEAR (deliberate offsets), different kinds, or elements whose
|
|
4
|
+
// before geometry declares vertical centering inside their immediate parent / containing control.
|
|
4
5
|
export const name = 'align';
|
|
5
6
|
export const maxShift = 6;
|
|
6
7
|
|
|
7
8
|
export function browserFn() {
|
|
8
9
|
return ({ metrics }) => {
|
|
10
|
+
// Population evidence (75 pages, 158 accepted align rule-parts; owner 2026-08-26): among the 14 vertical moves
|
|
11
|
+
// measurable inside declared buttons/circles/badges/chips, the centered cluster's inset imbalance is 0–1.024 px
|
|
12
|
+
// and the next observed value is 2.048 px. Keep the threshold between those clusters.
|
|
13
|
+
const CENTERED_INSET_TOLERANCE = 1.1;
|
|
14
|
+
const CENTERED_CONTAINER_SELECTOR = 'button, .button, .badge, .chip, .circle, [role="button"], [data-proto-action]';
|
|
9
15
|
const groups = new Map(); // parent|axis → [{id, value}]
|
|
10
16
|
const axisOf = { left: 'x', right: 'r', top: 'y', bottom: 'b', center: 'cy' };
|
|
11
17
|
const rectOf = (el) => { const r = el.getBoundingClientRect(); const s = el.closest('section[id^="viewport-"]') || document.body; const b = s.getBoundingClientRect(); return { x: r.left - b.left, y: r.top - b.top, r: r.right - b.left, b: r.bottom - b.top, cy: r.top - b.top + r.height / 2 }; };
|
|
18
|
+
const verticalCenterContainer = (el) => {
|
|
19
|
+
const r = el.getBoundingClientRect();
|
|
20
|
+
const parent = el.parentElement;
|
|
21
|
+
if (!parent || !r.width || !r.height) return null;
|
|
22
|
+
const contains = (c) => c.left <= r.left + 0.25 && c.right >= r.right - 0.25
|
|
23
|
+
&& c.top <= r.top + 0.25 && c.bottom >= r.bottom - 0.25;
|
|
24
|
+
// The relationship needs a declared control/container fact. A generic flex parent or painted surface is not
|
|
25
|
+
// enough: both occur around legitimate edge rows in the population and would turn this guard into suppression.
|
|
26
|
+
const ancestor = parent.closest(CENTERED_CONTAINER_SELECTOR);
|
|
27
|
+
const candidates = [...parent.children]
|
|
28
|
+
.filter((candidate) => candidate !== el && candidate.matches(CENTERED_CONTAINER_SELECTOR))
|
|
29
|
+
.map((candidate) => ({ el: candidate, rect: candidate.getBoundingClientRect() }))
|
|
30
|
+
.concat(ancestor ? [{ el: ancestor, rect: ancestor.getBoundingClientRect() }] : [])
|
|
31
|
+
.filter((candidate) => candidate.el !== el && contains(candidate.rect))
|
|
32
|
+
.sort((a, b) => a.rect.width * a.rect.height - b.rect.width * b.rect.height);
|
|
33
|
+
const container = candidates[0]?.el;
|
|
34
|
+
if (!container) return null;
|
|
35
|
+
const c = container.getBoundingClientRect();
|
|
36
|
+
if (!contains(c)) return null;
|
|
37
|
+
const topInset = r.top - c.top;
|
|
38
|
+
const bottomInset = c.bottom - r.bottom;
|
|
39
|
+
return topInset >= -0.5 && bottomInset >= -0.5
|
|
40
|
+
&& Math.abs(topInset - bottomInset) <= CENTERED_INSET_TOLERANCE
|
|
41
|
+
? container
|
|
42
|
+
: null;
|
|
43
|
+
};
|
|
12
44
|
// an element that already belongs to a left (top) column is left-aligned by design: never snap its RIGHT (bottom)
|
|
13
45
|
// edge — that is what moved the Lands "Proposals" nav item 5 px out of its icon column (owner, 2026-08-22)
|
|
14
46
|
const leftAligned = new Set(), topAligned = new Set();
|
|
@@ -41,6 +73,38 @@ export function browserFn() {
|
|
|
41
73
|
}
|
|
42
74
|
return false;
|
|
43
75
|
};
|
|
76
|
+
// Unlike transform, positioned insets do not create a stacking context around a container's independently
|
|
77
|
+
// z-indexed descendants. Preserve the target's positioning mode and existing inset, and stay silent when a static
|
|
78
|
+
// target cannot be nudged without changing layout semantics.
|
|
79
|
+
const insetNudge = (el, horizontal, visualDelta) => {
|
|
80
|
+
const style = getComputedStyle(el);
|
|
81
|
+
if (style.position === 'static') return null;
|
|
82
|
+
const primary = horizontal ? 'left' : 'top';
|
|
83
|
+
const opposite = horizontal ? 'right' : 'bottom';
|
|
84
|
+
const dimension = horizontal ? 'width' : 'height';
|
|
85
|
+
const rect = el.getBoundingClientRect();
|
|
86
|
+
const layoutSpan = Number.parseFloat(style[dimension]);
|
|
87
|
+
const visualSpan = horizontal ? rect.width : rect.height;
|
|
88
|
+
const scale = layoutSpan > 0 && visualSpan > 0 ? visualSpan / layoutSpan : 1;
|
|
89
|
+
const d = visualDelta / scale;
|
|
90
|
+
const current = Number.parseFloat(style[primary]);
|
|
91
|
+
const anchored = Number.parseFloat(style[opposite]);
|
|
92
|
+
// With both insets resolved, moving only one inset resizes the box. Width preservation has an explicit
|
|
93
|
+
// zoom/constrained fixture. For vertical movement, shift both insets in opposite directions instead: that keeps
|
|
94
|
+
// the declared height relationship without publishing an unproven height:...!important lock.
|
|
95
|
+
const constrainedPosition = style.position === 'absolute' || style.position === 'fixed';
|
|
96
|
+
const doublyConstrained = constrainedPosition && Number.isFinite(current) && Number.isFinite(anchored) && Number.isFinite(layoutSpan);
|
|
97
|
+
const preserveSize = horizontal && doublyConstrained
|
|
98
|
+
? { [dimension]: `${+layoutSpan.toFixed(2)}px` }
|
|
99
|
+
: {};
|
|
100
|
+
if (!horizontal && doublyConstrained) return {
|
|
101
|
+
[primary]: `${+(current + d).toFixed(2)}px`,
|
|
102
|
+
[opposite]: `${+(anchored - d).toFixed(2)}px`,
|
|
103
|
+
};
|
|
104
|
+
if (Number.isFinite(current)) return { ...preserveSize, [primary]: `${+(current + d).toFixed(2)}px` };
|
|
105
|
+
if (Number.isFinite(anchored)) return { ...preserveSize, [opposite]: `${+(anchored - d).toFixed(2)}px` };
|
|
106
|
+
return { [primary]: `${+d.toFixed(2)}px` };
|
|
107
|
+
};
|
|
44
108
|
const out = [];
|
|
45
109
|
for (const [key, g] of groups) {
|
|
46
110
|
const axis = key.split('|')[1];
|
|
@@ -57,9 +121,15 @@ export function browserFn() {
|
|
|
57
121
|
for (const [id, v] of cluster) {
|
|
58
122
|
const d = anchor - v;
|
|
59
123
|
if (Math.abs(d) < 0.5 || Math.abs(d) > 6) continue;
|
|
60
|
-
|
|
124
|
+
const target = document.getElementById(id);
|
|
125
|
+
if (breaksColumn(target, axis)) continue;
|
|
61
126
|
const horizontal = axis === 'left' || axis === 'right';
|
|
62
|
-
|
|
127
|
+
// Top/bottom/center edge repair cannot trade away a declared centre relationship. Aligning the containing
|
|
128
|
+
// control itself remains available; nudging only its centered child does not.
|
|
129
|
+
if (!horizontal && verticalCenterContainer(target)) continue;
|
|
130
|
+
const css = insetNudge(target, horizontal, d);
|
|
131
|
+
if (!css) continue;
|
|
132
|
+
out.push({ id, rule: 'align', reason: `${axis} ${v.toFixed(1)}→${anchor}`, dx: horizontal ? d : 0, dy: horizontal ? 0 : d, motion: 'inset', css });
|
|
63
133
|
}
|
|
64
134
|
}
|
|
65
135
|
i = j + 1;
|
|
@@ -14,11 +14,10 @@
|
|
|
14
14
|
// rows far from the rail (> 48 px: a second column, a grid), indented sub-items (a row ≥ 12 px right of its
|
|
15
15
|
// own block's edge while that block already sits on the rail), and pages with fewer than 3 ink rows.
|
|
16
16
|
// maxShift 48 is the declared magnitude; the harm guard still rejects the rule on any page where a snap adds an
|
|
17
|
-
// overlap, cut, escape, broken column or off-center fit.
|
|
18
|
-
//
|
|
17
|
+
// overlap, cut, escape, parent-containment breach, broken column, changed sibling gap, or off-center fit. No moving
|
|
18
|
+
// rule is exempt from the shared guard.
|
|
19
19
|
export const name = 'left-rail';
|
|
20
20
|
export const maxShift = 48;
|
|
21
|
-
export const movesBlocks = true;
|
|
22
21
|
export const params = { SNAP_MAX: 48, SNAP_MIN: 2, CLUSTER: 4, INDENT: 12, CENTER_TOL: 24 };
|
|
23
22
|
|
|
24
23
|
export function browserFn() {
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
// owner, then move the semantic block and later direct children. Gradient/image/texture grounds still abstain.
|
|
12
12
|
export const name = 'section-breathing';
|
|
13
13
|
export const maxShift = 192;
|
|
14
|
-
export const movesBlocks = true;
|
|
15
14
|
export const phase = 'post';
|
|
16
15
|
export const verifyPaint = true;
|
|
17
16
|
export const recordsCensus = true;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
[{"id":"viewport-1-a-cards-card-2","dy"
|
|
1
|
+
[{"id":"viewport-1-a-cards-card-2","dy":0,"css":{"top":"-1.02px"}},{"id":"viewport-1-a-cards-card-3","dy":0,"css":{"top":"-2.05px"}}]
|
|
@@ -4,11 +4,14 @@ section{position:relative;width:1536px;height:1024px;background:#fff}
|
|
|
4
4
|
.row{position:relative;display:flex;gap:24px;margin:100px 0 0 100px}
|
|
5
5
|
.card{position:relative;width:300px;height:200px;background:#f3f3f3;border:1px solid #ddd;border-radius:12px}
|
|
6
6
|
.text{position:relative;display:block;font-size:16px;line-height:20px;margin:20px 0 0 20px}
|
|
7
|
+
.action{position:absolute;right:16px;bottom:16px;z-index:100;width:96px;height:32px}
|
|
8
|
+
.canvas-sibling{position:absolute;left:100px;top:100px;z-index:50;width:948px;height:204px;background:rgba(255,255,255,.001)}
|
|
7
9
|
</style>
|
|
8
10
|
<section id="viewport-1-a">
|
|
9
11
|
<div id="viewport-1-a-cards" class="row">
|
|
10
12
|
<div id="viewport-1-a-cards-card-1" class="card"><span id="viewport-1-a-cards-card-1-text" class="text">Alpha</span></div>
|
|
11
|
-
<div id="viewport-1-a-cards-card-2" class="card" style="margin-top:1.02px"><span id="viewport-1-a-cards-card-2-text" class="text">Beta</span></div>
|
|
13
|
+
<div id="viewport-1-a-cards-card-2" class="card" style="margin-top:1.02px"><span id="viewport-1-a-cards-card-2-text" class="text">Beta</span><button id="viewport-1-a-cards-card-2-action" class="action" data-proto-action="modal">Open</button></div>
|
|
12
14
|
<div id="viewport-1-a-cards-card-3" class="card" style="margin-top:2.05px"><span id="viewport-1-a-cards-card-3-text" class="text">Gamma</span></div>
|
|
13
15
|
</div>
|
|
16
|
+
<div class="canvas-sibling"></div>
|
|
14
17
|
</section>
|
|
@@ -25,4 +25,12 @@ section{position:relative;width:1536px;height:1024px;background:#fff}
|
|
|
25
25
|
<div id="viewport-1-a-list-row-2" style="position:absolute;left:11.1px;top:40px;width:200px;height:24px"><span id="viewport-1-a-list-row-2-icon" class="icon" style="position:absolute;left:-1.1px;top:4px;width:16px;height:16px;background:#888"></span><span id="viewport-1-a-list-row-2-text" class="text" style="position:absolute;left:26.9px;top:2px;margin:0;width:100px">Beta</span></div>
|
|
26
26
|
<div id="viewport-1-a-list-row-3" style="position:absolute;left:10px;top:80px;width:200px;height:24px"><span id="viewport-1-a-list-row-3-icon" class="icon" style="position:absolute;left:0;top:4px;width:16px;height:16px;background:#888"></span><span id="viewport-1-a-list-row-3-text" class="text" style="position:absolute;left:28px;top:2px;margin:0;width:100px">Gamma</span></div>
|
|
27
27
|
</div>
|
|
28
|
+
<!-- Owner-found must-stay-silent pair: the icons' top edges differ by 2 px, but each icon is vertically centered
|
|
29
|
+
inside its containing button. Align must preserve that stronger relation instead of snapping their top edges. -->
|
|
30
|
+
<div id="viewport-1-a-centered-controls" style="position:absolute;left:760px;top:400px;width:116px;height:44px">
|
|
31
|
+
<button id="viewport-1-a-centered-controls-button-1" class="button" style="position:absolute;left:0;top:0;width:40px;height:40px;border:0;border-radius:20px;background:#dfeacc"></button>
|
|
32
|
+
<span id="viewport-1-a-centered-controls-icon-1" class="icon" style="position:absolute;left:12px;top:12px;width:16px;height:16px;background:#56714a"></span>
|
|
33
|
+
<div id="viewport-1-a-centered-controls-circle-2" class="circle" style="position:absolute;left:72px;top:0;width:44px;height:44px;border-radius:22px;background:#dfeacc"></div>
|
|
34
|
+
<span id="viewport-1-a-centered-controls-icon-2" class="icon" style="position:absolute;left:86px;top:14px;width:16px;height:16px;background:#56714a"></span>
|
|
35
|
+
</div>
|
|
28
36
|
</section>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html><meta charset="utf-8"><style>
|
|
2
|
+
*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui}.zoom{zoom:.75}.card,.box{background:#f3f3f3;border:1px solid #ddd}.text{position:absolute;left:12px;top:12px}.row{position:relative;display:flex;gap:20px;margin:80px}.card{position:relative;width:240px;height:100px}.stack{position:relative;width:1536px;height:500px}.box{position:absolute;right:100px;height:60px}
|
|
3
|
+
</style>
|
|
4
|
+
<div class="zoom"><section id="viewport-1-a" style="position:relative;width:1536px;height:300px"><div id="viewport-1-a-row" class="row">
|
|
5
|
+
<div id="viewport-1-a-card-1" class="card"><span id="viewport-1-a-card-1-text" class="text">One</span></div>
|
|
6
|
+
<div id="viewport-1-a-card-2" class="card" style="margin-top:2px"><span id="viewport-1-a-card-2-text" class="text">Two</span></div>
|
|
7
|
+
<div id="viewport-1-a-card-3" class="card" style="margin-top:4px"><span id="viewport-1-a-card-3-text" class="text">Three</span></div>
|
|
8
|
+
</div></section></div>
|
|
9
|
+
<section id="viewport-2-a" class="stack">
|
|
10
|
+
<div id="viewport-2-a-box-1" class="box" style="left:100px;top:80px"><span id="viewport-2-a-box-1-text" class="text">One</span></div>
|
|
11
|
+
<div id="viewport-2-a-box-2" class="box" style="left:102px;top:180px"><span id="viewport-2-a-box-2-text" class="text">Two</span></div>
|
|
12
|
+
<div id="viewport-2-a-box-3" class="box" style="left:104px;top:280px"><span id="viewport-2-a-box-3-text" class="text">Three</span></div>
|
|
13
|
+
</section>
|
|
14
|
+
<section id="viewport-3-a" style="position:relative;width:600px;height:120px">
|
|
15
|
+
<div id="viewport-3-a-box-1" class="box" style="position:absolute;left:80px;top:20px;bottom:20px;width:100px;height:auto"><span id="viewport-3-a-box-1-text" class="text">Anchor</span></div>
|
|
16
|
+
<div id="viewport-3-a-box-2" class="box" style="position:absolute;left:220px;top:22px;bottom:18px;width:100px;height:auto"><span id="viewport-3-a-box-2-text" class="text">Stray</span></div>
|
|
17
|
+
</section>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html><meta charset="utf-8"><style>
|
|
2
|
+
*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui}section{position:relative;width:1536px;height:1024px}.reference,.inbox{position:absolute;left:0;width:1536px}.text{position:absolute;white-space:nowrap}.inbox{left:64px;width:440px;height:490px}.inbox .text{left:0;width:400px;height:44px;padding:12px;background:#f5f5f2;border:1px solid #ddd}
|
|
3
|
+
</style><section id="viewport-1-a">
|
|
4
|
+
<div id="viewport-1-a-reference-1" class="reference" style="top:20px;height:40px"><p id="viewport-1-a-reference-1-text" class="text" style="left:80px;top:0">Inbox</p></div>
|
|
5
|
+
<div id="viewport-1-a-reference-2" class="reference" style="top:70px;height:40px"><p id="viewport-1-a-reference-2-text" class="text" style="left:80px;top:0">Assigned</p></div>
|
|
6
|
+
<div id="viewport-1-a-reference-3" class="reference" style="top:120px;height:40px"><p id="viewport-1-a-reference-3-text" class="text" style="left:80px;top:0">Resolved</p></div>
|
|
7
|
+
<div id="viewport-1-a-inbox" class="inbox" style="top:190px">
|
|
8
|
+
<button id="viewport-1-a-inbox-row-1" class="text" data-proto-action="navigate" style="top:0">Action one</button>
|
|
9
|
+
<button id="viewport-1-a-inbox-row-2" class="text" data-proto-action="navigate" style="top:60px">Action two</button>
|
|
10
|
+
<button id="viewport-1-a-inbox-row-3" class="text" data-proto-action="navigate" style="top:120px">Action three</button>
|
|
11
|
+
<button id="viewport-1-a-inbox-row-4" class="text" data-proto-action="navigate" style="top:180px">Action four</button>
|
|
12
|
+
<button id="viewport-1-a-inbox-row-5" class="text" data-proto-action="navigate" style="top:240px">Action five</button>
|
|
13
|
+
<button id="viewport-1-a-inbox-row-6" class="text" data-proto-action="navigate" style="top:300px">Action six</button>
|
|
14
|
+
<button id="viewport-1-a-inbox-row-7" class="text" data-proto-action="navigate" style="top:360px">Action seven</button>
|
|
15
|
+
</div>
|
|
16
|
+
</section>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html><meta charset="utf-8"><style>
|
|
2
|
+
*{margin:0;padding:0;box-sizing:border-box}body{font-family:system-ui}section{position:relative;width:1536px;height:1024px}.reference,.inbox{position:absolute;left:0;width:1536px}.text{position:absolute;white-space:nowrap}.inbox{left:64px;width:400px;height:490px}.inbox .text{left:0;width:400px;height:44px;padding:12px;background:#f5f5f2;border:1px solid #ddd}
|
|
3
|
+
</style><section id="viewport-1-a">
|
|
4
|
+
<div id="viewport-1-a-reference-1" class="reference" style="top:20px;height:40px"><p id="viewport-1-a-reference-1-text" class="text" style="left:80px;top:0">Inbox</p></div>
|
|
5
|
+
<div id="viewport-1-a-reference-2" class="reference" style="top:70px;height:40px"><p id="viewport-1-a-reference-2-text" class="text" style="left:80px;top:0">Assigned</p></div>
|
|
6
|
+
<div id="viewport-1-a-reference-3" class="reference" style="top:120px;height:40px"><p id="viewport-1-a-reference-3-text" class="text" style="left:80px;top:0">Resolved</p></div>
|
|
7
|
+
<div id="viewport-1-a-inbox" class="inbox" style="top:190px">
|
|
8
|
+
<button id="viewport-1-a-inbox-row-1" class="text" data-proto-action="navigate" style="top:0">Action one</button>
|
|
9
|
+
<button id="viewport-1-a-inbox-row-2" class="text" data-proto-action="navigate" style="top:60px">Action two</button>
|
|
10
|
+
<button id="viewport-1-a-inbox-row-3" class="text" data-proto-action="navigate" style="top:120px">Action three</button>
|
|
11
|
+
<button id="viewport-1-a-inbox-row-4" class="text" data-proto-action="navigate" style="top:180px">Action four</button>
|
|
12
|
+
<button id="viewport-1-a-inbox-row-5" class="text" data-proto-action="navigate" style="top:240px">Action five</button>
|
|
13
|
+
<button id="viewport-1-a-inbox-row-6" class="text" data-proto-action="navigate" style="top:300px">Action six</button>
|
|
14
|
+
<button id="viewport-1-a-inbox-row-7" class="text" data-proto-action="navigate" style="top:360px">Action seven</button>
|
|
15
|
+
</div>
|
|
16
|
+
</section>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<!doctype html><meta charset="utf-8"><style>
|
|
2
|
+
*{box-sizing:border-box}section{position:relative;width:1536px;height:1024px}#viewport-1-a-outer,#viewport-1-a-stacked{position:absolute;left:100px;width:240px;height:80px;background:#ddd}#viewport-1-a-outer{top:100px}#viewport-1-a-inner{position:absolute;inset:0;width:100%;height:100%;z-index:2}#viewport-1-a-stacked{top:220px}#viewport-1-a-stacked-button{width:100%;height:100%}
|
|
3
|
+
</style><section id="viewport-1-a">
|
|
4
|
+
<div id="viewport-1-a-outer" role="button" data-proto-action="modal" tabindex="0"><button id="viewport-1-a-inner" data-proto-action="dropdown">Inner action</button></div>
|
|
5
|
+
<div id="viewport-1-a-stacked" data-proto-action="modal"><button id="viewport-1-a-stacked-button">Plain pointer surface</button></div>
|
|
6
|
+
</section>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<!doctype html><meta charset="utf-8"><style>
|
|
2
|
+
*{margin:0;padding:0;box-sizing:border-box}section{position:relative;width:1536px;height:1024px}.wrapper{position:absolute;left:100px;top:100px;width:400px;height:100px}.row{position:absolute;left:0;top:0;width:400px;height:60px;background:#eee}
|
|
3
|
+
</style><section id="viewport-1-a"><div class="wrapper"><div id="viewport-1-a-row" class="row">Row</div></div></section>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
section{position:relative;width:1536px;height:1024px;background:#fff}
|
|
4
4
|
.rail{position:absolute;left:0;top:0;width:220px;height:1024px;background:#f6f4f0;padding:24px 0 0 16px}
|
|
5
5
|
.btn{position:relative;width:214px;height:43px;background:#245;border-radius:8px;margin-top:40px}
|
|
6
|
-
.btn .text{position:absolute;left:0;top:12px;width:
|
|
6
|
+
.btn .text{position:absolute;left:0;top:12px;width:100%;text-align:center;color:#fff;font-size:15px}
|
|
7
7
|
.nav{position:relative;margin-top:24px;font-size:15px;width:99px;height:18px}
|
|
8
8
|
.foot{position:absolute;left:16px;top:1040px;width:120px;height:14px;font-size:11px;color:#666}
|
|
9
9
|
.content{position:absolute;left:270px;top:40px;width:1200px;height:900px;background:#fff;border:1px solid #ddd}
|
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
// Paired cases for the structure harm guard (harm.mjs): a synthetic override that breaks an icon column or collapses
|
|
3
3
|
// the rail/content gutter MUST be flagged; a rhythm-sized nudge (≤ 6 px) on a gap MUST NOT.
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
5
7
|
import { chromium } from '@playwright/test';
|
|
6
8
|
import { measurePage } from '../measure.mjs';
|
|
7
|
-
import { applyOverridesScript } from '../overrides.mjs';
|
|
9
|
+
import { applyOverridesScript, mergeOverrides, overridesToCss, partitionProtectedShellOverridesScript } from '../overrides.mjs';
|
|
8
10
|
import { structureHarm } from '../harm.mjs';
|
|
11
|
+
import * as align from '../rules/align.mjs';
|
|
12
|
+
import * as leftRail from '../rules/left-rail.mjs';
|
|
13
|
+
import { polishPrototype } from '../polish.mjs';
|
|
9
14
|
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
10
15
|
const browser = await chromium.launch();
|
|
11
16
|
const page = await browser.newPage({ viewport: { width: 1536, height: 1024 } });
|
|
@@ -29,7 +34,7 @@ const gapBefore = await measurePage(page, {});
|
|
|
29
34
|
await page.evaluate(applyOverridesScript(), '#viewport-1-a-footer{transform:translate(0,32px)!important}');
|
|
30
35
|
const gapAfter = await measurePage(page, {});
|
|
31
36
|
const undeclaredGap = structureHarm(gapBefore, gapAfter);
|
|
32
|
-
const declaredGap = structureHarm(gapBefore, gapAfter, new Set(
|
|
37
|
+
const declaredGap = structureHarm(gapBefore, gapAfter, new Set(['viewport-1-a-content|viewport-1-a-footer']));
|
|
33
38
|
const okDeclaredGap = undeclaredGap.spacing.length > 0 && declaredGap.spacing.length === 0;
|
|
34
39
|
if (!okDeclaredGap) failures++;
|
|
35
40
|
console.log(`${okDeclaredGap ? 'ok ' : 'FAIL'} harm/declared-gap: undeclared=${undeclaredGap.spacing.length} declared=${declaredGap.spacing.length}`);
|
|
@@ -47,6 +52,130 @@ const pairs = mo.overlap.map((o) => [o.a, o.b].sort().join('|'));
|
|
|
47
52
|
const okBtn = pairs.length === 1 && pairs[0] === 'viewport-1-a-hero-eyebrow|viewport-1-a-hero-stray';
|
|
48
53
|
if (!okBtn) failures++;
|
|
49
54
|
console.log(`${okBtn ? 'ok ' : 'FAIL'} overlap/button-over-text: ${JSON.stringify(pairs)} (expected eyebrow|stray only)`);
|
|
55
|
+
// The page-gallery failure class: a positioned card is not a stacking context until align adds transform. Its high-z
|
|
56
|
+
// action can initially paint over a canvas sibling, then becomes trapped underneath it. The hit-test guard must catch
|
|
57
|
+
// that legacy emission; align's current inset emission must preserve reachability and contain no transform.
|
|
58
|
+
await page.goto('file://' + path.join(here, 'fixtures/align/fire.html'), { waitUntil: 'load' });
|
|
59
|
+
const alignBefore = await measurePage(page, {});
|
|
60
|
+
const alignOverrides = await page.evaluate(align.browserFn(), { metrics: alignBefore, params: align.params });
|
|
61
|
+
const protectedAlign = await page.evaluate(partitionProtectedShellOverridesScript(), {
|
|
62
|
+
overrides: alignOverrides,
|
|
63
|
+
shellIds: ['viewport-1-a-cards'],
|
|
64
|
+
});
|
|
65
|
+
const legacyAlignCss = alignOverrides.map((o) => `#${o.id}{transform:translate(${o.dx || 0}px,${o.dy || 0}px)!important}`).join('\n');
|
|
66
|
+
await page.evaluate(applyOverridesScript(), legacyAlignCss);
|
|
67
|
+
const alignLegacyAfter = await measurePage(page, {});
|
|
68
|
+
const legacyAlignHarm = structureHarm(alignBefore, alignLegacyAfter);
|
|
69
|
+
const fixedAlignCss = overridesToCss(mergeOverrides(alignOverrides));
|
|
70
|
+
await page.evaluate(applyOverridesScript(), fixedAlignCss);
|
|
71
|
+
const alignFixedAfter = await measurePage(page, {});
|
|
72
|
+
const fixedAlignHarm = structureHarm(alignBefore, alignFixedAfter);
|
|
73
|
+
const okAlignHit = alignBefore.counts.reachableActions === 1
|
|
74
|
+
&& alignLegacyAfter.counts.reachableActions === 0
|
|
75
|
+
&& legacyAlignHarm.unreachableActions.length === 1
|
|
76
|
+
&& alignFixedAfter.counts.reachableActions === 1
|
|
77
|
+
&& fixedAlignHarm.unreachableActions.length === 0
|
|
78
|
+
&& !fixedAlignCss.includes('transform:')
|
|
79
|
+
&& !fixedAlignCss.includes('height:')
|
|
80
|
+
&& protectedAlign.allowed.length === 0
|
|
81
|
+
&& protectedAlign.protected.length === 2;
|
|
82
|
+
if (!okAlignHit) failures++;
|
|
83
|
+
console.log(`${okAlignHit ? 'ok ' : 'FAIL'} harm/align-hit-test: legacy reachable ${alignBefore.counts.reachableActions}→${alignLegacyAfter.counts.reachableActions}, fixed ${alignBefore.counts.reachableActions}→${alignFixedAfter.counts.reachableActions}, fixed transform=${fixedAlignCss.includes('transform:')}, canonical-shell blocked=${protectedAlign.protected.length}`);
|
|
84
|
+
// Insets use layout coordinates, while alignment findings use visual coordinates. Under CSS zoom the emitted inset
|
|
85
|
+
// must divide by the visual scale, and a left+right constrained target must retain its used width.
|
|
86
|
+
await page.goto('file://' + path.join(here, 'fixtures/align/zoom-constrained.html'), { waitUntil: 'load' });
|
|
87
|
+
const scaledBefore = await measurePage(page, {});
|
|
88
|
+
const scaledWidthsBefore = await page.evaluate(() => ['viewport-2-a-box-2', 'viewport-2-a-box-3'].map((id) => document.getElementById(id).getBoundingClientRect().width));
|
|
89
|
+
const verticalHeightsBefore = await page.evaluate(() => ['viewport-3-a-box-1', 'viewport-3-a-box-2'].map((id) => document.getElementById(id).getBoundingClientRect().height));
|
|
90
|
+
const scaledOverrides = await page.evaluate(align.browserFn(), { metrics: scaledBefore, params: align.params });
|
|
91
|
+
const scaledCss = overridesToCss(mergeOverrides(scaledOverrides));
|
|
92
|
+
await page.evaluate(applyOverridesScript(), scaledCss);
|
|
93
|
+
const scaledAfter = await measurePage(page, {});
|
|
94
|
+
const scaledWidthsAfter = await page.evaluate(() => ['viewport-2-a-box-2', 'viewport-2-a-box-3'].map((id) => document.getElementById(id).getBoundingClientRect().width));
|
|
95
|
+
const verticalHeightsAfter = await page.evaluate(() => ['viewport-3-a-box-1', 'viewport-3-a-box-2'].map((id) => document.getElementById(id).getBoundingClientRect().height));
|
|
96
|
+
const verticalTop = await page.evaluate(() => ['viewport-3-a-box-1', 'viewport-3-a-box-2'].map((id) => document.getElementById(id).getBoundingClientRect().top));
|
|
97
|
+
const zoomTop = await page.evaluate(() => ['viewport-1-a-card-1', 'viewport-1-a-card-2', 'viewport-1-a-card-3'].map((id) => document.getElementById(id).getBoundingClientRect().top));
|
|
98
|
+
const constrained = scaledOverrides.filter((override) => override.id?.startsWith('viewport-2-a-box-') && override.id !== 'viewport-2-a-box-1');
|
|
99
|
+
const verticalConstrained = scaledOverrides.find((override) => override.id === 'viewport-3-a-box-2');
|
|
100
|
+
const okScaledAlign = Math.max(...zoomTop) - Math.min(...zoomTop) < 0.2
|
|
101
|
+
&& scaledWidthsAfter.every((width, index) => Math.abs(width - scaledWidthsBefore[index]) < 0.2)
|
|
102
|
+
&& constrained.length === 2
|
|
103
|
+
&& constrained.every((override) => override.css.width)
|
|
104
|
+
&& verticalConstrained?.css.top === '20px'
|
|
105
|
+
&& verticalConstrained?.css.bottom === '20px'
|
|
106
|
+
&& verticalConstrained?.css.height == null
|
|
107
|
+
&& !scaledCss.includes('height:')
|
|
108
|
+
&& Math.max(...verticalTop) - Math.min(...verticalTop) < 0.2
|
|
109
|
+
&& verticalHeightsAfter.every((height, index) => Math.abs(height - verticalHeightsBefore[index]) < 0.2)
|
|
110
|
+
&& structureHarm(scaledBefore, scaledAfter).unreachableActions.length === 0;
|
|
111
|
+
if (!okScaledAlign) failures++;
|
|
112
|
+
console.log(`${okScaledAlign ? 'ok ' : 'FAIL'} align/zoom-constrained: zoom top spread=${(Math.max(...zoomTop) - Math.min(...zoomTop)).toFixed(2)} width deltas=${scaledWidthsAfter.map((width, index) => (width - scaledWidthsBefore[index]).toFixed(2)).join(',')} vertical top spread=${(Math.max(...verticalTop) - Math.min(...verticalTop)).toFixed(2)} height lock=${scaledCss.includes('height:')}`);
|
|
113
|
+
// Owner-found centering pair: measurement must expose the tempting 2 px top-edge near miss, while align stays silent
|
|
114
|
+
// because each icon is vertically centered in its containing circle button.
|
|
115
|
+
await page.goto('file://' + path.join(here, 'fixtures/align/silent.html'), { waitUntil: 'load' });
|
|
116
|
+
const centeredBefore = await measurePage(page, {});
|
|
117
|
+
const centeredIds = new Set(['viewport-1-a-centered-controls-icon-1', 'viewport-1-a-centered-controls-icon-2']);
|
|
118
|
+
const centeredNearMiss = centeredBefore.alignment.some((pair) => pair.axis === 'top' && centeredIds.has(pair.a) && centeredIds.has(pair.b));
|
|
119
|
+
const centeredOverrides = await page.evaluate(align.browserFn(), { metrics: centeredBefore, params: align.params });
|
|
120
|
+
const centeredTouched = centeredOverrides.filter((override) => centeredIds.has(override.id));
|
|
121
|
+
const okCenteredAlign = centeredNearMiss && centeredTouched.length === 0;
|
|
122
|
+
if (!okCenteredAlign) failures++;
|
|
123
|
+
console.log(`${okCenteredAlign ? 'ok ' : 'FAIL'} align/centered-control-silent: near-miss=${centeredNearMiss} overrides=${centeredTouched.length}`);
|
|
124
|
+
// The autopilot-inbox failure shape: the actual left-rail rule moves seven full-width rows beyond their direct parent.
|
|
125
|
+
// A wider parent is the paired healthy case: the same seven proposals remain contained and the guard stays silent.
|
|
126
|
+
for (const [fixture, expectedHarm] of [['left-rail-overflow.html', 7], ['left-rail-contained.html', 0]]) {
|
|
127
|
+
await page.goto('file://' + path.join(here, 'fixtures/harm', fixture), { waitUntil: 'load' });
|
|
128
|
+
const railBefore = await measurePage(page, {});
|
|
129
|
+
const railOverrides = await page.evaluate(leftRail.browserFn(), { metrics: railBefore, params: leftRail.params });
|
|
130
|
+
await page.evaluate(applyOverridesScript(), overridesToCss(mergeOverrides(railOverrides)));
|
|
131
|
+
const railAfter = await measurePage(page, {});
|
|
132
|
+
const railHarm = structureHarm(railBefore, railAfter);
|
|
133
|
+
const okRail = railOverrides.length === 7 && railHarm.containment.length === expectedHarm;
|
|
134
|
+
if (!okRail) failures++;
|
|
135
|
+
console.log(`${okRail ? 'ok ' : 'FAIL'} harm/${fixture.replace('.html', '')}: proposals=${railOverrides.length} containment=${railHarm.containment.length}`);
|
|
136
|
+
}
|
|
137
|
+
// Exercise the production acceptance loop as well as the pure comparator: the overflow case must publish unchanged
|
|
138
|
+
// with left-rail reverted, while the contained twin accepts all seven moves.
|
|
139
|
+
{
|
|
140
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), '12ui-polish-harm-'));
|
|
141
|
+
try {
|
|
142
|
+
for (const [fixture, status, accepted] of [['left-rail-overflow.html', 'reverted', 0], ['left-rail-contained.html', 'fired', 7]]) {
|
|
143
|
+
const stem = fixture.replace('.html', '');
|
|
144
|
+
const source = path.join(root, `${stem}-source`); const out = path.join(root, `${stem}-out`);
|
|
145
|
+
fs.mkdirSync(source);
|
|
146
|
+
fs.copyFileSync(path.join(here, 'fixtures/harm', fixture), path.join(source, `${stem}.html`));
|
|
147
|
+
fs.writeFileSync(path.join(source, 'proto-manifest.json'), JSON.stringify({ units: [{ id: stem, states: [] }], holdings: [] }));
|
|
148
|
+
const report = await polishPrototype({ protoDir: source, outDir: out, ruleNames: ['left-rail'], concurrency: 1 });
|
|
149
|
+
const record = report.pages[0].ruleRecords.find((entry) => entry.rule === 'left-rail');
|
|
150
|
+
const okAcceptance = record?.status === status && record.accepted === accepted
|
|
151
|
+
&& (status !== 'reverted' || record.reasons?.includes('containment'));
|
|
152
|
+
if (!okAcceptance) failures++;
|
|
153
|
+
console.log(`${okAcceptance ? 'ok ' : 'FAIL'} harm/${stem}-acceptance: status=${record?.status} accepted=${record?.accepted} reasons=${record?.reasons?.join(',') || 'none'}`);
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Interaction identity is the nearest interactive ancestor, not merely DOM containment. An outer action completely
|
|
160
|
+
// covered by a nested action is unreachable even though element.contains(elementFromPoint(...)) would be true.
|
|
161
|
+
await page.goto('file://' + path.join(here, 'fixtures/harm/nested-actions.html'), { waitUntil: 'load' });
|
|
162
|
+
const nestedActions = await measurePage(page, {});
|
|
163
|
+
const nestedById = new Map(nestedActions.actions.map((action) => [action.id, action.reachable]));
|
|
164
|
+
const okNested = nestedById.get('viewport-1-a-outer') === false
|
|
165
|
+
&& nestedById.get('viewport-1-a-inner') === true
|
|
166
|
+
&& nestedById.get('viewport-1-a-stacked') === true
|
|
167
|
+
&& !nestedById.has('viewport-1-a-stacked-button');
|
|
168
|
+
if (!okNested) failures++;
|
|
169
|
+
console.log(`${okNested ? 'ok ' : 'FAIL'} harm/nested-action-identity: outer=${nestedById.get('viewport-1-a-outer')} inner=${nestedById.get('viewport-1-a-inner')} wrapper-over-native=${nestedById.get('viewport-1-a-stacked')}`);
|
|
170
|
+
// An ordinary un-IDed layout wrapper still owns containment and cannot be a blind spot.
|
|
171
|
+
await page.goto('file://' + path.join(here, 'fixtures/harm/unidentified-parent.html'), { waitUntil: 'load' });
|
|
172
|
+
const anonymousBefore = await measurePage(page, {});
|
|
173
|
+
await page.evaluate(applyOverridesScript(), '#viewport-1-a-row{transform:translate(20px,0)!important}');
|
|
174
|
+
const anonymousAfter = await measurePage(page, {});
|
|
175
|
+
const anonymousHarm = structureHarm(anonymousBefore, anonymousAfter);
|
|
176
|
+
const okAnonymous = anonymousHarm.containment.length === 1 && anonymousHarm.containment[0].id === 'viewport-1-a-row';
|
|
177
|
+
if (!okAnonymous) failures++;
|
|
178
|
+
console.log(`${okAnonymous ? 'ok ' : 'FAIL'} harm/unidentified-parent: containment=${anonymousHarm.containment.length}`);
|
|
50
179
|
// contrast refinement: declared brand colour keeps its hue (orange → readable orange, not black); readable declared
|
|
51
180
|
// colour untouched; a hue that cannot reach the threshold falls back and records the fact
|
|
52
181
|
{
|
|
@@ -5,6 +5,7 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { chromium } from '@playwright/test';
|
|
7
7
|
import { measurePage } from '../measure.mjs';
|
|
8
|
+
import { structureHarm } from '../harm.mjs';
|
|
8
9
|
import { loadRules, ALL } from '../rules/index.mjs';
|
|
9
10
|
import { applyOverridesScript, overridesToCss, mergeOverrides } from '../overrides.mjs';
|
|
10
11
|
|
|
@@ -31,8 +32,12 @@ for (const rule of rules) {
|
|
|
31
32
|
const merged = mergeOverrides(found);
|
|
32
33
|
await page.evaluate(applyOverridesScript(), overridesToCss(merged));
|
|
33
34
|
const after = await measurePage(page, {});
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
const structural = structureHarm(before, after);
|
|
36
|
+
harm = after.counts.overlap > before.counts.overlap
|
|
37
|
+
|| after.overflow.filter((o) => o.kind !== 'spill').length > before.overflow.filter((o) => o.kind !== 'spill').length
|
|
38
|
+
|| structural.containment.length > 0
|
|
39
|
+
|| structural.unreachableActions.length > 0;
|
|
40
|
+
detail += `; overlap ${before.counts.overlap}→${after.counts.overlap}, overflow ${before.counts.overflow}→${after.counts.overflow}, containment ${before.counts.containment}→${after.counts.containment} (new ${structural.containment.length}), reachable ${before.counts.reachableActions}→${after.counts.reachableActions} (lost ${structural.unreachableActions.length}), align ${before.counts.alignment}→${after.counts.alignment}, ws ${before.counts.whitespace}→${after.counts.whitespace}`;
|
|
36
41
|
const expectFile = path.join(dir, 'expect.json');
|
|
37
42
|
if (fs.existsSync(expectFile)) {
|
|
38
43
|
const expect = JSON.parse(fs.readFileSync(expectFile, 'utf8'));
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
|
|
11
11
|
// ---- content layer: replace text nodes from content/<state>.json (or content-<set>/)
|
|
12
12
|
async function hydrate() {
|
|
13
|
-
|
|
13
|
+
// Direct-file review already carries the same baked-in copy. Browsers do
|
|
14
|
+
// not permit fetch(file://...), and attempting it emits a console error
|
|
15
|
+
// even when the rejected promise is caught.
|
|
16
|
+
if (!cfg.contentFile || location.protocol === 'file:') return;
|
|
14
17
|
const dir = contentSet ? `content-${contentSet}` : 'content';
|
|
15
18
|
try {
|
|
16
19
|
const res = await fetch(`${dir}/${cfg.contentFile}`, { cache: 'no-store' });
|
|
@@ -130,6 +130,7 @@ const interactionPage = (file) => interactionRuntime.pages.find((page) => page.f
|
|
|
130
130
|
const interactionReachability = {
|
|
131
131
|
occludedExactFailure: interactionPage('occluded-saved-journeys.html')?.dropdown,
|
|
132
132
|
ordinaryControl: interactionPage('ordinary-dropdown.html')?.dropdown,
|
|
133
|
+
reviewBottomControl: interactionPage('review-bottom-action.html')?.modal,
|
|
133
134
|
};
|
|
134
135
|
|
|
135
136
|
const blockingScratch = fs.mkdtempSync(path.join(os.tmpdir(), 'prototype-blocking-runtime-'));
|
|
@@ -194,6 +195,8 @@ if (process.argv.includes('--assert')) {
|
|
|
194
195
|
assert.equal(interactionReachability.occludedExactFailure?.ok, true, 'must fire: the exact Saved Journeys dropdown is scrolled clear of the fixed review framing before pointer hit-testing');
|
|
195
196
|
assert.ok(interactionReachability.occludedExactFailure?.point?.y < 700, 'must fire: the exact initially occluded target is measured outside the review panel');
|
|
196
197
|
assert.equal(interactionReachability.ordinaryControl?.ok, true, 'must stay silent: an ordinary already-visible dropdown remains pointer reachable');
|
|
198
|
+
assert.equal(interactionReachability.reviewBottomControl?.ok, true, 'must fire: a bottom-edge action receives temporary scroll clearance from fixed review chrome before hit-testing');
|
|
199
|
+
assert.ok(interactionReachability.reviewBottomControl?.point?.y < 800, 'must fire: the bottom-edge action is measured above the review panel');
|
|
197
200
|
assert.notEqual(blockingVerification.status, 0, 'must fire: a claimed dropdown with broken pointer wiring still refuses publication');
|
|
198
201
|
assert.match(blockingVerification.stderr, /Prototype runtime gate failed: 1 page\(s\) with broken dropdown\/modal interaction/, 'must fire: the refusal names the genuinely blocking interaction gate');
|
|
199
202
|
assert.deepEqual(blockingAudit.interactionFailures, ['broken-interaction.html'], 'must stay silent: the blocking fixture identifies only the broken page');
|