@ggui-ai/ui-gen 0.25.0 → 0.27.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 (117) hide show
  1. package/README.md +2 -2
  2. package/dist/{adapter-Cg_rkpGq.d.ts → adapter-h4_V69LI.d.ts} +49 -1
  3. package/dist/adapters/base.d.ts +6 -1
  4. package/dist/adapters/base.js.map +1 -1
  5. package/dist/adapters/generation-dispatch.d.ts +8 -5
  6. package/dist/adapters/generation-dispatch.js +1174 -198
  7. package/dist/adapters/generation-dispatch.js.map +1 -1
  8. package/dist/adapters/index.js +59 -16
  9. package/dist/adapters/index.js.map +1 -1
  10. package/dist/advanced/index.d.ts +10 -9
  11. package/dist/advanced/index.js +1215 -216
  12. package/dist/advanced/index.js.map +1 -1
  13. package/dist/blueprint-validator.js +416 -92
  14. package/dist/blueprint-validator.js.map +1 -1
  15. package/dist/boilerplate.d.ts +2 -2
  16. package/dist/boilerplate.js +10 -5
  17. package/dist/boilerplate.js.map +1 -1
  18. package/dist/check/index.d.ts +3 -8
  19. package/dist/check/index.js +55 -2
  20. package/dist/check/index.js.map +1 -1
  21. package/dist/{create-ui-generator-DFnnArrv.d.ts → create-ui-generator-BrZHF0cf.d.ts} +3 -4
  22. package/dist/{design-mode-DLufhrNp.d.ts → design-mode-DGWN_Gkm.d.ts} +1 -1
  23. package/dist/{dispatch-CStl9eAw.d.ts → dispatch-B07TI30d.d.ts} +2 -2
  24. package/dist/evaluation/axis-checks/index.d.ts +5 -4
  25. package/dist/evaluation/axis-checks/index.js.map +1 -1
  26. package/dist/evaluation/axis-checks/registry.d.ts +4 -3
  27. package/dist/evaluation/index.d.ts +124 -546
  28. package/dist/evaluation/index.js +588 -65
  29. package/dist/evaluation/index.js.map +1 -1
  30. package/dist/evaluation/types.d.ts +17 -0
  31. package/dist/evaluation/types.js.map +1 -1
  32. package/dist/{generate-9IeNT4ZY.d.ts → generate-tSO_shQ0.d.ts} +1 -1
  33. package/dist/harness/check/runtime-render/index.d.ts +5 -5
  34. package/dist/harness/check/runtime-render/index.js +416 -92
  35. package/dist/harness/check/runtime-render/index.js.map +1 -1
  36. package/dist/harness/check/runtime-render/render-check-worker.js +393 -88
  37. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  38. package/dist/harness/index.d.ts +4 -5
  39. package/dist/harness/index.js +36 -7
  40. package/dist/harness/index.js.map +1 -1
  41. package/dist/harness/prompts.d.ts +2 -2
  42. package/dist/harness/prompts.js +7 -1
  43. package/dist/harness/prompts.js.map +1 -1
  44. package/dist/harness/result-types.d.ts +17 -3
  45. package/dist/harness/result-types.js.map +1 -1
  46. package/dist/harness/runtime.d.ts +14 -6
  47. package/dist/harness/runtime.js +13 -7
  48. package/dist/harness/runtime.js.map +1 -1
  49. package/dist/harness/types-public.d.ts +4 -2
  50. package/dist/harness/types-public.js +10 -5
  51. package/dist/harness/types-public.js.map +1 -1
  52. package/dist/harness/validator-trace-sink.d.ts +4 -3
  53. package/dist/index.d.ts +46 -11
  54. package/dist/index.js +1217 -214
  55. package/dist/index.js.map +1 -1
  56. package/dist/patch.d.ts +3 -3
  57. package/dist/{prompts-YU9piOgs.d.ts → prompts-BhNUuRoZ.d.ts} +1 -1
  58. package/dist/tools/render-check-worker.js +8 -1
  59. package/dist/tools/render-check-worker.js.map +1 -1
  60. package/dist/{types-public-BJTLpzWn.d.ts → types-public-h8IMnYuh.d.ts} +388 -3
  61. package/dist/validation/index.d.ts +1 -1
  62. package/dist/validation/index.js +1 -1
  63. package/dist/validation/index.js.map +1 -1
  64. package/dist/visual-evaluator-C2strxqD.d.ts +639 -0
  65. package/dist/workflows.d.ts +3 -3
  66. package/package.json +12 -12
  67. package/src/adapters/base.ts +6 -1
  68. package/src/adapters/claude/message-parser.ts +5 -6
  69. package/src/adapters/claude/sdk-tool-pins.ts +44 -0
  70. package/src/adapters/claude/sdk.ts +3 -3
  71. package/src/adapters/claude/source-capture.ts +20 -0
  72. package/src/adapters/generation-dispatch.ts +2 -0
  73. package/src/boilerplate/free-design-prompt.ts +3 -3
  74. package/src/boilerplate/hard-sections.ts +1 -1
  75. package/src/boilerplate/system-prompt.ts +3 -2
  76. package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +3 -1
  77. package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +3 -1
  78. package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +3 -1
  79. package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +3 -1
  80. package/src/check/run-tier0.ts +67 -3
  81. package/src/contract-context.ts +1 -1
  82. package/src/design-system-docs.ts +2 -1
  83. package/src/evaluation/cost-tracker.ts +54 -6
  84. package/src/evaluation/criteria/edge-probe.ts +286 -0
  85. package/src/evaluation/criteria/edge.ts +202 -0
  86. package/src/evaluation/criteria/resolve.ts +74 -8
  87. package/src/evaluation/index.ts +5 -1
  88. package/src/evaluation/judge-spend.ts +48 -0
  89. package/src/evaluation/loop.ts +25 -4
  90. package/src/evaluation/message-parsing.ts +11 -12
  91. package/src/evaluation/types-public.ts +95 -0
  92. package/src/evaluation/types.ts +10 -0
  93. package/src/evaluation/visual-evaluator.ts +236 -40
  94. package/src/harness/check/runtime-render/adapter.ts +18 -0
  95. package/src/harness/check/runtime-render/probe-pending.ts +111 -0
  96. package/src/harness/check/runtime-render/render-check-host.ts +10 -6
  97. package/src/harness/check/runtime-render/render-check-worker.ts +6 -3
  98. package/src/harness/check/runtime-render/render-check.ts +417 -133
  99. package/src/harness/check/runtime-render/walk.ts +200 -0
  100. package/src/harness/coding/assemble-result.ts +2 -0
  101. package/src/harness/coding/generate-task-runner.ts +27 -0
  102. package/src/harness/coding/init-session.ts +5 -3
  103. package/src/harness/coding/run-coding-turn.ts +5 -1
  104. package/src/harness/coding/run-eval-round.ts +63 -3
  105. package/src/harness/in-loop-evaluation.ts +39 -0
  106. package/src/harness/llm-router.ts +58 -1
  107. package/src/harness/result-types.ts +9 -0
  108. package/src/harness/runtime.ts +9 -0
  109. package/src/harness/types-public.ts +10 -1
  110. package/src/index.ts +5 -0
  111. package/src/internal/verdict-line.ts +33 -0
  112. package/src/taught-tokens.ts +31 -0
  113. package/src/tools/render-check-worker.ts +2 -1
  114. package/src/tools/render-check.ts +8 -7
  115. package/src/validation/primitives.ts +1 -1
  116. package/dist/llm-router-nfO7wPec.d.ts +0 -267
  117. package/dist/{bank-CT4eEkta.d.ts → bank-Ctdp3FUA.d.ts} +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/ui-gen",
