@ggui-ai/ui-gen 0.16.0 → 0.18.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 (105) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +6 -3
  2. package/dist/adapters/generation-dispatch.js +5161 -4312
  3. package/dist/adapters/generation-dispatch.js.map +1 -1
  4. package/dist/adapters/index.js +8 -5
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/advanced/index.d.ts +12 -8
  7. package/dist/advanced/index.js +4685 -3801
  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 +5 -2
  12. package/dist/boilerplate.js +120 -13
  13. package/dist/boilerplate.js.map +1 -1
  14. package/dist/check/index.d.ts +2 -2
  15. package/dist/check/index.js +72 -0
  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-CCIeQaEe.d.ts → create-ui-generator-C8Ki9Wz1.d.ts} +18 -2
  20. package/dist/{design-mode-BHEE7flj.d.ts → design-mode-DLufhrNp.d.ts} +0 -6
  21. package/dist/{dispatch-BA5dJcg-.d.ts → dispatch-D2htzi9h.d.ts} +2 -2
  22. package/dist/evaluation/axis-checks/index.d.ts +4 -4
  23. package/dist/evaluation/axis-checks/index.js +347 -13
  24. package/dist/evaluation/axis-checks/index.js.map +1 -1
  25. package/dist/evaluation/axis-checks/registry.d.ts +2 -2
  26. package/dist/evaluation/axis-checks/registry.js +302 -1
  27. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  28. package/dist/evaluation/index.d.ts +160 -8
  29. package/dist/evaluation/index.js +752 -81
  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-8L_bc2iP.d.ts → generate-9IeNT4ZY.d.ts} +1 -1
  34. package/dist/harness/check/runtime-render/index.d.ts +3 -2
  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 -2
  38. package/dist/harness/index.js +410 -255
  39. package/dist/harness/index.js.map +1 -1
  40. package/dist/harness/prompts.d.ts +2 -2
  41. package/dist/harness/prompts.js +7 -3
  42. package/dist/harness/prompts.js.map +1 -1
  43. package/dist/harness/result-types.d.ts +3 -3
  44. package/dist/harness/runtime.d.ts +13 -4
  45. package/dist/harness/runtime.js +131 -20
  46. package/dist/harness/runtime.js.map +1 -1
  47. package/dist/harness/types-public.d.ts +13 -2
  48. package/dist/harness/types-public.js +410 -255
  49. package/dist/harness/types-public.js.map +1 -1
  50. package/dist/harness/validator-trace-sink.d.ts +2 -2
  51. package/dist/index.d.ts +29 -7
  52. package/dist/index.js +4691 -3805
  53. package/dist/index.js.map +1 -1
  54. package/dist/patch.d.ts +3 -2
  55. package/dist/{prompts-k6m6reqe.d.ts → prompts-YU9piOgs.d.ts} +12 -46
  56. package/dist/styling-profile-CxfvV48g.d.ts +35 -0
  57. package/dist/tools/get-primitives-ts.d.ts +1 -1
  58. package/dist/tools/get-primitives-ts.js +1 -1
  59. package/dist/tools/get-primitives-ts.js.map +1 -1
  60. package/dist/{types-public-D24JLJgl.d.ts → types-public-DVSssdc-.d.ts} +39 -3
  61. package/dist/validation/index.d.ts +1 -1
  62. package/dist/validation/index.js +1 -1
  63. package/dist/validation/index.js.map +1 -1
  64. package/dist/workflows.d.ts +3 -2
  65. package/package.json +12 -12
  66. package/src/adapters/generation-dispatch.ts +6 -1
  67. package/src/boilerplate/free-design-prompt.ts +9 -0
  68. package/src/boilerplate/generate.ts +8 -1
  69. package/src/boilerplate/hard-sections.ts +45 -1
  70. package/src/boilerplate/styling-profile.ts +166 -0
  71. package/src/boilerplate/system-prompt.ts +22 -8
  72. package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +5 -3
  73. package/src/boilerplate/templates/layouts/fullscreen-mobile.tsx.tmpl +3 -3
  74. package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +5 -3
  75. package/src/check/run-tier0.ts +154 -0
  76. package/src/contract-context.ts +28 -14
  77. package/src/create-harness.ts +4 -0
  78. package/src/create-ui-generator.ts +41 -2
  79. package/src/design-mode.ts +11 -1
  80. package/src/design-system-docs.ts +7 -4
  81. package/src/effort.ts +48 -0
  82. package/src/evaluation/axis-checks/checks/grid.ts +148 -0
  83. package/src/evaluation/axis-checks/checks/universal.ts +308 -0
  84. package/src/evaluation/axis-checks/dispatch.ts +78 -13
  85. package/src/evaluation/axis-checks/helpers.ts +12 -0
  86. package/src/evaluation/axis-checks/registry.ts +2 -0
  87. package/src/evaluation/design-identity.ts +96 -0
  88. package/src/evaluation/index.ts +17 -0
  89. package/src/evaluation/llm-evaluator.ts +13 -1
  90. package/src/evaluation/theme-css.ts +14 -0
  91. package/src/evaluation/types-public.ts +35 -1
  92. package/src/evaluation/visual-evaluator.ts +478 -45
  93. package/src/fragments/render.ts +1 -1
  94. package/src/harness/check/runtime-render/prepare-mockup.ts +18 -11
  95. package/src/harness/coding/init-session.ts +1 -0
  96. package/src/harness/coding/run-coding-turn.ts +6 -14
  97. package/src/harness/coding/run-eval-round.ts +31 -3
  98. package/src/harness/runtime.ts +10 -0
  99. package/src/harness/types-public.ts +11 -0
  100. package/src/index.ts +2 -0
  101. package/src/pin-fixtures.ts +98 -0
  102. package/src/run-check.ts +10 -10
  103. package/src/tools/get-primitives-ts.ts +1 -1
  104. package/src/tools.ts +2 -1
  105. package/src/validation/primitives.ts +1 -1
