@usero/sdk 1.1.6 → 1.1.7

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.
@@ -56,14 +56,66 @@ interface UserTestOptions {
56
56
  testerName?: string;
57
57
  hideIndicator?: boolean;
58
58
  }
59
+ interface UserTestTask {
60
+ id: string;
61
+ prompt: string;
62
+ sortOrder: number;
63
+ }
64
+ interface MutedSegment {
65
+ startMs: number;
66
+ endMs: number;
67
+ }
68
+ interface InFlightNote {
69
+ atMs: number;
70
+ text: string;
71
+ acked: boolean;
72
+ serverId?: string;
73
+ }
74
+ interface RecorderStore {
75
+ cancelled: boolean;
76
+ slug: string;
77
+ sessionId: string | null;
78
+ clientId: string | null;
79
+ recorder: MediaRecorder | null;
80
+ stream: MediaStream | null;
81
+ chunkIndex: number;
82
+ uploadQueue: Promise<void>;
83
+ pendingUploads: number;
84
+ startedAt: number;
85
+ indicator: HTMLElement | null;
86
+ indicatorRoot: ShadowRoot | null;
87
+ indicatorState: 'recording' | 'finishing' | 'done' | 'no-audio' | 'error';
88
+ pageHideHandler: (() => void) | null;
89
+ options: Required<UserTestOptions>;
90
+ tasks: UserTestTask[];
91
+ tasksPanelOpen: boolean;
92
+ outsidePointerHandler: ((event: PointerEvent) => void) | null;
93
+ keydownHandler: ((event: KeyboardEvent) => void) | null;
94
+ hasMicPermission: boolean;
95
+ micAcquiring: boolean;
96
+ micFailReason: 'blocked' | 'not-found' | 'unsupported' | null;
97
+ muted: boolean;
98
+ mutedSinceMs: number | null;
99
+ mutedSegments: MutedSegment[];
100
+ muteToastShown: boolean;
101
+ muteToastTimers: number[];
102
+ notes: InFlightNote[];
103
+ notesPopoverOpen: boolean;
104
+ notePopoverAtMs: number | null;
105
+ endNote: string;
106
+ finishFlowRan: boolean;
107
+ replayOffsetAtStartMs: number | null;
108
+ }
59
109
  declare function getTestSlug(queryParam: string): string | null;
60
110
  declare function isMediaRecorderSupported(): boolean;
61
111
  declare function pickMimeType(): string | undefined;
112
+ declare function micChipState(store: RecorderStore): 'recording' | 'muted' | 'none' | 'connecting' | 'inactive';
62
113
  declare function userTest(options?: UserTestOptions): UseroPlugin;
63
114
  declare const __test__: {
64
115
  getTestSlug: typeof getTestSlug;
65
116
  pickMimeType: typeof pickMimeType;
66
117
  isMediaRecorderSupported: typeof isMediaRecorderSupported;
118
+ micChipState: typeof micChipState;
67
119
  };
68
120
 
69
121
  export { type UserTestOptions, __test__, userTest };
@@ -56,14 +56,66 @@ interface UserTestOptions {
56
56
  testerName?: string;
57
57
  hideIndicator?: boolean;
58
58
  }
59
+ interface UserTestTask {
60
+ id: string;
61
+ prompt: string;
62
+ sortOrder: number;
63
+ }
64
+ interface MutedSegment {
65
+ startMs: number;
66
+ endMs: number;
67
+ }
68
+ interface InFlightNote {
69
+ atMs: number;
70
+ text: string;
71
+ acked: boolean;
72
+ serverId?: string;
73
+ }
74
+ interface RecorderStore {
75
+ cancelled: boolean;
76
+ slug: string;
77
+ sessionId: string | null;
78
+ clientId: string | null;
79
+ recorder: MediaRecorder | null;
80
+ stream: MediaStream | null;
81
+ chunkIndex: number;
82
+ uploadQueue: Promise<void>;
83
+ pendingUploads: number;
84
+ startedAt: number;
85
+ indicator: HTMLElement | null;
86
+ indicatorRoot: ShadowRoot | null;
87
+ indicatorState: 'recording' | 'finishing' | 'done' | 'no-audio' | 'error';
88
+ pageHideHandler: (() => void) | null;
89
+ options: Required<UserTestOptions>;
90
+ tasks: UserTestTask[];
91
+ tasksPanelOpen: boolean;
92
+ outsidePointerHandler: ((event: PointerEvent) => void) | null;
93
+ keydownHandler: ((event: KeyboardEvent) => void) | null;
94
+ hasMicPermission: boolean;
95
+ micAcquiring: boolean;
96
+ micFailReason: 'blocked' | 'not-found' | 'unsupported' | null;
97
+ muted: boolean;
98
+ mutedSinceMs: number | null;
99
+ mutedSegments: MutedSegment[];
100
+ muteToastShown: boolean;
101
+ muteToastTimers: number[];
102
+ notes: InFlightNote[];
103
+ notesPopoverOpen: boolean;
104
+ notePopoverAtMs: number | null;
105
+ endNote: string;
106
+ finishFlowRan: boolean;
107
+ replayOffsetAtStartMs: number | null;
108
+ }
59
109
  declare function getTestSlug(queryParam: string): string | null;
