@wix/himalaya-cli 0.1465.0 → 0.1467.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.
@@ -7,6 +7,7 @@
7
7
  // The worker validates there, lands the write, and reports the outcome as state
8
8
  // (error / savedMessage strings + a hideSavedLink boolean), so the descriptor
9
9
  // renders every outcome with plain collapseWhenEmpty Texts + one gated button.
10
+ // A failed write keeps the typed values and shows a classified, user-facing line.
10
11
  //
11
12
  // The write rides the Wix transport → the descriptor's logicBundle MUST declare
12
13
  // "wix_api" (RFC 0043 D4).
@@ -24,6 +25,7 @@ interface PostFormState {
24
25
  savedMessage: string; // "" unless saved → collapseWhenEmpty
25
26
  hideSavedLink: boolean; // gates the "Open post" button via hiddenPath
26
27
  savedScreenId: string; // deep-link target for the created entity
28
+ cannotSave: boolean; // true after a permission failure → the submit's disabledPath
27
29
  }
28
30
 
29
31
  function shape(s: Partial<PostFormState>): PostFormState {
@@ -37,25 +39,69 @@ function shape(s: Partial<PostFormState>): PostFormState {
37
39
  savedMessage: s.savedMessage ?? "",
38
40
  savedScreenId,
39
41
  hideSavedLink: status !== "saved" || !savedScreenId,
42
+ cannotSave: s.cannotSave ?? false,
40
43
  };
41
44
  }
42
45
 
