@wix/himalaya-cli 0.1577.0 → 0.1579.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 (63) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/calendar/src/index.d.ts +42 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/clipboard/src/index.d.ts +32 -1
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/haptic/src/index.d.ts +69 -5
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/sdk-types/src/capabilities.generated.d.ts +3 -3
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/ui/src/index.d.ts +19 -2
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/define-app.d.ts +6 -0
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/audio-record.mjs +3 -3
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/calendar.mjs +48 -3
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/calls.mjs +3 -3
  10. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/clipboard.mjs +58 -1
  11. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/contacts.mjs +3 -3
  12. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/haptic.mjs +73 -4
  13. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/location.mjs +3 -3
  14. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/motion.mjs +3 -3
  15. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/photos.mjs +3 -3
  16. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdk-types.mjs +3 -3
  17. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/tasks.mjs +3 -3
  18. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/ui.mjs +5 -0
  19. package/dist/cli.mjs +1061 -917
  20. package/dist/preview-assets.generated.bin +0 -0
  21. package/guides/one-app-section-mappings.md +26 -0
  22. package/guides/qa-builds.md +14 -6
  23. package/guides/sdk-reference.json +159 -9
  24. package/guides/web-page-sdk-reference.md +3 -3
  25. package/package.json +2 -2
  26. package/templates/beauty-salon/dev/index.ts +14 -0
  27. package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +21 -0
  28. package/templates/beauty-salon/tier5-src/_shared/himi-copy.ts +8 -3
  29. package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +36 -12
  30. package/templates/beauty-salon-owner/tier5-src/_shared/himi-copy.ts +8 -3
  31. package/templates/branded-lite/dev/index.ts +14 -0
  32. package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +21 -0
  33. package/templates/branded-lite/tier5-src/_shared/himi-copy.ts +8 -3
  34. package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +36 -12
  35. package/templates/branded-lite/tier5-src/himi-branded-lite-programs/index.ts +40 -2
  36. package/templates/brandy/tier5-src/_shared/himi-copy.ts +8 -3
  37. package/templates/community-club/tier5-src/_shared/himi-copy.ts +8 -3
  38. package/templates/community-club-owner/tier5-src/_shared/himi-copy.ts +8 -3
  39. package/templates/events-meetups/tier5-src/_shared/himi-copy.ts +8 -3
  40. package/templates/events-meetups-owner/tier5-src/_shared/himi-copy.ts +8 -3
  41. package/templates/gym-fitness/dev/index.ts +14 -0
  42. package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +21 -0
  43. package/templates/gym-fitness/tier5-src/_shared/himi-copy.ts +8 -3
  44. package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +36 -12
  45. package/templates/gym-fitness-owner/tier5-src/_shared/himi-copy.ts +8 -3
  46. package/templates/himi-chat/tier5-src/_shared/himi-copy.ts +8 -3
  47. package/templates/nonprofit-cause/tier5-src/_shared/himi-copy.ts +8 -3
  48. package/templates/nonprofit-cause-owner/tier5-src/_shared/himi-copy.ts +8 -3
  49. package/templates/owner-lite/tier5-src/_shared/himi-copy.ts +8 -3
  50. package/templates/podcast-creator/tier5-src/_shared/himi-copy.ts +8 -3
  51. package/templates/podcast-creator-owner/tier5-src/_shared/himi-copy.ts +8 -3
  52. package/templates/portfolio-creative/tier5-src/_shared/himi-copy.ts +8 -3
  53. package/templates/portfolio-creative-owner/tier5-src/_shared/himi-copy.ts +8 -3
  54. package/templates/restaurant/tier5-src/_shared/himi-copy.ts +8 -3
  55. package/templates/restaurant-owner/tier5-src/_shared/himi-copy.ts +8 -3
  56. package/templates/retail-store/tier5-src/_shared/himi-copy.ts +8 -3
  57. package/templates/retail-store-owner/tier5-src/_shared/himi-copy.ts +8 -3
  58. package/templates/yoga-studio/dev/index.ts +14 -0
  59. package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +21 -0
  60. package/templates/yoga-studio/tier5-src/_shared/himi-copy.ts +8 -3
  61. package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +36 -12
  62. package/templates/yoga-studio/tier5-src/himi-branded-lite-programs/index.ts +40 -2
  63. package/templates/yoga-studio-owner/tier5-src/_shared/himi-copy.ts +8 -3
