@12ui/design 0.2.29 → 0.2.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/README.md +31 -6
  2. package/SKILL.md +1 -1
  3. package/dist/auth-request.d.ts +9 -0
  4. package/dist/auth-request.d.ts.map +1 -0
  5. package/dist/auth-request.js +32 -0
  6. package/dist/auth-request.js.map +1 -0
  7. package/dist/branch-execution-types.d.ts +2 -0
  8. package/dist/branch-execution-types.d.ts.map +1 -1
  9. package/dist/branch-execution.d.ts.map +1 -1
  10. package/dist/branch-execution.js +3 -0
  11. package/dist/branch-execution.js.map +1 -1
  12. package/dist/branch-run-record.d.ts +1 -0
  13. package/dist/branch-run-record.d.ts.map +1 -1
  14. package/dist/branch-run-record.js.map +1 -1
  15. package/dist/cli-arguments.d.ts.map +1 -1
  16. package/dist/cli-arguments.js +1 -0
  17. package/dist/cli-arguments.js.map +1 -1
  18. package/dist/cli-auth-command.d.ts +1 -1
  19. package/dist/cli-auth-command.d.ts.map +1 -1
  20. package/dist/cli-auth-command.js +37 -7
  21. package/dist/cli-auth-command.js.map +1 -1
  22. package/dist/cli-auth-onboarding.d.ts +15 -1
  23. package/dist/cli-auth-onboarding.d.ts.map +1 -1
  24. package/dist/cli-auth-onboarding.js +46 -12
  25. package/dist/cli-auth-onboarding.js.map +1 -1
  26. package/dist/cli-branch-command.d.ts +4 -0
  27. package/dist/cli-branch-command.d.ts.map +1 -1
  28. package/dist/cli-branch-command.js +30 -3
  29. package/dist/cli-branch-command.js.map +1 -1
  30. package/dist/cli-capabilities.d.ts +9 -0
  31. package/dist/cli-capabilities.d.ts.map +1 -1
  32. package/dist/cli-capabilities.js +9 -0
  33. package/dist/cli-capabilities.js.map +1 -1
  34. package/dist/cli-cli-command.d.ts.map +1 -1
  35. package/dist/cli-cli-command.js +5 -1
  36. package/dist/cli-cli-command.js.map +1 -1
  37. package/dist/cli-help/design.d.ts +8 -1
  38. package/dist/cli-help/design.d.ts.map +1 -1
  39. package/dist/cli-help/design.js +12 -0
  40. package/dist/cli-help/design.js.map +1 -1
  41. package/dist/cli-help/index.d.ts +16 -2
  42. package/dist/cli-help/index.d.ts.map +1 -1
  43. package/dist/cli-help/setup.d.ts +8 -1
  44. package/dist/cli-help/setup.d.ts.map +1 -1
  45. package/dist/cli-help/setup.js +11 -0
  46. package/dist/cli-help/setup.js.map +1 -1
  47. package/dist/cli-pending-auth.d.ts +2 -0
  48. package/dist/cli-pending-auth.d.ts.map +1 -1
  49. package/dist/cli-pending-auth.js +39 -15
  50. package/dist/cli-pending-auth.js.map +1 -1
  51. package/dist/cli-persistence-report.d.ts.map +1 -1
  52. package/dist/cli-persistence-report.js +6 -0
  53. package/dist/cli-persistence-report.js.map +1 -1
  54. package/dist/cli-persistence.d.ts +7 -0
  55. package/dist/cli-persistence.d.ts.map +1 -1
  56. package/dist/cli-persistence.js +53 -2
  57. package/dist/cli-persistence.js.map +1 -1
  58. package/dist/cli-prototype-command.d.ts +12 -0
  59. package/dist/cli-prototype-command.d.ts.map +1 -0
  60. package/dist/cli-prototype-command.js +29 -0
  61. package/dist/cli-prototype-command.js.map +1 -0
  62. package/dist/cli-review-command.d.ts.map +1 -1
  63. package/dist/cli-review-command.js +20 -1
  64. package/dist/cli-review-command.js.map +1 -1
  65. package/dist/cli-run.d.ts.map +1 -1
  66. package/dist/cli-run.js +12 -1
  67. package/dist/cli-run.js.map +1 -1
  68. package/dist/cli-skill-command.d.ts.map +1 -1
  69. package/dist/cli-skill-command.js +9 -3
  70. package/dist/cli-skill-command.js.map +1 -1
  71. package/dist/cli-usage.d.ts.map +1 -1
  72. package/dist/cli-usage.js +7 -1
  73. package/dist/cli-usage.js.map +1 -1
  74. package/dist/credential-setup.d.ts +4 -1
  75. package/dist/credential-setup.d.ts.map +1 -1
  76. package/dist/credential-setup.js +28 -3
  77. package/dist/credential-setup.js.map +1 -1
  78. package/dist/index.d.ts +4 -0
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +4 -0
  81. package/dist/index.js.map +1 -1
  82. package/dist/legacy-skill-catalog.d.ts.map +1 -1
  83. package/dist/legacy-skill-catalog.js +16 -0
  84. package/dist/legacy-skill-catalog.js.map +1 -1
  85. package/dist/login-auth.d.ts +16 -0
  86. package/dist/login-auth.d.ts.map +1 -1
  87. package/dist/login-auth.js +107 -25
  88. package/dist/login-auth.js.map +1 -1
  89. package/dist/prototype-gap-html.d.ts +10 -0
  90. package/dist/prototype-gap-html.d.ts.map +1 -0
  91. package/dist/prototype-gap-html.js +55 -0
  92. package/dist/prototype-gap-html.js.map +1 -0
  93. package/dist/prototype-gap-source.d.ts +14 -0
  94. package/dist/prototype-gap-source.d.ts.map +1 -0
  95. package/dist/prototype-gap-source.js +87 -0
  96. package/dist/prototype-gap-source.js.map +1 -0
  97. package/dist/prototype-gates.d.ts +24 -0
  98. package/dist/prototype-gates.d.ts.map +1 -0
  99. package/dist/prototype-gates.js +76 -0
  100. package/dist/prototype-gates.js.map +1 -0
  101. package/dist/prototype-label-client.d.ts +51 -0
  102. package/dist/prototype-label-client.d.ts.map +1 -0
  103. package/dist/prototype-label-client.js +53 -0
  104. package/dist/prototype-label-client.js.map +1 -0
  105. package/dist/prototype-label-score.d.ts +26 -0
  106. package/dist/prototype-label-score.d.ts.map +1 -0
  107. package/dist/prototype-label-score.js +51 -0
  108. package/dist/prototype-label-score.js.map +1 -0
  109. package/dist/prototype-process.d.ts +13 -0
  110. package/dist/prototype-process.d.ts.map +1 -0
  111. package/dist/prototype-process.js +26 -0
  112. package/dist/prototype-process.js.map +1 -0
  113. package/dist/prototype-requests.d.ts +29 -0
  114. package/dist/prototype-requests.d.ts.map +1 -0
  115. package/dist/prototype-requests.js +34 -0
  116. package/dist/prototype-requests.js.map +1 -0
  117. package/dist/prototype-runner.d.ts +22 -0
  118. package/dist/prototype-runner.d.ts.map +1 -0
  119. package/dist/prototype-runner.js +159 -0
  120. package/dist/prototype-runner.js.map +1 -0
  121. package/dist/prototype-source.d.ts +9 -0
  122. package/dist/prototype-source.d.ts.map +1 -0
  123. package/dist/prototype-source.js +48 -0
  124. package/dist/prototype-source.js.map +1 -0
  125. package/dist/review-discovery.d.ts +1 -0
  126. package/dist/review-discovery.d.ts.map +1 -1
  127. package/dist/review-discovery.js +49 -3
  128. package/dist/review-discovery.js.map +1 -1
  129. package/dist/review-page-assets.d.ts +2 -2
  130. package/dist/review-page-assets.d.ts.map +1 -1
  131. package/dist/review-page-assets.js +20 -1
  132. package/dist/review-page-assets.js.map +1 -1
  133. package/dist/review-page.d.ts.map +1 -1
  134. package/dist/review-page.js +38 -0
  135. package/dist/review-page.js.map +1 -1
  136. package/dist/review-prototype-gap.d.ts +48 -0
  137. package/dist/review-prototype-gap.d.ts.map +1 -0
  138. package/dist/review-prototype-gap.js +192 -0
  139. package/dist/review-prototype-gap.js.map +1 -0
  140. package/dist/review-server.d.ts +7 -0
  141. package/dist/review-server.d.ts.map +1 -1
  142. package/dist/review-server.js +64 -0
  143. package/dist/review-server.js.map +1 -1
  144. package/dist/review-types.d.ts +12 -0
  145. package/dist/review-types.d.ts.map +1 -1
  146. package/dist/run-journal-commands.d.ts.map +1 -1
  147. package/dist/run-journal-commands.js +13 -0
  148. package/dist/run-journal-commands.js.map +1 -1
  149. package/dist/run-journal.d.ts.map +1 -1
  150. package/dist/run-journal.js +6 -0
  151. package/dist/run-journal.js.map +1 -1
  152. package/open-design.json +2 -2
  153. package/package.json +7 -1
  154. package/prototype-kit/analyze.mjs +275 -0
  155. package/prototype-kit/build.mjs +393 -0
  156. package/prototype-kit/inventory.mjs +104 -0
  157. package/prototype-kit/label-briefs.mjs +47 -0
  158. package/prototype-kit/lib/actions.mjs +15 -0
  159. package/prototype-kit/lib/navigation.mjs +33 -0
  160. package/prototype-kit/lib/runtime-check.mjs +126 -0
  161. package/prototype-kit/lib/shells.mjs +68 -0
  162. package/prototype-kit/lib/static-server.mjs +40 -0
  163. package/prototype-kit/lib/text.mjs +20 -0
  164. package/prototype-kit/runtime/proto.css +46 -0
  165. package/prototype-kit/runtime/proto.js +173 -0
  166. package/prototype-kit/score-labels.mjs +42 -0
  167. package/prototype-kit/tests/README.md +28 -0
  168. package/prototype-kit/tests/adversarial-runtime.mjs +90 -0
  169. package/prototype-kit/tests/discover-runs.mjs +29 -0
  170. package/prototype-kit/tests/fixtures/no-semantic/branch.plan.json +7 -0
  171. package/prototype-kit/tests/fixtures/no-semantic/pages/legacy.html +1 -0
  172. package/prototype-kit/tests/fixtures/partial-plan/branch.plan.json +5 -0
  173. package/prototype-kit/tests/fixtures/partial-plan/pages/orphan.html +4 -0
  174. package/prototype-kit/tests/fixtures/runtime/double.html +5 -0
  175. package/prototype-kit/tests/fixtures/runtime/history-destination.html +1 -0
  176. package/prototype-kit/tests/fixtures/runtime/history.html +6 -0
  177. package/prototype-kit/tests/fixtures/runtime/hover-stack.html +15 -0
  178. package/prototype-kit/tests/fixtures/runtime/invalid-dropdown.html +6 -0
  179. package/prototype-kit/tests/fixtures/runtime/nested.html +7 -0
  180. package/prototype-kit/tests/fixtures/three-shell/branch.plan.json +30 -0
  181. package/prototype-kit/tests/fixtures/three-shell/pages/p0-home.html +11 -0
  182. package/prototype-kit/tests/fixtures/three-shell/pages/p1-reports.html +11 -0
  183. package/prototype-kit/tests/lib/run-discovery.mjs +87 -0
  184. package/prototype-kit/tests/lib/run-process.mjs +49 -0
  185. package/prototype-kit/tests/offline-check.mjs +41 -0
  186. package/prototype-kit/tests/population-sweep.mjs +94 -0
  187. package/prototype-kit/tests/rules-check.mjs +50 -0
  188. package/prototype-kit/verify-runtime.mjs +36 -0
  189. package/skills/12ui-design/SKILL.md +9 -3
