.htmx-indicator { opacity: 0; transition: opacity 150ms ease-in; }
.htmx-request .htmx-indicator { opacity: 1; }
.htmx-request.htmx-indicator { opacity: 1; }

/* Swing Conviction Board's inline Trade Map accordion (see
   swing_trade_map_accordion.js) - the chevron on the toggle just rotates
   180deg to point up once its panel is open, plain CSS rather than a
   Tailwind utility since these are hand-named BEM-ish classes the
   template scanner has no reason to know about. */
.trade-map-toggle-chevron { transition: transform 150ms ease; }
.trade-map-toggle-chevron--open { transform: rotate(180deg); }

/* Swing Conviction Board's Trade Map panel reveal - a plain opacity +
   slight upward slide instead of the .hidden class's abrupt display:
   none -> block cut, so expanding a card's own Trade Map section feels
   like this card settling into place, not new content just appearing.
   Deliberately opacity/transform only (never height) - the panel's real
   content height varies a lot per stock (radar chart present or not,
   score history data or not), so animating height would need JS to
   measure it first; animating opacity/transform sidesteps that entirely
   and still reads as smooth. swing_trade_map_accordion.js adds this
   class on every expand() and removes it on every collapse() so it
   reliably re-triggers on each open, including reopening a symbol you
   already viewed. */
.trade-map-panel--opening { animation: trade-map-reveal 220ms ease-out; }
@keyframes trade-map-reveal {
    from { opacity: 0; transform: translateY(-6px); }
    to { opacity: 1; transform: translateY(0); }
}

/* Site-wide decorative constellation particle field (see
   particle_field.js) - fixed, full-viewport, negative z-index so it
   sits above body's own background-image (the radial-gradient glow in
   tailwind_input.css) but below every normal page element (nav, main,
   the watchlist rail, footer) without needing to touch any of THEIR
   z-index/position - see base.html's own comment on the canvas element
   for exactly why a negative z-index is what makes that work.
   pointer-events: none is load-bearing - without it this full-screen
   canvas would silently eat every click/hover on the real page
   sitting "above" it. */
.particle-field-canvas {
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100vh;
  z-index: -1;
  pointer-events: none;
}

/* Same visual idiom as .htmx-indicator above, but driven explicitly by
   JS (see top_stocks_loading.js) instead of htmx's automatic
   hx-indicator wiring - for badges that are a separate sibling from
   the element actually making the request, where htmx doesn't
   guarantee clearing htmx-request once hx-sync starts superseding
   in-flight requests. `.is-active` is added/removed by JS, never by
   htmx itself. */
.js-loading-badge { opacity: 0; transition: opacity 150ms ease-in; }
.js-loading-badge.is-active { opacity: 1; }

/* Reusable dim + spinning-ring overlay for any htmx container that
   fully replaces its own children on a real (not-instant) fetch - Top
   Stocks re-ranking on horizon switch, Strategy Lab's #lab-detail swap,
   the Bullpen analysis panel/horizon view re-fetching on symbol or
   horizon change, the watchlist table re-scoring, or anywhere else this
   pattern is needed later. Dims the OLD content (instead of it just
   sitting there frozen and unchanged) AND layers a centered animated
   ring spinner on top of it via a plain ::after pseudo-element - no
   extra markup needed per caller, just this one class - then smoothly
   restores full opacity/hides the ring the moment the swap lands. The
   dim alone was the original version of this class; the ring was added
   because a color-only change is easy to miss out of the corner of an
   eye, while genuine motion reliably catches attention - exactly the
   "how do I know my click registered" signal this class exists for.
   Callers MUST include the container's own id/selector in their
   hx-indicator list (e.g. hx-indicator="#my-spinner, #my-container") -
   htmx only adds the htmx-request class to elements the hx-indicator
   selector actually resolves to, not automatically to every element
   that merely issued the request.

   Deliberately does NOT force position: relative here (tempting, since
   the ring below needs SOME positioned ancestor to center against) -
   this file's hand-written rules are unlayered CSS, which in this
   codebase's Tailwind v4 setup always outranks Tailwind's own @layer
   utilities regardless of source order (see .pill-selected's comment
   above for the same fact cited the other way around) - so a blanket
   position: relative here would silently stomp any caller that needs a
   DIFFERENT position value, e.g. index.html's #analysis-section, which
   relies on xl:sticky to stay pinned while scrolling. Every caller
   below instead adds its own `relative` Tailwind utility class
   alongside this one (harmless to pair with sticky - `xl:sticky`
   simply overrides `relative` at the xl breakpoint, same as any other
   responsive Tailwind override). */
.hx-swap-fade {
    transition: opacity 200ms ease;
}
.hx-swap-fade.htmx-request {
    opacity: 0.4;
    pointer-events: none;
}

/* WP03A/WP03B "five-second decision" verdict-first order (2026-09) -
   the DETAILED verdict (analysis-section's gauge/rating/confidence/
   opportunity/risk/drivers) is now FIRST in index.html's actual DOM
   markup, ahead of #bullpen-trade-desk-evidence (Trade Map/Pillar
   Shape/Score History/Company Snapshot) - real markup order, not just
   a CSS paint-order trick, so screen readers, keyboard tab order, and
   "view source" all agree with what a sighted mobile visitor sees:
   the verdict IS the five-second answer, the rest is supporting
   evidence. WP03A's first pass got this right visually on mobile but
   left the OLD evidence-first DOM order in place and used `order` to
   flip it purely for paint - this pass finally closes that out (see
   docs/work-packets/03a-analyze-experience-audit.md §K item 1).

   Mobile (<1024px) needs ZERO override now: natural DOM/flex order
   already puts the verdict first, which is exactly what WP03A §K
   asked for - no CSS at all is more robust than an order hack. Only
   DESKTOP (>=1024px, side-by-side columns) needs `order` to restore
   the established evidence-left/verdict-right visual layout despite
   the swapped markup - unlayered CSS (this codebase's own established
   fix for Tailwind v4's `lg:` variant-cascade quirk, see this file's
   .hx-swap-fade comment above) is used for the same reason WP03A's
   first pass already documented. */
@media (min-width: 1024px) {
    #bullpen-trade-desk-evidence { order: 1; }
    #bullpen-trade-desk-verdict { order: 2; }
}
.hx-swap-fade::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    width: 2.25rem;
    height: 2.25rem;
    margin: -1.125rem 0 0 -1.125rem;
    border-radius: 9999px;
    border: 3px solid rgba(226, 232, 240, 0.18);
    border-top-color: var(--color-accent);
    opacity: 0;
    z-index: 5;
    transition: opacity 150ms ease;
}
.hx-swap-fade.htmx-request::after {
    opacity: 1;
    animation: hx-swap-fade-spin 0.8s linear infinite;
}
@keyframes hx-swap-fade-spin {
    to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
    .hx-swap-fade { transition: none; }
    /* The dim alone is still a reduced-motion-safe loading signal on
       its own - the ring is purely a motion effect, so it's suppressed
       entirely rather than shown as a static (and therefore
       meaningless) circle. */
    .hx-swap-fade::after { display: none; }
}

/* Horizon toggle nav strip (base.html): the single most important,
   always-visible control on the site (it silently reshapes scoring +
   chart defaults everywhere), sitting inside the sticky nav so it's
   pinned at the top on every scroll. As the reader scrolls past the
   threshold below, body gets a `nav-scrolled` class (see base.html's
   scroll listener) which compacts this strip's padding and adds a
   stronger drop shadow - reinforcing this is a persistent, important
   control rather than part of the page content scrolling away. Skipped
   entirely under prefers-reduced-motion - the state still changes, it
   just snaps instead of tweening. */
.horizon-nav-bar {
    transition: padding 200ms ease, box-shadow 200ms ease, border-color 200ms ease;
}
body.nav-scrolled .horizon-nav-bar {
    padding-top: 0;
    padding-bottom: 0;
}
body.nav-scrolled .horizon-nav-bar > .page-shell {
    padding-top: 0.5rem;
    padding-bottom: 0.5rem;
}
body.nav-scrolled .horizon-nav-bar {
    box-shadow: 0 10px 28px -14px rgba(0, 0, 0, 0.55);
}
body.nav-scrolled .horizon-nav-hint {
    display: none;
}

/* The "docking" blend - see base.html's scroll listener for exactly
   when `nav-docked` gets toggled (the narrow scroll window where the
   Bullpen's merged Trade Map hero card - #bullpen-hero-card, see
   index.html - is sliding underneath the sticky nav's horizon strip).
   While docked: the strip's own bottom accent border fades out and the
   card immediately below it loses its top rounding + gains a matching
   top border in the SAME horizon color the strip already carries (the
   strip's `border-b-*` utility and the card's `border-t-*` override
   target different CSS properties, so they can carry the identical
   color without any specificity fight) - the two elements read as one
   continuous colored panel for that moment instead of two boxes
   meeting at a seam. Targets #bullpen-hero-card (the merged card's own
   outer wrapper, which owns all its border/rounding now) rather than
   the inner #horizon-view-card - that inner div is purely a scroll-
   position probe now (base.html's scroll listener still measures ITS
   bounding rect, since it's flush against the hero card's top edge
   with no border/padding of its own to skew the geometry), it no
   longer carries any border-radius of its own to flatten. Everything
   here is a pure visual overlay (no layout shift - border-radius and
   border-color transitions don't reflow), so it can never cause the
   exact "things jumping around while scrolling" feel this was built to
   avoid. */
