@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.
@@ -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,270 @@ 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
+ }
614
+ }
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
+ window.setTimeout(() => {
629
+ toast.setAttribute("data-leaving", "true");
630
+ window.setTimeout(() => {
631
+ toast.remove();
632
+ }, 260);
633
+ }, 3e3);
634
+ }
635
+ function openNotePopover(store, onSave, onCancel) {
636
+ const root = store.indicatorRoot;
637
+ if (!root) return;
638
+ const pop = root.querySelector(".note-popover");
639
+ const noteBtn = root.querySelector(".note-btn");
640
+ if (!(pop instanceof HTMLElement) || !(noteBtn instanceof HTMLElement)) return;
641
+ store.notesPopoverOpen = true;
642
+ store.notePopoverAtMs = Date.now() - store.startedAt;
643
+ noteBtn.setAttribute("aria-expanded", "true");
644
+ pop.innerHTML = "";
645
+ const head = document.createElement("div");
646
+ head.className = "note-head";
647
+ head.innerHTML = `<span>Add a note</span>`;
648
+ const form = document.createElement("form");
649
+ form.style.cssText = "display:flex;flex-direction:column;gap:10px;margin:0;";
650
+ form.noValidate = true;
651
+ const ta = document.createElement("textarea");
652
+ ta.className = "note-textarea";
653
+ ta.placeholder = "What just happened? Confusing? Surprising? Broken?";
654
+ ta.rows = 3;
655
+ ta.setAttribute("aria-label", "Note text");
656
+ const actions = document.createElement("div");
657
+ actions.className = "note-actions";
658
+ const hint = document.createElement("span");
659
+ hint.className = "hint";
660
+ hint.innerHTML = '<kbd style="font-family:inherit">Cmd</kbd>+Enter to save';
661
+ const group = document.createElement("div");
662
+ group.className = "group";
663
+ const cancelBtn = document.createElement("button");
664
+ cancelBtn.type = "button";
665
+ cancelBtn.className = "btn btn-ghost";
666
+ cancelBtn.textContent = "Cancel";
667
+ const saveBtn = document.createElement("button");
668
+ saveBtn.type = "submit";
669
+ saveBtn.className = "btn btn-primary";
670
+ saveBtn.textContent = "Save";
671
+ group.appendChild(cancelBtn);
672
+ group.appendChild(saveBtn);
673
+ actions.appendChild(hint);
674
+ actions.appendChild(group);
675
+ form.appendChild(ta);
676
+ form.appendChild(actions);
677
+ pop.appendChild(head);
678
+ pop.appendChild(form);
679
+ pop.hidden = false;
680
+ const submit = () => {
681
+ const text = ta.value.trim();
682
+ if (!text) {
683
+ onCancel();
684
+ return;
685
+ }
686
+ onSave(text);
687
+ };
688
+ form.addEventListener("submit", (e) => {
689
+ e.preventDefault();
690
+ submit();
691
+ });
692
+ cancelBtn.addEventListener("click", () => onCancel());
693
+ ta.addEventListener("keydown", (e) => {
694
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
695
+ e.preventDefault();
696
+ submit();
697
+ } else if (e.key === "Escape") {
698
+ e.preventDefault();
699
+ onCancel();
700
+ }
701
+ });
702
+ window.requestAnimationFrame(() => {
703
+ ta.focus({ preventScroll: true });
704
+ });
705
+ }
706
+ function closeNotePopover(store) {
707
+ const root = store.indicatorRoot;
708
+ if (!root) return;
709
+ const pop = root.querySelector(".note-popover");
710
+ const noteBtn = root.querySelector(".note-btn");
711
+ if (pop instanceof HTMLElement) {
712
+ pop.hidden = true;
713
+ pop.innerHTML = "";
714
+ }
715
+ if (noteBtn instanceof HTMLElement) noteBtn.setAttribute("aria-expanded", "false");
716
+ store.notesPopoverOpen = false;
717
+ store.notePopoverAtMs = null;
355
718
  }
