@usero/sdk 1.0.2 → 1.1.0

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.
@@ -145,7 +145,7 @@ async function uploadChunkWithRetry(apiUrl, sessionId, index, blob, logger, maxA
145
145
  }
146
146
  return false;
147
147
  }
148
- function buildIndicator(host, store, onFinish, onToggleTasks) {
148
+ function buildIndicator(host, store, callbacks) {
149
149
  const root = host.attachShadow({ mode: "closed" });
150
150
  const style = document.createElement("style");
151
151
  style.textContent = `
@@ -160,19 +160,21 @@ function buildIndicator(host, store, onFinish, onToggleTasks) {
160
160
  color: #fff;
161
161
  }
162
162
  .bar {
163
- display: inline-flex; align-items: center; gap: 10px;
164
- padding: 8px 14px 8px 12px;
165
- background: rgba(17,17,17,0.78);
163
+ display: inline-flex; align-items: center; gap: 6px;
164
+ padding: 6px 8px 6px 6px;
165
+ background: rgba(17,17,17,0.82);
166
+ border: 1px solid rgba(255,255,255,0.08);
166
167
  border-radius: 999px;
167
- box-shadow: 0 8px 24px rgba(0,0,0,0.18);
168
- backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
168
+ box-shadow: 0 8px 24px rgba(0,0,0,0.22);
169
+ backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
169
170
  max-width: 100%;
170
171
  }
171
172
  .panel {
172
- background: rgba(17,17,17,0.88);
173
+ background: rgba(17,17,17,0.92);
174
+ border: 1px solid rgba(255,255,255,0.08);
173
175
  border-radius: 14px; padding: 12px 14px 12px 8px;
174
176
  line-height: 1.45;
175
- box-shadow: 0 12px 32px rgba(0,0,0,0.28);
177
+ box-shadow: 0 12px 32px rgba(0,0,0,0.32);
176
178
  max-height: min(60vh, 480px);
177
179
  max-width: min(420px, calc(100vw - 32px));
178
180
  width: max-content; overflow-y: auto;
@@ -181,29 +183,156 @@ function buildIndicator(host, store, onFinish, onToggleTasks) {
181
183
  .panel ol { margin: 0; padding-left: 26px; }
182
184
  .panel li { margin: 0 0 8px; }
183
185
  .panel li:last-child { margin: 0; }
186
+
187
+ /* Mic chip: pill-within-pill with dot + label, doubles as mute toggle. */
188
+ .mic {
189
+ display: inline-flex; align-items: center; gap: 7px;
190
+ min-height: 32px; min-width: 44px;
191
+ padding: 0 11px 0 10px;
192
+ border-radius: 999px;
193
+ background: rgba(255,255,255,0.06);
194
+ border: 1px solid rgba(255,255,255,0.06);
195
+ color: #fff; font: inherit;
196
+ cursor: pointer; appearance: none;
197
+ transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
198
+ }
199
+ .mic:hover { background: rgba(255,255,255,0.12); }
200
+ .mic:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
201
+ .mic[data-mic-state="muted"] {
202
+ background: rgba(251, 191, 36, 0.18);
203
+ border-color: rgba(251, 191, 36, 0.45);
204
+ color: #fcd34d;
205
+ }
206
+ .mic[data-mic-state="muted"]:hover { background: rgba(251, 191, 36, 0.26); }
207
+ .mic[data-mic-state="none"] {
208
+ background: rgba(255,255,255,0.04);
209
+ color: rgba(255,255,255,0.55);
210
+ cursor: default;
211
+ }
212
+ .mic[data-mic-state="none"]:hover { background: rgba(255,255,255,0.04); }
213
+ .mic-icon { width: 13px; height: 13px; display: inline-block; flex-shrink: 0; }
214
+ .mic-label { font-weight: 500; letter-spacing: 0.01em; white-space: nowrap; }
215
+
184
216
  .dot {
185
- width: 8px; height: 8px; border-radius: 50%;
217
+ width: 7px; height: 7px; border-radius: 50%;
186
218
  background: #ef4444;
187
219
  box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6);
188
220
  animation: pulse 1.6s ease-out infinite;
221
+ flex-shrink: 0;
189
222
  }
190
223
  .dot[data-state="no-audio"] { background: #fbbf24; animation: none; }
191
224
  .dot[data-state="finishing"] { background: #fbbf24; animation: none; }
192
225
  .dot[data-state="done"] { background: #10b981; animation: none; }
193
226
  .dot[data-state="error"] { background: #ef4444; animation: none; }
194
- .label { font-weight: 500; letter-spacing: 0.01em; }
195
- .spacer { width: 1px; height: 16px; background: rgba(255,255,255,0.18); margin: 0 2px; }
227
+
196
228
  .btn {
197
- appearance: none; border: 0; background: rgba(255,255,255,0.12);
229
+ appearance: none; border: 0; background: rgba(255,255,255,0.10);
198
230
  color: #fff; font: inherit; font-weight: 600;
199
- padding: 6px 12px; border-radius: 999px; cursor: pointer;
200
- transition: background 0.15s ease;
231
+ padding: 6px 12px; min-height: 32px; border-radius: 999px; cursor: pointer;
232
+ transition: background 0.15s ease, transform 0.06s ease;
233
+ display: inline-flex; align-items: center; gap: 6px;
201
234
  }
202
- .btn:hover { background: rgba(255,255,255,0.22); }
235
+ .btn:hover { background: rgba(255,255,255,0.20); }
236
+ .btn:active { transform: scale(0.97); }
203
237
  .btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
204
238
  .btn[disabled] { opacity: 0.5; cursor: progress; }
205
239
  .tasks-btn[aria-expanded="true"] { background: rgba(255,255,255,0.24); }
206
- @media (max-width: 420px) { .btn { padding: 9px 14px; min-height: 40px; } }
240
+
241
+ /* Note button: icon-only, matches mic chip footprint */
242
+ .note-btn {
243
+ width: 32px; min-height: 32px; padding: 0;
244
+ background: rgba(255,255,255,0.06);
245
+ border: 1px solid rgba(255,255,255,0.06);
246
+ border-radius: 999px;
247
+ display: inline-flex; align-items: center; justify-content: center; gap: 4px;
248
+ color: #fff; font: inherit; cursor: pointer; appearance: none;
249
+ transition: background 0.15s ease, border-color 0.15s ease, width 0.18s ease;
250
+ overflow: hidden;
251
+ }
252
+ .note-btn:hover { background: rgba(255,255,255,0.14); }
253
+ .note-btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
254
+ .note-btn[data-has-notes="true"] { width: auto; padding: 0 10px 0 9px; gap: 6px; }
255
+ .note-btn[aria-expanded="true"] { background: rgba(255,255,255,0.22); border-color: rgba(255,255,255,0.18); }
256
+ .note-icon { width: 14px; height: 14px; display: inline-block; }
257
+ .note-count { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
258
+
259
+ .spacer { width: 1px; height: 18px; background: rgba(255,255,255,0.14); margin: 0 1px; }
260
+
261
+ @media (max-width: 480px) {
262
+ .bar { gap: 4px; padding: 5px 6px 5px 5px; }
263
+ .btn { padding: 7px 12px; min-height: 38px; }
264
+ .mic, .note-btn { min-height: 38px; }
265
+ .note-btn { width: 38px; }
266
+ .note-btn[data-has-notes="true"] { width: auto; }
267
+ }
268
+
269
+ /* First-mute helper toast: sits above the pill, auto-dismisses */
270
+ .toast {
271
+ background: rgba(17,17,17,0.92);
272
+ border: 1px solid rgba(251, 191, 36, 0.45);
273
+ color: #fff;
274
+ padding: 9px 14px; border-radius: 12px;
275
+ max-width: min(340px, calc(100vw - 32px));
276
+ box-shadow: 0 12px 28px rgba(0,0,0,0.28);
277
+ text-align: center; line-height: 1.4;
278
+ animation: toast-in 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
279
+ }
280
+ .toast[data-leaving="true"] { animation: toast-out 0.24s ease forwards; }
281
+ .toast strong { color: #fcd34d; font-weight: 600; }
282
+ @keyframes toast-in {
283
+ from { opacity: 0; transform: translateY(6px); }
284
+ to { opacity: 1; transform: translateY(0); }
285
+ }
286
+ @keyframes toast-out {
287
+ to { opacity: 0; transform: translateY(4px); }
288
+ }
289
+
290
+ /* Notes popover */
291
+ .note-popover {
292
+ background: rgba(17,17,17,0.94);
293
+ border: 1px solid rgba(255,255,255,0.10);
294
+ border-radius: 14px; padding: 12px;
295
+ width: min(340px, calc(100vw - 32px));
296
+ box-shadow: 0 18px 40px rgba(0,0,0,0.36);
297
+ display: flex; flex-direction: column; gap: 10px;
298
+ animation: pop-in 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
299
+ }
300
+ .note-popover[hidden] { display: none; }
301
+ @keyframes pop-in {
302
+ from { opacity: 0; transform: translateY(6px) scale(0.98); }
303
+ to { opacity: 1; transform: translateY(0) scale(1); }
304
+ }
305
+ .note-head {
306
+ color: rgba(255,255,255,0.7); font-size: 12px;
307
+ font-weight: 500; letter-spacing: 0.02em;
308
+ }
309
+ .note-textarea {
310
+ width: 100%; box-sizing: border-box;
311
+ min-height: 80px; resize: vertical;
312
+ padding: 10px 11px;
313
+ background: rgba(0,0,0,0.35);
314
+ border: 1px solid rgba(255,255,255,0.10);
315
+ border-radius: 10px;
316
+ color: #fff; font: inherit; font-size: 13.5px;
317
+ line-height: 1.45;
318
+ transition: border-color 0.15s ease;
319
+ }
320
+ .note-textarea:focus { outline: none; border-color: rgba(255,255,255,0.32); }
321
+ .note-textarea::placeholder { color: rgba(255,255,255,0.42); }
322
+ .note-actions {
323
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
324
+ }
325
+ .note-actions .hint {
326
+ color: rgba(255,255,255,0.45); font-size: 11px;
327
+ }
328
+ .note-actions .group { display: inline-flex; gap: 6px; }
329
+ .note-actions .btn { padding: 6px 12px; font-size: 12.5px; min-height: 32px; }
330
+ .btn-primary { background: #fff !important; color: #111; }
331
+ .btn-primary:hover { background: rgba(255,255,255,0.85) !important; }
332
+ .btn-ghost { background: transparent; color: rgba(255,255,255,0.7); }
333
+ .btn-ghost:hover { background: rgba(255,255,255,0.10); color: #fff; }
334
+
335
+ /* Thanks overlay + end-of-test note */
207
336
  .thanks {
208
337
  position: fixed; inset: 0;
209
338
  display: grid; place-items: center;
@@ -218,12 +347,14 @@ function buildIndicator(host, store, onFinish, onToggleTasks) {
218
347
  }
219
348
  .thanks-card {
220
349
  background: #fff; color: #111;
221
- border-radius: 16px; padding: 28px 24px;
222
- max-width: 360px; width: 100%;
350
+ border-radius: 18px; padding: 28px 24px;
351
+ max-width: 420px; width: 100%;
223
352
  box-shadow: 0 20px 50px rgba(0,0,0,0.25);
353
+ text-align: left;
224
354
  }
225
- .thanks h2 { margin: 0 0 8px; font-size: 20px; }
226
- .thanks p { margin: 0; font-size: 14px; line-height: 1.45; color: #4b5563; }
355
+ .thanks-card .head { text-align: center; }
356
+ .thanks h2 { margin: 0 0 6px; font-size: 20px; }
357
+ .thanks .lede { margin: 0 0 18px; font-size: 14px; line-height: 1.45; color: #4b5563; text-align: center; }
227
358
  .thanks .check {
228
359
  width: 44px; height: 44px; border-radius: 50%;
229
360
  background: #10b981; color: #fff;
@@ -231,6 +362,50 @@ function buildIndicator(host, store, onFinish, onToggleTasks) {
231
362
  margin: 0 auto 12px;
232
363
  font-size: 22px;
233
364
  }
365
+ .thanks .end-label {
366
+ display: block; margin: 0 0 8px;
367
+ font-size: 13px; font-weight: 500; color: #374151;
368
+ }
369
+ .thanks .end-textarea {
370
+ width: 100%; box-sizing: border-box;
371
+ min-height: 96px; resize: vertical;
372
+ padding: 11px 12px;
373
+ background: #f9fafb;
374
+ border: 1px solid #e5e7eb;
375
+ border-radius: 10px;
376
+ font: inherit; font-size: 14px; line-height: 1.5;
377
+ color: #111;
378
+ transition: border-color 0.15s ease, background 0.15s ease;
379
+ }
380
+ .thanks .end-textarea:focus {
381
+ outline: none; border-color: #111; background: #fff;
382
+ }
383
+ .thanks .end-textarea::placeholder { color: #9ca3af; }
384
+ .thanks .end-actions {
385
+ display: flex; gap: 10px; margin-top: 14px;
386
+ }
387
+ .thanks .end-actions button {
388
+ flex: 1;
389
+ appearance: none; border: 1px solid #e5e7eb;
390
+ background: #fff; color: #111;
391
+ padding: 11px 14px; border-radius: 10px;
392
+ font: inherit; font-weight: 600; font-size: 14px;
393
+ cursor: pointer;
394
+ transition: background 0.15s ease, border-color 0.15s ease;
395
+ }
396
+ .thanks .end-actions button:hover { background: #f3f4f6; }
397
+ .thanks .end-actions button.primary {
398
+ background: #111; color: #fff; border-color: #111;
399
+ }
400
+ .thanks .end-actions button.primary:hover { background: #1f2937; border-color: #1f2937; }
401
+ .thanks .end-actions button:focus-visible { outline: 2px solid #111; outline-offset: 2px; }
402
+ .thanks .end-hint {
403
+ margin: 10px 0 0; font-size: 11.5px; color: #9ca3af; text-align: center;
404
+ }
405
+ .thanks .end-sent {
406
+ margin-top: 14px; text-align: center; color: #4b5563; font-size: 13px;
407
+ }
408
+
234
409
  @keyframes pulse {
235
410
  0% { box-shadow: 0 0 0 0 rgba(239,68,68,0.55); }
236
411
  70% { box-shadow: 0 0 0 10px rgba(239,68,68,0); }
@@ -238,6 +413,7 @@ function buildIndicator(host, store, onFinish, onToggleTasks) {
238
413
  }
239
414
  @media (prefers-reduced-motion: reduce) {
240
415
  .dot { animation: none; }
416
+ .toast, .note-popover { animation: none; }
241
417
  }
242
418
  `;
243
419
  const anchor = document.createElement("div");
@@ -245,34 +421,66 @@ function buildIndicator(host, store, onFinish, onToggleTasks) {
245
421
  const panel = document.createElement("div");
246
422
  panel.className = "panel";
247
423
  panel.hidden = true;
424
+ const toastSlot = document.createElement("div");
425
+ toastSlot.className = "toast-slot";
426
+ const notePopover = document.createElement("div");
427
+ notePopover.className = "note-popover";
428
+ notePopover.hidden = true;
248
429
  const bar = document.createElement("div");
249
430
  bar.className = "bar";
250
431
  bar.setAttribute("role", "status");
251
432
  bar.setAttribute("aria-live", "polite");
433
+ const micBtn = document.createElement("button");
434
+ micBtn.type = "button";
435
+ micBtn.className = "mic";
436
+ micBtn.setAttribute("data-mic-state", "recording");
437
+ micBtn.setAttribute("aria-pressed", "false");
438
+ micBtn.setAttribute("aria-label", "Mute microphone");
252
439
  const dot = document.createElement("span");
253
440
  dot.className = "dot";
254
441
  dot.setAttribute("data-state", store.indicatorState);
255
- const label = document.createElement("span");
256
- label.className = "label";
257
- label.textContent = "Recording";
442
+ const micIcon = document.createElement("span");
443
+ micIcon.className = "mic-icon";
444
+ micIcon.innerHTML = MIC_ICON_SVG;
445
+ micIcon.setAttribute("aria-hidden", "true");
446
+ const micLabel = document.createElement("span");
447
+ micLabel.className = "mic-label";
448
+ micLabel.textContent = "Recording";
449
+ micBtn.appendChild(dot);
450
+ micBtn.appendChild(micIcon);
451
+ micBtn.appendChild(micLabel);
452
+ micBtn.addEventListener("click", callbacks.onToggleMute);
453
+ bar.appendChild(micBtn);
454
+ const noteBtn = document.createElement("button");
455
+ noteBtn.type = "button";
456
+ noteBtn.className = "note-btn";
457
+ noteBtn.setAttribute("aria-label", "Add a timestamped note");
458
+ noteBtn.setAttribute("aria-expanded", "false");
459
+ noteBtn.setAttribute("data-has-notes", "false");
460
+ noteBtn.innerHTML = `<span class="note-icon" aria-hidden="true">${NOTE_ICON_SVG}</span><span class="note-count" hidden></span>`;
461
+ noteBtn.addEventListener("click", callbacks.onOpenNote);
462
+ bar.appendChild(noteBtn);
258
463
  const spacer = document.createElement("span");
259
464
  spacer.className = "spacer";
260
- bar.appendChild(dot);
261
- bar.appendChild(label);
262
465
  bar.appendChild(spacer);
263
466
  const btn = document.createElement("button");
264
467
  btn.type = "button";
265
468
  btn.className = "btn finish-btn";
266
469
  btn.textContent = "Finish";
267
- btn.addEventListener("click", onFinish);
470
+ btn.addEventListener("click", callbacks.onFinish);
268
471
  bar.appendChild(btn);
269
- if (store.tasks.length > 0) installTasksToggle(bar, btn, store, onToggleTasks);
472
+ if (store.tasks.length > 0) installTasksToggle(bar, btn, store, callbacks.onToggleTasks);
270
473
  anchor.appendChild(panel);
474
+ anchor.appendChild(toastSlot);
475
+ anchor.appendChild(notePopover);
271
476
  anchor.appendChild(bar);
272
477
  root.appendChild(style);
273
478
  root.appendChild(anchor);
274
479
  return root;
275
480
  }
481
+ 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>`;
482
+ 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>`;
483
+ 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>`;
276
484
  function installTasksToggle(bar, finishBtn, store, onToggleTasks) {
277
485
  const tasksBtn = document.createElement("button");
278
486
  tasksBtn.type = "button";
@@ -315,53 +523,270 @@ function writeTasksPanelOpen(open) {
315
523
  } catch {
316
524
  }
317
525
  }
526
+ function micChipState(store) {
527
+ if (store.indicatorState === "finishing" || store.indicatorState === "done" || store.indicatorState === "error") {
528
+ return "inactive";
529
+ }
530
+ if (!store.hasMicPermission) return "none";
531
+ return store.muted ? "muted" : "recording";
532
+ }
318
533
  function renderIndicatorState(store) {
319
534
  const root = store.indicatorRoot;
320
535
  if (!root) return;
321
536
  const dot = root.querySelector(".dot");
322
- const label = root.querySelector(".label");
537
+ const mic = root.querySelector(".mic");
538
+ const micIcon = root.querySelector(".mic-icon");
539
+ const micLabel = root.querySelector(".mic-label");
323
540
  const btn = root.querySelector(".finish-btn");
324
- if (!(dot instanceof HTMLElement) || !(label instanceof HTMLElement) || !btn) return;
541
+ if (!(dot instanceof HTMLElement) || !mic || !(micIcon instanceof HTMLElement) || !(micLabel instanceof HTMLElement) || !btn) return;
325
542
  dot.setAttribute("data-state", store.indicatorState);
543
+ const chipState = micChipState(store);
544
+ mic.setAttribute("data-mic-state", chipState === "inactive" ? "none" : chipState);
326
545
  switch (store.indicatorState) {
327
546
  case "recording":
328
- label.textContent = "Recording";
329
- btn.textContent = "Finish";
330
- btn.disabled = false;
331
- break;
332
547
  case "no-audio":
333
- label.textContent = "No mic, replay only";
334
548
  btn.textContent = "Finish";
335
549
  btn.disabled = false;
336
550
  break;
337
551
  case "finishing":
338
- label.textContent = "Saving";
339
552
  btn.textContent = "Saving";
340
553
  btn.disabled = true;
341
554
  break;
342
555
  case "done":
343
- label.textContent = "Saved";
344
556
  btn.textContent = "Done";
345
557
  btn.disabled = true;
346
558
  break;
347
559
  case "error":
348
- label.textContent = "Save failed";
349
560
  btn.textContent = "Retry";
350
561
  btn.disabled = false;
351
562
  break;
352
563
  }
564
+ switch (chipState) {
565
+ case "recording":
566
+ micIcon.innerHTML = MIC_ICON_SVG;
567
+ micLabel.textContent = "Recording";
568
+ mic.setAttribute("aria-label", "Mute microphone");
569
+ mic.setAttribute("aria-pressed", "false");
570
+ mic.removeAttribute("tabindex");
571
+ break;
572
+ case "muted":
573
+ micIcon.innerHTML = MIC_MUTED_ICON_SVG;
574
+ micLabel.textContent = "Muted";
575
+ mic.setAttribute("aria-label", "Unmute microphone");
576
+ mic.setAttribute("aria-pressed", "true");
577
+ mic.removeAttribute("tabindex");
578
+ break;
579
+ case "none":
580
+ micIcon.innerHTML = MIC_MUTED_ICON_SVG;
581
+ micLabel.textContent = "No mic, replay only";
582
+ mic.setAttribute("aria-label", "Microphone not granted, replay only");
583
+ mic.setAttribute("aria-pressed", "false");
584
+ mic.setAttribute("tabindex", "-1");
585
+ break;
586
+ case "inactive":
587
+ micIcon.innerHTML = MIC_ICON_SVG;
588
+ micLabel.textContent = store.indicatorState === "finishing" ? "Saving" : store.indicatorState === "done" ? "Saved" : "Save failed";
589
+ mic.setAttribute("aria-label", "Recording stopped");
590
+ mic.setAttribute("aria-pressed", "false");
591
+ mic.setAttribute("tabindex", "-1");
592
+ break;
593
+ }
594
+ }
595
+ function renderNotesCount(store) {
596
+ const root = store.indicatorRoot;
597
+ if (!root) return;
598
+ const noteBtn = root.querySelector(".note-btn");
599
+ const count = root.querySelector(".note-count");
600
+ if (!(noteBtn instanceof HTMLElement) || !(count instanceof HTMLElement)) return;
601
+ const n = store.notes.length;
602
+ noteBtn.setAttribute("data-has-notes", n > 0 ? "true" : "false");
603
+ if (n > 0) {
604
+ count.textContent = String(n);
605
+ count.hidden = false;
606
+ noteBtn.setAttribute("aria-label", `Add a timestamped note (${n} so far)`);
607
+ } else {
608
+ count.textContent = "";
609
+ count.hidden = true;
610
+ noteBtn.setAttribute("aria-label", "Add a timestamped note");
611
+ }
612
+ }
613
+ function showMuteToast(store) {
614
+ if (store.muteToastShown) return;
615
+ store.muteToastShown = true;
616
+ const root = store.indicatorRoot;
617
+ if (!root) return;
618
+ const slot = root.querySelector(".toast-slot");
619
+ if (!(slot instanceof HTMLElement)) return;
620
+ slot.innerHTML = "";
621
+ const toast = document.createElement("div");
622
+ toast.className = "toast";
623
+ toast.setAttribute("role", "status");
624
+ toast.innerHTML = `<strong>Mic off.</strong> Screen is still recording. Tap to unmute.`;
625
+ slot.appendChild(toast);
626
+ window.setTimeout(() => {
627
+ toast.setAttribute("data-leaving", "true");
628
+ window.setTimeout(() => {
629
+ toast.remove();
630
+ }, 260);
631
+ }, 3e3);
632
+ }
633
+ function openNotePopover(store, onSave, onCancel) {
634
+ const root = store.indicatorRoot;
635
+ if (!root) return;
636
+ const pop = root.querySelector(".note-popover");
637
+ const noteBtn = root.querySelector(".note-btn");
638
+ if (!(pop instanceof HTMLElement) || !(noteBtn instanceof HTMLElement)) return;
639
+ store.notesPopoverOpen = true;
640
+ store.notePopoverAtMs = Date.now() - store.startedAt;
641
+ noteBtn.setAttribute("aria-expanded", "true");
642
+ pop.innerHTML = "";
643
+ const head = document.createElement("div");
644
+ head.className = "note-head";
645
+ head.innerHTML = `<span>Add a note</span>`;
646
+ const form = document.createElement("form");
647
+ form.style.cssText = "display:flex;flex-direction:column;gap:10px;margin:0;";
648
+ form.noValidate = true;
649
+ const ta = document.createElement("textarea");
650
+ ta.className = "note-textarea";
651
+ ta.placeholder = "What just happened? Confusing? Surprising? Broken?";
652
+ ta.rows = 3;
653
+ ta.setAttribute("aria-label", "Note text");
654
+ const actions = document.createElement("div");
655
+ actions.className = "note-actions";
656
+ const hint = document.createElement("span");
657
+ hint.className = "hint";
658
+ hint.innerHTML = '<kbd style="font-family:inherit">Cmd</kbd>+Enter to save';
659
+ const group = document.createElement("div");
660
+ group.className = "group";
661
+ const cancelBtn = document.createElement("button");
662
+ cancelBtn.type = "button";
663
+ cancelBtn.className = "btn btn-ghost";
664
+ cancelBtn.textContent = "Cancel";
665
+ const saveBtn = document.createElement("button");
666
+ saveBtn.type = "submit";
667
+ saveBtn.className = "btn btn-primary";
668
+ saveBtn.textContent = "Save";
669
+ group.appendChild(cancelBtn);
670
+ group.appendChild(saveBtn);
671
+ actions.appendChild(hint);
672
+ actions.appendChild(group);
673
+ form.appendChild(ta);
674
+ form.appendChild(actions);
675
+ pop.appendChild(head);
676
+ pop.appendChild(form);
677
+ pop.hidden = false;
678
+ const submit = () => {
679
+ const text = ta.value.trim();
680
+ if (!text) {
681
+ onCancel();
682
+ return;
683
+ }
684
+ onSave(text);
685
+ };
686
+ form.addEventListener("submit", (e) => {
687
+ e.preventDefault();
688
+ submit();
689
+ });
690
+ cancelBtn.addEventListener("click", () => onCancel());
691
+ ta.addEventListener("keydown", (e) => {
692
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
693
+ e.preventDefault();
694
+ submit();
695
+ } else if (e.key === "Escape") {
696
+ e.preventDefault();
697
+ onCancel();
698
+ }
699
+ });
700
+ window.requestAnimationFrame(() => {
701
+ ta.focus({ preventScroll: true });
702
+ });
703
+ }
704
+ function closeNotePopover(store) {
705
+ const root = store.indicatorRoot;
706
+ if (!root) return;
707
+ const pop = root.querySelector(".note-popover");
708
+ const noteBtn = root.querySelector(".note-btn");
709
+ if (pop instanceof HTMLElement) {
710
+ pop.hidden = true;
711
+ pop.innerHTML = "";
712
+ }
713
+ if (noteBtn instanceof HTMLElement) noteBtn.setAttribute("aria-expanded", "false");
714
+ store.notesPopoverOpen = false;
715
+ store.notePopoverAtMs = null;
353
716
  }
354
- function showThanksScreen(root) {
717
+ function showThanksScreen(root, opts) {
355
718
  const overlay = document.createElement("div");
356
719
  overlay.className = "thanks";
357
- overlay.innerHTML = `
358
- <div class="thanks-card">
359
- <div class="check" aria-hidden="true">&#10003;</div>
360
- <h2>Thanks for testing</h2>
361
- <p>Your session was saved. You can close this tab.</p>
720
+ const card = document.createElement("div");
721
+ card.className = "thanks-card";
722
+ const head = document.createElement("div");
723
+ head.className = "head";
724
+ head.innerHTML = `
725
+ <div class="check" aria-hidden="true">&#10003;</div>
726
+ <h2>Thanks for testing</h2>
727
+ <p class="lede">Your session was saved. One last thing if you have a moment.</p>
728
+ `;
729
+ const form = document.createElement("form");
730
+ form.noValidate = true;
731
+ form.innerHTML = `
732
+ <label class="end-label" for="usero-end-note">Anything you would add?</label>
733
+ <textarea
734
+ id="usero-end-note"
735
+ class="end-textarea"
736
+ rows="4"
737
+ placeholder="Confusing bits, things you liked, what you'd change..."
738
+ ></textarea>
739
+ <div class="end-actions">
740
+ <button type="button" class="skip">Skip</button>
741
+ <button type="submit" class="primary">Send</button>
362
742
  </div>
743
+ <p class="end-hint">Cmd or Ctrl plus Enter to send. Either button is fine.</p>
363
744
  `;
745
+ card.appendChild(head);
746
+ card.appendChild(form);
747
+ overlay.appendChild(card);
364
748
  root.appendChild(overlay);
749
+ const ta = form.querySelector("#usero-end-note");
750
+ const skipBtn = form.querySelector("button.skip");
751
+ if (!ta || !skipBtn) return;
752
+ const swapToSent = (message) => {
753
+ form.remove();
754
+ const sent = document.createElement("p");
755
+ sent.className = "end-sent";
756
+ sent.textContent = message;
757
+ card.appendChild(sent);
758
+ };
759
+ const submit = async () => {
760
+ const text = ta.value.trim();
761
+ ta.disabled = true;
762
+ skipBtn.disabled = true;
763
+ const submitBtn = form.querySelector("button.primary");
764
+ if (submitBtn) submitBtn.disabled = true;
765
+ if (text) {
766
+ await opts.onSubmitNote(text);
767
+ swapToSent("Thanks. You can close this tab.");
768
+ } else {
769
+ opts.onSkip();
770
+ swapToSent("All good. You can close this tab.");
771
+ }
772
+ };
773
+ form.addEventListener("submit", (e) => {
774
+ e.preventDefault();
775
+ void submit();
776
+ });
777
+ skipBtn.addEventListener("click", () => {
778
+ ta.value = "";
779
+ void submit();
780
+ });
781
+ ta.addEventListener("keydown", (e) => {
782
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
783
+ e.preventDefault();
784
+ void submit();
785
+ }
786
+ });
787
+ window.requestAnimationFrame(() => {
788
+ ta.focus({ preventScroll: true });
789
+ });
365
790
  }
