@qontinui/ui-bridge-native 0.6.12 → 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 +756 -15
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/index.mjs +738 -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 +272 -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 +1502 -117
  33. package/dist/index.js.map +1 -1
  34. package/dist/index.mjs +1484 -118
  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 +1319 -112
  39. package/dist/react/index.js.map +1 -1
  40. package/dist/react/index.mjs +1319 -112
  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 +345 -43
  52. package/dist/server/index.js.map +1 -1
  53. package/dist/server/index.mjs +345 -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
package/dist/index.js CHANGED
@@ -184,7 +184,42 @@ function projectVisionFields(args) {
184
184
  return fields;
185
185
  }
186
186
 
187
+ // src/core/element-actions.ts
188
+ function serializeElementCustomActions(customActions) {
189
+ if (!customActions) return void 0;
190
+ const out = [];
191
+ for (const [id, action] of Object.entries(customActions)) {
192
+ if (!action) continue;
193
+ const entry = { id };
194
+ if (action.label !== void 0) entry.label = action.label;
195
+ if (action.description !== void 0) entry.description = action.description;
196
+ if (action.effect !== void 0) entry.effect = action.effect;
197
+ out.push(entry);
198
+ }
199
+ return out;
200
+ }
201
+
187
202
  // src/core/registry.ts
