@ggui-ai/ui-gen 0.20.0 → 0.22.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 (93) hide show
  1. package/dist/adapters/base.d.ts +7 -0
  2. package/dist/adapters/base.js +1 -0
  3. package/dist/adapters/base.js.map +1 -1
  4. package/dist/adapters/generation-dispatch.d.ts +17 -3
  5. package/dist/adapters/generation-dispatch.js +1078 -3464
  6. package/dist/adapters/generation-dispatch.js.map +1 -1
  7. package/dist/adapters/index.js +60 -3
  8. package/dist/adapters/index.js.map +1 -1
  9. package/dist/advanced/index.d.ts +4 -4
  10. package/dist/advanced/index.js +1346 -3557
  11. package/dist/advanced/index.js.map +1 -1
  12. package/dist/blueprint-validator.js +10 -3
  13. package/dist/blueprint-validator.js.map +1 -1
  14. package/dist/boilerplate.js +2 -2
  15. package/dist/boilerplate.js.map +1 -1
  16. package/dist/check/index.d.ts +1 -1
  17. package/dist/check/index.js.map +1 -1
  18. package/dist/{create-ui-generator-CxPvudbs.d.ts → create-ui-generator-BEkzEtNz.d.ts} +2 -2
  19. package/dist/{dispatch-N8wdNOX-.d.ts → dispatch-DJNpQ7DI.d.ts} +1 -1
  20. package/dist/evaluation/axis-checks/index.d.ts +3 -3
  21. package/dist/evaluation/axis-checks/index.js.map +1 -1
  22. package/dist/evaluation/axis-checks/registry.d.ts +1 -1
  23. package/dist/evaluation/index.d.ts +38 -6
  24. package/dist/evaluation/index.js +670 -3374
  25. package/dist/evaluation/index.js.map +1 -1
  26. package/dist/harness/check/runtime-render/index.d.ts +2 -2
  27. package/dist/harness/check/runtime-render/index.js +10 -3
  28. package/dist/harness/check/runtime-render/index.js.map +1 -1
  29. package/dist/harness/check/runtime-render/render-check-worker.js +5 -3
  30. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  31. package/dist/harness/index.d.ts +2 -2
  32. package/dist/harness/index.js +587 -30
  33. package/dist/harness/index.js.map +1 -1
  34. package/dist/harness/llm-trace-sink.d.ts +7 -1
  35. package/dist/harness/llm-trace-sink.js.map +1 -1
  36. package/dist/harness/prompts.js +1 -1
  37. package/dist/harness/prompts.js.map +1 -1
  38. package/dist/harness/result-types.d.ts +15 -2
  39. package/dist/harness/result-types.js.map +1 -1
  40. package/dist/harness/runtime.d.ts +11 -2
  41. package/dist/harness/runtime.js +3 -3
  42. package/dist/harness/runtime.js.map +1 -1
  43. package/dist/harness/types-public.d.ts +1 -1
  44. package/dist/harness/types-public.js +2 -2
  45. package/dist/harness/types-public.js.map +1 -1
  46. package/dist/harness/validator-trace-sink.d.ts +1 -1
  47. package/dist/index.d.ts +23 -7
  48. package/dist/index.js +1346 -3557
  49. package/dist/index.js.map +1 -1
  50. package/dist/{llm-router-D7fjBhtr.d.ts → llm-router-DwwUK1bz.d.ts} +16 -3
  51. package/dist/patch.d.ts +1 -1
  52. package/dist/providers/index.js +5 -4
  53. package/dist/providers/index.js.map +1 -1
  54. package/dist/{render-check-WfOVO9kU.d.ts → render-check-C0CqVceF.d.ts} +17 -0
  55. package/dist/{types-public-DKR_h70H.d.ts → types-public-mM_-1Yk3.d.ts} +59 -1
  56. package/dist/validation/index.d.ts +1 -1
  57. package/dist/validation/index.js +1 -1
  58. package/dist/validation/index.js.map +1 -1
  59. package/dist/workflows.d.ts +1 -1
  60. package/package.json +16 -12
  61. package/src/adapters/base.ts +8 -0
  62. package/src/adapters/claude/claude-code-login.ts +128 -0
  63. package/src/adapters/claude/sdk.ts +9 -1
  64. package/src/adapters/generation-dispatch.ts +19 -2
  65. package/src/adapters/openai/tokens.ts +18 -0
  66. package/src/adapters/provider-router.ts +28 -0
  67. package/src/boilerplate/generate.ts +19 -4
  68. package/src/boilerplate/hard-sections.ts +1 -1
  69. package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +7 -3
  70. package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +7 -3
  71. package/src/contract-context.ts +1 -1
  72. package/src/create-ui-generator.ts +49 -10
  73. package/src/evaluation/design-identity.ts +16 -5
  74. package/src/evaluation/index.ts +1 -0
  75. package/src/evaluation/llm-evaluator.ts +40 -13
  76. package/src/evaluation/types-public.ts +58 -0
  77. package/src/evaluation/visual-evaluator.ts +240 -30
  78. package/src/generator-build.ts +158 -0
  79. package/src/harness/check/runtime-render/adapter.ts +9 -0
  80. package/src/harness/check/runtime-render/render-check.ts +26 -5
  81. package/src/harness/claude-code-login-query.ts +529 -0
  82. package/src/harness/coding/assemble-result.ts +18 -2
  83. package/src/harness/coding/generate-task-runner.ts +75 -36
  84. package/src/harness/coding/run-coding-turn.ts +9 -0
  85. package/src/harness/coding/run-eval-round.ts +338 -38
  86. package/src/harness/enforced-coding.ts +20 -0
  87. package/src/harness/llm-router.ts +390 -55
  88. package/src/harness/llm-trace-sink.ts +7 -1
  89. package/src/harness/result-types.ts +14 -0
  90. package/src/harness/runtime.ts +6 -0
  91. package/src/index.ts +2 -1
  92. package/src/providers/openai.ts +31 -19
  93. package/src/validation/primitives.ts +1 -1