366
791
  function parseTasks(raw) {
367
792
  if (!Array.isArray(raw)) return [];
@@ -388,12 +813,20 @@ async function createSession(apiUrl, slug, testerName) {
388
813
  return null;
389
814
  }
390
815
  }
391
- async function finaliseSession(apiUrl, sessionId, durationSeconds) {
816
+ async function finaliseSession(apiUrl, sessionId, durationSeconds, extras = {}) {
392
817
  try {
818
+ const body = {
819
+ durationSeconds: Math.max(0, Math.round(durationSeconds))
820
+ };
821
+ if (extras.mutedSegments && extras.mutedSegments.length > 0) {
822
+ body.mutedSegments = extras.mutedSegments;
823
+ }
824
+ const trimmedEndNote = extras.endNote?.trim();
825
+ if (trimmedEndNote) body.endNote = trimmedEndNote;
393
826
  const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/finalise`, {
394
827
  method: "POST",
395
828
  headers: { "Content-Type": "application/json" },
396
- body: JSON.stringify({ durationSeconds: Math.max(0, Math.round(durationSeconds)) }),
829
+ body: JSON.stringify(body),
397
830
  keepalive: true
398
831
  });
399
832
  return res.ok;
@@ -401,6 +834,24 @@ async function finaliseSession(apiUrl, sessionId, durationSeconds) {
401
834
  return false;
402
835
  }
403
836
  }
837
+ async function postNote(apiUrl, sessionId, atMs, text, logger) {
838
+ try {
839
+ const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/notes`, {
840
+ method: "POST",
841
+ headers: { "Content-Type": "application/json" },
842
+ body: JSON.stringify({ atMs: Math.max(0, Math.round(atMs)), text }),
843
+ keepalive: true
844
+ });
845
+ if (!res.ok) {
846
+ logger.warn(`note POST rejected with ${res.status}`);
847
+ return false;
848
+ }
849
+ return true;
850
+ } catch (err) {
851
+ logger.warn("note POST failed", err);
852
+ return false;
853
+ }
854
+ }
404
855
  async function flushPendingFromIdb(store, ctx) {
405
856
  if (!store.sessionId) return;
406
857
  const pending = await idbListChunks(store.sessionId);
@@ -449,6 +900,7 @@ async function startRecording(store, ctx) {
449
900
  return;
450
901
  }
451
902
  store.stream = stream;
903
+ store.hasMicPermission = true;
452
904
  const mimeType = pickMimeType();
453
905
  let recorder;
454
906
  try {
@@ -472,6 +924,34 @@ async function startRecording(store, ctx) {
472
924
  });
473
925
  recorder.start(store.options.chunkSeconds * 1e3);
474
926
  }