body.nav-docked #nav-horizon-bar {
    border-bottom-color: transparent;
}
body.nav-docked #bullpen-hero-card {
    margin-top: -1px;
    border-top-left-radius: 0;
    border-top-right-radius: 0;
}
@media (prefers-reduced-motion: reduce) {
    .horizon-nav-bar, #bullpen-hero-card { transition: none; }
}
/* Fluid page width: on a normal laptop this behaves like the old fixed
   max-w-7xl container, but on a wide/ultrawide monitor it keeps growing
   (up to a sane ceiling) instead of leaving big dead margins on both
   sides - the chart in particular benefits from every extra pixel of
   width. clamp() grows linearly with the viewport between the two
   bounds rather than jumping at fixed breakpoints. Ceiling raised to
   180rem / vw share bumped to 94vw (from 116rem / 92vw) after feedback
   that ~35" ultrawide monitors (3440px+) still had big dead margins on
   both sides - doesn't change anything on ordinary wide monitors (94vw
   stays under the new ceiling until ~2750px viewport width), it only
   kicks in for genuinely ultrawide displays. Deliberately still short
   of 100vw even at the ceiling - an edge-to-edge dashboard of
   fixed-ish-width cards (chart, pillar tiles) looks worse stretched
   completely flat than with a modest, intentional gutter. */
.page-shell {
    width: 100%;
    max-width: clamp(72rem, 94vw, 180rem);
    margin-left: auto;
    margin-right: auto;
    transition: margin-right 0.25s ease, max-width 0.25s ease;
}
@media (prefers-reduced-motion: reduce) {
    .page-shell { transition: none; }
}
/* Mid-drag: max-width/margin-right below both derive from the same
   --rail-width var the resize handle updates 60+ times/sec - same
   "transition fighting a live drag" lag the rail's own .is-resizing
   rule (further down) already guards against, mirrored here via a
   body-level class (set in watchlist_rail.js) so the content edge
   tracks the cursor 1:1 while dragging instead of visibly trailing
   behind it. */
body.watchlist-rail-resizing .page-shell {
    transition: none;
}

/* Chart expand/fullscreen mode - toggled by the Expand button. Lifts the
   chart card out of the grid into a fixed, near-viewport-filling overlay
   so a reader can study candles/indicators without the analysis panel
   and page chrome competing for space. Chart.js's `responsive: true`
   plus the resize() call fired from chart.js's toggle handler make the
   canvases redraw to fit this new size automatically. */
.chart-fullscreen-backdrop {
    display: none;
}
.chart-fullscreen-backdrop.active {
    display: block;
    position: fixed;
    inset: 0;
    background: rgba(5, 8, 13, 0.85);
    z-index: 40;
}
.chart-card.is-fullscreen {
    position: fixed;
    inset: 2vh 2vw;
    z-index: 50;
    overflow-y: auto;
    box-shadow: 0 20px 60px rgba(0,0,0,0.6);
}
.chart-card.is-fullscreen #priceChart-wrap {
    /* priceChart-wrap normally uses Tailwind's flex-1 (flex: 1 1 0%) to
       grow and fill the card - but flex-basis:0% would make a plain
       `height` override here get ignored by the flex layout algorithm.
       `flex: none` resets it back to a normal box (flex-basis: auto) so
       the explicit height below actually takes effect in fullscreen. */
    flex: none;
    height: 60vh;
}
.chart-card.is-fullscreen #volume-wrap {
    height: 12vh;
}
.chart-card.is-fullscreen #oscillator-wrap {
    height: 16vh;
}

