@12ui/design 0.2.43 → 0.2.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -5
- package/SKILL.md +1 -1
- package/dist/branch-execution-types.d.ts +2 -0
- package/dist/branch-execution-types.d.ts.map +1 -1
- package/dist/branch-execution.d.ts.map +1 -1
- package/dist/branch-execution.js +3 -0
- package/dist/branch-execution.js.map +1 -1
- package/dist/branch-run-record.d.ts +1 -0
- package/dist/branch-run-record.d.ts.map +1 -1
- package/dist/branch-run-record.js.map +1 -1
- package/dist/cli-arguments.d.ts.map +1 -1
- package/dist/cli-arguments.js +1 -0
- package/dist/cli-arguments.js.map +1 -1
- package/dist/cli-branch-command.d.ts.map +1 -1
- package/dist/cli-branch-command.js +41 -2
- package/dist/cli-branch-command.js.map +1 -1
- package/dist/cli-capabilities.d.ts +2 -1
- package/dist/cli-capabilities.d.ts.map +1 -1
- package/dist/cli-capabilities.js +2 -1
- package/dist/cli-capabilities.js.map +1 -1
- package/dist/cli-help/design.d.ts +2 -2
- package/dist/cli-help/design.d.ts.map +1 -1
- package/dist/cli-help/design.js +2 -0
- package/dist/cli-help/design.js.map +1 -1
- package/dist/cli-help/index.d.ts +2 -2
- package/dist/cli-prototype-command.d.ts.map +1 -1
- package/dist/cli-prototype-command.js +17 -1
- package/dist/cli-prototype-command.js.map +1 -1
- package/dist/cli-usage.d.ts.map +1 -1
- package/dist/cli-usage.js +4 -3
- package/dist/cli-usage.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/legacy-skill-catalog.d.ts.map +1 -1
- package/dist/legacy-skill-catalog.js +7 -0
- package/dist/legacy-skill-catalog.js.map +1 -1
- package/dist/prototype-gates.d.ts +8 -0
- package/dist/prototype-gates.d.ts.map +1 -1
- package/dist/prototype-gates.js +39 -5
- package/dist/prototype-gates.js.map +1 -1
- package/dist/prototype-poststep.d.ts +1 -0
- package/dist/prototype-poststep.d.ts.map +1 -1
- package/dist/prototype-poststep.js +10 -0
- package/dist/prototype-poststep.js.map +1 -1
- package/dist/prototype-process.d.ts +1 -1
- package/dist/prototype-process.d.ts.map +1 -1
- package/dist/prototype-revision.d.ts +2 -0
- package/dist/prototype-revision.d.ts.map +1 -0
- package/dist/prototype-revision.js +2 -0
- package/dist/prototype-revision.js.map +1 -0
- package/dist/prototype-run-record.d.ts +6 -1
- package/dist/prototype-run-record.d.ts.map +1 -1
- package/dist/prototype-run-record.js +113 -9
- package/dist/prototype-run-record.js.map +1 -1
- package/dist/prototype-runner.d.ts +30 -1
- package/dist/prototype-runner.d.ts.map +1 -1
- package/dist/prototype-runner.js +69 -16
- package/dist/prototype-runner.js.map +1 -1
- package/dist/run-journal.d.ts +21 -1
- package/dist/run-journal.d.ts.map +1 -1
- package/dist/run-journal.js +19 -2
- package/dist/run-journal.js.map +1 -1
- package/open-design.json +2 -2
- package/package.json +2 -2
- package/prototype-kit/build.mjs +86 -43
- package/prototype-kit/lib/actions.mjs +48 -0
- package/prototype-kit/lib/shell-identity.mjs +139 -0
- package/prototype-kit/lib/shell-stitching.mjs +70 -29
- package/prototype-kit/polish/ground-probe.mjs +119 -0
- package/prototype-kit/polish/harm.mjs +23 -0
- package/prototype-kit/polish/measure.mjs +333 -0
- package/prototype-kit/polish/overrides.mjs +55 -0
- package/prototype-kit/polish/polish.mjs +252 -0
- package/prototype-kit/polish/report.mjs +76 -0
- package/prototype-kit/polish/rules/align.mjs +70 -0
- package/prototype-kit/polish/rules/contrast.mjs +80 -0
- package/prototype-kit/polish/rules/escape.mjs +192 -0
- package/prototype-kit/polish/rules/fit-text.mjs +158 -0
- package/prototype-kit/polish/rules/index.mjs +61 -0
- package/prototype-kit/polish/rules/left-rail.mjs +77 -0
- package/prototype-kit/polish/rules/overlap.mjs +122 -0
- package/prototype-kit/polish/rules/page-cut.mjs +203 -0
- package/prototype-kit/polish/rules/photo-corners.mjs +38 -0
- package/prototype-kit/polish/rules/rhythm.mjs +148 -0
- package/prototype-kit/polish/rules/section-breathing.mjs +292 -0
- package/prototype-kit/polish/rules/shell-seam.mjs +48 -0
- package/prototype-kit/polish/tests/breathing-check.mjs +77 -0
- package/prototype-kit/polish/tests/fixtures/align/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/align/fire.html +14 -0
- package/prototype-kit/polish/tests/fixtures/align/silent.html +28 -0
- package/prototype-kit/polish/tests/fixtures/contrast/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/contrast/fallback.html +7 -0
- package/prototype-kit/polish/tests/fixtures/contrast/fire.html +9 -0
- package/prototype-kit/polish/tests/fixtures/contrast/silent.html +9 -0
- package/prototype-kit/polish/tests/fixtures/escape/expect.json +2 -0
- package/prototype-kit/polish/tests/fixtures/escape/fire.html +23 -0
- package/prototype-kit/polish/tests/fixtures/escape/silent.html +29 -0
- package/prototype-kit/polish/tests/fixtures/fit-text/expect.json +3 -0
- package/prototype-kit/polish/tests/fixtures/fit-text/fire.html +26 -0
- package/prototype-kit/polish/tests/fixtures/fit-text/silent.html +33 -0
- package/prototype-kit/polish/tests/fixtures/harm/button-overlap.html +10 -0
- package/prototype-kit/polish/tests/fixtures/harm/declared-gap.html +5 -0
- package/prototype-kit/polish/tests/fixtures/harm/offcenter.html +21 -0
- package/prototype-kit/polish/tests/fixtures/harm/page.html +17 -0
- package/prototype-kit/polish/tests/fixtures/left-rail/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/left-rail/fire.html +8 -0
- package/prototype-kit/polish/tests/fixtures/left-rail/silent.html +8 -0
- package/prototype-kit/polish/tests/fixtures/overlap/expect.json +4 -0
- package/prototype-kit/polish/tests/fixtures/overlap/fire.html +26 -0
- package/prototype-kit/polish/tests/fixtures/overlap/silent.html +39 -0
- package/prototype-kit/polish/tests/fixtures/page-cut/expect.json +2 -0
- package/prototype-kit/polish/tests/fixtures/page-cut/fire.html +31 -0
- package/prototype-kit/polish/tests/fixtures/page-cut/silent.html +22 -0
- package/prototype-kit/polish/tests/fixtures/photo-corners/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/photo-corners/fire.html +5 -0
- package/prototype-kit/polish/tests/fixtures/photo-corners/silent.html +9 -0
- package/prototype-kit/polish/tests/fixtures/rhythm/expect.json +6 -0
- package/prototype-kit/polish/tests/fixtures/rhythm/fire.html +22 -0
- package/prototype-kit/polish/tests/fixtures/rhythm/silent.html +27 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-fire.html +5 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/attachment-silent.html +5 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/fire.html +6 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/floor.html +7 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-fire.html +11 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/semantic-silent.html +8 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-fire.html +5 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/shadow-silent.html +5 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/silent.html +6 -0
- package/prototype-kit/polish/tests/fixtures/section-breathing/tight.html +7 -0
- package/prototype-kit/polish/tests/fixtures/shell-seam/expect.json +1 -0
- package/prototype-kit/polish/tests/fixtures/shell-seam/fire.html +20 -0
- package/prototype-kit/polish/tests/fixtures/shell-seam/silent.html +21 -0
- package/prototype-kit/polish/tests/harm-check.mjs +71 -0
- package/prototype-kit/polish/tests/report-check.mjs +31 -0
- package/prototype-kit/polish/tests/rules-check.mjs +52 -0
- package/prototype-kit/tests/adversarial-runtime.mjs +16 -1
- package/prototype-kit/tests/fixtures/blocking-runtime/broken-interaction.html +7 -0
- package/prototype-kit/tests/fixtures/four-dropdown/branch.plan.json +29 -0
- package/prototype-kit/tests/fixtures/four-dropdown/pages/p2-stream-proposals.html +10 -0
- package/prototype-kit/tests/fixtures/four-dropdown/pages/p4-live-stream-results.html +7 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/PROVENANCE.md +22 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/branch.plan.json +40 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p0-donor.html +38 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p1-id-collision.html +38 -0
- package/prototype-kit/tests/fixtures/shell-stitching-runtime/pages/p2-canvas-control.html +39 -0
- package/prototype-kit/tests/offline-check.mjs +1 -1
- package/prototype-kit/tests/rules-check.mjs +69 -3
- package/prototype-kit/tests/shell-stitching-runtime.mjs +116 -0
- package/skills/12ui-design/SKILL.md +4 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { pageRuleRecords, summarizePolishReport } from '../report.mjs';
|
|
5
|
+
|
|
6
|
+
const page = {
|
|
7
|
+
rules: {
|
|
8
|
+
align: { proposed: 2, overCap: 0 },
|
|
9
|
+
contrast: { proposed: 0, overCap: 0 },
|
|
10
|
+
rhythm: { proposed: 1, overCap: 1 },
|
|
11
|
+
},
|
|
12
|
+
accepted: [{ rule: 'align', overrides: 2 }],
|
|
13
|
+
rejected: [{ rule: 'rhythm', harm: ['spacing'] }],
|
|
14
|
+
};
|
|
15
|
+
page.ruleRecords = pageRuleRecords(page, ['align', 'contrast', 'rhythm']);
|
|
16
|
+
assert.deepEqual(page.ruleRecords.map(({ rule, status }) => ({ rule, status })), [
|
|
17
|
+
{ rule: 'align', status: 'fired' },
|
|
18
|
+
{ rule: 'contrast', status: 'silent' },
|
|
19
|
+
{ rule: 'rhythm', status: 'reverted' },
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const summary = summarizePolishReport({
|
|
23
|
+
policy: 'retained-v1',
|
|
24
|
+
rules: ['align', 'contrast', 'rhythm'],
|
|
25
|
+
pages: [page],
|
|
26
|
+
});
|
|
27
|
+
assert.equal(summary.fired, 1);
|
|
28
|
+
assert.equal(summary.silent, 1);
|
|
29
|
+
assert.equal(summary.reverted, 1);
|
|
30
|
+
assert.equal(summary.rules.find(({ name }) => name === 'rhythm').overCap, 1);
|
|
31
|
+
console.log('polish report outcomes green');
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Paired cases for every polish rule: fixtures/<rule>/fire.html must produce ≥1 override,
|
|
3
|
+
// fixtures/<rule>/silent.html must produce 0. Optional fixtures/<rule>/expect.json pins exact ids/deltas.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { chromium } from '@playwright/test';
|
|
7
|
+
import { measurePage } from '../measure.mjs';
|
|
8
|
+
import { loadRules, ALL } from '../rules/index.mjs';
|
|
9
|
+
import { applyOverridesScript, overridesToCss, mergeOverrides } from '../overrides.mjs';
|
|
10
|
+
|
|
11
|
+
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
12
|
+
const only = process.argv[2] ? process.argv[2].split(',') : ALL;
|
|
13
|
+
const rules = await loadRules(only);
|
|
14
|
+
const browser = await chromium.launch();
|
|
15
|
+
const page = await browser.newPage({ viewport: { width: 1536, height: 1024 } });
|
|
16
|
+
let failures = 0;
|
|
17
|
+
const near = (a, b, tol = 0.6) => Math.abs(a - b) <= tol;
|
|
18
|
+
for (const rule of rules) {
|
|
19
|
+
const dir = path.join(here, 'fixtures', rule.name);
|
|
20
|
+
for (const kind of ['fire', 'silent']) {
|
|
21
|
+
const file = path.join(dir, `${kind}.html`);
|
|
22
|
+
if (!fs.existsSync(file)) { console.log(`FAIL ${rule.name}/${kind}: fixture missing`); failures++; continue; }
|
|
23
|
+
await page.goto('file://' + file, { waitUntil: 'load' });
|
|
24
|
+
await page.evaluate(() => document.fonts.ready);
|
|
25
|
+
const before = await measurePage(page, {});
|
|
26
|
+
const found = await page.evaluate(rule.browserFn(), { metrics: before, params: rule.params || {} });
|
|
27
|
+
const ok = kind === 'fire' ? found.length > 0 : found.length === 0;
|
|
28
|
+
let detail = `${found.length} override(s)`;
|
|
29
|
+
let harm = false;
|
|
30
|
+
if (kind === 'fire' && found.length) {
|
|
31
|
+
const merged = mergeOverrides(found);
|
|
32
|
+
await page.evaluate(applyOverridesScript(), overridesToCss(merged));
|
|
33
|
+
const after = await measurePage(page, {});
|
|
34
|
+
harm = after.counts.overlap > before.counts.overlap || after.overflow.filter((o) => o.kind !== 'spill').length > before.overflow.filter((o) => o.kind !== 'spill').length;
|
|
35
|
+
detail += `; overlap ${before.counts.overlap}→${after.counts.overlap}, overflow ${before.counts.overflow}→${after.counts.overflow}, align ${before.counts.alignment}→${after.counts.alignment}, ws ${before.counts.whitespace}→${after.counts.whitespace}`;
|
|
36
|
+
const expectFile = path.join(dir, 'expect.json');
|
|
37
|
+
if (fs.existsSync(expectFile)) {
|
|
38
|
+
const expect = JSON.parse(fs.readFileSync(expectFile, 'utf8'));
|
|
39
|
+
for (const e of expect) {
|
|
40
|
+
const m = merged.find((o) => o.id === e.id);
|
|
41
|
+
const good = m && (e.dx == null || near(m.dx, e.dx)) && (e.dy == null || near(m.dy, e.dy)) && (e.w == null || near(m.w, e.w, 1)) && (e.h == null || near(m.h, e.h, 1)) && (!e.css || Object.entries(e.css).every(([k, v]) => m.css[k] === v));
|
|
42
|
+
if (!good) { detail += `; expected ${JSON.stringify(e)} got ${JSON.stringify(m || null)}`; failures++; }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!ok || harm) failures++;
|
|
47
|
+
console.log(`${ok && !harm ? 'ok ' : 'FAIL'} ${rule.name}/${kind}: ${detail}${harm ? ' HARM' : ''}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
await browser.close();
|
|
51
|
+
if (failures) { console.log(`${failures} failure(s)`); process.exit(1); }
|
|
52
|
+
console.log('all polish rule pairs green');
|
|
@@ -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
|
-
'
|
|
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 { suppressDeterministicAction, 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';
|
|
@@ -27,6 +28,37 @@ assert.equal(usableDropdownOptions(['Only choice']), null, 'must fire: a one-opt
|
|
|
27
28
|
assert.deepEqual(usableDropdownOptions(['All', 'Open', 'Closed']), ['All', 'Open', 'Closed'], 'must stay silent: a real three-option dropdown remains bindable');
|
|
28
29
|
assert.equal(suppressDeterministicAction({ action: 'none' }), true, 'must fire: a recovered explicit none suppresses an unsafe deterministic dropdown');
|
|
29
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
|
+
);
|
|
30
62
|
|
|
31
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');
|
|
32
64
|
assert.equal(restoreActionPointerReachability(unreachableDropdown), true, 'must fire: the exact failed Saved Journeys dropdown target becomes pointer reachable');
|
|
@@ -106,6 +138,34 @@ assert.equal(linkedNavForUnit({ navRows: [{ region: 'header', text: 'Journal', t
|
|
|
106
138
|
|
|
107
139
|
const rulesForFixture = () => '';
|
|
108
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
|
+
|
|
109
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;
|
|
110
170
|
const noWriteBefore = noWriteDocument.documentElement.outerHTML;
|
|
111
171
|
const noWriteResult = stitchUnitShells({
|
|
@@ -135,8 +195,14 @@ const fireResult = stitchUnitShells({
|
|
|
135
195
|
styleText: styleTextFixture,
|
|
136
196
|
});
|
|
137
197
|
assert.equal(fireResult.changes, 1, 'must fire: a measured incompatible fingerprint commits the declared donor shell');
|
|
138
|
-
|
|
139
|
-
|
|
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');
|
|
140
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);
|
|
141
207
|
|
|
142
208
|
process.stdout.write('rules: ok\n');
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { chromium } from '@playwright/test';
|
|
7
|
+
import { checkRuntime } from '../lib/runtime-check.mjs';
|
|
8
|
+
import { startStaticServer } from '../lib/static-server.mjs';
|
|
9
|
+
import { buildPrototype } from './lib/run-process.mjs';
|
|
10
|
+
|
|
11
|
+
const expectFailure = process.argv.includes('--expect-failure');
|
|
12
|
+
const unexpectedArgs = process.argv.slice(2).filter((arg) => arg !== '--expect-failure');
|
|
13
|
+
if (unexpectedArgs.length > 0) throw new Error(`usage: shell-stitching-runtime.mjs [--expect-failure]`);
|
|
14
|
+
|
|
15
|
+
const testsDir = path.dirname(new URL(import.meta.url).pathname);
|
|
16
|
+
const kitDir = path.resolve(testsDir, '..');
|
|
17
|
+
const fixtureDir = path.join(testsDir, 'fixtures', 'shell-stitching-runtime');
|
|
18
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'proto-shell-stitching-'));
|
|
19
|
+
const runWorkDir = path.join(scratch, 'build');
|
|
20
|
+
let browser = null;
|
|
21
|
+
let server = null;
|
|
22
|
+
|
|
23
|
+
const regionAt = (audit, file, width) => audit.pages
|
|
24
|
+
.find((page) => page.file === file)?.viewports
|
|
25
|
+
.find((viewport) => viewport.width === width)?.shellState?.regions
|
|
26
|
+
.find((region) => region.region === 'header') || null;
|
|
27
|
+
const bandHeight = (inventory, pageId, key) => inventory.pages
|
|
28
|
+
.find((page) => page.pageId === pageId)?.bands
|
|
29
|
+
.find((band) => band.key === key)?.height;
|
|
30
|
+
const decisionFor = (manifest, unitId) => manifest.units
|
|
31
|
+
.find((unit) => unit.id === unitId)?.shellDecisions
|
|
32
|
+
.find((decision) => decision.region === 'header') || null;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const built = buildPrototype({
|
|
36
|
+
kitDir,
|
|
37
|
+
run: { runDir: fixtureDir },
|
|
38
|
+
runWorkDir,
|
|
39
|
+
});
|
|
40
|
+
assert.equal(built.ok, true, `fixture pipeline must build: ${built.failedStage || 'unknown stage'} ${built.stages?.[built.failedStage]?.error || ''}`);
|
|
41
|
+
|
|
42
|
+
const inventory = JSON.parse(fs.readFileSync(built.inventoryFile, 'utf8'));
|
|
43
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(built.protoDir, 'proto-manifest.json'), 'utf8'));
|
|
44
|
+
assert.equal(manifest.canonicalShellPage?.pageId, 'p0-donor', 'fixture contract: the approved donor is the one canonical shell page');
|
|
45
|
+
assert.deepEqual(decisionFor(manifest, 'id-collision'), { region: 'header', commit: true, reason: 'compatible' }, 'fixture contract: differing compatible bands request one atomic transplant');
|
|
46
|
+
assert.deepEqual(decisionFor(manifest, 'canvas-control'), { region: 'header', commit: false, reason: 'compatible' }, 'must stay silent: the already canonical control does not request a write');
|
|
47
|
+
assert.deepEqual([
|
|
48
|
+
bandHeight(inventory, 'p0-donor', 'mobile-390'),
|
|
49
|
+
bandHeight(inventory, 'p2-canvas-control', 'mobile-390'),
|
|
50
|
+
], [900, 1400], 'fixture contract: the portrait control has a substantially taller canvas');
|
|
51
|
+
|
|
52
|
+
server = await startStaticServer(runWorkDir);
|
|
53
|
+
browser = await chromium.launch();
|
|
54
|
+
const audit = await checkRuntime({
|
|
55
|
+
browser,
|
|
56
|
+
baseUrl: server.baseUrl,
|
|
57
|
+
relativeProtoPath: 'proto',
|
|
58
|
+
protoDir: built.protoDir,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
assert.equal(audit.pageErrors, 0, 'fixture pages must load without page errors');
|
|
62
|
+
assert.equal(audit.consoleErrors, 0, 'fixture pages must not emit console errors');
|
|
63
|
+
assert.equal(audit.duplicateIds, 0, 'shell replacement must not leave duplicate ids');
|
|
64
|
+
|
|
65
|
+
const donor390 = regionAt(audit, 'donor.html', 390);
|
|
66
|
+
const collision390 = regionAt(audit, 'id-collision.html', 390);
|
|
67
|
+
const control390 = regionAt(audit, 'canvas-control.html', 390);
|
|
68
|
+
const donor1536 = regionAt(audit, 'donor.html', 1536);
|
|
69
|
+
const collision1536 = regionAt(audit, 'id-collision.html', 1536);
|
|
70
|
+
assert.ok(donor390 && collision390 && control390 && donor1536 && collision1536, 'fixture contract: all audited header regions are present');
|
|
71
|
+
assert.deepEqual(donor390.actualGeometry, [0, 0, 390, 72], 'fixture contract: donor mobile shell geometry is fixed');
|
|
72
|
+
assert.deepEqual(collision390.actualGeometry, donor390.actualGeometry, 'outer shell geometry alone cannot conceal descendant displacement');
|
|
73
|
+
assert.equal(donor390.consistencyStatus, 'canonical', 'fixture contract: the donor makes the canonical shell claim');
|
|
74
|
+
assert.equal(collision390.consistencyStatus, 'canonical', 'fixture contract: the committed recipient makes the canonical shell claim');
|
|
75
|
+
assert.equal(control390.consistencyStatus, 'silent', 'must stay silent: canvas/content-only variation makes no canonical-write claim');
|
|
76
|
+
assert.equal(control390.actualSignature, donor390.actualSignature, 'must stay silent: canvas height and page content are outside the shell signature');
|
|
77
|
+
assert.equal(collision1536.actualSignature, donor1536.actualSignature, 'must stay silent: distinct native identities render the same canonical header');
|
|
78
|
+
|
|
79
|
+
const groups = audit.shellConsistencyFailures.map((failure) => failure.group);
|
|
80
|
+
if (expectFailure) {
|
|
81
|
+
assert.deepEqual(groups, ['390:mobile-390:header'], 'pre-fix evidence must isolate one canonical collision to the mobile header');
|
|
82
|
+
assert.notEqual(collision390.actualSignature, donor390.actualSignature, 'must fire before the fix: recipient offsets alter transplanted donor descendants');
|
|
83
|
+
const failure = audit.shellConsistencyFailures[0];
|
|
84
|
+
assert.equal(failure.reason, 'prototype-shell-signature-mismatch', 'pre-fix evidence is the rendered canonical-shell mismatch');
|
|
85
|
+
assert.deepEqual(failure.pages, [
|
|
86
|
+
{ file: 'donor.html', signature: donor390.actualSignature, geometry: donor390.actualGeometry },
|
|
87
|
+
{ file: 'id-collision.html', signature: collision390.actualSignature, geometry: collision390.actualGeometry },
|
|
88
|
+
], 'pre-fix group carries the exact runtime signatures for only the donor and committed recipient');
|
|
89
|
+
} else {
|
|
90
|
+
assert.equal(collision390.actualSignature, donor390.actualSignature, 'must pass after the fix: transplanted donor render matches its canonical source at 390');
|
|
91
|
+
assert.deepEqual(audit.shellConsistencyFailures, [], 'post-fix runtime audit has no canonical shell consistency failures');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
process.stdout.write(JSON.stringify({
|
|
95
|
+
expectation: expectFailure ? 'pre-fix-failure' : 'post-fix-pass',
|
|
96
|
+
groups,
|
|
97
|
+
mobile: {
|
|
98
|
+
donor: donor390.actualSignature,
|
|
99
|
+
collision: collision390.actualSignature,
|
|
100
|
+
control: control390.actualSignature,
|
|
101
|
+
geometry: donor390.actualGeometry,
|
|
102
|
+
},
|
|
103
|
+
native: {
|
|
104
|
+
donor: donor1536.actualSignature,
|
|
105
|
+
collision: collision1536.actualSignature,
|
|
106
|
+
},
|
|
107
|
+
canvasHeights: {
|
|
108
|
+
donor: bandHeight(inventory, 'p0-donor', 'mobile-390'),
|
|
109
|
+
control: bandHeight(inventory, 'p2-canvas-control', 'mobile-390'),
|
|
110
|
+
},
|
|
111
|
+
}, null, 2) + '\n');
|
|
112
|
+
} finally {
|
|
113
|
+
if (browser) await browser.close();
|
|
114
|
+
if (server) await server.close();
|
|
115
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
116
|
+
}
|
|
@@ -39,10 +39,14 @@ Execute stays in the foreground through its network work; `next` can report prog
|
|
|
39
39
|
|
|
40
40
|
Use `--prototype` with `--convert html` when the deliverable should be clickable. The export becomes a working app — navigation, shared shells, and holding pages for unbuilt routes — written to `<run-dir>/branch/prototype/`.
|
|
41
41
|
|
|
42
|
+
Add `--polish` only when the user wants the opt-in production-feel pass. It runs the retained deterministic, harm-guarded rules after build, records every rule as fired, silent, or reverted, and runs the prototype runtime gates on that polished output.
|
|
43
|
+
|
|
42
44
|
For a completed branch that was exported without the flag, run:
|
|
43
45
|
|
|
44
46
|
12ui prototype <run-dir>
|
|
45
47
|
|
|
48
|
+
The same opt-in is available here as `12ui prototype <run-dir> --polish`.
|
|
49
|
+
|
|
46
50
|
## 3. Convert
|
|
47
51
|
|
|
48
52
|
When you have a single image to convert (or for example only designing a single viewport), you can directly convert the image instead of branching it.
|