@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
@@ -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
  */
@@ -545,6 +651,23 @@ function sleep(ms) {
545
651
  var DefaultNativeActionExecutor = class {
546
652
  constructor(registry) {
547
653
  this.registry = registry;
654
+ this.actionListeners = /* @__PURE__ */ new Set();
655
+ }
656
+ onActionExecuted(listener) {
657
+ this.actionListeners.add(listener);
658
+ return () => {
659
+ this.actionListeners.delete(listener);
660
+ };
661
+ }
662
+ /** Notify listeners out-of-band; isolate each invocation so one bad listener can't poison the rest. */
663
+ emitActionEvent(event) {
664
+ for (const listener of this.actionListeners) {
665
+ try {
666
+ listener(event);
667
+ } catch (error3) {
668
+ console.warn(`[ui-bridge-native] Action listener error:`, error3);
669
+ }
670
+ }
548
671
  }
549
672
  /**
550
673
  * Execute an action on an element
@@ -558,11 +681,23 @@ var DefaultNativeActionExecutor = class {
558
681
  registered = findElementByIdentifier(elementId) ?? void 0;
559
682
  }
560
683
  if (!registered) {
684
+ const failTime = Date.now();
685
+ const errorMsg = `Element not found: ${elementId}`;
686
+ this.emitActionEvent({
687
+ elementId,
688
+ action: request.action,
689
+ params: request.params,
690
+ success: false,
691
+ error: errorMsg,
692
+ timestamp: failTime,
693
+ requestId: request.requestId,
694
+ durationMs: failTime - startTime
695
+ });
561
696
  return {
562
697
  success: false,
563
- error: `Element not found: ${elementId}`,
564
- durationMs: Date.now() - startTime,
565
- timestamp: Date.now(),
698
+ error: errorMsg,
699
+ durationMs: failTime - startTime,
700
+ timestamp: failTime,
566
701
  requestId: request.requestId
567
702
  };
568
703
  }
@@ -570,33 +705,67 @@ var DefaultNativeActionExecutor = class {
570
705
  const waitResult = await this.waitForElementInternal(registered.id, request.waitOptions);
571
706
  waitDurationMs = waitResult.waitedMs;
572
707
  if (!waitResult.met) {
708
+ const failTime = Date.now();
709
+ const errorMsg = waitResult.error || "Wait condition not met";
710
+ this.emitActionEvent({
711
+ elementId,
712
+ action: request.action,
713
+ params: request.params,
714
+ success: false,
715
+ error: errorMsg,
716
+ timestamp: failTime,
717
+ requestId: request.requestId,
718
+ durationMs: failTime - startTime
719
+ });
573
720
  return {
574
721
  success: false,
575
- error: waitResult.error || "Wait condition not met",
576
- durationMs: Date.now() - startTime,
577
- timestamp: Date.now(),
722
+ error: errorMsg,
723
+ durationMs: failTime - startTime,
724
+ timestamp: failTime,
578
725
  requestId: request.requestId,
579
726
  waitDurationMs
580
727
  };
581
728
  }
582
729
  }
583
730
  const result = await this.performAction(registered, request.action, request.params);
731
+ const successTime = Date.now();
732
+ this.emitActionEvent({
733
+ elementId,
734
+ action: request.action,
735
+ params: request.params,
736
+ success: true,
737
+ timestamp: successTime,
738
+ requestId: request.requestId,
739
+ durationMs: successTime - startTime
740
+ });
584
741
  return {
585
742
  success: true,
586
743
  elementState: registered.getState(),
587
744
  result,
588
- durationMs: Date.now() - startTime,
589
- timestamp: Date.now(),
745
+ durationMs: successTime - startTime,
746
+ timestamp: successTime,
590
747
  requestId: request.requestId,
591
748
  waitDurationMs
592
749
  };
593
750
  } catch (error3) {
751
+ const failTime = Date.now();
752
+ const errorMsg = error3 instanceof Error ? error3.message : String(error3);
753
+ this.emitActionEvent({
754
+ elementId,
755
+ action: request.action,
756
+ params: request.params,
757
+ success: false,
758
+ error: errorMsg,
759
+ timestamp: failTime,
760
+ requestId: request.requestId,
761
+ durationMs: failTime - startTime
762
+ });
594
763
  return {
595
764
  success: false,
596
- error: error3 instanceof Error ? error3.message : String(error3),
765
+ error: errorMsg,
597
766
  stack: error3 instanceof Error ? error3.stack : void 0,
598
- durationMs: Date.now() - startTime,
599
- timestamp: Date.now(),
767
+ durationMs: failTime - startTime,
768
+ timestamp: failTime,
600
769
  requestId: request.requestId,
601
770
  waitDurationMs
602
771
  };
@@ -1755,6 +1924,20 @@ function createServerHandlers(registry, executor, config) {
1755
1924
  return success2(response);
1756
1925
  },
1757
1926
  // Discovery
1927
+ //
1928
+ // Native parity with the runner's `POST /ui-bridge/ai/find`. The runner
1929
+ // matches a free-text `query` against label > text > value > aria-label >
1930
+ // placeholder > name. We replicate that here against the native registry's
1931
+ // analogues:
1932
+ // 1. `label` (the registered display label)
1933
+ // 2. identifier fields: `testId`, `accessibilityLabel`, `treePath`, `uiId`
1934
+ // 3. registered handler names (e.g. `onPress`, `onChangeText`)
1935
+ //
1936
+ // Match is case-insensitive substring. Empty/missing query falls back to
1937
+ // the structured filters (types/visibleOnly/etc.) so callers that don't
1938
+ // pass `query` still get the legacy behaviour. Once we've narrowed by
1939
+ // query the response is shaped through the executor's NativeFindResponse
1940
+ // envelope so the wire format stays stable.
1758
1941
  find: async (ctx) => {
1759
1942
  const body = ctx.body;
1760
1943
  const response = await executor.find({
@@ -1762,9 +1945,49 @@ function createServerHandlers(registry, executor, config) {
1762
1945
  testIdPattern: body?.testIdPattern,
1763
1946
  accessibilityLabelPattern: body?.accessibilityLabelPattern,
1764
1947
  visibleOnly: body?.visibleOnly,
1765
- limit: body?.limit
1948
+ // When `query` is supplied we apply our own limit *after* ranking so
1949
+ // the highest-priority matches survive the cap; defer slicing to us
1950
+ // by widening here.
1951
+ limit: body?.query && body.query.length > 0 ? void 0 : body?.limit
1952
+ });
1953
+ const rawQuery = typeof body?.query === "string" ? body.query.trim() : "";
1954
+ if (rawQuery.length === 0) {
1955
+ return success2(response);
1956
+ }
1957
+ const needle = rawQuery.toLowerCase();
1958
+ const containsNeedle = (value) => typeof value === "string" && value.toLowerCase().includes(needle);
1959
+ const scored = [];
1960
+ for (const el of response.elements) {
1961
+ let priorityRank = Number.POSITIVE_INFINITY;
1962
+ if (containsNeedle(el.label)) {
1963
+ priorityRank = Math.min(priorityRank, 0);
1964
+ }
1965
+ const ident = el.identifier;
1966
+ if (ident) {
1967
+ if (containsNeedle(ident.testId)) priorityRank = Math.min(priorityRank, 1);
1968
+ if (containsNeedle(ident.accessibilityLabel)) priorityRank = Math.min(priorityRank, 1);
1969
+ if (containsNeedle(ident.treePath)) priorityRank = Math.min(priorityRank, 1);
1970
+ if (containsNeedle(ident.uiId)) priorityRank = Math.min(priorityRank, 1);
1971
+ }
1972
+ if (priorityRank === Number.POSITIVE_INFINITY) {
1973
+ const registered = registry.getElement(el.id);
1974
+ const handlers = extractHandlerNames(registered?.props);
1975
+ if (handlers.some((h) => h.toLowerCase().includes(needle))) {
1976
+ priorityRank = 2;
1977
+ }
1978
+ }
1979
+ if (priorityRank !== Number.POSITIVE_INFINITY) {
1980
+ scored.push({ priorityRank, element: el });
1981
+ }
1982
+ }
1983
+ scored.sort((a, b) => a.priorityRank - b.priorityRank);
1984
+ const limit = body?.limit && body.limit > 0 ? body.limit : void 0;
1985
+ const filtered = limit ? scored.slice(0, limit) : scored;
1986
+ return success2({
1987
+ ...response,
1988
+ elements: filtered.map((s) => s.element),
1989
+ total: filtered.length
1766
1990
  });
1767
- return success2(response);
1768
1991
  },
1769
1992
  getSnapshot: async (ctx) => {
1770
1993
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
@@ -1865,15 +2088,162 @@ function createServerHandlers(registry, executor, config) {
1865
2088
  getMethods: async () => {
1866
2089
  return error2("Methods introspection not configured", "NOT_SUPPORTED");
1867
2090
  },
2091
+ // AI helpers
2092
+ //
2093
+ // Mobile-side parity with the runner's `POST /ui-bridge/ai/fill-form`.
2094
+ // Each entry in `fields` is dispatched through the existing executor as a
2095
+ // `type` action with `clear: true` so the input value is replaced
2096
+ // atomically (the closest native equivalent to the web bridge's
2097
+ // "setValue" semantics — there is no `setValue` action on native).
2098
+ //
2099
+ // Failures do not short-circuit: every field is attempted, and the
2100
+ // response carries per-field success/error plus aggregate counts.
2101
+ fillForm: async (ctx) => {
2102
+ const body = ctx.body;
2103
+ const rawFields = body?.fields;
2104
+ if (!Array.isArray(rawFields)) {
2105
+ return error2('Request body must include "fields" array', "INVALID_REQUEST");
2106
+ }
2107
+ const results = [];
2108
+ for (let i = 0; i < rawFields.length; i++) {
2109
+ const field = rawFields[i];
2110
+ if (!field || typeof field !== "object") {
2111
+ results.push({
2112
+ elementId: "",
2113
+ success: false,
2114
+ error: `fields[${i}] must be an object with "elementId" and "value"`
2115
+ });
2116
+ continue;
2117
+ }
2118
+ const { elementId, value } = field;
2119
+ if (typeof elementId !== "string" || elementId.length === 0) {
2120
+ results.push({
2121
+ elementId: typeof elementId === "string" ? elementId : "",
2122
+ success: false,
2123
+ error: `fields[${i}] is missing a non-empty "elementId"`
2124
+ });
2125
+ continue;
2126
+ }
2127
+ if (typeof value !== "string") {
2128
+ results.push({
2129
+ elementId,
2130
+ success: false,
2131
+ error: `fields[${i}] is missing a string "value"`
2132
+ });
2133
+ continue;
2134
+ }
2135
+ try {
2136
+ const response = await executor.executeAction(elementId, {
2137
+ action: "type",
2138
+ params: { text: value, clear: true }
2139
+ });
2140
+ if (response.success) {
2141
+ results.push({ elementId, success: true });
2142
+ } else {
2143
+ results.push({
2144
+ elementId,
2145
+ success: false,
2146
+ error: response.error ?? "Action failed"
2147
+ });
2148
+ }
2149
+ } catch (err) {
2150
+ results.push({
2151
+ elementId,
2152
+ success: false,
2153
+ error: err instanceof Error ? err.message : String(err)
2154
+ });
2155
+ }
2156
+ }
2157
+ const succeededCount = results.filter((r) => r.success).length;
2158
+ const failedCount = results.length - succeededCount;
2159
+ return success2({ results, succeededCount, failedCount });
2160
+ },
2161
+ // Test hooks — modal stack manipulation
2162
+ //
2163
+ // These two endpoints exist so external automation runners can drive
2164
+ // ModalDetector state via HTTP, mirroring the in-tree `pushModal` /
2165
+ // `dismissModal` calls that components normally make. They're gated by
2166
+ // `features.testHooks` at the route layer (see http-server.ts); the
2167
+ // handlers themselves still defend against a missing modalDetector
2168
+ // because the bare server path used by CloudRelayClient may run before
2169
+ // enrichers are wired.
2170
+ pushModal: async (ctx) => {
2171
+ const detector = registry.getEnrichers().modalDetector;
2172
+ if (!detector) {
2173
+ return error2(
2174
+ "modal detector not registered",
2175
+ "ENRICHER_UNAVAILABLE"
2176
+ );
2177
+ }
2178
+ const body = ctx.body;
2179
+ const id = body?.id;
2180
+ if (typeof id !== "string" || id.length === 0) {
2181
+ return error2(
2182
+ 'pushModal requires a non-empty "id"',
2183
+ "INVALID_REQUEST"
2184
+ );
2185
+ }
2186
+ const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
2187
+ const requestedType = body?.type;
2188
+ if (requestedType !== void 0 && (typeof requestedType !== "string" || !allowedTypes.includes(requestedType))) {
2189
+ return error2(
2190
+ `pushModal "type" must be one of: ${allowedTypes.join(", ")}`,
2191
+ "INVALID_REQUEST"
2192
+ );
2193
+ }
2194
+ detector.pushModal({
2195
+ id,
2196
+ title: body?.title,
2197
+ // requestedType is narrowed by the allowedTypes guard above; the cast
2198
+ // converts the validated string to the NativeModalInfo['type'] union.
2199
+ type: requestedType,
2200
+ blocking: body?.blocking,
2201
+ dismissible: body?.dismissible
2202
+ });
2203
+ const stored = detector.getActive().find((m) => m.id === id);
2204
+ return success2({
2205
+ pushed: true,
2206
+ id,
2207
+ title: stored?.title,
2208
+ type: stored?.type,
2209
+ blocking: stored?.blocking,
2210
+ dismissible: stored?.dismissible,
2211
+ pushedAt: stored?.detectedAt ?? Date.now()
2212
+ });
2213
+ },
2214
+ dismissModal: async (ctx) => {
2215
+ const detector = registry.getEnrichers().modalDetector;
2216
+ if (!detector) {
2217
+ return error2(
2218
+ "modal detector not registered",
2219
+ "ENRICHER_UNAVAILABLE"
2220
+ );
2221
+ }
2222
+ const { id } = ctx.params;
2223
+ if (typeof id !== "string" || id.length === 0) {
2224
+ return error2(
2225
+ "dismissModal requires a modal id in the path",
2226
+ "INVALID_REQUEST"
2227
+ );
2228
+ }
2229
+ const wasPresent = detector.getActive().some((m) => m.id === id);
2230
+ if (!wasPresent) {
2231
+ return error2(`modal not found: ${id}`, "MODAL_NOT_FOUND");
2232
+ }
2233
+ detector.dismissModal(id);
2234
+ return success2({ dismissed: id });
2235
+ },
1868
2236
  // Design Review
1869
2237
  ...designHandlers,
1870
2238
  // Health
1871
2239
  health: async () => {
1872
2240
  const stats = registry.getStats();
2241
+ const registration = registry.getRegistrationCoverage();
1873
2242
  const response = {
1874
2243
  status: "healthy",
1875
2244
  timestamp: Date.now(),
1876
- ...stats
2245
+ ...stats,
2246
+ registration
1877
2247
  };
1878
2248
  if (config?.appInfo) {
1879
2249
  response.uiBridge = {
@@ -1974,7 +2344,24 @@ var WS_ROUTES = [
1974
2344
  { pattern: "design/evaluate/baseline", handler: "saveBaseline", httpMethods: ["POST"] },
1975
2345
  { pattern: "design/evaluate/diff", handler: "diffBaseline", httpMethods: ["POST"] },
1976
2346
  // Meta / Introspection
1977
- { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] }
2347
+ { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
2348
+ // AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
2349
+ { pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
2350
+ // Test hooks — gated behind `features.testHooks`. Let external runners
2351
+ // drive the ModalDetector over HTTP so tests can flip `snapshot.modalStack`
2352
+ // without mounting React components.
2353
+ {
2354
+ pattern: "control/modal/push",
2355
+ handler: "pushModal",
2356
+ httpMethods: ["POST"],
2357
+ requiresTestHooks: true
2358
+ },
2359
+ {
2360
+ pattern: "control/modal/dismiss/:id",
2361
+ handler: "dismissModal",
2362
+ httpMethods: ["POST"],
2363
+ requiresTestHooks: true
2364
+ }
1978
2365
  ];
1979
2366
  var HANDLER_DESCRIPTIONS = {
1980
2367
  health: "Liveness probe; returns app metadata + element count",
@@ -2007,7 +2394,10 @@ var HANDLER_DESCRIPTIONS = {
2007
2394
  getQualityContexts: "List available quality evaluation contexts",
2008
2395
  saveBaseline: "Save a quality baseline snapshot",
2009
2396
  diffBaseline: "Diff current UI against a saved baseline",
2010
- getMethods: "List every handler name (legacy introspection)"
2397
+ getMethods: "List every handler name (legacy introspection)",
2398
+ fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
2399
+ pushModal: "TEST HOOK \u2014 push a modal onto the snapshot.modalStack via the registry ModalDetector",
2400
+ dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector"
2011
2401
  };
2012
2402
  var ACTION_REFERENCE = [
2013
2403
  { name: "press", aliases: ["click"], params: "PressActionParams (optional position)" },
@@ -2024,10 +2414,11 @@ var ACTION_REFERENCE = [
2024
2414
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
2025
2415
  { name: "toggle", params: "none \u2014 flips boolean controls" }
2026
2416
  ];
2027
- function buildRoutesPayload() {
2417
+ function buildRoutesPayload(testHooks) {
2028
2418
  const entries = [];
2029
2419
  for (const r of WS_ROUTES) {
2030
2420
  if (!r.httpMethods) continue;
2421
+ if (r.requiresTestHooks && !testHooks) continue;
2031
2422
  for (const m of r.httpMethods) {
2032
2423
  entries.push({ method: m, path: `/ui-bridge/${r.pattern}` });
2033
2424
  }
@@ -2037,11 +2428,11 @@ function buildRoutesPayload() {
2037
2428
  entries.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
2038
2429
  return { routes: entries, count: entries.length };
2039
2430
  }
2040
- function buildHelpPayload() {
2431
+ function buildHelpPayload(testHooks) {
2041
2432
  return {
2042
2433
  version: 1,
2043
2434
  description: "UI Bridge Native HTTP surface. Prefix every path with /ui-bridge/.",
2044
- routes: WS_ROUTES.map((r) => ({
2435
+ routes: WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
2045
2436
  pattern: r.pattern,
2046
2437
  httpMethods: r.httpMethods ?? [],
2047
2438
  wsOnly: !r.httpMethods,
@@ -2077,7 +2468,8 @@ var NativeUIBridgeServer = class {
2077
2468
  appInfo: this.config.appInfo
2078
2469
  });
2079
2470
  this.handlers.getMethods = async () => {
2080
- const methods = WS_ROUTES.map((r) => ({
2471
+ const testHooks = this.config.testHooks === true;
2472
+ const methods = WS_ROUTES.filter((r) => !r.requiresTestHooks || testHooks).map((r) => ({
2081
2473
  method: r.pattern,
2082
2474
  httpMethods: r.httpMethods ? [...r.httpMethods] : void 0
2083
2475
  }));
@@ -2120,7 +2512,11 @@ var NativeUIBridgeServer = class {
2120
2512
  timestamp: Date.now()
2121
2513
  };
2122
2514
  } catch (e) {
2123
- return { success: false, error: `Navigation failed: ${e.message}`, timestamp: Date.now() };
2515
+ return {
2516
+ success: false,
2517
+ error: `Navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2518
+ timestamp: Date.now()
2519
+ };
2124
2520
  }
2125
2521
  };
2126
2522
  this.handlers.pageReplace = async (ctx) => {
@@ -2146,7 +2542,7 @@ var NativeUIBridgeServer = class {
2146
2542
  } catch (e) {
2147
2543
  return {
2148
2544
  success: false,
2149
- error: `Replace navigation failed: ${e.message}`,
2545
+ error: `Replace navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2150
2546
  timestamp: Date.now()
2151
2547
  };
2152
2548
  }
@@ -2165,7 +2561,7 @@ var NativeUIBridgeServer = class {
2165
2561
  } catch (e) {
2166
2562
  return {
2167
2563
  success: false,
2168
- error: `Back navigation failed: ${e.message}`,
2564
+ error: `Back navigation failed: ${e instanceof Error ? e.message : String(e)}`,
2169
2565
  timestamp: Date.now()
2170
2566
  };
2171
2567
  }
@@ -2186,7 +2582,11 @@ var NativeUIBridgeServer = class {
2186
2582
  timestamp: Date.now()
2187
2583
  };
2188
2584
  } catch (e) {
2189
- return { success: false, error: `Refresh failed: ${e.message}`, timestamp: Date.now() };
2585
+ return {
2586
+ success: false,
2587
+ error: `Refresh failed: ${e instanceof Error ? e.message : String(e)}`,
2588
+ timestamp: Date.now()
2589
+ };
2190
2590
  }
2191
2591
  };
2192
2592
  }
@@ -2244,7 +2644,7 @@ var NativeUIBridgeServer = class {
2244
2644
  } catch (e) {
2245
2645
  return {
2246
2646
  success: false,
2247
- error: `Screenshot capture failed: ${e.message}`,
2647
+ error: `Screenshot capture failed: ${e instanceof Error ? e.message : String(e)}`,
2248
2648
  timestamp: Date.now()
2249
2649
  };
2250
2650
  }
@@ -2799,9 +3199,11 @@ var NativeUIBridgeServer = class {
2799
3199
  * "control/component/my-comp/action/submit" → handlers.executeComponentAction
2800
3200
  */
2801
3201
  async routeMethodToHandler(method, params) {
3202
+ const testHooks = this.config.testHooks === true;
2802
3203
  for (const route of WS_ROUTES) {
2803
3204
  const pathParams = parsePath(route.pattern, method);
2804
3205
  if (!pathParams) continue;
3206
+ if (route.requiresTestHooks && !testHooks) continue;
2805
3207
  const ctx = {
2806
3208
  params: pathParams,
2807
3209
  query: params.query && typeof params.query === "object" ? params.query : {},
@@ -2879,17 +3281,18 @@ var NativeUIBridgeServer = class {
2879
3281
  };
2880
3282
  }
2881
3283
  const stripped = path.slice(PREFIX.length);
3284
+ const testHooks = this.config.testHooks === true;
2882
3285
  if (method === "GET" && stripped === "_help") {
2883
3286
  return {
2884
3287
  success: true,
2885
- data: buildHelpPayload(),
3288
+ data: buildHelpPayload(testHooks),
2886
3289
  timestamp: Date.now()
2887
3290
  };
2888
3291
  }
2889
3292
  if (method === "GET" && stripped === "_routes") {
2890
3293
  return {
2891
3294
  success: true,
2892
- data: buildRoutesPayload(),
3295
+ data: buildRoutesPayload(testHooks),
2893
3296
  timestamp: Date.now()
2894
3297
  };
2895
3298
  }
@@ -2900,6 +3303,9 @@ var NativeUIBridgeServer = class {
2900
3303
  if (!route.httpMethods) {
2901
3304
  continue;
2902
3305
  }
3306
+ if (route.requiresTestHooks && !testHooks) {
3307
+ continue;
3308
+ }
2903
3309
  if (!route.httpMethods.includes(method)) {
2904
3310
  patternMatchedButWrongVerb = true;
2905
3311
  continue;
@@ -3193,6 +3599,27 @@ var WebSocketEventBridge = class {
3193
3599
  }, this.heartbeatIntervalMs);
3194
3600
  }
3195
3601
  };
3602
+
3603
+ // src/transport/logger.ts
3604
+ function createTransportLogger() {
3605
+ return {
3606
+ silent: false,
3607
+ log(message, ...args) {
3608
+ if (!this.silent) {
3609
+ console.log(message, ...args);
3610
+ }
3611
+ },
3612
+ warn(message, ...args) {
3613
+ console.warn(message, ...args);
3614
+ },
3615
+ error(message, ...args) {
3616
+ console.error(message, ...args);
3617
+ }
3618
+ };
3619
+ }
3620
+ var transportLogger = createTransportLogger();
3621
+
3622
+ // src/transport/DeviceAnnouncer.ts
3196
3623
  var DEFAULT_PORT = 8087;
3197
3624
  var RECONNECT_INITIAL_DELAY_MS = 2e3;
3198
3625
  var RECONNECT_MAX_DELAY_MS = 6e4;
@@ -3227,7 +3654,7 @@ var DeviceAnnouncer = class {
3227
3654
  */
3228
3655
  async startMdnsAdvertise(ZeroconfCtor) {
3229
3656
  if (!ZeroconfCtor) {
3230
- console.log(
3657
+ transportLogger.log(
3231
3658
  "[DeviceAnnouncer] mDNS advertisement skipped (no Zeroconf constructor provided). Pass one to enable mDNS."
3232
3659
  );
3233
3660
  return;
@@ -3243,9 +3670,9 @@ var DeviceAnnouncer = class {
3243
3670
  pairing_token: this.pairingToken
3244
3671
  });
3245
3672
  this.state = { ...this.state, mdnsActive: true };
3246
- console.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
3673
+ transportLogger.log(`[DeviceAnnouncer] mDNS: advertising "${serviceName}" on port ${port}`);
3247
3674
  } catch (err) {
3248
- console.warn("[DeviceAnnouncer] mDNS start failed:", err);
3675
+ transportLogger.warn("[DeviceAnnouncer] mDNS start failed:", err);
3249
3676
  }
3250
3677
  }
3251
3678
  // ── Cloud relay ───────────────────────────────────────────────────────────
@@ -3261,7 +3688,7 @@ var DeviceAnnouncer = class {
3261
3688
  }
3262
3689
  if (this.stopped) return;
3263
3690
  const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
3264
- console.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
3691
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
3265
3692
  const ws = new WebSocket(url);
3266
3693
  this.cloudWs = ws;
3267
3694
  ws.onopen = () => {
@@ -3271,7 +3698,7 @@ var DeviceAnnouncer = class {
3271
3698
  }
3272
3699
  this.reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
3273
3700
  this.state = { ...this.state, cloudConnected: true };
3274
- console.log("[DeviceAnnouncer] Cloud relay: connected");
3701
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connected");
3275
3702
  ws.send(
3276
3703
  JSON.stringify({
3277
3704
  type: "device_register",
@@ -3286,7 +3713,7 @@ var DeviceAnnouncer = class {
3286
3713
  ws.onmessage = (event) => {
3287
3714
  try {
3288
3715
  const msg = JSON.parse(event.data);
3289
- console.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
3716
+ transportLogger.log("[DeviceAnnouncer] Cloud relay message:", msg.type);
3290
3717
  } catch {
3291
3718
  }
3292
3719
  };
@@ -3297,7 +3724,7 @@ var DeviceAnnouncer = class {
3297
3724
  }
3298
3725
  };
3299
3726
  ws.onerror = (err) => {
3300
- console.warn("[DeviceAnnouncer] Cloud relay error:", err);
3727
+ transportLogger.warn("[DeviceAnnouncer] Cloud relay error:", err);
3301
3728
  };
3302
3729
  }
3303
3730
  // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -3332,7 +3759,7 @@ var DeviceAnnouncer = class {
3332
3759
  scheduleReconnect() {
3333
3760
  if (this.stopped) return;
3334
3761
  const delay = this.reconnectDelay;
3335
- console.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
3762
+ transportLogger.log(`[DeviceAnnouncer] Cloud relay: reconnecting in ${delay}ms`);
3336
3763
  this.reconnectTimer = setTimeout(() => {
3337
3764
  this.reconnectTimer = null;
3338
3765
  if (!this.stopped) {
@@ -3394,7 +3821,7 @@ var CloudRelayClient = class {
3394
3821
  connect() {
3395
3822
  if (this.stopped) return;
3396
3823
  const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
3397
- console.log("[CloudRelayClient] Connecting to", url);
3824
+ transportLogger.log("[CloudRelayClient] Connecting to", url);
3398
3825
  const ws = new WebSocket(url);
3399
3826
  this.ws = ws;
3400
3827
  ws.onopen = () => {
@@ -3404,7 +3831,7 @@ var CloudRelayClient = class {
3404
3831
  }
3405
3832
  this._isConnected = true;
3406
3833
  this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
3407
- console.log("[CloudRelayClient] Connected to relay");
3834
+ transportLogger.log("[CloudRelayClient] Connected to relay");
3408
3835
  ws.send(
3409
3836
  JSON.stringify({
3410
3837
  type: "device_register",
@@ -3422,7 +3849,7 @@ var CloudRelayClient = class {
3422
3849
  }
3423
3850
  };
3424
3851
  ws.onerror = (err) => {
3425
- console.warn("[CloudRelayClient] WebSocket error:", err);
3852
+ transportLogger.warn("[CloudRelayClient] WebSocket error:", err);
3426
3853
  };
3427
3854
  }
3428
3855
  async onMessage(raw) {
@@ -3430,12 +3857,12 @@ var CloudRelayClient = class {
3430
3857
  try {
3431
3858
  msg = JSON.parse(raw);
3432
3859
  } catch {
3433
- console.warn("[CloudRelayClient] Received non-JSON message");
3860
+ transportLogger.warn("[CloudRelayClient] Received non-JSON message");
3434
3861
  return;
3435
3862
  }
3436
3863
  if (msg["type"] !== "tunnel_request") return;
3437
3864
  if (typeof msg["id"] !== "string" || typeof msg["method"] !== "string" || typeof msg["path"] !== "string") {
3438
- console.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
3865
+ transportLogger.warn("[CloudRelayClient] Malformed tunnel_request \u2014 missing required fields");
3439
3866
  return;
3440
3867
  }
3441
3868
  const req = {
@@ -3476,7 +3903,7 @@ var CloudRelayClient = class {
3476
3903
  body: bodyValue
3477
3904
  };
3478
3905
  } catch (err) {
3479
- console.error("[CloudRelayClient] Error handling tunneled request:", err);
3906
+ transportLogger.error("[CloudRelayClient] Error handling tunneled request:", err);
3480
3907
  return {
3481
3908
  id: req.id,
3482
3909
  status: 500,
@@ -3491,7 +3918,7 @@ var CloudRelayClient = class {
3491
3918
  scheduleReconnect() {
3492
3919
  if (this.stopped) return;
3493
3920
  const delay = this.reconnectDelay;
3494
- console.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
3921
+ transportLogger.log(`[CloudRelayClient] Reconnecting in ${delay}ms`);
3495
3922
  this.reconnectTimer = setTimeout(() => {
3496
3923
  this.reconnectTimer = null;
3497
3924
  if (!this.stopped) {
@@ -3514,6 +3941,192 @@ function parseQueryParams(path) {
3514
3941
  }
3515
3942
  return result;
3516
3943
  }
3944
+
3945
+ // src/modal/modal-detector.ts
3946
+ var ModalDetector = class {
3947
+ constructor() {
3948
+ /** Map preserves O(1) idempotent replace by id; ordering comes from detectedAt. */
3949
+ this.modals = /* @__PURE__ */ new Map();
3950
+ }
3951
+ pushModal(modal) {
3952
+ const existing = this.modals.get(modal.id);
3953
+ const detectedAt = existing ? existing.detectedAt : Date.now();
3954
+ const next = {
3955
+ id: modal.id,
3956
+ title: modal.title,
3957
+ type: modal.type ?? "modal",
3958
+ blocking: modal.blocking ?? true,
3959
+ dismissible: modal.dismissible ?? true,
3960
+ detectedAt
3961
+ };
3962
+ this.modals.set(modal.id, next);
3963
+ }
3964
+ /**
3965
+ * Remove a modal from the stack. Returns `true` if a modal with that id
3966
+ * was actually deleted, `false` if nothing matched (idempotent so repeated
3967
+ * calls don't throw, but callers — e.g. the HTTP test-hook endpoint — can
3968
+ * surface the miss to clients).
3969
+ */
3970
+ dismissModal(id) {
3971
+ return this.modals.delete(id);
3972
+ }
3973
+ getSnapshotModalContext() {
3974
+ const active = this.getActive();
3975
+ const ctx = {
3976
+ modals: active,
3977
+ hasBlockingModal: active.some((m) => m.blocking),
3978
+ count: active.length
3979
+ };
3980
+ if (active.length > 0) {
3981
+ ctx.topModal = active[active.length - 1];
3982
+ }
3983
+ return ctx;
3984
+ }
3985
+ getActive() {
3986
+ return Array.from(this.modals.values()).sort((a, b) => a.detectedAt - b.detectedAt);
3987
+ }
3988
+ clear() {
3989
+ this.modals.clear();
3990
+ }
3991
+ };
3992
+
3993
+ // src/toast/toast-capture.ts
3994
+ var DEFAULT_MAX_RECENT = 50;
3995
+ var ToastCapture = class {
3996
+ constructor(config = {}) {
3997
+ this.active = /* @__PURE__ */ new Map();
3998
+ this.recent = [];
3999
+ this.totalCaptured = 0;
4000
+ this.maxRecent = config.maxRecent ?? DEFAULT_MAX_RECENT;
4001
+ }
4002
+ recordToast(toast) {
4003
+ if (this.active.has(toast.id)) {
4004
+ return;
4005
+ }
4006
+ const appearedAt = toast.appearedAt ?? Date.now();
4007
+ const entry = {
4008
+ id: toast.id,
4009
+ message: toast.message,
4010
+ level: toast.level ?? "info",
4011
+ appearedAt,
4012
+ visible: true,
4013
+ durationMs: 0
4014
+ };
4015
+ this.active.set(toast.id, entry);
4016
+ this.totalCaptured += 1;
4017
+ }
4018
+ dismissToast(id) {
4019
+ const entry = this.active.get(id);
4020
+ if (!entry) {
4021
+ return;
4022
+ }
4023
+ this.active.delete(id);
4024
+ const dismissedAt = Date.now();
4025
+ const dismissed = {
4026
+ ...entry,
4027
+ dismissedAt,
4028
+ visible: false,
4029
+ durationMs: dismissedAt - entry.appearedAt
4030
+ };
4031
+ this.recent.push(dismissed);
4032
+ if (this.recent.length > this.maxRecent) {
4033
+ this.recent.shift();
4034
+ }
4035
+ }
4036
+ getSnapshotToastContext() {
4037
+ const now = Date.now();
4038
+ const active = Array.from(this.active.values()).map((entry) => ({
4039
+ ...entry,
4040
+ durationMs: now - entry.appearedAt
4041
+ }));
4042
+ return {
4043
+ active,
4044
+ recent: [...this.recent],
4045
+ totalCaptured: this.totalCaptured
4046
+ };
4047
+ }
4048
+ clear() {
4049
+ this.active.clear();
4050
+ this.recent = [];
4051
+ this.totalCaptured = 0;
4052
+ }
4053
+ };
4054
+
4055
+ // src/undo/undo-tracker.ts
4056
+ var DEFAULT_MAX_HISTORY = 50;
4057
+ function buildSummary(ctx) {
4058
+ if (!ctx.canUndo && !ctx.canRedo) {
4059
+ return "no undo available";
4060
+ }
4061
+ const parts = [];
4062
+ if (ctx.canUndo) {
4063
+ parts.push(`undo: '${ctx.undoDescription ?? "available"}'`);
4064
+ }
4065
+ if (ctx.canRedo) {
4066
+ parts.push(`redo: '${ctx.redoDescription ?? "available"}'`);
4067
+ }
4068
+ return parts.join(" / ");
4069
+ }
4070
+ var UndoTracker = class {
4071
+ constructor(config = {}) {
4072
+ this.actions = [];
4073
+ this.declaredState = null;
4074
+ this.maxHistory = config.maxHistory ?? DEFAULT_MAX_HISTORY;
4075
+ }
4076
+ recordAction(record) {
4077
+ this.actions.push(record);
4078
+ if (this.actions.length > this.maxHistory) {
4079
+ this.actions = this.actions.slice(-this.maxHistory);
4080
+ }
4081
+ }
4082
+ setDeclaredState(state) {
4083
+ this.declaredState = state;
4084
+ }
4085
+ getActionHistory() {
4086
+ return this.actions.slice();
4087
+ }
4088
+ getSnapshotUndoContext() {
4089
+ const declared = this.declaredState;
4090
+ if (declared) {
4091
+ const summary2 = buildSummary({
4092
+ canUndo: declared.canUndo,
4093
+ canRedo: declared.canRedo,
4094
+ undoDescription: declared.undoDescription,
4095
+ redoDescription: declared.redoDescription
4096
+ });
4097
+ return {
4098
+ canUndo: declared.canUndo,
4099
+ canRedo: declared.canRedo,
4100
+ undoDescription: declared.undoDescription,
4101
+ redoDescription: declared.redoDescription,
4102
+ undoDepth: declared.undoDepth,
4103
+ redoDepth: declared.redoDepth,
4104
+ summary: summary2
4105
+ };
4106
+ }
4107
+ const canUndo = this.actions.some((a) => a.reversible !== false);
4108
+ const canRedo = false;
4109
+ const lastReversible = [...this.actions].reverse().find((a) => a.reversible !== false);
4110
+ const undoDescription = lastReversible?.description;
4111
+ const summary = buildSummary({ canUndo, canRedo, undoDescription });
4112
+ return {
4113
+ canUndo,
4114
+ canRedo,
4115
+ undoDescription,
4116
+ summary
4117
+ };
4118
+ }
4119
+ executeUndo() {
4120
+ return { ok: false, reason: "Not implemented in native" };
4121
+ }
4122
+ executeRedo() {
4123
+ return { ok: false, reason: "Not implemented in native" };
4124
+ }
4125
+ clear() {
4126
+ this.actions = [];
4127
+ this.declaredState = null;
4128
+ }
4129
+ };
3517
4130
  var UIBridgeNativeContext = react.createContext(null);
3518
4131
  function UIBridgeNativeProvider({
3519
4132
  children,
@@ -3530,6 +4143,9 @@ function UIBridgeNativeProvider({
3530
4143
  }) {
3531
4144
  const registryRef = react.useRef(null);
3532
4145
  const executorRef = react.useRef(null);
4146
+ const modalDetectorRef = react.useRef(null);
4147
+ const toastCaptureRef = react.useRef(null);
4148
+ const undoTrackerRef = react.useRef(null);
3533
4149
  const [serverRunning, setServerRunning] = react.useState(false);
3534
4150
  if (!registryRef.current) {
3535
4151
  registryRef.current = new NativeUIBridgeRegistry({
@@ -3543,6 +4159,12 @@ function UIBridgeNativeProvider({
3543
4159
  executorRef.current = createNativeActionExecutor(registry);
3544
4160
  }
3545
4161
  const executor = executorRef.current;
4162
+ if (!modalDetectorRef.current) modalDetectorRef.current = new ModalDetector();
4163
+ if (!toastCaptureRef.current) toastCaptureRef.current = new ToastCapture();
4164
+ if (!undoTrackerRef.current) undoTrackerRef.current = new UndoTracker();
4165
+ const modalDetector = modalDetectorRef.current;
4166
+ const toastCapture = toastCaptureRef.current;
4167
+ const undoTracker = undoTrackerRef.current;
3546
4168
  const serverRef = react.useRef(null);
3547
4169
  const eventBridgeRef = react.useRef(null);
3548
4170
  const announcerRef = react.useRef(null);
@@ -3558,7 +4180,8 @@ function UIBridgeNativeProvider({
3558
4180
  const server = createNativeServer(registry, executor, {
3559
4181
  serverPort: config.serverPort || 8087,
3560
4182
  cors: true,
3561
- appInfo: config.appInfo
4183
+ appInfo: config.appInfo,
4184
+ testHooks: features.testHooks === true
3562
4185
  });
3563
4186
  server.setAdapter(serverAdapter);
3564
4187
  if (navigationProvider) {
@@ -3612,7 +4235,9 @@ function UIBridgeNativeProvider({
3612
4235
  }
3613
4236
  }, [
3614
4237
  features.server,
4238
+ features.testHooks,
3615
4239
  config.serverPort,
4240
+ config.appInfo,
3616
4241
  registry,
3617
4242
  executor,
3618
4243
  serverAdapter,
@@ -3646,7 +4271,8 @@ function UIBridgeNativeProvider({
3646
4271
  const bareServer = createNativeServer(registry, executor, {
3647
4272
  serverPort: config.serverPort || 8087,
3648
4273
  cors: true,
3649
- appInfo: config.appInfo
4274
+ appInfo: config.appInfo,
4275
+ testHooks: features.testHooks === true
3650
4276
  });
3651
4277
  if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
3652
4278
  if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
@@ -3731,6 +4357,22 @@ function UIBridgeNativeProvider({
3731
4357
  lastRoute = current;
3732
4358
  });
3733
4359
  }, [routeProvider, registry]);
4360
+ react.useEffect(() => {
4361
+ registry.setEnrichers({ modalDetector, toastCapture, undoTracker });
4362
+ }, [registry, modalDetector, toastCapture, undoTracker]);
4363
+ react.useEffect(() => {
4364
+ const unsubscribe = executor.onActionExecuted((event) => {
4365
+ if (!event.success) return;
4366
+ undoTracker.recordAction({
4367
+ id: event.requestId ?? `action-${event.timestamp}`,
4368
+ type: event.action,
4369
+ targetId: event.elementId,
4370
+ timestamp: event.timestamp,
4371
+ reversible: true
4372
+ });
4373
+ });
4374
+ return unsubscribe;
4375
+ }, [executor, undoTracker]);
3734
4376
  const on = react.useCallback(
3735
4377
  (type, listener) => registry.on(type, listener),
3736
4378
  [registry]
@@ -3745,6 +4387,9 @@ function UIBridgeNativeProvider({
3745
4387
  config,
3746
4388
  registry,
3747
4389
  executor,
4390
+ modalDetector,
4391
+ toastCapture,
4392
+ undoTracker,
3748
4393
  getElements,
3749
4394
  getComponents,
3750
4395
  createSnapshot,
@@ -3761,6 +4406,9 @@ function UIBridgeNativeProvider({
3761
4406
  config,
3762
4407
  registry,
3763
4408
  executor,
4409
+ modalDetector,
4410
+ toastCapture,
4411
+ undoTracker,
3764
4412
  getElements,
3765
4413
  getComponents,
3766
4414
  createSnapshot,
@@ -3855,7 +4503,7 @@ function useUIElement(options) {
3855
4503
  registeredRef.current = false;
3856
4504
  registeredIdRef.current = null;
3857
4505
  setRegistered(false);
3858
- }, [bridge, registered, id]);
4506
+ }, [bridge, id]);
3859
4507
  const onLayout = react.useCallback(
3860
4508
  (event) => {
3861
4509
  const { x, y, width, height } = event.nativeEvent.layout;
@@ -4121,7 +4769,7 @@ function useUIComponent(options) {
4121
4769
  const registeredComponent = react.useMemo(() => {
4122
4770
  if (!bridge) return null;
4123
4771
  return bridge.registry.getComponent(id) || null;
4124
- }, [bridge, id, registeredRef.current]);
4772
+ }, [bridge, id]);
4125
4773
  return {
4126
4774
  registered: registeredRef.current,
4127
4775
  executeAction,
@@ -4265,11 +4913,12 @@ function useAutoRegister(options = {}) {
4265
4913
  [bridge, onUnregister]
4266
4914
  );
4267
4915
  react.useEffect(() => {
4916
+ const registeredIds = registeredIdsRef.current;
4268
4917
  return () => {
4269
- registeredIdsRef.current.forEach((id) => {
4918
+ registeredIds.forEach((id) => {
4270
4919
  bridge?.registry.unregisterElement(id);
4271
4920
  });
4272
- registeredIdsRef.current.clear();
4921
+ registeredIds.clear();
4273
4922
  };
4274
4923
  }, [bridge]);
4275
4924
  return {
@@ -4278,14 +4927,48 @@ function useAutoRegister(options = {}) {
4278
4927
  isEnabled: enabled && !!bridge?.registry
4279
4928
  };
4280
4929
  }
4930
+ function useUIBridgeModal(opts) {
4931
+ const { modalDetector } = useUIBridgeNative();
4932
+ const { id, title, type, blocking, dismissible } = opts;
4933
+ react.useEffect(() => {
4934
+ modalDetector.pushModal({ id, title, type, blocking, dismissible });
4935
+ return () => {
4936
+ modalDetector.dismissModal(id);
4937
+ };
4938
+ }, [modalDetector, id, title, type, blocking, dismissible]);
4939
+ }
4940
+ function useUIBridgeToast(opts) {
4941
+ const { toastCapture } = useUIBridgeNative();
4942
+ const { id, message, level } = opts;
4943
+ react.useEffect(() => {
4944
+ toastCapture.recordToast({ id, message, level });
4945
+ return () => {
4946
+ toastCapture.dismissToast(id);
4947
+ };
4948
+ }, [toastCapture, id, message, level]);
4949
+ }
4950
+ function useToastRecorder() {
4951
+ const { toastCapture } = useUIBridgeNative();
4952
+ const recordToast = react.useCallback(
4953
+ (toast) => {
4954
+ toastCapture.recordToast(toast);
4955
+ },
4956
+ [toastCapture]
4957
+ );
4958
+ const dismissToast = react.useCallback((id) => toastCapture.dismissToast(id), [toastCapture]);
4959
+ return { recordToast, dismissToast };
4960
+ }
4281
4961
 
4282
4962
  exports.UIBridgeNativeProvider = UIBridgeNativeProvider;
4283
4963
  exports.useAutoRegister = useAutoRegister;
4964
+ exports.useToastRecorder = useToastRecorder;
4284
4965
  exports.useUIBridge = useUIBridge;
4966
+ exports.useUIBridgeModal = useUIBridgeModal;
4285
4967
  exports.useUIBridgeNative = useUIBridgeNative;
4286
4968
  exports.useUIBridgeNativeOptional = useUIBridgeNativeOptional;
4287
4969
  exports.useUIBridgeNativeRequired = useUIBridgeNativeRequired;
4288
4970
  exports.useUIBridgeRequired = useUIBridgeRequired;
4971
+ exports.useUIBridgeToast = useUIBridgeToast;
4289
4972
  exports.useUIComponent = useUIComponent;
4290
4973
  exports.useUIComponentAction = useUIComponentAction;
4291
4974
  exports.useUIElement = useUIElement;