Domotick's picture
v0.9.0
0e2186b
Raw
History Blame Contribute Delete
42.6 kB
@import "tailwindcss";
@source "../../reachy_web/templates/**/*.html";
@source "../../reachy_web/static/js/**/*.js";
/* dark: utilities follow the theme switch (data-theme), not the OS */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
/* Fonts, self-hosted (no CDN) — same faces as the PiWRT reference.
Each palette picks its own body font below; Space Grotesk also carries the product name. */
@font-face { font-family: 'Space Grotesk'; src: url('/static/fonts/SpaceGrotesk.ttf') format('truetype'); font-weight: 100 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Poppins'; src: url('/static/fonts/Poppins-Regular.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: 'Outfit'; src: url('/static/fonts/Outfit.ttf') format('truetype'); font-weight: 100 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Sora'; src: url('/static/fonts/Sora.ttf') format('truetype'); font-weight: 100 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Manrope'; src: url('/static/fonts/Manrope.ttf') format('truetype'); font-weight: 100 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Share Tech Mono'; src: url('/static/fonts/ShareTechMono-Regular.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: swap; }
/* Hugging Face's own body typeface (their site loads Source Sans Pro); shipped as
the Source Sans 3 variable TTF, its successor of the same design. */
@font-face { font-family: 'Source Sans Pro'; src: url('/static/fonts/SourceSans3.ttf') format('truetype'); font-weight: 200 900; font-style: normal; font-display: swap; }
/* ================= PiWRT design tokens ================= */
/* dark is the default; [data-theme=light] flips it; data-palette recolors both */
:root { color-scheme: dark; accent-color: var(--acc); --bg: #0b0e14; --card: #151923; --soft: #0f1320; --line: #1e2533; --txt: #e8eaed; --mut: #8a909a; --acc: #3b82f6; --ok: #27c281; --warn: #e0a52b; --bad: #c23a3f; }
[data-theme=light] { color-scheme: light; --bg: #eef1f7; --card: #fff; --soft: #f1f4f9; --line: #d9dfea; --txt: #1b2330; --mut: #5b6675; --acc: #2563eb; --ok: #16a34a; --warn: #b45309; --bad: #dc2626; }
:root[data-palette=ocean] { --bg: #0a141c; --card: #11202e; --soft: #1a3a48; --line: #1f3245; --txt: #e1ecf4; --mut: #85a1b5; --acc: #22d3ee; }
:root[data-palette=ocean][data-theme=light] { --bg: #f0f7fa; --card: #fff; --soft: #cfeaf1; --line: #dde9f0; --txt: #0f1b26; --mut: #5a7387; --acc: #0891b2; }
:root[data-palette=violet] { --bg: #15101f; --card: #1f1a2e; --soft: #2a2342; --line: #2e2942; --txt: #f1edf8; --mut: #a19bb5; --acc: #a78bfa; }
:root[data-palette=violet][data-theme=light] { --bg: #f7f4fb; --card: #fff; --soft: #efe9fa; --line: #e6e0ef; --txt: #1f1b2c; --mut: #6b6480; --acc: #7c3aed; }
:root[data-palette=sunset] { --bg: #1c100a; --card: #2a180f; --soft: #3d2412; --line: #3d2415; --txt: #f5e6d8; --mut: #b59880; --acc: #fb923c; }
:root[data-palette=sunset][data-theme=light] { --bg: #fff7f0; --card: #fff; --soft: #fce7d6; --line: #f5e6d8; --txt: #2c1810; --mut: #7a5a45; --acc: #ea580c; }
:root[data-palette=forest] { --bg: #0e140f; --card: #172218; --soft: #1a3422; --line: #1f2a1f; --txt: #e8f0e5; --mut: #8fa088; --acc: #4ade80; }
:root[data-palette=forest][data-theme=light] { --bg: #f4f8f1; --card: #fff; --soft: #dcefde; --line: #dde5d6; --txt: #1a2614; --mut: #5f6e55; --acc: #16a34a; }
:root[data-palette=rose] { --bg: #1a0b12; --card: #2a1320; --soft: #3d1a2d; --line: #3d1a2a; --txt: #fdf2f8; --mut: #c49aab; --acc: #fb7185; }
:root[data-palette=rose][data-theme=light] { --bg: #fdf2f8; --card: #fff; --soft: #fbe0ea; --line: #f4d9e2; --txt: #1f0e15; --mut: #7a4757; --acc: #e11d48; }
:root[data-palette=mint] { --bg: #06181a; --card: #0e2426; --soft: #133a35; --line: #1b3638; --txt: #e0f5ee; --mut: #7fa89a; --acc: #5eead4; }
:root[data-palette=mint][data-theme=light] { --bg: #f0fbf6; --card: #fff; --soft: #ccf1e8; --line: #d5ede0; --txt: #0a2e20; --mut: #5c7c6e; --acc: #14b8a6; }
:root[data-palette=amber] { --bg: #101114; --card: #1a1c20; --soft: #26292f; --line: #2e323a; --txt: #eceef2; --mut: #9aa0aa; --acc: #e9a23b; }
:root[data-palette=amber][data-theme=light] { --bg: #fafafb; --card: #fff; --soft: #f1f2f4; --line: #e2e5ea; --txt: #17191d; --mut: #5f6672; --acc: #b9761a; }
:root[data-palette=slate] { --bg: #0f172a; --card: #1e293b; --soft: #1e2b40; --line: #334155; --txt: #f1f5f9; --mut: #94a3b8; --acc: #94a3b8; }
:root[data-palette=slate][data-theme=light] { --bg: #f8fafc; --card: #fff; --soft: #e2e8f0; --line: #e2e8f0; --txt: #0f172a; --mut: #64748b; --acc: #475569; }
:root[data-palette=nordic] { --bg: #2e3440; --card: #3b4252; --soft: #3f4f65; --line: #4c566a; --txt: #eceff4; --mut: #a8b0bf; --acc: #88c0d0; }
:root[data-palette=nordic][data-theme=light] { --bg: #eceff4; --card: #fff; --soft: #e5ecf3; --line: #d8dee9; --txt: #2e3440; --mut: #4c566a; --acc: #5e81ac; }
:root[data-palette=one-dark] { --bg: #0e1015; --card: #1b1d22; --soft: #21304a; --line: #2a2d34; --txt: #abb2bf; --mut: #5c6370; --acc: #61afef; }
:root[data-palette=one-dark][data-theme=light] { --bg: #fafafa; --card: #fff; --soft: #dbe7fb; --line: #e5e5e6; --txt: #383a42; --mut: #6b6f76; --acc: #4078f2; }
:root[data-palette=midnight] { --bg: #000; --card: #0a0a0a; --soft: #1a1b3a; --line: #1a1a1a; --txt: #fafafa; --mut: #888; --acc: #818cf8; }
:root[data-palette=midnight][data-theme=light] { --bg: #fff; --card: #f5f5f5; --soft: #e0e1fb; --line: #e5e5e5; --txt: #000; --mut: #666; --acc: #4f46e5; }
:root[data-palette=matrix] { --bg: #000; --card: #0a140a; --soft: #0e1f0e; --line: #163a16; --txt: #4dff77; --mut: #2e9a4f; --acc: #00ff41; }
:root[data-palette=matrix][data-theme=light] { --bg: #eefbf1; --card: #fff; --soft: #d6f7dd; --line: #c3eece; --txt: #08350f; --mut: #4a7d57; --acc: #0ba21e; }
/* Hugging Face: their Ebony dark + Candlelight yellow (#FFD21E) accent; light is a
warm cream with a readable gold accent (pure yellow vanishes on white). */
:root[data-palette=huggingface] { --bg: #0b0f1a; --card: #161c2b; --soft: #202a40; --line: #26314a; --txt: #f5f6f9; --mut: #8c96ab; --acc: #ffd21e; }
:root[data-palette=huggingface][data-theme=light] { --bg: #fffdf4; --card: #fff; --soft: #fef3cf; --line: #efe4c0; --txt: #17202e; --mut: #5f6a7c; --acc: #d99700; }
/* Each palette brings its own typeface and halo (PiWRT parity) */
:root[data-palette=ocean] body { background: radial-gradient(1200px 600px at 50% -10%, #11202e, var(--bg)) fixed; font-family: 'Poppins', system-ui, sans-serif; }
:root[data-palette=violet] body { background: radial-gradient(1200px 600px at 50% -10%, #1f1a2e, var(--bg)) fixed; font-family: 'Space Grotesk', system-ui, sans-serif; }
:root[data-palette=sunset] body { background: radial-gradient(1200px 600px at 50% -10%, #2a180f, var(--bg)) fixed; font-family: 'Outfit', system-ui, sans-serif; }
:root[data-palette=forest] body { background: radial-gradient(1200px 600px at 50% -10%, #172218, var(--bg)) fixed; font-family: 'Poppins', system-ui, sans-serif; }
:root[data-palette=rose] body { background: radial-gradient(1200px 600px at 50% -10%, #2a1320, var(--bg)) fixed; font-family: 'Manrope', system-ui, sans-serif; }
:root[data-palette=mint] body { background: radial-gradient(1200px 600px at 50% -10%, #0e2426, var(--bg)) fixed; font-family: 'Poppins', system-ui, sans-serif; }
:root[data-palette=amber] body { background: radial-gradient(1200px 600px at 50% -10%, #1a1c20, var(--bg)) fixed; font-family: 'Sora', system-ui, sans-serif; }
:root[data-palette=slate] body { background: radial-gradient(1200px 600px at 50% -10%, #1e293b, var(--bg)) fixed; font-family: 'Poppins', system-ui, sans-serif; }
:root[data-palette=nordic] body { background: radial-gradient(1200px 600px at 50% -10%, #3b4252, var(--bg)) fixed; font-family: 'Poppins', system-ui, sans-serif; }
:root[data-palette=one-dark] body { background: radial-gradient(1200px 600px at 50% -10%, #1b1d22, var(--bg)) fixed; font-family: 'Share Tech Mono', ui-monospace, monospace; }
:root[data-palette=midnight] body { background: radial-gradient(1200px 600px at 50% -10%, #0a0a0a, var(--bg)) fixed; font-family: 'Poppins', system-ui, sans-serif; }
:root[data-palette=matrix] body { background: radial-gradient(1200px 600px at 50% -10%, #0a140a, var(--bg)) fixed; font-family: 'Share Tech Mono', ui-monospace, monospace; letter-spacing: 0.3px; text-shadow: 0 0 1px #00ff4140; }
:root[data-palette=huggingface] body { background: radial-gradient(1200px 600px at 50% -10%, #161c2b, var(--bg)) fixed; font-family: 'Source Sans Pro', system-ui, sans-serif; }
/* HF's accent is bright yellow/gold: dark text reads on it, white does not. Flip
the accent-filled controls that otherwise hardcode white text. */
:root[data-palette=huggingface] .tab.on,
:root[data-palette=huggingface] .navmenu-list button.on,
:root[data-palette=huggingface] .cam-seg button.on { color: #0b0f1a; }
/* in light mode every palette fades from white (the font stays the palette's) */
:root[data-palette][data-theme=light] body { background: radial-gradient(1200px 600px at 50% -10%, #fff, var(--bg)) fixed; }
/* `.hidden` must beat everything it is combined with: Tailwind's own display
utilities emitted after it (`inline-flex` on the empty:hidden pills), AND the
hand-written unlayered classes toggled at runtime (`.skin-list`, catalogue
panes...) which outrank any layered utility. Hence the !important sledgehammer.
Its one victim was the responsive show pattern (`hidden sm:flex` on the store
author/stars columns stayed hidden at every width), so the two responsive
display utilities used with `hidden` are re-asserted just below, later in the
source and equally !important, so above 640px they win back. */
.hidden { display: none !important; }
@media (min-width: 40rem) {
.sm\:flex.hidden { display: flex !important; }
.sm\:grid.hidden { display: grid !important; }
}
/* ================= utilities → tokens ================= */
/* The recovered UI is written in white/gray/blue/green utilities. Remapping the
scales onto the tokens makes every existing class — templates AND markup
built in JS — follow the theme and the palette without a rewrite.
gray-800/900 are NOT remapped: they are used as literal darks over video. */
@theme inline {
--color-bg: var(--bg);
--color-card: var(--card);
--color-soft: var(--soft);
--color-line: var(--line);
--color-txt: var(--txt);
--color-mut: var(--mut);
--color-acc: var(--acc);
--color-ok: var(--ok);
--color-warn: var(--warn);
--color-bad: var(--bad);
--color-gray-50: var(--bg);
--color-gray-100: var(--soft);
--color-gray-200: var(--soft);
--color-gray-300: var(--line);
--color-gray-400: color-mix(in srgb, var(--mut) 60%, var(--line));
--color-gray-500: var(--mut);
--color-gray-600: color-mix(in srgb, var(--mut) 55%, var(--txt));
--color-gray-700: color-mix(in srgb, var(--mut) 30%, var(--txt));
--color-gray-900: var(--txt);
--color-blue-300: color-mix(in srgb, var(--acc) 55%, white);
--color-blue-500: var(--acc);
--color-blue-600: var(--acc);
--color-blue-700: color-mix(in srgb, var(--acc) 85%, black);
--color-blue-800: color-mix(in srgb, var(--acc) 70%, black);
--color-green-400: var(--ok);
--color-green-500: var(--ok);
--color-green-600: color-mix(in srgb, var(--ok) 85%, black);
--color-green-700: color-mix(in srgb, var(--ok) 70%, black);
--color-red-400: color-mix(in srgb, var(--bad) 80%, white);
--color-red-500: var(--bad);
--color-red-600: var(--bad);
--color-red-700: color-mix(in srgb, var(--bad) 85%, black);
}
/* ================= base ================= */
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
color: var(--txt);
background: radial-gradient(1200px 600px at 50% -10%, var(--soft), var(--bg)) fixed;
transition: color 0.25s;
}
/* ================= PiWRT components ================= */
.topbar {
position: sticky;
top: 0;
z-index: 30;
backdrop-filter: blur(14px);
background: color-mix(in srgb, var(--bg) 78%, transparent);
border-bottom: 1px solid var(--line);
}
.topbar-inner {
/* width/centering comes from the same container utilities as the page
content (base.html), so the bar always aligns with the layout */
padding-top: 8px;
padding-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 1.05rem;
}
.topbar-brand img {
width: 30px;
height: 30px;
border-radius: 9px;
}
.topbar-controls {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
/* the 13 palettes on two balanced rows (7 + 6) rather than one long wrapping
line — a grid, so the dots line up in columns instead of drifting */
.palettes {
display: grid;
grid-template-columns: repeat(7, auto);
justify-items: center;
align-items: center;
gap: 6px;
}
.pdot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
transition: transform 0.15s, border-color 0.15s;
}
.pdot:hover { transform: scale(1.2); }
.pdot.on { border-color: var(--txt); transform: scale(1.15); }
.theme-ctl { display: flex; align-items: center; gap: 6px; color: var(--mut); font-size: 0.9rem; }
.ios {
appearance: none;
border: none;
position: relative;
width: 38px;
height: 22px;
border-radius: 999px;
background: var(--line);
cursor: pointer;
flex: none;
padding: 0;
transition: background-color 0.18s;
}
.ios:after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px #00000059;
transition: transform 0.18s;
}
.ios.on { background: var(--acc); }
.ios.on:after { transform: translateX(16px); }
.seg {
display: inline-flex;
gap: 4px;
background: var(--soft);
border: 1px solid var(--line);
border-radius: 12px;
padding: 4px;
}
.seg button {
border: none;
cursor: pointer;
border-radius: 9px;
background: transparent;
color: var(--mut);
font-family: inherit;
font-weight: 600;
font-size: 0.85rem;
padding: 7px 14px;
transition: background-color 0.18s, color 0.18s;
}
.seg button:hover:not(.on) { background: color-mix(in srgb, var(--acc) 14%, var(--card)); color: var(--txt); }
.seg button.on { background: var(--acc); color: var(--bg); }
/* the Camera / Simulator switch floats INSIDE the viewport, top-right, icons only.
A translucent dark chip so the icons read over both the video and the 3D scene,
whatever the theme; the active mode fills with the accent. */
.cam-seg {
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.14);
}
.cam-seg button { color: #e8eaed; }
.cam-seg button:hover:not(.on) { background: rgba(255, 255, 255, 0.14); color: #fff; }
.cam-seg button.on { background: var(--acc); color: #fff; }
.cam-seg .ic { font-size: 1.05rem; }
/* compact variant: a switch that only picks a list has no business being as tall
as a real control (the Dances / Emotions one, under its own header) */
.seg.sm { padding: 3px; border-radius: 10px; }
.seg.sm button { padding: 3px 10px; border-radius: 7px; font-size: 0.8rem; }
/* ================= Hugging Face auth (amber = HF brand, deliberate) ================= */
#hf-login-modal {
opacity: 0;
visibility: hidden;
transition: opacity 0.25s ease, visibility 0.25s ease;
}
#hf-login-modal.visible {
opacity: 1;
visibility: visible;
}
#hf-login-modal .modal-content {
transform: scale(0.95) translateY(-10px);
transition: transform 0.25s ease;
}
#hf-login-modal.visible .modal-content {
transform: scale(1) translateY(0);
}
#hf-modal-token-input {
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
#hf-modal-oauth-btn,
#hf-modal-token-btn {
transition: all 0.2s ease;
}
#hf-modal-oauth-btn:hover {
transform: translateY(-1px);
}
#hf-modal-oauth-btn:active,
#hf-modal-token-btn:active {
transform: translateY(0);
}
.hf-modal-head {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-bottom: 2px solid #fcd34d;
border-radius: 18px 18px 0 0;
}
.hf-btn-amber {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
/* header Login/Logout button — .logout is toggled by hf_auth.js */
.hf-auth-btn {
background-color: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
.hf-auth-btn:hover { background-color: #fde68a; }
.hf-auth-btn.logout {
background-color: color-mix(in srgb, var(--bad) 12%, var(--card));
color: var(--bad);
border-color: color-mix(in srgb, var(--bad) 35%, var(--line));
}
.hf-auth-btn.logout:hover { background-color: color-mix(in srgb, var(--bad) 22%, var(--card)); }
/* relay status chip — state class (ok/warn/err) set by hf_auth.js */
.hf-relay { background-color: var(--soft); border: 1px solid var(--line); }
.hf-relay .dot { background-color: var(--mut); }
.hf-relay .lbl { color: var(--mut); }
.hf-relay.ok { background-color: color-mix(in srgb, var(--ok) 14%, var(--card)); border-color: color-mix(in srgb, var(--ok) 40%, var(--line)); }
.hf-relay.ok .dot { background-color: var(--ok); }
.hf-relay.ok .lbl { color: var(--ok); }
.hf-relay.warn { background-color: color-mix(in srgb, var(--warn) 14%, var(--card)); border-color: color-mix(in srgb, var(--warn) 40%, var(--line)); }
.hf-relay.warn .dot { background-color: var(--warn); }
.hf-relay.warn .lbl { color: var(--warn); }
.hf-relay.err { background-color: color-mix(in srgb, var(--bad) 14%, var(--card)); border-color: color-mix(in srgb, var(--bad) 40%, var(--line)); }
.hf-relay.err .dot { background-color: var(--bad); }
.hf-relay.err .lbl { color: var(--bad); }
.hf-badge-user {
background-color: color-mix(in srgb, var(--ok) 14%, var(--card));
border: 1px solid color-mix(in srgb, var(--ok) 40%, var(--line));
}
/* ================= legacy component classes, tokenized ================= */
/* APPs styles */
.app-section {
border-radius: 16px;
border-width: 1px;
border-color: var(--line);
padding: 16px;
gap: 16px;
background: var(--card);
box-shadow: 0 8px 24px #00000040;
}
[data-theme=light] .app-section { box-shadow: 0 6px 18px #141e3c14; }
.installed-app-title {
font-weight: 600;
}
.update-badge {
display: inline-block;
background-color: var(--acc);
color: var(--bg);
font-size: 0.65rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
margin-left: 0.5rem;
vertical-align: middle;
}
/* Hugging Face App Store Styles */
.app-section-title {
font-weight: 500;
font-size: 24px;
letter-spacing: 0px;
}
.app-list-item {
padding: 4px;
}
.hf-app-icon {
width: 42px;
height: 42px;
border-radius: 10px;
text-align: center;
font-size: 31px;
background-color: var(--soft);
}
.hf-app-title {
font-weight: 500;
font-size: 18px;
}
.hf-app-description {
font-weight: 400;
font-size: 14px;
line-height: 1.4;
color: var(--mut);
}
.wifi-status {
font-weight: 500;
color: var(--txt);
}
.wifi-status-info {
font-weight: 400;
color: var(--mut);
}
.notification-modal {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
min-width: 300px;
max-width: 400px;
background: var(--card);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
border-radius: 8px;
display: none;
animation: fadeInUp 0.3s;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.notification-content {
padding: 20px 28px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
justify-content: space-between;
}
/* Notification variants */
.notification-content.info {
background: var(--card);
border: 1px solid var(--line);
}
.notification-content.error {
background: color-mix(in srgb, var(--bad) 12%, var(--card));
border: 1px solid var(--bad);
color: color-mix(in srgb, var(--bad) 70%, var(--txt));
}
.close-btn {
background: none;
border: none;
font-size: 1.5em;
cursor: pointer;
color: var(--mut);
}
/* ================= topbar tools (volume / mic) ================= */
.topbar-tool {
display: flex;
align-items: center;
gap: 6px;
color: var(--mut);
}
/* sound sliders (mic + speaker knobs, waveforms) are styled together lower down,
next to .topbar-sound */
.topbar-tool-val {
font-size: 0.75rem;
font-weight: 600;
/* FIXED width, tabular digits: `--`, `5%` and `100%` all take the same room,
so dragging (which swaps the text) can never reflow and shrink the waveform
beside it. Was min-width only, which let a wider number nudge the layout. */
width: 3em;
min-width: 3em;
font-variant-numeric: tabular-nums;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* NO ROBOT, NO PROMISES.
Anything that can only act on a real robot — the speaker and mic sliders, the
camera, recording — is hidden while none is connected. What still works stays:
the 3D twin, the skins, and the moves, which play on the simulator. */
[data-robot="absent"] .robot-only { display: none !important; }
/* Skin picker: eight looks on ONE scrollable row, each a real 3D render of the
robot wearing it (skin_thumbs.js). No families, no filters, no search. */
.skin-list {
display: flex;
flex-wrap: nowrap;
gap: 12px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 8px;
/* without this the row's intrinsic width wins and the whole page grows
sideways instead of the strip scrolling */
min-width: 0;
max-width: 100%;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
overscroll-behavior-x: contain; /* don't drag the page back a step */
}
.skin-list::-webkit-scrollbar { height: 8px; }
.skin-list::-webkit-scrollbar-track { background: transparent; }
.skin-list::-webkit-scrollbar-thumb { background: var(--line); border-radius: 999px; }
.skin-list::-webkit-scrollbar-thumb:hover { background: var(--mut); }
.skin-chip {
flex: 0 0 auto; /* never squeezed — it scrolls out of view */
width: 104px;
padding: 0;
overflow: hidden;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--soft);
transition: border-color 0.15s, box-shadow 0.15s;
cursor: pointer;
}
.skin-thumb {
display: block;
width: 100%;
aspect-ratio: 1 / 1; /* a square texture swatch */
background: var(--soft);
}
.skin-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
image-rendering: auto;
}
.skin-name {
display: block;
padding: 5px 6px;
font-size: 0.72rem;
font-weight: 500;
line-height: 1.2;
color: var(--mut);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.skin-chip:hover { border-color: var(--acc); }
.skin-chip.on { border-color: var(--acc); box-shadow: 0 0 0 2px var(--acc); }
.skin-chip.on .skin-name { background: var(--acc); color: var(--bg); font-weight: 600; }
/* The 3D twin renders on a transparent canvas, so the viewport must stop being a
black box: the robot then stands on the card itself. An #id + class beats the
bg-black utility on its own, so no !important is needed. The camera keeps its
black letterbox — a video does need one. */
#cam-wrap.immersive { background: transparent; }
/* the status text was white because the box was black; with no box it has to
follow the theme, or it vanishes on a light one */
#cam-wrap.immersive #cam-msg { color: var(--txt); }
/* the twin's overlay is not a video player: drop the dark gradient, and theme
the lone fullscreen button so it does not vanish on a light background */
#cam-wrap.immersive #cam-bar { background: none; }
#cam-wrap.immersive #cam-bar button { color: var(--txt); }
/* Moves: ONE line that scrolls sideways, like the skin strip. min-width: 0 is not
optional — a nowrap row's intrinsic width otherwise stretches the whole grid
instead of scrolling (the bug the skin strip already taught us). */
.move-row {
display: flex;
flex-wrap: nowrap;
gap: 12px;
padding: 8px 0;
overflow-x: auto;
overflow-y: hidden;
min-width: 0;
max-width: 100%;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
overscroll-behavior-x: contain;
}
.move-row::-webkit-scrollbar { height: 8px; }
.move-row::-webkit-scrollbar-track { background: transparent; }
.move-row::-webkit-scrollbar-thumb { background: var(--line); border-radius: 999px; }
.move-row::-webkit-scrollbar-thumb:hover { background: var(--mut); }
.move-row .move-card {
flex: 0 0 auto; /* a card is never squeezed; it scrolls out of view */
width: 168px;
}
/* A spinner while a thumbnail loads (fetch + first bake). It only exists on a
card marked .thumb-loading — set when a card scrolls into view unbaked, cleared
the moment its poster is drawn — so the ~70 off-screen cards never spin. */
.thumb-spin { display: none; }
.move-card.thumb-loading .thumb-spin {
display: block;
position: absolute;
top: 38%;
left: 50%;
width: 22px;
height: 22px;
margin: -11px 0 0 -11px;
border: 2.5px solid color-mix(in srgb, var(--mut) 30%, transparent);
border-top-color: var(--acc);
border-radius: 50%;
pointer-events: none;
animation: thumb-spin 0.7s linear infinite;
}
@keyframes thumb-spin { to { transform: rotate(360deg); } }
/* Move cards: the 3D thumbnail sits on the card, the label below it. */
.move-card.on { border-color: var(--acc); box-shadow: 0 0 0 2px var(--acc); }
.move-card.on > span { background: var(--acc); color: var(--bg); }
.move-card:hover .move-thumb { transform: scale(1.04); }
.move-thumb { transition: transform 150ms ease; }
.topbar-daemon { display: flex; align-items: center; gap: 6px; color: var(--mut); }
/* `hidden` alone would lose: Tailwind's utility is declared far above this rule,
and at equal specificity the last one wins. Say it here, where we win. */
.topbar-daemon.hidden { display: none; }
/* bare icons, no button chrome — only the color reacts */
.topbar-iconbtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: none;
padding: 0;
color: var(--mut);
cursor: pointer;
font-size: 1.15rem;
transition: color 0.15s;
}
.topbar-iconbtn:hover { color: var(--acc); }
/* .hidden (display:none) and this rule have equal specificity, so whichever is
emitted last wins — and .topbar-iconbtn came last, keeping a hidden button
visible. Make the hidden state explicit and more specific so it always wins. */
.topbar-iconbtn.hidden { display: none; }
/* destructive actions turn red on hover */
.topbar-iconbtn.danger:hover { color: var(--bad); }
/* ================= topbar v2: logo spanning 3 rows + burger mobile ================= */
/* logo and product name: clickable (home), never selectable */
.topbar-home {
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
text-decoration: none;
color: inherit;
cursor: pointer;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
}
.topbar-home:hover { color: var(--acc); }
.topbar-logo:hover { opacity: 0.85; }
.topbar-logo {
width: 108px;
height: 108px;
border-radius: 0.75rem; /* rounded-xl */
flex: 0 0 auto;
}
.topbar-rows {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.topbar-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
min-width: 0;
}
.topbar-spring { flex: 1; }
/* row 1: three zones — left identity, centered HF account, actions far right */
.topbar-row.row-main {
display: flex;
align-items: center;
justify-content: space-between;
}
.row-zone { display: flex; align-items: center; gap: 12px; min-width: 0; }
.row-zone-center { justify-content: center; }
.row-zone-right { justify-content: flex-end; }
/* rows 2 and 3 (sound, network info) hug the right edge, under the actions */
.topbar-row.row-right { justify-content: flex-end; }
/* row 2: account on the left, sound tools pushed right */
.topbar-row.row-split { justify-content: flex-end; }
.topbar-row.row-split .row-zone { margin-right: auto; }
/* HF account: plain text, not a button */
.topbar-user {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.9rem;
font-weight: 600;
color: var(--mut);
cursor: pointer;
white-space: nowrap;
}
.topbar-user.logged { color: var(--txt); }
/* HF avatar replaces the account icon once logged in — topbar and store alike */
.topbar-user img,
.hf-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--line);
}
.topbar-user:hover { color: var(--acc); }
/* One robot state line, last row, left: loader-or-dot · daemon word · soft message */
.topbar-state {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
font-weight: 600;
color: var(--txt);
white-space: nowrap;
}
.topbar-state .dot { flex: none; }
/* the status dot on the last topbar line: green Ready, orange in flight, red gone */
.rs-dot { flex: none; width: 9px; height: 9px; border-radius: 50%; }
.rs-dot.ok { background: var(--ok); box-shadow: 0 0 6px color-mix(in srgb, var(--ok) 70%, transparent); }
.rs-dot.warn { background: var(--warn); box-shadow: 0 0 6px color-mix(in srgb, var(--warn) 70%, transparent); }
.rs-dot.bad { background: var(--bad); box-shadow: 0 0 6px color-mix(in srgb, var(--bad) 70%, transparent); }
.rs-dot.off { background: var(--mut); }
/* No clipping here. This line is the only place the dashboard has to say what is
happening — "Waiting to reconnect…" cut down to "Waiting to reconnec…" is a
message that failed at its one job. It is short, it is nowrap, it gets its
full width. */
.topbar-state .lbl { flex: none; }
/* the soft message trails the state, quieter, behind a separator */
.topbar-state .soft {
flex: none;
font-size: 0.82rem;
font-weight: 500;
color: var(--mut);
}
.topbar-state .soft::before { content: "·"; margin-right: 8px; color: var(--line); }
/* the running app, after the daemon label: "Ready · 🕺 Dance". The accent tints
it so it reads as a distinct fact, not more of the status word. */
.topbar-state .topbar-app { flex: none; font-weight: 600; color: var(--acc); }
.topbar-state .topbar-app::before { content: "·"; margin: 0 8px 0 0; color: var(--line); font-weight: 400; }
/* the loader stands in for the dot: same footprint, no caption */
.topbar-state .reachy-loader { gap: 0; }
.topbar-title {
font-family: 'Space Grotesk', system-ui, sans-serif;
font-weight: 700;
font-size: 1.5rem;
letter-spacing: -0.02em;
line-height: 1.1;
}
/* daemon switch reads as power: green when on */
/* the ON/OFF switch is smaller in the menubar, and follows the theme accent
(var(--acc)) like every .ios — the old green forced --ok, which ignored the palette */
.topbar-daemon .ios { width: 30px; height: 18px; }
.topbar-daemon .ios:after { width: 14px; height: 14px; }
.topbar-daemon .ios.on:after { transform: translateX(12px); }
/* HF relay: just the big status dot (label lives in the tooltip) */
.topbar .hf-relay { background: none; border: none; padding: 0; }
.topbar .hf-relay .dot { width: 14px; height: 14px; border-radius: 50%; }
.topbar .hf-relay .lbl { display: none; }
.topbar-net { color: var(--mut); font-size: 0.8rem; gap: 16px; }
/* Two sound controls, SAME width and SAME round knob:
· MIC — the SVG waveform IS the slider's rail; the knob rides over it.
· SPEAKER — the SVG waveform sits beside a slider with a thin track line.
The waveform is an SVG stroked in the theme accent (follows theme + palette). */
.topbar-sound { display: flex; flex-direction: row; flex-wrap: wrap; gap: 6px 16px; align-items: center; justify-content: flex-end; }
/* each channel keeps its intrinsic size (it can still WRAP to a new line on a
narrow topbar, but it never shrinks a slider mid-drag) */
.topbar-sound .topbar-tool { flex: 0 0 auto; }
/* the MIC's waveform (SVG, theme accent) — its own control rail */
.wave { color: var(--acc); display: block; height: 24px; }
.wave .wave-path { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
/* MIC = the waveform IS the slider; the knob rides on it. SPEAKER = a plain
slider of the SAME width. */
.wave-slot { position: relative; width: 110px; height: 24px; flex: 0 0 110px; }
.wave-slot .wave { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.topbar-tool input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 14px;
margin: 0;
background: transparent;
cursor: pointer;
}
/* the SPEAKER: a classic slider with a thin centred track line. The line is the
theme accent (blue on the default palette), matching the mic's waveform — a
stand-in look while the speaker level itself is still being fixed. */
.topbar-tool > input[type="range"] {
width: 110px;
background: linear-gradient(to bottom, transparent 6px,
color-mix(in srgb, var(--acc) 70%, transparent) 6px,
color-mix(in srgb, var(--acc) 70%, transparent) 8px, transparent 8px);
}
/* the MIC slider: no track of its own — the waveform behind it is the rail */
.wave-range {
position: absolute;
left: 0; right: 0;
top: 50%;
transform: translateY(-50%);
width: 100%;
}
.topbar-tool input[type="range"]::-webkit-slider-runnable-track { height: 14px; background: transparent; }
.topbar-tool input[type="range"]::-moz-range-track { height: 14px; background: transparent; }
/* ONE knob for both: a theme-accent ball with a WHITE ring, like the native one */
.topbar-tool input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: var(--acc);
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
cursor: pointer;
}
.topbar-tool input[type="range"]::-moz-range-thumb {
width: 14px; height: 14px;
border-radius: 50%;
background: var(--acc);
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
cursor: pointer;
}
.topbar-net-item { display: inline-flex; align-items: center; gap: 5px; }
.topbar-net-link { color: inherit; cursor: pointer; transition: color 0.15s; }
.topbar-net-link:hover { color: var(--acc); }
/* The robot's vital signs: one lamp per subsystem, hover for the detail.
THREE states, never two — a subsystem we could not ask about must not look
like one that is switched off, so `unknown` is dimmed, not coloured. */
/* status lamps take the theme accent — only the "Ready" dot stays green */
.topbar-net-item.st-ok { color: var(--acc); }
.topbar-net-item.st-off { color: var(--mut); opacity: 0.75; }
.topbar-net-item.st-unknown { color: var(--mut); opacity: 0.4; }
.topbar-net-item .ic { font-size: 1.05rem; }
.burger { display: none; }
.rows-close { display: none; }
/* pure mobile mode: compact bar + fullscreen fixed menu.
The rows no longer vanish behind the burger: a COMPACT two-line bar stays
(Pierre's spec, driven by the desktop app's narrow embedded panel):
line 1 = the robot status, line 2 = HF account (smaller) + mic + speaker.
Everything else (name, power, action buttons, vitals) waits in the burger. */
@media (max-width: 700px) {
.topbar-logo { width: 42px; height: 42px; border-radius: 0.75rem; }
.burger { display: inline-flex; margin-left: auto; }
.topbar-rows { display: flex; flex-direction: column; gap: 4px; }
.topbar-rows:not(.open) .row-main,
.topbar-rows:not(.open) .topbar-net,
.topbar-rows:not(.open) .navmenu-list { display: none; }
.topbar-rows:not(.open) .row-last { order: -1; }
/* STACKED, not shared (Pierre): the HF login gets its OWN line (the 3rd,
after brand and status), and the mic/speaker sliders get theirs below,
full width — squeezed beside the login they were unusably small. */
.topbar-rows:not(.open) .row-split { display: flex; flex-direction: column; align-items: stretch; gap: 6px; font-size: 0.78rem; }
.topbar-rows:not(.open) .row-split .row-zone { margin-right: 0; }
.topbar-rows:not(.open) .topbar-user img,
.topbar-rows:not(.open) .hf-avatar { width: 18px; height: 18px; }
.topbar-rows:not(.open) .topbar-sound { flex: 1; min-width: 0; display: flex; gap: 8px; flex-wrap: nowrap; justify-content: flex-start; }
.topbar-rows:not(.open) .topbar-tool { flex: 1; min-width: 0; }
.topbar-rows:not(.open) .topbar-tool input[type="range"] { flex: 1; width: 100%; min-width: 0; }
.topbar-rows:not(.open) .topbar-tool-val { font-size: 0.7rem; }
/* the ON/OFF switch + wake button, moved into the brand row (topbar.powerRow) */
.hdr-power { margin-left: auto; display: flex; align-items: center; gap: 2px; }
.hdr-power + .burger { margin-left: 8px; }
body.menu-open { overflow: hidden; }
/* no backdrop-filter while open, else it becomes the fixed panel's containing block */
body.menu-open .topbar {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: transparent;
border-bottom: 0;
}
.topbar-rows.open {
display: flex;
position: fixed;
inset: 0;
z-index: 60;
background: var(--bg);
padding: 74px 22px 26px;
gap: 30px;
overflow-y: auto;
}
.topbar-rows.open .topbar-row {
border-bottom: 1px solid var(--line);
padding-bottom: 22px;
}
.topbar-rows.open .rows-close {
display: inline-flex;
position: absolute;
top: 18px;
right: 18px;
}
.topbar-rows.open .row-main { display: flex; flex-wrap: wrap; gap: 14px; }
}
/* the 4th row topbar.js builds on a phone: the sliders finally get some room */
.row-sound { display: flex; gap: 18px; }
.row-sound .topbar-tool { flex: 1; min-width: 0; }
.row-sound .topbar-tool input[type="range"] { flex: 1; width: 100%; min-width: 0; }
/* the controller asked for a pose the robot cannot hold: say so, quietly */
.cp.cp-unreachable { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--warn) 60%, transparent); }
/* ================= Reachy loader ================= */
/* Replaces the old cartoon SVG placeholders. Inline SVG, so it takes its
colours from the tokens and follows every theme and palette. */
.rl-svg { overflow: visible; }
.rl-track,
.rl-arc {
fill: none;
stroke-width: 5;
stroke-linecap: round;
}
.rl-track { stroke: var(--line); }
.rl-arc {
stroke: var(--acc);
/* 2πr ≈ 314 — a quarter drawn, the rest gapped */
stroke-dasharray: 80 234;
transform-origin: 60px 64px;
animation: rl-spin 1.1s linear infinite;
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--acc) 45%, transparent));
}
.rl-head {
fill: var(--card);
stroke: var(--txt);
stroke-width: 3;
}
.rl-body {
fill: var(--card);
stroke: var(--txt);
stroke-width: 3;
stroke-linejoin: round;
}
.rl-eyes circle { fill: var(--txt); }
.rl-ant path {
stroke: var(--txt);
stroke-width: 3;
stroke-linecap: round;
fill: none;
}
.rl-ant circle { fill: var(--acc); }
.rl-bot { animation: rl-bob 1.8s ease-in-out infinite; }
.rl-ant-l { transform-origin: 45px 46px; animation: rl-wiggle-l 1.8s ease-in-out infinite; }
.rl-ant-r { transform-origin: 75px 46px; animation: rl-wiggle-r 1.8s ease-in-out infinite; }
.rl-eyes { transform-origin: 60px 62px; animation: rl-blink 3.4s ease-in-out infinite; }
@keyframes rl-spin { to { transform: rotate(360deg); } }
@keyframes rl-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
@keyframes rl-wiggle-l { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(-9deg); } }
@keyframes rl-wiggle-r { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(9deg); } }
@keyframes rl-blink { 0%, 92%, 100% { transform: scaleY(1); } 96% { transform: scaleY(0.1); } }
/* empty states reuse the same robot, standing still */
.reachy-loader.is-still .rl-arc { display: none; }
.reachy-loader.is-still .rl-track { opacity: 0.5; }
.reachy-loader.is-still .rl-bot,
.reachy-loader.is-still .rl-ant,
.reachy-loader.is-still .rl-eyes { animation: none; }
.reachy-loader.is-still { opacity: 0.75; }
@media (prefers-reduced-motion: reduce) {
.rl-bot, .rl-ant, .rl-eyes { animation: none; }
.rl-arc { animation-duration: 2.4s; }
}
/* last topbar row: message centred, network info to the right */
/* two zones now: the soft status on the left, the network info on the right */
.topbar-row.row-last {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
@media (max-width: 640px) {
.topbar-row.row-last { grid-template-columns: 1fr; justify-items: center; }
}
/* the loader's eyes are the real Reachy face: two unequal eyes and a bridge */
.rl-eyes rect { fill: var(--txt); }
/* Click the picture and the head looks there — the pointer says so: an eye over
the image, the usual pointer over the controls that sit on top of it. Only
while a camera view is up (on the 3D twin a drag is an orbit, not an aim). */
#cam-wrap.cam-aim { cursor: url('/static/assets/cursor-eye.svg') 14 14, crosshair; }
#cam-wrap.cam-aim button,
#cam-wrap.cam-aim select,
#cam-wrap.cam-aim a { cursor: pointer; }