@ggui-ai/ui-gen 0.25.0 → 0.26.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 (106) hide show
  1. package/README.md +2 -2
  2. package/dist/{adapter-Cg_rkpGq.d.ts → adapter-BJwn74rx.d.ts} +49 -1
  3. package/dist/adapters/generation-dispatch.d.ts +8 -5
  4. package/dist/adapters/generation-dispatch.js +1118 -193
  5. package/dist/adapters/generation-dispatch.js.map +1 -1
  6. package/dist/adapters/index.js +27 -6
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/advanced/index.d.ts +10 -9
  9. package/dist/advanced/index.js +1126 -202
  10. package/dist/advanced/index.js.map +1 -1
  11. package/dist/blueprint-validator.js +416 -92
  12. package/dist/blueprint-validator.js.map +1 -1
  13. package/dist/boilerplate.d.ts +2 -2
  14. package/dist/boilerplate.js +10 -3
  15. package/dist/boilerplate.js.map +1 -1
  16. package/dist/check/index.d.ts +3 -2
  17. package/dist/check/index.js +9 -2
  18. package/dist/check/index.js.map +1 -1
  19. package/dist/{create-ui-generator-DFnnArrv.d.ts → create-ui-generator-N4gRXpcL.d.ts} +3 -4
  20. package/dist/{design-mode-DLufhrNp.d.ts → design-mode-DGWN_Gkm.d.ts} +1 -1
  21. package/dist/{dispatch-CStl9eAw.d.ts → dispatch-BUoXRj_r.d.ts} +2 -2
  22. package/dist/evaluation/axis-checks/index.d.ts +5 -4
  23. package/dist/evaluation/axis-checks/index.js.map +1 -1
  24. package/dist/evaluation/axis-checks/registry.d.ts +4 -3
  25. package/dist/evaluation/index.d.ts +107 -539
  26. package/dist/evaluation/index.js +552 -55
  27. package/dist/evaluation/index.js.map +1 -1
  28. package/dist/evaluation/types.d.ts +17 -0
  29. package/dist/evaluation/types.js.map +1 -1
  30. package/dist/{generate-9IeNT4ZY.d.ts → generate-tSO_shQ0.d.ts} +1 -1
  31. package/dist/harness/check/runtime-render/index.d.ts +5 -5
  32. package/dist/harness/check/runtime-render/index.js +416 -92
  33. package/dist/harness/check/runtime-render/index.js.map +1 -1
  34. package/dist/harness/check/runtime-render/render-check-worker.js +393 -88
  35. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  36. package/dist/harness/index.d.ts +4 -5
  37. package/dist/harness/index.js +36 -5
  38. package/dist/harness/index.js.map +1 -1
  39. package/dist/harness/prompts.d.ts +2 -2
  40. package/dist/harness/prompts.js +10 -1
  41. package/dist/harness/prompts.js.map +1 -1
  42. package/dist/harness/result-types.d.ts +17 -3
  43. package/dist/harness/result-types.js.map +1 -1
  44. package/dist/harness/runtime.d.ts +14 -6
  45. package/dist/harness/runtime.js +11 -4
  46. package/dist/harness/runtime.js.map +1 -1
  47. package/dist/harness/types-public.d.ts +4 -2
  48. package/dist/harness/types-public.js +10 -3
  49. package/dist/harness/types-public.js.map +1 -1
  50. package/dist/harness/validator-trace-sink.d.ts +4 -3
  51. package/dist/index.d.ts +46 -11
  52. package/dist/index.js +1128 -200
  53. package/dist/index.js.map +1 -1
  54. package/dist/patch.d.ts +3 -3
  55. package/dist/{prompts-YU9piOgs.d.ts → prompts-BhNUuRoZ.d.ts} +1 -1
  56. package/dist/tools/render-check-worker.js +8 -1
  57. package/dist/tools/render-check-worker.js.map +1 -1
  58. package/dist/{types-public-BJTLpzWn.d.ts → types-public-B7wn4ce5.d.ts} +384 -3
  59. package/dist/validation/index.d.ts +1 -1
  60. package/dist/validation/index.js +1 -1
  61. package/dist/validation/index.js.map +1 -1
  62. package/dist/visual-evaluator-C9L-o6ap.d.ts +639 -0
  63. package/dist/workflows.d.ts +3 -3
  64. package/package.json +12 -12
  65. package/src/adapters/generation-dispatch.ts +2 -0
  66. package/src/boilerplate/free-design-prompt.ts +3 -3
  67. package/src/boilerplate/hard-sections.ts +1 -1
  68. package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +3 -1
  69. package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +3 -1
  70. package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +3 -1
  71. package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +3 -1
  72. package/src/check/run-tier0.ts +6 -3
  73. package/src/contract-context.ts +1 -1
  74. package/src/evaluation/cost-tracker.ts +54 -6
  75. package/src/evaluation/criteria/edge-probe.ts +286 -0
  76. package/src/evaluation/criteria/edge.ts +202 -0
  77. package/src/evaluation/criteria/resolve.ts +55 -7
  78. package/src/evaluation/index.ts +5 -1
  79. package/src/evaluation/judge-spend.ts +48 -0
  80. package/src/evaluation/types-public.ts +91 -0
  81. package/src/evaluation/types.ts +10 -0
  82. package/src/evaluation/visual-evaluator.ts +236 -40
  83. package/src/harness/check/runtime-render/adapter.ts +18 -0
  84. package/src/harness/check/runtime-render/probe-pending.ts +111 -0
  85. package/src/harness/check/runtime-render/render-check-host.ts +10 -6
  86. package/src/harness/check/runtime-render/render-check-worker.ts +6 -3
  87. package/src/harness/check/runtime-render/render-check.ts +417 -133
  88. package/src/harness/check/runtime-render/walk.ts +200 -0
  89. package/src/harness/coding/assemble-result.ts +2 -0
  90. package/src/harness/coding/generate-task-runner.ts +27 -0
  91. package/src/harness/coding/init-session.ts +5 -3
  92. package/src/harness/coding/run-coding-turn.ts +5 -1
  93. package/src/harness/coding/run-eval-round.ts +63 -3
  94. package/src/harness/in-loop-evaluation.ts +39 -0
  95. package/src/harness/llm-router.ts +58 -1
  96. package/src/harness/result-types.ts +9 -0
  97. package/src/harness/runtime.ts +9 -0
  98. package/src/harness/types-public.ts +10 -1
  99. package/src/index.ts +5 -0
  100. package/src/internal/verdict-line.ts +33 -0
  101. package/src/taught-tokens.ts +32 -0
  102. package/src/tools/render-check-worker.ts +2 -1
  103. package/src/tools/render-check.ts +8 -7
  104. package/src/validation/primitives.ts +1 -1
  105. package/dist/llm-router-nfO7wPec.d.ts +0 -267
  106. package/dist/{bank-CT4eEkta.d.ts → bank-Ctdp3FUA.d.ts} +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/ui-gen",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "UI generation harness for ggui — the LLM system prompt, multi-turn coding loop, and evaluation engine that turn a DataContract into a React component. Implements the UiGenerator contract from @ggui-ai/mcp-server-core; consumed by @ggui-ai/mcp-server.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -189,9 +189,9 @@
