@wix/himalaya-cli 0.1613.0 → 0.1615.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.
Files changed (130) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/ui/src/index.d.ts +53 -13
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +3 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/ui.mjs +31 -5
  4. package/dist/cli.mjs +1638 -1330
  5. package/dist/preview-assets.generated.bin +0 -0
  6. package/fixtures/wix-canned.json +4 -0
  7. package/guides/component-reference.json +1 -1
  8. package/guides/component-reference.md +3 -0
  9. package/guides/designing-himi-screens.md +4 -1
  10. package/guides/hhp.md +2 -2
  11. package/guides/sdk-reference.json +28 -4
  12. package/package.json +2 -2
  13. package/template-icons/salon-spa.png +0 -0
  14. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
  15. package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
  16. package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
  17. package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
  18. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
  19. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
  20. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  21. package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +178 -12
  22. package/templates/branded-lite/dev/index.ts +192 -0
  23. package/templates/branded-lite/l10n/en.json +1 -1
  24. package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
  25. package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
  26. package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
  27. package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
  28. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
  29. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
  30. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  31. package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
  32. package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +178 -12
  33. package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
  34. package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
  35. package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +178 -12
  36. package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
  37. package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
  38. package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +178 -12
  39. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
  40. package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
  41. package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
  42. package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
  43. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
  44. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
  45. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  46. package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +178 -12
  47. package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
  48. package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
  49. package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +178 -12
  50. package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
  51. package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
  52. package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +178 -12
  53. package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
  54. package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
  55. package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +178 -12
  56. package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
  57. package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
  58. package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +178 -12
  59. package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
  60. package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
  61. package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +178 -12
  62. package/templates/salon-spa/README.md +53 -0
  63. package/templates/salon-spa/config.ts +272 -0
  64. package/templates/salon-spa/dev/index.ts +19893 -0
  65. package/templates/salon-spa/himalaya.content.json +49 -0
  66. package/templates/salon-spa/himalaya.template.json +14 -0
  67. package/templates/salon-spa/icon.png +0 -0
  68. package/templates/salon-spa/platforms.json +1 -0
  69. package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
  70. package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
  71. package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
  72. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
  73. package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
  74. package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
  75. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
  76. package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
  77. package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
  78. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
  79. package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
  80. package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
  81. package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
  82. package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
  83. package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
  84. package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
  85. package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
  86. package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
  87. package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
  88. package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
  89. package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
  90. package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
  91. package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
  92. package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
  93. package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
  94. package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
  95. package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
  96. package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
  97. package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
  98. package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
  99. package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
  100. package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
  101. package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
  102. package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
  103. package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
  104. package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
  105. package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
  106. package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
  107. package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
  108. package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
  109. package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
  110. package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
  111. package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
  112. package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
  113. package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
  114. package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
  115. package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
  116. package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
  117. package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
  118. package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
  119. package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
  120. package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
  121. package/templates/salon-spa/tokens.json +341 -0
  122. package/templates/yoga-studio/dev/index.ts +192 -0
  123. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
  124. package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
  125. package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
  126. package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
  127. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
  128. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
  129. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
  130. package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +178 -12
Binary file
@@ -625,6 +625,10 @@
625
625
  "total": 2
626
626
  }
627
627
  },
