@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.
- package/dist/cli.mjs +24 -7
- 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/qa-builds.md +78 -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
|
@@ -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.)",
|
package/guides/qa-builds.md
CHANGED
|
@@ -1026,6 +1026,81 @@ that block off the first Android `himi dist` run to settle the question with a m
|
|
|
1026
1026
|
than an assumption, the way the iOS identities were settled on 2026-07-27 after two wrong
|
|
1027
1027
|
diagnoses.
|
|
1028
1028
|
|
|
1029
|
+
### An Android App Bundle for Google Play — `--aab`
|
|
1030
|
+
|
|
1031
|
+
An APK installs on a device. Google Play wants an **Android App Bundle** (`.aab`) for a new app,
|
|
1032
|
+
and a bundle installs on nothing. `--aab` builds the bundle instead, for an owner who downloads the
|
|
1033
|
+
Wix-built binary and uploads it to Play Console themselves:
|
|
1034
|
+
|
|
1035
|
+
```sh
|
|
1036
|
+
himi login --dpx --target bo # an INTERNAL caller: self-serve callers get 403 (see below)
|
|
1037
|
+
himi dist --app <app> --platform android --aab --wait --target bo
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
- **What you get:** `artifact.downloadUrl` (presigned, 7 days) and `artifact.sha256`. No
|
|
1041
|
+
`installLink`, no `installPageUrl`, no QR, and `GET /v1/dist/<jobId>/install` answers 404 for it —
|
|
1042
|
+
a link that hands a phone a file it cannot open is worse than none. The record carries
|
|
1043
|
+
`androidFormat: "aab"`.
|
|
1044
|
+
- **Android only.** The default stays an APK, and iOS is untouched. It travels as
|
|
1045
|
+
`androidFormat: "aab"` on `POST /v1/admin/dist` (every `/v1/owner/dist` caller is self-serve, so
|
|
1046
|
+
that route always answers `403 android_format_not_allowed`), and reaches the pool as
|
|
1047
|
+
`DIST_ANDROID_FORMAT=aab` in the `command` blob (see
|
|
1048
|
+
[why](#why-the-jobs-parameters-ride-in-command-not-envvars)). Refusals, all typed 400s before
|
|
1049
|
+
any pool time is spent: `invalid_android_format` (anything but `apk` / `aab`),
|
|
1050
|
+
`android_format_requires_link` (any destination but `link`), `android_format_requires_android`
|
|
1051
|
+
(an iOS-only build, where the flag would be dropped in silence), and
|
|
1052
|
+
`android_format_requires_wix_keystore` (any `--android-signing` other than `wix-keystore`), and
|
|
1053
|
+
`android_format_shell_unsupported` (the `owner-lite` shell, whose gradle build refuses a store
|
|
1054
|
+
bundle without a real Firebase config and customer identity). A mixed `--platform ios,android`
|
|
1055
|
+
applies it to the Android leg only.
|
|
1056
|
+
- **How the pool builds it.** `run-dist` runs store-release's `bundleRelease` task
|
|
1057
|
+
(`bundleReleaseArgs` / `bundleReleaseEnv`, the same task the Play lane runs) with **AGP injected
|
|
1058
|
+
signing**, with the **Wix production key** (`wix-keystore`: `~/.signing/oneapp-production.keystore`,
|
|
1059
|
+
alias `WixOne`), resolved by `resolveAndroidSigner` and verified there. The pool refuses any
|
|
1060
|
+
other signer for a bundle — a pool-side `DIST_ANDROID_KEYSTORE` included. The keystore path and alias are `-P` flags; the passwords ride in the environment as
|
|
1061
|
+
`ORG_GRADLE_PROJECT_android.injected.signing.{store,key}.password`, never in argv. `jarsigner
|
|
1062
|
+
-verify` must say `jar verified.` before anything is uploaded. The seed gate and the binary-size
|
|
1063
|
+
budget run on the `.aab` as they do on an APK. `DIST_ANDROID_BUNDLE_TASK` overrides the task
|
|
1064
|
+
(`DIST_ANDROID_TASK` stays the APK one), and `DIST_ANDROID_FLAVOR` picks among flavored bundles.
|
|
1065
|
+
Only the task's own gradle module is searched, so a sibling module's stale bundle is never taken.
|
|
1066
|
+
An **ambiguous** choice fails the build instead of guessing: the bundle's applicationId becomes
|
|
1067
|
+
the app's permanent Play package.
|
|
1068
|
+
- **What it skips, and why.** `apksigner` and the re-sign step (a bundle is JAR-signed at build
|
|
1069
|
+
time, and the injected config overrides any `signingConfig`); the 16 KB check (its ZIP half is about an installed APK,
|
|
1070
|
+
which Play generates; its ELF half reads the APK `lib/` layout — Play checks alignment on
|
|
1071
|
+
upload); and `--reuse-artifact` (that is an APK).
|
|
1072
|
+
- **Version skew.** The pool runs the release's own `run-dist`, and one older than `--aab` ignores
|
|
1073
|
+
`DIST_ANDROID_FORMAT` and builds an APK. So the pool reports what it built (`androidFormat` on
|
|
1074
|
+
the result callback, the `androidSigningUsed` pattern), and serve-control turns a `SUCCESS` that
|
|
1075
|
+
does not say `aab` into **`FAILURE`** with `android_format_not_built: …`, recording
|
|
1076
|
+
`androidFormat: "apk"`, and drops the artifact link. The verdict is read from that error on every
|
|
1077
|
+
read, so a status refresh that races the callback cannot bring back `SUCCESS`. **An old runner
|
|
1078
|
+
never signs with the Wix key:** for a bundle the plane dispatches `DIST_ANDROID_SIGNING=qa-keystore`,
|
|
1079
|
+
and the new run-dist overrides it with `wix-keystore`, derived from `DIST_ANDROID_FORMAT=aab`. A
|
|
1080
|
+
run-dist older than `--aab` therefore signs an ordinary QA APK, never an installable APK carrying
|
|
1081
|
+
the Wix production certificate. The QA key is named rather than left out, because a named key
|
|
1082
|
+
outranks a pool machine's `DIST_ANDROID_USE_WIX_KEYSTORE=1`. A bundle must also report `androidSigningUsed: "wix-keystore"`; one signed
|
|
1083
|
+
with any other key, or that reports none, fails as `android_format_wrong_signer` with its link
|
|
1084
|
+
dropped. Rebuild from a release published on a newer commit. The other direction fails too: an
|
|
1085
|
+
APK job whose pool reports a bundle is `android_format_not_requested`, with its link dropped. A
|
|
1086
|
+
brokered job reads the format from its OWN parameters only, so a machine with
|
|
1087
|
+
`DIST_ANDROID_FORMAT=aab` in its environment no longer turns an APK job into a bundle; the check
|
|
1088
|
+
stays for a runner older than that rule. A later delivery that passes every check (a retry on a
|
|
1089
|
+
clean machine) clears the verdict.
|
|
1090
|
+
|
|
1091
|
+
**Why the Wix production key, and only for a bundle.** There are two Android keys: the committed
|
|
1092
|
+
QA keystore and the Wix production key (WixOne). WixOne is the Play **upload** key every Branded
|
|
1093
|
+
app's bundle carries (`mobile-apps-engine` refuses any other certificate for a Play build), with
|
|
1094
|
+
Play App Signing on, so Google re-signs what users install. An `.aab` therefore signs with it —
|
|
1095
|
+
only its `WixOne` entry of `oneapp-production.keystore`: the alias is pinned for a bundle, whatever
|
|
1096
|
+
alias the pool's signing files name. Because the first upload binds a Play listing to that key for good, a **self-serve** caller
|
|
1097
|
+
gets no bundle at all (`403 android_format_not_allowed`): `--aab` is for internal callers until a
|
|
1098
|
+
Containers store build checks the app, its owner and its package name. A self-serve **APK** with
|
|
1099
|
+
`wix-keystore` keeps `403 android_signing_not_allowed`, unchanged. For the owner, the Play listing is bound to the WixOne upload certificate, so later
|
|
1100
|
+
updates come from Wix builds too; leaving Wix needs an upload-key reset through Play Console
|
|
1101
|
+
support. A pool machine without the key fails the build, naming the paths it searched, rather than
|
|
1102
|
+
signing with another key.
|
|
1103
|
+
|
|
1029
1104
|
### The iOS lane lives in one shared place
|
|
1030
1105
|
|
|
1031
1106
|
`fastlane dist` runs with `cwd = <app>/ios`, so it needs `<app>/ios/fastlane/Fastfile`. That file is
|
|
@@ -1118,6 +1193,9 @@ baking the live asset.
|
|
|
1118
1193
|
or `DIST_ANDROID_USE_WIX_KEYSTORE=1` names one, and a named key wins — `apksigner` replaces the
|
|
1119
1194
|
signature block — because the job record is written at dispatch and would otherwise name a key
|
|
1120
1195
|
that is not on the binary.
|
|
1196
|
+
- **Android, `--aab`** → a **presigned `.aab` download URL** and its sha256, and **no install
|
|
1197
|
+
link**: a bundle is uploaded to Google Play, it does not install on a device. See
|
|
1198
|
+
[the App Bundle section](#an-android-app-bundle-for-google-play----aab).
|
|
1121
1199
|
- **How Android picks up the branding.** `prepare:customer` writes an **xcconfig** for iOS (bundle
|
|
1122
1200
|
id + display name) but on Android only tokens, the icon drawable and a colour XML. Android's
|
|
1123
1201
|
`applicationId` and app label come from the **contract JSON itself**, read at gradle-configure
|
|
@@ -60,9 +60,12 @@ interface Row { // FLAT strings/booleans only — see "Normalize" b
|
|
|
60
60
|
interface State {
|
|
61
61
|
status: "loading" | "ready" | "error";
|
|
62
62
|
items: Row[];
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
errorKind: "" | "offline" | "server" | "signIn" | "permission"; // classifyError(e), never the raw message
|
|
64
|
+
// hide* flags for hiddenPath, derived in shape() — see `himi docs recipe-four-state`
|
|
65
|
+
hideLoading: boolean; notEmpty: boolean;
|
|
66
|
+
hideOfflineError: boolean; hideServerError: boolean;
|
|
67
|
+
hideSignInError: boolean; hidePermissionError: boolean; hideRefreshError: boolean;
|
|
68
|
+
nextCursor: string; failedCursor: string; hideLoadMoreError: boolean; // §5 load-more
|
|
66
69
|
}
|
|
67
70
|
```
|
|
68
71
|
|
|
@@ -143,25 +146,46 @@ nesting to one level: a carousel of carousels is legal and unreadable.
|
|
|
143
146
|
- A binding substitutes only when it is the **entire prop value** — never mid-string
|
|
144
147
|
(`"Total: $item.price"` renders the raw token). Pre-compose strings in the worker.
|
|
145
148
|
|
|
146
|
-
## 4.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
## 4. Loading, error and retry
|
|
150
|
+
|
|
151
|
+
A list loads data, so it ships all four states — the full idiom is
|
|
152
|
+
`himi docs recipe-four-state`. For a list it comes down to this:
|
|
153
|
+
|
|
154
|
+
- **Loading** — a `Skeleton` shaped like the rows (`height` of one row, `count`
|
|
155
|
+
about one screen of them), gated by `hiddenPath: "$service:products.hideLoading"`.
|
|
156
|
+
It shows only while the first load runs with nothing on screen; a pull-to-refresh
|
|
157
|
+
keeps the rows and uses the list's own spinner.
|
|
158
|
+
- **Empty** — an `EmptyState` with a directive action, gated by `notEmpty`.
|
|
159
|
+
- **Error, nothing on screen** — one `EmptyState` per class `classifyError` can
|
|
160
|
+
return (offline, server, sign-in, permission), **no icon**, user-facing copy.
|
|
161
|
+
Offline and server get **Try again** → `tier5.products.retry`; sign-in gets
|
|
162
|
+
**Sign in**; permission gets no action. A list with no sign-in flow maps
|
|
163
|
+
`"signIn"` to `"permission"` — a visitor still gets a 401 or 403 (Bookings
|
|
164
|
+
`searchServices`, above) and a missing node paints a blank screen. The worker's
|
|
165
|
+
`catch` sets `errorKind` from `classifyError(e)` — never `String(e.message)`,
|
|
166
|
+
which shows the user a wire error.
|
|
167
|
+
- **Error, rows on screen** — a pull-to-refresh that fails offline or on the
|
|
168
|
+
server keeps the rows (`shape({ ...s, status: "error", errorKind })`, `items`
|
|
169
|
+
untouched) and shows a `Banner` with **Try again** above them, gated by
|
|
170
|
+
`hideRefreshError`. A sign-in or permission failure clears the rows instead.
|
|
171
|
+
A failed **load-more** has its own retry (§5).
|
|
151
172
|
|
|
152
173
|
```jsonc
|
|
153
|
-
{ "id": "loading",
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
{ "id": "error
|
|
174
|
+
{ "id": "loading",
|
|
175
|
+
"body": { "package": "himalaya", "type": "Skeleton",
|
|
176
|
+
"props": { "height": 72, "cornerRadius": 12, "count": 6 } },
|
|
177
|
+
"modifiers": { "hiddenPath": "$service:products.hideLoading" } },
|
|
178
|
+
{ "id": "error.server",
|
|
179
|
+
"body": { "package": "himalaya", "type": "EmptyState",
|
|
180
|
+
"props": { "title": "Unable to load products",
|
|
181
|
+
"subtitle": "There was a technical issue on our end. Please wait and try again later.",
|
|
182
|
+
"action": { "actionId": "tier5.products.retry" }, "actionLabel": "Try again" } },
|
|
183
|
+
"modifiers": { "hiddenPath": "$service:products.hideServerError" } }
|
|
158
184
|
```
|
|
159
185
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
`
|
|
163
|
-
Design guidance for these states: `himi docs designing-himi-screens` §5, and the
|
|
164
|
-
full state-machine idiom: `himi docs recipe-four-state`.
|
|
186
|
+
(Richer screens gate whole sections with the same `hiddenPath`/`hiddenWhen`
|
|
187
|
+
modifiers — see `apps/himi-owner-lite`.) Design guidance for these states:
|
|
188
|
+
`himi docs designing-himi-screens` §5 ("Loading states", "Error states").
|
|
165
189
|
|
|
166
190
|
## 5. Refresh + load-more gestures (RFC 0008)
|
|
167
191
|
|
|
@@ -193,6 +217,16 @@ native decoders drop anything else without a word:
|
|
|
193
217
|
stashed cursor, **append de-duplicated** rows (`seen` by id), roll `nextCursor`
|
|
194
218
|
from the response (`pagingMetadata.cursors.next ?? ""`), and on failure set
|
|
195
219
|
`nextCursor: ""` but **keep the rows** — a broken page-2 must not blank page-1.
|
|
220
|
+
Stash the cursor that failed (`failedCursor`) and show a small "Couldn't load
|
|
221
|
+
more" row with **Try again** at the end of the list (gated by
|
|
222
|
+
`hideLoadMoreError`), whose action restores `nextCursor = failedCursor` and runs
|
|
223
|
+
`loadMore` again. Do not route it through the refresh `Banner`: that **Try
|
|
224
|
+
again** reloads page 1 and throws away the pages already shown.
|
|
225
|
+
- **Except when page 2 loses access.** A `signIn` or `permission` failure on
|
|
226
|
+
`loadMore` is not retryable and the rows already shown are no longer the
|
|
227
|
+
user's to see: clear them and go to the same sign-in / permission
|
|
228
|
+
`EmptyState` a refresh uses (`load()` in `himi docs recipe-four-state`). Keep
|
|
229
|
+
page 1 and offer the cursor retry only for `offline` and `server`.
|
|
196
230
|
- APIs that don't page (or v1 fallbacks without cursors) simply never set
|
|
197
231
|
`nextCursor` — the gesture props are safe to leave in place.
|
|
198
232
|
|
|
@@ -245,10 +279,11 @@ Mechanics and the prod caveat in full: `himi docs tier-5-add-a-screen`.
|
|
|
245
279
|
no longer needed; prefer the component, which stays accessible and themable.
|
|
246
280
|
- **Declare `wix_api`** in `logicBundle.declaredCapabilities` if the worker touches
|
|
247
281
|
the Wix transport — validate warns (errors pre-push), and the native gate enforces it.
|
|
248
|
-
- **`himi render` shows
|
|
249
|
-
`himi render` boots the worker so `$service:` bindings resolve
|
|
250
|
-
|
|
251
|
-
|
|
282
|
+
- **`himi render --no-mocks` shows the loading frame; `himi render --live` shows REAL
|
|
283
|
+
data.** `himi render` boots the worker so `$service:` bindings resolve. When the app
|
|
284
|
+
has recorded data (`dev/net-mocks.json`, CMS seed rows) it runs `load()` against that
|
|
285
|
+
data by default and shows rows; with none, or with `--no-mocks`, it shows the
|
|
286
|
+
*initial* state — your loading skeleton, not rows. To prove real-data binding **locally, with no VPN and no push**, add `--live`:
|
|
252
287
|
it mints an anonymous visitor token from the app's `clientId` and **awaits the worker's
|
|
253
288
|
real fetches** against live Wix, so the render shows actual rows and catches live-only
|
|
254
289
|
binding bugs (a mistyped `$item.<field>` an empty initial array can't disprove). Use
|
|
@@ -35,6 +35,7 @@ the app's `.agents/skills/himalaya/examples/` (written by `himi init` and `himi
|
|
|
35
35
|
],
|
|
36
36
|
"submitButton": { "id": "save", "body": { "package": "himalaya", "type": "Button",
|
|
37
37
|
"props": { "title": "Create post", "style": "primary", "fullWidth": true,
|
|
38
|
+
"disabledPath": "$service:postForm.cannotSave",
|
|
38
39
|
"action": { "actionId": "tier5.postForm.submit" } } } }
|
|
39
40
|
}
|
|
40
41
|
}
|
|
@@ -91,12 +92,16 @@ interface FormState {
|
|
|
91
92
|
status: "idle" | "saving" | "saved" | "error";
|
|
92
93
|
error: string; // "" unless failed → collapseWhenEmpty
|
|
93
94
|
savedMessage: string; // "" unless saved → collapseWhenEmpty
|
|
95
|
+
cannotSave: boolean; // true after a permission failure → disabledPath
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
const svc = defineService<FormState, {
|
|
97
99
|
submit: (s: FormState, values?: Record<string, unknown>) => Promise<FormState>;
|
|
98
100
|
}>("postForm", {
|
|
99
|
-
state: { title: "", body: "", status: "idle", error: "", savedMessage: "" },
|
|
101
|
+
state: { title: "", body: "", status: "idle", error: "", savedMessage: "", cannotSave: false },
|
|
102
|
+
// Outcome keys are per-attempt: a cold boot must not restore a stale error, or a submit
|
|
103
|
+
// disabled by a 403 the user has since been granted past.
|
|
104
|
+
notHydrated: ["status", "error", "savedMessage", "cannotSave"],
|
|
100
105
|
actions: {
|
|
101
106
|
submit: async (s, values) => {
|
|
102
107
|
const title = String(values?.title ?? "").trim();
|
|
@@ -106,17 +111,38 @@ const svc = defineService<FormState, {
|
|
|
106
111
|
await createDraftPost({ title, richContent: /* … */ } as WixDraftPostWrite);
|
|
107
112
|
return { ...s, title, body, status: "saved", error: "", savedMessage: "Draft created." };
|
|
108
113
|
} catch (e) {
|
|
109
|
-
|
|
114
|
+
// Keep what the user typed (echo it back into the prefill), and say what
|
|
115
|
+
// happened in their words — never e.message.
|
|
116
|
+
const kind = classifyError(e); // from himi docs recipe-four-state
|
|
117
|
+
return { ...s, title, body, status: "error", error: SAVE_FAILED[kind], savedMessage: "",
|
|
118
|
+
cannotSave: kind === "permission" }; // bound to the submit's disabledPath
|
|
110
119
|
}
|
|
111
120
|
},
|
|
112
121
|
},
|
|
113
122
|
});
|
|
114
123
|
export { svc };
|
|
124
|
+
|
|
125
|
+
// One sentence per class of `classifyError` (himi docs recipe-four-state).
|
|
126
|
+
const SAVE_FAILED: Record<string, string> = {
|
|
127
|
+
offline: "You're offline, so nothing was saved. Try again when you reconnect.",
|
|
128
|
+
server: "Couldn't save your changes. Try again.",
|
|
129
|
+
signIn: "Your session has ended. Sign in, then save again.",
|
|
130
|
+
permission: "You don't have permission to save this.",
|
|
131
|
+
};
|
|
115
132
|
```
|
|
116
133
|
|
|
117
|
-
- **
|
|
118
|
-
`
|
|
119
|
-
|
|
134
|
+
- **Field rules stay on the fields.** Required and format checks belong in the
|
|
135
|
+
`validation` block above: the renderer holds the submit and shows each message
|
|
136
|
+
beside its field. The worker's `if (!title)` is only a guard; a cross-field or
|
|
137
|
+
server answer about ONE field goes to that field through `text.errorPath`.
|
|
138
|
+
- **A failed save is an action error.** It shows near the submit button (the
|
|
139
|
+
`err` line below), in user-facing words picked by class, and it **keeps what the
|
|
140
|
+
user typed** — the catch returns `title`/`body` into the prefill so nothing is
|
|
141
|
+
lost. The submit button is the retry: there is no separate **Try again**.
|
|
142
|
+
A **sign-in** failure says to sign in and save again (the submit works once the
|
|
143
|
+
user is signed in). A **permission** failure says so and sets `cannotSave`,
|
|
144
|
+
which the submit button binds through `disabledPath`, so it offers no retry
|
|
145
|
+
that would fail the same way.
|
|
120
146
|
- The write goes through a `@himalaya/wix-<domain>` client (declare `wix_api`!).
|
|
121
147
|
**Wrap the entity envelope the API expects** (`{ draftPost: {...} }` handled by
|
|
122
148
|
the SDK; raw `wixApi.call` writes must wrap it themselves) — an unwrapped body
|
|
@@ -125,7 +151,9 @@ export { svc };
|
|
|
125
151
|
|
|
126
152
|
## 3. Feedback: outcome strings + a gated follow-up action
|
|
127
153
|
|
|
128
|
-
|
|
154
|
+
Directly below the `Form` — its submit button is the form's last element — the
|
|
155
|
+
standard outcome row, each line rendering only in its state, next to the action it
|
|
156
|
+
reports on. Bind the submit's `disabledPath` to `cannotSave`:
|
|
129
157
|
|
|
130
158
|
```jsonc
|
|
131
159
|
{ "id": "err", "…Text": { "text": "$service:postForm.error", "color": "danger",
|
|
@@ -163,12 +191,16 @@ shape (heading swap, `savedScreenId`, back-navigation).
|
|
|
163
191
|
"$service:<svc>.busy"` — `disabledPath` wants a bool, not the `status` string.
|
|
164
192
|
It only ever ADDS a reason to be disabled: a bound `false` never re-enables a
|
|
165
193
|
submit the form's own validation is holding.
|
|
194
|
+
- **An edit form loads data first.** Its `load` is a read like any other: a
|
|
195
|
+
`Skeleton` shaped like the fields while it runs, and a classified error state
|
|
196
|
+
with **Try again** if it fails (`himi docs recipe-four-state`). A create form
|
|
197
|
+
with nothing to prefill renders at once and needs no loading state.
|
|
166
198
|
- **`himi render` shows the initial (empty) form** — prefill bindings resolve to
|
|
167
199
|
initial state; a real submit needs the pushed preview or a device drive.
|
|
168
200
|
|
|
169
201
|
## See also
|
|
170
202
|
|
|
171
|
-
- `himi docs recipe-four-state` — the
|
|
203
|
+
- `himi docs recipe-four-state` — the loading, classified error and retry idiom this recipe leans on.
|
|
172
204
|
- `himi docs recipe-dynamic-list` — the list the created entity lands in.
|
|
173
205
|
- `himi components Form` / `FormField` / `TextField` / `Toggle` / `Picker`.
|
|
174
206
|
- `.agents/skills/himalaya/examples/form-submit.*` (written into the app by `himi init` / `himi agent-init`) — this recipe, runnable.
|