@ggui-ai/ui-gen 0.18.0 → 0.20.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 (75) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +1 -1
  2. package/dist/adapters/generation-dispatch.js +248 -88
  3. package/dist/adapters/generation-dispatch.js.map +1 -1
  4. package/dist/adapters/index.js +39 -16
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/adapters/types.d.ts +1 -1
  7. package/dist/adapters/types.js.map +1 -1
  8. package/dist/advanced/index.d.ts +2 -2
  9. package/dist/advanced/index.js +217 -34
  10. package/dist/advanced/index.js.map +1 -1
  11. package/dist/blueprint-validator.js +5 -2
  12. package/dist/blueprint-validator.js.map +1 -1
  13. package/dist/boilerplate.js +2 -2
  14. package/dist/boilerplate.js.map +1 -1
  15. package/dist/check/index.d.ts +1 -1
  16. package/dist/check/index.js.map +1 -1
  17. package/dist/{create-ui-generator-C8Ki9Wz1.d.ts → create-ui-generator-CxPvudbs.d.ts} +1 -1
  18. package/dist/{dispatch-D2htzi9h.d.ts → dispatch-N8wdNOX-.d.ts} +4 -2
  19. package/dist/evaluation/axis-checks/index.d.ts +3 -3
  20. package/dist/evaluation/axis-checks/index.js +143 -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/axis-checks/registry.js +138 -1
  24. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  25. package/dist/evaluation/index.d.ts +3 -3
  26. package/dist/evaluation/index.js +213 -84
  27. package/dist/evaluation/index.js.map +1 -1
  28. package/dist/harness/check/runtime-render/index.d.ts +1 -1
  29. package/dist/harness/check/runtime-render/index.js +5 -2
  30. package/dist/harness/check/runtime-render/index.js.map +1 -1
  31. package/dist/harness/check/runtime-render/render-check-worker.js +5 -2
  32. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  33. package/dist/harness/index.d.ts +1 -1
  34. package/dist/harness/index.js +64 -61
  35. package/dist/harness/index.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 +1 -1
  39. package/dist/harness/runtime.d.ts +1 -1
  40. package/dist/harness/runtime.js +4 -4
  41. package/dist/harness/runtime.js.map +1 -1
  42. package/dist/harness/types-public.d.ts +8 -1
  43. package/dist/harness/types-public.js +64 -61
  44. package/dist/harness/types-public.js.map +1 -1
  45. package/dist/harness/validator-trace-sink.d.ts +1 -1
  46. package/dist/index.d.ts +2 -2
  47. package/dist/index.js +217 -34
  48. package/dist/index.js.map +1 -1
  49. package/dist/patch.d.ts +1 -1
  50. package/dist/{types-public-DVSssdc-.d.ts → types-public-DKR_h70H.d.ts} +9 -0
  51. package/dist/validation/index.d.ts +1 -1
  52. package/dist/validation/index.js +1 -1
  53. package/dist/validation/index.js.map +1 -1
  54. package/dist/workflows.d.ts +1 -1
  55. package/package.json +12 -12
  56. package/src/adapters/google/sdk.ts +11 -7
  57. package/src/adapters/openai/raw.ts +11 -5
  58. package/src/adapters/openai/sdk.ts +8 -5
  59. package/src/adapters/openai/tokens.ts +49 -0
  60. package/src/adapters/types.ts +1 -1
  61. package/src/boilerplate/hard-sections.ts +1 -1
  62. package/src/boilerplate/system-prompt.ts +1 -1
  63. package/src/coding-agent/tools.ts +58 -6
  64. package/src/contract-context.ts +1 -1
  65. package/src/create-harness.ts +1 -0
  66. package/src/evaluation/axis-checks/checks/universal.ts +207 -1
  67. package/src/evaluation/axis-checks/dispatch.ts +8 -1
  68. package/src/evaluation/types-public.ts +9 -0
  69. package/src/harness/check/runtime-render/render-check.ts +15 -0
  70. package/src/harness/coding/generate-task-runner.ts +1 -0
  71. package/src/harness/coding/run-coding-turn.ts +14 -0
  72. package/src/harness/types-public.ts +7 -0
  73. package/src/run-check.ts +2 -0
  74. package/src/tools/get-wire.ts +1 -1
  75. package/src/validation/primitives.ts +1 -1
@@ -2,7 +2,7 @@ import { C as Classification } from './axes-CzLEMDeB.js';
2
2
  import { Workflow } from './harness/types-public.js';
3
3
  import '@ggui-ai/protocol';
4
4
  import './styling-profile-CxfvV48g.js';
5
- import './types-public-DVSssdc-.js';
5
+ import './types-public-DKR_h70H.js';
6
6
  import './design-mode-DLufhrNp.js';
7
7
  import './types-BOvHNG7K.js';
8
8
  import './llm.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/ui-gen",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "UI generation harness for ggui — the LLM system prompt, multi-turn coding loop, and evaluation engine that turn a DataContract into a React component. Implements the UiGenerator contract from @ggui-ai/mcp-server-core; consumed by @ggui-ai/mcp-server.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -188,9 +188,9 @@
188
188
  "puppeteer-core": "^24.40.0 || ^25.0.0",
189
189
  "react": "^18.0.0 || ^19.0.0",
190
190
  "react-dom": "^18.0.0 || ^19.0.0",
