@seams/voice-capture 0.68.0 → 0.69.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.
@@ -1,5 +1,5 @@
1
1
  import { PhraseCard } from "./phraseCard.js";
2
- import { t as VoiceVisual } from "../chunks/visual-Di3ojTED.js";
2
+ import { t as VoiceVisual } from "../chunks/visual-DU1OVwCA.js";
3
3
  import { VoiceRibbon } from "./ribbon.js";
4
4
  import { VoicePrompt, leaveSurface } from "./prompt.js";
5
5
  import { openPhraseCard } from "./surface.js";
@@ -229,7 +229,7 @@ var PhraseCard = class {
229
229
  duration: RIBBON_MOVE_MS,
230
230
  easing: EASE_MOVE
231
231
  });
232
- for (const [index, element] of changed.entries()) fadeIn(element, this.motion.matches, 20 + index * 30);
232
+ for (const [index, element] of changed.entries()) fadeIn(element, this.motion.matches, 0 + index * 20);
233
233
  }
234
234
  markedText(text) {
235
235
  if (typeof text === "string") return [text];
@@ -444,7 +444,7 @@ function fadeIn(element, reduced, delay) {
444
444
  transform: "none"
445
445
  }], {
446
446
  delay: reduced ? 0 : delay,
447
- duration: reduced ? 150 : 200,
447
+ duration: reduced ? 150 : 140,
448
448
  easing: ROW_EASE,
449
449
  fill: "both"
450
450
  });
@@ -1,7 +1,7 @@
1
1
  import { styleNonce } from "../config.js";
2
2
  import { useSystemVoiceMicrophone } from "../microphoneInput.js";
3
3
  import { ROW_EASE } from "./rowMotion.js";
4
- import { t as VoiceVisual } from "../chunks/visual-Di3ojTED.js";
4
+ import { t as VoiceVisual } from "../chunks/visual-DU1OVwCA.js";
5
5
  //#region src/card/prompt.ts
6
6
  const CSS = `
7
7
  .seams-voiceid-activation-surface { width: min(100%, 26.25rem); margin: 0 auto; background: transparent; }
@@ -22,7 +22,9 @@ const CSS = `
22
22
  margin: 0 auto;
23
23
  padding: 3.5rem 1.5rem 1.5rem;
24
24
  display: grid;
