@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.
- package/README.md +2 -2
- package/dist/{adapter-Cg_rkpGq.d.ts → adapter-BJwn74rx.d.ts} +49 -1
- package/dist/adapters/generation-dispatch.d.ts +8 -5
- package/dist/adapters/generation-dispatch.js +1118 -193
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js +27 -6
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +10 -9
- package/dist/advanced/index.js +1126 -202
- package/dist/advanced/index.js.map +1 -1
- package/dist/blueprint-validator.js +416 -92
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.d.ts +2 -2
- package/dist/boilerplate.js +10 -3
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +3 -2
- package/dist/check/index.js +9 -2
- package/dist/check/index.js.map +1 -1
- package/dist/{create-ui-generator-DFnnArrv.d.ts → create-ui-generator-N4gRXpcL.d.ts} +3 -4
- package/dist/{design-mode-DLufhrNp.d.ts → design-mode-DGWN_Gkm.d.ts} +1 -1
- package/dist/{dispatch-CStl9eAw.d.ts → dispatch-BUoXRj_r.d.ts} +2 -2
- package/dist/evaluation/axis-checks/index.d.ts +5 -4
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.d.ts +4 -3
- package/dist/evaluation/index.d.ts +107 -539
- package/dist/evaluation/index.js +552 -55
- package/dist/evaluation/index.js.map +1 -1
- package/dist/evaluation/types.d.ts +17 -0
- package/dist/evaluation/types.js.map +1 -1
- package/dist/{generate-9IeNT4ZY.d.ts → generate-tSO_shQ0.d.ts} +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +5 -5
- package/dist/harness/check/runtime-render/index.js +416 -92
- package/dist/harness/check/runtime-render/index.js.map +1 -1
- package/dist/harness/check/runtime-render/render-check-worker.js +393 -88
- package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
- package/dist/harness/index.d.ts +4 -5
- package/dist/harness/index.js +36 -5
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/prompts.d.ts +2 -2
- package/dist/harness/prompts.js +10 -1
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/result-types.d.ts +17 -3
- package/dist/harness/result-types.js.map +1 -1
- package/dist/harness/runtime.d.ts +14 -6
- package/dist/harness/runtime.js +11 -4
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +4 -2
- package/dist/harness/types-public.js +10 -3
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +4 -3
- package/dist/index.d.ts +46 -11
- package/dist/index.js +1128 -200
- package/dist/index.js.map +1 -1
- package/dist/patch.d.ts +3 -3
- package/dist/{prompts-YU9piOgs.d.ts → prompts-BhNUuRoZ.d.ts} +1 -1
- package/dist/tools/render-check-worker.js +8 -1
- package/dist/tools/render-check-worker.js.map +1 -1
- package/dist/{types-public-BJTLpzWn.d.ts → types-public-B7wn4ce5.d.ts} +384 -3
- package/dist/validation/index.d.ts +1 -1
- package/dist/validation/index.js +1 -1
- package/dist/validation/index.js.map +1 -1
- package/dist/visual-evaluator-C9L-o6ap.d.ts +639 -0
- package/dist/workflows.d.ts +3 -3
- package/package.json +12 -12
- package/src/adapters/generation-dispatch.ts +2 -0
- package/src/boilerplate/free-design-prompt.ts +3 -3
- package/src/boilerplate/hard-sections.ts +1 -1
- package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +3 -1
- package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +3 -1
- package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +3 -1
- package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +3 -1
- package/src/check/run-tier0.ts +6 -3
- package/src/contract-context.ts +1 -1
- package/src/evaluation/cost-tracker.ts +54 -6
- package/src/evaluation/criteria/edge-probe.ts +286 -0
- package/src/evaluation/criteria/edge.ts +202 -0
- package/src/evaluation/criteria/resolve.ts +55 -7
- package/src/evaluation/index.ts +5 -1
- package/src/evaluation/judge-spend.ts +48 -0
- package/src/evaluation/types-public.ts +91 -0
- package/src/evaluation/types.ts +10 -0
- package/src/evaluation/visual-evaluator.ts +236 -40
- package/src/harness/check/runtime-render/adapter.ts +18 -0
- package/src/harness/check/runtime-render/probe-pending.ts +111 -0
- package/src/harness/check/runtime-render/render-check-host.ts +10 -6
- package/src/harness/check/runtime-render/render-check-worker.ts +6 -3
- package/src/harness/check/runtime-render/render-check.ts +417 -133
- package/src/harness/check/runtime-render/walk.ts +200 -0
- package/src/harness/coding/assemble-result.ts +2 -0
- package/src/harness/coding/generate-task-runner.ts +27 -0
- package/src/harness/coding/init-session.ts +5 -3
- package/src/harness/coding/run-coding-turn.ts +5 -1
- package/src/harness/coding/run-eval-round.ts +63 -3
- package/src/harness/in-loop-evaluation.ts +39 -0
- package/src/harness/llm-router.ts +58 -1
- package/src/harness/result-types.ts +9 -0
- package/src/harness/runtime.ts +9 -0
- package/src/harness/types-public.ts +10 -1
- package/src/index.ts +5 -0
- package/src/internal/verdict-line.ts +33 -0
- package/src/taught-tokens.ts +32 -0
- package/src/tools/render-check-worker.ts +2 -1
- package/src/tools/render-check.ts +8 -7
- package/src/validation/primitives.ts +1 -1
- package/dist/llm-router-nfO7wPec.d.ts +0 -267
- 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
|
-
|
|
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
|
-
):
|
|
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
|
-
):
|
|
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):
|
|
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):
|
|
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):
|
|
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):
|
|
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
|
-
| {
|
|
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
|
-
|
|
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
|
-
//
|
|
1268
|
-
// the
|
|
1269
|
-
//
|
|
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
|
-
|
|
1273
|
-
|
|
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
|
|
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:
|
|
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: `[
|
|
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:
|
|
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
|
|
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
|
-
...(
|
|
1968
|
+
...(criteria.answers !== undefined ? { criteriaAnswers: criteria.answers } : {}),
|
|
1969
|
+
criteriaCounts: criteria.counts,
|
|
1814
1970
|
};
|
|
1815
1971
|
}
|
|
1816
1972
|
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
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)
|
|
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')
|
|
1824
|
-
|
|
1825
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|