::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #080b10; }
::-webkit-scrollbar-thumb { background: #1c2431; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #2a3441; }

body { background-image: radial-gradient(circle at 50% 0%, rgba(200,244,93,0.06), transparent 60%); background-attachment: fixed; }

/* Phantom horizontal-scroll fix, mobile launch audit (2026-08-29).
   .watchlist-rail (base.html) is `position:fixed; right:0` and lives
   as a direct sibling of <nav>/<main> - when collapsed it's pushed
   off-screen via `transform: translateX(100%)` rather than
   `display:none`, which keeps its close/open transition animatable
   but means the browser still counts its full off-screen bounding box
   toward the document's scrollable area. Confirmed via Playwright on
   an iPhone 13 viewport: /app had a real, if invisible, 28px
   horizontal scroll caused by exactly this - a mobile visitor could
   drag the whole page sideways a little, which reads as broken/janky
   even though no visible content was actually cut off.
   `overflow-x: clip` on body (NOT `hidden`) - see tailwind_input.css's
   own detailed comment on nav/.chart-card for why: `hidden` was tried
   here first and confirmed (again, via Playwright) to break nav's own
   `position:sticky` the exact same way it did before, while `clip`
   does not, since it never promotes body into a real scroll container
   the way `hidden`/`auto`/`scroll` do. Re-verified after this change:
   nav still sticks on scroll, and index.html's Bullpen card `xl:sticky`
   still pins correctly at wide viewports - see the mobile-audit
   Playwright script this session for the exact checks run.

   `body` alone turned out not to be enough - document.scrollWidth is
   measured against <html> (the actual root scrolling element in
   every browser, with body just a normal block box living inside it),
   so html needs the same treatment for the fix to take effect. Both
   confirmed safe together via the same sticky re-checks. */
html, body { overflow-x: clip; }

/* Live quote indicator - a soft pulsing glow so the real-time feed reads
   as genuinely "live" rather than a static dot that could be lying. */
@keyframes live-pulse {
    0%, 100% { opacity: 1; box-shadow: 0 0 0 0 currentColor; }
    50% { opacity: 0.6; box-shadow: 0 0 4px 1px currentColor; }
}
.live-dot { animation: live-pulse 1.8s ease-in-out infinite; }

/* Globe exchange-open marker - same idea as .live-dot above but a
   noticeably bigger glow throw, since these dots sit on a much busier,
   darker background (a whole rendered sphere) than a plain quote page
   and need more contrast to read as "alive" at a glance/from a
   distance, rather than sharing .live-dot's subtler keyframe used
   everywhere else in the app. */
@keyframes live-pulse-globe {
    0%, 100% { opacity: 1; box-shadow: 0 0 0 0 currentColor; }
    50% { opacity: 0.7; box-shadow: 0 0 8px 2px currentColor; }
}
.live-dot-globe { animation: live-pulse-globe 1.8s ease-in-out infinite; }

/* Global Markets Pulse globe - fades/scales in from globe.js adding
   .is-ready only once the FIRST correct frame (canvas drawn, markers
   positioned) has rendered - never reveals a blank canvas or markers
   still stacked at their default top-left position, which is exactly
   what earlier read as "loading in bits and pieces" while spinning. */
#markets-globe { opacity: 0; transform: scale(0.96); transition: opacity 0.45s ease, transform 0.45s ease; }
#markets-globe.is-ready { opacity: 1; transform: scale(1); }
@media (prefers-reduced-motion: reduce) {
    #markets-globe { transition: opacity 0.2s ease; transform: none; }
}

/* Bull Solitaire - original card art (see bull_solitaire.js's own
   header comment on why this is legally fine to build: Klondike's
   rules aren't owned by anyone, only a specific brand's card art is,
   and every pip/back design here is hand-drawn, not copied). Card size
   uses clamp() so all 7 tableau columns always fit one row on mobile
   without horizontal scrolling, while still getting comfortably larger
   on desktop. */
.bs-card {
    width: clamp(2.4rem, 12vw, 4.4rem);
    height: clamp(3.4rem, 17vw, 6.3rem);
    border-radius: 0.35rem;
    padding: 0;
    border: none;
    background: transparent;
    cursor: pointer;
    transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.bs-card-face {
    width: 100%;
    height: 100%;
    border-radius: 0.35rem;
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
}
.bs-card-front {
    background: #e2e8f0;
    box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.bs-card-back {
    overflow: hidden;
}
.bs-rank {
    position: absolute;
    font-size: clamp(0.55rem, 2.2vw, 0.8rem);
    font-weight: 800;
    line-height: 1;
    font-family: Arial, sans-serif;
}
.bs-rank-tl { top: 0.2rem; left: 0.3rem; }
.bs-rank-br { bottom: 0.2rem; right: 0.3rem; transform: rotate(180deg); }
.bs-pip {
    width: 45%;
    height: 45%;
}
.bs-pile-slot {
    display: flex;
    align-items: center;
    justify-content: center;
}
.bs-empty-slot {
    border: 2px dashed rgba(148, 163, 184, 0.25);
    border-radius: 0.35rem;
    background: rgba(148, 163, 184, 0.04);
}
.bs-selected {
    transform: translateY(-6px);
    box-shadow: 0 0 0 2px var(--color-accent), 0 6px 14px rgba(0,0,0,0.5);
}
.bs-drop-hint {
    border-color: rgba(200, 244, 93, 0.5);
    animation: bs-hint-pulse 1.4s ease-in-out infinite;
}
@keyframes bs-hint-pulse {
    0%, 100% { opacity: 0.6; }
    50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
    .bs-drop-hint { animation: none; }
    .bs-card { transition: none; }
}

/* Bullpen price chart: brief highlight on whichever Range/Bar Size
   button the backend just auto-corrected TO (see chart.js's
   announceAutoCorrection - paired with market_data.resolve_range_and_bar).
   Picking a Range while an incompatible Bar Size was active (or vice
   versa) always resolves cleanly under the hood, but a button silently
   lighting up somewhere else with zero visual acknowledgment reads as
   "confusing"/"broken" even when it's working exactly as designed - this
   pulse plus the text note next to the Bar Size row (#chart-autocorrect-note)
   makes the auto-correction an intentional, visible moment instead of an
   unexplained jump. */
.axis-autocorrect-flash {
    animation: axis-flash-pulse 0.5s ease-in-out 2;
}
@keyframes axis-flash-pulse {
    0%, 100% { box-shadow: none; }
    50% { box-shadow: 0 0 0 3px var(--color-accent); }
}
@media (prefers-reduced-motion: reduce) {
    .axis-autocorrect-flash { animation: none; }
}
#bs-tableau-wrap {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: clamp(0.25rem, 1.2vw, 0.6rem);
}
.bs-tableau-col {
    position: relative;
}

@media (prefers-reduced-motion: reduce) {
    .live-dot { animation: none; }
    .live-dot-globe { animation: none; }
}

/* ---------------------------------------------------------------------
   Landing page animations - hero entrance, scroll-triggered section
   reveals, opt-in card hover-lift, and an ambient background drift.
   Scoped to dedicated lp-* classes (never applied to bare .bg-panel)
   specifically so none of this leaks into the /app dashboard, whose
   data cards aren't meant to feel like "browsable marketing tiles" -
   the dashboard and the landing page are different kinds of surfaces
   and shouldn't share a hover language just because they share a
   color palette. ------------------------------------------------- */

@keyframes lp-fade-up {
    from { opacity: 0; transform: translateY(16px); }
    to { opacity: 1; transform: translateY(0); }
}

/* Hero content cascades in on load, one beat after another. Uses
   nth-child (not nth-of-type) deliberately - the hero mixes <p>, <h1>,
   <div>, <a> as plain sequential siblings, and nth-of-type counts
   position per-tag-name rather than overall DOM order, which would
   silently break the stagger the moment two same-tag items appeared in
   a row. */
.lp-hero-item {
    opacity: 0;
    animation: lp-fade-up 0.7s ease-out forwards;
}
.lp-hero-item:nth-child(1) { animation-delay: 0.05s; }
.lp-hero-item:nth-child(2) { animation-delay: 0.15s; }
.lp-hero-item:nth-child(3) { animation-delay: 0.28s; }
.lp-hero-item:nth-child(4) { animation-delay: 0.41s; }
.lp-hero-item:nth-child(5) { animation-delay: 0.52s; }
.lp-hero-item:nth-child(6) { animation-delay: 0.62s; }

/* Scroll-triggered section reveal - JS (landing.html) adds .is-visible
   via IntersectionObserver as each section enters the viewport. Starts
   visible-by-default philosophy would be wrong here (that's the whole
   point), but the JS always has a no-observer/no-JS fallback that adds
   .is-visible immediately - this can only ever enhance content, never
   permanently hide it. */
.lp-reveal {
    opacity: 0;
    transform: translateY(28px);
    transition: opacity 0.7s ease-out, transform 0.7s ease-out;
}
.lp-reveal.is-visible {
    opacity: 1;
    transform: translateY(0);
}

/* Opt-in hover-lift for landing-page card grids. */
.lp-card {
    transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
}
.lp-card:hover {
    transform: translateY(-4px);
    border-color: rgba(200, 244, 93, 0.4);
    box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
}

/* ==========================================================================
   Landing page ambient background - v2, full rewrite.

   What was here before: a hero-only radial glow, plus three SEPARATE
   per-section ".lp-zone" washes further down the page, each painting
   its own independent lime linear-gradient that faded in from
   transparent and back out again within that one section's own
   height. Reported problem: it read as a series of murky green
   smudges, not a blend - because each wash was a disconnected patch
   with its own fade-in/fade-out edges, any two neighboring zones
   (or a zone next to the animated hero glow, drifting independently
   on its own timer) could easily be at different brightness right at
   their shared seam, so the "seams" this was supposed to remove just
   became more numerous and less predictable instead.

   New approach: ONE single ambient layer for the entire page, fixed to
   the viewport (not scrolled with the document), sitting behind every
   section. Because it's one continuous layer instead of N independent
   per-section patches, there are no seams to blend by construction.
   It's built from several large, heavily-blurred color blobs - using
   all four of the app's real brand hues (Day/Swing/Invest horizon
   colors + the primary lime accent) instead of a single flat green
   tint, which is what made the old version read as a dirty stain
   rather than a premium multi-color glow. Each blob drifts slowly and
   independently (transform-only, compositor-cheap) so the whole thing
   feels alive without ever being distracting - content panels are all
   opaque (--color-panel), so this only shows through the gaps between
   them and behind headings, never fighting with card content.
   ========================================================================== */
.lp-ambient {
    position: fixed;
    inset: 0;
    z-index: -1;
    overflow: hidden;
    pointer-events: none;
}
.lp-ambient-blob {
    position: absolute;
    border-radius: 50%;
    filter: blur(90px);
    opacity: 0.55;
    will-change: transform;
}
.lp-ambient-blob--day {
    top: -12%;
    left: -8%;
    width: 46vw;
    height: 46vw;
    background: radial-gradient(circle, var(--color-horizon-day), transparent 70%);
    animation: lp-ambient-drift-a 26s ease-in-out infinite;
}
.lp-ambient-blob--swing {
    top: 18%;
    right: -12%;
    width: 52vw;
    height: 52vw;
    background: radial-gradient(circle, var(--color-horizon-swing), transparent 70%);
    animation: lp-ambient-drift-b 32s ease-in-out infinite;
}
.lp-ambient-blob--accent {
    top: 46%;
    left: 20%;
    width: 40vw;
    height: 40vw;
    background: radial-gradient(circle, var(--color-accent), transparent 70%);
    opacity: 0.4;
    animation: lp-ambient-drift-c 24s ease-in-out infinite;
}
.lp-ambient-blob--invest {
    bottom: -14%;
    left: -10%;
    width: 50vw;
    height: 50vw;
    background: radial-gradient(circle, var(--color-horizon-invest), transparent 70%);
    animation: lp-ambient-drift-a 30s ease-in-out infinite reverse;
}
.lp-ambient-blob--bull {
    bottom: 4%;
    right: -8%;
    width: 42vw;
    height: 42vw;
    background: radial-gradient(circle, var(--color-bull), transparent 70%);
    opacity: 0.35;
    animation: lp-ambient-drift-b 28s ease-in-out infinite reverse;
}
@keyframes lp-ambient-drift-a {
    0%, 100% { transform: translate(0, 0) scale(1); }
    50% { transform: translate(4%, 6%) scale(1.08); }
}
@keyframes lp-ambient-drift-b {
    0%, 100% { transform: translate(0, 0) scale(1); }
    50% { transform: translate(-5%, 4%) scale(1.06); }
}
@keyframes lp-ambient-drift-c {
    0%, 100% { transform: translate(-50%, -50%) scale(1); }
    50% { transform: translate(-46%, -54%) scale(1.1); }
}

/* Very fine grain overlay on top of the blobs - the classic fix for
   the visible color-banding that large, soft, low-opacity gradients
   otherwise show on a near-black background (distinct concentric
   rings instead of a smooth falloff). A tiny tiled SVG noise texture
   at very low opacity breaks the banding up without being visible as
   "texture" in its own right. */
.lp-ambient::after {
    content: "";
    position: absolute;
    inset: -10%;
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
    opacity: 0.035;
    mix-blend-mode: overlay;
}

@media (prefers-reduced-motion: reduce) {
    .lp-ambient-blob {
        animation: none;
    }
}

/* Bullcade individual game screens - same .lp-ambient technique above,
   reused verbatim (fixed positioning, grain overlay, reduced-motion
   handling), just re-themed per game: one solid color (--tile-color,
   set inline per page - see partials/game_ambient.html) instead of
   the four market-horizon hues, so gaming has its own distinct,
   playful identity separate from the analytical Bullpen/landing pages
   while still feeling like the same living app. Three blobs of the
   SAME color at different sizes/positions/opacities read as depth
   rather than a flat tint - same drift keyframes as .lp-ambient's own
   blobs, just reassigned, so no new animation logic needed. */
.game-ambient-blob--a {
    top: -14%;
    left: -10%;
    width: 52vw;
    height: 52vw;
    opacity: 0.5;
    background: radial-gradient(circle, var(--tile-color), transparent 70%);
    animation: lp-ambient-drift-a 26s ease-in-out infinite;
}
.game-ambient-blob--b {
    bottom: -16%;
    right: -12%;
    width: 48vw;
    height: 48vw;
    opacity: 0.4;
    background: radial-gradient(circle, var(--tile-color), transparent 70%);
    animation: lp-ambient-drift-b 30s ease-in-out infinite reverse;
}
.game-ambient-blob--c {
    top: 38%;
    left: 32%;
    width: 32vw;
    height: 32vw;
    opacity: 0.3;
    background: radial-gradient(circle, var(--tile-color), transparent 70%);
    animation: lp-ambient-drift-c 22s ease-in-out infinite;
}

/* Final CTA backdrop - an original animated glowing chart-line, replacing
   an unlicensed watermarked stock video the user found and wanted to use
   for this same "look how alive the markets are" moment. Same visual
   idea (a bright upward line plus a few floating price readouts on a
   dark background) built from scratch as SVG/CSS instead: zero licensing
   risk, no watermark, a fraction of a video's file size, and crisper at
   any screen size since it's vector. Uses --color-bull (not the brand's
   --color-accent) since this specific shape IS meant to read as "market
   going up", the one case in this codebase where that distinction
   actually matters (see tailwind_input.css's palette comment on why
   brand accent and bullish-score green are deliberately different
   tokens).
   The line "flows" the same way the Global Markets Pulse globe's
   connector arcs do (see static/globe.js's drawFlowArcs) - one
   continuous dash-offset loop - rather than a one-shot draw-in, since this sits behind a
   permanent CTA card a visitor might look at for a while, not a
   scroll-triggered reveal that only needs to happen once. */
.lp-cta-chart-line {
    fill: none;
    stroke: var(--color-bull);
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
    filter: drop-shadow(0 0 6px rgba(61, 220, 151, 0.65));
    stroke-dasharray: 6 4;
    animation: lp-cta-line-flow 10s linear infinite;
}
@keyframes lp-cta-line-flow {
    to { stroke-dashoffset: -200; }
}

/* Floating price readouts - small monospace numbers drifting slowly
   upward and fading in/out near the line, echoing the reference video's
   look without reproducing any of its actual content. Purely
   decorative (aria-hidden) placeholder-looking numbers, not real
   quotes - nothing here claims to be live data, unlike the actual
   quote cards elsewhere on this page. */
.lp-cta-ticker {
    position: absolute;
    font-family: var(--font-mono);
    font-size: 0.7rem;
    font-weight: 700;
    color: var(--color-bull);
    text-shadow: 0 0 8px rgba(61, 220, 151, 0.5);
    opacity: 0;
    animation: lp-cta-ticker-float 6s ease-in-out infinite;
}
@keyframes lp-cta-ticker-float {
    0% { opacity: 0; transform: translateY(6px); }
    15%, 70% { opacity: 0.55; }
    100% { opacity: 0; transform: translateY(-10px); }
}

@media (prefers-reduced-motion: reduce) {
    .lp-cta-chart-line { animation: none; }
    .lp-cta-ticker { animation: none; opacity: 0.35; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
    .lp-hero-item {
        animation: none;
        opacity: 1;
    }
    .lp-reveal {
        transition: none;
        opacity: 1;
        transform: none;
    }
    .lp-card:hover {
        transform: none;
    }
}

/* Printing (Ctrl+P / "Save as PDF") never dispatches real scroll events,
   so IntersectionObserver-gated .lp-reveal sections would otherwise
   print as permanently blank space - force everything visible for this
   output target specifically. The JS-side navigator.webdriver check
   (landing.html) covers the equivalent case for headless screenshot
   tools/crawlers, which don't go through @media print at all. */
@media print {
    .lp-reveal {
        opacity: 1 !important;
        transform: none !important;
    }
}

/* Hero headline horizon-color sweep - "Three Horizons" cycles slowly
   through the exact same Day/Swing/Invest colors (--color-horizon-*)
   used everywhere else in the product (nav toggle, score cards, radar
   chart) instead of sitting in flat white like the rest of the H1.
   This is the very first thing a new visitor's eye lands on, so it's
   worth spending one deliberate animated moment tying the headline
   text itself to the three-horizons concept it's literally naming -
   a visitor who never reads a single word of body copy still sees
   "three colors, one phrase" before scrolling an inch. background-clip
   text with a background-size wider than 100% + a slow position sweep
   reads as a smooth hue traversal, not a jarring hard-cut color swap. */
@keyframes lp-horizon-sweep {
    0%, 100% { background-position: 0% 50%; }
    50% { background-position: 100% 50%; }
}
.lp-hero-horizon-text {
    background-image: linear-gradient(90deg,
        var(--color-horizon-day), var(--color-horizon-swing), var(--color-horizon-invest),
        var(--color-horizon-swing), var(--color-horizon-day));
    background-size: 300% 100%;
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    animation: lp-horizon-sweep 8s ease-in-out infinite;
}

/* Bouncing scroll cue beneath the hero - a small, universally-understood
   nudge that there's more below the fold, since the hero's own content
   is short enough on a tall viewport that a first-time visitor could
   otherwise mistake it for the whole page. */
@keyframes lp-scroll-cue-bounce {
    0%, 100% { transform: translateY(0); opacity: 0.5; }
    50% { transform: translateY(6px); opacity: 1; }
}
.lp-scroll-cue {
    animation: lp-scroll-cue-bounce 2s ease-in-out infinite;
}

/* Numbered step connector for the "why horizons" 3-card flow - a thin
   horizontal line running behind the three horizon cards on desktop,
   reinforcing that these are one continuous decision (Day -> Swing ->
   Invest) rather than three unrelated options. Hidden below the `sm`
   breakpoint where the cards stack vertically and a horizontal line
   would no longer connect anything. */
.lp-flow-line {
    display: none;
}
@media (min-width: 640px) {
    .lp-flow-line {
        display: block;
        position: absolute;
        top: 1.35rem;
        left: 16.5%;
        right: 16.5%;
        height: 2px;
        background: linear-gradient(90deg,
            var(--color-horizon-day), var(--color-horizon-swing), var(--color-horizon-invest));
        opacity: 0.35;
        z-index: 0;
    }
}

@media (prefers-reduced-motion: reduce) {
    .lp-hero-horizon-text {
        animation: none;
        background-position: 50% 50%;
    }
    .lp-scroll-cue {
        animation: none;
    }
}

/* Candle Match card icons - inline SVG injected via JS innerHTML (see
   candle_match.js), scaled to fill most of the card regardless of the
   grid's responsive card size. */
.cm-card svg {
    width: 100%;
    height: 100%;
    max-width: 2.75rem;
    max-height: 2.75rem;
}

/* Mascot cheer - a small celebratory pop used wherever a Golden Cross
   fires in the Trend section (analysis_panel.html). Reuses the same
   logo.svg mascot the nav already shows, just scaled up and given one
   extra "jump for joy" entrance on top of its own built-in idle bob -
   deliberately a ONE-SHOT animation (not looping) so a mascot sitting
   in a data-dense sidebar reads as "good news just happened" rather
   than a distracting perpetual wiggle competing with the numbers. */
@keyframes mascot-cheer-pop {
    0% { transform: scale(0) rotate(-15deg); opacity: 0; }
    55% { transform: scale(1.15) rotate(8deg); opacity: 1; }
    75% { transform: scale(0.95) rotate(-4deg); }
    100% { transform: scale(1) rotate(0deg); }
}
.mascot-cheer {
    animation: mascot-cheer-pop 0.6s cubic-bezier(.34, 1.56, .64, 1) both;
}

@media (prefers-reduced-motion: reduce) {
    .mascot-cheer {
        animation: none;
    }
}

/* Brand mascot hero treatment - reused anywhere the logo gets to be a
   genuine centerpiece instead of a small flat nav icon (login/signup,
   the account-required gate, the landing page hero, Bullcade). The
   blurred spotlight behind it uses a large soft blur radius
   specifically so it fades to nothing with no visible edge (unlike a
   radial-gradient inside a clipped box, which was the original "sharp
   line" bug on the auth pages - a blur this size has no hard boundary
   to begin with, so there's nothing TO clip). Floats on its own slow
   cycle, deliberately a different period than the SVG's own internal
   idle-bob (3.2s) so the two motions don't sync up into one
   mechanical-looking beat - two independent, organic-feeling layers of
   "alive" instead of one loop doing double duty. Deliberately ONE
   shared animation (not a different wobble per page) - varying WHERE
   and how big the mascot appears page to page already reads as
   "dynamic placement"; a different motion style per spot would tip
   into gimmicky rather than feeling like one consistent, polished
   brand mascot. */
@keyframes mascot-hero-float {
    0%, 100% { transform: translateY(0); }
    50% { transform: translateY(-8px); }
}
.mascot-hero-float {
    animation: mascot-hero-float 5s ease-in-out infinite;
}
.mascot-hero-spotlight {
    background: radial-gradient(circle, rgba(200, 244, 93, 0.35), rgba(200, 244, 93, 0.08) 45%, transparent 72%);
    filter: blur(28px);
}

@media (prefers-reduced-motion: reduce) {
    .mascot-hero-float {
        animation: none;
    }
}

/* Bullcade game fullscreen mode (static/fullscreen_toggle.js) - real
   browser Fullscreen API, NOT the chart's CSS-only ".is-fullscreen"
   overlay above - a game benefits from the browser chrome itself
   disappearing too.

   Two different shapes of fullscreen, by game type:

   1) Canvas action games (Bull Run, Bear Blast, Crash Invaders) via
      `.game-hud-bar` / `.game-canvas-wrap` / `.game-touch-controls`
      children - the HUD strip and (mobile-only) touch buttons take
      their natural height, and the canvas wrap is the one `flex: 1`
      element that eats 100% of whatever vertical space is left. This
      is a real "playable area fills the screen" fullscreen, not a
      bigger box around a small centered canvas - the actual complaint
      that prompted this rewrite. The canvas itself stretches to fill
      that wrap via width/height 100%, with object-fit: contain so a
      fixed-resolution canvas (e.g. 800x440) never distorts, just
      scales up with letterboxing on the axis that doesn't match the
      screen's aspect ratio (same tradeoff every fullscreen video
      player makes).

   2) Everything else (DOM-based games with no canvas) just gets the
      base centered flex column below with no special treatment -
      Candle Match additionally scales its own board (see #cm-board
      rule further down) since a memory-match grid benefits from
      genuinely filling the screen the same way a canvas game does;
      Higher or Lower/Trivia/Ticker Hunt get a per-ID reading-width cap
      instead since they're text/quiz UIs, not spatial "playable area"
      games - full-bleed text top-to-bottom would just read worse. */
:fullscreen.game-fullscreen-target,
:-webkit-full-screen.game-fullscreen-target,
.game-fullscreen-target.is-mobile-fake-fullscreen {
    display: flex;
    flex-direction: column;
    justify-content: center;
    height: 100%;
    gap: 0.75rem;
    padding: 1rem;
    background: var(--color-panel);
}
/* The fake mode has no real Fullscreen API backing it (see
   game_mobile_fullscreen.js) - it's `position: fixed` covering the
   whole viewport instead, since that's the one thing guaranteed to
   work even on iOS Safari, which never implemented the Fullscreen API
   on ordinary elements at all. */
.game-fullscreen-target.is-mobile-fake-fullscreen {
    position: fixed;
    inset: 0;
    z-index: 60;
    height: 100dvh;
    /* Now that viewport-fit=cover (base.html) lets this overlay draw
       under the notch/Dynamic Island/home-indicator safe areas, pad
       AT LEAST the normal 1rem there too so the HUD bar/exit button
       never end up physically obscured by that hardware - `max()`
       only adds extra padding on the specific side that actually has
       a safe-area inset (e.g. top on a notched phone in portrait),
       every other side just keeps the plain 1rem from the shared rule
       above. env() safely resolves to 0 on any device with no safe
       area at all, so this is a no-op everywhere else. */
    padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
}
body.game-mobile-fs-lock {
    overflow: hidden;
}

.game-hud-bar,
.game-touch-controls {
    flex: none;
}
/* A little horizontal breathing room between HUD stats - at a phone's
   390px width with 5 items (label + score + lives/rally + wave + best)
   plus this bar's own padding, `justify-content: space-between` alone
   can squeeze items flush against each other with zero gap once they
   wrap, reading as one run-on word ("Score: 20Lives: 3"). One shared
   rule for all games' HUD bars rather than a class edit repeated
   across every template. */
.game-hud-bar {
    column-gap: 0.5rem;
}
/* Every canvas game's own touch handling (drag-to-steer, swipe-to-turn,
   tap-to-jump) needs to fully own touch gestures on the canvas -
   without this, iOS/Android's default pan-to-scroll and
   pinch/double-tap-to-zoom gestures compete with the game for the
   same finger movement, which reads as "controls feel broken/laggy on
   mobile Safari" even though the JS itself is working correctly. This
   is the browser-level fix; game_touch_drag.js's preventDefault calls
   are the JS-level backstop for browsers with partial support. */
.game-hd-canvas {
    touch-action: none;
}
/* The mobile fullscreen exit button (game_mobile_fullscreen.js) is
   absolutely positioned top-right of the whole target, which sits
   directly above the HUD bar's own rightmost stat ("Best: N") -
   without this, the button visually collides with/covers that text
   whenever it's showing (phone + fullscreen only, see its own rule).
   A little unconditional right padding here is harmless even in the
   rare case fullscreen is active but the button itself is hidden
   (desktop fullscreen) - the HUD bar's flex layout just redistributes
   into that space instead of using it, no visible difference there. */
:fullscreen .game-hud-bar,
:-webkit-full-screen .game-hud-bar,
.is-mobile-fake-fullscreen .game-hud-bar {
    padding-right: 3.25rem;
}
:fullscreen .game-canvas-wrap,
:-webkit-full-screen .game-canvas-wrap,
.is-mobile-fake-fullscreen .game-canvas-wrap {
    flex: 1;
    min-height: 0;
    display: flex;
}
:fullscreen .game-canvas-wrap canvas,
:-webkit-full-screen .game-canvas-wrap canvas,
.is-mobile-fake-fullscreen .game-canvas-wrap canvas {
    width: 100%;
    height: 100%;
    object-fit: contain;
    margin: 0 auto;
}

/* Exit button for the mobile fullscreen gate (real OR fake mode) -
   injected once per .game-fullscreen-target by
   game_mobile_fullscreen.js, `hidden` by default and only shown on a
   phone viewport while that specific target is actually fullscreen.
   Absolutely positioned (not a flex child) so its presence in the DOM
   never shifts the HUD bar/canvas layout in the normal, non-fullscreen
   embedded view. */
.game-mobile-fs-exit {
    position: absolute;
    top: 0.5rem;
    right: 0.5rem;
    z-index: 70;
    width: 2.75rem;
    height: 2.75rem;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 9999px;
    background: rgba(10, 13, 19, 0.7);
    border: 1px solid rgba(255, 255, 255, 0.15);
    color: #e5e7eb;
}
.game-mobile-fs-exit svg {
    pointer-events: none;
}

/* Fullscreen vignette - a soft radial darkening toward the canvas's
   edges, purely cosmetic (pointer-events: none, ::after so it can
   never intercept clicks/touches meant for the game underneath). Real
   fullscreen arcade cabinets/game bezels never present a flat, evenly
   lit rectangle - a little edge darkening is what reads as "immersive
   playing field" instead of "a browser tab that got bigger". Scoped to
   :fullscreen only (not the normal in-page view, where the existing
   panel/border chrome already does that framing job) so this never
   fights with the smaller, non-fullscreen canvas's own contrast. */
:fullscreen .game-canvas-wrap,
:-webkit-full-screen .game-canvas-wrap,
.is-mobile-fake-fullscreen .game-canvas-wrap {
    position: relative;
}
:fullscreen .game-canvas-wrap::after,
:-webkit-full-screen .game-canvas-wrap::after,
.is-mobile-fake-fullscreen .game-canvas-wrap::after {
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    background: radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.35) 100%);
}

/* Candle Match's board has no canvas to flex/object-fit - instead size
   the (still-square, still grid-cols-4) board directly off the smaller
   of the two viewport axes so it's always the biggest square that fits,
   growing its 16 cards along with it, rather than staying pinned to the
   page's normal ~600px column width. `:not(.hidden)` matters here: this
   selector's specificity (id + pseudo-class) otherwise beats Tailwind's
   plain `.hidden { display: none }` utility and would force the
   in-progress screen visible even while the start/end screens are the
   ones actually showing. */
:fullscreen #cm-game:not(.hidden),
:-webkit-full-screen #cm-game:not(.hidden),
.is-mobile-fake-fullscreen #cm-game:not(.hidden) {
    flex: 1;
    display: flex;
    flex-direction: column;
    min-height: 0;
}
:fullscreen #cm-board,
:-webkit-full-screen #cm-board,
.is-mobile-fake-fullscreen #cm-board {
    width: min(90vw, 90vh);
    height: min(90vw, 90vh);
    margin: auto;
}

