Spaces:
Running
Running
| /* Theme palettes. | |
| * | |
| * The active theme is picked via `data-theme="dark|light"` on <html>, | |
| * set by `main.ts` from (in order of priority): | |
| * 1. the `?theme=` query param when the app is embedded in the | |
| * Reachy Mini mobile shell iframe, | |
| * 2. a postMessage `{ source: 'reachy-mini-shell', kind: 'theme' }` | |
| * sent by the shell when the user toggles their system theme | |
| * while the iframe is already open, | |
| * 3. `prefers-color-scheme` when the app runs standalone. | |
| * | |
| * Accent / state colors are intentionally hoisted out of the per-theme | |
| * blocks: the orb's glow, the listening / speaking / processing tints, | |
| * and the error / success swatches read well on both fonds and stay | |
| * stable across the theme switch (the orb is the app's signature; we | |
| * don't want it to drift colors when the user crosses the dark/light | |
| * boundary). */ | |
| :root { | |
| /* Brand accent β Pollen orange, matches `ACCENT` in | |
| * reachy_mini_mobile_app/src/theme.ts so CTAs / focus rings / | |
| * selections look identical on both sides of the iframe seam. | |
| * | |
| * Kept separate from the orb's state colors (`--listening`, | |
| * `--speaking`, β¦) which carry their own semantics (cyan = mic | |
| * is hot, violet = AI talking, β¦) and must NOT shift with the | |
| * brand. */ | |
| --accent: #ff9500; | |
| --accent-2: #22d3ee; | |
| --listening: #22d3ee; | |
| --speaking: #8b7dff; | |
| --processing: #f59e0b; | |
| --error: #ff6a75; | |
| --success: #34d399; | |
| /* Smoothed mic RMS in [0..1], updated every frame from JS while a session | |
| * is active. Used by audio-reactive circle states. */ | |
| --audio-level: 0; | |
| /* Five log-spaced frequency bands extracted from the mic analyser; | |
| * drive each bar's height independently for a real "spectrum" feel. */ | |
| --bar0: 0; | |
| --bar1: 0; | |
| --bar2: 0; | |
| --bar3: 0; | |
| --bar4: 0; | |
| --radius-sm: 8px; | |
| --radius-md: 14px; | |
| --radius-lg: 22px; | |
| } | |
| /* Dark theme β mirrors the mobile shell's MUI palette | |
| * (`reachy_mini_mobile_app/src/theme.ts`) so the iframe blends into | |
| * the host with no visible seam: same near-black canvas, same paper | |
| * tone, same divider opacity. Used as the default fallback when | |
| * `data-theme` hasn't been applied yet (initial paint before | |
| * main.ts runs). */ | |
| :root, | |
| html[data-theme="dark"] { | |
| --bg: #101013; | |
| --bg-elev: #1a1a1a; | |
| --bg-elev-2: #242427; | |
| --border: rgba(255, 255, 255, 0.08); | |
| --border-strong: rgba(255, 255, 255, 0.16); | |
| --text: #f5f5f5; | |
| --text-dim: rgba(255, 255, 255, 0.72); | |
| --text-faint: rgba(255, 255, 255, 0.42); | |
| --shadow-soft: 0 10px 40px rgba(0, 0, 0, 0.35); | |
| --backdrop-bg: rgba(0, 0, 0, 0.6); | |
| /* Foreground color used on top of `--accent` (the primary button). | |
| * Near-black so the violet accent pops on both themes; the accent | |
| * itself doesn't shift between dark and light. */ | |
| --on-accent: #0a0b10; | |
| /* Side-button (mic / stop) is one notch above bg-elev-2 so it | |
| * still reads as a control rather than a card. */ | |
| --side-btn-bg: #2e2e32; | |
| --side-btn-bg-hover: #3a3a3f; | |
| color-scheme: dark; | |
| } | |
| /* Light theme β same canvas/paper tones as the mobile shell's light | |
| * MUI palette. Calm off-white, near-black text, no tint. */ | |
| html[data-theme="light"] { | |
| --bg: #fafafa; | |
| --bg-elev: #ffffff; | |
| --bg-elev-2: #f0f0f0; | |
| --border: rgba(0, 0, 0, 0.08); | |
| --border-strong: rgba(0, 0, 0, 0.16); | |
| --text: #111111; | |
| --text-dim: rgba(0, 0, 0, 0.65); | |
| --text-faint: rgba(0, 0, 0, 0.42); | |
| --shadow-soft: 0 10px 40px rgba(0, 0, 0, 0.10); | |
| --backdrop-bg: rgba(0, 0, 0, 0.35); | |
| --on-accent: #ffffff; | |
| --side-btn-bg: #e8e8e8; | |
| --side-btn-bg-hover: #dcdcdc; | |
| color-scheme: light; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| html, | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| height: 100%; | |
| } | |
| body { | |
| font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| overflow-x: hidden; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| 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); | |
| } | |
| /* Post-iframe-migration: the only child of `#app` is `<main | |
| * class="stage">`. We fill the full iframe height with flex so the | |
| * stage's own `justify-content: center` actually has something to | |
| * center against. `100%` (not `100vh`) is critical: inside the | |
| * host's iframe `100vh` resolves to the parent window's viewport, | |
| * which leaves a 56px gap (the host top bar) hanging off the | |
| * bottom and pushes the orb up. */ | |
| #app { | |
| display: flex; | |
| flex-direction: column; | |
| height: 100%; | |
| } | |
| #app > .stage { | |
| flex: 1 1 auto; | |
| min-height: 0; | |
| } | |
| .hidden { | |
| display: none ; | |
| } | |
| /* βββ Topbar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| /* Topbar is just a floating row of controls over the stage - no | |
| * separator line, no background. Same story for the footer. Keeps the | |
| * app feeling like one continuous canvas. */ | |
| .topbar { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 18px 28px; | |
| } | |
| .brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| font-weight: 600; | |
| font-size: 14px; | |
| letter-spacing: 0.01em; | |
| color: var(--text-dim); | |
| } | |
| .brand .brand-logo { | |
| width: 26px; | |
| height: 26px; | |
| object-fit: contain; | |
| flex: none; | |
| transition: transform 0.2s ease; | |
| } | |
| .brand .brand-logo:hover { | |
| transform: translateY(-1px) rotate(-2deg); | |
| } | |
| /* Narrow viewports (typically the mobile shell iframe and phone-sized | |
| browser windows): the full product name pushes the topbar's right | |
| cluster off-screen. Keep the logo as the brand cue and drop the | |
| wordmark - the orb already gives enough context. */ | |
| @media (max-width: 600px) { | |
| .brand > span { | |
| display: none; | |
| } | |
| } | |
| .topbar-right { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| /* The HF user pill: compact avatar + handle. Rendered as a flex row so | |
| * the avatar stays aligned with the text baseline even when the text | |
| * wraps on narrow viewports. */ | |
| .hf-user { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 13px; | |
| color: var(--text-dim); | |
| padding: 4px 10px 4px 4px; | |
| border-radius: 999px; | |
| background: var(--bg-elev); | |
| border: 1px solid var(--border); | |
| line-height: 1; | |
| } | |
| .hf-avatar { | |
| width: 24px; | |
| height: 24px; | |
| border-radius: 50%; | |
| object-fit: cover; | |
| background: color-mix(in srgb, var(--accent) 25%, var(--bg-elev-2)); | |
| /* Hidden until an actual URL loads (see `setHfAvatar` in main.ts). | |
| * Keeps the initial login flash from showing a broken image icon. */ | |
| opacity: 0; | |
| transition: opacity 0.25s ease; | |
| flex: none; | |
| } | |
| .hf-avatar.loaded { | |
| opacity: 1; | |
| } | |
| .hf-user-name { | |
| white-space: nowrap; | |
| } | |
| .icon-btn { | |
| background: var(--bg-elev); | |
| border: 1px solid var(--border); | |
| color: var(--text-dim); | |
| width: 36px; | |
| height: 36px; | |
| border-radius: var(--radius-sm); | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: background 0.15s, color 0.15s, border-color 0.15s; | |
| } | |
| .icon-btn:hover { | |
| background: var(--bg-elev-2); | |
| color: var(--text); | |
| border-color: var(--border-strong); | |
| } | |
| /* βββ Stage βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .stage { | |
| position: relative; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 24px 24px 32px; | |
| gap: 20px; | |
| } | |
| /* Floating settings cog: same visual language as `.icon-btn` but | |
| * pinned to the top-right of the stage so it stays out of the orb's | |
| * way. Pre-iframe migration this lived in the topbar; with the host | |
| * shell owning the topbar now, the cog floats inside the embedded | |
| * area instead. */ | |
| .settings-fab { | |
| position: absolute; | |
| top: 12px; | |
| right: 12px; | |
| z-index: 4; | |
| background: var(--bg-elev); | |
| border: 1px solid var(--border); | |
| color: var(--text-dim); | |
| width: 36px; | |
| height: 36px; | |
| border-radius: var(--radius-sm); | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: background 0.15s, color 0.15s, border-color 0.15s; | |
| } | |
| .settings-fab:hover { | |
| background: var(--bg-elev-2); | |
| color: var(--text); | |
| border-color: var(--border-strong); | |
| } | |
| .settings-fab:focus-visible { | |
| outline: 2px solid var(--accent); | |
| outline-offset: 2px; | |
| } | |
| /* βββ Central circle ββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| /* Wraps the orb and its two side controls so they stay aligned on one row. */ | |
| .orb-wrap { | |
| position: relative; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: clamp(18px, 3vw, 32px); | |
| } | |
| .circle { | |
| position: relative; | |
| width: clamp(220px, 38vw, 320px); | |
| 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; | |
| } | |
| .circle:hover { | |
| filter: brightness(1.08); | |
| } | |
| .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: 0; | |
| border-radius: 50%; | |
| background: radial-gradient(circle at center, var(--glow, var(--accent)) 0%, transparent 65%); | |
| filter: blur(28px); | |
| opacity: 0.5; | |
| transform: scale(1); | |
| transition: opacity 0.25s, background 0.25s, transform 1.4s ease-in-out; | |
| pointer-events: none; | |
| } | |
| /* Two nested circular rings: the inner tracks the core edge, the outer | |
| * expands / fades to convey "audio radiating out" during speaking. */ | |
| .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; | |
| } | |
| .circle-ring { | |
| width: 82%; | |
| height: 82%; | |
| border: 1.5px solid color-mix(in srgb, var(--glow, var(--accent)) 35%, transparent); | |
| opacity: 0.35; | |
| } | |
| .circle-ring-outer { | |
| width: 94%; | |
| height: 94%; | |
| border: 1px solid color-mix(in srgb, var(--glow, var(--accent)) 22%, transparent); | |
| opacity: 0; | |
| } | |
| .circle-core { | |
| position: relative; | |
| width: 72%; | |
| height: 72%; | |
| border-radius: 50%; | |
| background: radial-gradient( | |
| circle at 35% 28%, | |
| color-mix(in srgb, var(--glow, var(--accent)) 28%, transparent), | |
| color-mix(in srgb, var(--glow, var(--accent)) 10%, transparent) 55%, | |
| color-mix(in srgb, var(--glow, var(--accent)) 5%, transparent) | |
| ); | |
| border: 2px solid color-mix(in srgb, var(--glow, var(--accent)) 40%, transparent); | |
| display: grid; | |
| place-items: center; | |
| box-shadow: | |
| 0 0 32px color-mix(in srgb, var(--glow, var(--accent)) 22%, transparent), | |
| inset 0 0 28px color-mix(in srgb, var(--glow, var(--accent)) 18%, transparent); | |
| transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease, transform 0.4s ease; | |
| } | |
| /* Indicator slot: a single SVG / spinner / bar group is visible at a time, | |
| * driven by the state class on `.circle`. */ | |
| .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%; | |
| } | |
| /* Spinner: a rotating ring gap, CSS-only. | |
| * | |
| * Note: the base `.circle-indicator > .ind` rule forces `transform: | |
| * scale(.85)` / `scale(1)` on every indicator to drive the show/hide | |
| * transition. If we only rotate here, the browser has to interpolate | |
| * between `scale(1)` and `rotate(360deg)` (two different transform | |
| * functions), which produces a broken, barely-moving animation. So we | |
| * include the scale explicitly in the keyframes and bump specificity | |
| * with `!important` so the spinner always wins over the state rule. */ | |
| .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; | |
| } | |
| /* Thinking dots: 3 soft pulsing dots while the model is composing a | |
| * response. Apple-style cadence: each dot scales up and brightens in | |
| * turn, staggered by ~160 ms. Per-dot animation is on the child, so | |
| * the parent's scale(.85 β 1) show/hide transform composes cleanly. */ | |
| .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; } | |
| /* Bars: 5 vertical pills driven by --bar0..--bar4 CSS vars. */ | |
| .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)); | |
| } | |
| /* Active indicator per state. | |
| * | |
| * Note: `ai-speaking` deliberately has NO indicator here. The orb | |
| * itself becomes the indicator by pulsing on Reachy's voice (see the | |
| * `--ai-audio-level` rules further down), which is a lot clearer and | |
| * less confusing than reusing the mic-bars (which viewers would read | |
| * as "you are speaking"). */ | |
| .state-signed-out .ind-connect, | |
| .state-authenticated .ind-mic, | |
| .state-ready .ind-mic, | |
| .state-connecting .ind-spinner, | |
| .state-connected .ind-spinner, | |
| .state-auto-selecting .ind-spinner, | |
| .state-starting .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); | |
| } | |
| /* AI speaking indicator: speaker + two sound waves. | |
| * | |
| * Each wave pulses outward (opacity + stroke grow) with a quarter- | |
| * beat offset so it reads as sound radiating out. Kept as a pure | |
| * CSS animation so the icon is always visually alive even between | |
| * syllables when --ai-audio-level momentarily dips. */ | |
| .ind-voice .wave { | |
| transform-origin: 50% 50%; | |
| animation: voice-wave 1.35s ease-out infinite; | |
| } | |
| .ind-voice .wave-1 { animation-delay: 0s; } | |
| .ind-voice .wave-2 { animation-delay: 0.35s; } | |
| /* Idle indicators: a chain-link "connect" glyph for the signed-out | |
| * step (invites the user to authenticate with HF) and a microphone | |
| * once a session is ready. Both picked up by the generic | |
| * `.circle-indicator > svg.ind` sizing rule (60% Γ 60% of the slot) | |
| * and rely on per-state opacity transitions for show / hide. */ | |
| .ind-connect, | |
| .ind-mic { | |
| color: color-mix(in srgb, var(--glow, var(--accent)) 85%, white); | |
| } | |
| /* βββ Caption below the circle ββββββββββββββββββββββββββββββββββββββββββ */ | |
| /* The caption under the orb is meant to whisper, not shout: micro-label | |
| * vibe, uppercase, letter-spaced, muted. Only appears for actionable / | |
| * transitional states (see STATE_VIEWS). During a live conversation the | |
| * orb alone carries the state so we collapse this row entirely. */ | |
| .circle-caption { | |
| margin: 0; | |
| font-size: 11px; | |
| font-weight: 600; | |
| letter-spacing: 0.16em; | |
| text-transform: uppercase; | |
| color: var(--text-faint); | |
| min-height: 1.2em; | |
| text-align: center; | |
| opacity: 0.75; | |
| transition: opacity 0.25s ease, color 0.25s ease, transform 0.25s ease, | |
| min-height 0.25s ease; | |
| } | |
| .circle-caption.empty { | |
| opacity: 0; | |
| min-height: 0; | |
| transform: translateY(-4px); | |
| pointer-events: none; | |
| } | |
| .circle-caption.muted { | |
| color: var(--text-faint); | |
| opacity: 0.65; | |
| } | |
| .circle-caption.error { | |
| color: var(--error); | |
| opacity: 1; | |
| letter-spacing: 0.08em; | |
| } | |
| /* βββ Tool-call toaster βββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| /* A small, non-interactive pill that appears below the circle when the | |
| * model invokes a tool (move_head, play_move). Sits just under the | |
| * state caption, collapses to zero height when no toast is active. */ | |
| .tool-toast { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-top: 10px; | |
| padding: 6px 12px; | |
| border-radius: 999px; | |
| border: 1px solid var(--border-strong); | |
| background: color-mix(in srgb, var(--bg-elev-2) 78%, transparent); | |
| color: var(--text-dim); | |
| font-size: 12px; | |
| font-weight: 500; | |
| letter-spacing: 0.01em; | |
| line-height: 1; | |
| white-space: nowrap; | |
| max-width: 80vw; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| opacity: 0; | |
| transform: translateY(-4px) scale(0.96); | |
| pointer-events: none; | |
| transition: opacity 0.22s ease, transform 0.22s ease; | |
| } | |
| .tool-toast.visible { | |
| opacity: 1; | |
| transform: translateY(0) scale(1); | |
| } | |
| .tool-toast-icon { | |
| width: 14px; | |
| height: 14px; | |
| flex: none; | |
| color: color-mix(in srgb, var(--accent) 80%, white); | |
| animation: tool-toast-spin 3.2s linear infinite; | |
| animation-play-state: paused; | |
| } | |
| .tool-toast.visible .tool-toast-icon { | |
| animation-play-state: running; | |
| } | |
| .tool-toast-text { | |
| display: inline-block; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| @keyframes tool-toast-spin { | |
| from { transform: rotate(0deg); } | |
| to { transform: rotate(360deg); } | |
| } | |
| /* βββ Side controls (mic / stop) ββββββββββββββββββββββββββββββββββββββββ */ | |
| .side-btn { | |
| flex: none; | |
| width: 52px; | |
| height: 52px; | |
| border-radius: 50%; | |
| border: 1px solid var(--border-strong); | |
| /* Brighter background so the buttons actually stand out against the | |
| * stage gradient; previous var(--bg-elev) was too close to the page | |
| * bg to be readable. The mix is theme-aware via --side-btn-bg. */ | |
| background: var(--side-btn-bg); | |
| color: var(--text); | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.06); | |
| /* Hidden by default: take up no space until the session is live. The | |
| * `width: 0` collapse keeps the orb centered on the idle screen. */ | |
| 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: var(--side-btn-bg-hover); | |
| border-color: var(--text-dim); | |
| } | |
| .side-btn svg { | |
| width: 22px; | |
| height: 22px; | |
| flex: none; | |
| } | |
| .side-btn .mic-off { display: none; } | |
| .side-btn.muted { | |
| color: #fff; | |
| border-color: var(--error); | |
| background: color-mix(in srgb, var(--error) 70%, #1a0e13); | |
| } | |
| .side-btn.muted .mic-on { display: none; } | |
| .side-btn.muted .mic-off { display: block; } | |
| /* Stop button: subtle warm tint so "end" reads as destructive. */ | |
| #stop-btn:hover { | |
| color: #fff; | |
| border-color: color-mix(in srgb, var(--error) 60%, var(--border-strong)); | |
| background: color-mix(in srgb, var(--error) 25%, var(--bg-elev-2)); | |
| } | |
| /* Reveal when the session is live: buttons flank the orb on a flex row. */ | |
| .orb-wrap.live .side-btn { | |
| opacity: 1; | |
| transform: scale(1); | |
| width: 52px; | |
| pointer-events: auto; | |
| } | |
| /* Disable every transition / animation during the first paint so the | |
| * orb doesn't fade-and-scale in when the page loads. `main.ts` removes | |
| * the class after one animation frame. */ | |
| body.booting, | |
| body.booting *, | |
| body.booting *::before, | |
| body.booting *::after { | |
| transition: none ; | |
| animation-duration: 0s ; | |
| animation-delay: 0s ; | |
| } | |
| /* βββ Circle animation keyframes ββββββββββββββββββββββββββββββββββββββββ */ | |
| /* Slow, subtle breathing for "warm idle" states. */ | |
| @keyframes breathe { | |
| 0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.4; } | |
| 50% { transform: translate(-50%, -50%) scale(1.06); opacity: 0.15; } | |
| } | |
| /* Outer ring expanding and fading - conveys "I am producing audio". */ | |
| @keyframes ring-out { | |
| 0% { transform: translate(-50%, -50%) scale(1); opacity: 0.35; } | |
| 100% { transform: translate(-50%, -50%) scale(1.18); opacity: 0; } | |
| } | |
| /* Soft inner scale for the core while talking. */ | |
| @keyframes core-breathe { | |
| 0%, 100% { transform: scale(1); } | |
| 50% { transform: scale(1.04); } | |
| } | |
| /* Subtle glow throb used for "thinking" β dimmer than speaking. */ | |
| @keyframes thinking { | |
| 0%, 100% { transform: scale(1); opacity: 0.7; } | |
| 50% { transform: scale(0.96); opacity: 0.45; } | |
| } | |
| /* Individual dot pulse for the 3-dot processing indicator. */ | |
| @keyframes thinking-dot { | |
| 0%, 60%, 100% { transform: scale(0.7); opacity: 0.3; } | |
| 30% { transform: scale(1.15); opacity: 1; } | |
| } | |
| /* Sound-wave pulse: arc fades in, scales up slightly, fades out. | |
| * The transform-origin is the speaker's center (roughly x=12), so | |
| * the waves feel like they're emanating from the cone. */ | |
| @keyframes voice-wave { | |
| 0% { opacity: 0; transform: scale(0.7); } | |
| 30% { opacity: 1; transform: scale(1); } | |
| 70% { opacity: 0.2; transform: scale(1.12); } | |
| 100% { opacity: 0; transform: scale(0.7); } | |
| } | |
| @keyframes ind-spin { | |
| /* Scale kept at 1 so we don't fight with the indicator's base | |
| * show/hide transform (see `.ind-spinner` for the rationale). */ | |
| from { transform: scale(1) rotate(0deg); } | |
| to { transform: scale(1) rotate(360deg); } | |
| } | |
| /* βββ State-specific colors ββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .circle.state-signed-out { --glow: #8b7dff; } | |
| .circle.state-authenticated, | |
| .circle.state-ready { --glow: #34d399; } | |
| .circle.state-connecting, | |
| .circle.state-connected, | |
| .circle.state-auto-selecting, | |
| .circle.state-starting { --glow: var(--accent); } | |
| .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); } | |
| /* Idle / ready: gentle breathing of the inner ring. Kept out of the | |
| * `signed-out` state so the very first paint on page load stays quiet | |
| * (the orb now shows the Reachy head silhouette, no need to also pulse). */ | |
| .circle.state-authenticated .circle-ring, | |
| .circle.state-ready .circle-ring { | |
| animation: breathe 2.4s ease-in-out infinite; | |
| } | |
| /* Connecting flows: subtle glow throb so the orb feels thoughtful | |
| * while the session is being negotiated. Kept off `processing` on | |
| * purpose - the 3 thinking dots already pulse, and layering another | |
| * throb on top competes with them for attention. */ | |
| .circle.state-connecting .circle-core, | |
| .circle.state-connected .circle-core, | |
| .circle.state-auto-selecting .circle-core, | |
| .circle.state-starting .circle-core { | |
| animation: thinking 1.4s ease-in-out infinite; | |
| } | |
| /* User is speaking: the mic RMS drives scale + opacity via --audio-level. */ | |
| .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; | |
| } | |
| /* Assistant is speaking: the whole orb breathes in sync with Reachy's | |
| * voice instead of running a fixed timer. `--ai-audio-level` (0-1) is | |
| * updated at display rate by AiLevelMonitor from the OpenAI output | |
| * track, so every syllable visibly moves the core + outer ring. This | |
| * reads instantly as "the orb is the voice" and completely avoids the | |
| * ambiguity of bars-vs-mic the user flagged. */ | |
| .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; | |
| } | |
| /* βββ Robot picker ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .robot-picker { | |
| width: min(420px, 92vw); | |
| background: var(--bg-elev); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-md); | |
| padding: 14px 16px; | |
| box-shadow: var(--shadow-soft); | |
| } | |
| .picker-title { | |
| margin: 0 0 10px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| letter-spacing: 0.1em; | |
| text-transform: uppercase; | |
| color: var(--text-faint); | |
| } | |
| .robot-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .robot-card { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 12px 14px; | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| background: var(--bg-elev-2); | |
| cursor: pointer; | |
| transition: border-color 0.15s, background 0.15s; | |
| } | |
| .robot-card:hover { | |
| border-color: var(--border-strong); | |
| } | |
| .robot-card.selected { | |
| border-color: var(--accent); | |
| background: color-mix(in srgb, var(--accent) 12%, transparent); | |
| } | |
| .robot-card .name { | |
| font-weight: 600; | |
| font-size: 14px; | |
| } | |
| .robot-card .id { | |
| font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; | |
| font-size: 12px; | |
| color: var(--text-faint); | |
| } | |
| .robot-empty { | |
| padding: 14px; | |
| text-align: center; | |
| color: var(--text-faint); | |
| font-size: 13px; | |
| } | |
| /* βββ Footer ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .footer { | |
| padding: 12px 28px 18px; | |
| font-size: 11px; | |
| letter-spacing: 0.02em; | |
| color: var(--text-faint); | |
| display: flex; | |
| justify-content: center; | |
| opacity: 0.55; | |
| transition: opacity 0.25s ease; | |
| } | |
| .footer:hover { | |
| opacity: 0.9; | |
| } | |
| .footer a { | |
| color: inherit; | |
| text-decoration: none; | |
| border-bottom: 1px dotted currentColor; | |
| } | |
| .footer a:hover { | |
| color: var(--text-dim); | |
| } | |
| /* βββ Modal βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .modal { | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-md); | |
| padding: 0; | |
| background: var(--bg-elev); | |
| color: var(--text); | |
| width: min(440px, 92vw); | |
| box-shadow: var(--shadow-soft); | |
| } | |
| .modal::backdrop { | |
| background: var(--backdrop-bg); | |
| backdrop-filter: blur(4px); | |
| } | |
| .modal-content { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| padding: 22px 24px 20px; | |
| } | |
| .modal-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 4px; | |
| } | |
| .modal-header h2 { | |
| margin: 0; | |
| font-size: 16px; | |
| font-weight: 600; | |
| letter-spacing: 0.02em; | |
| } | |
| .field { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: var(--text-dim); | |
| } | |
| .field > span { | |
| color: var(--text); | |
| font-weight: 600; | |
| font-size: 12px; | |
| letter-spacing: 0.04em; | |
| text-transform: uppercase; | |
| } | |
| .field input, | |
| .field select, | |
| .field textarea { | |
| font-family: inherit; | |
| font-size: 14px; | |
| color: var(--text); | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| padding: 10px 12px; | |
| outline: none; | |
| transition: border-color 0.15s; | |
| resize: vertical; | |
| } | |
| .field textarea { | |
| min-height: 84px; | |
| } | |
| .field input:focus, | |
| .field select:focus, | |
| .field textarea:focus { | |
| border-color: var(--accent); | |
| } | |
| .field small { | |
| color: var(--text-faint); | |
| font-size: 12px; | |
| line-height: 1.4; | |
| } | |
| .field small code { | |
| background: var(--bg); | |
| padding: 1px 5px; | |
| border-radius: 4px; | |
| border: 1px solid var(--border); | |
| } | |
| .modal-footer { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 12px; | |
| padding-top: 6px; | |
| } | |
| .btn { | |
| padding: 10px 16px; | |
| border-radius: var(--radius-sm); | |
| border: 1px solid var(--border-strong); | |
| background: var(--bg-elev-2); | |
| color: var(--text); | |
| font-weight: 600; | |
| font-size: 13px; | |
| cursor: pointer; | |
| transition: background 0.15s, border-color 0.15s, transform 0.05s; | |
| } | |
| .btn:hover { | |
| border-color: var(--text); | |
| } | |
| .btn:active { | |
| transform: translateY(1px); | |
| } | |
| /* Outlined CTA, MUI `variant="outlined" color="primary"` flavor: | |
| * accent border + accent text, transparent fill, a faint accent | |
| * wash on hover. Mirrors the mobile shell's action button style | |
| * so the settings modal reads as the same product. */ | |
| .btn.primary { | |
| background: transparent; | |
| border-color: var(--accent); | |
| color: var(--accent); | |
| } | |
| .btn.primary:hover { | |
| background: color-mix(in srgb, var(--accent) 8%, transparent); | |
| border-color: var(--accent); | |
| } | |
| .btn.primary:focus-visible { | |
| outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent); | |
| outline-offset: 2px; | |
| } | |
| .btn.ghost { | |
| background: transparent; | |
| border-color: var(--border); | |
| color: var(--text-dim); | |
| } | |
| .btn.wide { | |
| width: 100%; | |
| padding: 12px 16px; | |
| } | |
| .btn[disabled] { | |
| opacity: 0.45; | |
| cursor: not-allowed; | |
| } | |
| .btn[disabled]:hover { | |
| border-color: var(--border-strong); | |
| } | |
| /* βββ Settings tabs βββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .tabs { | |
| display: flex; | |
| gap: 4px; | |
| padding: 4px; | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| } | |
| .tab { | |
| flex: 1; | |
| padding: 8px 12px; | |
| border: none; | |
| background: transparent; | |
| color: var(--text-dim); | |
| font-family: inherit; | |
| font-size: 13px; | |
| font-weight: 600; | |
| letter-spacing: 0.02em; | |
| border-radius: calc(var(--radius-sm) - 3px); | |
| cursor: pointer; | |
| transition: background 0.15s, color 0.15s; | |
| } | |
| .tab:hover { | |
| color: var(--text); | |
| } | |
| .tab.active { | |
| background: var(--bg-elev-2); | |
| color: var(--text); | |
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); | |
| } | |
| .tab-panels { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .tab-panel { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| } | |
| .tab-panel[hidden] { | |
| display: none; | |
| } | |
| /* Horizontal layout for Voice + Model. */ | |
| .field-row { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 12px; | |
| } | |