/* gentika launcher shell v2 - ported VERBATIM from
   skill_to_create_new_shopping_agents_new_ui/baseline/web/static/shell.css
   with ONLY the standalone-page rules pruned (font-face, html/body, backdrop)
   and brand asset urls retargeted. Motion and chrome untouched. */
/* gentika launcher shell - config-driven. SHELL v2 (motion + glass generation).
   Built on the PILL shape language: one rounded-capsule primitive (border-radius
   var(--pill)) for the bar, the send circle, the suggestion bubbles and the chat
   bubbles - so the dot → pill → bar entrance is a single shape growing in width.
   Everything brand-facing is a TOKEN (--accent, --user-bubble, --page-bg, logo). The
   server injects per-brand values as an inline :root <style> after these links (see
   docs/CONFIG.md), so the values below are just the neutral fallback skin.
   Motion doctrine: the entrance keyframes and the linear() spring easings are
   GENERATED by tools/gen_motion.py (in this skill repo) - regenerate, don't
   hand-tweak; keep the intro duration in sync with shell_core.js runIntro. */

/* Geist - self-hosted (venue-safe, no CDN, mirroring the vendored marked/purify/
   jspdf libs). One variable woff2 per subset covers weights 100–900. `latin`
   carries Italian accents + the € sign; `latin-ext` is belt-and-suspenders. */

:root {
  --pill: 999px;
  --page-bg: #ececed;          /* original: white bar floats on a soft grey page */
  --ink: #2b2b30;
  --muted: #8a8d93;
  --line: #e4e4e7;

  --bar-bg: #ffffff;           /* white bar (reverted) */
  --bar-border: #e3e3e6;

  /* The one swappable brand knob: drives send button, focus ring, accents. */
  --accent: #3a3a40;
  --accent-ink: #ffffff;

  --user-bubble: #4a4a50;   /* user message + suggestion pills (mock: dark grey) */
  --user-ink: #ffffff;
  --bot-bubble: #eeeef1;
  --bot-ink: #26262b;

  --panel-bg: #f5f5f7;
  --shadow-bar: 0 18px 44px rgba(20, 20, 30, 0.16);
  --shadow-pop: 0 24px 64px rgba(20, 20, 30, 0.20);
}

/* Per-brand tokens (--accent, --page-bg, --page-bg-image, --send-bg, …) are injected
   by the server as an inline :root <style> block AFTER this stylesheet - docs/CONFIG.md. */

* { box-sizing: border-box; }

/* v3 site port: the standalone page's html,body rules are removed -
   the shell rides the landing page's own document. */

/* ========================= Voice failure note ======================== */
/* Sits above the bar inside .composer-wrap, so it lifts with the keyboard and is
   visible on the idle launcher as well as mid-chat. Same palette as .msg.err.
   [hidden] wins because the element is display:flex when shown. */
.voice-note {
  display: flex; justify-content: center;
  margin: 0 0 8px;
  pointer-events: none;
}
.voice-note[hidden] { display: none; }
.voice-note span {
  max-width: 100%;
  padding: 7px 14px;
  border-radius: 999px;
  background: #fdecea; color: #c2240a; border: 1px solid #f4b8b0;
  font-size: 13px; line-height: 1.35; text-align: center;
  box-shadow: 0 6px 18px #0000001a;
  animation: voice-note-in 0.22s cubic-bezier(0.2, 0.8, 0.3, 1);
}
@keyframes voice-note-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .voice-note span { animation: none; } }

/* ========================== The page mark ========================== */
/* Optional brand lockup over the backdrop's top-left (shell_render emits it only
   when brand_config sets brand.page_mark). A real element rather than pixels baked
   into the backdrop: it stays crisp on any DPR and, unlike the photo, it is never
   cropped - `cover` trims the image's edges on every viewport whose aspect differs
   from it, which is exactly where a baked-in lockup loses its first letters.
   z-index 1 keeps it under the panel (45) and the bar (50); it never takes clicks. */
.page-mark {
  position: fixed;
  top: clamp(14px, 2vw, 26px);
  left: clamp(16px, 2vw, 30px);
  height: clamp(24px, 2.6vw, 40px);
  width: auto;
  z-index: 1;
  pointer-events: none;
  user-select: none;
}

/* ============================ The pill ============================ */
/* Fixed, bottom-centered (full-page layout). It bounces up on load (rise),
   the inner .composer morphs from a dot (.seed) to the full bar. */
.composer-wrap {
  position: fixed;
  left: 50%; bottom: var(--bar-bottom);
  transform: translateX(-50%);
  width: min(700px, calc(100vw - 30px));   /* the EXPANDED footprint (+room for the elastic overshoot) */
  z-index: 50;
  margin: 0;
}
/* Intro (TWO-STAGE, shell v2.3): a small dot (.intro-dot) flies up and morphs
   to full width, ending at the BOTTOM OF THE RIMBALZO DIP - a zero-velocity
   frame, so the swap to the real bar is jitter-proof. The real bar then plays
   bar-land (the recovery + settle) while its CONTENTS fade in: the text and
   buttons appear as soon as the pill reads as a bar, not after the physics
   is over. The dot's width frames interpolate to var(--bar-target-w) so the
   morph lands on the REAL idle bar width on every viewport (a hard-coded
   420px target used to snap ~36px narrower at the swap on phones). BOTH
   stages are layout-only animations (bottom/width/height) - see the note on
   .intro-dot. */
:root {
  --dot-w: 53px;                                    /* = bar height (the seed) */
  --bar-target-w: min(420px, calc(100vw - 36px));   /* MUST equal the idle bar width */
  --kb: 0px;                                        /* soft-keyboard inset - shell_core publishes it via visualViewport */
  --bar-bottom: calc(40px + var(--kb));             /* rest height; bar-land animates around it */
  --dock-right: 40px;                               /* docked launcher's right margin (= the bar's bottom offset) */
}
body.intro .composer-wrap { opacity: 0; }
.intro-dot {
  position: fixed; left: 50%; bottom: var(--bar-bottom);
  width: var(--dot-w); height: var(--dot-w);
  /* STATIC centering transform only; the flight is layout keyframes
     (bottom/width/height) and the dot itself is INVISIBLE - all paint lives
     on ::before. Chrome freezes an element's backdrop-filter snapshot while
     ANY keyframe effect targets that element (property type irrelevant), so
     the frost must sit on a non-animated child that just rides along -
     exactly how the landing works (frosted bar inside the bottom-animated
     wrap). No opacity ramp: frame 0 starts fully below the viewport edge. */
  transform: translateX(-50%); z-index: 51; pointer-events: none;
}
.intro-dot::before {
  content: ''; position: absolute; inset: 0;
  border-radius: var(--pill);
  background: var(--bar-bg); border: 1px solid var(--bar-border);
  box-shadow: 0 10px 30px rgba(20, 20, 30, 0.12);
}
body:not(.intro) .intro-dot { display: none; }
/* Contents fade starting AT the swap - while the bar is still landing. */
#p-input { transition: opacity 0.34s ease; }
.composer-actions { transition: opacity 0.34s ease 0.06s; }
body.intro #p-input,
body.intro .composer-actions { opacity: 0; transition: none; }
@media (prefers-reduced-motion: no-preference) {
  body.intro .intro-dot { animation: dot-rise-morph 0.521s linear both; }
  body.intro-land .composer-wrap { animation: bar-land 0.509s linear both; }
}
/* While bar-land runs, the frosted bar FOLLOWS the wrap's animated geometry
   (impact squash + elastic recovery) without carrying any keyframe effect of
   its own - see the .intro-dot note for why that must never happen. */
body.intro-land .composer { width: 100%; max-width: none; height: 100%; min-height: 0; }
@media (prefers-reduced-motion: reduce) {
  body.intro .composer-wrap { opacity: 1; }   /* skip the theater */
  body.intro-land .composer-wrap { animation: none; }
}
/* Apple-grade entrance - real physics instead of hand-tuned keyframes, tuned
   frame-by-frame against the AirPods compare-pill reference GIF. ONE
   underdamped spring (ζ=0.40) drives the whole flight: launch from below the
   viewport, the peak IS the spring's first overshoot, it falls through and
   lands with the RIMBALZO IN BASSO - a dip ~25% of the bar height below the
   rest line (the reference measures ~30%) - recovers slightly ABOVE the line
   (-3px, like the reference's -2px) and settles. Width is a second,
   near-critically damped spring (ζ=0.92) released at the apex: it NEVER
   bounces (in the reference the pill's edge is strictly monotonic - the
   rimbalzo is purely vertical) and reaches full width right as the dip
   recovers. Squash & stretch are computed from the same motion - stretch ∝
   speed along the flight (tall droplet on launch), squash ∝ penetration below
   the rest line on landing. Stage 1 (dot-rise-morph) ends at the dip bottom
   where velocity is zero; stage 2 (bar-land, on the real .composer) carries
   the recovery and settle. The frames are dense samples of that simulation
   with linear timing between them: regenerate with tools/gen_motion.py
   rather than hand-tweaking values. */
