@wix/himalaya-cli 0.1474.0 → 0.1476.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/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +5 -0
- package/dist/cli.mjs +520 -86
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/component-reference.json +4 -83
- package/guides/component-reference.md +10 -21
- package/guides/designing-himi-screens.md +9 -6
- package/guides/index.json +1 -1
- package/guides/qa-builds.md +47 -18
- package/guides/recipe-dynamic-list.md +10 -9
- package/guides/recipe-form-submit.md +12 -13
- package/guides/recipe-four-state.md +61 -21
- package/guides/recipe-search.md +11 -7
- package/guides/tier-5-add-a-screen.md +8 -2
- package/package.json +2 -2
|
Binary file
|
|
@@ -14,10 +14,6 @@
|
|
|
14
14
|
"SelectionControl",
|
|
15
15
|
"ActivityIndicator",
|
|
16
16
|
"Menu",
|
|
17
|
-
"Tooltip",
|
|
18
|
-
"Popover",
|
|
19
|
-
"ChipsInput",
|
|
20
|
-
"OtpInput",
|
|
21
17
|
"Video",
|
|
22
18
|
"PdfView",
|
|
23
19
|
"MediaPlayer",
|
|
@@ -805,21 +801,7 @@
|
|
|
805
801
|
"category": "Overlays & menus",
|
|
806
802
|
"propsType": "TooltipProps",
|
|
807
803
|
"bindings": [],
|
|
808
|
-
"
|
|
809
|
-
"props": [
|
|
810
|
-
"edge",
|
|
811
|
-
"trigger",
|
|
812
|
-
"onDismiss"
|
|
813
|
-
],
|
|
814
|
-
"platforms": [
|
|
815
|
-
"ios",
|
|
816
|
-
"android",
|
|
817
|
-
"web"
|
|
818
|
-
],
|
|
819
|
-
"severity": "category",
|
|
820
|
-
"note": "Not an overlay — renders always-expanded inline; `edge`, `trigger` and dismissal are inert."
|
|
821
|
-
},
|
|
822
|
-
"propsSource": "export interface TooltipProps {\n anchor?: Component;\n text?: string;\n /** Present → rich tooltip. */\n title?: string;\n edge?: \"top\" | \"bottom\" | \"leading\" | \"trailing\" | \"auto\";\n trigger?: \"tap\" | \"long_press\" | \"auto\";\n onDismiss?: Action;\n}"
|
|
804
|
+
"propsSource": "export interface TooltipProps {\n anchor?: Component;\n text?: string;\n /** Present → rich tooltip. */\n title?: string;\n /** The side of the anchor the bubble floats on; `auto` / absent → `top`. Flips to the opposite\n * side when there is no room, as every renderer does. */\n edge?: \"top\" | \"bottom\" | \"leading\" | \"trailing\" | \"auto\";\n /** What opens the (otherwise hidden) bubble. `auto` / absent → a long-press on touch, hover and\n * keyboard focus on the web. */\n trigger?: \"tap\" | \"long_press\" | \"auto\";\n /** Fired once each time an open tooltip closes (outside tap, Escape, timeout). */\n onDismiss?: Action;\n}"
|
|
823
805
|
},
|
|
824
806
|
{
|
|
825
807
|
"name": "Popover",
|
|
@@ -828,16 +810,6 @@
|
|
|
828
810
|
"bindings": [
|
|
829
811
|
"presentedPath"
|
|
830
812
|
],
|
|
831
|
-
"notImplemented": {
|
|
832
|
-
"props": [
|
|
833
|
-
"edge"
|
|
834
|
-
],
|
|
835
|
-
"platforms": [
|
|
836
|
-
"ios"
|
|
837
|
-
],
|
|
838
|
-
"severity": "category",
|
|
839
|
-
"note": "The bubble is drawn in flow under its anchor; `edge` placement is not implemented."
|
|
840
|
-
},
|
|
841
813
|
"propsSource": "export interface PopoverProps {\n anchor?: Component;\n content?: Component;\n edge?: \"top\" | \"bottom\" | \"leading\" | \"trailing\" | \"auto\";\n presented?: boolean;\n presentedPath?: string;\n onDismiss?: Action;\n /** The overlay traps focus and hides the rest of the tree from AT while\n * presented (iOS `accessibilityViewIsModal`, Android `isTraversalGroup`, ARIA\n * `aria-modal`). TRI-STATE: absent takes the Popover's default — MODAL,\n * from the one shared overlay default table — and an explicit `false` opts out. Applies to a\n * popover presented through `presented` / `presentedPath`; one with neither is an\n * inline callout, and never modal. */\n modal?: boolean;\n}"
|
|
842
814
|
},
|
|
843
815
|
{
|
|
@@ -862,10 +834,7 @@
|
|
|
862
834
|
"callback": "onAdd",
|
|
863
835
|
"key": "value",
|
|
864
836
|
"encoding": "string",
|
|
865
|
-
"
|
|
866
|
-
"web"
|
|
867
|
-
],
|
|
868
|
-
"note": "On web, Enter dispatches the trimmed new chip value. Native onAdd remains listed as not implemented in the component reference."
|
|
837
|
+
"note": "The new token, trimmed and non-empty, committed by Return or a comma (each segment before a comma is one token; the text after the last stays in the field). The field clears; the chips themselves stay the worker's, so a worker that wants the token shown appends it to `valuesPath`."
|
|
869
838
|
},
|
|
870
839
|
{
|
|
871
840
|
"callback": "onRemove",
|
|
@@ -884,17 +853,6 @@
|
|
|
884
853
|
]
|
|
885
854
|
}
|
|
886
855
|
],
|
|
887
|
-
"notImplemented": {
|
|
888
|
-
"props": [
|
|
889
|
-
"onAdd"
|
|
890
|
-
],
|
|
891
|
-
"platforms": [
|
|
892
|
-
"ios",
|
|
893
|
-
"android"
|
|
894
|
-
],
|
|
895
|
-
"severity": "category",
|
|
896
|
-
"note": "`onAdd` never dispatches."
|
|
897
|
-
},
|
|
898
856
|
"propsSource": "export interface ChipsInputProps {\n values?: string[];\n /** \"$service:\" binding → string[]. */\n valuesPath?: string;\n placeholder?: string;\n onAdd?: Action;\n onRemove?: Action;\n disabled?: boolean;\n}"
|
|
899
857
|
},
|
|
900
858
|
{
|
|
@@ -1193,52 +1151,15 @@
|
|
|
1193
1151
|
"callback": "onChange",
|
|
1194
1152
|
"key": "value",
|
|
1195
1153
|
"encoding": "string",
|
|
1196
|
-
"note": "The assembled code.
|
|
1197
|
-
"divergences": [
|
|
1198
|
-
{
|
|
1199
|
-
"arms": [
|
|
1200
|
-
"web",
|
|
1201
|
-
"ios",
|
|
1202
|
-
"android"
|
|
1203
|
-
],
|
|
1204
|
-
"kind": "no-payload",
|
|
1205
|
-
"actual": "renders the digit boxes without wiring onChange — the control dispatches nothing.",
|
|
1206
|
-
"reason": "Unimplemented rather than divergent, but it must be declared: leaving OtpInput out of the contract is what let the gate whitelist RN's payload without checking it."
|
|
1207
|
-
}
|
|
1208
|
-
]
|
|
1154
|
+
"note": "The assembled code, on every edit (whitespace dropped, capped at `length`). Every arm dispatches it; an edit that leaves the code unchanged dispatches nothing."
|
|
1209
1155
|
},
|
|
1210
1156
|
{
|
|
1211
1157
|
"callback": "onComplete",
|
|
1212
1158
|
"key": "value",
|
|
1213
1159
|
"encoding": "string",
|
|
1214
|
-
"note": "Fires once the last box
|
|
1215
|
-
"divergences": [
|
|
1216
|
-
{
|
|
1217
|
-
"arms": [
|
|
1218
|
-
"web",
|
|
1219
|
-
"ios",
|
|
1220
|
-
"android"
|
|
1221
|
-
],
|
|
1222
|
-
"kind": "no-payload",
|
|
1223
|
-
"actual": "renders the digit boxes without wiring onChange — the control dispatches nothing.",
|
|
1224
|
-
"reason": "Unimplemented rather than divergent, but it must be declared: leaving OtpInput out of the contract is what let the gate whitelist RN's payload without checking it."
|
|
1225
|
-
}
|
|
1226
|
-
]
|
|
1160
|
+
"note": "Fires once, on the edit that fills the last box (the transition into `value.length == length`), carrying the same assembled code as onChange — never again while the code stays full. Declared separately because it is a second public callback — leaving it out left one outside the contract."
|
|
1227
1161
|
}
|
|
1228
1162
|
],
|
|
1229
|
-
"notImplemented": {
|
|
1230
|
-
"props": [
|
|
1231
|
-
"onComplete",
|
|
1232
|
-
"onChange"
|
|
1233
|
-
],
|
|
1234
|
-
"platforms": [
|
|
1235
|
-
"ios",
|
|
1236
|
-
"android",
|
|
1237
|
-
"web"
|
|
1238
|
-
],
|
|
1239
|
-
"severity": "category",
|
|
1240
|
-
"note": "Neither `onChange` nor `onComplete` dispatches."
|
|
1241
|
-
},
|
|
1242
1163
|
"propsSource": "export interface OtpInputProps {\n length?: number;\n /** LITERAL code. A `$service:` sentinel here paints the raw token into the\n * boxes — bind `valuePath` instead. */\n value?: string;\n /** `$service:`/`$item.` binding → the live code (overrides `value`), so a\n * worker that clears it after submit empties the boxes. */\n valuePath?: string;\n onChange?: Action;\n onComplete?: Action;\n secure?: boolean;\n}"
|
|
1243
1164
|
},
|
|
1244
1165
|
{
|
|
@@ -119,9 +119,9 @@ See also **[Modifiers](#modifiers)** (the `modifiers` block) and **[Actions](#ac
|
|
|
119
119
|
|
|
120
120
|
These props are accepted by the schema and pass validation, but the named platform's renderer never reads them — they silently do nothing (no warning, no log). Prefer a prop NOT listed here, or drive the behaviour from a worker.
|
|
121
121
|
|
|
122
|
-
**Scope:
|
|
122
|
+
**Scope: 14 of 87 components, all of them android / web / ios-only** — every other component/platform pair reads every prop it decodes. The list splits in two, and the halves mean different things:
|
|
123
123
|
|
|
124
|
-
- **`category` (
|
|
124
|
+
- **`category` (3: `Menu`, `MediaPlayer`, `TimeGrid`)** — the prop changes the component's *kind*. These genuinely are **not implemented**: an overlay renders always-visible inline, so the component does not do the job its name implies. Check the row's note for a working alternative.
|
|
125
125
|
- **`styling` (11: `Text`, `Icon`, `Carousel`, `Pager`, `Snackbar`, `WebView`, `Slider`, `SelectionControl`, `ActivityIndicator`, `Video`, `PdfView`)** — the component itself **works**; only the listed prop is inert. `Slider` on iOS is a working slider whose `debounceMs` does not throttle — not a broken `Slider`. Use the component; just do not depend on that one prop.
|
|
126
126
|
|
|
127
127
|
| Component | Unread props | Platform | Kind | Note |
|
|
@@ -137,10 +137,6 @@ These props are accepted by the schema and pass validation, but the named platfo
|
|
|
137
137
|
| `ActivityIndicator` | `ring` | ios, android, web | styling | The `ring` style is never selected (determinate vs indeterminate follows `value`). |
|
|
138
138
|
| `Menu` | `itemsPath` | ios | category | `itemsPath` does not bind items. |
|
|
139
139
|
| `Menu` | `style`, `itemsPath` | android, web | category | `style: "action_sheet"` renders as a dropdown. (`style: "context"` is honoured: it opens on a long-press, or right-click on web, with its `preview`.) `itemsPath` does not bind items. |
|
|
140
|
-
| `Tooltip` | `edge`, `trigger`, `onDismiss` | ios, android, web | category | Not an overlay — renders always-expanded inline; `edge`, `trigger` and dismissal are inert. |
|
|
141
|
-
| `Popover` | `edge` | ios | category | The bubble is drawn in flow under its anchor; `edge` placement is not implemented. |
|
|
142
|
-
| `ChipsInput` | `onAdd` | ios, android | category | `onAdd` never dispatches. |
|
|
143
|
-
| `OtpInput` | `onComplete`, `onChange` | ios, android, web | category | Neither `onChange` nor `onComplete` dispatches. |
|
|
144
140
|
| `Video` | `poster` | ios | styling | `poster` is not shown. |
|
|
145
141
|
| `PdfView` | `showPageControls`, `zoomable`, `allowShare` | web | styling | Interactive PDF.js canvas rendering honours the reader’s page, zoom and export controls. Static previews and PDF.js loading/rendering failures use a native browser-viewer fallback. In that fallback, hiding page or zoom controls is only a viewer hint and cannot reliably hide browser chrome; allowShare hides the reader’s export controls but cannot prevent browser save/print. These props are UI controls, not document access restrictions. |
|
|
146
142
|
| `MediaPlayer` | `autoplayVisibilityThreshold`, `preload`, `playbackRates`, `showQueue`, `showChapters`, `showTranscript` | ios | category | `autoplayVisibilityThreshold` is ignored (whenVisible = once on appear). `preload` is ignored. `playbackRates` is ignored, and the queue/chapters/transcript panels never render. |
|
|
@@ -1409,14 +1405,17 @@ export interface TooltipProps {
|
|
|
1409
1405
|
text?: string;
|
|
1410
1406
|
/** Present → rich tooltip. */
|
|
1411
1407
|
title?: string;
|
|
1408
|
+
/** The side of the anchor the bubble floats on; `auto` / absent → `top`. Flips to the opposite
|
|
1409
|
+
* side when there is no room, as every renderer does. */
|
|
1412
1410
|
edge?: "top" | "bottom" | "leading" | "trailing" | "auto";
|
|
1411
|
+
/** What opens the (otherwise hidden) bubble. `auto` / absent → a long-press on touch, hover and
|
|
1412
|
+
* keyboard focus on the web. */
|
|
1413
1413
|
trigger?: "tap" | "long_press" | "auto";
|
|
1414
|
+
/** Fired once each time an open tooltip closes (outside tap, Escape, timeout). */
|
|
1414
1415
|
onDismiss?: Action;
|
|
1415
1416
|
}
|
|
1416
1417
|
```
|
|
1417
1418
|
|
|
1418
|
-
> ⚠️ **Not implemented on ios, android, web:** `edge`, `trigger`, `onDismiss` — Not an overlay — renders always-expanded inline; `edge`, `trigger` and dismissal are inert.
|
|
1419
|
-
|
|
1420
1419
|
#### Popover
|
|
1421
1420
|
|
|
1422
1421
|
```ts
|
|
@@ -1437,8 +1436,6 @@ export interface PopoverProps {
|
|
|
1437
1436
|
}
|
|
1438
1437
|
```
|
|
1439
1438
|
|
|
1440
|
-
> ⚠️ **Not implemented on ios:** `edge` — The bubble is drawn in flow under its anchor; `edge` placement is not implemented.
|
|
1441
|
-
|
|
1442
1439
|
### Chips & selection
|
|
1443
1440
|
|
|
1444
1441
|
#### Chip
|
|
@@ -1477,8 +1474,6 @@ export interface ChipsInputProps {
|
|
|
1477
1474
|
}
|
|
1478
1475
|
```
|
|
1479
1476
|
|
|
1480
|
-
> ⚠️ **Not implemented on ios, android:** `onAdd` — `onAdd` never dispatches.
|
|
1481
|
-
|
|
1482
1477
|
**Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
|
|
1483
1478
|
|
|
1484
1479
|
| callback | key | type |
|
|
@@ -1486,7 +1481,7 @@ export interface ChipsInputProps {
|
|
|
1486
1481
|
| `onAdd` | `value` | string |
|
|
1487
1482
|
| `onRemove` | `value` | string |
|
|
1488
1483
|
|
|
1489
|
-
> `onAdd` —
|
|
1484
|
+
> `onAdd` — The new token, trimmed and non-empty, committed by Return or a comma (each segment before a comma is one token; the text after the last stays in the field). The field clears; the chips themselves stay the worker's, so a worker that wants the token shown appends it to `valuesPath`.
|
|
1490
1485
|
|
|
1491
1486
|
> `onRemove` — The removed chip's value.
|
|
1492
1487
|
|
|
@@ -1936,8 +1931,6 @@ export interface OtpInputProps {
|
|
|
1936
1931
|
}
|
|
1937
1932
|
```
|
|
1938
1933
|
|
|
1939
|
-
> ⚠️ **Not implemented on ios, android, web:** `onComplete`, `onChange` — Neither `onChange` nor `onComplete` dispatches.
|
|
1940
|
-
|
|
1941
1934
|
**Dispatches** — see [What a control sends](#what-a-control-sends-action-payloads):
|
|
1942
1935
|
|
|
1943
1936
|
| callback | key | type |
|
|
@@ -1945,13 +1938,9 @@ export interface OtpInputProps {
|
|
|
1945
1938
|
| `onChange` | `value` | string |
|
|
1946
1939
|
| `onComplete` | `value` | string |
|
|
1947
1940
|
|
|
1948
|
-
> `onChange` — The assembled code.
|
|
1949
|
-
|
|
1950
|
-
> ⚠️ **`onChange` on web + ios + android — DIVERGES:** renders the digit boxes without wiring onChange — the control dispatches nothing. Unimplemented rather than divergent, but it must be declared: leaving OtpInput out of the contract is what let the gate whitelist RN's payload without checking it.
|
|
1951
|
-
|
|
1952
|
-
> `onComplete` — Fires once the last box is filled, carrying the same assembled code as onChange. Declared separately because it is a second public callback — leaving it out left one outside the contract.
|
|
1941
|
+
> `onChange` — The assembled code, on every edit (whitespace dropped, capped at `length`). Every arm dispatches it; an edit that leaves the code unchanged dispatches nothing.
|
|
1953
1942
|
|
|
1954
|
-
>
|
|
1943
|
+
> `onComplete` — Fires once, on the edit that fills the last box (the transition into `value.length == length`), carrying the same assembled code as onChange — never again while the code stays full. Declared separately because it is a second public callback — leaving it out left one outside the contract.
|
|
1955
1944
|
|
|
1956
1945
|
### Cross-cutting & media
|
|
1957
1946
|
|
|
@@ -426,14 +426,16 @@ its own recovery.
|
|
|
426
426
|
|---|---|---|---|---|
|
|
427
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
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
|
-
| **
|
|
429
|
+
| **Sign-in** | The session is missing or has ended: an HTTP 401, a session-expired code, a bridge that has no session, or a 403 for a visitor the host knows is signed out. When the host knows the user is signed in, a 401 is a permission refusal instead. | The screen or section. | "Sign in to view orders" / "Your session has ended." | Open the app's configured auth flow. No retry until authentication succeeds. |
|
|
430
|
+
| **Site membership** | The account is not a member of this site (`NotSiteMember`). A `403 WDE0027` is not this class: Wix Data returns it to signed-in members without access too. | The screen or section. | "Join this site to view orders" / "Use this site's join flow, or ask the owner for access." | Open the site's join flow when the app provides one; otherwise show contact guidance. |
|
|
431
|
+
| **Permission** | The user lacks access (HTTP 403, `WDE0027` included), or the endpoint reports a policy refusal. Signing in again does not help. A 403 for a visitor the host knows is signed out is **Sign-in** instead: member-only data refuses visitors with `403 WDE0027`. | The screen or section. | "You need permission to view this screen" | No retry; show the next useful step, such as contacting the owner. |
|
|
430
432
|
| **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
433
|
| **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
434
|
| **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
435
|
|
|
434
436
|
- **A screen- or section-level error is an `EmptyState` with no icon.** Set `title` and
|
|
435
437
|
`subtitle`; leave `icon` out. Add `action` + `actionLabel` only when the user has a useful
|
|
436
|
-
recovery — retry, reload,
|
|
438
|
+
recovery — retry, reload, the app's auth flow, or the site's join flow when available. Every error then has the same layout and style, as every
|
|
437
439
|
empty state does. Do not build one by hand from `Icon`, `Text` and `Button` nodes.
|
|
438
440
|
- **One node per class.** iOS and Android do not resolve a `$service:` binding in `EmptyState`
|
|
439
441
|
`title` or `subtitle`, so the worker cannot swap the text. Give each class the screen can hit its
|
|
@@ -443,8 +445,9 @@ its own recovery.
|
|
|
443
445
|
- **User-facing language, never the raw error.** Say what happened and what the user can do. Never
|
|
444
446
|
show `e.message`, a status code, a stack trace or an API error string — keep those for your logs.
|
|
445
447
|
- **Recovery only when it makes sense.** Offer **Try again** for an offline or a server failure,
|
|
446
|
-
and for a failed write.
|
|
447
|
-
|
|
448
|
+
and for a failed write. Use the app's auth flow for a sign-in failure, and the
|
|
449
|
+
site's join flow for `NotSiteMember` when the app provides one. Do not offer retry for a
|
|
450
|
+
permission error, an empty result or a field error: the same request gives the same answer.
|
|
448
451
|
- **Empty is not an error.** A successful call with zero rows shows the empty state.
|
|
449
452
|
- **A failed refresh keeps the content.** When data is already on screen and a refresh fails,
|
|
450
453
|
keep the rows and show a `Banner` (`severity: "warning"`, with a **Try again** `action` and its
|
|
@@ -452,7 +455,7 @@ its own recovery.
|
|
|
452
455
|
again** go in a row at the end of the list that retries the same cursor
|
|
453
456
|
(`himi docs recipe-dynamic-list`) — the user is at the bottom, and the Banner's retry reloads
|
|
454
457
|
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.
|
|
458
|
+
sign-in, membership or permission failure means the rows are no longer the user's to see.
|
|
456
459
|
- **The error replaces the loader.** When a load fails, hide the loading state and show the error —
|
|
457
460
|
never both at once.
|
|
458
461
|
|
|
@@ -543,7 +546,7 @@ Run this punch list on the screen (it's also a good reviewer checklist):
|
|
|
543
546
|
|
|
544
547
|
- [ ] **One** primary action, on the one `accentPrimary` surface?
|
|
545
548
|
- [ ] 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 /
|
|
549
|
+
- [ ] Errors classified (offline / server / sign-in / site membership / permission), 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?
|
|
547
550
|
- [ ] 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?
|
|
548
551
|
- [ ] Every value a token — zero literal hex / off-scale spacing / invented type sizes?
|
|
549
552
|
- [ ] 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?
|
package/guides/index.json
CHANGED
|
@@ -25,7 +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
|
|
28
|
+
"CLASSIFY every error before you show it: offline, server failure, sign-in, site membership and permission 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; the app's auth flow for sign-in; the site's join flow for `NotSiteMember` when available; 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, membership 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`).",
|
|
29
29
|
"Workers are sandboxed: no DOM, no timers, no raw fetch. Produce state synchronously in actions; do not rely on an ambient refresh tick.",
|
|
30
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.",
|
|
31
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
|
@@ -1029,18 +1029,22 @@ diagnoses.
|
|
|
1029
1029
|
### An Android App Bundle for Google Play — `--aab`
|
|
1030
1030
|
|
|
1031
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
|
|
1033
|
-
|
|
1032
|
+
and a bundle installs on nothing. `--aab` builds the bundle **and an APK from the same build**, for
|
|
1033
|
+
an owner who uploads the bundle to Play Console themselves and installs the APK on a phone:
|
|
1034
1034
|
|
|
1035
1035
|
```sh
|
|
1036
1036
|
himi login --dpx --target bo # an INTERNAL caller: self-serve callers get 403 (see below)
|
|
1037
1037
|
himi dist --app <app> --platform android --aab --wait --target bo
|
|
1038
1038
|
```
|
|
1039
1039
|
|
|
1040
|
-
- **What you get:** `artifact.downloadUrl` (presigned, 7 days) and `artifact.sha256
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
`
|
|
1040
|
+
- **What you get:** the bundle as `artifact.downloadUrl` (presigned, 7 days) and `artifact.sha256`,
|
|
1041
|
+
as before, so a Google Play caller reads the same fields. The APK as `artifact.apk`
|
|
1042
|
+
(`downloadUrl`, `sha256`, and its `bucket`/`key`). The QR, the links surface and
|
|
1043
|
+
`GET /v1/dist/<jobId>/install` all point at that APK, never at the `.aab` — a link that hands a
|
|
1044
|
+
phone a file it cannot open is worse than none. A bundle job with no `artifact.apk` gets no QR and
|
|
1045
|
+
a 404 from the redirect. The APK is kept only when the pool reports it QA-signed
|
|
1046
|
+
(`apk.androidSigningUsed: "qa-keystore"`) and it sits in the bundle's own S3 bucket; otherwise the
|
|
1047
|
+
bundle still succeeds and the record says `companion_apk_dropped: …`. The record carries `androidFormat: "aab"`.
|
|
1044
1048
|
- **Android only.** The default stays an APK, and iOS is untouched. It travels as
|
|
1045
1049
|
`androidFormat: "aab"` on `POST /v1/admin/dist` (every `/v1/owner/dist` caller is self-serve, so
|
|
1046
1050
|
that route always answers `403 android_format_not_allowed`), and reaches the pool as
|
|
@@ -1054,21 +1058,25 @@ himi dist --app <app> --platform android --aab --wait --target bo
|
|
|
1054
1058
|
bundle without a real Firebase config and customer identity). A mixed `--platform ios,android`
|
|
1055
1059
|
applies it to the Android leg only.
|
|
1056
1060
|
- **How the pool builds it.** `run-dist` runs store-release's `bundleRelease` task
|
|
1057
|
-
(`bundleReleaseArgs` / `bundleReleaseEnv`, the same task the Play lane runs)
|
|
1058
|
-
|
|
1061
|
+
(`bundleReleaseArgs` / `bundleReleaseEnv`, the same task the Play lane runs) and, in the **same
|
|
1062
|
+
gradle run**, the module's matching `assemble<Variant>` task (`companionApkTask`: same module, same
|
|
1063
|
+
flavor), with **AGP injected signing**, with the **Wix production key** (`wix-keystore`: `~/.signing/oneapp-production.keystore`,
|
|
1059
1064
|
alias `WixOne`), resolved by `resolveAndroidSigner` and verified there. The pool refuses any
|
|
1060
1065
|
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
1066
|
`ORG_GRADLE_PROJECT_android.injected.signing.{store,key}.password`, never in argv. `jarsigner
|
|
1062
|
-
-verify` must say `jar verified.` before anything is uploaded. The
|
|
1063
|
-
|
|
1067
|
+
-verify` must say `jar verified.` before anything is uploaded. The APK gets every APK check:
|
|
1068
|
+
it is **re-signed with the committed QA key** (never left with WixOne) and `apksigner`-verified,
|
|
1069
|
+
gets the 16 KB check, and must report `qa-keystore`, or the build fails. Only release outputs are
|
|
1070
|
+
considered, so a stale debug APK in a warm tree is never picked. The seed gate and the binary-size budget run on both
|
|
1071
|
+
files. Compile, resources and R8 are shared, so the APK adds only its packaging and one upload. `DIST_ANDROID_BUNDLE_TASK` overrides the task
|
|
1064
1072
|
(`DIST_ANDROID_TASK` stays the APK one), and `DIST_ANDROID_FLAVOR` picks among flavored bundles.
|
|
1065
1073
|
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
|
|
1067
|
-
the app's permanent Play package.
|
|
1068
|
-
- **What
|
|
1074
|
+
An **ambiguous** choice fails the build instead of guessing, for the bundle and for the APK beside
|
|
1075
|
+
it: the bundle's applicationId becomes the app's permanent Play package.
|
|
1076
|
+
- **What the bundle skips, and why.** `apksigner` and the re-sign step (a bundle is JAR-signed at build
|
|
1069
1077
|
time, and the injected config overrides any `signingConfig`); the 16 KB check (its ZIP half is about an installed APK,
|
|
1070
1078
|
which Play generates; its ELF half reads the APK `lib/` layout — Play checks alignment on
|
|
1071
|
-
upload); and `--reuse-artifact` (that is an APK).
|
|
1079
|
+
upload); and `--reuse-artifact` (that is an APK). The APK beside it gets the APK checks above.
|
|
1072
1080
|
- **Version skew.** The pool runs the release's own `run-dist`, and one older than `--aab` ignores
|
|
1073
1081
|
`DIST_ANDROID_FORMAT` and builds an APK. So the pool reports what it built (`androidFormat` on
|
|
1074
1082
|
the result callback, the `androidSigningUsed` pattern), and serve-control turns a `SUCCESS` that
|
|
@@ -1088,7 +1096,7 @@ himi dist --app <app> --platform android --aab --wait --target bo
|
|
|
1088
1096
|
stays for a runner older than that rule. A later delivery that passes every check (a retry on a
|
|
1089
1097
|
clean machine) clears the verdict.
|
|
1090
1098
|
|
|
1091
|
-
**Why the Wix production key, and only for
|
|
1099
|
+
**Why the Wix production key, and only for the bundle.** There are two Android keys: the committed
|
|
1092
1100
|
QA keystore and the Wix production key (WixOne). WixOne is the Play **upload** key every Branded
|
|
1093
1101
|
app's bundle carries (`mobile-apps-engine` refuses any other certificate for a Play build), with
|
|
1094
1102
|
Play App Signing on, so Google re-signs what users install. An `.aab` therefore signs with it —
|
|
@@ -1096,7 +1104,10 @@ only its `WixOne` entry of `oneapp-production.keystore`: the alias is pinned for
|
|
|
1096
1104
|
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
1105
|
gets no bundle at all (`403 android_format_not_allowed`): `--aab` is for internal callers until a
|
|
1098
1106
|
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.
|
|
1107
|
+
`wix-keystore` keeps `403 android_signing_not_allowed`, unchanged. The APK built beside a bundle is
|
|
1108
|
+
QA-signed, so no installable APK carrying the WixOne signature is ever handed out; WixOne would not
|
|
1109
|
+
help it anyway, because the APKs Google Play delivers carry Google's app-signing key, so a phone with
|
|
1110
|
+
the QA APK uninstalls it before installing the app from Google Play. For the owner, the Play listing is bound to the WixOne upload certificate, so later
|
|
1100
1111
|
updates come from Wix builds too; leaving Wix needs an upload-key reset through Play Console
|
|
1101
1112
|
support. A pool machine without the key fails the build, naming the paths it searched, rather than
|
|
1102
1113
|
signing with another key.
|
|
@@ -1193,8 +1204,10 @@ baking the live asset.
|
|
|
1193
1204
|
or `DIST_ANDROID_USE_WIX_KEYSTORE=1` names one, and a named key wins — `apksigner` replaces the
|
|
1194
1205
|
signature block — because the job record is written at dispatch and would otherwise name a key
|
|
1195
1206
|
that is not on the binary.
|
|
1196
|
-
- **Android, `--aab`** → a **presigned `.aab` download URL** and its sha256
|
|
1197
|
-
|
|
1207
|
+
- **Android, `--aab`** → a **presigned `.aab` download URL** and its sha256 (`artifact.downloadUrl`;
|
|
1208
|
+
a bundle is uploaded to Google Play, it does not install on a device), plus the QA-signed APK
|
|
1209
|
+
from the same build as `artifact.apk`, which the QR, the links surface and
|
|
1210
|
+
`GET /v1/dist/<jobId>/install` point at. See
|
|
1198
1211
|
[the App Bundle section](#an-android-app-bundle-for-google-play----aab).
|
|
1199
1212
|
- **How Android picks up the branding.** `prepare:customer` writes an **xcconfig** for iOS (bundle
|
|
1200
1213
|
id + display name) but on Android only tokens, the icon drawable and a colour XML. Android's
|
|
@@ -1325,6 +1338,22 @@ apksigner verify --verbose qa.apk # must say "Verifies"; DOES
|
|
|
1325
1338
|
aapt2 dump badging qa.apk | head -2 # package + application-label = the branding actually baked
|
|
1326
1339
|
```
|
|
1327
1340
|
|
|
1341
|
+
For an **`--aab`** job, `downloadUrl` is the bundle (it installs on nothing) and the phone's file
|
|
1342
|
+
is `artifact.apk`, so check both:
|
|
1343
|
+
|
|
1344
|
+
```sh
|
|
1345
|
+
curl -sS -o play.aab "<artifact.downloadUrl>"
|
|
1346
|
+
shasum -a 256 play.aab # must equal artifact.sha256
|
|
1347
|
+
jarsigner -verify play.aab # must say "jar verified." (signed with WixOne)
|
|
1348
|
+
curl -sS -o qa.apk "<artifact.apk.downloadUrl>"
|
|
1349
|
+
shasum -a 256 qa.apk # must equal artifact.apk.sha256
|
|
1350
|
+
apksigner verify --print-certs qa.apk # must verify, with the committed QA key's certificate
|
|
1351
|
+
```
|
|
1352
|
+
|
|
1353
|
+
The APK is signed with the committed QA key (identical on every pool machine), not a per-machine
|
|
1354
|
+
debug key, so it installs over an earlier QA build of the same app. It does not install over the app
|
|
1355
|
+
from Google Play, whose APKs carry Google's app-signing key: uninstall one before installing the other.
|
|
1356
|
+
|
|
1328
1357
|
**5. Install and drive it.**
|
|
1329
1358
|
|
|
1330
1359
|
```sh
|
|
@@ -60,11 +60,11 @@ interface Row { // FLAT strings/booleans only — see "Normalize" b
|
|
|
60
60
|
interface State {
|
|
61
61
|
status: "loading" | "ready" | "error";
|
|
62
62
|
items: Row[];
|
|
63
|
-
errorKind: "" | "offline" | "server" | "signIn" | "permission"; // classifyError(e), never the raw message
|
|
63
|
+
errorKind: "" | "offline" | "server" | "signIn" | "membership" | "permission"; // classifyError(e, currentAuthState()), never the raw message
|
|
64
64
|
// hide* flags for hiddenPath, derived in shape() — see `himi docs recipe-four-state`
|
|
65
65
|
hideLoading: boolean; notEmpty: boolean;
|
|
66
66
|
hideOfflineError: boolean; hideServerError: boolean;
|
|
67
|
-
hideSignInError: boolean; hidePermissionError: boolean; hideRefreshError: boolean;
|
|
67
|
+
hideSignInError: boolean; hideMembershipError: boolean; hidePermissionError: boolean; hideRefreshError: boolean;
|
|
68
68
|
nextCursor: string; failedCursor: string; hideLoadMoreError: boolean; // §5 load-more
|
|
69
69
|
}
|
|
70
70
|
```
|
|
@@ -157,17 +157,18 @@ A list loads data, so it ships all four states — the full idiom is
|
|
|
157
157
|
keeps the rows and uses the list's own spinner.
|
|
158
158
|
- **Empty** — an `EmptyState` with a directive action, gated by `notEmpty`.
|
|
159
159
|
- **Error, nothing on screen** — one `EmptyState` per class `classifyError` can
|
|
160
|
-
return (offline, server, sign-in, permission), **no icon**, user-facing copy.
|
|
160
|
+
return (offline, server, sign-in, site membership, permission), **no icon**, user-facing copy.
|
|
161
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
|
|
162
|
+
**Sign in**; membership opens the site's join flow when available; permission gets no action. A list with no sign-in flow maps
|
|
163
163
|
`"signIn"` to `"permission"` — a visitor still gets a 401 or 403 (Bookings
|
|
164
164
|
`searchServices`, above) and a missing node paints a blank screen. The worker's
|
|
165
|
-
`catch` sets `errorKind` from `classifyError(e)`
|
|
165
|
+
`catch` sets `errorKind` from `classifyError(e, currentAuthState())` (both from
|
|
166
|
+
`himi docs recipe-four-state`) — never `String(e.message)`,
|
|
166
167
|
which shows the user a wire error.
|
|
167
168
|
- **Error, rows on screen** — a pull-to-refresh that fails offline or on the
|
|
168
169
|
server keeps the rows (`shape({ ...s, status: "error", errorKind })`, `items`
|
|
169
170
|
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
|
+
`hideRefreshError`. A sign-in, membership or permission failure clears the rows instead.
|
|
171
172
|
A failed **load-more** has its own retry (§5).
|
|
172
173
|
|
|
173
174
|
```jsonc
|
|
@@ -222,10 +223,10 @@ native decoders drop anything else without a word:
|
|
|
222
223
|
`hideLoadMoreError`), whose action restores `nextCursor = failedCursor` and runs
|
|
223
224
|
`loadMore` again. Do not route it through the refresh `Banner`: that **Try
|
|
224
225
|
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
|
+
- **Except when page 2 loses access.** A `signIn`, `membership` or `permission` failure on
|
|
226
227
|
`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
|
|
228
|
-
`EmptyState` a refresh uses (`load()` in `himi docs recipe-four-state`). Keep
|
|
228
|
+
user's to see: clear them and go to the same sign-in, membership or
|
|
229
|
+
permission `EmptyState` a refresh uses — membership with its join action (`load()` in `himi docs recipe-four-state`). Keep
|
|
229
230
|
page 1 and offer the cursor retry only for `offline` and `server`.
|
|
230
231
|
- APIs that don't page (or v1 fallbacks without cursors) simply never set
|
|
231
232
|
`nextCursor` — the gesture props are safe to leave in place.
|
|
@@ -35,7 +35,6 @@ 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",
|
|
39
38
|
"action": { "actionId": "tier5.postForm.submit" } } } }
|
|
40
39
|
}
|
|
41
40
|
}
|
|
@@ -92,16 +91,15 @@ interface FormState {
|
|
|
92
91
|
status: "idle" | "saving" | "saved" | "error";
|
|
93
92
|
error: string; // "" unless failed → collapseWhenEmpty
|
|
94
93
|
savedMessage: string; // "" unless saved → collapseWhenEmpty
|
|
95
|
-
cannotSave: boolean; // true after a permission failure → disabledPath
|
|
96
94
|
}
|
|
97
95
|
|
|
98
96
|
const svc = defineService<FormState, {
|
|
99
97
|
submit: (s: FormState, values?: Record<string, unknown>) => Promise<FormState>;
|
|
100
98
|
}>("postForm", {
|
|
101
|
-
state: { title: "", body: "", status: "idle", error: "", savedMessage: ""
|
|
102
|
-
// Outcome keys are per-attempt: a cold boot must not restore a stale error
|
|
103
|
-
//
|
|
104
|
-
notHydrated: ["status", "error", "savedMessage"
|
|
99
|
+
state: { title: "", body: "", status: "idle", error: "", savedMessage: "" },
|
|
100
|
+
// Outcome keys are per-attempt: a cold boot must not restore a stale error or a
|
|
101
|
+
// "Draft created." line from an earlier run.
|
|
102
|
+
notHydrated: ["status", "error", "savedMessage"],
|
|
105
103
|
actions: {
|
|
106
104
|
submit: async (s, values) => {
|
|
107
105
|
const title = String(values?.title ?? "").trim();
|
|
@@ -113,9 +111,8 @@ const svc = defineService<FormState, {
|
|
|
113
111
|
} catch (e) {
|
|
114
112
|
// Keep what the user typed (echo it back into the prefill), and say what
|
|
115
113
|
// happened in their words — never e.message.
|
|
116
|
-
const kind = classifyError(e);
|
|
117
|
-
return { ...s, title, body, status: "error", error: SAVE_FAILED[kind], savedMessage: ""
|
|
118
|
-
cannotSave: kind === "permission" }; // bound to the submit's disabledPath
|
|
114
|
+
const kind = classifyError(e, currentAuthState()); // from himi docs recipe-four-state
|
|
115
|
+
return { ...s, title, body, status: "error", error: SAVE_FAILED[kind], savedMessage: "" };
|
|
119
116
|
}
|
|
120
117
|
},
|
|
121
118
|
},
|
|
@@ -127,6 +124,7 @@ const SAVE_FAILED: Record<string, string> = {
|
|
|
127
124
|
offline: "You're offline, so nothing was saved. Try again when you reconnect.",
|
|
128
125
|
server: "Couldn't save your changes. Try again.",
|
|
129
126
|
signIn: "Your session has ended. Sign in, then save again.",
|
|
127
|
+
membership: "Join this site before saving.",
|
|
130
128
|
permission: "You don't have permission to save this.",
|
|
131
129
|
};
|
|
132
130
|
```
|
|
@@ -140,9 +138,10 @@ const SAVE_FAILED: Record<string, string> = {
|
|
|
140
138
|
user typed** — the catch returns `title`/`body` into the prefill so nothing is
|
|
141
139
|
lost. The submit button is the retry: there is no separate **Try again**.
|
|
142
140
|
A **sign-in** failure says to sign in and save again (the submit works once the
|
|
143
|
-
user is signed in). A **
|
|
144
|
-
|
|
145
|
-
|
|
141
|
+
user is signed in). A **membership** failure asks the user to join this site;
|
|
142
|
+
keep submit available so they can save after joining. A **permission** failure
|
|
143
|
+
says so and keeps submit available too, so the user can retry after access is
|
|
144
|
+
granted without restarting the form.
|
|
146
145
|
- The write goes through a `@himalaya/wix-<domain>` client (declare `wix_api`!).
|
|
147
146
|
**Wrap the entity envelope the API expects** (`{ draftPost: {...} }` handled by
|
|
148
147
|
the SDK; raw `wixApi.call` writes must wrap it themselves) — an unwrapped body
|
|
@@ -153,7 +152,7 @@ const SAVE_FAILED: Record<string, string> = {
|
|
|
153
152
|
|
|
154
153
|
Directly below the `Form` — its submit button is the form's last element — the
|
|
155
154
|
standard outcome row, each line rendering only in its state, next to the action it
|
|
156
|
-
reports on
|
|
155
|
+
reports on:
|
|
157
156
|
|
|
158
157
|
```jsonc
|
|
159
158
|
{ "id": "err", "…Text": { "text": "$service:postForm.error", "color": "danger",
|