voice_control 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/assets/widget.js ADDED
@@ -0,0 +1,1910 @@
1
+ (() => {
2
+ if (customElements.get("voice-control-widget")) return;
3
+
4
+ class VoiceControlWidget extends HTMLElement {
5
+ connectedCallback() {
6
+ if (this.initialized) return;
7
+ this.initialized = true;
8
+ if (!this.shadowRoot) this.attachShadow({ mode: "open" });
9
+ this.shadowRoot.innerHTML = `
10
+ <link rel="stylesheet" href="${this.dataset.endpoint}/widget.css?v=${encodeURIComponent(this.dataset.version || "0.1.0")}">
11
+ <button class="launcher" type="button" aria-label="Open Voice Control" aria-expanded="false">
12
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="9" y="2" width="6" height="13" rx="3" stroke="currentColor" stroke-width="1.8"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
13
+ </button>
14
+ <div class="notification" role="status" aria-live="polite" hidden></div>
15
+ <section class="panel" role="dialog" aria-label="Voice Control commands" hidden>
16
+ <header><strong>voice_control<span>↗</span></strong><div class="controls"><button class="help-toggle" type="button" aria-label="Show all commands">?</button><button class="close" type="button" aria-label="Close and stop listening">×</button></div></header>
17
+ <div class="activity"><span class="indicator"></span><span class="mode">Ready when you are</span><button class="mic" type="button" aria-label="Start microphone">Mic off</button></div>
18
+ <div class="field-tools" hidden><span class="selected-field" aria-live="polite"></span><button class="undo" type="button" hidden>Undo</button></div>
19
+ <p class="status" role="status" aria-live="polite">Say what you want to do, or type below.</p>
20
+ <div class="choices"></div>
21
+ <form><label class="sr-only" for="command">Your command</label><input id="command" autocomplete="off" placeholder="Try “open users”…" maxlength="2000"><button class="send" type="submit" aria-label="Run command">↗</button></form>
22
+ <div class="suggestions" aria-label="Suggested commands" hidden></div>
23
+ <details class="debug" hidden><summary>Command details</summary><button class="copy-debug" type="button">Copy details</button><span class="copy-status" role="status"></span><pre tabindex="0"></pre></details>
24
+ <div class="help" hidden><label class="sr-only" for="search">Search commands</label><input id="search" placeholder="Find a command…" type="search" maxlength="160"><div class="catalog"></div></div>
25
+ </section>`;
26
+ this.panel = this.shadowRoot.querySelector(".panel");
27
+ this.input = this.shadowRoot.querySelector("#command");
28
+ this.status = this.shadowRoot.querySelector(".status");
29
+ this.choices = this.shadowRoot.querySelector(".choices");
30
+ this.help = this.shadowRoot.querySelector(".help");
31
+ this.shadowRoot
32
+ .querySelector(".copy-debug")
33
+ .addEventListener("click", () => this.copyDebug());
34
+ this.shadowRoot
35
+ .querySelector(".undo")
36
+ .addEventListener("click", () => this.undoLastEdit());
37
+ this.shadowRoot
38
+ .querySelector(".launcher")
39
+ .addEventListener("click", () => this.toggle());
40
+ this.shadowRoot
41
+ .querySelector(".close")
42
+ .addEventListener("click", () => this.close());
43
+ this.shadowRoot
44
+ .querySelector(".mic")
45
+ .addEventListener("click", () =>
46
+ this.listening ? this.stopMicrophone() : this.startMicrophone(),
47
+ );
48
+ this.shadowRoot
49
+ .querySelector(".help-toggle")
50
+ .addEventListener("click", () => this.toggleHelp());
51
+ this.shadowRoot
52
+ .querySelector("#search")
53
+ .addEventListener("input", () => this.renderCatalog());
54
+ this.shadowRoot
55
+ .querySelector("form")
56
+ .addEventListener("submit", (event) => {
57
+ event.preventDefault();
58
+ this.submit(this.input.value);
59
+ });
60
+ this.shadowRoot.addEventListener("keydown", (event) => {
61
+ this.navigateHelp(event);
62
+ if (event.key === "Escape") {
63
+ event.stopPropagation();
64
+ this.close();
65
+ }
66
+ });
67
+ this.shadowRoot.addEventListener("pointerdown", () => this.touch());
68
+ this.input.addEventListener("input", () => this.touch());
69
+ this.keyHandler = (event) => {
70
+ if (this.matchesShortcut(event, this.dataset.pushToTalkShortcut)) {
71
+ event.preventDefault();
72
+ if (!event.repeat) this.startPushToTalk(event);
73
+ } else if (
74
+ !event.repeat &&
75
+ this.matchesShortcut(event, this.dataset.shortcut)
76
+ ) {
77
+ event.preventDefault();
78
+ this.toggle();
79
+ }
80
+ };
81
+ this.keyReleaseHandler = (event) => {
82
+ if (
83
+ this.pushToTalk &&
84
+ (event.code === this.pushToTalk.code ||
85
+ event.key === this.pushToTalk.key ||
86
+ ["Meta", "Control", "Alt", "Shift"].includes(event.key))
87
+ ) {
88
+ event.preventDefault();
89
+ this.releasePushToTalk();
90
+ }
91
+ };
92
+ this.blurHandler = () => {
93
+ if (this.pushToTalk) this.stopMicrophone();
94
+ };
95
+ this.visibilityHandler = () => {
96
+ if (document.hidden) this.blurHandler();
97
+ };
98
+ this.pageShowHandler = () => this.restoreSession();
99
+ this.pageHandler = () => {
100
+ const resume = !this.panel.hidden
101
+ ? { until: this.idleDeadline, listening: this.listening }
102
+ : null;
103
+ this.close(false);
104
+ try {
105
+ if (resume)
106
+ sessionStorage.setItem("voice_control:resume", JSON.stringify(resume));
107
+ if (this.notification?.until > Date.now())
108
+ sessionStorage.setItem(
109
+ "voice_control:notification",
110
+ JSON.stringify(this.notification),
111
+ );
112
+ } catch {
113
+ /* Storage may be disabled by the browser. */
114
+ }
115
+ };
116
+ window.addEventListener("keydown", this.keyHandler);
117
+ window.addEventListener("keyup", this.keyReleaseHandler);
118
+ window.addEventListener("blur", this.blurHandler);
119
+ document.addEventListener("visibilitychange", this.visibilityHandler);
120
+ window.addEventListener("pagehide", this.pageHandler);
121
+ window.addEventListener("pageshow", this.pageShowHandler);
122
+ this.pageUrl = location.href;
123
+ this.pageActionsHandler = () => {
124
+ if (this.pageUrl !== location.href) {
125
+ this.pageUrl = location.href;
126
+ this.continuation = null;
127
+ this.choices.replaceChildren();
128
+ this.catalog = [];
129
+ this.catalogVersion = (this.catalogVersion || 0) + 1;
130
+ if (!this.help.hidden) this.loadCatalog();
131
+ }
132
+ if (
133
+ this.selectedField &&
134
+ (this.selectedField.url !== location.href ||
135
+ !this.selectedField.node.isConnected)
136
+ )
137
+ this.selectedField = null;
138
+ clearTimeout(this.pageActionsTimer);
139
+ if (
140
+ this.undoEdit &&
141
+ (!this.undoEdit.node.isConnected ||
142
+ this.undoEdit.url !== location.href)
143
+ )
144
+ this.undoEdit = null;
145
+ if (this.panel.hidden) return;
146
+ this.pageActionsTimer = setTimeout(() => {
147
+ const snapshot = this.refreshPageTools();
148
+ if (!this.help.hidden) this.renderCatalog(snapshot);
149
+ }, 80);
150
+ };
151
+ if (this.dataset.browserActions === "true") {
152
+ this.fieldSelectionHandler = (event) => {
153
+ if (!event.composedPath().includes(this))
154
+ this.rememberPageField(event.target);
155
+ };
156
+ document.addEventListener("focusin", this.fieldSelectionHandler);
157
+ document.addEventListener("pointerdown", this.fieldSelectionHandler);
158
+ this.formSubmitHandler = (event) => {
159
+ if (this.undoEdit?.node.form === event.target) {
160
+ this.undoEdit = null;
161
+ this.pageActionsHandler();
162
+ }
163
+ };
164
+ this.fieldEditHandler = (event) => {
165
+ if (
166
+ this.undoEdit?.node === event.target &&
167
+ this.fieldValue(event.target) !== this.undoEdit.after
168
+ )
169
+ this.undoEdit = null;
170
+ if (!event.composedPath().includes(this)) this.pageActionsHandler();
171
+ };
172
+ document.addEventListener("submit", this.formSubmitHandler, true);
173
+ document.addEventListener("input", this.fieldEditHandler);
174
+ document.addEventListener("change", this.fieldEditHandler);
175
+ this.rememberPageField(document.activeElement);
176
+ this.pageActionsObserver = new window.MutationObserver(
177
+ this.pageActionsHandler,
178
+ );
179
+ this.pageActionsObserver.observe(document.documentElement, {
180
+ subtree: true,
181
+ childList: true,
182
+ characterData: true,
183
+ attributes: true,
184
+ attributeFilter: [
185
+ "id",
186
+ "name",
187
+ "type",
188
+ "role",
189
+ "href",
190
+ "for",
191
+ "title",
192
+ "placeholder",
193
+ "class",
194
+ "style",
195
+ "hidden",
196
+ "inert",
197
+ "disabled",
198
+ "readonly",
199
+ "multiple",
200
+ "label",
201
+ "value",
202
+ "aria-hidden",
203
+ "aria-disabled",
204
+ "aria-label",
205
+ "aria-labelledby",
206
+ "data-voice-control-label",
207
+ "data-voice-control-ignore",
208
+ "content",
209
+ ],
210
+ });
211
+ }
212
+ document.addEventListener("turbo:load", this.pageActionsHandler);
213
+ document.addEventListener("turbo:frame-load", this.pageActionsHandler);
214
+ window.addEventListener("popstate", this.pageActionsHandler);
215
+ window.addEventListener("hashchange", this.pageActionsHandler);
216
+ window.VoiceControl = window.VoiceControl || {};
217
+ window.VoiceControl.open = () => this.open();
218
+ window.VoiceControl.close = () => this.close();
219
+ window.VoiceControl.refresh = () => this.pageActionsHandler();
220
+ window.VoiceControl.setContext = (context) => {
221
+ this.clientContext = context;
222
+ };
223
+ window.VoiceControl.configure = (options) => {
224
+ this.options = options;
225
+ };
226
+ this.restoreSession();
227
+ }
228
+
229
+ restoreSession() {
230
+ try {
231
+ const resume = JSON.parse(sessionStorage.getItem("voice_control:resume"));
232
+ sessionStorage.removeItem("voice_control:resume");
233
+ if (resume?.until > Date.now()) this.open(resume.listening);
234
+ const notification = JSON.parse(
235
+ sessionStorage.getItem("voice_control:notification"),
236
+ );
237
+ sessionStorage.removeItem("voice_control:notification");
238
+ if (notification?.until > Date.now())
239
+ this.showNotification(notification.text, notification.until);
240
+ } catch {
241
+ /* A fresh visit never needs stored state. */
242
+ }
243
+ }
244
+
245
+ disconnectedCallback() {
246
+ // Turbo reparents permanent elements during a render; only dispose actual removals.
247
+ setTimeout(() => {
248
+ if (!this.isConnected) {
249
+ this.close(false);
250
+ window.removeEventListener("keydown", this.keyHandler);
251
+ window.removeEventListener("keyup", this.keyReleaseHandler);
252
+ window.removeEventListener("blur", this.blurHandler);
253
+ document.removeEventListener(
254
+ "visibilitychange",
255
+ this.visibilityHandler,
256
+ );
257
+ window.removeEventListener("pagehide", this.pageHandler);
258
+ window.removeEventListener("pageshow", this.pageShowHandler);
259
+ clearTimeout(this.notificationTimer);
260
+ this.pageActionsObserver?.disconnect();
261
+ document.removeEventListener("submit", this.formSubmitHandler, true);
262
+ document.removeEventListener("input", this.fieldEditHandler);
263
+ document.removeEventListener("change", this.fieldEditHandler);
264
+ document.removeEventListener("focusin", this.fieldSelectionHandler);
265
+ document.removeEventListener(
266
+ "pointerdown",
267
+ this.fieldSelectionHandler,
268
+ );
269
+ document.removeEventListener("turbo:load", this.pageActionsHandler);
270
+ document.removeEventListener(
271
+ "turbo:frame-load",
272
+ this.pageActionsHandler,
273
+ );
274
+ window.removeEventListener("popstate", this.pageActionsHandler);
275
+ window.removeEventListener("hashchange", this.pageActionsHandler);
276
+ this.initialized = false;
277
+ }
278
+ }, 0);
279
+ }
280
+
281
+ matchesShortcut(event, shortcut) {
282
+ if (!shortcut) return false;
283
+ const parts = shortcut.toLowerCase().split("+");
284
+ const modifiers = parts.slice(0, -1);
285
+ const mac = /Mac|iPhone|iPad/.test(navigator.platform);
286
+ const meta =
287
+ modifiers.includes("meta") || (modifiers.includes("mod") && mac);
288
+ const ctrl =
289
+ modifiers.includes("ctrl") || (modifiers.includes("mod") && !mac);
290
+ return (
291
+ event.key.toLowerCase() ===
292
+ (parts.at(-1) === "space" ? " " : parts.at(-1)) &&
293
+ event.metaKey === meta &&
294
+ event.ctrlKey === ctrl &&
295
+ event.shiftKey === modifiers.includes("shift") &&
296
+ event.altKey === modifiers.includes("alt")
297
+ );
298
+ }
299
+
300
+ toggle() {
301
+ this.panel.hidden ? this.open() : this.close();
302
+ }
303
+
304
+ open(listen = true) {
305
+ if (!this.isConnected || !this.panel.hidden) return;
306
+ this.panel.hidden = false;
307
+ this.shadowRoot
308
+ .querySelector(".launcher")
309
+ .setAttribute("aria-expanded", "true");
310
+ this.input.focus();
311
+ this.touch();
312
+ this.refreshPageTools();
313
+ if (!this.help.hidden) this.renderCatalog();
314
+ if (listen) this.startMicrophone();
315
+ }
316
+
317
+ close(focus = true) {
318
+ this.targetHighlight?.cancel();
319
+ this.panel.hidden = true;
320
+ try {
321
+ sessionStorage.removeItem("voice_control:resume");
322
+ } catch {
323
+ /* Storage is optional. */
324
+ }
325
+ this.shadowRoot
326
+ .querySelector(".launcher")
327
+ .setAttribute("aria-expanded", "false");
328
+ this.stopMicrophone();
329
+ clearTimeout(this.idleTimer);
330
+ clearTimeout(this.speechTimer);
331
+ clearTimeout(this.pageActionsTimer);
332
+ this.request?.abort();
333
+ this.generation = (this.generation || 0) + 1;
334
+ this.continuation = null;
335
+ this.choices.replaceChildren();
336
+ if (focus) this.shadowRoot.querySelector(".launcher").focus();
337
+ }
338
+
339
+ touch() {
340
+ clearTimeout(this.idleTimer);
341
+ if (!this.panel.hidden) {
342
+ const timeout = Number(this.dataset.idleTimeout) || 120000;
343
+ this.idleDeadline = Date.now() + timeout;
344
+ this.idleTimer = setTimeout(() => this.close(), timeout);
345
+ }
346
+ }
347
+
348
+ startMicrophone() {
349
+ const Recognition =
350
+ window.SpeechRecognition || window.webkitSpeechRecognition;
351
+ if (!Recognition) {
352
+ this.status.textContent =
353
+ "Voice is unavailable in this browser. Type a command below.";
354
+ return;
355
+ }
356
+ if (this.listening || this.panel.hidden) return;
357
+ this.listening = true;
358
+ this.recognition = new Recognition();
359
+ this.recognition.lang = this.dataset.speechLanguage || "en-US";
360
+ this.recognition.continuous = true;
361
+ this.recognition.interimResults = true;
362
+ const recognition = this.recognition;
363
+ this.recognition.onresult = (event) => {
364
+ if (this.recognition !== recognition || this.busy || this.panel.hidden)
365
+ return;
366
+ this.touch();
367
+ let final = "";
368
+ let interim = "";
369
+ for (
370
+ let index = event.resultIndex;
371
+ index < event.results.length;
372
+ index++
373
+ ) {
374
+ if (event.results[index].isFinal)
375
+ final += event.results[index][0].transcript;
376
+ else interim += event.results[index][0].transcript;
377
+ }
378
+ if (this.pushToTalk) {
379
+ this.pushToTalk.final = `${this.pushToTalk.final} ${final}`.trim();
380
+ this.pushToTalk.interim = interim;
381
+ this.input.value = `${this.pushToTalk.final} ${interim}`.trim();
382
+ return;
383
+ }
384
+ if (final.trim() || interim) {
385
+ this.pendingSpeech = `${this.pendingSpeech || ""} ${final}`.trim();
386
+ this.input.value = `${this.pendingSpeech} ${interim}`.trim();
387
+ clearTimeout(this.speechTimer);
388
+ if (!interim)
389
+ this.speechTimer = setTimeout(() => {
390
+ const text = this.pendingSpeech;
391
+ this.pendingSpeech = "";
392
+ this.submit(text);
393
+ }, 600);
394
+ }
395
+ };
396
+ this.recognition.onerror = (event) => {
397
+ if (event.error !== "no-speech" && event.error !== "aborted") {
398
+ this.stopMicrophone();
399
+ this.status.textContent =
400
+ event.error === "language-not-supported"
401
+ ? "This browser cannot recognize the configured speech language. You can still type commands."
402
+ : "Microphone unavailable. You can still type commands.";
403
+ }
404
+ };
405
+ this.recognition.onend = () => {
406
+ if (this.recognition !== recognition) return;
407
+ if (this.pushToTalk?.released) {
408
+ this.finishPushToTalk();
409
+ return;
410
+ }
411
+ if (this.listening && !this.panel.hidden && !this.busy) {
412
+ this.restartTimer = setTimeout(() => this.resumeRecognition(), 300);
413
+ }
414
+ };
415
+ this.resumeRecognition();
416
+ this.microphoneState();
417
+ }
418
+
419
+ resumeRecognition() {
420
+ if (!this.listening || this.busy || this.panel.hidden) return;
421
+ try {
422
+ this.recognition?.start();
423
+ } catch (error) {
424
+ if (error.name !== "InvalidStateError") this.stopMicrophone();
425
+ }
426
+ }
427
+
428
+ stopMicrophone() {
429
+ clearTimeout(this.pushToTalk?.timer);
430
+ this.pushToTalk = null;
431
+ this.listening = false;
432
+ clearTimeout(this.restartTimer);
433
+ clearTimeout(this.speechTimer);
434
+ this.pendingSpeech = "";
435
+ if (this.recognition) {
436
+ this.recognition.onend = null;
437
+ this.recognition.onerror = null;
438
+ this.recognition.onresult = null;
439
+ this.recognition.abort();
440
+ this.recognition = null;
441
+ }
442
+ this.microphoneState();
443
+ }
444
+
445
+ startPushToTalk(event) {
446
+ if (this.busy || this.pushToTalk) return;
447
+ this.open(false);
448
+ this.stopMicrophone();
449
+ this.pushToTalk = {
450
+ code: event.code,
451
+ key: event.key,
452
+ final: "",
453
+ interim: "",
454
+ released: false,
455
+ };
456
+ this.startMicrophone();
457
+ if (!this.listening) this.pushToTalk = null;
458
+ }
459
+
460
+ releasePushToTalk() {
461
+ const hold = this.pushToTalk;
462
+ if (!hold || hold.released) return;
463
+ hold.released = true;
464
+ clearTimeout(this.restartTimer);
465
+ // stop() delivers final recognition results before onend; abort() discards them.
466
+ hold.timer = setTimeout(() => this.finishPushToTalk(), 1500);
467
+ try {
468
+ this.recognition?.stop();
469
+ } catch {
470
+ this.finishPushToTalk();
471
+ }
472
+ }
473
+
474
+ finishPushToTalk() {
475
+ const hold = this.pushToTalk;
476
+ if (!hold?.released) return;
477
+ const text = hold.interim.trim() ? "" : hold.final.trim();
478
+ const draft = `${hold.final} ${hold.interim}`.trim();
479
+ this.stopMicrophone();
480
+ if (this.panel.hidden) return;
481
+ this.input.value = text || draft;
482
+ if (text) this.submit(text);
483
+ else
484
+ this.status.textContent = draft
485
+ ? "Speech was not finalized. Review it and press Enter."
486
+ : "No speech captured. Hold the shortcut and try again.";
487
+ }
488
+
489
+ microphoneState() {
490
+ this.shadowRoot.querySelector(".mode").textContent = this.listening
491
+ ? "Listening · English"
492
+ : "Type a command";
493
+ this.shadowRoot.querySelector(".mic").textContent = this.listening
494
+ ? "Mic on"
495
+ : "Mic off";
496
+ this.shadowRoot
497
+ .querySelector(".mic")
498
+ .setAttribute(
499
+ "aria-label",
500
+ this.listening ? "Stop microphone" : "Start microphone",
501
+ );
502
+ this.shadowRoot
503
+ .querySelector(".indicator")
504
+ .classList.toggle("live", !!this.listening);
505
+ }
506
+
507
+ async api(path, body, signal) {
508
+ const controller = new AbortController();
509
+ const abort = () => controller.abort();
510
+ if (signal?.aborted) abort();
511
+ signal?.addEventListener("abort", abort, { once: true });
512
+ let timer;
513
+ const duration = Number(this.dataset.requestTimeout);
514
+ const timeout =
515
+ Number.isInteger(duration) && duration >= 1000 && duration <= 300000
516
+ ? duration
517
+ : 30000;
518
+ try {
519
+ return await Promise.race([
520
+ (async () => {
521
+ const response = await fetch(`${this.dataset.endpoint}/${path}${path === "commands" ? `?path=${encodeURIComponent(location.pathname)}` : ""}`, {
522
+ method: body ? "POST" : "GET",
523
+ credentials: "same-origin",
524
+ signal: controller.signal,
525
+ headers: {
526
+ Accept: "application/json",
527
+ "Content-Type": "application/json",
528
+ "X-CSRF-Token":
529
+ document.querySelector('meta[name="csrf-token"]')?.content ||
530
+ "",
531
+ },
532
+ ...(body ? { body: JSON.stringify(body) } : {}),
533
+ });
534
+ if (
535
+ !response.headers
536
+ .get("content-type")
537
+ ?.includes("application/json")
538
+ )
539
+ throw new Error("Your session changed. Reload the page.");
540
+ const result = await response.json();
541
+ if (path !== "commands")
542
+ this.showDebug(result.debug, { http_status: response.status });
543
+ if (!response.ok)
544
+ throw new Error(
545
+ result.message || "The command could not be completed.",
546
+ );
547
+ return result;
548
+ })(),
549
+ new Promise((_, reject) => {
550
+ timer = setTimeout(() => {
551
+ reject(
552
+ new Error(
553
+ path === "execute"
554
+ ? "The action may already have completed. Check the page before trying again."
555
+ : path === "interpret"
556
+ ? "Understanding took too long. Please try again."
557
+ : "Loading commands took too long. Please try again.",
558
+ ),
559
+ );
560
+ controller.abort();
561
+ }, timeout);
562
+ }),
563
+ ]);
564
+ } finally {
565
+ clearTimeout(timer);
566
+ signal?.removeEventListener("abort", abort);
567
+ }
568
+ }
569
+
570
+ showNotification(text, until = Date.now() + 5000) {
571
+ if (typeof text !== "string" || !text.trim() || text.length > 200) return;
572
+ clearTimeout(this.notificationTimer);
573
+ this.notification = { text, until };
574
+ const notice = this.shadowRoot.querySelector(".notification");
575
+ notice.textContent = text;
576
+ notice.hidden = false;
577
+ this.notificationTimer = setTimeout(
578
+ () => {
579
+ notice.hidden = true;
580
+ this.notification = null;
581
+ },
582
+ Math.min(5000, Math.max(0, until - Date.now())),
583
+ );
584
+ }
585
+
586
+ showDebug(details = {}, extra = {}) {
587
+ if (this.dataset.debug !== "true") return;
588
+ const { duration_ms: duration, ...fields } = details;
589
+ this.debugDetails = {
590
+ ...this.debugDetails,
591
+ ...fields,
592
+ ...(duration !== undefined
593
+ ? { [`${details.stage}_ms`]: duration }
594
+ : {}),
595
+ ...extra,
596
+ };
597
+ const panel = this.shadowRoot.querySelector(".debug");
598
+ panel.hidden = false;
599
+ panel.querySelector(".copy-status").textContent = "";
600
+ panel.querySelector("pre").textContent = Object.entries(this.debugDetails)
601
+ .filter(
602
+ ([key, value]) =>
603
+ key !== "command_labels" && value !== undefined && value !== null,
604
+ )
605
+ .map(([key, value]) => {
606
+ if (key === "jev_result" && value.probabilities) {
607
+ value = {
608
+ ...value,
609
+ probabilities: Object.fromEntries(
610
+ Object.entries(value.probabilities).map(
611
+ ([command, probability]) => {
612
+ const label = this.debugDetails.command_labels?.[command];
613
+ return [
614
+ label ? `${label} [${command}]` : command,
615
+ probability,
616
+ ];
617
+ },
618
+ ),
619
+ ),
620
+ };
621
+ }
622
+ return `${key.replaceAll("_", " ")}: ${typeof value === "object" ? JSON.stringify(value, null, 2) : value}`;
623
+ })
624
+ .join("\n");
625
+ }
626
+
627
+ async copyDebug() {
628
+ if (this.dataset.debug !== "true") return;
629
+ const panel = this.shadowRoot.querySelector(".debug");
630
+ const report = panel.querySelector("pre").textContent;
631
+ if (!report) return;
632
+ const button = panel.querySelector(".copy-debug");
633
+ button.disabled = true;
634
+ try {
635
+ await navigator.clipboard.writeText(report);
636
+ panel.querySelector(".copy-status").textContent = "Copied.";
637
+ } catch {
638
+ panel.querySelector(".copy-status").textContent =
639
+ "Copy unavailable. Select the details below and copy manually.";
640
+ panel.querySelector("pre").focus();
641
+ } finally {
642
+ button.disabled = false;
643
+ }
644
+ }
645
+
646
+ async submit(text = "", command = null) {
647
+ text = text.trim();
648
+ if (/^(undo|undo that|undo last edit)[.!]?$/i.test(text)) {
649
+ this.undoLastEdit();
650
+ return;
651
+ }
652
+ if (
653
+ /^(stop listening|(?:close|stop) voice[ _]control|stop jev|close jev)[.!]?$/i.test(
654
+ text,
655
+ )
656
+ ) {
657
+ this.close();
658
+ return;
659
+ }
660
+ if (/^cancel[.!]?$/i.test(text)) {
661
+ this.request?.abort();
662
+ this.generation = (this.generation || 0) + 1;
663
+ clearTimeout(this.speechTimer);
664
+ this.pendingSpeech = "";
665
+ this.continuation = null;
666
+ this.choices.replaceChildren();
667
+ this.status.textContent = this.executing
668
+ ? "Stopped waiting. The action may already have run; check the result."
669
+ : "Command canceled.";
670
+ return;
671
+ }
672
+ if (this.busy || this.panel.hidden || (!text && !command)) return;
673
+ if (this.pushToTalk) this.stopMicrophone();
674
+ this.debugDetails = {};
675
+ this.showDebug(
676
+ {},
677
+ { transcript: text || "Help / suggestion", outcome: "pending" },
678
+ );
679
+ this.touch();
680
+ clearTimeout(this.speechTimer);
681
+ this.pendingSpeech = "";
682
+ this.busy = true;
683
+ this.refreshPageTools();
684
+ this.recognition?.abort();
685
+ this.request = new AbortController();
686
+ const generation = this.generation || 0;
687
+ const pageUrl = location.href;
688
+ this.shadowRoot.querySelector(".send").disabled = true;
689
+ this.status.textContent = "Understanding…";
690
+ this.choices.replaceChildren();
691
+ try {
692
+ if (this.browserActionsEnabled() && !this.continuation)
693
+ this.browserSnapshot = this.discoverBrowserControls();
694
+ const context =
695
+ typeof this.clientContext === "function"
696
+ ? this.clientContext()
697
+ : this.clientContext;
698
+ let result = await this.api(
699
+ "interpret",
700
+ {
701
+ transcript: text,
702
+ command,
703
+ continuation: this.continuation,
704
+ context: {
705
+ url: location.href,
706
+ path: location.pathname,
707
+ ...context,
708
+ },
709
+ ...(!this.continuation && this.browserActionsEnabled()
710
+ ? { browser_page: this.browserSnapshot.page }
711
+ : {}),
712
+ },
713
+ this.request.signal,
714
+ );
715
+ if (generation !== (this.generation || 0) || this.panel.hidden) return;
716
+ if (pageUrl !== location.href)
717
+ throw new Error("The page changed. Please start the command again.");
718
+ this.continuation = result.continuation || null;
719
+ if (result.kind === "execute") {
720
+ this.executing = true;
721
+ this.status.textContent = "Running…";
722
+ result = await this.api(
723
+ "execute",
724
+ { ticket: result.ticket },
725
+ this.request.signal,
726
+ );
727
+ }
728
+ if (generation !== (this.generation || 0) || this.panel.hidden) return;
729
+ if (pageUrl !== location.href)
730
+ throw new Error("The page changed. Check the action's result before trying again.");
731
+ this.input.value = "";
732
+ this.status.textContent = result.message || "Done.";
733
+ if (result.kind === "ambiguous") {
734
+ result.candidates.forEach((candidate, index) =>
735
+ this.addChoice(`${index + 1}. ${candidate.description}`, () =>
736
+ this.submit("", candidate.key),
737
+ ),
738
+ );
739
+ } else if (result.kind === "reload") {
740
+ this.undoEdit = null;
741
+ location.reload();
742
+ } else if (result.kind === "navigate") {
743
+ this.undoEdit = null;
744
+ const url = new URL(result.url, location.origin);
745
+ if (url.origin !== location.origin)
746
+ throw new Error("Navigation must stay on this website.");
747
+ if (this.options?.navigate)
748
+ this.options.navigate(url.pathname + url.search + url.hash);
749
+ else if (window.Turbo) window.Turbo.visit(url.href);
750
+ else location.assign(url.href);
751
+ } else if (result.kind === "event") {
752
+ this.undoEdit = null;
753
+ window.dispatchEvent(
754
+ new CustomEvent(result.name, { detail: result.detail }),
755
+ );
756
+ } else if (result.kind === "browser") {
757
+ this.runBrowserAction(result);
758
+ this.showDebug(
759
+ {},
760
+ { outcome: "completed", browser_action: result.action },
761
+ );
762
+ } else if (result.kind === "message") {
763
+ this.undoEdit = null;
764
+ }
765
+ if (
766
+ ["message", "navigate", "reload", "event", "browser"].includes(
767
+ result.kind,
768
+ )
769
+ )
770
+ this.showNotification(result.notification);
771
+ } catch (error) {
772
+ this.showDebug(
773
+ {},
774
+ {
775
+ outcome: error.name === "AbortError" ? "canceled" : "error",
776
+ message: error.message,
777
+ },
778
+ );
779
+ if (error.name !== "AbortError")
780
+ this.status.textContent =
781
+ error.message ||
782
+ "Something went wrong. Check the result before trying again.";
783
+ } finally {
784
+ this.executing = false;
785
+ this.busy = false;
786
+ this.refreshPageTools();
787
+ this.shadowRoot.querySelector(".send").disabled = false;
788
+ this.resumeRecognition();
789
+ }
790
+ }
791
+
792
+ browserActionsEnabled() {
793
+ return this.dataset.browserActions === "true" && !Array.from(
794
+ document.head.querySelectorAll('meta[name="voice-control-browser-actions"]'),
795
+ ).some((meta) => meta.content.trim().toLowerCase() === "off");
796
+ }
797
+
798
+ browserActionsFor(target) {
799
+ if (target instanceof window.HTMLInputElement) {
800
+ if (target.type === "checkbox") return ["check", "uncheck"];
801
+ if (target.type === "radio") return ["choose"];
802
+ if (["button", "submit", "reset"].includes(target.type))
803
+ return ["click"];
804
+ return !target.readOnly &&
805
+ [
806
+ "text",
807
+ "search",
808
+ "email",
809
+ "tel",
810
+ "url",
811
+ "number",
812
+ "date",
813
+ "time",
814
+ ].includes(target.type)
815
+ ? ["fill", "focus", "clear"]
816
+ : [];
817
+ }
818
+ if (target instanceof window.HTMLTextAreaElement)
819
+ return target.readOnly ? [] : ["fill", "focus", "clear"];
820
+ if (target.matches("h1, h2, h3, h4, h5, h6")) return ["reveal"];
821
+ if (target instanceof window.HTMLSelectElement)
822
+ return target.multiple || !this.dropdownOptions(target).length
823
+ ? []
824
+ : ["select", "focus"];
825
+ if (target.matches("button, [role='button']")) return ["click"];
826
+ if (target.matches("a[href]")) {
827
+ const url = new URL(target.href, location.href);
828
+ return url.origin === location.origin &&
829
+ ["http:", "https:"].includes(url.protocol)
830
+ ? ["click"]
831
+ : [];
832
+ }
833
+ return [];
834
+ }
835
+
836
+ browserControlVisible(target) {
837
+ return (
838
+ !target.closest(
839
+ "voice-control-widget, [data-voice-control-ignore], [hidden], [inert], [aria-hidden='true']",
840
+ ) &&
841
+ !target.form?.closest("[data-voice-control-ignore]") &&
842
+ !!target.getClientRects().length &&
843
+ !["hidden", "collapse"].includes(
844
+ window.getComputedStyle(target).visibility,
845
+ )
846
+ );
847
+ }
848
+
849
+ browserControlMetadata(target, ref) {
850
+ const shorten = (text) =>
851
+ (text || "").replace(/\s+/g, " ").trim().slice(0, 160);
852
+ const readableLabel = (text) => {
853
+ const label = shorten(
854
+ (text || "")
855
+ .normalize("NFC")
856
+ .replace(/[#*0-9]\uFE0F?\u20E3/gu, " ")
857
+ .replace(/[\p{S}\p{Cf}\p{Co}\uFE0E\uFE0F\u2022\u00B7]/gu, " ")
858
+ .replace(/[\p{Cc}]/gu, " "),
859
+ );
860
+ return /[\p{L}\p{N}]/u.test(label) ? label : "";
861
+ };
862
+ const labelText = (node) => {
863
+ if (
864
+ !node ||
865
+ node.matches("input, textarea, select") ||
866
+ node.closest("[data-voice-control-ignore]")
867
+ )
868
+ return "";
869
+ const copy = node.cloneNode(true);
870
+ copy
871
+ .querySelectorAll(
872
+ "input, textarea, select, script, style, svg, [hidden], [inert], [aria-hidden='true'], [role='img'], [data-voice-control-ignore], .material-icons, .material-icons-outlined, .material-symbols-outlined, .material-symbols-rounded, .material-symbols-sharp",
873
+ )
874
+ .forEach((child) => child.remove());
875
+ return copy.textContent;
876
+ };
877
+ const labelledBy = (target.getAttribute("aria-labelledby") || "")
878
+ .split(/\s+/)
879
+ .map((id) => labelText(document.getElementById(id)))
880
+ .join(" ");
881
+ const labels = Array.from(target.labels || [])
882
+ .map(labelText)
883
+ .join(" ");
884
+ const buttonValue = target.matches(
885
+ "input[type='button'], input[type='submit'], input[type='reset']",
886
+ )
887
+ ? target.value
888
+ : "";
889
+ let label =
890
+ [
891
+ target.getAttribute("data-voice-control-label"),
892
+ target.getAttribute("aria-label"),
893
+ labelledBy,
894
+ labels,
895
+ target.matches("button, a, [role='button'], h1, h2, h3, h4, h5, h6")
896
+ ? labelText(target)
897
+ : "",
898
+ Array.from(target.querySelectorAll("img[alt]"))
899
+ .filter((image) => this.browserControlVisible(image))
900
+ .map((image) => image.alt)
901
+ .join(" "),
902
+ buttonValue,
903
+ target.getAttribute("placeholder"),
904
+ target.getAttribute("title"),
905
+ ]
906
+ .map(readableLabel)
907
+ .find(Boolean) || "";
908
+ if (
909
+ !target.getAttribute("data-voice-control-label") &&
910
+ !target.getAttribute("aria-label") &&
911
+ !labelledBy.trim() &&
912
+ /^(edit|delete|remove|view|open|details|manage)$/i.test(label)
913
+ ) {
914
+ const row = target.closest("tr, [role='row']");
915
+ const cell = row?.querySelector(
916
+ "th[scope='row'], [role='rowheader'], td, [role='cell'], [role='gridcell']",
917
+ );
918
+ const identity =
919
+ cell?.querySelector("a, strong, [data-voice-control-row-label]") || cell;
920
+ if (
921
+ identity &&
922
+ !identity.contains(target) &&
923
+ this.browserControlVisible(identity)
924
+ ) {
925
+ const copy = identity.cloneNode(true);
926
+ copy
927
+ .querySelectorAll(
928
+ "small, button, input, textarea, select, [hidden], [aria-hidden='true'], [data-voice-control-ignore]",
929
+ )
930
+ .forEach((node) => node.remove());
931
+ const context = readableLabel(labelText(copy));
932
+ if (
933
+ context &&
934
+ !context.includes("@") &&
935
+ !/^(edit|delete|remove|view|open|details|manage)$/i.test(context)
936
+ )
937
+ label = readableLabel(`${label} ${context}`);
938
+ }
939
+ }
940
+ const metadata = {
941
+ ref,
942
+ label,
943
+ id: shorten(target.id),
944
+ name: shorten(target.name),
945
+ tag: target.localName,
946
+ type:
947
+ target instanceof window.HTMLInputElement
948
+ ? target.type
949
+ : target.getAttribute("role") === "button"
950
+ ? "button"
951
+ : "",
952
+ };
953
+ if (label && target instanceof window.HTMLSelectElement) {
954
+ metadata.options = this.dropdownOptions(target).map(
955
+ ({ option, index }) => ({
956
+ ref: `o${index}`,
957
+ label: readableLabel(option.label) || `Option ${index + 1}`,
958
+ }),
959
+ );
960
+ if (
961
+ metadata.options.length > 100 ||
962
+ new window.TextEncoder().encode(JSON.stringify(metadata.options))
963
+ .length > 4096
964
+ )
965
+ throw new Error(
966
+ "This dropdown has too many options for voice commands.",
967
+ );
968
+ }
969
+ return metadata;
970
+ }
971
+
972
+ dropdownOptions(target) {
973
+ return Array.from(target.options, (option, index) => ({
974
+ option,
975
+ index,
976
+ })).filter(
977
+ ({ option }) =>
978
+ !option.disabled &&
979
+ !option.closest(
980
+ "optgroup[disabled], [hidden], [data-voice-control-ignore]",
981
+ ) &&
982
+ option.getAttribute("aria-disabled") !== "true",
983
+ );
984
+ }
985
+
986
+ rememberPageField(target) {
987
+ this.formSelection =
988
+ target instanceof window.HTMLElement
989
+ ? {
990
+ node: target.closest("input, textarea, select, button, form"),
991
+ url: location.href,
992
+ }
993
+ : null;
994
+ this.selectedField =
995
+ target instanceof window.HTMLElement &&
996
+ this.browserActionsFor(target).some((action) =>
997
+ ["fill", "select"].includes(action),
998
+ ) &&
999
+ this.browserControlVisible(target) &&
1000
+ !target.matches(":disabled, [aria-disabled='true']")
1001
+ ? { node: target, url: location.href }
1002
+ : null;
1003
+ this.pageActionsHandler();
1004
+ }
1005
+
1006
+ activeFormState() {
1007
+ const control =
1008
+ this.formSelection?.url === location.href
1009
+ ? this.formSelection.node
1010
+ : null;
1011
+ let form;
1012
+ if (control) {
1013
+ if (
1014
+ !control.isConnected ||
1015
+ !this.browserControlVisible(control) ||
1016
+ control.matches(":disabled, [aria-disabled='true']") ||
1017
+ (!(control instanceof window.HTMLFormElement) &&
1018
+ !this.browserActionsFor(control).length)
1019
+ )
1020
+ return { error: "Select an available field in the form first." };
1021
+ form =
1022
+ control instanceof window.HTMLFormElement ? control : control.form;
1023
+ if (!form) return { error: "This field has no form to submit." };
1024
+ } else {
1025
+ const forms = Array.from(document.forms).filter((candidate) =>
1026
+ this.browserControlVisible(candidate),
1027
+ );
1028
+ if (forms.length !== 1)
1029
+ return {
1030
+ error: forms.length
1031
+ ? "Select a field in the form you want to submit."
1032
+ : "There is no available form to submit on this page.",
1033
+ };
1034
+ [form] = forms;
1035
+ }
1036
+ if (!this.browserControlVisible(form))
1037
+ return { error: "Show the form before submitting it." };
1038
+ const submitter = Array.from(form.elements).find(
1039
+ (element) =>
1040
+ (element instanceof window.HTMLButtonElement ||
1041
+ element instanceof window.HTMLInputElement) &&
1042
+ ["submit", "image"].includes(element.type),
1043
+ );
1044
+ if (
1045
+ submitter &&
1046
+ (!this.browserControlVisible(submitter) ||
1047
+ submitter.matches(":disabled, [aria-disabled='true']"))
1048
+ )
1049
+ return { error: "The form's submit button is unavailable." };
1050
+ const destination = new URL(
1051
+ submitter?.getAttribute("formaction") ?? form.action,
1052
+ document.baseURI,
1053
+ );
1054
+ if (
1055
+ destination.origin !== location.origin ||
1056
+ !["http:", "https:"].includes(destination.protocol)
1057
+ )
1058
+ return { error: "Form submission must stay on this website." };
1059
+ return {
1060
+ form,
1061
+ control,
1062
+ submitter,
1063
+ signature: JSON.stringify([
1064
+ form.action,
1065
+ form.method,
1066
+ form.enctype,
1067
+ form.target,
1068
+ form.noValidate,
1069
+ submitter &&
1070
+ [
1071
+ "formaction",
1072
+ "formmethod",
1073
+ "formenctype",
1074
+ "formtarget",
1075
+ "formnovalidate",
1076
+ "name",
1077
+ "value",
1078
+ "type",
1079
+ ].map((attribute) => submitter.getAttribute(attribute)),
1080
+ ]),
1081
+ };
1082
+ }
1083
+
1084
+ submitActiveForm(result) {
1085
+ const snapshot = this.browserSnapshot;
1086
+ if (
1087
+ !this.browserActionsEnabled() ||
1088
+ !snapshot ||
1089
+ result.page_id !== snapshot.page.page_id ||
1090
+ snapshot.url !== location.href
1091
+ )
1092
+ throw new Error("The page changed. Please try the command again.");
1093
+ const original = snapshot.formState;
1094
+ const current = this.activeFormState();
1095
+ if (original?.error || current.error)
1096
+ throw new Error(original?.error || current.error);
1097
+ if (
1098
+ !original ||
1099
+ original.form !== current.form ||
1100
+ original.control !== current.control ||
1101
+ original.submitter !== current.submitter ||
1102
+ original.signature !== current.signature
1103
+ )
1104
+ throw new Error("That form changed. Please start the command again.");
1105
+ const { form, submitter } = current;
1106
+ if (
1107
+ !form.noValidate &&
1108
+ !submitter?.formNoValidate &&
1109
+ !window.HTMLFormElement.prototype.reportValidity.call(form)
1110
+ )
1111
+ throw new Error("Check the highlighted fields before submitting.");
1112
+ window.HTMLFormElement.prototype.requestSubmit.call(form, submitter);
1113
+ this.status.textContent = "Form submitted.";
1114
+ }
1115
+
1116
+ discoverBrowserControls() {
1117
+ this.browserRefs ||= new WeakMap();
1118
+ this.browserRefCounter ||= 0;
1119
+ const targets = new Map();
1120
+ const elements = [];
1121
+ const seenLinks = new Set();
1122
+ for (const target of document.querySelectorAll(
1123
+ "button, a[href], input, textarea, select, [role='button'], h1, h2, h3, h4, h5, h6",
1124
+ )) {
1125
+ const actions = this.browserActionsFor(target);
1126
+ if (
1127
+ !actions.length ||
1128
+ !this.browserControlVisible(target) ||
1129
+ target.matches(":disabled, [aria-disabled='true']")
1130
+ )
1131
+ continue;
1132
+ if (!this.browserRefs.has(target))
1133
+ this.browserRefs.set(target, `e${++this.browserRefCounter}`);
1134
+ const ref = this.browserRefs.get(target);
1135
+ const metadata = this.browserControlMetadata(target, ref);
1136
+ if (!metadata.label) continue;
1137
+ if (
1138
+ target.matches("a[href]:not([role='button'])") &&
1139
+ !["", "#"].includes(target.getAttribute("href").trim())
1140
+ ) {
1141
+ const key = JSON.stringify([
1142
+ metadata.label.toLowerCase(),
1143
+ target.href,
1144
+ ...[
1145
+ "target",
1146
+ "download",
1147
+ "data-method",
1148
+ "data-turbo-method",
1149
+ "data-turbo-frame",
1150
+ ].map((attribute) => target.getAttribute(attribute)),
1151
+ ]);
1152
+ if (seenLinks.has(key)) continue;
1153
+ seenLinks.add(key);
1154
+ }
1155
+ if (elements.length === 200)
1156
+ throw new Error(
1157
+ "This page has too many controls for browser commands.",
1158
+ );
1159
+ elements.push(metadata);
1160
+ targets.set(ref, {
1161
+ node: target,
1162
+ metadata,
1163
+ actions,
1164
+ href: target.getAttribute("href"),
1165
+ form: target.form,
1166
+ submitBehavior: this.submitBehavior(target),
1167
+ formAction: target.form?.action,
1168
+ buttonAction: target.getAttribute("formaction"),
1169
+ formMethod: target.form?.method,
1170
+ options:
1171
+ target instanceof window.HTMLSelectElement
1172
+ ? this.dropdownOptions(target).map(({ option, index }) => ({
1173
+ ref: `o${index}`,
1174
+ node: option,
1175
+ value: option.value,
1176
+ }))
1177
+ : null,
1178
+ });
1179
+ }
1180
+ const selectedRef =
1181
+ this.selectedField?.url === location.href
1182
+ ? this.browserRefs.get(this.selectedField.node)
1183
+ : null;
1184
+ return {
1185
+ page: {
1186
+ page_id: window.crypto.randomUUID(),
1187
+ elements,
1188
+ ...(targets.has(selectedRef) ? { selected_ref: selectedRef } : {}),
1189
+ },
1190
+ targets,
1191
+ formState: this.activeFormState(),
1192
+ url: location.href,
1193
+ };
1194
+ }
1195
+
1196
+ dynamicBrowserTarget(result) {
1197
+ const snapshot = this.browserSnapshot;
1198
+ const entry = snapshot?.targets.get(result.target);
1199
+ if (
1200
+ !this.browserActionsEnabled() ||
1201
+ !entry ||
1202
+ result.page_id !== snapshot.page.page_id ||
1203
+ snapshot.url !== location.href ||
1204
+ !entry.node.isConnected ||
1205
+ (result.selected &&
1206
+ (snapshot.page.selected_ref !== result.target ||
1207
+ this.selectedField?.node !== entry.node ||
1208
+ this.selectedField?.url !== location.href)) ||
1209
+ JSON.stringify(entry.metadata) !==
1210
+ JSON.stringify(
1211
+ this.browserControlMetadata(entry.node, result.target),
1212
+ ) ||
1213
+ entry.href !== entry.node.getAttribute("href") ||
1214
+ entry.form !== entry.node.form ||
1215
+ entry.submitBehavior !== this.submitBehavior(entry.node) ||
1216
+ entry.formAction !== entry.node.form?.action ||
1217
+ entry.formMethod !== entry.node.form?.method ||
1218
+ entry.buttonAction !== entry.node.getAttribute("formaction") ||
1219
+ !entry.actions.includes(result.action) ||
1220
+ !this.browserActionsFor(entry.node).includes(result.action)
1221
+ )
1222
+ throw new Error(
1223
+ "That control changed. Please start the command again.",
1224
+ );
1225
+ return entry.node;
1226
+ }
1227
+
1228
+ submitBehavior(target) {
1229
+ return JSON.stringify([
1230
+ target.type,
1231
+ target.form?.enctype,
1232
+ target.form?.target,
1233
+ target.form?.noValidate,
1234
+ ...[
1235
+ "formmethod",
1236
+ "formenctype",
1237
+ "formtarget",
1238
+ "formnovalidate",
1239
+ "data-turbo-method",
1240
+ "data-method",
1241
+ "target",
1242
+ "download",
1243
+ "data-turbo-frame",
1244
+ "data-turbo-action",
1245
+ ].map((name) => target.getAttribute(name)),
1246
+ ]);
1247
+ }
1248
+
1249
+ runBrowserAction(result) {
1250
+ if (result.action === "submit") {
1251
+ this.submitActiveForm(result);
1252
+ return;
1253
+ }
1254
+ if (result.action === "history") {
1255
+ const snapshot = this.browserSnapshot;
1256
+ if (
1257
+ !this.browserActionsEnabled() ||
1258
+ !snapshot ||
1259
+ snapshot.page.page_id !== result.page_id ||
1260
+ snapshot.url !== location.href ||
1261
+ !["back", "forward"].includes(result.direction)
1262
+ )
1263
+ throw new Error("The page changed. Please try the command again.");
1264
+ this.undoEdit = null;
1265
+ this.status.textContent = `Asked the browser to go ${result.direction}.`;
1266
+ window.history[result.direction]();
1267
+ return;
1268
+ }
1269
+ if (result.action === "scroll") {
1270
+ this.scrollPage(result);
1271
+ return;
1272
+ }
1273
+ if (
1274
+ ![
1275
+ "click",
1276
+ "fill",
1277
+ "focus",
1278
+ "select",
1279
+ "check",
1280
+ "uncheck",
1281
+ "choose",
1282
+ "clear",
1283
+ "reveal",
1284
+ ].includes(result.action)
1285
+ )
1286
+ throw new Error("This browser action is not supported.");
1287
+ let target;
1288
+ if (result.target) {
1289
+ target = this.dynamicBrowserTarget(result);
1290
+ } else {
1291
+ let targets;
1292
+ try {
1293
+ targets = document.querySelectorAll(result.selector);
1294
+ } catch {
1295
+ throw new Error("This command has an invalid element selector.");
1296
+ }
1297
+ if (targets.length !== 1)
1298
+ throw new Error(
1299
+ targets.length
1300
+ ? "More than one element matches. Use a more specific command."
1301
+ : "That element is not on this page. Open the right page first.",
1302
+ );
1303
+ target = targets[0];
1304
+ }
1305
+ if (!this.browserControlVisible(target))
1306
+ throw new Error("That element is hidden. Show it first.");
1307
+ if (target.matches(":disabled, [aria-disabled='true']"))
1308
+ throw new Error("That element is disabled.");
1309
+
1310
+ if (result.action === "reveal") {
1311
+ if (!this.browserActionsFor(target).includes("reveal"))
1312
+ throw new Error("This command must target a page heading.");
1313
+ target.scrollIntoView({
1314
+ block: "center",
1315
+ behavior: this.scrollBehavior(),
1316
+ });
1317
+ } else if (["check", "uncheck", "choose"].includes(result.action)) {
1318
+ if (!this.browserActionsFor(target).includes(result.action))
1319
+ throw new Error(
1320
+ "This command must target a checkbox or radio button.",
1321
+ );
1322
+ if (result.action === "choose") {
1323
+ this.undoEdit = null;
1324
+ if (!target.checked) target.click();
1325
+ } else {
1326
+ this.changeField(target, result.action === "check");
1327
+ }
1328
+ target.focus();
1329
+ } else if (result.action === "click") {
1330
+ if (
1331
+ !this.browserActionsFor(target).includes("click") &&
1332
+ !target.matches("input[type='checkbox'], input[type='radio']")
1333
+ )
1334
+ throw new Error("This command must target a button or local link.");
1335
+ this.undoEdit = null;
1336
+ target.click();
1337
+ } else {
1338
+ const editableInput =
1339
+ target instanceof window.HTMLInputElement &&
1340
+ [
1341
+ "text",
1342
+ "search",
1343
+ "email",
1344
+ "tel",
1345
+ "url",
1346
+ "number",
1347
+ "date",
1348
+ "time",
1349
+ ].includes(target.type);
1350
+ const textarea = target instanceof window.HTMLTextAreaElement;
1351
+ const dropdown =
1352
+ target instanceof window.HTMLSelectElement && !target.multiple;
1353
+ if (!editableInput && !textarea && !dropdown)
1354
+ throw new Error("This command must target a text or number field.");
1355
+ if (target.readOnly) throw new Error("That field is read-only.");
1356
+ if (result.action === "select") {
1357
+ const option = this.browserSnapshot?.targets
1358
+ .get(result.target)
1359
+ ?.options?.find((entry) => entry.ref === result.option);
1360
+ if (
1361
+ !dropdown ||
1362
+ !option ||
1363
+ !Array.from(target.options).includes(option.node) ||
1364
+ option.node.value !== option.value ||
1365
+ !this.dropdownOptions(target).some(
1366
+ (entry) => entry.option === option.node,
1367
+ )
1368
+ )
1369
+ throw new Error("That dropdown option changed. Please try again.");
1370
+ this.changeField(
1371
+ target,
1372
+ Array.from(target.options).indexOf(option.node),
1373
+ );
1374
+ } else if (result.action === "clear") {
1375
+ if (dropdown) throw new Error("Use select for dropdown fields.");
1376
+ this.changeField(target, "");
1377
+ } else if (result.action === "fill") {
1378
+ if (dropdown) throw new Error("Use select for dropdown fields.");
1379
+ if (typeof result.value !== "string" || result.value.length > 2000)
1380
+ throw new Error("This command has an invalid field value.");
1381
+ if (target.type === "number" && result.value !== "") {
1382
+ const probe = document.createElement("input");
1383
+ probe.type = "number";
1384
+ probe.value = result.value;
1385
+ if (probe.value === "")
1386
+ throw new Error("Enter a number, for example 500 or 12.5.");
1387
+ }
1388
+ this.changeField(
1389
+ target,
1390
+ this.normalizeFieldValue(target, result.value),
1391
+ );
1392
+ }
1393
+ target.focus();
1394
+ }
1395
+ this.highlightTarget(target);
1396
+ }
1397
+
1398
+ scrollBehavior() {
1399
+ return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
1400
+ ? "instant"
1401
+ : "smooth";
1402
+ }
1403
+
1404
+ scrollPage(result) {
1405
+ const snapshot = this.browserSnapshot;
1406
+ if (
1407
+ !this.browserActionsEnabled() ||
1408
+ !snapshot ||
1409
+ snapshot.page.page_id !== result.page_id ||
1410
+ snapshot.url !== location.href ||
1411
+ !["up", "down", "top", "bottom"].includes(result.direction)
1412
+ )
1413
+ throw new Error("The page changed. Please try the command again.");
1414
+ const behavior = this.scrollBehavior();
1415
+ if (["top", "bottom"].includes(result.direction)) {
1416
+ window.scrollTo({
1417
+ top:
1418
+ result.direction === "top"
1419
+ ? 0
1420
+ : document.scrollingElement.scrollHeight,
1421
+ behavior,
1422
+ });
1423
+ } else {
1424
+ window.scrollBy({
1425
+ top: window.innerHeight * 0.8 * (result.direction === "up" ? -1 : 1),
1426
+ behavior,
1427
+ });
1428
+ }
1429
+ }
1430
+
1431
+ highlightTarget(target) {
1432
+ this.targetHighlight?.cancel();
1433
+ if (!target.isConnected || !target.animate) return;
1434
+ const reduced = window.matchMedia?.(
1435
+ "(prefers-reduced-motion: reduce)",
1436
+ ).matches;
1437
+ this.targetHighlight = target.animate(
1438
+ [
1439
+ { outline: "3px solid #75904b", outlineOffset: "4px" },
1440
+ {
1441
+ outline: `3px solid ${reduced ? "#75904b" : "transparent"}`,
1442
+ outlineOffset: "4px",
1443
+ },
1444
+ ],
1445
+ { duration: 1200, easing: "ease-out" },
1446
+ );
1447
+ }
1448
+
1449
+ normalizeFieldValue(target, value) {
1450
+ if (!["date", "time"].includes(target.type) || value === "") return value;
1451
+ let normalized = value.trim().toLowerCase();
1452
+ if (target.type === "date" && !/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
1453
+ const months = [
1454
+ "january",
1455
+ "february",
1456
+ "march",
1457
+ "april",
1458
+ "may",
1459
+ "june",
1460
+ "july",
1461
+ "august",
1462
+ "september",
1463
+ "october",
1464
+ "november",
1465
+ "december",
1466
+ ];
1467
+ const match = normalized
1468
+ .replace(/(\d)(st|nd|rd|th)\b/g, "$1")
1469
+ .replace(/,/g, "")
1470
+ .match(/^([a-z]+) (\d{1,2})(?: (\d{4}))?$/);
1471
+ const month = match ? months.indexOf(match[1]) : -1;
1472
+ if (month < 0)
1473
+ throw new Error("Use a date like October 1, 2026 or 2026-10-01.");
1474
+ normalized = `${match[3] || new Date().getFullYear()}-${String(month + 1).padStart(2, "0")}-${match[2].padStart(2, "0")}`;
1475
+ } else if (target.type === "time") {
1476
+ const match = normalized.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/);
1477
+ if (!match) throw new Error("Use a time like 14:30 or 2:30 PM.");
1478
+ let hour = Number(match[1]);
1479
+ if (
1480
+ hour > (match[3] ? 12 : 23) ||
1481
+ (match[3] && hour < 1) ||
1482
+ Number(match[2] || 0) > 59
1483
+ )
1484
+ throw new Error("Use a time like 14:30 or 2:30 PM.");
1485
+ if (match[3]) hour = (hour % 12) + (match[3] === "pm" ? 12 : 0);
1486
+ normalized = `${String(hour).padStart(2, "0")}:${match[2] || "00"}`;
1487
+ }
1488
+ const probe = document.createElement("input");
1489
+ probe.type = target.type;
1490
+ for (const attribute of ["min", "max", "step", "value"]) {
1491
+ if (target.hasAttribute(attribute))
1492
+ probe.setAttribute(attribute, target.getAttribute(attribute));
1493
+ }
1494
+ probe.value = normalized;
1495
+ if (probe.value !== normalized || !probe.validity.valid)
1496
+ throw new Error(
1497
+ "That date or time is invalid or outside the field's allowed range or step.",
1498
+ );
1499
+ return normalized;
1500
+ }
1501
+
1502
+ fieldValue(target) {
1503
+ if (
1504
+ target instanceof window.HTMLInputElement &&
1505
+ target.type === "checkbox"
1506
+ )
1507
+ return target.checked;
1508
+ return target instanceof window.HTMLSelectElement
1509
+ ? target.selectedIndex
1510
+ : target.value;
1511
+ }
1512
+
1513
+ writeField(target, value) {
1514
+ if (
1515
+ target instanceof window.HTMLInputElement &&
1516
+ target.type === "checkbox"
1517
+ ) {
1518
+ // Native activation updates React checked tracking and emits input/change events.
1519
+ if (target.checked !== value) target.click();
1520
+ return;
1521
+ }
1522
+ const dropdown = target instanceof window.HTMLSelectElement;
1523
+ const prototype = dropdown
1524
+ ? window.HTMLSelectElement.prototype
1525
+ : target instanceof window.HTMLTextAreaElement
1526
+ ? window.HTMLTextAreaElement.prototype
1527
+ : window.HTMLInputElement.prototype;
1528
+ // Native setters let React's value tracker observe the input event.
1529
+ Object.getOwnPropertyDescriptor(
1530
+ prototype,
1531
+ dropdown ? "selectedIndex" : "value",
1532
+ ).set.call(target, value);
1533
+ target.dispatchEvent(new window.Event("input", { bubbles: true }));
1534
+ target.dispatchEvent(new window.Event("change", { bubbles: true }));
1535
+ }
1536
+
1537
+ changeField(target, value) {
1538
+ const before = this.fieldValue(target);
1539
+ if (before === value) return;
1540
+ this.undoEdit = {
1541
+ node: target,
1542
+ before,
1543
+ after: value,
1544
+ url: location.href,
1545
+ form: target.form,
1546
+ metadata: JSON.stringify(this.browserControlMetadata(target, "e0")),
1547
+ options:
1548
+ target instanceof window.HTMLSelectElement
1549
+ ? Array.from(target.options, (node) => ({
1550
+ node,
1551
+ value: node.value,
1552
+ }))
1553
+ : null,
1554
+ };
1555
+ this.writeField(target, value);
1556
+ }
1557
+
1558
+ canUndo() {
1559
+ const edit = this.undoEdit;
1560
+ if (
1561
+ !edit ||
1562
+ edit.url !== location.href ||
1563
+ !edit.node.isConnected ||
1564
+ edit.node.form !== edit.form ||
1565
+ this.fieldValue(edit.node) !== edit.after ||
1566
+ !this.browserControlVisible(edit.node) ||
1567
+ edit.node.matches(":disabled, [readonly], [aria-disabled='true']")
1568
+ )
1569
+ return false;
1570
+ try {
1571
+ return (
1572
+ edit.metadata ===
1573
+ JSON.stringify(this.browserControlMetadata(edit.node, "e0")) &&
1574
+ (!edit.options ||
1575
+ edit.options.every(
1576
+ (option, index) =>
1577
+ edit.node.options[index] === option.node &&
1578
+ option.node.value === option.value,
1579
+ ))
1580
+ );
1581
+ } catch {
1582
+ return false;
1583
+ }
1584
+ }
1585
+
1586
+ undoLastEdit() {
1587
+ if (this.busy) return;
1588
+ const edit = this.undoEdit;
1589
+ const valid = this.canUndo();
1590
+ this.undoEdit = null;
1591
+ this.continuation = null;
1592
+ this.choices.replaceChildren();
1593
+ if (valid) {
1594
+ this.writeField(edit.node, edit.before);
1595
+ edit.node.focus();
1596
+ this.highlightTarget(edit.node);
1597
+ this.input.value = "";
1598
+ }
1599
+ this.status.textContent = valid
1600
+ ? "Undone."
1601
+ : "No unsaved field edit to undo.";
1602
+ this.touch();
1603
+ this.refreshPageTools();
1604
+ }
1605
+
1606
+ refreshPageTools() {
1607
+ if (!this.shadowRoot || this.panel.hidden) return;
1608
+ let snapshot;
1609
+ try {
1610
+ if (this.browserActionsEnabled())
1611
+ snapshot = this.discoverBrowserControls();
1612
+ } catch (error) {
1613
+ this.status.textContent = error.message;
1614
+ }
1615
+ const selected = snapshot?.page.elements.find(
1616
+ (target) => target.ref === snapshot.page.selected_ref,
1617
+ );
1618
+ this.shadowRoot.querySelector(".selected-field").textContent = selected
1619
+ ? `Editing: ${selected.label}`
1620
+ : "";
1621
+ if (!this.canUndo()) this.undoEdit = null;
1622
+ const undo = this.shadowRoot.querySelector(".undo");
1623
+ undo.hidden = !this.undoEdit;
1624
+ undo.disabled = !!this.busy;
1625
+ this.shadowRoot.querySelector(".field-tools").hidden =
1626
+ !selected && !this.undoEdit;
1627
+ const suggestions = this.shadowRoot.querySelector(".suggestions");
1628
+ suggestions.replaceChildren();
1629
+ suggestions.hidden =
1630
+ !!this.continuation || !!this.busy || !this.help.hidden || !snapshot;
1631
+ if (suggestions.hidden) return snapshot;
1632
+ const candidates = [];
1633
+ const add = (target, action) => {
1634
+ if (
1635
+ target &&
1636
+ !candidates.some((candidate) => candidate.target.ref === target.ref)
1637
+ )
1638
+ candidates.push({ target, action });
1639
+ };
1640
+ if (selected) add(selected, "enter");
1641
+ const local = snapshot.page.elements.filter(
1642
+ (target) =>
1643
+ !snapshot.targets.get(target.ref).node.closest("nav, aside, header"),
1644
+ );
1645
+ add(
1646
+ local.find((target) =>
1647
+ snapshot.targets.get(target.ref).actions.includes("fill"),
1648
+ ),
1649
+ "fill",
1650
+ );
1651
+ add(
1652
+ local.find((target) =>
1653
+ snapshot.targets.get(target.ref).actions.includes("select"),
1654
+ ),
1655
+ "select",
1656
+ );
1657
+ candidates.splice(2);
1658
+ const clicks = local.filter(
1659
+ (target) =>
1660
+ snapshot.targets.get(target.ref).actions.includes("click") &&
1661
+ !/\b(delete|remove|destroy|refund|reset|sign\s*out|log\s*out)\b/i.test(
1662
+ target.label,
1663
+ ),
1664
+ );
1665
+ const save = clicks.find((target) => {
1666
+ const node = snapshot.targets.get(target.ref).node;
1667
+ return (
1668
+ node.form &&
1669
+ node.matches(
1670
+ "button[type='submit'], button:not([type]), input[type='submit']",
1671
+ )
1672
+ );
1673
+ });
1674
+ add(save, "click");
1675
+ if (!candidates.length)
1676
+ clicks.slice(0, 3).forEach((target) => add(target, "click"));
1677
+ for (const { target, action } of candidates.slice(0, 3)) {
1678
+ this.addChoice(
1679
+ `${action[0].toUpperCase() + action.slice(1)} ${target.label}`,
1680
+ () => {
1681
+ this.continuation = null;
1682
+ this.submit("", `voice_control_browser_${action}_${target.ref}`);
1683
+ },
1684
+ suggestions,
1685
+ );
1686
+ }
1687
+ suggestions.hidden = !suggestions.childElementCount;
1688
+ return snapshot;
1689
+ }
1690
+
1691
+ addChoice(label, callback, container = this.choices) {
1692
+ const button = document.createElement("button");
1693
+ button.type = "button";
1694
+ button.textContent = label;
1695
+ button.addEventListener("click", callback);
1696
+ container.append(button);
1697
+ return button;
1698
+ }
1699
+
1700
+ navigateHelp(event) {
1701
+ if (this.help.hidden || !this.help.contains(event.target)) return;
1702
+ const search = this.shadowRoot.querySelector("#search");
1703
+ const buttons = Array.from(this.help.querySelectorAll(".catalog button"));
1704
+ if (!buttons.length) return;
1705
+ if (event.key === "Enter" && event.target === search) {
1706
+ event.preventDefault();
1707
+ buttons[0].click();
1708
+ } else if (["ArrowDown", "ArrowUp"].includes(event.key)) {
1709
+ event.preventDefault();
1710
+ const index = buttons.indexOf(event.target);
1711
+ if (index === 0 && event.key === "ArrowUp") search.focus();
1712
+ else {
1713
+ const next =
1714
+ index < 0
1715
+ ? event.key === "ArrowDown"
1716
+ ? 0
1717
+ : buttons.length - 1
1718
+ : (index +
1719
+ (event.key === "ArrowDown" ? 1 : -1) +
1720
+ buttons.length) %
1721
+ buttons.length;
1722
+ buttons[next].focus();
1723
+ buttons[next].scrollIntoView({ block: "nearest" });
1724
+ }
1725
+ }
1726
+ }
1727
+
1728
+ async toggleHelp() {
1729
+ this.help.hidden = !this.help.hidden;
1730
+ this.refreshPageTools();
1731
+ if (this.help.hidden) return;
1732
+ this.renderCatalog();
1733
+ this.shadowRoot.querySelector("#search").focus();
1734
+ await this.loadCatalog();
1735
+ }
1736
+
1737
+ async loadCatalog() {
1738
+ const path = location.pathname;
1739
+ const version = this.catalogVersion = (this.catalogVersion || 0) + 1;
1740
+ try {
1741
+ const result = await this.api("commands");
1742
+ if (path !== location.pathname || version !== this.catalogVersion) return;
1743
+ this.catalog = result.commands;
1744
+ this.renderCatalog();
1745
+ } catch (error) {
1746
+ if (path === location.pathname && version === this.catalogVersion)
1747
+ this.status.textContent = error.message;
1748
+ }
1749
+ }
1750
+
1751
+ renderCatalog(snapshot) {
1752
+ const focusedKey = this.shadowRoot.activeElement?.dataset.commandKey;
1753
+ const commands = [...(this.catalog || [])];
1754
+ try {
1755
+ if (this.browserActionsEnabled()) {
1756
+ snapshot ||= this.discoverBrowserControls();
1757
+ for (const target of snapshot.page.elements) {
1758
+ if (target.ref === snapshot.page.selected_ref) {
1759
+ commands.push({
1760
+ key: `voice_control_browser_enter_${target.ref}`,
1761
+ group: "On this page",
1762
+ description: `Enter into selected field (${target.label})`,
1763
+ examples: ["enter <value>"],
1764
+ });
1765
+ if (snapshot.targets.get(target.ref).actions.includes("clear")) {
1766
+ commands.push({
1767
+ key: `voice_control_browser_clear_selected_${target.ref}`,
1768
+ group: "On this page",
1769
+ description: `Clear selected field (${target.label})`,
1770
+ examples: ["clear this field"],
1771
+ });
1772
+ }
1773
+ }
1774
+ for (const action of snapshot.targets.get(target.ref).actions) {
1775
+ commands.push({
1776
+ key: `voice_control_browser_${action}_${target.ref}`,
1777
+ group: "On this page",
1778
+ description:
1779
+ action === "reveal"
1780
+ ? `Show ${target.label} section`
1781
+ : `${action[0].toUpperCase() + action.slice(1)} ${target.label}`,
1782
+ examples: [],
1783
+ });
1784
+ }
1785
+ }
1786
+ for (const direction of ["up", "down", "top", "bottom"]) {
1787
+ commands.push({
1788
+ key: `voice_control_browser_scroll_${direction}`,
1789
+ group: "On this page",
1790
+ description: `Scroll ${direction}`,
1791
+ examples: [],
1792
+ });
1793
+ }
1794
+ for (const direction of ["back", "forward"]) {
1795
+ commands.push({
1796
+ key: `voice_control_browser_history_${direction}`,
1797
+ group: "On this page",
1798
+ description: `Go ${direction}`,
1799
+ examples: [],
1800
+ });
1801
+ }
1802
+ commands.push({
1803
+ key: "voice_control_browser_submit",
1804
+ group: "On this page",
1805
+ description: "Submit active form",
1806
+ examples: ["submit", "submit this form"],
1807
+ });
1808
+ }
1809
+ } catch (error) {
1810
+ this.status.textContent = error.message;
1811
+ }
1812
+ const container = this.shadowRoot.querySelector(".catalog");
1813
+ const query = this.shadowRoot
1814
+ .querySelector("#search")
1815
+ .value.toLowerCase();
1816
+ container.replaceChildren();
1817
+ let group;
1818
+ for (const command of commands) {
1819
+ if (!this.matchesHelp(command, query)) continue;
1820
+ if (group !== command.group) {
1821
+ group = command.group;
1822
+ const heading = document.createElement("h3");
1823
+ heading.textContent = group;
1824
+ container.append(heading);
1825
+ }
1826
+ const button = this.addChoice(
1827
+ command.description,
1828
+ () => {
1829
+ this.continuation = null;
1830
+ this.help.hidden = true;
1831
+ this.submit("", command.key);
1832
+ },
1833
+ container,
1834
+ );
1835
+ button.dataset.commandKey = command.key;
1836
+ if (command.examples.length) {
1837
+ const example = document.createElement("small");
1838
+ example.textContent = command.examples.join(" · ");
1839
+ container.append(example);
1840
+ }
1841
+ }
1842
+ if (!container.childElementCount)
1843
+ container.textContent = "No matching commands.";
1844
+ if (focusedKey) {
1845
+ const replacement = Array.from(
1846
+ container.querySelectorAll("button"),
1847
+ ).find((button) => button.dataset.commandKey === focusedKey);
1848
+ (replacement || this.shadowRoot.querySelector("#search")).focus({
1849
+ preventScroll: true,
1850
+ });
1851
+ }
1852
+ }
1853
+
1854
+ matchesHelp(command, query) {
1855
+ const normalize = (value) =>
1856
+ value.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase();
1857
+ const text = normalize(
1858
+ [
1859
+ command.description,
1860
+ command.group,
1861
+ ...(command.aliases || []),
1862
+ ...command.examples,
1863
+ ].join(" "),
1864
+ );
1865
+ const terms =
1866
+ normalize(query.slice(0, 160)).match(/[\p{L}\p{N}]+/gu) || [];
1867
+ const words = text.match(/[\p{L}\p{N}]+/gu) || [];
1868
+ return terms.every(
1869
+ (term) =>
1870
+ text.includes(term) ||
1871
+ (term.length >= 4 &&
1872
+ term.length <= 64 &&
1873
+ words.some((word) => this.closeSpelling(term, word))),
1874
+ );
1875
+ }
1876
+
1877
+ closeSpelling(left, right) {
1878
+ const limit = left.length >= 7 ? 2 : 1;
1879
+ if (right.length > 64 || Math.abs(left.length - right.length) > limit)
1880
+ return false;
1881
+ let previous = Array.from(
1882
+ { length: right.length + 1 },
1883
+ (_, index) => index,
1884
+ );
1885
+ let beforePrevious;
1886
+ for (let i = 1; i <= left.length; i++) {
1887
+ const row = [i];
1888
+ for (let j = 1; j <= right.length; j++) {
1889
+ row[j] = Math.min(
1890
+ row[j - 1] + 1,
1891
+ previous[j] + 1,
1892
+ previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1),
1893
+ );
1894
+ if (
1895
+ i > 1 &&
1896
+ j > 1 &&
1897
+ left[i - 1] === right[j - 2] &&
1898
+ left[i - 2] === right[j - 1]
1899
+ )
1900
+ row[j] = Math.min(row[j], beforePrevious[j - 2] + 1);
1901
+ }
1902
+ if (Math.min(...row) > limit) return false;
1903
+ beforePrevious = previous;
1904
+ previous = row;
1905
+ }
1906
+ return previous[right.length] <= limit;
1907
+ }
1908
+ }
1909
+ customElements.define("voice-control-widget", VoiceControlWidget);
1910
+ })();