@ggui-ai/ui-gen 0.25.0 → 0.26.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 (106) hide show
  1. package/README.md +2 -2
  2. package/dist/{adapter-Cg_rkpGq.d.ts → adapter-BJwn74rx.d.ts} +49 -1
  3. package/dist/adapters/generation-dispatch.d.ts +8 -5
  4. package/dist/adapters/generation-dispatch.js +1118 -193
  5. package/dist/adapters/generation-dispatch.js.map +1 -1
  6. package/dist/adapters/index.js +27 -6
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/advanced/index.d.ts +10 -9
  9. package/dist/advanced/index.js +1126 -202
  10. package/dist/advanced/index.js.map +1 -1
  11. package/dist/blueprint-validator.js +416 -92
  12. package/dist/blueprint-validator.js.map +1 -1
  13. package/dist/boilerplate.d.ts +2 -2
  14. package/dist/boilerplate.js +10 -3
  15. package/dist/boilerplate.js.map +1 -1
  16. package/dist/check/index.d.ts +3 -2
  17. package/dist/check/index.js +9 -2
  18. package/dist/check/index.js.map +1 -1
  19. package/dist/{create-ui-generator-DFnnArrv.d.ts → create-ui-generator-N4gRXpcL.d.ts} +3 -4
  20. package/dist/{design-mode-DLufhrNp.d.ts → design-mode-DGWN_Gkm.d.ts} +1 -1
  21. package/dist/{dispatch-CStl9eAw.d.ts → dispatch-BUoXRj_r.d.ts} +2 -2
  22. package/dist/evaluation/axis-checks/index.d.ts +5 -4
  23. package/dist/evaluation/axis-checks/index.js.map +1 -1
  24. package/dist/evaluation/axis-checks/registry.d.ts +4 -3
  25. package/dist/evaluation/index.d.ts +107 -539
  26. package/dist/evaluation/index.js +552 -55
  27. package/dist/evaluation/index.js.map +1 -1
  28. package/dist/evaluation/types.d.ts +17 -0
  29. package/dist/evaluation/types.js.map +1 -1
  30. package/dist/{generate-9IeNT4ZY.d.ts → generate-tSO_shQ0.d.ts} +1 -1
  31. package/dist/harness/check/runtime-render/index.d.ts +5 -5
  32. package/dist/harness/check/runtime-render/index.js +416 -92
  33. package/dist/harness/check/runtime-render/index.js.map +1 -1
  34. package/dist/harness/check/runtime-render/render-check-worker.js +393 -88
  35. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  36. package/dist/harness/index.d.ts +4 -5
  37. package/dist/harness/index.js +36 -5
  38. package/dist/harness/index.js.map +1 -1
  39. package/dist/harness/prompts.d.ts +2 -2
  40. package/dist/harness/prompts.js +10 -1
  41. package/dist/harness/prompts.js.map +1 -1
  42. package/dist/harness/result-types.d.ts +17 -3
  43. package/dist/harness/result-types.js.map +1 -1
  44. package/dist/harness/runtime.d.ts +14 -6
  45. package/dist/harness/runtime.js +11 -4
  46. package/dist/harness/runtime.js.map +1 -1
  47. package/dist/harness/types-public.d.ts +4 -2
  48. package/dist/harness/types-public.js +10 -3
  49. package/dist/harness/types-public.js.map +1 -1
  50. package/dist/harness/validator-trace-sink.d.ts +4 -3
  51. package/dist/index.d.ts +46 -11
  52. package/dist/index.js +1128 -200
  53. package/dist/index.js.map +1 -1
  54. package/dist/patch.d.ts +3 -3
  55. package/dist/{prompts-YU9piOgs.d.ts → prompts-BhNUuRoZ.d.ts} +1 -1
  56. package/dist/tools/render-check-worker.js +8 -1
  57. package/dist/tools/render-check-worker.js.map +1 -1
  58. package/dist/{types-public-BJTLpzWn.d.ts → types-public-B7wn4ce5.d.ts} +384 -3
  59. package/dist/validation/index.d.ts +1 -1
  60. package/dist/validation/index.js +1 -1
  61. package/dist/validation/index.js.map +1 -1
  62. package/dist/visual-evaluator-C9L-o6ap.d.ts +639 -0
  63. package/dist/workflows.d.ts +3 -3
  64. package/package.json +12 -12
  65. package/src/adapters/generation-dispatch.ts +2 -0
  66. package/src/boilerplate/free-design-prompt.ts +3 -3
  67. package/src/boilerplate/hard-sections.ts +1 -1
  68. package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +3 -1
  69. package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +3 -1
  70. package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +3 -1
  71. package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +3 -1
  72. package/src/check/run-tier0.ts +6 -3
  73. package/src/contract-context.ts +1 -1
  74. package/src/evaluation/cost-tracker.ts +54 -6
  75. package/src/evaluation/criteria/edge-probe.ts +286 -0
  76. package/src/evaluation/criteria/edge.ts +202 -0
  77. package/src/evaluation/criteria/resolve.ts +55 -7
  78. package/src/evaluation/index.ts +5 -1
  79. package/src/evaluation/judge-spend.ts +48 -0
  80. package/src/evaluation/types-public.ts +91 -0
  81. package/src/evaluation/types.ts +10 -0
  82. package/src/evaluation/visual-evaluator.ts +236 -40
  83. package/src/harness/check/runtime-render/adapter.ts +18 -0
  84. package/src/harness/check/runtime-render/probe-pending.ts +111 -0
  85. package/src/harness/check/runtime-render/render-check-host.ts +10 -6
  86. package/src/harness/check/runtime-render/render-check-worker.ts +6 -3
  87. package/src/harness/check/runtime-render/render-check.ts +417 -133
  88. package/src/harness/check/runtime-render/walk.ts +200 -0
  89. package/src/harness/coding/assemble-result.ts +2 -0
  90. package/src/harness/coding/generate-task-runner.ts +27 -0
  91. package/src/harness/coding/init-session.ts +5 -3
  92. package/src/harness/coding/run-coding-turn.ts +5 -1
  93. package/src/harness/coding/run-eval-round.ts +63 -3
  94. package/src/harness/in-loop-evaluation.ts +39 -0
  95. package/src/harness/llm-router.ts +58 -1
  96. package/src/harness/result-types.ts +9 -0
  97. package/src/harness/runtime.ts +9 -0
  98. package/src/harness/types-public.ts +10 -1
  99. package/src/index.ts +5 -0
  100. package/src/internal/verdict-line.ts +33 -0
  101. package/src/taught-tokens.ts +32 -0
  102. package/src/tools/render-check-worker.ts +2 -1
  103. package/src/tools/render-check.ts +8 -7
  104. package/src/validation/primitives.ts +1 -1
  105. package/dist/llm-router-nfO7wPec.d.ts +0 -267
  106. package/dist/{bank-CT4eEkta.d.ts → bank-Ctdp3FUA.d.ts} +3 -3
