@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.
- package/README.md +10 -5
- package/SKILL.md +1 -1
- package/dist/cli-branch-command.d.ts.map +1 -1
- package/dist/cli-branch-command.js +10 -2
- package/dist/cli-branch-command.js.map +1 -1
- package/dist/cli-capabilities.d.ts +1 -1
- package/dist/cli-capabilities.js +1 -1
- package/dist/cli-capabilities.js.map +1 -1
- package/dist/cli-prototype-command.d.ts.map +1 -1
- package/dist/cli-prototype-command.js +4 -1
- package/dist/cli-prototype-command.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/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.map +1 -1
- package/dist/prototype-poststep.js +5 -0
- package/dist/prototype-poststep.js.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 +5 -1
- package/dist/prototype-run-record.d.ts.map +1 -1
- package/dist/prototype-run-record.js +89 -7
- package/dist/prototype-run-record.js.map +1 -1
- package/dist/prototype-runner.d.ts +4 -1
- package/dist/prototype-runner.d.ts.map +1 -1
- package/dist/prototype-runner.js +14 -8
- package/dist/prototype-runner.js.map +1 -1
- package/dist/run-journal.d.ts +6 -1
- package/dist/run-journal.d.ts.map +1 -1
- package/dist/run-journal.js +10 -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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12ui/design",
|
|
3
|
-
"version": "0.2.
|
|
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
|
},
|
package/prototype-kit/build.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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 =
|
|
338
|
-
for (const element of elements)
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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(
|
|
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(
|
|
370
|
-
if (cur.row) extraCss.push(
|
|
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(
|
|
373
|
-
extraCss.push(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
517
|
+
candidate !== placed && placed.ancestorIds.includes(candidate.sourceContainerId)
|
|
489
518
|
));
|
|
490
519
|
for (const placed of placedShells.filter((candidate) => !parentShell(candidate))) {
|
|
491
|
-
|
|
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 =
|
|
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.
|
|
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(
|
|
557
|
-
extraCss.push(
|
|
558
|
-
extraCss.push(
|
|
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
|
|
563
|
-
|
|
564
|
-
const
|
|
565
|
-
|
|
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(
|
|
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
|
});
|