@wix/himalaya-cli 0.1038.0 → 0.1040.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 (89) hide show
  1. package/dist/cli.mjs +1950 -1220
  2. package/dist/preview-assets.generated.bin +0 -0
  3. package/guides/designing-himi-screens.md +3 -2
  4. package/guides/getting-started.md +3 -2
  5. package/guides/headless-loop.md +4 -3
  6. package/guides/himi-skill.md +381 -0
  7. package/guides/index.json +4 -0
  8. package/guides/recipe-dynamic-list.md +3 -3
  9. package/guides/recipe-form-submit.md +3 -3
  10. package/guides/recipe-onboarding.md +3 -3
  11. package/guides/recipe-search.md +3 -3
  12. package/guides/skill.md +122 -0
  13. package/guides/wix-site-to-himi-app.md +4 -2
  14. package/package.json +2 -2
  15. package/templates/beauty-salon/dev/index.ts +58 -22
  16. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +144 -29
  17. package/templates/beauty-salon-owner/config.ts +1 -1
  18. package/templates/beauty-salon-owner/dev/index.ts +507 -35
  19. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +237 -4
  20. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  21. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  22. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  23. package/templates/branded-lite/dev/index.ts +61 -25
  24. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +144 -29
  25. package/templates/community-club-owner/config.ts +1 -1
  26. package/templates/community-club-owner/dev/index.ts +507 -35
  27. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +237 -4
  28. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  29. package/templates/community-club-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  30. package/templates/community-club-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  31. package/templates/events-meetups-owner/config.ts +1 -1
  32. package/templates/events-meetups-owner/dev/index.ts +507 -35
  33. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +237 -4
  34. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  35. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  36. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  37. package/templates/gym-fitness/dev/index.ts +58 -22
  38. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +144 -29
  39. package/templates/gym-fitness-owner/config.ts +1 -1
  40. package/templates/gym-fitness-owner/dev/index.ts +507 -35
  41. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +237 -4
  42. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  43. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  44. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  45. package/templates/nonprofit-cause-owner/config.ts +1 -1
  46. package/templates/nonprofit-cause-owner/dev/index.ts +507 -35
  47. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +237 -4
  48. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  49. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  50. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  51. package/templates/owner-lite/config.ts +1 -1
  52. package/templates/owner-lite/dev/index.ts +507 -35
  53. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +237 -4
  54. package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  55. package/templates/owner-lite/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  56. package/templates/owner-lite/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  57. package/templates/podcast-creator-owner/config.ts +1 -1
  58. package/templates/podcast-creator-owner/dev/index.ts +507 -35
  59. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +237 -4
  60. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  61. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  62. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  63. package/templates/portfolio-creative-owner/config.ts +1 -1
  64. package/templates/portfolio-creative-owner/dev/index.ts +507 -35
  65. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +237 -4
  66. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  67. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  68. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  69. package/templates/restaurant-owner/config.ts +1 -1
  70. package/templates/restaurant-owner/dev/index.ts +507 -35
  71. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +237 -4
  72. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  73. package/templates/restaurant-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  74. package/templates/restaurant-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  75. package/templates/retail-store/dev/index.ts +1 -1
  76. package/templates/retail-store-owner/config.ts +1 -1
  77. package/templates/retail-store-owner/dev/index.ts +507 -35
  78. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +237 -4
  79. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  80. package/templates/retail-store-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  81. package/templates/retail-store-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
  82. package/templates/yoga-studio/dev/index.ts +58 -22
  83. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +144 -29
  84. package/templates/yoga-studio-owner/config.ts +1 -1
  85. package/templates/yoga-studio-owner/dev/index.ts +507 -35
  86. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +237 -4
  87. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +28 -0
  88. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-drawer/index.ts +34 -48
  89. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-drawer/logic.ts +102 -0