60
110
  declare function isMediaRecorderSupported(): boolean;
61
111
  declare function pickMimeType(): string | undefined;
112
+ declare function micChipState(store: RecorderStore): 'recording' | 'muted' | 'none' | 'connecting' | 'inactive';
62
113
  declare function userTest(options?: UserTestOptions): UseroPlugin;
63
114
  declare const __test__: {
64
115
  getTestSlug: typeof getTestSlug;
65
116
  pickMimeType: typeof pickMimeType;
66
117
  isMediaRecorderSupported: typeof isMediaRecorderSupported;
118
+ micChipState: typeof micChipState;
67
119
  };
68
120
 
69
121
  export { type UserTestOptions, __test__, userTest };
@@ -217,12 +217,37 @@ function buildIndicator(host, store, callbacks) {
217
217
  color: #fcd34d;
218
218
  }
219
219
  .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);
220
+ /* Connecting: getUserMedia pending. Steady amber tint reads as "working",
221
+ distinct from the live red pulse and the failed state below. The icon
222
+ gets a gentle non-pulsing breathe so it feels alive without alarming. */
223
+ .mic[data-mic-state="connecting"] {
224
+ background: rgba(251, 191, 36, 0.14);
225
+ border-color: rgba(251, 191, 36, 0.32);
226
+ color: #fcd34d;
223
227
  cursor: default;
224
228
  }
225
- .mic[data-mic-state="none"]:hover { background: rgba(255,255,255,0.04); }
229
+ .mic[data-mic-state="connecting"]:hover { background: rgba(251, 191, 36, 0.14); }
230
+ .mic[data-mic-state="connecting"] .mic-icon {
231
+ color: #fbbf24;
232
+ animation: micBreathe 1.4s ease-in-out infinite;
233
+ }
234
+ /* Failed terminal state, actionable. Tappable affordance: clearer border,
235
+ pointer cursor, brightens on hover/focus to invite the retry tap. */
236
+ .mic[data-mic-state="none"] {
237
+ background: rgba(255,255,255,0.05);
238
+ border-color: rgba(255,255,255,0.14);
239
+ color: rgba(255,255,255,0.72);
240
+ cursor: pointer;
241
+ }
242
+ .mic[data-mic-state="none"]:hover {
243
+ background: rgba(255,255,255,0.12);
244
+ border-color: rgba(255,255,255,0.24);
245
+ color: #fff;
246
+ }
247
+ @keyframes micBreathe {
248
+ 0%, 100% { opacity: 0.55; }
249
+ 50% { opacity: 1; }
250
+ }
226
251
  .mic-icon { width: 13px; height: 13px; display: inline-block; flex-shrink: 0; }
227
252
  .mic-label { font-weight: 500; letter-spacing: 0.01em; white-space: nowrap; }
228
253
 
@@ -691,7 +716,10 @@ function micChipState(store) {
691
716
  if (store.indicatorState === "finishing" || store.indicatorState === "done" || store.indicatorState === "error") {
692
717
  return "inactive";
693
718
  }
694
- if (!store.hasMicPermission) return "none";
719
+ if (!store.hasMicPermission) {
720
+ if (store.micAcquiring) return "connecting";
721
+ return "none";
722
+ }
695
723
  return store.muted ? "muted" : "recording";
696
724
  }
