@ggui-ai/ui-gen 0.25.0 → 0.27.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 (117) hide show
  1. package/README.md +2 -2
  2. package/dist/{adapter-Cg_rkpGq.d.ts → adapter-h4_V69LI.d.ts} +49 -1
  3. package/dist/adapters/base.d.ts +6 -1
  4. package/dist/adapters/base.js.map +1 -1
  5. package/dist/adapters/generation-dispatch.d.ts +8 -5
  6. package/dist/adapters/generation-dispatch.js +1174 -198
  7. package/dist/adapters/generation-dispatch.js.map +1 -1
  8. package/dist/adapters/index.js +59 -16
  9. package/dist/adapters/index.js.map +1 -1
  10. package/dist/advanced/index.d.ts +10 -9
  11. package/dist/advanced/index.js +1215 -216
  12. package/dist/advanced/index.js.map +1 -1
  13. package/dist/blueprint-validator.js +416 -92
  14. package/dist/blueprint-validator.js.map +1 -1
  15. package/dist/boilerplate.d.ts +2 -2
  16. package/dist/boilerplate.js +10 -5
  17. package/dist/boilerplate.js.map +1 -1
  18. package/dist/check/index.d.ts +3 -8
  19. package/dist/check/index.js +55 -2
  20. package/dist/check/index.js.map +1 -1
  21. package/dist/{create-ui-generator-DFnnArrv.d.ts → create-ui-generator-BrZHF0cf.d.ts} +3 -4
  22. package/dist/{design-mode-DLufhrNp.d.ts → design-mode-DGWN_Gkm.d.ts} +1 -1
  23. package/dist/{dispatch-CStl9eAw.d.ts → dispatch-B07TI30d.d.ts} +2 -2
  24. package/dist/evaluation/axis-checks/index.d.ts +5 -4
  25. package/dist/evaluation/axis-checks/index.js.map +1 -1
  26. package/dist/evaluation/axis-checks/registry.d.ts +4 -3
  27. package/dist/evaluation/index.d.ts +124 -546
  28. package/dist/evaluation/index.js +588 -65
  29. package/dist/evaluation/index.js.map +1 -1
  30. package/dist/evaluation/types.d.ts +17 -0
  31. package/dist/evaluation/types.js.map +1 -1
  32. package/dist/{generate-9IeNT4ZY.d.ts → generate-tSO_shQ0.d.ts} +1 -1
  33. package/dist/harness/check/runtime-render/index.d.ts +5 -5
  34. package/dist/harness/check/runtime-render/index.js +416 -92
  35. package/dist/harness/check/runtime-render/index.js.map +1 -1
  36. package/dist/harness/check/runtime-render/render-check-worker.js +393 -88
  37. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  38. package/dist/harness/index.d.ts +4 -5
  39. package/dist/harness/index.js +36 -7
  40. package/dist/harness/index.js.map +1 -1
  41. package/dist/harness/prompts.d.ts +2 -2
  42. package/dist/harness/prompts.js +7 -1
  43. package/dist/harness/prompts.js.map +1 -1
  44. package/dist/harness/result-types.d.ts +17 -3
  45. package/dist/harness/result-types.js.map +1 -1
  46. package/dist/harness/runtime.d.ts +14 -6
  47. package/dist/harness/runtime.js +13 -7
  48. package/dist/harness/runtime.js.map +1 -1
  49. package/dist/harness/types-public.d.ts +4 -2
  50. package/dist/harness/types-public.js +10 -5
  51. package/dist/harness/types-public.js.map +1 -1
  52. package/dist/harness/validator-trace-sink.d.ts +4 -3
  53. package/dist/index.d.ts +46 -11
  54. package/dist/index.js +1217 -214
  55. package/dist/index.js.map +1 -1
  56. package/dist/patch.d.ts +3 -3
  57. package/dist/{prompts-YU9piOgs.d.ts → prompts-BhNUuRoZ.d.ts} +1 -1
  58. package/dist/tools/render-check-worker.js +8 -1
  59. package/dist/tools/render-check-worker.js.map +1 -1
  60. package/dist/{types-public-BJTLpzWn.d.ts → types-public-h8IMnYuh.d.ts} +388 -3
  61. package/dist/validation/index.d.ts +1 -1
  62. package/dist/validation/index.js +1 -1
  63. package/dist/validation/index.js.map +1 -1
  64. package/dist/visual-evaluator-C2strxqD.d.ts +639 -0
  65. package/dist/workflows.d.ts +3 -3
  66. package/package.json +12 -12
  67. package/src/adapters/base.ts +6 -1
  68. package/src/adapters/claude/message-parser.ts +5 -6
  69. package/src/adapters/claude/sdk-tool-pins.ts +44 -0
  70. package/src/adapters/claude/sdk.ts +3 -3
  71. package/src/adapters/claude/source-capture.ts +20 -0
  72. package/src/adapters/generation-dispatch.ts +2 -0
  73. package/src/boilerplate/free-design-prompt.ts +3 -3
  74. package/src/boilerplate/hard-sections.ts +1 -1
  75. package/src/boilerplate/system-prompt.ts +3 -2
  76. package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +3 -1
  77. package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +3 -1
  78. package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +3 -1
  79. package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +3 -1
  80. package/src/check/run-tier0.ts +67 -3
  81. package/src/contract-context.ts +1 -1
  82. package/src/design-system-docs.ts +2 -1
  83. package/src/evaluation/cost-tracker.ts +54 -6
  84. package/src/evaluation/criteria/edge-probe.ts +286 -0
  85. package/src/evaluation/criteria/edge.ts +202 -0
  86. package/src/evaluation/criteria/resolve.ts +74 -8
  87. package/src/evaluation/index.ts +5 -1
  88. package/src/evaluation/judge-spend.ts +48 -0
  89. package/src/evaluation/loop.ts +25 -4
  90. package/src/evaluation/message-parsing.ts +11 -12
  91. package/src/evaluation/types-public.ts +95 -0
  92. package/src/evaluation/types.ts +10 -0
  93. package/src/evaluation/visual-evaluator.ts +236 -40
  94. package/src/harness/check/runtime-render/adapter.ts +18 -0
  95. package/src/harness/check/runtime-render/probe-pending.ts +111 -0
  96. package/src/harness/check/runtime-render/render-check-host.ts +10 -6
  97. package/src/harness/check/runtime-render/render-check-worker.ts +6 -3
  98. package/src/harness/check/runtime-render/render-check.ts +417 -133
  99. package/src/harness/check/runtime-render/walk.ts +200 -0
  100. package/src/harness/coding/assemble-result.ts +2 -0
  101. package/src/harness/coding/generate-task-runner.ts +27 -0
  102. package/src/harness/coding/init-session.ts +5 -3
  103. package/src/harness/coding/run-coding-turn.ts +5 -1
  104. package/src/harness/coding/run-eval-round.ts +63 -3
  105. package/src/harness/in-loop-evaluation.ts +39 -0
  106. package/src/harness/llm-router.ts +58 -1
  107. package/src/harness/result-types.ts +9 -0
  108. package/src/harness/runtime.ts +9 -0
  109. package/src/harness/types-public.ts +10 -1
  110. package/src/index.ts +5 -0
  111. package/src/internal/verdict-line.ts +33 -0
  112. package/src/taught-tokens.ts +31 -0
  113. package/src/tools/render-check-worker.ts +2 -1
  114. package/src/tools/render-check.ts +8 -7
  115. package/src/validation/primitives.ts +1 -1
  116. package/dist/llm-router-nfO7wPec.d.ts +0 -267
  117. package/dist/{bank-CT4eEkta.d.ts → bank-Ctdp3FUA.d.ts} +3 -3