@@ -230,6 +230,15 @@ export interface AriaChatState {
230
230
  * Stop that cannot stop anything is worse than no Stop, because the owner
231
231
  * taps it, the answer arrives anyway, and the control is what they blame. */
232
232
  stopHidden: boolean;
233
+ /** The microphone is open for composer dictation. Set by `dictate`, cleared by
234
+ * `finishDictation` and by the result landing. */
235
+ listening: boolean;
236
+ /** Gates for the composer's mic control and its "Listening…" row, in the
237
+ * `hiddenPath` polarity. Derived in `gated`, like `stopHidden`: the mic is
238
+ * collapsed for a consumer with no `dictation` configured and while it is
239
+ * already listening; the listening row is collapsed whenever it is not. */
240
+ micHidden: boolean;
241
+ listeningHidden: boolean;
233
242
  /** Intentionally never true: a `form` submit that returns `ok` makes the host
234
243
  * POP the screen. A chat composer must stay put, so the whole surface leaves
235
244
  * this false and the bound message list simply re-renders. */
@@ -317,6 +326,13 @@ export interface AriaChatLabels {
317
326
  * all — it carries no Wix app binding to sign the chat-service call with.
318
327
  * See `isWixAccountUnreachable`, and note what it is NOT: a signed-out user. */
319
328
  unavailable?: Resolvable<string>;
329
+ /** Neutral line when dictation ended with nothing to insert. The recognizer
330
+ * reports silence, a denied microphone and a missing engine the SAME way
331
+ * (`cancelled`), so this cannot say which — it names both likely causes. */
332
+ dictationEmpty?: Resolvable<string>;
333
+ /** Error line when the host could not start dictation at all (the speech
334
+ * capability is not registered, or refused the call). */
335
+ dictationFailed?: Resolvable<string>;
320
336
  /** The mention picker's copy. See `MentionLabels`. */
321
337
  mentionTitle?: Resolvable<string>;
322
338
  mentionNoProviders?: Resolvable<string>;
@@ -349,6 +365,41 @@ export interface AriaChatLabels {
349
365
  toolFailed?: Resolvable<string>;
350
366
  }
351
367
 
368
+ /** One dictation outcome, handed back to the service by `AriaDictationOptions.deliver`.
369
+ * `session` names the `dictate` tap it answers, so a result for a session that has
370
+ * since been replaced is dropped instead of landing in the composer. */
371
+ export interface AriaDictationResult {
372
+ session: number;
373
+ transcript?: string;
374
+ cancelled?: boolean;
375
+ /** The host refused or threw — as opposed to hearing nothing. */
376
+ failed?: boolean;
377
+ }
378
+
379
+ /** Composer dictation, over whatever speech binding the consumer has.
380
+ *
381
+ * `listen` is NOT awaited by the action that starts it: an action's state only
382
+ * paints when it returns, so awaiting a whole utterance would leave the mic tap
383
+ * looking dead until the owner stopped talking. `dictate` returns "Listening…"
384
+ * at once, and the result re-enters as a FRESH dispatch through `deliver` —
385
+ * which is the bundle's own `svc.dictated`, since only the bundle holds `svc`. */
386
+ export interface AriaDictationOptions {
387
+ /** Record one utterance and resolve with what was heard. Must REJECT when the
388
+ * host cannot dictate at all, so that is told apart from hearing nothing. */
389
+ listen(): Promise<{ transcript: string; cancelled: boolean }>;
390
+ /** Close the microphone. On iOS this also resolves `listen` with what had been
391
+ * heard so far, which is what makes "Done" keep the owner's words. */
392
+ stop(): Promise<void>;
393
+ /** Re-enter the service with the outcome — `(r) => { void svc.dictated(r); }`. */
394
+ deliver(result: AriaDictationResult): void;
395
+ /** Can this host recognize speech at all? Asked once, on the first `load`.
396
+ * `false` (or a rejection) collapses the mic: a control that can only ever
397
+ * answer "Didn't catch that" — a browser with no SpeechRecognition, a macOS
398
+ * build whose recognizer is a stub — sends the owner to fix a microphone
399
+ * permission that is not the problem. Omitted ⇒ assumed available. */
400
+ available?(): Promise<boolean>;
401
+ }
402
+
352
403
  /** What the composer needs from the device to attach a photo. Supplied by the
353
404
  * app's adapter, because both are capabilities the SCREEN has to declare — the
354
405
  * shared core imports neither, so a consumer without attachments (the clone)
@@ -441,6 +492,9 @@ export interface AriaChatOptions {
441
492
  * authoritative and account-scoped by the signed instance; losing the offline
442
493
  * fallback is a far smaller cost than that. */
443
494
  localHistory?: boolean;
495
+ /** Composer dictation. Omit it and `dictate` is inert and `micHidden`
496
+ * stays true, so a descriptor that never adds a mic is unaffected. */
497
+ dictation?: AriaDictationOptions;
444
498
  }
445
499
 
446
500
  /** The argument shape every action tolerates. The clone's generic per-item
@@ -1235,6 +1289,16 @@ export interface AriaChatCore {
1235
1289
  widgetPick(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
1236
1290
  /** Answer one QUESTIONNAIRE question. */
1237
1291
  widgetAnswer(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
1292
+ /** The composer's mic: open the recognizer and show "Listening…". Returns at
1293
+ * once; the transcript arrives later through `dictated`. */
1294
+ dictate(s: AriaChatState): AriaChatState;
1295
+ /** "Done" on the listening row: close the microphone. The session stays live,
1296
+ * so what the recognizer had already heard is still inserted when it lands. */
1297
+ finishDictation(s: AriaChatState): AriaChatState;
1298
+ /** A dictation outcome (see `AriaDictationOptions.deliver`). Resolves to
1299
+ * `undefined` — leave the state untouched — for a session that is no longer
1300
+ * the live one. */
1301
+ dictated(s: AriaChatState, arg?: unknown): AriaChatState | undefined;
1238
1302
  /** Open the mention picker at the category level, listing the site's
1239
1303
  * installed providers the first time (per business). `undefined` when the
1240
1304
  * business switched while the list was being fetched. */
@@ -1300,6 +1364,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1300
1364
  const unavailableText = () => res(
1301
1365
  o.labels?.unavailable ?? `${assistant()} can't reach your Wix account from here.`,
1302
1366
  );
1367
+ const dictationEmptyText = () => res(
1368
+ o.labels?.dictationEmpty ?? "Didn't catch that. Check microphone access and try again.",
1369
+ );
1370
+ const dictationFailedText = () => res(o.labels?.dictationFailed ?? "Dictation isn't available right now.");
1303
1371
  const attachUploadingText = () => res(o.labels?.attachUploading ?? "Uploading…");
1304
1372
  const attachFailedText = () => res(o.labels?.attachFailed ?? "Couldn't upload");
1305
1373
  const chipLabels = () => ({ uploading: attachUploadingText(), failed: attachFailedText() });
@@ -1434,6 +1502,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1434
1502
  messages: [], prompts: [], _convId: "", _siteId: "", _pending: "", _pendingRefs: "", _pendingAttach: [],
1435
1503
  _pendingAfter: 0, _pendingId: "", _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0,
1436
1504
  _abortId: "", _turnId: "", stopHidden: true, ok: false,
1505
+ listening: false, micHidden: !o.dictation, listeningHidden: true,
1437
1506
  widget: NO_WIDGET, mention: mentionViewNow,
1438
1507
  attachments: [], attachmentsHidden: true,
1439
1508
  };
@@ -1658,6 +1727,77 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1658
1727
  let draft = "";
1659
1728
  let draftSeq = 0;
1660
1729
 
1730
+ /** Dictation sessions: a counter minted per `dictate` tap, and the one whose
1731
+ * result may still land (0 = none).
1732
+ *
1733
+ * A latch rather than state for the same reason `inFlightSend` is one: the
1734
+ * result arrives as a fresh dispatch handed the CURRENT state, and the only
1735
+ * way to tell it is the answer to an older tap — one the recognizer is still
1736
+ * finishing after the owner started another — is to remember which tap is
1737
+ * live here. Without it a slow recognizer's late result lands in the
1738
+ * composer on top of the one the owner is dictating now. */
1739
+ let dictationSession = 0;
1740
+ let dictationLive = 0;
1741
+ /** The live session was ended by the owner's own "Done". Its result is still
1742
+ * inserted, but an EMPTY one is not reported: they stopped it, and a
1743
+ * recognizer that does not answer a stop (Android's resolves only at its
1744
+ * safety cap, 30s later) would otherwise put "Didn't catch that" on screen
1745
+ * long after anyone was asking. */
1746
+ let dictationStopped = false;
1747
+ /** Sessions the owner ended with "Done" and then SUPERSEDED with a new tap
1748
+ * before their answers landed. Their words are still theirs: Done promises
1749
+ * to keep what was heard, and quick re-taps must not break that — on Android
1750
+ * the new `recognizeOnce` is exactly what settles the old recognizer, so its
1751
+ * answer arrives right as the new session opens. A SET, not one slot: Done →
1752
+ * Dictate → Done → Dictate leaves two outstanding, and a single slot dropped
1753
+ * the first. Each is appended as it lands, silently, without touching the
1754
+ * live session's `listening`, and leaves the set when it does. */
1755
+ const dictationFinishing = new Set<number>();
1756
+ /** The last dictated insertion: the draft it was built on and the draft it
1757
+ * produced — see `insertHeard`. Forgotten on any edit, send or fresh chat,
1758
+ * so it can only ever bridge answers that have not committed yet. */
1759
+ let lastInsert: { from: string; to: string } | null = null;
1760
+ /** The host's answer to `dictation.available`, once asked. `null` until then. */
1761
+ let dictationAvailable: boolean | null = null;
1762
+ const probeDictation = async (): Promise<void> => {
1763
+ if (!o.dictation?.available || dictationAvailable !== null) return;
1764
+ try { dictationAvailable = (await o.dictation.available()) === true; } catch { dictationAvailable = false; }
1765
+ };
1766
+ /** Heard words INTO the draft, never sent: the owner reads what the
1767
+ * recognizer made of them before it goes anywhere. Appended to what they had
1768
+ * already typed. Dictation is an edit to the draft, so it moves the draft
1769
+ * latch exactly as a keystroke does — `withDraft` must not put the old text
1770
+ * back. */
1771
+ const insertHeard = (s: AriaChatState, heard: string): string => {
1772
+ const current = s.input ?? "";
1773
+ // Two answers can land in the SAME turn — parked Done sessions settling
1774
+ // together — and each `dictated` is handed the state as it was before
1775
+ // either committed. Built on that snapshot, the second would overwrite the
1776
+ // first's words. So when the snapshot is still exactly what the previous
1777
+ // insertion started from, build on what that insertion produced instead.
1778
+ const base = lastInsert && current === lastInsert.from ? lastInsert.to : current;
1779
+ const typed = base.replace(/\s+$/, "");
1780
+ const input = typed ? `${typed} ${heard}` : heard;
1781
+ lastInsert = { from: current, to: input };
1782
+ draft = input;
1783
+ draftSeq += 1;
1784
+ return input;
1785
+ };
1786
+ /** A send or a fresh chat ends dictation. The words belong to the draft the
1787
+ * owner was writing THEN; a transcript that lands after they sent it, or in
1788
+ * another conversation's composer, reads as a duplicate draft ready to send
1789
+ * twice. So the live session is retired (its late answer will be dropped)
1790
+ * and a still-open microphone is closed. Returns the state fields to spread. */
1791
+ const retireDictation = (): { listening: false } => {
1792
+ dictationFinishing.clear();
1793
+ lastInsert = null;
1794
+ if (dictationLive) {
1795
+ dictationLive = 0;
1796
+ o.dictation?.stop().catch(() => { /* already closed */ });
1797
+ }
1798
+ return { listening: false };
1799
+ };
1800
+
1661
1801
  /** The client-tool exchange the current turn is in the middle of, if any.
1662
1802
  *
1663
1803
  * While it runs, the agent is WAITING ON US — for the owner's consent, say —
@@ -2037,6 +2177,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2037
2177
  // tap that answers a question the owner has already moved past — the same
2038
2178
  // reason a scrollback widget keeps its prose and loses its controls.
2039
2179
  widget: NO_WIDGET,
2180
+ ...retireDictation(),
2040
2181
  };
2041
2182
  inFlightSend = content;
2042
2183
  // Held outside the try so a FAILED send still reports the conversation it
@@ -2313,9 +2454,16 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2313
2454
  // `_pending` rather than `turnInFlight` — see `regenerate` for why `status`
2314
2455
  // is not trusted to have been cleared.
2315
2456
  const replyActionsHidden = !(lastReply(s) && !s._pending);
2457
+ // The dictation pair is derived here for the same reason: two exact
2458
+ // negations each maintained by hand is how a screen ends up with both the
2459
+ // mic and "Listening…" on it, or neither.
2460
+ const listening = s.listening === true;
2461
+ const micHidden = !o.dictation || dictationAvailable === false || listening;
2462
+ const listeningHidden = !listening;
2316
2463
  const actioned = s.stopHidden === hidden && s.replyActionsHidden === replyActionsHidden
2464
+ && s.micHidden === micHidden && s.listeningHidden === listeningHidden
2317
2465
  ? s
2318
- : { ...s, stopHidden: hidden, replyActionsHidden };
2466
+ : { ...s, stopHidden: hidden, replyActionsHidden, micHidden, listeningHidden };
2319
2467
  // The attachment projection rides the same exit, for the same reason: one
2320
2468
  // place that every action's result passes through, so no reducer can hand
2321
2469
  // back a stale copy of the chips. Seeded first, so the very first action of
@@ -2557,6 +2705,9 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2557
2705
  // still happened.
2558
2706
  draft = next;
2559
2707
  draftSeq += 1;
2708
+ // The owner's own edit: whatever a previous insertion produced is theirs
2709
+ // to change now, and must not be built on again.
2710
+ lastInsert = null;
2560
2711
  return next === s.input ? s : { ...s, input: next };
2561
2712
  },
2562
2713
 
@@ -2609,6 +2760,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2609
2760
  // cancel a poll, so the only way to stop the 2s cadence is to re-arm it
2610
2761
  // slow — do that whenever this interrupted something.
2611
2762
  const wasMidTurn = !!s._pending || s.status === "sending";
2763
+ const retired = retireDictation();
2612
2764
  const slow = (next: AriaChatState): AriaChatState =>
2613
2765
  wasMidTurn && o.poll ? cadence(next, o.poll.idleMs, null) : next;
2614
2766
  try {
@@ -2622,11 +2774,11 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2622
2774
  // and the old observed counts back — the fresh chat snapping back to the
2623
2775
  // previous one, which reads as ➕ having done nothing.
2624
2776
  turnSeq += 1;
2625
- return slow({ ...s, status: "ready", _convId: conversationIdOf(res), _siteId: site, input: "", statusText: "", error: "",
2777
+ return slow({ ...s, ...retired, status: "ready", _convId: conversationIdOf(res), _siteId: site, input: "", statusText: "", error: "",
2626
2778
  _pending: "", _abortId: "", _turnId: "", copyNote: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
2627
2779
  } catch (e) {
2628
2780
  turnSeq += 1; // see above — a failed create commits state too
2629
- return slow({ ...s, status: "ready", _convId: "", _siteId: site, input: "", ...failureSlots(e, unavailableText),
2781
+ return slow({ ...s, ...retired, status: "ready", _convId: "", _siteId: site, input: "", ...failureSlots(e, unavailableText),
2630
2782
  _pending: "", _abortId: "", _turnId: "", copyNote: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
2631
2783
  }
2632
2784
  },
@@ -2960,6 +3112,77 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2960
3112
  ? openWidgetUrl(s, s.widget.secondaryUrl)
2961
3113
  : impl.widgetRespond(s, { value: "secondary" }),
2962
3114
 
3115
+ dictate: (s) => {
3116
+ if (!o.dictation || s.listening) return s;
3117
+ const session = ++dictationSession;
3118
+ // Only a session the owner closed with Done can still be live here
3119
+ // (`listening` refuses a tap otherwise), and its answer is still owed.
3120
+ if (dictationLive && dictationStopped) dictationFinishing.add(dictationLive);
3121
+ dictationLive = session;
3122
+ dictationStopped = false;
3123
+ const deliver = (r: Omit<AriaDictationResult, "session">) => {
3124
+ try { o.dictation!.deliver({ session, ...r }); } catch { /* the bundle is gone */ }
3125
+ };
3126
+ // Deliberately not awaited — see `AriaDictationOptions`.
3127
+ o.dictation.listen().then(
3128
+ (r) => deliver({ transcript: r.transcript, cancelled: r.cancelled }),
3129
+ () => deliver({ failed: true }),
3130
+ );
3131
+ // A new attempt retires the previous attempt's own messages, and nothing
3132
+ // else: the status line may be saying "Aria is typing…", which is the
3133
+ // turn's, and the error slot may hold a failed send the owner still needs.
3134
+ return {
3135
+ ...s,
3136
+ listening: true,
3137
+ statusText: s.statusText === dictationEmptyText() ? "" : s.statusText,
3138
+ error: s.error === dictationFailedText() ? "" : s.error,
3139
+ };
3140
+ },
3141
+
3142
+ finishDictation: (s) => {
3143
+ if (!s.listening) return s;
3144
+ // Close the mic without retiring the session: the recognizer answers with
3145
+ // what it had already heard, and dropping that would make "Done" throw the
3146
+ // owner's words away.
3147
+ dictationStopped = true;
3148
+ if (o.dictation) o.dictation.stop().catch(() => { /* already closed */ });
3149
+ return { ...s, listening: false };
3150
+ },
3151
+
3152
+ dictated: (s, arg) => {
3153
+ const r = (arg && typeof arg === "object" ? arg : {}) as Partial<AriaDictationResult>;
3154
+ const session = Number(r.session);
3155
+ // A Done session superseded by a newer tap: keep its words, and nothing
3156
+ // else. Silent on nothing-heard or failure — the owner has moved on to the
3157
+ // next utterance — and `listening` belongs to the live session.
3158
+ if (session && dictationFinishing.has(session)) {
3159
+ dictationFinishing.delete(session);
3160
+ const words = !r.failed && !r.cancelled && typeof r.transcript === "string" ? r.transcript.trim() : "";
3161
+ return words ? { ...s, input: insertHeard(s, words) } : undefined;
3162
+ }
3163
+ // Not the live session: a retired tap's recognizer finishing late (a send
3164
+ // or a new chat ended it), or a result for a worker instance that no
3165
+ // longer exists. Leave everything alone.
3166
+ if (!session || session !== dictationLive) return undefined;
3167
+ dictationLive = 0;
3168
+ const done: AriaChatState = { ...s, listening: false };
3169
+ // Into the error slot only when it is free (or already ours): a failed send
3170
+ // the owner still has to act on outranks a mic that did not start.
3171
+ if (r.failed) return s.error && s.error !== dictationFailedText() ? done : { ...done, error: dictationFailedText() };
3172
+ const heard = typeof r.transcript === "string" ? r.transcript.trim() : "";
3173
+ if (r.cancelled || !heard) {
3174
+ // Neutral, and only when the status line is free: "Aria is typing…"
3175
+ // belongs to a turn in flight and is not ours to overwrite. Silent
3176
+ // after the owner's own Done — see `dictationStopped`.
3177
+ return turnInFlight(s) || dictationStopped ? done : { ...done, statusText: dictationEmptyText() };
3178
+ }
3179
+ return {
3180
+ ...done,
3181
+ input: insertHeard(s, heard),
3182
+ statusText: s.statusText === dictationEmptyText() ? "" : s.statusText,
3183
+ };
3184
+ },
3185
+
2963
3186
  openLink: async (s, arg) => {
2964
3187
  const url = typeof arg === "string" ? arg.trim() : (arg?.url ?? arg?.id ?? "").trim();
2965
3188
  if (!url) return s;
@@ -3215,7 +3438,14 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
3215
3438
  // Every action's result goes through the Stop gate on its way out.
3216
3439
  return {
3217
3440
  initialState,
3218
- load: (s) => impl.load(s).then(gated),
3441
+ // A `listening: true` that no session in THIS worker owns was persisted by
3442
+ // an earlier process: the recognizer it described is gone, and leaving the
3443
+ // flag up would hide the mic for good.
3444
+ load: async (s) => {
3445
+ const r = await impl.load(s);
3446
+ await probeDictation();
3447
+ return gated(r.listening && !dictationLive ? { ...r, listening: false } : r);
3448
+ },
3219
3449
  submit: (s, arg) => impl.submit(s, arg).then(gated),
3220
3450
  submitPrompt: (s, arg) => impl.submitPrompt(s, arg).then(gated),
3221
3451
  newChat: (s) => impl.newChat(s).then(gated),
@@ -3235,6 +3465,9 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
3235
3465
  widgetRespond: (s, arg) => impl.widgetRespond(s, arg).then(gated),
3236
3466
  widgetPrimary: (s) => impl.widgetPrimary(s).then(gated),
3237
3467
  widgetSecondary: (s) => impl.widgetSecondary(s).then(gated),
3468
+ dictate: (s) => gated(impl.dictate(s)),
3469
+ finishDictation: (s) => gated(impl.finishDictation(s)),
3470
+ dictated: (s, arg) => { const r = impl.dictated(s, arg); return r ? gated(r) : r; },
3238
3471
  mentionOpen: (s) => impl.mentionOpen(s).then((r) => (r ? gated(r) : r)),
3239
3472
  mentionClose: (s) => gated(impl.mentionClose(s)),
3240
3473
  mentionBack: (s) => gated(impl.mentionBack(s)),
@@ -27,6 +27,7 @@
27
27
  import { defineService } from "@himalaya/state";
28
28
  import { open } from "@himalaya/open-url";
29
29
  import { writeText } from "@himalaya/clipboard";
30
+ import { availability, recognizeOnce, stop as stopSpeech } from "@himalaya/speech";
30
31
  import { capturePhoto } from "@himalaya/camera";
31
32
  import { put } from "@himalaya/presigned-upload";
32
33
  import { alert } from "@himalaya/ui";
@@ -35,6 +36,7 @@ import {
35
36
  createAriaChat,
36
37
  type AriaActionArg,
37
38
  type AriaChatState,
39
+ type AriaDictationResult,
38
40
  type AriaPromptChip,
39
41
  } from "../_shared/aria-chat.js";
40
42
  import { WIDGET_SUBTREE_SLOT } from "../_shared/aria-generic-widget.js";
@@ -201,6 +203,8 @@ const core = createAriaChat({
201
203
  // English. Says no "sign in": the owner already is, and the missing piece is
202
204
  // the app's binding, so there is no action for them to take.
203
205
  unavailable: () => copyParam("ownerAria.unavailable", "Aria can't reach your Wix account from here."),
206
+ dictationEmpty: () => copyParam("ownerAria.dictationEmpty", "Didn't catch that. Check microphone access and try again."),
207
+ dictationFailed: () => copyParam("ownerAria.dictationFailed", "Dictation isn't available right now."),
204
208
  // The mention picker. Through copyParam for the same reason as the rest.
205
209
  // The CATEGORY labels are not here, deliberately: they are what the
206
210
  // reference's `data.type` carries to the model, so they stay English.
@@ -243,6 +247,19 @@ const core = createAriaChat({
243
247
  return screenId ? { screenId } : null;
244
248
  },
245
249
  poll: { action: `${SERVICE}.tick`, fastMs: FAST_MS, idleMs: IDLE_MS },
250
+ // Composer dictation, on the platform recognizer: SFSpeechRecognizer,
251
+ // Android SpeechRecognizer, Web Speech. No audio crosses the bridge — only the
252
+ // final transcript. An empty language means the device's own locale, which is
253
+ // the owner's, rather than a hardcoded en-US. The screen must declare "speech".
254
+ //
255
+ // `svc` is referenced lazily: it is defined below, and `deliver` only ever
256
+ // runs after an utterance, long after this module has finished evaluating.
257
+ dictation: {
258
+ listen: () => recognizeOnce(),
259
+ stop: () => stopSpeech(),
260
+ deliver: (r) => { void svc.dictated(r); },
261
+ available: async () => (await availability()).recognition !== "unavailable",
262
+ },
246
263
  // Photo attachments. The two device capabilities live HERE rather than
247
264
  // in the shared core so that only a screen which declares them imports them:
248
265
  // `camera` for the picker (it returns the image already re-encoded as JPEG and
@@ -293,6 +310,10 @@ const svc = defineService<AriaChatState, {
293
310
  widgetPick: (s: AriaChatState, arg?: AriaActionArg) => AriaChatState;
294
311
  widgetQuery: (s: AriaChatState, arg?: AriaActionArg) => AriaChatState;
295
312
  widgetAnswer: (s: AriaChatState, arg?: AriaActionArg) => AriaChatState;
313
+ dictate: (s: AriaChatState) => AriaChatState;
314
+ finishDictation: (s: AriaChatState) => AriaChatState;
315
+ // `undefined` for a result whose session is no longer the live one — see the core.
316
+ dictated: (s: AriaChatState, arg?: AriaDictationResult) => AriaChatState | undefined;
296
317
  mentionOpen: (s: AriaChatState) => Promise<AriaChatState | undefined>;
297
318
  mentionClose: (s: AriaChatState) => AriaChatState;
298
319
  mentionBack: (s: AriaChatState) => AriaChatState;
@@ -340,6 +361,13 @@ const svc = defineService<AriaChatState, {
340
361
  widgetPick: core.widgetPick,
341
362
  widgetQuery: core.widgetQuery,
342
363
  widgetAnswer: core.widgetAnswer,
364
+ // Composer dictation: the mic, the listening row's Done, and the result
365
+ // re-entering from `dictation.deliver` above. Exported for the same reason
366
+ // as the widget actions — an action the entry does not name is a control
367
+ // that renders and does nothing.
368
+ dictate: core.dictate,
369
+ finishDictation: core.finishDictation,
370
+ dictated: core.dictated,
343
371
  // Mentions: the "@ Mention" control, the two-level picker and the
344
372
  // chips above the composer. Every one is named by the descriptor, so every
345
373
  // one has to be exported here — see the note on the picker family above.
@@ -7,75 +7,61 @@
7
7
  // attention today — and none of that is knowable when the config is authored.
8
8
  //
9
9
  // Hosting the panel lets the worker answer it at runtime: the live site, per-site
10
- // counts on the rows that have them, and a "needs attention" line that simply is
11
- // not there when the count is zero.
10
+ // counts on the rows that have them, and a "needs attention" section that simply
11
+ // is not there when nothing is waiting.
12
+ //
13
+ // The derived fields (initials, the attention total and header, each row's badge
14
+ // and spoken label, the clear-confirmation flags) live in ./logic.ts so they are
15
+ // unit-tested and can only ever agree with each other.
12
16
  //
13
17
  // Build: node core/dev-server/tier5-bundles-src/build-all.mjs himi-owner-lite-drawer
14
18
  // → core/dev-server/tier5-bundles/himi-owner-lite-drawer.bundle.js
15
19
 
16
20
  import { defineService } from "@himalaya/state";
17
21
  import { serializeForAgent } from "@himalaya/agent";
22
+ import { activeScreenFromInput, attentionRow, navRow, withDerived, type DrawerState } from "./logic.js";
18
23
 
19
- interface Row {
20
- id: string;
21
- title: string;
22
- icon: string;
23
- screenId: string;
24
- /** Rendered only when non-empty — an owner does not need a "0". */
25
- badge: string;
26
- }
27
-
28
- interface DrawerState {
29
- siteName: string;
30
- siteInitials: string;
31
- nav: Row[];
32
- /** Rows an owner should look at today; empty ⇒ the section renders nothing. */
33
- attention: Row[];
34
- attentionLabel: string;
35
- /** TRUE when there is nothing to chase. Feeds `hiddenPath`, which hides on a
36
- * TRUTHY value — so the flag is phrased negatively on purpose. */
37
- noAttention: boolean;
38
- }
39
-
40
- const nav = (id: string, title: string, icon: string, screenId: string, badge = ""): Row =>
41
- ({ id, title, icon, screenId, badge });
42
-
43
- /** Recompute the derived attention summary from the rows. */
44
- function withAttention(s: Omit<DrawerState, "attentionLabel" | "noAttention">): DrawerState {
45
- const n = s.attention.length;
46
- return {
47
- ...s,
48
- noAttention: n === 0,
49
- attentionLabel: n === 0 ? "All caught up" : `${n} need${n === 1 ? "s" : ""} attention`,
50
- };
51
- }
24
+ /** The shell passes the screen it is showing as worker input, so the drawer can mark
25
+ * that destination selected the way the declarative drawer does. */
26
+ const hostActiveScreen = () => activeScreenFromInput((globalThis as { himalaya?: { input?: unknown } }).himalaya?.input);
52
27
 
53
28
  const drawer = defineService<DrawerState, {
54
- /** Re-read the live site + counts. */
55
- refresh: (s: DrawerState) => DrawerState;
56
- /** Clear the attention list — proves the section disappears, not just empties. */
29
+ /** Re-read the active screen from host input (root onAppear). An explicit
30
+ * `{ screenId }` wins, for a host that pushes the change instead. */
31
+ syncActive: (s: DrawerState, args?: { screenId?: string }) => DrawerState;
32
+ /** First tap on "Mark all handled": ask before hiding anything. */
33
+ askClearAttention: (s: DrawerState) => DrawerState;
34
+ /** The owner backed out of the confirmation. */
35
+ cancelClearAttention: (s: DrawerState) => DrawerState;
36
+ /** Confirmed: clear the attention list — the section disappears, not just empties. */
57
37
  clearAttention: (s: DrawerState) => DrawerState;
58
38
  }>("drawer", {
59
- state: withAttention({
39
+ state: withDerived({
60
40
  siteName: "My Site",
61
- siteInitials: "MS",
62
41
  nav: [
63
- nav("drawer_home", "Home", "home", "himi_owner_lite_home"),
42
+ navRow("drawer_home", "Home", "home", "himi_owner_lite_home"),
64
43
  // Between Home and Manage deliberately: analytics answers "how is the
65
44
  // business doing", which sits with the dashboard, not with the per-entity
66
45
  // management areas below it.
67
- nav("drawer_analytics", "Analytics", "chart", "himi_owner_lite_analytics"),
68
- nav("drawer_manage", "Manage", "grid", "himi_owner_lite_manage"),
69
- nav("drawer_account", "Account", "person", "himi_owner_lite_account"),
46
+ navRow("drawer_analytics", "Analytics", "chart", "himi_owner_lite_analytics"),
47
+ navRow("drawer_manage", "Manage", "grid", "himi_owner_lite_manage"),
48
+ navRow("drawer_account", "Account", "person", "himi_owner_lite_account"),
70
49
  ],
71
50
  attention: [
72
- nav("att_orders", "Orders", "cart", "himi_owner_lite_orders", "3"),
73
- nav("att_appts", "Appointments", "calendar", "himi_owner_lite_appointments", "1"),
51
+ attentionRow("att_orders", "Orders", "cart", "himi_owner_lite_orders", 3),
52
+ attentionRow("att_appts", "Appointments", "calendar", "himi_owner_lite_appointments", 1),
74
53
  ],
54
+ confirmingClear: false,
55
+ activeScreenId: hostActiveScreen(),
75
56
  }),
76
57
  actions: {
77
- refresh: (s) => snap(withAttention({ ...s, attention: [...s.attention] })),
78
- clearAttention: (s) => snap(withAttention({ ...s, attention: [] })),
58
+ syncActive: (s, args) => snap(withDerived({
59
+ ...s,
60
+ activeScreenId: typeof args?.screenId === "string" ? args.screenId : hostActiveScreen(),
61
+ })),
62
+ askClearAttention: (s) => snap(withDerived({ ...s, confirmingClear: true })),
63
+ cancelClearAttention: (s) => snap(withDerived({ ...s, confirmingClear: false })),
64
+ clearAttention: (s) => snap(withDerived({ ...s, attention: [], confirmingClear: false })),
79
65
  },
80
66
  });
81
67
 
@@ -0,0 +1,102 @@
1
+ // himi-owner-lite DRAWER — pure derivations, kept out of index.ts so they are
2
+ // unit-testable without booting a worker.
3
+ //
4
+ // Everything a reader of the panel compares lives here, so the pieces can only ever
5
+ // agree: the header counts ITEMS summed from the same numbers the badges show (it
6
+ // used to count rows, and read "2 need attention" over badges of 3 and 1), and each
7
+ // row's spoken label is built from those same numbers.
8
+
9
+ export interface Row {
10
+ id: string;
11
+ title: string;
12
+ /** Design-system icon name for the row's leading glyph. */
13
+ icon: string;
14
+ screenId: string;
15
+ /** Items waiting on this row; 0 for a plain destination. */
16
+ count: number;
17
+ /** The badge text — the count, or "" (a destination shows no badge). */
18
+ badge: string;
19
+ /** The badge read on its own, for a screen reader that keeps a labelled row's
20
+ * children reachable (iOS): "3 need attention". "" when there is no badge. */
21
+ badgeLabel: string;
22
+ /** What a screen reader announces for the whole row. */
23
+ a11yLabel: string;
24
+ /** This row is the screen the owner is on. Only destinations can be. */
25
+ selected: boolean;
26
+ /** `!selected`, for `hiddenPath` (which hides on a TRUTHY value). */
27
+ notSelected: boolean;
28
+ }
29
+
30
+ /** The authored part of the state; everything else is derived by `withDerived`. */
31
+ export interface DrawerCore {
32
+ siteName: string;
33
+ nav: Row[];
34
+ attention: Row[];
35
+ /** The owner tapped "Mark all handled" and has not confirmed or cancelled yet. */
36
+ confirmingClear: boolean;
37
+ /** The screen the shell is showing, passed in as worker input; "" when unknown. */
38
+ activeScreenId: string;
39
+ }
40
+
41
+ export interface DrawerState extends DrawerCore {
42
+ siteInitials: string;
43
+ attentionTotal: number;
44
+ attentionLabel: string;
45
+ /** TRUE when there is nothing to chase. Feeds `hiddenPath`, which hides on a
46
+ * TRUTHY value — so the flag is phrased negatively on purpose. */
47
+ noAttention: boolean;
48
+ /** Hides the inline confirmation (same negative phrasing, same reason). */
49
+ noConfirmClear: boolean;
50
+ /** The confirmation question, naming how much will be hidden. */
51
+ clearPrompt: string;
52
+ }
53
+
54
+ const needs = (n: number) => `${n} need${n === 1 ? "s" : ""} attention`;
55
+
56
+ export const navRow = (id: string, title: string, icon: string, screenId: string): Row =>
57
+ ({ id, title, icon, screenId, count: 0, badge: "", badgeLabel: "", a11yLabel: title, selected: false, notSelected: true });
58
+
59
+ export const attentionRow = (id: string, title: string, icon: string, screenId: string, count: number): Row => {
60
+ const n = Math.max(0, Math.floor(count));
61
+ return { id, title, icon, screenId, count: n, badge: n > 0 ? String(n) : "", badgeLabel: n > 0 ? needs(n) : "", a11yLabel: n > 0 ? `${title}, ${needs(n)}` : title, selected: false, notSelected: true };
62
+ };
63
+
64
+ /** Up to two initials from the site name: "Pottery Studio" → "PS". */
65
+ export function initialsOf(name: string): string {
66
+ const words = name.trim().split(/\s+/).filter(Boolean);
67
+ if (words.length === 0) return "?";
68
+ return words.slice(0, 2).map((w) => [...w][0]!.toUpperCase()).join("");
69
+ }
70
+
71
+ /** Recompute every derived field. Rows with nothing waiting are dropped, so a
72
+ * cleared or all-zero section hides instead of listing zeros. */
73
+ export function withDerived(core: DrawerCore): DrawerState {
74
+ const attention = core.attention.filter((row) => row.count > 0);
75
+ const active = core.activeScreenId;
76
+ const nav = core.nav.map((row) => {
77
+ const selected = active !== "" && row.screenId === active;
78
+ return { ...row, selected, notSelected: !selected };
79
+ });
80
+ const total = attention.reduce((sum, row) => sum + row.count, 0);
81
+ const confirmingClear = core.confirmingClear && total > 0;
82
+ return {
83
+ ...core,
84
+ nav,
85
+ attention,
86
+ confirmingClear,
87
+ siteInitials: initialsOf(core.siteName),
88
+ attentionTotal: total,
89
+ attentionLabel: total === 0
90
+ ? "All caught up"
91
+ : `${total} item${total === 1 ? "" : "s"} ${total === 1 ? "needs" : "need"} attention`,
92
+ noAttention: total === 0,
93
+ noConfirmClear: !confirmingClear,
94
+ clearPrompt: total === 1 ? "Hide this reminder?" : `Hide these ${total} reminders?`,
95
+ };
96
+ }
97
+
98
+ /** The active screen from the worker's host input (`himalaya.input.activeScreenId`). */
99
+ export function activeScreenFromInput(input: unknown): string {
100
+ const v = (input as { activeScreenId?: unknown } | undefined)?.activeScreenId;
101
+ return typeof v === "string" ? v : "";
102
+ }
@@ -37,7 +37,7 @@ export default defineApp({
37
37
  actions: "http://localhost:8190/v1/actions",
38
38
  sync: "http://localhost:8190/v1/sync",
39
39
  },
40
- capabilities: ["live_activity","push","share","secure_storage","open_url","local_database","clipboard","ui","camera","presigned_upload"],
40
+ capabilities: ["live_activity","push","share","secure_storage","open_url","local_database","clipboard","ui","speech","camera","presigned_upload"],
41
41
  designTokensPath: "./tokens.json",
42
42
  navigation: {
43
43
  "navKind": "responsive",