/* Higher or Lower / Trivia / Ticker Hunt - quiz/text UIs, not spatial
   playable areas, so full-bleed top-to-bottom text would read worse,
   not better. Scoped by id (not a generic "no canvas" selector) so it
   never touches Candle Match, which DOES want to fill the screen. */
#hl-root:fullscreen, #quiz-root:fullscreen, #th-root:fullscreen,
#hl-root:-webkit-full-screen, #quiz-root:-webkit-full-screen, #th-root:-webkit-full-screen,
#hl-root.is-mobile-fake-fullscreen, #quiz-root.is-mobile-fake-fullscreen, #th-root.is-mobile-fake-fullscreen {
    padding: 2rem;
}
#hl-root:fullscreen > *, #quiz-root:fullscreen > *, #th-root:fullscreen > *,
#hl-root:-webkit-full-screen > *, #quiz-root:-webkit-full-screen > *, #th-root:-webkit-full-screen > *,
#hl-root.is-mobile-fake-fullscreen > *, #quiz-root.is-mobile-fake-fullscreen > *, #th-root.is-mobile-fake-fullscreen > * {
    width: 100%;
    max-width: 640px;
    margin-left: auto;
    margin-right: auto;
}

/* Keyboard-highlighted search result (see search_keynav.js) - matches
   the existing hover:bg-base treatment exactly so arrow-key navigation
   and mouse hover feel like the same interaction, not two different
   visual languages for "this is the one you're about to pick." */
