/* Prompt-page styles for tip_compact.html, the only prompt-page
   template the donation flow ships. Layered on top of style.css, which
   still carries shared form-control / image-picker / voice-card
   primitives.

   All color / surface / radius / typography values come from
   /static/brand.css tokens (--bg, --panel, --accent, --radius*, etc.).
   See AGENTS.md PINNED RULE #3 — single source of truth for tokens.

   What lives here:
     - .compact-main single-column shell
     - .prompt-card container with dividers
     - .amount-pill--compact / .custom-amount-details collapsible
     - .message-shell textarea + bottom-right counter
     - .tile-row picker (Model / Name+Voice bento / GIF)
     - .disclaimer--compact button-wrap terms notice
     - .cta-button full-width CTA (tier styling tracks selected amount)
     - .sheet bottom-sheet system (scrim + slide-up panel + grabber)
     - .toggle pill switch used inside sheets (anon / disable TTS)
     - Model accent dots — token already defined in brand.css
       (--model-claude / --model-chatgpt / --model-kimi).
*/

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* ---- Page shell ----------------------------------------------------- */
html.prompt-compact-root,
html.prompt-compact-root body.compact-layout {
  width: 100%;
  height: 100%;
  min-height: 100%;
  overflow: hidden;
  overscroll-behavior: none;
}
html.prompt-compact-root body.compact-layout {
  position: fixed;
  inset: 0;
}
/* Real-app fit-to-viewport layout:
   - compact-main fills 100dvh below the fixed top-bar
   - title sits at the top at its natural size
   - stream-embed flex-grows to absorb every remaining pixel between the
     title and the form
   - prompt-card pins to the bottom at its natural size
   No vertical scroll on any phone — the stream just resizes. */
.compact-main {
  /* Full viewport width — the prompt page intentionally matches the
     fixed top-bar's full-bleed shape so the donation surface fills the
     screen edge-to-edge on every viewport (2026-05-22). Earlier
     revisions capped this at 560px (520px ≥720px) and centered the
     column; on desktop that left a large visible background gutter on
     either side of the prompt-card and made the page read as a narrow
     mobile-only column instead of a real product surface. */
  margin: 0 auto;
  height: 100vh;
  height: 100dvh;
  /* Bottom padding is a small hairline (4px) so the full .prompt-card
     frame — rounded bottom corners + bottom border — sits visibly
     above the viewport edge. On iPhones with a home indicator,
     env(safe-area-inset-bottom) wins and pushes the card above the
     indicator. The card is fully bordered + radius'd; this gutter
     is just enough to show the bottom edge.
     Horizontal padding is 0 here — the per-carousel-cell rule below
     owns the left/right gutter and uses the same
     --brand-header-padding-x token as the fixed top-bar, so the
     prompt-card and player edges align pixel-for-pixel with the
     brand wordmark on every viewport. Keeping the horizontal gutter
     on the cell (rather than the main shell) also means each
     carousel page maintains its own breathing room during a swipe
     drag without the OUTER gutter doubling up. */
  padding: calc(var(--top-bar-h) + 8px) 0 max(4px, env(safe-area-inset-bottom, 4px));
  display: flex;
  flex-direction: column;
  gap: 8px;
  overflow: hidden;
  box-sizing: border-box;
}
.compact-main .stream-head {
  flex: 0 0 auto;
  padding: 0;
}
/* Title runs on a single line in compact — the pixel display font is
   wide, so cap the size and tighten the line-height. */
.compact-main .stream-head h1 {
  font-size: 12px;
  line-height: 1.3;
  letter-spacing: 0.01em;
}
/* Stream embed becomes the flex spacer between title and form. */
.compact-main .stream-embed {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 0;
}
.compact-main .stream-head + .stream-embed {
  margin-top: 0;
}
.compact-main .stream-embed__player {
  flex: 1 1 auto;
  min-height: 0;
  /* Override the global 16:9 aspect-ratio — in fit-to-viewport mode the
     player simply absorbs whatever vertical space is left over. The
     Kick/Twitch players letterbox cleanly at any container shape. */
  aspect-ratio: auto;
  height: auto;
}
/* Stream-embed meta row — smaller pills, tighter spacing, kept inline. */
.compact-main .stream-embed__meta {
  flex: 0 0 auto;
  margin-top: 0;
  gap: 6px;
  padding: 0;
  font-size: 12px;
}
.compact-main .stream-embed__platform {
  padding: 4px 9px;
  font-size: 12px;
  font-weight: 600;
  border-radius: var(--radius-xs);
  gap: 5px;
}
.compact-main .stream-embed__platform .brand-icon {
  width: 12px;
  height: 12px;
}

/* ---- Cross-show page carousel ----------------------------------------
   See `.page-carousel` block below. */
/* Page-level swipe carousel — the OUTER carousel that wraps the entire
   prompt page (title + player + meta + amount + prompt + tiles + CTA).
   The donor can start a horizontal swipe ANYWHERE on the page and the
   whole screen translates together. Every channel is server-rendered
   as its own cell (N-cell preload); only the CURRENT cell owns canonical
   IDs and a live iframe src. Preview cells use data-promotable-id and
   defer stream connection until promotion. Bottom sheets live outside
   <main> (position: fixed) and are unaffected by the translate. */
.compact-main .page-carousel {
  /* Fills the entire <main> rectangle (top-bar already accounted for
     by .compact-main's padding). */
  flex: 1 1 auto;
  min-height: 0;
  position: relative;
  overflow: hidden;
  /* `touch-action: none` is critical on mobile. With `pan-y` the
     browser was claiming any touch with a slight vertical bias as a
     native vertical pan, swallowing the gesture before pointermove
     could decide it was horizontal. The page is fit-to-viewport
     (.compact-main is `overflow: hidden; height: 100dvh`) so there is
     nothing to vertically scroll anyway — handing 100% of touch
     handling to JS is safe and lets the horizontal swipe always win.
     Tap/click semantics still work; only pan/zoom is suppressed. */
  touch-action: none;
  cursor: grab;
  -webkit-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}
.compact-main .page-carousel.is-dragging {
  cursor: grabbing;
}
.compact-main .page-carousel__track {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  /* Do not set `right: 0`/`inset` with an explicit width — some WebKit
     builds resolve the used width to the viewport (N=1) instead of
     N× viewport, which makes %-of-track transforms land between cells. */
  width: calc(100% * var(--cell-count, 1));
  display: flex;
  align-items: stretch;
  /* Resting position aligns --current-index. JS overwrites with px. */
  transform: translate3d(calc(-100% * var(--current-index, 0) / var(--cell-count, 1)), 0, 0);
  /* Keep ms in sync with CAROUSEL_SNAP_MS in script-compact.js */
  transition: transform 420ms linear;
  /* Hint to the compositor — the track only ever animates via transform,
     and the donor swipes it constantly. Keeps the drag silky on lower-
     end mobile GPUs. */
  will-change: transform;
}
.compact-main .page-carousel.is-dragging .page-carousel__track {
  transition: none;
}
.compact-main .page-carousel__cell {
  flex: 0 0 calc(100% / var(--cell-count, 1));
  width: calc(100% / var(--cell-count, 1));
  min-width: 0;
  box-sizing: border-box;
  /* Per-cell horizontal gutter uses the SAME token as `.top-bar`
     (--brand-header-padding-x = 12px mobile, 16px desktop via the
     @media block at the bottom of this file). The active cell sits
     at viewport x=0 with this padding inside it, so the prompt-card
     left edge lands exactly under the brand wordmark's first glyph
     — the donation surface lines up with the header lockup on every
     viewport. During a swipe, adjacent cells contribute their own
     gutter on the opposite side so the two pages still read as
     separate surfaces (~24px / 32px combined gap) as the donor
     drags between channels. */
  padding: 0 var(--brand-header-padding-x);
  display: flex;
  flex-direction: column;
}
/* CSS-driven visual ordering for the cell role rotation. The JS also
   sets `style.order` inline after every rotation; this rule is the
   defense-in-depth fallback so cells are correctly positioned even
   in the brief window between HTML parse and JS init (and in the
   even more unlikely case where the inline style gets stripped by
   a future refactor). The "current" cell always sits between prev
   and next visually — DOM order doesn't matter; flex `order` does.
   See script-compact.js header docs for why we rotate via `order`
   instead of moving DOM nodes (iframe browsing-context preservation). */