package/open-design.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "specVersion": "1.0.0",
4
4
  "name": "12ui-design",
5
5
  "title": "12UI Design",
6
- "version": "0.2.29",
6
+ "version": "0.2.31",
7
7
  "description": "Explore corpus-grounded interface directions, choose one, and convert it into production-ready responsive or native artifacts with 12UI.",
8
8
  "author": {
9
9
  "name": "Just Every",
@@ -49,7 +49,7 @@
49
49
  "command": "npx",
50
50
  "args": [
51
51
  "-y",
52
- "@12ui/design@0.2.29",
52
+ "@12ui/design@0.2.31",
53
53
  "mcp"
54
54
  ]
55
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12ui/design",
3
- "version": "0.2.29",
3
+ "version": "0.2.31",
4
4
  "type": "module",
5
5
  "description": "The canonical 12ui CLI, SDK, MCP server, and visual interface design skills",
6
6
  "main": "dist/index.js",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "dist/**/*",
13
+ "prototype-kit/**/*",
13
14
  "skills/**/*",
14
15
  "SKILL.md",
15
16
  "open-design.json",
@@ -22,14 +23,19 @@
22
23
  "start": "node dist/cli.js",
23
24
  "test": "vitest",
24
25
  "test:run": "vitest run",
26
+ "test:prototype": "node prototype-kit/tests/rules-check.mjs && node prototype-kit/tests/offline-check.mjs && node prototype-kit/tests/adversarial-runtime.mjs --assert",
25
27
  "typecheck": "tsc --noEmit && tsc -p tsconfig.type-tests.json",
26
28
  "prepublishOnly": "npm run build"
27
29
  },
