@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.
@@ -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
 
@@ -64,9 +64,9 @@ not say why there are no rows.
64
64
  | Initial, no query | Search prompt plus useful recent queries, categories, or suggestions. Avoid presenting this as a failed search. |
65
65
  | Loading | First load: a skeleton or progress state. Refresh: keep current results visible and show a compact progress indicator when possible. |
66
66
  | Results | Matching rows, result count, and the available sort/filter controls. |
67
- | No matches | Explain that the current query or filters found nothing; offer Clear or a way to adjust them. |
67
+ | No matches | An `EmptyState` explaining that the current query or filters found nothing; offer a way to adjust them, and Clear only where the field is controlled (the native slot) — the inline `SearchField` is uncontrolled, so Clear there empties the query but not the field. |
68
68
  | Empty catalog | Explain that the catalog itself has no items yet, independent of the query; provide a useful browse/back action. |
69
- | Error | Show an error with Retry, retain the query and controls, and keep it distinct from no matches. |
69
+ | Error | Classified as in `himi docs recipe-four-state`: an icon-less `EmptyState` per class with user-facing copy, Try again only for offline and server, Sign in for sign-in, no action for permission. Retain the query and controls, and keep it distinct from no matches. |
70
70
 
71
71
  ### Native slot and old-client fallback
72
72
 
@@ -170,7 +170,12 @@ interface PostsState {
170
170
  all: PostRow[]; // the full fetched set — NOT bound by the descriptor
171
171
  items: PostRow[]; // ← itemsPath binds here; always derived from all + query
172
172
  query: string;
173
- loadingText: string; emptyText: string; error: string; notErrored: boolean;
173
+ hideNoPosts: boolean; // the EmptyState for "loaded, and there is nothing at all"
174
+ hideNoMatches: boolean; // the EmptyState for "there are posts, none match the query"
175
+ errorKind: "" | "offline" | "server" | "signIn" | "permission"; // classifyError(e)
176
+ hideLoading: boolean; hideOfflineError: boolean; hideServerError: boolean;
177
+ hideSignInError: boolean; hidePermissionError: boolean;
178
+ hideRefreshError: boolean; // hiddenPath flags, as in recipe-four-state
174
179
  }
175
180
 
