@12ui/design 0.2.29 → 0.2.30
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 +31 -6
- 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 +4 -0
- package/dist/cli-branch-command.d.ts.map +1 -1
- package/dist/cli-branch-command.js +30 -3
- package/dist/cli-branch-command.js.map +1 -1
- package/dist/cli-capabilities.d.ts +9 -0
- package/dist/cli-capabilities.d.ts.map +1 -1
- package/dist/cli-capabilities.js +9 -0
- package/dist/cli-capabilities.js.map +1 -1
- package/dist/cli-help/design.d.ts +8 -1
- package/dist/cli-help/design.d.ts.map +1 -1
- package/dist/cli-help/design.js +12 -0
- package/dist/cli-help/design.js.map +1 -1
- package/dist/cli-help/index.d.ts +8 -1
- package/dist/cli-help/index.d.ts.map +1 -1
- package/dist/cli-prototype-command.d.ts +12 -0
- package/dist/cli-prototype-command.d.ts.map +1 -0
- package/dist/cli-prototype-command.js +29 -0
- package/dist/cli-prototype-command.js.map +1 -0
- package/dist/cli-review-command.d.ts.map +1 -1
- package/dist/cli-review-command.js +20 -1
- package/dist/cli-review-command.js.map +1 -1
- package/dist/cli-run.d.ts.map +1 -1
- package/dist/cli-run.js +8 -0
- package/dist/cli-run.js.map +1 -1
- package/dist/cli-usage.d.ts.map +1 -1
- package/dist/cli-usage.js +6 -1
- package/dist/cli-usage.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/legacy-skill-catalog.d.ts.map +1 -1
- package/dist/legacy-skill-catalog.js +8 -0
- package/dist/legacy-skill-catalog.js.map +1 -1
- package/dist/prototype-gap-html.d.ts +10 -0
- package/dist/prototype-gap-html.d.ts.map +1 -0
- package/dist/prototype-gap-html.js +55 -0
- package/dist/prototype-gap-html.js.map +1 -0
- package/dist/prototype-gap-source.d.ts +14 -0
- package/dist/prototype-gap-source.d.ts.map +1 -0
- package/dist/prototype-gap-source.js +87 -0
- package/dist/prototype-gap-source.js.map +1 -0
- package/dist/prototype-gates.d.ts +24 -0
- package/dist/prototype-gates.d.ts.map +1 -0
- package/dist/prototype-gates.js +76 -0
- package/dist/prototype-gates.js.map +1 -0
- package/dist/prototype-label-client.d.ts +51 -0
- package/dist/prototype-label-client.d.ts.map +1 -0
- package/dist/prototype-label-client.js +53 -0
- package/dist/prototype-label-client.js.map +1 -0
- package/dist/prototype-label-score.d.ts +26 -0
- package/dist/prototype-label-score.d.ts.map +1 -0
- package/dist/prototype-label-score.js +51 -0
- package/dist/prototype-label-score.js.map +1 -0
- package/dist/prototype-process.d.ts +13 -0
- package/dist/prototype-process.d.ts.map +1 -0
- package/dist/prototype-process.js +26 -0
- package/dist/prototype-process.js.map +1 -0
- package/dist/prototype-requests.d.ts +29 -0
- package/dist/prototype-requests.d.ts.map +1 -0
- package/dist/prototype-requests.js +34 -0
- package/dist/prototype-requests.js.map +1 -0
- package/dist/prototype-runner.d.ts +22 -0
- package/dist/prototype-runner.d.ts.map +1 -0
- package/dist/prototype-runner.js +159 -0
- package/dist/prototype-runner.js.map +1 -0
- package/dist/prototype-source.d.ts +9 -0
- package/dist/prototype-source.d.ts.map +1 -0
- package/dist/prototype-source.js +48 -0
- package/dist/prototype-source.js.map +1 -0
- package/dist/review-discovery.d.ts +1 -0
- package/dist/review-discovery.d.ts.map +1 -1
- package/dist/review-discovery.js +49 -3
- package/dist/review-discovery.js.map +1 -1
- package/dist/review-page-assets.d.ts +2 -2
- package/dist/review-page-assets.d.ts.map +1 -1
- package/dist/review-page-assets.js +20 -1
- package/dist/review-page-assets.js.map +1 -1
- package/dist/review-page.d.ts.map +1 -1
- package/dist/review-page.js +38 -0
- package/dist/review-page.js.map +1 -1
- package/dist/review-prototype-gap.d.ts +48 -0
- package/dist/review-prototype-gap.d.ts.map +1 -0
- package/dist/review-prototype-gap.js +192 -0
- package/dist/review-prototype-gap.js.map +1 -0
- package/dist/review-server.d.ts +7 -0
- package/dist/review-server.d.ts.map +1 -1
- package/dist/review-server.js +64 -0
- package/dist/review-server.js.map +1 -1
- package/dist/review-types.d.ts +12 -0
- package/dist/review-types.d.ts.map +1 -1
- package/dist/run-journal-commands.d.ts.map +1 -1
- package/dist/run-journal-commands.js +13 -0
- package/dist/run-journal-commands.js.map +1 -1
- package/open-design.json +2 -2
- package/package.json +7 -1
- package/prototype-kit/analyze.mjs +275 -0
- package/prototype-kit/build.mjs +393 -0
- package/prototype-kit/inventory.mjs +104 -0
- package/prototype-kit/label-briefs.mjs +47 -0
- package/prototype-kit/lib/actions.mjs +15 -0
- package/prototype-kit/lib/navigation.mjs +33 -0
- package/prototype-kit/lib/runtime-check.mjs +126 -0
- package/prototype-kit/lib/shells.mjs +68 -0
- package/prototype-kit/lib/static-server.mjs +40 -0
- package/prototype-kit/lib/text.mjs +20 -0
- package/prototype-kit/runtime/proto.css +46 -0
- package/prototype-kit/runtime/proto.js +173 -0
- package/prototype-kit/score-labels.mjs +42 -0
- package/prototype-kit/tests/README.md +28 -0
- package/prototype-kit/tests/adversarial-runtime.mjs +90 -0
- package/prototype-kit/tests/discover-runs.mjs +29 -0
- package/prototype-kit/tests/fixtures/no-semantic/branch.plan.json +7 -0
- package/prototype-kit/tests/fixtures/no-semantic/pages/legacy.html +1 -0
- package/prototype-kit/tests/fixtures/partial-plan/branch.plan.json +5 -0
- package/prototype-kit/tests/fixtures/partial-plan/pages/orphan.html +4 -0
- package/prototype-kit/tests/fixtures/runtime/double.html +5 -0
- package/prototype-kit/tests/fixtures/runtime/history-destination.html +1 -0
- package/prototype-kit/tests/fixtures/runtime/history.html +6 -0
- package/prototype-kit/tests/fixtures/runtime/hover-stack.html +15 -0
- package/prototype-kit/tests/fixtures/runtime/invalid-dropdown.html +6 -0
- package/prototype-kit/tests/fixtures/runtime/nested.html +7 -0
- package/prototype-kit/tests/fixtures/three-shell/branch.plan.json +30 -0
- package/prototype-kit/tests/fixtures/three-shell/pages/p0-home.html +11 -0
- package/prototype-kit/tests/fixtures/three-shell/pages/p1-reports.html +11 -0
- package/prototype-kit/tests/lib/run-discovery.mjs +87 -0
- package/prototype-kit/tests/lib/run-process.mjs +49 -0
- package/prototype-kit/tests/offline-check.mjs +41 -0
- package/prototype-kit/tests/population-sweep.mjs +94 -0
- package/prototype-kit/tests/rules-check.mjs +50 -0
- package/prototype-kit/verify-runtime.mjs +36 -0
- package/skills/12ui-design/SKILL.md +8 -2
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
function errorText(error) {
|
|
5
|
+
return error instanceof Error ? error.message : String(error);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function smokeInteraction(page, selector, assertion) {
|
|
9
|
+
const locator = page.locator(selector).first();
|
|
10
|
+
if (!await locator.count()) return { present: false, ok: true };
|
|
11
|
+
try {
|
|
12
|
+
await locator.scrollIntoViewIfNeeded();
|
|
13
|
+
const point = await locator.evaluate((element) => {
|
|
14
|
+
const rect = element.getBoundingClientRect();
|
|
15
|
+
const insetX = Math.min(8, rect.width / 4);
|
|
16
|
+
const insetY = Math.min(8, rect.height / 4);
|
|
17
|
+
const points = [
|
|
18
|
+
[rect.left + rect.width / 2, rect.top + rect.height / 2],
|
|
19
|
+
[rect.left + insetX, rect.top + insetY],
|
|
20
|
+
[rect.right - insetX, rect.top + insetY],
|
|
21
|
+
[rect.left + insetX, rect.bottom - insetY],
|
|
22
|
+
[rect.right - insetX, rect.bottom - insetY],
|
|
23
|
+
];
|
|
24
|
+
for (const [x, y] of points) {
|
|
25
|
+
const receiver = document.elementFromPoint(x, y);
|
|
26
|
+
if (receiver?.closest('[data-proto-action]') === element) return { x, y };
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
});
|
|
30
|
+
if (!point) return { present: true, ok: false, error: 'no pointer-reachable point' };
|
|
31
|
+
// A real pointer lands on the rendered child and bubbles to its action
|
|
32
|
+
// wrapper. locator.click() rejects that valid shape as "intercepted".
|
|
33
|
+
await page.mouse.click(point.x, point.y);
|
|
34
|
+
const opened = await page.locator(assertion).count();
|
|
35
|
+
const ok = opened === 1;
|
|
36
|
+
await page.keyboard.press('Escape');
|
|
37
|
+
return { present: true, ok, point, opened };
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return { present: true, ok: false, error: errorText(error) };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function checkDeepLinkBack(browser, url, pageFile) {
|
|
44
|
+
const page = await browser.newPage({ viewport: { width: 1536, height: 1024 } });
|
|
45
|
+
try {
|
|
46
|
+
await page.goto(url, { waitUntil: 'networkidle' });
|
|
47
|
+
const anchor = page.locator(`[data-proto-action="navigate"][data-proto-target^="${pageFile}#"]`).first();
|
|
48
|
+
const target = await anchor.count() ? await anchor.getAttribute('data-proto-target') : null;
|
|
49
|
+
if (!target) return { present: false, ok: true };
|
|
50
|
+
const start = page.url();
|
|
51
|
+
const beforeLength = await page.evaluate(() => history.length);
|
|
52
|
+
await page.locator(`[data-proto-action="navigate"][data-proto-target="${target}"]`).first().click();
|
|
53
|
+
await page.waitForTimeout(250);
|
|
54
|
+
const afterClick = page.url();
|
|
55
|
+
const afterLength = await page.evaluate(() => history.length);
|
|
56
|
+
await page.goBack({ waitUntil: 'domcontentloaded', timeout: 3_000 }).catch(() => null);
|
|
57
|
+
await page.waitForTimeout(150);
|
|
58
|
+
const afterBack = page.url();
|
|
59
|
+
return {
|
|
60
|
+
present: true,
|
|
61
|
+
ok: afterClick.includes('#') && afterLength > beforeLength && afterBack === start,
|
|
62
|
+
start,
|
|
63
|
+
afterClick,
|
|
64
|
+
afterBack,
|
|
65
|
+
historyLength: [beforeLength, afterLength],
|
|
66
|
+
};
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return { present: true, ok: false, error: errorText(error) };
|
|
69
|
+
} finally {
|
|
70
|
+
await page.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function checkRuntime({ browser, baseUrl, relativeProtoPath, protoDir }) {
|
|
75
|
+
const htmlFiles = fs.readdirSync(protoDir).filter((name) => name.endsWith('.html') && name !== 'index.html').sort();
|
|
76
|
+
const pages = [];
|
|
77
|
+
const brokenTargets = [];
|
|
78
|
+
let deepLinkFile = null;
|
|
79
|
+
for (const file of htmlFiles) {
|
|
80
|
+
const pageErrors = [];
|
|
81
|
+
const consoleErrors = [];
|
|
82
|
+
const page = await browser.newPage({ viewport: { width: 1536, height: 1024 } });
|
|
83
|
+
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
84
|
+
page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()); });
|
|
85
|
+
const url = `${baseUrl}/${relativeProtoPath}/${encodeURIComponent(file)}`;
|
|
86
|
+
try {
|
|
87
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 15_000 });
|
|
88
|
+
const boundRaw = await page.evaluate(() => document.documentElement.dataset.protoBound || null);
|
|
89
|
+
const bound = boundRaw ? JSON.parse(boundRaw) : null;
|
|
90
|
+
const duplicateIds = await page.locator('[id]').evaluateAll((nodes) => {
|
|
91
|
+
const counts = new Map();
|
|
92
|
+
for (const node of nodes) counts.set(node.id, (counts.get(node.id) || 0) + 1);
|
|
93
|
+
return [...counts].filter(([, count]) => count > 1).map(([id, count]) => ({ id, count }));
|
|
94
|
+
});
|
|
95
|
+
const targets = await page.locator('[data-proto-action="navigate"]').evaluateAll((nodes) => nodes.map((node) => node.getAttribute('data-proto-target')));
|
|
96
|
+
for (const target of targets.filter(Boolean)) {
|
|
97
|
+
const [targetFile, hash] = target.split('#');
|
|
98
|
+
if (!deepLinkFile && targetFile === file && hash) deepLinkFile = file;
|
|
99
|
+
const targetPath = path.join(protoDir, targetFile);
|
|
100
|
+
if (!fs.existsSync(targetPath)) brokenTargets.push({ page: file, target, reason: 'missing file' });
|
|
101
|
+
else if (hash && !fs.readFileSync(targetPath, 'utf8').includes(`id="${hash}"`)) brokenTargets.push({ page: file, target, reason: 'missing anchor' });
|
|
102
|
+
}
|
|
103
|
+
const dropdown = await smokeInteraction(page, '[data-proto-action="dropdown"]', '.proto-menu');
|
|
104
|
+
const modal = await smokeInteraction(page, '[data-proto-action="modal"]', '.proto-modal');
|
|
105
|
+
pages.push({ file, ok: pageErrors.length === 0 && dropdown.ok && modal.ok, bound, duplicateIds, pageErrors, consoleErrors, dropdown, modal });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
pages.push({ file, ok: false, bound: null, pageErrors, consoleErrors, loadError: errorText(error) });
|
|
108
|
+
} finally {
|
|
109
|
+
await page.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
let deepLinkBack = { present: false, ok: true };
|
|
113
|
+
if (deepLinkFile) {
|
|
114
|
+
const result = await checkDeepLinkBack(browser, `${baseUrl}/${relativeProtoPath}/${encodeURIComponent(deepLinkFile)}`, deepLinkFile);
|
|
115
|
+
deepLinkBack = { ...result, file: deepLinkFile };
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
pages,
|
|
119
|
+
pageErrors: pages.reduce((sum, page) => sum + page.pageErrors.length + (page.loadError ? 1 : 0), 0),
|
|
120
|
+
consoleErrors: pages.reduce((sum, page) => sum + page.consoleErrors.length, 0),
|
|
121
|
+
duplicateIds: pages.reduce((sum, page) => sum + (page.duplicateIds?.length || 0), 0),
|
|
122
|
+
brokenTargets,
|
|
123
|
+
interactionFailures: pages.filter((page) => page.dropdown?.ok === false || page.modal?.ok === false).map((page) => page.file),
|
|
124
|
+
deepLinkBack,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
function edgeDistance(shell) {
|
|
2
|
+
if (shell.region === 'sidebar') return shell.box[0];
|
|
3
|
+
if (shell.region === 'header') return shell.box[1];
|
|
4
|
+
if (shell.region === 'footer') return -(shell.box[1] + shell.box[3]);
|
|
5
|
+
return 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function compareShells(a, b) {
|
|
9
|
+
return Number(b.semantic) - Number(a.semantic)
|
|
10
|
+
|| edgeDistance(a) - edgeDistance(b)
|
|
11
|
+
|| b.items - a.items
|
|
12
|
+
|| b.share - a.share
|
|
13
|
+
|| b.box[2] * b.box[3] - a.box[2] * a.box[3];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function selectOneShellPerRegion(shells) {
|
|
17
|
+
const regions = new Map();
|
|
18
|
+
for (const shell of shells) {
|
|
19
|
+
const list = regions.get(shell.region) || [];
|
|
20
|
+
list.push(shell);
|
|
21
|
+
regions.set(shell.region, list);
|
|
22
|
+
}
|
|
23
|
+
const selected = [];
|
|
24
|
+
const dropped = [];
|
|
25
|
+
for (const list of regions.values()) {
|
|
26
|
+
list.sort(compareShells);
|
|
27
|
+
selected.push(list[0]);
|
|
28
|
+
dropped.push(...list.slice(1));
|
|
29
|
+
}
|
|
30
|
+
return { selected, dropped };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isWithinShell(ancestorIds, shellIds) {
|
|
34
|
+
return ancestorIds.some((id) => shellIds.has(id));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function shellOverlapRatio(a, b) {
|
|
38
|
+
if (!a?.box || !b?.box) return 0;
|
|
39
|
+
const [ax, ay, aw, ah] = a.box;
|
|
40
|
+
const [bx, by, bw, bh] = b.box;
|
|
41
|
+
const width = Math.max(0, Math.min(ax + aw, bx + bw) - Math.max(ax, bx));
|
|
42
|
+
const height = Math.max(0, Math.min(ay + ah, by + bh) - Math.max(ay, by));
|
|
43
|
+
return width * height / Math.max(1, Math.min(aw * ah, bw * bh));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function shouldSuppressDroppedShell(ambiguity) {
|
|
47
|
+
return shellOverlapRatio({ box: ambiguity.droppedBox }, { box: ambiguity.keptBox }) >= 0.8;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function qualifiesShellCandidate(shell, viewportWidth) {
|
|
51
|
+
if (!shell.region) return false;
|
|
52
|
+
if ((shell.region === 'header' || shell.region === 'footer') && shell.box[2] < 0.5 * viewportWidth) return false;
|
|
53
|
+
return shell.share >= 0.5 || (shell.semantic && shell.region === 'sidebar' && shell.items >= 3);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function compareCanonicalShells(a, b) {
|
|
57
|
+
return Number(b.share >= 0.5) - Number(a.share >= 0.5)
|
|
58
|
+
|| b.matches - a.matches
|
|
59
|
+
|| Number(b.isSource) - Number(a.isSource)
|
|
60
|
+
|| b.share * b.items - a.share * a.items;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function shouldTransplantShell(ownBox, canonicalBox) {
|
|
64
|
+
if (!ownBox || !canonicalBox) return false;
|
|
65
|
+
const dimensionRatio = (a, b) => Math.min(a, b) / Math.max(1, a, b);
|
|
66
|
+
return dimensionRatio(ownBox[2], canonicalBox[2]) >= 0.75
|
|
67
|
+
&& dimensionRatio(ownBox[3], canonicalBox[3]) >= 0.75;
|
|
68
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const TYPES = new Map([
|
|
6
|
+
['.css', 'text/css; charset=utf-8'],
|
|
7
|
+
['.html', 'text/html; charset=utf-8'],
|
|
8
|
+
['.js', 'text/javascript; charset=utf-8'],
|
|
9
|
+
['.json', 'application/json; charset=utf-8'],
|
|
10
|
+
['.png', 'image/png'],
|
|
11
|
+
['.svg', 'image/svg+xml'],
|
|
12
|
+
['.webp', 'image/webp'],
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export async function startStaticServer(root) {
|
|
16
|
+
const absoluteRoot = path.resolve(root);
|
|
17
|
+
const server = http.createServer((request, response) => {
|
|
18
|
+
const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
|
|
19
|
+
const candidate = path.resolve(absoluteRoot, '.' + pathname);
|
|
20
|
+
if (candidate !== absoluteRoot && !candidate.startsWith(absoluteRoot + path.sep)) {
|
|
21
|
+
response.writeHead(403).end('forbidden');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
let file = candidate;
|
|
25
|
+
try {
|
|
26
|
+
if (fs.statSync(file).isDirectory()) file = path.join(file, 'index.html');
|
|
27
|
+
const body = fs.readFileSync(file);
|
|
28
|
+
response.writeHead(200, { 'content-type': TYPES.get(path.extname(file)) || 'application/octet-stream' });
|
|
29
|
+
response.end(body);
|
|
30
|
+
} catch {
|
|
31
|
+
response.writeHead(404).end('not found');
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
35
|
+
const address = server.address();
|
|
36
|
+
return {
|
|
37
|
+
baseUrl: `http://127.0.0.1:${address.port}`,
|
|
38
|
+
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function normalizeText(value) {
|
|
2
|
+
return String(value || '')
|
|
3
|
+
.normalize('NFKC')
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.replace(/[’']/g, "'")
|
|
6
|
+
.replace(/[^\p{L}\p{N}&+ ]/gu, ' ')
|
|
7
|
+
.replace(/\s+/g, ' ')
|
|
8
|
+
.trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function stemToken(token) {
|
|
12
|
+
if (!/^[a-z]+$/.test(token)) return token;
|
|
13
|
+
return token.replace(/ies$/, 'y').replace(/s$/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function slugText(value) {
|
|
17
|
+
return normalizeText(value)
|
|
18
|
+
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
19
|
+
.replace(/^-|-$/g, '') || 'untitled';
|
|
20
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* Prototype runtime chrome. Colours/fonts come from CSS variables set from the page's own tokens. */
|
|
2
|
+
.proto-clickable { cursor: pointer; }
|
|
3
|
+
.proto-clickable:hover { background-color: rgba(0, 0, 0, 0.035); }
|
|
4
|
+
p.proto-clickable:hover { text-decoration: underline; text-underline-offset: 3px; }
|
|
5
|
+
|
|
6
|
+
#proto-overlay-root { position: absolute; inset: 0; z-index: 2147483000; pointer-events: none; }
|
|
7
|
+
#proto-overlay-root > * { pointer-events: auto; }
|
|
8
|
+
|
|
9
|
+
.proto-backdrop { position: fixed; inset: 0; background: rgba(20, 24, 20, 0.28); display: flex; align-items: center; justify-content: center; animation: proto-fade 140ms ease-out; }
|
|
10
|
+
.proto-backdrop-clear { background: transparent; animation: none; }
|
|
11
|
+
|
|
12
|
+
.proto-menu { position: absolute; background: var(--proto-surface); border: 1px solid var(--proto-border); border-radius: calc(var(--proto-radius) * 0.6); box-shadow: 0 12px 32px rgba(0,0,0,0.10), 0 1px 2px rgba(0,0,0,0.06); padding: 6px; font-family: var(--proto-font); animation: proto-pop 120ms ease-out; }
|
|
13
|
+
.proto-menu-item { display: block; width: 100%; text-align: left; background: transparent; border: 0; padding: 9px 12px; border-radius: 8px; font: inherit; font-size: 14px; color: var(--proto-text); cursor: pointer; white-space: nowrap; }
|
|
14
|
+
.proto-menu-item:hover { background: rgba(0,0,0,0.05); }
|
|
15
|
+
.proto-menu-item:first-child { color: var(--proto-heading); font-weight: 500; }
|
|
16
|
+
|
|
17
|
+
.proto-modal { width: 440px; max-width: calc(100vw - 48px); background: var(--proto-surface); border: 1px solid var(--proto-border); border-radius: var(--proto-radius); box-shadow: 0 24px 64px rgba(0,0,0,0.16); padding: 28px 28px 22px; font-family: var(--proto-font); animation: proto-pop 160ms ease-out; }
|
|
18
|
+
.proto-modal-title { font-size: 20px; font-weight: 600; color: var(--proto-heading); margin: 0 0 10px; line-height: 1.25; }
|
|
19
|
+
.proto-modal-body { font-size: 14px; line-height: 1.55; color: var(--proto-text); margin: 0 0 8px; }
|
|
20
|
+
.proto-modal-detail { font-size: 14px; line-height: 1.5; color: var(--proto-muted); margin: 0 0 8px; font-style: italic; }
|
|
21
|
+
.proto-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
|
|
22
|
+
.proto-btn { font: inherit; font-size: 14px; font-weight: 500; padding: 9px 16px; border-radius: 999px; border: 1px solid transparent; cursor: pointer; }
|
|
23
|
+
.proto-btn-primary { background: var(--proto-button); color: #fff; }
|
|
24
|
+
.proto-btn-primary:hover { filter: brightness(1.08); }
|
|
25
|
+
.proto-btn-ghost { background: transparent; color: var(--proto-text); border-color: var(--proto-border); }
|
|
26
|
+
.proto-btn-ghost:hover { background: rgba(0,0,0,0.04); }
|
|
27
|
+
|
|
28
|
+
.proto-toast { position: fixed; left: 50%; bottom: 28px; transform: translate(-50%, 12px); background: var(--proto-heading); color: #fff; font-family: var(--proto-font); font-size: 14px; padding: 10px 16px; border-radius: 999px; opacity: 0; transition: opacity 200ms, transform 200ms; z-index: 2147483001; pointer-events: none; }
|
|
29
|
+
.proto-toast-in { opacity: 1; transform: translate(-50%, 0); }
|
|
30
|
+
|
|
31
|
+
.proto-field-text { outline: none; cursor: text; min-width: 40px; }
|
|
32
|
+
.proto-field-text.proto-field-active { color: var(--proto-heading) !important; }
|
|
33
|
+
.proto-field-text:empty::before { content: attr(data-placeholder); color: inherit; opacity: 0.6; }
|
|
34
|
+
|
|
35
|
+
.proto-holding-block { position: absolute; font-family: var(--proto-font); }
|
|
36
|
+
.proto-holding-title { font-weight: 600; color: var(--proto-heading); margin: 0 0 8px; letter-spacing: -0.01em; }
|
|
37
|
+
.proto-holding-sub { font-size: 15px; line-height: 1.5; color: var(--proto-muted); margin: 0 0 28px; max-width: 560px; }
|
|
38
|
+
.proto-holding-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
|
39
|
+
.proto-holding-card { background: var(--proto-surface); border: 1px solid var(--proto-border); border-radius: var(--proto-radius); padding: 20px; min-height: 140px; }
|
|
40
|
+
.proto-holding-card-title { font-size: 14px; font-weight: 600; color: var(--proto-heading); margin: 0 0 10px; }
|
|
41
|
+
.proto-holding-card-line { height: 10px; border-radius: 5px; background: rgba(0,0,0,0.06); margin: 8px 0; }
|
|
42
|
+
.proto-holding-card-line.short { width: 60%; }
|
|
43
|
+
.proto-holding-note { margin-top: 24px; font-size: 12px; color: var(--proto-muted); }
|
|
44
|
+
|
|
45
|
+
@keyframes proto-fade { from { opacity: 0; } to { opacity: 1; } }
|
|
46
|
+
@keyframes proto-pop { from { opacity: 0; transform: translateY(4px) scale(0.985); } to { opacity: 1; transform: none; } }
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Prototype runtime: binds data-proto-* actions, hydrates copy from the content layer, renders dropdowns/modals.
|
|
2
|
+
(() => {
|
|
3
|
+
if (document.documentElement.dataset.protoRuntime === 'bound') return;
|
|
4
|
+
document.documentElement.dataset.protoRuntime = 'bound';
|
|
5
|
+
const cfg = JSON.parse(document.getElementById('proto-state').textContent);
|
|
6
|
+
const T = cfg.tokens;
|
|
7
|
+
const qs = new URLSearchParams(location.search);
|
|
8
|
+
const contentSet = qs.get('content') || '';
|
|
9
|
+
const keepQuery = contentSet ? `?content=${encodeURIComponent(contentSet)}` : '';
|
|
10
|
+
|
|
11
|
+
// ---- content layer: replace text nodes from content/<state>.json (or content-<set>/)
|
|
12
|
+
async function hydrate() {
|
|
13
|
+
if (!cfg.contentFile) return;
|
|
14
|
+
const dir = contentSet ? `content-${contentSet}` : 'content';
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(`${dir}/${cfg.contentFile}`, { cache: 'no-store' });
|
|
17
|
+
if (!res.ok) return;
|
|
18
|
+
const data = await res.json();
|
|
19
|
+
let changed = 0;
|
|
20
|
+
for (const [id, text] of Object.entries(data)) {
|
|
21
|
+
const el = document.getElementById(id);
|
|
22
|
+
if (!el || el.textContent === text) continue;
|
|
23
|
+
el.textContent = text; changed++;
|
|
24
|
+
}
|
|
25
|
+
document.documentElement.dataset.protoContent = `${dir}:${changed}`;
|
|
26
|
+
} catch (e) { /* file:// or missing set: keep baked-in copy */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---- overlay primitives, styled from the page's own tokens
|
|
30
|
+
const root = document.createElement('div');
|
|
31
|
+
root.id = 'proto-overlay-root';
|
|
32
|
+
document.body.appendChild(root);
|
|
33
|
+
const style = document.documentElement.style;
|
|
34
|
+
style.setProperty('--proto-font', T.bodyFont ? `'${T.bodyFont}', sans-serif` : 'inherit');
|
|
35
|
+
style.setProperty('--proto-text', T.bodyColor || '#222');
|
|
36
|
+
style.setProperty('--proto-muted', T.mutedColor || '#777');
|
|
37
|
+
style.setProperty('--proto-heading', T.headingColor || '#111');
|
|
38
|
+
style.setProperty('--proto-accent', T.accent || '#222');
|
|
39
|
+
style.setProperty('--proto-surface', T.surfaceBg && T.surfaceBg !== 'rgba(0, 0, 0, 0)' ? T.surfaceBg : '#fff');
|
|
40
|
+
style.setProperty('--proto-border', (T.cardBorder || '1px rgb(230,230,230)').replace(/^\S+\s/, ''));
|
|
41
|
+
style.setProperty('--proto-radius', T.cardRadius || '12px');
|
|
42
|
+
style.setProperty('--proto-button', T.buttonBg && T.buttonBg !== 'rgb(255, 255, 255)' ? T.buttonBg : (T.accent || '#222'));
|
|
43
|
+
|
|
44
|
+
function closeAll() { root.innerHTML = ''; }
|
|
45
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeAll(); });
|
|
46
|
+
|
|
47
|
+
function openDropdown(trigger, options, labelEl) {
|
|
48
|
+
closeAll();
|
|
49
|
+
const r = trigger.getBoundingClientRect();
|
|
50
|
+
const menu = document.createElement('div');
|
|
51
|
+
menu.className = 'proto-menu';
|
|
52
|
+
menu.style.left = `${r.left + window.scrollX}px`;
|
|
53
|
+
menu.style.top = `${r.bottom + window.scrollY + 6}px`;
|
|
54
|
+
menu.style.minWidth = `${Math.max(160, r.width)}px`;
|
|
55
|
+
for (const opt of options) {
|
|
56
|
+
const item = document.createElement('button');
|
|
57
|
+
item.className = 'proto-menu-item';
|
|
58
|
+
item.textContent = opt;
|
|
59
|
+
item.addEventListener('click', (e) => {
|
|
60
|
+
e.stopPropagation();
|
|
61
|
+
if (labelEl) labelEl.textContent = opt;
|
|
62
|
+
closeAll();
|
|
63
|
+
});
|
|
64
|
+
menu.appendChild(item);
|
|
65
|
+
}
|
|
66
|
+
root.appendChild(menu);
|
|
67
|
+
const backdrop = document.createElement('div');
|
|
68
|
+
backdrop.className = 'proto-backdrop proto-backdrop-clear';
|
|
69
|
+
backdrop.addEventListener('click', closeAll);
|
|
70
|
+
root.insertBefore(backdrop, menu);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function openModal({ title, body, confirm, detail }) {
|
|
74
|
+
closeAll();
|
|
75
|
+
const backdrop = document.createElement('div');
|
|
76
|
+
backdrop.className = 'proto-backdrop';
|
|
77
|
+
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) closeAll(); });
|
|
78
|
+
const modal = document.createElement('div');
|
|
79
|
+
modal.className = 'proto-modal';
|
|
80
|
+
modal.innerHTML = `
|
|
81
|
+
<h2 class="proto-modal-title"></h2>
|
|
82
|
+
<p class="proto-modal-body"></p>
|
|
83
|
+
${detail ? '<p class="proto-modal-detail"></p>' : ''}
|
|
84
|
+
<div class="proto-modal-actions">
|
|
85
|
+
<button class="proto-btn proto-btn-ghost" data-act="cancel">Cancel</button>
|
|
86
|
+
<button class="proto-btn proto-btn-primary" data-act="confirm"></button>
|
|
87
|
+
</div>`;
|
|
88
|
+
modal.querySelector('.proto-modal-title').textContent = title || 'Confirm';
|
|
89
|
+
modal.querySelector('.proto-modal-body').textContent = body || '';
|
|
90
|
+
if (detail) modal.querySelector('.proto-modal-detail').textContent = detail;
|
|
91
|
+
modal.querySelector('[data-act="confirm"]').textContent = confirm || 'Confirm';
|
|
92
|
+
modal.querySelector('[data-act="cancel"]').addEventListener('click', closeAll);
|
|
93
|
+
modal.querySelector('[data-act="confirm"]').addEventListener('click', () => {
|
|
94
|
+
toast(`${confirm || 'Done'} — ${title || ''}`.trim());
|
|
95
|
+
closeAll();
|
|
96
|
+
});
|
|
97
|
+
backdrop.appendChild(modal);
|
|
98
|
+
root.appendChild(backdrop);
|
|
99
|
+
modal.querySelector('[data-act="confirm"]').focus();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toast(text) {
|
|
103
|
+
const t = document.createElement('div');
|
|
104
|
+
t.className = 'proto-toast';
|
|
105
|
+
t.textContent = text;
|
|
106
|
+
document.body.appendChild(t);
|
|
107
|
+
requestAnimationFrame(() => t.classList.add('proto-toast-in'));
|
|
108
|
+
setTimeout(() => { t.classList.remove('proto-toast-in'); setTimeout(() => t.remove(), 300); }, 1800);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- bind actions
|
|
112
|
+
const bound = { navigate: 0, dropdown: 0, modal: 0, field: 0 };
|
|
113
|
+
for (const el of document.querySelectorAll('[data-proto-action]')) {
|
|
114
|
+
const action = el.dataset.protoAction;
|
|
115
|
+
el.classList.add('proto-clickable');
|
|
116
|
+
if (action === 'navigate') {
|
|
117
|
+
bound.navigate++;
|
|
118
|
+
el.addEventListener('click', (e) => {
|
|
119
|
+
e.preventDefault(); e.stopPropagation();
|
|
120
|
+
const tgt = el.dataset.protoTarget;
|
|
121
|
+
const [file, hash] = tgt.split('#');
|
|
122
|
+
if (hash && location.pathname.endsWith('/' + file)) { const t = document.getElementById(hash); if (t) { t.scrollIntoView({ behavior: 'smooth', block: 'start' }); history.pushState({ protoAnchor: hash }, '', '#' + hash); return; } }
|
|
123
|
+
location.href = file + keepQuery + (hash ? '#' + hash : '');
|
|
124
|
+
});
|
|
125
|
+
} else if (action === 'dropdown') {
|
|
126
|
+
const options = JSON.parse(el.dataset.protoOptions || '[]');
|
|
127
|
+
if (new Set(options.map((option) => String(option).trim().toLocaleLowerCase())).size < 3) {
|
|
128
|
+
el.dataset.protoSuppressed = 'invalid-dropdown';
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
bound.dropdown++;
|
|
132
|
+
const labelEl = el.dataset.protoLabel ? document.getElementById(el.dataset.protoLabel) : null;
|
|
133
|
+
el.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openDropdown(el, options, labelEl); });
|
|
134
|
+
} else if (action === 'modal') {
|
|
135
|
+
bound.modal++;
|
|
136
|
+
const modal = JSON.parse(el.dataset.protoModal || '{}');
|
|
137
|
+
el.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openModal(modal); });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- fields: the placeholder text becomes editable; Enter submits to a modal
|
|
142
|
+
for (const el of document.querySelectorAll('[data-proto-field]')) {
|
|
143
|
+
const textEl = el.dataset.protoField ? document.getElementById(el.dataset.protoField) : null;
|
|
144
|
+
if (!textEl) continue;
|
|
145
|
+
bound.field++;
|
|
146
|
+
const placeholder = textEl.textContent;
|
|
147
|
+
const submitLabel = el.dataset.protoSubmit || 'Send';
|
|
148
|
+
textEl.contentEditable = 'true';
|
|
149
|
+
textEl.spellcheck = false;
|
|
150
|
+
textEl.classList.add('proto-field-text');
|
|
151
|
+
textEl.dataset.placeholder = placeholder;
|
|
152
|
+
textEl.addEventListener('focus', () => { if (textEl.textContent === placeholder) { textEl.textContent = ''; textEl.classList.add('proto-field-active'); } });
|
|
153
|
+
textEl.addEventListener('blur', () => { if (!textEl.textContent.trim()) { textEl.textContent = placeholder; textEl.classList.remove('proto-field-active'); } });
|
|
154
|
+
textEl.addEventListener('keydown', (e) => {
|
|
155
|
+
if (e.key !== 'Enter') return;
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
const value = textEl.textContent.trim();
|
|
158
|
+
if (!value) return;
|
|
159
|
+
openModal({ title: submitLabel, body: el.dataset.protoSubmitBody || 'Received. This is a prototype, so nothing was sent.', detail: `“${value}”`, confirm: 'OK' });
|
|
160
|
+
textEl.blur();
|
|
161
|
+
});
|
|
162
|
+
el.addEventListener('click', (e) => { if (e.target !== textEl) { textEl.focus(); } });
|
|
163
|
+
for (const btn of document.querySelectorAll(`[data-proto-submit-for="${textEl.id}"]`)) {
|
|
164
|
+
btn.classList.add('proto-clickable');
|
|
165
|
+
btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); textEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// same-page anchors: scroll to the target viewport section
|
|
169
|
+
if (location.hash) { const t = document.querySelector(location.hash); if (t) t.scrollIntoView({ block: 'start' }); }
|
|
170
|
+
|
|
171
|
+
document.documentElement.dataset.protoBound = JSON.stringify(bound);
|
|
172
|
+
hydrate();
|
|
173
|
+
})();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Score Luna label rounds against gold. Usage: node score-labels.mjs <model.json> <gold.json> <results-dir>...
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const [modelFile, goldFile, ...dirs] = process.argv.slice(2);
|
|
5
|
+
const model = JSON.parse(fs.readFileSync(modelFile, 'utf8'));
|
|
6
|
+
const gold = JSON.parse(fs.readFileSync(goldFile, 'utf8'));
|
|
7
|
+
const norm = (s) => (s || '').toLowerCase().replace(/[^a-z0-9&]+/g, ' ').trim();
|
|
8
|
+
function key(a) {
|
|
9
|
+
if (!a || !a.action) return 'missing';
|
|
10
|
+
if (a.action === 'navigate') return 'navigate:' + (a.target || '');
|
|
11
|
+
return a.action;
|
|
12
|
+
}
|
|
13
|
+
function matches(got, acceptable) {
|
|
14
|
+
for (const acc of acceptable) {
|
|
15
|
+
if (acc === got) return true;
|
|
16
|
+
if (acc === 'navigate:holding:*' && /^navigate:holding:/.test(got)) return true;
|
|
17
|
+
if (/^navigate:holding:/.test(acc) && /^navigate:holding:/.test(got) && norm(acc.slice(17)) === norm(got.slice(17))) return true;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
for (const dir of dirs) {
|
|
22
|
+
const tot = { n: 0, ok: 0, missing: 0, byKind: {}, wrong: [] };
|
|
23
|
+
for (const s of model.states) {
|
|
24
|
+
const g = gold[s.stateId]; if (!g) continue;
|
|
25
|
+
const f = path.join(dir, s.stateId + '.json'); if (!fs.existsSync(f)) continue;
|
|
26
|
+
const acts = JSON.parse(fs.readFileSync(f, 'utf8')).actions || [];
|
|
27
|
+
const prefix = 'viewport-' + s.sectionId.split('-').slice(1).join('-') + '-';
|
|
28
|
+
for (const [suffix, acceptable] of Object.entries(g)) {
|
|
29
|
+
const id = prefix + suffix;
|
|
30
|
+
const c = s.candidates.find((x) => x.id === id);
|
|
31
|
+
const kind = c ? c.kind : 'other';
|
|
32
|
+
const a = acts.find((x) => x.id === id);
|
|
33
|
+
const got = key(a);
|
|
34
|
+
tot.n++; tot.byKind[kind] = tot.byKind[kind] || { n: 0, ok: 0 }; tot.byKind[kind].n++;
|
|
35
|
+
if (got === 'missing') tot.missing++;
|
|
36
|
+
if (matches(got, acceptable)) { tot.ok++; tot.byKind[kind].ok++; } else tot.wrong.push(`${s.stateId} ${suffix} [${kind}] got=${got} want=${acceptable.join('|')}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
console.log(`\n== ${dir}: ${tot.ok}/${tot.n} acceptable (${(100 * tot.ok / tot.n).toFixed(1)}%), missing=${tot.missing}`);
|
|
40
|
+
for (const [k, v] of Object.entries(tot.byKind)) console.log(` ${k.padEnd(9)} ${v.ok}/${v.n}`);
|
|
41
|
+
for (const w of tot.wrong) console.log(' x ' + w);
|
|
42
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Prototype break-test harness
|
|
2
|
+
|
|
3
|
+
All commands are offline unless a command explicitly invokes the existing Luna
|
|
4
|
+
label fan. The population runner is intentionally serial: one stored branch is
|
|
5
|
+
inventoried, analysed, built, and browser-checked before the next begins.
|
|
6
|
+
|
|
7
|
+
Discover unique stored outputs (exact aliases are retained on each row):
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
node discover-runs.mjs <root>... --out <manifest.json>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Generated inventories, prototypes, screenshots, and result JSON belong in the
|
|
14
|
+
study's `work/` or `evidence/` directories, never in `tests/`.
|
|
15
|
+
|
|
16
|
+
Run the full population, one stored output at a time:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
node population-sweep.mjs \
|
|
20
|
+
--manifest <manifest.json> \
|
|
21
|
+
--work-dir <generated-work-dir> \
|
|
22
|
+
--results <results.jsonl>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The JSONL is append-only and resumable by run fingerprint. A row records the
|
|
26
|
+
failed stage or, for successful builds, real/holding page counts, action counts,
|
|
27
|
+
runtime page errors, broken targets, shell-size residuals, and same-page
|
|
28
|
+
deep-link/back behavior.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { chromium } from '@playwright/test';
|
|
6
|
+
import { startStaticServer } from '../lib/static-server.mjs';
|
|
7
|
+
|
|
8
|
+
const outIndex = process.argv.indexOf('--out');
|
|
9
|
+
const outFile = outIndex >= 0 ? process.argv[outIndex + 1] : null;
|
|
10
|
+
const kitDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
|
11
|
+
const server = await startStaticServer(kitDir);
|
|
12
|
+
const browser = await chromium.launch();
|
|
13
|
+
|
|
14
|
+
const doublePage = await browser.newPage();
|
|
15
|
+
await doublePage.goto(`${server.baseUrl}/tests/fixtures/runtime/double.html`, { waitUntil: 'networkidle' });
|
|
16
|
+
await doublePage.locator('#open').click();
|
|
17
|
+
const doubleBinding = {
|
|
18
|
+
overlayRoots: await doublePage.locator('#proto-overlay-root').count(),
|
|
19
|
+
modals: await doublePage.locator('.proto-modal').count(),
|
|
20
|
+
};
|
|
21
|
+
await doublePage.close();
|
|
22
|
+
|
|
23
|
+
const nestedPage = await browser.newPage();
|
|
24
|
+
await nestedPage.goto(`${server.baseUrl}/tests/fixtures/runtime/nested.html`, { waitUntil: 'networkidle' });
|
|
25
|
+
await nestedPage.locator('#child').click();
|
|
26
|
+
const nestedBinding = {
|
|
27
|
+
modals: await nestedPage.locator('.proto-modal').count(),
|
|
28
|
+
menus: await nestedPage.locator('.proto-menu').count(),
|
|
29
|
+
};
|
|
30
|
+
await nestedPage.close();
|
|
31
|
+
|
|
32
|
+
const dropdownPage = await browser.newPage();
|
|
33
|
+
await dropdownPage.goto(`${server.baseUrl}/tests/fixtures/runtime/invalid-dropdown.html`, { waitUntil: 'networkidle' });
|
|
34
|
+
await dropdownPage.locator('#invalid').click();
|
|
35
|
+
const invalidOpened = await dropdownPage.locator('.proto-menu').count();
|
|
36
|
+
await dropdownPage.locator('#valid').click();
|
|
37
|
+
const dropdownGuard = {
|
|
38
|
+
invalidOpened,
|
|
39
|
+
validOpened: await dropdownPage.locator('.proto-menu').count(),
|
|
40
|
+
bound: JSON.parse(await dropdownPage.locator('html').getAttribute('data-proto-bound')),
|
|
41
|
+
};
|
|
42
|
+
await dropdownPage.close();
|
|
43
|
+
|
|
44
|
+
const hoverPage = await browser.newPage();
|
|
45
|
+
await hoverPage.goto(`${server.baseUrl}/tests/fixtures/runtime/hover-stack.html`, { waitUntil: 'networkidle' });
|
|
46
|
+
await hoverPage.locator('#stacked-button').click();
|
|
47
|
+
const stackedOpened = await hoverPage.locator('.proto-modal').count();
|
|
48
|
+
await hoverPage.keyboard.press('Escape');
|
|
49
|
+
await hoverPage.locator('#plain').click();
|
|
50
|
+
const hoverStacking = { stackedOpened, plainOpened: await hoverPage.locator('.proto-modal').count() };
|
|
51
|
+
await hoverPage.close();
|
|
52
|
+
|
|
53
|
+
const historyPage = await browser.newPage();
|
|
54
|
+
const historyStart = `${server.baseUrl}/tests/fixtures/runtime/history.html`;
|
|
55
|
+
await historyPage.goto(historyStart, { waitUntil: 'networkidle' });
|
|
56
|
+
const beforeLength = await historyPage.evaluate(() => history.length);
|
|
57
|
+
await historyPage.locator('#jump').click();
|
|
58
|
+
await historyPage.waitForTimeout(150);
|
|
59
|
+
const afterClick = historyPage.url();
|
|
60
|
+
const afterLength = await historyPage.evaluate(() => history.length);
|
|
61
|
+
await historyPage.goBack({ waitUntil: 'domcontentloaded', timeout: 2_000 }).catch(() => null);
|
|
62
|
+
await historyPage.waitForTimeout(150);
|
|
63
|
+
const deepLinkBack = {
|
|
64
|
+
start: historyStart,
|
|
65
|
+
afterClick,
|
|
66
|
+
afterBack: historyPage.url(),
|
|
67
|
+
historyLength: [beforeLength, afterLength],
|
|
68
|
+
};
|
|
69
|
+
await historyPage.locator('#next').click();
|
|
70
|
+
await historyPage.waitForURL('**/history-destination.html');
|
|
71
|
+
const afterPageClick = historyPage.url();
|
|
72
|
+
await historyPage.goBack({ waitUntil: 'domcontentloaded', timeout: 2_000 });
|
|
73
|
+
const pageBack = { afterPageClick, afterBack: historyPage.url() };
|
|
74
|
+
await historyPage.close();
|
|
75
|
+
|
|
76
|
+
await browser.close();
|
|
77
|
+
await server.close();
|
|
78
|
+
const result = { doubleBinding, nestedBinding, dropdownGuard, hoverStacking, deepLinkBack, pageBack };
|
|
79
|
+
if (process.argv.includes('--assert')) {
|
|
80
|
+
assert.deepEqual(doubleBinding, { overlayRoots: 1, modals: 1 }, 'runtime must bind exactly once when injected twice');
|
|
81
|
+
assert.deepEqual(nestedBinding, { modals: 1, menus: 0 }, 'the innermost action must stop a parent binding from also firing');
|
|
82
|
+
assert.deepEqual(dropdownGuard, { invalidOpened: 0, validOpened: 1, bound: { navigate: 0, dropdown: 1, modal: 0, field: 0 } }, 'invalid dropdowns must stay inert while real dropdowns bind');
|
|
83
|
+
assert.deepEqual(hoverStacking, { stackedOpened: 1, plainOpened: 1 }, 'hover styling must not reorder either overlapping or ordinary actions');
|
|
84
|
+
assert.equal(afterLength, beforeLength + 1, 'same-page navigation must add one history entry');
|
|
85
|
+
assert.equal(deepLinkBack.afterBack, historyStart, 'browser Back must return to the pre-anchor URL');
|
|
86
|
+
assert.equal(pageBack.afterBack, historyStart, 'browser Back must return from a different prototype page');
|
|
87
|
+
}
|
|
88
|
+
const json = JSON.stringify(result, null, 2) + '\n';
|
|
89
|
+
if (outFile) fs.writeFileSync(outFile, json);
|
|
90
|
+
process.stdout.write(json);
|