@@ -38,8 +38,10 @@ import {
38
38
  type ParentLink,
39
39
  } from "./host-boundary.js";
40
40
  import { hostGlobals, openRecord } from "../../../internal/open-record.js";
41
- import type { ProbeHostLoad } from "../../../evaluation/types-public.js";
41
+ import type { ProbeHostLoad, ProbePendingAffordance } from "../../../evaluation/types-public.js";
42
+ import { createProbePendingSource, lookChanged, lookOf, settlesWithin, type ProbePendingSource } from "./probe-pending.js";
42
43
  import { primeInputs } from "@ggui-ai/ui-visual-tester/prime-inputs";
44
+ import { walkToAction, type WalkControl, type WalkMount, type WalkStop } from "./walk.js";
43
45
 
44
46
  /**
45
47
  * Module-namespace-shaped value injected into `loadComponent`'s
@@ -117,9 +119,25 @@ export interface RenderCheckIssue {
117
119
  * beside `{ primed: 0 }` or `{ error }` than beside `{ primed: 1 }`.
118
120
  */
119
121
  readonly inputPriming?: InputPrimingDiagnostic;
122
+ /** For action-wiring (ggui#1652): how far the walk over the card's screens got without dispatching the action. */
123
+ readonly walk?: ActionWalkDiagnostic;
120
124
  };
121
125
  }
122
126
 
127
+ /**
128
+ * The action walk's account of an action it could not dispatch (ggui#1652):
129
+ * how many controls it pressed across how many screens, why it stopped,
130
+ * whether any screen rendered a control naming the action, and the last
131
+ * controls it pressed. Never the first button in the page.
132
+ */
133
+ export interface ActionWalkDiagnostic {
134
+ readonly presses: number;
135
+ readonly screens: number;
136
+ readonly stoppedBy: Exclude<WalkStop, "dispatched">;
137
+ readonly namedControlSeen: boolean;
138
+ readonly lastPressed: readonly string[];
139
+ }
140
+
123
141
  /**
124
142
  * Outcome of `primeInputs` before the action-wiring probe (ggui#1187). A
125
143
  * priming failure never aborts the check — it is recorded here, on every
@@ -152,6 +170,8 @@ export interface RenderCheckResult {
152
170
  readonly renderMs: number;
153
171
  /** Host load around an isolated check (absent in-process). */
154
172
  readonly hostLoad?: ProbeHostLoad;
173
+ /** ggui#1398: the pending-affordance walk, report-only; absent when no action walk ran. */
174
+ readonly pendingAffordance?: ProbePendingAffordance;
155
175
  };
156
176
  }
157
177
 
@@ -159,8 +179,29 @@ export interface RunRenderCheckInput {
159
179
  readonly sourceCode: string;
160
180
  readonly mockupProps: JsonObject;
161
181
  readonly contract?: DataContract;
182
+ /**
183
+ * Epoch ms by which the check must finish: set by the isolating host from
184
+ * its own bound, absent in-process. The report-only pending phase is
185
+ * skipped when less than {@link PENDING_PHASE_RESERVE_MS} remains, so it
186
+ * can never push a check past its bound (ggui#1398).
187
+ */
188
+ readonly deadlineAt?: number;
162
189
  }
163
190
 
