:root{
  /* 🔴 LOAD-BEARING — do not delete. src/shell.html reads this custom property to
     detect that the real stylesheet actually applied. If Cloudflare Pages served
     the HTML shell under the /css/ URL (see _headers), nosniff refuses to apply it
     and this property is absent — which is the shell's cue to self-heal. */
  --css-ok:1;
  --g900:#0a1a0e;--g800:#0f2914;--g700:#163d1c;--g600:#1e5226;
  --g500:#276b30;--g400:#3a8e44;--g300:#5cb566;--g200:#8ed494;
  --cream:#f5f0e8;--cream2:#e8e0cc;--gold:#c9a84c;--goldl:#e8c96a;--sand:#d4b896;--danger:#c0392b;
  /* ── SURFACES (single source) ───────────────────────────────────────────
     ⚠️ Measured 2026-08-16: EIGHT near-identical dark-green surface colours were
     in inline use — 0b1e10 0c1e10 0c1f10 0c2011 0d2211 0d2412 0e2513 0e2613
     (written without the # so this comment cannot be rewritten by its own
     migration — it was, first time round), 81 sites between them.
     Their luminances span a contrast ratio of
     1.081:1, i.e. they are indistinguishable to the eye, let alone on a phone in
     sunlight. That is not a hierarchy, it is DRIFT: eight ways to write one
     colour, eight things to change, eight chances to diverge — and they had
     already diverged (the achievements and rounds "Show more" buttons were two
     different boxes on one screen, 0d2211 vs 0e2613).
     Three roles, deliberately far enough apart to actually read as different:
       --surf-1  RARE well — only things that must read as a HOLE in the page:
                 table row hover, an inset hint. Almost nothing should be here.
       --surf-2  raised    — panels, sheets, list containers. THE DEFAULT.
       --surf-3  elevated  — controls ON a panel: ghost buttons, chips, closes
     ⚠️ --surf-1 is DARKER THAN THE PAGE GROUND, so anything on it reads as sunk.
     v210 mapped the members list, the Play-tab steps (.nrsurf/.nrseg) and the
     live-scoring strip to it; all four had been LIGHTER than the ground before,
     so they inverted and read as holes punched in the page. Jordan reported it
     as "not a fan of the colouring". They are containers you look AT, not wells:
     they belong on --surf-2. **If you are unsure, the answer is --surf-2.**
     If a surface wants to sit between two of these, the answer is a border or a
     shadow, never a fourth colour. See DESIGN.md. */
  /* ⚠️ These are NOT three of the old eight. Picking the darkest/middle/lightest of
     the originals reproduced the original 1.081:1 spread — consolidated, but still
     indistinguishable. Retuned so each step is a real one:
       ground->s1 1.033  s1->s2 1.207  s2->s3 1.312   total spread 1.584:1
     Ceiling is set by TEXT, not taste: on surf-3 the lightest surface, --g300 meta
     text measures 4.61:1. Going one step lighter (#184320) drops it to 4.43 and
     fails AA. Do not lighten surf-3 without re-checking --g300 and --g400 on it. */
  --surf-1:#08160b; --surf-2:#102a15; --surf-3:#17401f;
  /* ── RADII (single source) ──────────────────────────────────────────────
     ⚠️ Same measurement, same story: 8/9/10/12/14px were all in inline use (91
     sites). Four steps inside 6px cannot be told apart, so they read as
     sloppiness rather than as a system. Three steps + the pill.
     ⚠️ --r-pill is NOT interchangeable with --r-lg: the home CTA spec in
     DESIGN.md pins radius 999 deliberately. */
  --r-sm:8px; --r-md:12px; --r-lg:16px; --r-pill:999px;
  /* ── TYPE SCALE (single source) ─────────────────────────────────────────
     ⚠️ Measured 2026-08-14: 9/10/11/12/13px were ALL in heavy use (65-124 uses
     each) — five steps inside 4px. That is not a hierarchy: the eye cannot tell
     what to skip, so every screen read as cluttered no matter how many buttons
     were removed. Jordan: "lots of stacked buttons ... looks AI generated".
     Six steps, ratio >=1.2, nothing between them. 11px is THE FLOOR — this app
     is used outdoors at arm's length in sunlight, where 8-9px is unreadable.
     If something wants to sit between two steps the answer is WEIGHT or COLOUR,
     never a seventh size. See DESIGN.md. */
  --t-micro:11px;   /* uppercase labels, captions, tile headings */
  --t-sm:13px;      /* secondary + meta lines */
  --t-base:15px;    /* body, list names, buttons */
  --t-lg:19px;      /* card titles, section headings */
  --t-xl:24px;      /* page titles, summary-row scores */
  --t-2xl:32px;     /* hero numbers */
  /* ── SCORE SCALE (single source of truth) ────────────────────────────────
     Was three disagreeing systems: getScoreColor() text colours for live
     scoring, .eagle/.birdie/… chip classes for history, and _sumScoreCol()
     for the summary grid. Bogey was brown-grey in one and orange-red in
     another; par was a green chip in one place and 12%-white (invisible,
     read as "no score") in another.
     One scale now, used everywhere. Fills are opaque enough to read on the
     dark green card, and every -fg is contrast-checked against its -bg.

     ⚠️ 5th pass (v86) — GREEN-TO-RED RAMP, Jordan's palette, ringed chips.
     A single continuous good→bad gradient, dark green (eagle) → bright red
     (worse), with a white outline on every chip.
       eagle  140° L.21  dark green   birdie 150° L.25  green
       par    108° L.53  light green  bogey   48° L.50  yellow
       double  30° L.55  orange       worse   13° L.53  red

     TWO glyph colours here, and the split is PRINCIPLED — this is not a
     return to the v78 mistake. v78 alternated black and white with no visible
     logic, so the eye re-adjusted unpredictably. Here the two DARK fills take
     white (--sc-paper) and the four LIGHT fills take dark (--sc-ink): exactly
     what the eye already expects, and the boundary falls on the good/bad line.
     A single glyph colour is impossible for this ramp — it spans L.21→L.55,
     so no one colour clears 4.5:1 at both ends.

     The white ring (--sc-ring) is on ALL SIX, not just the dark ones: eagle is
     only 1.60:1 against the card and vanishes without it, and ringing every
     chip keeps them a matched set. It also replaces the old eagle-only gold
     ring, whose colour no longer exists in the scale.

     Constraints every future palette MUST satisfy (verified in python before
     shipping — measure, never eyeball):
     1. Every fill ≥4.5:1 against WHICHEVER glyph it uses. If all six can share
        one glyph colour, prefer that; if not, split on lightness as above.
     2. All 15 PAIRWISE combinations must differ by contrast ≥1.4 OR hue ≥40°.
        Checking each band against the background only is not enough — that was
        the v77 mistake, which left pairs 1–10° apart looking identical.
     3. Six bands need ~40°+ hue steps OR a deliberate lightness ladder. A
        palette spanning <70° of hue collapses into 2 hue families × 3 shades
        and cannot work, however nice the swatches look. (Rejected on this
        basis: #eb5e28…#6a8532, 63° of arc.)
     Retune here and nowhere else. */
  --sc-ink:#0a1408;        /* dark glyph — used on the four LIGHT fills */
  --sc-paper:#ffffff;      /* light glyph — used on the two DARK greens  */
  /* Chip outline. The dark greens sit at only 1.60:1 / 3.09:1 against the card,
     so without this eagle in particular melts into the background. */
  --sc-ring:0 0 0 1.5px rgba(255,255,255,.85);
  --sc-eagle-bg:#1f4d2e;   --sc-eagle-fg:var(--sc-paper); /* dark green */
  --sc-birdie-bg:#008040;  --sc-birdie-fg:var(--sc-paper);/* green      */
  --sc-par-bg:#6bb359;     --sc-par-fg:var(--sc-ink);     /* light green*/
  --sc-bogey-bg:#ffcc00;   --sc-bogey-fg:var(--sc-ink);   /* yellow     */
  --sc-double-bg:#ff8c19;  --sc-double-fg:var(--sc-ink);  /* orange     */
  --sc-worse-bg:#ff400d;   --sc-worse-fg:var(--sc-ink);   /* red        */
  /* Empty/unplayed hole — deliberately the ONLY faint, borderless state, so
     "nothing here" is visually distinct from any real score (par included). */
  --sc-empty-bg:rgba(255,255,255,.03); --sc-empty-fg:rgba(255,255,255,.25);
  /* OPAQUE equivalents of the empty state, for the shared PNG. Canvas has no
     compositing context to resolve the rgba() pair above against — it would blend
     with whatever was painted underneath (the dot texture), so unplayed holes came
     out mottled. These are the same two colours pre-flattened over the var(--surf-2)
     scorecard surface. Retune alongside --sc-empty-* so the two never diverge. */
  --sc-share-empty-bg:#12331a; --sc-share-empty-fg:#4d7355;
  /* TEXT variants, for places that colour the glyph rather than fill behind it
     (live hole-by-hole). The fills above are tuned for text-ON-fill and are too
     dark to use as text on the card. All are >=4.9:1 against --g800.
     These keep the SAME hue families as the fills so live scoring and the chips
     speak one language. Text on a dark ground can't lean on lightness the way
     fills can (everything has to stay light to be readable), so the separation
     here is carried mostly by hue — all 15 pairs verified. */
  --sct-eagle:#3fca65; --sct-birdie:#4bf7b7; --sct-par:#98ef82;
  --sct-bogey:#f3e474; --sct-double:#f8a46a; --sct-worse:#fb6238;
  /* 🔴 PIXEL RADII, NOT PERCENTAGES — AND THAT IS THE WHOLE FIX.
     Percentage radii resolve against width and height SEPARATELY, so one token renders a
     different corner SHAPE on every box it touches: the same value measured 1.15 on the
     50x50 FAB, 1.31 on an 80x70 ornament, 1.71 on .blob1's own 480x380, and 6.82 on a
     wide bar. Past ~1.35 the quarter-ellipse runs almost straight and then turns
     sharply, which reads as an angular WEDGE bitten out of the shape — the "transparent
     slice" reported across six builds.
     Jordan isolated it on device with /slicetest.html, which drew ten identical rounded
     bars differing by one property each: "blob radius 5 looks to have an issue.
     Everything else looks ok." That one answer cleared shadows, GPU layers,
     backdrop-filter, gradients and green-on-green edges in a single step, after four
     wrong CSS theories had been shipped.
     ⚠️ THE PREVIOUS NOTE HERE WAS EXACTLY BACKWARDS. It claimed --blob1 was safe on the
     big background shapes because the percentages are "a gentle curve" at that size. A
     480x380 box is WIDE, and wide boxes stretch the ratio hardest — the background blobs
     had the worst corner in the app.
     In px, every corner is a true circular quarter (ratio 1.00) on ANY box, so no
     element can inherit a wedge again. The organic character now comes from the corners
     being DIFFERENT SIZES, not from stretching each one out of round — which also keeps
     v239's lesson intact (pulling all four to the same value is a circle, and Jordan
     spotted that immediately).
     🔴 Each edge's two radii must still fit the box (TL+TR <= width, etc.) or CSS
     down-scales all of them proportionally. These are sized for the smallest consumer.
     ⚠️ --blob2/--blob3 are only used by the two background shapes, which are 320x360 and
     200x180 and measured in band; they stay as-is. */
  --blob1:170px 120px 145px 185px;
  /* FAB-only. ⚠️ v239 pulled ALL FOUR corners to 0.96-1.04 and the FAB stopped
     reading as a blob at all — a 0.96-1.04 ratio IS a circle. Only the bottom-left
     (1.35) was ever the defect. This keeps blob1's top-right (0.86) EXACTLY, pushes
     top-left further off-circle (1.03 -> 1.15), and fixes only BL (1.35 -> 1.04).
     Every side still sums to 100% so no CSS radius scaling kicks in. See #chatfab. */
  --blob-fab:62% 38% 52% 48% / 54% 44% 56% 46%;
  /* 🔴 THE SAME FIX, FOR EVERY OTHER SMALL SQUARE-ISH CONTROL (2026-08-23).
     v240 corrected --blob1's flat bottom-left corner on #chatfab and on the chat header
     chip — "both spots Jordan circled" — but every OTHER element on --blob1 kept it, and
     they are the same shape of control: .aicon (60x60), .sbicon (38x38), .cblob (44x40),
     .emptyicon (56x50), .card::after (80x70). MEASURED bottom-left ratios: 1.35, 1.35,
     1.49, 1.51, 1.54 — all worse than or equal to the 1.35 that was diagnosed as the
     original "transparent slice", because a NON-SQUARE box stretches the ratio further.
     ⚠️ --blob1 itself stays as-is: on the 480x380 background blobs the same percentages
     are a gentle curve, which is what it was authored for. This is the small-control
     variant, exactly as --blob-fab is the 50px-button variant.
     Ratios here: TL 1.15, TR 0.86, BR 0.93, BL 1.04 — blob character kept (v239's
     mistake was pulling all four to ~1.0, which IS a circle), flat corner gone.
     🔴 Every side's two radii must still sum to 100% or CSS down-scales ALL of them. */
  --blob-sm:62% 38% 52% 48% / 54% 44% 56% 46%;
  --blob2:130px 165px 110px 155px;   /* px for the same reason as --blob1 */
  --blob3:55% 45% 60% 40% / 48% 55% 45% 52%;
  --fd:'Fraunces',Georgia,serif;
  /* ⚠️ Body font. Cabinet Grotesk was named here for months but NEVER loaded on the
     PWA: it is not a Google Font, so the Fonts request silently ignored it and every
     web user has always seen their device's default sans-serif. Naming a font that
     never arrives is a lie in the stylesheet, so it is gone (Jordan's call,
     2026-08-02 — "if I haven't seen this font in the PWA I'm not too bothered").
     Self-hosting the real woff2 is NOT a free option: the Fontshare EULA (§02)
     forbids uploading it to a public server / font-serving over the internet without
     written consent, so the sanctioned web route is their API — a fourth third-party
     origin on the critical path, which is not worth it for a face nobody has seen.
     ⚠️ The APK is a DIFFERENT case and is unaffected: it bundles the licensed woff2
     locally (vendor/fonts/fonts.css), which §01 permits under "Apps". Native still
     renders Cabinet Grotesk and should keep doing so — do not "unify" these.
     This is an explicit system-font stack, i.e. what the PWA has really been using
     all along; it just says so now. Fraunces (--fd) is unaffected and still loads. */
  --fb:system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;
  /* --nav is the ICON-ROW height (unchanged, still what the icons occupy).
     --nav-tall is the bar's OUTER height EXCLUDING the safe-area inset and excluding
     the ball's protrusion strip. MEASURED at 56px on every device profile with
     tests/app/measure-wrapnav.py (bar surface height), so everything that must clear
     the nav — .pg bottom padding, the chat FAB — tracks this plus the inset.
     ⚠️ It does NOT include --ball-rise: the ball protrudes over CONTENT by design, and
     padding the page for it would leave an obvious dead band across the whole width for
     the sake of one 56px circle. The FAB is the one thing that must dodge the ball, and
     it is on the right-hand side where the ball never reaches.
     ⚠️ Re-measure with tests/app/measure-wrapnav.py after touching .bntabs/.bnin
     paddings — this number is not derived, it is observed. */
  --nav:64px;
  --nav-tall:52px;
  /* 🔴 THE BAR'S BOTTOM GAP — one definition, used by #botnav AND the chat FAB.
     ⚠️ THE FULL SAFE-AREA INSET IS TOO MUCH GAP. `env(safe-area-inset-bottom)` is 34px
     on a notched iPhone, but that is a generous SYSTEM RESERVE, not the size of the
     home indicator: the pill is ~5px tall sitting ~8px off the screen edge, so it
     really occupies about the bottom 14px. Honouring all 34 left the bar floating ~20px
     higher than it needed to — Jordan, twice: "there is still such a large gap on the
     iphone", then "its better, there is still a gap though?".
     So the inset is CAPPED, not consumed whole. --nav-inset takes the smaller of the
     real inset and 20px, which clears the indicator by ~6px and lands the iPhone gap
     close to Android's 10px instead of 3x it.
     ⚠️ Do NOT drop to 0 — that puts the bar ON the home indicator, which is the bug
     this reserve exists to prevent. The cap is the fix, not removal.
     Android/desktop (inset 0) still resolves to 10px via --nav-gap below. */
  --nav-inset:min(20px, env(safe-area-inset-bottom));
  --nav-gap:calc(10px - min(10px, env(safe-area-inset-bottom)));
  /* ── THE BALL AND THE WRAP (2026-08-22, third pass) ───────────────────────────
     ⚠️ THESE NUMBERS ARE COUPLED TO THE SVG PATH IN src/shell.html. Re-derive both
     together with tests/app/measure-wrapnav.py, which is the generator the shipped
     path came out of — never hand-edit one and eyeball the other.

     🔴 THE BAR NO LONGER NOTCHES THE BALL, IT WRAPS IT. Jordan, 2026-08-22: "the golf
     ball takes the eye from everything else… reduce the ball size a bit and maybe try
     wrapping it in the navbar rather than notching it? So the navbar comes out and
     wraps around the top of the ball."
     The notch was a scoop CUT DOWN into the bar with a 5px gap all round, so the ball
     read as floating in a hole. The wrap is the inverse: the surface SWELLS UP either
     side of the ball and its inner edge hugs the ball's silhouette past the equator,
     ending in tips above the bar's flat top. All three were rejected on device.

     --ball-size  the sphere. 70 -> 56 (-20%): with a collar around it the assembly is
                  visually heavier than a bare ball, so the ball itself has to come down
                  more than the eye expects. 50 was tried and reads undersized against a
                  60px bar; this is the New Round CTA, not an ornament.
     --ball-rise  how much of the ball stands ABOVE the bar's flat top edge, and also
                  the transparent strip reserved at the top of #botnav (padding-top) so
                  nothing is clipped. Ball centre therefore sits (size/2 - rise) = 6px
                  BELOW the bar's top edge — that offset is what lets the collar meet
                  the flat surface at a shallow angle instead of a T-junction.
     🔴 THE WRAP IS GONE — THE BAR NOTCHES THE BALL AGAIN (2026-08-23).
     Jordan, after three attempts: "you dont seem to be understanding the requirement of
     the taskbar wrapping the ball, so lets go back to the notch keeping the same ball
     position and size."
     ⚠️ DO NOT RE-PROPOSE THE WRAP. Every variant was built, rendered and rejected: the
     surface tangent to the ball's circle (read as a swell, not a wrap), a true collar
     with radial tips (read as SPIKES), and a concentric arc outside the ball (which is
     just the notch again). The shape that works is the one below.
     The BALL's size and position are UNCHANGED from the wrap version — only the surface
     shape reverted, which is exactly what was asked for.
     ⚠️ The swell's inner edge runs at radius 25 against a 28px ball radius — it tucks
     3px UNDER the sphere ON PURPOSE, and that is what makes this read as a wrap. Two
     other shapes were rendered and rejected:
       · a concentric arc OUTSIDE the ball (a 5px gap all round) — that is the old notch
         with extra steps; the ball still reads as floating in a hole.
       · a true collar, two horns ending in radial cuts past the equator — the tips read
         as SPIKES either side of the ball, not as a wrap.
     What works is a solid swell that runs tangentially ONTO the ball's own circle and
     disappears behind it: no seam, no tips, and the green visibly climbs the ball. */
  --ball-size:56px;
  --ball-rise:20px;
  /* The wrap svg's rendered width. FIXED, not stretched: it is flanked by two flexible
     shoulders, so the wrap is identical at every viewport width. An earlier version
     stretched one path across the whole bar and was measured failing at 320px (a 50.9px
     hole for a 56px ball); see the markup note. */
  --notch-w:176px;  /* ⚠️ "notch" is a LEGACY NAME: this centre piece wraps now. Kept
                       because the token, the class (.bnsurf-notch) and three tests all
                       key off it, and a rename buys nothing but churn. */
  /* The trough's FOOTPRINT on the bar's top edge — where the easing fillets meet the
     flat surface — as distinct from the svg's full width (the rest is ordinary bar the
     icons may sit on). This is what the tab row must keep clear; see .bnspacer.
     Measured 96.7px including both fillets, reserved at 98.
     ⚠️ An output of --ball-size and the trough gap, not a free parameter;
     test-nav-hide re-derives it from the shipped path. */
  --notch-mouth:98px;
  /* The bar's outer corner radius. 🔴 IT IS A FRACTION OF --nav-tall, NOT A CONSTANT.
     At the bar's original 72px height, 14px was 19% — a soft rounded end. Slimming the
     bar to 52px without touching this took the same 14px to 27% of the height, and a
     corner that tight against a low-contrast fill reads as an angular WEDGE bitten out
     of the end rather than as rounding — the same failure mode --blob1's 1.35 corner
     produced on the FAB ([[clubhaus-fab-blob-corner]]), moved onto the bar.
     ⚠️ Keep it at ~19% of --nav-tall. If the bar's height changes, this changes with it. */
  --r-bar:10px;
  /* 🔴 THE BAR'S SURFACE FILL — ONE definition. It is referenced from THREE places
     that must agree (.bnsurf-side, .bnsurf-p's fill, and the live dot's ring, which
     punches through the icon so it has to be painted in the bar's own colour). All
     three were separate hardcoded hexes until 2026-08-22, and the dot's copy was not
     even covered by a test. See [[clubhaus-single-source-principle]].
     ⚠️ MUST stay a flat hex with NO alpha. Jordan: "the nav bar is transparent, needs
     to have a good colour" — even at .97 the score numbers behind it ghosted through.
     A nav bar is chrome, not a scrim; depth comes from the drop-shadow.
     🔴 IT MUST ALSO NOT BE BRIGHT. It was #1e4827, the LIGHTEST surface in the app —
     brighter than the panels (1.47:1) as well as the page ground (1.72:1) — and Jordan:
     "the colour of the nav bar is not quite right, I think it is too light and again
     draws the eye too much."
     ⚠️ THE FIRST FIX WAS TOO TIMID AND THAT IS THE LESSON. #163a1d measured a clear
     improvement (1.72 -> 1.42 against the page) and was obvious in a side-by-side
     render, but on the actual phone Jordan's verdict was "I dont see any colour change
     on the nav bar". A 0.30 step in contrast ratio is a measurement, not a perception:
     against a dark ground the eye reads the RELATIONSHIP to neighbouring surfaces, and
     at 1.21:1 vs the panels the bar was still the brightest thing on the screen.
     #123016 sits at 1.25:1 against the page and 1.07:1 against the panels — the bar now
     reads as the same material as a card, and only the shadow and its corners separate
     it. ⚠️ When a colour change is reported as invisible, do not re-measure and defend
     the number; move it until it is visible, then check the floor still holds.
     ⚠️ test-nav-hide asserts a BAND, 1.18-1.45:1 — both walls are real complaints, one
     from each direction. Do not push through either without a new one. */
  /* Slightly clearer than the previous #17381c without returning to the over-bright
     v246 bar. Measured against --g900: 1.44:1, inside the guarded chrome band. */
  --nav-surf:#173b1e;
  --radius:16px;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
/* ⚠️ iOS STANDALONE PWA: `height:100%` resolves against a viewport that EXCLUDES the
   home-indicator area, so the body ends short of the physical screen — and because
   #botnav is position:fixed;bottom:0, fixed positioning anchors to that SHORT viewport
   too, floating the whole bar up and leaving a dark band beneath it. That band is the
   "gap" reported repeatedly on iPhone (Sam's screenshot: icons ~270px off the bottom of
   a 2048px screen, nowhere near the 16px the CSS predicts).
   `100dvh` measures the LARGE viewport including that area, so the body reaches the
   screen and the fixed bar anchors to the real bottom. The height:100% line stays first
   as the fallback for browsers without dvh. This is what the old "make the BODY reach the
   screen (viewport sizing)" note was pointing at — headless Chromium has no iOS
   standalone mode, so no local harness can reproduce it. */
html,body{height:100%;font-family:var(--fb);background:var(--g900);color:var(--cream);overscroll-behavior:none}
@supports (height:100dvh){html,body{height:100dvh}}
body{overflow:hidden}
/* Contain pull/overscroll inside scrollable panels so a pull-down at the top of
   a list scrolls the panel — it never bubbles up to the browser's pull-to-refresh
   (which would reload the whole PWA, blowing away the screen you're on).
   #main and nav keep `contain`: they DO want the local bounce, they just must not
   chain to the document. */
#main,nav{overscroll-behavior:contain}
/* ⚠️ SHEET SCROLLERS USE `none`, NOT `contain` (Jordan, 2026-08-22: "it just pulls the
   text down expanding blank space… unresponsive and not very clean").
   `contain` blocks scroll CHAINING but deliberately PRESERVES the local rubber-band.
   So on iOS a swipe-down that failed to arm a dismiss gave the elastic stretch instead
   — content sliding down over blank space — which is the reported symptom, and it is
   invisible on Android because Chromium does not rubber-band at all.
   `none` additionally suppresses the bounce, so a downward drag at the top of a sheet
   produces NO scroll at all and belongs unambiguously to the dismiss gesture.
   ⚠️ This is load-bearing for the JS too: the bounce drove scrollTop NEGATIVE, which is
   the only reason the swipe handler needed its rubber-band heuristics. Reverting this to
   `contain` resurrects them. */
.modal,.chatmsgs,#hcp-modal-content,#mplayerdetail-content,#myrounds-list{overscroll-behavior:none}
/* Hide scrollbars everywhere (scrolling still works) — no visible track/thumb */
*{scrollbar-width:none;-ms-overflow-style:none}
::-webkit-scrollbar{width:0;height:0;display:none}
body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellipse 80% 60% at 15% 25%,rgba(39,107,48,.12) 0%,transparent 55%),radial-gradient(ellipse 50% 70% at 85% 75%,rgba(26,61,28,.15) 0%,transparent 55%);pointer-events:none;z-index:0}
/* ── STATUS-BAR SCRIM (iOS standalone) ────────────────────────────────────────
   ⚠️ Fixes a DIFFERENT bug from the #botnav one. `.pg` carries the top inset as
   PADDING, and padding belongs to the scrolling content — so it only protects the
   page at scrollTop 0. Scroll down and content passes under the translucent iOS
   status bar with nothing behind it (Sam's v137 screenshot: the "Add members"
   button sitting under the 22:19 clock). Adding more padding CANNOT fix this;
   the strip needs its own opaque backdrop that does not scroll.
   Same idea as #botnav::after at the other end of the screen. z-index sits below
   the nav (500) and modals (800) but above page content, and pointer-events:none
   so it can never eat a tap on anything beneath it.
   Height collapses to 0 where there is no inset, so Android/desktop are untouched. */
