@12ui/design 0.2.42 → 0.2.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +10 -5
  2. package/SKILL.md +1 -1
  3. package/dist/cli-branch-command.d.ts.map +1 -1
  4. package/dist/cli-branch-command.js +10 -2
  5. package/dist/cli-branch-command.js.map +1 -1
  6. package/dist/cli-capabilities.d.ts +1 -1
  7. package/dist/cli-capabilities.js +1 -1
  8. package/dist/cli-capabilities.js.map +1 -1
  9. package/dist/cli-prototype-command.d.ts.map +1 -1
  10. package/dist/cli-prototype-command.js +4 -1
  11. package/dist/cli-prototype-command.js.map +1 -1
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/prototype-gates.d.ts +8 -0
  17. package/dist/prototype-gates.d.ts.map +1 -1
  18. package/dist/prototype-gates.js +39 -5
  19. package/dist/prototype-gates.js.map +1 -1
  20. package/dist/prototype-label-client.d.ts +6 -1
  21. package/dist/prototype-label-client.d.ts.map +1 -1
  22. package/dist/prototype-label-client.js +21 -2
  23. package/dist/prototype-label-client.js.map +1 -1
  24. package/dist/prototype-poststep.d.ts.map +1 -1
  25. package/dist/prototype-poststep.js +5 -0
  26. package/dist/prototype-poststep.js.map +1 -1
  27. package/dist/prototype-revision.d.ts +2 -0
  28. package/dist/prototype-revision.d.ts.map +1 -0
  29. package/dist/prototype-revision.js +2 -0
  30. package/dist/prototype-revision.js.map +1 -0
  31. package/dist/prototype-run-record.d.ts +5 -1
  32. package/dist/prototype-run-record.d.ts.map +1 -1
  33. package/dist/prototype-run-record.js +89 -7
  34. package/dist/prototype-run-record.js.map +1 -1
  35. package/dist/prototype-runner.d.ts +4 -1
  36. package/dist/prototype-runner.d.ts.map +1 -1
  37. package/dist/prototype-runner.js +15 -9
  38. package/dist/prototype-runner.js.map +1 -1
  39. package/dist/run-journal.d.ts +6 -1
  40. package/dist/run-journal.d.ts.map +1 -1
  41. package/dist/run-journal.js +10 -2
  42. package/dist/run-journal.js.map +1 -1
  43. package/open-design.json +2 -2
  44. package/package.json +2 -2
  45. package/prototype-kit/build.mjs +88 -40
  46. package/prototype-kit/lib/actions.mjs +52 -0
  47. package/prototype-kit/lib/shell-identity.mjs +139 -0
  48. package/prototype-kit/lib/shell-stitching.mjs +70 -29
  49. package/prototype-kit/tests/adversarial-runtime.mjs +16 -1
  50. package/prototype-kit/tests/fixtures/blocking-runtime/broken-interaction.html +7 -0
  51. package/prototype-kit/tests/fixtures/four-dropdown/branch.plan.json +29 -0
  52. package/prototype-kit/tests/fixtures/four-dropdown/pages/p2-stream-proposals.html +10 -0
  53. package/prototype-kit/tests/fixtures/four-dropdown/pages/p4-live-stream-results.html +7 -0
  54. package/prototype-kit/tests/fixtures/shell-stitching-runtime/PROVENANCE.md +22 -0
  55. package/prototype-kit/tests/fixtures/shell-stitching-runtime/branch.plan.json +40 -0
  56. package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p0-donor.html +38 -0
  57. package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p1-id-collision.html +38 -0
  58. package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p2-canvas-control.html +39 -0
  59. package/prototype-kit/tests/offline-check.mjs +1 -1
  60. package/prototype-kit/tests/rules-check.mjs +71 -3
  61. package/prototype-kit/tests/shell-stitching-runtime.mjs +116 -0