356
- function showThanksScreen(root) {
719
+ function showThanksScreen(root, opts) {
357
720
  const overlay = document.createElement("div");
358
721
  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>
722
+ const card = document.createElement("div");
723
+ card.className = "thanks-card";
724
+ const head = document.createElement("div");
725
+ head.className = "head";
726
+ head.innerHTML = `
727
+ <div class="check" aria-hidden="true">&#10003;</div>
728
+ <h2>Thanks for testing</h2>
729
+ <p class="lede">Your session was saved. One last thing if you have a moment.</p>
730
+ `;
731
+ const form = document.createElement("form");
732
+ form.noValidate = true;
733
+ form.innerHTML = `
734
+ <label class="end-label" for="usero-end-note">Anything you would add?</label>
735
+ <textarea
736
+ id="usero-end-note"
737
+ class="end-textarea"
738
+ rows="4"
739
+ placeholder="Confusing bits, things you liked, what you'd change..."
740
+ ></textarea>
741
+ <div class="end-actions">
742
+ <button type="button" class="skip">Skip</button>
743
+ <button type="submit" class="primary">Send</button>
364
744
  </div>
745
+ <p class="end-hint">Cmd or Ctrl plus Enter to send. Either button is fine.</p>
365
746
  `;
747
+ card.appendChild(head);
748
+ card.appendChild(form);
749
+ overlay.appendChild(card);
366
750
  root.appendChild(overlay);
751
+ const ta = form.querySelector("#usero-end-note");
752
+ const skipBtn = form.querySelector("button.skip");
753
+ if (!ta || !skipBtn) return;
754
+ const swapToSent = (message) => {
755
+ form.remove();
756
+ const sent = document.createElement("p");
757
+ sent.className = "end-sent";
758
+ sent.textContent = message;
759
+ card.appendChild(sent);
760
+ };
761
+ const submit = async () => {
762
+ const text = ta.value.trim();
763
+ ta.disabled = true;
764
+ skipBtn.disabled = true;
765
+ const submitBtn = form.querySelector("button.primary");
766
+ if (submitBtn) submitBtn.disabled = true;
767
+ if (text) {
768
+ await opts.onSubmitNote(text);
769
+ swapToSent("Thanks. You can close this tab.");
770
+ } else {
771
+ opts.onSkip();
772
+ swapToSent("All good. You can close this tab.");
773
+ }
774
+ };
775
+ form.addEventListener("submit", (e) => {
776
+ e.preventDefault();
777
+ void submit();
778
+ });
779
+ skipBtn.addEventListener("click", () => {
780
+ ta.value = "";
781
+ void submit();
782
+ });
783
+ ta.addEventListener("keydown", (e) => {
784
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
785
+ e.preventDefault();
786
+ void submit();
787
+ }
788
+ });
789
+ window.requestAnimationFrame(() => {
790
+ ta.focus({ preventScroll: true });
791
+ });
367
792
  }
368
793
  function parseTasks(raw) {
369
794
  if (!Array.isArray(raw)) return [];
@@ -390,12 +815,20 @@ async function createSession(apiUrl, slug, testerName) {
390
815
  return null;
391
816
  }
392
817
  }
