@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.
package/dist/cli.mjs CHANGED
@@ -139246,7 +139246,7 @@ function stillLoading(state) {
139246
139246
  function hasOwnError(state) {
139247
139247
  if (!state || typeof state !== "object") return false;
139248
139248
  for (const [k2, v2] of Object.entries(state)) {
139249
- if (/^not/i.test(k2)) continue;
139249
+ if (/^not/i.test(k2) || /^(?:no|hide)[A-Z]/.test(k2)) continue;
139250
139250
  if (/error|failed|failure/i.test(k2) && v2 !== null && v2 !== void 0 && v2 !== false && v2 !== "") return true;
139251
139251
  if (/^(status|phase|state)$/i.test(k2) && typeof v2 === "string" && /error|fail/i.test(v2)) return true;
139252
139252
  }
@@ -153202,7 +153202,7 @@ function screenStateOf(services, html, opts = {}) {
153202
153202
  }
153203
153203
  if (status === "loading" || s2.isLoading === true || s2.noLoading === false) loading = true;
153204
153204
  const emptyText = str3(s2.emptyText) ?? str3(s2.emptySourceText);
153205
- if (!empty2 && (status === "empty" || s2.isEmpty === true || s2.noEmptySource === false || emptyText && page !== void 0 && painted(emptyText, page))) {
153205
+ if (!empty2 && (status === "empty" || s2.isEmpty === true || s2.noEmptySource === false || s2.notEmpty === false || emptyText && page !== void 0 && painted(emptyText, page))) {
153206
153206
  const line = emptyText && painted(emptyText, page) ? quotableLine(emptyText) : void 0;
153207
153207
  empty2 = { state: "empty", ...line ? { line } : {} };
153208
153208
  }
@@ -257,7 +257,7 @@ scroll. *Reach for `SegmentedControl` for 2–4 in-content modes; `Tabs`/tab-bar
257
257
  > in the *outer* column, between the pager and the bottom CTA). Its built-in indicator is faint; pair an
258
258
  > explicit `PageControl` if the dots need to read.
259
259
 
260
- **Feedback & overlays** — `Banner` for a persistent inline message (with a retry action on error);
260
+ **Feedback & overlays** — `Banner` for a persistent inline message (with a retry action when a refresh fails over content you kept; a screen with nothing to show uses an error `EmptyState`, see §5);
261
261
  `Snackbar` for a transient confirmation; `AlertDialog` for a blocking decision; `BottomSheet` for
262
262
  contextual actions/detail; `Tooltip`/`Popover` for a small anchored hint. *Reach for `Snackbar`, not
263
263
  `AlertDialog`, to confirm a completed action — don't block on success.* The `Snackbar` COMPONENT draws
@@ -345,7 +345,7 @@ to drive them:
345
345
  |---|---|---|
346
346
  | **Loading** | `Skeleton` (shape of the eventual content) or `StatefulSection` / `ActivityIndicator` | Required on every screen that loads data asynchronously, and re-checked on every redesign. Prefer a content-shaped `Skeleton` over a bare spinner — it previews the layout. The descriptor renders against the state-skeleton, then repaints as worker state lands. See [Loading states](#loading-states-part-of-the-design) below. |
347
347
  | **Empty** | `EmptyState` (`title`, optional `icon` / `subtitle`) **plus a directive action** (`action` + `actionLabel`) | An empty screen is an invitation to act, not a dead end. Give it the next step, for example `"actionLabel": "Add your first item"`. Use `EmptyState` for **every** empty state, so all empty states in the app have the same layout and style. See [Empty states](#empty-states-one-component-one-layout) below. |
348
- | **Error** | `Banner` (or inline) **with a retry action** | Say what failed and offer recovery; never a blank screen or a raw error string. |
348
+ | **Error** | Classified first. Nothing on screen: an `EmptyState` per class (`title` + `subtitle`, **no icon**, an action only for a real recovery). Rows on screen: keep them, add a `Banner` with retry. Field and action errors stay local. | Say what happened in the user's words and offer recovery only when there is one; never a blank screen or a raw error string. See [Error states](#error-states-classify-then-present) below. |
349
349
  | **Loaded** | the real content | The one everyone remembers to build. |
350
350
 
351
351
  Bind visibility to worker state with `hidden_path` (`$service:…`/`$item.` data-bound collapse in
@@ -416,6 +416,60 @@ To show the empty state only when the list is empty, bind `hiddenPath` to a work
416
416
  (see `himi docs recipe-four-state`). Write the title and the subtitle as literal copy or as
417
417
  `$t:` keys. Today, iOS and Android do not resolve a `$service:` binding in `title` or `subtitle`.
418
418
 
419
+ ### Error states: classify, then present
420
+
421
+ An error state says what happened, in the user's words, and offers a way out only when there
422
+ is one. Classify the failure first: each class has its own place on the screen, its own copy and
423
+ its own recovery.
424
+
425
+ | Class | When | Where it shows | Example copy | Recovery |
426
+ |---|---|---|---|---|
427
+ | **Offline** | No answer reached the device (no connection, no route, the request timed out). | The screen or section that failed to load. | "Reconnect to the internet" / "Connect to the internet, then try again." | **Try again**, which re-runs the load. |
428
+ | **Server failure** | The service answered with a failure. | The screen or section. | "Unable to load orders" / "There was a technical issue on our end. Please wait and try again later." | **Try again**, when a retry can help. |
429
+ | **Permission or sign-in** | The user is signed out, or signed in without access (HTTP 401 / 403). | The screen or section. | "Sign in to view orders" / "You need permission to view this screen" | The next step the app offers — **Sign in**, or a contact action. No retry: the same request fails again. |
430
+ | **Empty results** | The request succeeded and returned nothing. | **Not an error** — the empty state (above). | "No orders yet" | The empty state's directive action. |
431
+ | **Field validation** | A value the user typed is not valid. | Beside the field, in its own error slot (see **Inputs & forms** in §3). | "Enter a valid email." | The user corrects the field. No retry. |
432
+ | **Action failure** | A write or a command failed (save, delete, book). | Near the action or the affected content — a line by the button, or a `danger` snackbar (`ui.snackbar({ message, tone: "danger" })`). | "Couldn't save your changes. Try again." | Keep what the user entered; the action itself is the retry. |
433
+
434
+ - **A screen- or section-level error is an `EmptyState` with no icon.** Set `title` and
435
+ `subtitle`; leave `icon` out. Add `action` + `actionLabel` only when the user has a useful
436
+ recovery — retry, reload, sign in. Every error then has the same layout and style, as every
437
+ empty state does. Do not build one by hand from `Icon`, `Text` and `Button` nodes.
438
+ - **One node per class.** iOS and Android do not resolve a `$service:` binding in `EmptyState`
439
+ `title` or `subtitle`, so the worker cannot swap the text. Give each class the screen can hit its
440
+ own `EmptyState` with literal copy or `$t:` keys, and gate each one with `hiddenPath` bound to a
441
+ worker flag. The worker classifies the failure once and sets the flags — see
442
+ `himi docs recipe-four-state` for the whole pattern.
443
+ - **User-facing language, never the raw error.** Say what happened and what the user can do. Never
444
+ show `e.message`, a status code, a stack trace or an API error string — keep those for your logs.
445
+ - **Recovery only when it makes sense.** Offer **Try again** for an offline or a server failure,
446
+ and for a failed write. Do not offer it for a permission error, an empty result or a field error:
447
+ the same request gives the same answer.
448
+ - **Empty is not an error.** A successful call with zero rows shows the empty state.
449
+ - **A failed refresh keeps the content.** When data is already on screen and a refresh fails,
450
+ keep the rows and show a `Banner` (`severity: "warning"`, with a **Try again** `action` and its
451
+ `actionLabel`) above them. A failed "load more" keeps the rows too, but its error and **Try
452
+ again** go in a row at the end of the list that retries the same cursor
453
+ (`himi docs recipe-dynamic-list`) — the user is at the bottom, and the Banner's retry reloads
454
+ page 1. Replace the content with an error state only when there is nothing to show, or when a
455
+ sign-in or permission failure means the rows are no longer the user's to see.
456
+ - **The error replaces the loader.** When a load fails, hide the loading state and show the error —
457
+ never both at once.
458
+
459
+ ```jsonc
460
+ { "id": "orders.error.offline",
461
+ "body": { "package": "himalaya", "type": "EmptyState",
462
+ "props": { "title": "Reconnect to the internet",
463
+ "subtitle": "Connect to the internet, then try again.",
464
+ "action": { "actionId": "tier5.orders.retry" },
465
+ "actionLabel": "Try again" } },
466
+ "modifiers": { "hiddenPath": "$service:orders.hideOfflineError" } }
467
+ ```
468
+
469
+ **When an `ErrorState` component ships.** A dedicated `ErrorState` component is planned. When it
470
+ is available, use it in place of the icon-less `EmptyState`. Only the component changes: the
471
+ classes, where each one shows, the copy rules and the recovery rules above stay the same.
472
+
419
473
  **Loading & empty states are first-class — use the `Skeleton` and `EmptyState` component types.**
420
474
 
421
475
  There is **no builder-DSL package** — the `@wix/himalaya` umbrella exposes `./core`, `./state`,
@@ -488,7 +542,8 @@ screen for free.
488
542
  Run this punch list on the screen (it's also a good reviewer checklist):
489
543
 
490
544
  - [ ] **One** primary action, on the one `accentPrimary` surface?
491
- - [ ] All four states designed — loading / empty / error / loaded (empty & error have an action)?
545
+ - [ ] All four states designed — loading / empty / error / loaded (empty has a directive action; error has a recovery action when one exists)?
546
+ - [ ] Errors classified (offline / server / permission or sign-in), each an icon-less `EmptyState` with user-facing copy — no raw error text? Empty results shown as empty, not as an error? A failed refresh keeps the content?
492
547
  - [ ] Loading state matches the loaded layout (a content-shaped `Skeleton` where the layout is known)? For a redesign, was the existing loading state reviewed against the new layout? A static screen has none?
493
548
  - [ ] Every value a token — zero literal hex / off-scale spacing / invented type sizes?
494
549
  - [ ] Is every user-facing string routed through a copy key with a sensible fallback (`copyOf(key, fallback)` backed by your own `COPY` map; worker strings via `onAppear` params) — none baked directly into the descriptor?
@@ -21,54 +21,100 @@
21
21
  "id": "loading",
22
22
  "body": {
23
23
  "package": "himalaya",
24
- "type": "Text",
24
+ "type": "Skeleton",
25
25
  "props": {
26
- "text": "$service:products.loadingText",
27
- "style": "body",
28
- "color": "textSecondary",
29
- "collapseWhenEmpty": true
26
+ "height": 64,
27
+ "cornerRadius": 12,
28
+ "count": 5
30
29
  }
30
+ },
31
+ "modifiers": {
32
+ "hiddenPath": "$service:products.hideLoading"
31
33
  }
32
34
  },
33
35
  {
34
36
  "id": "empty",
35
37
  "body": {
36
38
  "package": "himalaya",
37
- "type": "Text",
39
+ "type": "EmptyState",
40
+ "props": {
41
+ "icon": "bag",
42
+ "title": "No products in this store yet",
43
+ "subtitle": "Products you add to the store show up here."
44
+ }
45
+ },
46
+ "modifiers": {
47
+ "hiddenPath": "$service:products.notEmpty"
48
+ }
49
+ },
50
+ {
51
+ "id": "error.offline",
52
+ "body": {
53
+ "package": "himalaya",
54
+ "type": "EmptyState",
55
+ "props": {
56
+ "title": "Reconnect to the internet",
57
+ "subtitle": "Connect to the internet, then try again.",
58
+ "action": {
59
+ "actionId": "tier5.products.retry"
60
+ },
61
+ "actionLabel": "Try again"
62
+ }
63
+ },
64
+ "modifiers": {
65
+ "hiddenPath": "$service:products.hideOfflineError"
66
+ }
67
+ },
68
+ {
69
+ "id": "error.server",
70
+ "body": {
71
+ "package": "himalaya",
72
+ "type": "EmptyState",
38
73
  "props": {
39
- "text": "$service:products.emptyText",
40
- "style": "body",
41
- "color": "textSecondary",
42
- "collapseWhenEmpty": true
74
+ "title": "Unable to load products",
75
+ "subtitle": "There was a technical issue on our end. Please wait and try again later.",
76
+ "action": {
77
+ "actionId": "tier5.products.retry"
78
+ },
79
+ "actionLabel": "Try again"
43
80
  }
81
+ },
82
+ "modifiers": {
83
+ "hiddenPath": "$service:products.hideServerError"
44
84
  }
45
85
  },
46
86
  {
47
- "id": "error",
87
+ "id": "error.permission",
48
88
  "body": {
49
89
  "package": "himalaya",
50
- "type": "Text",
90
+ "type": "EmptyState",
51
91
  "props": {
52
- "text": "$service:products.error",
53
- "style": "body",
54
- "color": "warning",
55
- "collapseWhenEmpty": true
92
+ "title": "You need permission to view this screen",
93
+ "subtitle": "Ask the owner of this store for access."
56
94
  }
95
+ },
96
+ "modifiers": {
97
+ "hiddenPath": "$service:products.hidePermissionError"
57
98
  }
58
99
  },
59
100
  {
60
- "id": "retry",
101
+ "id": "error.refresh",
61
102
  "body": {
62
103
  "package": "himalaya",
63
- "type": "Button",
104
+ "type": "Banner",
64
105
  "props": {
65
- "title": "Retry",
66
- "style": "secondary",
67
- "size": "sm",
68
- "action": { "actionId": "tier5.products.retry" }
106
+ "severity": "warning",
107
+ "title": "Couldn't refresh",
108
+ "message": "Showing what was loaded before.",
109
+ "action": {
110
+ "actionId": "tier5.products.retry"
111
+ },
112
+ "actionLabel": "Try again"
69
113
  }
70
114
  },
71
- "modifiers": { "hiddenPath": "$service:products.notErrored" }
115
+ "modifiers": {
116
+ "hiddenPath": "$service:products.hideRefreshError"
117
+ }
72
118
  },
73
119
  {
74
120
  "id": "products_list",
@@ -4,8 +4,10 @@
4
4
  //
5
5
  // The pattern: the worker OWNS the array. It fetches on boot (`svc.load()` at the
6
6
  // bottom), normalizes raw REST rows into FLAT `$item`-friendly fields, and computes
7
- // a display string per screen state (loading / empty / error) so the descriptor can
8
- // render four states with plain `collapseWhenEmpty` Texts — no conditional layout.
7
+ // one `hiddenPath` flag per screen state (loading / empty / each error class /
8
+ // refresh failure) so the descriptor renders every state from one flat layout.
9
+ // Errors are CLASSIFIED (`himi docs recipe-four-state`): the user sees the copy of
10
+ // the class, never the raw wire message.
9
11
  //
10
12
  // Because this worker rides the Wix transport (globalThis.himalaya.wixApi), the
11
13
  // descriptor's logicBundle.declaredCapabilities MUST include "wix_api" (RFC 0043 D4).
@@ -22,13 +24,21 @@ interface ProductRow {
22
24
  initial: string; // derived presentation field (avatar letter)
23
25
  }
24
26
 
27
+ // A public catalog has no sign-in flow, so a 401 reads as "permission" here — every
28
+ // class this worker can return has a node in the descriptor.
29
+ type ErrorKind = "" | "offline" | "server" | "permission";
30
+
25
31
  interface ProductsState {
26
32
  status: "loading" | "ready" | "error";
27
33
  items: ProductRow[]; // ← itemsPath binds here; MUST initialize as an array
28
- loadingText: string; // "Loading…" while loading, "" after → collapseWhenEmpty
29
- emptyText: string; // set when ready + no rows, "" otherwise
30
- error: string; // message when failed, "" otherwise
31
- notErrored: boolean; // for hiddenPath on the Retry button
34
+ errorKind: ErrorKind; // why the last load failed; "" unless status is "error"
35
+ // hiddenPath hides a node when its flag is TRUE.
36
+ hideLoading: boolean; // skeleton: first load, nothing on screen
37
+ notEmpty: boolean; // EmptyState: ready with zero rows
38
+ hideOfflineError: boolean; // one EmptyState per error class …
39
+ hideServerError: boolean;
40
+ hidePermissionError: boolean;
41
+ hideRefreshError: boolean; // … and a Banner when a refresh fails over kept rows
32
42
  selectedId: string; // row identity from onTap params — drive a detail view from it
33
43
  }
34
44
 
@@ -52,24 +62,59 @@ function normalize(raw: StoreProduct): ProductRow {
52
62
  function shape(s: Partial<ProductsState>): ProductsState {
53
63
  const status = s.status ?? "ready";
54
64
  const items = s.items ?? [];
65
+ const errorKind: ErrorKind = status === "error" ? (s.errorKind || "server") : "";
66
+ const blank = items.length === 0; // nothing on screen to keep
55
67
  return {
56
68
  status,
57
69
  items,
58
- loadingText: status === "loading" ? "Loading products…" : "",
59
- emptyText: status === "ready" && items.length === 0 ? "No products in this store yet." : "",
60
- error: s.error ?? "",
61
- notErrored: status !== "error",
70
+ errorKind,
71
+ hideLoading: !(status === "loading" && blank),
72
+ notEmpty: !(status === "ready" && blank),
73
+ hideOfflineError: !(blank && errorKind === "offline"),
74
+ hideServerError: !(blank && errorKind === "server"),
75
+ hidePermissionError: !(blank && errorKind === "permission"),
76
+ hideRefreshError: !(!blank && (errorKind === "offline" || errorKind === "server")),
62
77
  selectedId: s.selectedId ?? "",
63
78
  };
64
79
  }
65
80
 
66
- async function loadList(): Promise<ProductsState> {
81
+ // The HTTP status rides in the message: `HTTP 503: …` on a device, `Wix 503 for …`
82
+ // under `himi test` / `himi render`. Only a transport failure is "offline".
83
+ const STATUS = /\b(?:HTTP|Wix) (\d{3})\b/;
84
+ // No answer at all — what a dropped connection says on every platform.
85
+ 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;
86
+ // Android reports an unknown host as the bare hostname (`users.wix.com`) — hence the last form.
87
+ // No session on the device: the native bridge refuses before any request leaves.
88
+ // A pinned site with no member session: iOS `siteNotPermitted(requested: "account", …)`,
89
+ // Android `site account is not the bound site …`; the web host `HTTP 501: {"error":"webwix_owner_no_session"}`.
90
+ 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;
91
+
92
+ // The same classifier as `himi docs recipe-four-state`, except that a public screen with no sign-in flow shows the permission state for a sign-in failure.
93
+ function classifyError(e: unknown): ErrorKind {
94
+ const msg = String((e as Error)?.message ?? e ?? "");
95
+ const code = String((e as { code?: unknown })?.code ?? "");
96
+ const status = Number(STATUS.exec(msg)?.[1] ?? 0);
97
+ if (status === 401) return "permission";
98
+ if (status === 403) return "permission";
99
+ if (SIGNED_OUT.test(msg)) return "permission";
100
+ if (code === "offline_write_blocked") return "offline"; // a write refused before it left
101
+ // A web call that never reached Wix arrives as
102
+ // `HTTP 502: {"error":"webwix_direct_failed","detail":"Failed to fetch"}` — judge its detail.
103
+ if (status && !msg.includes("webwix_direct_failed")) return "server";
104
+ if (TRANSPORT.test(msg)) return "offline";
105
+ return "server"; // a bug or a malformed row is not the user's connection
106
+ }
107
+
108
+ async function loadList(s: Partial<ProductsState>): Promise<ProductsState> {
67
109
  try {
68
110
  const res = await queryStoreProducts(50);
69
111
  const items = (res.products ?? []).filter((p) => p?.id).map(normalize);
70
- return shape({ status: "ready", items });
112
+ return shape({ status: "ready", items, selectedId: s.selectedId });
71
113
  } catch (e) {
72
- return shape({ status: "error", items: [], error: String((e as Error)?.message ?? e) });
114
+ const errorKind = classifyError(e);
115
+ // Keep the rows on a refresh failure; clear them when access is gone.
116
+ const items = errorKind === "permission" ? [] : s.items ?? [];
117
+ return shape({ ...s, status: "error", items, errorKind });
73
118
  }
74
119
  }
75
120
 
@@ -80,8 +125,8 @@ const svc = defineService<ProductsState, {
80
125
  }>("products", {
81
126
  state: shape({ status: "loading" }),
82
127
  actions: {
83
- load: async () => loadList(),
84
- retry: async () => loadList(),
128
+ load: async (s) => loadList(s),
129
+ retry: async (s) => loadList(s),
85
130
  // Row tap — receives the template's params ({ id: "$item.id" }, substituted per row).
86
131
  // Stash the identity; wire real navigation per the tier-5 guide (detail screens).
87
132
  open: (s, arg) => ({ ...s, selectedId: String((arg as { id?: string })?.id ?? "") }),
@@ -25,32 +25,6 @@
25
25
  "props": { "text": "New post", "style": "title1" }
26
26
  }
27
27
  },
28
- {
29
- "id": "error",
30
- "body": {
31
- "package": "himalaya",
32
- "type": "Text",
33
- "props": {
34
- "text": "$service:postForm.error",
35
- "style": "footnote",
36
- "color": "danger",
37
- "collapseWhenEmpty": true
38
- }
39
- }
40
- },
41
- {
42
- "id": "saved",
43
- "body": {
44
- "package": "himalaya",
45
- "type": "Text",
46
- "props": {
47
- "text": "$service:postForm.savedMessage",
48
- "style": "footnote",
49
- "color": "accentPrimary",
50
- "collapseWhenEmpty": true
51
- }
52
- }
53
- },
54
28
  {
55
29
  "id": "post_form",
56
30
  "body": {
@@ -104,13 +78,40 @@
104
78
  "title": "Create post",
105
79
  "style": "primary",
106
80
  "fullWidth": true,
107
- "action": { "actionId": "tier5.postForm.submit" }
81
+ "action": { "actionId": "tier5.postForm.submit" },
82
+ "disabledPath": "$service:postForm.cannotSave"
108
83
  }
109
84
  }
110
85
  }
111
86
  }
112
87
  }
113
88
  },
89
+ {
90
+ "id": "error",
91
+ "body": {
92
+ "package": "himalaya",
93
+ "type": "Text",
94
+ "props": {
95
+ "text": "$service:postForm.error",
96
+ "style": "footnote",
97
+ "color": "danger",
98
+ "collapseWhenEmpty": true
99
+ }
100
+ }
101
+ },
102
+ {
103
+ "id": "saved",
104
+ "body": {
105
+ "package": "himalaya",
106
+ "type": "Text",
107
+ "props": {
108
+ "text": "$service:postForm.savedMessage",
109
+ "style": "footnote",
110
+ "color": "accentPrimary",
111
+ "collapseWhenEmpty": true
112
+ }
113
+ }
114
+ },
114
115
  {
115
116
  "id": "open_saved",
116
117
  "body": {
@@ -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.)",