@12ui/design 0.2.43 → 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 (56) 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 +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/prototype-gates.d.ts +8 -0
  16. package/dist/prototype-gates.d.ts.map +1 -1
  17. package/dist/prototype-gates.js +39 -5
  18. package/dist/prototype-gates.js.map +1 -1
  19. package/dist/prototype-poststep.d.ts.map +1 -1
  20. package/dist/prototype-poststep.js +5 -0
  21. package/dist/prototype-poststep.js.map +1 -1
  22. package/dist/prototype-revision.d.ts +2 -0
  23. package/dist/prototype-revision.d.ts.map +1 -0
  24. package/dist/prototype-revision.js +2 -0
  25. package/dist/prototype-revision.js.map +1 -0
  26. package/dist/prototype-run-record.d.ts +5 -1
  27. package/dist/prototype-run-record.d.ts.map +1 -1
  28. package/dist/prototype-run-record.js +89 -7
  29. package/dist/prototype-run-record.js.map +1 -1
  30. package/dist/prototype-runner.d.ts +4 -1
  31. package/dist/prototype-runner.d.ts.map +1 -1
  32. package/dist/prototype-runner.js +14 -8
  33. package/dist/prototype-runner.js.map +1 -1
  34. package/dist/run-journal.d.ts +6 -1
  35. package/dist/run-journal.d.ts.map +1 -1
  36. package/dist/run-journal.js +10 -2
  37. package/dist/run-journal.js.map +1 -1
  38. package/open-design.json +2 -2
  39. package/package.json +2 -2
  40. package/prototype-kit/build.mjs +86 -43
  41. package/prototype-kit/lib/actions.mjs +48 -0
  42. package/prototype-kit/lib/shell-identity.mjs +139 -0
  43. package/prototype-kit/lib/shell-stitching.mjs +70 -29
  44. package/prototype-kit/tests/adversarial-runtime.mjs +16 -1
  45. package/prototype-kit/tests/fixtures/blocking-runtime/broken-interaction.html +7 -0
  46. package/prototype-kit/tests/fixtures/four-dropdown/branch.plan.json +29 -0
  47. package/prototype-kit/tests/fixtures/four-dropdown/pages/p2-stream-proposals.html +10 -0
  48. package/prototype-kit/tests/fixtures/four-dropdown/pages/p4-live-stream-results.html +7 -0
  49. package/prototype-kit/tests/fixtures/shell-stitching-runtime/PROVENANCE.md +22 -0
  50. package/prototype-kit/tests/fixtures/shell-stitching-runtime/branch.plan.json +40 -0
  51. package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p0-donor.html +38 -0
  52. package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p1-id-collision.html +38 -0
  53. package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p2-canvas-control.html +39 -0
  54. package/prototype-kit/tests/offline-check.mjs +1 -1
  55. package/prototype-kit/tests/rules-check.mjs +69 -3
  56. package/prototype-kit/tests/shell-stitching-runtime.mjs +116 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12ui/design",
3
- "version": "0.2.43",
3
+ "version": "0.2.44",
4
4
  "type": "module",
5
5
  "description": "The canonical 12ui CLI, SDK, MCP server, and visual interface design skills",
6
6
  "main": "dist/index.js",
@@ -23,7 +23,7 @@
23
23
  "start": "node dist/cli.js",
24
24
  "test": "vitest",
25
25
  "test:run": "vitest run",
26
- "test:prototype": "node prototype-kit/tests/rules-check.mjs && node prototype-kit/tests/offline-check.mjs && node prototype-kit/tests/adversarial-runtime.mjs --assert",
26
+ "test:prototype": "node prototype-kit/tests/rules-check.mjs && node prototype-kit/tests/offline-check.mjs && node prototype-kit/tests/shell-stitching-runtime.mjs && node prototype-kit/tests/adversarial-runtime.mjs --assert",
27
27
  "typecheck": "tsc --noEmit && tsc -p tsconfig.type-tests.json",
28
28
  "prepublishOnly": "npm run build"
29
29
  },
@@ -3,7 +3,7 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { parseHTML } from 'linkedom';
6
- import { suppressDeterministicAction, usableDropdownOptions } from './lib/actions.mjs';
6
+ import { resolveDropdownAction } from './lib/actions.mjs';
7
7
  import { restoreActionPointerReachability } from './lib/action-target.mjs';