393
- async function finaliseSession(apiUrl, sessionId, durationSeconds) {
818
+ async function finaliseSession(apiUrl, sessionId, durationSeconds, extras = {}) {
394
819
  try {
820
+ const body = {
821
+ durationSeconds: Math.max(0, Math.round(durationSeconds))
822
+ };
823
+ if (extras.mutedSegments && extras.mutedSegments.length > 0) {
824
+ body.mutedSegments = extras.mutedSegments;
825
+ }
826
+ const trimmedEndNote = extras.endNote?.trim();
827
+ if (trimmedEndNote) body.endNote = trimmedEndNote;
395
828
  const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/finalise`, {
396
829
  method: "POST",
397
830
  headers: { "Content-Type": "application/json" },
398
- body: JSON.stringify({ durationSeconds: Math.max(0, Math.round(durationSeconds)) }),
831
+ body: JSON.stringify(body),
399
832
  keepalive: true
400
833
  });
401
834
  return res.ok;
@@ -403,6 +836,24 @@ async function finaliseSession(apiUrl, sessionId, durationSeconds) {
403
836
  return false;
404
837
  }
405
838
  }
839
+ async function postNote(apiUrl, sessionId, atMs, text, logger) {
840
+ try {
841
+ const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/notes`, {
842
+ method: "POST",
843
+ headers: { "Content-Type": "application/json" },
844
+ body: JSON.stringify({ atMs: Math.max(0, Math.round(atMs)), text }),
845
+ keepalive: true
846
+ });
847
+ if (!res.ok) {
848
+ logger.warn(`note POST rejected with ${res.status}`);
849
+ return false;
850
+ }
851
+ return true;
852
+ } catch (err) {
853
+ logger.warn("note POST failed", err);
854
+ return false;
855
+ }
856
+ }
406
857
  async function flushPendingFromIdb(store, ctx) {
407
858
  if (!store.sessionId) return;
408
859
  const pending = await idbListChunks(store.sessionId);
@@ -451,6 +902,7 @@ async function startRecording(store, ctx) {
451
902
  return;
452
903
  }
453
904
  store.stream = stream;
905
+ store.hasMicPermission = true;
454
906
  const mimeType = pickMimeType();
455
907
  let recorder;
456
908
  try {
@@ -474,6 +926,34 @@ async function startRecording(store, ctx) {
474
926
  });
475
927
  recorder.start(store.options.chunkSeconds * 1e3);
476
928
  }
929
+ function toggleMute(store) {
930
+ if (!store.stream || !store.hasMicPermission) return false;
931
+ const tracks = store.stream.getAudioTracks();
932
+ if (tracks.length === 0) return false;
933
+ const nowMs = Date.now() - store.startedAt;
934
+ if (!store.muted) {
935
+ for (const t of tracks) t.enabled = false;
936
+ store.muted = true;
937
+ store.mutedSinceMs = nowMs;
938
+ } else {
939
+ const startMs = store.mutedSinceMs ?? nowMs;
940
+ if (nowMs > startMs) {
941
+ store.mutedSegments.push({ startMs, endMs: nowMs });
942
+ }
943
+ store.mutedSinceMs = null;
944
+ store.muted = false;
945
+ for (const t of tracks) t.enabled = true;
946
+ }
947
+ return true;
948
+ }
949
+ function flushMuteIfActive(store) {
950
+ if (!store.muted || store.mutedSinceMs === null) return;
951
+ const nowMs = Date.now() - store.startedAt;
952
+ if (nowMs > store.mutedSinceMs) {
953
+ store.mutedSegments.push({ startMs: store.mutedSinceMs, endMs: nowMs });
954
+ }
955
+ store.mutedSinceMs = null;
956
+ }
477
957
  function stopRecording(store) {
478
958
  const recorder = store.recorder;
479
959
  if (recorder && recorder.state !== "inactive") {
@@ -496,20 +976,34 @@ async function finishFlow(store, ctx, opts) {
496
976
  if (store.cancelled) return;
497
977
  if (store.indicatorState === "finishing" || store.indicatorState === "done") return;
498
978
  store.indicatorState = "finishing";
979
+ flushMuteIfActive(store);
499
980
  renderIndicatorState(store);
500
981
  stopRecording(store);
501
982
  await store.uploadQueue;
502
983
  await flushPendingFromIdb(store, ctx);
503
984
  const durationSeconds = (Date.now() - store.startedAt) / 1e3;
504
985
  if (store.sessionId) {
505
- const ok = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds);
986
+ const ok = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds, {
987
+ mutedSegments: store.mutedSegments
988
+ });
506
989
  store.indicatorState = ok ? "done" : "error";
507
990
  } else {
508
991
  store.indicatorState = "error";
509
992
  }
510
993
  renderIndicatorState(store);
511
994
  if (opts.showThanks && store.indicatorRoot && store.indicatorState === "done") {
512
- showThanksScreen(store.indicatorRoot);
995
+ showThanksScreen(store.indicatorRoot, {
996
+ onSubmitNote: async (text) => {
997
+ if (!store.sessionId) return;
998
+ store.endNote = text;
999
+ await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds, {
1000
+ mutedSegments: store.mutedSegments,
1001
+ endNote: text
1002
+ });
1003
+ },
1004
+ onSkip: () => {
1005
+ }
1006
+ });
513
1007
  }
