@ggui-ai/ui-gen 0.15.0 → 0.17.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/dist/adapters/generation-dispatch.d.ts +24 -4
- package/dist/adapters/generation-dispatch.js +5188 -3920
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js +33 -26
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +22 -13
- package/dist/advanced/index.js +4889 -3561
- package/dist/advanced/index.js.map +1 -1
- package/dist/blueprint-validator.js +8 -12
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.d.ts +19 -1
- package/dist/boilerplate.js +671 -254
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +17 -3
- package/dist/check/index.js +34 -25
- package/dist/check/index.js.map +1 -1
- package/dist/compose.js +1 -1
- package/dist/compose.js.map +1 -1
- package/dist/create-ui-generator-C8Ki9Wz1.d.ts +189 -0
- package/dist/design-mode-DLufhrNp.d.ts +61 -0
- package/dist/{dispatch-BuZfTh9r.d.ts → dispatch-D2htzi9h.d.ts} +4 -1
- package/dist/evaluation/axis-checks/index.d.ts +4 -3
- package/dist/evaluation/axis-checks/index.js +300 -14
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.d.ts +2 -1
- package/dist/evaluation/axis-checks/registry.js +253 -1
- package/dist/evaluation/axis-checks/registry.js.map +1 -1
- package/dist/evaluation/index.d.ts +297 -5
- package/dist/evaluation/index.js +4515 -120
- package/dist/evaluation/index.js.map +1 -1
- package/dist/fragments/index.js +1 -1
- package/dist/fragments/index.js.map +1 -1
- package/dist/{generate-BTfvELgl.d.ts → generate-9IeNT4ZY.d.ts} +10 -1
- package/dist/harness/check/runtime-render/index.d.ts +3 -1
- package/dist/harness/check/runtime-render/index.js +8 -12
- package/dist/harness/check/runtime-render/index.js.map +1 -1
- package/dist/harness/index.d.ts +3 -1
- package/dist/harness/index.js +913 -441
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/llm-trace-sink.d.ts +3 -3
- package/dist/harness/llm-trace-sink.js.map +1 -1
- package/dist/harness/prompts.d.ts +2 -1
- package/dist/harness/prompts.js +6 -3
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/result-types.d.ts +17 -5
- package/dist/harness/result-types.js.map +1 -1
- package/dist/harness/runtime.d.ts +28 -3
- package/dist/harness/runtime.js +702 -274
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +39 -1
- package/dist/harness/types-public.js +913 -441
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +2 -1
- package/dist/index.d.ts +32 -6
- package/dist/index.js +4899 -3560
- package/dist/index.js.map +1 -1
- package/dist/patch.d.ts +3 -1
- package/dist/{prompts-BVhtH3M1.d.ts → prompts-YU9piOgs.d.ts} +21 -45
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.js.map +1 -1
- package/dist/styling-profile-CxfvV48g.d.ts +35 -0
- package/dist/tools/get-primitives-ts.d.ts +1 -1
- package/dist/tools/get-primitives-ts.js +1 -1
- package/dist/tools/get-primitives-ts.js.map +1 -1
- package/dist/{types-public-CkNWEov9.d.ts → types-public-DVSssdc-.d.ts} +85 -5
- package/dist/validation/index.d.ts +1 -1
- package/dist/validation/index.js +4 -4
- package/dist/validation/index.js.map +1 -1
- package/dist/workflows.d.ts +3 -1
- package/package.json +12 -12
- package/src/adapters/generation-dispatch.ts +62 -4
- package/src/adapters/provider-router.ts +7 -6
- package/src/adapters/tools.ts +5 -5
- package/src/advanced/generator.ts +11 -10
- package/src/advanced/index.ts +5 -4
- package/src/boilerplate/free-design-prompt.ts +336 -0
- package/src/boilerplate/generate.ts +10 -1
- package/src/boilerplate/hard-sections.ts +257 -0
- package/src/boilerplate/render.ts +32 -8
- package/src/boilerplate/styling-profile.ts +166 -0
- package/src/boilerplate/system-prompt.ts +101 -237
- package/src/boilerplate/templates/base-free.tsx.tmpl +24 -0
- package/src/check/run-tier0.ts +82 -20
- package/src/coding-agent/tools.ts +8 -2
- package/src/contract-context.ts +43 -14
- package/src/create-harness.ts +15 -0
- package/src/create-ui-generator.ts +112 -27
- package/src/design-mode.ts +192 -0
- package/src/design-system-docs.ts +24 -17
- package/src/effort.ts +48 -0
- package/src/evaluation/axis-checks/checks/grid.ts +148 -0
- package/src/evaluation/axis-checks/checks/universal.ts +244 -0
- package/src/evaluation/axis-checks/dispatch.ts +82 -13
- package/src/evaluation/axis-checks/extras.ts +5 -0
- package/src/evaluation/axis-checks/helpers.ts +12 -0
- package/src/evaluation/axis-checks/registry.ts +2 -0
- package/src/evaluation/cost-tracker.ts +2 -3
- package/src/evaluation/design-identity.ts +96 -0
- package/src/evaluation/index.ts +63 -2
- package/src/evaluation/llm-evaluator.ts +89 -21
- package/src/evaluation/theme-css.ts +14 -0
- package/src/evaluation/types-public.ts +137 -8
- package/src/evaluation/visual-evaluator.ts +759 -54
- package/src/fragments/render.ts +1 -1
- package/src/harness/check/runtime-render/prepare-mockup.ts +18 -11
- package/src/harness/coding/dupe-break.ts +0 -0
- package/src/harness/coding/init-session.ts +2 -0
- package/src/harness/coding/run-coding-turn.ts +9 -14
- package/src/harness/coding/run-eval-round.ts +46 -5
- package/src/harness/llm-trace-sink.ts +3 -3
- package/src/harness/result-types.ts +15 -3
- package/src/harness/runtime.ts +28 -0
- package/src/harness/types-public.ts +38 -0
- package/src/index.ts +28 -0
- package/src/internal/extract-call-signatures.ts +2 -2
- package/src/pin-fixtures.ts +98 -0
- package/src/providers/bedrock.ts +4 -4
- package/src/run-check.ts +11 -10
- package/src/tools/get-primitives-ts.ts +1 -1
- package/src/tools.ts +2 -1
- package/src/validation/allowed-imports.ts +8 -2
- package/src/validation/primitives.ts +1 -1
- package/dist/create-ui-generator-D6TKuv9V.d.ts +0 -106
|
@@ -11,15 +11,22 @@
|
|
|
11
11
|
// issues only visible when rendered: broken layouts, overlapping elements,
|
|
12
12
|
// poor visual hierarchy, missing whitespace.
|
|
13
13
|
|
|
14
|
+
import { buildStylingProfileJudgeBlock } from '../boilerplate/styling-profile.js';
|
|
15
|
+
import type { GenerationProfileInput } from '../boilerplate/styling-profile.js';
|
|
14
16
|
import { build } from 'esbuild';
|
|
15
|
-
import { getCssTokens } from '@ggui-ai/design/rendering';
|
|
17
|
+
import { fillFitRule, getCssTokens } from '@ggui-ai/design/rendering';
|
|
18
|
+
import { judgeDesignIdentity, type JudgeDesignIdentity } from './design-identity.js';
|
|
16
19
|
import { resolve, dirname } from 'path';
|
|
17
20
|
import { fileURLToPath } from 'url';
|
|
18
|
-
import {
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { existsSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
19
24
|
import { tmpdir } from 'os';
|
|
20
25
|
import { createVisionAgent, type AgentConfig } from '../harness/llm-router';
|
|
21
26
|
import type { EvaluationResult, EvaluationIssue, DimensionScores } from './types';
|
|
22
|
-
import type { EvalIssue } from './types-public.js';
|
|
27
|
+
import type { CanvasJudgeRecord, CanvasVisualSummary, EvalIssue, VisualEvalSummary } from './types-public.js';
|
|
28
|
+
import type { LaunchOptions } from 'puppeteer-core';
|
|
29
|
+
import { CANVAS_VIEWPORTS, displayModeForCanvas, type CanvasClass, type CanvasViewport } from '../design-mode.js';
|
|
23
30
|
|
|
24
31
|
// ---------------------------------------------------------------------------
|
|
25
32
|
// Types
|
|
@@ -36,12 +43,41 @@ export interface VisualEvalConfig {
|
|
|
36
43
|
sampleProps?: Record<string, unknown>;
|
|
37
44
|
/** Viewport dimensions for screenshot */
|
|
38
45
|
viewport?: { width: number; height: number };
|
|
46
|
+
/**
|
|
47
|
+
* Per-canvas judging — ARM-NEUTRAL (the same judge, prompt and
|
|
48
|
+
* threshold in every design mode). When set, the SAME rendered HTML
|
|
49
|
+
* shell is screenshotted once per class at `CANVAS_VIEWPORTS[class]`
|
|
50
|
+
* and judged once per class; the result carries one entry per class
|
|
51
|
+
* in `canvases`, and the single-shot fields aggregate: `finalScore` =
|
|
52
|
+
* mean of the canvas scores (rounded), `passed` = every canvas passed,
|
|
53
|
+
* `dimensions` = per-dimension means, `issues` = every canvas's issues
|
|
54
|
+
* (description prefixed `[<canvas>]`). Unset (or empty) = today's
|
|
55
|
+
* single screenshot at `viewport`; no `canvases` field on the result.
|
|
56
|
+
*/
|
|
57
|
+
canvases?: readonly CanvasClass[];
|
|
39
58
|
/**
|
|
40
59
|
* Optional provider-routing override — see
|
|
41
60
|
* `AgentConfig.routeOverride`. Threaded onto the agent this
|
|
42
61
|
* evaluator constructs so its multimodal call never falls back to
|
|
43
62
|
* `process.env` for credentials or model routing.
|
|
44
63
|
*/
|
|
64
|
+
/**
|
|
65
|
+
* Vision calls per judged canvas on the SAME captured frame, aggregated by
|
|
66
|
+
* the median (ggui#1072). Default 1 — a single judgement, byte-identical to
|
|
67
|
+
* the instrument before this option existed. K is an instrument dial: a
|
|
68
|
+
* run mixes K at its own peril.
|
|
69
|
+
*/
|
|
70
|
+
judgeK?: number;
|
|
71
|
+
/** The canvases sampled `judgeK` times; default = every judged canvas when `judgeK > 1`. */
|
|
72
|
+
judgeKCanvases?: readonly CanvasClass[];
|
|
73
|
+
/**
|
|
74
|
+
* The design `src` tree the judge bundles the component against
|
|
75
|
+
* (ggui#1042): an explicit input, so a judge-only re-judge names the tree
|
|
76
|
+
* it painted with. Default: the design package beside this one.
|
|
77
|
+
*/
|
|
78
|
+
designSrcDir?: string;
|
|
79
|
+
/** The mode the caller composed `cssTokens` in — a receipt on the row, never a switch (ggui#1076: judge and runtime must name the same mode). */
|
|
80
|
+
themeMode?: 'light' | 'dark';
|
|
45
81
|
routeOverride?: AgentConfig['routeOverride'];
|
|
46
82
|
/**
|
|
47
83
|
* Provider-429-retry observer — see `AgentConfig.onRetry`.
|
|
@@ -52,6 +88,116 @@ export interface VisualEvalConfig {
|
|
|
52
88
|
onRetry?: AgentConfig['onRetry'];
|
|
53
89
|
}
|
|
54
90
|
|
|
91
|
+
/** One canvas's verdict — screenshot + judge score at that class's viewport. */
|
|
92
|
+
export interface CanvasVisualResult {
|
|
93
|
+
canvas: CanvasClass;
|
|
94
|
+
viewport: CanvasViewport;
|
|
95
|
+
/** The judge's weighted score for this canvas (0-100). */
|
|
96
|
+
score: number;
|
|
97
|
+
/** `score >= passThreshold`. */
|
|
98
|
+
passed: boolean;
|
|
99
|
+
/** The PNG the judge saw — kept for artefact persistence + the human look. */
|
|
100
|
+
screenshotPng: Buffer;
|
|
101
|
+
/**
|
|
102
|
+
* The document's scroll height at this canvas's viewport, CSS px — what
|
|
103
|
+
* the box would have to be for nothing to be hidden (ggui#1027). `null`
|
|
104
|
+
* when the page could not be measured.
|
|
105
|
+
*/
|
|
106
|
+
contentHeight: number | null;
|
|
107
|
+
/** `contentHeight > viewport.height` — a measurement; what it MEANS is {@link canvasFitPolicy}'s. */
|
|
108
|
+
overflow: boolean;
|
|
109
|
+
/** How `score` was reached — always present (ggui#1072). */
|
|
110
|
+
judge: CanvasJudgeRecord;
|
|
111
|
+
/** How the judge composed the mount (ggui#1100): `'fill'` on every fullscreen canvas, absent on the inline card. */
|
|
112
|
+
fit?: 'fill';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** How a canvas is captured for the judge and what an overflow means there (ggui#1027). */
|
|
116
|
+
export type CaptureMode = 'viewport' | 'full-page';
|
|
117
|
+
export interface CanvasFitPolicy {
|
|
118
|
+
/** `viewport` = the judge sees the box the user sees; `full-page` = the whole scrolled document. */
|
|
119
|
+
readonly capture: CaptureMode;
|
|
120
|
+
/** The verdict an overflow earns on this canvas. */
|
|
121
|
+
readonly overflow: 'fail' | 'warn' | 'none';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The inline chat card is a BOX — the bubble bounds the component and it
|
|
126
|
+
* does not scroll — so the judge captures the viewport (a 1289 px hello on
|
|
127
|
+
* a 640 px card was scored 86 from a full-page capture that showed the
|
|
128
|
+
* judge what no user sees) and an overflow is a critical layout issue that
|
|
129
|
+
* fails the canvas. A phone's first screen may scroll, so the overflow is
|
|
130
|
+
* reported as a major issue and the score stands. Pages (`md`/`lg`/`xl`)
|
|
131
|
+
* scroll by design: measured, never judged.
|
|
132
|
+
*/
|
|
133
|
+
export function canvasFitPolicy(canvas: CanvasClass): CanvasFitPolicy {
|
|
134
|
+
switch (canvas) {
|
|
135
|
+
case 'xs-chat-card':
|
|
136
|
+
return { capture: 'viewport', overflow: 'fail' };
|
|
137
|
+
case 'mobile-fullscreen-small':
|
|
138
|
+
return { capture: 'full-page', overflow: 'warn' };
|
|
139
|
+
default:
|
|
140
|
+
return { capture: 'full-page', overflow: 'none' };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The mount element of the judge page (ggui#1100). Its class is the scope
|
|
146
|
+
* `fillFitRule` targets — the judge's counterpart of the runtime's mount list.
|
|
147
|
+
*/
|
|
148
|
+
export const JUDGE_SCOPE_CLASS = 'ggui-judge-scope';
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* How the judge composes a canvas (ggui#1100): the served runtime stretches
|
|
152
|
+
* the mounted root to the frame on every fullscreen surface (`fit: 'fill'`,
|
|
153
|
+
* ggui#1041/#1073/#1096) and leaves the inline card at its natural height —
|
|
154
|
+
* the judge does the same, or it scores a picture the user never sees (a
|
|
155
|
+
* content-sized hello on an empty 768×1024 page; candidate 34's hellos).
|
|
156
|
+
*/
|
|
157
|
+
export function canvasFit(canvas: CanvasClass): 'fill' | undefined {
|
|
158
|
+
return displayModeForCanvas(canvas) === 'fullscreen' ? 'fill' : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The deterministic fit issue — one per overflowing canvas, in the judge's issue shape so it rides the same channel. */
|
|
162
|
+
export function canvasOverflowIssue(
|
|
163
|
+
canvas: CanvasClass,
|
|
164
|
+
viewport: CanvasViewport,
|
|
165
|
+
contentHeight: number,
|
|
166
|
+
verdict: 'fail' | 'warn',
|
|
167
|
+
): EvaluationIssue {
|
|
168
|
+
const hidden = contentHeight - viewport.height;
|
|
169
|
+
return {
|
|
170
|
+
dimension: 'canvas-overflow',
|
|
171
|
+
severity: verdict === 'fail' ? 'critical' : 'major',
|
|
172
|
+
description:
|
|
173
|
+
`Rendered content is ${contentHeight}px tall on the ${canvas} canvas (${viewport.width}×${viewport.height}) — ` +
|
|
174
|
+
`${hidden}px ${verdict === 'fail' ? 'is cut off: the inline card does not scroll' : 'sits below the first screen'}.`,
|
|
175
|
+
fix:
|
|
176
|
+
'Fit the composition to the canvas: fewer and shorter sections, one compact row of chips, no hero taller than ' +
|
|
177
|
+
'a third of the box, no fixed min-heights or tall paddings — measure against the canvas, not the page.',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* `runVisualEvaluation`'s result. Identical to `EvaluationResult` when
|
|
183
|
+
* `config.canvases` is unset; with canvases set, `canvases` holds the
|
|
184
|
+
* per-class verdicts and the base fields aggregate them (see
|
|
185
|
+
* `VisualEvalConfig.canvases`).
|
|
186
|
+
*/
|
|
187
|
+
export interface VisualEvaluationResult extends EvaluationResult {
|
|
188
|
+
canvases?: CanvasVisualResult[];
|
|
189
|
+
/** The design tree the judge painted with (ggui#1042) — present on every per-canvas judgement. */
|
|
190
|
+
design?: JudgeDesignIdentity;
|
|
191
|
+
/** The mode `cssTokens` were composed in, when the caller said (ggui#1076). */
|
|
192
|
+
themeMode?: 'light' | 'dark';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Injection points for `runVisualEvaluation` — screenshot deps plus the judge call (tests). */
|
|
196
|
+
export interface VisualEvalDeps extends ScreenshotDeps {
|
|
197
|
+
/** The multimodal judge call (default: `callMultimodalLLM`). */
|
|
198
|
+
judge?: typeof callMultimodalLLM;
|
|
199
|
+
}
|
|
200
|
+
|
|
55
201
|
export interface VisualEvalContext {
|
|
56
202
|
/** Compiled JavaScript code (ESM with imports) */
|
|
57
203
|
compiledCode: string;
|
|
@@ -59,6 +205,8 @@ export interface VisualEvalContext {
|
|
|
59
205
|
originalPrompt: string;
|
|
60
206
|
/** Design system CSS tokens */
|
|
61
207
|
cssTokens?: string;
|
|
208
|
+
/** The app's generation profile (#991) — judged relative to, never against. */
|
|
209
|
+
profile?: GenerationProfileInput;
|
|
62
210
|
}
|
|
63
211
|
|
|
64
212
|
// ---------------------------------------------------------------------------
|
|
@@ -81,14 +229,82 @@ export interface VisualEvalContext {
|
|
|
81
229
|
* `<pkg>/dist/evaluation`) is `oss/packages/`; the design package is
|
|
82
230
|
* its direct child.
|
|
83
231
|
*/
|
|
84
|
-
|
|
232
|
+
/**
|
|
233
|
+
* The on-disk root of `@ggui-ai/ui-gen` itself. The bundle's entry lives
|
|
234
|
+
* in a temp dir, so bare imports in the GENERATED component (`@ggui-ai/wire`
|
|
235
|
+
* and every other allowlisted package) resolve nowhere from there — they
|
|
236
|
+
* resolve from this package's `node_modules` instead (`nodePaths`), the
|
|
237
|
+
* same place the harness's own imports come from.
|
|
238
|
+
*/
|
|
239
|
+
export function resolveUiGenPackageDir(): string {
|
|
85
240
|
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
86
|
-
return resolve(selfDir, '..', '..'
|
|
241
|
+
return resolve(selfDir, '..', '..');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Where `@ggui-ai/wire` actually is, from ui-gen's OWN module graph —
|
|
246
|
+
* layout-proof: a workspace checkout links it under `node_modules/`, a
|
|
247
|
+
* `pnpm deploy` tree puts it beside ui-gen under `.pnpm/…/node_modules/`,
|
|
248
|
+
* and a plain install nests it; `import.meta.resolve` answers for all
|
|
249
|
+
* three (directory candidates first, module graph last). Aliased
|
|
250
|
+
* explicitly in the judge's bundle so the generated component's wire
|
|
251
|
+
* import never depends on the directory layout the evaluator runs in.
|
|
252
|
+
*/
|
|
253
|
+
export function resolveWirePackageDir(): string | null {
|
|
254
|
+
const uiGenDir = resolveUiGenPackageDir();
|
|
255
|
+
const candidates = [
|
|
256
|
+
resolve(uiGenDir, 'node_modules', '@ggui-ai', 'wire'), // workspace / nested install
|
|
257
|
+
resolve(uiGenDir, '..', 'wire'), // pnpm deploy: `.pnpm/<ui-gen>@<ver>/node_modules/@ggui-ai/{ui-gen,wire}`
|
|
258
|
+
resolve(uiGenDir, '..', '..', '@ggui-ai', 'wire'), // hoisted flat install beside the scope dir
|
|
259
|
+
];
|
|
260
|
+
for (const dir of candidates) {
|
|
261
|
+
if (existsSync(resolve(dir, 'package.json'))) return dir;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
// Last resort: ask the module graph (Node ≥ 20.6). Not every runner
|
|
265
|
+
// supports it synchronously, hence the guarded position.
|
|
266
|
+
const entry = fileURLToPath(import.meta.resolve('@ggui-ai/wire'));
|
|
267
|
+
let dir = dirname(entry);
|
|
268
|
+
for (let i = 0; i < 6; i++) {
|
|
269
|
+
if (existsSync(resolve(dir, 'package.json'))) return dir;
|
|
270
|
+
dir = dirname(dir);
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// fall through — nodePaths still gets a chance
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The `@ggui-ai/design` package root — resolved by MODULE ID, never by counting
|
|
280
|
+
* directories (ggui#1110).
|
|
281
|
+
*
|
|
282
|
+
* The arithmetic this replaced (`…/'..','..','..','design'`) was correct only
|
|
283
|
+
* for the layout it was written against — `node_modules/@ggui-ai/ui-gen/dist/
|
|
284
|
+
* evaluation` → `node_modules/@ggui-ai/` + `design`. Under a deploy-flattened
|
|
285
|
+
* layout it landed one level too high, at `node_modules/design`, a path that is
|
|
286
|
+
* not the design package at all: the judge then walked a directory that does not
|
|
287
|
+
* exist, threw ENOENT inside every evaluation round on that deployment, and a
|
|
288
|
+
* bar refused cards no one could judge. `@ggui-ai/design` exports `./package.json`,
|
|
289
|
+
* so the module resolver answers correctly in every layout — scoped, flattened,
|
|
290
|
+
* hoisted — and the receipt keeps working inside the image.
|
|
291
|
+
*/
|
|
292
|
+
export function resolveDesignPackageDir(): string {
|
|
293
|
+
try {
|
|
294
|
+
return dirname(createRequire(import.meta.url).resolve('@ggui-ai/design/package.json'));
|
|
295
|
+
} catch {
|
|
296
|
+
// A runner whose module graph cannot see the package (a bundled judge, a
|
|
297
|
+
// test double). The layout guess is the last resort, and a wrong answer is
|
|
298
|
+
// now a NAMED absent receipt rather than a thrown round (`judgeDesignIdentity`).
|
|
299
|
+
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
300
|
+
return resolve(selfDir, '..', '..', '..', 'design');
|
|
301
|
+
}
|
|
87
302
|
}
|
|
88
303
|
|
|
89
304
|
async function bundleForRendering(
|
|
90
305
|
compiledCode: string,
|
|
91
306
|
sampleProps: Record<string, unknown>,
|
|
307
|
+
designSrc: string = resolve(resolveDesignPackageDir(), 'src'),
|
|
92
308
|
): Promise<string> {
|
|
93
309
|
// Write component + entry to temp files for esbuild
|
|
94
310
|
const tmpDir = resolve(tmpdir(), 'ggui-visual-eval-' + Date.now());
|
|
@@ -96,21 +312,50 @@ async function bundleForRendering(
|
|
|
96
312
|
const componentFile = resolve(tmpDir, 'component.tsx');
|
|
97
313
|
const entryFile = resolve(tmpDir, 'entry.tsx');
|
|
98
314
|
|
|
99
|
-
const designPkgDir =
|
|
315
|
+
const designPkgDir = resolve(designSrc, '..');
|
|
316
|
+
const wirePkgDir = resolveWirePackageDir();
|
|
100
317
|
|
|
101
318
|
// Write the compiled component as a separate module
|
|
102
319
|
writeFileSync(componentFile, compiledCode);
|
|
103
320
|
|
|
104
321
|
// Entry file imports the component and renders it
|
|
322
|
+
// The generated component is rendered the way a host renders it: inside
|
|
323
|
+
// `GguiWireProvider` with a stub config (dispatch/subscribe are no-ops —
|
|
324
|
+
// the judge sees the first paint, not the wire round-trip). Without the
|
|
325
|
+
// provider every `useAction` / `useStream` bearer threw
|
|
326
|
+
// "useWireContext must be used within a WireProvider", React unmounted
|
|
327
|
+
// the root and the judge scored a blank page. An error boundary turns a
|
|
328
|
+
// render throw into visible "Render error" text (which the rubric
|
|
329
|
+
// scores 0) instead of the same blank.
|
|
105
330
|
const entryCode = `
|
|
106
331
|
import React from 'react';
|
|
107
332
|
import { createRoot } from 'react-dom/client';
|
|
333
|
+
import { GguiWireProvider } from '@ggui-ai/wire';
|
|
108
334
|
import Component from './component.tsx';
|
|
109
335
|
|
|
110
336
|
const props = ${JSON.stringify(sampleProps)};
|
|
337
|
+
const wireConfig = {
|
|
338
|
+
app: { appId: 'visual-eval', appName: 'Visual evaluation' },
|
|
339
|
+
render: { sessionId: 'visual-eval', isConnected: true },
|
|
340
|
+
auth: { isAuthenticated: false },
|
|
341
|
+
dispatch: () => {},
|
|
342
|
+
subscribe: () => () => {},
|
|
343
|
+
};
|
|
344
|
+
class RenderErrorBoundary extends React.Component<{ children: React.ReactNode }, { message: string | null }> {
|
|
345
|
+
state = { message: null as string | null };
|
|
346
|
+
static getDerivedStateFromError(err: unknown) { return { message: err instanceof Error ? err.message : String(err) }; }
|
|
347
|
+
render() {
|
|
348
|
+
return this.state.message === null
|
|
349
|
+
? this.props.children
|
|
350
|
+
: React.createElement('div', { className: 'error' }, 'Render error: ' + this.state.message);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
111
353
|
const root = createRoot(document.getElementById('root')!);
|
|
112
354
|
try {
|
|
113
|
-
root.render(
|
|
355
|
+
root.render(
|
|
356
|
+
React.createElement(GguiWireProvider, { config: wireConfig },
|
|
357
|
+
React.createElement(RenderErrorBoundary, null, React.createElement(Component, props))),
|
|
358
|
+
);
|
|
114
359
|
} catch (err) {
|
|
115
360
|
root.render(React.createElement('div', { className: 'error' }, 'Render error: ' + (err as Error).message));
|
|
116
361
|
}
|
|
@@ -130,6 +375,10 @@ try {
|
|
|
130
375
|
jsxImportSource: 'react',
|
|
131
376
|
external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'],
|
|
132
377
|
alias: {
|
|
378
|
+
// The wire hooks: resolved from ui-gen's own module graph so the
|
|
379
|
+
// alias holds in a workspace checkout AND in a `pnpm deploy` image
|
|
380
|
+
// (where deps are siblings, not children, of this package).
|
|
381
|
+
...(wirePkgDir !== null ? { '@ggui-ai/wire': wirePkgDir } : {}),
|
|
133
382
|
// D1: generated code imports the single `@ggui-ai/design` barrel.
|
|
134
383
|
// The subpath aliases stay for any first-party / legacy code.
|
|
135
384
|
'@ggui-ai/design': resolve(designPkgDir, 'src', 'index.ts'),
|
|
@@ -138,6 +387,13 @@ try {
|
|
|
138
387
|
'@ggui-ai/design/compositions': resolve(designPkgDir, 'src', 'compositions', 'index.ts'),
|
|
139
388
|
'@ggui-ai/design/interact': resolve(designPkgDir, 'src', 'interact', 'index.ts'),
|
|
140
389
|
},
|
|
390
|
+
// Bare imports in the generated component resolve against ui-gen's
|
|
391
|
+
// own node_modules — the temp entry dir has none. Without this every
|
|
392
|
+
// wire-bearing component (`@ggui-ai/wire`) failed to bundle.
|
|
393
|
+
// Workspace layout (deps under this package's node_modules) and the
|
|
394
|
+
// `pnpm deploy` layout (deps as siblings of this package) — both
|
|
395
|
+
// searched for any other allowlisted bare import.
|
|
396
|
+
nodePaths: [resolve(resolveUiGenPackageDir(), 'node_modules'), resolve(resolveUiGenPackageDir(), '..')],
|
|
141
397
|
logLevel: 'silent',
|
|
142
398
|
});
|
|
143
399
|
|
|
@@ -151,6 +407,7 @@ try {
|
|
|
151
407
|
function buildRenderHTML(
|
|
152
408
|
bundledCode: string,
|
|
153
409
|
cssTokens?: string,
|
|
410
|
+
fit?: 'fill',
|
|
154
411
|
): string {
|
|
155
412
|
// Default to the design tokens production's no-theme branch injects
|
|
156
413
|
// (getCssTokens → default theme, light) — ggui#613: under the s4
|
|
@@ -174,10 +431,11 @@ function buildRenderHTML(
|
|
|
174
431
|
color: var(--ggui-color-neutral-900, #111827);
|
|
175
432
|
}
|
|
176
433
|
.error { color: #dc2626; padding: 16px; font-family: monospace; white-space: pre-wrap; }
|
|
434
|
+
${fit === 'fill' ? fillFitRule(JUDGE_SCOPE_CLASS) : ''}
|
|
177
435
|
</style>
|
|
178
436
|
</head>
|
|
179
437
|
<body>
|
|
180
|
-
<div id="root"></div>
|
|
438
|
+
<div id="root"${fit === 'fill' ? ` class="${JUDGE_SCOPE_CLASS}"` : ''}></div>
|
|
181
439
|
<script type="importmap">
|
|
182
440
|
{
|
|
183
441
|
"imports": {
|
|
@@ -199,27 +457,130 @@ ${bundledCode}
|
|
|
199
457
|
// Screenshot capture (optional puppeteer dependency)
|
|
200
458
|
// ---------------------------------------------------------------------------
|
|
201
459
|
|
|
460
|
+
/** The page surface the capture uses — a structural subset of puppeteer's `Page`. */
|
|
461
|
+
export interface ScreenshotPage {
|
|
462
|
+
setContent(html: string, options: { waitUntil: 'load'; timeout: number }): Promise<void>;
|
|
463
|
+
waitForNetworkIdle(options: { idleTime: number; timeout: number }): Promise<void>;
|
|
464
|
+
waitForSelector(selector: string, options: { timeout: number }): Promise<unknown>;
|
|
465
|
+
/** Evaluates a JS expression in the page and returns its serialised value — the fit measurement (ggui#1027). */
|
|
466
|
+
evaluate(expression: string): Promise<unknown>;
|
|
467
|
+
screenshot(options: { type: 'png'; fullPage: boolean }): Promise<Uint8Array>;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** The browser surface the capture uses — a structural subset of puppeteer's `Browser`. */
|
|
471
|
+
export interface ScreenshotBrowser {
|
|
472
|
+
newPage(): Promise<ScreenshotPage>;
|
|
473
|
+
close(): Promise<void>;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export type ScreenshotLauncher = (options: LaunchOptions) => Promise<ScreenshotBrowser>;
|
|
477
|
+
|
|
478
|
+
/** The two members of `@sparticuz/chromium` the fallback launch path reads. */
|
|
479
|
+
export interface ChromiumProvider {
|
|
480
|
+
readonly args: string[];
|
|
481
|
+
executablePath(): Promise<string>;
|
|
482
|
+
}
|
|
483
|
+
|
|
202
484
|
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
485
|
+
* Injection points for the capture — every default is the production
|
|
486
|
+
* path; tests replace them to pin branch selection without launching a
|
|
487
|
+
* browser.
|
|
205
488
|
*/
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
489
|
+
export interface ScreenshotDeps {
|
|
490
|
+
/** Launches the browser (default: `puppeteer-core`'s `launch`). */
|
|
491
|
+
launch?: ScreenshotLauncher;
|
|
492
|
+
/** Environment the executable-path override is read from (default: `process.env`). */
|
|
493
|
+
env?: NodeJS.ProcessEnv;
|
|
494
|
+
/** Loads `@sparticuz/chromium` (default: dynamic import) — only consulted when no override is set. */
|
|
495
|
+
loadChromium?: () => Promise<ChromiumProvider>;
|
|
496
|
+
/** Post-load settle time before the screenshot, ms (default 1000). */
|
|
497
|
+
settleMs?: number;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Chromium flags the `PUPPETEER_EXECUTABLE_PATH` launch adds: a system
|
|
502
|
+
* Chromium inside a container has no user namespace for the sandbox and
|
|
503
|
+
* `/dev/shm` is too small for a render surface.
|
|
504
|
+
*/
|
|
505
|
+
export const EXECUTABLE_PATH_LAUNCH_ARGS: readonly string[] = ['--no-sandbox', '--disable-dev-shm-usage'];
|
|
506
|
+
|
|
507
|
+
const DEFAULT_SCREENSHOT_VIEWPORT = { width: 1280, height: 800 } as const;
|
|
214
508
|
|
|
215
|
-
|
|
216
|
-
|
|
509
|
+
async function loadSparticuzChromium(): Promise<ChromiumProvider> {
|
|
510
|
+
const chromium = await import('@sparticuz/chromium');
|
|
511
|
+
return chromium.default;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Pick the browser binary. `PUPPETEER_EXECUTABLE_PATH` (a system
|
|
516
|
+
* Chromium — the bench runner image, a dev box) wins and skips the
|
|
517
|
+
* `@sparticuz/chromium` load entirely; otherwise the bundled Lambda
|
|
518
|
+
* Chromium supplies both binary and flags, as before.
|
|
519
|
+
*/
|
|
520
|
+
export async function resolveLaunchOptions(
|
|
521
|
+
viewport: { width: number; height: number },
|
|
522
|
+
deps: Pick<ScreenshotDeps, 'env' | 'loadChromium'> = {},
|
|
523
|
+
): Promise<LaunchOptions> {
|
|
524
|
+
const env = deps.env ?? process.env;
|
|
525
|
+
const override = env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
|
526
|
+
if (override) {
|
|
527
|
+
return {
|
|
528
|
+
executablePath: override,
|
|
529
|
+
args: [...EXECUTABLE_PATH_LAUNCH_ARGS],
|
|
217
530
|
defaultViewport: viewport,
|
|
218
|
-
executablePath: await chromium.default.executablePath(),
|
|
219
531
|
headless: true,
|
|
220
532
|
};
|
|
533
|
+
}
|
|
534
|
+
const chromium = await (deps.loadChromium ?? loadSparticuzChromium)();
|
|
535
|
+
return {
|
|
536
|
+
args: chromium.args,
|
|
537
|
+
defaultViewport: viewport,
|
|
538
|
+
executablePath: await chromium.executablePath(),
|
|
539
|
+
headless: true,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function launchWithPuppeteer(options: LaunchOptions): Promise<ScreenshotBrowser> {
|
|
544
|
+
const puppeteer = await import('puppeteer-core');
|
|
545
|
+
return puppeteer.default.launch(options);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Take a screenshot of the rendered component.
|
|
550
|
+
* Returns the PNG bytes, or null if no browser could be launched.
|
|
551
|
+
*/
|
|
552
|
+
/** Screenshot plus the reason when none could be taken — the judge's `unavailableReason`. */
|
|
553
|
+
export interface ScreenshotAttempt {
|
|
554
|
+
readonly png: Buffer | null;
|
|
555
|
+
readonly reason?: string;
|
|
556
|
+
/** The document's scroll height at the viewport (ggui#1027); `null` when unmeasurable. */
|
|
557
|
+
readonly contentHeight: number | null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** The expression the fit measurement evaluates in the page — the taller of the two scroll heights. */
|
|
561
|
+
export const CONTENT_HEIGHT_EXPRESSION =
|
|
562
|
+
'Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0)';
|
|
221
563
|
|
|
222
|
-
|
|
564
|
+
/** Measure the rendered document's height; a failed measurement is reported, never a failed capture. */
|
|
565
|
+
async function measureContentHeight(page: ScreenshotPage): Promise<number | null> {
|
|
566
|
+
try {
|
|
567
|
+
const h = await page.evaluate(CONTENT_HEIGHT_EXPRESSION);
|
|
568
|
+
return typeof h === 'number' && Number.isFinite(h) ? Math.round(h) : null;
|
|
569
|
+
} catch (e) {
|
|
570
|
+
console.warn(`[visual-eval] content height unavailable: ${e instanceof Error ? e.message : String(e)}`);
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export async function captureScreenshotDetailed(
|
|
576
|
+
html: string,
|
|
577
|
+
viewport: { width: number; height: number } = DEFAULT_SCREENSHOT_VIEWPORT,
|
|
578
|
+
deps: ScreenshotDeps = {},
|
|
579
|
+
capture: CaptureMode = 'full-page',
|
|
580
|
+
): Promise<ScreenshotAttempt> {
|
|
581
|
+
try {
|
|
582
|
+
const launchOptions = await resolveLaunchOptions(viewport, deps);
|
|
583
|
+
const browser = await (deps.launch ?? launchWithPuppeteer)(launchOptions);
|
|
223
584
|
try {
|
|
224
585
|
const page = await browser.newPage();
|
|
225
586
|
// puppeteer ≥24.43 narrowed setContent's waitUntil to
|
|
@@ -229,23 +590,33 @@ async function captureScreenshot(
|
|
|
229
590
|
await page.waitForNetworkIdle({ idleTime: 500, timeout: 15000 }).catch(() => {});
|
|
230
591
|
await page.waitForSelector('#root > *', { timeout: 10000 }).catch(() => {});
|
|
231
592
|
// Wait a bit for CSS/fonts to settle
|
|
232
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
233
|
-
const
|
|
234
|
-
|
|
593
|
+
await new Promise((r) => setTimeout(r, deps.settleMs ?? 1000));
|
|
594
|
+
const contentHeight = await measureContentHeight(page);
|
|
595
|
+
const screenshot = await page.screenshot({ type: 'png', fullPage: capture === 'full-page' });
|
|
596
|
+
return { png: Buffer.from(screenshot), contentHeight };
|
|
235
597
|
} finally {
|
|
236
598
|
await browser.close();
|
|
237
599
|
}
|
|
238
600
|
} catch (e) {
|
|
239
|
-
|
|
240
|
-
|
|
601
|
+
const reason = `screenshot failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
602
|
+
console.warn(`[visual-eval] ${reason}`);
|
|
603
|
+
return { png: null, reason, contentHeight: null };
|
|
241
604
|
}
|
|
242
605
|
}
|
|
243
606
|
|
|
607
|
+
export async function captureScreenshot(
|
|
608
|
+
html: string,
|
|
609
|
+
viewport: { width: number; height: number } = DEFAULT_SCREENSHOT_VIEWPORT,
|
|
610
|
+
deps: ScreenshotDeps = {},
|
|
611
|
+
): Promise<Buffer | null> {
|
|
612
|
+
return (await captureScreenshotDetailed(html, viewport, deps)).png;
|
|
613
|
+
}
|
|
614
|
+
|
|
244
615
|
// ---------------------------------------------------------------------------
|
|
245
616
|
// Multimodal LLM evaluation
|
|
246
617
|
// ---------------------------------------------------------------------------
|
|
247
618
|
|
|
248
|
-
const VISUAL_EVAL_PROMPT = `You are a visual quality evaluator for ggui — a platform where AI agents generate React UI components on demand.
|
|
619
|
+
export const VISUAL_EVAL_PROMPT = `You are a visual quality evaluator for ggui — a platform where AI agents generate React UI components on demand.
|
|
249
620
|
|
|
250
621
|
## Context
|
|
251
622
|
The screenshot shows a generated React component built from a design system with CSS variable theming. The component was created by an LLM to fulfill a user's request. Your job is to evaluate whether the generated UI achieves the user's goal AND looks professionally designed.
|
|
@@ -272,6 +643,8 @@ The screenshot shows a generated React component built from a design system with
|
|
|
272
643
|
- Error messages like "Render error" or blank white space = rendering score 0
|
|
273
644
|
- A component that renders but is just a wall of text with no structure should score low on layout and hierarchy
|
|
274
645
|
|
|
646
|
+
List AT MOST 6 issues, most severe first, one sentence each for "description" and "fix" — a longer list is cut off and lost.
|
|
647
|
+
|
|
275
648
|
Respond with ONLY a JSON object (no markdown, no explanation):
|
|
276
649
|
{
|
|
277
650
|
"completeness": <0-100>,
|
|
@@ -294,14 +667,37 @@ Respond with ONLY a JSON object (no markdown, no explanation):
|
|
|
294
667
|
* governs credentials and model routing (including the Anthropic
|
|
295
668
|
* Bedrock path) instead of `process.env`.
|
|
296
669
|
*/
|
|
670
|
+
/**
|
|
671
|
+
* Output budget for the judge's answer. 1500 cut a board's issue list
|
|
672
|
+
* mid-array (`Expected ',' or ']' after array element`, six kanban cells
|
|
673
|
+
* on 2026-09-11) — deterministic on the content, so a same-prompt retry
|
|
674
|
+
* could never clear it. 4096 fits the bounded list the prompt now asks for
|
|
675
|
+
* with room; the salvage path covers the remainder.
|
|
676
|
+
*/
|
|
677
|
+
export const VISUAL_JUDGE_MAX_OUTPUT_TOKENS = 4096;
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The judge instrument's name and content digest, for the record a run
|
|
681
|
+
* stamps beside every visual score. The NAME is for readers; the DIGEST
|
|
682
|
+
* is computed from the prompt text at module load, so a stamped row can
|
|
683
|
+
* never claim a prompt version it did not run — change the prompt and the
|
|
684
|
+
* digest moves with it (the pin ties the name to the bounded-list text).
|
|
685
|
+
*/
|
|
686
|
+
export const VISUAL_JUDGE_PROMPT_VERSION = 'v2-bounded-issues';
|
|
687
|
+
export const VISUAL_JUDGE_PROMPT_DIGEST = createHash('sha256').update(VISUAL_EVAL_PROMPT, 'utf8').digest('hex');
|
|
688
|
+
|
|
297
689
|
async function callMultimodalLLM(
|
|
298
690
|
config: VisualEvalConfig,
|
|
299
691
|
model: string,
|
|
300
692
|
prompt: string,
|
|
301
693
|
screenshot: Buffer,
|
|
302
694
|
originalPrompt: string,
|
|
695
|
+
profileBlock = '',
|
|
303
696
|
) {
|
|
304
|
-
const userPrompt =
|
|
697
|
+
const userPrompt =
|
|
698
|
+
`## Original Request\n${originalPrompt}\n\n` +
|
|
699
|
+
(profileBlock.length > 0 ? `${profileBlock}\n\n` : '') +
|
|
700
|
+
'Evaluate the screenshot of the generated component.';
|
|
305
701
|
const agent = createVisionAgent({
|
|
306
702
|
provider: config.provider === 'claude' ? 'anthropic' : config.provider,
|
|
307
703
|
model,
|
|
@@ -313,7 +709,7 @@ async function callMultimodalLLM(
|
|
|
313
709
|
prompt,
|
|
314
710
|
userPrompt,
|
|
315
711
|
{ mediaType: 'image/png', base64: screenshot.toString('base64') },
|
|
316
|
-
|
|
712
|
+
VISUAL_JUDGE_MAX_OUTPUT_TOKENS,
|
|
317
713
|
);
|
|
318
714
|
}
|
|
319
715
|
|
|
@@ -330,49 +726,297 @@ async function callMultimodalLLM(
|
|
|
330
726
|
*
|
|
331
727
|
* Returns null if puppeteer is not available (graceful fallback).
|
|
332
728
|
*/
|
|
729
|
+
/**
|
|
730
|
+
* The judge's answer WITH the reason when it could not judge: `result` is
|
|
731
|
+
* null exactly when no screenshot could be taken (no browser, a launch or
|
|
732
|
+
* in-page failure) or the component could not be bundled; `unavailableReason`
|
|
733
|
+
* names why in words and `canvas` the canvas it happened at. A production
|
|
734
|
+
* caller records the reason in its receipt — a `judge_unavailable` that only
|
|
735
|
+
* says "null" cost a log dive on 2026-09-10 (puppeteer-core missing in the
|
|
736
|
+
* deploy image).
|
|
737
|
+
*/
|
|
738
|
+
export interface VisualEvalDetailed {
|
|
739
|
+
readonly result: VisualEvaluationResult | null;
|
|
740
|
+
readonly unavailableReason?: string;
|
|
741
|
+
readonly canvas?: CanvasClass;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
/** One judge call parsed, retried ONCE on a malformed answer (#1017); the FIRST reason is kept verbatim. */
|
|
746
|
+
type JudgedAnswer =
|
|
747
|
+
| { readonly kind: 'ok'; readonly result: EvaluationResult; readonly inputTokens: number; readonly outputTokens: number }
|
|
748
|
+
| { readonly kind: 'unparsable'; readonly reason: string };
|
|
749
|
+
|
|
750
|
+
/** How many vision calls this canvas gets (ggui#1072): `judgeK` when the canvas is sampled, else 1. */
|
|
751
|
+
function judgeCountFor(config: VisualEvalConfig, canvas: CanvasClass): number {
|
|
752
|
+
const k = Math.max(1, Math.floor(config.judgeK ?? 1));
|
|
753
|
+
if (k === 1) return 1;
|
|
754
|
+
return config.judgeKCanvases === undefined || config.judgeKCanvases.includes(canvas) ? k : 1;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** The median of a non-empty list — the lower middle for an even count, so the decision is always one judge's number. */
|
|
758
|
+
function medianOf(values: readonly number[]): number {
|
|
759
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
760
|
+
return sorted[Math.floor((sorted.length - 1) / 2)]!;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Population σ, rounded to one decimal; 0 for one sample. */
|
|
764
|
+
function populationSigma(values: readonly number[]): number {
|
|
765
|
+
if (values.length < 2) return 0;
|
|
766
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
767
|
+
const variance = values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length;
|
|
768
|
+
return Math.round(Math.sqrt(variance) * 10) / 10;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function judgeAndParse(
|
|
772
|
+
judge: typeof callMultimodalLLM,
|
|
773
|
+
config: VisualEvalConfig,
|
|
774
|
+
model: string,
|
|
775
|
+
screenshot: Buffer,
|
|
776
|
+
originalPrompt: string,
|
|
777
|
+
profileBlock: string,
|
|
778
|
+
canvas?: CanvasClass,
|
|
779
|
+
): Promise<JudgedAnswer> {
|
|
780
|
+
let firstReason: string | undefined;
|
|
781
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
782
|
+
const response = await judge(config, model, VISUAL_EVAL_PROMPT, screenshot, originalPrompt, profileBlock);
|
|
783
|
+
try {
|
|
784
|
+
const result = parseVisualResponse(response.text, config.passThreshold);
|
|
785
|
+
return { kind: 'ok', result, inputTokens: response.inputTokens, outputTokens: response.outputTokens };
|
|
786
|
+
} catch (e) {
|
|
787
|
+
// A TRUNCATED answer (cut inside `issues` at the output cap) is deterministic on
|
|
788
|
+
// the content — retrying the same prompt reproduces it. The four dimensions are
|
|
789
|
+
// emitted before `issues`, so the score is recoverable from the closed prefix.
|
|
790
|
+
const salvaged = salvageTruncatedVisualAnswer(response.text, config.passThreshold);
|
|
791
|
+
if (salvaged !== null) {
|
|
792
|
+
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`);
|
|
793
|
+
return { kind: 'ok', result: salvaged, inputTokens: response.inputTokens, outputTokens: response.outputTokens };
|
|
794
|
+
}
|
|
795
|
+
const reason = `judge answer unparsable: ${e instanceof Error ? e.message : String(e)}`;
|
|
796
|
+
if (attempt === 1) {
|
|
797
|
+
firstReason = reason;
|
|
798
|
+
console.warn(`[visual-eval] visual_judge_retry${canvas ? ` canvas=${canvas}` : ''}: ${reason}`);
|
|
799
|
+
} else {
|
|
800
|
+
console.warn(`[visual-eval] visual_judge_retry failed again${canvas ? ` canvas=${canvas}` : ''}: ${reason}`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return { kind: 'unparsable', reason: firstReason ?? 'judge answer unparsable' };
|
|
805
|
+
}
|
|
806
|
+
|
|
333
807
|
export async function runVisualEvaluation(
|
|
334
808
|
context: VisualEvalContext,
|
|
335
809
|
config: VisualEvalConfig,
|
|
336
|
-
|
|
810
|
+
deps: VisualEvalDeps = {},
|
|
811
|
+
): Promise<VisualEvaluationResult | null> {
|
|
812
|
+
return (await runVisualEvaluationDetailed(context, config, deps)).result;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export async function runVisualEvaluationDetailed(
|
|
816
|
+
context: VisualEvalContext,
|
|
817
|
+
config: VisualEvalConfig,
|
|
818
|
+
deps: VisualEvalDeps = {},
|
|
819
|
+
): Promise<VisualEvalDetailed> {
|
|
337
820
|
const startTime = Date.now();
|
|
821
|
+
const judge = deps.judge ?? callMultimodalLLM;
|
|
822
|
+
// ggui#1042: the design tree is an input and a part of the judgement's identity.
|
|
823
|
+
const designSrc = config.designSrcDir ?? resolve(resolveDesignPackageDir(), 'src');
|
|
824
|
+
const design = judgeDesignIdentity(designSrc);
|
|
825
|
+
const stamp = <T extends VisualEvaluationResult>(r: T): T => ({
|
|
826
|
+
...r,
|
|
827
|
+
...(design !== null ? { design } : {}),
|
|
828
|
+
...(config.themeMode !== undefined ? { themeMode: config.themeMode } : {}),
|
|
829
|
+
});
|
|
338
830
|
|
|
339
831
|
// Bundle component + design system into a single JS file
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
config.sampleProps ?? {},
|
|
343
|
-
)
|
|
832
|
+
let bundledCode: string;
|
|
833
|
+
try {
|
|
834
|
+
bundledCode = await bundleForRendering(context.compiledCode, config.sampleProps ?? {}, designSrc);
|
|
835
|
+
} catch (e) {
|
|
836
|
+
const unavailableReason = `bundle failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
837
|
+
console.warn(`[visual-eval] ${unavailableReason}`);
|
|
838
|
+
return { result: null, unavailableReason };
|
|
839
|
+
}
|
|
344
840
|
console.log(`[visual-eval] bundled: ${bundledCode.length}B (${Date.now() - startTime}ms)`);
|
|
345
841
|
|
|
346
|
-
// Build the HTML page
|
|
842
|
+
// Build the HTML page — ONE shell; per-canvas mode re-renders it at each viewport.
|
|
347
843
|
const html = buildRenderHTML(bundledCode, context.cssTokens);
|
|
844
|
+
const model = config.model ?? getDefaultVisualModel(config.provider);
|
|
348
845
|
|
|
349
|
-
//
|
|
350
|
-
|
|
846
|
+
// ── Per-canvas mode: one screenshot + one judge call per class ──
|
|
847
|
+
if (config.canvases !== undefined && config.canvases.length > 0) {
|
|
848
|
+
const perCanvas: CanvasVisualResult[] = [];
|
|
849
|
+
const perCanvasResults: EvaluationResult[] = [];
|
|
850
|
+
for (const canvas of config.canvases) {
|
|
851
|
+
const viewport = CANVAS_VIEWPORTS[canvas];
|
|
852
|
+
const policy = canvasFitPolicy(canvas);
|
|
853
|
+
// ggui#1100: the page is composed per canvas with the runtime's fit — fullscreen canvases fill, the inline card does not.
|
|
854
|
+
const fit = canvasFit(canvas);
|
|
855
|
+
const canvasHtml = fit !== undefined ? buildRenderHTML(bundledCode, context.cssTokens, fit) : html;
|
|
856
|
+
const attempt = await captureScreenshotDetailed(canvasHtml, viewport, deps, policy.capture);
|
|
857
|
+
const screenshot = attempt.png;
|
|
858
|
+
if (!screenshot) {
|
|
859
|
+
const unavailableReason = attempt.reason ?? 'no browser available';
|
|
860
|
+
console.warn(`[visual-eval] ${unavailableReason} at canvas ${canvas} — skipping visual evaluation`);
|
|
861
|
+
return { result: null, unavailableReason, canvas };
|
|
862
|
+
}
|
|
863
|
+
// ggui#1072: k vision calls on the SAME frame; the decision value is the median.
|
|
864
|
+
const k = judgeCountFor(config, canvas);
|
|
865
|
+
const profileBlock = buildStylingProfileJudgeBlock(context.profile);
|
|
866
|
+
const answers = await Promise.all(
|
|
867
|
+
Array.from({ length: k }, () => judgeAndParse(judge, config, model, screenshot, context.originalPrompt, profileBlock, canvas)),
|
|
868
|
+
);
|
|
869
|
+
const parsed = answers.filter((a): a is Extract<JudgedAnswer, { kind: 'ok' }> => a.kind === 'ok');
|
|
870
|
+
if (parsed.length === 0) {
|
|
871
|
+
const first = answers.find((a): a is Extract<JudgedAnswer, { kind: 'unparsable' }> => a.kind === 'unparsable');
|
|
872
|
+
return { result: null, unavailableReason: first?.reason ?? 'judge answer unparsable', canvas };
|
|
873
|
+
}
|
|
874
|
+
const samples = parsed.map((a) => a.result.finalScore);
|
|
875
|
+
const median = medianOf(samples);
|
|
876
|
+
const representative = parsed[samples.indexOf(median)]!;
|
|
877
|
+
const result = representative.result;
|
|
878
|
+
result.finalScore = median;
|
|
879
|
+
result.passed = median >= config.passThreshold;
|
|
880
|
+
result.inputTokens = parsed.reduce((sum, a) => sum + a.inputTokens, 0);
|
|
881
|
+
result.outputTokens = parsed.reduce((sum, a) => sum + a.outputTokens, 0);
|
|
882
|
+
const response = { inputTokens: result.inputTokens, outputTokens: result.outputTokens };
|
|
883
|
+
const judgeRecord: CanvasJudgeRecord = {
|
|
884
|
+
k,
|
|
885
|
+
rule: 'median',
|
|
886
|
+
samples,
|
|
887
|
+
sigma: populationSigma(samples),
|
|
888
|
+
notes: parsed.map((a) => a.result.critique ?? ''),
|
|
889
|
+
};
|
|
890
|
+
// The fit verdict (ggui#1027): deterministic, in the judge's issue channel.
|
|
891
|
+
const contentHeight = attempt.contentHeight;
|
|
892
|
+
const overflow = contentHeight !== null && contentHeight > viewport.height;
|
|
893
|
+
if (contentHeight !== null && contentHeight > viewport.height && policy.overflow !== 'none') {
|
|
894
|
+
result.issues.push(canvasOverflowIssue(canvas, viewport, contentHeight, policy.overflow));
|
|
895
|
+
if (policy.overflow === 'fail') result.passed = false;
|
|
896
|
+
}
|
|
897
|
+
perCanvasResults.push(result);
|
|
898
|
+
perCanvas.push({
|
|
899
|
+
canvas,
|
|
900
|
+
viewport,
|
|
901
|
+
score: result.finalScore,
|
|
902
|
+
passed: result.passed,
|
|
903
|
+
screenshotPng: screenshot,
|
|
904
|
+
contentHeight,
|
|
905
|
+
overflow,
|
|
906
|
+
judge: judgeRecord,
|
|
907
|
+
...(fit !== undefined ? { fit } : {}),
|
|
908
|
+
});
|
|
909
|
+
console.log(
|
|
910
|
+
`[visual-eval] canvas=${canvas} ${viewport.width}×${viewport.height} score=${result.finalScore}${k > 1 ? ` (median of ${judgeRecord.samples.length}/${k}: ${judgeRecord.samples.join(',')} σ=${judgeRecord.sigma})` : ''} ` +
|
|
911
|
+
`${result.passed ? 'pass' : 'FAIL'} | content=${contentHeight ?? '?'}px${overflow ? ` OVERFLOW (${policy.overflow})` : ''} ` +
|
|
912
|
+
`| in=${response.inputTokens} out=${response.outputTokens}`,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
const aggregate = aggregateCanvasResults(perCanvas, perCanvasResults);
|
|
916
|
+
const elapsed = Date.now() - startTime;
|
|
917
|
+
console.log(
|
|
918
|
+
`[visual-eval] score=${aggregate.finalScore} (mean of ${perCanvas.length} canvases; ` +
|
|
919
|
+
`${aggregate.passed ? 'every canvas passed' : `${perCanvas.filter((c) => !c.passed).length} failed`}) (${elapsed}ms)`,
|
|
920
|
+
);
|
|
921
|
+
return { result: stamp(aggregate) };
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ── Single-shot mode (today's path) ──
|
|
925
|
+
const attempt = await captureScreenshotDetailed(html, config.viewport, deps);
|
|
926
|
+
const screenshot = attempt.png;
|
|
351
927
|
if (!screenshot) {
|
|
352
|
-
|
|
353
|
-
|
|
928
|
+
const unavailableReason = attempt.reason ?? 'no browser available';
|
|
929
|
+
console.warn(`[visual-eval] ${unavailableReason} — skipping visual evaluation`);
|
|
930
|
+
return { result: null, unavailableReason };
|
|
354
931
|
}
|
|
355
932
|
console.log(`[visual-eval] screenshot: ${screenshot.length}B (${Date.now() - startTime}ms)`);
|
|
356
933
|
|
|
357
|
-
// Send to multimodal LLM
|
|
358
|
-
const
|
|
359
|
-
|
|
934
|
+
// Send to multimodal LLM (one retry on a malformed answer — #1017)
|
|
935
|
+
const answer = await judgeAndParse(
|
|
936
|
+
judge,
|
|
360
937
|
config,
|
|
361
938
|
model,
|
|
362
|
-
VISUAL_EVAL_PROMPT,
|
|
363
939
|
screenshot,
|
|
364
940
|
context.originalPrompt,
|
|
941
|
+
buildStylingProfileJudgeBlock(context.profile),
|
|
365
942
|
);
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
result
|
|
370
|
-
result.
|
|
943
|
+
if (answer.kind === 'unparsable') {
|
|
944
|
+
return { result: null, unavailableReason: answer.reason };
|
|
945
|
+
}
|
|
946
|
+
const result = answer.result;
|
|
947
|
+
result.inputTokens = answer.inputTokens;
|
|
948
|
+
result.outputTokens = answer.outputTokens;
|
|
949
|
+
const response = { inputTokens: answer.inputTokens, outputTokens: answer.outputTokens };
|
|
371
950
|
|
|
372
951
|
const elapsed = Date.now() - startTime;
|
|
373
952
|
console.log(`[visual-eval] score=${result.finalScore} (${elapsed}ms) | in=${response.inputTokens} out=${response.outputTokens}`);
|
|
374
953
|
|
|
375
|
-
return result;
|
|
954
|
+
return { result: stamp(result) };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Fold per-canvas verdicts into the single-shot fields: `finalScore` =
|
|
959
|
+
* rounded mean, `passed` = every canvas passed, `dimensions` = rounded
|
|
960
|
+
* per-dimension means, `issues` = union (description prefixed with the
|
|
961
|
+
* canvas), `critique` = one line per canvas, tokens summed.
|
|
962
|
+
*/
|
|
963
|
+
function aggregateCanvasResults(
|
|
964
|
+
perCanvas: readonly CanvasVisualResult[],
|
|
965
|
+
results: readonly EvaluationResult[],
|
|
966
|
+
): VisualEvaluationResult {
|
|
967
|
+
const n = results.length;
|
|
968
|
+
const mean = (pick: (r: EvaluationResult) => number): number =>
|
|
969
|
+
Math.round(results.reduce((sum, r) => sum + pick(r), 0) / n);
|
|
970
|
+
const dimensions: DimensionScores = {
|
|
971
|
+
completeness: mean((r) => r.dimensions.completeness),
|
|
972
|
+
visualPolish: mean((r) => r.dimensions.visualPolish),
|
|
973
|
+
interactivity: mean((r) => r.dimensions.interactivity),
|
|
974
|
+
accessibility: mean((r) => r.dimensions.accessibility),
|
|
975
|
+
codeQuality: mean((r) => r.dimensions.codeQuality),
|
|
976
|
+
};
|
|
977
|
+
const issues: EvaluationIssue[] = [];
|
|
978
|
+
const critiques: string[] = [];
|
|
979
|
+
let inputTokens = 0;
|
|
980
|
+
let outputTokens = 0;
|
|
981
|
+
results.forEach((r, i) => {
|
|
982
|
+
const canvas = perCanvas[i]!.canvas;
|
|
983
|
+
for (const issue of r.issues) issues.push({ ...issue, description: `[${canvas}] ${issue.description}` });
|
|
984
|
+
if (r.critique) critiques.push(`${canvas}: ${r.critique}`);
|
|
985
|
+
inputTokens += r.inputTokens ?? 0;
|
|
986
|
+
outputTokens += r.outputTokens ?? 0;
|
|
987
|
+
});
|
|
988
|
+
return {
|
|
989
|
+
passed: perCanvas.every((c) => c.passed),
|
|
990
|
+
finalScore: mean((r) => r.finalScore),
|
|
991
|
+
dimensions,
|
|
992
|
+
issues,
|
|
993
|
+
...(critiques.length > 0 ? { critique: critiques.join('\n') } : {}),
|
|
994
|
+
inputTokens,
|
|
995
|
+
outputTokens,
|
|
996
|
+
canvases: [...perCanvas],
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** The per-canvas verdicts without the PNGs — what the harness stamps on `EvalResult.visual`. */
|
|
1001
|
+
export function summarizeVisualResult(result: VisualEvaluationResult): VisualEvalSummary | undefined {
|
|
1002
|
+
if (result.canvases === undefined) return undefined;
|
|
1003
|
+
const canvases: CanvasVisualSummary[] = result.canvases.map((c) => ({
|
|
1004
|
+
canvas: c.canvas,
|
|
1005
|
+
viewport: c.viewport,
|
|
1006
|
+
score: c.score,
|
|
1007
|
+
passed: c.passed,
|
|
1008
|
+
contentHeight: c.contentHeight,
|
|
1009
|
+
overflow: c.overflow,
|
|
1010
|
+
judge: c.judge,
|
|
1011
|
+
...(c.fit !== undefined ? { fit: c.fit } : {}),
|
|
1012
|
+
}));
|
|
1013
|
+
return {
|
|
1014
|
+
score: result.finalScore,
|
|
1015
|
+
passed: result.passed,
|
|
1016
|
+
canvases,
|
|
1017
|
+
...(result.design !== undefined ? { design: result.design } : {}),
|
|
1018
|
+
...(result.themeMode !== undefined ? { themeMode: result.themeMode } : {}),
|
|
1019
|
+
};
|
|
376
1020
|
}
|
|
377
1021
|
|
|
378
1022
|
/**
|
|
@@ -386,6 +1030,52 @@ export { bundleForRendering, buildRenderHTML, callMultimodalLLM };
|
|
|
386
1030
|
// Response parsing
|
|
387
1031
|
// ---------------------------------------------------------------------------
|
|
388
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Recover a judge answer cut off inside its `issues` array: all four
|
|
1035
|
+
* dimensions must be present in the prefix (else null); issue objects
|
|
1036
|
+
* that closed are kept; `critique` (emitted last) is usually lost and is
|
|
1037
|
+
* replaced by a truncation note so the receipt says what happened.
|
|
1038
|
+
*/
|
|
1039
|
+
export function salvageTruncatedVisualAnswer(text: string, passThreshold: number): EvaluationResult | null {
|
|
1040
|
+
const dim = (name: string): number | null => {
|
|
1041
|
+
const m = text.match(new RegExp(`"${name}"\\s*:\\s*(\\d+(?:\\.\\d+)?)`));
|
|
1042
|
+
return m ? Number(m[1]) : null;
|
|
1043
|
+
};
|
|
1044
|
+
const completeness = dim('completeness');
|
|
1045
|
+
const layout = dim('layout');
|
|
1046
|
+
const hierarchy = dim('hierarchy');
|
|
1047
|
+
const aesthetics = dim('aesthetics');
|
|
1048
|
+
if (completeness === null || layout === null || hierarchy === null || aesthetics === null) return null;
|
|
1049
|
+
const issues: Array<{ dimension: string; severity: string; description: string; fix: string }> = [];
|
|
1050
|
+
const issuesAt = text.indexOf('"issues"');
|
|
1051
|
+
if (issuesAt >= 0) {
|
|
1052
|
+
for (const m of text.slice(issuesAt).matchAll(/\{[^{}]*\}/g)) {
|
|
1053
|
+
try {
|
|
1054
|
+
const o = JSON.parse(m[0]) as { dimension?: unknown; severity?: unknown; description?: unknown; fix?: unknown };
|
|
1055
|
+
if (typeof o.description === 'string') {
|
|
1056
|
+
issues.push({
|
|
1057
|
+
dimension: typeof o.dimension === 'string' ? o.dimension : 'layout',
|
|
1058
|
+
severity: typeof o.severity === 'string' ? o.severity : 'minor',
|
|
1059
|
+
description: o.description,
|
|
1060
|
+
fix: typeof o.fix === 'string' ? o.fix : '',
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
} catch {
|
|
1064
|
+
// an unclosed object at the cut — skipped by construction
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
const rebuilt = JSON.stringify({
|
|
1069
|
+
completeness,
|
|
1070
|
+
layout,
|
|
1071
|
+
hierarchy,
|
|
1072
|
+
aesthetics,
|
|
1073
|
+
issues,
|
|
1074
|
+
critique: `[truncated judge answer — score recovered from the closed prefix; ${issues.length} issue(s) kept, the rest and the critique were cut]`,
|
|
1075
|
+
});
|
|
1076
|
+
return parseVisualResponse(rebuilt, passThreshold);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
389
1079
|
function parseVisualResponse(text: string, passThreshold: number): EvaluationResult {
|
|
390
1080
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
391
1081
|
if (!jsonMatch) {
|
|
@@ -448,17 +1138,30 @@ function getDefaultVisualModel(provider: 'claude' | 'google'): string {
|
|
|
448
1138
|
// Tier 2 adapter — returns EvalIssue[]
|
|
449
1139
|
// ---------------------------------------------------------------------------
|
|
450
1140
|
|
|
1141
|
+
/** What the harness's eval round consumes from the visual leg. */
|
|
1142
|
+
export interface VisualEvalOutcome {
|
|
1143
|
+
/** Tier-2 issues (empty when no browser is available). */
|
|
1144
|
+
issues: EvalIssue[];
|
|
1145
|
+
/**
|
|
1146
|
+
* Per-canvas summary — present ONLY when `config.canvases` was set and
|
|
1147
|
+
* the leg ran; the harness stamps it on `EvalResult.visual`. Absent on
|
|
1148
|
+
* the single-shot path (byte-identical to the pre-canvas result).
|
|
1149
|
+
*/
|
|
1150
|
+
summary?: VisualEvalSummary;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
451
1153
|
/**
|
|
452
|
-
* Run visual evaluation and return tier 2 EvalIssues
|
|
1154
|
+
* Run visual evaluation and return tier 2 EvalIssues (+ the per-canvas
|
|
1155
|
+
* summary when canvases were requested).
|
|
453
1156
|
*/
|
|
454
1157
|
export async function runVisualEval(
|
|
455
1158
|
context: VisualEvalContext,
|
|
456
1159
|
config: VisualEvalConfig,
|
|
457
|
-
): Promise<
|
|
1160
|
+
): Promise<VisualEvalOutcome> {
|
|
458
1161
|
const result = await runVisualEvaluation(context, config);
|
|
459
|
-
if (!result) return []; // puppeteer not available
|
|
1162
|
+
if (!result) return { issues: [] }; // puppeteer not available
|
|
460
1163
|
|
|
461
|
-
|
|
1164
|
+
const issues: EvalIssue[] = (result.issues || []).map(issue => ({
|
|
462
1165
|
tier: 2 as const,
|
|
463
1166
|
result: (issue.severity === 'critical' ? 'fail' : 'warn') as 'fail' | 'warn',
|
|
464
1167
|
category: 'visual' as const,
|
|
@@ -467,4 +1170,6 @@ export async function runVisualEval(
|
|
|
467
1170
|
description: issue.description,
|
|
468
1171
|
fix: issue.fix || '',
|
|
469
1172
|
}));
|
|
1173
|
+
const summary = summarizeVisualResult(result);
|
|
1174
|
+
return summary === undefined ? { issues } : { issues, summary };
|
|
470
1175
|
}
|