@keyframes dot-rise-morph {
  0.0% { bottom: calc(var(--bar-bottom) - 110.0px); width: calc(var(--dot-w) * 0.855); height: calc(var(--dot-w) * 1.170); }
  3.8% { bottom: calc(var(--bar-bottom) - 83.0px); width: calc(var(--dot-w) * 0.855); height: calc(var(--dot-w) * 1.169); }
  7.7% { bottom: calc(var(--bar-bottom) - 56.8px); width: calc(var(--dot-w) * 0.861); height: calc(var(--dot-w) * 1.161); }
  11.5% { bottom: calc(var(--bar-bottom) - 32.3px); width: calc(var(--dot-w) * 0.872); height: calc(var(--dot-w) * 1.147); }
  15.4% { bottom: calc(var(--bar-bottom) - 10.3px); width: calc(var(--dot-w) * 0.887); height: calc(var(--dot-w) * 1.128); }
  19.2% { bottom: calc(var(--bar-bottom) + 8.7px); width: calc(var(--dot-w) * 0.904); height: calc(var(--dot-w) * 1.107); }
  23.1% { bottom: calc(var(--bar-bottom) + 24.2px); width: calc(var(--dot-w) * 0.923); height: calc(var(--dot-w) * 1.084); }
  26.9% { bottom: calc(var(--bar-bottom) + 36.1px); width: calc(var(--dot-w) * 0.942); height: calc(var(--dot-w) * 1.061); }
  30.8% { bottom: calc(var(--bar-bottom) + 44.6px); width: calc(var(--dot-w) * 0.962); height: calc(var(--dot-w) * 1.040); }
  34.6% { bottom: calc(var(--bar-bottom) + 49.7px); width: calc(var(--dot-w) * 0.980); height: calc(var(--dot-w) * 1.021); }
  38.5% { bottom: calc(var(--bar-bottom) + 51.9px); width: calc(var(--dot-w) * 0.996); height: calc(var(--dot-w) * 1.004); }
  42.3% { bottom: calc(var(--bar-bottom) + 51.4px); width: calc((var(--dot-w) + 0.022 * (var(--bar-target-w) - var(--dot-w))) * 0.991); height: calc(var(--dot-w) * 1.009); }
  46.2% { bottom: calc(var(--bar-bottom) + 48.8px); width: calc((var(--dot-w) + 0.112 * (var(--bar-target-w) - var(--dot-w))) * 0.980); height: calc(var(--dot-w) * 1.020); }
  50.0% { bottom: calc(var(--bar-bottom) + 44.4px); width: calc((var(--dot-w) + 0.234 * (var(--bar-target-w) - var(--dot-w))) * 0.972); height: calc(var(--dot-w) * 1.029); }
  53.8% { bottom: calc(var(--bar-bottom) + 38.9px); width: calc((var(--dot-w) + 0.365 * (var(--bar-target-w) - var(--dot-w))) * 0.966); height: calc(var(--dot-w) * 1.036); }
  57.7% { bottom: calc(var(--bar-bottom) + 32.5px); width: calc((var(--dot-w) + 0.489 * (var(--bar-target-w) - var(--dot-w))) * 0.962); height: calc(var(--dot-w) * 1.039); }
  61.5% { bottom: calc(var(--bar-bottom) + 25.7px); width: calc((var(--dot-w) + 0.599 * (var(--bar-target-w) - var(--dot-w))) * 0.961); height: calc(var(--dot-w) * 1.040); }
  65.4% { bottom: calc(var(--bar-bottom) + 18.9px); width: calc((var(--dot-w) + 0.693 * (var(--bar-target-w) - var(--dot-w))) * 0.962); height: calc(var(--dot-w) * 1.040); }
  69.2% { bottom: calc(var(--bar-bottom) + 12.3px); width: calc((var(--dot-w) + 0.770 * (var(--bar-target-w) - var(--dot-w))) * 0.964); height: calc(var(--dot-w) * 1.037); }
  73.1% { bottom: calc(var(--bar-bottom) + 6.3px); width: calc((var(--dot-w) + 0.832 * (var(--bar-target-w) - var(--dot-w))) * 0.968); height: calc(var(--dot-w) * 1.033); }
  76.9% { bottom: calc(var(--bar-bottom) + 0.9px); width: calc((var(--dot-w) + 0.880 * (var(--bar-target-w) - var(--dot-w))) * 0.972); height: calc(var(--dot-w) * 1.029); }
  80.8% { bottom: calc(var(--bar-bottom) - 3.6px); width: calc((var(--dot-w) + 0.917 * (var(--bar-target-w) - var(--dot-w))) * 0.991); height: calc(var(--dot-w) * 1.009); }
  84.6% { bottom: calc(var(--bar-bottom) - 7.2px); width: calc((var(--dot-w) + 0.945 * (var(--bar-target-w) - var(--dot-w))) * 1.011); height: calc(var(--dot-w) * 0.989); }
  88.5% { bottom: calc(var(--bar-bottom) - 10.0px); width: calc((var(--dot-w) + 0.966 * (var(--bar-target-w) - var(--dot-w))) * 1.028); height: calc(var(--dot-w) * 0.973); }
  92.3% { bottom: calc(var(--bar-bottom) - 11.8px); width: calc((var(--dot-w) + 0.981 * (var(--bar-target-w) - var(--dot-w))) * 1.041); height: calc(var(--dot-w) * 0.961); }
  96.2% { bottom: calc(var(--bar-bottom) - 12.9px); width: calc((var(--dot-w) + 0.992 * (var(--bar-target-w) - var(--dot-w))) * 1.050); height: calc(var(--dot-w) * 0.952); }
  100.0% { bottom: calc(var(--bar-bottom) - 13.2px); width: calc(var(--bar-target-w) * 1.056); height: calc(var(--dot-w) * 0.947); }
}
@keyframes bar-land {
  0.0% { bottom: calc(var(--bar-bottom) - 13.2px); width: calc(var(--bar-target-w) * 1.056); height: calc(var(--dot-w) * 0.947); }
  5.6% { bottom: calc(var(--bar-bottom) - 12.6px); width: calc(var(--bar-target-w) * 1.049); height: calc(var(--dot-w) * 0.954); }
  11.1% { bottom: calc(var(--bar-bottom) - 11.1px); width: calc(var(--bar-target-w) * 1.039); height: calc(var(--dot-w) * 0.963); }
  16.7% { bottom: calc(var(--bar-bottom) - 9.0px); width: calc(var(--bar-target-w) * 1.028); height: calc(var(--dot-w) * 0.973); }
  22.2% { bottom: calc(var(--bar-bottom) - 6.6px); width: calc(var(--bar-target-w) * 1.017); height: calc(var(--dot-w) * 0.983); }
  27.8% { bottom: calc(var(--bar-bottom) - 4.1px); width: calc(var(--bar-target-w) * 1.007); height: calc(var(--dot-w) * 0.993); }
  33.3% { bottom: calc(var(--bar-bottom) - 1.9px); width: var(--bar-target-w); height: var(--dot-w); }
  38.9% { bottom: var(--bar-bottom); width: calc(var(--bar-target-w) * 0.993); height: calc(var(--dot-w) * 1.007); }
  44.4% { bottom: calc(var(--bar-bottom) + 1.5px); width: calc(var(--bar-target-w) * 0.995); height: calc(var(--dot-w) * 1.005); }
  50.0% { bottom: calc(var(--bar-bottom) + 2.5px); width: calc(var(--bar-target-w) * 0.997); height: calc(var(--dot-w) * 1.003); }
  55.6% { bottom: calc(var(--bar-bottom) + 3.1px); width: var(--bar-target-w); height: var(--dot-w); }
  61.1% { bottom: calc(var(--bar-bottom) + 3.3px); width: var(--bar-target-w); height: var(--dot-w); }
  66.7% { bottom: calc(var(--bar-bottom) + 3.2px); width: var(--bar-target-w); height: var(--dot-w); }
  72.2% { bottom: calc(var(--bar-bottom) + 2.9px); width: var(--bar-target-w); height: var(--dot-w); }
  77.8% { bottom: calc(var(--bar-bottom) + 2.3px); width: calc(var(--bar-target-w) * 0.998); height: calc(var(--dot-w) * 1.002); }
  83.3% { bottom: calc(var(--bar-bottom) + 1.7px); width: calc(var(--bar-target-w) * 0.998); height: calc(var(--dot-w) * 1.002); }
  88.9% { bottom: calc(var(--bar-bottom) + 1.1px); width: calc(var(--bar-target-w) * 0.998); height: calc(var(--dot-w) * 1.002); }
  94.4% { bottom: calc(var(--bar-bottom) + 0.5px); width: var(--bar-target-w); height: var(--dot-w); }
  100.0% { bottom: var(--bar-bottom); width: var(--bar-target-w); height: var(--dot-w); }
}

.composer {
  position: relative;                      /* anchors the ::after hover aura */
  display: flex; align-items: center; gap: 4px;
  width: 100%; min-height: 46px;          /* thinner */
  max-width: 420px;                        /* idle = SHORT pill */
  margin: 0 auto;
  background: var(--bar-bg);
  border: 1px solid var(--bar-border);
  border-radius: var(--pill);
  padding: 4px 5px 4px 20px;
  box-shadow: 0 10px 30px rgba(20, 20, 30, 0.10);
  transition: max-width 0.34s cubic-bezier(0.4, 0, 0.2, 1),   /* the shrink-back on blur */
              box-shadow 0.3s ease, border-color 0.3s ease;
}
/* 3a. Click/focus springs the pill open with a clear horizontal elastic bounce:
   it overshoots wider, swings back under, then settles. While chatting it stays long.
   (A keyframe animation, because a transition can't visibly overshoot a max-width.)
   FALLBACK PATH: modern browsers replace all these keyframe bounces with real
   spring transitions - see the "Organic motion" @supports block at the end. */
.composer-wrap:focus-within .composer,
body.chatting .composer {
  max-width: min(640px, calc(100vw - 36px));
  animation: bar-expand 0.62s cubic-bezier(0.32, 0.7, 0.28, 1) both;
}
/* Hold the bar at the expanded width while the mic is recording or transcribing.
   Stopping a recording disables (and so blurs) the voice button to show the
   spinner, which drops :focus-within and would otherwise shrink the bar mid-
   transcription. Keeping it open makes the spinner sit on a stable bar - the same
   as during the conversation, where body.chatting pins the width. */
.composer-wrap:has(.voice-btn.is-recording) .composer,
.composer-wrap:has(.voice-btn.is-transcribing) .composer {
  max-width: min(640px, calc(100vw - 36px));
}
@keyframes bar-expand {
  0%   { max-width: 420px; }
  46%  { max-width: min(678px, calc(100vw - 30px)); }   /* overshoots wider */
  70%  { max-width: min(620px, calc(100vw - 36px)); }   /* swings back under */
  87%  { max-width: min(648px, calc(100vw - 36px)); }   /* small over again */
  100% { max-width: min(640px, calc(100vw - 36px)); }   /* settle */
}
/* Minimize: the bar shrinks back to the short idle width with a soft bounce. */
@keyframes bar-shrink {
  0%   { max-width: min(640px, calc(100vw - 36px)); }
  48%  { max-width: 398px; }   /* shrinks a touch past (overshoot narrower) */
  76%  { max-width: 434px; }   /* bounces back a little wider */
  100% { max-width: 420px; }   /* settle at the short idle width */
}
.composer:focus-within {
  border-color: color-mix(in srgb, var(--accent) 35%, var(--bar-border));
  box-shadow: 0 16px 40px rgba(20, 20, 30, 0.14);
}

