@ggui-ai/ui-gen 0.28.0 → 0.29.0

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 (308) hide show
  1. package/dist/{adapter-h4_V69LI.d.ts → adapter-DLQ-5COm.d.ts} +1 -1
  2. package/dist/adapters/base.js +2 -27
  3. package/dist/adapters/base.js.map +1 -1
  4. package/dist/adapters/generation-dispatch.d.ts +14 -6
  5. package/dist/adapters/generation-dispatch.js +40 -19260
  6. package/dist/adapters/generation-dispatch.js.map +1 -1
  7. package/dist/adapters/index.d.ts +1 -1
  8. package/dist/adapters/index.js +25 -3561
  9. package/dist/adapters/index.js.map +1 -1
  10. package/dist/adapters/types.js +2 -9
  11. package/dist/adapters/types.js.map +1 -1
  12. package/dist/advanced/index.d.ts +13 -12
  13. package/dist/advanced/index.js +63 -21954
  14. package/dist/advanced/index.js.map +1 -1
  15. package/dist/{bank-Ctdp3FUA.d.ts → bank-LWaiAtaA.d.ts} +1 -1
  16. package/dist/blueprint-validator.js +6 -2301
  17. package/dist/blueprint-validator.js.map +1 -1
  18. package/dist/boilerplate.d.ts +3 -2
  19. package/dist/boilerplate.js +6 -1867
  20. package/dist/boilerplate.js.map +1 -1
  21. package/dist/check/index.d.ts +3 -2
  22. package/dist/check/index.js +6 -1923
  23. package/dist/check/index.js.map +1 -1
  24. package/dist/chunk-33B3BAWS.js +24 -0
  25. package/dist/chunk-33B3BAWS.js.map +1 -0
  26. package/dist/chunk-3HSGA6NI.js +591 -0
  27. package/dist/chunk-3HSGA6NI.js.map +1 -0
  28. package/dist/chunk-3NNNGNTO.js +1707 -0
  29. package/dist/chunk-3NNNGNTO.js.map +1 -0
  30. package/dist/chunk-3T2HMTON.js +79 -0
  31. package/dist/chunk-3T2HMTON.js.map +1 -0
  32. package/dist/chunk-3ZSSDV3L.js +123 -0
  33. package/dist/chunk-3ZSSDV3L.js.map +1 -0
  34. package/dist/chunk-4MBR5GA3.js +99 -0
  35. package/dist/chunk-4MBR5GA3.js.map +1 -0
  36. package/dist/chunk-56NJFD55.js +337 -0
  37. package/dist/chunk-56NJFD55.js.map +1 -0
  38. package/dist/chunk-5AGTY555.js +26 -0
  39. package/dist/chunk-5AGTY555.js.map +1 -0
  40. package/dist/chunk-5EJM7EGY.js +115 -0
  41. package/dist/chunk-5EJM7EGY.js.map +1 -0
  42. package/dist/chunk-5KXCQLLR.js +529 -0
  43. package/dist/chunk-5KXCQLLR.js.map +1 -0
  44. package/dist/chunk-5VBXHVEH.js +171 -0
  45. package/dist/chunk-5VBXHVEH.js.map +1 -0
  46. package/dist/chunk-6KDDEING.js +515 -0
  47. package/dist/chunk-6KDDEING.js.map +1 -0
  48. package/dist/chunk-6VDRWAP6.js +123 -0
  49. package/dist/chunk-6VDRWAP6.js.map +1 -0
  50. package/dist/chunk-732IBFEJ.js +2400 -0
  51. package/dist/chunk-732IBFEJ.js.map +1 -0
  52. package/dist/chunk-73AURS3I.js +46 -0
  53. package/dist/chunk-73AURS3I.js.map +1 -0
  54. package/dist/chunk-77H5SKLX.js +8 -0
  55. package/dist/chunk-77H5SKLX.js.map +1 -0
  56. package/dist/chunk-7QE3AHLE.js +35 -0
  57. package/dist/chunk-7QE3AHLE.js.map +1 -0
  58. package/dist/chunk-A375NNWN.js +11 -0
  59. package/dist/chunk-A375NNWN.js.map +1 -0
  60. package/dist/chunk-AOU33EFS.js +140 -0
  61. package/dist/chunk-AOU33EFS.js.map +1 -0
  62. package/dist/chunk-BA4QYA6E.js +461 -0
  63. package/dist/chunk-BA4QYA6E.js.map +1 -0
  64. package/dist/chunk-DBP764GL.js +3 -0
  65. package/dist/chunk-DBP764GL.js.map +1 -0
  66. package/dist/chunk-DUU3ZJDQ.js +41 -0
  67. package/dist/chunk-DUU3ZJDQ.js.map +1 -0
  68. package/dist/chunk-DXKRBXNU.js +30 -0
  69. package/dist/chunk-DXKRBXNU.js.map +1 -0
  70. package/dist/chunk-FB7OEEAH.js +1467 -0
  71. package/dist/chunk-FB7OEEAH.js.map +1 -0
  72. package/dist/chunk-FLRNMZV2.js +18 -0
  73. package/dist/chunk-FLRNMZV2.js.map +1 -0
  74. package/dist/chunk-FWJIMKFZ.js +2032 -0
  75. package/dist/chunk-FWJIMKFZ.js.map +1 -0
  76. package/dist/chunk-FXRHWYIQ.js +163 -0
  77. package/dist/chunk-FXRHWYIQ.js.map +1 -0
  78. package/dist/chunk-G3PMV62Z.js +33 -0
  79. package/dist/chunk-G3PMV62Z.js.map +1 -0
  80. package/dist/chunk-GAJI7Y3P.js +13 -0
  81. package/dist/chunk-GAJI7Y3P.js.map +1 -0
  82. package/dist/chunk-GYINWMPR.js +2052 -0
  83. package/dist/chunk-GYINWMPR.js.map +1 -0
  84. package/dist/chunk-HD6ABIK7.js +484 -0
  85. package/dist/chunk-HD6ABIK7.js.map +1 -0
  86. package/dist/chunk-HDQVHYO6.js +100 -0
  87. package/dist/chunk-HDQVHYO6.js.map +1 -0
  88. package/dist/chunk-IHUT3SIW.js +7 -0
  89. package/dist/chunk-IHUT3SIW.js.map +1 -0
  90. package/dist/chunk-JDH5EGNZ.js +126 -0
  91. package/dist/chunk-JDH5EGNZ.js.map +1 -0
  92. package/dist/chunk-JMD4RGSV.js +37 -0
  93. package/dist/chunk-JMD4RGSV.js.map +1 -0
  94. package/dist/chunk-KJWGZL5B.js +33 -0
  95. package/dist/chunk-KJWGZL5B.js.map +1 -0
  96. package/dist/chunk-MKMDQFZ7.js +86 -0
  97. package/dist/chunk-MKMDQFZ7.js.map +1 -0
  98. package/dist/chunk-NF6JUZWF.js +85 -0
  99. package/dist/chunk-NF6JUZWF.js.map +1 -0
  100. package/dist/chunk-O2YC6XDC.js +1933 -0
  101. package/dist/chunk-O2YC6XDC.js.map +1 -0
  102. package/dist/chunk-OVLH5ICV.js +29 -0
  103. package/dist/chunk-OVLH5ICV.js.map +1 -0
  104. package/dist/chunk-PBOYHGSD.js +106 -0
  105. package/dist/chunk-PBOYHGSD.js.map +1 -0
  106. package/dist/chunk-PJFH7QWA.js +81 -0
  107. package/dist/chunk-PJFH7QWA.js.map +1 -0
  108. package/dist/chunk-PUH4CXGZ.js +284 -0
  109. package/dist/chunk-PUH4CXGZ.js.map +1 -0
  110. package/dist/chunk-QLXXPGTV.js +18 -0
  111. package/dist/chunk-QLXXPGTV.js.map +1 -0
  112. package/dist/chunk-R5GIGPW2.js +2845 -0
  113. package/dist/chunk-R5GIGPW2.js.map +1 -0
  114. package/dist/chunk-RH4IUSTO.js +83 -0
  115. package/dist/chunk-RH4IUSTO.js.map +1 -0
  116. package/dist/chunk-S2GVXKKY.js +153 -0
  117. package/dist/chunk-S2GVXKKY.js.map +1 -0
  118. package/dist/chunk-TVHGKFJS.js +58 -0
  119. package/dist/chunk-TVHGKFJS.js.map +1 -0
  120. package/dist/chunk-U7GALWLG.js +66 -0
  121. package/dist/chunk-U7GALWLG.js.map +1 -0
  122. package/dist/chunk-VTC263LT.js +256 -0
  123. package/dist/chunk-VTC263LT.js.map +1 -0
  124. package/dist/chunk-WEOXFEVE.js +739 -0
  125. package/dist/chunk-WEOXFEVE.js.map +1 -0
  126. package/dist/chunk-WYHP46LT.js +28 -0
  127. package/dist/chunk-WYHP46LT.js.map +1 -0
  128. package/dist/chunk-XGLO5LGG.js +274 -0
  129. package/dist/chunk-XGLO5LGG.js.map +1 -0
  130. package/dist/chunk-XRBCRKA2.js +29 -0
  131. package/dist/chunk-XRBCRKA2.js.map +1 -0
  132. package/dist/chunk-XW7ADFFV.js +340 -0
  133. package/dist/chunk-XW7ADFFV.js.map +1 -0
  134. package/dist/chunk-YIQ42IUX.js +34 -0
  135. package/dist/chunk-YIQ42IUX.js.map +1 -0
  136. package/dist/chunk-YJZTUY7B.js +101 -0
  137. package/dist/chunk-YJZTUY7B.js.map +1 -0
  138. package/dist/chunk-Z3CXME3K.js +107 -0
  139. package/dist/chunk-Z3CXME3K.js.map +1 -0
  140. package/dist/classifier/index.js +2 -527
  141. package/dist/classifier/index.js.map +1 -1
  142. package/dist/client-JBPCXU7W.js +4 -0
  143. package/dist/client-JBPCXU7W.js.map +1 -0
  144. package/dist/compose.js +3 -367
  145. package/dist/compose.js.map +1 -1
  146. package/dist/cost-tracker-HHGQ4FTW.js +67 -0
  147. package/dist/cost-tracker-HHGQ4FTW.js.map +1 -0
  148. package/dist/{create-ui-generator-BrZHF0cf.d.ts → create-ui-generator-BY-WeaPi.d.ts} +3 -3
  149. package/dist/{design-mode-DGWN_Gkm.d.ts → design-mode-XyOQmzE0.d.ts} +4 -3
  150. package/dist/{dispatch-B07TI30d.d.ts → dispatch-8LzTddKw.d.ts} +3 -2
  151. package/dist/dist-UALN6ZVB.js +543 -0
  152. package/dist/dist-UALN6ZVB.js.map +1 -0
  153. package/dist/dupe-break-X6K7QFIS.js +4 -0
  154. package/dist/dupe-break-X6K7QFIS.js.map +1 -0
  155. package/dist/evaluation/axis-checks/index.d.ts +5 -4
  156. package/dist/evaluation/axis-checks/index.js +6 -1914
  157. package/dist/evaluation/axis-checks/index.js.map +1 -1
  158. package/dist/evaluation/axis-checks/registry.d.ts +3 -2
  159. package/dist/evaluation/axis-checks/registry.js +3 -1844
  160. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  161. package/dist/evaluation/index.d.ts +49 -132
  162. package/dist/evaluation/index.js +302 -6466
  163. package/dist/evaluation/index.js.map +1 -1
  164. package/dist/evaluation/types.d.ts +3 -2
  165. package/dist/evaluation/types.js +2 -5
  166. package/dist/evaluation/types.js.map +1 -1
  167. package/dist/fragments/index.js +2 -338
  168. package/dist/fragments/index.js.map +1 -1
  169. package/dist/{generate-tSO_shQ0.d.ts → generate-DQ4ci90s.d.ts} +1 -1
  170. package/dist/harness/check/runtime-render/index.d.ts +35 -15
  171. package/dist/harness/check/runtime-render/index.js +6 -2390
  172. package/dist/harness/check/runtime-render/index.js.map +1 -1
  173. package/dist/harness/check/runtime-render/render-check-worker.js +4 -1739
  174. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  175. package/dist/harness/index.d.ts +3 -2
  176. package/dist/harness/index.js +23 -5126
  177. package/dist/harness/index.js.map +1 -1
  178. package/dist/harness/llm-trace-sink.js +2 -24
  179. package/dist/harness/llm-trace-sink.js.map +1 -1
  180. package/dist/harness/prompts.d.ts +2 -2
  181. package/dist/harness/prompts.js +8 -324
  182. package/dist/harness/prompts.js.map +1 -1
  183. package/dist/harness/result-types.d.ts +6 -5
  184. package/dist/harness/result-types.js +2 -0
  185. package/dist/harness/result-types.js.map +1 -1
  186. package/dist/harness/runtime.d.ts +22 -5
  187. package/dist/harness/runtime.js +10 -2313
  188. package/dist/harness/runtime.js.map +1 -1
  189. package/dist/harness/types-public.d.ts +3 -2
  190. package/dist/harness/types-public.js +16 -2954
  191. package/dist/harness/types-public.js.map +1 -1
  192. package/dist/harness/validator-trace-sink.d.ts +3 -2
  193. package/dist/harness/validator-trace-sink.js +2 -26
  194. package/dist/harness/validator-trace-sink.js.map +1 -1
  195. package/dist/hash.js +2 -39
  196. package/dist/hash.js.map +1 -1
  197. package/dist/hashline-5OKREIRM.js +4 -0
  198. package/dist/hashline-5OKREIRM.js.map +1 -0
  199. package/dist/index.d.ts +50 -11
  200. package/dist/index.js +57 -22015
  201. package/dist/index.js.map +1 -1
  202. package/dist/llm-evaluator-PN3WFCS4.js +653 -0
  203. package/dist/llm-evaluator-PN3WFCS4.js.map +1 -0
  204. package/dist/magic-string.es-2NZTC4GZ.js +1307 -0
  205. package/dist/magic-string.es-2NZTC4GZ.js.map +1 -0
  206. package/dist/patch.d.ts +3 -2
  207. package/dist/patch.js +2 -56
  208. package/dist/patch.js.map +1 -1
  209. package/dist/policy-VCYKGSAC.js +5 -0
  210. package/dist/policy-VCYKGSAC.js.map +1 -0
  211. package/dist/policy.js +2 -35
  212. package/dist/policy.js.map +1 -1
  213. package/dist/primitive-index-2OF6F4TZ.js +131 -0
  214. package/dist/primitive-index-2OF6F4TZ.js.map +1 -0
  215. package/dist/primitive-slice-WTMBLYFJ.js +235 -0
  216. package/dist/primitive-slice-WTMBLYFJ.js.map +1 -0
  217. package/dist/{prompts-BhNUuRoZ.d.ts → prompts-CLZGywMP.d.ts} +1 -1
  218. package/dist/provider-adapter-contract.js +10407 -14204
  219. package/dist/provider-adapter-contract.js.map +1 -1
  220. package/dist/provider-adapter.js +2 -44
  221. package/dist/provider-adapter.js.map +1 -1
  222. package/dist/providers/index.d.ts +89 -1
  223. package/dist/providers/index.js +115 -44
  224. package/dist/providers/index.js.map +1 -1
  225. package/dist/raw-C6KBH5I3.js +141 -0
  226. package/dist/raw-C6KBH5I3.js.map +1 -0
  227. package/dist/raw-FXSBXQTZ.js +7 -0
  228. package/dist/raw-FXSBXQTZ.js.map +1 -0
  229. package/dist/raw-MURDUPGK.js +8 -0
  230. package/dist/raw-MURDUPGK.js.map +1 -0
  231. package/dist/raw-T3GW3UVX.js +8 -0
  232. package/dist/raw-T3GW3UVX.js.map +1 -0
  233. package/dist/render-check-host-ZNIS6BJ5.js +5 -0
  234. package/dist/render-check-host-ZNIS6BJ5.js.map +1 -0
  235. package/dist/sdk-4JNSDNSR.js +6 -0
  236. package/dist/sdk-4JNSDNSR.js.map +1 -0
  237. package/dist/sdk-DIVJMHCX.js +7 -0
  238. package/dist/sdk-DIVJMHCX.js.map +1 -0
  239. package/dist/sdk-QRJOCRTS.js +7 -0
  240. package/dist/sdk-QRJOCRTS.js.map +1 -0
  241. package/dist/tag-balance-CTHCTRJ7.js +123 -0
  242. package/dist/tag-balance-CTHCTRJ7.js.map +1 -0
  243. package/dist/tools/get-primitives-ts.d.ts +1 -1
  244. package/dist/tools/get-primitives-ts.js +3 -1
  245. package/dist/tools/get-primitives-ts.js.map +1 -1
  246. package/dist/tools/render-check-worker.js +3 -40
  247. package/dist/tools/render-check-worker.js.map +1 -1
  248. package/dist/{types-public-h8IMnYuh.d.ts → types-public-hYlrerZh.d.ts} +35 -2
  249. package/dist/validation/index.d.ts +2 -2
  250. package/dist/validation/index.js +3 -498
  251. package/dist/validation/index.js.map +1 -1
  252. package/dist/{visual-evaluator-C2strxqD.d.ts → visual-evaluator-KskajxD5.d.ts} +14 -4
  253. package/dist/visual-evaluator-TGEXYQTP.js +12 -0
  254. package/dist/visual-evaluator-TGEXYQTP.js.map +1 -0
  255. package/dist/workflows.d.ts +3 -2
  256. package/dist/workflows.js +2 -151
  257. package/dist/workflows.js.map +1 -1
  258. package/package.json +20 -16
  259. package/src/adapters/claude/sdk.ts +4 -2
  260. package/src/adapters/claude/tool-bridge.ts +2 -1
  261. package/src/adapters/generation-dispatch.ts +9 -0
  262. package/src/adapters/openai/raw.ts +4 -0
  263. package/src/adapters/openai/sdk.ts +6 -1
  264. package/src/adapters/openai/tokens.ts +21 -7
  265. package/src/adapters/tool-bridge.ts +1 -1
  266. package/src/adapters/tools.ts +1 -1
  267. package/src/advanced/feedback.ts +1 -2
  268. package/src/advanced/generator.ts +4 -3
  269. package/src/boilerplate/free-design-prompt.ts +7 -0
  270. package/src/boilerplate/generate.ts +18 -5
  271. package/src/boilerplate/hard-sections.ts +8 -3
  272. package/src/boilerplate/react-hooks.ts +56 -0
  273. package/src/boilerplate/system-prompt.ts +12 -3
  274. package/src/check/run-tier0.ts +233 -8
  275. package/src/check/type-checker.ts +128 -16
  276. package/src/coding-agent/tools.ts +26 -3
  277. package/src/design-mode.ts +4 -9
  278. package/src/evaluation/axis-checks/checks/state-merge.ts +2 -4
  279. package/src/evaluation/axis-checks/checks/state-payload.ts +1 -28
  280. package/src/evaluation/axis-checks/checks/tint.ts +197 -0
  281. package/src/evaluation/axis-checks/helpers.ts +121 -0
  282. package/src/evaluation/axis-checks/registry.ts +2 -0
  283. package/src/evaluation/criteria/__fixtures__/edge-v2-draw2.json +4629 -0
  284. package/src/evaluation/criteria/bank.ts +1 -1
  285. package/src/evaluation/criteria/edge-instruments.ts +14 -0
  286. package/src/evaluation/criteria/edge-probe-v2.ts +352 -0
  287. package/src/evaluation/criteria/edge-v2.ts +242 -0
  288. package/src/evaluation/criteria/resolve.ts +24 -5
  289. package/src/evaluation/index.ts +3 -5
  290. package/src/evaluation/mcp-server.ts +0 -50
  291. package/src/evaluation/types-public.ts +6 -1
  292. package/src/evaluation/visual-evaluator.ts +161 -30
  293. package/src/harness/check/runtime-render/index.ts +28 -4
  294. package/src/harness/check/runtime-render/render-check.ts +20 -21
  295. package/src/harness/claude-agent-sdk-loader.ts +93 -0
  296. package/src/harness/claude-code-login-query.ts +1 -1
  297. package/src/harness/coding/generate-task-runner.ts +1 -0
  298. package/src/harness/coding/run-coding-turn.ts +3 -0
  299. package/src/harness/coding/run-eval-round.ts +9 -0
  300. package/src/harness/llm-router.ts +50 -2
  301. package/src/harness/runtime.ts +17 -0
  302. package/src/index.ts +9 -0
  303. package/src/providers/anthropic-tool-call.ts +263 -0
  304. package/src/providers/index.ts +13 -0
  305. package/src/tools/get-primitives-ts.ts +1 -1
  306. package/src/tools/render-check.ts +4 -4
  307. package/src/validation/primitives.ts +2 -1
  308. package/src/evaluation/loop.ts +0 -218
