@ggui-ai/ui-gen 0.17.0 → 0.19.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 (69) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +1 -1
  2. package/dist/adapters/generation-dispatch.js +449 -194
  3. package/dist/adapters/generation-dispatch.js.map +1 -1
  4. package/dist/adapters/index.js +4 -6
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/advanced/index.d.ts +2 -2
  7. package/dist/advanced/index.js +391 -134
  8. package/dist/advanced/index.js.map +1 -1
  9. package/dist/boilerplate.js +29 -5
  10. package/dist/boilerplate.js.map +1 -1
  11. package/dist/check/index.d.ts +1 -1
  12. package/dist/check/index.js +70 -0
  13. package/dist/check/index.js.map +1 -1
  14. package/dist/{create-ui-generator-C8Ki9Wz1.d.ts → create-ui-generator-CxPvudbs.d.ts} +1 -1
  15. package/dist/{dispatch-D2htzi9h.d.ts → dispatch-N8wdNOX-.d.ts} +4 -2
  16. package/dist/evaluation/axis-checks/index.d.ts +3 -3
  17. package/dist/evaluation/axis-checks/index.js +147 -3
  18. package/dist/evaluation/axis-checks/index.js.map +1 -1
  19. package/dist/evaluation/axis-checks/registry.d.ts +1 -1
  20. package/dist/evaluation/axis-checks/registry.js +142 -1
  21. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  22. package/dist/evaluation/index.d.ts +3 -3
  23. package/dist/evaluation/index.js +217 -84
  24. package/dist/evaluation/index.js.map +1 -1
  25. package/dist/harness/check/runtime-render/index.d.ts +1 -1
  26. package/dist/harness/index.d.ts +1 -1
  27. package/dist/harness/index.js +91 -64
  28. package/dist/harness/index.js.map +1 -1
  29. package/dist/harness/prompts.js +4 -0
  30. package/dist/harness/prompts.js.map +1 -1
  31. package/dist/harness/result-types.d.ts +1 -1
  32. package/dist/harness/runtime.d.ts +1 -1
  33. package/dist/harness/runtime.js +31 -7
  34. package/dist/harness/runtime.js.map +1 -1
  35. package/dist/harness/types-public.d.ts +8 -1
  36. package/dist/harness/types-public.js +91 -64
  37. package/dist/harness/types-public.js.map +1 -1
  38. package/dist/harness/validator-trace-sink.d.ts +1 -1
  39. package/dist/index.d.ts +2 -2
  40. package/dist/index.js +391 -134
  41. package/dist/index.js.map +1 -1
  42. package/dist/patch.d.ts +1 -1
  43. package/dist/{types-public-DVSssdc-.d.ts → types-public-DKR_h70H.d.ts} +9 -0
  44. package/dist/validation/index.d.ts +1 -1
  45. package/dist/validation/index.js +1 -1
  46. package/dist/validation/index.js.map +1 -1
  47. package/dist/workflows.d.ts +1 -1
  48. package/package.json +12 -12
  49. package/src/adapters/google/sdk.ts +11 -7
  50. package/src/boilerplate/free-design-prompt.ts +2 -0
  51. package/src/boilerplate/generate.ts +8 -1
  52. package/src/boilerplate/hard-sections.ts +32 -3
  53. package/src/boilerplate/system-prompt.ts +3 -1
  54. package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +5 -3
  55. package/src/boilerplate/templates/layouts/fullscreen-mobile.tsx.tmpl +3 -3
  56. package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +5 -3
  57. package/src/check/run-tier0.ts +152 -0
  58. package/src/coding-agent/tools.ts +58 -6
  59. package/src/contract-context.ts +13 -0
  60. package/src/create-harness.ts +1 -0
  61. package/src/evaluation/axis-checks/checks/universal.ts +179 -1
  62. package/src/evaluation/axis-checks/dispatch.ts +8 -1
  63. package/src/evaluation/types-public.ts +9 -0
  64. package/src/harness/coding/generate-task-runner.ts +1 -0
  65. package/src/harness/coding/run-coding-turn.ts +14 -0
  66. package/src/harness/types-public.ts +7 -0
  67. package/src/run-check.ts +2 -0
  68. package/src/tools/get-wire.ts +1 -1
  69. package/src/validation/primitives.ts +1 -1
@@ -4,7 +4,8 @@
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";
10
11
  import {
@@ -333,6 +334,171 @@ function runViewportSized(input: AxisCheckInput): EvalIssue[] {
333
334
  ];
334
335
  }
335
336
 