.search-result-active {
    background-color: var(--color-base);
}

/* ==========================================================================
   Persistent Favorites rail - TradingView-style always-visible
   watchlist docked to the right edge, collapsible via watchlist_rail.js.
   Fixed-position (not part of the normal flex/grid flow) so it never
   has to fight any individual page's own layout, and top offset comes
   from a --rail-top custom property JS sets from the real <nav>
   height (varies per page - some add the horizon toggle strip inside
   <nav>, some don't).

   Content-push vs. overlay: above 1400px there's real spare margin
   outside .page-shell's own max-width clamp (72rem-116rem) for the
   rail to live in without covering anything, so .watchlist-rail-open
   nudges .page-shell over to guarantee that even on ultrawide
   monitors. Below that, the rail behaves as a lightweight overlay
   instead (matches how TradingView's own panel degrades on narrower/
   tablet-ish widths) - never permanently hides page content either
   way, since it's always collapsible with one click/tap. ========== */
.watchlist-rail {
    position: fixed;
    top: var(--rail-top, 64px);
    right: 0;
    bottom: 0;
    width: var(--rail-width, 260px);
    z-index: 20;
    background: var(--color-panel);
    border-left: 1px solid var(--color-border);
    display: flex;
    flex-direction: column;
    transition: transform 0.25s ease;
}
.watchlist-rail.is-resizing {
    /* No transform/width transition mid-drag - a transition fighting a
       mousemove that fires 60+ times/sec makes the edge visibly lag
       behind the cursor instead of tracking it 1:1. */
    transition: none;
    user-select: none;
}
.watchlist-rail-resize-handle {
    position: absolute;
    top: 0;
    left: -3px;
    width: 6px;
    height: 100%;
    cursor: ew-resize;
    z-index: 1;
    touch-action: none;
}
.watchlist-rail-resize-handle:hover,
.watchlist-rail.is-resizing .watchlist-rail-resize-handle {
    background: var(--color-accent);
    opacity: 0.5;
}
.watchlist-rail.is-collapsed {
    transform: translateX(100%);
}
/* WP03B.3C-A fix: watchlist_rail.js decides the correct initial
   collapsed/expanded state (saved localStorage pref, else collapsed
   under WIDE_VIEWPORT=1280px - see that file), but it's loaded with
   `defer`, so it only runs AFTER the raw HTML (which never bakes in
   .is-collapsed server-side) has already painted. On any viewport
   under that same 1280px threshold, that first paint used to show the
   rail fully OPEN at 82vw wide (max-width:640px block below), directly
   on top of the stock verdict, for the entire gap between first paint
   and this deferred script running - and, worse, PERMANENTLY if the
   script ever fails to load/execute at all (ad blocker, CSP, flaky
   network). Keeping the rail off-screen by default below 1280px until
   watchlist_rail.js proves it's alive (by adding .js-rail-ready right
   when it computes the real state) turns "JS never runs" into the far
   safer failure mode of "rail just stays tucked away", not "rail
   permanently occludes the page". Once .js-rail-ready is present, this
   rule steps aside and the normal .is-collapsed class (toggled by the
   user or restored from their saved preference) takes over exactly as
   before - nothing about desktop or post-load mobile behavior changes. */
@media (max-width: 1279.98px) {
    .watchlist-rail:not(.js-rail-ready) {
        transform: translateX(100%);
    }
}
.watchlist-rail-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    padding: 0.9rem 1rem 0.6rem;
    border-bottom: 1px solid var(--color-border);
    flex: none;
}
.watchlist-rail-content {
    flex: 1 1 auto;
    overflow-y: auto;
    min-height: 0;
}
/* Quick-info panel - bottom half of the rail, populated by clicking a
   row (see watchlist_rail.js's delegated click handler + each row's
   companion <template data-rail-detail> in watchlist_rail_rows.html).
   Height is reader-adjustable now (--rail-detail-height, dragged via
   .watchlist-rail-detail-resize-handle, persisted to localStorage) -
   the old fixed 45%/55% flex-basis split couldn't grow to fit the new
   pillar bar chart comfortably on a short viewport without also
   permanently stealing room from the tab list above on a tall one.
   The var defaults to 45% of the rail's own height via a wrapping
   flex-basis fallback so a reader who's never dragged it yet still
   gets the old sane default, not a random min-height sliver. */