@@ -10,7 +10,7 @@
10
10
  * never mid-run. Fields the loader does not read (`examples`, `fields`, `groups`, `seed`) are page
11
11
  * matter and are dropped here; an additive field tomorrow does not break today's loader.
12
12
  */
13
- import { z } from 'zod';
13
+ import * as z from 'zod';
14
14
 
15
15
  /** `family.property[.facet]` — `read.contrast`, `hello.field`. */
16
16
  const CRITERION_ID = /^[a-z][a-z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)+$/;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * #1765 — the identity of each edge instrument on a capture: its row id and the sha256 of the page expression it ran,
3
+ * so every reading names exactly which probe produced it (v1 is frozen at `cec465319`; v2 is a separate instrument).
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { EDGE_BLOCKS_EXPRESSION } from './edge-probe.js';
7
+ import { EDGE_BLOCKS_V2_EXPRESSION } from './edge-probe-v2.js';
8
+
9
+ const digest = (expression: string): string => createHash('sha256').update(expression).digest('hex').slice(0, 12);
10
+
11
+ export const EDGE_INSTRUMENT_IDS = {
12
+ v1: `space.edge@${digest(EDGE_BLOCKS_EXPRESSION)}`,
13
+ v2: `space.edge.v2@${digest(EDGE_BLOCKS_V2_EXPRESSION)}`,
14
+ } as const;
@@ -0,0 +1,352 @@
1
+ /**
2
+ * `space.edge.v2`'s measurement (#1765): `edge-probe.ts` (v1, frozen at `cec465319`) with what the v2 rule
3
+ * (`edge-v2.ts`) reads added, and nothing v1 measures changed. It runs where the screenshot is taken, on the same DOM at
4
+ * the same width, and only reads layout.
5
+ *
6
+ * Added to every block: `bottom`. Added to surfaces: their content box (`contentLeft` / `contentRight`, inside border
7
+ * and padding). Added to controls: `textEdge`, the first control's first character, so a control is compared by its
8
+ * content (rule 5). Added blocks: `media`, a painted image, video or icon that is not a text's marker and sits in no
9
+ * text, control or table, so a row led by an icon alone has a leader. Added to blocks in rows: `follows` and `item`, when
10
+ * the block sits in an item of a horizontal flex or grid row that is not the row's leading item on its line (rule 2).
11
+ * Added to the read: the card's own content box. Everything else is v1's, word for word.
12
+ */
13
+ import type { CardContentBox, EdgeBlockV2 } from './edge-v2.js';
14
+
15
+ /** What the probe returns: the measured blocks, and whether the frame showed nothing to evaluate. */
16
+ export interface EdgeProbeV2 {
17
+ readonly blocks: readonly EdgeBlockV2[];
18
+ readonly cardContent: CardContentBox;
19
+ readonly placeholdersOnly: boolean;
20
+ /** What the probe saw and did not measure, by rule, so a dropped block is counted rather than silent. */
21
+ readonly skipped: { readonly rowItems: number; readonly ornaments: number };
22
+ }
23
+
24
+ // Plain ES2020 for the page: no imports, no template literals, no helpers a bundler might rename.
25
+ export const EDGE_BLOCKS_V2_EXPRESSION = String.raw`(() => {
26
+ const root = document.getElementById('root');
27
+ if (!root) return null;
28
+ const rootBox = root.getBoundingClientRect();
29
+ const x0 = rootBox.left;
30
+ const px = (v) => Math.round((v - x0) * 2) / 2;
31
+ const CONTROL = 'button,input,select,textarea,[role="button"],[role="tab"],[role="switch"],[role="checkbox"],[role="radio"],[role="option"],[role="link"],a[href]';
32
+ const ALNUM = /[\p{L}\p{N}]/u;
33
+ const css = (el) => getComputedStyle(el);
34
+ const clear = (c) => !c || c === 'transparent' || /rgba\([^)]*,\s*0(\.0+)?\)$/.test(c);
35
+ // Visible through every ancestor (an ancestor at opacity 0 or visibility hidden hides it), and not degenerate.
36
+ const shown = (el) => {
37
+ const s = css(el);
38
+ if (s.display === 'none' || s.visibility === 'hidden' || Number(s.opacity) === 0) return false;
39
+ if (typeof el.checkVisibility === 'function' && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
40
+ const r = el.getBoundingClientRect();
41
+ return r.width > 1 && r.height > 1;
42
+ };
43
+ const inline = (s) => /^inline/.test(s.display) && s.display !== 'inline-block' && s.display !== 'inline-flex' && s.display !== 'inline-grid';
44
+ const groundOf = (el) => {
45
+ for (let p = el.parentElement; p; p = p.parentElement) { const b = css(p).backgroundColor; if (!clear(b)) return b; }
46
+ return 'rgb(255, 255, 255)';
47
+ };
48
+ const sides = (s) => ['Top', 'Right', 'Bottom', 'Left'].filter((k) => parseFloat(s['border' + k + 'Width']) > 0 && s['border' + k + 'Style'] !== 'none' && !clear(s['border' + k + 'Color'])).length;
49
+ const bounded = (el) => { const s = css(el); return sides(s) >= 1 || (!clear(s.backgroundColor) && s.backgroundColor !== groundOf(el)); };
50
+ // A control: a native or ARIA one, or a bounded box the visitor clicks (a pointer cursor or a tab stop) that holds no
51
+ // control of its own and is not the whole card. Its contents are not blocks of the column (decided 2026-10-02).
52
+ const isControl = (el) => {
53
+ const s = css(el);
54
+ if (inline(s)) return false;
55
+ if (el.matches(CONTROL)) return true;
56
+ if (el === root) return false;
57
+ const clickable = s.cursor === 'pointer' || (el.hasAttribute('tabindex') && el.tabIndex >= 0);
58
+ if (!clickable || !bounded(el) || el.querySelector(CONTROL)) return false;
59
+ const r = el.getBoundingClientRect();
60
+ return r.width * r.height < 0.6 * rootBox.width * rootBox.height;
61
+ };
62
+ const isTable = (el) => el.matches('table,[role="table"],[role="grid"]');
63
+ const isSurface = (el) => {
64
+ if (el === root || isControl(el)) return false;
65
+ const s = css(el);
66
+ if (/^inline/.test(s.display) || s.display === 'contents') return false;
67
+ if (isTable(el)) return true;
68
+ // v2 (the design lead, #1765): what paints owns its edge — any border side (a single rule counts), a fill, a shadow.
69
+ return sides(s) >= 1 || (!clear(s.backgroundColor) && s.backgroundColor !== groundOf(el)) || (s.boxShadow && s.boxShadow !== 'none');
70
+ };
71
+ const path = (el) => {
72
+ const parts = [];
73
+ // v2: the whole path from the root, so nested boxes with the same tail and text never share an id (v1 kept four
74
+ // steps, and three nested surfaces of one card read as one container).
75
+ for (let e = el; e && e !== root; e = e.parentElement) {
76
+ const i = e.parentElement ? Array.prototype.indexOf.call(e.parentElement.children, e) + 1 : 1;
77
+ parts.unshift(e.tagName.toLowerCase() + ':' + i);
78
+ }
79
+ return parts.join('>');
80
+ };
81
+ const label = (el) => { const t = (el.innerText || '').replace(/\s+/g, ' ').trim(); return path(el) + (t ? ' "' + t.slice(0, 24) + '"' : ''); };
82
+ const surfaces = [];
83
+ const surfaceOf = (el, self) => { for (let p = self ? el : el.parentElement; p && p !== root; p = p.parentElement) if (surfaces.indexOf(p) >= 0) return p; return null; };
84
+ const inTable = (el) => { for (let p = el.parentElement; p && p !== root; p = p.parentElement) if (isTable(p)) return true; return false; };
85
+ const inControl = (el) => { for (let p = el; p && p !== root; p = p.parentElement) if (isControl(p)) return p; return null; };
86
+ const all = Array.prototype.slice.call(root.querySelectorAll('*')).filter(shown);
87
+ for (const el of all) if (isSurface(el) && !inTable(el) && !inControl(el)) surfaces.push(el);
88
+ const ids = new Map();
89
+ const idOf = (el) => { if (!ids.has(el)) ids.set(el, label(el)); return ids.get(el); };
90
+ const blocks = [];
91
+ // Per block: the element it stands for and its first line's box, for rows.
92
+ const owners = [];
93
+ const lines = [];
94
+ // Per block: its raw line boxes and left, so a row reads the line two items share, not a wrapped item's widest line.
95
+ const rects = [];
96
+ const rawLeft = [];
97
+ const words = [];
98
+ const within = (el, self) => { const s = surfaceOf(el, self); return s ? { within: idOf(s) } : {}; };
99
+ for (const s of surfaces) {
100
+ const r = s.getBoundingClientRect();
101
+ owners.push(s); lines.push({ top: r.top, bottom: r.bottom }); rects.push([r]); rawLeft.push(r.left); words.push('');
102
+ // A placeholder: a box with no text, no control and no image or icon inside it (a loading bar). Not a block.
103
+ const holds = Array.prototype.some.call(s.querySelectorAll('*'), (el) => shown(el) && (el.matches(CONTROL) || el.tagName === 'IMG' || el.tagName.toLowerCase() === 'svg')) || ALNUM.test(s.innerText || '');
104
+ const ss = css(s);
105
+ const cl = r.left + parseFloat(ss.borderLeftWidth) + parseFloat(ss.paddingLeft);
106
+ const cr = r.right - parseFloat(ss.borderRightWidth) - parseFloat(ss.paddingRight);
107
+ blocks.push(Object.assign({ id: idOf(s), kind: 'surface', edge: px(r.left), left: px(r.left), right: px(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), contentLeft: px(cl), contentRight: px(cr) }, within(s, false), isTable(s) ? { role: 'table' } : holds ? {} : { role: 'placeholder' }));
108
+ }
109
+ const blockOf = (node) => { for (let p = node.parentElement; p && p !== root; p = p.parentElement) if (!inline(css(p))) return p; return root; };
110
+ const groups = new Map();
111
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
112
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
113
+ if (!/\S/.test(n.data) || !n.parentElement || n.parentElement.closest('style,script,noscript,template') || !shown(n.parentElement) || inControl(n.parentElement) || inTable(n.parentElement)) continue;
114
+ const b = blockOf(n);
115
+ if (!groups.has(b)) groups.set(b, []);
116
+ groups.get(b).push(n);
117
+ }
118
+ // Ink: text whose own colour is not transparent. The edge is the first visible character in ink (decided 2026-10-02):
119
+ // leading invisible text, private-use icon-font glyphs and formatting characters are not characters of the block,
120
+ // while a currency sign or a quotation mark is.
121
+ const inked = (n) => !clear(css(n.parentElement).color) && ALNUM.test(n.data);
122
+ const textRects = (nodes) => {
123
+ const rs = [];
124
+ for (const n of nodes) { if (!inked(n)) continue; const range = document.createRange(); range.selectNodeContents(n); for (const r of range.getClientRects()) if (r.width > 0 && r.height > 0) rs.push(r); }
125
+ return rs;
126
+ };
127
+ const glyphAt = (n, i) => { const range = document.createRange(); range.setStart(n, i); range.setEnd(n, i + 1); const r = range.getClientRects()[0]; return r || null; };
128
+ const firstGlyph = (nodes) => {
129
+ for (const n of nodes) { if (!inked(n)) continue; const i = n.data.search(/[^\s\p{Co}\p{Cf}]/u); if (i < 0) continue; const r = glyphAt(n, i); if (r) return r; }
130
+ return null;
131
+ };
132
+ // A marker paints: a loaded image, an SVG with a filled or stroked shape, or an element with its own fill, border
133
+ // or visible text. An element that paints nothing is not a marker.
134
+ const painted = (m) => {
135
+ if (!shown(m)) return false;
136
+ if (m.tagName === 'IMG') return m.complete && m.naturalWidth > 0;
137
+ if (m.tagName.toLowerCase() === 'svg' || m.closest('svg')) {
138
+ const shapes = m.querySelectorAll('path,circle,rect,line,polyline,polygon,ellipse,use,text');
139
+ return Array.prototype.some.call(shapes, (sh) => { const st = css(sh); return (st.fill !== 'none' && !clear(st.fill)) || (st.stroke !== 'none' && !clear(st.stroke)); });
140
+ }
141
+ const s = css(m);
142
+ if (!clear(s.backgroundColor) || sides(s) >= 1 || ((m.textContent || '').trim().length > 0 && !clear(s.color))) return true;
143
+ // A wrapper that paints nothing itself is a marker when an image or icon inside it paints.
144
+ return Array.prototype.some.call(m.querySelectorAll('svg,img'), (k) => painted(k));
145
+ };
146
+ const markerEls = [];
147
+ const markerBefore = (b, textLeft, lineTop, lineBottom) => {
148
+ const cands = [];
149
+ const consider = (m) => { if (!painted(m)) return; const r = m.getBoundingClientRect(); if (r.left < textLeft - 2 && r.bottom > lineTop && r.top < lineBottom) { cands.push(r.left); markerEls.push(m); } };
150
+ for (const m of b.querySelectorAll('svg,img,[aria-hidden="true"],i')) if ((m.textContent || '').trim().length <= 2) consider(m);
151
+ const p = b.parentElement;
152
+ if (p && p !== root && /flex/.test(css(p).display) && !/column/.test(css(p).flexDirection)) for (const sib of p.children) { if (sib === b) break; if (sib.matches('svg,img,i,[aria-hidden="true"]') || !(sib.textContent || '').trim()) consider(sib); }
153
+ return cands.length ? Math.min.apply(null, cands) : null;
154
+ };
155
+ let texts = 0;
156
+ let ornaments = 0;
157
+ for (const [b, nodes] of groups) {
158
+ if (!nodes.some((n) => ALNUM.test(n.data))) { ornaments += 1; continue; }
159
+ const rs = textRects(nodes);
160
+ const g = firstGlyph(nodes);
161
+ if (!rs.length || g === null) continue;
162
+ texts += 1;
163
+ const first = g.left;
164
+ const left = Math.min.apply(null, rs.map((r) => r.left));
165
+ const right = Math.max.apply(null, rs.map((r) => r.right));
166
+ const top = Math.min.apply(null, rs.map((r) => r.top));
167
+ const marker = markerBefore(b, first, g.top, g.bottom);
168
+ const s = css(b);
169
+ const box = b.getBoundingClientRect();
170
+ const parent = b.parentElement ? css(b.parentElement) : null;
171
+ const centredText = s.textAlign === 'center' && right - left < box.width * 0.9;
172
+ const centredBox = parent && /flex/.test(parent.display) && /column/.test(parent.flexDirection) && parent.alignItems === 'center';
173
+ const list = b.matches('li') || s.display === 'list-item' ? b.closest('ul,ol') : null;
174
+ const inProse = list && list.previousElementSibling && list.previousElementSibling.matches('p');
175
+ const bottom = Math.max.apply(null, rs.map((r) => r.bottom));
176
+ const block = { id: idOf(b), kind: 'text', edge: px(marker !== null ? marker : first), left: px(Math.min(left, marker !== null ? marker : left)), right: px(right), top: Math.round(top), bottom: Math.round(bottom) };
177
+ if (marker !== null) block.textEdge = px(first);
178
+ if (centredText || centredBox) block.role = 'centred';
179
+ else if (inProse) block.role = 'list-item';
180
+ owners.push(b); lines.push({ top: g.top, bottom: g.bottom }); rects.push(rs); rawLeft.push(Math.min(left, marker !== null ? marker : left)); words.push(nodes.map((n) => n.data).join('').trim());
181
+ blocks.push(Object.assign(block, within(b, true)));
182
+ }
183
+ const ctlGroups = new Map();
184
+ for (const el of all) { if (!isControl(el) || inTable(el) || (el.parentElement && inControl(el.parentElement))) continue; const p = el.parentElement || root; if (!ctlGroups.has(p)) ctlGroups.set(p, []); ctlGroups.get(p).push(el); }
185
+ let controls = 0;
186
+ for (const [p, els] of ctlGroups) {
187
+ const rs = els.map((e) => e.getBoundingClientRect());
188
+ const topRow = Math.min.apply(null, rs.map((r) => r.top));
189
+ let firstEl = null; let firstR = null;
190
+ els.forEach((e, i) => { const r = rs[i]; if (r.top - topRow < r.height / 2 && (!firstR || r.left < firstR.left)) { firstEl = e; firstR = r; } });
191
+ let edge = firstR.left;
192
+ const tw = document.createTreeWalker(firstEl, NodeFilter.SHOW_TEXT); const ns = []; for (let n = tw.nextNode(); n; n = tw.nextNode()) ns.push(n);
193
+ const cg = firstGlyph(ns);
194
+ if (!bounded(firstEl) && cg) edge = cg.left;
195
+ // v2: a control's edge is its first painted content — its first character or a painted icon inside its box,
196
+ // whichever is leftmost; an icon drawn over a native field counts. A field with neither reads its padding edge.
197
+ const fs = css(firstEl);
198
+ const fieldEdge = firstEl.matches('input,select,textarea') ? firstR.left + parseFloat(fs.borderLeftWidth) + parseFloat(fs.paddingLeft) : null;
199
+ const iconLefts = all.filter((k) => k.matches('svg,img,i,[aria-hidden="true"]') && !(k.parentElement && k.parentElement.closest('svg')) && painted(k)).map((k) => k.getBoundingClientRect()).filter((r) => r.left >= firstR.left - 1 && r.right <= firstR.right + 1 && r.top >= firstR.top - 1 && r.bottom <= firstR.bottom + 1).map((r) => r.left);
200
+ const iconLeft = iconLefts.length ? Math.min.apply(null, iconLefts) : null;
201
+ controls += 1;
202
+ const ps = css(p);
203
+ const centred = ps.justifyContent === 'center' || (ps.textAlign === 'center' && !/flex|grid/.test(ps.display));
204
+ const block = { id: idOf(p) + ' [controls]', kind: 'controls', edge: px(edge), left: px(Math.min.apply(null, rs.map((r) => r.left))), right: px(Math.max.apply(null, rs.map((r) => r.right))), top: Math.round(topRow), bottom: Math.round(Math.max.apply(null, rs.map((r) => r.bottom))) };
205
+ const firstPaint = [cg ? cg.left : null, iconLeft].filter((v) => v !== null);
206
+ if (firstPaint.length) block.textEdge = px(Math.min.apply(null, firstPaint));
207
+ else if (fieldEdge !== null) block.textEdge = px(fieldEdge);
208
+ if (centred) block.role = 'centred';
209
+ owners.push(firstEl); lines.push({ top: firstR.top, bottom: firstR.bottom }); rects.push(rs); rawLeft.push(Math.min.apply(null, rs.map((r) => r.left))); words.push('');
210
+ blocks.push(Object.assign(block, within(firstEl, true)));
211
+ }
212
+ // v2: media — a painted image, video or icon that is not a text's marker and sits in no text, control or table.
213
+ const textOwners = Array.from(groups.keys());
214
+ for (const el of all) {
215
+ if (!el.matches('img,video,picture,svg') || (el.parentElement && el.parentElement.closest('svg')) || !painted(el) || inControl(el) || inTable(el)) continue;
216
+ if (markerEls.some((m) => m === el || m.contains(el))) continue;
217
+ if (textOwners.some((t) => t.contains(el))) continue;
218
+ const r = el.getBoundingClientRect();
219
+ // An icon drawn inside a control's box (a search field's glass) is the control's adornment, not a block.
220
+ if (all.some((k) => isControl(k) && (() => { const kr = k.getBoundingClientRect(); return r.left >= kr.left - 1 && r.right <= kr.right + 1 && r.top >= kr.top - 1 && r.bottom <= kr.bottom + 1; })())) continue;
221
+ owners.push(el); lines.push({ top: r.top, bottom: r.bottom }); rects.push([r]); rawLeft.push(r.left); words.push('');
222
+ blocks.push(Object.assign({ id: idOf(el) + ' [media]', kind: 'media', edge: px(r.left), left: px(r.left), right: px(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom) }, within(el, false)));
223
+ }
224
+ // v2 rule 2, trailing: the right content edge of the box a block sits in (its nearest wider, non-inline ancestor
225
+ // inside its container), where a right-anchored item ends.
226
+ for (let i = 0; i < blocks.length; i += 1) {
227
+ const el = owners[i];
228
+ const w = el.getBoundingClientRect().width;
229
+ for (let p = el.parentElement; p && p !== root.parentElement; p = p.parentElement) {
230
+ const ps = css(p);
231
+ if (inline(ps) || ps.display === 'contents') continue;
232
+ const pr = p.getBoundingClientRect();
233
+ if (pr.width <= w + 2) continue;
234
+ blocks[i].parentRight = px(pr.right - parseFloat(ps.borderRightWidth) - parseFloat(ps.paddingRight));
235
+ break;
236
+ }
237
+ }
238
+ // v2 rule 2: a block in a row item that is not the row's leading item on its line follows that leader. A row is a
239
+ // horizontal flex or grid box; the leading item is the leftmost of the shown children that share the item's line.
240
+ const isRowBox = (el) => { const s = css(el); return (/flex/.test(s.display) && !/column/.test(s.flexDirection)) || /grid/.test(s.display); };
241
+ const vOverlap = (a, b) => Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1;
242
+ for (let i = 0; i < blocks.length; i += 1) {
243
+ const el = owners[i];
244
+ const c = (blocks[i].kind === 'surface' ? surfaceOf(el, false) : surfaceOf(el, true)) || root;
245
+ for (let e = el; e && e !== c && e !== root; e = e.parentElement) {
246
+ const p = e.parentElement;
247
+ if (!p) break;
248
+ if (isRowBox(p)) {
249
+ const er = e.getBoundingClientRect();
250
+ const line = Array.prototype.filter.call(p.children, (k) => shown(k) && vOverlap(k.getBoundingClientRect(), er));
251
+ if (line.length >= 2) {
252
+ const lead = line.reduce((a, k) => (k.getBoundingClientRect().left < a.getBoundingClientRect().left ? k : a));
253
+ // A leader that paints nothing (an empty icon slot) leads nothing: the item after it stands alone on its line.
254
+ const leads = painted(lead) || ALNUM.test(lead.innerText || '');
255
+ if (lead !== e && leads) { blocks[i].follows = label(lead); blocks[i].item = label(e); break; }
256
+ }
257
+ }
258
+ if (p === c) break;
259
+ }
260
+ }
261
+ // Rows (decided 2026-10-01, read by geometry 2026-10-02): items set on one line, however the CSS sets them. An item
262
+ // after another measured item on its line that holds one block (a text, a control, or a box holding at most one
263
+ // text) belongs to the row and is not measured; an item with a stack inside it is a column.
264
+ const dropped = new Set();
265
+ const sameLine = (a, b) => Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > Math.min(a.bottom - a.top, b.bottom - b.top) / 2;
266
+ const contains = (o, el) => o === el || o.contains(el);
267
+ const innerTexts = (i) => owners.map((el, k) => k).filter((k) => k !== i && blocks[k].kind !== 'surface' && contains(owners[i], owners[k]));
268
+ const isPill = (i) => blocks[i].kind === 'surface' && innerTexts(i).length <= 1 && !owners.some((el, k) => k !== i && blocks[k].kind === 'surface' && contains(owners[i], el));
269
+ const itemOf = (el, other) => { let e = el; while (e.parentElement && e.parentElement !== root && !e.parentElement.contains(other)) e = e.parentElement; return e; };
270
+ for (let i = 0; i < blocks.length; i += 1) {
271
+ if (blocks[i].kind === 'surface' && !isPill(i)) continue;
272
+ const before = blocks.findIndex((bj, j) => j !== i && bj.within === blocks[i].within && !(bj.kind === 'surface' && !isPill(j)) && !contains(owners[j], owners[i]) && !contains(owners[i], owners[j]) && rects[j].some((r) => sameLine(r, lines[i]) && r.right <= rawLeft[i] + 1));
273
+ if (before < 0) continue;
274
+ // A number or bullet in front of a text on its line is a hanging marker, not a row: the pair is one block with two
275
+ // edges, the marker's and the text's.
276
+ if (blocks[i].kind === 'text' && blocks[before].kind === 'text' && /^[\p{N}\p{P}\p{S}]{1,3}$/u.test(words[before])) {
277
+ blocks[i] = Object.assign({}, blocks[i], { edge: blocks[before].edge, left: Math.min(blocks[i].left, blocks[before].left), textEdge: blocks[i].edge });
278
+ delete blocks[i].role;
279
+ // v2: the merged block's edge is its marker's, which leads the row, so it stands in its container's column.
280
+ delete blocks[i].follows;
281
+ delete blocks[i].item;
282
+ dropped.add(before);
283
+ continue;
284
+ }
285
+ const item = itemOf(owners[i], owners[before]);
286
+ const inside = owners.map((el, k) => k).filter((k) => contains(item, owners[k]) && blocks[k].kind !== 'surface');
287
+ const boxes = owners.map((el, k) => k).filter((k) => contains(item, owners[k]) && blocks[k].kind === 'surface' && k !== i);
288
+ if (blocks[i].kind === 'surface') { if (boxes.length === 0) { dropped.add(i); for (const k of innerTexts(i)) dropped.add(k); } continue; }
289
+ if (inside.length === 1 && boxes.length === 0) dropped.add(i);
290
+ }
291
+ const kept = blocks.filter((_, i) => !dropped.has(i));
292
+ // Placeholders: a frame with no text in ink shows nothing to evaluate, whatever controls or boxes it draws.
293
+ const rs0 = css(root);
294
+ const cardContent = { left: px(rootBox.left + parseFloat(rs0.borderLeftWidth) + parseFloat(rs0.paddingLeft)), right: px(rootBox.right - parseFloat(rs0.borderRightWidth) - parseFloat(rs0.paddingRight)) };
295
+ return { blocks: kept, cardContent, placeholdersOnly: texts === 0, skipped: { rowItems: dropped.size, ornaments } };
296
+ })()`;
297
+
298
+ const KINDS: readonly EdgeBlockV2['kind'][] = ['text', 'controls', 'surface', 'media'];
299
+ const ROLES: readonly NonNullable<EdgeBlockV2['role']>[] = ['list-item', 'table', 'centred', 'placeholder'];
300
+ const isKind = (v: unknown): v is EdgeBlockV2['kind'] => KINDS.some((k) => k === v);
301
+ const isRole = (v: unknown): v is NonNullable<EdgeBlockV2['role']> => ROLES.some((r) => r === v);
302
+ const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
303
+ const field = (o: object, key: string): unknown => Reflect.get(o, key);
304
+ const optNum = (v: unknown): boolean => v === undefined || num(v);
305
+ const optStr = (v: unknown): boolean => v === undefined || typeof v === 'string';
306
+
307
+ /**
308
+ * The v2 probe's typed read: `null` when the page had no `#root` or returned something else, so a failed measurement
309
+ * is reported as unmeasured and never read as "no offsets". A malformed block fails the whole read for the same reason.
310
+ */
311
+ export function parseEdgeProbeV2(value: unknown): EdgeProbeV2 | null {
312
+ if (typeof value !== 'object' || value === null) return null;
313
+ const raw = field(value, 'blocks');
314
+ const placeholdersOnly = field(value, 'placeholdersOnly');
315
+ const cc = field(value, 'cardContent');
316
+ if (!Array.isArray(raw) || typeof placeholdersOnly !== 'boolean' || typeof cc !== 'object' || cc === null) return null;
317
+ const ccLeft = field(cc, 'left');
318
+ const ccRight = field(cc, 'right');
319
+ if (!num(ccLeft) || !num(ccRight)) return null;
320
+ const skippedRaw = field(value, 'skipped');
321
+ if (typeof skippedRaw !== 'object' || skippedRaw === null) return null;
322
+ const rowItems = field(skippedRaw, 'rowItems');
323
+ const ornaments = field(skippedRaw, 'ornaments');
324
+ if (!num(rowItems) || !num(ornaments)) return null;
325
+ const blocks: EdgeBlockV2[] = [];
326
+ for (const entry of raw) {
327
+ if (typeof entry !== 'object' || entry === null) return null;
328
+ const [id, kind, edge, left, right, top, bottom] = ['id', 'kind', 'edge', 'left', 'right', 'top', 'bottom'].map((k) => field(entry, k));
329
+ const [within, textEdge, role, contentLeft, contentRight, follows, item, parentRight] = ['within', 'textEdge', 'role', 'contentLeft', 'contentRight', 'follows', 'item', 'parentRight'].map((k) => field(entry, k));
330
+ if (typeof id !== 'string' || !isKind(kind) || !num(edge) || !num(left) || !num(right) || !num(top) || !num(bottom)) return null;
331
+ if (!optStr(within) || !optNum(textEdge) || !optNum(contentLeft) || !optNum(contentRight) || !optStr(follows) || !optStr(item) || !optNum(parentRight)) return null;
332
+ if (role !== undefined && !isRole(role)) return null;
333
+ blocks.push({
334
+ id,
335
+ kind,
336
+ edge,
337
+ left,
338
+ right,
339
+ top,
340
+ bottom,
341
+ ...(typeof within === 'string' ? { within } : {}),
342
+ ...(num(textEdge) ? { textEdge } : {}),
343
+ ...(role !== undefined ? { role } : {}),
344
+ ...(num(contentLeft) ? { contentLeft } : {}),
345
+ ...(num(contentRight) ? { contentRight } : {}),
346
+ ...(typeof follows === 'string' ? { follows } : {}),
347
+ ...(typeof item === 'string' ? { item } : {}),
348
+ ...(num(parentRight) ? { parentRight } : {}),
349
+ });
350
+ }
351
+ return { blocks, cardContent: { left: ccLeft, right: ccRight }, placeholdersOnly, skipped: { rowItems, ornaments } };
352
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * `space.edge.v2` (#1765): the edge rule as the design lead rewrote it, as a SEPARATE instrument beside the frozen v1
3
+ * (`edge.ts` / `edge-probe.ts`, validated at `cec465319`). v1 is untouched and keeps reporting; both verdicts go on
4
+ * every capture until v2 has its own fresh sealed draw.
5
+ *
6
+ * The rule (the design lead, #1765): in each column of a card the things that stack start on one content edge.
7
+ * 1. One-step nesting is legal only inside a visible surface (its own fill or a hairline): a surface's content may sit
8
+ * on the surface's content edge. An unboxed wrapper is not a surface; its content is compared at its container.
9
+ * 2. In a row, the leading item sits on the content edge; every item after it in the same row is measured from the
10
+ * item before it, not from the card. A trailing item is legal when its right edge sits on its container's right
11
+ * content edge (within 2 px) or when the row has a leading item on the edge. A block alone on its line whose right
12
+ * edge sits mid-card is a split.
13
+ * 3. Grid columns (items side by side in a row) each have their own edge: they are rows' later items (rule 2).
14
+ * 4. A media column may centre its image.
15
+ * 5. Content to content: a control is compared by its content's edge (its first character), never its border box.
16
+ *
17
+ * Input is the measured block list from `edge-probe-v2.ts`; this module measures nothing itself.
18
+ */
19
+ import { EDGE_TOLERANCE_PX, type EdgeBlock, type EdgeOffset, type EdgeVerdict } from './edge.js';
20
+
21
+ /** One block of a captured card, as the v2 probe measures it: v1's fields, plus what the v2 rule reads. */
22
+ export interface EdgeBlockV2 extends Omit<EdgeBlock, 'kind'> {
23
+ /** v1's kinds, plus `media`: a painted image, video or icon that is not a text's marker. */
24
+ readonly kind: EdgeBlock['kind'] | 'media';
25
+ /** The block's bottom, CSS px from the card's top, for rows. */
26
+ readonly bottom: number;
27
+ /** A surface's content box (inside its border and padding): where its content may sit, and where a trailing item may end. */
28
+ readonly contentLeft?: number;
29
+ readonly contentRight?: number;
30
+ /**
31
+ * Rule 2: the block sits in a row item that is not the row's leading item. The value names the row's leading item, so
32
+ * the block is measured with the other blocks of its own item, never against its container's column.
33
+ */
34
+ readonly follows?: string;
35
+ /** A row item's own id, so blocks that follow the same leader in the same item are grouped together. */
36
+ readonly item?: string;
37
+ /** Rule 2, trailing: the right content edge of the box the block sits in, where a right-anchored item ends. */
38
+ readonly parentRight?: number;
39
+ }
40
+
41
+ /** The card's own content box, from the probe: where the card's content edge is and where a trailing item may end. */
42
+ export interface CardContentBox {
43
+ readonly left: number;
44
+ readonly right: number;
45
+ }
46
+
47
+ export { EDGE_TOLERANCE_PX };
48
+ export type { EdgeOffset, EdgeVerdict };
49
+
50
+ // ── v1's column logic, copied verbatim from `edge.ts` at `cec465319` (that file stays frozen), typed for v2 blocks ──
51
+ const CARD = 'card';
52
+ const same = (a: number, b: number): boolean => Math.abs(a - b) <= EDGE_TOLERANCE_PX;
53
+ const overlaps = (a: EdgeBlockV2, b: EdgeBlockV2): boolean => a.left < b.right && b.left < a.right;
54
+ const axis = (b: EdgeBlockV2): number => (b.left + b.right) / 2;
55
+
56
+ /** A surface's content edge: where its first text starts (its topmost text block's edge), or undefined if it has none. */
57
+ function contentEdge(surface: EdgeBlockV2, blocks: readonly EdgeBlockV2[]): number | undefined {
58
+ const texts = blocks.filter((b) => b.within === surface.id && b.kind !== 'surface' && b.role !== 'centred');
59
+ if (texts.length === 0) return undefined;
60
+ const first = texts.reduce((a, b) => (b.top < a.top ? b : a));
61
+ return first.textEdge ?? first.edge;
62
+ }
63
+
64
+ /** Columns among a container's direct blocks: non-surface blocks that overlap horizontally, transitively. */
65
+ function columns(direct: readonly EdgeBlockV2[]): EdgeBlockV2[][] {
66
+ const all = direct.filter((b) => b.kind !== 'surface');
67
+ // A block that spans two columns side by side (it overlaps two blocks that do not overlap each other) belongs to
68
+ // their parent column, never to either (decided 2026-10-02): it must not chain them into one.
69
+ const bridges = (b: EdgeBlockV2): boolean => {
70
+ const under = all.filter((x) => x !== b && overlaps(x, b));
71
+ return under.some((x, i) => under.slice(i + 1).some((y) => !overlaps(x, y)));
72
+ };
73
+ const flow = all.filter((b) => !bridges(b));
74
+ const parentPool: EdgeBlockV2[] = all.filter((b) => bridges(b));
75
+ const parent = flow.map((_, i) => i);
76
+ const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]!)));
77
+ for (let i = 0; i < flow.length; i += 1) for (let j = i + 1; j < flow.length; j += 1) if (overlaps(flow[i]!, flow[j]!)) parent[find(i)] = find(j);
78
+ const groups = new Map<number, EdgeBlockV2[]>();
79
+ flow.forEach((b, i) => groups.set(find(i), [...(groups.get(find(i)) ?? []), b]));
80
+ const cols = [...groups.values()];
81
+ // A surface joins the one column whose text or controls it overlaps. One that overlaps two or more columns spans
82
+ // them and goes to their parent, like a spanning text block; one that overlaps none stands with the other
83
+ // unclaimed blocks. Only flow blocks decide, so a surface never chains another surface into a column.
84
+ for (const s of direct.filter((b) => b.kind === 'surface')) {
85
+ const hit = cols.filter((c) => c.some((b) => b.kind !== 'surface' && overlaps(b, s)));
86
+ if (hit.length === 1) hit[0]!.push(s);
87
+ else parentPool.push(s);
88
+ }
89
+ // The parent's blocks and the unclaimed surfaces form columns among themselves: blocks that overlap stack.
90
+ const lp = parentPool.map((_, i) => i);
91
+ const lfind = (i: number): number => (lp[i] === i ? i : (lp[i] = lfind(lp[i]!)));
92
+ for (let i = 0; i < parentPool.length; i += 1) for (let j = i + 1; j < parentPool.length; j += 1) if (overlaps(parentPool[i]!, parentPool[j]!)) lp[lfind(i)] = lfind(j);
93
+ const lgroups = new Map<number, EdgeBlockV2[]>();
94
+ parentPool.forEach((b, i) => lgroups.set(lfind(i), [...(lgroups.get(lfind(i)) ?? []), b]));
95
+ cols.push(...lgroups.values());
96
+ return cols;
97
+ }
98
+
99
+ /** One column's check: one edge, or a stacked surface's content edge (the surface is then a ground). */
100
+ function checkColumn(container: string, col: readonly EdgeBlockV2[], blocks: readonly EdgeBlockV2[]): EdgeOffset[] {
101
+ const surfaces = col.filter((b) => b.kind === 'surface');
102
+ const grounds = surfaces.flatMap((s) => {
103
+ const c = s.role === 'table' ? undefined : contentEdge(s, blocks);
104
+ return c === undefined ? [] : [c];
105
+ });
106
+ const flow = col.filter((b) => b.kind !== 'surface' && b.role !== 'centred' && b.role !== 'list-item');
107
+ const listItems = col.filter((b) => b.role === 'list-item');
108
+ // Every edge a flow block or a surface offers; the common edge E must sit on one of them.
109
+ const candidates = [...flow.flatMap((b) => [b.edge, ...(b.textEdge !== undefined ? [b.textEdge] : [])]), ...surfaces.flatMap((s) => [s.edge]), ...grounds];
110
+ const blockEdges = (b: EdgeBlockV2): number[] => [b.edge, ...(b.textEdge !== undefined ? [b.textEdge] : [])];
111
+ // The end edges another block of the column offers: its boxes' and its other control sets'.
112
+ const endEdgesBesides = (b: EdgeBlockV2): number[] => [...surfaces.map((s) => s.right), ...flow.filter((x) => x !== b && x.kind === 'controls').map((x) => x.right)];
113
+ // A column of only list items or only a centred group has no common edge to find; their own checks below still run.
114
+ let best: EdgeOffset[] | null = candidates.length === 0 ? [] : null;
115
+ for (const E of candidates) {
116
+ const allowed = [E, ...grounds];
117
+ const off: EdgeOffset[] = [];
118
+ for (const b of flow) {
119
+ if (blockEdges(b).some((x) => allowed.some((a) => same(x, a)))) continue;
120
+ // A control set alone on its line may END on the end edge its column's boxes or other controls share.
121
+ if (b.kind === 'controls' && endEdgesBesides(b).some((r) => same(b.right, r))) continue;
122
+ const nearest = allowed.reduce((n, a) => (Math.abs(a - b.edge) < Math.abs(n - b.edge) ? a : n));
123
+ off.push({ container, block: b.id, at: b.edge, nearest, offPx: Math.abs(b.edge - nearest) });
124
+ }
125
+ for (const s of surfaces) {
126
+ const c = s.role === 'table' ? undefined : contentEdge(s, blocks);
127
+ if (same(s.edge, E) || (c !== undefined && same(c, E))) continue;
128
+ const nearest = c !== undefined && Math.abs(c - E) < Math.abs(s.edge - E) ? c : s.edge;
129
+ off.push({ container, block: s.id, at: nearest, nearest: E, offPx: Math.abs(nearest - E) });
130
+ }
131
+ if (best === null || off.length < best.length || (off.length === best.length && sum(off) < sum(best))) best = off;
132
+ if (best.length === 0) break;
133
+ }
134
+ const offsets = best ?? [];
135
+ // A list inside running text: its items share an edge of their own.
136
+ if (listItems.length > 1) {
137
+ const e0 = listItems[0]!.edge;
138
+ for (const li of listItems.slice(1)) if (!same(li.edge, e0)) offsets.push({ container, block: li.id, at: li.edge, nearest: e0, offPx: Math.abs(li.edge - e0) });
139
+ }
140
+ // A centred group: its members share one centre axis, and it must be the whole content of its column. A column is
141
+ // edge-aligned or centred, never both (decided 2026-10-02).
142
+ const centred = col.filter((b) => b.role === 'centred');
143
+ // A centred group of two or more may follow a label or heading when nothing edge-aligned follows it (an empty state
144
+ // under its section label). One centred block among edge-aligned ones, or a column that returns to an edge after
145
+ // the group, fails (decided 2026-10-02).
146
+ const edgeAligned = col.filter((b) => b.role !== 'centred');
147
+ const groupTop = centred.length > 0 ? Math.min(...centred.map((c) => c.top)) : 0;
148
+ const groupEndsColumn = centred.length > 1 && edgeAligned.every((b) => b.top < groupTop);
149
+ if (centred.length > 0 && edgeAligned.some((b) => b.kind !== 'surface') && !groupEndsColumn) {
150
+ const E = flow[0]?.edge ?? edgeAligned[0]!.edge;
151
+ for (const c of centred) offsets.push({ container, block: c.id, at: c.edge, nearest: E, offPx: Math.abs(c.edge - E), reason: 'a centred block in an edge-aligned column' });
152
+ } else if (centred.length > 1) {
153
+ const a0 = axis(centred[0]!);
154
+ for (const c of centred.slice(1)) if (!same(axis(c), a0)) offsets.push({ container, block: c.id, at: axis(c), nearest: a0, offPx: Math.abs(axis(c) - a0) });
155
+ }
156
+ return offsets;
157
+ }
158
+ const sum = (o: readonly EdgeOffset[]): number => o.reduce((s, x) => s + x.offPx, 0);
159
+
160
+ // ── v2 ──
161
+
162
+ /** Rule 2: a block in a row item after the row's leader is measured with its own item, never against its container. */
163
+ const follower = (b: EdgeBlockV2): boolean => b.follows !== undefined;
164
+ const vOverlap = (a: EdgeBlockV2, b: EdgeBlockV2): boolean => Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1;
165
+
166
+ /**
167
+ * Judge one captured width under the v2 rule. `cardContent` is the card's own content box from the probe;
168
+ * `placeholdersOnly`: the frame shows only loading placeholders, so the row is not evaluated.
169
+ */
170
+ export function judgeEdgesV2(
171
+ measured: readonly EdgeBlockV2[],
172
+ opts: { readonly cardContent: CardContentBox; readonly placeholdersOnly?: boolean },
173
+ ): EdgeVerdict {
174
+ if (opts.placeholdersOnly === true) return { verdict: 'n/a', offsets: [], evidence: 'only loading placeholders are shown; not evaluated' };
175
+ const content = measured.filter((b) => b.role !== 'placeholder' && (b.kind === 'text' || b.kind === 'controls')).length;
176
+ if (content < 2) return { verdict: 'n/a', offsets: [], evidence: measured.length === 0 ? 'no blocks were measured' : 'fewer than two blocks once placeholders are set aside; not evaluated' };
177
+ const byId = new Map(measured.map((b) => [b.id, b]));
178
+ const containerOf = (b: EdgeBlockV2): string => b.within ?? CARD;
179
+ const contentBoxOf = (container: string): CardContentBox | undefined => {
180
+ if (container === CARD) return opts.cardContent;
181
+ const s = byId.get(container);
182
+ return s?.contentLeft !== undefined && s.contentRight !== undefined ? { left: s.contentLeft, right: s.contentRight } : undefined;
183
+ };
184
+ // A placeholder leads its row when a block of its container follows it on its line; otherwise it is not a block (v1).
185
+ const leadsARow = (p: EdgeBlockV2): boolean => measured.some((b) => b !== p && containerOf(b) === containerOf(p) && follower(b) && b.left >= p.right - 1 && vOverlap(b, p));
186
+ // Rule 4 (re-registered): media is a painted image, or a painted box with no control inside, that is the only block
187
+ // on its line in its container; it may centre in its column, and its own content may centre inside it.
188
+ const holdsControl = (s: EdgeBlockV2): boolean => {
189
+ const inside = (b: EdgeBlockV2): boolean => { for (let w = b.within; w !== undefined; w = byId.get(w)?.within) if (w === s.id) return true; return false; };
190
+ return measured.some((b) => b.kind === 'controls' && inside(b));
191
+ };
192
+ const aloneOnItsLine = (m: EdgeBlockV2): boolean =>
193
+ !measured.some((b) => b !== m && containerOf(b) === containerOf(m) && b.role !== 'placeholder' && vOverlap(b, m));
194
+ // Centred, and narrower than its column: a box that starts on its container's content edge is full width, not centred.
195
+ const centredInContainer = (m: EdgeBlockV2): boolean => {
196
+ const box = contentBoxOf(containerOf(m));
197
+ return box !== undefined && same((m.left + m.right) / 2, (box.left + box.right) / 2) && !same(m.left, box.left);
198
+ };
199
+ const mediaKind = (b: EdgeBlockV2): boolean => b.kind === 'media' || (b.kind === 'surface' && b.role !== 'table' && !holdsControl(b));
200
+ const blocks = measured.filter((b) => {
201
+ if (b.role === 'placeholder') return leadsARow(b);
202
+ if (b.kind === 'media' && centredInContainer(b)) return false;
203
+ return true;
204
+ });
205
+
206
+ // A surface that bleeds to the card's own edge (x0, full width): its outer edge is the card's, not a content edge,
207
+ // so it is compared by its content edge alone (the design lead, c.6263664713: the root's content edge against every
208
+ // bleeding surface's content).
209
+ const bleeds = (b: EdgeBlockV2): boolean => b.kind === 'surface' && containerOf(b) === CARD && b.left <= EDGE_TOLERANCE_PX && b.right >= opts.cardContent.right - EDGE_TOLERANCE_PX;
210
+ const judged = blocks.map((b) => (bleeds(b) && b.contentLeft !== undefined ? { ...b, edge: b.contentLeft } : b));
211
+ // Rule 2, trailing: a block alone on its line that ends on its container's right content edge is right-anchored.
212
+ // A trailing item trails: it starts in the right half of its container's content box, and is never a bleed.
213
+ const rightAnchored = (b: EdgeBlockV2): boolean => {
214
+ const box = contentBoxOf(containerOf(b));
215
+ const ends = (r: number | undefined): boolean => r !== undefined && same(b.right, r);
216
+ return (ends(box?.right) || ends(b.parentRight)) && box !== undefined && b.left >= (box.left + box.right) / 2 && !bleeds(b);
217
+ };
218
+ const offsets: EdgeOffset[] = [];
219
+ const containers = [CARD, ...judged.filter((b) => b.kind === 'surface' && b.role !== 'table').map((b) => b.id)];
220
+ for (const container of containers) {
221
+ const inContainer = judged.filter((b) => containerOf(b) === container);
222
+ // The container's own column: its blocks that lead (or are not in) a row, less right-anchored trailing items.
223
+ const direct = inContainer.filter((b) => !follower(b) && !rightAnchored(b));
224
+ for (const col of columns(direct)) {
225
+ // Rule 4: media alone in its column, centred in its container, may centre (the design lead, c.6282971546). A
226
+ // lone block of any kind passes its own column; this only exempts the media from its NEIGHBOURS' column, which
227
+ // is how a centred cover beside or above edge-aligned text reaches a column at all.
228
+ const only = col.length === 1 ? col[0]! : undefined;
229
+ if (only !== undefined && mediaKind(only) && aloneOnItsLine(only) && centredInContainer(only)) continue;
230
+ offsets.push(...checkColumn(container, col, judged));
231
+ }
232
+ // Each row item after a leader is its own column group, measured among its own blocks.
233
+ const items = new Map<string, EdgeBlockV2[]>();
234
+ for (const b of inContainer.filter(follower)) items.set(b.item ?? b.id, [...(items.get(b.item ?? b.id) ?? []), b]);
235
+ for (const [item, group] of items) for (const col of columns(group)) offsets.push(...checkColumn(`${container} › ${item}`, col, judged));
236
+ }
237
+ if (offsets.length === 0) return { verdict: 'pass', offsets, evidence: 'every column shares its content edge, every row item after its leader shares its own, within 2 px' };
238
+ const worst = offsets.reduce((a, b) => (b.offPx > a.offPx ? b : a));
239
+ const lines = offsets.map((o) => `${o.block} at x${round(o.at)} is ${round(o.offPx)} px from x${round(o.nearest)} (in ${o.container}${o.reason !== undefined ? `; ${o.reason}` : ''})`);
240
+ return { verdict: 'fail', offsets, evidence: `${offsets.length} block(s) off the allowed edges, largest ${round(worst.offPx)} px: ${lines.join('; ')}` };
241
+ }
242
+ const round = (n: number): string => (Math.round(n * 10) / 10).toString();