@qontinui/ui-bridge-native 0.1.6 → 0.2.1

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 (98) hide show
  1. package/dist/control/{action-executor.d.ts → index.d.mts} +14 -6
  2. package/dist/control/index.d.ts +95 -5
  3. package/dist/control/index.js +74 -11
  4. package/dist/control/index.js.map +1 -1
  5. package/dist/control/index.mjs +74 -11
  6. package/dist/control/index.mjs.map +1 -1
  7. package/dist/core/index.d.mts +46 -0
  8. package/dist/core/index.d.ts +43 -6
  9. package/dist/core/index.js +108 -2
  10. package/dist/core/index.js.map +1 -1
  11. package/dist/core/index.mjs +108 -2
  12. package/dist/core/index.mjs.map +1 -1
  13. package/dist/debug/{inspector.d.ts → index.d.mts} +6 -3
  14. package/dist/debug/index.d.ts +44 -4
  15. package/dist/debug/index.js +2 -2
  16. package/dist/debug/index.js.map +1 -1
  17. package/dist/debug/index.mjs +2 -2
  18. package/dist/debug/index.mjs.map +1 -1
  19. package/dist/design/{design-inspector-native.d.ts → index.d.mts} +11 -7
  20. package/dist/design/index.d.ts +60 -5
  21. package/dist/{design/design-types.d.ts → design-types-BHr6Q1FX.d.mts} +11 -10
  22. package/dist/design-types-BHr6Q1FX.d.ts +205 -0
  23. package/dist/http-server-D2U1k48d.d.mts +703 -0
  24. package/dist/http-server-ck8clUsZ.d.ts +703 -0
  25. package/dist/index.d.mts +89 -0
  26. package/dist/index.d.ts +86 -28
  27. package/dist/index.js +754 -50
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +749 -51
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/react/{useAutoRegister.d.ts → index.d.mts} +15 -7
  32. package/dist/react/index.d.ts +69 -8
  33. package/dist/react/index.js +733 -50
  34. package/dist/react/index.js.map +1 -1
  35. package/dist/react/index.mjs +731 -51
  36. package/dist/react/index.mjs.map +1 -1
  37. package/dist/{core/registry.d.ts → registry-BxXaxmsT.d.ts} +51 -10
  38. package/dist/registry-KdUW_koV.d.mts +253 -0
  39. package/dist/server/index.d.mts +119 -0
  40. package/dist/server/index.d.ts +117 -13
  41. package/dist/server/index.js +272 -16
  42. package/dist/server/index.js.map +1 -1
  43. package/dist/server/index.mjs +272 -16
  44. package/dist/server/index.mjs.map +1 -1
  45. package/dist/{core/types.d.ts → types-BwdPNpII.d.mts} +172 -30
  46. package/dist/types-BwdPNpII.d.ts +525 -0
  47. package/dist/{control/types.d.ts → types-DCEv5qb6.d.ts} +36 -15
  48. package/dist/types-DjB0zJJH.d.mts +197 -0
  49. package/dist/useUIBridgeToast-BPIEbsEc.d.mts +726 -0
  50. package/dist/useUIBridgeToast-DD13oF3k.d.ts +726 -0
  51. package/package.json +2 -2
  52. package/dist/control/action-executor.d.ts.map +0 -1
  53. package/dist/control/index.d.ts.map +0 -1
  54. package/dist/control/types.d.ts.map +0 -1
  55. package/dist/core/element-identifier.d.ts +0 -41
  56. package/dist/core/element-identifier.d.ts.map +0 -1
  57. package/dist/core/index.d.ts.map +0 -1
  58. package/dist/core/registry.d.ts.map +0 -1
  59. package/dist/core/types.d.ts.map +0 -1
  60. package/dist/debug/index.d.ts.map +0 -1
  61. package/dist/debug/inspector.d.ts.map +0 -1
  62. package/dist/design/design-inspector-native.d.ts.map +0 -1
  63. package/dist/design/design-types.d.ts.map +0 -1
  64. package/dist/design/index.d.ts.map +0 -1
  65. package/dist/index.d.ts.map +0 -1
  66. package/dist/react/UIBridgeNativeProvider.d.ts +0 -147
  67. package/dist/react/UIBridgeNativeProvider.d.ts.map +0 -1
  68. package/dist/react/index.d.ts.map +0 -1
  69. package/dist/react/useAutoRegister.d.ts.map +0 -1
  70. package/dist/react/useUIBridge.d.ts +0 -103
  71. package/dist/react/useUIBridge.d.ts.map +0 -1
  72. package/dist/react/useUIComponent.d.ts +0 -112
  73. package/dist/react/useUIComponent.d.ts.map +0 -1
  74. package/dist/react/useUIElement.d.ts +0 -162
  75. package/dist/react/useUIElement.d.ts.map +0 -1
  76. package/dist/server/design-handlers.d.ts +0 -47
  77. package/dist/server/design-handlers.d.ts.map +0 -1
  78. package/dist/server/handlers.d.ts +0 -20
  79. package/dist/server/handlers.d.ts.map +0 -1
  80. package/dist/server/http-server.d.ts +0 -247
  81. package/dist/server/http-server.d.ts.map +0 -1
  82. package/dist/server/index.d.ts.map +0 -1
  83. package/dist/server/types.d.ts +0 -200
  84. package/dist/server/types.d.ts.map +0 -1
  85. package/dist/server/ws-connection.d.ts +0 -48
  86. package/dist/server/ws-connection.d.ts.map +0 -1
  87. package/dist/server/ws-event-bridge.d.ts +0 -113
  88. package/dist/server/ws-event-bridge.d.ts.map +0 -1
  89. package/dist/server/ws-protocol.d.ts +0 -48
  90. package/dist/server/ws-protocol.d.ts.map +0 -1
  91. package/dist/server/ws-types.d.ts +0 -65
  92. package/dist/server/ws-types.d.ts.map +0 -1
  93. package/dist/transport/CloudRelayClient.d.ts +0 -51
  94. package/dist/transport/CloudRelayClient.d.ts.map +0 -1
  95. package/dist/transport/DeviceAnnouncer.d.ts +0 -75
  96. package/dist/transport/DeviceAnnouncer.d.ts.map +0 -1
  97. package/dist/transport/index.d.ts +0 -3
  98. package/dist/transport/index.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -55,9 +55,51 @@ var NativeUIBridgeRegistry = class {
55
55
  this.components = /* @__PURE__ */ new Map();
56
56
  this.workflows = /* @__PURE__ */ new Map();
57
57
  this.eventListeners = /* @__PURE__ */ new Map();
58
+ this.enrichers = {};
59
+ this.snapshotExtras = /* @__PURE__ */ new Map();
60
+ /**
61
+ * Sticky flag: flips `true` the first time any element is registered and
62
+ * stays `true` even after elements are unregistered. Lets agents distinguish
63
+ * "this route never wired any elements" from "this route registered then
64
+ * unmounted".
65
+ */
66
+ this.everHadRegistrations = false;
58
67
  this.config = config;
59
68
  }
60
69
  // ============================================================================
