@wix/himalaya-cli 0.1475.0 → 0.1476.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.
Binary file
@@ -14,8 +14,6 @@
14
14
  "SelectionControl",
15
15
  "ActivityIndicator",
16
16
  "Menu",
17
- "ChipsInput",
18
- "OtpInput",
19
17
  "Video",
20
18
  "PdfView",
21
19
  "MediaPlayer",
@@ -836,10 +834,7 @@
836
834
  "callback": "onAdd",
837
835
  "key": "value",
838
836
  "encoding": "string",
839
- "platforms": [
840
- "web"
841
- ],
842
- "note": "On web, Enter dispatches the trimmed new chip value. Native onAdd remains listed as not implemented in the component reference."
837
+ "note": "The new token, trimmed and non-empty, committed by Return or a comma (each segment before a comma is one token; the text after the last stays in the field). The field clears; the chips themselves stay the worker's, so a worker that wants the token shown appends it to `valuesPath`."
843
838
  },
844
839
  {
845
840
  "callback": "onRemove",
@@ -858,17 +853,6 @@
858
853
  ]
859
854
  }
860
855
  ],
861
- "notImplemented": {
862
- "props": [
863
- "onAdd"
864
- ],
865
- "platforms": [
866
- "ios",
867
- "android"
868
- ],
869
- "severity": "category",
870
- "note": "`onAdd` never dispatches."
871
- },
872
856
  "propsSource": "export interface ChipsInputProps {\n values?: string[];\n /** \"$service:\" binding → string[]. */\n valuesPath?: string;\n placeholder?: string;\n onAdd?: Action;\n onRemove?: Action;\n disabled?: boolean;\n}"
873
857
  },
874
858
  {
@@ -1167,52 +1151,15 @@
1167
1151
  "callback": "onChange",
1168
1152
  "key": "value",
1169
1153
  "encoding": "string",
1170
- "note": "The assembled code. Only RN dispatches today; web, iOS and Android render the boxes read-only.",
1171
- "divergences": [
1172
- {
1173
- "arms": [
1174
- "web",
1175
- "ios",
1176
- "android"
1177
- ],
1178
- "kind": "no-payload",
1179
- "actual": "renders the digit boxes without wiring onChange — the control dispatches nothing.",
1180
- "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."
1181
- }
1182
- ]
1154
+ "note": "The assembled code, on every edit (whitespace dropped, capped at `length`). Every arm dispatches it; an edit that leaves the code unchanged dispatches nothing."
1183
1155
  },
1184
1156
  {
1185
1157
  "callback": "onComplete",
1186
1158
  "key": "value",
1187
1159
  "encoding": "string",
1188
- "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.",
1189
- "divergences": [
1190
- {
1191
- "arms": [
1192
- "web",
1193
- "ios",
1194
- "android"
1195
- ],
1196
- "kind": "no-payload",
1197
- "actual": "renders the digit boxes without wiring onChange — the control dispatches nothing.",
1198
- "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."
1199
- }
1200
- ]
1160
+ "note": "Fires once, on the edit that fills the last box (the transition into `value.length == length`), carrying the same assembled code as onChange — never again while the code stays full. Declared separately because it is a second public callback — leaving it out left one outside the contract."
1201
1161
  }
1202
1162
  ],
1203
- "notImplemented": {
1204
- "props": [
1205
- "onComplete",
1206
- "onChange"
1207
- ],
1208
- "platforms": [
1209
- "ios",
1210
- "android",
1211
- "web"
1212
- ],
1213
- "severity": "category",
1214
- "note": "Neither `onChange` nor `onComplete` dispatches."
1215
- },
1216
1163
  "propsSource": "export interface OtpInputProps {\n length?: number;\n /** LITERAL code. A `$service:` sentinel here paints the raw token into the\n * boxes — bind `valuePath` instead. */\n value?: string;\n /** `$service:`/`$item.` binding → the live code (overrides `value`), so a\n * worker that clears it after submit empties the boxes. */\n valuePath?: string;\n onChange?: Action;\n onComplete?: Action;\n secure?: boolean;\n}"
1217
1164
  },