.compact-main .page-carousel__cell[data-page-pos="prev"] { order: 0; }
.compact-main .page-carousel__cell[data-page-pos="current"] { order: 1; }
.compact-main .page-carousel__cell[data-page-pos="next"] { order: 2; }
/* The CURRENT cell hosts the original page DOM: channel block + form. */
.compact-main .page-carousel__cell--current {
  /* Same gap as the old .compact-main column. */
  gap: 0;
}
/* Loop bookends are full SSR clones (first/last channel) — always preview-only. */
.compact-main .page-carousel__cell--bookend {
  pointer-events: none;
}
.compact-main .page-content {
  /* Vertical column for the live page: channel section flexes, form is
     natural height. Mirrors the old .compact-main flex shape so the
     player still fills the gap between title and CTA. */
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
/* Full-cell placeholder card shown in the PREV/NEXT cells. JS builds
   the inner content from carouselChannels data on every repaint. */
.compact-main .page-carousel__placeholder {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 24px 16px;
  background: var(--panel);
  color: var(--text-muted);
  text-align: center;
  border-radius: var(--radius);
}
.compact-main .page-carousel__placeholder .brand-icon {
  width: 80px;
  height: 80px;
  color: var(--text);
  opacity: 0.95;
}
.compact-main .page-carousel__placeholder-name {
  font-family: var(--font-display);
  font-size: 12px;
  font-weight: 400;
  letter-spacing: 0.02em;
  word-spacing: -0.45em;
  color: var(--text);
  line-height: 1.6;
  max-width: 90%;
}
.compact-main .page-carousel__placeholder-hint {
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.02em;
  color: var(--text-muted);
  opacity: 0.8;
}
.compact-main .page-carousel__placeholder-arrow {
  display: inline-block;
  font-size: 18px;
  color: var(--text-muted);
  opacity: 0.7;
}

/* Live preview variant used inside PREV/NEXT cells. The JS deep-clones
   the live .page-content into each neighbor cell and adds this class,
   so the donor sees a pixel-real preview of the page they're about to
   slide into (full channel header + player + meta + amount grid +
   message + tile row + terms + CTA + pay logos) — not a skeleton
   scaffold. The cloned iframe ships with no `src` (the video
   connection is deferred until the donor locks a horizontal swipe;
   see warmupNeighborIframes()), but every other DOM node is real.
   Cloning preserves the existing flex shape (.page-content's flex
   column + .channel-carousel's `flex: 1 1 auto`), so the preview
   player gets the same vertical budget as the live player and the
   peeking page doesn't overflow visually.

   Interactivity is suppressed two ways: the JS sets `inert` on the
   clone root (modern browsers disable focus/click/form-submit on the
   whole subtree), and `pointer-events: none` here is the older-
   browser fallback. Tap-to-open on the live cell's tiles still
   works because tile listeners only run on tiles inside the
   non-preview .page-content. */
.compact-main .page-content--preview {
  pointer-events: none;
  -webkit-user-select: none;
  user-select: none;
}
/* The live cell's swipe-catch is the transparent shield above the
   Kick/Twitch iframe (so swipes reach the document listener even
   while the cross-origin player is loaded). On preview cells the
   iframe never holds a live connection, so the shield is redundant
   visual weight; hide it. */
.compact-main .page-content--preview .channel-carousel__swipe-catch {
  display: none;
}

/* ---- Channel block (simple) — used inside the page-carousel current
   cell. No longer carousels horizontally itself; the outer page-
   carousel handles all sliding. Keeps the title / player / meta visual
   stack the donor used to see. */
.compact-main .channel-carousel {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
  /* Padding is 0 here so the stream-preview block (title + player + link
     row) is exactly as wide as the .prompt-card below it. The per-cell
     4px gutter on .page-carousel__cell already keeps adjacent cards from
     touching during swipe drags; the channel block does not need a
     second inset. */
  padding: 0;
  box-sizing: border-box;
}
/* Title block inside each cell — same visual style as the legacy
   .stream-head h1, just nested in a cell so it drags with the player. */
.compact-main .channel-carousel__title {
  flex: 0 0 auto;
}
.compact-main .channel-carousel__title h1 {
  margin: 0;
  /* Same `--font-display` (Press Start 2P) as the AI Plays Games
     wordmark — self-hosted in brand.css via /static/fonts/press-
     start-2p-v16-latin.woff2 so it's already loaded on the page.
     Press Start 2P only ships one weight (400), so visual weight
     reduction comes from a smaller pixel size rather than
     `font-weight: 300`. Color stays full `--text` (white) per the
     brand-token policy in PINNED RULE #3. */
  font-family: var(--font-display);
  font-size: 11px;
  font-weight: 400;
  letter-spacing: 0.02em;
  word-spacing: -0.45em;
  line-height: 1.6;
  color: var(--text);
  /* Allow the title to wrap on narrow viewports instead of truncating.
     `overflow-wrap: anywhere` lets long Press Start 2P tokens break
     inside a word if a single noun is still wider than the cell — the
     icons stay glued to their adjacent nouns because the icon spans
     don't carry a word break. */
  white-space: normal;
  overflow-wrap: anywhere;
  word-break: normal;
}
/* Inline brand-icon next to model/game nouns in the channel title
   (e.g. Minecraft: "[claude] Claude plays [minecraft] Minecraft").
   Sized in em so the icons scale with the h1's font-size and stay
   proportional if the title size changes again. The h1's word-spacing:
   -0.45em (matching the .brand wordmark) eats the natural space
   between text tokens, so we restore breathing room around each icon
   with explicit margin instead. Press Start 2P glyphs sit high in
   their em box (see brand.css note on the wordmark offset-y), hence
   the deeper vertical-align. */
.compact-main .channel-carousel__title-icon {
  width: 1.25em;
  height: 1.25em;
  /* Press Start 2P glyphs sit in the upper third of the em box, so
     `vertical-align: baseline` (or anything negative) drops the icons
     visibly below the rendered text. Push the icons up with a small
     positive value so they read as aligned with the visual midline
     of the pixel-font caps. Per-icon overrides below tune outliers
     whose underlying SVG/PNG has different intrinsic vertical
     padding (e.g. the KSP spacecraft sits higher in its bbox than
     the Claude sunburst and the Minecraft block grid). */
  vertical-align: 0.02em;
  margin: 0 0.35em 0 0.25em;
}
.compact-main .channel-carousel__title-icon[data-icon="ksp"] {
  vertical-align: 0.15em;
}
/* Player slot — relative + min-height: 0 so the absolutely-positioned
   iframe (or placeholder) can fill it and the cell's flex layout still
   collapses correctly when the viewport is short. */
.compact-main .channel-carousel__player {
  flex: 1 1 auto;
  min-height: 0;
  position: relative;
  background: var(--bg-stream);
  border-radius: var(--radius);
  overflow: hidden;
}
/* TV off-air color bars until the Twitch/Kick iframe is deliberately loaded.
   ::before is always present so opacity can transition when swiping away from
   a live embed (avoids an instant snap back to bars). */
.compact-main .channel-carousel__player::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(
    90deg,
    var(--off-air-white) 0%,
    var(--off-air-white) 14.28%,
    var(--off-air-yellow) 14.28%,
    var(--off-air-yellow) 28.57%,
    var(--off-air-cyan) 28.57%,
    var(--off-air-cyan) 42.85%,
    var(--off-air-green) 42.85%,
    var(--off-air-green) 57.14%,
    var(--off-air-magenta) 57.14%,
    var(--off-air-magenta) 71.42%,
    var(--off-air-red) 71.42%,
    var(--off-air-red) 85.71%,
    var(--off-air-blue) 85.71%,
    var(--off-air-blue) 100%
  );
}
/* Transition only on fade-in (class added). Removing is-off-air snaps bars off. */
.compact-main .channel-carousel__player.is-off-air::before {
  opacity: 1;
  transition: opacity 360ms ease;
}
@media (prefers-reduced-motion: reduce) {
  .compact-main .channel-carousel__player.is-off-air::before {
    transition: none;
  }
}
/* Kick offline: SMPTE bars stay up; centered bar replaces the Kick player UI. */
.compact-main .channel-carousel__player.is-stream-offline iframe {
  visibility: hidden;
}
.compact-main .channel-carousel__player .stream-player__offline-badge {
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: 3;
  transform: translate(-50%, -50%);
  min-width: min(72%, 280px);
  padding: 10px 20px;
  border-radius: var(--radius-sm);
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-display);
  font-size: clamp(11px, 2.8vw, 14px);
  letter-spacing: 0.12em;
  text-align: center;
  pointer-events: none;
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--text) 12%, transparent);
}
.compact-main .channel-carousel__player .stream-player__offline-badge[hidden] {
  display: none;
}
.compact-main .channel-carousel__player iframe {
  display: block;
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}
.compact-main .channel-carousel__swipe-catch {
  /* Transparent shield above the cross-origin iframe so horizontal
     swipes reach the parent page instead of being swallowed by the
     Kick/Twitch player. */
  position: absolute;
  inset: 0;
  z-index: 2;
  background: transparent;
  /* This is inside a fullscreen carousel; allowing pan-y lets Mobile
     Safari steal slightly diagonal swipes as page scroll. */
  touch-action: none;
}
/* (The legacy `.channel-carousel__placeholder` rule was used by the
   removed inner channel-carousel cells. The new outer page-carousel's
   placeholder lives on `.page-carousel__placeholder` above.) */
/* Meta row inside each cell — matches legacy .stream-embed__meta. */
.compact-main .channel-carousel__meta {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  font-size: 12px;
}
/* ---- Prompt card ---------------------------------------------------
   Fully bordered card with all four corners rounded — sits near the
   viewport bottom with a 4px (or safe-area-inset on iPhones) gutter
   so the entire frame is visible. The "Powered by Stripe" trust
   footer lives inside the form (last row, under the CTA) so the
   card reads as a self-contained "send your prompt" panel. */
