@qontinui/ui-bridge-native 0.6.11 → 0.6.13

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 (63) hide show
  1. package/README.md +36 -16
  2. package/dist/control/action-executor.d.ts +42 -2
  3. package/dist/control/action-executor.d.ts.map +1 -1
  4. package/dist/control/index.js +723 -4
  5. package/dist/control/index.js.map +1 -1
  6. package/dist/control/index.mjs +723 -5
  7. package/dist/control/index.mjs.map +1 -1
  8. package/dist/control/types.d.ts +82 -1
  9. package/dist/control/types.d.ts.map +1 -1
  10. package/dist/core/abortable.d.ts +151 -0
  11. package/dist/core/abortable.d.ts.map +1 -0
  12. package/dist/core/action-effect.d.ts +74 -0
  13. package/dist/core/action-effect.d.ts.map +1 -0
  14. package/dist/core/element-actions.d.ts +38 -0
  15. package/dist/core/element-actions.d.ts.map +1 -0
  16. package/dist/core/index.d.ts +4 -0
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/core/index.js +853 -15
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/index.mjs +835 -16
  21. package/dist/core/index.mjs.map +1 -1
  22. package/dist/core/param-schema.d.ts +282 -0
  23. package/dist/core/param-schema.d.ts.map +1 -0
  24. package/dist/core/registry.d.ts +317 -2
  25. package/dist/core/registry.d.ts.map +1 -1
  26. package/dist/core/types.d.ts +231 -32
  27. package/dist/core/types.d.ts.map +1 -1
  28. package/dist/debug/index.js.map +1 -1
  29. package/dist/debug/index.mjs.map +1 -1
  30. package/dist/index.d.ts +10 -5
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1603 -118
  33. package/dist/index.js.map +1 -1
  34. package/dist/index.mjs +1585 -119
  35. package/dist/index.mjs.map +1 -1
  36. package/dist/react/UIBridgeNativeProvider.d.ts +1 -23
  37. package/dist/react/UIBridgeNativeProvider.d.ts.map +1 -1
  38. package/dist/react/index.js +1420 -113
  39. package/dist/react/index.js.map +1 -1
  40. package/dist/react/index.mjs +1420 -113
  41. package/dist/react/index.mjs.map +1 -1
  42. package/dist/react/useUIBridge.d.ts +19 -22
  43. package/dist/react/useUIBridge.d.ts.map +1 -1
  44. package/dist/react/useUIComponent.d.ts +113 -8
  45. package/dist/react/useUIComponent.d.ts.map +1 -1
  46. package/dist/react/useUIElement.d.ts +34 -8
  47. package/dist/react/useUIElement.d.ts.map +1 -1
  48. package/dist/server/handlers.d.ts.map +1 -1
  49. package/dist/server/http-server.d.ts +15 -2
  50. package/dist/server/http-server.d.ts.map +1 -1
  51. package/dist/server/index.js +348 -43
  52. package/dist/server/index.js.map +1 -1
  53. package/dist/server/index.mjs +348 -44
  54. package/dist/server/index.mjs.map +1 -1
  55. package/dist/server/observability.d.ts +40 -0
  56. package/dist/server/observability.d.ts.map +1 -1
  57. package/dist/server/types.d.ts +57 -1
  58. package/dist/server/types.d.ts.map +1 -1
  59. package/dist/transport/CloudRelayClient.d.ts.map +1 -1
  60. package/dist/transport/DeviceAnnouncer.d.ts.map +1 -1
  61. package/dist/transport/relay-logging.d.ts +50 -0
  62. package/dist/transport/relay-logging.d.ts.map +1 -0
  63. package/package.json +2 -2
@@ -9,12 +9,31 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
9
9
 
10
10
  // src/server/types.ts