.watchlist-rail-detail {
    flex: 0 0 var(--rail-detail-height, 45%);
    min-height: 3rem;
    max-height: 70%;
    overflow-y: auto;
    background: var(--color-base);
    transition: flex-basis 0.2s ease;
}
.watchlist-rail-detail.is-resizing {
    transition: none;
    user-select: none;
}
.watchlist-rail-detail-resize-handle {
    flex: none;
    height: 6px;
    margin-top: -3px;
    position: relative;
    z-index: 2;
    cursor: ns-resize;
    touch-action: none;
    border-top: 1px solid var(--color-border);
}
.watchlist-rail-detail-resize-handle:hover,
.watchlist-rail-detail-resize-handle.is-resizing {
    background: var(--color-accent);
    opacity: 0.5;
}
.watchlist-rail-tabs {
    display: flex;
    flex: none;
    border-bottom: 1px solid var(--color-border);
}
.watchlist-rail-tab {
    flex: 1;
    padding: 0.5rem 0.25rem;
    font-size: 0.65rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--color-slate-500, #64748b);
    border-bottom: 2px solid transparent;
    transition: color 0.15s ease, border-color 0.15s ease;
}
.watchlist-rail-tab:hover {
    color: var(--color-slate-300, #cbd5e1);
}
.watchlist-rail-tab.is-active {
    color: var(--color-accent);
    border-bottom-color: var(--color-accent);
}
.watchlist-rail-toggle {
    position: absolute;
    top: 0.75rem;
    left: -1.75rem;
    width: 1.75rem;
    height: 2.5rem;
    background: var(--color-panel);
    border: 1px solid var(--color-border);
    border-right: none;
    border-radius: 0.5rem 0 0 0.5rem;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--color-accent);
    cursor: pointer;
    transition: transform 0.25s ease, background-color 0.15s ease;
}
.watchlist-rail-toggle:hover {
    background: var(--color-base);
}
.watchlist-rail.is-collapsed .watchlist-rail-toggle svg {
    transform: rotate(180deg);
}
/* UX-01B: bump the peek-tab toggle's touch target to ~44px on real
   touch devices only (pointer:coarse), leaving desktop mouse pointer
   appearance/metaphor exactly as-is per the UX-01A follow-up note -
   this was the one confirmed-but-not-blocking usability item from
   that packet's own Section 6/10. left offset grows in lockstep with
   width so the button's edge still lines up flush against the
   viewport edge instead of leaving a gap or overlapping the rail. */
@media (pointer: coarse) {
    .watchlist-rail-toggle {
        width: 2.75rem;
        height: 2.75rem;
        left: -2.75rem;
    }
    /* UX-01C Section 4: Discover's Follow button is a primary touch
       control (Section 2's own "make Analyze and Follow actions easy
       to find and operate" requirement) - bumped to a real ~44px
       target on touch devices only, scoped to .discover-row-actions so
       favorite_button.html's shared, smaller default sizing on every
       OTHER page it appears on (quote_card.html, top_stocks_table.html,
       etc.) is untouched - a bounded, Discover-specific fix, not a
       site-wide button-size change. gap-2 on the parent (see
       discover_opportunity_rows.html) already keeps this from
       overlapping the adjacent "Set Alert" link. */
    .discover-row-actions button {
        min-height: 2.75rem;
        min-width: 2.75rem;
    }
}
@media (min-width: 1400px) {
    /* Scoped to `main.page-shell` ONLY, not the bare `.page-shell`
       class - that class is also reused by the top <nav>, the horizon
       toggle strip, the mobile nav panel, and the footer (see
       base.html), and this rule used to match ALL of them. Pushing
       every one of those full-bleed bars over just to make room for a
       sidebar they don't actually overlap left a pointless blank gap
       on their right edge - the classic "why is there empty space
       here" bug. Only <main> (the actual scrollable page content)
       needs to make room; the nav/horizon bars stay put now.

       Was previously done via padding-right, which just shrank the
       box's own content area by the rail's width while its margin:
       auto kept centering it on the FULL viewport - meaning the box
       visibly narrowed ("squished") while the newly-freed space behind
       the rail's median position went to waste as a bigger left
       margin instead of ever reaching the content. Recalculating both
       max-width and margin-right off the same --rail-width var fixes
       that: the box shrinks by only as much as it has to, and the
       reclaimed room comes off the right margin (where the rail
       actually is) rather than out of the box itself. min() keeps the
       shrunk ceiling from ever exceeding the fluid clamp() above (so a
       closed-rail page never gets wider just because this rule is
       active), and the arithmetic guarantees margin-left never has to
       go negative (impossible in CSS - it would just silently clamp
       to 0 and the box would overflow into the rail) at any viewport
       width from 1400px up. */
    body.watchlist-rail-open main.page-shell {
        max-width: min(clamp(72rem, 94vw, 180rem), calc(100vw - var(--rail-width, 260px) - 4rem));
        margin-right: calc(var(--rail-width, 260px) + 2rem);
    }
}
@media (max-width: 640px) {
    .watchlist-rail {
        width: 82vw !important;
        box-shadow: -12px 0 32px rgba(0, 0, 0, 0.45);
    }
    .watchlist-rail-resize-handle {
        display: none;
    }
}
@media (prefers-reduced-motion: reduce) {
    .watchlist-rail,
    .watchlist-rail-toggle svg {
        transition: none;
    }
}

/* Bullcade hub tiles - v2, per-game color identity + gamification.
   Each tile now carries its own "--tile-color" custom property
   (inline style, set once per game in games_hub.html's data loop) -
   one shared set of rules below reads that variable instead of 11
   copy-pasted per-game CSS blocks (DRY). Still the same "arcade
   cabinet" lift + glow as before, just now tinted per-game instead of
   every tile glowing the identical brand lime - 11 different games
   glowing 11 different colors reads as "a wall of distinct arcade
   cabinets", which is the actual excitement this was asked for. */
.game-tile {
    position: relative;
    transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
    --tile-glow: rgba(200, 244, 93, 0.12);
}
.game-tile:hover {
    transform: translateY(-3px);
    box-shadow: 0 10px 34px var(--tile-glow);
    border-color: var(--tile-color, var(--color-accent));
}
/* Thin top accent bar per tile - the fastest possible visual read of
   "these are 11 different games", before a visitor even reads a
   title. A pseudo-element (not a real child div) so it never disturbs
   games_hub.html's actual DOM/spacing. */
.game-tile::before {
    content: "";
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 3px;
    background: var(--tile-color, var(--color-accent));
    opacity: 0.8;
    border-radius: 1rem 1rem 0 0;
}
@media (prefers-reduced-motion: reduce) {
    .game-tile:hover {
        transform: none;
    }
}

/* Genre pill - tiny colored tag ("SHOOTER", "MAZE", "QUIZ"...) reusing
   the same --tile-color, so the color-coding established by the top
   accent bar carries through consistently on every small detail of
   the tile rather than being a one-off stripe. */
.game-genre-pill {
    display: inline-block;
    font-size: 0.6rem;
    font-weight: 800;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    padding: 0.15rem 0.5rem;
    border-radius: 9999px;
    color: var(--tile-color, var(--color-accent));
    background: color-mix(in srgb, var(--tile-color, var(--color-accent)) 15%, transparent);
    border: 1px solid color-mix(in srgb, var(--tile-color, var(--color-accent)) 40%, transparent);
}

/* Personal-stat chip ("Your Best: 1,240" / "5-Day Streak") - real data
   read from each game's own existing localStorage best-score key (or
   Ticker Hunt's daily-streak key), never a fabricated number. Starts
   hidden in the DOM and is only unhidden by games_hub.html's small
   inline script AFTER it finds real stored data for that specific
   browser - a brand-new visitor with no play history simply never
   sees one, which is the honest behavior (matches this app's
   established "don't fake trust signals" rule elsewhere). */
.game-stat-badge {
    align-items: center;
    gap: 0.25rem;
    font-size: 0.65rem;
    font-weight: 700;
    color: var(--color-bull);
    background: rgba(61, 220, 151, 0.1);
    border: 1px solid rgba(61, 220, 151, 0.3);
    border-radius: 9999px;
    padding: 0.15rem 0.55rem;
}
/* style.css loads AFTER tailwind_built.css, so a same-specificity
   ".game-stat-badge { display: ... }" rule would win the cascade over
   Tailwind's ".hidden { display: none }" purely by source order,
   showing the badge before games_hub.html's script ever removes
   "hidden". Scoping display to :not(.hidden) instead (specificity
   0-2-0, beats .hidden's 0-1-0 regardless of source order) makes
   Tailwind's hidden utility the one true source of truth for whether
   this badge is visible at all - JS toggling that one class is
   enough, no fighting the cascade. */
.game-stat-badge:not(.hidden) {
    display: inline-flex;
}

/* Arcade marquee title sweep - same "hue traversal across the app's
   real brand colors" technique as the landing page hero's
   .lp-hero-horizon-text, reused here for "BULLCADE" so the page's
   biggest heading feels like a lit-up arcade sign instead of plain
   white text, before a visitor even reaches the colorful tile grid
   below it. */
@keyframes arcade-title-sweep {
    0%, 100% { background-position: 0% 50%; }
    50% { background-position: 100% 50%; }
}
.arcade-title-glow {
    background-image: linear-gradient(90deg,
        var(--color-horizon-day), var(--color-horizon-swing), var(--color-horizon-invest),
        var(--color-bull), var(--color-accent), var(--color-horizon-day));
    background-size: 300% 100%;
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    animation: arcade-title-sweep 10s ease-in-out infinite;
}