.prompt-card {
  flex: 0 0 auto;
  background: var(--panel);
  border: 1px solid var(--panel-stroke);
  border-radius: var(--radius);
  padding: 10px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
/* The "Send your prompt" heading and the section dividers are noise in
   compact mode — the form's affordances are self-evident. They stay in
   the DOM (smoke-test contract + a11y) but are visually hidden. */
.prompt-card__head {
  display: none;
}
.prompt-card__divider {
  display: none;
}
.prompt-card form {
  --prompt-card-section-gap: 8px;
  display: flex;
  flex-direction: column;
  gap: var(--prompt-card-section-gap);
  margin: 0;
}
/* One flex gap between every row (amount, prompt, tiles, terms, CTA). No
   nested stack wrapper — that duplicated rhythm. Zero margins on rows so
   base .disclaimer / UA <p> defaults cannot stack on top of gap. */
.prompt-card form > .prompt-section,
.prompt-card form > .tile-row,
.prompt-card form > .disclaimer,
.prompt-card form > .disclaimer--compact,
.prompt-card form > .cta-button,
.prompt-card form > .checkout-error {
  margin: 0;
}
.prompt-section {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
/* Tier preset glow (::before/::after on .amount-grid__presets) extends
   14px below the pills; without clip it fills the gap to the textarea and
   makes prompt→tiles look artificially larger than amount→prompt. */
.prompt-card form > .prompt-section:has(.amount-grid--compact) {
  overflow: clip;
}

/* ---- Amount pills (compact) ---------------------------------------
   The amount grid is exactly four cells wide:
     col 1–3: the .amount-grid__presets wrapper holds the three preset
              pills in its own internal 3-col grid.
     col 4:   the Custom pill.
   When the donor activates Custom, .custom-amount-input slides in to
   stack on top of the presets wrapper (same grid-row/grid-column),
   so the on-page layout never reflows — the input simply replaces
   the three preset pills inside the existing four-cell row. The
   stacked layers swap visibility via .is-custom-active on the grid;
   see script-compact.js setCustomActive(). */
.amount-grid--compact {
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 5px;
  margin: 0;
}
.amount-grid__presets {
  /* Same row as the Custom pill, columns 1–3. Internal 3-col grid so
     each preset pill picks up the same min-height + radius treatment
     it had pre-slide. The whole wrapper fades + nudges out when
     Custom is active; the Custom pill stays put in column 4. */
  position: relative;
  isolation: isolate;
  grid-column: 1 / 4;
  grid-row: 1;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 5px;
  transition: opacity 130ms ease-out, transform 130ms ease-out;
  will-change: opacity, transform;
}
.amount-grid__presets::before,
.amount-grid__presets::after {
  content: "";
  position: absolute;
  top: -14px;
  bottom: -14px;
  width: calc((100% - 10px) / 3);
  z-index: 0;
  border-radius: var(--radius-sm);
  pointer-events: none;
  opacity: 0;
  filter: blur(10px);
}
.amount-grid__presets::before {
  left: calc((100% - 10px) / 3 + 5px);
  background:
    radial-gradient(
      ellipse at center,
      var(--tip-tier-2-glow) 0%,
      transparent 72%
    );
}
.amount-grid__presets::after {
  right: 0;
  background:
    radial-gradient(
      ellipse 62% 100% at center,
      var(--tip-tier-3-glow) 0%,
      transparent 76%
    );
  animation: amount-tier-stage-light 5.8s ease-in-out infinite;
}
.amount-grid--compact.is-custom-active .amount-grid__presets {
  opacity: 0;
  transform: translateX(-12px);
  pointer-events: none;
}
.amount-grid__presets:has(.amount-pill--tier-2:hover, .amount-pill--tier-2:focus-within, .amount-pill--tier-2.is-active)::before {
  opacity: 0.42;
}
.amount-grid__presets:has(.amount-pill--tier-3:hover, .amount-pill--tier-3:focus-within, .amount-pill--tier-3.is-active)::after {
  opacity: 0.42;
}
.amount-pill--compact {
  min-height: 48px;
  border-radius: var(--radius-sm);
  padding: 0 8px;
  z-index: 1;
}
.amount-pill--compact span {
  font-size: 13px;
  font-weight: 700;
}

/* ---- Preset amount tier signals -----------------------------------
   Slot-based ladder for the three compact preset pills:
   1 = bronze entry, 2 = silver mid, 3 = gold generous capstone.
   Custom amounts above $10: galaxy blue $25 → magenta $50 → plasma → solar → prism.
   The dark body stays calm at rest; value is rims, inset bloom, sheen. */
.amount-pill--compact[class*="amount-pill--tier-"] {
  --tier-bg-mix: 4%;
  --tier-border-mix: 58%;
  --tier-outer-glow: 0 0 0 transparent;
  /* Resting state matches the Custom pill: neutral panel bg + stroke and
     no tier coloring. The tier accent / glow / prism rim only activate on
     hover, keyboard focus, or `.is-active`. */
  --pill-bg: var(--panel-2);
  --pill-hover-bg: color-mix(in oklch, var(--pill-accent) calc(var(--tier-bg-mix) + 3%), var(--panel-hover));
  --pill-active-bg: color-mix(in oklch, var(--pill-accent) var(--tier-bg-mix), var(--panel-2));
  --pill-glow: transparent;
  --pill-sheen: var(--surface-soft);
  border-color: var(--panel-stroke);
  background: var(--pill-bg);
}
.amount-pill--compact[class*="amount-pill--tier-"]::before,
.amount-pill--compact[class*="amount-pill--tier-"]::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  border-radius: inherit;
  pointer-events: none;
}
.amount-pill--compact[class*="amount-pill--tier-"]::before {
  opacity: 0;
  background:
    radial-gradient(
      ellipse at bottom,
      var(--pill-glow) 0%,
      color-mix(in srgb, var(--pill-glow) 48%, transparent) 34%,
      transparent 72%
    );
  transition: opacity 90ms ease-out, transform 90ms ease-out;
}
.amount-pill--compact[class*="amount-pill--tier-"]::after {
  opacity: 0;
  transform: translateX(-140%) skewX(-18deg);
  background:
    linear-gradient(
      90deg,
      transparent 0%,
      color-mix(in srgb, var(--pill-sheen) 20%, transparent) 42%,
      color-mix(in srgb, var(--pill-sheen) 54%, transparent) 50%,
      color-mix(in srgb, var(--pill-sheen) 20%, transparent) 58%,
      transparent 100%
    );
}
.amount-pill--compact.amount-pill--tier-1,
.cta-button.cta-button--tier-1 {
  --pill-accent: var(--tip-tier-1-accent);
  --pill-glow: var(--tip-tier-1-glow);
  --pill-sheen: var(--tip-tier-1-sheen);
  --tier-border-mix: 62%;
  --tier-outer-glow: inset 0 -4px 12px color-mix(in srgb, var(--tip-tier-1-accent) 12%, transparent);
}
.amount-pill--compact.amount-pill--tier-2,
.cta-button.cta-button--tier-2 {
  --pill-accent: var(--tip-tier-2-accent);
  --pill-glow: var(--tip-tier-2-glow);
  --pill-sheen: var(--tip-tier-2-sheen);
  --tier-bg-mix: 6%;
  --tier-border-mix: 70%;
  --tier-outer-glow: inset 0 -6px 14px color-mix(in srgb, var(--tip-tier-2-accent) 18%, transparent);
}
.amount-pill--compact.amount-pill--tier-3,
.cta-button.cta-button--tier-3 {
  --pill-accent: var(--tip-tier-3-accent);
  --pill-glow: var(--tip-tier-3-glow);
  --pill-sheen: var(--tip-tier-3-sheen);
  --tier-bg-mix: 6%;
  --tier-border-mix: 70%;
  --tier-outer-glow: inset 0 -6px 14px color-mix(in srgb, var(--tip-tier-3-accent) 18%, transparent);
}

/* Tiers 4–9 extend the custom-amount ladder above $10. Narrative:
       4 blue     ($25)   rich galaxy blue — sole deep cool anchor
       5 magenta  ($50)   legendary voltage — blue→magenta→fuchsia rim sweep
       6 plasma   ($100)  hot plasma rose + coral inner bloom (warm, not indigo)
       7 solar    ($250)  ember orange rim bridge through gold apex before prism
       8 galaxy   ($500)  multi-hue cosmic conic
       9 white    ($1000) radiant transcendent cap
   Mix percentages stay in the safe envelope (bg-mix <=14% so white
   labels stay AA on panel-2 base; border-mix climbs gradually;
   outer-glow opacities cap at ~30% per layer). The personality
   recipes (linear-gradient bridges, galaxy conic, white halo) live in
   the `:hover, :focus-within, .is-active` blocks further down.

   No ambient particle/background motion. We tried two variants (CSS
   spark-wind tiled-gradient drift, JS canvas firefly field) and the
   operator rejected both — particles read as either grid-like or
   distracting. The escalation now lives in three knobs:
     - --tier-outer-glow: inset-only bloom (name kept for compat) —
       more layers + higher opacity + wider blur per tier, clipped
       inside the pill by .amount-pill { overflow: hidden }
     - --tier-bloom-peak: per-tier opacity for the bottom-bloom
       ::before pseudo, scaling 0.78 (T4) → 1.0 (T8/T9)
     - --pill-accent / --pill-sheen: existing border + sheen colors
   Sheen sweep on hover/active uses amount-tier-sheen (shortened to
   320ms so the "click" feedback is snappy). */
.amount-pill--compact.amount-pill--tier-4,
.cta-button.cta-button--tier-4 {
  --pill-accent: var(--tip-tier-4-accent);
  --pill-glow: var(--tip-tier-4-glow);
  --pill-sheen: var(--tip-tier-4-sheen);
  --tier-bg-mix: 10%;
  --tier-border-mix: 74%;
  --tier-bloom-low: 0.36;
  --tier-bloom-high: 0.54;
  --tier-bloom-peak: 0.78;
  --tier-outer-glow:
    inset 0 -8px 20px color-mix(in srgb, var(--tip-tier-4-accent) 28%, transparent),
    inset 0 0 12px color-mix(in srgb, var(--tip-tier-4-accent) 18%, transparent);
}
.amount-pill--compact.amount-pill--tier-5,
.cta-button.cta-button--tier-5 {
  --pill-accent: var(--tip-tier-5-accent);
  --pill-glow: var(--tip-tier-5-glow);
  --pill-sheen: var(--tip-tier-5-sheen);
  --tier-bg-mix: 10%;
  --tier-border-mix: 74%;
  --tier-bloom-low: 0.40;
  --tier-bloom-high: 0.58;
  --tier-bloom-peak: 0.85;
  --tier-outer-glow:
    inset 0 -10px 22px color-mix(in srgb, var(--tip-tier-5-accent) 32%, transparent),
    inset 0 0 14px color-mix(in srgb, var(--tip-tier-5-warmer) 20%, transparent);
}
.amount-pill--compact.amount-pill--tier-6,
.cta-button.cta-button--tier-6 {
  --pill-accent: var(--tip-tier-6-accent);
  --pill-glow: var(--tip-tier-6-glow);
  --pill-sheen: var(--tip-tier-6-sheen);
  --tier-bg-mix: 11%;
  --tier-border-mix: 76%;
  --tier-bloom-low: 0.42;
  --tier-bloom-high: 0.62;
  --tier-bloom-peak: 0.92;
  --tier-outer-glow:
    inset 0 -12px 24px color-mix(in srgb, var(--tip-tier-6-accent) 36%, transparent),
    inset 0 0 16px color-mix(in srgb, var(--tip-tier-6-ion) 22%, transparent);
}
.amount-pill--compact.amount-pill--tier-7,
.cta-button.cta-button--tier-7 {
  --pill-accent: var(--tip-tier-7-accent);
  --pill-glow: var(--tip-tier-7-glow);
  --pill-sheen: var(--tip-tier-7-sheen);
  --tier-bg-mix: 12%;
  --tier-border-mix: 76%;
  --tier-bloom-low: 0.44;
  --tier-bloom-high: 0.64;
  --tier-bloom-peak: 0.97;
  --tier-outer-glow:
    inset 0 -12px 26px color-mix(in srgb, var(--tip-tier-7-accent) 38%, transparent),
    inset 0 0 18px color-mix(in srgb, var(--tip-tier-7-violet) 22%, transparent);
}
.amount-pill--compact.amount-pill--tier-8,
.cta-button.cta-button--tier-8 {
  --pill-accent: var(--tip-tier-8-accent);
  --pill-glow: var(--tip-tier-8-glow);
  --pill-sheen: var(--tip-tier-8-sheen);
  --tier-bg-mix: 13%;
  --tier-border-mix: 78%;
  --tier-bloom-low: 0.46;
  --tier-bloom-high: 0.66;
  --tier-bloom-peak: 1.0;
  --tier-outer-glow:
    inset 0 -14px 28px color-mix(in srgb, var(--tip-tier-8-accent) 40%, transparent),
    inset 0 0 20px color-mix(in srgb, var(--tip-tier-8-cyan) 22%, transparent),
    inset 0 0 14px color-mix(in srgb, var(--tip-tier-8-magenta) 16%, transparent);
}
.amount-pill--compact.amount-pill--tier-9,
.cta-button.cta-button--tier-9 {
  --pill-accent: var(--tip-tier-9-accent);
  --pill-glow: var(--tip-tier-9-glow);
  --pill-sheen: var(--tip-tier-9-sheen);
  --tier-bg-mix: 12%;
  --tier-border-mix: 82%;
  --tier-bloom-low: 0.48;
  --tier-bloom-high: 0.70;
  --tier-bloom-peak: 1.0;
  --tier-outer-glow:
    inset 0 -16px 30px color-mix(in srgb, var(--tip-tier-9-warm) 44%, transparent),
    inset 0 0 22px color-mix(in srgb, var(--tip-tier-9-accent) 30%, transparent),
    inset 0 0 16px color-mix(in srgb, var(--tip-tier-9-violet) 18%, transparent);
}
/* Active / hover / keyboard-focus = the pill picks up its tier dressing. */
.amount-pill--compact[class*="amount-pill--tier-"]:hover,
.amount-pill--compact[class*="amount-pill--tier-"]:focus-within,
.amount-pill--compact[class*="amount-pill--tier-"].is-active {
  border-color: color-mix(in oklch, var(--pill-accent) var(--tier-border-mix), var(--panel-stroke));
  background: var(--pill-active-bg);
  box-shadow: var(--tier-outer-glow);
}
.amount-pill--compact[class*="amount-pill--tier-"].is-active {
  border-color: var(--pill-accent);
  box-shadow:
    0 0 0 1px var(--pill-accent) inset,
    var(--tier-outer-glow);
}
/* Tier 4 ($25) — Rich galaxy blue. Saturated orbital floor — first step after
   gold presets; magenta tier-5 hits harder on purpose. Stronger bloom than tiers 1–3. */

/* Tier 5 ($50) — Legendary magenta. Rim blends tier-4 blue through magenta into
   fuchsia apex (`--tip-tier-5-warmer`) then hands off to plasma heat on tier 6. */
.amount-pill--compact.amount-pill--tier-5:hover,
.amount-pill--compact.amount-pill--tier-5:focus-within,
.amount-pill--compact.amount-pill--tier-5.is-active,
.cta-button.cta-button--tier-5:not(.cta-button--blended) {
  border-color: transparent;
  background:
    linear-gradient(var(--pill-active-bg), var(--pill-active-bg)) padding-box,
    linear-gradient(
      130deg,
      var(--tip-tier-4-accent) 0%,
      var(--tip-tier-5-accent) 50%,
      var(--tip-tier-5-warmer) 100%
    ) border-box;
}

/* Tier 6 ($100) — Plasma flare. Hot rose–red body with coral flare inner bloom
   (`--tip-tier-6-ion`) — stays in the warm quadrant (no midnight indigo rewind). */

/* Tier 7 ($250) — Solar apex. Rim steps tier-6 plasma through ember orange into
   bright gold flare (`--tip-tier-7-violet` token) before tier-8 prism detonates spectrum. */
.amount-pill--compact.amount-pill--tier-7:hover,
.amount-pill--compact.amount-pill--tier-7:focus-within,
.amount-pill--compact.amount-pill--tier-7.is-active,
.cta-button.cta-button--tier-7:not(.cta-button--blended) {
  border-color: transparent;
  background:
    linear-gradient(var(--pill-active-bg), var(--pill-active-bg)) padding-box,
    linear-gradient(
      125deg,
      var(--tip-tier-6-accent) 0%,
      var(--tip-tier-7-accent) 50%,
      var(--tip-tier-7-violet) 100%
    ) border-box;
}

/* Tier 8 ($500) — Galaxy. Multi-hue cosmic prism — this is where the
   conic-rim energy lives (relocated from tier-3 so the ladder actually
   escalates instead of peaking at $10). violet→indigo→cyan→magenta→
   violet sweep with color-mix(in oklch) midpoints so transitions blend
   smoothly instead of showing wedge edges. */
.amount-pill--compact.amount-pill--tier-8:hover,
.amount-pill--compact.amount-pill--tier-8:focus-within,
.amount-pill--compact.amount-pill--tier-8.is-active,
.cta-button.cta-button--tier-8:not(.cta-button--blended) {
  border-color: transparent;
  background:
    linear-gradient(var(--pill-active-bg), var(--pill-active-bg)) padding-box,
    conic-gradient(
      from 30deg,
      var(--tip-tier-8-accent) 0%,
      color-mix(in oklch, var(--tip-tier-8-accent) 50%, var(--tip-tier-8-violet)) 10%,
      var(--tip-tier-8-violet) 20%,
      color-mix(in oklch, var(--tip-tier-8-violet) 50%, var(--tip-tier-8-cyan)) 32%,
      var(--tip-tier-8-cyan) 45%,
      color-mix(in oklch, var(--tip-tier-8-cyan) 50%, var(--tip-tier-8-magenta)) 58%,
      var(--tip-tier-8-magenta) 70%,
      color-mix(in oklch, var(--tip-tier-8-magenta) 50%, var(--tip-tier-8-accent)) 85%,
      var(--tip-tier-8-accent) 100%
    ) border-box;
}

/* Tier 9 ($1000) — White holiness. Radiant transcendent cap. Mostly
   white with faint warm-white + violet undertones — apex via radiance,
   not chrome volume. Soft bright halo. Visual ceiling: pill does not
   escalate above tier-9 (anti-dark-pattern guardrail). */
.amount-pill--compact.amount-pill--tier-9:hover,
.amount-pill--compact.amount-pill--tier-9:focus-within,
.amount-pill--compact.amount-pill--tier-9.is-active {
  border-color: transparent;
  background:
    linear-gradient(var(--pill-active-bg), var(--pill-active-bg)) padding-box,
    linear-gradient(
      135deg,
      var(--tip-tier-9-violet) 0%,
      var(--tip-tier-9-warm) 30%,
      var(--tip-tier-9-accent) 55%,
      var(--tip-tier-9-warm) 80%,
      var(--tip-tier-9-violet) 100%
    ) border-box;
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, var(--tip-tier-9-warm) 36%, transparent),
    inset 0 0 18px color-mix(in srgb, var(--tip-tier-9-warm) 22%, transparent),
    var(--tier-outer-glow);
}
.cta-button.cta-button--tier-9:not(.cta-button--blended) {
  border-color: transparent;
  background:
    linear-gradient(var(--pill-active-bg), var(--pill-active-bg)) padding-box,
    linear-gradient(
      135deg,
      var(--tip-tier-9-violet) 0%,
      var(--tip-tier-9-warm) 30%,
      var(--tip-tier-9-accent) 55%,
      var(--tip-tier-9-warm) 80%,
      var(--tip-tier-9-violet) 100%
    ) border-box;
}
.amount-pill--compact[class*="amount-pill--tier-"].is-active span {
  color: var(--text);
}

