@wix/himalaya-cli 0.1465.0 → 0.1466.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.
@@ -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.
@@ -1,52 +1,117 @@
1
1
  # Recipe: four screen states (loading / empty / error / ready)
2
2
 
3
+ **The rule every recipe follows:** a screen that loads or writes data shows a
4
+ **loading state**, a **classified error state**, and a **recovery action where the
5
+ user can fix the failure** — retry for an offline or a server failure, and for a failed
6
+ write; Sign in when the user is signed out; nothing for a permission failure, an empty result
7
+ or a field error, where the same request gives the same answer. This recipe is the shared
8
+ idiom; the other recipes link here for it. Design guidance for the states:
9
+ `himi docs designing-himi-screens` §5 ("Loading states", "Error states").
10
+
3
11
  Every data-backed screen has four states, and Himi's idiom renders all of them
4
- from **one flat layout** — no conditional trees, no separate loading screen. The
5
- worker computes a **display string per state** (non-empty only in its state) and
6
- the descriptor binds each to a `Text` with `collapseWhenEmpty: true`. Sections
7
- that must fully appear/disappear gate with the `hiddenWhen`/`hiddenPath` modifier
8
- bound to a worker boolean. The empty state is always an `EmptyState` node, gated
9
- the same way.
12
+ from **one flat layout** — no conditional trees, no separate loading screen. Each
13
+ state is its own node, gated with the `hiddenPath` modifier bound to a worker
14
+ boolean, and the worker derives every flag from one `status` field. The empty state
15
+ and each error class are `EmptyState` nodes, gated the same way.
10
16
 
11
17
  This is the corpus norm: of the in-repo worker bundles, ~3 in 4 model loading,
12
18
  more than half model error, and the reference apps (`apps/himi-owner-lite`,
13
- `apps/demo-app`, and Himi Blog) all ship this exact shape. The
19
+ `apps/demo-app`, and Himi Blog) all ship this shape. The
14
20
  `examples/dynamic-list.*` and `examples/form-submit.*` pairs both demonstrate it.
15
21
 
16
- ## 1. One status field, derived display strings
22
+ ## 1. One status field, a classified error, derived flags
17
23
 
18
24
  ```ts
25
+ type ErrorKind = "" | "offline" | "server" | "signIn" | "permission";
26
+
19
27
  interface State {
20
28
  status: "loading" | "ready" | "error";
21
29
  items: Row[];
22
- loadingText: string; // "Loading…" only while loading
23
- error: string; // the message only when failed
24
- notEmpty: boolean; // gates the EmptyState (hiddenPath hides when TRUE)
25
- notErrored: boolean; // gates the Retry button (hiddenPath hides when TRUE)
30
+ errorKind: ErrorKind; // why the last load failed; "" unless status is "error"
31
+ // hiddenPath hides a node when its flag is TRUE — name flags in the hide direction.
32
+ hideLoading: boolean; // the skeleton: shown only while loading with nothing on screen
33
+ notEmpty: boolean; // the EmptyState: shown only when ready with zero rows
34
+ hideOfflineError: boolean; // one EmptyState per error class …
35
+ hideServerError: boolean;
36
+ hideSignInError: boolean;
37
+ hidePermissionError: boolean;
38
+ hideRefreshError: boolean; // … and a Banner when a refresh fails over rows we kept
26
39
  }
27
40
 
28
41
  function shape(s: Partial<State>): State {
29
42
  const status = s.status ?? "ready";
30
43
  const items = s.items ?? [];
44
+ const errorKind: ErrorKind = status === "error" ? (s.errorKind || "server") : "";
45
+ const blank = items.length === 0; // nothing on screen to keep
31
46
  return {
32
- status, items,
33
- loadingText: status === "loading" ? "Loading products…" : "",
34
- error: s.error ?? "",
35
- notEmpty: !(status === "ready" && items.length === 0),
36
- notErrored: status !== "error",
47
+ status, items, errorKind,
48
+ hideLoading: !(status === "loading" && blank),
49
+ notEmpty: !(status === "ready" && blank),
50
+ hideOfflineError: !(blank && errorKind === "offline"),
51
+ hideServerError: !(blank && errorKind === "server"),
52
+ hideSignInError: !(blank && errorKind === "signIn"),
53
+ hidePermissionError: !(blank && errorKind === "permission"),
54
+ // Only a failure a retry can fix gets the banner over kept rows.
55
+ hideRefreshError: !(!blank && (errorKind === "offline" || errorKind === "server")),
37
56
  };
38
57
  }
58
+
59
+ // The HTTP status rides in the message: `HTTP 503: …` on a device, `Wix 503 for …`
60
+ // under `himi test` / `himi render`.
61
+ const STATUS = /\b(?:HTTP|Wix) (\d{3})\b/;
62
+ // No answer at all — what a dropped connection says on every platform.
63
+ 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;
64
+ // Android reports an unknown host as the bare hostname (`users.wix.com`) — hence the last form.
65
+ // No session on the device: the native bridge refuses before any request leaves.
66
+ // A pinned site with no member session: iOS `siteNotPermitted(requested: "account", …)`,
67
+ // Android `site account is not the bound site …`.
68
+ const SIGNED_OUT = /wixApi not enabled|session unavailable|PRINCIPAL_CHANGED|not signed in|siteNotPermitted\(requested: "account"|site account is not the bound site/i;
69
+
70
+ function classifyError(e: unknown): ErrorKind {
71
+ const msg = String((e as Error)?.message ?? e ?? "");
72
+ const code = String((e as { code?: unknown })?.code ?? "");
73
+ const status = Number(STATUS.exec(msg)?.[1] ?? 0);
74
+ if (status === 401) return "signIn"; // signed out (or the session expired)
75
+ if (status === 403) return "permission"; // signed in, without access
76
+ if (SIGNED_OUT.test(msg)) return "signIn";
77
+ if (code === "offline_write_blocked") return "offline"; // a write refused before it left
78
+ // A web call that never reached Wix arrives as
79
+ // `HTTP 502: {"error":"webwix_direct_failed","detail":"Failed to fetch"}` — judge its detail.
80
+ if (status && !msg.includes("webwix_direct_failed")) return "server";
81
+ if (TRANSPORT.test(msg)) return "offline";
82
+ return "server"; // anything else — a bug, a malformed row — is not the user's connection
83
+ }
84
+
85
+ async function load(s: State): Promise<State> {
86
+ try {
87
+ const items = (await fetchRows()).map(normalize);
88
+ return shape({ status: "ready", items });
89
+ } catch (e) {
90
+ const errorKind = classifyError(e);
91
+ // Keep the rows: a refresh that fails must not blank a screen that had content —
92
+ // unless access is gone, when the rows must not stay on screen either.
93
+ const lostAccess = errorKind === "signIn" || errorKind === "permission";
94
+ return shape({ ...s, items: lostAccess ? [] : s.items, status: "error", errorKind });
95
+ }
96
+ }
39
97
  ```