Binary file
@@ -51,6 +51,32 @@ The **Himalaya implementation** cell is blank for every unassigned row. Notes on
51
51
  | `achievements_carousel_section` | `wix-one-app-achievements.MemberChallengesCarousel` | [Programs data/detail](https://github.com/wix-private/himalaya/blob/97f1542f2f7d6e8cb7a902b912c87dcaa37e5a67/core/dev-server/tier5-bundles-src/himi-branded-lite-programs/index.ts "audience-lint-ok: optional pinned upstream evidence, not a local authoring dependency") + [Core Carousel](https://github.com/wix-private/himalaya/blob/97f1542f2f7d6e8cb7a902b912c87dcaa37e5a67/core/dev-server/src/builder.ts "audience-lint-ok: optional pinned upstream evidence, not a local authoring dependency") | Existing base; adaptation needed | Partial | Reuse the Programs feature and compose a carousel; preserve source extraProps.limit=5 and selected programs. No ready-made legacy Challenges carousel adapter is claimed. |
52
52
  | `achievements_participant_carousel_section` | `wix-one-app-achievements.ParticipantChallengesCarousel` | | | Empty | The public Programs catalog is not a member-participating-programs carousel. No verified consumer participant/progress list implementation was found. |
53
53
 
54
+ For authored Programs sections, pass the source settings as a JSON string in the
55
+ `programsSection` parameter to `tier5.himiBrandedPrograms.load`:
56
+
57
+ ```js
58
+ const programsSection = JSON.stringify({ settings: {
59
+ sortingSettings: { ordering: "MANUAL", challengesOrder: ["program-id-b", "program-id-a"] },
60
+ } });
61
+ // Source IDs come from the collected section, never customer-specific constants in a template.
62
+ const onAppear = { actionId: "tier5.himiBrandedPrograms.load", params: { programsSection } };
63
+ ```
64
+
65
+ The worker also reads the same string from navigation input at boot. Preserve it
66
+ in the authored section's onAppear params when forwarding the copy pack. A
67
+ copy-only load or Retry retains the last settings. Sending an explicit empty
68
+ settings object, `DEFAULT`, or an empty manual order restores the complete
69
+ newest-first catalog. Manual ordering puts known configured IDs first, then all
70
+ remaining Programs in catalog order; it is not an inclusion list. Stale and
71
+ repeated IDs add no cards. Each worker instance retains its own source settings.
72
+ The collection's member-selected A–Z/Z–A sort still overrides its default order;
73
+ pass `collectionSort: "default"` when opening a source section that must start in
74
+ source order. The member list (`himi_member_programs`) does not read
75
+ `programsSection` and has no manual order yet. This is an authoring recipe, not an
76
+ automatic section importer.
77
+ Category selection, participant status/counts, display flags, limits, See All and
78
+ carousel placement remain separate gaps; the mappings above stay Partial.
79
+
54
80
  ### 3. bookings-section-definitions.json
55
81
 
56
82
  [Server definitions](https://github.com/wix-private/catalyst-server/blob/617c06d43d2a5608b6876d724553269b1e5346f0/one-app-sections/one-app-sections-definitions/src/main/resources/sections/bookings-section-definitions.json) · 4 definition(s).
@@ -1210,12 +1210,20 @@ himi dist --app <app> --platform android --aab --wait --target bo
1210
1210
  a bundle id saved in between is refused and stays changeable). The owner must already have the app
1211
1211
  in App Store Connect under that bundle id. The files
1212
1212
  and the key are never stored: the pool and the upload each fetch them again, the job keeps only
1213
- their hashes. The upload runs on the first status read after the pool validated the IPA; any read
1214
- the caller may make of the app moves it on, since the owner was proven when the build started (the
1215
- Containers server's reads carry a project token, not the owner's Wix login). So a caller keeps
1216
- reading the job until `customerAppStore.status` is `UPLOADED` or `FAILED`, not only until the job
1217
- is `SUCCESS`: a read that stops early, or gives up mid-upload, leaves the IPA waiting (a claimed
1218
- upload is retried by a read 15 minutes later).
1213
+ their hashes. The upload starts on its own once the pool reports the validated IPA: the pool's
1214
+ callback starts it in the background, and so does every list read of the app's builds (the owner
1215
+ card's poll) while it can be claimed, which also picks up one a stopped serve pod left. A status
1216
+ read still uploads too; any read the caller may make of the app moves it on, since the owner was
1217
+ proven when the build started (the Containers server's reads carry a project token, not the
1218
+ owner's Wix login). A caller reads the job until `customerAppStore.status` is `UPLOADED` or
1219
+ `FAILED` to learn how it ended; it no longer has to read for the upload to happen. An attempt that
1220
+ fails on a passing error (a 429 or 5xx from mobile-apps-containers-builder or Apple, a timeout, a
1221
+ download that broke off) goes back to `READY_FOR_UPLOAD` with `failedAttempts`, `retryAt` and
1222
+ `lastError`, and is tried again 1, 5 and 15 minutes later. The 4th failed attempt (an attempt
1223
+ whose 15-minute lease ran out counts as one) and any error the owner must fix (no app in App Store
1224
+ Connect, a refused key, a closed version) end `FAILED`, with `error`. When the IPA's 7-day link is
1225
+ within an hour of lapsing, the upload signs a fresh one from the stored bucket and key (the plane's
1226
+ `DIST_ARTIFACT_AWS_*` key pair; without it, the recorded link).
1219
1227
  Refusals, each with `code` equal to `error`: `store_build_ios_only`, `store_build_version_invalid`
1220
1228
  and `store_build_shell_unsupported` 400, `store_build_site_forbidden` 403, `store_id_missing`,
1221
1229
  `store_app_site_taken`, `release_predates_store_builds`, `apple_credentials_missing`,
@@ -681,7 +681,7 @@
681
681
  {
682
682
  "name": "createEvent",
683
683
  "kind": "function",
684
- "jsdoc": "",
684
+ "jsdoc": "/** Create an event directly. Needs calendar access — to let the user add an event with\n * NO permission prompt, use {@link presentEditor} (the system editor) instead. */",
685
685
  "decl": "export async function createEvent(event: NewCalendarEvent): Promise<{ id: string | null }>;"
686
686
  },
687
687
  {
@@ -696,6 +696,48 @@
696
696
  "jsdoc": "",
697
697
  "decl": "export async function deleteEvent(id: string): Promise<{ deleted: boolean }>;"
698
698
  },
699
+ {
700
+ "name": "CalendarInfo",
701
+ "kind": "interface",
702
+ "jsdoc": "/** A calendar the user has, from {@link listCalendars}. `color` is `#RRGGBB` (\"\" if unknown). */",
703
+ "decl": "export interface CalendarInfo {\n id: string;\n title: string;\n color: string;\n allowsModifications: boolean;\n isPrimary: boolean;\n}"
704
+ },
705
+ {
706
+ "name": "CalendarEditorResult",
707
+ "kind": "interface",
708
+ "jsdoc": "/** What {@link presentEditor} resolved with. `saved` is false on cancel. `id` is the new\n * event's id when the platform can report it — always `null` on Android (ACTION_INSERT\n * returns no id) and on web, and `null` on iOS without full access. On web, `downloaded`\n * is true when an .ics file was handed to the browser (the user imports it themselves,\n * so `saved` stays false). */",
709
+ "decl": "export interface CalendarEditorResult {\n saved: boolean;\n id: string | null;\n downloaded?: boolean;\n}"
710
+ },
711
+ {
712
+ "name": "CalendarViewerAction",
713
+ "kind": "type",
714
+ "jsdoc": "/** How the system viewer was closed. Android reports `done` always (ACTION_VIEW has no result). */",
715
+ "decl": "export type CalendarViewerAction = \"done\" | \"responded\" | \"deleted\";"
716
+ },
717
+ {
718
+ "name": "presentEditor",
719
+ "kind": "function",
720
+ "jsdoc": "/**\n * Open the system event editor prefilled with `event`; the user saves or cancels. Needs\n * NO calendar permission (the OS editor writes the event, not the app). Resolves\n * `{ saved: false, id: null }` on cancel. Web downloads an .ics instead; macOS rejects\n * `unavailable` (no system editor there).\n */",
721
+ "decl": "export async function presentEditor(event: Partial<NewCalendarEvent> = {}): Promise<CalendarEditorResult>;"
722
+ },
723
+ {
724
+ "name": "presentViewer",
725
+ "kind": "function",
726
+ "jsdoc": "/**\n * Show an existing event in the system viewer (iOS EKEventViewController, Android\n * ACTION_VIEW). Needs full calendar access — rejects `permission_denied` without it, and\n * `not_found` when the id does not resolve. Web and macOS reject `unavailable`.\n */",
727
+ "decl": "export async function presentViewer(id: string): Promise<{ action: CalendarViewerAction }>;"
728
+ },
729
+ {
730
+ "name": "listCalendars",
731
+ "kind": "function",
732
+ "jsdoc": "/** The user's calendars. Needs full access — rejects `permission_denied` without it. */",
733
+ "decl": "export async function listCalendars(): Promise<CalendarInfo[]>;"
734
+ },
735
+ {
736
+ "name": "getEvent",
737
+ "kind": "function",
738
+ "jsdoc": "/** One event by id, or `null` when it does not resolve. Needs full access — rejects\n * `permission_denied` without it. */",
739
+ "decl": "export async function getEvent(id: string): Promise<CalendarEvent | null>;"
740
+ },
699
741
  {
700
742
  "name": "version",
701
743
  "kind": "const",
@@ -1017,6 +1059,12 @@
1017
1059
  "source": "core/sdk/clipboard/src/index.ts",
1018
1060
  "description": "Tier-5 typed binding for the system clipboard.",
1019
1061
  "api": [
1062
+ {
1063
+ "name": "\"@himalaya/sdk-types\"",
1064
+ "kind": "reexport",
1065
+ "jsdoc": "",
1066
+ "decl": "export type { FileHandle } from \"@himalaya/sdk-types\";"
1067
+ },
1020
1068
  {
1021
1069
  "name": "writeText",
1022
1070
  "kind": "function",
@@ -1035,11 +1083,59 @@
1035
1083
  "jsdoc": "/** Whether the clipboard holds text — no content access, no iOS paste banner. On\n * web this can't be known without reading, so it returns true pessimistically;\n * treat it as a hint, not a guarantee. */",
1036
1084
  "decl": "export async function hasText(): Promise<boolean>;"
1037
1085
  },
1086
+ {
1087
+ "name": "MAX_IMAGE_BYTES",
1088
+ "kind": "const",
1089
+ "jsdoc": "/** Largest image writeImage accepts (20 MB). Hosts enforce it too. */",
1090
+ "decl": "export const MAX_IMAGE_BYTES = 20 * 1024 * 1024;"
1091
+ },
1092
+ {
1093
+ "name": "IMAGE_MIME_TYPES",
1094
+ "kind": "const",
1095
+ "jsdoc": "/** Image MIME types writeImage copies. HEIC is iOS/macOS only (Android and web\n * reject it `unsupported_type`); web accepts PNG only. The host sniffs the file's\n * bytes, so a handle is judged by its content, not its `mimeType` — the binding only\n * refuses up front a handle whose type is plainly not an image (e.g. application/pdf). */",
1096
+ "decl": "export const IMAGE_MIME_TYPES: readonly string[] = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/heic\"];"
1097
+ },
1098
+ {
1099
+ "name": "ClipboardImageErrorCode",
1100
+ "kind": "type",
1101
+ "jsdoc": "/** Why writeImage rejected. */",
1102
+ "decl": "export type ClipboardImageErrorCode = \"unsupported_type\" | \"too_large\" | \"not_allowed\" | \"not_found\" | \"unavailable\";"
1103
+ },
1104
+ {
1105
+ "name": "ClipboardImageError",
1106
+ "kind": "interface",
1107
+ "jsdoc": "/** A writeImage rejection: an `Error` named \"ClipboardImageError\" carrying a stable\n * `code`. (A plain Error, not a subclass — Tier-5 bundles target an engine without\n * class syntax.) Branch on `isClipboardImageError(e) && e.code === \"too_large\"`. */",
1108
+ "decl": "export interface ClipboardImageError extends Error {\n name: \"ClipboardImageError\";\n code: ClipboardImageErrorCode;\n}"
1109
+ },
1110
+ {
1111
+ "name": "clipboardImageError",
1112
+ "kind": "function",
1113
+ "jsdoc": "/** Build a coded writeImage rejection. */",
1114
+ "decl": "export function clipboardImageError(code: ClipboardImageErrorCode, message?: string): ClipboardImageError;"
1115
+ },
1116
+ {
1117
+ "name": "isClipboardImageError",
1118
+ "kind": "function",
1119
+ "jsdoc": "/** Whether `e` is a coded writeImage rejection. */",
1120
+ "decl": "export function isClipboardImageError(e: unknown): e is ClipboardImageError;"
1121
+ },
1122
+ {
1123
+ "name": "writeImage",
1124
+ "kind": "function",
1125
+ "jsdoc": "/** Copy an image file the worker already holds to the clipboard. Accepts a\n * `FileHandle` (from files / camera / photos) or its substrate `uri`. Rejects with a\n * `ClipboardImageError` (see `isClipboardImageError`) whose `code` is `unsupported_type` (not PNG/JPEG/WebP/HEIC, or\n * not supported on this host), `too_large` (over 20 MB), `not_allowed` (outside the\n * app's files substrate), `not_found`, or `unavailable` (no image clipboard here). */",
1126
+ "decl": "export async function writeImage(file: FileHandle | string): Promise<void>;"
1127
+ },
1128
+ {
1129
+ "name": "hasImage",
1130
+ "kind": "function",
1131
+ "jsdoc": "/** Whether the clipboard currently holds an image — without reading it (no iOS paste\n * banner). Web cannot know without a permission-gated read, so it answers false. */",
1132
+ "decl": "export async function hasImage(): Promise<boolean>;"
1133
+ },
1038
1134
  {
1039
1135
  "name": "version",
1040
1136
  "kind": "const",
1041
1137
  "jsdoc": "/** Capability version, for bundles that gate on min binary version. */",
1042
- "decl": "export const version = 1;"
1138
+ "decl": "export const version = 2;"
1043
1139
  }
1044
1140
  ]
1045
1141
  },
@@ -1550,20 +1646,68 @@
1550
1646
  {
1551
1647
  "name": "HapticPattern",
1552
1648
  "kind": "type",
1553
- "jsdoc": "/** Maps to a native impact/notification/selection generator. */",
1554
- "decl": "export type HapticPattern =\n | \"selection\"\n | \"light\"\n | \"medium\"\n | \"heavy\"\n | \"success\"\n | \"warning\"\n | \"error\";"
1649
+ "jsdoc": "/**\n * Maps to a native impact/notification/selection generator.\n *\n * - `rigid` / `soft` — iOS `UIImpactFeedbackGenerator.FeedbackStyle.rigid` / `.soft`.\n * Android: `rigid` is a short full-amplitude click (`VibrationEffect.EFFECT_CLICK`-like),\n * `soft` a longer low-amplitude tick (`PRIMITIVE_LOW_TICK`-like); on a device without\n * amplitude control both are plain one-shots of different length.\n */",
1650
+ "decl": "export type HapticPattern =\n | \"selection\"\n | \"light\"\n | \"medium\"\n | \"heavy\"\n | \"rigid\"\n | \"soft\"\n | \"success\"\n | \"warning\"\n | \"error\";"
1651
+ },
1652
+ {
1653
+ "name": "PlayOptions",
1654
+ "kind": "interface",
1655
+ "jsdoc": "",
1656
+ "decl": "export interface PlayOptions {\n /**\n * 0..1, clamped. Scales the strength of an IMPACT style (`light`, `medium`, `heavy`,\n * `rigid`, `soft`): iOS `impactOccurred(intensity:)`, Android amplitude where the\n * vibrator has amplitude control. Ignored for `selection` and the notification styles\n * (`success`, `warning`, `error`) — those have a fixed system strength — and on web.\n */\n intensity?: number;\n}"
1657
+ },
1658
+ {
1659
+ "name": "HapticStep",
1660
+ "kind": "interface",
1661
+ "jsdoc": "",
1662
+ "decl": "export interface HapticStep {\n pattern: HapticPattern;\n /** Milliseconds to wait BEFORE this step plays (≥ 0). */\n delayMs: number;\n /** Same meaning as {@link PlayOptions.intensity}. */\n intensity?: number;\n}"
1663
+ },
1664
+ {
1665
+ "name": "HapticResult",
1666
+ "kind": "type",
1667
+ "jsdoc": "/**\n * What a verb reports. `ok: false` is honest: `unavailable` (this surface has no haptic\n * hardware/API — web without `navigator.vibrate`, macOS) or `unsupported_binary` (the\n * native app predates the verb; nothing played, nothing broke).\n */",
1668
+ "decl": "export type HapticResult = { ok: true } | { ok: false; reason: \"unavailable\" | \"unsupported_binary\" };"
1669
+ },
1670
+ {
1671
+ "name": "MAX_SEQUENCE_STEPS",
1672
+ "kind": "const",
1673
+ "jsdoc": "/** Most steps one {@link sequence} may carry. */",
1674
+ "decl": "export const MAX_SEQUENCE_STEPS = 32;"
1675
+ },
1676
+ {
1677
+ "name": "MAX_DURATION_MS",
1678
+ "kind": "const",
1679
+ "jsdoc": "/** Longest a {@link sequence} or {@link vibrate} pattern may run, in milliseconds. */",
1680
+ "decl": "export const MAX_DURATION_MS = 10_000;"
1681
+ },
1682
+ {
1683
+ "name": "clampIntensity",
1684
+ "kind": "function",
1685
+ "jsdoc": "/** Clamp an intensity into 0..1; `undefined` / non-finite → `undefined` (system default). */",
1686
+ "decl": "export function clampIntensity(intensity: number | undefined): number | undefined;"
1555
1687
  },
1556
1688
  {
1557
1689
  "name": "play",
1558
1690
  "kind": "function",
1559
- "jsdoc": "/**\n * Play a haptic feedback pattern. Fire-and-forget; resolves once handed to the\n * platform. A no-op on web and for unknown patterns.\n */",
1560
- "decl": "export async function play(pattern: HapticPattern = \"light\"): Promise<void>;"
1691
+ "jsdoc": "/**\n * Play a haptic feedback pattern. Fire-and-forget; resolves once handed to the\n * platform. Unknown pattern names keep their historical behaviour (the platform's\n * default tap). `intensity` applies to impact styles only — see {@link PlayOptions}.\n */",
1692
+ "decl": "export async function play(pattern: HapticPattern = \"light\", options: PlayOptions = {}): Promise<HapticResult>;"
1693
+ },
1694
+ {
1695
+ "name": "sequence",
1696
+ "kind": "function",
1697
+ "jsdoc": "/**\n * Play `steps` in order, waiting each step's `delayMs` before it plays. Runs natively\n * as ONE bridge call (the timing does not depend on the worker), so a drum roll or a\n * countdown keeps its rhythm. A new `sequence` cancels one still running. Resolves once\n * the sequence has been scheduled.\n *\n * Rejects (`RangeError`) with more than {@link MAX_SEQUENCE_STEPS} steps, a negative or\n * non-finite delay, or delays totalling over {@link MAX_DURATION_MS}. An empty list just\n * cancels a running sequence.\n */",
1698
+ "decl": "export async function sequence(steps: HapticStep[]): Promise<HapticResult>;"
1699
+ },
1700
+ {
1701
+ "name": "vibrate",
1702
+ "kind": "function",
1703
+ "jsdoc": "/**\n * Raw vibration: `patternMs` alternates OFF / ON durations in milliseconds, starting\n * with an off gap (`[0, 200, 100, 400]` = buzz 200, pause 100, buzz 400).\n *\n * Android plays it as `VibrationEffect.createWaveform`. iOS approximates: with Core\n * Haptics each ON span is a continuous event (a transient one under 30 ms); without it,\n * each ON span becomes one `heavy` impact at its start. Web uses `navigator.vibrate`.\n *\n * Rejects (`RangeError`) on an empty list, a negative or non-finite duration, or a\n * pattern totalling over {@link MAX_DURATION_MS}.\n */",
1704
+ "decl": "export async function vibrate(patternMs: number[]): Promise<HapticResult>;"
1561
1705
  },
1562
1706
  {
1563
1707
  "name": "version",
1564
1708
  "kind": "const",
1565
1709
  "jsdoc": "/** Capability version, for bundles that gate on a min binary version. */",
1566
- "decl": "export const version = 1;"
1710
+ "decl": "export const version = 2;"
1567
1711
  }
1568
1712
  ]
1569
1713
  },