/* ---- Custom-pill continuous blend mode ----------------------------
   script-compact.js adds `amount-pill--blended` to the Custom pill
   whenever a valid amount has been typed, *in addition to* the
   discrete amount-pill--tier-N class. The JS also sets blended
   color-mix(in oklch, ...) values inline for --pill-accent,
   --pill-glow, --pill-sheen, --tier-outer-glow, --tier-bloom-peak,
   --tier-bg-mix, and --tier-border-mix. The shared single-hue
   :hover/:focus-within/.is-active rules above already paint with
   those tokens, so the bloom / sheen / outer-glow blend smoothly
   without any extra CSS. The block below is the one place where
   per-tier specialization (the gradient/conic rims on tier-5/7/8/9)
   would otherwise force a hard snap — we suppress those for the
   Custom pill only, restoring the standard single-hue rim with the
   already-blended --pill-accent so every custom amount reads as its
   own color along the ladder.
   Preset pills (tier-1..9 anchors in the row) are unaffected: they
   stay on the discrete per-tier treatments because the donor picked
   that exact tier. */
.amount-pill--custom.amount-pill--blended.amount-pill--tier-5:hover,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-5:focus-within,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-5.is-active,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-7:hover,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-7:focus-within,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-7.is-active,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-8:hover,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-8:focus-within,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-8.is-active,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-9:hover,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-9:focus-within,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-9.is-active,
.cta-button.cta-button--blended.cta-button--tier-5,
.cta-button.cta-button--blended.cta-button--tier-7,
.cta-button.cta-button--blended.cta-button--tier-8,
.cta-button.cta-button--blended.cta-button--tier-9 {
  border-color: color-mix(in oklch, var(--pill-accent) var(--tier-border-mix), var(--panel-stroke));
  background: var(--pill-active-bg);
}
.amount-pill--custom.amount-pill--blended.amount-pill--tier-5.is-active,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-7.is-active,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-8.is-active,
.amount-pill--custom.amount-pill--blended.amount-pill--tier-9.is-active {
  border-color: var(--pill-accent);
  box-shadow:
    0 0 0 1px var(--pill-accent) inset,
    var(--tier-outer-glow);
}
.cta-button.cta-button--blended[class*="cta-button--tier-"] {
  border-color: var(--pill-accent);
}
/* Short transition on the active/hover paint so each keystroke blends
   into the next color instead of snapping. The ::before bloom and
   ::after sheen already transition opacity (snappier-1 timings); this
   adds the border + background + outer-glow to the same fast envelope. */
.amount-pill--custom.amount-pill--blended,
.cta-button.cta-button--blended {
  transition: background 110ms ease-out, border-color 110ms ease-out, box-shadow 140ms ease-out;
}
.cta-button.cta-button--blended::before {
  transition: opacity 110ms ease-out;
}
.amount-pill--compact[class*="amount-pill--tier-"]:hover::after,
.amount-pill--compact[class*="amount-pill--tier-"]:focus-within::after,
.amount-pill--compact[class*="amount-pill--tier-"].is-active::after {
  animation: amount-tier-sheen 320ms ease-out;
}
.amount-pill--compact[class*="amount-pill--tier-"]:hover::before,
.amount-pill--compact[class*="amount-pill--tier-"]:focus-within::before,
.amount-pill--compact[class*="amount-pill--tier-"].is-active::before {
  opacity: var(--tier-bloom-peak, 0.72);
  transform: translateY(-1px);
}

@keyframes amount-tier-sheen {
  0% {
    opacity: 0;
    transform: translateX(-140%) skewX(-18deg);
  }
  22% {
    opacity: var(--tier-sheen-peak, 0.92);
  }
  100% {
    opacity: 0;
    transform: translateX(140%) skewX(-18deg);
  }
}

@keyframes amount-tier-bloom {
  0%,
  100% {
    opacity: var(--tier-bloom-low);
  }
  50% {
    opacity: var(--tier-bloom-high);
  }
}
@keyframes amount-tier-stage-light {
  0%,
  100% {
    transform: translateY(1px) scaleY(0.96);
  }
  50% {
    transform: translateY(-1px) scaleY(1.04);
  }
}

/* ---- Custom-amount pill (4th cell, always visible) ----------------
   Button-shaped pill that shares the .amount-pill base styles for
   border + radius + active state. Pinned to column 4 so it never
   moves whether Custom is active or not — only its `.is-active`
   visual swaps. */
.amount-pill--custom {
  grid-column: 4;
  grid-row: 1;
  appearance: none;
  font: inherit;
  color: var(--text);
  cursor: pointer;
}
.amount-pill--custom span {
  font-weight: 700;
  font-size: 12px;
  letter-spacing: -0.01em;
}
/* Custom active but no valid typed amount yet (`amount-pill--tier-*` absent):
   keep default panel chrome only — no tier pigment, no accent “selected” tint. */