@@ -11,17 +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 { CanvasVisualSummary, EvalIssue, VisualEvalSummary } from './types-public.js';
27
+ import type { CanvasJudgeRecord, CanvasVisualSummary, EvalIssue, VisualEvalSummary } from './types-public.js';
23
28
  import type { LaunchOptions } from 'puppeteer-core';
24
- import { CANVAS_VIEWPORTS, type CanvasClass, type CanvasViewport } from '../design-mode.js';
29
+ import { CANVAS_VIEWPORTS, displayModeForCanvas, type CanvasClass, type CanvasViewport } from '../design-mode.js';
25
30
 
26
31
  // ---------------------------------------------------------------------------
27
32
  // Types
@@ -56,6 +61,23 @@ export interface VisualEvalConfig {
56
61
  * evaluator constructs so its multimodal call never falls back to
57
62
  * `process.env` for credentials or model routing.
58
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';
59
81
  routeOverride?: AgentConfig['routeOverride'];
60
82
  /**
61
83
  * Provider-429-retry observer — see `AgentConfig.onRetry`.
@@ -76,6 +98,84 @@ export interface CanvasVisualResult {
76
98
  passed: boolean;
77
99
  /** The PNG the judge saw — kept for artefact persistence + the human look. */
78
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
+ };
79
179
  }
80
180
 
81
181
  /**
@@ -86,6 +186,10 @@ export interface CanvasVisualResult {
86
186
  */
87
187
  export interface VisualEvaluationResult extends EvaluationResult {
88
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';
89
193
  }
90
194
 
91
195
  /** Injection points for `runVisualEvaluation` — screenshot deps plus the judge call (tests). */
@@ -101,6 +205,8 @@ export interface VisualEvalContext {
101
205
  originalPrompt: string;
102
206
  /** Design system CSS tokens */
103
207
  cssTokens?: string;
208
+ /** The app's generation profile (#991) — judged relative to, never against. */
209
+ profile?: GenerationProfileInput;
104
210
  }
105
211
 
106
212
  // ---------------------------------------------------------------------------
@@ -135,14 +241,70 @@ export function resolveUiGenPackageDir(): string {
135
241
  return resolve(selfDir, '..', '..');
136
242
  }
137
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
+ */
138
292
  export function resolveDesignPackageDir(): string {
139
- const selfDir = dirname(fileURLToPath(import.meta.url));
140
- return resolve(selfDir, '..', '..', '..', 'design');
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
+ }
141
302
  }
