@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
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
|
}
|
|
@@ -185850,7 +185850,8 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
|
185850
185850
|
"from-base44-cli",
|
|
185851
185851
|
"no-browser",
|
|
185852
185852
|
"refresh-wix",
|
|
185853
|
-
"empty-app-ok"
|
|
185853
|
+
"empty-app-ok",
|
|
185854
|
+
"aab"
|
|
185854
185855
|
]);
|
|
185855
185856
|
var SHELL_ENUM = SHELL_NAMES2.join("|");
|
|
185856
185857
|
var SWITCH_FLAGS = new Set([...BOOLEAN_FLAGS].filter((name) => name !== "deep"));
|
|
@@ -186355,7 +186356,7 @@ var USAGE2 = `himi \u2014 the Himalaya authoring CLI (remote dev loop)
|
|
|
186355
186356
|
minBinaryVersion \u2014 all named in the output.
|
|
186356
186357
|
himi dist [--app <a>] [--platform ios,android] [--release <id>] [--allow-draft] [--target <t>]
|
|
186357
186358
|
[--shell branded-lite|owner-lite|brandy|base44|player|demo-app] [--signing wix-managed|byo]
|
|
186358
|
-
[--android-signing qa-keystore|wix-keystore|debug]
|
|
186359
|
+
[--android-signing qa-keystore|wix-keystore|debug] [--aab]
|
|
186359
186360
|
[--borrow-ios-id <bundleId>] [--no-wait] [--force] [--plan]
|
|
186360
186361
|
build an INSTALLABLE QA binary (signed IPA / APK) on
|
|
186361
186362
|
the Wix Job Service Mac pool and hand back an install
|
|
@@ -186390,6 +186391,13 @@ var USAGE2 = `himi \u2014 the Himalaya authoring CLI (remote dev loop)
|
|
|
186390
186391
|
wix-keystore (the pool's own Wix key, when the machine
|
|
186391
186392
|
carries one \u2014 fails loudly if not), or debug (the old
|
|
186392
186393
|
per-machine key; QA must uninstall first).
|
|
186394
|
+
--aab builds an Android App Bundle (.aab) instead of
|
|
186395
|
+
an APK, to upload to Google Play yourself: you get a
|
|
186396
|
+
download link and its sha256, and no install link or
|
|
186397
|
+
QR (a bundle does not install on a device). Android
|
|
186398
|
+
only. Signed with the Wix production key (wix-keystore,
|
|
186399
|
+
the upload key Branded apps use); any other
|
|
186400
|
+
--android-signing is refused. The default stays an APK.
|
|
186393
186401
|
--borrow-ios-id is an
|
|
186394
186402
|
iOS-only interim: sign under an internal enterprise id the
|
|
186395
186403
|
pool already has a profile for (e.g. ent.wix.time.dev); the
|
|
@@ -186836,7 +186844,7 @@ var COMMANDS2 = [
|
|
|
186836
186844
|
{ name: "patch", summary: 'change ONE item of a published app \u2014 a screen descriptor, a Tier-5 bundle, tokens, or the app-config baseline \u2014 from a RAW FILE, with no content package and no build. The low-level door: it does not lint your package, because there is no package here; `himi push --only <selector>` is the same edit with the full build + lint gate and is what to reach for when the package is in hand. Derives a new release from the app\'s current LIVE release, or from `--from-release <id>`. An explicit `--draft` or `--live` is REQUIRED \u2014 a patch has no default blast radius \u2014 and `--notes` is required with `--live` on a screen or app-config patch, because off-repo `gitSha` is "dev" and the ledger notes are then the only durable record of bytes that correspond to no commit. A branch does not merge: activating one derived from an older release reverts every item the patch does not move, so that case is refused until `--accept-regressions`. A bundle patch must repoint a screen (`--for-screen`) or the bytes land under a manifest key nothing reads. It cannot ADD a screen (screens are git-only, RFC 0012), never claims a slug \u2014 the app must already be published \u2014 and carries no attestation, source archive, assets, icon or minBinaryVersion, each named in the output.', required: "screen <id> --file <descriptor.json> [--bundle <sha256|none>] | bundle --file <bundle.js> (--for-screen <id>[,<id>] | --no-rebind) | tokens --file <tokens.json> | app-config --file <app-config.json> ; (--draft|--live) --app <a> [--from-release <id>] [--accept-regressions] [--label <n>] [--notes <t>] [--force] [--allow-binary-gap] [--target <t>] (--control-token)" },
|
|
186837
186845
|
{ name: "flags", summary: "read and change the app's OPERATIONAL flags (RFC 0013) \u2014 `list` prints what the DEVICE door serves, `set <key> --definition` creates or replaces one, `ramp <key>` moves its rollout share or kills it, `rm <key>` deletes it and echoes the definition back so the removal is reversible. Every write is followed by a read of the same door the device uses, and the answer is reported as `servedNow`: flags are stored in the app's git-committed flags.json, so a control plane with no checkout accepts a write it will never serve.", required: "list|set|ramp|rm [<key>] [--definition <json|@file>] [--percent 0-100] [--enabled true|false] [--app <a>] (--control-token)" },
|
|
186838
186846
|
{ name: "visibility", summary: "read (no value) or set whether the public registry (/v1/apps + the Himi Player picker) LISTS the app. `public` lists it for everyone; `unlisted` hides it from listings while direct reads by app id \u2014 deep links, previews \u2014 keep working. App from --app or the content dir. Setting it needs no republish.", required: "[public|unlisted] [--app <a>] [--content <dir>] (--control-token)" },
|
|
186839
|
-
{ name: "dist", summary: "build an INSTALLABLE QA binary (signed IPA / APK) on the Wix Job Service Mac pool and return an install link \u2014 the serve plane brokers the job, the pool builds it. WAITS for the build by default (~4 min) and prints the install link; `--no-wait` returns a jobId immediately instead. Omitting `--release` builds whatever is LIVE for the app. A named draft is refused unless `--allow-draft` makes that exceptional build explicit. `status <jobId>` / `watch <jobId>` / `list --app <a>` / `cancel <jobId>` inspect and stop builds (`watch` reports up front whether the job mints its own signing identity or borrows one, so a doomed iOS build is cancellable in seconds rather than after the archive). Off-repo, name a target \u2014 `--target wix` for a self-serve Wix login or `--target bo` for the internal employee path (the in-repo default is a local dev plane that isn't reachable off-repo); self-serve callers may build their OWN apps under a tighter budget (2 in flight / 5 per 24h), while a manage-only owner-role credential is refused. `--shell` picks the native host to sign into (branded-lite | owner-lite | brandy | base44 | player | demo-app) and defaults to the content package's himalaya.content.json `shell` (templates carry the one they were written for), else branded-lite. The per-app shells ADD the release's declared capabilities to the binary, while Himi Player and demo-app refuse a release declaring one they lack; `--plan` previews it without building; `brandy` is the site shell \u2014 the customer's own website as native-framed tabs. `--signing` defaults to `wix-managed` \u2014 the Wix Apple account mints a per-app signing identity for the build, so the caller provisions no Apple credentials of their own; `--signing byo --site <id>` reuses an existing customer ad-hoc profile and certificate for iOS; requires an owned site and a completed personal signing binding. `--android-signing` is the Android counterpart and a SEPARATE axis (a keystore, not an Apple account): `qa-keystore` (default \u2014 the repo's committed QA key, identical on every pool machine, so a build installs over the previous one), `wix-keystore` (the Wix key the pool machine carries; the build FAILS naming the paths searched if it carries none, rather than quietly signing with something else), or `debug` (the old per-machine key \u2014 QA must uninstall any previous build first). NOT the normal way to ship: screens, Tier-5 bundles, tokens and app-config all go over the air with `himi push` in seconds \u2014 a binary trip is only needed for a NATIVE delta (a new SDUI component, a new declared capability/entitlement, a new bundle id, or a minBinaryVersion floor bump). Refuses to build an app whose launcher icon is missing, a placeholder, or one a store would reject \u2014 the one thing a binary carries that no later OTA push can fix; `--force` builds anyway", required: "[--app <a>] [--platform ios,android] [--release <id>] [--allow-draft] [--target <t>] [--shell branded-lite|owner-lite|brandy|base44|player|demo-app] [--signing wix-managed|byo] [--site <id>] [--android-signing qa-keystore|wix-keystore|debug] [--borrow-ios-id <bundleId>] [--no-wait] [--force] [--plan] | status <jobId> [--target <t>] | watch <jobId> [--target <t>] [--interval <ms>] | list --app <a> [--target <t>] | cancel <jobId> [--target <t>]" },
|
|
186847
|
+
{ name: "dist", summary: "build an INSTALLABLE QA binary (signed IPA / APK) on the Wix Job Service Mac pool and return an install link \u2014 the serve plane brokers the job, the pool builds it. WAITS for the build by default (~4 min) and prints the install link; `--no-wait` returns a jobId immediately instead. Omitting `--release` builds whatever is LIVE for the app. A named draft is refused unless `--allow-draft` makes that exceptional build explicit. `status <jobId>` / `watch <jobId>` / `list --app <a>` / `cancel <jobId>` inspect and stop builds (`watch` reports up front whether the job mints its own signing identity or borrows one, so a doomed iOS build is cancellable in seconds rather than after the archive). Off-repo, name a target \u2014 `--target wix` for a self-serve Wix login or `--target bo` for the internal employee path (the in-repo default is a local dev plane that isn't reachable off-repo); self-serve callers may build their OWN apps under a tighter budget (2 in flight / 5 per 24h), while a manage-only owner-role credential is refused. `--shell` picks the native host to sign into (branded-lite | owner-lite | brandy | base44 | player | demo-app) and defaults to the content package's himalaya.content.json `shell` (templates carry the one they were written for), else branded-lite. The per-app shells ADD the release's declared capabilities to the binary, while Himi Player and demo-app refuse a release declaring one they lack; `--plan` previews it without building; `brandy` is the site shell \u2014 the customer's own website as native-framed tabs. `--signing` defaults to `wix-managed` \u2014 the Wix Apple account mints a per-app signing identity for the build, so the caller provisions no Apple credentials of their own; `--signing byo --site <id>` reuses an existing customer ad-hoc profile and certificate for iOS; requires an owned site and a completed personal signing binding. `--android-signing` is the Android counterpart and a SEPARATE axis (a keystore, not an Apple account): `qa-keystore` (default \u2014 the repo's committed QA key, identical on every pool machine, so a build installs over the previous one), `wix-keystore` (the Wix key the pool machine carries; the build FAILS naming the paths searched if it carries none, rather than quietly signing with something else), or `debug` (the old per-machine key \u2014 QA must uninstall any previous build first). `--aab` builds an Android App Bundle (.aab) for the owner to upload to Google Play instead of an APK \u2014 a download link and sha256, no install link (a bundle does not install on a device); Android only, signed with the Wix production key (`wix-keystore`, the upload key Branded apps use) \u2014 any other `--android-signing` is refused, and it is limited to internal callers (a self-serve caller gets 403 android_format_not_allowed). NOT the normal way to ship: screens, Tier-5 bundles, tokens and app-config all go over the air with `himi push` in seconds \u2014 a binary trip is only needed for a NATIVE delta (a new SDUI component, a new declared capability/entitlement, a new bundle id, or a minBinaryVersion floor bump). Refuses to build an app whose launcher icon is missing, a placeholder, or one a store would reject \u2014 the one thing a binary carries that no later OTA push can fix; `--force` builds anyway", required: "[--app <a>] [--platform ios,android] [--release <id>] [--allow-draft] [--target <t>] [--shell branded-lite|owner-lite|brandy|base44|player|demo-app] [--signing wix-managed|byo] [--site <id>] [--android-signing qa-keystore|wix-keystore|debug] [--aab] [--borrow-ios-id <bundleId>] [--no-wait] [--force] [--plan] | status <jobId> [--target <t>] | watch <jobId> [--target <t>] [--interval <ms>] | list --app <a> [--target <t>] | cancel <jobId> [--target <t>]" },
|
|
186840
186848
|
{ name: "testflight", summary: 'build, sign and upload an app to TestFlight (internal testers \u2014 no Beta App Review), then WAIT until App Store Connect finishes processing it. Sugar for `store upload --track testflight`. Prompts first: a build number is consumed permanently on a real Apple account and cannot be reused \u2014 `--yes` for CI, `--dry-run` to see the plan without touching anything. The wait is the real check, not a nicety: fastlane prints "Successfully uploaded" for a binary App Store Connect then rejects in processing, and a rejected binary leaves NO build record at all (the reason is emailed). Public App Store review is deliberately NOT automatable here \u2014 `--track appstore` is refused; use /submit-ios-app when you mean to go public', required: "<app> [--track testflight|internal] [--profile <name>] [--yes] [--dry-run] [--skip-build] [--no-wait] [--json]" },
|
|
186841
186849
|
{ name: "store", summary: "the App Store Connect group behind `testflight`. `preflight <app>` checks readiness offline (icon, entitlements, encryption flag, version, store.config ids, listing + review record); `upload <app>` is what `testflight` runs; `status <app>` reads live build + processing state. `--profile` selects WHICH App Store Connect account: an app built for a Wix customer ships under the CUSTOMER's Apple team (Apple guideline 4.2.6 forbids an app-generation service submitting on a client's behalf), declared per-app as ios.credentialProfile in store.config.yaml and resolved from ~/.himalaya/store-credentials/<name>.json \u2014 the .p8 itself never enters the repo. `iap draft|plan|apply|status|connect|screenshots` registers an app owner's digital products as in-app purchases (non-consumables, or consumable price tiers): draft reads the Wix catalog and writes the manifest, plan and status only read, apply is the one App Store mutation boundary, connect readies the Wix site to sell them (purchases app, In-App Purchase key, appleIAP provider, product mappings; --preview-tag routes only its Serve calls to a deploy preview), screenshots renders each product's purchase screen from the app (--content) as a bound iPhone shows it to a signed-in member and names the 1290\xD72796 PNG as the item's App Review screenshot, and none of them builds, signs or needs a checkout (`himi docs iap-products`)", required: "preflight <app> [--profile <n>] | upload <app> [--track <t>] [--profile <n>] [--yes] [--dry-run] | status <app> [--profile <n>] | iap draft --site <msid> --profile <n> --apple-app <id> --bundle <id> --base-territory <t> --out <f> [--territories <a,b>] [--locale <l>] [--mode per-item|tiers] [--merge <f>] [--report <f>] [--json] | iap plan --manifest <f> --profile <n> --out <f> [--json] | iap apply --plan <f> --profile <n> --out <f> [--yes] [--limit <n>] [--json] | iap status --manifest <f> --profile <n> [--out <f>] [--json] | iap connect --manifest <f> --profile <n> [--iap-key <AuthKey.p8> --iap-key-id <id>] [--content <dir>] [--preview-tag dp-<tag>] [--dry-run] [--yes] [--out <f>] [--json] | iap screenshots --manifest <f> --content <dir> [--profile <n>] [--out-dir <d>] [--only <id,\u2026>] [--force] [--dry-run] [--json]" },
|
|
186842
186850
|
{ name: "guide", summary: "print the Himalaya overview + headless dev loop + available topics (offline)", required: "[--json]" },
|
|
@@ -193474,6 +193482,11 @@ ${label3}`);
|
|
|
193474
193482
|
};
|
|
193475
193483
|
render(" Himi Player", player);
|
|
193476
193484
|
if (job.status !== "SUCCESS") return;
|
|
193485
|
+
if (job.platform === "android" && job.androidFormat === "aab") {
|
|
193486
|
+
io2.log(" android bundle");
|
|
193487
|
+
io2.log(" QR omitted (an .aab is uploaded to Google Play; it does not install on a device)");
|
|
193488
|
+
return;
|
|
193489
|
+
}
|
|
193477
193490
|
const shortUrl = `${origin.replace(/\/+$/, "")}/v1/dist/${encodeURIComponent(jobId)}/install`;
|
|
193478
193491
|
const artifact = links[String(job.platform)];
|
|
193479
193492
|
const installUrl = typeof artifact?.url === "string" ? artifact.url : void 0;
|
|
@@ -193753,7 +193766,10 @@ ${label3}`);
|
|
|
193753
193766
|
// keystore on Android" — the normal combination — with one command.
|
|
193754
193767
|
// flags["android-signing"], NOT flags.androidSigning: parseFlags does no kebab→camel
|
|
193755
193768
|
// conversion, so the camel form reads undefined and the flag is a silent no-op.
|
|
193756
|
-
...str10(flags["android-signing"]) ? { androidSigning: str10(flags["android-signing"]) } : {}
|
|
193769
|
+
...str10(flags["android-signing"]) ? { androidSigning: str10(flags["android-signing"]) } : {},
|
|
193770
|
+
// A Play bundle instead of an APK. The plane validates it (Android only, link destination,
|
|
193771
|
+
// a stable key) so every caller of the route gets the same refusal.
|
|
193772
|
+
...flags.aab === true ? { androidFormat: "aab" } : {}
|
|
193757
193773
|
}, { wixAuthorization });
|
|
193758
193774
|
} catch (e2) {
|
|
193759
193775
|
return distUnreachable(e2);
|
|
@@ -193830,10 +193846,11 @@ ${label3}`);
|
|
|
193830
193846
|
done.push(rec);
|
|
193831
193847
|
if (!flags.json) {
|
|
193832
193848
|
const artifact = rec.artifact;
|
|
193849
|
+
const isAab = rec.platform === "android" && rec.androidFormat === "aab";
|
|
193833
193850
|
const installUrl = rec.platform === "ios" ? artifact?.installPageUrl : artifact?.downloadUrl ?? artifact?.installLink;
|
|
193834
193851
|
const handoff = rec.customerAppStore;
|
|
193835
193852
|
const handoffText = typeof handoff?.status === "string" ? ` testflight ${handoff.status}${handoff.error ? ` (${handoff.error})` : ""}` : "";
|
|
193836
|
-
io2.log(` ${rec.platform} ${rec.status}${handoffText}${installUrl ? ` install \u2192 ${installUrl}` : ""}`);
|
|
193853
|
+
io2.log(` ${rec.platform} ${rec.status}${handoffText}${installUrl ? isAab ? ` .aab for Google Play (not installable) \u2192 ${installUrl}` : ` install \u2192 ${installUrl}` : ""}`);
|
|
193837
193854
|
if (artifact?.caveat) io2.log(` \u26A0 ${artifact.caveat}`);
|
|
193838
193855
|
}
|
|
193839
193856
|
}
|
|
@@ -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": {
|