.amount-pill--custom.is-active:not([class*="amount-pill--tier-"]) {
  border-color: var(--panel-stroke);
  background: var(--panel-2);
  box-shadow: none;
}
.amount-pill--custom.is-active:not([class*="amount-pill--tier-"]):hover {
  background: var(--panel-hover);
}
.amount-pill--custom.is-active:not([class*="amount-pill--tier-"]) span {
  color: var(--text);
}
/* Custom-active span color when a tier class IS present: keep the
   amount label white for contrast (matches the preset is-active rule
   in the tier block) rather than letting the per-tier `--pill-accent`
   take over the text. */
.amount-pill--custom.is-active[class*="amount-pill--tier-"] span { color: var(--text); }

/* ---- Custom-amount input (stacks on the presets) ------------------
   Lives inside .amount-grid--compact at the same grid cell as the
   presets wrapper (row 1, columns 1–3). Hidden by default — slides
   in from the right (translateX(12px) → 0) and fades up when
   .is-custom-active is set on the grid. `inert` is toggled in JS
   so the field doesn't catch keyboard tab-order while hidden. */
.custom-amount-input {
  grid-column: 1 / 4;
  grid-row: 1;
  display: flex;
  align-items: stretch;
  margin-top: 0;
  opacity: 0;
  transform: translateX(12px);
  pointer-events: none;
  transition: opacity 130ms ease-out, transform 130ms ease-out;
  will-change: opacity, transform;
}
.amount-grid--compact.is-custom-active .custom-amount-input {
  opacity: 1;
  transform: translateX(0);
  pointer-events: auto;
}
.custom-amount-input .field {
  flex: 1 1 auto;
  min-width: 0;
}
/* Custom amount: one bordered shell; flipper panels live inside on the right. */
.field--amount-flipper {
  display: block;
  position: relative;
  height: 48px;
  margin-top: 0;
  border: 1px solid var(--panel-stroke);
  border-radius: var(--radius-sm);
  background: var(--panel-2);
  overflow: hidden;
}
.field--amount-flipper input[type="text"] {
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  margin: 0;
  border: none;
  border-radius: 0;
  background: transparent;
  color: var(--text);
  padding: 0 52px 0 26px;
  font-size: 14px;
  outline: none;
  box-shadow: none;
  transition: filter 120ms linear;
}
/* Motion blur: none on tap; eases in during hold-repeat as speed rises (script-compact.js). */
.field--amount-flipper.is-amount-motion input[type="text"] {
  filter: blur(var(--amount-motion-blur, 0));
}
.field--amount-flipper:focus-within {
  border-color: var(--panel-stroke-strong);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 18%, transparent);
}
.amount-stepper {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: 48px;
  display: flex;
  flex-direction: column;
  border-left: 1px solid var(--panel-stroke);
  pointer-events: auto;
}
.amount-stepper__btn {
  flex: 1 1 0;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 0;
  margin: 0;
  padding: 0;
  border: none;
  border-radius: 0;
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  transition: background-color 120ms ease, color 120ms ease;
}
.amount-stepper__btn--up {
  border-bottom: 1px solid var(--panel-stroke);
}
.amount-stepper__btn:hover:not(:disabled) {
  background: color-mix(in srgb, var(--text-muted) 14%, var(--panel-2));
  color: var(--text);
}
.amount-stepper__btn:active:not(:disabled),
.amount-stepper__btn.is-pressed:not(:disabled) {
  background: color-mix(in srgb, var(--text-muted) 22%, var(--panel-2));
  color: var(--text);
}
.amount-stepper__btn:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}
.amount-stepper__btn:focus-visible {
  outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
  outline-offset: -2px;
  z-index: 1;
}
.custom-amount-input[data-tier] .amount-stepper__btn:not(:disabled) {
  color: color-mix(in srgb, var(--tier-input-accent, var(--text-muted)) 72%, var(--text-muted));
}
.custom-amount-input[data-tier] .amount-stepper__btn:not(:disabled):hover {
  color: var(--text);
}
@media (prefers-reduced-motion: reduce) {
  .field--amount-flipper input[type="text"] {
    transition: none;
  }
  .field--amount-flipper.is-amount-motion input[type="text"] {
    filter: none;
  }
}
.field--compact {
  margin-top: 0;
  position: relative;
}
.field--compact:not(.field--amount-flipper) input[type="text"] {
  width: 100%;
  height: 48px;
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--panel-stroke);
  border-radius: var(--radius-sm);
  padding: 0 12px 0 26px;
  font-size: 14px;
  outline: none;
}
.field--compact::before {
  content: "$";
  position: absolute;
  left: 10px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--text-muted);
  font-size: 14px;
  line-height: 1;
  pointer-events: none;
}
.field--compact input[type="text"]:focus {
  border-color: var(--panel-stroke-strong);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 18%, transparent);
}
/* When script-compact.js sets data-tier on the custom-amount-input wrap
   (a valid amount has been typed in Custom mode), the field's focus
   border/glow take on the tier accent so the input + pill read as a
   matched pair. Wrap-scoped `--tier-input-accent` lets the inner input
   pick the color up via inheritance without each focus rule restating
   per-tier hex values. */
.custom-amount-input[data-tier="1"] { --tier-input-accent: var(--tip-tier-1-accent); }
.custom-amount-input[data-tier="2"] { --tier-input-accent: var(--tip-tier-2-accent); }
.custom-amount-input[data-tier="3"] { --tier-input-accent: var(--tip-tier-3-accent); }
.custom-amount-input[data-tier="4"] { --tier-input-accent: var(--tip-tier-4-accent); }
.custom-amount-input[data-tier="5"] { --tier-input-accent: var(--tip-tier-5-accent); }
.custom-amount-input[data-tier="6"] { --tier-input-accent: var(--tip-tier-6-accent); }
.custom-amount-input[data-tier="7"] { --tier-input-accent: var(--tip-tier-7-accent); }
.custom-amount-input[data-tier="8"] { --tier-input-accent: var(--tip-tier-8-accent); }
.custom-amount-input[data-tier="9"] { --tier-input-accent: var(--tip-tier-9-accent); }
.custom-amount-input[data-tier] .field--amount-flipper {
  border-color: color-mix(in srgb, var(--tier-input-accent, var(--panel-stroke)) 36%, var(--panel-stroke));
  transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
}
.custom-amount-input[data-tier] .field--amount-flipper:focus-within {
  border-color: color-mix(in srgb, var(--tier-input-accent, var(--accent)) 65%, var(--panel-stroke));
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--tier-input-accent, var(--accent)) 16%, transparent);
}
.custom-amount-input[data-tier] .amount-stepper {
  border-left-color: color-mix(in srgb, var(--tier-input-accent, var(--panel-stroke)) 36%, var(--panel-stroke));
}
.custom-amount-input[data-tier] .amount-stepper__btn--up {
  border-bottom-color: color-mix(in srgb, var(--tier-input-accent, var(--panel-stroke)) 36%, var(--panel-stroke));
}
/* Reduced-motion donors get an instant swap with no slide animation. */
@media (prefers-reduced-motion: reduce) {
  .amount-grid__presets,
  .custom-amount-input {
    transition: none;
    transform: none;
  }
  .amount-pill--compact[class*="amount-pill--tier-"]::before,
  .amount-pill--compact[class*="amount-pill--tier-"]::after {
    animation: none !important;
    transition: none;
    transform: none !important;
  }
  .amount-grid__presets::before,
  .amount-grid__presets::after {
    animation: none !important;
    transition: none;
    transform: none !important;
  }
}

/* ---- Prompt textarea + counter ------------------------------------ */
.message-shell {
  position: relative;
}
.message-shell textarea {
  /* block — inline-block leaves a ~5px strut under the field so prompt→tiles
     reads larger than amount→prompt even when form gap is uniform. */
  display: block;
  width: 100%;
  min-height: 56px;
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--panel-stroke);
  border-radius: var(--radius-sm);
  padding: 8px 40px 22px 12px;
  font-size: 14px;
  font-family: inherit;
  outline: none;
  /* No drag-resize in fit-to-viewport mode — the layout stays pinned. */
  resize: none;
}
.message-shell textarea:focus,
.message-shell textarea:focus-visible {
  outline: none;
  box-shadow: none;
  border-color: var(--panel-stroke);
}
.message-shell__spark {
  position: absolute;
  top: 6px;
  right: 6px;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: 0;
  border-radius: var(--radius-xs);
  background: transparent;
  /* Lucide stroke icon — muted like char counter, not brand accent green */
  color: var(--text-muted);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: opacity 0.15s ease, transform 0.15s ease, color 0.15s ease;
}
.message-shell__spark:hover,
.message-shell__spark:focus,
.message-shell__spark:focus-visible,
.message-shell__spark:active {
  background: transparent;
  color: var(--text-muted);
  outline: none;
}
.message-shell__spark:active {
  transform: scale(0.94);
}
.message-shell__spark.is-loading {
  pointer-events: none;
  cursor: wait;
}
.message-shell__spark-icon {
  display: inline-flex;
  line-height: 0;
}
.message-shell__spark-icon svg {
  width: 15px;
  height: 15px;
}
.message-shell__spark.is-loading .message-shell__spark-icon {
  visibility: hidden;
}
.message-shell__spark-loader {
  position: absolute;
  inset: 0;
  margin: auto;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: 2px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
  border-top-color: var(--text-muted);
  animation: message-shell-spark-spin 0.65s linear infinite;
}
.message-shell__spark-loader[hidden] {
  display: none;
}
.message-shell__spark.is-loading .message-shell__spark-loader {
  display: block;
}
@keyframes message-shell-spark-spin {
  to { transform: rotate(360deg); }
}
.message-shell__counter {
  position: absolute;
  right: 8px;
  bottom: 6px;
  font-size: 10px;
  color: var(--text-muted);
  pointer-events: none;
  letter-spacing: 0.02em;
  background: color-mix(in srgb, var(--panel-2) 80%, transparent);
  padding: 0 4px;
  border-radius: var(--radius-xs);
}

/* ---- Tile picker row ---------------------------------------------- */
/* Flex (not grid) so the row handles MODEL (optional) + stacked NAME/VOICE
   + GIF. Name and voice are separate bordered cells in one column. */
.tile-row {
  display: flex;
  align-items: stretch;
  gap: 5px;
}