189
189
  "puppeteer-core": "^24.40.0 || ^25.0.0",
190
190
  "react": "^18.0.0 || ^19.0.0",
191
191
  "react-dom": "^18.0.0 || ^19.0.0",
192
- "@ggui-ai/project-config": "0.25.0",
193
- "@ggui-ai/mcp-server-core": "0.25.0",
194
- "@ggui-ai/protocol": "0.25.0"
192
+ "@ggui-ai/mcp-server-core": "0.26.0",
193
+ "@ggui-ai/protocol": "0.26.0",
194
+ "@ggui-ai/project-config": "0.26.0"
195
195
  },
196
196
  "peerDependenciesMeta": {
197
197
  "@google/adk": {
@@ -233,11 +233,11 @@
233
233
  "openai": "^7.9.0",
234
234
  "typescript": "^5.0.0",
235
235
  "zod": "^4.3.6",
236
- "@ggui-ai/design": "0.25.0",
237
- "@ggui-ai/ui-visual-tester": "0.25.0",
238
- "@ggui-ai/gadgets": "0.25.0",
239
- "@ggui-ai/sandbox": "0.25.0",
240
- "@ggui-ai/wire": "0.25.0"
236
+ "@ggui-ai/design": "0.26.0",
237
+ "@ggui-ai/gadgets": "0.26.0",
238
+ "@ggui-ai/ui-visual-tester": "0.26.0",
239
+ "@ggui-ai/sandbox": "0.26.0",
240
+ "@ggui-ai/wire": "0.26.0"
241
241
  },
242
242
  "devDependencies": {
243
243
  "@google/adk": "^2.0.0",
@@ -252,9 +252,9 @@
252
252
  "react-dom": "^19.0.0",
253
253
  "tsup": "^8.5.0",
254
254
  "vitest": "^3.2.6",
255
- "@ggui-ai/mcp-server-core": "0.25.0",
256
- "@ggui-ai/project-config": "0.25.0",
257
- "@ggui-ai/protocol": "0.25.0"
255
+ "@ggui-ai/project-config": "0.26.0",
256
+ "@ggui-ai/mcp-server-core": "0.26.0",
257
+ "@ggui-ai/protocol": "0.26.0"
258
258
  },
259
259
  "engines": {
260
260
  "node": ">=22.0.0"
@@ -126,6 +126,8 @@ export interface GenerationDispatchParams {
126
126
  /** ggui#1436 — see `SingleComponentParams.visualEvaluation.criteriaBank`. */
127
127
  criteriaBank?: CriteriaBank;
128
128
  criteriaKind?: string;
129
+ /** ggui#1542 — see `SingleComponentParams.visualEvaluation.actOn`. */
130
+ actOn?: "critical" | "major";
129
131
  };
130
132
  /** Quality config controlling eval tiers and improvement behavior */
131
133
  qualityConfig?: QualityConfig;
@@ -18,7 +18,7 @@
18
18
  // for raw elements · the one-gesture-surface invariant restated for raw
19
19
  // elements · a regenerated quality checklist.
20
20
 
21
- import { consumedTokenManifest } from "@ggui-ai/design/themes";
21
+ import { taughtTokenManifest } from "../taught-tokens.js";
22
22
  import { describeCanvas, type CanvasClass } from "../design-mode.js";
23
23
  import {
24
24
  ANTI_PATTERNS,
@@ -136,7 +136,7 @@ function renderRamp(r: ColorRamp): string {
136
136
  * every role the prose names exists on `:root`; nothing is implied by a
137
137
  * `50…800` range that the manifest does not carry step for step.
138
138
  */
139
- export function renderFreeColorRule(manifest: readonly string[] = consumedTokenManifest): string {
139
+ export function renderFreeColorRule(manifest: readonly string[] = taughtTokenManifest): string {
140
140
  const { ramps, roles } = colorVocabularyFromManifest(manifest);
141
141
  const brand = ramps.filter((r) => r.family === "primary" || r.family === "neutral");
142
142
  const state = ramps.filter((r) => r.family !== "primary" && r.family !== "neutral");
@@ -260,7 +260,7 @@ const TOKEN_GROUPS: readonly TokenGroup[] = [
260
260
  * does not define. Grouped by prefix; anything outside the known
261
261
  * prefixes lands under "Other".
262
262
  */
263
- export function renderTokenVocabulary(manifest: readonly string[] = consumedTokenManifest): string {
263
+ export function renderTokenVocabulary(manifest: readonly string[] = taughtTokenManifest): string {
264
264
  const sorted = [...manifest].sort();
265
265
  const claimed = new Set<string>();
266
266
  const lines: string[] = [
@@ -257,7 +257,7 @@ The width works the same way, and the mistake is the mirror image: **your outerm
257
257
 
258
258
  - Never put \`maxWidth\` (or a \`max-w-*\` class) on the element you return at the top. On a wide canvas that turns your card into a narrow strip with the frame's ground on either side of it, and on a narrow one it does nothing — the same composition reading differently at two sizes.
259
259
  - DO cap the reading measure inside: a column of prose is easiest to read at 60–70 characters, so wrap the TEXT in a \`<Container maxWidth="sm">\` (or your own \`max-width\`) and leave the root filling. A hero band, a toolbar, a table or an image then still uses the whole width, which is what makes a wide card look composed rather than padded.
260
- - Centre that inner column with the container's own centring, not by shrinking the root.
260
+ - Centre that inner column with the container's own centring, not by shrinking the root — and give the card ONE column: every block of the reading flow (a heading, its text, and the chips or buttons that answer it) sits in a \`<Container>\` of the same \`maxWidth\`, so their left edges line up even where a full-width band runs between them.
261
261
  - **They are two different elements.** Even when the whole card is one column of text, the root still fills and the column sits INSIDE it — \`<Box padding="lg"><Container maxWidth="sm">…</Container></Box>\`, never a root that is itself the container. A centred card is centring inside a filled frame, not a shrunken frame.`;
262
262
 
263
263
  /** Helper-component structure guidance (JSX depth 3–5, helpers above `Component`). Identical in both modes. */
@@ -3,5 +3,7 @@
3
3
  provides the chrome, so add no shadow or outer radius of your own.
4
4
  Compact and natural height; stack vertically. This root fills the
5
5
  bubble — wrap PROSE in <Container maxWidth="sm"> to cap the reading
6
- measure (inert at bubble width, composed on a wider frame). */}
6
+ measure (inert at bubble width, composed on a wider frame), and put
7
+ the chips or buttons that answer it in a Container of the same
8
+ maxWidth, so every block keeps one left edge. */}
7
9
  </Box>
@@ -3,5 +3,7 @@
3
3
  provides the chrome, so add no shadow or outer radius of your own.
4
4
  Compact and natural height; stack vertically. This root fills the
5
5
  bubble — wrap PROSE in <Container maxWidth="sm"> to cap the reading
6
- measure (inert at bubble width, composed on a wider frame). */}
6
+ measure (inert at bubble width, composed on a wider frame), and put
7
+ the chips or buttons that answer it in a Container of the same
8
+ maxWidth, so every block keeps one left edge. */}
7
9
  </Box>
@@ -1,5 +1,7 @@
1
1
  <Box padding="lg">
2
2
  {/* implement your UI here — this root FILLS the frame.
3
3
  Bands, toolbars, tables and images use the whole width;
4
- wrap PROSE in <Container maxWidth="sm"> to cap the reading measure. */}
4
+ wrap PROSE in <Container maxWidth="sm"> to cap the reading measure,
5
+ and put the chips or buttons that answer it in a Container of the
6
+ same maxWidth, so every block keeps one left edge. */}
5
7
  </Box>
@@ -1,5 +1,7 @@
1
1
  <Box padding="lg">
2
2
  {/* implement your UI here — this root FILLS the frame.
3
3
  Bands, toolbars, tables and images use the whole width;
4
- wrap PROSE in <Container maxWidth="sm"> to cap the reading measure. */}
4
+ wrap PROSE in <Container maxWidth="sm"> to cap the reading measure,
5
+ and put the chips or buttons that answer it in a Container of the
6
+ same maxWidth, so every block keeps one left edge. */}
5
7
  </Box>
@@ -17,6 +17,7 @@ import type { DataContract } from '@ggui-ai/protocol';
17
17
  import { HOOK_NAME_RE, listContractGadgets } from '@ggui-ai/protocol';
18
18
  import { consumedTokenManifest } from '@ggui-ai/design/themes';
19
19
  import { DEFAULT_DESIGN_MODE, type DesignMode } from '../design-mode.js';
20
+ import { taughtTokenManifest } from '../taught-tokens.js';
20
21
  import { validateAllContracts, type ContractIssue } from './contract-validation.js';
21
22
  import { typecheck } from './type-checker.js';
22
23
  import { lintReactHooks, type ReactLintDiagnostic } from './react-linter.js';
@@ -315,7 +316,9 @@ const RETIRED_TOKEN_SUCCESSORS: ReadonlyMap<string, string> = new Map([
315
316
  * (`--ggui-motion-duration-normal` → fast, base, slow); otherwise the names
316
317
  * sharing any of its segments of four letters or more
317
318
  * (`--ggui-duration-slow` → the motion durations). Empty when nothing is
318
- * near, so the hint falls back to the manifest's general shape.
319
+ * near, so the hint falls back to the manifest's general shape. A hint
320
+ * teaches, so it draws only on the names the model may be taught
321
+ * (`taughtTokenManifest`); the closed-set check above admits the full manifest.
319
322
  */
320
323
  export function suggestManifestTokens(name: string): readonly string[] {
321
324
  const successor = RETIRED_TOKEN_SUCCESSORS.get(name);
@@ -328,7 +331,7 @@ export function suggestManifestTokens(name: string): readonly string[] {
328
331
  const own = name.endsWith('-') ? segments.length : segments.length - 1;
329
332
  for (let n = own; n >= 2; n--) {
330
333
  const prefix = `--${segments.slice(0, n).join('-')}-`;
331
- const family = consumedTokenManifest.filter((token) => token.startsWith(prefix));
334
+ const family = taughtTokenManifest.filter((token) => token.startsWith(prefix));
332
335
  if (family.length > 0) return wordHitsFirst(name, rolesFirst(family));
333
336
  }
334
337
  const words = new Set(
@@ -338,7 +341,7 @@ export function suggestManifestTokens(name: string): readonly string[] {
338
341
  .map((segment) => segment.toLowerCase()),
339
342
  );
340
343
  return rolesFirst(
341
- consumedTokenManifest.filter((token) =>
344
+ taughtTokenManifest.filter((token) =>
342
345
  token.split('-').some((segment) => words.has(segment.toLowerCase())),
343
346
  ),
344
347
  );
@@ -59,7 +59,7 @@ const SHELL_HINTS: Record<string, string> = {
59
59
  - Padding: use var(--ggui-spacing-2) for inner elements, var(--ggui-spacing-4) for sections. Tight.
60
60
  - Scrolling: parent scrolls (chat feed) — component should NOT have internal scroll
61
61
  - Layout: single column, compact. No sidebar. Stack everything vertically.
62
- - Width: the root is width: 100% (fills the bubble) — never cap the ROOT. A prose column inside it carries the reading cap (<Container maxWidth="sm">), which is inert at bubble width.`,
62
+ - Width: the root is width: 100% (fills the bubble) — never cap the ROOT. A prose column inside it carries the reading cap (<Container maxWidth="sm">), which is inert at bubble width; the chips or buttons that answer the prose sit in a Container of the same maxWidth, so they share its left edge.`,
63
63
 
64
64
  fullscreen: `**Fullscreen Shell — Takes over entire viewport**
65
65
  - Container: the whole viewport, edge to edge — the frame stretches your root to its full height (you never set a viewport height yourself)
@@ -31,15 +31,54 @@ function resolveRegistryId(model: string): ModelId | null {
31
31
  return keys.find((id) => id.endsWith(`/${undated}`)) ?? null;
32
32
  }
33
33
 
34
+ /**
35
+ * ggui#1524 — the largest cache-WRITE premium any registry model states (`cacheWritePer1M / inputPer1M`), never
36
+ * below 1. A cost CAP errs toward over-counting, so a model that states no write rate is priced at this bound
37
+ * times its input rate: a write costs a premium over input wherever one is charged, and pricing it at the bare
38
+ * input rate would slip that premium under the cap. Derived from the registry, so it moves with it.
39
+ */
40
+ export const CACHE_WRITE_PREMIUM_BOUND: number = Math.max(
41
+ 1,
42
+ ...Object.values(MODEL_REGISTRY).flatMap(({ costs }) =>
43
+ costs.cacheWritePer1M !== undefined && costs.inputPer1M > 0 ? [costs.cacheWritePer1M / costs.inputPer1M] : [],
44
+ ),
45
+ );
46
+
47
+ /** Per-1K USD for each kind of token a call can spend. */
48
+ export interface PricePer1k {
49
+ readonly input: number;
50
+ readonly output: number;
51
+ /** Cache reads: the model's stated rate, else its input rate — over-counts a read, the safe side for a cap. */
52
+ readonly cacheRead: number;
53
+ /** Cache writes: the model's stated rate, else its input rate × {@link CACHE_WRITE_PREMIUM_BOUND}. */
54
+ readonly cacheWrite: number;
55
+ }
56
+
34
57
  /** Fallback when a model has no registry entry — Sonnet-class rates. */
35
- const FALLBACK_PER_1K = { input: 0.003, output: 0.015 };
58
+ const FALLBACK_PER_1K: PricePer1k = {
59
+ input: 0.003,
60
+ output: 0.015,
61
+ cacheRead: 0.003,
62
+ cacheWrite: 0.003 * CACHE_WRITE_PREMIUM_BOUND,
63
+ };
36
64
 
37
- /** Per-1K input/output USD for a bare wire model id. */
38
- export function pricePer1k(model: string): { input: number; output: number } {
65
+ /** Per-1K USD for a bare wire model id: input, output, and the two cache kinds. */
66
+ export function pricePer1k(model: string): PricePer1k {
39
67
  const id = resolveRegistryId(model);
40
68
  if (id === null) return FALLBACK_PER_1K;
41
69
  const { costs } = MODEL_REGISTRY[id];
42
- return { input: costs.inputPer1M / 1000, output: costs.outputPer1M / 1000 };
70
+ return {
71
+ input: costs.inputPer1M / 1000,
72
+ output: costs.outputPer1M / 1000,
73
+ cacheRead: (costs.cacheReadPer1M ?? costs.inputPer1M) / 1000,
74
+ cacheWrite: (costs.cacheWritePer1M ?? costs.inputPer1M * CACHE_WRITE_PREMIUM_BOUND) / 1000,
75
+ };
76
+ }
77
+
78
+ /** ggui#1524 — the cache tokens a call spent, as its provider reported them; absent = unreported. */
79
+ export interface CacheSpend {
80
+ readonly read?: number;
81
+ readonly write?: number;
43
82
  }
44
83
 
45
84
  export class CostTracker {
@@ -47,9 +86,18 @@ export class CostTracker {
47
86
 
48
87
  constructor(private maxBudget: number | null) {}
49
88
 
50
- record(model: string, inputTokens: number, outputTokens: number): number {
89
+ /**
90
+ * Record one call. `inputTokens` is the provider's UNCACHED input; a call that read or wrote the prompt cache
91
+ * passes those counts in `cache` (ggui#1524), priced at the cache rates — until then a turn with a long cached
92
+ * prefix cost the cap almost nothing while the provider billed its reads and writes.
93
+ */
94
+ record(model: string, inputTokens: number, outputTokens: number, cache: CacheSpend = {}): number {
51
95
  const prices = pricePer1k(model);
52
- const cost = (inputTokens / 1000) * prices.input + (outputTokens / 1000) * prices.output;
96
+ const cost =
97
+ (inputTokens / 1000) * prices.input +
98
+ (outputTokens / 1000) * prices.output +
99
+ ((cache.read ?? 0) / 1000) * prices.cacheRead +
100
+ ((cache.write ?? 0) / 1000) * prices.cacheWrite;
53
101
  this.totalCost += cost;
54
102
  return cost;
55
103
  }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * `space.edge`'s measurement (#1663): the expression a capture evaluates in the page to produce {@link EdgeBlock}s for
3
+ * {@link judgeEdges}. It runs where the screenshot is taken, on the same DOM at the same width, so the rule reads what
4
+ * the judge's frame shows. It only reads layout; it never writes to the page.
5
+ *
6
+ * What it measures, under `#root`, in CSS px from the card's left edge:
7
+ *
8
+ * - **Text blocks.** Text is grouped by its nearest non-inline element. A block's edge is its first visible character
9
+ * in ink: text in a transparent colour, private-use icon-font glyphs and formatting characters are not characters of
10
+ * the block. Its extent is the union of its inked line boxes. A marker before the text (an icon or image that paints,
11
+ * or a number or bullet on the same line) makes it a hanging marker: `edge` is the marker's, `textEdge` the text's.
12
+ * - **Control groups.** Native and ARIA controls, and bounded boxes the visitor clicks (a pointer cursor or a tab stop,
13
+ * holding no control of their own), group by their parent and are measured at the first control: the border box
14
+ * when bounded, else its text. A control's contents are never blocks of the enclosing column.
15
+ * - **Surfaces.** A non-inline box with a border on at least three sides, its own fill different from what it sits on,
16
+ * or a shadow. A table is a surface measured at its rule and not descended into. A box with no text, control, image
17
+ * or icon inside (a loading bar) is a `placeholder`, which is not a block.
18
+ * - **Roles.** A list item is `list-item` when its list follows a paragraph; text centred in a box wider than it, or
19
+ * controls in a centring row, are `centred`.
20
+ * - **Rows, first, read by geometry.** Items set on one line, however the CSS lays them out: an item after another
21
+ * measured item on its line that holds one block (a text, a control, or a box holding at most one text) belongs to
22
+ * the row and is not measured. An item with a stack inside it is a column.
23
+ * - **Ornaments:** text with no letters or digits is not measured.
24
+ *
25
+ * Hidden elements (through any ancestor), zero-size ones and transparent text are skipped. What the rows and ornament
26
+ * rules set aside is counted. A frame with no text in ink is reported as placeholders only. The expression returns a
27
+ * JSON value; {@link parseEdgeProbe} is its typed read. *
28
+ * **Validated instrument (#1663, 2026-10-02):** this file as of `cec465319` (with `7bb4da13e`) passed a fresh sealed
29
+ * blind draw at the registered bar (27/30, κ 0.76), stricter than the design lead's reading on its disagreements. Any
30
+ * later change to what this file measures or decides is a new instrument until another fresh sealed draw.
31
+ */
32
+ import type { EdgeBlock } from './edge.js';
33
+
34
+ /** What the probe returns: the measured blocks, and whether the frame showed nothing to evaluate. */
35
+ export interface EdgeProbe {
36
+ readonly blocks: readonly EdgeBlock[];
37
+ readonly placeholdersOnly: boolean;
38
+ /** What the probe saw and did not measure, by rule, so a dropped block is counted rather than silent. */
39
+ readonly skipped: { readonly rowItems: number; readonly ornaments: number };
40
+ }
41
+
42
+ // Plain ES2020 for the page: no imports, no template literals, no helpers a bundler might rename.
43
+ export const EDGE_BLOCKS_EXPRESSION = String.raw`(() => {
44
+ const root = document.getElementById('root');
45
+ if (!root) return null;
46
+ const rootBox = root.getBoundingClientRect();
47
+ const x0 = rootBox.left;
48
+ const px = (v) => Math.round((v - x0) * 2) / 2;
49
+ const CONTROL = 'button,input,select,textarea,[role="button"],[role="tab"],[role="switch"],[role="checkbox"],[role="radio"],[role="option"],[role="link"],a[href]';
50
+ const ALNUM = /[\p{L}\p{N}]/u;
51
+ const css = (el) => getComputedStyle(el);
52
+ const clear = (c) => !c || c === 'transparent' || /rgba\([^)]*,\s*0(\.0+)?\)$/.test(c);
53
+ // Visible through every ancestor (an ancestor at opacity 0 or visibility hidden hides it), and not degenerate.
54
+ const shown = (el) => {
55
+ const s = css(el);
56
+ if (s.display === 'none' || s.visibility === 'hidden' || Number(s.opacity) === 0) return false;
57
+ if (typeof el.checkVisibility === 'function' && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
58
+ const r = el.getBoundingClientRect();
59
+ return r.width > 1 && r.height > 1;
60
+ };
61
+ const inline = (s) => /^inline/.test(s.display) && s.display !== 'inline-block' && s.display !== 'inline-flex' && s.display !== 'inline-grid';
62
+ const groundOf = (el) => {
63
+ for (let p = el.parentElement; p; p = p.parentElement) { const b = css(p).backgroundColor; if (!clear(b)) return b; }
64
+ return 'rgb(255, 255, 255)';
65
+ };
66
+ 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;
67
+ const bounded = (el) => { const s = css(el); return sides(s) >= 1 || (!clear(s.backgroundColor) && s.backgroundColor !== groundOf(el)); };
68
+ // A control: a native or ARIA one, or a bounded box the visitor clicks (a pointer cursor or a tab stop) that holds no
69
+ // control of its own and is not the whole card. Its contents are not blocks of the column (decided 2026-10-02).
70
+ const isControl = (el) => {
71
+ const s = css(el);
72
+ if (inline(s)) return false;
73
+ if (el.matches(CONTROL)) return true;
74
+ if (el === root) return false;
75
+ const clickable = s.cursor === 'pointer' || (el.hasAttribute('tabindex') && el.tabIndex >= 0);
76
+ if (!clickable || !bounded(el) || el.querySelector(CONTROL)) return false;
77
+ const r = el.getBoundingClientRect();
78
+ return r.width * r.height < 0.6 * rootBox.width * rootBox.height;
79
+ };
80
+ const isTable = (el) => el.matches('table,[role="table"],[role="grid"]');
81
+ const isSurface = (el) => {
82
+ if (el === root || isControl(el)) return false;
83
+ const s = css(el);
84
+ if (/^inline/.test(s.display) || s.display === 'contents') return false;
85
+ if (isTable(el)) return true;
86
+ return sides(s) >= 3 || (!clear(s.backgroundColor) && s.backgroundColor !== groundOf(el)) || (s.boxShadow && s.boxShadow !== 'none');
87
+ };
88
+ const path = (el) => {
89
+ const parts = [];
90
+ for (let e = el; e && e !== root && parts.length < 4; e = e.parentElement) {
91
+ const i = e.parentElement ? Array.prototype.indexOf.call(e.parentElement.children, e) + 1 : 1;
92
+ parts.unshift(e.tagName.toLowerCase() + ':' + i);
93
+ }
94
+ return parts.join('>');
95
+ };
96
+ const label = (el) => { const t = (el.innerText || '').replace(/\s+/g, ' ').trim(); return path(el) + (t ? ' "' + t.slice(0, 24) + '"' : ''); };
97
+ const surfaces = [];
98
+ 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; };
99
+ const inTable = (el) => { for (let p = el.parentElement; p && p !== root; p = p.parentElement) if (isTable(p)) return true; return false; };
100
+ const inControl = (el) => { for (let p = el; p && p !== root; p = p.parentElement) if (isControl(p)) return p; return null; };
101
+ const all = Array.prototype.slice.call(root.querySelectorAll('*')).filter(shown);
102
+ for (const el of all) if (isSurface(el) && !inTable(el) && !inControl(el)) surfaces.push(el);
103
+ const ids = new Map();
104
+ const idOf = (el) => { if (!ids.has(el)) ids.set(el, label(el)); return ids.get(el); };
105
+ const blocks = [];
106
+ // Per block: the element it stands for and its first line's box, for rows.
107
+ const owners = [];
108
+ const lines = [];
109
+ // Per block: its raw line boxes and left, so a row reads the line two items share, not a wrapped item's widest line.
110
+ const rects = [];
111
+ const rawLeft = [];
112
+ const words = [];
113
+ const within = (el, self) => { const s = surfaceOf(el, self); return s ? { within: idOf(s) } : {}; };
114
+ for (const s of surfaces) {
115
+ const r = s.getBoundingClientRect();
116
+ owners.push(s); lines.push({ top: r.top, bottom: r.bottom }); rects.push([r]); rawLeft.push(r.left); words.push('');
117
+ // A placeholder: a box with no text, no control and no image or icon inside it (a loading bar). Not a block.
118
+ 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 || '');
119
+ 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) }, within(s, false), isTable(s) ? { role: 'table' } : holds ? {} : { role: 'placeholder' }));
120
+ }
121
+ const blockOf = (node) => { for (let p = node.parentElement; p && p !== root; p = p.parentElement) if (!inline(css(p))) return p; return root; };
122
+ const groups = new Map();
123
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
124
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
125
+ if (!/\S/.test(n.data) || !n.parentElement || n.parentElement.closest('style,script,noscript,template') || !shown(n.parentElement) || inControl(n.parentElement) || inTable(n.parentElement)) continue;
126
+ const b = blockOf(n);
127
+ if (!groups.has(b)) groups.set(b, []);
128
+ groups.get(b).push(n);
129
+ }
130
+ // Ink: text whose own colour is not transparent. The edge is the first visible character in ink (decided 2026-10-02):
131
+ // leading invisible text, private-use icon-font glyphs and formatting characters are not characters of the block,
132
+ // while a currency sign or a quotation mark is.
133
+ const inked = (n) => !clear(css(n.parentElement).color) && ALNUM.test(n.data);
134
+ const textRects = (nodes) => {
135
+ const rs = [];
136
+ 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); }
137
+ return rs;
138
+ };
139
+ 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; };
140
+ const firstGlyph = (nodes) => {
141
+ 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; }
142
+ return null;
143
+ };
144
+ // A marker paints: a loaded image, an SVG with a filled or stroked shape, or an element with its own fill, border
145
+ // or visible text. An element that paints nothing is not a marker.
146
+ const painted = (m) => {
147
+ if (!shown(m)) return false;
148
+ if (m.tagName === 'IMG') return m.complete && m.naturalWidth > 0;
149
+ if (m.tagName.toLowerCase() === 'svg' || m.closest('svg')) {
150
+ const shapes = m.querySelectorAll('path,circle,rect,line,polyline,polygon,ellipse,use,text');
151
+ return Array.prototype.some.call(shapes, (sh) => { const st = css(sh); return (st.fill !== 'none' && !clear(st.fill)) || (st.stroke !== 'none' && !clear(st.stroke)); });
152
+ }
153
+ const s = css(m);
154
+ if (!clear(s.backgroundColor) || sides(s) >= 1 || ((m.textContent || '').trim().length > 0 && !clear(s.color))) return true;
155
+ // A wrapper that paints nothing itself is a marker when an image or icon inside it paints.
156
+ return Array.prototype.some.call(m.querySelectorAll('svg,img'), (k) => painted(k));
157
+ };
158
+ const markerBefore = (b, textLeft, lineTop, lineBottom) => {
159
+ const cands = [];
160
+ 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); };
161
+ for (const m of b.querySelectorAll('svg,img,[aria-hidden="true"],i')) if ((m.textContent || '').trim().length <= 2) consider(m);
162
+ const p = b.parentElement;
163
+ 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); }
164
+ return cands.length ? Math.min.apply(null, cands) : null;
165
+ };
166
+ let texts = 0;
167
+ let ornaments = 0;
168
+ for (const [b, nodes] of groups) {
169
+ if (!nodes.some((n) => ALNUM.test(n.data))) { ornaments += 1; continue; }
170
+ const rs = textRects(nodes);
171
+ const g = firstGlyph(nodes);
172
+ if (!rs.length || g === null) continue;
173
+ texts += 1;
174
+ const first = g.left;
175
+ const left = Math.min.apply(null, rs.map((r) => r.left));
176
+ const right = Math.max.apply(null, rs.map((r) => r.right));
177
+ const top = Math.min.apply(null, rs.map((r) => r.top));
178
+ const marker = markerBefore(b, first, g.top, g.bottom);
179
+ const s = css(b);
180
+ const box = b.getBoundingClientRect();
181
+ const parent = b.parentElement ? css(b.parentElement) : null;
182
+ const centredText = s.textAlign === 'center' && right - left < box.width * 0.9;
183
+ const centredBox = parent && /flex/.test(parent.display) && /column/.test(parent.flexDirection) && parent.alignItems === 'center';
184
+ const list = b.matches('li') || s.display === 'list-item' ? b.closest('ul,ol') : null;
185
+ const inProse = list && list.previousElementSibling && list.previousElementSibling.matches('p');
186
+ 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) };
187
+ if (marker !== null) block.textEdge = px(first);
188
+ if (centredText || centredBox) block.role = 'centred';
189
+ else if (inProse) block.role = 'list-item';
190
+ 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());
191
+ blocks.push(Object.assign(block, within(b, true)));
192
+ }
193
+ const ctlGroups = new Map();
194
+ 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); }
195
+ let controls = 0;
196
+ for (const [p, els] of ctlGroups) {
197
+ const rs = els.map((e) => e.getBoundingClientRect());
198
+ const topRow = Math.min.apply(null, rs.map((r) => r.top));
199
+ let firstEl = null; let firstR = null;
200
+ els.forEach((e, i) => { const r = rs[i]; if (r.top - topRow < r.height / 2 && (!firstR || r.left < firstR.left)) { firstEl = e; firstR = r; } });
201
+ let edge = firstR.left;
202
+ if (!bounded(firstEl)) { const tw = document.createTreeWalker(firstEl, NodeFilter.SHOW_TEXT); const ns = []; for (let n = tw.nextNode(); n; n = tw.nextNode()) ns.push(n); const g = firstGlyph(ns); if (g) edge = g.left; }
203
+ controls += 1;
204
+ const ps = css(p);
205
+ const centred = ps.justifyContent === 'center' || (ps.textAlign === 'center' && !/flex|grid/.test(ps.display));
206
+ 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) };
207
+ if (centred) block.role = 'centred';
208
+ 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('');
209
+ blocks.push(Object.assign(block, within(firstEl, true)));
210
+ }
211
+ // Rows (decided 2026-10-01, read by geometry 2026-10-02): items set on one line, however the CSS sets them. An item
212
+ // after another measured item on its line that holds one block (a text, a control, or a box holding at most one
213
+ // text) belongs to the row and is not measured; an item with a stack inside it is a column.
214
+ const dropped = new Set();
215
+ 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;
216
+ const contains = (o, el) => o === el || o.contains(el);
217
+ const innerTexts = (i) => owners.map((el, k) => k).filter((k) => k !== i && blocks[k].kind !== 'surface' && contains(owners[i], owners[k]));
218
+ 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));
219
+ const itemOf = (el, other) => { let e = el; while (e.parentElement && e.parentElement !== root && !e.parentElement.contains(other)) e = e.parentElement; return e; };
220
+ for (let i = 0; i < blocks.length; i += 1) {
221
+ if (blocks[i].kind === 'surface' && !isPill(i)) continue;
222
+ 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));
223
+ if (before < 0) continue;
224
+ // 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
225
+ // edges, the marker's and the text's.
226
+ if (blocks[i].kind === 'text' && blocks[before].kind === 'text' && /^[\p{N}\p{P}\p{S}]{1,3}$/u.test(words[before])) {
227
+ blocks[i] = Object.assign({}, blocks[i], { edge: blocks[before].edge, left: Math.min(blocks[i].left, blocks[before].left), textEdge: blocks[i].edge });
228
+ delete blocks[i].role;
229
+ dropped.add(before);
230
+ continue;
231
+ }
232
+ const item = itemOf(owners[i], owners[before]);
233
+ const inside = owners.map((el, k) => k).filter((k) => contains(item, owners[k]) && blocks[k].kind !== 'surface');
234
+ const boxes = owners.map((el, k) => k).filter((k) => contains(item, owners[k]) && blocks[k].kind === 'surface' && k !== i);
235
+ if (blocks[i].kind === 'surface') { if (boxes.length === 0) { dropped.add(i); for (const k of innerTexts(i)) dropped.add(k); } continue; }
236
+ if (inside.length === 1 && boxes.length === 0) dropped.add(i);
237
+ }
238
+ const kept = blocks.filter((_, i) => !dropped.has(i));
239
+ // Placeholders: a frame with no text in ink shows nothing to evaluate, whatever controls or boxes it draws.
240
+ return { blocks: kept, placeholdersOnly: texts === 0, skipped: { rowItems: dropped.size, ornaments } };
241
+ })()`;
242
+
243
+ const KINDS: readonly EdgeBlock['kind'][] = ['text', 'controls', 'surface'];
244
+ const ROLES: readonly NonNullable<EdgeBlock['role']>[] = ['list-item', 'table', 'centred', 'placeholder'];
245
+ const isKind = (v: unknown): v is EdgeBlock['kind'] => KINDS.some((k) => k === v);
246
+ const isRole = (v: unknown): v is NonNullable<EdgeBlock['role']> => ROLES.some((r) => r === v);
247
+ const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
248
+ const field = (o: object, key: string): unknown => Reflect.get(o, key);
249
+
250
+ /**
251
+ * The probe's typed read: `null` when the page had no `#root` or returned something else, so a failed measurement is
252
+ * reported as unmeasured and never read as "no offsets". A malformed block fails the whole read for the same reason.
253
+ */
254
+ export function parseEdgeProbe(value: unknown): EdgeProbe | null {
255
+ if (typeof value !== 'object' || value === null) return null;
256
+ const raw = field(value, 'blocks');
257
+ const placeholdersOnly = field(value, 'placeholdersOnly');
258
+ if (!Array.isArray(raw) || typeof placeholdersOnly !== 'boolean') return null;
259
+ const skippedRaw = field(value, 'skipped');
260
+ if (typeof skippedRaw !== 'object' || skippedRaw === null) return null;
261
+ const rowItems = field(skippedRaw, 'rowItems');
262
+ const ornaments = field(skippedRaw, 'ornaments');
263
+ if (!num(rowItems) || !num(ornaments)) return null;
264
+ const blocks: EdgeBlock[] = [];
265
+ for (const entry of raw) {
266
+ if (typeof entry !== 'object' || entry === null) return null;
267
+ const [id, kind, edge, left, right, top] = ['id', 'kind', 'edge', 'left', 'right', 'top'].map((k) => field(entry, k));
268
+ const [within, textEdge, role] = ['within', 'textEdge', 'role'].map((k) => field(entry, k));
269
+ if (typeof id !== 'string' || !isKind(kind) || !num(edge) || !num(left) || !num(right) || !num(top)) return null;
270
+ if (within !== undefined && typeof within !== 'string') return null;
271
+ if (textEdge !== undefined && !num(textEdge)) return null;
272
+ if (role !== undefined && !isRole(role)) return null;
273
+ blocks.push({
274
+ id,
275
+ kind,
276
+ edge,
277
+ left,
278
+ right,
279
+ top,
280
+ ...(within !== undefined ? { within } : {}),
281
+ ...(textEdge !== undefined ? { textEdge } : {}),
282
+ ...(role !== undefined ? { role } : {}),
283
+ });
284
+ }
285
+ return { blocks, placeholdersOnly, skipped: { rowItems, ornaments } };
286
+ }