/* BoredAI scroll-animation system — global master rules (brief 042, Stage 1, preview only).
   Lives entirely in this file plus its JS twin (boredai-scroll-anim.js) so that when ARK says
   "push to live", the animation/feather layer can be lifted onto live's own tree as two linked
   files plus a handful of class/data attributes on existing headings — no shared edits to
   live's own bespoke hero or layout CSS. See that file for how a new section opts in.

   The recipe (brief §A): vertical rise out of a feathered mask, eased with the same soft
   overshoot-and-settle curve the hero tagline's own word-rise already uses (cubic-bezier
   (.3,1.5,.4,1), 850ms) — copied from .bai-word/.bai-word-rise above, not reinvented. */

:root{
  --sa-dur:850ms;            /* a single reveal's own run time */
  --sa-ease:cubic-bezier(.3,1.5,.4,1);
  --sa-beat:320ms;           /* one beat, same rhythm as the hero tagline's own word stagger */
  --sa-wave:60ms;            /* fast sequential stagger for a future "wave" of several items */
}

/* 16 Oct review round 2, item 1: --sa-feather has to be a REGISTERED (typed) custom property
   for the browser to interpolate it as a percentage across a transition — an unregistered
   custom property is just a string token to the animation engine, so transitioning it would
   silently jump instead of tweening, same as the mask-image:none it replaces below. */
@property --sa-feather{syntax:'<percentage>';inherits:false;initial-value:58%}

/* The feathered mask itself: percentage stops relative to each element's OWN box, so it scales
   with that element's own font-size/height instead of being one fixed mask reused everywhere
   (brief §B) — same stops the hero tagline's .bai-word-mask already uses, so every size reads
   as the same look. Heading uses keep this permanently (§B); non-heading uses remove it once
   settled via .sa-settled (§A — no feather left on anything else once it rises into place). */