628
+ "POST /online-programs/participants/v3/join-applications/query": {
629
+ "joinApplications": [],
630
+ "pagingMetadata": { "count": 0, "total": 0 }
631
+ },
628
632
  "GET /pricing-plans/v2/plans/public": {
629
633
  "plans": [
630
634
  {
@@ -565,7 +565,7 @@
565
565
  "category": "Feedback",
566
566
  "propsType": "SnackbarProps",
567
567
  "bindings": [],
568
- "propsSource": "export interface SnackbarProps {\n message: string;\n action?: Action;\n durationMs?: number;\n severity?: Severity;\n /** #1059 — action label; overridable for localized hosts. Default \"Take action\". */\n actionLabel?: string;\n}"
568
+ "propsSource": "export interface SnackbarProps {\n message: string;\n action?: Action;\n durationMs?: number;\n severity?: Severity;\n /** #1059 — action label; overridable for localized hosts. Default \"Take action\". */\n actionLabel?: string;\n /** #4661 — a 40 pt/dp thumbnail at the leading edge (`https:` or a local file/asset URL; any\n * other is ignored), drawn by the same presenter as a worker's `ui.snackbar({ imageUrl })`. */\n imageUrl?: string;\n}"
569
569
  },
570
570
  {
571
571
  "name": "AlertDialog",
@@ -1200,6 +1200,9 @@ export interface SnackbarProps {
1200
1200
  severity?: Severity;
1201
1201
  /** #1059 — action label; overridable for localized hosts. Default "Take action". */
1202
1202
  actionLabel?: string;
1203
+ /** #4661 — a 40 pt/dp thumbnail at the leading edge (`https:` or a local file/asset URL; any
1204
+ * other is ignored), drawn by the same presenter as a worker's `ui.snackbar({ imageUrl })`. */
1205
+ imageUrl?: string;
1203
1206
  }
1204
1207
  ```
1205
1208
 
@@ -307,7 +307,10 @@ contextual actions/detail; `Tooltip`/`Popover` for a small anchored hint. *Reach
307
307
  inline where you place it; for a transient toast that floats at the bottom of the screen — an app
308
308
  error after a failed action, say — use the `snackbar` presentation verb or `ui.snackbar({ message,
309
309
  tone: "danger" })` from a worker: it floats above the tab bar, animates in and out, queues, honours
310
- `durationMs`, and is announced to screen readers on every platform.
310
+ `durationMs`, and is announced to screen readers on every platform. It stays a toast: two lines of
311
+ message, one optional 40 pt thumbnail (`imageUrl`), one action of up to 24 characters, 2–10 s on screen.
312
+ For "Item deleted — Undo", a screen worker can `await` it and read `actionTapped` instead of wiring an
313
+ action id; anything richer is a descriptor overlay, not a snackbar.
311
314
 
312
315
  **Status & data-viz** — `Badge`/`Tag`/`Chip` for compact labels; `Rating` for stars (`size: "sm"` in a
313
316
  dense row); `ProgressBar` for determinate progress, `ActivityIndicator` for indeterminate; `Gauge` for
package/guides/hhp.md CHANGED
@@ -210,13 +210,13 @@ Declare the mockup each screen was designed from in `hhp.json`:
210
210
  A screen may also carry `params` (the navigation input a device opens it with, used by native capture, #4935) and `fidelity: { "warn": 80, "fail": 60 }`. Thresholds can also be set app-wide at the top level.
211
211
 
212
212
  ```bash
213
- himi fidelity --content <dir> [--screen a,b] [--variant light,dark,rtl,tablet] [--ios <udid>] [--android <serial>] [--ios-tablet <udid>] [--android-tablet <serial>] [--app-id <id>] [--release <id>] [--no-native] [--json] [--post-pr <n>]
213
+ himi fidelity --content <dir> [--screen a,b] [--variant light,dark,rtl,tablet] [--ios <udid>] [--android <serial>] [--ios-tablet <udid>] [--android-tablet <serial>] [--app-id <id>] [--release <id>] [--serve <port>] [--no-native] [--json] [--post-pr <n>]
214
214
  ```
215
215
 
216
216
  For each screen and variant (`light`, `dark`, `rtl`, `tablet`), the command does four things:
217
217
 
218
218
  1. **Captures the mockup.** It draws the page in Chromium at the variant's width. Dark mode is drawn by emulating `prefers-color-scheme`. RTL is drawn by setting `dir=rtl` on the page.
219
- 2. **Captures the web render** from the app's recorded data, as `himi render` does by default. Native frames come from Himi Player on the devices you pass: `--ios <udid>` and `--android <serial>`, plus `--ios-tablet` and `--android-tablet` for the tablet variant. Only those devices are touched. For each variant the command sets dark or light appearance and, for `rtl`, the Hebrew language (iOS `AppleLanguages`, Android per-app locale, API 33+). It opens `himi://app/<app>/screen/<screen>` and captures once two frames match. Every setting it changed is restored afterwards, also on Ctrl-C. A variant with no device is reported `skipped`, never passed. Without `--release <id>` the Player shows the app's **live** release, not your working copy. A screen that declares `params` is skipped natively for now, because the Player's deep link does not carry them yet. On a fresh iOS simulator, open one `himi://` link by hand first and accept iOS's "Open in Himi Player?" prompt: the command cannot answer it, and until it is accepted every iOS capture shows the prompt.
219
+ 2. **Captures the web render** from the app's recorded data, as `himi render` does by default. Native frames come from Himi Player on the devices you pass: `--ios <udid>` and `--android <serial>`, plus `--ios-tablet` and `--android-tablet` for the tablet variant. Only those devices are touched. For each variant the command sets dark or light appearance and, for `rtl`, the Hebrew language (iOS `AppleLanguages`, Android per-app locale, API 33+). It opens `himi://app/<app>/screen/<screen>` and captures once two frames match. Every setting it changed is restored afterwards, also on Ctrl-C. A variant with no device is reported `skipped`, never passed. Without `--release <id>` or `--serve <port>` the Player shows the app's **live** release, not your working copy. `--serve <port>` serves the working copy on `127.0.0.1:<port>` as `himi run` does, so native and web render the same release. The Player reads a local origin only from a Debug build that compiled it in (see [View your package on a device](himi-player-implementation.md#view-your-package-on-a-device)): build it with `HIMALAYA_API_BASE_URL_INFO=http://127.0.0.1:<port>` on iOS and `-PhimalayaDevServeBaseUrl=http://10.0.2.2:<port>` on Android. An iOS device whose Player reads another origin is skipped with the rebuild to run. Android's origin cannot be read back from the installed app, so check that build yourself. A screen that declares `params` is skipped natively for now, because the Player's deep link does not carry them yet. On a fresh iOS simulator, open one `himi://` link by hand first and accept iOS's "Open in Himi Player?" prompt: the command cannot answer it, and until it is accepted every iOS capture shows the prompt.
220
220
  3. **Scores mockup↔web** with ImageMagick SSIM, globally and per tile. The worst tile says where the screens differ most.
221
221
  4. **Asks a fresh model** two questions, using the repo's fidelity judge prompt. The model never ran in the session that wrote the app.
222
222
  - **Design:** does WEB look like the MOCKUP? This judges the port.
@@ -5332,19 +5332,43 @@
5332
5332
  "name": "SnackbarOptions",
5333
5333
  "kind": "interface",
5334
5334
  "jsdoc": "",
5335
- "decl": "export interface SnackbarOptions {\n message: string;\n /** Default \"info\". */\n tone?: SnackbarTone;\n /** Auto-dismiss delay in ms, on every host. Default 3000; 0 or less also means 3000. */\n durationMs?: number;\n /** One action button (\"Undo\", \"View\"). Tapping it runs `actionId` with `args`, then dismisses the\n * snackbar. The id must be a worker action:\n * - one of this bundle's own `tier5.<svc>.<action>` ids — run on THIS worker, never another\n * screen's or the app-level worker's;\n * - an app service's `app.<svc>.<action>` — needs `app_services` and an exported action (an app\n * service's own id needs neither). The web host refuses it until it hosts app services.\n * Any other id (`capability.*`, `himi.*`, a registered handler) is refused when the snackbar is\n * requested: the call rejects and nothing is shown. A host that cannot tell which worker asked\n * (React Native, an embedded web region) refuses every `action` the same way. */\n action?: SnackbarAction;\n}"
5335
+ "decl": "export interface SnackbarOptions {\n /** At most two lines on screen; a longer message is ellipsised. */\n message: string;\n /** Default \"info\". */\n tone?: SnackbarTone;\n /** Auto-dismiss delay in ms, clamped on every host to {@link SNACKBAR_LIMITS} (2–10 s, at least\n * 4 s with an `action` so it can be reached by VoiceOver / TalkBack before it goes). Absent, 0 or\n * less: the default, 3000 (4000 with an `action`). */\n durationMs?: number;\n /** A thumbnail of the thing the toast is about: a 40 pt square at the leading edge. `https:` on\n * every host; a local `file:` URL on iOS and macOS only, whose image pipeline already reads local\n * files. Any other URL is ignored (the toast shows without it), and an image that fails to load\n * just hides. */\n imageUrl?: string;\n /** One action button (\"Undo\", \"View\"). Tapping it dismisses the snackbar and resolves the call\n * `{ actionTapped: true }`. With an `actionId` the tap also runs that action with `args`. The id\n * must be a worker action:\n * - one of this bundle's own `tier5.<svc>.<action>` ids — run on THIS worker, never another\n * screen's or the app-level worker's;\n * - an app service's `app.<svc>.<action>` — needs `app_services` and an exported action (an app\n * service's own id needs neither). The web host refuses it until it hosts app services.\n * Any other id (`capability.*`, `himi.*`, a registered handler) is refused when the snackbar is\n * requested: the call rejects and nothing is shown. A host that cannot tell which worker asked\n * (React Native, an embedded web reg\n/* … */"
5336
5336
  },
5337
5337
  {
5338
5338
  "name": "SnackbarAction",
5339
5339
  "kind": "interface",
5340
5340
  "jsdoc": "",
5341
- "decl": "export interface SnackbarAction {\n /** The button's text — also its VoiceOver / TalkBack / screen-reader label. */\n label: string;\n /** What the tap runs: one of this bundle's own `tier5.<svc>.<action>` ids, or `app.<svc>.<action>`. */\n actionId: string;\n /** The action's arguments, which the reducer receives as typed JSON. */\n args?: Record<string, unknown>;\n}"
5341
+ "decl": "export interface SnackbarAction {\n /** The button's text — also its VoiceOver / TalkBack / screen-reader label. At most 24\n * characters; a longer label is ellipsised. */\n label: string;\n /** What the tap runs, if anything: one of this bundle's own `tier5.<svc>.<action>` ids, or\n * `app.<svc>.<action>`. A screen worker may leave it out and branch on `actionTapped` instead\n * (\"Undo\"); an app service may not. */\n actionId?: string;\n /** The action's arguments, which the reducer receives as typed JSON. */\n args?: Record<string, unknown>;\n}"
5342
+ },
5343
+ {
5344
+ "name": "SnackbarResult",
5345
+ "kind": "interface",
5346
+ "jsdoc": "/** What a snackbar call resolves with. */",
5347
+ "decl": "export interface SnackbarResult {\n /** The user tapped the action. Always false for a snackbar without one (resolved on\n * presentation); with one, false when it was dismissed any other way — timed out, swiped, closed,\n * or replaced by a newer snackbar. */\n actionTapped: boolean;\n}"
5348
+ },
5349
+ {
5350
+ "name": "SNACKBAR_LIMITS",
5351
+ "kind": "const",
5352
+ "jsdoc": "/** #4661 — the constraints that keep a snackbar a toast, enforced here and again by every host. */",
5353
+ "decl": "export const SNACKBAR_LIMITS = {\n messageLines: 2,\n labelChars: 24,\n minDurationMs: 2000,\n /** The floor with an action: long enough to reach it with a screen reader. */\n minActionDurationMs: 4000,\n maxDurationMs: 10_000,\n /** The thumbnail's side, in pt / dp / CSS px. */\n imageSize: 40,\n} as const;"
5354
+ },
5355
+ {
5356
+ "name": "snackbarDurationMs",
5357
+ "kind": "function",
5358
+ "jsdoc": "/** The duration a host shows a snackbar for: absent / non-finite / 0 or less → undefined (the host\n * default applies), else clamped to {@link SNACKBAR_LIMITS}. */",
5359
+ "decl": "export function snackbarDurationMs(durationMs: number | undefined, hasAction: boolean): number | undefined;"
5360
+ },
5361
+ {
5362
+ "name": "snackbarLabel",
5363
+ "kind": "function",
5364
+ "jsdoc": "/** An action label as every host draws it: at most {@link SNACKBAR_LIMITS}.labelChars characters\n * (by code point, so an emoji is never cut in half), the last one an ellipsis when it was cut. */",
5365
+ "decl": "export function snackbarLabel(label: string): string;"
5342
5366
  },
5343
5367
  {
5344
5368
  "name": "snackbar",
5345
5369
  "kind": "function",
5346
- "jsdoc": "/** Show a transient snackbar toast: it floats above the tab bar, slides and fades in and out\n * (fades only under reduced motion), queues behind one that is already showing, and is read\n * by VoiceOver / TalkBack. Resolves once it is PRESENTED (not after it auto-dismisses).\n * Rejects with `no_ui_host` if no presenter is attached; with an `action`, also with\n * `invalid_action` (empty label or id), `action_not_allowed` (not one of this bundle's\n * actions), or, for an `app.*` id, `undeclared` (no `app_services`), `not_exported`, `no_service`,\n * `no_action`, `not_booted` or `unavailable`. */",
5347
- "decl": "export async function snackbar(options: SnackbarOptions): Promise<void>;"
5370
+ "jsdoc": "/** Show a transient snackbar toast: it floats above the tab bar, slides and fades in and out\n * (fades only under reduced motion), queues behind one that is already showing, and is announced\n * by VoiceOver / TalkBack / the screen reader. Without an `action` it resolves\n * `{ actionTapped: false }` once it is queued, as before; with one it resolves when the snackbar\n * goes, `{ actionTapped: true }` only if the action was tapped (see {@link SnackbarResult}).\n * Rejects with `no_ui_host` if no presenter is attached; with an `action`, also with\n * `invalid_action` (empty label), `action_id_required` (an app service with no `actionId`),\n * `action_not_allowed` (not one of this bundle's actions), or, for an `app.*` id, `undeclared` (no\n * `app_services`), `not_exported`, `no_service`, `no_action`, `not_booted` or `unavailable`. */",
5371
+ "decl": "export async function snackbar(options: SnackbarOptions): Promise<SnackbarResult>;"
5348
5372
  },
5349
5373
  {
5350
5374
  "name": "version",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1613.0",
3
+ "version": "0.1615.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -72,5 +72,5 @@
72
72
  "artifactId": "himalaya-cli"
73
73
  }
74
74
  },
75
- "falconPackageHash": "8d1e7814c00d18146088e2680fe43650535ec12113548c216d4fdbe0"
75
+ "falconPackageHash": "16f0d18dbd708953827546385ed370640a8dc38276cb7033d3cc5068"
76
76
  }
Binary file
@@ -35,11 +35,14 @@ export interface LoadFlags {
35
35
  notMock: boolean;
36
36
  notMockEmpty: boolean;
37
37
  notMockUnavailable: boolean;
38
+ /** hiddenWhen gate for the empty state: false ONLY when the site answered with no rows
39
+ * (`notMockEmpty` is true then — it gates the sample banner, not the empty state). */
40
+ hideEmpty: boolean;
38
41
  }
39
42
 
40
43
  export const LOADING: LoadFlags = {
41
44
  status: "loading", error: "", signInRequired: false, notErrored: true, notLoading: false, busy: true, noSignIn: true,
42
- isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true,
45
+ isMock: false, notMock: true, notMockEmpty: true, notMockUnavailable: true, hideEmpty: true,
43
46
  };
44
47
 
45
48
  const READY: LoadFlags = { ...LOADING, status: "ready", notLoading: true, busy: false };
@@ -78,7 +81,7 @@ type Rows<K extends string, T> = { [P in K]: T[] } & { emptyText: string } & Loa
78
81
  export function loaded<K extends string, T>(key: K, rows: T[], copy: LoadCopy<T>): Rows<K, T> {
79
82
  if (rows.length) return { ...READY, [key]: rows, emptyText: "" } as Rows<K, T>;
80
83
  if (samplesRequested()) return sampleRows(key, copy, "empty");
81
- return { ...READY, [key]: [], emptyText: copyParam(`${copy.prefix}.emptyText`, copy.empty) } as Rows<K, T>;
84
+ return { ...READY, [key]: [], emptyText: copyParam(`${copy.prefix}.emptyText`, copy.empty), hideEmpty: false } as Rows<K, T>;
82
85
  }
83
86
 
84
87
  /** A query threw. Never rows the customer did not create. */
@@ -1,6 +1,8 @@
1
1
  import { dispatch } from "@himalaya/dispatch";
2
2
  // Relative for node resolution (see himi-store.ts note); rewritten off-repo.
3
3
  import { displayName, getMyData, initials, type WixMyData } from "@himalaya/wix-users";
4
+ import { getMyMember } from "@himalaya/wix-members";
5
+ import { wixMediaUrl } from "@himalaya/wix-media";
4
6
  import { formatDateMedium, formatWeekdayDateTime } from "../_shared/engine-intl.js";
5
7
 
6
8
  export interface AccountProfile {
@@ -82,6 +84,31 @@ function profileFromData(data: WixMyData, fallbackEmail = ""): AccountProfile {
82
84
  };
83
85
  }
84
86
 
87
+ /** The signed-in site member's name and photo from their member record, or undefined when it
88
+ * cannot be read or names nobody (the caller then shows the email). */
89
+ async function siteMemberProfile(email: string): Promise<AccountProfile | undefined> {
90
+ try {
91
+ const member = (await getMyMember({ fieldsets: ["FULL"] }))?.member;
92
+ const name = [member?.contact?.firstName, member?.contact?.lastName].map((part) => (part ?? "").trim()).filter(Boolean).join(" ")
93
+ || (member?.profile?.nickname ?? "").trim();
94
+ if (!name) return undefined;
95
+ const image = wixMediaUrl(member?.profile?.photo?.url) || wixMediaUrl(member?.profile?.photo?.id) || "";
96
+ return {
97
+ isMember: true,
98
+ identityLine: "Signed in",
99
+ detailLine: email || name,
100
+ memberName: name,
101
+ memberEmail: email || member?.loginEmail || "",
102
+ memberImage: image,
103
+ memberInitials: initialsFromName(name) || "M",
104
+ hasMemberImage: !!image,
105
+ noMemberImage: !image,
106
+ };
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
85
112
  export function guestProfile(detailLine: string): AccountProfile {
86
113
  return {
87
114
  isMember: false,
@@ -118,6 +145,10 @@ export async function loadMemberProfile(guestDetailLine: string): Promise<Accoun
118
145
  const data = await getMyData();
119
146
  return profileFromData(data, storedEmail);
120
147
  } catch {
148
+ // The account profile is an owner endpoint: the web host refuses it, and a site member need
149
+ // not have one. The member's own record (the Members API) still names them.
150
+ const member = await siteMemberProfile(storedEmail);
151
+ if (member) return member;
121
152
  return {
122
153
  isMember: true,
123
154
  identityLine: "Signed in",
@@ -0,0 +1,88 @@
1
+ // Saved (favorite) booking services use the host's durable, app-scoped KV, shared by every
2
+ // screen worker — the service detail toggles a row (himi-booking-service `toggleSaved`), the
3
+ // Favorites list reads them (himi-booking-saved). Same model as _shared/himi-store-saved.ts: a
4
+ // failed write must never claim that a save succeeded.
5
+ export interface SavedService {
6
+ id: string;
7
+ name: string;
8
+ /** The price line as the detail showed it ("$250"), kept so the list paints offline. */
9
+ price: string;
10
+ /** The duration line ("1 hr"). */
11
+ duration: string;
12
+ image: string;
13
+ category: string;
14
+ /** A course: the list's Book passes `course` to the time screen, which then refuses a course
15
+ * whose record failed to load instead of offering its sessions one by one. */
16
+ course?: boolean;
17
+ }
18
+ type StorageLike = { getItem(key: string): string | null; setItem(key: string, value: string): void };
19
+ type Host = { himalaya?: { storage?: StorageLike; input?: Record<string, unknown> }; localStorage?: StorageLike };
20
+ const BASE = "himi-booking:saved-services:v1";
21
+ function storage(): StorageLike {
22
+ const host = globalThis as Host;
23
+ const kv = host.himalaya?.storage ?? host.localStorage;
24
+ if (!kv) throw new Error("Saved-service storage is unavailable");
25
+ return kv;
26
+ }
27
+ const validPart = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_.:-]{1,128}$/.test(value) && value !== "default";
28
+ /** Saves belong to whoever made them. A signed-in member's live under the host's authenticated
29
+ * account + business — the partition collection preferences use, so a second member on the same
30
+ * device never sees them; a visitor's live on the device. A member's partition, read for the
31
+ * first time, takes the visitor's saves on this device (signing in carries them, like a cart),
32
+ * once — the device list is then emptied. */
33
+ function storeKey(kv: StorageLike): string {
34
+ const input = (globalThis as Host).himalaya?.input;
35
+ const account = input?.__himiPreferenceAccount, business = input?.__himiPreferenceBusiness;
36
+ if (!validPart(account) || !validPart(business)) return BASE;
37
+ const key = `${BASE}.${account}.${encodeURIComponent(business)}`;
38
+ if (kv.getItem(key) === null) {
39
+ const visitor = kv.getItem(BASE);
40
+ if (visitor !== null) {
41
+ kv.setItem(key, visitor);
42
+ // Empty the device list only once the member's copy is confirmed: a web host may swallow a
43
+ // failed write, and clearing then would lose the visitor's saves.
44
+ if (kv.getItem(key) === visitor) kv.setItem(BASE, "[]");
45
+ }
46
+ }
47
+ return key;
48
+ }
49
+ const str = (value: unknown): string => (typeof value === "string" ? value : "");
50
+ export function readSavedServices(): SavedService[] {
51
+ const kv = storage();
52
+ const raw = kv.getItem(storeKey(kv));
53
+ if (!raw) return [];
54
+ const parsed: unknown = JSON.parse(raw);
55
+ if (!Array.isArray(parsed)) throw new Error("Invalid saved-service data");
56
+ const rows = new Map<string, SavedService>();
57
+ for (const value of parsed) {
58
+ if (!value || typeof value !== "object") continue;
59
+ const row = value as Record<string, unknown>;
60
+ if (typeof row.id !== "string" || !row.id.trim() || typeof row.name !== "string") continue;
61
+ rows.set(row.id, { id: row.id, name: row.name, price: str(row.price), duration: str(row.duration), image: str(row.image), category: str(row.category), ...(row.course === true ? { course: true } : {}) });
62
+ }
63
+ return [...rows.values()];
64
+ }
65
+
66
+ export function isServiceSaved(id: string): boolean {
67
+ return readSavedServices().some(row => row.id === id);
68
+ }
69
+ export function writeSavedServices(rows: SavedService[]): void {
70
+ const kv = storage();
71
+ const key = storeKey(kv);
72
+ const value = JSON.stringify(rows);
73
+ kv.setItem(key, value);
74
+ // Web hosts may swallow quota/security errors. Confirm the synchronous write
75
+ // before displaying success; a missing/mismatched read is a failed save.
76
+ if (kv.getItem(key) !== value) throw new Error("Saved-service write did not persist");
77
+ }
78
+ /** Save or unsave; returns the new saved state. The newest save is listed first. */
79
+ export function toggleSavedService(service: SavedService): boolean {
80
+ if (!service.id.trim() || !service.name.trim()) throw new Error("No service to save");
81
+ const rows = readSavedServices();
82
+ const saved = !rows.some(row => row.id === service.id);
83
+ writeSavedServices(saved ? [service, ...rows] : rows.filter(row => row.id !== service.id));
84
+ return saved;
85
+ }
86
+ export function removeSavedService(id: string): void {
87
+ writeSavedServices(readSavedServices().filter(row => row.id !== id));
88
+ }
@@ -22,6 +22,7 @@ import { wixMediaUrl, WIX_DETAIL_IMAGE, WIX_LIST_IMAGE } from "@himalaya/wix-med
22
22
  import { formatClock, formatDateMedium, formatMonthDay } from "../_shared/engine-intl.js";
23
23
  import { descriptionBody } from "../_shared/rich-body.js";
24
24
  import { openRichLink } from "../_shared/rich-content-link.js";
25
+ import { isServiceSaved, toggleSavedService } from "../_shared/himi-booking-saved.js";
25
26
 
26
27
  // Offline-first (data-offline): the service detail is a public read-through. On a
27
28
  // fetch failure we fall back to the last-synced row for this service written by
@@ -182,6 +183,13 @@ interface S {
182
183
  sessionMessageOk: boolean;
183
184
  sessionMessageFailed: boolean;
184
185
  noSessionMessage: boolean;
186
+ // ---- Favorites (the heart an app adds with withServiceSaveToggle; _shared/himi-booking-saved.ts) ----
187
+ saved: boolean;
188
+ notSaved: boolean;
189
+ /** The heart's spoken label: what a tap does now. */
190
+ saveLabel: string;
191
+ /** "" unless the last save or unsave failed. */
192
+ saveMessage: string;
185
193
  }
186
194
 
187
195
  function inputId(): string {
@@ -790,6 +798,10 @@ function derive(s: S): S {
790
798
  // Change Time opens the time screen, which cannot book a service closed to online booking.
791
799
  noChangeTime: (control !== "book" && control !== "waitlist" && control !== "closed") || !s.bookableOnline,
792
800
  noSessionMessage: !s.sessionMessage,
801
+ notSaved: !s.saved,
802
+ saveLabel: s.saved
803
+ ? copyParam("service.unsaveLabel", "Remove from favorites")
804
+ : copyParam("service.saveLabel", "Save to favorites"),
793
805
  notBookableOnline: !s.bookableOnline,
794
806
  noAbout: !s.about && !s.aboutJson,
795
807
  noPolicy: s.policyLines.length === 0,
@@ -866,9 +878,16 @@ function withService(s: S, service: WixService): S {
866
878
  // WS-C — canonical param-less target; the descriptor's choose-time CTA
867
879
  // binds the separate `id` state field as a first-class nav param.
868
880
  timeScreenId: "himi_time",
881
+ saved: savedNow(serviceId),
869
882
  });
870
883
  }
871
884
 
885
+ /** Whether the service is a favorite. Unreadable storage reads as not saved: the heart is a
886
+ * convenience and never holds the screen back. */
887
+ function savedNow(id: string): boolean {
888
+ try { return !!id && isServiceSaved(id); } catch { return false; }
889
+ }
890
+
872
891
  /** The service record: with staff details when this token may read them, else without. */
873
892
  async function fetchService(id: string): Promise<WixService | undefined> {
874
893
  try {
@@ -897,9 +916,10 @@ const svc = defineService<S, {
897
916
  book: (s: S) => S;
898
917
  changeTime: (s: S) => S;
899
918
  joinWaitlist: (s: S, arg?: unknown) => Promise<S>;
919
+ toggleSaved: (s: S) => S;
900
920
  }>("himiBookingService", {
901
921
  // A join's outcome belongs to that visit: a cold start must not replay it from saved state.
902
- notHydrated: ["sessionMessage", "sessionMessageOk", "sessionMessageFailed", "noSessionMessage"],
922
+ notHydrated: ["sessionMessage", "sessionMessageOk", "sessionMessageFailed", "noSessionMessage", "saveMessage"],
903
923
  state: derive({
904
924
  status: "loading",
905
925
  id,
@@ -1005,6 +1025,10 @@ const svc = defineService<S, {
1005
1025
  noChangeTime: true,
1006
1026
  ...NO_SESSION_MESSAGE,
1007
1027
  noSessionMessage: true,
1028
+ saved: false,
1029
+ notSaved: true,
1030
+ saveLabel: "",
1031
+ saveMessage: "",
1008
1032
  }),
1009
1033
  actions: {
1010
1034
  load: async (s, arg) => {
@@ -1012,14 +1036,16 @@ const svc = defineService<S, {
1012
1036
  rememberSignIn(arg);
1013
1037
  if (!s.id) return derive({ ...s, status: "ready", errored: true });
1014
1038
  // A new view: a join's outcome from before is not news when the screen shows again.
1015
- const start = { ...s, policyExpanded: false, scheduleExpanded: false, ...NO_SESSION_MESSAGE };
1039
+ const start = { ...s, policyExpanded: false, scheduleExpanded: false, saveMessage: "", ...NO_SESSION_MESSAGE };
1016
1040
  try {
1017
1041
  const service = await fetchService(s.id);
1018
1042
  if (!service) return withSession(derive({ ...start, status: "ready" }), undefined);
1019
1043
  loadedService = service;
1020
1044
  // The session the Agenda row opened on, read beside the service-wide availability.
1021
1045
  const [shown, sel] = await Promise.all([withGroupAvailability(withService(start, service), service), readSessionBounded(service)]);
1022
- return withSession(shown, sel);
1046
+ // The heart is read again after the waits (the session read takes up to 10 s): a toggle
1047
+ // tapped meanwhile wrote storage, and the snapshot taken before would undo it.
1048
+ return withSession({ ...shown, saved: savedNow(s.id) }, sel);
1023
1049
  } catch {
1024
1050
  // Offline: paint from the last-synced services cache (written by the list
1025
1051
  // worker) before falling back to the error state. The cache holds the name,
@@ -1038,6 +1064,7 @@ const svc = defineService<S, {
1038
1064
  category: String(cached.category ?? ""),
1039
1065
  images: image ? [{ url: image, alt: name }] : [],
1040
1066
  timeScreenId: `himi_time_${s.id}`,
1067
+ saved: savedNow(s.id),
1041
1068
  }, undefined);
1042
1069
  }
1043
1070
  return withSession({ ...start, status: "ready", errored: true, name: "" }, undefined);
@@ -1056,6 +1083,26 @@ const svc = defineService<S, {
1056
1083
  },
1057
1084
  togglePolicy: (s) => derive({ ...s, policyExpanded: !s.policyExpanded }),
1058
1085
  toggleSchedule: (s) => derive({ ...s, scheduleExpanded: !s.scheduleExpanded }),
1086
+ // The heart: save or unsave this service on the device (the member's own list once signed
1087
+ // in). The row keeps what the Favorites list shows offline; a failed write says so and never
1088
+ // shows the heart as saved.
1089
+ toggleSaved: (s) => {
1090
+ if (!s.id || !s.hasService || s.errored) return s;
1091
+ try {
1092
+ const saved = toggleSavedService({
1093
+ id: s.id,
1094
+ name: s.name,
1095
+ price: s.priceIsText ? "" : s.priceText,
1096
+ duration: s.durationText,
1097
+ image: s.image,
1098
+ category: s.category,
1099
+ ...(s.isCourse ? { course: true } : {}),
1100
+ });
1101
+ return derive({ ...s, saved, saveMessage: "" });
1102
+ } catch {
1103
+ return derive({ ...s, saved: savedNow(s.id), saveMessage: copyParam("service.saveError", "Could not update your favorites. Try again.") });
1104
+ }
1105
+ },
1059
1106
  // The footer button opens the time screen for the loaded service. A worker directive,
1060
1107
  // not a `himi.open` with a `$service:` id param: the iOS and Android sticky footers
1061
1108
  // dispatch their taps without resolving `$service:` params, so the id never arrived.
@@ -3,7 +3,8 @@
3
3
  // Visitors browse every Wix Bookings service of the site, grouped by category under a small
4
4
  // title, in the business's own order (the order of the site's Book Online page). The category
5
5
  // chips are anchors, not filters: a tap scrolls the list to its category's title, and the chip
6
- // of the category at the top of the list lights up while the member scrolls. A name search
6
+ // of the category at the top of the list lights up while the member scrolls. Opened with a
7
+ // `category` nav param (a category tile), the list starts at that category. A name search
7
8
  // keeps the grouping. With two or more locations a pin limits the list to the services offered
8
9
  // at one location; the choice is shared with the Agenda and Choose Time screens. Each row
9
10
  // carries what the service record has: photo, name, tagline, a Class or Course tag, and
@@ -449,10 +450,57 @@ let activeCategory = "";
449
450
  let activeChosen = false;
450
451
  let scrollTarget = "";
451
452
  let jumps = 0;
453
+ /** This worker run's own mark on its jump values. A renderer remembers the value it last jumped
454
+ * for, and a screen restored from an earlier visit can bring that memory back: a value that
455
+ * repeats across runs ("cat:x|1") would then be taken as done. Not in the state, so the boot
456
+ * state (which a release hashes) does not change with the clock. */
457
+ const RUN = Date.now().toString(36);
452
458
  let quietUntil = 0;
453
459
  /** How long a jump the worker asked for takes to settle: a top-row report inside it is the jump's
454
460
  * own (the renderers suppress those, this is the belt to their braces). */
455
461
  const JUMP_SETTLE_MS = 900;
462
+ /** The category this list was opened on (the `category` nav param: a category's Wix id, its
463
+ * `name:<name>` key, or its name), e.g. from a category tile. Its chip lights and the list jumps
464
+ * to its title once that group shows, and once more when this run's live page lands. After that
465
+ * — or as soon as the member scrolls or taps a chip — the member leads.
466
+ *
467
+ * Armed by the screen's own `load` (its onAppear), never by the boot request: the iOS and
468
+ * Android lists act on a jump that CHANGES while they are on screen (a chip tap), and drop one
469
+ * that is already in the state when they mount. The second jump is for a screen restored from
470
+ * its last visit: the host puts back that visit's scroll position after the first one. */
471
+ let pendingCategory = "";
472
+ let bootLoad = true;
473
+ /** This run's own live read has landed (a restored snapshot's rows do not count). */
474
+ let liveLanded = false;
475
+ let jumpedFromCache = false;
476
+ /** The `category` param the list last started at, and that visit's `categoryActivation` (a
477
+ * nonce a category tap sends, so a second tap on the SAME category is a new visit too). */
478
+ let openedFor = "";
479
+ let openedActivation = "";
480
+ function inputParam(key: "category" | "categoryActivation"): string {
481
+ const value = (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input?.[key];
482
+ return typeof value === "string" ? value.trim().slice(0, 200) : "";
483
+ }
484
+ const inputCategory = (): string => inputParam("category");
485
+ /** Read on every `load`, not once at boot: a pooled worker is handed the next screen's input just
486
+ * before its `load` runs (the web host keeps one services worker for every push). The same param
487
+ * again is the screen coming back (a pop, a refresh), which keeps the member's place. */
488
+ function armCategory(): boolean {
489
+ const next = inputCategory();
490
+ const activation = inputParam("categoryActivation");
491
+ if (next === openedFor && activation === openedActivation) return false;
492
+ openedFor = next;
493
+ openedActivation = activation;
494
+ pendingCategory = next;
495
+ jumpedFromCache = false;
496
+ // A new visit is a new run for a pooled worker: the last visit's live page is not this one's,
497
+ // so the jump that waits for "this run's live read" waits for the read this load starts.
498
+ liveLanded = false;
499
+ // An open with no category ("See all") starts at the top: the last visit's lit chip and its
500
+ // jump do not carry over to it.
501
+ if (!next) { activeCategory = ""; activeChosen = false; scrollTarget = ""; }
502
+ return true;
503
+ }
456
504
 
457
505
  // ---- Location ------------------------------------------------------------------------
458
506
  /** The member's choice (shared with Agenda and Choose Time through the on-device store). */
@@ -500,6 +548,17 @@ function display(s: S): S {
500
548
  ...section.rows.map((row) => ({ ...row, isSection: false, notSection: true, sectionTitle: "" })),
501
549
  ]);
502
550
  const chips = titled && sections.length >= 2 ? sections.map((section) => ({ id: section.id, label: section.title })) : [];
551
+ const live = liveLanded && !loading;
552
+ if (pendingCategory && chips.length && (live || !jumpedFromCache)) {
553
+ const opened = chips.find((chip) => chip.id === sectionIdOf(pendingCategory) || chip.label === pendingCategory);
554
+ if (opened) {
555
+ activeCategory = opened.id;
556
+ activeChosen = true;
557
+ scrollTarget = `${opened.id}|${RUN}.${++jumps}`;
558
+ quietUntil = Date.now() + JUMP_SETTLE_MS;
559
+ if (live) pendingCategory = ""; else jumpedFromCache = true;
560
+ }
561
+ }
503
562
  // No chips (a reload in flight, a single group): the lit chip waits for the list to come back.
504
563
  if (chips.length && (!activeChosen || !chips.some((chip) => chip.id === activeCategory))) activeCategory = chips[0]!.id;
505
564
  const target = scrollTarget.split("|")[0];
@@ -553,9 +612,15 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
553
612
  register: registerServices,
554
613
  restorePagesOnLoad: true,
555
614
  onLoad() {
615
+ let reset: { query: string } | undefined;
616
+ if (bootLoad) bootLoad = false;
617
+ // A new visit at a category drops a search the member left on this list: a saved "massage"
618
+ // would hide the category the tile opened, and its jump would never land.
619
+ else if (armCategory() && pendingCategory) reset = { query: "" };
556
620
  // Copy is stashed before this hook. Repaint restored labels immediately, even
557
621
  // when the background query holds the old page through a slow or failed read.
558
622
  (svc as unknown as { normalizeRestored?: () => unknown }).normalizeRestored?.();
623
+ return reset;
559
624
  },
560
625
  state: { status: "loading", query: "", all: [], results: [], listRows: [], emptyText: "", countLabel: "", errored: false,
561
626
  notErrored: true, isLoading: true, notLoading: false, hasResults: false, noEmpty: true,
@@ -639,9 +704,10 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
639
704
  ?? ((p as { args?: { selectedIds?: unknown } } | undefined)?.args?.selectedIds);
640
705
  const picked = Array.isArray(ids) && typeof ids[0] === "string" ? ids[0] : activeCategory;
641
706
  if (!(s.categoryChips ?? []).some((chip) => chip.id === picked)) return s;
707
+ pendingCategory = "";
642
708
  activeCategory = picked;
643
709
  activeChosen = true;
644
- scrollTarget = `${picked}|${++jumps}`;
710
+ scrollTarget = `${picked}|${RUN}.${++jumps}`;
645
711
  quietUntil = Date.now() + JUMP_SETTLE_MS;
646
712
  return display(s);
647
713
  },
@@ -653,6 +719,7 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
653
719
  if (!rows.some((row) => !row.isSection)) return s;
654
720
  const section = sectionOfRow(rows, argOf(p, "id"));
655
721
  if (!section || !(s.categoryChips ?? []).some((chip) => chip.id === section)) return s;
722
+ pendingCategory = "";
656
723
  activeCategory = section;
657
724
  activeChosen = true;
658
725
  // The member has moved on from the last jump: a re-render never scrolls back to it.
@@ -688,7 +755,7 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
688
755
  // Ended label the restored row for the same course showed, until a current pack replaces it.
689
756
  const shown = new Map((restoredEndedBridge && Array.isArray(s.results) ? s.results : []).map((row) => [row.id, row.endedText]));
690
757
  const rows = page.items.map((item) => withRowDefaults(item, shown.get(item.id)));
691
- if (page.metadata?.fromCache === false && !loading && !errored) restoredEndedBridge = false;
758
+ if (page.metadata?.fromCache === false && !loading && !errored) { restoredEndedBridge = false; liveLanded = true; }
692
759
  const filtered = page.criteria.filter.startsWith("value:");
693
760
  const hasQuery = !!page.criteria.query.trim();
694
761
  // The menu offers every location of the whole catalog: a settled live read replaces it, the