#p-input {
  flex: 1; min-width: 0;
  border: none; outline: none; background: transparent;
  resize: none; font: inherit; font-size: 15px; color: var(--ink);
  line-height: 1.5; max-height: 44px; padding: 10px 0;
  /* Single line on every viewport: long input scrolls with the caret instead
     of wrapping (the mobile block pioneered this; fitInputHeight() then never
     grows the bar past one line). */
  white-space: nowrap; overflow: hidden; scrollbar-width: none;
}
#p-input::-webkit-scrollbar { display: none; }
#p-input::placeholder { color: var(--muted); }
/* Minimized-with-conversation: the bar is a clickable "Continua la conversazione"
   prompt. pointer-events:none lets clicks (even over the disabled input while the
   model is still responding) bubble to the .composer reopen handler. */
body.minimized .composer { cursor: pointer; animation: bar-shrink 0.32s cubic-bezier(0.3, 0.72, 0.28, 1) both; }  /* synced with panel-out */
/* Same bouncy shrink when focus leaves the expanded bar by clicking outside. */
body.bar-collapsing .composer { animation: bar-shrink 0.5s cubic-bezier(0.3, 0.72, 0.28, 1) both; }
body.minimized #p-input { pointer-events: none; }
body.minimized #p-input::placeholder { color: var(--ink); opacity: 0.6; }
#p-input:disabled { opacity: 1; }

.composer-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }

.voice-btn {
  display: inline-grid; place-items: center;
  width: 38px; height: 38px; flex-shrink: 0;
  /* --ink, not --muted: at this size a muted mic read faint beside the solid
     black send button next to it. */
  border-radius: 50%; border: none; background: transparent; color: var(--ink);
  cursor: pointer; transition: background 0.15s, color 0.15s, transform 0.1s;
}
.voice-btn[hidden] { display: none; }
.voice-btn:hover { background: #00000010; }   /* colour is already --ink; hover is the wash alone */
.voice-btn .voice-icon { width: 23px; height: 23px; }
.voice-btn.is-recording { background: var(--accent); color: #fff; animation: vpulse 1.4s ease-in-out infinite; }
@keyframes vpulse { 0%,100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent); } 50% { box-shadow: 0 0 0 8px transparent; } }
/* Unavailable while the agent is answering (setBusy), like the rest of the
   composer. Excludes .is-transcribing, which is ALSO disabled but owns its own
   spinner look and must not be dimmed. */
.voice-btn:disabled:not(.is-transcribing) { opacity: 0.4; cursor: default; }
.voice-btn:disabled:not(.is-transcribing):hover { background: transparent; }  /* opacity already dims it */
.voice-btn.is-transcribing { cursor: wait; }
.voice-btn.is-transcribing .voice-icon { display: none; }
.voice-btn.is-transcribing::after { content: ''; width: 16px; height: 16px; border: 2.5px solid var(--line); border-top-color: var(--ink); border-radius: 50%; animation: spin 0.8s linear infinite; }

.send-btn {
  width: 38px; height: 38px; flex-shrink: 0;
  display: inline-grid; place-items: center;
  background: var(--send-bg, var(--accent)); color: var(--accent-ink); border: none; border-radius: 50%;
  cursor: pointer; transition: filter 0.15s, transform 0.1s;
}
.send-btn:hover { filter: brightness(1.12); }
.send-btn:active { transform: scale(0.96); }
.send-btn:disabled { cursor: progress; }
.send-btn:disabled:hover { filter: none; }
.send-btn .arrow-icon { width: 18px; height: 18px; }
.send-btn.busy .arrow-icon { display: none; }
.send-btn.busy::after { content: ''; width: 18px; height: 18px; border: 2.5px solid color-mix(in srgb, var(--accent-ink) 35%, transparent); border-top-color: var(--accent-ink); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

/* ====================== Suggestion bubbles (3b) ====================== */
.suggestions {
  position: fixed;
  left: 50%; bottom: calc(100px + var(--kb));
  transform: translateX(-50%);
  width: min(640px, calc(100vw - 36px));
  display: flex; flex-direction: column; align-items: flex-start; gap: 9px;
  z-index: 45;
  pointer-events: none;
}
.sugg {
  font: inherit; font-size: 14px; font-weight: 500;
  color: var(--user-ink); background: var(--user-bubble);
  border: none; border-radius: var(--pill);
  padding: 10px 18px; cursor: pointer;
  box-shadow: 0 6px 18px rgba(20, 20, 30, 0.14);
  opacity: 0; transform: translateY(14px) scale(0.9); transform-origin: center bottom;
  /* This is the EXIT state: when .suggesting drops off <body>, the bubbles
     melt back toward the bar (fade + sink - the entry pop in reverse)
     instead of vanishing in one frame. All pills leave TOGETHER - one group
     fade, like the panel (founder feedback: no exit stagger). The
     visible-state rule below swaps in the fast hover transition, so this
     pair only ever times the exit. */
  transition: opacity 0.3s cubic-bezier(0.4, 0.1, 0.55, 1),
              transform 0.3s cubic-bezier(0.4, 0.1, 0.55, 1);
  /* Exit fires with NO per-pill stagger: all bubbles leave as one group
     (explicit single-value delay so it reads as "0s", not "0s, 0s"). */
  transition-delay: 0s;
}
/* Bubbles slightly expand on mouseover (desktop UX) + press feedback on touch.
   Scoped under body.suggesting: the visible-state rule `body.suggesting .sugg`
   sets `transform: none` at specificity (0,2,1), which outranks a bare
   `.sugg:hover` (0,2,0) - the prefix keeps the hover transform winning. */
body.suggesting .sugg:hover { filter: brightness(1.08); transform: translateY(-1px) scale(1.05); }
body.suggesting .sugg:active { transform: scale(0.97); }
/* Glass material: see the consolidated "Glass material" block near the end of
   this file - bubbles, search bar (+ intro dot) and chat panel share it. */
/* Subtle pop - a gentle rise + fade with just a whisper of overshoot, bottom-up cascade.
   End state lives on the rule and the fill is BACKWARDS: once the pop lands the
   animation releases the transform, so the hover lift (a transition) can work
   instead of being pinned by a forwards fill. */
body.suggesting .suggestions { pointer-events: auto; }
body.chatting .suggestions,
body.minimized .suggestions { pointer-events: none; }
body.suggesting .sugg {
  opacity: 1; transform: none; animation: pop 0.4s cubic-bezier(0.25, 0.9, 0.3, 1) backwards;
  /* Visible state: fast transitions so hover/press stay snappy (the exit
     pair above never leaks into hover). */
  transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.3, 1),
              background 0.2s ease, filter 0.18s ease;
}
body.chatting .sugg,
body.minimized .sugg { animation: none; opacity: 0; transform: translateY(14px) scale(0.9); }
body.suggesting .sugg:nth-child(1) { animation-delay: 0.14s; }
body.suggesting .sugg:nth-child(2) { animation-delay: 0.08s; }
body.suggesting .sugg:nth-child(3) { animation-delay: 0.02s; }
@keyframes pop {
  0%   { opacity: 0; transform: translateY(14px) scale(0.9); }
  65%  { opacity: 1; transform: translateY(-1.5px) scale(1.01); }   /* whisper of overshoot */
  100% { opacity: 1; transform: translateY(0) scale(1); }
}

