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
+10 -1
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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.
+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>
{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>
);
}
+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