@burdenoff/website-sdk 2026.1105.2 → 2026.1106.1

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.
@@ -1,4 +1,4 @@
1
- import { turnstileSiteKeyOf, isAvatarLiveLocaleReviewed, isAvatarSite, isAvatarVersion } from '../chunk-MVNN6LLO.mjs';
1
+ import { turnstileSiteKeyOf, isAvatarLiveLocaleReviewed, isAvatarSite, isAvatarVersion } from '../chunk-BHEHF3Y5.mjs';
2
2
  import { answerDisplayText, fill } from '../chunk-JZS6FQT6.mjs';
3
3
  import { cn } from '../chunk-R7G6TWIS.mjs';
4
4
  import '../chunk-VNS5WDWO.mjs';
@@ -69,6 +69,25 @@ function readSttOffer(value) {
69
69
  }
70
70
  return { token, expiresAt };
71
71
  }
72
+ var MIC_MAX_PRESS_SECONDS = 30;
73
+ var AVATAR_MIC_PAUSE_MIN_MS = 2e3;
74
+ var AVATAR_MIC_PAUSE_MAX_MS = 2500;
75
+ var UNREADABLE_MIC = { mode: "unreadable" };
76
+ function readMicOffer(value) {
77
+ if (!isRecord(value)) return UNREADABLE_MIC;
78
+ const keys = Object.keys(value).sort();
79
+ const { mode, maxPressSeconds, pauseMs } = value;
80
+ if (keys[0] !== "maxPressSeconds" || keys[1] !== "mode" || mode !== "browser" || maxPressSeconds !== MIC_MAX_PRESS_SECONDS) {
81
+ return UNREADABLE_MIC;
82
+ }
83
+ if (keys.length === 2) {
84
+ return { mode: "browser", maxPressSeconds: MIC_MAX_PRESS_SECONDS };
85
+ }
86
+ if (keys.length !== 3 || keys[2] !== "pauseMs" || typeof pauseMs !== "number" || !Number.isSafeInteger(pauseMs) || pauseMs < AVATAR_MIC_PAUSE_MIN_MS || pauseMs > AVATAR_MIC_PAUSE_MAX_MS) {
87
+ return UNREADABLE_MIC;
88
+ }
89
+ return { mode: "browser", maxPressSeconds: MIC_MAX_PRESS_SECONDS, pauseMs };
90
+ }
72
91
  function readAvailability(status, body) {
73
92
  if (status !== 200 || !isRecord(body)) return INERT;
74
93
  const { available, reason } = body;
@@ -83,6 +102,7 @@ function readAvailability(status, body) {
83
102
  return INERT;
84
103
  }
85
104
  const stt = readSttOffer(body.stt);
105
+ const mic = Object.prototype.hasOwnProperty.call(body, "mic") ? readMicOffer(body.mic) : void 0;
86
106
  return {
87
107
  kind: "available",
88
108
  offer: {
@@ -90,15 +110,19 @@ function readAvailability(status, body) {
90
110
  maxSessionSeconds,
91
111
  botCheck,
92
112
  questions,
93
- ...stt === void 0 ? {} : { stt }
113
+ ...stt === void 0 ? {} : { stt },
114
+ ...mic === void 0 ? {} : { mic }
94
115
  }
95
116
  };
96
117
  }
97
118
  const meaning = available ? void 0 : AVAILABILITY_REASONS.get(reason);
98
- return meaning === void 0 || meaning === "inert" ? INERT : {
119
+ if (meaning === void 0 || meaning === "inert") return INERT;
120
+ const named = body.consentVersion;
121
+ return {
99
122
  kind: "refused",
100
123
  reason: meaning,
101
- questions: body.questions === "any" ? "any" : "suggested"
124
+ questions: body.questions === "any" ? "any" : "suggested",
125
+ ...typeof named === "string" && isAvatarVersion(named) ? { consentVersion: named } : {}
102
126
  };
103
127
  }
104
128
  var CREATE_REFUSALS = /* @__PURE__ */ new Map([
@@ -845,6 +869,7 @@ var STRINGS = {
845
869
  function avatarLiveStrings(locale) {
846
870
  return STRINGS[locale] ?? EN;
847
871
  }
872
+ var BROWSER_MIC_NOTICE_VERSION = "web-mic-2026-11-21-c";
848
873
  var CONSENT = /* @__PURE__ */ new Map([
849
874
  [
850
875
  "web-consent-2026-11",
@@ -1022,6 +1047,72 @@ var CONSENT = /* @__PURE__ */ new Map([
1022
1047
  followUps: true
1023
1048
  }
1024
1049
  }
1050
+ ],
1051
+ [
1052
+ // Stage C (BOFF-9797, the operator's decision of 2026-11-05): the mic through the browser's own speech recognition.
1053
+ // A DRAFT for the operator's privacy review that no environment uses yet (global-speech-svc reserves the same two
1054
+ // versions, and pairs them with its AVATAR_WEB_MIC=browser): web-consent-2026-11-19-d with three changes only. The
1055
+ // intro warns against personal details said into the microphone and says a tap on it stops the answer being read;
1056
+ // the processing says which speech service turns the voice into text (the browser's own, under its own terms, with
1057
+ // the vendors as examples) and that a live session is told of a tap and of a recording, never of the words; the
1058
+ // storage says the camera is not used and that the voice never reaches us, nor the text until Send, in place of
1059
+ // "Your microphone and camera are not used.", which is true only with no mic. The notice files of record and their
1060
+ // digests are a gitops change of their own, made with the review.
1061
+ "web-consent-2026-11-21-c",
1062
+ {
1063
+ en: {
1064
+ title: "Live AI avatar (beta)",
1065
+ introProduct: null,
1066
+ intro: "This is an AI avatar, not a real person. It reads aloud the answers to the questions you ask in the Avatar tab, provided each passes the speech checks. After an answer, it waits about thirty seconds for your next question, then ends. A session lasts at most three minutes; if an answer doesn't fit in the time left, it reads only the first part and says so, or doesn't read it, and the full answer stays in Chat. The answers are AI-generated and can be wrong. Don't put personal details in a question you type. Don't say them into the microphone either. Tapping the microphone while the avatar speaks stops that answer.",
1067
+ processing: "When you start, Cloudflare checks for bots. The answer text goes to Google to create the voice. Our server sends that voice through LiveKit to Protoface, which makes the avatar video streamed through LiveKit. Your browser connects to LiveKit, which sees your IP address. If you use the microphone, the speech service your browser uses turns your voice into text and may receive the audio: for example Google for Chrome on a computer or Android phone, Apple for Safari and on iPhone and iPad, and Microsoft for Edge on a computer, each under its own privacy terms. During a live session, our server is told when you tap the microphone and while you record (never what you say), so the avatar stops and waits.",
1068
+ storage: "Your camera is not used. We never receive your voice: the text appears in the question box for you to check, and none of what you said reaches us until you press Send. We do not record the live session or store the generated voice. We keep a record of this consent. The privacy notice explains our safeguards, storage periods and the providers' processing. For people aged eighteen and over.",
1069
+ privacyLink: "How we handle this",
1070
+ botCheck: "turnstile",
1071
+ questions: "any",
1072
+ followUps: true,
1073
+ micNotice: BROWSER_MIC_NOTICE_VERSION
1074
+ }
1075
+ }
1076
+ ],
1077
+ [
1078
+ // The words of web-consent-2026-11-21-c without "When you start, Cloudflare checks for bots.", for a server that runs
1079
+ // no bot check. A DRAFT, like it.
1080
+ "web-consent-2026-11-22-c",
1081
+ {
1082
+ en: {
1083
+ title: "Live AI avatar (beta)",
1084
+ introProduct: null,
1085
+ intro: "This is an AI avatar, not a real person. It reads aloud the answers to the questions you ask in the Avatar tab, provided each passes the speech checks. After an answer, it waits about thirty seconds for your next question, then ends. A session lasts at most three minutes; if an answer doesn't fit in the time left, it reads only the first part and says so, or doesn't read it, and the full answer stays in Chat. The answers are AI-generated and can be wrong. Don't put personal details in a question you type. Don't say them into the microphone either. Tapping the microphone while the avatar speaks stops that answer.",
1086
+ processing: "The answer text goes to Google to create the voice. Our server sends that voice through LiveKit to Protoface, which makes the avatar video streamed through LiveKit. Your browser connects to LiveKit, which sees your IP address. If you use the microphone, the speech service your browser uses turns your voice into text and may receive the audio: for example Google for Chrome on a computer or Android phone, Apple for Safari and on iPhone and iPad, and Microsoft for Edge on a computer, each under its own privacy terms. During a live session, our server is told when you tap the microphone and while you record (never what you say), so the avatar stops and waits.",
1087
+ storage: "Your camera is not used. We never receive your voice: the text appears in the question box for you to check, and none of what you said reaches us until you press Send. We do not record the live session or store the generated voice. We keep a record of this consent. The privacy notice explains our safeguards, storage periods and the providers' processing. For people aged eighteen and over.",
1088
+ privacyLink: "How we handle this",
1089
+ botCheck: "off",
1090
+ questions: "any",
1091
+ followUps: true,
1092
+ micNotice: BROWSER_MIC_NOTICE_VERSION
1093
+ }
1094
+ }
1095
+ ],
1096
+ [
1097
+ // Stage C3 (BOFF-9797, the operator's final decision of 2026-11-06, "nothing at all"): the voice conversation with NO
1098
+ // notice in the Avatar tab. Typed questions, Stage B's three minutes, Stage D's answers after the first, the
1099
+ // browser's own recognizer (AVATAR_WEB_MIC=browser) with the server's pause, and no bot check (alpha and prod both
1100
+ // run it with AVATAR_WEB_BOT_CHECK=off). No words, so nothing to agree to: the server must record no consent for it
1101
+ // and hold no notice digest for it, and the create names it as a protocol token only. Speech processing and the
1102
+ // question sent by itself are the site's privacy policy's to describe. PROD waits for the privacy reviewer's
1103
+ // approval of this experience. (The
1104
+ // notice versions web-consent-2026-11-23-v and -24-v, which no environment ever ran, are retired with the dialog
1105
+ // that showed them: this SDK has no copy for them, so a server naming one gets the Avatar tab as it was.)
1106
+ "web-voice-2026-11-25-n",
1107
+ {
1108
+ en: {
1109
+ notice: "none",
1110
+ botCheck: "off",
1111
+ questions: "any",
1112
+ followUps: true,
1113
+ voice: { pauseMs: 2e3 }
1114
+ }
1115
+ }
1025
1116
  ]
1026
1117
  ]);
1027
1118
  function avatarConsentCopy(version, locale) {
@@ -1052,6 +1143,30 @@ function avatarMicStrings(locale) {
1052
1143
  if (!isAvatarLiveLocaleReviewed(locale)) return null;
1053
1144
  return MIC_STRINGS[locale] ?? null;
1054
1145
  }
1146
+ var VOICE_EN = {
1147
+ talk: "Talk",
1148
+ talkInterrupt: "Stop the avatar and talk",
1149
+ stopListening: "Stop listening",
1150
+ start: "Start",
1151
+ cancelled: "The avatar didn't start. Press Start to try again.",
1152
+ starting: "Starting the microphone\u2026",
1153
+ listening: "Listening\u2026",
1154
+ pausing: "Still listening \u2014 take your time",
1155
+ sending: "Sending your question\u2026",
1156
+ again: "The mic listens again after the answer.",
1157
+ off: "Voice is off. Tap Talk to speak, or type your question.",
1158
+ inBox: "Voice is off. Your words are in the box: edit them or press Send.",
1159
+ atLimit: "The mic stopped at thirty seconds. Your words are in the box: press Send to ask.",
1160
+ noQuestion: "No question was heard. Tap Talk to try again, or type your question.",
1161
+ tapNext: "Tap Talk to ask your next question.",
1162
+ headline: "Tap Talk and ask. The avatar answers aloud.",
1163
+ placeholder: "Or type\u2026"
1164
+ };
1165
+ var VOICE_STRINGS = { en: VOICE_EN };
1166
+ function avatarVoiceStrings(locale) {
1167
+ if (!isAvatarLiveLocaleReviewed(locale)) return null;
1168
+ return VOICE_STRINGS[locale] ?? null;
1169
+ }
1055
1170
  var AVATAR_MIC_NOTICE_VERSION = "web-mic-2026-11-05";
1056
1171
  var MIC_NOTICE = /* @__PURE__ */ new Map([
1057
1172
  [
@@ -1066,6 +1181,22 @@ var MIC_NOTICE = /* @__PURE__ */ new Map([
1066
1181
  privacyLink: "How we handle this"
1067
1182
  }
1068
1183
  }
1184
+ ],
1185
+ [
1186
+ // The browser's own speech recognition (Stage C): the browser's speech service, named by example where each is
1187
+ // true, under its own terms; nothing of the voice reaches us, nor the text before Send; a live session told only of
1188
+ // a tap and of a recording. No daily limits: nothing is counted on the server.
1189
+ BROWSER_MIC_NOTICE_VERSION,
1190
+ {
1191
+ en: {
1192
+ title: "Ask with your voice",
1193
+ processing: "The speech service your browser uses turns your voice into text and may receive the audio: for example Google for Chrome on a computer or Android phone, Apple for Safari and on iPhone and iPad, and Microsoft for Edge on a computer. Its own privacy terms apply.",
1194
+ storage: "We never receive your voice, and your camera is not used.",
1195
+ text: "The text appears in the box for you to check. None of what you said reaches us until you press Send; once you send it, it is a chat message, kept like any other. During a live session, we are told when you tap the microphone and while you record (never what you say), so the avatar stops and waits.",
1196
+ limits: "Each recording stops after thirty seconds at the latest.",
1197
+ privacyLink: "How we handle this"
1198
+ }
1199
+ }
1069
1200
  ]
1070
1201
  ]);
1071
1202
  function avatarMicNotice(version, locale) {
@@ -1076,10 +1207,12 @@ var PRIVACY_HREF = "/privacy#live-avatar";
1076
1207
  var LIMIT_SECONDS = 30;
1077
1208
  var TICK_MS = 250;
1078
1209
  var IDLE = { name: "idle" };
1079
- var FOR_GOOD = /* @__PURE__ */ new Set([
1080
- "unavailable",
1081
- "limit"
1082
- ]);
1210
+ var EN_IN = { lang: "en-IN", fallbackLang: "en-US" };
1211
+ var BROWSER_MIC_LANGUAGES = { en: EN_IN };
1212
+ var FOR_GOOD = {
1213
+ stream: /* @__PURE__ */ new Set(["unavailable", "limit"]),
1214
+ browser: /* @__PURE__ */ new Set(["unsupported"])
1215
+ };
1083
1216
  var FAILURE_TEXT = {
1084
1217
  denied: "micDenied",
1085
1218
  unsupported: "micUnsupported",
@@ -1088,14 +1221,33 @@ var FAILURE_TEXT = {
1088
1221
  busy: "micBusy",
1089
1222
  failed: "micFailed"
1090
1223
  };
1224
+ var loadedMic = null;
1091
1225
  function loadAvatarMic() {
1092
- return import('../avatar-mic/index.mjs').then((loaded) => ({
1093
- createAvatarMic: loaded.createAvatarMic
1094
- }));
1226
+ return import('../avatar-mic/index.mjs').then((loaded) => {
1227
+ const module = {
1228
+ createAvatarMic: loaded.createAvatarMic,
1229
+ createBrowserAvatarMic: loaded.createBrowserAvatarMic
1230
+ };
1231
+ loadedMic = module;
1232
+ return module;
1233
+ });
1095
1234
  }
1096
1235
  function canUseAvatarMic() {
1097
1236
  return typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function" && typeof AudioContext !== "undefined" && typeof AudioWorkletNode !== "undefined" && typeof WebSocket !== "undefined";
1098
1237
  }
1238
+ function canUseBrowserMic() {
1239
+ if (typeof window === "undefined" || typeof navigator === "undefined") {
1240
+ return false;
1241
+ }
1242
+ if (window.isSecureContext !== true) return false;
1243
+ const recognizer = Reflect.get(window, "SpeechRecognition") ?? Reflect.get(window, "webkitSpeechRecognition");
1244
+ if (typeof recognizer !== "function") return false;
1245
+ const agent = navigator.userAgent ?? "";
1246
+ if (/\bFirefox\//.test(agent) || /\bFxiOS\//.test(agent)) return false;
1247
+ if (/\bOPR\//.test(agent)) return false;
1248
+ return !("brave" in navigator);
1249
+ }
1250
+ var REPLAY_PRIVATE = "rr-block rr-mask";
1099
1251
  function clock(seconds) {
1100
1252
  const whole = Math.max(0, Math.floor(seconds));
1101
1253
  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
@@ -1106,47 +1258,110 @@ function withHeardText(current, heard, maxLength) {
1106
1258
  const joined = current.trim() === "" ? text : `${current.replace(/\s+$/, "")} ${text}`;
1107
1259
  return joined.slice(0, Math.max(0, maxLength));
1108
1260
  }
1261
+ function noticeOf(source) {
1262
+ return source.kind === "stream" ? AVATAR_MIC_NOTICE_VERSION : source.notice;
1263
+ }
1264
+ function micKeyOf(source, locale) {
1265
+ if (source.kind === "stream") return `stream ${source.base}`;
1266
+ const lang = BROWSER_MIC_LANGUAGES[locale]?.lang ?? "";
1267
+ return source.pauseMs === void 0 ? `browser ${lang}` : `browser ${lang} pause ${source.pauseMs}`;
1268
+ }
1269
+ function underWay(state) {
1270
+ return state.name === "starting" || state.name === "listening" || state.name === "finishing";
1271
+ }
1109
1272
  function useAvatarMic({
1110
1273
  enabled,
1111
1274
  locale,
1112
- base,
1113
- offer,
1114
- refreshOffer,
1275
+ source,
1115
1276
  onText,
1116
- onStart
1277
+ onStart,
1278
+ onTurn,
1279
+ onPress,
1280
+ echoOf,
1281
+ interruptible = false,
1282
+ note = null
1117
1283
  }) {
1118
1284
  const S = avatarMicStrings(locale);
1119
- const copy = avatarMicNotice(AVATAR_MIC_NOTICE_VERSION, locale);
1285
+ const noticeVersion = source === null ? null : noticeOf(source);
1286
+ const kind = source?.kind ?? null;
1287
+ const micKey = source === null ? null : micKeyOf(source, locale);
1288
+ const paused = source?.kind === "browser" && source.pauseMs !== void 0;
1289
+ const conversation = paused && noticeVersion !== null && avatarConsentCopy(noticeVersion, locale)?.notice === "none";
1290
+ const copy = noticeVersion === null || paused ? null : avatarMicNotice(noticeVersion, locale);
1291
+ const V = conversation ? avatarVoiceStrings(locale) : null;
1120
1292
  const uid = useId();
1121
1293
  const noticeTitleId = `${uid}-mic-notice`;
1122
- const [withdrawn, setWithdrawn] = useState(null);
1123
- const [supported, setSupported] = useState(false);
1124
- useEffect(() => setSupported(canUseAvatarMic()), []);
1125
- const possible = enabled && supported && base !== null && S !== null && copy !== null;
1126
- const offered = possible && withdrawn === null;
1294
+ const [withdrawn, setWithdrawn] = useState({});
1295
+ const [supported, setSupported] = useState({ stream: false, browser: false });
1296
+ useEffect(
1297
+ () => setSupported({ stream: canUseAvatarMic(), browser: canUseBrowserMic() }),
1298
+ []
1299
+ );
1300
+ const usable = source === null ? false : source.kind === "stream" ? supported.stream : supported.browser && BROWSER_MIC_LANGUAGES[locale] !== void 0;
1301
+ const showable = kind !== null && usable && S !== null && (paused ? conversation && V !== null : copy !== null);
1302
+ const possible = enabled && showable;
1303
+ const withdrawnReason = kind === null ? null : withdrawn[kind] ?? null;
1304
+ const offered = possible && withdrawnReason === null;
1127
1305
  const [state, setState] = useState(IDLE);
1128
1306
  const [noticeOpen, setNoticeOpen] = useState(false);
1129
- const agreedRef = useRef(false);
1130
- const [loading2, setLoading] = useState(false);
1307
+ const agreedRef = useRef(null);
1308
+ const [loading2, setLoadingState] = useState(false);
1309
+ const loadingRef = useRef(false);
1310
+ const setLoading = useCallback((value) => {
1311
+ loadingRef.current = value;
1312
+ setLoadingState(value);
1313
+ }, []);
1131
1314
  const [loadFailed, setLoadFailed] = useState(false);
1132
1315
  const [now, setNow] = useState(() => Date.now());
1133
- const micRef = useRef(null);
1316
+ const micRef = useRef(
1317
+ null
1318
+ );
1134
1319
  const seqRef = useRef(0);
1135
1320
  const buttonRef = useRef(null);
1136
1321
  const allowRef = useRef(null);
1137
- const usedEpochRef = useRef(0);
1138
- const latest = useRef({ offer, refreshOffer, onText, onStart, base });
1139
- latest.current = { offer, refreshOffer, onText, onStart, base };
1322
+ const usedRef = useRef(null);
1323
+ const gestureRef = useRef(false);
1324
+ const [, setUsedTick] = useState(0);
1325
+ const latest = useRef({
1326
+ source,
1327
+ locale,
1328
+ noticeVersion,
1329
+ onText,
1330
+ onStart,
1331
+ onTurn,
1332
+ onPress,
1333
+ echoOf,
1334
+ conversation,
1335
+ offered
1336
+ });
1337
+ latest.current = {
1338
+ source,
1339
+ locale,
1340
+ noticeVersion,
1341
+ onText,
1342
+ onStart,
1343
+ onTurn,
1344
+ onPress,
1345
+ echoOf,
1346
+ conversation,
1347
+ offered
1348
+ };
1140
1349
  const cancel = useCallback(() => {
1141
1350
  seqRef.current += 1;
1142
1351
  setLoading(false);
1143
1352
  setLoadFailed(false);
1144
1353
  setNoticeOpen(false);
1145
1354
  micRef.current?.mic.cancel();
1146
- }, []);
1355
+ }, [setLoading]);
1147
1356
  useEffect(() => {
1148
1357
  if (!offered) cancel();
1149
1358
  }, [offered, cancel]);
1359
+ const keyRef = useRef(micKey);
1360
+ useEffect(() => {
1361
+ if (keyRef.current === micKey) return;
1362
+ keyRef.current = micKey;
1363
+ cancel();
1364
+ }, [micKey, cancel]);
1150
1365
  useEffect(
1151
1366
  () => () => {
1152
1367
  seqRef.current += 1;
@@ -1154,43 +1369,134 @@ function useAvatarMic({
1154
1369
  },
1155
1370
  []
1156
1371
  );
1157
- const micFor = useCallback(async (target) => {
1158
- const held = micRef.current;
1159
- if (held !== null && held.base === target) return held.mic;
1160
- held?.mic.cancel();
1161
- const loaded = await loadAvatarMic();
1162
- const mic = loaded.createAvatarMic({
1163
- base: target,
1164
- notice: AVATAR_MIC_NOTICE_VERSION,
1165
- offer: () => latest.current.offer,
1166
- refreshOffer: () => latest.current.refreshOffer(),
1167
- onState: setState
1168
- });
1169
- micRef.current = { base: target, mic };
1170
- return mic;
1171
- }, []);
1172
- const press = useCallback(() => {
1173
- const target = latest.current.base;
1174
- if (target === null) return;
1175
- seqRef.current += 1;
1176
- const seq = seqRef.current;
1177
- setLoadFailed(false);
1178
- setLoading(true);
1179
- void micFor(target).then(
1180
- (mic) => {
1181
- if (seqRef.current !== seq) return;
1182
- setLoading(false);
1183
- mic.start();
1184
- },
1185
- () => {
1186
- if (seqRef.current !== seq) return;
1372
+ const micOf = useCallback(
1373
+ (loaded, wanted) => {
1374
+ const key = micKeyOf(wanted, latest.current.locale);
1375
+ const held = micRef.current;
1376
+ if (held !== null && held.key === key) return held.mic;
1377
+ held?.mic.cancel();
1378
+ let mic;
1379
+ if (wanted.kind === "stream") {
1380
+ const stream = () => {
1381
+ const current = latest.current.source;
1382
+ return current?.kind === "stream" ? current : null;
1383
+ };
1384
+ mic = loaded.createAvatarMic({
1385
+ base: wanted.base,
1386
+ notice: AVATAR_MIC_NOTICE_VERSION,
1387
+ offer: () => stream()?.offer ?? null,
1388
+ refreshOffer: () => stream()?.refreshOffer() ?? Promise.resolve(null),
1389
+ onState: setState
1390
+ });
1391
+ } else {
1392
+ const language = BROWSER_MIC_LANGUAGES[latest.current.locale] ?? EN_IN;
1393
+ const { pauseMs } = wanted;
1394
+ mic = loaded.createBrowserAvatarMic({
1395
+ lang: language.lang,
1396
+ fallbackLang: language.fallbackLang,
1397
+ maxPressMs: LIMIT_SECONDS * 1e3,
1398
+ onState: setState,
1399
+ // The voice conversation: each press ends itself on the server's pause. The avatar's own words heard back
1400
+ // are looked for only in a press the conversation started by itself: one the visitor's own tap started runs
1401
+ // while the avatar is quiet, or muted here by that tap (a Talk while it speaks interrupts it), so its words are
1402
+ // the visitor's, whatever of the answer they repeat ("Delivery is free across Tamil Nadu?").
1403
+ ...pauseMs === void 0 ? {} : {
1404
+ endpoint: {
1405
+ pauseMs,
1406
+ echoOf: () => gestureRef.current ? null : latest.current.echoOf?.() ?? null
1407
+ }
1408
+ }
1409
+ });
1410
+ }
1411
+ micRef.current = { key, kind: wanted.kind, mic };
1412
+ return mic;
1413
+ },
1414
+ []
1415
+ );
1416
+ const press = useCallback(
1417
+ (gesture) => {
1418
+ const wanted = latest.current.source;
1419
+ if (wanted === null) return;
1420
+ seqRef.current += 1;
1421
+ const seq = seqRef.current;
1422
+ setLoadFailed(false);
1423
+ gestureRef.current = gesture;
1424
+ if (latest.current.conversation) latest.current.onPress?.(gesture);
1425
+ const held = micRef.current;
1426
+ const ready = wanted.kind !== "browser" ? null : held !== null && held.key === micKeyOf(wanted, latest.current.locale) ? held.mic : loadedMic !== null ? micOf(loadedMic, wanted) : null;
1427
+ if (ready !== null) {
1187
1428
  setLoading(false);
1188
- setLoadFailed(true);
1429
+ ready.start();
1430
+ return;
1189
1431
  }
1190
- );
1191
- }, [micFor]);
1432
+ setLoading(true);
1433
+ const arriving = held !== null && held.key === micKeyOf(wanted, latest.current.locale) ? Promise.resolve(held.mic) : loadAvatarMic().then((loaded) => micOf(loaded, wanted));
1434
+ void arriving.then(
1435
+ (mic) => {
1436
+ if (seqRef.current !== seq) return;
1437
+ setLoading(false);
1438
+ mic.start();
1439
+ },
1440
+ () => {
1441
+ if (seqRef.current !== seq) return;
1442
+ setLoading(false);
1443
+ setLoadFailed(true);
1444
+ if (latest.current.conversation) {
1445
+ latest.current.onTurn?.({
1446
+ by: "failed",
1447
+ text: "",
1448
+ echo: false,
1449
+ gesture,
1450
+ failure: "failed"
1451
+ });
1452
+ }
1453
+ }
1454
+ );
1455
+ },
1456
+ [micOf, setLoading]
1457
+ );
1192
1458
  const recording = loading2 || state.name === "starting" || state.name === "listening";
1459
+ const finishing = kind === "browser" && state.name === "finishing";
1460
+ const preload = useCallback(() => {
1461
+ const seq = seqRef.current;
1462
+ void loadAvatarMic().then(
1463
+ (loaded) => {
1464
+ const wanted = latest.current.source;
1465
+ if (seqRef.current === seq && wanted?.kind === "browser") {
1466
+ micOf(loaded, wanted);
1467
+ }
1468
+ },
1469
+ () => void 0
1470
+ );
1471
+ }, [micOf]);
1472
+ const pressing = useCallback(() => {
1473
+ const held = micRef.current?.mic.state() ?? IDLE;
1474
+ return loadingRef.current || underWay(held) || (held.name === "done" || held.name === "failed") && held !== usedRef.current;
1475
+ }, []);
1476
+ const stopListening = useCallback(() => {
1477
+ const held = micRef.current?.mic.state() ?? IDLE;
1478
+ let text = "";
1479
+ if (held.name === "listening" || held.name === "finishing") {
1480
+ text = held.text;
1481
+ } else if ((held.name === "done" || held.name === "failed") && held !== usedRef.current) {
1482
+ usedRef.current = held;
1483
+ text = held.text;
1484
+ }
1485
+ const gesture = gestureRef.current;
1486
+ cancel();
1487
+ latest.current.onTurn?.({ by: "tap", text, echo: false, gesture });
1488
+ }, [cancel]);
1193
1489
  const tap = useCallback(() => {
1490
+ if (latest.current.conversation) {
1491
+ if (pressing()) {
1492
+ stopListening();
1493
+ return;
1494
+ }
1495
+ latest.current.onStart?.();
1496
+ agreedRef.current = latest.current.noticeVersion;
1497
+ press(true);
1498
+ return;
1499
+ }
1194
1500
  if (state.name === "listening" || state.name === "starting") {
1195
1501
  micRef.current?.mic.stop();
1196
1502
  return;
@@ -1199,24 +1505,60 @@ function useAvatarMic({
1199
1505
  cancel();
1200
1506
  return;
1201
1507
  }
1508
+ if (finishing) return;
1202
1509
  latest.current.onStart?.();
1203
- if (!agreedRef.current) {
1510
+ if (agreedRef.current !== latest.current.noticeVersion) {
1204
1511
  setNoticeOpen(true);
1205
- void loadAvatarMic().catch(() => void 0);
1512
+ preload();
1206
1513
  return;
1207
1514
  }
1208
- press();
1209
- }, [cancel, loading2, press, state.name]);
1515
+ press(true);
1516
+ }, [
1517
+ cancel,
1518
+ finishing,
1519
+ loading2,
1520
+ preload,
1521
+ press,
1522
+ pressing,
1523
+ state.name,
1524
+ stopListening
1525
+ ]);
1210
1526
  const allow = useCallback(() => {
1211
- agreedRef.current = true;
1527
+ agreedRef.current = latest.current.noticeVersion;
1212
1528
  setNoticeOpen(false);
1213
1529
  buttonRef.current?.focus();
1214
- press();
1530
+ press(true);
1215
1531
  }, [press]);
1216
1532
  const notNow = useCallback(() => {
1217
1533
  setNoticeOpen(false);
1218
1534
  buttonRef.current?.focus();
1219
1535
  }, []);
1536
+ const preparing = conversation && offered;
1537
+ useEffect(() => {
1538
+ if (preparing) preload();
1539
+ }, [preparing, preload]);
1540
+ const listen = useCallback(() => {
1541
+ if (!latest.current.conversation || !latest.current.offered || // Never before the visitor's own Talk on this page, under this version.
1542
+ agreedRef.current !== latest.current.noticeVersion || // A press under way, or one that has ended and whose outcome has not gone to the view yet (still that press: a
1543
+ // question that ended on its pause is about to be sent): the mic must not open over it.
1544
+ pressing()) {
1545
+ return false;
1546
+ }
1547
+ press(false);
1548
+ return true;
1549
+ }, [press, pressing]);
1550
+ const heard = useCallback(() => {
1551
+ const held = micRef.current?.mic.state() ?? IDLE;
1552
+ return held.name === "listening" || held.name === "finishing" ? held.text : "";
1553
+ }, []);
1554
+ const dropLoopPress = useCallback(() => {
1555
+ const held = micRef.current?.mic.state() ?? IDLE;
1556
+ if (!latest.current.conversation || gestureRef.current || !loadingRef.current && !underWay(held)) {
1557
+ return false;
1558
+ }
1559
+ cancel();
1560
+ return true;
1561
+ }, [cancel]);
1220
1562
  useEffect(() => {
1221
1563
  if (noticeOpen) allowRef.current?.focus();
1222
1564
  }, [noticeOpen]);
@@ -1228,24 +1570,71 @@ function useAvatarMic({
1228
1570
  return () => clearInterval(timer);
1229
1571
  }, [listeningSince]);
1230
1572
  useEffect(() => {
1231
- if (state.name === "done" && state.epoch !== usedEpochRef.current) {
1232
- usedEpochRef.current = state.epoch;
1233
- if (state.text !== "") latest.current.onText(state.text);
1234
- } else if (state.name === "failed" && FOR_GOOD.has(state.reason)) {
1235
- setWithdrawn(state.reason);
1573
+ const voice = latest.current.conversation;
1574
+ if (state.name === "done" && state !== usedRef.current) {
1575
+ usedRef.current = state;
1576
+ if (voice) {
1577
+ latest.current.onTurn?.({
1578
+ by: state.by ?? (state.limit ? "limit" : "tap"),
1579
+ text: state.text,
1580
+ echo: state.echo === true,
1581
+ gesture: gestureRef.current
1582
+ });
1583
+ setUsedTick((tick) => tick + 1);
1584
+ } else if (state.text !== "") {
1585
+ latest.current.onText(state.text);
1586
+ }
1587
+ return;
1588
+ }
1589
+ const pressed = micRef.current?.kind;
1590
+ if (state.name === "failed" && voice && state !== usedRef.current) {
1591
+ usedRef.current = state;
1592
+ const gesture = gestureRef.current;
1593
+ if (gesture && pressed !== void 0 && FOR_GOOD[pressed].has(state.reason)) {
1594
+ const reason = state.reason;
1595
+ setWithdrawn((held) => ({ ...held, [pressed]: reason }));
1596
+ }
1597
+ latest.current.onTurn?.({
1598
+ by: "failed",
1599
+ text: state.text,
1600
+ echo: false,
1601
+ gesture,
1602
+ failure: state.reason
1603
+ });
1604
+ setUsedTick((tick) => tick + 1);
1605
+ return;
1606
+ }
1607
+ if (state.name === "failed" && !voice && pressed !== void 0 && FOR_GOOD[pressed].has(state.reason)) {
1608
+ const reason = state.reason;
1609
+ setWithdrawn((held) => ({ ...held, [pressed]: reason }));
1236
1610
  }
1237
1611
  }, [state]);
1612
+ const outcomeDue = (state.name === "done" || state.name === "failed") && state !== usedRef.current;
1613
+ const busy = loading2 || state.name === "starting" || state.name === "listening" || state.name === "finishing" || outcomeDue;
1614
+ const voiceControls = {
1615
+ mode: conversation ? "conversation" : "editable",
1616
+ speaking: conversation && offered && state.name === "listening" && state.genuine === true,
1617
+ busy,
1618
+ pressing,
1619
+ listen,
1620
+ heard,
1621
+ dropLoopPress
1622
+ };
1623
+ if (conversation && showable && V !== null && S !== null) {
1624
+ return conversationControls(V, S);
1625
+ }
1238
1626
  if (!possible || S === null || copy === null) {
1239
1627
  return {
1240
1628
  button: null,
1241
1629
  notice: null,
1242
1630
  lines: null,
1243
1631
  cancel,
1244
- recording: false
1632
+ recording: false,
1633
+ ...voiceControls
1245
1634
  };
1246
1635
  }
1247
- const status = withdrawn !== null ? S[FAILURE_TEXT[withdrawn]] : loading2 ? S.micStarting : loadFailed ? S.micFailed : state.name === "starting" ? S.micStarting : state.name === "listening" ? S.micListening : state.name === "finishing" ? S.micFinishing : state.name === "done" ? state.text === "" ? S.micNoSpeech : state.limit ? S.micDoneLimit : S.micDone : state.name === "failed" ? S[FAILURE_TEXT[state.reason]] : "";
1248
- const heard = offered && (state.name === "listening" || state.name === "finishing") ? state.text : "";
1636
+ const status = withdrawnReason !== null ? S[FAILURE_TEXT[withdrawnReason]] : loading2 ? S.micStarting : loadFailed ? S.micFailed : state.name === "starting" ? S.micStarting : state.name === "listening" ? S.micListening : state.name === "finishing" ? S.micFinishing : state.name === "done" ? state.text === "" ? S.micNoSpeech : state.limit ? S.micDoneLimit : S.micDone : state.name === "failed" ? S[FAILURE_TEXT[state.reason]] : "";
1637
+ const heardText = offered && (state.name === "listening" || state.name === "finishing") ? state.text : "";
1249
1638
  const button = !offered ? null : /* @__PURE__ */ jsx(
1250
1639
  "button",
1251
1640
  {
@@ -1253,73 +1642,25 @@ function useAvatarMic({
1253
1642
  type: "button",
1254
1643
  "aria-label": recording ? S.micStop : S.micStart,
1255
1644
  "aria-pressed": recording,
1645
+ "aria-disabled": finishing ? true : void 0,
1256
1646
  title: recording ? S.micStop : S.micStart,
1257
1647
  "data-boff-explore": "avatar-live-mic",
1258
1648
  "data-mic": loading2 ? "starting" : state.name,
1649
+ "data-mic-source": kind ?? void 0,
1259
1650
  className: cn(
1260
1651
  "inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1261
- recording ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground"
1652
+ recording ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
1653
+ finishing && "cursor-wait opacity-50"
1262
1654
  ),
1263
1655
  onClick: tap,
1264
1656
  children: recording ? /* @__PURE__ */ jsx(Square, { "aria-hidden": "true", className: "h-4 w-4" }) : /* @__PURE__ */ jsx(Mic, { "aria-hidden": "true", className: "h-4 w-4" })
1265
1657
  }
1266
1658
  );
1267
- const notice = offered && noticeOpen ? /* @__PURE__ */ jsxs(
1268
- "section",
1269
- {
1270
- "aria-labelledby": noticeTitleId,
1271
- "data-boff-explore": "avatar-live-mic-notice",
1272
- className: "w-full rounded-lg border border-border p-3 text-start",
1273
- children: [
1274
- /* @__PURE__ */ jsx("h3", { id: noticeTitleId, className: "text-sm font-semibold", children: copy.title }),
1275
- /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.processing }),
1276
- /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.storage }),
1277
- /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.text }),
1278
- /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.limits }),
1279
- /* @__PURE__ */ jsx(
1280
- "a",
1281
- {
1282
- href: PRIVACY_HREF,
1283
- target: "_blank",
1284
- rel: "noopener noreferrer",
1285
- "data-boff-explore": "avatar-live-mic-privacy",
1286
- className: "mt-1 inline-block text-xs font-medium text-primary underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1287
- children: copy.privacyLink
1288
- }
1289
- ),
1290
- /* @__PURE__ */ jsxs("div", { className: "mt-2 flex flex-wrap gap-2", children: [
1291
- /* @__PURE__ */ jsx(
1292
- "button",
1293
- {
1294
- ref: allowRef,
1295
- type: "button",
1296
- "data-boff-explore": "avatar-live-mic-allow",
1297
- className: "inline-flex min-h-11 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1298
- onClick: allow,
1299
- children: S.micAllow
1300
- }
1301
- ),
1302
- /* @__PURE__ */ jsx(
1303
- "button",
1304
- {
1305
- type: "button",
1306
- "data-boff-explore": "avatar-live-mic-not-now",
1307
- className: "inline-flex min-h-11 items-center justify-center rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1308
- onClick: notNow,
1309
- children: S.micNotNow
1310
- }
1311
- )
1312
- ] })
1313
- ]
1314
- }
1315
- ) : null;
1659
+ const notice = offered && noticeOpen ? noticeCard(copy, S.micAllow, S.micNotNow) : null;
1316
1660
  const listening = offered && state.name === "listening";
1317
1661
  const lines = /* @__PURE__ */ jsxs(Fragment, { children: [
1318
1662
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
1319
- listening ? /* @__PURE__ */ jsxs("span", { "aria-hidden": "true", className: "relative flex h-2 w-2 shrink-0", children: [
1320
- /* @__PURE__ */ jsx("span", { className: "absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75 motion-reduce:animate-none" }),
1321
- /* @__PURE__ */ jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-primary" })
1322
- ] }) : null,
1663
+ listening ? listeningDot : null,
1323
1664
  /* @__PURE__ */ jsx("p", { role: "status", "data-boff-explore": "avatar-live-mic-status", children: status }),
1324
1665
  listening ? /* @__PURE__ */ jsx(
1325
1666
  "span",
@@ -1336,24 +1677,142 @@ function useAvatarMic({
1336
1677
  }
1337
1678
  ) : null
1338
1679
  ] }),
1339
- heard === "" ? null : /* @__PURE__ */ jsx(
1340
- "p",
1341
- {
1342
- "data-boff-explore": "avatar-live-mic-heard",
1343
- dir: "auto",
1344
- className: "line-clamp-3 break-words text-sm italic text-muted-foreground",
1345
- children: heard
1346
- }
1347
- )
1680
+ heardLine(heardText)
1348
1681
  ] });
1349
1682
  return {
1350
1683
  button,
1351
1684
  notice,
1352
1685
  lines,
1353
1686
  cancel,
1354
- recording: offered && state.name === "listening"
1687
+ recording: offered && state.name === "listening",
1688
+ ...voiceControls
1355
1689
  };
1690
+ function noticeCard(words, allowLabel, notNowLabel) {
1691
+ return /* @__PURE__ */ jsxs(
1692
+ "section",
1693
+ {
1694
+ "aria-labelledby": noticeTitleId,
1695
+ "data-boff-explore": "avatar-live-mic-notice",
1696
+ className: "w-full rounded-lg border border-border p-3 text-start",
1697
+ children: [
1698
+ /* @__PURE__ */ jsx("h3", { id: noticeTitleId, className: "text-sm font-semibold", children: words.title }),
1699
+ /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: words.processing }),
1700
+ /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: words.storage }),
1701
+ /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: words.text }),
1702
+ /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: words.limits }),
1703
+ /* @__PURE__ */ jsx(
1704
+ "a",
1705
+ {
1706
+ href: PRIVACY_HREF,
1707
+ target: "_blank",
1708
+ rel: "noopener noreferrer",
1709
+ "data-boff-explore": "avatar-live-mic-privacy",
1710
+ className: "mt-1 inline-block text-xs font-medium text-primary underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1711
+ children: words.privacyLink
1712
+ }
1713
+ ),
1714
+ /* @__PURE__ */ jsxs("div", { className: "mt-2 flex flex-wrap gap-2", children: [
1715
+ /* @__PURE__ */ jsx(
1716
+ "button",
1717
+ {
1718
+ ref: allowRef,
1719
+ type: "button",
1720
+ "data-boff-explore": "avatar-live-mic-allow",
1721
+ className: "inline-flex min-h-11 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1722
+ onClick: allow,
1723
+ children: allowLabel
1724
+ }
1725
+ ),
1726
+ /* @__PURE__ */ jsx(
1727
+ "button",
1728
+ {
1729
+ type: "button",
1730
+ "data-boff-explore": "avatar-live-mic-not-now",
1731
+ className: "inline-flex min-h-11 items-center justify-center rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1732
+ onClick: notNow,
1733
+ children: notNowLabel
1734
+ }
1735
+ )
1736
+ ] })
1737
+ ]
1738
+ }
1739
+ );
1740
+ }
1741
+ function heardLine(text) {
1742
+ return text === "" ? null : /* @__PURE__ */ jsx(
1743
+ "p",
1744
+ {
1745
+ "data-boff-explore": "avatar-live-mic-heard",
1746
+ dir: "auto",
1747
+ className: cn(
1748
+ // rrweb's default block and mask classes: a site's session replay records none of what is heard.
1749
+ REPLAY_PRIVATE,
1750
+ "line-clamp-3 break-words text-sm italic text-muted-foreground"
1751
+ ),
1752
+ children: text
1753
+ }
1754
+ );
1755
+ }
1756
+ function conversationControls(V2, S2) {
1757
+ const going = loading2 || state.name === "starting" || state.name === "listening" || state.name === "finishing";
1758
+ const label = busy ? V2.stopListening : V2.talk;
1759
+ const name = busy ? V2.stopListening : interruptible ? V2.talkInterrupt : V2.talk;
1760
+ const talk = !offered ? null : /* @__PURE__ */ jsxs(
1761
+ "button",
1762
+ {
1763
+ ref: buttonRef,
1764
+ type: "button",
1765
+ "aria-label": name,
1766
+ title: name,
1767
+ "data-boff-explore": "avatar-live-mic",
1768
+ "data-mic": loading2 ? "starting" : state.name,
1769
+ "data-mic-source": "browser",
1770
+ "data-mic-mode": "conversation",
1771
+ className: cn(
1772
+ "inline-flex h-11 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md border px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1773
+ busy ? "border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground" : "border-primary bg-primary text-primary-foreground hover:opacity-90"
1774
+ ),
1775
+ onClick: tap,
1776
+ children: [
1777
+ busy ? /* @__PURE__ */ jsx(Square, { "aria-hidden": "true", className: "h-4 w-4" }) : /* @__PURE__ */ jsx(Mic, { "aria-hidden": "true", className: "h-4 w-4" }),
1778
+ /* @__PURE__ */ jsx("span", { children: label })
1779
+ ]
1780
+ }
1781
+ );
1782
+ const pressLine = !offered ? null : loading2 || state.name === "starting" ? V2.starting : state.name === "listening" ? state.pausing === true ? V2.pausing : V2.listening : state.name === "finishing" ? state.limit ? S2.micFinishing : V2.sending : null;
1783
+ const noteLine = note === null ? "" : note.kind === "line" ? V2[note.line] : S2[FAILURE_TEXT[note.reason]];
1784
+ const line = withdrawnReason !== null ? S2[FAILURE_TEXT[withdrawnReason]] : pressLine ?? noteLine;
1785
+ const heardNow = offered && (state.name === "listening" || state.name === "finishing") ? state.text : "";
1786
+ const listeningNow = offered && state.name === "listening";
1787
+ const shown = possible || line !== "";
1788
+ return {
1789
+ button: talk,
1790
+ notice: null,
1791
+ lines: !shown ? null : /* @__PURE__ */ jsxs(Fragment, { children: [
1792
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
1793
+ listeningNow ? listeningDot : null,
1794
+ /* @__PURE__ */ jsx(
1795
+ "p",
1796
+ {
1797
+ role: "status",
1798
+ "data-boff-explore": "avatar-live-mic-status",
1799
+ "data-voice": going ? "pressing" : note?.kind ?? "idle",
1800
+ children: line
1801
+ }
1802
+ )
1803
+ ] }),
1804
+ heardLine(heardNow)
1805
+ ] }),
1806
+ cancel,
1807
+ recording: offered && state.name === "listening",
1808
+ ...voiceControls
1809
+ };
1810
+ }
1356
1811
  }
