fix(frontend): improve responsive transcripts and simplify theme styles

This commit is contained in:
2026-10-07 09:43:30 -03:00
parent a56e1082e5
commit 4ce6082b93
16 changed files with 536 additions and 1123 deletions
+12 -8
View File
@@ -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 }} />;
});
+3 -5
View File
@@ -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();
});
+45 -19
View File
@@ -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>
<div
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" ? (
<div
className={`hist-body markdown-content ${message.partial ? "partial" : ""}`}
{...data}
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
/>
<AssistantMarkdown text={message.text} className={`hist-body ${message.partial ? "partial" : ""}`} />
) : (
<div className={`hist-body ${message.partial ? "partial" : ""}`} {...data}>
{message.text}
</div>
<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>
<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>
{suggestions.length > 0 && (
<div className="reports-suggestions">
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
<ul className="reports-suggestions-list">
{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>
);
}
+1 -1
View File
@@ -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");
+1 -1
View File
@@ -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 -7
View File
@@ -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;
+298
View File
@@ -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