fix(frontend): improve responsive transcripts and simplify theme styles
This commit is contained in:
@@ -57,7 +57,8 @@ audio. It is not an unconditional byte-for-byte tunnel.
|
||||
| [client.ts](../../vocero-frontend/src/realtime/client.ts) | Realtime protocol, session lifecycle, audio and transcript events |
|
||||
| [components](../../vocero-frontend/src/components) | Chat, caller profile, settings and voice components |
|
||||
| [audio](../../vocero-frontend/src/audio) | TypeScript capture/playback worklets, PCM codec, and visualizer |
|
||||
| [legacy-theme.css](../../vocero-frontend/src/styles/legacy-theme.css) | Retained themes and responsive visual styles alongside Tailwind |
|
||||
| [legacy-theme.css](../../vocero-frontend/src/styles/legacy-theme.css) | Theme tokens, voice artwork and component appearance |
|
||||
| [layout.css](../../vocero-frontend/src/styles/layout.css) | Shared responsive layout, transcript sizing and reduced-motion behavior |
|
||||
|
||||
## Server routes
|
||||
|
||||
@@ -95,6 +96,12 @@ describe service-owned structure and retain loose upstream values. See
|
||||
4. Assistant PCM16 audio at the current 16 kHz protocol rate is decoded and sent to
|
||||
[audio-playback.worklet.ts](../../vocero-frontend/src/audio/audio-playback.worklet.ts), which buffers,
|
||||
resamples and fades playback edges. Input speech can clear playback for barge-in.
|
||||
The client flushes short final chunks on response completion and remains in
|
||||
the speaking state until the worklet reports that playback has drained.
|
||||
Sequence numbers reject drain notifications from superseded playback.
|
||||
|
||||
Microphone meter subscribers are separate from application-state subscribers,
|
||||
so level updates do not rerender the main React tree.
|
||||
|
||||
Audio worklets are loaded through `AudioWorklet.addModule`; keep their processor
|
||||
registration structure intact when editing them. The speech backend's thread
|
||||
@@ -105,6 +112,8 @@ and cancellation model is documented in [realtime API](../speech/realtime-api.md
|
||||
The browser triggers external reasoning on completed user transcripts only in
|
||||
`OPENAI` mode. A non-empty answer replaces the native response through the
|
||||
external-answer path. These HTTP operations supplement the realtime audio path.
|
||||
New user speech and session teardown cancel pending reasoning. Turn identity
|
||||
checks reject stale HTTP results and speech callbacks even if cancellation is late.
|
||||
|
||||
Read [reasoning](reasoning.md), [context injection](context-injection.md), and
|
||||
[tool calling](tool-calling.md) for the conditions and limitations of each path.
|
||||
|
||||
+25
-6
@@ -117,6 +117,10 @@ browsable pages.
|
||||
Voice, editable system instructions and connection settings are kept in browser
|
||||
`localStorage` under `s2s.ws.*` keys. A non-empty `S2S_PRIMARY_PROMPT` pins the
|
||||
instruction field. Restart applies connection/voice changes to a new session.
|
||||
Settings and tool preferences use a draft: **Guardar** applies them and
|
||||
**Cancelar** or dialog dismissal discards unsaved edits. Voice and instructions
|
||||
update the live session on Save; connection changes apply on the next start.
|
||||
Noise-gate calibration is explicitly live and saves immediately.
|
||||
Model voice names are runtime identifiers and stay unchanged across UI
|
||||
translations.
|
||||
|
||||
@@ -135,19 +139,33 @@ enabled data sources and limits.
|
||||
`S2S_THEME=white` selects the light interface. `dark` or an empty value selects
|
||||
the dark aurora theme. Theme-specific sections remain hidden until configuration
|
||||
resolves, with a fallback to avoid an invisible page after config failure.
|
||||
Configuration failures offer **Reintentar** and block session startup until a
|
||||
retry succeeds. Reports load independently. Caller context gets an eight-second
|
||||
loading window before the interface allows a generic session.
|
||||
|
||||
`S2S_START_SESSION_MODE` controls initial microphone/session startup:
|
||||
|
||||
| Value | Behavior |
|
||||
|---|---|
|
||||
| Empty/default | User taps to start |
|
||||
| `TALK` | After a 2-second warmup, show an audio unlocker; a user gesture starts the session and greeting |
|
||||
| `TALKFIRST` | After a 2-second warmup, attempt automatic start and inject the Spanish wake phrase; microphone failure falls back to the unlocker |
|
||||
| `TALK` | After a 2-second warmup, show a dismissible startup dialog; a user gesture starts the session and greeting |
|
||||
| `TALKFIRST` | After a 2-second warmup, attempt automatic start and inject the Spanish wake phrase; microphone failure falls back to the startup dialog |
|
||||
|
||||
The warmup is a timer, not proof that all upstream context requests have
|
||||
finished. Browser permission and audio policies can still require a gesture.
|
||||
The warmup starts after configuration and caller-context loading settle;
|
||||
optional reports and tools can still be loading. Browser permission and audio
|
||||
policies can still require a gesture.
|
||||
The greeting uses the same `hola vocero` input as wake-word handling.
|
||||
|
||||
On browsers with speech recognition, **Activar con «hola vocero»** opts into
|
||||
wake-word listening for the current page session. The interface shows when the
|
||||
microphone is listening for that phrase. It is off by default, pauses while a
|
||||
dialog or voice session is active, and turns off when the conversation stops.
|
||||
|
||||
The transcript keeps your reading position when new messages arrive. **Ir al
|
||||
último mensaje** resumes following the conversation. Names and Santiago-local
|
||||
times appear with each message; downloaded JSON and text retain raw timestamps
|
||||
and message text. The interface respects the browser's reduced-motion setting.
|
||||
|
||||
## Queue and session lifecycle
|
||||
|
||||
Load-balancer mode proxies `POST /api/session`. A free compute slot returns a
|
||||
@@ -161,8 +179,9 @@ to a keepalive fetch. Expired tickets and missed join deadlines require a new
|
||||
attempt. Full queues return `503` with `state: "at_capacity"`.
|
||||
|
||||
Once connected, session teardown closes the microphone, audio graph and
|
||||
WebSocket. The 30-second inactivity timeout remains active when the assistant
|
||||
is not speaking. Caller-profile lookup and upstream authorization operate
|
||||
WebSocket. The 30-second inactivity timeout counts time waiting for user input;
|
||||
processing, external reasoning and buffered playback do not consume it.
|
||||
Caller-profile lookup and upstream authorization operate
|
||||
independently of allocation and use their existing configured endpoints.
|
||||
|
||||
For external transcript processing, configure the selected
|
||||
|
||||
@@ -9,6 +9,11 @@ the implementation at the recorded date; current operating instructions live in
|
||||
|
||||
### Fixed
|
||||
|
||||
- Consolidate responsive transcript layout, keep export controls visible on narrow screens,
|
||||
and place feedback and version details below the conversation. Add jump-to-latest,
|
||||
semantic message metadata, state-aware empty hints, shared sanitized Markdown rendering,
|
||||
and reduced-motion support. Remove retired camera, account, and overlay styles.
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -208,11 +208,6 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<nav className="feedback-navigation" aria-label="Comentarios">
|
||||
<a className="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">
|
||||
Enviar comentarios
|
||||
</a>
|
||||
</nav>
|
||||
{state.error && (
|
||||
<div className="app-error" role="alert">
|
||||
<span>{state.error}</span>
|
||||
@@ -229,10 +224,16 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
<VoiceStage state={state} controller={controller} />
|
||||
{ready && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock && !reportsOpen && (
|
||||
{ready && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock && !(reportsOpen && !desktop) && (
|
||||
<WakeWordControl enabled={state.wakeWordEnabled} controller={controller} />
|
||||
)}
|
||||
<Conversation messages={state.messages} userName={name || "Usuario"} status={state.status} white={white} />
|
||||
<Conversation
|
||||
messages={state.messages}
|
||||
userName={name || "Usuario"}
|
||||
status={state.status}
|
||||
white={white}
|
||||
muted={state.muted}
|
||||
/>
|
||||
</div>
|
||||
{!white && ACTIVE_STATES.has(state.status) && <Bubbles messages={state.messages} userName={name || "Usuario"} />}
|
||||
{hasReports && state.reports && (
|
||||
@@ -250,7 +251,10 @@ export default function App() {
|
||||
{modal === "rag" && <RagDialog state={state} controller={controller} onClose={close} />}
|
||||
{modal === "profile" && <ProfileDialog state={state} onClose={close} />}
|
||||
{modal === "about" && <AboutDialog onClose={close} />}
|
||||
<footer className="app-footer" aria-label="Versión de la aplicación">
|
||||
<footer className="app-footer" aria-label="Información de la aplicación">
|
||||
<a className="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">
|
||||
Enviar comentarios
|
||||
</a>
|
||||
<span className="app-footer-text">Azurian Vocero en tiempo real ver. Beta 2.1</span>
|
||||
</footer>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
import { renderMarkdown } from "../lib/messages";
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
text,
|
||||
className = "",
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const html = useMemo(() => renderMarkdown(text), [text]);
|
||||
return <div className={`markdown-content ${className}`} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useEffect, useState } from "react";
|
||||
|
||||
import { renderMarkdown, type Message } from "../lib/messages";
|
||||
import type { Message } from "../lib/messages";
|
||||
import { AssistantMarkdown } from "./AssistantMarkdown";
|
||||
|
||||
function Bubble({ message, userName }: { message: Message; userName: string }) {
|
||||
const [expiredText, setExpiredText] = useState<string | null>(null);
|
||||
@@ -24,10 +25,7 @@ function Bubble({ message, userName }: { message: Message; userName: string }) {
|
||||
{message.role === "user" ? userName : message.role === "tool" ? "Herramienta" : "Vocero"}
|
||||
</div>
|
||||
{message.role === "assistant" ? (
|
||||
<div
|
||||
className="bubble-body markdown-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
|
||||
/>
|
||||
<AssistantMarkdown text={message.text} className="bubble-body" />
|
||||
) : (
|
||||
<div className="bubble-body">{message.role === "tool" ? message.toolName : message.text}</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import type { Message } from "../lib/messages";
|
||||
import { Conversation } from "./Conversation";
|
||||
|
||||
const message: Message = {
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
text: "Una respuesta con [enlace](https://example.test)",
|
||||
timestamp: "2026-10-07T12:00:00Z",
|
||||
partial: false,
|
||||
};
|
||||
it("renders accessible names, a real timestamp, and sanitized assistant content", () => {
|
||||
render(<Conversation messages={[message]} userName="Ana" status="listening" white muted={false} />);
|
||||
expect(screen.getByText("Vocero")).toBeVisible();
|
||||
expect(screen.getByText(/^09:00/).closest("time")).toHaveAttribute("datetime", message.timestamp);
|
||||
expect(screen.getByRole("link", { name: "enlace" })).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
it("preserves a reader's scroll position during updates and offers a jump to the latest message", async () => {
|
||||
const user = userEvent.setup();
|
||||
const view = render(<Conversation messages={[message]} userName="Ana" status="listening" white muted={false} />);
|
||||
const history = screen.getByRole("log");
|
||||
Object.defineProperties(history, { scrollHeight: { configurable: true, value: 1000 }, clientHeight: { value: 200 } });
|
||||
history.scrollTop = 200;
|
||||
fireEvent.scroll(history);
|
||||
view.rerender(
|
||||
<Conversation
|
||||
messages={[message, { ...message, id: "2", text: "Nueva respuesta" }]}
|
||||
userName="Ana"
|
||||
status="listening"
|
||||
white
|
||||
muted={false}
|
||||
/>,
|
||||
);
|
||||
expect(history.scrollTop).toBe(200);
|
||||
await user.click(screen.getByRole("button", { name: "Ir al último mensaje" }));
|
||||
expect(history.scrollTop).toBe(1000);
|
||||
expect(history).toHaveFocus();
|
||||
expect(screen.queryByRole("button", { name: "Ir al último mensaje" })).not.toBeInTheDocument();
|
||||
});
|
||||
it("gives useful empty-state instructions during a live session and when muted", () => {
|
||||
const view = render(<Conversation messages={[]} userName="Ana" status="listening" white muted={false} />);
|
||||
expect(screen.getByText("Habla cuando quieras. Tu conversación aparecerá aquí.")).toBeVisible();
|
||||
view.rerender(<Conversation messages={[]} userName="Ana" status="listening" white muted />);
|
||||
expect(screen.getByText("Activa el micrófono para hablar")).toBeVisible();
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Download, MessageCircle, Wrench } from "lucide-react";
|
||||
import { ArrowDown, Download, MessageCircle, Wrench } from "lucide-react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { initials } from "../lib/context";
|
||||
import type { ConnectionStatus } from "../lib/contracts";
|
||||
import { displayTime, exportConversation, renderMarkdown, type Message } from "../lib/messages";
|
||||
import { displayTime, exportConversation, type Message } from "../lib/messages";
|
||||
import { LIVE_STATES } from "../realtime/controller";
|
||||
import { AssistantMarkdown } from "./AssistantMarkdown";
|
||||
|
||||
const MessageRow = memo(function MessageRow({ message, userName }: { message: Message; userName: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -33,25 +34,19 @@ const MessageRow = memo(function MessageRow({ message, userName }: { message: Me
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const data = {
|
||||
"data-user-name": userName,
|
||||
"data-user-initials": initials(userName),
|
||||
"data-asst-name": "Vocero",
|
||||
"data-timestamp": time,
|
||||
};
|
||||
return (
|
||||
<div className={`hist-msg ${message.role} ${message.interrupted ? "interrupted" : ""}`} {...data}>
|
||||
<div className="hist-role">{message.role === "user" ? userName : "Vocero"}</div>
|
||||
{message.role === "assistant" ? (
|
||||
<div
|
||||
className={`hist-body markdown-content ${message.partial ? "partial" : ""}`}
|
||||
{...data}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
|
||||
/>
|
||||
) : (
|
||||
<div className={`hist-body ${message.partial ? "partial" : ""}`} {...data}>
|
||||
{message.text}
|
||||
className={`hist-msg ${message.role} ${message.interrupted ? "interrupted" : ""}`}
|
||||
data-user-initials={initials(userName)}
|
||||
>
|
||||
<div className="hist-meta">
|
||||
<span>{message.role === "user" ? userName : "Vocero"}</span>
|
||||
<time dateTime={message.timestamp}>{time}</time>
|
||||
</div>
|
||||
{message.role === "assistant" ? (
|
||||
<AssistantMarkdown text={message.text} className={`hist-body ${message.partial ? "partial" : ""}`} />
|
||||
) : (
|
||||
<div className={`hist-body ${message.partial ? "partial" : ""}`}>{message.text}</div>
|
||||
)}
|
||||
{message.interrupted && <div className="hist-note">Interrumpido</div>}
|
||||
</div>
|
||||
@@ -62,14 +57,17 @@ export const Conversation = memo(function Conversation({
|
||||
userName,
|
||||
status,
|
||||
white,
|
||||
muted,
|
||||
}: {
|
||||
messages: Message[];
|
||||
userName: string;
|
||||
status: ConnectionStatus;
|
||||
white: boolean;
|
||||
muted: boolean;
|
||||
}) {
|
||||
const history = useRef<HTMLDivElement>(null);
|
||||
const atBottom = useRef(true);
|
||||
const [showLatest, setShowLatest] = useState(false);
|
||||
useEffect(() => {
|
||||
const element = history.current;
|
||||
if (element && atBottom.current) {
|
||||
@@ -115,12 +113,15 @@ export const Conversation = memo(function Conversation({
|
||||
id="chat-history"
|
||||
className="chat-history"
|
||||
role="log"
|
||||
tabIndex={0}
|
||||
aria-label="Mensajes de la conversación"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
onScroll={() => {
|
||||
const element = history.current;
|
||||
if (element) {
|
||||
atBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 60;
|
||||
setShowLatest(!atBottom.current);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -130,10 +131,35 @@ export const Conversation = memo(function Conversation({
|
||||
<div className="chat-empty">
|
||||
<MessageCircle aria-hidden="true" />
|
||||
<span className="chat-empty-title">Aún no hay mensajes</span>
|
||||
<span className="chat-empty-hint">Toca el círculo y empieza a hablar</span>
|
||||
<span className="chat-empty-hint">
|
||||
{muted
|
||||
? "Activa el micrófono para hablar"
|
||||
: live
|
||||
? "Habla cuando quieras. Tu conversación aparecerá aquí."
|
||||
: waiting
|
||||
? "Tu conversación aparecerá aquí al conectar."
|
||||
: "Toca el círculo y empieza a hablar"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showLatest && (
|
||||
<button
|
||||
className="jump-to-latest btn secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (history.current) {
|
||||
history.current.scrollTop = history.current.scrollHeight;
|
||||
}
|
||||
atBottom.current = true;
|
||||
setShowLatest(false);
|
||||
history.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
>
|
||||
<ArrowDown size={16} aria-hidden="true" />
|
||||
Ir al último mensaje
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { reportSuggestions } from "../lib/reports";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
function ReportContent({ reports }: { reports: Reports }) {
|
||||
const suggestions = reportSuggestions(reports);
|
||||
return (
|
||||
<div className="reports-content">
|
||||
<div className="reports-hint">
|
||||
@@ -14,14 +15,16 @@ function ReportContent({ reports }: { reports: Reports }) {
|
||||
</ul>
|
||||
{reports.note && <p>{reports.note}</p>}
|
||||
</div>
|
||||
{suggestions.length > 0 && (
|
||||
<div className="reports-suggestions">
|
||||
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
|
||||
<ul className="reports-suggestions-list">
|
||||
{reportSuggestions(reports).map((suggestion) => (
|
||||
{suggestions.map((suggestion) => (
|
||||
<li key={suggestion}>{suggestion}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ class Recognition {
|
||||
onend: (() => void) | null = null;
|
||||
onerror = null;
|
||||
onresult = null;
|
||||
start = vi.fn(() => {
|
||||
this.onstart?.();
|
||||
});
|
||||
start = vi.fn();
|
||||
abort = vi.fn();
|
||||
constructor() {
|
||||
Recognition.instances.push(this);
|
||||
@@ -39,11 +37,19 @@ it("requires opt-in, displays actual listening, and releases recognition on Stop
|
||||
expect(Recognition.instances).toHaveLength(0);
|
||||
await user.click(screen.getByRole("checkbox"));
|
||||
expect(Recognition.instances).toHaveLength(1);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Preparando activación");
|
||||
act(() => {
|
||||
Recognition.instances[0]?.onstart?.();
|
||||
});
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Micrófono activo");
|
||||
await user.click(screen.getByRole("checkbox"));
|
||||
await user.click(screen.getByRole("checkbox"));
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Preparando activación");
|
||||
await act(async () => {
|
||||
await controller.stop();
|
||||
});
|
||||
expect(Recognition.instances[0]?.abort).toHaveBeenCalledOnce();
|
||||
expect(Recognition.instances[1]?.abort).toHaveBeenCalledOnce();
|
||||
expect(screen.getByRole("checkbox")).not.toBeChecked();
|
||||
expect(Recognition.instances).toHaveLength(1);
|
||||
expect(Recognition.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { useWakeWord } from "../hooks/use-wake-word";
|
||||
import type { VoiceController } from "../realtime/controller";
|
||||
|
||||
function WakeWordStatus({ controller }: { controller: VoiceController }) {
|
||||
const status = useWakeWord(true, controller);
|
||||
return (
|
||||
<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."
|
||||
: "Preparando activación por voz…"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
export function WakeWordControl({ enabled, controller }: { enabled: boolean; controller: VoiceController }) {
|
||||
const status = useWakeWord(enabled, controller);
|
||||
if (status === "unsupported") {
|
||||
if (!(window.SpeechRecognition ?? window.webkitSpeechRecognition)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
@@ -18,15 +29,11 @@ export function WakeWordControl({ enabled, controller }: { enabled: boolean; con
|
||||
/>
|
||||
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>
|
||||
{enabled ? (
|
||||
<WakeWordStatus controller={controller} />
|
||||
) : (
|
||||
<span role="status">Opcional. Se desactiva al detener la conversación.</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ describe("assistant Markdown and export", () => {
|
||||
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("[role='group']")?.getAttribute("tabindex")).toBe("0");
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
expect(container.querySelector("[href^='javascript:']")).toBeNull();
|
||||
expect(container.querySelector("a")?.getAttribute("rel")).toBe("noopener noreferrer");
|
||||
|
||||
@@ -81,7 +81,7 @@ export function renderMarkdown(text: string): string {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "markdown-table";
|
||||
wrapper.tabIndex = 0;
|
||||
wrapper.setAttribute("role", "region");
|
||||
wrapper.setAttribute("role", "group");
|
||||
wrapper.setAttribute("aria-label", "Tabla de la respuesta");
|
||||
table.replaceWith(wrapper);
|
||||
wrapper.append(table);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@layer theme, base, components, utilities;
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "./styles/legacy-theme.css" layer(components);
|
||||
@import "./styles/layout.css" layer(components);
|
||||
@import "tailwindcss/utilities.css" layer(utilities);
|
||||
|
||||
@layer components {
|
||||
@@ -13,10 +14,6 @@
|
||||
html[data-theme="white"] .chat-empty-hint {
|
||||
color: #64748b;
|
||||
}
|
||||
.feedback-navigation {
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
}
|
||||
#root {
|
||||
display: contents;
|
||||
}
|
||||
@@ -35,9 +32,6 @@
|
||||
html[data-theme="white"] .white-mic-circle svg.wmc-mic {
|
||||
fill: none;
|
||||
}
|
||||
#app:not(.has-reports) {
|
||||
padding-left: 0;
|
||||
}
|
||||
.loading-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/* Shared layout. Theme colors and voice artwork remain in legacy-theme.css. */
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: 100dvh;
|
||||
padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) 0 max(8px, env(safe-area-inset-left));
|
||||
}
|
||||
#app {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.stage,
|
||||
html[data-theme="white"] .stage {
|
||||
padding: 12px 12px 8px;
|
||||
gap: 12px;
|
||||
flex: none;
|
||||
}
|
||||
.chat-panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
flex-direction: column;
|
||||
height: clamp(240px, 45dvh, 640px);
|
||||
min-width: 0;
|
||||
margin: 8px 12px 0;
|
||||
}
|
||||
.circle-caption {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.chat-panel-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-width: 920px;
|
||||
margin-inline: auto;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-panel-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 0 10px;
|
||||
flex: none;
|
||||
}
|
||||
.chat-panel-header h2 {
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-header-actions,
|
||||
html[data-theme="white"] .chat-header-actions {
|
||||
gap: 6px;
|
||||
}
|
||||
.chat-export-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.chat-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 14px;
|
||||
padding: 12px 4px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-anchor: none;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.chat-history:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.hist-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
max-width: 86%;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
.hist-msg.user {
|
||||
align-self: flex-start;
|
||||
margin-right: auto;
|
||||
}
|
||||
.hist-msg.assistant {
|
||||
align-self: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
.hist-msg.tool {
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
}
|
||||
.hist-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 10px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.hist-meta span {
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hist-meta time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hist-body {
|
||||
min-width: 0;
|
||||
}
|
||||
.hist-body:not(.markdown-content) {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.jump-to-latest {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
bottom: 18px;
|
||||
right: 18px;
|
||||
z-index: 2;
|
||||
gap: 6px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
html[data-theme="white"] .chat-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
html[data-theme="white"] .chat-panel-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
html[data-theme="white"] .chat-history .hist-msg {
|
||||
max-width: calc(100% - 40px);
|
||||
padding-top: 0;
|
||||
}
|
||||
html[data-theme="white"] .chat-history .hist-msg.assistant {
|
||||
align-self: flex-start;
|
||||
margin: 4px auto 4px 40px;
|
||||
}
|
||||
html[data-theme="white"] .chat-history .hist-msg.user {
|
||||
align-self: flex-end;
|
||||
margin: 4px 40px 4px auto;
|
||||
}
|
||||
html[data-theme="white"] .chat-history .hist-msg.tool {
|
||||
max-width: 100%;
|
||||
margin: 4px 0;
|
||||
}
|
||||
html[data-theme="white"] .chat-history .hist-msg::before {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 11px;
|
||||
}
|
||||
html[data-theme="white"] .hist-meta {
|
||||
color: #64748b;
|
||||
}
|
||||
html[data-theme="white"] .hist-msg.user .hist-meta {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.app-footer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 12px max(12px, env(safe-area-inset-bottom));
|
||||
flex: none;
|
||||
text-align: center;
|
||||
}
|
||||
.feedback-link {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
border: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
.feedback-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.feedback-link:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
#app.has-reports {
|
||||
padding-left: 320px;
|
||||
}
|
||||
body:has(#app.has-reports) .app-footer {
|
||||
margin-left: 320px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.topbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.topbar-right {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chat-panel {
|
||||
margin-inline: 0;
|
||||
}
|
||||
.chat-panel-inner {
|
||||
padding: 10px;
|
||||
}
|
||||
html[data-theme="white"] .chat-panel-header h2 {
|
||||
font-size: 17px;
|
||||
}
|
||||
html[data-theme="white"] .chat-status-pill {
|
||||
font-size: 11px;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
.orb-wrap {
|
||||
flex-direction: row;
|
||||
gap: 18px;
|
||||
}
|
||||
.circle {
|
||||
width: clamp(110px, 32vw, 176px);
|
||||
}
|
||||
.orb-wrap.live .side-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.mic-gate-arc {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
}
|
||||
html[data-theme="white"] .white-mic-stage {
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
html[data-theme="white"] .white-waveform {
|
||||
top: 74px;
|
||||
}
|
||||
html[data-theme="white"] .white-session-controls {
|
||||
gap: 8px;
|
||||
}
|
||||
html[data-theme="white"] .white-mute-btn,
|
||||
html[data-theme="white"] .white-stop-btn {
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
min-height: 44px;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
@media (max-height: 640px) {
|
||||
.stage,
|
||||
html[data-theme="white"] .stage {
|
||||
padding-block: 8px;
|
||||
}
|
||||
html[data-theme="white"] .white-mic-stage {
|
||||
padding-top: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
html[data-theme="white"] .white-waveform {
|
||||
top: 57px;
|
||||
}
|
||||
html[data-theme="white"] .white-mic-caption {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--audio-level: 0 !important;
|
||||
--ai-audio-level: 0 !important;
|
||||
--bar0: 0 !important;
|
||||
--bar1: 0 !important;
|
||||
--bar2: 0 !important;
|
||||
--bar3: 0 !important;
|
||||
--bar4: 0 !important;
|
||||
}
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.circle.state-ai-speaking .bar,
|
||||
.circle.state-user-speaking .bar {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user