Files
vocero-client/s2s-client/style.css
T
2026-09-10 15:57:48 -04:00

4211 lines
124 KiB
CSS
Raw 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: #07080d;
--bg-elev: rgba(22, 25, 37, 0.55);
--bg-elev-2: rgba(31, 35, 52, 0.55);
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.16);
--glass: rgba(18, 20, 30, 0.45);
--glass-strong: rgba(18, 20, 30, 0.62);
--text: #f5f6fa;
--text-dim: rgba(245, 246, 250, 0.70);
--text-faint: rgba(245, 246, 250, 0.48);
--brand: #8b5cf6;
--brand-2: #ec4899;
--brand-3: #22d3ee;
--accent: #a78bfa;
--accent-2: #22d3ee;
--listening: #22d3ee;
--speaking: #a78bfa;
--processing: #fbbf24;
--error: #ff6a75;
--success: #34d399;
--brand-gradient: linear-gradient(135deg, #8b5cf6 0%, #ec4899 50%, #22d3ee 100%);
--brand-soft-gradient: linear-gradient(135deg, color-mix(in srgb, #8b5cf6 55%, transparent),
color-mix(in srgb, #ec4899 48%, transparent) 55%,
color-mix(in srgb, #22d3ee 55%, transparent));
/* 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;
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* 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: 10px;
--radius-md: 16px;
--radius-lg: 26px;
--radius-xl: 34px;
--shadow-soft: 0 10px 40px rgba(0, 0, 0, 0.35);
--shadow-glow-brand: 0 0 40px -10px color-mix(in srgb, var(--brand) 60%, transparent),
0 0 80px -30px color-mix(in srgb, var(--brand-2) 55%, transparent);
--shadow-glass: 0 8px 40px rgba(0, 0, 0, 0.32), 0 2px 8px rgba(0, 0, 0, 0.18);
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
body {
font-family: var(--font-sans);
color: var(--text);
/* Guarantee the page at least fills the viewport; if content is taller we
* scroll the document naturally (so footer is reachable via scroll in any
* case). Sticky .app-footer pins it to the BOTTOM of the viewport while
* scrolling / at rest. */
min-height: 100vh;
min-height: 100svh;
min-height: 100dvh;
width: 100%;
/* ─── iOS / notch / rounded-screen safe-area insets ───
The user reported the header brand card is slightly clipped (avatar AV
touches the top-right viewport edge). Mobile OSes use a non-rectangular
bezel/notch and an iOS Home Indicator bar at the bottom. We reserve
padding at document scope so NOTHING (topbar orb chat footer) is ever
painted into the OS reserved bezels. The `max(10px, env(...))` fallback
gives a minimum 10px breathing even on desktop / devices where env()
resolves to 0. */
padding-top: max(10px, env(safe-area-inset-top , 0px));
padding-right: max(10px, env(safe-area-inset-right , 0px));
padding-left: max(10px, env(safe-area-inset-left , 0px));
/* Bottom handled by #app padding-bottom below (footer bar + home indicator). */
overflow-x: hidden;
overflow-y: auto;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
position: relative;
background:
radial-gradient(1200px 800px at 15% -10%, rgba(139, 92, 246, 0.30) 0%, transparent 60%),
radial-gradient(900px 700px at 110% 10%, rgba(236, 72, 153, 0.22) 0%, transparent 55%),
radial-gradient(1000px 800px at 50% 120%, rgba(34, 211, 238, 0.22) 0%, transparent 60%),
radial-gradient(ellipse at 50% 50%, #0e1019 0%, var(--bg) 65%);
background-attachment: fixed;
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
radial-gradient(rgba(255, 255, 255, 0.045) 1px, transparent 1px),
radial-gradient(rgba(139, 92, 246, 0.035) 1px, transparent 1px);
background-size: 42px 42px, 42px 42px;
background-position: 0 0, 21px 21px;
mix-blend-mode: screen;
opacity: 0.55;
mask-image: linear-gradient(180deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.4) 70%, transparent 100%);
}
body::after {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(600px 600px at 20% 80%, rgba(139, 92, 246, 0.10), transparent 70%),
radial-gradient(500px 500px at 80% 20%, rgba(34, 211, 238, 0.08), transparent 70%);
animation: auroraShift 22s ease-in-out infinite alternate;
}
@keyframes auroraShift {
0% { transform: translate3d(0, 0, 0) scale(1); opacity: .9; }
50% { transform: translate3d(3%, -2%, 0) scale(1.08); opacity: 1; }
100% { transform: translate3d(-2%, 2%, 0) scale(1.02); opacity: .85; }
}
/* ─── Overlay unlocker TALK mode (S2S_START_SESSION_MODE=TALK) ───
Cattura QUALSIASI primo tap/click dell'utente sulla pagina, perché
i browser bloccano getUserMedia() fuori da una user gesture. Senza
questo layer il doStart() del setTimeout falliva e non si sentiva
il greet. Sopra TUTTO tranne i modali <dialog> (z-index auto di dialog
vince). */
.talk-unlocker {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, rgba(14,16,25,0.35) 0%, rgba(14,16,25,0.55) 60%, rgba(14,16,25,0.75) 100%);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
cursor: pointer;
animation: talkUnlockFade 260ms ease-out both;
}
.talk-unlocker[hidden] {
display: none !important;
}
@keyframes talkUnlockFade {
from { opacity: 0; }
to { opacity: 1; }
}
.talk-unlocker-hint {
width: min(86vw, 420px);
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 28px 22px 24px;
border-radius: 22px;
background: rgba(22, 25, 40, 0.78);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
0 20px 50px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
text-align: center;
animation: talkUnlockPop 380ms cubic-bezier(.2,.9,.3,1.2) 80ms both;
}
@keyframes talkUnlockPop {
from { opacity: 0; transform: translateY(14px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.talk-unlocker-spinner {
width: 38px;
height: 38px;
border-radius: 50%;
border: 3px solid rgba(255,255,255,0.1);
border-top-color: #8b5cf6;
border-right-color: #ec4899;
animation: talkSpinnerSpin 900ms linear infinite;
}
@keyframes talkSpinnerSpin {
to { transform: rotate(360deg); }
}
.talk-unlocker-title {
margin: 0;
font-size: 18px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text, #f3f4f6);
}
.talk-unlocker-sub {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(226, 232, 240, 0.68);
}
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 {
/* Simple flex column layout: header → stage → chat-panel.
At least fills viewport, grows if contents taller. The footer below is a
sibling position:sticky bottom=0 (NOT a child of #app) so to prevent the
sticky footer from COVERING the last line of the chat history (which
would otherwise be painted under the ~32-44px of glass footer) we reserve
a bottom padding matching the real footer height + breathing margin +
iOS Home Indicator (safe-area-inset-bottom). */
display: flex;
flex-direction: column;
width: 100%;
min-height: 100vh;
min-height: 100svh;
min-height: 100dvh;
/* 120px minimum = glass footer bar (32-44) + large breathing (76-88) + any
iOS home indicator safe inset. We intentionally OVER-provision here: the
last assistant bubble in the screenshot was clipped because the total
reserved room (56 + chat-inner 96) was smaller than the real floating
footer + text-wrap delta + OS safe area. */
padding-bottom: max(120px, calc(env(safe-area-inset-bottom, 0px) + 120px));
padding-right: 0; /* side safe areas already on <body>; don't double-add */
padding-left: 0;
padding-top: 0;
box-sizing: border-box;
}
.hidden {
display: none !important;
}
/* ─── Footer ─────────────────────────────────────────────────────────── */
/* #app uses flex column layout so footer sits as the last child.
We make it STICKY at the very bottom of the viewport so:
- if page fits within viewport → footer is at bottom, visible.
- if page is taller and we scroll document → footer STAYS pinned at
viewport bottom until the very end where it joins the end of content.
Glass tinted bg prevents footer text becoming unreadable when overlaying
the orb / aurora gradients during mid-scroll. */
.app-footer {
position: sticky;
bottom: 0;
left: 0;
width: 100%;
flex: 0 0 auto; /* never shrink or grow the footer */
display: flex;
align-items: center;
justify-content: center;
/* On iOS the floating "home indicator" pill is drawn at the very bottom of
the screen inside env(safe-area-inset-bottom) and obscures content. We
extend the footer glass padding to sit ABOVE the pill. Desktop browsers
resolve env() to 0, so clamp(max(12px,...)) is 12–20px there. */
padding:
clamp(8px, 1.6vw, 12px)
clamp(16px, 4vw, 24px)
max(clamp(12px, 2.4vw, 20px), env(safe-area-inset-bottom, 0px));
user-select: none;
pointer-events: none;
z-index: 3; /* above aurora bg & stage pedestal, below modals (z 200+) */
box-sizing: border-box;
backdrop-filter: blur(10px) saturate(140%);
-webkit-backdrop-filter: blur(10px) saturate(140%);
background:
linear-gradient(180deg,
transparent 0%,
color-mix(in srgb, var(--bg) 72%, transparent) 40%,
color-mix(in srgb, var(--bg) 90%, transparent) 100%);
}
.app-footer::before {
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: min(640px, 70vw);
height: 1px;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--brand) 40%, transparent) 30%,
color-mix(in srgb, var(--brand-2) 40%, transparent) 70%, transparent);
opacity: 0.7;
}
.app-footer-text {
font-family: var(--font-sans);
font-weight: 500;
font-size: clamp(10px, 1.5vw, 11px);
letter-spacing: 0.18em;
text-transform: uppercase;
background: var(--brand-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
text-align: center;
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
line-height: 1.35;
max-width: 100%;
filter: drop-shadow(0 1px 0 rgba(0,0,0,.5));
}
/* ─── 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;
/* Before 2026-09-04: padding-top 10px. Was clipped on mobile because the
rounded bezel of the phone + OS notch/inset sits on top. <body> now
reserves safe-area-inset-top (10px minimum on desktop) and we push the
topbar itself another 6px down so the brand glass card rounded corners
are fully visible, not half-painted behind the screen bezel. */
padding: 16px 16px 0 16px;
margin-top: max(6px, calc(env(safe-area-inset-top, 0px) * 0.2));
/* Default stacking (below the conversation panel, z 200) so the panel drawer
* covers the controls when it's open. */
position: relative;
z-index: 2;
gap: 8px;
flex-wrap: wrap;
flex: 0 0 auto; /* never let the topbar shrink/stretch inside flex col */
/* Prevent brand card edge from touching the right screen bezel on mobile:
box-sizing so padding is taken inside the topbar width. */
width: 100%;
box-sizing: border-box;
}
.brand {
display: flex;
align-items: flex-start;
gap: 10px;
font-weight: 600;
font-size: 14px;
letter-spacing: 0.01em;
color: var(--text-dim);
flex: 1 1 360px;
min-width: 0;
padding: 10px 12px;
border-radius: var(--radius-md);
background:
linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)) ,
var(--glass);
border: 1px solid var(--border);
backdrop-filter: blur(14px) saturate(135%);
-webkit-backdrop-filter: blur(14px) saturate(135%);
box-shadow: var(--shadow-glass), inset 0 1px 0 rgba(255,255,255,0.05);
position: relative;
overflow: hidden;
}
.brand::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, color-mix(in srgb, var(--brand) 45%, transparent),
transparent 40%,
color-mix(in srgb, var(--brand-2) 35%, transparent));
-webkit-mask: linear-gradient(#000, #000) content-box, linear-gradient(#000, #000);
mask: linear-gradient(#000, #000) content-box, linear-gradient(#000, #000);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0.7;
}
/* 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.65;
color: var(--text-faint);
}
.brand .brand-logo {
width: 30px;
height: 30px;
object-fit: contain;
flex: none;
filter: drop-shadow(0 2px 12px color-mix(in srgb, var(--brand) 60%, transparent));
transition: transform 0.35s cubic-bezier(.2,.7,.2,1), filter .35s ease;
}
.brand:hover .brand-logo {
transform: translateY(-2px) rotate(-4deg) scale(1.04);
filter: drop-shadow(0 4px 20px color-mix(in srgb, var(--brand-2) 70%, transparent));
}
.ident-title {
font-weight: 700;
font-size: 20px;
letter-spacing: -0.01em;
line-height: 1.1;
background: var(--brand-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
filter: drop-shadow(0 1px 0 rgba(0,0,0,.25));
display: block;
/* Previously a stacked two-line block had a small bottom spacer; now the
* title and avatar share the row → we don't need it any more. */
margin-bottom: 0;
transition: transform .3s cubic-bezier(.2,.7,.2,1), filter .3s ease;
}
.brand:hover .ident-title {
filter: drop-shadow(0 2px 10px color-mix(in srgb, var(--brand-2) 45%, transparent));
transform: translateY(-0.5px);
}
/* ── User avatar circle (instead of old "Utente: Nome" text) ──
* - perfectly round badge, matches the 42px icon-btn size in the topbar
* - glass + brand gradient 1px border
* - two uppercase initials (Alessandro Valenti → AV), bold white
* - hovering shows the full name via HTML title="" (still accessible), and
* lifts slightly with a soft brand glow like the topbar icon buttons. */
.ident-user-name {
position: relative;
flex: none;
width: 40px;
height: 40px;
aspect-ratio: 1 / 1;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid color-mix(in srgb, var(--brand) 32%, var(--border));
color: #fff;
background:
radial-gradient(120% 120% at 30% 20%, rgba(255,255,255,0.18), transparent 55%),
linear-gradient(135deg,
color-mix(in srgb, var(--brand) 72%, transparent),
color-mix(in srgb, var(--brand-2) 60%, transparent) 55%,
color-mix(in srgb, var(--brand-3) 55%, transparent));
box-shadow:
0 0 0 1px rgba(255,255,255,0.05) inset,
0 1px 0 rgba(255,255,255,0.08) inset,
0 2px 10px rgba(0,0,0,0.24),
0 0 22px -8px color-mix(in srgb, var(--brand) 58%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
font-family: var(--font-sans);
font-weight: 800;
font-size: 14px;
line-height: 1;
letter-spacing: 0.05em;
text-transform: uppercase;
text-shadow: 0 1px 2px rgba(0,0,0,0.35);
user-select: none;
cursor: default;
transition:
transform .22s cubic-bezier(.2,.7,.2,1),
box-shadow .3s ease,
border-color .3s ease;
}
.ident-user-name::before {
/* Subtle 1px highlight rim on the top-left quadrant. */
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(135deg, rgba(255,255,255,0.22), transparent 42%);
opacity: .9;
pointer-events: none;
mix-blend-mode: screen;
}
.ident-user-name:hover {
transform: translateY(-1.5px) scale(1.02);
box-shadow:
0 0 0 1px rgba(255,255,255,0.07) inset,
0 1px 0 rgba(255,255,255,0.1) inset,
0 6px 22px rgba(0,0,0,0.32),
0 0 34px -6px color-mix(in srgb, var(--brand-2) 70%, transparent);
border-color: color-mix(in srgb, var(--brand-2) 55%, var(--border));
}
/* Accessibility: keyboard-focusable style if ever wrapped in a <button> or gets
tabIndex (future-proof, we set :focus-visible for robustness). */
.ident-user-name:focus-visible {
outline: none;
box-shadow:
0 0 0 3px color-mix(in srgb, var(--brand-3) 45%, transparent),
0 1px 0 rgba(255,255,255,0.08) inset,
0 6px 22px rgba(0,0,0,0.28),
0 0 34px -6px color-mix(in srgb, var(--brand) 60%, transparent);
}
@keyframes pulseDot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .78; transform: scale(.9); }
}
/* 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;
}
/* ── Footer (narrow viewport): riduci tracking per non farlo andare
* a capo in malo modo; padding compatto + line-height maggiore */
.app-footer {
padding: 8px 14px 14px;
}
.app-footer::before {
width: min(320px, 82vw);
}
.app-footer-text {
letter-spacing: 0.12em;
line-height: 1.5;
font-size: 10px;
}
}
.topbar-right {
display: flex;
align-items: center;
gap: 10px;
}
/* Conversation history button: always-visible sibling of .topbar-right.
Same visual styling, but never gated by the ?debug=true flag. */
.chat-btn-always {
display: flex;
align-items: center;
gap: 10px;
/* Keep visual gap consistent whether debug buttons are shown or not. */
margin-right: 4px;
}
.chat-btn-always .icon-btn {
position: relative;
}
/* Safety fallback for any debug-only container: stays hidden unless the JS
bootstrap explicitly enables it (sets data-debug-enabled="true"). */
[data-debug-only="true"]:not([data-debug-enabled="true"]) {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
width: 0 !important;
height: 0 !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
}
/* 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;
/* Pack the orb + caption up top and make stage MIN-content (no longer eat
all remaining vertical space) — so the inline chat panel slides up and
sits immediately under "TAP OR SAY HOLA VOCERO" with a thin breathing
gap instead of a huge empty area. */
justify-content: flex-start;
padding: clamp(10px, 2vw, 20px) clamp(12px, 3vw, 24px) clamp(6px, 1.2vh, 14px);
gap: clamp(12px, 2.5vw, 20px);
padding-top: clamp(14px, 3vh, 36px);
/* Shrink to content. The chat panel below is what grows to fill slack. */
flex: 0 1 auto;
min-height: 0;
position: relative;
z-index: 1;
}
/* Subtle radial "light pedestal" behind the orb — makes the whole cluster
feel anchored instead of floating on the dark grid. */
.stage::before {
content: "";
position: absolute;
top: 46%;
left: 50%;
width: clamp(260px, 58vw, 620px);
height: clamp(260px, 58vw, 620px);
transform: translate(-50%, -50%);
background: radial-gradient(circle,
color-mix(in srgb, var(--brand) 22%, transparent) 0%,
color-mix(in srgb, var(--brand-2) 12%, transparent) 45%,
transparent 75%);
filter: blur(12px);
opacity: .65;
pointer-events: none;
border-radius: 999px;
animation: pedestalBreathe 8s ease-in-out infinite alternate;
}
@keyframes pedestalBreathe {
0% { transform: translate(-50%, -50%) scale(1); opacity: .55; }
50% { transform: translate(-50%, -50%) scale(1.06); opacity: .75; }
100% { transform: translate(-50%, -50%) scale(.98); opacity: .5; }
}
/* ─── 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 smallest-dim viewport units — svw/svh = smallest-viewport w/h so the
URL bar showing/hiding on mobile never makes the orb change size suddenly.
Since ~30% of the viewport is already header, we use 42svh as max height. */
width: clamp(120px, min(38svw, 42svh), 300px);
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::before {
content: "";
position: absolute;
inset: -8%;
border-radius: 50%;
background: conic-gradient(from 0deg,
color-mix(in srgb, var(--brand) 65%, transparent) 0%,
color-mix(in srgb, var(--brand-2) 55%, transparent) 35%,
color-mix(in srgb, var(--brand-3) 55%, transparent) 65%,
color-mix(in srgb, var(--brand) 65%, transparent) 100%);
filter: blur(14px);
opacity: 0.55;
z-index: 0;
pointer-events: none;
animation: orbConicSpin 18s linear infinite;
}
@keyframes orbConicSpin {
to { transform: rotate(360deg); }
}
.circle:hover {
filter: brightness(1.08);
}
.circle:hover::before { opacity: .8; }
.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: -8%;
border-radius: 50%;
background: radial-gradient(circle at center, var(--glow, var(--accent)) 0%, transparent 60%);
filter: blur(34px);
opacity: 0.55;
transform: scale(1);
transition: opacity 0.25s, background 0.25s, transform 1.4s ease-in-out;
pointer-events: none;
z-index: 0;
}
/* Idle-only slow "breathe" pulse for the outer glow — keeps the orb alive
even when the user is doing nothing. */
.circle.state-idle .circle-glow,
.circle.state-idle::before {
animation:
orbConicSpin 22s linear infinite,
orbBreatheGlow 5s ease-in-out infinite alternate;
}
@keyframes orbBreatheGlow {
0% { opacity: .42; transform: scale(.985); filter: blur(30px); }
50% { opacity: .72; transform: scale(1.04); filter: blur(40px); }
100% { opacity: .48; transform: scale(1); filter: blur(32px); }
}
/* 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;
z-index: 1;
}
.circle-ring {
width: 86%;
height: 86%;
border: 1.5px solid transparent;
background:
linear-gradient(var(--bg, #07080d), var(--bg, #07080d)) padding-box,
conic-gradient(from 0deg,
color-mix(in srgb, var(--brand) 80%, transparent),
color-mix(in srgb, var(--brand-2) 70%, transparent) 40%,
color-mix(in srgb, var(--brand-3) 75%, transparent) 70%,
color-mix(in srgb, var(--brand) 80%, transparent)) border-box;
border-color: transparent;
mask:
linear-gradient(#000, #000) content-box,
linear-gradient(#000, #000);
-webkit-mask:
linear-gradient(#000, #000) content-box,
linear-gradient(#000, #000);
-webkit-mask-composite: xor;
mask-composite: exclude;
box-shadow:
0 0 20px -6px color-mix(in srgb, var(--brand) 55%, transparent);
opacity: 0.75;
}
.circle-ring-outer {
width: 98%;
height: 98%;
border: 1px dashed color-mix(in srgb, var(--glow, var(--accent)) 30%, transparent);
opacity: 0.55;
}
.circle.state-idle .circle-ring-outer {
animation: outerRingPulse 6s ease-in-out infinite;
}
@keyframes outerRingPulse {
0%, 100% { opacity: .4; transform: translate(-50%,-50%) scale(1); }
50% { opacity: .65; transform: translate(-50%,-50%) scale(1.02); }
}
.circle-core {
position: relative;
width: 72%;
height: 72%;
border-radius: 50%;
background:
radial-gradient(circle at 30% 22%,
rgba(255,255,255,0.18) 0%,
rgba(255,255,255,0.04) 20%,
transparent 45%),
radial-gradient(circle at 35% 28%,
color-mix(in srgb, var(--glow, var(--accent)) 32%, transparent),
color-mix(in srgb, var(--glow, var(--accent)) 12%, transparent) 55%,
color-mix(in srgb, var(--glow, var(--accent)) 5%, transparent));
border: 1.5px solid color-mix(in srgb, var(--glow, var(--accent)) 48%, transparent);
display: grid;
place-items: center;
box-shadow:
0 0 44px -8px color-mix(in srgb, var(--glow, var(--accent)) 42%, transparent),
inset 0 0 34px color-mix(in srgb, var(--glow, var(--accent)) 22%, transparent),
inset 0 1px 0 rgba(255,255,255,0.12);
transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease, transform 0.4s ease;
z-index: 2;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.circle.state-idle .circle-core {
animation: coreBreathe 5s ease-in-out infinite alternate;
}
@keyframes coreBreathe {
0% { transform: scale(0.992); box-shadow: 0 0 36px -8px color-mix(in srgb, var(--brand) 40%, transparent), inset 0 0 26px color-mix(in srgb, var(--brand) 16%, transparent); }
50% { transform: scale(1.01); box-shadow: 0 0 54px -4px color-mix(in srgb, var(--brand-2) 48%, transparent), inset 0 0 38px color-mix(in srgb, var(--brand-2) 22%, transparent); }
100% { transform: scale(1); box-shadow: 0 0 44px -6px color-mix(in srgb, var(--brand-3) 42%, transparent), inset 0 0 32px color-mix(in srgb, var(--brand-3) 20%, transparent); }
}
/* 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. */
/* Orbit-caption keeps the "machine voice" position — mono, uppercase,
* letter-spaced. For idle state we tint it with the brand gradient so it
* reads as a CTA rather than dead metadata. */
.circle-caption {
margin: 0;
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--text-faint);
min-height: 1.3em;
text-align: center;
opacity: 0.78;
transition: opacity 0.25s ease, color 0.25s ease, transform 0.25s ease,
min-height 0.25s ease, background 0.3s ease, filter 0.3s ease;
line-height: 1.2;
}
.circle-caption:empty::before { content: "\00a0"; }
/* Idle CTA styling: brand gradient + bob. We use two ways to ensure
coverage: the modern :has() selector, plus a .idle-cta class toggled
from JS as a fallback for older embedded WebViews. */
.stage:has(.circle.state-idle) .circle-caption,
.circle-caption.idle-cta {
background: var(--brand-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
opacity: 1;
filter: drop-shadow(0 2px 10px color-mix(in srgb, var(--brand) 40%, transparent));
animation: idleCaptionBob 3.6s ease-in-out infinite alternate;
}
@keyframes idleCaptionBob {
0%, 100% { transform: translateY(0); letter-spacing: 0.24em; }
50% { transform: translateY(-1px); letter-spacing: 0.26em; }
}
.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;
background: none;
-webkit-text-fill-color: var(--error);
text-fill-color: var(--error);
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--error) 55%, transparent));
}
/* 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 (NOW INLINE in #app, below stage, above footer) ─ */
/* Old rule: position:fixed right-side overlay.
New rule: direct flex-col child of #app, 100% width, grows if content is
taller up to a max of ~40% viewport height, scrolls the history inside.
Keeps all the "ChatView" internals intact (bubble history renderer, etc). */
.chat-panel {
position: static;
inset: auto;
z-index: 1;
pointer-events: auto;
width: 100%;
/* Stage now stays compact above us; chat-panel absorbs any remaining vertical
space so it sits IMMEDIATELY below the orb caption and extends down to the
sticky footer — while keeping a sensible max so it never squishes stage. */
flex: 1 1 auto;
min-height: 160px;
max-height: 65svh;
max-height: 65dvh;
display: flex;
flex-direction: column;
/* Horizontal margins match the 920px inner max-width line-up + side rails.
BOTTOM PADDING (critical 2026-09-04 bump): was clamp(40px, 9vh, 96px).
The last assistant bubble line was STILL clipped against the sticky
footer because (a) the footer is rendered outside #app as a floating
overlay so it covers 32-56px of the chat area regardless of history
internal height and (b) when a bubble wraps to >5 lines on narrow mobile
its rendered height is larger than the 96px breathing. New minimum
60/160 (12vh = 160 at ~1333 svh but max 160px) adds another 64px. */
padding: clamp(8px, 1.4vh, 14px) clamp(16px, 3vw, 24px) clamp(60px, 12vh, 160px);
box-sizing: border-box;
overflow: hidden;
}
/* Inner wrapper: SEAMLESS (no glass card / no border / no shadow) per user
request 2026-09-04 — the chat area visually melts into the aurora page
background, no chrome box or visible frame around the history. We keep the
layout sizing + z-ordering but neutralise all decoration. */
.chat-panel-inner {
position: relative;
width: 100%;
height: 100%;
margin: 0 auto;
max-width: 920px;
box-sizing: border-box;
display: flex;
flex-direction: column;
/* Decoration removed — seamless transparent on aurora bg */
background: transparent;
border: 0 solid transparent;
border-radius: 0;
overflow: hidden;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.chat-panel-inner::before {
/* Top aurora fade removed (would add an artificial tint vs the page bg). */
content: none;
}
.chat-panel-inner > * { position: relative; z-index: 1; }
/* The inline chat is ALWAYS shown so we never need .open / backdrop rules. */
.chat-panel-backdrop { display: none; }
.chat-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
/* Padding sides 14px so "CONVERSACIÓN" lines up with the 14px content insets
of the bubbles below and leaves a clear breathing column near the right
edge so the assistant bubble never touches the screen border. */
padding: 0 14px 6px;
border-bottom: 0 solid transparent;
flex: none;
}
.chat-panel-header h3 {
margin: 0;
font-size: 13px;
letter-spacing: 0.04em;
background: var(--brand-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 800;
text-transform: uppercase;
opacity: .92;
}
/* Chat history: flex column of user/assistant bubbles — scrolled independently
while header + input row stay pinned. Transparent seamless on page aurora (no own bg).
Padding 14px sides for alignment with the header.
NOTE: the critical "not attached to screen border" rule is enforced by
`margin-right:22px` on `.hist-msg.assistant` and `margin-left:6px` on `.hist-msg.user`
(hard rules on the bubbles themselves so nothing can hide them).
Bottom padding BUMP 2026-09-04: 96 → 160px. The screenshot showed the last
assistant line "...Necesitas más detalles o" cut clean by the footer. 160px
inside the scroll container is 5× the real footer glass height; after we
snap scrollTop to the absolute max, the last bubble terminates at the TOP
of a 160px-high transparent region → fully visible, fully above the floating
sticky footer even on very short (800px svh) mobile viewports. */
.chat-history {
flex: 1 1 auto;
overflow-y: auto;
/* 160px bottom padding inside the scrollbox means scrollHeight grows to include
those 160px. When we snap scrollTop = max, the last bubble ends at the top
of that 160px void → visible and well above the floating footer line. */
padding: 4px 14px 160px;
display: flex;
flex-direction: column;
gap: 10px;
scrollbar-width: thin;
/* Disable Chrome's "scroll anchoring" which sometimes fights our snap-to-bottom by
re-anchoring the scroll pos whenever a new token is appended. We truly want
the tail, always, no exceptions. */
overflow-anchor: none;
/* Instant snap (never smooth) — the default was causing perceived "scroll
scroll does not go far enough" because smooth animates while new content
outpaces the animation frame budget on long streams). */
scroll-behavior: auto !important;
/* gutter removed: spacing is guaranteed by per-bubble margin rules instead */
}
.chat-history::-webkit-scrollbar { width: 6px; }
.chat-history::-webkit-scrollbar-track { background: transparent; }
.chat-history::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--brand) 30%, transparent);
border-radius: 999px;
border: 1px solid transparent;
background-clip: content-box;
}
/* ─── Chat input row (bottom of chat panel: "sobre el botón") ──────── */
/* NOTE: currently HIDDEN in index.html. Kept seamless here so if/when we
re-enable it the form melts visually into the page bg, no separator frame. */
.chat-form {
display: flex;
align-items: stretch;
gap: 8px;
padding: 8px 2px 0;
border-top: 0 solid transparent;
background: transparent;
flex: none;
}
.chat-input {
flex: 1 1 auto;
min-width: 0;
height: 40px;
padding: 0 14px;
border-radius: 12px;
background: color-mix(in srgb, var(--bg) 70%, rgba(255,255,255,0.02));
border: 1px solid color-mix(in srgb, var(--brand) 18%, var(--border));
color: var(--text);
font-size: 14px;
font-family: var(--font-sans);
line-height: 1;
transition: border-color .2s ease, box-shadow .2s ease, background .2s ease;
outline: none;
box-shadow: 0 0 0 0 transparent;
}
.chat-input::placeholder {
color: color-mix(in srgb, var(--text-dim) 72%, transparent);
letter-spacing: 0.01em;
}
.chat-input:hover {
border-color: color-mix(in srgb, var(--brand-2) 44%, var(--border));
}
.chat-input:focus {
border-color: color-mix(in srgb, var(--brand-3) 60%, transparent);
background: color-mix(in srgb, var(--bg) 82%, rgba(255,255,255,0.04));
box-shadow:
0 0 0 3px color-mix(in srgb, var(--brand-3) 22%, transparent),
0 0 26px -8px color-mix(in srgb, var(--brand-3) 70%, transparent);
}
.chat-send-btn {
flex: none;
width: 40px;
height: 40px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: #fff;
background:
radial-gradient(120% 120% at 30% 18%, rgba(255,255,255,0.18), transparent 58%),
linear-gradient(135deg,
color-mix(in srgb, var(--brand) 82%, transparent),
color-mix(in srgb, var(--brand-2) 70%, transparent) 55%,
color-mix(in srgb, var(--brand-3) 70%, transparent));
border: 1px solid color-mix(in srgb, var(--brand-2) 50%, var(--border));
box-shadow:
0 1px 0 rgba(255,255,255,0.1) inset,
0 6px 18px rgba(0,0,0,0.30),
0 0 24px -8px color-mix(in srgb, var(--brand-2) 70%, transparent);
cursor: pointer;
transition: transform .22s ease, box-shadow .3s ease, filter .3s ease;
}
.chat-send-btn:hover {
transform: translateY(-1px);
filter: brightness(1.05);
box-shadow:
0 1px 0 rgba(255,255,255,0.12) inset,
0 10px 22px rgba(0,0,0,0.34),
0 0 34px -6px color-mix(in srgb, var(--brand-2) 80%, transparent);
}
.chat-send-btn:active { transform: translateY(0) scale(0.98); }
.chat-send-btn:focus-visible {
outline: none;
box-shadow:
0 0 0 3px color-mix(in srgb, var(--brand-3) 40%, transparent),
0 6px 18px rgba(0,0,0,0.30);
}
/* ─── Reports sidebar (LEFT) ───────────────────────────────────────── */
/* Behaviour:
- desktop (>= 1024px): FIXED to the left edge, 320px, always visible.
Stage/header/footer leave room via #app padding-left 320px so the orb
is not covered.
- mobile / tablet: OFF-CANVAS slide-in from the left (like chat panel on
right), only shown on toggle, closed via backdrop / close button / ESC.
backdrop is shared between both breakpoints (only visible when open on
mobile; desktop uses no backdrop). */
.reports-sidebar {
position: fixed;
inset: 0;
z-index: 190; /* below chat-panel (z 200), above stage/header (z 1-2) */
pointer-events: none; /* backdrop + inner opt-in to clicks separately */
}
.reports-sidebar-backdrop {
position: absolute;
inset: 0;
background: rgba(8, 9, 13, 0.42);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.reports-sidebar-inner {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: min(320px, 88vw);
background:
linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)),
var(--bg-elev);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
transform: translateX(-105%);
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);
overflow: hidden;
}
.reports-sidebar.open .reports-sidebar-backdrop {
opacity: 1;
pointer-events: auto;
}
.reports-sidebar.open .reports-sidebar-inner {
transform: translateX(0);
}
/* Header — same pattern as chat panel header but tinted brand gradient line */
.reports-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex: none;
position: relative;
overflow: hidden;
}
.reports-sidebar-header::after {
content: "";
position: absolute;
left: 16px;
right: 16px;
bottom: -1px;
height: 1px;
background: linear-gradient(90deg,
transparent,
color-mix(in srgb, var(--brand) 60%, transparent) 45%,
color-mix(in srgb, var(--brand-2) 55%, transparent) 65%,
transparent);
opacity: .85;
}
.reports-sidebar-header h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
background: var(--brand-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Hint block lives inside the sidebar now. Reset any "topbar" flavour of the
card: no max-height cap here (the sidebar scrolls the full panel instead).
Keep full-height so reports-list chips can breathe. */
.reports-sidebar .reports-hint {
margin: 12px 14px 4px 14px;
padding: 12px 12px 10px 12px;
max-height: none;
max-width: none;
width: auto;
flex: none;
box-shadow:
var(--shadow-glass),
0 0 0 1px color-mix(in srgb, var(--brand) 16%, transparent) inset,
0 0 24px -14px color-mix(in srgb, var(--brand) 62%, transparent);
}
.reports-sidebar .reports-hint .reports-list {
gap: 6px 8px;
}
/* Scrollbar of the sidebar (NOT reports-hint — that one still has its cap
when embedded in a smaller view) */
.reports-sidebar-inner {
scrollbar-width: thin;
scrollbar-gutter: stable;
overflow-y: auto;
}
.reports-sidebar-inner::-webkit-scrollbar { width: 6px; }
.reports-sidebar-inner::-webkit-scrollbar-track { background: transparent; }
.reports-sidebar-inner::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--brand) 32%, transparent);
border-radius: 999px;
border: 1px solid transparent;
background-clip: content-box;
}
/* ── Toggle button in the topbar control cluster ─────────────────── */
button[data-reports-sidebar-toggle] {
/* Visible ONLY on mobile/tablet by default (desktop has sidebar always on) */
display: inline-flex;
}
/* Toggle button badge (count of reports scheduled, shown only if >0) */
#reports-sidebar-badge {
position: absolute;
top: -3px;
right: -3px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: linear-gradient(135deg, var(--brand-2), var(--brand));
color: #fff;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 18px;
text-align: center;
display: none;
box-shadow: 0 0 0 2px var(--bg), 0 0 10px rgba(0,0,0,.3);
pointer-events: none;
}
#reports-sidebar-badge[data-count]:not([data-count="0"]):not([data-count=""]) {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* ── DESKTOP (>=1024px): sidebar always visible on the LEFT ────────── */
@media (min-width: 1024px) {
/* Leave room for sidebar at left edge of the app */
#app {
padding-left: 320px;
box-sizing: border-box;
}
.reports-sidebar {
pointer-events: auto;
}
.reports-sidebar-backdrop {
display: none; /* never darken the page on desktop */
}
.reports-sidebar-inner {
transform: translateX(0) !important; /* keep always in */
width: 320px;
box-shadow: none; /* embedded sidebar, no floating shadow */
border-right: 1px solid color-mix(in srgb, var(--brand) 22%, var(--border));
background:
linear-gradient(180deg,
rgba(255,255,255,0.02),
rgba(255,255,255,0) 14%),
var(--bg);
}
/* Hide the toggle button in the topbar control cluster (sidebar always on) */
button[data-reports-sidebar-toggle] { display: none !important; }
/* Give the orb some extra breathing room since we stole 320px on the left */
.stage {
padding-left: clamp(24px, 4vw, 48px);
padding-right: clamp(24px, 4vw, 48px);
}
}
/* Mid-range tablet 768..1023 : sidebar is off-canvas (toggle shown) but we
can still show the toggle and use 320px sliding panel. */
@media (min-width: 768px) and (max-width: 1023px) {
.reports-sidebar-inner { width: min(360px, 78vw); }
button[data-reports-sidebar-toggle] { display: inline-flex; }
}
/* Very narrow (<=767): same off-canvas, narrower panel. */
@media (max-width: 767px) {
.reports-sidebar-inner { width: min(320px, 86vw); }
}
.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: 4px;
/* 76% leaves a clear 24% lateral column: at 14px chat side padding + 6px
scrollbar-gutter this guarantees the ASSISTANT bubble (align-self flex-end)
never touches the screen border, even with its outer glass shadow. */
max-width: 76%;
}
.hist-msg.user {
align-self: flex-start;
/* Hard safety margin-left: guarantees the left bubble NEVER touches the
viewport border, regardless of parent padding / cache drift. */
margin-left: 6px;
margin-right: auto;
}
.hist-msg.assistant {
align-self: flex-end;
/* Hard safety margin-right: 22px GUARANTEED between the rounded corner of
the assistant bubble and the right viewport/screen border. Always visible
even when no scrollbar is rendered (scrollbar-gutter was creating
inconsistent spacing between empty history and scrolled history). */
margin-right: 22px;
margin-left: auto;
}
.hist-msg.tool { align-self: flex-start; max-width: 100%; margin-right: 6px; margin-left: 6px; }
.hist-role {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-faint);
padding: 0 4px;
text-shadow: 0 1px 0 rgba(0,0,0,0.45);
}
.hist-msg.user .hist-role { color: color-mix(in srgb, var(--voice-user) 92%, white 8%); }
.hist-msg.assistant .hist-role { color: color-mix(in srgb, var(--voice-assistant) 92%, white 8%); }
.hist-msg.tool .hist-role { color: color-mix(in srgb, var(--voice-tool) 92%, white 8%); }
.hist-body {
padding: 11px 14px;
border-radius: 14px;
font-size: 14px;
line-height: 1.55;
word-break: break-word;
color: var(--text);
/* Glass base — visible against the seamless aurora page background. */
background: color-mix(in srgb, var(--bg-elev-2) 88%, rgba(255,255,255,0.03));
border: 1px solid color-mix(in srgb, var(--border) 92%, transparent);
box-shadow:
0 10px 28px rgba(0, 0, 0, 0.28),
0 0 0 1px rgba(255, 255, 255, 0.035) inset;
backdrop-filter: blur(8px) saturate(130%);
-webkit-backdrop-filter: blur(8px) saturate(130%);
}
/* Role-tinted bubbles so you can scan who's talking at a glance even when the
history is long. User bubble gets a soft cyan/violet rim; assistant gets a
pink/violet rim. Both stay readable against the page aurora. */
.hist-msg.user .hist-body {
background:
linear-gradient(180deg,
color-mix(in srgb, var(--voice-user) 10%, var(--bg-elev-2)),
color-mix(in srgb, var(--voice-user) 6%, var(--bg-elev-2))) padding-box;
border-color: color-mix(in srgb, var(--voice-user) 44%, var(--border));
box-shadow:
0 10px 28px rgba(0, 0, 0, 0.28),
0 0 0 1px color-mix(in srgb, var(--voice-user) 16%, rgba(255,255,255,0.05)) inset,
0 0 28px -14px color-mix(in srgb, var(--voice-user) 66%, transparent);
}
.hist-msg.assistant .hist-body {
background:
linear-gradient(180deg,
color-mix(in srgb, var(--voice-assistant) 10%, var(--bg-elev-2)),
color-mix(in srgb, var(--voice-assistant) 5%, var(--bg-elev-2))) padding-box;
border-color: color-mix(in srgb, var(--voice-assistant) 42%, var(--border));
box-shadow:
0 10px 28px rgba(0, 0, 0, 0.28),
0 0 0 1px color-mix(in srgb, var(--voice-assistant) 14%, rgba(255,255,255,0.05)) inset,
0 0 28px -14px color-mix(in srgb, var(--voice-assistant) 62%, transparent);
}
.hist-msg.user .hist-body.partial { opacity: 0.68; }
/* ─── 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.
* Use svh/svw (smallest-viewport units) to avoid URL bar resize jumps. */
width: clamp(110px, min(56svw, 36svh), 220px);
}
/* 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;
}
/* ─── User Profile Modal (opened by clicking the avatar circle) ──────── */
.user-profile-modal {
width: min(480px, 90vw);
max-height: 90vh;
}
.user-profile-card {
position: relative;
padding: 30px 28px 28px;
gap: 22px;
/* Glass + brand-gradient 1px border (same trick used in the .brand card). */
background:
linear-gradient(var(--bg-elev), var(--bg-elev)) padding-box,
linear-gradient(135deg,
color-mix(in srgb, var(--brand) 55%, transparent),
color-mix(in srgb, var(--brand-2) 48%, transparent) 55%,
color-mix(in srgb, var(--brand-3) 55%, transparent)) border-box;
border: 1px solid transparent;
border-radius: 18px;
box-shadow:
0 30px 80px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(255, 255, 255, 0.04) inset,
0 0 60px -20px color-mix(in srgb, var(--brand) 60%, transparent);
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
overflow: hidden;
}
.user-profile-card::before {
/* Top-edge soft aurora fade so the card feels "lit" from the top-left,
matching the orb + body aurora palette. */
content: "";
position: absolute;
inset: -1px;
background:
radial-gradient(420px 220px at 15% -10%, color-mix(in srgb, var(--brand) 26%, transparent), transparent 60%),
radial-gradient(380px 220px at 110% 15%, color-mix(in srgb, var(--brand-2) 22%, transparent), transparent 55%);
pointer-events: none;
opacity: .95;
z-index: 0;
}
.user-profile-card > * { position: relative; z-index: 1; }
/* Close button: absolute top-right, 32px so it doesn't feel cramped near
the large hero avatar. */
.user-profile-close {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
z-index: 5;
opacity: 0.88;
}
/* HERO — big glass avatar circle, name, email fallback subtitle. */
.user-profile-hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding-top: 6px;
text-align: center;
}
.user-profile-avatar {
width: 88px;
height: 88px;
aspect-ratio: 1 / 1;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid color-mix(in srgb, var(--brand) 40%, var(--border));
color: #fff;
background:
radial-gradient(140% 140% at 28% 22%, rgba(255,255,255,0.22), transparent 58%),
linear-gradient(135deg,
color-mix(in srgb, var(--brand) 76%, transparent),
color-mix(in srgb, var(--brand-2) 64%, transparent) 55%,
color-mix(in srgb, var(--brand-3) 62%, transparent));
box-shadow:
0 0 0 1px rgba(255,255,255,0.05) inset,
0 2px 0 rgba(255,255,255,0.08) inset,
0 10px 30px rgba(0,0,0,0.32),
0 0 48px -10px color-mix(in srgb, var(--brand-2) 72%, transparent);
font-family: var(--font-sans);
font-weight: 800;
font-size: 34px;
line-height: 1;
letter-spacing: 0.04em;
text-transform: uppercase;
text-shadow: 0 2px 4px rgba(0,0,0,0.35);
user-select: none;
position: relative;
}
.user-profile-avatar::after {
/* subtle specular rim */
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(135deg, rgba(255,255,255,0.22), transparent 46%);
opacity: .85;
pointer-events: none;
mix-blend-mode: screen;
}
.user-profile-title {
margin: 0;
font-size: 22px;
line-height: 1.2;
font-weight: 800;
letter-spacing: -0.01em;
background: var(--brand-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
filter: drop-shadow(0 1px 0 rgba(0,0,0,0.35));
max-width: 100%;
overflow-wrap: anywhere;
}
.user-profile-subtitle {
margin: 0;
font-size: 13px;
line-height: 1.4;
color: var(--text-dim);
max-width: 100%;
overflow-wrap: anywhere;
}
/* METADATA — 2-col grid: label (left, dim/caps) + value (right, bright). */
.user-profile-metadata {
display: grid;
grid-template-columns: 1fr 1.35fr;
row-gap: 12px;
column-gap: 16px;
padding: 10px 4px 4px;
border-top: 1px solid color-mix(in srgb, var(--brand) 18%, var(--border));
padding-top: 18px;
}
.user-profile-metadata:empty { display: none; }
.user-profile-row {
display: contents; /* let children participate in the outer grid directly */
}
.user-profile-row-label {
grid-column: 1 / 2;
align-self: start;
font-size: 11px;
line-height: 1.5;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: color-mix(in srgb, var(--brand-3) 80%, var(--text-dim));
padding-top: 2px;
}
.user-profile-row-value {
grid-column: 2 / 3;
font-size: 13.5px;
line-height: 1.45;
color: var(--text);
font-weight: 500;
overflow-wrap: anywhere;
word-break: break-word;
}
.user-profile-row-value a {
color: inherit;
text-decoration: none;
border-bottom: 1px dashed color-mix(in srgb, var(--brand-2) 50%, transparent);
padding-bottom: 1px;
}
.user-profile-row-value a:hover {
color: #fff;
border-bottom-color: color-mix(in srgb, var(--brand-2) 90%, #fff);
}
.user-profile-row-value code,
.user-profile-row-value .mono {
font-family: var(--font-mono);
font-size: 12px;
padding: 2px 7px;
border-radius: 6px;
background: color-mix(in srgb, var(--brand) 12%, rgba(255,255,255,0.03));
border: 1px solid color-mix(in srgb, var(--brand) 20%, var(--border));
color: color-mix(in srgb, var(--brand-2) 90%, #fff);
display: inline-block;
max-width: 100%;
overflow-wrap: anywhere;
}
@media (max-width: 480px) {
.user-profile-card { padding: 24px 20px 20px; }
.user-profile-avatar { width: 72px; height: 72px; font-size: 28px; }
.user-profile-title { font-size: 19px; }
.user-profile-metadata {
grid-template-columns: 1fr; /* stack label/value on super-narrow */
row-gap: 6px;
padding-top: 16px;
}
.user-profile-row-label { grid-column: 1 / -1; padding-top: 6px; }
.user-profile-row-value { grid-column: 1 / -1; }
}
/* 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 {
/* Align the wordmark (left) and the user avatar circle (right) on the SAME
* row. Vertical-align: both centered relative to each other — so "AV" in the
* round badge lines up perfectly with the "Vocero" gradient wordmark. */
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
width: 100%;
}
/* 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-user-name {
max-width: 32ch;
font-size: 13px;
line-height: 1.35;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reports-hint {
max-width: min(720px, 100%);
/* IMPORTANT: hard cap height so even 20 report chips never grow the topbar
* beyond ~35% of the viewport. If more space is needed the user scrolls
* inside the card itself (branded thin scrollbar below). */
max-height: 34svh;
max-height: 36dvh;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-gutter: stable;
font-size: 12px;
line-height: 1.3;
color: var(--text-dim);
background:
linear-gradient(180deg, rgba(255,255,255,0.028), rgba(255,255,255,0)) ,
var(--glass);
border: 1px solid color-mix(in srgb, var(--brand) 26%, var(--border));
border-radius: var(--radius-sm);
backdrop-filter: blur(12px) saturate(135%);
-webkit-backdrop-filter: blur(12px) saturate(135%);
padding: 8px 10px;
margin-top: 4px;
box-shadow:
var(--shadow-glass),
0 0 0 1px color-mix(in srgb, var(--brand) 14%, transparent) inset,
0 0 20px -10px color-mix(in srgb, var(--brand) 55%, transparent);
position: relative;
}
/* thin scrollbar inside the reports card so chips are still readable. */
.reports-hint::-webkit-scrollbar { width: 6px; }
.reports-hint::-webkit-scrollbar-track { background: transparent; }
.reports-hint::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--brand-3) 32%, transparent);
border-radius: 999px;
border: 1px solid transparent;
background-clip: content-box;
}
.reports-hint::before {
content: "";
position: absolute;
top: -1px;
left: -1px;
right: -1px;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
color-mix(in srgb, var(--brand) 70%, transparent) 25%,
color-mix(in srgb, var(--brand-2) 60%, transparent) 55%,
color-mix(in srgb, var(--brand-3) 65%, transparent) 80%,
transparent 100%);
opacity: .85;
pointer-events: none;
}
.reports-hint .reports-summary {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 4px 8px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
font-size: 11.5px;
line-height: 1.15;
}
.reports-hint .reports-summary::before {
content: "";
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--success);
box-shadow:
0 0 0 2.5px color-mix(in srgb, var(--success) 18%, transparent),
0 0 10px 1px color-mix(in srgb, var(--success) 68%, transparent);
animation: pulseDot 2.2s ease-in-out infinite;
flex: none;
}
.reports-hint .reports-summary-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 7px;
border-radius: 999px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--text);
background: linear-gradient(135deg,
color-mix(in srgb, var(--brand) 24%, transparent),
color-mix(in srgb, var(--brand-2) 20%, transparent));
border: 1px solid color-mix(in srgb, var(--brand) 30%, transparent);
white-space: nowrap;
}
.reports-hint .reports-summary-badge + .reports-summary-badge {
background: linear-gradient(135deg,
color-mix(in srgb, var(--brand-3) 20%, transparent),
color-mix(in srgb, var(--brand) 18%, transparent));
border-color: color-mix(in srgb, var(--brand-3) 28%, transparent);
}
.reports-hint .reports-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
}
.reports-hint .reports-list li {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px 3px 6px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
line-height: 1.1;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
position: relative;
border: 1px solid var(--border);
background:
linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)),
rgba(20, 22, 32, 0.52);
box-shadow: 0 1px 0 rgba(255,255,255,0.035) inset, 0 1px 4px rgba(0,0,0,.16);
transition:
transform .2s cubic-bezier(.2,.7,.2,1),
box-shadow .25s ease,
border-color .25s ease,
background .25s ease,
color .25s ease;
}
.reports-hint .reports-list li::before {
content: "";
width: 5px;
height: 5px;
border-radius: 999px;
background: var(--brand);
box-shadow: 0 0 7px 0 color-mix(in srgb, var(--brand) 75%, transparent);
flex: none;
}
/* Per-chip tint cycles (keyword-based, if no data attribute → fallback brand) */
.reports-hint .reports-list li[data-tint="mortalidad"] { background: linear-gradient(180deg, color-mix(in srgb, #ff6a75 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #ff6a75 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="mortalidad"]::before { background: #ff6a75; box-shadow: 0 0 8px 0 #ff6a75aa; }
.reports-hint .reports-list li[data-tint="metricas"] { background: linear-gradient(180deg, color-mix(in srgb, #22d3ee 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #22d3ee 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="metricas"]::before { background: #22d3ee; box-shadow: 0 0 8px 0 #22d3eeaa; }
.reports-hint .reports-list li[data-tint="diario"] { background: linear-gradient(180deg, color-mix(in srgb, #8b5cf6 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #8b5cf6 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="diario"]::before { background: #8b5cf6; box-shadow: 0 0 8px 0 #8b5cf6aa; }
.reports-hint .reports-list li[data-tint="productos"] { background: linear-gradient(180deg, color-mix(in srgb, #ec4899 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #ec4899 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="productos"]::before { background: #ec4899; box-shadow: 0 0 8px 0 #ec4899aa; }
.reports-hint .reports-list li[data-tint="synapse"] { background: linear-gradient(180deg, color-mix(in srgb, #fbbf24 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #fbbf24 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="synapse"]::before { background: #fbbf24; box-shadow: 0 0 8px 0 #fbbf24aa; }
.reports-hint .reports-list li[data-tint="supply"] { background: linear-gradient(180deg, color-mix(in srgb, #34d399 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #34d399 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="supply"]::before { background: #34d399; box-shadow: 0 0 8px 0 #34d399aa; }
.reports-hint .reports-list li[data-tint="finanzas"] { background: linear-gradient(180deg, color-mix(in srgb, #22c55e 14%, rgba(20,22,32,.55)), rgba(20,22,32,.55)); border-color: color-mix(in srgb, #22c55e 35%, var(--border)); }
.reports-hint .reports-list li[data-tint="finanzas"]::before { background: #22c55e; box-shadow: 0 0 8px 0 #22c55eaa; }
.reports-hint .reports-list li:hover {
transform: translateY(-1px) scale(1.01);
border-color: color-mix(in srgb, var(--brand) 55%, var(--border-strong));
background:
linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0)),
rgba(24, 27, 40, 0.7);
box-shadow:
0 6px 16px rgba(0,0,0,.3),
0 0 14px -4px color-mix(in srgb, var(--brand) 55%, transparent),
inset 0 1px 0 rgba(255,255,255,.07);
}
.reports-hint .reports-suggestions {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed color-mix(in srgb, var(--brand) 30%, transparent);
position: relative;
}
.reports-hint .reports-suggestions::before {
content: "";
position: absolute;
top: -1px;
left: 10%;
right: 10%;
height: 1px;
background: linear-gradient(90deg, transparent,
color-mix(in srgb, var(--brand-2) 55%, transparent) 50%,
transparent);
opacity: .7;
}
.reports-hint .reports-suggestions-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
font-size: 12.5px;
line-height: 1.2;
}
.reports-hint .reports-suggestions-title::before {
content: "💡";
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--brand-3) 65%, transparent));
}
.reports-hint .reports-suggestions-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 5px;
color: var(--text-dim);
}
.reports-hint .reports-suggestions-list li {
white-space: normal;
word-break: break-word;
padding: 5px 8px 5px 26px;
border-radius: 10px;
position: relative;
font-size: 12.5px;
transition: background .2s ease, color .2s ease;
}
.reports-hint .reports-suggestions-list li::before {
content: "→";
position: absolute;
left: 8px;
top: 5px;
color: var(--brand-2);
font-weight: 700;
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--brand-2) 70%, transparent));
}
.reports-hint .reports-suggestions-list li:hover {
background: color-mix(in srgb, var(--brand) 8%, transparent);
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);
}
.agent-tools-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
}
.agent-tool-item {
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
}
.agent-tool-summary {
list-style: none;
cursor: pointer;
padding: 10px 12px;
}
.agent-tool-summary::-webkit-details-marker {
display: none;
}
.agent-tool-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 4px;
}
.agent-tool-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
border: 1px solid rgba(116, 167, 255, 0.35);
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
color: #9cc0ff;
background: rgba(90, 140, 255, 0.12);
}
.agent-tool-badge.suggested {
border-color: rgba(123, 223, 168, 0.35);
color: #9fe0bc;
background: rgba(79, 168, 124, 0.14);
}
.agent-tool-match-banner {
padding: 10px 12px;
border: 1px solid rgba(123, 223, 168, 0.2);
border-radius: 12px;
background: rgba(79, 168, 124, 0.08);
}
.agent-tool-match-text {
margin-top: 6px;
font-size: 12px;
line-height: 1.45;
color: var(--text);
}
.agent-tool-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 12px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.agent-tool-call-label {
display: flex;
flex-direction: column;
gap: 6px;
}
.agent-tool-call-copy {
font-size: 12px;
color: var(--text-faint);
}
.agent-tool-call-args {
width: 100%;
min-height: 88px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
color: var(--text);
font: inherit;
font-family: "Geist Mono", monospace;
resize: vertical;
}
.agent-tool-call-actions {
display: flex;
justify-content: flex-start;
}
.agent-tool-call-btn {
min-width: 128px;
}
.agent-tool-call-result {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.agent-tool-call-result.success {
border-color: rgba(123, 223, 168, 0.25);
background: rgba(79, 168, 124, 0.08);
}
.agent-tool-call-result.error {
border-color: rgba(255, 120, 120, 0.24);
background: rgba(120, 40, 40, 0.16);
}
.agent-tool-call-result.running {
border-color: rgba(90, 140, 255, 0.24);
background: rgba(90, 140, 255, 0.08);
}
.agent-tool-call-result-title {
margin-bottom: 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
}
.agent-tool-call-output {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
max-height: 260px;
overflow: auto;
font: inherit;
font-family: "Geist Mono", monospace;
font-size: 12px;
line-height: 1.5;
}
.agent-tool-section-title {
margin-top: 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-faint);
}
.agent-param-row {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
}
.agent-param-name {
font-size: 12.5px;
font-weight: 600;
color: var(--text);
}
.agent-param-type {
font-size: 11.5px;
line-height: 1.4;
color: var(--text-faint);
}
.agent-tool-item .tool-name {
display: block;
}
.agent-tool-empty {
padding: 10px 12px;
border: 1px dashed var(--border);
border-radius: 12px;
color: var(--text-faint);
font-size: 12px;
}
.agent-tool-empty.small {
padding: 8px 10px;
font-size: 11.5px;
}
.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) — HIDDEN BY DEFAULT OGGI ──────────────────
* Floating self-view, bottom-left. Mirrored for the user; the frame sent to
* the model is drawn un-mirrored. Markup esiste per riattivazione futura.
* Per usarlo: togli hidden su cam-pip in index.html + scommenta listener in main.js. */
.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: 12px 20px 0; }
.ident { gap: 5px; }
.ident-title { font-size: 20px; }
.ident-user-name { font-size: 12px; }
.ident-blurb { font-size: 13.5px; line-height: 1.4; }
.ident-meta { gap: 5px; font-size: 13px; }
.stage { padding: 10px 18px 12px; gap: 12px; }
.app-footer { padding: 8px 24px 10px; }
.reports-hint { max-height: 30svh; }
.circle { width: clamp(130px, min(36svw, 38svh), 280px); }
}
@media (max-height: 560px) {
.topbar { padding: 6px 14px 0; gap: 6px; }
.ident-title { font-size: 17px; }
.ident-user-name { font-size: 11px; }
/* 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: 6px 12px 8px; gap: 6px; }
.app-footer { padding: 3px 12px 5px; }
.app-footer-text { font-size: 9px; letter-spacing: 0.1em; line-height: 1.25; }
.reports-hint { max-height: 28svh; padding: 6px 8px; }
.circle { width: clamp(90px, min(40svw, 34svh), 200px); }
}