46
+ // A failed save is an ACTION error: one user-facing sentence per class, shown next to
47
+ // the submit button, which is the retry (`himi docs recipe-four-state`). Never e.message.
48
+ type ErrorKind = "offline" | "server" | "signIn" | "permission";
49
+ const SAVE_FAILED: Record<ErrorKind, string> = {
50
+ offline: "You're offline, so nothing was saved. Try again when you reconnect.",
51
+ server: "Couldn't save your changes. Try again.",
52
+ signIn: "Your session has ended. Sign in, then save again.",
53
+ permission: "You don't have permission to save this.",
54
+ };
55
+
56
+ // The HTTP status rides in the message: `HTTP 503: …` on a device, `Wix 503 for …`
57
+ // under `himi test` / `himi render`. Only a transport failure is "offline".
58
+ const STATUS = /\b(?:HTTP|Wix) (\d{3})\b/;
59
+ // No answer at all — what a dropped connection says on every platform.
60
+ const TRANSPORT = /NSURLErrorDomain|(?:UnknownHost|Connect|SocketTimeout|NoRouteToHost|SSLHandshake)Exception|Failed to connect|Unable to resolve host|Connection (?:refused|reset)|Network is unreachable|Software caused connection abort|ECONNRESET|ETIMEDOUT|EPIPE|Broken pipe|stream was reset|unexpected end of stream|timed out|^timeout$|Failed to fetch|NetworkError|^(?:TypeError: )?Load failed$|net::ERR_|^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i;
61
+ // Android reports an unknown host as the bare hostname (`users.wix.com`) — hence the last form.
62
+ // No session on the device: the native bridge refuses before any request leaves.
63
+ // A pinned site with no member session: iOS `siteNotPermitted(requested: "account", …)`,
64
+ // Android `site account is not the bound site …`; the web host `HTTP 501: {"error":"webwix_owner_no_session"}`.
65
+ const SIGNED_OUT = /wixApi not enabled|session unavailable|PRINCIPAL_CHANGED|not signed in|siteNotPermitted\(requested: "account"|site account is not the bound site|webwix_owner_no_session/i;
66
+
67
+ // The same classifier as `himi docs recipe-four-state`.
68
+ function classifyError(e: unknown): ErrorKind {
69
+ const msg = String((e as Error)?.message ?? e ?? "");
70
+ const code = String((e as { code?: unknown })?.code ?? "");
71
+ const status = Number(STATUS.exec(msg)?.[1] ?? 0);
72
+ if (status === 401) return "signIn";
73
+ if (status === 403) return "permission";
74
+ if (SIGNED_OUT.test(msg)) return "signIn";
75
+ if (code === "offline_write_blocked") return "offline"; // a write refused before it left
76
+ // A web call that never reached Wix arrives as
77
+ // `HTTP 502: {"error":"webwix_direct_failed","detail":"Failed to fetch"}` — judge its detail.
78
+ if (status && !msg.includes("webwix_direct_failed")) return "server";
79
+ if (TRANSPORT.test(msg)) return "offline";
80
+ return "server"; // a bug or a malformed row is not the user's connection
81
+ }
82
+
43
83
  // Blog draft bodies are rich content — wrap the plain text minimally.
44
84
  function toRichContent(text: string): WixRichContent {
45
85
  return { nodes: [{ type: "PARAGRAPH", nodes: [{ type: "TEXT", textData: { text, decorations: [] } }] }] };
46
86
  }
47
87
 
88
+ // Every key that describes the LAST attempt, not this run: a cold boot starts from the declared values.
89
+ const OUTCOME_KEYS = ["status", "error", "savedMessage", "savedScreenId", "hideSavedLink", "cannotSave"] as const;
90
+
48
91
  const svc = defineService<PostFormState, {
49
92
  submit: (s: PostFormState, values?: Record<string, unknown>) => Promise<PostFormState>;
50
93
  }>("postForm", {
51
94
  state: shape({}),
95
+ // Outcome keys are per-attempt: a cold boot must not restore a stale error, or a submit
96
+ // disabled by a 403 the user has since been granted past.
97
+ notHydrated: OUTCOME_KEYS,
52
98
  actions: {
53
99
  // `values` is the ONLY channel for what the user typed — keyed by FormField id.
54
100
  // Coerce defensively: everything but toggles arrives as a string-ish unknown.
55
101
  submit: async (s, values) => {
56
102
  const title = String(values?.title ?? "").trim();
57
103
  const body = String(values?.body ?? "").trim();
58
- if (!title) return shape({ ...s, status: "error", error: "Title is required" });
104
+ if (!title) return shape({ ...s, status: "error", error: "Title is required", savedMessage: "" });
59
105
  try {
60
106
  const r = await createDraftPost({
61
107
  title,
@@ -69,10 +115,22 @@ const svc = defineService<PostFormState, {
69
115
  savedScreenId: id ? `my_post_${id}` : "", // wire a templated detail route per the tier-5 guide
70
116
  });
71
117
  } catch (e) {
72
- return shape({ ...s, title, body, status: "error", error: String((e as Error)?.message ?? e) });
118
+ const kind = classifyError(e);
119
+ // Echo what the user typed back into the prefill, so a failed save loses nothing.
120
+ // Clear the last success line: a failed attempt must not also read "Draft created.".
121
+ return shape({ ...s, title, body, status: "error", error: SAVE_FAILED[kind], savedMessage: "", cannotSave: kind === "permission" });
73
122
  }
74
123
  },
75
124
  },
76
125
  });
77
126
 
127
+ // An older runtime ignores `notHydrated` (it does not set `himalaya.hydrationOptOut`) and restores the
128
+ // whole snapshot: reset the outcome keys in place before any action runs, or a 403's disabled submit
129
+ // would come back on every launch with no way to clear it.
130
+ if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut !== true) {
131
+ const booted = (svc as unknown as { getState?: () => Record<string, unknown> | undefined }).getState?.();
132
+ const fresh = shape({}) as unknown as Record<string, unknown>;
133
+ if (booted && typeof booted === "object") for (const k of OUTCOME_KEYS) booted[k] = fresh[k];
134
+ }
135
+
78
136
  export { svc };
@@ -323,7 +323,8 @@ pull-to-refresh + load-more), a create/edit form `examples/form-submit.*`
323
323
  (`himi docs recipe-form-submit`), a searchable list `examples/search-list.*`
324
324
  (`himi docs recipe-search`), a first-run onboarding card `examples/onboarding.*`
