/* Fallback token values, used only until the boot script (index.html) sets
   data-theme and the active pack's own [data-theme="..."] block (see
   static/css/themes/*.css) takes over. Kept identical to Neon Casino's
   values so there's no flash of different colors if this :root block is
   ever what actually paints first. */
:root{
  --bg:#12101c; --bg2:#1a1727; --bg3:#231e35; --border:#746a9e;
  --text:#ece8fa; --text-2:#a79fc4; --muted:#8f87ab;
  --accent-1:#8b5cf6; --accent-2:#0891b2; --accent-3:#ec4899;
  --success:#34d399; --danger:#fb7185; --danger-dark:#350a0e; --warning:#d97706;
  --font-display:"Beon","Segoe UI",system-ui,sans-serif;
  --font-mono:"SFMono-Regular",Consolas,monospace;
  --font-size-display:2rem; --font-size-mono:15px; --font-size-h2:1.1rem;
}
/* Global UI scale (accessibility, 2026-09-04): accessibilityPrefs.js sets
   --ui-scale as an inline style on <html> (same mechanism as
   --font-mono-override, below) when a user picks anything other than the
   100% default -- absent entirely otherwise, so var(--ui-scale,1) is the
   real default, not a value this rule hardcodes. html's own font-size is
   the root all rem units resolve against app-wide, so scaling THIS one
   value scales every rem-sized thing in the app at once -- fonts, but also
   padding/gaps/icon sizes/etc. wherever they're expressed in rem, same as
   a browser's own page-zoom. calc(100% * ...) rather than a bare em/px
   value so it still respects the user's own browser/OS zoom on top of
   this, instead of overriding it. */
html{font-size:calc(100% * var(--ui-scale,1))}
/* data-motion="off" -- deliberately blunt: EVERYTHING stops, no exceptions,
   !important because this tier's entire point is a stronger guarantee than
   "reduced"'s block-by-block opt-in, and it must win over every pack's own
   more-specific animation rules without needing a matching override in
   all 13 pack files. Explicit *::before/*::after (not just bare *) --
   the universal selector alone doesn't match pseudo-elements, and several
   of this file's own animations live on ::before/::after (button's
   btn-border-shift, .vt-loader-glitch's vt-glitch-slice, etc); live
   verification during Task 6 caught the plain-`*` version leaving those
   running. */
:root[data-motion="off"] *,
:root[data-motion="off"] *::before,
:root[data-motion="off"] *::after{animation:none!important;transition:none!important}
@font-face{
  font-family:"OpenDyslexic";
  src:url("/vaportrade/static/fonts/opendyslexic/OpenDyslexic-Regular.woff2") format("woff2");
  font-weight:400;
  font-display:swap;
}
@font-face{
  font-family:"Atkinson Hyperlegible";
  src:url("/vaportrade/static/fonts/atkinson-hyperlegible/AtkinsonHyperlegible-Regular.woff2") format("woff2");
  font-weight:400;
  font-display:swap;
}
/* backlog #44/#45 Task 3: high-contrast mode. App.vue's applyAccessibilityPrefs
   (frontend/src/lib/accessibilityPrefs.js) sets/removes data-a11y-contrast="high"
   on <html> from auth.me.prefs.accessibility.high_contrast. Each block below
   only brightens (dark packs) or darkens (light packs) that SAME pack's own
   --text-2/--muted pair -- --bg/--bg2/--bg3 and every other token are left
   alone, so this only ever raises the two tokens the app actually uses for
   secondary/muted text, never touches primary --text (already the highest-
   contrast token in every pack) or any accent/semantic color.
   [data-theme="X"] alone (static/css/themes/*.css) has one attribute
   selector; this rule's two attribute selectors on :root
   (:root[data-a11y-contrast="high"][data-theme="X"]) always out-specificity
   it regardless of stylesheet load order, so this file doesn't need to load
   after the per-pack files for the override to win.
   Every pair below was verified via the standard WCAG relative-luminance
   contrast formula (the same one each pack's own header comment documents)
   against ALL THREE of that pack's own grounds (--bg/--bg2/--bg3), and each
   new pair's worst-case (minimum-of-the-three) ratio is confirmed higher
   than that same pack's own existing baseline worst-case ratio -- not just
   higher against one ground while regressing on another. */
:root[data-a11y-contrast="high"][data-theme="8bit"]{
  --text-2:#d0d0e5; --muted:#b2b2cf;
}
:root[data-a11y-contrast="high"][data-theme="archnix-catboi"]{
  --text-2:#d6cfe9; --muted:#b5adcf;
}
:root[data-a11y-contrast="high"][data-theme="cf-defcon"]{
  --text-2:#b8c5d1; --muted:#9aaab7;
}
:root[data-a11y-contrast="high"][data-theme="hollywood-lanes"]{
  --text-2:#b6bcd8; --muted:#a6accd;
}
:root[data-a11y-contrast="high"][data-theme="lisa-frank"]{
  --text-2:#76506e; --muted:#6f4665;
}
:root[data-a11y-contrast="high"][data-theme="matrix-terminal"]{
  --text-2:#65dc65; --muted:#69a869;
}
:root[data-a11y-contrast="high"][data-theme="neon-casino-light"]{
  --text-2:#483e60; --muted:#554c71;
}
:root[data-a11y-contrast="high"][data-theme="neon-casino"]{
  --text-2:#bfb9d4; --muted:#aba5c0;
}
:root[data-a11y-contrast="high"][data-theme="paragade"]{
  --text-2:#c3cde3; --muted:#a3b0c9;
}
:root[data-a11y-contrast="high"][data-theme="ps1-lowpoly"]{
  --text-2:#d3cfc6; --muted:#c7c3bb;
}
:root[data-a11y-contrast="high"][data-theme="solarpunk"]{
  --text-2:#393118; --muted:#4e462e;
}
:root[data-a11y-contrast="high"][data-theme="tron-neon"]{
  --text-2:#abe3ef; --muted:#7dacb6;
}
:root[data-a11y-contrast="high"][data-theme="zunelife"]{
  --text-2:#ecdce8; --muted:#c0a9cb;
}
*{box-sizing:border-box;margin:0}
body{background:var(--bg);color:var(--text);
  font:var(--font-size-mono)/1.5 var(--font-mono-override, var(--font-mono)),"Segoe UI",system-ui,sans-serif;
  min-height:100vh}
#appwrap{position:relative;z-index:1;max-width:1400px;margin:0 auto;padding:20px}
#cols{display:flex;gap:16px;align-items:flex-start}
#colmain{flex:1;min-width:0}
#lb_side{width:280px;flex:none;position:sticky;top:12px}
#lb_side .lbrow{display:flex;justify-content:space-between;gap:6px;font-size:.82rem;
  padding:3px 0;border-bottom:1px solid var(--bg3)}