40
98
 
41
- Route **every** state change through `shape()` — set `status` + raw data, let it
42
- recompute the strings and flags. Hand-maintaining them per action is how screens
43
- end up loading-and-errored at once.
99
+ Route **every** state change through `shape()` — set `status`, `errorKind` and the
100
+ raw data, and let it recompute the flags. Hand-maintaining them per action is how
101
+ screens end up loading-and-errored at once.
102
+
103
+ **Never show the raw error.** `e.message` is for your logs; the user sees the copy
104
+ of the class it falls in. The classifier above is deliberately small: add a class
105
+ only when the screen shows the user something different for it.
44
106
 
45
- ## 2. The descriptor: one node per state + a gated Retry
107
+ ## 2. The descriptor: one node per state
46
108
 
47
109
  ```jsonc
48
- { "id": "loading", "…Text": { "text": "$service:products.loadingText",
49
- "color": "textSecondary", "collapseWhenEmpty": true } },
110
+ { "id": "loading",
111
+ "body": { "package": "himalaya", "type": "Skeleton",
112
+ "props": { "height": 72, "cornerRadius": 12, "count": 4 } }, // shaped like the rows
113
+ "modifiers": { "hiddenPath": "$service:products.hideLoading" } },
114
+
50
115
  { "id": "empty",
51
116
  "body": { "package": "himalaya", "type": "EmptyState",
52
117
  "props": { "icon": "bag", "title": "No products in this store yet",
@@ -54,62 +119,121 @@ end up loading-and-errored at once.
54
119
  "action": { "actionId": "tier5.products.create" },
55
120
  "actionLabel": "Add your first product" } },
56
121
  "modifiers": { "hiddenPath": "$service:products.notEmpty" } },
57
- { "id": "error", "…Text": { "text": "$service:products.error",
58
- "color": "danger", "collapseWhenEmpty": true } },
59
- { "id": "retry",
60
- "body": { "package": "himalaya", "type": "Button",
61
- "props": { "title": "Retry", "style": "secondary", "size": "sm",
62
- "action": { "actionId": "tier5.products.retry" } } },
63
- "modifiers": { "hiddenPath": "$service:products.notErrored" } }
122
+
123
+ // Errors: an EmptyState per class, with NO icon, user-facing copy, and an action
124
+ // only when the user can recover.
125
+ { "id": "error.offline",
126
+ "body": { "package": "himalaya", "type": "EmptyState",
127
+ "props": { "title": "Reconnect to the internet",
128
+ "subtitle": "Connect to the internet, then try again.",
129
+ "action": { "actionId": "tier5.products.retry" },
130
+ "actionLabel": "Try again" } },
131
+ "modifiers": { "hiddenPath": "$service:products.hideOfflineError" } },
132
+ { "id": "error.server",
133
+ "body": { "package": "himalaya", "type": "EmptyState",
134
+ "props": { "title": "Unable to load products",
135
+ "subtitle": "There was a technical issue on our end. Please wait and try again later.",
136
+ "action": { "actionId": "tier5.products.retry" },
137
+ "actionLabel": "Try again" } },
138
+ "modifiers": { "hiddenPath": "$service:products.hideServerError" } },
139
+ { "id": "error.signIn",
140
+ "body": { "package": "himalaya", "type": "EmptyState",
141
+ "props": { "title": "Sign in to view products",
142
+ "subtitle": "Your session has ended.",
143
+ "action": { "actionId": "tier5.products.signIn" }, // a worker action that dispatches himi.wix.signInDirect
144
+ "actionLabel": "Sign in" } },
145
+ "modifiers": { "hiddenPath": "$service:products.hideSignInError" } },
146
+ { "id": "error.permission", // no action: retrying gives the same answer
147
+ "body": { "package": "himalaya", "type": "EmptyState",
148
+ "props": { "title": "You need permission to view this screen",
149
+ "subtitle": "Ask the owner of this business for access." } },
150
+ "modifiers": { "hiddenPath": "$service:products.hidePermissionError" } },
151
+
152
+ // A refresh that failed over rows we kept: say so above the rows, keep the rows.
153
+ { "id": "error.refresh",
154
+ "body": { "package": "himalaya", "type": "Banner",
155
+ "props": { "severity": "warning", "title": "Couldn't refresh",
156
+ "message": "Showing what was loaded before.",
157
+ "action": { "actionId": "tier5.products.retry" },
158
+ "actionLabel": "Try again" } },
159
+ "modifiers": { "hiddenPath": "$service:products.hideRefreshError" } }
64
160
  ```
