@burdenoff/website-sdk 2026.1105.2 → 2026.1105.3

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-JGU6JW4N.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,17 @@ function readSttOffer(value) {
69
69
  }
70
70
  return { token, expiresAt };
71
71
  }
72
+ var MIC_MAX_PRESS_SECONDS = 30;
73
+ var UNREADABLE_MIC = { mode: "unreadable" };
74
+ function readMicOffer(value) {
75
+ if (!isRecord(value)) return UNREADABLE_MIC;
76
+ const keys = Object.keys(value).sort();
77
+ const { mode, maxPressSeconds } = value;
78
+ if (keys.length !== 2 || keys[0] !== "maxPressSeconds" || keys[1] !== "mode" || mode !== "browser" || maxPressSeconds !== MIC_MAX_PRESS_SECONDS) {
79
+ return UNREADABLE_MIC;
80
+ }
81
+ return { mode: "browser", maxPressSeconds: MIC_MAX_PRESS_SECONDS };
82
+ }
72
83
  function readAvailability(status, body) {
73
84
  if (status !== 200 || !isRecord(body)) return INERT;
74
85
  const { available, reason } = body;
@@ -83,6 +94,7 @@ function readAvailability(status, body) {
83
94
  return INERT;
84
95
  }
85
96
  const stt = readSttOffer(body.stt);
97
+ const mic = Object.prototype.hasOwnProperty.call(body, "mic") ? readMicOffer(body.mic) : void 0;
86
98
  return {
87
99
  kind: "available",
88
100
  offer: {
@@ -90,7 +102,8 @@ function readAvailability(status, body) {
90
102
  maxSessionSeconds,
91
103
  botCheck,
92
104
  questions,
93
- ...stt === void 0 ? {} : { stt }
105
+ ...stt === void 0 ? {} : { stt },
106
+ ...mic === void 0 ? {} : { mic }
94
107
  }
95
108
  };
96
109
  }
@@ -845,6 +858,7 @@ var STRINGS = {
845
858
  function avatarLiveStrings(locale) {
846
859
  return STRINGS[locale] ?? EN;
847
860
  }
861
+ var BROWSER_MIC_NOTICE_VERSION = "web-mic-2026-11-21-c";
848
862
  var CONSENT = /* @__PURE__ */ new Map([
849
863
  [
850
864
  "web-consent-2026-11",
@@ -1022,6 +1036,51 @@ var CONSENT = /* @__PURE__ */ new Map([
1022
1036
  followUps: true
1023
1037
  }
1024
1038
  }
1039
+ ],
1040
+ [
1041
+ // Stage C (BOFF-9797, the operator's decision of 2026-11-05): the mic through the browser's own speech recognition.
1042
+ // A DRAFT for the operator's privacy review that no environment uses yet (global-speech-svc reserves the same two
1043
+ // versions, and pairs them with its AVATAR_WEB_MIC=browser): web-consent-2026-11-19-d with three changes only. The
1044
+ // intro warns against personal details said into the microphone and says a tap on it stops the answer being read;
1045
+ // the processing says which speech service turns the voice into text (the browser's own, under its own terms, with
1046
+ // the vendors as examples) and that a live session is told of a tap and of a recording, never of the words; the
1047
+ // storage says the camera is not used and that the voice never reaches us, nor the text until Send, in place of
1048
+ // "Your microphone and camera are not used.", which is true only with no mic. The notice files of record and their
1049
+ // digests are a gitops change of their own, made with the review.
1050
+ "web-consent-2026-11-21-c",
1051
+ {
1052
+ en: {
1053
+ title: "Live AI avatar (beta)",
1054
+ introProduct: null,
1055
+ 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.",
1056
+ 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.",
1057
+ 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.",
1058
+ privacyLink: "How we handle this",
1059
+ botCheck: "turnstile",
1060
+ questions: "any",
1061
+ followUps: true,
1062
+ micNotice: BROWSER_MIC_NOTICE_VERSION
1063
+ }
1064
+ }
1065
+ ],
1066
+ [
1067
+ // The words of web-consent-2026-11-21-c without "When you start, Cloudflare checks for bots.", for a server that runs
1068
+ // no bot check. A DRAFT, like it.
1069
+ "web-consent-2026-11-22-c",
1070
+ {
1071
+ en: {
1072
+ title: "Live AI avatar (beta)",
1073
+ introProduct: null,
1074
+ 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.",
1075
+ 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.",
1076
+ 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.",
1077
+ privacyLink: "How we handle this",
1078
+ botCheck: "off",
1079
+ questions: "any",
1080
+ followUps: true,
1081
+ micNotice: BROWSER_MIC_NOTICE_VERSION
1082
+ }
1083
+ }
1025
1084
  ]
1026
1085
  ]);
1027
1086
  function avatarConsentCopy(version, locale) {
@@ -1066,6 +1125,22 @@ var MIC_NOTICE = /* @__PURE__ */ new Map([
1066
1125
  privacyLink: "How we handle this"
1067
1126
  }
1068
1127
  }
1128
+ ],
1129
+ [
1130
+ // The browser's own speech recognition (Stage C): the browser's speech service, named by example where each is
1131
+ // true, under its own terms; nothing of the voice reaches us, nor the text before Send; a live session told only of
1132
+ // a tap and of a recording. No daily limits: nothing is counted on the server.
1133
+ BROWSER_MIC_NOTICE_VERSION,
1134
+ {
1135
+ en: {
1136
+ title: "Ask with your voice",
1137
+ 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.",
1138
+ storage: "We never receive your voice, and your camera is not used.",
1139
+ 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.",
1140
+ limits: "Each recording stops after thirty seconds at the latest.",
1141
+ privacyLink: "How we handle this"
1142
+ }
1143
+ }
1069
1144
  ]
1070
1145
  ]);