/* ======================= Chat panel (4) ======================= */
.panel {
  position: fixed;
  left: 50%; bottom: calc(112px + var(--kb));
  transform: translateX(-50%) translateY(14px);
  width: min(680px, calc(100vw - 32px));
  max-height: min(62vh, 560px);
  display: flex; flex-direction: column;
  background: var(--panel-bg);
  border: 1px solid var(--line);
  border-radius: 22px;
  box-shadow: var(--shadow-pop);
  opacity: 0; pointer-events: none;
  z-index: 40;
  transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
body.chatting .panel {
  opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto;
  animation: panel-in 0.5s cubic-bezier(0.3, 0.82, 0.3, 1) backwards;
}
/* The chat window bubbles in with a micro-bounce when it opens. */
@keyframes panel-in {
  0%   { opacity: 0; transform: translateX(-50%) translateY(28px) scale(0.965); }
  56%  { opacity: 1; transform: translateX(-50%) translateY(-6px) scale(1.012); }   /* overshoot up + inflate */
  80%  { transform: translateX(-50%) translateY(3px) scale(0.997); }                /* settle down */
  100% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
}
/* Minimize ("-"): the chat window zooms out toward the bar (scale down + fade).
   Kept snappier than the opening bounce: closing is a dismissal, not a show. */
body.minimized .panel { animation: panel-out 0.3s cubic-bezier(0.4, 0, 0.5, 1) forwards; }
@keyframes panel-out {
  0%   { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
  100% { opacity: 0; transform: translateX(-50%) translateY(10px) scale(0.82); }
}

/* Tight header: ~9-10px of air on the buttons' top, bottom AND right edge
   (the tallest control is ~33px, so 9px vertical padding ≈ the 10px right
   inset). The logo side keeps a roomier 16px. */
.panel-head {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 9px 10px 9px 16px; border-bottom: 0.5px solid var(--line);   /* hairline: the frost provides the separation, the line just confirms it */
}
.panel-logo { height: 26px; width: auto; display: block; }
.panel-head-actions { display: flex; align-items: center; gap: 8px; }
.ph-btn {
  display: inline-flex; align-items: center; gap: 7px;
  white-space: nowrap; flex-shrink: 0;   /* a wrapped label reads as a broken oval - the LOGO shrinks instead */
  font: inherit; font-size: 12.5px; font-weight: 600; color: var(--ink);
  background: #fff; border: 1px solid var(--line); border-radius: var(--pill);
  padding: 6px 12px 6px 10px; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s;
}
.ph-btn svg { width: 15px; height: 15px; color: var(--muted); transition: transform 0.45s cubic-bezier(0.22,1,0.36,1), color 0.2s; }
.ph-btn:hover { border-color: #cfd0d4; box-shadow: 0 2px 10px rgba(20,20,30,0.06); }
.ph-btn:hover svg { color: var(--accent); transform: rotate(-180deg); }
.ph-icon {
  display: inline-grid; place-items: center; width: 32px; height: 32px;
  background: #fff; border: 1px solid var(--line); border-radius: 50%;
  color: var(--muted); cursor: pointer; transition: border-color 0.15s, color 0.15s;
}
.ph-icon svg { width: 15px; height: 15px; }
.ph-icon:hover { border-color: #cfd0d4; color: var(--ink); }

.panel-msgs {
  flex: 1; min-height: 0; overflow-y: auto;
  padding: 16px; display: flex; flex-direction: column; gap: 12px;
}
/* Each message / card bubbles in with a micro-bounce as it arrives. */
#messages > * { animation: msg-in 0.36s cubic-bezier(0.3, 0.86, 0.32, 1) backwards; transform-origin: center bottom; }
@keyframes msg-in {
  0%   { opacity: 0; transform: translateY(10px) scale(0.94); }
  58%  { opacity: 1; transform: translateY(-3px) scale(1.018); }   /* micro overshoot */
  80%  { transform: translateY(1px) scale(0.997); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}
.msg { max-width: 86%; padding: 11px 15px; border-radius: 16px; font-size: 14.5px; word-wrap: break-word; }
.msg.user { align-self: flex-end; background: var(--user-bubble); color: var(--user-ink); border-bottom-right-radius: 5px; white-space: pre-wrap; }
.msg.bot { align-self: flex-start; background: var(--bot-bubble); color: var(--bot-ink); border-bottom-left-radius: 5px; }
.msg.bot p { margin: 0 0 9px; } .msg.bot p:last-child { margin: 0; }
.msg.bot strong { font-weight: 700; }
.msg.bot ul, .msg.bot ol { margin: 7px 0; padding-left: 20px; }
.msg.err { align-self: stretch; background: #fdecea; color: #c2240a; border: 1px solid #f4b8b0; font-size: 13px; font-family: ui-monospace, Menlo, monospace; }
/* Thinking/status indicator: a real bubble (like a messenger typing pill) so
   it holds its own on the frosted panel, with inkier dots + label. */
.msg.thinking {
  align-self: flex-start; display: inline-flex; align-items: center; gap: 5px;
  color: color-mix(in srgb, var(--bot-ink) 55%, var(--muted));
  background: var(--bot-bubble); border-bottom-left-radius: 5px;
}
.msg.thinking i { width: 5px; height: 5px; border-radius: 50%; background: currentColor; opacity: 0.5; animation: tdot 1.1s ease-in-out infinite; }
.msg.thinking i:nth-child(2) { animation-delay: 0.16s; } .msg.thinking i:nth-child(3) { animation-delay: 0.32s; }
.msg.thinking .tl { font-size: 12.5px; color: inherit; margin-left: 4px; }
.msg.thinking .tl:empty { display: none; }
@keyframes tdot { 0%,80%,100% { opacity: 0.5; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
.card-todo { align-self: stretch; font-size: 12px; color: var(--muted); font-style: italic; border: 1px dashed var(--line); border-radius: 10px; padding: 8px 12px; background: #fff; }

.powered {
  display: flex; align-items: center; justify-content: flex-end; gap: 4px;
  padding: 9px 16px; font-size: 11px; color: var(--ink);  /* always dark, matching the black lockup */
  border-top: 0.5px solid var(--line); text-decoration: none;
}
.powered:hover { opacity: 0.72; }  /* still reads as a link */
.powered .pw-mark { height: 14px; width: auto; }
.powered .pw-word { height: 10px; width: auto; }

@media (max-width: 720px) {
  :root {
    --mobile-bottom: calc(22px + env(safe-area-inset-bottom, 0px) + var(--kb));
    --mobile-bar-height: 54px;
    --mobile-panel-bottom: calc(var(--mobile-bottom) + var(--mobile-bar-height) + 18px);
    --mobile-long-width: calc(100vw - 24px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
    --mobile-short-width: min(420px, calc(100vw - 72px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)));
    --dot-w: var(--mobile-bar-height);
    --bar-target-w: var(--mobile-short-width);   /* the morph lands on the true mobile bar width */
    --bar-bottom: var(--mobile-bottom);
    /* The dot lands in the BAR's column: its right margin IS the bar's own side
       margin. Derived, not guessed - the previous 18px matched neither the bar's
       side margin (36px) nor its bottom offset (22px), so the dot sat ~18px
       further out than everything else. The wrap is centred, so this equals the
       real margin on any width and with any safe-area inset. */
    --dock-right: calc((100vw - var(--bar-target-w)) / 2);
  }

  .composer-wrap {
    width: var(--mobile-short-width);
    transition: width 0.34s cubic-bezier(0.4, 0, 0.2, 1);
  }

  .composer-wrap:focus-within,
  body.chatting .composer-wrap {
    width: var(--mobile-long-width);
  }

  body.minimized .composer-wrap {
    width: var(--mobile-short-width);
  }

  .suggestions {
    width: var(--mobile-long-width);
    bottom: var(--mobile-bottom);
  }

  .composer {
    min-height: var(--mobile-bar-height);
    padding: 4px 5px 4px 18px;
    max-width: none;
  }
  /* Every state that pins a desktop max-width defers to the wrap on mobile -
     the bar fills the wrap exactly, so the suggestion pills (same container
     width) left-align with the bar's actual edge. */
  .composer-wrap:focus-within .composer,
  body.chatting .composer,
  body.minimized .composer,
  .composer-wrap:has(.voice-btn.is-recording) .composer,
  .composer-wrap:has(.voice-btn.is-transcribing) .composer {
    max-width: none;
    animation: none;
  }

  #p-input {
    font-size: 16px;
    line-height: 1.4;
    max-height: 44px;
    padding: 10px 0;
    white-space: nowrap;
    overflow: hidden;
    scrollbar-width: none;
  }

  #p-input::-webkit-scrollbar { display: none; }

  .voice-btn,
  .send-btn {
    width: 44px;
    height: 44px;
  }

  .voice-btn .voice-icon { width: 24px; height: 24px; }   /* +4, matching the desktop bump */
  .send-btn .arrow-icon { width: 20px; height: 20px; }

  .suggestions {
    bottom: var(--mobile-panel-bottom);
    align-items: flex-start;
    gap: 8px;
  }

  .sugg {
    width: auto;
    min-height: 44px;
    max-width: 100%;
    padding: 10px 16px;
    font-size: 13px;
    text-align: left;
    /* One line, always: a pill that wraps reads as a paragraph. Long lines
       ellipsize (the full query is still sent on tap). */
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  body.suggesting .sugg { animation-duration: 0.5s; }
  body.suggesting .sugg:nth-child(1) { animation-delay: 0.38s; }
  body.suggesting .sugg:nth-child(2) { animation-delay: 0.28s; }
  body.suggesting .sugg:nth-child(3) { animation-delay: 0.18s; }
  /* The group exit fade keeps the slower mobile rhythm too. */
  .sugg { transition-duration: 0.34s; }

  .panel {
    width: calc(100vw - 20px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
    bottom: var(--mobile-panel-bottom);
    /* Keyboard budget: heights come from the VISUAL viewport (--vvh, pan-
       invariant, published by shell_core), counted exactly ONCE - the
       "- panel-bottom + kb" pair cancels the lift so only the un-lifted base
       is subtracted. iOS pans dynamically while typing; the old double
       subtraction squashed the panel to a sliver on real devices. */
    max-height: min(68vh, calc(var(--vvh, 100vh) - var(--mobile-panel-bottom) + var(--kb) - env(safe-area-inset-top, 0px) - 12px));
    max-height: min(68dvh, calc(var(--vvh, 100dvh) - var(--mobile-panel-bottom) + var(--kb) - env(safe-area-inset-top, 0px) - 12px));
    border-radius: 18px;
  }

  .panel-head {
    /* Tighter on phones too, but the 44px touch targets below stay intact. */
    padding: 8px 10px 8px 14px;
    gap: 8px;
  }

  .panel-logo {
    height: 24px; min-width: 0;
    /* Wide brand lockups give way to the nowrap buttons: the img box shrinks
       (min-width 0) and the artwork scales inside it, left-anchored. */
    object-fit: contain; object-position: left center;
  }
  .panel-head-actions { gap: 6px; }

  .ph-btn {
    min-height: 44px;
    padding: 8px 12px;
  }

  .ph-icon {
    width: 44px;
    height: 44px;
  }

  .panel-msgs {
    padding: 14px 12px;
    gap: 10px;
  }

  .msg {
    max-width: 92%;
    font-size: 14px;
  }

}

@media (max-width: 360px) {
  .ph-btn {
    width: 44px;
    padding: 0;
    justify-content: center;
  }

  .ph-btn span { display: none; }
}

/* ============== Cart quick actions (Carrello + Checkout pills) ============== */
/* Glass pills giving the cart a permanent presence from the first item onward:
   floating right-aligned above the bar while the panel is closed; docked in the
   panel footer (left of the gentika lockup) while chatting. Solid fallbacks
   here; the glass material lands in the @supports block below. */
.cart-bar { display: flex; gap: 8px; flex-wrap: wrap; opacity: 0; pointer-events: none; transition: opacity 0.3s ease, transform 0.3s ease; }
.cart-bar.show { opacity: 1; pointer-events: auto; }
/* In-bar cart (v2.4): while the chat is CLOSED the cart's only presence is a
   quiet ghost icon in the bar's action cluster - the idle scene stays clean
   (no floating pills, no checkout push; checkout lives inside the cart
   window). While chatting the panel foot owns the cart, so the icon yields. */
.cart-mini {
  position: relative;
  display: none;
  place-items: center;
  width: 38px; height: 38px; flex-shrink: 0;
  border-radius: 50%; border: none; background: transparent; color: var(--muted);
  cursor: pointer; transition: background 0.15s, color 0.15s;
}
.cart-mini.show { display: inline-grid; animation: pop 0.4s cubic-bezier(0.25, 0.9, 0.3, 1) backwards; }
body.chatting .cart-mini { display: none; }
.cart-mini:hover { background: #00000008; color: var(--ink); }
.cart-mini-ico { width: 19px; height: 19px; }
.cart-mini-count {
  position: absolute; top: 1px; right: -1px;
  background: var(--accent); color: var(--accent-ink);
  border-radius: 999px; min-width: 15px; height: 15px; padding: 0 4px;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 9.5px; font-weight: 800; line-height: 1;
  box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--bar-bg) 90%, transparent);
}
.cart-mini-count:empty { display: none; }
@media (max-width: 720px) {
  /* Placed AFTER the base rules - the 720px block earlier in the file would
     lose the source-order tie against them. 44px touch target, like the mic. */
  .cart-mini { width: 44px; height: 44px; }
  .cart-mini-ico { width: 20px; height: 20px; }
  /* Foot pills keep the 44px touch target on phones - the header buttons
     carry the same mobile min-height, so the two stay matched there too. */
  .cb-btn { min-height: 44px; padding: 8px 16px; }
}
.panel-foot {
  display: flex; align-items: center; gap: 10px;
  flex-wrap: wrap; row-gap: 4px;   /* long labels + lockup: wrap, never squeeze */
  border-top: 0.5px solid var(--line);
  padding: 7px 16px;
}
.cart-bar-panel:not(.show) { display: none; }
/* The lockup anchors itself to the right (margin-left auto) so it stays
   bottom-right whether or not the cart pills are present on the left. */
.panel-foot .powered { border-top: none; padding: 2px 0; margin-left: auto; }
.cb-btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  border-radius: var(--pill); padding: 6px 14px;
  /* Same scale as the panel-head buttons ("New chat"): the foot pills belong
     to the same chrome family as the header - bigger read as content. */
  font: inherit; font-size: 12.5px; font-weight: 600;
  white-space: nowrap; flex-shrink: 0;   /* labels never compress into two lines - the row wraps instead */
  cursor: pointer; border: 1px solid var(--line);
  transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s ease;
}
.cb-btn:hover { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(20, 20, 30, 0.14); }
.cb-btn:active { transform: none; box-shadow: none; }
.cb-cart { background: var(--bar-bg); color: var(--ink); }
.cb-checkout { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
.cb-ico { width: 15px; height: 15px; }
.cb-count {
  background: var(--accent); color: var(--accent-ink);
  border-radius: 999px; min-width: 17px; height: 17px; padding: 0 5px;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 10px; font-weight: 800;
}
.cb-count:empty { display: none; }
.cb-arrow { font-weight: 800; transition: transform 0.15s ease; }
.cb-checkout:hover .cb-arrow { transform: translateX(2px); }

/* ===================== Glass material (semi-transparent + blur) ============ */
/* One frosted material for every floating surface, tuned on the Instagram
   floating-toolbar reference: a very translucent fill (most of the colour
   comes from the blurred, saturated backdrop showing through), blur strong
   enough to dissolve what's behind into soft masses, and a light rim that
   catches the edge. Applied to the suggestion bubbles, the search bar - and
   the intro dot that morphs into it, so the landing swap stays invisible -
   and the chat panel (slightly more opaque: it's a reading surface). The
   solid token colours defined above remain the fallback wherever
   backdrop-filter isn't supported. NOTE: this block must stay AFTER the base
   .composer/.panel rules - it wins by cascade order, not specificity. */
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .sugg {
    background: color-mix(in srgb, var(--user-bubble) 42%, transparent);
    -webkit-backdrop-filter: blur(18px) saturate(1.6);
    backdrop-filter: blur(18px) saturate(1.6);
    border: 1px solid rgba(255, 255, 255, 0.22);
    box-shadow: 0 8px 24px rgba(20, 20, 30, 0.20),
                inset 0 1px 0 rgba(255, 255, 255, 0.15);
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);  /* white text stays crisp over bright forest spots */
  }
  .sugg:hover { background: color-mix(in srgb, var(--user-bubble) 58%, transparent); }

  /* Same glass in EVERY state - idle, expanded, focused, chatting. (An earlier
     pass firmed the fill up on :focus-within, which is precisely when the bar
     is stretched open - it read as solid. The bar stays translucent; the
     inkier placeholder below is what keeps it readable on the dark floor.) */
  .composer,
  .intro-dot::before {
    background: color-mix(in srgb, var(--bar-bg) 65%, transparent);
    -webkit-backdrop-filter: blur(20px) saturate(1.5);
    backdrop-filter: blur(20px) saturate(1.5);
    border-color: rgba(255, 255, 255, 0.45);
  }
  #p-input::placeholder { color: color-mix(in srgb, var(--ink) 35%, var(--muted)); }
  /* .voice-btn used to be in this list, matching the placeholder ink. It is not
     any more: the mic is a control, not a hint, and at 23px beside a solid black
     send button that mix read faint. It keeps --ink from its base rule. This
     block is inside @supports, so it silently outranked that rule - the reason
     an earlier attempt to darken the mic changed nothing. */
  .cart-mini { color: color-mix(in srgb, var(--ink) 35%, var(--muted)); }

  .panel {
    background: color-mix(in srgb, var(--panel-bg) 65%, transparent);
    -webkit-backdrop-filter: blur(22px) saturate(1.5);
    backdrop-filter: blur(22px) saturate(1.5);
    border-color: rgba(255, 255, 255, 0.35);
  }

  /* "Nuova Chat" + minimize: the same glass, one step firmer than the panel
     they sit on. NB: these are WHITE fills over a near-white frost - at high
     percentages the translucency is imperceptible (looked solid in testing),
     so they run more transparent than their nominal "step" suggests. */
  .ph-btn,
  .ph-icon {
    background: color-mix(in srgb, var(--bar-bg) 55%, transparent);
    -webkit-backdrop-filter: blur(12px) saturate(1.4);
    backdrop-filter: blur(12px) saturate(1.4);
    border-color: rgba(255, 255, 255, 0.5);
  }
  .ph-btn:hover,
  .ph-icon:hover {
    background: color-mix(in srgb, var(--bar-bg) 75%, transparent);
  }

  /* Conversation bubbles: translucent, one step firmer than the panel (65%).
     Deliberately NO per-bubble backdrop-filter: bubbles sit in flow and never
     overlap anything but the panel's own uniform frost, so a translucent fill
     reads identically to real glass - while dozens of blurred layers in a
     long conversation would cost GPU for nothing. (Card surfaces get the same
     treatment in prototype_cards.css.) */
  .msg.bot,
  .msg.thinking { background: color-mix(in srgb, var(--bot-bubble) 78%, transparent); }
  /* Cart pills: the same glass family - Carrello like the bar, Checkout as
     accent-tinted glass (the CTA keeps its punch through the frost). */
  .cb-cart {
    background: color-mix(in srgb, var(--bar-bg) 65%, transparent);
    -webkit-backdrop-filter: blur(14px) saturate(1.5);
    backdrop-filter: blur(14px) saturate(1.5);
    border-color: rgba(255, 255, 255, 0.45);
  }
  .cb-cart:hover { background: color-mix(in srgb, var(--bar-bg) 80%, transparent); }
  .cb-checkout {
    background: color-mix(in srgb, var(--accent) 72%, transparent);
    -webkit-backdrop-filter: blur(14px) saturate(1.6);
    backdrop-filter: blur(14px) saturate(1.6);
    border: 1px solid rgba(255, 255, 255, 0.35);
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
  }
  .cb-checkout:hover { background: color-mix(in srgb, var(--accent) 86%, transparent); }

  /* Dark fill over a light frost also hides translucency - run it lower than
     the light surfaces; the text-shadow keeps white text crisp (as on the
     suggestion chips, which use the same recipe). */
  .msg.user {
    background: color-mix(in srgb, var(--user-bubble) 66%, transparent);
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
  }
  .card-todo { background: color-mix(in srgb, #fff 78%, transparent); }
}

/* ===================== Organic motion (modern browsers) ===================== */
/* Real spring physics via linear() easings - curves generated by
   tools/gen_motion.py (a sampled underdamped spring; the ζ values below).
   The bar's elastic expand/shrink moves from keyframe animations to a plain
   max-width TRANSITION with the spring in the easing (linear() outputs >1, so
   a transition CAN overshoot). The real fluidity win: any interruption -
   focus lost mid-expand, minimize mid-bounce, a quick in-and-out - retargets
   from the CURRENT width instead of snapping to a keyframe's start value.
   Pop/panel/message keyframes become from-only: the spring easing supplies
   the overshoot on the way to the rule's own end state. Browsers without
   linear() keep the hand-tuned keyframe choreography above. */
@supports (animation-timing-function: linear(0, 0.5, 1)) {
  :root {
    /* ζ=0.52, 0.62s - the bar's elastic snap (+15% overshoot, one swing back) */
    --spring-bouncy: linear(0, 0.051, 0.179, 0.347, 0.529, 0.702, 0.852, 0.972, 1.058, 1.114, 1.142, 1.147, 1.137, 1.117, 1.091, 1.065, 1.04, 1.018, 1.001, 0.99, 0.982, 0.979, 0.978, 0.98, 0.983, 0.987, 0.991, 0.995, 0.998, 1, 1.002, 1.003, 1);
    /* ζ=0.72 - big surfaces (panel, mobile bar width): gentle +4% overshoot */
    --spring-soft: linear(0, 0.027, 0.095, 0.188, 0.295, 0.406, 0.513, 0.614, 0.703, 0.782, 0.847, 0.901, 0.944, 0.977, 1.001, 1.018, 1.029, 1.036, 1.038, 1.038, 1.036, 1.033, 1.029, 1.025, 1.02, 1.016, 1.013, 1.01, 1.007, 1.005, 1.003, 1.001, 1);
    /* ζ=0.60 - small pops (suggestion bubbles, messages): lively +9% */
    --spring-pop: linear(0, 0.038, 0.135, 0.266, 0.411, 0.556, 0.69, 0.806, 0.902, 0.977, 1.031, 1.066, 1.086, 1.094, 1.093, 1.085, 1.074, 1.06, 1.046, 1.033, 1.022, 1.012, 1.004, 0.999, 0.995, 0.992, 0.991, 0.991, 0.992, 0.993, 0.994, 0.995, 1);
    /* ζ=0.42 - the panel's opening bounce: +23% overshoot, one clear rebound */
    --spring-bounce: linear(0, 0.078, 0.268, 0.51, 0.751, 0.958, 1.109, 1.199, 1.233, 1.222, 1.182, 1.127, 1.07, 1.02, 0.981, 0.957, 0.946, 0.947, 0.955, 0.967, 0.981, 0.993, 1.003, 1.009, 1.012, 1.013, 1.011, 1.008, 1.005, 1.002, 1, 0.998, 1);
  }

  .composer {
    transition: max-width 0.62s var(--spring-bouncy),
                box-shadow 0.3s ease, border-color 0.3s ease;
  }
  /* Closing the chat is as ELASTIC as opening it: the same bouncy spring,
     narrowing past the idle width and swinging back (mirrors the historical
     bar-shrink keyframes). The MAIN travel still lands inside the panel's
     0.3s fade - the wobble that follows is character, not desync. Transitions
     take the DESTINATION state's timing, so this only touches the close. */
  body.minimized .composer {
    transition: max-width 0.5s var(--spring-bouncy),
                box-shadow 0.3s ease, border-color 0.3s ease;
  }
  /* The spring transition replaces every keyframe bounce on the bar. */
  .composer-wrap:focus-within .composer,
  body.chatting .composer,
  body.minimized .composer,
  body.bar-collapsing .composer { animation: none; }

  body.suggesting .sugg { animation: pop 0.5s var(--spring-pop) backwards; }
  @keyframes pop {
    from { opacity: 0; transform: translateY(16px) scale(0.92); }
  }
  body.chatting .panel { animation: panel-in 0.65s var(--spring-bounce) backwards; }
  @keyframes panel-in {
    from { opacity: 0; transform: translateX(-50%) translateY(34px) scale(0.96); }
  }
  #messages > * { animation: msg-in 0.45s var(--spring-pop) backwards; }
  @keyframes msg-in {
    from { opacity: 0; transform: translateY(12px) scale(0.96); }
  }

  @media (max-width: 720px) {
    /* Mobile expands via the wrap's WIDTH; soften the spring (the long width
       is nearly edge-to-edge, a +15% overshoot would kiss the viewport edge)
       and stop animating max-width - the wrap is the binding constraint, and
       a bouncy max-width falling toward calc(100vw - 36px) mid-expand would
       fight it with a visible wobble. */
    .composer-wrap { transition: width 0.5s var(--spring-soft); }
    /* Mobile close: same elastic spring - the overshoot narrows INWARD
       (away from the viewport edges), so bouncy is safe here, unlike the
       expand above. */
    body.minimized .composer-wrap { transition: width 0.5s var(--spring-bouncy); }
    .composer {
      transition: max-width 0s ease,
                  box-shadow 0.3s ease, border-color 0.3s ease;
    }
  }
}

/* ======================= Dock / launcher (bar minimize) ======================= */
/* Minimization_UX study: hovering the pill reveals a "−" satellite; clicking it
   collapses the bar rightward into a round launcher dot docked bottom-right;
   clicking the dot restores the bar. States (driven by shell_core timers):
   docking (droplet morph to the corner) → docked (wrap gone, launcher shown) →
   undocking (droplet morph back).
   The GEOMETRY animates on the WRAP while the frosted .composer rides at
   width/height:100% - the intro-land trick (see the .intro-dot note): the glass
   must never carry an effect of its own or Chrome freezes its backdrop-filter,
   and here the pill travels far enough for a frozen frost to smear visibly.
   The docked resting position is pure CSS (fixed-position % = viewport), so it
   stays correct through resizes while docked; the only JS measurement is the
   morph's START width (the pill may be expanded when docking begins). */

/* --- the gooey reveal (macOS Tahoe Spotlight: the button pinches OFF the bar) ---
   A silhouette copy of the pill plus a droplet, run through the #goo filter
   (blur + alpha contrast = metaballs), painted BEHIND the real glass pill: only
   the tongue pinching off the pill's edge is ever visible. The droplet slides
   out of the silhouette, necks, detaches, lands on the satellite's slot - then
   the real glass button sharpens in over it and the silhouette fades to inert.
   The real pill can't run through the filter itself: it would destroy its
   backdrop-filter and text. --goo-w tracks the pill's CURRENT width (idle vs
   expanded), so the droplet always emerges from the true edge. */
.composer-wrap { --goo-w: var(--bar-target-w); }
.goo {
  position: absolute;
  left: calc(50% - (var(--goo-w) / 2)); bottom: 0;
  width: calc(var(--goo-w) + 70px); height: var(--dot-w);
  filter: url(#goo);
  /* Mask off everything under the pill (mask applies AFTER filter, so the neck
     survives): the silhouette used to glow white through the translucent glass -
     the "weird colour change". Only the tongue past the pill's edge ever paints. */
  -webkit-mask: linear-gradient(90deg, transparent calc(var(--goo-w) - 18px), #000 calc(var(--goo-w) - 5px));
          mask: linear-gradient(90deg, transparent calc(var(--goo-w) - 18px), #000 calc(var(--goo-w) - 5px));
  display: none; pointer-events: none;
}
.goo-bar {
  /* Only the pill's right END, not the whole pill: the silhouette shows through
     the translucent glass above it, so the smaller it is, the less the pill
     appears to solidify during the pinch (Apple's bar does go dense mid-morph -
     frames 2-6 of the reference - but briefly). */
  position: absolute; top: 2px; bottom: 2px;
  left: calc(var(--goo-w) - 122px); width: 120px;
  border-radius: 999px; display: block;
  /* The metaball filter forces interiors opaque, so translucency can't come from
     alpha - bake the glass's RENDERED tone in instead: 65% bar-bg over the
     backdrop. Solid --bar-bg here read stark white against everything else. */
  background: color-mix(in srgb, var(--bar-bg) 65%, var(--page-bg));
}
.goo-dot {
  position: absolute; width: 38px; height: 38px;
  top: calc((var(--dot-w) - 38px) / 2);
  left: 0; border-radius: 50%; display: block;
  background: color-mix(in srgb, var(--bar-bg) 65%, var(--page-bg));
  transform: translateX(calc(var(--goo-w) - 56px)) scale(0.8);
  transform-origin: center;
}
@keyframes goo-emerge {
  0%   { transform: translateX(calc(var(--goo-w) - 56px)) scale(0.8, 0.8); }
  38%  { transform: translateX(calc(var(--goo-w) - 24px)) scale(1.3, 0.76); }   /* stretching out - long bead */
  62%  { transform: translateX(calc(var(--goo-w) + 2px))  scale(1.16, 0.85); }  /* neck thinning */
  82%  { transform: translateX(calc(var(--goo-w) + 12px)) scale(0.92, 1.05); }  /* pinch-off - snaps tall */
  100% { transform: translateX(calc(var(--goo-w) + 10px)) scale(0.97, 0.99); }
}
/* The real glass button does the LANDING itself - the goo dies at the pinch, so
   there is never a still, landed silhouette sitting next to the true material to
   compare tones against (the residual "change of color"). */
@keyframes bar-min-in {
  0%   { opacity: 0; transform: scale(0.7); }
  60%  { opacity: 1; transform: scale(1.07); }
  100% { opacity: 1; transform: none; }
}
@keyframes goo-fade {           /* hold through the pinch, out immediately after */
  0%, 62% { opacity: 1; }
  100%    { opacity: 0; }
}

/* The bar's hover AURA (cofounder: the border must wrap the BAR too): a ring
   that extends .composer's own hover ~20px in every direction, so both the
   reveal and the crossing tolerate imprecision around the whole ensemble.
   z-index:-1 is the load-bearing part - it paints UNDER everything, so over
   the pill the real controls (input, mic, send) always win hit-testing; the
   aura only receives events where nothing else exists. It is scoped away from
   every state where the minus cannot exist, so it never sits over the panel's
   footer or the suggestion bubbles (both of which the wrap outpaints, z 50 vs
   40/45 - an unscoped ring would steal their clicks). */
body:not(.chatting):not(.suggesting):not(.docking):not(.docked):not(.undocking)
  .composer-wrap:not(:focus-within) .composer::after {
  content: ''; position: absolute; inset: -120px; z-index: -1;
  /* -120px: the whole ensemble sits in one wide territory (cofounder:
     "significantly bigger", then "more than that"). A click landing in the
     ring targets .composer, i.e. focuses the input - a harmless near-miss in
     the idle states this rule is scoped to. */
}
@media (hover: none) {
  /* No hover chain on touch - a 70px ring would just be a huge invisible
     focus-tap target. Keep a small tap-forgiveness ring only. */
  body:not(.chatting):not(.suggesting):not(.docking):not(.docked):not(.undocking)
    .composer-wrap:not(:focus-within) .composer::after { inset: -12px; }
}

/* --- the "−" satellite --- */
/* Sized to the black SEND button (38px desktop / 44px mobile), not the pill -
   cofounder call: it reads as a sibling of the pill's own controls, riding just
   outside it. Vertically centred on the pill; the glass material is the pill's. */
.bar-min {
  position: absolute;
  left: calc(50% + (var(--bar-target-w) / 2) + 10px);
  bottom: calc((var(--dot-w) - 38px) / 2);   /* pill-centre aligned; anchored to the wrap's */
  width: 38px; height: 38px;                 /* BOTTOM edge - its top moves when #voice-note shows */
  display: grid; place-items: center;
  border-radius: 50%; border: none;
  /* Cofounder spec: semi-transparent light grey with a WHITE bar - the Apple
     overlay-control material, not the pill's glass. 0.35 alpha so the
     translucency reads clearly over the backdrop (was 0.5). */
  background: rgba(122, 122, 130, 0.35); color: #ffffff;
  box-shadow: 0 6px 18px rgba(20, 20, 30, 0.12);
  cursor: pointer;
  opacity: 0; pointer-events: none; transition: none;   /* hidden = INSTANT (house taste) */
}
.bar-min svg { width: 18px; height: 18px; }
/* Frost behind the grey chip where the browser supports it. */
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .bar-min {
    -webkit-backdrop-filter: blur(14px) saturate(1.4);
    backdrop-filter: blur(14px) saturate(1.4);
  }
}
/* The persistence ENVELOPE (cofounder's "border"): once the pill has revealed
   the button, the pointer may roam this invisible padded region - pill edge,
   gap, button, and ~18px around - without losing it; leaving it hides
   instantly. Crucially it is INERT while hidden (pointer-events:none on the
   base), so hovering empty space never reveals anything: the reveal trigger
   stays strictly pill-hover, only the persistence is forgiving. */
/* left inset covers the GAP only (-12 = 10px gap + 2px into the pill's 5px
   right padding). It must never reach the send button: the envelope paints
   above the pill, and -26px put an invisible click-stealer over the send
   button's right 11px - found by hit-testing, clicks there docked instead of
   sending. */
.bar-min::before { content: ''; position: absolute; inset: -24px -20px -24px -12px; }
@media (hover: none) {
  /* Touch has no hover chain to protect - keep only a small tap-forgiveness
     ring, so the envelope never swallows taps meant for the pill below. */
  .bar-min::before { inset: -6px; }
}
@media (hover: hover) {
  /* Shown on hovering the PILL (not the 700px wrap - empty-air hover would read
     as haunted) or the button itself; :where() keeps specificity at floor level
     so the force-hide rules below always win. The 0.15s delay stops a pointer
     merely crossing the bottom of the page from flashing it. */
  .composer-wrap:where(:has(.composer:hover)) .bar-min,
  .composer-wrap .bar-min:where(:hover),
  .composer-wrap .bar-min:where(:focus-visible) {
    opacity: 1; pointer-events: auto;
    /* Pops in AT the pinch-off (0.12s intent delay + ~0.21s to the pinch) with its
       own scale rebound - the glass button IS the landed droplet. fill:backwards,
       NOT both: after the pop ends, the static opacity:1 above must govern, or
       the hover-out grace can't fire (transitions don't run on changes caused by
       an animation being removed - the fill would make every hide a snap). */
    animation: bar-min-in 0.18s cubic-bezier(0.25, 0.9, 0.3, 1) 0.3s backwards;
  }
  .bar-min:hover { background: rgba(98, 98, 106, 0.48); }
  /* The goo underlay plays on the same trigger; its animations restart on every
     display:none -> block flip, i.e. once per hover session. */
  .composer-wrap:where(:has(.composer:hover), :has(.bar-min:hover), :has(.bar-min:focus-visible)) .goo {
    display: block;
    animation: goo-fade 0.34s linear 0.12s both;
  }
  .composer-wrap:where(:has(.composer:hover), :has(.bar-min:hover), :has(.bar-min:focus-visible)) .goo-dot {
    animation: goo-emerge 0.26s linear 0.12s both;
  }
}
@media (hover: none) {
  /* No hover to reveal it: always visible in idle, floating above the pill's
     right corner (phones have no horizontal room beside the bar). */
  .bar-min {
    width: 44px; height: 44px;                                  /* touch target */
    left: calc(50% + (var(--bar-target-w) / 2) - 44px);
    bottom: calc(var(--dot-w) + 10px); transform: none;
    opacity: 1; pointer-events: auto;
  }
}
@media (hover: none) and (max-width: 720px) {
  /* Cofounder: on a phone the chip must line up with the button it BECOMES, not
     with the bar's end. Centre it on the launcher's column - and since the dot's
     width is the bar's height, that column is also the send button's axis, so
     the whole right-hand stack shares one line. Anchored from the wrap's right
     edge, which at this breakpoint IS the bar's right edge (the wrap carries the
     bar's width here; on a WIDE touch screen it does not, hence the max-width). */
  .bar-min { left: auto; right: calc((var(--dot-w) - 44px) / 2); }
}
/* Force-hide: focused/suggesting (typing intent wins - the study's 3a; an open
   bar carries no minimize affordance), while the panel is open (it has its own
   "−"), mid-capture (the bar is stretched for the spinner and a satellite would
   dangle mid-air), and through every dock state. Placed LAST and above floor
   specificity, so they beat both media variants. */
body.suggesting .composer-wrap .bar-min,
.composer-wrap:focus-within .bar-min,
body.chatting .composer-wrap .bar-min,
body.intro .composer-wrap .bar-min,
body.intro-land .composer-wrap .bar-min,
body.docking .composer-wrap .bar-min,
body.docked .composer-wrap .bar-min,
body.undocking .composer-wrap .bar-min,
.composer-wrap:has(.voice-btn.is-recording) .bar-min,
.composer-wrap:has(.voice-btn.is-transcribing) .bar-min {
  /* animation:none is load-bearing: the reveal's bar-min-in fills opacity:1, and
     an animation fill OVERRIDES normal declarations - without this line, a
     focused bar with the pointer still on it keeps the button visible. */
  opacity: 0; pointer-events: none; transition: none; animation: none;
}
/* ...and the goo underlay obeys the same states. */
body.suggesting .composer-wrap .goo,
.composer-wrap:focus-within .goo,
body.chatting .composer-wrap .goo,
body.intro .composer-wrap .goo,
body.intro-land .composer-wrap .goo,
body.docking .composer-wrap .goo,
body.docked .composer-wrap .goo,
body.undocking .composer-wrap .goo,
.composer-wrap:has(.voice-btn.is-recording) .goo,
.composer-wrap:has(.voice-btn.is-transcribing) .goo {
  display: none;
}

/* --- the ride: pill follows the wrap's geometry, contents fade --- */
body.docking .composer, body.undocking .composer {
  width: 100%; max-width: none;
  height: 100%; min-height: 0;      /* 100%, not a fixed size: the droplet frames
                                       below wobble the wrap's HEIGHT and the glass
                                       must follow the deformation */
  overflow: hidden;
}
body.docking #p-input, body.undocking #p-input,
body.docking .composer-actions, body.undocking .composer-actions { opacity: 0; }
body.docking .voice-note, body.undocking .voice-note { display: none; }

/* --- the droplet morph (cofounder: "tensione superficiale di una goccia d'acqua") ---
   GENERATED frames in the gen_motion style: sampled every 3% (dense enough that
   the linear interpolation between stops is imperceptible - a first, sparser
   hand-authored version read as "vibrazione"), linear timing, physics in the
   frames. Layout-only (left/width/height - see the .intro-dot note: an animated
   transform anywhere above the glass would freeze its backdrop-filter).
   The shape language: a smootherstep travel with a cling-y start; the bead
   stretches with VELOCITY (width +10%·v, height −7%·v - motion stretch, not
   scripted poses); on arrival ONE damped squash/rebound (exp·cos, ~1 visible
   swing) instead of a multi-bounce wobble. Width and height move out of phase -
   the incompressible-volume jiggle that reads as liquid.
   --dock-start-w is set inline by dockBar() from the pill's measured width, so
   the morph starts true even from the EXPANDED (640px, suggesting) bar. */
body.docking .composer-wrap {
  animation: dock-morph 0.62s linear both;
  /* The flight is LEVEL: height is fixed here instead of being animated. The
     generated frames used to squash it up to 6.5% and then spike +11% in one
     19ms step at 69% - the bar visibly bobbed instead of travelling straight
     (cofounder). Liquid character now comes from the goo layer and the dot's
     landing bounce, neither of which moves the bar's own edges. */
  height: var(--dot-w);
  pointer-events: none;
}
@keyframes dock-morph {
  0% { left: calc(50.0% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0); width: calc(var(--dock-start-w, var(--bar-target-w)) * 1.0 + var(--dot-w) * 0.0); }
  3% { left: calc(50.001% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0); width: calc(var(--dock-start-w, var(--bar-target-w)) * 1.0001 + var(--dot-w) * 0.0); }
  6% { left: calc(50.013% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0003); width: calc(var(--dock-start-w, var(--bar-target-w)) * 1.0005 + var(--dot-w) * 0.0003); }
  9% { left: calc(50.056% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0011); width: calc(var(--dock-start-w, var(--bar-target-w)) * 1.0011 + var(--dot-w) * 0.0011); }
  12% { left: calc(50.158% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0032); width: calc(var(--dock-start-w, var(--bar-target-w)) * 1.0013 + var(--dot-w) * 0.0032); }
  15% { left: calc(50.352% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.007); width: calc(var(--dock-start-w, var(--bar-target-w)) * 1.0008 + var(--dot-w) * 0.0071); }
  18% { left: calc(50.671% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0134); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.9986 + var(--dot-w) * 0.0136); }
  21% { left: calc(51.148% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.023); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.9942 + var(--dot-w) * 0.0234); }
  24% { left: calc(51.813% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0363); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.9866 + var(--dot-w) * 0.0371); }
  27% { left: calc(52.694% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0539); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.9751 + var(--dot-w) * 0.0555); }
  30% { left: calc(53.812% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0762); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.959 + var(--dot-w) * 0.0791); }
  33% { left: calc(55.182% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.1036); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.9376 + var(--dot-w) * 0.1084); }
  36% { left: calc(56.813% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.1363); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.9103 + var(--dot-w) * 0.1436); }
  39% { left: calc(58.705% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.1741); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.8769 + var(--dot-w) * 0.1848); }
  42% { left: calc(60.85% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.217); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.8372 + var(--dot-w) * 0.232); }
  45% { left: calc(63.234% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.2647); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.7912 + var(--dot-w) * 0.2848); }
  48% { left: calc(65.832% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.3166); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.7394 + var(--dot-w) * 0.3426); }
  51% { left: calc(68.612% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.3722); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.6823 + var(--dot-w) * 0.4046); }
  54% { left: calc(71.535% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.4307); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.6208 + var(--dot-w) * 0.4697); }
  57% { left: calc(74.557% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.4911); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.556 + var(--dot-w) * 0.5366); }
  60% { left: calc(77.626% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.5525); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.4892 + var(--dot-w) * 0.604); }
  63% { left: calc(80.687% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.6137); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.4218 + var(--dot-w) * 0.6702); }
  66% { left: calc(83.684% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.6737); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.3554 + var(--dot-w) * 0.7336); }
  69% { left: calc(86.557% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.7311); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.2678 + var(--dot-w) * 0.7283); }
  72% { left: calc(89.25% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.785); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.2228 + var(--dot-w) * 0.8135); }
  75% { left: calc(91.71% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.8342); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.1775 + var(--dot-w) * 0.8929); }
  78% { left: calc(93.889% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.8778); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.1325 + var(--dot-w) * 0.9515); }
  81% { left: calc(95.748% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.915); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.0916 + var(--dot-w) * 0.9856); }
  84% { left: calc(97.262% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.9452); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.0579 + var(--dot-w) * 0.9998); }
  87% { left: calc(98.418% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.9684); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.0327 + var(--dot-w) * 1.0023); }
  90% { left: calc(99.226% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.9845); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.0157 + var(--dot-w) * 1.0003); }
  93% { left: calc(99.715% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.9943); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.0057 + var(--dot-w) * 0.9977); }
  96% { left: calc(99.943% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.9989); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.0011 + var(--dot-w) * 0.9962); }
  99% { left: calc(99.999% - (var(--dock-right) + (var(--dot-w) / 2)) * 1.0); width: calc(var(--dock-start-w, var(--bar-target-w)) * 0.0 + var(--dot-w) * 0.9968); }
  100% { left: calc(100% - var(--dock-right) - (var(--dot-w) / 2)); width: var(--dot-w); }
}
/* Restore: the droplet re-forms - gathers at the corner leaning left, detaches,
   streams back and lands as the bar with a small overshoot ripple. Ends at the
   PILL's width/height, so dropping the class is a computed-box no-op. */