3
- "version": "0.25.0",
3
+ "version": "0.27.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": [
@@ -189,9 +189,9 @@
189
189
  "puppeteer-core": "^24.40.0 || ^25.0.0",
190
190
  "react": "^18.0.0 || ^19.0.0",
191
191
  "react-dom": "^18.0.0 || ^19.0.0",
192
- "@ggui-ai/project-config": "0.25.0",
193
- "@ggui-ai/mcp-server-core": "0.25.0",
194
- "@ggui-ai/protocol": "0.25.0"
192
+ "@ggui-ai/mcp-server-core": "0.27.0",
193
+ "@ggui-ai/project-config": "0.27.0",
194
+ "@ggui-ai/protocol": "0.27.0"
195
195
  },
196
196
  "peerDependenciesMeta": {
197
197
  "@google/adk": {
@@ -233,11 +233,11 @@
233
233
  "openai": "^7.9.0",
234
234
  "typescript": "^5.0.0",
235
235
  "zod": "^4.3.6",
236
- "@ggui-ai/design": "0.25.0",
237
- "@ggui-ai/ui-visual-tester": "0.25.0",
238
- "@ggui-ai/gadgets": "0.25.0",
239
- "@ggui-ai/sandbox": "0.25.0",
240
- "@ggui-ai/wire": "0.25.0"
236
+ "@ggui-ai/design": "0.27.0",
237
+ "@ggui-ai/sandbox": "0.27.0",
238
+ "@ggui-ai/gadgets": "0.27.0",
239
+ "@ggui-ai/ui-visual-tester": "0.27.0",
240
+ "@ggui-ai/wire": "0.27.0"
241
241
  },
242
242
  "devDependencies": {
243
243
  "@google/adk": "^2.0.0",
@@ -252,9 +252,9 @@
252
252
  "react-dom": "^19.0.0",
253
253
  "tsup": "^8.5.0",
254
254
  "vitest": "^3.2.6",
255
- "@ggui-ai/mcp-server-core": "0.25.0",
256
- "@ggui-ai/project-config": "0.25.0",
257
- "@ggui-ai/protocol": "0.25.0"
255
+ "@ggui-ai/mcp-server-core": "0.27.0",
256
+ "@ggui-ai/project-config": "0.27.0",
257
+ "@ggui-ai/protocol": "0.27.0"
258
258
  },
259
259
  "engines": {
260
260
  "node": ">=22.0.0"
@@ -45,8 +45,13 @@ export interface ClaudeSdkConfig extends AdapterConfig {
45
45
  env?: Record<string, string>;
46
46
  /** MCP server configs for Claude Agent SDK (keyed by name) */
47
47
  mcpServers?: Record<string, McpServerConfig>;
48
- /** Allowed tool names for Claude Agent SDK */
48
+ /** Tool names to approve. Absent → every tool of every server in `mcpServers` (ggui#1714). */
49
49
  allowedTools?: string[];
50
+ /**
51
+ * CLI built-in tools to offer the model (e.g. `['Write']`). Default: none.
52
+ * An explicit opt-in: a built-in that writes or executes acts on the host (ggui#1714).
53
+ */
54
+ builtinTools?: readonly string[];
50
55
  /** Subagent definitions for Claude Agent SDK */
51
56
  agents?: Record<string, AgentDefinition>;
52
57
  /** Custom CLI path for Docker deployments */
@@ -13,6 +13,7 @@ import type {
13
13
  SDKUserMessage,
14
14
  } from '@anthropic-ai/claude-agent-sdk';
15
15
  import type { JsonObject } from '@ggui-ai/protocol';
16
+ import { sourceCodeFromToolUse } from './source-capture';
16
17
 
17
18
  /**
18
19
  * Mutable accumulator for artifacts extracted from the SDK message stream.
@@ -197,12 +198,10 @@ function extractSourceCode(message: SDKMessage, artifacts: MessageArtifacts): vo
197
198
  if (!Array.isArray(content)) return;
198
199
 
199
200
  for (const block of content) {
200
- if (block.type === 'tool_use' && block.name === 'Write') {
201
- const input = block.input as { content?: string } | undefined;
202
- if (input?.content) {
203
- artifacts.sourceCode = input.content;
204
- }
205
- }
201
+ if (block.type !== 'tool_use') continue;
202
+ // ggui#1714 — the rule lives in source-capture.ts, shared with the evaluation loop.
203
+ const value = sourceCodeFromToolUse(block.name, block.input);
204
+ if (value !== undefined) artifacts.sourceCode = value;
206
205
  }
207
206
  }
208
207
 
@@ -0,0 +1,44 @@
1
+ // packages/ui-gen/src/adapters/claude/sdk-tool-pins.ts
2
+ //
3
+ // ggui#1714 — the tool surface of every Agent SDK `query()` this package runs.
4
+ //
5
+ // Left to its defaults, a `query()` session offers the model the CLI's whole
6
+ // built-in set (Bash, Edit, Read, Write, WebFetch, Task, …) and loads the host
7
+ // user's own Claude configuration (plugins, MCP servers from `~/.claude`).
8
+ // `allowedTools` does not narrow that: it only approves the names it lists.
9
+ // Measured on SDK 0.3.229 from the session's `system`/`init` message:
10
+ //
11
+ // as before this file → every built-in + the caller's tools
12
+ // `tools: []` alone → the caller's tools + a user-level plugin server
13
+ // `tools: []` + `settingSources: []` → the caller's tools only
14
+ //
15
+ // So the pins are both, on every path. With them in place no permission
16
+ // bypass is needed: a tool named in `allowedTools` runs, and a server-level
17
+ // entry (`mcp__<server>`) approves every tool of that server (both measured
18
+ // on a live turn; with no entry the call is denied and the run reports it).
19
+
20
+ import type { McpServerConfig, Options } from '@anthropic-ai/claude-agent-sdk';
21
+
22
+ export type SdkToolPins = Required<Pick<Options, 'tools' | 'settingSources' | 'allowedTools'>>;
23
+
24
+ export interface SdkToolPinInput {
25
+ /** The MCP servers the session gets, keyed by name. */
26
+ readonly mcpServers?: Record<string, McpServerConfig>;
27
+ /** Tool names to approve. Absent → every tool of every server in `mcpServers`. */
28
+ readonly allowedTools?: readonly string[];
29
+ /**
30
+ * The CLI built-ins to offer (e.g. `['Write']`) — an explicit opt-in.
31
+ * Each one is also approved, so it runs without a prompt; a built-in that
32
+ * writes or executes acts on the host with the session's permissions.
33
+ */
34
+ readonly builtinTools?: readonly string[];
35
+ }
36
+
37
+ export function sdkToolPins(input: SdkToolPinInput): SdkToolPins {
38
+ const builtins = [...(input.builtinTools ?? [])];
39
+ const approved =
40
+ input.allowedTools !== undefined
41
+ ? [...input.allowedTools]
42
+ : Object.keys(input.mcpServers ?? {}).map((name) => `mcp__${name}`);
43
+ return { tools: builtins, settingSources: [], allowedTools: [...approved, ...builtins] };
44
+ }
@@ -14,6 +14,7 @@ import type { AdapterResult, ProviderName, AdapterMode } from '../types';
14
14
  import { createArtifacts, processMessage } from './message-parser';
15
15
  import { createToolMcpServer } from './tool-bridge';
16
16
  import { claudeCodeLoginQueryOptions, loginChildEnv } from './claude-code-login';
17
+ import { sdkToolPins } from './sdk-tool-pins';
17
18
 
18
19
  export class ClaudeSdkAdapter extends GeneratorAdapter {
19
20
  readonly provider: ProviderName = 'claude';
@@ -73,9 +74,8 @@ export class ClaudeSdkAdapter extends GeneratorAdapter {
73
74
  systemPrompt: params.systemPrompt,
74
75
  ...(mcpServers && { mcpServers }),
75
76
  ...(cfg.agents && { agents: cfg.agents }),
76
- ...(allowedTools && { allowedTools }),
77
- permissionMode: 'bypassPermissions',
78
- allowDangerouslySkipPermissions: true,
77
+ // ggui#1714 — no CLI built-ins unless asked for, no host settings, no permission bypass.
78
+ ...sdkToolPins({ mcpServers, allowedTools, builtinTools: cfg.builtinTools }),
79
79
  ...(cfg.cliPath && { pathToClaudeCodeExecutable: cfg.cliPath }),
80
80
  ...(cfg.stderr && { stderr: cfg.stderr }),
81
81
  // ggui#1185 — tool-less, config-less, non-bare; the model is pinned above.
@@ -0,0 +1,20 @@
1
+ // packages/ui-gen/src/adapters/claude/source-capture.ts
2
+ //
3
+ // ggui#1714 — which tool call carries a component's source, decided once.
4
+ // Both Agent SDK paths (ClaudeSdkAdapter's message parser and the evaluation
5
+ // loop's extractor) read source through this function, so the rule cannot be
6
+ // fixed on one path and left stale on the other.
7
+ //
8
+ // `*__compile_component` → `input.code` (the bridged compile tool)
9
+ // `Write` → `input.content` (a CLI built-in, offered only by opt-in)
10
+
11
+ const COMPILE_TOOL_SUFFIX = '__compile_component';
12
+
13
+ /** The source a tool_use block carries, or undefined when it carries none. */
14
+ export function sourceCodeFromToolUse(name: unknown, input: unknown): string | undefined {
15
+ if (typeof name !== 'string') return undefined;
16
+ const field = name === 'Write' ? 'content' : name.endsWith(COMPILE_TOOL_SUFFIX) ? 'code' : undefined;
17
+ if (field === undefined || typeof input !== 'object' || input === null) return undefined;
18
+ const value: unknown = Reflect.get(input, field);
19
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
20
+ }
@@ -126,6 +126,8 @@ export interface GenerationDispatchParams {
126
126
  /** ggui#1436 — see `SingleComponentParams.visualEvaluation.criteriaBank`. */
127
127
  criteriaBank?: CriteriaBank;
128
128
  criteriaKind?: string;
129
+ /** ggui#1542 — see `SingleComponentParams.visualEvaluation.actOn`. */
130
+ actOn?: "critical" | "major";
129
131
  };
130
132
  /** Quality config controlling eval tiers and improvement behavior */
131
133
  qualityConfig?: QualityConfig;
@@ -18,7 +18,7 @@
18
18
  // for raw elements · the one-gesture-surface invariant restated for raw
19
19
  // elements · a regenerated quality checklist.
20
20
 
21
- import { consumedTokenManifest } from "@ggui-ai/design/themes";
21
+ import { taughtTokenManifest } from "../taught-tokens.js";
22
22
  import { describeCanvas, type CanvasClass } from "../design-mode.js";
23
23
  import {
24
24
  ANTI_PATTERNS,
@@ -136,7 +136,7 @@ function renderRamp(r: ColorRamp): string {
136
136
  * every role the prose names exists on `:root`; nothing is implied by a
137
137
  * `50…800` range that the manifest does not carry step for step.
138
138
  */
139
- export function renderFreeColorRule(manifest: readonly string[] = consumedTokenManifest): string {
139
+ export function renderFreeColorRule(manifest: readonly string[] = taughtTokenManifest): string {
140
140
  const { ramps, roles } = colorVocabularyFromManifest(manifest);
141
141
  const brand = ramps.filter((r) => r.family === "primary" || r.family === "neutral");
142
142
  const state = ramps.filter((r) => r.family !== "primary" && r.family !== "neutral");
@@ -260,7 +260,7 @@ const TOKEN_GROUPS: readonly TokenGroup[] = [
260
260
  * does not define. Grouped by prefix; anything outside the known
261
261
  * prefixes lands under "Other".
262
262
  */
263
- export function renderTokenVocabulary(manifest: readonly string[] = consumedTokenManifest): string {
263
+ export function renderTokenVocabulary(manifest: readonly string[] = taughtTokenManifest): string {
264
264
  const sorted = [...manifest].sort();
265
265
  const claimed = new Set<string>();
266
266
  const lines: string[] = [
@@ -257,7 +257,7 @@ The width works the same way, and the mistake is the mirror image: **your outerm
257
257
 
258
258
  - Never put \`maxWidth\` (or a \`max-w-*\` class) on the element you return at the top. On a wide canvas that turns your card into a narrow strip with the frame's ground on either side of it, and on a narrow one it does nothing — the same composition reading differently at two sizes.
259
259
  - DO cap the reading measure inside: a column of prose is easiest to read at 60–70 characters, so wrap the TEXT in a \`<Container maxWidth="sm">\` (or your own \`max-width\`) and leave the root filling. A hero band, a toolbar, a table or an image then still uses the whole width, which is what makes a wide card look composed rather than padded.
260
- - Centre that inner column with the container's own centring, not by shrinking the root.
260
+ - Centre that inner column with the container's own centring, not by shrinking the root — and give the card ONE column: every block of the reading flow (a heading, its text, and the chips or buttons that answer it) sits in a \`<Container>\` of the same \`maxWidth\`, so their left edges line up even where a full-width band runs between them.
261
261
  - **They are two different elements.** Even when the whole card is one column of text, the root still fills and the column sits INSIDE it — \`<Box padding="lg"><Container maxWidth="sm">…</Container></Box>\`, never a root that is itself the container. A centred card is centring inside a filled frame, not a shrunken frame.`;
262
262
 
263
263
  /** Helper-component structure guidance (JSX depth 3–5, helpers above `Component`). Identical in both modes. */
@@ -481,13 +481,14 @@ MANDATORY:
481
481
  2. NEVER use CSS gradients with custom colors. If you need a gradient: \`linear-gradient(to bottom, var(--ggui-color-primary-500), var(--ggui-color-primary-700))\`.
482
482
  3. NEVER invent your own palette. The system provides primary, neutral, success, warning, error, and info — use ONLY these.
483
483
  4. NEVER include literal fallback values in ANY token reference — colors, spacing, typography, radius, shadows alike. Write \`var(--ggui-spacing-4)\` bare, never \`var(--ggui-spacing-4, 16px)\`; \`var(--ggui-color-primary-600)\` bare, never \`var(--ggui-color-primary-600, #0284c7)\`. The runtime injects every token; a literal fallback paints the wrong value exactly when the operator's theme matters most.
484
+ 5. NEVER set an inline \`border\`, \`borderColor\`, \`borderWidth\` or \`borderStyle\` on a kit control — \`Button\`, \`Input\`, \`Select\`, \`TextArea\`, \`Checkbox\`, \`RadioGroup\`, \`Toggle\`, \`SearchField\`. The control draws its own boundary, and it clears 3:1 on its surface in every theme: an outline chip is \`<Button variant="outline">\` exactly as it comes (its edge is \`controlAccentOutline\`). Where you draw a control-like edge on your own element, use \`var(--ggui-color-controlOutline)\`. A ramp step such as \`primary-200\` as a control's border is nearly invisible (about 1.1:1).
484
485
 
485
486
  Token categories:
486
487
  - Brand fills: \`var(--ggui-color-primary-600)\`, \`var(--ggui-color-primary-50)\`
487
488
  - Accent text: \`var(--ggui-color-link)\` — eyebrows, taglines, links, labels; readable per mode (never a bare \`primary-<stop>\` as a text color)
488
489
  - Text: \`var(--ggui-color-onContainer)\`, \`var(--ggui-color-onSunken)\`
489
490
  - Backgrounds: \`var(--ggui-color-container)\`, \`var(--ggui-color-sunken)\`
490
- - Borders: \`var(--ggui-color-outline)\`
491
+ - Borders: \`var(--ggui-color-outline)\` — dividers and the edges of non-interactive boxes; a control's edge is the kit's (rule 5)
491
492
  - Spacing: \`var(--ggui-spacing-4)\`, \`var(--ggui-spacing-6)\`
492
493
  - Typography: \`var(--ggui-font-size-sm)\`, \`var(--ggui-font-weight-semibold)\`
493
494
  - Shadows: \`var(--ggui-shape-shadow-sm)\`, \`var(--ggui-shape-shadow-md)\`, \`var(--ggui-shape-shadow-lg)\`
@@ -502,7 +503,7 @@ Use the FULL primary palette throughout the component — NOT only on submit but
502
503
  | Element | Token | Purpose |
503
504
  |---------|-------|---------|
504
505
  | Section headers, hero areas, highlight strips | \`primary-50\` / \`primary-100\` | Subtle branded backgrounds |
505
- | Borders, dividers, focus rings, input focus | \`primary-200\` / \`primary-300\` | Branded structure |
506
+ | Dividers, section rules, edges of non-interactive boxes | \`primary-200\` / \`primary-300\` | Branded structure — never a control's border (rule 5) |
506
507
  | Accent TEXT — eyebrows, taglines, links, labels, inline arrows | \`link\` (\`var(--ggui-color-link)\`) or \`<Text tone="emphasized">\` | The brand's READABLE ink, derived per mode to clear 4.5:1 — a bare \`primary-300…600\` as a text color fails tier-0 |
507
508
  | Icon fills, badges, active indicators | \`primary-500\` / \`primary-600\` | Core accent fill |
508
509
  | Buttons, CTAs, filled interactive elements | \`primary-600\` / \`primary-700\` | Primary actions |
@@ -3,5 +3,7 @@
3
3
  provides the chrome, so add no shadow or outer radius of your own.
4
4
  Compact and natural height; stack vertically. This root fills the
5
5
  bubble — wrap PROSE in <Container maxWidth="sm"> to cap the reading
6
- measure (inert at bubble width, composed on a wider frame). */}
6
+ measure (inert at bubble width, composed on a wider frame), and put
7
+ the chips or buttons that answer it in a Container of the same
8
+ maxWidth, so every block keeps one left edge. */}
7
9
  </Box>
@@ -3,5 +3,7 @@
3
3
  provides the chrome, so add no shadow or outer radius of your own.
4
4
  Compact and natural height; stack vertically. This root fills the
5
5
  bubble — wrap PROSE in <Container maxWidth="sm"> to cap the reading
6
- measure (inert at bubble width, composed on a wider frame). */}
6
+ measure (inert at bubble width, composed on a wider frame), and put
7
+ the chips or buttons that answer it in a Container of the same
8
+ maxWidth, so every block keeps one left edge. */}
7
9
  </Box>
@@ -1,5 +1,7 @@
1
1
  <Box padding="lg">
2
2
  {/* implement your UI here — this root FILLS the frame.
3
3
  Bands, toolbars, tables and images use the whole width;
4
- wrap PROSE in <Container maxWidth="sm"> to cap the reading measure. */}
4
+ wrap PROSE in <Container maxWidth="sm"> to cap the reading measure,
5
+ and put the chips or buttons that answer it in a Container of the
6
+ same maxWidth, so every block keeps one left edge. */}
5
7
  </Box>
@@ -1,5 +1,7 @@
1
1
  <Box padding="lg">
2
2
  {/* implement your UI here — this root FILLS the frame.
3
3
  Bands, toolbars, tables and images use the whole width;
4
- wrap PROSE in <Container maxWidth="sm"> to cap the reading measure. */}
4
+ wrap PROSE in <Container maxWidth="sm"> to cap the reading measure,
5
+ and put the chips or buttons that answer it in a Container of the
6
+ same maxWidth, so every block keeps one left edge. */}
5
7
  </Box>
@@ -17,6 +17,7 @@ import type { DataContract } from '@ggui-ai/protocol';
17
17
  import { HOOK_NAME_RE, listContractGadgets } from '@ggui-ai/protocol';
18
18
  import { consumedTokenManifest } from '@ggui-ai/design/themes';
19
19
  import { DEFAULT_DESIGN_MODE, type DesignMode } from '../design-mode.js';
20
+ import { taughtTokenManifest } from '../taught-tokens.js';
20
21
  import { validateAllContracts, type ContractIssue } from './contract-validation.js';
21
22
  import { typecheck } from './type-checker.js';
22
23
  import { lintReactHooks, type ReactLintDiagnostic } from './react-linter.js';
@@ -315,7 +316,9 @@ const RETIRED_TOKEN_SUCCESSORS: ReadonlyMap<string, string> = new Map([
315
316
  * (`--ggui-motion-duration-normal` → fast, base, slow); otherwise the names
316
317
  * sharing any of its segments of four letters or more
317
318
  * (`--ggui-duration-slow` → the motion durations). Empty when nothing is
318
- * near, so the hint falls back to the manifest's general shape.
319
+ * near, so the hint falls back to the manifest's general shape. A hint
320
+ * teaches, so it draws only on the names the model may be taught
321
+ * (`taughtTokenManifest`); the closed-set check above admits the full manifest.
319
322
  */
320
323
  export function suggestManifestTokens(name: string): readonly string[] {
321
324
  const successor = RETIRED_TOKEN_SUCCESSORS.get(name);
@@ -328,7 +331,7 @@ export function suggestManifestTokens(name: string): readonly string[] {
328
331
  const own = name.endsWith('-') ? segments.length : segments.length - 1;
329
332
  for (let n = own; n >= 2; n--) {
330
333
  const prefix = `--${segments.slice(0, n).join('-')}-`;
331
- const family = consumedTokenManifest.filter((token) => token.startsWith(prefix));
334
+ const family = taughtTokenManifest.filter((token) => token.startsWith(prefix));
332
335
  if (family.length > 0) return wordHitsFirst(name, rolesFirst(family));
333
336
  }
334
337
  const words = new Set(
@@ -338,7 +341,7 @@ export function suggestManifestTokens(name: string): readonly string[] {
338
341
  .map((segment) => segment.toLowerCase()),
339
342
  );
340
343
  return rolesFirst(
341
- consumedTokenManifest.filter((token) =>
344
+ taughtTokenManifest.filter((token) =>
342
345
  token.split('-').some((segment) => words.has(segment.toLowerCase())),
343
346
  ),
344
347
  );
@@ -568,6 +571,50 @@ function detectComponentNeverReturns(
568
571
  * Wraps the same logic as runSelfChecks (adapters/tools.ts) but emits EvalIssue[].
569
572
  * Also runs contract validation when contract are provided.
570
573
  */
574
+ /** The kit controls whose boundary the design system draws (ggui#1697). */
575
+ const KIT_CONTROLS = ['Button', 'Input', 'Select', 'TextArea', 'Checkbox', 'RadioGroup', 'Toggle', 'SearchField'] as const;
576
+
577
+ /**
578
+ * Opening tags of kit controls, read brace- and quote-aware: an attribute like `onClick={() => x}` holds a `>` that a
579
+ * plain `[^>]*` pattern stops at, which would hide every attribute after it.
580
+ */
581
+ function kitControlTags(source: string): { name: string; attrs: string; line: number }[] {
582
+ const out: { name: string; attrs: string; line: number }[] = [];
583
+ const open = new RegExp(`<(${KIT_CONTROLS.join('|')})\\b`, 'g');
584
+ for (const m of source.matchAll(open)) {
585
+ const start = (m.index ?? 0) + m[0].length;
586
+ let depth = 0;
587
+ let quote: string | null = null;
588
+ let i = start;
589
+ for (; i < source.length; i += 1) {
590
+ const c = source[i];
591
+ if (quote !== null) {
592
+ if (c === quote && source[i - 1] !== '\\') quote = null;
593
+ } else if (c === '"' || c === "'" || c === '`') quote = c;
594
+ else if (c === '{') depth += 1;
595
+ else if (c === '}') depth -= 1;
596
+ else if (c === '>' && depth === 0) break;
597
+ }
598
+ out.push({ name: m[1] ?? '', attrs: source.slice(start, i), line: source.slice(0, m.index ?? 0).split('\n').length });
599
+ }
600
+ return out;
601
+ }
602
+
603
+ /** A border key (not a radius) in the tag's inline style, or in a same-file object its `style={name}` points at. */
604
+ function inlineBorderKey(attrs: string, source: string): string | null {
605
+ const BORDER = /\b(border(?:Top|Right|Bottom|Left)?(?:Color|Width|Style)?)\s*:/;
606
+ const style = attrs.match(/\bstyle=\{\{([\s\S]*)\}\}/);
607
+ if (style !== null) {
608
+ const hit = (style[1] ?? '').match(BORDER);
609
+ if (hit !== null) return hit[1] ?? null;
610
+ }
611
+ const named = attrs.match(/\bstyle=\{\s*([A-Za-z_$][\w$]*)\s*\}/);
612
+ if (named === null) return null;
613
+ const decl = source.match(new RegExp(`\\b(?:const|let)\\s+${named[1]}\\b[^=]*=\\s*\\{([^{}]*)\\}`));
614
+ const hit = decl !== null ? (decl[1] ?? '').match(BORDER) : null;
615
+ return hit !== null ? hit[1] ?? null : null;
616
+ }
617
+
571
618
  export async function runTier0Checks(
572
619
  sourceCode: string,
573
620
  contract?: DataContract,
@@ -1166,6 +1213,23 @@ export async function runTier0Checks(
1166
1213
  }
1167
1214
  }
1168
1215
 
1216
+ // ── A kit control's boundary is the kit's (ggui#1697) ──────
1217
+ // A control draws its own edge, derived to clear 3:1 on its surface (`controlOutline`, or
1218
+ // `controlAccentOutline` on an outline Button); an inline border paints over it, and the ramp step
1219
+ // the model reached for (`primary-200`) measured about 1.1:1. The prompt's rule 5 says the same, so
1220
+ // this check enforces what the model was told. The description starts with the check's id so the
1221
+ // generator's logged violation lines (`✗ …`) count its fires per generation, in-turn repairs included.
1222
+ for (const tag of enforceDesignVocabulary ? kitControlTags(sourceCode) : []) {
1223
+ const key = inlineBorderKey(tag.attrs, sourceCode);
1224
+ if (key === null) continue;
1225
+ issues.push({
1226
+ tier: 0, result: 'fail', category: 'tokens', subcategory: 'control-inline-border', severity: 'critical',
1227
+ description: `control-inline-border: <${tag.name}> sets an inline \`${key}\` — a kit control draws its own boundary, and an inline border paints over it`,
1228
+ fix: `Remove the border from <${tag.name}>'s style (keep borderRadius if you need it). An outline chip is <Button variant="outline"> exactly as it comes; a control-like edge on your own element uses var(--ggui-color-controlOutline).`,
1229
+ line: tag.line,
1230
+ });
1231
+ }
1232
+
1169
1233
  // ── Missing Props interface ───────────────────────────────
1170
1234
  if (!sourceCode.includes('interface Props') && !sourceCode.includes('type Props')) {
1171
1235
  issues.push({
@@ -59,7 +59,7 @@ const SHELL_HINTS: Record<string, string> = {
59
59
  - Padding: use var(--ggui-spacing-2) for inner elements, var(--ggui-spacing-4) for sections. Tight.
60
60
  - Scrolling: parent scrolls (chat feed) — component should NOT have internal scroll
61
61
  - Layout: single column, compact. No sidebar. Stack everything vertically.
62
- - Width: the root is width: 100% (fills the bubble) — never cap the ROOT. A prose column inside it carries the reading cap (<Container maxWidth="sm">), which is inert at bubble width.`,
62
+ - Width: the root is width: 100% (fills the bubble) — never cap the ROOT. A prose column inside it carries the reading cap (<Container maxWidth="sm">), which is inert at bubble width; the chips or buttons that answer the prose sit in a Container of the same maxWidth, so they share its left edge.`,
63
63
 
64
64
  fullscreen: `**Fullscreen Shell — Takes over entire viewport**
65
65
  - Container: the whole viewport, edge to edge — the frame stretches your root to its full height (you never set a viewport height yourself)
@@ -49,6 +49,7 @@ These tokens adapt automatically to any theme (light, dark, branded). **ALWAYS u
49
49
  | link | var(--ggui-color-link) | Accent TEXT — eyebrows, taglines, links, labels, inline arrows: the brand's readable ink, derived per mode to clear 4.5:1 on the container and the ground |
50
50
  | outline | var(--ggui-color-outline) | Borders, dividers |
51
51
  | outlineVariant | var(--ggui-color-outlineVariant) | Subtle borders |
52
+ | controlOutline | var(--ggui-color-controlOutline) | A control's edge — clears 3:1 on its surface; kit controls draw it themselves |
52
53
 
53
54
  **Usage pattern:**
54
55
  - Page/section background → \`var(--ggui-color-ground)\`
@@ -173,7 +174,7 @@ Use \`var(--ggui-spacing-N)\` for all padding, gap, and margin values. **Never u
173
174
  ### Color usage guide
174
175
  - **Layering roles** (ground, container, elevated, sunken + their on* inks; outline): one kind of AREA each — use them for every background/text/border decision; they adapt to any theme
175
176
  - **primary-50/100**: Section backgrounds, highlight strips, card headers
176
- - **primary-200/300**: Borders, dividers, focus rings, input outlines
177
+ - **primary-200/300**: Dividers and section rules — never a control's border: kit controls draw their own edge (\`controlOutline\` / \`controlAccentOutline\`), so set no inline border on them
177
178
  - **primary-500/600**: Filled buttons, CTAs, badges, icon fills, active indicators — fills and borders, NOT a text color
178
179
  - **link**: Accent TEXT — eyebrows, taglines, links, labels, inline arrows; the brand's readable ink per mode (a bare \`primary-300…600\` as a text color fails tier-0: \`universal.accent_text_ink\`)
179
180
  - **primary-700/800/900**: Headings and text on light primary backgrounds
@@ -31,15 +31,54 @@ function resolveRegistryId(model: string): ModelId | null {
31
31
  return keys.find((id) => id.endsWith(`/${undated}`)) ?? null;
32
32
  }
33
33
 
34
+ /**
35
+ * ggui#1524 — the largest cache-WRITE premium any registry model states (`cacheWritePer1M / inputPer1M`), never
36
+ * below 1. A cost CAP errs toward over-counting, so a model that states no write rate is priced at this bound
37
+ * times its input rate: a write costs a premium over input wherever one is charged, and pricing it at the bare
38
+ * input rate would slip that premium under the cap. Derived from the registry, so it moves with it.
39
+ */
40
+ export const CACHE_WRITE_PREMIUM_BOUND: number = Math.max(
41
+ 1,
42
+ ...Object.values(MODEL_REGISTRY).flatMap(({ costs }) =>
43
+ costs.cacheWritePer1M !== undefined && costs.inputPer1M > 0 ? [costs.cacheWritePer1M / costs.inputPer1M] : [],
44
+ ),
45
+ );
46
+
47
+ /** Per-1K USD for each kind of token a call can spend. */
48
+ export interface PricePer1k {
49
+ readonly input: number;
50
+ readonly output: number;
51
+ /** Cache reads: the model's stated rate, else its input rate — over-counts a read, the safe side for a cap. */
52
+ readonly cacheRead: number;
53
+ /** Cache writes: the model's stated rate, else its input rate × {@link CACHE_WRITE_PREMIUM_BOUND}. */
54
+ readonly cacheWrite: number;
55
+ }
56
+
34
57
  /** Fallback when a model has no registry entry — Sonnet-class rates. */
35
- const FALLBACK_PER_1K = { input: 0.003, output: 0.015 };
58
+ const FALLBACK_PER_1K: PricePer1k = {
59
+ input: 0.003,
60
+ output: 0.015,
61
+ cacheRead: 0.003,
62
+ cacheWrite: 0.003 * CACHE_WRITE_PREMIUM_BOUND,
63
+ };
36
64
 
37
- /** Per-1K input/output USD for a bare wire model id. */
38
- export function pricePer1k(model: string): { input: number; output: number } {
65
+ /** Per-1K USD for a bare wire model id: input, output, and the two cache kinds. */
66
+ export function pricePer1k(model: string): PricePer1k {
39
67
  const id = resolveRegistryId(model);
40
68
  if (id === null) return FALLBACK_PER_1K;
41
69
  const { costs } = MODEL_REGISTRY[id];
42
- return { input: costs.inputPer1M / 1000, output: costs.outputPer1M / 1000 };
70
+ return {
71
+ input: costs.inputPer1M / 1000,
72
+ output: costs.outputPer1M / 1000,
73
+ cacheRead: (costs.cacheReadPer1M ?? costs.inputPer1M) / 1000,
74
+ cacheWrite: (costs.cacheWritePer1M ?? costs.inputPer1M * CACHE_WRITE_PREMIUM_BOUND) / 1000,
75
+ };
76
+ }
77
+
78
+ /** ggui#1524 — the cache tokens a call spent, as its provider reported them; absent = unreported. */
79
+ export interface CacheSpend {
80
+ readonly read?: number;
81
+ readonly write?: number;
43
82
  }
44
83
 
45
84
  export class CostTracker {
@@ -47,9 +86,18 @@ export class CostTracker {
47
86
 
48
87
  constructor(private maxBudget: number | null) {}
49
88
 
50
- record(model: string, inputTokens: number, outputTokens: number): number {
89
+ /**
90
+ * Record one call. `inputTokens` is the provider's UNCACHED input; a call that read or wrote the prompt cache
91
+ * passes those counts in `cache` (ggui#1524), priced at the cache rates — until then a turn with a long cached
92
+ * prefix cost the cap almost nothing while the provider billed its reads and writes.
93
+ */
94
+ record(model: string, inputTokens: number, outputTokens: number, cache: CacheSpend = {}): number {
51
95
  const prices = pricePer1k(model);
52
- const cost = (inputTokens / 1000) * prices.input + (outputTokens / 1000) * prices.output;
96
+ const cost =
97
+ (inputTokens / 1000) * prices.input +
98
+ (outputTokens / 1000) * prices.output +
99
+ ((cache.read ?? 0) / 1000) * prices.cacheRead +
100
+ ((cache.write ?? 0) / 1000) * prices.cacheWrite;
53
101
  this.totalCost += cost;
54
102
  return cost;
55
103
  }