927
+ function toggleMute(store) {
928
+ if (!store.stream || !store.hasMicPermission) return false;
929
+ const tracks = store.stream.getAudioTracks();
930
+ if (tracks.length === 0) return false;
931
+ const nowMs = Date.now() - store.startedAt;
932
+ if (!store.muted) {
933
+ for (const t of tracks) t.enabled = false;
934
+ store.muted = true;
935
+ store.mutedSinceMs = nowMs;
936
+ } else {
937
+ const startMs = store.mutedSinceMs ?? nowMs;
938
+ if (nowMs > startMs) {
939
+ store.mutedSegments.push({ startMs, endMs: nowMs });
940
+ }
941
+ store.mutedSinceMs = null;
942
+ store.muted = false;
943
+ for (const t of tracks) t.enabled = true;
944
+ }
945
+ return true;
946
+ }
947
+ function flushMuteIfActive(store) {
948
+ if (!store.muted || store.mutedSinceMs === null) return;
949
+ const nowMs = Date.now() - store.startedAt;
950
+ if (nowMs > store.mutedSinceMs) {
951
+ store.mutedSegments.push({ startMs: store.mutedSinceMs, endMs: nowMs });
952
+ }
953
+ store.mutedSinceMs = null;
954
+ }
475
955
  function stopRecording(store) {
476
956
  const recorder = store.recorder;
477
957
  if (recorder && recorder.state !== "inactive") {
@@ -494,20 +974,34 @@ async function finishFlow(store, ctx, opts) {
494
974
  if (store.cancelled) return;
495
975
  if (store.indicatorState === "finishing" || store.indicatorState === "done") return;
496
976
  store.indicatorState = "finishing";
977
+ flushMuteIfActive(store);
497
978
  renderIndicatorState(store);
498
979
  stopRecording(store);
499
980
  await store.uploadQueue;
500
981
  await flushPendingFromIdb(store, ctx);
501
982
  const durationSeconds = (Date.now() - store.startedAt) / 1e3;
502
983
  if (store.sessionId) {
503
- const ok = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds);
984
+ const ok = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds, {
985
+ mutedSegments: store.mutedSegments
986
+ });
504
987
  store.indicatorState = ok ? "done" : "error";
505
988
  } else {
506
989
  store.indicatorState = "error";
507
990
  }
508
991
  renderIndicatorState(store);
509
992
  if (opts.showThanks && store.indicatorRoot && store.indicatorState === "done") {
510
- showThanksScreen(store.indicatorRoot);
993
+ showThanksScreen(store.indicatorRoot, {
994
+ onSubmitNote: async (text) => {
995
+ if (!store.sessionId) return;
996
+ store.endNote = text;
997
+ await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds, {
998
+ mutedSegments: store.mutedSegments,
999
+ endNote: text
1000
+ });
1001
+ },
1002
+ onSkip: () => {
1003
+ }
1004
+ });
511
1005
  }