body.undocking .composer-wrap {
  /* Lands on --undock-end-w: the SHORT bar by default, or the long focused
     width when the restore is going to focus - so the bar never grows twice
     (short, then long) on its way back. Set by undockBar(). */
  animation: undock-morph 0.82s linear both;   /* the spring tail needs ~450ms to read */
  /* The flight is LEVEL: height is fixed here instead of being animated. The
     generated frames used to squash it up to 6.5% and then spike +11% in one
     19ms step at 69% - the bar visibly bobbed instead of travelling straight
     (cofounder). Liquid character now comes from the goo layer and the dot's
     landing bounce, neither of which moves the bar's own edges. */
  height: var(--dot-w);
  pointer-events: none;
}
/* The landing is the SAME spring the bar opens with (zeta 0.52): it flies in,
   overshoots, crosses UNDER the target and settles. An overshoot that merely
   eases back in does not read as a bounce - that was the previous tail. */
@keyframes undock-morph {
  0% { left: calc(100.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 1.0000); width: calc(var(--dot-w) * 1.0000 + var(--undock-end-w, var(--bar-target-w)) * 0.0000); }
  2% { left: calc(99.962% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.9992); width: calc(var(--dot-w) * 0.9992 + var(--undock-end-w, var(--bar-target-w)) * 0.0008); }
  4% { left: calc(99.713% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.9943); width: calc(var(--dot-w) * 0.9943 + var(--undock-end-w, var(--bar-target-w)) * 0.0061); }
  6% { left: calc(99.096% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.9819); width: calc(var(--dot-w) * 0.9819 + var(--undock-end-w, var(--bar-target-w)) * 0.0191); }
  8% { left: calc(98.008% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.9602); width: calc(var(--dot-w) * 0.9602 + var(--undock-end-w, var(--bar-target-w)) * 0.0420); }
  10% { left: calc(96.393% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.9279); width: calc(var(--dot-w) * 0.9279 + var(--undock-end-w, var(--bar-target-w)) * 0.0761); }
  12% { left: calc(94.234% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.8847); width: calc(var(--dot-w) * 0.8847 + var(--undock-end-w, var(--bar-target-w)) * 0.1217); }
  14% { left: calc(91.556% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.8311); width: calc(var(--dot-w) * 0.8311 + var(--undock-end-w, var(--bar-target-w)) * 0.1782); }
  16% { left: calc(88.410% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.7682); width: calc(var(--dot-w) * 0.7682 + var(--undock-end-w, var(--bar-target-w)) * 0.2446); }
  18% { left: calc(84.874% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.6975); width: calc(var(--dot-w) * 0.6975 + var(--undock-end-w, var(--bar-target-w)) * 0.3192); }
  20% { left: calc(81.045% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.6209); width: calc(var(--dot-w) * 0.6209 + var(--undock-end-w, var(--bar-target-w)) * 0.3999); }
  22% { left: calc(77.035% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.5407); width: calc(var(--dot-w) * 0.5407 + var(--undock-end-w, var(--bar-target-w)) * 0.4846); }
  24% { left: calc(72.965% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.4593); width: calc(var(--dot-w) * 0.4593 + var(--undock-end-w, var(--bar-target-w)) * 0.5704); }
  26% { left: calc(68.955% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.3791); width: calc(var(--dot-w) * 0.3791 + var(--undock-end-w, var(--bar-target-w)) * 0.6551); }
  28% { left: calc(65.126% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.3025); width: calc(var(--dot-w) * 0.3025 + var(--undock-end-w, var(--bar-target-w)) * 0.7358); }
  30% { left: calc(61.590% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.2318); width: calc(var(--dot-w) * 0.2318 + var(--undock-end-w, var(--bar-target-w)) * 0.8104); }
  32% { left: calc(58.444% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.1689); width: calc(var(--dot-w) * 0.1689 + var(--undock-end-w, var(--bar-target-w)) * 0.8768); }
  34% { left: calc(55.766% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.1153); width: calc(var(--dot-w) * 0.1153 + var(--undock-end-w, var(--bar-target-w)) * 0.9333); }
  36% { left: calc(53.607% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0721); width: calc(var(--dot-w) * 0.0721 + var(--undock-end-w, var(--bar-target-w)) * 0.9789); }
  38% { left: calc(51.992% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0398); width: calc(var(--dot-w) * 0.0398 + var(--undock-end-w, var(--bar-target-w)) * 1.0130); }
  40% { left: calc(50.904% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0181); width: calc(var(--dot-w) * 0.0181 + var(--undock-end-w, var(--bar-target-w)) * 1.0359); }
  42% { left: calc(50.287% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0057); width: calc(var(--dot-w) * 0.0057 + var(--undock-end-w, var(--bar-target-w)) * 1.0489); }
  44% { left: calc(50.038% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0008); width: calc(var(--dot-w) * 0.0008 + var(--undock-end-w, var(--bar-target-w)) * 1.0542); }
  46% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0550); }
  48% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0483); }
  50% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0393); }
  52% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0289); }
  54% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0182); }
  56% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0080); }
  58% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 0.9989); }
  60% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 0.9915); }
  62% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 0.9859); }
  64% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 0.9823); }
  66% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 0.9806); }
  68% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 0.9806); }
  70% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 0.9820); }
  72% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 0.9845); }
  74% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 0.9877); }
  76% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 0.9912); }
  78% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 0.9948); }
  80% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 0.9982); }
  82% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0011); }
  84% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0034); }
  86% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0051); }
  88% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0061); }
  90% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0066); }
  92% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0064); }
  94% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0058); }
  96% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0049); }
  98% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0038); }
  100% { left: calc(50.000% - (var(--dock-right) + (var(--dot-w) / 2)) * 0.0000); width: calc(var(--dot-w) * 0.0000 + var(--undock-end-w, var(--bar-target-w)) * 1.0000); }
}