176
181
  function visible(all: PostRow[], query: string): PostRow[] {
@@ -186,31 +191,103 @@ function queryFrom(arg: unknown): string {
186
191
  return String(a?.value ?? a?.text ?? a?.query ?? "");
187
192
  }
188
193
 
194
+ // Actions are not serialized: the user can type while load() is in flight, and the
195
+ // action that finishes last writes the whole state. So the latest query lives outside
196
+ // the state, and every fetch applies it when it returns.
197
+ let currentQuery = "";
198
+
199
+ // The SearchField is uncontrolled: it opens empty, so the query must start over too.
200
+ // `load` (opening the screen) resets it; a query typed while that fetch is in flight
201
+ // still wins. `retry` keeps it.
202
+ load: async (s) => { currentQuery = ""; return loadList(s); },
203
+
204
+ // After defineService: a cold boot restores the persisted `query` AND the `items` and
205
+ // empty flags derived from it. Re-derive them for an empty query before anything paints.
206
+ const booted = (svc as unknown as { getState?: () => Record<string, unknown> | undefined }).getState?.();
207
+ if (booted && typeof booted === "object") {
208
+ Object.assign(booted, shape({ ...(booted as Partial<PostsState>), query: "", items: undefined }));
209
+ }
210
+
189
211
  // actions:
190
212
  search: (s, arg) => {
191
213
  const query = queryFrom(arg);
214
+ currentQuery = query;
192
215
  const items = visible(s.all, query);
193
- return shape({ ...s, query, items }); // shape() recomputes emptyText from items+query
216
+ return shape({ ...s, query, items }); // shape() recomputes the empty flags from all+items+query
194
217
  },
195
- clear: (s) => shape({ ...s, query: "", items: s.all }),
218
+ clear: (s) => { currentQuery = ""; return shape({ ...s, query: "", items: s.all }); },
196
219
  ```
197
220
 
198
- - `load()` fetches once, fills `all`, and sets `items = visible(all, query)`.
221
+ - `load()` fetches once, fills `all`, and sets `items = visible(all, currentQuery)`.
222
+ On a `signIn` or `permission` failure it clears **`all` as well as `items`** —
223
+ `search`, `clear` and `retry` rebuild rows from `all`, so rows left there come
224
+ back on the next keystroke.
199
225
  - The shipped Blog v3 `WixPost` type includes `categoryIds` and both Wix-image and
200
226
  embed-thumbnail media projections. Normalize them without a local cast:
201
227
  `coverUrl: post.media?.wixMedia?.image?.url ?? post.media?.embedMedia?.thumbnail?.url ?? ""`
202
228
  and `categoryIds: post.categoryIds ?? []`.
203
- - **`emptyText` should distinguish** "no data at all" from "no matches":
204
- `query ? `No matches for “${query}”.` : "No posts yet."` — recompute it in
205
- `shape()` wherever items change.
229
+ - **Two empty states, both `EmptyState`**: "no data at all" and "no matches" are
230
+ different screens. Search's `shape()` takes four-state's flags with one change:
231
+ `blank` is `all.length === 0`, not `items.length === 0` — `items` is the filtered
232
+ view, so a query with no matches over rows kept after a failed refresh must not
233
+ read as "nothing loaded" and show the full-screen error. In `shape()`, wherever
234
+ items change, recompute
235
+ `hideNoPosts = !(status === "ready" && all.length === 0)` and
236
+ `hideNoMatches = !(all.length > 0 && items.length === 0)` — no `status` check,
237
+ because the user can still search the rows kept after a failed refresh. Their
238
+ copy is static — an `EmptyState` cannot bind its text on native — and the
239
+ no-matches one has no action: the `SearchField` is uncontrolled, so a "Clear
240
+ search" button would empty the worker's query while the field still shows it:
241
+
242
+ ```jsonc
243
+ { "id": "empty.noPosts",
244
+ "body": { "package": "himalaya", "type": "EmptyState",
245
+ "props": { "icon": "inbox", "title": "No posts yet",
246
+ "subtitle": "New posts will show up here." } },
247
+ "modifiers": { "hiddenPath": "$service:posts.hideNoPosts" } },
248
+ { "id": "empty.noMatches",
249
+ "body": { "package": "himalaya", "type": "EmptyState",
250
+ "props": { "icon": "search", "title": "No posts match your search",
251
+ "subtitle": "Try a different word." } },
252
+ "modifiers": { "hiddenPath": "$service:posts.hideNoMatches" } }
253
+ ```
254
+
255
+ ## 3. Loading, error and retry
256
+
257
+ A searchable list loads data, so it carries the four states of
258
+ `himi docs recipe-four-state`:
259
+
260
+ - **Loading** — a `Skeleton` shaped like the result rows, gated by `hideLoading`,
261
+ while the first `load()` runs. The search field is usable at once.
262
+ - **Error, nothing loaded** — one `EmptyState` per class `classifyError` can
263
+ return, **no icon**, user-facing copy; offline and server get **Try again** →
264
+ `tier5.posts.retry`, sign-in gets **Sign in**, permission gets no action. The
265
+ `catch` sets `errorKind` from `classifyError(e)`; it never shows `e.message`.
266
+ - **Retry keeps the query.** `retry` re-runs `load()` and then re-applies
267
+ `currentQuery` — not `s.query`, which is the query from when the retry
268
+ STARTED — so what the user typed during the fetch is not lost.
269
+ - **No matches is not an error.** A successful search with zero results shows the
270
+ no-matches `EmptyState` ("No posts match your search"), with no action and no retry.
271
+
272
+ ```ts
273
+ retry: async (s) => {
274
+ const next = await load(s); // classified on failure
275
+ // Lost access clears the rows; any other failure keeps them for the Banner.
276
+ const lostAccess = next.errorKind === "signIn" || next.errorKind === "permission";
277
+ const all = lostAccess ? [] : next.all;
278
+ return shape({ ...next, all, query: currentQuery, items: visible(all, currentQuery) });
279
+ },
280
+ ```
206
281
 
207
- ## 3. Server-side search (large datasets)
282
+ ## 4. Server-side search (large datasets)
208
283
 
209
284
  In-worker filtering is right up to a few hundred rows. Past that, make `search`
210
285
  re-query the API with the term (most Wix domain query endpoints take a filter,
211
286
  e.g. blog `queryPosts` — check `himi sdk wix-<domain>`), keep `status: "loading"`
212
287
  during the round-trip, and debounce higher (300–400ms). The descriptor does not
213
- change — only the action's body.
288
+ change — only the action's body. A failed server-side search **keeps the query and
289
+ the results already on screen** and shows the refresh `Banner` with **Try again**,
290
+ which runs the same query again.
214
291
 
215
292
  ## Gotchas
216
293
 
@@ -231,6 +308,6 @@ change — only the action's body.
231
308
  ## See also
232
309
 
233
310
  - `himi docs recipe-dynamic-list` — the list this recipe filters.
234
- - `himi docs recipe-four-state` — the emptyText/error/loading strings.
311
+ - `himi docs recipe-four-state` — the loading, classified error and retry idiom.
235
312
  - `himi components SearchField` / `FilterChips` / `Tabs`.
236
313
  - `.agents/skills/himalaya/examples/search-list.*` (written into the app by `himi init` / `himi agent-init`) — this recipe, runnable.
@@ -36,6 +36,21 @@ export async function finishVoiceJournal(sessionId: string) {
36
36
  }
37
37
  ```
38
38
 
39
+ **States.** Recording reads no remote data, so the journal screen needs no network
40
+ loading or error states — but it does have states the user must see:
41
+
42
+ - **Unavailable** (`status: "unavailable"` — no microphone, permission denied, or a
43
+ host without capture): an icon-less `EmptyState` that says recording is not
44
+ available and gives the next step the platform offers (for example, how to allow
45
+ the microphone). No **Try again**: the same call fails the same way until the
46
+ user changes something.
47
+ - **Processing** (transcription, diarization): a progress or a placeholder line on
48
+ the entry, never a blocking spinner; a processing failure keeps the audio and
49
+ shows the entry without a transcript.
50
+ - **Saved entries list**: reading `local-db` is local and quick, so it needs no
51
+ skeleton; an empty journal is an `EmptyState` with a "Record your first entry"
52
+ action.
53
+
39
54
  The SDUI layer can use the existing list, text, button, progress, audio/media, and state-binding
40
55
  components for recording, transcript detail, search, deferred processing, summaries, categories,
41
56
  delete, and export. Generate summaries and structured categories with `@himalaya/on-device-ai`
@@ -33,54 +33,115 @@
33
33
  "id": "loading",
34
34
  "body": {
35
35
  "package": "himalaya",
36
- "type": "Text",
36
+ "type": "Skeleton",
37
37
  "props": {
38
- "text": "$service:posts.loadingText",
39
- "style": "body",
40
- "color": "textSecondary",
41
- "collapseWhenEmpty": true
38
+ "height": 56,
39
+ "cornerRadius": 12,
40
+ "count": 5
42
41
  }
42
+ },
43
+ "modifiers": {
44
+ "hiddenPath": "$service:posts.hideLoading"
45
+ }
46
+ },
47
+ {
48
+ "id": "empty.no_posts",
49
+ "body": {
50
+ "package": "himalaya",
51
+ "type": "EmptyState",
52
+ "props": {
53
+ "icon": "doc",
54
+ "title": "No posts yet",
55
+ "subtitle": "Posts you publish show up here."
56
+ }
57
+ },
58
+ "modifiers": {
59
+ "hiddenPath": "$service:posts.hideNoPosts"
60
+ }
61
+ },
62
+ {
63
+ "id": "empty.no_matches",
64
+ "body": {
65
+ "package": "himalaya",
66
+ "type": "EmptyState",
67
+ "props": {
68
+ "icon": "search",
69
+ "title": "No posts match your search",
70
+ "subtitle": "Try a different word."
71
+ }
72
+ },
73
+ "modifiers": {
74
+ "hiddenPath": "$service:posts.hideNoMatches"
75
+ }
76
+ },
77
+ {
78
+ "id": "error.offline",
79
+ "body": {
80
+ "package": "himalaya",
81
+ "type": "EmptyState",
82
+ "props": {
83
+ "title": "Reconnect to the internet",
84
+ "subtitle": "Connect to the internet, then try again.",
85
+ "action": {
86
+ "actionId": "tier5.posts.retry"
87
+ },
88
+ "actionLabel": "Try again"
89
+ }
90
+ },
91
+ "modifiers": {
92
+ "hiddenPath": "$service:posts.hideOfflineError"
43
93
  }
44
94
  },