1812
+ var listeningDot = /* @__PURE__ */ jsxs("span", { "aria-hidden": "true", className: "relative flex h-2 w-2 shrink-0", children: [
1813
+ /* @__PURE__ */ jsx("span", { className: "absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75 motion-reduce:animate-none" }),
1814
+ /* @__PURE__ */ jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-primary" })
1815
+ ] });
1357
1816
 
1358
1817
  // src/components/explore-avatar-turnstile.ts
1359
1818
  var SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
@@ -1478,9 +1937,13 @@ var PLAYOUT_TAIL_MS = 5e3;
1478
1937
  var QUIET_PLAYED_OUT_MS = 2500;
1479
1938
  var OWED_VOICE_WAIT_MS = 15e3;
1480
1939
  var LISTENING_MARK_MS = 1e4;
1940
+ var LISTENING_MARK_SPACING_MS = 5e3;
1481
1941
  var BUSY_RETRY_MS = 2e3;
1482
1942
  var BUSY_RETRY_MAX_MS = 8e3;
1483
1943
  var MAX_BUSY_RETRIES = 24;
1944
+ var PAUSE_NOTICE_TOLERANCE_MS = 500;
1945
+ var MAX_ECHO_DISCARDS = 2;
1946
+ var LISTEN_AGAIN_MIN_LEFT_MS = 2e4;
1484
1947
  var SERVICE_MAX_AGE_MS = 15 * 6e4;
