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