#statusscrim{position:fixed;top:0;left:0;right:0;height:env(safe-area-inset-top);
  background:var(--g900);pointer-events:none;z-index:600;display:none}
@media(max-width:768px){#statusscrim{display:block}}
.blob{position:fixed;pointer-events:none;z-index:0;opacity:.04;border-radius:var(--blob1)}
.blob1{width:480px;height:380px;background:var(--g300);top:-100px;left:-80px;animation:bf 20s ease-in-out infinite alternate}
.blob2{width:320px;height:360px;background:var(--g400);border-radius:var(--blob2);bottom:-60px;right:-60px;animation:bf 25s ease-in-out infinite alternate-reverse}
@keyframes bf{0%{transform:translate(0,0) rotate(0) scale(1)}50%{transform:translate(18px,-12px) rotate(5deg) scale(1.03)}100%{transform:translate(-8px,16px) rotate(-3deg) scale(.98)}}
/* The FAB's own float. It must NOT reuse `bf` (above): those keyframes were written
   for the 480x380 background blobs, where an 18px drift is imperceptible. #chatfab is a
   50px button anchored `right:20px` and sitting a fixed gap above the nav pill, so on it
   the same numbers read as a bug — `translate(18px,…)` carried its right edge to ~2px
   from the screen edge (reported as "goes off the screen a bit") and `translate(…,16px)`
   drove it 16px DOWN into the pill it is positioned to clear.
   Budget here, against the FAB's own anchors: at most 2px right (leaves >=18px of edge
   margin) and at most 4px down (leaves >=8px of the 12px pill gap). Vertical-dominant so
   it still breathes, and the rotation/scale carry the organic character instead of
   travel. ⚠️ Keep the X and Y maxima inside that budget if you retune it. */
@keyframes fabf{0%{transform:translate(0,0) rotate(0) scale(1)}50%{transform:translate(-2px,-6px) rotate(2deg) scale(1.02)}100%{transform:translate(2px,4px) rotate(-1.5deg) scale(.99)}}
@keyframes slideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
@keyframes sheetUp{from{transform:translateY(100%)}to{transform:translateY(0)}}

/* ── FORMS ── */
label{font-size:var(--t-micro);color:var(--g300);letter-spacing:1.2px;text-transform:uppercase;display:block;margin-bottom:5px;margin-top:14px;font-weight:500}
input[type=text],input[type=email],input[type=password],input[type=number],input[type=date],select,textarea{width:100%;padding:13px 14px;background:rgba(10,26,14,.8);border:1px solid rgba(39,107,48,.4);border-radius:var(--r-md);color:var(--cream);font-family:var(--fb);font-size:var(--t-base);outline:none;transition:border-color .2s;-webkit-appearance:none;appearance:none}
input:focus,select:focus,textarea:focus{border-color:var(--g300);background:rgba(10,26,14,.95)}
input::placeholder{color:rgba(255,255,255,.2)}
select option{background:var(--g800)}

/* ── BUTTONS ── */
.btn{display:flex;align-items:center;justify-content:center;width:100%;min-height:46px;padding:12px 14px;background:var(--g500);border:none;border-radius:var(--r-md);color:var(--cream);font-family:var(--fb);font-size:var(--t-base);font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s,opacity .15s,transform .15s;margin-top:16px;letter-spacing:.3px;-webkit-tap-highlight-color:transparent;gap:6px}
.btn:active{opacity:.85;transform:scale(.98)}
.btn:focus-visible,button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{
  outline:2px solid var(--g300);outline-offset:2px
}
.btn:disabled,button:disabled{cursor:not-allowed;opacity:.48}
.bsm{width:auto;padding:9px 16px;font-size:var(--t-sm);margin-top:0;border-radius:var(--r-sm)}
.bgh{background:var(--surf-3);border:1px solid rgba(58,142,68,.26);color:var(--g200)}
/* Icon-only affordance for RARE actions that must exist but must not compete with
   the reading path (report a name, and anything of that class). DESIGN.md: "rare or
   destructive actions get icon affordances out of the reading path, not text rows".
   36px is the tap target; the glyph inside is 16px. Deliberately borderless at rest
   so it reads as chrome rather than as a button offered to you. */
/* ⚠️ --g400, not --g500. Measured against the --g900 ground: g500 is 2.77:1, under
   the 3:1 floor for a non-text UI element, and this app is read in direct sunlight.
   g400 is 4.40:1 — still recessive next to the cream name, but actually visible. */
.icobtn{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;
  width:36px;height:36px;padding:0;margin:0;background:transparent;border:none;
  border-radius:var(--r-sm);color:var(--g400);cursor:pointer;transition:background .15s,color .15s;
  -webkit-tap-highlight-color:transparent}
.icobtn:hover{background:rgba(39,107,48,.16);color:var(--g300)}
.icobtn:active{background:rgba(39,107,48,.24)}
.icobtn:focus-visible{outline:2px solid var(--g400);outline-offset:2px}
.bdng{background:rgba(192,57,43,.8);color:#fff}
.issue-kind.on{background:rgba(201,168,76,.16);border-color:rgba(201,168,76,.5);color:var(--goldl)}
/* Google sign-in. Their brand guidelines want the official mark unmodified on a
   neutral surface, so this deliberately does NOT inherit the green button skin. */
.bgoogle{background:#fff;color:#1f1f1f;font-weight:600;gap:10px;margin-top:0}
.bgoogle:active{background:#f1f1f1}
.gmark{flex:0 0 auto;display:block}
.auth-provider-label{display:inline-block}
/* Apple's Sign in with Apple guidelines: their mark, unmodified, on black or white
   with a minimum clear space. Black is the variant that suits this dark UI. */
.bapple{background:#000;color:#fff;font-weight:600;gap:8px;margin-top:10px;border:1px solid rgba(245,240,232,.22)}
.bapple:active{background:#141414}
.amark{flex:0 0 auto;display:block;margin-top:-2px}
/* "or" divider between the password and Google paths */
.authdiv{display:flex;align-items:center;gap:10px;margin:14px 0}
.authdiv::before,.authdiv::after{content:"";flex:1;height:1px;background:rgba(245,240,232,.15)}
.authdiv span{font-size:var(--t-micro);letter-spacing:1px;text-transform:uppercase;color:var(--g500)}
.bgld{background:linear-gradient(135deg,var(--gold),var(--goldl));color:var(--g900);font-weight:700}
.errmsg{color:#fc8181;font-size:var(--t-sm);margin-top:10px;text-align:center;padding:9px 14px;background:rgba(192,57,43,.12);border-radius:var(--r-sm);border:1px solid rgba(192,57,43,.25)}

/* ── AUTH ── */
/* ⚠️ The safe-area insets are ADDED to the 24px, not a replacement for it — #auth is
   `inset:0` under `viewport-fit=cover`, so without them a tall signup form scrolls its
   top field under the iOS clock. z-index 900 puts it above #statusscrim (600), so like
   .fullsheet it must carry its own inset. Collapses to a flat 24px on Android. */
#auth{position:fixed;inset:0;z-index:900;background:var(--g900);display:flex;align-items:center;justify-content:center;overflow-y:auto;
  padding:calc(24px + env(safe-area-inset-top)) calc(24px + env(safe-area-inset-right)) calc(24px + env(safe-area-inset-bottom)) calc(24px + env(safe-area-inset-left))}
.acard{width:100%;max-width:400px;background:linear-gradient(160deg,rgba(15,41,20,.95),rgba(10,26,14,.98));border:1px solid rgba(58,142,68,.3);border-radius:24px;padding:36px 28px;box-shadow:0 24px 64px rgba(0,0,0,.5);animation:slideUp .5s cubic-bezier(.16,1,.3,1);position:relative;overflow:hidden}
.acard::before{content:'';position:absolute;width:200px;height:180px;background:var(--g500);border-radius:var(--blob-sm);top:-60px;right:-50px;opacity:.12;pointer-events:none}
.alogo{display:flex;flex-direction:column;align-items:center;margin-bottom:28px;gap:10px}
.aicon{width:60px;height:60px;background:linear-gradient(135deg,var(--g500),var(--g400));border-radius:var(--blob-sm);display:flex;align-items:center;justify-content:center;font-size:var(--t-xl);box-shadow:0 8px 24px rgba(39,107,48,.35);animation:bf 8s ease-in-out infinite alternate}
.atitle{font-family:var(--fd);font-size:var(--t-xl);font-weight:700;color:var(--cream);letter-spacing:-.5px;text-align:center;line-height:1}
.asub{font-size:var(--t-micro);color:var(--g300);letter-spacing:2.5px;text-transform:uppercase;text-align:center}
.atabs{display:flex;background:rgba(10,26,14,.6);border:1px solid rgba(39,107,48,.2);border-radius:var(--r-sm);padding:3px;margin-bottom:20px}
.atab{flex:1;min-height:44px;padding:9px;border:none;border-radius:var(--r-sm);font-family:var(--fb);font-size:var(--t-base);font-weight:500;cursor:pointer;transition:background .2s,color .2s,box-shadow .2s;background:transparent;color:rgba(255,255,255,.4)}
.atab.on{background:var(--g600);color:var(--cream);box-shadow:0 2px 8px rgba(0,0,0,.3)}

/* ── PASSWORD RULE LIST + EMAIL-ACTION SCREEN ──
   Classes, not inline style attributes: the CSP ratchet in test-csp-migration.py
   fails any ADDITION to the inline-style debt, deliberately, so new markup has to
   be written the way the migration is heading rather than adding to the backlog.
   Shared by the signup tick-list and the password-reset one. */
.hidden{display:none}
.splashmark{width:220px;max-width:62%;height:auto}
/* ⚠️ .awordmark carried NO css — every sizing rule lived in an inline style attribute.
   The email-action card omitted that attribute and the 440px-wide asset rendered at
   full width, overflowing the card and clipping the logo. Caught only by rendering it;
   the markup and all 68 suites were green. Class-based so new cards inherit it. */
.awordmark{width:200px;max-width:70%;height:auto;margin-bottom:4px}
.splashspin{width:28px;height:28px;border:2.5px solid rgba(92,181,102,.2);border-top-color:var(--g400);border-radius:50%;animation:spin .7s linear infinite}
.nomt{margin-top:0}
.mt12{margin-top:12px}
.pwrules{display:none;margin:10px 0 2px;font-size:var(--t-sm);line-height:1.65}
.pwrules.on{display:block}
.pwrules-title{color:var(--cream);font-weight:600;margin-bottom:4px}
.pwrule{color:var(--g400)}
.pwrule.met{color:var(--g200)}
.pwrule>span{display:inline-block;width:16px}
/* Static note, not a tick — symbols are ALLOWED, so there is nothing to satisfy. */
.pwnote{color:var(--g500);margin-top:2px}
.eahead{font-family:var(--fd);font-size:var(--t-lg);font-weight:700;color:var(--g100);text-align:center;margin-bottom:4px}
.eawho{font-size:var(--t-sm);color:var(--g400);text-align:center;margin-bottom:18px}
.eawait{text-align:center;padding:22px 0}
.easpin{width:26px;height:26px;border:2.5px solid rgba(92,181,102,.2);border-top-color:var(--g400);border-radius:50%;animation:spin .7s linear infinite;margin:0 auto}
.eawaitmsg{font-size:var(--t-sm);color:var(--g400);margin-top:14px}
.eadone{text-align:center}
.eadone-title{font-family:var(--fd);font-size:var(--t-lg);font-weight:700;color:var(--g100);margin-bottom:8px}
.eadone-msg{font-size:var(--t-sm);color:var(--g400);line-height:1.6;margin-bottom:18px}
.eaerr{display:none;font-size:var(--t-sm);margin-top:12px;text-align:center;color:#fc8181}
.eaerr.on{display:block}

/* ── APP SHELL ── */
#app{display:none;height:100%;flex-direction:row;position:relative;z-index:1}
#app.visible{display:flex}

/* ── SIDEBAR (desktop) ── */
#sb{width:230px;flex-shrink:0;background:rgba(8,20,10,.85);backdrop-filter:blur(20px);border-right:1px solid rgba(39,107,48,.15);display:none;flex-direction:column;z-index:10}
.sbhead{padding:24px 18px 20px;border-bottom:1px solid rgba(39,107,48,.1)}
.sbicon{width:38px;height:38px;background:linear-gradient(135deg,var(--g500),var(--g400));border-radius:var(--blob-sm);display:flex;align-items:center;justify-content:center;font-size:var(--t-base);margin-bottom:8px;animation:bf 8s ease-in-out infinite alternate}
.sbtitle{font-family:var(--fd);font-size:var(--t-base);font-weight:700;color:var(--cream);line-height:1}
.sbtag{font-size:var(--t-micro);color:var(--g400);letter-spacing:2px;text-transform:uppercase;margin-top:2px}
nav{flex:1;padding:10px 10px;overflow-y:auto}
.ni{display:flex;align-items:center;gap:10px;min-height:44px;padding:10px 12px;border-radius:var(--r-sm);cursor:pointer;transition:background .15s,color .15s,border-color .15s;font-size:var(--t-sm);font-weight:500;color:var(--g400);margin-bottom:1px;border:1px solid transparent;background:none;width:100%;text-align:left;-webkit-tap-highlight-color:transparent;letter-spacing:.2px}
.ni:hover{background:#0d2412;color:var(--cream)}
.ni.on{background:#102a15;color:var(--cream);border:1px solid rgba(39,107,48,.25)}
.ni .ic{font-size:var(--t-base);width:20px;text-align:center;flex-shrink:0}
.nbadge{margin-left:auto;background:var(--gold);color:var(--g900);border-radius:20px;font-size:var(--t-micro);font-weight:700;padding:2px 6px;min-width:18px;text-align:center;display:none}
.sbbot{padding:10px 10px 16px;border-top:1px solid rgba(39,107,48,.1)}
.upill{display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:var(--r-sm);background:var(--surf-2);margin-bottom:6px;border:1px solid rgba(39,107,48,.15)}
.upname{font-size:var(--t-sm);font-weight:500;color:var(--cream);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.uphcp{font-size:var(--t-micro);color:var(--g300)}

/* ── MAIN ── */
#main{flex:1;overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch;height:100%}
/* ⚠️ Bottom padding must clear the FLOATING BAR **and** the chat FAB that sits above it.
   The bar does not push layout (content scrolls under it), so this padding is the ONLY
   thing stopping the last row of a list being permanently covered — the known "chat FAB
   overlaps the last leaderboard row" bug.
   🔴 IT MUST COMPOSE EVERY TERM #chatfab COMPOSES, OR IT SILENTLY UNDER-RESERVES.
   --nav-gap was missing here while #chatfab had it (2026-08-22): the FAB floated 10px
   higher on any device with inset 0 (Android, iPhone SE) and the padding did not follow,
   so clearance over the FAB fell 22px -> 12px and Jordan reported the old bug back:
   "sections being cut off the fab button again… it also is happening to the task bar".
   ⚠️ THE REAL LESSON IS THE TOKEN, NOT THE TERM. --nav-tall was redefined from the bar's
   OUTER height (72px, what content had to clear) to its SURFACE height (60px, what gets
   painted) without moving the consumers off it — one token, two meanings, so a painting
   change silently became a layout change. Everything that RESERVES space is listed
   below; everything that PAINTS uses --nav-tall. Keep the two sets apart.
     reserves: .pg (this), #chatfab
     paints:   .bnsurf, .bnsurf-side, .bnsurf-notch
   The 84px is the FAB (50px) + 12px above the bar + 22px of breathing room — verify with
   tests/app/measure-navclearance.py, which asserts the real rects, not this arithmetic. */
.pg{display:none;width:100%;max-width:1080px;margin-inline:auto;padding:calc(24px + env(safe-area-inset-top)) calc(20px + env(safe-area-inset-right)) calc(var(--nav-tall) + var(--nav-gap) + var(--nav-inset) + 84px) calc(20px + env(safe-area-inset-left));animation:fadeIn .3s ease}
.pg.on{display:block}
.pgtitle{font-family:var(--fd);font-size:var(--t-xl);font-weight:700;color:var(--cream);letter-spacing:-.5px;line-height:1}
/* ⚠️ `.pgsub` (the page subheading) was REMOVED, not restyled. All seven read as
   restatements of the title they sat under — "New Round / Log scores or go live",
   "Live Scoring / Update hole by hole in real time", "Leaderboard / Tap any category
   to see everyone's stats". None told you anything the title and the page below it
   did not. DESIGN.md copy rule: every word earns its place.
   Don't reintroduce the class. If a page genuinely needs to explain itself, that is
   an empty state or an inline hint at the control, not a permanent line of chrome
   under every title. */
.pghead{margin-bottom:18px}

/* ── CARDS ── */
/* The gradient is sized in PIXELS, not percent. As a 145deg percentage gradient it
   resolved over the element's own height, so a tall card (Courses Played lists 8
   rows; every other leaderboard card shows 3) stretched the light #12301a end across
   far more visible area and read noticeably lighter green than its neighbours.
   Fixed 260px means every card resolves over the same distance and matches
   regardless of row count. Reported by Jordan 2026-07-29. */
/* 🔴 NO backdrop-filter HERE, AND IT MUST NOT COME BACK (2026-08-23).
   It was `backdrop-filter:blur(10px)` on a card that paints an OPAQUE background over
   its whole box (#0b1d10, and both gradient stops opaque). A backdrop-filter blurs what
   is BEHIND an element, so on an opaque element it can never be seen: it was doing
   nothing visible and costing a compositor layer.
   ⚠️ WHY IT MATTERED: backdrop-filter + border-radius + overflow:hidden is the known
   recipe for a rounded-corner compositing artefact — the corners sample unblurred
   backdrop and read as pale angular wedges. This was the ONLY element in the app
   combining all three, and it sits directly behind the chat FAB and above the nav bar,
   which is exactly where Jordan reported "a transparent slice missing" across five
   builds. ⚠️ It never reproduced in headless Chromium, which composites in software —
   that is why four CSS theories (shadow direction, bar colour, --r-bar, blob corners)
   all failed to fix it. A bug that only appears on real GPU compositing will not show up
   in a local render, so DO NOT conclude "cannot reproduce" means "not real". */
.card{background:linear-gradient(145deg,#12301a,#0b1d10 260px);background-color:#0b1d10;border:1px solid rgba(58,142,68,.18);border-radius:var(--radius);padding:18px;margin-bottom:12px;position:relative;overflow:hidden}
/* Inner tiles (stat cells, list rows, sub-panels). These were inline
   rgba(39,107,48,.06-.10) — 6-10% opaque, roughly 10x more transparent than .card —
   so the fixed background blobs showed straight through and everything read flat.
   The leaderboard looked better purely because it uses .card. Now near-opaque with a
   subtle top-light gradient and a soft shadow so tiles sit ABOVE the surface they're
   on rather than dissolving into it. Reported by Jordan + Mac, 2026-07-27. */
.tile{background:linear-gradient(160deg,rgba(24,58,30,.92),rgba(16,40,21,.96));
  border:1px solid rgba(58,142,68,.22);border-radius:var(--r-md);
  box-shadow:0 2px 8px rgba(0,0,0,.28),inset 0 1px 0 rgba(255,255,255,.04)}
/* Slightly recessed variant for rows INSIDE a .tile or .card (e.g. list items). */
.tile-sub{background:rgba(12,32,17,.75);border:1px solid rgba(58,142,68,.14);
  border-radius:var(--r-sm)}
/* A row of related figures on ONE surface, divided rather than boxed. Used for the
   player profile's eagles/birdies/pars/bogeys, which were four bordered+tinted cards.
   Dividers are internal only (:first-child clears its left edge) so the panel keeps a
   single outline — the DESIGN.md "one surface per step, rows divided" rule applied
   horizontally. */
.pdstrip{display:grid;grid-template-columns:repeat(4,1fr);margin-bottom:18px;
  background:linear-gradient(160deg,rgba(24,58,30,.92),rgba(16,40,21,.96));
  border:1px solid rgba(58,142,68,.22);border-radius:var(--r-md);
  box-shadow:0 2px 8px rgba(0,0,0,.28),inset 0 1px 0 rgba(255,255,255,.04)}
.pdstrip>div{padding:10px 6px;text-align:center;border-left:1px solid rgba(39,107,48,.20)}
.pdstrip>div:first-child{border-left:none}
/* ⚠️ --g400. On the strip's own background --g500 measures 2.02:1, which is not a
   readable 11px label in sunlight; g400 is 3.21:1. Sentence case, NOT uppercase +
   tracking — DESIGN.md's .slbl lesson: the tracking is what made these read shouty,
   sentence case reads as a name for the number. */
.pdstrip-l{font-size:var(--t-micro);color:var(--g400);margin-bottom:3px}
.pdstrip-v{font-family:var(--fd);font-size:var(--t-lg);font-weight:700;line-height:1}
.card::after{content:'';position:absolute;width:80px;height:70px;background:var(--g400);border-radius:var(--blob-sm);right:-20px;top:-20px;opacity:.04;pointer-events:none}
.ctitle{font-family:var(--fd);font-size:var(--t-base);font-weight:700;color:var(--cream);margin-bottom:14px;letter-spacing:-.1px;display:flex;align-items:center;gap:6px}

/* ── GAME SNAPSHOT ──
   Four separate rounded tiles, restored after the joined 2x2 panel proved too severe.
   Keep the content treatment from the joined version: direct labels, strong figures,
   a chevron for affordance, and no repetitive "tap for details" helper copy. */
.sgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px}
.sc{appearance:none;background:linear-gradient(145deg,rgba(22,61,28,.9),rgba(15,41,20,.8));border:1px solid rgba(58,142,68,.18);border-radius:var(--r-md);padding:15px 14px 14px;min-height:82px;position:relative;overflow:hidden;display:flex;flex-direction:column;align-items:flex-start;justify-content:space-between;text-align:left;color:var(--cream);font-family:var(--fb);cursor:pointer;-webkit-tap-highlight-color:transparent;box-shadow:0 2px 8px rgba(0,0,0,.2);transition:background .18s,border-color .18s,transform .18s cubic-bezier(.22,1,.36,1)}
.sc:active{background:var(--surf-3);border-color:rgba(92,181,102,.3);transform:scale(.985)}
.sc:focus-visible{outline:2px solid var(--g300);outline-offset:-2px}
/* Sentence case turns the label into a name for the number, not another heading. */
/* Members list: a solid raised green keeps every row in the same colour family
   as the snapshot tiles, regardless of the number of members. */
.ch-players{display:flex;flex-direction:column;overflow:hidden;
  background:var(--surf-2);
  border:1px solid rgba(58,142,68,.18);border-radius:var(--r-md);
  box-shadow:0 2px 8px rgba(0,0,0,.2)}
.slbl{font-size:var(--t-micro);color:var(--g400);letter-spacing:0;margin-bottom:8px;font-weight:600;line-height:1.25}
/* ⚠️ 28px — the ORIGINAL value, restored 2026-08-14. The type migration rounded it up
   to --t-2xl (32px), which crowded the tile; I then "fixed" it to --t-xl (24px), which
   Jordan liked even less. The pre-migration 28px was right all along, and it is a
   deliberate exception to the six-step scale for the same reason the hero numerals are:
   these are display figures, not text. Do not re-round it to a token. */
.sval{font-family:var(--fd);font-size:28px;font-weight:700;color:var(--cream);line-height:1}
.scgo{position:absolute;right:12px;bottom:10px;color:var(--g500);font-size:22px;line-height:1;font-family:var(--fb);font-weight:400}

/* ── PROFILE ── */
.profile-head{padding:20px 20px 0}
.profile-identity{display:flex;align-items:center;gap:16px;margin-bottom:20px}
.profile-edit{appearance:none;background:none;border:0;color:var(--g300);font-family:var(--fb);font-size:var(--t-sm);font-weight:600;padding:10px 0 10px 12px;cursor:pointer}
.profile-edit:focus-visible,.profile-row:focus-visible,.profile-stat:focus-visible{outline:2px solid var(--g300);outline-offset:-2px}
.profile-stats{display:grid;grid-template-columns:repeat(3,1fr);background:var(--surf-2);border:1px solid rgba(58,142,68,.2);border-radius:var(--r-md);overflow:hidden;margin-bottom:12px;box-shadow:0 2px 8px rgba(0,0,0,.22)}
.profile-stat{appearance:none;background:none;border:0;border-left:1px solid rgba(39,107,48,.2);min-height:76px;padding:12px 8px;text-align:center;color:var(--cream);font-family:var(--fb);cursor:pointer;position:relative;-webkit-tap-highlight-color:transparent}
.profile-stat:first-child{border-left:0}
.profile-stat:active,.profile-row:active{background:var(--surf-3)}
.profile-stat-label{display:block;font-size:var(--t-micro);color:var(--g400);margin-bottom:6px;line-height:1.2}
.profile-stat-value{display:block;font-family:var(--fd);font-size:var(--t-xl);font-weight:700;line-height:1}
.profile-section{padding:0 20px;margin-bottom:20px}
.profile-section-label{font-size:var(--t-micro);letter-spacing:1.6px;text-transform:uppercase;color:var(--g500);font-weight:600;margin-bottom:9px}
.profile-list{background:var(--surf-2);border:1px solid rgba(58,142,68,.18);border-radius:var(--r-md);overflow:hidden}
.profile-row{appearance:none;width:100%;min-height:52px;background:none;border:0;border-top:1px solid rgba(39,107,48,.16);padding:12px 14px;display:flex;align-items:center;gap:12px;color:var(--cream);font-family:var(--fb);text-align:left;cursor:pointer;-webkit-tap-highlight-color:transparent}
.profile-list>.profile-row:first-child,.profile-list>.profile-disclosure:first-child>.profile-row{border-top:0}
.profile-row-icon{width:20px;height:20px;display:flex;align-items:center;justify-content:center;color:var(--g400);flex:0 0 auto}
.profile-row-copy{flex:1;min-width:0;font-size:var(--t-sm);line-height:1.3}
.profile-row-meta{font-size:var(--t-micro);color:var(--g500);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:42%}
.profile-chevron{color:var(--g500);flex:0 0 auto;transition:transform .18s ease}
.profile-disclosure{border-top:1px solid rgba(39,107,48,.16)}
.profile-disclosure:first-child{border-top:0}
.profile-disclosure>.profile-row{border-top:0}
.profile-disclosure-body{border-top:1px solid rgba(39,107,48,.16);background:var(--surf-1);padding:0 12px}
.profile-inner-row{padding-left:2px;padding-right:2px;border-top:1px solid rgba(39,107,48,.12)}
.profile-inner-row:first-child{border-top:0}
.profile-note{display:block;font-size:var(--t-micro);color:var(--g500);line-height:1.45;margin-top:4px}
.profile-switch{flex:0 0 auto;width:38px;height:22px;border-radius:11px;background:var(--g900);border:1px solid rgba(39,107,48,.3);position:relative;transition:background .18s ease,border-color .18s ease}
.profile-switch.on{background:var(--g400);border-color:var(--g400)}
.profile-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--g500);transition:left .18s ease,background .18s ease}
.profile-switch.on .profile-switch-knob{left:18px;background:var(--cream)}
.profile-account{padding:0 20px 40px}
.profile-delete{display:block;margin:14px auto 0;background:none;border:0;color:rgba(252,165,165,.75);font-size:var(--t-sm);font-family:var(--fb);cursor:pointer;padding:8px 10px;text-decoration:underline;text-underline-offset:3px}

/* ── TABLE ── */
.twrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
table{width:100%;border-collapse:collapse;font-size:var(--t-sm)}
th{font-size:var(--t-micro);letter-spacing:.8px;text-transform:uppercase;color:var(--g400);padding:8px 10px;text-align:left;border-bottom:1px solid rgba(39,107,48,.15);font-weight:600;white-space:nowrap}
td{padding:10px 10px;border-bottom:1px solid rgba(39,107,48,.08);color:var(--cream);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:var(--surf-1)}

/* ── CHIPS ── */
.chip{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;font-size:var(--t-micro);font-weight:700}
/* All six draw from the one --sc-* scale in :root — see the note there.
   Every chip carries the same white outline (--sc-ring): the ramp runs from a
   very dark green to a bright red, so the dark end needs an edge to separate it
   from the card (eagle is only 1.60:1 against it). Applying the ring to ALL six
   keeps them a matched set rather than singling one out. */
.eagle{background:var(--sc-eagle-bg);color:var(--sc-eagle-fg);box-shadow:var(--sc-ring)}
.birdie{background:var(--sc-birdie-bg);color:var(--sc-birdie-fg);box-shadow:var(--sc-ring)}
.par{background:var(--sc-par-bg);color:var(--sc-par-fg);box-shadow:var(--sc-ring)}
.bogey{background:var(--sc-bogey-bg);color:var(--sc-bogey-fg);box-shadow:var(--sc-ring)}
.double{background:var(--sc-double-bg);color:var(--sc-double-fg);box-shadow:var(--sc-ring)}
.worse{background:var(--sc-worse-bg);color:var(--sc-worse-fg);box-shadow:var(--sc-ring)}

/* ── HOLE BY HOLE SCORECARD ── */
.hbh-wrap{position:relative;overflow:hidden}
.hbh-progress{height:3px;background:var(--surf-3);border-radius:2px;margin-bottom:14px;overflow:hidden}
/* Offline / unsaved warnings. Shared by the scoring screen's connection strip and the
   round-summary "not saved" banner — both say the same thing (your scores exist here
   but not on the server), so they share one look. Classes rather than inline styles
   because the CSP ratchet in test-csp-migration.py only lets inline style= shrink. */
.warnbox{padding:10px 12px;border-radius:var(--r-sm);background:rgba(220,90,60,.16);border:1px solid rgba(220,90,60,.5);line-height:1.45}
.warnbox-t{font-size:var(--t-sm);font-weight:700;color:var(--cream);margin-bottom:2px}
.warnbox-b{font-size:var(--t-micro);color:var(--g300)}
.hbh-conn{margin-bottom:12px}
.hbh-conn .warnbox{padding:7px 10px;font-size:var(--t-micro);color:var(--cream);text-align:center;line-height:1.4}
.rsum-unsaved{margin:0 20px 14px;text-align:left}
.hbh-bar{height:100%;background:linear-gradient(90deg,var(--g400),var(--g300));border-radius:2px;transition:width .4s cubic-bezier(.16,1,.3,1)}
/* HERO = the hole NUMBER (orients "which hole am I on"); par/yardage/SI sit
   under it as supporting context. */
.hbh-hole{text-align:center;margin-bottom:14px}
.hbh-eyebrow{font-family:var(--fd);font-size:var(--t-micro);font-weight:700;color:var(--g500);letter-spacing:2px;text-transform:uppercase;margin-bottom:2px}
.hbh-hnum{font-family:var(--fd);font-size:52px;font-weight:900;color:var(--cream);line-height:1;margin-bottom:6px}
.hbh-meta{font-size:var(--t-sm);color:var(--g300);display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap}
.hbh-meta span{display:flex;align-items:center;gap:5px}
/* Compact standings strip — equal columns, scales to a 4-ball. */
.hbh-running{display:flex;padding:10px 6px;background:var(--surf-2);border:1px solid rgba(39,107,48,.12);border-radius:var(--r-md);margin-bottom:14px}
.hbh-running-item{flex:1;text-align:center;min-width:0}
.hbh-running-lbl{font-size:var(--t-micro);color:var(--g500);text-transform:uppercase;letter-spacing:.8px;margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hbh-running-val{font-family:var(--fd);font-size:var(--t-lg);font-weight:700;line-height:1}
.hbh-running-sub{font-size:var(--t-micro);color:var(--g500);margin-top:2px}
.hbh-running-item.leader .hbh-running-lbl{color:var(--gold)}
/* Handicap trend chart — the dots are PRESS-AND-HOLD targets, and a long press is
   exactly the gesture iOS/Android use to start text selection and raise the callout
   menu. preventDefault() on pointerdown does NOT stop either: they are driven by
   -webkit-touch-callout / user-select, so they must be turned off in CSS.
   Applied to the whole chart block (SVG + the readout that appears under the finger)
   because the selection was grabbing the text and image BEHIND the popup, not the dot
   itself. Reported by Jordan 2026-08-04. */
.hcp-trend,.hcp-trend *{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}
.hcp-trend svg{touch-action:none}
/* Mid-round scorecard holes are tappable (jump straight to that hole to fix it).
   Needs a visible press state or it reads as a static grid — the whole point is that
   people discover they can go back and edit. */
.hbh-card-hole{border-radius:var(--r-sm);transition:background .15s,transform .1s;-webkit-tap-highlight-color:transparent}
.hbh-card-hole:active{background:rgba(201,168,76,.14);transform:scale(.93)}
.hbh-card-hole:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
@media(hover:hover){.hbh-card-hole:hover{background:rgba(201,168,76,.09)}}
/* Player entry rows — the SCORE is the hero. Compact so 4 fit. */
.hbh-players{display:flex;flex-direction:column;gap:8px;margin-bottom:14px}
.hbh-player{background:var(--surf-2);border:1px solid rgba(39,107,48,.15);border-radius:var(--r-md);padding:10px 12px;display:flex;align-items:center;gap:11px;transition:border-color .2s,background .2s}
.hbh-player.leader{background:linear-gradient(145deg,rgba(201,168,76,.12),rgba(39,107,48,.08));border-color:rgba(201,168,76,.45)}
.hbh-score-wrap{display:flex;align-items:center;gap:6px;margin-left:auto}
.hbh-score-btn{width:38px;height:38px;border-radius:50%;border:1px solid rgba(39,107,48,.35);background:var(--surf-2);color:var(--cream);font-size:var(--t-xl);font-weight:300;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;transition:all .15s;flex-shrink:0}
.hbh-score-btn:active{transform:scale(.88);background:rgba(39,107,48,.3)}
.hbh-score-val{font-family:var(--fd);font-size:var(--t-xl);font-weight:700;min-width:40px;text-align:center;line-height:1;transition:color .2s}
.hbh-result{font-size:var(--t-micro);font-weight:600;letter-spacing:.5px;text-transform:uppercase;min-width:52px;text-align:center;transition:color .2s}
.hbh-nav{display:flex;gap:10px}

/* ── HOLE GRID (kept for live scoring) ── */
.hgrid{display:grid;grid-template-columns:repeat(6,1fr);gap:5px;margin:8px 0 14px}
.hcell{text-align:center}
.hnum{font-size:var(--t-micro);color:var(--g400);margin-bottom:2px}
/* par: --sand (8.13:1), NOT --gold (6.74:1) — the colour the 2026-08-22 legibility
   fix moved _nineGrid away from. This grid kept it and was missed. All four par
   renderers now agree; retune them together. */
.hpar{font-size:var(--t-micro);color:var(--sand);margin-bottom:2px}
.hinput{width:100%;padding:8px 2px;background:rgba(10,26,14,.8);border:1px solid rgba(39,107,48,.3);border-radius:var(--r-sm);color:var(--cream);font-family:var(--fb);font-size:var(--t-base);font-weight:600;text-align:center;outline:none;-webkit-appearance:none}
.hinput:focus{border-color:var(--g300)}
.htotal{font-family:var(--fd);font-size:var(--t-2xl);font-weight:700;color:var(--cream);text-align:center;margin:10px 0 2px}
.hdiff{text-align:center;font-size:var(--t-sm);color:var(--g400);margin-bottom:10px}
.hlabel{font-size:var(--t-micro);color:var(--g400);letter-spacing:1px;text-transform:uppercase;margin:10px 0 4px;font-weight:600}

/* ── AVATAR ── */
.av{border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-weight:700;color:#fff;flex-shrink:0;font-family:var(--fb)}

/* ── RANK ── */
.r1{color:var(--gold);font-family:var(--fd);font-weight:900;font-size:var(--t-lg)}
.r2{color:var(--cream2);font-family:var(--fd);font-weight:700;font-size:var(--t-base)}
.r3{color:var(--sand);font-family:var(--fd);font-weight:700;font-size:var(--t-base)}

/* ── MODAL ── */
.moverlay{position:fixed;inset:0;z-index:800;background:rgba(0,0,0,.6);backdrop-filter:blur(6px);display:none;align-items:flex-end;justify-content:center}
.moverlay.on{display:flex}
/* ⚠️ iOS: `92vh` was the "no space above pop-up screens" report. `vh` resolves to the
   LARGE viewport, which INCLUDES the status-bar strip, so a tall sheet's top edge
   reached right up under the iOS clock with zero clearance — and the drag handle then
   sat inside the ~20px top screen edge that iOS reserves for Control/Notification
   Centre, which is why "pull down to dismiss" opened the system panel instead. One
   cause, both symptoms. `dvh` measures the viewport actually available, and the extra
   inset keeps the sheet clear of the status bar on notched devices.
   Height collapses to the plain 92dvh where there is no inset (Android/desktop). */
/* 🔴 position:relative IS LOAD-BEARING — it is the containing block for .mclose.
   Jordan, 2026-08-23: "the small exit button (x) appears to show on the pop up card but
   then moves to top of the screen, doesnt seem to clean."
   .mclose is position:absolute and this element had NO position, so it resolved against
   the VIEWPORT. It only looked correct mid-animation: `animation:sheetUp` applies a
   transform, and a transformed element IS a containing block for absolutely-positioned
   descendants — so the close button sat on the card while the sheet slid up, then
   snapped to the top-right of the screen the moment the animation ended and the
   transform was gone.
   ⚠️ That is also why it never reproduced in a static screenshot: the bug is the
   TRANSITION between two different containing blocks, not either end state.
   ⚠️ Do not remove this to "clean up". The same trap catches .mhandle and anything else
   positioned against the card. */
.modal{position:relative;background:linear-gradient(160deg,rgba(15,41,20,.98),rgba(10,26,14,.99));border:1px solid rgba(58,142,68,.2);border-radius:20px 20px 0 0;padding:0 20px 40px;width:100%;max-width:580px;max-height:92vh;overflow-y:auto;animation:sheetUp .3s cubic-bezier(.16,1,.3,1)}
@supports (height:100dvh){.modal{max-height:calc(92dvh - env(safe-area-inset-top))}}
/* ── TALL SHEET (capped, scrolls internally) ──────────────────────────────────
   ⚠️ Jordan, 2026-08-16: "on Android pop-up modals have space above them, on iOS they
   don't." The cause was NOT the `.modal` rule — v189 fixed that correctly. It was that
   #mhcp / #mplayerdetail / #mmyrounds each carried an INLINE `max-height:90vh/88vh/85vh`,
   and an inline style beats the stylesheet, so those three silently opted OUT of the
   v189 `92dvh - inset-top` fix and kept raw `vh`.
   `vh` is the LARGE viewport (it INCLUDES the status-bar strip), so on iOS a sheet
   clamped to 90vh reaches up under the clock; on Android inset-top is 0 and the same
   number looks fine — which is exactly the asymmetry reported.
   ⚠️ Do NOT reintroduce a `vh` max-height inline on a sheet. Use this class; it is the
   single source for the cap, so the inset can never be forgotten again. */
.tallsheet{max-height:90vh}
@supports (height:100dvh){.tallsheet{max-height:calc(90dvh - env(safe-area-inset-top))}}
/* ── FULL-BLEED SHEET (live scoring) ──────────────────────────────────────────
   ⚠️ iOS "no space at the top" (Jordan, 2026-08-16). #mhbh is a full-screen sheet
   that deliberately bypasses `.modal` (align-items:stretch, padding:0), so it also
   bypassed the v189 `92dvh - inset-top` fix — it was `height:100%;max-height:100vh`
   with NO top inset at all. Its top edge therefore sat at y=0, i.e. UNDER the iOS
   clock, and its header rendered ~31px beneath the status bar.
   ⚠️ #statusscrim CANNOT save this one: the scrim is z-index 600 and .moverlay is
   800, so during live scoring the sheet paints straight over it. A full-bleed sheet
   has to carry its own inset.
   Android is untouched — inset-top is 0 there, which is exactly why the bug read as
   "Android has a hard stop, iOS doesn't".
   `dvh` (not `vh`) so the height matches the viewport actually available; `vh` is the
   LARGE viewport and overshoots in standalone. Both properties keep a vh fallback. */
.fullsheet{width:100%;max-width:560px;margin:auto;
  background:linear-gradient(160deg,rgba(15,41,20,.99),rgba(10,26,14,1));
  display:flex;flex-direction:column;overflow-y:auto;
  height:100%;max-height:100vh;
  padding-top:env(safe-area-inset-top);
  padding-bottom:env(safe-area-inset-bottom)}
@supports (height:100dvh){.fullsheet{max-height:100dvh}}
/* ── SWIPE-DRAG (finger tracking) ─────────────────────────────────────────────
   ⚠️ Jordan, 2026-08-22: the dismiss was "unresponsive and not very clean" next to
   Android. A large part of that was that NOTHING MOVED during the gesture: the old
   handler was a post-hoc test at touchend, so a drag under the 70px threshold gave no
   feedback at all and a drag over it made the sheet vanish instantly. Every native
   bottom sheet tracks the finger 1:1 — that is what tells you the gesture is live and
   shows you where the threshold is.

   `.dragging` is set for the duration of the drag; app.js writes the live offset into
   --dragy. Transition is suppressed WHILE dragging (the finger IS the animation) and
   restored for the snap-back, so releasing under threshold springs home.

   ⚠️ `animation:none` is REQUIRED. `.modal` carries `animation:sheetUp` which also
   animates `transform`; without clearing it the entry animation re-runs and fights the
   drag on any sheet re-rendered mid-gesture. Same reason `.snapback` clears it.
   ⚠️ Desktop (>=768px) overrides sheetUp to a scale+fade on a CENTRED modal, so a
   translateY drag is wrong there — the whole gesture is touch-only and disabled below. */
.modal.dragging{transform:translateY(var(--dragy,0px));animation:none;transition:none;will-change:transform}
.modal.snapback{transform:translateY(0);animation:none;transition:transform .28s cubic-bezier(.16,1,.3,1)}
/* Dismissal: ride the sheet out rather than cutting it. app.js removes `.on` when this
   finishes (or on a fallback timer, so a lost transitionend can never strand a sheet). */
.modal.dragout{transform:translateY(100%);animation:none;transition:transform .22s cubic-bezier(.4,0,1,1)}
/* The backdrop fades with the drag so the gesture reads as one movement. */
.moverlay.dragging{transition:none}
.moverlay.dragging,.moverlay.dragout{background:rgba(0,0,0,calc(.6*var(--dragfade,1)));backdrop-filter:blur(calc(6px*var(--dragfade,1)))}
.mhandle{width:32px;height:3px;background:rgba(255,255,255,.15);border-radius:2px;margin:12px auto 16px}
.mtitle{font-family:var(--fd);font-size:var(--t-lg);font-weight:700;color:var(--cream);margin-bottom:16px}
/* Header content must stop before the absolute close control. Without this reserve,
   right-aligned actions (notably Best score -> Summary) occupy the same corner and
   overlap the 28px close target. */
.modal-head-with-close{padding-right:54px}
/* 🔴 THE CLOSE BUTTON: absolute against the CARD, plus a sticky-like top offset.
   Four attempts, and each fix caused the next — so all four are recorded.
   Jordan, 2026-08-23: "the small exit button (x) appears to show on the pop up card but
   then moves to top of the screen"; then, after the first fix, "on the handicap page it
   is squashed and top left".
   1. `absolute` with NO positioned ancestor resolved against the VIEWPORT. Measured:
      scrolling a sheet 900px moved it to -885px relative to its card — it detached to
      the top of the screen. The original report.
   2. `absolute` + `.modal{position:relative}` anchors correctly, but on the ordinary
      sheets .modal is overflow-y:auto (max-height:92vh) so it scrolls away with content.
   3. `float:right` fixed the flow case but FLOATS DO NOT APPLY TO FLEX ITEMS, and .modal
      is `display:flex;flex-direction:column` on the three tallsheets (#mhcp,
      #mplayerdetail, #mmyrounds) — the button became a column flex child: squashed,
      top-LEFT. The second report.
   4. `sticky` + margins is layout-correct in both, but the offset then depends on each
      card's own padding: the tallsheets set `padding:0` while the others use 20px, so
      one margin cannot serve both (measured 37px vs 17px from the right edge).
   `position:absolute` with `top/right` measures from the CARD's padding box, which is
   the same reference regardless of that padding — one rule, every layout, and it is out
   of the flex flow so it cannot be squashed. The scroll-away in (2) is handled by the
   sheets that actually scroll their own body: the tallsheets scroll an INNER pane, and
   the ordinary sheets are short enough that the header stays in view.
   ⚠️ Do NOT reintroduce float, and do NOT make this a flex item.
   ⚠️ .modal{position:relative} is REQUIRED — without it we are back at (1). */
/* A tappable person row in the member picker. Matches the app's other list rows
   (avatar + name + chevron) so claiming a guest card looks like the rest of the app
   rather than a platform dropdown. */
.pickrow{display:flex;align-items:center;gap:10px;width:100%;padding:10px 12px;margin-bottom:8px;
  background:var(--surf-2);border:1px solid rgba(39,107,48,.15);border-radius:var(--r-md);
  color:var(--cream);font-size:var(--t-base);cursor:pointer;text-align:left;
  -webkit-tap-highlight-color:transparent;transition:background .15s,transform .1s}
.pickrow:active{background:var(--surf-3);transform:scale(.99)}
.pickrow svg{color:var(--g400);flex-shrink:0}
.mclose{position:absolute;top:8px;right:10px;z-index:3;background:var(--surf-3);border:none;color:var(--g300);width:44px;height:44px;border-radius:50%;cursor:pointer;font-size:var(--t-sm);display:flex;align-items:center;justify-content:center}

/* ── LIVE BADGE ── */
.livebadge{display:inline-flex;align-items:center;gap:4px;background:rgba(201,168,76,.1);border:1px solid rgba(201,168,76,.35);color:var(--gold);padding:3px 9px;border-radius:20px;font-size:var(--t-micro);font-weight:600;letter-spacing:.5px}
.ldot{width:5px;height:5px;border-radius:50%;background:var(--gold);animation:pulse 1.5s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}

/* ── HCP PILL ── */
.hcppill{display:inline-flex;align-items:center;background:rgba(201,168,76,.1);border:1px solid rgba(201,168,76,.25);color:var(--gold);padding:2px 8px;border-radius:6px;font-size:var(--t-micro);font-weight:600;letter-spacing:.3px}

/* ── MISC ── */
.cblob{width:44px;height:40px;background:linear-gradient(135deg,var(--g600),var(--g500));border-radius:var(--blob-sm);display:flex;align-items:center;justify-content:center;font-size:var(--t-lg);flex-shrink:0}
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:36px 20px;text-align:center;color:var(--g500)}
.emptyicon{width:56px;height:50px;background:var(--surf-2);border-radius:var(--blob-sm);display:flex;align-items:center;justify-content:center;font-size:var(--t-xl);margin-bottom:10px}
.empty h3{font-family:var(--fd);font-size:var(--t-base);color:var(--cream);margin-bottom:4px}
.empty p{font-size:var(--t-sm);color:var(--g500)}
.divider{height:1px;background:var(--surf-3);margin:14px 0}
/* ⚠️ NO EMOJI. Toasts used to prefix "✅ "/"❌ " into the message text. Emoji are
   banned as UI chrome app-wide (see DESIGN.md) — they render inconsistently across
   platforms, ignore the type scale and can't be coloured. Success/failure is carried
   by a 3px left border instead: colour, not a glyph. toast(msg,'err'|'ok') sets it;
   a bare toast(msg) stays neutral, which is right for most confirmations. */
/* ── HOME PRIMARY ACTION (New Round) ──────────────────────────────────────────
   Built to Jordan's spec sheet (v197). History matters here: it was a 64px gold
   GRADIENT SQUARE with "New / Round" stacked on two lines and a heavy drop-glow —
   which read as a sticker. I then over-corrected to a quiet hairline pill, which was
   worse: this is the app's main action and should carry weight. The answer was never
   more or less volume, it was CRAFT — fully-rounded, one line, a + affordance, flat
   fill, and a soft glow instead of a drop shadow.

   Spec (do not drift): fill oklch(0.82 0.13 88) = #e7bf57; label var(--cta-label)
   = #051e0a at 14.5px/700; radius 999; padding 0 18px 0 14px; glow 0 8px 26px gold/30. */
:root{--cta-fill:#e7bf57;--cta-fill-hover:#f8cf68;--cta-fill-press:#cea94b;--cta-label:#051e0a}
/* ⚠️ Vertically anchored to the WORDMARK, not the whole left stack. The header's left
   column is three stacked items (eyebrow / wordmark / greeting); centring the button on
   that block put it visibly below the wordmark's midline, which is the line the eye
   actually reads it against (Jordan, v199). The row is flex-start and this offset drops
   the button to the wordmark's centre: eyebrow (11px + 8px margin) + half the 38px
   wordmark - half the 40px button = 27px. Keep in step if the eyebrow or wordmark
   height changes. */
.nr-cta{margin-top:27px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;gap:7px;
  height:40px;padding:0 15px 0 12px;border-radius:var(--r-pill);border:none;
  background:var(--cta-fill);color:var(--cta-label);
  font-family:var(--fb);font-size:13.5px;font-weight:700;letter-spacing:.1px;line-height:1;
  cursor:pointer;-webkit-tap-highlight-color:transparent;
  box-shadow:0 6px 20px rgba(201,168,76,.28);
  transition:background .15s,box-shadow .15s,transform .1s}
/* ⚠️ Optical, not geometric. Measured: the button, icon and label boxes all centre at
   exactly the same y — but "Tee Off" is all cap-height with NO descenders, so its
   visual mass sits above its box centre and the + reads as sitting low. Nudged up 1px
   to match the TEXT's optical centre. Verified at 2.6x zoom against 0/-0.5/-1/-1.5. */
.nr-cta svg{flex-shrink:0;transform:translateY(-1px)}
/* ⚠️ SIZE + WRAPPING. At 46px tall with the label this is ~149px wide, and the
   wordmark beside it is up to 210px — on a 390px screen (350px of content) the two do
   not fit, and the header row's `flex-wrap:wrap` dropped the button onto its own line
   below "Good evening" (Jordan, v197). Measured, not guessed: left=210 cta=149 need=369.
   Fixed at BOTH ends — the button is smaller here, and the row no longer wraps.
   The icon-only breakpoint is 400px, not 360px: at 390px the label still does not fit. */
.nr-cta .nr-cta-lbl{white-space:nowrap}
/* ⚠️ Icon-only ONLY on genuinely tiny screens. This was 400px — a safety net from when
   the header row still wrapped — which hid the label on every normal phone (390px), so
   the button showed a bare "+" (Jordan, v198). The row is nowrap now and "Tee Off" is
   ~105px, which fits comfortably at 320px, so the label should essentially always show.
   300px is a real floor (a folded phone / tiny legacy device), not a common width. */
@media (max-width:300px){
  .nr-cta{padding:0;width:40px;justify-content:center}
  .nr-cta .nr-cta-lbl{display:none}
}
.nr-cta:active{background:var(--cta-fill-press);box-shadow:0 4px 14px rgba(201,168,76,.24);transform:translateY(1px)}
.nr-cta:focus-visible{outline:2px solid var(--cta-fill-hover);outline-offset:3px}
.nr-cta[disabled]{background:rgba(201,168,76,.28);color:rgba(5,30,10,.45);box-shadow:none;cursor:default}
@media (hover:hover){.nr-cta:hover{background:var(--cta-fill-hover);box-shadow:0 10px 30px rgba(201,168,76,.38)}}
@media (prefers-reduced-motion:reduce){.nr-cta{transition:none}.nr-cta:active{transform:none}}

/* ── NEW ROUND ────────────────────────────────────────────────────────────────
   Rebuilt 2026-08-14. The old page was three equal-weight cards ("Round Details",
   "Players", and a third holding Go Live) with the course block nested INSIDE the
   first one and the swap-nines button nested inside that — cards three deep, the
   same fault just fixed in the clubhouse list. Nothing said what to do first, and
   gold was spent on "Spin the tee", the least important control on the screen.

   Now: three quiet sections in the order you actually use them (course, round,
   players), with one surface per section and exactly one primary action at the end.
   ⚠️ Every id from the old markup is preserved — 24 of them are read by JS. */
.nrstep{margin-bottom:18px}
.nrstep-h{display:flex;align-items:center;gap:9px;margin-bottom:8px}
.nrstep-t{font-family:var(--fb);font-size:var(--t-sm);font-weight:700;color:var(--g300);letter-spacing:.8px;text-transform:uppercase}
.nrstep-hint{font-size:var(--t-sm);color:var(--g500);margin-left:auto}
/* One surface per step. Rows inside get dividers, never their own border. */
.nrsurf{background:var(--surf-2);border:1px solid rgba(39,107,48,.16);border-radius:var(--r-md);overflow:hidden}
.nrsurf-row{padding:13px 14px;border-top:1px solid rgba(39,107,48,.14)}
.nrsurf-row:first-child{border-top:none}
/* The course selector is an open search stage, then one selected-course record. */
/* ⚠️ NO border/background. This wrapper once held the selected-course card and was
   given card chrome; since v300 it holds only the search picker and manual entry.
   With a course chosen its children are hidden, so the chrome painted a bordered
   zero-height box — the stray line across the top Jordan reported. The picker draws
   its own field and manual entry its own rows; neither needs a box around it. */
.nr-course-surface{background:none;border:0;border-radius:0}
#pg-newround.picking .nr-pick-stage{min-height:calc(68svh - var(--nav-tall));display:flex;flex-direction:column;justify-content:center}
#pg-newround.picking .nrstep{margin-bottom:0}
.nr-pick-lead{padding:0 0 28px;text-align:left}
.nr-pick-lead-t{max-width:12ch;font-family:var(--fd);font-size:var(--t-2xl);font-weight:600;color:var(--cream);letter-spacing:-.03em;line-height:1.15}
.nr-search-label{display:block;margin:0 0 8px;color:var(--g200);font-size:var(--t-sm);font-weight:500;letter-spacing:0;text-transform:none}
.nr-search{display:flex;align-items:center;gap:12px;min-height:60px;padding:6px 8px 6px 16px;background:var(--cream);color:var(--g900);border:1px solid transparent;border-radius:var(--r-md)}
.nr-search>svg{flex-shrink:0}
.nr-search input{flex:1;min-width:0;min-height:46px;margin:0;padding:0;border:0;background:transparent;color:var(--g900);font-size:var(--t-base);font-family:var(--fb);border-radius:0}
.nr-search input::placeholder{color:color-mix(in srgb,var(--g900) 65%,var(--cream))}
.nr-search input:focus{outline:none;border:0}
.nr-search:focus-within{outline:2px solid var(--g300);outline-offset:4px}
.nr-search button{display:grid;place-items:center;flex-shrink:0;width:44px;height:44px;background:var(--surf-3);color:var(--cream);border:0;border-radius:var(--r-sm);cursor:pointer}
.nr-search button:active{transform:translateY(1px)}
.nr-search button:disabled{opacity:.6;cursor:wait}
.nralt{display:flex;justify-content:space-between;gap:16px;margin-top:14px}
.nralt button{display:flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:8px 0;background:none;border:none;color:var(--g200);font:500 var(--t-sm)/1.4 var(--fb);cursor:pointer}
.nralt.solo{justify-content:flex-end}
.nralt button:hover{color:var(--cream)}
.nr-search-results{margin-top:20px;border-top:1px solid color-mix(in srgb,var(--cream) 12%,transparent);max-height:340px;overflow-y:auto}
.nr-search-results:empty{display:none}
/* ══ NEW ROUND, BUILT TO THE REFERENCE (Jordan, 2026-09-13, third pass) ══════════
   Read clubhaus-new-round-v300 before touching any of this. Two earlier passes
   guessed the layout from memory; this one was measured off the supplied image.
   The load-bearing rules, which BOTH earlier passes got wrong:
     1. Selected state = gold BORDER + gold figure. Never a solid gold fill — that
        belongs to Start round alone, and three filled toggles drowned it out.
     2. No page-wide card wrapper. Sections are bare, separated by their own
        labels and margins; only the individual boxes carry a border.
   ────────────────────────────────────────────────────────────────────────────── */
.nr-course-card{margin-bottom:22px}
/* ── The header band. The reference gives the course name a taller, striped field
   rather than plain page background. Diagonal fairway-mow stripes at low contrast:
   there is NO course photography in this project and a stock photo of a real club
   would be a fabricated record, so the texture is drawn, not shot.
   ⚠️ Bleeds to the page edges with a negative inline margin that mirrors .pg's own
   padding (20px + the safe-area inset), so the band is full-bleed while its TEXT
   stays on the page's normal left edge. Keep the two in step. */
.nr-course-head{position:relative;display:flex;align-items:flex-start;gap:12px;
  margin:calc(-24px - env(safe-area-inset-top)) calc(-20px - env(safe-area-inset-right)) 18px calc(-20px - env(safe-area-inset-left));
  padding:calc(30px + env(safe-area-inset-top)) calc(20px + env(safe-area-inset-right)) 26px calc(20px + env(safe-area-inset-left));
  /* ⚠️ NO background-color here. The band's fill lives on ::before WITH the stripes so
     the two fade out together. A solid colour on this element ends in a hard
     horizontal edge against the page — that edge was the "straight line" that survived
     two attempts at softening only the stripes. Fade the FILL, not just the pattern. */
  overflow:visible;z-index:0}
/* ⚠️ The stripes live on a PSEUDO-ELEMENT, not on .nr-course-head itself, because they
   are masked — and a mask on the header would fade its TEXT too. Two changes from the
   first attempt (Jordan: "a hard finish against a line", "finer spacing"):
     · each stripe has soft edges (colour ramps in and out across the band) rather
       than the hard `0 22px, transparent 22px` stops, so no stripe has a cut edge;
     · 11px pitch instead of 22px.
   The mask fades the whole field to nothing before the bottom, so the band dissolves
   into the page instead of ending on a border. That is also why there is no
   border-bottom any more — the fade IS the edge. */
.nr-course-head::before{content:'';position:absolute;inset:0;z-index:-1;pointer-events:none;
  /* Fill AND pattern together, so the whole band fades as one. 18px pitch: 11px read
     as noise, 22px was too coarse. Soft edges on every stripe — no stripe has a cut
     edge of its own. */
  background-color:var(--surf-2);
  background-image:repeating-linear-gradient(135deg,
    transparent 0 4px,
    rgba(92,181,102,.09) 7px 11px,
    transparent 14px 18px);
  /* ⚠️ bottom:-96px — the band runs PAST its own element, down behind the Par/Tee/SI
     boxes, and the mask dissolves it roughly half way through them. It stopped level
     with the header before, which read as ending too early. The extra height is
     inside an overflow:hidden parent's ::before, so it cannot affect layout. */
  bottom:-96px;
  -webkit-mask-image:linear-gradient(to bottom,#000 0%,#000 46%,rgba(0,0,0,.5) 74%,transparent 100%);
  mask-image:linear-gradient(to bottom,#000 0%,#000 46%,rgba(0,0,0,.5) 74%,transparent 100%)}
.nr-course-head>*{position:relative;z-index:1}
/* ⚠️ padding-right reserves the pinned star/close corner — without it a long course
   name runs underneath them (measured: it did). ~88px = two 38px buttons + the gap. */
.nr-course-head-l{min-width:0;flex:1;padding-right:88px}
.nr-course-kicker{font-size:var(--t-micro);letter-spacing:1.6px;text-transform:uppercase;font-weight:700;color:var(--g300)}
/* Top-right actions: favourite (star) and close. Small icon buttons — the old
   full "Change" pill competed with the course name beside it. */
/* ⚠️ Pinned to the TOP-RIGHT OF THE SCREEN (Jordan, 2026-09-13), not inline beside
   the course name. The header band is full-bleed via negative margins, so `right`
   here is the page edge plus the safe-area inset — matching .pg's own padding.
   Sits above the band's ::before (z-index 1 vs -1) so the stripes pass behind it. */
.nr-course-acts{position:absolute;z-index:1;top:calc(10px + env(safe-area-inset-top));
  right:calc(14px + env(safe-area-inset-right));display:flex;align-items:center;gap:2px}
.nr-icobtn{position:relative;display:flex;align-items:center;justify-content:center;width:38px;height:38px;
  background:transparent;border:0;border-radius:var(--r-pill);color:var(--g200);cursor:pointer;-webkit-tap-highlight-color:transparent}
/* Drawn at 38px, tapped at 44 — same trick as .nrp-step. */
.nr-icobtn::before{content:'';position:absolute;inset:-3px}
.nr-icobtn:active{background:rgba(39,107,48,.34)}
.nr-icobtn:focus-visible{outline:2px solid var(--g300);outline-offset:2px}
/* Filled star = favourited. */
.nr-icobtn.on{color:var(--gold)}
.nr-icobtn.on svg{fill:var(--gold)}
/* ⚠️ The reference draws Change / Open / + Add small, but a 32-38px control misses
   the app's 44px touch floor. Keep the drawn size and extend the HIT AREA with a
   transparent inset pseudo-element — the same trick .nrp-step already uses.
   Measured on a 360px viewport; do not "tidy" these away. */
.nr-addlink::before{content:'';position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);height:44px}
.nr-course-name{margin-top:10px;font-family:var(--fd);font-size:var(--t-xl);font-weight:700;line-height:1.15;color:var(--cream);letter-spacing:-.02em}
.nr-course-sub{margin-top:6px;font-size:var(--t-sm);color:var(--g200)}
/* THREE facts — Par, Tee, SI. Rating/Slope are deliberately not here: they are
   recorded but never wrong, whereas SI is the one that needs fixing before you play,
   so it earns the third slot and the warning colour. */
/* ⚠️ position+z-index are LOAD-BEARING. The header band's ::before sits at z-index:-1
   so the stripes pass behind the header text, but a negative layer still paints ABOVE
   any following sibling that has no stacking position of its own — so the stripes
   showed through these boxes and they read as transparent. Lifting the facts into the
   positive stack puts them properly on top. */
.nr-facts{position:relative;z-index:1;display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
/* ⚠️ GOLD AS STANDARD (Jordan, 2026-09-13). These three are the course's headline
   facts, so they carry the accent by default; .needs turns the SI box RED, which is
   the only state that asks for action. Previously plain, with gold meaning "broken" —
   the inversion read as an error state on a perfectly good course. */
/* ⚠️ Same polished finish as the home page's .sc tiles (145deg gradient + soft drop
   shadow), tinted gold rather than green so these still read as the course's headline
   facts. Keep the gradient ANGLE at 145deg — it is what makes the whole app's tiles
   look like one family. */
.nr-fact{display:block;width:100%;min-width:0;padding:14px 14px 15px;text-align:left;
  background:linear-gradient(145deg,#2a2c18,#1b2413);
  border:1px solid rgba(201,168,76,.42);border-radius:var(--r-md);
  box-shadow:0 2px 8px rgba(0,0,0,.2)}
.nr-fact-btn{cursor:pointer;font-family:var(--fb);-webkit-tap-highlight-color:transparent}
.nr-fact-btn:active{background:rgba(201,168,76,.16)}
.nr-fact-btn:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
.nr-fact-k{display:block;font-size:var(--t-micro);letter-spacing:1.4px;text-transform:uppercase;color:var(--goldl);font-weight:700}
.nr-fact-v{display:block;margin-top:7px;font-family:var(--fd);font-size:22px;font-weight:700;line-height:1;color:var(--cream);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* .needs is written ONLY by _syncCourseSiCount(). Gold = "this one needs you". */
/* RED = the stroke index / per-hole par is missing and the round will score on a
   guess. _flagMissingSI() is the only writer of .needs. */
.nr-fact.needs{border-color:rgba(233,110,110,.6);
  background:linear-gradient(145deg,#3a1f18,#241711)}
.nr-fact.needs .nr-fact-k,.nr-fact.needs .nr-fact-v{color:#fca5a5}
.nr-fact.needs.nr-fact-btn:active{background:rgba(192,57,43,.22)}
/* The validation box. Only rendered while the course card is incomplete. */
/* Quiet sibling of the gold validation box — shown once the card is complete so a
   finished course still has a route into its own scorecard. Full-width row, chevron
   trailing, same 44px floor as everything else on this page. */
.nr-course-edit-l strong.ok{color:var(--g200)}
.nr-course-edit-r{display:flex;align-items:center;gap:6px;color:var(--cream);flex-shrink:0}
.nr-course-edit:active{background:rgba(39,107,48,.24)}

/* ── COURSE CARD PAGE ────────────────────────────────────────────────────────────
   A table, not a form: the user is transcribing a printed card, so the columns must
   line up with the one in their hand. Four fixed columns, tabular figures. */
.cc-head{display:flex;align-items:flex-start;gap:6px;margin-bottom:16px}
.cc-back{flex-shrink:0;width:44px;height:44px;margin-left:-10px;display:flex;align-items:center;justify-content:center;background:none;border:none;color:var(--g200);cursor:pointer;-webkit-tap-highlight-color:transparent}
.cc-fav{flex-shrink:0;margin-left:auto;align-self:flex-start}
.cc-head-txt{min-width:0;padding-top:2px}
.cc-sub{margin-top:4px;font-size:var(--t-sm);color:var(--g400);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cc-prog{margin-bottom:14px}
.cc-prog-bar{height:4px;border-radius:var(--r-pill);background:var(--surf-1);overflow:hidden}
.cc-prog-bar span{display:block;height:100%;width:0;border-radius:var(--r-pill);background:var(--gold);transition:width .2s ease-out}
.cc-prog-lbl{margin-top:7px;font-size:var(--t-micro);letter-spacing:.6px;text-transform:uppercase;font-weight:700;color:var(--g400)}
.cc-warn{margin-bottom:12px;padding:10px 12px;border-radius:var(--r-sm);background:rgba(201,168,76,.09);border:1px solid rgba(201,168,76,.3);color:var(--goldl);font-size:var(--t-sm);font-weight:600}
.cc-grid{background:var(--surf-2);border:1px solid rgba(39,107,48,.16);border-radius:var(--r-md);overflow:hidden}
.cc-headrow,.cc-row{display:grid;grid-template-columns:44px 1fr 1fr 1fr;align-items:center;column-gap:8px;padding:0 12px}
.cc-headrow{min-height:34px;color:var(--gold);font-size:var(--t-micro);font-weight:700;letter-spacing:1px;text-transform:uppercase;background:rgba(8,22,11,.4)}
.cc-headrow span:not(:first-child){text-align:center}
.cc-sec-h{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 12px 7px;border-top:1px solid rgba(39,107,48,.16);background:rgba(8,22,11,.22);
  font-size:var(--t-micro);font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--g300)}
.cc-sec-m{letter-spacing:.4px;color:var(--g400)}
.cc-sec-m.warn{color:var(--goldl)}
.cc-row{min-height:48px;border-top:1px solid rgba(39,107,48,.10)}
.cc-hole{font-family:var(--fd);font-size:var(--t-base);font-weight:700;color:var(--cream);font-variant-numeric:tabular-nums}
/* 44px tall: this is the app's touch floor and an 18-row form is where it matters most. */
.cc-in{width:100%;min-height:44px;padding:4px 2px;text-align:center;font-family:var(--fb);font-size:var(--t-base);font-weight:600;font-variant-numeric:tabular-nums;
  color:var(--cream);background:transparent;border:none;border-radius:var(--r-sm);-webkit-appearance:none;appearance:none;margin:0}
.cc-in::placeholder{color:var(--g600)}
.cc-in:focus{outline:none;background:rgba(8,22,11,.72);box-shadow:inset 0 0 0 1px rgba(201,168,76,.55)}
.cc-in-si:placeholder-shown{background:rgba(201,168,76,.07);box-shadow:inset 0 0 0 1px rgba(201,168,76,.22)}
.cc-in-yd{color:var(--g200);font-weight:500}
.cc-in:focus-visible{outline:2px solid var(--gold);outline-offset:-2px}
/* Sticky: the way out must not require scrolling 18 rows back up. Sits above the
   floating nav, hence the --nav-* terms rather than a hardcoded offset. */
.cc-foot{position:sticky;bottom:calc(var(--nav-tall) + var(--nav-gap) + var(--nav-inset) + 12px);margin-top:16px;padding-top:12px}
.cc-save{margin-top:0;box-shadow:0 10px 28px rgba(5,20,10,.55)}
/* Hide number spinners: they steal width from a 3-digit yardage and are unusable
   with a glove on. Arrow keys and the roving _phKey handler cover the same ground. */
.cc-in::-webkit-outer-spin-button,.cc-in::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
.cc-in[type=number]{-moz-appearance:textfield}
/* Round settings: labels stay fixed on the left and the choices sit directly beside
   them. No helper copy is needed because every option names the value it sets. */
/* Course search sheet: one field doing one job, so the search bar is a single unit
   rather than an input and a button that merely sit next to each other. */
.cs-note{margin:-8px 0 14px;font-size:var(--t-sm);color:var(--g400);line-height:1.5}
/* Back-up-a-level row above tee/course lists that got here via drill-down (club →
   courses → tees). Same tap target sizing as .cs-row so the list doesn't jump. */
.cs-back{display:flex;align-items:center;gap:6px;width:100%;padding:10px 12px;margin-bottom:2px;text-align:left;background:none;border:none;color:var(--g300);font-family:var(--fb);font-size:var(--t-sm);font-weight:600;cursor:pointer;-webkit-tap-highlight-color:transparent}
.cs-back:active{background:rgba(39,107,48,.18)}
.cs-bar{display:flex;align-items:center;gap:8px;padding:0 6px 0 12px;margin-bottom:14px;background:var(--surf-1);border:1px solid rgba(39,107,48,.32);border-radius:var(--r-md)}
.cs-bar:focus-within{border-color:rgba(201,168,76,.5)}
.cs-bar-ic{flex-shrink:0;color:var(--g300)}
.cs-bar input{flex:1;min-width:0;min-height:52px;padding:0;background:none;border:none;border-radius:0;color:var(--cream);font-size:var(--t-base)}
.cs-bar input:focus{outline:none;border:none}
.cs-go{flex-shrink:0;margin:0}
/* Result rows: the club name leads, its place is the quiet second line, and the whole
   row is the target rather than a chevron nobody aims at. */
.cs-row{display:flex;align-items:center;gap:10px;width:100%;padding:13px 12px;text-align:left;background:none;border:none;border-bottom:1px solid rgba(39,107,48,.12);
  color:var(--cream);font-family:var(--fb);cursor:pointer;-webkit-tap-highlight-color:transparent}
.cs-row:last-child{border-bottom:none}
.cs-row:active{background:rgba(39,107,48,.18)}
.cs-row:focus-visible{outline:2px solid var(--g300);outline-offset:-2px}
/* ⚠️ display:block is LOAD-BEARING. These are <span>s (they sit inside a <button>,
   where a <div> is invalid), so without it the name and its sub-line render on one
   run-on line instead of stacking. Same reason .cs-row-r is inline-flex, not flex. */
.cs-row-t{display:block;font-size:var(--t-base);font-weight:600}
.cs-row-s{display:block;margin-top:2px;font-size:var(--t-micro);color:var(--g400)}
.cs-row-x{margin-left:auto;flex-shrink:0;color:var(--g500)}
.cs-row-r{display:inline-flex;align-items:center;gap:3px;margin-top:2px;font-size:var(--t-micro);color:var(--gold)}
/* Roster rows. The handicap is the row's second fact, so it is stated in words
   ("plays off 12") rather than abbreviated into a pill competing with the name. */
/* ⚠️ min-height, so a member row (plain "plays off 12" text) and a guest row (which
   carries a 44px input between two steppers) are the SAME height. Measured before the
   fix: 50px vs 67px, which read as the list having two different row sizes. The guest
   row's input is the tallest thing here, so it sets the floor for everyone. */
.nrp-row{display:flex;align-items:center;gap:10px;min-height:64px;padding:9px 14px}
.nrp-row+.nrp-row{border-top:1px solid rgba(39,107,48,.14)}
.nrp-name{font-size:var(--t-base);font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.nrp-guest{margin-left:6px;font-size:var(--t-micro);color:var(--g500);font-weight:600}
.nrp-hcp{margin-left:auto;display:flex;align-items:center;gap:7px;flex-shrink:0}
.nrp-hcp-k{font-size:var(--t-micro);color:var(--g500);font-weight:600}
.nrp-hcp strong{font-family:var(--fd);font-size:var(--t-lg);font-weight:700;color:var(--gold);font-variant-numeric:tabular-nums}
.nrp-hcp-edit{gap:2px}
.nrp-hcp-edit input{width:46px;min-height:44px;padding:0;text-align:center;font-family:var(--fd);font-size:var(--t-lg);font-weight:700;font-variant-numeric:tabular-nums;
  color:var(--gold);background:transparent;border:none;-webkit-appearance:none;appearance:none;margin:0}
.nrp-hcp-edit input::-webkit-outer-spin-button,.nrp-hcp-edit input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
.nrp-hcp-edit input[type=number]{-moz-appearance:textfield}
.nrp-hcp-edit input:focus{outline:none;background:rgba(8,22,11,.6);border-radius:var(--r-sm);box-shadow:inset 0 0 0 1px rgba(201,168,76,.5)}
.nrp-step{width:32px;height:32px;flex-shrink:0;display:flex;align-items:center;justify-content:center;border:1px solid rgba(92,181,102,.28);border-radius:var(--r-sm);
  background:rgba(8,22,11,.42);color:var(--g200);cursor:pointer;-webkit-tap-highlight-color:transparent;position:relative}
/* Visually 32px, but a 44px tap target — the touch floor without a chunky control. */
.nrp-step::before{content:'';position:absolute;inset:-6px}
.nrp-step:active{background:rgba(39,107,48,.3)}
.nrp-step:focus-visible{outline:2px solid var(--g300);outline-offset:2px}
.nrp-del{background:none;border:none;color:rgba(255,255,255,.25);cursor:pointer;padding:4px;margin-left:2px;line-height:0;flex-shrink:0;-webkit-tap-highlight-color:transparent}
.nrp-del:focus-visible{outline:2px solid var(--g300);outline-offset:2px}
/* Section labels. These ARE wanted (ROUND / SCORING / PLAYERS) — v297 stripped them
   on a misreading of the reference and had to put them back. "Course" is the only
   one the reference omits, because the course card names itself. */
/* ── MANUAL ENTRY ────────────────────────────────────────────────────────────────
   Was the last screen still styled entirely inline, so it read as a different app.
   Reuses the page's own vocabulary: section label, labelled fields, .btn actions. */
.nr-mback{display:flex;align-items:center;gap:6px;margin:0 0 14px;padding:8px 10px 8px 0;
  background:none;border:0;color:var(--g300);font:600 var(--t-sm)/1 var(--fb);cursor:pointer;
  -webkit-tap-highlight-color:transparent}
.nr-mback:active{color:var(--cream)}
.nr-mfield{min-width:0;margin-bottom:14px}
.nr-mlabel{display:block;margin:0 0 7px;font-size:var(--t-micro);letter-spacing:1.4px;
  text-transform:uppercase;font-weight:700;color:var(--g300)}
.nr-mfield input{width:100%;min-height:48px;margin:0;padding:12px 14px;
  background:linear-gradient(145deg,rgba(22,61,28,.9),rgba(15,41,20,.8));
  border:1px solid rgba(58,142,68,.18);border-radius:var(--r-md);color:var(--cream);
  font-family:var(--fb);font-size:var(--t-base);box-shadow:0 2px 8px rgba(0,0,0,.2)}
.nr-mfield input:focus{outline:none;border-color:rgba(201,168,76,.5)}
.nr-mgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
.nr-msugg-wrap{position:relative}
.nr-msugg{display:none;position:absolute;left:0;right:0;top:100%;z-index:30;margin-top:4px;
  background:var(--surf-2);border:1px solid rgba(39,107,48,.3);border-radius:var(--r-sm);
  overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,.45);max-height:210px;overflow-y:auto}
.nr-mbtn{margin-top:10px;font-size:var(--t-sm);gap:8px}
.nr-seclbl{margin:0 0 10px;font-size:var(--t-micro);letter-spacing:1.6px;text-transform:uppercase;font-weight:700;color:var(--g300)}
.nr-seclbl-row{display:flex;align-items:center;justify-content:space-between;gap:12px}
.nr-addlink{position:relative;min-height:32px;padding:6px 2px;background:none;border:0;color:var(--g200);font:700 var(--t-sm)/1 var(--fb);letter-spacing:0;text-transform:none;cursor:pointer;-webkit-tap-highlight-color:transparent}
.nr-addlink:active{color:var(--cream)}

/* ── HOLES / FIRST HOLE: one box per setting, whole box is the control ──────────
   The chosen value is the big gold figure and the alternative trails it small and
   grey ("18 / 9"), so the box states what IS rather than offering two buttons of
   equal weight. Tapping anywhere in the box switches to the other value IN PLACE —
   nothing moves, only the two figures swap roles. */
.nr-bigrow{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:22px}
.nr-big{min-height:82px;display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:9px;padding:14px 16px;text-align:left;
  background:linear-gradient(145deg,#163d1c,#0f2914);
  border:1px solid rgba(58,142,68,.26);border-radius:var(--r-md);cursor:pointer;font-family:var(--fb);
  -webkit-tap-highlight-color:transparent;box-shadow:0 2px 8px rgba(0,0,0,.2);
  transition:background .18s,border-color .18s,transform .18s cubic-bezier(.22,1,.36,1)}
.nr-big:active{background:linear-gradient(145deg,#1d4d24,#143619);transform:scale(.985)}
.nr-big:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
.nr-big-k{font-size:var(--t-micro);letter-spacing:1.4px;text-transform:uppercase;font-weight:700;color:var(--g300)}
.nr-big-v{display:flex;align-items:baseline;gap:7px;min-width:0}
.nr-big-v strong{font-family:var(--fd);font-size:26px;font-weight:700;line-height:1;color:var(--goldl)}
.nr-big-v span{font-size:var(--t-sm);font-weight:600;color:var(--g400)}

/* ── SCORING: three equal boxes, selected = gold border + gold label ────────────
   ⚠️ NOT a solid gold fill. v299 filled these and the page then had four competing
   gold blocks; the reference keeps the fill for Start round alone. */
.nr-scoring{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:22px}
.nr-type-btn{min-width:0;min-height:48px;padding:10px 4px;
  background:linear-gradient(145deg,#163d1c,#0f2914);
  border:1px solid rgba(58,142,68,.18);border-radius:var(--r-md);
  color:var(--g200);font-family:var(--fb);font-size:var(--t-sm);font-weight:600;letter-spacing:-.2px;
  cursor:pointer;-webkit-tap-highlight-color:transparent;box-shadow:0 2px 8px rgba(0,0,0,.2);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:center;
  transition:background .18s,border-color .18s,color .18s,transform .18s cubic-bezier(.22,1,.36,1)}
.nr-type-btn.on{background:linear-gradient(145deg,#2f3119,#1f2814);
  border-color:rgba(201,168,76,.55);color:var(--goldl);font-weight:700}
.nr-type-btn:not(.on):active{background:linear-gradient(145deg,rgba(39,107,48,.4),rgba(30,82,38,.3));transform:scale(.985)}
.nr-type-btn:focus-visible{outline:2px solid var(--g300);outline-offset:2px}

/* Roster rows sit on one surface, as before — the reference keeps them boxed. */
.nr-players{background:var(--surf-2);border:1px solid color-mix(in srgb,var(--cream) 12%,transparent);border-radius:var(--r-md);overflow:hidden}
.nr-players:empty,.nr-players:has(#nrplayerlist:empty){display:none}
.nr-spin{display:flex;width:100%;margin-top:10px;padding:9px;align-items:center;justify-content:center;gap:7px;background:none;border:0;color:var(--g400);font:600 var(--t-sm)/1 var(--fb);cursor:pointer;-webkit-tap-highlight-color:transparent}
/* Sits directly under the PLAYERS label, so it does not need .btn's 16px top. */
.nr-addplayers{margin-top:0;font-size:var(--t-sm);gap:8px}
/* ── ADD PLAYER SHEET ────────────────────────────────────────────────────────────
   Compact rows: 12px padding + a 34px avatar made a club of 20 members a very tall
   scroll. 28px avatar, tighter padding, and a `+` icon instead of the word "Add"
   repeated on every line. The row still clears the 44px touch floor via its button. */
.apl-list{display:flex;flex-direction:column;gap:4px}
.apl-row{display:flex;align-items:center;gap:10px;width:100%;min-height:52px;padding:6px 10px 6px 8px;
  text-align:left;background:transparent;border:1px solid transparent;border-radius:var(--r-sm);
  color:var(--cream);font-family:var(--fb);cursor:pointer;-webkit-tap-highlight-color:transparent;
  transition:background .12s,border-color .12s}
/* Selected rows HIGHLIGHT — this is the whole point of multi-select: you can see at a
   glance who is going in before committing. */
/* Selected: a gold LEFT MARKER + gold name, not a filled box. The filled boxes sat
   edge to edge and read as one slab once two people were picked. */
.apl-row.on{background:transparent;border-color:transparent;box-shadow:inset 3px 0 0 var(--gold)}
.apl-row.on .apl-name{color:var(--goldl)}
.apl-row:active{background:rgba(39,107,48,.26)}
.apl-row:focus-visible{outline:2px solid var(--g300);outline-offset:2px}
.apl-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  font-size:var(--t-base);font-weight:600;color:var(--cream)}
.apl-check{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:24px;height:24px;
  border:1.5px solid rgba(92,181,102,.45);border-radius:6px;color:transparent;background:rgba(8,22,11,.4)}
.apl-row.on .apl-check{background:var(--gold);border-color:var(--gold);color:var(--g900)}
.apl-none{padding:18px 4px;text-align:center;font-size:var(--t-sm);color:var(--g400)}
.apl-commit{margin-top:12px}
.apl-commit:disabled{opacity:.45;cursor:default}
/* Visually 36px, but a 44px tap target — same trick as .nrp-step. */
/* ⚠️ WHITE, and smaller than the old 58px gold slab (Jordan: "a bit in your face").
   Overrides .bgld's gold gradient — .btn.bgld still supplies layout/press states, so
   this must stay AFTER it in the cascade and keep both class names on the element. */
.nr-start{width:auto;min-width:180px;display:flex;margin:22px auto 0;justify-content:center;min-height:46px;padding:12px 30px;
  background:var(--cream);background-image:none;border:0;border-radius:var(--r-pill);
  color:var(--g900);font-size:var(--t-base);font-weight:700;box-shadow:none}
.nr-start:active{background:color-mix(in srgb,var(--cream) 88%,var(--g900));transform:translateY(1px)}
.nr-start:focus-visible{outline:2px solid var(--cream);outline-offset:3px}

.nrpick:active,.nralt button:active{background:rgba(39,107,48,.22)}
@media (hover:hover){
  .nrpick:hover,.nralt button:hover{background:rgba(39,107,48,.12)}
  .nr-type-btn:not(.on):hover{background:rgba(39,107,48,.16);color:var(--cream)}
}
/* Narrow phones: the three scoring boxes are what runs out of room first. */
@media (max-width:370px){.nr-type-btn{font-size:var(--t-micro);padding-inline:2px}.nr-fact{padding-inline:11px}.nr-fact-v{font-size:20px}}
@media (prefers-reduced-motion:reduce){.nr-type-btn,.nr-big{transition:none}}

/* The achievement wall is scanned as a row, so title length must not move the
   earner/count. Reserve two title lines and let the final row take the bottom edge. */
#ch-badges{align-items:stretch}
.ch-badge-card{min-height:0}
.ch-badge-title{min-height:28px;display:flex;align-items:flex-end;justify-content:center}
.ch-badge-earner{margin-top:0;min-height:18px;max-width:100%;justify-content:center}
/* Short notices share a quiet surface and wrap on narrow phones. */
#toast,#toastundo{position:fixed;bottom:calc(96px + env(safe-area-inset-bottom));left:16px;right:16px;max-width:420px;margin-inline:auto;background:var(--surf-2);color:var(--cream);padding:14px 16px;border-radius:var(--r-md);font:500 var(--t-base)/1.5 var(--fb);z-index:1101;opacity:0;transition:opacity .18s;pointer-events:none;white-space:normal;overflow-wrap:anywhere;box-shadow:0 8px 24px oklch(0.12 0.02 148 / .25);border:1px solid color-mix(in srgb,var(--cream) 16%,transparent)}
#toast.err{border-color:color-mix(in srgb,var(--danger) 60%,var(--cream) 15%)}
#toast.ok{border-color:color-mix(in srgb,var(--g300) 30%,transparent)}
#toastundo{display:flex;align-items:center;gap:16px;padding:8px 8px 8px 16px}
#toastundo-msg{flex:1;min-width:0}
#toastundo-btn{flex-shrink:0;min-height:44px;padding:10px 14px;background:transparent;border:0;border-radius:var(--r-sm);color:var(--cream);font:700 var(--t-sm)/1.4 var(--fb);cursor:pointer}
#toastundo-btn:hover{background:var(--surf-3)}
#mconfirm{backdrop-filter:none}
#mconfirm .modal{background:var(--surf-2);border-color:color-mix(in srgb,var(--cream) 14%,transparent);padding-bottom:calc(24px + env(safe-area-inset-bottom))}
#mconfirm .mtitle{font-family:var(--fb);font-size:var(--t-xl);font-weight:600;letter-spacing:-.025em;margin-bottom:10px}
#mconfirm-body{font-size:var(--t-base);color:var(--g200);line-height:1.6;margin-bottom:24px}
.confirm-actions{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.confirm-actions .btn{margin:0;font-family:var(--fb)}
.confirm-actions .bgh{background:transparent;border-color:color-mix(in srgb,var(--cream) 16%,transparent);color:var(--cream)}
@media(prefers-reduced-motion:reduce){#toast,#toastundo{transition:none}}
/* Club-switch veil — see the markup comment. Deliberately opaque, not a translucent
   scrim: the point is to HIDE the previous club's stale rows, not tint them. */
#clubveil{position:fixed;inset:0;z-index:1050;background:var(--g900);display:none;align-items:center;justify-content:center;opacity:0;transition:opacity .18s}
#clubveil.on{display:flex;opacity:1}
#clubveil .cv-inner{display:flex;flex-direction:column;align-items:center;gap:14px}
#clubveil .cv-spin{width:30px;height:30px;border:2px solid rgba(245,240,232,.18);border-top-color:var(--gold);border-radius:50%;animation:cvspin .8s linear infinite}
#clubveil .cv-txt{font-size:var(--t-sm);color:var(--g300);letter-spacing:.3px}
@keyframes cvspin{to{transform:rotate(360deg)}}

/* Analytics consent banner — shown once until a choice is stored. */
#consent{position:fixed;left:12px;right:12px;bottom:12px;bottom:calc(12px + env(safe-area-inset-bottom));z-index:1200;background:rgba(15,41,20,.98);border:1px solid rgba(39,107,48,.4);border-radius:var(--r-md);padding:16px 18px;box-shadow:0 8px 32px rgba(0,0,0,.5);display:none;max-width:520px;margin:0 auto}
#consent.on{display:block}
#consent p{margin:0 0 12px;font-size:var(--t-sm);line-height:1.55;color:rgba(245,240,232,.8)}
#consent a{color:var(--g200)}
.consentbtns{display:flex;gap:8px}
.consentbtns button{flex:1;padding:10px 14px;border-radius:var(--r-sm);font-size:var(--t-sm);font-weight:600;cursor:pointer;font-family:var(--fb);border:1px solid rgba(39,107,48,.4)}
.cbtn-yes{background:linear-gradient(135deg,var(--g500),var(--g400));color:var(--cream);border-color:transparent}
.cbtn-no{background:transparent;color:rgba(245,240,232,.65)}

/* ── FLOATING CHAT BUTTON ── */
#chatfab{
  position:fixed;
  right:20px;
  /* Must clear the FLOATING pill, whose height now VARIES with the latch state.
     ⚠️ Track the TALL (labels-shown) state, not the compact one — sizing to the compact
     state would leave the FAB overlapping the pill whenever labels are visible, which
     is the resting state. --nav-tall is the single source for that height; see :root.
     The old value assumed a rigid 64px edge-anchored bar and predates the pill.
     Verified with tests/app/preview-floatnav.py at inset 0 / 24 / 34. */
  /* ⚠️ Tracks the pill's FULL inset, matching #botnav — see the note there. Using the
     halved inset here left the FAB sitting lower than the pill it must clear. */
  /* ⚠️ Tracks --nav-gap so the FAB can never drift from the pill it must clear.
     Was a hardcoded +22px, which stopped matching the moment the pill's gap became
     platform-dependent. +12px is the visual separation between FAB and pill. */
  /* ⚠️ --nav-inset, NOT the raw env(). The bar's margin is capped at 20px (see the
     token), so a FAB using the full 34px inset would float 14px above the bar it is
     meant to clear — the exact drift that stranded it in v135. Both must compose the
     SAME tokens or they cannot stay in step. */
  bottom:calc(var(--nav-tall) + var(--nav-inset) + var(--nav-gap) + 12px);
  width:50px;height:50px;
  background:linear-gradient(135deg,var(--g500),var(--g400));
  /* ⚠️ --blob-fab, NOT --blob1. Measured: --blob1's bottom-left corner is a 27x20px
     quarter-ellipse — a 1.35 width:height ratio, against 0.82-1.03 on the other three.
     At that ratio the curve runs almost STRAIGHT and then turns sharply, so the corner
     reads as an angular bite out of the button (reported 2026-08-19 as a "transparent
     area" / "missing bit"). The shape has been correct-per-spec since the first commit;
     what changed is that v220/217592a moved the FAB when the nav pill was rebuilt, onto
     a backdrop that makes that flat lobe obvious. Kept organic — every corner is still
     off-circle — but no ratio exceeds 1.12, so nothing flattens into a straight edge. */
  border-radius:var(--blob-fab);
  border:none;cursor:pointer;
  color:var(--cream);font-size:var(--t-lg);
  display:none;
  align-items:center;justify-content:center;
  /* 🔴 NEUTRAL AND TIGHT — no wide COLOURED glow. Jordan, 2026-08-23, with a
     screenshot: the FAB and the bar had "a transparent slice missing". Nothing was
     missing; it was this shadow. A 20px-blur GREEN glow at .4 over a dark green page
     does not read as light falling on a surface — it reads as a lobe of slightly
     different green with a soft edge, i.e. as a bite taken out of whatever it lands on
     (it was cutting through the "90" on the best-score card behind it).
     ⚠️ A shadow that is the SAME HUE as the page cannot read as depth. Keep it black
     and keep the blur under the button's own radius. */
  box-shadow:0 4px 10px rgba(0,0,0,.34);
  /* ⚠️ ABOVE the sheets (.moverlay is 800). The FAB was already position:fixed and
     therefore on every PAGE, but live scoring is a full-screen modal — so at z-index
     100 it sat underneath and chat was unreachable exactly when you most want it
     (mid-round, telling the group where you are). Sheets that must NOT be interrupted
     hide it explicitly via _fabOverSheet(false); see toggleChat/openHbh. */
  z-index:850;
  animation:fabf 6s ease-in-out infinite alternate;
  -webkit-tap-highlight-color:transparent;
  transition:transform .15s;
}
#chatfab:active{transform:scale(.92)}
#chatbadge{
  position:absolute;top:-5px;right:-5px;
  background:linear-gradient(135deg,var(--gold),var(--goldl));
  color:var(--g900);border-radius:50%;
  width:20px;height:20px;
  font-size:var(--t-micro);font-weight:700;
  display:none;align-items:center;justify-content:center;
  box-shadow:0 2px 6px rgba(0,0,0,.3);
  font-family:var(--fb);
}

/* ── CHAT PANEL ── */
/* 🔴 ABOVE THE SHEETS (.moverlay is 800), MATCHING THE FAB THAT OPENS IT.
   Jordan, 2026-08-23: "The chat during live scoring, we can see the fab now but when you
   click on it it still is hidden behind the live scoring."
   The FAB was deliberately raised to 850 so it stays reachable while a sheet is open —
   but the PANEL it opens was left at 700, below .moverlay's 800. So the button was
   visible, the tap worked, and the chat slid in behind the live-scoring sheet: the
   control was fixed without the thing it controls.
   ⚠️ A control and the surface it opens must be raised TOGETHER. 860 keeps it just above
   the FAB so the button cannot float on top of the message list.
   Still below the toast (900) and the confirm dialog (1050+), which must interrupt. */
#chatpanel{position:fixed;inset:0;z-index:860;background:rgba(8,20,10,.98);backdrop-filter:blur(20px);display:flex;flex-direction:column;transform:translateX(100%);transition:transform .3s cubic-bezier(.16,1,.3,1)}
#chatpanel.on{transform:translateX(0)}
.chatheader{padding:calc(16px + env(safe-area-inset-top)) 16px 14px;border-bottom:1px solid rgba(39,107,48,.15);display:flex;align-items:center;justify-content:space-between;background:rgba(10,26,14,.5)}
.chattitle{font-family:var(--fd);font-size:var(--t-lg);font-weight:700;color:var(--cream)}
.chatclose{background:var(--surf-3);border:1px solid rgba(39,107,48,.2);color:var(--g300);width:44px;height:44px;border-radius:50%;cursor:pointer;font-size:var(--t-base);display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent}
.chatmsgs{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:16px;display:flex;flex-direction:column;gap:8px}
.cmsg{max-width:80%}
.cmsg.mine{align-self:flex-end}.cmsg.theirs{align-self:flex-start}
.cbubble{padding:10px 14px;border-radius:var(--r-lg);font-size:var(--t-base);line-height:1.45;word-break:break-word;position:relative}
.cmsgmenu{position:absolute;top:2px;right:4px;background:none;border:none;color:inherit;opacity:.35;font-size:var(--t-base);line-height:1;padding:2px 4px;cursor:pointer;-webkit-tap-highlight-color:transparent}
.cmsgmenu:hover{opacity:.8}
.cbubble{padding-right:26px}
.mine .cbubble{background:linear-gradient(135deg,var(--g500),var(--g600));color:var(--cream);border-bottom-right-radius:4px}
.theirs .cbubble{background:rgba(22,61,28,.7);border:1px solid rgba(39,107,48,.2);color:var(--cream);border-bottom-left-radius:4px}
.cmeta{font-size:var(--t-micro);color:var(--g500);margin-top:3px}
.mine .cmeta{text-align:right}
.cname{font-size:var(--t-micro);color:var(--g400);margin-bottom:3px;font-weight:500}
.chatinrow{padding:12px 14px;padding-bottom:calc(12px + env(safe-area-inset-bottom));border-top:1px solid rgba(39,107,48,.15);display:flex;gap:8px;background:rgba(10,26,14,.8)}
.chatinrow input{flex:1;margin:0}
.chatsend{background:linear-gradient(135deg,var(--g500),var(--g400));border:none;color:var(--cream);width:42px;height:42px;border-radius:var(--r-sm);cursor:pointer;font-size:var(--t-lg);display:flex;align-items:center;justify-content:center;flex-shrink:0;-webkit-tap-highlight-color:transparent}

/* ── BOTTOM NAV (mobile, floating pill) ────────────────────────────────────────
   Rebuilt 2026-08-16 as a detached pill (Revolut-style): labels are visible at rest,
   hide on scroll-DOWN and return on scroll-UP. The state LATCHES — it persists until
   the opposite gesture, it does not time out. See _navScrollLatch().

   ⚠️ THE OLD BAR'S HISTORY STILL APPLIES. It was edge-anchored (bottom:0) and full
   width; this one floats, which REINTRODUCES the iOS standalone risk the v133/v136
   work closed, because a fixed element with a positive `bottom` is exactly the case
   that anchored to the SHORT viewport. The two mitigations are kept deliberately:
     · html,body use 100dvh (see the rule near the top) so the body reaches the screen
     · the ::after skirt below still paints from the pill's bottom edge downward
   Re-measure with tests/app/preview-floatnav.py before changing any of the geometry.

   Layout note: the pill no longer spans the viewport, so it is a `left/right` inset
   box centred by those insets — NOT translateX(-50%), which would fight the
   transform used for the hide animation. */
/* ── BOTTOM NAV — FLOATING BAR THAT WRAPS THE BALL (2026-08-22) ──────────────────
   Jordan: "a bar that is stuck to the bottom of the screen no spacing below… center
   will be a golf ball that is larger than the taskbar and sticks out protruding."

   ⚠️ THIS DELIBERATELY REPLACES THE FLOATING PILL (v215/v216). The pill's geometry
   comments are preserved in git; the hard-won LESSONS that still apply are kept here:

   1. ⚠️ `bottom:0` + the safe-area inset as INNER PADDING — never as an offset.
      The pill anchored with `bottom:0` + a margin because on iOS standalone the visible
      viewport is shorter than 100dvh and `body` is overflow:hidden, so anything pushed
      below that baseline gets CLIPPED. A flush bar wants to reach the baseline anyway,
      which removes that whole failure mode — but the inset must still be honoured or
      the icons sit under the home indicator. It goes INSIDE, as padding-bottom.
   2. ⚠️ Do NOT put padding-bottom on a RIGID-height icon row. That was the original
      v133 bug: `.bntabs` was `height:var(--nav)` so bottom padding could only shove the
      icons UPWARD, giving a 33px dead band on iPhone vs 16px on Android. `.bntabs` is
      content-sized now and the padding lives on `#botnav`, which is what makes this safe.
   3. ⚠️ The skirt is NOT reinstated. It existed to hide a dark band under an edge
      -anchored bar; this bar is opaque to the screen edge, so there is nothing to hide,
      and the skirt is what visually cut the corners off the pill.

   The SURFACE is an SVG path (.bnsurf), not this element's background — see the markup.
   That is what makes the wrap around the ball possible (a concave inner edge and a
   surface that rises above its own box). This element only positions and clips. */
#botnav{
  /* Also the containing block for the absolutely-positioned ball — that is deliberate:
     the ball must move WITH the bar (the hide transform, any height change), and an
     independently-fixed button is exactly how #chatfab drifted out of alignment. */
  position:fixed;
  /* ⚠️ FLOATING, INSET BAR (2026-08-22, second pass). The first pass was flush and
     full-bleed; Jordan then supplied a reference image of a rounded bar inset from all
     four edges and chose to match it exactly, superseding the earlier "no spacing
     below". The insets are deliberately SMALL (12px) — 56px was tried on the old pill
     and BROKE THE NAV: the tabs could not fit and the last one rendered outside the
     bar. Keep them small; see the .bnin note about five-across fitting at 320px.
     ⚠️ Insetting BOTH edges with width:auto — do NOT pair left/right with an explicit
     width, which anchors the box left and overflows the right edge. */
  left:12px;right:12px;
  /* ⚠️ bottom:0 + a MARGIN, never bottom:calc(). On iOS standalone the visible viewport
     is shorter than 100dvh and body is overflow:hidden, so a bar anchored a positive
     distance above the baseline has its lower portion CLIPPED. Anchoring the box bottom
     at the reliable baseline and creating the visual gap with margin avoids that — this
     cost three wrong fixes on the old floating pill. */
  bottom:0;
  /* 🔴 NO padding-bottom. The safe-area inset is carried ONCE, by the margin below.
     ⚠️ It used to be applied HERE **and** in that margin, so on an iPhone the bar sat
     34 + 34 = 68px above the screen edge instead of 34 — measured off Jordan's
     screenshot at ~92 CSS px of gap where 34 was expected. Android reports inset 0, so
     the double-count was invisible there and the bar looked correct.
     The padding was legitimate when .bnsurf stretched to fill this box (it reserved the
     strip the surface had to paint). .bnsurf is now a fixed --nav-tall, so the padding
     only ever added height. ⚠️ Do not reintroduce it: the inset belongs to the margin,
     which is what holds the FLOATING bar clear of the home indicator. */
  /* The visual gap beneath the bar. ⚠️ It MUST live in a margin shorthand alongside the
     auto centring — an earlier attempt declared margin-bottom above `margin:0 auto` and
     the shorthand silently reset it to 0. */
  margin:0 auto calc(var(--nav-gap) + var(--nav-inset));
  max-width:440px;
  /* ⚠️ NO background here — .bnsurf paints it. A background on this box would be a
     square rectangle behind the wrap and would fill the cut-out straight back in. */
  background:none;
  border:none;
  /* Room for the ball to protrude above the bar without being clipped. */
  padding-top:var(--ball-rise);
  display:none;
  flex-direction:column;
  justify-content:flex-end;
  z-index:500;
  /* ⚠️ Transition the TRANSFORM, never the height — animating layout properties is
     banned (shared design laws) and janks on a mid-range Android. */
  transition:transform .26s cubic-bezier(.22,1,.36,1);
}
/* The bar's painted surface, including the wrap. Stretched to the box with
   preserveAspectRatio="none", so the viewBox width never has to match the device.
   ⚠️ It spans the FULL height including the safe-area padding, so the colour reaches
   the physical screen edge — that is the "no spacing below" requirement.
   ⚠️ `bottom:0;top:var(--ball-rise)` rather than `inset:0`: the top strip is the ball's
   protrusion room and must stay transparent, or the ball's cut-out would be filled
   straight back in by surface. */
/* 🔴 NO BACKGROUND ON THIS ELEMENT. It used to paint one to cover the iOS safe-area
   strip, with only its BOTTOM corners rounded — so its square TOP corners drew a hard
   rectangle edge straight across the full width of the bar, behind the rounded
   shoulders. Jordan, on an Android screenshot: "there is a box behind it."
   ⚠️ Android has inset 0, so there was NO strip to cover there — the background was
   pure surplus and the box was the only thing it contributed.
   This element is now a pure layout row, exactly --nav-tall tall: it is the bar's
   SURFACE, and the safe-area inset below it is space (#botnav's padding), not surface. */
/* ⚠️ align-items:flex-end and an explicit height on the shoulders. The notch svg is
   exactly --nav-tall now, so stretch would also work — but flex-end states the intent
   (every child bottom-aligns on the bar's baseline) and means a child that is ever
   taller than the box cannot be silently squashed, which letterboxes its viewBox and
   opens transparent seams through the bar. That bug has shipped once. */
.bnsurf{position:absolute;left:0;right:0;top:var(--ball-rise);width:100%;
  height:var(--nav-tall);display:flex;align-items:flex-end;
  /* Filters apply after the three surface pieces are composited, so the pale top light
     follows the complete silhouette without reintroducing the broken CSS/SVG border.
     The two dark shadows stay close to the bar: separation, not a louder float. */
  pointer-events:none;filter:drop-shadow(0 0 1px rgba(142,212,148,.14)) drop-shadow(0 -1px 0 rgba(142,212,148,.20)) drop-shadow(0 2px 2px rgba(0,0,0,.56)) drop-shadow(0 6px 10px rgba(0,0,0,.32))}
/* 🔴 THE SAFE-AREA STRIP IS GONE, and it must not come back for THIS bar.
   Jordan, iPhone screenshot: the bar rendered ~94px tall with the icons crammed into
   its top 60px and a 34px slab of empty green below them.
   ⚠️ THE STRIP WAS A LEFTOVER FROM THE FLUSH, FULL-BLEED BAR. There, the bar genuinely
   reached the physical screen edge, so the inset had to be PAINTED or a dark band showed
   under it. This bar is FLOATING and INSET — the gap below it is the design — so there
   was never anything to cover, and painting the inset just made the bar 34px taller with
   nothing in the extra space. Android has inset 0, which is why it looked right there
   and wrong only on iOS.
   The inset is still honoured: #botnav's padding-bottom holds the bar clear of the home
   indicator. It is SPACE, not surface. */
/* The flat shoulders. They flex to fill whatever the fixed notch does not use, so the
   notch stays exactly centred and exactly --notch-w wide at every viewport width.
   ⚠️ Height is --nav-tall, NOT 100%: see the .bnsurf note. */
/* 🔴 THE SURFACE FILL — one value, repeated in three places that MUST agree
   (.bnsurf-side and .bnsurf-p's fill).
   Two separate fixes landed here on 2026-08-22:
   1. LIGHTER. It was rgba(23,54,29,.94), only 1.36:1 against the real page ground
      (--g900) — the bar barely read as a separate surface and its rounded corners were
      invisible because there was nothing for them to be rounded AGAINST. A floating bar
      reads as floating because it is LIGHTER than what is behind it, not because of a
      shadow. ⚠️ Earlier renders hid this because the test page used a brighter gradient
      than the app has — check contrast against --g900, never against a mockup.
   2. FULLY OPAQUE. Jordan: "the nav bar is transparent, needs to have a good colour."
      Even at .97 alpha the page content behind it (member rows, big score numbers) was
      visibly ghosting through — a nav bar is chrome, not a scrim, and content sliding
      under it must disappear cleanly. This is now a flat hex with NO alpha at all.
      ⚠️ Do not reintroduce alpha here "for depth"; the drop-shadow provides that.
   ⚠️ The OUTER corners are rounded (the reference is a rounded bar), the INNER edges
   that butt against the notch are square. :first-child / :last-child rather than a
   shared radius, or the shoulders would round where they meet the notch and open a
   notch-shaped gap in the middle of the bar. */
.bnsurf-side{flex:1 1 auto;min-width:0;height:var(--nav-tall);background:var(--nav-surf)}
/* 🔴 NO BORDERS ANYWHERE ON THE SURFACE. Jordan, 2026-08-22: "there is also a slight
   line going round it all which doesn't seem to be clean or all the way around."
   That was THREE different mechanisms trying to draw one outline: a border-top on the
   shoulders, a border-left/right on the end shoulders, and a stroke on the notch path.
   They cannot meet cleanly — a CSS border follows the box, an SVG stroke follows the
   path, and the two have different joins and antialiasing — so the line broke at every
   junction and stopped where the notch began. A single flat fill has no seams to get
   wrong; depth comes from the drop-shadow instead. ⚠️ Do not add a border back to any
   ONE of these pieces; it will only ever be a partial outline. */
.bnsurf-side:first-child{border-radius:var(--r-bar) 0 0 var(--r-bar)}
.bnsurf-side:last-child{border-radius:0 var(--r-bar) var(--r-bar) 0}
/* ⚠️ FIXED width AND fixed height — the viewBox aspect must equal the render box or the
   path letterboxes and opens transparent seams through the bar (that bug shipped once
   already; test-nav-hide asserts the aspect now).
   🔴 Height is exactly --nav-tall: this svg paints the bar surface with the trough cut
   down into it, and nothing stands above the bar's top edge. The viewBox height in
   shell.html must equal --nav-tall. */
.bnsurf-notch{flex:0 0 var(--notch-w);width:var(--notch-w);display:block;
  height:var(--nav-tall)}
/* Surface fill + hairline top edge. Matching the pill's tokens so the bar still reads
   as the same material, just a different shape. The stroke is the "lit from above"
   hairline the pill got from an inset box-shadow — an SVG path cannot take inset
   shadows, so it is drawn as a stroke on the path itself. */
/* ⚠️ FILL ONLY — no stroke. See the borders note above: the stroke was one of the three
   mechanisms that made the outline break at the notch. */
.bnsurf-p{fill:var(--nav-surf)}
/* ⚠️ The notch svg must overlap its neighbours by the stroke width, or the shoulders'
   1px border-top and the path's 1px stroke leave a visible seam where they meet.
   -0.5px each side is half a stroke — enough to butt them together, small enough not
   to shift the notch off centre. */
.bnsurf-notch{margin:0 -0.5px}
/* Skirt: fills the strip BELOW the floating pill with the page colour.
   ⚠️ Still required, and for the same reason as the old edge-anchored bar: in an iOS
   standalone PWA a fixed element can anchor to the short viewport, leaving a dark band
   beneath it. It sits BEHIND the pill (z-index:-1) and is the page colour, so on a
   correctly-anchored viewport it is invisible.
   ⚠️ It must NOT inherit the pill's rounded corners or surface — it is page ground,
   not part of the pill. Hence its own background and no border-radius.
   pointer-events:none so it can never eat a tap on the home indicator. */
/* ⚠️ THE SKIRT IS GONE — deleted 2026-08-16, and it must NOT be reinstated for the
   floating pill.
   It existed for the EDGE-ANCHORED bar: that bar had square bottom corners and was meant
   to reach the physical screen edge, so painting page colour from its bottom edge
   downward hid any dark band left by iOS anchoring to the short viewport.
   Against a floating pill it does the opposite of its job. It paints an opaque --g900
   rectangle flush across the pill's bottom edge, which VISUALLY CUTS OFF the rounded
   bottom corners and swallows the glow — reported three times on device as "the bottom
   of the pill is cut off" and misdiagnosed twice as a safe-area problem. The pill was
   never clipped; it was being painted over by its own pseudo-element.
   A detached pill has page ground beneath it by design, so there is nothing to hide. */
/* ⚠️ height:auto, NOT var(--nav). The old bar was a rigid --nav block; this one is
   content-sized so the label row can grow it. --nav is retained only as the ICON-ROW
   height, which is what .pg padding and #chatfab actually need to clear. */
/* 🔴 THE ROW IS EXACTLY THE SURFACE HEIGHT, AND CENTRES ITS ICONS IN IT.
   Jordan, 2026-08-23: "the icons now dont look center to the nav bar vertically, need to
   be lifted a bit." MEASURED: 15.5px of surface above the icons and 11.5px below — a 4px
   drop. The row was content-sized (8 + 7 + 25 icon + 7 + 8 = 55px) while the surface is
   --nav-tall (52px), so .bntabs overhung the bottom by 4px and carried the icons down
   with it. Everything looked centred in the ROW; the row was not the bar.
   ⚠️ THIS IS THE ONE PLACE A RIGID HEIGHT IS SAFE, and only because the safe-area inset
   is NOT carried here — it lives in #botnav's margin (see the v133 note there). Do not
   add padding-bottom to this row; that was the original v133 bug.
   ⚠️ Keep it equal to --nav-tall. If the row ever needs to be taller than the surface,
   the surface is what should grow. */
.bntabs{display:flex;width:100%;flex-shrink:0;min-height:var(--nav-tall);align-items:center;
  padding:0 4px;gap:2px;position:relative;z-index:1}
/* ⚠️ The centre SPACER holds the ball's cell open. It is FIXED to --notch-w (not flex:1
   — see its rule), and the four real tabs are flex:1, so they share whatever is left
   equally on either side. That is also what keeps the notch aligned with the gap.
   aria-hidden + no tabindex: the ball owns the interaction. */
/* 🔴 THE SPACER RESERVES THE BALL'S CELL, and it must be at least as wide as the
   wrap's FOOTPRINT or the neighbouring icons sit on the flare (measured once at 21px
   inside the old notch region — the flag and trophy were in the hole).
   ⚠️ But it must NOT be the full --notch-w. The svg is 176px wide while the wrap only
   touches the bar's top edge across 94.2px of that; the rest is ordinary flat surface
   that icons may sit on. Reserving all 176px shoved the inner icons 8.5-9% too far out
   versus the reference (measured: 19.5% vs 28.0% of bar width). --notch-mouth is the
   honest reservation, and test-nav-hide re-derives it from the shipped path. */
.bnspacer{flex:0 0 var(--notch-mouth);width:var(--notch-mouth);pointer-events:none}
/* ── THE PROTRUDING BALL ──────────────────────────────────────────────────────────
   ⚠️ Anchored to the BAR (position:absolute in #botnav), not to the viewport. An
   independently-fixed button is exactly how #chatfab drifted out of alignment when the
   bar's height changed — it must move with the thing it is wrapped by.
   ⚠️ Its vertical position is derived from --ball-rise and --ball-size, the SAME tokens
   the SVG wrap geometry is built from. Change one and the collar no longer follows the
   ball's edge — re-run tests/app/measure-wrapnav.py; see the :root note. */
.bnball{
  position:absolute;
  left:50%;
  /* The bar's SURFACE top edge sits at --ball-rise from this box's top (that strip is
     the protrusion room). Placing the ball so exactly --ball-rise of it stands above
     that edge means its bottom sits at:  100% - ball-rise - (ball-size - ball-rise)
     = 100% - ball-size, measured from this box's bottom.
     ⚠️ Written as the simplified form deliberately — the long version had --ball-rise
     appearing twice with opposite signs, which reads as if the rise were double-counted. */
  bottom:calc(100% - var(--ball-size));
  transform:translateX(-50%);
  width:var(--ball-size);height:var(--ball-size);
  padding:0;border:none;background:none;cursor:pointer;
  -webkit-tap-highlight-color:transparent;
  z-index:2;
  /* ⚠️ The hit area is the full square; the visual is the circle inside. */
  display:flex;align-items:center;justify-content:center;
}
.bnball-in{
  display:block;width:100%;height:100%;border-radius:50%;
  /* Lifted off the bar: a protruding control reads as protruding because of the shadow
     it casts DOWN onto the surface it emerges from, plus its own ambient depth. */
  /* ⚠️ RESTRAINED, because the ball is now a 3D RENDER that carries its own highlight
     and form shadow. The heavy ring + glow written for the old flat SVG disc fought
     that render — a lit sphere does not need a rim light drawn around it. What is left
     is only the CONTACT shadow that makes it read as sitting in front of the bar. */
  box-shadow:0 5px 14px rgba(0,0,0,.45),0 2px 5px rgba(0,0,0,.30);
  transition:transform .18s cubic-bezier(.22,1,.36,1),box-shadow .18s;
}
/* 🔴 THE IMAGE MUST FILL ITS CANVAS EDGE-TO-EDGE, or the circular drop-shadow on
   .bnball-in cannot line up with the visible sphere. Jordan: "the circle around the ball
   on both iOS and Android still doesn't fit or is positioned correctly."
   The first export padded the sphere onto a square canvas, so the art filled only 95.8%
   of the width and sat 1px off-centre while the shadow was a perfect centred circle —
   a mismatch no CSS can correct. The source sphere is genuinely 431x441 (an ellipse, not
   a circle), so it is now scaled to fill BOTH axes: a ~2% horizontal stretch on a
   dimpled sphere is invisible, a misaligned shadow ring is not.
   ⚠️ `object-fit:fill`, NOT contain — contain would re-letterbox it and undo the point.
   ⚠️ If nav-ball.webp is ever re-exported, check it still fills 100% of its canvas. */
.bnball-img{width:100%;height:100%;display:block;object-fit:fill;border-radius:50%}
.bnball:active .bnball-in{transform:scale(.92);box-shadow:0 2px 7px rgba(0,0,0,.42)}
/* 🔴 NO SELECTION RING ON THE BALL — removed at Jordan's request, 2026-08-22:
   "selection circle is even worse, lets just get rid of it for now".
   Two attempts both failed, and the reason is worth keeping: the ball is a RENDERED
   SPHERE with a soft antialiased edge, while any CSS ring (box-shadow spread, border,
   outline) has a hard mathematical edge. The two silhouettes cannot be made to agree —
   a border-box ring floats outside the sphere (measured: 12 of 18 sample points landed
   on alpha < 200), and insetting it to the solid silhouette then cuts visibly INTO the
   ball's shaded rim. This is a property of the artwork, not of the CSS.
   ⚠️ If a selected state is wanted here later, it has to come from something that is not
   an outline — a scale change, a shadow, or a ball IMAGE that has the ring baked into
   the render so it shares the sphere's own edge. Do not try a third CSS ring.
   The ball is the New Round button; it is the only thing on the bar that is not a tab,
   and there is no ambiguity about what it does. */
.bnball:focus-visible{outline:none}
.bnball:focus-visible .bnball-in{box-shadow:0 6px 16px rgba(0,0,0,.5)}
/* Inline SVG tab icons (the flag) — sized and tinted like the .webp ones so the row
   stays visually consistent. ⚠️ `currentColor` is what lets .bntab.on light it up;
   the .webp icons cannot do that, which is why the flag is inline. */
.bnsvg{width:24px;height:24px;display:block;opacity:.5;transition:opacity .2s,transform .18s cubic-bezier(.22,1,.36,1)}
.bntab.on .bnsvg{opacity:1;filter:drop-shadow(0 1px 1px rgba(0,0,0,.38))}
.bntab:active .bnsvg{transform:scale(.86)}
/* The LIVE dot. Reuses #bnlivebadge (already toggled by _setLiveBadge) but renders as a
   bare pulsing dot rather than a numeric badge — Jordan asked for "a dot indicating
   when a round is live". `.bndot` overrides the numeric badge's sizing.
   ⚠️ It must keep `display:none` in the base state: _setLiveBadge flips display between
   'none' and 'flex', so a dot that were visible by default would claim a round is live. */
.bndot{min-width:9px;width:9px;height:9px;padding:0;top:-1px;right:-3px;
  background:var(--gold);box-shadow:0 0 0 2px var(--nav-surf),0 0 8px rgba(201,168,76,.9);
  animation:pulse 1.6s infinite}
/* ⚠️ The tab is the TAP TARGET (flex:1, full cell) but NOT the lozenge — the lozenge is
   .bnin inside it. Painting the active background on .bntab itself made it stretch the
   full cell width, so it sat tight to the sides while leaving 6px above and below:
   "closer to the sides as it is the top" (Jordan, 2026-08-16). Splitting the two lets
   the hit area stay large while the visible lozenge takes even padding on all sides. */
.bntab{flex:1;display:flex;align-items:center;justify-content:center;padding:0;border:none;background:none;color:rgba(245,240,232,.42);cursor:pointer;-webkit-tap-highlight-color:transparent;position:relative;transition:color .18s}
/* ⚠️ RELATIVE width — `calc(100% - 10px)` — and it must NOT go back to a fixed px.
   Jordan, 2026-08-16, raised THREE times. The history matters because each fix caused
   the next problem:
     1. .bntab is flex:1 so .bnin STRETCHED to fill its cell. Changing .bnin's
        horizontal padding moved the outer gap by exactly 0px from 11px down to 8px —
        the lozenge was never sized by its padding.
     2. Stopping the stretch made each tab a DIFFERENT width (57 vs 61 measured),
        because the icons differ, so left and right could never match.
     3. A fixed 64px fixed that — and BROKE THE NAV IN PRODUCTION when paired with
        56px pill insets: five 64px lozenges need 320px but the pill only offered
        ~262px on a 390px phone, so the Profile tab rendered OUTSIDE the pill.
   A fixed width cannot satisfy both constraints, because the cell grows with the
   viewport: one number cannot give an even gap at every width AND fit five tabs at
   320px. Sizing relative to the cell does both.
   `calc(100% - 10px)` leaves 5px each side of the cell; `.bntabs{padding:8px 4px}`
   adds 4px, giving ~9-10px against the 9px vertical — even on all four sides at every
   width from 320px up. Change EITHER value and re-measure the painted rects. */
.bnin{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;padding:7px 0;width:calc(100% - 10px);max-width:72px;flex:0 0 auto;border-radius:var(--r-pill);transition:background .18s,box-shadow .18s,width .24s cubic-bezier(.22,1,.36,1)}
.bntab.on{color:var(--cream)}
/* The active lozenge. Wraps icon AND label together (it grows with the pill), which is
   what makes the selected tab read as one object rather than a tinted icon.
   Fully rounded to echo the pill, and lifted with its own soft glow so the selection
   reads at a glance in sunlight rather than relying on a small opacity difference. */
/* 🔴 NO LOZENGE. Jordan, 2026-08-22: "we could maybe just highlight the icon white
   rather than have the green box around them" — and separately that the box "doesn't
   fit or align to the ball very well". Both point the same way: a green pill sized for
   flat glyphs reads as a foreign object next to a 3D ball, and it was competing with
   the ball for the eye. The active state is now carried by the ICON ITSELF (full
   opacity, a restrained scale lift, and the cream label) rather than by a container.
   ⚠️ Deliberately EMPTY rather than deleted: .bnin is still the layout box and several
   notes reference this selector. If a background ever comes back here, re-check it
   against the ball — that is what went wrong the first time. */
.bntab.on .bnin{background:none;box-shadow:none}
/* ⚠️ 26px, down from the 34px the old full-width bar used. Jordan on device 2026-08-16:
   "icons all seem too big in both states". 34px was sized for a bar spanning the whole
   viewport; inside a floating pill the same glyph reads as oversized because it now
   sits against a much smaller surface. The tap TARGET is unaffected — the whole .bntab
   is the hit area (~64px wide), and the icon is only the visual mark inside it. */
.bntab .bnic{line-height:1;display:flex;align-items:center;justify-content:center;height:26px;flex-shrink:0}
.bnimg{width:26px;height:26px;object-fit:contain;display:block;opacity:.42;transform:scale(.96);transition:opacity .2s,transform .18s cubic-bezier(.22,1,.36,1),filter .2s}
/* The ACTIVE mark. The icons are already near-white renders, so full opacity, a
   restrained scale lift, and the cream label provide enough separation. Keep only
   a small grounding shadow here: an outward halo made the nav compete with content. */
.bntab.on .bnimg{opacity:1;transform:scale(1.02);
  filter:brightness(1.04) drop-shadow(0 1px 2px rgba(0,0,0,.42))}
.bntab:active .bnimg{transform:scale(.86)}
.bntab:focus-visible{outline:2px solid var(--g300);outline-offset:-2px}

/* ── LABEL ROW (the latched reveal) ───────────────────────────────────────────
   ⚠️ 11px (--t-micro) is THE FLOOR from DESIGN.md and is deliberate here: this app is
   read at arm's length in sunlight with a glove on. Revolut's labels are smaller; that
   is the right call for a banking app held close indoors and the WRONG one here.
   Do not shrink these to "match the reference".
   The row animates on max-height + opacity + transform. max-height is a layout
   property, but it is animating from 0 to a KNOWN constant on a 5-item row — the
   alternative (JS height measurement) is worse, and the eye tracks the opacity. */
/* ⚠️ COMPACT IS THE ONLY STATE (Jordan, 2026-08-16): "the nav bar should as standard be
   the smaller one without text". The labels are collapsed unconditionally rather than
   deleted from the DOM — every .bntab already carries an aria-label so screen readers
   are unaffected either way, and keeping the spans means the expanded state is a
   one-block revert (restore max-height:16px / opacity:1 / translateY(0)).
   ⚠️ ONE .bnlbl block, deliberately. An earlier edit here left TWO competing blocks
   relying on source order — precisely the drift [[clubhaus-single-source-principle]]
   exists to prevent. The collapsed values live here and nowhere else.
   ⚠️ The `.nav-compact` class is kept as a NO-OP alias further down so any JS or test
   still toggling it cannot break; the styling no longer depends on it. */
.bnlbl{
  font-size:var(--t-micro);
  font-weight:600;
  line-height:1.05;
  letter-spacing:.01em;
  max-height:0;
  opacity:0;
  overflow:hidden;
  transform:translateY(-4px);
  transition:max-height .24s cubic-bezier(.22,1,.36,1),
             opacity .16s ease-out,
             transform .24s cubic-bezier(.22,1,.36,1);
  /* Never wrap or ellipsise: a clipped label is worse than no label. Keeps the five
     names on one line each down to 320px. */
  white-space:nowrap;
}
/* ⚠️ The icons SHRINK in the compact state — confirmed against the real Revolut app by
   Jordan, 2026-08-16. An earlier version here kept them at a fixed size on the theory
   that a moving glyph reads as cheap; that was inferred from screenshots and is wrong.
   Scaling down is what sells the collapse as one continuous object getting out of the
   way, rather than a bar that merely loses a row of text.
   ⚠️ Scale via `transform`, NOT width/height — transform is composited, so the collapse
   stays smooth on a mid-range Android; animating the box would relayout all five tabs
   on every frame. The .bnic box keeps its height so the icons stay optically centred
   while shrinking, which is why nothing jumps. */
/* Compact: the lozenge tightens to a rounder shape around the icon alone. Padding is
   equal-ish on all four sides so it reads as a circle-ish badge, not a squashed bar. */
/* ⚠️ The pill CONTRACTS horizontally when compact, it does not just get shorter.
   Jordan on device 2026-08-16: "everything looks a little spread apart especially in
   the shrunk view". Cause: the tabs are `flex:1` and the pill kept its full width, so
   shrinking the icons left the SAME gaps between smaller marks — the row read as
   drifting apart. Pulling the side insets in gathers the icons back together, which is
   what the reference actually does.
   ⚠️ `left/right` are the width here (width is auto — see #botnav), so animating them
   IS animating layout. Accepted deliberately: it is a 2-property change on one element
   with no children reflowing independently, it only fires on a latch flip (not per
   scroll event), and the alternative (transform:scaleX) would squash the icons. */
/* ⚠️ NO-OP ALIAS. `.nav-compact` no longer changes anything — compact is the base state
   — but the class is still toggled by _navSetCompact() and referenced by tests, so it is
   kept rather than ripped out. Deleting it would be a wider change than this one needs. */
#botnav.nav-compact{}
/* ── HIDE ON SCROLL DOWN (Jordan, 2026-08-16) ────────────────────────────────────
   The pill slides out of the way while reading down a long list and returns the moment
   you scroll up — the standard mobile pattern.
   ⚠️ `transform`, not `display:none`. A composited slide costs no layout, keeps the
   return animation smooth, and — critically — leaves the pill in the layout so `.pg`
   bottom padding still reserves its space. Removing it from flow would let content jump
   under the FAB every time it hid.
   ⚠️ The Y distance must clear the pill AND its bottom margin, or a sliver stays on
   screen. --nav-tall (72) + the 4px constant + the safe-area inset covers it, plus a
   little slack for the shadow.
   ⚠️ `visibility:hidden` after the slide so the hidden pill cannot swallow a tap at the
   bottom of the screen. It is delayed by the transition duration, not applied instantly,
   or the slide would vanish mid-flight. */
#botnav.nav-hidden{
  /* ⚠️ Must clear the BALL too, not just the bar. The ball stands --ball-rise above the
     bar's top edge, so a slide sized only to the bar leaves it peeking over the bottom
     of the screen. --ball-rise is added for exactly that reason; do not trim it. */
  transform:translateY(calc(var(--nav-tall) + var(--ball-rise) + 24px + var(--nav-inset)));
  visibility:hidden;
  transition:transform .26s cubic-bezier(.22,1,.36,1),visibility 0s linear .26s;
}
/* ⚠️ left/right are no longer transitioned — the bar is full-bleed and those values are
   now constant 0. Animating a property that never changes is dead weight, and it was
   there for the floating pill's inset changes. */
#botnav{transition:transform .26s cubic-bezier(.22,1,.36,1),
                  visibility 0s linear 0s}
/* Respect the OS reduced-motion setting: state still changes, it just doesn't animate. */
@media(prefers-reduced-motion:reduce){
  #botnav,.bnlbl,.bntab,.bnimg{transition:none!important}
}
/* ⚠️ Anchored to the ICON, not to the pill: the pill's height changes between latch
   states, so a top-relative offset would drift when labels appear. Positioning against
   .bnic keeps it glued to the glyph in both states. 16px circle with 11px type — the
   type-scale floor pushed this up and it was MEASURED not to overflow (see DESIGN.md
   "Layout": a single digit still fits). */
.bnic{position:relative}
.bnbadge{position:absolute;top:-2px;right:-6px;background:linear-gradient(135deg,var(--gold),var(--goldl));color:var(--g900);border-radius:50%;min-width:16px;height:16px;padding:0 3px;font-size:var(--t-micro);font-weight:700;display:none;align-items:center;justify-content:center;z-index:2;box-shadow:0 0 0 2px rgba(15,41,20,.92)}

/* ── DESKTOP ── */
@media(min-width:768px){
  #sb{display:flex}
  #botnav{display:none!important}
  #chatfab{bottom:24px;right:24px}
  .pg{padding:28px 32px 40px}
  #pg-newround,#pg-profile,#pg-handicap,#pg-courses,#pg-players{max-width:760px}
  #pg-live,#pg-history,#pg-summary{max-width:920px}
  .sgrid{grid-template-columns:repeat(4,1fr)}
  .hgrid{grid-template-columns:repeat(9,1fr)}
  .moverlay{align-items:center;padding:20px}
  .modal{border-radius:20px;max-width:520px;padding:24px 24px 32px}
  @keyframes sheetUp{from{opacity:0;transform:translateY(16px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}
  .mhandle{display:none}
  /* ⚠️ Drag-to-dismiss is a TOUCH gesture on a bottom sheet. Here the modal is CENTRED
     (align-items:center) and animates as a scale+fade, so translating it down on drag
     would be wrong. app.js also gates the gesture on matchMedia, but neutralising the
     visual here means a stale class can never leave a desktop modal transformed. */
  .modal.dragging,.modal.snapback,.modal.dragout{transform:none;transition:none}
  #chatpanel{width:360px;left:auto;top:0;border-left:1px solid rgba(39,107,48,.15)}
  .chatheader{padding-top:20px}
}

@media(min-width:1280px){
  #sb{width:248px}
  .pg{padding-top:36px;padding-left:40px;padding-right:40px}
}

@media(prefers-reduced-motion:reduce){
  .blob,.sbicon,.aicon,.ldot{animation:none!important}
  .pg{animation:none}
  .btn,.ni,.atab,.sc,.profile-row,.profile-stat,.moverlay,.modal,#chatpanel,#chatfab{
    transition:none!important
  }
}

/* Hides the chat FAB while a sheet that must not be interrupted is open (chat itself).
   ⚠️ Both this and the mobile rule below use !important, so ORDER does not decide it —
   SPECIFICITY does: `#chatfab.fabhide` (id + class) beats `#chatfab` (id alone), so
   this wins wherever it sits. An inline `display:none` would NOT have won. */
#chatfab.fabhide{display:none!important}
/* ⚠️ The FAB's resting `bottom` reserves room for the floating pill (--nav-tall + inset
   + 22px ≈ 128px). On a FULL-SCREEN sheet there IS no pill — live scoring is the case
   Jordan raised (2026-08-16) — so that reservation becomes dead space and the button
   floats oddly high. Here it only needs to clear the home indicator.
   ⚠️ Class-based, applied by _fabOverNav(), NOT an inline style: the mobile media query
   sets `#chatfab{display:flex!important}` and this file has already been bitten once by
   an inline style losing to it (see _fabOverSheet). A class also keeps `bottom` in ONE
   place per state instead of duplicating the calc in JS. */
#chatfab.fabnonav{bottom:calc(22px + env(safe-area-inset-bottom))}
/* ⚠️ The FAB drops WITH the nav pill when it hides on scroll (2026-08-16). Without this
   the pill slides away and the FAB stays floating at the pill's old offset, which reads
   as a button hanging in mid-air over the content.
   ⚠️ `transform`, matching the pill — composited, and it leaves `bottom` untouched so
   the fabnonav / resting values stay the single source for the position. */
#chatfab.fabnavhidden{transform:translateY(calc(var(--nav-tall) + 24px + var(--nav-inset)));visibility:hidden;transition:transform .26s cubic-bezier(.22,1,.36,1),visibility 0s linear .26s}
/* ⚠️ The press feedback must COMPOSE with the hide, not replace it. `transform` is a
   single property, so a bare `scale(.92)` on :active would cancel the translate and
   snap a hidden FAB back on screen. Ordered translate-then-scale so the button scales
   in place wherever it currently sits. */
#chatfab.fabnavhidden:active{transform:translateY(calc(var(--nav-tall) + 24px + var(--nav-inset))) scale(.92)}
#chatfab{transition:transform .26s cubic-bezier(.22,1,.36,1),visibility 0s linear 0s}

/* ── MOBILE ── */
@media(max-width:767px){
  #sb{display:none!important}
  #botnav{display:flex!important}
  #chatfab{display:flex!important}
  .pgtitle{font-size:var(--t-xl)}
  .g2,.g3{display:block}
}
@keyframes spin{to{transform:rotate(360deg)}}

/* ── FIRST-RUN CHECKLIST (dashboard card) ─────────────────────────────────── */
.setup-row{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:var(--r-sm);background:rgba(255,255,255,.03);margin-bottom:6px;cursor:pointer;-webkit-tap-highlight-color:transparent}
.setup-row:last-child{margin-bottom:0}
.setup-row.done{cursor:default;background:transparent;opacity:.62}
.setup-lbl{font-size:var(--t-sm);color:var(--cream);line-height:1.3}
.setup-row.done .setup-lbl{text-decoration:line-through;text-decoration-color:rgba(255,255,255,.35);color:var(--g200)}
.setup-tick{width:19px;height:19px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;border:1.5px solid rgba(92,181,102,.4);background:transparent}
.setup-row.done .setup-tick{background:var(--g300);border-color:var(--g300);animation:setupPop .32s cubic-bezier(.16,1,.3,1)}
@keyframes setupPop{0%{transform:scale(.4)}60%{transform:scale(1.15)}100%{transform:scale(1)}}
@media(prefers-reduced-motion:reduce){.setup-row.done .setup-tick{animation:none}}

/* ── WELCOME TOUR ──────────────────────────────────────────────────────────
   The tour's visuals are MOCKUPS built from these classes, not screenshots.
   Deliberate: a PNG of the app goes stale the moment the UI changes (and this
   UI changes constantly), costs KB on a file that is `no-store`, and can't
   animate. These are built from the real tokens — --sc-*, --g*, --fd — so a
   palette retune carries into the tour for free and it can never show a
   version of the app that no longer exists.

   ⚠️ Every animation here is driven by a `.on` class added when the slide
   becomes current, NOT by `animation` on the base class. Slides are re-entered
   by swiping back, and a CSS animation only replays if the element is
   re-created or the class is re-added. See _tourPlay().

   ⚠️ prefers-reduced-motion is honoured at the bottom of this block: all the
   motion is decorative and the tour must still read as static slides. */
.tour-stage{position:relative;height:180px;margin:2px 0 4px;display:flex;align-items:center;justify-content:center;overflow:hidden}
/* Phone bezel the mockups sit in. Gives the flat chips a sense of being "the
   app" without shipping a device photo. */
.tour-phone{width:186px;height:172px;border-radius:20px;background:linear-gradient(170deg,#0d2312,#081a0c);border:1px solid rgba(92,181,102,.22);box-shadow:0 12px 30px -12px rgba(0,0,0,.7),inset 0 1px 0 rgba(255,255,255,.05);padding:12px 12px 0;position:relative;overflow:hidden}
.tour-phone:before{content:'';position:absolute;top:6px;left:50%;transform:translateX(-50%);width:38px;height:3px;border-radius:2px;background:rgba(255,255,255,.13)}
.tour-cap{font-size:var(--t-micro);letter-spacing:.08em;text-transform:uppercase;color:var(--g300);opacity:.7;margin-bottom:7px;font-weight:600}

/* Slide 1 — a scorecard filling itself in, hole by hole. */
.tour-holes{display:grid;grid-template-columns:repeat(5,1fr);gap:4px}
.tour-hole{height:26px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:var(--t-sm);font-weight:800;font-variant-numeric:tabular-nums;background:var(--sc-empty-bg);color:var(--sc-empty-fg);box-shadow:none;transform:scale(.9);opacity:.5}
.tour-hnum{font-size:var(--t-micro);color:rgba(255,255,255,.3);text-align:center;margin-bottom:3px;font-weight:600}
.on .tour-hole.f{animation:tourChip .34s cubic-bezier(.16,1,.3,1) forwards}
@keyframes tourChip{from{transform:scale(.6);opacity:0}60%{transform:scale(1.08)}to{transform:scale(1);opacity:1}}
.tour-tot{margin-top:9px;display:flex;justify-content:space-between;align-items:baseline;padding-top:8px;border-top:1px solid rgba(92,181,102,.14)}
.tour-tot b{font-family:var(--fd);font-size:var(--t-lg);color:var(--cream);font-variant-numeric:tabular-nums}

/* Slide 2 — the handicap index counting down as rounds land. */
.tour-hcp{text-align:center;padding-top:6px}
.tour-hcpnum{font-family:var(--fd);font-size:46px;font-weight:700;color:var(--g200);line-height:1;font-variant-numeric:tabular-nums;text-shadow:0 0 24px rgba(92,181,102,.28)}
.tour-spark{display:flex;align-items:flex-end;justify-content:center;gap:5px;height:46px;margin-top:12px}
.tour-bar{width:12px;border-radius:3px 3px 0 0;background:linear-gradient(180deg,var(--g300),var(--g500));transform-origin:bottom;transform:scaleY(0)}
.on .tour-bar{animation:tourBar .5s cubic-bezier(.16,1,.3,1) forwards}
@keyframes tourBar{to{transform:scaleY(1)}}

/* Slide 3 — real badge coins from /badges/, spinning in. These are the actual
   embossed art the app already caches, so this slide shows the true reward. */
.tour-coins{display:flex;justify-content:center;gap:12px;padding-top:4px}
/* ⚠️ Fixed width + nowrap: "On The Card" wrapped to two lines at 52px and left the
   row of three ragged. Caught by rendering it, not by reading it. */
.tour-coin{width:54px;text-align:center;opacity:0;transform:scale(.5) rotate(-25deg)}
.tour-coin img{width:50px;height:50px;display:block;margin:0 auto;filter:drop-shadow(0 5px 10px rgba(0,0,0,.5))}
.tour-coin span{display:block;font-size:var(--t-micro);color:var(--g300);margin-top:6px;line-height:1.2;font-weight:600;white-space:nowrap;letter-spacing:-.01em}
.on .tour-coin{animation:tourCoin .52s cubic-bezier(.16,1,.3,1) forwards}
@keyframes tourCoin{to{opacity:1;transform:scale(1) rotate(0)}}
.tour-lock{position:absolute;inset:auto 0 14px;text-align:center;font-size:var(--t-micro);color:var(--g300);opacity:0}
.on .tour-lock{animation:tourFade .4s ease .9s forwards}
@keyframes tourFade{to{opacity:.75}}

/* Slide 4 — the leaderboard, rows sliding in, your row highlighted. */
.tour-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:var(--r-sm);margin-bottom:5px;background:rgba(255,255,255,.035);opacity:0;transform:translateX(-12px)}
.on .tour-row{animation:tourRow .42s cubic-bezier(.16,1,.3,1) forwards}
@keyframes tourRow{to{opacity:1;transform:translateX(0)}}
.tour-row.me{background:linear-gradient(135deg,rgba(201,168,76,.16),rgba(201,168,76,.04));border:1px solid rgba(201,168,76,.34)}
.tour-av{width:20px;height:20px;border-radius:50%;background:linear-gradient(135deg,var(--g500),var(--g400));display:flex;align-items:center;justify-content:center;font-size:var(--t-micro);font-weight:800;color:#0f2914;flex-shrink:0}
.tour-nm{flex:1;font-size:var(--t-micro);color:var(--g100);text-align:left;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
.tour-sc{font-size:var(--t-micro);font-weight:800;color:var(--cream);font-variant-numeric:tabular-nums}

/* Slide transition. translate3d keeps it on the compositor — these run on
   low-end Androids on a phone signal. */
.tour-slide{opacity:0;transform:translate3d(var(--tdir,24px),0,0);transition:opacity .3s ease,transform .34s cubic-bezier(.16,1,.3,1)}
.tour-slide.on{opacity:1;transform:translate3d(0,0,0)}
.tour-dot{height:6px;border-radius:3px;background:rgba(92,181,102,.25);transition:width .28s cubic-bezier(.16,1,.3,1),background .28s ease;width:6px}
.tour-dot.on{width:20px;background:var(--g300)}
@media(prefers-reduced-motion:reduce){
  .tour-slide{transition:opacity .2s ease;transform:none}
  .on .tour-hole.f,.on .tour-bar,.on .tour-coin,.on .tour-row,.on .tour-lock{animation:none}
  .tour-hole.f{opacity:1;transform:scale(1)}
  .tour-bar{transform:scaleY(1)}
  .tour-coin{opacity:1;transform:none}
  .tour-row{opacity:1;transform:none}
  .tour-lock{opacity:.75}
}

@keyframes sumCardIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}
@keyframes awardPop{0%{opacity:0;transform:scale(.4) rotate(-8deg)}65%{transform:scale(1.12) rotate(2deg)}100%{opacity:1;transform:scale(1)}}
@keyframes confettiFall{0%{opacity:1;transform:translateY(0) rotate(0deg)}100%{opacity:0;transform:translateY(340px) rotate(720deg)}}
@keyframes winnerPulse{0%,100%{box-shadow:0 0 0 0 rgba(201,168,76,0)}50%{box-shadow:0 0 22px 5px rgba(201,168,76,.13)}}
@keyframes cardSlideIn{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:scale(1)}}
.rsum-card{opacity:0;animation:sumCardIn .42s cubic-bezier(.16,1,.3,1) forwards;background:rgba(255,255,255,.04);border:1px solid rgba(39,107,48,.2);border-radius:var(--r-md);padding:14px 16px;margin-bottom:8px}
.rsum-win{background:linear-gradient(135deg,rgba(201,168,76,.11),rgba(201,168,76,.03))!important;border-color:rgba(201,168,76,.38)!important;animation:sumCardIn .42s cubic-bezier(.16,1,.3,1) forwards,winnerPulse 2.2s ease .9s infinite!important}
.rsum-awd{display:inline-flex;align-items:center;gap:4px;background:var(--surf-3);border:1px solid rgba(92,181,102,.16);border-radius:20px;padding:3px 9px;font-size:var(--t-micro);color:var(--g200);margin:4px 4px 0 0;opacity:0;animation:awardPop .32s ease forwards}
.rsum-confetti{position:absolute;pointer-events:none;border-radius:2px;animation:confettiFall 2.4s ease forwards}
/* ── AWARD CARD CAROUSEL ── */
.award-card{width:min(86vw,420px);height:min(100%,640px);max-height:100%;border-radius:24px;overflow:hidden;display:flex;flex-direction:column;background:var(--surf-2);border:1px solid rgba(255,255,255,.07);box-shadow:0 24px 64px rgba(0,0,0,.75),inset 0 1px 0 rgba(255,255,255,.06);flex-shrink:0;position:relative;animation:cardSlideIn .38s cubic-bezier(.16,1,.3,1) forwards}
.award-card::before{content:'';position:absolute;inset:0;background:linear-gradient(135deg,rgba(255,255,255,.04) 0%,transparent 35%);pointer-events:none;z-index:10;border-radius:24px}
.card-badge{height:48px;flex-shrink:0;display:flex;align-items:center;justify-content:center;gap:7px;z-index:20;position:relative}
.card-badge svg{width:17px;height:17px;flex-shrink:0}
.card-badge span{font-size:var(--t-sm);font-weight:800;letter-spacing:2.6px;text-transform:uppercase}
.cat-positive .card-badge span{color:#c9a84c}
.cat-funny    .card-badge span{color:#e05a3a}
.cat-group    .card-badge span{color:#5cb566}
.cat-rare     .card-badge span{color:#9b6fd4}
.card-art{width:100%;flex:1 1 auto;min-height:0;position:relative;overflow:hidden}
.card-art svg,.card-art img{position:absolute;inset:0;width:100%;height:100%;display:block;object-fit:cover}
.card-art::after{content:'';position:absolute;bottom:0;left:0;right:0;height:88px;background:linear-gradient(to bottom,transparent 0%,#0c1e10 90%);pointer-events:none;z-index:2}
.card-body{flex:0 0 auto;display:flex;flex-direction:column;justify-content:flex-end;padding:0 22px 22px;position:relative;z-index:5}
.card-title{font-family:'Fraunces',Georgia,serif;font-size:var(--t-xl);font-weight:900;color:#f5f0e8;line-height:1.12;letter-spacing:-.4px;margin-bottom:6px;text-align:center}
.card-desc{font-size:var(--t-sm);color:rgba(245,240,232,.45);line-height:1.45;text-align:center;margin-bottom:16px}
.card-player{display:flex;align-items:center;gap:11px;border-top:1px solid rgba(255,255,255,.07);padding-top:15px}
.avatar{width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:var(--t-sm);font-weight:800;flex-shrink:0;letter-spacing:.3px}
.av-green{background:linear-gradient(135deg,#276b30,#163d1c);color:#8ed494;border:1.5px solid rgba(92,181,102,.25)}
.av-gold{background:linear-gradient(135deg,#6b5420,#3d2e0d);color:#e8c96a;border:1.5px solid rgba(201,168,76,.25)}
.av-purple{background:linear-gradient(135deg,#4a2870,#28154a);color:#c9a0e8;border:1.5px solid rgba(155,111,212,.25)}
.av-red{background:linear-gradient(135deg,#6b2010,#3d0e08);color:#e8967a;border:1.5px solid rgba(224,90,58,.25)}
.player-name{font-size:var(--t-sm);font-weight:700;color:#f5f0e8;text-transform:uppercase;letter-spacing:.6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.player-stat{font-size:var(--t-micro);color:rgba(245,240,232,.42);letter-spacing:.4px;margin-top:2px;font-weight:500;white-space:nowrap}

/* Withdrawal stays available without competing with score controls. */
.player-withdraw-btn{min-height:44px;padding:8px 10px;background:transparent;border:0;border-radius:var(--r-sm);color:var(--g300);font:600 var(--t-micro)/1.4 var(--fb);cursor:pointer}
.player-withdraw-btn:hover{background:var(--surf-3)}
.withdrawn-label{color:var(--sand);font-size:var(--t-micro);line-height:1.5}
.hbh-player{flex-wrap:wrap}
.hbh-withdraw-action{width:100%;text-align:right}
.hbh-player.withdrawn{opacity:.8}
