@qontinui/ui-bridge-native 0.6.12 → 0.6.14

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 (66) 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 +1568 -135
  33. package/dist/index.js.map +1 -1
  34. package/dist/index.mjs +1550 -136
  35. package/dist/index.mjs.map +1 -1
  36. package/dist/react/UIBridgeNativeProvider.d.ts +18 -24
  37. package/dist/react/UIBridgeNativeProvider.d.ts.map +1 -1
  38. package/dist/react/index.js +1385 -130
  39. package/dist/react/index.js.map +1 -1
  40. package/dist/react/index.mjs +1385 -130
  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 +35 -7
  61. package/dist/transport/DeviceAnnouncer.d.ts.map +1 -1
  62. package/dist/transport/index.d.ts +1 -1
  63. package/dist/transport/index.d.ts.map +1 -1
  64. package/dist/transport/relay-logging.d.ts +50 -0
  65. package/dist/transport/relay-logging.d.ts.map +1 -0
  66. package/package.json +2 -2
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Parameter-schema validation for component actions.
3
+ *
4
+ * Plan `2026-08-20-ui-bridge-action-declaration-shape`, Phase 2.
5
+ *
6
+ * `ComponentAction.paramSchema` is **published to agents** — it is spread onto
7
+ * `/control/components` and `/control/component/:id` by
8
+ * `server/handlers.ts` `annotateComponentWithInvocationPaths`, and four live
9
+ * runner consumers read it off the wire. Until Phase 2 nothing enforced it, so
10
+ * an agent that read the schema and sent conforming params had no guarantee
11
+ * the handler agreed. That is worse than publishing nothing: it invites trust
12
+ * it cannot honour. This module is what makes the declaration enforceable.
13
+ *
14
+ * ---------------------------------------------------------------------------
15
+ * THE SUPPORTED SUBSET — this is a CROSS-REPO CONTRACT
16
+ * ---------------------------------------------------------------------------
17
+ *
18
+ * `qontinui-runner` reads these same schemas
19
+ * (`workflow_generation/wrapper_manifest.rs`, `commands/command_interpreter.rs`,
20
+ * `bin/wrappers_mcp.rs`), and `command_interpreter.rs` feeds one to an LLM and
21
+ * asks it to produce conforming args. So the grammar below is not a private
22
+ * implementation detail — changing it changes what those consumers may emit.
23
+ *
24
+ * **No dependency.** `@qontinui/ui-bridge` ships to browsers and has exactly
25
+ * one runtime dependency (`dom-accessibility-api`). A full JSON Schema
26
+ * validator (`ajv` and friends) would be the SDK's first heavyweight runtime
27
+ * dep, which the plan rejected on bundle size. Hence a hand-implemented subset,
28
+ * documented here rather than inferred from the code.
29
+ *
30
+ * **Two accepted top-level shapes**, because `useUIComponent`'s own author-
31
+ * facing doc has always sanctioned both:
32
+ *
33
+ * 1. **Object-schema form** — the shape `paramSchemaOf()`
34
+ * (`@qontinui/ui-bridge-wrapper`) emits:
35
+ *
36
+ * ```jsonc
37
+ * { "type": "object",
38
+ * "properties": { "username": { "type": "string" },
39
+ * "remember": { "type": "boolean" } },
40
+ * "required": ["username"],
41
+ * "additionalProperties": false }
42
+ * ```
43
+ *
44
+ * 2. **Map form** — the lightweight shorthand the doc describes as
45
+ * "a map of `{ paramName: "string" | "number" | ... }`":
46
+ *
47
+ * ```jsonc
48
+ * { "username": "string", "count": { "type": "integer", "minimum": 1 } }
49
+ * ```
50
+ *
51
+ * **Disambiguation rule** (deterministic, no heuristics). The schema is read as
52
+ * form 1 **iff** either:
53
+ *
54
+ * - it carries a `properties` object; or
55
+ * - `type === "object"` **and every other top-level key is a JSON Schema
56
+ * object-applicable keyword** ({@link OBJECT_SCHEMA_KEYWORDS}).
57
+ *
58
+ * Everything else is form 2. So `{ "type": "string" }` is a map declaring one
59
+ * param named `type` of type `string`, not a top-level string schema — a
60
+ * params bag is always an object, so a non-object top-level `type` cannot be
61
+ * describing the bag.
62
+ *
63
+ * ⚠️ **The second clause is load-bearing, and a naive `type === "object"` test
64
+ * is a silent-validation-loss bug.** Consider the map form
65
+ * `{ "type": "object", "id": "string" }` — an action taking a param literally
66
+ * named `type` whose hint is `"object"`, plus an `id`. Reading that as form 1
67
+ * would find no `properties`, express no constraint, and **silently drop every
68
+ * sibling declaration in the map**: `validateActionParams(schema, { id: 12345 })`
69
+ * would answer `{ valid: true }` for a schema that plainly says `id` is a
70
+ * string. `id` is not an object keyword, so the keyword test sends it to form 2
71
+ * and both params validate. A param named `type` is not exotic — it is one of
72
+ * the most common names there is.
73
+ *
74
+ * The one genuinely ambiguous input is a bare `{ "type": "object" }`: it is
75
+ * both an empty object schema and a one-entry map declaring a param named
76
+ * `type` of type `object`. It is read as form 1 (the empty object schema),
77
+ * which constrains nothing either way, so the choice is unobservable except in
78
+ * combination with `additionalProperties: false` — where form 1 is clearly the
79
+ * intended reading.
80
+ *
81
+ * ### Map form is a TYPE hint, never a REQUIREDNESS hint
82
+ *
83
+ * A bare string value in map form is read as a type name **only when it is one
84
+ * of the seven JSON Schema primitive names** — `string`, `number`, `integer`,
85
+ * `boolean`, `object`, `array`, `null` — which is exactly `paramSchemaOf`'s
86
+ * own `PRIMITIVE_TYPES` set. **Any other string is prose and constrains
87
+ * nothing.**
88
+ *
89
+ * That is not defensive coding, it is the corpus: a census of every literal
90
+ * `paramSchema` in the fleet (2026-08-22) found ~50 map-form declarations in
91
+ * `qontinui-runner` alone whose values are human-readable hints, not types —
92
+ * `{ count: "number (>= 1, defaults to 1)", account: "string — either a Claude
93
+ * account label … or the literal \"best\"" }`. Reading `"number (>= 1,
94
+ * defaults to 1)"` as a type name would make every one of them unvalidatable
95
+ * noise. The file that declares them says so itself: *"loose `Record<string,
96
+ * unknown>` with hint strings, no runtime validation"*.
97
+ *
98
+ * For the same reason, **map form never marks anything required.** It has no
99
+ * `required` array and no other way to express optionality, and most of those
100
+ * ~50 real declarations describe params that are explicitly optional in their
101
+ * own prose ("defaults to 1", "optional initial prompt"). Requiring by default
102
+ * would report a violation on nearly every real call. An author who needs
103
+ * required params writes form 1, which has a keyword for it.
104
+ *
105
+ * **Optionality in form 1** is exactly `required`: a property is required iff
106
+ * listed there. Note `paramSchemaOf` **omits `required` entirely** when every
107
+ * field is optional, so its absence means "nothing required", never "malformed".
108
+ *
109
+ * `optional: true` is not a JSON Schema keyword — it is this monorepo's
110
+ * convention on `paramSchemaOf`'s *input* descriptors, and `paramSchemaOf`
111
+ * strips it before the schema reaches the wire. It is honoured here (form 2
112
+ * only) purely so an unstripped input-shaped object is read the same way.
113
+ *
114
+ * **Recognised property keywords** — everything else is IGNORED:
115
+ *
116
+ * | Keyword | Applies to | Meaning |
117
+ * |---|---|---|
118
+ * | `type` | any | `"string" \| "number" \| "integer" \| "boolean" \| "object" \| "array" \| "null"`, or an array of those (union — a value matching any member passes). Any OTHER type name constrains nothing (`paramSchemaOf` passes unknown names through verbatim, so they do occur) |
119
+ * | `enum` | any | value must equal one of the listed JSON values |
120
+ * | `const` | any | value must equal this JSON value |
121
+ * | `properties` | objects | per-key sub-schemas, applied recursively |
122
+ * | `required` | objects | array of key names that must be present |
123
+ * | `additionalProperties` | objects | when exactly `false`, keys absent from `properties` are rejected — **including when the schema declares no `properties` at all**, which is the natural spelling of "takes no params". Any other value is ignored |
124
+ * | `items` | arrays | one sub-schema applied to every element, recursively |
125
+ * | `minimum` / `maximum` | numbers | inclusive bounds |
126
+ * | `minLength` / `maxLength` | strings | inclusive bounds on `.length` |
127
+ * | `pattern` | strings | ECMAScript regex, unanchored. An uncompilable **or catastrophically-backtracking** pattern is ignored, never a failure — see {@link MAX_PATTERN_LENGTH} |
128
+ *
129
+ * **⚠ Anything outside this table is IGNORED, never rejected.** `description`,
130
+ * `default`, `title`, `format`, `examples`, `$schema`, `$ref`, `oneOf`,
131
+ * `anyOf`, `allOf`, `not`, `patternProperties`, `minItems`, `exclusiveMinimum`
132
+ * … all pass silently. This is deliberate and load-bearing: a schema outside
133
+ * this subset is still *valid JSON Schema*, and rejecting an unrecognised
134
+ * keyword would convert a documentation nicety into a hard invocation failure
135
+ * for every author who wrote a richer schema than this validator reads. An
136
+ * unreadable schema means "no constraint expressed here", not "invalid".
137
+ * The same rule applies one level up: a `paramSchema` that is not a plain
138
+ * object at all (`null`, an array, a string) expresses no constraint and
139
+ * validates everything.
140
+ *
141
+ * **No coercion.** `"5"` does not satisfy `{"type":"number"}`. The runner's
142
+ * LLM router is already instructed to "coerce numeric tokens to numbers"
143
+ * (`command_interpreter.rs`), so coercing here would hide that contract rather
144
+ * than enforce it.
145
+ *
146
+ * ### A validator must return a verdict, never throw
147
+ *
148
+ * {@link validateActionParams} is called at an invocation seam whose only
149
+ * generic failure label is `UB-ACTION-FAILED` — "the handler failed". A
150
+ * validator that throws would therefore be reported as a *handler* fault, and
151
+ * the author would go debug code that never ran. Two inputs could do it, and
152
+ * both are **author-supplied schema data**, not caller params:
153
+ *
154
+ * - **A self-referential schema.** `const s = { type: 'object', properties: {} };
155
+ * s.properties.self = s;` recurses until `RangeError: Maximum call stack size
156
+ * exceeded`. Bounded by {@link MAX_SCHEMA_DEPTH}: past that depth the walk
157
+ * stops and expresses no further constraint (the ignore-don't-reject rule,
158
+ * applied to depth).
159
+ * - **A catastrophically-backtracking `pattern`.** This one does not throw at
160
+ * all — it *hangs*, which no `try`/`catch` can rescue. Guarded before
161
+ * compilation by {@link MAX_PATTERN_LENGTH} and a nested-quantifier test,
162
+ * the same pair `control/action-executor.ts` already applies to a
163
+ * caller-supplied `srcPattern`.
164
+ *
165
+ * The seams wrap the call anyway (belt and braces) and report a validator
166
+ * fault distinguishably from a handler fault — see the executors.
167
+ *
168
+ * ---------------------------------------------------------------------------
169
+ * DUPLICATE — deliberately not an import
170
+ * ---------------------------------------------------------------------------
171
+ *
172
+ * DUPLICATE OF `@qontinui/ui-bridge` `src/core/param-schema.ts` — DELIBERATELY
173
+ * NOT AN IMPORT, for the same reason as `core/abortable.ts` beside it:
174
+ * `@qontinui/ui-bridge` is an OPTIONAL peerDependency of this package and is
175
+ * absent from `dependencies`, so a consumer may install
176
+ * `@qontinui/ui-bridge-native` alone; importing from it would resolve inside
177
+ * this monorepo and then fail for that consumer.
178
+ *
179
+ * KEEP IN SYNC with `@qontinui/ui-bridge` `src/core/param-schema.ts`. The
180
+ * module has no imports and touches no DOM or Node API — only `RegExp`,
181
+ * `JSON`, `Set` and `Number`, all present in React Native. The documented
182
+ * subset above is a cross-repo contract, so the two copies drifting is a
183
+ * contract break, not a style nit.
184
+ */
185
+ /** Which rule rejected a value. Machine-readable; pairs with `message`. */
186
+ export type ParamSchemaKeyword = 'required' | 'type' | 'enum' | 'const' | 'additionalProperties' | 'minimum' | 'maximum' | 'minLength' | 'maxLength' | 'pattern';
187
+ /**
188
+ * One rejected parameter. `path` is the whole point — an agent told only
189
+ * "invalid params" cannot fix anything, so every issue names the param it is
190
+ * about.
191
+ */
192
+ export interface ParamSchemaIssue {
193
+ /**
194
+ * Location of the offending value, in dotted/bracketed form relative to the
195
+ * params object: `"username"`, `"filter.status"`, `"ids[2]"`. Empty string
196
+ * means the params object itself.
197
+ */
198
+ path: string;
199
+ /** Which rule rejected it. */
200
+ keyword: ParamSchemaKeyword;
201
+ /** What the schema demanded, rendered for a human/agent reader. */
202
+ expected: string;
203
+ /** What was actually supplied, rendered compactly. Absent for `required`. */
204
+ received?: string;
205
+ /** One-line explanation naming both the param and the reason. */
206
+ message: string;
207
+ }
208
+ /** Outcome of {@link validateActionParams}. */
209
+ export interface ParamValidationResult {
210
+ valid: boolean;
211
+ /** Empty iff `valid`. */
212
+ issues: ParamSchemaIssue[];
213
+ }
214
+ /**
215
+ * What the executor does with a schema violation.
216
+ *
217
+ * - `'enforce'` — reject the invocation before the handler runs, with
218
+ * `errorCode: 'UB-ACTION-REJECTED'` and the issue list attached.
219
+ * - `'warn'` — log the violation and invoke the handler anyway.
220
+ * - `'off'` — skip validation entirely (no schema walk, no allocation).
221
+ */
222
+ export type ParamValidationMode = 'enforce' | 'warn' | 'off';
223
+ /**
224
+ * **`'warn'`.** Decided on the plan's priority order — *powerful features →
225
+ * scalability → robustness → clean code* — not on backward compatibility,
226
+ * which is explicitly not a factor on this project.
227
+ *
228
+ * The feature Phase 2 delivers is *an enforceable declaration*, and warn mode
229
+ * delivers all of it except the refusal: the schema is walked, the violating
230
+ * param is named, and the violation is reported. Enforcement is one call to
231
+ * {@link setDefaultParamValidationMode} away, per deployment.
232
+ *
233
+ * What arming `'enforce'` by default would buy is the refusal — and the
234
+ * population it would refuse first is not attackers, it is the fleet's own
235
+ * schemas. Nothing has ever validated these declarations, so on the balance of
236
+ * evidence some existing `paramSchema` values are wrong about their own
237
+ * handlers rather than the callers being wrong about the schema; a validator
238
+ * armed on day one reports that as the caller's failure. Worse, the largest
239
+ * live producer of action params is a *model*:
240
+ * `qontinui-runner/src-tauri/src/commands/command_interpreter.rs` asks an LLM
241
+ * for "args whose keys match the chosen action's `paramSchema`", and that
242
+ * failure surfaces to a user running a slash command. Defaulting to enforce
243
+ * would convert a documentation defect into a user-visible outage on a
244
+ * population that was never measured.
245
+ *
246
+ * Robustness — third in the order, and the tiebreaker here — argues the same
247
+ * way: the robust move is to collect violations from a validator that has
248
+ * never seen production traffic before letting it refuse anything. A default
249
+ * that fails open and is *observable* beats a default that fails closed and is
250
+ * *unmeasured*.
251
+ */
252
+ export declare const DEFAULT_PARAM_VALIDATION_MODE: ParamValidationMode;
253
+ /**
254
+ * Arm or disarm validation process-wide. Call once at app boot; each
255
+ * invocation may still override it via `executeComponentAction`'s
256
+ * `{ paramValidation }` option.
257
+ */
258
+ export declare function setDefaultParamValidationMode(mode: ParamValidationMode): void;
259
+ /** The mode an invocation gets when it does not pass one. */
260
+ export declare function getDefaultParamValidationMode(): ParamValidationMode;
261
+ /** Test/teardown helper: restore {@link DEFAULT_PARAM_VALIDATION_MODE}. */
262
+ export declare function resetDefaultParamValidationMode(): void;
263
+ /**
264
+ * Validate `params` against an action's `paramSchema`.
265
+ *
266
+ * Returns `{ valid: true, issues: [] }` for anything the documented subset
267
+ * cannot read — that is the ignore-don't-reject rule, and it is why a schema
268
+ * this validator does not understand can never fail an invocation.
269
+ *
270
+ * @param schema the action's declared `paramSchema` (any shape; unreadable
271
+ * shapes constrain nothing)
272
+ * @param params the params the caller supplied. `undefined` is treated as an
273
+ * empty object, so a schema with required properties still reports them.
274
+ */
275
+ export declare function validateActionParams(schema: unknown, params: unknown): ParamValidationResult;
276
+ /**
277
+ * One-line summary of a violation set, naming the action and every offending
278
+ * param. Used as the `error` / `message` string at every invocation seam so
279
+ * the three trees phrase the rejection identically.
280
+ */
281
+ export declare function formatParamValidationFailure(componentId: string, actionId: string, issues: ParamSchemaIssue[]): string;
282
+ //# sourceMappingURL=param-schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"param-schema.d.ts","sourceRoot":"","sources":["../../src/core/param-schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuLG;AAEH,2EAA2E;AAC3E,MAAM,MAAM,kBAAkB,GAC1B,UAAU,GACV,MAAM,GACN,MAAM,GACN,OAAO,GACP,sBAAsB,GACtB,SAAS,GACT,SAAS,GACT,WAAW,GACX,WAAW,GACX,SAAS,CAAC;AAEd;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,8BAA8B;IAC9B,OAAO,EAAE,kBAAkB,CAAC;IAC5B,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,+CAA+C;AAC/C,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,OAAO,CAAC;IACf,yBAAyB;IACzB,MAAM,EAAE,gBAAgB,EAAE,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,MAAM,GAAG,KAAK,CAAC;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,6BAA6B,EAAE,mBAA4B,CAAC;AAIzE;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,mBAAmB,GAAG,IAAI,CAE7E;AAED,6DAA6D;AAC7D,wBAAgB,6BAA6B,IAAI,mBAAmB,CAEnE;AAED,2EAA2E;AAC3E,wBAAgB,+BAA+B,IAAI,IAAI,CAEtD;AA6hBD;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,OAAO,GACd,qBAAqB,CA0CvB;AAED;;;;GAIG;AACH,wBAAgB,4BAA4B,CAC1C,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,gBAAgB,EAAE,GACzB,MAAM,CAGR"}
@@ -4,7 +4,7 @@
4
4
  * Central registry for all UI elements and components registered with UI Bridge Native.
5
5
  * Adapted from ui-bridge for React Native environments.
6
6
  */
7
- import type { RegisteredNativeElement, RegisteredNativeComponent, NativeElementState, NativeElementType, NativeStandardAction, NativeCustomAction, NativeAppInfo, NativeBridgeSnapshot, NativeElementBbox, NativeElementRef, NativeRegistrationCoverage, NativeRouteProviderLike, NativeSnapshotEnrichers, NativeSnapshotEnricher, Workflow, BridgeEventType, BridgeEventListener } from './types';
7
+ import type { RegisteredNativeElement, RegisteredNativeComponent, NativeElementState, NativeElementType, NativeStandardAction, NativeCustomAction, NativeAppInfo, NativeBridgeSnapshot, NativeElementBbox, NativeElementRef, NativeRegistrationCoverage, NativeRouteProviderLike, NativeSnapshotEnrichers, NativeSnapshotEnricher, Workflow, BridgeEventType, BridgeEventListener, IREffect } from './types';
8
8
  /**
9
9
  * Options for registering an element
10
10
  */
@@ -19,6 +19,24 @@ export interface RegisterElementOptions {
19
19
  accessibilityLabel?: string;
20
20
  /** Route path where the element was registered (for page-scoped filtering) */
21
21
  registrationRoute?: string | null;
22
+ /**
23
+ * Id of the registered scrollable container this element lives inside.
24
+ *
25
+ * When set, {@link computeVisibility} clips the element against that
26
+ * container's measured frame as well as the window, so a row scrolled out of
27
+ * its `ScrollView` / `FlatList` stops reporting `visibility: 'visible'`.
28
+ *
29
+ * DECLARED, not inferred, and that is deliberate. React Native gives the
30
+ * registry no parent chain — `useUIElement` is a hook, so it never wraps its
31
+ * element's children and cannot publish itself as their ancestor. Guessing
32
+ * the ancestor geometrically is worse than not clipping: a full-width scroll
33
+ * container horizontally contains the tab bar and the header too, and
34
+ * clipping those against it would report on-screen chrome as off-screen.
35
+ * A wrong `hidden` is a worse answer than a coarse `visible`.
36
+ *
37
+ * Unset (the default) leaves the window as the only clip region.
38
+ */
39
+ scrollAncestorId?: string;
22
40
  /** Flattened RN style (from StyleSheet.flatten) for design review */
23
41
  flatStyle?: Record<string, unknown>;
24
42
  /** State-specific style overrides for design review */
@@ -38,10 +56,49 @@ export interface RegisterComponentOptions {
38
56
  id: string;
39
57
  label?: string;
40
58
  description?: string;
41
- handler: (params?: unknown) => unknown | Promise<unknown>;
59
+ /**
60
+ * Phase 2 (same plan): the action's published parameter schema. It was
61
+ * ABSENT here and dropped by the object literal in `registerComponent`
62
+ * below, so the field `ComponentAction` declares could never reach a
63
+ * registered native component and the native invocation seam had nothing
64
+ * to validate against. That is the silent-drop trap the plan warns about,
65
+ * already realised.
66
+ */
67
+ paramSchema?: Record<string, unknown>;
68
+ /**
69
+ * Phase 4 (same plan): the safety annotation. Absent here, an author's
70
+ * `effect: 'destructive'` would die at this hop exactly the way
71
+ * `paramSchema` did before Phase 2 — the literal in `registerComponent`
72
+ * has a closed field list and nothing type-checks the omission.
73
+ */
74
+ effect?: IREffect;
75
+ /**
76
+ * Phase 3 (plan 2026-08-20-ui-bridge-action-declaration-shape): the second
77
+ * argument is the `ActionHandlerOptions` bag carrying the cancellation
78
+ * signal. A 1-arity handler stays assignable.
79
+ */
80
+ handler: (params?: unknown, options?: {
81
+ signal?: AbortSignal;
82
+ }) => unknown | Promise<unknown>;
42
83
  }>;
43
84
  elementIds?: string[];
44
85
  }
86
+ /**
87
+ * Options for {@link NativeUIBridgeRegistry.updateComponentMeta}.
88
+ *
89
+ * Every field is optional and `undefined` means "leave the registered value
90
+ * alone" — the same partial-update semantics {@link RegisterComponentOptions}'s
91
+ * sister {@link NativeUIBridgeRegistry.updateElementMeta} uses. A consequence
92
+ * worth stating: a `description` cannot be CLEARED through this door, only
93
+ * replaced. Pass `actions: []` to publish an empty action list; that is
94
+ * distinguishable from omitting the field.
95
+ */
96
+ export interface UpdateComponentMetaOptions {
97
+ name?: string;
98
+ description?: string;
99
+ actions?: RegisterComponentOptions['actions'];
100
+ elementIds?: string[];
101
+ }
45
102
  /**
46
103
  * Registry configuration
47
104
  */
@@ -78,6 +135,29 @@ export declare function extractHandlerNames(props?: Record<string, unknown>): st
78
135
  * at the root, because tab elements had `registrationRoute: null`.
79
136
  */
80
137
  export declare function matchesCurrentRoute(registrationRoute: string | null | undefined, currentRoute: string): boolean;
138
+ /**
139
+ * Derive the snapshot's `activeTab` from an Expo Router segment list.
140
+ *
141
+ * Expo Router spells a layout group as a parenthesised segment and a dynamic
142
+ * route as a bracketed one, so `usePathname()`/`useSegments()` on the "runs"
143
+ * tab of a `(tabs)` layout yield `"/(tabs)/runs"` and `["(tabs)", "runs"]`
144
+ * (the shape the `RouteProvider` doc comment already documents). The tab a user
145
+ * is looking at is therefore the segment immediately following the INNERMOST
146
+ * group.
147
+ *
148
+ * Rules, all deliberately conservative — an unknown tab is reported as absent
149
+ * rather than guessed:
150
+ *
151
+ * - `["(tabs)", "runs"]` → `"runs"`
152
+ * - `["(tabs)", "runs", "[id]"]`→ `"runs"` (a detail screen is still *on* a tab)
153
+ * - `["(tabs)"]` → `"index"` (the group's own index route)
154
+ * - `["(tabs)", "[id]"]` → `undefined` (dynamic route, not a tab)
155
+ * - `["settings"]` → `undefined` (no group layout ⇒ no tabs)
156
+ * - `[]` / `undefined` → `undefined`
157
+ *
158
+ * Exported so the rules are unit-testable without standing up a registry.
159
+ */
160
+ export declare function deriveActiveTabFromSegments(segments?: string[]): string | undefined;
81
161
  /**
82
162
  * Project an element's runtime state into a runner-native pixel-space `bbox`.
83
163
  *
@@ -105,6 +185,104 @@ export declare function matchesCurrentRoute(registrationRoute: string | null | u
105
185
  * (rare; keeps a best-effort box rather than dropping the element).
106
186
  */
107
187
  export declare function projectBbox(state: NativeElementState, visibility: 'visible' | 'likely-visible' | 'hidden', pixelRatio?: number): NativeElementBbox | undefined;
188
+ /**
189
+ * A rectangle in window (page) coordinates, logical dp — the same space
190
+ * `measureInWindow` reports `pageX`/`pageY` in.
191
+ */
192
+ export interface NativePageRect {
193
+ left: number;
194
+ top: number;
195
+ right: number;
196
+ bottom: number;
197
+ }
198
+ /**
199
+ * Why an element is not plainly visible.
200
+ *
201
+ * DUPLICATE of `@qontinui/ui-bridge` `VisibilityReason`
202
+ * (`src/core/types.ts` — `'hidden' | 'off-screen' | 'occluded' | 'no-layout'`),
203
+ * for the same reason the action types above are duplicated: `@qontinui/ui-bridge`
204
+ * is an OPTIONAL peer of this package, so this module must not import from it.
205
+ * KEEP IN SYNC — one vocabulary across both SDKs is the point; do not coin
206
+ * native-only spellings.
207
+ *
208
+ * `'occluded'` is declared but never produced here: React Native gives the
209
+ * registry no hit-test equivalent of `document.elementFromPoint`, so this SDK
210
+ * cannot honestly claim occlusion. It stays in the union so a native reason
211
+ * string is always a valid web reason string.
212
+ */
213
+ export type NativeVisibilityReason = 'hidden' | 'off-screen' | 'occluded' | 'no-layout';
214
+ /** The three-value verdict carried on every snapshot element. */
215
+ export type NativeVisibility = 'visible' | 'likely-visible' | 'hidden';
216
+ /**
217
+ * Convert an element's measured layout into a page-space rect, or `null` when
218
+ * it has no TRUSTWORTHY page-space origin.
219
+ *
220
+ * STRICTER THAN {@link projectBbox} ON PURPOSE. `projectBbox` falls back to the
221
+ * parent-relative `x`/`y` when `pageX`/`pageY` weren't measured, because a
222
+ * slightly-wrong box is still a usable box. Clipping cannot take that trade: a
223
+ * parent-relative origin compared against window coordinates silently mixes two
224
+ * coordinate spaces and yields a wrong `hidden` verdict — the outcome
225
+ * `RegisterElementOptions.scrollAncestorId` calls worse than a coarse
226
+ * `visible`. So a layout flagged `pageOriginUnmeasured` returns `null` here,
227
+ * and {@link computeVisibility} declines to clip rather than guess.
228
+ */
229
+ export declare function pageRectOf(state: NativeElementState): NativePageRect | null;
230
+ /**
231
+ * Is this rect empty — nothing can overlap it?
232
+ *
233
+ * {@link intersectRects} returns an INVERTED rect (`right < left`) when the two
234
+ * inputs are disjoint, which is the honest answer for "the reachable region",
235
+ * but `spansOverlap`'s `aMin < bMax && aMax > bMin` reads an inverted span as
236
+ * overlapping. Every consumer of an intersection must ask this first.
237
+ */
238
+ export declare function isEmptyRect(rect: NativePageRect): boolean;
239
+ /**
240
+ * Intersect two page rects, or `null` when either is absent (`null` means
241
+ * UNKNOWN, so it never narrows the other side).
242
+ *
243
+ * The result may be EMPTY — check {@link isEmptyRect}. That happens whenever
244
+ * the two rects are disjoint, e.g. a scroll container that has itself been
245
+ * scrolled out of the window: nothing inside it can be on screen, and an empty
246
+ * clip is exactly how that is expressed.
247
+ */
248
+ export declare function intersectRects(a: NativePageRect | null, b: NativePageRect | null): NativePageRect | null;
249
+ /**
250
+ * Decide what a snapshot should say about an element being on screen.
251
+ *
252
+ * THE DEFECT THIS CLOSES: visibility used to be derived from mount + `measure()`
253
+ * alone — `state.visible ? (state.layout ? 'visible' : 'likely-visible') : 'hidden'`
254
+ * — with no comparison against any bounds at all. Every mounted, measured
255
+ * element reported `visible`, including rows scrolled far past the fold, so a
256
+ * snapshot claimed things were on screen that were not.
257
+ *
258
+ * SCOPE — read this before citing it. This is a PURE PROJECTION: it reads
259
+ * `state.visible` and never writes it. An off-screen element still carries
260
+ * `state.visible === true`; only the snapshot's `visibility` /
261
+ * `visibilityReason` change. That is a deliberate divergence from the web SDK,
262
+ * which folds off-screen INTO the boolean (`@qontinui/ui-bridge`
263
+ * `src/core/registry.ts` — `if (!inViewport) return { visible: false, reason:
264
+ * 'off-screen' }`), and it means any consumer asserting on `state.visible`
265
+ * alone — `/manual-test`'s "PRESENT IS NOT VISIBLE" gate among them — sees no
266
+ * change on mobile and stays as vacuous as it was. Closing that needs either a
267
+ * matching demotion of `state.visible` here or a consumer that reads
268
+ * `visibility`; both are contract decisions beyond this projection, and
269
+ * neither is done. The strings are shared with the web SDK; the SHAPE is not.
270
+ *
271
+ * `clip` is the region the element must reach to count as on screen — the
272
+ * window, intersected with the measured frame of a declared
273
+ * `scrollAncestorId`. **A `null`/absent clip means the bounds are UNKNOWN, and
274
+ * an unknown never demotes**: with no viewport injected (every non-RN caller,
275
+ * including this package's own suite) the answer is exactly what it was before.
276
+ * Absence of evidence is not evidence of off-screen.
277
+ *
278
+ * Any overlap at all counts as visible. A half-scrolled row IS on screen, and
279
+ * a ratio threshold would be a second, unshared policy knob for callers to
280
+ * disagree about.
281
+ */
282
+ export declare function computeVisibility(state: NativeElementState, clip?: NativePageRect | null): {
283
+ visibility: NativeVisibility;
284
+ visibilityReason?: NativeVisibilityReason;
285
+ };
108
286
  /**
109
287
  * Native UI Bridge Registry
110
288
  *
@@ -150,6 +328,18 @@ export declare class NativeUIBridgeRegistry {
150
328
  * the `projectBbox` doc comment for why we don't import `react-native` here.
151
329
  */
152
330
  private pixelRatio;
331
+ /**
332
+ * Injected getter for the device window size in logical dp, used to clip
333
+ * reported visibility. Injected rather than read here for exactly the reason
334
+ * `pixelRatio` is: a static `import { Dimensions } from 'react-native'` in
335
+ * this non-React core module breaks the vitest suite and risks the
336
+ * Metro/Hermes `unknownModuleError`. It is a PROVIDER, not a value, so a
337
+ * rotation is picked up on the next snapshot with nothing to keep in sync.
338
+ *
339
+ * `null` means the window bounds are UNKNOWN, and {@link computeVisibility}
340
+ * treats unknown bounds as "cannot demote" — never as an empty viewport.
341
+ */
342
+ private viewportProvider;
153
343
  /**
154
344
  * Sticky flag: flips `true` the first time any element is registered and
155
345
  * stays `true` even after elements are unregistered. Lets agents distinguish
@@ -175,6 +365,16 @@ export declare class NativeUIBridgeRegistry {
175
365
  setRouteProvider(provider: NativeRouteProviderLike | null | undefined): void;
176
366
  /** Read-only accessor for the registered route provider, if any. */
177
367
  getRouteProvider(): NativeRouteProviderLike | null;
368
+ /**
369
+ * Resolve the snapshot's `activeTab`: an explicit `getActiveTab()` on the
370
+ * route provider wins, otherwise derive it from the Expo Router segments the
371
+ * provider already exposes.
372
+ *
373
+ * A throwing or empty-string provider degrades to the derivation rather than
374
+ * to `undefined` — the host's opt-in extra should never be able to make the
375
+ * snapshot WORSE than it was without it. A blank derivation stays blank.
376
+ */
377
+ private resolveActiveTab;
178
378
  /**
179
379
  * Set the device pixel ratio used to project per-element `bbox` geometry
180
380
  * into physical pixels. The React provider passes `PixelRatio.get()` here
@@ -184,6 +384,34 @@ export declare class NativeUIBridgeRegistry {
184
384
  setPixelRatio(ratio: number): void;
185
385
  /** Read-only accessor for the device pixel ratio used for bbox projection. */
186
386
  getPixelRatio(): number;
387
+ /**
388
+ * Set the getter for the device window size (logical dp) that reported
389
+ * visibility is clipped against. The React provider passes
390
+ * `() => Dimensions.get('window')` here at construction.
391
+ *
392
+ * Pass `null` to detach. While detached the window bounds are UNKNOWN and no
393
+ * element is ever demoted for being outside them.
394
+ */
395
+ setViewportProvider(provider: (() => {
396
+ width: number;
397
+ height: number;
398
+ }) | null | undefined): void;
399
+ /**
400
+ * The window as a page-space rect, or `null` when it is unknown or the
401
+ * provider returned something unusable (it throws in some RN teardown
402
+ * paths, and a zero-size window would silently hide the entire screen).
403
+ */
404
+ getViewportRect(): NativePageRect | null;
405
+ /**
406
+ * The region `element` must reach to count as on screen: the window,
407
+ * intersected with the measured frame of its declared `scrollAncestorId`.
408
+ *
409
+ * Returns `null` when neither bound is known — see {@link computeVisibility}
410
+ * for why that means "do not demote" rather than "nothing is visible".
411
+ * An ancestor that is not registered, or registered but not yet measured,
412
+ * contributes nothing rather than hiding its subtree.
413
+ */
414
+ getClipRectFor(element: RegisteredNativeElement, viewport?: NativePageRect | null): NativePageRect | null;
187
415
  /**
188
416
  * Register/replace canonical enrichers (modal/toast/undo). HMR-safe — calling
189
417
  * with a partial set merges into existing slots instead of clobbering them.
@@ -381,6 +609,33 @@ export declare class NativeUIBridgeRegistry {
381
609
  * Register a component
382
610
  */
383
611
  registerComponent(id: string, options: RegisterComponentOptions): RegisteredNativeComponent;
612
+ /**
613
+ * Update a registered component's declaration in place — name, description,
614
+ * actions, elementIds.
615
+ *
616
+ * Sister to {@link updateElementMeta}, and it exists for the same reason one
617
+ * level up. `registerComponent` publishes the declaration exactly once, and
618
+ * `useUIComponent` registers on mount and never again, so an action whose
619
+ * `label` carries a count, an action that only exists while a row is
620
+ * selected, a `paramSchema` that widens once options load, or an `elementIds`
621
+ * list that grows after mount, all published their mount-time shape to
622
+ * `/control/components` forever. PR #176 closed that hole for an ELEMENT's
623
+ * label; this closes it for a COMPONENT's declaration.
624
+ *
625
+ * **Idempotent by VALUE over the PUBLISHED shape.** `handler` identity is
626
+ * excluded from the comparison — call sites pass inline closures that are
627
+ * freshly allocated every render, so including it would report a change on
628
+ * every call and make the door useless for a render-path publisher. The
629
+ * newest handlers are still STORED whenever `actions` is supplied, which is
630
+ * the other half of the same defect: without it the registry keeps the
631
+ * closure from the render that registered the component, so a handler reading
632
+ * component state read the state as it was at mount.
633
+ *
634
+ * Returns `true` when the published shape changed and an event was emitted,
635
+ * `false` otherwise — the same contract as `updateElementMeta`, so a caller
636
+ * can gate a warning or a state write on a real change.
637
+ */
638
+ updateComponentMeta(id: string, meta: UpdateComponentMetaOptions): boolean;
384
639
  /**
385
640
  * Unregister a component
386
641
  */
@@ -430,10 +685,25 @@ export declare class NativeUIBridgeRegistry {
430
685
  * server-level override (the bare-server / cloud-relay path) still get a
431
686
  * populated `currentRoute` field as long as `setRouteProvider` was called
432
687
  * on the registry.
688
+ *
689
+ * `activeTab` resolves through {@link resolveActiveTab} on BOTH branches. An
690
+ * explicit `routeInfo.activeTab` wins outright; otherwise a registered
691
+ * provider's `getActiveTab()` answers and the Expo Router derivation is the
692
+ * fallback. Deriving straight from `routeInfo.segments` here instead — which
693
+ * is what this branch did when `activeTab` was introduced — silently ignored
694
+ * `RouteProvider.getActiveTab` on the ONLY path that serves
695
+ * `control/snapshot` once `setRouteProvider` is wired (the server installs a
696
+ * `getSnapshot` override that passes `routeInfo`), so the opt-in hook for an
697
+ * app whose visible pane is decoupled from the router reached no consumer.
433
698
  */
434
699
  createSnapshot(routeInfo?: {
435
700
  currentRoute?: string | null;
436
701
  segments?: string[];
702
+ /**
703
+ * Explicit active tab for callers that own the answer outright. Omit to
704
+ * let the registered `routeProvider` / the segment derivation resolve it.
705
+ */
706
+ activeTab?: string | null;
437
707
  }, options?: {
438
708
  visibleOnly?: boolean;
439
709
  currentRouteOnly?: boolean;