@@ -4867,7 +5011,13 @@
4867
5011
  "name": "AlertOptions",
4868
5012
  "kind": "interface",
4869
5013
  "jsdoc": "",
4870
- "decl": "export interface AlertOptions {\n /** Required — the native presenters drop alerts with an empty title. */\n title: string;\n message?: string;\n /** Confirm button label. Default \"Confirm\". */\n confirmLabel?: string;\n /** Cancel button label. OMIT for a single-button alert (no cancel button). */\n cancelLabel?: string;\n /** Style the confirm button with the `danger` token. */\n destructive?: boolean;\n}"
5014
+ "decl": "export interface AlertOptions {\n /** Required — the native presenters drop alerts with an empty title. */\n title: string;\n message?: string;\n /** Confirm button label. Default \"Confirm\". */\n confirmLabel?: string;\n /** Cancel button label. OMIT for a single-button alert (no cancel button). */\n cancelLabel?: string;\n /** Style the confirm button with the `danger` token. A destructive confirm is not the\n * affirmative button: its `confirm.actionId` still runs, but without gesture credit. */\n destructive?: boolean;\n /** The affirmative button's ACTION. Pressing it runs `actionId` as a NEW invocation\n * with user-gesture origin (so an app service's `app.<svc>.<action>` may return `_nav` and\n * navigate — the \"Ready — View\" alert). `label` replaces `confirmLabel`. The id is checked when\n * you set it, against this worker: a `capability.<id>.*` needs `<id>` allowed, an `app.*` from a\n * screen worker needs `app_services` and an exported action, a `tier5.*` must be this worker's\n * own; anything else rejects (`action_not_allowed`). Cancel and dismissal run nothing. */\n confirm?: AlertConfirm;\n}"
5015
+ },
5016
+ {
5017
+ "name": "AlertConfirm",
5018
+ "kind": "interface",
5019
+ "jsdoc": "/** The affirmative button of an `alert()`: its label and the action its press runs. */",
5020
+ "decl": "export interface AlertConfirm {\n label: string;\n actionId: string;\n /** Passed to the action (JSON types kept). */\n args?: Record<string, unknown>;\n}"
4871
5021
  },
