/* Theme palettes. * * The active theme is picked via `data-theme="dark|light"` on , * 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 `
`. 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 !important; } /* ─── 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 !important; animation-duration: 0s !important; animation-delay: 0s !important; } /* ─── 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; }