@usero/sdk 1.1.6 → 1.1.8

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.
@@ -4,7 +4,12 @@ var DEFAULT_API_URL = "https://usero.io";
4
4
  // src/plugins/user-test.ts
5
5
  var DEFAULT_OPTIONS = {
6
6
  queryParam: "usero_test",
7
- chunkSeconds: 30,
7
+ // 10s (not 30) so at most ~10s of audio is at risk if the tab is torn
8
+ // down before a flush, and so a session shorter than the old 30s window
9
+ // still emits at least one chunk (previously its single buffered chunk was
10
+ // never flushed and its audio was lost). Tradeoff: ~3x the R2 writes /
11
+ // upload requests per session; 10s is an acceptable balance, don't go lower.
12
+ chunkSeconds: 10,
8
13
  apiUrl: DEFAULT_API_URL,
9
14
  testerName: "",
10
15
  hideIndicator: false
@@ -217,12 +222,37 @@ function buildIndicator(host, store, callbacks) {
217
222
  color: #fcd34d;
218
223
  }
219
224
  .mic[data-mic-state="muted"]:hover { background: rgba(251, 191, 36, 0.26); }
220
- .mic[data-mic-state="none"] {
221
- background: rgba(255,255,255,0.04);
222
- color: rgba(255,255,255,0.55);
225
+ /* Connecting: getUserMedia pending. Steady amber tint reads as "working",
226
+ distinct from the live red pulse and the failed state below. The icon
227
+ gets a gentle non-pulsing breathe so it feels alive without alarming. */
228
+ .mic[data-mic-state="connecting"] {
229
+ background: rgba(251, 191, 36, 0.14);
230
+ border-color: rgba(251, 191, 36, 0.32);
231
+ color: #fcd34d;
223
232
  cursor: default;
224
233
  }
225
- .mic[data-mic-state="none"]:hover { background: rgba(255,255,255,0.04); }
234
+ .mic[data-mic-state="connecting"]:hover { background: rgba(251, 191, 36, 0.14); }
235
+ .mic[data-mic-state="connecting"] .mic-icon {
236
+ color: #fbbf24;
237
+ animation: micBreathe 1.4s ease-in-out infinite;
238
+ }
239
+ /* Failed terminal state, actionable. Tappable affordance: clearer border,
240
+ pointer cursor, brightens on hover/focus to invite the retry tap. */
241
+ .mic[data-mic-state="none"] {
242
+ background: rgba(255,255,255,0.05);
243
+ border-color: rgba(255,255,255,0.14);
244
+ color: rgba(255,255,255,0.72);
245
+ cursor: pointer;
246
+ }
247
+ .mic[data-mic-state="none"]:hover {
248
+ background: rgba(255,255,255,0.12);
249
+ border-color: rgba(255,255,255,0.24);
250
+ color: #fff;
251
+ }
252
+ @keyframes micBreathe {
253
+ 0%, 100% { opacity: 0.55; }
254
+ 50% { opacity: 1; }
255
+ }
226
256
  .mic-icon { width: 13px; height: 13px; display: inline-block; flex-shrink: 0; }
227
257
  .mic-label { font-weight: 500; letter-spacing: 0.01em; white-space: nowrap; }
228
258
 
@@ -691,7 +721,10 @@ function micChipState(store) {
691
721
  if (store.indicatorState === "finishing" || store.indicatorState === "done" || store.indicatorState === "error") {
692
722
  return "inactive";
693
723
  }
694
- if (!store.hasMicPermission) return "none";
724
+ if (!store.hasMicPermission) {
725
+ if (store.micAcquiring) return "connecting";
726
+ return "none";
727
+ }
695
728
  return store.muted ? "muted" : "recording";
696
729
  }
697
730
  function renderIndicatorState(store) {
@@ -706,6 +739,8 @@ function renderIndicatorState(store) {
706
739
  dot.setAttribute("data-state", store.indicatorState);
707
740
  const chipState = micChipState(store);
708
741
  mic.setAttribute("data-mic-state", chipState === "inactive" ? "none" : chipState);
742
+ mic.removeAttribute("data-mic-fail");
743
+ if (chipState === "none") mic.setAttribute("data-mic-fail", store.micFailReason ?? "blocked");
709
744
  switch (store.indicatorState) {
710
745
  case "recording":
711
746
  case "no-audio":
@@ -740,13 +775,23 @@ function renderIndicatorState(store) {
740
775
  mic.setAttribute("aria-pressed", "true");
741
776
  mic.removeAttribute("tabindex");
742
777
  break;
743
- case "none":
744
- micIcon.innerHTML = MIC_MUTED_ICON_SVG;
745
- micLabel.textContent = "No mic, replay only";
746
- mic.setAttribute("aria-label", "Microphone not granted, replay only");
778
+ case "connecting":
779
+ micIcon.innerHTML = MIC_ICON_SVG;
780
+ micLabel.textContent = "Connecting mic";
781
+ mic.setAttribute("aria-label", "Connecting microphone");
747
782
  mic.setAttribute("aria-pressed", "false");
748
783
  mic.setAttribute("tabindex", "-1");
749
784
  break;
785
+ case "none": {
786
+ micIcon.innerHTML = MIC_MUTED_ICON_SVG;
787
+ const failLabel = store.micFailReason === "not-found" ? "No mic found, tap to retry" : "Mic blocked, tap to retry";
788
+ const failAria = store.micFailReason === "not-found" ? "No microphone found, tap to retry. Replay continues." : "Microphone blocked, tap to retry. Replay continues.";
789
+ micLabel.textContent = failLabel;
790
+ mic.setAttribute("aria-label", failAria);
791
+ mic.setAttribute("aria-pressed", "false");
792
+ mic.removeAttribute("tabindex");
793
+ break;
794
+ }
750
795
  case "inactive":
751
796
  micIcon.innerHTML = MIC_ICON_SVG;
752
797
  micLabel.textContent = store.indicatorState === "finishing" ? "Saving" : store.indicatorState === "done" ? "Saved" : "Save failed";
@@ -1325,8 +1370,13 @@ function enqueueChunk(store, ctx, blob) {
1325
1370
  });
1326
1371
  }
1327
1372
  async function startRecording(store, ctx) {
1373
+ store.micAcquiring = true;
1374
+ store.micFailReason = null;
1375
+ renderIndicatorState(store);
1328
1376
  if (!isMediaRecorderSupported()) {
1329
1377
  ctx.logger.warn("MediaRecorder not supported, continuing without audio");
1378
+ store.micAcquiring = false;
1379
+ store.micFailReason = "unsupported";
1330
1380
  store.indicatorState = "no-audio";
1331
1381
  renderIndicatorState(store);
1332
1382
  return;
@@ -1336,12 +1386,17 @@ async function startRecording(store, ctx) {
1336
1386
  stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1337
1387
  } catch (err) {
1338
1388
  ctx.logger.warn("mic permission denied or unavailable", err);
1389
+ store.micAcquiring = false;
1390
+ const name = err instanceof Error ? err.name : "";
1391
+ store.micFailReason = name === "NotFoundError" || name === "DevicesNotFoundError" ? "not-found" : "blocked";
1339
1392
  store.indicatorState = "no-audio";
1340
1393
  renderIndicatorState(store);
1341
1394
  return;
1342
1395
  }
1343
1396
  store.stream = stream;
1344
1397
  store.hasMicPermission = true;
1398
+ store.micAcquiring = false;
1399
+ store.micFailReason = null;
1345
1400
  const mimeType = pickMimeType();
1346
1401
  let recorder;
1347
1402
  try {
@@ -1350,6 +1405,9 @@ async function startRecording(store, ctx) {
1350
1405
  ctx.logger.error("MediaRecorder construction failed", err);
1351
1406
  stream.getTracks().forEach((t) => t.stop());
1352
1407
  store.stream = null;
1408
+ store.hasMicPermission = false;
1409
+ store.micAcquiring = false;
1410
+ store.micFailReason = "unsupported";
1353
1411
  store.indicatorState = "no-audio";
1354
1412
  renderIndicatorState(store);
1355
1413
  return;
@@ -1364,6 +1422,10 @@ async function startRecording(store, ctx) {
1364
1422
  ctx.logger.error("MediaRecorder error", event);
1365
1423
  });
1366
1424
  recorder.start(store.options.chunkSeconds * 1e3);
1425
+ if (store.indicatorState === "no-audio") {
1426
+ store.indicatorState = "recording";
1427
+ }
1428
+ renderIndicatorState(store);
1367
1429
  }
1368
1430
  function toggleMute(store) {
1369
1431
  if (!store.stream || !store.hasMicPermission) return false;
@@ -1513,12 +1575,15 @@ function userTest(options = {}) {
1513
1575
  indicatorRoot: null,
1514
1576
  indicatorState: "recording",
1515
1577
  pageHideHandler: null,
1578
+ visibilityHandler: null,
1516
1579
  options: { ...merged, apiUrl },
1517
1580
  tasks: [],
1518
1581
  tasksPanelOpen: readTasksPanelOpen(),
1519
1582
  outsidePointerHandler: null,
1520
1583
  keydownHandler: null,
1521
1584
  hasMicPermission: false,
1585
+ micAcquiring: true,
1586
+ micFailReason: null,
1522
1587
  muted: false,
1523
1588
  mutedSinceMs: null,
1524
1589
  mutedSegments: [],
@@ -1543,7 +1608,12 @@ function userTest(options = {}) {
1543
1608
  };
1544
1609
  const onToggleTasks = () => setPanelOpen(!store.tasksPanelOpen);
1545
1610
  const onToggleMute = () => {
1546
- if (!store.hasMicPermission) return;
1611
+ if (!store.hasMicPermission) {
1612
+ if (!store.micAcquiring && store.indicatorState !== "finishing" && store.indicatorState !== "done" && store.indicatorState !== "error") {
1613
+ void startRecording(store, ctx);
1614
+ }
1615
+ return;
1616
+ }
1547
1617
  const ok = toggleMute(store);
1548
1618
  if (!ok) return;
1549
1619
  if (store.muted) showMuteToast(store);
@@ -1613,6 +1683,12 @@ function userTest(options = {}) {
1613
1683
  };
1614
1684
  store.pageHideHandler = pageHide;
1615
1685
  window.addEventListener("pagehide", pageHide);
1686
+ const onVisibilityChange = () => {
1687
+ if (document.visibilityState !== "hidden") return;
1688
+ void finishFlow(store, ctx, { showThanks: false });
1689
+ };
1690
+ store.visibilityHandler = onVisibilityChange;
1691
+ document.addEventListener("visibilitychange", onVisibilityChange);
1616
1692
  void (async () => {
1617
1693
  const adoptId = getAdoptSessionId();
1618
1694
  const created = adoptId ? await adoptSession(apiUrl, adoptId) : await createSession(apiUrl, slug, readTesterName(merged.testerName));
@@ -1648,6 +1724,10 @@ function userTest(options = {}) {
1648
1724
  window.removeEventListener("pagehide", store.pageHideHandler);
1649
1725
  store.pageHideHandler = null;
1650
1726
  }
1727
+ if (store.visibilityHandler) {
1728
+ document.removeEventListener("visibilitychange", store.visibilityHandler);
1729
+ store.visibilityHandler = null;
1730
+ }
1651
1731
  stopRecording(store);
1652
1732
  if (store.outsidePointerHandler) {
1653
1733
  document.removeEventListener("pointerdown", store.outsidePointerHandler, true);
@@ -1672,7 +1752,7 @@ function userTest(options = {}) {
1672
1752
  }
1673
1753
  };
1674
1754
  }
1675
- var __test__ = { getTestSlug, pickMimeType, isMediaRecorderSupported };
1755
+ var __test__ = { getTestSlug, pickMimeType, isMediaRecorderSupported, micChipState };
1676
1756
 
1677
1757
  export { __test__, userTest };
1678
1758
  //# sourceMappingURL=user-test.js.map