@usero/sdk 1.0.2 → 1.1.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.
@@ -147,7 +147,7 @@ async function uploadChunkWithRetry(apiUrl, sessionId, index, blob, logger, maxA
147
147
  }
148
148
  return false;
149
149
  }
150
- function buildIndicator(host, store, onFinish, onToggleTasks) {
150
+ function buildIndicator(host, store, callbacks) {
151
151
  const root = host.attachShadow({ mode: "closed" });
152
152
  const style = document.createElement("style");
153
153
  style.textContent = `
@@ -162,19 +162,21 @@ function buildIndicator(host, store, onFinish, onToggleTasks) {
162
162
  color: #fff;
163
163
  }
164
164
  .bar {
165
- display: inline-flex; align-items: center; gap: 10px;
166
- padding: 8px 14px 8px 12px;
167
- background: rgba(17,17,17,0.78);
165
+ display: inline-flex; align-items: center; gap: 6px;
166
+ padding: 6px 8px 6px 6px;
167
+ background: rgba(17,17,17,0.82);
168
+ border: 1px solid rgba(255,255,255,0.08);
168
169
  border-radius: 999px;
169
- box-shadow: 0 8px 24px rgba(0,0,0,0.18);
170
- backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
170
+ box-shadow: 0 8px 24px rgba(0,0,0,0.22);
171
+ backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
171
172
  max-width: 100%;
172
173
  }
173
174
  .panel {
174
- background: rgba(17,17,17,0.88);
175
+ background: rgba(17,17,17,0.92);
176
+ border: 1px solid rgba(255,255,255,0.08);
175
177
  border-radius: 14px; padding: 12px 14px 12px 8px;
176
178
  line-height: 1.45;
177
- box-shadow: 0 12px 32px rgba(0,0,0,0.28);
179
+ box-shadow: 0 12px 32px rgba(0,0,0,0.32);
178
180
  max-height: min(60vh, 480px);
179
181
  max-width: min(420px, calc(100vw - 32px));
180
182
  width: max-content; overflow-y: auto;
@@ -183,29 +185,156 @@ function buildIndicator(host, store, onFinish, onToggleTasks) {
183
185
  .panel ol { margin: 0; padding-left: 26px; }
184
186
  .panel li { margin: 0 0 8px; }
185
187
  .panel li:last-child { margin: 0; }
188
+
189
+ /* Mic chip: pill-within-pill with dot + label, doubles as mute toggle. */
190
+ .mic {
191
+ display: inline-flex; align-items: center; gap: 7px;
192
+ min-height: 32px; min-width: 44px;
193
+ padding: 0 11px 0 10px;
194
+ border-radius: 999px;
195
+ background: rgba(255,255,255,0.06);
196
+ border: 1px solid rgba(255,255,255,0.06);
197
+ color: #fff; font: inherit;
198
+ cursor: pointer; appearance: none;
199
+ transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
200
+ }
201
+ .mic:hover { background: rgba(255,255,255,0.12); }
202
+ .mic:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
203
+ .mic[data-mic-state="muted"] {
204
+ background: rgba(251, 191, 36, 0.18);
205
+ border-color: rgba(251, 191, 36, 0.45);
206
+ color: #fcd34d;
207
+ }
208
+ .mic[data-mic-state="muted"]:hover { background: rgba(251, 191, 36, 0.26); }
209
+ .mic[data-mic-state="none"] {
210
+ background: rgba(255,255,255,0.04);
211
+ color: rgba(255,255,255,0.55);
212
+ cursor: default;
213
+ }
214
+ .mic[data-mic-state="none"]:hover { background: rgba(255,255,255,0.04); }
215
+ .mic-icon { width: 13px; height: 13px; display: inline-block; flex-shrink: 0; }
216
+ .mic-label { font-weight: 500; letter-spacing: 0.01em; white-space: nowrap; }
217
+
186
218
  .dot {
187
- width: 8px; height: 8px; border-radius: 50%;
219
+ width: 7px; height: 7px; border-radius: 50%;
188
220
  background: #ef4444;
189
221
  box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6);
190
222
  animation: pulse 1.6s ease-out infinite;
223
+ flex-shrink: 0;
191
224
  }
192
225
  .dot[data-state="no-audio"] { background: #fbbf24; animation: none; }
193
226
  .dot[data-state="finishing"] { background: #fbbf24; animation: none; }
194
227
  .dot[data-state="done"] { background: #10b981; animation: none; }
195
228
  .dot[data-state="error"] { background: #ef4444; animation: none; }
196
- .label { font-weight: 500; letter-spacing: 0.01em; }
197
- .spacer { width: 1px; height: 16px; background: rgba(255,255,255,0.18); margin: 0 2px; }
229
+
198
230
  .btn {
199
- appearance: none; border: 0; background: rgba(255,255,255,0.12);
231
+ appearance: none; border: 0; background: rgba(255,255,255,0.10);
200
232
  color: #fff; font: inherit; font-weight: 600;
201
- padding: 6px 12px; border-radius: 999px; cursor: pointer;
202
- transition: background 0.15s ease;
233
+ padding: 6px 12px; min-height: 32px; border-radius: 999px; cursor: pointer;
234
+ transition: background 0.15s ease, transform 0.06s ease;
235
+ display: inline-flex; align-items: center; gap: 6px;
203
236
  }
204
- .btn:hover { background: rgba(255,255,255,0.22); }
237
+ .btn:hover { background: rgba(255,255,255,0.20); }
238
+ .btn:active { transform: scale(0.97); }
205
239
  .btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
206
240
  .btn[disabled] { opacity: 0.5; cursor: progress; }
207
241
  .tasks-btn[aria-expanded="true"] { background: rgba(255,255,255,0.24); }
208
- @media (max-width: 420px) { .btn { padding: 9px 14px; min-height: 40px; } }
242
+
243
+ /* Note button: icon-only, matches mic chip footprint */
244
+ .note-btn {
245
+ width: 32px; min-height: 32px; padding: 0;
246
+ background: rgba(255,255,255,0.06);
247
+ border: 1px solid rgba(255,255,255,0.06);
248
+ border-radius: 999px;
249
+ display: inline-flex; align-items: center; justify-content: center; gap: 4px;
250
+ color: #fff; font: inherit; cursor: pointer; appearance: none;
251
+ transition: background 0.15s ease, border-color 0.15s ease, width 0.18s ease;
252
+ overflow: hidden;
253
+ }
254
+ .note-btn:hover { background: rgba(255,255,255,0.14); }
255
+ .note-btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
256
+ .note-btn[data-has-notes="true"] { width: auto; padding: 0 10px 0 9px; gap: 6px; }
257
+ .note-btn[aria-expanded="true"] { background: rgba(255,255,255,0.22); border-color: rgba(255,255,255,0.18); }
258
+ .note-icon { width: 14px; height: 14px; display: inline-block; }
259
+ .note-count { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
260
+
261
+ .spacer { width: 1px; height: 18px; background: rgba(255,255,255,0.14); margin: 0 1px; }
262
+
263
+ @media (max-width: 480px) {
264
+ .bar { gap: 4px; padding: 5px 6px 5px 5px; }
265
+ .btn { padding: 7px 12px; min-height: 38px; }
266
+ .mic, .note-btn { min-height: 38px; }
267
+ .note-btn { width: 38px; }
268
+ .note-btn[data-has-notes="true"] { width: auto; }
269
+ }
270
+
271
+ /* First-mute helper toast: sits above the pill, auto-dismisses */
272
+ .toast {
273
+ background: rgba(17,17,17,0.92);
274
+ border: 1px solid rgba(251, 191, 36, 0.45);
275
+ color: #fff;
276
+ padding: 9px 14px; border-radius: 12px;
277
+ max-width: min(340px, calc(100vw - 32px));
278
+ box-shadow: 0 12px 28px rgba(0,0,0,0.28);
279
+ text-align: center; line-height: 1.4;
280
+ animation: toast-in 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
281
+ }
282
+ .toast[data-leaving="true"] { animation: toast-out 0.24s ease forwards; }
283
+ .toast strong { color: #fcd34d; font-weight: 600; }
284
+ @keyframes toast-in {
285
+ from { opacity: 0; transform: translateY(6px); }
286
+ to { opacity: 1; transform: translateY(0); }
287
+ }
288
+ @keyframes toast-out {
289
+ to { opacity: 0; transform: translateY(4px); }
290
+ }
291
+
292
+ /* Notes popover */
293
+ .note-popover {
294
+ background: rgba(17,17,17,0.94);
295
+ border: 1px solid rgba(255,255,255,0.10);
296
+ border-radius: 14px; padding: 12px;
297
+ width: min(340px, calc(100vw - 32px));
298
+ box-shadow: 0 18px 40px rgba(0,0,0,0.36);
299
+ display: flex; flex-direction: column; gap: 10px;
300
+ animation: pop-in 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
301
+ }
302
+ .note-popover[hidden] { display: none; }
303
+ @keyframes pop-in {
304
+ from { opacity: 0; transform: translateY(6px) scale(0.98); }
305
+ to { opacity: 1; transform: translateY(0) scale(1); }
306
+ }
307
+ .note-head {
308
+ color: rgba(255,255,255,0.7); font-size: 12px;
309
+ font-weight: 500; letter-spacing: 0.02em;
310
+ }
311
+ .note-textarea {
312
+ width: 100%; box-sizing: border-box;
313
+ min-height: 80px; resize: vertical;
314
+ padding: 10px 11px;
315
+ background: rgba(0,0,0,0.35);
316
+ border: 1px solid rgba(255,255,255,0.10);
317
+ border-radius: 10px;
318
+ color: #fff; font: inherit; font-size: 13.5px;
319
+ line-height: 1.45;
320
+ transition: border-color 0.15s ease;
321
+ }
322
+ .note-textarea:focus { outline: none; border-color: rgba(255,255,255,0.32); }
323
+ .note-textarea::placeholder { color: rgba(255,255,255,0.42); }
324
+ .note-actions {
325
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
326
+ }
327
+ .note-actions .hint {
328
+ color: rgba(255,255,255,0.45); font-size: 11px;
329
+ }
330
+ .note-actions .group { display: inline-flex; gap: 6px; }
331
+ .note-actions .btn { padding: 6px 12px; font-size: 12.5px; min-height: 32px; }
332
+ .btn-primary { background: #fff !important; color: #111; }
333
+ .btn-primary:hover { background: rgba(255,255,255,0.85) !important; }
334
+ .btn-ghost { background: transparent; color: rgba(255,255,255,0.7); }
335
+ .btn-ghost:hover { background: rgba(255,255,255,0.10); color: #fff; }
336
+
337
+ /* Thanks overlay + end-of-test note */
209
338
  .thanks {
210
339
  position: fixed; inset: 0;
211
340
  display: grid; place-items: center;
@@ -220,12 +349,14 @@ function buildIndicator(host, store, onFinish, onToggleTasks) {
220
349
  }
221
350
  .thanks-card {
222
351
  background: #fff; color: #111;
223
- border-radius: 16px; padding: 28px 24px;
224
- max-width: 360px; width: 100%;
352
+ border-radius: 18px; padding: 28px 24px;
353
+ max-width: 420px; width: 100%;
225
354
  box-shadow: 0 20px 50px rgba(0,0,0,0.25);
355
+ text-align: left;
226
356
  }
227
- .thanks h2 { margin: 0 0 8px; font-size: 20px; }
228
- .thanks p { margin: 0; font-size: 14px; line-height: 1.45; color: #4b5563; }
357
+ .thanks-card .head { text-align: center; }
358
+ .thanks h2 { margin: 0 0 6px; font-size: 20px; }
359
+ .thanks .lede { margin: 0 0 18px; font-size: 14px; line-height: 1.45; color: #4b5563; text-align: center; }
229
360
  .thanks .check {
230
361
  width: 44px; height: 44px; border-radius: 50%;
231
362
  background: #10b981; color: #fff;
@@ -233,6 +364,50 @@ function buildIndicator(host, store, onFinish, onToggleTasks) {
233
364
  margin: 0 auto 12px;
234
365
  font-size: 22px;
235
366
  }
367
+ .thanks .end-label {
368
+ display: block; margin: 0 0 8px;
369
+ font-size: 13px; font-weight: 500; color: #374151;
370
+ }
371
+ .thanks .end-textarea {
372
+ width: 100%; box-sizing: border-box;
373
+ min-height: 96px; resize: vertical;
374
+ padding: 11px 12px;
375
+ background: #f9fafb;
376
+ border: 1px solid #e5e7eb;
377
+ border-radius: 10px;
378
+ font: inherit; font-size: 14px; line-height: 1.5;
379
+ color: #111;
380
+ transition: border-color 0.15s ease, background 0.15s ease;
381
+ }
382
+ .thanks .end-textarea:focus {
383
+ outline: none; border-color: #111; background: #fff;
384
+ }
385
+ .thanks .end-textarea::placeholder { color: #9ca3af; }
386
+ .thanks .end-actions {
387
+ display: flex; gap: 10px; margin-top: 14px;
388
+ }
389
+ .thanks .end-actions button {
390
+ flex: 1;
391
+ appearance: none; border: 1px solid #e5e7eb;
392
+ background: #fff; color: #111;
393
+ padding: 11px 14px; border-radius: 10px;
394
+ font: inherit; font-weight: 600; font-size: 14px;
395
+ cursor: pointer;
396
+ transition: background 0.15s ease, border-color 0.15s ease;
397
+ }
398
+ .thanks .end-actions button:hover { background: #f3f4f6; }
399
+ .thanks .end-actions button.primary {
400
+ background: #111; color: #fff; border-color: #111;
401
+ }
402
+ .thanks .end-actions button.primary:hover { background: #1f2937; border-color: #1f2937; }
403
+ .thanks .end-actions button:focus-visible { outline: 2px solid #111; outline-offset: 2px; }
404
+ .thanks .end-hint {
405
+ margin: 10px 0 0; font-size: 11.5px; color: #9ca3af; text-align: center;
406
+ }
407
+ .thanks .end-sent {
408
+ margin-top: 14px; text-align: center; color: #4b5563; font-size: 13px;
409
+ }
410
+
236
411
  @keyframes pulse {
237
412
  0% { box-shadow: 0 0 0 0 rgba(239,68,68,0.55); }
238
413
  70% { box-shadow: 0 0 0 10px rgba(239,68,68,0); }
@@ -240,6 +415,7 @@ function buildIndicator(host, store, onFinish, onToggleTasks) {
240
415
  }
241
416
  @media (prefers-reduced-motion: reduce) {
242
417
  .dot { animation: none; }
418
+ .toast, .note-popover { animation: none; }
243
419
  }
244
420
  `;
245
421
  const anchor = document.createElement("div");
@@ -247,34 +423,66 @@ function buildIndicator(host, store, onFinish, onToggleTasks) {
247
423
  const panel = document.createElement("div");
248
424
  panel.className = "panel";
249
425
  panel.hidden = true;
426
+ const toastSlot = document.createElement("div");
427
+ toastSlot.className = "toast-slot";
428
+ const notePopover = document.createElement("div");
429
+ notePopover.className = "note-popover";
430
+ notePopover.hidden = true;
250
431
  const bar = document.createElement("div");
251
432
  bar.className = "bar";
252
433
  bar.setAttribute("role", "status");
253
434
  bar.setAttribute("aria-live", "polite");
435
+ const micBtn = document.createElement("button");
436
+ micBtn.type = "button";
437
+ micBtn.className = "mic";
438
+ micBtn.setAttribute("data-mic-state", "recording");
439
+ micBtn.setAttribute("aria-pressed", "false");
440
+ micBtn.setAttribute("aria-label", "Mute microphone");
254
441
  const dot = document.createElement("span");
255
442
  dot.className = "dot";
256
443
  dot.setAttribute("data-state", store.indicatorState);
257
- const label = document.createElement("span");
258
- label.className = "label";
259
- label.textContent = "Recording";
444
+ const micIcon = document.createElement("span");
445
+ micIcon.className = "mic-icon";
446
+ micIcon.innerHTML = MIC_ICON_SVG;
447
+ micIcon.setAttribute("aria-hidden", "true");
448
+ const micLabel = document.createElement("span");
449
+ micLabel.className = "mic-label";
450
+ micLabel.textContent = "Recording";
451
+ micBtn.appendChild(dot);
452
+ micBtn.appendChild(micIcon);
453
+ micBtn.appendChild(micLabel);
454
+ micBtn.addEventListener("click", callbacks.onToggleMute);
455
+ bar.appendChild(micBtn);
456
+ const noteBtn = document.createElement("button");
457
+ noteBtn.type = "button";
458
+ noteBtn.className = "note-btn";
459
+ noteBtn.setAttribute("aria-label", "Add a timestamped note");
460
+ noteBtn.setAttribute("aria-expanded", "false");
461
+ noteBtn.setAttribute("data-has-notes", "false");
462
+ noteBtn.innerHTML = `<span class="note-icon" aria-hidden="true">${NOTE_ICON_SVG}</span><span class="note-count" hidden></span>`;
463
+ noteBtn.addEventListener("click", callbacks.onOpenNote);
464
+ bar.appendChild(noteBtn);
260
465
  const spacer = document.createElement("span");
261
466
  spacer.className = "spacer";
262
- bar.appendChild(dot);
263
- bar.appendChild(label);
264
467
  bar.appendChild(spacer);
265
468
  const btn = document.createElement("button");
266
469
  btn.type = "button";
267
470
  btn.className = "btn finish-btn";
268
471
  btn.textContent = "Finish";
269
- btn.addEventListener("click", onFinish);
472
+ btn.addEventListener("click", callbacks.onFinish);
270
473
  bar.appendChild(btn);
271
- if (store.tasks.length > 0) installTasksToggle(bar, btn, store, onToggleTasks);
474
+ if (store.tasks.length > 0) installTasksToggle(bar, btn, store, callbacks.onToggleTasks);
272
475
  anchor.appendChild(panel);
476
+ anchor.appendChild(toastSlot);
477
+ anchor.appendChild(notePopover);
273
478
  anchor.appendChild(bar);
274
479
  root.appendChild(style);
275
480
  root.appendChild(anchor);
276
481
  return root;
277
482
  }
483
+ var MIC_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="13" height="13"><path d="M8 1.5a2 2 0 0 0-2 2v4a2 2 0 1 0 4 0v-4a2 2 0 0 0-2-2Z" fill="currentColor"/><path d="M4 7.5a4 4 0 0 0 8 0M8 11.5v3M5.5 14.5h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
484
+ var MIC_MUTED_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="13" height="13"><path d="M8 1.5a2 2 0 0 0-2 2v3.2L10 11V3.5a2 2 0 0 0-2-2Z" fill="currentColor"/><path d="M4 7.5a4 4 0 0 0 6.5 3.12M12 7.5a4 4 0 0 1-.3 1.5M8 11.5v3M5.5 14.5h5M2 2l12 12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
485
+ var NOTE_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path d="M3 3.5A1.5 1.5 0 0 1 4.5 2h7A1.5 1.5 0 0 1 13 3.5V10a1.5 1.5 0 0 1-1.5 1.5H7L4 14v-2.5h-.5A1.5 1.5 0 0 1 2 10V3.5A1.5 1.5 0 0 1 3.5 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
278
486
  function installTasksToggle(bar, finishBtn, store, onToggleTasks) {
279
487
  const tasksBtn = document.createElement("button");
280
488
  tasksBtn.type = "button";
@@ -317,53 +525,295 @@ function writeTasksPanelOpen(open) {
317
525
  } catch {
318
526
  }
319
527
  }
528
+ function micChipState(store) {
529
+ if (store.indicatorState === "finishing" || store.indicatorState === "done" || store.indicatorState === "error") {
530
+ return "inactive";
531
+ }
532
+ if (!store.hasMicPermission) return "none";
533
+ return store.muted ? "muted" : "recording";
534
+ }
320
535
  function renderIndicatorState(store) {
321
536
  const root = store.indicatorRoot;
322
537
  if (!root) return;
323
538
  const dot = root.querySelector(".dot");
324
- const label = root.querySelector(".label");
539
+ const mic = root.querySelector(".mic");
540
+ const micIcon = root.querySelector(".mic-icon");
541
+ const micLabel = root.querySelector(".mic-label");
325
542
  const btn = root.querySelector(".finish-btn");
326
- if (!(dot instanceof HTMLElement) || !(label instanceof HTMLElement) || !btn) return;
543
+ if (!(dot instanceof HTMLElement) || !mic || !(micIcon instanceof HTMLElement) || !(micLabel instanceof HTMLElement) || !btn) return;
327
544
  dot.setAttribute("data-state", store.indicatorState);
545
+ const chipState = micChipState(store);
546
+ mic.setAttribute("data-mic-state", chipState === "inactive" ? "none" : chipState);
328
547
  switch (store.indicatorState) {
329
548
  case "recording":
330
- label.textContent = "Recording";
331
- btn.textContent = "Finish";
332
- btn.disabled = false;
333
- break;
334
549
  case "no-audio":
335
- label.textContent = "No mic, replay only";
336
550
  btn.textContent = "Finish";
337
551
  btn.disabled = false;
338
552
  break;
339
553
  case "finishing":
340
- label.textContent = "Saving";
341
554
  btn.textContent = "Saving";
342
555
  btn.disabled = true;
343
556
  break;
344
557
  case "done":
345
- label.textContent = "Saved";
346
558
  btn.textContent = "Done";
347
559
  btn.disabled = true;
348
560
  break;
349
561
  case "error":
350
- label.textContent = "Save failed";
351
562
  btn.textContent = "Retry";
352
563
  btn.disabled = false;
353
564
  break;
354
565
  }
566
+ switch (chipState) {
567
+ case "recording":
568
+ micIcon.innerHTML = MIC_ICON_SVG;
569
+ micLabel.textContent = "Recording";
570
+ mic.setAttribute("aria-label", "Mute microphone");
571
+ mic.setAttribute("aria-pressed", "false");
572
+ mic.removeAttribute("tabindex");
573
+ break;
574
+ case "muted":
575
+ micIcon.innerHTML = MIC_MUTED_ICON_SVG;
576
+ micLabel.textContent = "Muted";
577
+ mic.setAttribute("aria-label", "Unmute microphone");
578
+ mic.setAttribute("aria-pressed", "true");
579
+ mic.removeAttribute("tabindex");
580
+ break;
581
+ case "none":
582
+ micIcon.innerHTML = MIC_MUTED_ICON_SVG;
583
+ micLabel.textContent = "No mic, replay only";
584
+ mic.setAttribute("aria-label", "Microphone not granted, replay only");
585
+ mic.setAttribute("aria-pressed", "false");
586
+ mic.setAttribute("tabindex", "-1");
587
+ break;
588
+ case "inactive":
589
+ micIcon.innerHTML = MIC_ICON_SVG;
590
+ micLabel.textContent = store.indicatorState === "finishing" ? "Saving" : store.indicatorState === "done" ? "Saved" : "Save failed";
591
+ mic.setAttribute("aria-label", "Recording stopped");
592
+ mic.setAttribute("aria-pressed", "false");
593
+ mic.setAttribute("tabindex", "-1");
594
+ break;
595
+ }
596
+ }
597
+ function renderNotesCount(store) {
598
+ const root = store.indicatorRoot;
599
+ if (!root) return;
600
+ const noteBtn = root.querySelector(".note-btn");
601
+ const count = root.querySelector(".note-count");
602
+ if (!(noteBtn instanceof HTMLElement) || !(count instanceof HTMLElement)) return;
603
+ const n = store.notes.length;
604
+ noteBtn.setAttribute("data-has-notes", n > 0 ? "true" : "false");
605
+ if (n > 0) {
606
+ count.textContent = String(n);
607
+ count.hidden = false;
608
+ noteBtn.setAttribute("aria-label", `Add a timestamped note (${n} so far)`);
609
+ } else {
610
+ count.textContent = "";
611
+ count.hidden = true;
612
+ noteBtn.setAttribute("aria-label", "Add a timestamped note");
613
+ }
355
614
  }
356
- function showThanksScreen(root) {
615
+ function showMuteToast(store) {
616
+ if (store.muteToastShown) return;
617
+ store.muteToastShown = true;
618
+ const root = store.indicatorRoot;
619
+ if (!root) return;
620
+ const slot = root.querySelector(".toast-slot");
621
+ if (!(slot instanceof HTMLElement)) return;
622
+ slot.innerHTML = "";
623
+ const toast = document.createElement("div");
624
+ toast.className = "toast";
625
+ toast.setAttribute("role", "status");
626
+ toast.innerHTML = `<strong>Mic off.</strong> Screen is still recording. Tap to unmute.`;
627
+ slot.appendChild(toast);
628
+ const outer = window.setTimeout(() => {
629
+ if (!toast.isConnected) return;
630
+ toast.setAttribute("data-leaving", "true");
631
+ const inner = window.setTimeout(() => {
632
+ if (toast.isConnected) toast.remove();
633
+ }, 260);
634
+ store.muteToastTimers.push(inner);
635
+ }, 3e3);
636
+ store.muteToastTimers.push(outer);
637
+ }
638
+ function openNotePopover(store, onSave, onCancel) {
639
+ const root = store.indicatorRoot;
640
+ if (!root) return;
641
+ const pop = root.querySelector(".note-popover");
642
+ const noteBtn = root.querySelector(".note-btn");
643
+ if (!(pop instanceof HTMLElement) || !(noteBtn instanceof HTMLElement)) return;
644
+ store.notesPopoverOpen = true;
645
+ store.notePopoverAtMs = Date.now() - store.startedAt;
646
+ noteBtn.setAttribute("aria-expanded", "true");
647
+ pop.innerHTML = "";
648
+ const head = document.createElement("div");
649
+ head.className = "note-head";
650
+ head.innerHTML = `<span>Add a note</span>`;
651
+ const form = document.createElement("form");
652
+ form.style.cssText = "display:flex;flex-direction:column;gap:10px;margin:0;";
653
+ form.noValidate = true;
654
+ const ta = document.createElement("textarea");
655
+ ta.className = "note-textarea";
656
+ ta.placeholder = "What just happened? Confusing? Surprising? Broken?";
657
+ ta.rows = 3;
658
+ ta.setAttribute("aria-label", "Note text");
659
+ const actions = document.createElement("div");
660
+ actions.className = "note-actions";
661
+ const hint = document.createElement("span");
662
+ hint.className = "hint";
663
+ hint.innerHTML = '<kbd style="font-family:inherit">Cmd</kbd>+Enter to save';
664
+ const group = document.createElement("div");
665
+ group.className = "group";
666
+ const cancelBtn = document.createElement("button");
667
+ cancelBtn.type = "button";
668
+ cancelBtn.className = "btn btn-ghost";
669
+ cancelBtn.textContent = "Cancel";
670
+ const saveBtn = document.createElement("button");
671
+ saveBtn.type = "submit";
672
+ saveBtn.className = "btn btn-primary";
673
+ saveBtn.textContent = "Save";
674
+ group.appendChild(cancelBtn);
675
+ group.appendChild(saveBtn);
676
+ actions.appendChild(hint);
677
+ actions.appendChild(group);
678
+ form.appendChild(ta);
679
+ form.appendChild(actions);
680
+ pop.appendChild(head);
681
+ pop.appendChild(form);
682
+ pop.hidden = false;
683
+ const submit = () => {
684
+ const text = ta.value.trim();
685
+ if (!text) {
686
+ onCancel();
687
+ return;
688
+ }
689
+ onSave(text);
690
+ };
691
+ form.addEventListener("submit", (e) => {
692
+ e.preventDefault();
693
+ submit();
694
+ });
695
+ cancelBtn.addEventListener("click", () => onCancel());
696
+ ta.addEventListener("keydown", (e) => {
697
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
698
+ e.preventDefault();
699
+ submit();
700
+ } else if (e.key === "Escape") {
701
+ e.preventDefault();
702
+ onCancel();
703
+ }
704
+ });
705
+ window.requestAnimationFrame(() => {
706
+ ta.focus({ preventScroll: true });
707
+ });
708
+ }
709
+ function closeNotePopover(store) {
710
+ const root = store.indicatorRoot;
711
+ if (!root) return;
712
+ const pop = root.querySelector(".note-popover");
713
+ const noteBtn = root.querySelector(".note-btn");
714
+ if (pop instanceof HTMLElement) {
715
+ pop.hidden = true;
716
+ pop.innerHTML = "";
717
+ }
718
+ if (noteBtn instanceof HTMLElement) noteBtn.setAttribute("aria-expanded", "false");
719
+ store.notesPopoverOpen = false;
720
+ store.notePopoverAtMs = null;
721
+ }
722
+ function showThanksScreen(root, opts) {
357
723
  const overlay = document.createElement("div");
358
724
  overlay.className = "thanks";
359
- overlay.innerHTML = `
360
- <div class="thanks-card">
361
- <div class="check" aria-hidden="true">&#10003;</div>
362
- <h2>Thanks for testing</h2>
363
- <p>Your session was saved. You can close this tab.</p>
725
+ const card = document.createElement("div");
726
+ card.className = "thanks-card";
727
+ const head = document.createElement("div");
728
+ head.className = "head";
729
+ head.innerHTML = `
730
+ <div class="check" aria-hidden="true">&#10003;</div>
731
+ <h2>Thanks for testing</h2>
732
+ <p class="lede">Your session was saved. One last thing if you have a moment.</p>
733
+ `;
734
+ const form = document.createElement("form");
735
+ form.noValidate = true;
736
+ form.innerHTML = `
737
+ <label class="end-label" for="usero-end-note">Anything you would add?</label>
738
+ <textarea
739
+ id="usero-end-note"
740
+ class="end-textarea"
741
+ rows="4"
742
+ placeholder="Confusing bits, things you liked, what you'd change..."
743
+ ></textarea>
744
+ <div class="end-actions">
745
+ <button type="button" class="skip">Skip</button>
746
+ <button type="submit" class="primary">Send</button>
364
747
  </div>
748
+ <p class="end-hint">Cmd or Ctrl plus Enter to send. Either button is fine.</p>
365
749
  `;
750
+ card.appendChild(head);
751
+ card.appendChild(form);
752
+ overlay.appendChild(card);
366
753
  root.appendChild(overlay);
754
+ const ta = form.querySelector("#usero-end-note");
755
+ const skipBtn = form.querySelector("button.skip");
756
+ if (!ta || !skipBtn) return;
757
+ const swapToSent = (message) => {
758
+ form.remove();
759
+ const sent = document.createElement("p");
760
+ sent.className = "end-sent";
761
+ sent.textContent = message;
762
+ card.appendChild(sent);
763
+ };
764
+ const ERROR_CLASS = "end-error";
765
+ const showError = (message) => {
766
+ const prior = form.querySelector(`.${ERROR_CLASS}`);
767
+ if (prior) prior.remove();
768
+ const err = document.createElement("p");
769
+ err.className = ERROR_CLASS;
770
+ err.textContent = message;
771
+ err.setAttribute("role", "alert");
772
+ err.style.cssText = "margin:10px 0 0;font-size:12.5px;color:#b91c1c;text-align:center;";
773
+ form.appendChild(err);
774
+ };
775
+ const submit = async () => {
776
+ const text = ta.value.trim();
777
+ ta.disabled = true;
778
+ skipBtn.disabled = true;
779
+ const submitBtn = form.querySelector("button.primary");
780
+ if (submitBtn) submitBtn.disabled = true;
781
+ if (text) {
782
+ try {
783
+ await Promise.race([
784
+ Promise.resolve(opts.onSubmitNote(text)),
785
+ new Promise((_, reject) => {
786
+ window.setTimeout(() => reject(new Error("timeout")), 3e4);
787
+ })
788
+ ]);
789
+ swapToSent("Thanks. You can close this tab.");
790
+ } catch {
791
+ ta.disabled = false;
792
+ skipBtn.disabled = false;
793
+ if (submitBtn) submitBtn.disabled = false;
794
+ showError("Couldn't save your note. Try again?");
795
+ }
796
+ } else {
797
+ swapToSent("All good. You can close this tab.");
798
+ }
799
+ };
800
+ form.addEventListener("submit", (e) => {
801
+ e.preventDefault();
802
+ void submit();
803
+ });
804
+ skipBtn.addEventListener("click", () => {
805
+ ta.value = "";
806
+ void submit();
807
+ });
808
+ ta.addEventListener("keydown", (e) => {
809
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
810
+ e.preventDefault();
811
+ void submit();
812
+ }
813
+ });
814
+ window.requestAnimationFrame(() => {
815
+ ta.focus({ preventScroll: true });
816
+ });
367
817
  }
368
818
  function parseTasks(raw) {
369
819
  if (!Array.isArray(raw)) return [];
@@ -390,12 +840,26 @@ async function createSession(apiUrl, slug, testerName) {
390
840
  return null;
391
841
  }
392
842
  }
393
- async function finaliseSession(apiUrl, sessionId, durationSeconds) {
843
+ async function finaliseSession(apiUrl, sessionId, durationSeconds, extras = {}) {
394
844
  try {
845
+ const body = {
846
+ durationSeconds: Math.max(0, Math.round(durationSeconds))
847
+ };
848
+ if (extras.mutedSegments && extras.mutedSegments.length > 0) {
849
+ body.mutedSegments = extras.mutedSegments;
850
+ }
851
+ const trimmedEndNote = extras.endNote?.trim();
852
+ if (trimmedEndNote) body.endNote = trimmedEndNote;
853
+ if (extras.notes && extras.notes.length > 0) {
854
+ body.notes = extras.notes.slice(0, 200).map((n) => ({
855
+ atMs: Math.max(0, Math.round(n.atMs)),
856
+ text: n.text
857
+ }));
858
+ }
395
859
  const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/finalise`, {
396
860
  method: "POST",
397
861
  headers: { "Content-Type": "application/json" },
398
- body: JSON.stringify({ durationSeconds: Math.max(0, Math.round(durationSeconds)) }),
862
+ body: JSON.stringify(body),
399
863
  keepalive: true
400
864
  });
401
865
  return res.ok;
@@ -403,6 +867,36 @@ async function finaliseSession(apiUrl, sessionId, durationSeconds) {
403
867
  return false;
404
868
  }
405
869
  }
870
+ async function postNoteOnce(apiUrl, sessionId, atMs, text, logger) {
871
+ try {
872
+ const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/notes`, {
873
+ method: "POST",
874
+ headers: { "Content-Type": "application/json" },
875
+ body: JSON.stringify({ atMs: Math.max(0, Math.round(atMs)), text }),
876
+ keepalive: true
877
+ });
878
+ if (!res.ok) {
879
+ logger.warn(`note POST rejected with ${res.status}`);
880
+ return { ok: false, transient: res.status >= 500 || res.status === 408 || res.status === 429 };
881
+ }
882
+ let id;
883
+ try {
884
+ const json = await res.json();
885
+ if (typeof json.id === "string") id = json.id;
886
+ } catch {
887
+ }
888
+ return { ok: true, id, transient: false };
889
+ } catch (err) {
890
+ logger.warn("note POST failed", err);
891
+ return { ok: false, transient: true };
892
+ }
893
+ }
894
+ async function postNoteWithRetry(apiUrl, sessionId, atMs, text, logger) {
895
+ const first = await postNoteOnce(apiUrl, sessionId, atMs, text, logger);
896
+ if (first.ok || !first.transient) return first;
897
+ await new Promise((resolve) => setTimeout(resolve, 400 + Math.floor(Math.random() * 200)));
898
+ return postNoteOnce(apiUrl, sessionId, atMs, text, logger);
899
+ }
406
900
  async function flushPendingFromIdb(store, ctx) {
407
901
  if (!store.sessionId) return;
408
902
  const pending = await idbListChunks(store.sessionId);
@@ -451,6 +945,7 @@ async function startRecording(store, ctx) {
451
945
  return;
452
946
  }
453
947
  store.stream = stream;
948
+ store.hasMicPermission = true;
454
949
  const mimeType = pickMimeType();
455
950
  let recorder;
456
951
  try {
@@ -474,6 +969,34 @@ async function startRecording(store, ctx) {
474
969
  });
475
970
  recorder.start(store.options.chunkSeconds * 1e3);
476
971
  }
972
+ function toggleMute(store) {
973
+ if (!store.stream || !store.hasMicPermission) return false;
974
+ const tracks = store.stream.getAudioTracks();
975
+ if (tracks.length === 0) return false;
976
+ const nowMs = Date.now() - store.startedAt;
977
+ if (!store.muted) {
978
+ for (const t of tracks) t.enabled = false;
979
+ store.muted = true;
980
+ store.mutedSinceMs = nowMs;
981
+ } else {
982
+ const startMs = store.mutedSinceMs ?? nowMs;
983
+ if (nowMs > startMs) {
984
+ store.mutedSegments.push({ startMs, endMs: nowMs });
985
+ }
986
+ store.mutedSinceMs = null;
987
+ store.muted = false;
988
+ for (const t of tracks) t.enabled = true;
989
+ }
990
+ return true;
991
+ }
992
+ function flushMuteIfActive(store) {
993
+ if (!store.muted || store.mutedSinceMs === null) return;
994
+ const nowMs = Date.now() - store.startedAt;
995
+ if (nowMs > store.mutedSinceMs) {
996
+ store.mutedSegments.push({ startMs: store.mutedSinceMs, endMs: nowMs });
997
+ }
998
+ store.mutedSinceMs = null;
999
+ }
477
1000
  function stopRecording(store) {
478
1001
  const recorder = store.recorder;
479
1002
  if (recorder && recorder.state !== "inactive") {
@@ -494,22 +1017,50 @@ function stopRecording(store) {
494
1017
  }
495
1018
  async function finishFlow(store, ctx, opts) {
496
1019
  if (store.cancelled) return;
1020
+ if (store.finishFlowRan) return;
497
1021
  if (store.indicatorState === "finishing" || store.indicatorState === "done") return;
1022
+ store.finishFlowRan = true;
498
1023
  store.indicatorState = "finishing";
1024
+ flushMuteIfActive(store);
499
1025
  renderIndicatorState(store);
500
1026
  stopRecording(store);
501
1027
  await store.uploadQueue;
502
1028
  await flushPendingFromIdb(store, ctx);
503
1029
  const durationSeconds = (Date.now() - store.startedAt) / 1e3;
504
1030
  if (store.sessionId) {
505
- const ok = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds);
1031
+ const unackedNotes = store.notes.filter((n) => !n.acked).map((n) => ({ atMs: n.atMs, text: n.text }));
1032
+ const ok = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds, {
1033
+ mutedSegments: store.mutedSegments,
1034
+ notes: unackedNotes
1035
+ });
1036
+ if (ok) {
1037
+ for (const n of store.notes) {
1038
+ if (!n.acked) n.acked = true;
1039
+ }
1040
+ }
506
1041
  store.indicatorState = ok ? "done" : "error";
507
1042
  } else {
508
1043
  store.indicatorState = "error";
509
1044
  }
510
1045
  renderIndicatorState(store);
511
1046
  if (opts.showThanks && store.indicatorRoot && store.indicatorState === "done") {
512
- showThanksScreen(store.indicatorRoot);
1047
+ showThanksScreen(store.indicatorRoot, {
1048
+ onSubmitNote: async (text) => {
1049
+ if (!store.sessionId) return;
1050
+ store.endNote = text;
1051
+ const stillUnacked = store.notes.filter((n) => !n.acked).map((n) => ({ atMs: n.atMs, text: n.text }));
1052
+ const ok = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds, {
1053
+ endNote: text,
1054
+ notes: stillUnacked
1055
+ });
1056
+ if (!ok) throw new Error("finalise failed");
1057
+ for (const n of store.notes) {
1058
+ if (!n.acked) n.acked = true;
1059
+ }
1060
+ },
1061
+ onSkip: () => {
1062
+ }
1063
+ });
513
1064
  }
514
1065
  }
515
1066
  function userTest(options = {}) {
@@ -546,7 +1097,18 @@ function userTest(options = {}) {
546
1097
  tasks: [],
547
1098
  tasksPanelOpen: readTasksPanelOpen(),
548
1099
  outsidePointerHandler: null,
549
- keydownHandler: null
1100
+ keydownHandler: null,
1101
+ hasMicPermission: false,
1102
+ muted: false,
1103
+ mutedSinceMs: null,
1104
+ mutedSegments: [],
1105
+ muteToastShown: false,
1106
+ muteToastTimers: [],
1107
+ notes: [],
1108
+ notesPopoverOpen: false,
1109
+ notePopoverAtMs: null,
1110
+ endNote: "",
1111
+ finishFlowRan: false
550
1112
  };
551
1113
  ctx.setStore(store);
552
1114
  const onFinish = () => {
@@ -559,23 +1121,67 @@ function userTest(options = {}) {
559
1121
  renderTasksPanel(store);
560
1122
  };
561
1123
  const onToggleTasks = () => setPanelOpen(!store.tasksPanelOpen);
1124
+ const onToggleMute = () => {
1125
+ if (!store.hasMicPermission) return;
1126
+ const ok = toggleMute(store);
1127
+ if (!ok) return;
1128
+ if (store.muted) showMuteToast(store);
1129
+ renderIndicatorState(store);
1130
+ };
1131
+ const closeNote = () => closeNotePopover(store);
1132
+ const onOpenNote = () => {
1133
+ if (store.notesPopoverOpen) {
1134
+ closeNote();
1135
+ return;
1136
+ }
1137
+ openNotePopover(
1138
+ store,
1139
+ (text) => {
1140
+ const atMs = store.notePopoverAtMs ?? Math.max(0, Date.now() - store.startedAt);
1141
+ const note = { atMs, text, acked: false };
1142
+ store.notes.push(note);
1143
+ closeNote();
1144
+ renderNotesCount(store);
1145
+ if (store.sessionId) {
1146
+ const sessionId = store.sessionId;
1147
+ void (async () => {
1148
+ const result = await postNoteWithRetry(store.options.apiUrl, sessionId, atMs, text, ctx.logger);
1149
+ if (result.ok) {
1150
+ note.acked = true;
1151
+ if (result.id) note.serverId = result.id;
1152
+ }
1153
+ })();
1154
+ }
1155
+ },
1156
+ () => closeNote()
1157
+ );
1158
+ };
562
1159
  if (!merged.hideIndicator) {
563
1160
  const host = document.createElement("div");
564
1161
  host.setAttribute("data-usero-user-test", "true");
565
1162
  document.body.appendChild(host);
566
1163
  store.indicator = host;
567
- store.indicatorRoot = buildIndicator(host, store, onFinish, onToggleTasks);
1164
+ store.indicatorRoot = buildIndicator(host, store, {
1165
+ onFinish,
1166
+ onToggleTasks,
1167
+ onToggleMute,
1168
+ onOpenNote
1169
+ });
568
1170
  renderIndicatorState(store);
1171
+ renderNotesCount(store);
569
1172
  }
570
1173
  const outsidePointer = (event) => {
571
- if (!store.tasksPanelOpen) return;
572
1174
  const host = store.indicator;
573
1175
  if (!host) return;
574
1176
  const path = event.composedPath();
575
- if (!path.includes(host)) setPanelOpen(false);
1177
+ if (path.includes(host)) return;
1178
+ if (store.tasksPanelOpen) setPanelOpen(false);
1179
+ if (store.notesPopoverOpen) closeNote();
576
1180
  };
577
1181
  const onKeydown = (event) => {
578
- if (event.key === "Escape" && store.tasksPanelOpen) setPanelOpen(false);
1182
+ if (event.key !== "Escape") return;
1183
+ if (store.tasksPanelOpen) setPanelOpen(false);
1184
+ if (store.notesPopoverOpen) closeNote();
579
1185
  };
580
1186
  store.outsidePointerHandler = outsidePointer;
581
1187
  store.keydownHandler = onKeydown;
@@ -627,6 +1233,13 @@ function userTest(options = {}) {
627
1233
  document.removeEventListener("keydown", store.keydownHandler);
628
1234
  store.keydownHandler = null;
629
1235
  }
1236
+ for (const id of store.muteToastTimers) {
1237
+ try {
1238
+ window.clearTimeout(id);
1239
+ } catch {
1240
+ }
1241
+ }
1242
+ store.muteToastTimers = [];
630
1243
  if (store.indicator && store.indicator.parentNode) {
631
1244
  store.indicator.parentNode.removeChild(store.indicator);
632
1245
  }