@ggui-ai/ui-gen 0.15.0 → 0.16.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 (101) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +21 -4
  2. package/dist/adapters/generation-dispatch.js +913 -350
  3. package/dist/adapters/generation-dispatch.js.map +1 -1
  4. package/dist/adapters/index.js +26 -22
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/advanced/index.d.ts +17 -12
  7. package/dist/advanced/index.js +966 -374
  8. package/dist/advanced/index.js.map +1 -1
  9. package/dist/boilerplate.d.ts +16 -1
  10. package/dist/boilerplate.js +579 -245
  11. package/dist/boilerplate.js.map +1 -1
  12. package/dist/check/index.d.ts +17 -3
  13. package/dist/check/index.js +32 -25
  14. package/dist/check/index.js.map +1 -1
  15. package/dist/{create-ui-generator-D6TKuv9V.d.ts → create-ui-generator-CCIeQaEe.d.ts} +77 -10
  16. package/dist/design-mode-BHEE7flj.d.ts +67 -0
  17. package/dist/{dispatch-BuZfTh9r.d.ts → dispatch-BA5dJcg-.d.ts} +4 -1
  18. package/dist/evaluation/axis-checks/index.d.ts +4 -3
  19. package/dist/evaluation/axis-checks/index.js +3 -1
  20. package/dist/evaluation/axis-checks/index.js.map +1 -1
  21. package/dist/evaluation/axis-checks/registry.d.ts +2 -1
  22. package/dist/evaluation/axis-checks/registry.js +1 -0
  23. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  24. package/dist/evaluation/index.d.ts +143 -3
  25. package/dist/evaluation/index.js +3866 -92
  26. package/dist/evaluation/index.js.map +1 -1
  27. package/dist/{generate-BTfvELgl.d.ts → generate-8L_bc2iP.d.ts} +10 -1
  28. package/dist/harness/check/runtime-render/index.d.ts +2 -1
  29. package/dist/harness/index.d.ts +2 -1
  30. package/dist/harness/index.js +589 -248
  31. package/dist/harness/index.js.map +1 -1
  32. package/dist/harness/llm-trace-sink.d.ts +3 -3
  33. package/dist/harness/llm-trace-sink.js.map +1 -1
  34. package/dist/harness/prompts.d.ts +2 -1
  35. package/dist/harness/prompts.js +3 -0
  36. package/dist/harness/prompts.js.map +1 -1
  37. package/dist/harness/result-types.d.ts +17 -5
  38. package/dist/harness/result-types.js.map +1 -1
  39. package/dist/harness/runtime.d.ts +19 -3
  40. package/dist/harness/runtime.js +602 -261
  41. package/dist/harness/runtime.js.map +1 -1
  42. package/dist/harness/types-public.d.ts +28 -1
  43. package/dist/harness/types-public.js +589 -248
  44. package/dist/harness/types-public.js.map +1 -1
  45. package/dist/harness/validator-trace-sink.d.ts +2 -1
  46. package/dist/index.d.ts +8 -4
  47. package/dist/index.js +973 -372
  48. package/dist/index.js.map +1 -1
  49. package/dist/patch.d.ts +2 -1
  50. package/dist/{prompts-BVhtH3M1.d.ts → prompts-k6m6reqe.d.ts} +11 -1
  51. package/dist/providers/index.d.ts +2 -2
  52. package/dist/providers/index.js.map +1 -1
  53. package/dist/tools/get-primitives-ts.d.ts +1 -1
  54. package/dist/tools/get-primitives-ts.js +1 -1
  55. package/dist/tools/get-primitives-ts.js.map +1 -1
  56. package/dist/{types-public-CkNWEov9.d.ts → types-public-D24JLJgl.d.ts} +49 -5
  57. package/dist/validation/index.d.ts +1 -1
  58. package/dist/validation/index.js +4 -4
  59. package/dist/validation/index.js.map +1 -1
  60. package/dist/workflows.d.ts +2 -1
  61. package/package.json +12 -12
  62. package/src/adapters/generation-dispatch.ts +57 -4
  63. package/src/adapters/provider-router.ts +7 -6
  64. package/src/adapters/tools.ts +5 -5
  65. package/src/advanced/generator.ts +11 -10
  66. package/src/advanced/index.ts +5 -4
  67. package/src/boilerplate/free-design-prompt.ts +329 -0
  68. package/src/boilerplate/generate.ts +10 -1
  69. package/src/boilerplate/hard-sections.ts +242 -0
  70. package/src/boilerplate/render.ts +32 -8
  71. package/src/boilerplate/system-prompt.ts +81 -229
  72. package/src/boilerplate/templates/base-free.tsx.tmpl +24 -0
  73. package/src/check/run-tier0.ts +80 -20
  74. package/src/coding-agent/tools.ts +8 -2
  75. package/src/contract-context.ts +28 -0
  76. package/src/create-harness.ts +11 -0
  77. package/src/create-ui-generator.ts +71 -25
  78. package/src/design-mode.ts +182 -0
  79. package/src/design-system-docs.ts +17 -13
  80. package/src/evaluation/axis-checks/dispatch.ts +4 -0
  81. package/src/evaluation/axis-checks/extras.ts +5 -0
  82. package/src/evaluation/cost-tracker.ts +2 -3
  83. package/src/evaluation/index.ts +46 -2
  84. package/src/evaluation/llm-evaluator.ts +77 -21
  85. package/src/evaluation/types-public.ts +103 -8
  86. package/src/evaluation/visual-evaluator.ts +301 -29
  87. package/src/harness/coding/dupe-break.ts +0 -0
  88. package/src/harness/coding/init-session.ts +1 -0
  89. package/src/harness/coding/run-coding-turn.ts +3 -0
  90. package/src/harness/coding/run-eval-round.ts +15 -2
  91. package/src/harness/llm-trace-sink.ts +3 -3
  92. package/src/harness/result-types.ts +15 -3
  93. package/src/harness/runtime.ts +18 -0
  94. package/src/harness/types-public.ts +27 -0
  95. package/src/index.ts +26 -0
  96. package/src/internal/extract-call-signatures.ts +2 -2
  97. package/src/providers/bedrock.ts +4 -4
  98. package/src/run-check.ts +1 -0
  99. package/src/tools/get-primitives-ts.ts +1 -1
  100. package/src/validation/allowed-imports.ts +8 -2
  101. package/src/validation/primitives.ts +1 -1