11
11
  var UI_BRIDGE_NATIVE_ROUTES = {
12
+ // Discovery / introspection — the two endpoints an agent hits first to learn
13
+ // the rest of this table without reading docs. Special-cased in
14
+ // `routeRequest` rather than living in `WS_ROUTES`, and appended by
15
+ // `buildRoutesPayload` so `_routes` describes itself.
16
+ HELP: {
17
+ method: "GET",
18
+ path: "/ui-bridge/_help",
19
+ description: "Route index + action reference + tips (rich discovery payload)"
20
+ },
21
+ ROUTES: {
22
+ method: "GET",
23
+ path: "/ui-bridge/_routes",
24
+ description: "Minimal {method, path} route list, matching the runner /_routes shape"
25
+ },
12
26
  // Control - Elements
13
27
  GET_ELEMENTS: {
14
28
  method: "GET",
15
29
  path: "/ui-bridge/control/elements",
16
30
  description: "List all registered elements"
17
31
  },
32
+ GET_ELEMENTS_FLAT: {
33
+ method: "GET",
34
+ path: "/ui-bridge/elements",
35
+ description: "List all registered elements (flat alias matching the runner path)"
36
+ },
18
37
  GET_ELEMENT: {
19
38
  method: "GET",
20
39
  path: "/ui-bridge/control/element/:id",
@@ -52,6 +71,11 @@ var UI_BRIDGE_NATIVE_ROUTES = {
52
71
  path: "/ui-bridge/control/find",
53
72
  description: "Find elements matching criteria"
54
73
  },
74
+ AI_FIND: {
75
+ method: "POST",
76
+ path: "/ui-bridge/ai/find",
77
+ description: "Find elements matching criteria (runner-parity alias for /control/find)"
78
+ },
55
79
  GET_SNAPSHOT: {
56
80
  method: "GET",
57
81
  path: "/ui-bridge/control/snapshot",
@@ -62,11 +86,78 @@ var UI_BRIDGE_NATIVE_ROUTES = {
62
86
  path: "/ui-bridge/control/discover",
63
87
  description: "Discover elements (alias for snapshot; web/runner parity)"
64
88
  },
89
+ DISCOVER_POST: {
90
+ method: "POST",
91
+ path: "/ui-bridge/control/discover",
92
+ description: "Discover elements over POST (same handler as the GET form; web/runner parity)"
93
+ },
65
94
  PAGE_HEALTH: {
66
95
  method: "POST",
67
96
  path: "/ui-bridge/control/page-health",
68
97
  description: "Holistic page health diagnostic: spatial coverage, layout regions, text signals, interactive readiness, anomalies + ASCII heatmap"
69
98
  },
99
+ PAGE_HEALTH_GET: {
100
+ method: "GET",
101
+ path: "/ui-bridge/control/page-health",
102
+ description: "Page health diagnostic over GET, for ad-hoc curls with no body"
103
+ },
104
+ // Screenshots — GET and POST both accepted so a curl works either way.
105
+ //
106
+ // Both keys carry an explicit verb suffix. The pre-existing multi-verb pairs
107
+ // do not agree on what an UNSUFFIXED key means — `DISCOVER` is the GET and
108
+ // `PAGE_HEALTH` is the POST — so a consumer reading `ROUTES.<NAME>.method`
109
+ // off a bare name gets opposite verbs from equally unmarked keys. Those two
110
+ // names are load-bearing for existing consumers and are left alone; new
111
+ // multi-verb entries name their verb rather than entrenching the ambiguity.
112
+ SCREENSHOT_GET: {
113
+ method: "GET",
114
+ path: "/ui-bridge/control/screenshot",
115
+ description: "PNG screenshot of the current screen"
116
+ },
117
+ SCREENSHOT_POST: {
118
+ method: "POST",
119
+ path: "/ui-bridge/control/screenshot",
120
+ description: "PNG screenshot of the current screen (POST form)"
121
+ },
122
+ // Coordinate-based interaction
123
+ CONTROL_TAP: {
124
+ method: "POST",
125
+ path: "/ui-bridge/control/tap",
126
+ description: "Synthesize a press at screen coords by matching registered element layout rects: body { x, y, action? }"
127
+ },
128
+ // App-agnostic interaction parity with the web/runner surface. The
129
+ // selector-based pair has no RN analog and answers NOT_SUPPORTED (501).
130
+ PAGE_CLICK_BY_TEXT: {
131
+ method: "POST",
132
+ path: "/ui-bridge/control/page/click-by-text",
133
+ description: "Press the first element matching free text: body { text, visibleOnly? }"
134
+ },
135
+ PAGE_CLICK_BY_SELECTOR: {
136
+ method: "POST",
137
+ path: "/ui-bridge/control/page/click-by-selector",
138
+ description: "Selector-based press (web-only; mobile returns NOT_SUPPORTED)"
139
+ },
140
+ PAGE_TYPE_INTO: {
141
+ method: "POST",
142
+ path: "/ui-bridge/control/page/type-into",
143
+ description: "Type into the input matching a label: body { label, text, clear?, visibleOnly? }"
144
+ },
145
+ PAGE_READ_VALUE: {
146
+ method: "POST",
147
+ path: "/ui-bridge/control/page/read-value",
148
+ description: "Selector-based value read (web-only; mobile returns NOT_SUPPORTED)"
149
+ },
150
+ PAGE_FIND_BY_TEXT: {
151
+ method: "POST",
152
+ path: "/ui-bridge/control/page/find-by-text",
153
+ description: "List elements matching free text without pressing: body { text, visibleOnly? }"
154
+ },
155
+ // Meta
156
+ META_METHODS: {
157
+ method: "GET",
158
+ path: "/ui-bridge/meta/methods",
159
+ description: "List every handler name (legacy introspection; prefer /_routes)"
160
+ },
70
161
  // Workflows
71
162
  GET_WORKFLOWS: {
72
163
  method: "GET",
@@ -145,6 +236,27 @@ var UI_BRIDGE_NATIVE_ROUTES = {
145
236
  path: "/ui-bridge/design/style-guide",
146
237
  description: "Clear loaded style guide"
147
238
  },
239
+ // Quality Evaluation
240
+ DESIGN_EVALUATE: {
241
+ method: "POST",
242
+ path: "/ui-bridge/design/evaluate",
243
+ description: "Score the current UI against a quality context"
244
+ },
245
+ DESIGN_EVALUATE_CONTEXTS: {
246
+ method: "GET",
247
+ path: "/ui-bridge/design/evaluate/contexts",
248
+ description: "List available quality evaluation contexts"
249
+ },
250
+ DESIGN_EVALUATE_BASELINE: {
251
+ method: "POST",
252
+ path: "/ui-bridge/design/evaluate/baseline",
253
+ description: "Save a quality baseline snapshot"
254
+ },
255
+ DESIGN_EVALUATE_DIFF: {
256
+ method: "POST",
257
+ path: "/ui-bridge/design/evaluate/diff",
258
+ description: "Diff the current UI against a saved baseline"
259
+ },
148
260
  // AI helpers
149
261
  AI_FILL_FORM: {
150
262
  method: "POST",
@@ -176,18 +288,34 @@ var UI_BRIDGE_NATIVE_ROUTES = {
176
288
  path: "/ui-bridge/ai/wait-for-element",
177
289
  description: "Wait for element predicate via HTTP (runner-only; mobile returns NOT_SUPPORTED \u2014 use WS waitForElement instead)"
178
290
  },
179
- // Test hooks — drive ModalDetector state from outside the React tree
180
- // (gated by `features.testHooks`). Mirrors `pushModal` / `dismissModal`
181
- // calls that components normally make in-process.
291
+ // Test hooks — drive ModalDetector state and the screen lock from outside
292
+ // the React tree (gated by `features.testHooks`). Mirrors `pushModal` /
293
+ // `dismissModal` calls that components normally make in-process.
294
+ //
295
+ // These are LISTED unconditionally — this table is a static constant with no
296
+ // config to consult — but each carries `requiresTestHooks: true`, which is
297
+ // the machine-readable form of the `(testHooks)` in its description. With the
298
+ // flag off `routeRequest` and `buildRoutesPayload` both drop them and the
299
+ // route answers a bare 404. `route-table-parity.test.ts` compares the whole
300
+ // table against `_routes` at `testHooks: true`, and the flagged subset
301
+ // against `_routes` at `testHooks: false`, so both surfaces are pinned.
182
302
  PUSH_MODAL: {
183
303
  method: "POST",
184
304
  path: "/ui-bridge/control/modal/push",
185
- description: "Push a modal onto the modal stack (testHooks)"
305
+ description: "Push a modal onto the modal stack (testHooks)",
306
+ requiresTestHooks: true
186
307
  },
187
308
  DISMISS_MODAL: {
188
309
  method: "POST",
189
310
  path: "/ui-bridge/control/modal/dismiss/:id",
190
- description: "Dismiss a modal by id (testHooks)"
311
+ description: "Dismiss a modal by id (testHooks)",
312
+ requiresTestHooks: true
313
+ },
314
+ KEEP_AWAKE: {
315
+ method: "POST",
316
+ path: "/ui-bridge/control/keep-awake",
317
+ description: "Keep the device screen awake via the injected keepAwakeProvider: body { enabled, durationMs? } (testHooks)",
318
+ requiresTestHooks: true
191
319
  },
192
320
  // Observability — last-N ring buffers (always mounted; `installed:false`
193
321
  // when the console/network patches are off, i.e. no testHooks).
@@ -382,6 +510,21 @@ function projectVisionFields(args) {
382
510
  return fields;
383
511
  }
384
512
 
513
+ // src/core/element-actions.ts
514
+ function serializeElementCustomActions(customActions) {
515
+ if (!customActions) return void 0;
516
+ const out = [];
517
+ for (const [id, action] of Object.entries(customActions)) {
518
+ if (!action) continue;
519
+ const entry = { id };
520
+ if (action.label !== void 0) entry.label = action.label;
521
+ if (action.description !== void 0) entry.description = action.description;
522
+ if (action.effect !== void 0) entry.effect = action.effect;
523
+ out.push(entry);
524
+ }
525
+ return out;
526
+ }
527
+
385
528
  // src/core/registry.ts
386
529
  function extractHandlerNames(props) {
387
530
  if (!props) return [];
@@ -410,6 +553,46 @@ function projectBbox(state, visibility, pixelRatio = 1) {
410
553
  h: toPx(layout.height)
411
554
  };
412
555
  }
556
+ function pageRectOf(state) {
557
+ const layout = state.layout;
558
+ if (!layout) return null;
559
+ if (layout.pageOriginUnmeasured === true) return null;
560
+ const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
561
+ if (!hasPage) return null;
562
+ const left = layout.pageX;
563
+ const top = layout.pageY;
564
+ if (typeof layout.width !== "number" || typeof layout.height !== "number" || !Number.isFinite(layout.width) || !Number.isFinite(layout.height)) {
565
+ return null;
566
+ }
567
+ return { left, top, right: left + layout.width, bottom: top + layout.height };
568
+ }
569
+ function spansOverlap(aMin, aMax, bMin, bMax) {
570
+ if (aMin === aMax) return aMin >= bMin && aMin <= bMax;
571
+ return aMin < bMax && aMax > bMin;
572
+ }
573
+ function isEmptyRect(rect) {
574
+ return rect.right <= rect.left || rect.bottom <= rect.top;
575
+ }
576
+ function computeVisibility(state, clip) {
577
+ if (!state.visible) {
578
+ return { visibility: "hidden", visibilityReason: "hidden" };
579
+ }
580
+ if (state.layout === null) {
581
+ return { visibility: "likely-visible", visibilityReason: "no-layout" };
582
+ }
583
+ if (!clip) {
584
+ return { visibility: "visible" };
585
+ }
586
+ const rect = pageRectOf(state);
587
+ if (!rect) {
588
+ return { visibility: "visible" };
589
+ }
590
+ if (isEmptyRect(clip)) {
591
+ return { visibility: "hidden", visibilityReason: "off-screen" };
592
+ }
593
+ const onScreen = spansOverlap(rect.left, rect.right, clip.left, clip.right) && spansOverlap(rect.top, rect.bottom, clip.top, clip.bottom);
594
+ return onScreen ? { visibility: "visible" } : { visibility: "hidden", visibilityReason: "off-screen" };
595
+ }
413
596
 
414
597
  // src/design/design-inspector-native.ts
415
598
  function toCssValue(value) {
@@ -1357,16 +1540,21 @@ function createServerHandlers(registry, executor, config) {
1357
1540
  return {
1358
1541
  // Elements
1359
1542
  getElements: async (ctx) => {
1543
+ await registry.refreshMeasurements();
1360
1544
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
1361
1545
  const forRoute = ctx.query?.route || ctx.body?.route;
1362
1546
  let allElements = visibleOnly ? registry.getMountedVisibleElements() : registry.getAllElements();
1363
1547
  if (forRoute) {
1364
1548
  allElements = allElements.filter((e) => matchesCurrentRoute(e.registrationRoute, forRoute));
1365
1549
  }
1550
+ const viewport = registry.getViewportRect();
1366
1551
  const elements = allElements.map((e) => {
1367
1552
  const handlers = extractHandlerNames(e.props);
1368
1553
  const state = e.getState();
1369
- const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
1554
+ const { visibility, visibilityReason } = computeVisibility(
1555
+ state,
1556
+ registry.getClipRectFor(e, viewport)
1557
+ );
1370
1558
  const bbox = projectBbox(state, visibility, registry.getPixelRatio());
1371
1559
  const vision = projectVisionFields({
1372
1560
  type: e.type,
@@ -1384,10 +1572,11 @@ function createServerHandlers(registry, executor, config) {
1384
1572
  identifier: e.getIdentifier(),
1385
1573
  state,
1386
1574
  actions: e.actions,
1387
- customActions: e.customActions ? Object.keys(e.customActions) : void 0,
1575
+ customActions: serializeElementCustomActions(e.customActions),
1388
1576
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
1389
1577
  registrationRoute: e.registrationRoute,
1390
1578
  visibility,
1579
+ ...visibilityReason ? { visibilityReason } : {},
1391
1580
  ...bbox ? { bbox } : {},
1392
1581
  ...vision
1393
1582
  };
@@ -1409,7 +1598,7 @@ function createServerHandlers(registry, executor, config) {
1409
1598
  identifier: element.getIdentifier(),
1410
1599
  state: resolveElementState(element),
1411
1600
  actions: element.actions,
1412
- customActions: element.customActions ? Object.keys(element.customActions) : void 0,
1601
+ customActions: serializeElementCustomActions(element.customActions),
1413
1602
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
1414
1603
  registrationRoute: element.registrationRoute
1415
1604
  }
@@ -1430,13 +1619,9 @@ function createServerHandlers(registry, executor, config) {
1430
1619
  if (!envelope?.action) {
1431
1620
  return error2("Action is required", "INVALID_REQUEST");
1432
1621
  }
1433
- const response = await executor.executeAction(id, {
1434
- action: envelope.action,
1435
- params: envelope.params,
1436
- waitOptions: envelope.waitOptions
1437
- });
1622
+ const response = await executor.executeAction(id, envelope);
1438
1623
  if (!response.success) {
1439
- return error2(response.error || "Action failed", "ACTION_FAILED");
1624
+ return error2(response.error || "Action failed", response.code ?? "ACTION_FAILED");
1440
1625
  }
1441
1626
  return success2(response);
1442
1627
  },
@@ -1446,7 +1631,11 @@ function createServerHandlers(registry, executor, config) {
1446
1631
  id: c.id,
1447
1632
  name: c.name,
1448
1633
  description: c.description,
1449
- actions: c.actions.map((a) => ({ id: a.id, label: a.label })),
1634
+ // Phase 4 (plan 2026-08-20-ui-bridge-action-declaration-shape):
1635
+ // carry the safety annotation onto the native wire too — a walker
1636
+ // driving a React Native app needs the same `destructive` exclusion
1637
+ // the web channel gets.
1638
+ actions: c.actions.map((a) => ({ id: a.id, label: a.label, effect: a.effect })),
1450
1639
  elementIds: c.elementIds
1451
1640
  }));
1452
1641
  return success2({ components });
@@ -1465,7 +1654,9 @@ function createServerHandlers(registry, executor, config) {
1465
1654
  actions: component.actions.map((a) => ({
1466
1655
  id: a.id,
1467
1656
  label: a.label,
1468
- description: a.description
1657
+ description: a.description,
1658
+ // Phase 4 — see the listing twin above.
1659
+ effect: a.effect
1469
1660
  })),
1470
1661
  elementIds: component.elementIds
1471
1662
  }
@@ -1476,7 +1667,8 @@ function createServerHandlers(registry, executor, config) {
1476
1667
  const body = ctx.body;
1477
1668
  const response = await executor.executeComponentAction(id, {
1478
1669
  action: actionId,
1479
- params: body?.params
1670
+ params: body?.params,
1671
+ timeoutMs: body?.timeoutMs
1480
1672
  });
1481
1673
  if (!response.success) {
1482
1674
  return error2(response.error || "Action failed", "ACTION_FAILED");
@@ -1550,6 +1742,7 @@ function createServerHandlers(registry, executor, config) {
1550
1742
  });
1551
1743
  },
1552
1744
  getSnapshot: async (ctx) => {
1745
+ await registry.refreshMeasurements();
1553
1746
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
1554
1747
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
1555
1748
  const snapshot = registry.createSnapshot(void 0, { visibleOnly, currentRouteOnly });
@@ -1667,9 +1860,12 @@ function createServerHandlers(registry, executor, config) {
1667
1860
  //
1668
1861
  // The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
1669
1862
  // cheatsheet paths so callers (and the /manual-test skill in particular)
1670
- // get a structured envelope explaining the gap rather than a confusing
1671
- // HTTP 404. Each error message names an in-tree mobile replacement so
1672
- // operators aren't left guessing what to use instead.
1863
+ // get a structured envelope explaining the gap rather than a bare
1864
+ // "no such route". These answer **HTTP 501** — `httpStatusForResponse` in
1865
+ // server/http-server.ts maps NOT_SUPPORTED → 501, so the status line and
1866
+ // the envelope agree; an unmounted path answers 404 instead. Each error
1867
+ // message names an in-tree mobile replacement so operators aren't left
1868
+ // guessing what to use instead.
1673
1869
  aiForms: async () => {
1674
1870
  return error2(
1675
1871
  'ai/forms (form discovery) is runner-only; mobile React Native has no DOM to walk. Use GET /ui-bridge/control/snapshot and filter for type === "input" instead.',
@@ -1865,6 +2061,7 @@ function createServerHandlers(registry, executor, config) {
1865
2061
  // that don't measure (the fallback only matches single-rooted screens but
1866
2062
  // is good enough for the unit suite).
1867
2063
  tapAt: async (ctx) => {
2064
+ await registry.refreshMeasurements();
1868
2065
  const body = ctx.body;
1869
2066
  const x = body?.x;
1870
2067
  const y = body?.y;
@@ -1995,18 +2192,12 @@ function createServerHandlers(registry, executor, config) {
1995
2192
  pushModal: async (ctx) => {
1996
2193
  const detector = registry.getEnrichers().modalDetector;
1997
2194
  if (!detector) {
1998
- return error2(
1999
- "modal detector not registered",
2000
- "ENRICHER_UNAVAILABLE"
2001
- );
2195
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
2002
2196
  }
2003
2197
  const body = ctx.body;
2004
2198
  const id = body?.id;
2005
2199
  if (typeof id !== "string" || id.length === 0) {
2006
- return error2(
2007
- 'pushModal requires a non-empty "id"',
2008
- "INVALID_REQUEST"
2009
- );
2200
+ return error2('pushModal requires a non-empty "id"', "INVALID_REQUEST");
2010
2201
  }
2011
2202
  const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
2012
2203
  const requestedType = body?.type;
@@ -2039,10 +2230,7 @@ function createServerHandlers(registry, executor, config) {
2039
2230
  dismissModal: async (ctx) => {
2040
2231
  const detector = registry.getEnrichers().modalDetector;
2041
2232
  if (!detector) {
2042
- return error2(
2043
- "modal detector not registered",
2044
- "ENRICHER_UNAVAILABLE"
2045
- );
2233
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
2046
2234
  }
2047
2235
  const { id } = ctx.params;
2048
2236
  if (typeof id !== "string" || id.length === 0) {
@@ -2072,7 +2260,7 @@ function createServerHandlers(registry, executor, config) {
2072
2260
  };
2073
2261
  if (config?.appInfo) {
2074
2262
  response.uiBridge = {
2075
- version: "0.6.11",
2263
+ version: "0.6.13",
2076
2264
  ...config.appInfo,
2077
2265
  capabilities: ["elements", "components", "actions", "design"],
2078
2266
  elementCount: stats.elements,
@@ -2220,8 +2408,48 @@ function extractMethod(input, init) {
2220
2408
  var NetworkRequestBuffer = class {
2221
2409
  constructor(capacity = 100) {
2222
2410
  this.installed = false;
2411
+ /**
2412
+ * Synchronous re-entrancy depth: non-zero while the ORIGINAL `fetch` is
2413
+ * executing, which is exactly the window in which React Native's whatwg-fetch
2414
+ * polyfill constructs and opens its internal `XMLHttpRequest`.
2415
+ *
2416
+ * A counter rather than a boolean so a `fetch` called from inside a `fetch`
2417
+ * (an interceptor, a retry wrapper) still unwinds to zero. Safe as a plain
2418
+ * number: JS is single-threaded and the only writes are the paired
2419
+ * increment/decrement around one synchronous call.
2420
+ */
2421
+ this.fetchDepth = { value: 0 };
2223
2422
  this.buffer = new RingBuffer(capacity);
2224
2423
  }
2424
+ /**
2425
+ * Patch `fetch` and `XMLHttpRequest` so `GET /sdk/network-requests` can
2426
+ * report what the app talked to.
2427
+ *
2428
+ * ONE REQUEST MUST PRODUCE ONE ENTRY. React Native implements `fetch` **on
2429
+ * top of** XHR (whatwg-fetch), so both patches observe the same request and
2430
+ * the buffer used to carry every call twice — same url, timestamps ~1ms
2431
+ * apart, and on failure one `"Network request failed"` beside one
2432
+ * `"XMLHttpRequest error"`. A tester reads that as a double-fetch bug in the
2433
+ * app and chases a defect that does not exist.
2434
+ *
2435
+ * The de-dup: `fetchDepth` is raised across the synchronous call into the
2436
+ * original `fetch`, which is when the polyfill builds its XHR; `open` stamps
2437
+ * any XHR created in that window with `__uiBridgeFromFetch`, and `send`
2438
+ * skips recording those. The `fetch` patch keeps the entry, because it is
2439
+ * the layer the app actually called and the only one that sees `Response.ok`.
2440
+ *
2441
+ * Uninstalling both patches together keeps this sound: an XHR can only be
2442
+ * marked while the fetch patch is live.
2443
+ *
2444
+ * KNOWN LIMIT — it is coupled to the polyfill building its XHR SYNCHRONOUSLY,
2445
+ * which whatwg-fetch does (its work happens in the Promise executor). If the
2446
+ * host's `fetch` is wrapped by an interceptor that defers the real call to a
2447
+ * microtask or `setTimeout`, the depth is back to 0 by the time `open` runs
2448
+ * and every request duplicates again — the original symptom, with no signal
2449
+ * that the mechanism disengaged. Duplicates in `/sdk/network-requests` are
2450
+ * the thing to look for; they mean this assumption stopped holding, not that
2451
+ * the app double-fetched.
2452
+ */
2225
2453
  install() {
2226
2454
  if (this.installed) return;
2227
2455
  const g = globalThis;
@@ -2229,12 +2457,20 @@ var NetworkRequestBuffer = class {
2229
2457
  this.originalFetch = g.fetch;
2230
2458
  const buffer = this.buffer;
2231
2459
  const original = this.originalFetch;
2460
+ const fetchDepth = this.fetchDepth;
2232
2461
  const patched = async (input, init) => {
2233
2462
  const start = Date.now();
2234
2463
  const url = extractUrl(input);
2235
2464
  const method = extractMethod(input, init);
2236
2465
  try {
2237
- const res = await original.call(globalThis, input, init);
2466
+ let pending;
2467
+ fetchDepth.value += 1;
2468
+ try {
2469
+ pending = original.call(globalThis, input, init);
2470
+ } finally {
2471
+ fetchDepth.value -= 1;
2472
+ }
2473
+ const res = await pending;
2238
2474
  buffer.push({
2239
2475
  timestamp: start,
2240
2476
  method,
@@ -2264,10 +2500,14 @@ var NetworkRequestBuffer = class {
2264
2500
  this.originalXhrOpen = Xhr.prototype.open;
2265
2501
  this.originalXhrSend = Xhr.prototype.send;
2266
2502
  const buffer = this.buffer;
2503
+ const fetchDepth = this.fetchDepth;
2267
2504
  const origOpen = this.originalXhrOpen;
2268
2505
  Xhr.prototype.open = function(method, url, ...rest) {
2269
2506
  this.__uiBridgeMethod = method.toUpperCase();
2270
2507
  this.__uiBridgeUrl = typeof url === "string" ? url : String(url);
2508
+ if (fetchDepth.value > 0) {
2509
+ this.__uiBridgeFromFetch = true;
2510
+ }
2271
2511
  return origOpen.call(
2272
2512
  this,
2273
2513
  method,
@@ -2277,6 +2517,9 @@ var NetworkRequestBuffer = class {
2277
2517
  };
2278
2518
  const origSend = this.originalXhrSend;
2279
2519
  Xhr.prototype.send = function(body) {
2520
+ if (this.__uiBridgeFromFetch === true) {
2521
+ return origSend.call(this, body);
2522
+ }
2280
2523
  const start = Date.now();
2281
2524
  const method = this.__uiBridgeMethod ?? "GET";
2282
2525
  const url = this.__uiBridgeUrl ?? "";
@@ -2356,6 +2599,16 @@ function isUnsubscribe(msg) {
2356
2599
  }
2357
2600
 
2358
2601
  // src/server/http-server.ts
2602
+ var ERROR_CODE_HTTP_STATUS = /* @__PURE__ */ new Map([
2603
+ ["NOT_SUPPORTED", 501],
2604
+ ["NOT_FOUND", 404],
2605
+ ["METHOD_NOT_ALLOWED", 405]
2606
+ ]);
2607
+ function httpStatusForResponse(response) {
2608
+ if (response.success) return 200;
2609
+ const mapped = response.code ? ERROR_CODE_HTTP_STATUS.get(response.code) : void 0;
2610
+ return mapped ?? 400;
2611
+ }
2359
2612
  function parsePath(pattern, actual) {
2360
2613
  const patternParts = pattern.split("/");
2361
2614
  const actualParts = actual.split("/");
@@ -2518,6 +2771,23 @@ var WS_ROUTES = [
2518
2771
  httpMethods: ["GET"]
2519
2772
  }
2520
2773
  ];
2774
+ var DISCOVERY_PATHS = /* @__PURE__ */ new Set(["_help", "_routes"]);
2775
+ function allowedMethodsForPath(path, testHooks) {
2776
+ const PREFIX = "/ui-bridge/";
2777
+ if (!path.startsWith(PREFIX)) return [];
2778
+ const stripped = path.slice(PREFIX.length);
2779
+ const methods = [];
2780
+ if (DISCOVERY_PATHS.has(stripped)) methods.push("GET");
2781
+ for (const route of WS_ROUTES) {
2782
+ if (!route.httpMethods) continue;
2783
+ if (route.requiresTestHooks && !testHooks) continue;
2784
+ if (parsePath(route.pattern, stripped) === null) continue;
2785
+ for (const verb of route.httpMethods) {
2786
+ if (!methods.includes(verb)) methods.push(verb);
2787
+ }
2788
+ }
2789
+ return methods;
2790
+ }
2521
2791
  var HANDLER_DESCRIPTIONS = {
2522
2792
  health: "Liveness probe; returns app metadata + element count",
2523
2793
  getElements: "Flat list of registered UI elements",
@@ -2580,6 +2850,10 @@ var ACTION_REFERENCE = [
2580
2850
  { name: "focus", params: "none" },
2581
2851
  { name: "blur", params: "none" },
2582
2852
  { name: "scroll", params: "ScrollActionParams { offset? {x,y}, to? }" },
2853
+ {
2854
+ name: "scrollIntoView",
2855
+ params: "ScrollIntoViewActionParams { padding? (dp, default 16) } \u2014 scrolls the element's DECLARED scrollAncestorId ScrollView; returns { alreadyVisible, scrolled }; NOT_SUPPORTED (501) without a usable declared ancestor"
2856
+ },
2583
2857
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
2584
2858
  { name: "toggle", params: "none \u2014 flips boolean controls" }
2585
2859
  ];
@@ -2777,7 +3051,9 @@ var NativeUIBridgeServer = class {
2777
3051
  }
2778
3052
  /**
2779
3053
  * Set a route provider for reporting the current navigation route.
2780
- * When set, `control/snapshot` responses include `currentRoute` and `segments`.
3054
+ * When set, `control/snapshot` responses include `currentRoute` and
3055
+ * `segments`, plus the canonical `route` / `activeTab` aliases the web SDK's
3056
+ * `BridgeSnapshot` spells the same way.
2781
3057
  *
2782
3058
  * Also forwards the provider into the registry so the *default*
2783
3059
  * `getSnapshot` handler (and any other call site of
@@ -2792,7 +3068,12 @@ var NativeUIBridgeServer = class {
2792
3068
  this.handlers.getElements = async (ctx) => {
2793
3069
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
2794
3070
  if (currentRouteOnly && !ctx.query?.route && !ctx.body?.route) {
2795
- const currentRoute = provider.getCurrentRoute();
3071
+ let currentRoute;
3072
+ try {
3073
+ currentRoute = provider.getCurrentRoute();
3074
+ } catch {
3075
+ currentRoute = null;
3076
+ }
2796
3077
  if (currentRoute) {
2797
3078
  ctx.query = { ...ctx.query, route: currentRoute };
2798
3079
  }
@@ -2802,11 +3083,26 @@ var NativeUIBridgeServer = class {
2802
3083
  this.handlers.getSnapshot = async (ctx) => {
2803
3084
  const visibleOnly = ctx?.query?.visibleOnly === "true" || ctx?.body?.visibleOnly === true;
2804
3085
  const currentRouteOnly = ctx?.query?.currentRouteOnly === "true" || ctx?.body?.currentRouteOnly === true;
3086
+ let currentRoute;
3087
+ try {
3088
+ currentRoute = provider.getCurrentRoute() ?? null;
3089
+ } catch {
3090
+ currentRoute = null;
3091
+ }
3092
+ let segments;
3093
+ try {
3094
+ segments = provider.getSegments?.();
3095
+ } catch {
3096
+ segments = void 0;
3097
+ }
3098
+ let activeTab;
3099
+ try {
3100
+ activeTab = provider.getActiveTab?.();
3101
+ } catch {
3102
+ activeTab = void 0;
3103
+ }
2805
3104
  const snapshot = this.registry.createSnapshot(
2806
- {
2807
- currentRoute: provider.getCurrentRoute(),
2808
- segments: provider.getSegments?.()
2809
- },
3105
+ { currentRoute, segments, activeTab },
2810
3106
  { visibleOnly, currentRouteOnly }
2811
3107
  );
2812
3108
  return { success: true, data: snapshot, timestamp: Date.now() };
@@ -3472,8 +3768,15 @@ var NativeUIBridgeServer = class {
3472
3768
  }
3473
3769
  try {
3474
3770
  const response = await this.routeRequest(request);
3771
+ if (response.code === "METHOD_NOT_ALLOWED") {
3772
+ const allow = allowedMethodsForPath(request.path, this.config.testHooks === true);
3773
+ if (allow.length > 0) {
3774
+ headers["Allow"] = allow.join(", ");
3775
+ if (this.config.cors) headers["Access-Control-Expose-Headers"] = "Allow";
3776
+ }
3777
+ }
3475
3778
  return {
3476
- status: response.success ? 200 : 400,
3779
+ status: httpStatusForResponse(response),
3477
3780
  headers,
3478
3781
  body: JSON.stringify(response)
3479
3782
  };
@@ -3530,7 +3833,7 @@ var NativeUIBridgeServer = class {
3530
3833
  timestamp: Date.now()
3531
3834
  };
3532
3835
  }
3533
- let patternMatchedButWrongVerb = false;
3836
+ let patternMatchedButWrongVerb = DISCOVERY_PATHS.has(stripped);
3534
3837
  for (const route of WS_ROUTES) {
3535
3838
  const pathParams = parsePath(route.pattern, stripped);
3536
3839
  if (!pathParams) continue;
@@ -3547,9 +3850,10 @@ var NativeUIBridgeServer = class {
3547
3850
  const ctx = { params: pathParams, query, body };
3548
3851
  return this.handlers[route.handler](ctx);
3549
3852
  }
3853
+ const allowed = patternMatchedButWrongVerb ? allowedMethodsForPath(path, testHooks) : [];
3550
3854
  return {
3551
3855
  success: false,
3552
- error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}` : `Route not found: ${method} ${path}`,
3856
+ error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}${allowed.length > 0 ? ` (allowed: ${allowed.join(", ")})` : ""}` : `Route not found: ${method} ${path}`,
3553
3857
  code: patternMatchedButWrongVerb ? "METHOD_NOT_ALLOWED" : "NOT_FOUND",
3554
3858
  timestamp: Date.now()
3555
3859
  };
@@ -4270,6 +4574,7 @@ exports.encodeCloseFrame = encodeCloseFrame;
4270
4574
  exports.encodePingFrame = encodePingFrame;
4271
4575
  exports.encodePongFrame = encodePongFrame;
4272
4576
  exports.encodeTextFrame = encodeTextFrame;
4577
+ exports.httpStatusForResponse = httpStatusForResponse;
4273
4578
  exports.isAddrInUseError = isAddrInUseError;
4274
4579
  exports.isBatchRequest = isBatchRequest;
4275
4580
  exports.isSubscribe = isSubscribe;