142
303
 
143
304
  async function bundleForRendering(
144
305
  compiledCode: string,
145
306
  sampleProps: Record<string, unknown>,
307
+ designSrc: string = resolve(resolveDesignPackageDir(), 'src'),
146
308
  ): Promise<string> {
147
309
  // Write component + entry to temp files for esbuild
148
310
  const tmpDir = resolve(tmpdir(), 'ggui-visual-eval-' + Date.now());
@@ -150,7 +312,8 @@ async function bundleForRendering(
150
312
  const componentFile = resolve(tmpDir, 'component.tsx');
151
313
  const entryFile = resolve(tmpDir, 'entry.tsx');
152
314
 
153
- const designPkgDir = resolveDesignPackageDir();
315
+ const designPkgDir = resolve(designSrc, '..');
316
+ const wirePkgDir = resolveWirePackageDir();
154
317
 
155
318
  // Write the compiled component as a separate module
156
319
  writeFileSync(componentFile, compiledCode);
@@ -212,6 +375,10 @@ try {
212
375
  jsxImportSource: 'react',
213
376
  external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'],
214
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 } : {}),
215
382
  // D1: generated code imports the single `@ggui-ai/design` barrel.
216
383
  // The subpath aliases stay for any first-party / legacy code.
217
384
  '@ggui-ai/design': resolve(designPkgDir, 'src', 'index.ts'),
@@ -223,7 +390,10 @@ try {
223
390
  // Bare imports in the generated component resolve against ui-gen's
224
391
  // own node_modules — the temp entry dir has none. Without this every
225
392
  // wire-bearing component (`@ggui-ai/wire`) failed to bundle.
226
- nodePaths: [resolve(resolveUiGenPackageDir(), 'node_modules')],
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(), '..')],
227
397
  logLevel: 'silent',
228
398
  });
229
399
 