body.docked .composer-wrap { display: none; }

/* --- the launcher dot --- */
.launcher {
  position: fixed;
  right: var(--dock-right); bottom: var(--bar-bottom);
  width: var(--dot-w); height: var(--dot-w);
  border-radius: 50%; border: none;
  background: var(--send-bg, var(--accent));
  display: none; place-items: center;
  z-index: 51; cursor: pointer;
  box-shadow: 0 10px 28px rgba(20, 20, 30, 0.22);
}
.launcher .launcher-mark {
  width: 52%; aspect-ratio: 1; display: block;
  background: var(--accent-ink, #ffffff);
  -webkit-mask: url("assets/gentika-mark-black.png") center / contain no-repeat;
          mask: url("assets/gentika-mark-black.png") center / contain no-repeat;
}
/* The cart badge on the docked dot. Proportions measured off the cofounder's
   mockup: ~48% of the dot, its centre up and left of the dot's, overlapping the
   edge. Palette is inverted from the dot via the same two tokens, so it reads on
   any brand: fill = --accent-ink (the "ink on accent" colour), ring and glyph =
   the dot's own --send-bg. The ring is invisible where it crosses the dot (same
   colour) and defines the badge against the page - exactly as drawn. */
.launcher-cart {
  position: absolute;
  top: calc(var(--dot-w) * -0.19); left: calc(var(--dot-w) * -0.14);
  width: calc(var(--dot-w) * 0.48); height: calc(var(--dot-w) * 0.48);
  box-sizing: border-box;
  display: none; place-items: center;
  border-radius: 50%;
  background: var(--accent-ink, #ffffff);
  color: var(--send-bg, var(--accent));
  border: 2px solid var(--send-bg, var(--accent));
  /* No separate action - but it is a CHILD of the button, so a tap on the half
     that overhangs the dot still restores the bar instead of falling through
     to the page. Decorative in behaviour, not a dead zone. */
}
.launcher-cart svg { width: 58%; height: 58%; }
.launcher.has-cart .launcher-cart { display: grid; }
.launcher:hover { filter: brightness(1.12); }
.launcher:active { transform: scale(0.96); }
/* display:none→grid restarts the animation, so the same rule pops it on a
   persisted boot AND at the end of every dock - no extra class needed. */
body.docked .launcher { display: grid; animation: launcher-pop 0.55s cubic-bezier(0.3, 0.7, 0.35, 1) backwards; }
body.undocking .launcher { display: none; }   /* material swap under the identical-geometry dot */
/* Cofounder: "effetto rimbalzo" when the bar becomes the corner button - the dot
   lands with a real bounce (overshoot, dip, settle), not just a soft pop. */
@keyframes launcher-pop {
  0%   { opacity: 0; transform: scale(0.5); }
  55%  { opacity: 1; transform: scale(1.12); }
  75%  { transform: scale(0.97); }
  90%  { transform: scale(1.02); }
  100% { opacity: 1; transform: none; }
}
@supports (animation-timing-function: linear(0, 0.5, 1)) {
  /* House pattern: the spring easing supplies the bounce, keyframe goes from-only.
     From 0.5 the ζ=0.24 spring peaks at scale ~1.12 and dips to ~0.97. */
  body.docked .launcher { animation: launcher-pop-s 0.6s var(--spring-bounce) backwards; }
  @keyframes launcher-pop-s { from { opacity: 0; transform: scale(0.5); } }
}

@media (prefers-reduced-motion: reduce) {
  body.intro .composer-wrap { animation: none; }
  .composer, .composer-wrap, .sugg, .panel, .ph-btn svg, .cart-bar { transition: none; }
  .composer-wrap:focus-within .composer, body.chatting .composer { animation: none; }
  body.suggesting .sugg { animation: none; opacity: 1; transform: none; }
  body.chatting .panel, #messages > * { animation: none; }
  body.minimized .composer, body.minimized .panel, body.bar-collapsing .composer { animation: none; }
  body.docking .composer-wrap, body.undocking .composer-wrap { animation: none; }
  body.docked .launcher { animation: none; }
  .bar-min { transition: none; animation: none; }
  .goo { display: none !important; }   /* reveal degrades to a plain instant appear */
}