1071
1146
  function avatarMicNotice(version, locale) {
@@ -1076,10 +1151,12 @@ var PRIVACY_HREF = "/privacy#live-avatar";
1076
1151
  var LIMIT_SECONDS = 30;
1077
1152
  var TICK_MS = 250;
1078
1153
  var IDLE = { name: "idle" };
1079
- var FOR_GOOD = /* @__PURE__ */ new Set([
1080
- "unavailable",
1081
- "limit"
1082
- ]);
1154
+ var EN_IN = { lang: "en-IN", fallbackLang: "en-US" };
1155
+ var BROWSER_MIC_LANGUAGES = { en: EN_IN };
1156
+ var FOR_GOOD = {
1157
+ stream: /* @__PURE__ */ new Set(["unavailable", "limit"]),
1158
+ browser: /* @__PURE__ */ new Set(["unsupported"])
1159
+ };
1083
1160
  var FAILURE_TEXT = {
1084
1161
  denied: "micDenied",
1085
1162
  unsupported: "micUnsupported",
@@ -1088,14 +1165,32 @@ var FAILURE_TEXT = {
1088
1165
  busy: "micBusy",
1089
1166
  failed: "micFailed"
1090
1167
  };
1168
+ var loadedMic = null;
1091
1169
  function loadAvatarMic() {
1092
- return import('../avatar-mic/index.mjs').then((loaded) => ({
1093
- createAvatarMic: loaded.createAvatarMic
1094
- }));
1170
+ return import('../avatar-mic/index.mjs').then((loaded) => {
1171
+ const module = {
1172
+ createAvatarMic: loaded.createAvatarMic,
1173
+ createBrowserAvatarMic: loaded.createBrowserAvatarMic
1174
+ };
1175
+ loadedMic = module;
1176
+ return module;
1177
+ });
1095
1178
  }
1096
1179
  function canUseAvatarMic() {
1097
1180
  return typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function" && typeof AudioContext !== "undefined" && typeof AudioWorkletNode !== "undefined" && typeof WebSocket !== "undefined";
1098
1181
  }
1182
+ function canUseBrowserMic() {
1183
+ if (typeof window === "undefined" || typeof navigator === "undefined") {
1184
+ return false;
1185
+ }
1186
+ if (window.isSecureContext !== true) return false;
1187
+ const recognizer = Reflect.get(window, "SpeechRecognition") ?? Reflect.get(window, "webkitSpeechRecognition");
1188
+ if (typeof recognizer !== "function") return false;
1189
+ const agent = navigator.userAgent ?? "";
1190
+ if (/\bFirefox\//.test(agent) || /\bFxiOS\//.test(agent)) return false;
1191
+ return !("brave" in navigator);
1192
+ }
1193
+ var REPLAY_PRIVATE = "rr-block rr-mask";
1099
1194
  function clock(seconds) {
1100
1195
  const whole = Math.max(0, Math.floor(seconds));
1101
1196
  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
@@ -1106,37 +1201,51 @@ function withHeardText(current, heard, maxLength) {
1106
1201
  const joined = current.trim() === "" ? text : `${current.replace(/\s+$/, "")} ${text}`;
1107
1202
  return joined.slice(0, Math.max(0, maxLength));
1108
1203
  }
1204
+ function noticeOf(source) {
1205
+ return source.kind === "stream" ? AVATAR_MIC_NOTICE_VERSION : source.notice;
1206
+ }
1207
+ function micKeyOf(source, locale) {
1208
+ return source.kind === "stream" ? `stream ${source.base}` : `browser ${BROWSER_MIC_LANGUAGES[locale]?.lang ?? ""}`;
1209
+ }
1109
1210
  function useAvatarMic({
1110
1211
  enabled,
1111
1212
  locale,
1112
- base,
1113
- offer,
1114
- refreshOffer,
1213
+ source,
1115
1214
  onText,
1116
1215
  onStart
1117
1216
  }) {
1118
1217
  const S = avatarMicStrings(locale);
1119
- const copy = avatarMicNotice(AVATAR_MIC_NOTICE_VERSION, locale);
1218
+ const noticeVersion = source === null ? null : noticeOf(source);
1219
+ const copy = noticeVersion === null ? null : avatarMicNotice(noticeVersion, locale);
1220
+ const kind = source?.kind ?? null;
1221
+ const micKey = source === null ? null : micKeyOf(source, locale);
1120
1222
  const uid = useId();
1121
1223
  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;
1224
+ const [withdrawn, setWithdrawn] = useState({});
1225
+ const [supported, setSupported] = useState({ stream: false, browser: false });
1226
+ useEffect(
1227
+ () => setSupported({ stream: canUseAvatarMic(), browser: canUseBrowserMic() }),
1228
+ []
1229
+ );
1230
+ const usable = source === null ? false : source.kind === "stream" ? supported.stream : supported.browser && BROWSER_MIC_LANGUAGES[locale] !== void 0;
1231
+ const possible = enabled && kind !== null && usable && S !== null && copy !== null;
1232
+ const withdrawnReason = kind === null ? null : withdrawn[kind] ?? null;
1233
+ const offered = possible && withdrawnReason === null;
1127
1234
  const [state, setState] = useState(IDLE);
1128
1235
  const [noticeOpen, setNoticeOpen] = useState(false);
1129
- const agreedRef = useRef(false);
1236
+ const agreedRef = useRef(null);
1130
1237
  const [loading2, setLoading] = useState(false);
1131
1238
  const [loadFailed, setLoadFailed] = useState(false);
1132
1239
  const [now, setNow] = useState(() => Date.now());
1133
- const micRef = useRef(null);
1240
+ const micRef = useRef(
1241
+ null
1242
+ );
1134
1243
  const seqRef = useRef(0);
1135
1244
  const buttonRef = useRef(null);
1136
1245
  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 };
1246
+ const usedRef = useRef(null);
1247
+ const latest = useRef({ source, locale, noticeVersion, onText, onStart });
1248
+ latest.current = { source, locale, noticeVersion, onText, onStart };
1140
1249
  const cancel = useCallback(() => {
1141
1250
  seqRef.current += 1;
1142
1251
  setLoading(false);
@@ -1147,6 +1256,12 @@ function useAvatarMic({
1147
1256
  useEffect(() => {
1148
1257
  if (!offered) cancel();
1149
1258
  }, [offered, cancel]);
1259
+ const keyRef = useRef(micKey);
1260
+ useEffect(() => {
1261
+ if (keyRef.current === micKey) return;
1262
+ keyRef.current = micKey;
1263
+ cancel();
1264
+ }, [micKey, cancel]);
1150
1265
  useEffect(
1151
1266
  () => () => {
1152
1267
  seqRef.current += 1;
@@ -1154,29 +1269,55 @@ function useAvatarMic({
1154
1269
  },
1155
1270
  []
1156
1271
  );
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
- }, []);
1272
+ const micOf = useCallback(
1273
+ (loaded, wanted) => {
1274
+ const key = micKeyOf(wanted, latest.current.locale);
1275
+ const held = micRef.current;
1276
+ if (held !== null && held.key === key) return held.mic;
1277
+ held?.mic.cancel();
1278
+ let mic;
1279
+ if (wanted.kind === "stream") {
1280
+ const stream = () => {
1281
+ const current = latest.current.source;
1282
+ return current?.kind === "stream" ? current : null;
1283
+ };
1284
+ mic = loaded.createAvatarMic({
1285
+ base: wanted.base,
1286
+ notice: AVATAR_MIC_NOTICE_VERSION,
1287
+ offer: () => stream()?.offer ?? null,
1288
+ refreshOffer: () => stream()?.refreshOffer() ?? Promise.resolve(null),
1289
+ onState: setState
1290
+ });
1291
+ } else {
1292
+ const language = BROWSER_MIC_LANGUAGES[latest.current.locale] ?? EN_IN;
1293
+ mic = loaded.createBrowserAvatarMic({
1294
+ lang: language.lang,
1295
+ fallbackLang: language.fallbackLang,
1296
+ maxPressMs: LIMIT_SECONDS * 1e3,
1297
+ onState: setState
1298
+ });
1299
+ }
1300
+ micRef.current = { key, kind: wanted.kind, mic };
1301
+ return mic;
1302
+ },
1303
+ []
1304
+ );
1172
1305
  const press = useCallback(() => {
1173
- const target = latest.current.base;
1174
- if (target === null) return;
1306
+ const wanted = latest.current.source;
1307
+ if (wanted === null) return;
1175
1308
  seqRef.current += 1;
1176
1309
  const seq = seqRef.current;
1177
1310
  setLoadFailed(false);
1311
+ const held = micRef.current;
1312
+ const ready = wanted.kind !== "browser" ? null : held !== null && held.key === micKeyOf(wanted, latest.current.locale) ? held.mic : loadedMic !== null ? micOf(loadedMic, wanted) : null;
1313
+ if (ready !== null) {
1314
+ setLoading(false);
1315
+ ready.start();
1316
+ return;
1317
+ }
1178
1318
  setLoading(true);
1179
- void micFor(target).then(
1319
+ const arriving = held !== null && held.key === micKeyOf(wanted, latest.current.locale) ? Promise.resolve(held.mic) : loadAvatarMic().then((loaded) => micOf(loaded, wanted));
1320
+ void arriving.then(
1180
1321
  (mic) => {
1181
1322
  if (seqRef.current !== seq) return;
1182
1323
  setLoading(false);
@@ -1188,8 +1329,9 @@ function useAvatarMic({
1188
1329
  setLoadFailed(true);
1189
1330
  }
1190
1331
  );
1191
- }, [micFor]);
1332
+ }, [micOf]);
1192
1333
  const recording = loading2 || state.name === "starting" || state.name === "listening";
1334
+ const finishing = kind === "browser" && state.name === "finishing";
1193
1335
  const tap = useCallback(() => {
1194
1336
  if (state.name === "listening" || state.name === "starting") {
1195
1337
  micRef.current?.mic.stop();
@@ -1199,16 +1341,26 @@ function useAvatarMic({
1199
1341
  cancel();
1200
1342
  return;
1201
1343
  }
1344
+ if (finishing) return;
1202
1345
  latest.current.onStart?.();
1203
- if (!agreedRef.current) {
1346
+ if (agreedRef.current !== latest.current.noticeVersion) {
1204
1347
  setNoticeOpen(true);
1205
- void loadAvatarMic().catch(() => void 0);
1348
+ const seq = seqRef.current;
1349
+ void loadAvatarMic().then(
1350
+ (loaded) => {
1351
+ const wanted = latest.current.source;
1352
+ if (seqRef.current === seq && wanted?.kind === "browser") {
1353
+ micOf(loaded, wanted);
1354
+ }
1355
+ },
1356
+ () => void 0
1357
+ );
1206
1358
  return;
1207
1359
  }
1208
1360
  press();
1209
- }, [cancel, loading2, press, state.name]);
1361
+ }, [cancel, finishing, loading2, micOf, press, state.name]);
1210
1362
  const allow = useCallback(() => {
1211
- agreedRef.current = true;
1363
+ agreedRef.current = latest.current.noticeVersion;
1212
1364
  setNoticeOpen(false);
1213
1365
  buttonRef.current?.focus();
1214
1366
  press();
@@ -1228,11 +1380,15 @@ function useAvatarMic({
1228
1380
  return () => clearInterval(timer);
1229
1381
  }, [listeningSince]);
1230
1382
  useEffect(() => {
1231
- if (state.name === "done" && state.epoch !== usedEpochRef.current) {
1232
- usedEpochRef.current = state.epoch;
1383
+ if (state.name === "done" && state !== usedRef.current) {
1384
+ usedRef.current = state;
1233
1385
  if (state.text !== "") latest.current.onText(state.text);
1234
- } else if (state.name === "failed" && FOR_GOOD.has(state.reason)) {
1235
- setWithdrawn(state.reason);
1386
+ return;
1387
+ }
1388
+ const pressed = micRef.current?.kind;
1389
+ if (state.name === "failed" && pressed !== void 0 && FOR_GOOD[pressed].has(state.reason)) {
1390
+ const reason = state.reason;
1391
+ setWithdrawn((held) => ({ ...held, [pressed]: reason }));
1236
1392
  }
1237
1393
  }, [state]);
1238
1394
  if (!possible || S === null || copy === null) {
@@ -1244,7 +1400,7 @@ function useAvatarMic({
1244
1400
  recording: false
1245
1401
  };
1246
1402
  }
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]] : "";
1403
+ 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]] : "";
1248
1404
  const heard = offered && (state.name === "listening" || state.name === "finishing") ? state.text : "";
1249
1405
  const button = !offered ? null : /* @__PURE__ */ jsx(
1250
1406
  "button",
@@ -1253,12 +1409,15 @@ function useAvatarMic({
1253
1409
  type: "button",
1254
1410
  "aria-label": recording ? S.micStop : S.micStart,
1255
1411
  "aria-pressed": recording,
1412
+ "aria-disabled": finishing ? true : void 0,
1256
1413
  title: recording ? S.micStop : S.micStart,
1257
1414
  "data-boff-explore": "avatar-live-mic",
1258
1415
  "data-mic": loading2 ? "starting" : state.name,
1416
+ "data-mic-source": kind ?? void 0,
1259
1417
  className: cn(
1260
1418
  "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"
1419
+ recording ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
1420
+ finishing && "cursor-wait opacity-50"
1262
1421
  ),
1263
1422
  onClick: tap,
1264
1423
  children: recording ? /* @__PURE__ */ jsx(Square, { "aria-hidden": "true", className: "h-4 w-4" }) : /* @__PURE__ */ jsx(Mic, { "aria-hidden": "true", className: "h-4 w-4" })
@@ -1341,7 +1500,11 @@ function useAvatarMic({
1341
1500
  {
1342
1501
  "data-boff-explore": "avatar-live-mic-heard",
1343
1502
  dir: "auto",
1344
- className: "line-clamp-3 break-words text-sm italic text-muted-foreground",
1503
+ className: cn(
1504
+ // rrweb's default block and mask classes: a site's session replay records none of what is heard.
1505
+ REPLAY_PRIVATE,
1506
+ "line-clamp-3 break-words text-sm italic text-muted-foreground"
1507
+ ),
1345
1508
  children: heard
1346
1509
  }
1347
1510
  )
@@ -1478,6 +1641,7 @@ var PLAYOUT_TAIL_MS = 5e3;
1478
1641
  var QUIET_PLAYED_OUT_MS = 2500;
1479
1642
  var OWED_VOICE_WAIT_MS = 15e3;
1480
1643
  var LISTENING_MARK_MS = 1e4;
1644
+ var LISTENING_MARK_SPACING_MS = 5e3;
1481
1645
  var BUSY_RETRY_MS = 2e3;
1482
1646
  var BUSY_RETRY_MAX_MS = 8e3;
1483
1647
  var MAX_BUSY_RETRIES = 24;
@@ -1603,8 +1767,15 @@ function acceptsQuestion(slot) {
1603
1767
  function followUpBox(phase, run, token) {
1604
1768
  return phase.name === "live" && run !== null && phase.session.followUp !== null && !phase.session.followUp.closed && token === run.token;
1605
1769
  }
1606
- function micOfferOf(result) {
1607
- return result.kind === "available" && result.offer.stt !== void 0 ? { ...result.offer.stt, receivedAt: Date.now() } : null;
1770
+ var NO_MIC = { kind: "none" };
1771
+ function micChoiceOf(result, locale) {
1772
+ if (result.kind !== "available") return NO_MIC;
1773
+ const { offer } = result;
1774
+ const micNotice = avatarConsentCopy(offer.consentVersion, locale)?.micNotice;
1775
+ if (offer.mic !== void 0) {
1776
+ return offer.mic.mode === "browser" && micNotice !== void 0 ? { kind: "browser", notice: micNotice } : NO_MIC;
1777
+ }
1778
+ return offer.stt !== void 0 && micNotice === void 0 ? { kind: "stream", offer: { ...offer.stt, receivedAt: Date.now() } } : NO_MIC;
1608
1779
  }
1609
1780
  function utcToday() {
1610
1781
  return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
@@ -1754,7 +1925,7 @@ function AvatarLiveView({
1754
1925
  const waitRef = useRef(null);
1755
1926
  const sendingRef = useRef(false);
1756
1927
  const textBoxRef = useRef(false);
1757
- const [micOffer, setMicOffer] = useState(null);
1928
+ const [micChoice, setMicChoice] = useState(NO_MIC);
1758
1929
  const micCancelRef = useRef(() => void 0);
1759
1930
  const sendDueRef = useRef(() => void 0);
1760
1931
  const checkRef = useRef(null);
@@ -2058,7 +2229,7 @@ function AvatarLiveView({
2058
2229
  const applyAvailability = useCallback(
2059
2230
  (result, locale2) => {
2060
2231
  const { live: live2 } = latest.current;
2061
- setMicOffer(micOfferOf(result));
2232
+ setMicChoice(micChoiceOf(result, locale2));
2062
2233
  if (result.kind === "inert") {
2063
2234
  setMode("suggested");
2064
2235
  go(INERT2);
@@ -2130,7 +2301,8 @@ function AvatarLiveView({
2130
2301
  due: null,
2131
2302
  retry: null,
2132
2303
  sent: /* @__PURE__ */ new Set([context.messageId]),
2133
- seq: 0
2304
+ seq: 0,
2305
+ listenedAt: null
2134
2306
  } : null;
2135
2307
  sessionRef.current = {
2136
2308
  session,
@@ -2765,16 +2937,18 @@ function AvatarLiveView({
2765
2937
  const refreshMicOffer = useCallback(async () => {
2766
2938
  const { live: live2, locale: locale2, base: base2, headers: headers2 } = latest.current;
2767
2939
  if (base2 === null) return null;
2768
- const next = micOfferOf(
2940
+ const next = micChoiceOf(
2769
2941
  await requestAvailabilityAnswer({
2770
2942
  base: base2,
2771
2943
  headers: headers2,
2772
2944
  site: live2.site,
2773
2945
  locale: locale2
2774
- })
2946
+ }),
2947
+ locale2
2775
2948
  );
2776
- if (next !== null) setMicOffer(next);
2777
- return next;
2949
+ if (next.kind !== "stream") return null;
2950
+ setMicChoice(next);
2951
+ return next.offer;
2778
2952
  }, []);
2779
2953
  const onMicText = useCallback(
2780
2954
  (text) => {
@@ -2800,8 +2974,14 @@ function AvatarLiveView({
2800
2974
  handle.playedOut = true;
2801
2975
  showFollowUp(handle, { speaking: false });
2802
2976
  }, [showFollowUp, tell]);
2977
+ const micSource = micChoice.kind === "browser" ? { kind: "browser", notice: micChoice.notice } : micChoice.kind === "stream" && base !== null ? {
2978
+ kind: "stream",
2979
+ base,
2980
+ offer: micChoice.offer,
2981
+ refreshOffer: refreshMicOffer
2982
+ } : null;
2803
2983
  const mic = useAvatarMic({
2804
- enabled: live.mic === true && micOffer !== null && !off && acceptsQuestion({
2984
+ enabled: live.mic === true && micSource !== null && !off && acceptsQuestion({
2805
2985
  any,
2806
2986
  phase: phase.name,
2807
2987
  kind,
@@ -2810,9 +2990,7 @@ function AvatarLiveView({
2810
2990
  followUp: followUpAsks
2811
2991
  }),
2812
2992
  locale,
2813
- base,
2814
- offer: micOffer,
2815
- refreshOffer: refreshMicOffer,
2993
+ source: micSource,
2816
2994
  onText: onMicText,
2817
2995
  onStart: onMicStart
2818
2996
  });
@@ -2823,15 +3001,23 @@ function AvatarLiveView({
2823
3001
  if (!micRecording || handle === null || handle.followUps === null || handle.done) {
2824
3002
  return;
2825
3003
  }
2826
- tell(handle, "listening");
3004
+ const run = handle.followUps;
2827
3005
  const every = Math.min(
2828
3006
  LISTENING_MARK_MS,
2829
- Math.max(1e3, handle.followUps.limits.idleSeconds * 1e3 / 3)
3007
+ Math.max(1e3, run.limits.idleSeconds * 1e3 / 3)
2830
3008
  );
2831
- const timer = setInterval(() => tell(handle, "listening"), every);
3009
+ const spacing = Math.min(LISTENING_MARK_SPACING_MS, every / 2);
3010
+ const mark = () => {
3011
+ const at = Date.now();
3012
+ if (run.listenedAt !== null && at - run.listenedAt < spacing) return;
3013
+ run.listenedAt = at;
3014
+ tell(handle, "listening");
3015
+ };
3016
+ mark();
3017
+ const timer = setInterval(mark, every);
2832
3018
  return () => {
2833
3019
  clearInterval(timer);
2834
- if (sessionRef.current === handle) tell(handle, "listening");
3020
+ if (sessionRef.current === handle) mark();
2835
3021
  };
2836
3022
  }, [micRecording, tell]);
2837
3023
  const notNow = useCallback(() => {
@@ -3703,7 +3889,12 @@ function AskBox({
3703
3889
  dir: "auto",
3704
3890
  "data-avatar-primary": touchFirst ? void 0 : "",
3705
3891
  "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",
3892
+ className: cn(
3893
+ // Kept out of a site's session replay before Send (rrweb's default classes; `rr-ignore` for its input
3894
+ // events): the text may be what the mic heard, or what the visitor typed.
3895
+ REPLAY_PRIVATE,
3896
+ "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"
3897
+ ),
3707
3898
  onChange: (event) => {
3708
3899
  setRefusedFiles(false);
3709
3900
  onChange(event.target.value);