45
95
  {
46
- "id": "empty",
96
+ "id": "error.server",
47
97
  "body": {
48
98
  "package": "himalaya",
49
- "type": "Text",
99
+ "type": "EmptyState",
50
100
  "props": {
51
- "text": "$service:posts.emptyText",
52
- "style": "body",
53
- "color": "textSecondary",
54
- "collapseWhenEmpty": true
101
+ "title": "Unable to load posts",
102
+ "subtitle": "There was a technical issue on our end. Please wait and try again later.",
103
+ "action": {
104
+ "actionId": "tier5.posts.retry"
105
+ },
106
+ "actionLabel": "Try again"
55
107
  }
108
+ },
109
+ "modifiers": {
110
+ "hiddenPath": "$service:posts.hideServerError"
56
111
  }
57
112
  },
58
113
  {
59
- "id": "error",
114
+ "id": "error.permission",
60
115
  "body": {
61
116
  "package": "himalaya",
62
- "type": "Text",
117
+ "type": "EmptyState",
63
118
  "props": {
64
- "text": "$service:posts.error",
65
- "style": "body",
66
- "color": "danger",
67
- "collapseWhenEmpty": true
119
+ "title": "You need permission to view this screen",
120
+ "subtitle": "Ask the owner of this blog for access."
68
121
  }
122
+ },
123
+ "modifiers": {
124
+ "hiddenPath": "$service:posts.hidePermissionError"
69
125
  }
70
126
  },
71
127
  {
72
- "id": "retry",
128
+ "id": "error.refresh",
73
129
  "body": {
74
130
  "package": "himalaya",
75
- "type": "Button",
131
+ "type": "Banner",
76
132
  "props": {
77
- "title": "Retry",
78
- "style": "secondary",
79
- "size": "sm",
80
- "action": { "actionId": "tier5.posts.retry" }
133
+ "severity": "warning",
134
+ "title": "Couldn't refresh",
135
+ "message": "Showing what was loaded before.",
136
+ "action": {
137
+ "actionId": "tier5.posts.retry"
138
+ },
139
+ "actionLabel": "Try again"
81
140
  }
82
141
  },
83
- "modifiers": { "hiddenPath": "$service:posts.notErrored" }
142
+ "modifiers": {
143
+ "hiddenPath": "$service:posts.hideRefreshError"
144
+ }
84
145
  },
85
146
  {
86
147
  "id": "posts_list",