1485
1948
  var ANSWER_OFFER_MS = SERVICE_MAX_AGE_MS - 6e4;
1486
1949
  var MAX_TIMER_MS = 2147483647;
@@ -1490,6 +1953,29 @@ var USED_ON_KEY = "boff.explore.avatarUsedOn";
1490
1953
  var PRIMARY = '[data-avatar-primary], [data-boff-explore="panel-continue-chat"]';
1491
1954
  var IDLE2 = { name: "idle" };
1492
1955
  var INERT2 = { name: "inert" };
1956
+ var VOICE_OFF = {
1957
+ on: false,
1958
+ arm: null,
1959
+ echoes: 0,
1960
+ note: null
1961
+ };
1962
+ function lineNote(line) {
1963
+ return { kind: "line", line };
1964
+ }
1965
+ function sameNote(a, b) {
1966
+ if (a === null || b === null) return a === b;
1967
+ return a.kind === "line" ? b.kind === "line" && a.line === b.line : b.kind === "failed" && a.reason === b.reason;
1968
+ }
1969
+ var SESSION_REFUSALS = /* @__PURE__ */ new Set([
1970
+ "no_time",
1971
+ "max_answers",
1972
+ "limits",
1973
+ "paused"
1974
+ ]);
1975
+ function playedOutNow(handle) {
1976
+ const run = handle.followUps;
1977
+ return run !== null && !run.voice && run.quiet === null && handle.playedOut && !run.owed;
1978
+ }
1493
1979
  function stopAttending(handle) {
1494
1980
  if (handle.unheard !== null) clearTimeout(handle.unheard);
1495
1981
  if (handle.tail !== null) clearTimeout(handle.tail);
@@ -1603,8 +2089,21 @@ function acceptsQuestion(slot) {
1603
2089
  function followUpBox(phase, run, token) {
1604
2090
  return phase.name === "live" && run !== null && phase.session.followUp !== null && !phase.session.followUp.closed && token === run.token;
1605
2091
  }
1606
- function micOfferOf(result) {
1607
- return result.kind === "available" && result.offer.stt !== void 0 ? { ...result.offer.stt, receivedAt: Date.now() } : null;
2092
+ var NO_MIC = { kind: "none" };
2093
+ function micChoiceOf(result, locale) {
2094
+ if (result.kind !== "available") return NO_MIC;
2095
+ const { offer } = result;
2096
+ const { mic } = offer;
2097
+ const copy = avatarConsentCopy(offer.consentVersion, locale);
2098
+ if (copy?.notice === "none") {
2099
+ const pauseMs = mic?.mode === "browser" ? mic.pauseMs : void 0;
2100
+ return pauseMs !== void 0 && Math.abs(pauseMs - copy.voice.pauseMs) <= PAUSE_NOTICE_TOLERANCE_MS && avatarVoiceStrings(locale) !== null ? { kind: "browser", notice: offer.consentVersion, pauseMs } : NO_MIC;
2101
+ }
2102
+ const micNotice = copy?.micNotice;
2103
+ if (mic !== void 0) {
2104
+ return mic.mode === "browser" && micNotice !== void 0 && mic.pauseMs === void 0 ? { kind: "browser", notice: micNotice } : NO_MIC;
2105
+ }
2106
+ return offer.stt !== void 0 && micNotice === void 0 ? { kind: "stream", offer: { ...offer.stt, receivedAt: Date.now() } } : NO_MIC;
1608
2107
  }
1609
2108
  function utcToday() {
1610
2109
  return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
@@ -1702,6 +2201,7 @@ function AvatarLiveView({
1702
2201
  conversation
1703
2202
  }) {
1704
2203
  const S = avatarLiveStrings(locale);
2204
+ const voiceWords = avatarVoiceStrings(locale);
1705
2205
  const uid = useId();
1706
2206
  const consentTitleId = `${uid}-consent`;
1707
2207
  const [questions, setQuestions] = useState("suggested");
@@ -1730,12 +2230,16 @@ function AvatarLiveView({
1730
2230
  const offerUntil = answerId === null ? null : exchange.offerUntil;
1731
2231
  const [phase, setPhase] = useState(IDLE2);
1732
2232
  const [blocked, setBlocked] = useState(false);
2233
+ const blockedRef = useRef(false);
2234
+ blockedRef.current = blocked;
1733
2235
  const [now, setNow] = useState(() => Date.now());
1734
2236
  const [draft, setDraft] = useState("");
1735
2237
  const chatMaxLength = conversation?.catalog.limits.maxMessageLength;
1736
2238
  const askMaxLength = chatMaxLength !== void 0 && Number.isFinite(chatMaxLength) && chatMaxLength >= 1 ? Math.min(ASK_MAX_LENGTH, Math.floor(chatMaxLength)) : ASK_MAX_LENGTH;
1737
2239
  const [note, setNote] = useState(null);
1738
2240
  const [usedToday, setUsedToday] = useState(false);
2241
+ const [voiceOffered, setVoiceOffered] = useState(false);
2242
+ const [noNotice, setNoNotice] = useState(false);
1739
2243
  const showing = active && !off;
1740
2244
  const behindChat = open && !active && !off;
1741
2245
  const phaseRef = useRef(IDLE2);
@@ -1754,8 +2258,36 @@ function AvatarLiveView({
1754
2258
  const waitRef = useRef(null);
1755
2259
  const sendingRef = useRef(false);
1756
2260
  const textBoxRef = useRef(false);
1757
- const [micOffer, setMicOffer] = useState(null);
2261
+ const [micChoice, setMicChoice] = useState(NO_MIC);
1758
2262
  const micCancelRef = useRef(() => void 0);
2263
+ const micControlRef = useRef(null);
2264
+ const voiceRef = useRef(VOICE_OFF);
2265
+ const [, setVoiceTick] = useState(0);
2266
+ const voiceSet = useCallback((next) => {
2267
+ voiceRef.current = { ...voiceRef.current, ...next };
2268
+ setVoiceTick((tick) => tick + 1);
2269
+ }, []);
2270
+ const askMaxLengthRef = useRef(ASK_MAX_LENGTH);
2271
+ const voiceOff = useCallback(
2272
+ (note2, keep) => {
2273
+ const run = voiceRef.current;
2274
+ if (!run.on && run.arm === null) {
2275
+ if (!sameNote(run.note, note2)) voiceSet({ note: note2 });
2276
+ return;
2277
+ }
2278
+ if (keep) {
2279
+ const heard = micControlRef.current?.heard() ?? "";
2280
+ if (heard !== "") {
2281
+ setDraft(
2282
+ (current) => withHeardText(current, heard, askMaxLengthRef.current)
2283
+ );
2284
+ }
2285
+ }
2286
+ micCancelRef.current();
2287
+ voiceSet({ ...VOICE_OFF, note: note2 });
2288
+ },
2289
+ [voiceSet]
2290
+ );
1759
2291
  const sendDueRef = useRef(() => void 0);
1760
2292
  const checkRef = useRef(null);
1761
2293
  const askedLocale = useRef(locale);
@@ -1812,6 +2344,8 @@ function AvatarLiveView({
1812
2344
  handle.done = true;
1813
2345
  stopAttending(handle);
1814
2346
  sessionRef.current = null;
2347
+ voiceOff(null, true);
2348
+ if (audioRef.current !== null) audioRef.current.muted = false;
1815
2349
  if (reason !== null) {
1816
2350
  sendEnd({
1817
2351
  base: handle.base,
@@ -1827,7 +2361,7 @@ function AvatarLiveView({
1827
2361
  setBlocked(false);
1828
2362
  go({ name: "ended", how });
1829
2363
  },
1830
- [go]
2364
+ [go, voiceOff]
1831
2365
  );
1832
2366
  const goLive = useCallback(() => {
1833
2367
  const current = phaseRef.current;
@@ -1900,6 +2434,18 @@ function AvatarLiveView({
1900
2434
  },
1901
2435
  []
1902
2436
  );
2437
+ const unmute = useCallback((handle) => {
2438
+ const run = handle.followUps;
2439
+ if (run === null || !run.muted) return;
2440
+ run.muted = false;
2441
+ if (audioRef.current !== null) audioRef.current.muted = false;
2442
+ }, []);
2443
+ const holdMute = useCallback(() => {
2444
+ const run = sessionRef.current?.followUps ?? null;
2445
+ const audio = audioRef.current;
2446
+ if (run === null || !run.muted || audio === null || audio.muted) return;
2447
+ audio.muted = true;
2448
+ }, []);
1903
2449
  const whenQuiet = useCallback(
1904
2450
  (handle, delay) => {
1905
2451
  const run = handle.followUps;
@@ -1913,10 +2459,12 @@ function AvatarLiveView({
1913
2459
  }
1914
2460
  if (run.owed) {
1915
2461
  run.owed = false;
2462
+ run.owedWait = true;
1916
2463
  wait(OWED_VOICE_WAIT_MS);
1917
2464
  return;
1918
2465
  }
1919
2466
  handle.playedOut = true;
2467
+ if (!run.mutedUnheard) unmute(handle);
1920
2468
  showFollowUp(handle, { speaking: false });
1921
2469
  attend();
1922
2470
  sendDueRef.current(handle);
@@ -1924,7 +2472,7 @@ function AvatarLiveView({
1924
2472
  };
1925
2473
  wait(delay);
1926
2474
  },
1927
- [attend, showFollowUp]
2475
+ [attend, showFollowUp, unmute]
1928
2476
  );
1929
2477
  const heardVoice = useCallback(
1930
2478
  (handle, speaking) => {
@@ -1940,7 +2488,10 @@ function AvatarLiveView({
1940
2488
  run.heard = true;
1941
2489
  run.starts += 1;
1942
2490
  run.owed = false;
2491
+ run.owedWait = false;
2492
+ run.mutedUnheard = false;
1943
2493
  handle.playedOut = false;
2494
+ if (voiceRef.current.on) micControlRef.current?.dropLoopPress();
1944
2495
  showFollowUp(handle, { speaking: true });
1945
2496
  },
1946
2497
  [showFollowUp, whenQuiet]
@@ -2007,6 +2558,7 @@ function AvatarLiveView({
2007
2558
  }
2008
2559
  run.due = null;
2009
2560
  run.answers += 1;
2561
+ unmute(handle);
2010
2562
  const heardSince = run.starts !== starts;
2011
2563
  run.owed = !heardSince;
2012
2564
  const playing = !heardSince || run.voice || run.quiet !== null;
@@ -2028,7 +2580,7 @@ function AvatarLiveView({
2028
2580
  if (run.owed && run.heard && !run.voice) whenQuiet(handle, 0);
2029
2581
  })();
2030
2582
  },
2031
- [attend, showFollowUp, whenQuiet]
2583
+ [attend, showFollowUp, unmute, whenQuiet]
2032
2584
  );
2033
2585
  sendDueRef.current = sendDue;
2034
2586
  const readFollowUp = useCallback(
@@ -2058,19 +2610,26 @@ function AvatarLiveView({
2058
2610
  const applyAvailability = useCallback(
2059
2611
  (result, locale2) => {
2060
2612
  const { live: live2 } = latest.current;
2061
- setMicOffer(micOfferOf(result));
2613
+ setMicChoice(micChoiceOf(result, locale2));
2062
2614
  if (result.kind === "inert") {
2063
2615
  setMode("suggested");
2064
2616
  go(INERT2);
2065
2617
  return;
2066
2618
  }
2067
2619
  if (result.kind === "refused") {
2620
+ if (result.consentVersion !== void 0) {
2621
+ setNoNotice(
2622
+ avatarConsentCopy(result.consentVersion, locale2)?.notice === "none"
2623
+ );
2624
+ }
2068
2625
  setMode(result.questions);
2069
2626
  go({ name: "refused", reason: result.reason });
2070
2627
  return;
2071
2628
  }
2072
2629
  setUsedToday(usedTodayHere());
2073
2630
  const copy = avatarConsentCopy(result.offer.consentVersion, locale2);
2631
+ setVoiceOffered(copy?.voice !== void 0);
2632
+ setNoNotice(copy?.notice === "none");
2074
2633
  if (copy === null || copy.botCheck !== result.offer.botCheck || copy.questions !== result.offer.questions) {
2075
2634
  setMode("suggested");
2076
2635
  go(INERT2);
@@ -2125,12 +2684,16 @@ function AvatarLiveView({
2125
2684
  heard: false,
2126
2685
  starts: 0,
2127
2686
  owed: true,
2687
+ owedWait: false,
2128
2688
  quiet: null,
2129
2689
  asked: null,
2130
2690
  due: null,
2131
2691
  retry: null,
2132
2692
  sent: /* @__PURE__ */ new Set([context.messageId]),
2133
- seq: 0
2693
+ seq: 0,
2694
+ listenedAt: null,
2695
+ muted: false,
2696
+ mutedUnheard: false
2134
2697
  } : null;
2135
2698
  sessionRef.current = {
2136
2699
  session,
@@ -2149,6 +2712,7 @@ function AvatarLiveView({
2149
2712
  followUps,
2150
2713
  done: false
2151
2714
  };
2715
+ if (audioRef.current !== null) audioRef.current.muted = false;
2152
2716
  setBlocked(false);
2153
2717
  setNow(received);
2154
2718
  go({
@@ -2404,6 +2968,7 @@ function AvatarLiveView({
2404
2968
  [applyAvailability, bindingHolds, cancelWaiting, exchangeNow, launch]
2405
2969
  );
2406
2970
  const stop = useCallback(() => {
2971
+ voiceOff(null, true);
2407
2972
  micCancelRef.current();
2408
2973
  const current = phaseRef.current;
2409
2974
  if (current.name === "waiting") {
@@ -2423,9 +2988,10 @@ function AvatarLiveView({
2423
2988
  if (current.name === "pending" || current.name === "live") {
2424
2989
  finish("stopped", "user");
2425
2990
  }
2426
- }, [cancelStart, cancelWaiting, finish, go, reoffer]);
2991
+ }, [cancelStart, cancelWaiting, finish, go, reoffer, voiceOff]);
2427
2992
  const leave = useCallback(
2428
2993
  (event) => {
2994
+ voiceOff(sessionRef.current === null ? null : lineNote("tapNext"), false);
2429
2995
  micCancelRef.current();
2430
2996
  const current = phaseRef.current;
2431
2997
  const reason = event === "user" || event === "chat" ? "user" : "page_hide";
@@ -2466,7 +3032,7 @@ function AvatarLiveView({
2466
3032
  return;
2467
3033
  }
2468
3034
  },
2469
- [cancelStart, cancelWaiting, finish, go, goLive, reoffer]
3035
+ [cancelStart, cancelWaiting, finish, go, goLive, reoffer, voiceOff]
2470
3036
  );
2471
3037
  useEffect(() => {
2472
3038
  if (!showing) {
@@ -2580,6 +3146,7 @@ function AvatarLiveView({
2580
3146
  const wasLive = phaseRef.current.name === "live";
2581
3147
  switch (event.type) {
2582
3148
  case "speaker-track":
3149
+ holdMute();
2583
3150
  if (event.kind !== "video") {
2584
3151
  handle.voiceArrived = true;
2585
3152
  attend();
@@ -2589,6 +3156,7 @@ function AvatarLiveView({
2589
3156
  if (!inSight()) goLive();
2590
3157
  return;
2591
3158
  case "playback-blocked":
3159
+ holdMute();
2592
3160
  setBlocked(event.blocked);
2593
3161
  handle.soundHeld = event.sound ?? event.blocked;
2594
3162
  attend();
@@ -2619,7 +3187,7 @@ function AvatarLiveView({
2619
3187
  return;
2620
3188
  }
2621
3189
  },
2622
- [attend, finish, goLive, heardVoice, inSight]
3190
+ [attend, finish, goLive, heardVoice, holdMute, inSight]
2623
3191
  );
2624
3192
  useEffect(() => {
2625
3193
  if (phase.name !== "pending") return;
@@ -2736,6 +3304,7 @@ function AvatarLiveView({
2736
3304
  wait?.check?.abort();
2737
3305
  const handle = sessionRef.current;
2738
3306
  sessionRef.current = null;
3307
+ if (audioRef.current !== null) audioRef.current.muted = false;
2739
3308
  if (handle && !handle.done) {
2740
3309
  handle.done = true;
2741
3310
  stopAttending(handle);
@@ -2752,7 +3321,8 @@ function AvatarLiveView({
2752
3321
  []
2753
3322
  );
2754
3323
  const any = questions === "any";
2755
- const textMode = any && (usedToday && (phase.name === "needs-answer" || phase.name === "consent" || phase.name === "declined" || phase.name === "ended") || phase.name === "refused" && phase.reason === "limits");
3324
+ const usedHere = usedToday && !voiceOffered;
3325
+ const textMode = any && (usedHere && (phase.name === "needs-answer" || phase.name === "consent" || phase.name === "declined" || phase.name === "ended") || phase.name === "refused" && phase.reason === "limits");
2756
3326
  textBoxRef.current = textMode;
2757
3327
  const askState = !any ? void 0 : textMode ? "text" : phase.name === "needs-answer" ? kind === "empty" ? "ready" : kind : phase.name;
2758
3328
  const followUp = phase.name === "live" ? phase.session.followUp : null;
@@ -2765,16 +3335,18 @@ function AvatarLiveView({
2765
3335
  const refreshMicOffer = useCallback(async () => {
2766
3336
  const { live: live2, locale: locale2, base: base2, headers: headers2 } = latest.current;
2767
3337
  if (base2 === null) return null;
2768
- const next = micOfferOf(
3338
+ const next = micChoiceOf(
2769
3339
  await requestAvailabilityAnswer({
2770
3340
  base: base2,
2771
3341
  headers: headers2,
2772
3342
  site: live2.site,
2773
3343
  locale: locale2
2774
- })
3344
+ }),
3345
+ locale2
2775
3346
  );
2776
- if (next !== null) setMicOffer(next);
2777
- return next;
3347
+ if (next.kind !== "stream") return null;
3348
+ setMicChoice(next);
3349
+ return next.offer;
2778
3350
  }, []);
2779
3351
  const onMicText = useCallback(
2780
3352
  (text) => {
@@ -2793,15 +3365,153 @@ function AvatarLiveView({
2793
3365
  return;
2794
3366
  }
2795
3367
  const run = handle.followUps;
3368
+ const voiced = micControlRef.current?.mode === "conversation";
2796
3369
  tell(handle, "interrupt");
3370
+ if (voiced) {
3371
+ run.muted = true;
3372
+ run.mutedUnheard = run.owed || run.owedWait;
3373
+ if (audioRef.current !== null) audioRef.current.muted = true;
3374
+ }
2797
3375
  if (run.quiet !== null) clearTimeout(run.quiet);
2798
3376
  run.quiet = null;
2799
3377
  run.owed = false;
2800
3378
  handle.playedOut = true;
2801
3379
  showFollowUp(handle, { speaking: false });
2802
- }, [showFollowUp, tell]);
3380
+ if (voiced && !run.voice) whenQuiet(handle, QUIET_PLAYED_OUT_MS);
3381
+ }, [showFollowUp, tell, whenQuiet]);
3382
+ const firstArmable = useCallback(() => {
3383
+ const current = phaseRef.current;
3384
+ return sessionRef.current === null && !textBoxRef.current && current.name === "needs-answer" && current.offer.copy.notice === "none" && exchangeNow().kind === "empty";
3385
+ }, [exchangeNow]);
3386
+ const armFirst = useCallback(
3387
+ (stage, askSeq) => {
3388
+ const current = phaseRef.current;
3389
+ if (current.name !== "needs-answer") return null;
3390
+ const { live: live2, locale: locale2 } = latest.current;
3391
+ unlockSound(audioRef.current);
3392
+ const module = loadModule(live2.load);
3393
+ module.catch(() => void 0);
3394
+ return {
3395
+ stage,
3396
+ consentVersion: current.offer.consentVersion,
3397
+ botCheck: current.offer.botCheck,
3398
+ site: live2.site,
3399
+ locale: locale2,
3400
+ path: pathNow(),
3401
+ module,
3402
+ askSeq
3403
+ };
3404
+ },
3405
+ [pathNow]
3406
+ );
3407
+ const onMicPress = useCallback(
3408
+ (gesture) => {
3409
+ const run = voiceRef.current;
3410
+ if (run.on && run.note === null && !gesture) return;
3411
+ const arm = gesture && firstArmable() ? armFirst("pressing", null) : null;
3412
+ voiceSet({
3413
+ on: true,
3414
+ note: null,
3415
+ ...gesture ? { echoes: 0, arm } : {}
3416
+ });
3417
+ },
3418
+ [armFirst, firstArmable, voiceSet]
3419
+ );
3420
+ const echoOf = useCallback(() => {
3421
+ const current = phaseRef.current;
3422
+ return current.name === "live" && current.session.followUp !== null && sessionRef.current?.followUps ? current.session.captions.text : null;
3423
+ }, []);
3424
+ const askRef = useRef(() => null);
3425
+ const askReasonRef = useRef(null);
3426
+ const draftRef = useRef("");
3427
+ draftRef.current = draft;
3428
+ askMaxLengthRef.current = askMaxLength;
3429
+ const autoSend = useCallback(
3430
+ (text) => {
3431
+ const question = text.trim();
3432
+ const refuse = () => {
3433
+ onMicText(text);
3434
+ voiceOff(lineNote("inBox"), false);
3435
+ };
3436
+ if (question === "" || askReasonRef.current !== null || sendingRef.current || question.length > askMaxLengthRef.current || draftRef.current.trim() !== "") {
3437
+ refuse();
3438
+ return;
3439
+ }
3440
+ const sending = askRef.current(question);
3441
+ if (sending === null) {
3442
+ refuse();
3443
+ return;
3444
+ }
3445
+ const arm = voiceRef.current.arm;
3446
+ voiceSet({
3447
+ echoes: 0,
3448
+ note: lineNote("sending"),
3449
+ ...arm === null ? {} : { arm: { ...arm, stage: "asked", askSeq: askSeqRef.current } }
3450
+ });
3451
+ void sending.then(
3452
+ (asked) => {
3453
+ if (!asked) {
3454
+ voiceOff(lineNote("off"), false);
3455
+ return;
3456
+ }
3457
+ const run = voiceRef.current;
3458
+ if (run.on && sameNote(run.note, lineNote("sending"))) {
3459
+ voiceSet({ note: lineNote("again") });
3460
+ }
3461
+ },
3462
+ () => voiceOff(lineNote("off"), false)
3463
+ );
3464
+ },
3465
+ [onMicText, voiceOff, voiceSet]
3466
+ );
3467
+ const onMicTurn = useCallback(
3468
+ (turn) => {
3469
+ const run = voiceRef.current;
3470
+ if (!run.on) return;
3471
+ if (turn.by === "pause" && turn.echo) {
3472
+ if (run.echoes >= MAX_ECHO_DISCARDS || run.arm !== null) {
3473
+ voiceOff(lineNote("off"), false);
3474
+ } else {
3475
+ voiceSet({ echoes: run.echoes + 1, note: null });
3476
+ }
3477
+ return;
3478
+ }
3479
+ if (turn.by === "pause") {
3480
+ autoSend(turn.text);
3481
+ return;
3482
+ }
3483
+ if (turn.by === "silence") {
3484
+ voiceOff(lineNote("noQuestion"), false);
3485
+ return;
3486
+ }
3487
+ if (turn.text.trim() !== "") {
3488
+ onMicText(turn.text);
3489
+ voiceOff(lineNote(turn.by === "limit" ? "atLimit" : "inBox"), false);
3490
+ return;
3491
+ }
3492
+ if (turn.by === "failed") {
3493
+ voiceOff(
3494
+ turn.gesture ? { kind: "failed", reason: turn.failure ?? "failed" } : lineNote("tapNext"),
3495
+ false
3496
+ );
3497
+ return;
3498
+ }
3499
+ voiceOff(lineNote("off"), false);
3500
+ },
3501
+ [autoSend, onMicText, voiceOff, voiceSet]
3502
+ );
3503
+ const micSource = micChoice.kind === "browser" ? {
3504
+ kind: "browser",
3505
+ notice: micChoice.notice,
3506
+ ...micChoice.pauseMs === void 0 ? {} : { pauseMs: micChoice.pauseMs }
3507
+ } : micChoice.kind === "stream" && base !== null ? {
3508
+ kind: "stream",
3509
+ base,
3510
+ offer: micChoice.offer,
3511
+ refreshOffer: refreshMicOffer
3512
+ } : null;
2803
3513
  const mic = useAvatarMic({
2804
- enabled: live.mic === true && micOffer !== null && !off && acceptsQuestion({
3514
+ enabled: live.mic === true && micSource !== null && !off && acceptsQuestion({
2805
3515
  any,
2806
3516
  phase: phase.name,
2807
3517
  kind,
@@ -2810,28 +3520,41 @@ function AvatarLiveView({
2810
3520
  followUp: followUpAsks
2811
3521
  }),
2812
3522
  locale,
2813
- base,
2814
- offer: micOffer,
2815
- refreshOffer: refreshMicOffer,
3523
+ source: micSource,
2816
3524
  onText: onMicText,
2817
- onStart: onMicStart
3525
+ onStart: onMicStart,
3526
+ onTurn: onMicTurn,
3527
+ onPress: onMicPress,
3528
+ echoOf,
3529
+ interruptible: followUp?.speaking === true,
3530
+ note: voiceRef.current.note
2818
3531
  });
2819
3532
  micCancelRef.current = mic.cancel;
2820
- const micRecording = mic.recording;
3533
+ micControlRef.current = mic;
3534
+ askReasonRef.current = askReason;
3535
+ const micRecording = mic.mode === "conversation" ? mic.speaking : mic.recording;
2821
3536
  useEffect(() => {
2822
3537
  const handle = sessionRef.current;
2823
3538
  if (!micRecording || handle === null || handle.followUps === null || handle.done) {
2824
3539
  return;
2825
3540
  }
2826
- tell(handle, "listening");
3541
+ const run = handle.followUps;
2827
3542
  const every = Math.min(
2828
3543
  LISTENING_MARK_MS,
2829
- Math.max(1e3, handle.followUps.limits.idleSeconds * 1e3 / 3)
3544
+ Math.max(1e3, run.limits.idleSeconds * 1e3 / 3)
2830
3545
  );
2831
- const timer = setInterval(() => tell(handle, "listening"), every);
3546
+ const spacing = Math.min(LISTENING_MARK_SPACING_MS, every / 2);
3547
+ const mark = () => {
3548
+ const at = Date.now();
3549
+ if (run.listenedAt !== null && at - run.listenedAt < spacing) return;
3550
+ run.listenedAt = at;
3551
+ tell(handle, "listening");
3552
+ };
3553
+ mark();
3554
+ const timer = setInterval(mark, every);
2832
3555
  return () => {
2833
3556
  clearInterval(timer);
2834
- if (sessionRef.current === handle) tell(handle, "listening");
3557
+ if (sessionRef.current === handle) mark();
2835
3558
  };
2836
3559
  }, [micRecording, tell]);
2837
3560
  const notNow = useCallback(() => {
@@ -2847,7 +3570,7 @@ function AvatarLiveView({
2847
3570
  const run = handle.followUps;
2848
3571
  const current = phaseRef.current;
2849
3572
  if (run === null || handle.done || current.name !== "live" || current.session.followUp?.pending !== null || !followUpBox(current, run, conversation2.getConversationToken())) {
2850
- return;
3573
+ return null;
2851
3574
  }
2852
3575
  sendingRef.current = true;
2853
3576
  run.seq += 1;
@@ -2855,17 +3578,17 @@ function AvatarLiveView({
2855
3578
  setDraft("");
2856
3579
  tell(handle, "asking");
2857
3580
  showFollowUp(handle, { pending: "asking", unread: null });
2858
- void conversation2.send(question, [], { surface: "AVATAR" }).then((added) => {
3581
+ return conversation2.send(question, [], { surface: "AVATAR" }).then((added) => {
2859
3582
  if (added === null) {
2860
3583
  setDraft((current2) => current2 === "" ? question : current2);
2861
3584
  }
2862
3585
  if (sessionRef.current !== handle || handle.done || run.seq !== seq) {
2863
- return;
3586
+ return false;
2864
3587
  }
2865
3588
  if (added === null || added.conversationToken !== run.token) {
2866
3589
  showFollowUp(handle, { pending: null });
2867
3590
  attend();
2868
- return;
3591
+ return false;
2869
3592
  }
2870
3593
  run.asked = {
2871
3594
  messageId: added.userMessageId,
@@ -2873,6 +3596,7 @@ function AvatarLiveView({
2873
3596
  answerId: null
2874
3597
  };
2875
3598
  showFollowUp(handle, { pending: "asking" });
3599
+ return true;
2876
3600
  }).finally(() => {
2877
3601
  sendingRef.current = false;
2878
3602
  });
@@ -2884,37 +3608,135 @@ function AvatarLiveView({
2884
3608
  const conversation2 = conversationRef.current;
2885
3609
  const question = text.trim();
2886
3610
  if (conversation2 === null || question === "" || sendingRef.current) {
2887
- return;
3611
+ return null;
2888
3612
  }
2889
3613
  const spoken = !textBoxRef.current;
2890
3614
  const handle = sessionRef.current;
2891
3615
  if (spoken && handle !== null && handle.followUps !== null) {
2892
- askFollowUp(handle, conversation2, question);
2893
- return;
3616
+ return askFollowUp(handle, conversation2, question);
2894
3617
  }
2895
- if (spoken && conversation2.messages.length > 0) return;
3618
+ if (spoken && conversation2.messages.length > 0) return null;
2896
3619
  sendingRef.current = true;
2897
3620
  setAskedHere(null);
2898
3621
  const seq = askSeqRef.current;
2899
3622
  setNote(null);
2900
3623
  setDraft("");
2901
- void conversation2.send(question, [], spoken ? { surface: "AVATAR" } : {}).then((added) => {
3624
+ return conversation2.send(question, [], spoken ? { surface: "AVATAR" } : {}).then((added) => {
2902
3625
  if (added === null) {
2903
3626
  setDraft((current) => current === "" ? question : current);
2904
3627
  }
2905
- if (!spoken || added === null || askSeqRef.current !== seq || added.conversationToken !== (conversationRef.current?.getConversationToken() ?? null)) {
2906
- return;
3628
+ if (added === null || spoken && (askSeqRef.current !== seq || added.conversationToken !== (conversationRef.current?.getConversationToken() ?? null))) {
3629
+ return false;
2907
3630
  }
3631
+ if (!spoken) return true;
2908
3632
  setAskedHere({
2909
3633
  messageId: added.userMessageId,
2910
3634
  token: added.conversationToken
2911
3635
  });
3636
+ return true;
2912
3637
  }).finally(() => {
2913
3638
  sendingRef.current = false;
2914
3639
  });
2915
3640
  },
2916
3641
  [askFollowUp, setAskedHere]
2917
3642
  );
3643
+ askRef.current = ask;
3644
+ const askTyped = useCallback(
3645
+ (text) => {
3646
+ voiceOff(null, false);
3647
+ const armable = firstArmable();
3648
+ const sending = ask(text);
3649
+ if (sending !== null && armable) {
3650
+ voiceSet({ arm: armFirst("asked", askSeqRef.current) });
3651
+ }
3652
+ return sending;
3653
+ },
3654
+ [armFirst, ask, firstArmable, voiceOff, voiceSet]
3655
+ );
3656
+ const onDraftChange = useCallback(
3657
+ (value) => {
3658
+ setDraft(value);
3659
+ const run = voiceRef.current;
3660
+ if (run.on || run.arm !== null) voiceOff(lineNote("off"), true);
3661
+ },
3662
+ [voiceOff]
3663
+ );
3664
+ const armVoiceStart = useCallback(() => {
3665
+ const arm = voiceRef.current.arm;
3666
+ if (arm === null || arm.stage !== "asked") return;
3667
+ const current = phaseRef.current;
3668
+ const exchange2 = exchangeNow();
3669
+ const drop = () => voiceSet({ arm: null });
3670
+ if (arm.askSeq !== askSeqRef.current || current.name !== "needs-answer" && current.name !== "consent") {
3671
+ drop();
3672
+ return;
3673
+ }
3674
+ if (sendingRef.current || exchange2.kind === "asking" && (!exchange2.askedHere || exchange2.pendingAnswerId === null)) {
3675
+ return;
3676
+ }
3677
+ const answerId2 = exchange2.kind === "asking" ? exchange2.pendingAnswerId : exchange2.kind === "answer" ? exchange2.answerId : null;
3678
+ const token2 = conversationRef.current?.getConversationToken() ?? null;
3679
+ const { live: live2, locale: locale2 } = latest.current;
3680
+ const path = pathNow();
3681
+ if (answerId2 === null || !exchange2.askedHere || exchange2.question === null || token2 === null || !isAvatarConversationToken(token2) || arm.consentVersion !== current.offer.consentVersion || arm.botCheck !== current.offer.botCheck || arm.site !== live2.site || arm.locale !== locale2 || arm.path !== path) {
3682
+ drop();
3683
+ return;
3684
+ }
3685
+ voiceSet({ arm: null });
3686
+ waitRef.current = {
3687
+ bound: {
3688
+ token: token2,
3689
+ questionId: exchange2.question.id,
3690
+ answerId: answerId2,
3691
+ site: live2.site,
3692
+ locale: locale2,
3693
+ path,
3694
+ consentVersion: arm.consentVersion,
3695
+ botCheck: arm.botCheck
3696
+ },
3697
+ module: arm.module,
3698
+ check: null
3699
+ };
3700
+ setNote(null);
3701
+ go({ name: "waiting", offer: current.offer });
3702
+ }, [exchangeNow, go, pathNow, voiceSet]);
3703
+ useEffect(() => {
3704
+ armVoiceStart();
3705
+ });
3706
+ useEffect(() => {
3707
+ const run = voiceRef.current;
3708
+ if (!run.on && run.arm === null) return;
3709
+ const current = phaseRef.current;
3710
+ const live2 = current.name === "live" ? current.session.followUp : null;
3711
+ const keeps = (mic.mode === "conversation" || !run.on) && !textMode && (current.name === "needs-answer" && (sendingRef.current || kind === "empty" || kind === "asking" || kind === "answer") || current.name === "consent" || current.name === "waiting" || current.name === "bot-check" || current.name === "creating" || current.name === "pending" || current.name === "live" && followUpAsks && live2 !== null && !live2.closed && (live2.unread === null || !SESSION_REFUSALS.has(live2.unread)));
3712
+ if (!keeps) voiceOff(lineNote("off"), true);
3713
+ });
3714
+ useEffect(() => {
3715
+ const run = voiceRef.current;
3716
+ const controls = micControlRef.current;
3717
+ const handle = sessionRef.current;
3718
+ const followUps = handle?.followUps ?? null;
3719
+ const current = phaseRef.current;
3720
+ if (!run.on || controls === null || controls.mode !== "conversation" || handle === null || followUps === null || handle.done || current.name !== "live") {
3721
+ return;
3722
+ }
3723
+ const shownFollowUp = current.session.followUp;
3724
+ if (shownFollowUp === null || shownFollowUp.speaking || shownFollowUp.pending !== null || shownFollowUp.closed || shownFollowUp.unread !== null && SESSION_REFUSALS.has(shownFollowUp.unread) || !followUpBox(
3725
+ current,
3726
+ followUps,
3727
+ conversationRef.current?.getConversationToken() ?? null
3728
+ )) {
3729
+ return;
3730
+ }
3731
+ if (sendingRef.current || followUps.asked !== null || followUps.due !== null || !playedOutNow(handle) || blockedRef.current || !inSight() || controls.pressing()) {
3732
+ return;
3733
+ }
3734
+ if (handle.endsAt - Date.now() < LISTEN_AGAIN_MIN_LEFT_MS) {
3735
+ voiceOff(lineNote("off"), false);
3736
+ return;
3737
+ }
3738
+ controls.listen();
3739
+ });
2918
3740
  useEffect(() => {
2919
3741
  const handle = sessionRef.current;
2920
3742
  const run = handle?.followUps ?? null;
@@ -2989,6 +3811,8 @@ function AvatarLiveView({
2989
3811
  check();
2990
3812
  }
2991
3813
  }, [check, exchangeNow, go, setAskedHere]);
3814
+ const voiceTalk = mic.mode === "conversation" && mic.button !== null && voiceWords !== null;
3815
+ const voiceArming = voiceRef.current.arm?.stage === "asked";
2992
3816
  useEffect(() => {
2993
3817
  if (!active) return;
2994
3818
  const focused = document.activeElement;
@@ -3004,8 +3828,12 @@ function AvatarLiveView({
3004
3828
  ]);
3005
3829
  const onPlaying = goLive;
3006
3830
  const enablePlayback = useCallback(() => {
3007
- void sessionRef.current?.joined?.enablePlayback().catch(() => void 0);
3008
- }, []);
3831
+ const room = sessionRef.current?.joined ?? null;
3832
+ if (room === null) return;
3833
+ const resumed = room.enablePlayback();
3834
+ holdMute();
3835
+ void resumed.catch(() => void 0).then(holdMute);
3836
+ }, [holdMute]);
3009
3837
  if (off || phase.name === "inert") {
3010
3838
  return (
3011
3839
  // `display: contents`: the earlier Avatar view's picture, line and button lay out in the pane exactly as before.
@@ -3124,7 +3952,7 @@ function AvatarLiveView({
3124
3952
  ]
3125
3953
  }
3126
3954
  );
3127
- const consentActions = (whenReady) => /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2", children: [
3955
+ const consentActions = (whenReady, label = whenReady ? S.startWhenReady : S.start) => /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2", children: [
3128
3956
  /* @__PURE__ */ jsx(
3129
3957
  "button",
3130
3958
  {
@@ -3133,7 +3961,7 @@ function AvatarLiveView({
3133
3961
  "data-boff-explore": whenReady ? "avatar-live-start-when-ready" : "avatar-live-start",
3134
3962
  className: primaryButton,
3135
3963
  onClick: whenReady ? startWhenReady : start,
3136
- children: whenReady ? S.startWhenReady : S.start
3964
+ children: label
3137
3965
  }
3138
3966
  ),
3139
3967
  /* @__PURE__ */ jsx(
@@ -3177,7 +4005,14 @@ function AvatarLiveView({
3177
4005
  className: "absolute inset-0 h-full w-full object-cover"
3178
4006
  }
3179
4007
  ),
3180
- any ? null : /* @__PURE__ */ jsx("audio", { ref: audioRef, "data-boff-explore": "avatar-live-audio" })
4008
+ any ? null : /* @__PURE__ */ jsx(
4009
+ "audio",
4010
+ {
4011
+ ref: audioRef,
4012
+ "data-boff-explore": "avatar-live-audio",
4013
+ onVolumeChange: holdMute
4014
+ }
4015
+ )
3181
4016
  ]
3182
4017
  }
3183
4018
  );
@@ -3310,20 +4145,24 @@ function AvatarLiveView({
3310
4145
  switch (shown.kind) {
3311
4146
  case "empty":
3312
4147
  status = note === "new-chat" ? S.newChatStarted : "";
3313
- lead = /* @__PURE__ */ jsx("p", { className: "text-base font-semibold", children: S.askHeadline });
4148
+ lead = /* @__PURE__ */ jsx("p", { className: "text-base font-semibold", children: voiceTalk && voiceWords !== null ? voiceWords.headline : S.askHeadline });
3314
4149
  box = "spoken";
3315
4150
  break;
3316
4151
  case "asking":
3317
4152
  case "answer": {
3318
- const card = shown.askedHere && shown.pendingAnswerId !== null;
3319
- status = note === "cancelled" ? S.armedCancelled : S.gettingAnswer;
3320
- if (card) media2 = stillPicture(true);
4153
+ const card = shown.askedHere && shown.pendingAnswerId !== null && !voiceArming;
4154
+ const words = phase.offer.copy.notice === "none" ? null : phase.offer.copy;
4155
+ status = note === "cancelled" ? words === null && voiceWords !== null ? voiceWords.cancelled : S.armedCancelled : S.gettingAnswer;
4156
+ if (card && words !== null) media2 = stillPicture(true);
3321
4157
  body2 = /* @__PURE__ */ jsxs(Fragment, { children: [
3322
4158
  questionLine,
3323
4159
  activityLine,
3324
- card ? consentCard(phase.offer.copy) : null
4160
+ card && words !== null ? consentCard(words) : null
3325
4161
  ] });
3326
- actions2 = card ? consentActions(true) : null;
4162
+ actions2 = card ? consentActions(
4163
+ true,
4164
+ words === null ? voiceWords?.start ?? S.start : S.startWhenReady
4165
+ ) : null;
3327
4166
  break;
3328
4167
  }
3329
4168
  case "text-only":
@@ -3355,6 +4194,16 @@ function AvatarLiveView({
3355
4194
  }
3356
4195
  break;
3357
4196
  case "consent":
4197
+ if (voiceArming) {
4198
+ status = S.armed;
4199
+ body2 = questionLine;
4200
+ break;
4201
+ }
4202
+ if (phase.offer.copy.notice === "none") {
4203
+ body2 = questionLine;
4204
+ actions2 = consentActions(false, voiceWords?.start ?? S.start);
4205
+ break;
4206
+ }
3358
4207
  media2 = stillPicture(true);
3359
4208
  body2 = /* @__PURE__ */ jsxs(Fragment, { children: [
3360
4209
  questionLine,
@@ -3446,7 +4295,7 @@ function AvatarLiveView({
3446
4295
  "aria-disabled": reason !== null ? "true" : void 0,
3447
4296
  className: "min-h-11 rounded-full border border-border bg-background px-3 py-2 text-start text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-disabled:cursor-not-allowed aria-disabled:opacity-50 motion-reduce:transition-none",
3448
4297
  onClick: () => {
3449
- if (reason === null) ask(prompt);
4298
+ if (reason === null) void askTyped(prompt);
3450
4299
  },
3451
4300
  children: prompt
3452
4301
  },
@@ -3474,7 +4323,7 @@ function AvatarLiveView({
3474
4323
  /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: S.newChatNote })
3475
4324
  ] }) : null,
3476
4325
  chips,
3477
- box !== null && usedToday && phase.name !== "live" ? /* @__PURE__ */ jsx(
4326
+ box !== null && usedHere && phase.name !== "live" ? /* @__PURE__ */ jsx(
3478
4327
  "p",
3479
4328
  {
3480
4329
  "data-boff-explore": "avatar-live-used-today",
@@ -3488,13 +4337,16 @@ function AvatarLiveView({
3488
4337
  {
3489
4338
  value: draft,
3490
4339
  maxLength: askMaxLength,
3491
- onChange: setDraft,
3492
- onAsk: ask,
4340
+ onChange: onDraftChange,
4341
+ onAsk: askTyped,
3493
4342
  reason,
3494
4343
  label: S.askLabel,
3495
- placeholder: S.askPlaceholder,
4344
+ placeholder: box === "spoken" && voiceTalk && voiceWords !== null ? (
4345
+ // None while the mic listens: its line says so, and the box is at its narrowest then.
4346
+ mic.busy ? "" : voiceWords.placeholder
4347
+ ) : S.askPlaceholder,
3496
4348
  send,
3497
- footer: S.askFooter,
4349
+ footer: noNotice ? null : S.askFooter,
3498
4350
  noFiles: S.askNoFiles,
3499
4351
  hint: box === "spoken" ? S.needsAnswerAny : null,
3500
4352
  touchFirst,
@@ -3532,7 +4384,14 @@ function AvatarLiveView({
3532
4384
  actions2
3533
4385
  ] }),
3534
4386
  foot,
3535
- /* @__PURE__ */ jsx("audio", { ref: audioRef, "data-boff-explore": "avatar-live-audio" })
4387
+ /* @__PURE__ */ jsx(
4388
+ "audio",
4389
+ {
4390
+ ref: audioRef,
4391
+ "data-boff-explore": "avatar-live-audio",
4392
+ onVolumeChange: holdMute
4393
+ }
4394
+ )
3536
4395
  ]
3537
4396
  }
3538
4397
  );
@@ -3564,6 +4423,7 @@ function AvatarLiveView({
3564
4423
  actions = startOver ? startOverActions : continueButton;
3565
4424
  break;
3566
4425
  case "consent":
4426
+ if (phase.offer.copy.notice === "none") break;
3567
4427
  media = stillPicture(true);
3568
4428
  body = consentCard(phase.offer.copy);
3569
4429
  actions = consentActions(false);
@@ -3656,6 +4516,7 @@ function AskBox({
3656
4516
  const footerId = `${uid}-footer`;
3657
4517
  const [refusedFiles, setRefusedFiles] = useState(false);
3658
4518
  const empty = value.trim() === "";
4519
+ const describedBy = [hint === null ? null : hintId, footer === null ? null : footerId].filter((id) => id !== null).join(" ") || void 0;
3659
4520
  const submit = () => {
3660
4521
  if (reason !== null || empty) return;
3661
4522
  onAsk(value);
@@ -3698,12 +4559,17 @@ function AskBox({
3698
4559
  rows: 1,
3699
4560
  maxLength,
3700
4561
  "aria-label": label,
3701
- "aria-describedby": hint === null ? footerId : `${hintId} ${footerId}`,
4562
+ "aria-describedby": describedBy,
3702
4563
  placeholder,
3703
4564
  dir: "auto",
3704
4565
  "data-avatar-primary": touchFirst ? void 0 : "",
3705
4566
  "data-boff-explore": "avatar-live-ask-input",
3706
- className: "max-h-32 min-h-11 flex-1 resize-none rounded-md border border-border bg-background px-3 py-2.5 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:text-sm",
4567
+ className: cn(
4568
+ // Kept out of a site's session replay before Send (rrweb's default classes; `rr-ignore` for its input
4569
+ // events): the text may be what the mic heard, or what the visitor typed.
4570
+ REPLAY_PRIVATE,
4571
+ "rr-ignore max-h-32 min-h-11 flex-1 resize-none rounded-md border border-border bg-background px-3 py-2.5 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:text-sm"
4572
+ ),
3707
4573
  onChange: (event) => {
3708
4574
  setRefusedFiles(false);
3709
4575
  onChange(event.target.value);
@@ -3746,7 +4612,7 @@ function AskBox({
3746
4612
  }
3747
4613
  ) : null,
3748
4614
  hint === null ? null : /* @__PURE__ */ jsx("p", { id: hintId, className: "sr-only", children: hint }),
3749
- /* @__PURE__ */ jsx("p", { id: footerId, className: "text-xs text-muted-foreground", children: footer })
4615
+ footer === null ? null : /* @__PURE__ */ jsx("p", { id: footerId, className: "text-xs text-muted-foreground", children: footer })
3750
4616
  ]
3751
4617
  }
3752
4618
  );