/* "Three Horizons." glow for horizon_view.html's card heading - the
   same hue-traversal technique again, but its OWN class (not a direct
   reuse of .lp-hero-horizon-text) so this frequently-rendered
   functional card heading can be tuned independently of the one-time
   landing hero moment, matching how .arcade-title-glow already got
   its own class above for the same reason. Pure day/swing/invest
   3-color gradient (no extra bull/accent hues mixed in like the
   arcade version) - this heading is naming the Day/Swing/Invest
   concept literally, not going for a decorative rainbow-marquee feel,
   so it stays tightly tied to just those three tokens. Renders on the
   Bullpen (/app, every page load) and the landing page's live demo -
   both use this same horizon_view.html partial. */
@keyframes horizon-card-title-sweep {
    0%, 100% { background-position: 0% 50%; }
    50% { background-position: 100% 50%; }
}
.horizon-card-title-glow {
    background-image: linear-gradient(90deg,
        var(--color-horizon-day), var(--color-horizon-swing), var(--color-horizon-invest),
        var(--color-horizon-swing), var(--color-horizon-day));
    background-size: 300% 100%;
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    animation: horizon-card-title-sweep 8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
    .arcade-title-glow {
        animation: none;
        background-position: 30% 50%;
    }
    .horizon-card-title-glow {
        animation: none;
        background-position: 50% 50%;
    }
    .nav-tagline-glow {
        animation: none;
        background-position: 50% 50%;
    }
}

/* Nav tagline ("Three horizons." in base.html's header, next to the
   BULLYEAH wordmark) - same technique and same day/swing/invest-only
   palette as .horizon-card-title-glow above, own class per the same
   "each signature phrase gets its own tunable class" precedent (see
   .arcade-title-glow's comment for why). This is the SMALLEST and most
   frequently-rendered of the three (10px, present in the header of
   nearly every real product page via base.html, not just one hero
   moment or one card) - reuses horizon-card-title-sweep's keyframes
   directly (they're pure background-position percentages, no colors
   baked in, so there's nothing horizon-specific to duplicate) rather
   than declaring a fourth identical @keyframes block. */
.nav-tagline-glow {
    background-image: linear-gradient(90deg,
        var(--color-horizon-day), var(--color-horizon-swing), var(--color-horizon-invest),
        var(--color-horizon-swing), var(--color-horizon-day));
    background-size: 300% 100%;
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    animation: horizon-card-title-sweep 8s ease-in-out infinite;
}

/* Leaderboard podium (partials/leaderboard.html) - top-3 rows pop in
   staggered on render (inline animation-delay per row, see the
   template), and rank #1's medal gets a continuous subtle shine sweep
   so the leaderboard reads as a genuine competitive podium instead of
   a static list with a slightly different icon on row one. Kept as a
   real CSS animation (not a one-shot transition) specifically for
   .medal-gold - it's the one visual reward that should feel ongoing,
   not just "played once on page load and then forgotten." */
@keyframes rank-pop {
    from { opacity: 0; transform: scale(0.92) translateY(4px); }
    to { opacity: 1; transform: scale(1) translateY(0); }
}

.medal-gold {
    position: relative;
    filter: drop-shadow(0 0 3px rgba(250, 204, 21, 0.6));
    animation: medal-shine 2.2s ease-in-out infinite;
}
@keyframes medal-shine {
    0%, 100% { filter: drop-shadow(0 0 2px rgba(250, 204, 21, 0.45)); }
    50% { filter: drop-shadow(0 0 6px rgba(250, 204, 21, 0.9)); }
}

@media (prefers-reduced-motion: reduce) {
    [style*="animation: rank-pop"] {
        animation: none !important;
    }
    .medal-gold {
        animation: none;
    }
}
/* ==========================================================================
   Landing page "See It In Action" feature spotlights - real app
   screenshots (never mockups or invented data, matching this page's
   existing "don't fake trust" discipline) presented as an alternating
   text+screenshot row per feature, each screenshot in a "browser
   chrome" frame (traffic-light dots + a fake address bar) rather than
   a bare cropped image.

   This replaced an earlier 3-up equal-height grid attempt: that layout
   fought the screenshots' own very different native aspect ratios
   (a wide Bullpen card vs. a much taller full chart panel) and caused
   two bugs in a row - CSS Grid stretching every card to the tallest
   one's height (dead space under the shorter images), then a caption
   overlapping real screenshot content once that stretch was removed.
   One-row-per-feature sidesteps both: each row sizes to its OWN image,
   there's no shared height to fight, and the description lives in the
   text column, never overlaid on the image at all.
   ========================================================================== */
.lp-browser-frame {
    border: 1px solid var(--color-border);
    border-radius: 0.75rem;
    overflow: hidden;
    background: var(--color-panel);
    box-shadow: 0 20px 50px -12px rgba(0, 0, 0, 0.5);
    transition: transform 0.25s ease, border-color 0.25s ease;
}
.lp-browser-frame:hover {
    transform: translateY(-4px);
    border-color: rgba(200, 244, 93, 0.35);
}
.lp-browser-frame img {
    display: block;
    width: 100%;
    height: auto;
}
.lp-browser-frame-dot {
    width: 0.6rem;
    height: 0.6rem;
    border-radius: 50%;
    flex-shrink: 0;
}

/* Small hand-drawn-style accent underline - an SVG squiggle (NOT a
   straight CSS border) sitting just under one emphasized word, echoing
   a handwritten signature rather than a mechanical rule line. Used
   sparingly (1-2 spots total on the landing page) as a small human
   touch against otherwise clean, geometric type - never as a general-
   purpose emphasis utility. */
.lp-handwritten-accent {
    display: inline-block;
    position: relative;
}
.lp-handwritten-accent svg {
    position: absolute;
    left: -2%;
    bottom: -0.35em;
    width: 104%;
    height: 0.4em;
    color: var(--color-accent);
    overflow: visible;
}

/* Editorial serif accent font (Fraunces, see tailwind_input.css's
   --font-serif) - italic weight is loaded specifically to pair with
   the existing bold sans headlines, the classic magazine-cover
   "serif italic mixed with bold sans on the same line" technique.
   Just a thin wrapper so call sites don't need to remember the exact
   italic+font-serif combo every time. */
.lp-serif-accent {
    font-family: var(--font-serif);
    font-style: italic;
    font-weight: 600;
}

/* ==========================================================================
   Bullpen full breakdown disclosure (bullpen_disclosure.js) - the
   collapsed-by-default drill-down for the ~11 pillar cards below the
   Bullpen's headline score. Progressive disclosure: a beginner gets
   score + rating + risk + confidence + drivers + the radar chart's
   glanceable shape and can stop there; a reader who wants the full
   per-pillar breakdown (RSI, P/E, margins, sentiment headlines, etc.)
   is one click away, never a separate page. max-height (not
   height/grid-rows) is the animation target because the real content
   height is unknown up front - it varies by asset class (a crypto
   symbol scores fewer pillars, a stock up to 11, BTC adds a 12th cycle section) -
   so it has to be measured (scrollHeight) at animate-time, not assumed.
   See bullpen_disclosure.js for the open/close sequencing this pairs
   with (why closing needs a synchronous reflow before the 0 transition
   can play, why opening un-pins back to max-height:none once settled). */