/* Name + voice stack — two individual tile-cards, one column slot. */
.tile-bento {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 5px;
  min-height: 0;
}
.tile-bento > .tile-card {
  flex: 1 1 0;
  min-height: 0;
  padding: 4px 8px 5px;
  gap: 2px;
}
.tile-bento > .tile-card .tile-card__value-text {
  font-size: 11px;
  line-height: 1.1;
}
.tile-card {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-height: 56px;
  padding: 5px 8px 7px;
  background: var(--panel-2);
  border: 1px solid var(--panel-stroke);
  border-radius: var(--radius-sm);
  cursor: pointer;
  text-align: left;
  color: var(--text);
  font: inherit;
  transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
  overflow: hidden;
}
/* The Model tile is rendered for every show (so neighbor carousel preview
   cells have an element to toggle on/off based on the destination's
   `show_model_picker` flag — see app.py and script-compact.js
   buildPlaceholderFragment). On the live page, shows without a picker
   carry [hidden]; this rule reasserts display:none over the explicit
   .tile-card { display: flex } above. Plain `[hidden]` without this
   override would render the tile because flex specificity wins. */
.tile-card[hidden] {
  display: none;
}
.tile-card:hover:not(:disabled) {
  background: var(--panel-hover);
  border-color: var(--panel-stroke-strong);
}
.tile-card:active:not(:disabled) {
  transform: scale(0.98);
}
.tile-card--disabled {
  opacity: 0.55;
  cursor: not-allowed;
}
.tile-card__label {
  display: block;
  font-size: 8px;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.tile-card__value-text {
  display: block;
  font-size: 12px;
  font-weight: 600;
  color: var(--text);
  /* Full names — no truncation, allow wrap onto a second line. */
  line-height: 1.15;
  word-break: break-word;
  overflow-wrap: anywhere;
}

/* GIF tile — value is the actual GIF thumbnail, filling the body of
   the tile under the small uppercase label. */
.tile-card--gif,
.tile-card--hat {
  padding-bottom: 5px;
  box-sizing: border-box;
}
.tile-card__gif-thumb {
  display: block;
  width: 100%;
  flex: 0 0 var(--prompt-gif-tile-thumb-h, 48px);
  height: var(--prompt-gif-tile-thumb-h, 48px);
  min-height: 0;
  border-radius: var(--radius-xs);
  background: var(--panel);
  border: 1px solid var(--panel-stroke);
  overflow: hidden;
}
.tile-card__gif-thumb img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.tile-card__gif-thumb--none {
  display: grid;
  place-items: center;
  color: var(--text-muted);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.04em;
}

/* Hat tile animation timeline (320ms, shared easing) — intro and outro are the same
   tracks run backward. Do not use max-width:none↔0 (does not interpolate → snap).
   Timeline (t=0 → 320ms):
     Track A layout — .tile-hat-slot flex-grow 0↔1, GIF flex-grow 1↔1 (inverse share)
     Track B slide  — .tile-card--hat transform translateX(14px)↔0, origin right
     Track C fade   — .tile-card--hat opacity 0↔1 (full 320ms, parallel with A+B)
     Track D chrome — padding, border-width, gap on .tile-card--hat
   Outro = remove .tile-row--hat-unlocked (all tracks reverse). */
.tile-row--hat-capable {
  --hat-tile-anim-ms: 320ms;
  --hat-tile-anim-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
.tile-row--hat-capable > .tile-card--gif,
.tile-row--hat-capable > .tile-hat-slot {
  min-height: 0;
  transition:
    flex-grow var(--hat-tile-anim-ms) var(--hat-tile-anim-ease),
    flex-shrink var(--hat-tile-anim-ms) var(--hat-tile-anim-ease),
    flex-basis var(--hat-tile-anim-ms) var(--hat-tile-anim-ease);
}
.tile-row--hat-capable > .tile-hat-slot {
  flex-grow: 0;
  flex-shrink: 1;
  flex-basis: 0%;
  min-width: 0;
  overflow: hidden;
  margin-left: auto;
}
.tile-row--hat-unlocked.tile-row--hat-capable > .tile-hat-slot {
  flex-grow: 1;
  flex-shrink: 1;
  flex-basis: 0%;
}
.tile-row--hat-capable > .tile-hat-slot > .tile-card--hat {
  display: flex;
  flex-direction: column;
  width: 100%;
  min-width: 0;
  box-sizing: border-box;
  transform-origin: right center;
  transition:
    opacity var(--hat-tile-anim-ms) var(--hat-tile-anim-ease),
    transform var(--hat-tile-anim-ms) var(--hat-tile-anim-ease),
    padding var(--hat-tile-anim-ms) var(--hat-tile-anim-ease),
    border-width var(--hat-tile-anim-ms) var(--hat-tile-anim-ease),
    gap var(--hat-tile-anim-ms) var(--hat-tile-anim-ease);
}
.tile-row--hat-capable .tile-card--hat {
  position: relative;
  overflow: hidden;
}
/* First paint only — skip timeline when SSR already matches locked/unlocked. */
.tile-row--hat-instant.tile-row--hat-capable > .tile-card--gif,
.tile-row--hat-instant.tile-row--hat-capable > .tile-hat-slot,
.tile-row--hat-instant.tile-row--hat-capable > .tile-hat-slot > .tile-card--hat {
  transition: none !important;
}
/* t=320ms locked keyframe (also t=0 for outro). Opacity fades last so the thumb
   stays visible while the slot slides closed (placeholder swaps at t=320 in JS). */
.tile-row--hat-capable:not(.tile-row--hat-unlocked) > .tile-hat-slot > .tile-card--hat {
  min-height: 0;
  opacity: 0;
  transform: translateX(14px);
  padding: 0;
  border-width: 0;
  gap: 0;
  pointer-events: none;
  transition:
    opacity 80ms var(--hat-tile-anim-ease) 240ms,
    transform var(--hat-tile-anim-ms) var(--hat-tile-anim-ease),
    padding var(--hat-tile-anim-ms) var(--hat-tile-anim-ease),
    border-width var(--hat-tile-anim-ms) var(--hat-tile-anim-ease),
    gap var(--hat-tile-anim-ms) var(--hat-tile-anim-ease);
}
/* t=0 intro / t=320ms outro start keyframe */
.tile-row--hat-unlocked.tile-row--hat-capable > .tile-hat-slot > .tile-card--hat {
  min-height: 56px;
  opacity: 1;
  transform: translateX(0);
  padding: 5px 8px 5px;
  border-width: 1px;
  gap: 3px;
  pointer-events: auto;
  transition:
    opacity var(--hat-tile-anim-ms) var(--hat-tile-anim-ease),
    transform var(--hat-tile-anim-ms) var(--hat-tile-anim-ease),
    padding var(--hat-tile-anim-ms) var(--hat-tile-anim-ease),
    border-width var(--hat-tile-anim-ms) var(--hat-tile-anim-ease),
    gap var(--hat-tile-anim-ms) var(--hat-tile-anim-ease);
}
@media (prefers-reduced-motion: reduce) {
  .tile-row--hat-capable > .tile-card--gif,
  .tile-row--hat-capable > .tile-hat-slot,
  .tile-row--hat-capable > .tile-hat-slot > .tile-card--hat {
    transition: none;
  }
  .tile-row--hat-capable:not(.tile-row--hat-unlocked) > .tile-hat-slot > .tile-card--hat {
    transform: none;
  }
}
.tile-card__hat-thumb {
  display: block;
  width: 100%;
  flex: 0 0 var(--prompt-gif-tile-thumb-h, 48px);
  height: var(--prompt-gif-tile-thumb-h, 48px);
  min-height: 0;
  border-radius: var(--radius-xs);
  background: var(--panel);
  border: 1px solid var(--panel-stroke);
  overflow: hidden;
}
.tile-card__hat-thumb img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  background: var(--surface-soft);
}
.tile-card__hat-thumb--none {
  display: grid;
  place-items: center;
  color: var(--text-muted);
  font-size: 9px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-align: center;
  line-height: 1.15;
  padding: 2px;
}
.tile-card__hat-thumb--prompt {
  display: grid;
  place-items: center;
  padding: 4px;
  color: var(--text);
  font-size: 8px;
  font-weight: 600;
  line-height: 1.2;
  text-align: center;
  overflow: hidden;
  word-break: break-word;
}
/* Pinned prompt row — same width as `.hat-grid--sheet` (panel padding only;
   do not add extra horizontal inset like the old 12px rule). */
#sheet-hat .sheet__hat-search {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  gap: 4px;
  width: 100%;
  min-width: 0;
}
#sheet-hat .sheet__hat-search .giphy-quick-search--sheet {
  width: 100%;
}
#sheet-hat .sheet__body--scroll {
  gap: 6px;
}
.hat-cell {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 6px;
  border: 1px solid var(--panel-stroke);
  border-radius: var(--radius-sm);
  background: var(--panel-2);
  cursor: pointer;
  color: var(--text);
  font: inherit;
  text-align: center;
}
.hat-cell img {
  display: block;
  width: 100%;
  aspect-ratio: 1;
  object-fit: contain;
  border-radius: var(--radius-xs);
  background: var(--surface-soft);
}
.hat-cell__label {
  font-size: 9px;
  line-height: 1.2;
  color: var(--text-muted);
}

.hat-cell__credit {
  display: block;
  font-size: 8px;
  line-height: 1.2;
  color: var(--text-muted);
  opacity: 0.85;
}

/* ---- Terms notice (button-wrap; acceptance implied by checkout click) */
.disclaimer--compact {
  margin: 0;
  font-size: 11px;
  color: var(--text-muted);
  line-height: 1.3;
}

/* ---- Full-width CTA (tier styling tracks selected amount) ---------- */
.cta-button {
  position: relative;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 46px;
  padding: 0 14px;
  background: var(--accent);
  color: var(--accent-ink);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  font-size: 15px;
  font-weight: 700;
  letter-spacing: -0.01em;
  cursor: pointer;
  transition:
    filter 120ms ease,
    transform 80ms ease,
    opacity 120ms ease,
    background 140ms ease,
    border-color 140ms ease,
    box-shadow 140ms ease,
    color 140ms ease;
  box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 32%, transparent);
}
.cta-button[class*="cta-button--tier-"] {
  --pill-active-bg: color-mix(in oklch, var(--pill-accent) var(--tier-bg-mix, 6%), var(--panel-2));
  --cta-bloom-opacity: var(--tier-bloom-peak, 0.9);
  overflow: hidden;
  isolation: isolate;
  color: var(--text);
  border-color: var(--pill-accent);
  background: var(--pill-active-bg);
  box-shadow:
    0 0 0 1px var(--pill-accent) inset,
    inset 0 -10px 20px color-mix(in srgb, var(--pill-glow) 14%, transparent),
    0 10px 26px -8px color-mix(in srgb, var(--pill-glow) 30%, transparent);
}
/* Bottom bloom: wider than the tight center pass, softer than full-width
   inset-only (reads muddy edge-to-edge on a full-width CTA). */
