@ggui-ai/ui-gen 0.14.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 (102) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +21 -4
  2. package/dist/adapters/generation-dispatch.js +915 -351
  3. package/dist/adapters/generation-dispatch.js.map +1 -1
  4. package/dist/adapters/index.js +28 -23
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/advanced/index.d.ts +17 -12
  7. package/dist/advanced/index.js +968 -375
  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 +34 -26
  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 +975 -373
  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/check/type-checker.ts +12 -2
  75. package/src/coding-agent/tools.ts +8 -2
  76. package/src/contract-context.ts +28 -0
  77. package/src/create-harness.ts +11 -0
  78. package/src/create-ui-generator.ts +71 -25
  79. package/src/design-mode.ts +182 -0
  80. package/src/design-system-docs.ts +17 -13
  81. package/src/evaluation/axis-checks/dispatch.ts +4 -0
  82. package/src/evaluation/axis-checks/extras.ts +5 -0
  83. package/src/evaluation/cost-tracker.ts +2 -3
  84. package/src/evaluation/index.ts +46 -2
  85. package/src/evaluation/llm-evaluator.ts +77 -21
  86. package/src/evaluation/types-public.ts +103 -8
  87. package/src/evaluation/visual-evaluator.ts +301 -29
  88. package/src/harness/coding/dupe-break.ts +0 -0
  89. package/src/harness/coding/init-session.ts +1 -0
  90. package/src/harness/coding/run-coding-turn.ts +3 -0
  91. package/src/harness/coding/run-eval-round.ts +15 -2
  92. package/src/harness/llm-trace-sink.ts +3 -3
  93. package/src/harness/result-types.ts +15 -3
  94. package/src/harness/runtime.ts +18 -0
  95. package/src/harness/types-public.ts +27 -0
  96. package/src/index.ts +26 -0
  97. package/src/internal/extract-call-signatures.ts +2 -2
  98. package/src/providers/bedrock.ts +4 -4
  99. package/src/run-check.ts +1 -0
  100. package/src/tools/get-primitives-ts.ts +1 -1
  101. package/src/validation/allowed-imports.ts +8 -2
  102. package/src/validation/primitives.ts +1 -1
@@ -16,6 +16,7 @@ import ts from 'typescript';
16
16
  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
+ import { DEFAULT_DESIGN_MODE, type DesignMode } from '../design-mode.js';
19
20
  import { validateAllContracts, type ContractIssue } from './contract-validation.js';
20
21
  import { typecheck } from './type-checker.js';
21
22
  import { lintReactHooks, type ReactLintDiagnostic } from './react-linter.js';
