@12ui/design 0.2.28 → 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 +97 -3
- 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 +2 -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 +15 -0
- package/dist/cli-capabilities.d.ts.map +1 -1
- package/dist/cli-capabilities.js +19 -0
- package/dist/cli-capabilities.js.map +1 -1
- package/dist/cli-help/design.d.ts +18 -1
- package/dist/cli-help/design.d.ts.map +1 -1
- package/dist/cli-help/design.js +33 -0
- package/dist/cli-help/design.js.map +1 -1
- package/dist/cli-help/index.d.ts +18 -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 +8 -0
- package/dist/cli-review-command.d.ts.map +1 -0
- package/dist/cli-review-command.js +90 -0
- package/dist/cli-review-command.js.map +1 -0
- package/dist/cli-run.d.ts.map +1 -1
- package/dist/cli-run.js +16 -0
- package/dist/cli-run.js.map +1 -1
- package/dist/cli-usage.d.ts.map +1 -1
- package/dist/cli-usage.js +8 -1
- package/dist/cli-usage.js.map +1 -1
- package/dist/conversion-client.d.ts.map +1 -1
- package/dist/conversion-client.js +8 -1
- package/dist/conversion-client.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/next-wait-lease.d.ts +2 -1
- package/dist/next-wait-lease.d.ts.map +1 -1
- package/dist/next-wait-lease.js +73 -22
- package/dist/next-wait-lease.js.map +1 -1
- package/dist/next-wait-process.d.ts +17 -0
- package/dist/next-wait-process.d.ts.map +1 -0
- package/dist/next-wait-process.js +40 -0
- package/dist/next-wait-process.js.map +1 -0
- 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 +11 -0
- package/dist/review-discovery.d.ts.map +1 -0
- package/dist/review-discovery.js +427 -0
- package/dist/review-discovery.js.map +1 -0
- package/dist/review-edit-html.d.ts +6 -0
- package/dist/review-edit-html.d.ts.map +1 -0
- package/dist/review-edit-html.js +11 -0
- package/dist/review-edit-html.js.map +1 -0
- package/dist/review-edit-source.d.ts +22 -0
- package/dist/review-edit-source.d.ts.map +1 -0
- package/dist/review-edit-source.js +103 -0
- package/dist/review-edit-source.js.map +1 -0
- package/dist/review-edit.d.ts +15 -0
- package/dist/review-edit.d.ts.map +1 -0
- package/dist/review-edit.js +192 -0
- package/dist/review-edit.js.map +1 -0
- package/dist/review-layerdoc-diff.d.ts +7 -0
- package/dist/review-layerdoc-diff.d.ts.map +1 -0
- package/dist/review-layerdoc-diff.js +92 -0
- package/dist/review-layerdoc-diff.js.map +1 -0
- package/dist/review-page-assets.d.ts +3 -0
- package/dist/review-page-assets.d.ts.map +1 -0
- package/dist/review-page-assets.js +62 -0
- package/dist/review-page-assets.js.map +1 -0
- package/dist/review-page.d.ts +3 -0
- package/dist/review-page.d.ts.map +1 -0
- package/dist/review-page.js +275 -0
- package/dist/review-page.js.map +1 -0
- package/dist/review-pricing.d.ts +14 -0
- package/dist/review-pricing.d.ts.map +1 -0
- package/dist/review-pricing.js +23 -0
- package/dist/review-pricing.js.map +1 -0
- 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 +22 -0
- package/dist/review-server.d.ts.map +1 -0
- package/dist/review-server.js +268 -0
- package/dist/review-server.js.map +1 -0
- package/dist/review-store.d.ts +11 -0
- package/dist/review-store.d.ts.map +1 -0
- package/dist/review-store.js +96 -0
- package/dist/review-store.js.map +1 -0
- package/dist/review-types.d.ts +140 -0
- package/dist/review-types.d.ts.map +1 -0
- package/dist/review-types.js +2 -0
- package/dist/review-types.js.map +1 -0
- package/dist/run-journal-commands.d.ts.map +1 -1
- package/dist/run-journal-commands.js +19 -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,393 @@
|
|
|
1
|
+
// Assemble a clickable prototype from a branch run + model + (optional) Luna labels.
|
|
2
|
+
// Usage: node build.mjs <run-dir> <inventory.json> <model.json> <labels-dir|-> <out-dir>
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { parseHTML } from 'linkedom';
|
|
6
|
+
import { usableDropdownOptions } from './lib/actions.mjs';
|
|
7
|
+
import { shouldSuppressDroppedShell, shouldTransplantShell } from './lib/shells.mjs';
|
|
8
|
+
import { normalizeText as norm, slugText as slug } from './lib/text.mjs';
|
|
9
|
+
|
|
10
|
+
const [runDir, invFile, modelFile, labelsDir, outDir] = process.argv.slice(2);
|
|
11
|
+
const inv = JSON.parse(fs.readFileSync(invFile, 'utf8'));
|
|
12
|
+
const model = JSON.parse(fs.readFileSync(modelFile, 'utf8'));
|
|
13
|
+
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
14
|
+
fs.mkdirSync(path.join(outDir, 'content'), { recursive: true });
|
|
15
|
+
fs.copyFileSync(path.join(here, 'runtime/proto.js'), path.join(outDir, 'proto.js'));
|
|
16
|
+
fs.copyFileSync(path.join(here, 'runtime/proto.css'), path.join(outDir, 'proto.css'));
|
|
17
|
+
|
|
18
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
19
|
+
|
|
20
|
+
// ---- state lookups
|
|
21
|
+
const states = model.states;
|
|
22
|
+
const byState = Object.fromEntries(states.map((s) => [s.stateId, s]));
|
|
23
|
+
const invSection = (s) => inv.pages.find((p) => p.pageId === s.pageId).sections.find((x) => x.sectionId === s.sectionId);
|
|
24
|
+
const isWebPage = (s) => s.surfaceType === 'web_page';
|
|
25
|
+
// output unit: app states -> one file per state; web pages -> one file per page (stacked)
|
|
26
|
+
const units = [];
|
|
27
|
+
for (const s of states) {
|
|
28
|
+
if (isWebPage(s)) {
|
|
29
|
+
let u = units.find((x) => x.pageId === s.pageId);
|
|
30
|
+
if (!u) { u = { id: s.pageId.replace(/^p\d+-/, ''), pageId: s.pageId, file: s.file, states: [], title: s.title, isSource: s.isSource }; units.push(u); }
|
|
31
|
+
u.states.push(s);
|
|
32
|
+
} else units.push({ id: s.stateId, pageId: s.pageId, file: s.file, states: [s], title: s.title, isSource: s.isSource });
|
|
33
|
+
}
|
|
34
|
+
const unitOf = (stateId) => units.find((u) => u.states.some((s) => s.stateId === stateId));
|
|
35
|
+
const fileOf = (stateId) => unitOf(stateId) ? unitOf(stateId).id + '.html' : null;
|
|
36
|
+
|
|
37
|
+
// ---- labels (Luna) per state
|
|
38
|
+
const labels = {};
|
|
39
|
+
if (labelsDir && labelsDir !== '-') for (const f of fs.readdirSync(labelsDir).filter((x) => x.endsWith('.json'))) {
|
|
40
|
+
const j = JSON.parse(fs.readFileSync(path.join(labelsDir, f), 'utf8'));
|
|
41
|
+
labels[f.replace(/\.json$/, '')] = j.actions || [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---- holding targets: nav holdings + any Luna "holding:X"
|
|
45
|
+
const holdings = new Map(); // slug -> title
|
|
46
|
+
for (const n of model.nav) if (n.holding) holdings.set(n.holding, n.text);
|
|
47
|
+
for (const acts of Object.values(labels)) for (const a of acts) if (a.action === 'navigate' && /^holding:/.test(a.target || '')) { const t = a.target.slice(8).trim(); holdings.set('holding-' + slug(t), t); }
|
|
48
|
+
const usedHoldings = new Map(); // slug -> title, only those actually referenced by a bound action or nav item
|
|
49
|
+
const holdingFile = (title) => { usedHoldings.set('holding-' + slug(title), holdings.get('holding-' + slug(title)) || title); return 'holding-' + slug(title) + '.html'; };
|
|
50
|
+
|
|
51
|
+
// ---- page docs cache
|
|
52
|
+
const docCache = new Map();
|
|
53
|
+
function loadDoc(file) {
|
|
54
|
+
if (!docCache.has(file)) {
|
|
55
|
+
const html = fs.readFileSync(path.join(runDir, 'pages', file), 'utf8');
|
|
56
|
+
docCache.set(file, html);
|
|
57
|
+
}
|
|
58
|
+
return parseHTML(docCache.get(file));
|
|
59
|
+
}
|
|
60
|
+
function styleText(document) { return [...document.querySelectorAll('style')].map((s) => s.textContent).join('\n'); }
|
|
61
|
+
function rulesFor(css, id) {
|
|
62
|
+
// one selector per rule in these exports; grab every rule whose selector is #id or #id-...
|
|
63
|
+
const re = new RegExp(`#${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:-[A-Za-z0-9_-]*)?(?:[:>\\s][^{]*)?\\{[^}]*\\}`, 'g');
|
|
64
|
+
return (css.match(re) || []).join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- canonical shell HTML/CSS per region
|
|
68
|
+
const shell = {};
|
|
69
|
+
for (const [region, can] of Object.entries(model.canonicalShell)) {
|
|
70
|
+
const s = byState[can.stateId];
|
|
71
|
+
const { document } = loadDoc(s.file);
|
|
72
|
+
const el = document.getElementById(can.containerId);
|
|
73
|
+
const css = rulesFor(styleText(document), can.containerId);
|
|
74
|
+
const ownRule = css.match(new RegExp(`#${can.containerId}\\{([^}]*)\\}`));
|
|
75
|
+
const background = ownRule ? (ownRule[1].match(/(?:^|;)background:([^;]+)/) || [])[1] : null;
|
|
76
|
+
shell[region] = { ...can, html: el.outerHTML, inner: el.innerHTML, background, css, section: invSection(s) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// nav: click container for each nav text = nearest ancestor container with h <= 72 and w >= 100 (row), from canonical inventory
|
|
80
|
+
function rowContainerFor(section, textId, otherTextIds, maxH = 72) {
|
|
81
|
+
const it = section.items.find((x) => x.id === textId);
|
|
82
|
+
if (!it) return null;
|
|
83
|
+
for (const cid of it.chain) {
|
|
84
|
+
const c = section.containers.find((x) => x.id === cid);
|
|
85
|
+
if (!c || c.h > maxH || c.w < 60) continue;
|
|
86
|
+
const holdsOther = section.items.some((o) => otherTextIds.includes(o.id) && o.chain.includes(cid));
|
|
87
|
+
if (holdsOther) break;
|
|
88
|
+
return c;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const navRows = model.nav.map((n) => {
|
|
93
|
+
const sec = shell[n.region]?.section;
|
|
94
|
+
const others = model.nav.filter((o) => o.region === n.region && o.id !== n.id).map((o) => o.id);
|
|
95
|
+
const row = sec ? rowContainerFor(sec, n.id, others) : null;
|
|
96
|
+
return { ...n, rowId: row ? row.id : n.id, row };
|
|
97
|
+
});
|
|
98
|
+
// active pill: in the canonical sidebar, a container with opaque bg enclosing the canonical state's own nav text
|
|
99
|
+
function findPill(region) {
|
|
100
|
+
const sh = shell[region]; if (!sh) return null;
|
|
101
|
+
const own = navRows.find((n) => n.region === region && n.target && unitOf(n.target)?.id === unitOf(sh.stateId)?.id);
|
|
102
|
+
if (!own) return null;
|
|
103
|
+
const it = sh.section.items.find((x) => x.id === own.id);
|
|
104
|
+
const covers = (c) => c.x <= it.x + 1 && c.y <= it.y + 1 && c.x + c.w >= it.x + it.w - 1 && c.y + c.h >= it.y + it.h - 1;
|
|
105
|
+
const pill = sh.section.containers
|
|
106
|
+
.filter((c) => c.id !== sh.containerId && c.id.startsWith(sh.containerId) && c.h <= 80 && c.w >= 80 && covers(c) && c.bg && c.bg !== 'rgba(0, 0, 0, 0)')
|
|
107
|
+
.sort((a, b) => a.w * a.h - b.w * b.h)[0] || null;
|
|
108
|
+
return { pill, own, textColor: it.color, textWeight: it.fontWeight };
|
|
109
|
+
}
|
|
110
|
+
const pills = Object.fromEntries(Object.keys(shell).map((r) => [r, findPill(r)]));
|
|
111
|
+
|
|
112
|
+
// ---- action resolution for one state
|
|
113
|
+
const report = { units: [], unbound: [], invalidDropdowns: [], shellSizeDelta: [], shellAmbiguities: model.shellAmbiguities || [], suppressedShellOverlaps: [], skippedShellTransplants: [], replacedShellActions: 0, shellActionRemaps: 0, holdings: [...holdings.values()], counts: { navigate: 0, dropdown: 0, modal: 0, field: 0, none: 0, lunaOnly: 0, detOnly: 0, agree: 0, disagree: 0 } };
|
|
114
|
+
function resolveTarget(target, currentStateId) {
|
|
115
|
+
if (!target) return null;
|
|
116
|
+
if (/^holding:/.test(target)) return holdingFile(target.slice(8).trim());
|
|
117
|
+
if (byState[target]) {
|
|
118
|
+
if (target === currentStateId) return null;
|
|
119
|
+
if (unitOf(target) === unitOf(currentStateId)) return fileOf(target) + '#' + byState[target].sectionId;
|
|
120
|
+
return fileOf(target);
|
|
121
|
+
}
|
|
122
|
+
if (holdings.has(target)) return 'holding-' + target.replace(/^holding-/, '') + '.html';
|
|
123
|
+
// an invented id like "reports" -> a holding page titled "Reports"
|
|
124
|
+
if (/^[a-z][a-z0-9-]{2,40}$/.test(target)) return holdingFile(target.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' '));
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
function actionsFor(s) {
|
|
128
|
+
const acts = new Map(); // elementId -> {action, ...}
|
|
129
|
+
const luna = labels[s.stateId] || [];
|
|
130
|
+
const lunaById = new Map(luna.map((a) => [a.id, a]));
|
|
131
|
+
const det = new Map();
|
|
132
|
+
// deterministic: dropdown triggers, rows -> dependent state, fields
|
|
133
|
+
const dependent = states.find((x) => x.dependsOn === s.stateId && unitOf(x.stateId) !== unitOf(s.stateId));
|
|
134
|
+
for (const c of s.candidates) {
|
|
135
|
+
if (c.kind === 'dropdown') det.set(c.id, { action: 'dropdown', labelId: c.textId, text: c.text });
|
|
136
|
+
if (c.kind === 'row' && dependent) det.set(c.id, { action: 'navigate', target: dependent.stateId });
|
|
137
|
+
}
|
|
138
|
+
// merge: deterministic kind wins for dropdown, Luna supplies options/targets/modals; Luna alone for the rest
|
|
139
|
+
for (const c of s.candidates) {
|
|
140
|
+
const d = det.get(c.id), l = lunaById.get(c.id) || luna.find((a) => a.id.startsWith(c.id + '-') || c.id.startsWith(a.id + '-'));
|
|
141
|
+
if (d && l) { if (d.action === l.action) report.counts.agree++; else report.counts.disagree++; }
|
|
142
|
+
else if (d) report.counts.detOnly++; else if (l) report.counts.lunaOnly++;
|
|
143
|
+
let a = null;
|
|
144
|
+
if (d?.action === 'dropdown' || l?.action === 'dropdown') {
|
|
145
|
+
const options = usableDropdownOptions(l?.options);
|
|
146
|
+
if (options) a = { action: 'dropdown', labelId: d?.labelId || c.textId || (c.kind === 'link' ? c.id : null), options };
|
|
147
|
+
else report.invalidDropdowns.push({ state: s.stateId, id: c.id, options: l?.options || [] });
|
|
148
|
+
}
|
|
149
|
+
else if (l?.action === 'modal') a = { action: 'modal', modal: l.modal || { title: c.text, body: '', confirm: 'OK' } };
|
|
150
|
+
else if (l?.action === 'navigate' || d?.action === 'navigate') {
|
|
151
|
+
// a real follow-on state beats a Luna holding page for rows
|
|
152
|
+
const detTgt = d?.target ? fileOf(d.target) : null;
|
|
153
|
+
const lunaTgt = resolveTarget(l?.target, s.stateId);
|
|
154
|
+
const lunaReal = l?.target && byState[l.target] ? lunaTgt : null;
|
|
155
|
+
const tgt = detTgt && !lunaReal ? detTgt : (lunaTgt || detTgt);
|
|
156
|
+
if (tgt) a = { action: 'navigate', target: tgt };
|
|
157
|
+
}
|
|
158
|
+
if (a) acts.set(c.id, a);
|
|
159
|
+
else report.counts.none++;
|
|
160
|
+
}
|
|
161
|
+
return acts;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function bindAction(el, action) {
|
|
165
|
+
el.setAttribute('data-proto-action', action.action);
|
|
166
|
+
if (action.target) el.setAttribute('data-proto-target', action.target);
|
|
167
|
+
if (action.options) el.setAttribute('data-proto-options', JSON.stringify(action.options));
|
|
168
|
+
if (action.labelId) el.setAttribute('data-proto-label', action.labelId);
|
|
169
|
+
if (action.modal) el.setAttribute('data-proto-modal', JSON.stringify(action.modal));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const stateActions = new Map(states.map((state) => [state.stateId, actionsFor(state)]));
|
|
173
|
+
const canonicalShellActions = {};
|
|
174
|
+
for (const [region, canonical] of Object.entries(model.canonicalShell)) {
|
|
175
|
+
const state = byState[canonical.stateId];
|
|
176
|
+
const { document } = loadDoc(state.file);
|
|
177
|
+
const root = document.getElementById(canonical.containerId);
|
|
178
|
+
canonicalShellActions[region] = [...stateActions.get(state.stateId)].filter(([id]) => {
|
|
179
|
+
const element = document.getElementById(id);
|
|
180
|
+
return element && root?.contains(element);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- build each unit
|
|
185
|
+
for (const u of units) {
|
|
186
|
+
const { document } = loadDoc(u.file);
|
|
187
|
+
const keep = new Set(u.states.map((s) => s.sectionId));
|
|
188
|
+
for (const sec of [...document.querySelectorAll('section[id^="viewport-"]')]) if (/^viewport-\d+-[A-Za-z0-9]+$/.test(sec.id) && !keep.has(sec.id)) sec.remove();
|
|
189
|
+
for (const sc of [...document.querySelectorAll('script')]) sc.remove();
|
|
190
|
+
const totalH = u.states.reduce((n, s) => n + invSection(s).height, 0);
|
|
191
|
+
const extraCss = [`.ly-anchor-canvas,#page-flow{height:${totalH}px!important}`];
|
|
192
|
+
const title = u.title; document.title = title;
|
|
193
|
+
const allActions = {};
|
|
194
|
+
const content = {};
|
|
195
|
+
let fieldCount = 0;
|
|
196
|
+
const activeNav = navRows.find((n) => n.target && unitOf(n.target)?.id === u.id);
|
|
197
|
+
|
|
198
|
+
for (const s of u.states) {
|
|
199
|
+
const section = document.getElementById(s.sectionId);
|
|
200
|
+
for (const ambiguity of report.shellAmbiguities.filter((candidate) => candidate.state === s.stateId && shouldSuppressDroppedShell(candidate))) {
|
|
201
|
+
const dropped = document.getElementById(ambiguity.dropped);
|
|
202
|
+
const kept = document.getElementById(ambiguity.kept);
|
|
203
|
+
if (dropped && kept && !dropped.contains(kept) && !kept.contains(dropped)) {
|
|
204
|
+
dropped.remove();
|
|
205
|
+
report.suppressedShellOverlaps.push({ state: s.stateId, region: ambiguity.region, dropped: ambiguity.dropped, kept: ambiguity.kept });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// shell transplant
|
|
209
|
+
for (const sh of s.shell) {
|
|
210
|
+
const can = shell[sh.region]; if (!can) continue;
|
|
211
|
+
const own = document.getElementById(sh.containerId);
|
|
212
|
+
if (!own) continue;
|
|
213
|
+
if (can.containerId === sh.containerId) continue; // this is the canonical page
|
|
214
|
+
if (!shouldTransplantShell(sh.box, can.box)) {
|
|
215
|
+
report.skippedShellTransplants.push({ state: s.stateId, region: sh.region, own: sh.box.slice(2), canonical: can.box.slice(2) });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// keep this page's own layout box (it may be in normal flow); swap in the canonical children + background
|
|
219
|
+
own.innerHTML = can.inner;
|
|
220
|
+
own.setAttribute('data-proto-shell', can.containerId);
|
|
221
|
+
extraCss.push(can.css);
|
|
222
|
+
extraCss.push(`#${sh.containerId}{${can.background ? `background:${can.background}!important;` : ''}overflow:visible!important;z-index:5}`);
|
|
223
|
+
if (Math.abs(sh.box[2] - can.box[2]) > 2 || Math.abs(sh.box[3] - can.box[3]) > 2) report.shellSizeDelta.push({ state: s.stateId, region: sh.region, own: sh.box.slice(2), canonical: can.box.slice(2) });
|
|
224
|
+
}
|
|
225
|
+
// actions
|
|
226
|
+
const acts = stateActions.get(s.stateId);
|
|
227
|
+
for (const [id, a] of acts) {
|
|
228
|
+
const el = document.getElementById(id);
|
|
229
|
+
if (!el) {
|
|
230
|
+
if (s.candidates.find((candidate) => candidate.id === id)?.inShell) report.replacedShellActions++;
|
|
231
|
+
else report.unbound.push({ state: s.stateId, id, action: a.action });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
bindAction(el, a);
|
|
235
|
+
report.counts[a.action]++;
|
|
236
|
+
allActions[id] = a;
|
|
237
|
+
}
|
|
238
|
+
// fields
|
|
239
|
+
for (const f of s.fields) {
|
|
240
|
+
if (acts.has(f.id)) continue;
|
|
241
|
+
if (s.fields.some((o) => o.id !== f.id && o.id.startsWith(f.id + '-'))) continue; // prefer the innermost field
|
|
242
|
+
const el = document.getElementById(f.id);
|
|
243
|
+
if (!el || el.closest('[data-proto-action]') || el.querySelector('[data-proto-action]')) continue;
|
|
244
|
+
const textEl = el.classList.contains('text') ? el
|
|
245
|
+
: [...el.querySelectorAll('.text')].find((t) => !t.closest('button'))
|
|
246
|
+
|| (el.textContent.trim() && [...el.children].every((c) => !c.children.length) ? el : null);
|
|
247
|
+
if (!textEl || textEl.closest('button')) continue;
|
|
248
|
+
el.setAttribute('data-proto-field', textEl.id);
|
|
249
|
+
el.setAttribute('data-proto-submit', /search/i.test(f.id + f.placeholder) ? 'Search' : 'Send');
|
|
250
|
+
// a sibling send/submit button under the same parent submits the field
|
|
251
|
+
const parent = el.parentElement;
|
|
252
|
+
const send = parent && [...parent.querySelectorAll('button')].find((b) => /send|submit|go$|search|arrow/.test(b.id) && !b.textContent.trim());
|
|
253
|
+
// bind the button's container too: the icon often sits beside (not inside) the button and swallows the click
|
|
254
|
+
if (send) (send.parentElement && send.parentElement !== parent ? send.parentElement : send).setAttribute('data-proto-submit-for', textEl.id);
|
|
255
|
+
fieldCount++; report.counts.field++;
|
|
256
|
+
}
|
|
257
|
+
// content layer
|
|
258
|
+
for (const p of section.querySelectorAll('.text, h1, h2, h3, h4, h5, h6')) if (p.id && !p.querySelector('.text')) content[p.id] = p.textContent;
|
|
259
|
+
}
|
|
260
|
+
// Shell children come from the canonical state. Bind that state's shell actions
|
|
261
|
+
// after every transplant so shared dropdowns/utilities behave on every page.
|
|
262
|
+
const unitRegions = new Set(u.states.flatMap((state) => state.shell.map((item) => item.region)));
|
|
263
|
+
for (const region of unitRegions) for (const [id, action] of canonicalShellActions[region] || []) {
|
|
264
|
+
const elements = [...document.querySelectorAll('[id]')].filter((element) => element.id === id);
|
|
265
|
+
for (const element of elements) bindAction(element, action);
|
|
266
|
+
if (elements.length && !allActions[id]) {
|
|
267
|
+
allActions[id] = action;
|
|
268
|
+
report.shellActionRemaps++;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// nav wiring on the (now shared) canonical shell ids — present wherever a shell was transplanted or is native
|
|
272
|
+
for (const n of navRows) {
|
|
273
|
+
const el = document.getElementById(n.rowId) || document.getElementById(n.id);
|
|
274
|
+
if (!el) continue;
|
|
275
|
+
const tgt = n.target ? (unitOf(n.target)?.id === u.id ? null : fileOf(n.target)) : holdingFile(n.text);
|
|
276
|
+
if (!tgt) { el.removeAttribute('data-proto-action'); continue; }
|
|
277
|
+
el.setAttribute('data-proto-action', 'navigate'); el.setAttribute('data-proto-target', tgt);
|
|
278
|
+
for (const d of el.querySelectorAll('[data-proto-action]')) { d.removeAttribute('data-proto-action'); d.removeAttribute('data-proto-target'); delete allActions[d.id]; }
|
|
279
|
+
allActions[n.rowId] = { action: 'navigate', target: tgt, nav: true };
|
|
280
|
+
}
|
|
281
|
+
// active nav highlight: move the pill style from the canonical active item to this unit's item
|
|
282
|
+
for (const [region, p] of Object.entries(pills)) {
|
|
283
|
+
if (!p) continue;
|
|
284
|
+
if (!activeNav || activeNav.region !== region) {
|
|
285
|
+
// no nav item maps to this page: show no active item rather than the canonical page's
|
|
286
|
+
const anyRow = u.states.some((s) => s.shell.some((x) => x.region === region));
|
|
287
|
+
if (anyRow && p.pill) { const inactive = navRows.find((n) => n.region === region && n.id !== p.own.id); const inText = inactive && shell[region].section.items.find((x) => x.id === inactive.id);
|
|
288
|
+
extraCss.push(`#${p.pill.id}{background:transparent!important;box-shadow:none!important}`); if (inText) extraCss.push(`#${p.own.id}{color:${inText.color}!important;font-weight:${inText.fontWeight}!important}`); }
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (activeNav.id === p.own.id) continue;
|
|
292
|
+
const cur = navRows.find((n) => n.id === activeNav.id);
|
|
293
|
+
const curText = shell[region].section.items.find((x) => x.id === cur.id);
|
|
294
|
+
const ownText = shell[region].section.items.find((x) => x.id === p.own.id);
|
|
295
|
+
if (p.pill) {
|
|
296
|
+
extraCss.push(`#${p.pill.id}{background:transparent!important;box-shadow:none!important}`);
|
|
297
|
+
if (cur.row) extraCss.push(`#${cur.row.id}{background:${p.pill.bg}!important;border-radius:${p.pill.radius}!important}`);
|
|
298
|
+
}
|
|
299
|
+
extraCss.push(`#${cur.id}{color:${p.textColor}!important;font-weight:${p.textWeight}!important}`);
|
|
300
|
+
extraCss.push(`#${p.own.id}{color:${curText.color}!important;font-weight:${curText.fontWeight}!important}`);
|
|
301
|
+
// swap icon colours too (icon = previous sibling of the text inside the row)
|
|
302
|
+
const ownIcon = shell[region].section.items.find((x) => x.cls.includes('icon') && p.own.row && x.chain.includes(p.own.row.id));
|
|
303
|
+
const curIcon = shell[region].section.items.find((x) => x.cls.includes('icon') && cur.row && x.chain.includes(cur.row.id));
|
|
304
|
+
if (ownIcon && curIcon) { extraCss.push(`#${curIcon.id}{color:${ownIcon.color}!important}`); extraCss.push(`#${ownIcon.id}{color:${curIcon.color}!important}`); }
|
|
305
|
+
}
|
|
306
|
+
// inject runtime
|
|
307
|
+
const head = document.querySelector('head');
|
|
308
|
+
head.insertAdjacentHTML('beforeend', `<style id="proto-extra">${extraCss.join('\n')}</style><link rel="stylesheet" href="proto.css">`);
|
|
309
|
+
const cfg = { unit: u.id, states: u.states.map((s) => s.stateId), tokens: model.tokens, contentFile: `${u.id}.json`, activeNav: activeNav?.text || null };
|
|
310
|
+
document.body.insertAdjacentHTML('beforeend', `<script id="proto-state" type="application/json">${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script><script src="proto.js"></script>`);
|
|
311
|
+
fs.writeFileSync(path.join(outDir, u.id + '.html'), '<!doctype html>\n' + document.documentElement.outerHTML);
|
|
312
|
+
fs.writeFileSync(path.join(outDir, 'content', u.id + '.json'), JSON.stringify(content, null, 1));
|
|
313
|
+
report.units.push({ id: u.id, states: u.states.map((s) => s.stateId), actions: Object.keys(allActions).length, fields: fieldCount, texts: Object.keys(content).length, activeNav: activeNav?.text || null });
|
|
314
|
+
console.error(`${u.id}.html actions=${Object.keys(allActions).length} fields=${fieldCount} texts=${Object.keys(content).length} active=${activeNav?.text || '-'}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---- holding pages: canonical shell(s) + page background + a styled block in the content area
|
|
318
|
+
{
|
|
319
|
+
const primaryRegion = model.canonicalShell.sidebar ? 'sidebar' : model.canonicalShell.header ? 'header' : Object.keys(model.canonicalShell)[0];
|
|
320
|
+
const can = primaryRegion ? shell[primaryRegion] : null;
|
|
321
|
+
const base = can ? byState[can.stateId] : (states.find((s) => s.isSource) || states[0]);
|
|
322
|
+
const baseSec = invSection(base);
|
|
323
|
+
// content area: the section minus shell boxes
|
|
324
|
+
let area = { x: 0, y: 0, w: 1536, h: baseSec.height };
|
|
325
|
+
for (const sh of base.shell) {
|
|
326
|
+
const [x, y, w, h] = sh.box;
|
|
327
|
+
if (sh.region === 'sidebar') { area.x = Math.max(area.x, x + w); }
|
|
328
|
+
if (sh.region === 'header') { area.y = Math.max(area.y, y + h); }
|
|
329
|
+
if (sh.region === 'footer') { area.h = Math.min(area.h, y); }
|
|
330
|
+
}
|
|
331
|
+
// heading style from the base page's largest content text
|
|
332
|
+
// anchor the block where the page's own title sits: the top-most large text in the content area
|
|
333
|
+
const heading = baseSec.items.filter((t) => t.text && t.x >= area.x && t.fontSize >= 20 && t.y < area.y + 200).sort((a, b) => a.y - b.y || a.x - b.x)[0]
|
|
334
|
+
|| baseSec.items.filter((t) => t.text && t.x >= area.x).sort((a, b) => b.fontSize - a.fontSize)[0];
|
|
335
|
+
const pad = Math.min(160, Math.max(32, Math.round((heading?.x || area.x + 40) - area.x)));
|
|
336
|
+
const top = Math.min(200, Math.max(40, Math.round(heading?.y || 60)));
|
|
337
|
+
for (const n of navRows) if (n.holding) holdingFile(n.text);
|
|
338
|
+
for (const [hslug, htitle] of usedHoldings) {
|
|
339
|
+
const { document } = loadDoc(base.file);
|
|
340
|
+
for (const sec of [...document.querySelectorAll('section[id^="viewport-"]')]) if (/^viewport-\d+-[A-Za-z0-9]+$/.test(sec.id) && sec.id !== base.sectionId) sec.remove();
|
|
341
|
+
for (const sc of [...document.querySelectorAll('script')]) sc.remove();
|
|
342
|
+
const section = document.getElementById(base.sectionId);
|
|
343
|
+
const keepIds = new Set(base.shell.map((x) => x.containerId));
|
|
344
|
+
const bgIds = new Set(baseSec.containers.filter((c) => c.x === 0 && c.y === 0 && c.w >= 1535 && c.children === 0).map((c) => c.id));
|
|
345
|
+
for (const child of [...section.children]) if (!keepIds.has(child.id) && !bgIds.has(child.id)) child.remove();
|
|
346
|
+
const navMatch = navRows.find((n) => n.holding === hslug);
|
|
347
|
+
const extraCss = [`.ly-anchor-canvas,#page-flow{height:${baseSec.height}px!important}`];
|
|
348
|
+
// composite the other canonical shell regions (e.g. a footer that lives in a different state)
|
|
349
|
+
for (const [region, can] of Object.entries(shell)) {
|
|
350
|
+
if (base.shell.some((x) => x.region === region)) continue;
|
|
351
|
+
section.insertAdjacentHTML('beforeend', can.html);
|
|
352
|
+
extraCss.push(can.css);
|
|
353
|
+
const top = region === 'footer' ? baseSec.height - can.box[3] : can.box[1];
|
|
354
|
+
extraCss.push(`#${can.containerId}{position:absolute!important;left:${can.box[0]}px!important;top:${top}px!important;margin:0!important;z-index:5}`);
|
|
355
|
+
if (region === 'footer') area.h = Math.min(area.h, top);
|
|
356
|
+
}
|
|
357
|
+
if (navMatch) {
|
|
358
|
+
const p = pills[navMatch.region];
|
|
359
|
+
if (p && p.own.id !== navMatch.id) {
|
|
360
|
+
const curText = shell[navMatch.region].section.items.find((x) => x.id === navMatch.id);
|
|
361
|
+
if (p.pill) { extraCss.push(`#${p.pill.id}{background:transparent!important;box-shadow:none!important}`); if (navMatch.row) extraCss.push(`#${navMatch.row.id}{background:${p.pill.bg}!important;border-radius:${p.pill.radius}!important}`); }
|
|
362
|
+
extraCss.push(`#${navMatch.id}{color:${p.textColor}!important;font-weight:${p.textWeight}!important}`);
|
|
363
|
+
extraCss.push(`#${p.own.id}{color:${curText.color}!important;font-weight:${curText.fontWeight}!important}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const purpose = navMatch ? `${htitle} is part of the planned product but was not one of the screens in this branch run.` : `${htitle} opens here in the full product. It was not one of the screens in this branch run.`;
|
|
367
|
+
const block = `<div class="proto-holding-block" style="left:${area.x + pad}px;top:${top}px;width:${Math.min(area.w - area.x - pad * 2, 1100)}px">
|
|
368
|
+
<h1 class="proto-holding-title" style="font-family:'${heading?.fontFamily || model.tokens.headingFont}',sans-serif;font-size:${heading?.fontSize || model.tokens.headingSize || 28}px;font-weight:${heading?.fontWeight || 600};color:${heading?.color || model.tokens.headingColor}">${esc(htitle)}</h1>
|
|
369
|
+
<p class="proto-holding-sub">${esc(purpose)}</p>
|
|
370
|
+
<div class="proto-holding-cards">${[1, 2, 3].map((i) => `<div class="proto-holding-card"><p class="proto-holding-card-title">${esc(htitle)} ${i}</p><div class="proto-holding-card-line"></div><div class="proto-holding-card-line short"></div><div class="proto-holding-card-line"></div></div>`).join('')}</div>
|
|
371
|
+
<p class="proto-holding-note">Prototype holding page · <span class="proto-clickable" data-proto-action="navigate" data-proto-target="${fileOf((states.find((s) => s.isSource) || states[0]).stateId)}">Back to ${esc((states.find((s) => s.isSource) || states[0]).title)}</span></p>
|
|
372
|
+
</div>`;
|
|
373
|
+
section.insertAdjacentHTML('beforeend', block);
|
|
374
|
+
for (const n of navRows) {
|
|
375
|
+
const el = document.getElementById(n.rowId) || document.getElementById(n.id);
|
|
376
|
+
if (!el) continue;
|
|
377
|
+
const tgt = n.holding === hslug ? null : n.target ? fileOf(n.target) : holdingFile(n.text);
|
|
378
|
+
if (tgt) { el.setAttribute('data-proto-action', 'navigate'); el.setAttribute('data-proto-target', tgt); for (const d of el.querySelectorAll('[data-proto-action]')) d.removeAttribute('data-proto-action'); }
|
|
379
|
+
}
|
|
380
|
+
document.title = htitle;
|
|
381
|
+
document.querySelector('head').insertAdjacentHTML('beforeend', `<style id="proto-extra">${extraCss.join('\n')}</style><link rel="stylesheet" href="proto.css">`);
|
|
382
|
+
const cfg = { unit: hslug, holding: true, tokens: model.tokens, contentFile: null, activeNav: navMatch?.text || null };
|
|
383
|
+
document.body.insertAdjacentHTML('beforeend', `<script id="proto-state" type="application/json">${JSON.stringify(cfg)}</script><script src="proto.js"></script>`);
|
|
384
|
+
fs.writeFileSync(path.join(outDir, hslug + '.html'), '<!doctype html>\n' + document.documentElement.outerHTML);
|
|
385
|
+
}
|
|
386
|
+
console.error(`holding pages: ${usedHoldings.size}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// index -> source
|
|
390
|
+
const home = fileOf((states.find((s) => s.isSource) || states[0]).stateId);
|
|
391
|
+
fs.writeFileSync(path.join(outDir, 'index.html'), `<!doctype html><meta http-equiv="refresh" content="0;url=${home}"><a href="${home}">${home}</a>`);
|
|
392
|
+
fs.writeFileSync(path.join(outDir, 'proto-manifest.json'), JSON.stringify({ units: report.units, nav: navRows.map((n) => ({ text: n.text, target: n.target, holding: n.holding, rowId: n.rowId })), holdings: [...usedHoldings.entries()], counts: report.counts, unbound: report.unbound, invalidDropdowns: report.invalidDropdowns, shellSizeDelta: report.shellSizeDelta, shellAmbiguities: report.shellAmbiguities, suppressedShellOverlaps: report.suppressedShellOverlaps, skippedShellTransplants: report.skippedShellTransplants, replacedShellActions: report.replacedShellActions, shellActionRemaps: report.shellActionRemaps, scriptDirections: states.map(({ stateId, scriptDirection, writingMode }) => ({ stateId, scriptDirection, writingMode })), canonicalShell: model.canonicalShell }, null, 1));
|
|
393
|
+
console.error('counts', JSON.stringify(report.counts), 'unbound', report.unbound.length);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Render each exported branch page and inventory every leaf element per state section.
|
|
2
|
+
// Usage: node inventory.mjs <run-dir> <out.json>
|
|
3
|
+
// run-dir must hold branch.plan.json and pages/*.html
|
|
4
|
+
import { chromium } from '@playwright/test';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const [runDir, outFile] = process.argv.slice(2);
|
|
9
|
+
if (!runDir || !outFile) throw new Error('usage: inventory.mjs <run-dir> <out.json>');
|
|
10
|
+
|
|
11
|
+
const planFile = path.join(runDir, 'branch.plan.json');
|
|
12
|
+
if (!fs.existsSync(planFile)) throw new Error(`Prototype input error: missing ${planFile}`);
|
|
13
|
+
let plan;
|
|
14
|
+
try {
|
|
15
|
+
plan = JSON.parse(fs.readFileSync(planFile, 'utf8'));
|
|
16
|
+
} catch (error) {
|
|
17
|
+
throw new Error(`Prototype input error: invalid branch.plan.json: ${error.message}`);
|
|
18
|
+
}
|
|
19
|
+
const planPages = Array.isArray(plan.pages) ? plan.pages : [];
|
|
20
|
+
const pagesDir = path.join(runDir, 'pages');
|
|
21
|
+
if (!fs.existsSync(pagesDir)) throw new Error(`Prototype input error: missing pages directory: ${pagesDir}`);
|
|
22
|
+
const files = fs.readdirSync(pagesDir).filter((f) => f.endsWith('.html')).sort();
|
|
23
|
+
if (!files.length) throw new Error(`Prototype input error: no exported HTML pages in ${pagesDir}`);
|
|
24
|
+
|
|
25
|
+
const browser = await chromium.launch();
|
|
26
|
+
const ctx = await browser.newContext({ viewport: { width: 1536, height: 1024 }, deviceScaleFactor: 1 });
|
|
27
|
+
const page = await ctx.newPage();
|
|
28
|
+
|
|
29
|
+
const result = { runDir, plan, pages: [] };
|
|
30
|
+
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const pageId = file.replace(/\.html$/, '');
|
|
33
|
+
const planPage = planPages.find((p) => p.id === pageId);
|
|
34
|
+
await page.goto('file://' + path.resolve(pagesDir, file), { waitUntil: 'load' });
|
|
35
|
+
await page.evaluate(() => document.fonts.ready);
|
|
36
|
+
const sections = await page.evaluate(() => {
|
|
37
|
+
const secs = [...document.querySelectorAll('section[id^="viewport-"]')].filter((s) => /^viewport-\d+-[A-Za-z0-9]+$/.test(s.id));
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const sec of secs) {
|
|
40
|
+
const sb = sec.getBoundingClientRect();
|
|
41
|
+
const sectionStyle = getComputedStyle(sec);
|
|
42
|
+
const els = [...sec.querySelectorAll('.text, h1, h2, h3, h4, h5, h6, button, .icon, img, .divider, [class*="input"], [class*="field"]')];
|
|
43
|
+
const items = [];
|
|
44
|
+
for (const el of els) {
|
|
45
|
+
const r = el.getBoundingClientRect();
|
|
46
|
+
if (r.width === 0 || r.height === 0) continue;
|
|
47
|
+
const cs = getComputedStyle(el);
|
|
48
|
+
const chain = [];
|
|
49
|
+
let a = el.parentElement;
|
|
50
|
+
while (a && a !== sec) { if (a.id) chain.push(a.id); a = a.parentElement; }
|
|
51
|
+
const inButton = !!el.closest('button');
|
|
52
|
+
const btn = el.closest('button');
|
|
53
|
+
items.push({
|
|
54
|
+
id: el.id,
|
|
55
|
+
tag: el.tagName.toLowerCase(),
|
|
56
|
+
cls: el.className && typeof el.className === 'string' ? el.className : '',
|
|
57
|
+
text: el.tagName === 'IMG' || el.classList.contains('icon') ? '' : (el.textContent || '').replace(/\s+/g, ' ').trim(),
|
|
58
|
+
label: el.getAttribute('alt') || el.getAttribute('aria-label') || '',
|
|
59
|
+
x: Math.round((r.left - sb.left) * 10) / 10,
|
|
60
|
+
y: Math.round((r.top - sb.top) * 10) / 10,
|
|
61
|
+
w: Math.round(r.width * 10) / 10,
|
|
62
|
+
h: Math.round(r.height * 10) / 10,
|
|
63
|
+
fontSize: parseFloat(cs.fontSize) || 0,
|
|
64
|
+
fontWeight: cs.fontWeight,
|
|
65
|
+
fontFamily: cs.fontFamily.split(',')[0].replace(/["']/g, ''),
|
|
66
|
+
color: cs.color,
|
|
67
|
+
bg: cs.backgroundColor,
|
|
68
|
+
radius: cs.borderTopLeftRadius,
|
|
69
|
+
inButton,
|
|
70
|
+
buttonId: btn ? btn.id : null,
|
|
71
|
+
chain,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
// containers: every element with an id that has children, for region analysis
|
|
75
|
+
const containers = [...sec.querySelectorAll('div[id], button[id], aside[id], header[id], footer[id], nav[id], section[id], article[id], form[id], main[id]')].map((c) => {
|
|
76
|
+
const r = c.getBoundingClientRect();
|
|
77
|
+
const cs = getComputedStyle(c);
|
|
78
|
+
return {
|
|
79
|
+
id: c.id, tag: c.tagName.toLowerCase(),
|
|
80
|
+
x: Math.round((r.left - sb.left) * 10) / 10, y: Math.round((r.top - sb.top) * 10) / 10,
|
|
81
|
+
w: Math.round(r.width * 10) / 10, h: Math.round(r.height * 10) / 10,
|
|
82
|
+
bg: cs.backgroundColor, border: cs.borderTopWidth + ' ' + cs.borderTopColor, radius: cs.borderTopLeftRadius,
|
|
83
|
+
children: c.children.length,
|
|
84
|
+
};
|
|
85
|
+
}).filter((c) => c.w > 0 && c.h > 0);
|
|
86
|
+
out.push({
|
|
87
|
+
sectionId: sec.id,
|
|
88
|
+
width: sb.width,
|
|
89
|
+
height: sb.height,
|
|
90
|
+
scriptDirection: sectionStyle.direction,
|
|
91
|
+
writingMode: sectionStyle.writingMode,
|
|
92
|
+
items,
|
|
93
|
+
containers,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
});
|
|
98
|
+
result.pages.push({ pageId, file, planPage, sections });
|
|
99
|
+
console.error(`${pageId}: ${sections.length} section(s), ${sections.reduce((n, s) => n + s.items.length, 0)} items`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await browser.close();
|
|
103
|
+
fs.writeFileSync(outFile, JSON.stringify(result, null, 1));
|
|
104
|
+
console.error('wrote', outFile);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Emit one Luna labeling brief per state: candidates + destinations -> simple action schema.
|
|
2
|
+
// Usage: node label-briefs.mjs <model.json> <briefs-dir>
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const [modelFile, outDir] = process.argv.slice(2);
|
|
7
|
+
const model = JSON.parse(fs.readFileSync(modelFile, 'utf8'));
|
|
8
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
9
|
+
|
|
10
|
+
const destinations = model.states.map((s) => `- ${s.stateId}: "${s.title}"${s.ordinal > 1 ? ` (follow-on state ${s.ordinal})` : ''} — ${s.content || s.purpose}`).join('\n');
|
|
11
|
+
const holdings = model.nav.filter((n) => n.holding).map((n) => `holding:${n.text}`);
|
|
12
|
+
|
|
13
|
+
for (const s of model.states) {
|
|
14
|
+
const cands = s.candidates.filter((c) => c.kind !== 'nav' || c.inShell);
|
|
15
|
+
const list = cands.map((c) => `- id=${c.id} | kind=${c.kind} | text="${c.text}"${c.inShell ? ' | in shared chrome' : ''}`).join('\n');
|
|
16
|
+
const fields = s.fields.map((f) => `- id=${f.id} | placeholder="${f.placeholder}"`).join('\n') || '(none)';
|
|
17
|
+
const brief = `tier: luna
|
|
18
|
+
You label interactive elements of ONE screen of a clickable HTML prototype. Reply with ONLY a JSON object, no prose, no markdown fences.
|
|
19
|
+
|
|
20
|
+
Product: ${model.plan.sitePurpose || model.plan.concept.slice(0, 300)}
|
|
21
|
+
|
|
22
|
+
Current screen: ${s.stateId} — "${s.title}". ${s.purpose} ${s.content}
|
|
23
|
+
${(() => { const d = model.states.find((x) => x.dependsOn === s.stateId); return d ? `This screen has a follow-on detail state: ${d.stateId} — ${d.content}. Rows or links that open the item it describes should navigate there.` : ''; })()}
|
|
24
|
+
|
|
25
|
+
Screens that exist (navigation targets):
|
|
26
|
+
${destinations}
|
|
27
|
+
Placeholder screens that also exist (use target exactly as written): ${holdings.join(', ') || '(none)'}
|
|
28
|
+
|
|
29
|
+
Elements on the current screen:
|
|
30
|
+
${list}
|
|
31
|
+
|
|
32
|
+
Text fields on the current screen:
|
|
33
|
+
${fields}
|
|
34
|
+
|
|
35
|
+
For EVERY element id above output exactly one entry. Choose:
|
|
36
|
+
- "navigate": clicking goes to another screen. target = one of the screen ids above, or one of the placeholder targets, or "holding:<Short title>" for a screen that should exist but does not. Never navigate to the current screen.
|
|
37
|
+
- "dropdown": a filter/select/sort trigger. Give "options": 3-6 short plausible values; first option = the current label without a prefix like "All".
|
|
38
|
+
- "modal": an action that submits, creates, runs, exports, approves, or confirms something. Give "modal": {"title": <=6 words, "body": one calm sentence in the product voice, "confirm": 1-2 word verb}.
|
|
39
|
+
- "none": decorative, a heading, not really clickable, or the navigation item for the CURRENT screen.
|
|
40
|
+
Rows (kind=row) and cards (kind=card, clickable card titled with the text) usually navigate to a detail screen if one exists, otherwise "holding:<Short title>" of the thing they open.
|
|
41
|
+
Output schema:
|
|
42
|
+
{"actions":[{"id":"<id>","action":"navigate|dropdown|modal|none","target":"<id or holding:Title>","options":["..."],"modal":{"title":"","body":"","confirm":""}}]}
|
|
43
|
+
Omit keys that do not apply. Do not read or write any files. Do not run commands.
|
|
44
|
+
`;
|
|
45
|
+
fs.writeFileSync(path.join(outDir, `${s.stateId}.md`), brief);
|
|
46
|
+
}
|
|
47
|
+
console.error(`wrote ${model.states.length} briefs to ${outDir}`);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { normalizeText } from './text.mjs';
|
|
2
|
+
|
|
3
|
+
export function usableDropdownOptions(options) {
|
|
4
|
+
if (!Array.isArray(options)) return null;
|
|
5
|
+
const unique = [];
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
for (const option of options) {
|
|
8
|
+
const text = String(option || '').trim();
|
|
9
|
+
const key = normalizeText(text);
|
|
10
|
+
if (!key || seen.has(key)) continue;
|
|
11
|
+
seen.add(key);
|
|
12
|
+
unique.push(text);
|
|
13
|
+
}
|
|
14
|
+
return unique.length >= 3 ? unique : null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { normalizeText, stemToken } from './text.mjs';
|
|
2
|
+
|
|
3
|
+
function tokens(value) {
|
|
4
|
+
return new Set(normalizeText(value).split(' ').filter((token) => token.length > 2).map(stemToken));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function identityText(page) {
|
|
8
|
+
return [page.title, page.pageId?.replace(/^p\d+-/, '').replace(/-/g, ' '), page.stateId?.replace(/-/g, ' ')].filter(Boolean);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function matchPlannedPage(text, pages) {
|
|
12
|
+
const normalized = normalizeText(text);
|
|
13
|
+
const exact = pages.filter((page) => identityText(page).some((value) => normalizeText(value) === normalized));
|
|
14
|
+
if (exact.length === 1) return exact[0];
|
|
15
|
+
if (exact.length > 1) return null;
|
|
16
|
+
|
|
17
|
+
const wanted = tokens(text);
|
|
18
|
+
if (!wanted.size) return null;
|
|
19
|
+
let bestScore = 0;
|
|
20
|
+
let best = [];
|
|
21
|
+
for (const page of pages) {
|
|
22
|
+
const available = tokens(identityText(page).join(' '));
|
|
23
|
+
const intersection = [...wanted].filter((token) => available.has(token)).length;
|
|
24
|
+
const score = intersection / Math.max(1, Math.min(wanted.size, available.size));
|
|
25
|
+
if (score > bestScore) {
|
|
26
|
+
bestScore = score;
|
|
27
|
+
best = [page];
|
|
28
|
+
} else if (score === bestScore && score > 0) {
|
|
29
|
+
best.push(page);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return bestScore >= 0.5 && best.length === 1 ? best[0] : null;
|
|
33
|
+
}
|