697
725
  function renderIndicatorState(store) {
@@ -706,6 +734,8 @@ function renderIndicatorState(store) {
706
734
  dot.setAttribute("data-state", store.indicatorState);
707
735
  const chipState = micChipState(store);
708
736
  mic.setAttribute("data-mic-state", chipState === "inactive" ? "none" : chipState);
737
+ mic.removeAttribute("data-mic-fail");
738
+ if (chipState === "none") mic.setAttribute("data-mic-fail", store.micFailReason ?? "blocked");
709
739
  switch (store.indicatorState) {
710
740
  case "recording":
711
741
  case "no-audio":
@@ -740,13 +770,23 @@ function renderIndicatorState(store) {
740
770
  mic.setAttribute("aria-pressed", "true");
741
771
  mic.removeAttribute("tabindex");
742
772
  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");
773
+ case "connecting":
774
+ micIcon.innerHTML = MIC_ICON_SVG;
775
+ micLabel.textContent = "Connecting mic";
776
+ mic.setAttribute("aria-label", "Connecting microphone");
747
777
  mic.setAttribute("aria-pressed", "false");
748
778
  mic.setAttribute("tabindex", "-1");
749
779
  break;
780
+ case "none": {
781
+ micIcon.innerHTML = MIC_MUTED_ICON_SVG;
782
+ const failLabel = store.micFailReason === "not-found" ? "No mic found, tap to retry" : "Mic blocked, tap to retry";
783
+ const failAria = store.micFailReason === "not-found" ? "No microphone found, tap to retry. Replay continues." : "Microphone blocked, tap to retry. Replay continues.";
784
+ micLabel.textContent = failLabel;
785
+ mic.setAttribute("aria-label", failAria);
786
+ mic.setAttribute("aria-pressed", "false");
787
+ mic.removeAttribute("tabindex");
788
+ break;
789
+ }
750
790
  case "inactive":
751
791
  micIcon.innerHTML = MIC_ICON_SVG;
752
792
  micLabel.textContent = store.indicatorState === "finishing" ? "Saving" : store.indicatorState === "done" ? "Saved" : "Save failed";
@@ -1325,8 +1365,13 @@ function enqueueChunk(store, ctx, blob) {
1325
1365
  });
1326
1366
  }
1327
1367
  async function startRecording(store, ctx) {
1368
+ store.micAcquiring = true;
1369
+ store.micFailReason = null;
1370
+ renderIndicatorState(store);
1328
1371
  if (!isMediaRecorderSupported()) {
1329
1372
  ctx.logger.warn("MediaRecorder not supported, continuing without audio");
1373
+ store.micAcquiring = false;
1374
+ store.micFailReason = "unsupported";
1330
1375
  store.indicatorState = "no-audio";
1331
1376
  renderIndicatorState(store);
1332
1377
  return;
@@ -1336,12 +1381,17 @@ async function startRecording(store, ctx) {
1336
1381
  stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1337
1382
  } catch (err) {
1338
1383
  ctx.logger.warn("mic permission denied or unavailable", err);
1384
+ store.micAcquiring = false;
1385
+ const name = err instanceof Error ? err.name : "";
1386
+ store.micFailReason = name === "NotFoundError" || name === "DevicesNotFoundError" ? "not-found" : "blocked";
1339
1387
  store.indicatorState = "no-audio";
1340
1388
  renderIndicatorState(store);
1341
1389
  return;
1342
1390
  }
1343
1391
  store.stream = stream;
1344
1392
  store.hasMicPermission = true;
1393
+ store.micAcquiring = false;
1394
+ store.micFailReason = null;
1345
1395
  const mimeType = pickMimeType();
1346
1396
  let recorder;
1347
1397
  try {
@@ -1350,6 +1400,9 @@ async function startRecording(store, ctx) {
1350
1400
  ctx.logger.error("MediaRecorder construction failed", err);
1351
1401
  stream.getTracks().forEach((t) => t.stop());
1352
1402
  store.stream = null;
1403
+ store.hasMicPermission = false;
1404
+ store.micAcquiring = false;
1405
+ store.micFailReason = "unsupported";
1353
1406
  store.indicatorState = "no-audio";
1354
1407
  renderIndicatorState(store);
1355
1408
  return;
@@ -1364,6 +1417,10 @@ async function startRecording(store, ctx) {
1364
1417
  ctx.logger.error("MediaRecorder error", event);
1365
1418
  });
1366
1419
  recorder.start(store.options.chunkSeconds * 1e3);
1420
+ if (store.indicatorState === "no-audio") {
1421
+ store.indicatorState = "recording";
1422
+ }
1423
+ renderIndicatorState(store);
1367
1424
  }
1368
1425
  function toggleMute(store) {
1369
1426
  if (!store.stream || !store.hasMicPermission) return false;
@@ -1519,6 +1576,8 @@ function userTest(options = {}) {
1519
1576
  outsidePointerHandler: null,
1520
1577
  keydownHandler: null,
1521
1578
  hasMicPermission: false,
1579
+ micAcquiring: true,
1580
+ micFailReason: null,
1522
1581
  muted: false,
1523
1582
  mutedSinceMs: null,
1524
1583
  mutedSegments: [],
@@ -1543,7 +1602,12 @@ function userTest(options = {}) {
1543
1602
  };
1544
1603
  const onToggleTasks = () => setPanelOpen(!store.tasksPanelOpen);
1545
1604
  const onToggleMute = () => {
1546
- if (!store.hasMicPermission) return;
1605
+ if (!store.hasMicPermission) {
1606
+ if (!store.micAcquiring && store.indicatorState !== "finishing" && store.indicatorState !== "done" && store.indicatorState !== "error") {
1607
+ void startRecording(store, ctx);
1608
+ }
1609
+ return;
1610
+ }
1547
1611
  const ok = toggleMute(store);
1548
1612
  if (!ok) return;
1549
1613
  if (store.muted) showMuteToast(store);
@@ -1672,7 +1736,7 @@ function userTest(options = {}) {
1672
1736
  }
1673
1737
  };
1674
1738
  }
1675
- var __test__ = { getTestSlug, pickMimeType, isMediaRecorderSupported };
1739
+ var __test__ = { getTestSlug, pickMimeType, isMediaRecorderSupported, micChipState };
1676
1740
 
1677
1741
  export { __test__, userTest };
1678
1742
  //# sourceMappingURL=user-test.js.map