@seams/voice-capture 0.68.1 → 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-CeEQ8QkM.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-CeEQ8QkM.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; }
@@ -266,6 +270,12 @@ const CSS = `
266
270
  .seams-voiceid-ribbon .seams-voiceid-ribbon-echo[data-layer="2"] {
267
271
  color: var(--seams-colors-voiceEchoDeep, var(--seams-colors-voiceEcho, var(--seams-colors-voiceLine, currentColor)));
268
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); }
269
279
  .seams-voiceid-prompt .seams-voiceid-phrase.seams-voiceid-success-headline {
270
280
  display: grid;
271
281
  align-content: end;
@@ -313,8 +323,10 @@ const CSS = `
313
323
  right: auto;
314
324
  left: 0.75rem;
315
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; }
316
328
  .seams-voiceid-prompt .seams-voiceid-record-slot {
317
- grid-row: 5;
329
+ grid-row: action;
318
330
  grid-column: 1;
319
331
  align-self: center;
320
332
  width: 100%;
@@ -396,7 +408,7 @@ const CSS = `
396
408
  .seams-voiceid-prompt:has(.seams-voiceid-model-download:not([data-state="ready"])) > .seams-voiceid-phrase { opacity: 0.35; }
397
409
  .seams-voiceid-prompt:has(.seams-voiceid-model-download[data-state="failed"]) > .seams-voiceid-record-slot { display: none; }
398
410
  .seams-voiceid-model-download progress {
399
- grid-row: 3;
411
+ grid-row: line;
400
412
  justify-self: center;
401
413
  appearance: none;
402
414
  display: block;
@@ -414,7 +426,7 @@ const CSS = `
414
426
  a looping animation would compete with it on a software-rendered page. */
415
427
  .seams-voiceid-model-download progress::-moz-progress-bar { background: var(--seams-colors-textPrimary, #eff2f3); border-radius: 999px; }
416
428
  .seams-voiceid-model-download p {
417
- grid-row: 4;
429
+ grid-row: status;
418
430
  align-self: start;
419
431
  margin: 0;
420
432
  font-size: 0.75rem;
@@ -422,9 +434,9 @@ const CSS = `
422
434
  color: var(--seams-colors-textSecondary, #9cabb3);
423
435
  font-variant-numeric: tabular-nums;
424
436
  }
425
- .seams-voiceid-model-download button { grid-row: 5; }
437
+ .seams-voiceid-model-download button { grid-row: action; }
426
438
  /* A phrase card shows its check and its result in the same card. */
427
- .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; }
428
440
  .seams-voiceid-status-line {
429
441
  transition: opacity 120ms ease;
430
442
  display: block;
@@ -434,7 +446,9 @@ const CSS = `
434
446
  margin: auto;
435
447
  overflow: visible;
436
448
  opacity: 0;
437
- 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)));
438
452
  }
439
453
  .seams-voiceid-ribbon { transition: opacity 120ms ease; }
440
454
  .seams-voiceid-prompt[data-phrase-line="status"] > .seams-voiceid-ribbon { opacity: 0; }
