fix(frontend): make overlays accessible and wake word opt-in
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+47
-103
@@ -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 && (
|
||||
<button
|
||||
className="talk-unlocker"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void controller.start();
|
||||
<Dialog
|
||||
title="Listo para conversar"
|
||||
onClose={() => {
|
||||
controller.dismissUnlock();
|
||||
}}
|
||||
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>
|
||||
</button>
|
||||
<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="btn secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
controller.dismissUnlock();
|
||||
}}
|
||||
>
|
||||
Ahora no
|
||||
</button>
|
||||
<button
|
||||
className="btn primary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void controller.start();
|
||||
}}
|
||||
>
|
||||
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={() => {
|
||||
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>
|
||||
{hasReports && state.reports && (
|
||||
<ReportsPanel
|
||||
reports={state.reports}
|
||||
desktop={desktop}
|
||||
open={reportsOpen}
|
||||
onClose={() => {
|
||||
setReportsOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === "settings" && <SettingsDialog state={state} controller={controller} onClose={close} />}
|
||||
{modal === "tools" && <ToolsDialog state={state} controller={controller} onClose={close} />}
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
.wake-word-control span {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
html[data-theme="white"] .wake-word-control span {
|
||||
color: #64748b;
|
||||
}
|
||||
.bubble-body {
|
||||
max-height: 240px;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user