1218
1165
  {
@@ -119,9 +119,9 @@ See also **[Modifiers](#modifiers)** (the `modifiers` block) and **[Actions](#ac
119
119
 
120
120
  These props are accepted by the schema and pass validation, but the named platform's renderer never reads them — they silently do nothing (no warning, no log). Prefer a prop NOT listed here, or drive the behaviour from a worker.
121
121
 
122
- **Scope: 16 of 87 components, all of them android / web / ios-only** — every other component/platform pair reads every prop it decodes. The list splits in two, and the halves mean different things:
122
+ **Scope: 14 of 87 components, all of them android / web / ios-only** — every other component/platform pair reads every prop it decodes. The list splits in two, and the halves mean different things:
123
123
 
124
- - **`category` (5: `Menu`, `ChipsInput`, `OtpInput`, `MediaPlayer`, `TimeGrid`)** — the prop changes the component's *kind*. These genuinely are **not implemented**: an overlay renders always-visible inline, so the component does not do the job its name implies. Check the row's note for a working alternative.
124
+ - **`category` (3: `Menu`, `MediaPlayer`, `TimeGrid`)** — the prop changes the component's *kind*. These genuinely are **not implemented**: an overlay renders always-visible inline, so the component does not do the job its name implies. Check the row's note for a working alternative.
125
125
  - **`styling` (11: `Text`, `Icon`, `Carousel`, `Pager`, `Snackbar`, `WebView`, `Slider`, `SelectionControl`, `ActivityIndicator`, `Video`, `PdfView`)** — the component itself **works**; only the listed prop is inert. `Slider` on iOS is a working slider whose `debounceMs` does not throttle — not a broken `Slider`. Use the component; just do not depend on that one prop.
126
126
 
127
127
  | Component | Unread props | Platform | Kind | Note |
@@ -137,8 +137,6 @@ These props are accepted by the schema and pass validation, but the named platfo
137
137
  | `ActivityIndicator` | `ring` | ios, android, web | styling | The `ring` style is never selected (determinate vs indeterminate follows `value`). |
138
138
  | `Menu` | `itemsPath` | ios | category | `itemsPath` does not bind items. |
139
139
  | `Menu` | `style`, `itemsPath` | android, web | category | `style: "action_sheet"` renders as a dropdown. (`style: "context"` is honoured: it opens on a long-press, or right-click on web, with its `preview`.) `itemsPath` does not bind items. |
140
- | `ChipsInput` | `onAdd` | ios, android | category | `onAdd` never dispatches. |
141
- | `OtpInput` | `onComplete`, `onChange` | ios, android, web | category | Neither `onChange` nor `onComplete` dispatches. |
142
140
  | `Video` | `poster` | ios | styling | `poster` is not shown. |
143
141
  | `PdfView` | `showPageControls`, `zoomable`, `allowShare` | web | styling | Interactive PDF.js canvas rendering honours the reader’s page, zoom and export controls. Static previews and PDF.js loading/rendering failures use a native browser-viewer fallback. In that fallback, hiding page or zoom controls is only a viewer hint and cannot reliably hide browser chrome; allowShare hides the reader’s export controls but cannot prevent browser save/print. These props are UI controls, not document access restrictions. |
144
142
  | `MediaPlayer` | `autoplayVisibilityThreshold`, `preload`, `playbackRates`, `showQueue`, `showChapters`, `showTranscript` | ios | category | `autoplayVisibilityThreshold` is ignored (whenVisible = once on appear). `preload` is ignored. `playbackRates` is ignored, and the queue/chapters/transcript panels never render. |
@@ -1476,8 +1474,6 @@ export interface ChipsInputProps {
1476
1474
  }
1477
1475
  ```
1478
1476
 
1479
- > ⚠️ **Not implemented on ios, android:** `onAdd` — `onAdd` never dispatches.
1480
-
1481
1477
  **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
1482
1478
 
1483
1479
  | callback | key | type |
@@ -1485,7 +1481,7 @@ export interface ChipsInputProps {
1485
1481
  | `onAdd` | `value` | string |
1486
1482
  | `onRemove` | `value` | string |
1487
1483
 
1488
- > `onAdd` — On web, Enter dispatches the trimmed new chip value. Native onAdd remains listed as not implemented in the component reference.
1484
+ > `onAdd` — The new token, trimmed and non-empty, committed by Return or a comma (each segment before a comma is one token; the text after the last stays in the field). The field clears; the chips themselves stay the worker's, so a worker that wants the token shown appends it to `valuesPath`.
1489
1485
 
1490
1486
  > `onRemove` — The removed chip's value.
1491
1487
 
@@ -1935,8 +1931,6 @@ export interface OtpInputProps {
1935
1931
  }
1936
1932
  ```
1937
1933
 
1938
- > ⚠️ **Not implemented on ios, android, web:** `onComplete`, `onChange` — Neither `onChange` nor `onComplete` dispatches.
1939
-
1940
1934
  **Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
1941
1935
 
1942
1936
  | callback | key | type |
@@ -1944,13 +1938,9 @@ export interface OtpInputProps {
1944
1938
  | `onChange` | `value` | string |
1945
1939
  | `onComplete` | `value` | string |
1946
1940
 
1947
- > `onChange` — The assembled code. Only RN dispatches today; web, iOS and Android render the boxes read-only.
1948
-
1949
- > ⚠️ **`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.
1950
-
1951
- > `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.
1941
+ > `onChange` — The assembled code, on every edit (whitespace dropped, capped at `length`). Every arm dispatches it; an edit that leaves the code unchanged dispatches nothing.
1952
1942
 
1953
- > ⚠️ **`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.
1943
+ > `onComplete` — Fires once, on the edit that fills the last box (the transition into `value.length == length`), carrying the same assembled code as onChange — never again while the code stays full. Declared separately because it is a second public callback — leaving it out left one outside the contract.
1954
1944
 
1955
1945
  ### Cross-cutting & media
1956
1946
 
@@ -426,14 +426,16 @@ its own recovery.
426
426
  |---|---|---|---|---|
427
427
  | **Offline** | No answer reached the device (no connection, no route, the request timed out). | The screen or section that failed to load. | "Reconnect to the internet" / "Connect to the internet, then try again." | **Try again**, which re-runs the load. |
428
428
  | **Server failure** | The service answered with a failure. | The screen or section. | "Unable to load orders" / "There was a technical issue on our end. Please wait and try again later." | **Try again**, when a retry can help. |
429
- | **Permission or sign-in** | The user is signed out, or signed in without access (HTTP 401 / 403). | The screen or section. | "Sign in to view orders" / "You need permission to view this screen" | The next step the app offers — **Sign in**, or a contact action. No retry: the same request fails again. |
429
+ | **Sign-in** | The session is missing or has ended: an HTTP 401, a session-expired code, a bridge that has no session, or a 403 for a visitor the host knows is signed out. When the host knows the user is signed in, a 401 is a permission refusal instead. | The screen or section. | "Sign in to view orders" / "Your session has ended." | Open the app's configured auth flow. No retry until authentication succeeds. |
430
+ | **Site membership** | The account is not a member of this site (`NotSiteMember`). A `403 WDE0027` is not this class: Wix Data returns it to signed-in members without access too. | The screen or section. | "Join this site to view orders" / "Use this site's join flow, or ask the owner for access." | Open the site's join flow when the app provides one; otherwise show contact guidance. |
431
+ | **Permission** | The user lacks access (HTTP 403, `WDE0027` included), or the endpoint reports a policy refusal. Signing in again does not help. A 403 for a visitor the host knows is signed out is **Sign-in** instead: member-only data refuses visitors with `403 WDE0027`. | The screen or section. | "You need permission to view this screen" | No retry; show the next useful step, such as contacting the owner. |
430
432
  | **Empty results** | The request succeeded and returned nothing. | **Not an error** — the empty state (above). | "No orders yet" | The empty state's directive action. |
431
433
  | **Field validation** | A value the user typed is not valid. | Beside the field, in its own error slot (see **Inputs & forms** in §3). | "Enter a valid email." | The user corrects the field. No retry. |
432
434
  | **Action failure** | A write or a command failed (save, delete, book). | Near the action or the affected content — a line by the button, or a `danger` snackbar (`ui.snackbar({ message, tone: "danger" })`). | "Couldn't save your changes. Try again." | Keep what the user entered; the action itself is the retry. |
433
435
 
434
436
  - **A screen- or section-level error is an `EmptyState` with no icon.** Set `title` and
435
437
  `subtitle`; leave `icon` out. Add `action` + `actionLabel` only when the user has a useful
436
- recovery — retry, reload, sign in. Every error then has the same layout and style, as every
438
+ recovery — retry, reload, the app's auth flow, or the site's join flow when available. Every error then has the same layout and style, as every
437
439
  empty state does. Do not build one by hand from `Icon`, `Text` and `Button` nodes.
438
440
  - **One node per class.** iOS and Android do not resolve a `$service:` binding in `EmptyState`
439
441
  `title` or `subtitle`, so the worker cannot swap the text. Give each class the screen can hit its
@@ -443,8 +445,9 @@ its own recovery.
443
445
  - **User-facing language, never the raw error.** Say what happened and what the user can do. Never
444
446
  show `e.message`, a status code, a stack trace or an API error string — keep those for your logs.
445
447
  - **Recovery only when it makes sense.** Offer **Try again** for an offline or a server failure,
446
- and for a failed write. Do not offer it for a permission error, an empty result or a field error:
447
- the same request gives the same answer.
448
+ and for a failed write. Use the app's auth flow for a sign-in failure, and the
449
+ site's join flow for `NotSiteMember` when the app provides one. Do not offer retry for a
450
+ permission error, an empty result or a field error: the same request gives the same answer.
448
451
  - **Empty is not an error.** A successful call with zero rows shows the empty state.
449
452
  - **A failed refresh keeps the content.** When data is already on screen and a refresh fails,
450
453
  keep the rows and show a `Banner` (`severity: "warning"`, with a **Try again** `action` and its
@@ -452,7 +455,7 @@ its own recovery.
452
455
  again** go in a row at the end of the list that retries the same cursor
453
456
  (`himi docs recipe-dynamic-list`) — the user is at the bottom, and the Banner's retry reloads
454
457
  page 1. Replace the content with an error state only when there is nothing to show, or when a
455
- sign-in or permission failure means the rows are no longer the user's to see.
458
+ sign-in, membership or permission failure means the rows are no longer the user's to see.
456
459
  - **The error replaces the loader.** When a load fails, hide the loading state and show the error —
457
460
  never both at once.
458
461
 
@@ -543,7 +546,7 @@ Run this punch list on the screen (it's also a good reviewer checklist):
543
546
 
544
547
  - [ ] **One** primary action, on the one `accentPrimary` surface?
545
548
  - [ ] All four states designed — loading / empty / error / loaded (empty has a directive action; error has a recovery action when one exists)?
546
- - [ ] Errors classified (offline / server / permission or sign-in), each an icon-less `EmptyState` with user-facing copy — no raw error text? Empty results shown as empty, not as an error? A failed refresh keeps the content?
549
+ - [ ] Errors classified (offline / server / sign-in / site membership / permission), each an icon-less `EmptyState` with user-facing copy — no raw error text? Empty results shown as empty, not as an error? A failed refresh keeps the content?
547
550
  - [ ] Loading state matches the loaded layout (a content-shaped `Skeleton` where the layout is known)? For a redesign, was the existing loading state reviewed against the new layout? A static screen has none?
548
551
  - [ ] Every value a token — zero literal hex / off-scale spacing / invented type sizes?
549
552
  - [ ] Is every user-facing string routed through a copy key with a sensible fallback (`copyOf(key, fallback)` backed by your own `COPY` map; worker strings via `onAppear` params) — none baked directly into the descriptor?
package/guides/index.json CHANGED
@@ -25,7 +25,7 @@
25
25
  "Keep structure descriptor-authored: use FragmentSlot for enumerable variants and bound list/grid templates for repetition. Use WorkerSubtree only for runtime-unknowable structure, with an explicit screen allowlist, policy budgets, and shipped fallback.",
26
26
  "Bindings ($service:<svc>.<path>, $item.<field>, $flag:<key>) are plain strings in ANY prop — INCLUDING Modifiers token refs (background, color, tint), so a worker can drive per-item theming, not just text. They CANNOT target strict-enum fields (style, size, alignment, severity, presentation, …) — those fail at render, not validation.",
27
27
  "A screen that loads data asynchronously ships a designed LOADING state with it — a `Skeleton` shaped like the loaded layout when that layout is known (`StatefulSection` or `ActivityIndicator` when they fit the behavior better) — and a REDESIGN of a data screen reviews and updates that loading state in the same change, so it never shows the old layout. Loading is its own state, never the empty or the error state; a static screen gets no loading state (`himi docs designing-himi-screens` §5).",
28
- "CLASSIFY every error before you show it: offline, server failure and permission/sign-in each get their own `EmptyState` with `title` + `subtitle` and NO `icon` (one node per class, gated by `hiddenPath`, because iOS/Android do not bind `$service:` in its text), user-facing copy — never `e.message`, a status code or an API error string — and an `action` + `actionLabel` ONLY for a real recovery (Try again for offline, server and failed writes; Sign in for sign-in; none for permission, empty results or field errors). Empty results are the empty state, not an error. A refresh that fails KEEPS the rows and adds a `Banner` with retry; a load-more that fails keeps the rows and ends the list with a \"Couldn't load more\" row that retries the SAME cursor (`himi docs recipe-dynamic-list`), never the Banner, which reloads page 1. A sign-in or permission failure CLEARS the rows instead — they are no longer the user's to see. Field errors stay in the field's slot; a failed action shows next to the action. When an `ErrorState` component ships, only the component changes — these rules stay (`himi docs designing-himi-screens` §5, `himi docs recipe-four-state`).",
28
+ "CLASSIFY every error before you show it: offline, server failure, sign-in, site membership and permission each get their own `EmptyState` with `title` + `subtitle` and NO `icon` (one node per class, gated by `hiddenPath`, because iOS/Android do not bind `$service:` in its text), user-facing copy — never `e.message`, a status code or an API error string — and an `action` + `actionLabel` ONLY for a real recovery (Try again for offline, server and failed writes; the app's auth flow for sign-in; the site's join flow for `NotSiteMember` when available; none for permission, empty results or field errors). Empty results are the empty state, not an error. A refresh that fails KEEPS the rows and adds a `Banner` with retry; a load-more that fails keeps the rows and ends the list with a \"Couldn't load more\" row that retries the SAME cursor (`himi docs recipe-dynamic-list`), never the Banner, which reloads page 1. A sign-in, membership or permission failure CLEARS the rows instead — they are no longer the user's to see. Field errors stay in the field's slot; a failed action shows next to the action. When an `ErrorState` component ships, only the component changes — these rules stay (`himi docs designing-himi-screens` §5, `himi docs recipe-four-state`).",
29
29
  "Workers are sandboxed: no DOM, no timers, no raw fetch. Produce state synchronously in actions; do not rely on an ambient refresh tick.",
30
30
  "Declare every capability a worker uses in TWO places: the calling screen's logicBundle.declaredCapabilities (wix_platform, stream, camera, location, …) — undeclared calls are blocked — AND config.ts `capabilities`, the app-level list a binary is built with. The `capabilities` guide shows each id's import, what the binary needs and which shells serve it; validate flags an unknown id, an undeclared call, and (with `shell` in himalaya.content.json) an id that shell's binary does not serve. A capability with a native footprint (camera, location, push, …) only arrives with a new binary — an over-the-air push cannot add it. EXCEPTION: the Wix REST bridge (wix_api — the @himalaya/wix-* clients / wixApi.call) is ungated today (installed unconditionally), so it works WITHOUT a declaration; declaring wix_api is still recommended — it is where the platform is heading, and it documents what your worker touches.",
31
31
  "`validate`/`render` prove a screen is WELL-FORMED and, by default, paint its INITIAL state — NOT that the app's LOGIC is correct. A wrong reducer (a game that never ends, a bad total) passes both with ok:true. Run `himi test`: it needs NO test files — it boots each screen's real worker and dispatches every action your descriptor already declares, then reports anything that threw, stayed stuck on `loading`, or leaked a NaN/undefined/`$service:` token into visible text. It crawls on HERMETIC DATA by default (zero network): your `dev/net-mocks.json` first, then your config's CMS `seed:` rows, then a canned fixture of frozen real vertical responses shipped with the CLI — so the fetch-SUCCESS mapping path runs too, where data-shape bugs actually live (canned data exercises code paths, not API-contract truth). Two boot-only HOSTILE passes (every list empty; every request 500) then check the empty/outage day — their findings are errors, not warnings. It also runs the worker in a DEVICE-PARITY sandbox (no setTimeout/setInterval, as on JSC/WebView), so a bundle that would only die on the device fails locally instead — e.g. `svc.actions.load(…)` (actions are DIRECT methods; `.actions` is undefined on device). (You are NOT limited to the crawled states either: rendering with `--live` runs the worker's REAL fetches against live Wix — a minted anonymous visitor token from the app's clientId — so a render CAN prove real-data binding locally with no VPN/push; and injecting `state` (`{\"<svc>\":{…}}`) renders a specific mid-flow state without the network.)",
@@ -1029,18 +1029,22 @@ diagnoses.
1029
1029
  ### An Android App Bundle for Google Play — `--aab`
1030
1030
 
1031
1031
  An APK installs on a device. Google Play wants an **Android App Bundle** (`.aab`) for a new app,
1032
- and a bundle installs on nothing. `--aab` builds the bundle instead, for an owner who downloads the
1033
- Wix-built binary and uploads it to Play Console themselves:
1032
+ and a bundle installs on nothing. `--aab` builds the bundle **and an APK from the same build**, for
1033
+ an owner who uploads the bundle to Play Console themselves and installs the APK on a phone:
1034
1034
 
1035
1035
  ```sh
1036
1036
  himi login --dpx --target bo # an INTERNAL caller: self-serve callers get 403 (see below)
1037
1037
  himi dist --app <app> --platform android --aab --wait --target bo
1038
1038
  ```
1039
1039
 
1040
- - **What you get:** `artifact.downloadUrl` (presigned, 7 days) and `artifact.sha256`. No
1041
- `installLink`, no `installPageUrl`, no QR, and `GET /v1/dist/<jobId>/install` answers 404 for it —
1042
- a link that hands a phone a file it cannot open is worse than none. The record carries
1043
- `androidFormat: "aab"`.
1040
+ - **What you get:** the bundle as `artifact.downloadUrl` (presigned, 7 days) and `artifact.sha256`,
1041
+ as before, so a Google Play caller reads the same fields. The APK as `artifact.apk`
1042
+ (`downloadUrl`, `sha256`, and its `bucket`/`key`). The QR, the links surface and
1043
+ `GET /v1/dist/<jobId>/install` all point at that APK, never at the `.aab` — a link that hands a
1044
+ phone a file it cannot open is worse than none. A bundle job with no `artifact.apk` gets no QR and
1045
+ a 404 from the redirect. The APK is kept only when the pool reports it QA-signed
1046
+ (`apk.androidSigningUsed: "qa-keystore"`) and it sits in the bundle's own S3 bucket; otherwise the
1047
+ bundle still succeeds and the record says `companion_apk_dropped: …`. The record carries `androidFormat: "aab"`.
1044
1048
  - **Android only.** The default stays an APK, and iOS is untouched. It travels as
1045
1049
  `androidFormat: "aab"` on `POST /v1/admin/dist` (every `/v1/owner/dist` caller is self-serve, so
1046
1050
  that route always answers `403 android_format_not_allowed`), and reaches the pool as
@@ -1054,21 +1058,25 @@ himi dist --app <app> --platform android --aab --wait --target bo
1054
1058
  bundle without a real Firebase config and customer identity). A mixed `--platform ios,android`
1055
1059
  applies it to the Android leg only.
1056
1060
  - **How the pool builds it.** `run-dist` runs store-release's `bundleRelease` task
1057
- (`bundleReleaseArgs` / `bundleReleaseEnv`, the same task the Play lane runs) with **AGP injected
1058
- signing**, with the **Wix production key** (`wix-keystore`: `~/.signing/oneapp-production.keystore`,
1061
+ (`bundleReleaseArgs` / `bundleReleaseEnv`, the same task the Play lane runs) and, in the **same
1062
+ gradle run**, the module's matching `assemble<Variant>` task (`companionApkTask`: same module, same
1063
+ flavor), with **AGP injected signing**, with the **Wix production key** (`wix-keystore`: `~/.signing/oneapp-production.keystore`,
1059
1064
  alias `WixOne`), resolved by `resolveAndroidSigner` and verified there. The pool refuses any
1060
1065
  other signer for a bundle — a pool-side `DIST_ANDROID_KEYSTORE` included. The keystore path and alias are `-P` flags; the passwords ride in the environment as
1061
1066
  `ORG_GRADLE_PROJECT_android.injected.signing.{store,key}.password`, never in argv. `jarsigner
1062
- -verify` must say `jar verified.` before anything is uploaded. The seed gate and the binary-size
1063
- budget run on the `.aab` as they do on an APK. `DIST_ANDROID_BUNDLE_TASK` overrides the task
1067
+ -verify` must say `jar verified.` before anything is uploaded. The APK gets every APK check:
1068
+ it is **re-signed with the committed QA key** (never left with WixOne) and `apksigner`-verified,
1069
+ gets the 16 KB check, and must report `qa-keystore`, or the build fails. Only release outputs are
1070
+ considered, so a stale debug APK in a warm tree is never picked. The seed gate and the binary-size budget run on both
1071
+ files. Compile, resources and R8 are shared, so the APK adds only its packaging and one upload. `DIST_ANDROID_BUNDLE_TASK` overrides the task
1064
1072
  (`DIST_ANDROID_TASK` stays the APK one), and `DIST_ANDROID_FLAVOR` picks among flavored bundles.
1065
1073
  Only the task's own gradle module is searched, so a sibling module's stale bundle is never taken.
1066
- An **ambiguous** choice fails the build instead of guessing: the bundle's applicationId becomes
1067
- the app's permanent Play package.
1068
- - **What it skips, and why.** `apksigner` and the re-sign step (a bundle is JAR-signed at build
1074
+ An **ambiguous** choice fails the build instead of guessing, for the bundle and for the APK beside
1075
+ it: the bundle's applicationId becomes the app's permanent Play package.
1076
+ - **What the bundle skips, and why.** `apksigner` and the re-sign step (a bundle is JAR-signed at build
1069
1077
  time, and the injected config overrides any `signingConfig`); the 16 KB check (its ZIP half is about an installed APK,
1070
1078
  which Play generates; its ELF half reads the APK `lib/` layout — Play checks alignment on
1071
- upload); and `--reuse-artifact` (that is an APK).
1079
+ upload); and `--reuse-artifact` (that is an APK). The APK beside it gets the APK checks above.
1072
1080
  - **Version skew.** The pool runs the release's own `run-dist`, and one older than `--aab` ignores
1073
1081
  `DIST_ANDROID_FORMAT` and builds an APK. So the pool reports what it built (`androidFormat` on
1074
1082
  the result callback, the `androidSigningUsed` pattern), and serve-control turns a `SUCCESS` that
@@ -1088,7 +1096,7 @@ himi dist --app <app> --platform android --aab --wait --target bo
1088
1096
  stays for a runner older than that rule. A later delivery that passes every check (a retry on a
1089
1097
  clean machine) clears the verdict.
1090
1098
 
1091
- **Why the Wix production key, and only for a bundle.** There are two Android keys: the committed
1099
+ **Why the Wix production key, and only for the bundle.** There are two Android keys: the committed
1092
1100
  QA keystore and the Wix production key (WixOne). WixOne is the Play **upload** key every Branded
1093
1101
  app's bundle carries (`mobile-apps-engine` refuses any other certificate for a Play build), with
1094
1102
  Play App Signing on, so Google re-signs what users install. An `.aab` therefore signs with it —
@@ -1096,7 +1104,10 @@ only its `WixOne` entry of `oneapp-production.keystore`: the alias is pinned for
1096
1104
  alias the pool's signing files name. Because the first upload binds a Play listing to that key for good, a **self-serve** caller
1097
1105
  gets no bundle at all (`403 android_format_not_allowed`): `--aab` is for internal callers until a
1098
1106
  Containers store build checks the app, its owner and its package name. A self-serve **APK** with
1099
- `wix-keystore` keeps `403 android_signing_not_allowed`, unchanged. For the owner, the Play listing is bound to the WixOne upload certificate, so later
1107
+ `wix-keystore` keeps `403 android_signing_not_allowed`, unchanged. The APK built beside a bundle is
1108
+ QA-signed, so no installable APK carrying the WixOne signature is ever handed out; WixOne would not
1109
+ help it anyway, because the APKs Google Play delivers carry Google's app-signing key, so a phone with
1110
+ the QA APK uninstalls it before installing the app from Google Play. For the owner, the Play listing is bound to the WixOne upload certificate, so later
1100
1111
  updates come from Wix builds too; leaving Wix needs an upload-key reset through Play Console
1101
1112
  support. A pool machine without the key fails the build, naming the paths it searched, rather than
1102
1113
  signing with another key.
@@ -1193,8 +1204,10 @@ baking the live asset.
1193
1204
  or `DIST_ANDROID_USE_WIX_KEYSTORE=1` names one, and a named key wins — `apksigner` replaces the
1194
1205
  signature block — because the job record is written at dispatch and would otherwise name a key
1195
1206
  that is not on the binary.
1196
- - **Android, `--aab`** → a **presigned `.aab` download URL** and its sha256, and **no install
1197
- link**: a bundle is uploaded to Google Play, it does not install on a device. See
1207
+ - **Android, `--aab`** → a **presigned `.aab` download URL** and its sha256 (`artifact.downloadUrl`;
1208
+ a bundle is uploaded to Google Play, it does not install on a device), plus the QA-signed APK
1209
+ from the same build as `artifact.apk`, which the QR, the links surface and
1210
+ `GET /v1/dist/<jobId>/install` point at. See
1198
1211
  [the App Bundle section](#an-android-app-bundle-for-google-play----aab).
1199
1212
  - **How Android picks up the branding.** `prepare:customer` writes an **xcconfig** for iOS (bundle
1200
1213
  id + display name) but on Android only tokens, the icon drawable and a colour XML. Android's
@@ -1325,6 +1338,22 @@ apksigner verify --verbose qa.apk # must say "Verifies"; DOES
1325
1338
  aapt2 dump badging qa.apk | head -2 # package + application-label = the branding actually baked
1326
1339
  ```
1327
1340
 
1341
+ For an **`--aab`** job, `downloadUrl` is the bundle (it installs on nothing) and the phone's file
1342
+ is `artifact.apk`, so check both:
1343
+
1344
+ ```sh
1345
+ curl -sS -o play.aab "<artifact.downloadUrl>"
1346
+ shasum -a 256 play.aab # must equal artifact.sha256
1347
+ jarsigner -verify play.aab # must say "jar verified." (signed with WixOne)
1348
+ curl -sS -o qa.apk "<artifact.apk.downloadUrl>"
1349
+ shasum -a 256 qa.apk # must equal artifact.apk.sha256
1350
+ apksigner verify --print-certs qa.apk # must verify, with the committed QA key's certificate
1351
+ ```
1352
+
1353
+ The APK is signed with the committed QA key (identical on every pool machine), not a per-machine
1354
+ debug key, so it installs over an earlier QA build of the same app. It does not install over the app
1355
+ from Google Play, whose APKs carry Google's app-signing key: uninstall one before installing the other.
1356
+
1328
1357
  **5. Install and drive it.**
1329
1358
 
1330
1359
  ```sh
@@ -60,11 +60,11 @@ interface Row { // FLAT strings/booleans only — see "Normalize" b
60
60
  interface State {
61
61
  status: "loading" | "ready" | "error";
62
62
  items: Row[];
63
- errorKind: "" | "offline" | "server" | "signIn" | "permission"; // classifyError(e), never the raw message
63
+ errorKind: "" | "offline" | "server" | "signIn" | "membership" | "permission"; // classifyError(e, currentAuthState()), never the raw message
64
64
  // hide* flags for hiddenPath, derived in shape() — see `himi docs recipe-four-state`
65
65
  hideLoading: boolean; notEmpty: boolean;
66
66
  hideOfflineError: boolean; hideServerError: boolean;
67
- hideSignInError: boolean; hidePermissionError: boolean; hideRefreshError: boolean;
67
+ hideSignInError: boolean; hideMembershipError: boolean; hidePermissionError: boolean; hideRefreshError: boolean;
68
68
  nextCursor: string; failedCursor: string; hideLoadMoreError: boolean; // §5 load-more
69
69
  }
70
70
  ```
@@ -157,17 +157,18 @@ A list loads data, so it ships all four states — the full idiom is
157
157
  keeps the rows and uses the list's own spinner.
158
158
  - **Empty** — an `EmptyState` with a directive action, gated by `notEmpty`.
159
159
  - **Error, nothing on screen** — one `EmptyState` per class `classifyError` can
160
- return (offline, server, sign-in, permission), **no icon**, user-facing copy.
160
+ return (offline, server, sign-in, site membership, permission), **no icon**, user-facing copy.
161
161
  Offline and server get **Try again** → `tier5.products.retry`; sign-in gets
162
- **Sign in**; permission gets no action. A list with no sign-in flow maps
162
+ **Sign in**; membership opens the site's join flow when available; permission gets no action. A list with no sign-in flow maps
163
163
  `"signIn"` to `"permission"` — a visitor still gets a 401 or 403 (Bookings
164
164
  `searchServices`, above) and a missing node paints a blank screen. The worker's
165
- `catch` sets `errorKind` from `classifyError(e)` — never `String(e.message)`,
165
+ `catch` sets `errorKind` from `classifyError(e, currentAuthState())` (both from
166
+ `himi docs recipe-four-state`) — never `String(e.message)`,
166
167
  which shows the user a wire error.
167
168
  - **Error, rows on screen** — a pull-to-refresh that fails offline or on the
168
169
  server keeps the rows (`shape({ ...s, status: "error", errorKind })`, `items`
169
170
  untouched) and shows a `Banner` with **Try again** above them, gated by
170
- `hideRefreshError`. A sign-in or permission failure clears the rows instead.
171
+ `hideRefreshError`. A sign-in, membership or permission failure clears the rows instead.
171
172
  A failed **load-more** has its own retry (§5).
172
173
 
173
174
  ```jsonc
@@ -222,10 +223,10 @@ native decoders drop anything else without a word:
222
223
  `hideLoadMoreError`), whose action restores `nextCursor = failedCursor` and runs
223
224
  `loadMore` again. Do not route it through the refresh `Banner`: that **Try
224
225
  again** reloads page 1 and throws away the pages already shown.
225
- - **Except when page 2 loses access.** A `signIn` or `permission` failure on
226
+ - **Except when page 2 loses access.** A `signIn`, `membership` or `permission` failure on
226
227
  `loadMore` is not retryable and the rows already shown are no longer the
227
- user's to see: clear them and go to the same sign-in / permission
228
- `EmptyState` a refresh uses (`load()` in `himi docs recipe-four-state`). Keep
228
+ user's to see: clear them and go to the same sign-in, membership or
229
+ permission `EmptyState` a refresh uses — membership with its join action (`load()` in `himi docs recipe-four-state`). Keep
229
230
  page 1 and offer the cursor retry only for `offline` and `server`.
230
231
  - APIs that don't page (or v1 fallbacks without cursors) simply never set
231
232
  `nextCursor` — the gesture props are safe to leave in place.
@@ -35,7 +35,6 @@ the app's `.agents/skills/himalaya/examples/` (written by `himi init` and `himi
35
35
  ],
36
36
  "submitButton": { "id": "save", "body": { "package": "himalaya", "type": "Button",
37
37
  "props": { "title": "Create post", "style": "primary", "fullWidth": true,
38
- "disabledPath": "$service:postForm.cannotSave",
39
38
  "action": { "actionId": "tier5.postForm.submit" } } } }
40
39
  }
41
40
  }
