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