65
161
 
66
- - `collapseWhenEmpty` on a `Text` renders **nothing at all** (no blank line) when
67
- the bound string is `""` — the whole state machine is "which string is non-empty".
68
162
  - `hiddenPath` / `hiddenWhen` hide a node when the bound boolean is **true** —
69
- name worker booleans in the *hide* direction (`notErrored`, `hideSavedLink`) so
163
+ name worker booleans in the *hide* direction (`notEmpty`, `hideServerError`) so
70
164
  the binding reads without negation.
71
165
  - Ready state is simply the data itself (the list rows, the detail fields) — it
72
166
  needs no dedicated marker.
73
- - **Use `EmptyState` for every empty state.** It draws one standard layout on
74
- every platform: icon, title, subtitle and button, with type and gaps from the
75
- app's tokens. Only `title` is required. `actionLabel` is required when you set
76
- `action`: there is no default label, and `himi validate` reports a missing one.
167
+ - **Sign in is a worker action.** A renderer cannot dispatch `himi.wix.*`, so the
168
+ sign-in `EmptyState` calls `tier5.products.signIn`, which the worker defines:
169
+ it dispatches `himi.wix.signInDirect` and then reloads
170
+ (`himi docs wix-login-integration`).
171
+ - **Loading is a `Skeleton` shaped like the loaded content** when you know the
172
+ layout — the same rows at about the same size. `StatefulSection` or
173
+ `ActivityIndicator` are fine where they fit better. A plain `"Loading…"` `Text`
174
+ bound to a worker string (`collapseWhenEmpty: true`) is the minimum, not the goal.
175
+ - **Use `EmptyState` for every empty state and every screen-level error.** It
176
+ draws one standard layout on every platform with the app's tokens. Only `title`
177
+ is required. `actionLabel` is required when you set `action`: there is no
178
+ default label, and `himi validate` reports a missing one. For an error, leave
179
+ `icon` out.
77
180
  - Write the `EmptyState` title and subtitle as literal copy or `$t:` keys. Today,
78
- iOS and Android do not resolve a `$service:` binding in those props, so gate the
79
- node with `hiddenPath` instead of binding its text.
181
+ iOS and Android do not resolve a `$service:` binding in those props — which is
182
+ why each error class is its own node instead of one node with bound text.
183
+ - **A node for every class `classifyError` can return** — otherwise that failure
184
+ paints a blank screen. A screen with no sign-in flow still gets a 401 when a
185
+ session ends; map `"signIn"` to `"permission"` in its classifier rather than drop
186
+ the node.
187
+ - **When a planned `ErrorState` component ships,** swap the error `EmptyState`
188
+ nodes for it. The classes, the copy and the recovery rules stay as they are.
80
189
 
81
190
  ## 3. Which state when
82
191
 
