chore(frontend): validate migration and document development

This commit is contained in:
2026-10-06 16:31:39 -03:00
parent 36efd5e13c
commit 7285d22e6c
12 changed files with 511 additions and 21 deletions
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
+111 -9
View File
@@ -1,14 +1,116 @@
# Vocero React frontend
React, TypeScript, Tailwind CSS, and Vite client for the existing `s2s-client` FastAPI backend.
React 19, TypeScript, Tailwind CSS 4, and Vite 8 client for the existing
`s2s-client` FastAPI backend. This is a client-side application with React-owned
rendering and state, including the realtime WebSocket and audio integration.
Requires Node.js 22.18+ or 24+ and npm. Run `npm ci`, then `npm run dev`.
The frontend listens on `http://127.0.0.1:5173` and proxies backend requests to
`http://127.0.0.1:8420`. Copy `.env.example` to `.env.local` to override that target.
## Run during development
`npm run check` runs formatting, strict ESLint, TypeScript, and Vitest checks.
`npm run build` typechecks and produces `dist/`. `npm run format` and `npm run lint:fix`
apply mechanical formatting and lint fixes.
Use Node.js 22.18 or later on the 22.x line, or Node.js 24+. Install dependencies
with npm; the lockfile is committed.
The repository's `start_ui.sh` still serves the original UI. This project is started
separately during the frontend migration.
1. Start the existing backend using `./start_ui.sh` from your configured S2S
checkout, or reuse an already-running instance. It normally listens on port
`8420` and continues serving the original static UI there. Its Python
environment, `.env`, speech service, and upstream services must be configured
as before.
2. In a second terminal, start this frontend:
```sh
cd frontend-s2s
npm ci
npm run dev
```
3. Open `http://127.0.0.1:5173`. Add `?debug=true` to access settings, tools,
and custom RAG context. Existing `phoneNumber` and `episode_id` query
parameters are supported.
Vite serves React and Tailwind with hot updates. It proxies `/api`,
`/v1/realtime` (including WebSockets), OAuth, and backend documentation routes
to FastAPI. The browser uses one origin on port `5173`, so there is no separate
API origin or development CORS setup. The proxy preserves the incoming Host
header because FastAPI uses it to construct the speech WebSocket URL.
For a different backend, copy `.env.example` to `.env.local` and set
`S2S_BACKEND_URL`. This is a **Vite server** setting. Runtime UI flags, voices,
reasoning mode, prompts, and the speech URL still come from FastAPI's
`/api/config`. A backend explicitly configured with `SPEECH_TO_SPEECH_URL`
continues to return that direct speech endpoint.
The Vite configuration reads only this project's environment files. Do not
copy the repository's backend secrets into this directory or into `VITE_*`
variables, which are exposed to browser code.
For access through a development reverse proxy, set the exact hostname in
`S2S_DEV_ALLOWED_HOSTS`. Use `npm run dev -- --host 0.0.0.0` only when Vite must
listen beyond localhost. The browser needs HTTPS for microphone access outside
localhost, and that proxy must forward WebSocket upgrades for both speech and
Vite HMR. Wiring the staging entrypoint and launcher is a separate step:
**this port does not change `start_ui.sh`.**
Component and CSS changes use Fast Refresh. Editing the session controller or
audio modules can require restarting a conversation or a page reload. Teardown
closes microphone tracks, worklets, sockets, queue tickets, timers, and pending
requests on session stop or page exit.
## Checks and commands
The scripts and code style follow `frontend-admin` conventions, with Vite and
React plugins replacing Next.js-specific tooling. Executable configuration
files use TypeScript; TypeScript's own project settings use JSON.
| Command | Purpose |
| -------------------- | ----------------------------------------------------------- |
| `npm run dev` | Vite development server on port 5173, with a fixed port |
| `npm run check` | Formatting, strict typed ESLint, type checking, and tests |
| `npm run lint:fix` | Apply ESLint fixes |
| `npm run format` | Format source and sort Tailwind classes |
| `npm run typecheck` | Check application, tests, audio worklets, and configuration |
| `npm test` | Run Vitest and React Testing Library regression tests |
| `npm run test:watch` | Watch tests while editing |
| `npm run build` | Typecheck and compile the app and worklets into `dist/` |
| `npm run preview` | Inspect the compiled output locally |
No build step is needed before `npm run dev`. `dist/` is generated and ignored
by Git. Build output is available for later serving integration; this change
does not mount it in FastAPI or change the staging launch process.
Tests cover session initialization, response sequencing, cumulative transcripts,
interruptions, queue cancellation, microphone-permission cancellation, tool
results, external reasoning and `LOG_ONLY`, late-response cleanup, the idle
timeout, settings, RAG, profiles, and sanitized Markdown/export behavior.
Vitest handles Node 26's experimental web storage behavior internally.
## Source layout
- `src/components/`: voice controls, chat, dialogs, profile, and account views.
- `src/realtime/`: session lifecycle, observable application state, queue and
budget handling, WebSocket protocol, tools, and external reasoning.
- `src/audio/`: TypeScript capture/playback worklets, audio graph, PCM codec,
and visualizer. Vite emits the worklets as separate JavaScript assets.
- `src/lib/`: validated backend contracts, settings, prompt context, profile
and report presentation, Markdown sanitization, and conversation exports.
- `config/backend-proxy.ts`: the shared HTTP/WebSocket development proxy.
- `src/styles.css`: Tailwind integration and React-specific layout adjustments.
- `src/styles/legacy-theme.css`: the existing visual styles, retained for
fidelity to both the white microphone and dark orb themes. The UI is ported
to React; the full design has not been rewritten as Tailwind utilities.
The current Spanish interface, local preference keys, session-only RAG,
Markdown replies, JSON/TXT exports, optional browser wake word, TALK/TALKFIRST
startup modes, and optional backend features are retained. Camera controls and
quick actions remain absent, matching their disabled state in the old UI.
Native speech stays at the current project's 16 kHz PCM rate. The backend
continues to own VAD, transcription, model inference, synthesis, upstream
credentials, and ApiPilot execution. Browser `SpeechSynthesis` is used only
when the configured external reasoning service returns an answer instead of
`log_only`.
## References
- [Vite getting started](https://vite.dev/guide/)
- [Vite server and proxy options](https://vite.dev/config/server-options)
- [Vite features and asset handling](https://vite.dev/guide/features)
- [Tailwind's Vite integration](https://tailwindcss.com/docs/installation/using-vite)
+105
View File
@@ -0,0 +1,105 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { StrictMode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "./App";
const bootstrap = { theme: "white", s2sUrl: "ws://localhost:8420/v1/realtime", apipilot: true };
beforeEach(() => {
window.history.replaceState({}, "", "/?debug=true&phoneNumber=1234");
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(function (this: HTMLDialogElement) {
this.open = true;
});
vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(function (this: HTMLDialogElement) {
this.open = false;
});
vi.stubGlobal(
"fetch",
vi.fn((input: string) => {
const path = new URL(input, window.location.origin).pathname;
const data: Record<string, unknown> = {
"/api/config": bootstrap,
"/api/me": { enabled: false },
"/api/user/phone": { result: { name: "Ana Vargas", contact: { emailAddress: "ana@example.test" } } },
"/api/user/reports": {
count: 1,
entries: [{ sourceScheduleLabel: null, date: null }],
hints: [],
truncated: true,
note: "Lista abreviada",
},
"/api/apipilot/tools": {
success: true,
data: { tools: [{ name: "get_reports", description: "Consulta tus reportes", parameters: {} }] },
},
};
return Promise.resolve(new Response(JSON.stringify(data[path] ?? {})));
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
window.history.replaceState({}, "", "/");
});
describe("React application", () => {
it("loads the configured theme and persists voice settings across remounts under StrictMode", async () => {
const user = userEvent.setup();
const view = render(
<StrictMode>
<App />
</StrictMode>,
);
await user.click(await screen.findByRole("button", { name: "Configuración" }));
expect(document.documentElement.dataset.theme).toBe("white");
await user.selectOptions(screen.getByRole("combobox"), "Serena");
expect(localStorage.getItem("s2s.ws.voice")).toBe("Serena");
await user.click(screen.getByRole("button", { name: "Guardar" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
view.unmount();
render(
<StrictMode>
<App />
</StrictMode>,
);
await user.click(await screen.findByRole("button", { name: "Configuración" }));
expect(screen.getByRole("combobox")).toHaveValue("Serena");
});
it("saves and clears session-only RAG context and supports keyboard dialog dismissal", async () => {
const user = userEvent.setup();
render(<App />);
await user.click(await screen.findByRole("button", { name: "Contexto RAG personalizado" }));
await user.type(screen.getByRole("textbox"), "Datos de prueba para esta conversación");
await user.click(screen.getByRole("button", { name: "Enviar a la sesión" }));
expect(within(screen.getByRole("dialog")).getByRole("status")).toHaveTextContent("Contexto actualizado");
fireEvent(screen.getByRole("dialog"), new Event("cancel", { bubbles: true }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Contexto RAG personalizado" }));
expect(screen.getByRole("textbox")).toHaveValue("Datos de prueba para esta conversación");
await user.click(screen.getByRole("button", { name: "Vaciar" }));
expect(screen.getByRole("textbox")).toHaveValue("");
});
it("shows nested profile fields and discovers remote tools after bootstrap", async () => {
const user = userEvent.setup();
render(<App />);
await user.click(await screen.findByRole("button", { name: "Ver datos de Ana Vargas" }));
expect(within(screen.getByRole("dialog")).getByRole("link", { name: "ana@example.test" })).toHaveAttribute(
"href",
"mailto:ana@example.test",
);
await user.click(screen.getByRole("button", { name: "Cerrar" }));
await user.click(screen.getByRole("button", { name: "Herramientas" }));
await waitFor(() => {
expect(screen.getByText("get_reports")).toBeInTheDocument();
});
expect(screen.getByText("Consulta tus reportes")).toBeInTheDocument();
});
it("keeps debug controls out of the normal user screen", async () => {
window.history.replaceState({}, "", "/");
render(<App />);
expect(await screen.findByRole("button", { name: "Iniciar conversación de voz" })).toBeEnabled();
expect(screen.queryByRole("button", { name: "Configuración" })).not.toBeInTheDocument();
expect(screen.getByText("Aún no hay mensajes")).toBeInTheDocument();
});
});
+7 -5
View File
@@ -106,9 +106,11 @@ export default function App() {
<div className="brand">
<div className="ident">
<div className="ident-head">
<a className="ident-title" href={window.location.pathname + window.location.search}>
Vocero
</a>
<h1 className="m-0 leading-none">
<a className="ident-title" href={window.location.pathname + window.location.search}>
Vocero
</a>
</h1>
{white && <p className="ident-subtitle">Agente conversacional</p>}
</div>
</div>
@@ -253,7 +255,7 @@ export default function App() {
/>
<div className="reports-sidebar-inner">
<header className="reports-sidebar-header">
<h3>Reportes</h3>
<h2>Reportes</h2>
<button
id="reports-sidebar-close"
className="icon-btn"
@@ -277,7 +279,7 @@ export default function App() {
</div>
{state.reports && (
<div className="reports-suggestions">
<h4 className="reports-suggestions-title">Puedes preguntar</h4>
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
<ul className="reports-suggestions-list">
{reportSuggestions(state.reports).map((suggestion) => (
<li key={suggestion}>{suggestion}</li>
+1 -1
View File
@@ -82,7 +82,7 @@ export const Conversation = memo(function Conversation({
<section id="chat-panel" className="chat-panel chat-panel-inline" aria-label="Transcripción de la conversación">
<div className="chat-panel-inner">
<header className="chat-panel-header">
<h3>Conversación</h3>
<h2>Conversación</h2>
<div className="chat-header-actions" role="group" aria-label="Acciones de la conversación">
{white && (
<span
+1 -1
View File
@@ -140,7 +140,7 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
return (
<main className="stage">
{white ? (
<div className={`white-mic-stage ${live ? "live" : ""}`} aria-label="Control de voz">
<div className={`white-mic-stage ${live ? "live" : ""}`} role="group" aria-label="Control de voz">
<Waveform side="left" />
<button
id="white-mic-circle"
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { conversationText, renderMarkdown, serializeConversation } from "./messages";
describe("assistant Markdown and export", () => {
it("renders formatting and accessible tables while keeping embedded HTML passive", () => {
const html = renderMarkdown(
"## Resumen\n\n**Bien**\n\n| A | B |\n|---|---|\n|1|2|\n\n<script>alert(1)</script>\n\n[malicioso](javascript:alert%281%29)\n\n[reporte](https://example.test)",
);
const container = document.createElement("div");
container.innerHTML = html;
expect(container.querySelector("h2")?.textContent).toBe("Resumen");
expect(container.querySelector("strong")?.textContent).toBe("Bien");
expect(container.querySelector("[role='region']")?.getAttribute("tabindex")).toBe("0");
expect(container.querySelector("script")).toBeNull();
expect(container.querySelector("[href^='javascript:']")).toBeNull();
expect(container.querySelector("a")?.getAttribute("rel")).toBe("noopener noreferrer");
expect(container.textContent).toContain("<script>alert(1)</script>");
});
it("exports the original Markdown and interrupted/tool records without scraping the UI", () => {
const conversation = serializeConversation(
[
{ id: "1", role: "user", text: "Hola", partial: false, timestamp: "2026-10-06T15:00:00Z" },
{
id: "2",
role: "assistant",
text: "**Respuesta** parcial",
partial: false,
interrupted: true,
timestamp: "2026-10-06T15:00:01Z",
},
{
id: "3",
role: "tool",
text: "Resultado",
toolName: "web_search",
toolArguments: '{"query":"hoy"}',
partial: false,
timestamp: "2026-10-06T15:00:02Z",
},
],
"Ana Vargas",
);
expect(conversation.messageCount).toBe(3);
expect(conversation.messages[0]).toMatchObject({ name: "Ana Vargas", initials: "AV" });
expect(conversation.messages[1]).toMatchObject({ text: "**Respuesta** parcial", interrupted: true });
expect(conversationText(conversation)).toContain("[interrumpida]");
expect(conversationText(conversation)).toContain('Entrada:\n{"query":"hoy"}');
});
});
+63
View File
@@ -89,6 +89,69 @@ afterEach(() => {
});
describe("realtime session", () => {
it("ends a metered session and releases its reservation when the budget expires", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
session_id: "metered",
connect_url: "ws://localhost/v1/realtime",
limited: true,
heartbeatSec: 5,
}),
),
)
.mockResolvedValueOnce(new Response(JSON.stringify({ expired: true })));
vi.stubGlobal("fetch", fetchMock);
const client = new RealtimeClient({ ...options(), directUrl: undefined, sessionUrl: "/api/session" });
const error = vi.fn();
client.on("error", error);
const socket = await connect(client);
await vi.advanceTimersByTimeAsync(5000);
expect(error).toHaveBeenCalledWith(expect.objectContaining({ code: "limit" }));
expect(socket.close).toHaveBeenCalledOnce();
expect(sendBeacon).toHaveBeenCalledWith("/api/session/end", expect.any(Blob));
expect(client.status).toBe("closed");
expect(vi.getTimerCount()).toBe(0);
});
it("replaces cumulative user transcript deltas instead of duplicating words", async () => {
const client = new RealtimeClient(options());
const transcript = vi.fn();
client.on("transcript", transcript);
const socket = await connect(client);
socket.receive({ type: "conversation.item.input_audio_transcription.delta", item_id: "user1", delta: "Hola" });
socket.receive({
type: "conversation.item.input_audio_transcription.delta",
item_id: "user1",
delta: "Hola Vocero",
});
await vi.waitFor(() => {
expect(transcript).toHaveBeenCalledTimes(2);
});
expect(transcript).toHaveBeenLastCalledWith({ role: "user", text: "Hola Vocero", partial: true, id: "user1" });
await client.close();
});
it("suppresses the next native reply when external reasoning wins before response.created", async () => {
const client = new RealtimeClient(options());
const transcript = vi.fn();
client.on("transcript", transcript);
const socket = await connect(client);
client.renderExternalAnswer("Respuesta externa");
socket.receive({ type: "response.created", response: { id: "native" } });
socket.receive({ type: "response.audio.delta", response_id: "native", delta: "AAAA" });
socket.receive({ type: "response.audio_transcript.delta", response_id: "native", delta: "Respuesta duplicada" });
socket.receive({ type: "response.done", response: { id: "native", status: "completed" } });
await vi.waitFor(() => {
expect(client.status).toBe("listening");
});
expect(audio.play).not.toHaveBeenCalled();
expect(transcript).not.toHaveBeenCalled();
expect(socket.sent.at(-1)).toMatchObject({ item: { role: "assistant", content: [{ text: "Respuesta externa" }] } });
await client.close();
});
it("waits for configuration before greeting and serializes response requests", async () => {
const client = new RealtimeClient({ ...options(), greeting: "hola vocero" });
const socket = await connect(client);
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import type { ClientOptions } from "./client";
import { VoiceController } from "./controller";
const { MockClient, closeContext } = vi.hoisted(() => {
const closeContext = vi.fn().mockResolvedValue(undefined);
class MockClient {
static instances: MockClient[] = [];
readonly options: ClientOptions;
private listeners = new Map<string, Set<(value: unknown) => void>>();
constructor(options: ClientOptions) {
this.options = options;
MockClient.instances.push(this);
}
on(name: string, listener: (value: unknown) => void) {
const listeners = this.listeners.get(name) ?? new Set();
listeners.add(listener);
this.listeners.set(name, listeners);
return () => {
listeners.delete(listener);
};
}
emit(name: string, value: unknown) {
this.listeners.get(name)?.forEach((listener) => {
listener(value);
});
}
connect = vi.fn(() => {
this.emit("status", "listening");
return Promise.resolve();
});
close = vi.fn(() => {
this.emit("status", "closed");
return Promise.resolve();
});
updateSession = vi.fn();
setGate = vi.fn();
setMuted = vi.fn();
renderExternalAnswer = vi.fn();
sendToolOutput = vi.fn();
join = vi.fn();
}
return { MockClient, closeContext };
});
vi.mock("./client", async (importOriginal) => ({ ...(await importOriginal()), RealtimeClient: MockClient }));
vi.mock("../audio/engine", () => ({ createAudioContext: () => ({ state: "running", close: closeContext }) }));
let pendingReasoning: ((value: Response) => void) | undefined;
let controller: VoiceController;
let fetchMock: Mock<(path: string, init?: RequestInit) => Promise<Response>>;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
MockClient.instances = [];
pendingReasoning = undefined;
vi.stubGlobal("AudioContext", vi.fn());
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn() } });
fetchMock = vi.fn((path: string) => {
if (path === "/api/reasoning") {
return new Promise<Response>((resolve) => {
pendingReasoning = resolve;
});
}
const pathname = new URL(path, "http://localhost").pathname;
const responses: Record<string, unknown> = {
"/api/config": { reasoningType: "NODE_RED", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" },
"/api/me": { enabled: false },
"/api/user/phone": { result: { name: "Ana" } },
"/api/user/reports": { count: 0 },
};
return Promise.resolve(new Response(JSON.stringify(responses[pathname] ?? {})));
});
vi.stubGlobal("fetch", fetchMock);
controller = new VoiceController({ phoneNumber: "1234", episodeId: "episode1" });
});
afterEach(async () => {
await controller.stop();
vi.useRealTimers();
vi.unstubAllGlobals();
});
async function start() {
await controller.initialize(new AbortController().signal);
await controller.start();
const client = MockClient.instances[0];
if (!client) {
throw new Error("No client created");
}
return client;
}
describe("voice application lifecycle", () => {
it("keeps LOG_ONLY reasoning on the native voice path and preserves separate profile context", async () => {
const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false });
pendingReasoning?.(new Response(JSON.stringify({ log_only: true, answer: "" })));
await vi.advanceTimersByTimeAsync(1);
const request = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning");
expect(request?.[1]?.body).toContain('"phoneNumber":"1234"');
expect(request?.[1]?.body).toContain("Caller profile resolved");
expect(client.options.instructions).not.toContain("Caller profile resolved");
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
expect(controller.getSnapshot().messages).toHaveLength(1);
});
it("discards external reasoning that finishes after the session was stopped", async () => {
const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Consulta", partial: false });
await controller.stop();
pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta tardía" })));
await vi.advanceTimersByTimeAsync(1);
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Consulta"]);
});
it("applies and removes RAG context in the live session", async () => {
const client = await start();
controller.setRag("Datos importantes");
expect(client.updateSession).toHaveBeenLastCalledWith(expect.stringContaining("Datos importantes"), "Aiden", []);
controller.setRag("");
expect(client.updateSession).toHaveBeenLastCalledWith(expect.not.stringContaining("CUSTOM_RAG_BEGIN"), "Aiden", []);
});
it("closes after silence but does not cut off the assistant while speaking", async () => {
const client = await start();
client.emit("status", "ai-speaking");
await vi.advanceTimersByTimeAsync(60000);
expect(client.close).not.toHaveBeenCalled();
client.emit("status", "listening");
await vi.advanceTimersByTimeAsync(30000);
expect(client.close).toHaveBeenCalledOnce();
expect(controller.getSnapshot().status).toBe("closed");
});
});
+9
View File
@@ -4,6 +4,15 @@
@import "tailwindcss/utilities.css" layer(utilities);
@layer components {
.chat-empty,
.chat-empty-hint {
opacity: 1;
color: var(--text-dim);
}
html[data-theme="white"] .chat-empty,
html[data-theme="white"] .chat-empty-hint {
color: #64748b;
}
.feedback-navigation {
align-self: flex-end;
display: flex;
+5 -5
View File
@@ -2739,7 +2739,7 @@ body.cam-on .footer {
border-bottom: 0 solid transparent;
flex: none;
}
.chat-panel-header h3 {
.chat-panel-header h2 {
margin: 0;
font-size: 13px;
letter-spacing: 0.04em;
@@ -2873,7 +2873,7 @@ body.cam-on .footer {
);
opacity: 0.85;
}
.reports-sidebar-header h3 {
.reports-sidebar-header h2 {
margin: 0;
font-size: 14px;
font-weight: 700;
@@ -4709,7 +4709,7 @@ html[data-theme="white"] .markdown-content a {
.about-intro p,
.about-repo,
.brand,
.chat-panel-header h3,
.chat-panel-header h2,
.field textarea,
.ident-meta,
.pipe-job,
@@ -5581,7 +5581,7 @@ html[data-theme="white"] .chat-panel-header {
/* Header never shrinks out of existence; keep it pinned. */
flex: none;
}
html[data-theme="white"] .chat-panel-header h3 {
html[data-theme="white"] .chat-panel-header h2 {
margin: 0;
color: #0f172a !important; /* slate-900 flat, no brand gradient */
background: none !important;
@@ -6108,7 +6108,7 @@ html[data-theme="white"] .reports-sidebar-header {
border-bottom-color: rgba(15, 23, 42, 0.08);
color: #0f172a;
}
html[data-theme="white"] .reports-sidebar-header h3 {
html[data-theme="white"] .reports-sidebar-header h2 {
color: #0f172a;
}
+18
View File
@@ -3,6 +3,24 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// jsdom has no native modal top layer; real focus/inert behavior is checked in Chromium.
Object.defineProperties(HTMLDialogElement.prototype, {
showModal: {
configurable: true,
writable: true,
value: function (this: HTMLDialogElement) {
this.open = true;
},
},
close: {
configurable: true,
writable: true,
value: function (this: HTMLDialogElement) {
this.open = false;
},
},
});
afterEach(() => {
cleanup();
window.localStorage.clear();