337
+ // ── universal.terminal_action_unguarded (ggui#1108, mitigation) ────────
338
+ // A control whose action the user means ONCE — submit, confirm, approve,
339
+ // schedule, reserve, book, pay, checkout, place order — that stays armed after
340
+ // it fires is one double-click away from doing it twice. The check is a WARN,
341
+ // deliberately: terminality lives in the request and the contract, not in a
342
+ // name, so a static rule can suspect it and must not refuse over it. It fires
343
+ // only when a terminal-sounding action or control exists AND the source guards
344
+ // nothing at all (no `disabled=`, no `aria-disabled`), which is the shape with
345
+ // no reading under which the control is safe.
346
+ /** The words that make an action read as meant-once. Matched as WORDS, never as substrings. */
347
+ const TERMINAL_WORDS = new Set([
348
+ "submit", "confirm", "approve", "schedule", "reserve", "book", "pay", "checkout", "purchase", "order",
349
+ ]);
350
+ /** In prose (a control's label) the same words, with real boundaries. */
351
+ const TERMINAL_LABEL_RX = /\b(submit|confirm|approve|schedule|reserve|book|pay|checkout|purchase|place\s+order)\b/i;
352
+ const GUARD_RX = /\b(disabled|aria-disabled)\s*=/;
353
+
354
+ /** `submitBooking` → ['submit','booking']; `place_order` → ['place','order']. A NAME is words, not a string to search. */
355
+ function nameWords(name: string): string[] {
356
+ return name
357
+ .split(/(?=[A-Z])|[_\-\s]+/)
358
+ .map((w) => w.toLowerCase())
359
+ .filter((w) => w.length > 0);
360
+ }
361
+
362
+ /** The terminal-sounding action names and control labels a source carries, deduped, in order. */
363
+ export function findUnguardedTerminalControls(sourceCode: string): string[] {
364
+ if (GUARD_RX.test(sourceCode)) return [];
365
+ const names = new Set<string>();
366
+ for (const m of sourceCode.matchAll(/useAction(?:<[^>]*>)?\(\s*['"`]([^'"`]+)['"`]/g)) {
367
+ const name = m[1] ?? "";
368
+ if (nameWords(name).some((w) => TERMINAL_WORDS.has(w))) names.add(name);
369
+ }
370
+ // A control's own words, taken as the plain text immediately before its close
371
+ // tag — attributes can carry `=>`, so a label is read from the text, never by
372
+ // trying to skip an attribute list with a regex.
373
+ for (const m of sourceCode.matchAll(/>([^<>{}]{1,60})<\/Button>/g)) {
374
+ const label = (m[1] ?? "").replace(/\s+/g, " ").trim();
375
+ if (label.length > 0 && TERMINAL_LABEL_RX.test(label)) names.add(label.slice(0, 40));
376
+ }
377
+ return [...names];
378
+ }
379
+
380
+ function runTerminalActionUnguarded(input: AxisCheckInput): EvalIssue[] {
381
+ if (input.compiledCode === null) return [];
382
+ const found = findUnguardedTerminalControls(input.sourceCode);
383
+ if (found.length === 0) return [];
384
+ const list = found.map((t) => `"${t}"`).join(", ");
385
+ return [
386
+ mkIssue(
387
+ "universal.terminal_action_unguarded",
388
+ `${list} reads as an action the user means once, and nothing in this component disables the control after it fires — a double-click sends it twice.`,
389
+ "Hold the fired state (`const [submitted, setSubmitted] = useState(false)`), set it in the handler, and pass `disabled={submitted}` to that control with a word that says so (\"Submitted\"). If the action IS meant to repeat — sending a message, adding an item — leave it armed and ignore this.",
390
+ "warn",
391
+ ),
392
+ ];
393
+ }
394
+
395
+ // ── universal.root_width_cap (ggui#1117 — the eval leg ggui#1113 left out) ─
396
+ // FRAME_SIZING's width half: the outermost element FILLS the frame; a width
397
+ // cap belongs INSIDE it, on the column that holds text. A cap on the root is
398
+ // the defect on a wide canvas (the card becomes a narrow strip with the
399
+ // frame's ground on either side) and a no-op in a chat bubble (~400 px; a
400
+ // 480 px cap never binds) — so the check reads `input.canvas`: it stands
401
+ // down with no canvas (never a guess) and below `md`, and fires only for a
402
+ // cap SMALLER than the canvas. A bare `<Container>` is a cap too — its
403
+ // default preset is `lg`, 768 px — so it binds on `lg` / `xl` and not on
404
+ // `md`. A cap the check cannot resolve to pixels (a `max-w-*` class, `rem`,
405
+ // `%`) is reported because the prompt forbids it on the root. WARN, not
406
+ // fail: the prompt names the one shape never to write; whether a centred
407
+ // column was the right composition stays the model's call.
408
+ export interface RootWidthCap {
409
+ /** The outermost element's tag name as written. */
410
+ readonly element: string;
411
+ /** The cap as written (`maxWidth="sm"`, `maxWidth: '480px'`, `max-w-md`, or `lg (Container default)`). */
412
+ readonly cap: string;
413
+ /** The cap in pixels when it resolves (a Container preset, an `Npx` value); `null` otherwise. */
414
+ readonly px: number | null;
415
+ }
416
+
417
+ /** The opening tag of the OUTERMOST element the default export returns, as written; `null` for none or a fragment. */
418
+ function rootOpeningTag(sourceCode: string): { readonly name: string; readonly attrs: string } | null {
419
+ const start = sourceCode.indexOf("export default function");
420
+ const body = start >= 0 ? sourceCode.slice(start) : sourceCode;
421
+ const ret = /\breturn\s*\(?\s*</.exec(body);
422
+ if (ret === null) return null;
423
+ const at = ret.index + ret[0].length - 1;
424
+ const nameMatch = /^<([A-Za-z][\w.]*)/.exec(body.slice(at));
425
+ if (nameMatch === null) return null;
426
+ let j = at + nameMatch[0].length;
427
+ let depth = 0;
428
+ let quote: string | null = null;
429
+ for (; j < body.length; j++) {
430
+ const ch = body[j]!;
431
+ if (quote !== null) {
432
+ if (ch === quote) quote = null;
433
+ continue;
434
+ }
435
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
436
+ else if (ch === "{") depth++;
437
+ else if (ch === "}") depth--;
438
+ else if (ch === ">" && depth === 0) break;
439
+ }
440
+ return { name: nameMatch[1]!, attrs: body.slice(at + nameMatch[0].length, j) };
441
+ }
442
+
443
+ const CONTAINER_PRESETS: Readonly<Record<string, string>> = CONTAINER_MAX_WIDTH;
444
+ const NOT_A_CAP = new Set(["full", "none", "100%", "unset", "initial", "inherit"]);
445
+
446
+ /** `sm` → 480; `480px` → 480; `100%` / `full` → not a cap (`undefined`); anything else → a cap of unknown size (`null`). */
447
+ function capPx(value: string): number | null | undefined {
448
+ const v = value.trim();
449
+ if (NOT_A_CAP.has(v)) return undefined;
450
+ const resolved = CONTAINER_PRESETS[v] ?? v;
451
+ if (NOT_A_CAP.has(resolved)) return undefined;
452
+ const px = /^(\d+(?:\.\d+)?)px$/.exec(resolved);
453
+ return px === null ? null : Number(px[1]);
454
+ }
455
+
456
+ /** The width cap the outermost returned element carries, or `null` when it fills the frame. */
457
+ export function findRootWidthCap(sourceCode: string): RootWidthCap | null {
458
+ const root = rootOpeningTag(sourceCode);
459
+ if (root === null) return null;
460
+ const { name, attrs } = root;
461
+ const prop = /\bmaxWidth\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*(?:"([^"]*)"|'([^']*)'|(\d+(?:\.\d+)?))\s*\})/.exec(attrs);
462
+ if (prop !== null) {
463
+ const raw = prop[1] ?? prop[2] ?? prop[3] ?? prop[4] ?? `${prop[5]!}px`;
464
+ const px = capPx(raw);
465
+ return px === undefined ? null : { element: name, cap: `maxWidth="${raw}"`, px };
466
+ }
467
+ const style = /\bmaxWidth\s*:\s*(?:"([^"]*)"|'([^']*)'|`([^`]*)`|(\d+(?:\.\d+)?))/.exec(attrs);
468
+ if (style !== null) {
469
+ const raw = style[1] ?? style[2] ?? style[3] ?? `${style[4]!}px`;
470
+ const px = capPx(raw);
471
+ return px === undefined ? null : { element: name, cap: `maxWidth: '${raw}'`, px };
472
+ }
473
+ const cls = /(?<![\w-])max-w-([\w[\]%.-]+)/.exec(attrs);
474
+ if (cls !== null) {
475
+ if (cls[1] === "full" || cls[1] === "none") return null;
476
+ return { element: name, cap: `max-w-${cls[1]!}`, px: null };
477
+ }
478
+ if (name === "Container") return { element: name, cap: "lg (Container default)", px: capPx("lg") ?? null };
479
+ return null;
480
+ }
481
+
482
+ const WIDE_CANVASES: ReadonlySet<CanvasClass> = new Set<CanvasClass>(["md", "lg", "xl"]);
483
+
484
+ function runRootWidthCap(input: AxisCheckInput): EvalIssue[] {
485
+ if (input.compiledCode === null) return [];
486
+ if (input.canvas === undefined || !WIDE_CANVASES.has(input.canvas)) return [];
487
+ const cap = findRootWidthCap(input.sourceCode);
488
+ if (cap === null) return [];
489
+ const width = CANVAS_VIEWPORTS[input.canvas].width;
490
+ if (cap.px !== null && cap.px >= width) return [];
491
+ const sized = cap.px !== null ? `${cap.px}px on a ${width}px canvas` : `on a ${width}px canvas`;
492
+ return [
493
+ mkIssue(
494
+ "universal.root_width_cap",
495
+ `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.`,
496
+ '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>`.',
497
+ "warn",
498
+ ),
499
+ ];
500
+ }
501
+
336
502
  export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
337
503
  {
338
504
  id: "universal.icon_name_known",
@@ -352,6 +518,18 @@ export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
352
518
  values: ALL_RENDER_VALUES,
353
519
  run: runViewportSized,
354
520
  },
521
+ {
522
+ id: "universal.root_width_cap",
523
+ axis: "render",
524
+ values: ALL_RENDER_VALUES,
525
+ run: runRootWidthCap,
526
+ },
527
+ {
528
+ id: "universal.terminal_action_unguarded",
529
+ axis: "render",
530
+ values: ALL_RENDER_VALUES,
531
+ run: runTerminalActionUnguarded,
532
+ },
355
533
  {
356
534
  id: "universal.accent_text_ink",
357
535
  axis: "render",
@@ -9,7 +9,7 @@ import { REGISTRY } from "./registry.js";
9
9
  import { matches, type AxisCheck, type AxisCheckInput } from "./types.js";
10
10
  import { axisCheckTraceEnabled } from "./helpers.js";
11
11
  import { createHash } from "node:crypto";
12
- import type { DesignMode } from "../../design-mode.js";
12
+ import type { CanvasClass, DesignMode } from "../../design-mode.js";
13
13
 
14
14
  export interface RunAxisChecksInput {
15
15
  sourceCode: string;
@@ -18,6 +18,8 @@ export interface RunAxisChecksInput {
18
18
  originalPrompt: string;
19
19
  /** Which triad produced the source — see `AxisCheckInput.designMode`. */
20
20
  designMode?: DesignMode;
21
+ /** The canvas the source is judged for — see `AxisCheckInput.canvas` (ggui#1117); absent = no rendering context. */
22
+ canvas?: CanvasClass;
21
23
  }
22
24
 
23
25
  export function runAxisChecks(
@@ -30,6 +32,7 @@ export function runAxisChecks(
30
32
  originalPrompt: input.originalPrompt,
31
33
  classification,
32
34
  ...(input.designMode !== undefined ? { designMode: input.designMode } : {}),
35
+ ...(input.canvas !== undefined ? { canvas: input.canvas } : {}),
33
36
  };
34
37
  if (input.compiledCode === null) {
35
38
  traceAxisChecksSkipped(facts, "compiledCode null — no check ran");
@@ -93,6 +96,8 @@ export interface AxisCheckTrace {
93
96
  readonly classification: Classification["vector"];
94
97
  readonly propsSpecKeys: readonly string[];
95
98
  readonly propsSpecPropertyKeys: readonly string[];
99
+ /** The canvas the verdict was built for (ggui#1117) — present only when the input carried one. */
100
+ readonly canvas?: CanvasClass;
96
101
  readonly matched: readonly string[];
97
102
  readonly issues: ReadonlyArray<{ readonly id: string; readonly result: EvalIssue["result"] }>;
98
103
  readonly note?: string;
@@ -107,5 +112,7 @@ function traceFacts(input: AxisTraceFacts): Omit<AxisCheckTrace, "matched" | "is
107
112
  classification: input.classification.vector,
108
113
  propsSpecKeys: Object.keys(propsSpec ?? {}),
109
114
  propsSpecPropertyKeys: Object.keys(propsSpec?.properties ?? {}),
115
+ // No default here, unlike designMode: an absent canvas is a fact about the caller, not a guess to paper over.
116
+ ...(input.canvas !== undefined ? { canvas: input.canvas } : {}),
110
117
  };
111
118
  }
@@ -280,6 +280,15 @@ export interface AxisCheckInput {
280
280
  * means `constrained`.
281
281
  */
282
282
  designMode?: DesignMode;
283
+ /**
284
+ * The rendering canvas the source is judged FOR (ggui#1117). A width cap on
285
+ * the outermost element is a defect on a fullscreen canvas and a no-op in a
286
+ * chat bubble, so a check that reads width must know the surface. Populated
287
+ * where the harness is built (`Harness.canvas`); ABSENT for callers with no
288
+ * rendering context — a check handles `undefined`, it never assumes a
289
+ * default (a silent `lg` would be the same guess with a nicer face).
290
+ */
291
+ canvas?: CanvasClass;
283
292
  }
284
293
 
285
294
  /**
@@ -178,6 +178,7 @@ export function createGenerateTaskRunner(input: CreateGenerateRunnerInput): Task
178
178
  systemPrompt: session.systemPrompt,
179
179
  harness: session.harness,
180
180
  contract: params.contract,
181
+ originalPrompt: params.userPrompt,
181
182
  commitMeta: session.commitMeta,
182
183
  originalProps: session.originalProps,
183
184
  costTracker: session.costTracker,
@@ -155,6 +155,13 @@ export interface CodingTurnContext {
155
155
  readonly systemPrompt: string;
156
156
  readonly harness: Harness;
157
157
  readonly contract: DataContract | undefined;
158
+ /**
159
+ * The ORIGINAL request (ggui#1122) — what the eval round has always judged
160
+ * against, now handed to auto-commit so the axis checks run on the serve
161
+ * path with the same input. Required: a caller that forgot it would silently
162
+ * run no axis checks on every served generation.
163
+ */
164
+ readonly originalPrompt: string;
158
165
  readonly commitMeta: Map<string, CommitMetadata>;
159
166
  readonly originalProps: string | undefined;
160
167
  readonly costTracker: CostTracker | null;
@@ -669,6 +676,13 @@ ${closingInstruction}`;
669
676
  // Which tier-0 legs fire — the design-vocabulary legs stand down in
670
677
  // `free` mode; every contract leg runs in both.
671
678
  harness.designMode,
679
+ // ggui#1122: the harness's pre-filtered axis checks run at auto-commit — the serve path.
680
+ {
681
+ checks: harness.check.axisChecks,
682
+ classification: harness.classification,
683
+ originalPrompt: ctx.originalPrompt,
684
+ ...(harness.canvas !== undefined ? { canvas: harness.canvas } : {}),
685
+ },
672
686
  );
673
687
  const toolMs = Date.now() - toolStart;
674
688
 
@@ -303,6 +303,13 @@ export interface Harness {
303
303
  * into `free`.
304
304
  */
305
305
  readonly designMode: DesignMode;
306
+ /**
307
+ * The rendering canvas this harness was built for (ggui#1117) — threaded
308
+ * from `CreateHarnessInput.canvas` into the HOW leg (the `free` prompt
309
+ * states it) and, via `run-check.ts`, onto every axis check's input.
310
+ * ABSENT when the caller had no rendering context; never defaulted.
311
+ */
312
+ readonly canvas?: CanvasClass;
306
313
  /** The app's generation profile in force for this harness (#991), if any. */
307
314
  readonly profile?: GenerationProfileInput;
308
315
 
package/src/run-check.ts CHANGED
@@ -119,6 +119,8 @@ export async function runCheck(input: RunCheckInput): Promise<CheckResult> {
119
119
  originalPrompt: prompt,
120
120
  classification: harness.classification,
121
121
  designMode: harness.designMode,
122
+ // ggui#1117: the canvas the harness was built for, when it has one — never a default.
123
+ ...(harness.canvas !== undefined ? { canvas: harness.canvas } : {}),
122
124
  };
123
125
  // The one runner `runAxisChecks` shares (ggui#1046 trace prints here, on the served path).
124
126
  const axisRun = runGatedAxisChecks(check.axisChecks, axisInput);
@@ -2,4 +2,4 @@
2
2
  * AUTO-GENERATED from @ggui-ai/wire JSDoc.
3
3
  * Do not edit manually. Run: pnpm --filter @ggui-ai/wire generate:docs
4
4
  */
5
- export const WIRE_DOCUMENTATION = "# ggui Wire Hooks Reference\n\n> Wire hooks connect generated UI components to agent communication.\n> They are pre-imported in the boilerplate — use them directly.\n> All hooks must be called inside a GguiWireProvider (handled automatically by the renderer).\n\nImport: `import { useAction, useStream } from '@ggui-ai/wire'`\n\n## Communication Hooks\n\n\n\nThese are the wire primitives for component-agent communication. `useWiredTool` retired 2026-05-11 — agentCapabilities.tools is a catalog the AGENT invokes, not a component hook surface; user gestures use `useAction(name)` and the optional `nextStep` field on the action entry names the tool the agent SHOULD invoke next.\n\n### useAction\n\nFire an action to the agent. Fire-and-forget — no response, no pending state.\n\nEvery action is an event: it lands on the GguiSession's consume buffer and\nthe agent receives it on its next turn (via `ggui_consume`). The action\nentry's optional `nextStep` names the tool the agent SHOULD invoke in\nresponse — advisory only; the agent owns the call decision. Component code\nis identical either way: call `useAction(name)(payload)` and treat\n`nextStep` as informational (use it to inform button labels, icons, copy).\n\nRUNTIME DEDUP (backstop, not a feature). Same-`(name, payload)` calls within\none event-loop task are coalesced — the first wins, subsequent duplicates\nare suppressed. This is the structural defense against LLM-generated\nnested-interactive components where a Checkbox `onChange` and an outer\n`Card as={Clickable}` `onClick` both wire to the same `useAction` binding;\nthe inner gesture bubbles to the outer handler, so without dedup one user\nclick would fire the action twice (a toggle would run back-to-back and the\nuser's change would disappear). **Do not rely on this as a feature** — wire\neach `useAction` callback to exactly ONE interactive surface; the dedup\nexists only because the LLM's source code can be wrong in subtle ways and\nthe runtime is the only place that can see the actual event-bubble path.\nSee `dispatch-dedup.ts` for the full failure-mode rationale.\n\nNEVER SILENT. When the dedup fires, a `console.warn` is emitted in BOTH dev\nand prod with the full diagnostic. The suppression is always visible in\nbrowser DevTools; operators investigating a \"the second click does nothing\"\nreport see the warning immediately. A structured observer also exists as\n`WireConfig.onDispatchSuppressed`, but the first-party renderers never set\nit — it is reachable only from a hand-built `WireConfig` (see its\ndocstring), so the console signal is what operators get in practice.\n\n**Signature:** `useAction<T = unknown>(actionName: string): (data: T) => void`\n\n**Parameters:**\n\n| Param | Type | Description |\n|-------|------|-------------|\n| actionName | `string` | Action name from the action contract |\n\n**Returns:** `(data: T) => void`\n\n**Example:**\n```tsx\nconst submitForm = useAction<{name: string; email: string}>('formSubmit');\n\n// In JSX:\n<Button onClick={() => submitForm({ name, email })}>Submit</Button>\n```\n\n\n### useStream\n\nSubscribe to deliveries on a named stream channel.\n\nHonors the channel's per-delivery `mode` ('append' vs 'replace')\nand the optional `complete` terminal marker. Channels declared\n`mode: 'replace'` on the spec typically emit every delivery with\n`mode: 'replace'` — this hook folds them into a single-latest\nvalue without accumulating history.\n\n**Signature:** `useStream<T = unknown>(channelName: string): StreamResult<T>`\n\n**Parameters:**\n\n| Param | Type | Description |\n|-------|------|-------------|\n| channelName | `string` | Channel name from the GguiSession's streamSpec |\n\n**Returns:** `StreamResult<T>`\n\n| Property | Type | Description |\n|----------|------|-------------|\n| latest | `T \\| null` | Most recent payload delivered on this channel, or null if none received yet. |\n| all | `T[]` | All payloads accumulated on this channel. - `mode: 'append'` deliveries are pushed to the tail (continuous stream). - `mode: 'replace'` deliveries collapse `all` to a single-element array containing the latest payload — matching the channel's \"full replacement\" semantics. |\n| isComplete | `boolean` | Truthy after the channel has delivered an envelope with `complete: true`. Subscribers flip into a \"channel closed\" rendering state based on this signal; further deliveries on a completed channel are still accumulated, since the underlying wire doesn't enforce quiescence. |\n\n**Example:**\n```tsx\nconst progress = useStream<{ percent: number; message: string }>('progress');\n\n// In JSX:\n{progress.latest && (\n <Progress value={progress.latest.percent} />\n)}\n<Text>{progress.all.length} updates received</Text>\n```\n\n\n## Context Hooks\n\n\n\nRead-only access to render, app, and auth context.\n\n### useAuth\n\nRead-only auth context. Token excluded — auth is added server-side.\n\n**Signature:** `useAuth(): AuthInfo`\n\n**Returns:** `AuthInfo`\n\n| Property | Type | Description |\n|----------|------|-------------|\n| userId | `string` | userId |\n| isAuthenticated | `boolean` | isAuthenticated |\n\n**Example:**\n```tsx\nconst auth = useAuth();\n\n// In JSX:\n{auth.isAuthenticated\n ? <Text>Welcome, user {auth.userId}</Text>\n : <Text>Please sign in</Text>\n}\n```\n\n\n### useApp\n\nRead-only app metadata.\n\n**Signature:** `useApp(): AppInfo`\n\n**Returns:** `AppInfo`\n\n| Property | Type | Description |\n|----------|------|-------------|\n| appId | `string` | appId |\n| appName | `string` | appName |\n| appDescription | `string` | appDescription |\n| appIcon | `string` | appIcon |\n\n**Example:**\n```tsx\nconst app = useApp();\n\n// In JSX:\n<Heading>{app.appName}</Heading>\n{app.appDescription && <Text>{app.appDescription}</Text>}\n```\n\n\n### useRender\n\nRead-only render context with connection status.\n\n**Signature:** `useRender(): GguiSessionInfo`\n\n**Returns:** `GguiSessionInfo`\n\n| Property | Type | Description |\n|----------|------|-------------|\n| sessionId | `string` | sessionId |\n| isConnected | `boolean` | isConnected |\n\n**Example:**\n```tsx\nconst render = useRender();\n\n// In JSX:\n<Badge variant={render.isConnected ? 'success' : 'error'}>\n {render.isConnected ? 'Connected' : 'Disconnected'}\n</Badge>\n```\n\n\n## Provider\n\n\n\nThe provider is set up automatically by the renderer. Generated components do not need to wrap themselves in a provider.\n\n### GguiWireProvider\n\nReact context provider that injects WireConfig for all wire hooks.\n\n**Props:**\n\n| Prop | Type | Description |\n|------|------|-------------|\n| config | `WireConfig` | config |\n| children | `ReactNode` | children |\n\n**Example:**\n```tsx\n<GguiWireProvider config={wireConfig}>\n <YourComponent />\n</GguiWireProvider>\n```\n\n\n## Internal: WireConfig\n\n\n\nThis is the configuration object passed to GguiWireProvider. Generated components do not interact with this directly — it is provided by the renderer.\n\n### WireConfig\n\nConfiguration injected by the provider — the renderer inside the\niframe.\n\nEvery method is typed against the contract generic `T` so typed\ncallers get compile-time enforcement:\n - `dispatch(name, data)` — `name` MUST be a declared actionSpec\n key; `data` MUST satisfy that action's schema.\n - `subscribe(channel, handler)` — same discipline for streamSpec.\n\nUntyped callers (`T = DataContract` default) degrade to the broad\nshape via the conditional `WireDispatchData` / `WireStreamPayload`\naliases — no call-site break.\n\nThe contract's `agentCapabilities.tools` catalog declares tools the AGENT\ninvokes (not the component); user gestures fire via\n`dispatch(name, data)` and the optional `nextStep` field on the\naction entry names the tool the agent SHOULD invoke next.\n\nThe renderer mounts exactly ONE GguiSession per iframe — `render.sessionId`\nis the stable identity the WireProvider was constructed with. No\nper-item scoping factory; with a one-GguiSession-per-mount lifecycle,\n\"scope\" collapses to identity.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| app | `{ readonly appId: string; readonly appName: string; readonly appDescription?: string; readonly appIcon?: string; }` | app |\n| render | `{ readonly sessionId: string; readonly isConnected: boolean; }` | render |\n| auth | `{ readonly userId?: string; readonly isAuthenticated: boolean; }` | auth |\n| dispatch | `<N extends string>( actionName: N, data: WireDispatchData<T, N>, ) => void` | Fire an action to the agent (fire-and-forget over WS). Typed callers get compile-time checked `name` + `data`; untyped callers (`T = DataContract`) keep the broad shape. |\n| subscribe | `<N extends string>( channelName: N, handler: (delivery: StreamDelivery<WireStreamPayload<T, N>>) => void, ) => () => void` | Subscribe to deliveries on a named stream channel. |\n| onDispatchSuppressed | `(info: DispatchSuppressedInfo) => void` | Optional structured observability for `useAction`'s task-scoped duplicate-dispatch suppression. Fires alongside the always-on `console.warn` whenever the runtime coalesces a same-(name, payload) re-dispatch within one event-loop task — the nested- interactive double-fire backstop. Reachability constraint: the first-party renderers do NOT set this field — `buildRootWireConfig` (`@ggui-ai/iframe-runtime`) and `<GguiRender>`'s internal config both omit it. It fires only when a host hand-builds a complete `WireConfig` and mounts `GguiWireProvider` itself. On every first-party render path the `console.warn` is the sole suppression signal. |\n";
5
+ export const WIRE_DOCUMENTATION = "# ggui Wire Hooks Reference\n\n> Wire hooks connect generated UI components to agent communication.\n> They are pre-imported in the boilerplate — use them directly.\n> All hooks must be called inside a GguiWireProvider (handled automatically by the renderer).\n\nImport: `import { useAction, useStream } from '@ggui-ai/wire'`\n\n## Communication Hooks\n\n\n\nThese are the wire primitives for component-agent communication. `useWiredTool` retired 2026-05-11 — agentCapabilities.tools is a catalog the AGENT invokes, not a component hook surface; user gestures use `useAction(name)` and the optional `nextStep` field on the action entry names the tool the agent SHOULD invoke next.\n\n### useAction\n\nFire an action to the agent. Fire-and-forget — no response, no pending state.\n\nEvery action is an event: it lands on the GguiSession's consume buffer and\nthe agent receives it on its next turn (via `ggui_consume`). The action\nentry's optional `nextStep` names the tool the agent SHOULD invoke in\nresponse — advisory only; the agent owns the call decision. Component code\nis identical either way: call `useAction(name)(payload)` and treat\n`nextStep` as informational (use it to inform button labels, icons, copy).\n\nRUNTIME DEDUP (backstop, not a feature). Same-`(name, payload)` calls within\none event-loop task are coalesced — the first wins, subsequent duplicates\nare suppressed. This is the structural defense against LLM-generated\nnested-interactive components where a Checkbox `onChange` and an outer\n`Card as={Clickable}` `onClick` both wire to the same `useAction` binding;\nthe inner gesture bubbles to the outer handler, so without dedup one user\nclick would fire the action twice (a toggle would run back-to-back and the\nuser's change would disappear). **Do not rely on this as a feature** — wire\neach `useAction` callback to exactly ONE interactive surface; the dedup\nexists only because the LLM's source code can be wrong in subtle ways and\nthe runtime is the only place that can see the actual event-bubble path.\nSee `dispatch-dedup.ts` for the full failure-mode rationale.\n\nNEVER SILENT. When the dedup fires, a `console.warn` is emitted in BOTH dev\nand prod with the full diagnostic. The suppression is always visible in\nbrowser DevTools; operators investigating a \"the second click does nothing\"\nreport see the warning immediately. A structured observer also exists as\n`WireConfig.onDispatchSuppressed`, but the first-party renderers never set\nit — it is reachable only from a hand-built `WireConfig` (see its\ndocstring), so the console signal is what operators get in practice.\n\n**Signature:** `useAction<T = unknown>(actionName: string): (data: T) => void`\n\n**Parameters:**\n\n| Param | Type | Description |\n|-------|------|-------------|\n| actionName | `string` | Action name from the action contract |\n\n**Returns:** `(data: T) => void`\n\n**Example:**\n```tsx\nconst submitForm = useAction<{name: string; email: string}>('formSubmit');\n\n// In JSX:\n<Button onClick={() => submitForm({ name, email })}>Submit</Button>\n```\n\n\n### useStream\n\nSubscribe to deliveries on a named stream channel.\n\nHonors the channel's per-delivery `mode` ('append' vs 'replace')\nand the optional `complete` terminal marker. Channels declared\n`mode: 'replace'` on the spec typically emit every delivery with\n`mode: 'replace'` — this hook folds them into a single-latest\nvalue without accumulating history.\n\n**Signature:** `useStream<T = unknown>(channelName: string): StreamResult<T>`\n\n**Parameters:**\n\n| Param | Type | Description |\n|-------|------|-------------|\n| channelName | `string` | Channel name from the GguiSession's streamSpec |\n\n**Returns:** `StreamResult<T>`\n\n| Property | Type | Description |\n|----------|------|-------------|\n| latest | `T \\| null` | Most recent payload delivered on this channel, or null if none received yet. |\n| all | `T[]` | All payloads accumulated on this channel. - `mode: 'append'` deliveries are pushed to the tail (continuous stream). - `mode: 'replace'` deliveries collapse `all` to a single-element array containing the latest payload — matching the channel's \"full replacement\" semantics. |\n| isComplete | `boolean` | Truthy after the channel has delivered an envelope with `complete: true`. Subscribers flip into a \"channel closed\" rendering state based on this signal; further deliveries on a completed channel are still accumulated, since the underlying wire doesn't enforce quiescence. |\n\n**Example:**\n```tsx\nconst progress = useStream<{ percent: number; message: string }>('progress');\n\n// In JSX:\n{progress.latest && (\n <Progress value={progress.latest.percent} />\n)}\n<Text>{progress.all.length} updates received</Text>\n```\n\n\n## Context Hooks\n\n\n\nRead-only access to render, app, and auth context.\n\n### useAuth\n\nRead-only auth context. Token excluded — auth is added server-side.\n\n**Signature:** `useAuth(): AuthInfo`\n\n**Returns:** `AuthInfo`\n\n| Property | Type | Description |\n|----------|------|-------------|\n| userId | `string` | userId |\n| isAuthenticated | `boolean` | isAuthenticated |\n\n**Example:**\n```tsx\nconst auth = useAuth();\n\n// In JSX:\n{auth.isAuthenticated\n ? <Text>Welcome, user {auth.userId}</Text>\n : <Text>Please sign in</Text>\n}\n```\n\n\n### useApp\n\nRead-only app metadata.\n\n**Signature:** `useApp(): AppInfo`\n\n**Returns:** `AppInfo`\n\n| Property | Type | Description |\n|----------|------|-------------|\n| appId | `string` | appId |\n| appName | `string` | appName |\n| appDescription | `string` | appDescription |\n| appIcon | `string` | appIcon |\n\n**Example:**\n```tsx\nconst app = useApp();\n\n// In JSX:\n<Heading>{app.appName}</Heading>\n{app.appDescription && <Text>{app.appDescription}</Text>}\n```\n\n\n### useRender\n\nRead-only render context with connection status.\n\n**Signature:** `useRender(): GguiSessionInfo`\n\n**Returns:** `GguiSessionInfo`\n\n| Property | Type | Description |\n|----------|------|-------------|\n| sessionId | `string` | sessionId |\n| isConnected | `boolean` | isConnected |\n\n**Example:**\n```tsx\nconst render = useRender();\n\n// In JSX:\n<Badge variant={render.isConnected ? 'success' : 'error'}>\n {render.isConnected ? 'Connected' : 'Disconnected'}\n</Badge>\n```\n\n\n## Provider\n\n\n\nThe provider is set up automatically by the renderer. Generated components do not need to wrap themselves in a provider.\n\n### GguiWireProvider\n\nReact context provider that injects WireConfig for all wire hooks.\n\n**Props:**\n\n| Prop | Type | Description |\n|------|------|-------------|\n| config | `WireConfig` | config |\n| children | `ReactNode` | children |\n\n**Example:**\n```tsx\n<GguiWireProvider config={wireConfig}>\n <YourComponent />\n</GguiWireProvider>\n```\n\n\n## Internal: WireConfig\n\n\n\nThis is the configuration object passed to GguiWireProvider. Generated components do not interact with this directly — it is provided by the renderer.\n\n### WireConfig\n\nConfiguration injected by the provider — the renderer inside the\niframe.\n\nEvery method is typed against the contract generic `T` so typed\ncallers get compile-time enforcement:\n - `dispatch(name, data)` — `name` MUST be a declared actionSpec\n key; `data` MUST satisfy that action's schema.\n - `subscribe(channel, handler)` — same discipline for streamSpec.\n\nUntyped callers (`T = DataContract` default) degrade to the broad\nshape via the conditional `WireDispatchData` / `WireStreamPayload`\naliases — no call-site break.\n\nThe contract's `agentCapabilities.tools` catalog declares tools the AGENT\ninvokes (not the component); user gestures fire via\n`dispatch(name, data)` and the optional `nextStep` field on the\naction entry names the tool the agent SHOULD invoke next.\n\nThe renderer mounts exactly ONE GguiSession per iframe — `render.sessionId`\nis the stable identity the WireProvider was constructed with. No\nper-item scoping factory; with a one-GguiSession-per-mount lifecycle,\n\"scope\" collapses to identity.\n\n| Property | Type | Description |\n|----------|------|-------------|\n| app | `{ readonly appId: string; readonly appName: string; readonly appDescription?: string; readonly appIcon?: string; }` | app |\n| render | `{ readonly sessionId: string; readonly isConnected: boolean; }` | render |\n| auth | `{ readonly userId?: string; readonly isAuthenticated: boolean; }` | auth |\n| dispatch | `<N extends string>( actionName: N, data: WireDispatchData<T, N>, ) => void` | Fire an action to the agent (fire-and-forget over WS). Typed callers get compile-time checked `name` + `data`; untyped callers (`T = DataContract`) keep the broad shape. |\n| subscribe | `<N extends string>( channelName: N, handler: (delivery: StreamDelivery<WireStreamPayload<T, N>>) => void, ) => () => void` | Subscribe to deliveries on a named stream channel. |\n| onDispatchSuppressed | `(info: DispatchSuppressedInfo) => void` | Optional structured observability for the runtime's two dispatch- suppression invariants — the task-scoped duplicate backstop (`reason: 'duplicate-dispatch'`, fired by `useAction`) and the render-lifetime one-shot guard (`reason: 'one-shot-spent'`, fired by the config's own `dispatch` when a declared `oneShot` action is gestured a second time, ggui#1108). Both fire ALONGSIDE the always-on `console.warn`; neither reaches the agent. Reachability constraint: the first-party renderers do NOT set this field — `buildRootWireConfig` (`@ggui-ai/iframe-runtime`) and `<GguiRender>`'s internal config both omit it. It fires only when a host hand-builds a complete `WireConfig` (or passes `onDispatchSuppressed` to `buildWireConfig`). On every first-party render path the `console.warn` is the sole suppression signal — which is what the `oneShot` contract requires as its minimum trace. |\n";