191
+ /** The time the pending phase needs left before the check's deadline to run at all (ggui#1398). */
192
+ export const PENDING_PHASE_RESERVE_MS = 5000;
193
+
194
+ /**
195
+ * The action walk's bounds (ggui#1652), applied once a press has left the
196
+ * first screen (see walk.ts): at most this many presses and this long per
197
+ * action, and never within the pending phase's reserve plus
198
+ * {@link WALK_DEADLINE_MARGIN_MS} (for the checks that run after the walk)
199
+ * of the check's deadline.
200
+ */
201
+ export const WALK_MAX_PRESSES = 60;
202
+ export const WALK_ACTION_CAP_MS = 3000;
203
+ const WALK_DEADLINE_MARGIN_MS = 2000;
204
+
164
205
  /**
165
206
  * Options of {@link runRenderCheck} (ggui#1380): the host's options —
166
207
  * `bounds` are the isolated subprocess's wall-clock, heap and grace (see
@@ -341,7 +382,11 @@ export async function runRenderCheckInProcess(
341
382
  // Must run before RTL's first import in this process — see
342
383
  // production-act-shim.ts's docstring for why.
343
384
  installProductionActShim();
344
- const { render, cleanup } = await import("@testing-library/react");
385
+ const { render, cleanup, act } = await import("@testing-library/react");
386
+ // ggui#1398: the card mounts inside the runtime's pending context, driven
387
+ // by the probe (see probe-pending.ts). Same wire instance as the card's.
388
+ const { ActionPendingContext } = await import("@ggui-ai/wire/internal");
389
+ const pendingSource = createProbePendingSource();
345
390
  // user-event v14 default export — see https://testing-library.com/docs/user-event/intro/
346
391
  const userEventModule = await import("@testing-library/user-event");
347
392
  const userEvent = userEventModule.default ?? userEventModule;
@@ -417,9 +462,10 @@ export async function runRenderCheckInProcess(
417
462
  // Use object container so TS narrowing follows property access (not
418
463
  // closure flow analysis, which can't see the componentDidCatch
419
464
  // mutation and would narrow these to `null` at the use site).
420
- const boundaryRef: { stack: string | null; error: Error | null } = {
465
+ const boundaryRef: { stack: string | null; error: Error | null; catches: number } = {
421
466
  stack: null,
422
467
  error: null,
468
+ catches: 0,
423
469
  };
424
470
  class ProbeErrorBoundary extends React.Component<
425
471
  { children: React.ReactNode },
@@ -430,6 +476,9 @@ export async function runRenderCheckInProcess(
430
476
  return { caught: true };
431
477
  }
432
478
  componentDidCatch(error: Error, errorInfo: { componentStack?: string }): void {
479
+ // ggui#1398: every catch is counted, so the pending phase can tell a
480
+ // crash while pending from a control that simply went away.
481
+ boundaryRef.catches += 1;
433
482
  if (errorInfo.componentStack && !boundaryRef.stack) {
434
483
  boundaryRef.stack = errorInfo.componentStack;
435
484
  }
@@ -443,6 +492,20 @@ export async function runRenderCheckInProcess(
443
492
  }
444
493
  }
445
494
 
495
+ // The card as the runtime mounts it: inside the wire, the probe's error
496
+ // boundary and the pending context. The main render and every action
497
+ // walk's mount (ggui#1652) are this same tree.
498
+ const cardTree = (): React.ReactElement =>
499
+ React.createElement(GguiWireProvider, {
500
+ config: wireConfig,
501
+ children: React.createElement(ProbeErrorBoundary, {
502
+ children: React.createElement(ActionPendingContext.Provider, {
503
+ value: pendingSource,
504
+ children: React.createElement(Component, input.mockupProps),
505
+ }),
506
+ }),
507
+ });
508
+
446
509
  let renderResult: Awaited<ReturnType<typeof render>>;
447
510
  const RENDER_TIMEOUT_MS = 5000;
448
511
  try {
@@ -450,16 +513,7 @@ export async function runRenderCheckInProcess(
450
513
  // Synchronous hangs would block the event loop and the timer never
451
514
  // fires, but happy-dom effects are mostly microtask-scheduled so the
452
515
  // timeout is actually effective for ~95% of the hang cases.
453
- const renderPromise = Promise.resolve().then(() =>
454
- render(
455
- React.createElement(GguiWireProvider, {
456
- config: wireConfig,
457
- children: React.createElement(ProbeErrorBoundary, {
458
- children: React.createElement(Component, input.mockupProps),
459
- }),
460
- })
461
- )
462
- );
516
+ const renderPromise = Promise.resolve().then(() => render(cardTree()));
463
517
  const timeoutPromise = new Promise<never>((_, reject) => {
464
518
  setTimeout(
465
519
  () =>
@@ -559,16 +613,25 @@ export async function runRenderCheckInProcess(
559
613
 
560
614
  let actionsChecked = 0;
561
615
  let streamsChecked = 0;
616
+ // ggui#1398 leg 3 — the pending-affordance tally, report-only. Set only
617
+ // when the action walk runs, so an absent field means "no walk".
618
+ let pendingTally: PendingTally | undefined;
619
+ // The controls whose click really dispatched their action, looked at in
620
+ // the pending phase after every other check (see runPendingPhase).
621
+ const pendingTargets: PendingTarget[] = [];
622
+ // The text of every screen the action walks reached (ggui#1652): a prop
623
+ // shown on a later step is shown, though the action check never moves
624
+ // the main render off its first screen.
625
+ const walkedTexts: string[] = [];
562
626
 
563
627
  try {
564
628
  // ggui#1187: prime form inputs before the action-wiring probe. A wired
565
629
  // Send/Submit gated on a non-empty input (the chat-interface shape) is a
566
630
  // no-op on empty, so an un-primed synthetic click never dispatches and
567
631
  // the probe false-warns "did not dispatch it". Shared with the visual
568
- // tester's primeInputs (#1021/#1040) so both prime identically. Priming
569
- // sets input VALUES (not textContent) and fires input/change before the
570
- // per-candidate fire-log window is opened, so it neither perturbs the
571
- // prop-sensitivity text baseline nor false-positives the wiring probe.
632
+ // tester's primeInputs (#1021/#1040) so both prime identically. The
633
+ // main render is primed here, on every card, before any check reads it;
634
+ // each action walk's mount (ggui#1652) primes every screen it reaches.
572
635
  // A priming failure never aborts the check — it is RECORDED on every
573
636
  // action-wiring issue as `diagnostics.inputPriming`. (The first cut
574
637
  // swallowed it, and a `ReferenceError` in the check worker's realm —
@@ -581,6 +644,10 @@ export async function runRenderCheckInProcess(
581
644
  inputPriming = { error: err instanceof Error ? err.message : String(err) };
582
645
  }
583
646
  // ── Check 2: Action wiring (BLOCK / unverified-as-warn) ───────────
647
+ // ggui#1652: each action is looked for in a mount of its own, walked
648
+ // screen by screen (walk.ts), so this check never presses the main
649
+ // render the later checks read. (The duplicate-label check below, #601,
650
+ // still makes its two clicks there, as it always did.)
584
651
  if (input.contract?.actionSpec) {
585
652
  const actionSpec = input.contract.actionSpec;
586
653
  for (const [name, entry] of Object.entries(actionSpec)) {
@@ -590,15 +657,47 @@ export async function runRenderCheckInProcess(
590
657
  hookName: "useAction",
591
658
  hookArg: name,
592
659
  });
660
+ // A priming failure on a screen the walk reached is recorded too,
661
+ // when the main render's priming did not already fail.
662
+ const walkPriming: { error?: string } = {};
663
+ const mount = async (): Promise<WalkMount | undefined> => {
664
+ let mounted: ReturnType<typeof render>;
665
+ try {
666
+ mounted = render(cardTree());
667
+ } catch {
668
+ return undefined;
669
+ }
670
+ await flushPromises();
671
+ const root = mounted.container;
672
+ return {
673
+ container: toMinimalElement(root),
674
+ prime: () => {
675
+ try {
676
+ primeInputs(root);
677
+ } catch (err) {
678
+ walkPriming.error ??= err instanceof Error ? err.message : String(err);
679
+ }
680
+ },
681
+ };
682
+ };
593
683
  const issue = await checkActionWiring({
594
- container,
684
+ mount,
595
685
  actionName: name,
596
686
  actionLabel: entry.label,
597
687
  wiring,
598
688
  probe,
599
689
  user,
690
+ pendingTargets,
691
+ stopAt: walkStopAt(input.deadlineAt),
692
+ walkedTexts,
600
693
  });
601
- if (issue) issues.push({ ...issue, diagnostics: { ...issue.diagnostics, inputPriming } });
694
+ if (issue) {
695
+ const priming: InputPrimingDiagnostic =
696
+ "primed" in inputPriming && walkPriming.error !== undefined
697
+ ? { error: `on a screen the action walk reached: ${walkPriming.error}` }
698
+ : inputPriming;
699
+ issues.push({ ...issue, diagnostics: { ...issue.diagnostics, inputPriming: priming } });
700
+ }
602
701
  }
603
702
  }
604
703
 
@@ -650,6 +749,7 @@ export async function runRenderCheckInProcess(
650
749
  if (input.contract?.propsSpec) {
651
750
  for (const issue of checkPropCoverage({
652
751
  container,
752
+ walkedTexts,
653
753
  propsSpec: input.contract.propsSpec as PropsSpec,
654
754
  mockupProps: input.mockupProps,
655
755
  })) {
@@ -801,11 +901,27 @@ export async function runRenderCheckInProcess(
801
901
  }
802
902
  }
803
903
  }
904
+
905
+ // ── Pending phase (ggui#1398, report-only) ────────────────────────
906
+ // LAST, while the main render is still mounted: every check above has
907
+ // already read the DOM, so a card that crashes or resets while pending
908
+ // cannot change what they found.
909
+ const timeLeft = input.deadlineAt === undefined ? Number.POSITIVE_INFINITY : input.deadlineAt - Date.now();
910
+ if (input.contract?.actionSpec && timeLeft >= PENDING_PHASE_RESERVE_MS) {
911
+ pendingTally = await runPendingPhase({
912
+ targets: pendingTargets,
913
+ source: pendingSource,
914
+ act,
915
+ catches: () => boundaryRef.catches,
916
+ actions: Object.entries(input.contract.actionSpec).map(([name, entry]) => ({ name, label: entry.label })),
917
+ ...(input.deadlineAt !== undefined ? { deadlineAt: input.deadlineAt } : {}),
918
+ });
919
+ }
804
920
  } finally {
805
921
  cleanup();
806
922
  }
807
923
 
808
- return finalize(issues, t0, actionsChecked, streamsChecked);
924
+ return finalize(issues, t0, actionsChecked, streamsChecked, pendingTally);
809
925
  } finally {
810
926
  try {
811
927
  uninstallSpy?.();
@@ -1169,7 +1285,8 @@ function finalize(
1169
1285
  issues: RenderCheckIssue[],
1170
1286
  t0: number,
1171
1287
  actionsChecked: number,
1172
- streamsChecked: number
1288
+ streamsChecked: number,
1289
+ pendingTally?: PendingTally
1173
1290
  ): RenderCheckResult {
1174
1291
  const ok = !issues.some((i) => i.outcome === "failed");
1175
1292
  return {
@@ -1179,25 +1296,191 @@ function finalize(
1179
1296
  actionsChecked,
1180
1297
  streamsChecked,
1181
1298
  renderMs: Date.now() - t0,
1299
+ ...(pendingTally !== undefined
1300
+ ? {
1301
+ pendingAffordance: {
1302
+ dispatched: pendingTally.dispatched,
1303
+ visible: pendingTally.visible,
1304
+ missing: [...pendingTally.missing],
1305
+ gone: [...pendingTally.gone],
1306
+ },
1307
+ }
1308
+ : {}),
1182
1309
  },
1183
1310
  };
1184
1311
  }
1185
1312
 
1313
+ /** The walk's running tally (ggui#1398); frozen into `stats.pendingAffordance` by {@link finalize}. */
1314
+ interface PendingTally {
1315
+ dispatched: number;
1316
+ visible: number;
1317
+ missing: string[];
1318
+ gone: string[];
1319
+ }
1320
+
1321
+ /** A control whose click really dispatched its action (ggui#1398). */
1322
+ interface PendingTarget {
1323
+ readonly actionName: string;
1324
+ readonly actionLabel: string;
1325
+ readonly wiringKind: "click" | "submit";
1326
+ readonly element: MinimalElement;
1327
+ /** The mount the action's walk pressed it in (ggui#1652), where it is looked for again. */
1328
+ readonly container: MinimalElement;
1329
+ }
1330
+
1331
+ interface PendingPhaseInput {
1332
+ readonly targets: readonly PendingTarget[];
1333
+ readonly source: ProbePendingSource;
1334
+ /** React Testing Library's `act`, so the pending re-render commits before the control is read. */
1335
+ readonly act: (callback: () => Promise<void>) => Promise<unknown>;
1336
+ /** How many throws the probe's error boundary has caught so far. */
1337
+ readonly catches: () => number;
1338
+ /** Every action the contract declares: a control that now names another one is no longer this action's control. */
1339
+ readonly actions: readonly { readonly name: string; readonly label: string }[];
1340
+ /** The check's deadline (epoch ms), when the isolating host set one. */
1341
+ readonly deadlineAt?: number;
1342
+ }
1343
+
1344
+ /** The longest the phase waits on one control's pending mark and read (ggui#1398). */
1345
+ export const PENDING_TARGET_CAP_MS = 2000;
1346
+ /** The time the phase always leaves before the check's deadline. */
1347
+ const PENDING_PHASE_TAIL_MS = 1000;
1348
+
1349
+ /**
1350
+ * For each control whose click dispatched: mark its action pending (the
1351
+ * runtime does this when the dispatch commits), compare the control with
1352
+ * its look just before, then clear it (the agent's answer). Visible means
1353
+ * the pending state itself changed the control.
1354
+ *
1355
+ * - The pressed element stays the action's control while it is in the page,
1356
+ * whatever it now says ("Add to Cart" may relabel itself "Added"), unless
1357
+ * it now names another of the contract's actions. Only a control that left
1358
+ * the page is looked for again, by its own action identity (label,
1359
+ * aria-label, `data-action`); if none is found it is `gone`.
1360
+ * - A form target (submit wiring) is read through its control that names the
1361
+ * action, else its first submit-type control, else the form itself.
1362
+ * - A card that throws while pending counts that control as missing, and
1363
+ * every control after it is gone (the card renders nothing).
1364
+ * - Each mark-and-read is capped ({@link PENDING_TARGET_CAP_MS}, and never
1365
+ * past the check's deadline less a tail). A control whose read does not
1366
+ * settle in time counts as missing and ends the phase, so a stalled card
1367
+ * cannot push the check past its bound. A synchronous busy loop cannot be
1368
+ * cut short this way; React turns runaway update loops into throws.
1369
+ */
1370
+ async function runPendingPhase(input: PendingPhaseInput): Promise<PendingTally> {
1371
+ const tally: PendingTally = { dispatched: 0, visible: 0, missing: [], gone: [] };
1372
+ let stopped = false;
1373
+ for (const target of input.targets) {
1374
+ const budget = Math.min(
1375
+ PENDING_TARGET_CAP_MS,
1376
+ input.deadlineAt === undefined ? Number.POSITIVE_INFINITY : input.deadlineAt - Date.now() - PENDING_PHASE_TAIL_MS
1377
+ );
1378
+ const found = stopped || budget <= 0 ? undefined : pendingControlOf(target, input.actions);
1379
+ if (found === undefined) {
1380
+ tally.gone.push(target.actionName);
1381
+ if (budget <= 0) stopped = true;
1382
+ continue;
1383
+ }
1384
+ const el = controlToRead(found, target);
1385
+ const before = lookOf(el);
1386
+ const caughtBefore = input.catches();
1387
+ const settled = await settlesWithin(
1388
+ budget,
1389
+ (async () => {
1390
+ await input.act(async () => {
1391
+ input.source.mark(target.actionName);
1392
+ });
1393
+ await flushPromises();
1394
+ })()
1395
+ );
1396
+ const crashed = input.catches() !== caughtBefore;
1397
+ tally.dispatched += 1;
1398
+ if (settled && !crashed && lookChanged(before, lookOf(el))) tally.visible += 1;
1399
+ else tally.missing.push(target.actionName);
1400
+ if (!settled) {
1401
+ stopped = true;
1402
+ continue;
1403
+ }
1404
+ await input.act(async () => {
1405
+ input.source.clear();
1406
+ });
1407
+ await flushPromises();
1408
+ if (crashed) stopped = true;
1409
+ }
1410
+ return tally;
1411
+ }
1412
+
1413
+ /**
1414
+ * The action's control at the phase's turn: the pressed element while it is
1415
+ * in the page and does not now name another of the contract's actions;
1416
+ * otherwise, for click wiring, the action's control found again by its own
1417
+ * identity only. A submit target (a form) has no identity to look for.
1418
+ */
1419
+ function pendingControlOf(
1420
+ target: PendingTarget,
1421
+ actions: readonly { readonly name: string; readonly label: string }[]
1422
+ ): MinimalElement | undefined {
1423
+ const movedToAnother =
1424
+ !matchesActionIdentity(target.element, target.actionName, target.actionLabel) &&
1425
+ actions.some((a) => a.name !== target.actionName && matchesActionIdentity(target.element, a.name, a.label));
1426
+ if (lookOf(target.element).connected && !movedToAnother) return target.element;
1427
+ if (target.wiringKind !== "click") return undefined;
1428
+ return findActionElements(target.container, target.actionName, target.actionLabel, { identityOnly: true })[0];
1429
+ }
1430
+
1431
+ /**
1432
+ * The control a person sees for a target. A form is read through its
1433
+ * clickable that names the action (generated forms often submit through a
1434
+ * `type="button"` Button), else its first submit-type control, else itself.
1435
+ */
1436
+ function controlToRead(el: MinimalElement, target: PendingTarget): MinimalElement {
1437
+ if (el.tagName.toLowerCase() !== "form") return el;
1438
+ for (const c of el.querySelectorAll<MinimalElement>('button, input[type="submit"], input[type="button"], [role="button"]')) {
1439
+ if (matchesActionIdentity(c, target.actionName, target.actionLabel)) return c;
1440
+ }
1441
+ for (const submitter of el.querySelectorAll<MinimalElement>(
1442
+ 'button[type="submit"], input[type="submit"], button:not([type])'
1443
+ )) {
1444
+ return submitter;
1445
+ }
1446
+ return el;
1447
+ }
1448
+
1186
1449
  // ─────────────────────────────────────────────────────────────────────────────
