@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.
Files changed (123) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +24 -4
  2. package/dist/adapters/generation-dispatch.js +5188 -3920
  3. package/dist/adapters/generation-dispatch.js.map +1 -1
  4. package/dist/adapters/index.js +33 -26
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/advanced/index.d.ts +22 -13
  7. package/dist/advanced/index.js +4889 -3561
  8. package/dist/advanced/index.js.map +1 -1
  9. package/dist/blueprint-validator.js +8 -12
  10. package/dist/blueprint-validator.js.map +1 -1
  11. package/dist/boilerplate.d.ts +19 -1
  12. package/dist/boilerplate.js +671 -254
  13. package/dist/boilerplate.js.map +1 -1
  14. package/dist/check/index.d.ts +17 -3
  15. package/dist/check/index.js +34 -25
  16. package/dist/check/index.js.map +1 -1
  17. package/dist/compose.js +1 -1
  18. package/dist/compose.js.map +1 -1
  19. package/dist/create-ui-generator-C8Ki9Wz1.d.ts +189 -0
  20. package/dist/design-mode-DLufhrNp.d.ts +61 -0
  21. package/dist/{dispatch-BuZfTh9r.d.ts → dispatch-D2htzi9h.d.ts} +4 -1
  22. package/dist/evaluation/axis-checks/index.d.ts +4 -3
  23. package/dist/evaluation/axis-checks/index.js +300 -14
  24. package/dist/evaluation/axis-checks/index.js.map +1 -1
  25. package/dist/evaluation/axis-checks/registry.d.ts +2 -1
  26. package/dist/evaluation/axis-checks/registry.js +253 -1
  27. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  28. package/dist/evaluation/index.d.ts +297 -5
  29. package/dist/evaluation/index.js +4515 -120
  30. package/dist/evaluation/index.js.map +1 -1
  31. package/dist/fragments/index.js +1 -1
  32. package/dist/fragments/index.js.map +1 -1
  33. package/dist/{generate-BTfvELgl.d.ts → generate-9IeNT4ZY.d.ts} +10 -1
  34. package/dist/harness/check/runtime-render/index.d.ts +3 -1
  35. package/dist/harness/check/runtime-render/index.js +8 -12
  36. package/dist/harness/check/runtime-render/index.js.map +1 -1
  37. package/dist/harness/index.d.ts +3 -1
  38. package/dist/harness/index.js +913 -441
  39. package/dist/harness/index.js.map +1 -1
  40. package/dist/harness/llm-trace-sink.d.ts +3 -3
  41. package/dist/harness/llm-trace-sink.js.map +1 -1
  42. package/dist/harness/prompts.d.ts +2 -1
  43. package/dist/harness/prompts.js +6 -3
  44. package/dist/harness/prompts.js.map +1 -1
  45. package/dist/harness/result-types.d.ts +17 -5
  46. package/dist/harness/result-types.js.map +1 -1
  47. package/dist/harness/runtime.d.ts +28 -3
  48. package/dist/harness/runtime.js +702 -274
  49. package/dist/harness/runtime.js.map +1 -1
  50. package/dist/harness/types-public.d.ts +39 -1
  51. package/dist/harness/types-public.js +913 -441
  52. package/dist/harness/types-public.js.map +1 -1
  53. package/dist/harness/validator-trace-sink.d.ts +2 -1
  54. package/dist/index.d.ts +32 -6
  55. package/dist/index.js +4899 -3560
  56. package/dist/index.js.map +1 -1
  57. package/dist/patch.d.ts +3 -1
  58. package/dist/{prompts-BVhtH3M1.d.ts → prompts-YU9piOgs.d.ts} +21 -45
  59. package/dist/providers/index.d.ts +2 -2
  60. package/dist/providers/index.js.map +1 -1
  61. package/dist/styling-profile-CxfvV48g.d.ts +35 -0
  62. package/dist/tools/get-primitives-ts.d.ts +1 -1
  63. package/dist/tools/get-primitives-ts.js +1 -1
  64. package/dist/tools/get-primitives-ts.js.map +1 -1
  65. package/dist/{types-public-CkNWEov9.d.ts → types-public-DVSssdc-.d.ts} +85 -5
  66. package/dist/validation/index.d.ts +1 -1
  67. package/dist/validation/index.js +4 -4
  68. package/dist/validation/index.js.map +1 -1
  69. package/dist/workflows.d.ts +3 -1
  70. package/package.json +12 -12
  71. package/src/adapters/generation-dispatch.ts +62 -4
  72. package/src/adapters/provider-router.ts +7 -6
  73. package/src/adapters/tools.ts +5 -5
  74. package/src/advanced/generator.ts +11 -10
  75. package/src/advanced/index.ts +5 -4
  76. package/src/boilerplate/free-design-prompt.ts +336 -0
  77. package/src/boilerplate/generate.ts +10 -1
  78. package/src/boilerplate/hard-sections.ts +257 -0
  79. package/src/boilerplate/render.ts +32 -8
  80. package/src/boilerplate/styling-profile.ts +166 -0
  81. package/src/boilerplate/system-prompt.ts +101 -237
  82. package/src/boilerplate/templates/base-free.tsx.tmpl +24 -0
  83. package/src/check/run-tier0.ts +82 -20
  84. package/src/coding-agent/tools.ts +8 -2
  85. package/src/contract-context.ts +43 -14
  86. package/src/create-harness.ts +15 -0
  87. package/src/create-ui-generator.ts +112 -27
  88. package/src/design-mode.ts +192 -0
  89. package/src/design-system-docs.ts +24 -17
  90. package/src/effort.ts +48 -0
  91. package/src/evaluation/axis-checks/checks/grid.ts +148 -0
  92. package/src/evaluation/axis-checks/checks/universal.ts +244 -0
  93. package/src/evaluation/axis-checks/dispatch.ts +82 -13
  94. package/src/evaluation/axis-checks/extras.ts +5 -0
  95. package/src/evaluation/axis-checks/helpers.ts +12 -0
  96. package/src/evaluation/axis-checks/registry.ts +2 -0
  97. package/src/evaluation/cost-tracker.ts +2 -3
  98. package/src/evaluation/design-identity.ts +96 -0
  99. package/src/evaluation/index.ts +63 -2
  100. package/src/evaluation/llm-evaluator.ts +89 -21
  101. package/src/evaluation/theme-css.ts +14 -0
  102. package/src/evaluation/types-public.ts +137 -8
  103. package/src/evaluation/visual-evaluator.ts +759 -54
  104. package/src/fragments/render.ts +1 -1
  105. package/src/harness/check/runtime-render/prepare-mockup.ts +18 -11
  106. package/src/harness/coding/dupe-break.ts +0 -0
  107. package/src/harness/coding/init-session.ts +2 -0
  108. package/src/harness/coding/run-coding-turn.ts +9 -14
  109. package/src/harness/coding/run-eval-round.ts +46 -5
  110. package/src/harness/llm-trace-sink.ts +3 -3
  111. package/src/harness/result-types.ts +15 -3
  112. package/src/harness/runtime.ts +28 -0
  113. package/src/harness/types-public.ts +38 -0
  114. package/src/index.ts +28 -0
  115. package/src/internal/extract-call-signatures.ts +2 -2
  116. package/src/pin-fixtures.ts +98 -0
  117. package/src/providers/bedrock.ts +4 -4
  118. package/src/run-check.ts +11 -10
  119. package/src/tools/get-primitives-ts.ts +1 -1
  120. package/src/tools.ts +2 -1
  121. package/src/validation/allowed-imports.ts +8 -2
  122. package/src/validation/primitives.ts +1 -1
  123. 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 { writeFileSync, mkdirSync, unlinkSync } from 'fs';
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
- export function resolveDesignPackageDir(): string {
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, '..', '..', '..', 'design');
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 = resolveDesignPackageDir();
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(React.createElement(Component, props));
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
- * Take a screenshot of the rendered component.
204
- * Returns base64-encoded PNG or null if puppeteer is not available.
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
- async function captureScreenshot(
207
- html: string,
208
- viewport: { width: number; height: number } = { width: 1280, height: 800 },
209
- ): Promise<Buffer | null> {
210
- try {
211
- // puppeteer-core + @sparticuz/chromium — works on both local dev & Lambda
212
- const puppeteer = await import('puppeteer-core');
213
- const chromium = await import('@sparticuz/chromium');
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
- const launchOptions = {
216
- args: chromium.default.args,
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
- const browser = await puppeteer.default.launch(launchOptions);
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 screenshot = await page.screenshot({ type: 'png', fullPage: true }) as Buffer;
234
- return screenshot;
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
- console.warn(`[visual-eval] screenshot failed: ${e instanceof Error ? e.message : e}`);
240
- return null;
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 = `## Original Request\n${originalPrompt}\n\nEvaluate the screenshot of the generated component.`;
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
- 1500,
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
- ): Promise<EvaluationResult | null> {
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
- const bundledCode = await bundleForRendering(
341
- context.compiledCode,
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
- // Take screenshot
350
- const screenshot = await captureScreenshot(html, config.viewport);
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
- console.warn('[visual-eval] no browser available — skipping visual evaluation');
353
- return null;
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 model = config.model ?? getDefaultVisualModel(config.provider);
359
- const response = await callMultimodalLLM(
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
- // Parse response
368
- const result = parseVisualResponse(response.text, config.passThreshold);
369
- result.inputTokens = response.inputTokens;
370
- result.outputTokens = response.outputTokens;
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<EvalIssue[]> {
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
- return (result.issues || []).map(issue => ({
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
  }