203
+ function toRegisteredComponentActions(actions) {
204
+ return actions.map((a) => ({
205
+ id: a.id,
206
+ label: a.label,
207
+ description: a.description,
208
+ paramSchema: a.paramSchema,
209
+ effect: a.effect,
210
+ handler: a.handler
211
+ }));
212
+ }
213
+ function publishedActionSignature(actions) {
214
+ return JSON.stringify(
215
+ actions.map((a) => [a.id, a.label, a.description, a.paramSchema, a.effect])
216
+ );
217
+ }
218
+ function sameElementIds(a, b) {
219
+ if (a === b) return true;
220
+ if (a === void 0 || b === void 0) return false;
221
+ return a.length === b.length && a.every((id, i) => id === b[i]);
222
+ }
188
223
  function extractHandlerNames(props) {
189
224
  if (!props) return [];
190
225
  return Object.keys(props).filter((k) => typeof props[k] === "function");
@@ -193,6 +228,22 @@ function matchesCurrentRoute(registrationRoute, currentRoute) {
193
228
  if (registrationRoute == null || registrationRoute === "") return true;
194
229
  return registrationRoute === currentRoute;
195
230
  }
231
+ function deriveActiveTabFromSegments(segments) {
232
+ if (!segments || segments.length === 0) return void 0;
233
+ let innermostGroup = -1;
234
+ for (let i = 0; i < segments.length; i++) {
235
+ const segment = segments[i];
236
+ if (typeof segment === "string" && segment.startsWith("(") && segment.endsWith(")")) {
237
+ innermostGroup = i;
238
+ }
239
+ }
240
+ if (innermostGroup === -1) return void 0;
241
+ const next = segments[innermostGroup + 1];
242
+ if (next === void 0) return "index";
243
+ if (typeof next !== "string" || next.length === 0) return void 0;
244
+ if (next.startsWith("[") && next.endsWith("]")) return void 0;
245
+ return next;
246
+ }
196
247
  function projectBbox(state, visibility, pixelRatio = 1) {
197
248
  if (visibility !== "visible") return void 0;
198
249
  const layout = state.layout;
@@ -212,8 +263,58 @@ function projectBbox(state, visibility, pixelRatio = 1) {
212
263
  h: toPx(layout.height)
213
264
  };
214
265
  }
215
- function inferActions(type) {
216
- const baseActions = ["focus", "blur"];
266
+ function pageRectOf(state) {
267
+ const layout = state.layout;
268
+ if (!layout) return null;
269
+ if (layout.pageOriginUnmeasured === true) return null;
270
+ const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
271
+ if (!hasPage) return null;
272
+ const left = layout.pageX;
273
+ const top = layout.pageY;
274
+ if (typeof layout.width !== "number" || typeof layout.height !== "number" || !Number.isFinite(layout.width) || !Number.isFinite(layout.height)) {
275
+ return null;
276
+ }
277
+ return { left, top, right: left + layout.width, bottom: top + layout.height };
278
+ }
279
+ function spansOverlap(aMin, aMax, bMin, bMax) {
280
+ if (aMin === aMax) return aMin >= bMin && aMin <= bMax;
281
+ return aMin < bMax && aMax > bMin;
282
+ }
283
+ function isEmptyRect(rect) {
284
+ return rect.right <= rect.left || rect.bottom <= rect.top;
285
+ }
286
+ function intersectRects(a, b) {
287
+ if (!a) return b;
288
+ if (!b) return a;
289
+ return {
290
+ left: Math.max(a.left, b.left),
291
+ top: Math.max(a.top, b.top),
292
+ right: Math.min(a.right, b.right),
293
+ bottom: Math.min(a.bottom, b.bottom)
294
+ };
295
+ }
296
+ function computeVisibility(state, clip) {
297
+ if (!state.visible) {
298
+ return { visibility: "hidden", visibilityReason: "hidden" };
299
+ }
300
+ if (state.layout === null) {
301
+ return { visibility: "likely-visible", visibilityReason: "no-layout" };
302
+ }
303
+ if (!clip) {
304
+ return { visibility: "visible" };
305
+ }
306
+ const rect = pageRectOf(state);
307
+ if (!rect) {
308
+ return { visibility: "visible" };
309
+ }
310
+ if (isEmptyRect(clip)) {
311
+ return { visibility: "hidden", visibilityReason: "off-screen" };
312
+ }
313
+ const onScreen = spansOverlap(rect.left, rect.right, clip.left, clip.right) && spansOverlap(rect.top, rect.bottom, clip.top, clip.bottom);
314
+ return onScreen ? { visibility: "visible" } : { visibility: "hidden", visibilityReason: "off-screen" };
315
+ }
316
+ function inferActions(type, scrollAncestorId) {
317
+ const baseActions = scrollAncestorId ? ["focus", "blur", "scrollIntoView"] : ["focus", "blur"];
217
318
  switch (type) {
218
319
  case "button":
219
320
  case "touchable":
@@ -285,6 +386,18 @@ var NativeUIBridgeRegistry = class {
285
386
  * the `projectBbox` doc comment for why we don't import `react-native` here.
286
387
  */
287
388
  this.pixelRatio = 1;
389
+ /**
390
+ * Injected getter for the device window size in logical dp, used to clip
391
+ * reported visibility. Injected rather than read here for exactly the reason
392
+ * `pixelRatio` is: a static `import { Dimensions } from 'react-native'` in
393
+ * this non-React core module breaks the vitest suite and risks the
394
+ * Metro/Hermes `unknownModuleError`. It is a PROVIDER, not a value, so a
395
+ * rotation is picked up on the next snapshot with nothing to keep in sync.
396
+ *
397
+ * `null` means the window bounds are UNKNOWN, and {@link computeVisibility}
398
+ * treats unknown bounds as "cannot demote" — never as an empty viewport.
399
+ */
400
+ this.viewportProvider = null;
288
401
  /**
289
402
  * Sticky flag: flips `true` the first time any element is registered and
290
403
  * stays `true` even after elements are unregistered. Lets agents distinguish
@@ -322,6 +435,30 @@ var NativeUIBridgeRegistry = class {
322
435
  getRouteProvider() {
323
436
  return this.routeProvider;
324
437
  }
438
+ /**
439
+ * Resolve the snapshot's `activeTab`: an explicit `getActiveTab()` on the
440
+ * route provider wins, otherwise derive it from the Expo Router segments the
441
+ * provider already exposes.
442
+ *
443
+ * A throwing or empty-string provider degrades to the derivation rather than
444
+ * to `undefined` — the host's opt-in extra should never be able to make the
445
+ * snapshot WORSE than it was without it. A blank derivation stays blank.
446
+ */
447
+ resolveActiveTab(segments) {
448
+ const explicit = (() => {
449
+ if (!this.routeProvider?.getActiveTab) return void 0;
450
+ try {
451
+ const value = this.routeProvider.getActiveTab();
452
+ return typeof value === "string" && value.length > 0 ? value : void 0;
453
+ } catch (error3) {
454
+ if (this.config.verbose) {
455
+ console.warn(`[ui-bridge-native] routeProvider getActiveTab threw:`, error3);
456
+ }
457
+ return void 0;
458
+ }
459
+ })();
460
+ return explicit ?? deriveActiveTabFromSegments(segments);
461
+ }
325
462
  /**
326
463
  * Set the device pixel ratio used to project per-element `bbox` geometry
327
464
  * into physical pixels. The React provider passes `PixelRatio.get()` here
@@ -337,6 +474,56 @@ var NativeUIBridgeRegistry = class {
337
474
  getPixelRatio() {
338
475
  return this.pixelRatio;
339
476
  }
477
+ /**
478
+ * Set the getter for the device window size (logical dp) that reported
479
+ * visibility is clipped against. The React provider passes
480
+ * `() => Dimensions.get('window')` here at construction.
481
+ *
482
+ * Pass `null` to detach. While detached the window bounds are UNKNOWN and no
483
+ * element is ever demoted for being outside them.
484
+ */
485
+ setViewportProvider(provider) {
486
+ this.viewportProvider = provider ?? null;
487
+ }
488
+ /**
489
+ * The window as a page-space rect, or `null` when it is unknown or the
490
+ * provider returned something unusable (it throws in some RN teardown
491
+ * paths, and a zero-size window would silently hide the entire screen).
492
+ */
493
+ getViewportRect() {
494
+ if (!this.viewportProvider) return null;
495
+ let size;
496
+ try {
497
+ size = this.viewportProvider();
498
+ } catch (error3) {
499
+ if (this.config.verbose) {
500
+ console.warn(`[ui-bridge-native] viewportProvider threw:`, error3);
501
+ }
502
+ return null;
503
+ }
504
+ const { width, height } = size ?? {};
505
+ if (typeof width !== "number" || typeof height !== "number" || !Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
506
+ return null;
507
+ }
508
+ return { left: 0, top: 0, right: width, bottom: height };
509
+ }
510
+ /**
511
+ * The region `element` must reach to count as on screen: the window,
512
+ * intersected with the measured frame of its declared `scrollAncestorId`.
513
+ *
514
+ * Returns `null` when neither bound is known — see {@link computeVisibility}
515
+ * for why that means "do not demote" rather than "nothing is visible".
516
+ * An ancestor that is not registered, or registered but not yet measured,
517
+ * contributes nothing rather than hiding its subtree.
518
+ */
519
+ getClipRectFor(element, viewport) {
520
+ const bounds = viewport === void 0 ? this.getViewportRect() : viewport;
521
+ const ancestorId = element.scrollAncestorId;
522
+ if (!ancestorId) return bounds;
523
+ const ancestor = this.elements.get(ancestorId);
524
+ if (!ancestor || ancestorId === element.id) return bounds;
525
+ return intersectRects(bounds, pageRectOf(ancestor.getState()));
526
+ }
340
527
  // ============================================================================
341
528
  // Snapshot Enricher Slots
342
529
  // ============================================================================
@@ -380,16 +567,17 @@ var NativeUIBridgeRegistry = class {
380
567
  const {
381
568
  type = "custom",
382
569
  label,
383
- actions = inferActions(type),
384
570
  customActions,
385
571
  props,
386
572
  treePath = id,
387
573
  testId,
388
574
  accessibilityLabel,
389
575
  registrationRoute,
576
+ scrollAncestorId,
390
577
  flatStyle,
391
578
  stateStyles
392
579
  } = options;
580
+ const actions = options.actions ?? inferActions(type, scrollAncestorId);
393
581
  const getState = () => {
394
582
  const element = ref.current;
395
583
  if (!element) {
@@ -432,6 +620,7 @@ var NativeUIBridgeRegistry = class {
432
620
  registeredAt: Date.now(),
433
621
  mounted: true,
434
622
  registrationRoute: registrationRoute ?? null,
623
+ scrollAncestorId,
435
624
  flatStyle,
436
625
  stateStyles
437
626
  };
@@ -808,12 +997,8 @@ var NativeUIBridgeRegistry = class {
808
997
  id,
809
998
  name,
810
999
  description,
811
- actions: actions.map((a) => ({
812
- id: a.id,
813
- label: a.label,
814
- description: a.description,
815
- handler: a.handler
816
- })),
1000
+ // Closed field list, applied once — see `toRegisteredComponentActions`.
1001
+ actions: toRegisteredComponentActions(actions),
817
1002
  elementIds,
818
1003
  registeredAt: Date.now(),
819
1004
  mounted: true
@@ -825,6 +1010,55 @@ var NativeUIBridgeRegistry = class {
825
1010
  }
826
1011
  return registered;
827
1012
  }
1013
+ /**
1014
+ * Update a registered component's declaration in place — name, description,
1015
+ * actions, elementIds.
1016
+ *
1017
+ * Sister to {@link updateElementMeta}, and it exists for the same reason one
1018
+ * level up. `registerComponent` publishes the declaration exactly once, and
1019
+ * `useUIComponent` registers on mount and never again, so an action whose
1020
+ * `label` carries a count, an action that only exists while a row is
1021
+ * selected, a `paramSchema` that widens once options load, or an `elementIds`
1022
+ * list that grows after mount, all published their mount-time shape to
1023
+ * `/control/components` forever. PR #176 closed that hole for an ELEMENT's
1024
+ * label; this closes it for a COMPONENT's declaration.
1025
+ *
1026
+ * **Idempotent by VALUE over the PUBLISHED shape.** `handler` identity is
1027
+ * excluded from the comparison — call sites pass inline closures that are
1028
+ * freshly allocated every render, so including it would report a change on
1029
+ * every call and make the door useless for a render-path publisher. The
1030
+ * newest handlers are still STORED whenever `actions` is supplied, which is
1031
+ * the other half of the same defect: without it the registry keeps the
1032
+ * closure from the render that registered the component, so a handler reading
1033
+ * component state read the state as it was at mount.
1034
+ *
1035
+ * Returns `true` when the published shape changed and an event was emitted,
1036
+ * `false` otherwise — the same contract as `updateElementMeta`, so a caller
1037
+ * can gate a warning or a state write on a real change.
1038
+ */
1039
+ updateComponentMeta(id, meta) {
1040
+ const component = this.components.get(id);
1041
+ if (!component) return false;
1042
+ const nextName = meta.name !== void 0 ? meta.name : component.name;
1043
+ const nextDescription = meta.description !== void 0 ? meta.description : component.description;
1044
+ const nextActions = meta.actions !== void 0 ? toRegisteredComponentActions(meta.actions) : component.actions;
1045
+ const nextElementIds = meta.elementIds !== void 0 ? meta.elementIds : component.elementIds;
1046
+ const changed = nextName !== component.name || nextDescription !== component.description || !sameElementIds(nextElementIds, component.elementIds) || publishedActionSignature(nextActions) !== publishedActionSignature(component.actions);
1047
+ if (!changed && meta.actions === void 0) return false;
1048
+ this.components.set(id, {
1049
+ ...component,
1050
+ name: nextName,
1051
+ description: nextDescription,
1052
+ actions: nextActions,
1053
+ elementIds: nextElementIds
1054
+ });
1055
+ if (!changed) return false;
1056
+ this.emit("component:registered", { id, name: nextName });
1057
+ if (this.config.verbose) {
1058
+ console.log(`[ui-bridge-native] Updated component: ${id} (${nextName})`);
1059
+ }
1060
+ return true;
1061
+ }
828
1062
  /**
829
1063
  * Unregister a component
830
1064
  */
@@ -938,41 +1172,59 @@ var NativeUIBridgeRegistry = class {
938
1172
  * server-level override (the bare-server / cloud-relay path) still get a
939
1173
  * populated `currentRoute` field as long as `setRouteProvider` was called
940
1174
  * on the registry.
1175
+ *
1176
+ * `activeTab` resolves through {@link resolveActiveTab} on BOTH branches. An
1177
+ * explicit `routeInfo.activeTab` wins outright; otherwise a registered
1178
+ * provider's `getActiveTab()` answers and the Expo Router derivation is the
1179
+ * fallback. Deriving straight from `routeInfo.segments` here instead — which
1180
+ * is what this branch did when `activeTab` was introduced — silently ignored
1181
+ * `RouteProvider.getActiveTab` on the ONLY path that serves
1182
+ * `control/snapshot` once `setRouteProvider` is wired (the server installs a
1183
+ * `getSnapshot` override that passes `routeInfo`), so the opt-in hook for an
1184
+ * app whose visible pane is decoupled from the router reached no consumer.
941
1185
  */
942
1186
  createSnapshot(routeInfo, options) {
943
1187
  const resolvedRoute = (() => {
944
1188
  if (routeInfo !== void 0) {
1189
+ const explicit = typeof routeInfo.activeTab === "string" && routeInfo.activeTab.length > 0 ? routeInfo.activeTab : void 0;
945
1190
  return {
946
1191
  currentRoute: routeInfo.currentRoute ?? null,
947
- segments: routeInfo.segments
1192
+ segments: routeInfo.segments,
1193
+ activeTab: explicit ?? this.resolveActiveTab(routeInfo.segments)
948
1194
  };
949
1195
  }
950
1196
  if (this.routeProvider) {
951
1197
  try {
1198
+ const segments = this.routeProvider.getSegments?.();
952
1199
  return {
953
1200
  currentRoute: this.routeProvider.getCurrentRoute() ?? null,
954
- segments: this.routeProvider.getSegments?.()
1201
+ segments,
1202
+ activeTab: this.resolveActiveTab(segments)
955
1203
  };
956
1204
  } catch (error3) {
957
1205
  if (this.config.verbose) {
958
1206
  console.warn(`[ui-bridge-native] routeProvider getCurrentRoute threw:`, error3);
959
1207
  }
960
- return { currentRoute: null, segments: void 0 };
1208
+ return { currentRoute: null, segments: void 0, activeTab: void 0 };
961
1209
  }
962
1210
  }
963
- return { currentRoute: null, segments: void 0 };
1211
+ return { currentRoute: null, segments: void 0, activeTab: void 0 };
964
1212
  })();
965
1213
  let elements = options?.visibleOnly ? this.getMountedVisibleElements() : this.getAllElements();
966
1214
  if (options?.currentRouteOnly && resolvedRoute.currentRoute) {
967
1215
  const currentRoute = resolvedRoute.currentRoute;
968
1216
  elements = elements.filter((e) => matchesCurrentRoute(e.registrationRoute, currentRoute));
969
1217
  }
1218
+ const viewport = this.getViewportRect();
970
1219
  const snapshot = {
971
1220
  timestamp: Date.now(),
972
1221
  elements: elements.map((e) => {
973
1222
  const handlers = extractHandlerNames(e.props);
974
1223
  const state = e.getState();
975
- const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
1224
+ const { visibility, visibilityReason } = computeVisibility(
1225
+ state,
1226
+ this.getClipRectFor(e, viewport)
1227
+ );
976
1228
  const bbox = projectBbox(state, visibility, this.pixelRatio);
977
1229
  const vision = projectVisionFields({
978
1230
  type: e.type,
@@ -992,10 +1244,11 @@ var NativeUIBridgeRegistry = class {
992
1244
  identifier: e.getIdentifier(),
993
1245
  state,
994
1246
  actions: e.actions,
995
- customActions: e.customActions ? Object.keys(e.customActions) : void 0,
1247
+ customActions: serializeElementCustomActions(e.customActions),
996
1248
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
997
1249
  registrationRoute: e.registrationRoute,
998
1250
  visibility,
1251
+ ...visibilityReason ? { visibilityReason } : {},
999
1252
  ...bbox ? { bbox } : {},
1000
1253
  ...vision
1001
1254
  };
@@ -1015,6 +1268,13 @@ var NativeUIBridgeRegistry = class {
1015
1268
  })),
1016
1269
  currentRoute: resolvedRoute.currentRoute,
1017
1270
  segments: resolvedRoute.segments,
1271
+ // Canonical aliases, spelled the way the web SDK's `BridgeSnapshot`
1272
+ // spells them so a cross-platform consumer reads one field name on both
1273
+ // platforms. Omitted rather than nulled when unknown — same convention
1274
+ // as the web fields, and it keeps the emitted shape byte-identical for
1275
+ // hosts that wire no route provider.
1276
+ ...resolvedRoute.currentRoute ? { route: resolvedRoute.currentRoute } : {},
1277
+ ...resolvedRoute.activeTab ? { activeTab: resolvedRoute.activeTab } : {},
1018
1278
  registration: this.getRegistrationCoverage()
1019
1279
  };
1020
1280
  if (this.appInfo) {
@@ -1117,6 +1377,468 @@ function resetGlobalRegistry() {
1117
1377
  globalRegistry = null;
1118
1378
  }
1119
1379
 
1380
+ // src/core/action-effect.ts
1381
+ var NATIVE_STANDARD_ACTION_EFFECTS = {
1382
+ // --- Reads ---------------------------------------------------------------
1383
+ focus: "read",
1384
+ blur: "read",
1385
+ scroll: "read",
1386
+ scrollIntoView: "read",
1387
+ longPress: "read",
1388
+ // --- Writes --------------------------------------------------------------
1389
+ press: "write",
1390
+ click: "write",
1391
+ doubleTap: "write",
1392
+ type: "write",
1393
+ setValue: "write",
1394
+ clear: "write",
1395
+ swipe: "write",
1396
+ toggle: "write"
1397
+ };
1398
+ function nativeStandardActionEffect(actionId) {
1399
+ return Object.prototype.hasOwnProperty.call(NATIVE_STANDARD_ACTION_EFFECTS, actionId) ? NATIVE_STANDARD_ACTION_EFFECTS[actionId] : void 0;
1400
+ }
1401
+ function resolveActionEffect(action) {
1402
+ return action.effect ?? nativeStandardActionEffect(action.id);
1403
+ }
1404
+
1405
+ // src/core/param-schema.ts
1406
+ var DEFAULT_PARAM_VALIDATION_MODE = "warn";
1407
+ var currentDefaultMode = DEFAULT_PARAM_VALIDATION_MODE;
1408
+ function setDefaultParamValidationMode(mode) {
1409
+ currentDefaultMode = mode;
1410
+ }
1411
+ function getDefaultParamValidationMode() {
1412
+ return currentDefaultMode;
1413
+ }
1414
+ function resetDefaultParamValidationMode() {
1415
+ currentDefaultMode = DEFAULT_PARAM_VALIDATION_MODE;
1416
+ }
1417
+ var PRIMITIVE_TYPE_NAMES = /* @__PURE__ */ new Set([
1418
+ "string",
1419
+ "number",
1420
+ "integer",
1421
+ "boolean",
1422
+ "object",
1423
+ "array",
1424
+ "null"
1425
+ ]);
1426
+ var OBJECT_SCHEMA_KEYWORDS = /* @__PURE__ */ new Set([
1427
+ "type",
1428
+ "properties",
1429
+ "required",
1430
+ "additionalProperties",
1431
+ "patternProperties",
1432
+ "propertyNames",
1433
+ "minProperties",
1434
+ "maxProperties",
1435
+ "dependentRequired",
1436
+ "dependentSchemas",
1437
+ "unevaluatedProperties",
1438
+ "allOf",
1439
+ "anyOf",
1440
+ "oneOf",
1441
+ "not",
1442
+ "if",
1443
+ "then",
1444
+ "else",
1445
+ "enum",
1446
+ "const",
1447
+ "default",
1448
+ "examples",
1449
+ "title",
1450
+ "description",
1451
+ "deprecated",
1452
+ "readOnly",
1453
+ "writeOnly",
1454
+ "$schema",
1455
+ "$id",
1456
+ "$ref",
1457
+ "$comment",
1458
+ "$defs",
1459
+ "definitions"
1460
+ ]);
1461
+ var MAX_SCHEMA_DEPTH = 32;
1462
+ var MAX_PATTERN_LENGTH = 200;
1463
+ function hasNestedQuantifiers(pattern) {
1464
+ return /(\((?:[^()]*[+*}])[^()]*\))[+*?]|\(\?:[^()]*[+*}][^()]*\)[+*?]/.test(pattern);
1465
+ }
1466
+ function isPlainObject(value) {
1467
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1468
+ }
1469
+ var hasOwnProperty = Object.prototype.hasOwnProperty;
1470
+ function hasOwn(value, key) {
1471
+ return hasOwnProperty.call(value, key);
1472
+ }
1473
+ function render(value) {
1474
+ if (value === null) return "null";
1475
+ if (value === void 0) return "undefined";
1476
+ if (Array.isArray(value)) return `array(${value.length})`;
1477
+ const t = typeof value;
1478
+ if (t === "string") return JSON.stringify(value);
1479
+ if (t === "number" || t === "boolean") return String(value);
1480
+ if (t === "object") return "object";
1481
+ return t;
1482
+ }
1483
+ var MAX_RENDERED_JSON_LENGTH = 120;
1484
+ function renderJson(value) {
1485
+ if (value === void 0) return "undefined";
1486
+ let text;
1487
+ try {
1488
+ text = JSON.stringify(value) ?? String(value);
1489
+ } catch {
1490
+ return render(value);
1491
+ }
1492
+ return text.length > MAX_RENDERED_JSON_LENGTH ? `${text.slice(0, MAX_RENDERED_JSON_LENGTH)}\u2026` : text;
1493
+ }
1494
+ function jsonTypeOf(value) {
1495
+ if (value === null) return "null";
1496
+ if (Array.isArray(value)) return "array";
1497
+ const t = typeof value;
1498
+ if (t === "number") return Number.isInteger(value) ? "integer" : "number";
1499
+ if (t === "string" || t === "boolean" || t === "object") return t;
1500
+ return t;
1501
+ }
1502
+ function matchesType(value, typeName) {
1503
+ switch (typeName) {
1504
+ case "string":
1505
+ return typeof value === "string";
1506
+ case "number":
1507
+ return typeof value === "number" && Number.isFinite(value);
1508
+ case "integer":
1509
+ return typeof value === "number" && Number.isInteger(value);
1510
+ case "boolean":
1511
+ return typeof value === "boolean";
1512
+ case "null":
1513
+ return value === null;
1514
+ case "array":
1515
+ return Array.isArray(value);
1516
+ case "object":
1517
+ return isPlainObject(value);
1518
+ default:
1519
+ return true;
1520
+ }
1521
+ }
1522
+ function jsonEquals(a, b, depth = 0) {
1523
+ if (a === b) return true;
1524
+ if (depth > MAX_SCHEMA_DEPTH) return false;
1525
+ if (a === null || b === null || a === void 0 || b === void 0) return false;
1526
+ if (typeof a !== typeof b) return false;
1527
+ if (typeof a !== "object") return false;
1528
+ const aIsArray = Array.isArray(a);
1529
+ if (aIsArray !== Array.isArray(b)) return false;
1530
+ if (aIsArray) {
1531
+ const bArr = b;
1532
+ const aArr = a;
1533
+ if (aArr.length !== bArr.length) return false;
1534
+ return aArr.every((item, i) => jsonEquals(item, bArr[i], depth + 1));
1535
+ }
1536
+ const aObj = a;
1537
+ const bObj = b;
1538
+ const aKeys = Object.keys(aObj);
1539
+ const bKeys = Object.keys(bObj);
1540
+ if (aKeys.length !== bKeys.length) return false;
1541
+ return aKeys.every((key) => hasOwn(bObj, key) && jsonEquals(aObj[key], bObj[key], depth + 1));
1542
+ }
1543
+ function joinPath(base, key) {
1544
+ return base === "" ? key : `${base}.${key}`;
1545
+ }
1546
+ function readTypes(schema) {
1547
+ const raw = schema["type"];
1548
+ if (typeof raw === "string") return [raw];
1549
+ if (Array.isArray(raw)) {
1550
+ const names = raw.filter((t) => typeof t === "string");
1551
+ return names.length > 0 ? names : null;
1552
+ }
1553
+ return null;
1554
+ }
1555
+ function validateValue(value, schema, path, issues, depth) {
1556
+ if (depth > MAX_SCHEMA_DEPTH) return;
1557
+ if (typeof schema === "string") {
1558
+ if (PRIMITIVE_TYPE_NAMES.has(schema) && !matchesType(value, schema)) {
1559
+ issues.push({
1560
+ path,
1561
+ keyword: "type",
1562
+ expected: schema,
1563
+ received: render(value),
1564
+ message: `Parameter "${path}" must be of type ${schema}, received ${jsonTypeOf(value)} (${render(value)}).`
1565
+ });
1566
+ }
1567
+ return;
1568
+ }
1569
+ if (!isPlainObject(schema)) return;
1570
+ const types = readTypes(schema);
1571
+ if (types !== null && !types.some((t) => matchesType(value, t))) {
1572
+ const expected = types.join(" | ");
1573
+ issues.push({
1574
+ path,
1575
+ keyword: "type",
1576
+ expected,
1577
+ received: render(value),
1578
+ message: `Parameter "${path}" must be of type ${expected}, received ${jsonTypeOf(value)} (${render(value)}).`
1579
+ });
1580
+ return;
1581
+ }
1582
+ if (hasOwn(schema, "const") && !jsonEquals(value, schema["const"])) {
1583
+ const expected = renderJson(schema["const"]);
1584
+ const received = renderJson(value);
1585
+ issues.push({
1586
+ path,
1587
+ keyword: "const",
1588
+ expected,
1589
+ received,
1590
+ message: `Parameter "${path}" must equal ${expected}, received ${received}.`
1591
+ });
1592
+ return;
1593
+ }
1594
+ const enumValues = schema["enum"];
1595
+ if (Array.isArray(enumValues) && !enumValues.some((allowed) => jsonEquals(value, allowed))) {
1596
+ const expected = enumValues.map((v) => renderJson(v)).join(", ");
1597
+ const received = renderJson(value);
1598
+ issues.push({
1599
+ path,
1600
+ keyword: "enum",
1601
+ expected,
1602
+ received,
1603
+ message: `Parameter "${path}" must be one of [${expected}], received ${received}.`
1604
+ });
1605
+ return;
1606
+ }
1607
+ if (typeof value === "number") {
1608
+ const min = schema["minimum"];
1609
+ if (typeof min === "number" && value < min) {
1610
+ issues.push({
1611
+ path,
1612
+ keyword: "minimum",
1613
+ expected: `>= ${min}`,
1614
+ received: render(value),
1615
+ message: `Parameter "${path}" must be >= ${min}, received ${value}.`
1616
+ });
1617
+ }
1618
+ const max = schema["maximum"];
1619
+ if (typeof max === "number" && value > max) {
1620
+ issues.push({
1621
+ path,
1622
+ keyword: "maximum",
1623
+ expected: `<= ${max}`,
1624
+ received: render(value),
1625
+ message: `Parameter "${path}" must be <= ${max}, received ${value}.`
1626
+ });
1627
+ }
1628
+ }
1629
+ if (typeof value === "string") {
1630
+ const minLength = schema["minLength"];
1631
+ if (typeof minLength === "number" && value.length < minLength) {
1632
+ issues.push({
1633
+ path,
1634
+ keyword: "minLength",
1635
+ expected: `length >= ${minLength}`,
1636
+ received: `length ${value.length}`,
1637
+ message: `Parameter "${path}" must be at least ${minLength} characters, received ${value.length}.`
1638
+ });
1639
+ }
1640
+ const maxLength = schema["maxLength"];
1641
+ if (typeof maxLength === "number" && value.length > maxLength) {
1642
+ issues.push({
1643
+ path,
1644
+ keyword: "maxLength",
1645
+ expected: `length <= ${maxLength}`,
1646
+ received: `length ${value.length}`,
1647
+ message: `Parameter "${path}" must be at most ${maxLength} characters, received ${value.length}.`
1648
+ });
1649
+ }
1650
+ const pattern = schema["pattern"];
1651
+ if (typeof pattern === "string" && pattern.length <= MAX_PATTERN_LENGTH && !hasNestedQuantifiers(pattern)) {
1652
+ let re;
1653
+ try {
1654
+ re = new RegExp(pattern);
1655
+ } catch {
1656
+ re = null;
1657
+ }
1658
+ if (re !== null && !re.test(value)) {
1659
+ issues.push({
1660
+ path,
1661
+ keyword: "pattern",
1662
+ expected: `match /${pattern}/`,
1663
+ received: render(value),
1664
+ message: `Parameter "${path}" must match /${pattern}/, received ${render(value)}.`
1665
+ });
1666
+ }
1667
+ }
1668
+ }
1669
+ if (Array.isArray(value) && hasOwn(schema, "items")) {
1670
+ const items = schema["items"];
1671
+ value.forEach((element, index) => {
1672
+ validateValue(element, items, `${path}[${index}]`, issues, depth + 1);
1673
+ });
1674
+ }
1675
+ if (isPlainObject(value) && (isPlainObject(schema["properties"]) || hasOwn(schema, "required") || schema["additionalProperties"] === false)) {
1676
+ validateObject(value, schema, path, issues, depth);
1677
+ }
1678
+ }
1679
+ function validateObject(value, schema, basePath, issues, depth) {
1680
+ if (depth > MAX_SCHEMA_DEPTH) return;
1681
+ const properties = isPlainObject(schema["properties"]) ? schema["properties"] : {};
1682
+ const requiredRaw = schema["required"];
1683
+ const required = Array.isArray(requiredRaw) ? requiredRaw.filter((k) => typeof k === "string") : [];
1684
+ for (const key of required) {
1685
+ if (!hasOwn(value, key) || value[key] === void 0) {
1686
+ const at = joinPath(basePath, key);
1687
+ issues.push({
1688
+ path: at,
1689
+ keyword: "required",
1690
+ expected: "present",
1691
+ message: `Required parameter "${at}" is missing.`
1692
+ });
1693
+ }
1694
+ }
1695
+ if (schema["additionalProperties"] === false) {
1696
+ const declared = new Set(Object.keys(properties));
1697
+ const expected = declared.size === 0 ? "no parameters" : `one of [${[...declared].join(", ")}]`;
1698
+ for (const key of Object.keys(value)) {
1699
+ if (!declared.has(key)) {
1700
+ const at = joinPath(basePath, key);
1701
+ issues.push({
1702
+ path: at,
1703
+ keyword: "additionalProperties",
1704
+ expected,
1705
+ received: render(value[key]),
1706
+ message: `Parameter "${at}" is not declared by the schema, which sets additionalProperties: false.`
1707
+ });
1708
+ }
1709
+ }
1710
+ }
1711
+ for (const [key, propSchema] of Object.entries(properties)) {
1712
+ if (!hasOwn(value, key) || value[key] === void 0) continue;
1713
+ validateValue(value[key], propSchema, joinPath(basePath, key), issues, depth + 1);
1714
+ }
1715
+ }
1716
+ function isObjectSchemaForm(schema) {
1717
+ if (isPlainObject(schema["properties"])) return true;
1718
+ if (schema["type"] !== "object") return false;
1719
+ return Object.keys(schema).every((key) => OBJECT_SCHEMA_KEYWORDS.has(key));
1720
+ }
1721
+ function normalizeMapForm(schema) {
1722
+ if (isObjectSchemaForm(schema)) return null;
1723
+ const properties = {};
1724
+ for (const [key, raw] of Object.entries(schema)) {
1725
+ if (raw === null || raw === void 0) continue;
1726
+ if (typeof raw === "string") {
1727
+ properties[key] = PRIMITIVE_TYPE_NAMES.has(raw) ? { type: raw } : {};
1728
+ continue;
1729
+ }
1730
+ if (isPlainObject(raw)) {
1731
+ const { optional: _optional, ...rest } = raw;
1732
+ properties[key] = rest;
1733
+ continue;
1734
+ }
1735
+ }
1736
+ return { type: "object", properties };
1737
+ }
1738
+ function validateActionParams(schema, params) {
1739
+ if (!isPlainObject(schema)) return { valid: true, issues: [] };
1740
+ const normalized = normalizeMapForm(schema) ?? schema;
1741
+ const hasProperties = isPlainObject(normalized["properties"]);
1742
+ const hasRequired = Array.isArray(normalized["required"]);
1743
+ const forbidsExtras = normalized["additionalProperties"] === false;
1744
+ if (!hasProperties && !hasRequired && !forbidsExtras) {
1745
+ return { valid: true, issues: [] };
1746
+ }
1747
+ const issues = [];
1748
+ if (params === void 0 || params === null) {
1749
+ validateObject({}, normalized, "", issues, 0);
1750
+ return { valid: issues.length === 0, issues };
1751
+ }
1752
+ if (!isPlainObject(params)) {
1753
+ issues.push({
1754
+ path: "",
1755
+ keyword: "type",
1756
+ expected: "object",
1757
+ received: render(params),
1758
+ message: `Action params must be an object, received ${jsonTypeOf(params)} (${render(params)}).`
1759
+ });
1760
+ return { valid: false, issues };
1761
+ }
1762
+ validateObject(params, normalized, "", issues, 0);
1763
+ return { valid: issues.length === 0, issues };
1764
+ }
1765
+ function formatParamValidationFailure(componentId, actionId, issues) {
1766
+ const detail = issues.map((i) => i.message).join(" ");
1767
+ return `Action "${actionId}" on component "${componentId}" was rejected: params do not match the action's declared paramSchema. ${detail}`;
1768
+ }
1769
+
1770
+ // src/core/abortable.ts
1771
+ var MAX_ACTION_TIMEOUT_MS = 864e5;
1772
+ function normalizeActionTimeoutMs(raw) {
1773
+ if (raw === void 0 || raw === null) {
1774
+ return { ok: true, timeoutMs: void 0, clamped: false };
1775
+ }
1776
+ if (typeof raw !== "number") {
1777
+ return {
1778
+ ok: false,
1779
+ reason: `timeoutMs must be a number of milliseconds, received ${typeof raw === "string" ? JSON.stringify(raw) : typeof raw}`
1780
+ };
1781
+ }
1782
+ if (!Number.isFinite(raw)) {
1783
+ return {
1784
+ ok: false,
1785
+ reason: `timeoutMs must be a finite number of milliseconds, received ${String(raw)}`
1786
+ };
1787
+ }
1788
+ if (raw < 0) {
1789
+ return {
1790
+ ok: false,
1791
+ reason: `timeoutMs must not be negative, received ${String(raw)}`
1792
+ };
1793
+ }
1794
+ if (raw > MAX_ACTION_TIMEOUT_MS) {
1795
+ return { ok: true, timeoutMs: MAX_ACTION_TIMEOUT_MS, clamped: true };
1796
+ }
1797
+ return { ok: true, timeoutMs: Math.floor(raw), clamped: false };
1798
+ }
1799
+ function inertAbortSignal() {
1800
+ return new AbortController().signal;
1801
+ }
1802
+ async function runAbortable(invoke, options = {}) {
1803
+ const callerSignal = options.signal;
1804
+ const timeoutMs = options.timeoutMs;
1805
+ if (callerSignal?.aborted) {
1806
+ return { aborted: true, reason: "signal" };
1807
+ }
1808
+ const controller = new AbortController();
1809
+ let reason = "signal";
1810
+ let timer;
1811
+ const onCallerAbort = () => {
1812
+ reason = "signal";
1813
+ controller.abort();
1814
+ };
1815
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
1816
+ if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs >= 0) {
1817
+ timer = setTimeout(
1818
+ () => {
1819
+ reason = "timeout";
1820
+ controller.abort();
1821
+ },
1822
+ Math.min(timeoutMs, MAX_ACTION_TIMEOUT_MS)
1823
+ );
1824
+ }
1825
+ try {
1826
+ const abandoned = new Promise((resolve) => {
1827
+ controller.signal.addEventListener("abort", () => resolve({ aborted: true, reason }), {
1828
+ once: true
1829
+ });
1830
+ });
1831
+ const completed = (async () => ({
1832
+ aborted: false,
1833
+ result: await invoke(controller.signal)
1834
+ }))();
1835
+ return await Promise.race([completed, abandoned]);
1836
+ } finally {
1837
+ if (timer !== void 0) clearTimeout(timer);
1838
+ callerSignal?.removeEventListener("abort", onCallerAbort);
1839
+ }
1840
+ }
1841
+
1120
1842
  // src/core/element-identifier.ts
1121
1843
  function createNativeElementIdentifier(id, options = {}) {
1122
1844
  return {
@@ -1221,6 +1943,68 @@ var DEFAULT_WAIT_OPTIONS = {
1221
1943
  timeout: 1e4,
1222
1944
  interval: 100
1223
1945
  };
1946
+ var NativeActionError = class extends Error {
1947
+ constructor(message, code) {
1948
+ super(message);
1949
+ this.code = code;
1950
+ this.name = "NativeActionError";
1951
+ }
1952
+ };
1953
+ var DEFAULT_SCROLL_INTO_VIEW_PADDING = 16;
1954
+ var SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS = 1e3;
1955
+ var SCROLL_INTO_VIEW_WINDOW_MEASURE_TIMEOUT_MS = 250;
1956
+ function measurePageRectNow(element, preferred) {
1957
+ const preferredNode = preferred;
1958
+ const node = preferredNode && typeof preferredNode.measureInWindow === "function" ? preferredNode : element.ref.current;
1959
+ if (!node || typeof node.measureInWindow !== "function") {
1960
+ const state = element.getState();
1961
+ return Promise.resolve(state.visible ? pageRectOf(state) : null);
1962
+ }
1963
+ return new Promise((resolve) => {
1964
+ let settled = false;
1965
+ const done = (rect) => {
1966
+ if (settled) return;
1967
+ settled = true;
1968
+ clearTimeout(timer);
1969
+ resolve(rect);
1970
+ };
1971
+ const timer = setTimeout(() => done(null), SCROLL_INTO_VIEW_WINDOW_MEASURE_TIMEOUT_MS);
1972
+ try {
1973
+ node.measureInWindow(
1974
+ (x, y, w, h) => done(
1975
+ [x, y, w, h].every(Number.isFinite) && w > 0 && h > 0 ? { left: x, top: y, right: x + w, bottom: y + h } : null
1976
+ )
1977
+ );
1978
+ } catch {
1979
+ done(null);
1980
+ }
1981
+ });
1982
+ }
1983
+ function resolveScrollView(current) {
1984
+ const c = current;
1985
+ if (!c) return null;
1986
+ if (typeof c.scrollTo === "function")
1987
+ return c;
1988
+ for (const unwrap of [c.getNativeScrollRef, c.getScrollResponder]) {
1989
+ if (typeof unwrap !== "function") continue;
1990
+ let inner;
1991
+ try {
1992
+ inner = unwrap.call(c);
1993
+ } catch {
1994
+ inner = null;
1995
+ }
1996
+ if (inner && typeof inner.scrollTo === "function")
1997
+ return inner;
1998
+ }
1999
+ return null;
2000
+ }
2001
+ function isDeclaredHorizontal(registeredProps, ...refs) {
2002
+ if (registeredProps?.horizontal === true) return true;
2003
+ return refs.some((r) => {
2004
+ const props = r?.props;
2005
+ return props !== null && typeof props === "object" && props.horizontal === true;
2006
+ });
2007
+ }
1224
2008
  function sleep(ms) {
1225
2009
  return new Promise((resolve) => setTimeout(resolve, ms));
1226
2010
  }
@@ -1340,6 +2124,7 @@ var DefaultNativeActionExecutor = class {
1340
2124
  return {
1341
2125
  success: false,
1342
2126
  error: errorMsg,
2127
+ ...error3 instanceof NativeActionError ? { code: error3.code } : {},
1343
2128
  stack: error3 instanceof Error ? error3.stack : void 0,
1344
2129
  durationMs: failTime - startTime,
1345
2130
  timestamp: failTime,
@@ -1357,7 +2142,7 @@ var DefaultNativeActionExecutor = class {
1357
2142
  }
1358
2143
  const props = element.props || {};
1359
2144
  if (element.customActions && action in element.customActions) {
1360
- return element.customActions[action].handler(params);
2145
+ return element.customActions[action].handler(params, { signal: inertAbortSignal() });
1361
2146
  }
1362
2147
  switch (action) {
1363
2148
  case "click":
@@ -1380,6 +2165,11 @@ var DefaultNativeActionExecutor = class {
1380
2165
  return this.performBlur(element);
1381
2166
  case "scroll":
1382
2167
  return this.performScroll(props, params);
2168
+ case "scrollIntoView":
2169
+ return this.performScrollIntoView(
2170
+ element,
2171
+ params
2172
+ );
1383
2173
  case "swipe":
1384
2174
  return this.performSwipe(props, params);
1385
2175
  case "toggle":
@@ -1546,6 +2336,138 @@ var DefaultNativeActionExecutor = class {
1546
2336
  props.onScroll(event);
1547
2337
  }
1548
2338
  }
2339
+ /**
2340
+ * Perform scrollIntoView: scroll the element's DECLARED scroll container so
2341
+ * the element sits `padding` dp below the top of its viewport.
2342
+ *
2343
+ * The container comes from `scrollAncestorId` and nowhere else — the
2344
+ * registry keeps no parent chain (`RegisterElementOptions.scrollAncestorId`),
2345
+ * so there is no "nearest ScrollView" to find. Nothing here tracks a
2346
+ * ScrollView's content offset either (`performScroll` only fakes an
2347
+ * `onScroll` event), so the target is measured CONTENT-relative with
2348
+ * `measureLayout` against the container's inner content view: stateless,
2349
+ * and immune to a stale-offset race.
2350
+ *
2351
+ * Result shape mirrors the web executor's `scrollIntoView`
2352
+ * (`@qontinui/ui-bridge` `control/action-executor.ts`):
2353
+ * `{ alreadyVisible, scrolled }`, short-circuiting when the element is
2354
+ * already fully inside its clip region. "Fully visible" is only claimed when
2355
+ * it is MEASURED — a page-space rect for the element and a known,
2356
+ * non-empty clip (window ∩ declared container), both measured at call
2357
+ * time. Anything unknown scrolls.
2358
+ *
2359
+ * Containers: a ScrollView, or a VirtualizedList (FlatList / SectionList)
2360
+ * unwrapped through `getNativeScrollRef()` / `getScrollResponder()`.
2361
+ * Vertical only — a `horizontal` container is NOT_SUPPORTED. The action
2362
+ * returns once `scrollTo` is dispatched; positions settle on the next frame.
2363
+ *
2364
+ * Prerequisites are checked BEFORE the visibility short-circuit, so whether
2365
+ * the element supports the action does not depend on where it happens to be
2366
+ * on screen. Each missing prerequisite is a `NOT_SUPPORTED` failure.
2367
+ */
2368
+ async performScrollIntoView(element, params) {
2369
+ const padding = params?.padding ?? DEFAULT_SCROLL_INTO_VIEW_PADDING;
2370
+ if (typeof padding !== "number" || !Number.isFinite(padding) || padding < 0) {
2371
+ throw new Error('scrollIntoView "padding" must be a finite, non-negative number');
2372
+ }
2373
+ const ancestorId = element.scrollAncestorId;
2374
+ if (!ancestorId) {
2375
+ throw new NativeActionError(
2376
+ `scrollIntoView is not supported on element "${element.id}": it declares no scrollAncestorId, and the registry keeps no parent chain to find its scroll container from`,
2377
+ "NOT_SUPPORTED"
2378
+ );
2379
+ }
2380
+ const ancestor = ancestorId === element.id ? void 0 : this.registry.getElement(ancestorId);
2381
+ if (!ancestor) {
2382
+ throw new NativeActionError(
2383
+ `scrollIntoView is not supported on element "${element.id}": its declared scrollAncestorId "${ancestorId}" is not a registered element`,
2384
+ "NOT_SUPPORTED"
2385
+ );
2386
+ }
2387
+ const container = resolveScrollView(ancestor.ref.current);
2388
+ if (!container) {
2389
+ throw new NativeActionError(
2390
+ `scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" has no scrollTo (not a mounted ScrollView, and no getNativeScrollRef/getScrollResponder yielding one)`,
2391
+ "NOT_SUPPORTED"
2392
+ );
2393
+ }
2394
+ if (isDeclaredHorizontal(ancestor.props, ancestor.ref.current, container)) {
2395
+ throw new NativeActionError(
2396
+ `scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" is horizontal (only vertical containers are supported)`,
2397
+ "NOT_SUPPORTED"
2398
+ );
2399
+ }
2400
+ const innerViewRef = container.getInnerViewRef?.();
2401
+ const innerView = innerViewRef ?? container.getInnerViewNode?.();
2402
+ const innerViewIsTag = innerViewRef == null && typeof innerView === "number";
2403
+ if (innerView === void 0 || innerView === null) {
2404
+ throw new NativeActionError(
2405
+ `scrollIntoView is not supported on element "${element.id}": its scroll ancestor "${ancestorId}" exposes no inner content view to measure against`,
2406
+ "NOT_SUPPORTED"
2407
+ );
2408
+ }
2409
+ const target = element.ref.current;
2410
+ if (!target || typeof target.measureLayout !== "function") {
2411
+ throw new NativeActionError(
2412
+ `scrollIntoView is not supported on element "${element.id}": its ref has no measureLayout (not a mounted host view)`,
2413
+ "NOT_SUPPORTED"
2414
+ );
2415
+ }
2416
+ const [rect, ancestorRect] = await Promise.all([
2417
+ measurePageRectNow(element),
2418
+ measurePageRectNow(ancestor, container)
2419
+ ]);
2420
+ const viewport = this.registry.getViewportRect();
2421
+ const clip = ancestorRect ? intersectRects(viewport, ancestorRect) : null;
2422
+ if (rect && clip && !isEmptyRect(clip) && rect.right > rect.left && rect.bottom > rect.top && rect.top >= clip.top && rect.left >= clip.left && rect.bottom <= clip.bottom && rect.right <= clip.right) {
2423
+ return { alreadyVisible: true, scrolled: false };
2424
+ }
2425
+ const measured = await new Promise(
2426
+ (resolve, reject) => {
2427
+ let settled = false;
2428
+ const timer = setTimeout(() => {
2429
+ if (settled) return;
2430
+ settled = true;
2431
+ reject(
2432
+ new Error(
2433
+ `scrollIntoView: measureLayout did not call back within ${SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS}ms` + (innerViewIsTag ? " (measured against a numeric inner-view tag from getInnerViewNode, which the new architecture does not support)" : "")
2434
+ )
2435
+ );
2436
+ }, SCROLL_INTO_VIEW_MEASURE_TIMEOUT_MS);
2437
+ const finish = (fn) => {
2438
+ if (settled) return;
2439
+ settled = true;
2440
+ clearTimeout(timer);
2441
+ fn();
2442
+ };
2443
+ try {
2444
+ target.measureLayout(
2445
+ innerView,
2446
+ (x, y, width) => finish(
2447
+ () => Number.isFinite(y) ? resolve({ x, y, width }) : reject(new Error("scrollIntoView: measureLayout returned a non-finite y"))
2448
+ ),
2449
+ () => finish(
2450
+ () => reject(
2451
+ new Error(
2452
+ `scrollIntoView: measureLayout failed for "${element.id}" relative to "${ancestorId}"`
2453
+ )
2454
+ )
2455
+ )
2456
+ );
2457
+ } catch (error3) {
2458
+ finish(() => reject(error3));
2459
+ }
2460
+ }
2461
+ );
2462
+ if (ancestorRect && Number.isFinite(measured.x) && Number.isFinite(measured.width) && measured.x + measured.width > ancestorRect.right - ancestorRect.left + 1) {
2463
+ throw new NativeActionError(
2464
+ `scrollIntoView is not supported on element "${element.id}": it is laid out beyond the horizontal extent of its scroll ancestor "${ancestorId}" (sideways overflow, a horizontal container, or a nested horizontal container \u2014 declare that inner container as its scrollAncestorId); only vertical scrolling is supported`,
2465
+ "NOT_SUPPORTED"
2466
+ );
2467
+ }
2468
+ container.scrollTo({ y: Math.max(0, measured.y - padding), animated: false });
2469
+ return { alreadyVisible: false, scrolled: true };
2470
+ }
1549
2471
  /**
1550
2472
  * Perform swipe action
1551
2473
  */
@@ -1600,7 +2522,7 @@ var DefaultNativeActionExecutor = class {
1600
2522
  /**
1601
2523
  * Execute a component action
1602
2524
  */
1603
- async executeComponentAction(componentId, request) {
2525
+ async executeComponentAction(componentId, request, options = {}) {
1604
2526
  const startTime = Date.now();
1605
2527
  try {
1606
2528
  const component = this.registry.getComponent(componentId);
@@ -1623,10 +2545,71 @@ var DefaultNativeActionExecutor = class {
1623
2545
  requestId: request.requestId
1624
2546
  };
1625
2547
  }
1626
- const result = await action.handler(request.params);
2548
+ const timeout = normalizeActionTimeoutMs(request.timeoutMs);
2549
+ if (!timeout.ok) {
2550
+ return {
2551
+ success: false,
2552
+ error: `Action "${request.action}" on component "${componentId}" was rejected: ${timeout.reason}.`,
2553
+ durationMs: Date.now() - startTime,
2554
+ timestamp: Date.now(),
2555
+ requestId: request.requestId
2556
+ };
2557
+ }
2558
+ const timeoutMs = timeout.timeoutMs;
2559
+ const validationMode = options.paramValidation ?? getDefaultParamValidationMode();
2560
+ if (validationMode !== "off" && action.paramSchema !== void 0) {
2561
+ let validation;
2562
+ try {
2563
+ validation = validateActionParams(action.paramSchema, request.params);
2564
+ } catch (validatorFault) {
2565
+ const detail = validatorFault instanceof Error ? validatorFault.message : String(validatorFault);
2566
+ const faultMessage = `Action "${request.action}" on component "${componentId}": its declared paramSchema could not be evaluated (${detail}).`;
2567
+ if (validationMode === "enforce") {
2568
+ return {
2569
+ success: false,
2570
+ error: faultMessage,
2571
+ durationMs: Date.now() - startTime,
2572
+ timestamp: Date.now(),
2573
+ requestId: request.requestId
2574
+ };
2575
+ }
2576
+ console.warn(`[ui-bridge-native] ${faultMessage}`);
2577
+ validation = { valid: true, issues: [] };
2578
+ }
2579
+ if (!validation.valid) {
2580
+ const message = formatParamValidationFailure(
2581
+ componentId,
2582
+ request.action,
2583
+ validation.issues
2584
+ );
2585
+ if (validationMode === "enforce") {
2586
+ return {
2587
+ success: false,
2588
+ error: message,
2589
+ durationMs: Date.now() - startTime,
2590
+ timestamp: Date.now(),
2591
+ requestId: request.requestId
2592
+ };
2593
+ }
2594
+ console.warn(`[ui-bridge-native] ${message}`);
2595
+ }
2596
+ }
2597
+ const outcome = await runAbortable((signal) => action.handler(request.params, { signal }), {
2598
+ signal: options.signal,
2599
+ timeoutMs
2600
+ });
2601
+ if (outcome.aborted) {
2602
+ return {
2603
+ success: false,
2604
+ error: outcome.reason === "timeout" ? `Action "${request.action}" on component "${componentId}" was abandoned after its ${timeoutMs}ms timeout elapsed.` : `Action "${request.action}" on component "${componentId}" was cancelled by the caller's abort signal.`,
2605
+ durationMs: Date.now() - startTime,
2606
+ timestamp: Date.now(),
2607
+ requestId: request.requestId
2608
+ };
2609
+ }
1627
2610
  return {
1628
2611
  success: true,
1629
- result,
2612
+ result: outcome.result,
1630
2613
  durationMs: Date.now() - startTime,
1631
2614
  timestamp: Date.now(),
1632
2615
  requestId: request.requestId
@@ -2700,10 +3683,14 @@ function createServerHandlers(registry, executor, config) {
2700
3683
  if (forRoute) {
2701
3684
  allElements = allElements.filter((e) => matchesCurrentRoute(e.registrationRoute, forRoute));
2702
3685
  }
3686
+ const viewport = registry.getViewportRect();
2703
3687
  const elements = allElements.map((e) => {
2704
3688
  const handlers = extractHandlerNames(e.props);
2705
3689
  const state = e.getState();
2706
- const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
3690
+ const { visibility, visibilityReason } = computeVisibility(
3691
+ state,
3692
+ registry.getClipRectFor(e, viewport)
3693
+ );
2707
3694
  const bbox = projectBbox(state, visibility, registry.getPixelRatio());
2708
3695
  const vision = projectVisionFields({
2709
3696
  type: e.type,
@@ -2721,10 +3708,11 @@ function createServerHandlers(registry, executor, config) {
2721
3708
  identifier: e.getIdentifier(),
2722
3709
  state,
2723
3710
  actions: e.actions,
2724
- customActions: e.customActions ? Object.keys(e.customActions) : void 0,
3711
+ customActions: serializeElementCustomActions(e.customActions),
2725
3712
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
2726
3713
  registrationRoute: e.registrationRoute,
2727
3714
  visibility,
3715
+ ...visibilityReason ? { visibilityReason } : {},
2728
3716
  ...bbox ? { bbox } : {},
2729
3717
  ...vision
2730
3718
  };
@@ -2746,7 +3734,7 @@ function createServerHandlers(registry, executor, config) {
2746
3734
  identifier: element.getIdentifier(),
2747
3735
  state: resolveElementState(element),
2748
3736
  actions: element.actions,
2749
- customActions: element.customActions ? Object.keys(element.customActions) : void 0,
3737
+ customActions: serializeElementCustomActions(element.customActions),
2750
3738
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
2751
3739
  registrationRoute: element.registrationRoute
2752
3740
  }
@@ -2767,13 +3755,9 @@ function createServerHandlers(registry, executor, config) {
2767
3755
  if (!envelope?.action) {
2768
3756
  return error2("Action is required", "INVALID_REQUEST");
2769
3757
  }
2770
- const response = await executor.executeAction(id, {
2771
- action: envelope.action,
2772
- params: envelope.params,
2773
- waitOptions: envelope.waitOptions
2774
- });
3758
+ const response = await executor.executeAction(id, envelope);
2775
3759
  if (!response.success) {
2776
- return error2(response.error || "Action failed", "ACTION_FAILED");
3760
+ return error2(response.error || "Action failed", response.code ?? "ACTION_FAILED");
2777
3761
  }
2778
3762
  return success2(response);
2779
3763
  },
@@ -2783,7 +3767,11 @@ function createServerHandlers(registry, executor, config) {
2783
3767
  id: c.id,
2784
3768
  name: c.name,
2785
3769
  description: c.description,
2786
- actions: c.actions.map((a) => ({ id: a.id, label: a.label })),
3770
+ // Phase 4 (plan 2026-08-20-ui-bridge-action-declaration-shape):
3771
+ // carry the safety annotation onto the native wire too — a walker
3772
+ // driving a React Native app needs the same `destructive` exclusion
3773
+ // the web channel gets.
3774
+ actions: c.actions.map((a) => ({ id: a.id, label: a.label, effect: a.effect })),
2787
3775
  elementIds: c.elementIds
2788
3776
  }));
2789
3777
  return success2({ components });
@@ -2802,7 +3790,9 @@ function createServerHandlers(registry, executor, config) {
2802
3790
  actions: component.actions.map((a) => ({
2803
3791
  id: a.id,
2804
3792
  label: a.label,
2805
- description: a.description
3793
+ description: a.description,
3794
+ // Phase 4 — see the listing twin above.
3795
+ effect: a.effect
2806
3796
  })),
2807
3797
  elementIds: component.elementIds
2808
3798
  }
@@ -2813,7 +3803,8 @@ function createServerHandlers(registry, executor, config) {
2813
3803
  const body = ctx.body;
2814
3804
  const response = await executor.executeComponentAction(id, {
2815
3805
  action: actionId,
2816
- params: body?.params
3806
+ params: body?.params,
3807
+ timeoutMs: body?.timeoutMs
2817
3808
  });
2818
3809
  if (!response.success) {
2819
3810
  return error2(response.error || "Action failed", "ACTION_FAILED");
@@ -3005,9 +3996,12 @@ function createServerHandlers(registry, executor, config) {
3005
3996
  //
3006
3997
  // The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
3007
3998
  // cheatsheet paths so callers (and the /manual-test skill in particular)
3008
- // get a structured envelope explaining the gap rather than a confusing
3009
- // HTTP 404. Each error message names an in-tree mobile replacement so
3010
- // operators aren't left guessing what to use instead.
3999
+ // get a structured envelope explaining the gap rather than a bare
4000
+ // "no such route". These answer **HTTP 501** — `httpStatusForResponse` in
4001
+ // server/http-server.ts maps NOT_SUPPORTED → 501, so the status line and
4002
+ // the envelope agree; an unmounted path answers 404 instead. Each error
4003
+ // message names an in-tree mobile replacement so operators aren't left
4004
+ // guessing what to use instead.
3011
4005
  aiForms: async () => {
3012
4006
  return error2(
3013
4007
  '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.',
@@ -3334,18 +4328,12 @@ function createServerHandlers(registry, executor, config) {
3334
4328
  pushModal: async (ctx) => {
3335
4329
  const detector = registry.getEnrichers().modalDetector;
3336
4330
  if (!detector) {
3337
- return error2(
3338
- "modal detector not registered",
3339
- "ENRICHER_UNAVAILABLE"
3340
- );
4331
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
3341
4332
  }
3342
4333
  const body = ctx.body;
3343
4334
  const id = body?.id;
3344
4335
  if (typeof id !== "string" || id.length === 0) {
3345
- return error2(
3346
- 'pushModal requires a non-empty "id"',
3347
- "INVALID_REQUEST"
3348
- );
4336
+ return error2('pushModal requires a non-empty "id"', "INVALID_REQUEST");
3349
4337
  }
3350
4338
  const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
3351
4339
  const requestedType = body?.type;
@@ -3378,10 +4366,7 @@ function createServerHandlers(registry, executor, config) {
3378
4366
  dismissModal: async (ctx) => {
3379
4367
  const detector = registry.getEnrichers().modalDetector;
3380
4368
  if (!detector) {
3381
- return error2(
3382
- "modal detector not registered",
3383
- "ENRICHER_UNAVAILABLE"
3384
- );
4369
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
3385
4370
  }
3386
4371
  const { id } = ctx.params;
3387
4372
  if (typeof id !== "string" || id.length === 0) {
@@ -3411,7 +4396,7 @@ function createServerHandlers(registry, executor, config) {
3411
4396
  };
3412
4397
  if (config?.appInfo) {
3413
4398
  response.uiBridge = {
3414
- version: "0.6.12",
4399
+ version: "0.6.13",
3415
4400
  ...config.appInfo,
3416
4401
  capabilities: ["elements", "components", "actions", "design"],
3417
4402
  elementCount: stats.elements,
@@ -3559,8 +4544,48 @@ function extractMethod(input, init) {
3559
4544
  var NetworkRequestBuffer = class {
3560
4545
  constructor(capacity = 100) {
3561
4546
  this.installed = false;
4547
+ /**
4548
+ * Synchronous re-entrancy depth: non-zero while the ORIGINAL `fetch` is
4549
+ * executing, which is exactly the window in which React Native's whatwg-fetch
4550
+ * polyfill constructs and opens its internal `XMLHttpRequest`.
4551
+ *
4552
+ * A counter rather than a boolean so a `fetch` called from inside a `fetch`
4553
+ * (an interceptor, a retry wrapper) still unwinds to zero. Safe as a plain
4554
+ * number: JS is single-threaded and the only writes are the paired
4555
+ * increment/decrement around one synchronous call.
4556
+ */
4557
+ this.fetchDepth = { value: 0 };
3562
4558
  this.buffer = new RingBuffer(capacity);
3563
4559
  }
4560
+ /**
4561
+ * Patch `fetch` and `XMLHttpRequest` so `GET /sdk/network-requests` can
4562
+ * report what the app talked to.
4563
+ *
4564
+ * ONE REQUEST MUST PRODUCE ONE ENTRY. React Native implements `fetch` **on
4565
+ * top of** XHR (whatwg-fetch), so both patches observe the same request and
4566
+ * the buffer used to carry every call twice — same url, timestamps ~1ms
4567
+ * apart, and on failure one `"Network request failed"` beside one
4568
+ * `"XMLHttpRequest error"`. A tester reads that as a double-fetch bug in the
4569
+ * app and chases a defect that does not exist.
4570
+ *
4571
+ * The de-dup: `fetchDepth` is raised across the synchronous call into the
4572
+ * original `fetch`, which is when the polyfill builds its XHR; `open` stamps
4573
+ * any XHR created in that window with `__uiBridgeFromFetch`, and `send`
4574
+ * skips recording those. The `fetch` patch keeps the entry, because it is
4575
+ * the layer the app actually called and the only one that sees `Response.ok`.
4576
+ *
4577
+ * Uninstalling both patches together keeps this sound: an XHR can only be
4578
+ * marked while the fetch patch is live.
4579
+ *
4580
+ * KNOWN LIMIT — it is coupled to the polyfill building its XHR SYNCHRONOUSLY,
4581
+ * which whatwg-fetch does (its work happens in the Promise executor). If the
4582
+ * host's `fetch` is wrapped by an interceptor that defers the real call to a
4583
+ * microtask or `setTimeout`, the depth is back to 0 by the time `open` runs
4584
+ * and every request duplicates again — the original symptom, with no signal
4585
+ * that the mechanism disengaged. Duplicates in `/sdk/network-requests` are
4586
+ * the thing to look for; they mean this assumption stopped holding, not that
4587
+ * the app double-fetched.
4588
+ */
3564
4589
  install() {
3565
4590
  if (this.installed) return;
3566
4591
  const g = globalThis;
@@ -3568,12 +4593,20 @@ var NetworkRequestBuffer = class {
3568
4593
  this.originalFetch = g.fetch;
3569
4594
  const buffer = this.buffer;
3570
4595
  const original = this.originalFetch;
4596
+ const fetchDepth = this.fetchDepth;
3571
4597
  const patched = async (input, init) => {
3572
4598
  const start = Date.now();
3573
4599
  const url = extractUrl(input);
3574
4600
  const method = extractMethod(input, init);
3575
4601
  try {
3576
- const res = await original.call(globalThis, input, init);
4602
+ let pending;
4603
+ fetchDepth.value += 1;
4604
+ try {
4605
+ pending = original.call(globalThis, input, init);
4606
+ } finally {
4607
+ fetchDepth.value -= 1;
4608
+ }
4609
+ const res = await pending;
3577
4610
  buffer.push({
3578
4611
  timestamp: start,
3579
4612
  method,
@@ -3603,10 +4636,14 @@ var NetworkRequestBuffer = class {
3603
4636
  this.originalXhrOpen = Xhr.prototype.open;
3604
4637
  this.originalXhrSend = Xhr.prototype.send;
3605
4638
  const buffer = this.buffer;
4639
+ const fetchDepth = this.fetchDepth;
3606
4640
  const origOpen = this.originalXhrOpen;
3607
4641
  Xhr.prototype.open = function(method, url, ...rest) {
3608
4642
  this.__uiBridgeMethod = method.toUpperCase();
3609
4643
  this.__uiBridgeUrl = typeof url === "string" ? url : String(url);
4644
+ if (fetchDepth.value > 0) {
4645
+ this.__uiBridgeFromFetch = true;
4646
+ }
3610
4647
  return origOpen.call(
3611
4648
  this,
3612
4649
  method,
@@ -3616,6 +4653,9 @@ var NetworkRequestBuffer = class {
3616
4653
  };
3617
4654
  const origSend = this.originalXhrSend;
3618
4655
  Xhr.prototype.send = function(body) {
4656
+ if (this.__uiBridgeFromFetch === true) {
4657
+ return origSend.call(this, body);
4658
+ }
3619
4659
  const start = Date.now();
3620
4660
  const method = this.__uiBridgeMethod ?? "GET";
3621
4661
  const url = this.__uiBridgeUrl ?? "";
@@ -3695,6 +4735,16 @@ function isUnsubscribe(msg) {
3695
4735
  }
3696
4736
 
3697
4737
  // src/server/http-server.ts
4738
+ var ERROR_CODE_HTTP_STATUS = /* @__PURE__ */ new Map([
4739
+ ["NOT_SUPPORTED", 501],
4740
+ ["NOT_FOUND", 404],
4741
+ ["METHOD_NOT_ALLOWED", 405]
4742
+ ]);
4743
+ function httpStatusForResponse(response) {
4744
+ if (response.success) return 200;
4745
+ const mapped = response.code ? ERROR_CODE_HTTP_STATUS.get(response.code) : void 0;
4746
+ return mapped ?? 400;
4747
+ }
3698
4748
  function parsePath(pattern, actual) {
3699
4749
  const patternParts = pattern.split("/");
3700
4750
  const actualParts = actual.split("/");
@@ -3857,6 +4907,23 @@ var WS_ROUTES = [
3857
4907
  httpMethods: ["GET"]
3858
4908
  }
3859
4909
  ];
4910
+ var DISCOVERY_PATHS = /* @__PURE__ */ new Set(["_help", "_routes"]);
4911
+ function allowedMethodsForPath(path, testHooks) {
4912
+ const PREFIX = "/ui-bridge/";
4913
+ if (!path.startsWith(PREFIX)) return [];
4914
+ const stripped = path.slice(PREFIX.length);
4915
+ const methods = [];
4916
+ if (DISCOVERY_PATHS.has(stripped)) methods.push("GET");
4917
+ for (const route of WS_ROUTES) {
4918
+ if (!route.httpMethods) continue;
4919
+ if (route.requiresTestHooks && !testHooks) continue;
4920
+ if (parsePath(route.pattern, stripped) === null) continue;
4921
+ for (const verb of route.httpMethods) {
4922
+ if (!methods.includes(verb)) methods.push(verb);
4923
+ }
4924
+ }
4925
+ return methods;
4926
+ }
3860
4927
  var HANDLER_DESCRIPTIONS = {
3861
4928
  health: "Liveness probe; returns app metadata + element count",
3862
4929
  getElements: "Flat list of registered UI elements",
@@ -3919,6 +4986,10 @@ var ACTION_REFERENCE = [
3919
4986
  { name: "focus", params: "none" },
3920
4987
  { name: "blur", params: "none" },
3921
4988
  { name: "scroll", params: "ScrollActionParams { offset? {x,y}, to? }" },
4989
+ {
4990
+ name: "scrollIntoView",
4991
+ 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"
4992
+ },
3922
4993
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
3923
4994
  { name: "toggle", params: "none \u2014 flips boolean controls" }
3924
4995
  ];
@@ -4116,7 +5187,9 @@ var NativeUIBridgeServer = class {
4116
5187
  }
4117
5188
  /**
4118
5189
  * Set a route provider for reporting the current navigation route.
4119
- * When set, `control/snapshot` responses include `currentRoute` and `segments`.
5190
+ * When set, `control/snapshot` responses include `currentRoute` and
5191
+ * `segments`, plus the canonical `route` / `activeTab` aliases the web SDK's
5192
+ * `BridgeSnapshot` spells the same way.
4120
5193
  *
4121
5194
  * Also forwards the provider into the registry so the *default*
4122
5195
  * `getSnapshot` handler (and any other call site of
@@ -4131,7 +5204,12 @@ var NativeUIBridgeServer = class {
4131
5204
  this.handlers.getElements = async (ctx) => {
4132
5205
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
4133
5206
  if (currentRouteOnly && !ctx.query?.route && !ctx.body?.route) {
4134
- const currentRoute = provider.getCurrentRoute();
5207
+ let currentRoute;
5208
+ try {
5209
+ currentRoute = provider.getCurrentRoute();
5210
+ } catch {
5211
+ currentRoute = null;
5212
+ }
4135
5213
  if (currentRoute) {
4136
5214
  ctx.query = { ...ctx.query, route: currentRoute };
4137
5215
  }
@@ -4141,11 +5219,26 @@ var NativeUIBridgeServer = class {
4141
5219
  this.handlers.getSnapshot = async (ctx) => {
4142
5220
  const visibleOnly = ctx?.query?.visibleOnly === "true" || ctx?.body?.visibleOnly === true;
4143
5221
  const currentRouteOnly = ctx?.query?.currentRouteOnly === "true" || ctx?.body?.currentRouteOnly === true;
5222
+ let currentRoute;
5223
+ try {
5224
+ currentRoute = provider.getCurrentRoute() ?? null;
5225
+ } catch {
5226
+ currentRoute = null;
5227
+ }
5228
+ let segments;
5229
+ try {
5230
+ segments = provider.getSegments?.();
5231
+ } catch {
5232
+ segments = void 0;
5233
+ }
5234
+ let activeTab;
5235
+ try {
5236
+ activeTab = provider.getActiveTab?.();
5237
+ } catch {
5238
+ activeTab = void 0;
5239
+ }
4144
5240
  const snapshot = this.registry.createSnapshot(
4145
- {
4146
- currentRoute: provider.getCurrentRoute(),
4147
- segments: provider.getSegments?.()
4148
- },
5241
+ { currentRoute, segments, activeTab },
4149
5242
  { visibleOnly, currentRouteOnly }
4150
5243
  );
4151
5244
  return { success: true, data: snapshot, timestamp: Date.now() };
@@ -4811,8 +5904,15 @@ var NativeUIBridgeServer = class {
4811
5904
  }
4812
5905
  try {
4813
5906
  const response = await this.routeRequest(request);
5907
+ if (response.code === "METHOD_NOT_ALLOWED") {
5908
+ const allow = allowedMethodsForPath(request.path, this.config.testHooks === true);
5909
+ if (allow.length > 0) {
5910
+ headers["Allow"] = allow.join(", ");
5911
+ if (this.config.cors) headers["Access-Control-Expose-Headers"] = "Allow";
5912
+ }
5913
+ }
4814
5914
  return {
4815
- status: response.success ? 200 : 400,
5915
+ status: httpStatusForResponse(response),
4816
5916
  headers,
4817
5917
  body: JSON.stringify(response)
4818
5918
  };
@@ -4869,7 +5969,7 @@ var NativeUIBridgeServer = class {
4869
5969
  timestamp: Date.now()
4870
5970
  };
4871
5971
  }
4872
- let patternMatchedButWrongVerb = false;
5972
+ let patternMatchedButWrongVerb = DISCOVERY_PATHS.has(stripped);
4873
5973
  for (const route of WS_ROUTES) {
4874
5974
  const pathParams = parsePath(route.pattern, stripped);
4875
5975
  if (!pathParams) continue;
@@ -4886,9 +5986,10 @@ var NativeUIBridgeServer = class {
4886
5986
  const ctx = { params: pathParams, query, body };
4887
5987
  return this.handlers[route.handler](ctx);
4888
5988
  }
5989
+ const allowed = patternMatchedButWrongVerb ? allowedMethodsForPath(path, testHooks) : [];
4889
5990
  return {
4890
5991
  success: false,
4891
- error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}` : `Route not found: ${method} ${path}`,
5992
+ error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}${allowed.length > 0 ? ` (allowed: ${allowed.join(", ")})` : ""}` : `Route not found: ${method} ${path}`,
4892
5993
  code: patternMatchedButWrongVerb ? "METHOD_NOT_ALLOWED" : "NOT_FOUND",
4893
5994
  timestamp: Date.now()
4894
5995
  };
@@ -5192,6 +6293,31 @@ function createTransportLogger() {
5192
6293
  }
5193
6294
  var transportLogger = createTransportLogger();
5194
6295
 
6296
+ // src/transport/relay-logging.ts
6297
+ var CREDENTIAL_PARAMS = ["token", "access_token", "auth", "authorization", "key", "sig"];
6298
+ var CREDENTIAL_PARAM_RE = new RegExp(`([?&](?:${CREDENTIAL_PARAMS.join("|")})=)[^&#]*`, "gi");
6299
+ var USERINFO_RE = /^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i;
6300
+ function redactRelayUrl(url) {
6301
+ return url.replace(USERINFO_RE, "$1<redacted>@").replace(CREDENTIAL_PARAM_RE, "$1<redacted>");
6302
+ }
6303
+ function describeCloseEvent(event) {
6304
+ const e = event ?? {};
6305
+ const code = typeof e.code === "number" ? String(e.code) : "unknown";
6306
+ const reason = typeof e.reason === "string" && e.reason.length > 0 ? e.reason : "(none)";
6307
+ const wasClean = typeof e.wasClean === "boolean" ? String(e.wasClean) : "unknown";
6308
+ return `code=${code} reason=${reason} wasClean=${wasClean}`;
6309
+ }
6310
+ function describeErrorEvent(event) {
6311
+ const e = event ?? {};
6312
+ const parts = [];
6313
+ if (typeof e.message === "string" && e.message.length > 0) parts.push(`message=${e.message}`);
6314
+ if (e.error instanceof Error) parts.push(`error=${e.error.message}`);
6315
+ else if (typeof e.error === "string" && e.error.length > 0) parts.push(`error=${e.error}`);
6316
+ if (typeof e.code === "number") parts.push(`code=${e.code}`);
6317
+ if (parts.length === 0) parts.push("message=(none reported by the transport)");
6318
+ return parts.join(" ");
6319
+ }
6320
+
5195
6321
  // src/transport/DeviceAnnouncer.ts
5196
6322
  var DEFAULT_PORT = 8087;
5197
6323
  var RECONNECT_INITIAL_DELAY_MS = 2e3;
@@ -5261,7 +6387,8 @@ var DeviceAnnouncer = class {
5261
6387
  }
5262
6388
  if (this.stopped) return;
5263
6389
  const url = `${this.config.cloudRelayUrl}?token=${encodeURIComponent(this.config.cloudToken)}`;
5264
- transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", url);
6390
+ const safeUrl = redactRelayUrl(url);
6391
+ transportLogger.log("[DeviceAnnouncer] Cloud relay: connecting to", safeUrl);
5265
6392
  const ws = new WebSocket(url);
5266
6393
  this.cloudWs = ws;
5267
6394
  ws.onopen = () => {
@@ -5290,14 +6417,22 @@ var DeviceAnnouncer = class {
5290
6417
  } catch {
5291
6418
  }
5292
6419
  };
5293
- ws.onclose = () => {
6420
+ ws.onclose = (event) => {
5294
6421
  this.state = { ...this.state, cloudConnected: false };
5295
- if (!this.stopped) {
6422
+ const detail = describeCloseEvent(event);
6423
+ if (this.stopped) {
6424
+ transportLogger.log(`[DeviceAnnouncer] Cloud relay closed ${detail} url=${safeUrl}`);
6425
+ } else {
6426
+ transportLogger.warn(
6427
+ `[DeviceAnnouncer] Cloud relay closed unexpectedly ${detail} url=${safeUrl}`
6428
+ );
5296
6429
  this.scheduleReconnect();
5297
6430
  }
5298
6431
  };
5299
6432
  ws.onerror = (err) => {
5300
- transportLogger.warn("[DeviceAnnouncer] Cloud relay error:", err);
6433
+ transportLogger.warn(
6434
+ `[DeviceAnnouncer] Cloud relay error: ${describeErrorEvent(err)} url=${safeUrl}`
6435
+ );
5301
6436
  };
5302
6437
  }
5303
6438
  // ── Lifecycle ─────────────────────────────────────────────────────────────
@@ -5394,7 +6529,8 @@ var CloudRelayClient = class {
5394
6529
  connect() {
5395
6530
  if (this.stopped) return;
5396
6531
  const url = `${this.config.relayUrl}?token=${encodeURIComponent(this.config.authToken)}`;
5397
- transportLogger.log("[CloudRelayClient] Connecting to", url);
6532
+ const safeUrl = redactRelayUrl(url);
6533
+ transportLogger.log("[CloudRelayClient] Connecting to", safeUrl);
5398
6534
  const ws = new WebSocket(url);
5399
6535
  this.ws = ws;
5400
6536
  ws.onopen = () => {
@@ -5415,14 +6551,22 @@ var CloudRelayClient = class {
5415
6551
  ws.onmessage = (event) => {
5416
6552
  void this.onMessage(event.data);
5417
6553
  };
5418
- ws.onclose = () => {
6554
+ ws.onclose = (event) => {
5419
6555
  this._isConnected = false;
5420
- if (!this.stopped) {
6556
+ const detail = describeCloseEvent(event);
6557
+ if (this.stopped) {
6558
+ transportLogger.log(`[CloudRelayClient] WebSocket closed ${detail} url=${safeUrl}`);
6559
+ } else {
6560
+ transportLogger.warn(
6561
+ `[CloudRelayClient] WebSocket closed unexpectedly ${detail} url=${safeUrl}`
6562
+ );
5421
6563
  this.scheduleReconnect();
5422
6564
  }
5423
6565
  };
5424
6566
  ws.onerror = (err) => {
5425
- transportLogger.warn("[CloudRelayClient] WebSocket error:", err);
6567
+ transportLogger.warn(
6568
+ `[CloudRelayClient] WebSocket error: ${describeErrorEvent(err)} url=${safeUrl}`
6569
+ );
5426
6570
  };
5427
6571
  }
5428
6572
  async onMessage(raw) {
@@ -5701,10 +6845,20 @@ var UndoTracker = class {
5701
6845
  }
5702
6846
  };
5703
6847
  var UIBridgeNativeContext = react.createContext(null);
6848
+ var EMPTY_FEATURES = {};
6849
+ var EMPTY_CONFIG = {};
6850
+ function useValueStable(value) {
6851
+ const signature = JSON.stringify(value);
6852
+ return react.useMemo(() => value, [signature]);
6853
+ }
6854
+ function readWindowViewport() {
6855
+ const win = reactNative.Dimensions.get("window");
6856
+ return { width: win.width, height: win.height };
6857
+ }
5704
6858
  function UIBridgeNativeProvider({
5705
6859
  children,
5706
- features = {},
5707
- config = {},
6860
+ features: featuresProp = EMPTY_FEATURES,
6861
+ config: configProp = EMPTY_CONFIG,
5708
6862
  onEvent,
5709
6863
  serverAdapter,
5710
6864
  navigationProvider,
@@ -5715,6 +6869,8 @@ function UIBridgeNativeProvider({
5715
6869
  enableMdnsAnnounce,
5716
6870
  deviceId
5717
6871
  }) {
6872
+ const features = useValueStable(featuresProp);
6873
+ const config = useValueStable(configProp);
5718
6874
  const registryRef = react.useRef(null);
5719
6875
  const executorRef = react.useRef(null);
5720
6876
  const modalDetectorRef = react.useRef(null);
@@ -5727,6 +6883,7 @@ function UIBridgeNativeProvider({
5727
6883
  onEvent
5728
6884
  });
5729
6885
  registryRef.current.setPixelRatio(reactNative.PixelRatio.get());
6886
+ registryRef.current.setViewportProvider(readWindowViewport);
5730
6887
  setGlobalRegistry(registryRef.current);
5731
6888
  }
5732
6889
  const registry = registryRef.current;
@@ -5761,16 +6918,10 @@ function UIBridgeNativeProvider({
5761
6918
  // the `testHooks` value when undefined, so passing it through verbatim
5762
6919
  // keeps existing consumers unchanged.
5763
6920
  observability: features.observability,
5764
- // Injected device-viewport getter for POST /control/page-health. The
5765
- // server doesn't import `react-native` itself — the require/import
5766
- // pattern there crashed the host RN app in 0.6.3/0.6.4 (Metro/Hermes
5767
- // raised `unknownModuleError` past every try/catch). This provider
5768
- // already has a live react-native import, so it's the safe injection
5769
- // point. See feedback_metro_require_gotcha for the full incident.
5770
- viewportProvider: () => {
5771
- const win = reactNative.Dimensions.get("window");
5772
- return { width: win.width, height: win.height };
5773
- }
6921
+ // Injected device-viewport getter for POST /control/page-health and the
6922
+ // design handlers. Same source as the registry's clipping bounds — see
6923
+ // `readWindowViewport`.
6924
+ viewportProvider: readWindowViewport
5774
6925
  });
5775
6926
  server.setAdapter(serverAdapter);
5776
6927
  if (navigationProvider) {
@@ -5868,10 +7019,7 @@ function UIBridgeNativeProvider({
5868
7019
  appInfo: config.appInfo,
5869
7020
  testHooks: features.testHooks === true,
5870
7021
  observability: features.observability,
5871
- viewportProvider: () => {
5872
- const win = reactNative.Dimensions.get("window");
5873
- return { width: win.width, height: win.height };
5874
- }
7022
+ viewportProvider: readWindowViewport
5875
7023
  });
5876
7024
  if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
5877
7025
  if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
@@ -6076,6 +7224,7 @@ function useUIElement(options) {
6076
7224
  const propsRef = react.useRef({});
6077
7225
  const registeredRef = react.useRef(false);
6078
7226
  const registeredIdRef = react.useRef(null);
7227
+ const autoPublishedLabelRef = react.useRef(void 0);
6079
7228
  const bridgeRef = react.useRef(bridge);
6080
7229
  bridgeRef.current = bridge;
6081
7230
  const nodeRef = react.useRef(null);
@@ -6115,6 +7264,7 @@ function useUIElement(options) {
6115
7264
  autoRegister = true,
6116
7265
  onStateChange,
6117
7266
  parentPath,
7267
+ scrollAncestorId,
6118
7268
  style,
6119
7269
  stateStyles: stateStylesProp,
6120
7270
  handlers: handlersProp,
@@ -6139,22 +7289,38 @@ function useUIElement(options) {
6139
7289
  testId: id,
6140
7290
  accessibilityLabel: label,
6141
7291
  registrationRoute: bridge.getCurrentRoute(),
7292
+ scrollAncestorId,
6142
7293
  flatStyle: flattenStyle(style),
6143
7294
  stateStyles: flattenStateStyles(stateStylesProp)
6144
7295
  });
6145
7296
  registeredRef.current = true;
6146
7297
  registeredIdRef.current = id;
7298
+ autoPublishedLabelRef.current = typeof label === "string" ? label : void 0;
6147
7299
  if (typeof valueProp === "string") {
6148
7300
  bridge.registry.updateElementState(id, { value: valueProp });
6149
7301
  }
6150
7302
  setRegistered(true);
6151
7303
  scheduleDeadButtonWarning(bridge, id, type);
6152
- }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp, valueProp, ref]);
7304
+ }, [
7305
+ bridge,
7306
+ id,
7307
+ type,
7308
+ label,
7309
+ actions,
7310
+ customActions,
7311
+ treePath,
7312
+ scrollAncestorId,
7313
+ style,
7314
+ stateStylesProp,
7315
+ valueProp,
7316
+ ref
7317
+ ]);
6153
7318
  const unregister = react.useCallback(() => {
6154
7319
  if (!bridge || !registeredRef.current) return;
6155
7320
  bridge.registry.unregisterElement(registeredIdRef.current ?? id);
6156
7321
  registeredRef.current = false;
6157
7322
  registeredIdRef.current = null;
7323
+ autoPublishedLabelRef.current = void 0;
6158
7324
  setRegistered(false);
6159
7325
  }, [bridge, id]);
6160
7326
  const onStateChangeRef = react.useRef(onStateChange);
@@ -6182,7 +7348,7 @@ function useUIElement(options) {
6182
7348
  writeLayout({ x, y, width, height, pageX, pageY });
6183
7349
  });
6184
7350
  } else {
6185
- writeLayout({ x, y, width, height, pageX: x, pageY: y });
7351
+ writeLayout({ x, y, width, height, pageX: x, pageY: y, pageOriginUnmeasured: true });
6186
7352
  }
6187
7353
  },
6188
7354
  // `ref` is the once-per-hook interceptor object — identity-stable.
@@ -6294,6 +7460,16 @@ function useUIElement(options) {
6294
7460
  if (current === valueProp) return;
6295
7461
  bridge.registry.updateElementState(id, { value: valueProp });
6296
7462
  }, [bridge, registered, id, valueProp]);
7463
+ react.useEffect(() => {
7464
+ if (!bridge || !registered) return;
7465
+ if (typeof label !== "string") return;
7466
+ autoPublishedLabelRef.current = label;
7467
+ bridge.registry.updateElementMeta(id, {
7468
+ label,
7469
+ accessibilityLabel: label,
7470
+ testId: id
7471
+ });
7472
+ }, [bridge, registered, id, label]);
6297
7473
  react.useCallback(
6298
7474
  (props) => {
6299
7475
  propsRef.current = { ...propsRef.current, ...props };
@@ -6336,7 +7512,8 @@ function useUIElement(options) {
6336
7512
  accessibilityLabel: newLabel,
6337
7513
  testId: id
6338
7514
  });
6339
- if (!changed && !warnedRedundantUpdateLabelIds.has(id)) {
7515
+ const alreadyPublishedBySdk = newLabel === autoPublishedLabelRef.current;
7516
+ if (!changed && !alreadyPublishedBySdk && !warnedRedundantUpdateLabelIds.has(id)) {
6340
7517
  warnedRedundantUpdateLabelIds.add(id);
6341
7518
  console.warn(
6342
7519
  `[UI Bridge] updateLabel("${newLabel}") on element "${id}" was a no-op (label unchanged). Gate the call behind a useEffect with [label] deps to avoid redundant publishes.`
@@ -6392,36 +7569,56 @@ function useUIElementWithProps(options) {
6392
7569
  }
6393
7570
  function useUIComponent(options) {
6394
7571
  const bridge = useUIBridgeNativeOptional();
7572
+ const [registered, setRegistered] = react.useState(false);
7573
+ const [publishVersion, setPublishVersion] = react.useState(0);
7574
+ const publishedIdRef = react.useRef(null);
6395
7575
  const registeredRef = react.useRef(false);
6396
7576
  const registeredIdRef = react.useRef(null);
6397
7577
  const actionsRef = react.useRef(options.actions || []);
6398
7578
  const elementIdsRef = react.useRef(options.elementIds || []);
6399
7579
  const { id, name, description, autoRegister = true } = options;
7580
+ const actionsSignature = JSON.stringify(
7581
+ (options.actions ?? []).map((a) => [a.id, a.label, a.description, a.paramSchema, a.effect])
7582
+ );
7583
+ const elementIdsSignature = JSON.stringify(options.elementIds ?? []);
6400
7584
  react.useEffect(() => {
6401
- actionsRef.current = options.actions || [];
6402
- elementIdsRef.current = options.elementIds || [];
6403
- }, [options.actions, options.elementIds]);
7585
+ if (options.actions !== void 0) actionsRef.current = options.actions;
7586
+ }, [options.actions]);
7587
+ react.useEffect(() => {
7588
+ if (options.elementIds !== void 0) elementIdsRef.current = options.elementIds;
7589
+ }, [elementIdsSignature]);
7590
+ const toRegistryActions = react.useCallback(
7591
+ () => actionsRef.current.map(({ handler: declaredHandler, ...rest }) => ({
7592
+ ...rest,
7593
+ handler: (params, handlerOptions) => {
7594
+ const latest = actionsRef.current.find((a) => a.id === rest.id);
7595
+ return (latest?.handler ?? declaredHandler)(params, handlerOptions);
7596
+ }
7597
+ })),
7598
+ []
7599
+ );
6404
7600
  const register = react.useCallback(() => {
6405
7601
  if (!bridge || registeredRef.current) return;
6406
7602
  bridge.registry.registerComponent(id, {
6407
7603
  name,
6408
7604
  description,
6409
- actions: actionsRef.current.map((a) => ({
6410
- id: a.id,
6411
- label: a.label,
6412
- description: a.description,
6413
- handler: a.handler
6414
- })),
7605
+ actions: toRegistryActions(),
6415
7606
  elementIds: elementIdsRef.current
6416
7607
  });
6417
7608
  registeredRef.current = true;
6418
7609
  registeredIdRef.current = id;
6419
- }, [bridge, id, name, description]);
7610
+ setRegistered(true);
7611
+ if (publishedIdRef.current !== id) {
7612
+ publishedIdRef.current = id;
7613
+ setPublishVersion((v) => v + 1);
7614
+ }
7615
+ }, [bridge, id, name, description, toRegistryActions]);
6420
7616
  const unregister = react.useCallback(() => {
6421
7617
  if (!bridge || !registeredRef.current) return;
6422
7618
  bridge.registry.unregisterComponent(registeredIdRef.current ?? id);
6423
7619
  registeredRef.current = false;
6424
7620
  registeredIdRef.current = null;
7621
+ setRegistered(false);
6425
7622
  }, [bridge, id]);
6426
7623
  const executeAction = react.useCallback(
6427
7624
  async (actionId, params) => {
@@ -6439,25 +7636,47 @@ function useUIComponent(options) {
6439
7636
  },
6440
7637
  [bridge, id]
6441
7638
  );
7639
+ const publishElementIds = react.useCallback(() => {
7640
+ if (!registeredRef.current || !bridge) return;
7641
+ const changed = bridge.registry.updateComponentMeta(registeredIdRef.current ?? id, {
7642
+ elementIds: elementIdsRef.current
7643
+ });
7644
+ if (changed) {
7645
+ setPublishVersion((v) => v + 1);
7646
+ }
7647
+ }, [bridge, id]);
6442
7648
  const updateActions = react.useCallback(
6443
7649
  (actions) => {
6444
7650
  actionsRef.current = actions;
6445
7651
  if (registeredRef.current && bridge) {
6446
- bridge.registry.unregisterComponent(id);
6447
- registeredRef.current = false;
6448
- register();
7652
+ const targetId = registeredIdRef.current ?? id;
7653
+ const changed = bridge.registry.updateComponentMeta(targetId, {
7654
+ actions: toRegistryActions()
7655
+ });
7656
+ if (changed) {
7657
+ setPublishVersion((v) => v + 1);
7658
+ }
6449
7659
  }
6450
7660
  },
6451
- [bridge, id, register]
7661
+ [bridge, id, toRegistryActions]
7662
+ );
7663
+ const addElement = react.useCallback(
7664
+ (elementId) => {
7665
+ if (!elementIdsRef.current.includes(elementId)) {
7666
+ elementIdsRef.current = [...elementIdsRef.current, elementId];
7667
+ publishElementIds();
7668
+ }
7669
+ },
7670
+ [publishElementIds]
7671
+ );
7672
+ const removeElement = react.useCallback(
7673
+ (elementId) => {
7674
+ if (!elementIdsRef.current.includes(elementId)) return;
7675
+ elementIdsRef.current = elementIdsRef.current.filter((eid) => eid !== elementId);
7676
+ publishElementIds();
7677
+ },
7678
+ [publishElementIds]
6452
7679
  );
6453
- const addElement = react.useCallback((elementId) => {
6454
- if (!elementIdsRef.current.includes(elementId)) {
6455
- elementIdsRef.current = [...elementIdsRef.current, elementId];
6456
- }
6457
- }, []);
6458
- const removeElement = react.useCallback((elementId) => {
6459
- elementIdsRef.current = elementIdsRef.current.filter((eid) => eid !== elementId);
6460
- }, []);
6461
7680
  const registerRef = react.useRef(register);
6462
7681
  const unregisterRef = react.useRef(unregister);
6463
7682
  react.useEffect(() => {
@@ -6473,13 +7692,32 @@ function useUIComponent(options) {
6473
7692
  unregisterRef.current();
6474
7693
  }
6475
7694
  };
6476
- }, [autoRegister, bridge]);
6477
- const registeredComponent = react.useMemo(() => {
6478
- if (!bridge) return null;
6479
- return bridge.registry.getComponent(id) || null;
6480
- }, [bridge, id]);
7695
+ }, [autoRegister, bridge, id]);
7696
+ react.useEffect(() => {
7697
+ if (!bridge || !registered) return;
7698
+ const targetId = registeredIdRef.current ?? id;
7699
+ const changed = bridge.registry.updateComponentMeta(targetId, {
7700
+ name,
7701
+ description,
7702
+ ...options.actions !== void 0 ? { actions: toRegistryActions() } : {},
7703
+ ...options.elementIds !== void 0 ? { elementIds: elementIdsRef.current } : {}
7704
+ });
7705
+ if (changed) {
7706
+ setPublishVersion((v) => v + 1);
7707
+ }
7708
+ }, [
7709
+ bridge,
7710
+ registered,
7711
+ id,
7712
+ name,
7713
+ description,
7714
+ actionsSignature,
7715
+ elementIdsSignature,
7716
+ toRegistryActions
7717
+ ]);
7718
+ const registeredComponent = registered && bridge ? bridge.registry.getComponent(id) ?? null : null;
6481
7719
  return {
6482
- registered: registeredRef.current,
7720
+ registered,
6483
7721
  executeAction,
6484
7722
  register,
6485
7723
  unregister,
@@ -6637,12 +7875,31 @@ function TransitionTo(props) {
6637
7875
 
6638
7876
  // src/server/types.ts
6639
7877
  var UI_BRIDGE_NATIVE_ROUTES = {
7878
+ // Discovery / introspection — the two endpoints an agent hits first to learn
7879
+ // the rest of this table without reading docs. Special-cased in
7880
+ // `routeRequest` rather than living in `WS_ROUTES`, and appended by
7881
+ // `buildRoutesPayload` so `_routes` describes itself.
7882
+ HELP: {
7883
+ method: "GET",
7884
+ path: "/ui-bridge/_help",
7885
+ description: "Route index + action reference + tips (rich discovery payload)"
7886
+ },
7887
+ ROUTES: {
7888
+ method: "GET",
7889
+ path: "/ui-bridge/_routes",
7890
+ description: "Minimal {method, path} route list, matching the runner /_routes shape"
7891
+ },
6640
7892
  // Control - Elements
6641
7893
  GET_ELEMENTS: {
6642
7894
  method: "GET",
6643
7895
  path: "/ui-bridge/control/elements",
6644
7896
  description: "List all registered elements"
6645
7897
  },
7898
+ GET_ELEMENTS_FLAT: {
7899
+ method: "GET",
7900
+ path: "/ui-bridge/elements",
7901
+ description: "List all registered elements (flat alias matching the runner path)"
7902
+ },
6646
7903
  GET_ELEMENT: {
6647
7904
  method: "GET",
6648
7905
  path: "/ui-bridge/control/element/:id",
@@ -6680,6 +7937,11 @@ var UI_BRIDGE_NATIVE_ROUTES = {
6680
7937
  path: "/ui-bridge/control/find",
6681
7938
  description: "Find elements matching criteria"
6682
7939
  },
7940
+ AI_FIND: {
7941
+ method: "POST",
7942
+ path: "/ui-bridge/ai/find",
7943
+ description: "Find elements matching criteria (runner-parity alias for /control/find)"
7944
+ },
6683
7945
  GET_SNAPSHOT: {
6684
7946
  method: "GET",
6685
7947
  path: "/ui-bridge/control/snapshot",
@@ -6690,11 +7952,78 @@ var UI_BRIDGE_NATIVE_ROUTES = {
6690
7952
  path: "/ui-bridge/control/discover",
6691
7953
  description: "Discover elements (alias for snapshot; web/runner parity)"
6692
7954
  },
7955
+ DISCOVER_POST: {
7956
+ method: "POST",
7957
+ path: "/ui-bridge/control/discover",
7958
+ description: "Discover elements over POST (same handler as the GET form; web/runner parity)"
7959
+ },
6693
7960
  PAGE_HEALTH: {
6694
7961
  method: "POST",
6695
7962
  path: "/ui-bridge/control/page-health",
6696
7963
  description: "Holistic page health diagnostic: spatial coverage, layout regions, text signals, interactive readiness, anomalies + ASCII heatmap"
6697
7964
  },
7965
+ PAGE_HEALTH_GET: {
7966
+ method: "GET",
7967
+ path: "/ui-bridge/control/page-health",
7968
+ description: "Page health diagnostic over GET, for ad-hoc curls with no body"
7969
+ },
7970
+ // Screenshots — GET and POST both accepted so a curl works either way.
7971
+ //
7972
+ // Both keys carry an explicit verb suffix. The pre-existing multi-verb pairs
7973
+ // do not agree on what an UNSUFFIXED key means — `DISCOVER` is the GET and
7974
+ // `PAGE_HEALTH` is the POST — so a consumer reading `ROUTES.<NAME>.method`
7975
+ // off a bare name gets opposite verbs from equally unmarked keys. Those two
7976
+ // names are load-bearing for existing consumers and are left alone; new
7977
+ // multi-verb entries name their verb rather than entrenching the ambiguity.
7978
+ SCREENSHOT_GET: {
7979
+ method: "GET",
7980
+ path: "/ui-bridge/control/screenshot",
7981
+ description: "PNG screenshot of the current screen"
7982
+ },
7983
+ SCREENSHOT_POST: {
7984
+ method: "POST",
7985
+ path: "/ui-bridge/control/screenshot",
7986
+ description: "PNG screenshot of the current screen (POST form)"
7987
+ },
7988
+ // Coordinate-based interaction
7989
+ CONTROL_TAP: {
7990
+ method: "POST",
7991
+ path: "/ui-bridge/control/tap",
7992
+ description: "Synthesize a press at screen coords by matching registered element layout rects: body { x, y, action? }"
7993
+ },
7994
+ // App-agnostic interaction parity with the web/runner surface. The
7995
+ // selector-based pair has no RN analog and answers NOT_SUPPORTED (501).
7996
+ PAGE_CLICK_BY_TEXT: {
7997
+ method: "POST",
7998
+ path: "/ui-bridge/control/page/click-by-text",
7999
+ description: "Press the first element matching free text: body { text, visibleOnly? }"
8000
+ },
8001
+ PAGE_CLICK_BY_SELECTOR: {
8002
+ method: "POST",
8003
+ path: "/ui-bridge/control/page/click-by-selector",
8004
+ description: "Selector-based press (web-only; mobile returns NOT_SUPPORTED)"
8005
+ },
8006
+ PAGE_TYPE_INTO: {
8007
+ method: "POST",
8008
+ path: "/ui-bridge/control/page/type-into",
8009
+ description: "Type into the input matching a label: body { label, text, clear?, visibleOnly? }"
8010
+ },
8011
+ PAGE_READ_VALUE: {
8012
+ method: "POST",
8013
+ path: "/ui-bridge/control/page/read-value",
8014
+ description: "Selector-based value read (web-only; mobile returns NOT_SUPPORTED)"
8015
+ },
8016
+ PAGE_FIND_BY_TEXT: {
8017
+ method: "POST",
8018
+ path: "/ui-bridge/control/page/find-by-text",
8019
+ description: "List elements matching free text without pressing: body { text, visibleOnly? }"
8020
+ },
8021
+ // Meta
8022
+ META_METHODS: {
8023
+ method: "GET",
8024
+ path: "/ui-bridge/meta/methods",
8025
+ description: "List every handler name (legacy introspection; prefer /_routes)"
8026
+ },
6698
8027
  // Workflows
6699
8028
  GET_WORKFLOWS: {
6700
8029
  method: "GET",
@@ -6773,6 +8102,27 @@ var UI_BRIDGE_NATIVE_ROUTES = {
6773
8102
  path: "/ui-bridge/design/style-guide",
6774
8103
  description: "Clear loaded style guide"
6775
8104
  },
8105
+ // Quality Evaluation
8106
+ DESIGN_EVALUATE: {
8107
+ method: "POST",
8108
+ path: "/ui-bridge/design/evaluate",
8109
+ description: "Score the current UI against a quality context"
8110
+ },
8111
+ DESIGN_EVALUATE_CONTEXTS: {
8112
+ method: "GET",
8113
+ path: "/ui-bridge/design/evaluate/contexts",
8114
+ description: "List available quality evaluation contexts"
8115
+ },
8116
+ DESIGN_EVALUATE_BASELINE: {
8117
+ method: "POST",
8118
+ path: "/ui-bridge/design/evaluate/baseline",
8119
+ description: "Save a quality baseline snapshot"
8120
+ },
8121
+ DESIGN_EVALUATE_DIFF: {
8122
+ method: "POST",
8123
+ path: "/ui-bridge/design/evaluate/diff",
8124
+ description: "Diff the current UI against a saved baseline"
8125
+ },
6776
8126
  // AI helpers
6777
8127
  AI_FILL_FORM: {
6778
8128
  method: "POST",
@@ -6804,18 +8154,34 @@ var UI_BRIDGE_NATIVE_ROUTES = {
6804
8154
  path: "/ui-bridge/ai/wait-for-element",
6805
8155
  description: "Wait for element predicate via HTTP (runner-only; mobile returns NOT_SUPPORTED \u2014 use WS waitForElement instead)"
6806
8156
  },
6807
- // Test hooks — drive ModalDetector state from outside the React tree
6808
- // (gated by `features.testHooks`). Mirrors `pushModal` / `dismissModal`
6809
- // calls that components normally make in-process.
8157
+ // Test hooks — drive ModalDetector state and the screen lock from outside
8158
+ // the React tree (gated by `features.testHooks`). Mirrors `pushModal` /
8159
+ // `dismissModal` calls that components normally make in-process.
8160
+ //
8161
+ // These are LISTED unconditionally — this table is a static constant with no
8162
+ // config to consult — but each carries `requiresTestHooks: true`, which is
8163
+ // the machine-readable form of the `(testHooks)` in its description. With the
8164
+ // flag off `routeRequest` and `buildRoutesPayload` both drop them and the
8165
+ // route answers a bare 404. `route-table-parity.test.ts` compares the whole
8166
+ // table against `_routes` at `testHooks: true`, and the flagged subset
8167
+ // against `_routes` at `testHooks: false`, so both surfaces are pinned.
6810
8168
  PUSH_MODAL: {
6811
8169
  method: "POST",
6812
8170
  path: "/ui-bridge/control/modal/push",
6813
- description: "Push a modal onto the modal stack (testHooks)"
8171
+ description: "Push a modal onto the modal stack (testHooks)",
8172
+ requiresTestHooks: true
6814
8173
  },
6815
8174
  DISMISS_MODAL: {
6816
8175
  method: "POST",
6817
8176
  path: "/ui-bridge/control/modal/dismiss/:id",
6818
- description: "Dismiss a modal by id (testHooks)"
8177
+ description: "Dismiss a modal by id (testHooks)",
8178
+ requiresTestHooks: true
8179
+ },
8180
+ KEEP_AWAKE: {
8181
+ method: "POST",
8182
+ path: "/ui-bridge/control/keep-awake",
8183
+ description: "Keep the device screen awake via the injected keepAwakeProvider: body { enabled, durationMs? } (testHooks)",
8184
+ requiresTestHooks: true
6819
8185
  },
6820
8186
  // Observability — last-N ring buffers (always mounted; `installed:false`
6821
8187
  // when the console/network patches are off, i.e. no testHooks).
@@ -7642,9 +9008,13 @@ var styles = reactNative.StyleSheet.create({
7642
9008
  });
7643
9009
 
7644
9010
  exports.CloudRelayClient = CloudRelayClient;
9011
+ exports.DEFAULT_PARAM_VALIDATION_MODE = DEFAULT_PARAM_VALIDATION_MODE;
7645
9012
  exports.DefaultNativeActionExecutor = DefaultNativeActionExecutor;
7646
9013
  exports.DeviceAnnouncer = DeviceAnnouncer;
9014
+ exports.MAX_ACTION_TIMEOUT_MS = MAX_ACTION_TIMEOUT_MS;
7647
9015
  exports.ModalDetector = ModalDetector;
9016
+ exports.NATIVE_STANDARD_ACTION_EFFECTS = NATIVE_STANDARD_ACTION_EFFECTS;
9017
+ exports.NativeActionError = NativeActionError;
7648
9018
  exports.NativeUIBridgeRegistry = NativeUIBridgeRegistry;
7649
9019
  exports.NativeUIBridgeServer = NativeUIBridgeServer;
7650
9020
  exports.State = State;
@@ -7662,6 +9032,7 @@ exports.buildUpgradeResponse = buildUpgradeResponse;
7662
9032
  exports.captureNativeResponsiveSnapshot = captureNativeResponsiveSnapshot;
7663
9033
  exports.captureNativeStateVariations = captureNativeStateVariations;
7664
9034
  exports.computeAcceptKey = computeAcceptKey;
9035
+ exports.computeVisibility = computeVisibility;
7665
9036
  exports.createDesignHandlers = createDesignHandlers;
7666
9037
  exports.createNativeActionExecutor = createNativeActionExecutor;
7667
9038
  exports.createNativeElementIdentifier = createNativeElementIdentifier;
@@ -7672,17 +9043,30 @@ exports.deriveText = deriveText;
7672
9043
  exports.extractHandlerNames = extractHandlerNames;
7673
9044
  exports.findAllByPattern = findAllByPattern;
7674
9045
  exports.findElementByIdentifier = findElementByIdentifier;
9046
+ exports.formatParamValidationFailure = formatParamValidationFailure;
9047
+ exports.getDefaultParamValidationMode = getDefaultParamValidationMode;
7675
9048
  exports.getGlobalRegistry = getGlobalRegistry;
7676
9049
  exports.getNativeElementDesignData = getNativeElementDesignData;
9050
+ exports.httpStatusForResponse = httpStatusForResponse;
9051
+ exports.inertAbortSignal = inertAbortSignal;
9052
+ exports.intersectRects = intersectRects;
7677
9053
  exports.isAddrInUseError = isAddrInUseError;
9054
+ exports.isEmptyRect = isEmptyRect;
7678
9055
  exports.mapNativeStyleToExtended = mapNativeStyleToExtended;
7679
9056
  exports.matchesIdentifier = matchesIdentifier;
9057
+ exports.nativeStandardActionEffect = nativeStandardActionEffect;
9058
+ exports.normalizeActionTimeoutMs = normalizeActionTimeoutMs;
9059
+ exports.pageRectOf = pageRectOf;
7680
9060
  exports.parseColorToRgb = parseColorToRgb;
7681
9061
  exports.parseFontSizePx = parseFontSizePx;
7682
9062
  exports.parseTreePath = parseTreePath;
7683
9063
  exports.projectVisionFields = projectVisionFields;
9064
+ exports.resetDefaultParamValidationMode = resetDefaultParamValidationMode;
7684
9065
  exports.resetGlobalRegistry = resetGlobalRegistry;
9066
+ exports.resolveActionEffect = resolveActionEffect;
7685
9067
  exports.roleForType = roleForType;
9068
+ exports.runAbortable = runAbortable;
9069
+ exports.setDefaultParamValidationMode = setDefaultParamValidationMode;
7686
9070
  exports.setGlobalRegistry = setGlobalRegistry;
7687
9071
  exports.useToastRecorder = useToastRecorder;
7688
9072
  exports.useUIBridge = useUIBridge;
@@ -7696,5 +9080,6 @@ exports.useUIComponent = useUIComponent;
7696
9080
  exports.useUIComponentAction = useUIComponentAction;
7697
9081
  exports.useUIElement = useUIElement;
7698
9082
  exports.useUIElementWithProps = useUIElementWithProps;
9083
+ exports.validateActionParams = validateActionParams;
7699
9084
  //# sourceMappingURL=index.js.map
7700
9085
  //# sourceMappingURL=index.js.map