@@ -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(document, markers) {
38
- for (const marker of markers) document.getElementById(marker.id)?.remove();
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
- shellElement.insertAdjacentHTML('beforeend', marker.html);
43
- if (marker.css) extraCss.push(marker.css);
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(`#${marker.id}{position:absolute!important;left:${desiredX - shell.box[0]}px!important;top:${desiredY - shell.box[1]}px!important;`
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 = document.getElementById(pair.canonical.containerId);
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
- const recipientId = recipientElement.id;
151
- recipientElement.insertAdjacentHTML('beforebegin', donorElement.outerHTML);
152
- recipientElement.replaceChildren();
153
- if (recipientId === pair.canonical.containerId) {
154
- const placeholderId = `${recipientId}-proto-shell-space`;
155
- const placeholderCss = rulesFor(styleText(document), recipientId)
156
- .replaceAll(`#${recipientId}`, `#${placeholderId}`);
157
- recipientElement.id = placeholderId;
158
- if (placeholderCss) extraCss.push(placeholderCss);
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 = pair.canonical.containerId;
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
- extraCss.push(rulesFor(styleText(donorDocument), pair.canonical.containerId));
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(`#${pair.canonical.containerId}{position:absolute!important;left:${x}px!important;top:${y}px!important;`
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(document, canonicalMarkers);
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({ shellElement: outputShell, shell: pair.canonical, marker: markerToPlace, targetItem, extraCss });
192
- outputMarkers = [{ ...markerToPlace, id: markerToPlace.id }];
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({ shellElement: outputShell, shell: pair.canonical, marker, targetItem: null, extraCss });
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: targetItem.id, markerId: activeMarker.id }
214
- : linkedNav ? { status: 'unproven', navText: linkedNav.text, navId: targetItem?.id || null, markerId: null }
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
  });
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
3
4
  import fs from 'node:fs';
5
+ import os from 'node:os';
4
6
  import path from 'node:path';
5
7
  import { chromium } from '@playwright/test';
6
8
  import { checkRuntime } from '../lib/runtime-check.mjs';
@@ -130,9 +132,19 @@ const interactionReachability = {
130
132
  ordinaryControl: interactionPage('ordinary-dropdown.html')?.dropdown,
131
133
  };
132
134
 
135
+ const blockingScratch = fs.mkdtempSync(path.join(os.tmpdir(), 'prototype-blocking-runtime-'));
136
+ const blockingAuditFile = path.join(blockingScratch, 'runtime-audit.json');
137
+ const blockingVerification = spawnSync(process.execPath, [
138
+ path.join(kitDir, 'verify-runtime.mjs'),
139
+ path.join(kitDir, 'tests', 'fixtures', 'blocking-runtime'),
140
+ blockingAuditFile,
141
+ ], { cwd: kitDir, encoding: 'utf8', timeout: 30_000 });
142
+ const blockingAudit = JSON.parse(fs.readFileSync(blockingAuditFile, 'utf8'));
143
+ fs.rmSync(blockingScratch, { recursive: true, force: true });
144
+
133
145
  await browser.close();
134
146
  await server.close();
135
- const result = { doubleBinding, nestedBinding, dropdownGuard, hoverStacking, deepLinkBack, accessibleBack, pageBack, holdingComposition, shellNavigation, interactionReachability };
147
+ const result = { doubleBinding, nestedBinding, dropdownGuard, hoverStacking, deepLinkBack, accessibleBack, pageBack, holdingComposition, shellNavigation, interactionReachability, blockingInteraction: { status: blockingVerification.status, failures: blockingAudit.interactionFailures } };
136
148
  if (process.argv.includes('--assert')) {
137
149
  assert.deepEqual(doubleBinding, { overlayRoots: 1, modals: 1 }, 'runtime must bind exactly once when injected twice');
138
150
  assert.deepEqual(nestedBinding, { modals: 1, menus: 0 }, 'the innermost action must stop a parent binding from also firing');
@@ -182,6 +194,9 @@ if (process.argv.includes('--assert')) {
182
194
  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');
183
195
  assert.ok(interactionReachability.occludedExactFailure?.point?.y < 700, 'must fire: the exact initially occluded target is measured outside the review panel');
184
196
  assert.equal(interactionReachability.ordinaryControl?.ok, true, 'must stay silent: an ordinary already-visible dropdown remains pointer reachable');
197
+ assert.notEqual(blockingVerification.status, 0, 'must fire: a claimed dropdown with broken pointer wiring still refuses publication');
198
+ 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
+ assert.deepEqual(blockingAudit.interactionFailures, ['broken-interaction.html'], 'must stay silent: the blocking fixture identifies only the broken page');
185
200
  }
186
201
  const json = JSON.stringify(result, null, 2) + '\n';
187
202
  if (outFile) fs.writeFileSync(outFile, json);
@@ -0,0 +1,7 @@
1
+ <!doctype html><html><head><meta charset="utf-8"><style>
2
+ html,body{margin:0;width:1536px;height:1024px}.control{position:absolute;left:120px;top:120px;width:220px;height:44px}.interceptor{position:fixed;inset:0;z-index:20;background:rgba(255,255,255,.01)}
3
+ </style></head><body>
4
+ <button id="broken-dropdown" class="control" data-proto-action="dropdown" data-proto-options='["One","Two","Three"]'>Menu</button>
5
+ <div class="interceptor" aria-hidden="true"></div>
6
+ <script id="proto-state" type="application/json">{"unit":"broken-interaction","tokens":{},"contentFile":null}</script>
7
+ </body></html>
@@ -0,0 +1,29 @@
1
+ {
2
+ "version": 1,
3
+ "scope": "site",
4
+ "concept": "Observed four-candidate dropdown degradation shape",
5
+ "pages": [
6
+ {
7
+ "index": 0,
8
+ "id": "p2-stream-proposals",
9
+ "title": "Stream proposals",
10
+ "surfaceType": "web_app",
11
+ "purpose": "Review proposed streams.",
12
+ "startsFromWinner": true,
13
+ "slots": ["b"]
14
+ },
15
+ {
16
+ "index": 1,
17
+ "id": "p4-live-stream-results",
18
+ "title": "Live stream results",
19
+ "surfaceType": "web_app",
20
+ "purpose": "Review live stream performance.",
21
+ "startsFromWinner": false,
22
+ "slots": ["e"]
23
+ }
24
+ ],
25
+ "screens": [
26
+ { "slot": "b", "pageIndex": 0, "content": "Stream proposal filters", "dependsOnSlot": null },
27
+ { "slot": "e", "pageIndex": 1, "content": "Live stream date filter", "dependsOnSlot": null }
28
+ ]
29
+ }
@@ -0,0 +1,10 @@
1
+ <!doctype html>
2
+ <html><head><meta charset="utf-8"><style>
3
+ html,body,#page-flow,#viewport-1-b{margin:0;width:1536px;height:1024px;font-family:Arial,sans-serif}
4
+ .filter{position:absolute;display:flex;align-items:center;justify-content:space-between;width:280px;height:44px}.text{font-size:16px}.icon{width:16px;height:16px}
5
+ #viewport-1-b-filters{left:80px;top:180px}#viewport-1-b-sort{left:80px;top:260px}#viewport-1-b-table-footer{left:80px;top:340px}
6
+ </style></head><body><main id="page-flow"><section id="viewport-1-b">
7
+ <button class="filter" id="viewport-1-b-filters"><span class="text" id="viewport-1-b-filters-device-button">All countries</span><span class="icon" id="viewport-1-b-filters-language-icon-2" aria-label="Chevron down">⌄</span></button>
8
+ <button class="filter" id="viewport-1-b-sort"><span class="text" id="viewport-1-b-sort-text">Sort by</span><span class="icon" id="viewport-1-b-sort-icon" aria-label="Chevron down">⌄</span></button>
9
+ <button class="filter" id="viewport-1-b-table-footer"><span class="text" id="viewport-1-b-table-footer-text-2">Compare</span><span class="icon" id="viewport-1-b-table-footer-icon" aria-label="Chevron down">⌄</span></button>
10
+ </section></main></body></html>
@@ -0,0 +1,7 @@
1
+ <!doctype html>
2
+ <html><head><meta charset="utf-8"><style>
3
+ html,body,#page-flow,#viewport-1-e{margin:0;width:1536px;height:1024px;font-family:Arial,sans-serif}
4
+ .filter{position:absolute;left:80px;top:180px;display:flex;align-items:center;justify-content:space-between;width:460px;height:44px}.text{font-size:16px}.icon{width:16px;height:16px}
5
+ </style></head><body><main id="page-flow"><section id="viewport-1-e">
6
+ <button class="filter" id="viewport-1-e-main-date-filter"><span class="text" id="viewport-1-e-main-date-filter-text">compared to Apr 6 – May 5, 2025</span><span class="icon" id="viewport-1-e-main-date-filter-icon-3" aria-label="Chevron down">⌄</span></button>
7
+ </section></main></body></html>
@@ -0,0 +1,22 @@
1
+ # Responsive shell-stitching runtime fixture
2
+
3
+ This reduced three-page branch preserves the deterministic mechanism found by
4
+ the 2026-08-25 b13 header-signature refusal study. The full artifact's canonical
5
+ Central Station Departures and stitched Network Map pages had byte-identical
6
+ `#mobile-390-header` DOM and identical outer geometry `[0, 0, 390, 72.5]`, but
7
+ their runtime signatures differed:
8
+
9
+ - donor: `47b710c2aee9240892128ad7271eb0451792db7d8b1c1650c36bc076770ca088`;
10
+ - recipient: `5f1aae89d7c2083b27edc7c50e265bb844ed2760722b8dccea91a8c07ab4fbf9`.
11
+
12
+ The recipient stylesheet still addressed the reused `mobile-390-header-*` ids.
13
+ Its `left` and `top` declarations therefore survived the later, property-partial
14
+ donor rules and displaced donor descendants. The fixture keeps that exact
15
+ boundary: the donor omits `left`/`top`, while the recipient declares them on the
16
+ same responsive ids. Native shell identities stay distinct so the failure is
17
+ isolated to the 390px band.
18
+
19
+ The third page is the paired silent control. It renders the donor shell but has
20
+ different product content and a substantially taller portrait canvas. Its shell
21
+ signature must remain equal to the donor's because canvas and body variation are
22
+ outside the declared shell subtree.
@@ -0,0 +1,40 @@
1
+ {
2
+ "version": 1,
3
+ "scope": "site",
4
+ "concept": "Canonical responsive shell CSS isolation fixture",
5
+ "sitePurpose": "Exercise one donor shell, one colliding recipient, and one canvas-only control.",
6
+ "pages": [
7
+ {
8
+ "index": 0,
9
+ "id": "p0-donor",
10
+ "title": "Departures",
11
+ "surfaceType": "web_app",
12
+ "purpose": "Provide the canonical responsive header.",
13
+ "startsFromWinner": true,
14
+ "slots": ["a"]
15
+ },
16
+ {
17
+ "index": 1,
18
+ "id": "p1-id-collision",
19
+ "title": "Network map",
20
+ "surfaceType": "web_app",
21
+ "purpose": "Reuse responsive header ids under recipient-specific offsets.",
22
+ "startsFromWinner": false,
23
+ "slots": ["b"]
24
+ },
25
+ {
26
+ "index": 2,
27
+ "id": "p2-canvas-control",
28
+ "title": "Service notes",
29
+ "surfaceType": "web_app",
30
+ "purpose": "Keep the canonical header while changing content and canvas height.",
31
+ "startsFromWinner": false,
32
+ "slots": ["c"]
33
+ }
34
+ ],
35
+ "screens": [
36
+ { "slot": "a", "pageIndex": 0, "content": "Departures content", "dependsOnSlot": null },
37
+ { "slot": "b", "pageIndex": 1, "content": "Network map content", "dependsOnSlot": null },
38
+ { "slot": "c", "pageIndex": 2, "content": "Long service notes content", "dependsOnSlot": null }
39
+ ]
40
+ }
@@ -0,0 +1,38 @@
1
+ <!doctype html>
2
+ <html><head><meta charset="utf-8"><style>
3
+ html,body,#page-flow{margin:0;width:100%;min-height:100%;font-family:Arial,sans-serif;background:#eef2f4}.ly-band{display:none}.generated-screen,.ly-anchor-canvas{position:relative}.generated-screen{width:100%}.text{margin:0}.fixture-shell{box-sizing:border-box;color:#f7fafc;background:#071923}
4
+ @media(max-width:959px){.ly-band[data-ly-band="mobile-390"]{display:block}}
5
+ @media(min-width:960px){.ly-band[data-ly-band="native"]{display:block}}
6
+ #donor-mobile-canvas{width:390px;height:900px;background:#dce7eb}
7
+ #mobile-390-header{position:relative;display:flex;align-items:flex-start;margin:0;width:390px;height:72px;background:#071923}
8
+ #mobile-390-header-brand{position:relative;margin:20px 0 0 20px;width:100px;height:28px}
9
+ #mobile-390-header-time{position:relative;margin:20px 0 0 90px;width:80px;height:32px}
10
+ #mobile-390-header-menu{position:relative;margin:24px 0 0 25px;width:24px;height:24px}
11
+ #mobile-390-header-divider{position:absolute;left:0;bottom:0;width:390px;height:2px;background:#3e5e6d}
12
+ #mobile-390-header-brand-text{font-size:16px;line-height:28px;font-weight:700}
13
+ #mobile-390-header-time-text{font-size:14px;line-height:16px;font-weight:600}
14
+ #mobile-390-header-time-live{font-size:11px;line-height:16px;color:#b6f437}
15
+ #mobile-390-header-menu-icon{display:block;width:24px;height:24px;font-size:20px;line-height:24px;text-align:center}
16
+ #donor-mobile-content{padding:44px 24px;color:#17252c}
17
+ #viewport-1-a{position:relative;width:1536px;height:1024px;background:#eef2f4}
18
+ #viewport-1-a-header{position:absolute;left:0;top:0;display:flex;align-items:center;width:1536px;height:72px}
19
+ #viewport-1-a-header-brand{margin-left:32px;font-size:16px;line-height:24px;font-weight:700}
20
+ #viewport-1-a-header-status{margin-left:1200px;font-size:14px;line-height:24px}
21
+ #viewport-1-a-content{position:absolute;left:80px;top:150px;width:800px;height:400px;color:#17252c}
22
+ </style></head><body><main id="page-flow">
23
+ <div class="ly-band" data-ly-band="mobile-390"><div class="generated-screen"><div id="donor-mobile-canvas" class="ly-anchor-canvas">
24
+ <header id="mobile-390-header" class="fixture-shell">
25
+ <div id="mobile-390-header-brand"><p id="mobile-390-header-brand-text" class="text">Kinetic</p></div>
26
+ <div id="mobile-390-header-time"><p id="mobile-390-header-time-text" class="text">09:42</p><p id="mobile-390-header-time-live" class="text">Live</p></div>
27
+ <nav id="mobile-390-header-menu"><span id="mobile-390-header-menu-icon" class="icon" role="img" aria-label="Menu">≡</span></nav>
28
+ <div id="mobile-390-header-divider" class="divider"></div>
29
+ </header>
30
+ <article id="donor-mobile-content"><h1>Central departures</h1><p>Two platforms are boarding.</p></article>
31
+ </div></div></div>
32
+ <div class="ly-band" data-ly-band="native"><div class="generated-screen"><div class="ly-anchor-canvas">
33
+ <section id="viewport-1-a">
34
+ <header id="viewport-1-a-header" class="fixture-shell"><p id="viewport-1-a-header-brand" class="text">Kinetic</p><p id="viewport-1-a-header-status" class="text">Live service</p></header>
35
+ <div id="viewport-1-a-content"><h1 id="viewport-1-a-content-title">Central departures</h1><p id="viewport-1-a-content-copy">Two platforms are boarding.</p></div>
36
+ </section>
37
+ </div></div></div>
38
+ </main></body></html>
@@ -0,0 +1,38 @@
1
+ <!doctype html>
2
+ <html><head><meta charset="utf-8"><style>
3
+ html,body,#page-flow{margin:0;width:100%;min-height:100%;font-family:Arial,sans-serif;background:#eef2f4}.ly-band{display:none}.generated-screen,.ly-anchor-canvas{position:relative}.generated-screen{width:100%}.text{margin:0}.fixture-shell{box-sizing:border-box;color:#f7fafc;background:#071923}
4
+ @media(max-width:959px){.ly-band[data-ly-band="mobile-390"]{display:block}}
5
+ @media(min-width:960px){.ly-band[data-ly-band="native"]{display:block}}
6
+ #collision-mobile-canvas{width:390px;height:900px;background:#d9e4e8}
7
+ #mobile-390-header{position:relative;margin:0;width:390px;height:72px;background:#0a202b}
8
+ #mobile-390-header-brand{position:absolute;left:25px;top:22px;width:104px;height:30px}
9
+ #mobile-390-header-time{position:absolute;left:215px;top:19px;width:82px;height:34px}
10
+ #mobile-390-header-menu{position:absolute;left:340px;top:23px;width:26px;height:26px}
11
+ #mobile-390-header-divider{position:absolute;left:0;top:68px;width:390px;height:2px;background:#476b7a}
12
+ #mobile-390-header-brand-text{font-size:16px;line-height:28px;font-weight:700}
13
+ #mobile-390-header-time-text{font-size:14px;line-height:16px;font-weight:600}
14
+ #mobile-390-header-time-live{font-size:11px;line-height:16px;color:#b6f437}
15
+ #mobile-390-header-menu-icon{display:block;width:24px;height:24px;font-size:20px;line-height:24px;text-align:center}
16
+ #collision-mobile-content{padding:70px 36px;color:#17252c}
17
+ #viewport-1-b{position:relative;width:1536px;height:1024px;background:#eef2f4}
18
+ #viewport-1-b-header{position:absolute;left:0;top:0;display:flex;align-items:center;width:1536px;height:72px}
19
+ #viewport-1-b-header-brand{margin-left:32px;font-size:16px;line-height:24px;font-weight:700}
20
+ #viewport-1-b-header-status{margin-left:1200px;font-size:14px;line-height:24px}
21
+ #viewport-1-b-content{position:absolute;left:110px;top:180px;width:900px;height:420px;color:#17252c}
22
+ </style></head><body><main id="page-flow">
23
+ <div class="ly-band" data-ly-band="mobile-390"><div class="generated-screen"><div id="collision-mobile-canvas" class="ly-anchor-canvas">
24
+ <header id="mobile-390-header" class="fixture-shell">
25
+ <div id="mobile-390-header-brand"><p id="mobile-390-header-brand-text" class="text">Kinetic</p></div>
26
+ <div id="mobile-390-header-time"><p id="mobile-390-header-time-text" class="text">09:42</p><p id="mobile-390-header-time-live" class="text">Live</p></div>
27
+ <nav id="mobile-390-header-menu"><span id="mobile-390-header-menu-icon" class="icon" role="img" aria-label="Menu">≡</span></nav>
28
+ <div id="mobile-390-header-divider" class="divider"></div>
29
+ </header>
30
+ <article id="collision-mobile-content"><h1>Network map</h1><p>Choose a station to inspect its connections.</p></article>
31
+ </div></div></div>
32
+ <div class="ly-band" data-ly-band="native"><div class="generated-screen"><div class="ly-anchor-canvas">
33
+ <section id="viewport-1-b">
34
+ <header id="viewport-1-b-header" class="fixture-shell"><p id="viewport-1-b-header-brand" class="text">Kinetic</p><p id="viewport-1-b-header-status" class="text">Live service</p></header>
35
+ <div id="viewport-1-b-content"><h1 id="viewport-1-b-content-title">Network map</h1><p id="viewport-1-b-content-copy">Choose a station to inspect its connections.</p></div>
36
+ </section>
37
+ </div></div></div>
38
+ </main></body></html>
@@ -0,0 +1,39 @@
1
+ <!doctype html>
2
+ <html><head><meta charset="utf-8"><style>
3
+ html,body,#page-flow{margin:0;width:100%;min-height:100%;font-family:Arial,sans-serif;background:#f3eee6}.ly-band{display:none}.generated-screen,.ly-anchor-canvas{position:relative}.generated-screen{width:100%}.text{margin:0}.fixture-shell{box-sizing:border-box;color:#f7fafc;background:#071923}
4
+ @media(max-width:959px){.ly-band[data-ly-band="mobile-390"]{display:block}}
5
+ @media(min-width:960px){.ly-band[data-ly-band="native"]{display:block}}
6
+ #control-mobile-canvas{width:390px;height:1400px;background:#f3eee6}
7
+ #mobile-390-header{position:relative;display:flex;align-items:flex-start;margin:0;width:390px;height:72px;background:#071923}
8
+ #mobile-390-header-brand{position:relative;margin:20px 0 0 20px;width:100px;height:28px}
9
+ #mobile-390-header-time{position:relative;margin:20px 0 0 90px;width:80px;height:32px}
10
+ #mobile-390-header-menu{position:relative;margin:24px 0 0 25px;width:24px;height:24px}
11
+ #mobile-390-header-divider{position:absolute;left:0;bottom:0;width:390px;height:2px;background:#3e5e6d}
12
+ #mobile-390-header-brand-text{font-size:16px;line-height:28px;font-weight:700}
13
+ #mobile-390-header-time-text{font-size:14px;line-height:16px;font-weight:600}
14
+ #mobile-390-header-time-live{font-size:11px;line-height:16px;color:#b6f437}
15
+ #mobile-390-header-menu-icon{display:block;width:24px;height:24px;font-size:20px;line-height:24px;text-align:center}
16
+ #control-mobile-content{padding:96px 28px;color:#30271f}
17
+ #control-mobile-content section{margin-top:180px;padding:28px;background:#fff;border-radius:16px}
18
+ #viewport-1-c{position:relative;width:1536px;height:1400px;background:#f3eee6}
19
+ #viewport-1-c-header{position:absolute;left:0;top:0;display:flex;align-items:center;width:1536px;height:72px}
20
+ #viewport-1-c-header-brand{margin-left:32px;font-size:16px;line-height:24px;font-weight:700}
21
+ #viewport-1-c-header-status{margin-left:1200px;font-size:14px;line-height:24px}
22
+ #viewport-1-c-content{position:absolute;left:140px;top:220px;width:1000px;height:900px;color:#30271f}
23
+ </style></head><body><main id="page-flow">
24
+ <div class="ly-band" data-ly-band="mobile-390"><div class="generated-screen"><div id="control-mobile-canvas" class="ly-anchor-canvas">
25
+ <header id="mobile-390-header" class="fixture-shell">
26
+ <div id="mobile-390-header-brand"><p id="mobile-390-header-brand-text" class="text">Kinetic</p></div>
27
+ <div id="mobile-390-header-time"><p id="mobile-390-header-time-text" class="text">09:42</p><p id="mobile-390-header-time-live" class="text">Live</p></div>
28
+ <nav id="mobile-390-header-menu"><span id="mobile-390-header-menu-icon" class="icon" role="img" aria-label="Menu">≡</span></nav>
29
+ <div id="mobile-390-header-divider" class="divider"></div>
30
+ </header>
31
+ <article id="control-mobile-content"><h1>Service notes</h1><p>This deliberately taller route has unrelated portrait content.</p><section><h2>Maintenance archive</h2><p>Older notices continue far below the shared shell.</p></section></article>
32
+ </div></div></div>
33
+ <div class="ly-band" data-ly-band="native"><div class="generated-screen"><div class="ly-anchor-canvas">
34
+ <section id="viewport-1-c">
35
+ <header id="viewport-1-c-header" class="fixture-shell"><p id="viewport-1-c-header-brand" class="text">Kinetic</p><p id="viewport-1-c-header-status" class="text">Live service</p></header>
36
+ <div id="viewport-1-c-content"><h1 id="viewport-1-c-content-title">Service notes</h1><p id="viewport-1-c-content-copy">This route has substantially more content and a taller canvas.</p></div>
37
+ </section>
38
+ </div></div></div>
39
+ </main></body></html>
@@ -44,7 +44,7 @@ const outputDigest = (directory) => {
44
44
  };
45
45
  assert.equal(
46
46
  outputDigest(threeShell.protoDir),
47
- '5168cc6926988d3bee32d5e890792e989417d9ca78376282f988ebf7875a57bc',
47
+ '0c2147df5bc6552016cc278faec7abaabecbc7f5945cad7e1ac9b1df6e554403',
48
48
  'must stay silent: review support leaves the accepted shell/navigation complete output byte-identical',
49
49
  );
50
50
  const threeShellModel = JSON.parse(fs.readFileSync(threeShell.modelFile, 'utf8'));
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import assert from 'node:assert/strict';
3
3
  import { parseHTML } from 'linkedom';
4
- import { usableDropdownOptions } from '../lib/actions.mjs';
4
+ import { resolveDropdownAction, suppressDeterministicAction, usableDropdownOptions } from '../lib/actions.mjs';
5
5
  import { restoreActionPointerReachability } from '../lib/action-target.mjs';
6
6
  import { declaredActiveMarkers, linkedNavForUnit, markerOverlapsNav } from '../lib/nav-active-state.mjs';
7
7
  import { matchPlannedPage } from '../lib/navigation.mjs';
8
8
  import { needsCanonicalShell, selectCanonicalShellPage } from '../lib/shell-consistency.mjs';
9
+ import { isolateTransplantedShell, shellElementsForSourceId } from '../lib/shell-identity.mjs';
9
10
  import { stitchUnitShells } from '../lib/shell-stitching.mjs';
10
11
  import { compareCanonicalShells, isWithinShell, qualifiesShellCandidate, selectOneShellPerRegion, shouldSuppressDroppedShell, shouldTransplantShell } from '../lib/shells.mjs';
11
12
  import { slugText } from '../lib/text.mjs';
@@ -25,6 +26,39 @@ assert.equal(matchPlannedPage('التقارير', [{ stateId: 'reports-ar', titl
25
26
  assert.notEqual(slugText('التقارير'), slugText('الإعدادات'), 'must stay silent: distinct non-Latin holdings do not collide');
26
27
  assert.equal(usableDropdownOptions(['Only choice']), null, 'must fire: a one-option fallback is not bound as a fake dropdown');
27
28
  assert.deepEqual(usableDropdownOptions(['All', 'Open', 'Closed']), ['All', 'Open', 'Closed'], 'must stay silent: a real three-option dropdown remains bindable');
29
+ assert.equal(suppressDeterministicAction({ action: 'none' }), true, 'must fire: a recovered explicit none suppresses an unsafe deterministic dropdown');
30
+ assert.equal(suppressDeterministicAction({ action: 'dropdown', options: ['All', 'Open', 'Closed'] }), false, 'must stay silent: a valid labeled dropdown remains bindable');
31
+ assert.deepEqual(resolveDropdownAction({
32
+ deterministic: { action: 'dropdown' },
33
+ label: { action: 'none' },
34
+ labelId: 'filter-label',
35
+ }), {
36
+ disposition: 'static',
37
+ degradation: {
38
+ code: 'label_none_overrode_deterministic_dropdown',
39
+ reason: 'explicit-label-none',
40
+ fromAction: 'dropdown',
41
+ toAction: 'none',
42
+ options: [],
43
+ },
44
+ }, 'must fire: normalized none becomes one recorded static dropdown policy outcome');
45
+ assert.deepEqual(resolveDropdownAction({
46
+ deterministic: { action: 'dropdown' },
47
+ label: { action: 'dropdown', options: ['All', 'Open', 'Closed'] },
48
+ labelId: 'filter-label',
49
+ }), {
50
+ disposition: 'dropdown',
51
+ action: { action: 'dropdown', labelId: 'filter-label', options: ['All', 'Open', 'Closed'] },
52
+ }, 'must stay silent: a proven three-option dropdown remains actionable');
53
+ assert.equal(
54
+ resolveDropdownAction({
55
+ deterministic: { action: 'dropdown' },
56
+ label: { action: 'modal', options: null },
57
+ labelId: 'filter-label',
58
+ }).degradation.code,
59
+ 'dropdown_options_incomplete',
60
+ 'must fire: a deterministic dropdown with no usable label options is recorded and kept static',
61
+ );
28
62
 
29
63
  const unreachableDropdown = parseHTML('<span id="viewport-1-c-journeys-harbourfront-source-row-1-icon" style="width:29.696px;height:11.264px;pointer-events:none">...</span>').document.querySelector('span');
30
64
  assert.equal(restoreActionPointerReachability(unreachableDropdown), true, 'must fire: the exact failed Saved Journeys dropdown target becomes pointer reachable');
@@ -104,6 +138,34 @@ assert.equal(linkedNavForUnit({ navRows: [{ region: 'header', text: 'Journal', t
104
138
 
105
139
  const rulesForFixture = () => '';
106
140
  const styleTextFixture = () => '';
141
+ const identityRecipient = parseHTML('<html><body><div id="donor-shell"><span id="donor-shell-label"></span></div></body></html>').document;
142
+ const identityDonor = parseHTML('<html><body><section id="donor-shell" aria-labelledby="donor-shell-label"><label id="donor-shell-label" for="donor-shell-field">Name</label><input id="donor-shell-field"><svg id="donor-shell-icon" aria-labelledby="donor-shell-label"><use id="donor-shell-icon-use" href="#donor-shell-icon"></use></svg></section></body></html>').document;
143
+ const isolatedIdentity = isolateTransplantedShell({
144
+ document: identityRecipient,
145
+ sourceElement: identityDonor.getElementById('donor-shell'),
146
+ css: '#donor-shell{display:block}#donor-shell-label{color:red}#donor-shell-icon-use{fill:url(#donor-shell-icon)}',
147
+ namespaceParts: ['mobile-390', 'header'],
148
+ });
149
+ const isolatedIdentityDocument = parseHTML(`<html><body>${isolatedIdentity.html}</body></html>`).document;
150
+ const isolatedIdentityRoot = isolatedIdentityDocument.getElementById(isolatedIdentity.rootId);
151
+ const isolatedIdentityLabel = shellElementsForSourceId(isolatedIdentityRoot, 'donor-shell-label')[0];
152
+ const isolatedIdentityField = shellElementsForSourceId(isolatedIdentityRoot, 'donor-shell-field')[0];
153
+ const isolatedIdentityIcon = shellElementsForSourceId(isolatedIdentityRoot, 'donor-shell-icon')[0];
154
+ assert.notEqual(isolatedIdentity.rootId, 'donor-shell', 'must fire: the physical donor root cannot reuse a recipient identity');
155
+ assert.equal(isolatedIdentityRoot.getAttribute('aria-labelledby'), isolatedIdentityLabel.id, 'must fire: ARIA id references follow isolated identities');
156
+ assert.equal(isolatedIdentityLabel.getAttribute('for'), isolatedIdentityField.id, 'must fire: form id references follow isolated identities');
157
+ assert.equal(isolatedIdentityRoot.querySelector('use').getAttribute('href'), `#${isolatedIdentityIcon.id}`, 'must fire: fragment references follow isolated identities');
158
+ assert.ok(isolatedIdentity.css.includes(`#${isolatedIdentityLabel.id}{color:red}`), 'must fire: exact donor CSS selectors follow isolated identities');
159
+ assert.ok(isolatedIdentity.css.includes(`url(#${isolatedIdentityIcon.id})`), 'must fire: CSS fragment references follow isolated identities');
160
+ assert.equal(isolatedIdentity.css.includes('#donor-shell'), false, 'must stay silent: no donor id selector remains available to recipient CSS');
161
+ const duplicateDonor = parseHTML('<html><body><section id="duplicate-shell"><span id="duplicate-child"></span><span id="duplicate-child"></span></section></body></html>').document;
162
+ assert.throws(() => isolateTransplantedShell({
163
+ document: identityRecipient,
164
+ sourceElement: duplicateDonor.getElementById('duplicate-shell'),
165
+ css: '',
166
+ namespaceParts: ['native', 'header'],
167
+ }), /duplicate donor id duplicate-child/, 'must fire: invalid donor identity cannot be ambiguously namespaced');
168
+
107
169
  const noWriteDocument = parseHTML('<html><head></head><body><header id="detail-header"><span id="detail-home">Home</span><img id="detail-ink" alt="Home navigation underline"></header></body></html>').document;
108
170
  const noWriteBefore = noWriteDocument.documentElement.outerHTML;
109
171
  const noWriteResult = stitchUnitShells({
@@ -133,8 +195,14 @@ const fireResult = stitchUnitShells({
133
195
  styleText: styleTextFixture,
134
196
  });
135
197
  assert.equal(fireResult.changes, 1, 'must fire: a measured incompatible fingerprint commits the declared donor shell');
136
- assert.ok(fireDocument.getElementById('journal-header'), 'must fire: the canonical donor outer shell replaces the recipient shell');
137
- assert.ok(fireDocument.getElementById('menu-ink'), 'must fire: the page-declared Menu underline is retained and moved into the donor shell');
198
+ const isolatedHeader = shellElementsForSourceId(fireDocument, 'journal-header')[0];
199
+ const isolatedMarker = shellElementsForSourceId(fireDocument, 'menu-ink')[0];
200
+ assert.ok(isolatedHeader, 'must fire: the canonical donor outer shell replaces the recipient shell');
201
+ assert.notEqual(isolatedHeader.id, 'journal-header', 'must fire: transplanted donor identity is physically isolated from recipient CSS');
202
+ assert.ok(isolatedMarker && isolatedHeader.contains(isolatedMarker), 'must fire: the page-declared Menu underline is retained and moved into the isolated donor shell');
203
+ assert.equal(fireDocument.getElementById('menu-header')?.getAttribute('data-proto-shell-space'), 'header', 'must stay silent: the recipient shell remains an invisible flow spacer under its own identity');
138
204
  assert.equal(fireResult.bands[0].regions[0].active.navText, 'Menu');
205
+ assert.equal(fireResult.bands[0].regions[0].shellId, isolatedHeader.id);
206
+ assert.equal(fireResult.bands[0].regions[0].active.markerId, isolatedMarker.id);
139
207
 
140
208
  process.stdout.write('rules: ok\n');