@@ -474,17 +488,29 @@ const CSS = `
474
488
  .seams-voiceid-prompt .seams-voiceid-phrase.seams-voiceid-phrase-dim { color: var(--seams-colors-textMuted, #6f7f87); }
475
489
  .seams-voiceid-prompt[data-phrase-view] > h2 { min-height: 1.19rem; }
476
490
  .seams-voiceid-prompt[data-phrase-view="check"] {
477
- 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;
478
492
  }
479
493
  .seams-voiceid-prompt[data-phrase-view="check"] .seams-voiceid-record-slot { display: none; }
480
494
  /* A card that records by itself (autoStart) has no Record row: its rows sit in the middle. */
481
495
  .seams-voiceid-prompt[data-auto-start] {
482
- grid-template-rows: auto minmax(6rem, auto) 5rem minmax(3.375rem, auto) 0;
496
+ grid-template-rows: [context] auto [title] auto [phrase] minmax(6rem, auto) [line] 5rem [status] minmax(3.375rem, auto) [action] 0;
483
497
  }
484
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; }
485
510
  .seams-voiceid-prompt[data-phrase-view] .seams-voiceid-microphone { display: none; }
486
511
  .seams-voiceid-prompt .seams-voiceid-close { transition: opacity 150ms ease, visibility 0s linear 0s; }
487
- .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) {
488
514
  opacity: 0;
489
515
  visibility: hidden;
490
516
  transition: opacity 150ms ease, visibility 0s linear 150ms;
@@ -521,6 +547,213 @@ const CSS = `
521
547
  @media (prefers-reduced-motion: reduce) {
522
548
  .seams-voiceid-status-line rect { animation: none !important; transition: none; }
523
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
+ }
524
757
  @media (hover: hover) and (pointer: fine) {
525
758
  .seams-voiceid-close:hover { background: var(--seams-colors-surface, #202b31); }
526
759
  .seams-voiceid-primary:hover:not(:disabled) { opacity: 0.9; }
@@ -611,6 +844,29 @@ var VoicePrompt = class {
611
844
  this.element.append(this.visual.glow, this.cancel, this.title, this.phrase, this.visual.element, this.status, slot, this.progress);
612
845
  this.sizes.observe(this.element);
613
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
+ }
614
870
  /**
615
871
  * In an app's dialog with a step before this card, Back in the leading corner returns to that
616
872
  * step. With `close`, Close stays in the trailing corner and ends the whole dialog; without it,
@@ -630,6 +886,16 @@ var VoicePrompt = class {
630
886
  return this.cancel;
631
887
  }
632
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
+ /**
633
899
  * Moves the rows to a new state. The rows whose content changes fade out together, take their
634
900
  * new content and fade in one after another; the other rows stay still. A change that comes
635
901
  * during a fade joins it, so no state in between is drawn.
@@ -1034,8 +1300,8 @@ function fadeRowsIn(rows) {
1034
1300
  opacity: to,
1035
1301
  transform: "none"
1036
1302
  }], {
1037
- delay: reduced ? 0 : 20 + index * 30,
1038
- duration: reduced ? 150 : 200,
1303
+ delay: reduced ? 0 : 0 + index * 20,
1304
+ duration: reduced ? 150 : 140,
1039
1305
  easing: ROW_EASE,
1040
1306
  fill: "backwards",
1041
1307
  id: ROW_MOTION
@@ -1045,7 +1311,7 @@ function fadeRowsIn(rows) {
1045
1311
  function ignoreCancel() {}
1046
1312
  function continueFade({ row, opacity }) {
1047
1313
  row.animate([{ opacity }, { opacity: 1 }], {
1048
- duration: 200 * (1 - opacity),
1314
+ duration: 140 * (1 - opacity),
1049
1315
  easing: ROW_EASE,
1050
1316
  id: ROW_MOTION
1051
1317
  });
@@ -1078,6 +1344,34 @@ function leaveSurface(element) {
1078
1344
  }
1079
1345
  const CLOSE_PATH = "M6 6l12 12M18 6L6 18";
1080
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
+ }
1081
1375
  /** A control in a corner of the card, with an icon and no text. */
1082
1376
  function cornerButton(document, label, path) {
1083
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");
@@ -103,6 +124,46 @@ var VoiceRibbon = class {
103
124
  this.paint(performance.now());
104
125
  }
105
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
+ }
106
167
  /** The voice line's width now: 1, or the theme's `voiceLineWidth`. */
107
168
  lineWidth() {
108
169
  const line = this.lines[this.lines.length - 1];
@@ -188,13 +249,23 @@ var VoiceRibbon = class {
188
249
  this.previousTime = time;
189
250
  this.level += (this.target - this.level) * (1 - Math.exp(-dt / (this.target > this.level ? 55 : 200)));
190
251
  if (!this.motion.matches) this.position += dt * (.0012 + this.level * .003);
252
+ if (this.lineStyle === "pixels") this.stepPixels(dt);
191
253
  this.paint(time);
192
- 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);
193
255
  };
194
256
  idleAmplitude(time) {
195
257
  if (this.phase !== "listening" || this.motion.matches) return 0;
196
258
  return IDLE_AMPLITUDE * (.72 + .28 * Math.sin(time / IDLE_PERIOD_MS * TAU));
197
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
+ }
198
269
  paint(time) {
199
270
  for (const part of [
200
271
  this.ripple,
@@ -206,9 +277,11 @@ var VoiceRibbon = class {
206
277
  this.check
207
278
  ]) part.setAttribute("opacity", "0");
208
279
  for (const line of this.lines) line.setAttribute("opacity", "0");
280
+ this.pixels?.group.setAttribute("opacity", "0");
209
281
  switch (this.stage.kind) {
210
282
  case "line":
211
- 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)));
212
285
  return;
213
286
  case "capture":
214
287
  this.paintCapture(time, this.stage);
@@ -237,6 +310,23 @@ var VoiceRibbon = class {
237
310
  this.lines[index].setAttribute("opacity", String(layer === 0 ? LINE_REST_OPACITY + amplitude * .4 : .1 + amplitude * .1));
238
311
  }
239
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
+ }
240
330
  paintSegments(time, stage) {
241
331
  const elapsed = time - stage.startedAt;
242
332
  if (!this.motion.matches && elapsed < SPLIT_FLATTEN_MS) {
@@ -1,8 +1,8 @@
1
1
  //#region src/card/rowMotion.ts
2
2
  const ROW_OUT_MS = 150;
3
- const ROW_IN_MS = 200;
4
- const ROW_IN_DELAY_MS = 20;
5
- const ROW_STAGGER_MS = 30;
3
+ const ROW_IN_MS = 140;
4
+ const ROW_IN_DELAY_MS = 0;
5
+ const ROW_STAGGER_MS = 20;
6
6
  const ROW_EASE = "cubic-bezier(0.2, 0, 0, 1)";
7
7
  //#endregion
8
8
  export { ROW_EASE, ROW_IN_DELAY_MS, ROW_IN_MS, ROW_OUT_MS, ROW_STAGGER_MS };