28
30
  "dependencies": {
29
31
  "@just-every/ensemble": "0.2.274",
30
32
  "@modelcontextprotocol/sdk": "^1.29.0",
33
+ "linkedom": "^0.18.12",
31
34
  "sharp": "^0.35.3"
32
35
  },
36
+ "peerDependencies": {
37
+ "@playwright/test": "1.59.1"
38
+ },
33
39
  "devDependencies": {
34
40
  "@types/node": "^22.19.17",
35
41
  "typescript": "^5.9.3",
@@ -0,0 +1,275 @@
1
+ // Deterministic analysis of an inventory: states, shared shell, nav items, dropdowns, rows, buttons, link-like texts.
2
+ // Usage: node analyze.mjs <inventory.json> <model.json>
3
+ import fs from 'node:fs';
4
+ import { matchPlannedPage } from './lib/navigation.mjs';
5
+ import { compareCanonicalShells, isWithinShell, qualifiesShellCandidate, selectOneShellPerRegion } from './lib/shells.mjs';
6
+ import { normalizeText as norm, slugText as slug } from './lib/text.mjs';
7
+
8
+ const [invFile, outFile] = process.argv.slice(2);
9
+ const inv = JSON.parse(fs.readFileSync(invFile, 'utf8'));
10
+ const W = 1536, H = 1024;
11
+ const planScreens = Array.isArray(inv.plan?.screens) ? inv.plan.screens : [];
12
+ const chevronDown = (l) => /\b(down|caret down|chevron down|expand)\b/.test(norm(l)) && /chevron|caret|arrow|down/.test(norm(l));
13
+ const chevronRight = (l) => /right/.test(norm(l)) && /chevron|arrow|caret/.test(norm(l));
14
+ const linkVerb = /^(view|open|see|manage|clear|add|run|export|download|learn|read|sign|get|start|try|book|browse|explore|edit|approve|defer|review|show|load|invite|create|upgrade|contact|subscribe|join|buy|shop|reserve|rsvp|register|continue|back|cancel|save|send|submit|apply|filter|sort|search|show)\b/;
15
+
16
+ // ---- states
17
+ const states = [];
18
+ for (const p of inv.pages) {
19
+ p.sections.forEach((s, i) => {
20
+ const slotMatch = s.sectionId.match(/^viewport-(\d+)-(\w+)$/);
21
+ const slot = slotMatch ? slotMatch[2] : String(i);
22
+ const screen = planScreens.find((x) => x.slot === slot);
23
+ const ordinal = i + 1;
24
+ const base = p.pageId.replace(/^p\d+-/, '');
25
+ states.push({
26
+ stateId: ordinal === 1 ? base : `${base}-${ordinal}`,
27
+ pageId: p.pageId,
28
+ pageIndex: p.planPage?.index ?? null,
29
+ file: p.file,
30
+ sectionId: s.sectionId,
31
+ slot,
32
+ ordinal,
33
+ title: p.planPage?.title || base.replace(/-/g, ' ').replace(/\b\p{L}/gu, (letter) => letter.toUpperCase()),
34
+ purpose: p.planPage?.purpose || '',
35
+ surfaceType: p.planPage?.surfaceType || '',
36
+ direction: screen?.direction || '',
37
+ content: screen?.content || '',
38
+ dependsOnSlot: screen?.dependsOnSlot || null,
39
+ isSource: !!p.planPage?.startsFromWinner && ordinal === 1,
40
+ items: s.items,
41
+ containers: s.containers,
42
+ height: s.height,
43
+ scriptDirection: s.scriptDirection,
44
+ writingMode: s.writingMode,
45
+ });
46
+ });
47
+ }
48
+ if (!states.length) throw new Error('Prototype input error: no semantic viewport sections found; exports require section ids shaped like viewport-<ordinal>-<slot>');
49
+ const byId = Object.fromEntries(states.map((s) => [s.stateId, s]));
50
+ const slotToState = Object.fromEntries(states.map((s) => [s.slot, s.stateId]));
51
+
52
+ // ---- shell: elements repeated (same text/label, bbox within tol) across >= 60% of states
53
+ const key = (it) => (it.text ? 't:' + norm(it.text) : it.label ? 'l:' + norm(it.label) : null);
54
+ const TOL = 40;
55
+ const minStates = Math.max(2, Math.ceil(states.length * 0.4));
56
+ function repeatedKeys() {
57
+ const map = new Map();
58
+ for (const s of states) {
59
+ const seen = new Set();
60
+ for (const it of s.items) {
61
+ const k = key(it); if (!k || seen.has(k)) continue;
62
+ seen.add(k);
63
+ (map.get(k) || map.set(k, []).get(k)).push({ state: s.stateId, x: it.x, y: it.y, yb: s.height - it.y, w: it.w, h: it.h, id: it.id });
64
+ }
65
+ }
66
+ const rep = new Map();
67
+ for (const [k, occ] of map) {
68
+ if (occ.length < minStates) continue;
69
+ // cluster by position: count occurrences within TOL of the median
70
+ const mx = median(occ.map((o) => o.x)), my = median(occ.map((o) => o.y)), myb = median(occ.map((o) => o.yb));
71
+ const near = occ.filter((o) => Math.abs(o.x - mx) <= TOL && (Math.abs(o.y - my) <= TOL || Math.abs(o.yb - myb) <= TOL));
72
+ if (near.length >= minStates) rep.set(k, { x: mx, y: my, occ: near });
73
+ }
74
+ return rep;
75
+ }
76
+ function median(a) { const b = [...a].sort((x, y) => x - y); return b[Math.floor(b.length / 2)]; }
77
+ const repeated = repeatedKeys();
78
+
79
+ // top-level containers per state and their shell share
80
+ function topContainers(s) {
81
+ const pre = s.sectionId + '-';
82
+ return s.containers.filter((c) => c.id.startsWith(pre) && !c.id.slice(pre.length).includes('-') && c.children > 0);
83
+ }
84
+ function regionOf(c) {
85
+ if (c.w <= 0.3 * W && c.h >= 0.7 * H && c.x <= 0.05 * W) return 'sidebar';
86
+ if (c.w >= 0.15 * W && c.h <= 0.2 * H && c.y <= 0.08 * H) return 'header';
87
+ if (c.w >= 0.6 * W && c.h <= 0.45 * H && c.y + c.h >= 0.9 * H) return 'footer';
88
+ return null;
89
+ }
90
+ const rejectedShellsByState = new Map();
91
+ for (const s of states) {
92
+ s.shell = [];
93
+ const rejected = [];
94
+ for (const c of topContainers(s)) {
95
+ const inside = s.items.filter((it) => it.chain.includes(c.id) || it.id === c.id);
96
+ if (inside.length < 2) continue;
97
+ const shellCount = inside.filter((it) => { const k = key(it); return k && repeated.has(k) && repeated.get(k).occ.some((o) => o.state === s.stateId); }).length;
98
+ const share = shellCount / inside.length;
99
+ const region = regionOf(c);
100
+ const semantic = ['aside', 'header', 'footer', 'nav'].includes(c.tag) || /-(sidebar|nav|navbar|header|footer|topbar|sidenav)$/.test(c.id);
101
+ const shell = { containerId: c.id, region, share: +share.toFixed(2), semantic, items: inside.length, box: [c.x, c.y, c.w, c.h] };
102
+ const hasShellEvidence = region && (share >= 0.5 || (semantic && region === 'sidebar' && inside.length >= 3));
103
+ if (!hasShellEvidence) continue;
104
+ if (qualifiesShellCandidate(shell, W)) s.shell.push(shell);
105
+ else rejected.push(shell);
106
+ }
107
+ rejectedShellsByState.set(s.stateId, rejected);
108
+ }
109
+ const shellAmbiguities = [];
110
+ for (const state of states) {
111
+ const { selected, dropped } = selectOneShellPerRegion(state.shell);
112
+ state.shell = selected;
113
+ for (const shell of [...dropped, ...(rejectedShellsByState.get(state.stateId) || [])]) {
114
+ const kept = selected.find((candidate) => candidate.region === shell.region);
115
+ if (!kept) continue;
116
+ shellAmbiguities.push({
117
+ state: state.stateId,
118
+ region: shell.region,
119
+ dropped: shell.containerId,
120
+ droppedBox: shell.box,
121
+ kept: kept?.containerId || null,
122
+ keptBox: kept?.box || null,
123
+ });
124
+ }
125
+ }
126
+ // canonical shell per region: prefer the source state, else the state whose container has the highest share*items
127
+ let regions = [...new Set(states.flatMap((s) => s.shell.map((x) => x.region)))];
128
+ const canonical = {};
129
+ // how many short texts inside a container match a planned page title (used to rank competing shells)
130
+ function pageMatches(s, cid) {
131
+ return s.items.filter((it) => it.text && it.text.length <= 32 && it.chain.includes(cid)).map((it) => matchPage(it.text)).filter(Boolean).length;
132
+ }
133
+ for (const r of regions) {
134
+ const cands = states.flatMap((s) => s.shell.filter((x) => x.region === r).map((x) => ({ ...x, stateId: s.stateId, isSource: s.isSource, matches: pageMatches(s, x.containerId) })));
135
+ if (cands.length < 2) { for (const s of states) s.shell = s.shell.filter((x) => x.region !== r); continue; }
136
+ cands.sort(compareCanonicalShells);
137
+ canonical[r] = { stateId: cands[0].stateId, containerId: cands[0].containerId, box: cands[0].box, presentIn: cands.map((c) => c.stateId), ranking: cands.map((c) => `${c.stateId}:${c.matches}m/${c.items}i/${c.share}`) };
138
+ }
139
+ regions = Object.keys(canonical);
140
+
141
+ // ---- nav items: texts inside a canonical shell container that repeat, font >= 12, excluding long copy
142
+ const nav = [];
143
+ for (const r of regions) {
144
+ const can = canonical[r]; const s = byId[can.stateId];
145
+ const texts = s.items.filter((it) => it.text && it.chain.includes(can.containerId) && it.fontSize >= 12 && it.text.length <= 32 && !it.inButton && !/\d/.test(it.text));
146
+ // a nav list = >= 3 texts sharing the same x (vertical list) or same y (horizontal list)
147
+ const groups = new Map();
148
+ for (const t of texts) {
149
+ const gx = 'x' + Math.round(t.x / 6), gy = 'y' + Math.round(t.y / 6);
150
+ (groups.get(gx) || groups.set(gx, []).get(gx)).push(t);
151
+ (groups.get(gy) || groups.set(gy, []).get(gy)).push(t);
152
+ }
153
+ const best = [...groups.values()].sort((a, b) => b.length - a.length)[0] || [];
154
+ if (best.length >= 3) for (const t of best) nav.push({ region: r, id: t.id, text: t.text, x: t.x, y: t.y, w: t.w, h: t.h, fontSize: t.fontSize, color: t.color, fontWeight: t.fontWeight });
155
+ }
156
+ // match nav text -> page (first state of that page) by token overlap with title / pageId
157
+ function matchPage(text) {
158
+ return matchPlannedPage(text, states.filter((state) => state.ordinal === 1))?.stateId || null;
159
+ }
160
+ for (const n of nav) { n.target = matchPage(n.text); }
161
+ { const first = nav.find((n) => n.region !== 'footer'); const home = states.find((s) => s.isSource) || states[0];
162
+ if (first && !first.target && /^(home|overview|dashboard|start)$/.test(norm(first.text))) { first.target = home.stateId; first.fallback = 'home'; } }
163
+ for (const n of nav) n.holding = n.target ? null : 'holding-' + slug(n.text);
164
+
165
+ // ---- per-state interactive candidates
166
+ function smallestCommon(s, ids) {
167
+ const chains = ids.map((id) => { const it = s.items.find((x) => x.id === id); return it ? [it.id, ...it.chain] : []; });
168
+ for (const c of chains[0]) if (chains.every((ch) => ch.includes(c))) return c;
169
+ return null;
170
+ }
171
+ function containerItems(s, cid) { return s.items.filter((it) => it.id === cid || it.chain.includes(cid)); }
172
+ for (const s of states) {
173
+ const shellIds = new Set(s.shell.map((x) => x.containerId));
174
+ const inShell = (it) => it.chain.some((c) => shellIds.has(c));
175
+ const cands = [];
176
+ // buttons
177
+ const btnMap = new Map();
178
+ for (const it of s.items) if (it.inButton && it.text) btnMap.set(it.buttonId, ((btnMap.get(it.buttonId) || '') + ' ' + it.text).trim());
179
+ for (const [bid, text] of btnMap) {
180
+ const c = s.containers.find((x) => x.id === bid);
181
+ cands.push({ id: bid, kind: 'button', text, box: c ? [c.x, c.y, c.w, c.h] : null, inShell: !!c && [...shellIds].some((sh) => s.items.find((it) => it.buttonId === bid)?.chain.includes(sh)) });
182
+ }
183
+ // dropdown triggers: a down-chevron icon whose smallest container with a text sibling
184
+ for (const ic of s.items.filter((it) => (it.cls.includes('icon') || it.tag === 'img') && chevronDown(it.label))) {
185
+ // find nearest text to the left on the same line
186
+ // prefer an enclosing input/select-like container; else the nearest text to the left on the same line
187
+ const fieldCid = ic.chain.find((cid) => /(input|select|filter|sort|dropdown|picker|menu)(-|$)/.test(cid) && (s.containers.find((x) => x.id === cid)?.w || 0) <= 420);
188
+ let near = fieldCid ? containerItems(s, fieldCid).filter((t) => t.text).sort((a, b) => a.x - b.x)[0] : null;
189
+ if (!near) near = s.items.filter((t) => t.text && Math.abs((t.y + t.h / 2) - (ic.y + ic.h / 2)) <= Math.max(8, t.h) && t.x + t.w <= ic.x + 4 && ic.x - (t.x + t.w) <= 260).sort((a, b) => (ic.x - (a.x + a.w)) - (ic.x - (b.x + b.w)))[0];
190
+ if (!near) continue;
191
+ const cid = fieldCid || smallestCommon(s, [ic.id, near.id]);
192
+ const c = s.containers.find((x) => x.id === cid);
193
+ if (cands.some((x) => x.id === (cid || near.id))) { const ex = cands.find((x) => x.id === (cid || near.id)); ex.kind = 'dropdown'; ex.textId = near.id; continue; }
194
+ cands.push({ id: cid || near.id, kind: 'dropdown', text: near.text, textId: near.id, iconId: ic.id, box: c ? [c.x, c.y, c.w, c.h] : [near.x, near.y, ic.x + ic.w - near.x, Math.max(near.h, ic.h)], inShell: inShell(near) });
195
+ }
196
+ // rows: right-chevron icon -> its row container (smallest container wider than 200px holding the icon and >=1 text)
197
+ for (const ic of s.items.filter((it) => (it.cls.includes('icon') || it.tag === 'img') && chevronRight(it.label))) {
198
+ // the full row = the widest ancestor that is still row-shaped (<= 140px tall)
199
+ let row = null;
200
+ for (const cid of ic.chain) { const c = s.containers.find((x) => x.id === cid); if (c && c.h <= 140 && c.w >= 200 && containerItems(s, cid).some((t) => t.text) && (!row || c.w * c.h >= row.w * row.h)) row = c; }
201
+ if (!row || shellIds.has(row.id) || isWithinShell(ic.chain, shellIds)) continue;
202
+ if (cands.some((x) => x.id === row.id)) continue;
203
+ const texts = containerItems(s, row.id).filter((t) => t.text).sort((a, b) => b.fontSize - a.fontSize);
204
+ cands.push({ id: row.id, kind: 'row', text: texts.slice(0, 2).map((t) => t.text).join(' — '), box: [row.x, row.y, row.w, row.h], inShell: false });
205
+ }
206
+ // link-like texts
207
+ for (const t of s.items) {
208
+ if (!t.text || t.inButton || t.text.length > 40) continue;
209
+ if (cands.some((c) => c.id === t.id || c.textId === t.id)) continue;
210
+ const isNav = inShell(t) && nav.some((n) => norm(n.text) === norm(t.text));
211
+ if (isNav) { cands.push({ id: t.id, kind: 'nav', text: t.text, box: [t.x, t.y, t.w, t.h], inShell: true }); continue; }
212
+ if (linkVerb.test(norm(t.text)) && t.text.split(' ').length <= 5) cands.push({ id: t.id, kind: 'link', text: t.text, box: [t.x, t.y, t.w, t.h], inShell: inShell(t) });
213
+ }
214
+ // card titles: prominent texts inside a sizeable card container (not the page title, not chrome)
215
+ const pageTitleY = Math.min(...s.items.filter((t) => t.text && t.fontSize >= 24 && !inShell(t)).map((t) => t.y), 1e9);
216
+ for (const t of s.items) {
217
+ if (!t.text || t.inButton || inShell(t) || t.fontSize < 17 || t.text.length > 48 || t.y <= pageTitleY + 4) continue;
218
+ if (cands.some((c) => c.id === t.id || c.textId === t.id || t.chain.includes(c.id))) continue;
219
+ const card = t.chain.map((cid) => s.containers.find((x) => x.id === cid)).find((c) => c && c.w >= 240 && c.h >= 80 && c.h <= 600 && c.bg && c.bg !== 'rgba(0, 0, 0, 0)');
220
+ if (!card) continue;
221
+ cands.push({ id: card.id, kind: 'card', text: t.text, textId: t.id, box: [card.x, card.y, card.w, card.h], inShell: false });
222
+ }
223
+ // search / bell / avatar icons in header: utility
224
+ for (const ic of s.items.filter((it) => (it.cls.includes('icon') || it.tag === 'img') && /search|magnif|bell|notification|avatar|profile|menu|hamburger|user/.test(norm(it.label)) && !it.inButton)) {
225
+ if (cands.some((c) => c.id === ic.id)) continue;
226
+ cands.push({ id: ic.id, kind: 'utility', text: ic.label, box: [ic.x, ic.y, ic.w, ic.h], inShell: inShell(ic) });
227
+ }
228
+ s.candidates = cands;
229
+ // form fields: containers whose id mentions input/field/composer/search and have a text child (placeholder)
230
+ s.fields = s.containers.filter((c) => /(input|field|composer|search|textarea|prompt)(-|$)/.test(c.id) && c.w >= 120 && c.h >= 28 && c.h <= 160 && !cands.some((x) => x.id === c.id)).map((c) => {
231
+ const texts = containerItems(s, c.id).filter((t) => t.text);
232
+ return { id: c.id, placeholder: texts[0]?.text || '', box: [c.x, c.y, c.w, c.h] };
233
+ });
234
+ }
235
+
236
+ // ---- style tokens from the source (or first) state
237
+ const src = states.find((s) => s.isSource) || states[0];
238
+ function mode(arr) { const m = new Map(); for (const a of arr) if (a) m.set(a, (m.get(a) || 0) + 1); return [...m.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]; }
239
+ const bodyTexts = src.items.filter((t) => t.text && t.fontSize >= 12 && t.fontSize <= 16);
240
+ const heading = src.items.filter((t) => t.text).sort((a, b) => b.fontSize - a.fontSize)[0];
241
+ const btnBgs = states.flatMap((s) => s.containers.filter((c) => c.tag === 'button' && c.bg && c.bg !== 'rgba(0, 0, 0, 0)').map((c) => c.bg));
242
+ const accentText = mode(src.items.filter((t) => t.text && t.fontWeight >= 500 && !/rgb\((\d+), \1, \1\)/.test(t.color) && t.color !== 'rgb(0, 0, 0)').map((t) => t.color));
243
+ const tokens = {
244
+ bodyFont: mode(bodyTexts.map((t) => t.fontFamily)),
245
+ bodySize: mode(bodyTexts.map((t) => Math.round(t.fontSize))),
246
+ bodyColor: mode(bodyTexts.map((t) => t.color)),
247
+ mutedColor: mode(src.items.filter((t) => t.text && t.fontSize < 13).map((t) => t.color)),
248
+ headingFont: heading?.fontFamily, headingSize: heading?.fontSize, headingColor: heading?.color, headingWeight: heading?.fontWeight,
249
+ accent: accentText,
250
+ buttonBg: mode(btnBgs),
251
+ cardRadius: mode(src.containers.filter((c) => c.w > 200 && c.h > 80 && c.radius !== '0px').map((c) => c.radius)),
252
+ cardBorder: mode(src.containers.filter((c) => c.w > 200 && c.h > 80 && !c.border.startsWith('0px')).map((c) => c.border)),
253
+ pageBg: src.containers.find((c) => c.x === 0 && c.y === 0 && c.w >= W - 1 && c.h >= H - 1)?.bg,
254
+ surfaceBg: mode(src.containers.filter((c) => c.w > 300 && c.h > 100 && c.bg !== 'rgba(0, 0, 0, 0)').map((c) => c.bg)),
255
+ };
256
+
257
+ const model = {
258
+ run: inv.runDir,
259
+ plan: { concept: typeof inv.plan?.concept === 'string' ? inv.plan.concept : '', sitePurpose: typeof inv.plan?.sitePurpose === 'string' ? inv.plan.sitePurpose : '', scope: inv.plan?.scope || null },
260
+ tokens,
261
+ canonicalShell: canonical,
262
+ shellAmbiguities,
263
+ nav,
264
+ states: states.map((s) => ({
265
+ stateId: s.stateId, pageId: s.pageId, file: s.file, sectionId: s.sectionId, slot: s.slot, ordinal: s.ordinal, title: s.title, purpose: s.purpose,
266
+ surfaceType: s.surfaceType, direction: s.direction, content: s.content, dependsOn: s.dependsOnSlot ? slotToState[s.dependsOnSlot] : null, isSource: s.isSource,
267
+ shell: s.shell, candidates: s.candidates, fields: s.fields, scriptDirection: s.scriptDirection, writingMode: s.writingMode,
268
+ texts: s.items.filter((t) => t.text).map((t) => ({ id: t.id, text: t.text })).length,
269
+ })),
270
+ };
271
+ fs.writeFileSync(outFile, JSON.stringify(model, null, 1));
272
+ // summary
273
+ console.error(`states: ${states.length}; shell regions: ${regions.join(',') || 'none'}; nav items: ${nav.length} (${nav.filter((n) => n.target).length} matched to pages)`);
274
+ for (const s of model.states) console.error(` ${s.stateId.padEnd(28)} shell=${s.shell.map((x) => x.region).join('+') || '-'} cands=${s.candidates.length} [${['button', 'dropdown', 'row', 'link', 'nav', 'utility', 'card'].map((k) => k[0] + s.candidates.filter((c) => c.kind === k).length).join(' ')}] fields=${s.fields.length}`);
275
+ console.error('tokens', JSON.stringify(tokens));