70
+ // Snapshot Enricher Slots
71
+ // ============================================================================
72
+ /**
73
+ * Register/replace canonical enrichers (modal/toast/undo). HMR-safe — calling
74
+ * with a partial set merges into existing slots instead of clobbering them.
75
+ */
76
+ setEnrichers(e) {
77
+ this.enrichers = { ...this.enrichers, ...e };
78
+ }
79
+ /**
80
+ * Read-only accessor for the canonical enrichers slot. Returned object is
81
+ * the live reference, so callers can detect "no detector wired" via
82
+ * `getEnrichers().modalDetector === undefined`. Used by the test-hook HTTP
83
+ * handlers (`control/modal/push`, `control/modal/dismiss/:id`) which need
84
+ * to drive the ModalDetector directly without re-instantiating it.
85
+ */
86
+ getEnrichers() {
87
+ return this.enrichers;
88
+ }
89
+ /**
90
+ * Register a custom snapshot enricher. The returned object will be
91
+ * `Object.assign`ed onto the snapshot, so use unique top-level keys to avoid
92
+ * clobbering canonical fields. Returns a disposer.
93
+ */
94
+ registerSnapshotEnricher(name, fn) {
95
+ this.snapshotExtras.set(name, fn);
96
+ return () => this.unregisterSnapshotEnricher(name);
97
+ }
98
+ /** Remove a custom snapshot enricher by name */
99
+ unregisterSnapshotEnricher(name) {
100
+ this.snapshotExtras.delete(name);
101
+ }
102
+ // ============================================================================
61
103
  // Element Management
62
104
  // ============================================================================
63
105
  /**
@@ -123,6 +165,7 @@ var NativeUIBridgeRegistry = class {
123
165
  stateStyles
124
166
  };
125
167
  this.elements.set(id, registered);
168
+ this.everHadRegistrations = true;
126
169
  this.emit("element:registered", { id, type, label });
127
170
  if (this.config.verbose) {
128
171
  console.log(`[ui-bridge-native] Registered element: ${id} (${type})`);
@@ -420,7 +463,7 @@ var NativeUIBridgeRegistry = class {
420
463
  const currentRoute = routeInfo.currentRoute;
421
464
  elements = elements.filter((e) => e.registrationRoute === currentRoute);
422
465
  }
423
- return {
466
+ const snapshot = {
424
467
  timestamp: Date.now(),
425
468
  elements: elements.map((e) => {
426
469
  const handlers = extractHandlerNames(e.props);
@@ -450,8 +493,52 @@ var NativeUIBridgeRegistry = class {
450
493
  stepCount: w.steps.length
451
494
  })),
452
495
  currentRoute: routeInfo?.currentRoute ?? null,
453
- segments: routeInfo?.segments
496
+ segments: routeInfo?.segments,
497
+ registration: this.getRegistrationCoverage()
454
498
  };
499
+ if (this.enrichers.modalDetector) {
500
+ try {
501
+ snapshot.modalStack = this.enrichers.modalDetector.getSnapshotModalContext();
502
+ } catch (error3) {
503
+ if (this.config.verbose) {
504
+ console.warn(`[ui-bridge-native] modalDetector enricher threw:`, error3);
505
+ }
506
+ }
507
+ }
508
+ if (this.enrichers.toastCapture) {
509
+ try {
510
+ snapshot.toasts = this.enrichers.toastCapture.getSnapshotToastContext();
511
+ } catch (error3) {
512
+ if (this.config.verbose) {
513
+ console.warn(`[ui-bridge-native] toastCapture enricher threw:`, error3);
514
+ }
515
+ }
516
+ }
517
+ if (this.enrichers.undoTracker) {
518
+ try {
519
+ snapshot.undoRedo = this.enrichers.undoTracker.getSnapshotUndoContext();
520
+ } catch (error3) {
521
+ if (this.config.verbose) {
522
+ console.warn(`[ui-bridge-native] undoTracker enricher threw:`, error3);
523
+ }
524
+ }
525
+ }
526
+ if (this.snapshotExtras.size > 0) {
527
+ const ctx = { elements, currentRoute: routeInfo?.currentRoute ?? null };
528
+ for (const [name, fn] of this.snapshotExtras) {
529
+ try {
530
+ const extra = fn(ctx);
531
+ if (extra && typeof extra === "object") {
532
+ Object.assign(snapshot, extra);
533
+ }
534
+ } catch (error3) {
535
+ if (this.config.verbose) {
536
+ console.warn(`[ui-bridge-native] snapshot enricher "${name}" threw:`, error3);
537
+ }
538
+ }
539
+ }
540
+ }
541
+ return snapshot;
455
542
  }
456
543
  /**
457
544
  * Get registry statistics
@@ -463,6 +550,25 @@ var NativeUIBridgeRegistry = class {
463
550
  workflows: this.workflows.size
464
551
  };
465
552
  }
553
+ /**
554
+ * Compute registration coverage metadata for the current registry state.
555
+ *
556
+ * Groups currently-registered elements by `registrationRoute`, bucketing
557
+ * elements without a route under `'?'`. `everHadRegistrations` is sticky —
558
+ * once true, it stays true for the lifetime of the registry instance.
559
+ */
560
+ getRegistrationCoverage() {
561
+ const byRoute = {};
562
+ for (const element of this.elements.values()) {
563
+ const key = element.registrationRoute == null || element.registrationRoute === "" ? "?" : element.registrationRoute;
564
+ byRoute[key] = (byRoute[key] ?? 0) + 1;
565
+ }
566
+ return {
567
+ totalRegistered: this.elements.size,
568
+ everHadRegistrations: this.everHadRegistrations,
569
+ byRoute
570
+ };
571
+ }
466
572
  /**
467
573
  * Clear all registrations
468
574
  */
