Spaces:
Sleeping
Sleeping
| :root { | |
| --bg: #0a0b10; | |
| --bg-elev: #13151c; | |
| --bg-elev-2: #1b1e29; | |
| --border: rgba(255, 255, 255, 0.08); | |
| --border-strong: rgba(255, 255, 255, 0.16); | |
| --text: #f5f6fa; | |
| --text-dim: rgba(245, 246, 250, 0.65); | |
| --text-faint: rgba(245, 246, 250, 0.42); | |
| --accent: #8b7dff; | |
| --accent-2: #22d3ee; | |
| --listening: #22d3ee; | |
| --speaking: #8b7dff; | |
| --processing: #f59e0b; | |
| --error: #ff6a75; | |
| --success: #34d399; | |
| /* Mono is the "machine voice": reserved for system text the app emits β | |
| * the orb's status caption, tool calls, the transport tag. Body/UI stays | |
| * Inter. */ | |
| --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; | |
| /* Thesis: color belongs to the voice. The chrome is monochrome; saturated | |
| * hue appears only on the orb and on these tiny role echoes, which mirror | |
| * the orb's own state colors (you listening = cyan, assistant = violet, | |
| * tool = amber) so the transcript reads in the same color language. */ | |
| --voice-user: var(--accent-2); | |
| --voice-assistant: var(--accent); | |
| --voice-tool: var(--processing); | |
| /* Smoothed mic RMS in [0..1], updated every frame from JS while a session | |
| * is active. Used by audio-reactive circle states. */ | |
| --audio-level: 0; | |
| /* Five log-spaced frequency bands extracted from the mic analyser; | |
| * drive each bar's height independently for a real "spectrum" feel. */ | |
| --bar0: 0; | |
| --bar1: 0; | |
| --bar2: 0; | |
| --bar3: 0; | |
| --bar4: 0; | |
| --radius-sm: 8px; | |
| --radius-md: 14px; | |
| --radius-lg: 22px; | |
| --shadow-soft: 0 10px 40px rgba(0, 0, 0, 0.35); | |
| color-scheme: dark; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| height: 100%; | |
| } | |
| body { | |
| font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| background: radial-gradient(ellipse at 50% 20%, #1a1c28 0%, var(--bg) 60%); | |
| color: var(--text); | |
| min-height: 100vh; | |
| overflow-x: hidden; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| button { | |
| font-family: inherit; | |
| } | |
| a { | |
| color: var(--text); | |
| text-decoration: none; | |
| border-bottom: 1px solid var(--border-strong); | |
| } | |
| a:hover { | |
| border-bottom-color: var(--text); | |
| } | |
| #app { | |
| display: grid; | |
| grid-template-rows: auto 1fr auto; | |
| min-height: 100vh; | |
| } | |
| .hidden { | |
| display: none ; | |
| } | |
| /* βββ Topbar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| /* Topbar is just a floating row of controls over the stage - no | |
| * separator line, no background. Same story for the footer. Keeps the | |
| * app feeling like one continuous canvas. */ | |
| .topbar { | |
| display: flex; | |
| /* Top-align so the right control cluster sits up with the title instead of | |
| * floating to the vertical middle of the tall identity block. */ | |
| align-items: flex-start; | |
| justify-content: space-between; | |
| padding: 18px 28px; | |
| /* Default stacking (below the conversation panel, z 200) so the panel drawer | |
| * covers the controls when it's open. */ | |
| } | |
| .brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| font-weight: 600; | |
| font-size: 14px; | |
| letter-spacing: 0.01em; | |
| color: var(--text-dim); | |
| } | |
| /* Transport tag rides the wordmark as a system label, not prose β mono, | |
| * dimmed, the only mono in the topbar. */ | |
| .brand-tag { | |
| font-family: var(--font-mono); | |
| font-weight: 500; | |
| font-size: 11px; | |
| letter-spacing: 0.02em; | |
| opacity: 0.55; | |
| } | |
| .brand .brand-logo { | |
| width: 26px; | |
| height: 26px; | |
| object-fit: contain; | |
| flex: none; | |
| filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.45)); | |
| transition: transform 0.2s ease; | |
| } | |
| .brand .brand-logo:hover { | |
| transform: translateY(-1px) rotate(-2deg); | |
| } | |
| /* Narrow viewports (typically the mobile shell iframe and phone-sized | |
| browser windows): the full product name pushes the topbar's right | |
| cluster off-screen. Keep the logo as the brand cue and drop the | |
| wordmark - the orb already gives enough context. */ | |
| @media (max-width: 600px) { | |
| /* The full identity stack is too tall for a phone topbar. Show only the | |
| * title + (i); the popup carries the blurb, credits and pipeline. | |
| * `.brand` prefix raises specificity so this beats the later base | |
| * `.ident-meta { display: flex }` rule regardless of source order. */ | |
| .brand .ident-blurb, | |
| .brand .ident-meta { | |
| display: none; | |
| } | |
| .ident-title { | |
| font-size: 18px; | |
| } | |
| /* (i) moves into the right-hand control cluster on phones, leaving the | |
| * title alone on the left. */ | |
| .brand .about-btn { | |
| display: none; | |
| } | |
| .about-btn-mobile { | |
| display: inline-flex; | |
| } | |
| /* Keep phone icons at the current compact size β the desktop bump above | |
| * shouldn't leak into the mobile topbar. */ | |
| .topbar-right .icon-btn { | |
| width: 36px; | |
| height: 36px; | |
| } | |
| .topbar-right .icon-btn svg { | |
| width: 18px; | |
| height: 18px; | |
| } | |
| /* Keep the account control compact on phones: avatar-only chip, shorter pill. */ | |
| .account-chip { | |
| height: 36px; | |
| padding: 0 6px; | |
| } | |
| .account-handle { | |
| display: none; | |
| } | |
| .signin-pill { | |
| height: 36px; | |
| padding: 0 12px; | |
| } | |
| } | |
| .topbar-right { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| /* The HF user pill: compact avatar + handle. Rendered as a flex row so | |
| * the avatar stays aligned with the text baseline even when the text | |
| * wraps on narrow viewports. */ | |
| .hf-user { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 13px; | |
| color: var(--text-dim); | |
| padding: 4px 10px 4px 4px; | |
| border-radius: 999px; | |
| background: var(--bg-elev); | |
| border: 1px solid var(--border); | |
| line-height: 1; | |
| } | |
| .hf-avatar { | |
| width: 24px; | |
| height: 24px; | |
| border-radius: 50%; | |
| object-fit: cover; | |
| background: color-mix(in srgb, var(--accent) 25%, var(--bg-elev-2)); | |
| /* Hidden until an actual URL loads (see `setHfAvatar` in main.ts). | |
| * Keeps the initial login flash from showing a broken image icon. */ | |
| opacity: 0; | |
| transition: opacity 0.25s ease; | |
| flex: none; | |
| } | |
| .hf-avatar.loaded { | |
| opacity: 1; | |
| } | |
| .hf-user-name { | |
| white-space: nowrap; | |
| } | |
| /* βββ Transport pill ββββββββββββββββββββββββββββββββββββββββββββββ | |
| * Surface the actual network path WebRTC picked for the robot peer | |
| * connection (LAN, direct-through-NAT, or TURN-relayed). Lets the | |
| * user spot at a glance when audio is going through the internet | |
| * instead of staying on-prem. | |
| */ | |
| .transport-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 12px; | |
| font-weight: 500; | |
| letter-spacing: 0.02em; | |
| padding: 5px 10px; | |
| border-radius: 999px; | |
| background: var(--bg-elev); | |
| border: 1px solid var(--border); | |
| color: var(--text-dim); | |
| user-select: none; | |
| transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; | |
| } | |
| .transport-pill .transport-dot { | |
| width: 7px; | |
| height: 7px; | |
| border-radius: 50%; | |
| background: currentColor; | |
| box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 25%, transparent); | |
| flex: none; | |
| } | |
| /* Bitrate readout sits after the kind label. Dimmer + monospace so the | |
| * kind (LAN / Direct / Relayed) stays the primary info and the numbers | |
| * don't wiggle the layout as digits change. */ | |
| .transport-pill .transport-bitrate { | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| font-weight: 500; | |
| color: color-mix(in srgb, currentColor 75%, var(--text-dim)); | |
| opacity: 0.85; | |
| padding-left: 6px; | |
| border-left: 1px solid color-mix(in srgb, currentColor 25%, transparent); | |
| } | |
| .transport-pill .transport-bitrate:empty { | |
| display: none; | |
| } | |
| .transport-pill.transport-checking { | |
| color: var(--text-dim); | |
| } | |
| .transport-pill.transport-checking .transport-dot { | |
| animation: transport-blink 1.2s ease-in-out infinite; | |
| } | |
| .transport-pill.transport-lan { | |
| color: #4ade80; | |
| border-color: color-mix(in srgb, #4ade80 35%, var(--border)); | |
| background: color-mix(in srgb, #4ade80 10%, var(--bg-elev)); | |
| } | |
| .transport-pill.transport-direct { | |
| color: #60a5fa; | |
| border-color: color-mix(in srgb, #60a5fa 35%, var(--border)); | |
| background: color-mix(in srgb, #60a5fa 10%, var(--bg-elev)); | |
| } | |
| .transport-pill.transport-relay { | |
| color: #fbbf24; | |
| border-color: color-mix(in srgb, #fbbf24 35%, var(--border)); | |
| background: color-mix(in srgb, #fbbf24 10%, var(--bg-elev)); | |
| } | |
| @keyframes transport-blink { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.35; } | |
| } | |
| .icon-btn { | |
| background: var(--bg-elev); | |
| border: 1px solid var(--border); | |
| color: var(--text-dim); | |
| width: 36px; | |
| height: 36px; | |
| border-radius: var(--radius-sm); | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| touch-action: manipulation; | |
| transition: background 0.15s, color 0.15s, border-color 0.15s; | |
| } | |
| .icon-btn:hover { | |
| background: var(--bg-elev-2); | |
| color: var(--text); | |
| border-color: var(--border-strong); | |
| } | |
| /* Topbar control cluster reads a touch bigger on desktop. Phones keep the | |
| * compact 36px (reset in the mobile media query). */ | |
| .topbar-right .icon-btn { | |
| width: 40px; | |
| height: 40px; | |
| } | |
| .topbar-right .icon-btn svg { | |
| width: 20px; | |
| height: 20px; | |
| } | |
| /* βββ Account (HF login chip + sign-in pill + popover) βββββββββββββββββββββββ */ | |
| .account { | |
| position: relative; | |
| display: inline-flex; | |
| align-items: center; | |
| } | |
| /* Signed-out: a compact pill that reads as the primary affordance. */ | |
| .signin-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 7px; | |
| height: 40px; | |
| padding: 0 14px; | |
| border-radius: var(--radius-sm); | |
| border: 1px solid var(--border-strong); | |
| background: var(--bg-elev); | |
| color: var(--text); | |
| font-size: 13px; | |
| font-weight: 600; | |
| text-decoration: none; | |
| transition: background 0.15s, border-color 0.15s; | |
| } | |
| .signin-pill svg { | |
| width: 16px; | |
| height: 16px; | |
| flex: none; | |
| } | |
| .signin-pill:hover { | |
| background: var(--bg-elev-2); | |
| border-color: var(--text); | |
| } | |
| /* Narrow viewports: collapse the pill to an icon-only square. */ | |
| @media (max-width: 800px) { | |
| .signin-pill { | |
| gap: 0; | |
| width: 40px; | |
| padding: 0; | |
| justify-content: center; | |
| } | |
| .signin-pill span { | |
| display: none; | |
| } | |
| } | |
| /* Signed-in: avatar + handle chip. */ | |
| .account-chip { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| height: 40px; | |
| padding: 0 10px 0 6px; | |
| border-radius: var(--radius-sm); | |
| border: 1px solid var(--border); | |
| background: var(--bg-elev); | |
| color: var(--text-dim); | |
| cursor: pointer; | |
| transition: background 0.15s, color 0.15s, border-color 0.15s; | |
| } | |
| .account-chip:hover { | |
| background: var(--bg-elev-2); | |
| color: var(--text); | |
| border-color: var(--border-strong); | |
| } | |
| .account-avatar { | |
| width: 26px; | |
| height: 26px; | |
| border-radius: 50%; | |
| object-fit: cover; | |
| flex: none; | |
| } | |
| .account-avatar-fallback { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: var(--bg-elev-2); | |
| color: var(--text); | |
| font-size: 12px; | |
| font-weight: 700; | |
| } | |
| .account-handle { | |
| font-size: 13px; | |
| font-weight: 600; | |
| max-width: 12ch; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .account-pro { | |
| font-size: 10px; | |
| font-weight: 700; | |
| letter-spacing: 0.04em; | |
| padding: 2px 5px; | |
| border-radius: 5px; | |
| background: var(--text); | |
| color: var(--bg); | |
| } | |
| /* Org "Team" badge: same pill as PRO, accent-coloured so it reads as | |
| * unlimited without claiming a paid PRO subscription. */ | |
| .account-team { | |
| background: var(--accent); | |
| color: var(--bg); | |
| } | |
| .account-pop { | |
| position: absolute; | |
| top: calc(100% + 8px); | |
| right: 0; | |
| min-width: 200px; | |
| padding: 6px; | |
| border-radius: var(--radius-md); | |
| border: 1px solid var(--border-strong); | |
| background: var(--bg-elev); | |
| box-shadow: var(--shadow-soft); | |
| z-index: 50; | |
| } | |
| .account-pop[hidden] { | |
| display: none; | |
| } | |
| .account-pop-row { | |
| padding: 8px 10px; | |
| } | |
| .account-pop-name { | |
| font-weight: 600; | |
| font-size: 13px; | |
| } | |
| .account-pop-meta { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| padding-top: 0; | |
| font-size: 12px; | |
| color: var(--text-dim); | |
| } | |
| .account-tier { | |
| font-weight: 600; | |
| color: var(--text); | |
| } | |
| .account-pop-link { | |
| display: block; | |
| padding: 8px 10px; | |
| border-radius: var(--radius-sm); | |
| color: var(--text-dim); | |
| font-size: 13px; | |
| text-decoration: none; | |
| transition: background 0.15s, color 0.15s; | |
| } | |
| .account-pop-link:hover { | |
| background: var(--bg-elev-2); | |
| color: var(--text); | |
| } | |
| .account-signout { | |
| border-top: 1px solid var(--border); | |
| margin-top: 4px; | |
| padding-top: 10px; | |
| border-radius: 0 0 var(--radius-sm) var(--radius-sm); | |
| } | |
| /* βββ Daily-limit modal ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .limit-modal { | |
| width: min(400px, 92vw); | |
| } | |
| .limit-card { | |
| position: relative; | |
| align-items: center; | |
| text-align: center; | |
| gap: 14px; | |
| padding: 34px 28px 26px; | |
| } | |
| .limit-close { | |
| position: absolute; | |
| top: 12px; | |
| right: 12px; | |
| } | |
| /* HF smiling face (brand yellow) on a neutral badge β friendly, not a stop sign. | |
| * The logo is the single pop of colour, so the badge itself stays quiet. */ | |
| .limit-badge { | |
| width: 66px; | |
| height: 66px; | |
| border-radius: 50%; | |
| display: grid; | |
| place-items: center; | |
| margin: 2px auto 2px; | |
| background: var(--bg-elev-2); | |
| border: 1px solid var(--border-strong); | |
| box-shadow: 0 0 0 6px rgba(255, 210, 30, 0.06); | |
| } | |
| .limit-badge .hf-logo { | |
| width: 46px; | |
| height: auto; | |
| } | |
| .limit-title { | |
| margin: 0; | |
| font-size: 20px; | |
| font-weight: 700; | |
| letter-spacing: 0; | |
| } | |
| .limit-msg { | |
| color: var(--text-dim); | |
| font-size: 14px; | |
| line-height: 1.55; | |
| margin: 0; | |
| max-width: 30ch; | |
| } | |
| .limit-cta { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| margin-top: 4px; | |
| } | |
| .limit-cta .hf-logo { | |
| width: 20px; | |
| height: auto; | |
| flex: none; | |
| } | |
| .limit-note { | |
| margin: 0; | |
| font-size: 12px; | |
| color: var(--text-faint); | |
| } | |
| #limit-cta[hidden] { | |
| display: none; | |
| } | |
| /* βββ Stage βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .stage { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 24px 24px 32px; | |
| gap: 20px; | |
| } | |
| /* βββ Central circle ββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| /* Wraps the orb and its two side controls so they stay aligned on one row. */ | |
| .orb-wrap { | |
| position: relative; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: clamp(18px, 3vw, 32px); | |
| } | |
| .circle { | |
| position: relative; | |
| width: clamp(220px, 38vw, 320px); | |
| aspect-ratio: 1 / 1; | |
| border-radius: 50%; | |
| border: none; | |
| background: transparent; | |
| padding: 0; | |
| cursor: pointer; | |
| outline: none; | |
| display: grid; | |
| place-items: center; | |
| color: var(--glow, var(--accent)); | |
| transition: transform 0.18s ease, filter 0.18s ease; | |
| -webkit-tap-highlight-color: transparent; | |
| /* Treat taps as immediate clicks (no 300ms delay / double-tap-zoom). */ | |
| touch-action: manipulation; | |
| } | |
| .circle:hover { | |
| filter: brightness(1.08); | |
| } | |
| .circle:active { | |
| transform: scale(0.97); | |
| filter: brightness(0.92); | |
| } | |
| .circle:focus-visible .circle-core { | |
| outline: 2px solid var(--glow, var(--accent)); | |
| outline-offset: 6px; | |
| } | |
| .circle[disabled] { | |
| cursor: default; | |
| opacity: 0.75; | |
| } | |
| .circle-glow { | |
| position: absolute; | |
| inset: 0; | |
| border-radius: 50%; | |
| background: radial-gradient(circle at center, var(--glow, var(--accent)) 0%, transparent 65%); | |
| filter: blur(28px); | |
| opacity: 0.5; | |
| transform: scale(1); | |
| transition: opacity 0.25s, background 0.25s, transform 1.4s ease-in-out; | |
| pointer-events: none; | |
| } | |
| /* Two nested circular rings: the inner tracks the core edge, the outer | |
| * expands / fades to convey "audio radiating out" during speaking. */ | |
| .circle-ring, | |
| .circle-ring-outer { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| border-radius: 50%; | |
| transform: translate(-50%, -50%); | |
| pointer-events: none; | |
| transition: opacity 0.4s ease, border-color 0.4s ease, transform 0.3s ease; | |
| } | |
| .circle-ring { | |
| width: 82%; | |
| height: 82%; | |
| border: 1.5px solid color-mix(in srgb, var(--glow, var(--accent)) 35%, transparent); | |
| opacity: 0.35; | |
| } | |
| .circle-ring-outer { | |
| width: 94%; | |
| height: 94%; | |
| border: 1px solid color-mix(in srgb, var(--glow, var(--accent)) 22%, transparent); | |
| opacity: 0; | |
| } | |
| .circle-core { | |
| position: relative; | |
| width: 72%; | |
| height: 72%; | |
| border-radius: 50%; | |
| background: radial-gradient( | |
| circle at 35% 28%, | |
| color-mix(in srgb, var(--glow, var(--accent)) 28%, transparent), | |
| color-mix(in srgb, var(--glow, var(--accent)) 10%, transparent) 55%, | |
| color-mix(in srgb, var(--glow, var(--accent)) 5%, transparent) | |
| ); | |
| border: 2px solid color-mix(in srgb, var(--glow, var(--accent)) 40%, transparent); | |
| display: grid; | |
| place-items: center; | |
| box-shadow: | |
| 0 0 32px color-mix(in srgb, var(--glow, var(--accent)) 22%, transparent), | |
| inset 0 0 28px color-mix(in srgb, var(--glow, var(--accent)) 18%, transparent); | |
| transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease, transform 0.4s ease; | |
| } | |
| /* Indicator slot: a single SVG / spinner / bar group is visible at a time, | |
| * driven by the state class on `.circle`. */ | |
| .circle-indicator { | |
| position: relative; | |
| width: 44%; | |
| height: 44%; | |
| display: grid; | |
| place-items: center; | |
| color: var(--glow, var(--accent)); | |
| } | |
| .circle-indicator > .ind { | |
| grid-area: 1 / 1; | |
| opacity: 0; | |
| transform: scale(0.85); | |
| transition: opacity 0.25s ease, transform 0.25s ease; | |
| pointer-events: none; | |
| } | |
| .circle-indicator > svg.ind { | |
| width: 60%; | |
| height: 60%; | |
| } | |
| /* Spinner: a rotating ring gap, CSS-only. | |
| * | |
| * Note: the base `.circle-indicator > .ind` rule forces `transform: | |
| * scale(.85)` / `scale(1)` on every indicator to drive the show/hide | |
| * transition. If we only rotate here, the browser has to interpolate | |
| * between `scale(1)` and `rotate(360deg)` (two different transform | |
| * functions), which produces a broken, barely-moving animation. So we | |
| * include the scale explicitly in the keyframes and bump specificity | |
| * with `!important` so the spinner always wins over the state rule. */ | |
| .ind-spinner { | |
| width: 48%; | |
| height: 48%; | |
| border: 3px solid currentColor; | |
| border-right-color: transparent; | |
| border-radius: 50%; | |
| opacity: 0; | |
| animation: ind-spin 0.9s linear infinite; | |
| } | |
| /* Thinking dots: 3 soft pulsing dots while the model is composing a | |
| * response. Apple-style cadence: each dot scales up and brightens in | |
| * turn, staggered by ~160 ms. Per-dot animation is on the child, so | |
| * the parent's scale(.85 β 1) show/hide transform composes cleanly. */ | |
| .ind-thinking { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| width: 60%; | |
| height: 60%; | |
| } | |
| .ind-thinking .dot { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| background: currentColor; | |
| opacity: 0.3; | |
| animation: thinking-dot 1.25s ease-in-out infinite; | |
| } | |
| .ind-thinking .dot:nth-child(1) { animation-delay: 0s; } | |
| .ind-thinking .dot:nth-child(2) { animation-delay: 0.16s; } | |
| .ind-thinking .dot:nth-child(3) { animation-delay: 0.32s; } | |
| /* Bars: 5 vertical pills driven by --bar0..--bar4 CSS vars. */ | |
| .ind-bars { | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| height: 42%; | |
| } | |
| .ind-bars .bar { | |
| width: 4px; | |
| min-height: 4px; | |
| border-radius: 3px; | |
| background: currentColor; | |
| opacity: 0.7; | |
| --h: var(--bar0, 0); | |
| height: calc(4px + var(--h) * 36px); | |
| transition: height 0.08s ease-out, opacity 0.08s ease-out; | |
| } | |
| .ind-bars .bar:nth-child(1) { --h: var(--bar0); } | |
| .ind-bars .bar:nth-child(2) { --h: var(--bar1); } | |
| .ind-bars .bar:nth-child(3) { --h: var(--bar2); } | |
| .ind-bars .bar:nth-child(4) { --h: var(--bar3); } | |
| .ind-bars .bar:nth-child(5) { --h: var(--bar4); } | |
| .ind-bars .bar { | |
| opacity: calc(0.55 + 0.45 * var(--h)); | |
| } | |
| /* Active indicator per state. | |
| * | |
| * Note: `ai-speaking` deliberately has NO indicator here. The orb | |
| * itself becomes the indicator by pulsing on Reachy's voice (see the | |
| * `--ai-audio-level` rules further down), which is a lot clearer and | |
| * less confusing than reusing the mic-bars (which viewers would read | |
| * as "you are speaking"). */ | |
| .state-signed-out .ind-connect, | |
| .state-authenticated .ind-mic, | |
| .state-ready .ind-mic, | |
| .state-connecting .ind-spinner, | |
| .state-connected .ind-spinner, | |
| .state-auto-selecting .ind-spinner, | |
| .state-starting .ind-spinner, | |
| .state-queued .ind-spinner, | |
| .state-processing .ind-thinking, | |
| .state-listening .ind-bars, | |
| .state-user-speaking .ind-bars, | |
| .state-ai-speaking .ind-voice, | |
| .state-error .ind-error { | |
| opacity: 1; | |
| transform: scale(1); | |
| } | |
| /* AI speaking indicator: speaker + two sound waves. | |
| * | |
| * Each wave pulses outward (opacity + stroke grow) with a quarter- | |
| * beat offset so it reads as sound radiating out. Kept as a pure | |
| * CSS animation so the icon is always visually alive even between | |
| * syllables when --ai-audio-level momentarily dips. */ | |
| .ind-voice .wave { | |
| transform-origin: 50% 50%; | |
| animation: voice-wave 1.35s ease-out infinite; | |
| } | |
| .ind-voice .wave-1 { animation-delay: 0s; } | |
| .ind-voice .wave-2 { animation-delay: 0.35s; } | |
| /* Idle indicators: a chain-link "connect" glyph for the signed-out | |
| * step (invites the user to authenticate with HF) and a microphone | |
| * once a session is ready. Both picked up by the generic | |
| * `.circle-indicator > svg.ind` sizing rule (60% Γ 60% of the slot) | |
| * and rely on per-state opacity transitions for show / hide. */ | |
| .ind-connect, | |
| .ind-mic { | |
| color: color-mix(in srgb, var(--glow, var(--accent)) 85%, white); | |
| } | |
| /* βββ Caption below the circle ββββββββββββββββββββββββββββββββββββββββββ */ | |
| /* The caption under the orb is meant to whisper, not shout: micro-label | |
| * vibe, uppercase, letter-spaced, muted. Only appears for actionable / | |
| * transitional states (see STATE_VIEWS). During a live conversation the | |
| * orb alone carries the state so we collapse this row entirely. */ | |
| .circle-caption { | |
| margin: 0; | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| font-weight: 500; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| color: var(--text-faint); | |
| min-height: 1.2em; | |
| text-align: center; | |
| opacity: 0.75; | |
| transition: opacity 0.25s ease, color 0.25s ease, transform 0.25s ease, | |
| min-height 0.25s ease; | |
| } | |
| .circle-caption.empty { | |
| opacity: 0; | |
| min-height: 0; | |
| transform: translateY(-4px); | |
| pointer-events: none; | |
| } | |
| .circle-caption.muted { | |
| color: var(--text-faint); | |
| opacity: 0.65; | |
| } | |
| .circle-caption.error { | |
| color: var(--error); | |
| opacity: 1; | |
| letter-spacing: 0.08em; | |
| } | |
| /* Warm, human line under the mono caption β sentence case, only while queued. | |
| * The caption keeps the terse position; this reassures. */ | |
| .circle-subcaption { | |
| margin: 8px 0 0; | |
| max-width: 32ch; | |
| font-size: 13.5px; | |
| line-height: 1.5; | |
| color: var(--text-dim); | |
| text-align: center; | |
| text-wrap: balance; | |
| transition: opacity 0.25s ease; | |
| } | |
| .circle-subcaption[hidden] { display: none; } | |
| /* Queue actions sit under the caption: "Join now" (primary, only when it's your | |
| * turn) stacked above the quiet "Leave queue" escape hatch. */ | |
| .queue-actions { | |
| margin-top: 16px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .queue-actions[hidden] { display: none; } | |
| /* "Join now": the one call to action in the queue flow, so it reads as inviting | |
| * (filled accent) while everything around it stays quiet. */ | |
| .join-queue-btn { | |
| padding: 10px 26px; | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| font-weight: 600; | |
| letter-spacing: 0.1em; | |
| text-transform: uppercase; | |
| color: #0b0b10; | |
| background: var(--accent); | |
| border: none; | |
| border-radius: 999px; | |
| cursor: pointer; | |
| font-variant-numeric: tabular-nums; | |
| transition: transform 0.12s ease, filter 0.2s ease; | |
| } | |
| .join-queue-btn:hover { filter: brightness(1.08); } | |
| .join-queue-btn:active { transform: scale(0.97); } | |
| .join-queue-btn:focus-visible { | |
| outline: 2px solid var(--accent); | |
| outline-offset: 3px; | |
| } | |
| .join-queue-btn[hidden] { display: none; } | |
| /* "Leave queue": a quiet outlined pill, understated so it reads as an escape | |
| * hatch, not a CTA. */ | |
| .leave-queue-btn { | |
| padding: 7px 16px; | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| font-weight: 500; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| color: var(--text-faint); | |
| background: transparent; | |
| border: 1px solid color-mix(in srgb, var(--text-faint) 35%, transparent); | |
| border-radius: 999px; | |
| cursor: pointer; | |
| transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease; | |
| } | |
| .leave-queue-btn:hover { | |
| color: var(--text); | |
| border-color: color-mix(in srgb, var(--text-faint) 60%, transparent); | |
| background: color-mix(in srgb, var(--text-faint) 8%, transparent); | |
| } | |
| .leave-queue-btn:focus-visible { | |
| outline: 2px solid var(--accent); | |
| outline-offset: 2px; | |
| } | |
| .leave-queue-btn[hidden] { display: none; } | |
| /* βββ Tool-call toaster βββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| /* A small, non-interactive pill that appears below the circle when the | |
| * model invokes a tool (move_head, play_move). Sits just under the | |
| * state caption, collapses to zero height when no toast is active. */ | |
| .tool-toast { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-top: 10px; | |
| padding: 6px 12px; | |
| border-radius: 999px; | |
| border: 1px solid var(--border-strong); | |
| background: color-mix(in srgb, var(--bg-elev-2) 78%, transparent); | |
| color: var(--text-dim); | |
| font-size: 12px; | |
| font-weight: 500; | |
| letter-spacing: 0.01em; | |
| line-height: 1; | |
| white-space: nowrap; | |
| max-width: 80vw; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| opacity: 0; | |
| transform: translateY(-4px) scale(0.96); | |
| pointer-events: none; | |
| transition: opacity 0.22s ease, transform 0.22s ease; | |
| } | |
| .tool-toast.visible { | |
| opacity: 1; | |
| transform: translateY(0) scale(1); | |
| } | |
| .tool-toast-icon { | |
| width: 14px; | |
| height: 14px; | |
| flex: none; | |
| color: color-mix(in srgb, var(--voice-tool) 80%, white); | |
| animation: tool-toast-spin 3.2s linear infinite; | |
| animation-play-state: paused; | |
| } | |
| .tool-toast.visible .tool-toast-icon { | |
| animation-play-state: running; | |
| } | |
| .tool-toast-text { | |
| display: inline-block; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| @keyframes tool-toast-spin { | |
| from { transform: rotate(0deg); } | |
| to { transform: rotate(360deg); } | |
| } | |
| /* βββ Side controls (mic / stop) ββββββββββββββββββββββββββββββββββββββββ */ | |
| /* Mic button + its radial noise-gate arc. The wrapper centres the button; the | |
| arc SVG is an absolute overlay larger than the button, revealed with the | |
| live session (it has no layout footprint, so the idle collapse is unaffected). */ | |
| .mic-gate { | |
| position: relative; | |
| flex: none; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .mic-gate-arc { | |
| position: absolute; | |
| left: 50%; | |
| top: 50%; | |
| width: 80px; | |
| height: 80px; | |
| transform: translate(-50%, -50%); | |
| pointer-events: none; /* only the hit-path below catches drags */ | |
| opacity: 0; | |
| transition: opacity 0.25s ease; | |
| } | |
| .orb-wrap.live .mic-gate-arc { opacity: 1; } | |
| .mga-track { | |
| stroke: rgba(255, 255, 255, 0.1); /* hairline; recedes until needed */ | |
| stroke-width: 1.5; | |
| stroke-linecap: round; | |
| } | |
| .mga-fill { | |
| stroke: var(--accent-2); | |
| stroke-width: 2; | |
| stroke-linecap: round; | |
| opacity: 0.85; | |
| transition: stroke-dashoffset 0.06s linear; | |
| } | |
| /* Threshold setpoint: a bead riding the ring. White with a thin dark outline so | |
| it stays legible over the moving fill; it recolors to cyan with a soft glow | |
| the moment the live level crosses it (the gate opening). */ | |
| .mga-handle { | |
| fill: var(--text); | |
| stroke: none; | |
| transition: fill 0.2s ease, filter 0.2s ease; | |
| } | |
| .mic-gate.gate-open .mga-handle { | |
| fill: var(--accent-2); | |
| filter: drop-shadow(0 0 3px var(--accent-2)); | |
| } | |
| .mga-hit { | |
| stroke: transparent; | |
| stroke-width: 16; | |
| pointer-events: none; /* enabled only while live (below) */ | |
| cursor: pointer; | |
| touch-action: none; | |
| } | |
| /* Only catch drags during a live call; when idle the arc is hidden and must | |
| not steal clicks near the collapsed mic button / orb. */ | |
| .orb-wrap.live .mga-hit { pointer-events: stroke; } | |
| .side-btn { | |
| flex: none; | |
| width: 52px; | |
| height: 52px; | |
| border-radius: 50%; | |
| border: 1px solid var(--border-strong); | |
| /* Brighter background so the buttons actually stand out against the | |
| * deep-blue stage gradient; previous var(--bg-elev) was too close to | |
| * the page bg to be readable. */ | |
| background: color-mix(in srgb, var(--bg-elev-2) 80%, #2a2e3c); | |
| color: var(--text); | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| touch-action: manipulation; | |
| box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35), | |
| inset 0 1px 0 rgba(255, 255, 255, 0.06); | |
| /* Hidden by default: take up no space until the session is live. The | |
| * `width: 0` collapse keeps the orb centered on the idle screen. */ | |
| opacity: 0; | |
| transform: scale(0.55); | |
| width: 0; | |
| padding: 0; | |
| pointer-events: none; | |
| overflow: hidden; | |
| transition: opacity 0.25s ease, transform 0.25s ease, width 0.25s ease, | |
| background 0.15s, color 0.15s, border-color 0.15s; | |
| } | |
| .side-btn:hover { | |
| background: color-mix(in srgb, var(--bg-elev-2) 60%, #323746); | |
| border-color: var(--text-dim); | |
| } | |
| .side-btn svg { | |
| width: 22px; | |
| height: 22px; | |
| flex: none; | |
| } | |
| .side-btn .mic-off { display: none; } | |
| .side-btn.muted { | |
| color: #fff; | |
| border-color: var(--error); | |
| background: color-mix(in srgb, var(--error) 70%, #1a0e13); | |
| } | |
| .side-btn.muted .mic-on { display: none; } | |
| .side-btn.muted .mic-off { display: block; } | |
| /* Stop button: subtle warm tint so "end" reads as destructive. */ | |
| #stop-btn:hover { | |
| color: #fff; | |
| border-color: color-mix(in srgb, var(--error) 60%, var(--border-strong)); | |
| background: color-mix(in srgb, var(--error) 25%, var(--bg-elev-2)); | |
| } | |
| /* Reveal when the session is live: buttons flank the orb on a flex row. */ | |
| .orb-wrap.live .side-btn { | |
| opacity: 1; | |
| transform: scale(1); | |
| width: 52px; | |
| pointer-events: auto; | |
| } | |
| /* Disable every transition / animation during the first paint so the | |
| * orb doesn't fade-and-scale in when the page loads. `main.ts` removes | |
| * the class after one animation frame. */ | |
| body.booting, | |
| body.booting *, | |
| body.booting *::before, | |
| body.booting *::after { | |
| transition: none ; | |
| animation-duration: 0s ; | |
| animation-delay: 0s ; | |
| } | |
| /* βββ Circle animation keyframes ββββββββββββββββββββββββββββββββββββββββ */ | |
| /* Slow, subtle breathing for "warm idle" states. */ | |
| @keyframes breathe { | |
| 0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.4; } | |
| 50% { transform: translate(-50%, -50%) scale(1.06); opacity: 0.15; } | |
| } | |
| /* Outer ring expanding and fading - conveys "I am producing audio". */ | |
| @keyframes ring-out { | |
| 0% { transform: translate(-50%, -50%) scale(1); opacity: 0.35; } | |
| 100% { transform: translate(-50%, -50%) scale(1.18); opacity: 0; } | |
| } | |
| /* Soft inner scale for the core while talking. */ | |
| @keyframes core-breathe { | |
| 0%, 100% { transform: scale(1); } | |
| 50% { transform: scale(1.04); } | |
| } | |
| /* Subtle glow throb used for "thinking" β dimmer than speaking. */ | |
| @keyframes thinking { | |
| 0%, 100% { transform: scale(1); opacity: 0.7; } | |
| 50% { transform: scale(0.96); opacity: 0.45; } | |
| } | |
| /* Individual dot pulse for the 3-dot processing indicator. */ | |
| @keyframes thinking-dot { | |
| 0%, 60%, 100% { transform: scale(0.7); opacity: 0.3; } | |
| 30% { transform: scale(1.15); opacity: 1; } | |
| } | |
| /* Sound-wave pulse: arc fades in, scales up slightly, fades out. | |
| * The transform-origin is the speaker's center (roughly x=12), so | |
| * the waves feel like they're emanating from the cone. */ | |
| @keyframes voice-wave { | |
| 0% { opacity: 0; transform: scale(0.7); } | |
| 30% { opacity: 1; transform: scale(1); } | |
| 70% { opacity: 0.2; transform: scale(1.12); } | |
| 100% { opacity: 0; transform: scale(0.7); } | |
| } | |
| @keyframes ind-spin { | |
| /* Scale kept at 1 so we don't fight with the indicator's base | |
| * show/hide transform (see `.ind-spinner` for the rationale). */ | |
| from { transform: scale(1) rotate(0deg); } | |
| to { transform: scale(1) rotate(360deg); } | |
| } | |
| /* βββ State-specific colors ββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .circle.state-signed-out { --glow: #8b7dff; } | |
| .circle.state-authenticated, | |
| .circle.state-ready { --glow: #34d399; } | |
| .circle.state-connecting, | |
| .circle.state-connected, | |
| .circle.state-auto-selecting, | |
| .circle.state-starting { --glow: #facc15; } | |
| /* Queued: a calm slate glow, distinct from connecting's active yellow β this is | |
| * waiting, not working. The spinner turns slowly and the ring breathes. */ | |
| .circle.state-queued { --glow: #94a3b8; } | |
| .circle.state-queued .ind-spinner { animation-duration: 2.4s; } | |
| .circle.state-queued .circle-ring { animation: breathe 2.6s ease-in-out infinite; } | |
| /* Your turn: a slot is held for you β the orb warms to the accent and breathes a | |
| * little quicker, an invitation to join. */ | |
| .circle.state-your-turn { --glow: var(--accent); } | |
| .circle.state-your-turn .circle-ring { animation: breathe 1.6s ease-in-out infinite; } | |
| .circle.state-listening, | |
| .circle.state-user-speaking { --glow: var(--listening); } | |
| .circle.state-processing { --glow: var(--processing); } | |
| .circle.state-ai-speaking { --glow: var(--speaking); } | |
| .circle.state-error { --glow: var(--error); } | |
| /* Idle / ready: gentle breathing of the inner ring. Kept out of the | |
| * `signed-out` state so the very first paint on page load stays quiet | |
| * (the orb now shows the Reachy head silhouette, no need to also pulse). */ | |
| .circle.state-authenticated .circle-ring, | |
| .circle.state-ready .circle-ring { | |
| animation: breathe 2.4s ease-in-out infinite; | |
| } | |
| /* Connecting flows: subtle glow throb so the orb feels thoughtful | |
| * while the session is being negotiated. Kept off `processing` on | |
| * purpose - the 3 thinking dots already pulse, and layering another | |
| * throb on top competes with them for attention. */ | |
| .circle.state-connecting .circle-core, | |
| .circle.state-connected .circle-core, | |
| .circle.state-auto-selecting .circle-core, | |
| .circle.state-starting .circle-core { | |
| animation: thinking 1.4s ease-in-out infinite; | |
| } | |
| /* User is speaking: the mic RMS drives scale + opacity via --audio-level. */ | |
| .circle.state-user-speaking .circle-ring { | |
| animation: none; | |
| opacity: calc(0.25 + 0.55 * var(--audio-level)); | |
| transform: translate(-50%, -50%) scale(calc(1 + 0.08 * var(--audio-level))); | |
| transition: transform 0.08s linear, opacity 0.08s linear; | |
| } | |
| .circle.state-user-speaking .circle-ring-outer { | |
| opacity: calc(0.1 + 0.35 * var(--audio-level)); | |
| transform: translate(-50%, -50%) scale(calc(1 + 0.12 * var(--audio-level))); | |
| transition: transform 0.08s linear, opacity 0.08s linear; | |
| } | |
| .circle.state-listening .circle-ring { | |
| animation: breathe 2s ease-in-out infinite; | |
| } | |
| /* Assistant is speaking: the whole orb breathes in sync with Reachy's | |
| * voice instead of running a fixed timer. `--ai-audio-level` (0-1) is | |
| * updated at display rate by AiLevelMonitor from the OpenAI output | |
| * track, so every syllable visibly moves the core + outer ring. This | |
| * reads instantly as "the orb is the voice" and completely avoids the | |
| * ambiguity of bars-vs-mic the user flagged. */ | |
| .circle.state-ai-speaking .circle-core { | |
| animation: none; | |
| transform: scale(calc(1 + 0.09 * var(--ai-audio-level, 0))); | |
| transition: transform 0.08s linear; | |
| } | |
| .circle.state-ai-speaking .circle-ring { | |
| animation: none; | |
| opacity: calc(0.3 + 0.5 * var(--ai-audio-level, 0)); | |
| transform: translate(-50%, -50%) scale(calc(1 + 0.05 * var(--ai-audio-level, 0))); | |
| transition: transform 0.08s linear, opacity 0.08s linear; | |
| } | |
| .circle.state-ai-speaking .circle-ring-outer { | |
| animation: none; | |
| opacity: calc(0.15 + 0.55 * var(--ai-audio-level, 0)); | |
| transform: translate(-50%, -50%) scale(calc(1 + 0.18 * var(--ai-audio-level, 0))); | |
| transition: transform 0.08s linear, opacity 0.08s linear; | |
| } | |
| .circle.state-ai-speaking .circle-glow { | |
| opacity: calc(0.35 + 0.45 * var(--ai-audio-level, 0)); | |
| transition: opacity 0.08s linear; | |
| } | |
| /* βββ Robot picker ββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .robot-picker { | |
| width: min(420px, 92vw); | |
| background: var(--bg-elev); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-md); | |
| padding: 14px 16px; | |
| box-shadow: var(--shadow-soft); | |
| } | |
| .picker-title { | |
| margin: 0 0 10px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| letter-spacing: 0.1em; | |
| text-transform: uppercase; | |
| color: var(--text-faint); | |
| } | |
| .robot-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .robot-card { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 12px 14px; | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| background: var(--bg-elev-2); | |
| cursor: pointer; | |
| transition: border-color 0.15s, background 0.15s; | |
| } | |
| .robot-card:hover { | |
| border-color: var(--border-strong); | |
| } | |
| .robot-card.selected { | |
| border-color: var(--border-strong); | |
| background: var(--bg-elev); | |
| } | |
| .robot-card .name { | |
| font-weight: 600; | |
| font-size: 14px; | |
| } | |
| .robot-card .id { | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| color: var(--text-faint); | |
| } | |
| .robot-empty { | |
| padding: 14px; | |
| text-align: center; | |
| color: var(--text-faint); | |
| font-size: 13px; | |
| } | |
| /* βββ Footer ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .footer { | |
| padding: 12px 28px 18px; | |
| font-size: 11px; | |
| letter-spacing: 0.02em; | |
| color: var(--text-faint); | |
| display: flex; | |
| justify-content: center; | |
| opacity: 0.55; | |
| transition: opacity 0.25s ease; | |
| } | |
| .footer:hover { | |
| opacity: 0.9; | |
| } | |
| .footer a { | |
| color: inherit; | |
| text-decoration: none; | |
| border-bottom: 1px dotted currentColor; | |
| } | |
| .footer a:hover { | |
| color: var(--text-dim); | |
| } | |
| /* While the webcam preview sits bottom-left, push the credit to the | |
| * bottom-right so the two don't collide. */ | |
| body.cam-on .footer { | |
| justify-content: flex-end; | |
| } | |
| /* βββ Modal βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .modal { | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-md); | |
| padding: 0; | |
| background: var(--bg-elev); | |
| color: var(--text); | |
| width: min(440px, 92vw); | |
| box-shadow: var(--shadow-soft); | |
| } | |
| .modal::backdrop { | |
| background: rgba(8, 9, 13, 0.65); | |
| backdrop-filter: blur(4px); | |
| } | |
| .modal-content { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| padding: 22px 24px 20px; | |
| } | |
| .modal-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 4px; | |
| } | |
| .modal-header h2 { | |
| margin: 0; | |
| font-size: 16px; | |
| font-weight: 600; | |
| letter-spacing: 0.02em; | |
| } | |
| .field[hidden] { display: none; } | |
| .field { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: var(--text-dim); | |
| } | |
| .field > span { | |
| color: var(--text); | |
| font-weight: 600; | |
| font-size: 12px; | |
| letter-spacing: 0.04em; | |
| text-transform: uppercase; | |
| } | |
| .field input, | |
| .field select, | |
| .field textarea { | |
| font-family: inherit; | |
| font-size: 14px; | |
| color: var(--text); | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| padding: 10px 12px; | |
| outline: none; | |
| transition: border-color 0.15s; | |
| resize: vertical; | |
| } | |
| .field textarea { | |
| min-height: 84px; | |
| } | |
| .field input:focus, | |
| .field select:focus, | |
| .field textarea:focus { | |
| border-color: var(--text-dim); | |
| } | |
| .field small { | |
| color: var(--text-faint); | |
| font-size: 12px; | |
| line-height: 1.4; | |
| } | |
| .field small.error { color: var(--error); } | |
| .field small code { | |
| background: var(--bg); | |
| padding: 1px 5px; | |
| border-radius: 4px; | |
| border: 1px solid var(--border); | |
| } | |
| /* Noise gate: a header with a live value, a level meter, and a range slider | |
| that shares the meter's horizontal (dB) axis. */ | |
| .field-head { | |
| display: flex; | |
| align-items: baseline; | |
| justify-content: space-between; | |
| } | |
| .field-value { | |
| font-weight: 500; | |
| font-size: 12px; | |
| letter-spacing: 0; | |
| text-transform: none; | |
| color: var(--text-dim); | |
| } | |
| /* The slider and the level meter are one widget: the range input is overlaid | |
| on the meter track (its native track is transparent), so the live-level fill | |
| shows through behind the thumb and the thumb itself is the threshold. */ | |
| .gate { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| } | |
| .gate-track { | |
| position: relative; | |
| height: 14px; | |
| display: flex; | |
| align-items: center; | |
| } | |
| .gate-track::before { | |
| /* the visible meter track */ | |
| content: ""; | |
| position: absolute; | |
| left: 0; | |
| right: 0; | |
| height: 8px; | |
| border-radius: 999px; | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| } | |
| .gate-meter-fill { | |
| position: absolute; | |
| left: 1px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| height: 6px; | |
| width: 0; | |
| border-radius: 999px; | |
| background: var(--accent-2); | |
| transition: width 0.06s linear; | |
| pointer-events: none; | |
| } | |
| .gate-ends { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 11px; | |
| color: var(--text-faint); | |
| letter-spacing: 0; | |
| text-transform: none; | |
| } | |
| /* The range input sits transparently on top of the meter track. */ | |
| .gate-track input[type="range"] { | |
| position: relative; | |
| z-index: 1; | |
| width: 100%; | |
| margin: 0; | |
| -webkit-appearance: none; | |
| appearance: none; | |
| padding: 0; | |
| border: none; | |
| background: transparent; | |
| height: 14px; | |
| cursor: pointer; | |
| } | |
| .gate-track input[type="range"]::-webkit-slider-runnable-track { | |
| height: 14px; | |
| background: transparent; | |
| } | |
| .gate-track input[type="range"]::-moz-range-track { | |
| height: 14px; | |
| background: transparent; | |
| } | |
| .gate-track input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 6px; | |
| height: 18px; | |
| border-radius: 3px; | |
| background: var(--text); | |
| border: 2px solid var(--bg-elev); | |
| box-shadow: 0 0 0 1px var(--border-strong); | |
| } | |
| .gate-track input[type="range"]::-moz-range-thumb { | |
| width: 6px; | |
| height: 18px; | |
| border-radius: 3px; | |
| background: var(--text); | |
| border: 2px solid var(--bg-elev); | |
| box-shadow: 0 0 0 1px var(--border-strong); | |
| } | |
| .gate-track input[type="range"]:focus { border: none; } | |
| .modal-footer { | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 12px; | |
| padding-top: 6px; | |
| } | |
| .btn { | |
| padding: 10px 16px; | |
| border-radius: var(--radius-sm); | |
| border: 1px solid var(--border-strong); | |
| background: var(--bg-elev-2); | |
| color: var(--text); | |
| font-weight: 600; | |
| font-size: 13px; | |
| cursor: pointer; | |
| transition: background 0.15s, border-color 0.15s, transform 0.05s; | |
| } | |
| .btn:hover { | |
| border-color: var(--text); | |
| } | |
| .btn:active { | |
| transform: translateY(1px); | |
| } | |
| .btn.primary { | |
| background: var(--text); | |
| border-color: var(--text); | |
| color: var(--bg); | |
| } | |
| .btn.primary:hover { | |
| background: #fff; | |
| border-color: #fff; | |
| } | |
| .btn.ghost { | |
| background: transparent; | |
| border-color: var(--border); | |
| color: var(--text-dim); | |
| } | |
| .btn.wide { | |
| width: 100%; | |
| padding: 12px 16px; | |
| } | |
| .btn[disabled] { | |
| opacity: 0.45; | |
| cursor: not-allowed; | |
| } | |
| .btn[disabled]:hover { | |
| border-color: var(--border-strong); | |
| } | |
| /* βββ Settings tabs βββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .tabs { | |
| display: flex; | |
| gap: 4px; | |
| padding: 4px; | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| } | |
| .tab { | |
| flex: 1; | |
| padding: 8px 12px; | |
| border: none; | |
| background: transparent; | |
| color: var(--text-dim); | |
| font-family: inherit; | |
| font-size: 13px; | |
| font-weight: 600; | |
| letter-spacing: 0.02em; | |
| border-radius: calc(var(--radius-sm) - 3px); | |
| cursor: pointer; | |
| transition: background 0.15s, color 0.15s; | |
| } | |
| .tab:hover { | |
| color: var(--text); | |
| } | |
| .tab.active { | |
| background: var(--bg-elev-2); | |
| color: var(--text); | |
| box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); | |
| } | |
| .tab-panels { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .tab-panel { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| } | |
| .tab-panel[hidden] { | |
| display: none; | |
| } | |
| /* Horizontal layout for Voice + Model. */ | |
| .field-row { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 12px; | |
| } | |
| /* βββ Chat button + badge ββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| #chat-btn { | |
| position: relative; | |
| } | |
| .chat-badge { | |
| position: absolute; | |
| top: 6px; | |
| right: 6px; | |
| width: 7px; | |
| height: 7px; | |
| border-radius: 50%; | |
| background: var(--text); | |
| border: 1.5px solid var(--bg-elev); | |
| opacity: 0; | |
| transform: scale(0); | |
| transition: opacity 0.2s ease, transform 0.2s ease; | |
| pointer-events: none; | |
| } | |
| .chat-badge.visible { | |
| opacity: 1; | |
| transform: scale(1); | |
| } | |
| /* βββ Ephemeral bubble stack βββββββββββββββββββββββββββββββββββββββββββ */ | |
| .bubble-stack { | |
| position: fixed; | |
| /* Clear the topbar control row so the first bubble doesn't sit at the same | |
| * height as the buttons. */ | |
| top: 96px; | |
| right: 28px; | |
| width: min(300px, calc(100vw - 56px)); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| pointer-events: none; | |
| z-index: 100; | |
| } | |
| .bubble { | |
| --bubble-dx: 10px; | |
| pointer-events: auto; | |
| padding: 10px 14px; | |
| border-radius: var(--radius-md); | |
| font-size: 13px; | |
| line-height: 1.5; | |
| border: 1px solid var(--border); | |
| background: var(--bg-elev); | |
| box-shadow: 0 4px 18px rgba(0, 0, 0, 0.32); | |
| max-width: 100%; | |
| word-break: break-word; | |
| opacity: 0; | |
| transform: translateX(var(--bubble-dx)); | |
| transition: opacity 0.22s ease, transform 0.22s ease; | |
| } | |
| .bubble.in { | |
| opacity: 1; | |
| transform: translateX(0); | |
| } | |
| .bubble.out { | |
| opacity: 0; | |
| transform: translateX(var(--bubble-dx)); | |
| pointer-events: none; | |
| transition: opacity 0.3s ease, transform 0.3s ease; | |
| } | |
| /* Surfaces stay neutral; the side they sit on plus the mono role label | |
| * carry the distinction. No tinted fills β color is the orb's job. */ | |
| .bubble.user { | |
| --bubble-dx: -10px; | |
| align-self: flex-start; | |
| } | |
| .bubble.assistant { | |
| --bubble-dx: 10px; | |
| align-self: flex-end; | |
| } | |
| .bubble.tool { | |
| --bubble-dx: -10px; | |
| align-self: flex-start; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| color: var(--text-dim); | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| } | |
| .bubble.tool .bubble-tool-icon { | |
| width: 13px; | |
| height: 13px; | |
| flex: none; | |
| color: var(--voice-tool); | |
| } | |
| .bubble-role { | |
| font-family: var(--font-mono); | |
| font-size: 10px; | |
| font-weight: 500; | |
| letter-spacing: 0.1em; | |
| text-transform: uppercase; | |
| margin-bottom: 4px; | |
| opacity: 0.7; | |
| } | |
| .bubble.user .bubble-role { color: var(--voice-user); } | |
| .bubble.assistant .bubble-role { color: var(--voice-assistant); } | |
| /* βββ Conversation history panel βββββββββββββββββββββββββββββββββββββββ */ | |
| .chat-panel { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 200; | |
| pointer-events: none; | |
| } | |
| .chat-panel-backdrop { | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(8, 9, 13, 0.45); | |
| backdrop-filter: blur(6px); | |
| -webkit-backdrop-filter: blur(6px); | |
| opacity: 0; | |
| transition: opacity 0.25s ease; | |
| } | |
| .chat-panel.open .chat-panel-backdrop { | |
| opacity: 1; | |
| pointer-events: auto; | |
| } | |
| .chat-panel-inner { | |
| position: absolute; | |
| top: 0; | |
| right: 0; | |
| bottom: 0; | |
| width: min(360px, 90vw); | |
| background: var(--bg-elev); | |
| border-left: 1px solid var(--border); | |
| display: flex; | |
| flex-direction: column; | |
| transform: translateX(100%); | |
| transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1); | |
| pointer-events: auto; | |
| box-shadow: -8px 0 32px rgba(0, 0, 0, 0.35); | |
| } | |
| .chat-panel.open .chat-panel-inner { | |
| transform: translateX(0); | |
| } | |
| .chat-panel-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 18px 20px; | |
| border-bottom: 1px solid var(--border); | |
| flex: none; | |
| } | |
| .chat-panel-header h3 { | |
| margin: 0; | |
| font-size: 14px; | |
| font-weight: 600; | |
| letter-spacing: 0.01em; | |
| } | |
| .chat-history { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 16px 20px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| scroll-behavior: smooth; | |
| } | |
| .chat-history::-webkit-scrollbar { width: 3px; } | |
| .chat-history::-webkit-scrollbar-track { background: transparent; } | |
| .chat-history::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; } | |
| .chat-empty { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 6px; | |
| color: var(--text-faint); | |
| padding: 48px 0; | |
| opacity: 0.65; | |
| } | |
| .chat-empty svg { | |
| margin-bottom: 6px; | |
| opacity: 0.8; | |
| } | |
| .chat-empty-title { | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| font-weight: 500; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| } | |
| .chat-empty-hint { | |
| font-size: 12px; | |
| color: var(--text-faint); | |
| opacity: 0.7; | |
| } | |
| /* βββ History messages βββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .hist-msg { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 3px; | |
| max-width: 88%; | |
| } | |
| .hist-msg.user { align-self: flex-start; } | |
| .hist-msg.assistant { align-self: flex-end; } | |
| .hist-msg.tool { align-self: flex-start; max-width: 100%; } | |
| .hist-role { | |
| font-family: var(--font-mono); | |
| font-size: 10px; | |
| font-weight: 500; | |
| letter-spacing: 0.1em; | |
| text-transform: uppercase; | |
| color: var(--text-faint); | |
| padding: 0 2px; | |
| } | |
| .hist-msg.user .hist-role { color: color-mix(in srgb, var(--voice-user) 75%, var(--text-faint)); } | |
| .hist-msg.assistant .hist-role { color: color-mix(in srgb, var(--voice-assistant) 75%, var(--text-faint)); } | |
| .hist-msg.tool .hist-role { color: color-mix(in srgb, var(--voice-tool) 75%, var(--text-faint)); } | |
| .hist-body { | |
| padding: 9px 12px; | |
| border-radius: var(--radius-md); | |
| font-size: 13px; | |
| line-height: 1.5; | |
| border: 1px solid var(--border); | |
| background: var(--bg-elev-2); | |
| word-break: break-word; | |
| } | |
| /* Bodies share one neutral surface; alignment + the mono role label do the | |
| * distinguishing, so the panel reads as one quiet column. */ | |
| .hist-msg.user .hist-body.partial { opacity: 0.65; } | |
| /* βββ Tool call history item βββββββββββββββββββββββββββββββββββββββββββ */ | |
| .hist-tool-header { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 12px; | |
| border-radius: var(--radius-md) var(--radius-md) 0 0; | |
| background: color-mix(in srgb, var(--processing) 10%, var(--bg-elev-2)); | |
| border: 1px solid color-mix(in srgb, var(--processing) 22%, var(--border)); | |
| border-bottom: 1px solid color-mix(in srgb, var(--processing) 15%, var(--border)); | |
| cursor: pointer; | |
| color: var(--text); | |
| font-family: inherit; | |
| font-size: 13px; | |
| font-weight: 500; | |
| width: 100%; | |
| text-align: left; | |
| transition: background 0.15s; | |
| } | |
| .hist-tool-header:only-child { | |
| border-radius: var(--radius-md); | |
| border-bottom: 1px solid color-mix(in srgb, var(--processing) 22%, var(--border)); | |
| } | |
| .hist-tool-header:hover { | |
| background: color-mix(in srgb, var(--processing) 16%, var(--bg-elev-2)); | |
| } | |
| .hist-tool-icon { | |
| width: 13px; | |
| height: 13px; | |
| flex: none; | |
| color: var(--processing); | |
| } | |
| .hist-tool-name { | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| color: var(--voice-tool); | |
| font-weight: 600; | |
| } | |
| .hist-tool-chevron { | |
| margin-left: auto; | |
| width: 13px; | |
| height: 13px; | |
| color: var(--text-faint); | |
| transition: transform 0.2s ease; | |
| flex: none; | |
| } | |
| .hist-tool-header[aria-expanded="true"] .hist-tool-chevron { | |
| transform: rotate(180deg); | |
| } | |
| .hist-tool-body { | |
| padding: 10px 12px 12px; | |
| border-radius: 0 0 var(--radius-md) var(--radius-md); | |
| background: var(--bg); | |
| border: 1px solid color-mix(in srgb, var(--processing) 22%, var(--border)); | |
| border-top: none; | |
| display: none; | |
| } | |
| .hist-tool-body.open { display: block; } | |
| .hist-tool-label { | |
| font-family: var(--font-mono); | |
| font-size: 10px; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| color: var(--text-faint); | |
| margin: 8px 0 4px; | |
| } | |
| .hist-tool-label:first-child { margin-top: 0; } | |
| .hist-tool-block { | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| line-height: 1.65; | |
| color: var(--text-dim); | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| overflow-x: auto; | |
| } | |
| .hist-tool-output { color: var(--text); } | |
| /* βββ Phone layout ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * On phones the floating bubble stream overlaps the orb and there isn't room | |
| * for it, so we drop it entirely and rely on the conversation panel (opened | |
| * from the top-right chat button) as the single place to read the transcript. | |
| * The badge still pulses there when new messages arrive while it's closed. | |
| * We also stack the mic / stop controls above and below the orb (instead of | |
| * left/right) so the wide live row never overflows, and let the panel take | |
| * the full width. */ | |
| @media (max-width: 600px) { | |
| .bubble-stack { | |
| display: none; | |
| } | |
| .topbar { | |
| padding: 14px 16px; | |
| } | |
| .stage { | |
| padding: 16px 12px 24px; | |
| } | |
| /* Stack vertically: mic above the orb, stop below it (DOM order is | |
| * mic β circle β stop). */ | |
| .orb-wrap { | |
| flex-direction: column; | |
| gap: 14px; | |
| } | |
| .circle { | |
| width: clamp(170px, 56vw, 240px); | |
| } | |
| /* In the column layout the side controls must collapse by HEIGHT, not | |
| * width, so they take no vertical space until the session is live. */ | |
| .side-btn { | |
| width: 44px; | |
| height: 0; | |
| transition: opacity 0.25s ease, transform 0.25s ease, height 0.25s ease, | |
| background 0.15s, color 0.15s, border-color 0.15s; | |
| } | |
| .side-btn svg { | |
| width: 19px; | |
| height: 19px; | |
| } | |
| .orb-wrap.live .side-btn { | |
| width: 44px; | |
| height: 44px; | |
| } | |
| .mic-gate-arc { width: 70px; height: 70px; } | |
| /* Full-screen conversation on phones β feels more deliberate than a | |
| * narrow slide-over. It already spans top-to-bottom (inset 0); make it | |
| * span edge-to-edge too and drop the now-pointless border/shadow. */ | |
| .chat-panel-inner { | |
| width: 100vw; | |
| border-left: none; | |
| box-shadow: none; | |
| } | |
| } | |
| /* βββ About panel βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * Opened from the (i) by the wordmark. Reuses the <dialog> .modal shell. | |
| * Strictly monochrome per DESIGN.md β the only "color" is the orb, never | |
| * here. Machine identifiers (model IDs, role tags, usernames) ride Geist | |
| * Mono; everything human stays Inter. */ | |
| .about-modal { | |
| /* Roomier on tablet/desktop; the 92vw cap keeps phones full-width. */ | |
| width: min(680px, 92vw); | |
| max-height: 88vh; | |
| } | |
| .about-modal .modal-content { | |
| max-height: 88vh; | |
| overflow-y: auto; | |
| gap: 20px; | |
| padding: 26px 30px 24px; | |
| } | |
| .about-modal .modal-content::-webkit-scrollbar { width: 3px; } | |
| .about-modal .modal-content::-webkit-scrollbar-track { background: transparent; } | |
| .about-modal .modal-content::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; } | |
| /* Links here are quiet: no permanent underline (the global dotted rule is | |
| * too loud for a dense credit block), brighten + underline on hover only. */ | |
| .about-modal a { | |
| color: var(--text); | |
| border-bottom: none; | |
| text-decoration: none; | |
| transition: color 0.15s ease; | |
| } | |
| .about-modal a:hover { | |
| color: #fff; | |
| } | |
| .about-intro a:hover { | |
| text-decoration: underline; | |
| text-underline-offset: 2px; | |
| } | |
| .about-modal .ext { | |
| width: 12px; | |
| height: 12px; | |
| flex: none; | |
| opacity: 0.5; | |
| } | |
| /* Title row: the demo name and the (i) read as one unit. */ | |
| .ident-head { | |
| display: flex; | |
| align-items: center; | |
| gap: 9px; | |
| } | |
| /* (i) trigger sits right after the title. A faint outline keeps it | |
| * catchable without turning into a card; it fills in on hover. */ | |
| .about-btn { | |
| flex: none; | |
| width: 34px; | |
| height: 34px; | |
| border-radius: 50%; | |
| background: transparent; | |
| border: 1px solid var(--border-strong); | |
| color: var(--text-dim); | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| touch-action: manipulation; | |
| transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; | |
| } | |
| .about-btn:hover { | |
| background: var(--bg-elev); | |
| border-color: var(--text-dim); | |
| color: var(--text); | |
| } | |
| .about-btn svg { | |
| width: 20px; | |
| height: 20px; | |
| } | |
| /* The mobile twin of (i) lives in the right-hand control cluster and is | |
| * hidden on desktop (the in-title one shows there instead). */ | |
| .about-btn-mobile { | |
| display: none; | |
| } | |
| /* ββ Popup intro: a plain paragraph on the project + a repo link ββ */ | |
| .about-intro p { | |
| margin: 0; | |
| font-size: 14px; | |
| line-height: 1.6; | |
| color: var(--text-dim); | |
| } | |
| .about-repo { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| margin-top: 12px; | |
| font-size: 14px; | |
| font-weight: 500; | |
| } | |
| /* ββ Corner identity (replaces the wordmark) ββ | |
| * A compact stack in the topbar: title, one-line blurb, two meta rows. | |
| * Monochrome; only the role/name identifiers ride the machine typeface. */ | |
| .ident { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| min-width: 0; | |
| } | |
| /* Reset the global underlined-anchor styling inside the identity block. */ | |
| .ident a { | |
| border-bottom: none; | |
| color: inherit; | |
| transition: color 0.15s ease; | |
| } | |
| .ident a:hover { | |
| color: var(--text); | |
| text-decoration: underline; | |
| text-underline-offset: 2px; | |
| } | |
| .ident-title { | |
| font-size: 22px; | |
| font-weight: 600; | |
| letter-spacing: 0.005em; | |
| line-height: 1.1; | |
| color: var(--text); | |
| } | |
| .ident-blurb { | |
| margin: 0; | |
| max-width: 48ch; | |
| font-size: 14.5px; | |
| line-height: 1.5; | |
| font-weight: 400; | |
| color: var(--text-dim); | |
| } | |
| .ident-meta { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 7px; | |
| font-size: 14px; | |
| font-weight: 400; | |
| color: var(--text-dim); | |
| } | |
| .ident-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| gap: 4px 8px; | |
| } | |
| .ident-label { | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| font-weight: 500; | |
| letter-spacing: 0.1em; | |
| text-transform: uppercase; | |
| color: var(--text-faint); | |
| } | |
| /* Shared credit bits β now used in the corner identity block. */ | |
| .sep { | |
| color: var(--text-faint); | |
| opacity: 0.6; | |
| } | |
| .hf-credit { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| } | |
| /* Brand marks keep their own color β a deliberate exception to the | |
| * monochrome rule, for the HF and Cerebras logos only. */ | |
| .hf-mark { | |
| width: 14px; | |
| height: 14px; | |
| flex: none; | |
| color: #ffd21e; | |
| } | |
| .cerebras-credit { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| } | |
| .cerebras-mark { | |
| width: 14px; | |
| height: 14px; | |
| flex: none; | |
| } | |
| /* Usernames are identifiers, so they ride the machine typeface. */ | |
| .handle { | |
| font-family: var(--font-mono); | |
| font-size: 13.5px; | |
| } | |
| /* ββ Pipeline (the signal flow) ββ */ | |
| .about-pipeline { | |
| border-top: 1px solid var(--border); | |
| padding-top: 16px; | |
| } | |
| .pipeline-title { | |
| margin: 0 0 14px; | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| font-weight: 500; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| color: var(--text-faint); | |
| } | |
| .pipeline { | |
| list-style: none; | |
| margin: 0; | |
| padding: 0; | |
| position: relative; | |
| } | |
| /* One continuous rail threading every node, dot centers at x=8.5px. */ | |
| .pipeline::before { | |
| content: ""; | |
| position: absolute; | |
| left: 8px; | |
| top: 7px; | |
| bottom: 7px; | |
| width: 1px; | |
| background: var(--border-strong); | |
| } | |
| .pipeline > li { | |
| position: relative; | |
| padding-left: 30px; | |
| } | |
| /* Stages: solid node. Endpoints (you / orb): hollow node. */ | |
| .pipe-stage::before { | |
| content: ""; | |
| position: absolute; | |
| left: 5px; | |
| top: 4px; | |
| width: 7px; | |
| height: 7px; | |
| border-radius: 50%; | |
| background: var(--text-dim); | |
| } | |
| .pipe-endpoint::before { | |
| content: ""; | |
| position: absolute; | |
| left: 5px; | |
| top: 3px; | |
| width: 7px; | |
| height: 7px; | |
| border-radius: 50%; | |
| border: 1px solid var(--text-faint); | |
| background: var(--bg-elev); | |
| } | |
| .pipe-endpoint { | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| font-weight: 500; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| color: var(--text-faint); | |
| padding-bottom: 12px; | |
| } | |
| .pipeline > li.pipe-endpoint:last-child { | |
| padding-bottom: 0; | |
| } | |
| .pipe-stage { | |
| padding-bottom: 16px; | |
| } | |
| .pipe-tag { | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| font-weight: 600; | |
| letter-spacing: 0.08em; | |
| color: var(--text); | |
| margin-right: 9px; | |
| } | |
| .pipe-job { | |
| font-size: 14px; | |
| color: var(--text-dim); | |
| } | |
| /* Middot between the job and its model link, matching the separators | |
| * used elsewhere. */ | |
| .pipe-job::after { | |
| content: "Β·"; | |
| margin: 0 7px; | |
| color: var(--text-faint); | |
| } | |
| .pipe-note { | |
| color: var(--text-faint); | |
| } | |
| /* The Cerebras link stays as quiet as the note; brightens + underlines on hover. */ | |
| .pipe-note a { | |
| color: inherit; | |
| } | |
| .pipe-note a:hover { | |
| color: var(--text); | |
| text-decoration: underline; | |
| text-underline-offset: 2px; | |
| } | |
| .pipe-model { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 4px; | |
| margin-top: 4px; | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| color: var(--text-dim); | |
| word-break: break-all; | |
| } | |
| .pipe-model:hover { | |
| color: #fff; | |
| text-decoration: underline; | |
| text-underline-offset: 2px; | |
| } | |
| .pipe-model .ext { | |
| width: 11px; | |
| height: 11px; | |
| } | |
| /* "Interrupted" tag on an assistant reply the user barged in on. */ | |
| .hist-note { | |
| font-family: var(--font-mono); | |
| font-size: 10px; | |
| font-weight: 500; | |
| letter-spacing: 0.1em; | |
| text-transform: uppercase; | |
| color: var(--text-faint); | |
| padding: 0 2px; | |
| } | |
| .hist-msg.assistant .hist-note { | |
| align-self: flex-end; | |
| } | |
| .hist-msg.interrupted .hist-body { | |
| opacity: 0.7; | |
| } | |
| /* Captured webcam frame shown in the transcript (camera tool result). */ | |
| .hist-image { | |
| display: block; | |
| width: 100%; | |
| max-width: 240px; | |
| border-radius: var(--radius-sm); | |
| border: 1px solid var(--border); | |
| margin-top: 2px; | |
| } | |
| /* βββ Tools panel ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * Reuses the modal/field shell. Switches are monochrome (checked = near-white, | |
| * the same high-contrast treatment as the primary button): color belongs to | |
| * the voice, not the chrome. */ | |
| .tools-intro { | |
| margin: 0; | |
| font-size: 13px; | |
| line-height: 1.5; | |
| color: var(--text-dim); | |
| } | |
| .tool-list { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .tool-row { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 16px; | |
| padding: 12px 0; | |
| } | |
| .tool-row-sep { | |
| border-top: 1px solid var(--border); | |
| margin-top: 4px; | |
| } | |
| .tool-info { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 3px; | |
| min-width: 0; | |
| } | |
| .tool-name { | |
| font-size: 14px; | |
| font-weight: 600; | |
| color: var(--text); | |
| } | |
| .tool-desc { | |
| font-size: 12.5px; | |
| color: var(--text-dim); | |
| } | |
| .tool-row.disabled .tool-name, | |
| .tool-row.disabled .tool-desc { | |
| opacity: 0.5; | |
| } | |
| .tool-hint { | |
| display: block; | |
| font-size: 12px; | |
| line-height: 1.4; | |
| color: var(--text-faint); | |
| } | |
| .tools-key { | |
| margin: 0 0 4px; | |
| } | |
| /* Toggle switch */ | |
| .switch { | |
| position: relative; | |
| display: inline-flex; | |
| flex: none; | |
| width: 40px; | |
| height: 24px; | |
| cursor: pointer; | |
| } | |
| .switch input { | |
| position: absolute; | |
| inset: 0; | |
| width: 100%; | |
| height: 100%; | |
| margin: 0; | |
| opacity: 0; | |
| cursor: pointer; | |
| } | |
| .switch-track { | |
| position: absolute; | |
| inset: 0; | |
| border-radius: 999px; | |
| background: var(--bg); | |
| border: 1px solid var(--border-strong); | |
| transition: background 0.15s, border-color 0.15s; | |
| } | |
| .switch-track::after { | |
| content: ""; | |
| position: absolute; | |
| top: 50%; | |
| left: 3px; | |
| transform: translateY(-50%); | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: var(--text-dim); | |
| transition: transform 0.18s ease, background 0.15s; | |
| } | |
| .switch input:checked + .switch-track { | |
| background: var(--text); | |
| border-color: var(--text); | |
| } | |
| .switch input:checked + .switch-track::after { | |
| transform: translate(16px, -50%); | |
| background: var(--bg); | |
| } | |
| .switch input:focus-visible + .switch-track { | |
| outline: 2px solid var(--text-dim); | |
| outline-offset: 2px; | |
| } | |
| .switch input:disabled { | |
| cursor: not-allowed; | |
| } | |
| .switch input:disabled + .switch-track { | |
| opacity: 0.5; | |
| } | |
| /* βββ Webcam preview (camera tool) ββββββββββββββββββββββββββββββββββββββββββ | |
| * Floating self-view, bottom-left. Mirrored for the user; the frame sent to | |
| * the model is drawn un-mirrored (see captureSnapshot). */ | |
| .cam-pip { | |
| position: fixed; | |
| left: 20px; | |
| bottom: 20px; | |
| width: 280px; | |
| aspect-ratio: 4 / 3; | |
| border-radius: var(--radius-md); | |
| overflow: hidden; | |
| border: 1px solid var(--border-strong); | |
| background: var(--bg-elev); | |
| box-shadow: var(--shadow-soft); | |
| z-index: 90; | |
| opacity: 0; | |
| transform: translateY(8px) scale(0.96); | |
| pointer-events: none; | |
| transition: opacity 0.22s ease, transform 0.22s ease; | |
| } | |
| .cam-pip.visible { | |
| opacity: 1; | |
| transform: none; | |
| } | |
| .cam-video { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| transform: scaleX(-1); /* mirror the self-view only */ | |
| background: var(--bg); | |
| } | |
| .cam-label { | |
| position: absolute; | |
| left: 8px; | |
| bottom: 6px; | |
| font-family: var(--font-mono); | |
| font-size: 9.5px; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| color: var(--text); | |
| opacity: 0.8; | |
| text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); | |
| } | |
| .cam-flash { | |
| position: absolute; | |
| inset: 0; | |
| background: #fff; | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| .cam-pip.flash .cam-flash { | |
| animation: cam-flash 0.4s ease; | |
| } | |
| @keyframes cam-flash { | |
| 0% { opacity: 0; } | |
| 12% { opacity: 0.85; } | |
| 100% { opacity: 0; } | |
| } | |
| @media (max-width: 600px) { | |
| /* Bottom-centred on phones. Auto margins centre it without touching the | |
| * transform, so the slide/scale-in animation still works. */ | |
| .cam-pip { | |
| left: 0; | |
| right: 0; | |
| margin-inline: auto; | |
| bottom: 16px; | |
| width: min(188px, 52vw); | |
| } | |
| /* The credit would sit under the centred preview, so drop it while the | |
| * camera is on. */ | |
| body.cam-on .footer { | |
| display: none; | |
| } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| .cam-pip { | |
| transition: opacity 0.22s ease; | |
| transform: none; | |
| } | |
| .cam-pip.flash .cam-flash { | |
| animation: none; | |
| } | |
| } | |
| /* βββ Desktop type scale ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * A uniform step up (~+1px, title +2) for all UI text on larger screens. | |
| * Scoped to min-width: 601px so it can't reach phones β the β€600px layout | |
| * keeps every size exactly as it was. */ | |
| @media (min-width: 601px) { | |
| .cam-label { font-size: 10.5px; } | |
| .bubble-role, | |
| .hist-role { font-size: 11px; } | |
| .circle-caption, | |
| .footer, | |
| .chat-empty-title, | |
| .hist-tool-block, | |
| .ident-label, | |
| .pipeline-title, | |
| .pipe-endpoint { font-size: 12px; } | |
| .field > span, | |
| .field small, | |
| .bubble.tool, | |
| .chat-empty-hint, | |
| .hist-tool-name, | |
| .pipe-tag, | |
| .pipe-model, | |
| .tool-hint { font-size: 13px; } | |
| .tool-desc { font-size: 13.5px; } | |
| .btn, | |
| .bubble, | |
| .field, | |
| .hist-body, | |
| .hist-tool-header, | |
| .tools-intro { font-size: 14px; } | |
| .handle { font-size: 14.5px; } | |
| .about-intro p, | |
| .about-repo, | |
| .brand, | |
| .chat-panel-header h3, | |
| .field textarea, | |
| .ident-meta, | |
| .pipe-job, | |
| .tool-name { font-size: 15px; } | |
| .ident-blurb { font-size: 15.5px; } | |
| .modal-header h2 { font-size: 17px; } | |
| .ident-title { font-size: 24px; } | |
| } | |