191
- "@ggui-ai/mcp-server-core": "0.18.0",
192
- "@ggui-ai/project-config": "0.18.0",
193
- "@ggui-ai/protocol": "0.18.0"
191
+ "@ggui-ai/project-config": "0.20.0",
192
+ "@ggui-ai/mcp-server-core": "0.20.0",
193
+ "@ggui-ai/protocol": "0.20.0"
194
194
  },
195
195
  "peerDependenciesMeta": {
196
196
  "@google/adk": {
@@ -229,11 +229,11 @@
229
229
  "openai": "^7.9.0",
230
230
  "typescript": "^5.0.0",
231
231
  "zod": "^4.3.6",
232
- "@ggui-ai/design": "0.18.0",
233
- "@ggui-ai/sandbox": "0.18.0",
234
- "@ggui-ai/ui-visual-tester": "0.18.0",
235
- "@ggui-ai/wire": "0.18.0",
236
- "@ggui-ai/gadgets": "0.18.0"
232
+ "@ggui-ai/gadgets": "0.20.0",
233
+ "@ggui-ai/design": "0.20.0",
234
+ "@ggui-ai/sandbox": "0.20.0",
235
+ "@ggui-ai/ui-visual-tester": "0.20.0",
236
+ "@ggui-ai/wire": "0.20.0"
237
237
  },
238
238
  "devDependencies": {
239
239
  "@google/adk": "^2.0.0",
@@ -248,9 +248,9 @@
248
248
  "react-dom": "^19.0.0",
249
249
  "tsup": "^8.5.0",
250
250
  "vitest": "^3.2.6",
251
- "@ggui-ai/project-config": "0.18.0",
252
- "@ggui-ai/protocol": "0.18.0",
253
- "@ggui-ai/mcp-server-core": "0.18.0"
251
+ "@ggui-ai/project-config": "0.20.0",
252
+ "@ggui-ai/protocol": "0.20.0",
253
+ "@ggui-ai/mcp-server-core": "0.20.0"
254
254
  },
255
255
  "engines": {
256
256
  "node": ">=22.0.0"
@@ -62,15 +62,10 @@ export async function runAdkLoop(
62
62
  ): Promise<AdapterResult> {
63
63
  const startTime = Date.now();
64
64
 
65
- const { FunctionTool, LlmAgent, InMemoryRunner, isFinalResponse } = await import('@google/adk');
65
+ const { FunctionTool, Gemini, LlmAgent, InMemoryRunner, isFinalResponse } = await import('@google/adk');
66
66
 
67
67
  const capture = createCapture();
68
68
 
69
- // Ensure ADK can find the API key
70
- if (params.apiKey && !process.env.GOOGLE_GENAI_API_KEY) {
71
- process.env.GOOGLE_GENAI_API_KEY = params.apiKey;
72
- }
73
-
74
69
  // Build tools with capture wrappers
75
70
  const adkTools = params.tools.map((def) =>
76
71
  new FunctionTool({
@@ -88,9 +83,18 @@ export async function runAdkLoop(
88
83
  }),
89
84
  );
90
85
 
86
+ // The key the caller passed WINS, handed to the model directly (ggui#1132).
87
+ // The ADK resolves GOOGLE_GENAI_API_KEY || GOOGLE_API_KEY || GEMINI_API_KEY
88
+ // on its own when no key is given, so writing GOOGLE_GENAI_API_KEY here —
89
+ // a variable this adapter does not own — let a stale ambient value beat a
90
+ // fresh key, and no rotation could reach it. Precedence is fixed at the
91
+ // source: an explicit key never touches process.env; no key defers to the
92
+ // ADK's order untouched.
93
+ const model = params.apiKey ? new Gemini({ model: params.model, apiKey: params.apiKey }) : params.model;
94
+
91
95
  const agent = new LlmAgent({
92
96
  name: 'ui_generator',
93
- model: params.model,
97
+ model,
94
98
  instruction: params.systemPrompt,
95
99
  tools: adkTools,
96
100
  });
@@ -10,6 +10,7 @@ import { GeneratorAdapter, hasCredentials } from '../base';
10
10
  import type { AdapterConfig, GenerateParams } from '../base';
11
11
  import type { AdapterResult, ToolDefinition, ProviderName, AdapterMode } from '../types';
12
12
  import { zodToJsonSchema } from '../tool-bridge';
13
+ import { splitOpenAiUsage } from './tokens';
13
14
  import {
14
15
  createCapture,
15
16
  captureSourceCode,
@@ -54,6 +55,9 @@ export class OpenAiRawAdapter extends GeneratorAdapter {
54
55
  const capture = createCapture();
55
56
  let totalInputTokens = 0;
56
57
  let totalOutputTokens = 0;
58
+ // OpenAI's input token count INCLUDES cached tokens; track the cached
59
+ // subset so tokens.input reports NON-cached input (ggui#1186).
60
+ let totalCachedInputTokens = 0;
57
61
  let turnsUsed = 0;
58
62
  let allTextOutput = '';
59
63
 
@@ -74,6 +78,8 @@ export class OpenAiRawAdapter extends GeneratorAdapter {
74
78
  if (response.usage) {
75
79
  totalInputTokens += response.usage.input_tokens ?? 0;
76
80
  totalOutputTokens += response.usage.output_tokens ?? 0;
81
+ totalCachedInputTokens +=
82
+ response.usage.input_tokens_details?.cached_tokens ?? 0;
77
83
  }
78
84
 
79
85
  // Process output items
@@ -156,11 +162,11 @@ export class OpenAiRawAdapter extends GeneratorAdapter {
156
162
  sourceCode: capture.sourceCode,
157
163
  stream: capture.stream,
158
164
  generatorMeta: capture.generatorMeta,
159
- tokens: {
160
- input: totalInputTokens,
161
- output: totalOutputTokens,
162
- total: totalInputTokens + totalOutputTokens,
163
- },
165
+ ...splitOpenAiUsage(
166
+ totalInputTokens,
167
+ totalOutputTokens,
168
+ totalCachedInputTokens,
169
+ ),
164
170
  generationTimeMs: Date.now() - startTime,
165
171
  turnsUsed,
166
172
  };
@@ -6,6 +6,7 @@
6
6
  import { GeneratorAdapter, hasCredentials } from '../base';
7
7
  import type { AdapterConfig, GenerateParams } from '../base';
8
8
  import type { AdapterResult, ProviderName, AdapterMode } from '../types';
9
+ import { splitOpenAiUsage } from './tokens';
9
10
  import {
10
11
  createCapture,
11
12
  captureSourceCode,
@@ -87,17 +88,19 @@ export class OpenAiSdkAdapter extends GeneratorAdapter {
87
88
  const usage = result.state.usage;
88
89
  const inputTokens = usage.inputTokens;
89
90
  const outputTokens = usage.outputTokens;
91
+ // OpenAI's input token count INCLUDES cached tokens; sum the cached
92
+ // subset across per-request details (ggui#1186).
93
+ const cachedInputTokens = (usage.inputTokensDetails ?? []).reduce(
94
+ (sum, details) => sum + (details.cached_tokens ?? 0),
95
+ 0,
96
+ );
90
97
 
91
98
  return {
92
99
  compiledCode: capture.compiledCode,
93
100
  sourceCode: capture.sourceCode,
94
101
  stream: capture.stream,
95
102
  generatorMeta: capture.generatorMeta,
96
- tokens: {
97
- input: inputTokens,
98
- output: outputTokens,
99
- total: inputTokens + outputTokens,
100
- },
103
+ ...splitOpenAiUsage(inputTokens, outputTokens, cachedInputTokens),
101
104
  generationTimeMs: Date.now() - startTime,
102
105
  turnsUsed: result.rawResponses?.length ?? 0,
103
106
  };
@@ -0,0 +1,49 @@
1
+ import type { AdapterResult } from '../types';
2
+
3
+ /**
4
+ * Split an OpenAI usage report into the shared cross-adapter token
5
+ * convention (ggui#1186).
6
+ *
7
+ * OpenAI reports the cached prompt tokens as a SUBSET already counted
8
+ * inside its input token total (`usage.input_tokens` on the Responses
9
+ * API, `usage.inputTokens` on the Agents SDK, both INCLUDING cached
10
+ * tokens). Anthropic (and OpenRouter's Anthropic passthrough) instead
11
+ * report `input_tokens` EXCLUDING cache reads, with cache reads as a
12
+ * separate field.
13
+ *
14
+ * The whole fleet reads one convention: **`tokens.input` is the
15
+ * NON-cached input; `cacheReadTokens` is the cache-hit subset.** Cost
16
+ * and observability consumers price/aggregate `input` at the full input
17
+ * rate and `cacheReadTokens` at the cache-read rate, so the cached
18
+ * portion must appear in exactly one of the two — never both. Reporting
19
+ * OpenAI's cache-inclusive `input_tokens` as `tokens.input` while also
20
+ * populating `cacheReadTokens` would double-count the cached tokens (it
21
+ * inflates `$/gen` and breaks `render_cache_metric`'s `cacheReadPct`
22
+ * denominator). So the OpenAI adapters subtract the cached subset here.
23
+ *
24
+ * `total` is the full processed footprint (non-cached input + cache
25
+ * reads + output = `inputTokensInclCached + outputTokens`), matching the
26
+ * Claude adapter's `total` (input + cacheCreation + cacheRead + output);
27
+ * OpenAI has no explicit cache-creation step, so there is no
28
+ * `cacheCreationTokens` term.
29
+ */
30
+ export function splitOpenAiUsage(
31
+ inputTokensInclCached: number,
32
+ outputTokens: number,
33
+ cachedTokens: number,
34
+ ): { tokens: AdapterResult['tokens']; cacheReadTokens: number } {
35
+ // Never let a malformed usage report drive input negative.
36
+ const cacheRead = Math.min(
37
+ Math.max(cachedTokens, 0),
38
+ Math.max(inputTokensInclCached, 0),
39
+ );
40
+ const nonCachedInput = Math.max(inputTokensInclCached, 0) - cacheRead;
41
+ return {
42
+ tokens: {
43
+ input: nonCachedInput,
44
+ output: outputTokens,
45
+ total: nonCachedInput + cacheRead + outputTokens,
46
+ },
47
+ cacheReadTokens: cacheRead,
48
+ };
49
+ }
@@ -88,7 +88,7 @@ export interface AdapterResult {
88
88
  subagentsUsed?: string[];
89
89
  /** Tokens written to prompt cache (Claude-specific) */
90
90
  cacheCreationTokens?: number;
91
- /** Tokens read from prompt cache (Claude-specific) */
91
+ /** Tokens read from prompt cache (Claude / OpenRouter / OpenAI). */
92
92
  cacheReadTokens?: number;
93
93
  /** SDK session ID for evaluation loop resume (Claude SDK-specific) */
94
94
  sdkSessionId?: string;
@@ -213,7 +213,7 @@ Rules:
213
213
  1. All request-specific data → props with defaults. City names, tickers, user names, dates, counts.
214
214
  2. Layout and styling are universal. Colors, spacing, structure — these are the reusable part.
215
215
  3. Default values come from the current request — so the component renders correctly standalone.
216
- 4. Copy comes from the props. NEVER invent display text the contract does not carry — no eyebrow / kicker / overline (a \`<Text caps>\` label), no helper line, no status text, no tagline. A caps label exists only when a prop supplies its text (\`<Text caps>{props.section}</Text>\`); a heading's fallback is the request's own words, never a slogan of your own. Text the agent cannot change is chatter on every render.
216
+ 4. Copy comes from the props. NEVER invent display text the contract does not carry — no eyebrow / kicker / overline (a \`<Text caps>\` label), no helper line, no status text, no tagline. **Do NOT add a caps label above the heading** — not \`<Text caps>LET'S BEGIN</Text>\`, not \`WELCOME\`, not a section kicker like \`GETTING STARTED\` or \`OVERVIEW\` — UNLESS a prop supplies that exact text (\`<Text caps>{props.section}</Text>\`). When the request asks for a heading and a line, render the heading and the line and NOTHING above the heading. A heading's fallback is the request's own words, never a slogan of your own. Text the agent cannot change is chatter on every render, and a caps eyebrow you wrote is the most common form of it.
217
217
  5. Props interface must be typed and exported.`;
218
218
 
219
219
  /**
@@ -603,7 +603,7 @@ Don't render text-only metrics. A weather widget without a sun/cloud, a stock ca
603
603
  </Row>
604
604
  \`\`\`
605
605
 
606
- Use \`<Icon name="..." />\` for line icons — ONLY the curated Lucide subset renders (call \`get_available_icons\` for the exact kebab-case names; a name outside it renders an empty box and fails the check); emoji directly for status/weather/mood. Both are valid. For per-stat tiny accents, use a small icon next to the label.
606
+ Use \`<Icon name="..." />\` for line icons — ONLY the design system's curated set renders (~200 kebab-case Lucide names); a name OUTSIDE it renders an empty box and fails the check. On a single-pass render you cannot look the set up, so use ONLY a name you are sure is in it. Safe common names: \`check\` \`x\` \`plus\` \`minus\` \`search\` \`settings\` \`user\` \`users\` \`calendar\` \`clock\` \`arrow-right\` \`arrow-left\` \`chevron-right\` \`chevron-down\` \`trash-2\` \`edit\` \`info\` \`alert-circle\` \`bell\` \`home\` \`file\` \`file-text\` \`star\` \`heart\` \`mail\` \`send\` \`download\` \`upload\` \`external-link\` \`filter\` \`check-circle\`. The numbered variants (\`edit-2\`, \`edit-3\`) and synonyms (\`pencil\`, \`pen\`, \`trash\`, \`x-circle\`) are NOT in the set — use \`edit\` and \`trash-2\`. If the icon you want is not clearly one of these, OMIT it: a card with no icon beats one with an empty box. Emoji render directly for status/weather/mood. For per-stat tiny accents, use a small icon next to the label.
607
607
 
608
608
  ### Spacing — generosity beats compactness
609
609
 
@@ -12,6 +12,10 @@ import * as esbuild from 'esbuild';
12
12
  import { AgentWorkspace } from './workspace';
13
13
  import { getSoftWarnings } from './self-check';
14
14
  import { runTier0Checks } from '../check/index.js';
15
+ import { runGatedAxisChecks, traceAxisChecksSkipped } from '../evaluation/axis-checks/dispatch.js';
16
+ import type { AxisCheck, AxisCheckInput } from '../evaluation/types-public.js';
17
+ import type { Classification } from '../classifier/axes.js';
18
+ import type { CanvasClass } from '../design-mode.js';
15
19
  import { PRIMITIVES_DOCUMENTATION } from '../validation/index.js';
16
20
  import type { DataContract } from '@ggui-ai/protocol';
17
21
  import type {
@@ -46,6 +50,22 @@ function getComponentDocumentation(name: string): string {
46
50
  // Auto-commit helper (shared by write and apply_changes)
47
51
  // =============================================================================
48
52
 
53
+ /**
54
+ * The deterministic axis family at auto-commit (ggui#1122) — what a caller with a
55
+ * harness hands `executeTool` so the checks run on EVERY commit the coding agent
56
+ * makes, i.e. on the serve path, not only inside the eval round a serving deployment
57
+ * never enters. `checks` is the harness's pre-filtered list (`harness.check.axisChecks`)
58
+ * — the same list `run-check.ts` feeds the same pure runner. Absent: nothing runs.
59
+ */
60
+ export interface AutoCommitAxisChecks {
61
+ readonly checks: readonly AxisCheck[];
62
+ readonly classification: Classification;
63
+ /** The ORIGINAL request — several checks read it (labels invented past the copy, terminal words). */
64
+ readonly originalPrompt: string;
65
+ /** The canvas the harness was built for (ggui#1117); absent = no rendering context, never a default. */
66
+ readonly canvas?: CanvasClass;
67
+ }
68
+
49
69
  async function autoCommit(
50
70
  workspace: AgentWorkspace,
51
71
  commitMeta: Map<string, CommitMetadata>,
@@ -72,6 +92,8 @@ async function autoCommit(
72
92
  gadgetTypes?: Readonly<Record<string, string>>,
73
93
  /** Which tier-0 legs fire — see `runTier0Checks`'s `designMode`. */
74
94
  designMode: DesignMode = DEFAULT_DESIGN_MODE,
95
+ /** ggui#1122: run the harness's axis checks here too; absent, none run. */
96
+ axis?: AutoCommitAxisChecks,
75
97
  ): Promise<ToolResult> {
76
98
  const commitStart = Date.now();
77
99
  const raw = workspace.read();
@@ -123,17 +145,45 @@ async function autoCommit(
123
145
  gadgetTypes,
124
146
  designMode,
125
147
  );
126
- const tier0Fails = tier0Issues.filter(i => i.result === 'fail');
148
+ // ggui#1122: the deterministic axis family runs HERE — on every commit the coding
149
+ // agent makes, which is the serve path — through the one pure runner `run-check.ts`
150
+ // feeds (the ggui#1046 trace line prints on both paths). Posture is the founder's:
151
+ // warn-as-warn, fail-as-fail. A WARN that started blocking a served generation
152
+ // because it moved surface would be a posture change, not a relocation, and comes
153
+ // to him as a named item. Only a source that BUILT is judged: on a build failure
154
+ // the checks are not asked (the same stand-down the gate+run wrapper makes) and
155
+ // the ggui#1046 trace says so — a skipped round never reads as a clean one.
156
+ const axisFacts = axis !== undefined
157
+ ? {
158
+ sourceCode: formatted,
159
+ ...(contract !== undefined ? { contract } : {}),
160
+ originalPrompt: axis.originalPrompt,
161
+ classification: axis.classification,
162
+ designMode,
163
+ ...(axis.canvas !== undefined ? { canvas: axis.canvas } : {}),
164
+ }
165
+ : undefined;
166
+ if (axisFacts !== undefined && !buildSuccess) traceAxisChecksSkipped(axisFacts, 'build failed — no check ran');
167
+ const axisRun = axis !== undefined && axisFacts !== undefined && buildSuccess
168
+ ? runGatedAxisChecks(axis.checks, { ...axisFacts, compiledCode } satisfies AxisCheckInput)
169
+ : undefined;
170
+ const axisIssues = axisRun?.issues ?? [];
171
+ const checkIssues = [...tier0Issues, ...axisIssues];
172
+ const tier0Fails = checkIssues.filter(i => i.result === 'fail');
127
173
  const selfCheckPassed = tier0Fails.length === 0;
128
174
  // When contextPolicy.labeledTier0 is set, prefix each violation with
129
175
  // its P0/P1/P2 priority so the LLM can rank against the prompt's
130
176
  // schema. The default (off) emits unlabeled feedback.
131
- const violations = tier0Issues
177
+ const violations = checkIssues
132
178
  .filter(i => i.result === 'fail')
133
179
  .map(i => contextPolicy?.labeledTier0
134
180
  ? `[${i.priority ?? 'P0'}-${i.category}] ${i.description}\n Fix: ${i.fix}`
135
181
  : `[${i.category}] ${i.description}\n Fix: ${i.fix}`);
136
- const softWarnings = getSoftWarnings(raw);
182
+ // Axis WARNs ride the existing non-blocking channel — feedback the model sees, never a block.
183
+ const axisWarnings = axisIssues
184
+ .filter(i => i.result === 'warn')
185
+ .map(i => `[${i.subcategory}] ${i.description} Fix: ${i.fix}`);
186
+ const softWarnings = [...getSoftWarnings(raw), ...axisWarnings];
137
187
  const selfCheckMs = Date.now() - selfCheckStart;
138
188
 
139
189
  // Always commit (preserves history)
@@ -144,7 +194,7 @@ async function autoCommit(
144
194
 
145
195
  const status = buildSuccess && selfCheckPassed ? 'PASS' : 'FAIL';
146
196
  console.log(
147
- `[coding-agent] auto-commit: ${status} | build=${buildMs}ms self-check=${selfCheckMs}ms git=${gitMs}ms total=${Date.now() - commitStart}ms | violations=${violations.length}`,
197
+ `[coding-agent] auto-commit: ${status} | build=${buildMs}ms self-check=${selfCheckMs}ms git=${gitMs}ms total=${Date.now() - commitStart}ms | violations=${violations.length} | axis=${axisRun === undefined ? 'off' : `${axisIssues.length - axisWarnings.length}F/${axisWarnings.length}W`}`,
148
198
  );
149
199
  if (violations.length > 0) {
150
200
  for (const v of violations) {
@@ -202,6 +252,8 @@ export async function executeTool(
202
252
  gadgetTypes?: Readonly<Record<string, string>>,
203
253
  /** Which tier-0 legs fire on auto-commit — see `runTier0Checks`'s `designMode`. */
204
254
  designMode: DesignMode = DEFAULT_DESIGN_MODE,
255
+ /** ggui#1122: the harness's axis checks, run at every auto-commit — see `AutoCommitAxisChecks`. */
256
+ axis?: AutoCommitAxisChecks,
205
257
  ): Promise<ToolResult> {
206
258
  switch (tool) {
207
259
  case 'write':
@@ -222,7 +274,7 @@ export async function executeTool(
222
274
  const message = (input.commit_message as string) || `write ${lineCount} lines`;
223
275
 
224
276
  console.log(`[coding-agent] write: ${lineCount} lines → auto-commit`);
225
- return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode);
277
+ return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode, axis);
226
278
  }
227
279
 
228
280
  case 'apply_changes': {
@@ -529,7 +581,7 @@ export async function executeTool(
529
581
  const message = (input.commit_message as string) || 'apply changes';
530
582
 
531
583
  console.log(`[coding-agent] apply_changes: ${changes.length} changes applied → auto-commit`);
532
- return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode);
584
+ return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode, axis);
533
585
  }
534
586
 
535
587
  case 'get_components_info': {
@@ -213,7 +213,7 @@ export function buildContractsContext(
213
213
  // model reads it that way. Emitted only when true, so an absent flag
214
214
  // leaves the prompt bytes identical to before this line existed.
215
215
  if (entry.confirm === true) {
216
- line += `\n Author flag: \`confirm\` — the author marked this action grave enough to confirm before it fires, so a second press is a mistake.`;
216
+ line += `\n Author flag: \`confirm\` — the author marked this action as one to confirm with the user before it fires.`;
217
217
  }
218
218
  return line;
219
219
  })
@@ -224,6 +224,7 @@ export function createHarness(input: CreateHarnessInput): Harness {
224
224
  name,
225
225
  classification,
226
226
  designMode,
227
+ ...(input.canvas !== undefined ? { canvas: input.canvas } : {}),
227
228
  ...(input.profile !== undefined ? { profile: input.profile } : {}),
228
229
  how,
229
230
  what,
@@ -4,9 +4,11 @@
4
4
  // component. Dispatched via a "universal" gate that matches every render
5
5
  // value.
6
6
 
7
- import { LUCIDE_ICON_NAMES } from "@ggui-ai/design";
7
+ import { LUCIDE_ICON_NAMES, maxWidth as CONTAINER_MAX_WIDTH } from "@ggui-ai/design";
8
+ import { CANVAS_VIEWPORTS, type CanvasClass } from "../../../design-mode.js";
8
9
  import type { EvalIssue } from "../../types-public.js";
9
10
  import type { AxisCheck, AxisCheckInput } from "../types.js";
11
+ import type { DataContract } from "@ggui-ai/protocol";
10
12
  import {
11
13
  getRequiredPropNames,
12
14
  mkIssue,
@@ -391,6 +393,198 @@ function runTerminalActionUnguarded(input: AxisCheckInput): EvalIssue[] {
391
393
  ];
392
394
  }
393
395
 
396
+ // ── universal.root_width_cap (ggui#1117 — the eval leg ggui#1113 left out) ─
397
+ // FRAME_SIZING's width half: the outermost element FILLS the frame; a width
398
+ // cap belongs INSIDE it, on the column that holds text. A cap on the root is
399
+ // the defect on a wide canvas (the card becomes a narrow strip with the
400
+ // frame's ground on either side) and a no-op in a chat bubble (~400 px; a
401
+ // 480 px cap never binds) — so the check reads `input.canvas`: it stands
402
+ // down with no canvas (never a guess) and below `md`, and fires only for a
403
+ // cap SMALLER than the canvas. A bare `<Container>` is a cap too — its
404
+ // default preset is `lg`, 768 px — so it binds on `lg` / `xl` and not on
405
+ // `md`. A cap the check cannot resolve to pixels (a `max-w-*` class, `rem`,
406
+ // `%`) is reported because the prompt forbids it on the root. WARN, not
407
+ // fail: the prompt names the one shape never to write; whether a centred
408
+ // column was the right composition stays the model's call.
409
+ export interface RootWidthCap {
410
+ /** The outermost element's tag name as written. */
411
+ readonly element: string;
412
+ /** The cap as written (`maxWidth="sm"`, `maxWidth: '480px'`, `max-w-md`, or `lg (Container default)`). */
413
+ readonly cap: string;
414
+ /** The cap in pixels when it resolves (a Container preset, an `Npx` value); `null` otherwise. */
415
+ readonly px: number | null;
416
+ }
417
+
418
+ /** The opening tag of the OUTERMOST element the default export returns, as written; `null` for none or a fragment. */
419
+ function rootOpeningTag(sourceCode: string): { readonly name: string; readonly attrs: string } | null {
420
+ const start = sourceCode.indexOf("export default function");
421
+ const body = start >= 0 ? sourceCode.slice(start) : sourceCode;
422
+ const ret = /\breturn\s*\(?\s*</.exec(body);
423
+ if (ret === null) return null;
424
+ const at = ret.index + ret[0].length - 1;
425
+ const nameMatch = /^<([A-Za-z][\w.]*)/.exec(body.slice(at));
426
+ if (nameMatch === null) return null;
427
+ let j = at + nameMatch[0].length;
428
+ let depth = 0;
429
+ let quote: string | null = null;
430
+ for (; j < body.length; j++) {
431
+ const ch = body[j]!;
432
+ if (quote !== null) {
433
+ if (ch === quote) quote = null;
434
+ continue;
435
+ }
436
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
437
+ else if (ch === "{") depth++;
438
+ else if (ch === "}") depth--;
439
+ else if (ch === ">" && depth === 0) break;
440
+ }
441
+ return { name: nameMatch[1]!, attrs: body.slice(at + nameMatch[0].length, j) };
442
+ }
443
+
444
+ const CONTAINER_PRESETS: Readonly<Record<string, string>> = CONTAINER_MAX_WIDTH;
445
+ const NOT_A_CAP = new Set(["full", "none", "100%", "unset", "initial", "inherit"]);
446
+
447
+ /** `sm` → 480; `480px` → 480; `100%` / `full` → not a cap (`undefined`); anything else → a cap of unknown size (`null`). */
448
+ function capPx(value: string): number | null | undefined {
449
+ const v = value.trim();
450
+ if (NOT_A_CAP.has(v)) return undefined;
451
+ const resolved = CONTAINER_PRESETS[v] ?? v;
452
+ if (NOT_A_CAP.has(resolved)) return undefined;
453
+ const px = /^(\d+(?:\.\d+)?)px$/.exec(resolved);
454
+ return px === null ? null : Number(px[1]);
455
+ }
456
+
457
+ /** The width cap the outermost returned element carries, or `null` when it fills the frame. */
458
+ export function findRootWidthCap(sourceCode: string): RootWidthCap | null {
459
+ const root = rootOpeningTag(sourceCode);
460
+ if (root === null) return null;
461
+ const { name, attrs } = root;
462
+ const prop = /\bmaxWidth\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*(?:"([^"]*)"|'([^']*)'|(\d+(?:\.\d+)?))\s*\})/.exec(attrs);
463
+ if (prop !== null) {
464
+ const raw = prop[1] ?? prop[2] ?? prop[3] ?? prop[4] ?? `${prop[5]!}px`;
465
+ const px = capPx(raw);
466
+ return px === undefined ? null : { element: name, cap: `maxWidth="${raw}"`, px };
467
+ }
468
+ const style = /\bmaxWidth\s*:\s*(?:"([^"]*)"|'([^']*)'|`([^`]*)`|(\d+(?:\.\d+)?))/.exec(attrs);
469
+ if (style !== null) {
470
+ const raw = style[1] ?? style[2] ?? style[3] ?? `${style[4]!}px`;
471
+ const px = capPx(raw);
472
+ return px === undefined ? null : { element: name, cap: `maxWidth: '${raw}'`, px };
473
+ }
474
+ const cls = /(?<![\w-])max-w-([\w[\]%.-]+)/.exec(attrs);
475
+ if (cls !== null) {
476
+ if (cls[1] === "full" || cls[1] === "none") return null;
477
+ return { element: name, cap: `max-w-${cls[1]!}`, px: null };
478
+ }
479
+ if (name === "Container") return { element: name, cap: "lg (Container default)", px: capPx("lg") ?? null };
480
+ return null;
481
+ }
482
+
483
+ const WIDE_CANVASES: ReadonlySet<CanvasClass> = new Set<CanvasClass>(["md", "lg", "xl"]);
484
+
485
+ function runRootWidthCap(input: AxisCheckInput): EvalIssue[] {
486
+ if (input.compiledCode === null) return [];
487
+ if (input.canvas === undefined || !WIDE_CANVASES.has(input.canvas)) return [];
488
+ const cap = findRootWidthCap(input.sourceCode);
489
+ if (cap === null) return [];
490
+ const width = CANVAS_VIEWPORTS[input.canvas].width;
491
+ if (cap.px !== null && cap.px >= width) return [];
492
+ const sized = cap.px !== null ? `${cap.px}px on a ${width}px canvas` : `on a ${width}px canvas`;
493
+ return [
494
+ mkIssue(
495
+ "universal.root_width_cap",
496
+ `The outermost element <${cap.element}> carries a width cap (${cap.cap}, ${sized}): on this canvas the card becomes a narrow strip with the frame's ground on either side. The frame owns the width; a cap belongs INSIDE the root, on the column that holds text.`,
497
+ 'Let the root fill the frame — drop the cap on the element you return at the top (or `maxWidth="full"`) — and put the reading measure on an inner column: `<Box padding="lg"><Container maxWidth="sm">…</Container></Box>`.',
498
+ "warn",
499
+ ),
500
+ ];
501
+ }
502
+
503
+ // ── universal.action_label_dropped (ggui#1190 — the transcription-drop guard) ─
504
+ // The GUARD half of #1190: the model transcribes an actionSpec label onto a
505
+ // control but DROPS a special character ("Confirm & Schedule" → "Confirm
506
+ // Schedule"), because the verbatim label is not at the write-site. This check
507
+ // NAMES that class on the bench and guards regression; the fix (carry the
508
+ // verbatim label / render it from data) lands separately, so #1190 stays open.
509
+ // WARN-class, deterministic, and tuned to flag ONLY the high-confidence drop.
510
+
511
+ const LABEL_SPECIAL_CHAR = /[^a-z0-9\s]/i;
512
+ const normText = (s: string): string => s.toLowerCase().replace(/\s+/g, " ").trim();
513
+ const alnumText = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, "");
514
+
515
+ /**
516
+ * Extract the VISIBLE rendered text of a component — JSX text nodes (with `{…}`
517
+ * expression containers stripped, nesting-aware) plus visible string
518
+ * attributes (aria-label / placeholder / title / alt). Deliberately excludes
519
+ * identifiers and call args: `useAction('confirmSchedule')` and the `const
520
+ * confirmSchedule` binding must NOT count as the label "Confirm & Schedule"
521
+ * appearing, or every action would false-match its own camelCase name.
522
+ */
523
+ function visibleText(sourceCode: string): string {
524
+ // JSX text nodes: `>text<`. Excluding `>` from the run (`[^<>]`) skips the
525
+ // `=>` arrow-body conflation — an arrow's `>` is followed by code then a
526
+ // `>`, never text then a `<`, so only true tag-close→text→tag runs match,
527
+ // and identifiers (`const confirmSchedule`, `useAction('confirmSchedule')`)
528
+ // are never between `>` and `<`. `{…}` expression children are stripped so an
529
+ // expression's identifiers don't count as visible text.
530
+ const jsx = (sourceCode.match(/>([^<>]*)</g) ?? [])
531
+ .map((m) => m.slice(1, -1).replace(/\{[^}]*\}/g, " "))
532
+ .join(" ");
533
+ let attrs = "";
534
+ const attrRx = /\b(?:aria-label|placeholder|title|alt)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
535
+ let m: RegExpExecArray | null;
536
+ while ((m = attrRx.exec(sourceCode)) !== null) attrs += " " + (m[1] ?? m[2] ?? "");
537
+ return jsx + " " + attrs;
538
+ }
539
+
540
+ /**
541
+ * Find actionSpec labels whose special-character content was dropped from the
542
+ * generated source. FALSE-POSITIVE-SAFE by construction — flags ONLY when the
543
+ * label's alphanumeric words appear CONTIGUOUSLY in the source but the full
544
+ * label (with its "&", "/", "+" …) does not, i.e. a clean transcription drop.
545
+ * Every ambiguous shape is exempt: labels rendered from data (`.label`
546
+ * anywhere), labels with no special char (nothing to drop), labels present
547
+ * verbatim, entity-encoded `&` (`&amp;` — the words stop being contiguous, so
548
+ * no flag), and very short labels. Presence-ANYWHERE, not wiring-scoped, on
549
+ * purpose: if the label's text appears anywhere we never flag.
550
+ */
551
+ export function findDroppedActionLabels(
552
+ sourceCode: string,
553
+ contract: DataContract | undefined,
554
+ ): Array<{ readonly name: string; readonly label: string }> {
555
+ const actionSpec = contract?.actionSpec;
556
+ if (actionSpec === undefined) return [];
557
+ // Component renders any label from data → it does not transcribe → exempt all.
558
+ if (/\.label\b/.test(sourceCode)) return [];
559
+ const vis = visibleText(sourceCode);
560
+ const normVis = normText(vis);
561
+ const alnumVis = alnumText(vis);
562
+ const out: Array<{ name: string; label: string }> = [];
563
+ for (const [name, entry] of Object.entries(actionSpec)) {
564
+ const label = entry.label;
565
+ if (typeof label !== "string" || label.length === 0 || label === name) continue;
566
+ if (!LABEL_SPECIAL_CHAR.test(label)) continue; // no special char → nothing to drop
567
+ if (alnumText(label).length < 3) continue; // too short to match confidently
568
+ if (normVis.includes(normText(label))) continue; // present verbatim in visible text → correct
569
+ // The label's words appear contiguously in the VISIBLE text but the full
570
+ // label (with its punctuation) does not → a character was dropped.
571
+ if (alnumVis.includes(alnumText(label))) out.push({ name, label });
572
+ }
573
+ return out;
574
+ }
575
+
576
+ function runActionLabelDropped(input: AxisCheckInput): EvalIssue[] {
577
+ if (input.compiledCode === null) return [];
578
+ return findDroppedActionLabels(input.sourceCode, input.contract).map(({ name, label }) =>
579
+ mkIssue(
580
+ "universal.action_label_dropped",
581
+ `The action "${name}" declares label "${label}", but the rendered control appears to drop its "${label.replace(/[a-z0-9\s]/gi, "").trim()}": the label's words are in the generated source, the full label is not — a transcription drop (ggui#1190).`,
582
+ `Render the label EXACTLY as the contract states it — button text should be the verbatim \`actionSpec.${name}.label\`, or render it from data (\`{x.label}\`), never a re-typed copy that drops "&", "/", "+", etc.`,
583
+ "warn",
584
+ ),
585
+ );
586
+ }
587
+
394
588
  export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
395
589
  {
396
590
  id: "universal.icon_name_known",
@@ -410,6 +604,18 @@ export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
410
604
  values: ALL_RENDER_VALUES,
411
605
  run: runViewportSized,
412
606
  },
607
+ {
608
+ id: "universal.root_width_cap",
609
+ axis: "render",
610
+ values: ALL_RENDER_VALUES,
611
+ run: runRootWidthCap,
612
+ },
613
+ {
614
+ id: "universal.action_label_dropped",
615
+ axis: "render",
616
+ values: ALL_RENDER_VALUES,
617
+ run: runActionLabelDropped,
618
+ },
413
619
  {
414
620
  id: "universal.terminal_action_unguarded",
415
621
  axis: "render",