325
325
  (`himi docs recipe-onboarding`), and the loading/empty/error/ready idiom they all share
326
- (`himi docs recipe-four-state`). Then — **before you
326
+ (`himi docs recipe-four-state` — every recipe that loads or writes data shows a loading state,
327
+ a classified error state and a recovery action where the user can fix the failure). Then — **before you
327
328
  design** — read the UX guide (`himi docs designing-himi-screens`) and, for a multi-screen app,
328
329
  the mobile contract guide (`himi docs mobile-ux`), then follow
329
330
  *Author a Tier-5 screen*.
@@ -346,7 +346,8 @@ pull-to-refresh + load-more), a create/edit form `examples/form-submit.*`
346
346
  (`himi docs recipe-form-submit`), a searchable list `examples/search-list.*`
347
347
  (`himi docs recipe-search`), a first-run onboarding card `examples/onboarding.*`
348
348
  (`himi docs recipe-onboarding`), and the loading/empty/error/ready idiom they all share
349
- (`himi docs recipe-four-state`). Then — **before you
349
+ (`himi docs recipe-four-state` — every recipe that loads or writes data shows a loading state,
350
+ a classified error state and a recovery action where the user can fix the failure). Then — **before you
350
351
  design** — read the UX guide (`himi docs designing-himi-screens`) and, for a multi-screen app,
351
352
  the mobile contract guide (`himi docs mobile-ux`), then follow
352
353
  *Author a Tier-5 screen*.
package/guides/index.json CHANGED
@@ -25,6 +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
29
  "Workers are sandboxed: no DOM, no timers, no raw fetch. Produce state synchronously in actions; do not rely on an ambient refresh tick.",
29
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.",
30
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.)",
@@ -1026,6 +1026,81 @@ that block off the first Android `himi dist` run to settle the question with a m
1026
1026
  than an assumption, the way the iOS identities were settled on 2026-07-27 after two wrong
1027
1027
  diagnoses.
1028
1028
 