83
- | State | Set where | User affordance |
84
- |---------|--------------------------------------|------------------------------------|
85
- | loading | initial state + start of `load` | skeleton/spinner text; NO retry |
86
- | error | `catch` in any fetch/write | message + **Retry** (re-runs load) |
87
- | empty | derived: ready + 0 rows | say what *would* fill it, and how |
88
- | ready | successful fetch | the content |
192
+ | State | Set where | User affordance |
193
+ |---|---|---|
194
+ | loading | initial state (`status: "loading"`) | content-shaped skeleton; NO retry |
195
+ | error, nothing on screen | `catch` in the first load | the class's `EmptyState`: user-facing copy + a recovery action where one exists |
196
+ | error, rows on screen | `catch` in a refresh (offline or server) | rows stay; a `Banner` with **Try again** above them |
197
+ | sign-in or permission | `catch` in any load | rows go; the class's `EmptyState` (Sign in, or no action) |
198
+ | empty | derived: ready + 0 rows | say what *would* fill it, and how |
199
+ | ready | successful fetch | the content |
89
200
 
90
201
  - **Transient write feedback** (saved/deleted/published toasts) is the same idiom
91
202
  one more time: an `actionMessage`/`savedMessage` string, non-empty only right
92
- after the write (see `himi docs recipe-form-submit`).
203
+ after the write (see `himi docs recipe-form-submit`). A **failed write** keeps
204
+ what the user entered and shows its error next to the submit button, which is
205
+ the retry.
93
206
  - **Empty ≠ error.** A 200 with zero rows is empty; only a failed call is error.
94
207
  Write empty copy that names the next step ("Add your first product") — see
95
208
  `himi docs designing-himi-screens` §5 for the design guidance.
96
- - **Retry re-runs `load()`**, resetting to `loading` first, so a stuck error can't
97
- double-render with stale rows.
209
+ - **Retry re-runs `load()`.** An action returns one state, so the error stays up
210
+ until the new result replaces it — rows, empty, or a fresh error. Do not clear
211
+ the rows to "show progress"; that is how a second failure blanks the screen.
98
212
 
99
213
  ## Gotchas
100
214
 
101
215
  - **Initialize `status: "loading"` and kick `svc.load()` at the bottom of the
102
216
  worker file** — a screen that boots `ready`+empty flashes the empty state before
103
217
  data arrives.
104
- - **Never leave a display string `undefined`** — `collapseWhenEmpty` collapses
105
- `""`; an undefined binding renders the literal path. `shape()` prevents this.
106
- - **Keep failed-load rows.** On a *refresh* failure, prefer keeping the old rows +
107
- showing the error over blanking to a dead screen (set `error`, keep `items`).
108
- - **`himi render` shows the initial frame** — expect the loading state, not rows;
109
- all four bindings are still structurally checkable there.
218
+ - **Keep failed-load rows.** On a *refresh* failure, keep the old rows and show
219
+ the `Banner` — never blank to a dead screen (set `status` and `errorKind`, keep
220
+ `items`). `load-more` is the same: a broken page 2 must not blank page 1 — and
221
+ its retry is its own (see `himi docs recipe-dynamic-list`), because the refresh
222
+ banner's **Try again** reloads page 1.
223
+ - **Never leave a bound display string `undefined`** — `collapseWhenEmpty`
224
+ collapses `""`; an undefined binding renders the literal path. `shape()` prevents
225
+ this.
226
+ - **`himi render` shows the loaded screen when the app has recorded data**
227
+ (`dev/net-mocks.json`, CMS seed rows). `himi render <screenId> --no-mocks` shows
228
+ the initial frame — the loading state — and
229
+ `--state '{"products":{"status":"error","errorKind":"offline","hideOfflineError":false,…}}'`
230
+ forces an error class without the network. A `dev/net-mocks.json` rule that
231
+ answers 401, 403 or 500 previews the sign-in, permission and server classes
232
+ through the real `classifyError`.
110
233
 
111
234
  ## See also
112
235
 
113
236
  - `himi docs recipe-dynamic-list` — the list recipe this state machine wraps.
114
237
  - `himi docs recipe-form-submit` — the write-outcome variant (saving/saved/error).
238
+ - `himi docs recipe-search` — the same states around a query.
115
239
  - `himi docs designing-himi-screens` §5 — copywriting + design for the states.
@@ -57,7 +57,8 @@ const svc = defineService<State, {
57
57
 
58
58
  - **Best-effort by design**: wrap every storage touch in `try/catch`. A missing
59
59
  or failing store means the card shows again next launch — a mild repeat,
60
- never an error state.
60
+ never an error state. The card reads a local flag and loads no remote data,
61
+ so it needs no loading state, no error state and no retry.
61
62
  - Namespace the key (`<app>.<what>`) — the store is app-scoped but shared
62
63
  across the app's workers.
63
64