dsh-realtime-audio-ws 0.2.7 → 0.2.9

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.
@@ -24,7 +24,7 @@
24
24
  * actually been read, rather than shipping a capture path that a policy might silently kill.
25
25
  */
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
- exports.STRIP_ELEMENT_ID = exports.INJECTED_KEY = exports.GLOBAL_KEY = exports.MAX_BACKLOG_SECONDS = exports.CAPTURE_BUFFER = exports.SAMPLE_RATE = exports.DEFAULT_PATH = exports.inject = exports.name = void 0;
27
+ exports.METER_SEGMENTS = exports.STRIP_MODEL = exports.STRIP_WORDMARK = exports.STRIP_ELEMENT_ID = exports.INJECTED_KEY = exports.GLOBAL_KEY = exports.MAX_BACKLOG_SECONDS = exports.CAPTURE_BUFFER = exports.SAMPLE_RATE = exports.DEFAULT_PATH = exports.inject = exports.name = void 0;
28
28
  exports.socketUrl = socketUrl;
29
29
  exports.pageAuthority = pageAuthority;
30
30
  exports.withToken = withToken;
@@ -33,6 +33,7 @@ exports.float32FromPcm16 = float32FromPcm16;
33
33
  exports.defaultDeps = defaultDeps;
34
34
  exports.createAudioClient = createAudioClient;
35
35
  exports.stripRoot = stripRoot;
36
+ exports.waitKeys = waitKeys;
36
37
  exports.renderStrip = renderStrip;
37
38
  exports.mountStrip = mountStrip;
38
39
  exports.apply = apply;