@@ -20,6 +20,8 @@
20
20
 
21
21
  export type Priority = "P0" | "P1" | "P2";
22
22
 
23
+ import type { CanvasClass, DesignMode } from "../design-mode.js";
24
+
23
25
  // ─── Issue shape ───────────────────────────────────────────────────────────
24
26
 
25
27
  export type EvalTier = 0 | 1 | 2;
@@ -174,6 +176,34 @@ export interface EvalResult {
174
176
  * probe runner; absent on eval paths that never invoke the probe.
175
177
  */
176
178
  runtimeProbe?: RuntimeProbeMeta;
179
+ /**
180
+ * Per-canvas visual verdicts. Stamped by the harness ONLY when the
181
+ * visual leg ran with `visualEvaluation.canvases` set (arm-neutral —
182
+ * the same judge in every design mode); absent on the single-shot
183
+ * visual path and whenever the visual leg did not run. PNGs stay on
184
+ * `runVisualEvaluation`'s own result (`VisualEvaluationResult.canvases[*].screenshotPng`),
185
+ * never here — this object is serialized into reports.
186
+ */
187
+ visual?: VisualEvalSummary;
188
+ }
189
+
190
+ /** One canvas's visual verdict, PNG-free — see `EvalResult.visual`. */
191
+ export interface CanvasVisualSummary {
192
+ canvas: CanvasClass;
193
+ viewport: { width: number; height: number };
194
+ /** The judge's weighted score at this canvas (0-100). */
195
+ score: number;
196
+ /** `score >= passThreshold`. */
197
+ passed: boolean;
198
+ }
199
+
200
+ /** The visual leg's per-canvas summary — see `EvalResult.visual`. */
201
+ export interface VisualEvalSummary {
202
+ /** Mean of the canvas scores (rounded). */
203
+ score: number;
204
+ /** Every canvas passed. */
205
+ passed: boolean;
206
+ canvases: CanvasVisualSummary[];
177
207
  }
178
208
 
179
209
  // ─── Quality mode ──────────────────────────────────────────────────────────
@@ -209,6 +239,13 @@ export interface AxisCheckInput {
209
239
  originalPrompt: string;
210
240
  /** Full classification — checks may read sibling axes. */
211
241
  classification: Classification;
242
+ /**
243
+ * Which triad produced the source. Checks that assert a design-package
244
+ * component (e.g. `layout.multi_step.stepper_adopted`) read it and
245
+ * stand down in `free` mode, where the primitive is optional. Absent
246
+ * means `constrained`.
247
+ */
248
+ designMode?: DesignMode;
212
249
  }
213
250
 
