@wix/himalaya-cli 0.1464.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 +3 -2
- package/guides/designing-himi-screens.md +58 -3
- package/guides/dynamic-list-t5.descriptor.json +69 -23
- package/guides/dynamic-list.service.ts +60 -15
- package/guides/form-submit-t5.descriptor.json +28 -27
- package/guides/form-submit.service.ts +60 -2
- package/guides/getting-started.md +2 -1
- package/guides/himi-skill.md +2 -1
- package/guides/index.json +1 -0
- package/guides/recipe-dynamic-list.md +57 -22
- package/guides/recipe-form-submit.md +39 -7
- package/guides/recipe-four-state.md +178 -54
- package/guides/recipe-onboarding.md +2 -1
- package/guides/recipe-search.md +89 -12
- package/guides/recipe-voice-journal.md +15 -0
- package/guides/search-list-t5.descriptor.json +85 -24
- package/guides/search-list.service.ts +83 -21
- package/package.json +2 -2
package/dist/cli.mjs
CHANGED
|
@@ -8127,6 +8127,7 @@ function resolveTranslation(value, catalog3, sourceCatalog) {
|
|
|
8127
8127
|
var BINDING_SIGILS, BINDING_SIGIL_RE, TRANSLATION_PREFIX, TRANSLATION_KEY_RE;
|
|
8128
8128
|
var init_bindings = __esm({
|
|
8129
8129
|
"../../core/ts/src/bindings.ts"() {
|
|
8130
|
+
"use strict";
|
|
8130
8131
|
BINDING_SIGILS = ["service", "flag", "item", "t"];
|
|
8131
8132
|
BINDING_SIGIL_RE = new RegExp(
|
|
8132
8133
|
`^\\$(?:${BINDING_SIGILS.filter((s2) => s2 !== "t").join("|")})\\b|^\\$t:`
|
|
@@ -139245,7 +139246,7 @@ function stillLoading(state) {
|
|
|
139245
139246
|
function hasOwnError(state) {
|
|
139246
139247
|
if (!state || typeof state !== "object") return false;
|
|
139247
139248
|
for (const [k2, v2] of Object.entries(state)) {
|
|
139248
|
-
if (/^not/i.test(k2)) continue;
|
|
139249
|
+
if (/^not/i.test(k2) || /^(?:no|hide)[A-Z]/.test(k2)) continue;
|
|
139249
139250
|
if (/error|failed|failure/i.test(k2) && v2 !== null && v2 !== void 0 && v2 !== false && v2 !== "") return true;
|
|
139250
139251
|
if (/^(status|phase|state)$/i.test(k2) && typeof v2 === "string" && /error|fail/i.test(v2)) return true;
|
|
139251
139252
|
}
|
|
@@ -153201,7 +153202,7 @@ function screenStateOf(services, html, opts = {}) {
|
|
|
153201
153202
|
}
|
|
153202
153203
|
if (status === "loading" || s2.isLoading === true || s2.noLoading === false) loading = true;
|
|
153203
153204
|
const emptyText = str3(s2.emptyText) ?? str3(s2.emptySourceText);
|
|
153204
|
-
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))) {
|
|
153205
153206
|
const line = emptyText && painted(emptyText, page) ? quotableLine(emptyText) : void 0;
|
|
153206
153207
|
empty2 = { state: "empty", ...line ? { line } : {} };
|
|
153207
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
|
|
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** | `
|
|
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
|
|
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": "
|
|
24
|
+
"type": "Skeleton",
|
|
25
25
|
"props": {
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
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": "
|
|
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
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
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": "
|
|
90
|
+
"type": "EmptyState",
|
|
51
91
|
"props": {
|
|
52
|
-
"
|
|
53
|
-
"
|
|
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": "
|
|
101
|
+
"id": "error.refresh",
|
|
61
102
|
"body": {
|
|
62
103
|
"package": "himalaya",
|
|
63
|
-
"type": "
|
|
104
|
+
"type": "Banner",
|
|
64
105
|
"props": {
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"action": {
|
|
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": {
|
|
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
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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*.
|
package/guides/himi-skill.md
CHANGED
|
@@ -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`
|
|
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.)",
|