@wix/himalaya-cli 0.1037.0 → 0.1039.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 (94) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/genie-chat/src/index.d.ts +119 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-genie-chat.mjs +51 -1
  3. package/dist/cli.mjs +2523 -1327
  4. package/dist/preview-assets.generated.bin +0 -0
  5. package/guides/designing-himi-screens.md +3 -2
  6. package/guides/getting-started.md +3 -2
  7. package/guides/headless-loop.md +11 -3
  8. package/guides/himi-skill.md +381 -0
  9. package/guides/himi-test.md +66 -1
  10. package/guides/index.json +4 -0
  11. package/guides/recipe-dynamic-list.md +3 -3
  12. package/guides/recipe-form-submit.md +3 -3
  13. package/guides/recipe-onboarding.md +3 -3
  14. package/guides/recipe-search.md +3 -3
  15. package/guides/sdk-reference.json +74 -2
  16. package/guides/skill.md +122 -0
  17. package/guides/trim-a-template.md +5 -0
  18. package/guides/wix-site-to-himi-app.md +4 -2
  19. package/package.json +2 -2
  20. package/templates/beauty-salon/dev/index.ts +58 -22
  21. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +144 -29
  22. package/templates/beauty-salon-owner/dev/index.ts +2148 -1262
  23. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +338 -26
  24. package/templates/beauty-salon-owner/tier5-src/_shared/aria-mentions.ts +202 -0
  25. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  26. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-ask/index.ts +6 -0
  27. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-manage/index.ts +64 -6
  28. package/templates/branded-lite/dev/index.ts +61 -25
  29. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +144 -29
  30. package/templates/community-club-owner/dev/index.ts +2151 -1265
  31. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +338 -26
  32. package/templates/community-club-owner/tier5-src/_shared/aria-mentions.ts +202 -0
  33. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  34. package/templates/community-club-owner/tier5-src/himi-owner-lite-ask/index.ts +6 -0
  35. package/templates/community-club-owner/tier5-src/himi-owner-lite-manage/index.ts +64 -6
  36. package/templates/events-meetups-owner/dev/index.ts +2151 -1265
  37. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +338 -26
  38. package/templates/events-meetups-owner/tier5-src/_shared/aria-mentions.ts +202 -0
  39. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  40. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-ask/index.ts +6 -0
  41. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-manage/index.ts +64 -6
  42. package/templates/gym-fitness/dev/index.ts +58 -22
  43. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +144 -29
  44. package/templates/gym-fitness-owner/dev/index.ts +2148 -1262
  45. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +338 -26
  46. package/templates/gym-fitness-owner/tier5-src/_shared/aria-mentions.ts +202 -0
  47. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  48. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-ask/index.ts +6 -0
  49. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-manage/index.ts +64 -6
  50. package/templates/nonprofit-cause-owner/dev/index.ts +2148 -1262
  51. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +338 -26
  52. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-mentions.ts +202 -0
  53. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  54. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-ask/index.ts +6 -0
  55. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-manage/index.ts +64 -6
  56. package/templates/owner-lite/dev/index.ts +2145 -1259
  57. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +338 -26
  58. package/templates/owner-lite/tier5-src/_shared/aria-mentions.ts +202 -0
  59. package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  60. package/templates/owner-lite/tier5-src/himi-owner-lite-ask/index.ts +6 -0
  61. package/templates/owner-lite/tier5-src/himi-owner-lite-manage/index.ts +64 -6
  62. package/templates/podcast-creator-owner/dev/index.ts +2151 -1265
  63. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +338 -26
  64. package/templates/podcast-creator-owner/tier5-src/_shared/aria-mentions.ts +202 -0
  65. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  66. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-ask/index.ts +6 -0
  67. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-manage/index.ts +64 -6
  68. package/templates/portfolio-creative-owner/dev/index.ts +2148 -1262
  69. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +338 -26
  70. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-mentions.ts +202 -0
  71. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  72. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-ask/index.ts +6 -0
  73. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-manage/index.ts +64 -6
  74. package/templates/restaurant-owner/dev/index.ts +2148 -1262
  75. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +338 -26
  76. package/templates/restaurant-owner/tier5-src/_shared/aria-mentions.ts +202 -0
  77. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  78. package/templates/restaurant-owner/tier5-src/himi-owner-lite-ask/index.ts +6 -0
  79. package/templates/restaurant-owner/tier5-src/himi-owner-lite-manage/index.ts +64 -6
  80. package/templates/retail-store/dev/index.ts +1 -1
  81. package/templates/retail-store-owner/dev/index.ts +2151 -1265
  82. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +338 -26
  83. package/templates/retail-store-owner/tier5-src/_shared/aria-mentions.ts +202 -0
  84. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  85. package/templates/retail-store-owner/tier5-src/himi-owner-lite-ask/index.ts +6 -0
  86. package/templates/retail-store-owner/tier5-src/himi-owner-lite-manage/index.ts +64 -6
  87. package/templates/yoga-studio/dev/index.ts +58 -22
  88. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +144 -29
  89. package/templates/yoga-studio-owner/dev/index.ts +2148 -1262
  90. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +338 -26
  91. package/templates/yoga-studio-owner/tier5-src/_shared/aria-mentions.ts +202 -0
  92. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
  93. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-ask/index.ts +6 -0
  94. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-manage/index.ts +64 -6
