@wix/himalaya-cli 0.943.0 → 0.945.0

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.
@@ -533,6 +533,12 @@ export type TextProps = TextContent & {
533
533
  * the current left-to-right behavior. Both renderers honor it (parity). */
534
534
  textDirection?: "auto" | "ltr" | "rtl";
535
535
  decoration?: "underline" | "strikethrough";
536
+ /** EXTRA SPACE BETWEEN LINES, in points/px — added ON TOP of the line height the `style`
537
+ * token already sets. It is NOT a multiple: `lineSpacing: 6` means six points of extra
538
+ * leading, not six times the font size, and every renderer agrees on that. To
539
+ * change the line height as a ratio of the font size, set the typography token's `line`
540
+ * instead; that is where a body style's 1.4/1.7 lives. Absent/0 = the token's own line
541
+ * height, which is the right answer for almost every screen. */
536
542
  lineSpacing?: number;
537
543
  /** Per-item gate (usually an `$item.` field pre-substituted per row): when it
538
544
  * resolves empty, the text collapses to zero size instead of taking layout. */
package/dist/cli.mjs CHANGED
@@ -18546,6 +18546,7 @@ function shouldRevealError(args) {
18546
18546
  var EMAIL_RE, URL_HOST_RE, INTEGER_RE, NUMBER_RE;
18547
18547
  var init_validation = __esm({
18548
18548
  "../../core/ts/src/validation.ts"() {
18549
+ "use strict";
18549
18550
  EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/;
18550
18551
  URL_HOST_RE = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*(:[0-9]+)?$/;
18551
18552
  INTEGER_RE = /^[+-]?[0-9]+$/;
@@ -21969,6 +21970,7 @@ function validateWorkerSubtree(raw, slotId, policy, actionCatalog) {
21969
21970
  var WORKER_SUBTREE_CEILINGS, ID_PART, ACTION_ID;
21970
21971
  var init_worker_subtree = __esm({
21971
21972
  "../../core/ts/src/worker-subtree.ts"() {
21973
+ "use strict";
21972
21974
  init_worker_subtree_safe_types_generated();
21973
21975
  WORKER_SUBTREE_CEILINGS = Object.freeze({
21974
21976
  maxEncodedBytes: 65536,
@@ -25593,7 +25595,7 @@ var init_renderNode = __esm({
25593
25595
  const content = countContent != null ? countContent : icuPattern != null ? /* @__PURE__ */ jsx6(InterpolatedTextView, { pattern: icuPattern, params: props.template?.params ?? {}, ctx }) : mdSource != null ? /* @__PURE__ */ jsx6("span", { dangerouslySetInnerHTML: mdInline(mdSource) }) : txt(props.text, ctx);
25594
25596
  const tAlign = props.textAlign === "center" ? "center" : props.textAlign === "trailing" ? "right" : props.textAlign === "justify" ? "justify" : props.textAlign === "leading" ? "left" : void 0;
25595
25597
  const tDecor = props.decoration === "strikethrough" ? "line-through" : props.decoration === "underline" ? "underline" : void 0;
25596
- const ln = typeof props.lineSpacing === "number" && f.line ? f.line + props.lineSpacing : f.line;
25598
+ const ln = typeof props.lineSpacing === "number" ? `${(f.line ?? 1.4) * f.size + props.lineSpacing}px` : f.line;
25597
25599
  return /* @__PURE__ */ jsx6("div", { ...countAttrs, style: { fontFamily: fontStack(f.family), fontSize: f.size, fontWeight: f.weight, lineHeight: ln, letterSpacing: f.letterSpacing, color: C(resolveScalarStatic(props.color, ctx) ?? "textPrimary", "#13171e"), whiteSpace: "pre-wrap", overflowWrap: "anywhere", wordBreak: "break-word", minWidth: 0, ...tAlign ? { textAlign: tAlign } : {}, ...tDecor ? { textDecoration: tDecor } : {}, ...props.maxLines ? { display: "-webkit-box", WebkitLineClamp: props.maxLines, WebkitBoxOrient: "vertical", overflow: "hidden" } : {}, ...wrap }, children: content == null || content === "" ? collapse ? null : "\xA0" : content });
25598
25600
  });
25599
25601
  ImageNode = withExtras("ImageNode", ({ props, ctx, wrap }) => {
@@ -43281,19 +43283,19 @@ var init_preview_assets_generated = __esm({
43281
43283
  "../../packages/serve/src/preview-assets.generated.ts"() {
43282
43284
  init_preview_assets_format();
43283
43285
  PREVIEW_ASSET_MANIFEST = [
43284
- { key: "assets/index-D1ZgNUMR.js", contentType: "application/javascript; charset=utf-8", offset: 8, length: 983079, sha256: "2ccd3ce0303a1f4ade606b38e144243d87217dc439e4738417556224e314b8a5" },
43285
- { key: "fonts/WixMadeforDisplay-Bold.ttf", contentType: "font/ttf", offset: 983087, length: 90924, sha256: "ec4c7d63772d980d6e147b2df6215fd2737e4b04cebe0cf7e11dc695fb63cdcf" },
43286
- { key: "fonts/WixMadeforDisplay-ExtraBold.ttf", contentType: "font/ttf", offset: 1074011, length: 90984, sha256: "bf4c16a2774edd38a921edf3099469866cd74c094fd596f3fa4e81f54a04dfaa" },
43287
- { key: "fonts/WixMadeforDisplay-Medium.ttf", contentType: "font/ttf", offset: 1164995, length: 90980, sha256: "368faca44fb9f7207e4af784e94750b1ee59c1098b8689e0f4a918926f244fb5" },
43288
- { key: "fonts/WixMadeforDisplay-Regular.ttf", contentType: "font/ttf", offset: 1255975, length: 90872, sha256: "97ed8f1a6772abe26e3d8af5cecfc4b02faa94497d585b0398b5053b739a70c1" },
43289
- { key: "fonts/WixMadeforDisplay-SemiBold.ttf", contentType: "font/ttf", offset: 1346847, length: 90980, sha256: "642feab8a48ddf6c7a89d7c2a7c0edfa0e6e773f671287e460e222703563191e" },
43290
- { key: "fonts/WixMadeforText-Bold.ttf", contentType: "font/ttf", offset: 1437827, length: 108636, sha256: "37c179b4fefb63ea9637322b84583a2df53aa226c150294eea1675c14b1b8e29" },
43291
- { key: "fonts/WixMadeforText-ExtraBold.ttf", contentType: "font/ttf", offset: 1546463, length: 109084, sha256: "62e2fe4718129f36ecdb0ae4de385bd6b81ecc876146447167c6c140fad699a9" },
43292
- { key: "fonts/WixMadeforText-Medium.ttf", contentType: "font/ttf", offset: 1655547, length: 109048, sha256: "4ecf270136af928612c76fe4ebade91d8156975e5ea4c0cf9225501dd1788e02" },
43293
- { key: "fonts/WixMadeforText-Regular.ttf", contentType: "font/ttf", offset: 1764595, length: 107700, sha256: "dee56f09fd3efc127c1ea95557edc74110c1460a46287da36e0af25efd341ef5" },
43294
- { key: "fonts/WixMadeforText-SemiBold.ttf", contentType: "font/ttf", offset: 1872295, length: 88108, sha256: "dced97a77624bc5f8b1ae65850360908297f4588e09feb6f22792d29dc7e765b" },
43295
- { key: "index.html", contentType: "text/html; charset=utf-8", offset: 1960403, length: 4448, sha256: "8f2222f638f46d9c9a5e57fc9cc5a30a76cee7f91923120aac299292c0f1f604" },
43296
- { key: "t5-frame.html", contentType: "text/html; charset=utf-8", offset: 1964851, length: 13784, sha256: "cfb9465254fab84f0e084bb4542c282758ea29774b5f172840cc0b9e4194ef89" }
43286
+ { key: "assets/index-C40lmxo4.js", contentType: "application/javascript; charset=utf-8", offset: 8, length: 983092, sha256: "919193db2182eb6a481f3729dda29af2441d6c46027c7fab508c2abb78f8fc0f" },
43287
+ { key: "fonts/WixMadeforDisplay-Bold.ttf", contentType: "font/ttf", offset: 983100, length: 90924, sha256: "ec4c7d63772d980d6e147b2df6215fd2737e4b04cebe0cf7e11dc695fb63cdcf" },
43288
+ { key: "fonts/WixMadeforDisplay-ExtraBold.ttf", contentType: "font/ttf", offset: 1074024, length: 90984, sha256: "bf4c16a2774edd38a921edf3099469866cd74c094fd596f3fa4e81f54a04dfaa" },
43289
+ { key: "fonts/WixMadeforDisplay-Medium.ttf", contentType: "font/ttf", offset: 1165008, length: 90980, sha256: "368faca44fb9f7207e4af784e94750b1ee59c1098b8689e0f4a918926f244fb5" },
43290
+ { key: "fonts/WixMadeforDisplay-Regular.ttf", contentType: "font/ttf", offset: 1255988, length: 90872, sha256: "97ed8f1a6772abe26e3d8af5cecfc4b02faa94497d585b0398b5053b739a70c1" },
43291
+ { key: "fonts/WixMadeforDisplay-SemiBold.ttf", contentType: "font/ttf", offset: 1346860, length: 90980, sha256: "642feab8a48ddf6c7a89d7c2a7c0edfa0e6e773f671287e460e222703563191e" },
43292
+ { key: "fonts/WixMadeforText-Bold.ttf", contentType: "font/ttf", offset: 1437840, length: 108636, sha256: "37c179b4fefb63ea9637322b84583a2df53aa226c150294eea1675c14b1b8e29" },
43293
+ { key: "fonts/WixMadeforText-ExtraBold.ttf", contentType: "font/ttf", offset: 1546476, length: 109084, sha256: "62e2fe4718129f36ecdb0ae4de385bd6b81ecc876146447167c6c140fad699a9" },
43294
+ { key: "fonts/WixMadeforText-Medium.ttf", contentType: "font/ttf", offset: 1655560, length: 109048, sha256: "4ecf270136af928612c76fe4ebade91d8156975e5ea4c0cf9225501dd1788e02" },
43295
+ { key: "fonts/WixMadeforText-Regular.ttf", contentType: "font/ttf", offset: 1764608, length: 107700, sha256: "dee56f09fd3efc127c1ea95557edc74110c1460a46287da36e0af25efd341ef5" },
43296
+ { key: "fonts/WixMadeforText-SemiBold.ttf", contentType: "font/ttf", offset: 1872308, length: 88108, sha256: "dced97a77624bc5f8b1ae65850360908297f4588e09feb6f22792d29dc7e765b" },
43297
+ { key: "index.html", contentType: "text/html; charset=utf-8", offset: 1960416, length: 4448, sha256: "4cff52eaf77b0c3c09effb2dd0447d78d2b03ecaa68893281b1637510b93d8ad" },
43298
+ { key: "t5-frame.html", contentType: "text/html; charset=utf-8", offset: 1964864, length: 13784, sha256: "cfb9465254fab84f0e084bb4542c282758ea29774b5f172840cc0b9e4194ef89" }
43297
43299
  ];
43298
43300
  }
43299
43301
  });
@@ -55207,7 +55209,10 @@ function ownerPlaneHint(status, error, target) {
55207
55209
  return { hint: "this environment has no token signing secret configured \u2014 an operator must set SERVE_CONTROL_SIGNING_SECRET" };
55208
55210
  }
55209
55211
  if (error === "owner_requires_selfserve_token") {
55210
- return { hint: `that credential is an operator token, not a self-serve one \u2014 run \`himi login --browser --target ${target}\` to get an owner identity` };
55212
+ const base = `that credential is an operator token, not a self-serve one \u2014 run \`himi login --browser --target ${target}\` to get an owner identity`;
55213
+ return {
55214
+ hint: isPublicTarget(target) ? base : `${base}. On ${target} that REPLACES the employee token stored for this target, and \`himi dist\` then charges the self-serve tier instead of the employee one \u2014 deliberately the tightest of the three, so far fewer concurrent builds and far fewer per day. \`himi login --dpx --target ${target}\` switches back.`
55215
+ };
55211
55216
  }
55212
55217
  if (error === "token_revoked") {
55213
55218
  return { hint: `this token was revoked \u2014 run \`himi login --browser --target ${target}\` to get a new one` };
Binary file
@@ -101,7 +101,7 @@
101
101
  "bindings": [
102
102
  "text"
103
103
  ],
104
- "propsSource": "export type TextProps = TextContent & {\n style?: TokenName;\n /** Foreground color token (default \"textPrimary\"). */\n color?: TokenName;\n /** Tail-truncate after N lines (absent = unlimited). */\n maxLines?: number;\n /** Lever D — a live time value (countdown/relative) the OS ticks with no refresh.\n * When present it OVERRIDES `text`. Widget-safe render only. */\n timeRef?: WidgetTimeRef;\n /** Render `text` as a constrained Markdown subset. */\n markdown?: boolean;\n /** Capability enrichments. */\n textAlign?: \"leading\" | \"center\" | \"trailing\" | \"justify\";\n /** Base writing direction. \"auto\" (or \"rtl\") makes a bidi paragraph resolve +\n * align by its own strong-directional content (Hebrew/Arabic → right-aligned,\n * RTL layout), matching per-message direction in chat bubbles. Absent/\"ltr\" =\n * the current left-to-right behavior. Both renderers honor it (parity). */\n textDirection?: \"auto\" | \"ltr\" | \"rtl\";\n decoration?: \"underline\" | \"strikethrough\";\n lineSpacing?: number;\n /** Per-item gate (usually an `$item.` field pre-substituted per row): when it\n * resolves empty, the text collapses to zero size instead of taking layout. */\n collapseWhenEmpty?: boolean;\n /** Motion grammar (Batch 1, item 4) — count-up tween for a numeric value on\n * change; non-numeric values render instantly. Gated by reduced motion. */\n animateValue?: boolean;\n /** Motion grammar — reveal the (post-substitution literal) text word-by-word,\n * once per distinct content, like a chat assistant \"typing\" its reply. Each\n * content animates a single time (progress is remembered), so list re-renders\n * and scrolls don't restart it. Gated by reduced motion (snaps to full).\n * Both renderers honor it (parity). Default nil/false. */\n animateReveal?: boolean;\n};"
104
+ "propsSource": "export type TextProps = TextContent & {\n style?: TokenName;\n /** Foreground color token (default \"textPrimary\"). */\n color?: TokenName;\n /** Tail-truncate after N lines (absent = unlimited). */\n maxLines?: number;\n /** Lever D — a live time value (countdown/relative) the OS ticks with no refresh.\n * When present it OVERRIDES `text`. Widget-safe render only. */\n timeRef?: WidgetTimeRef;\n /** Render `text` as a constrained Markdown subset. */\n markdown?: boolean;\n /** Capability enrichments. */\n textAlign?: \"leading\" | \"center\" | \"trailing\" | \"justify\";\n /** Base writing direction. \"auto\" (or \"rtl\") makes a bidi paragraph resolve +\n * align by its own strong-directional content (Hebrew/Arabic → right-aligned,\n * RTL layout), matching per-message direction in chat bubbles. Absent/\"ltr\" =\n * the current left-to-right behavior. Both renderers honor it (parity). */\n textDirection?: \"auto\" | \"ltr\" | \"rtl\";\n decoration?: \"underline\" | \"strikethrough\";\n /** EXTRA SPACE BETWEEN LINES, in points/px — added ON TOP of the line height the `style`\n * token already sets. It is NOT a multiple: `lineSpacing: 6` means six points of extra\n * leading, not six times the font size, and every renderer agrees on that. To\n * change the line height as a ratio of the font size, set the typography token's `line`\n * instead; that is where a body style's 1.4/1.7 lives. Absent/0 = the token's own line\n * height, which is the right answer for almost every screen. */\n lineSpacing?: number;\n /** Per-item gate (usually an `$item.` field pre-substituted per row): when it\n * resolves empty, the text collapses to zero size instead of taking layout. */\n collapseWhenEmpty?: boolean;\n /** Motion grammar (Batch 1, item 4) — count-up tween for a numeric value on\n * change; non-numeric values render instantly. Gated by reduced motion. */\n animateValue?: boolean;\n /** Motion grammar — reveal the (post-substitution literal) text word-by-word,\n * once per distinct content, like a chat assistant \"typing\" its reply. Each\n * content animates a single time (progress is remembered), so list re-renders\n * and scrolls don't restart it. Gated by reduced motion (snaps to full).\n * Both renderers honor it (parity). Default nil/false. */\n animateReveal?: boolean;\n};"
105
105
  },
106
106
  {
107
107
  "name": "Image",
@@ -139,6 +139,44 @@
139
139
  "focusPath",
140
140
  "errorPath"
141
141
  ],
142
+ "dispatches": [
143
+ {
144
+ "callback": "onChange",
145
+ "key": "value",
146
+ "encoding": "string",
147
+ "note": "The typed text, clamped to maxLength by the renderer.",
148
+ "divergences": [
149
+ {
150
+ "arms": [
151
+ "ios",
152
+ "android",
153
+ "web"
154
+ ],
155
+ "kind": "additive",
156
+ "actual": "additionally injects `fieldId` when the text field is inside a Form, so one worker action can serve several fields.",
157
+ "reason": "Additive, not conflicting, and shipped bundles already duck-type it. Every arm guards on the field actually being registered in a Form, so a standalone TextField still sends no `fieldId`. Still a divergence rather than a promoted contract key because RN does not send it and the contract does not require it."
158
+ }
159
+ ]
160
+ },
161
+ {
162
+ "callback": "onSubmit",
163
+ "key": "value",
164
+ "encoding": "string",
165
+ "note": "The field's current text at return-key time.",
166
+ "divergences": [
167
+ {
168
+ "arms": [
169
+ "ios",
170
+ "android",
171
+ "web"
172
+ ],
173
+ "kind": "additive",
174
+ "actual": "additionally injects `fieldId` when the text field is inside a Form, so one worker action can serve several fields.",
175
+ "reason": "Additive, not conflicting, and shipped bundles already duck-type it. Every arm guards on the field actually being registered in a Form, so a standalone TextField still sends no `fieldId`. Still a divergence rather than a promoted contract key because RN does not send it and the contract does not require it."
176
+ }
177
+ ]
178
+ }
179
+ ],
142
180
  "propsSource": "export interface TextFieldProps {\n placeholder?: string;\n /** LITERAL initial text. Renderers paint it verbatim — a `$service:` sentinel\n * here shows the raw token to the user. Bind `valuePath` instead. */\n value?: string;\n /** `$service:`/`$item.` binding → live text from the worker (overrides `value`).\n * Adopted while the field is NOT focused; an empty worker value (a draft reset\n * after send/post/submit) is adopted even mid-focus, so a cleared draft empties\n * the field without the worker ever fighting the caret. */\n valuePath?: string;\n onChange?: Action;\n /** Text input enrichments. */\n keyboard?: Keyboard;\n secure?: boolean;\n multilineMaxLines?: number;\n maxLength?: number;\n autoCapitalization?: AutoCapitalization;\n onSubmit?: Action;\n /** Keyboard control. `suggestions: \"off\"` kills autocorrect + the suggestion\n * strip. `submitLabel` paints the return key and, with `onSubmit`, fires it —\n * submitting dismisses the keyboard except for `\"next\"`, which keeps it up so\n * a worker can move focus on. */\n suggestions?: Suggestions;\n submitLabel?: SubmitLabel;\n /** `$service:` binding → a TOKEN the worker changes to focus this field. Focus\n * is requested whenever the resolved value becomes a new NON-EMPTY value\n * (the first seed included, so a non-empty init focuses on open). Use a\n * monotonic nonce — `String(Date.now())` — since a latched boolean cannot\n * re-fire. Blur is the keyboard-dismiss capability, not a token here. */\n focusPath?: string;\n /** Accessory & state enrichments. */\n leadingIcon?: IconRef;\n trailingIcon?: IconRef;\n clearButton?: boolean;\n helperText?: string;\n error?: boolean;\n prefix?: string;\n suffix?: string;\n /** Drop the default rounded-border chrome so a caller background shows (composer pill). */\n borderless?: boolean;\n /** Declarative syntactic rules the RENDERER evaluates, so they hold with no\n * worker running (Tier 0-4) and with no bridge round-trip. Before this,\n * validation was reachable only by wrapping the field in `Form` -> `FormField`\n * - the shape the design guide steers you away from for hero flows. */\n validation?: Validation;\n /** `$service:`/`$item.` binding -> a worker-computed error string. Non-empty =\n * invalid. This is how SEMANTIC and CROSS-FIELD errors (\"passwords don't\n * match\", \"email taken\") reach the same error slot, with the same styling and\n * a11y wiring, as the declarative ones. Wins over `validation.message`. */\n errorPath?: string;\n /** LITERAL error string for a server-rendered screen with no worker to bind.\n * Lower precedence than `errorPath`, higher than the rules. */\n errorText?: string;\n}"
143
181
  },
144
182
  {
@@ -148,6 +186,23 @@
148
186
  "bindings": [
149
187
  "valuePath"
150
188
  ],
189
+ "dispatches": [
190
+ {
191
+ "callback": "onChange",
192
+ "key": "value",
193
+ "encoding": "boolean",
194
+ "note": "The NEXT state, not the previous one.",
195
+ "divergences": [
196
+ {
197
+ "arms": [
198
+ "ios"
199
+ ],
200
+ "kind": "additive",
201
+ "actual": "additionally injects `fieldId` when the control is inside a Form — via bindingForBool, for a Toggle nested in a FormField."
202
+ }
203
+ ]
204
+ }
205
+ ],
151
206
  "propsSource": "export interface ToggleProps {\n label?: string;\n value: boolean;\n /** `$service:`/`$item.` binding → a live bool (overrides `value`). */\n valuePath?: string;\n onChange?: Action;\n /** #1047 — ON-state track color token (default: `accentPrimary`). Setting any\n * appearance prop switches to a custom-drawn control so a host can match its own\n * switch and keep the screen OTA (vs the `intents:` escape hatch). */\n trackOn?: TokenName;\n /** #1047 — OFF-state track color token (default: system/`surfaceTertiary`). */\n trackOff?: TokenName;\n /** #1047 — thumb color token (default: white). */\n thumb?: TokenName;\n /** #1047 — track width (pt/dp/px). Absent = system size. */\n width?: number;\n /** #1047 — track height (pt/dp/px). Absent = system size. */\n height?: number;\n}"
152
207
  },
153
208
  {
@@ -157,6 +212,25 @@
157
212
  "bindings": [
158
213
  "valuePath"
159
214
  ],
215
+ "dispatches": [
216
+ {
217
+ "callback": "onChange",
218
+ "key": "value",
219
+ "encoding": "int",
220
+ "note": "The clamped next value. A no-change step must not dispatch.",
221
+ "divergences": [
222
+ {
223
+ "arms": [
224
+ "web",
225
+ "rn"
226
+ ],
227
+ "kind": "params-mirror",
228
+ "actual": "mirrors the value into `params` as a NUMBER.",
229
+ "reason": "Violates rule 2 — `Action.params` is `map<string,string>`. Harmless through the JS hosts, wrong on the wire."
230
+ }
231
+ ]
232
+ }
233
+ ],
160
234
  "propsSource": "export interface StepperProps {\n value: number;\n /** `$service:`/`$item.` binding → a live integer (overrides `value`). */\n valuePath?: string;\n min?: number;\n max?: number;\n onChange?: Action;\n}"
161
235
  },
162
236
  {
@@ -238,6 +312,24 @@
238
312
  "category": "Form primitives",
239
313
  "propsType": "PickerProps",
240
314
  "bindings": [],
315
+ "dispatches": [
316
+ {
317
+ "callback": "onChange",
318
+ "key": "selectedId",
319
+ "encoding": "string",
320
+ "note": "The chosen option's id — an enum the reducer switches on, never an index.",
321
+ "divergences": [
322
+ {
323
+ "arms": [
324
+ "ios"
325
+ ],
326
+ "kind": "probe",
327
+ "actual": "inside a FormField, the non-text kinds store into the form bag and RETURN without dispatching — the renderer's own code.",
328
+ "reason": "A formField({kind:'picker'}) onChange is a dead action on iOS while every other arm fires it."
329
+ }
330
+ ]
331
+ }
332
+ ],
241
333
  "notImplemented": {
242
334
  "props": [
243
335
  "optionsPath",
@@ -256,6 +348,39 @@
256
348
  "category": "Form primitives",
257
349
  "propsType": "DatePickerProps",
258
350
  "bindings": [],
351
+ "dispatches": [
352
+ {
353
+ "callback": "onChange",
354
+ "key": "value",
355
+ "encoding": "unix-seconds-string",
356
+ "note": "Unix SECONDS, string-encoded so the params mirror stays legal. iOS ships `valueUnix` as an int instead; RN ships nothing. The normative key is `value` because that is what three of four arms already use for every other scalar control.",
357
+ "divergences": [
358
+ {
359
+ "arms": [
360
+ "ios"
361
+ ],
362
+ "kind": "wrong-key",
363
+ "actual": "sends `valueUnix` as an int instead of the contract's `value`.",
364
+ "reason": "Only web conforms. This is the widest split in the vocabulary and the clearest argument for the file."
365
+ },
366
+ {
367
+ "arms": [
368
+ "rn"
369
+ ],
370
+ "kind": "no-payload",
371
+ "actual": "dispatches the bare action with no date at all."
372
+ },
373
+ {
374
+ "arms": [
375
+ "ios"
376
+ ],
377
+ "kind": "probe",
378
+ "actual": "inside a FormField, the non-text kinds store into the form bag and RETURN without dispatching — the renderer's own code.",
379
+ "reason": "A formField({kind:'picker'}) onChange is a dead action on iOS while every other arm fires it."
380
+ }
381
+ ]
382
+ }
383
+ ],
259
384
  "notImplemented": {
260
385
  "props": [
261
386
  "mode",
@@ -308,6 +433,20 @@
308
433
  "category": "Search & filter",
309
434
  "propsType": "SearchFieldProps",
310
435
  "bindings": [],
436
+ "dispatches": [
437
+ {
438
+ "callback": "onChange",
439
+ "key": "value",
440
+ "encoding": "string",
441
+ "note": "Debounced by `debounceMs` before dispatch."
442
+ },
443
+ {
444
+ "callback": "onSubmit",
445
+ "key": "value",
446
+ "encoding": "string",
447
+ "note": "These two rows share a control and key, so they are NOT independently gated: a payload observation cannot say which callback produced it, and a helper-mediated dispatch does not name the callback at the write site at all. The gate proves the callback is still referenced in the component that dispatches, not that this callback carries the payload."
448
+ }
449
+ ],
311
450
  "propsSource": "export interface SearchFieldProps {\n placeholder?: string;\n value?: string;\n onChange?: Action;\n onSubmit?: Action;\n debounceMs?: number;\n}"
312
451
  },
313
452
  {
@@ -318,6 +457,24 @@
318
457
  "optionsPath",
319
458
  "selectedIds"
320
459
  ],
460
+ "dispatches": [
461
+ {
462
+ "callback": "onChange",
463
+ "key": "selectedIds",
464
+ "encoding": "string-array",
465
+ "note": "The full selection AFTER the toggle, not the chip that moved.",
466
+ "divergences": [
467
+ {
468
+ "arms": [
469
+ "rn"
470
+ ],
471
+ "kind": "params-mirror",
472
+ "actual": "mirrors `selectedIds` into `params` as an ARRAY.",
473
+ "reason": "Rule-2 violation."
474
+ }
475
+ ]
476
+ }
477
+ ],
321
478
  "propsSource": "export interface FilterChipsProps {\n chips: FilterChip[];\n /** Literal ids, or a binding-aware `$service:`/`$item.` live selection. */\n selectedIds?: string[];\n multiSelect?: boolean;\n onChange?: Action;\n /** `$service:` binding → a live options array (overrides `chips`). */\n optionsPath?: string;\n}"
322
479
  },
323
480
  {
@@ -329,6 +486,13 @@
329
486
  "activeColorPath",
330
487
  "selectedId"
331
488
  ],
489
+ "dispatches": [
490
+ {
491
+ "callback": "onChange",
492
+ "key": "selectedId",
493
+ "encoding": "string"
494
+ }
495
+ ],
332
496
  "notImplemented": {
333
497
  "props": [
334
498
  "style"
@@ -349,6 +513,13 @@
349
513
  "optionsPath",
350
514
  "selectedId"
351
515
  ],
516
+ "dispatches": [
517
+ {
518
+ "callback": "onChange",
519
+ "key": "selectedId",
520
+ "encoding": "string"
521
+ }
522
+ ],
352
523
  "propsSource": "export interface SegmentedControlProps {\n options: TabItem[];\n /** Literal id, or a binding-aware `$service:`/`$item.` live selection. */\n selectedId: string;\n onChange?: Action;\n /** `$service:` binding → a live options array (overrides `options`). */\n optionsPath?: string;\n}"
353
524
  },
354
525
  {
@@ -456,6 +627,40 @@
456
627
  "bindings": [
457
628
  "valuePath"
458
629
  ],
630
+ "dispatches": [
631
+ {
632
+ "callback": "onChange",
633
+ "key": "value",
634
+ "encoding": "double",
635
+ "note": "Range mode additionally sends `upperValue`.",
636
+ "divergences": [
637
+ {
638
+ "arms": [
639
+ "web"
640
+ ],
641
+ "kind": "no-payload",
642
+ "actual": "dispatches the bare action — the renderer's own code — with no injected value."
643
+ },
644
+ {
645
+ "arms": [
646
+ "rn"
647
+ ],
648
+ "kind": "no-payload",
649
+ "actual": "renders a static visual and never dispatches at all.",
650
+ "reason": "RN Slider is unimplemented rather than divergent; listed so the gap is counted, not hidden."
651
+ },
652
+ {
653
+ "arms": [
654
+ "ios",
655
+ "android"
656
+ ],
657
+ "kind": "additive",
658
+ "actual": "range mode additionally sends `upperValue` beside `value`.",
659
+ "reason": "Additive, and the contract's Slider note already describes it. Same problem as groupId: allowed by a hard-coded list rather than declared."
660
+ }
661
+ ]
662
+ }
663
+ ],
459
664
  "notImplemented": {
460
665
  "props": [
461
666
  "debounceMs"
@@ -475,6 +680,38 @@
475
680
  "bindings": [
476
681
  "valuePath"
477
682
  ],
683
+ "dispatches": [
684
+ {
685
+ "callback": "onChange",
686
+ "key": "value",
687
+ "encoding": "boolean",
688
+ "note": "The next checked state. iOS additionally sends `groupId` for radio groups.",
689
+ "divergences": [
690
+ {
691
+ "arms": [
692
+ "web"
693
+ ],
694
+ "kind": "no-payload",
695
+ "actual": "dispatches the bare action — the renderer's own code — with no injected value."
696
+ },
697
+ {
698
+ "arms": [
699
+ "rn"
700
+ ],
701
+ "kind": "params-mirror",
702
+ "actual": "mirrors the value into `params` as a BOOLEAN."
703
+ },
704
+ {
705
+ "arms": [
706
+ "ios"
707
+ ],
708
+ "kind": "additive",
709
+ "actual": "additionally sends `groupId` so a radio group can identify which set changed.",
710
+ "reason": "Additive, not conflicting. It was previously hard-coded as an allowed key in the gate, which put it outside the contract — the one place this PR claims is the single source of truth."
711
+ }
712
+ ]
713
+ }
714
+ ],
478
715
  "propsSource": "export interface SelectionControlProps {\n label?: string;\n value?: boolean;\n kind?: \"checkbox\" | \"radio\";\n /** Radios sharing a `groupId` are mutually exclusive. */\n groupId?: string;\n onChange?: Action;\n /** \"$service:\"/\"$item.\" binding → bool (overrides `value`). */\n valuePath?: string;\n disabled?: boolean;\n /** Checkbox tri-state (renders a dash); ignored for radio. */\n indeterminate?: boolean;\n}"
479
716
  },
480
717
  {
@@ -592,6 +829,23 @@
592
829
  "bindings": [
593
830
  "valuesPath"
594
831
  ],
832
+ "dispatches": [
833
+ {
834
+ "callback": "onRemove",
835
+ "key": "value",
836
+ "encoding": "string",
837
+ "note": "The removed chip's value.",
838
+ "divergences": [
839
+ {
840
+ "arms": [
841
+ "web"
842
+ ],
843
+ "kind": "no-payload",
844
+ "actual": "dispatches the bare action — the renderer's own code — with no injected value."
845
+ }
846
+ ]
847
+ }
848
+ ],
595
849
  "notImplemented": {
596
850
  "props": [
597
851
  "onAdd"
@@ -660,6 +914,41 @@
660
914
  "bindings": [
661
915
  "valuePath"
662
916
  ],
917
+ "dispatches": [
918
+ {
919
+ "callback": "onChange",
920
+ "key": "value",
921
+ "encoding": "int",
922
+ "note": "1-based star index.",
923
+ "divergences": [
924
+ {
925
+ "arms": [
926
+ "web",
927
+ "rn"
928
+ ],
929
+ "kind": "params-only",
930
+ "actual": "writes the value into `params` ONLY, never `args`.",
931
+ "reason": "Survives only because the Tier-5 hosts merge params into args. Any consumer reading `args` — which rule 1 says is the payload map — gets nothing."
932
+ },
933
+ {
934
+ "arms": [
935
+ "ios"
936
+ ],
937
+ "kind": "encoding",
938
+ "actual": "sends `value` as .double where the contract says int."
939
+ },
940
+ {
941
+ "arms": [
942
+ "web",
943
+ "rn"
944
+ ],
945
+ "kind": "params-mirror",
946
+ "actual": "stringifies the 1-based index into `params` (`String(i + 1)`) for a control the contract declares `int`.",
947
+ "reason": "Rule 2 permits a params mirror only for string-encoded values, and Rating is int. The earlier expression heuristic missed it because `String(i + 1)` does not look like a literal — it surfaced only once rule 2 keyed off the declared encoding."
948
+ }
949
+ ]
950
+ }
951
+ ],
663
952
  "propsSource": "export interface RatingProps {\n value?: number;\n max?: number;\n interactive?: boolean;\n onChange?: Action;\n color?: TokenName;\n icon?: IconRef;\n valuePath?: string;\n}"
664
953
  },
665
954
  {
@@ -744,6 +1033,31 @@
744
1033
  "optionsPath",
745
1034
  "selectedId"
746
1035
  ],
1036
+ "dispatches": [
1037
+ {
1038
+ "callback": "onChange",
1039
+ "key": "selectedId",
1040
+ "encoding": "string",
1041
+ "divergences": [
1042
+ {
1043
+ "arms": [
1044
+ "rn"
1045
+ ],
1046
+ "kind": "params-wrong-key",
1047
+ "actual": "writes `params.value = String(id)` but `args.selectedId = id` — a different key in each map.",
1048
+ "reason": "Conforms on `args` (rule 1) but the params mirror uses the wrong key, so a params-reading consumer sees a key no other arm sends."
1049
+ },
1050
+ {
1051
+ "arms": [
1052
+ "web"
1053
+ ],
1054
+ "kind": "no-payload",
1055
+ "actual": "renders a static window of options and never dispatches — the case has no onAction.",
1056
+ "reason": "Unimplemented on web rather than divergent. Surfaced by the missing-site assertion; the other four it reported were blind spots in the scan, now fixed."
1057
+ }
1058
+ ]
1059
+ }
1060
+ ],
747
1061
  "propsSource": "export interface WheelPickerProps {\n options?: PickerOption[];\n /** Literal option id, or binding-aware `$service:`/`$item.` live id. */\n selectedId?: string;\n onChange?: Action;\n optionsPath?: string;\n}"
748
1062
  },
749
1063
  {
@@ -753,6 +1067,25 @@
753
1067
  "bindings": [
754
1068
  "valuePath"
755
1069
  ],
1070
+ "dispatches": [
1071
+ {
1072
+ "callback": "onChange",
1073
+ "key": "value",
1074
+ "encoding": "string",
1075
+ "note": "Hex, including the leading '#'.",
1076
+ "divergences": [
1077
+ {
1078
+ "arms": [
1079
+ "web",
1080
+ "rn"
1081
+ ],
1082
+ "kind": "params-only",
1083
+ "actual": "writes the value into `params` ONLY, never `args`.",
1084
+ "reason": "Survives only because the Tier-5 hosts merge params into args. Any consumer reading `args` — which rule 1 says is the payload map — gets nothing."
1085
+ }
1086
+ ]
1087
+ }
1088
+ ],
756
1089
  "propsSource": "export interface ColorPickerProps {\n value?: string;\n onChange?: Action;\n swatches?: string[];\n label?: string;\n valuePath?: string;\n}"
757
1090
  },
758
1091
  {
@@ -762,6 +1095,44 @@
762
1095
  "bindings": [
763
1096
  "valuePath"
764
1097
  ],
1098
+ "dispatches": [
1099
+ {
1100
+ "callback": "onChange",
1101
+ "key": "value",
1102
+ "encoding": "string",
1103
+ "note": "The assembled code. Only RN dispatches today; web, iOS and Android render the boxes read-only.",
1104
+ "divergences": [
1105
+ {
1106
+ "arms": [
1107
+ "web",
1108
+ "ios",
1109
+ "android"
1110
+ ],
1111
+ "kind": "no-payload",
1112
+ "actual": "renders the digit boxes without wiring onChange — the control dispatches nothing.",
1113
+ "reason": "Unimplemented rather than divergent, but it must be declared: leaving OtpInput out of the contract is what let the gate whitelist RN's payload without checking it."
1114
+ }
1115
+ ]
1116
+ },
1117
+ {
1118
+ "callback": "onComplete",
1119
+ "key": "value",
1120
+ "encoding": "string",
1121
+ "note": "Fires once the last box is filled, carrying the same assembled code as onChange. Declared separately because it is a second public callback — leaving it out left one outside the contract.",
1122
+ "divergences": [
1123
+ {
1124
+ "arms": [
1125
+ "web",
1126
+ "ios",
1127
+ "android"
1128
+ ],
1129
+ "kind": "no-payload",
1130
+ "actual": "renders the digit boxes without wiring onChange — the control dispatches nothing.",
1131
+ "reason": "Unimplemented rather than divergent, but it must be declared: leaving OtpInput out of the contract is what let the gate whitelist RN's payload without checking it."
1132
+ }
1133
+ ]
1134
+ }
1135
+ ],
765
1136
  "notImplemented": {
766
1137
  "props": [
767
1138
  "onChange",
@@ -180,6 +180,36 @@ The two share a leaf name and mean opposite things — descriptor side takes the
180
180
  registry takes the HEX — so a validator error about `shadows.card.color` is not this table
181
181
  contradicting itself.
182
182
 
183
+ ## What a control sends (action payloads)
184
+
185
+ A component's props say what it READS. This says what it SENDS: the value a control injects
186
+ into the `Action` it dispatches, so a Tier-5 reducer can be written from this page instead of
187
+ from trial and error.
188
+
189
+ 1. **The payload is in `args`.** `Action.args` is a `Value`, so it holds the real type
190
+ (bool / int / double / array), and every host merges `{...params, ...args}` with `args`
191
+ winning. Read `args` first.
192
+ 2. **`params` may mirror it, but only for a string.** `Action.params` is declared
193
+ `map<string,string>` in the descriptor schema; a number, bool or array there is a type
194
+ violation even where JavaScript tolerates it.
195
+ 3. **The key and the encoding below are normative.** A renderer that sends a different key —
196
+ or no payload at all — is a divergence, and every one that exists today is printed with the
197
+ component it affects, because that is precisely the case a reducer has to be written around.
198
+
199
+ Every row here is generated from the platform's own payload contract and re-checked against
200
+ all four renderers on every build, so it cannot drift from what the components actually send.
201
+
202
+ **What it covers, and what it does not.** The contract is about **value controls** — the ones
203
+ whose callback carries the thing the user chose or typed. A value callback absent from a
204
+ component's block injects nothing: it dispatches the author's action verbatim, so any
205
+ discriminator must come from the `args` the author wrote (`Chip.onRemove` and
206
+ `DisclosureGroup.onToggle` are the two that work this way today).
207
+
208
+ That is NOT a statement about every action a component can fire. Gesture and event callbacks —
209
+ `Map.onMapTap` / `onMarkerTap`, a media player's progress events — inject payloads of their
210
+ own and are outside this contract, so read the component's own prop documentation for those
211
+ rather than concluding from a missing row here that nothing arrives.
212
+
183
213
  ## Props (verbatim from schema.ts)
184
214
 
185
215
  ### Core
@@ -379,6 +409,12 @@ export type TextProps = TextContent & {
379
409
  * the current left-to-right behavior. Both renderers honor it (parity). */
380
410
  textDirection?: "auto" | "ltr" | "rtl";
381
411
  decoration?: "underline" | "strikethrough";
412
+ /** EXTRA SPACE BETWEEN LINES, in points/px — added ON TOP of the line height the `style`
413
+ * token already sets. It is NOT a multiple: `lineSpacing: 6` means six points of extra
414
+ * leading, not six times the font size, and every renderer agrees on that. To
415
+ * change the line height as a ratio of the font size, set the typography token's `line`
416
+ * instead; that is where a body style's 1.4/1.7 lives. Absent/0 = the token's own line
417
+ * height, which is the right answer for almost every screen. */
382
418
  lineSpacing?: number;
383
419
  /** Per-item gate (usually an `$item.` field pre-substituted per row): when it
384
420
  * resolves empty, the text collapses to zero size instead of taking layout. */
@@ -519,6 +555,21 @@ export interface TextFieldProps {
519
555
  }
520
556
  ```
521
557
 
558
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
559
+
560
+ | callback | key | type |
561
+ |---|---|---|
562
+ | `onChange` | `value` | string |
563
+ | `onSubmit` | `value` | string |
564
+
565
+ > `onChange` — The typed text, clamped to maxLength by the renderer.
566
+
567
+ > ⚠️ **`onChange` on ios + android + web — ALSO sends:** additionally injects `fieldId` when the text field is inside a Form, so one worker action can serve several fields. Additive, not conflicting, and shipped bundles already duck-type it. Every arm guards on the field actually being registered in a Form, so a standalone TextField still sends no `fieldId`. Still a divergence rather than a promoted contract key because RN does not send it and the contract does not require it.
568
+
569
+ > `onSubmit` — The field's current text at return-key time.
570
+
571
+ > ⚠️ **`onSubmit` on ios + android + web — ALSO sends:** additionally injects `fieldId` when the text field is inside a Form, so one worker action can serve several fields. Additive, not conflicting, and shipped bundles already duck-type it. Every arm guards on the field actually being registered in a Form, so a standalone TextField still sends no `fieldId`. Still a divergence rather than a promoted contract key because RN does not send it and the contract does not require it.
572
+
522
573
  #### Toggle
523
574
 
524
575
  ```ts
@@ -543,6 +594,16 @@ export interface ToggleProps {
543
594
  }
544
595
  ```
545
596
 
597
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
598
+
599
+ | callback | key | type |
600
+ |---|---|---|
601
+ | `onChange` | `value` | boolean |
602
+
603
+ > `onChange` — The NEXT state, not the previous one.
604
+
605
+ > ⚠️ **`onChange` on ios — ALSO sends:** additionally injects `fieldId` when the control is inside a Form — via bindingForBool, for a Toggle nested in a FormField.
606
+
546
607
  #### Stepper
547
608
 
548
609
  ```ts
@@ -556,6 +617,16 @@ export interface StepperProps {
556
617
  }
557
618
  ```
558
619
 
620
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
621
+
622
+ | callback | key | type |
623
+ |---|---|---|
624
+ | `onChange` | `value` | int |
625
+
626
+ > `onChange` — The clamped next value. A no-change step must not dispatch.
627
+
628
+ > ⚠️ **`onChange` on web + rn — DIVERGES:** mirrors the value into `params` as a NUMBER. Violates rule 2 — `Action.params` is `map<string,string>`. Harmless through the JS hosts, wrong on the wire.
629
+
559
630
  #### Avatar
560
631
 
561
632
  ```ts
@@ -712,6 +783,16 @@ export interface PickerProps {
712
783
 
713
784
  > ⚠️ **Implemented, but these props are inert on ios:** `optionsPath`, `placeholder` — `optionsPath` does not bind options; `placeholder` is not shown.
714
785
 
786
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
787
+
788
+ | callback | key | type |
789
+ |---|---|---|
790
+ | `onChange` | `selectedId` | string |
791
+
792
+ > `onChange` — The chosen option's id — an enum the reducer switches on, never an index.
793
+
794
+ > ⚠️ **`onChange` on ios — DIVERGES:** inside a FormField, the non-text kinds store into the form bag and RETURN without dispatching — the renderer's own code. A formField({kind:'picker'}) onChange is a dead action on iOS while every other arm fires it.
795
+
715
796
  #### DatePicker
716
797
 
717
798
  ```ts
@@ -728,6 +809,20 @@ export interface DatePickerProps {
728
809
 
729
810
  > ⚠️ **Implemented, but these props are inert on ios:** `mode`, `minUnix`, `maxUnix`, `valuePath` — The picker itself renders as a native date wheel — these four props are inert: `mode: "time"` still renders a date wheel; `minUnix`/`maxUnix` do not clamp; `valuePath` does not bind.
730
811
 
812
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
813
+
814
+ | callback | key | type |
815
+ |---|---|---|
816
+ | `onChange` | `value` | unix-seconds-string |
817
+
818
+ > `onChange` — Unix SECONDS, string-encoded so the params mirror stays legal. iOS ships `valueUnix` as an int instead; RN ships nothing. The normative key is `value` because that is what three of four arms already use for every other scalar control.
819
+
820
+ > ⚠️ **`onChange` on ios — DIVERGES:** sends `valueUnix` as an int instead of the contract's `value`. Only web conforms. This is the widest split in the vocabulary and the clearest argument for the file.
821
+
822
+ > ⚠️ **`onChange` on rn — DIVERGES:** dispatches the bare action with no date at all.
823
+
824
+ > ⚠️ **`onChange` on ios — DIVERGES:** inside a FormField, the non-text kinds store into the form bag and RETURN without dispatching — the renderer's own code. A formField({kind:'picker'}) onChange is a dead action on iOS while every other arm fires it.
825
+
731
826
  ### State envelopes
732
827
 
733
828
  #### StatefulSection
@@ -793,6 +888,17 @@ export interface SearchFieldProps {
793
888
  }
794
889
  ```
795
890
 
891
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
892
+
893
+ | callback | key | type |
894
+ |---|---|---|
895
+ | `onChange` | `value` | string |
896
+ | `onSubmit` | `value` | string |
897
+
898
+ > `onChange` — Debounced by `debounceMs` before dispatch.
899
+
900
+ > `onSubmit` — These two rows share a control and key, so they are NOT independently gated: a payload observation cannot say which callback produced it, and a helper-mediated dispatch does not name the callback at the write site at all. The gate proves the callback is still referenced in the component that dispatches, not that this callback carries the payload.
901
+
796
902
  #### FilterChips
797
903
 
798
904
  ```ts
@@ -807,6 +913,16 @@ export interface FilterChipsProps {
807
913
  }
808
914
  ```
809
915
 
916
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
917
+
918
+ | callback | key | type |
919
+ |---|---|---|
920
+ | `onChange` | `selectedIds` | string-array |
921
+
922
+ > `onChange` — The full selection AFTER the toggle, not the chip that moved.
923
+
924
+ > ⚠️ **`onChange` on rn — DIVERGES:** mirrors `selectedIds` into `params` as an ARRAY. Rule-2 violation.
925
+
810
926
  ### Navigation primitives
811
927
 
812
928
  #### Tabs
@@ -827,6 +943,12 @@ export interface TabsProps {
827
943
 
828
944
  > ⚠️ **Implemented, but these props are inert on ios:** `style` — The tab bar itself renders — only `style` is inert: `swipe` still renders as top tabs.
829
945
 
946
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
947
+
948
+ | callback | key | type |
949
+ |---|---|---|
950
+ | `onChange` | `selectedId` | string |
951
+
830
952
  #### SegmentedControl
831
953
 
832
954
  ```ts
@@ -840,6 +962,12 @@ export interface SegmentedControlProps {
840
962
  }
841
963
  ```
842
964
 
965
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
966
+
967
+ | callback | key | type |
968
+ |---|---|---|
969
+ | `onChange` | `selectedId` | string |
970
+
843
971
  #### Carousel
844
972
 
845
973
  ```ts
@@ -1056,6 +1184,20 @@ export interface SliderProps {
1056
1184
 
1057
1185
  > ⚠️ **Implemented, but these props are inert on ios:** `debounceMs` — `debounceMs` does not throttle the onChange stream.
1058
1186
 
1187
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
1188
+
1189
+ | callback | key | type |
1190
+ |---|---|---|
1191
+ | `onChange` | `value` | double |
1192
+
1193
+ > `onChange` — Range mode additionally sends `upperValue`.
1194
+
1195
+ > ⚠️ **`onChange` on web — DIVERGES:** dispatches the bare action — the renderer's own code — with no injected value.
1196
+
1197
+ > ⚠️ **`onChange` on rn — DIVERGES:** renders a static visual and never dispatches at all. RN Slider is unimplemented rather than divergent; listed so the gap is counted, not hidden.
1198
+
1199
+ > ⚠️ **`onChange` on ios + android — ALSO sends:** range mode additionally sends `upperValue` beside `value`. Additive, and the contract's Slider note already describes it. Same problem as groupId: allowed by a hard-coded list rather than declared.
1200
+
1059
1201
  #### SelectionControl
1060
1202
 
1061
1203
  ```ts
@@ -1074,6 +1216,20 @@ export interface SelectionControlProps {
1074
1216
  }
1075
1217
  ```
1076
1218
 
1219
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
1220
+
1221
+ | callback | key | type |
1222
+ |---|---|---|
1223
+ | `onChange` | `value` | boolean |
1224
+
1225
+ > `onChange` — The next checked state. iOS additionally sends `groupId` for radio groups.
1226
+
1227
+ > ⚠️ **`onChange` on web — DIVERGES:** dispatches the bare action — the renderer's own code — with no injected value.
1228
+
1229
+ > ⚠️ **`onChange` on rn — DIVERGES:** mirrors the value into `params` as a BOOLEAN.
1230
+
1231
+ > ⚠️ **`onChange` on ios — ALSO sends:** additionally sends `groupId` so a radio group can identify which set changed. Additive, not conflicting. It was previously hard-coded as an allowed key in the gate, which put it outside the contract — the one place this PR claims is the single source of truth.
1232
+
1077
1233
  #### ActivityIndicator
1078
1234
 
1079
1235
  ```ts
@@ -1200,6 +1356,16 @@ export interface ChipsInputProps {
1200
1356
 
1201
1357
  > ⚠️ **Implemented, but these props are inert on ios:** `onAdd` — `onAdd` never dispatches.
1202
1358
 
1359
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
1360
+
1361
+ | callback | key | type |
1362
+ |---|---|---|
1363
+ | `onRemove` | `value` | string |
1364
+
1365
+ > `onRemove` — The removed chip's value.
1366
+
1367
+ > ⚠️ **`onRemove` on web — DIVERGES:** dispatches the bare action — the renderer's own code — with no injected value.
1368
+
1203
1369
  ### Structure & disclosure
1204
1370
 
1205
1371
  #### Section
@@ -1296,6 +1462,20 @@ export interface RatingProps {
1296
1462
  }
1297
1463
  ```
1298
1464
 
1465
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
1466
+
1467
+ | callback | key | type |
1468
+ |---|---|---|
1469
+ | `onChange` | `value` | int |
1470
+
1471
+ > `onChange` — 1-based star index.
1472
+
1473
+ > ⚠️ **`onChange` on web + rn — DIVERGES:** writes the value into `params` ONLY, never `args`. Survives only because the Tier-5 hosts merge params into args. Any consumer reading `args` — which rule 1 says is the payload map — gets nothing.
1474
+
1475
+ > ⚠️ **`onChange` on ios — DIVERGES:** sends `value` as .double where the contract says int.
1476
+
1477
+ > ⚠️ **`onChange` on web + rn — DIVERGES:** stringifies the 1-based index into `params` (`String(i + 1)`) for a control the contract declares `int`. Rule 2 permits a params mirror only for string-encoded values, and Rating is int. The earlier expression heuristic missed it because `String(i + 1)` does not look like a literal — it surfaced only once rule 2 keyed off the declared encoding.
1478
+
1299
1479
  #### Chart
1300
1480
 
1301
1481
  ```ts
@@ -1463,6 +1643,16 @@ export interface WheelPickerProps {
1463
1643
  }
1464
1644
  ```
1465
1645
 
1646
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
1647
+
1648
+ | callback | key | type |
1649
+ |---|---|---|
1650
+ | `onChange` | `selectedId` | string |
1651
+
1652
+ > ⚠️ **`onChange` on rn — DIVERGES:** writes `params.value = String(id)` but `args.selectedId = id` — a different key in each map. Conforms on `args` (rule 1) but the params mirror uses the wrong key, so a params-reading consumer sees a key no other arm sends.
1653
+
1654
+ > ⚠️ **`onChange` on web — DIVERGES:** renders a static window of options and never dispatches — the case has no onAction. Unimplemented on web rather than divergent. Surfaced by the missing-site assertion; the other four it reported were blind spots in the scan, now fixed.
1655
+
1466
1656
  #### ColorPicker
1467
1657
 
1468
1658
  ```ts
@@ -1475,6 +1665,16 @@ export interface ColorPickerProps {
1475
1665
  }
1476
1666
  ```
1477
1667
 
1668
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
1669
+
1670
+ | callback | key | type |
1671
+ |---|---|---|
1672
+ | `onChange` | `value` | string |
1673
+
1674
+ > `onChange` — Hex, including the leading '#'.
1675
+
1676
+ > ⚠️ **`onChange` on web + rn — DIVERGES:** writes the value into `params` ONLY, never `args`. Survives only because the Tier-5 hosts merge params into args. Any consumer reading `args` — which rule 1 says is the payload map — gets nothing.
1677
+
1478
1678
  #### OtpInput
1479
1679
 
1480
1680
  ```ts
@@ -1494,6 +1694,21 @@ export interface OtpInputProps {
1494
1694
 
1495
1695
  > ⚠️ **Implemented, but these props are inert on ios:** `onChange`, `onComplete` — Neither callback dispatches.
1496
1696
 
1697
+ **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
1698
+
1699
+ | callback | key | type |
1700
+ |---|---|---|
1701
+ | `onChange` | `value` | string |
1702
+ | `onComplete` | `value` | string |
1703
+
1704
+ > `onChange` — The assembled code. Only RN dispatches today; web, iOS and Android render the boxes read-only.
1705
+
1706
+ > ⚠️ **`onChange` on web + ios + android — DIVERGES:** renders the digit boxes without wiring onChange — the control dispatches nothing. Unimplemented rather than divergent, but it must be declared: leaving OtpInput out of the contract is what let the gate whitelist RN's payload without checking it.
1707
+
1708
+ > `onComplete` — Fires once the last box is filled, carrying the same assembled code as onChange. Declared separately because it is a second public callback — leaving it out left one outside the contract.
1709
+
1710
+ > ⚠️ **`onComplete` on web + ios + android — DIVERGES:** renders the digit boxes without wiring onChange — the control dispatches nothing. Unimplemented rather than divergent, but it must be declared: leaving OtpInput out of the contract is what let the gate whitelist RN's payload without checking it.
1711
+
1497
1712
  ### Cross-cutting & media
1498
1713
 
1499
1714
  #### Fab
@@ -14,6 +14,13 @@ lists) and `apps/family-cart` (cart list). The `examples/dynamic-list.*` pair th
14
14
  card grids). `Carousel` and `Menu` take `itemsPath` the same way. Do **not** hand-roll
15
15
  a "VStack of Cards" — that renders only static items and can't bind live data.
16
16
 
17
+ **`Grid` is not a `LazyList` with one column.** It shares `itemsPath` and the row
18
+ template, and nothing else: the gesture props in §5 (`onPullToRefresh`, `pagination`)
19
+ and `stickyHeader`/`rowSwipeActions` are declared on `LazyListProps` only. A key `Grid`
20
+ does not declare is **silently dropped** by the native decoders (`ignoreUnknownKeys =
21
+ true`) — `himi validate` catches it and names the declared set, but nothing catches it
22
+ at runtime. If you need pull-to-refresh or load-more, use `LazyList`.
23
+
17
24
  ## The shape (canonical off-repo)
18
25
 
19
26
  ```jsonc
@@ -70,6 +77,25 @@ including derived presentation fields (`initial` for an avatar letter, `hasImage
70
77
  booleans, pre-formatted date lines). Vertical-client responses are raw REST JSON —
71
78
  map them defensively; typed-looking fields can be absent at runtime.
72
79
 
80
+ **One carve-out, and it is the useful one: a NESTED REPEATER may bind to the row's own
81
+ array.** "Flat fields" is about *scalar* bindings — `$item.price`, not
82
+ `$item.priceData.formatted.amount`. A `Carousel`/`LazyList`/`Grid` *inside* a row template
83
+ may set `itemsPath: "$item.<field>"` and render that element's own array, with its own
84
+ `$item.` scope one level down. All four renderers resolve it as of #2265; before that fix web
85
+ rendered zero rows silently, which is why older guidance says it cannot be done. So a photo
86
+ carousel per feed row **is** expressible:
87
+
88
+ ```jsonc
89
+ { "id": "photos_$item.id", "body": { "package": "himalaya", "type": "Carousel",
90
+ "props": { "itemsPath": "$item.images", // ← the ROW's array, not a $service: path
91
+ "items": [ { "id": "photo_$item.url", "body": { "package": "himalaya",
92
+ "type": "Image", "props": { "url": "$item.url" } } } ] } } }
93
+ ```
94
+
95
+ `normalize()` still has to put a real array on the row (`images: []`, never `null`), and the
96
+ inner template obeys every rule above — flat scalar fields, no strict-enum bindings. Keep the
97
+ nesting to one level: a carousel of carousels is legal and unreadable.
98
+
73
99
  ## 3. The descriptor binds the array
74
100
 
75
101
  ```jsonc
@@ -130,8 +156,10 @@ full state-machine idiom: `himi docs recipe-four-state`.
130
156
 
131
157
  ## 5. Refresh + load-more gestures (RFC 0008)
132
158
 
133
- `LazyList` (and `Grid` in list mode) takes two optional gesture props — both are
134
- plain action wiring, no new worker concepts:
159
+ `LazyList` takes two optional gesture props — both are plain action wiring, no new
160
+ worker concepts. **`LazyList` only**: `GridProps` declares `items`, `columns`,
161
+ `columnsMedium`, `columnsExpanded`, `spacing`, `itemsPath` and `staggerMs`, and the
162
+ native decoders drop anything else without a word:
135
163
 
136
164
  ```jsonc
137
165
  "props": {
@@ -159,6 +187,21 @@ plain action wiring, no new worker concepts:
159
187
  - APIs that don't page (or v1 fallbacks without cursors) simply never set
160
188
  `nextCursor` — the gesture props are safe to leave in place.
161
189
 
190
+ ## 6. Tapping a row into a detail screen
191
+
192
+ A row usually opens something, and the id space is usually unbounded (every product, every
193
+ listing). **Use ONE canonical detail screen and pass the id as a nav param** — the row's action is
194
+ `{ "actionId": "himi.open", "params": { "screenId": "property_detail", "id": "$item.id" } }`, and
195
+ the detail worker reads `himalaya.input.id`.
196
+
197
+ The alternative, a **templated route** (`screens.templated` with a `pattern`), is for a *bounded*
198
+ catalog and nothing else. It resolves live for any id while you are developing, which is what makes it
199
+ look like the general answer — but a production OTA release serves only `screens.exact`, so the
200
+ build materializes one screen per id from the route's `seedIds` and a record created after that
201
+ push is simply not in the release. Reach for it when the set is small and known (a handful of
202
+ categories); reach for the canonical screen whenever the ids come from a live collection.
203
+ Mechanics and the prod caveat in full: `himi docs tier-5-add-a-screen`.
204
+
162
205
  ## Gotchas (each of these has cost an agent a full push→drive cycle)
163
206
 
164
207
  - **`itemsPath` must bind to an array.** `himi validate` errors if the bound path's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.943.0",
3
+ "version": "0.945.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -64,5 +64,5 @@
64
64
  "artifactId": "himalaya-cli"
65
65
  }
66
66
  },
67
- "falconPackageHash": "20d4d02bfd992d1556a2c7e77e8eae63a7b4ec23e7c9e082ff43e951"
67
+ "falconPackageHash": "7c47343c4a5027aabc50c963502aa5034b2828f8bf4e1e133fec1b94"
68
68
  }