refactor(frontend): retire legacy CSS and isolate visual artwork

This commit is contained in:
2026-10-07 10:06:11 -03:00
parent f3bae8ae0c
commit 65eaf36278
16 changed files with 1376 additions and 5334 deletions
+4 -2
View File
@@ -167,8 +167,10 @@ evidence; do not bundle them into tooling or structural refactors.
`vocero-frontend/src/lib/messages.ts`. User text stays literal, and exports retain `vocero-frontend/src/lib/messages.ts`. User text stays literal, and exports retain
raw message text. Keep sanitization and unsafe-link regression coverage when raw message text. Keep sanitization and unsafe-link regression coverage when
changing it. changing it.
- Reuse the existing design tokens, Tailwind integration, and - Reuse the Tailwind tokens in `vocero-frontend/src/styles/theme.css` and shared
`vocero-frontend/src/styles/legacy-theme.css`. Verify both light and dark themes, controls in `vocero-frontend/src/components/ui.tsx`. Keep specialized artwork
and sanitized Markdown styles scoped under `vocero-frontend/src/styles/`.
Verify both light and dark themes,
narrow screens, keyboard focus, dialog dismissal, and voice-state feedback for narrow screens, keyboard focus, dialog dismissal, and voice-state feedback for
UI changes. UI changes.
Preserve intentional `?debug=true` controls and browser audio-permission flows. Preserve intentional `?debug=true` controls and browser audio-permission flows.
+3 -2
View File
@@ -57,8 +57,9 @@ 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 | | [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 | | [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 | | [audio](../../vocero-frontend/src/audio) | TypeScript capture/playback worklets, PCM codec, and visualizer |
| [legacy-theme.css](../../vocero-frontend/src/styles/legacy-theme.css) | Theme tokens, voice artwork and component appearance | | [theme.css](../../vocero-frontend/src/styles/theme.css) | Tailwind semantic tokens, configured theme variants and responsive variants |
| [layout.css](../../vocero-frontend/src/styles/layout.css) | Shared responsive layout, transcript sizing and reduced-motion behavior | | [ui.tsx](../../vocero-frontend/src/components/ui.tsx) | Shared Tailwind buttons, fields and dialog controls; layout utilities live with components |
| [styles](../../vocero-frontend/src/styles) | Minimal base defaults, reduced motion, audio artwork, sanitized Markdown, decorative surfaces and native range controls |
## Server routes ## Server routes
+37 -18
View File
@@ -1,7 +1,7 @@
# Design language # Design language
This guide describes the current browser UI. Use This guide describes the current browser UI. Use
[legacy-theme.css](../../vocero-frontend/src/styles/legacy-theme.css) as the token/selector [theme.css](../../vocero-frontend/src/styles/theme.css) as the Tailwind token
reference and [App.tsx](../../vocero-frontend/src/App.tsx) for the theme-specific structure. reference and [App.tsx](../../vocero-frontend/src/App.tsx) for the theme-specific structure.
Earlier documentation described a monochrome-only dark UI; the current design Earlier documentation described a monochrome-only dark UI; the current design
also uses aurora gradients, glass surfaces, and a separate light theme. also uses aurora gradients, glass surfaces, and a separate light theme.
@@ -10,8 +10,10 @@ also uses aurora gradients, glass surfaces, and a separate light theme.
`S2S_THEME=white` selects the white/blue interface. Empty or `dark` selects the `S2S_THEME=white` selects the white/blue interface. Empty or `dark` selects the
dark aurora interface. The browser sets `html[data-theme]` after configuration dark aurora interface. The browser sets `html[data-theme]` after configuration
loads; an initial visibility guard prevents the wrong theme flashing on first loads; React renders a loading screen until configuration resolves. Theme values
paint. Extend the existing theme selectors when adding components. live under `:root` and `html[data-theme="white"]`, with `@theme inline` exposing
semantic utilities. The `white:` and `dark:` variants follow this attribute,
independently of the browser's preferred color scheme.
| Token group | Dark theme values/purpose | | Token group | Dark theme values/purpose |
|---|---| |---|---|
@@ -20,14 +22,20 @@ paint. Extend the existing theme selectors when adding components.
| Text | `--text`, `--text-dim`, `--text-faint` | | Text | `--text`, `--text-dim`, `--text-faint` |
| Borders | `--border` and `--border-strong` hairlines | | Borders | `--border` and `--border-strong` hairlines |
| Brand | `--brand: #8b5cf6`, `--brand-2: #ec4899`, `--brand-3: #22d3ee` | | Brand | `--brand: #8b5cf6`, `--brand-2: #ec4899`, `--brand-3: #22d3ee` |
| Radius | `--radius-sm: 10px`, `--radius-md: 16px`, `--radius-lg: 26px`, `--radius-xl: 34px` | | Radius | `rounded-control: 10px`, `rounded-panel: 16px`, `rounded-card: 26px` (22px in white) |
| Shadows | `--shadow-soft`, `--shadow-glow-brand`, `--shadow-glass` | | Shadows | `--shadow-soft`, `--shadow-glow-brand`, `--shadow-glass` |
Use semantic utilities such as `bg-surface`, `bg-surface-raised`, `text-foreground`,
`text-muted`, `border-line`, `rounded-control` and `shadow-panel`. Palette values
are runtime CSS variables; fonts are declared in `@theme`. Keep audio levels and
waveform bands outside the theme token registry.
Prefer existing variables and `color-mix` over new unrelated colors. The dark Prefer existing variables and `color-mix` over new unrelated colors. The dark
theme's orb is the strongest visual focus, while gradients and restrained role theme's orb is the strongest visual focus, while gradients and restrained role
tints also appear in the surrounding interface. The white theme changes tints also appear in the surrounding interface. The white theme changes
surfaces, typography contrast, controls and chat presentation through its own surfaces, typography contrast, controls and chat presentation through its own
selector overrides rather than a second stylesheet. tokens and explicit component variants. The two voice presentations retain
their separate React markup and scoped artwork.
## Voice state ## Voice state
@@ -62,29 +70,40 @@ details out of ordinary user flows.
## Layout and components ## Layout and components
The page uses a column layout: header, voice stage, always-visible inline The page uses a column layout: header, voice stage, always-visible inline
conversation, and sticky footer. The conversation is no longer the old conversation, and footer. The document scrolls naturally while the transcript
toggleable right overlay. Preserve space below the latest message so the sticky has its own scroll area. New messages preserve the reader's position until they
footer does not obscure it; chat autoscroll retries after layout/reflow changes. return to the bottom or use the jump-to-latest action.
In the dark theme, the reports sidebar is fixed on the left at desktop widths In the dark theme, the reports sidebar is fixed on the left at desktop widths
of at least 1024px. On smaller screens it becomes an off-canvas panel with a of at least 1024px. On smaller screens it becomes a native dialog drawer with a
toggle and backdrop. The white theme hides the sidebar, its toggle, and hint. toggle and backdrop. The white theme omits the sidebar, its toggle, and hint.
The user avatar opens a profile dialog; profile fields collapse from two The user avatar opens a profile dialog with label/value rows. Some controls are
columns on narrow screens. Some controls are intentionally gated by `?debug=true`. intentionally gated by `?debug=true`.
Reuse [ui.tsx](../../vocero-frontend/src/components/ui.tsx) for buttons, icon buttons,
fields and dialog controls. Keep layout utilities with the owning component.
Use complete class names in conditional maps; interpolated utility names cannot
be detected reliably. `compact:` preserves the existing maximum width of 600px;
`short:` covers heights at or below 640px. Other artwork breakpoints remain local.
Use the existing button, icon-button, modal, switch and chat-message classes.
The dark theme uses glass bubbles with role tints, borders and restrained glow; The dark theme uses glass bubbles with role tints, borders and restrained glow;
the light theme uses its white/blue message and avatar treatment. Keep required the light theme uses its white/blue message and avatar treatment.
chat DOM elements intact; optional historical overlay controls may be absent.
The stylesheet entry point imports Tailwind's theme and utilities without
Preflight. Keep resets explicit in `base.css`; enabling Preflight is a separate
visual change. Custom CSS is reserved for the page background, audio artwork,
sanitized Markdown descendants, decorative masks and native range pseudo-elements.
Do not add model-supplied utility classes to the Markdown sanitizer.
## Motion and accessibility ## Motion and accessibility
Microphone RMS and assistant audio levels drive the voice visualization. Microphone RMS and assistant audio levels drive the voice visualization.
Breathing, glow and decorative gradients should remain subordinate to speech Breathing, glow and decorative gradients should remain subordinate to speech
feedback. `body.booting` suppresses initial transitions; new animation should feedback. Preserve the reduced-motion rules in `base.css`: they disable both
respect that state and `prefers-reduced-motion`. CSS animations and the audio-driven transform variables. Their `!important`
declarations intentionally override inline variables written by the audio engine.
For changes, verify both themes, narrow mobile layouts down to 360px, keyboard For changes, verify both themes, narrow mobile layouts down to 320px, keyboard
focus, dialog dismissal, and readable contrast. Prefer touch targets of at least focus, dialog dismissal, and readable contrast. Prefer touch targets of at least
44px and respect safe-area padding. A CSS guideline is not proof that every 44px and respect safe-area padding. A CSS guideline is not proof that every
existing control already meets the target. existing control already meets the target.
+5
View File
@@ -31,6 +31,11 @@ the implementation at the recorded date; current operating instructions live in
### Changed ### Changed
- Replace the frontend legacy stylesheet with Tailwind v4 semantic theme tokens,
shared form/dialog controls and component-owned layout utilities. Preserve both
configured themes, responsive layouts, sanitized Markdown and audio-driven
visuals in focused stylesheets, and remove unused historical selectors.
- Renamed the API and browser projects to `vocero-api/` and `vocero-frontend/`. - Renamed the API and browser projects to `vocero-api/` and `vocero-frontend/`.
Python uses the `vocero-api` distribution and `vocero_api` import package; Python uses the `vocero-api` distribution and `vocero_api` import package;
npm uses `vocero-frontend`. Updated the launcher, Docker setup, tooling, npm uses `vocero-frontend`. Updated the launcher, Docker setup, tooling,
+7 -4
View File
@@ -131,10 +131,13 @@ Vitest handles Node 26's experimental web storage behavior internally.
- `src/lib/`: validated backend contracts, settings, prompt context, profile - `src/lib/`: validated backend contracts, settings, prompt context, profile
and report presentation, Markdown sanitization, and conversation exports. and report presentation, Markdown sanitization, and conversation exports.
- `config/backend-proxy.ts`: the shared HTTP/WebSocket development proxy. - `config/backend-proxy.ts`: the shared HTTP/WebSocket development proxy.
- `src/styles.css`: Tailwind integration and React-specific layout adjustments. - `src/styles.css`: Tailwind layer/import entry point. Preflight remains disabled.
- `src/styles/legacy-theme.css`: the existing visual styles, retained for - `src/styles/theme.css`: semantic Tailwind tokens and server-selected theme
fidelity to both the white microphone and dark orb themes. The UI is ported variants. Use `bg-surface`, `text-foreground`, `border-line`, and related utilities.
to React; the full design has not been rewritten as Tailwind utilities. - `src/components/ui.tsx`: shared buttons, icon buttons, fields and dialog controls.
Components own their layout, responsive utilities and interaction styling.
- `src/styles/`: minimal element defaults and reduced motion, voice artwork,
sanitized Markdown, decorative surfaces and native range pseudo-elements.
The current Spanish interface, local preference keys, session-only RAG, The current Spanish interface, local preference keys, session-only RAG,
Markdown replies, JSON/TXT exports, optional browser wake word, TALK/TALKFIRST Markdown replies, JSON/TXT exports, optional browser wake word, TALK/TALKFIRST
+2 -2
View File
@@ -118,13 +118,13 @@ export default function App() {
</Dialog> </Dialog>
)} )}
<div id="app" className={`flex w-full min-w-0 flex-1 flex-col ${hasReports ? "lg:pl-[320px]" : ""}`}> <div id="app" className={`flex w-full min-w-0 flex-1 flex-col ${hasReports ? "lg:pl-[320px]" : ""}`}>
<header className="relative z-2 mt-[max(6px,calc(env(safe-area-inset-top,0px)*0.2))] flex w-full flex-none flex-wrap items-start justify-between gap-2 px-4 pt-4 white:static white:grid white:auto-rows-min white:grid-cols-[52px_minmax(0,1fr)_52px] white:justify-items-center white:gap-x-1 white:gap-y-0 white:px-2.5 white:pt-4 white:max-[480px]:px-1.5 compact:gap-3 [@media(max-height:560px)]:px-3.5 [@media(max-height:560px)]:pt-1.5 [@media(max-height:780px)]:px-5 [@media(max-height:780px)]:pt-3"> <header className="relative z-2 mt-[max(6px,calc(env(safe-area-inset-top,0px)*0.2))] flex w-full flex-none flex-wrap items-start justify-between gap-2 px-4 pt-4 white:static white:grid white:auto-rows-min white:grid-cols-[52px_minmax(0,1fr)_52px] white:justify-items-center white:gap-x-1 white:gap-y-0 white:px-2.5 white:pt-4 white:max-[480px]:px-1.5 dark:compact:gap-3 dark:[@media(560px<height<=780px)]:px-5 dark:[@media(560px<height<=780px)]:pt-3 dark:[@media(max-height:560px)]:px-3.5 dark:[@media(max-height:560px)]:pt-1.5">
<div className="brand-art relative flex min-w-0 flex-[1_1_360px] items-start gap-2.5 overflow-hidden rounded-panel border border-solid border-line px-3 py-2.5 text-[15px] font-semibold tracking-[0.01em] text-muted white:col-start-2 white:row-start-1 white:m-0 white:w-full white:flex-none white:justify-center white:rounded-none white:border-0 white:p-0 white:text-center compact:text-[14px]"> <div className="brand-art relative flex min-w-0 flex-[1_1_360px] items-start gap-2.5 overflow-hidden rounded-panel border border-solid border-line px-3 py-2.5 text-[15px] font-semibold tracking-[0.01em] text-muted white:col-start-2 white:row-start-1 white:m-0 white:w-full white:flex-none white:justify-center white:rounded-none white:border-0 white:p-0 white:text-center compact:text-[14px]">
<div className="flex w-full min-w-0 items-center justify-between gap-3 white:flex-col white:justify-center white:gap-0.5 white:px-1"> <div className="flex w-full min-w-0 items-center justify-between gap-3 white:flex-col white:justify-center white:gap-0.5 white:px-1">
<div className="flex items-center gap-[9px] white:mx-auto white:w-full white:flex-col white:justify-center white:gap-0.5"> <div className="flex items-center gap-[9px] white:mx-auto white:w-full white:flex-col white:justify-center white:gap-0.5">
<h1 className="m-0 leading-none"> <h1 className="m-0 leading-none">
<a <a
className="block border-0 bg-(image:--brand-gradient) bg-clip-text text-[24px] leading-[1.1] font-semibold tracking-[0.005em] text-transparent hover:underline hover:underline-offset-2 white:bg-none white:text-[clamp(24px,6vw,32px)] white:leading-[1.05] white:font-extrabold white:tracking-[-0.02em] white:whitespace-nowrap white:text-foreground compact:text-[22px] [@media(max-height:560px)]:text-[17px] [@media(max-height:780px)]:text-[20px]" className="block border-0 bg-(image:--brand-gradient) bg-clip-text text-[24px] leading-[1.1] font-semibold tracking-[0.005em] text-transparent hover:underline hover:underline-offset-2 white:bg-none white:text-[clamp(24px,6vw,32px)] white:leading-[1.05] white:font-extrabold white:tracking-[-0.02em] white:whitespace-nowrap white:text-foreground dark:compact:text-[22px] dark:[@media(560px<height<=780px)]:text-[20px] dark:[@media(max-height:560px)]:text-[17px]"
href={window.location.pathname + window.location.search} href={window.location.pathname + window.location.search}
> >
Vocero Vocero
@@ -73,10 +73,11 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
{settings.noiseGate <= GATE_OFF ? "Desactivada" : `${String(settings.noiseGate)} dB`} {settings.noiseGate <= GATE_OFF ? "Desactivada" : `${String(settings.noiseGate)} dB`}
</span> </span>
</label> </label>
<div className="gate"> <div className="flex flex-col gap-1">
<div className="gate-track"> <div className="relative flex h-3.5 items-center before:absolute before:inset-x-0 before:h-2 before:rounded-full before:border before:border-solid before:border-line before:bg-surface before:content-['']">
<GateMeter controller={controller} /> <GateMeter controller={controller} />
<input <input
className="gate-range relative z-1 m-0 h-3.5 w-full cursor-pointer appearance-none border-0 bg-transparent p-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
id="noise-gate" id="noise-gate"
aria-label="Umbral de la puerta de ruido" aria-label="Umbral de la puerta de ruido"
type="range" type="range"
@@ -89,7 +90,7 @@ export function SettingsDialog({ state, controller, onClose }: Props) {
}} }}
/> />
</div> </div>
<div className="gate-ends"> <div className="flex justify-between text-[11px] text-faint">
<span>Desactivada</span> <span>Desactivada</span>
<span>−3 dB</span> <span>−3 dB</span>
</div> </div>
@@ -151,7 +152,13 @@ function GateMeter({ controller }: { controller: VoiceController }) {
0, 0,
Math.min(100, ((20 * Math.log10(level || 0.00001) - GATE_OFF) * 100) / (GATE_MAX - GATE_OFF)), Math.min(100, ((20 * Math.log10(level || 0.00001) - GATE_OFF) * 100) / (GATE_MAX - GATE_OFF)),
); );
return <div className="gate-meter-fill" style={{ width: `${String(meter)}%` }} aria-hidden="true" />; return (
<div
className="pointer-events-none absolute top-1/2 left-px h-1.5 -translate-y-1/2 rounded-full bg-accent-secondary transition-[width] duration-[60ms] ease-linear"
style={{ width: `${String(meter)}%` }}
aria-hidden="true"
/>
);
} }
export function RagDialog({ state, controller, onClose }: Props) { export function RagDialog({ state, controller, onClose }: Props) {
+31 -18
View File
@@ -4,6 +4,7 @@ import { useEffect, useState, useSyncExternalStore, type CSSProperties, type Poi
import type { ConnectionStatus } from "../lib/contracts"; import type { ConnectionStatus } from "../lib/contracts";
import { GATE_MAX, GATE_OFF } from "../lib/settings"; import { GATE_MAX, GATE_OFF } from "../lib/settings";
import { ACTIVE_STATES, LIVE_STATES, type AppState, type VoiceController } from "../realtime/controller"; import { ACTIVE_STATES, LIVE_STATES, type AppState, type VoiceController } from "../realtime/controller";
import { Button } from "./ui";
const CAPTIONS: Record<ConnectionStatus, string> = { const CAPTIONS: Record<ConnectionStatus, string> = {
idle: "Toca para empezar", idle: "Toca para empezar",
@@ -41,7 +42,7 @@ function GateArc({
} }
return ( return (
<svg <svg
className="mic-gate-arc" className="mic-gate-arc rounded-full focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent-secondary"
viewBox="0 0 100 100" viewBox="0 0 100 100"
role="slider" role="slider"
tabIndex={0} tabIndex={0}
@@ -179,9 +180,13 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
}; };
const disabled = !ready || (active && status !== "your-turn"); const disabled = !ready || (active && status !== "your-turn");
return ( return (
<main className="stage"> <main className="stage relative z-1 flex min-h-0 flex-none flex-col items-center justify-start gap-3 px-3 pt-3 pb-2 short:py-2">
{white ? ( {white ? (
<div className={`white-mic-stage ${live ? "live" : ""}`} role="group" aria-label="Control de voz"> <div
className={`white-mic-stage relative flex flex-col items-center gap-4 pt-[22px] pb-1.5 compact:gap-3 compact:pt-4 short:gap-2 short:pt-2 ${live ? "live" : ""}`}
role="group"
aria-label="Control de voz"
>
<Waveform side="left" /> <Waveform side="left" />
<button <button
id="white-mic-circle" id="white-mic-circle"
@@ -198,15 +203,24 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
<span className="wmc-icon wmc-spinner" aria-hidden="true" /> <span className="wmc-icon wmc-spinner" aria-hidden="true" />
</button> </button>
<Waveform side="right" /> <Waveform side="right" />
<p className="white-mic-caption" role="status"> <p
className="m-0 text-center text-[clamp(17px,4.2vw,20px)] leading-[1.25] font-semibold tracking-[-0.005em] text-accent short:text-[16px]"
role="status"
>
{caption} {caption}
</p> </p>
{state.notice && <p className="white-mic-subcaption">{state.notice}</p>} {state.notice && (
<p className="m-0 max-w-[440px] text-center text-[13px] leading-[1.5] text-slate-600">{state.notice}</p>
)}
{active && ( {active && (
<div className="white-session-controls" role="group" aria-label="Controles de la conversación"> <div
className="flex max-w-full flex-wrap items-center justify-center gap-2.5 compact:gap-2 [&_svg]:size-[18px] [&_svg]:shrink-0"
role="group"
aria-label="Controles de la conversación"
>
{live && ( {live && (
<button <button
className={`white-mute-btn ${state.muted ? "muted" : ""}`} className="inline-flex min-h-11 cursor-pointer items-center justify-center gap-2 rounded-full border border-solid border-slate-300 bg-surface px-4 py-2.5 text-[clamp(14px,3.4vw,16px)] font-semibold text-speaking transition-colors hover:border-blue-300 hover:bg-blue-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent aria-pressed:border-amber-300 aria-pressed:bg-amber-50 aria-pressed:text-amber-900 aria-pressed:hover:bg-amber-100 compact:gap-1.5 compact:px-2.5 compact:py-2 compact:text-[13px]"
type="button" type="button"
onClick={() => { onClick={() => {
controller.toggleMuted(); controller.toggleMuted();
@@ -219,7 +233,7 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
</button> </button>
)} )}
<button <button
className="white-stop-btn" className="inline-flex min-h-11 cursor-pointer items-center gap-2.5 rounded-full border-0 bg-slate-200 py-2.5 pr-[22px] pl-4 text-[clamp(14px,3.4vw,16px)] font-semibold tracking-[-0.005em] text-foreground shadow-[0_2px_8px_rgb(15_23_42/0.06)] transition-colors hover:bg-slate-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent compact:gap-1.5 compact:px-2.5 compact:py-2 compact:text-[13px]"
type="button" type="button"
onClick={() => { onClick={() => {
void controller.stop(); void controller.stop();
@@ -233,7 +247,9 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
</div> </div>
) : ( ) : (
<> <>
<div className={`orb-wrap ${active ? "live" : ""}`}> <div
className={`orb-wrap relative flex items-center justify-center gap-[clamp(18px,3vw,32px)] compact:flex-row compact:gap-[18px] ${active ? "live" : ""}`}
>
<MicrophoneGate <MicrophoneGate
controller={controller} controller={controller}
threshold={state.settings.noiseGate} threshold={state.settings.noiseGate}
@@ -290,32 +306,29 @@ export function VoiceStage({ state, controller }: { state: AppState; controller:
</> </>
)} )}
{(status === "queued" || status === "your-turn") && ( {(status === "queued" || status === "your-turn") && (
<div className="queue-actions"> <div className="flex flex-wrap items-center justify-center gap-3 [&_.circle-subcaption]:basis-full">
{status === "queued" ? ( {status === "queued" ? (
<p className="circle-subcaption">Posición en la cola: {state.queuePosition}</p> <p className="circle-subcaption">Posición en la cola: {state.queuePosition}</p>
) : ( ) : (
<> <>
<JoinCountdown deadline={state.joinDeadline} /> <JoinCountdown deadline={state.joinDeadline} />
<button <Button
className="join-queue-btn" variant="primary"
type="button"
onClick={() => { onClick={() => {
controller.join(); controller.join();
}} }}
> >
Entrar ahora Entrar ahora
</button> </Button>
</> </>
)} )}
<button <Button
className="leave-queue-btn"
type="button"
onClick={() => { onClick={() => {
void controller.stop(); void controller.stop();
}} }}
> >
Salir de la cola Salir de la cola
</button> </Button>
</div> </div>
)} )}
</main> </main>
+4 -149
View File
@@ -1,154 +1,9 @@
@layer theme, base, components, utilities; @layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme); @import "tailwindcss/theme.css" layer(theme);
@import "./styles/theme.css"; @import "./styles/theme.css";
@import "./styles/legacy-theme.css" layer(components); @import "./styles/base.css" layer(base);
@import "./styles/layout.css" layer(components); @import "./styles/voice-stage.css" layer(components);
@import "./styles/markdown.css" layer(components);
@import "./styles/surfaces.css" layer(components); @import "./styles/surfaces.css" layer(components);
@import "./styles/range.css" layer(components);
@import "tailwindcss/utilities.css" layer(utilities); @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;
}
#root {
display: contents;
}
.state-idle .ind-mic {
opacity: 1;
transform: scale(1);
}
html[data-theme="white"] .topbar-right[data-debug-enabled="true"] {
display: flex;
grid-column: 1 / -1;
grid-row: 2;
flex-wrap: wrap;
justify-content: center;
margin-top: 12px;
}
html[data-theme="white"] .white-mic-circle svg.wmc-mic {
fill: none;
}
.loading-screen {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background: white;
color: #334155;
font: 500 16px system-ui;
z-index: 1000;
}
.startup-error {
max-width: 32rem;
padding: 24px;
text-align: center;
}
.app-error {
display: flex;
align-items: center;
gap: 12px;
margin: 12px auto;
padding: 10px 16px;
max-width: 680px;
border: 1px solid var(--error);
border-radius: 12px;
font-size: 13px;
background: var(--bg-elev);
}
.agent-tool-item {
padding: 12px 0;
border-bottom: 1px solid var(--border);
overflow-wrap: anywhere;
}
.queue-actions {
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.queue-actions .circle-subcaption {
flex-basis: 100%;
}
.reports-panel {
position: fixed;
inset: 0 auto 0 0;
width: 320px;
overflow-y: auto;
box-sizing: border-box;
padding: 20px;
background: var(--bg-elev);
border-right: 1px solid var(--border);
z-index: 3;
}
.reports-panel h2 {
margin: 0 0 16px;
font-size: 18px;
}
.reports-drawer.modal {
margin: 0;
width: min(360px, 92vw);
max-height: 100dvh;
height: 100dvh;
border-radius: 0;
}
.reports-content .reports-hint {
margin: 0;
max-height: none;
padding: 12px;
}
.wake-word-control {
display: grid;
gap: 4px;
padding: 0 16px 12px;
text-align: center;
font-size: 13px;
}
.wake-word-control label {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
}
.wake-word-control span {
font-size: 12px;
color: var(--text-dim);
}
html[data-theme="white"] .wake-word-control span {
color: #64748b;
}
.bubble-body {
max-height: 240px;
overflow: auto;
}
.white-session-controls svg {
width: 18px;
height: 18px;
}
.mic-gate-arc:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 4px;
border-radius: 50%;
}
.user-profile-row {
display: grid;
grid-template-columns: 90px 1fr;
gap: 16px;
padding: 12px 0;
}
.user-profile-value {
margin: 0;
overflow-wrap: anywhere;
}
.user-profile-label {
color: var(--text-dim);
}
.hist-tool-block {
margin: 0;
}
}
+128
View File
@@ -0,0 +1,128 @@
/* Minimal element defaults and page background. Preflight is intentionally not enabled. */
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
body {
display: flex;
flex-direction: column;
height: auto;
padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) 0 max(8px, env(safe-area-inset-left));
font-family: var(--font-sans);
color: var(--text);
min-height: 100vh;
min-height: 100svh;
min-height: 100dvh;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
position: relative;
background:
radial-gradient(1200px 800px at 15% -10%, rgba(139, 92, 246, 0.3) 0%, transparent 60%),
radial-gradient(900px 700px at 110% 10%, rgba(236, 72, 153, 0.22) 0%, transparent 55%),
radial-gradient(1000px 800px at 50% 120%, rgba(34, 211, 238, 0.22) 0%, transparent 60%),
radial-gradient(ellipse at 50% 50%, #0e1019 0%, var(--bg) 65%);
background-attachment: fixed;
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
radial-gradient(rgba(255, 255, 255, 0.045) 1px, transparent 1px),
radial-gradient(rgba(139, 92, 246, 0.035) 1px, transparent 1px);
background-size:
42px 42px,
42px 42px;
background-position:
0 0,
21px 21px;
mix-blend-mode: screen;
opacity: 0.55;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.4) 70%, transparent 100%);
}
body::after {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(600px 600px at 20% 80%, rgba(139, 92, 246, 0.1), transparent 70%),
radial-gradient(500px 500px at 80% 20%, rgba(34, 211, 238, 0.08), transparent 70%);
animation: auroraShift 22s ease-in-out infinite alternate;
}
button {
font-family: inherit;
}
a {
color: var(--text);
text-decoration: none;
border-bottom: 1px solid var(--border-strong);
}
a:hover {
border-bottom-color: var(--text);
}
html[data-theme="white"] body {
background: var(--bg);
color: var(--text);
background-image: none;
}
html[data-theme="white"] body::before,
html[data-theme="white"] body::after {
display: none;
background-image: none;
animation: none;
}
#root {
display: contents;
}
@keyframes auroraShift {
0% {
transform: translate3d(0, 0, 0) scale(1);
opacity: 0.9;
}
50% {
transform: translate3d(3%, -2%, 0) scale(1.08);
opacity: 1;
}
100% {
transform: translate3d(-2%, 2%, 0) scale(1.02);
opacity: 0.85;
}
}
@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;
}
}
-298
View File
@@ -1,298 +0,0 @@
/* 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
+123
View File
@@ -0,0 +1,123 @@
/* Scoped styles for sanitized HTML produced by Marked; model content never supplies utility classes. */
.markdown-content {
min-width: 0;
max-width: 100%;
box-sizing: border-box;
white-space: normal;
overflow-wrap: anywhere;
}
.markdown-content > :first-child {
margin-top: 0;
}
.markdown-content > :last-child {
margin-bottom: 0;
}
.markdown-content p {
margin: 0 0 0.75em;
}
.markdown-content strong {
font-weight: 700;
}
.markdown-content :is(h1, h2, h3, h4, h5, h6) {
margin: 1em 0 0.5em;
font-size: 1.12em;
font-weight: 700;
line-height: 1.35;
}
.markdown-content :is(ul, ol) {
margin: 0.65em 0;
padding-inline-start: 1.5em;
}
.markdown-content ul {
list-style-type: disc;
}
.markdown-content ol {
list-style-type: decimal;
}
.markdown-content li + li {
margin-top: 0.35em;
}
.markdown-content li > p {
margin: 0.25em 0;
}
.markdown-content li :is(ul, ol) {
margin: 0.3em 0;
}
.markdown-content a {
color: var(--brand-3);
text-decoration: underline;
text-underline-offset: 0.15em;
}
html[data-theme="white"] .markdown-content a {
color: var(--speaking);
}
.markdown-content :is(a, pre, .markdown-table):focus-visible {
outline: 2px solid var(--brand);
outline-offset: 3px;
}
.markdown-content blockquote {
margin: 0.75em 0;
padding-inline-start: 12px;
border-inline-start: 3px solid var(--brand);
color: var(--text-dim);
}
.markdown-content code {
padding: 0.15em 0.35em;
border-radius: 4px;
background: color-mix(in srgb, currentColor 8%, transparent);
font-family: var(--font-mono);
font-size: 0.9em;
}
.markdown-content pre {
max-width: 100%;
margin: 0.75em 0;
padding: 12px;
border: 1px solid var(--border-strong);
border-radius: 8px;
overflow: auto;
white-space: pre;
}
.markdown-content pre code {
display: block;
padding: 0;
background: transparent;
white-space: pre;
overflow-wrap: normal;
word-break: normal;
}
.markdown-content hr {
margin: 1em 0;
border: 0;
border-top: 1px solid var(--border-strong);
}
.markdown-content img {
max-width: 100%;
height: auto;
}
.markdown-content .markdown-table {
max-width: 100%;
margin: 0.75em 0;
overflow-x: auto;
overscroll-behavior-x: contain;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
font-size: 0.92em;
}
.markdown-content :is(th, td) {
padding: 7px 10px;
border: 1px solid var(--border-strong);
text-align: start;
vertical-align: top;
}
.markdown-content th {
font-weight: 700;
}
.markdown-content [align="center"] {
text-align: center;
}
.markdown-content [align="right"] {
text-align: end;
}
+26
View File
@@ -0,0 +1,26 @@
/* Native range pseudo-elements cannot share a selector across browser engines. */
.gate-range::-webkit-slider-runnable-track {
height: 14px;
background: transparent;
}
.gate-range::-moz-range-track {
height: 14px;
background: transparent;
}
.gate-range::-webkit-slider-thumb {
appearance: none;
width: 6px;
height: 18px;
border-radius: 3px;
background: var(--text);
border: 2px solid var(--bg-elev);
box-shadow: 0 0 0 1px var(--border-strong);
}
.gate-range::-moz-range-thumb {
width: 6px;
height: 18px;
border-radius: 3px;
background: var(--text);
border: 2px solid var(--bg-elev);
box-shadow: 0 0 0 1px var(--border-strong);
}
+9 -17
View File
@@ -26,9 +26,9 @@
--color-voice-user: var(--voice-user); --color-voice-user: var(--voice-user);
--color-voice-assistant: var(--voice-assistant); --color-voice-assistant: var(--voice-assistant);
--color-voice-tool: var(--voice-tool); --color-voice-tool: var(--voice-tool);
--radius-control: var(--radius-sm); --radius-control: var(--app-radius-control);
--radius-panel: var(--radius-md); --radius-panel: var(--app-radius-panel);
--radius-card: var(--radius-lg); --radius-card: var(--app-radius-card);
--shadow-panel: var(--shadow-soft); --shadow-panel: var(--shadow-soft);
--shadow-brand: var(--shadow-glow-brand); --shadow-brand: var(--shadow-glow-brand);
--shadow-card-glass: var(--shadow-glass); --shadow-card-glass: var(--shadow-glass);
@@ -73,17 +73,10 @@
--voice-user: var(--accent-2); --voice-user: var(--accent-2);
--voice-assistant: var(--accent); --voice-assistant: var(--accent);
--voice-tool: var(--processing); --voice-tool: var(--processing);
--audio-level: 0;
--bar0: 0;
--bar1: 0;
--bar2: 0;
--bar3: 0;
--bar4: 0;
--radius-sm: 10px; --app-radius-control: 10px;
--radius-md: 16px; --app-radius-panel: 16px;
--radius-lg: 26px; --app-radius-card: 26px;
--radius-xl: 34px;
--shadow-soft: 0 10px 40px rgba(0, 0, 0, 0.35); --shadow-soft: 0 10px 40px rgba(0, 0, 0, 0.35);
--shadow-glow-brand: --shadow-glow-brand:
@@ -161,9 +154,8 @@
--message-user-shadow: none; --message-user-shadow: none;
--message-assistant-shadow: none; --message-assistant-shadow: none;
--radius-sm: 10px; --app-radius-control: 10px;
--radius-md: 16px; --app-radius-panel: 16px;
--radius-lg: 22px; --app-radius-card: 22px;
--radius-xl: 28px;
} }
} }
+986
View File
@@ -0,0 +1,986 @@
/* Audio-reactive artwork. Runtime CSS variables are written by the audio engine, outside React. */
:root {
--audio-level: 0;
--ai-audio-level: 0;
--bar0: 0;
--bar1: 0;
--bar2: 0;
--bar3: 0;
--bar4: 0;
}
.stage::before {
content: "";
position: absolute;
top: 46%;
left: 50%;
width: clamp(260px, 58vw, 620px);
height: clamp(260px, 58vw, 620px);
transform: translate(-50%, -50%);
background: radial-gradient(
circle,
color-mix(in srgb, var(--brand) 22%, transparent) 0%,
color-mix(in srgb, var(--brand-2) 12%, transparent) 45%,
transparent 75%
);
filter: blur(12px);
opacity: 0.65;
pointer-events: none;
border-radius: 999px;
animation: pedestalBreathe 8s ease-in-out infinite alternate;
}
.circle {
position: relative;
width: clamp(120px, min(38svw, 42svh), 300px);
aspect-ratio: 1 / 1;
border-radius: 50%;
border: none;
background: transparent;
padding: 0;
cursor: pointer;
outline: none;
display: grid;
place-items: center;
color: var(--glow, var(--accent));
transition:
transform 0.18s ease,
filter 0.18s ease;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.circle::before {
content: "";
position: absolute;
inset: -8%;
border-radius: 50%;
background: conic-gradient(
from 0deg,
color-mix(in srgb, var(--brand) 65%, transparent) 0%,
color-mix(in srgb, var(--brand-2) 55%, transparent) 35%,
color-mix(in srgb, var(--brand-3) 55%, transparent) 65%,
color-mix(in srgb, var(--brand) 65%, transparent) 100%
);
filter: blur(14px);
opacity: 0.55;
z-index: 0;
pointer-events: none;
animation: orbConicSpin 18s linear infinite;
}
.circle:hover {
filter: brightness(1.08);
}
.circle:hover::before {
opacity: 0.8;
}
.circle:active {
transform: scale(0.97);
filter: brightness(0.92);
}
.circle:focus-visible .circle-core {
outline: 2px solid var(--glow, var(--accent));
outline-offset: 6px;
}
.circle[disabled] {
cursor: default;
opacity: 0.75;
}
.circle-glow {
position: absolute;
inset: -8%;
border-radius: 50%;
background: radial-gradient(circle at center, var(--glow, var(--accent)) 0%, transparent 60%);
filter: blur(34px);
opacity: 0.55;
transform: scale(1);
transition:
opacity 0.25s,
background 0.25s,
transform 1.4s ease-in-out;
pointer-events: none;
z-index: 0;
}
.circle.state-idle .circle-glow,
.circle.state-idle::before {
animation:
orbConicSpin 22s linear infinite,
orbBreatheGlow 5s ease-in-out infinite alternate;
}
.circle-ring,
.circle-ring-outer {
position: absolute;
top: 50%;
left: 50%;
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
transition:
opacity 0.4s ease,
border-color 0.4s ease,
transform 0.3s ease;
z-index: 1;
}
.circle-ring {
width: 86%;
height: 86%;
border: 1.5px solid transparent;
background:
linear-gradient(var(--bg, #07080d), var(--bg, #07080d)) padding-box,
conic-gradient(
from 0deg,
color-mix(in srgb, var(--brand) 80%, transparent),
color-mix(in srgb, var(--brand-2) 70%, transparent) 40%,
color-mix(in srgb, var(--brand-3) 75%, transparent) 70%,
color-mix(in srgb, var(--brand) 80%, transparent)
)
border-box;
border-color: transparent;
mask:
linear-gradient(#000, #000) content-box,
linear-gradient(#000, #000);
-webkit-mask:
linear-gradient(#000, #000) content-box,
linear-gradient(#000, #000);
-webkit-mask-composite: xor;
mask-composite: exclude;
box-shadow: 0 0 20px -6px color-mix(in srgb, var(--brand) 55%, transparent);
opacity: 0.75;
}
.circle-ring-outer {
width: 98%;
height: 98%;
border: 1px dashed color-mix(in srgb, var(--glow, var(--accent)) 30%, transparent);
opacity: 0.55;
}
.circle.state-idle .circle-ring-outer {
animation: outerRingPulse 6s ease-in-out infinite;
}
.circle-core {
position: relative;
width: 72%;
height: 72%;
border-radius: 50%;
background:
radial-gradient(circle at 30% 22%, rgba(255, 255, 255, 0.18) 0%, rgba(255, 255, 255, 0.04) 20%, transparent 45%),
radial-gradient(
circle at 35% 28%,
color-mix(in srgb, var(--glow, var(--accent)) 32%, transparent),
color-mix(in srgb, var(--glow, var(--accent)) 12%, transparent) 55%,
color-mix(in srgb, var(--glow, var(--accent)) 5%, transparent)
);
border: 1.5px solid color-mix(in srgb, var(--glow, var(--accent)) 48%, transparent);
display: grid;
place-items: center;
box-shadow:
0 0 44px -8px color-mix(in srgb, var(--glow, var(--accent)) 42%, transparent),
inset 0 0 34px color-mix(in srgb, var(--glow, var(--accent)) 22%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
transition:
background 0.4s ease,
border-color 0.4s ease,
box-shadow 0.4s ease,
transform 0.4s ease;
z-index: 2;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.circle.state-idle .circle-core {
animation: coreBreathe 5s ease-in-out infinite alternate;
}
.circle-indicator {
position: relative;
width: 44%;
height: 44%;
display: grid;
place-items: center;
color: var(--glow, var(--accent));
}
.circle-indicator > .ind {
grid-area: 1 / 1;
opacity: 0;
transform: scale(0.85);
transition:
opacity 0.25s ease,
transform 0.25s ease;
pointer-events: none;
}
.circle-indicator > svg.ind {
width: 60%;
height: 60%;
}
.ind-spinner {
width: 48%;
height: 48%;
border: 3px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
opacity: 0;
animation: ind-spin 0.9s linear infinite;
}
.ind-thinking {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 60%;
height: 60%;
}
.ind-thinking .dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: currentColor;
opacity: 0.3;
animation: thinking-dot 1.25s ease-in-out infinite;
}
.ind-thinking .dot:nth-child(1) {
animation-delay: 0s;
}
.ind-thinking .dot:nth-child(2) {
animation-delay: 0.16s;
}
.ind-thinking .dot:nth-child(3) {
animation-delay: 0.32s;
}
.ind-bars {
display: flex;
align-items: center;
gap: 5px;
height: 42%;
}
.ind-bars .bar {
width: 4px;
min-height: 4px;
border-radius: 3px;
background: currentColor;
opacity: 0.7;
--h: var(--bar0, 0);
height: calc(4px + var(--h) * 36px);
transition:
height 0.08s ease-out,
opacity 0.08s ease-out;
}
.ind-bars .bar:nth-child(1) {
--h: var(--bar0);
}
.ind-bars .bar:nth-child(2) {
--h: var(--bar1);
}
.ind-bars .bar:nth-child(3) {
--h: var(--bar2);
}
.ind-bars .bar:nth-child(4) {
--h: var(--bar3);
}
.ind-bars .bar:nth-child(5) {
--h: var(--bar4);
}
.ind-bars .bar {
opacity: calc(0.55 + 0.45 * var(--h));
}
.state-connecting .ind-spinner,
.state-queued .ind-spinner,
.state-processing .ind-thinking,
.state-listening .ind-bars,
.state-user-speaking .ind-bars,
.state-ai-speaking .ind-voice,
.state-error .ind-error {
opacity: 1;
transform: scale(1);
}
.ind-mic {
color: color-mix(in srgb, var(--glow, var(--accent)) 85%, white);
}
.circle-caption {
margin: 0;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--text-faint);
min-height: 1.3em;
text-align: center;
opacity: 0.78;
transition:
opacity 0.25s ease,
color 0.25s ease,
transform 0.25s ease,
min-height 0.25s ease,
background 0.3s ease,
filter 0.3s ease;
line-height: 1.2;
}
.circle-caption:empty::before {
content: "\00a0";
}
.stage:has(.circle.state-idle) .circle-caption {
background: var(--brand-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
opacity: 1;
filter: drop-shadow(0 2px 10px color-mix(in srgb, var(--brand) 40%, transparent));
animation: idleCaptionBob 3.6s ease-in-out infinite alternate;
}
.circle-subcaption {
margin: 8px 0 0;
max-width: 32ch;
font-size: 13.5px;
line-height: 1.5;
color: var(--text-dim);
text-align: center;
text-wrap: balance;
transition: opacity 0.25s ease;
}
.mic-gate {
position: relative;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mic-gate-arc {
position: absolute;
left: 50%;
top: 50%;
width: 80px;
height: 80px;
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
transition: opacity 0.25s ease;
}
.orb-wrap.live .mic-gate-arc {
opacity: 1;
}
.mga-track {
stroke: rgba(255, 255, 255, 0.1);
stroke-width: 1.5;
stroke-linecap: round;
}
.mga-fill {
stroke: var(--accent-2);
stroke-width: 2;
stroke-linecap: round;
opacity: 0.85;
transition: stroke-dashoffset 0.06s linear;
}
.mga-handle {
fill: var(--text);
stroke: none;
transition:
fill 0.2s ease,
filter 0.2s ease;
}
.mic-gate.gate-open .mga-handle {
fill: var(--accent-2);
filter: drop-shadow(0 0 3px var(--accent-2));
}
.mga-hit {
stroke: transparent;
stroke-width: 16;
pointer-events: none;
cursor: pointer;
touch-action: none;
}
.orb-wrap.live .mga-hit {
pointer-events: stroke;
}
.side-btn {
flex: none;
width: 52px;
height: 52px;
border-radius: 50%;
border: 1px solid var(--border-strong);
background: color-mix(in srgb, var(--bg-elev-2) 80%, #2a2e3c);
color: var(--text);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
touch-action: manipulation;
box-shadow:
0 6px 16px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
opacity: 0;
transform: scale(0.55);
width: 0;
padding: 0;
pointer-events: none;
overflow: hidden;
transition:
opacity 0.25s ease,
transform 0.25s ease,
width 0.25s ease,
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.side-btn:hover {
background: color-mix(in srgb, var(--bg-elev-2) 60%, #323746);
border-color: var(--text-dim);
}
.side-btn svg {
width: 22px;
height: 22px;
flex: none;
}
.side-btn.muted {
color: #fff;
border-color: var(--error);
background: color-mix(in srgb, var(--error) 70%, #1a0e13);
}
.orb-wrap.live .side-btn {
opacity: 1;
transform: scale(1);
width: 52px;
pointer-events: auto;
}
.circle.state-connecting {
--glow: #facc15;
}
.circle.state-queued {
--glow: #94a3b8;
}
.circle.state-queued .ind-spinner {
animation-duration: 2.4s;
}
.circle.state-queued .circle-ring {
animation: breathe 2.6s ease-in-out infinite;
}
.circle.state-your-turn {
--glow: var(--accent);
}
.circle.state-your-turn .circle-ring {
animation: breathe 1.6s ease-in-out infinite;
}
.circle.state-listening,
.circle.state-user-speaking {
--glow: var(--listening);
}
.circle.state-processing {
--glow: var(--processing);
}
.circle.state-ai-speaking {
--glow: var(--speaking);
}
.circle.state-error {
--glow: var(--error);
}
.circle.state-connecting .circle-core {
animation: thinking 1.4s ease-in-out infinite;
}
.circle.state-user-speaking .circle-ring {
animation: none;
opacity: calc(0.25 + 0.55 * var(--audio-level));
transform: translate(-50%, -50%) scale(calc(1 + 0.08 * var(--audio-level)));
transition:
transform 0.08s linear,
opacity 0.08s linear;
}
.circle.state-user-speaking .circle-ring-outer {
opacity: calc(0.1 + 0.35 * var(--audio-level));
transform: translate(-50%, -50%) scale(calc(1 + 0.12 * var(--audio-level)));
transition:
transform 0.08s linear,
opacity 0.08s linear;
}
.circle.state-listening .circle-ring {
animation: breathe 2s ease-in-out infinite;
}
.circle.state-ai-speaking .circle-core {
animation: none;
transform: scale(calc(1 + 0.09 * var(--ai-audio-level, 0)));
transition: transform 0.08s linear;
}
.circle.state-ai-speaking .circle-ring {
animation: none;
opacity: calc(0.3 + 0.5 * var(--ai-audio-level, 0));
transform: translate(-50%, -50%) scale(calc(1 + 0.05 * var(--ai-audio-level, 0)));
transition:
transform 0.08s linear,
opacity 0.08s linear;
}
.circle.state-ai-speaking .circle-ring-outer {
animation: none;
opacity: calc(0.15 + 0.55 * var(--ai-audio-level, 0));
transform: translate(-50%, -50%) scale(calc(1 + 0.18 * var(--ai-audio-level, 0)));
transition:
transform 0.08s linear,
opacity 0.08s linear;
}
.circle.state-ai-speaking .circle-glow {
opacity: calc(0.35 + 0.45 * var(--ai-audio-level, 0));
transition: opacity 0.08s linear;
}
@media (max-width: 600px) {
.circle {
width: clamp(110px, min(56svw, 36svh), 220px);
}
.side-btn {
width: 44px;
height: 0;
transition:
opacity 0.25s ease,
transform 0.25s ease,
height 0.25s ease,
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.side-btn svg {
width: 19px;
height: 19px;
}
.orb-wrap.live .side-btn {
width: 44px;
height: 44px;
}
.mic-gate-arc {
width: 70px;
height: 70px;
}
}
@media (min-width: 601px) {
.circle-caption {
font-size: 12px;
}
}
@media (max-height: 780px) {
.circle {
width: clamp(130px, min(36svw, 38svh), 280px);
}
}
@media (max-height: 560px) {
.circle {
width: clamp(90px, min(40svw, 34svh), 200px);
}
}
html[data-theme="white"] .white-mic-circle {
position: relative;
z-index: 2;
width: 116px;
height: 116px;
border-radius: 999px;
border: none;
padding: 0;
cursor: pointer;
background: #ffffff;
color: #ffffff;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
overflow: visible;
flex: none;
transition:
transform 180ms cubic-bezier(0.2, 0.7, 0.2, 1),
box-shadow 180ms ease;
}
html[data-theme="white"] .white-mic-circle .wmc-halo {
position: absolute;
inset: -22px;
border-radius: 999px;
background: radial-gradient(closest-side, rgba(59, 130, 246, 0.22), rgba(59, 130, 246, 0.1) 55%, transparent 75%);
z-index: 0;
pointer-events: none;
animation: wmcHaloPulse 3.2s ease-in-out infinite alternate;
}
html[data-theme="white"] .white-mic-circle .wmc-ring {
position: absolute;
inset: 0;
border-radius: 999px;
padding: 4px;
background: linear-gradient(135deg, #2563eb 0%, #3b82f6 45%, #60a5fa 100%);
z-index: 0;
pointer-events: none;
box-sizing: border-box;
display: block;
}
html[data-theme="white"] .white-mic-circle .wmc-ring::before {
content: "";
display: block;
width: 100%;
height: 100%;
border-radius: 999px;
background: transparent;
}
html[data-theme="white"] .white-mic-circle .wmc-core {
position: absolute;
inset: 4px;
border-radius: 999px;
background: linear-gradient(135deg, #2563eb 0%, #3b82f6 55%, #60a5fa 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.35),
inset 0 -3px 6px rgba(15, 23, 42, 0.12),
0 8px 22px rgba(37, 99, 235, 0.22);
z-index: 1;
pointer-events: none;
}
html[data-theme="white"] .white-mic-circle .wmc-icon {
position: relative;
z-index: 5;
color: #ffffff;
fill: #ffffff;
stroke: #ffffff;
width: 48px;
height: 48px;
flex: none;
display: none;
filter: drop-shadow(0 2px 4px rgba(15, 23, 42, 0.14));
}
html[data-theme="white"] .white-mic-circle.state-idle > svg.wmc-icon.wmc-mic,
html[data-theme="white"] .white-mic-circle.state-idle .wmc-icon.wmc-mic,
html[data-theme="white"] .white-mic-circle.state-listening .wmc-icon.wmc-mic,
html[data-theme="white"] .white-mic-circle.state-user-speaking .wmc-icon.wmc-mic,
html[data-theme="white"] .white-mic-circle.state-ai-speaking .wmc-icon.wmc-mic {
display: inline-flex;
}
html[data-theme="white"] .white-mic-circle.state-your-turn > svg.wmc-icon.wmc-mic,
html[data-theme="white"] .white-mic-circle.state-your-turn .wmc-icon.wmc-mic {
display: inline-flex;
}
html[data-theme="white"] .white-mic-circle.state-error > svg.wmc-icon.wmc-mic,
html[data-theme="white"] .white-mic-circle.state-error .wmc-icon.wmc-mic {
display: inline-flex;
}
html[data-theme="white"] .white-mic-circle.state-processing > .wmc-icon.wmc-spinner,
html[data-theme="white"] .white-mic-circle.state-processing .wmc-icon.wmc-spinner,
html[data-theme="white"] .white-mic-circle.state-connecting > .wmc-icon.wmc-spinner,
html[data-theme="white"] .white-mic-circle.state-connecting .wmc-icon.wmc-spinner,
html[data-theme="white"] .white-mic-circle.state-queued > .wmc-icon.wmc-spinner,
html[data-theme="white"] .white-mic-circle.state-queued .wmc-icon.wmc-spinner {
display: block;
width: 40px;
height: 40px;
border-radius: 999px;
border: 4px solid rgba(255, 255, 255, 0.22);
border-top-color: #ffffff;
animation: voice-spinner-spin 850ms linear infinite;
margin: 4px;
padding: 0;
background: transparent;
box-sizing: border-box;
}
html[data-theme="white"] .white-mic-circle:disabled {
cursor: default;
}
html[data-theme="white"] .white-mic-circle:focus-visible {
outline: 3px solid #1d4ed8;
outline-offset: 4px;
}
html[data-theme="white"] .white-mic-circle.state-your-turn .wmc-ring {
animation: wmcListeningPulse 1.6s ease-in-out infinite;
}
html[data-theme="white"] .white-waveform {
position: absolute;
top: calc(22px + 116px / 2);
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 7px;
height: 70px;
pointer-events: none;
z-index: 1;
}
html[data-theme="white"] .white-waveform-left {
left: calc(50% - 58px - 90px);
}
html[data-theme="white"] .white-waveform-right {
right: calc(50% - 58px - 90px);
}
html[data-theme="white"] .white-waveform .w-bar {
display: block;
width: 6px;
border-radius: 999px;
background: linear-gradient(180deg, #3b82f6, #60a5fa);
opacity: 0.8;
transform-origin: center;
height: calc(var(--bar-h, 0.5) * 70px);
min-height: 8px;
}
html[data-theme="white"] .white-mic-circle.state-your-turn ~ * .w-bar,
html[data-theme="white"] .white-mic-stage:has(.state-your-turn) .w-bar {
animation: wBarDance 900ms ease-in-out infinite alternate;
}
html[data-theme="white"] .white-waveform .wb-1 {
animation-delay: -0.6s;
}
html[data-theme="white"] .white-waveform .wb-2 {
animation-delay: -0.5s;
}
html[data-theme="white"] .white-waveform .wb-3 {
animation-delay: -0.4s;
}
html[data-theme="white"] .white-waveform .wb-4 {
animation-delay: -0.3s;
}
html[data-theme="white"] .white-waveform .wb-5 {
animation-delay: -0.2s;
}
html[data-theme="white"] .white-waveform .wb-6 {
animation-delay: -0.1s;
}
html[data-theme="white"] .white-waveform .wb-7 {
animation-delay: 0s;
}
html[data-theme="white"] .white-waveform-right .wb-1 {
animation-delay: -0s;
}
html[data-theme="white"] .white-waveform-right .wb-2 {
animation-delay: -0.1s;
}
html[data-theme="white"] .white-waveform-right .wb-3 {
animation-delay: -0.2s;
}
html[data-theme="white"] .white-waveform-right .wb-4 {
animation-delay: -0.3s;
}
html[data-theme="white"] .white-waveform-right .wb-5 {
animation-delay: -0.4s;
}
html[data-theme="white"] .white-waveform-right .wb-6 {
animation-delay: -0.5s;
}
html[data-theme="white"] .white-waveform-right .wb-7 {
animation-delay: -0.6s;
}
@media (max-width: 480px) {
html[data-theme="white"] .white-waveform-left {
left: calc(50% - 58px - 72px);
}
html[data-theme="white"] .white-waveform-right {
right: calc(50% - 58px - 72px);
}
html[data-theme="white"] .white-waveform {
gap: 5px;
}
html[data-theme="white"] .white-waveform .w-bar {
width: 5px;
}
}
@media (max-height: 640px) {
html[data-theme="white"] .white-mic-circle {
width: 98px;
height: 98px;
}
html[data-theme="white"] .white-mic-circle .wmc-icon {
width: 40px;
height: 40px;
}
html[data-theme="white"] .white-mic-circle.state-processing .wmc-spinner,
html[data-theme="white"] .white-mic-circle.state-connecting .wmc-spinner,
html[data-theme="white"] .white-mic-circle.state-queued .wmc-spinner {
width: 34px;
height: 34px;
}
html[data-theme="white"] .white-waveform .w-bar {
height: calc(var(--bar-h, 0.5) * 56px);
}
html[data-theme="white"] .white-waveform {
height: 56px;
}
html[data-theme="white"] .white-waveform-left {
left: calc(50% - 49px - 76px);
top: calc(14px + 49px);
}
html[data-theme="white"] .white-waveform-right {
right: calc(50% - 49px - 76px);
top: calc(14px + 49px);
}
}
.circle-caption {
color: var(--text-dim);
}
@media (max-width: 600px) {
.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-waveform {
top: 74px;
}
}
@media (max-height: 640px) {
html[data-theme="white"] .white-waveform {
top: 57px;
}
}
.state-idle .ind-mic {
opacity: 1;
transform: scale(1);
}
html[data-theme="white"] .white-mic-circle svg.wmc-mic {
fill: none;
}
.mic-gate-arc:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 4px;
border-radius: 50%;
}
@keyframes voice-spinner-spin {
to {
transform: rotate(360deg);
}
}
@keyframes pedestalBreathe {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.55;
}
50% {
transform: translate(-50%, -50%) scale(1.06);
opacity: 0.75;
}
100% {
transform: translate(-50%, -50%) scale(0.98);
opacity: 0.5;
}
}
@keyframes orbConicSpin {
to {
transform: rotate(360deg);
}
}
@keyframes orbBreatheGlow {
0% {
opacity: 0.42;
transform: scale(0.985);
filter: blur(30px);
}
50% {
opacity: 0.72;
transform: scale(1.04);
filter: blur(40px);
}
100% {
opacity: 0.48;
transform: scale(1);
filter: blur(32px);
}
}
@keyframes outerRingPulse {
0%,
100% {
opacity: 0.4;
transform: translate(-50%, -50%) scale(1);
}
50% {
opacity: 0.65;
transform: translate(-50%, -50%) scale(1.02);
}
}
@keyframes coreBreathe {
0% {
transform: scale(0.992);
box-shadow:
0 0 36px -8px color-mix(in srgb, var(--brand) 40%, transparent),
inset 0 0 26px color-mix(in srgb, var(--brand) 16%, transparent);
}
50% {
transform: scale(1.01);
box-shadow:
0 0 54px -4px color-mix(in srgb, var(--brand-2) 48%, transparent),
inset 0 0 38px color-mix(in srgb, var(--brand-2) 22%, transparent);
}
100% {
transform: scale(1);
box-shadow:
0 0 44px -6px color-mix(in srgb, var(--brand-3) 42%, transparent),
inset 0 0 32px color-mix(in srgb, var(--brand-3) 20%, transparent);
}
}
@keyframes idleCaptionBob {
0%,
100% {
transform: translateY(0);
letter-spacing: 0.24em;
}
50% {
transform: translateY(-1px);
letter-spacing: 0.26em;
}
}
@keyframes breathe {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.4;
}
50% {
transform: translate(-50%, -50%) scale(1.06);
opacity: 0.15;
}
}
@keyframes thinking {
0%,
100% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(0.96);
opacity: 0.45;
}
}
@keyframes thinking-dot {
0%,
60%,
100% {
transform: scale(0.7);
opacity: 0.3;
}
30% {
transform: scale(1.15);
opacity: 1;
}
}
@keyframes ind-spin {
from {
transform: scale(1) rotate(0deg);
}
to {
transform: scale(1) rotate(360deg);
}
}
@keyframes wmcHaloPulse {
0% {
transform: scale(0.98);
opacity: 0.75;
}
100% {
transform: scale(1.08);
opacity: 1;
}
}
@keyframes wmcListeningPulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.45);
}
50% {
box-shadow: 0 0 0 18px rgba(59, 130, 246, 0);
}
}
@keyframes wBarDance {
from {
transform: scaleY(0.55);
}
to {
transform: scaleY(1.05);
}
}