@@ -0,0 +1,202 @@
1
+ // Mentions for the Aria chat: let the owner point at a product, a contact, a
2
+ // coupon… and have the reference travel WITH the turn, beside the text.
3
+ //
4
+ // The shape follows the native Aria app (iOS + Android), which talks to the same
5
+ // Wix services:
6
+ //
7
+ // - The picker is two levels: the item PROVIDERS the site has installed (the
8
+ // categories), then one provider's ITEMS, searchable.
9
+ // - Only an ALLOWLISTED provider is offered, under a fixed label. The service
10
+ // lists every provider an installed app registers, and most of them are not
11
+ // things an owner talks to an assistant about.
12
+ // - A pick becomes a chip above the composer; the message text carries NO
13
+ // token. On send, each chip becomes a `global` reference whose `data` names
14
+ // the item and its category — see `mentionReference` in the chat client.
15
+ //
16
+ // This file is pure: the catalogue, the model the chat core keeps, and the flat
17
+ // view the descriptor binds. The network calls and the state plumbing live in
18
+ // aria-chat.ts beside everything else that talks to the chat-service.
19
+ //
20
+ // Copied verbatim into every generated starter app, so it explains itself
21
+ // without pointing at anything only this repo has.
22
+
23
+ /** One allowlisted category: the label shown AND sent (the model reads the
24
+ * English label, so it is not localized), and whether its items have
25
+ * pictures worth showing. */
26
+ export interface MentionCategory { label: string; showsImage: boolean }
27
+
28
+ /** The providers a picker offers, keyed by the provider's `providerName`
29
+ * lowercased. Transcribed from the native Aria app's `MentionProviderCatalog`
30
+ * — the same fifteen entries, labels and picture flags — so both clients
31
+ * offer the same categories and send the same `type`. */
32
+ export const MENTION_CATALOG: Readonly<Record<string, MentionCategory>> = {
33
+ "contacts": { label: "Contacts", showsImage: true },
34
+ "product": { label: "Products", showsImage: true },
35
+ "bookings services": { label: "Bookings Services", showsImage: true },
36
+ "categories": { label: "Product Categories", showsImage: false },
37
+ "cms": { label: "CMS", showsImage: false },
38
+ "wix forms": { label: "Forms", showsImage: false },
39
+ "pay links": { label: "Pay Links", showsImage: false },
40
+ "coupons": { label: "Coupons", showsImage: false },
41
+ "menu item": { label: "Menu Items", showsImage: true },
42
+ "wix blog": { label: "Blog Posts", showsImage: true },
43
+ "campaign selection": { label: "Email Marketing Campaigns", showsImage: true },
44
+ "pricing plans": { label: "Pricing Plans", showsImage: true },
45
+ "wix table reservation experience": { label: "Experiences", showsImage: true },
46
+ "onlineprogramsprovider": { label: "Online Programs", showsImage: true },
47
+ "wix events": { label: "Events", showsImage: true },
48
+ };
49
+
50
+ /** An installed, allowlisted provider. */
51
+ export interface MentionProvider {
52
+ key: string;
53
+ label: string;
54
+ showsImage: boolean;
55
+ paging: "CURSOR" | "OFFSET" | "NONE";
56
+ }
57
+
58
+ /** The allowlisted subset of what the site has installed, one entry per
59
+ * category label (two installed apps can register the same category — the
60
+ * first wins, as natively), sorted by label so the order does not depend on
61
+ * the order the service happened to list them in. */
62
+ export function allowlisted(
63
+ installed: Array<{ key: string; name: string; paging: "CURSOR" | "OFFSET" | "NONE" }>,
64
+ ): MentionProvider[] {
65
+ const seen = new Set<string>();
66
+ const out: MentionProvider[] = [];
67
+ for (const p of installed) {
68
+ const cat = MENTION_CATALOG[p.name.toLowerCase()];
69
+ if (!cat || seen.has(cat.label)) continue;
70
+ seen.add(cat.label);
71
+ out.push({ key: p.key, label: cat.label, showsImage: cat.showsImage, paging: p.paging });
72
+ }
73
+ return out.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
74
+ }
75
+
76
+ /** One item row in the picker. */
77
+ export interface MentionItemRow {
78
+ /** Row id — positional, because an item id is the service's and is not
79
+ * guaranteed to be safe inside a component id. */
80
+ id: string;
81
+ /** The item id the reference names, and what a tap on the row carries: a
82
+ * newer search can re-order the rows between paint and tap, so a
83
+ * positional id would pick the wrong item. */
84
+ itemId: string;
85
+ title: string;
86
+ subtitle: string;
87
+ subtitleHidden: boolean;
88
+ pictureUrl: string;
89
+ pictureHidden: boolean;
90
+ }
91
+
92
+ /** A pick, waiting above the composer to be sent with the next turn. */
93
+ export interface MentionPick {
94
+ /** Chip id, minted by the core. */
95
+ id: string;
96
+ /** Provider + item identify a pick; an item id is only unique within its
97
+ * provider, so picking the same item twice keeps one chip. */
98
+ providerKey: string;
99
+ itemId: string;
100
+ /** The item's name. */
101
+ label: string;
102
+ /** The category's English label — what the reference's `data.type` carries. */
103
+ category: string;
104
+ }
105
+
106
+ /** Everything the picker knows. Kept by the chat core OUTSIDE the reducer
107
+ * state — see `mentionModel` there for why. */
108
+ export interface MentionModel {
109
+ open: boolean;
110
+ /** The business the providers and picks belong to. A switch drops both: a
111
+ * product of the previous business is not something to send to this one. */
112
+ site: string;
113
+ /** Null until listed for `site`. */
114
+ providers: MentionProvider[] | null;
115
+ providersLoading: boolean;
116
+ providersFailed: string;
117
+ /** The provider being browsed, or null at the category level. */
118
+ provider: MentionProvider | null;
119
+ query: string;
120
+ items: MentionItemRow[];
121
+ itemsLoading: boolean;
122
+ itemsFailed: string;
123
+ picks: MentionPick[];
124
+ }
125
+
126
+ export function emptyMentionModel(): MentionModel {
127
+ return {
128
+ open: false, site: "", providers: null, providersLoading: false, providersFailed: "",
129
+ provider: null, query: "", items: [], itemsLoading: false, itemsFailed: "", picks: [],
130
+ };
131
+ }
132
+
133
+ /** Worker-emitted mention copy. */
134
+ export interface MentionLabels {
135
+ /** Panel title at the category level. */
136
+ title: string;
137
+ /** No allowlisted provider is installed. */
138
+ noProviders: string;
139
+ /** A provider answered with nothing (or nothing matching). */
140
+ noItems: string;
141
+ /** The provider list could not be fetched. */
142
+ providersFailed: string;
143
+ /** This host cannot reach the provider service at all (the web preview). */
144
+ unavailable: string;
145
+ }
146
+
147
+ /** The flat shape the descriptor binds, in the polarity the renderers want
148
+ * (`hiddenPath` HIDES when true). Derived from the model and nothing else. */
149
+ export interface MentionView {
150
+ /** The whole panel. */
151
+ hidden: boolean;
152
+ /** The "@ Mention" entry control. Hidden while the panel is open — one way
153
+ * in, one way out. */
154
+ openHidden: boolean;
155
+ title: string;
156
+ backHidden: boolean;
157
+ providers: Array<{ id: string; key: string; label: string }>;
158
+ providersHidden: boolean;
159
+ /** The search field + item list (the second level). */
160
+ itemsHidden: boolean;
161
+ items: MentionItemRow[];
162
+ loadingHidden: boolean;
163
+ error: string;
164
+ errorHidden: boolean;
165
+ empty: string;
166
+ emptyHidden: boolean;
167
+ /** The picks, as chips above the composer. */
168
+ chips: Array<{ id: string; label: string }>;
169
+ chipsHidden: boolean;
170
+ }
171
+
172
+ export function mentionView(m: MentionModel, labels: MentionLabels): MentionView {
173
+ const atItems = !!m.provider;
174
+ const loading = atItems ? m.itemsLoading : m.providersLoading;
175
+ const error = atItems ? m.itemsFailed : m.providersFailed;
176
+ const providers = m.providers ?? [];
177
+ // "Nothing here" is only said once an answer has actually come back — a
178
+ // list still loading, or one that failed, is not an empty one.
179
+ const empty = !m.open || loading || error ? ""
180
+ : atItems ? (m.items.length ? "" : labels.noItems)
181
+ : (m.providers !== null && providers.length === 0 ? labels.noProviders : "");
182
+ return {
183
+ hidden: !m.open,
184
+ openHidden: m.open,
185
+ title: atItems ? m.provider!.label : labels.title,
186
+ backHidden: !atItems,
187
+ providers: providers.map((p) => ({ id: p.key, key: p.key, label: p.label })),
188
+ providersHidden: !m.open || atItems || providers.length === 0,
189
+ itemsHidden: !m.open || !atItems,
190
+ items: atItems ? m.items : [],
191
+ loadingHidden: !m.open || !loading,
192
+ error: m.open ? error : "",
193
+ errorHidden: !m.open || !error,
194
+ empty,
195
+ emptyHidden: !empty,
196
+ chips: m.picks.map((p) => ({ id: p.id, label: p.label })),
197
+ chipsHidden: m.picks.length === 0,
198
+ };
199
+ }
200
+
201
+ /** How many item rows one query asks for — the native page size. */
202
+ export const MENTION_PAGE_SIZE = 30;
@@ -201,6 +201,18 @@ const core = createAriaChat({
201
201
  // English. Says no "sign in": the owner already is, and the missing piece is
202
202
  // the app's binding, so there is no action for them to take.
203
203
  unavailable: () => copyParam("ownerAria.unavailable", "Aria can't reach your Wix account from here."),
204
+ // The mention picker. Through copyParam for the same reason as the rest.
205
+ // The CATEGORY labels are not here, deliberately: they are what the
206
+ // reference's `data.type` carries to the model, so they stay English.
207
+ mentionTitle: () => copyParam("ownerAria.mention.title", "Mention"),
208
+ mentionNoProviders: () => copyParam("ownerAria.mention.noProviders", "There's nothing on this site to mention yet."),
209
+ mentionNoItems: () => copyParam("ownerAria.mention.noItems", "Nothing found."),
210
+ mentionFailed: () => copyParam("ownerAria.mention.failed", "Couldn't load what you can mention."),
211
+ mentionUnavailable: () => copyParam("ownerAria.mention.unavailable", "Mentions aren't available here."),
212
+ // Takes the CATEGORY label, which stays English (see above) — only the
213
+ // sentence around it is localized.
214
+ mentionItemsFailed: (category: string) =>
215
+ copyTemplate("ownerAria.mention.itemsFailed", "Couldn't load {category}.", { category }),
204
216
  attachUploading: () => copyParam("ownerAria.attach.uploading", "Uploading…"),
205
217
  attachFailed: () => copyParam("ownerAria.attach.failed", "Couldn't upload"),
206
218
  attachTooMany: (max) => copyTemplate("ownerAria.attach.tooMany", "You can attach up to {max} photos.", { max }),
@@ -281,6 +293,13 @@ const svc = defineService<AriaChatState, {
281
293
  widgetPick: (s: AriaChatState, arg?: AriaActionArg) => AriaChatState;
282
294
  widgetQuery: (s: AriaChatState, arg?: AriaActionArg) => AriaChatState;
283
295
  widgetAnswer: (s: AriaChatState, arg?: AriaActionArg) => AriaChatState;
296
+ mentionOpen: (s: AriaChatState) => Promise<AriaChatState | undefined>;
297
+ mentionClose: (s: AriaChatState) => AriaChatState;
298
+ mentionBack: (s: AriaChatState) => AriaChatState;
299
+ mentionProvider: (s: AriaChatState, arg?: AriaActionArg) => Promise<AriaChatState | undefined>;
300
+ mentionQuery: (s: AriaChatState, arg?: AriaActionArg) => Promise<AriaChatState | undefined>;
301
+ mentionPick: (s: AriaChatState, arg?: AriaActionArg) => AriaChatState;
302
+ mentionRemove: (s: AriaChatState, arg?: AriaActionArg) => AriaChatState;
284
303
  attach: (s: AriaChatState, arg?: AriaActionArg) => Promise<AriaChatState>;
285
304
  attachmentSettled: (s: AriaChatState, arg?: AriaActionArg) => AriaChatState;
286
305
  removeAttachment: (s: AriaChatState, arg?: AriaActionArg) => AriaChatState;
@@ -321,6 +340,16 @@ const svc = defineService<AriaChatState, {
321
340
  widgetPick: core.widgetPick,
322
341
  widgetQuery: core.widgetQuery,
323
342
  widgetAnswer: core.widgetAnswer,
343
+ // Mentions: the "@ Mention" control, the two-level picker and the
344
+ // chips above the composer. Every one is named by the descriptor, so every
345
+ // one has to be exported here — see the note on the picker family above.
346
+ mentionOpen: core.mentionOpen,
347
+ mentionClose: core.mentionClose,
348
+ mentionBack: core.mentionBack,
349
+ mentionProvider: core.mentionProvider,
350
+ mentionQuery: core.mentionQuery,
351
+ mentionPick: core.mentionPick,
352
+ mentionRemove: core.mentionRemove,
324
353
  // The composer's photos. `attachmentSettled` is dispatched by the upload
325
354
  // itself (see `attachments.settled` above), never by the descriptor.
326
355
  attach: core.attach,
@@ -53,6 +53,11 @@ interface AskState {
53
53
  results: Record<string, QueryResult>;
54
54
  /** The validated-on-arrival encoded component tree. */
55
55
  treeJson: string;
56
+ /** always "ready" once this worker exists. The slot's fallback keys a
57
+ * FragmentSlot on it: an unresolved path (worker not hydrated yet) takes the
58
+ * default loading case, so only a tree that really failed validation shows
59
+ * "Can't show that answer". */
60
+ phase: "ready";
56
61
  /** Authored-UI visibility flags. */
57
62
  isWorking: boolean;
58
63
  notWorking: boolean;
@@ -130,6 +135,7 @@ const initialState: AskState = {
130
135
  repairs: "",
131
136
  results: {},
132
137
  treeJson: enc(STARTER),
138
+ phase: "ready",
133
139
  isWorking: false,
134
140
  notWorking: true,
135
141
  recents: [],
@@ -19,6 +19,11 @@ import { stashCopy, copyParam, copyTemplate } from '../_shared/himi-copy.js';
19
19
  import { DASHBOARD_PATHS, deriveManage, parseAreas, recentAreas, rememberArea, withMetrics, type ManageArea, type Metric } from './logic.js';
20
20
 
21
21
  interface State {
22
+ /** `loading` until the descriptor has delivered the areas, then
23
+ * `ready`. The descriptor keys a FragmentSlot on it whose DEFAULT case is the
24
+ * skeleton, so an unresolved path (worker not booted yet) is loading too —
25
+ * never the filtered-empty "No matching business areas" state. */
26
+ status: 'loading' | 'ready';
22
27
  areas: ManageArea[];
23
28
  results: ManageArea[];
24
29
  sections: Array<{ id: string; label: string; count: string; areas: ManageArea[] }>;
@@ -37,6 +42,17 @@ interface State {
37
42
  }
38
43
 
39
44
  const PAGE = 50;
45
+
46
+ /** The "All" chip label — ONE function for the initial state and every derive
47
+ * after load, so the chip never changes text when the areas arrive. The copy
48
+ * pack only reaches the worker with onAppear's `load`, after boot, so the
49
+ * descriptor also bakes the pack's label into the screen input (present at
50
+ * boot); the stashed pack wins once it exists, and both pass the same trim. */
51
+ function allLabel(): string {
52
+ const input = (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input?.allLabel;
53
+ const bootLabel = typeof input === 'string' && input.trim() ? input : 'All';
54
+ return copyParam('ownerManage.all', bootLabel).trim() || 'All';
55
+ }
40
56
  const read = (arg: unknown) => arg && typeof arg === 'object' ? arg as Record<string, unknown> : {};
41
57
  const str = (arg: unknown, field: string) => String(read(arg)[field] ?? '');
42
58
  const value = (record: unknown, path: string): unknown => path.split('.').reduce<unknown>((current, key) =>
@@ -168,13 +184,38 @@ function derive(state: State, areas = state.areas): State {
168
184
  state.query,
169
185
  state.group,
170
186
  copyParam('ownerManage.areas', 'business areas'),
171
- copyParam('ownerManage.all', 'All'),
187
+ allLabel(),
172
188
  );
173
189
  const recents = recentAreas(recentIds, areas);
174
- return { ...state, ...next, recents, hasRecents: recents.length > 0, noRecents: recents.length === 0 };
190
+ // "No matching" is a statement about a filter over KNOWN areas. Before the
191
+ // areas arrive — or when this app provisions none at all — there is nothing a
192
+ // filter could have excluded, so "Clear filters" would be a lie.
193
+ const noEmpty = next.noEmpty || state.status !== 'ready' || areas.length === 0;
194
+ return { ...state, ...next, noEmpty, recents, hasRecents: recents.length > 0, noRecents: recents.length === 0 };
175
195
  }
176
196
 
197
+ // Loads are not serialized: on a warm reappearance the host's bare refresh and
198
+ // the descriptor's onAppear can both be in flight, and whichever settles last
199
+ // replaces the whole state. Each load takes a generation, and a superseded one
200
+ // never applies its own stale snapshot: it returns the newest load's result if
201
+ // that has settled, and otherwise the newest load's still-loading start state.
202
+ let loadGeneration = 0;
203
+ let newestStarted: State | undefined;
204
+ let newestSettled: { generation: number; state: State } | undefined;
205
+
177
206
  async function loadMetrics(state: State): Promise<State> {
207
+ const generation = ++loadGeneration;
208
+ newestStarted = state;
209
+ const settled = await readMetrics(state);
210
+ if (generation === loadGeneration) {
211
+ newestSettled = { generation, state: settled };
212
+ return settled;
213
+ }
214
+ if (newestSettled && newestSettled.generation === loadGeneration) return newestSettled.state;
215
+ return newestStarted ?? settled;
216
+ }
217
+
218
+ async function readMetrics(state: State): Promise<State> {
178
219
  const keys = [...new Set(state.areas.map(area => area.metricKey))];
179
220
  const settled = await Promise.allSettled(keys.map(metricFor));
180
221
  const metrics: Record<string, Metric | undefined> = {};
@@ -220,17 +261,33 @@ const svc = defineService<State, {
220
261
  handoff: (s: State, arg?: unknown) => Promise<State>;
221
262
  }>('himiOwnerLiteManage', {
222
263
  state: {
223
- areas: [], results: [], sections: [], recents: [], query: '', group: 'all', groups: [], summary: '', noEmpty: false,
264
+ status: 'loading',
265
+ areas: [], results: [], sections: [], recents: [], query: '', group: 'all',
266
+ // 'All' is always a valid filter, so the chip row paints from the first frame
267
+ // instead of appearing (and shifting the layout) when the areas arrive.
268
+ groups: [{ id: 'all', label: allLabel() }], summary: '', noEmpty: true,
224
269
  hasRecents: false, noRecents: true, isLoading: true, notLoading: false, summaryNote: '', handoffError: '',
225
270
  },
226
271
  actions: {
227
272
  load: async (state, arg) => {
228
273
  stashCopy(arg);
274
+ // A `load` with no areas comes from the host, not the descriptor: the
275
+ // warm-worker refresh lifecycle calls it bare. Before the areas are known
276
+ // it must not settle (that derived "0 business areas" and painted the
277
+ // filtered-empty state); once they are, it re-reads their summaries.
278
+ if (!('areas' in read(arg))) {
279
+ return state.status === 'ready'
280
+ ? loadMetrics(derive({ ...state, isLoading: true, notLoading: false, summaryNote: '', handoffError: '' }))
281
+ : state;
282
+ }
229
283
  const parsed = (() => { try { return JSON.parse(str(arg, 'areas') || '[]'); } catch { return []; } })();
230
284
  const areas = parseAreas(parsed);
231
- return loadMetrics(derive({ ...state, areas, isLoading: true, notLoading: false, summaryNote: '', handoffError: '' }, areas));
285
+ return loadMetrics(derive({ ...state, status: 'ready', areas, isLoading: true, notLoading: false, summaryNote: '', handoffError: '' }, areas));
232
286
  },
233
- refresh: async state => loadMetrics(derive({ ...state, isLoading: true, notLoading: false, summaryNote: '', handoffError: '' })),
287
+ // Refresh re-reads summaries for KNOWN areas. Before the descriptor has
288
+ // delivered them there is nothing to refresh, and settling here would open
289
+ // the skeleton gate over an empty hub.
290
+ refresh: async state => state.status !== 'ready' ? state : loadMetrics(derive({ ...state, isLoading: true, notLoading: false, summaryNote: '', handoffError: '' })),
234
291
  search: (state, arg) => derive({ ...state, query: str(arg, 'value').slice(0, 256) }),
235
292
  filter: (state, arg) => {
236
293
  const ids = read(arg).selectedIds;
@@ -262,5 +319,6 @@ const svc = defineService<State, {
262
319
  },
263
320
  });
264
321
 
265
- svc.load();
322
+ // No boot `svc.load()`: the areas ride the descriptor's onAppear, and a boot load
323
+ // without them painted "0 business areas" / "No matching" before they arrived.
266
324
  export { svc };