@@ -30,11 +30,21 @@ import { createRequire } from 'node:module';
30
30
  import { existsSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
31
31
  import { createHash } from 'node:crypto';
32
32
  import { tmpdir } from 'os';
33
- import { createVisionAgent, type AgentConfig } from '../harness/llm-router';
33
+ import { createVisionAgent, type AgentConfig, type VisionFinishReason } from '../harness/llm-router';
34
34
  import type { EvaluationResult, EvaluationIssue, DimensionScores } from './types';
35
- import type { CanvasHostPresentation, CanvasJudgeRecord, CanvasPresentationOutcome, CanvasVisualSummary, EvalIssue, HostInlineFrame, HostPresentationIgnoredReason, VisualCoverage, VisualEvalSummary, VisualFitStamp } from './types-public.js';
35
+
36
+ /**
37
+ * An issue as the visual leg CREATES it (ggui#1545 follow-up): its `origin` is required here, so a new producer, an
38
+ * instrument check or a judge-side parse, that forgets to say whose finding it is fails to compile instead of
39
+ * quietly dropping out of `visualEvaluation.actOn`'s treatment. The public `EvaluationIssue` keeps `origin` optional
40
+ * for stored and external data.
41
+ */
42
+ type VisualIssue = EvaluationIssue & { origin: 'judge' | 'instrument' };
43
+ import type { CanvasHostPresentation, CanvasJudgeRecord, CriteriaAnswerCounts, CanvasPresentationOutcome, CanvasVisualSummary, EvalIssue, HostInlineFrame, HostPresentationIgnoredReason, JudgeSalvageCause, JudgeSalvageCounts, VisualCoverage, VisualEvalSummary, VisualFitStamp } from './types-public.js';
36
44
  import type { LaunchOptions } from 'puppeteer-core';
37
45
  import { CANVAS_VIEWPORTS, displayModeForCanvas, type CanvasClass, type CanvasViewport } from '../design-mode.js';
46
+ import { judgeEdges, type EdgeVerdict } from './criteria/edge.js';
47
+ import { EDGE_BLOCKS_EXPRESSION, parseEdgeProbe } from './criteria/edge-probe.js';
38
48
 
39
49
  // ---------------------------------------------------------------------------
40
50
  // Types
@@ -361,7 +371,7 @@ export function canvasOverflowIssue(
361
371
  viewport: CanvasViewport,
362
372
  contentHeight: number,
363
373
  verdict: 'fail' | 'warn',
364
- ): EvaluationIssue {
374
+ ): VisualIssue {
365
375
  const hidden = contentHeight - viewport.height;
366
376
  // ggui#1195 — when the judge captured at a DECLARED box, the refusal names
367
377
  // both boxes: the ceiling the content was measured against and the class
@@ -372,6 +382,7 @@ export function canvasOverflowIssue(
372
382
  : `${viewport.width}×${viewport.height}`;
373
383
  return {
374
384
  dimension: 'canvas-overflow',
385
+ origin: 'instrument',
375
386
  severity: verdict === 'fail' ? 'critical' : 'major',
376
387
  description:
377
388
  `Rendered content is ${contentHeight}px tall on the ${canvas} canvas (${box}) — ` +
@@ -388,9 +399,10 @@ export function canvasOverflowXIssue(
388
399
  viewport: CanvasViewport,
389
400
  hiddenPx: number,
390
401
  verdict: 'fail' | 'warn',
391
- ): EvaluationIssue {
402
+ ): VisualIssue {
392
403
  return {
393
404
  dimension: 'canvas-overflow-x',
405
+ origin: 'instrument',
394
406
  severity: verdict === 'fail' ? 'critical' : 'major',
395
407
  description:
396
408
  `Content runs ${hiddenPx}px past the ${canvas} card's ${viewport.width}px width — the card's frame clips it, ` +
@@ -402,13 +414,14 @@ export function canvasOverflowXIssue(
402
414
  }
403
415
 
404
416
  /** ggui#1120 — the deterministic blank: the capture is one flat colour, so the component mounted and painted nothing. */
405
- export function canvasBlankIssue(canvas: CanvasClass, viewport: CanvasViewport): EvaluationIssue {
417
+ export function canvasBlankIssue(canvas: CanvasClass, viewport: CanvasViewport): VisualIssue {
406
418
  const classBox = CANVAS_VIEWPORTS[canvas];
407
419
  const box = isDeclaredViewport(canvas, viewport)
408
420
  ? `declared ${viewport.width}×${viewport.height}; class box ${classBox.width}×${classBox.height}`
409
421
  : `${viewport.width}×${viewport.height}`;
410
422
  return {
411
423
  dimension: 'canvas-blank',
424
+ origin: 'instrument',
412
425
  severity: 'critical',
413
426
  description: `Nothing painted on the ${canvas} canvas (${box}): the capture is one flat colour — the component mounted and rendered no pixels.`,
414
427
  fix:
@@ -430,6 +443,11 @@ export interface VisualEvaluationResult extends EvaluationResult {
430
443
  * `outputTokens` stay the SCORING calls' alone, so a cost read off them never includes the report-only block.
431
444
  */
432
445
  criteriaTokens?: CriteriaCallTokens;
446
+ /**
447
+ * ggui#1127 — salvaged judge answers across the canvases, per leg, as N of M (scoring answers; criteria calls
448
+ * answered). Present on every canvas run, zeros included, so a reader can tell "none salvaged" from "not counted".
449
+ */
450
+ judgeSalvage?: JudgeSalvageCounts;
433
451
  /** The design tree the judge painted with (ggui#1042) — present on every per-canvas judgement. */
434
452
  design?: JudgeDesignIdentity;
435
453
  /** The mode `cssTokens` were composed in, when the caller said (ggui#1076). */
@@ -833,6 +851,11 @@ export interface ScreenshotAttempt {
833
851
  readonly contentHeight: number | null;
834
852
  /** ggui#1475 — a natural capture's horizontal overflow: how many px the card's content runs past its width; absent on every other capture. */
835
853
  readonly overflowX?: number | null;
854
+ /**
855
+ * ggui#1663 — the edge rule on this capture's DOM, read just before the screenshot (report-only); `null` when the
856
+ * probe failed. Absent on a failed capture.
857
+ */
858
+ readonly edge?: EdgeVerdict | null;
836
859
  }
837
860
 
838
861
  /** The expression the fit measurement evaluates in the page — the taller of the two scroll heights. */
@@ -890,6 +913,24 @@ async function measureContentHeight(page: ScreenshotPage, expression: string = C
890
913
  }
891
914
  }
892
915
 
916
+ /**
917
+ * ggui#1663 — read the edge rule off the page the screenshot is about to take. The probe only reads layout (it never
918
+ * writes to the DOM), so the frame is unchanged; a failed read is reported and returns `null`, never a failed capture.
919
+ */
920
+ async function measureEdges(page: ScreenshotPage): Promise<EdgeVerdict | null> {
921
+ try {
922
+ const probe = parseEdgeProbe(await page.evaluate(EDGE_BLOCKS_EXPRESSION));
923
+ if (probe === null) {
924
+ console.warn('[visual-eval] edge probe unavailable: the page returned no readable blocks');
925
+ return null;
926
+ }
927
+ return judgeEdges(probe.blocks, { placeholdersOnly: probe.placeholdersOnly });
928
+ } catch (e) {
929
+ console.warn(`[visual-eval] edge probe unavailable: ${e instanceof Error ? e.message : String(e)}`);
930
+ return null;
931
+ }
932
+ }
933
+
893
934
  /** ggui#1492 — a natural capture's geometry: the page's pad per side, and the host's floor when it has one. */
894
935
  export interface NaturalCaptureOptions {
895
936
  readonly padPx: number;
@@ -928,12 +969,14 @@ export async function captureScreenshotDetailed(
928
969
  }
929
970
  const framed = cardHeightPx === null ? viewport.height - 2 * natural.padPx : floorPx !== undefined ? Math.max(cardHeightPx, floorPx) : cardHeightPx;
930
971
  const clip = naturalClip(viewport, framed, natural.padPx);
972
+ const edge = await measureEdges(page);
931
973
  const screenshot = await page.screenshot({ type: 'png', fullPage: false, clip });
932
- return { png: Buffer.from(screenshot), contentHeight: cardHeightPx, overflowX };
974
+ return { png: Buffer.from(screenshot), contentHeight: cardHeightPx, overflowX, edge };
933
975
  }
934
976
  const contentHeight = await measureContentHeight(page);
977
+ const edge = await measureEdges(page);
935
978
  const screenshot = await page.screenshot({ type: 'png', fullPage: capture === 'full-page' });
936
- return { png: Buffer.from(screenshot), contentHeight };
979
+ return { png: Buffer.from(screenshot), contentHeight, edge };
937
980
  } finally {
938
981
  await browser.close();
939
982
  }
@@ -1119,7 +1162,7 @@ function readInk(png: Buffer | null, chrome: CanvasChrome, canvas: CanvasClass,
1119
1162
  return ink;
1120
1163
  }
1121
1164
  /** The deterministic blank verdict (ggui#1120): an issue when the frame read no ink at all; `null` otherwise, an unreadable capture included. */
1122
- function blankVerdict(canvas: CanvasClass, frame: CanvasFrame): EvaluationIssue | null {
1165
+ function blankVerdict(canvas: CanvasClass, frame: CanvasFrame): VisualIssue | null {
1123
1166
  // ggui#1475 — a naturally-captured card that measured no height painted nothing: its capture is only the host's frame,
1124
1167
  // which the ink reading skips by design, so the blank is read from the measurement, never mistaken for unreadable.
1125
1168
  if (frame.chrome === 'ground' && frame.attempt.contentHeight !== null && frame.attempt.contentHeight <= 0) {
@@ -1165,7 +1208,7 @@ async function frameCanvas(
1165
1208
  * canvas's policy gives an overflow, or `null` when the content fits, could
1166
1209
  * not be measured, or the canvas scrolls by design.
1167
1210
  */
1168
- function fitVerdict(canvas: CanvasClass, frame: CanvasFrame): EvaluationIssue | null {
1211
+ function fitVerdict(canvas: CanvasClass, frame: CanvasFrame): VisualIssue | null {
1169
1212
  const contentHeight = frame.attempt.contentHeight;
1170
1213
  if (contentHeight === null || contentHeight <= frame.viewport.height || frame.policy.overflow === 'none') return null;
1171
1214
  return canvasOverflowIssue(canvas, frame.viewport, contentHeight, frame.policy.overflow);
@@ -1176,7 +1219,7 @@ function fitVerdict(canvas: CanvasClass, frame: CanvasFrame): EvaluationIssue |
1176
1219
  * is judged by the canvas's own fit policy (the inline card: a critical that fails the canvas), in its own dimension,
1177
1220
  * so the in-loop `[fit]` extension (vertical overflow) is unchanged.
1178
1221
  */
1179
- function overflowXVerdict(canvas: CanvasClass, frame: CanvasFrame): EvaluationIssue | null {
1222
+ function overflowXVerdict(canvas: CanvasClass, frame: CanvasFrame): VisualIssue | null {
1180
1223
  const px = frame.attempt.overflowX;
1181
1224
  // `scrollWidth` / `clientWidth` are rounded integers: a fractional layout width reads as 1 px past the card with
1182
1225
  // nothing a visitor could see, so 1 px is rounding and an overflow starts at 2.
@@ -1186,9 +1229,95 @@ function overflowXVerdict(canvas: CanvasClass, frame: CanvasFrame): EvaluationIs
1186
1229
 
1187
1230
  /** One judge call parsed, retried ONCE on a malformed answer (#1017); the FIRST reason is kept verbatim. */
1188
1231
  type JudgedAnswer =
1189
- | { readonly kind: 'ok'; readonly result: EvaluationResult; readonly inputTokens: number; readonly outputTokens: number }
1232
+ | {
1233
+ readonly kind: 'ok';
1234
+ readonly result: EvaluationResult;
1235
+ readonly inputTokens: number;
1236
+ readonly outputTokens: number;
1237
+ /** ggui#1127 — present when the answer did not parse and its score was salvaged. */
1238
+ readonly salvage?: JudgeSalvage;
1239
+ /** ggui#1687 — the provider's stop reason and the model it says answered; each absent when unreported. */
1240
+ readonly finishReason?: VisionFinishReason;
1241
+ readonly servedModel?: string;
1242
+ }
1190
1243
  | { readonly kind: 'unparsable'; readonly reason: string };
1191
1244
 
1245
+ /** ggui#1127 — what a salvaged answer leaves on the record: why, and where the parse broke. Never the answer's text. */
1246
+ interface JudgeSalvage {
1247
+ readonly cause: JudgeSalvageCause;
1248
+ /** The provider's normalized stop reason; absent when it reported none. */
1249
+ readonly finishReason?: VisionFinishReason;
1250
+ /** The parser's error, with every quoted span of the answer removed. */
1251
+ readonly parseError: string;
1252
+ /** Where the parse broke, as a character offset into the answer, when the parser said. */
1253
+ readonly offset?: number;
1254
+ /** The answer's length in characters: an offset at the end reads as a cut, one mid-answer as malformed. */
1255
+ readonly chars: number;
1256
+ }
1257
+
1258
+ /**
1259
+ * ggui#1127 — why an unparsable answer was salvaged. The cause comes from the provider's stop reason, never from the
1260
+ * answer's length: a short answer can still be cut (thinking spends the cap), and a long one can still be malformed.
1261
+ */
1262
+ function judgeSalvageOf(error: unknown, text: string, finishReason: VisionFinishReason | undefined): JudgeSalvage {
1263
+ const cause: JudgeSalvageCause =
1264
+ finishReason === undefined ? 'unknown' : finishReason === 'length' ? 'output-cap' : finishReason === 'stop' ? 'malformed' : 'stopped';
1265
+ const message = error instanceof Error ? error.message : String(error);
1266
+ // The JSON is parsed from the first `{` (parseVisualResponse), so the parser's position is shifted by that start.
1267
+ const position = /position (\d+)/.exec(message);
1268
+ const start = text.indexOf('{');
1269
+ const offset = position !== null ? (start > 0 ? start : 0) + Number(position[1]) : undefined;
1270
+ const parseError = message.startsWith('Visual evaluator did not return valid JSON')
1271
+ ? 'no complete JSON object in the answer'
1272
+ : message.replace(/"(?:[^"\\]|\\.)*"/g, '"…"').slice(0, 160);
1273
+ return { cause, ...(finishReason !== undefined ? { finishReason } : {}), parseError, ...(offset !== undefined ? { offset } : {}), chars: text.length };
1274
+ }
1275
+
1276
+ /** ggui#1127 — a canvas's judge record: the K samples, and which of them (and the criteria call) were salvaged. */
1277
+ function judgeRecordOf(
1278
+ k: number,
1279
+ parsed: readonly Extract<JudgedAnswer, { kind: 'ok' }>[],
1280
+ samples: number[],
1281
+ criteria: JudgedAnswer | undefined,
1282
+ requestedModel: string,
1283
+ ): CanvasJudgeRecord {
1284
+ const salvaged = parsed.flatMap((a) => (a.salvage !== undefined ? [a.salvage.cause] : []));
1285
+ // ggui#1687 — every model the provider said answered, scoring and criteria, deduplicated in call order.
1286
+ const answered = [...parsed, ...(criteria?.kind === 'ok' ? [criteria] : [])];
1287
+ const served = [...new Set(answered.flatMap((a) => (a.servedModel !== undefined ? [a.servedModel] : [])))];
1288
+ const criteriaSalvaged = criteria?.kind === 'ok' ? criteria.salvage?.cause : undefined;
1289
+ return {
1290
+ k,
1291
+ rule: 'median',
1292
+ samples,
1293
+ sigma: populationSigma(samples),
1294
+ notes: parsed.map((a) => a.result.critique ?? ''),
1295
+ ...(salvaged.length > 0 ? { salvaged } : {}),
1296
+ ...(criteriaSalvaged !== undefined ? { criteriaSalvaged } : {}),
1297
+ models: { requested: requestedModel, served },
1298
+ };
1299
+ }
1300
+
1301
+ /** ggui#1687 — what one call's response says about itself: its stop reason and the model that answered. */
1302
+ function callFacts(response: { readonly finishReason?: VisionFinishReason; readonly servedModel?: string }): {
1303
+ finishReason?: VisionFinishReason;
1304
+ servedModel?: string;
1305
+ } {
1306
+ return {
1307
+ ...(response.finishReason !== undefined ? { finishReason: response.finishReason } : {}),
1308
+ ...(response.servedModel !== undefined ? { servedModel: response.servedModel } : {}),
1309
+ };
1310
+ }
1311
+
1312
+ const NO_CRITERIA_ARRAY: CriteriaAnswerCounts = { array: false, received: 0, kept: 0, dropped: { notObject: 0, badId: 0, badVerdict: 0 } };
1313
+
1314
+ /** ggui#1687 — the criteria call as it came back, for the block: its answer's counts and its stop reason. */
1315
+ function criteriaCallOf(criteria: JudgedAnswer | undefined): { call?: { answer: CriteriaAnswerCounts; finishReason?: VisionFinishReason } } {
1316
+ if (criteria?.kind !== 'ok') return {};
1317
+ const answer = criteria.result.criteriaCounts ?? NO_CRITERIA_ARRAY;
1318
+ return { call: { answer, ...(criteria.finishReason !== undefined ? { finishReason: criteria.finishReason } : {}) } };
1319
+ }
1320
+
1192
1321
  /**
1193
1322
  * ggui#1436 — the criteria are REPORT-ONLY, so they are never asked inside a call whose score binds. The K
1194
1323
  * scoring calls go out exactly as they did before the block existed (the same user turn, byte for byte); when a
@@ -1223,7 +1352,15 @@ function criteriaCallTokens(criteria: JudgedAnswer | undefined): CriteriaCallTok
1223
1352
 
1224
1353
  /** The criteria call's answers, as the one read the block resolves; none when it was not asked or did not parse. */
1225
1354
  function criteriaReads(criteria: JudgedAnswer | undefined): CriteriaAnswer[][] {
1226
- return criteria?.kind === 'ok' ? [criteria.result.criteriaAnswers ?? []] : [];
1355
+ // ggui#1127 — a salvaged answer keeps no criteria: it is no read, and its rows say why (criteriaUnansweredReason).
1356
+ return criteria?.kind === 'ok' && criteria.salvage === undefined ? [criteria.result.criteriaAnswers ?? []] : [];
1357
+ }
1358
+
1359
+ /** ggui#1127 — the reason a salvaged criteria answer's judge rows give, so none reads as a clean `n/a`. */
1360
+ function criteriaUnansweredReason(criteria: JudgedAnswer | undefined): { unansweredReason?: string } {
1361
+ return criteria?.kind === 'ok' && criteria.salvage !== undefined
1362
+ ? { unansweredReason: `not answered: the criteria answer was salvaged (${criteria.salvage.cause})` }
1363
+ : {};
1227
1364
  }
1228
1365
 
1229
1366
  /** How many vision calls this canvas gets (ggui#1072): `judgeK` when the canvas is sampled, else 1. */
@@ -1262,15 +1399,21 @@ async function judgeAndParse(
1262
1399
  const response = await judge(config, model, VISUAL_EVAL_PROMPT, screenshot, originalPrompt, profileBlock, criteriaBlock);
1263
1400
  try {
1264
1401
  const result = parseVisualResponse(response.text, config.passThreshold);
1265
- return { kind: 'ok', result, inputTokens: response.inputTokens, outputTokens: response.outputTokens };
1402
+ return { kind: 'ok', result, inputTokens: response.inputTokens, outputTokens: response.outputTokens, ...callFacts(response) };
1266
1403
  } catch (e) {
1267
- // A TRUNCATED answer (cut inside `issues` at the output cap) is deterministic on
1268
- // the content — retrying the same prompt reproduces it. The four dimensions are
1269
- // emitted before `issues`, so the score is recoverable from the closed prefix.
1404
+ // An answer that fails to parse but whose four dimensions closed is SALVAGED (ggui#1127): they are emitted
1405
+ // before `issues`, so the score is recoverable from the closed prefix. Whether it was cut at the output cap or
1406
+ // ended malformed is the provider's stop reason to say, not the answer's length; the record and the warn
1407
+ // line carry the cause, the parse error and where it broke, never the answer's text. One token per leg.
1270
1408
  const salvaged = salvageTruncatedVisualAnswer(response.text, config.passThreshold);
1271
1409
  if (salvaged !== null) {
1272
- console.warn(`[visual-eval] visual_judge_truncated${canvas ? ` canvas=${canvas}` : ''}: answer cut after ${response.text.length} chars — score recovered from the closed prefix, issues list partial`);
1273
- return { kind: 'ok', result: salvaged, inputTokens: response.inputTokens, outputTokens: response.outputTokens };
1410
+ const salvage = judgeSalvageOf(e, response.text, response.finishReason);
1411
+ const token = criteriaBlock.length > 0 ? 'visual_criteria_salvaged' : 'visual_judge_salvaged';
1412
+ console.warn(
1413
+ `[visual-eval] ${token}${canvas ? ` canvas=${canvas}` : ''} cause=${salvage.cause} finish=${salvage.finishReason ?? 'unreported'} ` +
1414
+ `at=${salvage.offset ?? '?'}/${salvage.chars} (${salvage.parseError}): score recovered from the closed prefix, issues list partial`,
1415
+ );
1416
+ return { kind: 'ok', result: salvaged, inputTokens: response.inputTokens, outputTokens: response.outputTokens, salvage, ...callFacts(response) };
1274
1417
  }
1275
1418
  const reason = `judge answer unparsable: ${e instanceof Error ? e.message : String(e)}`;
1276
1419
  if (attempt === 1) {
@@ -1369,13 +1512,7 @@ export async function runVisualEvaluationDetailed(
1369
1512
  result.outputTokens = parsed.reduce((sum, a) => sum + a.outputTokens, 0);
1370
1513
  const response = { inputTokens: result.inputTokens, outputTokens: result.outputTokens };
1371
1514
  const criteriaTokens = criteriaCallTokens(criteriaAnswer);
1372
- const judgeRecord: CanvasJudgeRecord = {
1373
- k,
1374
- rule: 'median',
1375
- samples,
1376
- sigma: populationSigma(samples),
1377
- notes: parsed.map((a) => a.result.critique ?? ''),
1378
- };
1515
+ const judgeRecord = judgeRecordOf(k, parsed, samples, criteriaAnswer, model);
1379
1516
  // The fit verdict (ggui#1027): deterministic, in the judge's issue channel.
1380
1517
  const contentHeight = attempt.contentHeight;
1381
1518
  const overflow = contentHeight !== null && contentHeight > viewport.height;
@@ -1404,7 +1541,9 @@ export async function runVisualEvaluationDetailed(
1404
1541
  context: criteriaContext,
1405
1542
  selected: criteriaSelected,
1406
1543
  answers: criteriaReads(criteriaAnswer),
1407
- measurements: { overflow, contentHeight, viewportHeight: viewport.height, inkRatio },
1544
+ measurements: { overflow, contentHeight, viewportHeight: viewport.height, inkRatio, edge: attempt.edge ?? null },
1545
+ ...criteriaUnansweredReason(criteriaAnswer),
1546
+ ...criteriaCallOf(criteriaAnswer),
1408
1547
  })
1409
1548
  : undefined;
1410
1549
  perCanvasResults.push(result);
@@ -1511,7 +1650,20 @@ function aggregateCanvasResults(
1511
1650
  outputTokens += r.outputTokens ?? 0;
1512
1651
  });
1513
1652
  const criteriaSpent = perCanvas.flatMap((c) => (c.criteriaTokens !== undefined ? [c.criteriaTokens] : []));
1653
+ // ggui#1127 — N of M per leg. A criteria call that parsed or was salvaged reports its spend, so `criteriaTokens`
1654
+ // marks an answered call.
1655
+ const judgeSalvage: JudgeSalvageCounts = {
1656
+ scoring: {
1657
+ salvaged: perCanvas.reduce((sum, c) => sum + (c.judge.salvaged?.length ?? 0), 0),
1658
+ answers: perCanvas.reduce((sum, c) => sum + c.judge.samples.length, 0),
1659
+ },
1660
+ criteria: {
1661
+ salvaged: perCanvas.filter((c) => c.judge.criteriaSalvaged !== undefined).length,
1662
+ answered: perCanvas.filter((c) => c.criteriaTokens !== undefined).length,
1663
+ },
1664
+ };
1514
1665
  return {
1666
+ judgeSalvage,
1515
1667
  passed: perCanvas.every((c) => c.passed),
1516
1668
  finalScore: mean((r) => r.finalScore),
1517
1669
  dimensions,
@@ -1682,6 +1834,8 @@ export async function judgeStoredCapture(
1682
1834
  selected: criteriaSelected,
1683
1835
  answers: criteriaReads(criteriaAnswer),
1684
1836
  measurements: { overflow, contentHeight, viewportHeight: viewport.height, inkRatio },
1837
+ ...criteriaUnansweredReason(criteriaAnswer),
1838
+ ...criteriaCallOf(criteriaAnswer),
1685
1839
  })
1686
1840
  : undefined;
1687
1841
  const criteriaTokens = criteriaCallTokens(criteriaAnswer);
@@ -1693,7 +1847,7 @@ export async function judgeStoredCapture(
1693
1847
  score: median,
1694
1848
  passed,
1695
1849
  issues,
1696
- judge: { k, rule: 'median', samples, sigma: populationSigma(samples), notes: parsed.map((a) => a.result.critique ?? '') },
1850
+ judge: judgeRecordOf(k, parsed, samples, criteriaAnswer, model),
1697
1851
  contentHeight,
1698
1852
  overflow,
1699
1853
  inkRatio,
@@ -1757,7 +1911,7 @@ export function salvageTruncatedVisualAnswer(text: string, passThreshold: number
1757
1911
  hierarchy,
1758
1912
  aesthetics,
1759
1913
  issues,
1760
- critique: `[truncated judge answer — score recovered from the closed prefix; ${issues.length} issue(s) kept, the rest and the critique were cut]`,
1914
+ critique: `[salvaged judge answer — it did not parse; score recovered from the closed prefix; ${issues.length} issue(s) kept, the rest and the critique lost]`,
1761
1915
  });
1762
1916
  return parseVisualResponse(rebuilt, passThreshold);
1763
1917
  }
@@ -1793,8 +1947,9 @@ function parseVisualResponse(text: string, passThreshold: number): EvaluationRes
1793
1947
  codeQuality: 0, // can't assess code from screenshot
1794
1948
  };
1795
1949
 
1796
- const issues: EvaluationIssue[] = (raw.issues || []).map((i: Record<string, string>) => ({
1950
+ const issues: VisualIssue[] = (raw.issues || []).map((i: Record<string, string>) => ({
1797
1951
  dimension: i.dimension || 'visual',
1952
+ origin: 'judge',
1798
1953
  severity: (i.severity as 'critical' | 'major' | 'minor') || 'minor',
1799
1954
  description: i.description || '',
1800
1955
  fix: i.fix || '',
@@ -1802,7 +1957,7 @@ function parseVisualResponse(text: string, passThreshold: number): EvaluationRes
1802
1957
 
1803
1958
  // ggui#1436 — the criteria answers, tolerant: absent block → no field (an old answer stays
1804
1959
  // valid); an entry without a string id or with a verdict outside the vocabulary is dropped.
1805
- const criteriaAnswers = parseCriteriaAnswers(raw.criteria);
1960
+ const criteria = parseCriteriaAnswers(raw.criteria);
1806
1961
 
1807
1962
  return {
1808
1963
  passed: finalScore >= passThreshold,
@@ -1810,21 +1965,37 @@ function parseVisualResponse(text: string, passThreshold: number): EvaluationRes
1810
1965
  dimensions,
1811
1966
  issues,
1812
1967
  critique: raw.critique,
1813
- ...(criteriaAnswers !== undefined ? { criteriaAnswers } : {}),
1968
+ ...(criteria.answers !== undefined ? { criteriaAnswers: criteria.answers } : {}),
1969
+ criteriaCounts: criteria.counts,
1814
1970
  };
1815
1971
  }
1816
1972
 
1817
- function parseCriteriaAnswers(raw: unknown): CriteriaAnswer[] | undefined {
1818
- if (!Array.isArray(raw)) return undefined;
1819
- const out: CriteriaAnswer[] = [];
1973
+ /**
1974
+ * The criteria answers, tolerant (ggui#1436), and what the tolerance dropped (ggui#1687): an absent or non-array block
1975
+ * reads no row, and each dropped entry is counted by why, so an empty or cut call is visible rather than a row of
1976
+ * clean `n/a`s.
1977
+ */
1978
+ function parseCriteriaAnswers(raw: unknown): { answers?: CriteriaAnswer[]; counts: CriteriaAnswerCounts } {
1979
+ if (!Array.isArray(raw)) return { counts: NO_CRITERIA_ARRAY };
1980
+ const answers: CriteriaAnswer[] = [];
1981
+ const dropped = { notObject: 0, badId: 0, badVerdict: 0 };
1820
1982
  for (const entry of raw) {
1821
- if (typeof entry !== 'object' || entry === null) continue;
1983
+ if (typeof entry !== 'object' || entry === null) {
1984
+ dropped.notObject += 1;
1985
+ continue;
1986
+ }
1822
1987
  const o = entry as { id?: unknown; verdict?: unknown; evidence?: unknown };
1823
- if (typeof o.id !== 'string') continue;
1824
- if (o.verdict !== 'pass' && o.verdict !== 'fail' && o.verdict !== 'n/a') continue;
1825
- out.push({ id: o.id, verdict: o.verdict, evidence: typeof o.evidence === 'string' ? o.evidence : '' });
1988
+ if (typeof o.id !== 'string') {
1989
+ dropped.badId += 1;
1990
+ continue;
1991
+ }
1992
+ if (o.verdict !== 'pass' && o.verdict !== 'fail' && o.verdict !== 'n/a') {
1993
+ dropped.badVerdict += 1;
1994
+ continue;
1995
+ }
1996
+ answers.push({ id: o.id, verdict: o.verdict, evidence: typeof o.evidence === 'string' ? o.evidence : '' });
1826
1997
  }
1827
- return out;
1998
+ return { answers, counts: { array: true, received: raw.length, kept: answers.length, dropped } };
1828
1999
  }
1829
2000
 
1830
2001
  // ---------------------------------------------------------------------------
@@ -1842,6 +2013,16 @@ function getDefaultVisualModel(provider: 'claude' | 'google'): string {
1842
2013
  // Tier 2 adapter — returns EvalIssue[]
1843
2014
  // ---------------------------------------------------------------------------
1844
2015
 
2016
+ /**
2017
+ * ggui#1522 — what a visual leg's judge calls spent: the scoring calls, and the report-only criteria calls apart
2018
+ * (ggui#1436). Priced at the VISUAL agent's model, never folded into a text evaluator's or a coding turn's tokens.
2019
+ */
2020
+ export interface VisualLegTokens {
2021
+ readonly inputTokens: number;
2022
+ readonly outputTokens: number;
2023
+ readonly criteria?: CriteriaCallTokens;
2024
+ }
2025
+
1845
2026
  /** What the harness's eval round consumes from the visual leg. */
1846
2027
  export interface VisualEvalOutcome {
1847
2028
  /** Tier-2 issues (empty when no browser is available). */
@@ -1858,6 +2039,11 @@ export interface VisualEvalOutcome {
1858
2039
  * summary used to be indistinguishable from a clean single-shot run.
1859
2040
  */
1860
2041
  coverage: VisualCoverage;
2042
+ /**
2043
+ * ggui#1522 — the leg's own spend, when its judge ran and reported tokens. The round records it in the cost
2044
+ * tracker at the visual agent's model and carries it apart; absent on a skipped leg.
2045
+ */
2046
+ tokens?: VisualLegTokens;
1861
2047
  }
1862
2048
 
1863
2049
  /**
@@ -1881,7 +2067,16 @@ export async function runVisualEval(
1881
2067
  const issues: EvalIssue[] = (result.issues || []).map(toEvalIssue);
1882
2068
  const summary = summarizeVisualResult(result);
1883
2069
  const coverage: VisualCoverage = { status: 'ran' };
1884
- return summary === undefined ? { issues, coverage } : { issues, summary, coverage };
2070
+ // ggui#1522 — the judge's spend rides the outcome; until now it was dropped here, before the harness saw it.
2071
+ const tokens: VisualLegTokens | undefined =
2072
+ result.inputTokens !== undefined && result.outputTokens !== undefined
2073
+ ? {
2074
+ inputTokens: result.inputTokens,
2075
+ outputTokens: result.outputTokens,
2076
+ ...(result.criteriaTokens !== undefined ? { criteria: result.criteriaTokens } : {}),
2077
+ }
2078
+ : undefined;
2079
+ return { issues, ...(summary !== undefined ? { summary } : {}), coverage, ...(tokens !== undefined ? { tokens } : {}) };
1885
2080
  }
1886
2081
 
1887
2082
  /** A judge-shaped issue as the harness's tier-2 issue: critical ⇒ a blocking fail, anything else ⇒ a warn. */
@@ -1894,6 +2089,7 @@ function toEvalIssue(issue: EvaluationIssue): EvalIssue {
1894
2089
  severity: issue.severity === 'critical' ? 'critical' : 'major',
1895
2090
  description: issue.description,
1896
2091
  fix: issue.fix || '',
2092
+ ...(issue.origin !== undefined ? { origin: issue.origin } : {}),
1897
2093
  };
1898
2094
  }
1899
2095
 
@@ -198,6 +198,8 @@ export function createRuntimeRenderCheck(
198
198
  ...queuedField(queuedMs),
199
199
  renderMs: result.stats.renderMs,
200
200
  ...load,
201
+ // ggui#1398: report-only — carried to the probe's meta for the bench, never an issue.
202
+ ...(result.stats.pendingAffordance !== undefined ? { pendingAffordance: result.stats.pendingAffordance } : {}),
201
203
  };
202
204
  },
203
205
  };
@@ -376,6 +378,22 @@ export function toEvalIssue(issue: RenderCheckIssue): EvalIssue | null {
376
378
  : `input priming failed: ${diag.inputPriming.error}`
377
379
  );
378
380
  }
381
+ if (diag?.walk) {
382
+ // ggui#1652: how far the walk over the card's screens got, so a
383
+ // multi-step card reads differently from a control that never rendered.
384
+ const w = diag.walk;
385
+ const stopped = {
386
+ explored: "every reachable control pressed",
387
+ "press-cap": "stopped at the press cap",
388
+ deadline: "stopped at the time budget",
389
+ "no-mount": "the card could not be mounted for the walk",
390
+ }[w.stoppedBy];
391
+ const named = w.namedControlSeen
392
+ ? "a control naming the action rendered, but pressing it did not dispatch the action"
393
+ : "no screen rendered a control naming the action";
394
+ const last = w.lastPressed.length ? `; last pressed: ${w.lastPressed.join(", ")}` : "";
395
+ diagParts.push(`walk: ${w.presses} press(es) across ${w.screens} screen(s), ${stopped}; ${named}${last}`);
396
+ }
379
397
  const diagSuffix = diagParts.length ? ` [observed: ${diagParts.join("; ")}]` : "";
380
398
 
381
399
  switch (issue.check) {
@@ -0,0 +1,111 @@
1
+ /**
2
+ * ggui#1398 leg 3 — the render check's stand-in for the runtime's pending
3
+ * state, and the rule for "the control visibly changed".
4
+ *
5
+ * In a served card, the runtime marks an action pending when the card's
6
+ * dispatch of it commits, and clears it when the session's next frame lands;
7
+ * `useActionPending` reads that state through wire's `ActionPendingContext`.
8
+ * The probe has no transport and no next frame, so it marks an action pending
9
+ * right after its own synthetic click really fired the action, looks at the
10
+ * clicked control, then clears it: one dispatch, one answer.
11
+ */
12
+ import type { ActionPendingSource } from "@ggui-ai/wire";
13
+
14
+ /** An {@link ActionPendingSource} the probe drives by hand. */
15
+ export interface ProbePendingSource extends ActionPendingSource {
16
+ /** Mark `actionName` pending and notify subscribers. */
17
+ mark(actionName: string): void;
18
+ /** Clear every pending action (the "answer landed"), notifying only if something was pending. */
19
+ clear(): void;
20
+ }
21
+
22
+ export function createProbePendingSource(): ProbePendingSource {
23
+ const pending = new Set<string>();
24
+ const listeners = new Set<() => void>();
25
+ const notify = (): void => {
26
+ for (const listener of [...listeners]) listener();
27
+ };
28
+ return {
29
+ isPending: (actionName) => pending.has(actionName),
30
+ subscribe: (listener) => {
31
+ listeners.add(listener);
32
+ return () => {
33
+ listeners.delete(listener);
34
+ };
35
+ },
36
+ mark: (actionName) => {
37
+ pending.add(actionName);
38
+ notify();
39
+ },
40
+ clear: () => {
41
+ if (pending.size === 0) return;
42
+ pending.clear();
43
+ notify();
44
+ },
45
+ };
46
+ }
47
+
48
+ /** What a control shows, for a before/after comparison around a dispatch. */
49
+ export interface ControlLook {
50
+ /** Still in the document; `false` when the card replaced the control. */
51
+ readonly connected: boolean;
52
+ /** `disabled`, or `aria-disabled="true"`. */
53
+ readonly disabled: boolean;
54
+ /** `aria-busy="true"`. */
55
+ readonly ariaBusy: boolean;
56
+ /** The control's text. */
57
+ readonly text: string;
58
+ }
59
+
60
+ /**
61
+ * The DOM surface {@link lookOf} reads: the probe's `MinimalElement` plus
62
+ * the optional `isConnected` a live DOM node carries, and an input's live
63
+ * `value` (an `<input type="submit">` shows its label through it).
64
+ */
65
+ export interface LookableElement {
66
+ readonly tagName?: string;
67
+ readonly textContent: string | null;
68
+ getAttribute(name: string): string | null;
69
+ readonly isConnected?: boolean;
70
+ readonly value?: unknown;
71
+ }
72
+
73
+ export function lookOf(el: LookableElement): ControlLook {
74
+ return {
75
+ connected: el.isConnected !== false,
76
+ // React renders `disabled={true}` as `disabled=""`, so presence is the test.
77
+ disabled: el.getAttribute("disabled") !== null || el.getAttribute("aria-disabled") === "true",
78
+ ariaBusy: el.getAttribute("aria-busy") === "true",
79
+ text:
80
+ el.tagName?.toLowerCase() === "input"
81
+ ? String(typeof el.value === "string" ? el.value : (el.getAttribute("value") ?? ""))
82
+ : (el.textContent ?? ""),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Whether `work` settles within `ms`. It never cancels `work`; it only stops
88
+ * waiting for it, so a phase can give up on a card that stalls.
89
+ */
90
+ export async function settlesWithin(ms: number, work: Promise<unknown>): Promise<boolean> {
91
+ let timer: ReturnType<typeof setTimeout> | undefined;
92
+ const expired = new Promise<false>((resolve) => {
93
+ timer = setTimeout(() => resolve(false), Math.max(0, ms));
94
+ });
95
+ try {
96
+ return await Promise.race([work.then(() => true), expired]);
97
+ } finally {
98
+ if (timer !== undefined) clearTimeout(timer);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Whether the control visibly changed between two looks: it became disabled
104
+ * or busy, its text changed (whitespace aside), or the card replaced it.
105
+ */
106
+ export function lookChanged(before: ControlLook, after: ControlLook): boolean {
107
+ if (before.connected && !after.connected) return true;
108
+ if (!before.disabled && after.disabled) return true;
109
+ if (!before.ariaBusy && after.ariaBusy) return true;
110
+ return before.text.trim() !== after.text.trim();
111
+ }