@@ -351,6 +352,26 @@ function createAudioClient(deps) {
351
352
  // ---- the strip: the panel this plugin puts in the app ---------------------------------------------------
352
353
  /** The element the injected markup provides. Duplicated in the host half, which mints the row. */
353
354
  exports.STRIP_ELEMENT_ID = 'dsh-realtime-strip';
355
+ /** The plate's wordmark and model, engraved. The host half styles the plate; the deck is minted here. */
356
+ exports.STRIP_WORDMARK = 'REALTIME';
357
+ exports.STRIP_MODEL = 'DV-1 · DESK VOICE UNIT';
358
+ /** The segments in the input meter. Its own scale, not a level — see {@link meterMarkup}. */
359
+ exports.METER_SEGMENTS = 14;
360
+ /** The lamp's own word, engraved beside it. */
361
+ const LAMP_WORDS = { live: 'LIVE', connecting: 'LINK', closed: 'CLOSED' };
362
+ /**
363
+ * Short legends for the controls that sit on the plate.
364
+ *
365
+ * A description is not a label: schema prose written to explain a field to a developer is too long to scan
366
+ * beside a control, and two such sentences about different fields read as one field described twice. The
367
+ * full prose is one drill level down, where the raw registry rows are.
368
+ */
369
+ const SHORT_LABELS = {
370
+ speakMilestones: 'SPEAK STEPS',
371
+ maxPromptChars: 'PROMPT',
372
+ maxSpokenMilestones: 'MILESTONES',
373
+ sessionId: 'SESSION',
374
+ };
354
375
  /**
355
376
  * Where the injected markup puts the panel, or `null` when this page does not carry it.
356
377
  * @param scope - the page scope, injectable so both cases are testable without a DOM.
@@ -386,113 +407,470 @@ function show(value) {
386
407
  return typeof value === 'string' ? value : JSON.stringify(value);
387
408
  }
388
409
  /**
389
- * Render the whole panel.
410
+ * A value as a number, for the controls that are numeric.
390
411
  *
391
- * A pure function of the state, because that is what makes the panel testable at all: the injected script
392
- * is three lines and the DOM is one assignment, so everything worth checking lives here.
393
- * @param state - what the panel is showing.
394
- * @returns the markup for the panel's element.
412
+ * `0` rather than a refusal for anything that is not a number: a number control for a setting that reported
413
+ * nothing is a control for a setting that will refuse the write, and the registry's own reply is what says
414
+ * so. Inventing a second refusal here would put two answers behind one refusal.
415
+ * @param value - a setting's value as `status` reported it.
416
+ * @returns the value, or `0`.
395
417
  */
396
- function renderStrip(state) {
397
- const reply = state.reply;
398
- const lines = [`<p data-dsh-strip-voice>${escapeHtml(summariseVoice(reply, state.audio))}</p>`];
399
- if (reply !== null)
400
- lines.push(`<p data-dsh-strip-route>${escapeHtml(summariseRoute(reply))}</p>`);
401
- if (state.notice !== '') {
402
- const link = state.noticeLink ?? '';
403
- const anchor = link === ''
404
- ? ''
405
- : ` <a data-dsh-strip-link href="${escapeHtml(link)}" target="_blank"`
406
- + ' rel="noreferrer noopener">open ↗</a>';
407
- lines.push(`<p data-dsh-strip-notice>${escapeHtml(state.notice)}${anchor}</p>`);
418
+ function asNumber(value) {
419
+ return typeof value === 'number' ? value : 0;
420
+ }
421
+ /**
422
+ * A value as a list of strings, when it is one.
423
+ * @param value - a setting's value as `status` reported it.
424
+ * @returns the strings, or `undefined` when the value is not an array.
425
+ */
426
+ function asList(value) {
427
+ if (!Array.isArray(value))
428
+ return undefined;
429
+ return value.filter((item) => typeof item === 'string');
430
+ }
431
+ /**
432
+ * One live setting of a given kind, by field.
433
+ *
434
+ * By `field` and not by `<owner>.<field>` key: which plugin owns a budget is not the panel's business, and
435
+ * a panel keyed on an owner's name breaks the day the owner is renamed. The same resolution the control
436
+ * channel's `steer` verb makes, for the same reason.
437
+ * @param settings - every setting the status reported.
438
+ * @param field - the setting's own name.
439
+ * @param kind - the kind the control can drive.
440
+ * @returns the setting, or `undefined`.
441
+ */
442
+ function liveField(settings, field, kind) {
443
+ return settings.find(entry => entry.field === field && entry.kind === kind && entry.scope === 'live');
444
+ }
445
+ /**
446
+ * The two budgets that each bound how long the voice waits, in the order the panel writes them.
447
+ *
448
+ * Measured rather than assumed, and the panel therefore rests on facts: the agent's `delegationTimeoutMs`
449
+ * (how long the voice model waits for a responder) and the responder's `answerTimeoutMs` (how long one turn
450
+ * waits for the agent). Both are `live`, so one control that writes both is an affordance the protocol can
451
+ * honour — and **the smaller bound is what is heard**, in the responder's own words, so `min` is the truth
452
+ * about what the pair does.
453
+ *
454
+ * Zero or one key is a real answer: in a profile with no agent row, or no responder row, the other budget
455
+ * does not exist to be written.
456
+ * @param settings - every setting the status reported.
457
+ * @returns the keys, in the order they are written.
458
+ */
459
+ function waitKeys(settings) {
460
+ const keys = [];
461
+ for (const field of ['delegationTimeoutMs', 'answerTimeoutMs']) {
462
+ const entry = liveField(settings, field, 'number');
463
+ if (entry !== undefined)
464
+ keys.push(entry.key);
408
465
  }
409
- lines.push(`<p data-dsh-strip-buttons>${buttons(state.audio)}</p>`);
410
- // Restart-bound settings are omitted rather than shown read-only: the gate says no control at all, and a
411
- // row that cannot be changed is a row a reader will try to change.
412
- const settings = (reply?.settings ?? []).filter(entry => entry.scope !== 'restart');
413
- lines.push(settings.length === 0
414
- ? '<p data-dsh-strip-empty>no settings to show</p>'
415
- : `<ul>${settings.map(entry => settingRow(entry, state.notes)).join('')}</ul>`);
416
- return lines.join('');
466
+ return keys;
417
467
  }
418
468
  /**
419
- * The voice line: whether a session is live, and what it is.
420
- * @param reply - the last status, if any.
469
+ * What the wait control says it wrote, from the pair the registry now reports.
470
+ *
471
+ * Never the value that was asked for: a setter may clamp what it was handed, so the answer to "what does
472
+ * this now do?" is the pair the plugin holds. Reported as a **pair** — a half-applied write has to be
473
+ * visible as one, and averaging it would hide exactly the state a reader needs to see.
474
+ * @param settings - every setting the status reported, re-read after the write.
475
+ * @returns one line for the note.
476
+ */
477
+ function waitSummary(settings) {
478
+ const values = waitKeys(settings).map(key => asNumber(settings.find(entry => entry.key === key)?.value));
479
+ if (values.length === 0)
480
+ return 'no wait budget is registered';
481
+ return `waits up to ${String(Math.round(Math.min(...values) / 1000))} s — ${values.map(value => String(value)).join(' / ')} ms`;
482
+ }
483
+ /**
484
+ * The pilot lamp's state.
485
+ *
486
+ * Green live, amber connecting, dark closed — one lamp rather than three words, and the amber is real
487
+ * rather than decorative: a connect is a promise that settles, and the state it is in while it settles is
488
+ * the only thing on this plate that changes without a repaint from the status.
489
+ * @param state - what the panel is showing.
490
+ * @returns which of the three the lamp reports.
491
+ */
492
+ function lampState(state) {
493
+ if (state.connecting)
494
+ return 'connecting';
495
+ return state.reply?.voice?.open === true ? 'live' : 'closed';
496
+ }
497
+ /**
498
+ * The input meter.
499
+ *
500
+ * **Not a level.** A real level needs a repaint loop, and repainting this element's `innerHTML` on a timer
501
+ * would destroy whatever is half-typed in it — the list editor is the case that matters. So the bar encodes
502
+ * the capture path's *state*, which is the one thing about the microphone that changes without a repaint:
503
+ * dark when the device is not open, filled when it is, and a fault shown at the top of the scale where the
504
+ * eye already looks for "something is wrong with the input". The state is named on the element for anyone
505
+ * who cannot read the colour, and the title says what the bar is so it cannot be mistaken for a peak meter.
421
506
  * @param audio - what the microphone is doing.
422
- * @returns one line of plain text.
507
+ * @returns the meter's markup.
423
508
  */
424
- function summariseVoice(reply, audio) {
425
- const microphone = audio.kind === 'failed' ? `microphone: failed — ${audio.reason}` : `microphone: ${audio.kind}`;
509
+ function meterMarkup(audio) {
510
+ const live = audio.kind === 'live';
511
+ const fault = audio.kind === 'failed';
512
+ const segments = Array.from({ length: exports.METER_SEGMENTS }, (_unused, index) => {
513
+ if (fault)
514
+ return index >= exports.METER_SEGMENTS - 2 ? '<i class="dvr-seg on hot"></i>' : '<i class="dvr-seg"></i>';
515
+ if (!live || index >= 11)
516
+ return '<i class="dvr-seg"></i>';
517
+ return `<i class="dvr-seg on${index >= 9 ? ' warm' : ''}"></i>`;
518
+ }).join('');
519
+ return `<span class="dvr-meter" data-meter="${audio.kind}" role="img"`
520
+ + ` aria-label="microphone ${audio.kind}" title="capture state — not a level">`
521
+ + '<span class="dvr-cap"><span>INPUT</span><span>−12 −6 0</span></span>'
522
+ + `<span class="dvr-scale">${segments}</span>`
523
+ + '</span>';
524
+ }
525
+ /**
526
+ * Where the voice is pointed.
527
+ *
528
+ * Three separate states, kept separate: nothing has answered yet, the profile has no agent to answer, and a
529
+ * session that exists and knows where it points. Collapsing the first two is the mistake this line exists to
530
+ * avoid — "no agent is mounted" behind a control that has simply not been asked yet sends a reader to the
531
+ * wrong file.
532
+ * @param reply - the last status, if any.
533
+ * @returns one engraved line.
534
+ */
535
+ function whereText(reply) {
426
536
  if (reply === null)
427
- return `no answer yet · ${microphone}`;
537
+ return 'no status yet';
428
538
  const voice = reply.voice;
429
- // `null` is "nobody answered the question", which is a composition without an agent row — deliberately
430
- // not the same statement as a session that is merely closed.
431
539
  if (voice === null || voice === undefined)
432
- return `voice: no agent is mounted in this profile · ${microphone}`;
433
- const where = voice.provider === undefined ? '' : ` · ${voice.provider}/${voice.model ?? '?'}`;
434
- const which = voice.sessionId === undefined ? '' : ` · ${voice.sessionId}`;
435
- return `voice: ${voice.open ? 'open' : 'closed'}${where}${which} · ${microphone}`;
540
+ return 'no agent mounted';
541
+ const model = `${voice.provider ?? '?'}/${voice.model ?? '?'}`;
542
+ return voice.sessionId === undefined ? model : `${model} · ${voice.sessionId}`;
436
543
  }
437
544
  /**
438
- * The route line: what this socket is, and the last thing the journal recorded.
439
- * @param reply - the last status.
440
- * @returns one line of plain text.
545
+ * The knob's pointer position, as one of eleven buckets.
546
+ *
547
+ * A readout, not a control: the pointer encodes where the value sits on the knob's own scale, and the field
548
+ * beside it is what writes. The scale is the panel's, because the registry declares a kind and not a range —
549
+ * which is exactly why a draggable knob would be a lie about what the panel knows.
550
+ * @param value - the value in force.
551
+ * @param span - the panel's own scale for this knob.
552
+ * @returns a bucket from 0 to 10.
441
553
  */
442
- function summariseRoute(reply) {
443
- const last = reply.journal?.last?.kind;
444
- return [
445
- reply.audio?.path ?? 'the audio route',
446
- `${String(reply.audio?.clients ?? 0)} socket(s)`,
447
- `journal ${String(reply.journal?.size ?? 0)}`,
448
- ...last === undefined ? [] : [`last ${last}`],
449
- ].join(' · ');
554
+ function knobBucket(value, span) {
555
+ const [low, high] = span;
556
+ const bucket = Math.round(((value - low) / (high - low)) * 10);
557
+ return Math.min(10, Math.max(0, bucket));
450
558
  }
451
559
  /**
452
- * The buttons above the settings.
560
+ * One knob: the dial, the legend, the value, and the field that writes it.
561
+ * @param knob - what to draw.
562
+ * @returns the knob's markup.
563
+ */
564
+ function knobMarkup(knob) {
565
+ const key = escapeHtml(knob.key);
566
+ const caption = knob.caption === undefined ? '' : `<span class="dvr-knob-caption">${escapeHtml(knob.caption)}</span>`;
567
+ return '<div class="dvr-knob-unit">'
568
+ + `<span class="dvr-knob dvr-knob--${String(knobBucket(knob.value, knob.span))}"`
569
+ + ' title="readout — the field below is the control"></span>'
570
+ + `<span class="dvr-knob-label">${escapeHtml(knob.label)}</span>`
571
+ + `<span class="dvr-knob-value">${escapeHtml(knob.shown)}</span>`
572
+ + caption
573
+ + '<span class="dvr-knob-set">'
574
+ + `<input class="dvr-input" type="number" data-input="${key}" value="${escapeHtml(String(knob.value))}">`
575
+ + `<button class="dvr-btn" data-action="${knob.action}" data-key="${key}">Set</button>`
576
+ + '</span></div>';
577
+ }
578
+ /**
579
+ * The few controls worth a physical affordance.
453
580
  *
454
- * The microphone's own button is the on-switch this sprint exists to provide: what used to be a
455
- * `globalThis` call in a devtools console is now the first control in the panel.
456
- * @param audio - what the microphone is doing.
457
- * @returns the markup.
581
+ * One primary for the wait budget rather than the two indistinguishable rows the screenshot showed, and the
582
+ * mechanism one drill level down where it is reachable without being in the way.
583
+ * @param settings - every setting the status reported.
584
+ * @returns the knobs' markup, empty when none of them are registered.
458
585
  */
459
- function buttons(audio) {
460
- const live = audio.kind === 'live';
461
- return [
462
- `<button data-action="${live ? 'disconnect' : 'connect'}">${live ? 'Disconnect microphone' : 'Connect microphone'}</button>`,
463
- '<button data-action="voice-start">Start voice</button>',
464
- '<button data-action="voice-stop">Stop voice</button>',
465
- '<button data-action="refresh">Refresh</button>',
466
- ].join(' ');
586
+ function knobsMarkup(settings) {
587
+ const knobs = [];
588
+ const waits = waitKeys(settings);
589
+ if (waits.length > 0) {
590
+ const values = waits.map(key => asNumber(settings.find(entry => entry.key === key)?.value));
591
+ const binding = Math.min(...values);
592
+ // The cap is named only when the two actually diverge, because that is the only case in which one of
593
+ // them is inert — and an inert control that accepts input and does nothing is the defect this design
594
+ // exists to remove.
595
+ const caption = values.length < 2 || values[0] === values[1]
596
+ ? undefined
597
+ : `capped by the ${values[0] < values[1] ? 'reply' : 'turn'} budget`;
598
+ knobs.push(knobMarkup({
599
+ label: 'WAIT',
600
+ action: waits.length === 2 ? 'wait' : 'set',
601
+ key: waits[0],
602
+ value: binding,
603
+ shown: `${String(Math.round(binding / 1000))} s`,
604
+ span: [1000, 120_000],
605
+ ...caption === undefined ? {} : { caption },
606
+ }));
607
+ }
608
+ const prompt = liveField(settings, 'maxPromptChars', 'number');
609
+ if (prompt !== undefined) {
610
+ knobs.push(knobMarkup({
611
+ label: SHORT_LABELS.maxPromptChars,
612
+ action: 'set',
613
+ key: prompt.key,
614
+ value: asNumber(prompt.value),
615
+ shown: `${String(asNumber(prompt.value))} ch`,
616
+ span: [0, 8000],
617
+ }));
618
+ }
619
+ const milestones = liveField(settings, 'maxSpokenMilestones', 'number');
620
+ if (milestones !== undefined) {
621
+ knobs.push(knobMarkup({
622
+ label: SHORT_LABELS.maxSpokenMilestones,
623
+ action: 'set',
624
+ key: milestones.key,
625
+ value: asNumber(milestones.value),
626
+ shown: String(asNumber(milestones.value)),
627
+ span: [0, 6],
628
+ }));
629
+ }
630
+ return knobs.join('');
467
631
  }
468
632
  /**
469
- * One setting's row.
633
+ * A switch that is not a setting: it drives a verb.
634
+ * @param action - the control verb.
635
+ * @param on - whether the thing it drives is on.
636
+ * @param label - its legend.
637
+ * @param colour - the LED's colour when lit.
638
+ * @returns the switch's markup.
639
+ */
640
+ function lampSwitch(action, on, label, colour) {
641
+ const lit = on ? ` on ${colour}` : '';
642
+ return `<button class="dvr-switch" data-action="${action}" aria-pressed="${String(on)}">`
643
+ + `<i class="dvr-led${lit}"></i><span>${label}</span></button>`;
644
+ }
645
+ /**
646
+ * A boolean setting as a toggle.
470
647
  *
471
- * Three classes, three treatments, and the gate's rule is the reason: a live field gets a control, a
472
- * session-bound field says what it would take to change it, and a restart-bound field is not rendered.
473
- * @param entry - the setting as `status` reported it.
474
- * @param notes - what the last change to each setting produced.
475
- * @returns the markup for one row.
648
+ * A toggle and not a text box holding `true`: typing `false` into a field is a coercion the user has to get
649
+ * right, and a toggle cannot be got wrong. The value it writes is carried on the button, so the flip is
650
+ * decided against the value the panel was rendered with rather than a second read of the page.
651
+ * @param entry - the setting.
652
+ * @returns the toggle's markup.
476
653
  */
477
- function settingRow(entry, notes) {
654
+ function booleanSwitch(entry) {
478
655
  const key = escapeHtml(entry.key);
479
- const label = `<span data-dsh-strip-label>${escapeHtml(entry.describe ?? entry.field)}</span>`;
480
- const note = notes[entry.key] === undefined
481
- ? ''
482
- : `<em data-note="${key}">${escapeHtml(notes[entry.key])}</em>`;
483
- if (entry.scope !== 'live') {
484
- return `<li data-key="${key}" data-scope="session">${label}<b>${escapeHtml(show(entry.value))}</b>`
485
- + `<em>fixed when the session opens — reconnect to apply</em>${note}</li>`;
486
- }
487
- // The channel has a verb for exactly one purpose — steering at a session — and this is the field it
488
- // means. Everything else goes through `set <key>=<value>`.
656
+ const on = entry.value === true;
657
+ return `<button class="dvr-switch" data-action="toggle" data-key="${key}"`
658
+ + ` data-value="${on ? 'false' : 'true'}" aria-pressed="${String(on)}">`
659
+ + `<i class="dvr-led${on ? ' on' : ''}"></i>`
660
+ + `<span>${escapeHtml(SHORT_LABELS[entry.field] ?? entry.field)}</span>`
661
+ + `<b class="dvr-switch-state">${on ? 'ON' : 'OFF'}</b></button>`;
662
+ }
663
+ /**
664
+ * The switches row: the microphone, the session, and every live boolean.
665
+ *
666
+ * The microphone and the session switches are controls in their own right, so they appear at both levels.
667
+ * A *setting* boolean does not: the deepest level gives every setting its own control, so carrying the
668
+ * switch there as well would put one setting behind two toggles on one screen — the same defect as two rows
669
+ * that read identically, in miniature.
670
+ * @param state - what the panel is showing.
671
+ * @param settings - every setting the status reported.
672
+ * @returns the row's markup.
673
+ */
674
+ function switchesMarkup(state, settings) {
675
+ const voice = state.reply?.voice;
676
+ const open = voice !== null && voice !== undefined && voice.open;
677
+ const boolean = state.level === 'controls' ? liveField(settings, 'speakMilestones', 'boolean') : undefined;
678
+ return '<div class="dvr-switches">'
679
+ + lampSwitch(state.audio.kind === 'live' ? 'disconnect' : 'connect', state.audio.kind === 'live', 'MIC', 'green')
680
+ + lampSwitch(open ? 'voice-stop' : 'voice-start', open, 'VOICE', 'green')
681
+ + (boolean === undefined ? '' : booleanSwitch(boolean))
682
+ + '</div>';
683
+ }
684
+ /**
685
+ * The tabs that switch between the two drill levels inside the panel.
686
+ * @param level - the level showing.
687
+ * @returns the tabs' markup.
688
+ */
689
+ function tabsMarkup(level) {
690
+ const tab = (target, label) => `<button class="dvr-tab${level === target ? ' active' : ''}" data-action="level" data-level="${target}">${label}</button>`;
691
+ return `<div class="dvr-tabs">${tab('controls', 'CONTROLS')}${tab('all', 'ALL SETTINGS')}</div>`;
692
+ }
693
+ /**
694
+ * A list as a real editor: every entry visible, each removable, and one field to add.
695
+ *
696
+ * Not a comma-separated string in a text box — that is a serialisation format leaked into the UI, and one
697
+ * stray space silently changes the value. The wire format is JSON and only JSON (a list of secrets cannot be
698
+ * split on a comma), so the editor builds the array and sends it whole.
699
+ *
700
+ * When the plugin **does not report** the value — a write-only list — the note says so, because an editor
701
+ * showing an empty list would otherwise read as "the list is empty" rather than "the list is not readable".
702
+ * @param entry - the setting.
703
+ * @param state - what the panel is showing, for the entries the editor has built.
704
+ * @returns the editor's markup.
705
+ */
706
+ function listEditor(entry, state) {
707
+ const key = escapeHtml(entry.key);
708
+ const reported = asList(entry.value);
709
+ const entries = reported ?? state.drafts[entry.key] ?? [];
710
+ const rows = entries.map((item, index) => `<li class="dvr-entry"><span class="dvr-entry-value">${escapeHtml(item)}</span>`
711
+ + `<button class="dvr-remove" data-action="list-remove" data-key="${key}"`
712
+ + ` data-index="${String(index)}">✕</button></li>`).join('');
713
+ return `<ul class="dvr-list">${rows}</ul>`
714
+ + (reported === undefined
715
+ ? '<em class="dvr-write-only">the plugin does not report this value — this list replaces whatever is set</em>'
716
+ : '')
717
+ + `<input class="dvr-input dvr-input--wide" data-input="${key}" value="">`
718
+ + `<button class="dvr-btn" data-action="list-add" data-key="${key}">Add</button>`;
719
+ }
720
+ /**
721
+ * The control a live setting gets, which is decided by its kind rather than by its description.
722
+ * @param entry - the setting.
723
+ * @param state - what the panel is showing, for a list editor's own entries.
724
+ * @returns the control's markup.
725
+ */
726
+ function liveControl(entry, state) {
727
+ const key = escapeHtml(entry.key);
728
+ if (entry.kind === 'boolean')
729
+ return booleanSwitch(entry);
730
+ if (entry.kind === 'string-list')
731
+ return listEditor(entry, state);
732
+ // The channel has a verb for exactly one purpose — steering at a session — and this is the field it means.
733
+ // Everything else goes through `set <key>=<value>`.
489
734
  const action = entry.field === 'sessionId' && entry.kind === 'string' ? 'steer' : 'set';
490
735
  const control = entry.choices !== undefined && entry.choices.length > 0
491
- ? `<select data-input="${key}">${choiceOptions(entry, entry.choices)}</select>`
492
- : `<input data-input="${key}" value="${escapeHtml(show(entry.value))}">`;
493
- const name = action === 'steer' ? 'Steer' : 'Set';
494
- return `<li data-key="${key}" data-scope="live">${label}${control}`
495
- + `<button data-action="${action}" data-key="${key}">${name}</button>${note}</li>`;
736
+ ? `<select class="dvr-select" data-input="${key}">${choiceOptions(entry, entry.choices)}</select>`
737
+ : `<input class="dvr-input dvr-input--wide" data-input="${key}" value="${escapeHtml(show(entry.value))}">`;
738
+ return control + `<button class="dvr-btn" data-action="${action}" data-key="${key}">${action === 'steer' ? 'Steer' : 'Set'}</button>`;
739
+ }
740
+ /**
741
+ * One registry row, as the deepest drill level shows it: the key, what the owner says it does, and the
742
+ * control its kind asks for.
743
+ * @param entry - the setting.
744
+ * @param state - what the panel is showing.
745
+ * @returns the row's markup.
746
+ */
747
+ function settingRow(entry, state) {
748
+ const key = escapeHtml(entry.key);
749
+ const head = `<span class="dvr-skey">${key}</span>`;
750
+ const describe = entry.describe === undefined ? '' : `<span class="dvr-sdesc">${escapeHtml(entry.describe)}</span>`;
751
+ const noted = state.notes[entry.key];
752
+ const note = noted === undefined ? '' : `<em class="dvr-note" data-note="${key}">${escapeHtml(noted)}</em>`;
753
+ if (entry.scope !== 'live') {
754
+ // Session-bound: the change exists, it just costs a reconnect, and saying so is the gate's second class.
755
+ return `<div class="dvr-srow" data-key="${key}" data-scope="session">${head}${describe}`
756
+ + `<b>${escapeHtml(show(entry.value))}</b>`
757
+ + '<em class="dvr-fixed">fixed when the session opens — reconnect to apply</em>'
758
+ + note
759
+ + '</div>';
760
+ }
761
+ return `<div class="dvr-srow" data-key="${key}" data-scope="live">${head}${describe}`
762
+ + liveControl(entry, state) + note + '</div>';
763
+ }
764
+ /**
765
+ * The raw registry rows.
766
+ *
767
+ * Developer detail the panel used to carry — the journal's size, the socket count, the last event name — is
768
+ * **not** here. It belongs to `voice_journal` and `voice_diagnose`, which already expose it; a surface
769
+ * carrying it reads as a log excerpt with buttons under it, which is what "maximally expanded with every
770
+ * field visible" actually described. The test for what belongs: which of these would a reader *act* on?
771
+ * @param state - what the panel is showing.
772
+ * @param settings - every setting the status reported.
773
+ * @returns the rows' markup.
774
+ */
775
+ function rowsMarkup(state, settings) {
776
+ // Restart-bound settings get no row at all. The control-plane gate says no affordance the protocol cannot
777
+ // honour, and a row that cannot be changed is a row a reader will try to change.
778
+ const rows = settings.filter(entry => entry.scope !== 'restart');
779
+ if (rows.length === 0)
780
+ return '<p class="dvr-empty">no settings to show</p>';
781
+ return rows.map(entry => settingRow(entry, state)).join('');
782
+ }
783
+ /**
784
+ * The panel: the drill-through's contents, one level at a time.
785
+ *
786
+ * The knobs and the raw rows are never on screen together. That is not layout taste — both name their inputs
787
+ * by the setting's key, and two inputs under one name would make the panel read whichever came first in the
788
+ * document, so a control would write a value nobody typed.
789
+ * @param state - what the panel is showing.
790
+ * @param settings - every setting the status reported.
791
+ * @returns the panel's markup.
792
+ */
793
+ function panelMarkup(state, settings) {
794
+ const knobs = state.level === 'controls' ? knobsMarkup(settings) : '';
795
+ return '<div class="dvr-panel">'
796
+ + (knobs === '' ? '' : `<div class="dvr-knobs">${knobs}</div>`)
797
+ + switchesMarkup(state, settings)
798
+ + tabsMarkup(state.level)
799
+ + (state.level === 'all' ? rowsMarkup(state, settings) : '')
800
+ + '</div>';
801
+ }
802
+ /**
803
+ * The 1U plate: state, not settings.
804
+ * @param state - what the panel is showing.
805
+ * @returns the deck's markup.
806
+ */
807
+ function deckMarkup(state) {
808
+ const lamp = lampState(state);
809
+ const collapsed = state.level === 'collapsed';
810
+ const micOn = state.audio.kind === 'live';
811
+ return '<div class="dvr-row">'
812
+ + '<span class="dvr-screw"></span>'
813
+ + '<span class="dvr-brand">'
814
+ + `<b class="dvr-name">${exports.STRIP_WORDMARK}</b><i class="dvr-model">${exports.STRIP_MODEL}</i>`
815
+ + '</span>'
816
+ + '<span class="dvr-rule"></span>'
817
+ + `<span class="dvr-jewel" data-lamp="${lamp}" title="session: ${lamp}">`
818
+ + `<i class="dvr-lamp"></i><i class="dvr-jewel-label">${LAMP_WORDS[lamp]}</i></span>`
819
+ + meterMarkup(state.audio)
820
+ + `<span class="dvr-where">${escapeHtml(whereText(state.reply))}</span>`
821
+ + '<span class="dvr-spacer"></span>'
822
+ + `<button class="dvr-btn${micOn ? ' on' : ''}" data-action="${micOn ? 'disconnect' : 'connect'}">MIC</button>`
823
+ + '<button class="dvr-btn" data-action="level"'
824
+ + ` data-level="${collapsed ? 'controls' : 'collapsed'}">SETTINGS ${collapsed ? '⌃' : '⌄'}</button>`
825
+ + '<span class="dvr-screw"></span>'
826
+ + '</div>';
827
+ }
828
+ /**
829
+ * The microphone's own failure, said in words rather than implied by a lamp.
830
+ * @param audio - what the microphone is doing.
831
+ * @returns the line's markup, empty when there is nothing wrong.
832
+ */
833
+ function faultMarkup(audio) {
834
+ return audio.kind === 'failed' ? `<p class="dvr-fault">microphone: ${escapeHtml(audio.reason)}</p>` : '';
835
+ }
836
+ /**
837
+ * The notice, and the page it points at when it points anywhere.
838
+ * @param state - what the panel is showing.
839
+ * @returns the line's markup, empty when there is nothing to say.
840
+ */
841
+ function noticeMarkup(state) {
842
+ if (state.notice === '')
843
+ return '';
844
+ const link = state.noticeLink ?? '';
845
+ const anchor = link === ''
846
+ ? ''
847
+ : ` <a href="${escapeHtml(link)}" target="_blank" rel="noreferrer noopener">open ↗</a>`;
848
+ return `<p class="dvr-notice" data-dsh-strip-notice>${escapeHtml(state.notice)}${anchor}</p>`;
849
+ }
850
+ /**
851
+ * Render the whole panel.
852
+ *
853
+ * A pure function of the state, because that is what makes the panel testable at all: the injected script
854
+ * is three lines and the DOM is one assignment, so everything worth checking lives here.
855
+ *
856
+ * The order is the panel's own geometry: the drill-through grows **upward** from the plate, so it comes
857
+ * first in the document and the deck stays pinned to the bottom of the page.
858
+ * @param state - what the panel is showing.
859
+ * @returns the markup for the panel's element.
860
+ */
861
+ function renderStrip(state) {
862
+ const settings = state.reply?.settings ?? [];
863
+ const parts = [];
864
+ if (state.level !== 'collapsed')
865
+ parts.push(panelMarkup(state, settings));
866
+ parts.push(deckMarkup(state));
867
+ const fault = faultMarkup(state.audio);
868
+ if (fault !== '')
869
+ parts.push(fault);
870
+ const notice = noticeMarkup(state);
871
+ if (notice !== '')
872
+ parts.push(notice);
873
+ return parts.join('');
496
874
  }
497
875
  /**
498
876
  * A picker's options, with the value in force always among them.
@@ -510,6 +888,16 @@ function choiceOptions(entry, candidates) {
510
888
  const all = candidates.includes(current) ? candidates : [current, ...candidates];
511
889
  return all.map(choice => `<option value="${escapeHtml(choice)}"${choice === current ? ' selected' : ''}>${escapeHtml(choice)}</option>`).join('');
512
890
  }
891
+ /**
892
+ * Read a click's level, refusing anything that is not one.
893
+ * @param raw - the `data-level` the button carried.
894
+ * @returns the level it names.
895
+ */
896
+ function asLevel(raw) {
897
+ if (raw === 'controls' || raw === 'all')
898
+ return raw;
899
+ return 'collapsed';
900
+ }
513
901
  /**
514
902
  * Mount the panel on its element.
515
903
  *
@@ -522,11 +910,17 @@ function choiceOptions(entry, candidates) {
522
910
  function mountStrip(deps) {
523
911
  let reply = null;
524
912
  const notes = {};
913
+ const drafts = {};
525
914
  let notice = '';
526
915
  /** The page the current notice points at, or empty. Set only where a refusal carried one. */
527
916
  let noticeLink = '';
917
+ let level = 'collapsed';
918
+ let connecting = false;
919
+ const settings = () => reply?.settings ?? [];
528
920
  const paint = () => {
529
- deps.root.innerHTML = renderStrip({ reply, notes, notice, noticeLink, audio: deps.audio() });
921
+ deps.root.innerHTML = renderStrip({
922
+ reply, notes, drafts, notice, noticeLink, audio: deps.audio(), level, connecting,
923
+ });
530
924
  };
531
925
  const refresh = async () => {
532
926
  try {
@@ -539,52 +933,69 @@ function mountStrip(deps) {
539
933
  }
540
934
  paint();
541
935
  };
936
+ /** One list as the editor holds it: what the plugin reported, else what the editor has built. */
937
+ const listAt = (key) => asList(settings().find(entry => entry.key === key)?.value) ?? drafts[key] ?? [];
542
938
  /**
543
- * Send one frame and report what came back, then re-render.
544
- * @param frame - the control frame.
939
+ * Send one intent and report what came back, then re-render.
940
+ *
941
+ * An intent can be more than one frame: the wait control writes two budgets, and the pair is reported as
942
+ * a pair. The first refusal stops the sequence — a half-applied pair is a state to *see*, not one to keep
943
+ * writing past.
944
+ * @param frames - the control frames, in order.
545
945
  * @param fieldKey - the setting the change was about, when it was about one.
546
- * @param done - what to say when it worked.
946
+ * @param done - what to say when every frame was applied.
947
+ * @returns the refusal it stopped on, or `undefined` when every frame was applied. Returned rather than
948
+ * only recorded because one caller — the wait pair — has something better to say than "applied", and a
949
+ * line written from a re-read must not overwrite a refusal.
547
950
  */
548
- const send = async (frame, fieldKey, done) => {
549
- let line;
951
+ const send = async (frames, fieldKey, done) => {
952
+ let refusal;
550
953
  try {
551
- const answer = await deps.request(frame);
552
- // A refusal carries the reason written to be relayed verbatim, so it is relayed rather than
553
- // translated — including for a field frozen by its class. A **session** refusal nests its own classified
554
- // code, remedy and page and leaves the control-level `reason` unset, so reading only that answered the
555
- // failure a user is most likely to meet with "refused: no reason given" — a panel saying nothing at the
556
- // exact moment it knows what to do.
557
- const refusal = answer.refusal;
558
- if (!answer.ok && refusal !== undefined) {
559
- noticeLink = refusal.link ?? '';
560
- line = `${refusal.code ?? refusal.class ?? 'refused'}: ${refusal.remedy ?? 'no reason given'}`;
561
- }
562
- else {
954
+ for (const frame of frames) {
955
+ const answer = await deps.request(frame);
956
+ if (!answer.ok) {
957
+ // A refusal carries the reason written to be relayed verbatim, so it is relayed rather than
958
+ // translated — including for a field frozen by its class. A **session** refusal nests its own
959
+ // classified code, remedy and page and leaves the control-level `reason` unset, so reading only
960
+ // that answered the failure a user is most likely to meet with "refused: no reason given" — a
961
+ // panel saying nothing at the exact moment it knows what to do.
962
+ refusal = answer.refusal === undefined
963
+ ? `${answer.code ?? 'refused'}: ${answer.reason ?? 'no reason given'}`
964
+ : `${answer.refusal.code ?? answer.refusal.class ?? 'refused'}: ${answer.refusal.remedy ?? 'no reason given'}`;
965
+ noticeLink = answer.refusal?.link ?? '';
966
+ break;
967
+ }
563
968
  noticeLink = '';
564
- line = answer.ok ? done : `${answer.code ?? 'refused'}: ${answer.reason ?? 'no reason given'}`;
565
969
  }
566
970
  }
567
971
  catch (error) {
568
- line = error instanceof Error ? error.message : String(error);
972
+ refusal = error instanceof Error ? error.message : String(error);
569
973
  }
974
+ const line = refusal ?? done;
570
975
  if (fieldKey === undefined)
571
976
  notice = line;
572
977
  else
573
978
  notes[fieldKey] = line;
574
979
  await refresh();
980
+ return refusal;
575
981
  };
576
982
  const onClick = (event) => {
577
983
  const action = event.target?.dataset?.action;
578
984
  if (action === undefined)
579
985
  return;
580
986
  const key = event.target?.dataset?.key;
581
- const value = key === undefined ? undefined : deps.root.querySelector(`[data-input="${key}"]`)?.value;
987
+ const value = event.target?.dataset?.value
988
+ ?? (key === undefined ? undefined : deps.root.querySelector(`[data-input="${key}"]`)?.value);
582
989
  const act = async () => {
583
990
  // Cleared per action: a notice from the last one is about the last one, and leaving it up would make
584
991
  // a successful reconnect read as a failed one.
585
992
  notice = '';
586
993
  noticeLink = '';
587
994
  switch (action) {
995
+ case 'level':
996
+ level = asLevel(event.target?.dataset?.level);
997
+ paint();
998
+ return;
588
999
  case 'refresh':
589
1000
  await refresh();
590
1001
  return;
@@ -593,28 +1004,72 @@ function mountStrip(deps) {
593
1004
  await refresh();
594
1005
  return;
595
1006
  case 'connect': {
1007
+ // The lamp goes amber for the length of the connect, which is the only thing on this plate that
1008
+ // moves without the status having moved.
1009
+ connecting = true;
1010
+ paint();
596
1011
  const reached = await deps.connect();
1012
+ connecting = false;
597
1013
  if (reached.kind === 'failed')
598
1014
  notice = reached.reason;
599
1015
  await refresh();
600
1016
  return;
601
1017
  }
602
1018
  case 'voice-start':
603
- await send('start', undefined, 'voice started');
1019
+ await send(['start'], undefined, 'voice started');
604
1020
  return;
605
1021
  case 'voice-stop':
606
- await send('stop', undefined, 'voice stopped');
1022
+ await send(['stop'], undefined, 'voice stopped');
1023
+ return;
1024
+ case 'toggle':
1025
+ await send([`set ${key ?? ''}=${value ?? ''}`], key, `${value ?? ''}`);
607
1026
  return;
1027
+ case 'wait': {
1028
+ const keys = waitKeys(settings());
1029
+ if (keys.length === 0)
1030
+ return;
1031
+ const stopped = await send(keys.map(name => `set ${name}=${value ?? ''}`), keys[0], 'applied');
1032
+ // The note is what the registry now says, not what was asked for: a setter may clamp, and a pair
1033
+ // that half-applied has to be visible as a pair. Only when the write went through, because a
1034
+ // refusal is a better answer than any value.
1035
+ if (stopped === undefined) {
1036
+ notes[keys[0]] = waitSummary(settings());
1037
+ paint();
1038
+ }
1039
+ return;
1040
+ }
1041
+ case 'list-add': {
1042
+ if (key === undefined)
1043
+ return;
1044
+ const next = [...listAt(key), value ?? ''];
1045
+ drafts[key] = next;
1046
+ await send([`set ${key}=${JSON.stringify(next)}`], key, `${String(next.length)} in the list`);
1047
+ return;
1048
+ }
1049
+ case 'list-remove': {
1050
+ if (key === undefined)
1051
+ return;
1052
+ // A click that named no index is a click from something the panel did not render. It removes
1053
+ // nothing rather than defaulting to the first entry, which is what `Number(undefined)` would do.
1054
+ const index = event.target?.dataset?.index;
1055
+ const next = listAt(key).filter((_item, at) => at !== (index === undefined ? -1 : Number(index)));
1056
+ drafts[key] = next;
1057
+ await send([`set ${key}=${JSON.stringify(next)}`], key, `${String(next.length)} in the list`);
1058
+ return;
1059
+ }
608
1060
  case 'steer':
609
- await send(`steer ${value ?? ''}`, key, 'steered');
1061
+ await send([`steer ${value ?? ''}`], key, 'steered');
610
1062
  return;
611
- case 'set': await send(`set ${key ?? ''}=${value ?? ''}`, key, 'applied');
1063
+ case 'set': await send([`set ${key ?? ''}=${value ?? ''}`], key, 'applied');
612
1064
  }
613
1065
  };
614
1066
  void act();
615
1067
  };
616
1068
  deps.root.addEventListener('click', onClick);
617
- return { refresh, state: () => ({ reply, notes, notice, noticeLink, audio: deps.audio() }) };
1069
+ return {
1070
+ refresh,
1071
+ state: () => ({ reply, notes, drafts, notice, noticeLink, audio: deps.audio(), level, connecting }),
1072
+ };
618
1073
  }
619
1074
  /**
620
1075
  * The client plugin. Publishes the client and the strip on a global, and releases them with the plugin.