514
1008
  }
515
1009
  function userTest(options = {}) {
@@ -546,7 +1040,16 @@ function userTest(options = {}) {
546
1040
  tasks: [],
547
1041
  tasksPanelOpen: readTasksPanelOpen(),
548
1042
  outsidePointerHandler: null,
549
- keydownHandler: null
1043
+ keydownHandler: null,
1044
+ hasMicPermission: false,
1045
+ muted: false,
1046
+ mutedSinceMs: null,
1047
+ mutedSegments: [],
1048
+ muteToastShown: false,
1049
+ notes: [],
1050
+ notesPopoverOpen: false,
1051
+ notePopoverAtMs: null,
1052
+ endNote: ""
550
1053
  };
551
1054
  ctx.setStore(store);
552
1055
  const onFinish = () => {
@@ -559,23 +1062,59 @@ function userTest(options = {}) {
559
1062
  renderTasksPanel(store);
560
1063
  };
561
1064
  const onToggleTasks = () => setPanelOpen(!store.tasksPanelOpen);
1065
+ const onToggleMute = () => {
1066
+ if (!store.hasMicPermission) return;
1067
+ const ok = toggleMute(store);
1068
+ if (!ok) return;
1069
+ if (store.muted) showMuteToast(store);
1070
+ renderIndicatorState(store);
1071
+ };
1072
+ const closeNote = () => closeNotePopover(store);
1073
+ const onOpenNote = () => {
1074
+ if (store.notesPopoverOpen) {
1075
+ closeNote();
1076
+ return;
1077
+ }
1078
+ openNotePopover(
1079
+ store,
1080
+ (text) => {
1081
+ const atMs = store.notePopoverAtMs ?? Math.max(0, Date.now() - store.startedAt);
1082
+ store.notes.push({ atMs, text });
1083
+ closeNote();
1084
+ renderNotesCount(store);
1085
+ if (store.sessionId) {
1086
+ void postNote(store.options.apiUrl, store.sessionId, atMs, text, ctx.logger);
1087
+ }
1088
+ },
1089
+ () => closeNote()
1090
+ );
1091
+ };
562
1092
  if (!merged.hideIndicator) {
563
1093
  const host = document.createElement("div");
564
1094
  host.setAttribute("data-usero-user-test", "true");
565
1095
  document.body.appendChild(host);
566
1096
  store.indicator = host;
567
- store.indicatorRoot = buildIndicator(host, store, onFinish, onToggleTasks);
1097
+ store.indicatorRoot = buildIndicator(host, store, {
1098
+ onFinish,
1099
+ onToggleTasks,
1100
+ onToggleMute,
1101
+ onOpenNote
1102
+ });
568
1103
  renderIndicatorState(store);
1104
+ renderNotesCount(store);
569
1105
  }
570
1106
  const outsidePointer = (event) => {
571
- if (!store.tasksPanelOpen) return;
572
1107
  const host = store.indicator;
573
1108
  if (!host) return;
574
1109
  const path = event.composedPath();
575
- if (!path.includes(host)) setPanelOpen(false);
1110
+ if (path.includes(host)) return;
1111
+ if (store.tasksPanelOpen) setPanelOpen(false);
1112
+ if (store.notesPopoverOpen) closeNote();
576
1113
  };
577
1114
  const onKeydown = (event) => {
578
- if (event.key === "Escape" && store.tasksPanelOpen) setPanelOpen(false);
1115
+ if (event.key !== "Escape") return;
1116
+ if (store.tasksPanelOpen) setPanelOpen(false);
1117
+ if (store.notesPopoverOpen) closeNote();
579
1118
  };
580
1119
  store.outsidePointerHandler = outsidePointer;
581
1120
  store.keydownHandler = onKeydown;