@@ -597,6 +703,23 @@ function sleep(ms) {
597
703
  var DefaultNativeActionExecutor = class {
598
704
  constructor(registry) {
599
705
  this.registry = registry;
706
+ this.actionListeners = /* @__PURE__ */ new Set();
707
+ }
708
+ onActionExecuted(listener) {
709
+ this.actionListeners.add(listener);
710
+ return () => {
711
+ this.actionListeners.delete(listener);
712
+ };
713
+ }
714
+ /** Notify listeners out-of-band; isolate each invocation so one bad listener can't poison the rest. */
715
+ emitActionEvent(event) {
716
+ for (const listener of this.actionListeners) {
717
+ try {
718
+ listener(event);
719
+ } catch (error3) {
720
+ console.warn(`[ui-bridge-native] Action listener error:`, error3);
721
+ }
722
+ }
600
723
  }
601
724
  /**
602
725
  * Execute an action on an element
@@ -610,11 +733,23 @@ var DefaultNativeActionExecutor = class {
610
733
  registered = findElementByIdentifier(elementId) ?? void 0;
611
734
  }
612
735
  if (!registered) {
736
+ const failTime = Date.now();
737
+ const errorMsg = `Element not found: ${elementId}`;
738
+ this.emitActionEvent({
739
+ elementId,
740
+ action: request.action,
741
+ params: request.params,
742
+ success: false,
743
+ error: errorMsg,
744
+ timestamp: failTime,
745
+ requestId: request.requestId,
746
+ durationMs: failTime - startTime
747
+ });
613
748
  return {
614
749
  success: false,
615
- error: `Element not found: ${elementId}`,
616
- durationMs: Date.now() - startTime,
617
- timestamp: Date.now(),
750
+ error: errorMsg,
751
+ durationMs: failTime - startTime,
752
+ timestamp: failTime,
618
753
  requestId: request.requestId
619
754
  };
620
755
  }
@@ -622,33 +757,67 @@ var DefaultNativeActionExecutor = class {
622
757
  const waitResult = await this.waitForElementInternal(registered.id, request.waitOptions);
623
758
  waitDurationMs = waitResult.waitedMs;
624
759
  if (!waitResult.met) {
760
+ const failTime = Date.now();
761
+ const errorMsg = waitResult.error || "Wait condition not met";
762
+ this.emitActionEvent({
763
+ elementId,
764
+ action: request.action,
765
+ params: request.params,
766
+ success: false,
767
+ error: errorMsg,
768
+ timestamp: failTime,
769
+ requestId: request.requestId,
770
+ durationMs: failTime - startTime
771
+ });
625
772
  return {
626
773
  success: false,
627
- error: waitResult.error || "Wait condition not met",
628
- durationMs: Date.now() - startTime,
629
- timestamp: Date.now(),
774
+ error: errorMsg,
775
+ durationMs: failTime - startTime,
776
+ timestamp: failTime,
630
777
  requestId: request.requestId,
631
778
  waitDurationMs
632
779
  };
633
780
  }
634
781
  }
635
782
  const result = await this.performAction(registered, request.action, request.params);
783
+ const successTime = Date.now();
784
+ this.emitActionEvent({
785
+ elementId,
786
+ action: request.action,
787
+ params: request.params,
788
+ success: true,
789
+ timestamp: successTime,
790
+ requestId: request.requestId,
791
+ durationMs: successTime - startTime
792
+ });
636
793
  return {
637
794
  success: true,
638
795
  elementState: registered.getState(),
639
796
  result,
640
- durationMs: Date.now() - startTime,
641
- timestamp: Date.now(),
797
+ durationMs: successTime - startTime,
798
+ timestamp: successTime,
642
799
  requestId: request.requestId,
643
800
  waitDurationMs
644
801
  };
645
802
  } catch (error3) {
803
+ const failTime = Date.now();
804
+ const errorMsg = error3 instanceof Error ? error3.message : String(error3);
805
+ this.emitActionEvent({
806
+ elementId,
807
+ action: request.action,
808
+ params: request.params,
809
+ success: false,
810
+ error: errorMsg,
811
+ timestamp: failTime,
812
+ requestId: request.requestId,
813
+ durationMs: failTime - startTime
814
+ });
646
815
  return {
647
816
  success: false,
648
- error: error3 instanceof Error ? error3.message : String(error3),
817
+ error: errorMsg,
649
818
  stack: error3 instanceof Error ? error3.stack : void 0,
650
- durationMs: Date.now() - startTime,
651
- timestamp: Date.now(),
819
+ durationMs: failTime - startTime,
820
+ timestamp: failTime,
652
821
  requestId: request.requestId,
653
822
  waitDurationMs
654
823
  };
@@ -1807,6 +1976,20 @@ function createServerHandlers(registry, executor, config) {
1807
1976
  return success2(response);
1808
1977
  },
1809
1978
  // Discovery
1979
+ //
1980
+ // Native parity with the runner's `POST /ui-bridge/ai/find`. The runner
1981
+ // matches a free-text `query` against label > text > value > aria-label >
1982
+ // placeholder > name. We replicate that here against the native registry's
1983
+ // analogues:
1984
+ // 1. `label` (the registered display label)
1985
+ // 2. identifier fields: `testId`, `accessibilityLabel`, `treePath`, `uiId`
1986
+ // 3. registered handler names (e.g. `onPress`, `onChangeText`)
1987
+ //
1988
+ // Match is case-insensitive substring. Empty/missing query falls back to
1989
+ // the structured filters (types/visibleOnly/etc.) so callers that don't
1990
+ // pass `query` still get the legacy behaviour. Once we've narrowed by
1991
+ // query the response is shaped through the executor's NativeFindResponse
1992
+ // envelope so the wire format stays stable.
1810
1993
  find: async (ctx) => {
1811
1994
  const body = ctx.body;
1812
1995
  const response = await executor.find({
@@ -1814,9 +1997,49 @@ function createServerHandlers(registry, executor, config) {
1814
1997
  testIdPattern: body?.testIdPattern,
1815
1998
  accessibilityLabelPattern: body?.accessibilityLabelPattern,
1816
1999
  visibleOnly: body?.visibleOnly,
1817
- limit: body?.limit
2000
+ // When `query` is supplied we apply our own limit *after* ranking so
2001
+ // the highest-priority matches survive the cap; defer slicing to us
2002
+ // by widening here.
2003
+ limit: body?.query && body.query.length > 0 ? void 0 : body?.limit
2004
+ });
2005
+ const rawQuery = typeof body?.query === "string" ? body.query.trim() : "";
2006
+ if (rawQuery.length === 0) {
2007
+ return success2(response);
2008
+ }
2009
+ const needle = rawQuery.toLowerCase();
2010
+ const containsNeedle = (value) => typeof value === "string" && value.toLowerCase().includes(needle);
2011
+ const scored = [];
2012
+ for (const el of response.elements) {
2013
+ let priorityRank = Number.POSITIVE_INFINITY;
2014
+ if (containsNeedle(el.label)) {
2015
+ priorityRank = Math.min(priorityRank, 0);
2016
+ }
2017
+ const ident = el.identifier;
2018
+ if (ident) {
2019
+ if (containsNeedle(ident.testId)) priorityRank = Math.min(priorityRank, 1);
2020
+ if (containsNeedle(ident.accessibilityLabel)) priorityRank = Math.min(priorityRank, 1);
2021
+ if (containsNeedle(ident.treePath)) priorityRank = Math.min(priorityRank, 1);
2022
+ if (containsNeedle(ident.uiId)) priorityRank = Math.min(priorityRank, 1);
2023
+ }
2024
+ if (priorityRank === Number.POSITIVE_INFINITY) {
2025
+ const registered = registry.getElement(el.id);
2026
+ const handlers = extractHandlerNames(registered?.props);
2027
+ if (handlers.some((h) => h.toLowerCase().includes(needle))) {
2028
+ priorityRank = 2;
2029
+ }
2030
+ }
2031
+ if (priorityRank !== Number.POSITIVE_INFINITY) {
2032
+ scored.push({ priorityRank, element: el });
2033
+ }
2034
+ }
2035
+ scored.sort((a, b) => a.priorityRank - b.priorityRank);
2036
+ const limit = body?.limit && body.limit > 0 ? body.limit : void 0;
2037
+ const filtered = limit ? scored.slice(0, limit) : scored;
2038
+ return success2({
2039
+ ...response,
2040
+ elements: filtered.map((s) => s.element),
2041
+ total: filtered.length
1818
2042
  });
1819
- return success2(response);
1820
2043
  },
1821
2044
  getSnapshot: async (ctx) => {
1822
2045
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
@@ -1917,15 +2140,162 @@ function createServerHandlers(registry, executor, config) {
1917
2140
  getMethods: async () => {
1918
2141
  return error2("Methods introspection not configured", "NOT_SUPPORTED");
1919
2142
  },
2143
+ // AI helpers
2144
+ //
2145
+ // Mobile-side parity with the runner's `POST /ui-bridge/ai/fill-form`.
2146
+ // Each entry in `fields` is dispatched through the existing executor as a
2147
+ // `type` action with `clear: true` so the input value is replaced
2148
+ // atomically (the closest native equivalent to the web bridge's
2149
+ // "setValue" semantics — there is no `setValue` action on native).
2150
+ //
2151
+ // Failures do not short-circuit: every field is attempted, and the
2152
+ // response carries per-field success/error plus aggregate counts.
2153
+ fillForm: async (ctx) => {
2154
+ const body = ctx.body;
2155
+ const rawFields = body?.fields;
2156
+ if (!Array.isArray(rawFields)) {
2157
+ return error2('Request body must include "fields" array', "INVALID_REQUEST");
2158
+ }
2159
+ const results = [];
2160
+ for (let i = 0; i < rawFields.length; i++) {
2161
+ const field = rawFields[i];
2162
+ if (!field || typeof field !== "object") {
2163
+ results.push({
2164
+ elementId: "",
2165
+ success: false,
2166
+ error: `fields[${i}] must be an object with "elementId" and "value"`
2167
+ });
2168
+ continue;
2169
+ }
2170
+ const { elementId, value } = field;
2171
+ if (typeof elementId !== "string" || elementId.length === 0) {
2172
+ results.push({
2173
+ elementId: typeof elementId === "string" ? elementId : "",
2174
+ success: false,
2175
+ error: `fields[${i}] is missing a non-empty "elementId"`
2176
+ });
2177
+ continue;
2178
+ }
2179
+ if (typeof value !== "string") {
2180
+ results.push({
2181
+ elementId,
2182
+ success: false,
2183
+ error: `fields[${i}] is missing a string "value"`
2184
+ });
2185
+ continue;
2186
+ }
2187
+ try {
2188
+ const response = await executor.executeAction(elementId, {
2189
+ action: "type",
2190
+ params: { text: value, clear: true }
2191
+ });
2192
+ if (response.success) {
2193
+ results.push({ elementId, success: true });
2194
+ } else {
2195
+ results.push({
2196
+ elementId,
2197
+ success: false,
2198
+ error: response.error ?? "Action failed"
2199
+ });
2200
+ }
2201
+ } catch (err) {
2202
+ results.push({
2203
+ elementId,
2204
+ success: false,
2205
+ error: err instanceof Error ? err.message : String(err)
2206
+ });
2207
+ }
2208
+ }
2209
+ const succeededCount = results.filter((r) => r.success).length;
2210
+ const failedCount = results.length - succeededCount;
2211
+ return success2({ results, succeededCount, failedCount });
2212
+ },
2213
+ // Test hooks — modal stack manipulation
2214
+ //
2215
+ // These two endpoints exist so external automation runners can drive
2216
+ // ModalDetector state via HTTP, mirroring the in-tree `pushModal` /
2217
+ // `dismissModal` calls that components normally make. They're gated by
2218
+ // `features.testHooks` at the route layer (see http-server.ts); the
2219
+ // handlers themselves still defend against a missing modalDetector
2220
+ // because the bare server path used by CloudRelayClient may run before
2221
+ // enrichers are wired.
2222
+ pushModal: async (ctx) => {
2223
+ const detector = registry.getEnrichers().modalDetector;
2224
+ if (!detector) {
2225
+ return error2(
2226
+ "modal detector not registered",
2227
+ "ENRICHER_UNAVAILABLE"
2228
+ );
2229
+ }
2230
+ const body = ctx.body;
2231
+ const id = body?.id;
2232
+ if (typeof id !== "string" || id.length === 0) {
2233
+ return error2(
2234
+ 'pushModal requires a non-empty "id"',
2235
+ "INVALID_REQUEST"
2236
+ );
2237
+ }
2238
+ const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
2239
+ const requestedType = body?.type;
2240
+ if (requestedType !== void 0 && (typeof requestedType !== "string" || !allowedTypes.includes(requestedType))) {
2241
+ return error2(
2242
+ `pushModal "type" must be one of: ${allowedTypes.join(", ")}`,
2243
+ "INVALID_REQUEST"
2244
+ );
2245
+ }
2246
+ detector.pushModal({
2247
+ id,
2248
+ title: body?.title,
2249
+ // requestedType is narrowed by the allowedTypes guard above; the cast
2250
+ // converts the validated string to the NativeModalInfo['type'] union.
2251
+ type: requestedType,
2252
+ blocking: body?.blocking,
2253
+ dismissible: body?.dismissible
2254
+ });
2255
+ const stored = detector.getActive().find((m) => m.id === id);
2256
+ return success2({
2257
+ pushed: true,
2258
+ id,
2259
+ title: stored?.title,
2260
+ type: stored?.type,
2261
+ blocking: stored?.blocking,
2262
+ dismissible: stored?.dismissible,
2263
+ pushedAt: stored?.detectedAt ?? Date.now()
2264
+ });
2265
+ },
2266
+ dismissModal: async (ctx) => {
2267
+ const detector = registry.getEnrichers().modalDetector;
2268
+ if (!detector) {
2269
+ return error2(
2270
+ "modal detector not registered",
2271
+ "ENRICHER_UNAVAILABLE"
2272
+ );
2273
+ }
2274
+ const { id } = ctx.params;
2275
+ if (typeof id !== "string" || id.length === 0) {
2276
+ return error2(
2277
+ "dismissModal requires a modal id in the path",
2278
+ "INVALID_REQUEST"
2279
+ );
2280
+ }
2281
+ const wasPresent = detector.getActive().some((m) => m.id === id);
2282
+ if (!wasPresent) {
2283
+ return error2(`modal not found: ${id}`, "MODAL_NOT_FOUND");
2284
+ }
2285
+ detector.dismissModal(id);
2286
+ return success2({ dismissed: id });
2287
+ },
1920
2288
  // Design Review
1921
2289
  ...designHandlers,
1922
2290
  // Health
1923
2291
  health: async () => {
1924
2292
  const stats = registry.getStats();
2293
+ const registration = registry.getRegistrationCoverage();
1925
2294
  const response = {
1926
2295
  status: "healthy",
1927
2296
  timestamp: Date.now(),
1928
- ...stats
2297
+ ...stats,
2298
+ registration
1929
2299
  };
1930
2300
  if (config?.appInfo) {
1931
2301
  response.uiBridge = {
@@ -2026,7 +2396,24 @@ var WS_ROUTES = [
2026
2396
  { pattern: "design/evaluate/baseline", handler: "saveBaseline", httpMethods: ["POST"] },
2027
2397
  { pattern: "design/evaluate/diff", handler: "diffBaseline", httpMethods: ["POST"] },
2028
2398
  // Meta / Introspection
2029
- { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] }
2399
+ { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
2400
+ // AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
2401
+ { pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
2402
+ // Test hooks — gated behind `features.testHooks`. Let external runners
2403
+ // drive the ModalDetector over HTTP so tests can flip `snapshot.modalStack`
2404
+ // without mounting React components.
2405
+ {
2406
+ pattern: "control/modal/push",
2407
+ handler: "pushModal",
2408
+ httpMethods: ["POST"],
2409
+ requiresTestHooks: true
2410
+ },
2411
+ {
2412
+ pattern: "control/modal/dismiss/:id",
2413
+ handler: "dismissModal",
2414
+ httpMethods: ["POST"],
2415
+ requiresTestHooks: true
2416
+ }
2030
2417
  ];
2031
2418
  var HANDLER_DESCRIPTIONS = {
2032
2419
  health: "Liveness probe; returns app metadata + element count",
@@ -2059,7 +2446,10 @@ var HANDLER_DESCRIPTIONS = {
2059
2446
  getQualityContexts: "List available quality evaluation contexts",
2060
2447
  saveBaseline: "Save a quality baseline snapshot",
2061
2448
  diffBaseline: "Diff current UI against a saved baseline",
2062
- getMethods: "List every handler name (legacy introspection)"
2449
+ getMethods: "List every handler name (legacy introspection)",
2450
+ fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
2451
+ pushModal: "TEST HOOK \u2014 push a modal onto the snapshot.modalStack via the registry ModalDetector",
2452
+ dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector"
2063
2453
  };
2064
2454
  var ACTION_REFERENCE = [
2065
2455
  { name: "press", aliases: ["click"], params: "PressActionParams (optional position)" },
@@ -2076,10 +2466,11 @@ var ACTION_REFERENCE = [
2076
2466
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
2077
2467
  { name: "toggle", params: "none \u2014 flips boolean controls" }
2078
2468
  ];
2079
- function buildRoutesPayload() {
2469
+ function buildRoutesPayload(testHooks) {
2080
2470
  const entries = [];
2081
2471
  for (const r of WS_ROUTES) {
2082
2472
  if (!r.httpMethods) continue;
2473
+ if (r.requiresTestHooks && !testHooks) continue;
2083
2474
  for (const m of r.httpMethods) {
2084
2475
  entries.push({ method: m, path: `/ui-bridge/${r.pattern}` });
2085
2476
  }
@@ -2089,11 +2480,11 @@ function buildRoutesPayload() {
2089
2480
  entries.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
2090
2481
  return { routes: entries, count: entries.length };
2091
2482
  }
2092
- function buildHelpPayload() {
2483
+ function buildHelpPayload(testHooks) {
2093
2484
  return {
2094
2485
  version: 1,
2095
2486
  description: "UI Bridge Native HTTP surface. Prefix every path with /ui-bridge/.",
2096
- routes: WS_ROUTES.map((r) => ({
2487
+ routes: WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
2097
2488
  pattern: r.pattern,
2098
2489
  httpMethods: r.httpMethods ?? [],
2099
2490
  wsOnly: !r.httpMethods,
@@ -2129,7 +2520,8 @@ var NativeUIBridgeServer = class {
2129
2520
  appInfo: this.config.appInfo
2130
2521
  });
2131
2522
  this.handlers.getMethods = async () => {
2132
- const methods = WS_ROUTES.map((r) => ({
2523
+ const testHooks = this.config.testHooks === true;
2524
+ const methods = WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
2133
2525
  method: r.pattern,
2134
2526
  httpMethods: r.httpMethods ? [...r.httpMethods] : void 0
2135
2527
  }));
@@ -2172,7 +2564,11 @@ var NativeUIBridgeServer = class {
2172
2564
  timestamp: Date.now()
2173
2565
  };
2174
2566
  } catch (e) {
2175
- return { success: false, error: `Navigation failed: ${e.message}`, timestamp: Date.now() };
2567
+ return {
2568
+ success: false,
2569
+ error: `Navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2570
+ timestamp: Date.now()
2571
+ };
2176
2572
  }
2177
2573
  };
2178
2574
  this.handlers.pageReplace = async (ctx) => {
@@ -2198,7 +2594,7 @@ var NativeUIBridgeServer = class {
2198
2594
  } catch (e) {
2199
2595
  return {
2200
2596
  success: false,
2201
- error: `Replace navigation failed: ${e.message}`,
2597
+ error: `Replace navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2202
2598
  timestamp: Date.now()
2203
2599
  };
2204
2600
  }
@@ -2217,7 +2613,7 @@ var NativeUIBridgeServer = class {
2217
2613
  } catch (e) {
2218
2614
  return {
2219
2615
  success: false,
2220
- error: `Back navigation failed: ${e.message}`,
2616
+ error: `Back navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2221
2617
  timestamp: Date.now()
2222
2618
  };
2223
2619
  }
@@ -2238,7 +2634,11 @@ var NativeUIBridgeServer = class {
2238
2634
  timestamp: Date.now()
2239
2635
  };
2240
2636
  } catch (e) {
2241
- return { success: false, error: `Refresh failed: ${e.message}`, timestamp: Date.now() };
2637
+ return {
2638
+ success: false,
2639
+ error: `Refresh failed: ${e instanceof Error ? e.message : String(e)}`,
2640
+ timestamp: Date.now()
2641
+ };
2242
2642
  }
2243
2643
  };
2244
2644
  }
@@ -2296,7 +2696,7 @@ var NativeUIBridgeServer = class {
2296
2696
  } catch (e) {
2297
2697
  return {
2298
2698
  success: false,
2299
- error: `Screenshot capture failed: ${e.message}`,
2699
+ error: `Screenshot capture failed: ${e instanceof Error ? e.message : String(e)}`,
2300
2700
  timestamp: Date.now()
2301
2701
  };
2302
2702
  }
@@ -2851,9 +3251,11 @@ var NativeUIBridgeServer = class {
2851
3251
  * "control/component/my-comp/action/submit" → handlers.executeComponentAction
2852
3252
  */
2853
3253
  async routeMethodToHandler(method, params) {
3254
+ const testHooks = this.config.testHooks === true;
2854
3255
  for (const route of WS_ROUTES) {
2855
3256
  const pathParams = parsePath(route.pattern, method);
2856
3257
  if (!pathParams) continue;
3258
+ if (route.requiresTestHooks && !testHooks) continue;
2857
3259
  const ctx = {
2858
3260
  params: pathParams,
2859
3261
  query: params.query && typeof params.query === "object" ? params.query : {},
@@ -2931,17 +3333,18 @@ var NativeUIBridgeServer = class {
2931
3333
  };
2932
3334
  }
2933
3335
  const stripped = path.slice(PREFIX.length);
3336
+ const testHooks = this.config.testHooks === true;
2934
3337
  if (method === "GET" && stripped === "_help") {
2935
3338
  return {
2936
3339
  success: true,
2937
- data: buildHelpPayload(),
3340
+ data: buildHelpPayload(testHooks),
2938
3341
  timestamp: Date.now()
2939
3342
  };
2940
3343
  }
2941
3344
  if (method === "GET" && stripped === "_routes") {
2942
3345
  return {
2943
3346
  success: true,
2944
- data: buildRoutesPayload(),
3347
+ data: buildRoutesPayload(testHooks),
2945
3348
  timestamp: Date.now()
2946
3349
  };
2947
3350
  }
@@ -2952,6 +3355,9 @@ var NativeUIBridgeServer = class {
2952
3355
  if (!route.httpMethods) {
2953
3356
  continue;
2954
3357
  }
3358
+ if (route.requiresTestHooks && !testHooks) {
3359
+ continue;
3360
+ }
2955
3361
  if (!route.httpMethods.includes(method)) {
2956
3362
  patternMatchedButWrongVerb = true;
2957
3363
  continue;
@@ -3245,6 +3651,27 @@ var WebSocketEventBridge = class {
3245
3651
  }, this.heartbeatIntervalMs);
3246
3652
  }
3247
3653
  };
3654
+
3655
+ // src/transport/logger.ts
3656
+ function createTransportLogger() {
3657
+ return {
3658
+ silent: false,
3659
+ log(message, ...args) {
3660
+ if (!this.silent) {
3661
+ console.log(message, ...args);
3662
+ }
3663
+ },
3664
+ warn(message, ...args) {
3665
+ console.warn(message, ...args);
3666
+ },
3667
+ error(message, ...args) {
3668
+ console.error(message, ...args);
3669
+ }
3670
+ };
3671
+ }
3672
+ var transportLogger = createTransportLogger();
3673
+
3674
+ // src/transport/DeviceAnnouncer.ts
3248
3675
  var DEFAULT_PORT = 8087;
3249
3676
  var RECONNECT_INITIAL_DELAY_MS = 2e3;
3250
3677
  var RECONNECT_MAX_DELAY_MS = 6e4;
@@ -3279,7 +3706,7 @@ var DeviceAnnouncer = class {
3279
3706
  */
3280
3707
  async startMdnsAdvertise(ZeroconfCtor) {
3281
3708
  if (!ZeroconfCtor) {
3282
- console.log(
3709
+ transportLogger.log(
3283
3710
  "[DeviceAnnouncer] mDNS advertisement skipped (no Zeroconf constructor provided). Pass one to enable mDNS."
3284
3711
  );
3285
3712
  return;
@@ -3295,9 +3722,9 @@ var DeviceAnnouncer = class {
3295
3722
  pairing_token: this.pairingToken
3296
3723
  });
3297
3724
  this.state = { ...this.state, mdnsActive: true };
3298
- console.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
3725
+ transportLogger.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
3299
3726
  } catch (err) {
3300
- console.warn("[DeviceAnnouncer] mDNS start failed:", err);
3727
+ transportLogger.warn("[DeviceAnnouncer] mDNS start failed:", err);
3301
3728
  }
3302
3729
  }
3303
3730
  // ── Cloud relay ───────────────────────────────────────────────────────────
@@ -3313,7 +3740,7 @@ var DeviceAnnouncer = class {
3313
3740
  }
3314
3741
  if (this.stopped) return;
3315
3742
  const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
3316
- console.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
3743
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
3317
3744
  const ws = new WebSocket(url);
3318
3745
  this.cloudWs = ws;
3319
3746
  ws.onopen = () => {
@@ -3323,7 +3750,7 @@ var DeviceAnnouncer = class {
3323
3750
  }
3324
3751
  this.reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
3325
3752
  this.state = { ...this.state, cloudConnected: true };
3326
- console.log("[DeviceAnnouncer] Cloud relay: connected");
3753
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connected");
3327
3754
  ws.send(
3328
3755
  JSON.stringify({
3329
3756
  type: "device_register",
@@ -3338,7 +3765,7 @@ var DeviceAnnouncer = class {
3338
3765
  ws.onmessage = (event) => {
3339
3766
  try {
3340
3767
  const msg = JSON.parse(event.data);
3341
- console.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
3768
+ transportLogger.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
3342
3769
  } catch {
3343
3770
  }
3344
3771
  };
@@ -3349,7 +3776,7 @@ var DeviceAnnouncer = class {
3349
3776
  }
3350
3777
  };
3351
3778
  ws.onerror = (err) => {
3352
- console.warn("[DeviceAnnouncer] Cloud relay error:", err);
3779
+ transportLogger.warn("[DeviceAnnouncer] Cloud relay error:", err);
3353
3780
  };
3354
3781
  }
3355
3782
  // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -3384,7 +3811,7 @@ var DeviceAnnouncer = class {
3384
3811
  scheduleReconnect() {
3385
3812
  if (this.stopped) return;
3386
3813
  const delay = this.reconnectDelay;
3387
- console.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
3814
+ transportLogger.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
3388
3815
  this.reconnectTimer = setTimeout(() => {
3389
3816
  this.reconnectTimer = null;
3390
3817
  if (!this.stopped) {
@@ -3446,7 +3873,7 @@ var CloudRelayClient = class {
3446
3873
  connect() {
3447
3874
  if (this.stopped) return;
3448
3875
  const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
3449
- console.log("[CloudRelayClient] Connecting to", url);
3876
+ transportLogger.log("[CloudRelayClient] Connecting to", url);
3450
3877
  const ws = new WebSocket(url);
3451
3878
  this.ws = ws;
3452
3879
  ws.onopen = () => {
@@ -3456,7 +3883,7 @@ var CloudRelayClient = class {
3456
3883
  }
3457
3884
  this._isConnected = true;
3458
3885
  this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
3459
- console.log("[CloudRelayClient] Connected to relay");
3886
+ transportLogger.log("[CloudRelayClient] Connected to relay");
3460
3887
  ws.send(
3461
3888
  JSON.stringify({
3462
3889
  type: "device_register",
@@ -3474,7 +3901,7 @@ var CloudRelayClient = class {
3474
3901
  }
3475
3902
  };
3476
3903
  ws.onerror = (err) => {
3477
- console.warn("[CloudRelayClient] WebSocket error:", err);
3904
+ transportLogger.warn("[CloudRelayClient] WebSocket error:", err);
3478
3905
  };
3479
3906
  }
3480
3907
  async onMessage(raw) {
@@ -3482,12 +3909,12 @@ var CloudRelayClient = class {
3482
3909
  try {
3483
3910
  msg = JSON.parse(raw);
3484
3911
  } catch {
3485
- console.warn("[CloudRelayClient] Received non-JSON message");
3912
+ transportLogger.warn("[CloudRelayClient] Received non-JSON message");
3486
3913
  return;
3487
3914
  }
3488
3915
  if (msg["type"] !== "tunnel_request") return;
3489
3916
  if (typeof msg["id"] !== "string" || typeof msg["method"] !== "string" || typeof msg["path"] !== "string") {
3490
- console.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
3917
+ transportLogger.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
3491
3918
  return;
3492
3919
  }
3493
3920
  const req = {
@@ -3528,7 +3955,7 @@ var CloudRelayClient = class {
3528
3955
  body: bodyValue
3529
3956
  };
3530
3957
  } catch (err) {
3531
- console.error("[CloudRelayClient] Error handling tunneled request:", err);
3958
+ transportLogger.error("[CloudRelayClient] Error handling tunneled request:", err);
3532
3959
  return {
3533
3960
  id: req.id,
3534
3961
  status: 500,
@@ -3543,7 +3970,7 @@ var CloudRelayClient = class {
3543
3970
  scheduleReconnect() {
3544
3971
  if (this.stopped) return;
3545
3972
  const delay = this.reconnectDelay;
3546
- console.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
3973
+ transportLogger.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
3547
3974
  this.reconnectTimer = setTimeout(() => {
3548
3975
  this.reconnectTimer = null;
3549
3976
  if (!this.stopped) {
@@ -3566,6 +3993,192 @@ function parseQueryParams(path) {
3566
3993
  }
3567
3994
  return result;
3568
3995
  }
3996
+
3997
+ // src/modal/modal-detector.ts
3998
+ var ModalDetector = class {
3999
+ constructor() {
4000
+ /** Map preserves O(1) idempotent replace by id; ordering comes from detectedAt. */
4001
+ this.modals = /* @__PURE__ */ new Map();
4002
+ }
4003
+ pushModal(modal) {
4004
+ const existing = this.modals.get(modal.id);
4005
+ const detectedAt = existing ? existing.detectedAt : Date.now();
4006
+ const next = {
4007
+ id: modal.id,
4008
+ title: modal.title,
4009
+ type: modal.type ?? "modal",
4010
+ blocking: modal.blocking ?? true,
4011
+ dismissible: modal.dismissible ?? true,
4012
+ detectedAt
4013
+ };
4014
+ this.modals.set(modal.id, next);
4015
+ }
4016
+ /**
4017
+ * Remove a modal from the stack. Returns `true` if a modal with that id
4018
+ * was actually deleted, `false` if nothing matched (idempotent so repeated
4019
+ * calls don't throw, but callers — e.g. the HTTP test-hook endpoint — can
4020
+ * surface the miss to clients).
4021
+ */
4022
+ dismissModal(id) {
4023
+ return this.modals.delete(id);
4024
+ }
4025
+ getSnapshotModalContext() {
4026
+ const active = this.getActive();
4027
+ const ctx = {
4028
+ modals: active,
4029
+ hasBlockingModal: active.some((m) => m.blocking),
4030
+ count: active.length
4031
+ };
4032
+ if (active.length > 0) {
4033
+ ctx.topModal = active[active.length - 1];
4034
+ }
4035
+ return ctx;
4036
+ }
4037
+ getActive() {
4038
+ return Array.from(this.modals.values()).sort((a, b) => a.detectedAt - b.detectedAt);
4039
+ }
4040
+ clear() {
4041
+ this.modals.clear();
4042
+ }
4043
+ };
4044
+
4045
+ // src/toast/toast-capture.ts
4046
+ var DEFAULT_MAX_RECENT = 50;
4047
+ var ToastCapture = class {
4048
+ constructor(config = {}) {
4049
+ this.active = /* @__PURE__ */ new Map();
4050
+ this.recent = [];
4051
+ this.totalCaptured = 0;
4052
+ this.maxRecent = config.maxRecent ?? DEFAULT_MAX_RECENT;
4053
+ }
4054
+ recordToast(toast) {
4055
+ if (this.active.has(toast.id)) {
4056
+ return;
4057
+ }
4058
+ const appearedAt = toast.appearedAt ?? Date.now();
4059
+ const entry = {
4060
+ id: toast.id,
4061
+ message: toast.message,
4062
+ level: toast.level ?? "info",
4063
+ appearedAt,
4064
+ visible: true,
4065
+ durationMs: 0
4066
+ };
4067
+ this.active.set(toast.id, entry);
4068
+ this.totalCaptured += 1;
4069
+ }
4070
+ dismissToast(id) {
4071
+ const entry = this.active.get(id);
4072
+ if (!entry) {
4073
+ return;
4074
+ }
4075
+ this.active.delete(id);
4076
+ const dismissedAt = Date.now();
4077
+ const dismissed = {
4078
+ ...entry,
4079
+ dismissedAt,
4080
+ visible: false,
4081
+ durationMs: dismissedAt - entry.appearedAt
4082
+ };
4083
+ this.recent.push(dismissed);
4084
+ if (this.recent.length > this.maxRecent) {
4085
+ this.recent.shift();
4086
+ }
4087
+ }
4088
+ getSnapshotToastContext() {
4089
+ const now = Date.now();
4090
+ const active = Array.from(this.active.values()).map((entry) => ({
4091
+ ...entry,
4092
+ durationMs: now - entry.appearedAt
4093
+ }));
4094
+ return {
4095
+ active,
4096
+ recent: [...this.recent],
4097
+ totalCaptured: this.totalCaptured
4098
+ };
4099
+ }
4100
+ clear() {
4101
+ this.active.clear();
4102
+ this.recent = [];
4103
+ this.totalCaptured = 0;
4104
+ }
4105
+ };
4106
+
4107
+ // src/undo/undo-tracker.ts
4108
+ var DEFAULT_MAX_HISTORY = 50;
4109
+ function buildSummary(ctx) {
4110
+ if (!ctx.canUndo && !ctx.canRedo) {
4111
+ return "no undo available";
4112
+ }
4113
+ const parts = [];
4114
+ if (ctx.canUndo) {
4115
+ parts.push(`undo: '${ctx.undoDescription ?? "available"}'`);
4116
+ }
4117
+ if (ctx.canRedo) {
4118
+ parts.push(`redo: '${ctx.redoDescription ?? "available"}'`);
4119
+ }
4120
+ return parts.join(" / ");
4121
+ }
4122
+ var UndoTracker = class {
4123
+ constructor(config = {}) {
4124
+ this.actions = [];
4125
+ this.declaredState = null;
4126
+ this.maxHistory = config.maxHistory ?? DEFAULT_MAX_HISTORY;
4127
+ }
4128
+ recordAction(record) {
4129
+ this.actions.push(record);
4130
+ if (this.actions.length > this.maxHistory) {
4131
+ this.actions = this.actions.slice(-this.maxHistory);
4132
+ }
4133
+ }
4134
+ setDeclaredState(state) {
4135
+ this.declaredState = state;
4136
+ }
4137
+ getActionHistory() {
4138
+ return this.actions.slice();
4139
+ }
4140
+ getSnapshotUndoContext() {
4141
+ const declared = this.declaredState;
4142
+ if (declared) {
4143
+ const summary2 = buildSummary({
4144
+ canUndo: declared.canUndo,
4145
+ canRedo: declared.canRedo,
4146
+ undoDescription: declared.undoDescription,
4147
+ redoDescription: declared.redoDescription
4148
+ });
4149
+ return {
4150
+ canUndo: declared.canUndo,
4151
+ canRedo: declared.canRedo,
4152
+ undoDescription: declared.undoDescription,
4153
+ redoDescription: declared.redoDescription,
4154
+ undoDepth: declared.undoDepth,
4155
+ redoDepth: declared.redoDepth,
4156
+ summary: summary2
4157
+ };
4158
+ }
4159
+ const canUndo = this.actions.some((a) => a.reversible !== false);
4160
+ const canRedo = false;
4161
+ const lastReversible = [...this.actions].reverse().find((a) => a.reversible !== false);
4162
+ const undoDescription = lastReversible?.description;
4163
+ const summary = buildSummary({ canUndo, canRedo, undoDescription });
4164
+ return {
4165
+ canUndo,
4166
+ canRedo,
4167
+ undoDescription,
4168
+ summary
4169
+ };
4170
+ }
4171
+ executeUndo() {
4172
+ return { ok: false, reason: "Not implemented in native" };
4173
+ }
4174
+ executeRedo() {
4175
+ return { ok: false, reason: "Not implemented in native" };
4176
+ }
4177
+ clear() {
4178
+ this.actions = [];
4179
+ this.declaredState = null;
4180
+ }
4181
+ };
3569
4182
  var UIBridgeNativeContext = react.createContext(null);
3570
4183
  function UIBridgeNativeProvider({
3571
4184
  children,
@@ -3582,6 +4195,9 @@ function UIBridgeNativeProvider({
3582
4195
  }) {
3583
4196
  const registryRef = react.useRef(null);
3584
4197
  const executorRef = react.useRef(null);
4198
+ const modalDetectorRef = react.useRef(null);
4199
+ const toastCaptureRef = react.useRef(null);
4200
+ const undoTrackerRef = react.useRef(null);
3585
4201
  const [serverRunning, setServerRunning] = react.useState(false);
3586
4202
  if (!registryRef.current) {
3587
4203
  registryRef.current = new NativeUIBridgeRegistry({
@@ -3595,6 +4211,12 @@ function UIBridgeNativeProvider({
3595
4211
  executorRef.current = createNativeActionExecutor(registry);
3596
4212
  }
3597
4213
  const executor = executorRef.current;
4214
+ if (!modalDetectorRef.current) modalDetectorRef.current = new ModalDetector();
4215
+ if (!toastCaptureRef.current) toastCaptureRef.current = new ToastCapture();
4216
+ if (!undoTrackerRef.current) undoTrackerRef.current = new UndoTracker();
4217
+ const modalDetector = modalDetectorRef.current;
4218
+ const toastCapture = toastCaptureRef.current;
4219
+ const undoTracker = undoTrackerRef.current;
3598
4220
  const serverRef = react.useRef(null);
3599
4221
  const eventBridgeRef = react.useRef(null);
3600
4222
  const announcerRef = react.useRef(null);
@@ -3610,7 +4232,8 @@ function UIBridgeNativeProvider({
3610
4232
  const server = createNativeServer(registry, executor, {
3611
4233
  serverPort: config.serverPort || 8087,
3612
4234
  cors: true,
3613
- appInfo: config.appInfo
4235
+ appInfo: config.appInfo,
4236
+ testHooks: features.testHooks === true
3614
4237
  });
3615
4238
  server.setAdapter(serverAdapter);
3616
4239
  if (navigationProvider) {
@@ -3664,7 +4287,9 @@ function UIBridgeNativeProvider({
3664
4287
  }
3665
4288
  }, [
3666
4289
  features.server,
4290
+ features.testHooks,
3667
4291
  config.serverPort,
4292
+ config.appInfo,
3668
4293
  registry,
3669
4294
  executor,
3670
4295
  serverAdapter,
@@ -3698,7 +4323,8 @@ function UIBridgeNativeProvider({
3698
4323
  const bareServer = createNativeServer(registry, executor, {
3699
4324
  serverPort: config.serverPort || 8087,
3700
4325
  cors: true,
3701
- appInfo: config.appInfo
4326
+ appInfo: config.appInfo,
4327
+ testHooks: features.testHooks === true
3702
4328
  });
3703
4329
  if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
3704
4330
  if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
@@ -3783,6 +4409,22 @@ function UIBridgeNativeProvider({
3783
4409
  lastRoute = current;
3784
4410
  });
3785
4411
  }, [routeProvider, registry]);
4412
+ react.useEffect(() => {
4413
+ registry.setEnrichers({ modalDetector, toastCapture, undoTracker });
4414
+ }, [registry, modalDetector, toastCapture, undoTracker]);
4415
+ react.useEffect(() => {
4416
+ const unsubscribe = executor.onActionExecuted((event) => {
4417
+ if (!event.success) return;
4418
+ undoTracker.recordAction({
4419
+ id: event.requestId ?? `action-${event.timestamp}`,
4420
+ type: event.action,
4421
+ targetId: event.elementId,
4422
+ timestamp: event.timestamp,
4423
+ reversible: true
4424
+ });
4425
+ });
4426
+ return unsubscribe;
4427
+ }, [executor, undoTracker]);
3786
4428
  const on = react.useCallback(
3787
4429
  (type, listener) => registry.on(type, listener),
3788
4430
  [registry]
@@ -3797,6 +4439,9 @@ function UIBridgeNativeProvider({
3797
4439
  config,
3798
4440
  registry,
3799
4441
  executor,
4442
+ modalDetector,
4443
+ toastCapture,
4444
+ undoTracker,
3800
4445
  getElements,
3801
4446
  getComponents,
3802
4447
  createSnapshot,
@@ -3813,6 +4458,9 @@ function UIBridgeNativeProvider({
3813
4458
  config,
3814
4459
  registry,
3815
4460
  executor,
4461
+ modalDetector,
4462
+ toastCapture,
4463
+ undoTracker,
3816
4464
  getElements,
3817
4465
  getComponents,
3818
4466
  createSnapshot,
@@ -3907,7 +4555,7 @@ function useUIElement(options) {
3907
4555
  registeredRef.current = false;
3908
4556
  registeredIdRef.current = null;
3909
4557
  setRegistered(false);
3910
- }, [bridge, registered, id]);
4558
+ }, [bridge, id]);
3911
4559
  const onLayout = react.useCallback(
3912
4560
  (event) => {
3913
4561
  const { x, y, width, height } = event.nativeEvent.layout;
@@ -4173,7 +4821,7 @@ function useUIComponent(options) {
4173
4821
  const registeredComponent = react.useMemo(() => {
4174
4822
  if (!bridge) return null;
4175
4823
  return bridge.registry.getComponent(id) || null;
4176
- }, [bridge, id, registeredRef.current]);
4824
+ }, [bridge, id]);
4177
4825
  return {
4178
4826
  registered: registeredRef.current,
4179
4827
  executeAction,
@@ -4293,6 +4941,37 @@ function useUIBridgeRequired() {
4293
4941
  }
4294
4942
  return result;
4295
4943
  }
4944
+ function useUIBridgeModal(opts) {
4945
+ const { modalDetector } = useUIBridgeNative();
4946
+ const { id, title, type, blocking, dismissible } = opts;
4947
+ react.useEffect(() => {
4948
+ modalDetector.pushModal({ id, title, type, blocking, dismissible });
4949
+ return () => {
4950
+ modalDetector.dismissModal(id);
4951
+ };
4952
+ }, [modalDetector, id, title, type, blocking, dismissible]);
4953
+ }
4954
+ function useUIBridgeToast(opts) {
4955
+ const { toastCapture } = useUIBridgeNative();
4956
+ const { id, message, level } = opts;
4957
+ react.useEffect(() => {
4958
+ toastCapture.recordToast({ id, message, level });
4959
+ return () => {
4960
+ toastCapture.dismissToast(id);
4961
+ };
4962
+ }, [toastCapture, id, message, level]);
4963
+ }
4964
+ function useToastRecorder() {
4965
+ const { toastCapture } = useUIBridgeNative();
4966
+ const recordToast = react.useCallback(
4967
+ (toast) => {
4968
+ toastCapture.recordToast(toast);
4969
+ },
4970
+ [toastCapture]
4971
+ );
4972
+ const dismissToast = react.useCallback((id) => toastCapture.dismissToast(id), [toastCapture]);
4973
+ return { recordToast, dismissToast };
4974
+ }
4296
4975
 
4297
4976
  // src/server/types.ts
4298
4977
  var UI_BRIDGE_NATIVE_ROUTES = {
@@ -4422,6 +5101,25 @@ var UI_BRIDGE_NATIVE_ROUTES = {
4422
5101
  path: "/ui-bridge/design/style-guide",
4423
5102
  description: "Clear loaded style guide"
4424
5103
  },
5104
+ // AI helpers
5105
+ AI_FILL_FORM: {
5106
+ method: "POST",
5107
+ path: "/ui-bridge/ai/fill-form",
5108
+ description: "Fill multiple input elements in a single call"
5109
+ },
5110
+ // Test hooks — drive ModalDetector state from outside the React tree
5111
+ // (gated by `features.testHooks`). Mirrors `pushModal` / `dismissModal`
5112
+ // calls that components normally make in-process.
5113
+ PUSH_MODAL: {
5114
+ method: "POST",
5115
+ path: "/ui-bridge/control/modal/push",
5116
+ description: "Push a modal onto the modal stack (testHooks)"
5117
+ },
5118
+ DISMISS_MODAL: {
5119
+ method: "POST",
5120
+ path: "/ui-bridge/control/modal/dismiss/:id",
5121
+ description: "Dismiss a modal by id (testHooks)"
5122
+ },
4425
5123
  // Health
4426
5124
  HEALTH: {
4427
5125
  method: "GET",
@@ -4931,8 +5629,8 @@ function UIBridgeInspector({
4931
5629
  const bridge = useUIBridgeNativeOptional();
4932
5630
  const [showInspector, setShowInspector] = react.useState(visible);
4933
5631
  const [selectedElement, setSelectedElement] = react.useState(null);
4934
- const elements = react.useMemo(() => bridge ? bridge.getElements() : [], [bridge, showInspector]);
4935
- const components = react.useMemo(() => bridge ? bridge.getComponents() : [], [bridge, showInspector]);
5632
+ const elements = react.useMemo(() => bridge ? bridge.getElements() : [], [bridge]);
5633
+ const components = react.useMemo(() => bridge ? bridge.getComponents() : [], [bridge]);
4936
5634
  const handleToggle = react.useCallback(() => {
4937
5635
  setShowInspector((prev) => !prev);
4938
5636
  }, []);
@@ -5197,11 +5895,14 @@ var styles = reactNative.StyleSheet.create({
5197
5895
  exports.CloudRelayClient = CloudRelayClient;
5198
5896
  exports.DefaultNativeActionExecutor = DefaultNativeActionExecutor;
5199
5897
  exports.DeviceAnnouncer = DeviceAnnouncer;
5898
+ exports.ModalDetector = ModalDetector;
5200
5899
  exports.NativeUIBridgeRegistry = NativeUIBridgeRegistry;
5201
5900
  exports.NativeUIBridgeServer = NativeUIBridgeServer;
5901
+ exports.ToastCapture = ToastCapture;
5202
5902
  exports.UIBridgeInspector = UIBridgeInspector;
5203
5903
  exports.UIBridgeNativeProvider = UIBridgeNativeProvider;
5204
5904
  exports.UI_BRIDGE_NATIVE_ROUTES = UI_BRIDGE_NATIVE_ROUTES;
5905
+ exports.UndoTracker = UndoTracker;
5205
5906
  exports.WebSocketConnection = WebSocketConnection;
5206
5907
  exports.WebSocketEventBridge = WebSocketEventBridge;
5207
5908
  exports.buildTreePath = buildTreePath;
@@ -5224,11 +5925,14 @@ exports.matchesIdentifier = matchesIdentifier;
5224
5925
  exports.parseTreePath = parseTreePath;
5225
5926
  exports.resetGlobalRegistry = resetGlobalRegistry;
5226
5927
  exports.setGlobalRegistry = setGlobalRegistry;
5928
+ exports.useToastRecorder = useToastRecorder;
5227
5929
  exports.useUIBridge = useUIBridge;
5930
+ exports.useUIBridgeModal = useUIBridgeModal;
5228
5931
  exports.useUIBridgeNative = useUIBridgeNative;
5229
5932
  exports.useUIBridgeNativeOptional = useUIBridgeNativeOptional;
5230
5933
  exports.useUIBridgeNativeRequired = useUIBridgeNativeRequired;
5231
5934
  exports.useUIBridgeRequired = useUIBridgeRequired;
5935
+ exports.useUIBridgeToast = useUIBridgeToast;
5232
5936
  exports.useUIComponent = useUIComponent;
5233
5937
  exports.useUIComponentAction = useUIComponentAction;
5234
5938
  exports.useUIElement = useUIElement;