Download style.css from amir-tfrere/minimal-conversation-app-s2s-backend: direct link, hf CLI and curl.
- Browser
- Download file 30 kB
-
https://huggingface.co/spaces/amir-tfrere/minimal-conversation-app-s2s-backend/resolve/main/style.css
- Command line
-
hf download hf://spaces/amir-tfrere/minimal-conversation-app-s2s-backend/style.css
-
curl -L -o style.css https://huggingface.co/spaces/amir-tfrere/minimal-conversation-app-s2s-backend/resolve/main/style.css
30 kB
| :root { | |
| --bg: #0a0b10; | |
| --bg-elev: #13151c; | |
| --bg-elev-2: #1b1e29; | |
| --border: rgba(255, 255, 255, 0.08); | |
| --border-strong: rgba(255, 255, 255, 0.16); | |
| --text: #f5f6fa; | |
| --text-dim: rgba(245, 246, 250, 0.65); | |
| --text-faint: rgba(245, 246, 250, 0.42); | |
| --accent: #8b7dff; | |
| --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; | |
| --shadow-soft: 0 10px 40px rgba(0, 0, 0, 0.35); | |
| color-scheme: dark; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| height: 100%; | |
| } | |
| body { | |
| font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| background: radial-gradient(ellipse at 50% 20%, #1a1c28 0%, var(--bg) 60%); | |
| color: var(--text); | |
| min-height: 100vh; | |
| 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); | |
| } | |
| #app { | |
| display: grid; | |
| grid-template-rows: auto 1fr auto; | |
| min-height: 100vh; | |
| } | |
| .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; | |
| filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.45)); | |
| 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; | |
| } | |
| /* βββ Transport pill ββββββββββββββββββββββββββββββββββββββββββββββ | |
| * Surface the actual network path WebRTC picked for the robot peer | |
| * connection (LAN, direct-through-NAT, or TURN-relayed). Lets the | |
| * user spot at a glance when audio is going through the internet | |
| * instead of staying on-prem. | |
| */ | |
| .transport-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 12px; | |
| font-weight: 500; | |
| letter-spacing: 0.02em; | |
| padding: 5px 10px; | |
| border-radius: 999px; | |
| background: var(--bg-elev); | |
| border: 1px solid var(--border); | |
| color: var(--text-dim); | |
| user-select: none; | |
| transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; | |
| } | |
| .transport-pill .transport-dot { | |
| width: 7px; | |
| height: 7px; | |
| border-radius: 50%; | |
| background: currentColor; | |
| box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 25%, transparent); | |
| flex: none; | |
| } | |
| /* Bitrate readout sits after the kind label. Dimmer + monospace so the | |
| * kind (LAN / Direct / Relayed) stays the primary info and the numbers | |
| * don't wiggle the layout as digits change. */ | |
| .transport-pill .transport-bitrate { | |
| font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; | |
| font-size: 11px; | |
| font-weight: 500; | |
| color: color-mix(in srgb, currentColor 75%, var(--text-dim)); | |
| opacity: 0.85; | |
| padding-left: 6px; | |
| border-left: 1px solid color-mix(in srgb, currentColor 25%, transparent); | |
| } | |
| .transport-pill .transport-bitrate:empty { | |
| display: none; | |
| } | |
| .transport-pill.transport-checking { | |
| color: var(--text-dim); | |
| } | |
| .transport-pill.transport-checking .transport-dot { | |
| animation: transport-blink 1.2s ease-in-out infinite; | |
| } | |
| .transport-pill.transport-lan { | |
| color: #4ade80; | |
| border-color: color-mix(in srgb, #4ade80 35%, var(--border)); | |
| background: color-mix(in srgb, #4ade80 10%, var(--bg-elev)); | |
| } | |
| .transport-pill.transport-direct { | |
| color: #60a5fa; | |
| border-color: color-mix(in srgb, #60a5fa 35%, var(--border)); | |
| background: color-mix(in srgb, #60a5fa 10%, var(--bg-elev)); | |
| } | |
| .transport-pill.transport-relay { | |
| color: #fbbf24; | |
| border-color: color-mix(in srgb, #fbbf24 35%, var(--border)); | |
| background: color-mix(in srgb, #fbbf24 10%, var(--bg-elev)); | |
| } | |
| @keyframes transport-blink { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.35; } | |
| } | |
| .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 { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 24px 24px 32px; | |
| gap: 20px; | |
| } | |
| /* βββ 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 | |
| * deep-blue stage gradient; previous var(--bg-elev) was too close to | |
| * the page bg to be readable. */ | |
| 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; | |
| 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: 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 .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: #facc15; } | |
| .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: rgba(139, 125, 255, 0.12); | |
| } | |
| .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: rgba(8, 9, 13, 0.65); | |
| 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); | |
| } | |
| .btn.primary { | |
| background: var(--accent); | |
| border-color: var(--accent); | |
| color: #0a0b10; | |
| } | |
| .btn.primary:hover { | |
| background: #9d92ff; | |
| } | |
| .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; | |
| } | |