@12ui/design 0.2.43 → 0.2.45
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/README.md +17 -5
- package/SKILL.md +1 -1
- package/dist/branch-execution-types.d.ts +2 -0
- package/dist/branch-execution-types.d.ts.map +1 -1
- package/dist/branch-execution.d.ts.map +1 -1
- package/dist/branch-execution.js +3 -0
- package/dist/branch-execution.js.map +1 -1
- package/dist/branch-run-record.d.ts +1 -0
- package/dist/branch-run-record.d.ts.map +1 -1
- package/dist/branch-run-record.js.map +1 -1
- package/dist/cli-arguments.d.ts.map +1 -1
- package/dist/cli-arguments.js +1 -0
- package/dist/cli-arguments.js.map +1 -1
- package/dist/cli-branch-command.d.ts.map +1 -1
- package/dist/cli-branch-command.js +41 -2
- package/dist/cli-branch-command.js.map +1 -1
- package/dist/cli-capabilities.d.ts +2 -1
- package/dist/cli-capabilities.d.ts.map +1 -1
- package/dist/cli-capabilities.js +2 -1
- package/dist/cli-capabilities.js.map +1 -1
- package/dist/cli-help/design.d.ts +2 -2
- package/dist/cli-help/design.d.ts.map +1 -1
- package/dist/cli-help/design.js +2 -0
- package/dist/cli-help/design.js.map +1 -1
- package/dist/cli-help/index.d.ts +2 -2
- package/dist/cli-prototype-command.d.ts.map +1 -1
- package/dist/cli-prototype-command.js +17 -1
- package/dist/cli-prototype-command.js.map +1 -1
- package/dist/cli-usage.d.ts.map +1 -1
- package/dist/cli-usage.js +4 -3
- package/dist/cli-usage.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/legacy-skill-catalog.d.ts.map +1 -1
- package/dist/legacy-skill-catalog.js +7 -0
- package/dist/legacy-skill-catalog.js.map +1 -1
- package/dist/prototype-gates.d.ts +8 -0
- package/dist/prototype-gates.d.ts.map +1 -1
- package/dist/prototype-gates.js +39 -5
- package/dist/prototype-gates.js.map +1 -1
- package/dist/prototype-poststep.d.ts +1 -0
- package/dist/prototype-poststep.d.ts.map +1 -1
- package/dist/prototype-poststep.js +10 -0
- package/dist/prototype-poststep.js.map +1 -1
- package/dist/prototype-process.d.ts +1 -1
- package/dist/prototype-process.d.ts.map +1 -1
- package/dist/prototype-revision.d.ts +2 -0
- package/dist/prototype-revision.d.ts.map +1 -0
- package/dist/prototype-revision.js +2 -0
- package/dist/prototype-revision.js.map +1 -0
- package/dist/prototype-run-record.d.ts +6 -1
- package/dist/prototype-run-record.d.ts.map +1 -1
- package/dist/prototype-run-record.js +113 -9
- package/dist/prototype-run-record.js.map +1 -1
- package/dist/prototype-runner.d.ts +30 -1
- package/dist/prototype-runner.d.ts.map +1 -1
- package/dist/prototype-runner.js +69 -16
- package/dist/prototype-runner.js.map +1 -1
- package/dist/run-journal.d.ts +21 -1
- package/dist/run-journal.d.ts.map +1 -1
- package/dist/run-journal.js +19 -2
- package/dist/run-journal.js.map +1 -1
- package/open-design.json +2 -2
- package/package.json +2 -2
- package/prototype-kit/build.mjs +86 -43
- package/prototype-kit/lib/actions.mjs +48 -0
- package/prototype-kit/lib/shell-identity.mjs +139 -0
- package/prototype-kit/lib/shell-stitching.mjs +70 -29
- package/prototype-kit/polish/ground-probe.mjs +119 -0
- package/prototype-kit/polish/harm.mjs +23 -0
- package/prototype-kit/polish/measure.mjs +333 -0
- package/prototype-kit/polish/overrides.mjs +55 -0
- package/prototype-kit/polish/polish.mjs +252 -0
- package/prototype-kit/polish/report.mjs +76 -0
- package/prototype-kit/polish/rules/align.mjs +70 -0
- package/prototype-kit/polish/rules/contrast.mjs +80 -0
- package/prototype-kit/polish/rules/escape.mjs +192 -0
- package/prototype-kit/polish/rules/fit-text.mjs +158 -0
- package/prototype-kit/polish/rules/index.mjs +61 -0
- package/prototype-kit/polish/rules/left-rail.mjs +77 -0
- package/prototype-kit/polish/rules/overlap.mjs +122 -0
- package/prototype-kit/polish/rules/page-cut.mjs +203 -0
- package/prototype-kit/polish/rules/photo-corners.mjs +38 -0
- package/prototype-kit/polish/rules/rhythm.mjs +148 -0
- package/prototype-kit/polish/rules/section-breathing.mjs +292 -0
- package/prototype-kit/polish/rules/shell-seam.mjs +48 -0
- package/prototype-kit/polish/tests/breathing-check.mjs +77 -0
- package/prototype-kit/polish/tests/fixtures/align/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/align/fire.html +14 -0
- package/prototype-kit/polish/tests/fixtures/align/silent.html +28 -0
- package/prototype-kit/polish/tests/fixtures/contrast/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/contrast/fallback.html +7 -0
- package/prototype-kit/polish/tests/fixtures/contrast/fire.html +9 -0
- package/prototype-kit/polish/tests/fixtures/contrast/silent.html +9 -0
- package/prototype-kit/polish/tests/fixtures/escape/expect.json +2 -0
- package/prototype-kit/polish/tests/fixtures/escape/fire.html +23 -0
- package/prototype-kit/polish/tests/fixtures/escape/silent.html +29 -0
- package/prototype-kit/polish/tests/fixtures/fit-text/expect.json +3 -0
- package/prototype-kit/polish/tests/fixtures/fit-text/fire.html +26 -0
- package/prototype-kit/polish/tests/fixtures/fit-text/silent.html +33 -0
- package/prototype-kit/polish/tests/fixtures/harm/button-overlap.html +10 -0
- package/prototype-kit/polish/tests/fixtures/harm/declared-gap.html +5 -0
- package/prototype-kit/polish/tests/fixtures/harm/offcenter.html +21 -0
- package/prototype-kit/polish/tests/fixtures/harm/page.html +17 -0
- package/prototype-kit/polish/tests/fixtures/left-rail/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/left-rail/fire.html +8 -0
- package/prototype-kit/polish/tests/fixtures/left-rail/silent.html +8 -0
- package/prototype-kit/polish/tests/fixtures/overlap/expect.json +4 -0
- package/prototype-kit/polish/tests/fixtures/overlap/fire.html +26 -0
- package/prototype-kit/polish/tests/fixtures/overlap/silent.html +39 -0
- package/prototype-kit/polish/tests/fixtures/page-cut/expect.json +2 -0
- package/prototype-kit/polish/tests/fixtures/page-cut/fire.html +31 -0
- package/prototype-kit/polish/tests/fixtures/page-cut/silent.html +22 -0
- package/prototype-kit/polish/tests/fixtures/photo-corners/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/photo-corners/fire.html +5 -0
- package/prototype-kit/polish/tests/fixtures/photo-corners/silent.html +9 -0
- package/prototype-kit/polish/tests/fixtures/rhythm/expect.json +6 -0
- package/prototype-kit/polish/tests/fixtures/rhythm/fire.html +22 -0
- package/prototype-kit/polish/tests/fixtures/rhythm/silent.html +27 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-fire.html +5 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-silent.html +5 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/fire.html +6 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/floor.html +7 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-fire.html +11 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-silent.html +8 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-fire.html +5 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-silent.html +5 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/silent.html +6 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/tight.html +7 -0
- package/prototype-kit/polish/tests/fixtures/shell-seam/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/shell-seam/fire.html +20 -0
- package/prototype-kit/polish/tests/fixtures/shell-seam/silent.html +21 -0
- package/prototype-kit/polish/tests/harm-check.mjs +71 -0
- package/prototype-kit/polish/tests/report-check.mjs +31 -0
- package/prototype-kit/polish/tests/rules-check.mjs +52 -0
- package/prototype-kit/tests/adversarial-runtime.mjs +16 -1
- package/prototype-kit/tests/fixtures/blocking-runtime/broken-interaction.html +7 -0
- package/prototype-kit/tests/fixtures/four-dropdown/branch.plan.json +29 -0
- package/prototype-kit/tests/fixtures/four-dropdown/pages/p2-stream-proposals.html +10 -0
- package/prototype-kit/tests/fixtures/four-dropdown/pages/p4-live-stream-results.html +7 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/PROVENANCE.md +22 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/branch.plan.json +40 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p0-donor.html +38 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p1-id-collision.html +38 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p2-canvas-control.html +39 -0
- package/prototype-kit/tests/offline-check.mjs +1 -1
- package/prototype-kit/tests/rules-check.mjs +69 -3
- package/prototype-kit/tests/shell-stitching-runtime.mjs +116 -0
- package/skills/12ui-design/SKILL.md +4 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const SOURCE_ID_ATTRIBUTE = 'data-proto-shell-source-id';
|
|
2
|
+
const ID_REFERENCE_ATTRIBUTES = new Set([
|
|
3
|
+
'aria-activedescendant',
|
|
4
|
+
'aria-controls',
|
|
5
|
+
'aria-describedby',
|
|
6
|
+
'aria-details',
|
|
7
|
+
'aria-errormessage',
|
|
8
|
+
'aria-flowto',
|
|
9
|
+
'aria-labelledby',
|
|
10
|
+
'aria-owns',
|
|
11
|
+
'commandfor',
|
|
12
|
+
'data-proto-field',
|
|
13
|
+
'data-proto-label',
|
|
14
|
+
'data-proto-submit-for',
|
|
15
|
+
'for',
|
|
16
|
+
'form',
|
|
17
|
+
'headers',
|
|
18
|
+
'list',
|
|
19
|
+
'popovertarget',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function escapeRegex(value) {
|
|
23
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function safeIdentityPart(value) {
|
|
27
|
+
return String(value).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'shell';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rewriteHashIds(value, identities) {
|
|
31
|
+
let rewritten = value;
|
|
32
|
+
for (const [sourceId, physicalId] of [...identities].sort(([left], [right]) => right.length - left.length)) {
|
|
33
|
+
rewritten = rewritten.replace(
|
|
34
|
+
new RegExp(`#${escapeRegex(sourceId)}(?=[^A-Za-z0-9_-]|$)`, 'g'),
|
|
35
|
+
`#${physicalId}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return rewritten;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rewriteIdentityReferences(root, identities) {
|
|
42
|
+
for (const element of [root, ...root.querySelectorAll('*')]) for (const attribute of [...element.attributes]) {
|
|
43
|
+
let value = attribute.value;
|
|
44
|
+
if (ID_REFERENCE_ATTRIBUTES.has(attribute.name)) {
|
|
45
|
+
value = value.split(/(\s+)/).map((token) => identities.get(token) || token).join('');
|
|
46
|
+
}
|
|
47
|
+
value = rewriteHashIds(value, identities);
|
|
48
|
+
if (attribute.name === 'begin' || attribute.name === 'end') {
|
|
49
|
+
for (const [sourceId, physicalId] of identities) {
|
|
50
|
+
value = value.replace(
|
|
51
|
+
new RegExp(`(^|;)${escapeRegex(sourceId)}(?=\\.)`, 'g'),
|
|
52
|
+
`$1${physicalId}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (value !== attribute.value) element.setAttribute(attribute.name, value);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function unusedNamespace(document, parts) {
|
|
61
|
+
const existing = [...document.querySelectorAll('[id]')].map((element) => element.id);
|
|
62
|
+
const base = `proto-shell-${parts.map(safeIdentityPart).join('-')}`;
|
|
63
|
+
let namespace = base;
|
|
64
|
+
let suffix = 2;
|
|
65
|
+
while (existing.some((id) => id === namespace || id.startsWith(`${namespace}-`))) {
|
|
66
|
+
namespace = `${base}-${suffix}`;
|
|
67
|
+
suffix++;
|
|
68
|
+
}
|
|
69
|
+
return namespace;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isolatedElements(root, sourceId) {
|
|
73
|
+
const elements = [];
|
|
74
|
+
if (root.nodeType === 1 && root.getAttribute(SOURCE_ID_ATTRIBUTE) === sourceId) elements.push(root);
|
|
75
|
+
for (const element of root.querySelectorAll(`[${SOURCE_ID_ATTRIBUTE}]`)) {
|
|
76
|
+
if (element.getAttribute(SOURCE_ID_ATTRIBUTE) === sourceId) elements.push(element);
|
|
77
|
+
}
|
|
78
|
+
return elements;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function identitiesIn(root) {
|
|
82
|
+
return new Map(
|
|
83
|
+
[root, ...root.querySelectorAll(`[${SOURCE_ID_ATTRIBUTE}]`)]
|
|
84
|
+
.filter((element) => element.getAttribute(SOURCE_ID_ATTRIBUTE))
|
|
85
|
+
.map((element) => [element.getAttribute(SOURCE_ID_ATTRIBUTE), element.id]),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Clone foreign shell DOM under opaque physical ids and rewrite every internal reference and donor selector. */
|
|
90
|
+
export function isolateTransplantedShell({ document, sourceElement, css, namespaceParts }) {
|
|
91
|
+
const clone = sourceElement.cloneNode(true);
|
|
92
|
+
const identityNodes = [clone, ...clone.querySelectorAll('[id]')].filter((element) => element.id);
|
|
93
|
+
const identities = new Map();
|
|
94
|
+
const namespace = unusedNamespace(document, namespaceParts);
|
|
95
|
+
for (const [index, element] of identityNodes.entries()) {
|
|
96
|
+
if (identities.has(element.id)) {
|
|
97
|
+
throw new Error(`Canonical shell isolation found duplicate donor id ${element.id}.`);
|
|
98
|
+
}
|
|
99
|
+
identities.set(element.id, `${namespace}-${index + 1}`);
|
|
100
|
+
}
|
|
101
|
+
rewriteIdentityReferences(clone, identities);
|
|
102
|
+
for (const element of identityNodes) {
|
|
103
|
+
const sourceId = element.id;
|
|
104
|
+
element.id = identities.get(sourceId);
|
|
105
|
+
element.setAttribute(SOURCE_ID_ATTRIBUTE, sourceId);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
css: rewriteHashIds(css, identities),
|
|
109
|
+
html: clone.outerHTML,
|
|
110
|
+
identities,
|
|
111
|
+
rootId: identities.get(sourceElement.id),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Resolve a logical donor id to the physical nodes emitted for this document or subtree. */
|
|
116
|
+
export function shellElementsForSourceId(root, sourceId) {
|
|
117
|
+
const isolated = isolatedElements(root, sourceId);
|
|
118
|
+
if (isolated.length > 0) return isolated;
|
|
119
|
+
const document = root.nodeType === 9 ? root : root.ownerDocument;
|
|
120
|
+
const original = document?.getElementById(sourceId) || null;
|
|
121
|
+
return original && (root.nodeType === 9 || root === original || root.contains(original)) ? [original] : [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Build a CSS selector that follows a logical donor identity through shell isolation. */
|
|
125
|
+
export function shellSelectorForSourceId(document, sourceId) {
|
|
126
|
+
const elements = shellElementsForSourceId(document, sourceId);
|
|
127
|
+
return (elements.length > 0 ? elements.map((element) => `#${element.id}`) : [`#${sourceId}`]).join(',');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Rewrite an additional donor rule set for a shell that was already isolated as part of an ancestor. */
|
|
131
|
+
export function cssForIsolatedShell(root, css) {
|
|
132
|
+
return rewriteHashIds(css, identitiesIn(root));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function rulesFor(css, id) {
|
|
136
|
+
// One selector per rule in these exports; retain rules owned by this shell id family.
|
|
137
|
+
const re = new RegExp(`#${escapeRegex(id)}(?:-[A-Za-z0-9_-]*)?(?:[:>\\s][^{]*)?\\{[^}]*\\}`, 'g');
|
|
138
|
+
return (css.match(re) || []).join('\n');
|
|
139
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { declaredActiveMarkers, linkedNavForUnit } from './nav-active-state.mjs';
|
|
2
2
|
import { needsCanonicalShell, shellForBand } from './shell-consistency.mjs';
|
|
3
|
+
import {
|
|
4
|
+
cssForIsolatedShell,
|
|
5
|
+
isolateTransplantedShell,
|
|
6
|
+
rulesFor,
|
|
7
|
+
shellElementsForSourceId,
|
|
8
|
+
} from './shell-identity.mjs';
|
|
3
9
|
import { shouldTransplantShell } from './shells.mjs';
|
|
4
10
|
import { normalizeText } from './text.mjs';
|
|
5
11
|
|
|
@@ -26,6 +32,7 @@ function markerSnapshots({ document, shell, markers, rulesFor, styleText }) {
|
|
|
26
32
|
if (!element) return [];
|
|
27
33
|
return [{
|
|
28
34
|
...marker,
|
|
35
|
+
element: element.cloneNode(true),
|
|
29
36
|
html: element.outerHTML,
|
|
30
37
|
css: rulesFor(css, marker.id),
|
|
31
38
|
item: shell.items.find((item) => item.id === marker.id),
|
|
@@ -34,20 +41,28 @@ function markerSnapshots({ document, shell, markers, rulesFor, styleText }) {
|
|
|
34
41
|
});
|
|
35
42
|
}
|
|
36
43
|
|
|
37
|
-
function removeMarkers(
|
|
38
|
-
for (const marker of markers)
|
|
44
|
+
function removeMarkers(shellElement, markers) {
|
|
45
|
+
for (const marker of markers) shellElementsForSourceId(shellElement, marker.id)[0]?.remove();
|
|
39
46
|
}
|
|
40
47
|
|
|
41
|
-
function placeMarker({ shellElement, shell, marker, targetItem, extraCss }) {
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
function placeMarker({ document, shellElement, shell, marker, targetItem, extraCss, namespaceParts }) {
|
|
49
|
+
const isolated = isolateTransplantedShell({
|
|
50
|
+
document,
|
|
51
|
+
sourceElement: marker.element,
|
|
52
|
+
css: marker.css,
|
|
53
|
+
namespaceParts,
|
|
54
|
+
});
|
|
55
|
+
shellElement.insertAdjacentHTML('beforeend', isolated.html);
|
|
56
|
+
if (isolated.css) extraCss.push(isolated.css);
|
|
57
|
+
const outputMarker = { ...marker, id: isolated.identities.get(marker.id) };
|
|
44
58
|
const sourceTarget = marker.targetItem;
|
|
45
59
|
const source = marker.item;
|
|
46
|
-
if (!source) return;
|
|
60
|
+
if (!source) return outputMarker;
|
|
47
61
|
const desiredX = targetItem && sourceTarget ? targetItem.x + (source.x - sourceTarget.x) : source.x;
|
|
48
62
|
const desiredY = targetItem && sourceTarget ? targetItem.y + (source.y - sourceTarget.y) : source.y;
|
|
49
|
-
extraCss.push(`#${
|
|
63
|
+
extraCss.push(`#${outputMarker.id}{position:absolute!important;left:${desiredX - shell.box[0]}px!important;top:${desiredY - shell.box[1]}px!important;`
|
|
50
64
|
+ `width:${source.w}px!important;height:${source.h}px!important;margin:0!important;translate:none!important}`);
|
|
65
|
+
return outputMarker;
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
function decisionForRegion({ unit, donorPage, recipientPage, region, canonicalShell }) {
|
|
@@ -83,7 +98,6 @@ export function stitchUnitShells({
|
|
|
83
98
|
unit,
|
|
84
99
|
canonicalShell,
|
|
85
100
|
navRows,
|
|
86
|
-
rulesFor,
|
|
87
101
|
styleText,
|
|
88
102
|
}) {
|
|
89
103
|
const extraCss = [];
|
|
@@ -142,31 +156,39 @@ export function stitchUnitShells({
|
|
|
142
156
|
const donorElement = donorDocument.getElementById(pair.canonical.containerId);
|
|
143
157
|
const root = bandRoot(document, pair.key);
|
|
144
158
|
const host = shellHost(root);
|
|
145
|
-
const alreadyPlaced =
|
|
159
|
+
const alreadyPlaced = root && shellElementsForSourceId(root, pair.canonical.containerId)[0];
|
|
146
160
|
if (!donorElement || !root || !host || (!root.contains(recipientElement) && !root.contains(alreadyPlaced))) {
|
|
147
161
|
throw new Error(`Canonical shell commit lost declared ${region} DOM in band ${pair.key}.`);
|
|
148
162
|
}
|
|
163
|
+
const donorAlreadyIsolated = alreadyPlaced && alreadyPlaced.id !== pair.canonical.containerId;
|
|
164
|
+
let outputShell = donorAlreadyIsolated ? alreadyPlaced : null;
|
|
149
165
|
if (recipientElement && root.contains(recipientElement)) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
recipientElement.
|
|
158
|
-
|
|
166
|
+
if (!donorAlreadyIsolated) {
|
|
167
|
+
const isolated = isolateTransplantedShell({
|
|
168
|
+
document,
|
|
169
|
+
sourceElement: donorElement,
|
|
170
|
+
css: rulesFor(styleText(donorDocument), pair.canonical.containerId),
|
|
171
|
+
namespaceParts: [pair.key, region],
|
|
172
|
+
});
|
|
173
|
+
recipientElement.insertAdjacentHTML('beforebegin', isolated.html);
|
|
174
|
+
outputShell = document.getElementById(isolated.rootId);
|
|
175
|
+
if (isolated.css) extraCss.push(isolated.css);
|
|
159
176
|
}
|
|
177
|
+
recipientElement.replaceChildren();
|
|
160
178
|
recipientElement.setAttribute('data-proto-shell-space', region);
|
|
161
179
|
recipientElement.setAttribute('aria-hidden', 'true');
|
|
162
180
|
extraCss.push(`#${recipientElement.id}{visibility:hidden!important;pointer-events:none!important}`);
|
|
163
181
|
}
|
|
164
|
-
outputShellId =
|
|
165
|
-
const outputShell = document.getElementById(outputShellId);
|
|
182
|
+
outputShellId = outputShell?.id || null;
|
|
166
183
|
if (!outputShell || !root.contains(outputShell)) {
|
|
167
184
|
throw new Error(`Canonical shell commit did not place ${region} in band ${pair.key}.`);
|
|
168
185
|
}
|
|
169
|
-
|
|
186
|
+
if (outputShell === alreadyPlaced) {
|
|
187
|
+
extraCss.push(cssForIsolatedShell(
|
|
188
|
+
outputShell,
|
|
189
|
+
rulesFor(styleText(donorDocument), pair.canonical.containerId),
|
|
190
|
+
));
|
|
191
|
+
}
|
|
170
192
|
const donorBand = donorPage.bands.find((band) => band.key === pair.key);
|
|
171
193
|
const parentShell = donorBand?.shells.find((candidate) => {
|
|
172
194
|
if (candidate.containerId === pair.canonical.containerId) return false;
|
|
@@ -180,21 +202,36 @@ export function stitchUnitShells({
|
|
|
180
202
|
pair.canonical.box[1] - parentShell.box[1],
|
|
181
203
|
]
|
|
182
204
|
: pair.canonical.box;
|
|
183
|
-
extraCss.push(`#${
|
|
205
|
+
extraCss.push(`#${outputShellId}{position:absolute!important;left:${x}px!important;top:${y}px!important;`
|
|
184
206
|
+ 'margin:0!important;z-index:100000!important}');
|
|
185
|
-
removeMarkers(
|
|
207
|
+
removeMarkers(outputShell, canonicalMarkers);
|
|
186
208
|
const matchingSource = linkedNav
|
|
187
209
|
? sourceSnapshots.find((marker) => normalizeText(marker.targetText) === normalizeText(linkedNav.text))
|
|
188
210
|
: null;
|
|
189
211
|
const markerToPlace = matchingSource || (linkedNav ? donorSnapshots[0] : null);
|
|
190
212
|
if (markerToPlace) {
|
|
191
|
-
placeMarker({
|
|
192
|
-
|
|
213
|
+
outputMarkers = [placeMarker({
|
|
214
|
+
document,
|
|
215
|
+
shellElement: outputShell,
|
|
216
|
+
shell: pair.canonical,
|
|
217
|
+
marker: markerToPlace,
|
|
218
|
+
targetItem,
|
|
219
|
+
extraCss,
|
|
220
|
+
namespaceParts: [pair.key, region, 'marker'],
|
|
221
|
+
})];
|
|
193
222
|
} else if (!linkedNav) {
|
|
223
|
+
outputMarkers = [];
|
|
194
224
|
for (const marker of sourceSnapshots) {
|
|
195
|
-
placeMarker({
|
|
225
|
+
outputMarkers.push(placeMarker({
|
|
226
|
+
document,
|
|
227
|
+
shellElement: outputShell,
|
|
228
|
+
shell: pair.canonical,
|
|
229
|
+
marker,
|
|
230
|
+
targetItem: null,
|
|
231
|
+
extraCss,
|
|
232
|
+
namespaceParts: [pair.key, region, 'marker'],
|
|
233
|
+
}));
|
|
196
234
|
}
|
|
197
|
-
outputMarkers = sourceSnapshots;
|
|
198
235
|
} else outputMarkers = [];
|
|
199
236
|
changes++;
|
|
200
237
|
}
|
|
@@ -203,6 +240,10 @@ export function stitchUnitShells({
|
|
|
203
240
|
|| outputMarkers[0]
|
|
204
241
|
|| null
|
|
205
242
|
: null;
|
|
243
|
+
const outputShell = document.getElementById(outputShellId);
|
|
244
|
+
const outputNavId = targetItem
|
|
245
|
+
? shellElementsForSourceId(outputShell || document, targetItem.id)[0]?.id || targetItem.id
|
|
246
|
+
: null;
|
|
206
247
|
bands.get(pair.key)?.regions.push({
|
|
207
248
|
region,
|
|
208
249
|
shellId: outputShellId,
|
|
@@ -210,8 +251,8 @@ export function stitchUnitShells({
|
|
|
210
251
|
consistencyStatus: decision.commit || decision.reason === 'canonical-page' ? 'canonical' : 'silent',
|
|
211
252
|
activeMarkerIds: outputMarkers.map((marker) => marker.id),
|
|
212
253
|
active: linkedNav && targetItem && activeMarker
|
|
213
|
-
? { status: 'linked', navText: linkedNav.text, navId:
|
|
214
|
-
: linkedNav ? { status: 'unproven', navText: linkedNav.text, navId:
|
|
254
|
+
? { status: 'linked', navText: linkedNav.text, navId: outputNavId, markerId: activeMarker.id }
|
|
255
|
+
: linkedNav ? { status: 'unproven', navText: linkedNav.text, navId: outputNavId, markerId: null }
|
|
215
256
|
: { status: 'silent', navText: null, navId: null, markerId: null },
|
|
216
257
|
changed: decision.commit,
|
|
217
258
|
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Real-pixel paint verification for section breathing.
|
|
2
|
+
//
|
|
3
|
+
// Ordinary seam pixels remain strict (owner colour +/-2). One measured exception is admitted: a neutral, monotone
|
|
4
|
+
// drop-shadow gradient may darken the top of the new air by at most 8 levels, must fade through at least three levels,
|
|
5
|
+
// may change by at most two levels per row, and must return to the owner colour within 20 px. This is the exact shared
|
|
6
|
+
// signature of the r10 probe-declined population; hue shifts, flat bands, hard lines, and floor exposure still fail.
|
|
7
|
+
import sharp from 'sharp';
|
|
8
|
+
|
|
9
|
+
export const STRICT_CHANNEL_TOLERANCE = 2;
|
|
10
|
+
export const SHADOW_MAX_CHANNEL_DELTA = 8;
|
|
11
|
+
export const SHADOW_MAX_FADE_PX = 20;
|
|
12
|
+
|
|
13
|
+
const parseRgb = (s) => {
|
|
14
|
+
const m = s && s.match(/rgba?\(([^)]+)\)/); if (!m) return null;
|
|
15
|
+
const p = m[1].split(/[, /]+/).filter(Boolean).map(Number);
|
|
16
|
+
return { r: p[0], g: p[1], b: p[2] };
|
|
17
|
+
};
|
|
18
|
+
const pixelAt = (data, info, x, y) => {
|
|
19
|
+
const i = (y * info.width + x) * info.channels;
|
|
20
|
+
return { r: data[i], g: data[i + 1], b: data[i + 2] };
|
|
21
|
+
};
|
|
22
|
+
const channelDistance = (a, b) => Math.max(Math.abs(a.r - b.r), Math.abs(a.g - b.g), Math.abs(a.b - b.b));
|
|
23
|
+
|
|
24
|
+
export function shadowGradientProfile(data, info, x, want) {
|
|
25
|
+
const last = Math.min(info.height - 1, SHADOW_MAX_FADE_PX);
|
|
26
|
+
const deltas = [];
|
|
27
|
+
for (let y = 0; y <= last; y++) {
|
|
28
|
+
const got = pixelAt(data, info, x, y);
|
|
29
|
+
const channels = [want.r - got.r, want.g - got.g, want.b - got.b];
|
|
30
|
+
if (channels.some((d) => d < 0 || d > SHADOW_MAX_CHANNEL_DELTA)) return { ok: false, reason: 'not-bounded-darkening', deltas };
|
|
31
|
+
if (Math.max(...channels) - Math.min(...channels) > 1) return { ok: false, reason: 'hue-shift', deltas };
|
|
32
|
+
deltas.push(Math.max(...channels));
|
|
33
|
+
}
|
|
34
|
+
const exactAt = deltas.findIndex((d) => d <= STRICT_CHANNEL_TOLERANCE);
|
|
35
|
+
if (deltas[0] <= STRICT_CHANNEL_TOLERANCE || exactAt < 0) return { ok: false, reason: 'does-not-fade', deltas };
|
|
36
|
+
if (new Set(deltas.slice(0, exactAt + 1)).size < 3) return { ok: false, reason: 'not-a-gradient', deltas };
|
|
37
|
+
for (let i = 1; i <= exactAt; i++) {
|
|
38
|
+
if (deltas[i] > deltas[i - 1] || deltas[i - 1] - deltas[i] > 2) return { ok: false, reason: 'not-monotone-soft', deltas };
|
|
39
|
+
}
|
|
40
|
+
if (deltas.slice(exactAt).some((d) => d > STRICT_CHANNEL_TOLERANCE)) return { ok: false, reason: 'returns-after-fade', deltas };
|
|
41
|
+
return { ok: true, exactAt, deltas };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function probeSeams(page, seams, baselineDocumentHeight) {
|
|
45
|
+
const failures = []; let probes = 0; let toleratedShadowGradients = 0;
|
|
46
|
+
const height = await page.evaluate(() => document.documentElement.scrollHeight);
|
|
47
|
+
const expected = (baselineDocumentHeight || 0) + seams.reduce((n, s) => n + s.addition, 0);
|
|
48
|
+
if (baselineDocumentHeight && Math.abs(height - expected) > 1) failures.push({ kind: 'document-height', expected, actual: height });
|
|
49
|
+
const floor = await page.evaluate(() => ({
|
|
50
|
+
body: getComputedStyle(document.body).backgroundColor,
|
|
51
|
+
html: getComputedStyle(document.documentElement).backgroundColor,
|
|
52
|
+
}));
|
|
53
|
+
for (const seam of seams) {
|
|
54
|
+
const geometry = await page.evaluate(({ before, after, attachments }) => {
|
|
55
|
+
const a = document.getElementById(before), b = document.getElementById(after);
|
|
56
|
+
if (!a || !b) return null;
|
|
57
|
+
const ar = a.getBoundingClientRect(), br = b.getBoundingClientRect();
|
|
58
|
+
const strip = { top: ar.bottom + window.scrollY, bottom: br.top + window.scrollY, left: ar.left + window.scrollX, width: ar.width };
|
|
59
|
+
const attachmentRects = (attachments || []).flatMap((expected) => {
|
|
60
|
+
const el = document.getElementById(expected.id); if (!el) return [];
|
|
61
|
+
const r = el.getBoundingClientRect();
|
|
62
|
+
return [{ ...expected, top: r.top + window.scrollY, bottom: r.bottom + window.scrollY, left: r.left + window.scrollX, right: r.right + window.scrollX }];
|
|
63
|
+
});
|
|
64
|
+
return { strip, attachmentRects };
|
|
65
|
+
}, seam);
|
|
66
|
+
if (!geometry) { failures.push({ kind: 'coverage', seam, reason: 'section missing' }); continue; }
|
|
67
|
+
const { strip, attachmentRects } = geometry;
|
|
68
|
+
const seamName = `${seam.before}→${seam.after}`;
|
|
69
|
+
const h = strip.bottom - strip.top; const expectedStripHeight = (seam.baseStripHeight || 0) + seam.addition;
|
|
70
|
+
if (Math.abs(h - expectedStripHeight) > 0.75) {
|
|
71
|
+
failures.push({ kind: 'coverage', seam: seamName, reason: `seam height ${h.toFixed(1)} != ${expectedStripHeight.toFixed(1)}` });
|
|
72
|
+
}
|
|
73
|
+
for (const expectedAttachment of seam.attachments || []) {
|
|
74
|
+
const actual = attachmentRects.find((r) => r.id === expectedAttachment.id);
|
|
75
|
+
if (!actual) { failures.push({ kind: 'attachment', seam: seamName, id: expectedAttachment.id, reason: 'attachment missing' }); continue; }
|
|
76
|
+
const topOffset = actual.top - strip.bottom, bottomOffset = actual.bottom - strip.bottom;
|
|
77
|
+
if (Math.abs(topOffset - expectedAttachment.topOffset) > 0.75 || Math.abs(bottomOffset - expectedAttachment.bottomOffset) > 0.75) {
|
|
78
|
+
failures.push({ kind: 'attachment', seam: seamName, id: expectedAttachment.id, expected: { topOffset: expectedAttachment.topOffset, bottomOffset: expectedAttachment.bottomOffset }, actual: { topOffset, bottomOffset } });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const clip = {
|
|
82
|
+
x: Math.max(0, strip.left), y: Math.max(0, Math.floor(strip.top)),
|
|
83
|
+
width: Math.max(1, Math.floor(strip.width)), height: Math.max(1, Math.floor(h)),
|
|
84
|
+
};
|
|
85
|
+
const png = await page.screenshot({ clip, fullPage: true });
|
|
86
|
+
const { data, info } = await sharp(png).raw().toBuffer({ resolveWithObject: true });
|
|
87
|
+
const want = parseRgb(seam.color); const floorRgb = parseRgb(floor.body) || parseRgb(floor.html);
|
|
88
|
+
const blocked = attachmentRects.map((r) => ({ left: r.left - strip.left, right: r.right - strip.left }));
|
|
89
|
+
const candidateX = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
|
|
90
|
+
.map((f) => Math.min(info.width - 1, Math.floor(f * info.width)))
|
|
91
|
+
.filter((x) => !blocked.some((r) => x >= r.left - 0.75 && x <= r.right + 0.75));
|
|
92
|
+
if (candidateX.length < 3) {
|
|
93
|
+
failures.push({ kind: 'attachment-coverage-unproven', seam: seamName, availableGroundColumns: candidateX.length });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const xs = [candidateX[0], candidateX[Math.floor((candidateX.length - 1) / 2)], candidateX[candidateX.length - 1]];
|
|
97
|
+
for (const x of xs) for (const fy of [0.15, 0.5, 0.85]) {
|
|
98
|
+
// Internal seams retain their original gap verbatim. Verify only the newly inserted air after that base strip;
|
|
99
|
+
// page seams have a zero-height base strip and therefore keep the original whole-strip sampling behavior.
|
|
100
|
+
const y = Math.min(info.height - 1, Math.floor((seam.baseStripHeight || 0) + fy * seam.addition));
|
|
101
|
+
const got = pixelAt(data, info, x, y);
|
|
102
|
+
const d = channelDistance(got, want); probes++;
|
|
103
|
+
if (d <= STRICT_CHANNEL_TOLERANCE) continue;
|
|
104
|
+
const isFloor = floorRgb && channelDistance(got, floorRgb) <= STRICT_CHANNEL_TOLERANCE;
|
|
105
|
+
if (!isFloor) {
|
|
106
|
+
const profile = shadowGradientProfile(data, info, x, want);
|
|
107
|
+
if (profile.ok) { toleratedShadowGradients++; continue; }
|
|
108
|
+
}
|
|
109
|
+
failures.push({
|
|
110
|
+
kind: isFloor ? 'floor-exposure' : 'coverage', seam: seamName, probe: { x, y }, got, want,
|
|
111
|
+
shadowProfile: isFloor ? null : shadowGradientProfile(data, info, x, want),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
ok: failures.length === 0, failures, probes, toleratedShadowGradients,
|
|
117
|
+
documentHeight: height, expectedHeight: expected,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Structure the harm guard protects, computed from two measurements of the same page (before / after a rule set):
|
|
2
|
+
// misaligned — a same-kind sibling pair whose left (top) edges were exactly aligned before and are not after
|
|
3
|
+
// (an icon nudged out of its column, a nav label pulled off its list edge);
|
|
4
|
+
// spacing — a consecutive sibling gap that changed by more than GAP_TOL px, unless one side was itself cut or
|
|
5
|
+
// escaped before (a stranded footer coming home is allowed to change its gap).
|
|
6
|
+
// Owner rule (2026-08-22): a repair that moves an icon out of its column or changes sibling spacing is harm.
|
|
7
|
+
export const GAP_TOL = 6;
|
|
8
|
+
|
|
9
|
+
export function structureHarm(before, after, exempt = new Set(), exemptGaps = new Set()) {
|
|
10
|
+
const colKey = (c) => `${c.axis}|${c.a}|${c.b}`;
|
|
11
|
+
const beforeCols = new Set((before.columns || []).map(colKey));
|
|
12
|
+
const afterCols = new Set((after.columns || []).map(colKey));
|
|
13
|
+
const misaligned = [...beforeCols].filter((k) => !afterCols.has(k));
|
|
14
|
+
const beforeGaps = new Map((before.gaps || []).map((g) => [`${g.axis}|${g.a}|${g.b}`, g.gap]));
|
|
15
|
+
const broken = new Set((before.overflow || []).filter((o) => o.kind === 'cut' || o.kind === 'escape').map((o) => o.id));
|
|
16
|
+
const spacing = (after.gaps || []).filter((g) => {
|
|
17
|
+
const b = beforeGaps.get(`${g.axis}|${g.a}|${g.b}`);
|
|
18
|
+
// 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)));
|
|
21
|
+
}).map((g) => ({ ...g, before: beforeGaps.get(`${g.axis}|${g.a}|${g.b}`) }));
|
|
22
|
+
return { misaligned, spacing };
|
|
23
|
+
}
|