.cta-button[class*="cta-button--tier-"]::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: var(--cta-bloom-opacity);
  background: radial-gradient(
    ellipse 52% 100% at 50% 108%,
    color-mix(in srgb, var(--pill-accent) 34%, var(--pill-glow)) 0%,
    var(--pill-glow) 14%,
    color-mix(in srgb, var(--pill-glow) 40%, transparent) 40%,
    transparent 74%
  );
  transition: opacity 140ms ease-out;
}
.cta-button:hover:not(:disabled) { filter: brightness(1.06); }
.cta-button:active:not(:disabled) { transform: translateY(1px); }
.cta-button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  box-shadow: none;
}
.cta-button[class*="cta-button--tier-"]:disabled {
  box-shadow: none;
}
.cta-button__label {
  position: relative;
  z-index: 1;
  text-align: center;
}
.cta-button--sheet {
  margin-top: 4px;
  min-height: 48px;
  box-shadow: none;
}

/* Pay-logos lives INSIDE .prompt-card form, directly under the CTA
   (see tip_compact.html). Form's flex-column `gap: 8px` already
   spaces it from #checkout-error / CTA, so margin stays 0 to avoid
   stacking the gap. The card itself docks to the viewport bottom
   edge (.prompt-card above), so this row reads as the closing
   trust signal on the very last visible line of the page. */
.compact-layout .pay-logos--compact {
  display: block;
  margin: 0;
  padding: 0;
  border-top: 0;
  font-size: 9px;
  letter-spacing: 0.03em;
  text-align: center;
  color: var(--text-muted);
}

/* ---- Bottom sheets ------------------------------------------------- */
body.sheet-open {
  overflow: hidden;
}
.sheet {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
}
.sheet[hidden] {
  display: none;
}
.sheet__scrim {
  position: absolute;
  inset: 0;
  border: 0;
  padding: 0;
  margin: 0;
  background: color-mix(in srgb, var(--shadow-ink) 65%, transparent);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  opacity: 1;
  transition: opacity 160ms ease-out;
}
.sheet.is-closing .sheet__scrim {
  opacity: 0;
  pointer-events: none;
}
.sheet__panel {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 560px;
  margin: 0 auto;
  background: var(--panel);
  /* Flat tab — no border, square corners. Surface reads against the
     scrim purely via fill + box-shadow. The circular X close button
     in the head-row handles dismissal alongside the scrim. */
  border: 0;
  border-radius: 0;
  padding: 14px 16px max(20px, env(safe-area-inset-bottom, 16px));
  /* Unified bottom-sheet cap (2026-05-22). All compact sheets (name,
     model, GIF, voice) share the same 300px ceiling so that a donor
     scrolling on their phone one-handed can always reach the X in the
     upper-right corner of the panel with a comparable right-thumb arc,
     regardless of which option they tap. Sheets whose natural content
     height is below the cap (name = ~166px) keep their shorter natural
     height; taller sheets (model, GIF categories, voice list) cap at
     300px and scroll inside `.sheet__body--scroll`. `min(300px, 60vh)`
     keeps the cap proportionate on landscape / very short viewports so
     the sheet never overwhelms the screen. */
  max-height: min(300px, 60vh);
  display: flex;
  flex-direction: column;
  gap: 12px;
  /* Full slide-up from below the viewport — donor reads it as a real
     "sheet rising" gesture regardless of which sheet they tap.
     `translateY(100%)` shifts the panel down by its OWN height, so
     short sheets (Name ~166px) and capped sheets (Model/GIF/Voice
     300px) both start fully off-screen at the bottom and travel
     proportionally before resting at translateY(0). Previously the
     keyframe was a 20px lift + opacity fade, which read as a slide on
     the short Name sheet (~12% of its height) but as a static "pop"
     on the 300px sheets (~7% of their height). Don't reintroduce the
     opacity component — modern bottom-sheet UX is transform-only,
     opacity flickers are jarring on OLED phone displays. */
  animation: sheet-rise 260ms cubic-bezier(0.2, 0.7, 0.3, 1) forwards;
  will-change: transform;
  box-shadow: 0 -24px 60px color-mix(in srgb, var(--shadow-ink) 70%, transparent);
}
/* GIF sheet only — slightly taller than the unified 300px cap so
   category cards + pinned search breathe; model/name/voice stay at
   `.sheet__panel` defaults. Keep the X in `.sheet__head-row` reachable. */
#sheet-gif .sheet__panel--gif {
  max-height: min(340px, 65vh);
  gap: 6px;
}
@keyframes sheet-rise {
  from { transform: translateY(100%); }
  to   { transform: translateY(0);    }
}
.sheet__panel.is-closing {
  animation: sheet-fall 260ms cubic-bezier(0.2, 0.7, 0.3, 1) forwards;
}
@keyframes sheet-fall {
  from { transform: translateY(0);    }
  to   { transform: translateY(100%); }
}
@media (prefers-reduced-motion: reduce) {
  .sheet__scrim { transition: none; }
  .sheet__panel { animation: none; }
  .sheet__panel.is-closing { animation: none; }
}
/* Top-right X close button on every bottom sheet. Circular pill
   border, panel-2 fill, accent hover. Lives inside `.sheet__head-row`
   so flex `align-items: center` vertically centers it against the
   sheet title text. */
.sheet__close {
  flex: 0 0 auto;
  width: 32px;
  height: 32px;
  border: 1px solid var(--panel-stroke);
  border-radius: var(--radius-pill);
  background: var(--panel-2);
  color: var(--text);
  cursor: pointer;
  display: inline-grid;
  place-items: center;
  padding: 0;
  line-height: 0;
  font: inherit;
  -webkit-tap-highlight-color: transparent;
  transition: background-color 120ms ease, border-color 120ms ease;
}
.sheet__close:hover {
  background: var(--panel-hover);
  border-color: var(--panel-stroke-strong);
}
.sheet__close:focus-visible {
  outline: 2px solid var(--panel-stroke-strong);
  outline-offset: 2px;
}
.sheet__close svg {
  display: block;
  width: 14px;
  height: 14px;
}
/* Header row owns the title + circular X (and, on the GIF sheet, the
   GIPHY "Powered by" lockup). Flex `align-items: center` keeps the
   X icon vertically centered against the title text regardless of
   font metrics. The title takes the remaining space so the X always
   pins to the right. */
.sheet__head-row {
  display: flex;
  align-items: center;
  gap: 12px;
}
.sheet__head-row .sheet__title {
  flex: 1 1 auto;
  min-width: 0;
}
.sheet__title {
  margin: 0;
  font-size: 15px;
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--text);
}
.sheet__subhead {
  margin: 14px 0 6px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.sheet__body {
  display: flex;
  flex-direction: column;
  gap: 10px;
  min-height: 0;
}
.sheet__body--scroll {
  overflow-y: auto;
  flex: 1;
  -ms-overflow-style: none;
  scrollbar-width: none;
}
.sheet__body--scroll::-webkit-scrollbar { display: none; }
.sheet__body--list {
  gap: 6px;
}

/* Name sheet input + GIF sheet search inherit .card input styles
   from style.css. Apply a small override so the input still looks
   right outside a .card. */
.sheet__body > input[type="text"] {
  width: 100%;
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--panel-stroke);
  border-radius: var(--radius-sm);
  padding: 12px 14px;
  font-size: 16px;
  outline: none;
}
.sheet__body > input[type="text"]:focus {
  border-color: var(--panel-stroke-strong);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 18%, transparent);
}

/* ---- Toggle (pill switch) ----------------------------------------- */
.sheet__toggle-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 6px 0;
  cursor: pointer;
  font-size: 14px;
  color: var(--text);
}
.sheet__toggle-row--card {
  padding: 12px 14px;
  background: var(--panel-2);
  border: 1px solid var(--panel-stroke);
  border-radius: var(--radius-sm);
}
.sheet__toggle-label {
  flex: 1;
  min-width: 0;
}
.toggle {
  position: relative;
  display: inline-flex;
  flex: 0 0 auto;
}
.toggle input {
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;
}
.toggle__track {
  display: inline-block;
  width: 38px;
  height: 22px;
  border-radius: var(--radius-pill);
  background: var(--panel-stroke-strong);
  position: relative;
  transition: background 140ms ease;
}
.toggle__thumb {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--text);
  transition: transform 140ms ease, background 140ms ease;
}
.toggle input:checked + .toggle__track {
  background: var(--panel-stroke-strong);
}
.toggle input:checked + .toggle__track .toggle__thumb {
  transform: translateX(16px);
  background: var(--text);
}
.toggle input:focus-visible + .toggle__track {
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 22%, transparent);
}

/* ---- Model sheet — rows reuse .model-card from style.css --------- */
.model-grid--sheet {
  grid-template-columns: 1fr;
  margin-top: 0;
  gap: 8px;
}

/* ---- GIF sheet ----------------------------------------------------- */
/* Pinned search row — lives in `.sheet__gif-search` between the title
   row and `.sheet__body--scroll`, same pattern as the voice sheet toggle
   sitting outside the scroll region. */
.sheet__gif-search {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.giphy-quick-search--sheet {
  margin-top: 0;
  gap: 8px;
}
/* Input + button live outside any .card, so the brand-token treatment
   that .card input[type="search"] gets in style.css doesn't apply here.
   Restate it against panel-2 / panel-stroke / accent so the field stops
   rendering as the browser-default white pill inside the sheet. */
.giphy-quick-search--sheet input[type="search"] {
  flex: 1;
  min-width: 0;
  height: 40px;
  margin: 0;
  padding: 0 12px;
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--panel-stroke);
  border-radius: var(--radius-sm);
  font: inherit;
  font-size: 14px;
  line-height: 1.2;
  outline: none;
  -webkit-appearance: none;
  appearance: none;
  transition: border-color 120ms ease, box-shadow 120ms ease;
}
.giphy-quick-search--sheet input[type="search"]::placeholder {
  color: var(--text-muted);
}
.giphy-quick-search--sheet input[type="search"]:focus {
  border-color: var(--panel-stroke-strong);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 18%, transparent);
}
.giphy-quick-search--sheet input[type="search"]::-webkit-search-cancel-button,
.giphy-quick-search--sheet input[type="search"]::-webkit-search-decoration {
  -webkit-appearance: none;
  appearance: none;
}
.giphy-quick-search--sheet .giphy-search-button {
  position: relative;
  flex: 0 0 auto;
  width: 40px;
  height: 40px;
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--panel-stroke);
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
.giphy-quick-search--sheet .giphy-search-button.is-loading {
  pointer-events: none;
  cursor: wait;
}
.giphy-quick-search--sheet .giphy-search-button__icon {
  display: inline-flex;
  line-height: 0;
}
.giphy-quick-search--sheet .giphy-search-button__icon svg {
  width: 18px;
  height: 18px;
}
.giphy-quick-search--sheet .giphy-search-button.is-loading .giphy-search-button__icon {
  visibility: hidden;
}
.giphy-quick-search--sheet .giphy-search-button.is-loading .message-shell__spark-loader {
  display: block;
}
.giphy-quick-search--sheet .giphy-search-button:hover {
  background: var(--panel-hover);
  border-color: var(--panel-stroke-strong);
  color: var(--text);
}
.giphy-quick-search--sheet .giphy-search-button:focus-visible {
  outline: none;
  border-color: var(--panel-stroke-strong);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 18%, transparent);
}
.image-category-cards--sheet {
  margin-top: 6px;
}
/* Category / GIPHY detail — tight under pinned search (match card browse). */
#sheet-gif .sheet__body--scroll {
  gap: 6px;
}
#sheet-gif .image-category {
  margin-top: 0;
}
#sheet-gif .image-category__head {
  margin: 0 0 4px;
}
.image-grid--sheet {
  max-height: none;
}