#lb_side .lbrow:last-child{border-bottom:0}
@media (max-width:1000px){#cols{flex-direction:column}#lb_side{width:100%;position:static}}
h1{font-size:var(--font-size-display);letter-spacing:1px;font-family:var(--font-display)}
/* theme-font-scope fix (2026-08-20): h1 was the ONLY selector reading
   --font-display anywhere in this file, so every sibling heading (h2/h3),
   the .verdict line, and dialog titles silently fell through to body's
   hardcoded font instead of the active pack's display face - see
   circuitforge-plans/vaportrade-clone/superpowers/live-fixes/
   2026-08-20-theme-font-scope-report.md for the screenshot-verified bug
   and fix writeup. .dialog-title is applied to an <h3> (static/js/
   dialog.js) so it's covered by the h3 selector already, but it's kept
   explicit here in case that markup ever changes. */
/* --font-h2 (2026-09-02, Alan: "we need a third font for H2/subcategory
   head"): a genuinely separate role from --font-display (h1/.app-brand)
   and --font-mono (body text). var()'s fallback argument means a pack
   that never sets --font-h2 renders EXACTLY as before -- it falls back
   to that same pack's own --font-display, not a hardcoded default, so
   this is a zero-visual-change addition until a pack opts in. */
h2,h3,.verdict,.dialog-title{font-family:var(--font-h2,var(--font-display));font-size:var(--font-size-h2)}
.tag{color:var(--text-2);font-size:.9rem;margin-bottom:18px}
.card{background:var(--bg2);border:1px solid var(--border);border-radius:14px;
  padding:16px;margin-bottom:16px}
/* glass-panel (BotDetail desktop-redesign, 2026-08-29): a .card variant for
   panels that want a frosted-glass look over whatever sits behind them --
   applied to the "dials 🎛️" panel on BotDetail.vue's row-2 grid. Background/
   border are derived from this app's EXISTING --bg3/--border tokens via
   color-mix() rather than a hardcoded rgba, so it auto-adapts to every
   theme pack's own token values (dark packs get a dark tint, the
   neon-casino-light variant gets a light tint) with zero per-pack CSS
   needed -- same token-driven philosophy as .tile-promoted above (see that
   rule's own comment). backdrop-filter needs a semi-transparent background
   to actually read as "glass" rather than a flat fill, which is why this
   can't just reuse plain .card's fully-opaque --bg2. */
.glass-panel{
  background:color-mix(in srgb, var(--bg3) 55%, transparent);
  border:1px solid color-mix(in srgb, var(--border) 40%, transparent);
  backdrop-filter:blur(18px);
  -webkit-backdrop-filter:blur(18px);
  box-shadow:0 20px 40px rgba(0,0,0,.25), inset 0 1px 0 color-mix(in srgb, var(--text) 8%, transparent);
}
.row{display:flex;gap:12px;flex-wrap:wrap}
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
/* tiles-2x2 (BotDetail desktop-redesign, 2026-08-29): forces the tile grid
   to exactly 2 columns x 2 rows instead of .tiles' own auto-fit -- used on
   BotDetail.vue's left column, where the container is narrower than the
   full-width contexts .tiles' auto-fit was designed for (4 tiles would
   otherwise wrap awkwardly at that width). Same 2-column shape .tiles
   itself already forces at the mobile breakpoint below, just not
   viewport-gated here since a narrow desktop column has the same width
   constraint mobile does. */
.tiles-2x2{grid-template-columns:repeat(2,minmax(0,1fr))}
.tile{background:var(--bg3);border:1px solid var(--border);border-radius:12px;
  padding:12px 14px}
.tile .lbl{font-size:.75rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
.tile .val{font-size:1.5rem;font-weight:700;margin-top:2px}
.tile .sub{font-size:.8rem;color:var(--text-2)}
/* vibe-check tile promotion (render/vibes.js) — the single tile with the
   highest normalized "extremeness" score this render gets promoted.
   Token-driven so it composes correctly under every pack automatically;
   no per-pack override needed. See spec §5 "Vibe-check tile visual
   hierarchy (2026-08-14)". */
.tile-promoted{
  grid-column:span 2;
  border-color:var(--accent-1);
  box-shadow:0 0 0 1px var(--accent-1);
}
.tile-promoted .val{font-size:1.9rem}
/* .tiles .tile-promoted .val (0,3,0) rather than .tile-promoted .val
   (0,2,0): the later "mobile glow-up" media block below sets
   .tile .val{font-size:1.2rem}, also (0,2,0) -- an equal-specificity tie
   that block would win on source order alone. Raising specificity here
   makes the override order-independent instead of relying on staying
   positioned after that block. grid-column:span 2 is deliberately not
   restated in this mobile block: the non-mobile .tile-promoted rule above
   has no viewport dependency, so it already applies at every width. */
@media (max-width:700px){
  .tiles .tile-promoted .val{font-size:1.5rem}
}
/* vibe-check card sentiment tints (vibe-check-cards plan). Low-alpha rgba
   blends of the existing --success/--danger tokens, matching this repo's
   established tint convention (see the Lisa Frank theme pack's border/badge
   rgba blends) rather than color-mix(), which isn't used elsewhere here. */
.vibe-card.bull{background:rgba(52,211,153,.08);border-color:rgba(52,211,153,.35)}
.vibe-card.bear{background:rgba(251,113,133,.08);border-color:rgba(251,113,133,.35)}
/* on a card that is both sentiment-tinted and the single promoted tile,
   keep the promotion accent border visible rather than letting the tint's
   higher-specificity border-color silently win */
.vibe-card.bull.tile-promoted,
.vibe-card.bear.tile-promoted{border-color:var(--accent-1)}
/* moon-phase flagship vibe card. Starfield restructured 2026-08-25 (Alan:
   "the stars ... only twinkle instead of moving like in the mercury card
   ... just a bit too faint") to match mercury-fire.js's own starfield
   treatment exactly -- same two-copy translateX(-140px) drift-track
   technique (moon-stars/moon-star-drift below are a literal duplicate of
   mercury's mercury-stars/mercury-star-drift, see moon-phase.js's render
   function for why they're kept as separate named rules instead of
   shared), same width:140px/overflow:hidden/right:0 anchor so the field
   sits under the SVG disc the same way mercury's sits under the planet,
   and opacity raised .55->.8 (paired with starfieldBoxShadow's own
   0.55-1.0 per-star alpha, up from 0.35-0.90 -- the two multiply together,
   so both needed raising for the faintness fix to actually read on
   screen). */
.vibe-card.moon-phase{position:relative;overflow:hidden}
.vibe-card.moon-phase .moon-starfield{position:absolute;top:0;right:0;bottom:0;width:140px;overflow:hidden;pointer-events:none;opacity:.8}
.vibe-card.moon-phase .moon-stars{position:absolute;inset:0}
.vibe-card.moon-phase .moon-stars span{
  position:absolute;top:0;left:0;width:1px;height:1px;border-radius:50%;
}
.vibe-card.moon-phase .moon-stars span:nth-child(2){left:140px}
.vibe-card.moon-phase .moon-stars-small span{box-shadow:var(--moon-stars-small)}
.vibe-card.moon-phase .moon-stars-large span{box-shadow:var(--moon-stars-large)}
.vibe-card.moon-phase .moon-stars-small{
  animation:moon-star-drift 46s linear infinite, moon-twinkle-a 3s ease-in-out infinite;
}
.vibe-card.moon-phase .moon-stars-large{
  animation:moon-star-drift 19s linear infinite, moon-twinkle-b 5s ease-in-out infinite;
}
@keyframes moon-star-drift{to{transform:translateX(-140px)}}
@keyframes moon-twinkle-a{0%,100%{opacity:.35}50%{opacity:.9}}
@keyframes moon-twinkle-b{0%,100%{opacity:.5}50%{opacity:1}}
.vibe-card.moon-phase .moon-svg{position:relative;z-index:1;float:right;margin:-4px -2px 0 6px}
/* shooting star -- shared by moon-phase AND mercury-fire (both cards'
   .moon-starfield/.mercury-starfield containers are the positioned
   ancestor its top/left percentages resolve against; starfield.js's
   shootingStarHtml() sets those inline per-render, plus --ss-dx for which
   way it streaks). Fires once per render, then stays hidden (no
   iteration, no reset) -- a NEW random one only appears on the card's next
   full re-render (next vibecheck poll), which is what makes it read as an
   occasional event instead of a looping decoration. */
.shooting-star{
  position:absolute;width:2px;height:2px;border-radius:50%;background:#fff;
  box-shadow:0 0 6px 2px rgba(255,255,255,.9), 0 0 14px 5px rgba(255,255,255,.5);
  opacity:0;animation:shooting-star-streak 1.1s ease-in 1;
}
@keyframes shooting-star-streak{
  0%{opacity:0;transform:translate(0,0) scale(1)}
  6%{opacity:1}
  65%{opacity:.9}
  100%{opacity:0;transform:translate(var(--ss-dx,-60px),40px) scale(.3)}
}
/* mercury retrograde flagship vibe card. Apparent rotation viewed from the
   side, not the card's original coin-flip spin: the starfield behind drifts
   right-to-left at two parallax depths (nearer/sparser stars faster,
   further/denser stars slower - see starfield.js's starsSmall/starsLarge
   density split, wired up via mercury-fire.js's mercury-stars-small/-large
   layers), the planet's base disc (the circle + radial-gradient shading
   giving it volume, mercuryPlanetSvg's static <circle>) never itself
   animates, and only the crater ellipses slide left-to-right - opposite the
   starfield - clipped to the planet's circular silhouette so surface detail
   crosses the visible face the way features cross a rotating globe seen
   edge-on. */
.vibe-card.mercury-fire{position:relative;overflow:hidden}
/* Fixed-width + its own overflow:hidden (not inset:0 stretched to the full,
   fluid tile width) - see task-1-report.md fix-round entry for the full
   derivation. The two-copy loop technique below is only seamless while the
   VISIBLE window is <=140px: with N=2 duplicate 140px-wide copies spanning
   local x in [0,280) and a translateX(-140px) drift, the local x visible at
   screen x=0 is t*140 (t = animation progress, 0..1), so the visible window
   at progress t covers local x in [t*140, t*140+Wv). That stays inside the
   defined [0,280) content for every t only when Wv<=140 (worst case t=1:
   140+Wv<=280). The tile grid guarantees widths from 150px (theme.css's
   .tiles minmax(150px,1fr) floor) up past 300px+ for promoted cards
   (tile-promoted's grid-column:span 2) - all of which exceed 140px - so
   sizing this layer to the full tile via inset:0 (as a prior revision did)
   opened a growing right-edge gap of undefined starfield that snapped shut
   on loop reset. Clamping this element itself to width:140px with its own
   overflow:hidden makes the visible window exactly 140px regardless of tile
   width, satisfying Wv<=140 (gap(t) = max(0, t*140+140-280) = 0 for all t in
   [0,1]) at every tile width the grid can produce, not just the ones
   spot-checked. Anchored right:0 (not left:0) because .mercury-planet below
   is float:right - the planet always renders at the tile's right edge
   regardless of tile width, so the starfield band has to sit under/behind
   it there too. At the 150px grid-floor width the two anchors are nearly
   coincident, which is why a left:0 anchor wasn't obviously wrong in a
   narrow spot-check, but on any promoted card (250-400px+) a left-anchored
   band and a right-floated planet are spatially disjoint - see
   task-1-report.md's second fix-round entry for the measured gap. Only the
   anchor changed here: width:140px/overflow:hidden (and everything in the
   loop-seam derivation above, which depends only on those two properties,
   not on which side the box is anchored to) is untouched. */
.vibe-card.mercury-fire .mercury-starfield{position:absolute;top:0;right:0;bottom:0;width:140px;overflow:hidden;pointer-events:none;opacity:.8}
/* each .mercury-stars layer is a translated "track" holding two identical
   copies of its star pattern (mercury-fire.js's two <span> children), the
   second placed exactly one starfieldBoxShadow coordinate-box (140px) to
   the right of the first via the nth-child(2) rule below. Animating the
   track by exactly translateX(-140px) means the frame at 100% is
   pixel-identical to the frame at 0% - the second copy has arrived exactly
   where the first one started - so linear infinite looping shows no seam,
   as long as the visible window is <=140px - guaranteed by
   .mercury-starfield's fixed width:140px + overflow:hidden above. */
.vibe-card.mercury-fire .mercury-stars{position:absolute;inset:0}
.vibe-card.mercury-fire .mercury-stars span{
  position:absolute;top:0;left:0;width:1px;height:1px;border-radius:50%;
}
.vibe-card.mercury-fire .mercury-stars span:nth-child(2){left:140px}
.vibe-card.mercury-fire .mercury-stars-small span{box-shadow:var(--mercury-stars-small)}
.vibe-card.mercury-fire .mercury-stars-large span{box-shadow:var(--mercury-stars-large)}
/* small/dense = further/background -> slower; large/sparse = nearer/
   foreground -> faster, per the plan's parallax-depth framing. Twinkle
   keyframes (moon-twinkle-a/-b) are unchanged from the card's original
   build, just layered onto the same element as the new drift. */
.vibe-card.mercury-fire .mercury-stars-small{
  animation:mercury-star-drift 46s linear infinite, moon-twinkle-a 3s ease-in-out infinite;
}
.vibe-card.mercury-fire .mercury-stars-large{
  animation:mercury-star-drift 19s linear infinite, moon-twinkle-b 5s ease-in-out infinite;
}
@keyframes mercury-star-drift{to{transform:translateX(-140px)}}
.vibe-card.mercury-fire .mercury-planet{
  position:relative;z-index:1;float:right;margin:-4px -2px 0 6px;
}
/* the crater group is duplicated in the SVG markup (mercury-fire.js) at
   transform="translate(-40,0)" - exactly one viewBox-width (40 user units)
   to the left of the primary copy. This animation then slides the whole
   group +40px (=40 user units here, 1:1 with the viewBox since the <svg>'s
   width/height match it) to the right. At 100% the duplicate copy -
   identical to the primary - has arrived exactly at the primary's starting
   position, so the loop has no seam, mirroring the starfield's math above
   but in the opposite direction. Clipped to the planet's circle via the
   wrapping <clipPath> (same clip-path-on-a-circle technique moon-phase.js's
   shadow circle uses) so craters only ever show inside the visible disc,
   sliding on at one edge and off the other rather than the disc itself
   spinning. */
.vibe-card.mercury-fire .mercury-craters{
  animation:mercury-crater-slide 7s linear infinite;
}
@keyframes mercury-crater-slide{to{transform:translateX(40px)}}
/* retrograde reverses BOTH layers - starfield drift and crater slide - via
   the same animation-direction:reverse technique this card already used for
   its old single-keyframe spin, just applied to both new animations instead
   of one. Reversing the twinkle animations riding along on the star layers
   is harmless: moon-twinkle-a/-b are symmetric oscillations (0%=100%,
   50%=peak), so playing them time-reversed looks identical. */
.vibe-card.mercury-fire.retrograde .mercury-stars-small,
.vibe-card.mercury-fire.retrograde .mercury-stars-large,
.vibe-card.mercury-fire.retrograde .mercury-craters{animation-direction:reverse}
.vibe-card.mercury-fire.retrograde{
  background:radial-gradient(circle at 30% 20%, rgba(217,119,6,.25), rgba(251,113,133,.12) 60%, transparent);
  border-color:rgba(217,119,6,.5);
  animation:mercury-glow 4s ease-in-out infinite, mercury-glitch 8s steps(1) infinite;
}
@keyframes mercury-glow{
  0%,100%{box-shadow:0 0 8px 0 rgba(217,119,6,.25)}
  50%{box-shadow:0 0 16px 2px rgba(217,119,6,.45)}
}
@keyframes mercury-glitch{
  0%,97%,100%{clip-path:none}
  97.5%{clip-path:polygon(0 2%,100% 0,100% 40%,0 38%,0 60%,100% 62%,100% 100%,0 98%)}
  98%{clip-path:none}
}
/* spy_chg/btc_chg flagship vibe card (price-move-fx plan; reshaped drip +
   persistent edge ooze added by the 2026-08-18 blood-drip-revision-plan).
   Scoped entirely under .vibe-card.price-move - deliberately does NOT touch
   the generic .up/.down classes above (those tint dozens of unrelated
   tables/rows across the app; see this plan's scoping note). */
.vibe-card.price-move{position:relative;overflow:hidden}
/* Foreground text lifted above the absolutely-positioned decoration -
   solar-storm.js's .solar-content pattern (give the FOREGROUND element
   position:relative;z-index:1, not the background decoration a negative
   z-index). Migrated here from this file's earlier z-index:0/-1 technique
   (position:relative;z-index:0 on the card above, z-index:-1 on the fx
   layer below, needed because a negative z-index only stays contained
   inside a stacking context the parent explicitly establishes - see
   price-move-fx-plan's task-1-report.md fix-round entry for the incident
   that technique fixed). The 2026-08-18 revision plan judged solar-storm's
   later pattern sounder and preferred it while touching this stacking code
   anyway (it was already restructuring this block to add the ooze layer
   below): the decoration now stays at its default z-index:auto
   (position:absolute, no z-index set), which always paints before a
   z-index:1 descendant per CSS2.1 Appendix E regardless of what stacking
   context the parent does or doesn't establish - no dependency on the
   .price-move rule below at all. Both the reshaped drip and the new
   persistent ooze use this same lifted-content layer. */
/* LEGIBILITY VERDICT (2026-08-19 flagship-card-typography-plan Task 1) -
   NO CHANGE. This card's decoration never paints a background wash behind
   .price-content at all - the drips/ooze/cash-sprites are edge-confined
   (fixed-px strips inset to the tile's own border, see the ooze comment
   below) and stay well clear of the .tile padding band the text sits in.
   The only thing that could theoretically sit "behind" the text is the
   bull/bear tile tint (.vibe-card.bull/.bear, ~line 62, rgba(*,.08) over
   the page's own --bg #12101c) - worst-case blend computes to roughly
   rgb(21,32,38), still essentially --bg-dark, so --text (#ece8fa) against
   it is >15:1, nowhere near the 4.5:1 bar. No text-shadow or backdrop
   needed. */
.vibe-card.price-move .price-content{position:relative;z-index:1}
.vibe-card.price-move .price-fx-burst,
.vibe-card.price-move .price-fx-drip,
.vibe-card.price-move .price-fx-ooze{position:absolute;inset:0;pointer-events:none}
/* cash burst (price-up): 4 emoji sprites, each its own corner + its own
   animation-delay, so the burst reads as staggered/asymmetric rather than a
   single synchronized pop. Mostly at rest (0-82% of the cycle) with a brief
   pop-and-fade near the end, matching mercury-glitch's "rare event, mostly
   quiet" cadence - restraint per Global Constraint #3, not a cash shower. */
.vibe-card.price-move.price-up .cash-sprite{
  position:absolute;font-size:1rem;line-height:1;opacity:0;
  animation:cash-pop 8s ease-out infinite;
}
.vibe-card.price-move.price-up .cash-sprite:nth-child(1){top:8%;left:6%;--cash-dx:-14px;--cash-dy:-14px;animation-delay:0s}
.vibe-card.price-move.price-up .cash-sprite:nth-child(2){top:10%;right:8%;--cash-dx:14px;--cash-dy:-16px;animation-delay:1.6s}
.vibe-card.price-move.price-up .cash-sprite:nth-child(3){bottom:10%;left:10%;--cash-dx:-12px;--cash-dy:14px;animation-delay:3.2s}
.vibe-card.price-move.price-up .cash-sprite:nth-child(4){bottom:8%;right:6%;--cash-dx:16px;--cash-dy:12px;animation-delay:4.8s}
@keyframes cash-pop{
  0%,82%,100%{opacity:0;transform:translate(0,0) scale(.5)}
  88%{opacity:1;transform:translate(calc(var(--cash-dx) * .5),calc(var(--cash-dy) * .5)) scale(1)}
  97%{opacity:0;transform:translate(var(--cash-dx),var(--cash-dy)) scale(.85)}
}
/* blood drip (price-down): 2 teardrop shapes anchored to the tile's SIDE
   edges (left/right), each on its own staggered delay. Dark red reuses
   --danger (no new red token, per Constraint #5) and stays small/stylized,
   matching the app's existing dark-humor brand voice rather than a gore
   effect. Loop is slow and mostly quiet (0-70% at rest) - a drip, not a
   constant wound.
   2026-08-18 revision v2 (blood-drip-revision-v2-plan Fix 2): these were
   originally anchored to the tile's TOP edge (top:-2px, left:20%/64%).
   User feedback: the teardrop shape itself is good, but they should only
   ever spawn from the sides, not top/center/bottom - bottom is reserved
   for the persistent ooze (Fix 1, directly below) so the two effects read
   as visually distinct zones rather than overlapping. Repositioned to
   anchor from the left/right edges instead (one upper-left, one
   mid-right) via left:%/right:% + top:%, in place of the old top:-2px +
   left:% pairing - same @keyframes blood-drip animation untouched (still
   just translateY/scaleY on the whole clipped shape, no rotation), same
   cadence/stagger, only WHERE each one is anchored changed. Reshaped by
   the 2026-08-18 blood-drip-revision-plan (v1) before this: the previous
   border-radius:50% 50% 50% 0 shape (an unrotated rounded square with one
   square corner) read as a blob/"potato" (the user's own word), not a
   drip - fixed with clip-path:path(), a hand-authored teardrop silhouette
   sized directly to this element's own 8x13px box (path() takes absolute
   coordinates, not percentages, so these numbers ARE the box's own pixel
   dimensions, not a scaled reference to some other viewBox): a point at
   (4,0) - the top attachment point - bulging out to near the box's full
   width by y=8.6, then curving back in to a rounded bottom edge by y=13,
   where gravity would pool it. border-radius is kept alongside clip-path
   as a graceful-degradation fallback (pre-clip-path:path() browsers, e.g.
   Safari <16.4, just get the old rounded-square instead of a hard
   rectangle) - clip-path fully determines the paintable region wherever it
   IS supported, so the two rules don't conflict. The animation below
   (@keyframes blood-drip) is untouched by this reshape - it only scales/
   translates the whole clipped shape as one unit, no rotation involved, so
   transform-origin didn't need to change either. Left untouched
   deliberately: static/theme.css's fear-greed block reuses this exact
   keyframe BY NAME for its own unrelated .sweat-drop accent (see that
   block's comment) - editing the keyframe's transform values here would
   have silently reshaped fear-greed's sweat drops too, outside this
   revision's blood-drop/down-state-only scope (Global Constraint #6). */
.vibe-card.price-move.price-down .blood-drop{
  position:absolute;width:8px;height:13px;background:var(--danger);
  border-radius:50% 50% 50% 0;
  clip-path:path('M4 0 C6 3 7.6 6.4 7.6 8.6 C7.6 11.4 6 13 4 13 C2 13 0.4 11.4 0.4 8.6 C0.4 6.4 2 3 4 0 Z');
  transform-origin:top center;opacity:0;
  animation:blood-drip 9s ease-in infinite;
}
.vibe-card.price-move.price-down .blood-drop:nth-child(1){left:4%;top:14%;animation-delay:0s}
.vibe-card.price-move.price-down .blood-drop:nth-child(2){right:4%;top:52%;animation-delay:3.5s}
@keyframes blood-drip{
  0%,70%{opacity:0;transform:translateY(-4px) scaleY(.2)}
  78%{opacity:1;transform:translateY(0) scaleY(1)}
  92%{opacity:1;transform:translateY(10px) scaleY(1.25)}
  100%{opacity:0;transform:translateY(16px) scaleY(1.25)}
}
/* persistent edge ooze (price-down): NEW in the 2026-08-18
   blood-drip-revision-plan, added alongside (not instead of) the periodic
   drip above - a standing blood stain along the tile's side+bottom edges,
   always visible on a down tile rather than looping/fading like the drip.
   "Not in the main viewport" (the plan's own phrase for this requirement)
   is treated as load-bearing: each strip is a fixed few px wide/tall,
   inset flush to the tile's own edge, and stays well inside this file's
   .tile rule's 12px/14px padding band (see that rule, well above) - the
   tile's actual .lbl/.val/.sub text starts inside that padding, so a strip
   this thin can never reach it. Fixed px (not %) deliberately, so this
   holds at both the plain-tile width and .tile-promoted's wider
   grid-column:span 2 - a %-based strip would grow with the tile and could
   eventually creep toward the text on a very wide promoted tile; a
   px-based one can't. Unlike the drip above, this has no fade-in/fade-out
   loop - just a slow opacity-only creep (Global Constraint #4: visible is
   fine, motion stays subtle so it doesn't compete with the drip accent).
   Colors are hardcoded rgb() decompositions of --danger/--danger-dark
   (#fb7185 / #350a0e) rather than var() - alpha-blended gradient stops
   need literal rgb components, and this file's existing .vibe-card.bear
   tint rule (above) already established rgba-decomposition-over-color-mix
   as this codebase's convention for exactly this case (see that rule's
   comment).

   2026-08-18 revision v2 (blood-drip-revision-v2-plan Fix 1): live user
   feedback on the v1 shape above was "looks meh, just border lines" - a
   flat linear-gradient bar fading center-to-edge reads as a colored rule,
   not congealed liquid, no matter how dark the color. Reworked here to an
   irregular, non-uniform shape using two techniques together (no markup
   change - still the same 3 <span> elements from price-move.js, this is
   CSS-only per Global Constraint #1):
   1) clip-path on the strip's OWN box (values below are percentages of
      THIS element's own width/height, not the tile's) traces a jagged
      inner edge that wanders in and out along the strip's length instead
      of running dead straight - the same "irregular edge" idea the v1
      .ooze-bottom polygon already used, now applied to .ooze-left/-right
      too (v1 only gave the bottom strip a jagged edge; the two side
      strips were still flat rectangles, which is exactly what read as
      "border lines"). The outer edge (flush against the tile edge, x=0
      for left / x=100% for right) stays pinned there since that's the
      confinement boundary; only the inner edge wanders.
   2) The background is now several overlapping radial-gradient "pools" at
      different points along the strip (varying size/position/opacity)
      layered under a faint linear-gradient wash, instead of one flat
      gradient - real ooze pools thicker in some spots than others, a
      single gradient can't express that but several irregular blobs can.
      Kept CSS-only radial-gradient() rather than an SVG path/image per
      Global Constraint #1 (no new asset, no canvas).
   Widths bumped 6px->8px (left/right) and the vertical inset loosened
   10%->6% to give the wave/pool shapes room to read as irregular without
   which they'd be clipped flat again - still well inside the .tile
   padding band (12-14px desktop, 12px horizontal at mobile - see that
   rule) and still narrower than the .ooze-bottom mobile fix's 6px height,
   so text clearance (6673f4f) is unaffected; re-verified below. Animation
   keyframes (ooze-creep/ooze-pulse) and the elements' opacity-only motion
   are untouched - Global Constraint #4's "don't over-animate, still a
   standing state" restraint applies exactly as much to the reshaped
   version as the old one. */
.vibe-card.price-move.price-down .ooze-edge{position:absolute;opacity:.85}
.vibe-card.price-move.price-down .ooze-left{
  left:0;top:6%;bottom:6%;width:8px;
  background:
    radial-gradient(ellipse 8px 26px at 0 12%, rgba(53,10,14,.85), transparent 72%),
    radial-gradient(ellipse 9px 34px at 0 48%, rgba(251,113,133,.5), transparent 68%),
    radial-gradient(ellipse 7px 20px at 0 82%, rgba(53,10,14,.8), transparent 70%),
    linear-gradient(to right, rgba(53,10,14,.5) 0%, transparent 80%);
  clip-path:polygon(0 0,60% 5%,100% 14%,72% 24%,100% 33%,42% 40%,88% 50%,55% 60%,
    100% 68%,48% 76%,92% 86%,62% 94%,100% 100%,0 100%);
  animation:ooze-creep 22s ease-in-out infinite alternate;
}
.vibe-card.price-move.price-down .ooze-right{
  right:0;top:6%;bottom:6%;width:8px;
  background:
    radial-gradient(ellipse 8px 26px at 100% 12%, rgba(53,10,14,.85), transparent 72%),
    radial-gradient(ellipse 9px 34px at 100% 48%, rgba(251,113,133,.5), transparent 68%),
    radial-gradient(ellipse 7px 20px at 100% 82%, rgba(53,10,14,.8), transparent 70%),
    linear-gradient(to left, rgba(53,10,14,.5) 0%, transparent 80%);
  clip-path:polygon(100% 0,40% 5%,0 14%,28% 24%,0 33%,58% 40%,12% 50%,45% 60%,
    0 68%,52% 76%,8% 86%,38% 94%,0 100%,100% 100%);
  animation:ooze-creep 22s ease-in-out infinite alternate-reverse;
}
.vibe-card.price-move.price-down .ooze-bottom{
  left:0;right:0;bottom:0;height:10px;
  background:
    radial-gradient(ellipse 30px 8px at 10% 100%, rgba(53,10,14,.85), transparent 75%),
    radial-gradient(ellipse 26px 9px at 38% 100%, rgba(251,113,133,.5), transparent 70%),
    radial-gradient(ellipse 34px 8px at 68% 100%, rgba(53,10,14,.8), transparent 75%),
    radial-gradient(ellipse 24px 7px at 92% 100%, rgba(251,113,133,.5), transparent 70%),
    linear-gradient(to top, rgba(53,10,14,.55) 0%, transparent 85%);
  clip-path:polygon(0% 35%,6% 8%,14% 42%,19% 12%,27% 38%,35% 4%,41% 45%,49% 18%,
    55% 40%,63% 6%,70% 36%,78% 15%,84% 44%,90% 10%,96% 32%,100% 20%,100% 100%,0% 100%);
  animation:ooze-pulse 26s ease-in-out infinite alternate;
}
@keyframes ooze-creep{0%{opacity:.7}100%{opacity:1}}
@keyframes ooze-pulse{0%{opacity:.75;transform:translateY(0)}100%{opacity:1;transform:translateY(1px)}}
/* fear_greed/crypto_fg flagship vibe card (fear-greed-card plan). Bear
   ("extreme fear", raw<30) gets a translucent blue haze wash + a couple of
   "flop sweat" drops near the tile's temple-adjacent upper corners; bull
   ("extreme greed", raw>70) gets a warm rosy-flush wash + brimstone embers
   rising from the bottom edge specifically. Neutral ("mid vibes") gets
   neither wash nor accents - the plain sentiment tile, deliberately
   unremarkable, matching mercury-fire's calm-state restraint.
   Colors below are hardcoded decomposed rgba() literals, NOT live var()
   token references - intentionally, same as mercury-fire's amber glow and
   solar-storm's cyan aurora/electric-border. These are hue-dependent
   decorative accents that must stay visually consistent across every theme
   pack, unlike semantic tokens (e.g. --danger) that legitimately vary by
   pack. Do NOT replace these with var(--accent-2)/var(--accent-3)/
   var(--warning) - that would make the fear/greed hue shift (and in some
   packs collide with --danger's red or mercury's amber) per-pack, which is
   the opposite of the intent here.
   rgb(8,145,178) is cyan's decomposition (matches --accent-2 under Neon
   Casino, the reference pack) and reads as a cool, nervous blue for fear -
   the same cyan solar-storm's aurora/electric-border already reuses.
   rgb(236,72,153) is rose/pink's decomposition (matches --accent-3 under
   Neon Casino) - the "rosy" half of greed's flush - deliberately distinct
   from --danger (price-move's blood-red) and from the hardcoded amber
   mercury's retrograde glow uses, per this plan's Constraint #3: the flush
   needs to read as its own hue, not "this tile is in trouble" red or
   mercury's amber warning color. The brimstone embers use rgb(217,119,6)
   (amber/sulfur, matches --warning under Neon Casino) for the fire/heat
   accent specifically, layered on top of (and visually distinct from) the
   pink flush wash underneath - two separate effects, same split
   solar-storm's aurora-vs-electric-border already establishes. */
.vibe-card.fear-greed{position:relative;overflow:hidden}
/* foreground text lifted above the absolutely-positioned decoration - the
   established pattern (mercury-fire's .mercury-planet, moon-phase's
   .moon-svg, solar-storm's .solar-content) of giving the FOREGROUND element
   position:relative;z-index:1 rather than giving the decoration a negative
   z-index and needing the parent to establish its own stacking context (see
   this plan's Context note on price-move's earlier z-index bug). */
/* LEGIBILITY VERDICT (2026-08-19 flagship-card-typography-plan Task 1) -
   NO CHANGE. Both washes (.fg-decor's bear/bull radial gradients) top out
   at .22-.26 alpha over the tile's own --bg3 base, fading to transparent
   by 75% - low enough that the blended background stays in the dark
   family: worst-case bear-wash blend (alpha .22 of rgb(8,145,178) over
   --bg3 #231e35) computes to roughly rgb(28,60,86), still dark navy.
   --text against that is well over 4.5:1. The sweat-drop/ooze/ember
   accents are small and edge/corner-confined (same fixed-px-strip
   confinement as price-move's ooze, directly above), never under the
   text block. No text-shadow or backdrop needed. */
.vibe-card.fear-greed .fg-content{position:relative;z-index:1}
.vibe-card.fear-greed .fg-decor{position:absolute;inset:0;pointer-events:none}
/* persistent edge ooze wrapper, same absolute/inset:0/pointer-events:none
   shell as price-move's .price-fx-ooze (theme.css, ~line 221) - shared
   across bear/bull here since it's just a positioning shell, populated only
   for bear (fear-greed.js only emits the .ooze-edge spans when
   sentiment==='bear', per this plan's judgment call #1). */
.vibe-card.fear-greed .price-fx-ooze{position:absolute;inset:0;pointer-events:none}
/* bear (fear): haze concentrated toward the tile's upper area (temple/brow
   framing, thematically "flop sweat"), slow ambient opacity drift rather
   than a static wash - restraint per Global Constraint #4.
   Alpha values below were cut ~21% (.28->.22, .08->.06) by this plan's Task
   1 judgment call #2, now that the new persistent ooze (below) is also
   competing for attention on the same bear tile - the original haze read
   fine on its own but got visually busy stacked with the new ooze, and the
   plan asked for content to "show through more clearly" while the wash
   still clearly reads as present. Scoped to bear's OWN background rule
   only, not to the shared @keyframes fg-wash-drift a few lines below -
   that keyframe's 0%/100% opacity multipliers (.7/1) are shared with bull's
   rosy flush, and touching them would have quietly dimmed greed's wash too,
   which this plan's judgment call #2 explicitly says NOT to do (fear only,
   scoped via the background alpha, not the keyframe). */
.vibe-card.fear-greed.bear .fg-decor{
  background:radial-gradient(ellipse at 50% 0%, rgba(8,145,178,.22) 0%, rgba(8,145,178,.06) 55%, transparent 75%);
  animation:fg-wash-drift 14s ease-in-out infinite alternate;
}
.vibe-card.fear-greed.bear{border-color:rgba(8,145,178,.45)}
/* bull (greed): rosy flush wash from the lower half, same ambient-drift
   cadence as fear's haze. */
.vibe-card.fear-greed.bull .fg-decor{
  background:radial-gradient(ellipse at 50% 100%, rgba(236,72,153,.26) 0%, rgba(236,72,153,.07) 55%, transparent 75%);
  animation:fg-wash-drift 14s ease-in-out infinite alternate;
}
.vibe-card.fear-greed.bull{border-color:rgba(236,72,153,.45)}
@keyframes fg-wash-drift{0%{opacity:.7}100%{opacity:1}}
/* sweat drops: reuses price-move.js's existing @keyframes blood-drip
   verbatim (see this file's price-move block above) rather than a
   near-duplicate keyframe - both are "a small teardrop shape dripping
   down," and only color/size/position differ here, all handled by this
   rule rather than the keyframe itself (this plan's Scoping note). Paler,
   cooler blue (not blood-red) and positioned at the temple-flanking edges
   (18%/72%) rather than price-move's more centered 20%/64% spread, to read
   as brow sweat rather than a wound.
   2026-08-19 fear-greed-drip-shape-and-aurora-contrast-plan Task 1: this
   rule still had the pre-fix border-radius:50% 50% 50% 0 "potato" blob
   that .blood-drop had before the 2026-08-18 blood-drip-revision-plan
   reshaped IT with clip-path:path() - that fix was deliberately scoped to
   .blood-drop's own rule only (see its comment above, ~line 246-280) so it
   wouldn't silently reshape this shared-keyframe sibling too, but the
   shape fix itself was never ported here until now. Same teardrop-
   silhouette technique (point at top-center, bulge to near full width
   partway down, curve back to a rounded bottom), but recomputed for this
   element's own 7x13->7x11 box - clip-path:path() takes absolute
   coordinates matched to the element's own box, not a scaled reference to
   .blood-drop's 8x13 viewBox, so .blood-drop's numbers can't be copied
   verbatim. Path below is .blood-drop's path scaled by 7/8 (x) and 11/13
   (y): top point (4,0)->(3.5,0), widest point (7.6,8.6)->(6.65,7.28),
   bottom point (4,13)->(3.5,11). border-radius kept alongside as the same
   graceful-degradation fallback for pre-clip-path:path() browsers
   (Safari <16.4). @keyframes blood-drip itself untouched, same scoping
   discipline as the original fix. */
.vibe-card.fear-greed.bear .sweat-drop{
  position:absolute;top:-2px;width:7px;height:11px;background:rgba(8,145,178,.85);
  border-radius:50% 50% 50% 0;
  clip-path:path('M3.5 0 C5.25 2.54 6.65 5.42 6.65 7.28 C6.65 9.65 5.25 11 3.5 11 C1.75 11 0.35 9.65 0.35 7.28 C0.35 5.42 1.75 2.54 3.5 0 Z');
  transform-origin:top center;opacity:0;
  animation:blood-drip 9s ease-in infinite;
}
.vibe-card.fear-greed.bear .sweat-drop:nth-child(1){left:18%;animation-delay:1s}
.vibe-card.fear-greed.bear .sweat-drop:nth-child(2){left:72%;animation-delay:4.5s}
/* persistent edge ooze (bear/fear only - 2026-08-19
   fear-greed-ooze-revision-plan Task 1, judgment call #1). Structurally
   this is price-move's persistent-ooze block (theme.css ~line 351-387)
   copy-pasted rule-for-rule and only recolored/reselected - same
   clip-path irregular-edge technique (jagged inner edge on left/right, the
   spiky drip-fringe polygon on bottom), same layered radial-gradient
   "pools" + linear-gradient wash construction, and the shared
   ooze-creep/ooze-pulse keyframes reused VERBATIM (Global Constraint #1 -
   no new keyframes declared here). Colors are the cyan/blue "fear" family
   already established above (rgb(8,145,178), matching --accent-2/the
   sweat-drop/haze color) instead of price-move's blood-red, per judgment
   call #1 - NOT --danger's red, which would misread this as "loss," and
   not --warning's amber (that's bull/greed's brimstone-ember hue). The
   darker pool shade rgb(4,36,44) is a hand-picked dark-cyan companion to
   rgb(8,145,178) (roughly the same light/dark relationship --danger/
   --danger-dark has for price-move's red, decomposed the same way per this
   file's established rgba-decomposition-over-color-mix convention).
   Sizes/insets (8px left/right, 10px bottom, 6%/6% vertical inset) are
   copied unchanged from price-move's v2 shape - fear-greed's .tile uses
   the same 12px/14px desktop padding band (see the shared .tile rule near
   the top of this file) as price-move, so the same geometry clears
   .fg-content's text with the same margin (re-measured via Playwright
   getBoundingClientRect during this task's verification, not assumed -
   see this plan's Task 1 report). */
.vibe-card.fear-greed.bear .ooze-edge{position:absolute;opacity:.85}
.vibe-card.fear-greed.bear .ooze-left{
  left:0;top:6%;bottom:6%;width:8px;
  background:
    radial-gradient(ellipse 8px 26px at 0 12%, rgba(4,36,44,.85), transparent 72%),
    radial-gradient(ellipse 9px 34px at 0 48%, rgba(8,145,178,.5), transparent 68%),
    radial-gradient(ellipse 7px 20px at 0 82%, rgba(4,36,44,.8), transparent 70%),
    linear-gradient(to right, rgba(4,36,44,.5) 0%, transparent 80%);
  clip-path:polygon(0 0,60% 5%,100% 14%,72% 24%,100% 33%,42% 40%,88% 50%,55% 60%,
    100% 68%,48% 76%,92% 86%,62% 94%,100% 100%,0 100%);
  animation:ooze-creep 22s ease-in-out infinite alternate;
}
.vibe-card.fear-greed.bear .ooze-right{
  right:0;top:6%;bottom:6%;width:8px;
  background:
    radial-gradient(ellipse 8px 26px at 100% 12%, rgba(4,36,44,.85), transparent 72%),
    radial-gradient(ellipse 9px 34px at 100% 48%, rgba(8,145,178,.5), transparent 68%),
    radial-gradient(ellipse 7px 20px at 100% 82%, rgba(4,36,44,.8), transparent 70%),
    linear-gradient(to left, rgba(4,36,44,.5) 0%, transparent 80%);
  clip-path:polygon(100% 0,40% 5%,0 14%,28% 24%,0 33%,58% 40%,12% 50%,45% 60%,
    0 68%,52% 76%,8% 86%,38% 94%,0 100%,100% 100%);
  animation:ooze-creep 22s ease-in-out infinite alternate-reverse;
}
.vibe-card.fear-greed.bear .ooze-bottom{
  left:0;right:0;bottom:0;height:10px;
  background:
    radial-gradient(ellipse 30px 8px at 10% 100%, rgba(4,36,44,.85), transparent 75%),
    radial-gradient(ellipse 26px 9px at 38% 100%, rgba(8,145,178,.5), transparent 70%),
    radial-gradient(ellipse 34px 8px at 68% 100%, rgba(4,36,44,.8), transparent 75%),
    radial-gradient(ellipse 24px 7px at 92% 100%, rgba(8,145,178,.5), transparent 70%),
    linear-gradient(to top, rgba(4,36,44,.55) 0%, transparent 85%);
  clip-path:polygon(0% 35%,6% 8%,14% 42%,19% 12%,27% 38%,35% 4%,41% 45%,49% 18%,
    55% 40%,63% 6%,70% 36%,78% 15%,84% 44%,90% 10%,96% 32%,100% 20%,100% 100%,0% 100%);
  animation:ooze-pulse 26s ease-in-out infinite alternate;
}
/* brimstone embers: small glowing particles anchored to the bottom edge
   specifically (not the whole tile), drifting upward and fading - a
   different enough motif from a downward-dripping teardrop (rising, not
   falling; a soft glow, not a sharp drop shape) that it gets its own
   keyframe rather than forcing a reuse that doesn't fit, per the Scoping
   note. 3 embers, staggered across the bottom edge and in time, matching
   price-move's "a few accents on a long loop" restraint rather than a
   constant shower. */
.vibe-card.fear-greed.bull .brimstone-ember{
  position:absolute;bottom:-2px;width:8px;height:8px;border-radius:50%;
  background:radial-gradient(circle at 50% 65%, rgba(255,214,153,.95), rgba(217,119,6,.85) 55%, transparent 75%);
  opacity:0;
  animation:brimstone-rise 11s ease-out infinite;
}
.vibe-card.fear-greed.bull .brimstone-ember:nth-child(1){left:16%;animation-delay:0s}
.vibe-card.fear-greed.bull .brimstone-ember:nth-child(2){left:48%;animation-delay:3.7s}
.vibe-card.fear-greed.bull .brimstone-ember:nth-child(3){left:80%;animation-delay:7.4s}
@keyframes brimstone-rise{
  0%,72%{opacity:0;transform:translateY(0) scale(.5)}
  80%{opacity:.9;transform:translateY(-7px) scale(1)}
  92%{opacity:.55;transform:translateY(-17px) scale(.8)}
  100%{opacity:0;transform:translateY(-24px) scale(.5)}
}
/* quakes_7d flagship vibe card (quakes-card plan). Calm (raw<12, "earth is
   chill") is the plain sentiment tile - no decoration at all, not even an
   empty wrapper div (quakes.js's renderQuakesCard() returns the exact same
   shape as vibe-cards.js's own generic fallback for that state) - matching
   mercury-fire's direct state / fear-greed's neutral state restraint.

   Angry (raw>=12, "earth is ANGY 🌋") gets cracking/fissure SVG lines over a
   molten magma glow - the user's explicit "magma and fire" direction
   (2026-08-18), not a plain grey ground-crack.

   VESTIBULAR SAFETY (Global Constraint #1 - read that constraint in full
   before touching this block): this is deliberately NOT a tile-shake/vibrate
   effect for the ambient/automatic angry state. Screen-shake as an AMBIENT
   effect is a named WCAG 2.3.3 (Animation from Interactions) accessibility
   risk. Every rule below this comment up to (not including) the
   quakes-hover-shake rule only ever animates opacity/filter (brightness) -
   never `transform` on the card itself. The one exception, by design, is
   the separate HOVER-triggered shake near the bottom of this block, which
   is wrapped in @media (prefers-reduced-motion: no-preference) specifically
   because it's user-triggered (not ambient) and must stay fully inert for
   anyone with the OS-level reduced-motion preference set, even on hover -
   see that rule's own comment and quakes.js's header comment for the full
   reasoning.

   Colors are hardcoded gradient/rgba literals (SVG defs in quakes.js, plus
   the .quakes-magma background below) rather than var() tokens - same
   "hue-dependent decorative accent, not a semantic pack token" reasoning
   fear-greed's block above documents: var(--danger)'s rose-red alone
   doesn't read as molten lava, so the magma gradient anchors its deep-red
   stop near --danger's own hue family (#b91c1c) while adding the bright
   orange/gold "hot core" stops (#fb923c/#fde68a) a lava effect needs. */
.vibe-card.quakes.angry{position:relative;overflow:hidden;border-color:rgba(217,38,14,.5)}
/* foreground text lifted above the absolutely-positioned decoration - the
   established pattern (mercury-fire's .mercury-planet, solar-storm's
   .solar-content, fear-greed's .fg-content) of giving the FOREGROUND
   element position:relative;z-index:1 rather than giving the decoration a
   negative z-index. */
/* LEGIBILITY VERDICT (2026-08-19 flagship-card-typography-plan Task 1) -
   TEXT-SHADOW. The magma glow stays in one dark/warm-red tone family (per
   the Approach section's own example of this card as a text-shadow
   candidate) and numerically still clears the bar even at its worst point:
   radial-gradient(rgba(217,38,14,.55)) blended over --bg3 at its anchor
   (50% 100%), then run through the ember-flicker's peak filter:
   brightness(1.3), computes to roughly rgb(176,45,42) - --text (#ece8fa)
   against that is ~5.37:1, still above the 4.5:1 bar. Not a contrast
   failure, but the background IS busy/textured (crack lines, glow,
   flicker) right where the .sub line sits lowest in the tile, so a small
   text-shadow in the card's own dark magma tone (not pure black) adds
   edge definition without a new visual layer - the cheap technique #1
   case, not a backdrop panel this card doesn't need. */
.vibe-card.quakes.angry .quakes-content{
  position:relative;z-index:1;
  text-shadow:0 1px 3px rgba(53,10,14,.65);
}
.vibe-card.quakes.angry .quakes-magma,
.vibe-card.quakes.angry .quakes-cracks{position:absolute;inset:0;pointer-events:none}
/* the magma glow underneath the cracks - a warm radial wash rising from the
   tile's bottom edge (as if lava is pooling below the cracked ground), with
   a slow brightness/opacity ember-flicker (NOT a strobe - 5s ease-in-out,
   same cadence family as mercury-fire's mercury-glow pulse) so the "fire"
   reads without needing literal flame shapes, per Global Constraint #1's
   "subtle warm flicker... small, slow, ember-like" guidance. */
.vibe-card.quakes.angry .quakes-magma{
  background:radial-gradient(ellipse at 50% 100%, rgba(217,38,14,.55) 0%, rgba(180,20,10,.28) 45%, transparent 72%);
  animation:quakes-magma-pulse 5s ease-in-out infinite;
}
@keyframes quakes-magma-pulse{
  0%,100%{opacity:.75;filter:brightness(1)}
  50%{opacity:1;filter:brightness(1.3)}
}
/* the glow "visible through/along the crack lines" (not just a border
   accent, per Global Constraint #1) - a soft drop-shadow in the same
   orange/gold hot-core color as each path's own gradient stroke, so the
   crack lines themselves appear to be lit from within. */
.vibe-card.quakes.angry .quake-crack-steady,
.vibe-card.quakes.angry .quake-crack-flash{
  filter:drop-shadow(0 0 3px rgba(251,146,60,.75));
}
/* steady cracks: always visible while angry - the baseline "the ground is
   cracked" state, present for the whole cycle (not just during the rare
   flash below) and what reduced-motion falls back to unchanged (Global
   Constraint #2 - the visual information survives, only motion is
   removed). */
.vibe-card.quakes.angry .quake-crack-steady{opacity:.9}
/* flash cracks: an additional pair of fissures that pop into view rarely
   and briefly, then reset - reusing mercury-glitch's steps() rare-event
   cadence shape verbatim (theme.css's mercury-glitch keyframe above: this
   keyframe's 0%,97%,100%/97.5%/98% percentage layout is the same shape,
   just applied to opacity instead of clip-path), per Global Constraint #4's
   instruction to reuse that existing technique rather than inventing a new
   cadence. Reads as "a new crack opening" against the always-present steady
   pair. */
.vibe-card.quakes.angry .quake-crack-flash{
  opacity:0;
  animation:quakes-crack-flash 8s steps(1) infinite;
}
@keyframes quakes-crack-flash{
  0%,97%,100%{opacity:0}
  97.5%{opacity:1}
  98%{opacity:0}
}
/* hover-triggered shake (Global Constraint #1's explicit carve-out,
   2026-08-18 user direction): this is the ONLY transform/position motion
   this card ever produces, and it only exists on deliberate user
   interaction (:hover), never as an ambient/automatic effect - the WCAG
   2.3.3 concern is specifically about motion the user didn't ask for and
   can't avoid, and a hover-only effect is inherently avoidable (a user
   with motion sensitivity simply never triggers it by hovering). Still
   wrapped in @media (prefers-reduced-motion: no-preference) as the
   explicit, required disable mechanism the plan calls for, so it stays
   fully inert - even on hover - for anyone with that OS-level preference
   set. Small amplitude (<=2px), brief (.5s), settles back to rest - "a fun
   interactive flourish on deliberate user action," not maximized just
   because hover makes it safe. Scoped to .angry only (Global Constraint
   #1's last bullet - never on the calm state). */
@media (prefers-reduced-motion: no-preference){
  .vibe-card.quakes.angry:hover{
    animation:quakes-hover-shake .5s ease-in-out;
  }
}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.quakes.angry:hover{animation:none}
@keyframes quakes-hover-shake{
  0%,100%{transform:translate(0,0)}
  20%{transform:translate(-2px,1px)}
  40%{transform:translate(2px,-1px)}
  60%{transform:translate(-1px,2px)}
  80%{transform:translate(1px,-2px)}
}
/* pizza_index flagship vibe card (render/vibe-fx/pizza-index.js) - Pip-Boy/
   Fallout terminal treatment. No existing green token in any color pack (per
   this plan's note), so phosphor-green is a hardcoded literal (#4ade80),
   same "hue-dependent decorative accent, not a semantic pack token"
   reasoning fear-greed's and quakes' own blocks already document - amber
   reuses mercury-fire's existing rgba(217,119,6,...) literal verbatim as
   this plan's closest precedent for a card-specific amber accent.
   font-family:var(--font-mono) gives the whole card the monospace-terminal
   feel called for; this app already defines --font-mono (theme.css :root)
   for exactly this purpose. */
.vibe-card.pizza-index{position:relative;overflow:hidden;font-family:var(--font-mono)}
/* LEGIBILITY VERDICT (2026-08-19 flagship-card-typography-plan Task 1) -
   TEXT-SHADOW. Named as a text-shadow candidate in the Approach section
   ("pizza-index's green terminal") - the scanline/alert-wash decoration
   stays low-alpha (.08 calm scanlines, .14 alert wash max) over the
   tile's own dark --bg3, so contrast was never actually at risk (>10:1
   throughout), but the scanline texture is busy right behind the text.
   A small text-shadow in the card's own phosphor-green tone (dimmed, not
   the bright #4ade80 accent itself, per "not pure black... matching each
   card's palette") gives edge definition consistent with the terminal
   aesthetic without adding a new layer. */
.vibe-card.pizza-index .pizza-content{
  position:relative;z-index:2;
  text-shadow:0 1px 2px rgba(6,30,16,.7);
}
.vibe-card.pizza-index .pizza-scanlines,
.vibe-card.pizza-index .pizza-alert-wash{position:absolute;inset:0;pointer-events:none;z-index:0}
/* calm: dim steady green glow (no pulse - "the terminal is on, nothing's
   happening," not alarming) plus a faint horizontal scanline texture. The
   scanline drift below is wrapped in @media (prefers-reduced-motion:
   no-preference) rather than declared unconditionally + overridden later -
   this sidesteps the specificity gotcha documented on this file's own
   reduced-motion block entirely, since an animation that's never declared
   needs no override to "disable" under reduced motion. Even with motion
   enabled the drift is a slow 12s linear background-position scroll, not a
   flash - purely decorative "terminal alive" texture, well outside any
   seizure-risk cadence. */
.vibe-card.pizza-index.calm{
  border-color:rgba(74,222,128,.35);
  box-shadow:0 0 6px 0 rgba(74,222,128,.22);
}
.vibe-card.pizza-index.calm .pizza-scanlines{
  background:repeating-linear-gradient(to bottom,
    rgba(74,222,128,.08) 0px, rgba(74,222,128,.08) 1px,
    transparent 1px, transparent 4px);
  opacity:.55;
}
@media (prefers-reduced-motion:no-preference){
  .vibe-card.pizza-index.calm .pizza-scanlines{animation:pizza-scan-drift 12s linear infinite}
}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.pizza-index.calm .pizza-scanlines{animation:none}
@keyframes pizza-scan-drift{from{background-position-y:0}to{background-position-y:40px}}
/* alert ("doughcon is spiking"): the green scanline base keeps rendering
   underneath unchanged (.pizza-scanlines has no .alert-specific override -
   same repeating-linear-gradient, same class), with an amber glow border +
   box-shadow pulse layered on top. HARD SAFETY CONSTRAINT (WCAG 2.3.1):
   this reuses mercury-glow's exact 4s ease-in-out cadence verbatim (see
   that keyframe above) - well under the 3-flashes/sec ceiling - and is a
   border/glow accent, never a full-tile saturated flash. */
.vibe-card.pizza-index.alert{
  border-color:rgba(217,119,6,.55);
  animation:pizza-alert-glow 4s ease-in-out infinite;
}
@keyframes pizza-alert-glow{
  0%,100%{box-shadow:0 0 8px 0 rgba(217,119,6,.3)}
  50%{box-shadow:0 0 18px 3px rgba(217,119,6,.5)}
}
/* interference wash: a faint amber top-down gradient that briefly "glitches"
   into a jagged clip-path, reusing mercury-glitch's exact rare-event
   steps() cadence shape (0%,97%,100%/97.5%/98%) verbatim per this plan's
   "same order of magnitude (~8s cycle, brief), not faster" guidance -
   applied to a low-opacity amber wash, not the whole tile's color. */
.vibe-card.pizza-index.alert .pizza-alert-wash{
  background:linear-gradient(180deg, rgba(217,119,6,.14), transparent 60%);
  animation:pizza-glitch 8s steps(1) infinite;
}
@keyframes pizza-glitch{
  0%,97%,100%{clip-path:none}
  97.5%{clip-path:polygon(0 2%,100% 0,100% 40%,0 38%,0 60%,100% 62%,100% 100%,0 98%)}
  98%{clip-path:none}
}
/* radiation-trefoil-with-pizza-slice badge (optional per Global Constraint
   #3): small, confined to a corner - never full-tile - so any red used here
   stays a tiny accent area, not a large-area flash. The wrapper div keeps
   the same slow opacity breathe as before (pizza-badge-pulse, same 4s
   cadence family as the border glow above, nowhere near 3Hz); the inline
   SVG itself (pizza-index.js) additionally gets a continuous slow rotation
   below - a "lazily rotating sign" read, not a spinner.
   ROTATION CADENCE (nuke-sign-plan Task 1 / Global Constraint #2): 30s per
   360deg. Chosen deliberately much slower than this card's existing 4s
   glow-pulse/badge-breathe family - continuous transform:rotate() is far
   more motion-salient at a given period than an opacity or box-shadow
   breathe (a rotating shape gives the visual system a constant directional
   motion cue to track; a breathe just dims/brightens in place), so matching
   the 4s cadence 1:1 would read as "spinning," not "slowly rotating sign."
   30s sits mid-range of the plan's 20-40s+ target and keeps a full rotation
   well under a minute for a human to visually confirm. */
.vibe-card.pizza-index .pizza-badge{
  position:absolute;top:6px;right:8px;z-index:3;
  line-height:1;
  animation:pizza-badge-pulse 4s ease-in-out infinite;
}
.vibe-card.pizza-index .pizza-badge-spin{
  display:block;
  filter:drop-shadow(0 0 4px rgba(248,113,113,.6));
  animation:pizza-badge-spin 30s linear infinite;
}
@keyframes pizza-badge-pulse{
  0%,100%{opacity:.7}
  50%{opacity:1}
}
@keyframes pizza-badge-spin{
  from{transform:rotate(0deg)}
  to{transform:rotate(360deg)}
}
/* solar_kp flagship vibe card (render/vibe-fx/solar-storm.js) -
   2026-08-19 solar-storm-aurora-revival-plan Task 1. See that file's header
   comment for the full "why this exists again, why it's different from the
   deleted canvas version, why it's adapted (not copy-pasted) from the
   uiverse.io source" writeup - this block is just the CSS half.

   .solar-content gets position:relative;z-index:1 above the
   absolutely-positioned .solar-aurora decoration - the established pattern
   (mercury-fire's .mercury-planet, fear-greed's .fg-content, quakes'
   .quakes-content, pizza-index's .pizza-content) of lifting the FOREGROUND
   element rather than giving the decoration a negative z-index (see
   fear-greed.js's header comment for the price-move.js incident that
   established this). overflow:hidden on the tile itself is what actually
   clips the oversized rotating layers down to the tile's visible bounds -
   there is no separate overflow:clip wrapper the way the source's
   .btn-wrapper has one, since the tile element already plays that role
   here. */
.vibe-card.solar-storm{position:relative;overflow:hidden}
/* LEGIBILITY VERDICT (2026-08-19 flagship-card-typography-plan Task 1) -
   NO CHANGE, re-confirmed rather than assumed. This aurora already had two
   dedicated legibility fixes earlier this session (the opacity:.4/
   filter:saturate(.4) brightness(.95) safeguard documented in the
   .solar-aurora comment below, re-verified again after the color-layer
   swap and the 3rd-stop addition) - re-checked here via Playwright
   screenshot across the rotation cycle rather than re-deriving from
   scratch: .solar-content's text stayed legible at every sampled point,
   including the color-dodge layers' brightest phase, same as the prior
   passes found (see task-1-report.md for this task's own screenshots).
   No redundant backdrop panel added - the existing opacity/filter
   safeguard is still sufficient. */
.vibe-card.solar-storm .solar-content{position:relative;z-index:1}
.vibe-card.solar-storm .solar-aurora{
  position:absolute;inset:0;pointer-events:none;overflow:hidden;
  /* --color-layer-a/--color-layer-b: same two-color-alternating pattern the
     uiverse.io source uses. Originally both stops were cyan/teal
     (#67e8f9/#0e7490 - Tailwind cyan-300/cyan-700), which differ mainly in
     LIGHTNESS, not hue - same-hue-family pairing is exactly why this read
     as flat/dull (user feedback, 2026-08-19
     fear-greed-drip-shape-and-aurora-contrast-plan Task 2). The source
     itself paired near-white (#fff) with saturated blue (#00f) - genuine
     hue contrast, part of why it read as vivid. Re-picked here as icy
     cyan-white (#cffafe, Tailwind cyan-100 - bright without being pure
     #fff, which risked an even harsher color-dodge blowout than the
     original vivid pass already caused, see below) into deep violet
     (#6d28d9, Tailwind violet-700), spanning cyan through purple/magenta -
     real aurora photography covers that range, so this stays thematically
     apt storm/aurora color, not contrast for its own sake.
     opacity:.4 and the toned-down filter (source: saturate(.65)
     brightness(1.8); here: saturate(.4) brightness(.95)) are both
     legibility safeguards, not copied from the source verbatim - the
     ORIGINAL rejected aurora attempt needed a dedicated contrast fix for
     exactly this reason (see solar-storm.js's header comment). An initial
     pass at opacity:.6/brightness(1.15) still blew out to a near-white
     wash at certain points in the rotation cycle - two of the four layers
     use mix-blend-mode:color-dodge (see .aurora-layer:nth-child(even)
     below), which brightens toward white on its own when several
     overlapping bright-cyan stops compound; layering a brightness(1.15)
     filter ON TOP of that compounded the blowout instead of taming it.
     Dropping the filter's brightness below 1 (rather than above) and
     cutting the wrapper opacity to .4 was what actually fixed it -
     verified via Playwright screenshots at multiple points across the
     rotation cycle (task-1-report.md) that .solar-content's text stays
     legible throughout, including the color-dodge layers' brightest
     phase. Re-verified again after this Task 2 color swap (same
     opacity/filter values held, still legible at all sampled rotation
     points - see this plan's task-1-report.md for the new screenshots;
     no opacity/filter change was needed).

     --color-layer-c (2026-08-19 aurora-more-variation-plan Task 1): a 3rd
     stop color, aurora green (#34d399, Tailwind emerald-400), added
     between the existing cyan/violet pair per live feedback ("needs more
     variation, probably more layers of gradiation"). Real aurora
     photography spans green-cyan-purple, so green sits thematically
     between the two existing hues rather than introducing an unrelated
     accent. Same opacity/filter legibility safeguards held unchanged here
     - re-verified (task-1-report.md, aurora-more-variation-plan) that the
     3-color gradient does not reintroduce the near-white blowout the
     2-color version was originally fixed for. */
  --color-layer-a:#cffafe;
  --color-layer-b:#6d28d9;
  --color-layer-c:#34d399;
  opacity:.4;
  filter:saturate(.4) brightness(.95);
}
/* 6 layers (2026-08-19 aurora-more-variation-plan Task 1), up from the
   original revival's 4, still under the source's 7. Global Constraint #5
   explicitly allows judgment on layer count; the revival plan's perf check
   found 4 layers x 8 stress-cloned tiles (32 layers) held a clean 60fps
   with real headroom, so this task spends some of that headroom on live
   feedback ("needs more variation, probably more layers of gradiation")
   without going all the way to the source's 7 (this app's earlier bespoke
   aurora attempt was rolled back for being "way too much"). See
   task-1-report.md (aurora-more-variation-plan) for the re-run perf check
   at 6 layers x 8 tiles (48 layers).
   Sizing is percentage-based (top/left:50%, width:220%, translate via the
   keyframe below), NOT the source's fixed `width:500%;left:-160px` tuned
   to one specific button's pixel dimensions - this is what actually makes
   the effect scale correctly across the tile grid's fluid width
   (.tiles's auto-fit,minmax(150px,1fr)), from the 150px floor up through
   a wide promoted card, rather than a literal copy-paste of proportions
   tuned for something else entirely. */
.vibe-card.solar-storm .aurora-layer{
  position:absolute;top:50%;left:50%;
  width:220%;aspect-ratio:1;border-radius:50%;
  background:radial-gradient(ellipse at 65% 180%,
    var(--color-layer-a), var(--color-layer-c), var(--color-layer-b),
    var(--color-layer-c), var(--color-layer-a));
  mix-blend-mode:difference;
  animation-name:aurora-rotate;
  animation-timing-function:linear;
  animation-iteration-count:infinite;
}
.vibe-card.solar-storm .aurora-layer:nth-child(even){mix-blend-mode:color-dodge}
/* aurora-more-variation-plan Task 1, 2nd motion axis: every layer used to
   rotate around the exact same fixed center point
   (translate(-50%,-50%) rotate() only) - one shared axis no matter how
   many layers or delay/duration phase offsets. Two changes, both folded
   into this SAME keyframe/animation (Global Constraint: one continuous
   animation per layer, no new element or second animation property):
     1. each .aurora-layer instance gets its own transform-origin set
        inline by solar-storm.js (a few percent off dead-center, different
        per layer) - a STATIC property, so it composes for free with this
        keyframe without needing its own animation or timing; the visual
        effect is that each layer now sweeps a slightly different
        orbit/arc instead of all six tracing the identical circle.
     2. a scale() wobble at the keyframe's midpoint - a slow "breathe"
        that peaks at 33%/66% instead of holding flat - adds a second
        _kind_ of motion (expand/contract) layered onto the rotation
        itself, still transform-only (compositor-friendly, Global
        Constraint #1: no layout-triggering properties).
   Reduced-motion note: the existing override below
   (.vibe-card.solar-storm .aurora-layer{animation-name:none}) still fully
   freezes this - animation-name:none collapses the animation list to
   zero animations regardless of how many keyframe stops or transform
   functions the single named animation contains, so no new override was
   needed for this change (verified live, see task-1-report.md). */
@keyframes aurora-rotate{
  0%{transform:translate(-50%,-50%) rotate(0deg) scale(1)}
  33%{transform:translate(-50%,-50%) rotate(120deg) scale(1.08)}
  66%{transform:translate(-50%,-50%) rotate(240deg) scale(.94)}
  100%{transform:translate(-50%,-50%) rotate(360deg) scale(1)}
}
.vibe-card.solar-storm.bear{border-color:rgba(14,116,144,.5)}
/* nyc_clouds flagship vibe card (render/vibe-fx/wall-st-weather.js) -
   2026-08-18 wall-st-weather-card-plan Task 1. See that file's header
   comment for the full "why this is adapted, not copy-pasted, from
   uiverse.io/Galahhad/strong-squid-82" writeup - this block is just the CSS
   half.

   Percentage-based sizing/positioning throughout (--wsw-sun/-overcast at
   top:%/width:% of the tile itself, not the source's fixed em pill
   offsets) is what makes this genuinely rescale across .tiles' fluid width
   (auto-fit,minmax(150px,1fr), the same floor mercury-fire's/solar-storm's
   own scaling comments already document respecting) instead of reading as
   a shrunk 5.625em x 2.5em pill toggle sitting in a box. min-height gives
   the scene room to read as a scene at the 150px tile-width floor, where
   the label/value/sub text alone would otherwise leave very little
   vertical space for it.

   .wsw-content gets position:relative;z-index:1 above the absolutely
   positioned .wsw-scene decoration - the established pattern
   (mercury-fire's .mercury-planet, fear-greed's .fg-content, quakes'
   .quakes-content, pizza-index's .pizza-content, solar-storm's
   .solar-content) of lifting the FOREGROUND element rather than giving the
   decoration a negative z-index.

   The checkbox itself is visually hidden with the same clip-rect
   sr-only-style technique this file already uses for
   .invert-toggle-input (see that rule, above) rather than display:none,
   since :checked still needs to be evaluable - display:none doesn't affect
   :checked matching either way, this just keeps the treatment consistent
   with the one other checkbox-driven control in this file. It is also
   `disabled` (set in wall-st-weather.js) and aria-hidden, so it is neither
   focusable nor clickable; the decorative scene wrapper also carries
   aria-hidden="true" since it duplicates information the tile's own
   text/label already conveys (WCAG 1.1.1 - decorative, not
   informational). */
.vibe-card.wall-st-weather{position:relative;overflow:hidden;min-height:96px}
.vibe-card.wall-st-weather .wsw-toggle{position:absolute;width:1px;height:1px;
  margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
/* LEGIBILITY VERDICT v2 (2026-08-19 wall-st-weather-invert-text-plan Task 1)
   - backdrop panel REMOVED per explicit user request ("remove the backdrop
   from the weather card, and maybe we should change the text such that it
   inverts against the background"). Replaced with a genuinely self-adapting
   technique for the state it actually works for, plus an honest fallback
   for the state it doesn't - full math below because both halves of that
   claim need receipts, not a hand-wave.

   SUNNY (unchecked, sky #bfe4ff->#eaf6ff): mix-blend-mode:difference with
   color:#fff on .lbl/.val/.sub (below) genuinely works, and works BETTER
   than the removed panel at points along the gradient, because the diff
   result self-adapts per pixel instead of using one flat scrim color.
   difference(white, bg) = |255-bg| per channel, so a near-white bg (bottom
   stop #eaf6ff, 234/246/255) diffs down to a near-black glyph (~21/9/0) -
   computed WCAG contrast against that stop: ~17.9:1. The top stop
   (#bfe4ff, 191/228/255) diffs to (64/27/0), still ~11.5:1. Both comfortably
   clear the 4.5:1 body-text bar with real margin - verified against the
   live-rendered composite via Playwright, see task-1-report.md.

   GLOOMY (checked, sky #5b6472->#8a93a1): the SAME diff(white,bg) technique
   does NOT clear the bar - computed ~2.18:1 at the top stop, ~1.66:1 at the
   bottom, both hard fails. This isn't a tuning problem: the two stops sit
   on OPPOSITE sides of the diff-inversion sweet spot (diff-blend only
   produces strong contrast when bg luminance is near 0 or 1; gloomy's range
   -  L~0.13 to L~0.29 - is squarely mid-tone, where difference/exclusion
   mathematically under-perform regardless of which fixed text color is
   picked. It's also proven UNFIXABLE by any single flat (non-blended) text
   color either: reaching 4.5:1 against the dark top stop requires text
   luminance >=0.74 (i.e. must be light), but reaching 4.5:1 against the
   lighter bottom stop requires text luminance <=0.025 (i.e. must be dark) -
   contradictory requirements no one static color can satisfy. That
   contradiction is exactly why the original panel existed - it flattened
   both stops to the same dark backdrop so one light color worked
   everywhere; removing it re-exposes the gradient's inherent two-extreme
   problem.
   Honest fallback for gloomy only, borrowing doomposts' own just-established
   precedent (see doomposts' "LEGIBILITY VERDICT v2" comment below) for
   exactly this "blend/flat-color genuinely can't clear the bar" situation:
   blend-mode is switched OFF (mix-blend-mode:normal) and a solid
   var(--bg) outline ring (8-direction 1px text-shadow, same technique)
   is added around the still-white glyph fill. Neither half of this hits
   4.5:1 at every point - the white FILL alone computes ~5.98:1 vs the top
   stop but only ~3.10:1 vs the bottom stop; the dark RING alone computes
   ~3.14:1 vs the top stop but ~6.06:1 vs the bottom stop - but at every
   point along the gradient at least one of fill-vs-bg or ring-vs-bg clears
   4.5:1, and both always clear the 3:1 large-text bar, so the glyph reads
   via the higher-contrast half at any given point rather than relying on
   the fill number alone. This is a real, disclosed shortfall, not a
   silent one - see task-1-report.md for the full numeric readout and the
   honest call-out that gloomy does not reach a clean AA pass everywhere. */
/* 2026-08-20 wall-st-weather-scene-first-restyle-plan Task 1: shrink +
   reposition, let the scene lead. .wsw-content was in-flow (position:
   relative) at the generic .tile text sizes (.75rem/1.5rem/.8rem) - full
   tile width, top of the padding box, directly over the sun/overcast disc
   (top:10%;left:50%;width:32% below) - which is why live screenshots read
   as "a text card with a weather icon stuck on" rather than an adaptation
   of strong-squid-82's icon-first feel. Switched to position:absolute,
   bottom-left-anchored with a max-width cap, so it reads as a compact
   corner annotation instead of the tile's dominant content, leaving the
   sun/overcast disc (which stays top-center, untouched per Global
   Constraint #3) as the only thing occupying the tile's visual center.
   Font sizes below (.45rem/.95rem/.5rem, ~60-63% of the .tile defaults)
   are a deliberate shrink per that plan's Task 1 - not a token nudge.
   The parent .vibe-card.wall-st-weather already has position:relative
   (declared above), so left/bottom below resolve against ITS padding box,
   same as the sun/overcast/clouds do - no extra positioning context
   needed. mix-blend-mode:difference/#fff and the :checked outline-ring
   fallback further down are UNCHANGED from the LEGIBILITY VERDICT v2 pass
   above - re-verified numerically at these new sizes in task-1-report.md:
   the underlying contrast ratios (diff-blend sunny ~17.9:1/~11.5:1;
   gloomy fill/ring ~5.98:1|~3.10:1 and ~3.14:1|~6.06:1) are pure color
   math and don't change with font-size, but at these smaller sizes the
   text no longer qualifies for WCAG's "large text" 3:1 allowance the
   original comment leaned on as a secondary reassurance - the numbers
   were re-checked against the stricter 4.5:1-only bar and still hold (at
   every point along gloomy's gradient, at least one of fill-vs-bg or
   ring-vs-bg still clears 4.5:1 on its own). */
/* 2026-08-20 wall-st-weather-scene-first-restyle-plan Task 1 review round 1
   fix: the block above was only measured at a row-stretched sample
   (161.7x156.4px, taller than the tile's own min-height:96px because a
   taller grid row-mate stretched it via CSS Grid's default
   align-items:stretch), never at .tiles' actual floor width
   (auto-fit,minmax(150px,1fr) -> 150px) with the tile at its true
   unstretched intrinsic height (96px). At that real floor size, .val
   ("0% clouds") and .sub ("sunny = traders happy = pump (sun emoji)") each
   wrapped to 2 lines within the old max-width:58% (85.8px), pushing
   .wsw-content's rendered height to 65.3px in a 96px-tall tile - a large,
   visible collision with the sun/overcast disc (top:10%;width:32%, bottom
   edge ~57.6px down), not merely a tight-clearance case. Wrapping height is
   inherently unpredictable (depends on live weather-derived numbers/copy,
   not just font-size), so the fix caps each line to ONE line via
   white-space:nowrap;overflow:hidden;text-overflow:ellipsis (truncate
   rather than grow) instead of trying to out-guess wrap points, on top of
   a further font-size shrink and a tighter line-height/no margin-top so
   three guaranteed-single-line rows fit with real clearance under the sun
   at the 150x96 floor. Measured at that true floor size (forced via
   card.style.width/height, not row-stretched): sun disc bottom ~57.75px
   from tile top; .wsw-content top 63px from tile top, identically in both
   sunny and checked-gloomy (overcast shares the sun's exact box, and was
   read after its .5s translate transition settled, not mid-animation) -
   5.25px genuine, measured vertical clearance, verified via Playwright
   getBoundingClientRect, see task-1-report.md round-1-fix addendum. Scene
   decoration (sun/overcast size/position) is UNCHANGED - this is a
   text-side-only fix per Global Constraint #3 and the review finding's
   stated preference. */
.vibe-card.wall-st-weather .wsw-content{
  position:absolute;z-index:1;left:10px;bottom:6px;max-width:60%;
  line-height:1.1;
  mix-blend-mode:difference;
}
.vibe-card.wall-st-weather .wsw-content .lbl{font-size:.35rem}
.vibe-card.wall-st-weather .wsw-content .val{font-size:.75rem;margin-top:0}
.vibe-card.wall-st-weather .wsw-content .sub{font-size:.38rem}
.vibe-card.wall-st-weather .wsw-content .lbl,
.vibe-card.wall-st-weather .wsw-content .val,
.vibe-card.wall-st-weather .wsw-content .sub{
  color:#fff;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.vibe-card.wall-st-weather .wsw-scene{position:absolute;inset:0;overflow:hidden;
  border-radius:inherit;pointer-events:none;z-index:0}
.vibe-card.wall-st-weather .wsw-sky{position:absolute;inset:0;
  background:linear-gradient(180deg,#bfe4ff 0%,#eaf6ff 100%);
  transition:background .6s ease}
/* sun + overcast disc share identical size/position (top:10%;width:32% of
   the tile) so the overcast disc lines up exactly over the sun when it
   slides in - the repurposed "moon covers sun" mechanic from the source's
   .theme-switch__moon, translateX(100%)-equivalent here as translateY on
   the tile's own percentage box rather than the source's fixed em
   circle-container offset. */
.vibe-card.wall-st-weather .wsw-sun{
  position:absolute;top:10%;left:50%;width:32%;aspect-ratio:1;
  transform:translate(-50%,0);border-radius:50%;
  background:radial-gradient(circle at 35% 30%,#fff7c2,#f2c53d 65%,#d99a1f);
  box-shadow:0 0 14px 2px rgba(242,197,61,.55);
  transition:opacity .5s ease,filter .5s ease}
.vibe-card.wall-st-weather .wsw-overcast{
  position:absolute;top:10%;left:50%;width:32%;aspect-ratio:1;
  transform:translate(-50%,120%);border-radius:50%;opacity:0;
  background:radial-gradient(circle at 35% 30%,#dfe4ea,#aab2bd 65%,#8b93a1);
  transition:transform .5s cubic-bezier(0,-.02,.35,1.17)}
/* cloud puff cluster: wall-st-weather.js's cloudPuffBoxShadow generates the
   --wsw-clouds box-shadow value against a fixed local coordinate box (the
   same "small local box, not full-tile pixel math" approach starfield.js's
   own header comment documents for stars) on a single small base circle,
   centered via left:50%/transform so the cluster stays centered regardless
   of tile width; scale()/bottom%/opacity - all relative units - are what
   actually move and grow it between states, not the box-shadow coordinates
   themselves. Sunny: small, low, faint (clouds pushed toward the bottom
   edge, echoing the source's clouds sliding mostly out of view). Gloomy:
   scaled up and raised to fill much of the scene. */
.vibe-card.wall-st-weather .wsw-clouds{
  position:absolute;left:50%;bottom:18%;width:8px;height:8px;
  transform:translate(-50%,0) scale(.5);border-radius:50%;
  box-shadow:var(--wsw-clouds);opacity:.5;
  transition:transform .5s cubic-bezier(0,-.02,.4,1.25),
             bottom .5s cubic-bezier(0,-.02,.4,1.25),
             opacity .5s ease}
.wsw-toggle:checked ~ .wsw-scene .wsw-sky{background:linear-gradient(180deg,#5b6472 0%,#8a93a1 100%)}
.wsw-toggle:checked ~ .wsw-scene .wsw-sun{opacity:.35;filter:blur(1px)}
.wsw-toggle:checked ~ .wsw-scene .wsw-overcast{transform:translate(-50%,0);opacity:1}
.wsw-toggle:checked ~ .wsw-scene .wsw-clouds{transform:translate(-50%,0) scale(1.1);bottom:40%;opacity:.95}
/* gloomy-state text fallback - see the "LEGIBILITY VERDICT v2" comment
   above .wsw-content: diff(white,bg) fails both gloomy sky stops (~2.18:1
   top, ~1.66:1 bottom), so blend-mode is switched off here and swapped for
   doomposts' own outline-ring technique (a solid var(--bg) ring painted
   around the still-white glyph, via multi-direction 1px text-shadow)
   instead. Static per-state swap, no transition - Global Constraint #5
   doesn't apply, nothing here animates. */
.wsw-toggle:checked ~ .wsw-content{
  mix-blend-mode:normal;
}
.wsw-toggle:checked ~ .wsw-content .lbl,
.wsw-toggle:checked ~ .wsw-content .val,
.wsw-toggle:checked ~ .wsw-content .sub{
  text-shadow:1px 0 0 var(--bg),-1px 0 0 var(--bg),0 1px 0 var(--bg),0 -1px 0 var(--bg),
    1px 1px 0 var(--bg),-1px -1px 0 var(--bg),1px -1px 0 var(--bg),-1px 1px 0 var(--bg);
}
/* hn_doom flagship vibe card (render/vibe-fx/doomposts.js) - "Meadow of the
   Four Horsemen" (2026-08-18 doomposts-card-plan Task 1). Two independent
   raw-driven mechanisms per that plan's Global Constraint #2: the sky
   gradient (continuous, --doompost-sky-top/-horizon set inline per-render
   from doomposts.js's skyGradientStops(raw), the same inline-custom-
   property technique moon-phase.js/mercury-fire.js already establish) and
   each horse's grazing/occupied fill (discrete, driven by which class
   doompost-horse.js attaches per horse). This card has NO ambient/
   continuous animation (Global Constraint #4 - unlike mercury/solar-storm/
   moon, its interest is current state + what changed since the last poll,
   not constant motion) - the sky/ground never animate, and the only
   transition anywhere is the horse fill/opacity flip below, which is brief
   (.4s) and only ever fires on an actual state change. */
.vibe-card.doomposts{position:relative;overflow:hidden;min-height:110px}
/* LEGIBILITY VERDICT v2 (2026-08-19 doomposts-font-and-debug-toggle-plan
   Task 1) - backdrop panel REMOVED per this task's explicit request; the
   font/weight/outline treatment below has to carry the legibility burden
   the panel used to carry, on its own.
   Honest math first, because it matters: font-family, font-weight and
   letter-spacing are NOT inputs to the WCAG contrast formula - it only
   ever takes two flat colors. So none of those three can move the
   text-vs-sky number at all. --text (#ece8fa) against the raw=0 sky's
   worst stop is still ~1.12:1 (horizon #fef9c3) / ~1.13:1 (top #bfe3f7),
   exactly what the removed panel's comment measured - a hard WCAG fail,
   unfixed and unfixable by weight/spacing alone. That's the honest
   "cannot reach 4.5:1 by font choice" finding this task's brief expected
   as a real possible outcome.
   What DOES move the needle is the layered text-shadow below, which is a
   real (if WCAG-formula-external) technique: it paints a near-solid ~1-2px
   dark ring in var(--bg) (#12101c, this card's/app's own dark token, same
   provenance rule the removed panel followed) directly around every glyph
   stroke, the same "outline over a variable background" trick captions/
   subtitles use over photos. That ring color against the raw=0 horizon
   stop computes to ~17.5:1 (WCAG formula, same two-flat-colors math, just
   applied to the ring color instead of the glyph-fill color) - verified
   against the live-rendered composite via Playwright during this task,
   see task-1-report.md for the full readout including the ring's actual
   sampled pixels. The glyph FILL itself is still --text at ~1.12:1
   against the sky and that number cannot change - legibility here comes
   from the eye reading the high-contrast ring, not the fill-vs-sky pair,
   same principle white subtitle text over bright video frames relies on.
   raw=30 (dark end) is unaffected either way: top/horizon are --bg/--bg3,
   --text already clears WCAG there with no ring needed.
   occupiedCount(raw) (0-4, doompost-occ-N classes, doomposts.js) drives
   escalating weight/letter-spacing/ring-thickness/glow below - a THIRD
   thing keyed off skyDarkness would violate Global Constraint #3, so the
   ring's own color/base presence intentionally does NOT vary by raw; only
   the occ-N classes add intensity on top of the always-present ring. */
.vibe-card.doomposts .doompost-content{
  position:relative;z-index:1;
  font-family:'Cinzel','Iowan Old Style','Palatino Linotype',Palatino,Georgia,serif;
  font-weight:500;letter-spacing:.01em;
  text-shadow:1px 0 0 var(--bg),-1px 0 0 var(--bg),0 1px 0 var(--bg),0 -1px 0 var(--bg),
    1px 1px 0 var(--bg),-1px -1px 0 var(--bg),1px -1px 0 var(--bg),-1px 1px 0 var(--bg);
}
/* occupiedCount escalation (0-4) - font-weight climbs, letter-spacing
   widens, the outline ring thickens, and the top of the range adds a
   dread-red glow outside the ring. Static per-state treatment, no
   transition (Global Constraint #7 - a static per-state font change needs
   no reduced-motion gating). */
.vibe-card.doomposts .doompost-occ-1{font-weight:600;letter-spacing:.02em}
.vibe-card.doomposts .doompost-occ-2{
  font-weight:700;letter-spacing:.035em;
  text-shadow:1.5px 0 0 var(--bg),-1.5px 0 0 var(--bg),0 1.5px 0 var(--bg),0 -1.5px 0 var(--bg),
    1.5px 1.5px 0 var(--bg),-1.5px -1.5px 0 var(--bg),1.5px -1.5px 0 var(--bg),-1.5px 1.5px 0 var(--bg);
}
.vibe-card.doomposts .doompost-occ-3{
  font-weight:800;letter-spacing:.05em;
  text-shadow:2px 0 0 var(--bg),-2px 0 0 var(--bg),0 2px 0 var(--bg),0 -2px 0 var(--bg),
    2px 2px 0 var(--bg),-2px -2px 0 var(--bg),2px -2px 0 var(--bg),-2px 2px 0 var(--bg),
    0 0 6px #7a1220;
}
.vibe-card.doomposts .doompost-occ-4{
  font-weight:900;letter-spacing:.08em;
  text-shadow:2px 0 0 var(--bg),-2px 0 0 var(--bg),0 2px 0 var(--bg),0 -2px 0 var(--bg),
    2px 2px 0 var(--bg),-2px -2px 0 var(--bg),2px -2px 0 var(--bg),-2px 2px 0 var(--bg),
    0 0 10px #8a1526,0 0 18px #4a0e16;
}
/* sky fills the whole tile behind everything else; the ground strip sits
   on top of the sky's bottom edge. Both z-index:0, ground painted after sky
   in source order so it visually sits above the sky without needing a
   higher index. */
.vibe-card.doomposts .doompost-sky{
  position:absolute;inset:0;z-index:0;
  background:linear-gradient(to bottom, var(--doompost-sky-top), var(--doompost-sky-horizon));
}
/* ground: a plain green gradient strip - "doesn't need literal grass-blade
   illustration" per the plan's Visual design guidance. Static regardless of
   raw (only the sky darkens) - keeps the two raw-driven mechanisms visually
   separate rather than having a third thing quietly change with raw. */
.vibe-card.doomposts .doompost-ground{
  position:absolute;left:0;right:0;bottom:0;height:34px;z-index:0;
  background:linear-gradient(to bottom, #4d7c34, #2c4a1e);
}
.vibe-card.doomposts .doompost-horses{
  position:absolute;left:4px;right:4px;bottom:0;z-index:0;
  display:flex;justify-content:space-between;align-items:flex-end;
}
/* horse fill/state: lighter warm tan/brown grazing -> darker near-black/
   deep-red occupied, per the plan's Visual design guidance. transition is
   the only motion this card ever has - it only actually plays if a horse's
   `occupied` class changes on an in-place DOM node (e.g. the verification
   technique of toggling classes without a full re-render); vibes.js's
   normal poll cycle replaces the whole card's innerHTML each time; per
   Global Constraint #4 that's fine - the card is a rendered state, not an
   ambient animation. */
.vibe-card.doomposts .horse-body,
.vibe-card.doomposts .horse-neck,
.vibe-card.doomposts .horse-ear,
.vibe-card.doomposts .horse-tail{
  fill:#c98a4b;transition:fill .4s ease;
}
.vibe-card.doomposts .doompost-horse.occupied .horse-body,
.vibe-card.doomposts .doompost-horse.occupied .horse-neck,
.vibe-card.doomposts .doompost-horse.occupied .horse-ear,
.vibe-card.doomposts .doompost-horse.occupied .horse-tail{
  fill:#2a0a0d;
}
/* the small hooded rider silhouette markup only exists at all when a horse
   is occupied (doomposts.js), so this needs no state-qualified selector -
   it's simply always dark, no transition needed since it fades in/out with
   the horse's own class toggle rather than color-shifting itself. */
.vibe-card.doomposts .horse-rider{fill:#0d0710}
/* backtest "trades made" tile (render/backtest.js) — was a bare onclick-div
   wrapping a .tile, converted to a real <button> for keyboard access.
   Strips the global button gradient/padding so the wrapped .tile looks
   exactly like the plain div it replaces; the .tile inside still supplies
   its own background/border/padding. */
button.tile-btn{display:block;width:100%;background:none;border:0;padding:0;
  margin:0;text-align:inherit;font:inherit;color:inherit;cursor:pointer}
.up{color:var(--success)} .down{color:var(--danger)}
/* Default button glow-up (2026-08-28, Alan: "our default button themes
   need a glow-up in general. they look too much like every other
   first-pass AI app" -- root cause: the base button{} rule had NO
   background at all, so every unstyled primary-action button rendered
   as a raw browser form control). Adapted from
   uiverse.io/kleenpulse/dangerous-horse-41 -- same animated
   gradient-border + cut-corner clip-path technique, re-skinned onto
   this app's own --accent-1/2/3 tokens (the same 3-token set
   DiceSpinner.vue's own --clr pattern already draws from) instead of
   the reference's hardcoded orange/purple/cyan, so it re-skins
   correctly across all 12 theme packs including the light one rather
   than looking neon on exactly one. Scope: BASE button{} (primary
   actions) only -- .ghost/.danger/utility variants stay as they are,
   already reading as intentional secondary actions, not the "generic
   AI app" offenders. Dropped the reference's text-transform:uppercase
   -- this app's whole voice is lowercase brainrot copy ("force a
   trade", "dip 🚪"), forcing caps on every button would fight that.

   The button's own fill is a fixed near-black (not var(--bg3), which
   swings all the way to near-white on the light theme pack) --
   the neon gradient border needs a dark backdrop to read as a glow
   regardless of the surrounding page theme, same reasoning a real app
   commits to a punchy CTA button color across light/dark modes rather
   than blending into the page. Text color similarly can't be
   var(--text) (that token is dark-on-light in the light pack, would be
   illegible against this button's always-dark fill) -- uses --accent-2
   lightened toward white instead, full white on hover/focus, matching
   the reference's own hover treatment. */
/* backlog #44/#45 Task 3: bigger touch targets. App.vue's
   applyAccessibilityPrefs sets/removes data-a11y-touch="big" on <html> from
   auth.me.prefs.accessibility.big_touch_targets. 44px matches the WCAG
   2.5.5 minimum target-size guidance -- not an arbitrary number. Shared
   across every theme pack (no per-pack variant needed, unlike the
   high-contrast block above) since target size is a geometry concern, not
   a color one -- every pack's button/.chip/.app-nav-item already inherit
   this rule's min-height/padding regardless of which pack-specific fill/
   border rules also apply to those same selectors elsewhere in this file.

   Whole-branch review findings (fixed here):
   1. `button` alone tied .tile-btn/.linklike/.chip-del/.chip-load on
      specificity, but a plain-element selector loses a tie to those
      classed ones per cascade order, not a tie -- silently overriding
      their deliberate padding:0. Excluded via :not() so this rule never
      touches the buttons that are supposed to stay unpadded.
   2. 23 buttons across frontend/src (all .vue files, recursively) carry inline
      style="padding:..." (e.g. the buy-slot buttons on Bots.vue), and an
      inline style always wins over an author stylesheet regardless of
      specificity -- so those buttons grew to min-height:44px but kept
      their tiny inline horizontal padding, the exact small/hard-to-hit
      buttons this feature exists to fix. padding:...!important reaches
      through the inline style; min-height doesn't need !important since
      none of those buttons set an inline min-height. */
:root[data-a11y-touch="big"] button:not(.tile-btn):not(.linklike):not(.chip-del):not(.chip-load),
:root[data-a11y-touch="big"] .chip,
:root[data-a11y-touch="big"] .app-nav-item{
  min-height:44px;padding:12px 20px!important;
}
button{
  --btn-border: linear-gradient(-45deg, var(--accent-1), var(--accent-3), var(--accent-2));
  --btn-border-w: .12em;
  --btn-curve: .4em;
  --btn-fill: color-mix(in srgb, var(--bg) 15%, #05030a 85%);
  color: color-mix(in srgb, var(--accent-2) 55%, white 45%);
  position: relative; isolation: isolate;
  /* grid-auto-flow:column, not the bare default (row): with no
     grid-template-columns set, a grid container's implicit grid has
     exactly ONE auto column, so grid-auto-flow:row (the default) stacks
     each child into its own ROW instead of sitting side by side --
     invisible for a plain-text button (one anonymous text-node grid
     item, nothing to stack), but any button with a real icon ELEMENT
     next to its label (DiceSpinner.vue's <div>, not an emoji character)
     got silently stacked into two rows, growing the button and reading
     as the icon "jumping" when the icon swapped from a text glyph to an
     element (2026-08-28 live report, force-a-trade in
     BotRosterGrid.vue). :column makes multiple grid items lay out in
     one row like the icon+label pairing was always meant to render. */
  display: inline-grid; grid-auto-flow: column; place-content: center;
  border: 0; padding: 9px 18px; font-weight: 700; font-size: .9rem;
  cursor: pointer;
  box-shadow: 0 4px 14px rgba(0,0,0,.35);
  clip-path: polygon(0% var(--btn-curve), var(--btn-curve) 0, 100% 0,
    100% calc(100% - var(--btn-curve)), calc(100% - var(--btn-curve)) 100%, 0 100%);
  transition: color 200ms;
}
button::before, button::after{content:"";position:absolute;inset:0}
button::before{background:var(--btn-border);background-size:300% 300%;z-index:-2}
@media (prefers-reduced-motion:no-preference){
  button::before{animation:btn-border-shift 5s ease infinite}
}
:root:is([data-motion="reduced"],[data-motion="off"]) button::before{animation:none}
@keyframes btn-border-shift{
  0%{background-position:31% 0%} 50%{background-position:70% 100%} 100%{background-position:31% 0%}
}
button::after{
  background: var(--btn-fill); z-index: -1;
  clip-path: polygon(
    var(--btn-border-w) calc(var(--btn-curve) + var(--btn-border-w) * .5),
    calc(var(--btn-curve) + var(--btn-border-w) * .5) var(--btn-border-w),
    calc(100% - var(--btn-border-w)) var(--btn-border-w),
    calc(100% - var(--btn-border-w)) calc(100% - calc(var(--btn-curve) + var(--btn-border-w) * .5)),
    calc(100% - calc(var(--btn-curve) + var(--btn-border-w) * .5)) calc(100% - var(--btn-border-w)),
    var(--btn-border-w) calc(100% - var(--btn-border-w)));
  transition: clip-path 400ms;
}
button:where(:hover,:focus-visible){color:#fff}
button:where(:hover,:focus-visible)::after{
  clip-path: polygon(
    calc(100% - var(--btn-border-w)) calc(100% - calc(var(--btn-curve) + var(--btn-border-w) * .5)),
    calc(100% - var(--btn-border-w)) var(--btn-border-w),
    calc(100% - var(--btn-border-w)) var(--btn-border-w),
    calc(100% - var(--btn-border-w)) calc(100% - calc(var(--btn-curve) + var(--btn-border-w) * .5)),
    calc(100% - calc(var(--btn-curve) + var(--btn-border-w) * .5)) calc(100% - var(--btn-border-w)),
    calc(100% - calc(var(--btn-curve) + var(--btn-border-w) * .5)) calc(100% - var(--btn-border-w)));
}
/* Every non-primary button variant opts out of the clip-path/gradient-
   border look entirely -- plain shape, no pseudo-elements, same as
   before this glow-up. .ghost/.danger are secondary/destructive actions
   that should read as visually quieter than a primary button; .chip is
   a small filter/tag pill; .tile-btn/.linklike aren't buttons visually
   at all (a whole clickable card, and plain link-styled text,
   respectively) -- none of these should inherit a neon CTA treatment. */
button.ghost,button.danger,button.chip,button.tile-btn,button.linklike{
  isolation:auto;box-shadow:none;clip-path:none;
}
button.ghost::before,button.danger::before,button.chip::before,button.tile-btn::before,button.linklike::before,
button.ghost::after,button.danger::after,button.chip::after,button.tile-btn::after,button.linklike::after{
  content:none;
}
button.ghost,button.danger{border-radius:10px;color:var(--text)}
button.ghost{background:var(--bg3);border:1px solid var(--border)}
button.danger{background:#7f1d1d;color:#fff}
button.ghost:hover,button.danger:hover{filter:brightness(1.15)}
button.chip,button.tile-btn,button.linklike{border-radius:0}
/* a.ghost (2026-08-25, BotDetail.vue's "manage his roster" router-link):
   a real navigation, not a button-triggered action -- Alan specifically
   asked for it "linked rather than nested" (Daniel's feedback: splitting
   bots/roster reads confusing without a way back and forth between them).
   button/button.ghost above are element-scoped (button{...}), so an <a>
   picks up none of that reset by default -- this mirrors both onto the
   one link that needs to look identical to its sibling buttons in the
   same row, without a generic .ghost class quietly restyling every
   other <a> in the app. */
a.ghost{color:var(--text);border:1px solid var(--border);border-radius:10px;
  padding:9px 16px;font-weight:700;font-size:.9rem;cursor:pointer;
  background:var(--bg3);text-decoration:none;display:inline-block}
a.ghost:hover{filter:brightness(1.15)}
/* .a-btn (2026-09-01, GooberDerbyPanel.vue's per-goober "small, themed"
   router-links to /goobers/:id, goober-derby-redesign-plan Task 4) --
   same reasoning as a.ghost just above: the base button{} glow-up rule
   is element-scoped (button{...}), so a router-link's real <a> tag
   picks up none of it by default. This mirrors the PRIMARY button{}
   glow-up (animated-gradient-border CTA, not the quieter .ghost look)
   since these ARE the primary action on this page now that
   stake/tune/swap moved off it -- but sized down (smaller padding/
   font) per Alan's "small, maybe name only" ask, one per goober times
   up to 8 goobers in a wrapped row. Each link sets its own
   --accent-1/2/3 inline (via gooberColor()) so the gradient border
   reads as that goober's own color rather than the page's default
   accent triad. */
a.a-btn{
  --btn-border: linear-gradient(-45deg, var(--accent-1), var(--accent-3), var(--accent-2));
  --btn-border-w: .1em;
  --btn-curve: .3em;
  --btn-fill: color-mix(in srgb, var(--bg) 15%, #05030a 85%);
  color: color-mix(in srgb, var(--accent-2) 55%, white 45%);
  position: relative; isolation: isolate;
  display: inline-grid; grid-auto-flow: column; place-content: center;
  border: 0; padding: 6px 12px; font-weight: 700; font-size: .78rem;
  cursor: pointer; text-decoration: none;
  box-shadow: 0 4px 14px rgba(0,0,0,.35);
  clip-path: polygon(0% var(--btn-curve), var(--btn-curve) 0, 100% 0,
    100% calc(100% - var(--btn-curve)), calc(100% - var(--btn-curve)) 100%, 0 100%);
  transition: color 200ms;
}
a.a-btn::before, a.a-btn::after{content:"";position:absolute;inset:0}
a.a-btn::before{background:var(--btn-border);background-size:300% 300%;z-index:-2}
@media (prefers-reduced-motion:no-preference){
  a.a-btn::before{animation:btn-border-shift 5s ease infinite}
}
:root:is([data-motion="reduced"],[data-motion="off"]) a.a-btn::before{animation:none}
a.a-btn::after{
  background: var(--btn-fill); z-index: -1;
  clip-path: polygon(
    var(--btn-border-w) calc(var(--btn-curve) + var(--btn-border-w) * .5),
    calc(var(--btn-curve) + var(--btn-border-w) * .5) var(--btn-border-w),
    calc(100% - var(--btn-border-w)) var(--btn-border-w),
    calc(100% - var(--btn-border-w)) calc(100% - calc(var(--btn-curve) + var(--btn-border-w) * .5)),
    calc(100% - calc(var(--btn-curve) + var(--btn-border-w) * .5)) calc(100% - var(--btn-border-w)),
    var(--btn-border-w) calc(100% - var(--btn-border-w)));
  transition: clip-path 400ms;
}
a.a-btn:where(:hover,:focus-visible){color:#fff}
a.a-btn:where(:hover,:focus-visible)::after{
  clip-path: polygon(
    calc(100% - var(--btn-border-w)) calc(100% - calc(var(--btn-curve) + var(--btn-border-w) * .5)),
    calc(100% - var(--btn-border-w)) var(--btn-border-w),
    calc(100% - var(--btn-border-w)) var(--btn-border-w),
    calc(100% - var(--btn-border-w)) calc(100% - calc(var(--btn-curve) + var(--btn-border-w) * .5)),
    calc(100% - calc(var(--btn-curve) + var(--btn-border-w) * .5)) calc(100% - var(--btn-border-w)),
    calc(100% - calc(var(--btn-curve) + var(--btn-border-w) * .5)) calc(100% - var(--btn-border-w)));
}
/* "force a decision 🎲" die-key button (2026-08-26, BotDetail.vue). Reskinned
   from uiverse.io chintu_2484/fat-moose-56 — a physical keyboard-key press
   effect (dark bezel + raised inset key, 3px translateY on :active) — with
   the key's letter swapped for the die emoji and its literal white/grey
   plastic-key palette replaced with theme tokens (--bg/--bg3/--border) so
   the button reads correctly across all 12 themes instead of only the demo's
   light-grey key. The "force a decision" copy moved out of the button and
   into a sibling label per Alan's ask — the button is now icon-only. */
.dice-roll-wrap{display:inline-flex;align-items:center;gap:10px}
.dice-roll{position:relative;width:64px;height:56px;padding:0;border:0;
  border-radius:10px;background:var(--bg);box-shadow:0 1px 1px 1px var(--border)}
.dice-roll .key{position:absolute;inset:3px 3px 6px;display:flex;align-items:center;
  justify-content:center;border:0;border-bottom:2px solid var(--border);
  border-radius:8px;background:var(--bg3);box-shadow:0 4px 0 .2px var(--bg);
  font-size:22px;line-height:1;transition:box-shadow 80ms,transform 80ms}
.dice-roll:active .key{box-shadow:0 0 0 0 transparent;transform:translateY(4px)}
.dice-roll-label{font-weight:700;font-size:.9rem;color:var(--text)}
/* DiceSpinner.vue -- "force a decision" loading state (2026-08-27 live
   feedback). Cube geometry + tumble keyframe lifted verbatim from
   uiverse.io AqFox/young-dragon-29 / bociKond/wise-bat-13 (6 flat divs
   folded via translate/rotate+transform-origin, no per-face translateZ)
   -- only the face fill changed, from a translucent color panel to a
   real SVG pip-face (DiceSpinner.vue's own template), themed here via
   --clr off the accent token so it matches every pack automatically. */
/* 2026-08-28 live report: "the die 'jumps' up about one line, where I
   would expect it to stay in place and spin where it is on those other
   style buttons ... the jumping up on the bot detail page makes it look
   like a hologram above the button we made, very cool, but the jumping
   is detrimental if not paired with that same visual button." Root
   cause: .dice-spinner-face is position:absolute but .dice-spinner
   itself never declared itself as their containing block, so the faces
   escaped to whatever POSITIONED ancestor was nearest up the DOM.
   Inside BotDetail's .dice-roll button that ancestor is .key
   (position:relative, sized/placed almost exactly like the die itself),
   so the escape happened to read as a deliberate "hologram above the
   button." Inside BotCard.vue/BotRosterGrid.vue's plain buttons, the
   button glow-up's own `button{position:relative}` (above) is the
   nearest positioned ancestor instead -- a much bigger, differently-
   padded box, so the same escape put the die noticeably out of place.
   position:relative here makes .dice-spinner its own faces' containing
   block everywhere, fixing the plain-button case; the .dice-roll
   override right below deliberately un-contains it again in that one
   context, keeping the hologram look Alan wants paired with that
   specific button style. */
.dice-spinner{--clr:var(--accent-1);animation:dice-spinner-tumble 1.6s infinite ease;
  transform-style:preserve-3d;display:inline-block;position:relative;vertical-align:middle}
.dice-roll .dice-spinner{position:static}
.dice-spinner-face{background:color-mix(in srgb, var(--clr) 15%, transparent);
  height:100%;position:absolute;width:100%;border:2px solid var(--clr);border-radius:3px}
.dice-spinner-pips{width:100%;height:100%;fill:var(--clr)}
/* translateZ can't take a percentage (no z-axis size to resolve it
   against) -- DiceSpinner.vue sets --half:(size/2)px as an inline style
   alongside width/height so this scales with the `size` prop instead of
   a fixed pixel value baked into the stylesheet. */
.dice-spinner-face.face-1{transform:translateZ(calc(var(--half) * -1)) rotateY(180deg)}
.dice-spinner-face.face-2{transform:rotateY(-270deg) translateX(50%);transform-origin:top right}
.dice-spinner-face.face-3{transform:rotateY(270deg) translateX(-50%);transform-origin:center left}
.dice-spinner-face.face-4{transform:rotateX(90deg) translateY(-50%);transform-origin:top center}
.dice-spinner-face.face-5{transform:rotateX(-90deg) translateY(50%);transform-origin:bottom center}
.dice-spinner-face.face-6{transform:translateZ(var(--half))}
@keyframes dice-spinner-tumble{
  0%{transform:rotate(45deg) rotateX(-25deg) rotateY(25deg)}
  50%{transform:rotate(45deg) rotateX(-385deg) rotateY(25deg)}
  100%{transform:rotate(45deg) rotateX(-385deg) rotateY(385deg)}
}
/* leaderboard sidebar nav triggers (index.html) — were bare onclick-<h3>/<p>,
   converted to real <button>s (wrapped by the heading/paragraph) for
   keyboard access. Reset to plain inline text matching what the
   cursor:pointer heading/paragraph looked like before. */
button.linklike{display:inline;background:none;border:0;padding:0;margin:0;
  font:inherit;color:inherit;cursor:pointer}
button.linklike:hover{text-decoration:underline dotted;text-underline-offset:3px}
input,select{background:var(--bg3);border:1px solid var(--border);color:var(--text);
  border-radius:8px;padding:8px 10px;font-size:.9rem}
input[type=range]{padding:0}
.err{color:var(--danger);font-size:.85rem;min-height:1.2em}
.small{font-size:.8rem;color:var(--muted)}
/* Reusable visually-hidden utility (cf-a11y-dev audit, 2026-08-25) --
   extracted from .invert-toggle-input's existing clip-rect technique
   below rather than duplicated inline. Home.vue's first use case: a page
   needs SOME top-level heading for screen-reader users who navigate by
   heading level (finding #5 -- no <h1> existed anywhere in the app), but
   Home's actual visual design (score/graph/signal-cards, no header per
   its own spec) doesn't call for a visible one. */
.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;
  overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
/* chart-zoom-pan-plan: the time-range control row above a zoomable equity
   chart (render/util.js lineChart(), opts.timeZoom). Reuses button.ghost's
   existing theme-aware styling (var(--bg3)/var(--border)/var(--text), works
   across every theme pack unchanged), just compacted down to fit a row of
   4 alongside a status label without dominating the chart above/below it.
   flex-wrap so narrow/mobile viewports drop the label to its own line
   instead of forcing horizontal scroll on the controls themselves (the
   chart's own overflow-x:auto container is separate and unaffected). */
.chart-zoom-controls{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:6px}
.chart-zoom-controls button{padding:3px 9px;font-size:.78rem;font-weight:600;border-radius:6px}

/* multipage-vue-app-plan.md Phase 1 — the new app's nav shell
   (frontend/src/components/AppShell.vue). Mobile-first: .app-nav is a
   fixed bottom tab bar by default; the 700px breakpoint below (matches
   this file's existing mobile/desktop line, see the max-width:700px
   rules elsewhere in this file) repositions it into a left sidebar
   instead — same markup, same classes, CSS does all the work, nothing
   in the component branches on screen size. Every color/surface here is
   a theme token, so it works unchanged across all 11 packs the same way
   the rest of this app does. */
.app-shell{min-height:100vh;display:flex;flex-direction:column;background:var(--bg)}
.app-header{display:flex;align-items:center;justify-content:space-between;gap:8px 10px;
  flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--border);background:var(--bg2)}
/* GooberMarquee.vue's .tk-marquee-wrap sits between .app-brand-group and
   .app-header-actions in the DOM (2026-08-31) -- as a bare block-level
   flex child with no width bound, it had no room on the same line as its
   two siblings at ordinary desktop widths, so .app-header's own
   flex-wrap:wrap dropped it onto its own second line, nearly quadrupling
   the header's real height (live bug, 2026-09-01: overlapped page
   content below it even after --header-h tracked the new height
   correctly -- the actual fix is not tracking a taller header, it's not
   letting the header get taller in the first place). Desktop gets a real
   3-column layout instead: brand left and actions right both flex:0 0
   auto (their natural width, never shrink), the marquee is the only
   flexible middle column (flex:1, min-width:0 so a flex item can
   actually shrink below its content's natural width -- the #1 flexbox
   gotcha that silently defeats this exact layout without it) bounded to
   whatever space is left between them, with flex-wrap:nowrap on the
   header itself so nothing can escape onto a second line the way the
   marquee just did. Scoped to .app-header .tk-marquee-wrap specifically
   (not a bare .tk-marquee-wrap override) since DiscoveryMarquee.vue
   reuses the same classes in RosterModal.vue/SpawnBotWizard.vue, where
   the original margin:10px 0 (theme.css, below) is exactly right and
   must stay untouched.

   min-width:0 below is pulled OUT of the min-width:700px block (page UX
   pass, 2026-09-04): .app-header is display:flex at every width, so
   .tk-marquee-wrap is always a flex item, and a flex item's default
   min-width:auto means its intrinsic content width (the marquee's full
   un-clipped track, 2000px+) wins over flex-shrink/wrapping regardless
   of viewport -- below 700px the wrap correctly drops the marquee to
   its own row, but without min-width:0 that row still renders at full
   content width, blowing out .app-header and the whole page horizontally
   on phones (confirmed: body scrollWidth 2467px at a 390px viewport).
   The marquee's own overflow:hidden (below, .tk-marquee) was never the
   problem -- it works fine wherever its wrapper actually shrinks (e.g.
   RosterModal's discover row) -- only the header's wrapper was missing
   this at narrow widths. */
.app-header .tk-marquee-wrap{min-width:0}
@media (min-width:700px){
  .app-header{flex-wrap:nowrap}
  .app-brand-group,.app-header-actions{flex:0 0 auto}
  .app-header .tk-marquee-wrap{flex:1 1 auto;margin:0 4px;text-decoration:none}
}
/* font-family fix (2026-09-02, Alan: "the VaporTrade title seems to be
   mono"): .app-brand never had its own font-family, so it silently
   inherited body's --font-mono by cascade instead of the intended
   display face -- unlike a real <h1> (theme.css's own h1 rule), this is
   a router-link (<a>), which body's font shorthand covers by default.
   font-size deliberately left at its own fixed 1.1rem, NOT bound to
   --font-size-display -- that token is calibrated for full-page h1
   headings (now ranging 1.2rem-2.2rem per pack), which would read as
   oversized/disproportionate crammed into this compact nav-bar brand
   slot; only the font-family was actually broken. */
.app-brand{font-weight:800;font-size:1.1rem;font-family:var(--font-display);color:var(--text);text-decoration:none}
/* app-brand-group: wraps the brand link + HeaderScore.vue's compact
   readout so they sit together on the header's left side (2026-08-27,
   "your score" moved here from Home.vue's own tile -- "next to the
   VaporTrade text... score to the right of the text"), while
   .app-header's own justify-content:space-between still pushes
   .app-header-actions to the right exactly as before -- this group is
   just one flex item on that row, not a layout change to the header
   itself. gap widened 2026-08-27 follow-up ("needs more space between
   it and the text") from the score's own earlier flush-against-the-
   brand placement. */
.app-brand-group{display:flex;align-items:center;gap:18px}
/* header-score-holo: holographic 3-layer readout (2026-08-27 follow-up:
   "needs to be a lot more prominent... thinking this to start with the
   neon theme: uiverse.io/Thomas-Cabrit/helpless-penguin-80"). Adapted
   from that reference's 3D-isometric-digit card (see HeaderScore.vue's
   own header comment for the full writeup) -- same back/mid/front
   layered-blur-and-glow structure, scaled down to header size, glow
   color driven by --success/--danger (via .up/.down) instead of a
   hardcoded blue so it re-skins per theme automatically. The real text
   node is the front layer; ::before/::after are the decorative back/
   mid copies (content: attr(data-text)), positioned via translateZ
   inside a perspective wrapper on the element itself. */
.header-score-holo{position:relative;display:inline-block;font-weight:900;
  font-size:1.15rem;letter-spacing:-.01em;line-height:1;
  perspective:300px;transform-style:preserve-3d;
  animation:header-score-wobble 6s ease-in-out infinite;
  background:linear-gradient(90deg,
    color-mix(in srgb, var(--clr) 20%, transparent) 0%,
    color-mix(in srgb, var(--clr) 95%, transparent) 40%,
    color-mix(in srgb, var(--clr) 70%, white 15%) 65%,
    color-mix(in srgb, var(--clr) 25%, transparent) 100%);
  -webkit-background-clip:text;background-clip:text;color:transparent;
  filter:drop-shadow(0 2px 6px color-mix(in srgb, var(--clr) 55%, transparent));}
.header-score-holo.up{--clr:var(--success)}
.header-score-holo.down{--clr:var(--danger)}
.header-score-holo::before,.header-score-holo::after{content:attr(data-text);
  position:absolute;inset:0;background:inherit;-webkit-background-clip:text;
  background-clip:text;color:transparent}
.header-score-holo::before{opacity:.18;filter:blur(1.5px);transform:translateZ(-14px)}
.header-score-holo::after{opacity:.4;filter:blur(.6px);transform:translateZ(-7px)}
@keyframes header-score-wobble{
  0%{transform:rotateX(8deg) rotateY(-10deg)}
  50%{transform:rotateX(9deg) rotateY(10deg)}
  100%{transform:rotateX(8deg) rotateY(-10deg)}
}
@media (max-width:480px){.header-score-holo{font-size:1rem}}
/* header-passes: persistent scout-passes currency indicator, a simpler
   first-cut companion to .header-score-holo above (that holographic
   treatment was a specific follow-up escalation for the equity score --
   this is a plain, readable, theme-aware inline pill, not a duplicate
   design push). --text/--accent-1/--bg2/--bg3 are the same tokens the
   rest of this file already keys every element off of, so this re-skins
   across all theme packs with no per-pack rule needed. */
.header-passes{display:inline-flex;align-items:center;gap:4px;
  font-size:.85rem;font-weight:700;line-height:1;color:var(--text);
  background:var(--bg2);border:1px solid var(--bg3);border-radius:999px;
  padding:3px 9px;white-space:nowrap;}
@media (max-width:480px){.header-passes{font-size:.78rem;padding:2px 7px}}
.app-header-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end}
/* mobile: header wraps to two rows (brand alone, then actions) rather
   than overflowing horizontally — confirmed live at 390px viewport
   (multipage-vue-app-plan.md Phase 1 mobile smoke check) that a fixed
   max-width here clipped the picker off the right edge instead of
   wrapping, so this is a min()-based cap instead of a fixed one: never
   wider than it needs to be, but shrinks to fit whatever's left on the
   header's row before wrapping. */
.app-theme-picker{max-width:min(140px, 40vw)}
/* AlmostThereWidget.vue -- moved out of its own <style scoped> block to
   this central theme file (2026-08-27, matches every other component's
   own convention). `right:0` is the DEFAULT/desktop anchor; on mobile
   the component overrides `left`/`right` via inline style, computed at
   open-time, to stay clamped inside the viewport regardless of where
   the trigger button ends up in .app-header-actions' flex-end packing
   -- see AlmostThereWidget.vue's own header comment for the full
   "off the left side of the screen" writeup. */
.almost-there-widget{position:relative}
.almost-there-toggle{padding:6px 10px;display:flex;align-items:center;gap:4px}
.almost-there-count{font-weight:700}
.almost-there-popover{position:absolute;right:0;top:calc(100% + 6px);z-index:40;
  width:min(280px, 80vw);padding:12px;border-radius:10px;
  background:var(--bg2);border:1px solid var(--border);
  box-shadow:0 8px 24px rgba(0,0,0,.35);
  display:flex;flex-direction:column;gap:8px}
.almost-there-dismiss{padding:2px 6px;line-height:1}
.almost-there-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
.almost-there-row{display:flex;align-items:center;gap:8px}
.almost-there-name{flex:1}
.almost-there-frac{font-variant-numeric:tabular-nums;opacity:.8}
.almost-there-empty{margin:0;opacity:.75}
.app-content{flex:1;padding:14px;padding-bottom:76px}  /* bottom bar clearance on mobile */
.app-nav{position:fixed;left:0;right:0;bottom:0;display:flex;justify-content:space-around;
  background:var(--bg2);border-top:1px solid var(--border);z-index:10}
.app-nav-item{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;
  padding:8px 4px;color:var(--muted);text-decoration:none;font-size:.7rem}
.app-nav-item.router-link-exact-active{color:var(--accent-1)}
.app-nav-icon{font-size:1.25rem}
/* Task 14 (nav restructuring, 2026-08-26): the "👤 Account" nav item
   expands to Settings/Achievements/Fed Zone instead of navigating
   straight there (unlike every other .app-nav-item, which is a plain
   router-link). .app-nav-toggle is a <button>, not an <a>, so it needs
   its own reset (no default button chrome) layered on top of the SAME
   .app-nav-item look every other nav entry already has -- one visual
   language, two element types. .app-nav-subgroup/.app-nav-subitem reuse
   the same color/spacing tokens, just indented one step so the nesting
   reads clearly in both the mobile bottom-bar and desktop-sidebar
   layouts below. */
.app-nav-toggle{background:none;border:0;font:inherit;cursor:pointer;width:100%}
/* Mobile: .app-nav-group is a flex:1 item alongside the other tabs (same
   width budget one .app-nav-item gets), but its expanded submenu can't
   just flow inline below the toggle -- .app-nav is position:fixed to the
   viewport bottom, so pushing content down there would grow the fixed
   bar into a shifting mess. Floats the submenu UP instead (bottom:100%,
   same surface/border tokens as the bar itself) so it reads as a popover
   anchored to the Account tab, same interaction shape a mobile OS's own
   nav-with-submenu pattern uses. */
.app-nav-group{flex:1;position:relative;display:flex;flex-direction:column}
.app-nav-subgroup{display:flex;flex-direction:column;
  position:absolute;left:0;right:0;bottom:100%;background:var(--bg2);
  border-top:1px solid var(--border);box-shadow:0 -2px 10px rgba(0,0,0,.25)}
.app-nav-subitem{padding-left:calc(4px + 1em)}
@media (min-width:700px){
  .app-shell{flex-direction:row}
  .app-header{position:fixed;top:0;left:180px;right:0;z-index:5}
  /* Live feedback (2026-08-25): desktop content stretched full-bleed
     edge-to-edge with no cap -- a lone score card or empty-state card on
     a wide monitor sprawled across 1200+px with huge gaps between its
     own header row and its content, and multi-column grids (signal
     cards) wrapped at whatever width the viewport happened to be rather
     than a stable, comfortable one. Capped to the same 1400px the OLD
     app's #appwrap used for this exact concern (static/index.html),
     centered in the remaining space next to the sidebar via flex
     auto-margins (.app-shell is display:flex;flex-direction:row here, so
     margin:0 auto centers a flex item along the row axis the same way it
     would a block element) -- so anyone on a narrower desktop (700-
     ~1730px) still gets full width, and only wide monitors get the cap.
     padding-left/right restore the same breathing room padding:14px gave
     on all four sides before this only constrained horizontally.

     Live feedback continued: a 1400px cap (the old app's own value)
     still left ~960px of visibly dead space on a real 2560px-wide
     monitor -- confirmed via a live screenshot at that exact width, not
     assumed. Raised to 1800px: still bounds genuinely absurd stretch on
     a 3440px+ ultrawide, but reads as deliberate breathing room rather
     than emptiness at the 2560px width this was actually reported on
     (2560 - 180 sidebar - 1800 cap = 580px margin, ~24% -- comparable to
     how Notion/Linear/GitHub-style dashboards cap content width even on
     wide monitors, not a broken layout). */
  /* margin-top/padding-top reference --header-h (set live by AppShell.vue's
     ResizeObserver on .app-header, live bug fix 2026-09-01) rather than a
     bare pixel literal -- .app-header is position:fixed so it's out of
     flex flow entirely, meaning nothing here can reserve space for it
     automatically; the 56px/60px literals are ONLY the fallback for the
     instant before the observer's first callback (or if ResizeObserver
     isn't available at all), not the real budget. A hardcoded number here
     silently goes stale the moment anything changes what's actually IN
     the header (this is exactly what broke when GooberMarquee.vue's row
     was added) -- var()/calc() keep this self-correcting instead. */
  .app-content{margin:var(--header-h, 56px) auto 0;padding:0 14px 14px;max-width:1800px}
  .app-nav{position:sticky;top:0;left:0;bottom:auto;width:180px;height:100vh;
    flex-direction:column;justify-content:flex-start;border-top:0;border-right:1px solid var(--border);
    padding-top:calc(var(--header-h, 56px) + 4px)}
  /* Live feedback (2026-08-25): "small icons spread over a lot of empty
     space" -- root cause was flex:1 (set above for the mobile bottom bar,
     where 5 equal-width tabs across one row is exactly right) never
     getting reset here. .app-nav switches to flex-direction:column on
     desktop, so that same flex:1 was flex-GROWING each item to fill an
     equal fifth of the sidebar's full 100vh height instead of the items
     stacking at their own natural size -- five icons stretched across an
     entire viewport height with huge gaps, not a deliberate spacing
     choice. flex:0 0 auto stops the grow/shrink, gap on .app-nav (not
     per-item margin, which the mobile layout doesn't use either) gives
     the now-compact stack real spacing instead of touching. */
  .app-nav{gap:2px}
  .app-nav-item{flex:0 0 auto;flex-direction:row;justify-content:flex-start;gap:10px;padding:10px 16px;font-size:.85rem}
  /* Desktop sidebar: the Account submenu flows inline UNDER the toggle
     (the mobile-only floating popover above makes no sense once .app-nav
     is a tall left column, not a fixed bottom bar) -- same static
     .app-nav-item row treatment every other sidebar entry gets, just
     indented via .app-nav-subitem's padding-left (still in effect,
     unchanged at this breakpoint). */
  .app-nav-group{flex:0 0 auto}
  .app-nav-subgroup{position:static;box-shadow:none;border-top:0}
}
table{width:100%;border-collapse:collapse;font-size:.85rem}
th{text-align:left;color:var(--muted);font-weight:600;padding:6px 8px;
  border-bottom:1px solid var(--border)}
td{padding:6px 8px;border-bottom:1px solid var(--bg3)}
/* recruit-row / recruit-row-done / recruit-status -- 2026-08-28 live
   report: "the recruit button on the right side" was getting clipped in
   the roster modal on desktop. The scout table's row is now the click
   target itself (bot-optimize.js's scoutBot(), recruitTickerRow()), not
   a narrow trailing button that overflow could hide -- this just gives
   that clickable row a visible hover state and the recruited row a
   settled, "done" look, same restrained treatment .ban-btn/.recruit-btn
   hover already got via button:hover's filter. recruit-status is a
   single glyph column (✅ or empty), never the row's only affordance. */
.recruit-row:hover td{background:var(--bg3)}
.recruit-row-done{opacity:.7}
.recruit-status{text-align:center;width:1.6em}
.scroller{overflow-x:auto}
/* .scroller is horizontal-only (used by multiple non-virtualized tables in
   this app) — .scroller-v is a separate class for the row-windowing case
   (virtual-list.js), which needs a bounded height + vertical scroll for its
   scrollTop/clientHeight math to have a stable viewport. Deliberately not
   folded into .scroller so its existing horizontal-only callers are
   unaffected. See:
   /Library/Development/CircuitForge/circuitforge-plans/vaportrade-clone/superpowers/plans/2026-08-14-list-virtualization-plan.md */
.scroller-v{overflow-y:auto;max-height:480px}
/* Admin.vue's "xp & pass economy" config knobs + achievement-reward
   editor (2026-08-28) -- one label/input/reset/status row per knob,
   wrapping on narrow viewports rather than a rigid table (mobile-
   friendly per this org's own CLAUDE.md convention). */
.config-grid{display:flex;flex-direction:column;gap:6px}
.config-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.config-row label{flex:1;min-width:180px}
.config-row input[type=number]{width:90px}
/* config-row-saved: 2026-08-28 live report, Alan: "the 'saved' check on
   the admin ui is moving the entire set of buttons when it pops up.
   Needs to be moved to the left side of the picker." Root cause: it
   used to be conditionally rendered (v-if) AFTER the stepper/reset
   button in a flex row whose label has flex:1 -- appearing/
   disappearing changed the row's total content width, which shrank/
   grew the label and shifted every button after it. Now it's ALWAYS in
   the DOM (visibility toggle, not v-if) and sits BEFORE the stepper --
   reserves its own fixed space permanently, so the stepper/reset button
   never move regardless of whether it's showing. */
.config-row-saved{color:var(--success);white-space:nowrap;flex-shrink:0}
/* NumberStepper.vue -- [−][value][+] replacement for the browser's own
   number-input spinner arrows (2026-08-28 live report: "adding an up/
   down button with a persistent numerical display for the field in
   between the two buttons"). The two buttons carry .ghost (opts them
   out of the base button{} glow-up's animated-gradient-border CTA look
   -- too heavy repeated dozens of times in a config table -- same
   restrained-secondary-control posture .ghost already has elsewhere),
   with their own background/border/radius reset here since this fuses
   them into ONE three-segment pill rather than each being its own
   separate .ghost button. The center value is a plain <span>, not an
   <input> -- it's a display, not something to type into directly
   (arrow-key/typing entry is a possible follow-up, not in this pass's
   brief). */
.number-stepper{display:inline-flex;align-items:stretch;border:1px solid var(--border);
  border-radius:8px;overflow:hidden;background:var(--bg3)}
/* .number-stepper .number-stepper-btn (0,2,0), not .number-stepper-btn
   alone (0,1,0) -- has to out-specificity button.ghost's own (0,1,1)
   background/border rule (theme.css above) to actually fuse into one
   pill instead of each button keeping .ghost's individual box. */
.number-stepper .number-stepper-btn{background:transparent;border:0;border-radius:0;color:var(--text);
  font-weight:700;font-size:.95rem;line-height:1;padding:4px 10px;cursor:pointer;
  transition:background 120ms}
.number-stepper .number-stepper-btn:hover:not(:disabled){background:var(--bg2)}
.number-stepper .number-stepper-btn:disabled{opacity:.35;cursor:not-allowed}
/* .number-stepper-value is a real <input> (2026-08-28 follow-up, Alan:
   "can you allow the user to type in the text field as well as use the
   buttons?"), not a plain display <span> -- resets every UA default
   (border/outline/background/appearance) so it reads as the same fused
   center segment it was before typing was added. */
.number-stepper-value{min-width:3.2em;width:3.2em;padding:4px 8px;text-align:center;
  font-family:var(--font-mono);font-size:.85rem;color:var(--text);
  border:0;border-left:1px solid var(--border);border-right:1px solid var(--border);
  border-radius:0;background:transparent;outline-offset:-2px}
.number-stepper-disabled{opacity:.5}
/* virtual-list.js requires a fixed row height (34px, DEEPSCOUT_ROW_HEIGHT in
   bot-optimize.js) for its scrollTop/clientHeight windowing math to stay
   correct — the base (non-virtualized) table just rendered every row
   unconditionally, so text wrapping onto 2+ lines was harmless there. Under
   virtualization, a narrow/mobile viewport wrapping the free-text verdict
   column (or any of the other 6 columns) breaks that fixed-height
   assumption and produces scroll-position/row-mapping drift. Scoped to
   .scroller-v only (not .scroller in general, which other non-virtualized
   tables in this app rely on for horizontal scroll without this
   constraint) — leans into .scroller's existing horizontal-scroll behavior
   (present alongside .scroller-v on this container) instead of fighting
   wrapping, keeping the 34px invariant true at every viewport width. */
.scroller-v td,.scroller-v th{white-space:nowrap}
/* Sticky leading columns for the roster select-tables (2026-08-31, Alan
   live report: "manage his roster ... the user has to close the modal in
   order to select anything"). Root cause wasn't the modal migration --
   scout/deep-scout/audit results DO render inside RosterModal.vue's own
   dialog-content -- it's that these tables are genuinely wider than a
   narrow dialog (checkbox + ticker + return% + SPY% + trades + verdict +
   a trailing recruit control), correctly wrapped in .scroller's own
   overflow-x:auto, but tapping that TRAILING recruit button triggers the
   browser's native focus-scroll, which drags the whole horizontal
   scroller rightward to keep the focused button in view -- permanently
   stranding the checkbox + ticker-symbol columns off-screen to the left
   with no visual cue that scrolling back is even possible on a small
   dialog. Pinning those two columns (position:sticky;left) keeps them
   reachable at any scroll position instead.
   Scoped to the five .scroller variants that actually share this
   checkbox-first/ticker-symbol-second column shape (renderSelectAllControl()
   in <th> 1, a ticker-identifying column in <th> 2) -- NOT bare .scroller,
   which is also used for unrelated shapes (weight tables, trade logs,
   admin panels) where column 1/2 aren't a checkbox/ticker pair and
   pinning them would be wrong. min-width on the checkbox column gives the
   second column's `left` offset something fixed to sit against; harmless
   on desktop where these tables usually fit without ever scrolling (sticky
   is a no-op with nothing to stick against). */
.positions-scroller td:first-child,.positions-scroller th:first-child,
.unheld-scroller td:first-child,.unheld-scroller th:first-child,
.scout-scroller td:first-child,.scout-scroller th:first-child,
.audit-scroller td:first-child,.audit-scroller th:first-child,
.scroller-v td:first-child,.scroller-v th:first-child{
  position:sticky;left:0;z-index:2;min-width:36px;background:var(--bg2)}
.positions-scroller td:nth-child(2),.positions-scroller th:nth-child(2),
.unheld-scroller td:nth-child(2),.unheld-scroller th:nth-child(2),
.scout-scroller td:nth-child(2),.scout-scroller th:nth-child(2),
.audit-scroller td:nth-child(2),.audit-scroller th:nth-child(2),
.scroller-v td:nth-child(2),.scroller-v th:nth-child(2){
  position:sticky;left:36px;z-index:2;background:var(--bg2)}
.botcard{border-left:4px solid var(--accent-1)}
/* opt-panel-a11y-grouping fix — visually distinguishes the 6 .botactions
   buttons that write their result into the shared #opt_<id> panel below
   (edit weights/gym arc/greed gym/talent scout/roster audit/deep scout)
   from the other 3 (force a decision + share, which open their own
   self-contained modal/confirm dialog; toggle bench/cook + delete, which
   act immediately with their own feedback). A dashed border reads as "this
   one's output lands somewhere else" at a glance without new markup or a
   color outside the theme's own token palette — color-mix() derives the
   tint from each theme pack's own --accent-1 (works correctly across all
   12 packs, never a hardcoded color) instead of hand-picking one accent
   color that would clash with off-brand packs.
   `.opens-panel + button:not(.opens-panel)` — the group is always
   contiguous and always followed immediately by the first non-panel
   button (toggle bench/cook) in bots.js's loadBots() template, so this
   single adjacent-sibling rule reaches exactly the boundary between the
   two clusters without needing a wrapper div (out of scope per this fix's
   own constraints) or reordering the buttons (explicitly deferred). */
.botactions .opens-panel{
  border-style:dashed;
  background:color-mix(in srgb, var(--accent-1) 12%, var(--bg3));
}
.botactions .opens-panel + button:not(.opens-panel){
  margin-left:8px; padding-left:14px; border-left:1px solid var(--border);
}
.badge{display:inline-block;font-size:.7rem;padding:2px 8px;border-radius:99px;
  background:var(--bg3);border:1px solid var(--border);color:var(--text-2)}
/* bot-name rename trigger (render/bots.js) — was a bare onclick-<b>,
   converted to a real <button> for keyboard access. Reset button chrome
   and restore the bold look the <b> tag used to give it for free. */
.renameable{cursor:pointer;background:none;border:0;color:inherit;font:inherit;
  font-weight:700;padding:0}
.renameable:hover{text-decoration:underline dotted;text-underline-offset:3px}
.badge.live{background:rgba(52,211,153,.12);color:var(--success);border-color:var(--success)}
/* a11y BLOCK #5: 4 bot-creation threshold labels (index.html) — always-
   visible compact help line under each label/input, replacing title=. */
.threshold-item{display:flex;flex-direction:column;gap:2px;min-width:170px}
.threshold-help{margin:0;max-width:220px}
.wrow{display:flex;align-items:center;gap:8px;margin:4px 0;flex-wrap:wrap}
.wrow label{flex:0 0 190px;font-size:.83rem}
.wrow output{width:44px;text-align:right;font-size:.83rem;color:var(--text-2)}
.wrow input{flex:1}
/* invert-toggle (render/weight-row.js) — chevrons-to-X morph toggle,
   adapted from uiverse.io/vinodjangid07/soft-octopus-29's checkbox+label
   3-bar hamburger-to-X switch. Two structural departures from the source,
   both required here and not there:
   1. The source used a single global #checkbox/#bar1-3 id set, fine for a
      lone 40x40 demo switch. This control renders once per signal per
      form (bot-form.js AND bot-optimize.js, each keyed by sig.key), so
      every part is class-scoped and reached only via the `~` general
      sibling combinator off THIS row's own <input>, never a page-global id.
   2. The source hid its checkbox with display:none, which drops it from
      the tab order — display:none elements are never focusable. Swapped
      for the standard clip-rect visually-hidden pattern below, which
      keeps the input in normal tab flow (Tab reaches it, Space/Enter
      toggles it — native checkbox behavior, untouched) while still being
      visually replaced by .invert-toggle-vis; :focus-visible drives an
      explicit outline on the visible glyph since the input itself is
      invisible.
   Geometry: unchecked state is two stacked chevrons ("^" over "^",
   suggested by the user as a collapse/double-up icon) instead of the
   source's 3 flat bars; each chevron is 2 half-bars (ivt-tl/tr for the
   top chevron, ivt-bl/br for the bottom one) meeting at a peak. On
   :checked they re-rotate and re-position to the source's X: tl+br pair
   up into one full diagonal, tr+bl into the other, sliding to meet at
   the control's center — same checkbox-driven, transition-only technique
   as the source (no keyframes), just 4 half-segments instead of 3 full
   ones. Scaled to 18x18 (source was a standalone 40x40) to sit inline in
   this compact row next to the range slider/output. */
.invert-toggle{position:relative;display:inline-flex;align-items:center;justify-content:center;
  width:26px;height:26px;flex:0 0 auto;margin-left:4px;cursor:pointer}
/* .wrow label{flex:0 0 190px} (specificity 0,1,1) beats .invert-toggle's
   flex:0 0 auto (0,1,0) since .invert-toggle is itself a <label> matched by
   both selectors, and flex-basis wins over width regardless of source
   order — without this override the toggle renders at 190x26px instead of
   the intended ~26x26px. Explicit higher-specificity override, same
   technique as the prefers-reduced-motion transition-duration:0 fix below. */
.wrow .invert-toggle{flex:0 0 26px}
.invert-toggle-input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;
  overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.invert-toggle-vis{position:relative;width:18px;height:18px;transition:transform .5s;border-radius:4px}
.ivt-bar{position:absolute;top:0;left:0;width:9px;height:2px;border-radius:2px;
  background:var(--text-2);transform-origin:center center;
  transition:top .5s,left .5s,transform .5s,background .5s}
/* unchecked: two stacked "^" chevrons, each pair of half-bars meeting at
   a peak (top chevron peak y=4, bottom chevron peak y=13; center x=9) */
.ivt-tl{top:3px;left:1.5px;transform:rotate(-45deg)}
.ivt-tr{top:3px;left:8px;transform:rotate(45deg)}
.ivt-bl{top:12px;left:1.5px;transform:rotate(-45deg)}
.ivt-br{top:12px;left:8px;transform:rotate(45deg)}
/* checked: tl+br become one full "\" diagonal through the center, tr+bl
   become the other "/" diagonal — X, then the whole glyph spins 180deg,
   matching the source's #checkbox:checked + .toggle rotate */
.invert-toggle-input:checked ~ .invert-toggle-vis{transform:rotate(180deg)}
.invert-toggle-input:checked ~ .invert-toggle-vis .ivt-tl{top:4.5px;left:1.5px;transform:rotate(45deg)}
.invert-toggle-input:checked ~ .invert-toggle-vis .ivt-br{top:12.5px;left:8px;transform:rotate(45deg)}
.invert-toggle-input:checked ~ .invert-toggle-vis .ivt-tr{top:4.5px;left:8px;transform:rotate(-45deg)}
.invert-toggle-input:checked ~ .invert-toggle-vis .ivt-bl{top:12.5px;left:1.5px;transform:rotate(-45deg)}
.invert-toggle-input:checked ~ .invert-toggle-vis .ivt-bar{background:var(--accent-1)}
.invert-toggle-input:focus-visible ~ .invert-toggle-vis{outline:2px solid var(--accent-1);outline-offset:2px}
/* a11y BLOCK #5: signal-weight / grass-is-greener rows (render/bot-form.js,
   render/bot-optimize.js) — title= replaced with a focus-triggered visible
   help line (Option B: many rows, so an always-visible line per row would
   be too dense; this scales while staying keyboard+touch reachable, since
   both keyboard tab and a touch tap on the range input move focus onto it,
   which is what :focus-within keys off — dismissible by moving focus away,
   satisfying WCAG 1.4.13). flex-basis:100%+order pushes it onto its own
   line only once shown, so it costs nothing visually until then. */
.wrow .help-pop{display:none;flex-basis:100%;order:1;margin:2px 0 0;
  font-size:.78rem;color:var(--text-2)}
.wrow:focus-within .help-pop{display:block}
/* a11y BLOCK #5: ticker-picker chips (render/bot-form.js) — same Option B
   reasoning as above (potentially ~100 chips, an always-visible line per
   chip would be unusable clutter). Popover is absolutely positioned off
   the wrapper so it doesn't reflow the chip grid when revealed. */
.chip-wrap{position:relative;display:inline-block}
.chip-wrap .help-pop{display:none;position:absolute;left:0;top:100%;
  margin-top:4px;z-index:20;width:max-content;max-width:240px;
  background:var(--bg3);border:1px solid var(--border);border-radius:8px;
  padding:6px 10px;font-size:.78rem;color:var(--text-2);white-space:normal;
  pointer-events:none}
.chip-wrap:hover .help-pop,.chip-wrap:focus-within .help-pop{display:block}
/* .chip-group-list wraps every .chip-wrap in one ticker group (see
   render/bot-form.js). Needed as a fallback positioning root for the mobile
   override below — position:relative here is a no-op on desktop since
   .chip-wrap stays the nearest positioned ancestor there. */
.chip-group-list{position:relative}
/* Locked signal-row tooltip (SignalPicker.vue) -- a11y fix, final review
   2026-08-26: locked rows had no focusable element (checkbox is v-if'd
   away) and relied on a native title= attribute, which is unreachable via
   keyboard focus and unreliable on touch. Row itself gets tabindex="0" in
   the component; this is the same hover+focus-within popover pattern as
   .chip-wrap's .help-pop above, adapted for a full-width row instead of
   an inline chip -- :focus is added alongside :focus-within since the row
   itself (not a descendant) is what receives focus here. */
.sig-locked{position:relative;cursor:help}
.sig-locked .vt-tooltip{display:none;position:absolute;left:0;top:100%;
  margin-top:2px;z-index:20;width:max-content;max-width:280px;
  background:var(--bg3);border:1px solid var(--border);border-radius:8px;
  padding:8px 10px;font-size:.8rem;color:var(--text-2);white-space:normal;
  pointer-events:none}
.sig-locked .vt-tooltip-title{color:var(--text);font-weight:700;margin-bottom:2px}
.sig-locked:hover .vt-tooltip,
.sig-locked:focus-within .vt-tooltip,
.sig-locked:focus .vt-tooltip{display:block}
/* TickerPicker.vue's search-results dropdown (2026-09-01, Alan: "the list
   is popping under the picker instead of just under the button") -- same
   position:relative-wrapper + position:absolute-panel popover shape as
   .chip-wrap/.help-pop and .sig-locked/.vt-tooltip above, just open-state
   driven by the component's own `open` ref (v-show) instead of :hover/
   :focus-within, since this needs to stay open while the user scrolls
   the results or clicks between rows, not just while directly hovering
   one. z-index:20 matches the other two popovers -- never open at the
   same time as either (different components, different trigger areas),
   so there's no real stacking conflict to worry about, just consistency. */
.ticker-picker-wrap{position:relative}
.ticker-picker-results{position:absolute;top:100%;left:0;right:0;z-index:20;
  margin-top:4px;max-height:280px;overflow-y:auto;background:var(--bg3);
  border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.35)}
/* row hover/focus feedback (2026-09-01, same live report: "roster
   management modal is missing the css visual feedback") -- bare .row
   (theme.css) is a zero-feedback flex layout utility; a clickable ticker
   row previously gave no visual sign it was interactive beyond the
   cursor. tabindex="0" + this rule makes it keyboard-reachable too, not
   just mouse-hoverable. */
.ticker-picker-row{padding:6px 10px;cursor:pointer;border-radius:6px}
.ticker-picker-row:hover,.ticker-picker-row:focus-visible{background:var(--bg);outline:none}
svg text{fill:var(--text-2);font-size:11px}
.tooltip{position:fixed;background:var(--bg3);border:1px solid var(--border);
  border-radius:8px;padding:6px 10px;font-size:.8rem;pointer-events:none;z-index:10;
  display:none}
#login{max-width:380px;margin:8vh auto}
.presetbtn{display:block;width:100%;text-align:left;margin:6px 0;padding:10px 12px}
.presetbtn b{display:block}
/* ticker-picker chips (render/bot-form.js) — were bare onclick-<span>s,
   converted to real <button>s for keyboard access. .chip already sets
   background/border/padding/font-size explicitly so it visually replaces
   the UA button chrome; this just clears the couple of button-only
   defaults (font-family/line-height/text-align) that would otherwise
   leak through. */
.chip{display:inline-block;margin:3px;padding:4px 10px;border-radius:99px;
  background:var(--bg3);border:1px solid var(--border);color:var(--text-2);
  font-size:.78rem;cursor:pointer;user-select:none}
button.chip{font-family:inherit;line-height:normal;text-align:inherit}
.chip.on{background:rgba(139,92,246,.25);border-color:var(--accent-1);color:var(--text)}
/* saved-weight-config load trigger (render/bot-form.js) — was a bare
   onclick-span, converted to a real <button> for keyboard access. Reset
   to look like the plain name text it replaces (sits inside a .chip). */
.chip-load{background:none;border:0;color:inherit;font:inherit;padding:0;cursor:pointer}
/* saved-weight-config delete trigger (render/bot-form.js) — was a bare
   onclick-span, converted to a real <button> so it's keyboard-operable
   (needed once it's wired through armConfirm(), which relies on the
   button's own blur/focus for disarm-on-focus-loss). Reset to look like
   the plain "✕" span it replaces. */
.chip-del{background:none;border:0;color:inherit;font:inherit;opacity:.55;
  cursor:pointer;padding:0;line-height:1}
.chip-del:hover,.chip-del:focus-visible{opacity:1}
.chipgroup summary{cursor:pointer;font-size:.85rem;color:var(--text-2);margin:6px 0 2px}
.presetbtn span{font-weight:400;font-size:.8rem;color:var(--text-2)}
.verdict{font-size:1.1rem;font-weight:700;margin:8px 0}
footer{color:var(--muted);font-size:.75rem;text-align:center;margin:24px 0}
/* never let anything push the page wider than the screen */
html,body{max-width:100%;overflow-x:hidden}
/* bot-card expandable sections — purple arrow replaces invisible browser triangle */
.botcard details>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:6px}
.botcard details>summary::-webkit-details-marker{display:none}
.botcard details>summary::before{content:'▶';color:var(--accent-1);font-size:.7rem;
  display:inline-block;transition:transform .15s;flex:none}
.botcard details[open]>summary::before{transform:rotate(90deg)}
.botcard details>summary:hover::before{color:var(--text)}
/* bot vibe strip */
.bot-vibe{font-size:.81rem;color:var(--text-2);margin:6px 0;padding:5px 10px;
  background:var(--bg3);border-radius:8px;border-left:3px solid var(--accent-1)}
/* accessible dialog module (static/js/dialog.js) — used by the llm event
   modal today, and by the kill-native-popups plan's toast/armed-button/
   prompt() replacements. Was #llm-modal-scoped IDs; generalized to classes
   so any dialog opened via openDialog()/openFormDialog() gets this look. */
.dialog-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:200;
  display:flex;align-items:center;justify-content:center;padding:16px}
.dialog-box{background:var(--bg2);border:1px solid var(--border);border-radius:16px;
  padding:20px 22px;max-width:560px;width:100%;max-height:80vh;overflow-y:auto;position:relative}
.dialog-box:focus{outline:none}
.dialog-close{position:absolute;top:10px;right:12px;background:none;border:0;
  color:var(--text-2);font-size:1rem;cursor:pointer;padding:4px 8px;line-height:1}
.dialog-close:hover{color:var(--text)}
.dialog-title{margin:0 12px 10px 0}
/* "the verdict 🎲" dialog content (static/js/render/verdictDialog.js,
   wired in bots.js's runBot()) -- 2026-09-04 UX fix: a large-universe
   bot's verdict used to be a flat wall of near-identical HOLD lines with
   zero visual priority for the outcome that actually mattered. Now: a
   one-line summary, trade/notable lines always visible (color-coded by
   bucket), routine holds collapsed behind .verdict-routine. Full design
   writeup + what's queued for the bigger visual-overhaul pass:
   circuitforge-plans/vaportrade-clone/notes/
   2026-09-04-verdict-dialog-visual-overhaul-notes.md */
.verdict-summary{font-weight:600;margin:0 0 10px;color:var(--text)}
.verdict-list{list-style:none;margin:0 0 8px;padding:0;display:flex;flex-direction:column;gap:6px}
.verdict-list li{padding:6px 10px;border-radius:8px;border-left:3px solid var(--border);
  background:var(--bg3);font-size:.9rem;line-height:1.4}
.verdict-trade.verdict-buy{border-left-color:var(--success);background:color-mix(in srgb,var(--success) 12%,var(--bg3))}
.verdict-trade.verdict-sell{border-left-color:var(--danger);background:color-mix(in srgb,var(--danger) 12%,var(--bg3))}
/* .verdict-list .verdict-notable (not a bare .verdict-notable) -- found live
   2026-09-06 (Alan: "the force-a-decision visual overhaul regressed"): a bare
   single-class selector has LOWER specificity than .verdict-list li just
   above (that rule's own type selector outweighs this one's class-only
   selector once class-counts tie), so border-left-color:var(--warning) never
   actually won -- every "notable" line silently fell back to the base
   var(--border) purple instead of amber. Confirmed via getComputedStyle, not
   a screenshot guess (borderLeftColor measured as #746a9e, --border, on a
   real live ⏸️-skip notable line). .verdict-trade.verdict-buy/-sell just
   above were never affected -- 2 classes already outrank .verdict-list li's
   (class + type). This compound selector matches that same working pattern
   instead of introducing a third one. */
.verdict-list .verdict-notable{border-left-color:var(--warning)}
.verdict-routine{margin-top:4px}
.verdict-routine>summary{cursor:pointer;color:var(--text-2);font-size:.85rem;user-select:none}
.verdict-routine>summary:hover{color:var(--text)}
.verdict-list-routine{margin-top:8px;max-height:240px;overflow-y:auto}
.verdict-list-routine .verdict-routine-item{border-left-color:var(--border);opacity:.75;font-size:.82rem}
/* Trade-fired flourish -- applied via openDialog()'s `className` (lands on
   .dialog-overlay, see dialog.js), scoping this to ONLY the verdict
   dialog's own instance, never the other 8 openDialog() call sites in
   this app. Two Uiverse.io snippets adapted, not copied verbatim (full
   originals + fit notes vendored in frontend/harness/gallery/cards/
   cyber-tilt-card and gallery/buttons/{burst-hover-button,bubble-pop-
   button} for the future overhaul pass):
     - corner brackets, from the "cyber tilt card" -- kept static (no
       hover-tilt/particles; a modal you're reading shouldn't move under
       your cursor), retinted from the original's fixed blue to the trade
       tone.
     - the burst itself, from "bubble pop button" -- swapped its :hover
       trigger for auto-play-on-mount (a CSS `animation:` on a selector
       plays the instant a freshly-inserted element matches it, no JS
       needed), retinted from orange to --success/--danger, ONE-SHOT
       (ease, not infinite) since this dialog closes in seconds, not a
       persistent tile. Restrained per this file's own established
       "rare event, mostly quiet" cadence family (cash-pop, mercury-glow)
       -- WCAG 2.3.1: single 700ms play, nowhere near the 3Hz flash bar. */
.verdict-fx .dialog-box{position:relative;overflow:visible}
.verdict-fx .dialog-box::before,.verdict-fx .dialog-box::after{
  content:"";position:absolute;width:16px;height:16px;pointer-events:none;transition:none}
.verdict-fx .dialog-box::before{top:-8px;left:-8px;border-right:0;border-bottom:0}
.verdict-fx .dialog-box::after{bottom:-8px;right:-8px;border-left:0;border-top:0}
.verdict-fx-buy .dialog-box::before,.verdict-fx-buy .dialog-box::after{border:2px solid var(--success)}
.verdict-fx-sell .dialog-box::before,.verdict-fx-sell .dialog-box::after{border:2px solid var(--danger)}
.verdict-fx .verdict-summary{position:relative}
.verdict-fx .verdict-summary::before{
  content:"";position:absolute;inset:-6px -10px;z-index:-1;background-repeat:no-repeat;
  background-size:10% 10%,20% 20%,15% 15%,20% 20%,18% 18%,10% 10%,15% 15%;
  background-position:50% 120%;animation:verdict-burst-top 700ms ease-out forwards}
.verdict-fx-buy .verdict-summary::before{background-image:
  radial-gradient(circle,var(--success) 20%,transparent 20%),
  radial-gradient(circle,transparent 20%,var(--success) 20%,transparent 30%),
  radial-gradient(circle,var(--success) 20%,transparent 20%),
  radial-gradient(circle,var(--success) 20%,transparent 20%),
  radial-gradient(circle,transparent 10%,var(--success) 15%,transparent 20%),
  radial-gradient(circle,var(--success) 20%,transparent 20%),
  radial-gradient(circle,var(--success) 20%,transparent 20%)}
.verdict-fx-sell .verdict-summary::before{background-image:
  radial-gradient(circle,var(--danger) 20%,transparent 20%),
  radial-gradient(circle,transparent 20%,var(--danger) 20%,transparent 30%),
  radial-gradient(circle,var(--danger) 20%,transparent 20%),
  radial-gradient(circle,var(--danger) 20%,transparent 20%),
  radial-gradient(circle,transparent 10%,var(--danger) 15%,transparent 20%),
  radial-gradient(circle,var(--danger) 20%,transparent 20%),
  radial-gradient(circle,var(--danger) 20%,transparent 20%)}
@keyframes verdict-burst-top{
  0%{background-position:10% 90%,15% 90%,25% 90%,25% 90%,40% 90%,55% 90%,70% 90%}
  50%{background-position:0% 20%,10% 40%,20% 0%,30% 30%,22% 50%,50% 50%,65% 20%}
  100%{background-position:0% 0%,10% 20%,20% -20%,30% 10%,22% 30%,50% 30%,65% 0%;
    background-size:0% 0%,0% 0%,0% 0%,0% 0%,0% 0%,0% 0%,0% 0%}
}
.wizard-step-dot{width:28px;height:28px;border-radius:50%;display:flex;
  align-items:center;justify-content:center;font-weight:700;font-size:.85rem;
  background:var(--bg3);border:1px solid var(--border);color:var(--text-2)}
.wizard-step-line{flex:1;height:2px;background:var(--border);margin-bottom:16px}
/* code-review fix (2026-08-31): the brief's own original code sample paired
   a STATIC data-testid="wizard-step" with a DYNAMIC :data-testid="`wizard-
   step-${n}`" on the same element -- Vue 3's compiler deduplicates same-name
   attribute bindings, keeping only the dynamic one, so no element ever
   actually carried the literal "wizard-step" value these selectors were
   written against. Fixed by giving the element a real, always-present
   static `class="wizard-step"` instead (class bindings, unlike arbitrary
   attributes, DO merge with a co-existing :class binding in Vue 3) and
   selecting on that. */
.wizard-step.active .wizard-step-dot{background:var(--accent-1);
  border-color:var(--accent-1);color:var(--bg)}
.wizard-step.done .wizard-step-dot{opacity:.7}
.wizard-step-line.done{background:var(--accent-1);opacity:.6}
.preset-active{border-color:var(--accent-1) !important}
.dialog-form input{width:100%;padding:8px 10px;margin-top:6px;border-radius:8px;
  border:1px solid var(--border);background:var(--bg3);color:var(--text)}
/* .highlighted — PeakTuningModal.vue (per-ticker sell-the-peak plan, Task
   6): marks the row for whichever ticker the caller opened the modal FOR
   (BotDetail.vue's roster-peek 🏔️ column, via `initialSymbol`), so the
   user immediately sees which row they clicked into among a full ticker
   table. Theme-aware (var() tokens only, no hardcoded hex) so it reads
   correctly under every installed theme pack, not just the default. */
tr.highlighted{background:var(--bg3);box-shadow:inset 3px 0 0 var(--accent-1)}
/* toast notifications (static/js/toast.js) — bottom-right stack, clear of
   .tooltip#tip (z-index:10, transient/cursor-anchored) and the sticky
   #lb_side leaderboard column (only present ≥1000px, right-column content
   not the viewport edge). z-index:300 sits above dialog-overlay (200) so a
   toast fired while a dialog is open (e.g. a save confirmation) stays
   visible on top of it. */
#toast-stack{position:fixed;right:16px;bottom:16px;z-index:300;
  display:flex;flex-direction:column;gap:8px;width:min(340px,calc(100vw - 32px));
  pointer-events:none}
.toast-region{display:flex;flex-direction:column;gap:8px}
.toast{pointer-events:auto;display:flex;align-items:flex-start;gap:8px;
  background:var(--bg3);border:1px solid var(--border);border-left:3px solid var(--text-2);
  border-radius:10px;padding:10px 12px;box-shadow:0 4px 16px rgba(0,0,0,.35);
  animation:toast-in .15s ease-out}
@keyframes toast-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
.toast-success{border-left-color:var(--success)}
.toast-error{border-left-color:var(--danger)}
.toast-msg{flex:1;font-size:.85rem;line-height:1.4;white-space:pre-line}
.toast-action{background:none;border:1px solid var(--border);color:var(--accent-1);
  font-size:.8rem;font-weight:600;cursor:pointer;padding:4px 10px;border-radius:6px;
  flex:none;white-space:nowrap}
.toast-action:hover{background:var(--bg2)}
.toast-close{background:none;border:0;color:var(--text-2);font-size:.85rem;
  cursor:pointer;padding:2px 4px;line-height:1;flex:none}
.toast-close:hover{color:var(--text)}
@media (max-width:700px){
  #toast-stack{right:8px;bottom:8px;left:8px;width:auto}
}
/* inline "armed button" confirm pattern (static/js/confirm-button.js) —
   replaces window.confirm() for destructive actions. Ring pulse adapted
   verbatim from cf-orch's .flush-arm.armed / @keyframes flush-arm-pulse
   (circuitforge_orch/coordinator/dashboard.html) per the redesign spec's
   §3 reuse decision — same 1.5s ease-in-out timing and 0/0.2-alpha ring,
   swapped from cf-orch's --red to this app's --down token (#fb7185 ==
   rgb(251,113,133), same rgba() shape as cf-orch's own rgba(248,81,73,…)). */
/* button.confirm-armed (not bare .confirm-armed): every armed button in
   this app is also button.danger or button.ghost — those pre-existing
   type+class rules are specificity (0,1,1), which beats a bare (0,1,0)
   class selector and silently defeats background/border/color here. The
   compound selector raises this rule to (0,2,0) so it always wins. The
   background is an opaque --danger-dark (not a translucent rgba(--danger,.12)
   tint) so the armed state renders identically — and passes WCAG 1.4.3
   (>=4.5:1) — on both button.danger's dark red base and button.ghost's
   --bg3 base; a translucent tint measured only ~3.2:1 against var(--danger)
   text on the danger variant.
   TOKEN CONTRACT: every pack's --danger-dark MUST yield >=4.5:1 contrast
   against that pack's own --danger, per WCAG 1.4.3 — this is a checkable
   requirement for any future pack's author to satisfy (compute it with the
   standard relative-luminance contrast formula), not just a value that
   happened to work for Neon Casino's current --danger/--danger-dark pair. */
button.confirm-armed{
  color:var(--danger);border-color:var(--danger);
  background:var(--danger-dark);
  animation:confirm-arm-pulse 1.5s ease-in-out infinite;
}
@keyframes confirm-arm-pulse{
  0%,100%{box-shadow:0 0 0 0 rgba(251,113,133,0)}
  50%{box-shadow:0 0 0 3px rgba(251,113,133,.2)}
}
/* in-button progress fill (vibe-check-cards plan, Task 3) — skewed slab
   adapted from uiverse.io's mrhyddenn/tricky-baboon-12 (`.button_sl` inside
   an overflow:hidden `.button_lg`, `transform:skew(-15deg)`), with the
   original's `:hover` trigger removed entirely: the fill's width is driven
   by state, not pointer position. Lives here (not in a theme pack file)
   because it must render identically across every current and future theme
   pack — only var(--accent-1)/var(--accent-3) tokens are pack-specific,
   same convention as every other cross-cutting component rule in this file.

   `isolation:isolate` gives each of these 5 buttons its own stacking
   context (without needing a `z-index` on the button itself, which would
   otherwise fight with unrelated page stacking) so the ::before fill's
   `z-index:-1` paints behind the button's own in-flow text/emoji content
   but above the button's own background — see CSS2.1 §9.9's stacking-context
   painting order (negative-z-index children paint before in-flow content of
   the same context). No extra markup needed for this: real <button> text
   content is already "in-flow, non-positioned," so it wins over the
   positioned, negative-z-index ::before for free.

   Real-percentage buttons (gym arc/greed gym/deep scout) — pollGymRun() and
   pollDeepScout() (bot-optimize.js) write the SAME pctDone (progressPct(),
   util.js) they already show in the #opt_<id> panel onto --fill via
   style.setProperty() on each 2-3s poll tick, and clear it (removeProperty)
   the instant the run stops (done or error) — the button is never left
   mid-fill after a run ends. --fill defaults to 0% so an idle button's
   ::before has zero width and is invisible without needing a separate
   "active" class.

   As of the button-progress-immediate-signal live fix (2026-08-20), --fill
   is INVERTED relative to pctDone (util.js's inverseFillPct(): 100-pctDone)
   — full accent at 0% done, receding to empty at 100% done — so
   optimizeBot()/optimizeGreed()/deepScoutBot() can flash the button to its
   fully-filled state SYNCHRONOUSLY at click time (flashButtonStart(),
   bot-optimize.js), before the POST that starts the run has even gone out,
   as an immediate "yes, this started" signal instead of waiting on the
   first poll tick. See the ::before rule below for the right-edge
   re-anchor that keeps the visual sweep DIRECTION unchanged despite the
   inverted value.

   Indeterminate buttons (talent scout/roster audit) — scoutBot()/auditBot()
   are single blocking calls with no total/progress the backend reports at
   all, so per Global Constraint #4 there is nothing honest to bind a width
   to. Their fill is a looping CSS @keyframes sweep instead, gated purely by
   the `.in-progress` class added/removed around the single await in each
   function — never a JS-set width, never a fabricated percentage or a
   duration-based creep toward 100%.

   .lockingym-btn (regression fix, 2026-09-01, Alan: "we lost a lot of
   the custom animated button css") -- lock in gym's own button/JS
   wiring (bot-optimize.js's flashButtonStart(GYM_BTN_SELECTOR.lock_in)
   already sets --fill on it correctly), but this selector list was
   never extended when that button shipped, so the ::before sweep rule
   below had nothing to attach to -- the CSS variable was being set on
   an element with no rule reading it. Root-caused and fixed same
   session it was introduced. */
.gym-btn, .greedgym-btn, .lockingym-btn, .deepscout-btn, .scout-btn, .audit-btn{
  position:relative; isolation:isolate; overflow:hidden; --fill:0%;
}
/* Anchored from the RIGHT edge (right:-8px), not the left, as of the
   button-progress-immediate-signal live fix (2026-08-20). --fill's meaning
   inverted (util.js's inverseFillPct() — full accent at 0% done, receding
   to empty at 100% done) but the sweep DIRECTION must stay identical to the
   pre-fix behavior per user feedback: originally anchored at left:-8px with
   width growing 0->100% over time, so the accent/normal boundary (the
   slab's right edge) swept RIGHTWARD as the run progressed. Simply
   inverting the --fill value while leaving the left anchor in place would
   have reversed that sweep (a shrinking width anchored on the left retreats
   its right edge LEFTWARD instead). Anchoring on the right instead keeps
   the right edge fixed and lets width shrink toward it, which moves the
   boundary (now the slab's LEFT edge) RIGHTWARD as --fill shrinks over
   time — same boundary motion as before, just full-to-empty instead of
   empty-to-full. */
.gym-btn::before, .greedgym-btn::before, .lockingym-btn::before, .deepscout-btn::before{
  content:''; display:block; position:absolute; z-index:-1;
  top:0; bottom:-1px; right:-8px;
  width:var(--fill,0%); max-width:calc(100% + 15px);
  background:linear-gradient(90deg, var(--accent-1), var(--accent-3));
  opacity:.4; transform:skew(-15deg); transition:width .2s ease;
  pointer-events:none;
}
.scout-btn::before, .audit-btn::before, .deepscoutnew-btn::before{
  content:''; display:none; position:absolute; z-index:-1;
  top:0; bottom:-1px; left:-8%; width:20%;
  background:linear-gradient(90deg, var(--accent-1), var(--accent-3));
  opacity:.4; transform:skew(-15deg); pointer-events:none;
}
.scout-btn.in-progress::before, .audit-btn.in-progress::before, .deepscoutnew-btn.in-progress::before{
  display:block; animation:progress-sweep 1.2s ease-in-out infinite;
}
/* deepscoutnew-btn also needs the base position/overflow setup the other
   4 indeterminate/real-percentage buttons get from the shared selector list
   above (~line 1639) — it isn't a member of that list itself since it never
   gets a real --fill (it's honest-indeterminate only, per its own comment
   in bot-optimize.js), but its ::before rule above needs the same
   positioning context to render correctly. */
.deepscoutnew-btn{ position:relative; isolation:isolate; overflow:hidden; }
/* left/width move together so the slab sweeps left-to-right and shrinks
   back rather than just widening in place — reads as "still searching,"
   not "70% of something specific," which is the whole point of keeping
   this honestly indeterminate. */
@keyframes progress-sweep{
  0%{ left:-20%; width:20%; }
  50%{ left:40%; width:45%; }
  100%{ left:100%; width:20%; }
}
/* stalk-a-ticker's async-signal pending indicator (Kronos/LLM news tiles +
   poll-status line, render/backtest.js's hotPick()/renderStalk()) — a
   plain opacity pulse on the ⏳ glyph so "still waiting on the worker" reads
   as alive, not stuck. Ported alongside hotPick()/hotTake() from Daniel's
   original branch, where this predates the JS module decomposition. */
@keyframes stalk-pulse{0%,100%{opacity:1}50%{opacity:.3}}
.stalk-pending{animation:stalk-pulse 1.4s ease-in-out infinite;display:inline-block}
/* BotCard.vue (Task 13, unlockable-signals plan: "richer Bots page
   cards") — the equity sparkline's up/down stroke tokens, the top-
   holdings/last-run/slots grid, and the row of rounded quick-action
   pills under each card. Pill shape/hover is the same ghost-button
   language the rest of the app already uses (button.ghost), just
   pill-radius'd and sized down to fit 5-across in a card footer, so
   this reuses --accent-1/--bg3/--border/--text-2 rather than
   introducing new tokens. .ghost-pill (roster peek) is the one pill
   that navigates instead of acting -- visually distinguished with a
   dashed border, same "this leaves the page" affordance router-link
   nav items elsewhere in the app don't otherwise carry, since a pill
   next to 4 action buttons would otherwise look like a 5th action. */
.bot-card{display:flex;flex-direction:column;gap:10px}
.bot-card-name{font-weight:700;font-size:1.05rem;color:var(--text);text-decoration:none}
.bot-card-name:visited{color:var(--text)}
.bot-card-name:hover{color:var(--accent-1);text-decoration:underline dotted;text-underline-offset:3px}
.bot-card svg{flex-shrink:0}
.bot-card svg .up-stroke{stroke:var(--success)}
.bot-card svg .down-stroke{stroke:var(--danger)}
.bot-card svg .sparkline-empty{fill:var(--muted)}
.bot-card-body{display:flex;gap:22px;flex-wrap:wrap}
.bot-card-col{flex:1;min-width:150px}
.bot-card-col .badge{margin:2px 4px 2px 0}
.bot-card-pills{display:flex;gap:8px;flex-wrap:wrap}
.pill{border-radius:99px;padding:4px 12px;font-size:.78rem;
  background:var(--bg3);border:1px solid var(--border);color:var(--text-2);
  cursor:pointer;transition:background .15s ease,border-color .15s ease}
.pill:hover{background:color-mix(in srgb, var(--accent-1) 12%, var(--bg3));border-color:var(--accent-1)}
.pill.ghost-pill{border-style:dashed}
/* slots-chip pulse — same rgba-ring @keyframes SHAPE as button.confirm-
   armed above (this file's own established "an outward-fading ring
   means pay attention" language), retuned to --accent-1 (not --danger,
   this isn't a destructive-action warning) and slowed to 2s (a nudge,
   not an alarm) — fires only at exactly 1 signal slot left before the
   user's cap, via BotCard.vue's slotsFromCap computed. */
.slots-chip.slots-pulse{animation:slots-chip-pulse 2s ease-in-out infinite;
  border-color:var(--accent-1)}
@keyframes slots-chip-pulse{
  0%,100%{box-shadow:0 0 0 0 color-mix(in srgb, var(--accent-1) 35%, transparent)}
  50%{box-shadow:0 0 0 4px color-mix(in srgb, var(--accent-1) 0%, transparent)}
}
@media (prefers-reduced-motion: reduce){
  .stalk-pending{animation:none;opacity:1}
  .confirm-armed{animation:none;box-shadow:0 0 0 3px rgba(251,113,133,.2)}
  .slots-chip.slots-pulse{animation:none;box-shadow:0 0 0 3px color-mix(in srgb, var(--accent-1) 25%, transparent)}
  /* DiceSpinner freezes on face-1 (straight-on, transform:none) rather
     than tumbling -- the caller's own "rolling..." text + disabled button
     state already carries the loading meaning without motion, same
     "freeze not hide" posture the confirm-armed/slots-pulse rules above
     take (unlike shooting-star/price-move-fx below, which have no
     meaningful static frame and hide entirely instead). */
  .dice-spinner{animation:none;transform:none}
  .dice-spinner-face{transition:none}
  .dice-spinner-face:not(.face-1){display:none}
  .dice-spinner-face.face-1{transform:none}
  /* header-score-holo: freeze the wobble, keep the glow -- same
     "freeze not hide" posture as DiceSpinner above (the gradient/glow
     itself isn't motion, only the rotateX/Y wobble is). */
  .header-score-holo{animation:none;transform:none}
  /* DiscoveryMarquee.vue: revert to the pre-2026-08-27 manual-scroll
     behavior -- no continuous motion, the doubled/aria-hidden track
     content is simply never shown so a reduced-motion user never has to
     scroll past a duplicate copy of the same pills. */
  .tk-marquee{overflow-x:auto;-webkit-overflow-scrolling:touch}
  .tk-marquee-track{animation:none;width:auto}
  .tk-marquee-track button[aria-hidden="true"]{display:none}
  .vibe-card.moon-phase .moon-stars-small,
  .vibe-card.moon-phase .moon-stars-large{animation:none}
  .vibe-card.mercury-fire.retrograde{animation:none;box-shadow:0 0 8px 0 rgba(217,119,6,.25)}
  .vibe-card.mercury-fire .mercury-stars-small,
  .vibe-card.mercury-fire .mercury-stars-large{animation:none}
  .vibe-card.mercury-fire .mercury-craters{animation:none}
  /* shooting star is pure motion with no static fallback state worth
     keeping visible (a frozen mid-streak dot reads as a stray dead pixel,
     not a star) -- same "hide entirely" treatment as price-move's
     burst/drip decorations just below. */
  .shooting-star{display:none}
  /* price-move-fx plan: hide the decorative burst/drip elements entirely
     rather than freezing their animation - a frozen mid-burst dollar bill
     or a frozen drip reads as visual clutter, not motion. Reduced-motion
     users still get the plain sentiment-tinted tile (color + text), same
     as today. */
  .vibe-card.price-move .price-fx-burst,
  .vibe-card.price-move .price-fx-drip{display:none}
  /* blood-drip-revision-plan (persistent ooze): the ooze strips stay
     visible - a static color/shape state, not "an animation" in the
     disable-worthy sense (Global Constraint #4 explicitly carves this out)
     - only their slow creep is frozen. This is why .ooze-edge gets
     its own rule here rather than being folded into the price-fx-burst/
     price-fx-drip display:none above - those two are events that should
     disappear entirely under reduced motion, the ooze is a standing state
     that shouldn't.
     Task 2 verification caught a real bug here: the non-media rules for
     .ooze-left/.ooze-right/.ooze-bottom (see ~line 308) each match on
     .vibe-card.price-move.price-down .ooze-{left,right,bottom} - 4 classes,
     specificity (0,4,0) - and each sets the `animation` shorthand directly.
     The original override here matched only
     .vibe-card.price-move .ooze-edge (3 classes, (0,3,0)) - LOWER
     specificity than the rules it was trying to override, so per CSS
     cascade rules it silently lost regardless of source order or the media
     query matching; confirmed via getComputedStyle().animationName still
     reporting "ooze-creep"/"ooze-pulse" (not "none") with
     prefers-reduced-motion emulated. Selector now targets each of the
     three edge classes by name at 4-class specificity to match (and, via
     source order, win the tie against) the base rules. */
  .vibe-card.price-move.price-down .ooze-left,
  .vibe-card.price-move.price-down .ooze-right,
  .vibe-card.price-move.price-down .ooze-bottom{animation:none}
  /* fear-greed-card plan: freeze the haze/flush wash's ambient opacity
     drift (color still communicates state - the static wash + border tint
     stay visible) and hide the sweat-drop/brimstone-ember accents entirely
     rather than freezing them mid-animation, same reasoning as price-move's
     burst/drip hide above - a frozen mid-drip sweat bead or a frozen ember
     reads as clutter, not motion.
     Must match (or beat) the .bear/.bull-qualified selectors' specificity
     (0,4,0) below this media query in source order - a plain
     `.vibe-card.fear-greed .fg-decor` selector is only (0,3,0) and loses to
     them regardless of which one is declared later, so the animation would
     never actually stop under reduced motion. Found live via computed-style
     verification (Task 2) - animationName still reported `fg-wash-drift`
     under emulated prefers-reduced-motion:reduce before this fix. */
  .vibe-card.fear-greed.bear .fg-decor,
  .vibe-card.fear-greed.bull .fg-decor{animation:none}
  .vibe-card.fear-greed .sweat-drop,
  .vibe-card.fear-greed .brimstone-ember{display:none}
  /* fear-greed-ooze-revision-plan Task 1: freeze the bear ooze's creep/pulse
     while KEEPING it visible - same "standing state, not an event"
     reasoning as price-move's ooze override just above (and this plan's
     Global Constraint #5 carries that reasoning over explicitly), NOT
     folded into the sweat-drop/brimstone-ember display:none rule above,
     since those two are periodic accents this plan hides entirely under
     reduced motion while the ooze stays.
     Specificity must match (or beat) the non-media base rules for these
     same selectors (above, ~line 462): each of
     .vibe-card.fear-greed.bear .ooze-{left,right,bottom} is 4 classes,
     (0,4,0) - the exact bug this file's price-move ooze override comment
     (above) documents finding for a 3-class override that silently lost
     the cascade. Selecting each edge class by name at 4-class specificity
     here (not a shared `.ooze-edge` shorthand, which would only be
     (0,3,0)) avoids repeating that bug for the new fear-greed rules. */
  .vibe-card.fear-greed.bear .ooze-left,
  .vibe-card.fear-greed.bear .ooze-right,
  .vibe-card.fear-greed.bear .ooze-bottom{animation:none}
  /* quakes-card plan: freeze the magma ember-flicker AND the crack-flash
     pop, but keep everything visibly present exactly as Global Constraint
     #2 requires - "show a static cracked-ground image/state ... not the
     calm state and not a frozen mid-transition frame". animation:none alone
     would freeze .quake-crack-flash at whatever opacity its own non-media
     base rule sets (0 - it's normally hidden except during its brief
     flash), which would silently make reduced-motion users see FEWER
     cracks than motion users ever see at rest (only the 2 steady cracks,
     never the 2 flash cracks) - the opposite of "the visual information
     survives". The explicit opacity:1 override below is what actually
     fixes that: both crack groups end up statically visible together (the
     full 4-fissure "grown" state), same specificity concern as fear-greed's
     own reduced-motion override above (must match/beat the qualified
     .angry-scoped base rules' specificity, so this uses the same
     .vibe-card.quakes.angry .quake-crack-flash selector at equal
     specificity, winning via source order). The magma glow itself is left
     visible (just frozen mid-pulse, not hidden) - color/gradient alone
     still communicates the "molten" state without motion. Hover-shake
     needs no override here: it lives entirely inside its own
     @media (prefers-reduced-motion: no-preference) block above, so under
     reduced motion that rule doesn't exist in the first place - nothing to
     freeze or hide. */
  .vibe-card.quakes.angry .quakes-magma{animation:none}
  .vibe-card.quakes.angry .quake-crack-flash{animation:none;opacity:1}
  /* pizza-index-card plan: .calm's scanline drift needs no override here -
     it's only ever declared inside its own @media (prefers-reduced-motion:
     no-preference) block above, so under reduce that rule doesn't exist in
     the first place (same "nothing to freeze" reasoning quakes.js's
     hover-shake comment documents for its own no-preference-gated rule).
     .alert's glow pulse, interference wash, and badge breathe ARE declared
     unconditionally above, so each needs an explicit override here - each
     selector below matches its base rule's selector text exactly (same
     specificity), winning via source order per this file's documented
     specificity gotcha (see the .ooze-edge/.fg-decor comments above for the
     bug pattern this avoids). Static fallback values (mid-pulse box-shadow,
     clip-path:none, opacity:1) keep the alert state legible without motion,
     not frozen mid-transition. */
  .vibe-card.pizza-index.alert{animation:none;box-shadow:0 0 8px 0 rgba(217,119,6,.3)}
  .vibe-card.pizza-index.alert .pizza-alert-wash{animation:none;clip-path:none}
  .vibe-card.pizza-index .pizza-badge{animation:none;opacity:1}
  /* Task 1 (nuke-sign-plan) addendum, same "identical selector text" fix as
     the .pizza-badge rule directly above: the new rotation lives on the
     inner SVG (.pizza-badge-spin, a different selector than .pizza-badge)
     so it needs its own override here, freezing at a static 0deg angle
     with zero animation per Global Constraint #3 - not a random or
     mid-spin frame. */
  .vibe-card.pizza-index .pizza-badge-spin{animation:none;transform:rotate(0deg)}
  /* invert-toggle-chevron-x-morph plan: this control has no @keyframes to
     disable (Global Constraint #5) — the source's technique (and this
     modification's) is a :checked-driven CSS `transition`, so the fix is
     transition-duration:0 rather than animation:none. Every rule above
     that sets `transform`/`top`/`left` on .invert-toggle-vis/.ivt-bar
     already fires correctly under reduced motion (the :checked selector
     still applies, so the chevrons still flip to the X and the accent
     color still swaps) — this just collapses the .5s glide to an instant
     jump, so state is conveyed identically, only without the animated
     glide. Needs .invert-toggle-vis AND .ivt-bar named explicitly (not a
     shared ancestor selector) since each has its own `transition`
     declaration in the non-media rules above. */
  .invert-toggle-vis,.ivt-bar{transition-duration:0s}
  /* wall-st-weather-card-plan Task 1: same "no @keyframes here, the source
     technique is a :checked-driven CSS `transition`" situation the
     invert-toggle-chevron-x-morph comment above documents (Global
     Constraint #5) - the fix is transition-duration:0 rather than
     animation:none. Every :checked-qualified rule above (the sky/sun/
     overcast/clouds color and position changes) still applies unchanged
     under reduced motion, so the correct sunny/gloomy state still renders
     - this just collapses each .5s/.6s glide to an instant jump. Each
     selector here matches its own non-media rule's selector text exactly
     (.vibe-card.wall-st-weather .wsw-sky/.wsw-sun/.wsw-overcast/
     .wsw-clouds, 3 classes each, (0,3,0)) rather than a shared ancestor
     shorthand, matching the exact-specificity-match technique this file's
     own .ooze-edge/.fg-decor comments (above) document needing after this
     branch hit the cascade-losing bug on a lower-specificity override more
     than once. */
  .vibe-card.wall-st-weather .wsw-sky,
  .vibe-card.wall-st-weather .wsw-sun,
  .vibe-card.wall-st-weather .wsw-overcast,
  .vibe-card.wall-st-weather .wsw-clouds{transition-duration:0s}
  /* solar-storm-aurora-revival-plan Task 1: freeze the aurora layers'
     rotation while KEEPING the wash visible (color/gradient alone still
     communicates the "storm" state) - same "standing decorative wash,
     freeze don't hide" precedent as fear-greed's .fg-decor override above,
     not the sweat-drop/brimstone-ember "hide entirely" treatment (those are
     periodic accents; this is the persistent alert-state background).
     animation-name:none alone is enough here (unlike quakes' crack-flash
     case) because .aurora-layer's own non-media rule never sets opacity or
     any other property that only reads correctly mid-animation - freezing
     it just stops the rotate() keyframe at whatever transform CSS resolves
     to absent an animation (none - so translate(-50%,-50%) with rotate(0),
     a static frame of the wash, not a frozen mid-transition artifact).
     Selector matches the base rule's selector text exactly
     (.vibe-card.solar-storm .aurora-layer, 3 classes) per this file's
     documented specificity gotcha (see the .fg-decor/.ooze-edge comments
     above for the bug pattern this avoids) - winning via source order at
     equal specificity since it comes later in the file. */
  .vibe-card.solar-storm .aurora-layer{animation-name:none}
  /* doomposts-card-plan Task 1: same "no @keyframes, the technique is a
     class-driven CSS `transition`" situation the wall-st-weather/invert-
     toggle comments above document (Global Constraint #5) - the fix is
     transition-duration:0 rather than animation:none. Horses still show
     their correct grazing/occupied fill instantly (the .occupied
     class-qualified rule above still applies unchanged under reduced
     motion), this only removes the .4s glide between states. Selectors
     match the base rule's selector text exactly (.vibe-card.doomposts
     .horse-body/-neck/-ear/-tail, 3 classes each, (0,3,0)) per this file's
     documented exact-specificity-match requirement (see the .ooze-edge/
     .fg-decor/.wsw-sky comments above for the bug pattern this avoids),
     winning via source order at equal specificity since this block comes
     later in the file. The sky gradient/ground/horse-occupied-count
     themselves need no entry here - they're rendered STATE per Global
     Constraint #4, not animation, confirmed by this card having no other
     `animation` or un-gated `transition` property anywhere above. */
  .vibe-card.doomposts .horse-body,
  .vibe-card.doomposts .horse-neck,
  .vibe-card.doomposts .horse-ear,
  .vibe-card.doomposts .horse-tail{transition-duration:0s}
  /* in-button progress fill: freeze the indeterminate sweep entirely
     rather than leaving it mid-frame — a frozen partial slab reads as a
     fabricated percentage, which is exactly what the honest-indeterminate
     design is trying to avoid (see the rule's own comment above). Real-
     percentage buttons need no entry here: their fill is a plain
     `transition: width`, not an `animation`, and stays fully functional
     under reduced motion (it just also loses the .2s glide, same as every
     other `transition`-only element in this file that this media query
     doesn't otherwise touch). */
  .scout-btn.in-progress::before,
  .audit-btn.in-progress::before,
  .deepscoutnew-btn.in-progress::before{animation:none; display:none}
}
/* animation-mode-plan Task 3 (VaporTrade#46): mirror of the entire
   @media (prefers-reduced-motion: reduce) block directly above, re-targeted
   to the app-level data-motion="reduced" attribute set by Task 2, so a
   user-controlled override produces the identical reduced-motion experience
   regardless of what the OS reports via prefers-reduced-motion. Every rule
   below is a mechanical 1:1 copy of the corresponding rule above with each
   selector prefixed by :root:is([data-motion="reduced"],[data-motion="off"]) - same declarations,
   same specificity-fight reasoning documented in that block's own comments,
   same source order. Do not diverge the two blocks; if the media-query
   block above ever changes, mirror the change here too. */
:root:is([data-motion="reduced"],[data-motion="off"]) .stalk-pending{animation:none;opacity:1}
:root:is([data-motion="reduced"],[data-motion="off"]) .confirm-armed{animation:none;box-shadow:0 0 0 3px rgba(251,113,133,.2)}
:root:is([data-motion="reduced"],[data-motion="off"]) .slots-chip.slots-pulse{animation:none;box-shadow:0 0 0 3px color-mix(in srgb, var(--accent-1) 25%, transparent)}
:root:is([data-motion="reduced"],[data-motion="off"]) .dice-spinner{animation:none;transform:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .dice-spinner-face{transition:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .dice-spinner-face:not(.face-1){display:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .dice-spinner-face.face-1{transform:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .header-score-holo{animation:none;transform:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .tk-marquee{overflow-x:auto;-webkit-overflow-scrolling:touch}
:root:is([data-motion="reduced"],[data-motion="off"]) .tk-marquee-track{animation:none;width:auto}
:root:is([data-motion="reduced"],[data-motion="off"]) .tk-marquee-track button[aria-hidden="true"]{display:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.moon-phase .moon-stars-small,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.moon-phase .moon-stars-large{animation:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.mercury-fire.retrograde{animation:none;box-shadow:0 0 8px 0 rgba(217,119,6,.25)}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.mercury-fire .mercury-stars-small,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.mercury-fire .mercury-stars-large{animation:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.mercury-fire .mercury-craters{animation:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .shooting-star{display:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.price-move .price-fx-burst,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.price-move .price-fx-drip{display:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.price-move.price-down .ooze-left,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.price-move.price-down .ooze-right,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.price-move.price-down .ooze-bottom{animation:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.fear-greed.bear .fg-decor,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.fear-greed.bull .fg-decor{animation:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.fear-greed .sweat-drop,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.fear-greed .brimstone-ember{display:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.fear-greed.bear .ooze-left,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.fear-greed.bear .ooze-right,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.fear-greed.bear .ooze-bottom{animation:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.quakes.angry .quakes-magma{animation:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.quakes.angry .quake-crack-flash{animation:none;opacity:1}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.pizza-index.alert{animation:none;box-shadow:0 0 8px 0 rgba(217,119,6,.3)}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.pizza-index.alert .pizza-alert-wash{animation:none;clip-path:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.pizza-index .pizza-badge{animation:none;opacity:1}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.pizza-index .pizza-badge-spin{animation:none;transform:rotate(0deg)}
:root:is([data-motion="reduced"],[data-motion="off"]) .invert-toggle-vis,:root:is([data-motion="reduced"],[data-motion="off"]) .ivt-bar{transition-duration:0s}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.wall-st-weather .wsw-sky,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.wall-st-weather .wsw-sun,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.wall-st-weather .wsw-overcast,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.wall-st-weather .wsw-clouds{transition-duration:0s}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.solar-storm .aurora-layer{animation-name:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.doomposts .horse-body,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.doomposts .horse-neck,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.doomposts .horse-ear,
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.doomposts .horse-tail{transition-duration:0s}
:root:is([data-motion="reduced"],[data-motion="off"]) .scout-btn.in-progress::before,
:root:is([data-motion="reduced"],[data-motion="off"]) .audit-btn.in-progress::before,
:root:is([data-motion="reduced"],[data-motion="off"]) .deepscoutnew-btn.in-progress::before{animation:none; display:none}
/* vix + geo_tension flagship vibe cards (render/vibe-fx/vix.js,
   render/vibe-fx/geo-tension.js) — 2026-08-20 vix-geo-tension-cards-plan
   Task 1. Both are simple horizontal gauge-track meters built by the
   shared render/vibe-fx/market-gauge.js helper — see that file's header
   comment for why a shared module made sense here (same track/gradient/
   marker/threshold-tick shape, differing only in range + color direction)
   unlike the more bespoke illustrated-scene flagships above.

   .vix-content/.geo-tension-content get position:relative;z-index:1 for
   structural consistency with the rest of the card family's foreground-lift
   convention (quakes.js's .quakes-content, pizza-index.js's .pizza-content)
   even though neither card has absolutely-positioned decoration behind its
   text to lift above — the gauge sits below the content in normal
   document flow, not overlaid.

   No animation anywhere in this block (Global Constraint #1: "static
   gauge position per current value") — nothing here needs a
   prefers-reduced-motion guard because nothing here ever moves. */
.vibe-card.vix .vix-content,
.vibe-card.geo-tension .geo-tension-content{position:relative;z-index:1}
.gauge-track{
  position:relative;height:8px;border-radius:4px;margin-top:10px;
  overflow:visible;
}
/* the current-value marker: a small vertical tick positioned via inline
   `left:%` (computed in market-gauge.js's pct()) — plain white/near-white
   so it reads clearly against either gradient end without needing a
   per-position color swap. */
.gauge-marker{
  position:absolute;top:50%;left:0;width:3px;height:16px;
  background:var(--text);border-radius:1.5px;
  transform:translate(-50%,-50%);
  box-shadow:0 0 3px 0 rgba(0,0,0,.6);
}
/* threshold ticks: thin, low-contrast lines marking exactly where
   sentimentOf() flips (vix's single raw>=25 cutoff, geo_tension's
   raw<=-0.3/raw>=0.3 pair) — never a separately invented cutoff, always
   the same constant the flagship renderer already passed to
   sentimentOf() via vibe-cards.js. */
.gauge-threshold{
  position:absolute;top:-2px;bottom:-2px;left:0;width:1px;
  background:rgba(255,255,255,.45);
}
/* geo_tension only: a slightly more visible center reference line at 0,
   since its bidirectional range has no other visual midpoint the way
   vix's unsigned range implicitly does at either edge. */
.gauge-zero{
  position:absolute;top:-3px;bottom:-3px;left:0;width:1px;
  background:rgba(255,255,255,.7);
}
/* LLM-signal flagship cards (render/vibe-fx/llm-signal.js) — 2026-08-20
   llm-signal-cards-plan Task 1. Active state reuses .gauge-track/-marker/
   -threshold/-zero above verbatim (same market-gauge.js helper as vix/
   geo_tension) — nothing new needed for that state.

   Pending state (raw===null, .pending class): the whole card gets a
   gentle desaturate+dim rather than any bull/bear tint, so a "not
   deployed yet" reading never reads as alarming — restraint per this
   branch's established pattern for non-alert states. The pulsing dot is
   the only animation this block adds, and it's introduced ONLY inside the
   `@media (prefers-reduced-motion: no-preference)` guard below (same
   technique as pizza-index.calm's scanline drift, ~line 771) — the
   animation-assigning rule itself never exists under reduced-motion, so
   there's no separate override rule to keep at matching specificity. */
.vibe-card.llm-signal .llm-signal-content{position:relative;z-index:1}
.vibe-card.llm-signal.pending{opacity:.86;filter:saturate(.55)}
.vibe-card.llm-signal .llm-pending-badge{
  margin-top:10px;display:inline-flex;align-items:center;gap:6px;
  font-size:.7rem;color:var(--text-2);letter-spacing:.02em;
}
.vibe-card.llm-signal .llm-pending-dot{
  width:6px;height:6px;border-radius:50%;background:var(--text-2);
  display:inline-block;flex:none;
}
@media (prefers-reduced-motion:no-preference){
  .vibe-card.llm-signal .llm-pending-dot{animation:llm-pending-pulse 2.4s ease-in-out infinite}
}
:root:is([data-motion="reduced"],[data-motion="off"]) .vibe-card.llm-signal .llm-pending-dot{animation:none;opacity:1}
@keyframes llm-pending-pulse{0%,100%{opacity:.3}50%{opacity:1}}
.confirm-consequence{
  margin-top:6px;color:var(--text-2);max-width:340px;line-height:1.4;
  white-space:pre-line;
}
.signal-row td{color:var(--text-2);font-style:italic}
/* batch-select.js — selection checkbox cell + bulk action bar
   (roster-batch-management plan Task 2). Only these new elements need
   Global Constraint #4's 44x44px touch targets; existing small per-row
   action buttons elsewhere are unchanged by this plan. */
.select-cell{width:1%;white-space:nowrap;text-align:center}
.select-cell input[type=checkbox]{width:20px;height:20px;cursor:pointer}
/* full row is the real tap target (wireSelectableTable() in batch-select.js
   handles the click delegation) — this is just a visual affordance so a
   selectable row reads as tappable, not the target itself. */
tr[data-select-key]{cursor:pointer}
.bulk-bar{position:sticky;bottom:8px;z-index:50;display:flex;align-items:center;
  gap:8px;flex-wrap:wrap;background:var(--bg3);border:1px solid var(--border);
  border-radius:12px;padding:10px 12px;margin-top:8px;
  box-shadow:0 -4px 16px rgba(0,0,0,.35)}
.bulk-bar-count{font-weight:700;margin-right:4px}
/* 44x44 minimum + >=8px gap (the .bulk-bar `gap:8px` above covers spacing
   between these two buttons specifically) */
.bulk-bar-clear,.bulk-bar-primary,.select-all-visible{min-height:44px;min-width:44px;padding:10px 16px}
/* mountBulkBar() (batch-select.js) renders this shell ONCE on first
   non-empty selection and toggles visibility via this class rather than
   clearing innerHTML on every selection.onChange() — see that module's
   header for why (live-region announce reliability + focus survival on
   a click that mutates the selection from inside the bar itself). */
.bulk-bar.hidden{display:none}
@media (max-width:700px){
  .bulk-bar{bottom:8px}
}
/* bulk-confirm-dialog.js — itemized review list for bulk-destructive
   confirms (roster-batch-management plan Task 3). This is a SEPARATE
   scroll region from .dialog-box's own overflow-y:auto (theme.css line
   ~183): .dialog-box caps the whole dialog at 80vh so it never grows
   off-screen, but for a large selection we also want the title and the
   confirm/cancel buttons to stay put while only the item list itself
   scrolls, so the confirm button is never the thing that scrolls out of
   reach. min(...) keeps this sane on both a tall desktop viewport and a
   short mobile one. */
.bulk-confirm-list{overflow-y:auto;max-height:min(50vh,420px);margin:10px 0}
.bulk-confirm-list table{width:100%}
.bulk-confirm-list th,.bulk-confirm-list td{white-space:nowrap;padding:4px 8px}
.bulk-confirm-list thead th{position:sticky;top:0;background:var(--bg2);z-index:1}
.llm-detail-link{color:var(--accent-1);text-decoration:none;font-size:.8rem;white-space:nowrap}
.llm-detail-link:hover{text-decoration:underline}
/* 📱 mobile glow-up — beta testers said the UI was cumbersome. they were right */
@media (max-width:700px){
  #appwrap{padding:10px}
  /* min-width:0 lets flex/grid children shrink instead of blowing out the page.
     .botdetail-grid/.botdetail-col added with the BotDetail desktop-redesign
     (2026-08-29) -- without these, the 340px dials-panel column and its
     sibling tiles/roster-peek column don't shrink below their content width
     even after .botdetail-grid itself collapses to a single column at this
     breakpoint, causing horizontal overflow on the whole page. */
  .card,.row,.tiles,#colmain,#cols,.botdetail-grid,.botdetail-col{min-width:0;max-width:100%}
  .scroller{max-width:100%}
  h1{font-size:1.35rem}
  .tag{font-size:.78rem;margin-bottom:10px}
  .card{padding:12px;border-radius:12px;margin-bottom:12px}
  .row{gap:8px}
  button{padding:8px 12px;font-size:.85rem}
  /* nav becomes a swipeable strip instead of a 3-row button blob -- OLD
     app only (static/index.html's own <nav>). :not(.app-nav) added
     2026-08-27 (live report: "clicking account on mobile does nothing")
     -- root cause was this generic `nav` element selector ALSO matching
     AppShell.vue's <nav class="app-nav"> (the new SPA nav is a <nav> tag
     too), whose overflow-x:auto here computes overflow-y to auto as well
     (CSS overflow spec: any axis non-'visible' forces the other axis out
     of 'visible' too), clipping .app-nav-subgroup's popover (positioned
     bottom:100%, intentionally rendering OUTSIDE .app-nav's own box) into
     a zero-size, non-hit-testable region -- confirmed via
     elementsFromPoint returning zero .app-nav-subgroup hits at its own
     reported bounding rect, not just a lower-z-index paint-order issue. */
  nav:not(.app-nav){flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;
    scrollbar-width:none;padding-bottom:4px}
  nav:not(.app-nav)::-webkit-scrollbar{display:none}
  nav:not(.app-nav) button{white-space:nowrap;padding:7px 11px;font-size:.8rem;flex:none}
  #navwho{margin-left:8px;white-space:nowrap}
  .tiles{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
  .tile{padding:10px 12px;overflow:hidden}
  .tile .val{font-size:1.2rem;overflow-wrap:anywhere}
  .tile .sub{overflow-wrap:anywhere}
  /* blood-drip-revision-plan mobile fix: at this breakpoint .tile's own
     padding drops to 10px (immediately above) - the exact same value as
     .ooze-bottom's 10px height (see that rule, ~line 318) - so the
     persistent edge ooze's top edge landed flush against (measured via
     Playwright getBoundingClientRect during Task 2 verification: a
     sub-pixel NEGATIVE gap, i.e. touching) the tile's .sub text baseline at
     mobile widths, even though the desktop 12px/14px padding band gave it
     ~21px of clearance. Shrinking the strip to 6px here (matching
     .ooze-left/.ooze-right's width, both unaffected since the overlap risk
     was bottom-specific) restores several px of real clearance under the
     tighter mobile padding. clip-path on .ooze-bottom uses percentage y
     coordinates so the jagged drip-fringe silhouette rescales automatically
     - no clip-path change needed here. */
  .vibe-card.price-move.price-down .ooze-bottom{height:6px}
  /* fear-greed-ooze-revision-plan Task 1: same mobile-padding-collision risk
     as price-move's fix immediately above applies here too - fear-greed's
     .tile shares the exact same .tile padding rule (12px/14px desktop,
     10px at this breakpoint), and this bear ooze block copied price-move's
     10px .ooze-bottom height verbatim, so it would land flush against
     .fg-content's text at mobile for the identical reason. Shrunk to 6px
     here for the same clearance restored, re-measured (not assumed) via
     Playwright getBoundingClientRect in fear-greed's own layout - see this
     plan's Task 1 report. */
  .vibe-card.fear-greed.bear .ooze-bottom{height:6px}
  /* 16px stops iOS from zoom-lunging at every input */
  input,select,textarea{font-size:16px}
  /* bot action pile -> tidy 2-col grid, every move still one tap away */
  .botactions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));width:100%;gap:6px;margin-top:8px}
  .botactions button{padding:9px 6px;font-size:.78rem}
  .wrow label{flex:0 0 108px;font-size:.72rem}
  .wrow output{width:36px;font-size:.72rem}
  .verdict{font-size:.95rem}
  /* spawn form: stop the side-by-side squish, stack it */
  #create .row > div{min-width:100% !important}
  #login{margin:4vh auto}
  /* ticker-chip help-pop (see .chip-wrap above): on mobile the ticker panel
     goes full-width right up to the viewport edge (#create .row > div rule
     above), so a popup anchored to an individual chip and sized to its own
     content can extend past the right edge and get silently clipped by the
     html,body overflow-x:hidden rule — measured 5/12 chips off-screen by
     18-87px in a 375px-viewport sample, worst case +87px. Fix: stop
     anchoring to the individual chip — un-position .chip-wrap so .help-pop's
     containing block falls through to .chip-group-list (the whole group),
     then span it left:0..right:0 as a full-width bar under the group
     instead of a max-content bubble hung off one chip. */
  .chip-wrap{position:static}
  .chip-wrap .help-pop{left:0;right:0;width:auto;max-width:none}
}
/* dev-only debug theme switcher (static/js/debug-theme-switcher.js) —
   ?debug_theme=1-gated, never shown to regular users. Token-driven so it
   renders correctly under every active pack, including whichever pack
   it's currently being used to preview. */
#debug-theme-panel{position:fixed;bottom:12px;left:12px;z-index:400;
  background:var(--bg2);border:1px solid var(--border);border-radius:10px;
  padding:10px 12px;font-size:.8rem;color:var(--text);
  box-shadow:0 4px 16px rgba(0,0,0,.35);display:flex;flex-direction:column;gap:6px}
#debug-theme-panel .dtp-label{font-weight:700;color:var(--text-2);
  text-transform:uppercase;font-size:.7rem;letter-spacing:.06em}
#debug-theme-panel button{padding:6px 10px;font-size:.78rem}
#debug-theme-panel button.dtp-active{outline:2px solid var(--accent-1);outline-offset:1px}
/* dev-only debug doomposts switcher (static/js/debug-doomposts-switcher.js)
   — ?debug_doomposts=1-gated. Anchored bottom-right (vs. the theme
   switcher's bottom-left) so both panels can be active at once, e.g.
   ?debug_theme=1&debug_doomposts=1, without overlapping. Reuses
   #debug-theme-panel's visual language (same tokens, .dtp-label/.dtp-active
   classes) rather than inventing a second style. */
#debug-doomposts-panel{position:fixed;bottom:12px;right:12px;z-index:400;
  background:var(--bg2);border:1px solid var(--border);border-radius:10px;
  padding:10px 12px;font-size:.8rem;color:var(--text);max-width:260px;
  box-shadow:0 4px 16px rgba(0,0,0,.35);display:flex;flex-wrap:wrap;gap:6px}
#debug-doomposts-panel .dtp-label{flex-basis:100%;font-weight:700;color:var(--text-2);
  text-transform:uppercase;font-size:.7rem;letter-spacing:.06em}
#debug-doomposts-panel button{padding:6px 10px;font-size:.78rem}
#debug-doomposts-panel button.dtp-active{outline:2px solid var(--accent-1);outline-offset:1px}

/* Loading placeholder (2026-08-25) — a slow page's plain "loading..."
   text upgraded to a chromatic-aberration glitch treatment, adapted from
   uiverse.io/andrew-demchenk0/silent-earwig-10 (see the
   2026-08-19-uiverse-reference-library.md doc for the rest of that
   session's finds). Deliberately a stand-in for now, not the final
   design — the doc's kind-snail-5/jolly-kangaroo-36 finds sketch a
   bird-mascot loading system for VaporTrade's 40x/loading states, but
   that needs its own scoping pass (which species, single vs rotating
   cast) before it's buildable; this is the zero-dependency option in
   the meantime. --accent-2/--accent-3 (already-defined, consistent
   across every pack) stand in for the reference's hardcoded
   #8b00ff/#00e571, and the base layer reads var(--text) instead of
   #fff so it's legible on every pack's background, light or dark.
   Markup: <span class="vt-loader-glitch" data-text="...">...</span> —
   data-text must match the visible text exactly, it's what the ::before/
   ::after pseudo-elements duplicate via attr(). */
.vt-loader-glitch{position:relative;display:inline-block;font-size:1.05rem;
  font-weight:700;line-height:1.2;letter-spacing:.15em;color:var(--text);z-index:1}
.vt-loader-glitch::before,.vt-loader-glitch::after{
  display:block;content:attr(data-text);position:absolute;top:0;left:0;opacity:.8}
.vt-loader-glitch::before{color:var(--accent-2);z-index:-1}
.vt-loader-glitch::after{color:var(--accent-3);z-index:-2}
/* --glitch-dur/-before/-after and --glitch-delay: set per-instance, inline,
   by the boot script in index.html (both apps) the moment a
   .vt-loader-glitch mounts -- without them every instance shares the exact
   same keyframe timeline and animates in lockstep, which reads as
   noticeably repetitive/robotic with two loaders on one page at once
   (Alan, 2026-08-25). Fallbacks below match the pre-randomization values so
   nothing breaks if the boot script hasn't run yet (SSR-less app, but
   defensive regardless). */
@media (prefers-reduced-motion: no-preference){
  .vt-loader-glitch{animation:vt-glitch-shift var(--glitch-dur,1s) ease-in-out infinite alternate;
    animation-delay:var(--glitch-delay,0s)}
  .vt-loader-glitch::before{animation:vt-glitch-slice var(--glitch-dur-before,.4s) cubic-bezier(.25,.46,.45,.94) both infinite;
    animation-delay:var(--glitch-delay,0s)}
  .vt-loader-glitch::after{animation:vt-glitch-slice var(--glitch-dur-after,.4s) cubic-bezier(.25,.46,.45,.94) reverse both infinite;
    animation-delay:var(--glitch-delay,0s)}
}
:root:is([data-motion="reduced"],[data-motion="off"]) .vt-loader-glitch{animation:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vt-loader-glitch::before{animation:none}
:root:is([data-motion="reduced"],[data-motion="off"]) .vt-loader-glitch::after{animation:none}
@keyframes vt-glitch-slice{
  0%{transform:translate(0)}
  20%{transform:translate(-3px,3px)}
  40%{transform:translate(-3px,-3px)}
  60%{transform:translate(3px,3px)}
  80%{transform:translate(3px,-3px)}
  to{transform:translate(0)}
}
@keyframes vt-glitch-shift{
  0%,40%,44%,58%,61%,65%,69%,73%,100%{transform:skewX(0deg)}
  41%{transform:skewX(10deg)}
  42%{transform:skewX(-10deg)}
  59%{transform:skewX(40deg) skewY(10deg)}
  60%{transform:skewX(-40deg) skewY(-10deg)}
  63%{transform:skewX(10deg) skewY(-5deg)}
  70%{transform:skewX(-50deg) skewY(-20deg)}
  71%{transform:skewX(10deg) skewY(-10deg)}
}

/* DiscoveryMarquee.vue (Task 11; self-moving animation added 2026-08-27
   after a live report that this didn't match the self-moving version
   demo'ed). Self-moving by default: a slow, smooth translateX loop over
   DOUBLED track content (component renders `picks` twice, second copy
   aria-hidden) -- animating exactly -50% of the doubled track's own
   width loops seamlessly regardless of pick count/pill width, no JS
   measurement needed. This never flashes, so it was never actually at
   odds with this app's flash-rate/seizure-safety rule (WCAG 2.3.1 is
   about flashing >3x/sec, not continuous motion) -- that rule was
   conflated with a DIFFERENT concern, vestibular/motion sensitivity,
   which gets the standard answer below: prefers-reduced-motion freezes
   the animation and reverts to manual overflow-x scrolling (today's
   original behavior), same "freeze on a static frame" posture this
   file's other reduced-motion rules already use. Paused on hover/focus
   too, so a pointer or keyboard user can actually read/click a pill
   without it sliding out from under them. Theme-aware via the shared
   CSS vars, and responsive: overflows into its own scroll container on
   narrow viewports instead of the pill row wrapping and blowing out
   layout. */
.tk-marquee-wrap{margin:10px 0}
.tk-marquee-label{font-size:.78rem;color:var(--text-2);margin-bottom:4px}
.tk-marquee{overflow:hidden}
.tk-marquee-track{display:flex;gap:8px;padding:2px 2px 8px;width:max-content;
  animation:tk-marquee-scroll 32s linear infinite}
.tk-marquee-wrap:hover .tk-marquee-track,
.tk-marquee-wrap:focus-within .tk-marquee-track{animation-play-state:paused}
.tk-marquee-track button{flex:0 0 auto;background:var(--bg3);
  border:1px solid var(--border);white-space:nowrap;font-size:.82rem;
  padding:6px 12px}
.tk-marquee-track button span{margin-left:4px;font-weight:700}
@keyframes tk-marquee-scroll{
  0%{transform:translateX(0)}
  100%{transform:translateX(-50%)}
}
