hf-realtime-voice / style.css
A-Mahla's picture
A-Mahla HF Staff
Warm up queue copy: overhug subline + funnier at-capacity modal
d2d793e
Raw
History Blame Contribute Delete
68.2 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;
/* 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;
}
/* ─── 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;
/* 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);
}
.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 {
width: clamp(170px, 56vw, 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; }
}