vocero-s2s/demo/style.css
valenti b5f82fb48c
Some checks are pending
CI / ruff (push) Waiting to run
CI / mypy (push) Waiting to run
CI / pytest (push) Waiting to run
CI / package (push) Waiting to run
CI / Install smoke (${{ matrix.label }}) (linux, ubuntu-latest) (push) Blocked by required conditions
CI / Install smoke (${{ matrix.label }}) (macos-arm64, macos-14) (push) Blocked by required conditions
first git
2026-08-26 11:30:14 +00:00

2647 lines
69 KiB
CSS
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

: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;
/* Mono is the "machine voice": reserved for system text the app emits —
* the orb's status caption, tool calls, the transport tag. Body/UI stays
* Inter. */
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
/* Thesis: color belongs to the voice. The chrome is monochrome; saturated
* hue appears only on the orb and on these tiny role echoes, which mirror
* the orb's own state colors (you listening = cyan, assistant = violet,
* tool = amber) so the transcript reads in the same color language. */
--voice-user: var(--accent-2);
--voice-assistant: var(--accent);
--voice-tool: var(--processing);
/* 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 !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;
/* Top-align so the right control cluster sits up with the title instead of
* floating to the vertical middle of the tall identity block. */
align-items: flex-start;
justify-content: space-between;
padding: 18px 28px;
/* Default stacking (below the conversation panel, z 200) so the panel drawer
* covers the controls when it's open. */
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 14px;
letter-spacing: 0.01em;
color: var(--text-dim);
}
/* Transport tag rides the wordmark as a system label, not prose — mono,
* dimmed, the only mono in the topbar. */
.brand-tag {
font-family: var(--font-mono);
font-weight: 500;
font-size: 11px;
letter-spacing: 0.02em;
opacity: 0.55;
}
.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) {
/* The full identity stack is too tall for a phone topbar. Show only the
* title + (i); the popup carries the blurb, credits and pipeline.
* `.brand` prefix raises specificity so this beats the later base
* `.ident-meta { display: flex }` rule regardless of source order. */
.brand .ident-blurb,
.brand .ident-meta {
display: none;
}
.ident-title {
font-size: 18px;
}
/* (i) moves into the right-hand control cluster on phones, leaving the
* title alone on the left. */
.brand .about-btn {
display: none;
}
.about-btn-mobile {
display: inline-flex;
}
/* Keep phone icons at the current compact size — the desktop bump above
* shouldn't leak into the mobile topbar. */
.topbar-right .icon-btn {
width: 36px;
height: 36px;
}
.topbar-right .icon-btn svg {
width: 18px;
height: 18px;
}
/* Keep the account control compact on phones: avatar-only chip, shorter pill. */
.account-chip {
height: 36px;
padding: 0 6px;
}
.account-handle {
display: none;
}
.signin-pill {
height: 36px;
padding: 0 12px;
}
}
.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: var(--font-mono);
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;
touch-action: manipulation;
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);
}
/* Topbar control cluster reads a touch bigger on desktop. Phones keep the
* compact 36px (reset in the mobile media query). */
.topbar-right .icon-btn {
width: 40px;
height: 40px;
}
.topbar-right .icon-btn svg {
width: 20px;
height: 20px;
}
/* ─── Account (HF login chip + sign-in pill + popover) ─────────────────────── */
.account {
position: relative;
display: inline-flex;
align-items: center;
}
/* Signed-out: a compact pill that reads as the primary affordance. */
.signin-pill {
display: inline-flex;
align-items: center;
gap: 7px;
height: 40px;
padding: 0 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--bg-elev);
color: var(--text);
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition: background 0.15s, border-color 0.15s;
}
.signin-pill svg {
width: 16px;
height: 16px;
flex: none;
}
.signin-pill:hover {
background: var(--bg-elev-2);
border-color: var(--text);
}
/* Narrow viewports: collapse the pill to an icon-only square. */
@media (max-width: 800px) {
.signin-pill {
gap: 0;
width: 40px;
padding: 0;
justify-content: center;
}
.signin-pill span {
display: none;
}
}
/* Signed-in: avatar + handle chip. */
.account-chip {
display: inline-flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 10px 0 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text-dim);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.account-chip:hover {
background: var(--bg-elev-2);
color: var(--text);
border-color: var(--border-strong);
}
.account-avatar {
width: 26px;
height: 26px;
border-radius: 50%;
object-fit: cover;
flex: none;
}
.account-avatar-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--bg-elev-2);
color: var(--text);
font-size: 12px;
font-weight: 700;
}
.account-handle {
font-size: 13px;
font-weight: 600;
max-width: 12ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-pro {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 2px 5px;
border-radius: 5px;
background: var(--text);
color: var(--bg);
}
/* Org "Team" badge: same pill as PRO, accent-coloured so it reads as
* unlimited without claiming a paid PRO subscription. */
.account-team {
background: var(--accent);
color: var(--bg);
}
.account-pop {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 200px;
padding: 6px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: var(--bg-elev);
box-shadow: var(--shadow-soft);
z-index: 50;
}
.account-pop[hidden] {
display: none;
}
.account-pop-row {
padding: 8px 10px;
}
.account-pop-name {
font-weight: 600;
font-size: 13px;
}
.account-pop-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 0;
font-size: 12px;
color: var(--text-dim);
}
.account-tier {
font-weight: 600;
color: var(--text);
}
.account-pop-link {
display: block;
padding: 8px 10px;
border-radius: var(--radius-sm);
color: var(--text-dim);
font-size: 13px;
text-decoration: none;
transition: background 0.15s, color 0.15s;
}
.account-pop-link:hover {
background: var(--bg-elev-2);
color: var(--text);
}
.account-signout {
border-top: 1px solid var(--border);
margin-top: 4px;
padding-top: 10px;
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
/* ─── Daily-limit modal ────────────────────────────────────────────────────── */
.limit-modal {
width: min(400px, 92vw);
}
.limit-card {
position: relative;
align-items: center;
text-align: center;
gap: 14px;
padding: 34px 28px 26px;
}
.limit-close {
position: absolute;
top: 12px;
right: 12px;
}
/* HF smiling face (brand yellow) on a neutral badge — friendly, not a stop sign.
* The logo is the single pop of colour, so the badge itself stays quiet. */
.limit-badge {
width: 66px;
height: 66px;
border-radius: 50%;
display: grid;
place-items: center;
margin: 2px auto 2px;
background: var(--bg-elev-2);
border: 1px solid var(--border-strong);
box-shadow: 0 0 0 6px rgba(255, 210, 30, 0.06);
}
.limit-badge .hf-logo {
width: 46px;
height: auto;
}
.limit-title {
margin: 0;
font-size: 20px;
font-weight: 700;
letter-spacing: 0;
}
.limit-msg {
color: var(--text-dim);
font-size: 14px;
line-height: 1.55;
margin: 0;
max-width: 30ch;
}
.limit-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 4px;
}
.limit-cta .hf-logo {
width: 20px;
height: auto;
flex: none;
}
.limit-note {
margin: 0;
font-size: 12px;
color: var(--text-faint);
}
#limit-cta[hidden] {
display: none;
}
/* ─── Stage ───────────────────────────────────────────────────────────── */
.stage {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 24px 32px;
gap: 20px;
/* Let the stage shrink inside the grid's 1fr row so a height-capped orb can
* give back space rather than forcing the whole app to overflow. */
min-height: 0;
}
/* ─── 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;
/* Cap by height as well as width (min(vw, vh)-style): in a short container
* the orb shrinks instead of overflowing, so the page stays scroll-free even
* when embedded in a small box. On normal screens the 320px cap still wins,
* so the desktop size is unchanged. */
width: clamp(140px, min(38vw, 46vh), 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;
/* Treat taps as immediate clicks (no 300ms delay / double-tap-zoom). */
touch-action: manipulation;
}
.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-queued .ind-spinner,
.state-processing .ind-thinking,
.state-listening .ind-bars,
.state-user-speaking .ind-bars,
.state-ai-speaking .ind-voice,
.state-error .ind-error {
opacity: 1;
transform: scale(1);
}
/* 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-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.14em;
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;
}
/* Warm, human line under the mono caption — sentence case, only while queued.
* The caption keeps the terse position; this reassures. */
.circle-subcaption {
margin: 8px 0 0;
max-width: 32ch;
font-size: 13.5px;
line-height: 1.5;
color: var(--text-dim);
text-align: center;
text-wrap: balance;
transition: opacity 0.25s ease;
}
.circle-subcaption[hidden] { display: none; }
/* Queue actions sit under the caption: "Join now" (primary, only when it's your
* turn) stacked above the quiet "Leave queue" escape hatch. */
.queue-actions {
margin-top: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.queue-actions[hidden] { display: none; }
/* "Join now": the one call to action in the queue flow, so it reads as inviting
* (filled accent) while everything around it stays quiet. */
.join-queue-btn {
padding: 10px 26px;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #0b0b10;
background: var(--accent);
border: none;
border-radius: 999px;
cursor: pointer;
font-variant-numeric: tabular-nums;
transition: transform 0.12s ease, filter 0.2s ease;
}
.join-queue-btn:hover { filter: brightness(1.08); }
.join-queue-btn:active { transform: scale(0.97); }
.join-queue-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.join-queue-btn[hidden] { display: none; }
/* "Leave queue": a quiet outlined pill, understated so it reads as an escape
* hatch, not a CTA. */
.leave-queue-btn {
padding: 7px 16px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-faint);
background: transparent;
border: 1px solid color-mix(in srgb, var(--text-faint) 35%, transparent);
border-radius: 999px;
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.leave-queue-btn:hover {
color: var(--text);
border-color: color-mix(in srgb, var(--text-faint) 60%, transparent);
background: color-mix(in srgb, var(--text-faint) 8%, transparent);
}
.leave-queue-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.leave-queue-btn[hidden] { display: none; }
/* ─── 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(--voice-tool) 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) ──────────────────────────────────────── */
/* Mic button + its radial noise-gate arc. The wrapper centres the button; the
arc SVG is an absolute overlay larger than the button, revealed with the
live session (it has no layout footprint, so the idle collapse is unaffected). */
.mic-gate {
position: relative;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mic-gate-arc {
position: absolute;
left: 50%;
top: 50%;
width: 80px;
height: 80px;
transform: translate(-50%, -50%);
pointer-events: none; /* only the hit-path below catches drags */
opacity: 0;
transition: opacity 0.25s ease;
}
.orb-wrap.live .mic-gate-arc { opacity: 1; }
.mga-track {
stroke: rgba(255, 255, 255, 0.1); /* hairline; recedes until needed */
stroke-width: 1.5;
stroke-linecap: round;
}
.mga-fill {
stroke: var(--accent-2);
stroke-width: 2;
stroke-linecap: round;
opacity: 0.85;
transition: stroke-dashoffset 0.06s linear;
}
/* Threshold setpoint: a bead riding the ring. White with a thin dark outline so
it stays legible over the moving fill; it recolors to cyan with a soft glow
the moment the live level crosses it (the gate opening). */
.mga-handle {
fill: var(--text);
stroke: none;
transition: fill 0.2s ease, filter 0.2s ease;
}
.mic-gate.gate-open .mga-handle {
fill: var(--accent-2);
filter: drop-shadow(0 0 3px var(--accent-2));
}
.mga-hit {
stroke: transparent;
stroke-width: 16;
pointer-events: none; /* enabled only while live (below) */
cursor: pointer;
touch-action: none;
}
/* Only catch drags during a live call; when idle the arc is hidden and must
not steal clicks near the collapsed mic button / orb. */
.orb-wrap.live .mga-hit { pointer-events: stroke; }
.side-btn {
flex: none;
width: 52px;
height: 52px;
border-radius: 50%;
border: 1px solid var(--border-strong);
/* 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;
touch-action: manipulation;
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 !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: #facc15; }
/* Queued: a calm slate glow, distinct from connecting's active yellow — this is
* waiting, not working. The spinner turns slowly and the ring breathes. */
.circle.state-queued { --glow: #94a3b8; }
.circle.state-queued .ind-spinner { animation-duration: 2.4s; }
.circle.state-queued .circle-ring { animation: breathe 2.6s ease-in-out infinite; }
/* Your turn: a slot is held for you — the orb warms to the accent and breathes a
* little quicker, an invitation to join. */
.circle.state-your-turn { --glow: var(--accent); }
.circle.state-your-turn .circle-ring { animation: breathe 1.6s ease-in-out infinite; }
.circle.state-listening,
.circle.state-user-speaking { --glow: var(--listening); }
.circle.state-processing { --glow: var(--processing); }
.circle.state-ai-speaking { --glow: var(--speaking); }
.circle.state-error { --glow: var(--error); }
/* 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(--border-strong);
background: var(--bg-elev);
}
.robot-card .name {
font-weight: 600;
font-size: 14px;
}
.robot-card .id {
font-family: var(--font-mono);
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);
}
/* While the webcam preview sits bottom-left, push the credit to the
* bottom-right so the two don't collide. */
body.cam-on .footer {
justify-content: flex-end;
}
/* ─── 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[hidden] { display: none; }
.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(--text-dim);
}
/* Deploy-pinned server URL: shown for transparency, not editable. */
.field input[readonly] {
color: var(--text-dim);
border-style: dashed;
cursor: default;
}
.field input[readonly]:focus {
border-color: var(--border);
}
.field small {
color: var(--text-faint);
font-size: 12px;
line-height: 1.4;
}
.field small.error { color: var(--error); }
.field small code {
background: var(--bg);
padding: 1px 5px;
border-radius: 4px;
border: 1px solid var(--border);
}
/* Noise gate: a header with a live value, a level meter, and a range slider
that shares the meter's horizontal (dB) axis. */
.field-head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.field-value {
font-weight: 500;
font-size: 12px;
letter-spacing: 0;
text-transform: none;
color: var(--text-dim);
}
/* The slider and the level meter are one widget: the range input is overlaid
on the meter track (its native track is transparent), so the live-level fill
shows through behind the thumb and the thumb itself is the threshold. */
.gate {
display: flex;
flex-direction: column;
gap: 4px;
}
.gate-track {
position: relative;
height: 14px;
display: flex;
align-items: center;
}
.gate-track::before {
/* the visible meter track */
content: "";
position: absolute;
left: 0;
right: 0;
height: 8px;
border-radius: 999px;
background: var(--bg);
border: 1px solid var(--border);
}
.gate-meter-fill {
position: absolute;
left: 1px;
top: 50%;
transform: translateY(-50%);
height: 6px;
width: 0;
border-radius: 999px;
background: var(--accent-2);
transition: width 0.06s linear;
pointer-events: none;
}
.gate-ends {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-faint);
letter-spacing: 0;
text-transform: none;
}
/* The range input sits transparently on top of the meter track. */
.gate-track input[type="range"] {
position: relative;
z-index: 1;
width: 100%;
margin: 0;
-webkit-appearance: none;
appearance: none;
padding: 0;
border: none;
background: transparent;
height: 14px;
cursor: pointer;
}
.gate-track input[type="range"]::-webkit-slider-runnable-track {
height: 14px;
background: transparent;
}
.gate-track input[type="range"]::-moz-range-track {
height: 14px;
background: transparent;
}
.gate-track input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 6px;
height: 18px;
border-radius: 3px;
background: var(--text);
border: 2px solid var(--bg-elev);
box-shadow: 0 0 0 1px var(--border-strong);
}
.gate-track input[type="range"]::-moz-range-thumb {
width: 6px;
height: 18px;
border-radius: 3px;
background: var(--text);
border: 2px solid var(--bg-elev);
box-shadow: 0 0 0 1px var(--border-strong);
}
.gate-track input[type="range"]:focus { border: none; }
.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(--text);
border-color: var(--text);
color: var(--bg);
}
.btn.primary:hover {
background: #fff;
border-color: #fff;
}
.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;
}
/* ─── Chat button + badge ────────────────────────────────────────────── */
#chat-btn {
position: relative;
}
.chat-badge {
position: absolute;
top: 6px;
right: 6px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text);
border: 1.5px solid var(--bg-elev);
opacity: 0;
transform: scale(0);
transition: opacity 0.2s ease, transform 0.2s ease;
pointer-events: none;
}
.chat-badge.visible {
opacity: 1;
transform: scale(1);
}
/* ─── Ephemeral bubble stack ─────────────────────────────────────────── */
.bubble-stack {
position: fixed;
/* Clear the topbar control row so the first bubble doesn't sit at the same
* height as the buttons. */
top: 96px;
right: 28px;
width: min(300px, calc(100vw - 56px));
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
z-index: 100;
}
.bubble {
--bubble-dx: 10px;
pointer-events: auto;
padding: 10px 14px;
border-radius: var(--radius-md);
font-size: 13px;
line-height: 1.5;
border: 1px solid var(--border);
background: var(--bg-elev);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.32);
max-width: 100%;
word-break: break-word;
opacity: 0;
transform: translateX(var(--bubble-dx));
transition: opacity 0.22s ease, transform 0.22s ease;
}
.bubble.in {
opacity: 1;
transform: translateX(0);
}
.bubble.out {
opacity: 0;
transform: translateX(var(--bubble-dx));
pointer-events: none;
transition: opacity 0.3s ease, transform 0.3s ease;
}
/* Surfaces stay neutral; the side they sit on plus the mono role label
* carry the distinction. No tinted fills — color is the orb's job. */
.bubble.user {
--bubble-dx: -10px;
align-self: flex-start;
}
.bubble.assistant {
--bubble-dx: 10px;
align-self: flex-end;
}
.bubble.tool {
--bubble-dx: -10px;
align-self: flex-start;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
}
.bubble.tool .bubble-tool-icon {
width: 13px;
height: 13px;
flex: none;
color: var(--voice-tool);
}
.bubble-role {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 4px;
opacity: 0.7;
}
.bubble.user .bubble-role { color: var(--voice-user); }
.bubble.assistant .bubble-role { color: var(--voice-assistant); }
/* ─── Conversation history panel ─────────────────────────────────────── */
.chat-panel {
position: fixed;
inset: 0;
z-index: 200;
pointer-events: none;
}
.chat-panel-backdrop {
position: absolute;
inset: 0;
background: rgba(8, 9, 13, 0.45);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
opacity: 0;
transition: opacity 0.25s ease;
}
.chat-panel.open .chat-panel-backdrop {
opacity: 1;
pointer-events: auto;
}
.chat-panel-inner {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(360px, 90vw);
background: var(--bg-elev);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
pointer-events: auto;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.35);
}
.chat-panel.open .chat-panel-inner {
transform: translateX(0);
}
.chat-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
border-bottom: 1px solid var(--border);
flex: none;
}
.chat-panel-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.01em;
}
.chat-history {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 10px;
scroll-behavior: smooth;
}
.chat-history::-webkit-scrollbar { width: 3px; }
.chat-history::-webkit-scrollbar-track { background: transparent; }
.chat-history::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
.chat-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--text-faint);
padding: 48px 0;
opacity: 0.65;
}
.chat-empty svg {
margin-bottom: 6px;
opacity: 0.8;
}
.chat-empty-title {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.chat-empty-hint {
font-size: 12px;
color: var(--text-faint);
opacity: 0.7;
}
/* ─── History messages ───────────────────────────────────────────────── */
.hist-msg {
display: flex;
flex-direction: column;
gap: 3px;
max-width: 88%;
}
.hist-msg.user { align-self: flex-start; }
.hist-msg.assistant { align-self: flex-end; }
.hist-msg.tool { align-self: flex-start; max-width: 100%; }
.hist-role {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
padding: 0 2px;
}
.hist-msg.user .hist-role { color: color-mix(in srgb, var(--voice-user) 75%, var(--text-faint)); }
.hist-msg.assistant .hist-role { color: color-mix(in srgb, var(--voice-assistant) 75%, var(--text-faint)); }
.hist-msg.tool .hist-role { color: color-mix(in srgb, var(--voice-tool) 75%, var(--text-faint)); }
.hist-body {
padding: 9px 12px;
border-radius: var(--radius-md);
font-size: 13px;
line-height: 1.5;
border: 1px solid var(--border);
background: var(--bg-elev-2);
word-break: break-word;
}
/* Bodies share one neutral surface; alignment + the mono role label do the
* distinguishing, so the panel reads as one quiet column. */
.hist-msg.user .hist-body.partial { opacity: 0.65; }
/* ─── Tool call history item ─────────────────────────────────────────── */
.hist-tool-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: var(--radius-md) var(--radius-md) 0 0;
background: color-mix(in srgb, var(--processing) 10%, var(--bg-elev-2));
border: 1px solid color-mix(in srgb, var(--processing) 22%, var(--border));
border-bottom: 1px solid color-mix(in srgb, var(--processing) 15%, var(--border));
cursor: pointer;
color: var(--text);
font-family: inherit;
font-size: 13px;
font-weight: 500;
width: 100%;
text-align: left;
transition: background 0.15s;
}
.hist-tool-header:only-child {
border-radius: var(--radius-md);
border-bottom: 1px solid color-mix(in srgb, var(--processing) 22%, var(--border));
}
.hist-tool-header:hover {
background: color-mix(in srgb, var(--processing) 16%, var(--bg-elev-2));
}
.hist-tool-icon {
width: 13px;
height: 13px;
flex: none;
color: var(--processing);
}
.hist-tool-name {
font-family: var(--font-mono);
font-size: 12px;
color: var(--voice-tool);
font-weight: 600;
}
.hist-tool-chevron {
margin-left: auto;
width: 13px;
height: 13px;
color: var(--text-faint);
transition: transform 0.2s ease;
flex: none;
}
.hist-tool-header[aria-expanded="true"] .hist-tool-chevron {
transform: rotate(180deg);
}
.hist-tool-body {
padding: 10px 12px 12px;
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: var(--bg);
border: 1px solid color-mix(in srgb, var(--processing) 22%, var(--border));
border-top: none;
display: none;
}
.hist-tool-body.open { display: block; }
.hist-tool-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
margin: 8px 0 4px;
}
.hist-tool-label:first-child { margin-top: 0; }
.hist-tool-block {
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.65;
color: var(--text-dim);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
.hist-tool-output { color: var(--text); }
/* ─── Phone layout ────────────────────────────────────────────────────────
* On phones the floating bubble stream overlaps the orb and there isn't room
* for it, so we drop it entirely and rely on the conversation panel (opened
* from the top-right chat button) as the single place to read the transcript.
* The badge still pulses there when new messages arrive while it's closed.
* We also stack the mic / stop controls above and below the orb (instead of
* left/right) so the wide live row never overflows, and let the panel take
* the full width. */
@media (max-width: 600px) {
.bubble-stack {
display: none;
}
.topbar {
padding: 14px 16px;
}
.stage {
padding: 16px 12px 24px;
}
/* Stack vertically: mic above the orb, stop below it (DOM order is
* mic → circle → stop). */
.orb-wrap {
flex-direction: column;
gap: 14px;
}
.circle {
/* Height-capped like the base rule so a narrow AND short box still fits. */
width: clamp(130px, min(56vw, 52vh), 240px);
}
/* In the column layout the side controls must collapse by HEIGHT, not
* width, so they take no vertical space until the session is live. */
.side-btn {
width: 44px;
height: 0;
transition: opacity 0.25s ease, transform 0.25s ease, height 0.25s ease,
background 0.15s, color 0.15s, border-color 0.15s;
}
.side-btn svg {
width: 19px;
height: 19px;
}
.orb-wrap.live .side-btn {
width: 44px;
height: 44px;
}
.mic-gate-arc { width: 70px; height: 70px; }
/* Full-screen conversation on phones — feels more deliberate than a
* narrow slide-over. It already spans top-to-bottom (inset 0); make it
* span edge-to-edge too and drop the now-pointless border/shadow. */
.chat-panel-inner {
width: 100vw;
border-left: none;
box-shadow: none;
}
}
/* ─── About panel ─────────────────────────────────────────────────────────
* Opened from the (i) by the wordmark. Reuses the <dialog> .modal shell.
* Strictly monochrome per DESIGN.md — the only "color" is the orb, never
* here. Machine identifiers (model IDs, role tags, usernames) ride Geist
* Mono; everything human stays Inter. */
.about-modal {
/* Roomier on tablet/desktop; the 92vw cap keeps phones full-width. */
width: min(680px, 92vw);
max-height: 88vh;
}
.about-modal .modal-content {
max-height: 88vh;
overflow-y: auto;
gap: 20px;
padding: 26px 30px 24px;
}
.about-modal .modal-content::-webkit-scrollbar { width: 3px; }
.about-modal .modal-content::-webkit-scrollbar-track { background: transparent; }
.about-modal .modal-content::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
/* Links here are quiet: no permanent underline (the global dotted rule is
* too loud for a dense credit block), brighten + underline on hover only. */
.about-modal a {
color: var(--text);
border-bottom: none;
text-decoration: none;
transition: color 0.15s ease;
}
.about-modal a:hover {
color: #fff;
}
.about-intro a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.about-modal .ext {
width: 12px;
height: 12px;
flex: none;
opacity: 0.5;
}
/* Title row: the demo name and the (i) read as one unit. */
.ident-head {
display: flex;
align-items: center;
gap: 9px;
}
/* (i) trigger sits right after the title. A faint outline keeps it
* catchable without turning into a card; it fills in on hover. */
.about-btn {
flex: none;
width: 34px;
height: 34px;
border-radius: 50%;
background: transparent;
border: 1px solid var(--border-strong);
color: var(--text-dim);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
touch-action: manipulation;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.about-btn:hover {
background: var(--bg-elev);
border-color: var(--text-dim);
color: var(--text);
}
.about-btn svg {
width: 20px;
height: 20px;
}
/* The mobile twin of (i) lives in the right-hand control cluster and is
* hidden on desktop (the in-title one shows there instead). */
.about-btn-mobile {
display: none;
}
/* ── Popup intro: a plain paragraph on the project + a repo link ── */
.about-intro p {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: var(--text-dim);
}
.about-repo {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 12px;
font-size: 14px;
font-weight: 500;
}
/* ── Corner identity (replaces the wordmark) ──
* A compact stack in the topbar: title, one-line blurb, two meta rows.
* Monochrome; only the role/name identifiers ride the machine typeface. */
.ident {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
/* Reset the global underlined-anchor styling inside the identity block. */
.ident a {
border-bottom: none;
color: inherit;
transition: color 0.15s ease;
}
.ident a:hover {
color: var(--text);
text-decoration: underline;
text-underline-offset: 2px;
}
.ident-title {
font-size: 22px;
font-weight: 600;
letter-spacing: 0.005em;
line-height: 1.1;
color: var(--text);
}
.ident-blurb {
margin: 0;
max-width: 48ch;
font-size: 14.5px;
line-height: 1.5;
font-weight: 400;
color: var(--text-dim);
}
.ident-meta {
display: flex;
flex-direction: column;
gap: 7px;
font-size: 14px;
font-weight: 400;
color: var(--text-dim);
}
.ident-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
}
.ident-label {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
}
/* Shared credit bits — now used in the corner identity block. */
.sep {
color: var(--text-faint);
opacity: 0.6;
}
.hf-credit {
display: inline-flex;
align-items: center;
gap: 5px;
}
/* Brand marks keep their own color — a deliberate exception to the
* monochrome rule, for the HF and Cerebras logos only. */
.hf-mark {
width: 14px;
height: 14px;
flex: none;
color: #ffd21e;
}
.cerebras-credit {
display: inline-flex;
align-items: center;
gap: 5px;
}
.cerebras-mark {
width: 14px;
height: 14px;
flex: none;
}
/* Usernames are identifiers, so they ride the machine typeface. */
.handle {
font-family: var(--font-mono);
font-size: 13.5px;
}
/* ── Pipeline (the signal flow) ── */
.about-pipeline {
border-top: 1px solid var(--border);
padding-top: 16px;
}
.pipeline-title {
margin: 0 0 14px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-faint);
}
.pipeline {
list-style: none;
margin: 0;
padding: 0;
position: relative;
}
/* One continuous rail threading every node, dot centers at x=8.5px. */
.pipeline::before {
content: "";
position: absolute;
left: 8px;
top: 7px;
bottom: 7px;
width: 1px;
background: var(--border-strong);
}
.pipeline > li {
position: relative;
padding-left: 30px;
}
/* Stages: solid node. Endpoints (you / orb): hollow node. */
.pipe-stage::before {
content: "";
position: absolute;
left: 5px;
top: 4px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-dim);
}
.pipe-endpoint::before {
content: "";
position: absolute;
left: 5px;
top: 3px;
width: 7px;
height: 7px;
border-radius: 50%;
border: 1px solid var(--text-faint);
background: var(--bg-elev);
}
.pipe-endpoint {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-faint);
padding-bottom: 12px;
}
.pipeline > li.pipe-endpoint:last-child {
padding-bottom: 0;
}
.pipe-stage {
padding-bottom: 16px;
}
.pipe-tag {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--text);
margin-right: 9px;
}
.pipe-job {
font-size: 14px;
color: var(--text-dim);
}
/* Middot between the job and its model link, matching the separators
* used elsewhere. */
.pipe-job::after {
content: "·";
margin: 0 7px;
color: var(--text-faint);
}
.pipe-note {
color: var(--text-faint);
}
/* The Cerebras link stays as quiet as the note; brightens + underlines on hover. */
.pipe-note a {
color: inherit;
}
.pipe-note a:hover {
color: var(--text);
text-decoration: underline;
text-underline-offset: 2px;
}
.pipe-model {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 4px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-dim);
word-break: break-all;
}
.pipe-model:hover {
color: #fff;
text-decoration: underline;
text-underline-offset: 2px;
}
.pipe-model .ext {
width: 11px;
height: 11px;
}
/* "Interrupted" tag on an assistant reply the user barged in on. */
.hist-note {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
padding: 0 2px;
}
.hist-msg.assistant .hist-note {
align-self: flex-end;
}
.hist-msg.interrupted .hist-body {
opacity: 0.7;
}
/* Captured webcam frame shown in the transcript (camera tool result). */
.hist-image {
display: block;
width: 100%;
max-width: 240px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
margin-top: 2px;
}
/* ─── Tools panel ──────────────────────────────────────────────────────────
* Reuses the modal/field shell. Switches are monochrome (checked = near-white,
* the same high-contrast treatment as the primary button): color belongs to
* the voice, not the chrome. */
.tools-intro {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-dim);
}
.tool-list {
display: flex;
flex-direction: column;
}
.tool-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
}
.tool-row-sep {
border-top: 1px solid var(--border);
margin-top: 4px;
}
.tool-info {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.tool-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.tool-desc {
font-size: 12.5px;
color: var(--text-dim);
}
.tool-row.disabled .tool-name,
.tool-row.disabled .tool-desc {
opacity: 0.5;
}
.tool-hint {
display: block;
font-size: 12px;
line-height: 1.4;
color: var(--text-faint);
}
.tools-key {
margin: 0 0 4px;
}
/* Toggle switch */
.switch {
position: relative;
display: inline-flex;
flex: none;
width: 40px;
height: 24px;
cursor: pointer;
}
.switch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.switch-track {
position: absolute;
inset: 0;
border-radius: 999px;
background: var(--bg);
border: 1px solid var(--border-strong);
transition: background 0.15s, border-color 0.15s;
}
.switch-track::after {
content: "";
position: absolute;
top: 50%;
left: 3px;
transform: translateY(-50%);
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-dim);
transition: transform 0.18s ease, background 0.15s;
}
.switch input:checked + .switch-track {
background: var(--text);
border-color: var(--text);
}
.switch input:checked + .switch-track::after {
transform: translate(16px, -50%);
background: var(--bg);
}
.switch input:focus-visible + .switch-track {
outline: 2px solid var(--text-dim);
outline-offset: 2px;
}
.switch input:disabled {
cursor: not-allowed;
}
.switch input:disabled + .switch-track {
opacity: 0.5;
}
/* ─── Webcam preview (camera tool) ──────────────────────────────────────────
* Floating self-view, bottom-left. Mirrored for the user; the frame sent to
* the model is drawn un-mirrored (see captureSnapshot). */
.cam-pip {
position: fixed;
left: 20px;
bottom: 20px;
width: 280px;
aspect-ratio: 4 / 3;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--border-strong);
background: var(--bg-elev);
box-shadow: var(--shadow-soft);
z-index: 90;
opacity: 0;
transform: translateY(8px) scale(0.96);
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease;
}
.cam-pip.visible {
opacity: 1;
transform: none;
}
.cam-video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1); /* mirror the self-view only */
background: var(--bg);
}
.cam-label {
position: absolute;
left: 8px;
bottom: 6px;
font-family: var(--font-mono);
font-size: 9.5px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text);
opacity: 0.8;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
}
.cam-flash {
position: absolute;
inset: 0;
background: #fff;
opacity: 0;
pointer-events: none;
}
.cam-pip.flash .cam-flash {
animation: cam-flash 0.4s ease;
}
@keyframes cam-flash {
0% { opacity: 0; }
12% { opacity: 0.85; }
100% { opacity: 0; }
}
@media (max-width: 600px) {
/* Bottom-centred on phones. Auto margins centre it without touching the
* transform, so the slide/scale-in animation still works. */
.cam-pip {
left: 0;
right: 0;
margin-inline: auto;
bottom: 16px;
width: min(188px, 52vw);
}
/* The credit would sit under the centred preview, so drop it while the
* camera is on. */
body.cam-on .footer {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cam-pip {
transition: opacity 0.22s ease;
transform: none;
}
.cam-pip.flash .cam-flash {
animation: none;
}
}
/* ─── Desktop type scale ────────────────────────────────────────────────────
* A uniform step up (~+1px, title +2) for all UI text on larger screens.
* Scoped to min-width: 601px so it can't reach phones — the ≤600px layout
* keeps every size exactly as it was. */
@media (min-width: 601px) {
.cam-label { font-size: 10.5px; }
.bubble-role,
.hist-role { font-size: 11px; }
.circle-caption,
.footer,
.chat-empty-title,
.hist-tool-block,
.ident-label,
.pipeline-title,
.pipe-endpoint { font-size: 12px; }
.field > span,
.field small,
.bubble.tool,
.chat-empty-hint,
.hist-tool-name,
.pipe-tag,
.pipe-model,
.tool-hint { font-size: 13px; }
.tool-desc { font-size: 13.5px; }
.btn,
.bubble,
.field,
.hist-body,
.hist-tool-header,
.tools-intro { font-size: 14px; }
.handle { font-size: 14.5px; }
.about-intro p,
.about-repo,
.brand,
.chat-panel-header h3,
.field textarea,
.ident-meta,
.pipe-job,
.tool-name { font-size: 15px; }
.ident-blurb { font-size: 15.5px; }
.modal-header h2 { font-size: 17px; }
.ident-title { font-size: 24px; }
}
/* ─── Short viewports: fit without a vertical scrollbar ─────────────────────
* The base media queries only react to WIDTH, so a short container (e.g. the
* app embedded in a small box, or a laptop window once the browser chrome eats
* into the viewport) used to overflow vertically and get clipped. We KEEP the
* identity header, orb, caption and credits — we just undo the desktop type
* bump, tighten the vertical rhythm and let the height-capped orb shrink, so
* everything fits with comfortable margin. No opt-in needed.
*
* The single biggest space eater is the identity block (~180px at the desktop
* type scale), so this tier compacts it first. */
@media (max-height: 780px) {
.topbar { padding: 14px 24px; }
.ident { gap: 5px; }
.ident-title { font-size: 20px; }
.ident-blurb { font-size: 13.5px; line-height: 1.4; }
.ident-meta { gap: 5px; font-size: 13px; }
.stage { padding: 12px 20px 16px; gap: 12px; }
.footer { padding: 8px 24px 10px; }
.circle { width: clamp(140px, min(38vw, 44vh), 300px); }
}
@media (max-height: 560px) {
.topbar { padding: 8px 16px; }
.ident-title { font-size: 17px; }
/* Below this, room is tight enough that the one-line blurb and the footer
* (a verbatim duplicate of the header's "Powered by" credit) are dropped so
* the title + credits still read; the rest of the header stays. */
.ident-blurb { display: none; }
.stage { padding: 8px 14px 10px; gap: 8px; }
.footer { display: none; }
.circle { width: clamp(120px, min(50vw, 46vh), 240px); }
}