    /* BeatVig design tokens — dark only, orange brand, teal = money.
       Orange never appears inside the odds grid (see STYLE decision 2026-09-15). */
    :root {
      /* ---- SURFACE HIERARCHY (2026-09-21 redesign) ----------------------
         Four steps, not two. The old palette had a page ground and one card
         colour, so a modal, a dropdown, a pill and a card all painted the
         same and the site read flat — every surface at the same altitude.
         These four are each ~4-6 points of lightness apart, which is enough
         to read as depth on an OLED phone and still dark enough that the
         board's teal and orange are the brightest things on screen.

         The whole ramp cooled and darkened: the ground went #0B0F14 ->
         #070A0F. A darker ground buys contrast headroom for everything
         painted on it (every text pair below gained ratio, none lost any)
         and it is what lets a 1px hairline read as an edge instead of as
         noise. --bv-bg is also the value <meta name="theme-color"> and the
         header's translucent fill are derived from; both are stated in
         terms of --bv-rgb-bg below so they can never drift from it again. */
      --bv-bg: #070A0F;         /* page ground */
      --bv-surface: #0F141C;    /* card / panel */
      --bv-raised: #172130;     /* pill, price button, active nav */
      --bv-overlay: #1D2837;    /* modal, dropdown menu, sheet */
      --bv-rgb-bg: 7, 10, 15;

      /* TWO BORDER WEIGHTS. --bv-border is the hairline between rows and
         around a resting card; --bv-border-strong is the edge of something
         lifted off the page (a modal, a hovered control, a focused field).
         The modal rule further down used --bv-muted for want of this token
         and got a ~5:1 edge where it wanted ~2:1. */
      --bv-border: #1E2836; --bv-border-strong: #2E3C4F;

      /* TEXT. Three steps, all measured against --bv-surface (#0F141C),
         which is the darkest surface body copy is ever set on:
           --bv-text   #EEF3F8  16.8:1  (body, headings)
           --bv-muted  #93A3B8   7.3:1  (decks, labels, secondary lines)
           --bv-faint  #64748B   3.6:1  DECORATION ONLY, and there is exactly
                       one consumer: .bv-input::placeholder. A placeholder is
                       allowed to sit under 4.5:1 here because every input on
                       the site also carries a real <label> (often sr-only),
                       so the placeholder is never the only thing naming the
                       field — and a placeholder that matches --bv-muted is
                       indistinguishable from a typed value.

                       IT IS NOT A TEXT UTILITY. There is deliberately no
                       `.bv-faint` class to go with it: the first draft of
                       this redesign had one, four surfaces immediately used
                       it for column headers and caption copy, and all four
                       were failing AA. If a third role for this value ever
                       appears, give it its own named token rather than a
                       class anything can reach for. */
      --bv-text: #EEF3F8; --bv-muted: #93A3B8; --bv-faint: #64748B;

      --bv-brand: #F97316; --bv-brand-hover: #FB923C;
      /* The brand at a size small enough to be body copy: #F97316 on
         --bv-surface is 5.2:1 and fine for 14px+ semibold, but the 11px
         eyebrows and inline links needed a lighter step to clear 4.5:1
         with room to spare. --bv-brand-wash backs a brand-tinted chip. */
      --bv-brand-soft: #FDBA74;
      --bv-brand-wash: rgba(249, 115, 22, 0.12);
      --bv-best: #2DD4BF; --bv-up: #4ADE80; --bv-down: #F87171;

      /* ---- RADII ---------------------------------------------------------
         One scale, five steps, and every rounded thing on the site picks
         from it. Before this, pages hand-picked rounded / rounded-lg /
         rounded-xl per call site and neighbouring modules disagreed about
         their own corners by 4-8px, which is most of what "reskin" looks
         like. Cells and chips stay tight (4-8px) so the board still reads
         as an instrument; modules get the generous 14-20px that dates a
         dark UI to this decade rather than to 2016. */
      --bv-r-xs: 0.25rem; --bv-r-sm: 0.5rem; --bv-r-md: 0.75rem;
      --bv-r-lg: 1rem; --bv-r-xl: 1.25rem;

      /* ---- ELEVATION -----------------------------------------------------
         On a near-black ground a cast shadow alone is invisible, so every
         step pairs a shadow with a 1px top highlight — the light-from-above
         cue that actually separates a surface from its background in dark
         UI. Used by .bv-card / .bv-panel / .bv-pop below; never applied to
         anything inside a board row (a shadow on a live cell would repaint
         on every tick for no information). */
      --bv-shadow-sm: 0 1px 2px rgba(0,0,0,.4), inset 0 1px 0 rgba(255,255,255,.03);
      --bv-shadow-md: 0 4px 16px -4px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.04);
      --bv-shadow-lg: 0 24px 48px -12px rgba(0,0,0,.7), inset 0 1px 0 rgba(255,255,255,.05);

      /* ---- TYPE ----------------------------------------------------------
         See the font link in <head> for why these two faces. Both tokens
         carry the full fallback stack, so nothing on the site depends on a
         third-party request completing. */
      --bv-font-ui: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
                    Roboto, "Helvetica Neue", Arial, sans-serif;
      --bv-font-display: "Space Grotesk", "Inter", -apple-system,
                    BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      /* Amber is the CAUTION colour and nothing else — reserved for "this
         number is not settled yet" (KJ 2026-09-16). Distinct from --bv-down,
         which means a price moved against you: an early line is not bad, it is
         unreliable, and conflating the two would misread the board. */
      --bv-warn: #FBBF24; --bv-warn-wash: rgba(251, 191, 36, 0.12);
      /* Teal washes for the deep-linked prop row. Literal rgba rather than
         color-mix() so the highlight exists on every browser that renders
         the board — keep the channels in step with --bv-best. */
      --bv-best-wash: rgba(45, 212, 191, 0.12);
      --bv-best-wash-strong: rgba(45, 212, 191, 0.26);

      /* ---- THE BRAND GLOW (KJ 2026-09-22) --------------------------------
         "maybe we could use the gradient effect as well where its orange in
         one corner and teal in another ... on every card site wide."

         The hero band has carried two radial blooms in exactly these two
         colours since the Phase 7 redesign (shared/_hero) — they were written
         as literal rgba() inline on that one element. These two tokens are
         that effect named, so the card layer and the hero speak the same
         vocabulary and a palette change moves both.

         THE ALPHAS ARE A CONTRAST BUDGET, NOT A TASTE CALL, and they are set
         at the top of what that budget buys. The glow is the BOTTOM of a
         stack that can also carry the hero watermark (14% of a pixel that
         grayscale/contrast caps at #CCC — see .bv-teamwatermark-hero), and
         --bv-muted is the quietest text on any card, at 12px. Measured on the
         worst pixel of that stack — a full-white logo pixel sitting exactly
         on a glow's corner peak:

           --bv-surface alone                  --bv-muted  7.3:1
           + hero watermark, no glow                       5.3:1
           + 7% orange glow + hero watermark               4.73:1
           + 6% teal glow   + hero watermark               4.75:1

         against AA's 4.5:1. The first draft ran 5.5%/5% (4.9:1 and 4.8:1) and
         read as almost nothing on a card; this is the same effect with the
         remaining margin spent on it. There is no more to spend: 10%/9% lands
         the same pixel at 4.4:1, under AA.

         REVISED SAME DAY (KJ, on production): "the first cards I loaded I
         notice has no background image of the team or shading we discussed?!"
         The markup was live; at these alphas it was invisible at arm's length
         on a normal display and normal zoom. THE FIX IS STRUCTURAL, NOT A
         RE-FADE: the watermark below now carries a mask that fades it OUT at
         the card's top and bottom edges — where the only --bv-muted-coloured
         text on the card lives (the eyebrow row, the meta footer) — and holds
         full strength through the vertical middle, which is where the mark
         actually sits once centered on the card (see .bv-teamwatermark). A
         first draft masked HORIZONTALLY instead (peak only in the bled
         sliver past the card's edge) and it was wrong — nearly the whole
         visible logo sits inside the "near the text" 70% of its own width, so
         that draft faded out almost everything a reader could see. The
         vertical version keeps the visible peak intact because the zone it
         avoids was never where most of the mark was. Measured on the worst
         realistic pixel — surface, the masked hero watermark at the most
         generous edge stop a muted line could still reach (0.32 peak opacity
         x 0.20 mask alpha, contrast(0.85) white-point), and a glow corner
         peak:

           --bv-surface alone                        --bv-muted  7.19:1
           + masked hero watermark, no glow                      6.23:1
           + 7.5% orange glow + masked hero watermark             5.65:1
           + 6.5% teal glow   + masked hero watermark              5.50:1

         against AA's 4.5:1 — more margin than the original 4.73/4.75, spent
         on a watermark that is now actually visible rather than on the glow.
         10%/9% (the old "no more to spend" ceiling) still clears AA under
         this mask; 7.5%/6.5% was kept anyway so the glow stays the site's
         quiet accent and the logo stays the loud one.

         VISIBILITY WAS BOUGHT WITH AREA, NOT ONLY ALPHA. The gradients run
         85% x 110% of the card rather than 70% x 90%, so more of the card is
         tinted while the brightest pixel — the one measured above — is
         unchanged. Reach is free; peak is not.

         Why these are a quarter of the hero band's (.30/.22): the hero's
         blooms sit on the page ground BEHIND a band, with no watermark over
         them and no 12px meta lines under them. A card has both.

         REVISED 2026-09-22: this comment previously closed early, right
         above this paragraph, and never reopened — which left the paragraph
         below as literal, unclosed CSS text sitting directly inside the
         :root block. Invalid syntax there made the CSS custom-property
         parser drop the very next declaration, --bv-brand-glow, silently.
         That is why the corner glow never painted on production either: not
         only the watermark's `hidden` attribute (see .bv-teamwatermarks) but
         this stray comment terminator killed --bv-brand-glow's value outright
         (getComputedStyle confirmed it: empty for --bv-brand-glow, a real
         value for --bv-best-glow right after it once the parser resynced).
         Mind this the next time this comment is edited: the two characters
         that close a CSS comment cannot appear anywhere in its prose, even
         inside backticks describing the bug — that mistake reintroduced this
         exact failure once already while writing this very paragraph. One
         continuous comment block fixes it — nothing in the cascade needed to
         change.

         WRITTEN AS A PERCENTAGE, NOT AS A DECIMAL FRACTION, and that is not a
         style preference. This whole stylesheet ships inline in every BeatVig
         page, and the decimal spelling of the orange alpha is character for
         character one of the venue fee coefficients that R3 forbids reaching
         the client. odds_comparison_spec asserts that string is absent from
         the response body, precisely so nobody can start shipping the fee
         schedule to the browser. A decorative alpha spelled the same way
         would have turned that guard into a permanent false positive — and
         then into a deleted assertion. The percentage form is the identical
         colour (CSS Color 4 alpha percentages) and leaves the guard meaning
         what it says. Mind this in the prose here too: do not write either
         alpha as a decimal anywhere in this file. */
      --bv-brand-glow: rgba(249, 115, 22, 7.5%);
      --bv-best-glow: rgba(45, 212, 191, 6.5%);
      --bv-header-h: 3.5rem;
      /* 4rem, up from 3.5rem: the bar carries an icon over its label now.
         <body>'s bottom padding is computed from this token, so the reserved
         space moved with the bar and nothing sits behind it. */
      --bv-tabbar-h: 4rem;
      /* THE PURPLE FOCUS RING (KJ 2026-09-17: "Why is the highlighted state of
         a button purple? That is not in our approved color scheme on site").

         It was not Chrome's default and it was not a missing rule. FTA's
         stylesheet — which BeatVig loads, because both products build from one
         tailwind.css — carries

             html.dark        :focus-visible { outline-color: var(--color-focus-ring) }
             html:not(.dark)  :focus-visible { outline-color: var(--color-focus-ring) }
             html.dark                       { --tw-ring-color: var(--color-focus-ring) }

         and defines --color-focus-ring as FTA's indigo (#4f46e5 / #6366f1).
         BeatVig never set the variable, so every focusable element on this
         host — the books dialog's close button, form inputs, links — inherited
         FTA's indigo. That selector is (0,2,1), so NO amount of adding another
         :focus-visible rule here wins; three attempts at higher-specificity
         overrides all lost to it, which is the tell that the variable, not the
         rule, is the thing to set.

         So: point the shared variable at BeatVig's own token. One line, fixes
         every control at once, uses the mechanism FTA already built instead of
         fighting it, and needs no !important and no specificity games. Teal is
         the site's "money" accent and is already the focus colour the handful
         of hand-written rules below use, so this makes the default match what
         those rules were doing by hand. The declaration is NOT in this :root
         block: FTA defines the variable on `html:not(.dark)` / `html.dark`,
         which at (0,1,1) out-specifies a bare `:root` at (0,1,0), so a :root
         declaration here loses no matter that it is unlayered and later. It
         is emitted just below on the SAME two selectors instead, where equal
         specificity plus later source order wins cleanly — no !important. */
    }
    html:not(.dark), html.dark { --color-focus-ring: var(--bv-best); }
    /* ---- BASE TYPOGRAPHY (2026-09-21 redesign) --------------------------
       The UI face on <body>, so every number, label and reserved line on the
       board is set in Inter and its tabular figures — which is what .bv-num
       has been asking for since Phase 1 while the site was actually rendering
       in whatever the visitor's system stack resolved to.

       `text-rendering: optimizeLegibility` is deliberately NOT here: on a
       board of several hundred short numeric strings it costs measurable
       layout time and buys nothing a price display can see. */
    body {
      background: var(--bv-bg); color: var(--bv-text);
      font-family: var(--bv-font-ui);
      -webkit-font-smoothing: antialiased;
    }
    /* THE DISPLAY FACE IS OPT-IN, VIA .bv-display, AND IS NOT ON BARE h1-h6.
       A blanket `h1,h2,h3 { font-family }` would also have caught the board's
       own headings — the ones sitting inside a fixed-height reserved line —
       and swapping a face under a fixed line-height is exactly how R2 breaks
       silently. Page headers, heroes and section headings opt in by class;
       everything on a live row keeps the UI face. */
    .bv-display {
      font-family: var(--bv-font-display);
      letter-spacing: -0.02em;
      font-weight: 700;
    }
    .bv-fta-mark svg { height: 1rem; width: auto; display: inline-block; vertical-align: middle; }

    /* ---- Shared utilities -------------------------------------------------
       These exist so pages stop repeating
       style="background:var(--bv-surface);border-color:var(--bv-border)".
       Deliberately tiny: colour + surface only. Radius, padding and layout
       stay in Tailwind utilities so pages keep full control (and so nothing
       here ever needs !important to be overridden).
       Loaded after tailwind.css, so a same-specificity Tailwind colour
       utility on the same element would lose — don't pair .bv-card with
       another background/border-colour utility. */
    /* .bv-card GAINED ELEVATION AND NOT ONE PIXEL OF BOX. The shadow token
       is a cast shadow plus a 1px inset top highlight; both paint inside or
       outside the border box without participating in layout, so every card
       on every board is byte-for-byte the size it was (R2). Still NO radius
       and NO padding here, for the reason the original comment gives: this
       block loads after tailwind.css, so a radius declared here would beat
       the caller's own `rounded-*` utility and pages would lose control of
       their own corners. Radius is swept at the call sites instead, onto the
       --bv-r-* scale. */
    .bv-card   { background: var(--bv-surface); border: 1px solid var(--bv-border);
                 box-shadow: var(--bv-shadow-sm); }
    /* THE BRAND GLOW — the whole of KJ's "orange in one corner and teal in
       another", on every card on the site, as .bv-card's own background-image.
       The full rationale (why a background rather than a layer, why this rule
       must stay ABOVE .bv-teamcard, and what .bv-noglow is for) is in the
       BRANDED CARD block further down, next to the watermark it pairs with. */
    /* .bv-glow IS THE SAME DECLARATION, NOT A SECOND ONE — one rule, two ways
       in. A few surfaces are card-shaped but cannot wear .bv-card without
       losing something the class would reset: the two /vig <table>s (whose
       fill is a table background) and the two 2px-bordered plan cards on
       /premium (.bv-card's `border: 1px` would beat their border-2). They get
       the glow by name instead. Their own fill must be stated as
       `background-color:` and not the `background:` shorthand, which would
       reset background-image to none from an inline style nothing can win
       against. */
    .bv-card, .bv-glow { background-image:
                   radial-gradient(85% 110% at 0% 0%, var(--bv-brand-glow), transparent 70%),
                   radial-gradient(85% 110% at 100% 100%, var(--bv-best-glow), transparent 70%); }
    .bv-noglow { background-image: none; }
    @media (prefers-reduced-transparency: reduce) {
      .bv-card { background-image: none; }
    }
    .bv-raised { background: var(--bv-raised); }
    .bv-muted  { color: var(--bv-muted); }
    .bv-text   { color: var(--bv-text); }
    .bv-best   { color: var(--bv-best); }
    .bv-brand  { color: var(--bv-brand); }
    /* A price cell: the flash controller targets [data-price] inside these. */
    .bv-pill   { background: var(--bv-raised); border-radius: var(--bv-r-xs); }
    /* The uppercase muted section label used above every board section. */
    .bv-heading { color: var(--bv-muted); text-transform: uppercase; letter-spacing: 0.05em; }

    /* ---- THE MODULE (2026-09-21 redesign) -------------------------------
       .bv-panel is what a page's content blocks are made of: a card that has
       decided on its own corners and its own elevation, so a page stops
       hand-picking `rounded-lg` here and `rounded-xl` two modules down.

       It is a SEPARATE class from .bv-card rather than an option on it,
       precisely because it DOES set border-radius — the thing .bv-card must
       never do. A caller wanting its own corners keeps using .bv-card; a
       caller that just wants "a module" uses this and gets the site's. */
    .bv-panel {
      background: var(--bv-surface); border: 1px solid var(--bv-border);
      border-radius: var(--bv-r-lg); box-shadow: var(--bv-shadow-md);
    }
    /* The floating layer: dropdown menus, popovers, the books sheet. Darker
       border and a real cast shadow, because the thing under it is a page
       rather than a page background. */
    .bv-pop {
      background: var(--bv-overlay); border: 1px solid var(--bv-border-strong);
      border-radius: var(--bv-r-md); box-shadow: var(--bv-shadow-lg);
    }
    /* A hairline that fades at both ends. Used to close a section without the
       hard full-width rule that makes a dark page look like a settings
       screen. No height of its own — the caller gives it one. */
    .bv-rule {
      border: 0; height: 1px;
      background: linear-gradient(90deg, transparent, var(--bv-border-strong) 18%,
                                  var(--bv-border-strong) 82%, transparent);
    }
    /* The small uppercase label above a module or beside a headline. One
       definition, so the site stops spelling out
       text-[11px] font-bold uppercase tracking-[0.14em] at 30 call sites. */
    .bv-eyebrow {
      font-size: 0.6875rem; font-weight: 700; line-height: 1.2;
      text-transform: uppercase; letter-spacing: 0.14em;
    }

    /* ---- TEAM-BRANDED CARDS (KJ 2026-09-21: "bigger team logos. Maybe even
       the team logo as the background and the card being the team color") ----

       THREE CUSTOM PROPERTIES, SET INLINE PER CARD, AND EVERY VISUAL DECISION
       HERE. Beatvig::OddsHelper#bv_team_accent_style writes --team-color /
       --team-border / --team-shade from Nfl::TeamColors; nothing in Ruby knows
       an opacity, and nothing in this block knows a team. An unknown team (any
       non-NFL sport, an abbreviation the map has not heard of) resolves to
       Nfl::TeamColors::DEFAULT — which IS var(--bv-border) — so the card
       degrades to the neutral surface it rendered before team colours existed,
       with no nil branch anywhere.

       WHY color-mix AND NOT AN OPACITY LAYER. The wash has to sit UNDER the
       card's content, and the card's content is not positioned — an absolutely
       positioned ::before with z-index:auto paints in step 8 of the painting
       order, ABOVE the in-flow text in step 7, so the "subtle wash" would have
       covered every price on the card. A background-image always paints under
       everything in the box, which is the correct layer, and alpha in a
       background-image needs a mixed colour rather than an `opacity` on a
       layer that no longer exists. color-mix has been in every engine since
       2023. A browser without it drops the declaration and gets .bv-card's
       plain fill — a fallback, not a break.

       CONTRAST IS THE CONSTRAINT, AND 16% IS WHERE IT WAS SET. The brightest
       shade in the league (Steelers/Cardinals gold, #FFB612) mixed 16% over
       --bv-surface gives rgb(53,46,26); --bv-muted on that is 5.3:1 and
       --bv-text is far higher, so the quietest text on the darkest card still
       clears AA (4.5:1). The gradient also FADES OUT by 58%, so the strongest
       tint is in the card's top-left corner, where the matchup heading is, and
       the board rows below it sit on very nearly the untinted surface. Do not
       raise these percentages without re-running that pair. */
    .bv-teamcard {
      background-image: linear-gradient(135deg,
        color-mix(in srgb, var(--team-shade, transparent) 16%, transparent) 0%,
        transparent 58%);
    }
    /* TWO TEAMS, TWO EDGES: away on the left, home on the right, which is the
       order the card already names them in and the convention every broadcast
       score cell uses. Both fade to nothing by 45%, so the middle of the card
       — where the prices are — is untinted. */
    /* 16%, THE SAME AS THE SINGLE-TEAM WASH AND FOR THE SAME REASON. It was
       20% for one screenshot: a matchup card's wash covers the price rows at
       the two edges rather than an empty corner, and at 20% the gold teams
       (PIT, ARI, WAS) put --bv-muted at 4.76:1 — over the line, with nothing
       left for a future palette. 16% restores the 5.3:1 the single-team wash
       was measured at. */
    .bv-teamcard-vs {
      background-image:
        linear-gradient(100deg, color-mix(in srgb, var(--team-shade-away, transparent) 16%, transparent) 0%, transparent 45%),
        linear-gradient(260deg, color-mix(in srgb, var(--team-shade-home, transparent) 16%, transparent) 0%, transparent 45%);
    }
    /* THE TOP EDGE — 3px of team colour, split for a matchup. This is the
       "which game is this" cue that works at a glance down a board of cards,
       and it is the BORDER role rather than the primary one precisely because
       a third of the league's primaries are near-black and would read as a gap
       in the card rather than as a mark. Absolutely positioned, so adding it
       moves nothing (R2). */
    .bv-teamedge {
      position: absolute; inset-inline: 0; top: 0; height: 3px;
      background: var(--team-border, var(--bv-border));
    }
    .bv-teamedge-vs {
      background: linear-gradient(90deg,
        var(--team-border-away, var(--bv-border)) 0 50%,
        var(--team-border-home, var(--bv-border)) 50% 100%);
    }
    /* THE LOGO WATERMARK. Large, right-aligned, bled off the card's edge and
       faint enough to be texture rather than an image. Absolutely positioned
       and aria-hidden: it contributes no layout and no accessible name — the
       team's abbreviation and its own linked logomark are both already in the
       card's heading, so this is decoration twice over.

       THE LAYER (.bv-teamwatermarks, shared/_team_watermark). One clipping,
       non-painting box holding the card's marks, so a card gets the treatment
       by adding `isolate` and rendering the partial — and gets it identically
       on all three surfaces that now use it.

         * z-index: -1 with `isolate` ON THE CARD. The card is then the
           stacking context, so its own surface paints first, the marks paint
           on top of it, and everything in flow paints on top of them. No text
           is ever painted over, and the odds-comparison card's stretched
           click overlay stays the topmost hit target — which it would NOT if
           the content had been raised above the marks instead.
         * overflow: hidden HERE AND NOT ON THE CARD. The marks bleed past the
           card's edge and something must clip them; clipping on the card
           would also clip the card's own focus ring (.bv-expand's outline is
           drawn 2px OUTSIDE a box that is already inset: 0), so a keyboard
           visitor would lose the focus indicator on every comparison row.
         * border-radius: inherit, so a mark bleeding into a corner is cut by
           the card's curve rather than squared off across it. */
    .bv-teamwatermarks {
      position: absolute; inset: 0; z-index: -1;
      overflow: hidden; border-radius: inherit;
      pointer-events: none;
    }

    /* ---- THE BRANDED CARD (KJ 2026-09-22) --------------------------------
       "All cards across the site should get the treatment where the
       background is the team logo. And maybe we could use the gradient effect
       as well where its orange in one corner and teal in another. And make it
       a reusable component so its on every card site wide."

       TWO HALVES, AND ONLY ONE OF THEM IS AN ELEMENT:

         THE GLOW is .bv-card's OWN background-image, declared right here —
         orange bleeding out of the top-left corner, teal out of the
         bottom-right, at the alphas the --bv-brand-glow token comment buys.
         THE WATERMARK is .bv-teamwatermarks, an element, because it is an
         <img> of a club and a card has to say which club.

       WHY THE GLOW IS A BACKGROUND AND NOT A LAYER, which is the decision
       that made this "site wide" instead of "wherever someone remembered":

         * IT REACHES EVERY CARD. There are ~70 card-shaped surfaces in
           BeatVig and only a third of them render beatvig/shared/_card; the
           rest write `bv_card_classes(...)` on a link, or `class="bv-card"`
           on a form. A background on .bv-card is on all of them the moment
           this rule exists, with no call site touched and none able to
           forget.
         * IT CANNOT MIS-STACK. background-image paints over
           background-color and under every child, by definition — no
           z-index, no `isolation: isolate` on seventy cards, and therefore
           no risk of trapping a dropdown or a sheet inside a new stacking
           context it used to escape.
         * THE CARD'S OWN CURVE CLIPS IT, free, because a background is
           painted inside the border box. No `overflow: hidden` anywhere,
           which matters: clipping on the CARD would also clip the card's
           focus ring — .bv-expand draws its outline 2px OUTSIDE a box that
           is already inset: 0, so a keyboard visitor would lose the focus
           indicator on every comparison row.
         * IT COSTS NOTHING. No element, no request, no box — a card gains
           the treatment without moving a pixel (R2), and a live price tick
           has nothing here to repaint.

       ONE COLOURED GROUND PER CARD, AND THE CASCADE ENFORCES IT FOR FREE.
       .bv-teamcard / .bv-teamcard-vs set their own background-image (the
       club's 16% wash) and are emitted ABOVE this rule, so on equal
       specificity the club's colours win and the brand glow simply does not
       apply to a card that already has a gradient. That is the design answer
       — a game card wearing the league's two colours AND the site's two
       colours is the "noisy" failure this rollout was told to avoid — and it
       is also the only answer the contrast budget allows: the brightest
       league wash (#FFB612 at 16%) plus the standard 6% watermark already
       puts --bv-muted at 4.55:1, 0.05 above AA, and the glow at half the
       alpha below still drags that pixel to 4.40:1. Same reason .bv-card-pinned
       (the `background:` shorthand) drops it: a pinned row is wearing teal.

       .bv-noglow is the opt-out for a caller that wants the plain surface.

       WHERE THE RULE LIVES IS PART OF THE RULE. The glow is declared with
       .bv-card itself, ABOVE .bv-teamcard, precisely so the club's wash wins
       the equal-specificity tie below. Declared here — after .bv-teamcard —
       it would win instead, and every game card on the site would lose its
       team colours to the site's. Do not move it down. */

    /* .bv-carddecor IS THE WATERMARK'S PRECONDITION, and beatvig/shared/_card
       adds it to any card that renders one. `isolation: isolate` makes the
       card its own stacking context so the mark's z-index: -1 lands ABOVE the
       card's own fill and BELOW every child in flow; without it the mark
       would either vanish behind .bv-card's background or paint over the
       card's text. `position: relative` is what the absolute layer resolves
       against.

       It sets NOTHING ELSE — no radius, no padding, no background. The three
       cards that wrote `relative isolate` as utilities before this existed
       keep working; this is the same two declarations, stated once, for the
       callers that had no reason to know they were required. */
    .bv-carddecor { position: relative; isolation: isolate; }
    /* THE LAYER NO LONGER CARRIES THE `hidden` ATTRIBUTE (REMOVED 2026-09-22).
       It did, and this rule existed to take the display back from it —
       `[hidden]` is Tailwind's escape hatch for `space-y-*` / `divide-y-*`
       (`> :not([hidden]) ~ :not([hidden])`, a margin-top or border-top on
       every child except the first), and putting it on this decorative layer
       kept a card's real first child out of that selector.

       IT ALSO SILENTLY DISPLAY:NONE'D THE LAYER, ON EVERY CARD, EVERYWHERE,
       FROM DAY ONE. Tailwind v4's preflight ships
       `[hidden]:where(:not([hidden=until-found])){display:none!important}` —
       importance always wins over layers, so no unlayered override in this
       file, `!important` or not, could ever win it back (and this repo's
       CLAUDE.md forbids reaching for `!important` ourselves). This rule ran
       right here and never once painted anything; KJ's "no background image
       of the team" report on production was this, not the alphas below.

       shared/_team_watermark now renders `aria-hidden="true"` with no
       `hidden` attribute — decoration stays out of the accessibility tree by
       that alone — and carries the note on why none of the eight call sites
       needed the space-y/divide-y exemption in the first place. */
    /* REVISED 2026-09-22 (KJ, on production): "the first cards I loaded I
       notice has no background image of the team or shading we discussed?!"
       The mark was rendering (two <img>s per card, verified on prod) but at
       6% opacity + grayscale(1) it was texture, not an image — invisible at
       arm's length on a normal display.

       PEAK OPACITY IS UP (0.06 -> 0.28) AND GRAYSCALE IS MOSTLY GONE (1 ->
       0.2), so the club's colour actually reads. That alone would blow the
       contrast budget under any text the mark's bounding box overlaps, so THE
       MASK BELOW IS WHAT MAKES IT SAFE — and it fades VERTICALLY, not
       horizontally. A first draft tried a horizontal mask (opaque only in the
       final 30% of the mark's own width, toward the bled edge) and it was
       wrong: because the mark is centered mid-card and bleeds only ~1.5rem
       past the edge, almost the entire VISIBLE logo sits inside that "near
       the text column" 70%, so that draft faded out nearly everything a
       reader could actually see — the card looked exactly as bare as before.
       Screenshotting caught it before it shipped.

       The card's own layout is what actually separates text from open space:
       the two lines that use --bv-muted (the lowest-contrast text on the
       card) are the eyebrow row and the meta footer, both pinned to the
       card's TOP and BOTTOM edges. Everything between them — the headline,
       the claim/venue chips — either sits on --bv-text (far higher contrast,
       unaffected either way) or inside a chip with its own opaque fill (the
       chip paints over the mark regardless of its strength). So the mask
       fades out only the outer ~22% top and bottom of the mark's own height,
       where a muted line could plausibly sit, and holds full strength through
       the vertical middle, which is where the visible logo actually lives
       once it is centered on the card (`top: 50%`). Reach is now real reach:
       the peak survives, because the zone it needs to avoid was never where
       most of the mark was anyway. See .bv-teamwatermark-hero below for the
       measured worst pixel. */
    .bv-teamwatermark {
      position: absolute; top: 50%; right: -1.5rem; transform: translateY(-50%);
      opacity: 0.28; pointer-events: none; user-select: none;
      filter: grayscale(0.2) contrast(0.85);
      mask-image: linear-gradient(180deg, transparent 0%, #000 22%, #000 78%, transparent 100%);
      -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 22%, #000 78%, transparent 100%);
    }
    /* Not a child selector any more: the marks sit inside the clipping layer
       described above, not directly on the card. */
    .bv-teamcard-vs .bv-teamwatermark { right: -2rem; }
    /* THE HERO WATERMARK — the odds-comparison card (KJ 2026-09-21: "add the
       team logo as the background centered on the right of the card. Make it
       large"). A card with no colour wash of its own can carry a mark that is
       actually legible as a mark, and at 6% on a flat surface it simply was
       not: the request was for a background, not for a rumour of one.

       REVISED 2026-09-22: 14% still read as a rumour on production at normal
       zoom. Now 0.32 peak opacity, grayscale(0.15) (a stronger hint of the
       club's colour than the base mark carries, matching how much larger this
       variant renders), the same vertical mask as .bv-teamwatermark above.

       Measured worst pixel: not the mark's own peak (that lives in the
       vertical middle, which the mask never touches) but the pixel at the
       mask's most generous edge stop a muted line could plausibly still
       reach — 0.32 opacity x 0.20 mask alpha = 0.064 effective, through
       contrast(0.85)'s white-point. On --bv-surface (#0F141C) that puts
       --bv-muted, the quietest text on the card, at 6.23:1 against AA's 4.5 —
       see the --bv-brand-glow token comment above for the same pixel with a
       glow corner stacked on top of it (5.50-5.65:1, comfortably clearing
       AA). */
    .bv-teamwatermark-hero { opacity: 0.32; filter: grayscale(0.15) contrast(0.85); }
    /* Two watermarks on a matchup card: away bled off the LEFT, home off the
       right, mirroring the two edge colours above. The mask is vertical on
       both, so it needs no mirroring for the away side. */
    .bv-teamwatermark-away { right: auto; left: -2rem; }
    /* CONFINED TO THE CARD'S RIGHT SIDE ON THE HERO VARIANT ONLY (KJ
       2026-09-22: "now the text isn't really readable for the per $100
       payout" — the away mark's left bleed on a comparison-row GAME row ran
       directly behind the claim chip and the support line in .bv-cmeta,
       because it is centered mid-card and the vertical mask never touched its
       horizontal reach).
       KJ's original ask for this card was "centered on the RIGHT of the
       card" (see .bv-teamwatermark-hero above), which the away mark's left
       bleed never honoured once a game row carried two marks instead of one.
       This is a card-relative horizontal mask on the CLIPPING LAYER
       (.bv-teamwatermarks, inset: 0 to the card), not on either mark's own
       box, so its 0%/100% stops are the card's own width and "confined to
       the right 40%" means what it says regardless of which mark lands
       where. Scoped with :has() to layers that contain a `.bv-teamwatermark-
       hero` mark, so the plain matchup-card layer on /nfl/games — which has
       no readability collision (its marks sit in the board's price columns,
       not behind a chip) — is untouched, exactly as it shipped.
       Stacked with the per-mark VERTICAL mask already on .bv-teamwatermark:
       a mask on this wrapper composites with a mask on its child by the same
       multiplication the browser already does for nested opacity, so no
       mask-composite trick is needed to combine the two axes. */
    .bv-teamwatermarks:has(.bv-teamwatermark-hero) {
      mask-image: linear-gradient(90deg, transparent 0%, transparent 55%, #000 72%, #000 100%);
      -webkit-mask-image: linear-gradient(90deg, transparent 0%, transparent 55%, #000 72%, #000 100%);
    }
    .bv-teamcontent { position: relative; z-index: 1; }
    /* Reduced motion is not the axis here, but reduced TRANSPARENCY is: a
       reader who has asked for it gets the flat surface and the edge, with no
       wash and no watermark. */
    @media (prefers-reduced-transparency: reduce) {
      .bv-teamcard, .bv-teamcard-vs { background-image: none; }
      .bv-teamwatermark { display: none; }
    }

    /* ---- THE BUTTON FAMILY ----------------------------------------------
       THREE TIERS AND A GHOST, replacing the inline
       style="background:var(--bv-brand);color:#0B0F14" that was copy-pasted
       onto 25 separate controls — every one of which hardcoded the OLD page
       ground as its text colour, so all 25 would have silently gone
       off-palette the moment --bv-bg moved (which this redesign does).

       COLOUR, WEIGHT AND BORDER ONLY. No padding, no radius, no display: the
       call sites already carry their own (w-full, py-3, min-h-[44px],
       rounded-full ...) as Tailwind utilities, and a declaration here would
       beat them on source order and flatten a pill CTA into a rectangle. So
       the family restyles what those 25 controls were saying in colour and
       leaves every one of their boxes exactly as it was.

       Contrast: --bv-bg (#070A0F) on --bv-brand (#F97316) is 8.9:1; the
       secondary's teal-on-surface is 9.4:1. Both clear AA for any size. */
    .bv-btn-primary {
      background: var(--bv-brand); color: var(--bv-bg); font-weight: 700;
      border: 1px solid transparent; box-shadow: var(--bv-shadow-sm);
    }
    .bv-btn-primary:hover { background: var(--bv-brand-hover); }
    .bv-btn-secondary {
      background: var(--bv-raised); color: var(--bv-best); font-weight: 700;
      border: 1px solid var(--bv-border-strong);
    }
    .bv-btn-secondary:hover { border-color: var(--bv-best); }
    .bv-btn-ghost {
      background: transparent; color: var(--bv-text); font-weight: 600;
      border: 1px solid var(--bv-border-strong);
    }
    .bv-btn-ghost:hover { background: var(--bv-raised); border-color: var(--bv-border-strong); }

    /* ---- THE CONTROL FAMILY ---------------------------------------------
       One resting look for every filter chip, tab and segmented option on the
       site. The board already had four near-identical versions of it
       (.bv-venuepill, .bv-feebasis-option, .bv-charttab, .bv-unitswitch) —
       those keep their own geometry, which differs for good documented
       reasons, and this is the one NEW surfaces use so a fifth variant is
       never invented.

       Selected state is background + colour + border AND an aria attribute at
       every call site, never colour alone (WCAG 1.4.1). */
    .bv-chip {
      display: inline-flex; align-items: center; justify-content: center;
      border-radius: 9999px; border: 1px solid var(--bv-border);
      background: var(--bv-surface); color: var(--bv-muted);
      font-weight: 600; white-space: nowrap;
    }
    .bv-chip:hover { border-color: var(--bv-border-strong); color: var(--bv-text); }
    .bv-chip[aria-current="page"], .bv-chip[aria-current="true"], .bv-chip[aria-pressed="true"] {
      background: var(--bv-best-wash); border-color: var(--bv-best); color: var(--bv-best);
    }
    /* THE SEGMENTED GROUP. Chips laid in a trough rather than free on the
       page — the header's unit toggle in general form, so the billing-interval
       switch and any future two-or-three-way choice read as the same object
       instead of as a fresh invention per page.

       The child rules are a DESCENDANT selector (0,2,0) so they beat the bare
       .bv-chip (0,1,0) rules above: a chip in a trough must lose its own
       border and fill, and a Tailwind `bg-transparent` utility could not do
       it — that is one class, same specificity as .bv-chip, and this block is
       emitted after tailwind.css, so .bv-chip would have won. Specificity at
       source, never !important. */
    .bv-segment {
      display: inline-flex; padding: 2px; border-radius: 9999px;
      background: var(--bv-raised); border: 1px solid var(--bv-border-strong);
    }
    .bv-segment > .bv-chip { background: transparent; border-color: transparent; }
    .bv-segment > .bv-chip:hover { color: var(--bv-text); border-color: transparent; }
    .bv-segment > .bv-chip[aria-current="true"], .bv-segment > .bv-chip[aria-pressed="true"] {
      background: var(--bv-overlay); color: var(--bv-best);
      border-color: transparent; box-shadow: var(--bv-shadow-sm);
    }

    /* Form controls on a dark surface (selects on /edges). */
    /* Doubled class selector out-specifies @tailwindcss/forms' input reset
       (which paints inputs white) without resorting to !important. */
    /* The resting fill stepped UP to --bv-raised while the page ground
       stepped DOWN. Before the redesign an input painted --bv-surface on a
       page of --bv-surface cards and simply vanished into whatever it sat on;
       a control has to read as a slot you can type into. */
    .bv-input.bv-input, input.bv-input, select.bv-input {
      background-color: var(--bv-raised); border: 1px solid var(--bv-border-strong);
      color: var(--bv-text); border-radius: var(--bv-r-sm);
    }
    .bv-input::placeholder { color: var(--bv-faint); }
    .bv-input:hover { border-color: var(--bv-muted); }
    .bv-input:focus { outline: 2px solid var(--bv-best); outline-offset: 1px; border-color: var(--bv-best); }

    /* Sticky chrome. The header blurs the board scrolling under it; the
       body reserves room for the fixed bottom tab bar plus the iPhone home
       indicator so the last card is never trapped behind it. */
    /* Stated from --bv-rgb-bg rather than as a second literal, so the header's
       translucent fill can never drift from the ground it is meant to be a
       translucent version of — which is exactly what had happened by the time
       of the redesign. Opacity dropped 0.85 -> 0.72 and the blur went up:
       with a real blur behind it the header reads as glass over a moving
       board instead of as a near-opaque bar, and the board scrolling under it
       stays legible as motion. */
    .bv-header { background: rgba(var(--bv-rgb-bg), 0.72); border-color: var(--bv-border); }
    @supports (backdrop-filter: blur(8px)) or (-webkit-backdrop-filter: blur(8px)) {
      .bv-header { -webkit-backdrop-filter: blur(16px) saturate(160%); backdrop-filter: blur(16px) saturate(160%); }
    }
    /* No-blur fallback: without backdrop-filter a 0.72 fill lets the board
       show through as noise behind the nav, so those browsers get the solid
       ground instead. @supports not, so it is the exception and the modern
       path above stays the plain reading. */
    @supports not ((backdrop-filter: blur(8px)) or (-webkit-backdrop-filter: blur(8px))) {
      .bv-header { background: var(--bv-bg); }
    }
    /* THE NAV LINK. One resting look for every link in the header bar and in
       a header dropdown, replacing the per-link hover:bg-[var(--bv-raised)]
       that each nav item was carrying inline. Colour and background only —
       the call sites keep their own 44px targets and padding. */
    /* A CHIP, NOT AN OVAL (KJ 2026-09-21: "the highlight on the NFL button is
       super weird ... it looks like an oval, and not a button at all"). The
       call sites give every nav item a 44px tap target, which is right and is
       not negotiable; painting a 9999px radius across all 44px of it turned a
       three-letter word into a 44px-tall ellipse.

       TRANSPARENT BLOCK BORDERS + background-clip: padding-box is how the fill
       shrinks WITHOUT the target shrinking with it. The border box is still
       the 44px the call site asked for — box-sizing is border-box, so
       min-h-[44px] counts these — and the padding box the fill is clipped to
       is 28px tall, hugging the text. The horizontal padding is untouched, so
       the chip keeps its breathing room on the left and right, which a
       content-box clip would have eaten.

       The radius is read against the BORDER box and the fill against the
       padding box, so the painted corner radius is 1rem less the 0.5rem
       border = 8px: a rounded rectangle on a 28px chip, which is the shape
       asked for.

       THE SPORT TRIGGERS NO LONGER USE THIS CLASS — see .bv-sportnav below.
       This is now the Admin / Account / Upgrade / Sign in group on the right
       of the bar, which is what the chip geometry is holding up. */
    .bv-navlink {
      color: var(--bv-muted); border-radius: 1rem;
      border-top: 0.5rem solid transparent;
      border-bottom: 0.5rem solid transparent;
      background-clip: padding-box;
    }
    .bv-navlink:hover { color: var(--bv-text); background: var(--bv-raised); }
    /* ACTIVE = TEXT ONLY (KJ 2026-09-21): no fill, no chip — the brand color
       on the word is the whole state. The wash version kept reading as a blob
       whatever the padding. Hover keeps its quiet fill as the pointer cue. */
    .bv-navlink[aria-current="page"] {
      color: var(--bv-brand); font-weight: 600;
    }
    /* THE SPORT TRIGGERS PAINT NOTHING, IN ANY STATE (KJ 2026-09-21:
       "clicking it should only highlight the text. No visible button, because
       the button overlaps the border at the top and bottom and looks awful").

       WHY A SEPARATE CLASS AND NOT .bv-navlink: the hover fill above is what
       he was seeing. A 44px target inside a 3.5rem (42px at this 12px root)
       header has nowhere to put a fill that does not run into the header's
       own top hairline and bottom border — the box is TALLER than the space
       between them. Every previous attempt shrank the painted box to fit;
       this one stops painting. The target keeps all 44px, it is just
       invisible, so there is no geometry left to collide.

       State is carried by TEXT COLOUR alone — muted at rest, full text on
       hover, brand orange when this sport is the one you are on or its menu
       is open — plus the chevron flipping, so open/closed is never stated in
       colour alone (WCAG 1.4.1). The focus ring is deliberately untouched:
       "no visible button" is about resting chrome, not about taking the
       keyboard outline away. */
    .bv-sportnav { color: var(--bv-muted); background: none; border: 0; }
    .bv-sportnav:hover { color: var(--bv-text); background: none; }
    .bv-sportnav-on, .bv-sportnav[aria-expanded="true"] {
      color: var(--bv-brand); font-weight: 600;
    }
    .bv-sportnav-chev { transition: transform 120ms ease; }
    .bv-sportnav[aria-expanded="true"] .bv-sportnav-chev { transform: rotate(180deg); }
    @media (prefers-reduced-motion: reduce) {
      .bv-sportnav-chev { transition: none; }
    }
    /* A menu row inside a header dropdown: square corners at the panel's own
       edge radius, full-bleed hover. */
    .bv-menuitem { color: var(--bv-text); }
    .bv-menuitem:hover { background: var(--bv-raised); color: var(--bv-best); }
    .bv-body { padding-bottom: calc(var(--bv-tabbar-h) + env(safe-area-inset-bottom, 0px)); }
    @media (min-width: 1024px) { .bv-body { padding-bottom: 0; } }
    /* THE BOTTOM BAR IS GLASS TOO, and for the same reason the header is: it
       sits over a board that scrolls under it, and a solid bar reads as the
       page ending 56px early. Same fill and same fallback as .bv-header so
       the two pieces of chrome are recognisably one material. */
    .bv-tabbar {
      background: rgba(var(--bv-rgb-bg), 0.82); border-color: var(--bv-border);
      padding-bottom: env(safe-area-inset-bottom, 0px);
    }
    @supports (backdrop-filter: blur(8px)) or (-webkit-backdrop-filter: blur(8px)) {
      .bv-tabbar { -webkit-backdrop-filter: blur(16px) saturate(160%); backdrop-filter: blur(16px) saturate(160%); }
    }
    @supports not ((backdrop-filter: blur(8px)) or (-webkit-backdrop-filter: blur(8px))) {
      .bv-tabbar { background: var(--bv-surface); }
    }
    /* 44px minimum tap target (WCAG 2.5.5 / iOS HIG). The bar itself grew to
       --bv-tabbar-h (4rem) to hold an icon over its label; the token is what
       <body>'s bottom padding is computed from, so raising it moved the
       reserved space with it and nothing is trapped behind the bar. */
    .bv-tab { min-height: 44px; position: relative; color: var(--bv-muted); }
    .bv-tab[aria-current="page"] { color: var(--bv-brand); }
    /* The active mark is a short bar at the TOP edge of the tab, which is the
       convention every current mobile product uses and is legible at a glance
       in a way a colour change alone is not. Absolutely positioned, so adding
       it moves nothing; the tab also carries aria-current, so the state is
       never stated in colour or in decoration alone (WCAG 1.4.1). */
    .bv-tab[aria-current="page"]::before {
      content: ""; position: absolute; top: 0; left: 50%; transform: translateX(-50%);
      width: 1.75rem; height: 2px; border-radius: 0 0 2px 2px;
      background: var(--bv-brand);
    }
    .bv-tab svg { width: 1.25rem; height: 1.25rem; }
    /* Anchored headings clear the sticky header when jumped to. */
    :target { scroll-margin-top: calc(var(--bv-header-h) + 0.5rem); }

    /* ---- Stretched-link cards --------------------------------------------
       A whole card that navigates, WITHOUT an <a> inside an <a>. The primary
       link's ::after covers the card; the card's other links (player name,
       team logomark) are raised above that overlay and keep working, for the
       mouse and for the keyboard alike.

       Stacking order matters more than it looks: .bv-stretch must sit in a
       LOWER stacking context than its sibling links, or its overlay (painted
       inside its own context) covers them and the nested links go dead.

       Because the clickable box (the card) and the focusable element (the
       link) are no longer the same box, the CARD carries the focus ring. */
    .bv-linkcard { position: relative; }
    /* The nested links (player, team) sit ABOVE the overlay so they keep their
       own destinations. */
    .bv-linkcard a:not(.bv-stretch) { position: relative; z-index: 2; }
    /* The primary link must stay POSITION: STATIC. An absolutely positioned
       ::after resolves against its nearest POSITIONED ancestor — so making the
       link itself `relative` sized the overlay to the link's own little box
       (the "+8.74%" text) instead of the card, and the card was not clickable
       at all. Static here means the overlay's containing block is .bv-linkcard,
       which is the whole point of the pattern. */
    .bv-linkcard .bv-stretch { position: static; }
    .bv-linkcard .bv-stretch::after { content: ""; position: absolute; inset: 0; z-index: 1; }
    .bv-linkcard:focus-within { outline: 2px solid var(--bv-best); outline-offset: 2px; }
    .bv-linkcard a:focus-visible { outline: 2px solid var(--bv-best); outline-offset: 2px; }
    /* The all-books disclosure used to live INSIDE a stretched-link card on
       the odds comparison board (that card is now .bv-expandable below, but
       the rule stays: any disclosure inside a stretched-overlay card needs
       it), and the overlay covered it: elementFromPoint at
       the centre of the chevron returned the .bv-stretch anchor, so the button
       swallowed every click and the panel never opened. Exactly the dead
       nested-link problem the rule above exists for, so it gets the same fix
       and the same z-index — the control and its panel sit above the overlay.
       Verified by hit-testing the button's centre, not by reading the markup:
       an inert control renders perfectly. */
    .bv-linkcard .bv-disclosure,
    .bv-linkcard [data-bv-disclosure-panel] { position: relative; z-index: 2; }

    /* ---- Click-to-expand cards -------------------------------------------
       The comparison board's cards and the games board's price rows do NOT
       navigate: a click anywhere on them opens the all-books panel, because
       the venue list is what the comparison is for (KJ 2026-09-17).

       Same geometry as the stretched link above, one button instead of one
       anchor. The control is a SIBLING of the card's contents, never a
       wrapper — nesting the player/team/venue links inside a button would be
       invalid HTML and would make them unreachable by keyboard. It is
       absolutely positioned, so it takes no space in a flow card or inside
       the price row's fixed grid tracks, and adding it moves nothing.

       Cursor is a pointer because the box IS clickable; it no longer reads as
       a link to a detail page because there is no longer a detail page to go
       to. */
    .bv-expandable { position: relative; }
    .bv-expand { position: absolute; inset: 0; z-index: 1; cursor: pointer;
                 background: transparent; border: 0; padding: 0; }
    /* Everything interactive inside sits ABOVE the overlay and keeps its own
       destination: the player name, the team logomark, the venue badge, the
       chevron, and the panel once it is open. */
    .bv-expandable a,
    .bv-expandable button:not(.bv-expand),
    .bv-expandable [data-bv-disclosure-panel] { position: relative; z-index: 2; }
    /* The focus ring is the overlay's own box, which is the card. */
    .bv-expand:focus-visible { outline: 2px solid var(--bv-best); outline-offset: 2px; }

    /* ---- Deep-linked prop row --------------------------------------------
       The row an edge card pointed at. Painted on the CELLS, not the <tr>:
       under border-collapse a row-level background/box-shadow is unreliable,
       and the cells are what the visitor is actually reading. Background and
       inset shadow only, so the row's box is unchanged and nothing moves.
       The one-shot pulse is opt-out for anyone asking for reduced motion. */
    .bv-row-pinned > td { background: var(--bv-best-wash); }
    .bv-row-pinned > td:first-child { box-shadow: inset 3px 0 0 0 var(--bv-best); }
    @media (prefers-reduced-motion: no-preference) {
      .bv-row-pinned > td { animation: bv-row-pinned-pulse 1.6s ease-out 1; }
    }
    @keyframes bv-row-pinned-pulse {
      0%   { background: var(--bv-best-wash-strong); }
      100% { background: var(--bv-best-wash); }
    }
    /* The CARD form of the same treatment, for board rows that are cards
       rather than table rows (the Prop Edge Tool, the venue picks pages).
       Emitted after .bv-card so the wash wins on equal specificity — no
       !important. Border colour and the inset rule only, so the card's box is
       byte-for-byte the size it was and nothing on the page moves. Same
       keyframes as the table row: one pulse, one definition. */
    .bv-card-pinned {
      background: var(--bv-best-wash);
      border-color: var(--bv-best);
      box-shadow: inset 3px 0 0 0 var(--bv-best);
    }
    @media (prefers-reduced-motion: no-preference) {
      .bv-card-pinned { animation: bv-row-pinned-pulse 1.6s ease-out 1; }
    }

    /* ---- Price row columns ------------------------------------------------
       Every price cell lays out on the SAME grid, so the number, the book and
       the disclosure control sit at identical x positions down a card and
       across its two-column grid:

         outcome label | price | book | disclosure
         minmax(0,1fr) | fixed | fixed| fixed

       The price and book tracks are FIXED, not content-sized. Content-sized
       tracks are what put "-104 Lowvig" and "+208 Novig" at different
       positions on neighbouring rows: both the number and the venue name are
       variable width, and the logomark is present for some venues and absent
       for others. Fixed tracks plus tabular figures (.bv-num) make a row's
       geometry independent of what is in it, so a book with no artwork does
       not shift its row relative to one that has it — the badge reserves the
       logo box either way (shared/_book_badge, slot:).

       The three column shapes are separate classes rather than one class with
       a variable template because a CSS custom property cannot supply a
       repeat() count. .bv-has-toggle is emitted AFTER .bv-has-book and has
       the same specificity, so a row carrying both gets four tracks — order,
       not !important, decides it. */
    .bv-num { font-variant-numeric: tabular-nums; font-feature-settings: "tnum" 1; }
    .bv-pricerow {
      --bv-price-w: 3rem; --bv-book-w: 4.5rem;
      display: grid; align-items: center; column-gap: 0.5rem;
      grid-template-columns: minmax(0, 1fr) var(--bv-price-w);
    }
    /* The games board: 44px row, the price is the largest thing on it.
       PHASE 6 LEFT THESE WIDTHS ALONE, and that was the second attempt: the
       price is a bordered button now and widening the track to pay for its
       padding looked correct in isolation and wrong on the board, because
       this row's flexible track is an outcome LABEL ("NYG +3.5", "LAR -7")
       rather than a sentence, and 0.75rem off it truncated the line itself.
       The button's padding is paid for out of the price's TYPE instead
       (shared/_price_pill's price_classes), which costs a reader nothing. */
    /* 2026-09-21: the price track takes 0.5rem and the book track gives it,
       so the fixed total is 0.25rem SMALLER than before and the label track
       (the 1fr) gains rather than loses — which is what the Phase 6 note below
       was protecting. The reason is the movement line, which shares this track:
       at 4rem "Open +295" truncated to "Open +2…" on exactly the rows with a
       four-digit open, so a card showed the line on some rows and an ellipsis
       on others. Venue names already truncate and lose 8px gracefully; a
       half-printed number does not. */
    .bv-pricerow-lg { --bv-price-w: 4.5rem; --bv-book-w: 5.5rem; }
    .bv-pricerow.bv-has-book {
      grid-template-columns: minmax(0, 1fr) var(--bv-price-w) var(--bv-book-w);
    }
    .bv-pricerow.bv-has-toggle {
      grid-template-columns: minmax(0, 1fr) var(--bv-price-w) var(--bv-book-w) 1.75rem;
    }
    /* THE ODDS-COMPARISON ROW USED TO TAKE TWO MORE TRACKS HERE — a widened
       one for its "+11.4pp" headline and a fifth, breakpoint-conditional one
       for the fair value. Both are gone with the 2026-09-21 card redesign:
       that card is off this grid entirely and composes its own zones (see the
       .bv-ccard block far below, and shared/_comparison_row's header for why).
       What is left here is the games board and the prop tables, which are line
       pickers where a shared column grid is the right answer. */
    /* The edge card's fair-value line. Reserved on EVERY card whether or not
       the row carries a fair value, so a board of edge cards is the same
       height per card and nothing moves when one row happens to be scored
       and its neighbour is not (R2). */
    .bv-fairnote { height: 0.875rem; line-height: 0.875rem; }
    /* The struck-through raw edge on an edge card (Phase 4, roadmap §2.1).
       Reserved on EVERY card whether or not the fee ate half the edge, or a
       list of cards becomes a staircase the moment one row qualifies (R2). */
    .bv-feeline { height: 0.875rem; line-height: 0.875rem; }
    /* The small line under the headline ("better") and under the book badge
       (the demoted American price). Fixed height, like .bv-rownote, so a
       stack is the same height on every row and nothing moves when a value
       changes width. */
    .bv-subnote {
      height: 0.875rem; line-height: 0.875rem;
      font-size: 0.6875rem; font-weight: 600;
    }
    /* The advantage line under an outcome label ("+2.4pp better").
       A FIXED height, and it lives in the label track: a row that has nothing
       to say reserves the same 0.875rem and stays exactly as tall as the row
       beside it, so no card re-flows when one outcome happens to have a market
       to beat. Teal is --bv-best, the money colour; the word "better" is in
       the text, so the meaning never depends on seeing the colour. */
    .bv-rownote {
      display: block; height: 0.875rem; line-height: 0.875rem;
      font-size: 0.6875rem; font-weight: 600;
    }
    /* Per-book shortfall in the expanded all-books panel ("-4.5% less"). It
       reuses the row's BOOK track, which the panel otherwise leaves empty, so
       the panel gains a column of detail without gaining a column. Muted, not
       red: --bv-down is reserved for a price that moved against you. */
    .bv-rowgap { font-size: 0.625rem; line-height: 1; color: var(--bv-muted); }

    /* ---- PHASE 6 — THE CATEGORY-LEADER ROW -------------------------------
       Phases 1-5 changed what the board SAYS. This changes what it LOOKS
       like, and it changes nothing else: every data-* hook, every aria
       attribute and every string on these surfaces is byte-for-byte what
       Phase 5 shipped. The whole phase is this block plus the class strings
       that reference it.

       Three shapes, and they are the three the category leaders use:

         THE EDGE CHIP (.bv-edgechip) — Outlier and OddsJam make the edge a
         filled rounded pill and it is the row's visual anchor. Ours is the
         teal wash plus a 1px teal ring, both already tokens, on the span the
         live reduction rewrites by textContent — so the chip survives a tick
         without the JS knowing it exists.

         THE PRICE BUTTON (.bv-cellbox + .bv-pricebutton) — Covers renders a
         price as a bordered, rounded, button-like cell. Ours is a 1px
         --bv-border ring on --bv-surface, which on the raised pill reads as
         a recessed control rather than as loose text. It carries the venue's
         logomark (shared/_book_badge, unchanged) directly above-left of the
         number, which is where the leaders glue it.

         THE QUIET CELL (.bv-cellbox alone) — the fair-value track and the
         matrix's non-price columns get the same box with no fill, so the
         grid reads as a set of cells rather than as columns of text.

       WHY A RING AND NOT A BORDER, EVERYWHERE HERE. An inset box-shadow
       paints INSIDE the element's existing box. A border would add 2px to
       every cell it landed on and move every row and column around it — and
       on the matrix the live reduction toggles .bv-best-cell mid-tick, so a
       border there would re-flow the grid on a price change. R2 is absolute
       and this is how it is kept: nothing in this block changes an element's
       box, only what is painted in it.

       AND NOTHING HERE TOUCHES A FIXED TRACK'S WIDTH RULE (R1). The padding
       below is INSIDE tracks whose widths are set above and are sized to the
       widest unit; the two --bv-book-w / --bv-fair-w bumps further down are
       fixed values at a fixed breakpoint, not content-sized, so a unit switch
       still cannot move a column. */
    .bv-edgechip {
      display: block; border-radius: 9999px;
      padding: 0.0625rem 0.375rem;
      background: var(--bv-best-wash);
      box-shadow: inset 0 0 0 1px var(--bv-best);
    }
    /* NO `display` DECLARATION, deliberately. Every caller of .bv-cellbox
       brings its own — .bv-venue is a flex column, a matrix cell is a block —
       and this block is emitted AFTER tailwind.css, so a `display` here would
       win on equal specificity and silently collapse whichever of them it
       disagreed with. */
    .bv-cellbox {
      border-radius: 0.3125rem; padding: 0.125rem 0.25rem;
      box-shadow: inset 0 0 0 1px var(--bv-border);
    }
    /* 0.25rem of inline padding and no more, at every width. The ring sits
       inside FIXED tracks that were sized before it existed, and a wider box
       is paid for by whatever is in the track: at 0.375rem the games board's
       own "Open -292" stopped fitting its price button. 4px is enough to
       stop a number touching its ring and is cheap enough that no track had
       to be re-measured for it. */
    .bv-pricebutton { background: var(--bv-surface); }
    /* ON THE MATRIX, A BUTTON IS BUTTON-SIZED. A <table> hands each column
       its share of the full width, so an unconstrained ring stretched a
       "-190" across 150px and read as a banner rather than as a control —
       the reference grids run these at roughly 60-110px. Capped and pushed
       to the right edge of its cell, so the numbers still share an x
       position down the column, which is the whole reason they are tabular.
       A max-width, never a width: the cell is still free to be narrower at a
       small viewport, and nothing here can make one taller than another. */
    .bv-matrix .bv-pricebutton { max-width: 6.5rem; margin-left: auto; }
    /* THE VENUE'S PRICE IS THE SECOND ANCHOR, so it steps up from .bv-subnote
       (11px) to the size the reference boards give it. Still a FIXED-height
       line, and still reserved on every row by the same `book_price` key, so
       the stack is one height per board (R2). */
    .bv-bookprice {
      height: 1.125rem; line-height: 1.125rem;
      font-size: 0.8125rem; font-weight: 700;
    }
    /* BEST PRICE = A GREEN RING ON THE BUTTON PLUS A WASH BEHIND IT, which is
       Covers' treatment and is now the one treatment: the wash stays on the
       <td> (which is what comparison_rows_stream toggles .bv-best-cell on,
       by cell, unchanged) and the ring moves onto the button inside it, so
       the mark lands on the object a reader is actually comparing. The
       descendant selector keeps the JS contract exactly as it was — it
       toggles one class on one cell and the ring follows. */
    .bv-best-cell .bv-cellbox {
      box-shadow: inset 0 0 0 1px var(--bv-best);
      background: var(--bv-best-wash-strong);
    }


    /* ---- THE BILINGUAL LAYER (spec §2.0 P1/P2/P3/P9, §2.5 M1) -------------

       ONE DOM, THREE FORMATS, CSS-TOGGLED. Every price ships American,
       implied probability and binary-contract cents at once
       (shared/_price_value); the class on <html> reveals exactly one. No
       refetch, no re-render, no server-side unit branch.

       NO !important, BY SPECIFICITY AT SOURCE: `.bv-u` is one class, the
       reveal is two. `inline`, not `block` as the spec sketches it, because
       this span also appears mid-sentence on /how-to-win/:book and inside an
       already-blocked .bv-subnote — the containing element decides the box,
       which is exactly what keeps R1 (below) a property of the TRACK rather
       than of the number.

       R1 — WIDTH IS THE RISK, AND THE ANSWER IS THAT NONE OF THESE RULES
       TOUCHES WIDTH. "+138", "41.9%" and "42¢" are different widths. Every
       track that holds one is FIXED and already sized to the widest of them
       (--bv-price-w / --bv-book-w / .bv-pricestack above), so a row's box is
       identical in all three units at 390px, 640px and 1024px. The invariant
       that keeps it true as this file grows: NO .bv-unit-* selector anywhere
       may set width, min-width, max-width, grid-template-columns, padding,
       margin, font-size or letter-spacing. spec/requests/beatvig/
       odds_format_toggle_spec.rb parses this block and fails if one does. */
    .bv-u { display: none; }
    .bv-unit-american .bv-u-american,
    .bv-unit-prob     .bv-u-prob,
    .bv-unit-cents    .bv-u-cents { display: inline; }

    /* The unit toggle: three radios rendered as one segmented control. The
       inputs are visually hidden but focusable and in the accessibility tree —
       never display:none, which takes them out of the tab order. */
    /* Redesign: the control sits ON --bv-raised rather than on nothing, so
       the segmented control reads as one object with a selected segment
       inside it instead of as three loose words in a ring. The selected
       segment steps UP to --bv-overlay against that trough — the standard
       inset-track / raised-thumb reading, and the only way a segmented
       control says "selected" without relying on its label colour. */
    .bv-unitswitch { display: inline-flex; border: 1px solid var(--bv-border-strong);
                     background: var(--bv-raised);
                     border-radius: 9999px; overflow: hidden; padding: 2px; }
    .bv-unitswitch input { position: absolute; width: 1px; height: 1px;
                           padding: 0; margin: -1px; overflow: hidden;
                           clip-path: inset(50%); white-space: nowrap; }
    .bv-unitswitch label { display: inline-flex; align-items: center;
                           justify-content: center; min-width: 2.25rem;
                           min-height: 1.75rem; padding: 0 0.5rem;
                           font-size: 0.6875rem; font-weight: 700;
                           color: var(--bv-muted); }
    .bv-unitswitch label { border-radius: 9999px; }
    .bv-unitswitch input:checked + label {
      background: var(--bv-overlay); color: var(--bv-best);
      box-shadow: var(--bv-shadow-sm);
    }
    .bv-unitswitch input:focus-visible + label { outline: 2px solid var(--bv-best);
                                                 outline-offset: -2px; }
    /* THE SAME FIELDSET, STACKED, INSIDE THE COMPACT TOGGLE'S POPOVER
       (shared/_unit_toggle, variant: :menu). No trough and no pill: in a
       popover the panel IS the container, and a second border inside it reads
       as a control inside a control. Rows are 44px because a menu row is a tap
       target, which a 28px segment in a three-abreast group is not expected to
       be.

       The word appears only here. It is display:none everywhere else, so the
       segmented group in a desktop header is byte-for-byte what it was. */
    .bv-unitword { display: none; }
    .bv-unitswitch-stack {
      display: flex; flex-direction: column; border: 0; background: none;
      border-radius: 0; overflow: visible; padding: 0; min-width: 9rem;
    }
    .bv-unitswitch-stack label {
      justify-content: flex-start; min-height: 44px; width: 100%;
      gap: 0.5rem; padding: 0 0.75rem; border-radius: 0.5rem;
      font-size: 0.75rem;
    }
    .bv-unitswitch-stack .bv-unitword { display: inline; }
    .bv-unitswitch-stack label:hover { background: var(--bv-raised); color: var(--bv-text); }
    .bv-unitswitch-stack input:checked + label {
      background: none; color: var(--bv-best); box-shadow: none;
    }

    /* P3 — BEST-PRICE TREATMENT, PER SIDE, EVERY TIE MARKED. The
       dark-palette analogue of the mint fill + green outline convention,
       built from tokens already here. Background and an INSET shadow only, so
       the cell's box is byte-for-byte what it was and marking a tie moves
       nothing. Never colour alone: the cell's row also carries the word
       "Best" in the gap track (WCAG 1.4.1). */
    /* P2's second unit, IN BRACKETS ON THE PRIMARY'S LINE (KJ 2026-09-21).
       The dual-unit book stack that used to live here — two ordered lines,
       .bv-bookstack / .bv-native-cents flipping which was on top — is gone
       with the stack itself; see .bv-cprice in Zone 3 below for the line that
       replaced it, and shared/_comparison_row for which unit leads.

       TWO VOCABULARIES, NOT ONE WITH A SECOND MEANING BOLTED ON. `.bv-u`
       reveals the reader's unit and is used by every other surface on the
       site. `.bv-uprim` is the comparison chip's LEADING number and `.bv-ualt`
       its bracket, and they are separate names because this chip answers a
       question no other surface has: which unit leads when the reader has not
       actually chosen one. American is the DEFAULT, not a preference, so on it
       the VENUE decides — an exchange leads in cents and a book in American,
       which is what the site has always done and the whole point of P2. State
       a unit and it wins everywhere, which is what P1 asks for.

       NOTE FOR WHOEVER EDITS THIS COMMENT: it is inside <style> and therefore
       SHIPS TO THE PAGE, where spec/requests/beatvig's copy bans read it. The
       word this paragraph originally used for "what P2 exists to do" is on
       that list. The prose rules apply in here too.

       THE VENUE FAMILY IS THE THIRD CLASS. .bv-native-cents is on the chip,
       so "American, and this venue quotes cents" is a three-class selector
       that outranks the two-class default at source. Six combinations, one
       revealed span per slot in each, no markup branch and no !important.

       Same R1 contract as .bv-u above, checked by the same example: `display`
       and nothing else, so neither slot can resize a chip when somebody flips
       the toggle. */
    .bv-uprim, .bv-ualt { display: none; }
    .bv-unit-american .bv-uprim-american,
    .bv-unit-prob     .bv-uprim-prob,
    .bv-unit-cents    .bv-uprim-cents { display: inline; }
    .bv-unit-american .bv-native-cents .bv-uprim-american { display: none; }
    .bv-unit-american .bv-native-cents .bv-uprim-cents { display: inline; }
    /* The bracket is whichever unit the leading slot did not take. On American
       that is the venue's own translation — and on a cents venue American IS
       the translation, because the cents are leading. */
    .bv-unit-american .bv-ualt-prob,
    .bv-unit-prob     .bv-ualt-american,
    .bv-unit-cents    .bv-ualt-american { display: inline; }
    .bv-unit-american .bv-native-cents .bv-ualt-american { display: inline; }

    /* PHASE 6: the ring moved INSIDE, onto the price button this cell now
       holds (.bv-best-cell .bv-cellbox above), because that is the object a
       reader compares — Covers outlines the button, not the table cell. The
       cell keeps the wash, which is what gives the button something to sit
       on. Background only, so the cell's box is still byte-for-byte what it
       was and marking a tie still moves nothing. Every caller of this class
       contains a .bv-cellbox, so nothing lost a ring. */
    .bv-best-cell { background: var(--bv-best-wash); }
    /* A venue that does not price this outcome. An EQUAL-SIZE cell carrying an
       em dash — never a collapsed one, or the grid reflows the moment a venue
       drops out mid-tick. */
    .bv-cell-empty { color: var(--bv-muted); }

    /* P11 — THE FAMILY CHIP. Prediction venues (Kalshi, Polymarket: binary
       $1/$0 contracts, quoted in cents) sit INLINE with the sportsbooks on
       the matrix, ordered by price like everything else — segregating them
       into their own section would answer a question nobody asked instead of
       the one the board is for. What marks them is this chip plus the P2
       dual-unit stack, never a position in the grid.

       A WORD, NOT A GLYPH OR A COLOUR. The spec sketches ⬢ / ▪; a bare shape
       is exactly the "stated in colour alone" failure (WCAG 1.4.1) in another
       medium, and a screen reader reads it as nothing useful. The chip says
       "contract" with the full sentence sr-only beside it. Muted, because it
       is a classification and teal is the money colour. */
    .bv-familychip {
      flex: 0 0 auto; font-size: 0.5625rem; font-weight: 700; line-height: 1;
      text-transform: uppercase; letter-spacing: 0.04em;
      padding: 0.125rem 0.25rem; border-radius: 0.25rem;
      color: var(--bv-muted); border: 1px solid var(--bv-border);
    }

    /* P4 — IN-CELL MOVEMENT ("Open 41¢ ▲"). A FIXED-height line like every
       other optional line on a card (R2): the caller reserves it on every row
       by passing the key, so a card whose rows disagree about having archived
       history is still one height per row and nothing moves mid-morph.

       No width, no padding, no font-size change between the three states —
       the line is the same box whether it is empty, up or down, which is what
       lets it sit inside a FIXED price track (R1). */
    .bv-move { display: block; height: 0.875rem; line-height: 0.875rem;
               font-size: 0.625rem; font-weight: 600; white-space: nowrap;
               color: var(--bv-muted); }
    .bv-move-up { color: var(--bv-up); }
    .bv-move-down { color: var(--bv-down); }
    /* THE STEAM BADGE (spec §4 Phase 5) lives INSIDE .bv-move, so it inherits
       that box and cannot change its height — the badge is a word on a line
       that was already reserved, not a line of its own. Colour only; no
       padding, no border, no background, because any of the three would make
       the badge a different height from the text beside it and R2's whole
       point is that this box never changes.

       --bv-warn, not a price colour. It is a fact about HOW the price moved;
       teal is the money colour and red already means "moved against you", so
       borrowing either would make the badge read as a claim about value. */
    .bv-steam { color: var(--bv-warn); text-transform: uppercase;
                letter-spacing: 0.02em; }

    /* P5 — THE PERSISTENT DIRECTIONAL TINT, which is what the 1400ms flash
       cannot be. The flash (beatvig_price_flash_controller) answers "this
       just changed"; this answers "which way has this gone since it opened",
       and it is rendered SERVER-SIDE from the tick archive so it survives a
       Turbo morph and the client carries no movement arithmetic (§5 R6).

       AN INSET SHADOW, NEVER A BORDER OR A BACKGROUND. A border would change
       the cell's box and move every row that gained one; a background would
       fight the flash's own inline background-color, which is the one thing
       on this element the JS writes. An inset shadow paints inside the
       existing box and composites under nothing. */
    .bv-moved-up { box-shadow: inset 1px 0 0 var(--bv-up); }
    .bv-moved-down { box-shadow: inset 1px 0 0 var(--bv-down); }

    /* The prop page's chart tabs ("Last 10" / "Line", spec §2.4). A separate
       class from .bv-tab, which is the mobile tab BAR and carries a 44px rule
       and an aria-current colour that mean something else entirely — one
       class doing two jobs is how a nav restyle silently moves a chart
       control. 44px tall here too, because it is a tap target.

       The selected state is a background and a colour AND aria-selected, so
       it is never stated in colour alone (WCAG 1.4.1). */
    .bv-charttab { min-height: 2rem; padding: 0 0.625rem; border-radius: 9999px;
                   font-size: 0.6875rem; font-weight: 700; color: var(--bv-muted);
                   border: 1px solid var(--bv-border); }
    .bv-charttab-on { background: var(--bv-raised); color: var(--bv-best); }
    .bv-charttab:focus-visible { outline: 2px solid var(--bv-best); outline-offset: 2px; }

    /* P9 — PER-VENUE QUOTE AGE. A fixed-height line like every other optional
       line on a card (R2), so reserving it costs exactly what filling it does.
       Amber past an hour, never red: --bv-down means a price moved against
       you, and an old quote is not a bad one. */
    .bv-age { height: 0.875rem; line-height: 0.875rem; font-size: 0.625rem;
              font-weight: 600; color: var(--bv-muted); white-space: nowrap; }
    .bv-age-warn { color: var(--bv-warn); }

    /* ---- The venue matrix (shared/_venue_matrix) -------------------------
       A real <table> on desktop. Every row carries an EXPLICIT ARIA role
       (role="table"/"rowgroup"/"row"/"columnheader"/"cell") rather than
       relying on the implicit ones, because M1 below changes `display` on
       those elements and a browser drops the implicit table semantics the
       moment it does — the reflow would silently cost a screen-reader user
       the whole grid. */
    .bv-matrix, .bv-routetable { width: 100%; border-collapse: collapse; }
    /* P10's route table shares the venue matrix's TYPE but not its class, on
       purpose. .bv-matrix's mobile rules (M1) reflow a row into a stacked card
       keyed on each cell's data-l label, sized for the venue grid; the route
       table is four short numeric columns that fit a 390px row as a table and
       would be made worse by the reflow. Same visual grammar, different
       geometry — and separating them also keeps every selector that means "the
       venue matrix" unambiguous now that a panel can hold two tables. */
    .bv-routetable th, .bv-routetable td { padding: 0.25rem 0.5rem; vertical-align: middle; }
    .bv-routetable th { font-size: 0.625rem; font-weight: 700; text-transform: uppercase;
                        letter-spacing: 0.04em; color: var(--bv-muted); text-align: left; }
    .bv-routetable tbody tr + tr > td { border-top: 1px solid var(--bv-border); }
    .bv-matrix th { font-size: 0.625rem; font-weight: 700; text-transform: uppercase;
                    letter-spacing: 0.06em; color: var(--bv-muted);
                    text-align: left; padding: 0.25rem 0.5rem; }
    /* PHASE 6: the cells tightened, because each one now draws its own ring
       and the cell padding was doing that job badly. The hairline between
       rows stays and is the row separation — no zebra, which would fight
       both the best-price wash and the pinned-row wash for the same pixels
       and lose to whichever selector happened to be more specific. */
    .bv-matrix td { padding: 0.1875rem 0.25rem; vertical-align: middle; }
    .bv-matrix tbody tr + tr > td { border-top: 1px solid var(--bv-border); }
    /* The BEST pseudo-venue row, pinned first. It is what makes a venue-pill
       filter safe: a reader who hides every venue still has an answer. */
    .bv-matrix .bv-matrix-best > td { background: rgba(45, 212, 191, 0.06); }
    /* The in-cell venue logomark M1 reveals on mobile to replace the lost
       column header. Present in the markup at every width — one DOM — and
       simply not displayed on desktop, where the header row says it instead. */
    .bv-cell-logo { display: none; }

    /* M1 — COVERS' TABLE→CARD REFLOW. Below 768px the header row goes, each
       venue <tr> becomes a stacked card, and each cell becomes a
       label/value grid. ONE DOM, no JS, no duplicate render — and NEVER a
       wide horizontal-scroll matrix, which is what a four-column table does
       at 390px on the panel that exists precisely to be read on a phone. */
    @media (max-width: 767px) {
      .bv-matrix thead { display: none; }
      .bv-matrix tr { display: flex; flex-direction: column; padding: 0.25rem 0; }
      .bv-matrix tbody tr + tr { border-top: 1px solid var(--bv-border); }
      .bv-matrix tbody tr + tr > td { border-top: 0; }
      .bv-matrix td { display: grid; grid-template-columns: 5.5rem minmax(0, 1fr);
                      align-items: center; column-gap: 0.5rem; padding: 0.125rem 0.5rem; }
      .bv-matrix .bv-cell-logo { display: inline-flex; }
      /* The cell's own label. GENERATED FROM data-l rather than shipped as a
         span: the mobile card has no column header to read a value against,
         but this markup also rides the live broadcast (capped at 12KB by
         spec/services/beatvig/comparison_row_stream_spec.rb) and three styled
         spans per venue is ~280 bytes a row of text that desktop never shows.
         Decoration only — the real semantics are the explicit
         role="columnheader" cells above, which survive this reflow. */
      .bv-matrix td[data-l]::before {
        content: attr(data-l);
        font-size: 0.625rem; font-weight: 700; text-transform: uppercase;
        letter-spacing: 0.06em; color: var(--bv-muted);
      }
      /* M2's filter. A CLASS, and only hidden at this width: the desktop grid
         shows every venue and offers no pill row to change that, so the
         controller's mark must be inert above 768px. */
      .bv-matrix tr.bv-venuehidden { display: none; }
    }

    /* M2 — the venue pills. A horizontally scrolling row of the venues on THIS
       row, with the BEST pseudo-venue selected by default so the first paint
       answers the question with one entry. It is a VIEW of
       Beatvig::BookSelection (the venues here arrived through `.board`), never
       a second venue-selection store — a parallel store would break `.board`
       scoping, the broadcast intersection and the paywall at once. */
    .bv-venuepills { display: flex; gap: 0.375rem; overflow-x: auto;
                     scrollbar-width: none; -webkit-overflow-scrolling: touch; }
    /* PHASE 6 FOUND THIS ON SCREEN: the pill row was rendering on DESKTOP,
       above a grid that already shows every venue — a venue picker for a
       problem the desktop layout does not have. The markup has always said
       `md:hidden`; it never applied, because that utility is one class in
       tailwind.css and `.bv-venuepills` is one class in THIS block, which is
       loaded after it — equal specificity, later source order, the display
       declaration here wins. So the width rule has to be stated here too,
       where it can win. Same breakpoint as the M1 reflow above, so the pills
       exist exactly while the stacked card they steer does. No !important
       and no change to the markup's own `md:hidden`, which stays as the
       intent it always expressed. */
    @media (min-width: 768px) { .bv-venuepills { display: none; } }
    .bv-venuepills::-webkit-scrollbar { display: none; }
    .bv-venuepill { flex: 0 0 auto; min-height: 2.75rem; padding: 0 0.75rem;
                    display: inline-flex; align-items: center; border-radius: 9999px;
                    border: 1px solid var(--bv-border); font-size: 0.6875rem;
                    font-weight: 700; color: var(--bv-muted); white-space: nowrap; }
    .bv-venuepill[aria-pressed="true"] { border-color: var(--bv-best); color: var(--bv-best);
                                         background: var(--bv-best-wash); }

    /* ---- THE BOARD STACK (KJ 2026-09-21: "give more spacing between rows")
       ------------------------------------------------------------------
       ONE GAP FOR EVERY BOARD. The comparison rows, the game cards and the
       edge cards each set their own space-y utility — 6px, 6px and 9px at this
       theme's 12px root — so three lists of cards that read as the same thing
       sat at three different rhythms, all of them tight enough that a card
       ended and the next began with no pause between them.

       Flex gap rather than space-y: these lists hold hidden children (an empty
       state, and on the comparison board rows the client inserts and removes),
       and a margin-based rule puts space above a display:none child while a
       gap does not. */
    .bv-board-stack { display: flex; flex-direction: column; gap: 16px; }
    @media (min-width: 768px) {
      .bv-board-stack { gap: 20px; }
    }

    /* ---- THE CONTROL BAND (KJ 2026-09-21) -------------------------------
       "The filters on mobile leave a lot to be desired. We should fit them
       all into one filters section."

       ONE BAND, TWO SHAPES, NO DUPLICATE MARKUP. Below 768px the band is a
       compact row — the Filters disclosure and the page search — over a panel
       that is `hidden` in the served HTML. At 768px and up the bar and the
       panel both go `display: contents`, which dissolves the two wrappers and
       hands their children straight to the band's own flex row: the same
       inline toolbar desktop already had, minus the per-partial bottom
       margins that used to stagger it across three lines.

       display:contents is also what re-shows the panel at that width. The
       `hidden` attribute is a UA `display: none`, and any author declaration
       beats it — so the panel needs no JS and no second copy of the markup to
       be permanently open on desktop, and no state can leak from one width to
       the other. The toggle is display:none there, so its aria-expanded is
       never exposed while the panel it names is showing. */
    .bv-controls-bar { display: flex; align-items: flex-start; gap: 0.5rem; }
    /* 40px, IN PIXELS (KJ 2026-09-21: "the filter button should be the same
       size as the search bar"). This said 2.75rem, which reads as the 44px tap
       target it was written for — but :root here is 12px, so it was resolving
       to 33px and shipping a button 7px shorter than the input beside it. 40px
       is the height every other BeatVig control is built at
       (Beatvig::OddsHelper::INPUT_CLASSES), so stating it in px both fixes the
       mismatch and makes the button agree with the search bar by construction
       rather than by two numbers that happen to match. */
    .bv-controls-toggle { flex: 0 0 auto; display: inline-flex; align-items: center;
                          gap: 0.5rem; min-height: 40px; padding: 0 0.875rem;
                          border-radius: 9999px; border: 1px solid var(--bv-border);
                          background: var(--bv-surface); color: var(--bv-text);
                          font-size: 0.8125rem; font-weight: 700; white-space: nowrap; }
    .bv-controls-toggle[aria-expanded="true"] { border-color: var(--bv-best);
                                                color: var(--bv-best);
                                                background: var(--bv-best-wash); }
    .bv-controls-toggle:focus-visible { outline: 2px solid var(--bv-best); outline-offset: 1px; }
    /* The count of filters actually set. Money colour, same as every other
       "this is the one that counts" mark on the site — and never the only
       thing saying it: the button carries an sr-only "active" after the
       number and the panel below names each filter in full. */
    .bv-controls-count { display: inline-flex; align-items: center; justify-content: center;
                         min-width: 1.25rem; height: 1.25rem; padding: 0 0.3125rem;
                         border-radius: 9999px; background: var(--bv-best-wash);
                         color: var(--bv-best); font-size: 0.6875rem; font-weight: 800; }
    /* The state readout beside the count — one word for a panel setting the
       number cannot name ("Raw"). Same pill as .bv-controls-count by design,
       so the collapsed row carries one chip grammar and not two; it only
       needs its own selector to be taken out at the width where the control
       it describes is itself on screen. */
    /* 12px is (40 - 15) / 2 — half the difference between the 40px toggle and
       this 1.25rem badge, in px for the same reason the toggle's own height is
       (:root here is 12px, so a rem figure would not mean what it reads as).
       align-self:center cannot do it: .bv-controls-bar is align-items:flex-start
       because the search wrapper is taller than its own input, so centering
       against the BAR would drop the chip below the button it sits beside. */
    .bv-controls-chip { padding: 0 0.4375rem; margin-top: 12px;
                        text-transform: uppercase; letter-spacing: 0.04em; }
    @media (min-width: 768px) {
      .bv-controls-chip { display: none; }
    }
    .bv-controls-search { flex: 1 1 auto; min-width: 0; }
    /* The two share the phone's first row, and .bv-controls-bar is
       align-items:flex-start because the search wrapper is TALLER than its own
       input (it reserves a line under it for the result count). Top-aligning
       is what puts the button and the input on the same baseline now that they
       are the same 40px tall. */
    /* A page whose controls are a real form with a submit button rather than
       a board's auto-submitting select row (the Prop Edge tool). Stacked in
       the panel, inline in the toolbar — stated here rather than with
       sm:flex-row because this app's Tailwind `sm` is 300px. */
    .bv-controls-form { display: flex; flex-direction: column; gap: 0.75rem; }
    @media (min-width: 768px) {
      .bv-controls-form { flex-direction: row; align-items: flex-end; }
    }
    /* The panel is a <dialog> (KJ 2026-09-21: "I think the filters should be a
       modal, rather than pushing content down"), so these also undo what the
       UA gives a dialog: its own border, padding, background and colour. They
       describe the INLINE shape — the desktop toolbar, and the unscripted
       phone fallback. The modal geometry is on [open] alone, below, because a
       modal is the only state a dialog can be open in here.

       THE CLASS IS DOUBLED, which is the trick .bv-input.bv-input already uses
       here against the forms plugin — out-specify, never !important. The
       app-wide reset (app/assets/stylesheets/components/theme.css) is
       `dialog:not(.mock-draft-modal):not(.optimizer-modal) { background-color:
       transparent; border: none }`, and a :not() carries its argument's
       specificity — so that reset is (0,2,1) and both a bare class and a
       plain `dialog.bv-controls-panel` (0,1,1) lose their background and
       border to it. That is not a style nit on a modal: it is a bottom sheet
       you can read the board straight through, which is what the first
       screenshot of this change showed. */
    dialog.bv-controls-panel.bv-controls-panel {
                         display: flex; flex-direction: column; gap: 0.75rem;
                         margin-top: 0.75rem; padding: 0.875rem; border-radius: 0.75rem;
                         border: 1px solid var(--bv-border); background: var(--bv-surface);
                         color: var(--bv-text); }
    /* The modal's own header: a visible title for aria-labelledby to point at,
       and the close button. Phone-only, like the modal itself. */
    .bv-controls-head { display: flex; align-items: flex-start; gap: 0.5rem; }
    @media (min-width: 768px) {
      .bv-controls-head { display: none; }
    }
    @media (max-width: 767px) {
      /* A dialog is CLOSED in the served HTML and opened by showModal(). The
         author rules above would otherwise beat the UA's
         `dialog:not([open]) { display: none }`, so it is restated here at a
         specificity that holds. */
      dialog.bv-controls-panel:not([open]) { display: none; }
      /* THE BOTTOM SHEET. Full width at the bottom of the screen rather than a
         centred box: it is a panel of controls on a phone, the thumb is at the
         bottom, and the board stays visible above it rather than being boxed
         out. Capped at 82vh with its own scroll, because the comparison
         board's panel carries five controls and a books button. */
      dialog.bv-controls-panel[open] { position: fixed; left: 0; right: 0; bottom: 0;
          top: auto; width: 100%; max-width: 100%; max-height: 82vh; overflow-y: auto;
          margin: 0; padding: 1rem 1rem calc(1.5rem + env(safe-area-inset-bottom));
          border-radius: 1rem 1rem 0 0; border-bottom: 0;
          /* The same material the books sheet is made of — the top of the
             surface ramp, the strong edge and the cast shadow — so the two
             modals on a BeatVig phone read as one thing lifted off the page
             rather than two different kinds of panel. */
          background: var(--bv-overlay); border-color: var(--bv-border-strong);
          box-shadow: var(--bv-shadow-lg); }
      /* Blurred where supported, like the books sheet: the board behind a
         modal should recede, not merely dim. */
      dialog.bv-controls-panel::backdrop { background: rgba(var(--bv-rgb-bg), 0.78); }
      @supports (backdrop-filter: blur(4px)) or (-webkit-backdrop-filter: blur(4px)) {
        dialog.bv-controls-panel::backdrop {
          -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
        }
      }
      /* A STACKED PANEL NEEDS REAL LABELS. On one line a select reading
         "All markets" carries its own meaning; stacked four deep under a
         "Filters" heading it does not, and a first option shaped like a
         placeholder is not a label (WCAG 1.3.1). The label is already in the
         markup on every board — shared/_filter_bar ships it sr-only — so this
         un-hides what is there rather than shipping a second copy of it. */
      .bv-controls-panel .bv-controls-label { position: static; display: block;
          width: auto; height: auto; margin: 0 0 0.25rem; overflow: visible;
          clip: auto; clip-path: none; white-space: normal;
          font-size: 0.6875rem; font-weight: 700; text-transform: uppercase;
          letter-spacing: 0.06em; color: var(--bv-muted); }
      .bv-controls-panel .bv-controls-field { display: block; }
      .bv-controls-panel .bv-controls-filters { width: 100%; }
      .bv-controls-panel .bv-controls-fields { width: 100%; flex-direction: column;
                                               align-items: stretch; gap: 0.75rem; }
      .bv-controls-panel select { width: 100%; }
      /* Full-width targets, because at 390px a pill sized to its own words
         leaves the rest of the row dead space in a panel the reader opened
         precisely to tap something. */
      .bv-controls-panel .bv-controls-books { display: flex; }
      .bv-controls-panel .bv-controls-books > button { flex: 1 1 auto; justify-content: center; }
      .bv-controls-panel .bv-feebasis { display: flex; width: 100%; }
      .bv-controls-panel .bv-feebasis-option { flex: 1 1 0; justify-content: center; }
      /* Full-width too, for the same reason: a three-segment pill sized to
         "US % ¢" in a panel opened to tap something is mostly dead row. */
      .bv-controls-unit .bv-unitswitch { display: flex; width: 100%; }
      .bv-controls-unit .bv-unitswitch label { flex: 1 1 0; min-height: 36px; }
    }
    /* WITHOUT JS THE DISCLOSURE IS A DEAD BUTTON, so it is not shown one. The
       filter row still works unscripted (shared/_filter_bar ships a <noscript>
       Apply button), and collapsing it behind a control nothing can open would
       have taken that away — so with scripting off there is no toggle and the
       panel is simply open, which is the pre-band layout.

       STATED HERE RATHER THAN IN A <noscript><style>, which is where it lived
       until 2026-09-21. A <noscript>'s contents are text to a scripted browser
       but real elements to DOMParser — which is what Turbo parses a navigation
       with — so the rules came back to life on the first Turbo visit and took
       the Filters button away from every phone that clicked anything. The
       marker class on <html> cannot be resurrected that way: it is added in
       <head> on a scripted load and never added on an unscripted one. See the
       script in <head>.

       Higher specificity than the rules it overrides and later in the
       document, so it wins on both counts — no !important. */
    @media (max-width: 767px) {
      html:not(.bv-js) .bv-controls-toggle { display: none; }
      html:not(.bv-js) dialog.bv-controls-panel:not(.bv-controls-open) { display: flex; }
      /* AND THE PANEL THE SERVER WAS ASKED TO OPEN (?panel=filters), which
         carries .bv-controls-open and so is not matched by the rule above. It
         needs its own line now that the panel is a <dialog>: without one it
         would be hidden by dialog:not([open]), which is the one state an
         unscripted reader can never leave. */
      html:not(.bv-js) dialog.bv-controls-panel.bv-controls-open { display: flex; }
      /* No modal, so no modal header — and its close button would be a dead
         control, for the same reason the toggle above is not shown one. */
      html:not(.bv-js) .bv-controls-head { display: none; }
    }
    /* ONE COPY SHOWING AT A TIME. Two fieldsets, one radio group (see
       shared/_unit_toggle) — display:none takes the other out of the layout
       AND out of the accessibility tree, so a screen reader meets three radios
       at either width, never six. 768px because that is the band's breakpoint;
       this theme's Tailwind md: is 600px and would disagree with it. */
    @media (max-width: 767px) {
      .bv-unitswitch-header { display: none; }
    }
    /* THE HEADER'S COMPACT TOGGLE IS THE PHONE COPY. It is the mirror of the
       rule above: the segmented group carries .bv-unitswitch-header
       unconditionally now and is gone below 768px, and this one is gone at
       768px and up. On a page whose filter band carries the control, the
       compact copy ALSO carries .bv-unitswitch-header, so the two rules
       together hide it at every width and the band's copy is the only one
       left — exactly one fieldset is displayed on every page at every width,
       which is what keeps three radios in the accessibility tree and not six
       or nine. */
    @media (min-width: 768px) {
      .bv-unitmenu-header { display: none; }
    }
    @media (min-width: 768px) {
      .bv-controls-unit { display: none; }
    }
    @media (min-width: 768px) {
      .bv-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.75rem; }
      /* The panel keeps the `dialog` in its selector at this width too: the
         inline shape above is written that way to clear the app-wide dialog
         reset, and a bare class here would lose to it. */
      .bv-controls-bar { display: contents; }
      dialog.bv-controls-panel.bv-controls-panel { display: contents; }
      .bv-controls-toggle { display: none; }
      /* ORDER, because the DOM order is the MOBILE one. The search shares the
         phone's visible row with the disclosure, so it has to come before the
         panel in the markup — but on desktop it belongs where it has always
         been, after the filters and the books button. `order` moves it there
         without a second copy of the partial or a second DOM. */
      .bv-controls-search { flex: 0 1 18rem; order: 1; }
    }

    /* P10 — NET OF FEE / RAW. Two links to two views of one board, so the
       current one is marked with aria-current and not with aria-pressed (it is
       not a button, and "current page" is what is actually true of it). The
       44px minimum tap target is the site rule and applies here too: this sits
       directly above a board half our readers scroll on a phone.

       The selected option takes the money colour, which is the same treatment
       the venue pills and the best-price cell already use — one visual grammar
       for "this is the one that counts". It is never colour alone: aria-current
       names it, and the note beside the control says in words which basis the
       board is ranked on. */
    .bv-feebasis { display: inline-flex; gap: 0.375rem; flex: 0 0 auto; }
    .bv-feebasis-option { min-height: 2.75rem; padding: 0 0.75rem; display: inline-flex;
                          align-items: center; border-radius: 9999px;
                          border: 1px solid var(--bv-border); font-size: 0.6875rem;
                          font-weight: 700; color: var(--bv-muted); white-space: nowrap; }
    .bv-feebasis-option[aria-current="true"] { border-color: var(--bv-best); color: var(--bv-best);
                                               background: var(--bv-best-wash); }

    /* The stacked variant (prop tables): price over book, right-aligned.
       A FIXED width, because these pills are laid out by their parent's flex
       row rather than by a shared grid: sized to content, the O/U pair on a
       DraftKings line came out 6px wider than the pair on a ProphetX line and
       every row on the market board started at a different x. The name
       truncates inside the box (shared/_book_badge, slot:) instead.

       px, not rem: the badge's own type size is px (text-[10px]), so a rem
       track would stop matching its content if the root size changes. */
    .bv-pricestack { width: 88px; }
    /* The disclosure arrow points down when closed, up when open. Transform
       only — the button's box never changes, so nothing on the card moves. */
    .bv-disclosure-arrow { transition: transform 0.15s ease; }
    [aria-expanded="true"] > .bv-disclosure-arrow { transform: rotate(180deg); }
    @media (prefers-reduced-motion: reduce) { .bv-disclosure-arrow { transition: none; } }
    .bv-disclosure:focus-visible { outline: 2px solid var(--bv-best); outline-offset: 1px; }
    /* The disclosure control is a full 44px tap target (iOS HIG / WCAG 2.5.5)
       that must not GROW the row it sits in. A :lg pill is min-height 44px
       built from a ~24px content box plus its own py-2.5; drop a 44px child
       into that and the row becomes 64px — and only the rows that HAVE a
       control, which is the layout shift this whole change exists to remove.
       The negative block margin cancels the pill's padding, so the control's
       MARGIN box contributes 24px and the row stays exactly 44px whether it
       carries a button, a padlock or an empty reserved track. Keep the two
       numbers paired: -0.625rem is _price_pill's py-2.5. */
    .bv-rowcontrol { height: 2.75rem; margin-block: -0.625rem; }
    /* beatvig_price_flash_controller appends its ▲/▼ glyph to the PILL when a
       morph changes the number. Under the old flex pill that just sat at the
       end of the row; under the grid it would be a fifth child, land in an
       implicit SECOND ROW and make the card jump every time a price ticked.
       Taking it out of flow keeps the glyph and keeps the geometry. Bottom
       right corner, clear of the 44px row's centred disclosure arrow. */
    .bv-pill { position: relative; }
    .bv-pill > .bv-flash-glyph { position: absolute; right: 0.25rem; bottom: 0.0625rem; }

    /* ---- Books picker ----------------------------------------------------
       touch-action:none on the grip is what makes drag-to-reorder work on a
       phone: without it the browser claims the gesture as a scroll and the row
       never moves (iOS Safari will not start an HTML5 drag without a long
       press either, which is why this is pointer-driven). */
    /* Tailwind v4's preflight dropped the old `button { cursor: pointer }`, so
       every button and select on the site showed a plain arrow and nothing
       looked clickable. Anchors already get it from the UA stylesheet; this
       covers the rest of the interactive surface. */
    .bv-body :is(button, summary, [role="button"], select, label[for], input[type="checkbox"]):not(:disabled) {
      cursor: pointer;
    }
    .bv-body :is(button, [role="button"], select):disabled { cursor: not-allowed; }

    /* The paywall stacks a blurred preview and the upgrade card in ONE grid
       cell, so the section is as tall as the TALLER of the two. Previously the
       card was absolutely positioned and contributed no height, so the section
       sized itself to the preview alone — on a page whose preview is a single
       short row, overflow:hidden sliced the Go Premium button in half. Grid
       stacking fixes it for every preview size without a magic min-height. */
    .bv-paywall-stack { display: grid; }
    .bv-paywall-stack > * { grid-area: 1 / 1; min-width: 0; }

    /* ---- Focus ring -------------------------------------------------------
       ONE on-scheme focus ring for everything focusable (KJ 2026-09-17: "Why
       is the highlighted state of a button purple? That is not in our approved
       color scheme"). It was not ours — it was Chrome's user-agent default,
       which paints violet-blue and is banned in this codebase's design
       language. Individual rules existed for .bv-input, .bv-book-move and
       .bv-book-check, so every control NOBODY had written a rule for — the
       modal's close button first among them — fell through to the UA ring.

       The fix is a default, not another per-control rule: anything focusable
       gets --bv-best unless a later rule overrides it. NEVER `outline: none`
       — a keyboard user has to be able to see where they are (WCAG 2.4.7);
       the ring is restyled, never removed. Scoped to :focus-visible so a
       mouse click does not paint a ring the pointer user did not ask for.
       :where() carries ZERO specificity, so it is a floor every existing rule
       above already beats — nothing here needs !important to be overridden.
       <dialog> is deliberately NOT in this list: showModal() focuses the panel
       itself, and ringing a whole sheet reads as an error state rather than as
       focus. Its CONTROLS are covered, which is what the keyboard user needs. */
    :where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
      outline: 2px solid var(--bv-best); outline-offset: 2px; border-radius: 0.25rem;
    }

    /* ---- sr-only MUST NOT WIDEN THE PAGE ---------------------------------
       Found by measuring, not by reading: at 390px the document's scrollWidth
       on /odds-comparison was 599px — 209px of horizontal scroll on the
       flagship board — and the element sticking out was a 1px
       <span class="sr-only">.

       Tailwind's .sr-only is `position:absolute` with no `left`, so the box
       stays at its STATIC position in the inline flow. Inside a truncated
       line that static position is off the end of the (overflowing, clipped)
       text — and because the span is absolutely positioned, its containing
       block is the nearest POSITIONED ancestor, not the `overflow:hidden`
       parent that was clipping the text. So it escaped the clip and sat 200px
       past the viewport, dragging the document's scroll width with it. The
       comparison row has several of these (the quote-age meaning, the
       venue-family sentence), and every one of them is inside a `truncate`.

       Pinning `left: 0` puts the box at the start of its containing block
       instead of wherever the overflowing text ended. It is still 1px, still
       clipped, still in the accessibility tree and still read aloud — the
       only thing that changes is that it can no longer be the widest thing on
       the page. Scoped to .bv-body so FTA's own pages keep Tailwind's rule
       exactly as it is. */
    .bv-body .sr-only { left: 0; }

    .bv-grip { touch-action: none; cursor: grab; }
    .bv-book-row--dragging { opacity: 0.6; cursor: grabbing; }
    .bv-book-check { accent-color: var(--bv-brand); }
    .bv-book-move { min-width: 28px; min-height: 28px; display: inline-flex;
                    align-items: center; justify-content: center; border-radius: 0.25rem; }
    .bv-book-move:hover { color: var(--bv-text); background: var(--bv-border); }
    .bv-book-move:focus-visible, .bv-book-check:focus-visible {
      outline: 2px solid var(--bv-best); outline-offset: 1px; }
    /* Mobile first: the sheet fills a phone and becomes a centred card at lg.
       max-height + scroll so a long catalogue never pushes the close button
       off screen. */
    /* Centred by <dialog>'s own default placement. Deliberately LARGE (KJ
       2026-09-16): 25 book rows each carry a grip, a checkbox and two move
       buttons, and a cramped panel makes dragging fiddly — which is the whole
       point of the control. Still viewport-capped so a phone never scrolls
       sideways. */
    /* ELEVATION, not just a border (KJ 2026-09-17: "should have a border
       around it as a modal" — the panel read as bleeding into the page).
       .bv-card already put a 1px --bv-border on it, but --bv-border on
       --bv-surface over a dark backdrop is a ~1.2:1 edge: technically drawn,
       visually absent. Three changes and the panel reads as a sheet lifted
       off the page —

         * the border steps up to --bv-muted, which is a real edge at any
           brightness instead of a hairline that disappears on a dim screen;
         * a drop shadow, which is what actually separates a modal from what
           is behind it — no border weight substitutes for the cast shadow the
           eye reads as depth;
         * a darker backdrop, so the page behind recedes rather than competing.

       Emitted after .bv-card so the border colour wins on equal specificity,
       which is the same ordering trick .bv-card-pinned above relies on. */
    /* `dialog.bv-book-dialog`, not `.bv-book-dialog`, for the border: an
       element-qualified selector (0,1,1) is needed to beat the reset that
       zeroes borders on every element, which a bare class (0,1,0) loses to
       here — the box-shadow below landed from the bare class while the border
       silently did not, which is what "there is no border" looked like on
       screen. Same doubling trick .bv-input.bv-input above uses against the
       forms plugin, and the same reason: out-specify, never !important. */
    /* The 2026-09-21 redesign gave the palette the token this rule had been
       improvising with --bv-muted for: --bv-border-strong IS "the edge of
       something lifted off the page", and it is the same edge the dropdown
       menus and popovers now use, so a sheet and a menu finally read as the
       same material. Elevation comes from --bv-shadow-lg for the same reason.
       The fill steps up to --bv-overlay, the top of the surface ramp — it is
       the highest thing on screen and now paints like it. */
    dialog.bv-book-dialog {
      color: var(--bv-text); width: min(42rem, calc(100vw - 2rem));
      max-height: min(46rem, calc(100vh - 4rem)); overflow-y: auto;
      background: var(--bv-overlay);
      border: 1px solid var(--bv-border-strong);
      border-radius: var(--bv-r-lg);
      box-shadow: var(--bv-shadow-lg);
    }
    /* Backdrop stated from --bv-rgb-bg, like the header, so it tracks the
       ground. Blurred where supported: the page behind a sheet should recede,
       not merely dim. */
    .bv-book-dialog::backdrop { background: rgba(var(--bv-rgb-bg), 0.78); }
    @supports (backdrop-filter: blur(4px)) or (-webkit-backdrop-filter: blur(4px)) {
      .bv-book-dialog::backdrop {
        -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
      }
    }

    /* FTA's team_logo helper emits BOTH a light and a dark <img> and lets
       theme.css hide one per html.dark. BeatVig is dark-only and does not load
       theme.css, so without this both variants render stacked and the row looks
       broken. One rule beats duplicating FTA's whole theme layer here. */
    .team-logo-light { display: none; }

    /* Same root cause, the headshot fallback. FTA's player_silhouette paints
       itself with theme.css's THEME tokens (bg-surface-elevated +
       text-on-surface-faint), and theme.css's default branch is html:not(.dark)
       — which BeatVig's <html class="bv"> matches. Left alone, a player with no
       headshot lands as a WHITE disc on a dark card.

       Redefine the two variables the silhouette reads rather than overriding
       the utilities that read them: the utilities keep their own specificity,
       nothing needs !important, and any other FTA component that lands on a
       BeatVig page inherits the dark values too. html.bv and html:not(.dark)
       have identical specificity, so this wins on source order — this <style>
       block is after the stylesheet link. */
    html.bv {
      --color-surface-elevated: var(--bv-raised);
      --color-on-surface-faint: var(--bv-muted);

      /* ---- EVERY INPUT ON BEATVIG WAS PAINTED FOR LIGHT MODE -------------
         Same root cause as the purple focus ring documented at the top of
         this file, and the same fix. theme.css (which BeatVig loads, because
         both products build from one tailwind.css) carries

             html:not(.dark) body input:not([type="submit"]):not(...)
             { background-color: var(--color-input-bg); color: ...; border-color: ... }

         at roughly (0,8,3), and defines those three variables as WHITE on its
         `html:not(.dark)` branch. BeatVig's <html class="bv"> matches
         :not(.dark), so every text input on the site inherited FTA's light
         palette: the board's "Search this page" field rendered white-on-dark
         with grey-900 text, and .bv-input — one class, (0,1,1) at most — could
         not win against it no matter where it was emitted. The `rounded`
         utility applied and the colours did not, which is the tell that a MORE
         SPECIFIC rule, not a missing one, was the problem.

         So: point theme.css's own variables at BeatVig's tokens, exactly as
         --color-focus-ring is pointed at --bv-best above. One declaration
         fixes every input on the site at once — including the ones nobody
         thought to give .bv-input — needs no !important, and leaves .bv-input
         to do what a class should: radius, placeholder, hover and focus. */
      --color-input-bg: var(--bv-raised);
      --color-input-text: var(--bv-text);
      --color-input-border: var(--bv-border-strong);
    }

    /* ---- PHASE 6 — DENSITY, AND IT IS LAST IN THE FILE ON PURPOSE --------
       The leaders fit 8-12 rows in a 900px viewport; before Phase 6 BeatVig
       fit under 6, because every optional line reserves a 0.875rem box and a
       card stacks four of them. The reserved lines all STAY — R2 is not
       negotiable, and this phase adds no conditional line anywhere — they
       simply get tighter from sm: up, where the type is read at arm's length
       rather than on a phone. Below 600px nothing here applies: mobile keeps
       every generous box and every 44px tap target exactly as shipped.

       600px, AND THAT IS THIS CODEBASE'S `md:`, NOT ITS `sm:`. FTA's Tailwind
       theme sets --breakpoint-sm: 300px (app/assets/tailwind/application.css),
       so an `sm:` utility here fires on every phone in existence and would
       have applied this whole block to the 390px board it is meant to leave
       alone. Every density utility in the partials below is spelled `md:` for
       the same reason, and this query is 600px so the two stay in step. Do
       not "fix" either to 640 to match the fair-track rules above: those are
       a different decision about a different track and predate the theme.

       ALL ROWS AT ONCE, NEVER PER ROW OR PER STATE. These are media queries
       on the SHARED line classes, so every row on every board changes height
       in the same frame and a card can never become a staircase.

       SOURCE ORDER IS THE MECHANISM. .bv-move (P4) and .bv-age (P9) are
       defined further up this file at the same specificity, so a media query
       placed beside .bv-subnote would have lost to them and half the lines
       would have stayed tall. Emitted here, after every base definition,
       equal specificity plus later source order wins cleanly — no
       !important, and nothing needs its selector doubled. Keep this block
       LAST: a new line class defined below it would silently opt out. */
    @media (min-width: 600px) {
      .bv-subnote, .bv-move, .bv-rownote, .bv-fairnote, .bv-feeline, .bv-age {
        height: 0.75rem; line-height: 0.75rem;
      }
      .bv-bookprice { height: 1rem; line-height: 1rem; }
    }

    /* ---- THE COMPARISON CARD (2026-09-21 redesign) ------------------------

       KJ, after two restyling passes that changed colour inside the old
       geometry: "The cards didn't change hardly at all... I believe modern
       websites use a lot more spacing for readability."

       The old card was one five-track .bv-pricerow grid with the outcome
       sentence, the claim, the fair value, the venue and the chevron sharing a
       single horizontal line, plus two meta lines crushed around it. Every
       track was fixed and sized to its tightest case, so there was nowhere for
       air to come FROM — padding added to one cell came out of the sentence
       beside it. This block replaces that grid with FOUR STACKED ZONES and the
       space between them. See shared/_comparison_row's header for the diagram.

       EVERY HEIGHT HERE IS FIXED, WHICH IS §5 R2 ON NEW NUMBERS. These cards
       update live over the wire; a zone sized to its content would re-flow the
       card every time a price ticked and a digit was gained or lost. The rule
       is unchanged — only the measurements are this card's own rather than the
       shared grid's. Nothing below may become content-sized.

       ONE COLUMN AT EVERY WIDTH. The card does not re-flow into a strip on a
       desktop: the vertical composition IS the redesign, and a wide strip is
       the shape that was rejected. A wider viewport buys more padding, larger
       type and the two meta lines side by side — never a different layout. */
    .bv-ccard { display: flex; flex-direction: column; gap: 0.75rem; }

    /* ZONE 1 — the eyebrow. Sport, market, and the quote's age.
       THE UPPERCASING IS ON THE LABEL, NOT THE ZONE. The age chip shares this
       line and carries its own case from .bv-age, which every other board uses
       too; uppercasing the zone turned "3d ago" into "3D AGO" here and nowhere
       else on the site. */
    .bv-ceyebrow {
      height: 1rem; font-size: 0.6875rem; line-height: 1rem;
      color: var(--bv-muted); font-weight: 600;
    }
    .bv-ceyebrow-label { letter-spacing: 0.06em; text-transform: uppercase; }
    /* WHICH GAME — "Week 3 · Sun 1:00 PM ET". Deliberately NOT uppercased
       (see .bv-ceyebrow's own note) and deliberately not muted further: it is
       the fact that tells a reader the card is not the game on their TV, so it
       reads at the eyebrow's own weight rather than as fine print. Tabular
       figures so a column of cards does not jitter at the digits. */
    .bv-ckick { font-variant-numeric: tabular-nums; letter-spacing: 0.02em; white-space: nowrap; }

    /* ZONE 2 — who the bet is on. The line's height is PINNED to the tallest
       image on it, so the media box and the line are declared together: a prop
       row is a 40px headshot plus a 32px logomark, a game row is two :card
       logomarks (40px, 48px from 600px up), and both sit in the same box so
       the sentence starts at one x position down a board of interleaved rows.

       2026-09-21, KJ: "make those logos much bigger". The box grew with them —
       the widths below are the game pair plus its gap at each width (2x40+8,
       2x48+8), which is also wider than the prop pair, so the sentence still
       starts at one x position whichever kind of row it is. The HEIGHTS did
       not have to move: 2.75rem already clears a 40px mark and 3.25rem a 48px
       one, which is why this is a bigger mark and not a taller board. */
    .bv-cwho { height: 2.75rem; }
    .bv-cmedia { width: 5.5rem; }
    .bv-cname {
      font-size: 1.0625rem; line-height: 1.5rem; font-weight: 600;
      color: var(--bv-text);
    }

    /* ZONE 3 — the deal: the claim and the venue paying it, as two boxes of
       equal fixed height. The claim takes the flexible column so it absorbs
       the difference between a 390px card and a 1280px one; the venue takes a
       fixed one so the price button lands at the same x position down the
       board (the same argument the pricerow grid makes, applied to two
       columns instead of five). */
    .bv-cdeal {
      display: grid; grid-template-columns: minmax(0, 1fr) 8.5rem;
      gap: 0.75rem; align-items: start;
    }
    /* THE HERO. A teal wash and a teal ring — the money treatment already used
       by .bv-edgechip, at the scale a hero deserves rather than a chip's. */
    /* THE TWO CHIPS ARE THE SAME BOX (KJ 2026-09-21: "this chip [should be]
       the same size as the chip next to it"). The claim used to STRETCH to the
       full venue COLUMN — the button plus the gap plus the movement line —
       which made it 55px against the venue button's 42 and quietly undid the
       "two boxes of equal height" this zone was built on. align-items: start
       on the grid stops the stretch; both boxes now take the same declared
       height, and the movement line hangs below the venue button where it
       belongs rather than dragging a teal panel down with it.

       Still R2-safe, and more obviously so than before: both heights are
       declarations, so neither box can be resized by its content. */
    /* OPAQUE BASE, THEN THE WASH (KJ 2026-09-22: "now the text isn't really
       readable for the per $100 payout"). .bv-best-wash is a 12%-alpha teal —
       fine as the whole fill on a plain surface, but this chip sits directly
       in front of the hero watermark (see .bv-teamwatermark-hero below), and a
       translucent fill lets the mark show straight through it, right behind
       "Save $X" and "per $100 payout". color-mix bakes the same wash tint onto
       an OPAQUE --bv-surface — identical colour to the old `background:
       var(--bv-best-wash)` on the surface it always sat on, but now a solid
       layer nothing behind it can bleed through. The ring is unchanged. */
    .bv-claim {
      display: flex; flex-direction: column; justify-content: center;
      min-width: 0; min-height: 3.5rem;
      border-radius: 0.625rem; padding: 0 0.75rem;
      background: color-mix(in srgb, var(--bv-best) 12%, var(--bv-surface));
      box-shadow: inset 0 0 0 1px var(--bv-best);
    }
    .bv-claim-amount {
      display: block; height: 1.625rem; line-height: 1.625rem;
      font-size: 1.25rem; font-weight: 800; color: var(--bv-best);
    }
    /* Says what the amount is PER, and carries the sign in words. Not
       decoration and never dropped at a breakpoint: "$11.90" alone invites a
       reader to think it is what they win. */
    .bv-claim-unit {
      display: block; height: 0.875rem; line-height: 0.875rem;
      font-size: 0.6875rem; font-weight: 600; color: var(--bv-muted);
    }
    /* The venue button. Same height as the claim beside it, so the deal row is
       one band; .bv-cellbox / .bv-pricebutton bring the ring and the fill.

       ONE TEAL OBJECT PER CARD. The venue's price sets in --bv-text rather than
       the money teal it wears everywhere else on the site. Teal is still the
       money colour — it is just that on THIS card the money is the claim, and
       when the price wore it too the two boxes read as equals and the hero
       stopped being a hero. The price is still the biggest thing in its own
       box, which is what makes it findable; the colour is what ranks the two
       boxes against each other. Scoped to .bv-venue, so the matrix below and
       every other board keep the convention exactly. */
    .bv-venue {
      display: flex; flex-direction: column; justify-content: center;
      min-width: 0; height: 3.5rem; gap: 0.0625rem;
    }
    /* THE PRICE LINE — "44¢ (+127)", one line, at the claim amount's own size
       (KJ 2026-09-21: "enlarge the text so this chip is the same size as the
       chip next to it"). .bv-cprice-main tracks .bv-claim-amount exactly, at
       both widths, because the two chips are meant to read as equals across
       the deal row; the colour is what still ranks them (see .bv-venue above).

       FIXED HEIGHT, LIKE EVERY LINE ON THIS CARD. The line box is declared,
       not inherited from whichever unit happens to be revealed, so a toggle
       flip and a price tick both repaint inside a box that cannot move (R2).

       BASELINE, NOT CENTRE. The bracket is 13px against a 20px number and
       centring them would float it visually above the number's baseline,
       which is what makes a parenthetical read as a superscript.

       THE BRACKET IS WHAT GIVES, IF ANYTHING HAS TO. The primary number never
       truncates (flex-shrink: 0): it is the thing the chip exists to show.
       The track below is sized so that the widest real pairing still fits at
       390px, so this is a guard rather than a behaviour anyone will see. */
    .bv-cprice {
      display: flex; align-items: baseline; justify-content: flex-end;
      gap: 0.25rem; min-width: 0; overflow: hidden; white-space: nowrap;
      height: 1.625rem; line-height: 1.625rem; color: var(--bv-text);
    }
    .bv-cprice-main { flex: 0 0 auto; font-size: 1.25rem; font-weight: 800; }
    .bv-cprice-alt {
      min-width: 0; overflow: hidden; text-overflow: ellipsis;
      font-size: 0.875rem; font-weight: 600;
    }
    /* P5's tint AND the button's ring, on one element. Both are box-shadow, so
       the bare .bv-moved-* rules further up would simply replace the ring and a
       moved row would lose its button outline. Two classes beat one on
       specificity at source — no !important — and the two shadows compose.
       Still inset-only: the box is byte-for-byte what it was, so marking a row
       as moved shifts nothing (R2). */
    .bv-venue.bv-moved-up {
      box-shadow: inset 0 0 0 1px var(--bv-border), inset 2px 0 0 var(--bv-up);
    }
    .bv-venue.bv-moved-down {
      box-shadow: inset 0 0 0 1px var(--bv-border), inset 2px 0 0 var(--bv-down);
    }
    /* The venue column: the button and the movement line under it. A fixed
       height built from two fixed heights, which is what lets the claim beside
       it simply stretch to match (see .bv-claim) and still be tick-stable. */
    .bv-cvenuecol { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }

    /* P4/P5 — where the price opened. A fixed-height line, RESERVED on every
       card whether or not the bet has archived history: a board is normally a
       mixture, and a line that appeared only on the rows that had one would
       make the board a staircase (R2).

       IN THE VENUE COLUMN, RIGHT-ALIGNED. It is the VENUE'S opening price —
       "Open -190" is the same number the button above it is showing — and as a
       full-width line under the whole deal it read as an orphan belonging to
       nothing, with equal gaps above and below grouping it with neither. */
    .bv-cmove { height: 0.875rem; text-align: right; }

    /* ZONE 4 — the meta footer. Everything supporting the claim without
       competing with it. Two truncating fixed-height lines and the chevron. */
    .bv-cmeta { display: flex; align-items: center; gap: 0.75rem; }
    .bv-cmeta-lines { flex: 1 1 auto; display: flex; flex-direction: column; gap: 0.25rem; }
    .bv-cmetaline {
      height: 1rem; line-height: 1rem; font-size: 0.75rem;
      color: var(--bv-muted);
    }

    @media (min-width: 600px) {
      /* More air and bigger type on a wider card — the same composition, not a
         different one. The media box and the name step up together because the
         zone's height is pinned to the imagery inside it. */
      .bv-ccard { gap: 0.875rem; }
      /* Tracks .bv-move, which the block above shortens at this same width. */
      .bv-cmove { height: 0.75rem; }
      .bv-cwho { height: 3.25rem; }
      .bv-cmedia { width: 6.5rem; }
      .bv-cname { font-size: 1.25rem; line-height: 1.75rem; }
      /* The venue column widened with the price that now sits on one line in
         it (7.75/9.5rem before). Sized to the widest real pairing at each
         width — a five-figure American number and its bracket — so the chip
         is the same box in all three units and on every tick (R1/R2), and the
         extra width comes out of the claim's flexible column rather than out
         of the card. */
      .bv-cdeal { grid-template-columns: minmax(0, 1fr) 10.5rem; gap: 1rem; }
      .bv-venue { height: 4rem; }
      /* Tracks .bv-claim-amount, which steps up at this same width. */
      .bv-cprice { height: 2rem; line-height: 2rem; }
      .bv-cprice-main { font-size: 1.5rem; }
      .bv-cprice-alt { font-size: 0.875rem; }
      /* CAPPED, NOT STRETCHED, once there is room to stretch into. On a phone
         the claim fills its column and that is right; on a 1280px card the same
         rule gave eleven characters a 1,200px teal panel, which read as a
         banner rather than as the card's hero and made the amount inside it
         look small. 22rem is about twice the widest value ("Costs $15.00"), so
         the box still reads as generous and the amount still dominates it.
         The freed width becomes quiet space between the claim and the venue —
         the two objects the eye is meant to travel between. */
      .bv-claim { min-height: 4rem; padding: 0 1rem; max-width: 22rem; }
      .bv-claim-amount { height: 2rem; line-height: 2rem; font-size: 1.5rem; }
      /* THE META LINES STAY STACKED AT EVERY WIDTH, and that is a decision
         rather than an omission. Putting them side by side from 600px up was
         tried twice — half each, then sized from content — and both truncated
         BOTH lines on a 1280px card, because together they are longer than one
         row however the space is divided. Stacked, each gets the card's full
         width and neither truncates on a desktop at all.

         It costs one 20px line of card height, which is the trade KJ has
         already made explicitly: "Do NOT optimize for rows-per-viewport."
         It also leaves the footer with ONE behaviour instead of two, so there
         is no second geometry for a live update to be correct in. */
    }