.sa-mask{
  --sa-feather:58%;
  -webkit-mask-image:linear-gradient(to bottom,#000 var(--sa-feather),transparent 100%);
  mask-image:linear-gradient(to bottom,#000 var(--sa-feather),transparent 100%);
  transition:--sa-feather var(--sa-dur) var(--sa-ease);
}
/* 16 Oct review round 2, item 1: the feather now shrinks to nothing (var(--sa-feather) tweens
   58%/79% up to 100%, so the gradient's visible band collapses to a hairline at the very edge)
   in lockstep with the rise, and only THEN does .sa-settled below switch mask-image off — by
   then the switch is invisible, because the masked and unmasked states already look identical.
   :not([data-sa-head]) keeps a heading's own permanent feather (see the override further down)
   untouched even though headings also pick up .sa-in for their rise transform. See
   boredai-scroll-anim.js playIn()/watchSettle() for what sets .sa-in here and why watchSettle
   now keys off the transform property specifically, so this and the rise finish together. */
.sa-mask.sa-in:not([data-sa-head]){--sa-feather:100%}
.sa-mask.sa-settled{-webkit-mask-image:none;mask-image:none}
/* 18 Oct 2026, voice brief: the 16 Oct narrowing above made the ramp read too short/quick —
   ARK wants the opposite direction. Ramp now runs over twice the distance it covered at 79%
   (21% of the box) by moving the start back out to 58% (42% of the box) — same end point
   (100%, fully transparent), so the bottom row's own opacity is untouched; only how far up
   from there the fade climbs before hitting full solid white changes. */
[data-sa-head].sa-mask{--sa-feather:58%}
/* 18 Oct 2026, second pass same brief: ARK reviewed the ramp above live and it read right, but
   the bottom row itself (100% stop) was too transparent. Ramp start is untouched (still 58%);
   only the bottom stop changes, from fully transparent to 50% opaque — headings only, via this
   heading-specific mask-image, so the non-heading .sa-mask rule above (whose bottom row only
   ever shows mid-reveal, before .sa-settled removes the mask entirely) is unaffected. */
[data-sa-head].sa-mask{
  -webkit-mask-image:linear-gradient(to bottom,#000 var(--sa-feather),rgba(0,0,0,.5) 100%);
  mask-image:linear-gradient(to bottom,#000 var(--sa-feather),rgba(0,0,0,.5) 100%);
}

/* The moving part. A real CSS transition (not a @keyframes animation) on purpose: if scroll
   direction flips mid-flight, a transition's target value just changes and the browser
   continues smoothly from whatever position it's already at — no snap, no restart (brief §E,
   the dampener). An interrupted @keyframes animation would instead jump to that keyframe's own
   "from" state. */
.sa-rise{
  transform:translateY(68%);
  opacity:0;
  /* --sa-feather riding along here (16 Oct review round 2, item 1): on any element that carries
     BOTH .sa-mask and .sa-rise directly (no separate inner wrapper — the "container-with-
     contents" members below, plus any plain mask+rise element with no dedicated override), the
     `transition` shorthand is a single cascade winner, so .sa-mask's own --sa-feather transition
     declaration would otherwise be entirely replaced rather than merged. Listing it here too
     (same duration/ease as the rise) means whichever rule wins still carries the feather. A
     harmless no-op on a .sa-rise element with no .sa-mask (nothing ever sets --sa-feather there). */
  transition:transform var(--sa-dur) var(--sa-ease),opacity calc(var(--sa-dur) * .7) linear,--sa-feather var(--sa-dur) var(--sa-ease);
  will-change:transform,opacity;
}
.sa-rise.sa-in{transform:translateY(0);opacity:1}

/* Fills its parent exactly, so absolutely-positioned layers inside (tile art, rim, halo, info)
   keep resolving against the same box they always did — this wrapper is the new nearest
   positioned ancestor, sized identically to the tile it sits inside. Used for the first
   section's tiles/cue only; headings animate themselves directly, no wrapper needed. */
.sa-rise-fill{display:block;position:relative;width:100%;height:100%}
/* The "scroll to meet Ace" cue's own children were laid out by .cue's flex-column directly —
   now that they sit one level deeper inside .sa-rise-fill, that same stacking has to happen on
   the wrapper instead. Scoped to .cue only; every other .sa-rise-fill usage keeps the plain
   block default above (its own children are all absolutely positioned, so display mode here
   doesn't reach them either way). */
.cue .sa-rise-fill{display:flex;flex-direction:column;align-items:center;gap:6px}

/* Stage 2 (brief 043) layout overrides — same reasoning as .cue .sa-rise-fill above: a handful
   of members are flex/inline-flex containers whose own children relied on that layout (a seat
   tile's number-then-quote spread apart, a pay pill's coin icon sitting beside its own label),
   so the plain block default a level deeper would collapse that spacing. Scoped narrowly, one
   rule per shape, rather than changing the shared .sa-rise-fill default for everyone. */
.seat .sa-rise-fill{display:flex;flex-direction:column;justify-content:space-between;height:100%}
.paybtn .sa-rise-fill{display:inline-flex;align-items:center;gap:10px}
.ace-chat-fill{display:flex;flex-direction:column;height:100%}

/* Container-with-contents grouping (16 Oct review, brief 005c): OG seat tiles, Prepay "coming
   soon" tiles and the coin pills now put .sa-rise on the OUTER tile/pill itself (index.html),
   same recipe as the Ace avatar+chat panel (brief 005a's ace-row2) — so the tile's own
   background/border rises and fades in together with its contents instead of sitting fully
   opaque and static while only the text inside flies in. The inner .sa-rise-fill wrapper no
   longer also carries .sa-rise (single motion source, not two compounding transforms).
   .seat alone needs the explicit override below; .pay-card and .paybtn declare no transition
   of their own so .sa-rise's base rule already applies cleanly to them with no extra CSS. */
.seat.sa-rise{
  transition:transform var(--sa-dur) var(--sa-ease),opacity calc(var(--sa-dur) * .7) linear,border-color .8s var(--ease),box-shadow 1.1s var(--float),--sa-feather var(--sa-dur) var(--sa-ease);
}
/* .seat's own base rule (index.html, loaded after this file) declares `transition:transform
   1.1s var(--float),border-color .8s var(--ease),box-shadow 1.1s var(--float)` with the same
   specificity as .sa-rise's own `transition` — at a tie the later rule (index.html's) would win
   outright and silently drop the opacity transition this entrance needs. The two-class selector
   above outranks both regardless of load order, so the entrance's transform+opacity pair always
   applies; border-color/box-shadow are carried over unchanged so the existing hover feel holds. */

/* "Pay"/"Contact Lanzer" button text centring (16 Oct review, brief 005c): .angel-cta-btn's own
   justify-content:center only centres the flex ITEM — but .sa-rise-fill is display:block at
   100% width, so it already fills the whole flex item and the text inside it was left-aligned
   by plain CSS default, flush against the button's left padding instead of centred. */
.angel-cta-btn .sa-rise-fill{text-align:center}

/* Whitelist tile as one container (16 Oct review, brief 005d item 1): .wl-panel's own base rule
   (index.html, loaded after this file) declares `transition:transform 1.1s var(--float),
   box-shadow 1.1s var(--float)` at the same specificity as .sa-rise's own `transition` — same
   cascade tie .seat.sa-rise above already fixes for the seat tiles, same fix here: the two-class
   selector outranks both regardless of load order, so the entrance's transform+opacity pair
   always applies; the hover box-shadow glow is carried over unchanged. */
.wl-panel.sa-rise{
  transition:transform var(--sa-dur) var(--sa-ease),opacity calc(var(--sa-dur) * .7) linear,box-shadow 1.1s var(--float),--sa-feather var(--sa-dur) var(--sa-ease);
}

/* The green check-tick draw (brief 043 §2/§3): the circle draws itself clockwise from 12
   o'clock, then — as it finishes its last quarter — the check mark draws inside it with a tiny
   overshoot-and-settle "lift", using the very same .sa-ease the rest of the system already uses
   rather than a one-off keyframe. Lives on the tick's own <svg>, which doubles as the
   `.sa-rise-fill` the controller looks for — these don't rise/translate at all (the brief is
   explicit the ticks stay put while the text around them rises), so no .sa-rise class here.
   `pathLength="100"` on the circle/check markup turns both into plain 0-100 percentages
   regardless of their real pixel length, so this dasharray math never needs to match hand-measured
   path lengths. */
.tick-wrap{display:inline-flex;flex:none}
.sa-tick-anim{overflow:visible}
/* 100.6, not 100 (16 Oct review, brief 005a item 2): a dasharray exactly equal to the
   normalized pathLength leaves a hairline seam at the circle's start/end point in some
   browsers' arc rendering — the dash and the gap meet exactly at the join with no overlap to
   hide the antialiasing. Running the dash very slightly past the full lap means there's no
   room left in one circumference for any gap to show at all once dashoffset reaches 0, so the
   stroke closes solid with a sub-pixel self-overlap instead of a visible gap. dashoffset's
   resting value matches the dash length so the hidden state is still fully hidden. */
/* 16 Oct review round 2, item 6: the group's own per-item stagger (registerGroup's `delayFor`)
   is written as `transitionDelay` on the tick's <svg> (the element registerGroup matches as
   this member's `.sa-rise-fill`) — but the circle/check below are its CHILDREN with their own
   hardcoded `transition-delay`, so that inline style landed on an element with nothing of its
   own to delay and never reached the properties that actually draw. `--sa-tick-delay` is a
   custom property instead of a plain style property specifically because custom properties
   inherit down to descendants by default — set once on the <svg> in boredai-scroll-anim.js's
   playIn(), it reaches both children below without either of them being matched directly. */
/* batch-12 child c, item 10: the circle's fill (markup now a flat #4ADE80, the alpha that used
   to live in its rgba() moved here) was a static always-on wash — visible on page load, before
   any draw started. fill-opacity now starts at 0 and transitions to the same .14 the old rgba
   carried, on the SAME transition-delay as the outline's own stroke-dashoffset (so the fill
   starts exactly when the outline begins drawing) over .5625s+.75s = 1.3125s — the circle's own
   .75s draw plus the check's .5625s delay and .75s draw, i.e. ending exactly when the check
   finishes, not when the circle does. */
.tick-circle{stroke-dasharray:100.6;stroke-dashoffset:100.6;fill-opacity:0;transform-box:fill-box;transform-origin:50% 50%;transform:rotate(-90deg);
  transition:stroke-dashoffset .75s linear,fill-opacity 1.3125s linear;transition-delay:var(--sa-tick-delay,0s)}
.tick-check{stroke-dasharray:100;stroke-dashoffset:100;transform-box:fill-box;transform-origin:50% 50%;transform:scale(.99);
  transition:stroke-dashoffset .75s var(--sa-ease),transform .75s var(--sa-ease);
  transition-delay:calc(var(--sa-tick-delay,0s) + .5625s)}
.sa-tick-anim.sa-in .tick-circle{stroke-dashoffset:0;fill-opacity:.14}
.sa-tick-anim.sa-in .tick-check{stroke-dashoffset:0;transform:scale(1)}

/* Stage 3 (brief this-job) additions. Same reasoning as Stage 2's own additions above: new
   primitives the controller's registerSimple() (see .js) can drive directly on an existing
   element, no .sa-rise-fill wrapper required — used wherever the target is a leaf (an SVG
   <line>, a button's own text) or wherever wrapping would fight an element's own hover
   transform (the documented Stage 1 gotcha) and a plain fade sidesteps that entirely. */

/* Fade-only counterpart to .sa-rise — opacity alone, no translateY. Used for the Wall of Fame
   blossom reveal (brief: "FADE in, not rise") and anywhere an entrance transform would collide
   with an element's own hover/pulse transform (hero cue + sound toggle below). */
.sa-fade{opacity:0;transition:opacity .4s linear;will-change:opacity}
.sa-fade.sa-in{opacity:1}
/* 22 Oct addendum (ARK: Wall of Fame's inner rings "smash in with no animation"): on the
   founder/OG-backer avatars, .sa-fade shares its element with .wd (index.html, class="wd
   sa-fade"), which carries its OWN `transition:transform .15s` for the hover-scale effect
   (.wn[data-n]:hover .wd{transform:scale(1.25)}). Both are single-class selectors — an exact
   specificity tie — and .wd's rule sits LATER in source order (index.html's inline <style>
   loads after this file's <link>), so by the cascade it wins the WHOLE `transition` shorthand,
   leaving these avatars with no transition on opacity at all: .sa-in's opacity:1 lands the
   instant the class is added, with no fade and no respect for the per-member delay set via
   transitionDelay (nothing to delay). Same class-tie shape as the .sa-mask feather fix above
   (wireFeatherTransition) and the .seat/.wl-panel .sa-rise fixes elsewhere in this file — the
   fix here is the same kind: a two-class selector beats either single-class rule outright
   regardless of source order, so this is just .wd's own transition with .sa-fade's opacity
   transition added back in, never overridden again. */
.wd.sa-fade{transition:transform .15s,opacity .4s linear}
/* batch-12 child d (22 Oct, "Lanzer/Lampchairfloor/Njord still switch on like a light switch"):
   the three real founders get a scale+opacity pop instead of the plain fade above — still no
   translateY (rise was rejected for this element, see the "FADE in, not rise" comment higher up)
   so this only adds a scale transform riding alongside the existing opacity fade. Scoped to
   .wn.wf (the three real founders) only, not .wn.wa (OG-Backer placeholder circles, which stay
   on the plain fade above — nothing real to pop yet, same "out of scope" status every other
   placeholder slot on this panel already carries). Three classes outranks .wd.sa-fade's two
   regardless of source order, same cascade-tie pattern as every other fix in this file. */
.wn.wf .wd.sa-fade{transform:scale(.5);transition:transform .5s var(--sa-ease),opacity .35s linear}
.wn.wf .wd.sa-fade.sa-in{transform:scale(1)}

/* Whitelist tile emblem letterbox (brief: "mask clips hard at the tile's edges, never spills").
   The crest's own sizing moved here from .wl-crest; the img now just fills this box so its
   rise is clipped by a real overflow:hidden rather than the soft feather .sa-mask uses
   elsewhere — a hard boundary is the point, not a fade. */
.wl-crest-mask{width:182px;height:100%;max-height:260px;overflow:hidden;display:block;justify-self:center;margin:0 auto}
.wl-crest-mask .wl-crest{width:100%;height:100%;max-height:none}
/* 16 Oct review, item 2: the crest's base .sa-rise curve overshoots past its landing point
   (the same soft bounce every other .sa-rise uses) before settling — measured at ~10px of
   travel above the resting position. Against a hard overflow:hidden box (the "never spills"
   rule above, kept as-is rather than loosened), that overshoot poked above the clip box's own
   top edge and got visibly cut mid-bounce. A gentler, still-eased curve with the overshoot
   control point pulled in from 1.5 to 1.08 keeps the same soft landing feel without the swing
   that clips. */
.wl-crest-mask .wl-crest.sa-rise{transition:transform .85s cubic-bezier(.3,1.08,.4,1),opacity calc(var(--sa-dur) * .7) linear}
@media (max-width:480px){.wl-crest-mask{width:126px;height:126px}}

/* Wall of Fame blossom lines (angel1-5 + the three real founders) — this rule's own
   percentage-based dasharray/dashoffset (pathLength="100") is now only ever a flash-of-
   unstyled-content fallback for the instant before .js runs: on paths this short, that
   normalized dash math renders as a visibly broken/dashed line rather than one clean stroke
   (found on the three founders, 16 Oct review item 6; batch-12 child d, item 13 found the same
   risk on the angel1-5 placeholder lines, which use this same rule). Both now get overridden by
   .js with their own real on-screen pixel length (getTotalLength()) via inline style the instant
   it runs — drawFounderLines() for the three founders (which also drives their reveal), and
   pixelizeGhostLines() for the five angel placeholders (which stay permanently hidden either
   way — no trigger exists for them, nothing real to connect yet — this only makes that hidden
   state dash-safe, same as the founders' visible one). The .sa-in rule below is dead for both:
   neither ever gets that class (founders animate via inline style, angels via no trigger at
   all) — kept only for the reduced-motion override lower in this file, which forces
   stroke-dashoffset:0 directly rather than through .sa-in. */
svg.wof-links line[data-wof-line]{stroke-dasharray:100;stroke-dashoffset:100;transition:stroke-dashoffset .4s linear}
svg.wof-links line[data-wof-line].sa-in{stroke-dashoffset:0}

/* Ghost-ring tiers (Prepay/blue, Whitelist/purple — brief's "blue circles"/"purple circles"
   beats): these are unpopulated placeholder slots with no individual line or photo yet, so a
   per-dot line-draw would animate nothing real. registerSimple() fades the whole ring in as one
   fast sweep instead (a few ms of stagger per dot, see .js) — the moment either ring holds real
   accounts, the same per-item line+fade recipe as the top tier can be lifted onto it the way
   the top tier itself is built, see the .js comment above registerSimple(). */
.wn.wp .wd,.wn.ww .wd{opacity:0;transition:opacity .4s linear}
.wn.wp .wd.sa-in,.wn.ww .wd.sa-in{opacity:1}

/* Terms of Service row wave: the static border-top becomes an animated draw (brief: "divider,
   then title+arrow"). .tos-sub needs position:relative as the new span's anchor — done in
   index.html's own tos-sub rule, not duplicated here. */
.tos-divider{position:absolute;top:0;left:0;right:0;height:1px;background:rgba(255,255,255,.1);
  transform:scaleX(0);transform-origin:left;transition:transform .4s linear}
.tos-divider.sa-in{transform:scaleX(1)}
/* The chev's own [open]-state rotate transition must not share a transition rule with this
   entrance transform (Stage 1's hover-transform gotcha, same shape here) — wrapped in
   .chev-fill in index.html so the rotate stays on the bare <svg> untouched. */
.chev-fill{display:inline-flex;flex:none}

/* "Meet Ace" chat loop (brief 005b-continued-a, 16 Oct review): a reusable dots→shape morph
   primitive (bubbles today, parameterised so a later brief can drive the same primitive for a
   voice waveform shape instead) plus the looping conversation it drives. All sizing/timing is
   set inline by boredai-scroll-anim.js (box-sizing:border-box here so the JS's measured
   border-box target width/height applies directly) — this file only owns the dot bounce, the
   fade handoff between dots and text, and the reduced-motion kill switch below. */
/* :not(.ace-morph) on purpose (found 23 Oct 2026, G-ACE-BUBBLE-SNAP): #aceChatLog .bubble has
   an ID in its selector, which outranks .ace-morph's own transition rule below on specificity
   alone regardless of source order — `transition` is a single shorthand property, so whichever
   selector wins supplies ALL of it, not a merge of both. That meant every morphing bubble's
   real computed transition was "transform .5s" only, and its width/height/border-radius/padding
   transitions from .ace-morph were silently discarded — the morph still *tried* to transition,
   it just had no transition to run, so it jumped straight to its final size on the frame the
   width changed. Plain (non-morphing) bubbles keep the transform-only rule; .ace-morph now
   carries transform itself too, alongside the sizing transitions, so nothing is lost. */
#aceChatLog .bubble:not(.ace-morph){transition:transform .5s cubic-bezier(.3,.9,.4,1)}
.ace-morph{box-sizing:border-box;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:0;
  transition:transform .5s cubic-bezier(.3,.9,.4,1),width .65s cubic-bezier(.3,.9,.4,1),height .65s cubic-bezier(.3,.9,.4,1),border-radius .65s cubic-bezier(.3,.9,.4,1),padding .65s cubic-bezier(.3,.9,.4,1),opacity .3s linear}
.ace-morph.is-shaping{padding:13px 16px}
.ace-morph .morph-dots{display:flex;gap:4px;align-items:center;flex:none;transition:opacity .18s linear,transform .18s linear}
.ace-morph .morph-dots i{width:6px;height:6px;border-radius:50%;background:currentColor;opacity:.5;animation:aceDotBounce 1.1s ease-in-out infinite}
.ace-morph .morph-dots i:nth-child(2){animation-delay:.15s}
.ace-morph .morph-dots i:nth-child(3){animation-delay:.3s}
@keyframes aceDotBounce{0%,60%,100%{transform:translateY(0);opacity:.45}30%{transform:translateY(-4px);opacity:1}}
.ace-morph.is-shaping .morph-dots{opacity:0;transform:scale(.4)}
.ace-morph .morph-text{opacity:0;transition:opacity .32s linear;flex:none;width:100%}
/* Ace's reply types in as per-character spans (see typeAceText in boredai-scroll-anim.js) so
   the full string's line-wrap is settled before any character shows — each one just flips
   from invisible to visible in place, nothing is ever re-wrapped mid-type. */
.morph-text .ace-char{opacity:0}
.morph-text .ace-char.is-shown{opacity:1}
.ace-morph.is-filled .morph-text{opacity:1}
.ace-morph.is-filled .morph-dots{display:none}
/* Chat bubbles only (not the hold-wave voice waveform, which keeps its bars vertically
   centred within its own nested flex row): the box is already fixed at its final measured
   size before typing starts, but the outer align-items:center above re-centres whatever
   text IS revealed so far every time a new line starts, sliding already-typed characters
   upward mid-type. Pinning to the top once text is revealing means the already-exact box
   height (it was measured off the SAME final text) leaves no slack to recentre into, so
   nothing already on screen ever moves again. */
.bubble.ace-morph.is-filled{align-items:flex-start}

/* Typed-message variant (brief 005b-continued-b): the #say field appears to type itself on the
   loop's "you" turns, the send button presses, and the typed line visibly flies from the field
   into the log as a bubble. `disabled`/`aria-disabled` on the real controls are untouched —
   these are pure visual states driven by class toggles in boredai-scroll-anim.js. */
.field.is-typing{border-color:color-mix(in oklab,var(--accent) 60%,transparent);
  box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 0 0 3px color-mix(in oklab,var(--accent) 16%,transparent)}
.field.is-sent{transition:border-color .5s var(--ease),opacity .18s linear;opacity:.7}
.btn-icon.is-pressed{animation:aceSendPress .22s ease}
@keyframes aceSendPress{0%{transform:scale(1)}45%{transform:scale(.78)}100%{transform:scale(1)}}
/* The flying ghost: a real .bubble.you clone so it already carries the bubble's own colours/
   radius — this file only adds the fixed positioning and the width/height/position tween that
   carries it from the field's shape to the real bubble's measured shape. */
.ace-send-ghost{position:fixed;top:0;left:0;z-index:60;pointer-events:none;margin:0;box-sizing:border-box;
  white-space:nowrap;overflow:hidden;max-width:none;
  transition:width .36s cubic-bezier(.3,.9,.4,1),height .36s cubic-bezier(.3,.9,.4,1),transform .36s cubic-bezier(.3,.9,.4,1)}

/* Voice-input variant (brief 005b-continued-c): a second "turn path" for the loop's "you" turns,
   alternating with the typed path above. The hold-to-talk label's roll-away is a NEW primitive —
   nothing like it existed anywhere on the page (checked index.html's styles/scripts, boredai-
   login.js, boredai-checkout.js; only prior rule was the instant max-width:560px display:none
   below) — a plain max-width/opacity collapse, JS sets the real starting width inline (see .js)
   so the transition has a real number to animate from regardless of language/line length.
   The waveform reuses brief a's .ace-morph dots-stretch-into-a-shape primitive verbatim (same
   class, same width/height/border-radius/opacity transition, same dot-bounce) with a bar end-
   shape dropped into its existing .morph-text slot instead of a bubble's text. */
.hold-label{display:inline-block;overflow:hidden;white-space:nowrap;
  transition:max-width .32s cubic-bezier(.3,.9,.4,1),opacity .22s linear}
/* .hold.is-voice must out-specificity the max-width:560px rule that pins .hold to a fixed 40px
   square (icon-only) at phone widths — without this the waveform's own grown width (see
   .hold-wave below) would spill past that fixed-width button's rounded edge on a phone. Two
   classes beats that rule's one regardless of source order, so this applies at every width. */
.hold.is-voice{gap:0;width:auto;min-width:40px;overflow:hidden;
  transition:gap .32s cubic-bezier(.3,.9,.4,1),width .42s cubic-bezier(.3,.9,.4,1)}
.ghost.hold.is-pressed{animation:aceSendPress .22s ease}
.hold-wave{flex:none;padding:0}
.hold-wave.is-shaping{padding:0 8px}
.hold-wave .morph-text{display:flex;align-items:center;gap:3px}
.hold-wave .morph-text i{display:block;width:2.5px;border-radius:2px;background:currentColor;
  animation:aceWaveBar .9s ease-in-out infinite}
.hold-wave .morph-text i:nth-child(1){height:6px;animation-delay:0s}
.hold-wave .morph-text i:nth-child(2){height:12px;animation-delay:.12s}
.hold-wave .morph-text i:nth-child(3){height:16px;animation-delay:.24s}
.hold-wave .morph-text i:nth-child(4){height:10px;animation-delay:.36s}
.hold-wave .morph-text i:nth-child(5){height:14px;animation-delay:.18s}
@keyframes aceWaveBar{0%,100%{transform:scaleY(.4)}50%{transform:scaleY(1)}}
/* Dictated words (word-at-a-time, not char-by-char) get the "you" bubble colour and a pulse
   instead of the typed path's steady accent glow, so the two input paths read apart even on a
   still frame, not just by their timing. */
.field.is-dictating{border-color:color-mix(in oklab,var(--bubbleYou) 60%,transparent);
  animation:aceDictatePulse 1.1s ease-in-out infinite}
@keyframes aceDictatePulse{
  0%,100%{box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 0 0 3px color-mix(in oklab,var(--bubbleYou) 10%,transparent)}
  50%{box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 0 0 3px color-mix(in oklab,var(--bubbleYou) 22%,transparent)}}

@media (prefers-reduced-motion:reduce){
  .sa-rise,.sa-fade{transition:none!important;transform:none!important;opacity:1!important}
  .sa-mask{-webkit-mask-image:none!important;mask-image:none!important}
  .tick-circle,.tick-check{transition:none!important;stroke-dashoffset:0!important;transform:none!important}
  .tick-circle{fill-opacity:.14!important}
  svg.wof-links line[data-wof-line]{transition:none!important;stroke-dashoffset:0!important}
  .wn.wp .wd,.wn.ww .wd{transition:none!important;opacity:1!important}
  .tos-divider{transition:none!important;transform:scaleX(1)!important}
  #aceChatLog .bubble,.ace-morph{transition:none!important}
  .ace-morph .morph-dots{animation:none!important}
  .field.is-typing,.field.is-sent,.ace-send-ghost{transition:none!important}
  .btn-icon.is-pressed{animation:none!important}
  .hold-label,.hold.is-voice{transition:none!important}
  .ghost.hold.is-pressed{animation:none!important}
  .hold-wave .morph-text i{animation:none!important}
  .field.is-dictating{animation:none!important}
}