/* GIF picker — fixed height, natural-aspect width.
   Every GIF thumb in the sheet (GIPHY results + the per-category grid
   via .image-grid--modal) renders at the same fixed height —
   `--sheet-gif-cell-h` — and the cell's width follows the GIF's
   natural aspect ratio. Wide / panoramic GIFs stay wide, tall GIFs
   stay narrow; no center-crop, no stretch, just a consistent vertical
   rhythm across the picker. (The retired top 4-up preview-grid was
   removed 2026-05-22; we keep `.sheet .image-preview-grid` in the
   selectors so any future single-cell preview placed inside a sheet
   still inherits the same flex-natural-aspect behavior.)

   Implementation: cells flow as flex items in a wrapping row. Each
   cell is height-pinned, width-auto; the inner <img> is also fixed
   to 100% height with `width: auto` so the browser sizes the box to
   the image's natural aspect once it loads. A small `min-width`
   keeps un-loaded cells from collapsing to 0 (which would break the
   wrap layout before lazy-loaded thumbs come in). The `grid-template-
   columns: none` line is required because .image-grid / .image-
   preview-grid set their own grid-template-columns in style.css;
   without resetting it, switching the container `display` to flex
   isn't enough to neutralize the column track sizes. */
.sheet {
  --sheet-gif-cell-h: 80px;
}
.sheet .image-category-card {
  height: var(--sheet-gif-cell-h);
  min-height: 0;
  aspect-ratio: auto;
}
/* Failed category cover — hide broken-image chrome, show black tile. */
.sheet .image-category-card.image-category-card--cover-missing {
  background-color: var(--bg);
}
.sheet .image-category-card.image-category-card--cover-missing .image-category-card__cover {
  display: none;
}
.sheet .image-grid,
.sheet .image-grid--giphy,
.sheet .image-grid--modal,
.sheet .image-preview-grid {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-start;
  align-content: flex-start;
  gap: 6px;
  grid-template-columns: none;
  /* style.css `.image-grid` paints var(--bg) + border; sheet sits on
     var(--panel) — drop the inner black box so thumbs sit on the card. */
  margin-top: 0;
  padding: 0;
  max-height: none;
  overflow: visible;
  background: transparent;
  border: 0;
  border-radius: 0;
}
/* `display: flex` above is more specific than `.image-grid[hidden] {
   display: none }` in style.css (same specificity, declared later), so
   hidden GIPHY results would still render an empty bordered box below
   the search input. Re-assert `display: none` for the [hidden] state at
   the same .sheet scope so an unsubmitted modal/sheet shows just the
   search row + categories — no awkward empty grid. (2026-05-22) */
.sheet .image-grid[hidden],
.sheet .image-grid--giphy[hidden],
.sheet .image-grid--modal[hidden],
.sheet .image-preview-grid[hidden] {
  display: none;
}
.sheet .image-cell {
  flex: 0 0 auto;
  width: auto;
  max-width: none;
  height: var(--sheet-gif-cell-h);
  max-height: none;
  min-width: 60px;
  aspect-ratio: auto;
  justify-self: stretch;
  background: transparent;
  border: 0;
  box-shadow: none;
}
.sheet .image-cell:hover {
  border: 0;
  box-shadow: none;
}
.sheet .image-cell img {
  width: auto;
  height: 100%;
  max-width: none;
  max-height: 100%;
  object-fit: cover;
}
.image-category__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin: 6px 0 6px;
}
.image-category__title {
  flex: 1;
  min-width: 0;
}
.image-category__back {
  flex-shrink: 0;
  margin-left: auto;
  border: 1px solid var(--panel-stroke);
  border-radius: var(--radius-pill);
  background: var(--panel-2);
  color: var(--text);
  height: 30px;
  padding: 0 10px;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font: inherit;
  font-size: 12px;
  cursor: pointer;
}
.image-category__back svg {
  width: 12px;
  height: 12px;
}
.image-category__head .image-category__title {
  margin: 0;
  font-size: 13px;
  font-weight: 700;
  color: var(--text);
  letter-spacing: 0.02em;
  text-align: left;
}
.gif-quick-actions {
  display: flex;
  gap: 8px;
  margin-top: 10px;
}
.gif-quick-action {
  flex: 1;
  min-height: 38px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  border: 1px solid var(--panel-stroke);
  border-radius: var(--radius-sm);
  background: var(--panel-2);
  color: var(--text);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease;
}
.gif-quick-action__icon {
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  display: block;
}
.gif-quick-action:hover {
  background: var(--panel-hover);
  border-color: var(--panel-stroke-strong);
}

/* ---- Voice sheet --------------------------------------------------- */
.voice-grid--sheet {
  grid-template-columns: 1fr;
  max-height: none;
  margin-top: 0;
  gap: 8px;
}

/* ---- Compact prompt: neutral pickers (no gamer-green chrome) -------- */
.compact-layout .model-card.is-active {
  border-color: var(--panel-stroke-strong);
  background: var(--panel-hover);
  box-shadow: none;
}
.compact-layout .model-card.is-active .model-card__check {
  background: var(--text);
  border-color: var(--text);
}
.compact-layout .model-card.is-active .model-card__check::after {
  color: var(--bg);
}
.compact-layout .voice-card.is-active {
  border-color: var(--panel-stroke-strong);
  background: var(--panel-hover);
}
.compact-layout .voice-card.is-active .voice-card__check {
  background: var(--text);
  border-color: var(--text);
}
.compact-layout .voice-card.is-active .voice-card__check::after {
  color: var(--bg);
}
.compact-layout .voice-card__preview:hover {
  border-color: var(--panel-stroke-strong);
}
.compact-layout .voice-card__preview[data-state="playing"] {
  background: var(--panel-hover);
  border-color: var(--panel-stroke-strong);
  color: var(--text);
}
.compact-layout .voice-card__preview[data-state="loading"] {
  border-color: var(--panel-stroke-strong);
  color: var(--text-muted);
}
.compact-layout .image-cell.is-active {
  border-color: var(--panel-stroke-strong);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 22%, transparent);
}
.compact-layout .sheet .image-cell.is-active {
  border-color: transparent;
  box-shadow: none;
}
/* GIF sheet: pick closes immediately — no in-grid checkmark flash */
.compact-layout .sheet .image-cell .image-cell__check {
  display: none;
}
.compact-layout .hat-cell.is-active,
.compact-layout .sheet .hat-cell.is-active {
  border-color: transparent;
  box-shadow: none;
}
.compact-layout .image-cell.is-active .image-cell__check {
  background: var(--text);
  color: var(--bg);
}
.compact-layout .image-category__back:hover {
  border-color: var(--panel-stroke-strong);
}

/* ---- Pick-a-hat sheet (grid on inner `.hat-cell__frame`, not `<button>` —
   Chromium/WebKit ignore display:grid/flex on buttons (internal wrapper
   shrink-wraps). MDN card cookbook + SO #35464067 / gridbugs #10. */
#sheet-hat {
  --hat-sheet-copy-pad-y: 1px;
  /* 2-line title + credit + gap; pad-y gives breathing room on both sides */
  --hat-sheet-copy-text-h: calc(9px * 1.15 * 2 + 2px + 8px * 1.15);
  --hat-sheet-copy-min-h: calc(var(--hat-sheet-copy-text-h) + var(--hat-sheet-copy-pad-y) * 2);
}
.compact-layout #sheet-hat .hat-grid.hat-grid--sheet {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  align-items: stretch;
  gap: 8px;
  margin-bottom: 12px;
}
.compact-layout #sheet-hat .hat-grid--sheet > .hat-cell {
  display: block;
  align-self: stretch;
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  min-height: 0;
  /* No bottom pad — it sat below the copy band and made labels look top-heavy. */
  padding: 4px 4px 0;
  -webkit-appearance: none;
  appearance: none;
}
.compact-layout #sheet-hat .hat-grid--sheet > .hat-cell .hat-cell__frame {
  display: grid;
  grid-template-rows: auto minmax(var(--hat-sheet-copy-min-h), 1fr);
  width: 100%;
  height: 100%;
  min-height: 0;
}
.compact-layout #sheet-hat .hat-grid--sheet > .hat-cell .hat-cell__thumb {
  display: block;
  width: 100%;
  aspect-ratio: 1;
  border-radius: var(--radius-xs);
  background: var(--surface-soft);
  overflow: hidden;
}
.compact-layout #sheet-hat .hat-grid--sheet > .hat-cell .hat-cell__thumb img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}
.compact-layout #sheet-hat .hat-grid--sheet > .hat-cell .hat-cell__copy {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  min-height: var(--hat-sheet-copy-min-h);
  padding: var(--hat-sheet-copy-pad-y) 0;
  margin: 0;
  width: 100%;
}
.compact-layout #sheet-hat .hat-grid--sheet > .hat-cell .hat-cell__copy-inner {
  display: block;
  width: 100%;
  margin: 0;
  padding: 0;
  text-align: center;
}
.compact-layout #sheet-hat .hat-grid--sheet > .hat-cell .hat-cell__label {
  font-size: 9px;
  line-height: 1.15;
  overflow: hidden;
  overflow-wrap: anywhere;
  width: 100%;
  max-height: calc(9px * 1.15 * 2);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
}
.compact-layout #sheet-hat .hat-grid--sheet > .hat-cell .hat-cell__credit {
  font-size: 8px;
  line-height: 1.15;
  margin-top: 2px;
  overflow: hidden;
  width: 100%;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 1;
  line-clamp: 1;
}

/* ---- Larger screens — extra breathing room above the player ---------
   Width stays full-bleed at every breakpoint (see .compact-main above);
   the top inset relaxes a bit so the player doesn't crowd the header
   lockup on tall desktop viewports, and the per-cell horizontal gutter
   bumps to --brand-header-padding-x-desktop so the prompt-card edges
   stay aligned with the desktop top-bar lockup. */
@media (min-width: 720px) {
  .compact-main {
    padding-top: calc(var(--top-bar-h) + 22px);
  }
  .compact-main .page-carousel__cell {
    padding: 0 var(--brand-header-padding-x-desktop);
  }
}