512
1006
  }
513
1007
  function userTest(options = {}) {
@@ -544,7 +1038,16 @@ function userTest(options = {}) {
544
1038
  tasks: [],
545
1039
  tasksPanelOpen: readTasksPanelOpen(),
546
1040
  outsidePointerHandler: null,
547
- keydownHandler: null
1041
+ keydownHandler: null,
1042
+ hasMicPermission: false,
1043
+ muted: false,
1044
+ mutedSinceMs: null,
1045
+ mutedSegments: [],
1046
+ muteToastShown: false,
1047
+ notes: [],
1048
+ notesPopoverOpen: false,
1049
+ notePopoverAtMs: null,
1050
+ endNote: ""
548
1051
  };
549
1052
  ctx.setStore(store);
550
1053
  const onFinish = () => {
@@ -557,23 +1060,59 @@ function userTest(options = {}) {
557
1060
  renderTasksPanel(store);
558
1061
  };
559
1062
  const onToggleTasks = () => setPanelOpen(!store.tasksPanelOpen);
1063
+ const onToggleMute = () => {
1064
+ if (!store.hasMicPermission) return;
1065
+ const ok = toggleMute(store);
1066
+ if (!ok) return;
1067
+ if (store.muted) showMuteToast(store);
1068
+ renderIndicatorState(store);
1069
+ };
1070
+ const closeNote = () => closeNotePopover(store);
1071
+ const onOpenNote = () => {
1072
+ if (store.notesPopoverOpen) {
1073
+ closeNote();
1074
+ return;
1075
+ }
1076
+ openNotePopover(
1077
+ store,
1078
+ (text) => {
1079
+ const atMs = store.notePopoverAtMs ?? Math.max(0, Date.now() - store.startedAt);
1080
+ store.notes.push({ atMs, text });
1081
+ closeNote();
1082
+ renderNotesCount(store);
1083
+ if (store.sessionId) {
1084
+ void postNote(store.options.apiUrl, store.sessionId, atMs, text, ctx.logger);
1085
+ }
1086
+ },
1087
+ () => closeNote()
1088
+ );
1089
+ };
560
1090
  if (!merged.hideIndicator) {
561
1091
  const host = document.createElement("div");
562
1092
  host.setAttribute("data-usero-user-test", "true");
563
1093
  document.body.appendChild(host);
564
1094
  store.indicator = host;
565
- store.indicatorRoot = buildIndicator(host, store, onFinish, onToggleTasks);
1095
+ store.indicatorRoot = buildIndicator(host, store, {
1096
+ onFinish,
1097
+ onToggleTasks,
1098
+ onToggleMute,
1099
+ onOpenNote
1100
+ });
566
1101
  renderIndicatorState(store);
1102
+ renderNotesCount(store);
567
1103
  }
568
1104
  const outsidePointer = (event) => {
569
- if (!store.tasksPanelOpen) return;
570
1105
  const host = store.indicator;
571
1106
  if (!host) return;
572
1107
  const path = event.composedPath();
573
- if (!path.includes(host)) setPanelOpen(false);
1108
+ if (path.includes(host)) return;
1109
+ if (store.tasksPanelOpen) setPanelOpen(false);
1110
+ if (store.notesPopoverOpen) closeNote();
574
1111
  };
575
1112
  const onKeydown = (event) => {
576
- if (event.key === "Escape" && store.tasksPanelOpen) setPanelOpen(false);
1113
+ if (event.key !== "Escape") return;
1114
+ if (store.tasksPanelOpen) setPanelOpen(false);
1115
+ if (store.notesPopoverOpen) closeNote();
577
1116
  };
578
1117
  store.outsidePointerHandler = outsidePointer;
579
1118
  store.keydownHandler = onKeydown;