fix(frontend): make overlays accessible and wake word opt-in

This commit is contained in:
2026-10-07 09:32:53 -03:00
parent 0c57f7c76d
commit a56e1082e5
12 changed files with 285 additions and 138 deletions
+4
View File
@@ -9,6 +9,10 @@ the implementation at the recorded date; current operating instructions live in
### Fixed
- Use native dialogs for startup and mobile reports, restore focus on dismissal,
and make duplicate transcript bubbles inert. Require explicit wake-word opt-in,
show when it is listening, and turn it off when the conversation stops.
- Isolate microphone meter subscriptions from the application tree. Apply draft settings only
on Save, preserve explicitly live noise calibration, and persist/send only relevant changes.
+39 -95
View File
@@ -1,13 +1,15 @@
import { BookOpen, ChartNoAxesColumn, ChevronRight, Info, PanelLeft, Settings, Wrench, X } from "lucide-react";
import { BookOpen, Info, PanelLeft, Settings, Wrench, X } from "lucide-react";
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { Bubbles } from "./components/Bubbles";
import { Conversation } from "./components/Conversation";
import { Dialog } from "./components/Dialog";
import { ReportsPanel } from "./components/ReportsPanel";
import { AboutDialog, ProfileDialog, RagDialog, SettingsDialog, ToolsDialog } from "./components/SettingsDialogs";
import { VoiceStage } from "./components/VoiceStage";
import { useWakeWord } from "./hooks/use-wake-word";
import { WakeWordControl } from "./components/WakeWordControl";
import { useMediaQuery } from "./hooks/use-media-query";
import { initials, profileName } from "./lib/context";
import { reportSuggestions } from "./lib/reports";
import { isDebug } from "./lib/settings";
import { ACTIVE_STATES, VoiceController } from "./realtime/controller";
@@ -17,6 +19,7 @@ export default function App() {
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
const [modal, setModal] = useState<Modal>(null);
const [reportsOpen, setReportsOpen] = useState(false);
const desktop = useMediaQuery("(min-width: 1024px)");
const debug = isDebug();
const white = state.config.theme === "white";
const name = profileName(state.profile);
@@ -51,21 +54,6 @@ export default function App() {
clearTimeout(timer);
};
}, [ready, controller]);
useWakeWord(ready && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock, controller);
useEffect(() => {
if (!reportsOpen) {
return;
}
const escape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setReportsOpen(false);
}
};
window.addEventListener("keydown", escape);
return () => {
window.removeEventListener("keydown", escape);
};
}, [reportsOpen]);
const close = () => {
setModal(null);
};
@@ -98,20 +86,36 @@ export default function App() {
return (
<>
{state.unlock && (
<Dialog
title="Listo para conversar"
onClose={() => {
controller.dismissUnlock();
}}
>
<p className="tools-intro">
Activa el micrófono para hablar con Vocero. Puedes detener la conversación en cualquier momento.
</p>
<footer className="modal-footer">
<button
className="talk-unlocker"
className="btn secondary"
type="button"
onClick={() => {
controller.dismissUnlock();
}}
>
Ahora no
</button>
<button
className="btn primary"
type="button"
onClick={() => {
void controller.start();
}}
aria-label="Empezar a hablar"
>
<span className="talk-unlocker-hint">
<span className="talk-unlocker-spinner" aria-hidden="true" />
<span className="talk-unlocker-title">Preparando Vocero…</span>
<span className="talk-unlocker-sub">Toca en cualquier lugar para empezar a hablar</span>
</span>
Empezar a hablar
</button>
</footer>
</Dialog>
)}
<div id="app" className={hasReports ? "has-reports" : ""}>
<header className="topbar">
@@ -141,7 +145,7 @@ export default function App() {
{initials(name)}
</button>
)}
{hasReports && (
{hasReports && !desktop && (
<button
id="reports-sidebar-toggle"
className="icon-btn"
@@ -209,27 +213,6 @@ export default function App() {
Enviar comentarios
</a>
</nav>
{white && (
<div className="status-card" aria-label="Estado de la sesión">
<div className="status-card-icon" aria-hidden="true">
<ChartNoAxesColumn size={24} />
</div>
<div className="status-card-text">
<h2 className="status-card-title">Listo para conversar</h2>
<p className="status-card-sub">Informes, análisis, datos y mucho más.</p>
</div>
<button
className="status-card-arrow icon-btn"
type="button"
aria-label="Ver más detalles"
onClick={() => {
setModal("about");
}}
>
<ChevronRight size={22} />
</button>
</div>
)}
{state.error && (
<div className="app-error" role="alert">
<span>{state.error}</span>
@@ -246,60 +229,21 @@ export default function App() {
</div>
)}
<VoiceStage state={state} controller={controller} />
{ready && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock && !reportsOpen && (
<WakeWordControl enabled={state.wakeWordEnabled} controller={controller} />
)}
<Conversation messages={state.messages} userName={name || "Usuario"} status={state.status} white={white} />
</div>
{!white && ACTIVE_STATES.has(state.status) && <Bubbles messages={state.messages} userName={name || "Usuario"} />}
{hasReports && (
<aside
id="reports-sidebar"
className={`reports-sidebar ${reportsOpen ? "open" : ""}`}
aria-label="Reportes programados"
>
<button
className="reports-sidebar-backdrop"
type="button"
aria-label="Cerrar panel de reportes"
tabIndex={reportsOpen ? 0 : -1}
onClick={() => {
{hasReports && state.reports && (
<ReportsPanel
reports={state.reports}
desktop={desktop}
open={reportsOpen}
onClose={() => {
setReportsOpen(false);
}}
/>
<div className="reports-sidebar-inner">
<header className="reports-sidebar-header">
<h2>Reportes</h2>
<button
id="reports-sidebar-close"
className="icon-btn"
type="button"
aria-label="Cerrar reportes"
onClick={() => {
setReportsOpen(false);
}}
>
<X size={18} />
</button>
</header>
<div className="reports-hint">
<p>{state.reports?.count} reportes disponibles</p>
<ul>
{state.reports?.hints.map((hint, index) => (
<li key={index}>{hint}</li>
))}
</ul>
{state.reports?.note && <p>{state.reports.note}</p>}
</div>
{state.reports && (
<div className="reports-suggestions">
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
<ul className="reports-suggestions-list">
{reportSuggestions(state.reports).map((suggestion) => (
<li key={suggestion}>{suggestion}</li>
))}
</ul>
</div>
)}
</div>
</aside>
)}
{modal === "settings" && <SettingsDialog state={state} controller={controller} onClose={close} />}
{modal === "tools" && <ToolsDialog state={state} controller={controller} onClose={close} />}
+2 -2
View File
@@ -15,7 +15,7 @@ function Bubble({ message, userName }: { message: Message; userName: string }) {
clearTimeout(timer);
};
}, [message.text, message.partial]);
if (expiredText === message.text) {
if (!message.partial && expiredText === message.text) {
return null;
}
return (
@@ -36,7 +36,7 @@ function Bubble({ message, userName }: { message: Message; userName: string }) {
}
export const Bubbles = memo(function Bubbles({ messages, userName }: { messages: Message[]; userName: string }) {
return (
<div className="bubble-stack" aria-hidden="true">
<div className="bubble-stack" aria-hidden="true" inert>
{messages.slice(-3).map((message) => (
<Bubble key={message.id} message={message} userName={userName} />
))}
+6 -2
View File
@@ -1,5 +1,5 @@
import { X } from "lucide-react";
import { useEffect, useId, useRef, type ReactNode } from "react";
import { useLayoutEffect, useId, useRef, type ReactNode } from "react";
interface Props {
title: string;
@@ -10,11 +10,15 @@ interface Props {
export function Dialog({ title, children, onClose, className = "" }: Props) {
const ref = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
useLayoutEffect(() => {
const dialog = ref.current;
const opener = document.activeElement;
dialog?.showModal();
return () => {
dialog?.close();
if (opener instanceof HTMLElement && opener.isConnected) {
opener.focus({ preventScroll: true });
}
};
}, []);
return (
@@ -0,0 +1,54 @@
import type { Reports } from "../lib/contracts";
import { reportSuggestions } from "../lib/reports";
import { Dialog } from "./Dialog";
function ReportContent({ reports }: { reports: Reports }) {
return (
<div className="reports-content">
<div className="reports-hint">
<p>{reports.count} reportes disponibles</p>
<ul>
{reports.hints.map((hint, index) => (
<li key={index}>{hint}</li>
))}
</ul>
{reports.note && <p>{reports.note}</p>}
</div>
<div className="reports-suggestions">
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
<ul className="reports-suggestions-list">
{reportSuggestions(reports).map((suggestion) => (
<li key={suggestion}>{suggestion}</li>
))}
</ul>
</div>
</div>
);
}
export function ReportsPanel({
reports,
desktop,
open,
onClose,
}: {
reports: Reports;
desktop: boolean;
open: boolean;
onClose: () => void;
}) {
if (desktop) {
return (
<aside id="reports-sidebar" className="reports-panel" aria-label="Reportes programados">
<h2>Reportes</h2>
<ReportContent reports={reports} />
</aside>
);
}
return open ? (
<Dialog title="Reportes" className="reports-drawer" onClose={onClose}>
<div id="reports-sidebar">
<ReportContent reports={reports} />
</div>
</Dialog>
) : null;
}
@@ -0,0 +1,49 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useSyncExternalStore } from "react";
import { afterEach, expect, it, vi } from "vitest";
import { VoiceController } from "../realtime/controller";
import { WakeWordControl } from "./WakeWordControl";
class Recognition {
static instances: Recognition[] = [];
lang = "";
continuous = false;
interimResults = false;
onstart: (() => void) | null = null;
onend: (() => void) | null = null;
onerror = null;
onresult = null;
start = vi.fn(() => {
this.onstart?.();
});
abort = vi.fn();
constructor() {
Recognition.instances.push(this);
}
}
function Control({ controller }: { controller: VoiceController }) {
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
return <WakeWordControl enabled={state.wakeWordEnabled} controller={controller} />;
}
afterEach(() => {
vi.unstubAllGlobals();
});
it("requires opt-in, displays actual listening, and releases recognition on Stop", async () => {
Recognition.instances = [];
vi.stubGlobal("SpeechRecognition", Recognition);
const controller = new VoiceController();
const user = userEvent.setup();
render(<Control controller={controller} />);
expect(Recognition.instances).toHaveLength(0);
await user.click(screen.getByRole("checkbox"));
expect(Recognition.instances).toHaveLength(1);
expect(screen.getByRole("status")).toHaveTextContent("Micrófono activo");
await act(async () => {
await controller.stop();
});
expect(Recognition.instances[0]?.abort).toHaveBeenCalledOnce();
expect(screen.getByRole("checkbox")).not.toBeChecked();
expect(Recognition.instances).toHaveLength(1);
});
@@ -0,0 +1,32 @@
import { useWakeWord } from "../hooks/use-wake-word";
import type { VoiceController } from "../realtime/controller";
export function WakeWordControl({ enabled, controller }: { enabled: boolean; controller: VoiceController }) {
const status = useWakeWord(enabled, controller);
if (status === "unsupported") {
return null;
}
return (
<div className="wake-word-control">
<label>
<input
type="checkbox"
checked={enabled}
onChange={(event) => {
controller.setWakeWordEnabled(event.target.checked);
}}
/>
Activar con «hola vocero»
</label>
<span role="status">
{status === "listening"
? "Micrófono activo: escuchando la frase de activación."
: status === "blocked"
? "No se pudo activar. Revisa el permiso del micrófono y vuelve a marcar la opción."
: status === "starting"
? "Preparando activación por voz…"
: "Opcional. Se desactiva al detener la conversación."}
</span>
</div>
);
}
@@ -0,0 +1,15 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
export function useMediaQuery(query: string) {
const media = useMemo(() => window.matchMedia(query), [query]);
const subscribe = useCallback(
(listener: () => void) => {
media.addEventListener("change", listener);
return () => {
media.removeEventListener("change", listener);
};
},
[media],
);
return useSyncExternalStore(subscribe, () => media.matches);
}
+13 -2
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import type { VoiceController } from "../realtime/controller";
@@ -9,6 +9,7 @@ interface Recognition {
onresult: ((event: { resultIndex: number; results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null;
onerror: ((event: { error: string }) => void) | null;
onend: (() => void) | null;
onstart: (() => void) | null;
start: () => void;
abort: () => void;
}
@@ -20,6 +21,8 @@ declare global {
}
/** Optional browser wake word; never owns the microphone during a voice session. */
export function useWakeWord(enabled: boolean, controller: VoiceController) {
const [status, setStatus] = useState<"starting" | "listening" | "blocked">("starting");
const supported = !!(window.SpeechRecognition ?? window.webkitSpeechRecognition);
useEffect(() => {
const Constructor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
if (!enabled || !Constructor) {
@@ -36,10 +39,14 @@ export function useWakeWord(enabled: boolean, controller: VoiceController) {
try {
recognition.start();
} catch {
/* A browser may require a click first. */
stopped = true;
setStatus("blocked");
}
}
};
recognition.onstart = () => {
setStatus("listening");
};
recognition.onresult = (event) => {
for (let index = event.resultIndex; index < event.results.length; index++) {
const text =
@@ -58,10 +65,12 @@ export function useWakeWord(enabled: boolean, controller: VoiceController) {
recognition.onerror = (event) => {
if (["not-allowed", "service-not-allowed", "audio-capture"].includes(event.error)) {
stopped = true;
setStatus("blocked");
}
};
recognition.onend = () => {
if (!stopped) {
setStatus("starting");
timer = setTimeout(start, 1000);
}
};
@@ -72,7 +81,9 @@ export function useWakeWord(enabled: boolean, controller: VoiceController) {
recognition.onresult = null;
recognition.onerror = null;
recognition.onend = null;
recognition.onstart = null;
recognition.abort();
};
}, [enabled, controller]);
return !supported ? "unsupported" : !enabled ? "off" : status;
}
@@ -57,6 +57,7 @@ export interface AppState {
queuePosition: number;
joinDeadline: number;
unlock: boolean;
wakeWordEnabled: boolean;
}
/** Observable application state; React owns rendering, this object owns session lifetime. */
@@ -103,6 +104,7 @@ export class VoiceController {
queuePosition: 0,
joinDeadline: 0,
unlock: false,
wakeWordEnabled: false,
};
}
getSnapshot = () => this.state;
@@ -261,6 +263,12 @@ export class VoiceController {
dismissError() {
this.update({ error: "" });
}
dismissUnlock() {
this.update({ unlock: false });
}
setWakeWordEnabled(wakeWordEnabled: boolean) {
this.update({ wakeWordEnabled });
}
offerAutoStart() {
if (this.autoStarted || this.client || !this.state.configReady || this.state.contextLoading) {
return;
@@ -608,6 +616,7 @@ export class VoiceController {
this.updateLevel(0);
this.update({
status,
wakeWordEnabled: false,
muted: false,
joinDeadline: 0,
unlock: false,
+44 -29
View File
@@ -65,16 +65,6 @@
font-size: 13px;
background: var(--bg-elev);
}
.talk-unlocker {
border: 0;
width: 100%;
color: inherit;
font: inherit;
cursor: pointer;
}
.talk-unlocker-hint > span {
display: block;
}
.agent-tool-item {
padding: 12px 0;
border-bottom: 1px solid var(--border);
@@ -88,28 +78,53 @@
.queue-actions .circle-subcaption {
flex-basis: 100%;
}
.reports-sidebar-backdrop {
border: 0;
.reports-panel {
position: fixed;
inset: 0 auto 0 0;
width: 320px;
overflow-y: auto;
box-sizing: border-box;
padding: 20px;
background: var(--bg-elev);
border-right: 1px solid var(--border);
z-index: 3;
}
.reports-panel h2 {
margin: 0 0 16px;
font-size: 18px;
}
.reports-drawer.modal {
margin: 0;
width: min(360px, 92vw);
max-height: 100dvh;
height: 100dvh;
border-radius: 0;
}
.reports-content .reports-hint {
margin: 0;
max-height: none;
padding: 12px;
}
.wake-word-control {
display: grid;
gap: 4px;
padding: 0 16px 12px;
text-align: center;
font-size: 13px;
}
.wake-word-control label {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
visibility: hidden;
}
.reports-sidebar-inner {
visibility: hidden;
}
.reports-sidebar {
pointer-events: none;
}
.reports-sidebar.open .reports-sidebar-inner,
.reports-sidebar.open .reports-sidebar-backdrop {
visibility: visible;
}
@media (min-width: 1024px) {
#reports-sidebar-close {
display: none;
}
.reports-sidebar-inner {
visibility: visible;
.wake-word-control span {
font-size: 12px;
color: var(--text-dim);
}
html[data-theme="white"] .wake-word-control span {
color: #64748b;
}
.bubble-body {
max-height: 240px;
+10
View File
@@ -3,6 +3,16 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (media: string) => ({
media,
matches: false,
addEventListener: () => undefined,
removeEventListener: () => undefined,
}),
});
// jsdom has no native modal top layer; real focus/inert behavior is checked in Chromium.
Object.defineProperties(HTMLDialogElement.prototype, {
showModal: {