/* 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. */
@font-face {
  font-family: "Geist";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("/static/fonts/geist-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Geist";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("/static/fonts/geist-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

: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; }

html, body {
  margin: 0; height: 100%;
  min-height: 100vh;
  min-height: 100dvh;
  background: var(--page-bg);
  color: var(--ink);
  font-family: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  font-size: 15px; line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  transition: background 0.4s ease;
}
body {
  overflow: hidden;
  /* Optional photographic backdrop: shell_render.py emits --page-bg-image as a
     url(...) token (plus a <link rel=preload>) when brand_config sets
     theme.page_bg_image; without it the flat --page-bg colour shows. The page
     never scrolls, so no background-attachment tricks are needed. */
  background-color: var(--page-bg);
  background-image: var(--page-bg-image, none);
  background-position: center;
  background-size: cover;
  background-repeat: no-repeat;
}

/* ============================ 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 */
}
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 {
  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: 120px; padding: 10px 0;
}
#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;
  border-radius: 50%; border: none; background: transparent; color: var(--muted);
  cursor: pointer; transition: background 0.15s, color 0.15s, transform 0.1s;
}
.voice-btn[hidden] { display: none; }
.voice-btn:hover { background: #00000008; color: var(--ink); }
.voice-btn .voice-icon { width: 19px; height: 19px; }
.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; } }
.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;
  transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.3, 1),
              background 0.2s ease, filter 0.18s ease;
}
/* 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; }
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);
  }

  .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: 20px; height: 20px; }
  .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; }

  .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)); }
  /* The idle mic matches the placeholder ink exactly (hover/recording states
     keep their own colours). */
  .voice-btn,
  .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;
    }
  }
}

@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; }
}