4872
5022
  {
4873
5023
  "name": "AlertResult",
@@ -4878,7 +5028,7 @@
4878
5028
  {
4879
5029
  "name": "alert",
4880
5030
  "kind": "function",
4881
- "jsdoc": "/** Present a modal alert on the current screen. Resolves when the user picks a\n * button. Rejects with `ui_busy` while another alert() is pending, and with\n * `no_ui_host` if the app has no presenter attached. */",
5031
+ "jsdoc": "/** Present a modal alert on the current screen. Resolves when the user picks a\n * button. Rejects with `ui_busy` while another alert() is pending, and with\n * `no_ui_host` if the app has no presenter attached — or, from an APP SERVICE, when no\n * shell is in the foreground (re-show it from `onForeground`). An app service's alert shows on\n * whatever screen is open. */",
4882
5032
  "decl": "export async function alert(options: AlertOptions): Promise<AlertResult>;"
4883
5033
  },
4884
5034
  {
@@ -19,15 +19,15 @@ the SDK sources, so it matches the shipped SDKs.
19
19
  - [`base44`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fbase44?format=md) — A Base44 app's backend. 11 methods · Brandy: conditional.
20
20
  - [`biometrics`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fbiometrics?format=md) — Biometric authentication capability. 2 methods.
21
21
  - [`browser_auth`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fbrowser_auth?format=md) — Native browser SSO. 1 method.
22
- - [`calendar`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fcalendar?format=md) — Device calendar. 6 methods.
22
+ - [`calendar`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fcalendar?format=md) — Device calendar. 10 methods.
23
23
  - [`calls`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fcalls?format=md) — Real-time 1:1 audio calls (Stage 1). 16 methods.
24
24
  - [`camera`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fcamera?format=md) — Camera capability. 4 methods · returns FileHandles.
25
- - [`clipboard`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fclipboard?format=md) — System clipboard. 3 methods.
25
+ - [`clipboard`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fclipboard?format=md) — System clipboard. 5 methods.
26
26
  - [`contacts`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fcontacts?format=md) — Device address book. 4 methods.
27
27
  - [`file_picker`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Ffile_picker?format=md) — User-mediated document access OUTSIDE the app sandbox. 3 methods · returns FileHandles.
28
28
  - [`files`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Ffiles?format=md) — App-scoped document storage. 6 methods · returns FileHandles.
29
29
  - [`godot`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fgodot?format=md) — A Godot game surface. 3 methods · not in Brandy.
30
- - [`haptic`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fhaptic?format=md) — Haptic capability. 1 method.
30
+ - [`haptic`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fhaptic?format=md) — Haptic capability. 3 methods.
31
31
  - [`health`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fhealth?format=md) — Platform HEALTH store. 5 methods.
32
32
  - [`himi_apps`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fhimi_apps?format=md) — Cross-app open / host queries. 4 methods · not in Brandy.
33
33
  - [`iap`](https://apps.wix.com/_api/himi-serve/authoring/resource/web-sdk%2Fiap?format=md) — In-app-purchase capability. 9 methods.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1577.0",
3
+ "version": "0.1579.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": [
@@ -71,5 +71,5 @@
71
71
  "artifactId": "himalaya-cli"
72
72
  }
73
73
  },
74
- "falconPackageHash": "9902ef55d169da2c82868b986ceafb0c54b8102e501f513b72e4c8c0"
74
+ "falconPackageHash": "939c5230409902d493ec5b28c46a83496669032bc8f663eabd42dcf6"
75
75
  }
@@ -20977,6 +20977,20 @@ const SCREENS = runtimeSetting({
20977
20977
  "hiddenPath": "$item.noTypeTag"
20978
20978
  }
20979
20979
  },
20980
+ {
20981
+ "id": "himi_services.row.$item.id/ended",
20982
+ "body": {
20983
+ "package": "himalaya",
20984
+ "type": "Text",
20985
+ "props": {
20986
+ "text": "$item.endedText",
20987
+ "style": "caption",
20988
+ "color": "textSecondary",
20989
+ "maxLines": 1,
20990
+ "collapseWhenEmpty": true
20991
+ }
20992
+ }
20993
+ },
20980
20994
  {
20981
20995
  "id": "",
20982
20996
  "body": {
@@ -80,6 +80,19 @@ type CoursePolicyShape = {
80
80
  };
81
81
  };
82
82
 
83
+ /** A completed course, independent of whether bookings are currently allowed. Missing,
84
+ * invalid or reversed dates are unknown, never evidence that a course has ended. */
85
+ export function courseHasEnded(service: CoursePolicyShape, now: number): boolean {
86
+ const start = service.schedule?.firstSessionStart ?? "";
87
+ const end = service.schedule?.lastSessionEnd ?? "";
88
+ // The schedule has no zone beside these fields. A local wall time must not be interpreted
89
+ // in the device's zone and presented as a confirmed business end instant.
90
+ if (!HAS_OFFSET.test(start) || !HAS_OFFSET.test(end)) return false;
91
+ const first = validDate(start)?.getTime() ?? NaN;
92
+ const last = validDate(end)?.getTime() ?? NaN;
93
+ return Number.isFinite(now) && Number.isFinite(first) && Number.isFinite(last) && first <= last && last <= now;
94
+ }
95
+
83
96
  /** Whether the business takes course bookings at `now` (ms): the course has not ended, has not
84
97
  * started (unless the policy lets people join late), and `now` is inside its booking window —
85
98
  * which the early and late limits count from the first session. */
@@ -238,6 +251,14 @@ export function serviceDurationText(service: PricedServiceShape, label: BookingL
238
251
 
239
252
  function validDate(value: string | undefined): Date | undefined {
240
253
  if (!value) return undefined;
254
+ const parts = /^(\d{4})-(\d{2})-(\d{2})(?:T|$)/i.exec(value);
255
+ if (!parts) return undefined;
256
+ const year = Number(parts[1]), month = Number(parts[2]) - 1, day = Number(parts[3]);
257
+ // Date accepts February 30 by rolling into March. Check the stated calendar day first;
258
+ // setUTCFullYear also preserves years 0–99, which Date.UTC would reinterpret as 1900–1999.
259
+ const calendar = new Date(0);
260
+ calendar.setUTCFullYear(year, month, day);
261
+ if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month || calendar.getUTCDate() !== day) return undefined;
241
262
  const date = new Date(value);
242
263
  return Number.isFinite(date.getTime()) ? date : undefined;
243
264
  }
@@ -21,6 +21,7 @@
21
21
  export type CopyPackValue = string | { source: string; translation: string };
22
22
 
23
23
  let stashedCopy: Record<string, CopyPackValue> = Object.create(null);
24
+ let copyReceived = false;
24
25
 
25
26
  /** Absorb the `copy` onAppear param (customer strings or stock translation records)
26
27
  * from an action arg. Call first in every action that can receive onAppear
@@ -44,15 +45,19 @@ export function stashCopy(arg: unknown): void {
44
45
  }
45
46
  }
46
47
  stashedCopy = next;
48
+ copyReceived = true;
47
49
  } catch {
48
50
  // Malformed pack param — keep the stock copy rather than dying mid-action.
49
51
  }
50
52
  }
51
53
 
52
- /** The provisioned override for `key`, or `fallback` (the stock copy) when no
53
- * pack / no such key reached this worker. */
54
- export function copyParam(key: string, fallback: string): string {
54
+ /** The provisioned override for `key`, or `fallback` (the stock copy). An optional
55
+ * restored label survives only until a current copy pack arrives. */
56
+ export function copyParam(key: string, fallback: string, restoredFallback = fallback): string {
55
57
  const value = stashedCopy[key];
58
+ // A disk-restored row may paint before onAppear supplies its copy pack. Keep its
59
+ // last label only before a pack arrives; even an empty current pack selects stock copy.
60
+ if (value === undefined) return copyReceived ? fallback : restoredFallback;
56
61
  if (typeof value === "string") return value !== "" ? value : fallback;
57
62
  return value?.source === fallback && value.translation !== "" ? value.translation : fallback;
58
63
  }
@@ -10,7 +10,7 @@
10
10
  import { defineBackendCollection } from "../_shared/collection-service.js";
11
11
  import { COLLECTION_CHROME_MIN_ITEMS } from "../_shared/collection-contract.js";
12
12
  import { queryServiceCategories, queryServices, type WixService } from "@himalaya/wix-bookings";
13
- import { categoryName, courseDatesText, serviceDurationText, servicePriceLine, type BookingLabel } from "../_shared/himi-bookings.js";
13
+ import { categoryName, courseDatesText, courseHasEnded, serviceDurationText, servicePriceLine, type BookingLabel } from "../_shared/himi-bookings.js";
14
14
  import { formatServicePrice } from "../_shared/wix-service-price.js";
15
15
  import { getAll, upsert, type DbEntity } from "@himalaya/local-db";
16
16
  import { initials } from "../_shared/himi-text.js";
@@ -60,6 +60,9 @@ interface Row {
60
60
  /** "Class" or "Course"; "" for an appointment. */
61
61
  typeTag: string;
62
62
  noTypeTag: boolean;
63
+ endedText: string;
64
+ /** Retain course dates so a cached catalog can refresh its status after the course ends. */
65
+ courseSchedule?: { firstSessionStart?: string; lastSessionEnd?: string };
63
66
  /** "1 hr · ₪70"; a course: "Nov 3 – Dec 15, 2026 · ₪1,200". "" when the record has neither. */
64
67
  meta: string;
65
68
  /** Online booking by time is possible: online booking is on and the service is not a course. */
@@ -186,8 +189,8 @@ function typeTagOf(type: unknown): string {
186
189
  return "";
187
190
  }
188
191
 
189
- function spokenName(row: Pick<Row, "name" | "typeTag" | "tagline" | "meta">): string {
190
- return [row.name, row.typeTag, row.tagline, row.meta].filter(Boolean).join(", ");
192
+ function spokenName(row: Pick<Row, "name" | "typeTag" | "tagline" | "meta" | "endedText">): string {
193
+ return [row.name, row.typeTag, row.endedText, row.tagline, row.meta].filter(Boolean).join(", ");
191
194
  }
192
195
 
193
196
  function bookCtaFor(request: boolean): string {
@@ -212,6 +215,11 @@ function norm(service: WixService): Row {
212
215
  ].filter(Boolean);
213
216
  const image = imageUrl(service);
214
217
  const isCourse = service.type === "COURSE";
218
+ const courseSchedule = isCourse ? {
219
+ firstSessionStart: service.schedule?.firstSessionStart,
220
+ lastSessionEnd: service.schedule?.lastSessionEnd,
221
+ } : undefined;
222
+ const endedText = isCourse && courseHasEnded(service, Date.now()) ? copyParam("services.ended", "Ended") : "";
215
223
  const price = servicePriceLine(service, listLabel).text;
216
224
  const meta = [isCourse ? courseDatesText(service) : serviceDurationText(service, listLabel), price].filter(Boolean).join(" · ");
217
225
  const typeTag = typeTagOf(service.type);
@@ -234,18 +242,20 @@ function norm(service: WixService): Row {
234
242
  tagline,
235
243
  typeTag,
236
244
  noTypeTag: !typeTag,
245
+ endedText,
246
+ courseSchedule,
237
247
  meta,
238
248
  canBook,
239
249
  noBook: !canBook,
240
250
  bookCta: bookCtaFor(request),
241
251
  bookA11y: bookLabel(name, request),
242
- a11y: spokenName({ name, typeTag, tagline, meta }),
252
+ a11y: spokenName({ name, typeTag, endedText, tagline, meta }),
243
253
  };
244
254
  }
245
255
 
246
256
  /** A row saved by an older worker (offline cache, restored state) has none of the new
247
257
  * fields: it renders with no tagline, meta or tag, and no Book pill until the live read. */
248
- function withRowDefaults(row: Partial<Row>): Row {
258
+ function withRowDefaults(row: Partial<Row>, shownEnded?: string): Row {
249
259
  const name = typeof row.name === "string" && row.name ? row.name : "Service";
250
260
  const str = (v: unknown) => (typeof v === "string" ? v : "");
251
261
  const image = str(row.image);
@@ -253,6 +263,9 @@ function withRowDefaults(row: Partial<Row>): Row {
253
263
  const tagline = str(row.tagline);
254
264
  const meta = str(row.meta);
255
265
  const canBook = row.canBook === true;
266
+ const courseSchedule = row.courseSchedule;
267
+ const endedText = courseSchedule && courseHasEnded({ schedule: courseSchedule }, Date.now())
268
+ ? copyParam("services.ended", "Ended", shownEnded || str(row.endedText) || "Ended") : "";
256
269
  return {
257
270
  ...row,
258
271
  id: str(row.id),
@@ -268,12 +281,13 @@ function withRowDefaults(row: Partial<Row>): Row {
268
281
  tagline,
269
282
  typeTag,
270
283
  noTypeTag: !typeTag,
284
+ endedText,
271
285
  meta,
272
286
  canBook,
273
287
  noBook: !canBook,
274
288
  bookCta: str(row.bookCta) || bookCtaFor(false),
275
289
  bookA11y: str(row.bookA11y) || bookLabel(name),
276
- a11y: str(row.a11y) || spokenName({ name, typeTag, tagline, meta }),
290
+ a11y: spokenName({ name, typeTag, endedText, tagline, meta }),
277
291
  };
278
292
  }
279
293
 
@@ -287,6 +301,9 @@ let categoriesInFlight = false;
287
301
  let categoriesFailedAt = 0;
288
302
  const CATEGORY_RETRY_MS = 60_000;
289
303
  const rowCategories = new Map<string, number>();
304
+ /** A restored Ended label bridges only the first live page after boot, because an app that sends no
305
+ * copy pack gives no sign that none is coming: after it, current copy (pack or stock) decides. */
306
+ let restoredEndedBridge = true;
290
307
 
291
308
  /** Repaint the chips only, on the state already shown. Re-deriving from the query controller
292
309
  * would publish its working state, which a revalidation deliberately holds back (a restored list
@@ -302,7 +319,7 @@ function withChips(s: S): S {
302
319
  // rows of the whole list (no category, no query), or the stand-in would be one category.
303
320
  const whole = filter === "all" && !(typeof collection?.query === "string" && collection.query.trim());
304
321
  // Not while a load is out: the rows on screen may still be a previous search's.
305
- if (!siteCategories?.length && rowCategories.size === 0 && whole && s.isLoading !== true) rememberRowCategories((s.results ?? []).map(withRowDefaults));
322
+ if (!siteCategories?.length && rowCategories.size === 0 && whole && s.isLoading !== true) rememberRowCategories((s.results ?? []).map((row) => withRowDefaults(row)));
306
323
  const chips = categoryChips(filter);
307
324
  return { ...s, categoryChips: chips, hideCategories: collection?.hideChrome === true || (chips.length < 3 && !filter.startsWith("value:")) };
308
325
  }
@@ -345,6 +362,11 @@ function categoryChips(filter: string): CategoryChip[] {
345
362
 
346
363
  const svc = defineBackendCollection<S, Row>("himiBookingServices", {
347
364
  restorePagesOnLoad: true,
365
+ onLoad() {
366
+ // Copy is stashed before this hook. Repaint restored labels immediately, even
367
+ // when the background query holds the old page through a slow or failed read.
368
+ (svc as unknown as { normalizeRestored?: () => unknown }).normalizeRestored?.();
369
+ },
348
370
  state: { status: "loading", query: "", all: [], results: [], emptyText: "", countLabel: "", errored: false,
349
371
  notErrored: true, isLoading: true, notLoading: false, hasResults: false, noEmpty: true,
350
372
  categoryChips: [], hideCategories: true, hideRows: false, noMatchText: "", hasQuery: false, noQuery: true, categoriesLabel: "" },
@@ -377,11 +399,10 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
377
399
  },
378
400
  actions: {
379
401
  categoriesLanded: (s) => withChips(s),
380
- // A snapshot saved by an older worker comes back without the new row fields; a missing
381
- // `noBook` would show Book on a course until the live read. Give restored rows the defaults.
402
+ // Normalize old snapshots and refresh Ended/copy before revalidation settles.
403
+ // A missing `noBook` must never show Book on a restored course.
382
404
  normalizeRestored: (s) => {
383
405
  const rows = Array.isArray(s.results) ? s.results : [];
384
- if (rows.every((row) => typeof (row as Partial<Row>).noBook === "boolean")) return s;
385
406
  const fixed = rows.map((row) => withRowDefaults(row as Partial<Row>));
386
407
  return { ...s, results: fixed, all: fixed };
387
408
  },
@@ -389,7 +410,11 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
389
410
  apply(s, page) {
390
411
  const loading = page.status === "loading";
391
412
  const errored = page.status === "error";
392
- const rows = page.items.map(withRowDefaults);
413
+ // A live row is built before a cold boot's copy pack arrives: the first live page keeps the
414
+ // Ended label the restored row for the same course showed, until a current pack replaces it.
415
+ const shown = new Map((restoredEndedBridge && Array.isArray(s.results) ? s.results : []).map((row) => [row.id, row.endedText]));
416
+ const rows = page.items.map((item) => withRowDefaults(item, shown.get(item.id)));
417
+ if (page.metadata?.fromCache === false && !loading && !errored) restoredEndedBridge = false;
393
418
  // The stand-in categories come from the accepted rows of the whole list (a superseded
394
419
  // response never reaches apply): a live page replaces them, the device's copy only fills a gap.
395
420
  if (!page.criteria.query && page.criteria.filter === "all" && rows.length > 0) {
@@ -427,7 +452,6 @@ const svc = defineBackendCollection<S, Row>("himiBookingServices", {
427
452
  // (there is no Refine now): both go back to the business's own view before the first read.
428
453
  type Retired = { getState?: () => { collection?: { filter?: unknown; sort?: unknown } };
429
454
  collectionFilter?: (arg: unknown) => unknown; collectionSort?: (arg: unknown) => unknown };
430
- (svc as unknown as { normalizeRestored?: () => unknown }).normalizeRestored?.();
431
455
  const retired = svc as unknown as Retired;
432
456
  const persisted = retired.getState?.()?.collection;
433
457
  if (persisted?.filter === "saved") void retired.collectionFilter?.({ selectedIds: ["all"] });
@@ -21,6 +21,7 @@
21
21
  export type CopyPackValue = string | { source: string; translation: string };
22
22
 
23
23
  let stashedCopy: Record<string, CopyPackValue> = Object.create(null);
24
+ let copyReceived = false;
24
25
 
25
26
  /** Absorb the `copy` onAppear param (customer strings or stock translation records)
26
27
  * from an action arg. Call first in every action that can receive onAppear
@@ -44,15 +45,19 @@ export function stashCopy(arg: unknown): void {
44
45
  }
45
46
  }
46
47
  stashedCopy = next;
48
+ copyReceived = true;
47
49
  } catch {
48
50
  // Malformed pack param — keep the stock copy rather than dying mid-action.
49
51
  }
50
52
  }
51
53
 
52
- /** The provisioned override for `key`, or `fallback` (the stock copy) when no
53
- * pack / no such key reached this worker. */
54
- export function copyParam(key: string, fallback: string): string {
54
+ /** The provisioned override for `key`, or `fallback` (the stock copy). An optional
55
+ * restored label survives only until a current copy pack arrives. */
56
+ export function copyParam(key: string, fallback: string, restoredFallback = fallback): string {
55
57
  const value = stashedCopy[key];
58
+ // A disk-restored row may paint before onAppear supplies its copy pack. Keep its
59
+ // last label only before a pack arrives; even an empty current pack selects stock copy.
60
+ if (value === undefined) return copyReceived ? fallback : restoredFallback;
56
61
  if (typeof value === "string") return value !== "" ? value : fallback;
57
62
  return value?.source === fallback && value.translation !== "" ? value.translation : fallback;
58
63
  }