@@ -25,7 +25,7 @@ import type {
25
25
  } from './types-public.js';
26
26
  import { LLM_EVAL_STATIC_CRITERIA } from './types-public.js';
27
27
  import { createAgent } from '../harness/llm-router';
28
- import type { AgentConfig, LLMToolDef, LLMAgent } from '../harness/llm-router';
28
+ import type { AgentConfig, LLMToolDef, LLMAgent, LLMToolCallResponse } from '../harness/llm-router';
29
29
  import type { JsonObject } from '@ggui-ai/protocol';
30
30
  import { getCriterionById } from './types-public.js';
31
31
  import { DEFAULT_DESIGN_MODE, type DesignMode } from '../design-mode.js';
@@ -596,10 +596,36 @@ interface CriterionResult {
596
596
  coverage: CriterionRunStatus;
597
597
  /** Populated for `skipped` — why the criterion produced no verdict. */
598
598
  skipReason?: string;
599
- tokens: { in: number; out: number };
599
+ /** This call's token accounting. The cache counts are present only when the
600
+ * provider reported them — absent means unreported, never zero (ggui#1281). */
601
+ tokens: { in: number; out: number; cacheRead?: number; cacheCreation?: number };
600
602
  durationMs: number;
601
603
  }
602
604
 
605
+ /** A call's token accounting, carrying the prompt-cache counts only when the
606
+ * provider reported them (the router's rule: absent is "unreported"). */
607
+ function callTokens(response: LLMToolCallResponse): CriterionResult['tokens'] {
608
+ return {
609
+ in: response.inputTokens,
610
+ out: response.outputTokens,
611
+ ...(response.cacheReadTokens !== undefined ? { cacheRead: response.cacheReadTokens } : {}),
612
+ ...(response.cacheCreationTokens !== undefined ? { cacheCreation: response.cacheCreationTokens } : {}),
613
+ };
614
+ }
615
+
616
+ /**
617
+ * The evaluation's summed token accounting. `inputTokens` is the NON-cached
618
+ * input (the router's convention); the cache counts are the sums of what the
619
+ * calls reported, and stay absent when no call reported one — so a caller can
620
+ * tell "no cache reads" from "the provider does not say" (ggui#1281).
621
+ */
622
+ export interface LLMEvalTokenUsage {
623
+ inputTokens: number;
624
+ outputTokens: number;
625
+ cacheReadTokens?: number;
626
+ cacheCreationTokens?: number;
627
+ }
628
+
603
629
  /**
604
630
  * All valid categories for LLM evaluation — derived from the ONE static
605
631
  * list in types-public so the coverage stamp here and the harness's
@@ -642,7 +668,7 @@ async function evalCriterion(
642
668
  pass: true,
643
669
  coverage: 'skipped',
644
670
  skipReason: 'no tool call returned',
645
- tokens: { in: response.inputTokens, out: response.outputTokens },
671
+ tokens: callTokens(response),
646
672
  durationMs,
647
673
  };
648
674
  }
@@ -700,7 +726,7 @@ async function evalCriterion(
700
726
  issues,
701
727
  pass,
702
728
  coverage: 'ran',
703
- tokens: { in: response.inputTokens, out: response.outputTokens },
729
+ tokens: callTokens(response),
704
730
  durationMs,
705
731
  };
706
732
  } catch (e) {
@@ -833,7 +859,7 @@ export async function runLLMEvaluation(
833
859
  context: LLMEvalContext,
834
860
  config: LLMEvalConfig,
835
861
  preWarmedContext?: PreWarmedEvalContext | null,
836
- ): Promise<EvalResult & { inputTokens: number; outputTokens: number }> {
862
+ ): Promise<EvalResult & LLMEvalTokenUsage> {
837
863
  const providerName = config.provider;
838
864
  const model = config.model || getDefaultEvalModel(providerName);
839
865
  const routerProvider =
@@ -887,7 +913,10 @@ export async function runLLMEvaluation(
887
913
  const passCategories: string[] = [];
888
914
  let totalIn = 0;
889
915
  let totalOut = 0;
890
- let cachedTokensEstimate = 0;
916
+ // Measured, not estimated: summed from what the calls reported; absent
917
+ // while no call has reported one (ggui#1281).
918
+ let cacheRead: number | undefined;
919
+ let cacheCreation: number | undefined;
891
920
 
892
921
  for (const r of results) {
893
922
  allIssues.push(...r.issues);
@@ -896,12 +925,8 @@ export async function runLLMEvaluation(
896
925
  }
897
926
  totalIn += r.tokens.in;
898
927
  totalOut += r.tokens.out;
899
- }
900
-
901
- // Estimate cached tokens (mother prompt sent N times, cached N-1 times)
902
- const firstResult = results[0];
903
- if (firstResult && firstResult.tokens.in > 0) {
904
- cachedTokensEstimate = firstResult.tokens.in * (totalCriteriaCount - 1);
928
+ if (r.tokens.cacheRead !== undefined) cacheRead = (cacheRead ?? 0) + r.tokens.cacheRead;
929
+ if (r.tokens.cacheCreation !== undefined) cacheCreation = (cacheCreation ?? 0) + r.tokens.cacheCreation;
905
930
  }
906
931
 
907
932
  const wallMs = Date.now() - evalStart;
@@ -928,7 +953,7 @@ export async function runLLMEvaluation(
928
953
  }
929
954
  console.log(
930
955
  `[eval] total: ${(wallMs / 1000).toFixed(1)}s wall (${totalCriteriaCount} parallel) | ` +
931
- `in=${totalIn} out=${totalOut} cached~=${cachedTokensEstimate} | ` +
956
+ `in=${totalIn} out=${totalOut} cacheRead=${cacheRead ?? 'unreported'} cacheWrite=${cacheCreation ?? 'unreported'} | ` +
932
957
  `issues=${allIssues.length} pass=${passCategories.length}` +
933
958
  (skippedCount > 0 ? ` skipped=${skippedCount}` : ''),
934
959
  );
@@ -939,5 +964,7 @@ export async function runLLMEvaluation(
939
964
  criteriaCoverage,
940
965
  inputTokens: totalIn,
941
966
  outputTokens: totalOut,
967
+ ...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}),
968
+ ...(cacheCreation !== undefined ? { cacheCreationTokens: cacheCreation } : {}),
942
969
  };
943
970
  }
@@ -123,6 +123,24 @@ export interface CriterionCoverage {
123
123
  readonly reason?: string;
124
124
  }
125
125
 
126
+ /**
127
+ * Coverage of the screenshot JUDGE leg for one eval round (ggui#1221) — a
128
+ * different surface from the `visual` CRITERION in `criteriaCoverage` (the
129
+ * LLM evaluator's read of the source). `ran` = a frame was captured and
130
+ * judged; `skipped` = the leg was configured but produced no verdict
131
+ * (no browser, a launch or in-page failure, an unbundlable component, an
132
+ * unparsable judge answer) with `reason` naming why; `not-applicable` =
133
+ * the round had no visual leg configured. Before this row a skipped leg
134
+ * left NO trace on the result — `{ issues: [] }` read exactly like a
135
+ * clean run, and every bootstrap mint on a browserless runtime "passed"
136
+ * its visual round that way.
137
+ */
138
+ export interface VisualCoverage {
139
+ readonly status: CriterionRunStatus;
140
+ /** Populated for `skipped` / `not-applicable` — why no verdict. */
141
+ readonly reason?: string;
142
+ }
143
+
126
144
  /**
127
145
  * The static criteria `runLLMEvaluation` runs, in stamp order — the
128
146
  * ONE list both the evaluator's coverage stamp and the harness's
@@ -157,6 +175,14 @@ export function notApplicableCoverage(reason: string): CriterionCoverage[] {
157
175
  }));
158
176
  }
159
177
 
178
+ /** One contract-feedback round (ggui#1261): what bought it, and the source it was fed back on. */
179
+ export interface ContractFeedbackRecord {
180
+ /** The exit-probe issue subcategories that bought the round, e.g. `runtime:prop-sensitivity:currentUser`. */
181
+ readonly firedOn: readonly string[];
182
+ /** The component source as it stood when the model was told — the round's BEFORE. */
183
+ readonly sourceBefore: string;
184
+ }
185
+
160
186
  export interface EvalResult {
161
187
  issues: EvalIssue[];
162
188
  pass: string[];
@@ -176,6 +202,14 @@ export interface EvalResult {
176
202
  * probe runner; absent on eval paths that never invoke the probe.
177
203
  */
178
204
  runtimeProbe?: RuntimeProbeMeta;
205
+ /**
206
+ * The contract-feedback round, when one fired this generation (ggui#1261):
207
+ * which exit-probe findings bought it and the source as it stood when the
208
+ * model was told. `runtimeProbe` and the `runtime:*` issues on this result
209
+ * are the probe's verdict AFTER that round; `sourceBefore` beside the final
210
+ * source is the round's before/after. Absent when no such round fired.
211
+ */
212
+ contractFeedback?: ContractFeedbackRecord;
179
213
  /**
180
214
  * Per-canvas visual verdicts. Stamped by the harness ONLY when the
181
215
  * visual leg ran with `visualEvaluation.canvases` set (arm-neutral —
@@ -185,6 +219,13 @@ export interface EvalResult {
185
219
  * never here — this object is serialized into reports.
186
220
  */
187
221
  visual?: VisualEvalSummary;
222
+ /**
223
+ * Whether the screenshot judge leg ran, was skipped (with the reason) or
224
+ * was not configured — ALWAYS stamped by the harness's eval round
225
+ * (ggui#1221), so a consumer can tell a clean visual run from a leg
226
+ * that silently could not run. See {@link VisualCoverage}.
227
+ */
228
+ visualCoverage?: VisualCoverage;
188
229
  }
189
230
 
190
231
  /** One canvas's visual verdict, PNG-free — see `EvalResult.visual`. */
@@ -227,6 +268,21 @@ export interface CanvasVisualSummary {
227
268
  fit?: 'fill';
228
269
  }
229
270
 
271
+ /**
272
+ * ggui#1195 — the fit reading on the canvas whose policy FAILS an overflow
273
+ * (the inline chat card): the box it was judged at (`ceiling` — the order's
274
+ * declared viewport when one was carried, else the class box), whether that
275
+ * box was declared, and how many px of content sit below it (0 = fits).
276
+ * Present only when such a canvas was judged with a measurable height, so
277
+ * the bar and the ledger read one number for "does the hello fit".
278
+ */
279
+ export interface VisualFitStamp {
280
+ readonly canvas: CanvasClass;
281
+ readonly ceiling: { readonly width: number; readonly height: number };
282
+ readonly declared: boolean;
283
+ readonly overflowPx: number;
284
+ }
285
+
230
286
  /** The visual leg's per-canvas summary — see `EvalResult.visual`. */
231
287
  export interface VisualEvalSummary {
232
288
  /** Mean of the canvas scores (rounded). */
@@ -234,6 +290,8 @@ export interface VisualEvalSummary {
234
290
  /** Every canvas passed. */
235
291
  passed: boolean;
236
292
  canvases: CanvasVisualSummary[];
293
+ /** ggui#1195 — see {@link VisualFitStamp}; absent when no fail-policy canvas was judged. */
294
+ fit?: VisualFitStamp;
237
295
  /** The design tree the judge painted with (ggui#1042): `src` + `srcSha256` — `design@judge`. */
238
296
  design?: { readonly src: string; readonly srcSha256: string };
239
297
  /** The mode the judge's tokens were composed in, when the caller said (ggui#1076). */
@@ -19,12 +19,12 @@ import { judgeDesignIdentity, type JudgeDesignIdentity } from './design-identity
19
19
  import { resolve, dirname } from 'path';
20
20
  import { fileURLToPath } from 'url';
21
21
  import { createRequire } from 'node:module';
22
- import { existsSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
22
+ import { existsSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
23
23
  import { createHash } from 'node:crypto';
24
24
  import { tmpdir } from 'os';
25
25
  import { createVisionAgent, type AgentConfig } from '../harness/llm-router';
26
26
  import type { EvaluationResult, EvaluationIssue, DimensionScores } from './types';
27
- import type { CanvasJudgeRecord, CanvasVisualSummary, EvalIssue, VisualEvalSummary } from './types-public.js';
27
+ import type { CanvasJudgeRecord, CanvasVisualSummary, EvalIssue, VisualCoverage, VisualEvalSummary, VisualFitStamp } from './types-public.js';
28
28
  import type { LaunchOptions } from 'puppeteer-core';
29
29
  import { CANVAS_VIEWPORTS, displayModeForCanvas, type CanvasClass, type CanvasViewport } from '../design-mode.js';
30
30
 
@@ -55,6 +55,20 @@ export interface VisualEvalConfig {
55
55
  * single screenshot at `viewport`; no `canvases` field on the result.
56
56
  */
57
57
  canvases?: readonly CanvasClass[];
58
+ /**
59
+ * ggui#1195 — the box a canvas is COMPOSED for and CAPTURED at when the
60
+ * order declared one (the chat card at the visitor's real column, not the
61
+ * class's 400×640). Keyed by canvas; a canvas without an entry is judged
62
+ * at `CANVAS_VIEWPORTS[canvas]` as before. The composer's
63
+ * `rendering.viewport` and this entry are ONE value written once (the
64
+ * seam derives it), so the judge never scores a box the mint did not
65
+ * target. Only read in per-canvas mode. Captured at WHOLE CSS px: the
66
+ * browser's device-metrics override takes integers (measured — a 383.5 px
67
+ * width is refused by CDP), so a fractional declared box is rounded once
68
+ * where it enters and the capture, the ceiling and the reported
69
+ * `viewport` all carry that same integer box.
70
+ */
71
+ canvasViewports?: Partial<Readonly<Record<CanvasClass, CanvasViewport>>>;
58
72
  /**
59
73
  * Optional provider-routing override — see
60
74
  * `AgentConfig.routeOverride`. Threaded onto the agent this
@@ -110,6 +124,8 @@ export interface CanvasVisualResult {
110
124
  judge: CanvasJudgeRecord;
111
125
  /** How the judge composed the mount (ggui#1100): `'fill'` on every fullscreen canvas, absent on the inline card. */
112
126
  fit?: 'fill';
127
+ /** ggui#1195 — `true` when the order DECLARED this canvas's box (`config.canvasViewports` carried an entry), whatever its value. */
128
+ declared?: true;
113
129
  }
114
130
 
115
131
  /** How a canvas is captured for the judge and what an overflow means there (ggui#1027). */
@@ -158,6 +174,23 @@ export function canvasFit(canvas: CanvasClass): 'fill' | undefined {
158
174
  return displayModeForCanvas(canvas) === 'fullscreen' ? 'fill' : undefined;
159
175
  }
160
176
 
177
+ /**
178
+ * The box a browser can be asked for: whole CSS px. CDP's
179
+ * `Emulation.setDeviceMetricsOverride` deserialises width/height as int32 and
180
+ * refuses a fraction (measured on Chrome: `383.5` → "int32 value expected"),
181
+ * while a host measuring its chat column with `getBoundingClientRect()` may
182
+ * legitimately declare one. Rounded once, at the judge's boundary.
183
+ */
184
+ export function integerBox(viewport: { width: number; height: number }): { width: number; height: number } {
185
+ return { width: Math.round(viewport.width), height: Math.round(viewport.height) };
186
+ }
187
+
188
+ /** ggui#1195 — `true` when a canvas was judged at a box other than its class viewport (the order declared one). */
189
+ export function isDeclaredViewport(canvas: CanvasClass, viewport: CanvasViewport): boolean {
190
+ const classBox = CANVAS_VIEWPORTS[canvas];
191
+ return viewport.width !== classBox.width || viewport.height !== classBox.height;
192
+ }
193
+
161
194
  /** The deterministic fit issue — one per overflowing canvas, in the judge's issue shape so it rides the same channel. */
162
195
  export function canvasOverflowIssue(
163
196
  canvas: CanvasClass,
@@ -166,11 +199,18 @@ export function canvasOverflowIssue(
166
199
  verdict: 'fail' | 'warn',
167
200
  ): EvaluationIssue {
168
201
  const hidden = contentHeight - viewport.height;
202
+ // ggui#1195 — when the judge captured at a DECLARED box, the refusal names
203
+ // both boxes: the ceiling the content was measured against and the class
204
+ // box a reader would otherwise assume.
205
+ const classBox = CANVAS_VIEWPORTS[canvas];
206
+ const box = isDeclaredViewport(canvas, viewport)
207
+ ? `declared ${viewport.width}×${viewport.height}; class box ${classBox.width}×${classBox.height}`
208
+ : `${viewport.width}×${viewport.height}`;
169
209
  return {
170
210
  dimension: 'canvas-overflow',
171
211
  severity: verdict === 'fail' ? 'critical' : 'major',
172
212
  description:
173
- `Rendered content is ${contentHeight}px tall on the ${canvas} canvas (${viewport.width}×${viewport.height}) — ` +
213
+ `Rendered content is ${contentHeight}px tall on the ${canvas} canvas (${box}) — ` +
174
214
  `${hidden}px ${verdict === 'fail' ? 'is cut off: the inline card does not scroll' : 'sits below the first screen'}.`,
175
215
  fix:
176
216
  'Fit the composition to the canvas: fewer and shorter sections, one compact row of chips, no hero taller than ' +
@@ -306,9 +346,12 @@ async function bundleForRendering(
306
346
  sampleProps: Record<string, unknown>,
307
347
  designSrc: string = resolve(resolveDesignPackageDir(), 'src'),
308
348
  ): Promise<string> {
309
- // Write component + entry to temp files for esbuild
310
- const tmpDir = resolve(tmpdir(), 'ggui-visual-eval-' + Date.now());
311
- mkdirSync(tmpDir, { recursive: true });
349
+ // Write component + entry to temp files for esbuild — in a directory of THIS
350
+ // call's own. `mkdtempSync` is atomic and unique: a timestamp-named directory
351
+ // was shared by two bundles started in the same millisecond, so the first
352
+ // build read the second's component (a judge scoring the wrong generation)
353
+ // and the first's cleanup deleted the files the second was building.
354
+ const tmpDir = mkdtempSync(resolve(tmpdir(), 'ggui-visual-eval-'));
312
355
  const componentFile = resolve(tmpDir, 'component.tsx');
313
356
  const entryFile = resolve(tmpDir, 'entry.tsx');
314
357
 
@@ -399,8 +442,8 @@ try {
399
442
 
400
443
  return result.outputFiles[0]?.text ?? '';
401
444
  } finally {
402
- try { unlinkSync(entryFile); } catch { /* cleanup */ }
403
- try { unlinkSync(componentFile); } catch { /* cleanup */ }
445
+ // The whole per-call directory; `force` makes an already-absent path a no-op.
446
+ rmSync(tmpDir, { recursive: true, force: true });
404
447
  }
405
448
  }
406
449
 
@@ -427,7 +470,10 @@ function buildRenderHTML(
427
470
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
428
471
  body {
429
472
  font-family: var(--ggui-font-family-sans, system-ui, -apple-system, sans-serif);
430
- background: var(--ggui-color-neutral-50, #ffffff);
473
+ /* ggui#1083 — the host stand-in (neutral-50) under the theme's scrim (its tint at
474
+ its opacity): the ground a frosted host puts under the card, so the judge scores
475
+ the card where the visitor sees it. */
476
+ background: color-mix(in oklch, var(--ggui-scrim-tint, var(--ggui-color-ground, #ffffff)) calc(var(--ggui-scrim-opacity, 0.45) * 100%), var(--ggui-color-neutral-50, #ffffff));
431
477
  color: var(--ggui-color-neutral-900, #111827);
432
478
  }
433
479
  .error { color: #dc2626; padding: 16px; font-family: monospace; white-space: pre-wrap; }
@@ -523,18 +569,20 @@ export async function resolveLaunchOptions(
523
569
  ): Promise<LaunchOptions> {
524
570
  const env = deps.env ?? process.env;
525
571
  const override = env.PUPPETEER_EXECUTABLE_PATH?.trim();
572
+ // Whole px at the one place `defaultViewport` is built — the single-shot path takes the caller's box verbatim.
573
+ const defaultViewport = integerBox(viewport);
526
574
  if (override) {
527
575
  return {
528
576
  executablePath: override,
529
577
  args: [...EXECUTABLE_PATH_LAUNCH_ARGS],
530
- defaultViewport: viewport,
578
+ defaultViewport,
531
579
  headless: true,
532
580
  };
533
581
  }
534
582
  const chromium = await (deps.loadChromium ?? loadSparticuzChromium)();
535
583
  return {
536
584
  args: chromium.args,
537
- defaultViewport: viewport,
585
+ defaultViewport,
538
586
  executablePath: await chromium.executablePath(),
539
587
  headless: true,
540
588
  };
@@ -742,6 +790,51 @@ export interface VisualEvalDetailed {
742
790
  }
743
791
 
744
792
 
793
+ /**
794
+ * One canvas, framed: the box it is captured at (ggui#1195 — the DECLARED box
795
+ * when the order carried one, else the class box), the canvas's fit policy,
796
+ * the runtime's fit for the mount (ggui#1100 — fullscreen canvases fill, the
797
+ * inline card does not) and the capture. The judge path and the fit-only path
798
+ * ({@link runVisualFit}) frame through this ONE function, so a fit verdict
799
+ * reached without the judge is taken on the frame the judge would have seen.
800
+ */
801
+ interface CanvasFrame {
802
+ readonly viewport: CanvasViewport;
803
+ readonly policy: CanvasFitPolicy;
804
+ readonly fit: 'fill' | undefined;
805
+ /** `true` when `canvasViewports` carried an entry for this canvas, whatever its value. */
806
+ readonly declared: boolean;
807
+ readonly attempt: ScreenshotAttempt;
808
+ }
809
+
810
+ async function frameCanvas(
811
+ bundledCode: string,
812
+ context: VisualEvalContext,
813
+ html: string,
814
+ canvas: CanvasClass,
815
+ canvasViewports: VisualEvalConfig['canvasViewports'],
816
+ deps: ScreenshotDeps,
817
+ ): Promise<CanvasFrame> {
818
+ const declaredBox = canvasViewports?.[canvas];
819
+ const viewport = declaredBox !== undefined ? integerBox(declaredBox) : CANVAS_VIEWPORTS[canvas];
820
+ const policy = canvasFitPolicy(canvas);
821
+ const fit = canvasFit(canvas);
822
+ const canvasHtml = fit !== undefined ? buildRenderHTML(bundledCode, context.cssTokens, fit) : html;
823
+ const attempt = await captureScreenshotDetailed(canvasHtml, viewport, deps, policy.capture);
824
+ return { viewport, policy, fit, declared: declaredBox !== undefined, attempt };
825
+ }
826
+
827
+ /**
828
+ * The deterministic fit verdict on one frame (ggui#1027): the issue the
829
+ * canvas's policy gives an overflow, or `null` when the content fits, could
830
+ * not be measured, or the canvas scrolls by design.
831
+ */
832
+ function fitVerdict(canvas: CanvasClass, frame: CanvasFrame): EvaluationIssue | null {
833
+ const contentHeight = frame.attempt.contentHeight;
834
+ if (contentHeight === null || contentHeight <= frame.viewport.height || frame.policy.overflow === 'none') return null;
835
+ return canvasOverflowIssue(canvas, frame.viewport, contentHeight, frame.policy.overflow);
836
+ }
837
+
745
838
  /** One judge call parsed, retried ONCE on a malformed answer (#1017); the FIRST reason is kept verbatim. */
746
839
  type JudgedAnswer =
747
840
  | { readonly kind: 'ok'; readonly result: EvaluationResult; readonly inputTokens: number; readonly outputTokens: number }
@@ -848,12 +941,8 @@ export async function runVisualEvaluationDetailed(
848
941
  const perCanvas: CanvasVisualResult[] = [];
849
942
  const perCanvasResults: EvaluationResult[] = [];
850
943
  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);
944
+ const frame = await frameCanvas(bundledCode, context, html, canvas, config.canvasViewports, deps);
945
+ const { viewport, policy, fit, attempt } = frame;
857
946
  const screenshot = attempt.png;
858
947
  if (!screenshot) {
859
948
  const unavailableReason = attempt.reason ?? 'no browser available';
@@ -890,8 +979,9 @@ export async function runVisualEvaluationDetailed(
890
979
  // The fit verdict (ggui#1027): deterministic, in the judge's issue channel.
891
980
  const contentHeight = attempt.contentHeight;
892
981
  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));
982
+ const fitIssue = fitVerdict(canvas, frame);
983
+ if (fitIssue !== null) {
984
+ result.issues.push(fitIssue);
895
985
  if (policy.overflow === 'fail') result.passed = false;
896
986
  }
897
987
  perCanvasResults.push(result);
@@ -905,6 +995,7 @@ export async function runVisualEvaluationDetailed(
905
995
  overflow,
906
996
  judge: judgeRecord,
907
997
  ...(fit !== undefined ? { fit } : {}),
998
+ ...(frame.declared ? { declared: true as const } : {}),
908
999
  });
909
1000
  console.log(
910
1001
  `[visual-eval] canvas=${canvas} ${viewport.width}×${viewport.height} score=${result.finalScore}${k > 1 ? ` (median of ${judgeRecord.samples.length}/${k}: ${judgeRecord.samples.join(',')} σ=${judgeRecord.sigma})` : ''} ` +
@@ -960,6 +1051,11 @@ export async function runVisualEvaluationDetailed(
960
1051
  * per-dimension means, `issues` = union (description prefixed with the
961
1052
  * canvas), `critique` = one line per canvas, tokens summed.
962
1053
  */
1054
+ /** A canvas's issue as the aggregate carries it: the description prefixed `[<canvas>]`. */
1055
+ function canvasScopedIssue(canvas: CanvasClass, issue: EvaluationIssue): EvaluationIssue {
1056
+ return { ...issue, description: `[${canvas}] ${issue.description}` };
1057
+ }
1058
+
963
1059
  function aggregateCanvasResults(
964
1060
  perCanvas: readonly CanvasVisualResult[],
965
1061
  results: readonly EvaluationResult[],
@@ -980,7 +1076,7 @@ function aggregateCanvasResults(
980
1076
  let outputTokens = 0;
981
1077
  results.forEach((r, i) => {
982
1078
  const canvas = perCanvas[i]!.canvas;
983
- for (const issue of r.issues) issues.push({ ...issue, description: `[${canvas}] ${issue.description}` });
1079
+ for (const issue of r.issues) issues.push(canvasScopedIssue(canvas, issue));
984
1080
  if (r.critique) critiques.push(`${canvas}: ${r.critique}`);
985
1081
  inputTokens += r.inputTokens ?? 0;
986
1082
  outputTokens += r.outputTokens ?? 0;
@@ -1010,10 +1106,24 @@ export function summarizeVisualResult(result: VisualEvaluationResult): VisualEva
1010
1106
  judge: c.judge,
1011
1107
  ...(c.fit !== undefined ? { fit: c.fit } : {}),
1012
1108
  }));
1109
+ // ggui#1195 — the fit stamp: the first canvas whose policy FAILS an
1110
+ // overflow (the inline card), judged with a measurable height.
1111
+ const fitCanvas = result.canvases.find((c) => canvasFitPolicy(c.canvas).overflow === 'fail' && c.contentHeight !== null);
1112
+ const fit: VisualFitStamp | undefined =
1113
+ fitCanvas !== undefined && fitCanvas.contentHeight !== null
1114
+ ? {
1115
+ canvas: fitCanvas.canvas,
1116
+ ceiling: { width: fitCanvas.viewport.width, height: fitCanvas.viewport.height },
1117
+ // Declared = the order carried a box, even one equal to the class box (the review's point).
1118
+ declared: fitCanvas.declared === true,
1119
+ overflowPx: Math.max(0, fitCanvas.contentHeight - fitCanvas.viewport.height),
1120
+ }
1121
+ : undefined;
1013
1122
  return {
1014
1123
  score: result.finalScore,
1015
1124
  passed: result.passed,
1016
1125
  canvases,
1126
+ ...(fit !== undefined ? { fit } : {}),
1017
1127
  ...(result.design !== undefined ? { design: result.design } : {}),
1018
1128
  ...(result.themeMode !== undefined ? { themeMode: result.themeMode } : {}),
1019
1129
  };
@@ -1148,6 +1258,12 @@ export interface VisualEvalOutcome {
1148
1258
  * the single-shot path (byte-identical to the pre-canvas result).
1149
1259
  */
1150
1260
  summary?: VisualEvalSummary;
1261
+ /**
1262
+ * ggui#1221 — whether the judge leg ran or was skipped (with the reason
1263
+ * `runVisualEvaluationDetailed` names). Always present: the absence of a
1264
+ * summary used to be indistinguishable from a clean single-shot run.
1265
+ */
1266
+ coverage: VisualCoverage;
1151
1267
  }
1152
1268
 
1153
1269
  /**
@@ -1157,19 +1273,113 @@ export interface VisualEvalOutcome {
1157
1273
  export async function runVisualEval(
1158
1274
  context: VisualEvalContext,
1159
1275
  config: VisualEvalConfig,
1276
+ deps: VisualEvalDeps = {},
1160
1277
  ): Promise<VisualEvalOutcome> {
1161
- const result = await runVisualEvaluation(context, config);
1162
- if (!result) return { issues: [] }; // puppeteer not available
1278
+ const detailed = await runVisualEvaluationDetailed(context, config, deps);
1279
+ const result = detailed.result;
1280
+ if (!result) {
1281
+ // ggui#1221 — no browser / launch failure / unbundlable / unparsable judge:
1282
+ // the leg is SKIPPED, and says so on the outcome instead of leaving `{ issues: [] }`.
1283
+ const reason = detailed.unavailableReason ?? 'no browser available';
1284
+ return { issues: [], coverage: { status: 'skipped', reason } };
1285
+ }
1163
1286
 
1164
- const issues: EvalIssue[] = (result.issues || []).map(issue => ({
1165
- tier: 2 as const,
1166
- result: (issue.severity === 'critical' ? 'fail' : 'warn') as 'fail' | 'warn',
1167
- category: 'visual' as const,
1287
+ const issues: EvalIssue[] = (result.issues || []).map(toEvalIssue);
1288
+ const summary = summarizeVisualResult(result);
1289
+ const coverage: VisualCoverage = { status: 'ran' };
1290
+ return summary === undefined ? { issues, coverage } : { issues, summary, coverage };
1291
+ }
1292
+
1293
+ /** A judge-shaped issue as the harness's tier-2 issue: critical ⇒ a blocking fail, anything else ⇒ a warn. */
1294
+ function toEvalIssue(issue: EvaluationIssue): EvalIssue {
1295
+ return {
1296
+ tier: 2,
1297
+ result: issue.severity === 'critical' ? 'fail' : 'warn',
1298
+ category: 'visual',
1168
1299
  subcategory: issue.dimension,
1169
- severity: (issue.severity === 'critical' ? 'critical' : 'major') as 'critical' | 'major',
1300
+ severity: issue.severity === 'critical' ? 'critical' : 'major',
1170
1301
  description: issue.description,
1171
1302
  fix: issue.fix || '',
1172
- }));
1173
- const summary = summarizeVisualResult(result);
1174
- return summary === undefined ? { issues } : { issues, summary };
1303
+ };
1304
+ }
1305
+
1306
+ // ---------------------------------------------------------------------------
1307
+ // The fit half without the judge
1308
+ // ---------------------------------------------------------------------------
1309
+
1310
+ /** What the fit half reads — the frame's inputs; nothing about a judge. */
1311
+ export type VisualFitConfig = Pick<VisualEvalConfig, 'sampleProps' | 'canvases' | 'canvasViewports' | 'designSrcDir'>;
1312
+
1313
+ /** One canvas's fit reading: the box, the measure, whether it overflowed — no score. */
1314
+ export interface CanvasFitReading {
1315
+ readonly canvas: CanvasClass;
1316
+ readonly viewport: CanvasViewport;
1317
+ readonly contentHeight: number | null;
1318
+ readonly overflow: boolean;
1319
+ /** `true` when the order declared this canvas's box. */
1320
+ readonly declared?: true;
1321
+ }
1322
+
1323
+ /** `measured` = every canvas was framed (its fit issues, possibly none); `unavailable` = no verdict, with why. */
1324
+ export type VisualFitOutcome =
1325
+ | { readonly status: 'measured'; readonly issues: EvalIssue[]; readonly readings: CanvasFitReading[] }
1326
+ | { readonly status: 'unavailable'; readonly reason: string };
1327
+
1328
+ /**
1329
+ * The visual leg's deterministic half, taken WITHOUT the vision judge.
1330
+ *
1331
+ * The fit verdict needs a browser and nothing else: each canvas is framed at
1332
+ * its box (the declared one when the order carried it), its scroll height is
1333
+ * measured against the box's height, and the canvas's fit policy decides what
1334
+ * an overflow earns — the same frame ({@link frameCanvas}), the same measure
1335
+ * and the same issue ({@link fitVerdict}, canvas-scoped) as the judge path, so
1336
+ * the two reach one verdict. What is absent is the score. A caller whose
1337
+ * generation provider has no vision judge takes this half alone, so a chat
1338
+ * card cut off at its declared box is still reported in the loop, where the
1339
+ * round can buy the composition fix.
1340
+ *
1341
+ * Per-canvas only: without `canvases` there is no box to hold the content
1342
+ * against, and the outcome is `unavailable`. A frame that cannot be taken is
1343
+ * `unavailable` with the capture's reason — never a pass.
1344
+ */
1345
+ export async function runVisualFit(
1346
+ context: VisualEvalContext,
1347
+ config: VisualFitConfig,
1348
+ deps: ScreenshotDeps = {},
1349
+ ): Promise<VisualFitOutcome> {
1350
+ if (config.canvases === undefined || config.canvases.length === 0) {
1351
+ return { status: 'unavailable', reason: 'no canvases — the fit verdict is taken per canvas' };
1352
+ }
1353
+ const designSrc = config.designSrcDir ?? resolve(resolveDesignPackageDir(), 'src');
1354
+ let bundledCode: string;
1355
+ try {
1356
+ bundledCode = await bundleForRendering(context.compiledCode, config.sampleProps ?? {}, designSrc);
1357
+ } catch (e) {
1358
+ return { status: 'unavailable', reason: `bundle failed: ${e instanceof Error ? e.message : String(e)}` };
1359
+ }
1360
+ const html = buildRenderHTML(bundledCode, context.cssTokens);
1361
+ const issues: EvalIssue[] = [];
1362
+ const readings: CanvasFitReading[] = [];
1363
+ for (const canvas of config.canvases) {
1364
+ const frame = await frameCanvas(bundledCode, context, html, canvas, config.canvasViewports, deps);
1365
+ if (!frame.attempt.png) {
1366
+ return { status: 'unavailable', reason: `${frame.attempt.reason ?? 'no browser available'} at canvas ${canvas}` };
1367
+ }
1368
+ const contentHeight = frame.attempt.contentHeight;
1369
+ const overflow = contentHeight !== null && contentHeight > frame.viewport.height;
1370
+ const verdict = fitVerdict(canvas, frame);
1371
+ if (verdict !== null) issues.push(toEvalIssue(canvasScopedIssue(canvas, verdict)));
1372
+ readings.push({
1373
+ canvas,
1374
+ viewport: frame.viewport,
1375
+ contentHeight,
1376
+ overflow,
1377
+ ...(frame.declared ? { declared: true as const } : {}),
1378
+ });
1379
+ console.log(
1380
+ `[visual-fit] canvas=${canvas} ${frame.viewport.width}×${frame.viewport.height} ` +
1381
+ `content=${contentHeight ?? '?'}px${overflow ? ` OVERFLOW (${frame.policy.overflow})` : ''} | no judge`,
1382
+ );
1383
+ }
1384
+ return { status: 'measured', issues, readings };
1175
1385
  }