1187
1450
  // Check implementations
1188
1451
  // ─────────────────────────────────────────────────────────────────────────────
1189
1452
 
1190
1453
  interface CheckActionInput {
1191
- container: MinimalElement;
1454
+ /** Mount a fresh copy of the card for this action's walk (ggui#1652). */
1455
+ mount: () => Promise<WalkMount | undefined>;
1192
1456
  actionName: string;
1193
1457
  actionLabel: string;
1194
1458
  wiring: WiringDetection;
1195
1459
  probe: Probe;
1196
1460
  user: { click: (el: MinimalElement) => Promise<void> };
1461
+ /** ggui#1398: where a control whose press dispatched is recorded, for the pending phase. */
1462
+ pendingTargets?: PendingTarget[];
1463
+ /** Epoch ms after which the walk stops, once a press has left the first screen. */
1464
+ stopAt: number;
1465
+ /** Where the text of every screen the walk reaches is collected, for prop coverage (ggui#1652). */
1466
+ walkedTexts: string[];
1467
+ }
1468
+
1469
+ /** When the walk stops, once a press has left the first screen (ggui#1652). */
1470
+ function walkStopAt(deadlineAt: number | undefined): number {
1471
+ const cap = Date.now() + WALK_ACTION_CAP_MS;
1472
+ return deadlineAt === undefined ? cap : Math.min(cap, deadlineAt - PENDING_PHASE_RESERVE_MS - WALK_DEADLINE_MARGIN_MS);
1473
+ }
1474
+
1475
+ type WiredKind = "click" | "submit" | "change" | "keyboard-enter";
1476
+
1477
+ /** A control the walk may press, and the trigger it is pressed with. */
1478
+ interface ActionControl extends WalkControl {
1479
+ readonly how: WiredKind;
1197
1480
  }