8
8
  import {
9
9
  HOLDING_SHELL_ATTRIBUTE,
@@ -13,6 +13,12 @@ import {
13
13
  shellPlacementCss,
14
14
  } from './lib/holding-composition.mjs';
15
15
  import { injectReviewFraming } from './lib/review-framing.mjs';
16
+ import {
17
+ isolateTransplantedShell,
18
+ rulesFor,
19
+ shellElementsForSourceId,
20
+ shellSelectorForSourceId,
21
+ } from './lib/shell-identity.mjs';
16
22
  import { stitchUnitShells } from './lib/shell-stitching.mjs';
17
23
  import { shouldSuppressDroppedShell } from './lib/shells.mjs';
18
24
  import { normalizeText as norm, slugText as slug } from './lib/text.mjs';
@@ -98,11 +104,6 @@ function loadDoc(file) {
98
104
  return parseHTML(docCache.get(file));
99
105
  }
100
106
  function styleText(document) { return [...document.querySelectorAll('style')].map((s) => s.textContent).join('\n'); }
101
- function rulesFor(css, id) {
102
- // one selector per rule in these exports; grab every rule whose selector is #id or #id-...
103
- const re = new RegExp(`#${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:-[A-Za-z0-9_-]*)?(?:[:>\\s][^{]*)?\\{[^}]*\\}`, 'g');
104
- return (css.match(re) || []).join('\n');
105
- }
106
107
 
107
108
  // ---- canonical shell HTML/CSS per region
108
109
  const shell = {};
@@ -154,6 +155,7 @@ const report = {
154
155
  units: [],
155
156
  unbound: [],
156
157
  invalidDropdowns: [],
158
+ actionDemotions: [],
157
159
  shellSizeDelta: [],
158
160
  shellAmbiguities: model.shellAmbiguities || [],
159
161
  suppressedShellOverlaps: [],
@@ -198,15 +200,27 @@ function actionsFor(s) {
198
200
  if (d && l) { if (d.action === l.action) report.counts.agree++; else report.counts.disagree++; }
199
201
  else if (d) report.counts.detOnly++; else if (l) report.counts.lunaOnly++;
200
202
  let a = null;
201
- if (suppressDeterministicAction(l)) {
203
+ const dropdown = resolveDropdownAction({
204
+ deterministic: d,
205
+ label: l,
206
+ labelId: d?.labelId || c.textId || (c.kind === 'link' ? c.id : null),
207
+ });
208
+ if (dropdown.disposition === 'dropdown') a = dropdown.action;
209
+ else if (dropdown.disposition === 'static') {
210
+ const demotion = {
211
+ state: s.stateId,
212
+ id: c.id,
213
+ ...dropdown.degradation,
214
+ };
215
+ report.actionDemotions.push(demotion);
216
+ if (dropdown.degradation.code === 'dropdown_options_incomplete') {
217
+ report.invalidDropdowns.push(demotion);
218
+ }
219
+ }
220
+ else if (dropdown.disposition === 'suppressed') {
202
221
  // A recovered explicit none is the labeler's durable decision that this
203
222
  // candidate is not safely actionable. Do not resurrect a deterministic
204
- // dropdown with missing choices and fail publication downstream.
205
- }
206
- else if (d?.action === 'dropdown' || l?.action === 'dropdown') {
207
- const options = usableDropdownOptions(l?.options);
208
- if (options) a = { action: 'dropdown', labelId: d?.labelId || c.textId || (c.kind === 'link' ? c.id : null), options };
209
- else report.invalidDropdowns.push({ state: s.stateId, id: c.id, options: l?.options || [] });
223
+ // dropdown with missing choices.
210
224
  }
211
225
  else if (l?.action === 'modal') a = { action: 'modal', modal: l.modal || { title: c.text, body: '', confirm: 'OK' } };
212
226
  else if (l?.action === 'navigate' || d?.action === 'navigate') {
@@ -269,7 +283,6 @@ for (const u of units) {
269
283
  unit: u,
270
284
  canonicalShell: model.canonicalShell,
271
285
  navRows,
272
- rulesFor,
273
286
  styleText,
274
287
  })
275
288
  : { extraCss: [], bands: [], decisions: [], changes: 0 };
@@ -308,6 +321,13 @@ for (const u of units) {
308
321
  report.counts[a.action]++;
309
322
  allActions[id] = a;
310
323
  }
324
+ for (const demotion of report.actionDemotions.filter((candidate) => candidate.state === s.stateId)) {
325
+ const el = document.getElementById(demotion.id);
326
+ if (!el) continue;
327
+ el.removeAttribute('data-proto-action');
328
+ el.removeAttribute('data-proto-options');
329
+ el.setAttribute('data-proto-demoted', demotion.code);
330
+ }
311
331
  // fields
312
332
  for (const f of s.fields) {
313
333
  if (acts.has(f.id)) continue;
@@ -334,8 +354,14 @@ for (const u of units) {
334
354
  // after every transplant so shared dropdowns/utilities behave on every page.
335
355
  const unitRegions = new Set(u.states.flatMap((state) => state.shell.map((item) => item.region)));
336
356
  for (const region of unitRegions) for (const [id, action] of canonicalShellActions[region] || []) {
337
- const elements = [...document.querySelectorAll('[id]')].filter((element) => element.id === id);
338
- for (const element of elements) bindAction(element, action);
357
+ const elements = shellElementsForSourceId(document, id);
358
+ for (const element of elements) {
359
+ const scope = element.closest('.ly-band[data-ly-band]') || document;
360
+ const labelId = action.labelId
361
+ ? shellElementsForSourceId(scope, action.labelId)[0]?.id || action.labelId
362
+ : null;
363
+ bindAction(element, labelId ? { ...action, labelId } : action);
364
+ }
339
365
  if (elements.length && !allActions[id]) {
340
366
  allActions[id] = action;
341
367
  report.shellActionRemaps++;
@@ -343,13 +369,15 @@ for (const u of units) {
343
369
  }
344
370
  // nav wiring on the (now shared) canonical shell ids — present wherever a shell was transplanted or is native
345
371
  for (const n of navRows) {
346
- const el = document.getElementById(n.rowId) || document.getElementById(n.id);
347
- if (!el) continue;
348
- const tgt = n.target ? (unitOf(n.target)?.id === u.id ? null : fileOf(n.target)) : holdingFile(n.text);
349
- if (!tgt) { el.removeAttribute('data-proto-action'); continue; }
350
- el.setAttribute('data-proto-action', 'navigate'); el.setAttribute('data-proto-target', tgt);
351
- for (const d of el.querySelectorAll('[data-proto-action]')) { d.removeAttribute('data-proto-action'); d.removeAttribute('data-proto-target'); delete allActions[d.id]; }
352
- allActions[n.rowId] = { action: 'navigate', target: tgt, nav: true };
372
+ const elements = shellElementsForSourceId(document, n.rowId);
373
+ const targets = elements.length > 0 ? elements : shellElementsForSourceId(document, n.id);
374
+ for (const el of targets) {
375
+ const tgt = n.target ? (unitOf(n.target)?.id === u.id ? null : fileOf(n.target)) : holdingFile(n.text);
376
+ if (!tgt) { el.removeAttribute('data-proto-action'); continue; }
377
+ el.setAttribute('data-proto-action', 'navigate'); el.setAttribute('data-proto-target', tgt);
378
+ for (const d of el.querySelectorAll('[data-proto-action]')) { d.removeAttribute('data-proto-action'); d.removeAttribute('data-proto-target'); delete allActions[d.id]; }
379
+ allActions[n.rowId] = { action: 'navigate', target: tgt, nav: true };
380
+ }
353
381
  }
354
382
  // active nav highlight: move the pill style from the canonical active item to this unit's item
355
383
  for (const [region, p] of Object.entries(pills)) {
@@ -358,7 +386,7 @@ for (const u of units) {
358
386
  // no nav item maps to this page: show no active item rather than the canonical page's
359
387
  const anyRow = u.states.some((s) => s.shell.some((x) => x.region === region));
360
388
  if (anyRow && p.pill) { const inactive = navRows.find((n) => n.region === region && n.id !== p.own.id); const inText = inactive && shell[region].section.items.find((x) => x.id === inactive.id);
361
- extraCss.push(`#${p.pill.id}{background:transparent!important;box-shadow:none!important}`); if (inText) extraCss.push(`#${p.own.id}{color:${inText.color}!important;font-weight:${inText.fontWeight}!important}`); }
389
+ extraCss.push(`${shellSelectorForSourceId(document, p.pill.id)}{background:transparent!important;box-shadow:none!important}`); if (inText) extraCss.push(`${shellSelectorForSourceId(document, p.own.id)}{color:${inText.color}!important;font-weight:${inText.fontWeight}!important}`); }
362
390
  continue;
363
391
  }
364
392
  if (activeNav.id === p.own.id) continue;
@@ -366,15 +394,15 @@ for (const u of units) {
366
394
  const curText = shell[region].section.items.find((x) => x.id === cur.id);
367
395
  const ownText = shell[region].section.items.find((x) => x.id === p.own.id);
368
396
  if (p.pill) {
369
- extraCss.push(`#${p.pill.id}{background:transparent!important;box-shadow:none!important}`);
370
- if (cur.row) extraCss.push(`#${cur.row.id}{background:${p.pill.bg}!important;border-radius:${p.pill.radius}!important}`);
397
+ extraCss.push(`${shellSelectorForSourceId(document, p.pill.id)}{background:transparent!important;box-shadow:none!important}`);
398
+ if (cur.row) extraCss.push(`${shellSelectorForSourceId(document, cur.row.id)}{background:${p.pill.bg}!important;border-radius:${p.pill.radius}!important}`);
371
399
  }
372
- extraCss.push(`#${cur.id}{color:${p.textColor}!important;font-weight:${p.textWeight}!important}`);
373
- extraCss.push(`#${p.own.id}{color:${curText.color}!important;font-weight:${curText.fontWeight}!important}`);
400
+ extraCss.push(`${shellSelectorForSourceId(document, cur.id)}{color:${p.textColor}!important;font-weight:${p.textWeight}!important}`);
401
+ extraCss.push(`${shellSelectorForSourceId(document, p.own.id)}{color:${curText.color}!important;font-weight:${curText.fontWeight}!important}`);
374
402
  // swap icon colours too (icon = previous sibling of the text inside the row)
375
403
  const ownIcon = shell[region].section.items.find((x) => x.cls.includes('icon') && p.own.row && x.chain.includes(p.own.row.id));
376
404
  const curIcon = shell[region].section.items.find((x) => x.cls.includes('icon') && cur.row && x.chain.includes(cur.row.id));
377
- if (ownIcon && curIcon) { extraCss.push(`#${curIcon.id}{color:${ownIcon.color}!important}`); extraCss.push(`#${ownIcon.id}{color:${curIcon.color}!important}`); }
405
+ if (ownIcon && curIcon) { extraCss.push(`${shellSelectorForSourceId(document, curIcon.id)}{color:${ownIcon.color}!important}`); extraCss.push(`${shellSelectorForSourceId(document, ownIcon.id)}{color:${curIcon.color}!important}`); }
378
406
  }
379
407
  // inject runtime
380
408
  const head = document.querySelector('head');
@@ -474,7 +502,8 @@ for (const u of units) {
474
502
  return {
475
503
  ...candidate,
476
504
  region,
477
- html: element.outerHTML,
505
+ sourceContainerId: candidate.containerId,
506
+ sourceElement: element.cloneNode(true),
478
507
  css: rulesFor(styleText(donorDocument), candidate.containerId),
479
508
  ancestorIds,
480
509
  };
@@ -485,18 +514,30 @@ for (const u of units) {
485
514
  }
486
515
  for (const child of [...host.children]) child.remove();
487
516
  const parentShell = (placed) => placedShells.find((candidate) => (
488
- candidate !== placed && placed.ancestorIds.includes(candidate.containerId)
517
+ candidate !== placed && placed.ancestorIds.includes(candidate.sourceContainerId)
489
518
  ));
490
519
  for (const placed of placedShells.filter((candidate) => !parentShell(candidate))) {
491
- host.insertAdjacentHTML('beforeend', placed.html);
520
+ const subtreeCss = placedShells
521
+ .filter((candidate) => candidate === placed || candidate.ancestorIds.includes(placed.sourceContainerId))
522
+ .map((candidate) => candidate.css)
523
+ .filter(Boolean)
524
+ .join('\n');
525
+ const isolated = isolateTransplantedShell({
526
+ document,
527
+ sourceElement: placed.sourceElement,
528
+ css: subtreeCss,
529
+ namespaceParts: ['holding', key, placed.region],
530
+ });
531
+ host.insertAdjacentHTML('beforeend', isolated.html);
532
+ if (isolated.css) extraCss.push(isolated.css);
492
533
  }
493
534
  for (const placed of placedShells) {
494
- const element = document.getElementById(placed.containerId);
535
+ const element = shellElementsForSourceId(host, placed.sourceContainerId)[0];
495
536
  if (!element || !host.contains(element)) {
496
- throw new Error(`Holding composition failed to retain ${placed.region} shell ${placed.containerId} in band ${key}.`);
537
+ throw new Error(`Holding composition failed to retain ${placed.region} shell ${placed.sourceContainerId} in band ${key}.`);
497
538
  }
539
+ placed.containerId = element.id;
498
540
  element.setAttribute(HOLDING_SHELL_ATTRIBUTE, placed.region);
499
- if (placed.css) extraCss.push(placed.css);
500
541
  const parent = parentShell(placed);
501
542
  const box = parent
502
543
  ? [placed.box[0] - parent.box[0], placed.box[1] - parent.box[1], placed.box[2], placed.box[3]]
@@ -553,16 +594,18 @@ for (const u of units) {
553
594
  const p = pills[navMatch.region];
554
595
  if (p && p.own.id !== navMatch.id) {
555
596
  const curText = shell[navMatch.region].section.items.find((x) => x.id === navMatch.id);
556
- if (p.pill) { extraCss.push(`#${p.pill.id}{background:transparent!important;box-shadow:none!important}`); if (navMatch.row) extraCss.push(`#${navMatch.row.id}{background:${p.pill.bg}!important;border-radius:${p.pill.radius}!important}`); }
557
- extraCss.push(`#${navMatch.id}{color:${p.textColor}!important;font-weight:${p.textWeight}!important}`);
558
- extraCss.push(`#${p.own.id}{color:${curText.color}!important;font-weight:${curText.fontWeight}!important}`);
597
+ if (p.pill) { extraCss.push(`${shellSelectorForSourceId(document, p.pill.id)}{background:transparent!important;box-shadow:none!important}`); if (navMatch.row) extraCss.push(`${shellSelectorForSourceId(document, navMatch.row.id)}{background:${p.pill.bg}!important;border-radius:${p.pill.radius}!important}`); }
598
+ extraCss.push(`${shellSelectorForSourceId(document, navMatch.id)}{color:${p.textColor}!important;font-weight:${p.textWeight}!important}`);
599
+ extraCss.push(`${shellSelectorForSourceId(document, p.own.id)}{color:${curText.color}!important;font-weight:${curText.fontWeight}!important}`);
559
600
  }
560
601
  }
561
602
  for (const n of navRows) {
562
- const el = document.getElementById(n.rowId) || document.getElementById(n.id);
563
- if (!el) continue;
564
- const tgt = n.holding === hslug ? null : n.target ? fileOf(n.target) : holdingFile(n.text);
565
- if (tgt) { el.setAttribute('data-proto-action', 'navigate'); el.setAttribute('data-proto-target', tgt); for (const d of el.querySelectorAll('[data-proto-action]')) d.removeAttribute('data-proto-action'); }
603
+ const elements = shellElementsForSourceId(document, n.rowId);
604
+ const targets = elements.length > 0 ? elements : shellElementsForSourceId(document, n.id);
605
+ for (const el of targets) {
606
+ const tgt = n.holding === hslug ? null : n.target ? fileOf(n.target) : holdingFile(n.text);
607
+ if (tgt) { el.setAttribute('data-proto-action', 'navigate'); el.setAttribute('data-proto-target', tgt); for (const d of el.querySelectorAll('[data-proto-action]')) d.removeAttribute('data-proto-action'); }
608
+ }
566
609
  }
567
610
  document.title = htitle;
568
611
  document.querySelector('head').insertAdjacentHTML('beforeend', `<style id="proto-extra">${extraCss.join('\n')}</style><link rel="stylesheet" href="proto.css">`);
@@ -580,5 +623,5 @@ fs.writeFileSync(path.join(outDir, 'index.html'), `<!doctype html><meta http-equ
580
623
  const manifestHoldings = review
581
624
  ? [...usedHoldings.entries()].map(([holding, title]) => [holding.replace(/\.html$/, ''), title])
582
625
  : [...usedHoldings.entries()];
583
- fs.writeFileSync(path.join(outDir, 'proto-manifest.json'), JSON.stringify({ units: report.units, nav: navRows.map((n) => ({ text: n.text, target: n.target, holding: n.holding, rowId: n.rowId })), holdings: manifestHoldings, counts: report.counts, unbound: report.unbound, invalidDropdowns: report.invalidDropdowns, shellSizeDelta: report.shellSizeDelta, shellAmbiguities: report.shellAmbiguities, suppressedShellOverlaps: report.suppressedShellOverlaps, skippedShellTransplants: report.skippedShellTransplants, replacedShellActions: report.replacedShellActions, shellActionRemaps: report.shellActionRemaps, scriptDirections: states.map(({ stateId, scriptDirection, writingMode }) => ({ stateId, scriptDirection, writingMode })), canonicalShell: model.canonicalShell, canonicalShellPage: report.canonicalShellPage, shellConsistency: report.shellConsistency, ...(review ? { review } : {}) }, null, 1));
626
+ fs.writeFileSync(path.join(outDir, 'proto-manifest.json'), JSON.stringify({ units: report.units, nav: navRows.map((n) => ({ text: n.text, target: n.target, holding: n.holding, rowId: n.rowId })), holdings: manifestHoldings, counts: report.counts, unbound: report.unbound, invalidDropdowns: report.invalidDropdowns, ...(report.actionDemotions.length > 0 ? { actionDemotions: report.actionDemotions } : {}), shellSizeDelta: report.shellSizeDelta, shellAmbiguities: report.shellAmbiguities, suppressedShellOverlaps: report.suppressedShellOverlaps, skippedShellTransplants: report.skippedShellTransplants, replacedShellActions: report.replacedShellActions, shellActionRemaps: report.shellActionRemaps, scriptDirections: states.map(({ stateId, scriptDirection, writingMode }) => ({ stateId, scriptDirection, writingMode })), canonicalShell: model.canonicalShell, canonicalShellPage: report.canonicalShellPage, shellConsistency: report.shellConsistency, ...(review ? { review } : {}) }, null, 1));
584
627
  console.error('counts', JSON.stringify(report.counts), 'unbound', report.unbound.length);
@@ -17,3 +17,51 @@ export function usableDropdownOptions(options) {
17
17
  export function suppressDeterministicAction(label) {
18
18
  return label?.action === 'none';
19
19
  }
20
+
21
+ /**
22
+ * One policy boundary for a dropdown-shaped control. The analyzer may know
23
+ * that the source looks like a dropdown while the semantic label chooses a
24
+ * different action or supplies an incomplete option set. Only a proven set of
25
+ * three distinct options is actionable; every other dropdown-shaped result is
26
+ * deliberately static and carries a machine-readable degradation fact.
27
+ */
28
+ export function resolveDropdownAction({ deterministic, label, labelId }) {
29
+ if (suppressDeterministicAction(label)) {
30
+ return deterministic?.action === 'dropdown'
31
+ ? {
32
+ disposition: 'static',
33
+ degradation: {
34
+ code: 'label_none_overrode_deterministic_dropdown',
35
+ reason: 'explicit-label-none',
36
+ fromAction: 'dropdown',
37
+ toAction: 'none',
38
+ options: [],
39
+ },
40
+ }
41
+ : { disposition: 'suppressed' };
42
+ }
43
+ if (deterministic?.action !== 'dropdown' && label?.action !== 'dropdown') {
44
+ return { disposition: 'not-dropdown' };
45
+ }
46
+ const options = usableDropdownOptions(label?.options);
47
+ if (!options) {
48
+ return {
49
+ disposition: 'static',
50
+ degradation: {
51
+ code: 'dropdown_options_incomplete',
52
+ reason: 'fewer-than-three-distinct-options',
53
+ fromAction: label?.action || deterministic?.action || 'dropdown',
54
+ toAction: 'none',
55
+ options: Array.isArray(label?.options) ? label.options : [],
56
+ },
57
+ };
58
+ }
59
+ return {
60
+ disposition: 'dropdown',
61
+ action: {
62
+ action: 'dropdown',
63
+ labelId,
64
+ options,
65
+ },
66
+ };
67
+ }
@@ -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(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
  });