25
- grid-template-rows: auto minmax(6rem, auto) 5rem minmax(3.375rem, auto) minmax(5rem, auto);
25
+ /* Named rows. The context row is empty (0 high) unless the card shows the text that its sentence
26
+ speaks for. A host that sets its own rows keeps these names. */
27
+ grid-template-rows: [context] auto [title] auto [phrase] minmax(6rem, auto) [line] 5rem [status] minmax(3.375rem, auto) [action] minmax(5rem, auto);
26
28
  border: 1px solid var(--seams-colors-borderPrimary, #303b41);
27
29
  border-radius: var(--seams-shape-card, 16px);
28
30
  background: var(--seams-colors-colorBackground, #192024);
@@ -54,12 +56,14 @@ const CSS = `
54
56
  }
55
57
  /* A card that holds focus itself (until its first control is ready, or after a confirm). */
56
58
  .seams-voiceid-prompt[tabindex="-1"]:focus { outline: none; }
57
- /* Each line owns its row. The download overlays rows 3-5; an auto-placed line would move to new rows. */
58
- .seams-voiceid-prompt > h2 { grid-area: 1 / 1; }
59
- .seams-voiceid-prompt > .seams-voiceid-phrase { grid-area: 2 / 1; }
60
- .seams-voiceid-prompt > .seams-voiceid-ribbon { grid-area: 3 / 1; }
61
- .seams-voiceid-prompt > .seams-voiceid-status { grid-area: 4 / 1; }
62
- .seams-voiceid-progress-slot { grid-row: 5; grid-column: 1; min-width: 0; align-self: center; }
59
+ /* Each line owns its row. The download overlays the rows line, status and action; an auto-placed
60
+ line would move to new rows. */
61
+ .seams-voiceid-prompt > .seams-voiceid-context { grid-row: context; grid-column: 1; }
62
+ .seams-voiceid-prompt > h2 { grid-row: title; grid-column: 1; }
63
+ .seams-voiceid-prompt > .seams-voiceid-phrase { grid-row: phrase; grid-column: 1; }
64
+ .seams-voiceid-prompt > .seams-voiceid-ribbon { grid-row: line; grid-column: 1; }
65
+ .seams-voiceid-prompt > .seams-voiceid-status { grid-row: status; grid-column: 1; }
66
+ .seams-voiceid-progress-slot { grid-row: action; grid-column: 1; min-width: 0; align-self: center; }
63
67
  .seams-voiceid-progress-slot:empty { visibility: hidden; }
64
68
  .seams-voiceid-activation-surface .seams-voiceid-record-slot { transition: opacity 200ms cubic-bezier(0.2, 0, 0, 1) 80ms, visibility 0s linear 0s; }
65
69
  .seams-voiceid-activation-surface[data-preparation="loading"] .seams-voiceid-record-slot { opacity: 0; visibility: hidden; transition: none; }
@@ -255,6 +259,23 @@ const CSS = `
255
259
  overflow: visible;
256
260
  }
257
261
  .seams-voiceid-ribbon .seams-voiceid-ribbon-check { stroke: var(--seams-colors-colorBackground, #192024); }
262
+ /* The theme can colour the voice line (with its ring and its seal) and its three echo lines,
263
+ and set their widths. With none of these values, the line has the card's text colour. */
264
+ .seams-voiceid-ribbon { color: var(--seams-colors-voiceLine, currentColor); }
265
+ .seams-voiceid-ribbon .seams-voiceid-ribbon-line { stroke-width: var(--seams-shape-voiceLineWidth, 1px); }
266
+ .seams-voiceid-ribbon .seams-voiceid-ribbon-echo {
267
+ stroke-width: var(--seams-shape-voiceEchoWidth, 0.65px);
268
+ color: var(--seams-colors-voiceEcho, var(--seams-colors-voiceLine, currentColor));
269
+ }
270
+ .seams-voiceid-ribbon .seams-voiceid-ribbon-echo[data-layer="2"] {
271
+ color: var(--seams-colors-voiceEchoDeep, var(--seams-colors-voiceEcho, var(--seams-colors-voiceLine, currentColor)));
272
+ }
273
+ /* The pixel style (voiceLine: 'pixels'): dots in X blue tones by default, the lightest in the
274
+ middle row. The theme colours the three bands: voiceEcho (the middle row), voiceLine (rows
275
+ 1 and 2 from it) and voiceEchoDeep (rows 3 and 4). */
276
+ .seams-voiceid-ribbon-pixel { fill: var(--seams-colors-voiceEchoDeep, #1a6fb0); }
277
+ .seams-voiceid-ribbon-pixel[data-band="1"] { fill: var(--seams-colors-voiceLine, #1d9bf0); }
278
+ .seams-voiceid-ribbon-pixel[data-band="0"] { fill: var(--seams-colors-voiceEcho, #8ecdf8); }
258
279
  .seams-voiceid-prompt .seams-voiceid-phrase.seams-voiceid-success-headline {
259
280
  display: grid;
260
281
  align-content: end;
@@ -302,8 +323,10 @@ const CSS = `
302
323
  right: auto;
303
324
  left: 0.75rem;
304
325
  }
326
+ /* Minimize sits beside Close, in the trailing corner: one button width and a small gap in. */
327
+ .seams-voiceid-prompt .seams-voiceid-close.seams-voiceid-minimize { right: 3.375rem; }
305
328
  .seams-voiceid-prompt .seams-voiceid-record-slot {
306
- grid-row: 5;
329
+ grid-row: action;
307
330
  grid-column: 1;
308
331
  align-self: center;
309
332
  width: 100%;
@@ -385,7 +408,7 @@ const CSS = `
385
408
  .seams-voiceid-prompt:has(.seams-voiceid-model-download:not([data-state="ready"])) > .seams-voiceid-phrase { opacity: 0.35; }
386
409
  .seams-voiceid-prompt:has(.seams-voiceid-model-download[data-state="failed"]) > .seams-voiceid-record-slot { display: none; }
387
410
  .seams-voiceid-model-download progress {
388
- grid-row: 3;
411
+ grid-row: line;
389
412
  justify-self: center;
390
413
  appearance: none;
391
414
  display: block;
@@ -403,7 +426,7 @@ const CSS = `
403
426
  a looping animation would compete with it on a software-rendered page. */
404
427
  .seams-voiceid-model-download progress::-moz-progress-bar { background: var(--seams-colors-textPrimary, #eff2f3); border-radius: 999px; }
405
428
  .seams-voiceid-model-download p {
406
- grid-row: 4;
429
+ grid-row: status;
407
430
  align-self: start;
408
431
  margin: 0;
409
432
  font-size: 0.75rem;
@@ -411,9 +434,9 @@ const CSS = `
411
434
  color: var(--seams-colors-textSecondary, #9cabb3);
412
435
  font-variant-numeric: tabular-nums;
413
436
  }
414
- .seams-voiceid-model-download button { grid-row: 5; }
437
+ .seams-voiceid-model-download button { grid-row: action; }
415
438
  /* A phrase card shows its check and its result in the same card. */
416
- .seams-voiceid-prompt > .seams-voiceid-status-line { grid-area: 3 / 1; }
439
+ .seams-voiceid-prompt > .seams-voiceid-status-line { grid-row: line; grid-column: 1; }
417
440
  .seams-voiceid-status-line {
418
441
  transition: opacity 120ms ease;
419
442
  display: block;
@@ -423,7 +446,9 @@ const CSS = `
423
446
  margin: auto;
424
447
  overflow: visible;
425
448
  opacity: 0;
426
- color: var(--seams-colors-textPrimary, #eff2f3);
449
+ /* The theme can colour the check's line and the seal that it closes into (the disc behind the
450
+ tick): voiceSeal, else the voice line's colour, else the card's text colour. */
451
+ color: var(--seams-colors-voiceSeal, var(--seams-colors-voiceLine, var(--seams-colors-textPrimary, #eff2f3)));
427
452
  }
428
453
  .seams-voiceid-ribbon { transition: opacity 120ms ease; }
429
454
  .seams-voiceid-prompt[data-phrase-line="status"] > .seams-voiceid-ribbon { opacity: 0; }
@@ -463,12 +488,29 @@ const CSS = `
463
488
  .seams-voiceid-prompt .seams-voiceid-phrase.seams-voiceid-phrase-dim { color: var(--seams-colors-textMuted, #6f7f87); }
464
489
  .seams-voiceid-prompt[data-phrase-view] > h2 { min-height: 1.19rem; }
465
490
  .seams-voiceid-prompt[data-phrase-view="check"] {
466
- grid-template-rows: auto minmax(6rem, auto) 5rem minmax(3.375rem, auto) 0;
491
+ grid-template-rows: [context] auto [title] auto [phrase] minmax(6rem, auto) [line] 5rem [status] minmax(3.375rem, auto) [action] 0;
467
492
  }
468
493
  .seams-voiceid-prompt[data-phrase-view="check"] .seams-voiceid-record-slot { display: none; }
494
+ /* A card that records by itself (autoStart) has no Record row: its rows sit in the middle. */
495
+ .seams-voiceid-prompt[data-auto-start] {
496
+ grid-template-rows: [context] auto [title] auto [phrase] minmax(6rem, auto) [line] 5rem [status] minmax(3.375rem, auto) [action] 0;
497
+ }
498
+ .seams-voiceid-prompt[data-auto-start] > .seams-voiceid-record-slot { display: none; }
499
+ /* The result reads from the top as one group: the seal, the headline under it, the caption and
500
+ the detail, then the button. Every row has its content's height, so the card has no empty
501
+ band, and the line row glides to its new place (phraseCard.ts transition). An empty label
502
+ takes no room. */
503
+ .seams-voiceid-prompt[data-phrase-view="result"] {
504
+ grid-template-rows: [context] auto [title] auto [line] 4.5rem [phrase] auto [status] auto [action] auto;
505
+ }
506
+ .seams-voiceid-prompt[data-phrase-view="result"] > h2:empty { display: none; }
507
+ .seams-voiceid-prompt[data-phrase-view="result"] > .seams-voiceid-phrase { margin-top: 0.5rem; }
508
+ .seams-voiceid-prompt[data-phrase-view="result"] > .seams-voiceid-status { min-height: 0; margin-top: 0.375rem; }
509
+ .seams-voiceid-prompt[data-phrase-view="result"] > .seams-voiceid-record-slot { margin-top: 1.5rem; }
469
510
  .seams-voiceid-prompt[data-phrase-view] .seams-voiceid-microphone { display: none; }
470
511
  .seams-voiceid-prompt .seams-voiceid-close { transition: opacity 150ms ease, visibility 0s linear 0s; }
471
- .seams-voiceid-prompt[data-phrase-view="check"] .seams-voiceid-close {
512
+ /* The check hides Back and Close, as the caller works; Minimize stays. */
513
+ .seams-voiceid-prompt[data-phrase-view="check"] .seams-voiceid-close:not(.seams-voiceid-minimize) {
472
514
  opacity: 0;
473
515
  visibility: hidden;
474
516
  transition: opacity 150ms ease, visibility 0s linear 150ms;
@@ -505,6 +547,213 @@ const CSS = `
505
547
  @media (prefers-reduced-motion: reduce) {
506
548
  .seams-voiceid-status-line rect { animation: none !important; transition: none; }
507
549
  }
550
+ /* The note layout: a card with a context, in the shape of a form. The context (a note) sits
551
+ in a soft box under its label, the sentence to read sits centred in a blue box under its own
552
+ label, and one button right under it changes with the card: Record or the voice line while
553
+ it records, the check, then the result's action. The status sits at the foot, under the
554
+ button. */
555
+ .seams-voiceid-prompt[data-context] {
556
+ grid-template-rows: [context] auto [title] auto [phrase] auto [line] 4.5rem [action] 0 [status] auto;
557
+ text-align: start;
558
+ /* The height of the button: 4.5rem as the voice line, with either line style, and 3.5rem as
559
+ Record or a result's action. The microphone's circle follows it. */
560
+ --seams-voiceid-button-height: 4.5rem;
561
+ /* The fill of what the person says: a tint of the main button's colour (voiceFill). */
562
+ --seams-voiceid-tint: 16%;
563
+ --seams-voiceid-fill: var(--seams-colors-voiceFill, color-mix(in srgb, var(--seams-colors-buttonBackground, #1d9bf0) var(--seams-voiceid-tint), var(--seams-colors-colorBackground, #192024)));
564
+ }
565
+ /* A form is as wide as an app's dialog for a note: 560 px. */
566
+ .seams-voiceid-activation-surface:has(> .seams-voiceid-prompt[data-context]),
567
+ .seams-voiceid-prompt[data-context] {
568
+ width: min(100%, 35rem);
569
+ }
570
+ [data-theme="light"] .seams-voiceid-prompt[data-context] { --seams-voiceid-tint: 14%; }
571
+ [data-theme="dark"] .seams-voiceid-prompt[data-context] { --seams-voiceid-tint: 24%; }
572
+ /* A button (Record, Try again, a result's action) takes the place of the voice line. */
573
+ .seams-voiceid-prompt[data-context]:not([data-phrase-view="check"]):has(> .seams-voiceid-record-slot > .seams-voiceid-primary:not([hidden])) {
574
+ --seams-voiceid-button-height: 3.5rem;
575
+ grid-template-rows: [context] auto [title] auto [phrase] auto [line] 0 [action] auto [status] auto;
576
+ }
577
+ /* A result's caption and detail read before its button. */
578
+ .seams-voiceid-prompt[data-context][data-phrase-view="result"]:not([data-phrase-view="check"]):has(> .seams-voiceid-record-slot > .seams-voiceid-primary:not([hidden])) {
579
+ grid-template-rows: [context] auto [title] auto [phrase] auto [status] auto [line] 0 [action] auto;
580
+ }
581
+ .seams-voiceid-prompt[data-context]:not([data-phrase-view="check"]):has(> .seams-voiceid-record-slot > .seams-voiceid-primary:not([hidden])) > :is(.seams-voiceid-ribbon, .seams-voiceid-status-line) {
582
+ visibility: hidden;
583
+ }
584
+ .seams-voiceid-prompt[data-context] > .seams-voiceid-glow { display: none; }
585
+ /* A line across the card under the context: above it is what the person posts, below it is
586
+ the voice signature. */
587
+ .seams-voiceid-prompt .seams-voiceid-context {
588
+ margin: 0 -1.5rem 1.25rem;
589
+ padding: 0 1.5rem 1.25rem;
590
+ border: 0;
591
+ border-bottom: 1px solid var(--seams-colors-borderPrimary, #303b41);
592
+ }
593
+ .seams-voiceid-prompt .seams-voiceid-context-label {
594
+ display: block;
595
+ margin-bottom: 0.375rem;
596
+ color: var(--seams-colors-textSecondary, #9cabb3);
597
+ font-size: 0.9375rem;
598
+ line-height: 1.3;
599
+ }
600
+ .seams-voiceid-prompt .seams-voiceid-context > blockquote {
601
+ max-height: 8.5rem;
602
+ margin: 0;
603
+ padding: 0.75rem 1rem;
604
+ overflow-y: auto;
605
+ border-radius: 1rem;
606
+ background: var(--seams-colors-surface, #202b31);
607
+ color: var(--seams-colors-textPrimary, #eff2f3);
608
+ font-size: 1rem;
609
+ line-height: 1.45;
610
+ white-space: pre-line;
611
+ overflow-wrap: anywhere;
612
+ }
613
+ /* A result has no label: its empty title takes no room. */
614
+ .seams-voiceid-prompt[data-context] > h2:empty { display: none; }
615
+ .seams-voiceid-prompt[data-context] > h2 {
616
+ margin: 0 0 0.625rem;
617
+ color: var(--seams-colors-textPrimary, #eff2f3);
618
+ font-size: 1rem;
619
+ font-weight: 700;
620
+ line-height: 1.3;
621
+ text-align: center;
622
+ }
623
+ .seams-voiceid-prompt[data-context] .seams-voiceid-phrase {
624
+ box-sizing: border-box;
625
+ width: 100%;
626
+ max-width: none;
627
+ /* A thin gap to the button: the sentence and the voice belong together. */
628
+ margin: 0 0 0.5rem;
629
+ padding: 0.875rem 1.25rem;
630
+ border-radius: 1rem;
631
+ /* Blue, apart from the grey box of the context: this is what the person says. */
632
+ background: var(--seams-voiceid-fill);
633
+ font-size: 1.25rem;
634
+ font-weight: 600;
635
+ letter-spacing: normal;
636
+ line-height: 1.35;
637
+ text-align: center;
638
+ }
639
+ .seams-voiceid-prompt[data-context] .seams-voiceid-phrase.seams-voiceid-success-headline {
640
+ display: block;
641
+ padding: 0;
642
+ background: none;
643
+ font-size: 1.25rem;
644
+ font-weight: 700;
645
+ }
646
+ /* The status sits at the foot of the card, under the button: "Listening", or the check. */
647
+ .seams-voiceid-prompt[data-context] .seams-voiceid-status {
648
+ min-height: 1.125rem;
649
+ margin: 0;
650
+ padding: 0.625rem 0 0;
651
+ text-align: center;
652
+ }
653
+ .seams-voiceid-prompt[data-context][data-phrase-view="result"] .seams-voiceid-status { padding: 0.25rem 0 0.75rem; }
654
+ /* Under the button, the check's caption and detail share one line at the size of "Listening",
655
+ so the card keeps its height when the check starts. A long line ends with an ellipsis. */
656
+ .seams-voiceid-prompt[data-context]:not([data-phrase-view="result"]) .seams-voiceid-status.seams-voiceid-two-lines {
657
+ overflow: hidden;
658
+ white-space: nowrap;
659
+ text-overflow: ellipsis;
660
+ }
661
+ .seams-voiceid-prompt[data-context]:not([data-phrase-view="result"]) .seams-voiceid-status.seams-voiceid-two-lines > span {
662
+ display: inline;
663
+ }
664
+ .seams-voiceid-prompt[data-context]:not([data-phrase-view="result"]) .seams-voiceid-status .seams-voiceid-caption {
665
+ margin: 0;
666
+ font-size: inherit;
667
+ }
668
+ .seams-voiceid-prompt[data-context]:not([data-phrase-view="result"]) .seams-voiceid-status .seams-voiceid-caption-detail:not(:empty)::before {
669
+ content: ' · ';
670
+ }
671
+ /* A result's link, such as the transaction in a block explorer: short, in the main button's
672
+ colour, underlined only on hover. */
673
+ .seams-voiceid-prompt[data-context] .seams-voiceid-status a {
674
+ color: var(--seams-colors-buttonBackground, var(--seams-colors-textPrimary, #eff2f3));
675
+ font-weight: 500;
676
+ text-decoration: none;
677
+ }
678
+ .seams-voiceid-prompt[data-context] .seams-voiceid-status a:hover,
679
+ .seams-voiceid-prompt[data-context] .seams-voiceid-status a:focus-visible { text-decoration: underline; }
680
+ /* The voice line and the check, in the main button's colours, with either line style. The line
681
+ keeps its drawing size; the taller button gives it room above and below. */
682
+ .seams-voiceid-prompt[data-context] > :is(.seams-voiceid-ribbon, .seams-voiceid-status-line) {
683
+ box-sizing: border-box;
684
+ width: 100%;
685
+ height: 4.5rem;
686
+ padding-block: 0.5rem;
687
+ overflow: hidden;
688
+ border-radius: 9999px;
689
+ background: var(--seams-colors-buttonBackground, var(--seams-colors-textPrimary, #eff2f3));
690
+ color: var(--seams-colors-textButton, var(--seams-colors-colorBackground, #192024));
691
+ }
692
+ .seams-voiceid-prompt[data-context] .seams-voiceid-ribbon .seams-voiceid-ribbon-echo { color: currentColor; }
693
+ .seams-voiceid-prompt[data-context] .seams-voiceid-status-line rect {
694
+ fill: color-mix(in srgb, currentColor 35%, transparent);
695
+ }
696
+ .seams-voiceid-prompt[data-context] .seams-voiceid-status-line rect[data-state="done"],
697
+ .seams-voiceid-prompt[data-context] .seams-voiceid-status-line rect[data-state="run"] { fill: currentColor; }
698
+ .seams-voiceid-prompt[data-context] .seams-voiceid-status-check {
699
+ stroke: var(--seams-colors-buttonBackground, var(--seams-colors-textPrimary, #eff2f3));
700
+ }
701
+ .seams-voiceid-prompt[data-context][data-voice-line="pixels"] {
702
+ --seams-voiceid-pixel-background: var(--seams-colors-voicePixelBackground, var(--seams-colors-buttonBackground, var(--seams-colors-textPrimary, #eff2f3)));
703
+ --seams-voiceid-pixel-ink: var(--seams-colors-textButton, var(--seams-colors-colorBackground, #192024));
704
+ }
705
+ .seams-voiceid-prompt[data-context][data-voice-line="pixels"] > :is(.seams-voiceid-ribbon, .seams-voiceid-status-line) {
706
+ background: var(--seams-voiceid-pixel-background);
707
+ color: var(--seams-voiceid-pixel-ink);
708
+ }
709
+ .seams-voiceid-prompt[data-context][data-voice-line="pixels"] .seams-voiceid-status-check {
710
+ stroke: var(--seams-voiceid-pixel-background);
711
+ }
712
+ /* On the button, the dots are the button's text colour: full in the middle row, then mixed a
713
+ little and more with the button's colour (white, light and light blue on X blue). */
714
+ .seams-voiceid-prompt[data-context] .seams-voiceid-ribbon-pixel[data-band="0"] { fill: var(--seams-voiceid-pixel-ink); }
715
+ .seams-voiceid-prompt[data-context] .seams-voiceid-ribbon-pixel[data-band="1"] {
716
+ fill: color-mix(in srgb, var(--seams-voiceid-pixel-ink) 80%, var(--seams-voiceid-pixel-background));
717
+ }
718
+ .seams-voiceid-prompt[data-context] .seams-voiceid-ribbon-pixel[data-band="2"] {
719
+ fill: color-mix(in srgb, var(--seams-voiceid-pixel-ink) 55%, var(--seams-voiceid-pixel-background));
720
+ }
721
+ /* The microphone at the left of the button, as on an app's own voice button: on Record, on the
722
+ voice line while the card records, and on the check. A result's button has none. Its circle
723
+ keeps the same gap to the button's top, left and bottom, so it follows the button's round end.
724
+ The line leaves the icon its room. */
725
+ .seams-voiceid-prompt[data-context] > .seams-voiceid-line-icon {
726
+ --seams-voiceid-icon-gap: 0.375rem;
727
+ grid-row: line / span 2;
728
+ grid-column: 1;
729
+ align-self: center;
730
+ justify-self: start;
731
+ z-index: 2;
732
+ display: grid;
733
+ place-items: center;
734
+ width: calc(var(--seams-voiceid-button-height) - 2 * var(--seams-voiceid-icon-gap));
735
+ height: calc(var(--seams-voiceid-button-height) - 2 * var(--seams-voiceid-icon-gap));
736
+ margin-inline-start: var(--seams-voiceid-icon-gap);
737
+ border-radius: 50%;
738
+ transition: width 180ms ease, height 180ms ease;
739
+ background: var(--seams-colors-textButton, var(--seams-colors-colorBackground, #192024));
740
+ color: var(--seams-colors-buttonBackground, var(--seams-colors-textPrimary, #eff2f3));
741
+ pointer-events: none;
742
+ }
743
+ .seams-voiceid-line-icon > svg { width: 46%; height: 46%; }
744
+ .seams-voiceid-prompt[data-context][data-phrase-view="result"] > .seams-voiceid-line-icon { display: none; }
745
+ .seams-voiceid-prompt[data-context] > :is(.seams-voiceid-ribbon, .seams-voiceid-status-line) {
746
+ box-sizing: border-box;
747
+ padding-inline: calc(var(--seams-voiceid-button-height) + 0.25rem) 1rem;
748
+ }
749
+ /* The status line's own space below it is the gap above the button, in every view. */
750
+ .seams-voiceid-prompt[data-context] > .seams-voiceid-record-slot { margin-top: 0; }
751
+ .seams-voiceid-prompt[data-context] .seams-voiceid-primary {
752
+ min-height: 3.5rem;
753
+ border-radius: 9999px;
754
+ font-size: 1.0625rem;
755
+ font-weight: 700;
756
+ }
508
757
  @media (hover: hover) and (pointer: fine) {
509
758
  .seams-voiceid-close:hover { background: var(--seams-colors-surface, #202b31); }
510
759
  .seams-voiceid-primary:hover:not(:disabled) { opacity: 0.9; }
@@ -595,6 +844,29 @@ var VoicePrompt = class {
595
844
  this.element.append(this.visual.glow, this.cancel, this.title, this.phrase, this.visual.element, this.status, slot, this.progress);
596
845
  this.sizes.observe(this.element);
597
846
  }
847
+ /** Draws the voice as wave lines (the default) or as columns of pixels. */
848
+ useVoiceLine(style) {
849
+ this.element.dataset.voiceLine = style;
850
+ this.visual.setLineStyle(style);
851
+ }
852
+ /**
853
+ * Shows the text that the sentence speaks for, such as the note that a spoken summary signs,
854
+ * above the sentence. It stays for the check and the result.
855
+ */
856
+ showContext(label, text) {
857
+ const document = this.element.ownerDocument;
858
+ const context = document.createElement("figure");
859
+ context.className = "seams-voiceid-context";
860
+ const caption = document.createElement("figcaption");
861
+ caption.className = "seams-voiceid-context-label";
862
+ caption.textContent = label;
863
+ const body = document.createElement("blockquote");
864
+ body.textContent = text;
865
+ context.append(caption, body);
866
+ this.title.before(context);
867
+ this.element.append(microphoneIcon(document));
868
+ this.element.dataset.context = "";
869
+ }
598
870
  /**
599
871
  * In an app's dialog with a step before this card, Back in the leading corner returns to that
600
872
  * step. With `close`, Close stays in the trailing corner and ends the whole dialog; without it,
@@ -614,6 +886,16 @@ var VoicePrompt = class {
614
886
  return this.cancel;
615
887
  }
616
888
  /**
889
+ * Minimize in the trailing corner, beside Close. It stays in the check, where Close is hidden,
890
+ * so the person can hide the card while the check goes on. The result is the control.
891
+ */
892
+ showMinimize() {
893
+ const minimize = cornerButton(this.element.ownerDocument, "Minimize", MINIMIZE_PATH);
894
+ minimize.classList.add("seams-voiceid-minimize");
895
+ this.cancel.before(minimize);
896
+ return minimize;
897
+ }
898
+ /**
617
899
  * Moves the rows to a new state. The rows whose content changes fade out together, take their
618
900
  * new content and fade in one after another; the other rows stay still. A change that comes
619
901
  * during a fade joins it, so no state in between is drawn.
@@ -1018,8 +1300,8 @@ function fadeRowsIn(rows) {
1018
1300
  opacity: to,
1019
1301
  transform: "none"
1020
1302
  }], {
1021
- delay: reduced ? 0 : 20 + index * 30,
1022
- duration: reduced ? 150 : 200,
1303
+ delay: reduced ? 0 : 0 + index * 20,
1304
+ duration: reduced ? 150 : 140,
1023
1305
  easing: ROW_EASE,
1024
1306
  fill: "backwards",
1025
1307
  id: ROW_MOTION
@@ -1029,7 +1311,7 @@ function fadeRowsIn(rows) {
1029
1311
  function ignoreCancel() {}
1030
1312
  function continueFade({ row, opacity }) {
1031
1313
  row.animate([{ opacity }, { opacity: 1 }], {
1032
- duration: 200 * (1 - opacity),
1314
+ duration: 140 * (1 - opacity),
1033
1315
  easing: ROW_EASE,
1034
1316
  id: ROW_MOTION
1035
1317
  });
@@ -1062,6 +1344,34 @@ function leaveSurface(element) {
1062
1344
  }
1063
1345
  const CLOSE_PATH = "M6 6l12 12M18 6L6 18";
1064
1346
  const BACK_PATH = "m15 18-6-6 6-6";
1347
+ const MINIMIZE_PATH = "M6 12h12";
1348
+ /** A microphone: its head, its stand and its foot. */
1349
+ const MICROPHONE_PATHS = [
1350
+ "M9 5a3 3 0 0 1 6 0v5a3 3 0 0 1-6 0z",
1351
+ "M5 10a7 7 0 0 0 14 0",
1352
+ "M8 21h8",
1353
+ "M12 17v4"
1354
+ ];
1355
+ /** The microphone at the left of a form card's button, in its own circle. */
1356
+ function microphoneIcon(document) {
1357
+ const circle = document.createElement("span");
1358
+ circle.className = "seams-voiceid-line-icon";
1359
+ circle.setAttribute("aria-hidden", "true");
1360
+ const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1361
+ icon.setAttribute("viewBox", "0 0 24 24");
1362
+ for (const d of MICROPHONE_PATHS) {
1363
+ const part = document.createElementNS("http://www.w3.org/2000/svg", "path");
1364
+ part.setAttribute("d", d);
1365
+ part.setAttribute("fill", "none");
1366
+ part.setAttribute("stroke", "currentColor");
1367
+ part.setAttribute("stroke-width", "1.8");
1368
+ part.setAttribute("stroke-linecap", "round");
1369
+ part.setAttribute("stroke-linejoin", "round");
1370
+ icon.append(part);
1371
+ }
1372
+ circle.append(icon);
1373
+ return circle;
1374
+ }
1065
1375
  /** A control in a corner of the card, with an icon and no text. */
1066
1376
  function cornerButton(document, label, path) {
1067
1377
  const button = document.createElement("button");
@@ -31,6 +31,19 @@ const LINE_REST_OPACITY = .55;
31
31
  const SUCCESS_MS = 1140;
32
32
  const SEAL_RADIUS = 22.5;
33
33
  const CHECK_PATH = "M161 26.8 151.4 36.3 147.1 32";
34
+ const PIXEL_COLUMNS = 44;
35
+ /** The rows above the middle row, and the rows below it. */
36
+ const PIXEL_ROWS = 4;
37
+ const PIXEL_PITCH = 7;
38
+ /** Rows sit closer than columns, so the tallest column keeps a gap to the edge of a button. */
39
+ const PIXEL_ROW_PITCH = 6;
40
+ const PIXEL_SIZE = 4.5;
41
+ const PIXEL_STEP_MS = 70;
42
+ /** A column shows between this share of the level and all of it. */
43
+ const PIXEL_SPREAD = .3;
44
+ const PIXEL_IDLE_LEVEL = .18;
45
+ const PIXEL_IDLE_SWING = .12;
46
+ const PIXEL_IDLE_PERIOD_MS = 2400;
34
47
  const easeMove = cubicBezier(.65, 0, .35, 1);
35
48
  const easeArc = cubicBezier(.4, 0, .2, 1);
36
49
  const easeSeal = cubicBezier(.34, 1.56, .64, 1);
@@ -57,6 +70,14 @@ var VoiceRibbon = class {
57
70
  target = 0;
58
71
  level = 0;
59
72
  stopped = false;
73
+ lineStyle = "wave";
74
+ /** The pixel style's dots by column, top row first. Made when the style is first chosen. */
75
+ pixels = null;
76
+ /** The level of each column, the newest at the right end. */
77
+ pixelLevels = new Array(PIXEL_COLUMNS).fill(0);
78
+ /** The rows that each column shows now, above and below the middle row. */
79
+ pixelHeights = new Array(PIXEL_COLUMNS).fill(-1);
80
+ pixelClock = 0;
60
81
  constructor(document, checking = "ring") {
61
82
  this.checking = checking;
62
83
  this.element = document.createElementNS(SVG_NAMESPACE, "svg");
@@ -94,10 +115,61 @@ var VoiceRibbon = class {
94
115
  this.bottom
95
116
  ].entries()) segment.dataset.seamsProgressSegment = String(index);
96
117
  }
97
- for (let layer = 3; layer >= 0; layer -= 1) this.lines.push(this.append(document, "path", layer === 0 ? "1" : "0.65"));
118
+ for (let layer = 3; layer >= 0; layer -= 1) {
119
+ const line = this.append(document, "path", layer === 0 ? "1" : "0.65");
120
+ line.classList.add(layer === 0 ? "seams-voiceid-ribbon-line" : "seams-voiceid-ribbon-echo");
121
+ if (layer > 0) line.dataset.layer = String(layer);
122
+ this.lines.push(line);
123
+ }
98
124
  this.paint(performance.now());
99
125
  }
100
126
  readLevel = () => this.level;
127
+ /** Draws the voice as wave lines (the default) or as columns of pixels. */
128
+ setLineStyle(style) {
129
+ this.lineStyle = style;
130
+ this.element.dataset.lineStyle = style;
131
+ if (style === "pixels" && !this.pixels) this.pixels = this.makePixels();
132
+ this.pixelHeights.fill(-1);
133
+ this.paint(performance.now());
134
+ this.wake();
135
+ }
136
+ makePixels() {
137
+ const document = this.element.ownerDocument;
138
+ const group = document.createElementNS(SVG_NAMESPACE, "g");
139
+ group.classList.add("seams-voiceid-ribbon-pixels");
140
+ group.setAttribute("opacity", "0");
141
+ const left = (WIDTH - ((PIXEL_COLUMNS - 1) * PIXEL_PITCH + PIXEL_SIZE)) / 2;
142
+ const columns = [];
143
+ for (let column = 0; column < PIXEL_COLUMNS; column += 1) {
144
+ const dots = [];
145
+ for (let row = -PIXEL_ROWS; row <= PIXEL_ROWS; row += 1) {
146
+ const dot = document.createElementNS(SVG_NAMESPACE, "rect");
147
+ dot.classList.add("seams-voiceid-ribbon-pixel");
148
+ const distance = Math.abs(row);
149
+ dot.dataset.band = distance === 0 ? "0" : distance <= 2 ? "1" : "2";
150
+ dot.setAttribute("x", (left + column * PIXEL_PITCH).toFixed(2));
151
+ dot.setAttribute("y", (MID - PIXEL_SIZE / 2 + row * PIXEL_ROW_PITCH).toFixed(2));
152
+ dot.setAttribute("width", String(PIXEL_SIZE));
153
+ dot.setAttribute("height", String(PIXEL_SIZE));
154
+ dot.setAttribute("rx", "1");
155
+ dot.setAttribute("opacity", "0");
156
+ group.append(dot);
157
+ dots.push(dot);
158
+ }
159
+ columns.push(dots);
160
+ }
161
+ this.element.prepend(group);
162
+ return {
163
+ group,
164
+ columns
165
+ };
166
+ }
167
+ /** The voice line's width now: 1, or the theme's `voiceLineWidth`. */
168
+ lineWidth() {
169
+ const line = this.lines[this.lines.length - 1];
170
+ const width = line ? Number.parseFloat(getComputedStyle(line).strokeWidth) : NaN;
171
+ return Number.isFinite(width) && width > 0 ? width : 1;
172
+ }
101
173
  setPhase(phase) {
102
174
  if (phase === "processing" && this.stage.kind === "line") {
103
175
  const amplitude = Math.max(this.level, this.idleAmplitude(performance.now()));
@@ -177,13 +249,23 @@ var VoiceRibbon = class {
177
249
  this.previousTime = time;
178
250
  this.level += (this.target - this.level) * (1 - Math.exp(-dt / (this.target > this.level ? 55 : 200)));
179
251
  if (!this.motion.matches) this.position += dt * (.0012 + this.level * .003);
252
+ if (this.lineStyle === "pixels") this.stepPixels(dt);
180
253
  this.paint(time);
181
- if (!(!this.element.isConnected || this.stage.kind === "sealed" || this.stage.kind === "line" && this.phase === "idle" && this.level <= .001)) this.frame = requestAnimationFrame(this.draw);
254
+ if (!(!this.element.isConnected || this.stage.kind === "sealed" || this.stage.kind === "line" && this.phase === "idle" && this.level <= .001 && this.pixelLevels.every((level) => level <= .001))) this.frame = requestAnimationFrame(this.draw);
182
255
  };
183
256
  idleAmplitude(time) {
184
257
  if (this.phase !== "listening" || this.motion.matches) return 0;
185
258
  return IDLE_AMPLITUDE * (.72 + .28 * Math.sin(time / IDLE_PERIOD_MS * TAU));
186
259
  }
260
+ /** Adds the level of each step at the right end; the older columns move left. */
261
+ stepPixels(dt) {
262
+ this.pixelClock += dt;
263
+ while (this.pixelClock >= PIXEL_STEP_MS) {
264
+ this.pixelClock -= PIXEL_STEP_MS;
265
+ this.pixelLevels.shift();
266
+ this.pixelLevels.push(this.level * (PIXEL_SPREAD + (1 - PIXEL_SPREAD) * Math.random()));
267
+ }
268
+ }
187
269
  paint(time) {
188
270
  for (const part of [
189
271
  this.ripple,
@@ -195,9 +277,11 @@ var VoiceRibbon = class {
195
277
  this.check
196
278
  ]) part.setAttribute("opacity", "0");
197
279
  for (const line of this.lines) line.setAttribute("opacity", "0");
280
+ this.pixels?.group.setAttribute("opacity", "0");
198
281
  switch (this.stage.kind) {
199
282
  case "line":
200
- this.paintLine(Math.max(this.level, this.idleAmplitude(time)));
283
+ if (this.lineStyle === "pixels") this.paintPixels(time);
284
+ else this.paintLine(Math.max(this.level, this.idleAmplitude(time)));
201
285
  return;
202
286
  case "capture":
203
287
  this.paintCapture(time, this.stage);
@@ -226,6 +310,23 @@ var VoiceRibbon = class {
226
310
  this.lines[index].setAttribute("opacity", String(layer === 0 ? LINE_REST_OPACITY + amplitude * .4 : .1 + amplitude * .1));
227
311
  }
228
312
  }
313
+ /** Each column shows the middle dot and as many rows above and below it as its level. */
314
+ paintPixels(time) {
315
+ if (!this.pixels) return;
316
+ this.pixels.group.setAttribute("opacity", "1");
317
+ const live = this.phase === "listening" && !this.motion.matches;
318
+ for (const [column, dots] of this.pixels.columns.entries()) {
319
+ let level = this.pixelLevels[column] ?? 0;
320
+ if (live) {
321
+ const wave = Math.sin(time / PIXEL_IDLE_PERIOD_MS * TAU - column * .55);
322
+ level = Math.max(level, PIXEL_IDLE_LEVEL + PIXEL_IDLE_SWING * wave);
323
+ }
324
+ const height = Math.min(PIXEL_ROWS, Math.round(level * PIXEL_ROWS * 1.3));
325
+ if (height === this.pixelHeights[column]) continue;
326
+ this.pixelHeights[column] = height;
327
+ for (const [index, dot] of dots.entries()) dot.setAttribute("opacity", Math.abs(index - PIXEL_ROWS) <= height ? "1" : "0");
328
+ }
329
+ }
229
330
  paintSegments(time, stage) {
230
331
  const elapsed = time - stage.startedAt;
231
332
  if (!this.motion.matches && elapsed < SPLIT_FLATTEN_MS) {
@@ -242,7 +343,7 @@ var VoiceRibbon = class {
242
343
  ].entries()) {
243
344
  const start = index * (width + gap);
244
345
  segment.setAttribute("d", `M${start.toFixed(2)} ${MID}H${(start + width).toFixed(2)}`);
245
- segment.setAttribute("stroke-width", "1");
346
+ segment.setAttribute("stroke-width", String(this.lineWidth()));
246
347
  segment.setAttribute("opacity", this.segmentOpacity(split, index));
247
348
  }
248
349
  }
@@ -268,7 +369,7 @@ var VoiceRibbon = class {
268
369
  const lineOpacity = LINE_REST_OPACITY + amplitude * .4;
269
370
  for (const [half, turned] of [[this.top, false], [this.bottom, true]]) {
270
371
  half.setAttribute("d", this.halfPath(tail, head, turned, amplitude, waveScale));
271
- half.setAttribute("stroke-width", lerp(1, 2, settled).toFixed(2));
372
+ half.setAttribute("stroke-width", lerp(this.lineWidth(), Math.max(2, this.lineWidth()), settled).toFixed(2));
272
373
  half.setAttribute("opacity", lerp(lineOpacity, 1, settled).toFixed(3));
273
374
  }
274
375
  this.middle.setAttribute("d", this.linePath(0, amplitude, BEND_START, WIDTH - BEND_START, waveScale));