Download style.css from pollen-robotics/marionette-experimental: direct link, hf CLI and curl.
- Browser
- Download file 61.9 kB
-
https://huggingface.co/spaces/pollen-robotics/marionette-experimental/resolve/main/style.css
- Command line
-
hf download hf://spaces/pollen-robotics/marionette-experimental/style.css
-
curl -L -o style.css https://huggingface.co/spaces/pollen-robotics/marionette-experimental/resolve/main/style.css
61.9 kB
| /* Marionette β mobile-first palette */ | |
| :root { | |
| /* βββ Design tokens - mirror reachy_mini_marionette_v2/src/theme.ts ββ | |
| The shell (Emotions / Telepresence / mobile) uses an MUI theme; | |
| we don't pull MUI in here, we just copy its *light* palette so | |
| this app stays visually consistent. Values map 1:1 to the theme: | |
| ACCENT #FF9500 β --primary / --ring | |
| shape.borderRadius 12 β --radius | |
| background.default #fafafa β --background | |
| background.paper #ffffff β --surface / --card | |
| text.primary #111111 β --foreground | |
| text.secondary rgba(0,0,0,.65) β --muted-foreground | |
| divider rgba(0,0,0,.08) β --border | |
| Dark mode lives in the `[data-theme="dark"]` block below; the host | |
| decides which palette is active (see index.html bootstrap + the | |
| applyTheme()/onThemeChange wiring in main.js). Component styles | |
| read these tokens only, so they flip for free. */ | |
| color-scheme: light; | |
| --radius: 12px; /* theme shape.borderRadius */ | |
| --radius-sm: calc(var(--radius) - 4px); | |
| --radius-md: calc(var(--radius) - 2px); | |
| --radius-lg: var(--radius); | |
| --radius-xl: calc(var(--radius) + 4px); | |
| --radius-2xl: calc(var(--radius) + 8px); | |
| --background: #fafafa; /* theme background.default */ | |
| --foreground: #111111; /* theme text.primary */ | |
| --surface: #ffffff; /* theme background.paper */ | |
| --surface-2: #f2f2f2; | |
| --card: #ffffff; /* theme background.paper */ | |
| --primary: #FF9500; /* theme ACCENT (palette.primary.main) */ | |
| --primary-foreground: #ffffff; | |
| --muted: #f2f2f2; | |
| --muted-foreground: rgba(0, 0, 0, 0.65); /* theme text.secondary */ | |
| --accent: rgba(255, 149, 0, 0.10); /* tinted accent surface */ | |
| --destructive: oklch(0.62 0.22 27); | |
| --border: rgba(0, 0, 0, 0.08); /* theme divider */ | |
| --input: rgba(0, 0, 0, 0.12); | |
| --overlay-hover: rgba(0, 0, 0, 0.06); /* subtle hover/press tint */ | |
| --ring: #FF9500; /* theme ACCENT */ | |
| --mint: oklch(0.84 0.1 165); | |
| --coral: oklch(0.78 0.13 30); | |
| --primary-soft: #ffb84d; /* lighter accent - gradient end (was --violet) */ | |
| --sky: oklch(0.82 0.08 225); | |
| --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.04), | |
| 0 8px 24px -8px rgba(0, 0, 0, 0.08); | |
| --shadow-elev: 0 2px 4px rgba(0, 0, 0, 0.04), | |
| 0 16px 40px -12px rgba(0, 0, 0, 0.12); | |
| --shadow-focus: 0 0 0 4px rgba(255, 149, 0, 0.15); /* theme ACCENT @ 15% */ | |
| --font-display: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif; | |
| --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", ui-sans-serif, system-ui, sans-serif; /* theme FONT_FAMILY */ | |
| } | |
| /* βββ Dark palette ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| Activated by `data-theme="dark"` on <html>, set by the host: | |
| index.html's bootstrap paints a best guess pre-connect, then main.js | |
| applies the host's authoritative `handle.theme` and follows | |
| `onThemeChange` live. Only color tokens are overridden here β radii, | |
| fonts and the geometry above are shared. The accent stays the same | |
| Reachy orange, nudged a touch brighter so it carries on a dark | |
| surface. */ | |
| [data-theme="dark"] { | |
| color-scheme: dark; | |
| --background: #0e0e10; | |
| --foreground: #f5f5f7; | |
| --surface: #1b1b1e; | |
| --surface-2: #2a2a2e; | |
| --card: #1b1b1e; | |
| --primary: #ff9f0a; /* accent, brightened for dark */ | |
| --primary-foreground: #1a1205; /* near-black reads better on orange */ | |
| --muted: #2a2a2e; | |
| --muted-foreground: rgba(255, 255, 255, 0.62); | |
| --accent: rgba(255, 159, 10, 0.16); /* tinted accent surface */ | |
| --border: rgba(255, 255, 255, 0.12); | |
| --input: rgba(255, 255, 255, 0.18); | |
| --ring: #ff9f0a; | |
| --overlay-hover: rgba(255, 255, 255, 0.08); | |
| --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), | |
| 0 8px 24px -8px rgba(0, 0, 0, 0.6); | |
| --shadow-elev: 0 2px 4px rgba(0, 0, 0, 0.45), | |
| 0 16px 40px -12px rgba(0, 0, 0, 0.7); | |
| --shadow-focus: 0 0 0 4px rgba(255, 159, 10, 0.22); | |
| } | |
| /* Dark-mode touch-ups. The tokens above re-skin every token-driven | |
| surface for free; these few selectors exist only because their | |
| backgrounds are fixed light gradients hand-tuned for a light backdrop, | |
| which would leave the token-driven text on top unreadable on dark. */ | |
| [data-theme="dark"] .sync-banner { | |
| background: linear-gradient(135deg, rgba(255, 196, 0, 0.10), rgba(255, 149, 0, 0.06)); | |
| border-color: rgba(255, 196, 0, 0.22); | |
| } | |
| [data-theme="dark"] .sync-banner .ttl { color: oklch(0.9 0.07 85); } | |
| [data-theme="dark"] .sync-banner-icon { | |
| background: rgba(255, 196, 0, 0.12); | |
| border-color: rgba(255, 196, 0, 0.24); | |
| } | |
| [data-theme="dark"] .row.new { | |
| background: linear-gradient(135deg, rgba(255, 149, 0, 0.08), rgba(255, 149, 0, 0.04)); | |
| } | |
| [data-theme="dark"] .row .emoji, | |
| [data-theme="dark"] .folder-head .emoji { | |
| background: linear-gradient(135deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.03)); | |
| } | |
| * { box-sizing: border-box; border-color: var(--border); } | |
| html, body { | |
| margin: 0; | |
| background: var(--background); | |
| color: var(--foreground); | |
| font-family: var(--font-sans); | |
| -webkit-font-smoothing: antialiased; | |
| text-rendering: optimizeLegibility; | |
| } | |
| body { | |
| min-height: 100dvh; | |
| } | |
| h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.02em; margin: 0; } | |
| button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; } | |
| input { font-family: inherit; } | |
| /* βββ Layout shell (mobile-first) βββββββββββββββββββββββ */ | |
| .app-shell { | |
| max-width: 28rem; | |
| margin: 0 auto; | |
| min-height: 100dvh; | |
| position: relative; | |
| padding-bottom: env(safe-area-inset-bottom); | |
| } | |
| .topbar { | |
| position: sticky; top: 0; z-index: 50; | |
| display: flex; align-items: center; justify-content: space-between; gap: 8px; | |
| padding: max(12px, env(safe-area-inset-top)) 12px 12px; | |
| background: color-mix(in oklab, var(--background) 85%, transparent); | |
| backdrop-filter: blur(10px); | |
| } | |
| /* Left slot reserves the back button's footprint so the cog stays pinned | |
| to the right edge even on roots where Back is hidden. Grows to take the | |
| leftover width so the page title can ellipsis instead of pushing the | |
| right-hand actions off-screen. */ | |
| .topbar-left { | |
| display: flex; align-items: center; gap: 2px; | |
| flex: 1 1 auto; min-width: 0; min-height: 40px; | |
| } | |
| .topbar-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } | |
| /* Title + optional subtitle, stacked. Takes the leftover width so both | |
| lines ellipsis instead of pushing the right-hand actions off-screen. */ | |
| .topbar-titles { | |
| display: flex; flex-direction: column; justify-content: center; | |
| /* Grow to claim the leftover width so the title uses every available | |
| pixel before ellipsing, and shrink freely (min-width:0) when the | |
| back button + EXPERIMENTAL chip + right-hand actions crowd it. */ | |
| flex: 1 1 auto; min-width: 0; gap: 1px; | |
| } | |
| /* Page title (Material-style top app bar). Sits next to the Back chevron, | |
| replacing the explicit "Back" label when a view supplies a title. */ | |
| .topbar-title { | |
| font-family: var(--font-display, inherit); | |
| font-size: 20px; font-weight: 800; line-height: 1.15; | |
| color: var(--foreground); | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| min-width: 0; | |
| } | |
| /* Muted one-line description under the title. */ | |
| .topbar-subtitle { | |
| font-size: 13px; font-weight: 500; line-height: 1.2; | |
| color: var(--muted-foreground); | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| min-width: 0; | |
| } | |
| /* Roots have no Back chevron, so give their lone title block a little | |
| breathing room from the safe-area edge. */ | |
| .topbar-left > .topbar-titles:first-child { padding-left: 8px; } | |
| /* "EXPERIMENTAL" build chip. flex:none so it never shrinks, but it stays | |
| compact ("EXP") on narrow phones where the full word would starve the | |
| page title; the full label appears from 480px up. */ | |
| .topbar-exp { | |
| flex: none; margin-left: 8px; | |
| padding: 2px 7px; border-radius: 999px; | |
| font-size: 9px; font-weight: 800; letter-spacing: 0.04em; | |
| text-transform: uppercase; | |
| background: var(--destructive); color: var(--primary-foreground); | |
| } | |
| .topbar-exp-full { display: none; } | |
| .topbar-exp-short { display: inline; } | |
| @media (min-width: 480px) { | |
| .topbar-exp-full { display: inline; } | |
| .topbar-exp-short { display: none; } | |
| } | |
| /* Contextual "β―" actions menu β primary-tinted, borderless icon button, | |
| matching the in-app menu trigger convention. */ | |
| .topbar-back, .topbar-menu, .topbar-settings { color: var(--primary); } | |
| .topbar-back:hover, .topbar-menu:hover, .topbar-settings:hover { background: var(--accent); } | |
| .icon-btn { | |
| width: 40px; height: 40px; border-radius: 999px; | |
| display: inline-grid; place-items: center; | |
| transition: background-color .15s ease, transform .15s ease; | |
| } | |
| .icon-btn:hover { background: var(--surface-2); } | |
| .icon-btn:active { transform: scale(0.96); } | |
| /* Labeled variant: outlined-primary icon + text pill (topbar Back / Settings). */ | |
| .icon-btn--labeled { | |
| width: auto; height: 40px; | |
| display: inline-flex; align-items: center; gap: 7px; | |
| padding: 0 12px; border-radius: 12px; | |
| font-size: 14px; font-weight: 600; | |
| color: var(--primary); | |
| background: transparent; | |
| } | |
| .icon-btn--labeled:hover { background: var(--accent); } | |
| .icon-btn-label { line-height: 1; } | |
| /* βββ Step container ββββββββββββββββββββββββββββββββββββ */ | |
| .step { | |
| padding: 8px 20px 140px; | |
| } | |
| .step h2 { | |
| font-size: 28px; font-weight: 800; line-height: 1.1; margin-top: 14px; | |
| } | |
| .step p.sub { margin-top: 6px; color: var(--muted-foreground); font-size: 15px; } | |
| .card { | |
| background: transparent; | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-2xl); | |
| } | |
| /* Announcement banner shown at the top of HomeView for the | |
| 2026-05-25 sync architecture change. Dismissible. */ | |
| .sync-banner { | |
| position: relative; | |
| display: flex; | |
| gap: 12px; | |
| margin: 16px 20px 4px; | |
| padding: 14px 42px 14px 14px; | |
| background: linear-gradient(135deg, oklch(0.97 0.035 90), oklch(0.98 0.025 60)); | |
| border: 1px solid oklch(0.88 0.07 82); | |
| border-radius: var(--radius-xl); | |
| box-shadow: var(--shadow-card); | |
| } | |
| .sync-banner-icon { | |
| flex-shrink: 0; | |
| width: 34px; height: 34px; | |
| display: grid; place-items: center; | |
| font-size: 17px; | |
| border-radius: 10px; | |
| background: oklch(0.92 0.08 85 / 0.6); | |
| border: 1px solid oklch(0.85 0.09 82 / 0.5); | |
| } | |
| .sync-banner-content { min-width: 0; } | |
| .sync-banner .ttl { | |
| font-weight: 700; | |
| font-size: 13.5px; | |
| line-height: 1.3; | |
| margin-bottom: 3px; | |
| color: oklch(0.38 0.06 70); | |
| } | |
| .sync-banner .body { | |
| font-size: 12.5px; | |
| line-height: 1.45; | |
| color: var(--muted-foreground); | |
| } | |
| .sync-banner .dismiss { | |
| position: absolute; | |
| top: 8px; right: 8px; | |
| width: 26px; height: 26px; | |
| display: grid; place-items: center; | |
| border: none; background: transparent; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| color: var(--muted-foreground); | |
| font-size: 17px; line-height: 1; | |
| transition: background-color .15s ease, color .15s ease; | |
| } | |
| .sync-banner .dismiss:hover { background: var(--overlay-hover); color: var(--foreground); } | |
| .list { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; } | |
| .row { | |
| display: flex; align-items: center; gap: 12px; | |
| padding: 14px; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-xl); | |
| box-shadow: var(--shadow-card); | |
| transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease; | |
| text-align: left; | |
| width: 100%; | |
| } | |
| .row { cursor: pointer; } | |
| .row:active { transform: scale(0.99); } | |
| .row.selected { border-color: var(--primary); box-shadow: var(--shadow-focus); } | |
| /* "Default" pill on the default folder's title line. */ | |
| .row-badge { | |
| display: inline-block; margin-left: 8px; | |
| padding: 1px 7px; border-radius: 999px; | |
| font-size: 10px; font-weight: 700; letter-spacing: .02em; | |
| text-transform: uppercase; vertical-align: middle; | |
| color: var(--muted-foreground); | |
| background: var(--surface-2); border: 1px solid var(--border); | |
| } | |
| /* β― more-actions button on a Mine folder row. */ | |
| .row-menu { | |
| flex-shrink: 0; width: 32px; height: 32px; border-radius: 10px; | |
| display: grid; place-items: center; | |
| font-size: 20px; line-height: 1; | |
| color: var(--primary); | |
| background: transparent; | |
| transition: background-color .15s ease, color .15s ease; | |
| } | |
| .row-menu:hover { background: var(--accent); color: var(--primary); } | |
| .row .emoji { | |
| width: 44px; height: 44px; border-radius: 12px; | |
| display: grid; place-items: center; font-size: 22px; | |
| background: linear-gradient(135deg, oklch(0.95 0.02 260), oklch(0.97 0.02 285)); | |
| flex-shrink: 0; | |
| } | |
| .row .grow { flex: 1; min-width: 0; } | |
| .row .title { font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .row .meta { color: var(--muted-foreground); font-size: 12px; margin-top: 2px; } | |
| .row .chev { color: var(--muted-foreground); flex-shrink: 0; } | |
| /* Anchored popover menu (folder β―). Backdrop catches outside clicks. */ | |
| .menu-backdrop { position: fixed; inset: 0; z-index: 90; } | |
| .menu-popover { | |
| position: fixed; z-index: 91; min-width: 176px; padding: 6px; | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: 14px; box-shadow: var(--shadow-card); | |
| animation: menu-pop .12s ease both; | |
| } | |
| @keyframes menu-pop { | |
| from { opacity: 0; transform: translateY(-4px) scale(0.98); } | |
| to { opacity: 1; transform: translateY(0) scale(1); } | |
| } | |
| .menu-item { | |
| display: flex; align-items: center; gap: 10px; width: 100%; | |
| padding: 10px 12px; border-radius: 10px; | |
| font-size: 14px; font-weight: 600; color: var(--foreground); | |
| text-align: left; transition: background-color .12s ease; | |
| } | |
| .menu-item:hover { background: var(--accent); } | |
| .menu-item.danger { color: var(--destructive); } | |
| .menu-ico { | |
| width: 18px; height: 18px; flex-shrink: 0; | |
| display: grid; place-items: center; | |
| } | |
| .menu-ico svg { width: 18px; height: 18px; } | |
| .row.new { | |
| background: linear-gradient(135deg, oklch(0.97 0.02 270), oklch(0.96 0.04 290)); | |
| border: 1px dashed rgba(255, 149, 0, 0.4); | |
| } | |
| .row.new .emoji { | |
| background: linear-gradient(135deg, var(--primary), var(--primary-soft)); | |
| color: white; | |
| } | |
| .segmented { | |
| display: inline-flex; padding: 4px; | |
| background: var(--surface-2); border-radius: 999px; gap: 4px; | |
| border: 1px solid var(--border); | |
| } | |
| .segmented button { | |
| padding: 8px 14px; border-radius: 999px; font-size: 13px; font-weight: 600; | |
| color: var(--muted-foreground); | |
| } | |
| .segmented button.active { | |
| background: var(--surface); color: var(--foreground); box-shadow: var(--shadow-card); | |
| } | |
| /* βββ Forms βββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .field { display: flex; flex-direction: column; gap: 6px; margin-top: 14px; } | |
| .field label { font-size: 13px; color: var(--muted-foreground); font-weight: 600; } | |
| .input, .textarea { | |
| width: 100%; padding: 15px 16px; | |
| background: transparent; color: var(--foreground); | |
| border: 1px solid var(--border); border-radius: 14px; | |
| font-size: 16px; outline: none; transition: box-shadow .15s ease, border-color .15s ease; | |
| } | |
| .input:focus { border-color: var(--primary); box-shadow: var(--shadow-focus); } | |
| /* βββ Select (themed dropdown) ββββββββββββββββββββββββββ */ | |
| /* Trigger mirrors .input metrics; the listbox opens in an anchored | |
| popover (see .select-popover) sized to the trigger width. */ | |
| .select { | |
| width: 100%; display: flex; align-items: center; gap: 10px; | |
| padding: 15px 16px; text-align: left; | |
| background: transparent; color: var(--foreground); | |
| border: 1px solid var(--border); border-radius: 14px; | |
| font-size: 16px; outline: none; cursor: pointer; | |
| transition: box-shadow .15s ease, border-color .15s ease; | |
| } | |
| .select:hover { border-color: color-mix(in oklab, var(--primary) 45%, var(--border)); } | |
| .select[aria-expanded="true"] { border-color: var(--primary); box-shadow: var(--shadow-focus); } | |
| .select-label { | |
| flex: 1; min-width: 0; | |
| overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| } | |
| .select-label.is-placeholder { color: var(--muted-foreground); } | |
| .select-chevron { flex-shrink: 0; color: var(--muted-foreground); transition: transform .15s ease; } | |
| .select[aria-expanded="true"] .select-chevron { transform: rotate(180deg); color: var(--primary); } | |
| .select-popover { | |
| position: fixed; z-index: 91; max-height: 280px; overflow-y: auto; | |
| padding: 6px; | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: 14px; box-shadow: var(--shadow-card); | |
| animation: menu-pop .12s ease both; | |
| } | |
| .select-option { | |
| display: flex; align-items: center; gap: 10px; width: 100%; | |
| padding: 11px 12px; border-radius: 10px; | |
| font-size: 15px; font-weight: 600; color: var(--foreground); | |
| text-align: left; transition: background-color .12s ease; | |
| } | |
| .select-option:hover { background: var(--accent); } | |
| .select-option.selected { color: var(--primary); } | |
| .select-option-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .select-check { width: 16px; flex-shrink: 0; color: var(--primary); display: inline-grid; place-items: center; } | |
| /* βββ Buttons βββββββββββββββββββββββββββββββββββββββββββ */ | |
| .btn { | |
| display: inline-flex; align-items: center; justify-content: center; gap: 8px; | |
| padding: 14px 18px; border-radius: 14px; font-weight: 700; font-size: 15px; | |
| /* Fixed line-height so a button's height doesn't change whether or not | |
| its label contains an emoji (emoji glyphs have a taller line box). */ | |
| line-height: 20px; | |
| background: var(--surface); color: var(--foreground); | |
| border: 1px solid var(--border); box-shadow: var(--shadow-card); | |
| transition: transform .15s ease, box-shadow .15s ease, background-color .15s ease; | |
| } | |
| .btn:active { transform: scale(0.98); } | |
| .btn.primary { | |
| background: transparent; | |
| color: var(--primary); | |
| border: 1px solid var(--primary); | |
| box-shadow: none; | |
| } | |
| .btn.primary:hover { background: var(--accent); } | |
| .btn.danger { | |
| background: transparent; | |
| color: var(--destructive); | |
| border: 1px solid var(--destructive); | |
| box-shadow: none; | |
| } | |
| .btn.danger:hover { background: color-mix(in oklab, var(--destructive) 12%, transparent); } | |
| .btn.ghost { background: transparent; box-shadow: none; } | |
| .btn[disabled] { opacity: 0.5; pointer-events: none; } | |
| .btn-block { width: 100%; } | |
| /* βββ Sticky footer βββββββββββββββββββββββββββββββββββββ */ | |
| .footer { | |
| position: sticky; bottom: 0; left: 0; right: 0; | |
| padding: 12px 20px max(16px, env(safe-area-inset-bottom)); | |
| background: color-mix(in oklab, var(--background) 85%, transparent); | |
| backdrop-filter: blur(12px); | |
| border-top: 1px solid var(--border); | |
| } | |
| /* βββ Step 2: sound tiles βββββββββββββββββββββββββββββββ */ | |
| /* Sound type: 3 joined, outlined buttons (segmented), sharing borders. */ | |
| .tiles { display: flex; margin-top: 0; } | |
| .tile { | |
| flex: 1; position: relative; | |
| display: flex; flex-direction: column; align-items: center; gap: 8px; | |
| padding: 14px 8px; | |
| background: transparent; color: var(--foreground); | |
| border: 1px solid var(--border); box-shadow: none; | |
| text-align: center; | |
| transition: border-color .15s ease, color .15s ease, background-color .15s ease; | |
| } | |
| .tile:not(:first-child) { margin-left: -1px; } | |
| .tile:first-child { border-radius: var(--radius-xl) 0 0 var(--radius-xl); } | |
| .tile:last-child { border-radius: 0 var(--radius-xl) var(--radius-xl) 0; } | |
| .tile.active { | |
| border-color: var(--primary); color: var(--primary); | |
| background: var(--accent); z-index: 1; | |
| } | |
| .tile .ico { | |
| width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; | |
| background: linear-gradient(135deg, var(--accent), var(--surface-2)); | |
| font-size: 18px; | |
| } | |
| .tile .ttl { font-weight: 700; font-size: 13px; } | |
| .tile .desc { font-size: 11px; line-height: 1.25; color: var(--muted-foreground); } | |
| /* Loaded-file block for the upload picker: outlined like the buttons, | |
| left-aligned, showing the file name + a "Replace" affordance. */ | |
| .file-picker { | |
| display: flex; align-items: center; gap: 12px; | |
| width: 100%; padding: 12px 16px; margin-top: 14px; | |
| background: transparent; color: var(--foreground); | |
| border: 1px solid var(--primary); border-radius: 14px; | |
| text-align: left; | |
| } | |
| .file-picker .fp-ico { font-size: 18px; flex-shrink: 0; } | |
| .file-picker .fp-body { flex: 1; min-width: 0; } | |
| .file-picker .fp-name { | |
| display: block; font-weight: 700; font-size: 14px; | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| } | |
| .file-picker .fp-meta { | |
| display: block; font-size: 12px; color: var(--muted-foreground); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .file-picker .fp-action { | |
| flex-shrink: 0; font-size: 13px; font-weight: 700; color: var(--primary); | |
| } | |
| /* Themed range slider β default browser sliders render in accent blue, | |
| which clashes with the Reachy-orange theme. Track in --surface-2, | |
| thumb in the primary gradient. */ | |
| /* Outlined slider, matching the app's outlined-primary language: a thin | |
| bordered track (more visible than a flat 4px line) and a smaller knob with | |
| a transparent-ish fill ringed by a 2px primary border, instead of the old | |
| chunky gradient disc. Track is drawn via the runnable-track pseudo so the | |
| thumb can be centered precisely across browsers. */ | |
| .slider { | |
| -webkit-appearance: none; appearance: none; | |
| width: 100%; height: 20px; | |
| background: transparent; | |
| outline: none; cursor: pointer; | |
| } | |
| .slider::-webkit-slider-runnable-track { | |
| height: 6px; border-radius: 999px; | |
| background: var(--surface-2); | |
| border: 1px solid var(--border); | |
| } | |
| .slider::-moz-range-track { | |
| height: 6px; border-radius: 999px; | |
| background: var(--surface-2); | |
| border: 1px solid var(--border); | |
| } | |
| .slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; appearance: none; | |
| width: 16px; height: 16px; border-radius: 50%; | |
| background: var(--background); | |
| border: 2px solid var(--primary); | |
| box-shadow: 0 1px 2px oklch(0 0 0 / 0.14); | |
| cursor: pointer; | |
| transition: background-color .12s ease, box-shadow .12s ease; | |
| /* Center the 16px(+2px border)=20px knob on the 6px(+1px border)=8px track. */ | |
| margin-top: -6px; | |
| } | |
| .slider::-moz-range-thumb { | |
| width: 16px; height: 16px; border-radius: 50%; | |
| background: var(--background); | |
| border: 2px solid var(--primary); | |
| box-shadow: 0 1px 2px oklch(0 0 0 / 0.14); | |
| cursor: pointer; | |
| } | |
| .slider:hover::-webkit-slider-thumb { background: var(--accent); } | |
| .slider:hover::-moz-range-thumb { background: var(--accent); } | |
| .slider:active::-webkit-slider-thumb { | |
| background: var(--accent); | |
| box-shadow: 0 0 0 4px var(--accent); | |
| } | |
| .slider:focus-visible::-webkit-slider-thumb { | |
| box-shadow: 0 0 0 4px var(--accent); | |
| } | |
| /* Mic sub-selector (inside "Record now") β two joined, outlined buttons | |
| sharing borders, full width. Same treatment as the sound-type buttons. */ | |
| /* Joined, outlined segmented buttons. Shared by the record-now mic | |
| picker (.mic-sub) and the generic tab component (.tabs, see | |
| views/components/tabs.js) so Mine/Community + the community sort match | |
| the "This device / Robot mic" look. */ | |
| .mic-sub { display: flex; margin-top: 12px; } | |
| .tabs { display: flex; } | |
| .mic-sub .opt, | |
| .tabs .opt { | |
| /* Match a normal .btn's box metrics so these sit at the same height. */ | |
| flex: 1; position: relative; padding: 14px 18px; | |
| background: transparent; color: var(--foreground); | |
| border: 1px solid var(--border); | |
| text-align: center; font-size: 15px; font-weight: 600; | |
| transition: border-color .15s, color .15s, background-color .15s; | |
| } | |
| .mic-sub .opt:not(:first-child), | |
| .tabs .opt:not(:first-child) { margin-left: -1px; } | |
| .mic-sub .opt:first-child, | |
| .tabs .opt:first-child { border-radius: 14px 0 0 14px; } | |
| .mic-sub .opt:last-child, | |
| .tabs .opt:last-child { border-radius: 0 14px 14px 0; } | |
| .mic-sub .opt.active, | |
| .tabs .opt.active { | |
| border-color: var(--primary); color: var(--primary); | |
| background: var(--accent); z-index: 1; | |
| } | |
| .mic-sub .opt[disabled], | |
| .tabs .opt[disabled] { opacity: 0.45; pointer-events: none; } | |
| /* Live mic-input meter, embedded inside the Stop button while recording. | |
| Bars use the button's text color (white on the danger button), kept | |
| faint and short so it stays discreet next to the label. */ | |
| .mic-meter { | |
| display: inline-flex; align-items: center; gap: 2px; | |
| height: 14px; | |
| } | |
| .mic-meter span { | |
| width: 2px; height: 2px; border-radius: 1px; | |
| background: currentColor; opacity: 0.7; | |
| transition: height .07s linear; | |
| } | |
| /* βββ Step 3: recording stage βββββββββββββββββββββββββββ */ | |
| .stage { | |
| position: relative; | |
| margin-top: 18px; | |
| /* Fixed height β the card NEVER resizes between idle / countdown / | |
| recording. The illustration is flex-centered; everything else (badge, | |
| countdown, progress readout) is an absolute overlay so nothing reflows. */ | |
| height: 260px; | |
| display: flex; align-items: center; justify-content: center; | |
| border-radius: var(--radius-xl); | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| padding: 20px; | |
| text-align: center; | |
| box-shadow: var(--shadow-card); | |
| overflow: hidden; | |
| transition: border-color .25s ease, box-shadow .25s ease; | |
| } | |
| /* Active capture: warm the frame so the recording state reads at a | |
| glance even if the REC badge scrolls off. */ | |
| .stage.rec { | |
| border-color: color-mix(in oklch, var(--destructive) 45%, var(--border)); | |
| box-shadow: var(--shadow-card), 0 0 0 3px oklch(0.62 0.22 27 / 0.12); | |
| } | |
| /* Bottom overlay holding the live status + progress readout. Absolute so it | |
| floats over the card's lower edge and never affects the card height. */ | |
| .stage-readout { | |
| position: absolute; left: 0; right: 0; bottom: 0; | |
| padding: 0 16px 12px; | |
| } | |
| /* Hero: the Reachy-head-in-human-hands illustration. Fixed aspect box | |
| so the layout doesn't jump while the asset loads, and so the | |
| countdown overlay + REC badge have a stable canvas to sit on. */ | |
| .stage-hero { | |
| position: relative; | |
| width: 100%; | |
| aspect-ratio: 16 / 10; | |
| max-height: 190px; | |
| margin: 2px auto 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: transform .25s ease; | |
| } | |
| .stage-illu { | |
| height: 100%; | |
| max-width: 100%; | |
| display: grid; | |
| place-items: center; | |
| filter: drop-shadow(0 12px 26px rgba(0, 0, 0, 0.18)); | |
| transition: filter .25s ease, opacity .25s ease, transform .25s ease; | |
| } | |
| /* Inlined line-art: fit by height, keep strokes crisp + theme-aware | |
| regardless of the large viewBox downscale. */ | |
| .stage-illu > svg { | |
| display: block; | |
| height: 100%; | |
| width: auto; | |
| max-width: 100%; | |
| } | |
| /* While counting down, push the illustration back so the big 3Β·2Β·1 | |
| number owns the frame. */ | |
| .stage-hero.counting .stage-illu { | |
| filter: drop-shadow(0 12px 26px rgba(0, 0, 0, 0.18)) blur(3px); | |
| opacity: 0.45; | |
| transform: scale(0.97); | |
| } | |
| /* No asset yet (or load failure): an ambient glow placeholder so the | |
| hero box still reads as intentional rather than empty/broken. */ | |
| .stage-hero--empty::after { | |
| content: ""; | |
| position: absolute; left: 50%; top: 50%; | |
| width: 150px; height: 150px; | |
| transform: translate(-50%, -50%); | |
| border-radius: 50%; | |
| background: radial-gradient(circle at 50% 42%, var(--primary-soft), transparent 70%); | |
| opacity: 0.45; | |
| } | |
| .countdown { | |
| position: absolute; inset: 0; | |
| display: flex; align-items: center; justify-content: center; | |
| font-family: var(--font-display); font-size: 110px; font-weight: 800; line-height: 1; | |
| background: linear-gradient(135deg, var(--primary), var(--primary-soft)); | |
| -webkit-background-clip: text; background-clip: text; color: transparent; | |
| pointer-events: none; | |
| } | |
| .rec-badge { | |
| position: absolute; top: 14px; right: 14px; z-index: 2; | |
| display: inline-flex; align-items: center; gap: 8px; | |
| padding: 6px 12px; border-radius: 999px; | |
| background: oklch(0.62 0.22 27 / 0.12); color: var(--destructive); | |
| font-weight: 700; font-size: 12px; | |
| } | |
| .rec-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--destructive); animation: pulse-ring 1.6s ease-out infinite; } | |
| /* Live elapsed-time readout under the record-now Stop button. */ | |
| .rec-elapsed { | |
| display: flex; align-items: center; justify-content: center; gap: 8px; | |
| margin-top: 10px; | |
| color: var(--destructive); font-weight: 700; font-size: 15px; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .rec-elapsed[hidden] { display: none; } | |
| /* Status label (warnings, "Get readyβ¦", live Hz) pinned top-left as an | |
| absolute overlay so it never shifts the centered illustration. The REC | |
| badge owns the top-right corner. */ | |
| .stage-status { | |
| position: absolute; top: 14px; left: 16px; z-index: 2; | |
| max-width: calc(100% - 110px); | |
| margin: 0; text-align: left; | |
| font-weight: 700; font-size: 13px; line-height: 1.3; | |
| color: var(--foreground); | |
| } | |
| .stage-status[hidden] { display: none; } | |
| .capture-meters[hidden] { display: none; } | |
| /* Capture guidance: a horizontal stepper sitting BELOW the (fixed-height) | |
| stage card. Each step lights up as the capture phase advances; earlier | |
| steps read as "done". A single helper line under it mirrors the active | |
| step's description. */ | |
| .stepper { | |
| list-style: none; margin: 22px 0 0; padding: 0; | |
| display: flex; align-items: flex-start; | |
| } | |
| .stepper-step { | |
| flex: 1; position: relative; | |
| display: flex; flex-direction: column; align-items: center; gap: 9px; | |
| text-align: center; min-width: 0; | |
| color: var(--muted-foreground); | |
| } | |
| /* Connector line running from this dot to the next one. Spans the full gap | |
| between adjacent dots so the stepper reads edge-to-edge. */ | |
| .stepper-step:not(:last-child)::after { | |
| content: ""; position: absolute; | |
| top: 15px; left: calc(50% + 20px); right: calc(-50% + 20px); height: 2px; | |
| background: color-mix(in oklab, var(--primary) 20%, transparent); | |
| transition: background .2s ease; | |
| } | |
| .stepper-dot { | |
| flex-shrink: 0; | |
| width: 32px; height: 32px; border-radius: 999px; | |
| display: grid; place-items: center; | |
| font-size: 13.5px; font-weight: 800; | |
| color: var(--muted-foreground); | |
| background: var(--surface-2); | |
| border: 2px solid color-mix(in oklab, var(--primary) 20%, transparent); | |
| transition: color .2s ease, background .2s ease, border-color .2s ease, box-shadow .2s ease; | |
| } | |
| .stepper-label { | |
| font-size: 13px; font-weight: 600; line-height: 1.2; | |
| max-width: 100%; | |
| } | |
| .stepper-step.is-active .stepper-dot { | |
| color: var(--primary-foreground); background: var(--primary); border-color: var(--primary); | |
| box-shadow: 0 0 0 4px var(--primary-soft); | |
| } | |
| .stepper-step.is-active .stepper-label { color: var(--foreground); font-weight: 700; } | |
| .stepper-step.is-done .stepper-dot { | |
| color: var(--primary); background: var(--primary-soft); border-color: var(--primary); | |
| } | |
| .stepper-step.is-done .stepper-label { color: var(--foreground); } | |
| .stepper-step.is-done::after { background: var(--primary); } | |
| .stepper-desc { | |
| margin: 16px auto 0; max-width: 30rem; text-align: center; | |
| font-size: 13.5px; line-height: 1.5; color: var(--muted-foreground); | |
| min-height: 3em; | |
| } | |
| .stepper-desc strong { color: var(--foreground); font-weight: 700; } | |
| /* Capture progress bar β a full-width strip flush against the card's bottom | |
| edge (the card's overflow:hidden + radius round its corners). Only visible | |
| while recording. */ | |
| .stage-progress { | |
| position: absolute; left: 0; right: 0; bottom: 0; | |
| height: 5px; background: var(--surface-2); | |
| opacity: 0; transition: opacity .2s ease; | |
| } | |
| .stage.rec .stage-progress { opacity: 1; } | |
| .stage-progress > span { | |
| display: block; height: 100%; width: 0%; | |
| background: linear-gradient(90deg, var(--primary), var(--primary-soft)); | |
| transition: width .15s linear; | |
| } | |
| /* Elapsed / total readout, pinned bottom-left and bottom-right just above the | |
| progress strip. */ | |
| .capture-meters .time { | |
| display: flex; justify-content: space-between; color: var(--muted-foreground); | |
| font-size: 12px; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| /* βββ Folder view βββββββββββββββββββββββββββββββββββββββ */ | |
| .folder-head { | |
| display: flex; align-items: center; gap: 12px; padding: 8px 20px 4px; | |
| } | |
| .folder-head .emoji { | |
| width: 52px; height: 52px; border-radius: 14px; display: grid; place-items: center; font-size: 26px; | |
| background: linear-gradient(135deg, oklch(0.95 0.02 260), oklch(0.97 0.02 285)); | |
| flex-shrink: 0; | |
| } | |
| .folder-head .grow { flex: 1; min-width: 0; } | |
| .folder-head .title { | |
| font-weight: 800; font-size: 20px; font-family: var(--font-display); | |
| overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| } | |
| .folder-head .meta { color: var(--muted-foreground); font-size: 13px; } | |
| .folder-head .row-menu { width: 36px; height: 36px; font-size: 22px; } | |
| /* Folder view: primary CTA, moves section, and the collapsible | |
| "Playback settings" disclosure. */ | |
| /* Folder + Home views: flex the shell + view so the primary CTA docks at the | |
| bottom (thumb zone) while the list stays top-aligned and scrolls. */ | |
| .app-shell--folder, | |
| .app-shell--home { display: flex; flex-direction: column; } | |
| .folder-view, | |
| .home-view { flex: 1; display: flex; flex-direction: column; } | |
| /* Home reuses the .step shell (140px reserved bottom padding meant for a | |
| floating CTA); with the dock now in flow, drop that padding and instead | |
| reserve space under the list so the last folder clears the sticky dock. */ | |
| .home-view { padding-bottom: 0; } | |
| .home-view #folder-list { padding-bottom: 88px; } | |
| /* Docked primary action: margin-top:auto pushes it to the bottom when content | |
| is short; position:sticky keeps it visible above the scrolling list when the | |
| content overflows. The trailing .disclosure reserves padding-bottom so its | |
| last row isn't hidden behind the dock. */ | |
| .cta-dock { | |
| margin-top: auto; | |
| position: sticky; | |
| bottom: 0; | |
| z-index: 5; | |
| padding: 16px 20px max(24px, calc(env(safe-area-inset-bottom) + 12px)); | |
| background: color-mix(in oklab, var(--background) 88%, transparent); | |
| backdrop-filter: blur(12px); | |
| border-top: 1px solid var(--border); | |
| } | |
| .folder-body { padding: 10px 20px 4px; } | |
| .folder-body .list { margin-top: 10px; } | |
| .section-label { | |
| font-size: 12px; font-weight: 700; letter-spacing: .06em; | |
| text-transform: uppercase; color: var(--muted-foreground); | |
| } | |
| /* Playback controls (volume + per-folder audio sync) live in the bottom | |
| dock, stacked above the primary CTA. The dock owns the outer padding, so | |
| here we just stack the rows and add a little breathing room below before | |
| the record button. */ | |
| .folder-playback { | |
| display: flex; flex-direction: column; gap: 10px; | |
| margin-bottom: 14px; | |
| } | |
| .folder-playback .section-label { margin-bottom: 2px; } | |
| /* Keep the card chrome but let it span the full section width: the only | |
| undesirable inset was the volume-row's horizontal margin (20px). */ | |
| .folder-playback .volume-row { | |
| margin: 0; | |
| } | |
| /* Folder empty state: icon badge + title + hint, centered. */ | |
| .folder-empty { | |
| text-align: center; padding: 24px 20px 4px; | |
| display: flex; flex-direction: column; align-items: center; gap: 10px; | |
| } | |
| .folder-empty-ico { | |
| width: 64px; height: 64px; border-radius: 18px; | |
| display: grid; place-items: center; font-size: 30px; | |
| background: var(--surface-2); border: 1px solid var(--border); | |
| } | |
| .folder-empty-title { font-weight: 800; font-size: 17px; font-family: var(--font-display); } | |
| .folder-empty-sub { color: var(--muted-foreground); font-size: 13px; line-height: 1.45; max-width: 260px; } | |
| .disclosure { padding-bottom: max(96px, env(safe-area-inset-bottom)); } | |
| .disclosure-toggle { | |
| display: flex; align-items: center; justify-content: space-between; | |
| width: 100%; padding: 14px 20px; | |
| color: var(--foreground); font-size: 14px; font-weight: 600; | |
| } | |
| .disclosure-toggle:hover { color: var(--primary); } | |
| .disclosure-chev { color: var(--muted-foreground); transition: transform .2s ease; } | |
| .disclosure.open .disclosure-chev { transform: rotate(180deg); } | |
| .disclosure-body { padding-bottom: 6px; } | |
| /* Generic move card β used in the folder list and the review screen. | |
| Leading icon + name/meta + optional play toggle + contextual action | |
| buttons, with a dedicated progress bar pinned to the top edge. */ | |
| .move-card { | |
| position: relative; overflow: hidden; | |
| display: flex; align-items: center; gap: 12px; padding: 12px 14px; | |
| border-radius: var(--radius-xl); border: 1px solid var(--border); background: var(--surface); | |
| box-shadow: var(--shadow-card); | |
| } | |
| .move-card .move-card-row { | |
| display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; | |
| /* Let a crowded action row (Share + rename + delete on an owned move) | |
| drop to a second line on narrow phones instead of starving the title | |
| to nothing. Wide layouts keep everything on one line. */ | |
| flex-wrap: wrap; row-gap: 10px; | |
| } | |
| .move-card.highlight { | |
| border-color: var(--primary); box-shadow: var(--shadow-focus); | |
| } | |
| .move-card-play { | |
| width: 40px; height: 40px; border-radius: 12px; | |
| display: grid; place-items: center; | |
| background: linear-gradient(135deg, var(--primary), var(--primary-soft)); | |
| color: white; font-size: 15px; | |
| flex-shrink: 0; | |
| } | |
| .move-card-play:disabled { opacity: 0.4; cursor: not-allowed; } | |
| /* Loading state: the play button shows a spinner while the move is being | |
| prepared/uploaded (no progress bar during this phase). */ | |
| .move-card.is-loading .move-card-play { color: transparent; position: relative; } | |
| .move-card.is-loading .move-card-play::after { | |
| content: ""; position: absolute; inset: 0; margin: auto; | |
| width: 16px; height: 16px; border-radius: 50%; | |
| border: 2px solid rgba(255, 255, 255, 0.35); border-top-color: #fff; | |
| animation: move-card-spin 0.7s linear infinite; | |
| } | |
| @keyframes move-card-spin { to { transform: rotate(360deg); } } | |
| .move-card-ico { | |
| width: 34px; height: 34px; flex-shrink: 0; | |
| display: grid; place-items: center; | |
| font-size: 18px; | |
| border-radius: 10px; | |
| background: var(--surface-2); | |
| } | |
| /* Basis of 150px so the row wraps the actions below once the title would | |
| be squeezed under ~150px; above that everything stays on one line. */ | |
| .move-card-body { flex: 1 1 150px; min-width: 0; } | |
| .move-card-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| /* Single-line, like the title: without this the meta wrapped to several | |
| lines when the action buttons squeezed the body on narrow phones, and | |
| the overflowing text bled under the buttons. */ | |
| .move-card-meta { | |
| color: var(--muted-foreground); font-size: 12px; | |
| overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| } | |
| /* Live playback readout ("Uploading 43%", "0:03 / 0:07") written into the | |
| meta slot by createPlaybackReadout. Tabular digits keep the ticking | |
| timer from jittering horizontally; the accent colour marks it as live | |
| rather than descriptive. */ | |
| .move-card-meta.is-live { | |
| color: var(--primary); font-weight: 600; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .move-card-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; margin-left: auto; } | |
| /* Compact outlined action buttons on a move card β same primary button | |
| language as the rest of the app, sized down to sit inside the row. */ | |
| .move-card-actions .btn.move-action { | |
| padding: 7px 12px; gap: 6px; | |
| font-size: 13px; border-radius: 10px; | |
| box-shadow: none; | |
| } | |
| .move-card-actions .btn.move-action svg { width: 16px; height: 16px; } | |
| .move-card-actions .btn.move-action.icon-only { | |
| padding: 0; width: 36px; height: 36px; | |
| } | |
| /* In-flight state (share upload): the button carries its own spinner so | |
| the feedback stays on the row that's working, no toast needed. Keep the | |
| disabled look readable (opacity stays high) since it's an active state, | |
| not an unavailable one. */ | |
| .move-card-actions .btn.move-action.is-busy { opacity: 0.85; cursor: progress; } | |
| .btn-spinner { | |
| display: inline-block; width: 12px; height: 12px; border-radius: 50%; | |
| border: 2px solid currentColor; border-top-color: transparent; | |
| opacity: 0.8; flex-shrink: 0; | |
| animation: move-card-spin 0.7s linear infinite; | |
| } | |
| /* "Shared" state: swap the outline to the success accent. */ | |
| .move-card-actions .btn.move-action.shared { | |
| color: oklch(0.5 0.12 165); | |
| border-color: oklch(0.5 0.12 165); | |
| } | |
| .move-card-actions .btn.move-action.shared:hover { | |
| background: oklch(0.84 0.1 165 / 0.16); | |
| } | |
| /* Playback progress β a thin bar flush to the top edge of the card. | |
| Hidden (no track) until activated, so idle cards show nothing. */ | |
| .move-progress { | |
| position: absolute; top: 0; left: 0; right: 0; | |
| height: 3px; | |
| background: var(--surface-2); | |
| z-index: 2; | |
| pointer-events: none; | |
| overflow: hidden; | |
| display: none; | |
| } | |
| .move-progress.is-active { display: block; } | |
| .move-progress > span { | |
| display: block; height: 100%; width: 0%; | |
| background: linear-gradient(90deg, var(--primary), var(--primary-soft)); | |
| transition: width 80ms linear; | |
| } | |
| .chip { | |
| display: inline-flex; align-items: center; gap: 6px; | |
| padding: 6px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; | |
| background: var(--surface-2); color: var(--muted-foreground); | |
| border: 1px solid var(--border); | |
| } | |
| .chip.shared { background: oklch(0.84 0.1 165 / 0.2); color: oklch(0.4 0.12 165); border-color: transparent; } | |
| /* βββ Empty, toast ββββββββββββββββββββββββββββββββββββββ */ | |
| .empty { | |
| text-align: center; padding: 40px 20px; color: var(--muted-foreground); | |
| } | |
| /* Toast: a single status card, bottom-center. Primary-outlined (accent | |
| border + soft accent halo) so it stays legible when it lands over | |
| similar surface cards. Accent is theme-aware and per-kind. The bottom | |
| bar runs in 3 modes β countdown / determinate / indeterminate (see | |
| components/toast.js). */ | |
| .toast { | |
| --toast-accent: var(--primary); | |
| /* Sit above the bottom dock (playback controls + Record CTA) when one is | |
| present β main.js sets --toast-dock-offset to the dock height so the | |
| toast never lands on top of the sliders β and always clear the device | |
| safe-area / home indicator so it can't be clipped at the screen edge. */ | |
| position: fixed; left: 50%; transform: translateX(-50%); | |
| bottom: calc(var(--toast-dock-offset, 0px) + max(18px, env(safe-area-inset-bottom))); | |
| display: flex; align-items: center; gap: 12px; | |
| /* Span the full app width, aligned with the content cards' 20px gutters | |
| (.app-shell is 28rem; on phones it tracks the viewport edges). */ | |
| width: min(calc(28rem - 40px), calc(100vw - 40px)); | |
| padding: 16px 16px 14px; | |
| background: color-mix(in oklab, var(--toast-accent) 12%, var(--surface)); | |
| color: var(--foreground); | |
| border: 1.5px solid var(--toast-accent); | |
| border-radius: 16px; | |
| box-shadow: var(--shadow-elev); | |
| z-index: 80; overflow: hidden; | |
| font-size: 14px; font-weight: 600; line-height: 1.35; | |
| animation: toast-in .26s cubic-bezier(.21, 1.02, .73, 1) both; | |
| } | |
| .toast.success { --toast-accent: oklch(0.55 0.13 165); } | |
| .toast.error { --toast-accent: var(--destructive); } | |
| .toast.toast-out { animation: toast-out .2s ease forwards; } | |
| .toast-msg { flex: 1; min-width: 0; overflow-wrap: anywhere; } | |
| /* With a trailing metric (live Hz), keep the message on a single line so the | |
| appearing number can't push it to two lines β ellipsis long names instead. */ | |
| .toast.has-detail .toast-msg { | |
| overflow-wrap: normal; white-space: nowrap; | |
| overflow: hidden; text-overflow: ellipsis; | |
| } | |
| /* Trailing metric (e.g. live "100 Hz" during playback): muted, monospaced, | |
| fixed min-width so the digits don't jitter the layout as they tick. */ | |
| .toast-detail { | |
| flex-shrink: 0; | |
| min-width: 54px; text-align: center; | |
| font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| color: var(--muted-foreground); | |
| font-variant-numeric: tabular-nums; | |
| padding: 3px 8px; border-radius: 999px; | |
| background: color-mix(in oklab, var(--toast-accent) 14%, transparent); | |
| } | |
| .toast-icon { | |
| flex-shrink: 0; width: 22px; height: 22px; border-radius: 999px; | |
| display: grid; place-items: center; | |
| font-size: 13px; font-weight: 800; color: white; | |
| background: var(--toast-accent); | |
| } | |
| /* Loading spinner inside the toast icon chip β same pattern as the move-card | |
| play button (reuses the move-card-spin keyframes). */ | |
| .toast-spinner { | |
| width: 14px; height: 14px; border-radius: 50%; | |
| border: 2px solid rgba(255, 255, 255, 0.4); border-top-color: #fff; | |
| animation: move-card-spin 0.7s linear infinite; | |
| } | |
| .toast-close { | |
| flex-shrink: 0; width: 26px; height: 26px; margin: -4px -6px -4px 0; | |
| border-radius: 999px; font-size: 19px; line-height: 1; | |
| color: var(--muted-foreground); | |
| display: grid; place-items: center; | |
| transition: background-color .15s ease, color .15s ease; | |
| } | |
| .toast-close:hover { background: var(--surface-2); color: var(--foreground); } | |
| /* Top progress bar (track + fill). */ | |
| .toast-bar { | |
| position: absolute; left: 0; right: 0; top: 0; height: 3px; | |
| background: color-mix(in oklab, var(--toast-accent) 18%, transparent); | |
| overflow: hidden; | |
| } | |
| .toast-bar > i { | |
| display: block; height: 100%; background: var(--toast-accent); | |
| transform-origin: left center; | |
| } | |
| /* countdown: drains 100% β 0% over the toast's lifetime (duration set inline). */ | |
| .toast-bar.countdown > i { transform: scaleX(1); animation: toast-countdown linear forwards; } | |
| @keyframes toast-countdown { from { transform: scaleX(1); } to { transform: scaleX(0); } } | |
| /* determinate: width = progress fraction (transform set inline per tick). | |
| The bar node is preserved across in-place refreshes (see toast.js), so a | |
| short linear transition smooths the stepped updates into a steady fill β | |
| used by both the import progress and the live playback bar. */ | |
| .toast-bar.determinate > i { transform: scaleX(0); transition: transform .16s linear; } | |
| /* indeterminate: a short segment looping for unknown-length operations. */ | |
| .toast-bar.indeterminate > i { | |
| width: 40%; transform: translateX(-120%); | |
| animation: toast-indet 1.15s ease-in-out infinite; | |
| } | |
| @keyframes toast-indet { | |
| 0% { transform: translateX(-120%); } | |
| 100% { transform: translateX(280%); } | |
| } | |
| @keyframes toast-in { | |
| from { opacity: 0; transform: translateX(-50%) translateY(12px); } | |
| to { opacity: 1; transform: translateX(-50%) translateY(0); } | |
| } | |
| @keyframes toast-out { | |
| from { opacity: 1; transform: translateX(-50%) translateY(0); } | |
| to { opacity: 0; transform: translateX(-50%) translateY(12px); } | |
| } | |
| /* βββ Connection gate βββββββββββββββββββββββββββββββββββ */ | |
| .gate { | |
| display: grid; place-items: center; | |
| min-height: 100dvh; | |
| padding: 20px; | |
| } | |
| .gate-card { | |
| max-width: 26rem; | |
| width: 100%; | |
| padding: 32px 24px; | |
| text-align: center; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-2xl); | |
| box-shadow: var(--shadow-elev); | |
| } | |
| .gate-card .big-emoji { font-size: 64px; } | |
| /* Image variant of .big-emoji (the Marionette logo SVG). The div | |
| variant above keeps its 64px font sizing for the robot-picker emoji. */ | |
| .gate-card img.big-emoji { | |
| width: 96px; height: 96px; | |
| object-fit: contain; | |
| display: block; | |
| margin: 0 auto 4px; | |
| } | |
| .gate-card h1 { | |
| font-family: var(--font-display); font-weight: 800; font-size: 28px; | |
| margin-top: 6px; | |
| } | |
| .gate-card p { color: var(--muted-foreground); font-size: 15px; margin-top: 6px; } | |
| .gate-card .btn { margin-top: 18px; } | |
| .gate-card .divider { | |
| margin: 22px 0 6px; | |
| color: var(--muted-foreground); | |
| font-size: 12px; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| position: relative; | |
| } | |
| .gate-card .divider::before, | |
| .gate-card .divider::after { | |
| content: ""; | |
| position: absolute; top: 50%; | |
| width: calc(50% - 30px); | |
| height: 1px; background: var(--border); | |
| } | |
| .gate-card .divider::before { left: 0; } | |
| .gate-card .divider::after { right: 0; } | |
| .robot-list { display: grid; gap: 8px; margin-top: 14px; text-align: left; } | |
| /* βββ Settings sheet ββββββββββββββββββββββββββββββββββββ */ | |
| .sheet-backdrop { | |
| position: fixed; inset: 0; background: oklch(0.18 0.02 260 / 0.4); | |
| z-index: 60; animation: fade-in .2s ease both; | |
| } | |
| /* Settings: a full-screen page that pushes in from the right (native | |
| "settings page" feel) instead of a cramped bottom sheet. Sticky header | |
| (Back chevron + title) mirrors the app topbar; the body scrolls on its | |
| own so the long list of rows has full height to breathe. */ | |
| .settings-page { | |
| position: fixed; inset: 0; z-index: 70; | |
| max-width: 28rem; margin: 0 auto; | |
| background: var(--background); color: var(--foreground); | |
| display: flex; flex-direction: column; | |
| } | |
| .settings-head { | |
| position: sticky; top: 0; z-index: 1; | |
| display: flex; align-items: center; justify-content: space-between; gap: 6px; | |
| /* Vertical padding matches the global topbar (12px top/bottom) so the | |
| settings header is exactly the same height. Right padding (12px) keeps | |
| the close X over the cog it covers; left padding (20px) lines the title | |
| up with the rows below. */ | |
| padding: max(12px, env(safe-area-inset-top)) 12px 12px 20px; | |
| background: color-mix(in oklab, var(--background) 85%, transparent); | |
| backdrop-filter: blur(10px); | |
| } | |
| .settings-title { | |
| font-family: var(--font-display); font-weight: 800; font-size: 22px; | |
| } | |
| .settings-head .icon-btn { color: var(--primary); } | |
| .settings-head .icon-btn:hover { background: var(--accent); } | |
| .settings-body { | |
| flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; | |
| overscroll-behavior: contain; | |
| padding: 4px 20px max(24px, env(safe-area-inset-bottom)); | |
| } | |
| .settings-page .row-setting { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 14px 0; | |
| border-bottom: 1px solid var(--border); | |
| gap: 12px; | |
| } | |
| .settings-page .row-setting:last-child { border-bottom: none; } | |
| .settings-page .row-setting .label { font-weight: 600; font-size: 14px; } | |
| .settings-page .row-setting .sublabel { color: var(--muted-foreground); font-size: 12px; margin-top: 2px; } | |
| /* Stacked variant: label/help on top, a full-width control (tabs) below. | |
| Used for the Audio output + Debug HUD segmented controls. */ | |
| .settings-page .row-setting.stack { flex-direction: column; align-items: stretch; gap: 10px; } | |
| .segmented.small button { padding: 6px 10px; font-size: 12px; } | |
| /* βββ Animations ββββββββββββββββββββββββββββββββββββββββ */ | |
| @keyframes float-y { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-8px)} } | |
| @keyframes pulse-ring { | |
| 0% { box-shadow: 0 0 0 0 oklch(0.62 0.22 27 / 0.4); } | |
| 70% { box-shadow: 0 0 0 16px oklch(0.62 0.22 27 / 0); } | |
| 100% { box-shadow: 0 0 0 0 oklch(0.62 0.22 27 / 0); } | |
| } | |
| @keyframes slide-up { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } | |
| @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } | |
| /* ---------------------------------------------------------------------- | |
| Waveform trim widget: a decoded waveform canvas with a start bracket, | |
| an end bracket, and a draggable playhead. A tap on the waveform toggles | |
| play/pause of the selected region. | |
| ---------------------------------------------------------------------- | |
| Layout in pixels: | |
| - Outer .trim-bar: 72 px tall, 12 px horizontal padding. The | |
| padding equals half the widest handle (24 px) so when start is | |
| at 0 or end is at dur the handles still sit inside the card. | |
| - .trim-wave: the <canvas> waveform; bars inside the selection are | |
| drawn in --primary, bars outside are dimmed. | |
| - .trim-region: a faint highlight band between the brackets. | |
| - .trim-handle: 32 px wide hit area (>=44 px on phones via media | |
| query) so taps land reliably. Visible shape is a single thin | |
| vertical bar drawn with ::before. | |
| - .trim-playhead-new: a thin vertical line marking playback position. | |
| */ | |
| .trim-bar { | |
| position: relative; | |
| height: 72px; | |
| padding: 0 12px; /* gutter == half the widest handle */ | |
| touch-action: none; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| cursor: pointer; | |
| } | |
| .trim-wave { | |
| position: absolute; | |
| inset: 0; | |
| width: 100%; height: 100%; | |
| z-index: 0; | |
| } | |
| .trim-region { | |
| /* No fill: the waveform bars already color the selection. Kept as a | |
| zero-cost layout marker for the bracket handles. */ | |
| position: absolute; | |
| top: 6px; bottom: 6px; | |
| background: transparent; | |
| z-index: 1; | |
| pointer-events: none; | |
| } | |
| .trim-handle { | |
| position: absolute; | |
| top: 0; bottom: 0; | |
| width: 32px; /* wide hit area for touch */ | |
| margin-left: -16px; /* center on the time x */ | |
| cursor: ew-resize; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 2; | |
| } | |
| @media (hover: none) and (pointer: coarse) { | |
| /* Phones: even wider hit area. */ | |
| .trim-handle { width: 44px; margin-left: -22px; } | |
| } | |
| .trim-handle::before { | |
| /* Vertical spine of the bracket. */ | |
| content: ""; | |
| position: absolute; | |
| width: 6px; | |
| top: 6px; bottom: 6px; | |
| background: var(--primary); | |
| border-radius: 3px; | |
| box-shadow: 0 0 0 1px var(--background), 0 1px 3px rgba(0,0,0,0.18); | |
| } | |
| .trim-handle:hover::before { filter: brightness(1.1); } | |
| .trim-handle.dragging::before { box-shadow: 0 0 0 2px var(--background), 0 2px 6px rgba(0,0,0,0.25); } | |
| .trim-playhead-new { | |
| position: absolute; | |
| top: 0; bottom: 0; | |
| width: 28px; /* hit area, knob is smaller */ | |
| margin-left: -14px; | |
| cursor: grab; | |
| display: none; /* shown once a cursor exists */ | |
| z-index: 3; | |
| } | |
| .trim-playhead-new.on { display: block; } | |
| .trim-playhead-new.dragging { cursor: grabbing; } | |
| .trim-playhead-new::before { | |
| /* The thin red vertical line itself. */ | |
| content: ""; | |
| position: absolute; | |
| top: 6px; bottom: 6px; | |
| left: 50%; | |
| width: 2px; | |
| margin-left: -1px; | |
| background: var(--destructive); | |
| box-shadow: 0 0 6px var(--destructive); | |
| border-radius: 1px; | |
| pointer-events: none; | |
| } | |
| .trim-link { | |
| background: transparent; border: none; padding: 0; | |
| font-size: 13px; font-weight: 600; color: var(--primary); | |
| /* Fixed line-height so swapping the βΆ / βΈ glyph (which have different | |
| intrinsic heights) doesn't change the row height. */ | |
| line-height: 20px; height: 20px; | |
| cursor: pointer; | |
| } | |
| .trim-link:hover { text-decoration: underline; } | |
| /* Generic slider field: a leading icon, the themed range (.slider), and | |
| a right-aligned value readout. One row, layout-neutral β wrap it in a | |
| card (.volume-row) or drop it straight into a form. */ | |
| .slider-field { | |
| display: flex; align-items: center; gap: 10px; | |
| } | |
| .slider-field .slider { flex: 1; } | |
| /* Disabled (e.g. the sync-offset slider while a move is playing): dim and | |
| block interaction, but keep the value readout legible. */ | |
| .slider-field.is-disabled { opacity: 0.5; } | |
| .slider-field.is-disabled .slider { pointer-events: none; cursor: not-allowed; } | |
| .slider-field .sf-ico { | |
| width: 28px; height: 28px; | |
| display: grid; place-items: center; | |
| font-size: 16px; flex-shrink: 0; | |
| } | |
| .slider-field .sf-val { | |
| min-width: 40px; text-align: right; | |
| font-variant-numeric: tabular-nums; | |
| font-size: 13px; font-weight: 600; | |
| color: var(--muted-foreground); | |
| } | |
| /* Card presentation for a slider field β the speaker/mic volume and the | |
| per-folder audio offset bars at the top of Home/Folder, always within | |
| reach when playing moves. */ | |
| .volume-row { | |
| padding: 10px 14px; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-xl); | |
| box-shadow: var(--shadow-card); | |
| margin: 14px 20px 0; | |
| } | |
| /* In-app dialogs (confirm / prompt) β themed alternative to the | |
| native browser pop-ups, which look out-of-place. */ | |
| .dialog-backdrop { | |
| position: fixed; inset: 0; background: oklch(0.18 0.02 260 / 0.5); | |
| z-index: 100; animation: fade-in .18s ease both; | |
| } | |
| .dialog { | |
| position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-2xl); | |
| box-shadow: var(--shadow-elev); | |
| z-index: 110; | |
| width: min(420px, calc(100vw - 32px)); | |
| padding: 22px 22px 18px; | |
| animation: dialog-pop .2s ease both; | |
| } | |
| .dialog h3 { | |
| font-family: var(--font-display); font-weight: 800; font-size: 18px; | |
| margin-bottom: 8px; | |
| } | |
| .dialog p { | |
| margin: 0; color: var(--muted-foreground); font-size: 14px; | |
| line-height: 1.45; | |
| } | |
| .dialog .input { | |
| margin-top: 14px; | |
| } | |
| .dialog-actions { | |
| display: flex; gap: 10px; margin-top: 20px; | |
| } | |
| /* Every dialog button is outlined regardless of its semantic kind: a | |
| transparent fill with a 1px border + text tinted to its color. Keeps the | |
| modal calm and consistent (no heavy filled buttons), while the border/text | |
| color still signals intent (neutral cancel, primary confirm, danger). */ | |
| .dialog-actions .btn { | |
| flex: 1; padding: 12px 14px; | |
| background: transparent; | |
| box-shadow: none; | |
| } | |
| .dialog-actions .btn.ghost { | |
| border: 1px solid var(--border); | |
| color: var(--foreground); | |
| } | |
| .dialog-actions .btn.ghost:hover { background: var(--surface-2); } | |
| .dialog-actions .btn.primary { | |
| border: 1px solid var(--primary); | |
| color: var(--primary); | |
| } | |
| .dialog-actions .btn.primary:hover { background: var(--accent); } | |
| .dialog-actions .btn.danger { | |
| border: 1px solid var(--destructive); | |
| color: var(--destructive); | |
| } | |
| .dialog-actions .btn.danger:hover { | |
| background: color-mix(in oklab, var(--destructive) 12%, transparent); | |
| } | |
| @keyframes dialog-pop { | |
| from { opacity: 0; transform: translate(-50%, calc(-50% + 8px)) scale(.97); } | |
| to { opacity: 1; transform: translate(-50%, -50%) scale(1); } | |
| } | |
| /* Tiny debug HUD (toggled from settings) */ | |
| .debug-hud { | |
| position: fixed; | |
| right: 8px; bottom: 8px; | |
| background: rgba(0,0,0,0.72); | |
| color: #fff; | |
| font-family: ui-monospace, monospace; | |
| font-size: 11px; | |
| padding: 6px 10px; | |
| border-radius: 8px; | |
| z-index: 90; | |
| pointer-events: none; | |
| line-height: 1.4; | |
| } | |
| /* βββ Welcome / onboarding βββββββββββββββββββββββββββββββ */ | |
| .welcome { | |
| text-align: center; | |
| padding-bottom: 48px; | |
| } | |
| /* Vertical centering: the shell becomes a flex column for this view only | |
| (set in main.js via .app-shell--welcome) and the onboarding stack grows | |
| to fill the height below the topbar, centering its content. */ | |
| .app-shell--welcome { display: flex; flex-direction: column; } | |
| .app-shell--welcome .welcome { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| padding-bottom: 0; | |
| } | |
| /* Wizard create steps: center the [form body + action row] together so | |
| the validation button sits directly below the form, not pinned to the | |
| viewport bottom. `margin-top: auto` above the body and `margin-bottom: | |
| auto` below the footer split the free space evenly, centering the whole | |
| group. When the content is taller than the viewport both auto margins | |
| collapse to 0 and it scrolls from the top. The footer drops its sticky | |
| bar chrome here and flows inline. Scoped to the wizard via the modifier | |
| main.js adds. */ | |
| .app-shell--wizard { display: flex; flex-direction: column; } | |
| .app-shell--wizard .step { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| padding-bottom: 0; | |
| } | |
| /* Body content sits flush under the topbar (top-aligned). The primary | |
| actions get pushed to the bottom via the .footer rule below, giving the | |
| standard 3-zone mobile layout (header / scrolling content / thumb-zone CTA). */ | |
| .app-shell--wizard .wiz-body { margin-top: 0; width: 100%; } | |
| .app-shell--wizard .wiz-body--top { margin-top: 0; } | |
| .app-shell--wizard .footer { | |
| /* 3-zone mobile layout: body top-aligned, primary actions pinned to the | |
| bottom (thumb zone). margin-top:auto absorbs the slack above the footer | |
| so it sits at the bottom of the shell while the body stays flush at top. */ | |
| margin-top: auto; | |
| position: static; | |
| background: none; | |
| backdrop-filter: none; | |
| border-top: 0; | |
| /* No horizontal padding: the parent .step already pads 20px, so the | |
| action row aligns with the form fields and the two buttons span the | |
| full content width. Extra top padding separates the actions from the | |
| form above. */ | |
| padding: 32px 0 max(16px, env(safe-area-inset-bottom)); | |
| } | |
| /* Hero: the icon.svg under a glow, with decorative "strings" above it | |
| that gently sway β a nod to the puppeteer control cross in the mark. */ | |
| .welcome-hero { | |
| position: relative; | |
| width: 168px; | |
| height: 180px; | |
| margin: 14px auto 2px; | |
| display: grid; | |
| place-items: end center; | |
| } | |
| .welcome-hero::before { | |
| content: ""; | |
| position: absolute; | |
| left: 50%; bottom: 6px; | |
| width: 180px; height: 180px; | |
| transform: translateX(-50%); | |
| background: radial-gradient(circle at 50% 55%, var(--accent), transparent 70%); | |
| z-index: 0; | |
| } | |
| .welcome-mark { | |
| position: relative; | |
| z-index: 1; | |
| width: 140px; | |
| height: 140px; | |
| object-fit: contain; | |
| } | |
| .welcome-title { | |
| font-family: var(--font-display); | |
| font-size: 34px; | |
| font-weight: 800; | |
| letter-spacing: -0.02em; | |
| margin-top: 10px; | |
| } | |
| .welcome-tagline { | |
| color: var(--primary); | |
| font-weight: 700; | |
| font-size: 15px; | |
| margin-top: 2px; | |
| } | |
| .welcome-lede { | |
| color: var(--muted-foreground); | |
| font-size: 15px; | |
| line-height: 1.5; | |
| margin: 12px auto 0; | |
| max-width: 19rem; | |
| } | |
| .welcome-actions { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| margin-top: 20px; | |
| } | |