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