214
251
  /**
@@ -506,26 +543,84 @@ Only list ACTUAL violations. Use 'fail' only for pervasive violations.`,
506
543
  },
507
544
  ] as const;
508
545
 
546
+ /**
547
+ * `designMode: 'free'` overrides — ONLY the two criteria whose text
548
+ * encodes the design vocabulary. `tokens` narrows to COLOR (the free arm
549
+ * relaxes spacing / typography / radius / shadow to literals but keeps
550
+ * brand-bearing color on the closed `--ggui-color-*` manifest, no
551
+ * fallbacks); `visual` becomes arm-neutral (composition, hierarchy,
552
+ * consistency, fit to canvas — no reward or penalty for design-system
553
+ * usage). Every other criterion is shared verbatim between the modes.
554
+ * Constraint alignment: what the free prompt says, the free evaluator
555
+ * checks — and nothing more.
556
+ */
557
+ const FREE_DESIGN_CRITERIA_OVERRIDES: readonly EvalCriterion[] = [
558
+ {
559
+ id: "tokens",
560
+ name: "Color tokens",
561
+ priority: "P1",
562
+ tier: 0,
563
+ failOutcome: "warn",
564
+ codingGuidance:
565
+ "Every color is a bare var(--ggui-color-*) from the token manifest — no hex, rgb()/hsl(), CSS named colors or literal fallbacks, in style props and <style> blocks alike. Spacing, typography, radius and shadow geometry may be literals.",
566
+ evalInstruction:
567
+ "Flag hardcoded hex colors, rgba/hsl functions, CSS named colors, and literal fallbacks inside var(--ggui-*) references. Do NOT flag literal spacing, font sizes, radii or pixel values — they are allowed in this mode.",
568
+ },
569
+ {
570
+ id: "visual",
571
+ name: "Visual composition",
572
+ priority: "P2",
573
+ tier: 2,
574
+ failOutcome: "warn",
575
+ codingGuidance:
576
+ "Compose deliberately: one clear focal point, consistent spacing rhythm, a coherent theme-token palette, a fit to the rendering canvas.",
577
+ evalInstruction: `Evaluate VISUAL COMPOSITION: Is the component composed deliberately for its canvas?
578
+
579
+ Judge: visual hierarchy (one clear focal point, headings distinct from body text), consistent spacing rhythm and alignment, a coherent palette drawn from the theme tokens, fit to the canvas (fluid width, no fixed widths, nothing cramped or stretched).
580
+ Do NOT reward or penalise the use of design-system primitives — raw HTML with inline CSS is a first-class choice here. Literal spacing / typography / radius values are fine.
581
+
582
+ Only list ACTUAL composition problems. Use 'fail' only for a fundamentally incoherent composition.`,
583
+ },
584
+ ];
585
+
586
+ /**
587
+ * The criteria registry for a design mode. `constrained` IS `CRITERIA`
588
+ * (same array, byte-identical summary); `free` swaps in the two
589
+ * overrides above by id, preserving order.
590
+ */
591
+ export function criteriaFor(designMode: DesignMode = "constrained"): readonly EvalCriterion[] {
592
+ if (designMode === "constrained") return CRITERIA;
593
+ return CRITERIA.map(
594
+ (c) => FREE_DESIGN_CRITERIA_OVERRIDES.find((o) => o.id === c.id) ?? c,
595
+ );
596
+ }
597
+
509
598
  /** Get all criteria for a specific priority level */
