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