@@ -237,6 +407,7 @@ try {
237
407
  function buildRenderHTML(
238
408
  bundledCode: string,
239
409
  cssTokens?: string,
410
+ fit?: 'fill',
240
411
  ): string {
241
412
  // Default to the design tokens production's no-theme branch injects
242
413
  // (getCssTokens → default theme, light) — ggui#613: under the s4
@@ -260,10 +431,11 @@ function buildRenderHTML(
260
431
  color: var(--ggui-color-neutral-900, #111827);
261
432
  }
262
433
  .error { color: #dc2626; padding: 16px; font-family: monospace; white-space: pre-wrap; }
434
+ ${fit === 'fill' ? fillFitRule(JUDGE_SCOPE_CLASS) : ''}
263
435
  </style>
264
436
  </head>
265
437
  <body>
266
- <div id="root"></div>
438
+ <div id="root"${fit === 'fill' ? ` class="${JUDGE_SCOPE_CLASS}"` : ''}></div>
267
439
  <script type="importmap">
268
440
  {
269
441
  "imports": {
@@ -290,6 +462,8 @@ export interface ScreenshotPage {
290
462
  setContent(html: string, options: { waitUntil: 'load'; timeout: number }): Promise<void>;
291
463
  waitForNetworkIdle(options: { idleTime: number; timeout: number }): Promise<void>;
292
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>;
293
467
  screenshot(options: { type: 'png'; fullPage: boolean }): Promise<Uint8Array>;
294
468
  }
295
469
 
@@ -375,11 +549,35 @@ async function launchWithPuppeteer(options: LaunchOptions): Promise<ScreenshotBr
375
549
  * Take a screenshot of the rendered component.
376
550
  * Returns the PNG bytes, or null if no browser could be launched.
377
551
  */
378
- export async function captureScreenshot(
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)';
563
+
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(
379
576
  html: string,
380
577
  viewport: { width: number; height: number } = DEFAULT_SCREENSHOT_VIEWPORT,
381
578
  deps: ScreenshotDeps = {},
382
- ): Promise<Buffer | null> {
579
+ capture: CaptureMode = 'full-page',
580
+ ): Promise<ScreenshotAttempt> {
383
581
  try {
384
582
  const launchOptions = await resolveLaunchOptions(viewport, deps);
385
583
  const browser = await (deps.launch ?? launchWithPuppeteer)(launchOptions);
@@ -393,22 +591,32 @@ export async function captureScreenshot(
393
591
  await page.waitForSelector('#root > *', { timeout: 10000 }).catch(() => {});
394
592
  // Wait a bit for CSS/fonts to settle
395
593
  await new Promise((r) => setTimeout(r, deps.settleMs ?? 1000));
396
- const screenshot = await page.screenshot({ type: 'png', fullPage: true });
397
- return Buffer.from(screenshot);
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 };
398
597
  } finally {
399
598
  await browser.close();
400
599
  }
401
600
  } catch (e) {
402
- console.warn(`[visual-eval] screenshot failed: ${e instanceof Error ? e.message : e}`);
403
- 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 };
404
604
  }
405
605
  }
406
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
+
407
615
  // ---------------------------------------------------------------------------
408
616
  // Multimodal LLM evaluation
409
617
  // ---------------------------------------------------------------------------
410
618
 
411
- 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.
412
620
 
413
621
  ## Context
414
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.
@@ -435,6 +643,8 @@ The screenshot shows a generated React component built from a design system with
435
643
  - Error messages like "Render error" or blank white space = rendering score 0
436
644
  - A component that renders but is just a wall of text with no structure should score low on layout and hierarchy
437
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
+
438
648
  Respond with ONLY a JSON object (no markdown, no explanation):
439
649
  {
440
650
  "completeness": <0-100>,
@@ -457,14 +667,37 @@ Respond with ONLY a JSON object (no markdown, no explanation):
457
667
  * governs credentials and model routing (including the Anthropic
458
668
  * Bedrock path) instead of `process.env`.
459
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
+
460
689
  async function callMultimodalLLM(
461
690
  config: VisualEvalConfig,
462
691
  model: string,
463
692
  prompt: string,
464
693
  screenshot: Buffer,
465
694
  originalPrompt: string,
695
+ profileBlock = '',
466
696
  ) {
467
- 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.';
468
701
  const agent = createVisionAgent({
469
702
  provider: config.provider === 'claude' ? 'anthropic' : config.provider,
470
703
  model,
@@ -476,7 +709,7 @@ async function callMultimodalLLM(
476
709
  prompt,
477
710
  userPrompt,
478
711
  { mediaType: 'image/png', base64: screenshot.toString('base64') },
479
- 1500,
712
+ VISUAL_JUDGE_MAX_OUTPUT_TOKENS,
480
713
  );
481
714
  }
482
715
 
@@ -493,19 +726,117 @@ async function callMultimodalLLM(
493
726
  *
494
727
  * Returns null if puppeteer is not available (graceful fallback).
495
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
+
496
807
  export async function runVisualEvaluation(
497
808
  context: VisualEvalContext,
498
809
  config: VisualEvalConfig,
499
810
  deps: VisualEvalDeps = {},
500
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> {
501
820
  const startTime = Date.now();
502
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
+ });
503
830
 
504
831
  // Bundle component + design system into a single JS file
505
- const bundledCode = await bundleForRendering(
506
- context.compiledCode,
507
- config.sampleProps ?? {},
508
- );
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
+ }
509
840
  console.log(`[visual-eval] bundled: ${bundledCode.length}B (${Date.now() - startTime}ms)`);
510
841
 
511
842
  // Build the HTML page — ONE shell; per-canvas mode re-renders it at each viewport.
@@ -518,15 +849,51 @@ export async function runVisualEvaluation(
518
849
  const perCanvasResults: EvaluationResult[] = [];
519
850
  for (const canvas of config.canvases) {
520
851
  const viewport = CANVAS_VIEWPORTS[canvas];
521
- const screenshot = await captureScreenshot(html, viewport, deps);
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;
522
858
  if (!screenshot) {
523
- console.warn(`[visual-eval] no browser available at canvas ${canvas} — skipping visual evaluation`);
524
- return null;
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;
525
896
  }
526
- const response = await judge(config, model, VISUAL_EVAL_PROMPT, screenshot, context.originalPrompt);
527
- const result = parseVisualResponse(response.text, config.passThreshold);
528
- result.inputTokens = response.inputTokens;
529
- result.outputTokens = response.outputTokens;
530
897
  perCanvasResults.push(result);
531
898
  perCanvas.push({
532
899
  canvas,
@@ -534,10 +901,15 @@ export async function runVisualEvaluation(
534
901
  score: result.finalScore,
535
902
  passed: result.passed,
536
903
  screenshotPng: screenshot,
904
+ contentHeight,
905
+ overflow,
906
+ judge: judgeRecord,
907
+ ...(fit !== undefined ? { fit } : {}),
537
908
  });
538
909
  console.log(
539
- `[visual-eval] canvas=${canvas} ${viewport.width}×${viewport.height} score=${result.finalScore} ` +
540
- `${result.passed ? 'pass' : 'FAIL'} | in=${response.inputTokens} out=${response.outputTokens}`,
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}`,
541
913
  );
542
914
  }
543
915
  const aggregate = aggregateCanvasResults(perCanvas, perCanvasResults);
@@ -546,35 +918,40 @@ export async function runVisualEvaluation(
546
918
  `[visual-eval] score=${aggregate.finalScore} (mean of ${perCanvas.length} canvases; ` +
547
919
  `${aggregate.passed ? 'every canvas passed' : `${perCanvas.filter((c) => !c.passed).length} failed`}) (${elapsed}ms)`,
548
920
  );
549
- return aggregate;
921
+ return { result: stamp(aggregate) };
550
922
  }
551
923
 
552
924
  // ── Single-shot mode (today's path) ──
553
- const screenshot = await captureScreenshot(html, config.viewport, deps);
925
+ const attempt = await captureScreenshotDetailed(html, config.viewport, deps);
926
+ const screenshot = attempt.png;
554
927
  if (!screenshot) {
555
- console.warn('[visual-eval] no browser available — skipping visual evaluation');
556
- return null;
928
+ const unavailableReason = attempt.reason ?? 'no browser available';
929
+ console.warn(`[visual-eval] ${unavailableReason} — skipping visual evaluation`);
930
+ return { result: null, unavailableReason };
557
931
  }
558
932
  console.log(`[visual-eval] screenshot: ${screenshot.length}B (${Date.now() - startTime}ms)`);
559
933
 
560
- // Send to multimodal LLM
561
- const response = await judge(
934
+ // Send to multimodal LLM (one retry on a malformed answer — #1017)
935
+ const answer = await judgeAndParse(
936
+ judge,
562
937
  config,
563
938
  model,
564
- VISUAL_EVAL_PROMPT,
565
939
  screenshot,
566
940
  context.originalPrompt,
941
+ buildStylingProfileJudgeBlock(context.profile),
567
942
  );
568
-
569
- // Parse response
570
- const result = parseVisualResponse(response.text, config.passThreshold);
571
- result.inputTokens = response.inputTokens;
572
- 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 };
573
950
 
574
951
  const elapsed = Date.now() - startTime;
575
952
  console.log(`[visual-eval] score=${result.finalScore} (${elapsed}ms) | in=${response.inputTokens} out=${response.outputTokens}`);
576
953
 
577
- return result;
954
+ return { result: stamp(result) };
578
955
  }
579
956
 
580
957
  /**
@@ -628,8 +1005,18 @@ export function summarizeVisualResult(result: VisualEvaluationResult): VisualEva
628
1005
  viewport: c.viewport,
629
1006
  score: c.score,
630
1007
  passed: c.passed,
1008
+ contentHeight: c.contentHeight,
1009
+ overflow: c.overflow,
1010
+ judge: c.judge,
1011
+ ...(c.fit !== undefined ? { fit: c.fit } : {}),
631
1012
  }));
632
- return { score: result.finalScore, passed: result.passed, canvases };
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
+ };
633
1020
  }
634
1021
 
635
1022
  /**
@@ -643,6 +1030,52 @@ export { bundleForRendering, buildRenderHTML, callMultimodalLLM };
643
1030
  // Response parsing
644
1031
  // ---------------------------------------------------------------------------
645
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
+
646
1079
  function parseVisualResponse(text: string, passThreshold: number): EvaluationResult {
647
1080
  const jsonMatch = text.match(/\{[\s\S]*\}/);
648
1081
  if (!jsonMatch) {