1029
+ ### An Android App Bundle for Google Play — `--aab`
1030
+
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:
1034
+
1035
+ ```sh
1036
+ himi login --dpx --target bo # an INTERNAL caller: self-serve callers get 403 (see below)
1037
+ himi dist --app <app> --platform android --aab --wait --target bo
1038
+ ```
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"`.
1044
+ - **Android only.** The default stays an APK, and iOS is untouched. It travels as
1045
+ `androidFormat: "aab"` on `POST /v1/admin/dist` (every `/v1/owner/dist` caller is self-serve, so
1046
+ that route always answers `403 android_format_not_allowed`), and reaches the pool as
1047
+ `DIST_ANDROID_FORMAT=aab` in the `command` blob (see
1048
+ [why](#why-the-jobs-parameters-ride-in-command-not-envvars)). Refusals, all typed 400s before
1049
+ any pool time is spent: `invalid_android_format` (anything but `apk` / `aab`),
1050
+ `android_format_requires_link` (any destination but `link`), `android_format_requires_android`
1051
+ (an iOS-only build, where the flag would be dropped in silence), and
1052
+ `android_format_requires_wix_keystore` (any `--android-signing` other than `wix-keystore`), and
1053
+ `android_format_shell_unsupported` (the `owner-lite` shell, whose gradle build refuses a store
1054
+ bundle without a real Firebase config and customer identity). A mixed `--platform ios,android`
1055
+ applies it to the Android leg only.
1056
+ - **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`,
1059
+ alias `WixOne`), resolved by `resolveAndroidSigner` and verified there. The pool refuses any
1060
+ 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
+ `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
1064
+ (`DIST_ANDROID_TASK` stays the APK one), and `DIST_ANDROID_FLAVOR` picks among flavored bundles.
1065
+ 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
1069
+ time, and the injected config overrides any `signingConfig`); the 16 KB check (its ZIP half is about an installed APK,
1070
+ which Play generates; its ELF half reads the APK `lib/` layout — Play checks alignment on
1071
+ upload); and `--reuse-artifact` (that is an APK).
1072
+ - **Version skew.** The pool runs the release's own `run-dist`, and one older than `--aab` ignores
1073
+ `DIST_ANDROID_FORMAT` and builds an APK. So the pool reports what it built (`androidFormat` on
1074
+ the result callback, the `androidSigningUsed` pattern), and serve-control turns a `SUCCESS` that
1075
+ does not say `aab` into **`FAILURE`** with `android_format_not_built: …`, recording
1076
+ `androidFormat: "apk"`, and drops the artifact link. The verdict is read from that error on every
1077
+ read, so a status refresh that races the callback cannot bring back `SUCCESS`. **An old runner
1078
+ never signs with the Wix key:** for a bundle the plane dispatches `DIST_ANDROID_SIGNING=qa-keystore`,
1079
+ and the new run-dist overrides it with `wix-keystore`, derived from `DIST_ANDROID_FORMAT=aab`. A
1080
+ run-dist older than `--aab` therefore signs an ordinary QA APK, never an installable APK carrying
1081
+ the Wix production certificate. The QA key is named rather than left out, because a named key
1082
+ outranks a pool machine's `DIST_ANDROID_USE_WIX_KEYSTORE=1`. A bundle must also report `androidSigningUsed: "wix-keystore"`; one signed
1083
+ with any other key, or that reports none, fails as `android_format_wrong_signer` with its link
1084
+ dropped. Rebuild from a release published on a newer commit. The other direction fails too: an
1085
+ APK job whose pool reports a bundle is `android_format_not_requested`, with its link dropped. A
1086
+ brokered job reads the format from its OWN parameters only, so a machine with
1087
+ `DIST_ANDROID_FORMAT=aab` in its environment no longer turns an APK job into a bundle; the check
1088
+ stays for a runner older than that rule. A later delivery that passes every check (a retry on a
1089
+ clean machine) clears the verdict.
1090
+
1091
+ **Why the Wix production key, and only for a bundle.** There are two Android keys: the committed
1092
+ QA keystore and the Wix production key (WixOne). WixOne is the Play **upload** key every Branded
1093
+ app's bundle carries (`mobile-apps-engine` refuses any other certificate for a Play build), with
1094
+ Play App Signing on, so Google re-signs what users install. An `.aab` therefore signs with it —
1095
+ only its `WixOne` entry of `oneapp-production.keystore`: the alias is pinned for a bundle, whatever
1096
+ 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
+ gets no bundle at all (`403 android_format_not_allowed`): `--aab` is for internal callers until a
1098
+ 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
1100
+ updates come from Wix builds too; leaving Wix needs an upload-key reset through Play Console
1101
+ support. A pool machine without the key fails the build, naming the paths it searched, rather than
1102
+ signing with another key.
1103
+
1029
1104
  ### The iOS lane lives in one shared place
1030
1105
 
1031
1106
  `fastlane dist` runs with `cwd = <app>/ios`, so it needs `<app>/ios/fastlane/Fastfile`. That file is
@@ -1118,6 +1193,9 @@ baking the live asset.
1118
1193
  or `DIST_ANDROID_USE_WIX_KEYSTORE=1` names one, and a named key wins — `apksigner` replaces the
1119
1194
  signature block — because the job record is written at dispatch and would otherwise name a key
1120
1195
  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
1198
+ [the App Bundle section](#an-android-app-bundle-for-google-play----aab).
1121
1199
  - **How Android picks up the branding.** `prepare:customer` writes an **xcconfig** for iOS (bundle
1122
1200
  id + display name) but on Android only tokens, the icon drawable and a colour XML. Android's
1123
1201
  `applicationId` and app label come from the **contract JSON itself**, read at gradle-configure
@@ -60,9 +60,12 @@ interface Row { // FLAT strings/booleans only — see "Normalize" b
60
60
  interface State {
61
61
  status: "loading" | "ready" | "error";
62
62
  items: Row[];
63
- loadingText: string; // "Loading…" while loading, "" after → collapseWhenEmpty
64
- emptyText: string; // "No products yet." when ready+empty, "" otherwise
65
- error: string; // message when failed, "" otherwise
63
+ errorKind: "" | "offline" | "server" | "signIn" | "permission"; // classifyError(e), never the raw message
64
+ // hide* flags for hiddenPath, derived in shape() — see `himi docs recipe-four-state`
65
+ hideLoading: boolean; notEmpty: boolean;
66
+ hideOfflineError: boolean; hideServerError: boolean;
67
+ hideSignInError: boolean; hidePermissionError: boolean; hideRefreshError: boolean;
68
+ nextCursor: string; failedCursor: string; hideLoadMoreError: boolean; // §5 load-more
66
69
  }
67
70
  ```
68
71
 
@@ -143,25 +146,46 @@ nesting to one level: a carousel of carousels is legal and unreadable.
143
146
  - A binding substitutes only when it is the **entire prop value** — never mid-string
144
147
  (`"Total: $item.price"` renders the raw token). Pre-compose strings in the worker.
145
148
 
146
- ## 4. Four-state, the corpus way
147
-
148
- Loading / empty / error live in **worker-computed display strings** bound to `Text`s
149
- with `collapseWhenEmpty: true` — each renders only in its state, no conditional
150
- layout needed:
149
+ ## 4. Loading, error and retry
150
+
151
+ A list loads data, so it ships all four states — the full idiom is
152
+ `himi docs recipe-four-state`. For a list it comes down to this:
153
+
154
+ - **Loading** — a `Skeleton` shaped like the rows (`height` of one row, `count`
155
+ about one screen of them), gated by `hiddenPath: "$service:products.hideLoading"`.
156
+ It shows only while the first load runs with nothing on screen; a pull-to-refresh
157
+ keeps the rows and uses the list's own spinner.
158
+ - **Empty** — an `EmptyState` with a directive action, gated by `notEmpty`.
159
+ - **Error, nothing on screen** — one `EmptyState` per class `classifyError` can
160
+ return (offline, server, sign-in, permission), **no icon**, user-facing copy.
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
163
+ `"signIn"` to `"permission"` — a visitor still gets a 401 or 403 (Bookings
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)`,
166
+ which shows the user a wire error.
167
+ - **Error, rows on screen** — a pull-to-refresh that fails offline or on the
168
+ server keeps the rows (`shape({ ...s, status: "error", errorKind })`, `items`
169
+ 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
+ A failed **load-more** has its own retry (§5).
151
172
 
152
173
  ```jsonc
153
- { "id": "loading", "body": { "package": "himalaya", "type": "Text",
154
- "props": { "text": "$service:products.loadingText", "style": "body",
155
- "color": "textSecondary", "collapseWhenEmpty": true } } },
156
- { "id": "empty", "body": { "…text": "$service:products.emptyText", "…": "collapseWhenEmpty" } },
157
- { "id": "error", "body": { "…text": "$service:products.error", "…": "collapseWhenEmpty" } }
174
+ { "id": "loading",
175
+ "body": { "package": "himalaya", "type": "Skeleton",
176
+ "props": { "height": 72, "cornerRadius": 12, "count": 6 } },
177
+ "modifiers": { "hiddenPath": "$service:products.hideLoading" } },
178
+ { "id": "error.server",
179
+ "body": { "package": "himalaya", "type": "EmptyState",
180
+ "props": { "title": "Unable to load products",
181
+ "subtitle": "There was a technical issue on our end. Please wait and try again later.",
182
+ "action": { "actionId": "tier5.products.retry" }, "actionLabel": "Try again" } },
183
+ "modifiers": { "hiddenPath": "$service:products.hideServerError" } }
158
184
  ```
159
185
 
160
- Pair the error text with a `Button` → `tier5.products.retry`. (Richer screens gate
161
- whole sections with the `hiddenWhen` modifier bound to worker booleans — see
162
- `apps/himi-owner-lite`; start with `collapseWhenEmpty`, it covers most lists.)
163
- Design guidance for these states: `himi docs designing-himi-screens` §5, and the
164
- full state-machine idiom: `himi docs recipe-four-state`.
186
+ (Richer screens gate whole sections with the same `hiddenPath`/`hiddenWhen`
187
+ modifiers — see `apps/himi-owner-lite`.) Design guidance for these states:
188
+ `himi docs designing-himi-screens` §5 ("Loading states", "Error states").
165
189
 
166
190
  ## 5. Refresh + load-more gestures (RFC 0008)
167
191
 
@@ -193,6 +217,16 @@ native decoders drop anything else without a word:
193
217
  stashed cursor, **append de-duplicated** rows (`seen` by id), roll `nextCursor`
194
218
  from the response (`pagingMetadata.cursors.next ?? ""`), and on failure set
195
219
  `nextCursor: ""` but **keep the rows** — a broken page-2 must not blank page-1.
220
+ Stash the cursor that failed (`failedCursor`) and show a small "Couldn't load
221
+ more" row with **Try again** at the end of the list (gated by
222
+ `hideLoadMoreError`), whose action restores `nextCursor = failedCursor` and runs
223
+ `loadMore` again. Do not route it through the refresh `Banner`: that **Try
224
+ 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
+ `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
229
+ page 1 and offer the cursor retry only for `offline` and `server`.
196
230
  - APIs that don't page (or v1 fallbacks without cursors) simply never set
197
231
  `nextCursor` — the gesture props are safe to leave in place.
198
232
 
@@ -245,10 +279,11 @@ Mechanics and the prod caveat in full: `himi docs tier-5-add-a-screen`.
245
279
  no longer needed; prefer the component, which stays accessible and themable.
246
280
  - **Declare `wix_api`** in `logicBundle.declaredCapabilities` if the worker touches
247
281
  the Wix transport — validate warns (errors pre-push), and the native gate enforces it.
248
- - **`himi render` shows initial state; `himi render --live` shows REAL data.** Plain
249
- `himi render` boots the worker so `$service:` bindings resolve — but to the *initial*
250
- state (your loading frame), because it does not await `load()`: expect `loadingText`,
251
- not rows. To prove real-data binding **locally, with no VPN and no push**, add `--live`:
282
+ - **`himi render --no-mocks` shows the loading frame; `himi render --live` shows REAL
283
+ data.** `himi render` boots the worker so `$service:` bindings resolve. When the app
284
+ has recorded data (`dev/net-mocks.json`, CMS seed rows) it runs `load()` against that
285
+ data by default and shows rows; with none, or with `--no-mocks`, it shows the
286
+ *initial* state — your loading skeleton, not rows. To prove real-data binding **locally, with no VPN and no push**, add `--live`:
252
287
  it mints an anonymous visitor token from the app's `clientId` and **awaits the worker's
253
288
  real fetches** against live Wix, so the render shows actual rows and catches live-only
254
289
  binding bugs (a mistyped `$item.<field>` an empty initial array can't disprove). Use
@@ -35,6 +35,7 @@ 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",
38
39
  "action": { "actionId": "tier5.postForm.submit" } } } }
39
40
  }
40
41
  }
@@ -91,12 +92,16 @@ interface FormState {
91
92
  status: "idle" | "saving" | "saved" | "error";
92
93
  error: string; // "" unless failed → collapseWhenEmpty
93
94
  savedMessage: string; // "" unless saved → collapseWhenEmpty
95
+ cannotSave: boolean; // true after a permission failure → disabledPath
94
96
  }
95
97
 
96
98
  const svc = defineService<FormState, {
97
99
  submit: (s: FormState, values?: Record<string, unknown>) => Promise<FormState>;
98
100
  }>("postForm", {
99
- state: { title: "", body: "", status: "idle", error: "", savedMessage: "" },
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"],
100
105
  actions: {
101
106
  submit: async (s, values) => {
102
107
  const title = String(values?.title ?? "").trim();
@@ -106,17 +111,38 @@ const svc = defineService<FormState, {
106
111
  await createDraftPost({ title, richContent: /* … */ } as WixDraftPostWrite);
107
112
  return { ...s, title, body, status: "saved", error: "", savedMessage: "Draft created." };
108
113
  } catch (e) {
109
- return { ...s, status: "error", error: String((e as Error)?.message ?? e), savedMessage: "" };
114
+ // Keep what the user typed (echo it back into the prefill), and say what
115
+ // 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
110
119
  }
111
120
  },
112
121
  },
113
122
  });
114
123
  export { svc };
124
+
125
+ // One sentence per class of `classifyError` (himi docs recipe-four-state).
126
+ const SAVE_FAILED: Record<string, string> = {
127
+ offline: "You're offline, so nothing was saved. Try again when you reconnect.",
128
+ server: "Couldn't save your changes. Try again.",
129
+ signIn: "Your session has ended. Sign in, then save again.",
130
+ permission: "You don't have permission to save this.",
131
+ };
115
132
  ```
116
133
 
117
- - **Validate in the worker**, not the descriptor: required/format checks run in
118
- `submit`, and a failure is just `status: "error"` + a message — same rendering
119
- path as a network failure.
134
+ - **Field rules stay on the fields.** Required and format checks belong in the
135
+ `validation` block above: the renderer holds the submit and shows each message
136
+ beside its field. The worker's `if (!title)` is only a guard; a cross-field or
137
+ server answer about ONE field goes to that field through `text.errorPath`.
138
+ - **A failed save is an action error.** It shows near the submit button (the
139
+ `err` line below), in user-facing words picked by class, and it **keeps what the
140
+ user typed** — the catch returns `title`/`body` into the prefill so nothing is
141
+ lost. The submit button is the retry: there is no separate **Try again**.
142
+ 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.
120
146
  - The write goes through a `@himalaya/wix-<domain>` client (declare `wix_api`!).
121
147
  **Wrap the entity envelope the API expects** (`{ draftPost: {...} }` handled by
122
148
  the SDK; raw `wixApi.call` writes must wrap it themselves) — an unwrapped body
@@ -125,7 +151,9 @@ export { svc };
125
151
 
126
152
  ## 3. Feedback: outcome strings + a gated follow-up action
127
153
 
128
- Above (or below) the form, the standard state row — each renders only in its state:
154
+ Directly below the `Form` — its submit button is the form's last element — the
155
+ 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`:
129
157
 
130
158
  ```jsonc
131
159
  { "id": "err", "…Text": { "text": "$service:postForm.error", "color": "danger",
@@ -163,12 +191,16 @@ shape (heading swap, `savedScreenId`, back-navigation).
163
191
  "$service:<svc>.busy"` — `disabledPath` wants a bool, not the `status` string.
164
192
  It only ever ADDS a reason to be disabled: a bound `false` never re-enables a
165
193
  submit the form's own validation is holding.
194
+ - **An edit form loads data first.** Its `load` is a read like any other: a
195
+ `Skeleton` shaped like the fields while it runs, and a classified error state
196
+ with **Try again** if it fails (`himi docs recipe-four-state`). A create form
197
+ with nothing to prefill renders at once and needs no loading state.
166
198
  - **`himi render` shows the initial (empty) form** — prefill bindings resolve to
167
199
  initial state; a real submit needs the pushed preview or a device drive.
168
200
 
169
201
  ## See also
170
202
 
171
- - `himi docs recipe-four-state` — the state-machine idiom this recipe leans on.
203
+ - `himi docs recipe-four-state` — the loading, classified error and retry idiom this recipe leans on.
172
204
  - `himi docs recipe-dynamic-list` — the list the created entity lands in.
173
205
  - `himi components Form` / `FormField` / `TextField` / `Toggle` / `Picker`.
174
206
  - `.agents/skills/himalaya/examples/form-submit.*` (written into the app by `himi init` / `himi agent-init`) — this recipe, runnable.