510
- export function getCriteriaByPriority(priority: Priority): EvalCriterion[] {
511
- return CRITERIA.filter((c) => c.priority === priority);
599
+ export function getCriteriaByPriority(
600
+ priority: Priority,
601
+ designMode: DesignMode = "constrained",
602
+ ): EvalCriterion[] {
603
+ return criteriaFor(designMode).filter((c) => c.priority === priority);
512
604
  }
513
605
 
514
606
  /** Get a specific criterion by ID */
515
- export function getCriterionById(id: string): EvalCriterion | undefined {
516
- return CRITERIA.find((c) => c.id === id);
607
+ export function getCriterionById(
608
+ id: string,
609
+ designMode: DesignMode = "constrained",
610
+ ): EvalCriterion | undefined {
611
+ return criteriaFor(designMode).find((c) => c.id === id);
517
612
  }
518
613
 
519
614
  /** Get all LLM-evaluated criteria (tier 1 + 2) */
520
- export function getLLMCriteria(): EvalCriterion[] {
521
- return CRITERIA.filter((c) => c.tier > 0);
615
+ export function getLLMCriteria(designMode: DesignMode = "constrained"): EvalCriterion[] {
616
+ return criteriaFor(designMode).filter((c) => c.tier > 0);
522
617
  }
523
618
 
524
619
  /**
525
620
  * Build the coding agent's criteria summary from the single source of truth.
526
621
  * Grouped by priority for the P0→P1→P2 hierarchy.
527
622
  */
528
- export function buildCodingCriteriaSummary(): string {
623
+ export function buildCodingCriteriaSummary(designMode: DesignMode = "constrained"): string {
529
624
  const lines: string[] = ["## Priority (P0 first, then P1, then P2)", ""];
530
625
 
531
626
  for (const priority of ["P0", "P1", "P2"] as Priority[]) {
@@ -535,7 +630,7 @@ export function buildCodingCriteriaSummary(): string {
535
630
  : priority === "P1"
536
631
  ? "Should (safety)"
537
632
  : "Nice (quality)";
538
- const criteria = getCriteriaByPriority(priority);
633
+ const criteria = getCriteriaByPriority(priority, designMode);
539
634
  lines.push(`**${priority} — ${label}:**`);
540
635
  for (const c of criteria) {
541
636
  lines.push(`- ${c.codingGuidance}`);
@@ -19,7 +19,9 @@ import { writeFileSync, mkdirSync, unlinkSync } from 'fs';
19
19
  import { tmpdir } from 'os';
20
20
  import { createVisionAgent, type AgentConfig } from '../harness/llm-router';
21
21
  import type { EvaluationResult, EvaluationIssue, DimensionScores } from './types';
22
- import type { EvalIssue } from './types-public.js';
22
+ import type { CanvasVisualSummary, EvalIssue, VisualEvalSummary } from './types-public.js';
23
+ import type { LaunchOptions } from 'puppeteer-core';
24
+ import { CANVAS_VIEWPORTS, type CanvasClass, type CanvasViewport } from '../design-mode.js';
23
25
 
24
26
  // ---------------------------------------------------------------------------
25
27
  // Types
@@ -36,6 +38,18 @@ export interface VisualEvalConfig {
36
38
  sampleProps?: Record<string, unknown>;
37
39
  /** Viewport dimensions for screenshot */
38
40
  viewport?: { width: number; height: number };
41
+ /**
42
+ * Per-canvas judging — ARM-NEUTRAL (the same judge, prompt and
43
+ * threshold in every design mode). When set, the SAME rendered HTML
44
+ * shell is screenshotted once per class at `CANVAS_VIEWPORTS[class]`
45
+ * and judged once per class; the result carries one entry per class
46
+ * in `canvases`, and the single-shot fields aggregate: `finalScore` =
47
+ * mean of the canvas scores (rounded), `passed` = every canvas passed,
48
+ * `dimensions` = per-dimension means, `issues` = every canvas's issues
49
+ * (description prefixed `[<canvas>]`). Unset (or empty) = today's
50
+ * single screenshot at `viewport`; no `canvases` field on the result.
51
+ */
52
+ canvases?: readonly CanvasClass[];
39
53
  /**
40
54
  * Optional provider-routing override — see
41
55
  * `AgentConfig.routeOverride`. Threaded onto the agent this
@@ -52,6 +66,34 @@ export interface VisualEvalConfig {
52
66
  onRetry?: AgentConfig['onRetry'];
53
67
  }
54
68
 
69
+ /** One canvas's verdict — screenshot + judge score at that class's viewport. */
70
+ export interface CanvasVisualResult {
71
+ canvas: CanvasClass;
72
+ viewport: CanvasViewport;
73
+ /** The judge's weighted score for this canvas (0-100). */
74
+ score: number;
75
+ /** `score >= passThreshold`. */
76
+ passed: boolean;
77
+ /** The PNG the judge saw — kept for artefact persistence + the human look. */
78
+ screenshotPng: Buffer;
79
+ }
80
+
81
+ /**
82
+ * `runVisualEvaluation`'s result. Identical to `EvaluationResult` when
83
+ * `config.canvases` is unset; with canvases set, `canvases` holds the
84
+ * per-class verdicts and the base fields aggregate them (see
85
+ * `VisualEvalConfig.canvases`).
86
+ */
87
+ export interface VisualEvaluationResult extends EvaluationResult {
88
+ canvases?: CanvasVisualResult[];
89
+ }
90
+
91
+ /** Injection points for `runVisualEvaluation` — screenshot deps plus the judge call (tests). */
92
+ export interface VisualEvalDeps extends ScreenshotDeps {
93
+ /** The multimodal judge call (default: `callMultimodalLLM`). */
94
+ judge?: typeof callMultimodalLLM;
95
+ }
96
+
55
97
  export interface VisualEvalContext {
56
98
  /** Compiled JavaScript code (ESM with imports) */
57
99
  compiledCode: string;
@@ -81,6 +123,18 @@ export interface VisualEvalContext {
81
123
  * `<pkg>/dist/evaluation`) is `oss/packages/`; the design package is
82
124
  * its direct child.
83
125
  */
126
+ /**
127
+ * The on-disk root of `@ggui-ai/ui-gen` itself. The bundle's entry lives
128
+ * in a temp dir, so bare imports in the GENERATED component (`@ggui-ai/wire`
129
+ * and every other allowlisted package) resolve nowhere from there — they
130
+ * resolve from this package's `node_modules` instead (`nodePaths`), the
131
+ * same place the harness's own imports come from.
132
+ */
133
+ export function resolveUiGenPackageDir(): string {
134
+ const selfDir = dirname(fileURLToPath(import.meta.url));
135
+ return resolve(selfDir, '..', '..');
136
+ }
137
+
84
138
  export function resolveDesignPackageDir(): string {
85
139
  const selfDir = dirname(fileURLToPath(import.meta.url));
86
140
  return resolve(selfDir, '..', '..', '..', 'design');
@@ -102,15 +156,43 @@ async function bundleForRendering(
102
156
  writeFileSync(componentFile, compiledCode);
103
157
 
104
158
  // Entry file imports the component and renders it
159
+ // The generated component is rendered the way a host renders it: inside
160
+ // `GguiWireProvider` with a stub config (dispatch/subscribe are no-ops —
161
+ // the judge sees the first paint, not the wire round-trip). Without the
162
+ // provider every `useAction` / `useStream` bearer threw
163
+ // "useWireContext must be used within a WireProvider", React unmounted
164
+ // the root and the judge scored a blank page. An error boundary turns a
165
+ // render throw into visible "Render error" text (which the rubric
166
+ // scores 0) instead of the same blank.
105
167
  const entryCode = `
106
168
  import React from 'react';
107
169
  import { createRoot } from 'react-dom/client';
170
+ import { GguiWireProvider } from '@ggui-ai/wire';
108
171
  import Component from './component.tsx';
109
172
 
110
173
  const props = ${JSON.stringify(sampleProps)};
174
+ const wireConfig = {
175
+ app: { appId: 'visual-eval', appName: 'Visual evaluation' },
176
+ render: { sessionId: 'visual-eval', isConnected: true },
177
+ auth: { isAuthenticated: false },
178
+ dispatch: () => {},
179
+ subscribe: () => () => {},
180
+ };
181
+ class RenderErrorBoundary extends React.Component<{ children: React.ReactNode }, { message: string | null }> {
182
+ state = { message: null as string | null };
183
+ static getDerivedStateFromError(err: unknown) { return { message: err instanceof Error ? err.message : String(err) }; }
184
+ render() {
185
+ return this.state.message === null
186
+ ? this.props.children
187
+ : React.createElement('div', { className: 'error' }, 'Render error: ' + this.state.message);
188
+ }
189
+ }
111
190
  const root = createRoot(document.getElementById('root')!);
112
191
  try {
113
- root.render(React.createElement(Component, props));
192
+ root.render(
193
+ React.createElement(GguiWireProvider, { config: wireConfig },
194
+ React.createElement(RenderErrorBoundary, null, React.createElement(Component, props))),
195
+ );
114
196
  } catch (err) {
115
197
  root.render(React.createElement('div', { className: 'error' }, 'Render error: ' + (err as Error).message));
116
198
  }
@@ -138,6 +220,10 @@ try {
138
220
  '@ggui-ai/design/compositions': resolve(designPkgDir, 'src', 'compositions', 'index.ts'),
139
221
  '@ggui-ai/design/interact': resolve(designPkgDir, 'src', 'interact', 'index.ts'),
140
222
  },
223
+ // Bare imports in the generated component resolve against ui-gen's
224
+ // own node_modules — the temp entry dir has none. Without this every
225
+ // wire-bearing component (`@ggui-ai/wire`) failed to bundle.
226
+ nodePaths: [resolve(resolveUiGenPackageDir(), 'node_modules')],
141
227
  logLevel: 'silent',
142
228
  });
143
229
 
@@ -199,27 +285,104 @@ ${bundledCode}
199
285
  // Screenshot capture (optional puppeteer dependency)
200
286
  // ---------------------------------------------------------------------------
201
287
 
288
+ /** The page surface the capture uses — a structural subset of puppeteer's `Page`. */
289
+ export interface ScreenshotPage {
290
+ setContent(html: string, options: { waitUntil: 'load'; timeout: number }): Promise<void>;
291
+ waitForNetworkIdle(options: { idleTime: number; timeout: number }): Promise<void>;
292
+ waitForSelector(selector: string, options: { timeout: number }): Promise<unknown>;
293
+ screenshot(options: { type: 'png'; fullPage: boolean }): Promise<Uint8Array>;
294
+ }
295
+
296
+ /** The browser surface the capture uses — a structural subset of puppeteer's `Browser`. */
297
+ export interface ScreenshotBrowser {
298
+ newPage(): Promise<ScreenshotPage>;
299
+ close(): Promise<void>;
300
+ }
301
+
302
+ export type ScreenshotLauncher = (options: LaunchOptions) => Promise<ScreenshotBrowser>;
303
+
304
+ /** The two members of `@sparticuz/chromium` the fallback launch path reads. */
305
+ export interface ChromiumProvider {
306
+ readonly args: string[];
307
+ executablePath(): Promise<string>;
308
+ }
309
+
202
310
  /**
203
- * Take a screenshot of the rendered component.
204
- * Returns base64-encoded PNG or null if puppeteer is not available.
311
+ * Injection points for the capture — every default is the production
312
+ * path; tests replace them to pin branch selection without launching a
313
+ * browser.
205
314
  */
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');
315
+ export interface ScreenshotDeps {
316
+ /** Launches the browser (default: `puppeteer-core`'s `launch`). */
317
+ launch?: ScreenshotLauncher;
318
+ /** Environment the executable-path override is read from (default: `process.env`). */
319
+ env?: NodeJS.ProcessEnv;
320
+ /** Loads `@sparticuz/chromium` (default: dynamic import) — only consulted when no override is set. */
321
+ loadChromium?: () => Promise<ChromiumProvider>;
322
+ /** Post-load settle time before the screenshot, ms (default 1000). */
323
+ settleMs?: number;
324
+ }
325
+
326
+ /**
327
+ * Chromium flags the `PUPPETEER_EXECUTABLE_PATH` launch adds: a system
328
+ * Chromium inside a container has no user namespace for the sandbox and
329
+ * `/dev/shm` is too small for a render surface.
330
+ */
331
+ export const EXECUTABLE_PATH_LAUNCH_ARGS: readonly string[] = ['--no-sandbox', '--disable-dev-shm-usage'];
214
332
 
215
- const launchOptions = {
216
- args: chromium.default.args,
333
+ const DEFAULT_SCREENSHOT_VIEWPORT = { width: 1280, height: 800 } as const;
334
+
335
+ async function loadSparticuzChromium(): Promise<ChromiumProvider> {
336
+ const chromium = await import('@sparticuz/chromium');
337
+ return chromium.default;
338
+ }
339
+
340
+ /**
341
+ * Pick the browser binary. `PUPPETEER_EXECUTABLE_PATH` (a system
342
+ * Chromium — the bench runner image, a dev box) wins and skips the
343
+ * `@sparticuz/chromium` load entirely; otherwise the bundled Lambda
344
+ * Chromium supplies both binary and flags, as before.
345
+ */
346
+ export async function resolveLaunchOptions(
347
+ viewport: { width: number; height: number },
348
+ deps: Pick<ScreenshotDeps, 'env' | 'loadChromium'> = {},
349
+ ): Promise<LaunchOptions> {
350
+ const env = deps.env ?? process.env;
351
+ const override = env.PUPPETEER_EXECUTABLE_PATH?.trim();
352
+ if (override) {
353
+ return {
354
+ executablePath: override,
355
+ args: [...EXECUTABLE_PATH_LAUNCH_ARGS],
217
356
  defaultViewport: viewport,
218
- executablePath: await chromium.default.executablePath(),
219
357
  headless: true,
220
358
  };
359
+ }
360
+ const chromium = await (deps.loadChromium ?? loadSparticuzChromium)();
361
+ return {
362
+ args: chromium.args,
363
+ defaultViewport: viewport,
364
+ executablePath: await chromium.executablePath(),
365
+ headless: true,
366
+ };
367
+ }
221
368
 
222
- const browser = await puppeteer.default.launch(launchOptions);
369
+ async function launchWithPuppeteer(options: LaunchOptions): Promise<ScreenshotBrowser> {
370
+ const puppeteer = await import('puppeteer-core');
371
+ return puppeteer.default.launch(options);
372
+ }
373
+
374
+ /**
375
+ * Take a screenshot of the rendered component.
376
+ * Returns the PNG bytes, or null if no browser could be launched.
377
+ */
378
+ export async function captureScreenshot(
379
+ html: string,
380
+ viewport: { width: number; height: number } = DEFAULT_SCREENSHOT_VIEWPORT,
381
+ deps: ScreenshotDeps = {},
382
+ ): Promise<Buffer | null> {
383
+ try {
384
+ const launchOptions = await resolveLaunchOptions(viewport, deps);
385
+ const browser = await (deps.launch ?? launchWithPuppeteer)(launchOptions);
223
386
  try {
224
387
  const page = await browser.newPage();
225
388
  // puppeteer ≥24.43 narrowed setContent's waitUntil to
@@ -229,9 +392,9 @@ async function captureScreenshot(
229
392
  await page.waitForNetworkIdle({ idleTime: 500, timeout: 15000 }).catch(() => {});
230
393
  await page.waitForSelector('#root > *', { timeout: 10000 }).catch(() => {});
231
394
  // 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;
395
+ await new Promise((r) => setTimeout(r, deps.settleMs ?? 1000));
396
+ const screenshot = await page.screenshot({ type: 'png', fullPage: true });
397
+ return Buffer.from(screenshot);
235
398
  } finally {
236
399
  await browser.close();
237
400
  }
@@ -333,8 +496,10 @@ async function callMultimodalLLM(
333
496
  export async function runVisualEvaluation(
334
497
  context: VisualEvalContext,
335
498
  config: VisualEvalConfig,
336
- ): Promise<EvaluationResult | null> {
499
+ deps: VisualEvalDeps = {},
500
+ ): Promise<VisualEvaluationResult | null> {
337
501
  const startTime = Date.now();
502
+ const judge = deps.judge ?? callMultimodalLLM;
338
503
 
339
504
  // Bundle component + design system into a single JS file
340
505
  const bundledCode = await bundleForRendering(
@@ -343,11 +508,49 @@ export async function runVisualEvaluation(
343
508
  );
344
509
  console.log(`[visual-eval] bundled: ${bundledCode.length}B (${Date.now() - startTime}ms)`);
345
510
 
346
- // Build the HTML page
511
+ // Build the HTML page — ONE shell; per-canvas mode re-renders it at each viewport.
347
512
  const html = buildRenderHTML(bundledCode, context.cssTokens);
513
+ const model = config.model ?? getDefaultVisualModel(config.provider);
348
514
 
349
- // Take screenshot
350
- const screenshot = await captureScreenshot(html, config.viewport);
515
+ // ── Per-canvas mode: one screenshot + one judge call per class ──
516
+ if (config.canvases !== undefined && config.canvases.length > 0) {
517
+ const perCanvas: CanvasVisualResult[] = [];
518
+ const perCanvasResults: EvaluationResult[] = [];
519
+ for (const canvas of config.canvases) {
520
+ const viewport = CANVAS_VIEWPORTS[canvas];
521
+ const screenshot = await captureScreenshot(html, viewport, deps);
522
+ if (!screenshot) {
523
+ console.warn(`[visual-eval] no browser available at canvas ${canvas} — skipping visual evaluation`);
524
+ return null;
525
+ }
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
+ perCanvasResults.push(result);
531
+ perCanvas.push({
532
+ canvas,
533
+ viewport,
534
+ score: result.finalScore,
535
+ passed: result.passed,
536
+ screenshotPng: screenshot,
537
+ });
538
+ 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}`,
541
+ );
542
+ }
543
+ const aggregate = aggregateCanvasResults(perCanvas, perCanvasResults);
544
+ const elapsed = Date.now() - startTime;
545
+ console.log(
546
+ `[visual-eval] score=${aggregate.finalScore} (mean of ${perCanvas.length} canvases; ` +
547
+ `${aggregate.passed ? 'every canvas passed' : `${perCanvas.filter((c) => !c.passed).length} failed`}) (${elapsed}ms)`,
548
+ );
549
+ return aggregate;
550
+ }
551
+
552
+ // ── Single-shot mode (today's path) ──
553
+ const screenshot = await captureScreenshot(html, config.viewport, deps);
351
554
  if (!screenshot) {
352
555
  console.warn('[visual-eval] no browser available — skipping visual evaluation');
353
556
  return null;
@@ -355,8 +558,7 @@ export async function runVisualEvaluation(
355
558
  console.log(`[visual-eval] screenshot: ${screenshot.length}B (${Date.now() - startTime}ms)`);
356
559
 
357
560
  // Send to multimodal LLM
358
- const model = config.model ?? getDefaultVisualModel(config.provider);
359
- const response = await callMultimodalLLM(
561
+ const response = await judge(
360
562
  config,
361
563
  model,
362
564
  VISUAL_EVAL_PROMPT,
@@ -375,6 +577,61 @@ export async function runVisualEvaluation(
375
577
  return result;
376
578
  }
377
579
 
580
+ /**
581
+ * Fold per-canvas verdicts into the single-shot fields: `finalScore` =
582
+ * rounded mean, `passed` = every canvas passed, `dimensions` = rounded
583
+ * per-dimension means, `issues` = union (description prefixed with the
584
+ * canvas), `critique` = one line per canvas, tokens summed.
585
+ */
586
+ function aggregateCanvasResults(
587
+ perCanvas: readonly CanvasVisualResult[],
588
+ results: readonly EvaluationResult[],
589
+ ): VisualEvaluationResult {
590
+ const n = results.length;
591
+ const mean = (pick: (r: EvaluationResult) => number): number =>
592
+ Math.round(results.reduce((sum, r) => sum + pick(r), 0) / n);
593
+ const dimensions: DimensionScores = {
594
+ completeness: mean((r) => r.dimensions.completeness),
595
+ visualPolish: mean((r) => r.dimensions.visualPolish),
596
+ interactivity: mean((r) => r.dimensions.interactivity),
597
+ accessibility: mean((r) => r.dimensions.accessibility),
598
+ codeQuality: mean((r) => r.dimensions.codeQuality),
599
+ };
600
+ const issues: EvaluationIssue[] = [];
601
+ const critiques: string[] = [];
602
+ let inputTokens = 0;
603
+ let outputTokens = 0;
604
+ results.forEach((r, i) => {
605
+ const canvas = perCanvas[i]!.canvas;
606
+ for (const issue of r.issues) issues.push({ ...issue, description: `[${canvas}] ${issue.description}` });
607
+ if (r.critique) critiques.push(`${canvas}: ${r.critique}`);
608
+ inputTokens += r.inputTokens ?? 0;
609
+ outputTokens += r.outputTokens ?? 0;
610
+ });
611
+ return {
612
+ passed: perCanvas.every((c) => c.passed),
613
+ finalScore: mean((r) => r.finalScore),
614
+ dimensions,
615
+ issues,
616
+ ...(critiques.length > 0 ? { critique: critiques.join('\n') } : {}),
617
+ inputTokens,
618
+ outputTokens,
619
+ canvases: [...perCanvas],
620
+ };
621
+ }
622
+
623
+ /** The per-canvas verdicts without the PNGs — what the harness stamps on `EvalResult.visual`. */
624
+ export function summarizeVisualResult(result: VisualEvaluationResult): VisualEvalSummary | undefined {
625
+ if (result.canvases === undefined) return undefined;
626
+ const canvases: CanvasVisualSummary[] = result.canvases.map((c) => ({
627
+ canvas: c.canvas,
628
+ viewport: c.viewport,
629
+ score: c.score,
630
+ passed: c.passed,
631
+ }));
632
+ return { score: result.finalScore, passed: result.passed, canvases };
633
+ }
634
+
378
635
  /**
379
636
  * Exported for testing — bundles and builds the HTML for rendering;
380
637
  * `callMultimodalLLM` so the #504 routing contract (config →
@@ -448,17 +705,30 @@ function getDefaultVisualModel(provider: 'claude' | 'google'): string {
448
705
  // Tier 2 adapter — returns EvalIssue[]
449
706
  // ---------------------------------------------------------------------------
450
707
 
708
+ /** What the harness's eval round consumes from the visual leg. */
709
+ export interface VisualEvalOutcome {
710
+ /** Tier-2 issues (empty when no browser is available). */
711
+ issues: EvalIssue[];
712
+ /**
713
+ * Per-canvas summary — present ONLY when `config.canvases` was set and
714
+ * the leg ran; the harness stamps it on `EvalResult.visual`. Absent on
715
+ * the single-shot path (byte-identical to the pre-canvas result).
716
+ */
717
+ summary?: VisualEvalSummary;
718
+ }
719
+
451
720
  /**
452
- * Run visual evaluation and return tier 2 EvalIssues.
721
+ * Run visual evaluation and return tier 2 EvalIssues (+ the per-canvas
722
+ * summary when canvases were requested).
453
723
  */
454
724
  export async function runVisualEval(
455
725
  context: VisualEvalContext,
456
726
  config: VisualEvalConfig,
457
- ): Promise<EvalIssue[]> {
727
+ ): Promise<VisualEvalOutcome> {
458
728
  const result = await runVisualEvaluation(context, config);
459
- if (!result) return []; // puppeteer not available
729
+ if (!result) return { issues: [] }; // puppeteer not available
460
730
 
461
- return (result.issues || []).map(issue => ({
731
+ const issues: EvalIssue[] = (result.issues || []).map(issue => ({
462
732
  tier: 2 as const,
463
733
  result: (issue.severity === 'critical' ? 'fail' : 'warn') as 'fail' | 'warn',
464
734
  category: 'visual' as const,
@@ -467,4 +737,6 @@ export async function runVisualEval(
467
737
  description: issue.description,
468
738
  fix: issue.fix || '',
469
739
  }));
740
+ const summary = summarizeVisualResult(result);
741
+ return summary === undefined ? { issues } : { issues, summary };
470
742
  }
Binary file
@@ -168,6 +168,7 @@ export async function initSession(input: {
168
168
  originalPrompt: params.userPrompt,
169
169
  contract: params.contract,
170
170
  designContext: undefined,
171
+ designMode: harness.designMode,
171
172
  },
172
173
  {
173
174
  provider: mapProviderForEvaluator(evalSpec.provider),
@@ -674,6 +674,9 @@ ${closingInstruction}`;
674
674
  // typecheck overlays the real wrapper declarations and a generated
675
675
  // direct gadget import gets strict option/return narrowing.
676
676
  harness.what.gadgetTypes,
677
+ // Which tier-0 legs fire — the design-vocabulary legs stand down in
678
+ // `free` mode; every contract leg runs in both.
679
+ harness.designMode,
677
680
  );
678
681
  const toolMs = Date.now() - toolStart;
679
682
 
@@ -22,7 +22,7 @@ import { listContractGadgets } from "@ggui-ai/protocol";
22
22
  import type { Classification } from "../../classifier/index.js";
23
23
  import type { AgentWorkspace } from "../../coding-agent/workspace.js";
24
24
  import type { CostTracker } from "../../evaluation/cost-tracker.js";
25
- import type { EvalIssue, EvalResult, RuntimeProbeMeta } from "../../evaluation/types-public.js";
25
+ import type { EvalIssue, EvalResult, RuntimeProbeMeta, VisualEvalSummary } from "../../evaluation/types-public.js";
26
26
  import { notApplicableCoverage } from "../../evaluation/types-public.js";
27
27
  import { mapProviderForEvaluator } from "../enforced-coding.js";
28
28
  import { runCheck } from "../index.js";
@@ -453,6 +453,10 @@ export async function runEvalRound(
453
453
  sourceCode: currentSource,
454
454
  originalPrompt: userPrompt,
455
455
  contract,
456
+ // Criteria + mother prompt follow the triad that produced
457
+ // the source (free mode: color-only tokens, arm-neutral
458
+ // visual judging).
459
+ designMode: harness.designMode,
456
460
  },
457
461
  {
458
462
  provider: mapProviderForEvaluator(evaluationAgent.provider),
@@ -483,6 +487,10 @@ export async function runEvalRound(
483
487
  passThreshold: visualThreshold,
484
488
  sampleProps: visualEvaluation?.sampleProps,
485
489
  viewport: visualEvaluation?.viewport,
490
+ // Per-canvas judging (arm-neutral) — absent = single shot.
491
+ ...(visualEvaluation?.canvases !== undefined
492
+ ? { canvases: visualEvaluation.canvases }
493
+ : {}),
486
494
  // #484/#489 — same threading as the LLM-eval leg above:
487
495
  // the visual eval's multimodal call runs inside the same
488
496
  // concurrent generation, so it needs the same routing
@@ -497,7 +505,8 @@ export async function runEvalRound(
497
505
  ]);
498
506
  evalLlmMs = Date.now() - evalLlmStart;
499
507
  llmResult = llm;
500
- visualIssues = visual;
508
+ visualIssues = visual?.issues ?? null;
509
+ const visualSummary: VisualEvalSummary | undefined = visual?.summary;
501
510
 
502
511
  // Track costs from LLM eval calls
503
512
  if (llmResult) {
@@ -524,6 +533,10 @@ export async function runEvalRound(
524
533
  ...(llmResult?.criteriaCoverage !== undefined
525
534
  ? { criteriaCoverage: llmResult.criteriaCoverage }
526
535
  : {}),
536
+ // Per-canvas visual summary — present only when canvases were
537
+ // requested and the leg ran; every later re-stamp spreads this
538
+ // base so it survives to the assembled result.
539
+ ...(visualSummary !== undefined ? { visual: visualSummary } : {}),
527
540
  };
528
541
 
529
542
  // ── Log merged results, with mode-check subcategory breakdown ──