.bullpen-breakdown {
    overflow: hidden;
    transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.bullpen-breakdown.no-anim {
    transition: none;
}
@media (prefers-reduced-motion: reduce) {
    .bullpen-breakdown { transition: none; }
}

.bullpen-breakdown-toggle {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    width: 100%;
    text-align: left;
    cursor: pointer;
}
.bullpen-breakdown-toggle .bullpen-breakdown-chevron {
    transition: transform 0.25s ease;
    flex-shrink: 0;
}
.bullpen-breakdown-toggle[aria-expanded="true"] .bullpen-breakdown-chevron {
    transform: rotate(180deg);
}
@media (prefers-reduced-motion: reduce) {
    .bullpen-breakdown-toggle .bullpen-breakdown-chevron { transition: none; }
}

/* Price Chart "Chart Settings" disclosure (chart_settings_disclosure.js)
   - same animated-max-height mechanics as .bullpen-breakdown just above,
   collapsing Scale/Bar Size/Indicator behind one toggle so a first-time
   visitor's default chart view is just Chart Type + Range. */
.chart-settings-panel {
    overflow: hidden;
    transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.chart-settings-panel.no-anim {
    transition: none;
}
@media (prefers-reduced-motion: reduce) {
    .chart-settings-panel { transition: none; }
}

.chart-settings-toggle {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    width: 100%;
    text-align: left;
    cursor: pointer;
}
.chart-settings-toggle .chart-settings-chevron {
    transition: transform 0.25s ease;
    flex-shrink: 0;
}
.chart-settings-toggle[aria-expanded="true"] .chart-settings-chevron {
    transform: rotate(180deg);
}
@media (prefers-reduced-motion: reduce) {
    .chart-settings-toggle .chart-settings-chevron { transition: none; }
}

/* ==========================================================================
   Drag-to-reorder (sortable_list.js) - shared between the top-of-page
   Favorites pill bar (index.html) and the persistent watchlist rail's
   own Favorites tab (watchlist_rail_rows.html). One visual language
   for "you can grab this" everywhere it applies, not two.

   The handle is ALWAYS at least dimly visible (not hover-only) - a
   hidden-until-hover affordance is invisible on touch devices, which
   defeats half the point of switching off native HTML5 drag-and-drop
   in the first place. It brightens on hover/focus purely as a hint
   that THIS is the interactive part, not to reveal its existence. ====== */
.drag-handle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0.35rem 0.15rem 0.35rem 0.5rem;
    color: var(--color-slate-600, #475569);
    cursor: grab;
    touch-action: none;
    transition: color 0.15s ease;
}
.drag-handle:hover,
.drag-handle:focus-visible {
    color: var(--color-accent);
}
.is-dragging {
    opacity: 0.55;
    cursor: grabbing;
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
    z-index: 5;
    position: relative;
    will-change: transform;
    /* Transform itself is JS-driven (sortable_list.js drives a live
       translate+scale while dragging, then a settle transition on
       drop) - this only smooths the opacity/shadow "lift" look, which
       rides along with that same drop transition since both change
       (this class gets removed) in the same frame. */
    transition: opacity 0.15s ease, box-shadow 0.15s ease;
}
@media (prefers-reduced-motion: reduce) {
    .is-dragging { transition: none; }
}

/* ==========================================================================
   Watchlist rail live-tick + sort + sparkline + quick-alert (see
   watchlist_rail.js, rail_sparkline_chart.js, _rail_macros.html). ====== */

.rail-sparkline-wrap {
    width: 44px;
    height: 24px;
}

@keyframes rail-flash-up {
    from { background-color: rgba(61, 220, 151, 0.22); }
    to { background-color: transparent; }
}
@keyframes rail-flash-down {
    from { background-color: rgba(255, 107, 122, 0.22); }
    to { background-color: transparent; }
}
.rail-row-flash-up { animation: rail-flash-up 0.9s ease-out; }
.rail-row-flash-down { animation: rail-flash-down 0.9s ease-out; }
@media (prefers-reduced-motion: reduce) {
    .rail-row-flash-up, .rail-row-flash-down { animation: none; }
}

.rail-sort-bar { overflow-x: auto; }
.rail-sort-btn {
    font-size: 10px;
    font-weight: 600;
    padding: 0.15rem 0.5rem;
    border-radius: 9999px;
    color: var(--color-slate-500, #64748b);
    background: transparent;
    border: 1px solid transparent;
    white-space: nowrap;
    transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
.rail-sort-btn:hover { color: var(--color-slate-300, #cbd5e1); }
.rail-sort-btn.is-active {
    color: var(--color-accent);
    background: color-mix(in srgb, var(--color-accent) 14%, transparent);
    border-color: color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.rail-rows-sorted .drag-handle { display: none; }

.rail-alert-details { position: relative; }
.rail-alert-details summary { list-style: none; cursor: pointer; }
.rail-alert-details summary::-webkit-details-marker { display: none; }
.rail-row-alert-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0.3rem;
    color: var(--color-slate-600, #475569);
    transition: color 0.15s ease;
}
.rail-row-alert-btn:hover, .rail-alert-details[open] .rail-row-alert-btn {
    color: var(--color-accent);
}
.rail-alert-form {
    position: absolute;
    right: 0;
    top: 100%;
    z-index: 10;
    margin-top: 0.25rem;
    display: flex;
    align-items: center;
    gap: 0.25rem;
    padding: 0.4rem;
    background: var(--color-panel, #111722);
    border: 1px solid var(--color-border, #1c2431);
    border-radius: 0.5rem;
    box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
    white-space: nowrap;
}
.rail-alert-form select,
.rail-alert-form input {
    background: var(--color-base, #080b10);
    border: 1px solid var(--color-border, #1c2431);
    border-radius: 0.35rem;
    font-size: 11px;
    padding: 0.2rem 0.35rem;
}
.rail-alert-form input[type="number"] { width: 5.5rem; }
.rail-alert-form button[type="submit"] {
    font-size: 11px;
    font-weight: 600;
    padding: 0.2rem 0.5rem;
    border-radius: 0.35rem;
    background: var(--color-accent);
    color: var(--color-base, #080b10);
}
.rail-alert-status { font-size: 10px; }

/* ==========================================================================
   Watchlist category dropdowns (Recently Viewed / Favorites / Magnificent 7
   / Movers & Active / Crypto / Funds - see index.html + watchlist_category_
   dropdown.js). Replaced an always-visible tab-panel-below-the-tabs layout:
   that panel occupied real vertical space (a whole extra row, sometimes
   several rows once Favorites grew) before a visitor ever reached the
   Trade Setup card. Each category is now just a small button; its stock
   pills live in this absolutely-positioned flyout, which overlays the page
   instead of pushing content down, and only appears on hover/focus/click.

   .watchlist-category-panel starts at top:100% of its own OWN trigger
   (not a single shared dropdown), padded-top instead of margin-top so the
   "gap" between button and panel is still part of this element's own
   hit-region - a MARGIN gap would be dead space a mouse has to cross
   without hovering anything, closing the flyout before the pointer ever
   reaches it. visibility (not just opacity) is toggled specifically so a
   closed panel's links are unfocusable-until-opened, matching keyboard tab
   order to what's actually visible.

   .is-open is a plain JS-toggled class (watchlist_category_dropdown.js) for
   touch devices, which have no real :hover, and for explicit click-to-pin-
   open on desktop - layered on TOP of the CSS-only :hover/:focus-within
   reveal below, not a replacement for it. */
.watchlist-category-panel {
    position: absolute;
    top: 100%;
    left: 0;
    z-index: 30;
    padding-top: 0.5rem;
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    transform: translateY(-4px);
    transition: opacity 150ms ease, transform 150ms ease, visibility 150ms ease;
}
.watchlist-category:hover > .watchlist-category-panel,
.watchlist-category:focus-within > .watchlist-category-panel,
.watchlist-category.is-open > .watchlist-category-panel {
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
    transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
    .watchlist-category-panel { transition: none; }
}

/* Explain This vs. card-wide note conflict guard (2026-09) - see
   _macros.html's explain_this()/section_note()/button_tooltip() for
   the full reasoning. Hovering/focusing a glossary term nested inside
   a card that ALSO shows its own hover note (every pillar card in
   analysis_breakdown_detail.html, every Key Metrics tile in
   financials_panel.html) used to pop BOTH tooltips at once - CSS
   :hover unavoidably applies to every ancestor of whatever's actually
   under the pointer, and the card's own note uses a bare group-hover:
   that can't tell "the whole card" apart from "specifically this one
   nested term". explain_this()'s own tooltip already fixed its half
   (a NAMED group, group/explain, so it only ever fires from ITS OWN
   span, never a wider ancestor) - this rule fixes the other half,
   suppressing the ANCESTOR's note while tooltip_conflict_guard.js says
   a nested term is actively hovered/focused. Selector specificity
   (three classes) beats the group-hover utility it's overriding (two
   classes plus :hover) on its own merits - no !important needed, and
   style.css already loads after tailwind_built.css in base.html as a
   second line of defense if that ever changes. */
.bg-base.has-active-explain-term .section-note-tooltip {
    opacity: 0;
    visibility: hidden;
}

/* Trade Desk chart grow-on-breakdown-expand (2026-09) - Pillar Shape +
   Score History (analysis_breakdown_viz.html, LEFT column) grow taller
   when the pillar breakdown (#bullpen-breakdown, RIGHT column) is
   expanded, since that's exactly when the right column runs taller
   than the left and leaves visible empty space beside it instead.
   trade_desk_chart_grow.js sets the actual height via inline style
   (not a class swap) so there's no specificity fight with the
   Tailwind h-56/sm:h-64 utility classes already on these same
   elements (inline style always wins regardless of source order) -
   this rule only supplies the transition itself. Chart.js's own
   responsive:true + maintainAspectRatio:false (every chart in this
   app already has both) plus its resize handling mean the canvas
   redraws in lockstep automatically - no separate "animate the chart
   shape growing" code needed beyond animating this wrapper's height. */
.trade-desk-chart-wrap {
    transition: height 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
    .trade-desk-chart-wrap { transition: none; }
}

/* Conviction Track (2026-09 "fill the Trade Desk gap" follow-up, v4) -
   the BullYeah mascot runs along a Bear-to-Bull track to today's score
   position, in analysis_breakdown_viz.html. --conviction-pct is set
   inline per-render (the score as a 0-100 percentage, clamped so the
   mascot never clips past either track edge); the animation always
   starts from a fixed 4% and eases out to that value, so every fresh
   render (initial load, horizon switch, symbol change) replays the
   "run" - a deliberate re-trigger, not a bug, since htmx's innerHTML
   swap replaces this element entirely each time anyway (a CSS
   transition on a mutated element wouldn't fire here the way it does
   for .trade-desk-chart-wrap above, which is why this uses a keyframe
   animation instead of a transition). */
@keyframes convictionTrackRun {
    from { left: 4%; }
    to { left: var(--conviction-pct, 50%); }
}
.conviction-track-marker {
    animation: convictionTrackRun 900ms cubic-bezier(0.22, 0.9, 0.35, 1) both;
}
@media (prefers-reduced-motion: reduce) {
    .conviction-track-marker { animation: none; left: var(--conviction-pct, 50%); }
}

/* Conviction Track, continuous "alive" motion (v4 follow-up - a static
   track + a marker that only moves once on load read as dead/flat, not
   the "wow" this was built for). Two independent effects, both loop
   forever so the track never looks finished/frozen:

   1. A light sweep drifts across the whole bear/neutral/bull bar,
      loosely evoking a stock ticker/scanner - .conviction-track-bar
      needs position:relative (it's already position:absolute for its
      OWN placement, which establishes a containing block fine) so the
      shimmer's own absolute positioning tracks the bar, not the page.
   2. A soft pulse ring breathes behind the mascot, reusing whichever
      score_bg() tint the template already picked (bg-bull/bear/
      neutral-score's own /15 tone - not a new color) so the ring's
      color always matches the score without this CSS needing to know
      the mood itself. */
@keyframes convictionShimmerSweep {
    0% { transform: translateX(-120%); }
    100% { transform: translateX(320%); }
}
.conviction-track-shimmer {
    position: absolute;
    inset: 0;
    width: 35%;
    background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.45), transparent);
    animation: convictionShimmerSweep 3.4s ease-in-out infinite;
    pointer-events: none;
}
@keyframes convictionPulseRing {
    0% { transform: scale(0.65); opacity: 0.65; }
    75%, 100% { transform: scale(1.55); opacity: 0; }
}
.conviction-pulse-ring {
    position: absolute;
    inset: -8px;
    border-radius: 9999px;
    animation: convictionPulseRing 2.4s ease-out infinite;
    pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
    .conviction-track-shimmer { display: none; }
    .conviction-pulse-ring { animation: none; opacity: 0.35; transform: scale(1); }
}