@@ -92,16 +91,15 @@ interface FormState {
92
91
  status: "idle" | "saving" | "saved" | "error";
93
92
  error: string; // "" unless failed → collapseWhenEmpty
94
93
  savedMessage: string; // "" unless saved → collapseWhenEmpty
95
- cannotSave: boolean; // true after a permission failure → disabledPath
96
94
  }
97
95
 
98
96
  const svc = defineService<FormState, {
99
97
  submit: (s: FormState, values?: Record<string, unknown>) => Promise<FormState>;
100
98
  }>("postForm", {
101
- state: { title: "", body: "", status: "idle", error: "", savedMessage: "", cannotSave: false },
102
- // Outcome keys are per-attempt: a cold boot must not restore a stale error, or a submit
103
- // disabled by a 403 the user has since been granted past.
104
- notHydrated: ["status", "error", "savedMessage", "cannotSave"],
99
+ state: { title: "", body: "", status: "idle", error: "", savedMessage: "" },
100
+ // Outcome keys are per-attempt: a cold boot must not restore a stale error or a
101
+ // "Draft created." line from an earlier run.
102
+ notHydrated: ["status", "error", "savedMessage"],
105
103
  actions: {
106
104
  submit: async (s, values) => {
107
105
  const title = String(values?.title ?? "").trim();
@@ -113,9 +111,8 @@ const svc = defineService<FormState, {
113
111
  } catch (e) {
114
112
  // Keep what the user typed (echo it back into the prefill), and say what
115
113
  // happened in their words — never e.message.
116
- const kind = classifyError(e); // from himi docs recipe-four-state
117
- return { ...s, title, body, status: "error", error: SAVE_FAILED[kind], savedMessage: "",
118
- cannotSave: kind === "permission" }; // bound to the submit's disabledPath
114
+ const kind = classifyError(e, currentAuthState()); // from himi docs recipe-four-state
115
+ return { ...s, title, body, status: "error", error: SAVE_FAILED[kind], savedMessage: "" };
119
116
  }
120
117
  },
121
118
  },
@@ -127,6 +124,7 @@ const SAVE_FAILED: Record<string, string> = {
127
124
  offline: "You're offline, so nothing was saved. Try again when you reconnect.",
128
125
  server: "Couldn't save your changes. Try again.",
129
126
  signIn: "Your session has ended. Sign in, then save again.",
127
+ membership: "Join this site before saving.",
130
128
  permission: "You don't have permission to save this.",
131
129
  };
132
130
  ```
@@ -140,9 +138,10 @@ const SAVE_FAILED: Record<string, string> = {
140
138
  user typed** — the catch returns `title`/`body` into the prefill so nothing is
141
139
  lost. The submit button is the retry: there is no separate **Try again**.
142
140
  A **sign-in** failure says to sign in and save again (the submit works once the
143
- user is signed in). A **permission** failure says so and sets `cannotSave`,
144
- which the submit button binds through `disabledPath`, so it offers no retry
145
- that would fail the same way.
141
+ user is signed in). A **membership** failure asks the user to join this site;
142
+ keep submit available so they can save after joining. A **permission** failure
143
+ says so and keeps submit available too, so the user can retry after access is
144
+ granted without restarting the form.
146
145
  - The write goes through a `@himalaya/wix-<domain>` client (declare `wix_api`!).
147
146
  **Wrap the entity envelope the API expects** (`{ draftPost: {...} }` handled by
148
147
  the SDK; raw `wixApi.call` writes must wrap it themselves) — an unwrapped body
@@ -153,7 +152,7 @@ const SAVE_FAILED: Record<string, string> = {
153
152
 
154
153
  Directly below the `Form` — its submit button is the form's last element — the
155
154
  standard outcome row, each line rendering only in its state, next to the action it
156
- reports on. Bind the submit's `disabledPath` to `cannotSave`:
155
+ reports on:
157
156
 
158
157
  ```jsonc
159
158
  { "id": "err", "…Text": { "text": "$service:postForm.error", "color": "danger",