1198
1481
 
1199
1482
  async function checkActionWiring(input: CheckActionInput): Promise<RenderCheckIssue | null> {
1200
- const { container, actionName, actionLabel, wiring, probe, user } = input;
1483
+ const { actionName, actionLabel, wiring, probe, user } = input;
1201
1484
 
1202
1485
  const baseDiagnostics = {
1203
1486
  observedJsxElements: wiring.observedJsxElements,
@@ -1231,32 +1514,73 @@ async function checkActionWiring(input: CheckActionInput): Promise<RenderCheckIs
1231
1514
  };
1232
1515
  }
1233
1516
 
1234
- // Source-detected deterministic wiring → simulate the matching trigger.
1235
- const fired = await simulateAndCheck({
1236
- container,
1237
- user,
1238
- probe,
1239
- wiringKind: wiring.kind,
1240
- actionName,
1241
- actionLabel,
1242
- eventKind: "action.fired",
1517
+ // Source-detected deterministic wiring → walk the card's screens, pressing
1518
+ // the matching trigger, until one press dispatches the action (ggui#1652).
1519
+ const kind = wiring.kind;
1520
+ const walk = await walkToAction<ActionControl>({
1521
+ mount: input.mount,
1522
+ controls: (container) => actionControls(container, kind, actionName, actionLabel),
1523
+ press: async (control) => {
1524
+ const before = probe.getFireLog().length;
1525
+ try {
1526
+ await dispatchTrigger(control.element, control.how, user);
1527
+ } catch {
1528
+ return false;
1529
+ }
1530
+ await flushPromises();
1531
+ return probe
1532
+ .getFireLog()
1533
+ .slice(before)
1534
+ .some((e) => e.kind === "action.fired" && e.name === actionName);
1535
+ },
1536
+ describe: describeElement,
1537
+ stopAt: input.stopAt,
1538
+ maxPresses: WALK_MAX_PRESSES,
1243
1539
  });
1540
+ input.walkedTexts.push(...walk.screenTexts);
1541
+
1542
+ if (walk.fired) {
1543
+ // ggui#1398: record the control for the pending phase; nothing is marked here.
1544
+ if (input.pendingTargets && walk.element && walk.container && (kind === "click" || kind === "submit")) {
1545
+ input.pendingTargets.push({
1546
+ actionName,
1547
+ actionLabel,
1548
+ wiringKind: kind,
1549
+ element: walk.element,
1550
+ container: walk.container,
1551
+ });
1552
+ }
1553
+ return null; // verified — no issue
1554
+ }
1244
1555
 
1245
- if (fired.fired) return null; // verified — no issue
1556
+ // Capture what DID fire — useful diagnostic.
1557
+ const allFired = probe
1558
+ .getFireLog()
1559
+ .filter((e) => e.kind === "action.fired")
1560
+ .map((e) => e.name);
1561
+ const otherActionsFired = Array.from(new Set(allFired)).filter((n) => n !== actionName);
1562
+ const stoppedBy = walk.stoppedBy === "dispatched" ? "explored" : walk.stoppedBy;
1246
1563
 
1247
- // The AST already confirmed the wiring exists in source. The
1248
- // simulator couldn't trigger it — most likely a conditional-render or
1564
+ // The AST already confirmed the wiring exists in source. The walk
1565
+ // couldn't trigger it — most likely a conditional-render or
1249
1566
  // required-form-fill case. Be conservative: downgrade to unverified
1250
1567
  // rather than reporting a fake failure.
1251
1568
  return {
1252
1569
  check: "action-wiring",
1253
1570
  outcome: "unverified",
1254
1571
  subject: actionName,
1255
- reason: `Source confirms ${wiring.kind} wiring exists for action '${actionName}', but synthetic ${wiring.kind} did not dispatch it. Likely cause: conditional rendering, required input/form fill, or a multi-step interaction the static probe cannot complete.`,
1256
- elementHint: fired.attemptedHint,
1572
+ reason: `Source confirms ${kind} wiring exists for action '${actionName}', but synthetic ${kind} did not dispatch it. Likely cause: conditional rendering, required input/form fill, or a multi-step interaction the static probe cannot complete.`,
1573
+ ...(walk.presses === 0 && stoppedBy === "explored" ? { elementHint: `No ${kind}-eligible element found in DOM` } : {}),
1257
1574
  diagnostics: {
1258
1575
  ...baseDiagnostics,
1259
- actionsFiredFromClicks: fired.otherActionsFired,
1576
+ actionsFiredFromClicks: otherActionsFired,
1577
+ walk: {
1578
+ presses: walk.presses,
1579
+ screens: walk.screens,
1580
+ stoppedBy,
1581
+ namedControlSeen: walk.namedControlSeen,
1582
+ lastPressed: walk.lastPressed,
1583
+ },
1260
1584
  },
1261
1585
  };
1262
1586
  }
@@ -1382,60 +1706,9 @@ async function checkSelectionIdentity(
1382
1706
  // Trigger simulators — narrow set, deterministic only
1383
1707
  // ─────────────────────────────────────────────────────────────────────────────
1384
1708
 
1385
- interface SimulateAndCheckInput {
1386
- container: MinimalElement;
1387
- user: { click: (el: MinimalElement) => Promise<void> };
1388
- probe: Probe;
1389
- wiringKind: "click" | "submit" | "change" | "keyboard-enter";
1390
- actionName: string;
1391
- actionLabel: string;
1392
- eventKind: "action.fired" | "wiredTool.called";
1393
- }
1394
-
1395
- interface SimulateResult {
1396
- fired: boolean;
1397
- attemptedHint?: string;
1398
- otherActionsFired?: string[];
1399
- }
1400
-
1401
- async function simulateAndCheck(input: SimulateAndCheckInput): Promise<SimulateResult> {
1402
- const { container, user, probe, wiringKind, actionName, actionLabel, eventKind } = input;
1403
-
1404
- const candidates = findCandidateElements(container, wiringKind, actionName, actionLabel);
1405
- if (candidates.length === 0) {
1406
- return { fired: false, attemptedHint: `No ${wiringKind}-eligible element found in DOM` };
1407
- }
1408
-
1409
- for (const el of candidates) {
1410
- const before = probe.getFireLog().length;
1411
- try {
1412
- await dispatchTrigger(el, wiringKind, user);
1413
- } catch {
1414
- continue;
1415
- }
1416
- await flushPromises();
1417
- const newEvents = probe.getFireLog().slice(before);
1418
- const matched = newEvents.some((e) => e.kind === eventKind && e.name === actionName);
1419
- if (matched) return { fired: true };
1420
- }
1421
-
1422
- // Capture what DID fire — useful diagnostic.
1423
- const allFired = probe
1424
- .getFireLog()
1425
- .filter((e) => e.kind === "action.fired")
1426
- .map((e) => e.name);
1427
- const otherActionsFired = Array.from(new Set(allFired)).filter((n) => n !== actionName);
1428
-
1429
- return {
1430
- fired: false,
1431
- attemptedHint: candidates.length ? describeElement(candidates[0]!) : undefined,
1432
- otherActionsFired,
1433
- };
1434
- }
1435
-
1436
1709
  async function dispatchTrigger(
1437
1710
  el: MinimalElement,
1438
- kind: "click" | "submit" | "change" | "keyboard-enter",
1711
+ kind: WiredKind,
1439
1712
  user: { click: (el: MinimalElement) => Promise<void> }
1440
1713
  ): Promise<void> {
1441
1714
  switch (kind) {
@@ -1560,41 +1833,44 @@ function closestForm(el: MinimalElement): MinimalElement | null {
1560
1833
  return null;
1561
1834
  }
1562
1835
 
1563
- function findCandidateElements(
1564
- container: MinimalElement,
1565
- kind: "click" | "submit" | "change" | "keyboard-enter",
1566
- name: string,
1567
- label: string
1568
- ): MinimalElement[] {
1569
- switch (kind) {
1570
- case "click":
1571
- return findActionElements(container, name, label);
1572
- case "submit": {
1573
- // submit-eligible: <form>, button[type=submit], input[type=submit]
1574
- const all = container.querySelectorAll<MinimalElement>(
1575
- 'form, button[type="submit"], input[type="submit"]'
1576
- );
1577
- return Array.from(all);
1578
- }
1579
- case "change": {
1580
- const all = container.querySelectorAll<MinimalElement>("select, input, textarea");
1581
- return Array.from(all);
1582
- }
1583
- case "keyboard-enter":
1584
- return findActionElements(container, name, label);
1836
+ /**
1837
+ * The controls an action's walk may press on the current screen, in the
1838
+ * order they are tried (ggui#1652). Click and Enter wiring: the controls
1839
+ * that name the action, then every other clickable, each pressed the way the
1840
+ * action is wired. Submit and change wiring: the eligible elements, pressed
1841
+ * with that trigger, then every clickable, pressed with a click, so the walk
1842
+ * can reach a screen where the eligible element appears.
1843
+ */
1844
+ function actionControls(container: MinimalElement, kind: WiredKind, name: string, label: string): ActionControl[] {
1845
+ const clickables = findActionElements(container, name, label);
1846
+ const named = (el: MinimalElement): boolean => matchesActionIdentity(el, name, label);
1847
+ if (kind === "click" || kind === "keyboard-enter") {
1848
+ return clickables.map((element) => ({ element, namesAction: named(element), how: kind }));
1585
1849
  }
1850
+ const eligible = Array.from(
1851
+ container.querySelectorAll<MinimalElement>(
1852
+ kind === "submit" ? 'form, button[type="submit"], input[type="submit"]' : "select, input, textarea"
1853
+ )
1854
+ );
1855
+ const seen = new Set(eligible);
1856
+ return [
1857
+ ...eligible.map((element) => ({ element, namesAction: named(element), how: kind })),
1858
+ ...clickables.filter((el) => !seen.has(el)).map((element) => ({ element, namesAction: named(element), how: "click" as const })),
1859
+ ];
1586
1860
  }
1587
1861
 
1588
1862
  interface CheckPropCoverageInput {
1589
1863
  container: MinimalElement;
1864
+ /** The text of the screens the action walks reached (ggui#1652). */
1865
+ walkedTexts: readonly string[];
1590
1866
  propsSpec: PropsSpec;
1591
1867
  mockupProps: JsonObject;
1592
1868
  }
1593
1869
 
1594
1870
  function checkPropCoverage(input: CheckPropCoverageInput): RenderCheckIssue[] {
1595
- const { container, propsSpec, mockupProps } = input;
1871
+ const { container, walkedTexts, propsSpec, mockupProps } = input;
1596
1872
  const issues: RenderCheckIssue[] = [];
1597
- const text = container.textContent ?? "";
1873
+ const texts = [container.textContent ?? "", ...walkedTexts];
1598
1874
 
1599
1875
  for (const [propName, entry] of Object.entries(propsSpec.properties)) {
1600
1876
  if (!entry.required) continue;
@@ -1605,12 +1881,15 @@ function checkPropCoverage(input: CheckPropCoverageInput): RenderCheckIssue[] {
1605
1881
  const marker = pickScalarMarker(value);
1606
1882
  if (marker === null) continue;
1607
1883
 
1608
- if (!text.includes(marker)) {
1884
+ if (!texts.some((text) => text.includes(marker))) {
1885
+ // Name the walked screens only where a walk ran (a card with actions);
1886
+ // a card without one reads exactly as it did before the walk.
1887
+ const where = walkedTexts.length > 0 ? ", on the first screen or any screen the action walk reached" : "";
1609
1888
  issues.push({
1610
1889
  check: "prop-coverage",
1611
1890
  outcome: "unverified",
1612
1891
  subject: propName,
1613
- reason: `Required prop '${propName}' value (${JSON.stringify(marker).slice(0, 60)}) not visible in rendered DOM`,
1892
+ reason: `Required prop '${propName}' value (${JSON.stringify(marker).slice(0, 60)}) not visible in rendered DOM${where}`,
1614
1893
  });
1615
1894
  }
1616
1895
  }
@@ -1676,35 +1955,38 @@ async function checkStreamRerender(
1676
1955
  // Helpers
1677
1956
  // ─────────────────────────────────────────────────────────────────────────────
1678
1957
 
1958
+ /** Whether `el` names the action itself: its text, aria-label or `data-action` carries the action's name or label. */
1959
+ function matchesActionIdentity(el: MinimalElement, name: string, label: string): boolean {
1960
+ const norm = (s: string) => s.toLowerCase().replace(/[\s_-]+/g, "");
1961
+ const aria = (el.getAttribute("aria-label") ?? "").toLowerCase();
1962
+ const txt = (el.textContent ?? "").toLowerCase();
1963
+ const dataAction = (el.getAttribute("data-action") ?? "").toLowerCase();
1964
+ return (
1965
+ aria.includes(label.toLowerCase()) ||
1966
+ txt.includes(label.toLowerCase()) ||
1967
+ norm(aria).includes(norm(name)) ||
1968
+ norm(txt).includes(norm(name)) ||
1969
+ dataAction === name.toLowerCase() ||
1970
+ norm(txt).includes(norm(label))
1971
+ );
1972
+ }
1973
+
1679
1974
  function findActionElements(
1680
1975
  container: MinimalElement,
1681
1976
  name: string,
1682
- label: string
1977
+ label: string,
1978
+ options: { readonly identityOnly?: boolean } = {}
1683
1979
  ): MinimalElement[] {
1684
1980
  const candidates: MinimalElement[] = [];
1685
1981
  const seen = new Set<MinimalElement>();
1686
1982
 
1687
- const norm = (s: string) => s.toLowerCase().replace(/[\s_-]+/g, "");
1688
- const nameKey = norm(name);
1689
- const labelKey = norm(label);
1690
-
1691
1983
  const allClickable = container.querySelectorAll<MinimalElement>(
1692
1984
  'button, [role="button"], input[type="submit"], input[type="button"], a[href]'
1693
1985
  );
1694
1986
 
1695
1987
  // Pass 1: text/aria-label/data-action match
1696
1988
  for (const el of allClickable) {
1697
- const aria = (el.getAttribute("aria-label") ?? "").toLowerCase();
1698
- const txt = (el.textContent ?? "").toLowerCase();
1699
- const dataAction = (el.getAttribute("data-action") ?? "").toLowerCase();
1700
- if (
1701
- aria.includes(label.toLowerCase()) ||
1702
- txt.includes(label.toLowerCase()) ||
1703
- norm(aria).includes(nameKey) ||
1704
- norm(txt).includes(nameKey) ||
1705
- dataAction === name.toLowerCase() ||
1706
- norm(txt).includes(labelKey)
1707
- ) {
1989
+ if (matchesActionIdentity(el, name, label)) {
1708
1990
  if (!seen.has(el)) {
1709
1991
  seen.add(el);
1710
1992
  candidates.push(el);
@@ -1712,7 +1994,9 @@ function findActionElements(
1712
1994
  }
1713
1995
  }
1714
1996
 
1715
- // Pass 2: any clickable as fallback (we'll click them all)
1997
+ // Pass 2: any clickable as fallback (we'll click them all). Skipped when
1998
+ // only the action's own control may answer (the pending phase's re-find).
1999
+ if (options.identityOnly) return candidates;
1716
2000
  for (const el of allClickable) {
1717
2001
  if (!seen.has(el)) {
1718
2002
  seen.add(el);