@@ -95,10 +96,22 @@ function stripComments(code: string): string {
95
96
  function detectCertainDoubleWiredActions(
96
97
  sourceCode: string,
97
98
  actionBindings: readonly string[],
99
+ /**
100
+ * `constrained` keeps the design-package tag sets above. `free`
101
+ * retargets the SAME invariant ("one useAction binding → exactly one
102
+ * gesture surface") at raw elements: the outer host is ANY JSX element
103
+ * whose onClick/onPress calls a binding (`<div onClick>`, `<li>`,
104
+ * `<tr>`, `<button>`, `<a>`, `[role=button]`, a design primitive…),
105
+ * and the inner match is ANY descendant whose handler attribute calls
106
+ * the same binding. Tag names stop mattering because in free mode a
107
+ * raw element is a first-class gesture surface.
108
+ */
109
+ designMode: DesignMode = DEFAULT_DESIGN_MODE,
98
110
  ): EvalIssue[] {
99
111
  const issues: EvalIssue[] = [];
100
112
  if (actionBindings.length === 0) return issues;
101
113
  const bindings = new Set(actionBindings);
114
+ const anyElementIsInteractive = designMode === 'free';
102
115
  const TRAIT_HOST_TAGS = new Set(['Card', 'Box', 'Stack', 'Row']);
103
116
  const TRAITS_THAT_FIRE = new Set(['Clickable', 'Pressable']);
104
117
  const INTERACTIVE_DESCENDANTS = new Set([
@@ -148,7 +161,11 @@ function detectCertainDoubleWiredActions(
148
161
 
149
162
  function isTraitHostWithFiringAs(node: ts.JsxOpeningElement): boolean {
150
163
  const tag = tagNameText(node);
151
- if (tag === undefined || !TRAIT_HOST_TAGS.has(tag)) return false;
164
+ if (tag === undefined) return false;
165
+ // Free mode: every element with a handler is a gesture surface —
166
+ // the callee check in `visit` decides whether it fires a binding.
167
+ if (anyElementIsInteractive) return true;
168
+ if (!TRAIT_HOST_TAGS.has(tag)) return false;
152
169
  const asExpr = attrExpression(node, 'as');
153
170
  return (
154
171
  asExpr !== undefined &&
@@ -206,7 +223,10 @@ function detectCertainDoubleWiredActions(
206
223
  : undefined;
207
224
  if (opening !== undefined) {
208
225
  const tag = tagNameText(opening);
209
- if (tag !== undefined && INTERACTIVE_DESCENDANTS.has(tag)) {
226
+ if (
227
+ tag !== undefined &&
228
+ (anyElementIsInteractive || INTERACTIVE_DESCENDANTS.has(tag))
229
+ ) {
210
230
  for (const attrName of HANDLER_ATTRS) {
211
231
  const callee = extractCalleeName(attrExpression(opening, attrName));
212
232
  if (callee === expectedBinding) {
@@ -239,6 +259,9 @@ function detectCertainDoubleWiredActions(
239
259
  const outerLine =
240
260
  sf.getLineAndCharacterOfPosition(node.openingElement.getStart())
241
261
  .line + 1;
262
+ const outerLabel = anyElementIsInteractive
263
+ ? `<${outerTag}>`
264
+ : `<${outerTag} as={...}>`;
242
265
  issues.push({
243
266
  tier: 0,
244
267
  result: 'fail',
@@ -246,9 +269,11 @@ function detectCertainDoubleWiredActions(
246
269
  subcategory: 'double-wired-action:certain',
247
270
  severity: 'critical',
248
271
  description:
249
- `Nested-interactive double-wire: outer <${outerTag} as={...}> at line ${outerLine} and inner <${match.tag}> at line ${match.line} both dispatch the same useAction binding '${outerCallee}'. ` +
272
+ `Nested-interactive double-wire: outer ${outerLabel} at line ${outerLine} and inner <${match.tag}> at line ${match.line} both dispatch the same useAction binding '${outerCallee}'. ` +
250
273
  `One user click on the inner control fires its handler AND bubbles to the outer handler — '${outerCallee}' dispatches TWICE, the action runs back-to-back, and a toggle-style action silently reverts the user's change.`,
251
- fix: `Pick ONE surface for the gesture: either drop \`as={...}\` + onClick on the outer <${outerTag}> and let the inner <${match.tag}> own the gesture, OR remove the inner <${match.tag}> and let the outer <${outerTag} as={...}> own it. Don't wire both to the same useAction binding.`,
274
+ fix: anyElementIsInteractive
275
+ ? `Pick ONE gesture surface for '${outerCallee}': either remove the onClick from the outer <${outerTag}> and let the inner <${match.tag}> own the gesture, OR drop the inner <${match.tag}>'s handler and let the outer <${outerTag}> own it. Never wire both to the same useAction binding — stopPropagation is not a fix.`
276
+ : `Pick ONE surface for the gesture: either drop \`as={...}\` + onClick on the outer <${outerTag}> and let the inner <${match.tag}> own the gesture, OR remove the inner <${match.tag}> and let the outer <${outerTag} as={...}> own it. Don't wire both to the same useAction binding.`,
252
277
  line: outerLine,
253
278
  });
254
279
  }
@@ -346,9 +371,25 @@ export async function runTier0Checks(
346
371
  * narrowing). Standard-library-only callers omit it.
347
372
  */
348
373
  gadgetTypes?: Readonly<Record<string, string>>,
374
+ /**
375
+ * Which triad produced the source. `constrained` (default) runs every
376
+ * leg exactly as before. `free` gates OFF the design-VOCABULARY legs —
377
+ * `raw-spacing`, `raw-pixels`, `numeric-spacing-prop`, the
378
+ * `stack-row-*` prop checks and `clickable-wrapper` — because raw
379
+ * elements, inline CSS and literal spacing are first-class there. It
380
+ * KEEPS every contract leg: `hex-color`, `hardcoded-color-fn`,
381
+ * `named-color`, `token-fallback`, `off-manifest-token`,
382
+ * `asset-color-pair` (color stays on the closed token manifest),
383
+ * imports, security, Props / default-export, wire_* and gadget checks,
384
+ * and the double-wired-action detector (retargeted at raw elements).
385
+ */
386
+ designMode: DesignMode = DEFAULT_DESIGN_MODE,
349
387
  ): Promise<EvalIssue[]> {
350
388
  const issues: EvalIssue[] = [];
351
389
  const lines = sourceCode.split('\n');
390
+ // Design-vocabulary legs fire only when the prompt taught the
391
+ // vocabulary — see `designMode` above.
392
+ const enforceDesignVocabulary = designMode === 'constrained';
352
393
 
353
394
  // ── Compile check ─────────────────────────────────────────
354
395
  //
@@ -591,7 +632,7 @@ export async function runTier0Checks(
591
632
  subcategory: 'hardcoded-color-fn',
592
633
  severity: 'critical',
593
634
  description: `Hardcoded ${colorFnMatch[1]}() breaks theme switching — use design tokens.`,
594
- fix: 'Replace with a primitive variant OR a semantic token: var(--ggui-color-surface), var(--ggui-color-onSurface), var(--ggui-color-outline), etc.',
635
+ fix: 'Replace with a primitive variant OR a layering role: var(--ggui-color-container), var(--ggui-color-onContainer), var(--ggui-color-outline), etc.',
595
636
  line: lineNum,
596
637
  });
597
638
  }
@@ -616,7 +657,7 @@ export async function runTier0Checks(
616
657
  subcategory: 'off-manifest-token',
617
658
  severity: 'critical',
618
659
  description: `Token "${name}" is not in the design system's token manifest — no theme defines it, so it renders unset.`,
619
- fix: `Choose a token that exists: the closed vocabulary is the manifest the design-system docs list (colors: var(--ggui-color-primary-500), var(--ggui-color-onSurface), …; spacing: var(--ggui-spacing-4); type: var(--ggui-font-size-sm)). Never invent a name and never add a literal fallback.`,
660
+ fix: `Choose a token that exists: the closed vocabulary is the manifest the design-system docs list (colors: var(--ggui-color-primary-500), var(--ggui-color-onContainer), …; spacing: var(--ggui-spacing-4); type: var(--ggui-font-size-sm)). Never invent a name and never add a literal fallback.`,
620
661
  line: lineNum,
621
662
  });
622
663
  }
@@ -627,9 +668,11 @@ export async function runTier0Checks(
627
668
  // `#fff` defeats its colors. Only the STRING-literal form fails;
628
669
  // the numeric escape (`gap={12}`) is untouched, so a genuine
629
670
  // off-scale pixel value still has a path.
630
- const rawSpacingMatch = line.match(
631
- /\b(gap|padding|paddingX|paddingY|margin|radius)\s*=\s*["'][\d.]+(?:px|rem|em)["']/,
632
- );
671
+ const rawSpacingMatch = enforceDesignVocabulary
672
+ ? line.match(
673
+ /\b(gap|padding|paddingX|paddingY|margin|radius)\s*=\s*["'][\d.]+(?:px|rem|em)["']/,
674
+ )
675
+ : null;
633
676
  if (rawSpacingMatch) {
634
677
  issues.push({
635
678
  tier: 0,
@@ -693,14 +736,16 @@ export async function runTier0Checks(
693
736
  subcategory: 'named-color',
694
737
  severity: 'critical',
695
738
  description: `Hardcoded CSS named color "${named}" breaks theme switching — use design tokens.`,
696
- fix: `Replace with a typed slot (Text tone="muted" / Box surface="accent" / Badge variant="success") OR a semantic token: var(--ggui-color-onSurfaceVariant), var(--ggui-color-primary-500), etc. The keyword "${named}" maps to a fixed RGB; the operator's theme has no effect on it.`,
739
+ fix: `Replace with a typed slot (Text tone="muted" / Box surface="accent" / Badge variant="success") OR a semantic token: var(--ggui-color-onSunken), var(--ggui-color-primary-500), etc. The keyword "${named}" maps to a fixed RGB; the operator's theme has no effect on it.`,
697
740
  line: lineNum,
698
741
  });
699
742
  }
700
743
  }
701
744
 
702
745
  // Raw pixel values in spacing CSS properties
703
- const pxMatch = line.match(/(?:padding|margin|gap|borderRadius)\s*:\s*['"]?\d+px/);
746
+ const pxMatch = enforceDesignVocabulary
747
+ ? line.match(/(?:padding|margin|gap|borderRadius)\s*:\s*['"]?\d+px/)
748
+ : null;
704
749
  if (pxMatch && !line.includes('var(--ggui-')) {
705
750
  issues.push({
706
751
  tier: 0,
@@ -708,14 +753,16 @@ export async function runTier0Checks(
708
753
  category: 'tokens',
709
754
  subcategory: 'raw-pixels',
710
755
  description: 'Raw pixel value in spacing — must use design tokens',
711
- fix: 'Replace with var(--ggui-spacing-*, fallback)',
756
+ fix: 'Replace with a bare var(--ggui-spacing-*) token — no literal fallback (the runtime injects every token) — or the scale name on the primitive prop (gap="md", padding="lg")',
712
757
  line: lineNum,
713
758
  });
714
759
  }
715
760
 
716
761
  // Numeric spacing props on components (e.g., padding={24}, gap={8})
717
762
  // These bypass the design system — should use var(--ggui-spacing-*)
718
- const numericSpacingMatch = line.match(/\b(padding|paddingX|paddingY|gap|margin)\s*=\s*\{?\s*(\d+)\s*\}?/);
763
+ const numericSpacingMatch = enforceDesignVocabulary
764
+ ? line.match(/\b(padding|paddingX|paddingY|gap|margin)\s*=\s*\{?\s*(\d+)\s*\}?/)
765
+ : null;
719
766
  if (numericSpacingMatch && !line.includes('var(--ggui-')) {
720
767
  issues.push({
721
768
  tier: 0,
@@ -735,7 +782,7 @@ export async function runTier0Checks(
735
782
  // onClick on them is a plain type error the type-checker catches — don't
736
783
  // steer those toward `as={Clickable}` (it won't compile on them).
737
784
  const clickablePattern = /<(?:Card|Box|Stack|Row)\s[^>]*onClick/;
738
- for (let i = 0; i < lines.length; i++) {
785
+ for (let i = 0; enforceDesignVocabulary && i < lines.length; i++) {
739
786
  if (clickablePattern.test(lines[i]) && !lines[i].includes('as={Clickable}') && !lines[i].includes('as={Pressable}')) {
740
787
  issues.push({
741
788
  tier: 0, result: 'warn', category: 'contract', subcategory: 'clickable-wrapper',
@@ -752,9 +799,21 @@ export async function runTier0Checks(
752
799
  // another (the inner gesture bubbles to the outer handler). Captured
753
800
  // in scenario-07: a `Card as={Clickable} onClick` row containing a
754
801
  // `Checkbox onChange`, both calling the same action → double-toggle.
755
- const actionBindings = [
756
- ...sourceCode.matchAll(/(?:const|let)\s+(\w+)\s*=\s*useAction\s*\(/g),
757
- ]
802
+ //
803
+ // Binding extraction. The constrained regex is the pre-`designMode`
804
+ // one, kept verbatim: it matches `useAction(` only, so a binding
805
+ // declared with the generic the boilerplate itself emits —
806
+ // `useAction<ActionXPayload>('x')` — is NOT collected there (a
807
+ // pre-existing gap, left in place because the constrained gate must
808
+ // stay byte-identical for the A/B; collapse the two regexes once that
809
+ // pin is lifted). `free` mode accepts the optional generic so the
810
+ // one-gesture-surface invariant actually fires on boilerplate-shaped
811
+ // hook lines.
812
+ const bindingRe =
813
+ designMode === 'free'
814
+ ? /(?:const|let)\s+(\w+)\s*=\s*useAction\s*(?:<[^>]*>)?\s*\(/g
815
+ : /(?:const|let)\s+(\w+)\s*=\s*useAction\s*\(/g;
816
+ const actionBindings = [...sourceCode.matchAll(bindingRe)]
758
817
  .map((m) => m[1])
759
818
  .filter((name): name is string => typeof name === 'string');
760
819
  for (const binding of actionBindings) {
@@ -794,7 +853,7 @@ export async function runTier0Checks(
794
853
  // CAUSE one turn earlier and prevents shipping the broken a11y nest.
795
854
  if (actionBindings.length > 0) {
796
855
  issues.push(
797
- ...detectCertainDoubleWiredActions(sourceCode, actionBindings),
856
+ ...detectCertainDoubleWiredActions(sourceCode, actionBindings, designMode),
798
857
  );
799
858
  }
800
859
 
@@ -862,7 +921,7 @@ export async function runTier0Checks(
862
921
  const ALIGN_VALUES = new Set(['start', 'center', 'end', 'stretch']);
863
922
  const JUSTIFY_VALUES = new Set(['start', 'center', 'end', 'between', 'around', 'evenly']);
864
923
  const tagRegex = /<(Stack|Row)\b([^>]*)>/gs;
865
- for (const tag of sourceCode.matchAll(tagRegex)) {
924
+ for (const tag of enforceDesignVocabulary ? sourceCode.matchAll(tagRegex) : []) {
866
925
  const tagName = tag[1];
867
926
  const attrs = tag[2];
868
927
  const tagLine = sourceCode.slice(0, tag.index).split('\n').length;
@@ -1316,8 +1375,9 @@ export async function runTier0Checks(
1316
1375
  export async function runTier0(
1317
1376
  sourceCode: string,
1318
1377
  contract?: DataContract,
1378
+ designMode: DesignMode = DEFAULT_DESIGN_MODE,
1319
1379
  ): Promise<EvalResult> {
1320
- const issues = await runTier0Checks(sourceCode, contract);
1380
+ const issues = await runTier0Checks(sourceCode, contract, undefined, undefined, designMode);
1321
1381
  const pass: string[] = [];
1322
1382
 
1323
1383
  // Build pass list from categories that had no issues
@@ -316,10 +316,18 @@ async function loadVfs(): Promise<Map<string, VfsEntry>> {
316
316
  /**
317
317
  * Try to resolve a relative import within the VFS.
318
318
  * Given `containingFile` (e.g. `node_modules/@ggui-ai/design/primitives/index.d.ts`)
319
- * and `importPath` (e.g. `./Card`), try:
319
+ * and `importPath` (e.g. `./Card` or, as every published declaration says
320
+ * since 0.15.0, `./Card.js`), try:
320
321
  * - <resolved>.d.ts
321
322
  * - <resolved>/index.d.ts
322
323
  * - <resolved>.ts
324
+ * A `.js` / `.mjs` / `.cjs` extension on the specifier is the ESM
325
+ * convention for "the module that compiles to this file" — TypeScript's
326
+ * NodeNext maps `./Card.js` to `./Card.d.ts`, and so does this: the
327
+ * extension is stripped before the candidates are tried. Without that, a
328
+ * `.js`-suffixed re-export resolved to nothing, the imported names typed
329
+ * as `any`, and the checker reported zero errors on code it must reject
330
+ * (ggui 0.15.0 publish-gate, type-checker.test.ts).
323
331
  */
324
332
  function resolveRelativeInVfs(
325
333
  vfs: Map<string, VfsEntry>,
@@ -329,7 +337,9 @@ function resolveRelativeInVfs(
329
337
  // Strip leading '/' from containingFile — TS may prepend getCurrentDirectory()
330
338
  const normalized = containingFile.startsWith('/') ? containingFile.slice(1) : containingFile;
331
339
  const dir = path.posix.dirname(normalized);
332
- const resolved = path.posix.normalize(`${dir}/${importPath}`);
340
+ // `./x.js` (`.mjs`, `.cjs`) names the declaration `./x.d.ts` — strip it.
341
+ const specifier = importPath.replace(/\.(?:m|c)?js$/, '');
342
+ const resolved = path.posix.normalize(`${dir}/${specifier}`);
333
343
 
334
344
  // Try .d.ts extension
335
345
  const withDts = `${resolved}.d.ts`;
@@ -18,6 +18,7 @@ import type {
18
18
  ToolResult,
19
19
  CommitMetadata,
20
20
  } from './types';
21
+ import { DEFAULT_DESIGN_MODE, type DesignMode } from '../design-mode.js';
21
22
 
22
23
  /**
23
24
  * Extract documentation for a single component from the full primitives docs.
@@ -69,6 +70,8 @@ async function autoCommit(
69
70
  * stdlib hooks unconditionally.
70
71
  */
71
72
  gadgetTypes?: Readonly<Record<string, string>>,
73
+ /** Which tier-0 legs fire — see `runTier0Checks`'s `designMode`. */
74
+ designMode: DesignMode = DEFAULT_DESIGN_MODE,
72
75
  ): Promise<ToolResult> {
73
76
  const commitStart = Date.now();
74
77
  const raw = workspace.read();
@@ -118,6 +121,7 @@ async function autoCommit(
118
121
  contract,
119
122
  buildResult.errors,
120
123
  gadgetTypes,
124
+ designMode,
121
125
  );
122
126
  const tier0Fails = tier0Issues.filter(i => i.result === 'fail');
123
127
  const selfCheckPassed = tier0Fails.length === 0;
@@ -196,6 +200,8 @@ export async function executeTool(
196
200
  * callers omit it.
197
201
  */
198
202
  gadgetTypes?: Readonly<Record<string, string>>,
203
+ /** Which tier-0 legs fire on auto-commit — see `runTier0Checks`'s `designMode`. */
204
+ designMode: DesignMode = DEFAULT_DESIGN_MODE,
199
205
  ): Promise<ToolResult> {
200
206
  switch (tool) {
201
207
  case 'write':
@@ -216,7 +222,7 @@ export async function executeTool(
216
222
  const message = (input.commit_message as string) || `write ${lineCount} lines`;
217
223
 
218
224
  console.log(`[coding-agent] write: ${lineCount} lines → auto-commit`);
219
- return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes);
225
+ return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode);
220
226
  }
221
227
 
222
228
  case 'apply_changes': {
@@ -523,7 +529,7 @@ export async function executeTool(
523
529
  const message = (input.commit_message as string) || 'apply changes';
524
530
 
525
531
  console.log(`[coding-agent] apply_changes: ${changes.length} changes applied → auto-commit`);
526
- return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes);
532
+ return autoCommit(workspace, commitMeta, message, contract, contextPolicy, gadgetTypes, designMode);
527
533
  }
528
534
 
529
535
  case 'get_components_info': {
@@ -35,6 +35,11 @@ import type {
35
35
  } from '@ggui-ai/protocol';
36
36
  import { HOOK_NAME_RE, listContractGadgets } from '@ggui-ai/protocol';
37
37
  import { propsSpecToTypeScript } from './check/index.js';
38
+ import {
39
+ canvasDisplayModeForShell,
40
+ canvasForViewportWidth,
41
+ type CanvasClass,
42
+ } from './design-mode.js';
38
43
 
39
44
  /**
40
45
  * Rendering context — how and where the component will be displayed.
@@ -87,6 +92,29 @@ const DEVICE_HINTS: Record<string, string> = {
87
92
  spatial: `**Spatial:** Fixed-size floating panel (~600x400). High contrast. Large text. No hover (gaze/hand input).`,
88
93
  };
89
94
 
95
+ /**
96
+ * Map a host rendering context onto the canvas class the `free` prompt
97
+ * states and the visual evaluator judges at. The class is derived from
98
+ * DISPLAY MODE + width: a `chat` shell is always the inline card
99
+ * whatever the host viewport is (the bubble, not the window, bounds the
100
+ * component); a known viewport width refines the fullscreen / partial
101
+ * shells; otherwise the device category decides.
102
+ */
103
+ export function canvasForRenderingContext(ctx: RenderingContext): CanvasClass {
104
+ const mode = canvasDisplayModeForShell(ctx.shell);
105
+ if (mode === 'inline') return 'xs-chat-card';
106
+ if (ctx.viewport) return canvasForViewportWidth(mode, ctx.viewport.width);
107
+ switch (ctx.device) {
108
+ case 'mobile':
109
+ return 'mobile-fullscreen-small';
110
+ case 'tablet':
111
+ case 'spatial':
112
+ return 'md';
113
+ case 'desktop':
114
+ return ctx.shell === 'partial' ? 'md' : 'lg';
115
+ }
116
+ }
117
+
90
118
  /** Build rendering-context block to inject into user prompt. */
91
119
  export function buildRenderingContext(ctx: RenderingContext): string {
92
120
  const parts = [
@@ -20,6 +20,7 @@ import { defaultApplyPatch } from "./patch.js";
20
20
  import { computeHarnessId, computeHarnessName, hashClassification } from "./hash.js";
21
21
  import { pickWorkflow } from "./workflows.js";
22
22
  import { DEFAULT_HARNESS_POLICY, isDefaultHarnessPolicy } from "./policy.js";
23
+ import { DEFAULT_DESIGN_MODE } from "./design-mode.js";
23
24
  import type {
24
25
  CreateHarnessInput,
25
26
  Harness,
@@ -69,6 +70,9 @@ function countCacheTiers(fragments: readonly { cacheTier: CacheTier }[]): Record
69
70
  export function createHarness(input: CreateHarnessInput): Harness {
70
71
  const { classification, contract, prompt, shellType, screen, overrides } = input;
71
72
  const ctx: HarnessConstructionContext = { classification, contract, prompt };
73
+ // Which triad to materialise — reaches the HOW leg (prompt), the WHAT
74
+ // leg (boilerplate) and, via `harness.designMode`, every CHECK consumer.
75
+ const designMode = input.designMode ?? DEFAULT_DESIGN_MODE;
72
76
 
73
77
  // ── Compose fragments from classification ────────────────────────────────
74
78
  const composed = compose(classification);
@@ -84,6 +88,8 @@ export function createHarness(input: CreateHarnessInput): Harness {
84
88
  shellType,
85
89
  screen,
86
90
  axisDelta: composed.promptText,
91
+ designMode,
92
+ ...(input.canvas !== undefined ? { canvas: input.canvas } : {}),
87
93
  });
88
94
  const howFragments = composed.fragments.filter(
89
95
  (f) => f.promptText && f.promptText.trim().length > 0,
@@ -112,6 +118,7 @@ export function createHarness(input: CreateHarnessInput): Harness {
112
118
  screen,
113
119
  composed.boilerplateSections,
114
120
  input.appGadgets,
121
+ designMode,
115
122
  );
116
123
  const whatFragments = composed.fragments.filter(
117
124
  (f) => f.boilerplateMarker && f.boilerplateMarker.trim().length > 0,
@@ -179,6 +186,9 @@ export function createHarness(input: CreateHarnessInput): Harness {
179
186
  // Only non-default policies contribute to the harness id — preserves
180
187
  // byte-identical id across default-policy runs.
181
188
  if (!isDefaultHarnessPolicy(policy)) overrideLabels.push("policy");
189
+ // Only a non-default design mode contributes — the constrained id is
190
+ // byte-identical to the pre-`designMode` harness.
191
+ if (designMode !== DEFAULT_DESIGN_MODE) overrideLabels.push(`designMode:${designMode}`);
182
192
 
183
193
  const id = computeHarnessId({
184
194
  classificationHash,
@@ -210,6 +220,7 @@ export function createHarness(input: CreateHarnessInput): Harness {
210
220
  id,
211
221
  name,
212
222
  classification,
223
+ designMode,
213
224
  how,
214
225
  what,
215
226
  check,
@@ -35,7 +35,10 @@ import type {
35
35
  GadgetDescriptor,
36
36
  GenerationError,
37
37
  JsonObject,
38
+ GeneratorId,
39
+ ModelRef,
38
40
  } from '@ggui-ai/protocol';
41
+ import { isGeneratorId, modelRefOfRoute } from '@ggui-ai/protocol';
39
42
  import type { GadgetCatalogAdapter } from '@ggui-ai/gadgets';
40
43
  import type {
41
44
  GenerationMetadata,
@@ -53,19 +56,22 @@ import {
53
56
  import { createGeneratorTools } from './adapters/index.js';
54
57
  import { dispatchGeneration } from './adapters/generation-dispatch.js';
55
58
  import type { ProviderName } from './adapters/types.js';
59
+ import type { GenerationResult } from './harness/result-types.js';
56
60
  import {
61
+ canvasForRenderingContext,
57
62
  injectContracts,
58
63
  injectRenderingContext,
59
64
  injectVariance,
60
65
  } from './contract-context.js';
61
66
  import type { RenderingContext } from './contract-context.js';
67
+ import type { DesignMode } from './design-mode.js';
62
68
  import { resolveRoute, applyRouteToEnv } from './adapters/provider-router.js';
63
69
  import type { QualityConfig } from './evaluation/types-public.js';
64
70
  import type { AgentConfig } from './harness/llm-router.js';
65
71
 
66
72
  /** The slug for the OSS default seed generator. */
67
73
  const DEFAULT_TIER: GeneratorTier = 'default';
68
- const DEFAULT_MODEL = 'haiku-4-5';
74
+ const DEFAULT_MODEL: ModelRef = 'anthropic/claude-haiku-4-5';
69
75
 
70
76
  export interface CreateUiGeneratorOptions {
71
77
  /**
@@ -86,24 +92,25 @@ export interface CreateUiGeneratorOptions {
86
92
  /** Quality config controlling eval tiers + improvement behavior. */
87
93
  readonly qualityConfig?: QualityConfig;
88
94
  /**
89
- * Generator identity — registry key + parsed components.
95
+ * Generator identity — registry key + declared model.
90
96
  *
91
- * The tier + model determine the {@link UiGenerator.slug} the
92
- * factory bakes onto the returned generator. Defaults to
93
- * `{tier: 'default', model: 'haiku-4-5'}` → slug
94
- * `ui-gen-default-haiku-4-5`, the OSS seed.
97
+ * The tier alone determines the {@link UiGenerator.slug} the factory
98
+ * bakes onto the returned generator (`ui-gen-<tier>`); `model` is the
99
+ * generator's declared model field and is never part of the slug
100
+ * (ggui#923). Defaults to tier `'default'` → slug `ui-gen-default`,
101
+ * the OSS seed, with the factory's default model.
95
102
  *
96
103
  * The actual model used per request still comes from
97
104
  * `UiGenerateInput.llm.model` (operators may override via BYOK).
98
105
  * The identity here is the registry-level handle, not a runtime
99
106
  * model constraint.
100
107
  *
101
- * Mutually-exclusive shortcut: pass `slug` instead and the factory
102
- * parses it for you. The slug + tier/model overloads conflict; the
103
- * factory throws if both are supplied.
108
+ * Mutually-exclusive shortcut: pass `slug` instead of `tier` and the
109
+ * factory parses the tier from it; `model` may accompany either form.
110
+ * The factory throws if `slug` and `tier` are both supplied.
104
111
  */
105
112
  readonly tier?: GeneratorTier;
106
- readonly model?: string;
113
+ readonly model?: ModelRef;
107
114
  readonly slug?: string;
108
115
  /**
109
116
  * Per-deployment gadget descriptor source. Wired once at factory
@@ -163,6 +170,35 @@ export interface CreateUiGeneratorOptions {
163
170
  * still log via `console.warn`, just without a caller-side hook.
164
171
  */
165
172
  readonly onRetry?: AgentConfig['onRetry'];
173
+ /**
174
+ * Generation observer. Invoked once per successful `generate()` with
175
+ * the FULL harness result — source, compiled code, token counters,
176
+ * timing breakdown, evaluation rounds and (when configured) the
177
+ * per-canvas visual scores — before it is projected onto the
178
+ * `UiGenerateResult` envelope, which carries only what the protocol
179
+ * needs. Lets a host record or forward the complete run without
180
+ * re-deriving it from the envelope. Bound ONCE at construction
181
+ * (mirrors `onRetry`); not invoked when generation fails. An
182
+ * exception thrown by the observer is the caller's own and fails
183
+ * that generation like any other error on the path — keep it
184
+ * non-throwing.
185
+ */
186
+ readonly onGenerated?: (result: GenerationResult) => void;
187
+ /**
188
+ * Which generation triad to run. `constrained` (default) is the
189
+ * design-system triad — byte-identical to the generator's behaviour
190
+ * before this option existed. `free` relaxes only the design
191
+ * vocabulary (raw HTML, inline CSS, literal spacing / typography /
192
+ * radius; the design package importable but optional) while keeping
193
+ * every hard contract: the boilerplate-typed wire hooks, the import
194
+ * allowlist + security bans, the host envelope, the budgets, the
195
+ * blueprint-reuse shape, and brand-bearing color on the closed
196
+ * `--ggui-color-*` token manifest. Reaches the prompt, boilerplate,
197
+ * tier-0 gate, axis checks and evaluator criteria through the harness.
198
+ * The rendering canvas the `free` prompt states is derived from
199
+ * `input.rendering` (device × shell, refined by viewport width).
200
+ */
201
+ readonly designMode?: DesignMode;
166
202
  }
167
203
 
168
204
  export function createUiGenerator(
@@ -176,6 +212,8 @@ export function createUiGenerator(
176
212
  gadgetCatalog,
177
213
  disableEnvMutation = false,
178
214
  onRetry,
215
+ onGenerated,
216
+ designMode,
179
217
  } = options;
180
218
 
181
219
  const identity = resolveIdentity(options);
@@ -268,6 +306,10 @@ export function createUiGenerator(
268
306
  const promptWithRendering = rendering
269
307
  ? injectRenderingContext(input.request.prompt, rendering)
270
308
  : input.request.prompt;
309
+ // Canvas class for the `free` prompt — TOLD, never guessed.
310
+ // Ignored by the constrained prompt (its shell/screen
311
+ // descriptors stay byte-identical).
312
+ const canvas = rendering ? canvasForRenderingContext(rendering) : undefined;
271
313
  const promptWithVariance = injectVariance(
272
314
  promptWithRendering,
273
315
  input.variance,
@@ -316,12 +358,16 @@ export function createUiGenerator(
316
358
  enableRuntimeRender,
317
359
  ...(routeOverride !== undefined ? { routeOverride } : {}),
318
360
  ...(onRetry !== undefined ? { onRetry } : {}),
361
+ ...(designMode !== undefined ? { designMode } : {}),
362
+ ...(canvas !== undefined ? { canvas } : {}),
319
363
  });
320
364
 
365
+ onGenerated?.(result);
366
+
321
367
  const metadata: GenerationMetadata = {
322
368
  provider: input.llm.provider,
323
369
  generator: identity.slug,
324
- model: input.llm.model,
370
+ model: modelRefOfRoute(input.llm),
325
371
  inputTokens: result.tokens.input,
326
372
  outputTokens: result.tokens.output,
327
373
  latencyMs: Date.now() - startedAt,
@@ -362,7 +408,7 @@ export function createUiGenerator(
362
408
  metadata: {
363
409
  provider: input.llm.provider,
364
410
  generator: identity.slug,
365
- model: input.llm.model,
411
+ model: modelRefOfRoute(input.llm),
366
412
  inputTokens: 0,
367
413
  outputTokens: 0,
368
414
  latencyMs: Date.now() - startedAt,
@@ -381,29 +427,29 @@ export function createUiGenerator(
381
427
  }
382
428
 
383
429
  function resolveIdentity(opts: CreateUiGeneratorOptions): {
384
- slug: string;
430
+ slug: GeneratorId;
385
431
  tier: GeneratorTier;
386
- model: string;
432
+ model: ModelRef;
387
433
  } {
388
434
  const hasSlug = typeof opts.slug === 'string' && opts.slug.length > 0;
389
- const hasTierOrModel = opts.tier !== undefined || opts.model !== undefined;
390
- if (hasSlug && hasTierOrModel) {
435
+ if (hasSlug && opts.tier !== undefined) {
391
436
  throw new Error(
392
- 'createUiGenerator: pass either { slug } or { tier, model } — not both.',
437
+ 'createUiGenerator: pass either { slug } or { tier } — not both.',
393
438
  );
394
439
  }
440
+ const model = opts.model ?? DEFAULT_MODEL;
395
441
  if (hasSlug) {
396
- const parsed = parseGeneratorSlug(opts.slug!);
397
- if (!parsed) {
442
+ const slug = opts.slug!;
443
+ const parsed = parseGeneratorSlug(slug);
444
+ if (!parsed || !isGeneratorId(slug)) {
398
445
  throw new Error(
399
- `createUiGenerator: slug ${JSON.stringify(opts.slug)} is not a valid ui-gen-<tier>-<model> identifier.`,
446
+ `createUiGenerator: slug ${JSON.stringify(slug)} is not a valid ui-gen-<tier> identifier.`,
400
447
  );
401
448
  }
402
- return { slug: opts.slug!, tier: parsed.tier, model: parsed.model };
449
+ return { slug, tier: parsed.tier, model };
403
450
  }
404
451
  const tier = opts.tier ?? DEFAULT_TIER;
405
- const model = opts.model ?? DEFAULT_MODEL;
406
- const slug = formatGeneratorSlug({ tier, model });
452
+ const slug = formatGeneratorSlug({ tier });
407
453
  if (!isValidGeneratorSlug(slug)) {
408
454
  // Unreachable: formatGeneratorSlug throws on malformed components,
409
455
  // so a slug it returns must parse. Defensive check kept to surface
@@ -442,7 +488,7 @@ function mapRendering(
442
488
 
443
489
  function failWithoutMetadata(
444
490
  input: UiGenerateInput,
445
- generatorSlug: string,
491
+ generatorSlug: GeneratorId,
446
492
  startedAt: number,
447
493
  error: GenerationError,
448
494
  ): UiGenerateResult {
@@ -452,7 +498,7 @@ function failWithoutMetadata(
452
498
  metadata: {
453
499
  provider: input.llm.provider,
454
500
  generator: generatorSlug,
455
- model: input.llm.model,
501
+ model: modelRefOfRoute(input.llm),
456
502
  inputTokens: 0,
457
503
  outputTokens: 0,
458
504
  latencyMs: Date.now() - startedAt,