/* ============================================================================
   ONLAST - THE WHOLE STYLESHEET.
   Lifted out of index.html on 15 Aug 2026, unchanged, one block to one file.

   WHY. index.html had reached 494,103 bytes against a 500,000 ceiling that had
   already been raised twice, and tools/check-load-shape.js says in its own words
   that a THIRD raise before a split is the finding rather than the fix. The CSS
   was 107,849 of those bytes - 21% - and it sat in a single <style> block with
   no ordering semantics beyond "before the body paints", which makes it the one
   part of this file that can be moved mechanically.

   WHY NOT ALSO THE JAVASCRIPT, YET. That is 77% and it is where every ordering
   assumption lives. Doing it five days before a season with 200 pounds a head on
   it is the wrong trade; this half buys 114KB of headroom and can be verified by
   looking at the app.

   HOW IT FAILS, IF IT FAILS. Visibly and immediately - an unstyled app - not
   silently. That is the reason this half is safe to do now. The service worker
   precaches it, so an installed app never waits for it twice.

   NOTHING IN HERE WAS EDITED IN THE MOVE. If a rule looks wrong, it looked
   wrong in index.html yesterday too.
   ============================================================================ */
*{box-sizing:border-box}
  @keyframes lmsPulse{0%{box-shadow:0 0 0 0 var(--glow-1)}70%{box-shadow:0 0 0 7px var(--glow-0)}100%{box-shadow:0 0 0 0 var(--glow-0)}}
  @keyframes lmsUp{from{transform:translateY(10px);opacity:.4}to{transform:none;opacity:1}}
  /* ===== Onlast brand identity ===== */
  :root{--onlast-lime:#c9ff3a;--onlast-lime-bright:#d6ff5e;--onlast-amber:#f3c969;--onlast-red:#ff6a6a;--onlast-white:#eafff0;--onlast-dim:#2b3a20;--onlast-night:#0a1409;--onlast-black:#080d07;--onlast-line:#1e2a1c;--wm-on:#c8d6be;--wm-last:#d6ff5e}
  body[data-theme="light"]{--wm-on:#4a5c3e;--wm-last:#0a1409}
  @media(prefers-color-scheme:light){body:not([data-theme="dark"]):not([data-theme="light"]){--wm-on:#4a5c3e;--wm-last:#0a1409}}
  .onlast-mark{display:block}
  .lmstile .onlast-mark{width:34px;height:34px}
  .biglogo .onlast-mark{width:52px;height:52px}
  .wm-on{font-weight:600;color:var(--wm-on)}
  .wm-last{font-weight:900;color:var(--wm-last)}
  .auth .descriptor{color:var(--wm-last);font-weight:700;letter-spacing:.26em}
  .auth .bigsub2{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.2em;color:var(--text-4);margin-top:6px}
  @keyframes onlastPulse{0%,100%{opacity:.20}50%{opacity:.55}}
  .biglogo .onlast-lampglow{animation:onlastPulse 3s ease-in-out infinite}
  @keyframes onlastIgnite{0%{fill:var(--onlast-dim)}45%{fill:var(--onlast-lime-bright)}100%{fill:var(--onlast-dim)}}
  .onlast-ignite .onlast-lamp-1{animation:onlastIgnite .7s ease 0s 1}
  .onlast-ignite .onlast-lamp-2{animation:onlastIgnite .7s ease .12s 1}
  .onlast-ignite .onlast-lamp-3{animation:onlastIgnite .7s ease .24s 1}
  /* ---- THE LOADING INDICATOR IS THE FLOODLIGHT (BJB-141) -----------------
     it was asked twice whether the loading state could use the Onlast mark
     rather than a generic spinner, with one condition: "performance comes
     first". That condition is the whole design, because the obvious answer -
     loop the existing brand mark - is the wrong one.

     WHY NOT THE REAL MARK. ONLAST_MARK carries two feGaussianBlur filters
     (stdDeviation 52 and 34) and five gradients. An SVG filter is repainted
     every frame it changes, so animating that mark in a loop would run a
     blur pass at 60fps on a mid-range phone, to say "please wait". The
     existing ignite animation is fine precisely because it runs ONCE.

     WHAT THIS IS INSTEAD. The lamp bar alone: three circles and an outline.
     No gradients, no filters, no blur. It animates OPACITY only, which the
     compositor handles without repainting, so the cost is roughly a
     scroll bar's. It is still unmistakably the floodlight - the lamps light
     left to right, the same order and the same rhythm as the real ignite.

     currentColor, so it takes the accent from whichever theme is in force
     without a second rule. Reduced motion gets three steady lamps rather
     than none, because a loading indicator that does not move is not a
     loading indicator. */
  /* IT DOES NOT APPEAR FOR A FAST LOAD. BJB-141 named this risk before the
     thing was built: "under about 200ms nothing should appear at all - a
     flash of any loader is worse than a moment of stillness", and noted the
     old grey card already had that fault. Making the loader prettier makes a
     flash MORE noticeable, not less, so the fix ships with it.

     A zero-length animation with a 220ms delay is the whole mechanism: the
     element is built and thrown away entirely at opacity 0 if the content
     beats the delay, so a fast tab switch shows nothing at all. No timer, no
     JavaScript, nothing to clear up. The reduced-motion rule at the top of
     this file overrides animation DURATION, not delay, so the debounce
     survives there too - which is right, because a debounce is not motion. */
  @keyframes lmsShow{to{opacity:1}}
  .lmsload{display:inline-flex;align-items:center;gap:11px;color:var(--accent-fg);min-height:24px;
    opacity:0;animation:lmsShow 0s linear .22s forwards}
  .lmsload svg{width:62px;height:16px;display:block;flex:none}
  .lmsload-txt{font-size:13.5px;color:var(--text-3)}
  @keyframes lmsLamp{0%,100%{opacity:.16}45%{opacity:1}}
  .lmsload .l1{animation:lmsLamp 1.15s ease-in-out 0s infinite}
  .lmsload .l2{animation:lmsLamp 1.15s ease-in-out .16s infinite}
  .lmsload .l3{animation:lmsLamp 1.15s ease-in-out .32s infinite}
  @media(prefers-reduced-motion:reduce){
    .lmsload .l1,.lmsload .l2,.lmsload .l3{animation:none;opacity:.7}
  }
  .onlast-winmark{width:76px;height:76px;margin:0 auto 8px}
  .onlast-winmark .onlast-mark{width:76px;height:76px}
  .clubbtn:active{transform:scale(.97)}
  button:active{opacity:.85}
  @media(prefers-reduced-motion:reduce){
    *,*::before,*::after{animation-duration:.001ms !important;animation-iteration-count:1 !important;transition-duration:.001ms !important;scroll-behavior:auto !important}
  }
  /* ==========================================================================
     THE PALETTE. Every colour in Onlast comes from here, twice: once for dark
     and once for light. Each theme is written in TWO places - a
     prefers-color-scheme block for people who have never touched the toggle,
     and a body[data-theme] block for people who have. The two must stay
     byte-identical; tools/check-light-mode.js fails if they drift.

     LIGHT MODE PASS, 14 Aug 2026. Product decision: "I use dark mode pretty much all
     the time, so I've probably neglected the light-mode experience... it
     currently feels a little too harsh and bright on the eyes."

     He was right, and it was three separate faults, not one:

     · THE NEON. --accent was #c9ff3a in BOTH themes. On a dark page that is a
       floodlight; on a white one it vibrates. Light mode now uses #9fd327 -
       the same hue, a third less light. Dark text on it still reads at 9.8:1.

     · THE GLOW. Eighteen box-shadows had the lime hardcoded as
       rgba(201,255,58,...). A neon halo is the whole point on a dark page and
       a dirty smudge on a light one, and being literals they could not
       differ. They are now --glow-0..6, and light mode maps them to a soft
       olive at a fifth of the strength. THE DARK VALUES ARE THE ORIGINAL
       LITERALS, byte for byte - dark mode renders exactly as it did.

     · THE PAPER. The background was #f5f9ee -> #e7eedb with pure white cards.
       Near-white on near-white gives no hierarchy and a lot of glare. The page
       is now a deeper, greyer sage and the cards have come off pure white, so
       a card lifts off the page instead of merging into it.

     Contrast was checked, not eyeballed: body text 11:1, muted text 6.5:1,
     links 6.3:1, dark-on-accent 9.8:1. All are asserted in the harness. */
  :root{--bg-grad:radial-gradient(140% 100% at 50% -10%,#10231a 0%,#0a1410 45%,#070b08 100%);--text:#e9f2e9;--text-2:#9aa89c;--text-3:#8b998d;--text-4:#7d8d7f;--text-5:#718473;--card:#0e1a11;--panel:#0b150d;--border:#1d2c1f;--border-2:#1a281c;--border-3:#243325;--chip-bg:#0a110c;--chip-border:#16201a;--row-line2:#ffffff08;--track:#1c2a1e;--muted-dot:#33442f;--accent:#c9ff3a;--gold:#ffcd00;--accent-fg:#c9ff3a;--accent-soft:#aebf8a;--accent-soft2:#6a8841;--accent-card:linear-gradient(160deg,#16240f,#0e1a0c);--accent-sel:linear-gradient(160deg,#1b2c10,#0e1a0c);--accent-row:linear-gradient(90deg,rgba(201,255,58,.08),transparent);--danger:#ff5a5a;--danger-text:#ff8a8a;--danger-bg:rgba(255,90,90,.13);--danger-border:rgba(255,90,90,.3);--danger-zone-bg:rgba(255,90,90,.05);--warn-text:#ffce6a;--warn-bg:rgba(255,206,106,.12);--warn-border:rgba(255,206,106,.3);--cyan:#5fd4e0;--glow-0:rgba(201,255,58,0);--glow-1:rgba(201,255,58,.5);--glow-2:rgba(201,255,58,.55);--glow-3:rgba(201,255,58,.6);--glow-4:rgba(201,255,58,.7);--glow-5:rgba(201,255,58,.8);--glow-6:rgba(201,255,58,.85)}
  /* THE LIME IS THE BRAND, IN BOTH THEMES (BJB-164 item 4, 16 Aug 2026).
     Light mode used to desaturate --accent from the brand's #c9ff3a to #9fd327,
     a duller yellow-green - so two players in the same league saw two different
     products, and the one on light mode saw the weaker one. The desaturation
     was solving a problem a different variable already solves: --accent is a
     FILL (buttons, the IN pill, the active tab) and always carries dark text on
     it, exactly as in dark mode, while accent-coloured TEXT on a pale
     background uses --accent-fg, which stays dark at #40691a. Separating those
     two jobs is what makes the pure lime safe here.
     BOTH light blocks are changed: the system-preference one and the explicit
     one. Changing a single light-mode variable in one place has been wrong
     every time - a player who has never touched the toggle gets the first.

     WHY NOT THE PURE #c9ff3a. That was tried first and tools/check-light-mode.js
     refused it, correctly. An earlier decision requires the light accent to be
     materially less luminous than the dark-mode neon, and the reasoning holds:
     #c9ff3a GLOWS against near-black, and on a pale sage background the same
     value is simply acid. So this is a re-tune, not a swap. #abe12d is a third
     of the way back from the old #9fd327 towards the brand - luminance 0.63
     against a ceiling of 0.67, saturation 0.75 against the old 0.69 - which is
     as close to the identity as the softness rule allows. Two decisions, both
     kept, rather than one overwritten. */
  @media (prefers-color-scheme:light){:root{--bg-grad:radial-gradient(140% 110% at 50% -20%,#eef1e6 0%,#e4ead6 52%,#d9e1cc 100%);--text:#18241b;--text-2:#48563f;--text-3:#54614b;--text-4:#5d6a51;--text-5:#67735a;--card:#fcfdf8;--panel:#f5f8ec;--border:#d5dcc6;--border-2:#e0e6d2;--border-3:#c5cfb2;--chip-bg:#eef1e4;--chip-border:#dee4d0;--row-line2:#eef1e5;--track:#d4dcc4;--muted-dot:#bfc9ab;--accent:#abe12d;--gold:#8a6100;--accent-fg:#40691a;--accent-soft:#55702a;--accent-soft2:#5f7538;--accent-card:linear-gradient(160deg,#eaf6c9,#dfeeb2);--accent-sel:linear-gradient(160deg,#eaf6c9,#dfeeb2);--accent-row:linear-gradient(90deg,rgba(159,211,39,.2),transparent);--danger:#c62b34;--danger-text:#9c2617;--danger-bg:#f9e5e1;--danger-border:#eec3ba;--danger-zone-bg:#f8eae7;--warn-text:#845b00;--warn-bg:#f8eecf;--warn-border:#e6d199;--cyan:#19757f;--glow-0:rgba(88,124,26,0);--glow-1:rgba(88,124,26,.10);--glow-2:rgba(88,124,26,.12);--glow-3:rgba(88,124,26,.14);--glow-4:rgba(88,124,26,.17);--glow-5:rgba(88,124,26,.20);--glow-6:rgba(88,124,26,.22)}}
  body[data-theme="dark"]{--bg-grad:radial-gradient(140% 100% at 50% -10%,#10231a 0%,#0a1410 45%,#070b08 100%);--text:#e9f2e9;--text-2:#9aa89c;--text-3:#8b998d;--text-4:#7d8d7f;--text-5:#718473;--card:#0e1a11;--panel:#0b150d;--border:#1d2c1f;--border-2:#1a281c;--border-3:#243325;--chip-bg:#0a110c;--chip-border:#16201a;--row-line2:#ffffff08;--track:#1c2a1e;--muted-dot:#33442f;--accent:#c9ff3a;--gold:#ffcd00;--accent-fg:#c9ff3a;--accent-soft:#aebf8a;--accent-soft2:#6a8841;--accent-card:linear-gradient(160deg,#16240f,#0e1a0c);--accent-sel:linear-gradient(160deg,#1b2c10,#0e1a0c);--accent-row:linear-gradient(90deg,rgba(201,255,58,.08),transparent);--danger:#ff5a5a;--danger-text:#ff8a8a;--danger-bg:rgba(255,90,90,.13);--danger-border:rgba(255,90,90,.3);--danger-zone-bg:rgba(255,90,90,.05);--warn-text:#ffce6a;--warn-bg:rgba(255,206,106,.12);--warn-border:rgba(255,206,106,.3);--cyan:#5fd4e0;--glow-0:rgba(201,255,58,0);--glow-1:rgba(201,255,58,.5);--glow-2:rgba(201,255,58,.55);--glow-3:rgba(201,255,58,.6);--glow-4:rgba(201,255,58,.7);--glow-5:rgba(201,255,58,.8);--glow-6:rgba(201,255,58,.85)}
  body[data-theme="light"]{--bg-grad:radial-gradient(140% 110% at 50% -20%,#eef1e6 0%,#e4ead6 52%,#d9e1cc 100%);--text:#18241b;--text-2:#48563f;--text-3:#54614b;--text-4:#5d6a51;--text-5:#67735a;--card:#fcfdf8;--panel:#f5f8ec;--border:#d5dcc6;--border-2:#e0e6d2;--border-3:#c5cfb2;--chip-bg:#eef1e4;--chip-border:#dee4d0;--row-line2:#eef1e5;--track:#d4dcc4;--muted-dot:#bfc9ab;--accent:#abe12d;--gold:#8a6100;--accent-fg:#40691a;--accent-soft:#55702a;--accent-soft2:#5f7538;--accent-card:linear-gradient(160deg,#eaf6c9,#dfeeb2);--accent-sel:linear-gradient(160deg,#eaf6c9,#dfeeb2);--accent-row:linear-gradient(90deg,rgba(159,211,39,.2),transparent);--danger:#c62b34;--danger-text:#9c2617;--danger-bg:#f9e5e1;--danger-border:#eec3ba;--danger-zone-bg:#f8eae7;--warn-text:#845b00;--warn-bg:#f8eecf;--warn-border:#e6d199;--cyan:#19757f;--glow-0:rgba(88,124,26,0);--glow-1:rgba(88,124,26,.10);--glow-2:rgba(88,124,26,.12);--glow-3:rgba(88,124,26,.14);--glow-4:rgba(88,124,26,.17);--glow-5:rgba(88,124,26,.20);--glow-6:rgba(88,124,26,.22)}

  /* --bg-solid is the flat colour BEHIND the gradient. body{background:<gradient>} sets
     background-color to transparent, and the gradient is sized to the root box - so on a
     phone the strip below it (the home-indicator safe area, and anything revealed by
     rubber-band scroll) fell through to the browser default, which is white. */
  :root{--bg-solid:#070b08}
  @media (prefers-color-scheme:light){:root{--bg-solid:#d9e1cc}}
  body[data-theme="dark"]{--bg-solid:#070b08}
  body[data-theme="light"]{--bg-solid:#d9e1cc}
  /* html cannot read a variable overridden on body, so the toggle also stamps
     data-theme on <html> and these two lines are what let the CANVAS follow it.
     Without them a light-system phone kept a light status bar, home-indicator
     strip and overscroll behind a dark app for ever. See applyTheme(). */
  html[data-theme="dark"]{--bg-solid:#070b08}
  html[data-theme="light"]{--bg-solid:#d9e1cc}
  html{background-color:var(--bg-solid)}
  /* BJB-326, 3 Sep 2026. corrective successor to BJB-277/1fda081.
     WHY THIS IS CSS, NOT ANOTHER visualViewport BRANCH. 1fda081's tabbarPin
     corrects on DISCRETE events - visualViewport resize/scroll, window
     resize, focusout, pageshow - each debounced 450ms for the diagnostic and
     applied on arrival. A touch-driven rubber-band overscroll is not a
     discrete event: it is a continuous, natively-composited gesture, and on
     iOS WebKit the elastic bounce drags the whole painted layer - `position:
     fixed` content included - as the touch moves, frame by frame, before any
     JS handler runs. No event-driven correction can out-run a compositor
     effect between the frames it is not listening to; it can only clean up
     after the gesture ends, by which point the lift has already been seen.
     So the fix has to stop the BOUNCE ITSELF, not react to its aftermath.
     `overscroll-behavior-y` is the standard, load-bearing property for
     exactly this: it stops the root scroller's overscroll from chaining
     past the document edge - which is the same mechanism both native
     pull-to-refresh and the elastic rubber-band ride on. Set on the SAME
     element the fixed bar's containing block already is (html and body,
     neither of which carries a transform - see check-fixed-nav.js section 6)
     so the guarantee that keeps the bar anchored at all is unweakened.
     `contain`, not `none`: it stops the overscroll from reaching the browser
     chrome (no native pull-to-refresh reload, no scroll chaining out of the
     document) while leaving ordinary in-page scrolling, modals and their own
     overflow untouched - the narrower of the two options for the guarantee
     actually needed here.
     NOT VERIFIED ON REAL iOS WebKit FROM THIS MACHINE - stated rather than
     hidden. This machine has Command Line Tools only, no Xcode, no
     Simulator.app (xcode-select -p confirms it), so neither a real device
     nor the iOS Simulator was available to reproduce the touch-driven bounce
     directly. This is the diagnosed, standard remedy for the documented
     mechanism above, not a guess - but the owner's dual-surface live check
     remains the actual Done gate, exactly as BJB-326 already states. */
  html,body{overscroll-behavior-y:contain}
  /* BJB-326 SECOND ATTEMPT, 3 Sep 2026 - STRUCTURAL, NOT A THIRD GUESS AT A
     CSS PROPERTY. The nav still lifts on real iOS PWA and iPhone
     Chrome with the overscroll-behavior-y line above live. That line asks the
     browser not to CHAIN the root scroller's overscroll past the document
     edge; it does not change WHO OWNS the scroll. On iOS WebKit the native
     rubber-band bounce and pull-to-refresh gesture are a property of the ROOT
     scroller specifically - <html>, the true document/viewport scroller -
     and asking that scroller to behave better is not the same as removing its
     ability to overscroll at all.
     So this removes the root's overscroll capability structurally: <html> is
     pinned to exactly the viewport height and never itself scrolls
     (overflow:hidden), and <body> becomes the actual scrolling surface -
     everything that used to make the DOCUMENT taller than the viewport now
     makes body's own internal scrollbox taller instead. There is no longer a
     root-level scroll position for the OS to pull past zero, so the
     browser-level bounce/pull-to-refresh gesture that used to drag every
     `position:fixed` layer with it - the tabbar included - has nothing left
     to act on at the level it operates at.
     WHY THE FIXED BAR IS UNAFFECTED, NOT JUST UNBOUNCED. `.tabbar` stays
     `position:fixed`, anchored to the (now permanently viewport-sized, never-
     scrolling) initial containing block - exactly the guarantee
     check-fixed-nav.js section 6 already pins (no transform/filter/etc on
     html or body). It does not move when body scrolls internally, for the
     same reason it never moved when the whole page scrolled before: fixed
     positioning was never anchored to body's content, only to the viewport -
     what changes is that the VIEWPORT itself can no longer be dragged.
     MODALS AND OVERLAYS ARE UNAFFECTED. `.modal-bg{position:fixed;inset:0}`
     anchors to the same never-scrolling root the tabbar does; nothing about
     where it opens or how it sizes reads from body's own scroll state.
     THE PRIOR overscroll-behavior-y LINE STAYS - it is additive and harmless
     (a root that cannot scroll has nothing left for that property to chain),
     not reverted separately per the standing instruction not to unwind a
     composed, inert delta on its own.
     NOT VERIFIED ON REAL iOS WebKit FROM THIS MACHINE, same standing
     constraint as the first attempt (no Xcode, no Simulator.app, no device).
     This is the standard app-shell scroll-ownership pattern used specifically
     to eliminate root-level rubber-band/pull-to-refresh, reasoned from how
     the bug actually operates - not a third guess at a property name.
     The owner's dual-surface live check remains the Done gate. */
  /* BJB-326 SUCCESSOR, 3 Sep 2026. The owner's device pass: the nav-lift CURE
     WORKS on both surfaces - no rollback - but the installed PWA gained a
     thick black bar BENEATH the nav that did not exist before <html> was
     capped and made non-scrolling. Chrome is unaffected; this is standalone-
     only, which points at exactly one thing: dvh in an installed iOS PWA.

     THE MECHANISM. Before this candidate, body had no explicit height cap
     (min-height:100dvh only), so a page taller than one screen - true of
     every real screen in this app - made body's own box, and therefore its
     own background, extend WELL PAST the viewport regardless of exactly
     where "the viewport" ended. Whatever gap existed between the dvh
     calculation and the true visible screen was invisibly absorbed by that
     natural overflow. Capping html AND body at exactly 100dvh removed that
     margin entirely - and dvh's relationship to the safe-area-inset-bottom
     strip in an installed iOS PWA specifically is exactly the kind of
     platform inconsistency this class of bug lives in: if the true visible
     screen is even a few px taller than what dvh reports there, that strip
     is now genuinely unpainted, showing whatever sits behind the WKWebView -
     black - rather than the app's own background it always showed before.

     THE FIX IS A FLOOR, NOT A DIFFERENT CAP. min-height:100vh on <html> only
     (the LARGE, chrome-hidden viewport dimension, which cannot be smaller
     than the true visible screen) guarantees html's painted background can
     never fall short, however dvh is interpreted here - overflow:hidden
     safely clips anything the floor adds beyond what is actually visible, so
     there is nothing MORE to scroll to and nothing extra becomes reachable.
     body's own height stays exactly 100dvh, unmoved - the scroll-owning box
     this whole structural change exists for is untouched, so normal
     scrolling, long-page reachability and the keyboard-dismiss guard are all
     exactly as they were. Chrome's <html> was never short of this floor to
     begin with (it has no standalone safe-area quirk to compensate for), so
     visually nothing there changes either.
     NOT VERIFIED ON REAL iOS WebKit FROM THIS MACHINE - same standing
     constraint as both prior BJB-326 candidates (no Xcode, no Simulator.app,
     no device). The owner's dual-surface pass remains the Done gate. */
  html{height:100vh;height:100dvh;min-height:100vh;overflow:hidden}
  body{margin:0;background-color:var(--bg-solid);background-image:var(--bg-grad);color:var(--text);height:100vh;height:100dvh;
    overflow-y:auto;-webkit-overflow-scrolling:touch;
    font-family:'Hanken Grotesk',system-ui,-apple-system,sans-serif;-webkit-text-size-adjust:100%;
    -webkit-tap-highlight-color:transparent;transition:background .25s ease,color .25s ease}
  a{color:var(--accent-fg)}
  .mono{font-family:'JetBrains Mono',ui-monospace,monospace}
  .saira{font-family:'Saira',sans-serif}

  /* header */
  header.top{padding:calc(26px + env(safe-area-inset-top,0px)) 16px 0;text-align:left}
  .hdrwrap{max-width:1060px;margin:0 auto;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}
  .lmsbrand{display:flex;align-items:center;gap:14px;min-width:0}
  /* without min-width:0 a flex child refuses to shrink below its content, so the
     subtitle could never ellipsis and the header overlapped instead of truncating */
  .lmsbrand>div{min-width:0}
  .lmstile{width:48px;height:48px;border-radius:14px;border:2px solid var(--accent);background:#0c160d;display:grid;place-items:center;box-shadow:0 0 18px -4px var(--glow-1);flex:none}
  .lmstile span{font-family:'JetBrains Mono',monospace;font-weight:700;font-size:14px;color:var(--accent)}
  /* THE BRAND COLUMN HAS min-width:0 SO THE LEAGUE NAME CAN ELLIPSIS - which
     means at 320px it collapses to 6px while the wordmark, being nowrap with
     visible overflow, spilled its 83px of glyphs rightwards UNDER the opaque
     user chip. Measured at 320px on an admin account: 14px of "ONLAST" visible,
     reading as a lone "O". It never showed as document overflow, so nothing
     caught it. Clip it to its own box like every other truncating thing here. */
  .wordmark{font-family:'Saira',sans-serif;font-weight:800;font-size:25px;letter-spacing:.04em;line-height:1;white-space:nowrap;text-transform:uppercase;overflow:hidden;text-overflow:clip}
  .subtitle{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.2em;color:var(--text-3);margin-top:5px}
  .userbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end}
  /* was an inline style, which outranked the phone rule below and let the switcher
     grow until it sat on top of the wordmark and the league name */
  .leaguesel{padding:7px 11px;font-size:16px;font-weight:700;border-radius:999px;max-width:160px}
  /* THE LEAGUE PICKER, IN ONLAST'S OWN CLOTHES. A native <select> hands the
     interaction to iOS and draws a grey system wheel over the app; its focus
     ring also outlived the interaction and sat under the buttons below it.
     A league is a destination, so this is a list of destinations: full width,
     one row each, the current one marked and inert. No field, so no ring to be
     clipped, and it looks the same on every platform. */
  /* A DROPDOWN, BECAUSE THE LIST IS THE ONLY THING ON THIS SHEET THAT GROWS
     WITHOUT LIMIT. (Product decision, 17 Aug: "if someone eventually belongs to 5, 10 or
     more leagues, that section is going to become cluttered and messy very
     quickly.") Closed it is one row whatever the number. Open it scrolls inside
     its own maximum height, so twenty leagues cost the same vertical space as
     three and the buttons underneath never move off the screen.
     max-height is in px rather than vh because this sits inside a modal that is
     already height-constrained; a vh here would fight the sheet's own scroll. */
  /* ---- CHAT: replying to one message, and who reacted ------------------- */
  /* The quote is deliberately quieter than the message it sits on: it is
     context, not content, and a quote as loud as the reply makes a thread read
     like everyone is shouting the same thing twice. */
  .cquote{display:flex;flex-direction:column;gap:2px;max-width:78%;margin:0 0 5px 42px;padding:7px 11px;
    border-left:2px solid var(--accent);border-radius:0 10px 10px 0;background:var(--panel);cursor:pointer}
  .chatmsg.mine .cquote{margin-left:auto;margin-right:0;border-left:0;border-right:2px solid var(--accent);
    border-radius:10px 0 0 10px;text-align:right}
  .cquote-n{font-family:'JetBrains Mono',monospace;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--accent-fg)}
  .cquote-b{font-size:12.5px;line-height:1.4;color:var(--text-3);overflow:hidden;text-overflow:ellipsis;
    display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
  @media(hover:hover){ .cquote:hover{border-color:var(--accent);background:var(--card)} }
  /* Arriving in the middle of a thread with no signal leaves you hunting for
     the message you were sent to, so it says which one it is. */
  @keyframes cflash{0%{background:var(--accent-card)}100%{background:transparent}}
  .chatmsg.cflash{animation:cflash 1.4s ease-out;border-radius:12px}
  .replybar{display:flex;align-items:flex-start;gap:10px;margin:0 0 8px;padding:9px 12px;
    border-radius:12px;background:var(--panel);border:1px solid var(--border-2);border-left:2px solid var(--accent)}
  .replybar-l{min-width:0;flex:1}
  .replybar-n{font-family:'JetBrains Mono',monospace;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;
    color:var(--accent-fg);display:flex;align-items:center;gap:5px;margin-bottom:3px}
  .replybar-x{font-size:12.5px;color:var(--text-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .replybar-c{flex:none;background:none;border:0;color:var(--text-3);font-size:15px;cursor:pointer;
    padding:2px 4px;min-height:28px;min-width:28px}
  /* The names, written out only while the action bar is open. */
  .rwho{flex-basis:100%;margin:5px 0 1px;display:flex;flex-direction:column;gap:2px}
  .rwho div{font-size:12px;color:var(--text-3);line-height:1.4}
  .rwhoe{margin-right:4px}
  .creplychip{color:var(--accent-fg)}

  .lgdrop{margin-top:8px}
  .lgtrigger{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;
    padding:11px 15px;border-radius:13px;border:1px solid var(--border);background:var(--card);
    color:var(--text);font-family:inherit;text-align:left;cursor:pointer;min-height:52px}
  .lgtriglab{display:flex;flex-direction:column;gap:2px;min-width:0}
  .lgtrigcap{font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.16em;
    text-transform:uppercase;color:var(--text-4)}
  .lgchev{flex:none;color:var(--text-3);display:inline-flex;transition:transform .16s ease}
  .lgtrigger.open{border-color:var(--accent)}
  .lgtrigger.open .lgchev{transform:rotate(180deg);color:var(--accent-fg)}
  @media(hover:hover){ .lgtrigger:hover{border-color:var(--accent)} }
  .lgmenu{display:flex;flex-direction:column;gap:6px;margin-top:8px;
    max-height:232px;overflow-y:auto;-webkit-overflow-scrolling:touch;
    padding:6px;border-radius:13px;border:1px solid var(--border-2);background:var(--panel)}
  .lgmenu[hidden]{display:none}
  .lgopt{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;
    padding:12px 14px;border-radius:11px;border:1px solid var(--border);background:var(--card);
    color:var(--text);font-family:inherit;font-size:15px;font-weight:700;text-align:left;
    cursor:pointer;min-height:48px}
  .lgopt.on{border-color:var(--accent);background:var(--accent-card)}
  /* THE SAME DOT AS THE CHAT TAB, INLINE.                BJB-175 item 4.
     --accent-fg not --accent, the rule .tabdot already states. It sits INSIDE
     the existing "Switch ->" label rather than being positioned over the row,
     so it cannot change the row's height - BJB-185 pins that twelve leagues
     close to the same height as three, and this must not be what breaks it. */
  .lgdot{display:inline-block;width:7px;height:7px;border-radius:50%;
    background:var(--accent-fg);margin-right:6px;vertical-align:middle}
  .lgname{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:700;font-size:15px}
  .lgnow{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.12em;
    text-transform:uppercase;color:var(--accent-fg);flex:none;display:inline-flex;align-items:center;gap:5px}
  .lggo{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.12em;
    text-transform:uppercase;color:var(--text-3);flex:none}
  @media(hover:hover){ .lgopt:not(.on):hover{border-color:var(--accent);color:var(--accent-fg)} }
  .lgopt:active{transform:scale(.995)}
  /* THE BADGES ARE STROKE GLYPHS NOW, NOT EMOJI. An emoji sat on the text
     baseline; a 13px SVG needs saying where it goes or it rides high in a pill
     that is only 22px tall. */
  .bdg .uic{vertical-align:-2px;margin-right:1px}
  .userchip{display:flex;align-items:center;gap:8px;background:var(--card);border:1px solid var(--border);border-radius:999px;padding:7px 13px;font-weight:700;font-size:13px}
  button.userchip{cursor:pointer;font-family:inherit;color:var(--text);min-height:34px;max-width:190px;transition:border-color .12s,background .12s}
  @media(hover:hover){button.userchip:hover{border-color:var(--accent)}}
  button.userchip>.chipname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
  /* The initial the chip collapses to on a genuinely narrow phone. Hidden at
     every ordinary width - the product rule: the NAME is the control unless the
     header truly cannot afford it. */
  .chipini{display:none;font-weight:800}
  .chipchev{opacity:.55;font-size:10px;flex:none}
  /* THE BELL'S UNREAD DOT (BJB-213 rework). One dot serves both halves of the
     inbox. Ringed in the card colour so it reads on the button in both themes. */
  .hdrbell{position:relative}
  .hdrdot{position:absolute;top:7px;right:8px;width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--card)}
  /* A personal item in the inbox - the same quiet row language as .pfrow. */
  .ibrow{display:flex;gap:10px;align-items:center;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:11px 13px;margin-top:8px}
  /* THE GAME CARD.                                       BJB-293, 29 Aug 2026.
     Identity first - name and one line, grouped - then the slot. Up to 400px
     the card is a single column with the copy wrapping naturally across the
     full width; the fault it replaces was a 57px text column at 320 and 129px
     at 390, both breaking the sentence word by word. Above 400px the two
     groups sit side by side ONLY because both keep a comfortable width there:
     the copy holds 167px and the slot is a status line or a small pill, never
     the oversized outlined block that used to compete with the copy. */
  .ibtext{min-width:0;flex:1}
  .ibrow .ibname{font-weight:700;font-size:14px;line-height:1.25}
  /* .ibrow .ibline, not .ibline: `.hint` sets margin-bottom:12px and is
     declared LATER in this file, so a single-class rule loses on source order
     and leaves dead space under the copy. Two classes beat one, wherever it
     sits. */
  .ibrow .ibline{margin:3px 0 0}
  .ibslot{flex:none;display:flex;align-items:center}
  /* BOTH ACTION SHAPES ARE 44px, ON ANY POINTER.         BJB-293, 29 Aug 2026.
     The repository pins `button.ghost.tiny` at 44px under @media(pointer:coarse)
     but says nothing about `button.btn.tiny` - so the moment the card started
     drawing the URGENT action as a filled .btn, the most important control on
     it measured 83x30 while the SECONDARY "See result" beside it measured
     89x44. The primary was the smallest thing and the only one under the
     floor. Scoped to .ibact so no other screen's buttons move. */
  .ibrow .ibact{min-height:44px;padding:11px 15px}
  /* THE CARD AS A ROUTE.                               BJB-301, 29 Aug 2026.
     Only when the slot holds a status - see app.js rowGo(). It must READ as a
     status card that happens to be tappable, not as a button: no fill, no
     second border, just a pointer, a proper focus ring and a pressed state so
     a finger gets an answer. The 44px minimum is already met by the row. */
  /* THE DAILY LAST CARD CARRIES A PERSISTENT TRIM.     BJB-302, 30 Aug 2026.
     It marks the card as the mini-game surface, and it is deliberately
     STATE-INDEPENDENT: the same 3px edge on a rest day, a settled result, a
     cold cache and a warm one. That is the whole point - the trim identifies
     the card, it does not report the day. What reports the day is the slot. */
  .ibrow-dl{box-shadow:inset 3px 0 0 var(--accent-soft2)}
  /* The identity block: the approved mark beside the official wordmark, which
     is the accessible name. Sized to the row, not to the modal header. */
  .ibname-dl{display:flex;align-items:center;gap:8px}
  .ibname-dl .dlwordmark{height:13px;width:auto}
  .ibname-dl .gtwordmark{height:13px;width:auto}
  /* THE FAMILY HEADING IS A HEADING, NOT A PANEL. A rule above it separates the
     two families; the folds below stay siblings and keep their own borders. */
  /* THE FAMILY TRANSITION IS A CENTRED SEPARATOR.      BJB-302, 30 Aug 2026.
     The name sits in the middle of the divider with a rule running out to each
     edge, so the change of family reads as a break in the page rather than as
     another heading stacked on the column. The rules are flex children, so they
     take whatever space the centred name leaves at any width.

     ONLY THE NAME IS CENTRED. The league and gameweek beneath it, and every
     fold below, stay left-aligned on the established content grid - centring
     context would start a second alignment nobody asked for. */
  .famhead{margin:24px 0 10px}
  .famrule{display:flex;align-items:center;gap:12px}
  .famrule::before,.famrule::after{content:"";flex:1 1 auto;height:1px;background:var(--border-2)}
  .famtitle{flex:0 0 auto;font-weight:800;font-size:12px;letter-spacing:.16em;color:var(--text-3);
    white-space:nowrap}
  .famsub{margin-top:8px;font-size:13px;color:var(--text-2)}
  /* How to play now sits above both families rather than inside one. */
  .utilrow-top{margin-bottom:12px}
  .ibrowgo{cursor:pointer;-webkit-tap-highlight-color:transparent}
  .ibrowgo:active{background:var(--card-2,var(--card));border-color:var(--border-2,var(--border))}
  .ibrowgo:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
  /* A STATUS IS NOT A CONTROL, so it is not shaped like one: no border, no
     fill, no hit area, and the pulsing dot says "still reading" without a
     spinner of its own - onlastPulse is the animation this system already
     uses. Under prefers-reduced-motion the dot simply rests. */
  .ibstatus{display:inline-flex;align-items:center;gap:7px;font-size:12.5px;font-weight:600;
    color:var(--text-3);letter-spacing:.01em;white-space:nowrap}
  .ibdot{width:6px;height:6px;border-radius:50%;background:var(--text-4);flex:none;
    animation:onlastPulse 1.6s ease-in-out infinite}
  @media(prefers-reduced-motion:reduce){ .ibdot{animation:none;opacity:.55} }
  @media(max-width:400px){
    .ibrow{flex-direction:column;align-items:stretch;gap:9px}
    /* The slot keeps its natural size and sits with the copy rather than
       stretching into a full-width block - a status must not look like a bar,
       and an action here is secondary to the card, not a page CTA. */
    .ibslot{align-self:flex-start}
  }
  /* THE WAY TO THE RULES IS NOT A THING YOU CAN PLAY.     BJB-293, 29 Aug 2026.
     A conventional 14px/600 row - the same size as the game titles it sits
     above, one weight lighter and one colour quieter, so it belongs to the
     section without competing with the cards. The hairline beneath it is what
     ties it to the games rather than leaving it floating: it reads as the
     section's own first row. 44px of target, taken as padding rather than as
     whitespace, so it is compact. */
  .utilrow{display:flex;align-items:center;gap:10px;width:100%;min-height:44px;
    padding:9px 2px;margin:0;background:none;border:0;border-bottom:1px solid var(--border-2);
    border-radius:0;font-family:inherit;font-size:14px;font-weight:600;
    color:var(--text-2);text-align:left;cursor:pointer;-webkit-tap-highlight-color:transparent}
  .utilrow .utilic{display:flex;align-items:center;color:var(--text-3);flex:none}
  .utilrow .utillab{min-width:0;flex:1}
  .utilrow .utilchev{display:flex;align-items:center;flex:none;color:var(--text-3);transform:rotate(-90deg)}
  @media(hover:hover){.utilrow:hover{color:var(--accent-fg)}
    .utilrow:hover .utilic,.utilrow:hover .utilchev{color:var(--accent-fg)}}
  .utilrow:active .utillab{transform:translateX(1px)}
  :focus-visible{outline:2px solid var(--accent-fg);outline-offset:2px}
  .pfrow{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:11px 13px;font-size:14px;font-weight:600}
  .avgrid{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-top:4px}
  @media(max-width:400px){.avgrid{grid-template-columns:repeat(5,1fr)}}
  /* MEASURED, NOT GUESSED (design review, shot 24): at 320px the modal is
     288px wide and the fold's own padding leaves ~232px, but five 44px tiles
     with their gaps need 252 - so the last column overflowed the modal by
     19px. Four columns need 200. The 44px tap target is the floor that may
     not move, so the COLUMN COUNT moves instead. */
  @media(max-width:365px){.avgrid{grid-template-columns:repeat(4,1fr)}}
  /* Sticker-sheet tiles: each avatar sits on its own colour (set inline from
     avTint), with a soft inner shadow so the emoji reads as ON something. The
     glyph is dropped-shadowed for lift; the colour never tints the emoji. */
  /* A COLOUR TILE, NOT AN EMOJI TILE. The initial is set in the wordmark face so
     the picker previews exactly what appears beside your name everywhere else -
     the old picker showed a glyph on a gradient that then rendered differently
     in Chat, which is how the cheapest screen in the app leaked into the two
     most-used ones. */
  .avopt.avpick{font-family:'Saira',sans-serif;font-weight:800;font-size:19px;letter-spacing:.02em;
    text-shadow:none;box-shadow:none;border-color:transparent}
  .av.avpick{border-color:transparent;font-family:'Saira',sans-serif;letter-spacing:.01em}
  .avopt{aspect-ratio:1;min-height:44px;display:flex;align-items:center;justify-content:center;font-size:23px;line-height:1;background:var(--card);border:1px solid rgba(0,0,0,.18);border-radius:14px;cursor:pointer;padding:0;transition:border-color .12s,transform .12s;box-shadow:inset 0 -8px 14px -8px rgba(0,0,0,.35),inset 0 6px 10px -6px rgba(255,255,255,.25);text-shadow:0 1px 2px rgba(0,0,0,.3)}
  .avopt:active{transform:scale(.94)}
  /* THE SELECTED STATE MUST SURVIVE EVERY TILE COLOUR (design review).
     An accent ring on the accent-lime tile was invisible - same colour on
     same colour. The ring now stands OFF the tile: a gap in the card colour,
     then the accent, so it reads on lime, on teal, on every pattern. */
  .avopt[aria-pressed="true"]{border-color:var(--accent);box-shadow:0 0 0 2.5px var(--card),0 0 0 5px var(--accent)}
  .avopt.avnone{background:var(--card);border:1px dashed var(--border-3);color:var(--text-3);font-family:'Saira','Arial Narrow',sans-serif;font-weight:800;font-size:18px;box-shadow:none;text-shadow:none}
  .avopt.avnone[aria-pressed="true"]{border-style:solid;border-color:var(--accent);box-shadow:0 0 0 2.5px var(--card),0 0 0 5px var(--accent)}
  .av{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--card);border:1px solid var(--border);flex:none;line-height:1;overflow:hidden}
  .av.avtint{box-shadow:inset 0 -4px 7px -4px rgba(0,0,0,.4),inset 0 3px 5px -3px rgba(255,255,255,.3);text-shadow:0 1px 1.5px rgba(0,0,0,.3)}
  .minibtn.on{background:var(--accent);color:#0a1409;border-color:var(--accent)}
  /* Sits IN the header rather than over it. The first version was position:fixed
     with a padding-top on the body to compensate - which works only while the
     bar is exactly the height you guessed. It wrapped to three lines on a phone
     and buried the league name and every header control behind it. In the flow,
     it simply cannot cover anything, at any height, in any language. */
  .viewasbar{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;background:#ffd84d;color:#2a2200;border-radius:12px;margin:12px 0 0;padding:10px 12px;font-family:'Hanken Grotesk',sans-serif;font-weight:700;font-size:12.5px;line-height:1.35}
  .viewasbar .vabtxt{flex:1;min-width:0}
  .viewasbar .vabnote{display:block;font-weight:500;font-size:11px;opacity:.75;margin-top:1px}
  .viewasbar button{background:#2a2200;color:#ffd84d;border:none;border-radius:8px;padding:9px 14px;min-height:44px;font-family:inherit;font-weight:700;font-size:12.5px;cursor:pointer;white-space:nowrap}
  .adminbadge{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.1em;color:#0a1409;background:var(--accent);padding:3px 7px;border-radius:4px;font-weight:700}
  /* Onlast's own confirm - the browser dialog looks like the operating system
     interrupting the game, and it cannot be styled, themed or worded as part
     of the product. Sits ABOVE modalRoot: leaving a league is asked for from
     inside the profile modal, so this must layer over an open modal. */
  .cfm-bg{position:fixed;inset:0;background:rgba(4,7,5,.62);backdrop-filter:blur(3px);z-index:220;display:flex;align-items:center;justify-content:center;padding:22px;animation:cfmIn .14s ease}
  .cfm{background:var(--panel);border:1px solid var(--border);border-radius:16px;max-width:400px;width:100%;padding:22px 22px 18px;box-shadow:0 24px 64px -18px rgba(0,0,0,.6)}
  .cfm h3{margin:0 0 10px;font-family:'Saira','Arial Narrow',sans-serif;font-weight:800;font-size:18px;text-transform:uppercase;letter-spacing:.02em}
  .cfm .cfm-body{font-size:14px;line-height:1.5;color:var(--text-2);white-space:pre-line}
  .cfm .cfm-acts{display:flex;gap:10px;justify-content:flex-end;margin-top:18px}
  .cfm .cfm-acts .ghost,.cfm .cfm-acts .btn{min-height:44px;padding:10px 18px}
  .cfm .btn.cfm-danger{background:var(--danger);color:#fff}
  @keyframes cfmIn{from{opacity:0}to{opacity:1}}
  @media(prefers-reduced-motion:reduce){.cfm-bg{animation:none}}
  .minibtn{font-size:12px;font-weight:600;color:var(--text-2);background:var(--card);border:1px solid var(--border-3);border-radius:999px;padding:8px 13px;cursor:pointer;white-space:nowrap}
  .themebtn{display:flex;align-items:center;gap:8px;font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:700;letter-spacing:.1em;color:var(--text-2);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:8px 13px;cursor:pointer}

  /* ribbon */
  /* The ribbon is the app's heartbeat and it is on every screen, so it has to
     look deliberate at every width. It used to be one flex row of four loose
     children with a flex:1 spacer in the middle. On a phone that wrapped badly:
     LIVE and ROUND/GAMEWEEK filled the first line, the spacer shoved LOCKS IN
     to the far right of it, and the countdown dropped to the second line on the
     left - so the label sat in one corner and its own value in the opposite one.
     Rendered and measured at 393px before it was changed.

     Now the label and the thing it labels are locked together in one box each,
     and on a phone it becomes a two-column grid: identity on the left, deadline
     on the right, each row filling the card edge to edge. Nothing can strand
     itself in a corner because nothing wraps independently any more. */
  .ribbon{max-width:1060px;margin:18px auto 0;display:flex;align-items:center;gap:18px;padding:13px 18px;background:var(--card);border:1px solid var(--border);border-radius:12px}
  /* Needed explicitly: the `hidden` attribute only sets display:none in the
     browser's own stylesheet, and any author rule - .ribbon{display:flex} right
     above - beats it. Without this line the ribbon would show on the sign-in
     screen, before there is a league to be live about. */
  .ribbon[hidden]{display:none}
  .ribbon .ribleft,.ribbon .ribright{display:flex;align-items:center;gap:18px;min-width:0}
  .ribbon .ribright{gap:10px;margin-left:auto}
  .ribbon .live{display:flex;align-items:center;gap:8px}
  .ribbon .dot{width:8px;height:8px;border-radius:50%;background:var(--accent);animation:lmsPulse 1.8s infinite}
  .ribbon .lab{font-family:'Hanken Grotesk',sans-serif;font-weight:700;font-size:10.5px;letter-spacing:.09em;color:var(--accent-fg);text-transform:uppercase}
  .ribbon .rd{font-family:'Hanken Grotesk',sans-serif;font-weight:600;font-size:11.5px;letter-spacing:.06em;color:var(--text-2)}
  .ribbon .grow{flex:1}
  .ribbon .lock{font-family:'Hanken Grotesk',sans-serif;font-weight:600;font-size:10.5px;letter-spacing:.08em;color:var(--text-3)}
  .ribbon .cd{font-family:'JetBrains Mono',monospace;font-weight:700;font-size:15px;letter-spacing:.06em;color:var(--text);white-space:nowrap}
  .ribbon .cd s{color:var(--text-5);text-decoration:none}
  @media(max-width:560px){
    .ribbon{display:grid;grid-template-columns:1fr auto;align-items:center;gap:6px 12px;padding:11px 14px}
    .ribbon .ribleft,.ribbon .ribright{display:contents}
    .ribbon .live{grid-column:1;grid-row:1}
    .ribbon .lock{grid-column:2;grid-row:1;justify-self:end}
    .ribbon .rd{grid-column:1;grid-row:2}
    .ribbon .cd{grid-column:2;grid-row:2;justify-self:end}
  }

  /* nav */
  /* 96px cleared the old flat bar. The Home circle lifts 22px ABOVE the bar,
     so it was sitting on top of the last card - "PICKS IN . 3/5 ACTIVE
     PLAYERS" was half-hidden behind it. 124px clears the circle with room
     to spare, and desktop never sees the bar at all. */
  .wrap{max-width:1060px;margin:0 auto;padding:0 16px calc(124px + env(safe-area-inset-bottom))}
  /* ...but only where the bar exists. Above 720px the fixed bottom bar is
     display:none and this reserved 124px for nothing: measured at 1920x1080 the
     Chat document came to 1139px and grew a scrollbar for a navigation bar that
     is not rendered. Also restores the horizontal safe area, which only the
     fatal-error screen was honouring - in landscape on a notched iPhone the
     sensor housing takes 47-59pt off one side, and 16px of padding put the
     floodlight tile and "Sign out" underneath it. */
  @media(min-width:721px){ .wrap{padding-bottom:40px} }
  @media(orientation:landscape){
    .wrap{padding-left:calc(16px + env(safe-area-inset-left,0px));padding-right:calc(16px + env(safe-area-inset-right,0px))}
    header.top{padding-left:calc(14px + env(safe-area-inset-left,0px));padding-right:calc(14px + env(safe-area-inset-right,0px))}
    .tabbar{padding-left:calc(6px + env(safe-area-inset-left,0px));padding-right:calc(6px + env(safe-area-inset-right,0px))}
    .modal-bg{padding-left:calc(16px + env(safe-area-inset-left,0px));padding-right:calc(16px + env(safe-area-inset-right,0px))}
  }
  /* 44px IS A PROPERTY OF THE FINGER, NOT OF THE VIEWPORT. Every one of these
     guarantees was written inside @media(max-width:720px), so an iPad in
     portrait and EVERY modern iPhone in landscape (852x393, 932x430) got the
     32px desktop controls: the header help button measured 35x32 on a touch
     screen. Repeat them for a coarse pointer at any width. */
  @media(pointer:coarse){
    .userbar .themebtn{min-height:44px;min-width:44px}
    .userbar .minibtn{min-height:44px;min-width:44px}
    button.userchip{min-height:44px}
    .modalclose{width:44px;height:44px;font-size:17px}
    button.ghost.tiny{min-height:44px;padding:11px 15px}
    .codecopy{width:44px;height:44px}
  }
  .tabs{display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin:24px 0}
  .tabs button{white-space:nowrap;font-family:'Hanken Grotesk',sans-serif;font-weight:600;font-size:13px;color:var(--text-2);background:var(--card);border:1px solid var(--border);padding:9px 18px;border-radius:999px;cursor:pointer}
  .tabs button.active{font-weight:700;color:#0a1409;background:var(--accent);border-color:var(--accent);box-shadow:0 0 16px -3px var(--glow-2)}
  /* HOME ON DESKTOP AND MOBILE BROWSER (product decision, raised repeatedly).
     The centre treatment lived entirely inside a max-width:720px media query,
     so above 720px Home was just another pill and the "central Home" idea
     simply did not exist. The desktop row now carries the same idea in the
     medium it has: the icon, a lime disc, and a wider pill - Home reads as the
     one primary destination without the raised-circle trick, which needs a
     fixed bottom bar to make sense.
     Every tab also gains its icon up here, so the two layouts are one design. */
  .tabs button{display:inline-flex;align-items:center;gap:8px}
  .tabs .tabico{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px}
  .tabs .tabico svg{width:18px;height:18px}
  .tabs button.hometab{font-weight:800;padding-left:9px;border-color:var(--accent);color:var(--text)}

  /* ==========================================================================
     DESKTOP: TWO COLUMNS.                                    BJB-191, 19 Aug 2026

     Product decision, 18 Aug: "The desktop identity mockup looks really cool." And the
     problem it solves, 19 Aug: "Onlast should feel like a premium product
     everywhere, not just on the PWA." At 1440px the app was a 1060px phone
     column with dead space either side.

     EVERY RULE IN THIS BLOCK LIVES BEHIND min-width:1100px, AND THAT IS THE
     WHOLE SAFETY ARGUMENT. A media query that only matches at 1100px and above
     cannot execute on a phone. This is not "low risk" - the launch audience is
     five people on handsets, and this block is unreachable from every one of
     their screens. tools/check-desktop-layout.js asserts that no rule here
     escapes the query, so it stays true.

     WHY 1100 AND NOT 1024. A landscape iPad is 1024 wide. Turning a tablet
     sideways should not rebuild the navigation, and 1100 is comfortably clear
     of every common tablet landscape width while catching every laptop.

     WHY THE TABS BECOME A LEFT RAIL rather than staying a centred row: a
     horizontal pill row is a phone pattern borrowed upward. On a wide screen
     the eye reads left-to-right, so navigation belongs down the left and the
     content gets the width. It is also the only change that makes the extra
     width worth having - a wider column of the same layout is just longer
     lines to read.
     ========================================================================== */
  @media(min-width:1100px){
    /* body.appshell IS NOT DECORATION.                     BJB-191, 20 Aug 2026
       Every rule below assumes there is a tab rail to put in the 224px track.
       .wrap is on every screen, so without this gate the sign-in page, Create
       league, Join a league, No leagues yet and the load-error screen all laid
       their content into a column meant for navigation - one word per line on a
       laptop. The rail is display:none on those screens, so it is not even a
       grid item and `main` slides into the narrow track.
       setAppShell() in index.html is the only thing that sets this class, and
       it is also the only thing that shows or hides the rail, so the two cannot
       disagree. */
    body.appshell .wrap{
      /* BJB-311, 1 Sep 2026. WAS A FLAT 1240px, AND THAT IS WHY THE APP LOOKED
         STRANDED. The container stopped growing at 1240 however wide the screen
         was, so a signed-in player on a 3440px monitor got a 944px column using
         27% of it - measured, not estimated. min() lets the container follow the
         screen up to 1600px and keeps a 92vw margin so it never runs edge to
         edge. The owner chose this over centring the column: the +132px offset is
         the ordinary sidebar convention, and on a filled screen it reads as a
         sidebar rather than a card adrift. Only the cap moves - the rail, the
         gap and the grid are untouched. */
      max-width:min(1600px,92vw);
      display:grid;
      grid-template-columns:224px minmax(0,1fr);
      column-gap:40px;
      align-items:start;
    }
    /* The rail. Sticky, so navigation stays put on a long My History. */
    body.appshell .tabs{
      flex-direction:column;
      align-items:stretch;
      flex-wrap:nowrap;
      justify-content:flex-start;
      gap:6px;
      margin:34px 0 0;
      position:sticky;
      top:24px;
    }
    body.appshell .tabs button{
      justify-content:flex-start;
      text-align:left;
      border-radius:12px;
      padding:11px 14px;
      font-size:14px;
      width:100%;
    }
    body.appshell .tabs button.hometab{padding-left:14px}
    /* min-width:0 is not decoration. Without it a grid column refuses to be
       narrower than its widest child, so one wide table pushes the whole
       layout sideways and the page scrolls horizontally. */
    body.appshell main{min-width:0;margin-top:34px}
    body.appshell .footer{grid-column:1 / -1;margin-top:44px}
    /* The header and the live-score ribbon are centred on their own max-width.
       Left at 1060 they would sit visibly narrower than the 1240 body beneath
       them, which reads as a mistake rather than a margin. */
    /* BJB-311, 2 Sep 2026. THE HEADER CAP MOVES WITH THE CONTENT CAP, because
       they are one set of edges and always were. Raising .wrap to min(1600px,92vw)
       while this stayed at a flat 1240 left the header and ribbon INSET FROM THEIR
       OWN CONTENT - measured at 42px each side at 1440 and 180px at 1920, 2560 and
       3440, with the tab rail's left edge sitting outside the header's. That reads
       as broken rather than fixed, and it would have traded a symmetric dead-space
       column for a misaligned top bar. Same expression as .wrap, deliberately: if
       one cap ever changes, the other has to change with it. */
    body.appshell .hdrwrap,body.appshell .ribbon{max-width:min(1600px,92vw)}
  }

  .tabs button.hometab .tabico{width:28px;height:28px;border-radius:50%;background:var(--accent);color:#0a1409;box-shadow:0 4px 12px -4px var(--glow-5)}
  .tabs button.hometab .tabico svg{width:17px;height:17px}
  .tabs button.hometab.active{background:var(--accent);color:#0a1409}
  .tabs button.hometab.active .tabico{background:#0a1409;color:var(--accent);box-shadow:none}

  /* mobile bottom tab bar */
  /* BJB-127. Lock is its own control now, directly beneath the pick grid. */
  .lockrow{margin-top:12px}
  .lockbtn{width:100%;padding:14px 16px;font-size:16px;font-weight:800;letter-spacing:.02em}
  .picktag.seltag{background:var(--accent-card);color:var(--accent-fg);border:1px solid var(--accent-soft2)}
  .tabbar{display:none}
  /* BJB-117. The unread mark on the Chat tab. A dot, never a count: chat is
     fetched rather than streamed, so any number would be stale the moment it
     was drawn. A dot only has to be right about one thing. */
  .tabico{position:relative}
  /* --accent-fg, not --accent: the flat lime is #c9ff3a in BOTH themes, which
     against a white panel in light mode is about 1.4:1 - a non-text indicator
     needs 3:1. --accent-fg is the token that stays legible either way. */
  .tabdot{position:absolute;top:-2px;right:-5px;width:8px;height:8px;border-radius:50%;background:var(--accent-fg);box-shadow:0 0 0 2px var(--panel);pointer-events:none}
  @media(max-width:720px){
    .tabs{display:none}
    /* BJB-326: body is now the scroll container, capped at exactly the
       viewport height (see the html/body rule above) - it no longer relies
       on a naturally-taller document to leave the fixed tabbar floating over
       already-scrolled-past content. Without this, the last ~60-95px of
       every scrollable screen ends up permanently UNDER the tabbar, because
       body's own scrollable area now stops exactly where the viewport does.
       Mirrors .tabbar's own height formula below (5px+5px padding, a 46px
       button, safe-area-inset-bottom) rather than a guessed number, rounded
       up rather than measured exactly - a few spare px of scroll room is
       harmless; a hidden control is not. check-platforms.js section on
       "controls under the bottom bar" is what this answers. */
    body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}
    .tabbar{display:flex;align-items:stretch;position:fixed;left:0;right:0;bottom:0;z-index:50;background:var(--panel);border-top:1px solid var(--border);padding:5px 6px calc(5px + env(safe-area-inset-bottom));box-shadow:0 -10px 28px -14px rgba(0,0,0,.45)}
    .tabbar button{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;gap:4px;min-height:46px;padding:6px 2px 4px;background:transparent;border:none;color:var(--text-4);font-family:'Hanken Grotesk',sans-serif;font-weight:600;font-size:10.5px;white-space:nowrap;cursor:pointer;transition:color .16s ease,transform .1s ease}
    .tabbar .tabico{display:flex;align-items:center;justify-content:center;height:22px}
    .tabbar button:active{transform:scale(.93)}
    .tabbar button.active{color:var(--text);font-weight:700}
    /* HOME, THE CENTRE OF THE BAR.
       It was a lime rounded rectangle with the word inside it, which put its
       label on a different line from every other label and made the row read as
       four tabs plus a button. Now the ICON sits in a raised circle and the word
       "Home" sits on the same baseline as its neighbours: the row is five tabs,
       one of which is unmistakably primary. A circle rather than a squircle
       because it is the one shape nothing else in the bar uses - the difference
       reads instantly at a glance, which is the entire job of a centre tab. */
    .tabbar button.hometab{color:var(--text-3)}
    .tabbar button.hometab .tabico{margin-top:-22px;width:54px;height:54px;border-radius:50%;background:var(--accent);color:#0a1409;box-shadow:0 10px 22px -10px var(--glow-6),0 0 0 5px var(--panel);border:1px solid rgba(10,20,9,.12);transition:transform .14s ease}
    .tabbar button.hometab.active{color:var(--text);font-weight:700}
    .tabbar button.hometab:active .tabico{transform:scale(.93)}
    /* one indicator that MOVES, rather than a border appearing on a different
       button each time. The node is reused across re-renders, which is what lets
       the transition run; recreating it would just teleport. */
    .tabink{position:absolute;top:0;left:0;width:0;height:2px;border-radius:0 0 2px 2px;background:var(--accent);pointer-events:none;box-shadow:0 0 10px -1px var(--glow-5);transition:transform .26s cubic-bezier(.4,0,.2,1),width .26s cubic-bezier(.4,0,.2,1)}
  }

  /* cards / shared */
  .view{animation:lmsUp .35s ease}
  /* SWIPE MOTION (BJB-101 item 12, second pass — Product decision: "the transition still
     feels a little abrupt").

     Changing gameweek re-renders the whole view, and `.view` then plays lmsUp —
     a 10px RISE. That is the right motion for arriving at a new screen and the
     wrong one for moving sideways through a list: the gesture goes one way and
     the pixels go another, which is what reads as abrupt rather than fast.

     So a gameweek change now slides HORIZONTALLY, in the direction of travel,
     and the vertical rise is suppressed while it does. 22px and 220ms — far
     enough to be read as movement, short enough that nobody waiting on a
     deadline is waiting on an animation. The easing is a decelerate curve, so
     it arrives rather than stops.

     It is on the PANEL, not the page, so the header and the tab bar stay put —
     the same thing native apps do, and the reason this feels attached to the
     thumb rather than played at you.

     Both the swipe AND the Prev/Next buttons go through it, because two ways of
     doing the same thing that feel different is worse than either one. */
  /* SECOND PASS, 14 Aug. Product decision: "the whole screen seems to shake/stutter".
     He was right and there were two faults, both mine.

     FAULT 1, and it is the shake: the selector matched THE PANEL ALREADY ON
     SCREEN. gwSlide() sets the class, and only then does the render run - and
     renderGW() is async, so on a gameweek it has not cached it waits on the
     network first. So the OLD panel slid, sat there, got destroyed mid-flight,
     and the NEW panel slid again. Two animations per swipe, with a gap of
     however long the fetch took. That is not a slow animation, it is two.

     Fixed by marking everything already on screen as stale and animating only
     what is NOT stale. The incoming panel is built by the render and has no
     mark, so it is the only thing that ever moves.

     FAULT 2: a 22px translate on a full-width panel pushes past the right edge
     for 220ms, which on iOS lets the page rubber-band sideways. `overflow-x:
     clip` contains it. Clip rather than hidden on purpose - hidden would make
     #view a scroll container and break the sticky header above it.

     Travel is also down 22px -> 14px and the opacity floor up .35 -> .6. The
     movement should be felt rather than watched. */
  @keyframes gwInFromRight{from{transform:translateX(14px);opacity:.6}to{transform:none;opacity:1}}
  @keyframes gwInFromLeft {from{transform:translateX(-14px);opacity:.6}to{transform:none;opacity:1}}
  #view.gwnext, #view.gwprev{overflow-x:clip}
  #view.gwnext [data-swipe]:not([data-stale]){animation:gwInFromRight .2s cubic-bezier(.22,.61,.36,1)}
  #view.gwprev [data-swipe]:not([data-stale]){animation:gwInFromLeft  .2s cubic-bezier(.22,.61,.36,1)}
  #view.gwnext > .view, #view.gwprev > .view{animation:none}
  /* The :not() above RAISED the specificity of the animation rules, and a media
     query adds none of its own - so this override has to carry the same
     selector or it silently loses and reduced-motion users get the animation
     anyway. check-swipe.js §7b caught exactly that, in a browser genuinely
     asking for reduced motion. */
  @media(prefers-reduced-motion:reduce){
    #view.gwnext [data-swipe]:not([data-stale]),
    #view.gwprev [data-swipe]:not([data-stale]){animation:none}
  }
  /* a re-render of the tab you are ALREADY on must not replay the entrance animation */
  #view.soft .view{animation:none}
  .card{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:20px 22px;margin-bottom:16px}
  .panel{background:var(--panel);border:1px solid var(--border-2);border-radius:16px;margin-bottom:16px}
  .card h2,.panel h2{font-family:'Saira',sans-serif;font-weight:800;font-size:19px;letter-spacing:.02em;text-transform:uppercase;margin:0 0 4px;color:var(--text)}
  .card h3{font-family:'JetBrains Mono',monospace;font-weight:700;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--text-4);margin:16px 0 8px}
  .hint{color:var(--text-2);font-size:13px;line-height:1.55;margin:0 0 12px}
  /* A QUIET WAY OUT, not a second front door. Used under Google's own button in
     the installed app, where a popup can be swallowed by the OS shell with
     nothing on screen to say why. It has to be findable by somebody who has
     just tapped a button and had nothing happen, and invisible to everybody
     else - so it reads as a sentence, not as a call to action. */
  .linkbtn{background:none;border:0;padding:6px 4px;font-family:'Hanken Grotesk',system-ui,-apple-system,sans-serif;
    font-size:12.5px;line-height:1.4;color:var(--text-3);text-decoration:underline;text-underline-offset:3px;cursor:pointer}
  /* BEHIND hover:hover, like every other hover rule in this file. On a touch
     screen a :hover style sticks after the tap, so the link stays "lit" until
     you touch something else - which reads as a control that did not respond.
     check-states caught this the day after I wrote it. */
  @media(hover:hover){ .linkbtn:hover{color:var(--text-2)} }
  .linkbtn:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px}
  /* The 6px is not decoration. .label carried no bottom margin, so DISPLAY NAME
     sat flush on the top edge of its input; focus the field and the accent
     border met the label's descenders. Every .label in the file is followed by
     the thing it names, so the gap belongs on the label rather than on each of
     the twenty things after it. */
  .label{font-family:'Hanken Grotesk',sans-serif;font-weight:600;font-size:10.5px;letter-spacing:.07em;color:var(--text-4);text-transform:uppercase;margin-bottom:6px}
  .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
  input,select,textarea{background:var(--card);border:1px solid var(--border);color:var(--text);padding:11px 14px;border-radius:10px;font-size:16px;font-weight:600;outline:none;font-family:inherit;max-width:100%}
  /* 16px is load-bearing: iOS Safari zooms the page on focus for anything smaller,
     and with no maximum-scale it never zooms back out. Do not lower it. */
  input[type=checkbox],input[type=radio]{width:auto;min-height:0}
  input:focus,select:focus{border-color:var(--accent-fg)}
  label{font-size:13px;color:var(--text-2)}
  button.btn{font-family:'Hanken Grotesk',sans-serif;font-weight:700;font-size:14px;color:#0a1409;background:var(--accent);border:none;border-radius:999px;padding:12px 20px;cursor:pointer;box-shadow:0 0 18px -4px var(--glow-3);transition:transform .09s ease,box-shadow .16s ease}
  /* every one of these is disabled automatically by the prefers-reduced-motion rule above */
  button.btn:active{transform:translateY(1px) scale(.985);box-shadow:0 0 12px -6px var(--glow-3)}
  button.ghost{transition:transform .09s ease,border-color .12s,background .12s}
  button.ghost:active{transform:translateY(1px) scale(.99)}
  button.ghost{background:var(--card);color:var(--text);border:1px solid var(--border-3);border-radius:999px;padding:10px 16px;font-weight:600;cursor:pointer;box-shadow:none}
  button.tiny{padding:7px 13px;font-size:12px}
  button:disabled{opacity:.5;cursor:not-allowed}
  .pill{display:inline-flex;align-items:center;gap:6px;background:var(--chip-bg);border:1px solid var(--chip-border);padding:5px 11px;border-radius:999px;font-size:12px;color:var(--text-2)}
  .muted{color:var(--text-3)}
  .alive{color:var(--accent-fg)} .out{color:var(--danger-text)} .pending{color:var(--warn-text)} .gold{color:var(--warn-text)}
  .empty{color:var(--text-3);text-align:center;padding:24px}
  .grid-scroll{overflow-x:auto}

  /* status badges */
  .badge-in{font-family:'Hanken Grotesk',sans-serif;font-size:10.5px;font-weight:700;letter-spacing:.07em;color:#0a1409;background:var(--accent);padding:4px 10px;border-radius:5px}
  .badge-out{font-family:'Hanken Grotesk',sans-serif;font-size:10.5px;font-weight:700;letter-spacing:.07em;color:var(--danger-text);background:var(--danger-bg);border:1px solid var(--danger-border);padding:3px 9px;border-radius:5px}
  /* THE MATCH STATES, AND THEY MUST NOT LOOK PROVISIONAL.     BJB-253.
     The first attempt at this had GOING OUT in a dashed outline, on the
     reasoning that settlement had not confirmed it yet.

       Product decision: "Do not use GOING OUT after the match has finished. It sounds
       tentative and makes an obvious result look uncertain."

     He is right. A finished defeat is a fact, and a player should not be shown
     a hedge about a football result they already know. OUT is therefore the
     ordinary filled OUT badge - no separate style exists any more.
     What IS uncertain is a match still being played, and only those states are
     drawn softly: TO PLAY in muted grey because nothing has happened yet, IN
     PLAY in amber, AT RISK in amber with a danger outline because their club
     is behind and it still might not end that way. */
  .badge-risk{font-family:'Hanken Grotesk',sans-serif;font-size:10.5px;font-weight:700;letter-spacing:.07em;color:var(--danger-text);background:var(--warn-bg);border:1px solid var(--danger-border);padding:3px 9px;border-radius:5px}
  .badge-inplay{font-family:'Hanken Grotesk',sans-serif;font-size:10.5px;font-weight:700;letter-spacing:.07em;color:var(--warn-text);background:var(--warn-bg);border:1px solid var(--warn-border);padding:3px 9px;border-radius:5px}
  .badge-toplay{font-family:'Hanken Grotesk',sans-serif;font-size:10.5px;font-weight:700;letter-spacing:.07em;color:var(--muted);background:transparent;border:1px solid var(--row-line2);padding:3px 9px;border-radius:5px}
  .risk{color:var(--danger-text)}
  .badge-won{font-family:'Hanken Grotesk',sans-serif;font-size:10.5px;font-weight:700;letter-spacing:.07em;color:#0a1409;background:var(--accent);border-radius:5px;padding:3px 8px}

  /* crest circle (placeholder club mark) */
  .crest{display:inline-block;flex:none;vertical-align:middle;line-height:0;filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.35))}
  /* Club badges are transparent PNGs. Drawn over the lettered fallback shield,
     the letters showed straight through the real badge - so the shield hides
     the moment the badge has actually loaded, and only then. If the badge never
     arrives, the shield stays: that is its entire job.

     THE SHIELD NOW WAITS 400ms BEFORE IT SHOWS. (Product decision, PWA cold start:
     "for a split second during loading, the Home tab shows the fallback crest
     for my provisional pick before replacing it with the real club crest".)

     He was right, and the cause is not the network. An <img> is created at the
     moment the panel renders, so even a badge sitting in the browser's own disk
     cache needs a few frames to be read, decoded and reported as loaded. The
     shield painted in those frames, then vanished - a flash, on a crest that
     was never going to be needed.

     So the shield starts invisible and fades itself in on a 400ms delay. A
     badge that arrives inside that window adds .haveimg, which cancels the
     animation, and the shield is never seen at all. A badge that is genuinely
     slow, or never arrives, still gets its shield exactly as before - which is
     the whole reason the shield is drawn first (see crest() below).

     This is a CSS animation, not a timer: the pick grid draws 20+ crests at
     once and 20 setTimeouts per render would be a real cost for a cosmetic
     delay. A crest with no badge URL at all is plain .crest, not .crestimg, so
     it is untouched and shows instantly. */
  .crestimg > svg{opacity:0;animation:crestSettle 0s linear .4s forwards}
  .crestimg.haveimg > svg{animation:none;opacity:0}
  @keyframes crestSettle{to{opacity:1}}
  @media(prefers-reduced-motion:reduce){
    /* No motion here to reduce - the delay IS the feature, and removing it
       would put the flash back for exactly the people least able to tolerate
       it. The transition on the badge itself is what gets shortened. */
    .crestimg img{transition-duration:.01ms !important}
  }
  .pickacts{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
  .pickclear{color:var(--text-3)}
  @media(hover:hover){.pickclear:hover{color:var(--danger-text)}}

  /* PRESS FEEDBACK.
     Tapping a row in the Premier League table opens a panel, but nothing on
     screen acknowledged the tap, so on a slow connection it felt like nothing
     had happened and people tapped again. These are deliberately small: a
     press should be felt, not watched. Everything here is suppressed under
     prefers-reduced-motion, and the tap-highlight square iOS draws by default
     is removed in favour of something that matches the rest of the app. */
  .trow.teamrow,.clubbtn,.pchip,.minibtn,.ghost,.btn,.anav,.userchip{-webkit-tap-highlight-color:transparent}
  .trow.teamrow{transition:background .12s ease,transform .10s ease}
  .trow.teamrow:active{transform:scale(.995);background:var(--chip-bg)}
  .trow.teamrow:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
  .minibtn,.ghost,.btn{transition:transform .10s ease,filter .12s ease}
  .minibtn:active,.ghost:active,.btn:active{transform:scale(.97)}
  .pchip{transition:transform .10s ease,border-color .12s ease}
  .pchip:active{transform:scale(.96)}
  .anav{transition:background .12s ease,transform .10s ease}
  .anav:active{transform:scale(.995)}
  .fixrow{transition:background .12s ease}
  @media(prefers-reduced-motion:reduce){
    .trow.teamrow,.minibtn,.ghost,.btn,.pchip,.anav,.fixrow{transition:none}
    .trow.teamrow:active,.minibtn:active,.ghost:active,.btn:active,.pchip:active,.anav:active{transform:none}
  }
  .teamcell{display:inline-flex;align-items:center;gap:8px;min-width:0}
  /* GW PICKS, REVEALED - the one list in the app where three different things
     have to line up down a column.   BJB-229, and got it wrong twice first.

     ATTEMPT ONE was a .row: flex, space-between, wrap. Three children of
     different widths puts the middle one wherever the other two leave it, so
     every crest started somewhere different down the list.

     ATTEMPT TWO made each ROW a grid and I wrote in this very comment that
     "the columns are decided once for the whole list rather than negotiated
     per row". That sentence was false, and believing it rather than measuring
     it is why it had to be reported the same fault a third time.

     EACH ROW WAS ITS OWN GRID. `1fr` and `auto` are resolved against the row
     they are in, so a row ending in "pending" and a row ending in "won" have
     different amounts of space left over, and their middle columns therefore
     start at different x. That is the whole bug, and it is visible in his
     screenshot: every "pending" row sits a few pixels left of every "won" row.

     ATTEMPT THREE, this one: ONE grid for the WHOLE LIST. The rows are
     display:contents, so the nine cells of three rows are all children of the
     same grid and share its three columns. Now a column really is decided
     once. Every crest is at the same x because it is the same column.

     `max-content` on the status column sizes it to the widest status in the
     list - "postponed - safe" is far wider than "won" - so the widest one
     never overlaps and the narrower ones align to the same right edge.

     Proved by tools/check-reveal-alignment.js, which renders this CSS in a
     real browser and measures the crest and name positions across rows with
     deliberately mixed name lengths and statuses. Its negative control is the
     per-row grid above: it must fail. No more tweaking by eye. */
  .revlist{display:grid;
           grid-template-columns:minmax(0,38%) minmax(0,1fr) max-content;
           column-gap:12px}
  .revrow{display:contents}
  /* The cells carry the row's border and padding, because display:contents
     removes the row box itself - and a flex cell rather than a block one is
     what keeps the three cells of one row the same height, so their top
     borders form a single unbroken line. */
  .revrow>*{display:flex;align-items:center;min-width:0;
            padding:9px 0;border-top:1px solid var(--row-line2)}
  .revname{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block}
  .revrow>.revname{display:block;line-height:1.45}
  /* max-width AND overflow, not just min-width:0. .teamcell sizes to its
     content and will happily overrun its grid track - which put "Nott'm
     Forest" straight through the word "pending" with no gap. */
  .revrow .teamcell{display:flex;align-items:center;gap:8px;justify-content:flex-start;
                    max-width:100%;overflow:hidden;white-space:nowrap}
  /* BJB-203: a revealed club opens its research panel - cursor, hover
     feedback and a focus ring, same convention as .fixrow .side.tap. */
  .revrow .teamcell.tap{cursor:pointer;border-radius:8px}
  @media(hover:hover){.revrow .teamcell.tap:hover{background:var(--line)}}
  .revrow .teamcell.tap:focus-visible{outline:2px solid var(--accent-fg);outline-offset:-2px}
  /* "no pick" is text with a space in it, so on a 360px phone it wrapped and
     made that one row 14px taller than every other. Measured, at three widths,
     because the fault only ever appears at the narrow end. */
  .revrow .teamcell>.muted{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  /* The club name needs its own element to be truncated. As a bare text node
     beside the crest it became an anonymous flex item, which cannot carry an
     ellipsis - so "Nott'm Forest" wrapped onto two lines and made that one row
     taller than every other. */
  .revrow .revteam{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .revrow>span:last-child{justify-content:flex-end;text-align:right;white-space:nowrap}
  /* The crest must not shrink when a name is long - a squashed badge reads as
     a rendering fault rather than a tight column. */
  .revrow .teamcell img,.revrow .teamcell svg{flex:none}
  @media(max-width:400px){
    .revlist{column-gap:8px}
  }

  /* scorebug */
  /* THE SURVIVORS CARD. One full-width card replaced four tiles and a bar on 15
     Aug 2026, because four of those five things were the same fact: the round
     and the gameweek are in the ribbon on every tab, the pot has its own card
     below with the same figure, and the bar underneath was already saying "2 in,
     0 out" straight after two tiles had said it in numbers. How many are left is
     the story of a survival game, so it gets the space rather than a quarter of
     it. */
  .survcard{background:var(--accent-card);border:1px solid var(--accent);border-radius:16px;
    padding:18px 20px;margin-bottom:16px;box-shadow:0 0 28px -14px var(--glow-3)}
  .survtop{display:flex;align-items:center;gap:16px}
  .survnum{font-family:'Saira',sans-serif;font-weight:800;font-size:44px;line-height:.95;color:var(--accent-fg);flex:none}
  .survof{color:var(--accent-soft2);font-size:22px;font-weight:700}
  .survlab{min-width:0}
  .survl{font-family:'Hanken Grotesk',sans-serif;font-weight:700;font-size:13px;letter-spacing:.06em;
    text-transform:uppercase;color:var(--accent-fg)}
  .survs{font-size:12.5px;line-height:1.45;color:var(--text-2);margin-top:3px}
  /* THE SURVIVORS CARD HAD AN INVISIBLE STRIP ALONG THE BOTTOM. It drew a row
     of <span class="seg"> bars, but those are styled only under `.strip` - so
     inside this card they have never rendered anything, at any point. What they
     did contribute was a 14px margin-top under an element of zero height, which
     pushed the "2/2 STILL STANDING" block exactly 7px above the card's centre.
     Measured: card 93.8px, content 41.8px, 18px padding top and bottom, and
     14px of nothing. Product decision, 17 Aug: "it sits too close to the top rather than
     being balanced between the top and bottom."
     The dead markup is removed rather than restyled - a component that has
     never once been visible is not a feature waiting to be switched on. */
  .scorebug{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:16px}
  .sb{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:18px}
  .sb.hot{background:var(--accent-card);border:1px solid var(--accent);box-shadow:0 0 24px -10px var(--glow-3)}
  .sb .n{font-family:'Saira',sans-serif;font-weight:800;font-size:30px;line-height:1}
  .sb.hot .n{color:var(--accent-fg)}
  .sb .l{font-family:'Hanken Grotesk',sans-serif;font-weight:600;font-size:10.5px;letter-spacing:.07em;color:var(--text-3);margin-top:10px;text-transform:uppercase}
  .sb.hot .l{color:var(--accent-soft)}

  /* survivor strip */
  .strip{display:flex;align-items:center;gap:18px;background:var(--panel);border:1px solid var(--border-2);border-radius:14px;padding:16px 18px;margin-bottom:16px;flex-wrap:wrap}
  .strip .segs{display:flex;gap:4px;flex:1;min-width:160px}
  .strip .seg{flex:1;height:10px;border-radius:3px;background:var(--track)}
  .strip .seg.on{background:var(--accent)}

  /* tables (grid rows) */
  .thead,.trow{display:grid;align-items:center;gap:8px;padding:13px 22px}
  .thead{padding-top:10px;padding-bottom:10px;border-top:1px solid var(--border-2);border-bottom:1px solid var(--border-2);font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.14em;color:var(--text-4);text-transform:uppercase}
  .trow{border-bottom:1px solid var(--row-line2);transition:background .15s}
  @media(hover:hover){.trow:hover{background:var(--row-line2)}}
  .trow.you{background:var(--accent-row);border-left:3px solid var(--accent)}
  .trow.dead{opacity:.85}
  .trow .rk{font-family:'Saira',sans-serif;font-weight:800;font-size:18px;color:var(--accent-soft)}
  .trow.r1 .rk{color:var(--accent-fg)}
  .trow .nm{font-weight:700;font-size:15px}
  .trow.dead .nm{font-weight:600;text-decoration:line-through;color:var(--text-2)}
  .ptsval{font-family:'JetBrains Mono',monospace;font-weight:700;font-size:15px;text-align:right}
  .pts{font-family:'JetBrains Mono',monospace;font-weight:700;color:var(--text)}

  /* form dots */
  .form{display:inline-flex;gap:5px}
  .fdot{width:8px;height:8px;border-radius:50%;display:inline-block}
  .fdot.w{background:var(--accent)} .fdot.x{background:var(--danger)} .fdot.n{background:var(--muted-dot)} .fdot.p{background:var(--cyan)}

  /* This GW header: headline + your status. See the comment at the markup. */
  .gwhead{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}
  .gwhead-main{min-width:0}
  .gwstatus{text-align:right;min-width:0;flex:0 0 auto}
  .gwstatus-l{font-size:10px;letter-spacing:.18em;color:var(--text-3);white-space:nowrap}
  /* "Back in when the round resets" is a sentence, not a label - it was nowrap
     and lost 32px of itself at 320px. Let it wrap; nothing below it is fixed. */
  .gwstatus-v{font-weight:800;font-size:18px;margin-top:2px;overflow:visible;white-space:normal}
  @media(max-width:560px){
    .gwhead{display:block}
    .gwstatus{text-align:left;margin-top:14px;padding-top:13px;border-top:1px solid var(--border-2)}
    .gwstatus-v{font-size:20px;margin-top:3px}
  }

  /* match / fixtures */
  .fixrow{display:grid;grid-template-columns:1fr 92px 1fr;align-items:center;gap:10px;padding:14px 22px;border-top:1px solid var(--border-2)}
  .fixrow .side{display:flex;align-items:center;gap:9px;font-weight:700;font-size:14px;min-width:0}
  .fixrow .side.away{justify-content:flex-end;text-align:right}
  .fixrow .side .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
  /* BJB-203: each club in a fixture row opens its own research panel, same
     affordance as .trow.teamrow on the Prem tab - cursor, hover feedback,
     a visible focus ring - .side's own layout is untouched. */
  .fixrow .side.tap{cursor:pointer;border-radius:8px;margin:-4px;padding:4px}
  @media(hover:hover){.fixrow .side.tap:hover{background:var(--line)}}
  .fixrow .side.tap:focus-visible{outline:2px solid var(--accent-fg);outline-offset:-2px}
  .fixrow .mid{font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--text-2);white-space:nowrap;text-align:center;justify-self:center}
  .livescore{display:inline-flex;align-items:center;gap:6px;color:var(--text);font-weight:700}
  .livedot{width:6px;height:6px;border-radius:50%;background:var(--danger);animation:lmsPulse 1.8s infinite;flex:none}
  @media(prefers-reduced-motion:reduce){ .livedot{animation:none} }
  /* FIXTURES ON A PHONE. Measured before this existed, at 393px: the row was
     1fr / 92px / 1fr, and the crest plus its gap ate 35 of each 94px column, so
     a club name got 59px when the longest needs 114. Rendered, that was
     "Arse... v Aston..." and "Bour... v Brent...". On an iPhone SE the name
     column was 23px wide. No amount of trimming padding buys back 55px twice
     over, so side-by-side is simply the wrong shape at this width - it has to
     stack.

     Home above away, both with the crest first so the two lines share one left
     edge, kickoff or score on the right and vertically centred against both.
     Each name now gets the full column, which fits all twenty clubs with room
     to spare. Done in CSS alone: the markup for this row appears twice in the
     file and two copies that must stay identical is how things drift apart. */
  @media(max-width:560px){
    .fixrow{grid-template-columns:1fr auto;grid-template-rows:auto auto;gap:5px 14px;padding:12px 15px}
    .fixrow .side{grid-column:1}
    /* row-reverse so the away crest leads, matching the home line above it;
       flex-end then packs to the LEFT because the main axis is reversed. */
    .fixrow .side.away{flex-direction:row-reverse;justify-content:flex-end;text-align:left}
    .fixrow .mid{grid-column:2;grid-row:1/3;align-self:center;justify-self:end;text-align:right}
  }
  .nm.win{color:var(--accent-fg)}

  /* picker grid */
  /* A CLUB NAME IS NEVER SLICED. The two-column layout and the short opponent
     codes both lived in @media(max-width:720px), so at 721px the grid jumped
     straight to FIVE columns AND switched back to full opponent names in the
     same step: measured at 768px (iPad portrait, and any half-screen desktop
     window), the name column is 67px and "v Nott'm Forest" needs 93px, so
     "Bournemouth" and "Sunderland" - single words, which cannot wrap - were cut
     off mid-word by .nmcol's overflow:hidden. Three columns between 721 and
     900px gives the name column its room back. */
  /* ONE ROW PER FIXTURE, ALWAYS. BJB-334, 4 Sep 2026 (approved:
     LOS-FIXTURE-GROUPED-PICKER-2026-09-03.md). This used to be the grid
     itself - up to five .clubbtn cards per line, ordered by kickoff with an
     alphabetical tiebreak for ties, which is exactly how two fixtures
     sharing a kickoff ended up with their clubs split across different
     lines. The grid now holds FIXTURE ROWS (.fixturerow below), each one
     kept together regardless of width; .pickgrid itself is just their
     vertical stack. Trades the old five-wide density for a pairing
     guarantee that holds at every width, which is what was asked for. */
  .pickgrid{display:flex;flex-direction:column;gap:10px}
  /* Home left, away right, exactly as approved. A row with only one club -
     the second leg of a double-gameweek club whose partner already appeared
     earlier in the grid - gets the full row rather than being squeezed into
     a half-width cell it does not need. */
  .fixturerow{display:grid;grid-template-columns:1fr 1fr;gap:10px}
  .fixturerow:has(.clubbtn:only-child){grid-template-columns:1fr}
  .clubbtn{display:flex;align-items:center;gap:9px;padding:10px 11px;border-radius:12px;text-align:left;width:100%;overflow:hidden;font-family:'Hanken Grotesk',sans-serif;font-weight:700;font-size:13px;background:var(--card);border:1px solid var(--border);color:var(--text);cursor:pointer;transition:transform .12s,border-color .12s}
  /* A club name is never abbreviated by an ellipsis. It wraps to a second line
     instead, at EVERY width - this was first fixed only below 560px, and a
     768px tablet then clipped "Newcastle Utd" in the five-column grid exactly
     the same way. Two tidy lines beat "Newcastle U…" on the one screen where
     somebody stakes 200 pounds. */
  .clubbtn .nm2{overflow:visible;text-overflow:clip;white-space:normal;line-height:1.15;min-width:0}
  .clubbtn .nmcol{display:flex;flex-direction:column;min-width:0;flex:1;overflow:hidden}
  .clubbtn.sel{background:var(--accent-sel);border:1px solid var(--accent);box-shadow:0 0 20px -8px var(--glow-4)}
  /* only on the tile you have JUST chosen - not every time the grid redraws, and
     not on the way back to the tab. Picking is the moment of the whole game. */
  .clubbtn.justsel{animation:clubsettle .34s cubic-bezier(.34,1.4,.5,1)}
  @keyframes clubsettle{0%{transform:scale(.94)}55%{transform:scale(1.035)}100%{transform:scale(1)}}
  .clubbtn.used{background:var(--chip-bg);border:1px solid var(--chip-border);color:var(--text-5);cursor:not-allowed}
  .clubbtn.used .crest{filter:grayscale(.7);opacity:.55}
  .picktag{margin-left:auto;font-family:'Hanken Grotesk',sans-serif;font-weight:700;font-size:10.5px;letter-spacing:.07em;color:#0a1409;background:var(--accent);padding:2px 6px;border-radius:4px;white-space:nowrap}
  /* The selected card's tag IS the lock button now, so it has to look pressable
     rather than like a status label: bigger target, and a slow pulse that says
     "waiting for you" without shouting. */
  .picktag.locktag{padding:5px 10px;border-radius:7px;box-shadow:0 0 0 0 var(--glow-2);animation:lockpulse 1.9s ease-out infinite}
  @keyframes lockpulse{0%{box-shadow:0 0 0 0 var(--glow-1)}70%{box-shadow:0 0 0 9px var(--glow-0)}100%{box-shadow:0 0 0 0 var(--glow-0)}}
  @media(prefers-reduced-motion:reduce){ .picktag.locktag{animation:none} }
  .usedtag{margin-left:auto;font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.06em;color:var(--text-5);white-space:nowrap}
  /* Inside line 1 it is a badge, not a right-aligned column, so it must not
     claim the leftover space the club name is using. */
  .clubbtn .ln1 .usedtag{margin-left:0;flex:none;font-size:9px}
  .chips{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:16px}
  .chip{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--text-3);background:var(--chip-bg);border:1px solid var(--chip-border);padding:4px 9px;border-radius:6px}
  .savebar{display:flex;justify-content:space-between;align-items:center;gap:14px;background:var(--accent-row);border:1px solid var(--border-3);border-radius:12px;padding:14px 16px;margin-top:18px;flex-wrap:wrap}
  .emptypick{text-align:center;font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.08em;color:var(--text-4);border:1px dashed var(--border-3);border-radius:12px;padding:15px;margin-top:18px;text-transform:uppercase}

  /* accent / locked card */
  .acard{background:var(--accent-card);border:1px solid var(--accent);border-radius:16px;padding:22px;margin-bottom:16px;box-shadow:0 0 30px -14px var(--glow-3)}

  /* banners */
  .banner{border-radius:12px;padding:13px 16px;margin-bottom:14px;font-size:13px;font-weight:600}
  .banner.win{background:var(--accent-card);border:1px solid var(--accent);color:var(--accent-fg)}
  .banner.warn{background:var(--warn-bg);border:1px solid var(--warn-border);color:var(--warn-text)}
  .banner.info{background:var(--card);border:1px solid var(--border);color:var(--text-2)}
  .notice{font-size:13px;color:var(--warn-text);background:var(--warn-bg);border:1px solid var(--warn-border);padding:11px 14px;border-radius:10px;margin-top:10px}

  /* legend / footer / messages */
  /* Supporting labels speak in Hanken now, not mono (11 Aug font pass) - the
     legend was missed. Each entry is its own flex row so the coloured dot and
     its word are spaced deliberately instead of touching (Product decision, 12 Aug). */
  .legend{display:flex;gap:16px;flex-wrap:wrap;font-family:'Hanken Grotesk',sans-serif;font-weight:600;font-size:10.5px;letter-spacing:.06em;color:var(--text-4);text-transform:uppercase}
  .legend>span{display:inline-flex;align-items:center;gap:7px}
  .footer{text-align:center;font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.14em;color:var(--text-5);margin-top:22px;text-transform:uppercase}
  .trustrow{display:flex;flex-wrap:wrap;gap:8px 16px;justify-content:center;font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.08em;color:var(--text-4);text-transform:none}
  .trustrow span{white-space:nowrap}
  /* ONLAST:legal */
  .legalrow{display:flex;flex-wrap:wrap;gap:4px 5px;justify-content:center;align-items:center;margin-top:7px;font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:normal;text-transform:uppercase}
  .legalrow a{color:var(--text-4);text-decoration:none;border-bottom:1px solid transparent;display:inline-block;padding:6px 2px 5px;transition:color .15s,border-color .15s}
  @media(hover:hover){.legalrow a:hover{color:var(--accent-fg);border-bottom-color:var(--accent-fg)}}
  .legalrow .sep{color:var(--text-5)}
  /* THE SEASON NOTE, ON THE SAME LINE AS THE LEGAL LINKS.

     TWO CORRECTIONS IN ONE, 19 Aug 2026.

     1. "INVITE ONLY" IS GONE, because it was not true. Sign-up is open to
        anyone, and on the day it was written a real company found the app and
        set up a twelve-person league without an invite from anybody. A footer
        that claims something the software does not do is worse than no footer.

     2. IT SHARES THE LEGAL LINE rather than taking its own. As three separate
        rows the footer ran to four lines on a phone and ate most of the screen
        above the tab bar. The season note is a footnote, not an announcement,
        so it sits at the end of the line the other footnotes are on. */
  /* --- Explore Onlast: the way in, on the front door --------------------- */
  .exploreline{display:flex;align-items:center;gap:10px;margin:18px 0 12px}
  /* The rule was var(--border), which on the auth screen's darker panel came out
     as no line at all - leaving a lone floating "OR" above the button. It was
     seen on desktop on 20 Aug. It borrows the divider colour the form itself
     uses, so it reads as one line broken by a word. */
  /* The rule was var(--border), which against the auth screen's own background
     came out as no line at all - leaving a lone floating "OR" with nothing
     either side of it. seen on desktop on 20 Aug. --border-3 is the
     lighter of the two divider tokens and is what the form itself uses. */
  .explorerule{flex:1;height:1px;background:var(--border-3)}
  .exploreor{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.16em;
    text-transform:uppercase;color:var(--text-5)}
  /* SECONDARY, AND IT HAS TO LOOK IT.                      Product decision, 20 Aug 2026
     "I want the demo discoverable, but signing in/joining the real product
     should remain the primary action." It was full width, 46px tall and the
     same 14px semibold as the real call to action, so on a phone the two read
     as equals. It is now an auto-width pill, centred, a size down and a weight
     down, with the muted text colour. Still one tap, still unmissable, but the
     eye lands on Sign in first. */
  .exploreb{display:inline-flex;align-items:center;justify-content:center;gap:7px;
    width:auto;min-height:38px;padding:0 18px;font-size:13px;font-weight:500;
    color:var(--text-3);border-radius:999px}
  /* HOVER LATCHES ON TOUCH.                              20 Aug 2026
     Caught by check-states.js the moment this rule went in. Every :hover in the
     app sits behind @media(hover:hover) because without it, tapping the button
     on a phone leaves it highlighted as though it were selected - the defect
     BJB-68 item 1 was raised for. One new rule almost put it back. */
  @media(hover:hover){ .exploreb:hover{color:var(--text)} }
  .explorewrap{display:flex;justify-content:center}

  /* --- Explore Onlast: the bar that says what this is --------------------- */
  /* Fixed, so it survives every tab change without a screen having to draw it,
     and low enough contrast that it frames the app rather than shouting over
     it. It still has to be unmissable - a stranger who thinks a demo league is
     their own is a support problem, not a success. */
  .demobar{position:fixed;left:0;right:0;top:0;z-index:60;display:flex;align-items:center;
    /* THE INSET WAS ON THE BOTTOM.                       Product decision, 20 Aug 2026
       Three-value padding is top / sides / bottom, so
       `7px 12px calc(7px + env(safe-area-inset-top))` put the notch allowance
       BELOW the text - which jammed LEAVE DEMO up under the clock, signal and
       battery, and left a tall empty band underneath it. it was photographed
       exactly that. The inset belongs on the TOP, which is where the notch is. */
    justify-content:center;gap:8px;
    padding:calc(7px + env(safe-area-inset-top,0px)) 12px 7px;
    background:var(--accent);color:#0a1409;font-family:'JetBrains Mono',monospace;
    font-size:10px;letter-spacing:.12em;text-transform:uppercase}
  .demobar b{font-weight:800}
  .demosep{opacity:.55}
  .demodot{width:6px;height:6px;border-radius:50%;background:#0a1409;opacity:.8}
  .demotext{opacity:.8}
  .demoexit{margin-left:6px;background:#0a1409;color:var(--accent);border:0;border-radius:999px;
    padding:5px 11px;font-family:inherit;font-size:10px;letter-spacing:.1em;text-transform:uppercase;
    font-weight:700;cursor:pointer;min-height:28px}
  /* The app's own chrome moves down by exactly the bar's height, so nothing is
     hidden underneath it on any screen. */
  /* AND THE OFFSET HAS TO MATCH THE BAR.                 20 Aug 2026
     34px was the bar's height on a phone with no notch. Once the safe-area
     inset moved to the top edge - where it belongs - the bar became taller than
     that on every notched handset, so a flat 34px left the app's own header
     sliding underneath it. Both numbers now come from the same expression, so
     they cannot disagree. */
  /* --demobar-h IS PUBLISHED BY THE BAR ITSELF, measured after it renders and
     re-measured on rotation. The 42px fallback is only for the instant between
     the bar appearing and the first measurement; it is the bar's real height on
     a phone with no notch, so even that instant is not wrong.
     The flat 34px this replaces was short by 8px on EVERY phone, and by the
     height of the notch on a notched one. */
  body.hasdemobar .top{margin-top:var(--demobar-h,42px)}
  body.hasdemobar.authscreen .wrap{padding-top:var(--demobar-h,42px)}
  @media(max-width:380px){ .demotext,.demosep{display:none} }

  /* What's New (BJB-213): the history modal's clothing lives here, not inline -
   app.js pays for every byte on the wire budget and the stylesheet does not. */
/* Guided league creation (BJB-204): the two access choice cards. */
  .clchoice{display:block;width:100%;text-align:left;padding:12px 14px;border-radius:11px;cursor:pointer;background:var(--card);border:1px solid var(--border);color:var(--text)}
  .clchoice b{display:block;font-size:14px;margin-bottom:4px}
  .clchoice[aria-pressed="true"]{border-color:var(--accent);background:var(--accent-sel,var(--card))}
  .clchoice:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
/* Admin's compact dashboard (BJB-204, 23 Aug 2026): four cards, no forms.
     Every colour and face is an existing token - the dashboard may not invent
     an almost-matching treatment of its own. */
  .adcard{display:block;width:100%;text-align:left;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-top:10px;cursor:pointer;color:var(--text);font-family:inherit;transition:border-color .12s,transform .1s}
  .adcard:active{transform:scale(.99)}
  @media(hover:hover){.adcard:hover{border-color:var(--accent)}}
  .adcard-t{display:flex;align-items:center;gap:8px;font-weight:700;font-size:14px}
  .adcard-s{display:block;margin-top:5px;font-size:12px;color:var(--text-3);line-height:1.45}
  .adcard.attn{border-color:var(--warn-border)}
  .adcard.attn .adcard-t{color:var(--warn-text)}
  .adcard.danger{border-color:var(--danger-border);background:var(--danger-zone-bg)}
  .adcard.danger .adcard-t{color:var(--danger-text)}
  .adbadge{font-family:'JetBrains Mono',monospace;font-size:10px;background:var(--warn-bg);border:1px solid var(--warn-border);color:var(--warn-text);border-radius:999px;padding:2px 8px;margin-left:2px}
  /* The focused screen's one compact bar: back on the left, expand controls
     (when they exist) on the right. */
  .adfocbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 12px}
  /* Admin's three groups (BJB-204): This Week / League Setup / Danger Zone. */
  .agroup{margin-top:22px}
  .agroup-t{font-family:'JetBrains Mono',monospace;font-size:11px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;color:var(--text-3);margin:0 2px 8px;display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
  .agroup-hint{font-family:'Hanken Grotesk',sans-serif;font-weight:400;font-size:11px;letter-spacing:.02em;text-transform:none;color:var(--text-4)}
  .agroup.danger .agroup-t{color:var(--danger-text)}
  .wnitem{padding:12px 0;border-bottom:1px solid var(--border-2)}
  .wnhead{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
  /* Unread STATE: a dot, not a second NEW label - the type chips own the
     words. Same accent dot the bell wears, ringed for both themes. */
  .wnun{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--card);flex:none;align-self:center}
  /* The entry TYPE, in the three words a player already knows. Same badge
     language as .badge-inplay; every colour is a theme token. */
  .wnk{font-family:'Hanken Grotesk',sans-serif;font-size:9.5px;font-weight:700;letter-spacing:.09em;padding:3px 8px;border-radius:5px;flex:none}
  .wnk-new{color:var(--accent-fg);background:var(--accent-card);border:1px solid var(--border-3)}
  .wnk-improved{color:var(--cyan);background:var(--chip-bg);border:1px solid var(--chip-border)}
  .wnk-fixed{color:var(--text-3);background:var(--chip-bg);border:1px solid var(--chip-border)}
  .wndate{font-size:11.5px;color:var(--text-4)}
  .wnbody{margin-top:6px;color:var(--text-2);font-size:14px;line-height:1.5}
  .wnempty{color:var(--text-3)}
  .wnsub{font-size:12.5px;color:var(--text-4);margin:2px 0 8px}
  .wnscroll{max-height:min(60vh,480px);overflow-y:auto}
  .legalrow .beta{color:var(--text-5)}
  .legalrow .beta b{font-weight:700;color:var(--text-4)}
  /* BETA SEASON STANDS DOWN ON THE NARROWEST PHONES.             BJB-295.
     The legal row carries six items and wraps to two lines at 375px and below,
     which put the whole footer at 61px against its own 50px budget - measured,
     not estimated, on 1 September 2026. The budget exists because this footer
     once "ran to four lines on a phone and ate most of the screen above the
     tab bar", and 320/375 are exactly the screens where that costs most.

     Product decision, 1 September 2026: Beta Season is the item that gives
     way. Privacy, Terms, Contact and What's New are legal routes, the only
     route to a human, and a product link - none of them may go. The label
     itself is unchanged and still shows from 380px up, so BJB-255's wording
     decision is intact everywhere it is visible.

     THE BREAKPOINT IS 379, NOT 375, AND THAT IS MEASURED. The first approval
     said 375. Swept on the untouched tree, the row does not stop wrapping
     until 380: 320, 375 AND 376 all render 61px on two lines, 380 upward 45px
     on one. A 375 breakpoint therefore left 376-379 still wrapped and still
     over budget. it was extended to 379 on that evidence, so the rule now
     ends exactly where the wrap does.

     The separator goes with it, or the row would end on a dangling middot. It
     is addressed by its own class rather than by position, so re-ordering the
     row cannot silently strand it. */
  @media(max-width:379px){
    .legalrow .beta,.legalrow .sep-beta{display:none}
  }
  .sronly{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
  .legalnote{margin:16px auto 0;max-width:330px;font-size:11.5px;line-height:1.6;color:var(--text-4);text-align:center;text-transform:none;letter-spacing:0}
  .legalnote a{color:var(--text-3);text-decoration:none;border-bottom:1px solid var(--border)}
  @media(hover:hover){.legalnote a:hover{color:var(--accent-fg);border-bottom-color:var(--accent-fg)}}
  .msg{font-size:13px;min-height:18px;font-weight:600} .msg.ok{color:var(--accent-fg)} .msg.err{color:var(--danger-text)}
  /* COLOUR WAS THE ONLY THING SAYING "REFUSED".          BJB-263, 28 Aug 2026.
     .msg.ok and .msg.err were the same 13px bold line in a different colour,
     so "Saved." and "Nothing has been saved" were distinguishable only by hue.
     On the admin screens that costs real work: an admin reads a refusal as a
     confirmation and walks away believing a setting changed when the database
     still holds the old one. Onlast already solved this for the Daily Last
     marks, where every colour is paired with a SHAPE. One rule, every error
     message, no per-site edit and no new component: a text-presentation
     warning sign, which does not enter textContent and therefore changes no
     assertion that reads the words. */
  .msg.err::before{content:"\26A0\FE0E";margin-right:6px;font-weight:700}
  .trophy{color:var(--warn-text);font-size:12px;margin-left:5px;white-space:nowrap}
  .rankno{font-family:'Saira',sans-serif;font-weight:800;color:var(--text-3)}
  /* Prev / label / Next. As a centred flex row on a phone the label was squeezed
     until "GAMEWEEK 1 · THIS WEEK" broke over two lines and both buttons wrapped
     their own arrows onto a second line - three separate pieces of text wrapping
     for no reason in a row eight words long. A grid fixes it properly: the two
     buttons take exactly the width they need and never wrap, and the label gets
     everything that is left, centred between them. */
  .gwsel{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:10px;margin-bottom:12px}
  .gwsel>button{white-space:nowrap}
  .gwsel .lbl{font-family:'Saira',sans-serif;font-weight:800;letter-spacing:.02em;text-align:center;text-transform:uppercase;min-width:0;line-height:1.15}
  .gwsel .lblwhen{display:block;font-family:'JetBrains Mono',monospace;font-weight:400;font-size:10px;letter-spacing:.14em;color:var(--text-4);margin-top:3px}
  @media(max-width:560px){ .gwsel{gap:8px} .gwsel .lbl{font-size:15px} }
  table{width:100%;border-collapse:collapse;font-size:14px}
  th,td{padding:11px 8px;text-align:left;border-bottom:1px solid var(--row-line2);vertical-align:middle}
  th{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.12em;color:var(--text-4);text-transform:uppercase;font-weight:700}
  .feedrow{display:flex;align-items:flex-start;gap:14px;padding:16px 22px;border-top:1px solid var(--border-2)}
  .gwtag{font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:700;color:var(--danger-text);background:var(--danger-bg);border:1px solid var(--danger-border);padding:4px 8px;border-radius:5px;white-space:nowrap}
  .runtiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(88px,1fr));gap:10px}
  .hdtoggle{font-family:'JetBrains Mono',monospace;font-size:11px;font-weight:700;color:var(--text-2);background:var(--card);border:1px solid var(--border);border-radius:6px;padding:6px 11px;cursor:pointer}
  .hdtoggle.on{color:#0a1409;background:var(--accent);border-color:var(--accent)}
  .stat{display:flex;gap:14px;flex-wrap:wrap;margin-top:6px}
  .stat .b{background:var(--card);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:14px;padding:14px 16px;flex:1;min-width:120px}
  .stat .b .n{font-family:'Saira',sans-serif;font-weight:800;font-size:22px}
  .stat .b .l{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.14em;color:var(--text-3);text-transform:uppercase;margin-top:8px}
  .match{display:flex;justify-content:space-between;align-items:center;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:10px 14px;margin-bottom:8px}
  .match .side{display:flex;align-items:center;gap:8px;flex:1}
  .match .side.away{justify-content:flex-end;text-align:right}
  .match .score{font-family:'JetBrains Mono',monospace;font-weight:700;padding:0 12px;white-space:nowrap}
  .match .nm{font-weight:700}
  .modal-bg{position:fixed;inset:0;z-index:100;background:rgba(8,12,10,.46);-webkit-backdrop-filter:blur(18px) saturate(140%);backdrop-filter:blur(18px) saturate(140%);display:flex;align-items:center;justify-content:center;
    padding:calc(16px + env(safe-area-inset-top,0px)) 16px calc(16px + env(safe-area-inset-bottom,0px));animation:lmsFade .22s ease}
  body[data-theme="light"] .modal-bg{background:rgba(206,216,192,.55)}
  @media(prefers-color-scheme:light){body:not([data-theme="dark"]):not([data-theme="light"]) .modal-bg{background:rgba(206,216,192,.55)}}
  @keyframes lmsFade{from{opacity:0}to{opacity:1}}
  body.modal-open{overflow:hidden}
  /* DAILY LAST IS CENTRED, AND SAFELY.            BJB-302, 29 August 2026.

     WHAT THIS REPLACES, AND WHY IT WAS WRONG. The complaint was that the
     panel "starts far below the safe area, leaving excessive dead space above".
     The reading applied on 29 August was that the panel should stop being
     centred, and it shipped as an unconditional align-items:flex-start. On his
     actual iPhone that is worse, not better: the panel now sits under the
     status bar with roughly a quarter of the screen empty BELOW it. The dead
     field was moved, not removed, and a top-aligned sheet reads as unfinished.
     That was a misreading of the complaint and it is withdrawn here.

     WHAT THE COMPLAINT ACTUALLY ASKED FOR is a panel that does not waste the
     screen. The internal compaction and grouping do that work and are kept; the
     POSITION goes back to balanced centring.

     WHY NOT SIMPLY align-items:center, WHICH IS WHAT IT WAS BEFORE. Because
     centring a flex item that is TALLER than its container overflows it equally
     in both directions, and the overflow above the container's start edge
     cannot be scrolled back to - the header, the close and the help control
     become unreachable. That is not hypothetical here: enlarged text is a
     supported condition and it makes this panel taller than a short viewport.

     SO: auto block margins, not align-items. Auto margins absorb the free space
     symmetrically when there IS free space - true centring, balanced top and
     bottom - and resolve to zero when there is none, so the panel anchors to
     the top and every part of it stays reachable. One declaration, both
     behaviours, no media query and no measurement.

     The safe areas are already respected by .modal-bg's own padding, which
     includes env(safe-area-inset-top) and env(safe-area-inset-bottom); auto
     margins distribute what is left INSIDE that padding, so the panel can never
     be pushed under the notch or the home indicator.

     Scoped to this panel by its data attribute, so no other modal moves. */
  #modalRoot[data-panel="daily"] .modal-bg{align-items:flex-start}
  #modalRoot[data-panel="daily"] .modal{margin-block:auto}
  /* THE INBOX OPENS FROM THE BELL.                            BJB-274.
     Owner: "it appears in the centre of the screen with no spatial
     relationship to the control that opened it... A restrained origin
     animation - scale and translate from the bell's own position, with a
     fade - would make the interaction feel intentional."
     --bell-bottom/--bell-right are MEASURED at open time (openInbox()) from
     the bell's own getBoundingClientRect(), not a guessed offset - correct
     under any header height, safe-area inset or text-size setting.
     RESPONSIVE, the two ways named above. Above 720px (a popover suits a
     wide screen) the panel sits anchored near the bell, its own width, not
     centred. At 720px and below (a sheet suits a narrow one) it becomes a
     full-width sheet dropping from the same point, because a small anchored
     popover on a phone is a tap target nobody could reliably reach two-
     handed and a strip of screen either side of it would be dead space.
     One shared backdrop (still darkened, still click-outside-to-close via
     openModal's existing handler) and one shared close/focus lifecycle -
     only POSITION and ANIMATION change for this one panel; every other
     modal in the app is untouched. */
  #modalRoot[data-panel="inbox"] .modal-bg{align-items:flex-start;justify-content:flex-end;
    padding-top:calc(var(--bell-bottom, 60px) + 8px);padding-right:var(--bell-right, 16px);
    animation:lmsFade .16s ease-out}
  #modalRoot[data-panel="inbox"] .modal{transform-origin:top right;animation:lmsInboxIn .16s ease-out}
  @keyframes lmsInboxIn{from{transform:scale(.86) translateY(-10px);opacity:0}to{transform:none;opacity:1}}
  @media(prefers-reduced-motion:reduce){
    /* Fade only, no movement - lmsFade is already opacity-only. */
    #modalRoot[data-panel="inbox"] .modal{animation:lmsFade .16s ease-out}
  }
  @media(max-width:720px){
    /* THE SHEET. Full-width, still dropping from the bell's own row, not
       centred and not the popover's fixed card width. */
    /* justify-content controls the MAIN axis of a row-flex container - it
       distributes free space between items, it does not stretch one item's
       own width. flex:1 is what actually fills the row. */
    #modalRoot[data-panel="inbox"] .modal-bg{padding-left:calc(16px + env(safe-area-inset-left,0px));padding-right:calc(16px + env(safe-area-inset-right,0px))}
    #modalRoot[data-panel="inbox"] .modal{max-width:none;flex:1 1 auto;margin:0}
  }
  @media(min-width:721px){
    /* THE POPOVER. Anchored near the bell, its own width - not a full-screen
       card pretending to be a corner menu. */
    #modalRoot[data-panel="inbox"] .modal{max-width:380px;margin:0}
  }
  .modal{background:var(--card);border:1px solid var(--border);border-radius:18px;max-width:460px;width:100%;max-height:88vh;max-height:82dvh;overflow:auto;-webkit-overflow-scrolling:touch;padding:24px;box-shadow:0 24px 70px -24px rgba(0,0,0,.7);animation:lmsUp .26s ease}
  .modal h3{font-family:'Saira',sans-serif;font-weight:800;font-size:22px;text-transform:uppercase;letter-spacing:.02em;margin:0}
  /* ===== DAILY LAST: THE MODAL CHROME AND THE LOADING SHELL =====
     THIS LIVES IN app.css ON PURPOSE (26 Aug 2026). app.js renders the chrome
     and the loading skeleton, and both must be styled at FIRST PAINT - before
     any Daily Last module has been fetched. It used to live in daily-rules.js,
     which is lazily loaded, so the opening frame was an unstyled row.
     THE HEADER IS ONE ROW. It was two: a full-width bar carrying only the ?
     and the x on the right, with a large empty space beside them, and then the
     title on its own line below - which also put DAILY LAST directly above
     HOW DAILY LAST WORKS on the rules view. */
  .dl-chrome{position:sticky;top:-24px;z-index:5;display:flex;align-items:center;gap:10px;
    margin:-24px -24px 14px;padding:24px 24px 10px;background:var(--card);border-radius:18px 18px 0 0}
  /* THE TITLE HOLDS ONE LINE AT EVERY WIDTH (Product decision, 26 Aug 2026, from
     01-open-320.png: "DAILY LAST wraps onto two lines beside the controls.
     This is not an acceptable finished state in a design/clarity release").

     IT WAS `overflow-wrap:break-word` AT A FIXED 22px. At 320 the row is 320
     minus 48 of modal padding, minus two 44px controls, minus two 10px gaps -
     about 204px of title - and DAILY LAST at Saira 800 22px does not fit, so
     it broke and pushed the modal's first row to two lines.

     A CLAMP, NOT A MEDIA QUERY, and not a smaller fixed size. The width
     available to the title is a function of the viewport, so the type that
     fits it is too: 4.9vw is 15.7px at 320 and 19.1px at 390, and the clamp
     stops it shrinking below 15px on anything narrower or growing past 22px
     on anything wider - so the 390 and 430 screens are unchanged from what
     was approved. `nowrap` makes a regression impossible to miss rather than
     letting it wrap quietly, and min-width:0 keeps the flex item shrinkable
     so the controls never get pushed out of the row.
     Measured at 320/375/390/430 by tools/check-daily-help.js section 1. */
  .modal .dl-title,.dl-screen .dl-title{flex:1;min-width:0;font-family:'Saira',sans-serif;font-weight:800;
    font-size:clamp(15px,4.9vw,22px);
    text-transform:uppercase;letter-spacing:.02em;margin:0;white-space:nowrap}
  /* THE IN-SHELL DAILY LAST DESTINATION.                             BJB-328.
     Daily Last is a dedicated child screen of My Games, hosted in #view rather
     than the global modal. The card is the SAME shape the modal panel gave it
     (card surface, 18px radius, 24px padding, 460px column) so the game modules
     - which were authored for that padded card and are unchanged - lay out
     byte-for-byte as before. It differs from the modal in the two ways a
     destination must: it sits in the page, so the page scrolls it and the fixed
     bottom nav and header stay usable (no backdrop, no scroll lock); and it has
     no max-height/inner scroll of its own. */
  /* PADDING IS THE APP'S ORDINARY .card VALUE, NOT THE MODAL'S.  BJB-328 Unit 1.
     .dl-screen was born as a straight copy of .modal's own dimensions (24px)
     so the unchanged game modules would lay out exactly as they did inside
     the modal - correct for that first landing, but a modal's generous
     padding is not this app's normal page-card padding (.card: 20px 22px),
     and a destination the player scrolls a page around is closer to .card
     than to a raised dialog. Border-radius is left at 18px, unchanged -
     .dl-chrome's own top corners are cut to match it and touching both
     would be a second thing moving for one stated reason. */
  .dl-screen{background:var(--card);border:1px solid var(--border);border-radius:18px;max-width:460px;width:100%;margin:16px auto 8px;padding:20px 22px;box-shadow:0 24px 70px -24px rgba(0,0,0,.5);animation:lmsUp .26s ease}
  /* The chrome stays reachable while the day scrolls: it sticks to the top of
     the viewport (top:0, not the modal's -24px, because in the page there is no
     modal padding above it to hide under). Its negative-margin bleed is
     coupled to the container's OWN padding, so it is overridden here to match
     .dl-screen's - a bleed sized for the modal's 24px inside this 20/22px card
     would fall short of the edge on top/bottom and overshoot it on the sides. */
  .dl-screen .dl-chrome{top:0;margin:-20px -22px 14px;padding:20px 22px 10px}
  /* Back is the way out of a destination - a compact pill on the left of the
     chrome row, matching the app's other Back controls. */
  .dl-back{flex:none;display:inline-flex;align-items:center;gap:5px;font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--text-2);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:8px 12px 8px 9px;cursor:pointer;min-height:44px;transition:transform .09s ease,border-color .12s}
  .dl-back .backchev{font-size:15px;line-height:1;color:var(--accent-fg,#c9ff3a);margin-top:-1px}
  @media(hover:hover){.dl-back:hover{border-color:var(--accent)}}
  .dl-back:active{transform:translateY(1px) scale(.98)}
  /* 44px both, always, with room between them. Scoped to THIS modal's controls
     by their own classes, so no other modal changes size. */
  /* THE HEADING IS THE APPROVED WORDMARK, and it keeps the exact responsive
     behaviour the text had: the same clamp drives the image height, so it
     still shrinks at 320 rather than sitting at one fixed size. The package's
     clear-space rule is one supporting-form width - 14 of 68 units, 20.6% of
     height - and the padding below exceeds that at every clamp value. */
  .dl-titlemark{display:flex;align-items:center;padding:6px 0;white-space:normal}
  .dl-titlemark .dlwordmark{height:clamp(15px,4.9vw,22px);width:auto;max-width:100%}
  /* THE LAUNCH TAKEOVER CARRIES THE SAME WORDMARK, AND MUST CONSTRAIN IT THE SAME
     WAY.                                        BJB-213 hotfix, 31 Aug 2026.
     dl-wordmark--dark.svg is intrinsically 527x68. .dlmark-dark sets display:block
     and nothing else, so the two places that size it do so themselves - .ibname-dl
     at 13px and .dl-titlemark above. The launch popup was written with a bare
     <div style="margin-bottom:10px"> and therefore sized it nowhere: the image
     laid out at its full 527px inside a card about 288px wide and was cut off
     mid-word, reading "DAILY LA". Reported from a real iPhone at 10:04.
     Same clamp as .dl-titlemark on purpose - it is the same identity in the same
     modal chrome, it is already proven across the mobile range, and inventing a
     second scale for one card is how two lockups drift apart. max-width:100% is
     the belt to the clamp's braces: even if a future viewport makes the height
     win, the image can never be wider than the card again. */
  .dl-launchmark{margin-bottom:10px}
  .dl-launchmark .dlwordmark{height:clamp(15px,4.9vw,22px);width:auto;max-width:100%}
  .dl-close,.dl-helpbtn{flex:none;width:44px;height:44px;font-size:17px;float:none}
  /* THE FIRST-VISIT CUE IS NOT A RING, AND THAT IS THE POINT (Product decision,
     26 Aug 2026: "several state captures show a persistent lime double ring
     around ?, while the rules capture does not ... confirm this is only
     :focus-visible").

     IT WAS NOT FOCUS. It was this cue, doing exactly what it was written to
     do - and that is the defect. It drew `box-shadow:0 0 0 2px` OUTSIDE the
     button's own 1px border, so it read as two rings; it is the DEFAULT state
     for every player who has not yet opened the rules, on touch as much as on
     a keyboard; and stacked under the global :focus-visible outline it made a
     literal third ring. A cue that is indistinguishable from a focus state is
     not a quiet cue, it is a broken one.

     A TINT INSTEAD. The button is filled and its own border is coloured -
     one edge, never two - so the ONLY ring this control can ever draw is the
     focus outline, and a lime ring in a capture now means exactly one thing.
     It still clears on the first tap of ?, as it always did (dl_help_seen).
     Asserted by tools/check-daily-help.js section 7. */
  .dl-helpbtn.dl-cue{border-color:var(--accent-dim,#5a7a1e);
    background:rgba(201,255,58,.10);color:var(--accent,#c9ff3a)}
  /* THE LOADING SHELL IS THE REAL LAYOUT, NOT A SMALLER ONE. The modal used to
     open holding a title and a Close button, then swap in a full screen - a
     visible jump on every open. These blocks reserve the same rows the loaded
     screen uses, so the swap changes content and not shape. */
  .dl-skel{display:grid;gap:12px}
  .dl-skel-row{border-radius:10px;background:var(--panel);border:1px solid var(--border-2)}
  .dl-skel-chip{width:64px;height:22px;border-radius:6px}
  .dl-skel-date{width:74%;height:26px}
  .dl-skel-line{width:100%;height:38px}
  .dl-skel-tiles{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
  .dl-skel-tile{height:64px}
  .dl-skel-load{display:grid;place-items:center;padding:6px 0 2px}
  @media(prefers-reduced-motion:no-preference){
    .dl-skel-row{animation:dlSkel 1.4s ease-in-out infinite}
    @keyframes dlSkel{0%,100%{opacity:.55}50%{opacity:.85}}
  }
  .modal .rule{display:flex;gap:12px;padding:13px 0;border-top:1px solid var(--border-2)}
  .modal .rule .ic{width:32px;height:32px;border-radius:9px;background:var(--accent-card);border:1px solid var(--accent);display:grid;place-items:center;flex:none;font-size:15px;color:var(--accent)}
  /* ONE ICON FAMILY (BJB-164 item 2). Stroke glyphs replace Apple's colour
     emoji everywhere the app speaks in its own voice. They inherit colour
     from whatever they sit in, so there is one copy of each, not one per
     state, and they sit on the text baseline rather than above it. */
  .uic{vertical-align:-2px;flex:none}
  /* THE ICON IS CENTRED ON THE MODAL, NOT ONLY INSIDE ITS OWN BOX.
                                                          27 August 2026.
     MEASURED, not assumed. tools/evidence-onboarding-icon.js compares the
     union of the SVG's DRAWN CHILDREN - the visible ink - against the midpoint
     of the modal's content box, at 320, 375, 390 and 430 CSS pixels in both
     themes. It found the mark 20px LEFT of centre at every one of the eight,
     and it found something the audit did not predict: the ink is centred
     inside its container correctly. THE CONTAINER is what sits off-centre.

     Every other child of .onbcard measures 230px inside the same 230px
     content box. This one measured 190 - exactly 40 short, which is the icon's
     own width - with no author rule setting a width on it anywhere in this
     file. width:100% restores it to 230 and the measured delta to zero.

     I HAVE NOT ESTABLISHED WHICH CASCADE PRODUCED THE 190, and I am not going
     to write a confident story about it here. The fix is verified by
     measurement, in both directions, and the measurement now runs on every
     deploy - so if the underlying cause resurfaces somewhere else, it is
     caught there rather than looking like a new bug. */
  .onbic{color:var(--accent);display:grid;place-items:center;height:44px;width:100%}
  .modal .rule .t{font-weight:700;font-size:14px}
  .modal .rule .d{font-size:13px;color:var(--text-2);margin-top:3px;line-height:1.5}
  /* ADMIN SETTINGS: one grid, two widths, labels above. Per-field widths clipped
     their own values - "2026-202", "GBI" - so an admin read the wrong setting. */
  /* THE VERDICT CARD. Deliberately the loudest thing in the app for one day a
     week: this is the moment 200 pounds turns into an answer. */
  .verdict{border-radius:18px;padding:20px 22px;border:1px solid var(--border);background:var(--card);box-shadow:0 18px 44px -28px rgba(0,0,0,.8)}
  .verdict.vwon{border-color:var(--accent);background:var(--accent-card)}
  .verdict.vsafe{border-color:var(--cyan);}
  .verdict.vout{border-color:var(--danger-border);background:var(--danger-bg)}
  .verdict-top{display:flex;align-items:center;gap:16px}
  .verdict-lab{font-family:'Saira',sans-serif;font-weight:900;font-size:clamp(26px,8.5vw,38px);line-height:1;letter-spacing:.01em;text-transform:uppercase}
  .verdict-club{font-family:'Saira',sans-serif;font-weight:800;font-size:19px;margin-top:5px;text-transform:uppercase;letter-spacing:.02em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .verdict-score{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:12px;letter-spacing:.06em;color:var(--text-2);margin-top:5px}
  .verdict-why{font-size:13.5px;line-height:1.55;color:var(--text-2);margin-top:14px}
  .verdict-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:14px;padding-top:12px;border-top:1px solid var(--border-2);font-size:12.5px;color:var(--text-3)}
  .verdict-foot .mono{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:10px;letter-spacing:.12em;color:var(--text-4)}
  .asetgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}
  .asetgrid label{display:flex;flex-direction:column;gap:6px;min-width:0}
  .asetgrid label>span{font-family:'Hanken Grotesk',sans-serif;font-weight:600;font-size:11.5px;letter-spacing:.02em;color:var(--text-3)}
  .asetgrid input,.asetgrid select{width:100%;max-width:100%}
  .asetblock{margin-top:16px;padding-top:14px;border-top:1px solid var(--row-line2)}
  .asetblock-t{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--text-4);margin-bottom:10px}
  .modalclose{float:right;background:transparent;border:1px solid var(--border-3);color:var(--text-2);border-radius:999px;width:30px;height:30px;cursor:pointer;font-size:14px;line-height:1}
  /* ---- PLAYER PROFILE (BJB-120) ---------------------------------------
     A profile COLLECTS things the league table already shows. It deliberately
     reuses the table's own tokens - form dots, badges, IN/OUT - so it cannot
     start saying something the table does not already say. */
  .pfhead{display:flex;align-items:center;gap:13px;margin:4px 0 16px}
  .pfname{font-family:'Saira',sans-serif;font-weight:800;font-size:21px;text-transform:uppercase;letter-spacing:.02em;line-height:1.1;overflow-wrap:anywhere}
  .pfsub{font-size:11.5px;color:var(--text-3);margin-top:5px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
  .pfgrid{display:grid;grid-template-columns:repeat(2,1fr);gap:9px;margin:0 0 4px}
  .pfstat{background:var(--accent-card);border:1px solid var(--border-2);border-radius:12px;padding:11px 13px}
  .pfstat .l{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--text-4)}
  .pfstat .v{font-family:'Saira',sans-serif;font-weight:800;font-size:20px;line-height:1.15;margin-top:4px}
  .pfstat .s{font-size:11px;color:var(--text-3);margin-top:2px}
  .pfsect{padding:14px 0 0;border-top:1px solid var(--row-line2);margin-top:14px}
  .pfsect .l{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--text-4);margin-bottom:9px}
  /* A row that opens a profile. The cursor and the underline on hover are the
     only affordance: a chevron on every row would compete with the form dots. */
  .trow.tap{cursor:pointer}
  .trow.tap:focus-visible{outline:2px solid var(--accent);outline-offset:-2px;border-radius:8px}
  .trow.tap:focus-visible .nm{text-decoration:underline}
  @media(hover:hover){.trow.tap:hover .nm{text-decoration:underline}}
  /* CROSS-LEAGUE "NEEDS YOU" STRIP.                          BJB-175.
     Design System Checklist, applied because this is a new player-facing
     control: 48px minimum target (above the 44 floor), the same 10px radius
     and 1px card border every other row uses, a visible focus ring, and
     meaning carried in WORDS - "no pick" - never in colour alone. The amber
     is emphasis on top of a sentence that already says it, so a colour-blind
     reader loses nothing.
     HOVER IS BEHIND @media(hover:hover), which is the rule check-states.js
     caught being broken once already: a :hover rule outside the query leaves a
     touch device stuck in the hovered state after a tap. */
  .xneed{padding:12px 14px}
  .xneedhead{font-size:10px;letter-spacing:.12em;color:var(--text-4);margin:0 0 8px}
  .xneedrow{display:flex;align-items:center;gap:10px;width:100%;min-height:48px;
    padding:8px 10px;margin:0 0 6px;text-align:left;cursor:pointer;
    background:transparent;border:1px solid var(--border,#1d2c1f);border-radius:10px;
    color:var(--text,#e9f2e9);font:inherit}
  .xneedrow:last-child{margin-bottom:0}
  .xneedrow:focus-visible{outline:2px solid var(--text,#e9f2e9);outline-offset:2px}
  @media(hover:hover){.xneedrow:hover{border-color:var(--text-3,#8b998d)}}
  .xneedname{font-weight:700;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .xneedwhen{margin-left:auto;font-size:12px;color:var(--text-2,#9aa89c);white-space:nowrap}
  .xneedstate{font-size:11px;font-weight:700;color:var(--warn-text,#ffce6a);white-space:nowrap}
  /* THE AVATAR IS A CIRCLE, NOT A WORD.                 BJB-262, 30 Aug 2026.
     .nmtap underlines on hover and focus, which is right for a name and wrong
     for a 30px disc - it would draw a line under the circle. This variant
     cancels that and rings the disc instead, and it is display:flex with
     flex:none so wrapping the avatar in a control does not disturb the chat
     row's alignment. Hover stays behind @media(hover:hover) like everything
     else here. */
  .nmtap.cavtap{display:flex;flex:none;border-radius:50%}
  .nmtap.cavtap:focus-visible{text-decoration:none;outline:2px solid var(--text,#e9f2e9);outline-offset:2px}
  @media(hover:hover){.nmtap.cavtap:hover{text-decoration:none;box-shadow:0 0 0 2px var(--text-3,#8b998d)}}
  .nmtap{cursor:pointer;color:inherit}
  .nmtap:focus-visible{text-decoration:underline}
  @media(hover:hover){.nmtap:hover{text-decoration:underline}}
  /* ---- ONBOARDING: the Add to Home Screen steps ----------------------
     Left-aligned inside a centred card on purpose. These are instructions
     to follow one after another on a phone while looking at Safari's own
     menus, and centred numbered steps are measurably harder to track down
     the page. The numbers are the accent, so the eye finds the next one. */
  .onbsteps{text-align:left;background:var(--accent-card);border:1px solid var(--border-2);border-radius:12px;padding:12px 14px;margin:0 0 16px}
  .onbsteps-t{font-weight:700;font-size:13px;margin:0 0 8px}
  .onbsteps ol{margin:0;padding:0 0 0 20px}
  .onbsteps li{font-size:13px;color:var(--text-2);line-height:1.55;margin:0 0 7px}
  .onbsteps li:last-child{margin-bottom:0}
  .onbsteps li::marker{color:var(--accent-fg);font-weight:700}
  /* ---- PHONE HEADER -------------------------------------------------
     Measured on the live site at 393x852 (BJB-68): the wrapping header
     was 202px tall - 24% of an iPhone screen - because six controls fell
     onto four rows, with "Sign out" stranded alone on the last one. Every
     one of them was ~31px tall, under the 44px minimum for a reliable tap.
     On a phone the bottom tab bar IS the navigation, so the header only
     needs identity and account. Everything hidden here is reachable from
     the profile panel behind the name. Measured after the change: 58px, 7%.
     This block sits after .modalclose and button.tiny deliberately - a media
     query adds no specificity, so it has to come later in the file to win.
     Desktop (>720px) is untouched. */
  @media(max-width:720px){
    header.top{padding:calc(14px + env(safe-area-inset-top,0px)) 14px 0}
    .hdrwrap{flex-wrap:nowrap;gap:8px}
    .lmsbrand{gap:10px;min-width:0}
    .lmstile{width:40px;height:40px;border-radius:12px}
    .lmstile .onlast-mark{width:28px;height:28px}
    .lmstile span{font-size:11px}
    .wordmark{font-size:19px}
    /* 9px was below the point where letter-spaced mono is comfortably readable,
       and it was only that small to fit a season string that has now gone. */
    .subtitle{font-size:10px;letter-spacing:.16em;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
    /* gap 8→6 and no chevron: the Admin gear joining the phone header cost the
       brand column 52px and the league name started clipping. These two give
       ~20px back without shrinking any tap target below 44px. */
    .userbar{flex-wrap:nowrap;gap:6px;flex:none}
    .userchip .chipchev{display:none}
    .userbar select{max-width:96px}
    .userbar .minibtn:not(.keep-on-phone){display:none}
    .userbar .themebtn{display:inline-flex;align-items:center;justify-content:center;min-height:44px;min-width:44px;padding:8px;gap:0}
    .userbar .themebtn .themebtn-txt{display:none}
    .hdr-admin-word{display:none}   /* phone header: the gear speaks for itself */
    /* on a phone the switcher lives in the profile panel, with Browse and + New */
    .userbar .leaguesel{display:none}
    .userbar .minibtn.keep-on-phone{display:inline-flex;align-items:center;justify-content:center;min-height:44px;min-width:44px;padding:8px}
    button.userchip{min-height:44px;padding:9px 10px;max-width:150px}
    /* the profile panel is now the only route to half the app's chrome,
       so its own controls have to be tappable too */
    .modalclose{width:44px;height:44px;font-size:17px}
    button.ghost.tiny{min-height:44px;padding:11px 15px}
    /* THE BUTTONS THEMSELVES. Measured 13 Aug by tools/check-states.js across
       22 screens at 320px and 393px: "Join with code →" came out 40px tall,
       "Browse public leagues →" and "+ Create a new league" 37px - the three
       primary actions on the screen a NEW player meets first, all under Apple's
       44px minimum. .tiny is excluded because it has its own 44px rule above,
       and the confirm dialog already had one; this closes the same gap for the
       ordinary buttons. Padding is untouched, so nothing moves on a screen
       whose buttons already cleared 44px. */
    button.btn:not(.tiny), button.ghost:not(.tiny){min-height:44px}
    /* The password eye was 36px square, and it is the control somebody reaches
       for when they have already mistyped their password once. */
    .pwtog{width:44px;height:44px}
    /* The theme toggle on the front door - the one OUTSIDE .userbar, which had
       its own rule already - measured 32px tall. */
    .themebtn{min-height:44px}
  }

  /* THE NARROWEST PHONES. Placed AFTER the block above on purpose: a media query
     adds no specificity, so nesting it inside was silently overridden by the
     later button.userchip rule in the same block. Measured before this: at 375px
     the brand column was 61px against a wordmark needing 83, and at 320px it
     collapsed to 6px - the header was all controls and no product. The space
     comes off the CONTROLS, which have slack; flooring the text column instead
     just pushes the league name out of its own box (check-states.js caught that
     on five screens). Below 341px the header help button goes too: "How to play"
     is also on the profile panel, the pick section and the no-league screen. */
  @media(max-width:400px){
    button.userchip{max-width:104px;padding:9px 8px}
    .wordmark{font-size:17px;letter-spacing:.03em}
    .userbar{gap:4px}
  }
  /* THE COMPACT HEADER, DELIBERATE (Product decision, 23 Aug 2026 night). Measured
     with the admin header - the worst case, five controls: the full brand
     plus a named chip needs ~400px, the full brand plus an initial chip
     needs ~365px, and the icon-only brand fits 320 with room. So three
     states, each chosen rather than crushed:
       >=400px  logo + wordmark + subtitle + named chip
       364-399  logo + wordmark + subtitle + INITIAL chip (ADMIN badge stands
                down - the gear already says it)
       <=363px  icon-only brand: the tile is the identity, the wordmark
                stands down INTACT rather than rendering half of itself.
     The bell NEVER hides: it is the main route to notifications. Clipping is
     policed by tools/check-clipping.js at 320/375/390/430. */
  /* The weekly Players & pot table must FIT a phone, not scroll it: a
     MARK PAID button half-visible behind a scroll edge reads as a broken
     control, not a scrollable one. The role select and the mono badge give
     up padding nobody reads; the facts keep their size. */
  @media(max-width:560px){
    .asecbody table select{max-width:96px;padding:8px 6px;font-size:13px}
    .asecbody th,.asecbody td{padding-left:5px;padding-right:5px}
    .asecbody td .mono{letter-spacing:.02em}
  }
  @media(max-width:399px){
    button.userchip .chipname{display:none}
    button.userchip .chipini{display:inline}
    button.userchip{max-width:none;padding:9px 12px}
    button.userchip .adminbadge{display:none}
  }
  @media(max-width:363px){
    .lmsbrand>div:last-child{display:none}
    .lmsbrand{gap:0}
  }
  .hero{border-radius:16px;padding:15px 18px;margin-bottom:16px;border:1px solid var(--border);transition:transform .12s}
  .hero[onclick]:active{transform:scale(.99)}
  .hero.in,.hero.fest{background:var(--accent-card);border-color:var(--accent)}
  .hero.warn{background:var(--warn-bg);border-color:var(--warn-border)}
  .hero.out{background:var(--danger-bg);border-color:var(--danger-border)}
  .hero .lab{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.16em;font-weight:700;color:var(--accent-soft);line-height:1.4}
  .hero.warn .lab{color:var(--warn-text)} .hero.out .lab{color:var(--danger-text)}
  .hero .sub{font-weight:700;font-size:15px;margin-top:4px;line-height:1.35}
  .herostat{width:40px;height:40px;border-radius:50%;display:grid;place-items:center;font-weight:800;font-size:18px;background:rgba(127,127,127,.14);flex:none}
  .hero.in .herostat,.hero.fest .herostat{color:var(--accent-fg)} .hero.warn .herostat{color:var(--warn-text)} .hero.out .herostat{color:var(--danger-text)}
  .herocta{font-family:'JetBrains Mono',monospace;font-size:11px;font-weight:700;letter-spacing:.06em;color:var(--accent-fg);flex:none;white-space:nowrap}
  /* THE HERO SUBTITLE WRAPPED ON EVERY PHONE, and this is the card that tells a
     player to make their pick. Measured 16 Aug: "GW1 · locks in 3d 21h 13m"
     needs 224.6px on one line and was given 109px at 320, 164px at 375 and
     219px at 430 - so it broke onto two lines everywhere and three at 320,
     orphaning the minutes on a line of their own.
     The row is icon + text + call-to-action, all on one line, and the text was
     the only part allowed to shrink. Letting the ROW wrap instead drops the
     call-to-action to its own line exactly when it does not fit, and the
     subtitle then gets the full width of the card - 285px at 375 - which is
     more than it needs. Nothing shrinks and no wording changes.
     min() caps it: if the text genuinely exceeds the card (a long club name in
     the picked state) it falls back to 100% and wraps inside the card rather
     than overflowing it. */
  /* THE CALL-TO-ACTION STAYS ON THE ROW. An earlier fix on 16 Aug moved it to
     its own line so a long subtitle could have the full width. It worked, and
     it made the card taller - Product decision, 17 Aug: "noticeably thicker than it used
     to be". The subtitle that needed the room has since been removed from the
     no-pick hero, because the ribbon above already counts down. So the row is
     one line again, which is the most compact this card has ever been. */
  .hero>div{flex-wrap:wrap}
  /* THREE SHORT LINES, IDENTICAL ON EVERY CARD: the club, who it is playing and
     which side of the fixture it is on, and when it kicks off. Nothing wraps and
     nothing shrinks - the club name is the only thing allowed to ellipsis, and
     it is the only thing on its line, so in practice it never has to. Putting
     the kickoff beside the name crushed "Bournemouth" to "B..."; putting all
     three on one line made the Monday fixtures taller than the rest. */
  /* ---- THE PICK TILE, TWO LINES (16 Aug 2026) --------------------------
     line 1   the club, and one letter for home or away
     line 2   kick-off, opponent, recent form
     The 15 Aug tile spent one line on the words HOME and AWAY and another on the
     kick-off. That is a 76px card carrying four facts in three stacked rows, and
     the pill sat hard against the opponent code with nothing between them.
     The only thing here allowed to give way under pressure is the form dots: the
     time and the opponent are facts a player is deciding on, and the club name
     wraps rather than ellipsising, which it has done since 12 Aug. */
  .clubbtn .ln1{display:flex;align-items:baseline;gap:6px;min-width:0}
  .clubbtn .ln2{display:flex;align-items:center;gap:6px;margin-top:4px;flex-wrap:nowrap;overflow:hidden;white-space:nowrap;min-width:0}
  .clubbtn .l2t,.clubbtn .l2o{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:9.5px;
    letter-spacing:.01em;font-weight:700;white-space:nowrap;flex:none}
  .clubbtn .l2t{color:var(--text-3)}
  .clubbtn .l2o{color:var(--text-4)}
  /* H or A. A single letter beside the name reads as a badge, costs a quarter of
     the width of the word, and matches every other three-character column in the
     app. The letter is aria-hidden; the button's accessible name says "home to
     Coventry" in full, so nothing is lost to a screen reader. */
  .clubbtn .ha{flex:none;display:inline-grid;place-items:center;width:16px;height:16px;border-radius:5px;
    font-family:'JetBrains Mono',ui-monospace,monospace;font-size:9.5px;font-weight:700;line-height:1}
  .clubbtn .ha.h{background:var(--accent-card);color:var(--accent-fg);border:1px solid var(--border-2)}
  .clubbtn .ha.a{background:transparent;color:var(--text-4);border:1px solid var(--border-3)}
  /* One height for all twenty, so the grid is a grid rather than a ragged wall.
     Two lines instead of three, so 76 comes down to 64. */
  .clubbtn{min-height:64px;align-items:center}
  /* MEASURED, NOT GUESSED. At 360px the name column is 73px, of which the
     home/away badge and its gap take 22 - so a club name has 51px, and
     "Man City" needs 55 at 13px and broke at its own space onto a second line.
     Two clubs wrapping is what makes a grid of twenty look ragged. Half a point
     of type below 400px buys the four pixels, and 12.5px is still larger than
     anything else on the card. */
  @media(max-width:399px){
    .clubbtn{gap:7px;padding:9px 9px}
    .clubbtn .nm2{font-size:12.5px}
  }
  /* 8px was the smallest type in the whole app, and it was carrying real
     information - who your club is actually playing - on the control a player
     uses to stake 200 pounds. It is 9.5px now, and .clubbtn below gives it the
     room by trimming its own padding and gap on a phone rather than by
     shrinking the words. Measured at 393px: the longest, "@ Nott'm Forest",
     fits without an ellipsis. */
  .clubbtn .oppcode{display:none}
  .metagrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}
  .metacard{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:12px 14px}
  .metacard .ml{font-family:'Hanken Grotesk',sans-serif;font-weight:600;font-size:10.5px;letter-spacing:.06em;color:var(--text-4);text-transform:uppercase}
  .metacard .mv{display:flex;align-items:center;gap:7px;font-weight:800;font-size:16px;margin-top:5px}
  .metacard .mv.gold{color:var(--gold)}
  .metacard .ms{font-size:11px;color:var(--text-3);margin-top:2px}
  .bdg{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:700;background:var(--accent-card);border:1px solid var(--accent);color:var(--accent-fg);border-radius:999px;padding:4px 10px;white-space:nowrap}
  .korow{display:flex;gap:12px;align-items:flex-start;padding:12px 22px;border-top:1px solid var(--row-line2)}
  .koppl{display:flex;flex-wrap:wrap;gap:7px;flex:1}
  .kochip{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;background:var(--card);border:1px solid var(--border);border-radius:999px;padding:4px 11px 4px 4px}
  .komiss{display:inline-grid;place-items:center;width:18px;height:18px;border-radius:50%;background:var(--danger-bg);color:var(--danger-text);font-size:10px;font-weight:800}
  .clubtrack{display:flex;flex-wrap:wrap;gap:7px}
  .ctk{display:inline-flex;align-items:center;gap:5px;font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:700;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:4px 8px}
  /* ---- KIT PATTERNS ON AN AVATAR (16 Aug 2026) -------------------------
     The tile sets --a2, an overlay derived from its own palette entry: dark
     over a light colour, light over a dark one. Every pattern is one gradient
     on top of a flat background-color, so an avatar is still a single span with
     no image, at any size from the 22px one in Chat to the 52px on a profile.
     Deliberately restrained: these read as a shirt at 52px and as a texture at
     22px, which is the right way round - the league table must stay legible. */
  /* Both the avatar itself (.av) and the picker chip (.avopt) wear these, so the
     chip previews exactly what the league table will draw. Scoping the rules to
     .av alone left every chip in the style row painting nothing at all - five
     buttons offering five patterns and showing none of them. */
  .pat-stripe{background-image:repeating-linear-gradient(90deg,transparent 0 22%,var(--a2) 22% 44%)}
  .pat-hoop{background-image:repeating-linear-gradient(180deg,transparent 0 24%,var(--a2) 24% 48%)}
  .pat-half{background-image:linear-gradient(90deg,transparent 0 50%,var(--a2) 50% 100%)}
  .pat-sash{background-image:linear-gradient(135deg,transparent 0 36%,var(--a2) 36% 64%,transparent 64% 100%)}
  .pat-quarter{background-image:conic-gradient(var(--a2) 0 25%,transparent 25% 50%,var(--a2) 50% 75%,transparent 75% 100%)}
  /* THE 23 AUG ADDITIONS (Product decision: more variety, still Onlast's own visual
     language - so every one is a real football-kit device, drawn by the
     browser from the same two colours as the rest). */
  .pat-pinstripe{background-image:repeating-linear-gradient(90deg,transparent 0 15%,var(--a2) 15% 21%)}
  .pat-band{background-image:linear-gradient(180deg,transparent 0 36%,var(--a2) 36% 62%,transparent 62% 100%)}
  .pat-chevron{background-image:linear-gradient(45deg,transparent 0 40%,var(--a2) 40% 66%,transparent 66% 100%),linear-gradient(-45deg,transparent 0 40%,var(--a2) 40% 66%,transparent 66% 100%);background-size:51% 100%,51% 100%;background-position:left center,right center;background-repeat:no-repeat}
  /* The initial sits on top of a patterned tile, so it needs a hair of
     separation from a band running underneath it. A shadow in the tile's own
     background colour, which is always the colour the foreground was chosen
     against. */
  .pat-stripe,.pat-hoop,.pat-half,.pat-sash,.pat-quarter,.pat-pinstripe,.pat-band,.pat-chevron{text-shadow:0 1px 2px rgba(0,0,0,.35)}
  .ctk.used{color:var(--text-4);text-decoration:line-through}
  /* A tappable club chip on the pre-season Prem tab. The plain .ctk is a label;
     this one opens the club, so it has to look and feel like a control. */
  .ctk.tap{cursor:pointer;transition:border-color .12s ease,transform .12s ease;min-height:34px}
  @media(hover:hover){.ctk.tap:hover{border-color:var(--accent-border,var(--accent))}}
  .ctk.tap:active{transform:scale(.96)}
  .ctk.tap:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
  .ctk.used .crest{filter:grayscale(.8)}
  .confetti{position:fixed;top:-12px;width:9px;height:14px;border-radius:2px;z-index:9999;pointer-events:none;animation:cfall linear forwards}
  @keyframes cfall{to{transform:translateY(105vh) rotate(540deg);opacity:.9}}
  /* BJB-130. The gap between two DIFFERENT people is what separates one thought
     from the next, so it is bigger than it was. The gap inside one person's run
     is smaller, so four messages fired off in a row read as one turn rather than
     four blocks. Both numbers moved together - widening one without tightening
     the other just makes the thread longer. */
  .chatscroll{max-height:54vh;max-height:52dvh;overflow-y:auto;display:flex;flex-direction:column;gap:14px;padding:4px 2px 8px}
  /* AV is the avatar width, GAP the space after it. The name line and the
     reaction row are indented by exactly AV+GAP so all three left edges agree. */
  .chatmsg{--av:30px;--gap:9px;display:block}
  .cline{display:flex;gap:var(--gap);align-items:flex-start}
  .chatmsg.mine .cline{flex-direction:row-reverse}
  .chatmsg.runon{margin-top:-11px}
  /* .cavatar now sizes ONLY the run-on spacer (an empty aria-hidden box that
     holds the column while the same sender carries on). BJB-314: the real
     avatar moved to avatarHtml() - the shared component every other identity
     surface calls - so this class no longer paints anybody; it just reserves
     the same 30px this column has always been. .cavem (the old emoji tint
     treatment) is gone with the hand-built branch it belonged to; avatarHtml()
     supplies its own emoji tint via .avtint. */
  .cavatar.spacer{background:transparent;border:none;box-shadow:none}
  .cavatar{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;font-weight:800;font-size:12px;flex:none}
  /* The wrapper around the bubble is already capped at 84%, and this used to cap
     it again at 78% of THAT - so a bubble could never use more than about 65% of
     the width and short messages like "good luck all" wrapped onto two lines for
     no reason. One cap, on the wrapper, is enough. */
  /* fit-content, not 100%. A four-letter message in a box sized by the line
     above it is the "too much space in the message box" was reported. */
  .cbubble{max-width:82%;width:fit-content;background:var(--card);border:1px solid var(--border);border-radius:13px;padding:7px 11px;cursor:pointer}
  .cbubble:active{border-color:var(--text-4)}
  .chatmsg.mine .cbubble{background:var(--accent-card);border-color:var(--accent)}
  .cmeta{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.03em;color:var(--text-4);margin-bottom:4px;margin-left:calc(var(--av) + var(--gap))}
  .chatmsg.mine .cmeta{text-align:right;margin-left:0;margin-right:calc(var(--av) + var(--gap))}
  /* pre-wrap so the newlines people can now type actually survive to the
     bubble - without it, a carefully broken message renders as one blob. */
  .cbody{font-size:14px;line-height:1.4;word-wrap:break-word;overflow-wrap:anywhere;white-space:pre-wrap}
  .annbody{white-space:pre-wrap}
  .cdel{color:var(--danger-text);cursor:pointer;display:inline-block;padding:8px 4px;margin:-8px -4px;line-height:1.1}
  /* The same action as .cdel, but shown beside the emoji picker rather than in
     every message header. Sized as a chip so it lines up with them. */
  .rchip.cdelchip{color:var(--danger-text);font-family:'Hanken Grotesk',sans-serif;font-weight:600;font-size:11px;letter-spacing:.04em;background:transparent;border:1px dashed var(--danger-border,var(--border-3))}
  .chatbar{display:flex;gap:8px;margin-top:10px;border-top:1px solid var(--border-2);padding-top:12px}
  /* min-width:0 for the same reason as the header: a flex child will not shrink below
     its intrinsic width without it, so the chat bar sat 9px wider than its panel */
  /* textarea now, not input. 16px because anything smaller makes iOS Safari
     zoom the whole page on focus. border-radius stays round until the box
     grows past one line, at which point a pill looks wrong. */
  .chatbar textarea{flex:1;min-width:0;padding:11px 14px;border-radius:20px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:16px;font-family:inherit;line-height:1.35;resize:none;overflow-y:auto;max-height:120px}
  .chatbar textarea:focus{outline:2px solid var(--accent);outline-offset:1px}
  .chatbar .btn{align-self:flex-end}
  .chatbar input{flex:1;min-width:0;padding:11px 14px;border-radius:999px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:16px}
  .mention{color:var(--accent-fg);font-weight:700}
  /* noticeboard: pinned admin announcements atop Chat */
  .annstrip{border-color:var(--accent-border,var(--border))}
  .anncard{border-left:3px solid var(--accent-fg);background:var(--card);border-radius:0 10px 10px 0;padding:10px 12px;margin-top:8px}
  .anncard:first-of-type{margin-top:0}
  .annbody{font-size:14px;line-height:1.55;cursor:pointer;word-break:break-word}
  .annclamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
  .reactbar{display:flex;flex-wrap:wrap;gap:5px;margin-top:5px;align-items:center}
  /* Indented to the same edge as the bubble, for the same reason as .cmeta. */
  .chatmsg .reactbar{margin-left:calc(var(--av) + var(--gap))}
  .chatmsg.mine .reactbar{justify-content:flex-end;margin-left:0;margin-right:calc(var(--av) + var(--gap))}
  .rchip{font-size:12px;background:var(--card);border:1px solid var(--border);border-radius:999px;padding:4px 10px;cursor:pointer;user-select:none}
  .rchip.mine{background:var(--accent-card);border-color:var(--accent);color:var(--accent-fg)}
  /* The add-reaction control is an ACTION, not a reaction. It used to be a
     smiley chip styled exactly like a real one, which on a lone pinned
     announcement with no reactions yet read as "somebody already reacted, and
     I can't remove it" (Product decision, 12 Aug). A dashed outline and a plain plus
     can only mean "add one". */
  .rchip.addr{color:var(--text-4);font-family:'Hanken Grotesk',sans-serif;font-weight:600;font-size:11px;letter-spacing:.04em;background:transparent;border:1px dashed var(--border-3);padding:4px 11px}
  @media(hover:hover){.rchip.addr:hover{color:var(--text-2);border-color:var(--text-4)}}
  /* BJB-130. This control used to sit under EVERY message, reacted to or not.
     Eleven messages meant eleven dashed pills, and they were most of why the
     thread read as a stack of blocks rather than a conversation - each one added
     a row of chrome as tall as a short message. It now appears only where a
     reaction already exists, or while the picker is open. Tapping the bubble is
     what opens the picker, which is one gesture instead of a permanent button. */
  .rpick{display:inline-flex;gap:4px;background:var(--card);border:1px solid var(--border-3);border-radius:999px;padding:3px 8px}
  .rpick span{cursor:pointer;font-size:15px;line-height:1.1}
  /* BJB-259: REACT WITH ANY EMOJI, KEEPING THE QUICK ROW.
     The quick row above is untouched - it is the one-tap path and it stays
     first. One MORE control follows it (an action, so it wears the same
     dashed .addr outline as the add control: it can only mean "open more",
     never "somebody reacted"), and opens a curated, categorised grid BELOW
     the bar. The grid is the browser's own emoji - no vendor, no external
     file, the same philosophy as the kit avatars. Every tile is a real
     <button>, so Tab/Enter/Space work without a line of script, and the
     tiles are 36px - denser than a 44px control because a grid of them is
     scanned, not aimed at, and the quick row it sits under is 15px glyphs.
     The MORE control lives INSIDE the pill, last, behind a hairline: as a
     separate chip it wrapped alone onto its own line at 393px. */
  .rpick .rmore{background:transparent;border:0;border-left:1px solid var(--border-3);border-radius:0;margin-left:3px;padding:0 3px 0 8px;color:var(--text-4);font-size:15px;line-height:1.1;cursor:pointer;font-family:inherit;font-weight:700}
  .rpick .rmore[aria-expanded="true"]{color:var(--text-2)}
  .rpick .rmore:focus-visible{outline:2px solid var(--accent);outline-offset:1px;border-radius:6px}
  @media(hover:hover){.rpick .rmore:hover{color:var(--text-2)}}
  .rgrid{flex-basis:100%;margin-top:6px;background:var(--card);border:1px solid var(--border-3);border-radius:12px;padding:6px 8px 8px;max-height:224px;overflow-y:auto;-webkit-overflow-scrolling:touch}
  .rgcat h6{margin:6px 2px 2px;font-family:'Hanken Grotesk',sans-serif;font-weight:600;font-size:10px;line-height:1.2;letter-spacing:.06em;text-transform:uppercase;color:var(--text-4)}
  .rgcat:first-child h6{margin-top:2px}
  .rgrow{display:flex;flex-wrap:wrap;gap:2px}
  .rge{background:transparent;border:0;border-radius:8px;font-size:20px;line-height:1;padding:0;min-width:36px;min-height:36px;cursor:pointer;color:inherit}
  .rge:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
  @media(hover:hover){.rge:hover{background:var(--accent-card)}}
  /* Install instructions read as numbered steps, not a paragraph - the
     difference between something you follow and something you skim. */
  /* Google's own button guidance: white surface, its four-colour mark, text
     that says Continue with Google. Full width so it reads as the primary
     route rather than an afterthought beside the email form. */
  .oauthbtn{display:flex;align-items:center;justify-content:center;gap:10px;width:100%;min-height:48px;padding:12px 16px;border-radius:12px;border:1px solid #dadce0;background:#fff;color:#1f1f1f;font-family:'Hanken Grotesk',sans-serif;font-weight:700;font-size:15px;cursor:pointer}
  .oauthbtn:active{transform:scale(.98)}
  .oauthor{display:flex;align-items:center;gap:10px;margin:16px 0 4px;color:var(--text-4);font-size:11.5px}
  .oauthor:before,.oauthor:after{content:'';flex:1;height:1px;background:var(--border)}
  .installsteps{margin:0;padding-left:0;list-style:none;counter-reset:ist}
  .installsteps li{counter-increment:ist;position:relative;padding:0 0 12px 34px;font-size:13.5px;line-height:1.55;color:var(--text-2)}
  .installsteps li:last-child{padding-bottom:0}
  .installsteps li:before{content:counter(ist);position:absolute;left:0;top:0;width:23px;height:23px;border-radius:50%;background:var(--accent);color:#0a1409;font-family:'Hanken Grotesk',sans-serif;font-weight:800;font-size:11.5px;display:flex;align-items:center;justify-content:center}
  /* the contextual nudge on Home */
  /* A section that still needs you: amber spine and heading, subtitle carries
     the deadline. Loud enough to catch the eye on arrival, quiet enough that
     it is not a second banner. */
  /* THE SECTION-LEVEL URGENT TREATMENT IS GONE.        BJB-302, 30 Aug 2026.
     It was set from the day's state and, once the answer was cached, survived
     refresh, return and resume - so a treatment that meant "right now" became
     permanent furniture. Nothing sets .lsec.urgent any more; the rules go with
     it rather than lingering as dead CSS a future state could re-enable by
     accident. Urgency is the filled button on the card's action, which is
     re-decided every time the day is read. */
  /* THE SAME PULL, IN THE BRAND COLOUR. `urgent` is amber, and amber is what
     this app uses for "something is wrong". Make your pick is not something
     going wrong - it is the thing the player came to do - and it was the only
     amber primary action in a product where every other primary is lime. */
  .lsec.primed{border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}
  .lsec.primed .lsectitle{color:var(--accent-fg)}
  .lsec.primed .lsecsub{color:var(--accent-fg);opacity:.85}
  /* THE SELECTED CARD IS THE CREST AND THE ACTION, NOTHING ELSE. The club name
     and its fixture line used to sit behind the Lock In button and get clipped
     to "Arse / Fri / 20:0". The crest is the club, and it keeps its own tap
     target so a change of mind is still one tap. */
  .clubbtn.sel{gap:10px}
  .clubbtn.sel .selcrest{flex:none;display:grid;place-items:center;cursor:pointer;border-radius:9px;padding:1px}
  .clubbtn.sel .selcrest:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
  .clubbtn.sel .locktag{flex:1 1 auto;justify-content:center;text-align:center;min-height:38px;display:flex;align-items:center}
  /* Wraps rather than squeezing: at 393px the first version forced the text
     into a five-word-wide column beside the icon and grew to a third of the
     screen. Text on its own line, actions beneath it. */
  .installnudge{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;background:var(--card);border:1px dashed var(--border-3);border-radius:12px;padding:12px 14px;margin:14px 0 0}
  .installnudge .initxt{flex:1 1 100%;min-width:0;font-size:12.5px;line-height:1.45;color:var(--text-3)}
  .installnudge .inacts{display:flex;align-items:center;gap:6px;margin-left:auto}
  .installnudge .indismiss{background:none;border:none;color:var(--text-4);font-size:12px;cursor:pointer;padding:6px 10px;min-height:44px}
  /* A system line is a change of subject, not a message, so it gets air on both
     sides rather than sitting in the run like another player speaking. */
  .sysline{display:flex;justify-content:center;margin:6px 0}
  /* JUMP TO LATEST FLOATS. It used to be a block in the normal flow, so the
     moment it appeared it pushed the composer down by its own height plus a
     margin - a small jolt every time you scrolled up. Product decision, 22 Aug 2026:
     "not a functional glitch but does make the interface feel less polished."

     Absolutely positioned inside .chatwrap, which contains ONLY the scroll
     area, so it can never reach the composer no matter how tall the reply bar
     or the announcement row becomes. It adds no height at all, so nothing can
     move when it appears.

     pointer-events:none on the strip, auto on the chip: the strip spans the
     full width and would otherwise swallow taps on the last message behind
     it. Only the chip itself is clickable. */
  .chatwrap{position:relative}
  .chatjump{position:absolute;left:0;right:0;bottom:10px;margin:0;z-index:3;
            display:flex;justify-content:center;pointer-events:none}
  .chatjump .pchip{pointer-events:auto;box-shadow:0 6px 18px -6px rgba(0,0,0,.55)}
  /* BJB-130 item 3. One date per day, across the thread, instead of the date
     repeated in the header of every message that happens to be from another
     day. A hairline label rather than a pill: a pill reads as something someone
     said, and this is the thread telling you where you are. */
  .daydiv{display:flex;align-items:center;gap:10px;margin:14px 2px 2px}
  .daydiv::before,.daydiv::after{content:"";flex:1;height:1px;background:var(--border)}
  .daylabel{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--text-4);white-space:nowrap}
  .syspill{font-size:11.5px;color:var(--text-3);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:5px 13px;text-align:center}
  .syscard{border-radius:14px;padding:11px 14px}
  .sysreveal{background:var(--accent-card);border:1px solid var(--accent)}
  .sysdrama{background:var(--danger-bg);border:1px solid var(--danger-border)}
  .sysgold{background:var(--warn-bg);border:1px solid var(--warn-border)}
  .systag{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.12em;color:var(--accent-soft);margin-bottom:5px}
  .sysdrama .systag{color:var(--danger-text)} .sysgold .systag{color:var(--warn-text)}
  .sysbody{font-size:14px;font-weight:600;line-height:1.4}
  .promptchips{display:flex;gap:7px;overflow-x:auto;padding:10px 0 4px;-webkit-overflow-scrolling:touch}
  .pchip{font-size:12px;color:var(--text-2);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:6px 12px;white-space:nowrap;cursor:pointer;flex:none}
  /* THE CODE IS A THING YOU COPY, so it carries its own copy control (Product decision,
     14 Aug). Copy link was already there, but somebody reading the code out in
     a group chat had to select six characters by hand on a phone, which is
     fiddly and easy to get wrong by one character. The control sits INSIDE the
     code panel rather than under it, because it belongs to the code and not to
     the panel - the same reasoning as LOCK IN living on the club card. The code
     stays optically centred: the button is absolutely positioned, so it does
     not push the letters off centre. */
  .invitecode{position:relative;font-family:'JetBrains Mono',monospace;font-weight:800;font-size:clamp(19px,7vw,30px);letter-spacing:.18em;text-align:center;background:var(--accent-card);border:1px solid var(--accent);color:var(--accent-fg);border-radius:12px;padding:14px 52px}
  .invitecode>span{display:block;overflow:hidden;text-overflow:ellipsis}
  .codecopy{position:absolute;top:50%;right:8px;transform:translateY(-50%);display:grid;place-items:center;width:44px;height:44px;padding:0;border-radius:10px;border:1px solid var(--border-3);background:transparent;color:var(--accent-fg);cursor:pointer;transition:transform .09s ease,border-color .12s}
  @media(hover:hover){.codecopy:hover{border-color:var(--accent)}}
  .codecopy:active{transform:translateY(-50%) scale(.94)}
  .codecopy svg{width:18px;height:18px;display:block}
  .codecopy.done{border-color:var(--accent);color:var(--accent-fg)}
  .invitelink{font-family:'JetBrains Mono',monospace;font-size:12px;word-break:break-all;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:10px 12px;color:var(--text-2)}
  .featcard{border-color:var(--gold) !important;box-shadow:0 0 18px -10px var(--gold)}
  .adminnav{display:flex;flex-wrap:wrap;gap:6px;margin-top:14px}
  .anav{font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:700;letter-spacing:.04em;background:var(--card);border:1px solid var(--border);color:var(--text-2);border-radius:999px;padding:6px 11px;cursor:pointer;white-space:nowrap}
  /* The admin jump-nav measured 26px tall on a phone. It is eight chips in a
     wrapping row, so a flat 44px would turn a convenience into four rows of
     furniture; 40px with the row's own gap gives a comfortable target without
     that. Deliberately NOT 44: recorded here so the decision is arguable rather
     than accidental. */
  @media(max-width:720px){ .anav{min-height:40px;padding:10px 12px} }
  @media(hover:hover){.anav:hover{border-color:var(--accent);color:var(--accent-fg)}}
  @media(hover:hover){.anav.danger:hover{border-color:var(--danger-border);color:var(--danger-text)}}
  .asec{background:var(--panel,var(--card));border:1px solid var(--border);border-radius:14px;margin-bottom:10px;overflow:hidden}
  .asec.danger{border-color:#5a2330}
  .asechead{width:100%;display:flex;justify-content:space-between;align-items:center;gap:10px;padding:15px 20px;background:none;border:none;color:var(--text);font-family:'Saira',sans-serif;font-weight:800;font-size:15px;text-transform:uppercase;letter-spacing:.02em;cursor:pointer}
  .asec.danger .asechead{color:var(--danger-text)}
  .achev{font-size:13px;color:var(--text-4)}
  .asecbody{padding:0 20px 18px}
  .exrow{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:12px 0;border-top:1px solid var(--row-line2)}
  /* collapsible league sections */
  .lsec{overflow:hidden}
  .lsechead{width:100%;display:flex;justify-content:space-between;align-items:center;gap:12px;padding:18px 22px;background:none;border:none;color:var(--text);cursor:pointer;text-align:left}
  .lsectitle{font-weight:800;font-size:18px;letter-spacing:.02em;text-transform:uppercase}
  .lsecsub{font-family:'Hanken Grotesk',sans-serif;font-weight:600;font-size:10.5px;letter-spacing:.06em;color:var(--text-4);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .lsecbody{padding:0 22px 20px}
  /* systemic: an input sized in px (or with a long placeholder) was spilling out of
     its card on a phone. Nothing in this app ever wants a control wider than its box. */
  input,select,textarea,button{max-width:100%;box-sizing:border-box}
  .honrow{padding:11px 0;border-top:1px solid var(--row-line2)}
  .honname{font-weight:700;font-size:14px;margin-bottom:8px}
  .honbadges{display:flex;gap:6px;flex-wrap:wrap}

  /* auth / login */
  .auth{min-height:84vh;min-height:84dvh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;animation:lmsUp .4s ease;padding:40px 16px}
  /* THE FRONT DOOR ON A DESKTOP (BJB-164 item 5, 16 Aug 2026).
     Hidden below 900px and it is the ONLY thing that changes: the mark, the
     wordmark and the sign-in card are identical at every width, so the screen a
     phone shows is still the screen that was designed. Above 900px the column
     becomes two, and the empty half of a 1440px window carries the product
     instead of nothing. */
  .authstory{display:none}
  @media(min-width:900px){
    /* EVERY CHILD OF THIS GRID NEEDS A NAMED CELL.        BJB-92, 20 Aug 2026
       The Explore Onlast divider and button were added to the auth screen with
       no grid-area, so on a desktop they were auto-placed after every named
       area and landed alone at the very bottom of the page - below the legal
       row, detached from the "or" they belong to. They now sit in the form
       column directly beneath the sign-in card, which is where the choice
       actually is: sign in, or have a look round first. */
    .auth{display:grid;grid-template-columns:minmax(0,1fr) 380px;grid-template-areas:
      'logo form' 'word form' 'desc form' 'sub form' 'story form' 'tag form'
      'trust exline' 'legal explore';
      column-gap:64px;align-items:center;justify-items:start;text-align:left;max-width:1000px;margin:0 auto}
    .auth .exploreline{grid-area:exline;width:380px;margin:20px 0 10px}
    .auth .explorewrap{grid-area:explore;width:380px}
    .auth .biglogo{grid-area:logo}
    .auth .bigword{grid-area:word}
    .auth .descriptor{grid-area:desc}
    .auth .bigsub2{grid-area:sub}
    .auth .authstory{grid-area:story;display:block;max-width:460px;margin-top:22px}
    .auth .tag{grid-area:tag}
    .auth .formcard{grid-area:form;width:380px;max-width:380px}
    .auth .trustrow{grid-area:trust;justify-content:flex-start!important;margin-top:26px!important}
    .auth .legalrow,.auth .legalnote{grid-area:legal;text-align:left}
    .asline{font-size:17px;line-height:1.5;color:var(--text-2);margin:0 0 14px;max-width:440px}
    .aspoints{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:9px}
    .aspoints li{font-size:14px;line-height:1.5;color:var(--text-3);padding-left:18px;position:relative}
    .aspoints li::before{content:'';position:absolute;left:0;top:8px;width:7px;height:7px;border-radius:2px;background:var(--accent)}
    .aspoints b{color:var(--text)}
  }
  .auth .biglogo{width:74px;height:74px;border-radius:22px;border:2px solid var(--accent);background:#0c160d;display:grid;place-items:center;box-shadow:0 0 32px -6px var(--glow-2);margin-bottom:24px}
  .auth .biglogo span{font-family:'JetBrains Mono',monospace;font-weight:700;font-size:22px;color:var(--accent)}
  .auth .bigword{font-family:'Saira',sans-serif;font-weight:800;font-size:40px;letter-spacing:.03em;line-height:1;text-transform:uppercase}
  .auth .bigsub{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.24em;color:var(--text-3);margin-top:12px}
  .auth .tag{font-size:15px;color:var(--text-2);max-width:380px;margin:20px 0 0;line-height:1.6}
  .auth .formcard{width:100%;max-width:360px;background:var(--panel);border:1px solid var(--border-2);border-radius:18px;padding:22px;text-align:left;margin-top:28px}
  .auth .formcard input{width:100%;margin:7px 0 14px}
  .auth .formcard .btn{width:100%;margin-top:6px}
  .toggle{color:var(--text-2);font-size:13px;margin-top:8px;padding:8px 0;cursor:pointer}
  @media(min-width:721px){ .topfade{display:none} }

  /* mobile responsive */
  @media(max-width:720px){
    .wordmark{font-size:19px}
    .scorebug{grid-template-columns:1fr 1fr;gap:12px}
    .sb{padding:16px 14px}
    /* 10px mono at .16em tracking is ~150px for "PRIZE POT · 5/8 PAID", which does not
       fit half a 393px screen. Tighter tracking plus a balanced break instead of a
       ragged one hanging a single word on its own line. */
    .sb .l{letter-spacing:.08em;font-size:10px;margin-top:8px;text-wrap:balance}
    .strip{gap:10px;padding:14px 15px}
    .strip .segs{min-width:70px}
    /* the name is the useful part of the chip; the header gear already says you are admin */
    .userbar .adminbadge{display:none}
    /* 156px predates the Admin gear joining the phone header; at 156 the gear's
       44px squeezed the league name in the brand column by 15px. The chip
       ellipsizes gracefully - the subtitle just clips. Chip gives, name wins. */
    button.userchip{max-width:118px}
    /* The theme button now lives in the phone header, and at 360px its 44px
       have to come from somewhere. Two smaller givers: the subtitle's very
       wide letter-spacing (a luxury, not information) and 2px of button gap.
       Both measured: "The Gaffers" was 13px short at 360px; this recovers it
       without hiding anything or shrinking any touch target. */
    .lmsbrand .subtitle{letter-spacing:.06em}
    .userbar{gap:6px}
    .pickgrid{gap:8px}
  }
  /* THE HISTORICAL BOURNEMOUTH/SUNDERLAND CLASS: the two single-word club
     names too long to wrap, the ones this codebase has hit narrow-width
     clipping on before. A .fixturerow's two SIDE-BY-SIDE columns halve the
     space each club card gets, and Lane 4's own measured sweep after the
     first fix found the real range: name overflow starts at 350px (77px of
     text in a 49px box) and does not clear until 410px (measured clean at
     410, still 3-15px short at 405) - a wider band than the first pass's
     345px cutoff caught, which is why 375px alone (one of the four required
     widths) still clipped after that first fix. Confirmed by re-sweeping
     every 5-10px from 320 to 430 rather than re-guessing a single number.
     409px, not 405 or 410 exactly, is the deliberate one-pixel-safe margin
     under the measured-clean 410. Its own media query, not folded into the
     345px admin-table one below - that one exists for an unrelated screen
     and extending IT to 409px would move rules that were never measured at
     this width. */
  @media(max-width:409px){ .fixturerow{grid-template-columns:1fr} }
  @media(max-width:345px){
    .asecbody table select{max-width:74px;font-size:12px;padding:8px 3px}
    .asecbody th,.asecbody td{padding-left:2px;padding-right:2px}
    .asecbody td .badge-won{padding:4px 6px;letter-spacing:.04em}
  }
  @media(max-width:720px){
    /* MEASURED at 360px, which is the commonest Android width and narrower than
       the 393px phone the sweep used to run on: "Newcastle Utd" lost 11px behind
       an ellipsis and "v Nott'm Forest" lost 15px. A half-read club name on the
       screen where somebody stakes 200 pounds is not a small thing.

       The name is allowed to take a second line rather than be cut - two tidy
       lines beat "Newcastle U…" - and the opponent falls back to its three-letter
       code, which is the same alphabet the rest of the app already uses. */
    /* Same problem, same answer, on the fixture list and the league table: at
       320px - an iPhone SE, and there are still plenty about - "Newcastle Utd"
       lost 18px and a player's own name lost 12px. A club or a person half-named
       is not a layout detail, it is the app failing to say who it means. */
    .fixrow .side .nm{white-space:normal;overflow:visible;text-overflow:clip;line-height:1.15}
    /* the span INSIDE .nm carries its own nowrap+ellipsis, so relaxing only the
       parent changed nothing - the name was still cut at the child. */
    .trow .nm,.trow .nm span{white-space:normal;overflow:visible;text-overflow:clip;line-height:1.2}
    .clubbtn .oppfull{display:none}
    .clubbtn .oppcode{display:inline}
    /* Buy back width for the club name and the opponent line from the button's
       own padding and gap, which nobody reads, rather than from the type. */
    .clubbtn{padding:10px 9px;gap:7px}
    .thead,.trow{padding-left:12px;padding-right:12px}
    .lsechead{padding:15px 16px}
    .lsecbody{padding:0 15px 18px}
    /* these subtitles were nowrap+ellipsis, so "PLAYERS LEFT STANDING EACH GAMEWEEK ·
       ROUND RESETS MARKED" lost 140px of itself behind an ellipsis. Let them wrap. */
    .lsecsub{white-space:normal;overflow:visible;letter-spacing:.08em}
    .fixrow{padding-left:15px;padding-right:15px}
    .card,.panel{padding-left:14px;padding-right:14px}
    .auth .bigword{font-size:30px}
    .metagrid{grid-template-columns:1fr 1fr}
    .asecbody{padding-left:16px;padding-right:16px}
    .cbubble{max-width:88%}
    .ribbon{margin-left:16px;margin-right:16px;gap:10px 12px}
    #view{padding-top:12px}
    /* league table on a phone: ONE line per player. The old layout forced every row
       onto two lines whatever it contained, so a single short name still produced a
       bulky two-line row. The name is the only thing allowed to grow, and it
       ellipsises; form is capped at six dots by formDotsArr() so it cannot push. */
    .thead.lt{display:none}
    /* FIXED WIDTHS ON EVERY COLUMN BUT THE NAME, NOT auto.
       A player photographed it on a real league, 8 Sep 2026: on "Who
       is still in", an OUT row's status badge sat at a visibly different
       horizontal start than a TO PLAY row's - it read as the badge floating,
       not lining up in its own column.

       EACH .trow HERE IS ITS OWN INDEPENDENT GRID (every row carries the
       column template in its own inline style, not a shared table). auto
       sizes a column to THAT row's own content and nothing else's - so a row
       whose badge says the three letters "OUT" gets a narrower status track
       than a row whose badge says "TO PLAY", and because the name column is
       minmax(0,1fr) - it absorbs whatever the other two tracks do not use -
       the name column silently grew wider on the OUT row to fill the gap,
       and every other cell shifted right to sit against it. This is the same
       fault BJB-233 found on Picks Revealed, on a different list - see
       `.trow.lt.rnd` below for where it was actually fixed, on the list it
       was reported on. This row (`.trow.lt`, the season standings table, no
       `.rnd`) carries the identical latent fault and is NOT fixed here - it
       was never reported and this slice does not touch it, to avoid mixing
       an unverified change into a verified one. `auto auto auto` is its
       original, pre-existing value. */
    .trow.lt{grid-template-columns:20px minmax(0,1fr) auto auto auto!important;column-gap:7px;row-gap:0;padding-top:11px;padding-bottom:11px}
    /* THE ROUND CARD HAS THREE COLUMNS, NOT FIVE, AND THE RULE ABOVE IS
       !important - so on a phone it captured the round card too and put its
       three cells into the season table's first three slots. The name landed
       in the 20px RANK slot and was clipped to nothing; the form dots landed
       in the name slot; the badge landed in the form slot. Every player row on
       "Who is still in" rendered as an avatar and a badge with no name at all.
       Found 18 Aug 2026 by rendering the card and measuring the name, which is
       the only reason it was found: the card's own checks measured whether it
       existed and whether it was open, never whether you could read it.
       `.trow.lt.rnd` is one class more specific than `.trow.lt`, so it wins
       between two !important rules. It must live INSIDE this media query,
       because that is where the rule it is beating lives.

       WHY NOT FIXED PIXEL WIDTHS (the same player's report, 8 Sep 2026, on
       THIS card): a first attempt hard-coded 50px/74px - the exact desktop
       value, wide enough for "TO PLAY", the longest word this column ever
       shows. That does stop the drift, but it reserves the WORST CASE on
       EVERY row regardless of what that row actually renders, and check-
       preseason-table.js caught what it cost: early in a season, with one
       form dot and only "IN"/"OUT" ever showing, 124px was still being taken
       from the name column for content that was never there, and a name as
       short as a seven-letter one with a "(you)" tag clipped at 320px.
       Fixed widths traded one bug
       for the other - they cannot fit both a short-content fixture and a
       worst-case one, because a static number cannot see what is actually
       rendered.

       SUBGRID CAN. `.rndlist` (see app.js) wraps the header and every row of
       this list in ONE grid, declared once, with `max-content` columns - so
       the form and status tracks size to the WIDEST form-dot-count and
       status word actually present across the rows that are on screen right
       now, never a hypothetical worst case, and every row (being a subgrid
       of the same tracks) is handed the identical, already-resolved column
       widths. A short-content fixture wastes nothing; a long-content one
       still never clips, because the track that needs the room takes it -
       once, for the whole list, not row by row. */
    /* BOTH BRANCHES ARE GATED BY THE SAME FEATURE QUERY, NOT JUST THE COLUMN
       DECLARATION. A first version of this fallback left `.rndlist{display:
       grid}` unconditional and only gated the row's own `grid-template-
       columns` - so on a non-subgrid browser the row (grid-column:1/-1, its
       own fixed 50px/74px template, but no subgrid) still sat inside a LIVE
       parent grid whose `max-content` tracks have nothing subgridded into
       them to size against. That produced a measured, reproducible few-px
       drift on the one row carrying a "(you)" tag - caught by proving the
       fallback, not asserted from reasoning. Making `.rndlist` itself part
       of the subgrid-only branch removes the ambiguity entirely: on a
       non-subgrid browser it is never a grid at all, and each row goes back
       to being its own fully independent, self-contained grid - exactly the
       configuration already proven clean (42/0) before subgrid replaced it. */
    @supports (grid-template-columns: subgrid) {
      /* `.rndlist` (see app.js) wraps the header and every row of this list
         in ONE grid, declared once, with `max-content` columns - so the form
         and status tracks size to the WIDEST form-dot-count and status word
         actually present across the rows that are on screen right now, never
         a hypothetical worst case, and every row (being a subgrid of the
         same tracks) is handed the identical, already-resolved column
         widths. A short-content fixture wastes nothing; a long-content one
         still never clips, because the track that needs the room takes it -
         once, for the whole list, not row by row. */
      .rndlist{display:grid;grid-template-columns:minmax(0,1fr) max-content max-content}
      .trow.lt.rnd,.thead.lt.rnd{grid-column:1/-1;grid-template-columns:subgrid!important}
    }
    /* A BROWSER THAT DOES NOT KNOW subgrid IGNORES THAT DECLARATION ENTIRELY -
       CSS drops an unsupported value at parse time, it does not fall back to
       anything. Without a rule here, the cascade would fall through to the
       desktop inline style (`rcols`, 150px/74px, sized for a wide screen) at
       PHONE widths - not a graceful failure, a badly disproportionate one.
       This restores the first (rejected) fix's fixed 50px/74px, but ONLY
       behind `@supports not`, so it is inert on any browser subgrid actually
       reaches - the same worst-case-reservation tradeoff that fixed widths
       always carry, accepted here because it is now a minority-browser
       fallback, not the primary path. Proved by forcing this branch active
       (the tautology `@supports (X) or (not (X))` - `not (X)` needs its own
       parens when joined with `or`/`and`, a mistake made and caught during
       this proof: without them the whole condition was invalid and BOTH
       branches went inert, silently falling through to the season table's
       five-column rule instead - a real non-subgrid browser was not
       available to test on, so this Chromium-forced simulation is what
       stands in for it) and confirming it holds alignment on its own, at
       the cost of the same worst-case-reservation tradeoff the first
       rejected attempt always carried. */
    @supports not (grid-template-columns: subgrid) {
      .trow.lt.rnd,.thead.lt.rnd{grid-template-columns:minmax(0,1fr) 50px 74px!important}
    }
    .trow.lt .rk{align-self:center;font-size:16px}
    .trow.lt .nm{min-width:0}
    /* the row is highlighted and accent-bordered already, so "(you)" is 35px of screen
       spent saying what the styling says. Kept for screen readers, not for the eye. */
    .trow.lt .youtag{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
    /* four most recent results, not six - the oldest two were costing the name 20px */
    .trow.lt .tform .fdot:nth-last-child(n+5){display:none}
    .trow.lt .tform{flex:none;align-self:center}
    .trow.lt .tstatus{justify-self:end}
    .trow.lt .ptsval{justify-self:end;min-width:20px;text-align:right}
    .prem-full{display:none}
  }
  /* 320px - an iPhone SE, and there are still plenty in pockets. A single word
     cannot wrap, so "a long name" in a 53px column is cut no matter what white-space
     says; the column has to get wider or the word smaller. One step down in size
     plus six pixels off a decorative avatar does it, and 14px is still four
     sizes above the floor.

     THIS BLOCK MUST STAY OUTSIDE THE max-width:720px BLOCK ABOVE. It was first
     written spliced into the middle of it, which closed that block early and
     quietly dropped every rule after it at 360px and 393px - the two commonest
     phone widths - while the 320px case it was added for measured clean. */
  @media(max-width:340px){
    /* The header gained a theme button on phones; at 320px those 44px come out
       of the league-name subtitle, which was losing 53px behind an ellipsis.
       On a screen this small the subtitle is the right thing to give up - the
       league's name is on the ribbon, the table and the admin screen. */
    .lmsbrand .subtitle{display:none}
    .trow .nm,.trow .nm span{font-size:14px}
    .trow{padding-left:11px;padding-right:11px}
    .trow.lt .nm .av{width:18px!important;height:18px!important}
  }
  @media(min-width:721px){ .prem-code{display:none} }

  /* ---- FIRST PAINT ----------------------------------------------------------
     The page used to paint the bare footer on an empty background for as long as
     the Supabase CDN took to answer, which read as "the app is broken". A splash
     lives in the static HTML so something intentional is on screen from frame 1. */
  .boot{position:fixed;inset:0;z-index:9998;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;background-color:var(--bg-solid);background-image:var(--bg-grad);transition:opacity .3s ease}
  /* THE SPLASH IS THE SAME FLOODLIGHT AS EVERY OTHER LOADING STATE.
     Corrected 14 Aug 2026, and this is the correction that matters: the tab
     loading card was changed to the floodlight and THIS was not, so the very
     first loading state anybody sees was still a single pulsing dot over a
     sliding bar. the app was opened after the deploy and, quite reasonably,
     said the loader had not changed - because the one he was looking at
     hadn't. One product should have one loading language, and now does.

     It used to run TWO animations at once - a pulsing lamp and a sliding bar -
     which is a second reason to drop it: two competing motions read as busy
     rather than calm. The three lamps carry it alone.

     Written as plain markup in the HTML, not built by lmsLoading(), because
     this has to be on screen before any script has run. It reuses the same
     @keyframes lmsLamp, so the two can never drift out of rhythm. */
  /* THE SPLASH IS THE ONLAST MARK ITSELF, AND ITS FLOODLIGHT BREATHES.
     (Product decision, 14 Aug 2026.) The ask was for the logo on the boot splash with the
     lit floodlight inside it pulsing gently while the app loads, on one
     condition he has now attached three times: performance comes first.

     WHY THIS IS ALLOWED HERE AND STILL REFUSED FOR THE IN-APP LOADER. They are
     not the same budget. The in-app loader can run on every tab change, many
     times a session, so BJB-149 gave it a drawing with no filter and no
     gradient at all. The splash is drawn ONCE per app open and is gone in
     about two seconds.

     MEASURED, not assumed. Chromium at 6x CPU throttling, 180 frames, four
     runs, splash-sized: a static mark costs 33-43ms of CPU per 3s; pulsing the
     LENS costs 107-136ms; pulsing the blurred glow costs 126-151ms. No variant
     dropped a single frame. So the pulse costs roughly 90ms of CPU per three
     seconds at six times slower than a desktop - and it runs for two seconds,
     once.

     WHAT IS PULSED, AND WHAT IS NOT. `.onlast-lamp-lit` is the lens: a plain
     circle sitting OUTSIDE every filter. `.onlast-lampglow` is the halo and it
     lives inside feGaussianBlur stdDeviation=52 - it is deliberately left
     alone here. The measurement says the difference is small, but "nothing
     inside a blur filter is ever animated" is a rule worth keeping whole,
     because a rule with one exception is not a rule. */
  /* NO OUTER TILE (Product decision, 17 Aug: "the logo/icon still sits inside an outer
     container that feels noticeably too large"). ONLAST_MARK is already drawn
     as an app icon - it carries its own rounded corners and its own dark
     ground - so wrapping it in a second bordered, rounded, dark box was a box
     inside a box, and the gap between the two read as wasted space. The tile is
     gone and the mark is the size the tile used to be, so the logo fills what
     the container occupied instead of floating inside it. The glow moves onto
     the mark so the floodlight still throws light onto the page. */
  .boot .bootmark{width:72px;height:72px;display:grid;place-items:center;
    border-radius:21px;box-shadow:0 0 34px -6px var(--glow-2)}
  .boot .bootmark .onlast-mark{width:72px;height:72px;border-radius:21px}
  /* IT WAS THERE AND HE COULD NOT SEE IT, WHICH IS THE SAME AS NOT BEING THERE.
     (Product decision, 14 Aug: "the floodlight currently isn't breathing or pulsing...
     it's just static.") The animation WAS running - a harness read it off
     getAnimations() and a screenshot caught it mid-cycle at 0.60 opacity. It
     was simply unreadable: a 2.4s cycle dipping only to 55% brightness, on a
     splash that is gone in about two seconds. He saw roughly three quarters of
     one slow fade and correctly called it static.

     So it now runs at 1.15s - the same rhythm as every other loading state in
     the app - and dips to 28%, with the soft halo swinging further still. Two
     full breaths inside the time the splash is on screen, and each one an
     obvious change in brightness rather than a hint of one. */
  /* THE BEAM PULSES, NOT THE BULB. (Product decision, 15 Aug, third pass and the clearest
     statement of it: "the main Onlast logo staying intact, with the beam/light
     FROM the floodlight subtly pulsing to indicate loading, rather than just the
     light source itself.")

     Earlier versions pulsed the lens - the lamp itself - which read as the logo
     flickering rather than as a floodlight throwing light. Now the lamp stays
     steadily lit, the logo is never interrupted, and what moves is the shaft of
     light coming out of it. That is the thing that actually says "working".

     WHICH ELEMENT, AND WHY IT IS SAFE. `.onlast-beam` is the outer light shaft,
     a single path with a linear gradient and NO filter on it. The brighter core
     and the pool of light on the ground both sit inside feGaussianBlur and are
     deliberately untouched, so the rule stands: nothing inside a blur filter is
     ever animated. The lamp halo keeps the faintest movement so the source does
     not look frozen while its beam breathes.

     THE TILE WAS TOO BIG. 84px -> 68px, mark 60px -> 48px. It is a loading
     screen, not a landing page: the logo should be recognisable and get out of
     the way, and at 84 it dominated a phone. */
  @keyframes onlastBeam{0%,100%{opacity:1}50%{opacity:.34}}
  @keyframes onlastBreatheHalo{0%,100%{opacity:.30}50%{opacity:.14}}
  .boot .onlast-beam{animation:onlastBeam 1.4s ease-in-out infinite}
  .boot .onlast-lamp-halo{animation:onlastBreatheHalo 1.4s ease-in-out infinite}
  /* ---- PULL TO REFRESH (installed app only) ---------------------------
     Product decision, 15 Aug: "I would also like a small subtle indicator when I pull
     down to refresh the PWA to show it is actually reloading."

     INSTALLED ONLY, ON PURPOSE. In a browser the pull-to-refresh gesture
     already exists and already draws its own spinner; adding a second one
     underneath it is two indicators for one action. Standalone is the only
     place the gesture goes nowhere and the player is left wondering whether
     anything happened.

     It borrows the loading language rather than inventing a third one: the same
     three lamps as lmsLoading(), the same rhythm. Only transform and opacity
     animate, so this costs a composite and never a layout. */
  .ptr{position:fixed;left:50%;top:calc(6px + env(safe-area-inset-top,0px));z-index:9990;
    display:flex;align-items:center;gap:8px;padding:8px 14px;border-radius:999px;
    background:var(--card);border:1px solid var(--border);color:var(--accent-fg);
    box-shadow:0 6px 22px -8px rgba(0,0,0,.55);
    transform:translate(-50%,-160%);opacity:0;pointer-events:none;
    font-family:'JetBrains Mono',ui-monospace,monospace;font-size:10px;letter-spacing:.16em;text-transform:uppercase}
  .ptr svg{width:44px;height:12px;display:block}
  .ptr circle{fill:currentColor;opacity:.22}
  .ptr.armed circle{opacity:.85}
  /* Only while it is actually working do the lamps run - a pill that animates
     during the drag would say "loading" before anything had been asked for. */
  .ptr.go .l1{animation:lmsLamp 1.15s ease-in-out 0s infinite}
  .ptr.go .l2{animation:lmsLamp 1.15s ease-in-out .16s infinite}
  .ptr.go .l3{animation:lmsLamp 1.15s ease-in-out .32s infinite}
  @media (prefers-reduced-motion:reduce){.ptr.go .l1,.ptr.go .l2,.ptr.go .l3{animation:none;opacity:.85}}
  .boot .boothint{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--text-4)}
  body.booted .boot{opacity:0;pointer-events:none}
  .footer{visibility:hidden}
  body.booted .footer{visibility:visible}
  body.authscreen .footer{display:none}
  /* Reduced motion still has to SAY something is happening, so the lamps stop but
     the wording changes from a label to a progress line. A frozen logo with no
     moving part and no changing words is indistinguishable from a hung app. */
  @media (prefers-reduced-motion:reduce){.boot{transition:none}.boot .onlast-beam,.boot .onlast-lamp-lit,.boot .onlast-lamp-halo{animation:none;opacity:1}.boot .boothint::after{content:" — please wait"}}
  /* ---- THEME CONTROL --------------------------------------------------------
     The icon shows the mode you are IN, not the one you would switch to. What the
     tap does is carried by the title/aria-label instead, so the control is never
     ambiguous. themeBtn() in the script below is the only place it is built. */
  /* ---- BACK, ON A SCREEN WITH NO BROWSER CHROME ----------------------------
     Installed to the home screen there is no address bar and no browser back
     button, and Browse Leagues put its only way out at the very bottom, under
     the whole directory. Every full-screen sub-screen now carries the same
     control in the same place, mirroring the theme button on the right. */
  .backbar{display:inline-flex;align-items:center;gap:6px;font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--text-2);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:8px 14px 8px 11px;cursor:pointer;transition:transform .09s ease,border-color .12s}
  @media(hover:hover){.backbar:hover{border-color:var(--accent)}}
  .backbar:active{transform:translateY(1px) scale(.98)}
  .backbar .backchev{font-size:15px;line-height:1;margin-top:-1px;color:var(--accent-fg)}
  @media(max-width:720px){ .backbar{min-height:44px;padding:8px 15px 8px 12px} }
  .themeicon{width:14px;height:14px;flex:none;stroke:var(--accent-fg);stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;fill:none}
  .themeicon.moon{fill:var(--accent-fg);stroke:none}
  .themebtn .themebtn-txt,.themebtn-ghost .themebtn-txt{text-transform:none;letter-spacing:.06em}
  .themebtn-ghost{display:inline-flex;align-items:center;gap:8px}

/* Notification preference rows. A whole-row <label> so the tap target is the
   full width, not just the 20px checkbox - this is the main reason settings
   toggles feel fiddly on a phone. */
.notifrow.notifhi{outline:2px solid var(--accent);outline-offset:3px;border-radius:10px}
.notifrow{display:flex;align-items:flex-start;gap:14px;justify-content:space-between;
  padding:12px 14px;margin-bottom:8px;border:1px solid var(--line);border-radius:14px;
  background:var(--card2,transparent);cursor:pointer}
.notifrow span{display:flex;flex-direction:column;gap:3px;min-width:0}
.notifrow b{font-size:14px;font-weight:700}
.notifrow i{font-style:normal;font-size:12px;line-height:1.45;opacity:.7}
.notifrow input[type=checkbox]{flex:0 0 auto;width:20px;height:20px;margin-top:2px;
  accent-color:var(--accent,#3ddc84);cursor:pointer}
.notifrow input[type=checkbox]:disabled{cursor:not-allowed;opacity:.4}
.notifrow:has(input:disabled){opacity:.6;cursor:not-allowed}

/* Password field with a reveal button sat inside it. position:relative on the
   wrapper and padding-right on the input, so the eye never sits on top of the
   text the person is trying to read. */
.pwwrap{position:relative;display:block}
.pwwrap input{width:100%;padding-right:46px}
.pwtog{position:absolute;right:6px;top:50%;transform:translateY(-50%);
  display:flex;align-items:center;justify-content:center;width:36px;height:36px;
  padding:0;border:0;border-radius:10px;background:transparent;color:var(--text-4);
  cursor:pointer;transition:color .15s,background .15s}
@media(hover:hover){.pwtog:hover{color:var(--text-2);background:var(--line)}}
.pwtog:focus-visible{outline:2px solid var(--accent-fg);outline-offset:2px}
.pwtog[aria-pressed="true"]{color:var(--accent-fg)}
/* 36px was under Apple's 44px minimum, on the control somebody reaches for when
   they have already mistyped their password once. This rule has to live HERE
   rather than in the phone block near the top of the file: a media query adds no
   specificity, so the plain .pwtog rule above would otherwise win. Same trap the
   header block documents. */
@media(max-width:720px){ .pwtog{width:44px;height:44px} }

/* Signed in, the footer now reads as one line like the sign-in screen does.
   The three promises are shorter than they were for exactly that reason. */
@media(max-width:430px){
  /* This used to squeeze to 8.6px on one line rather than wrap. 8.6px is not a
     size, it is an apology. Two tidy lines at a readable size is the better
     trade in a footer nobody is racing through. */
  .trustrow{flex-wrap:wrap;gap:2px 12px;font-size:10px;letter-spacing:.01em}
  .trustrow span{white-space:nowrap}
}

/* Team view, opened from the Premier League table */
.trow.teamrow{cursor:pointer;transition:background .12s}
@media(hover:hover){.trow.teamrow:hover{background:var(--line)}}
.trow.teamrow:focus-visible{outline:2px solid var(--accent-fg);outline-offset:-2px}
.teamhead{display:flex;align-items:center;justify-content:space-between;gap:12px}
.teamhead-l{display:flex;align-items:center;gap:12px;min-width:0}
.teamhead-nm{font-family:'Saira',system-ui,sans-serif;font-weight:800;font-size:20px;letter-spacing:.01em;line-height:1.15}
.teamhead-sub{font-size:12px;color:var(--text-4);margin-top:2px}
/* League position + form, BJB-203. .form (formDots()'s own wrapper) had no
   layout rule anywhere in the file - the dots would have rendered inline
   with no gap wherever it was next used. Both rules are additive: nothing
   else calls formDots() today, so nothing else's layout can be affected. */
.teamhead-pos{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-4);margin-top:4px}
.tmpos{white-space:nowrap}
.form{display:inline-flex;align-items:center;gap:3px}
/* Attribution, kept deliberately quiet. The old line also explained that an @
   means away, which is a convention every football fan already reads without
   help - a caption explaining the interface is a sign the interface needs
   fixing, not captioning. */
.teamsrc{margin-top:16px;padding-top:12px;border-top:1px solid var(--border-2);font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.1em;color:var(--text-5);text-align:center}
.teamskel{height:180px;border-radius:14px;background:var(--line);opacity:.35;margin-top:16px}
.tmrow{display:grid;grid-template-columns:46px minmax(0,1fr) auto;align-items:center;gap:10px;
  padding:10px 12px;border-bottom:1px solid var(--line)}
.tmrow:last-child{border-bottom:0}
.tmgw{font-size:11px;color:var(--text-5)}
.tmopp{display:flex;align-items:center;gap:8px;min-width:0;font-size:14px;font-weight:600}
.tmopp span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tmres{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--text-3);white-space:nowrap}
.tmres.w{color:var(--accent-fg);font-weight:700}
.tmres.x{color:var(--danger);font-weight:700}

/* ---------------------------------------------------------------------------
   FIRST-RUN STATE. A section with no data yet still has to look like somebody
   designed it: a stroke icon in the app's own family, one sentence of plain
   English, the deadline that will change it, and at most one action.
   Deliberately NOT a variant of `.empty` - `.empty` is the right thing for a
   list that happens to be short right now ("No fixtures for GW7 yet"), and it
   should stay small and quiet. This is for a section a new player meets before
   they have played at all, which is a different job.
   -------------------------------------------------------------------------- */
.estate{display:flex;flex-direction:column;align-items:center;text-align:center;
  gap:12px;padding:4px 4px 8px}
.estateic{display:grid;place-items:center;width:46px;height:46px;border-radius:14px;
  background:var(--chip-bg);border:1px solid var(--chip-border);color:var(--text-3)}
.estateic .uic{opacity:.9}
.estatep{color:var(--text-2);font-size:14.5px;line-height:1.5;max-width:30ch}
.estatem{font-size:10px;letter-spacing:.12em;color:var(--text-4)}
.estate .btn{margin-top:2px}

/* ---------------------------------------------------------------------------
   AN ERROR SLOT MUST NOT RESERVE SPACE IT IS NOT USING.
   Product decision, 18 Aug 2026, on Your GW1 Pick: "The content is sitting too high
   within the section - there is noticeably less space above compared with
   below."
   Measured rather than eyeballed: the card was 224px tall with 23px above the
   content and 51px below it. The 28px difference is this element - an EMPTY
   `.msg` that clearPick() writes failures into, carrying `min-height:18px`
   from `.msg` plus its own 10px margin. It is invisible, and it was padding
   the bottom of the most important card on Home on every single render.
   `:empty` collapses it until something is actually written into it, so the
   card is balanced 23/23 and 28px shorter. The message still appears the
   moment there is one - that is the control in check-pick-card.js.
   -------------------------------------------------------------------------- */
/* APPLIED TO EVERY MESSAGE SLOT, NOT JUST THE ONE THAT WAS REPORTED.
   Product decision, 18 Aug 2026: "So you'd rather launch with potentially 26 other
   defected items with dead space? ... I want us to be professional from the
   very first moment and never let standards slip."
   He is right. index.html holds 26 of these slots - Admin, the profile sheet,
   join, create, payments, announcements, the festive setup - and every one of
   them was reserving 28px of nothing on every render. Fixing one and logging
   the other twenty-five would have shipped a known defect on purpose.
   THE LAYOUT JUMP THIS COULD CAUSE IS THE POINT, not a side effect. A message
   arriving is a change the player should notice; a permanent hole where a
   message might one day go is not. Every affected screen is measured in
   tools/check-message-slots.js, which also proves each slot still shows its
   message. */
.msg:empty{display:none;margin-top:0}

/* ---------------------------------------------------------------------------
   THE SPAM NOTE on the "check your email" screen.
   Product decision, 18 Aug 2026: two players found their sign-up email in junk, both on
   Hotmail. The words were already on that screen, at the end of a grey
   sentence in the smallest type on the page. Both missed them.
   So it is a bordered note in ordinary body colour rather than another line of
   fine print. It is deliberately NOT a warning colour: nothing has gone wrong,
   and painting it amber would make a routine first sign-up feel like a fault.
   -------------------------------------------------------------------------- */
.spamnote{display:flex;gap:10px;align-items:flex-start;text-align:left;
  background:var(--chip-bg);border:1px solid var(--chip-border);border-radius:12px;
  padding:12px 13px;margin:0 0 16px;font-size:13px;line-height:1.55;color:var(--text-2)}
.spamnote b{color:var(--text)}
.spamnote .uicwrap{flex:none;display:grid;place-items:center;margin-top:1px;color:var(--accent-fg)}

/* ===== CHAT LINKS & MENTIONS ARE THINGS YOU CAN TAP ===== Product decision, 20 Aug 2026
   A YouTube link posted in the LMS league chat rendered as plain text. Links
   are now anchors; a mention is a door to that player's card, the same
   openPlayer() the league table uses. Colour: the accent the app already uses
   for "you can act on this", underlined because in a bubble of plain text the
   underline IS the affordance - colour alone fails for colour-blind players. */
.clink{color:var(--accent-fg);text-decoration:underline;text-underline-offset:2px;word-break:break-all;border-radius:3px}
.clink:visited{color:var(--accent-fg)}
.clink:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.chatmsg.mine .clink{color:var(--accent-fg)}
.mention.mtap{cursor:pointer;border-radius:4px}
.mention.mtap:focus-visible{outline:2px solid var(--accent);outline-offset:1px}

/* ===== THE PROFILE AVATAR STUDIO, FOLDED ===== BJB-204, 20 Aug 2026
   ~20 tap targets of colour and pattern used to open the screen. They now sit
   behind a summary row that shows the tile you actually have. Native details:
   the marker is hidden and the chevron in .pffold-c rotates instead, because
   the default triangle reads as a file-tree, not a premium app. */
.pffold{border:1px solid var(--border);border-radius:12px;background:var(--card)}
.pffold-s{display:flex;align-items:center;gap:10px;padding:10px 14px;cursor:pointer;list-style:none;user-select:none}
.pffold-s::-webkit-details-marker{display:none}
.pffold-s::marker{content:""}
.pffold-t{flex:1;font-size:13px;font-weight:600;text-transform:capitalize}
.pffold-c{display:flex;align-items:center;gap:5px;font-size:11px;font-weight:600;letter-spacing:.04em;color:var(--text-3)}
.pffold-c svg{transition:transform .18s ease}
.pffold[open] .pffold-c svg{transform:rotate(180deg)}
.pffold-b{padding:2px 14px 14px}
.pfavprev{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;font-weight:800;font-size:15px;border:1px solid var(--border);flex:none}
.pfavprev.avnone{background:var(--card);color:var(--text-2)}
@media (prefers-reduced-motion: reduce){ .pffold-c svg{transition:none} }

/* THE TWO-ROUTE CHOOSER (BJB-290). Onlast is a Last One Standing league AND a
   daily game, and the first screen of a new account has to offer both without
   ranking them. The two cards are deliberately IDENTICAL - same border, same
   radius, same padding, same heading, and a .btn in each - because a chooser
   whose second card is quieter than its first is not a chooser. Stacked on a
   phone, side by side from 560px where there is room for two columns without
   squeezing the code field. align-items:start keeps the shorter card its own
   height rather than stretching it to match the taller one, which would make
   the Daily Last card look padded out to fill space it does not need. */
.dlchoice{display:grid;gap:12px}
@media(min-width:560px){.dlchoice{grid-template-columns:1fr 1fr;align-items:start}}
.dlchoice .cc{border:1px solid var(--border-2);border-radius:14px;padding:14px 14px 16px}
/* 320px IS A REAL PHONE, AND THE CARD TOOK ITS WIDTH. Measured, not guessed:
   putting the join field inside a card cost it 28px of the line it used to
   have, and at 320 the placeholder rendered as "E.G. RAFTERS2(" - a string cut
   off mid-word, in the one control a person has to type into. Eleven pixels
   come back here and the placeholder lost five characters it did not need,
   because the label above it already says what the box is for. */
@media(max-width:360px){.dlchoice .cc{padding:13px 11px 15px}}
.dlchoice .cch{font-weight:800;font-size:15.5px;letter-spacing:.01em;margin:0 0 6px}
.dlchoice .label{margin-top:2px}

/* ===== THE HOW TO PLAY HUB ===== 27 Aug 2026.
   A real tablist, styled. The focus ring is the app's own and is NOT removed:
   a branded control that a keyboard cannot see is not acceptable at any level
   of polish, and that was the condition this component shipped under. */
/* TWO EQUAL SEGMENTS, WHATEVER THE WORDS.               BJB-293, 29 Aug 2026.
   Daily Last and Last One Standing are two game modes of equal standing, and
   the selector was making them look otherwise: "Last One Standing" wrapped to
   two lines and grew its own segment while "Daily Last" stayed one line and
   small, so the longer NAME read as the more important GAME. 1fr 1fr already
   guarantees equal width; align-items:stretch plus a min-height guarantees
   equal HEIGHT, so the wrap is absorbed inside a segment whose geometry does
   not move. Last One Standing may take two lines - that is deliberate now,
   and it costs the other segment nothing. */
.htptabs{display:grid;grid-template-columns:1fr 1fr;align-items:stretch;gap:6px;margin:12px 0 4px;
  background:var(--card-2,var(--card));border:1px solid var(--border-2);border-radius:12px;padding:4px}
.htptab{appearance:none;border:0;background:transparent;color:var(--text-2);cursor:pointer;display:flex;align-items:center;justify-content:center;text-align:center;
  font-family:inherit;font-weight:700;font-size:13px;letter-spacing:.01em;
  padding:9px 8px;border-radius:9px;min-height:46px;line-height:1.2}
/* --accent-fg, NOT --accent - the rule .tabdot already states. Measured on the
   real hub in light against painted pixels: the SELECTED tab was #abe12d on the
   pale accent card at 1.26:1 while the UNSELECTED tab measured 7.67:1, so the
   control was telling a light-theme player which tab they were NOT on. */
.htptab.on{background:var(--accent-card);color:var(--accent-fg);box-shadow:inset 0 0 0 1px var(--accent-fg)}
.htpfmt{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 2px}
.htpchip{appearance:none;cursor:pointer;font-family:inherit;font-weight:600;font-size:11.5px;
  letter-spacing:.02em;padding:7px 11px;border-radius:999px;min-height:32px;
  border:1px solid var(--border-2);background:transparent;color:var(--text-2)}
.htpchip.on{border-color:var(--accent-fg);color:var(--accent-fg);background:var(--accent-card)}
/* "RULES FOR" - ONE BRANDED SELECTOR, NOT A CLOUD OF PILLS. BJB-293, 29 Aug.
   Your league / Classic / Rounds and revival were three chips in a wrapping
   flex row: at 320 they broke onto two lines in a ragged cloud, the current
   value was only findable by spotting which pill was tinted, and the group had
   no label saying what it selected. It is the same job the league switcher
   does, so it is the same control: .lgtrigger + .lgmenu + .lgopt, drawn in
   Onlast's own type and colours precisely so no platform can put a grey native
   wheel over the app. One row closed, a labelled list open, the current value
   always spelled out. Selecting one calls howToPlayFmt(), which is the
   function the canonical rules already re-render from - no rule copy is
   duplicated to make this work. */
.rulesfor{margin:12px 0 2px}
.rulesfor .htpsub{margin:8px 0 0}
.rfhead{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.18em;
  text-transform:uppercase;color:var(--text-3);margin:0 0 6px}
.rftrigger{display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;
  min-height:46px;padding:11px 13px;border-radius:11px;border:1px solid var(--border-3);
  background:var(--card);color:var(--text);font-family:inherit;font-size:14px;font-weight:700;
  text-align:left;cursor:pointer}
.rftrigger .rfval{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rfchev{flex:none;color:var(--text-3);display:inline-flex;transition:transform .16s ease}
.rftrigger.open{border-color:var(--accent-fg)}
.rftrigger.open .rfchev{transform:rotate(180deg);color:var(--accent-fg)}
@media(hover:hover){ .rftrigger:hover{border-color:var(--accent-fg)} }
.rfmenu{display:flex;flex-direction:column;gap:6px;margin-top:8px;padding:6px;
  border-radius:13px;border:1px solid var(--border-2);background:var(--panel)}
.rfmenu[hidden]{display:none}
.rfopt{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;
  min-height:46px;padding:11px 13px;border-radius:11px;border:1px solid var(--border);
  background:var(--card);color:var(--text);font-family:inherit;font-size:14px;font-weight:700;
  text-align:left;cursor:pointer}
.rfopt.on{border-color:var(--accent-fg);background:var(--accent-card);color:var(--accent-fg)}
.rfnow{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.12em;
  text-transform:uppercase;color:var(--accent-fg);flex:none}
@media(hover:hover){ .rfopt:not(.on):hover{border-color:var(--accent-fg)} }
.rfopt:active{transform:scale(.995)}
.htpsub{font-size:10px;letter-spacing:.18em;color:var(--text-3);margin:8px 0 2px}
.htppanel{outline:none}
/* SIX SECTIONS, VISIBLY SIX. Rules used to be one undifferentiated vertical
   list, so universal mechanics, this league's configuration and the festive
   special all read as the same kind of statement. */
.rulesec{margin-top:14px}
.rulesec-t{font-family:'JetBrains Mono',monospace;font-size:9.5px;letter-spacing:.18em;
  color:var(--text-3);text-transform:uppercase;margin:0 0 2px}
.rulesec .rule:first-of-type{border-top:0}
/* The festive block was one dense paragraph. Short lines, one idea each. */
.modal .rulelist{margin:5px 0 0;padding:0 0 0 17px;display:grid;gap:6px;
  font-size:13px;color:var(--text-2);line-height:1.5}

/* ===== THE APPROVED DAILY LAST MARK ===== Package 01.3, 27 Aug 2026.
   A THEME IS A DIFFERENT FILE, NOT A FILTER. The package forbids currentColor
   and its SVGs carry literal hex fills, so the light and dark masters are both
   referenced and one is shown. Two blocks, exactly as the rest of this
   stylesheet does it: a prefers-color-scheme block for anybody who has never
   touched the toggle, and a body[data-theme] block for anybody who has.
   CLEAR SPACE. One supporting-form width on all four sides - 2px at the 24
   master, 5px at the 48. The gaps below are larger than both. Package 01.3
   lists clear space as an OPEN ITEM awaiting sign-off, so this observes
   the stated rule and nothing more. */
/* THE 16px ICON IN THE MY GAMES SUMMARY sits on the text baseline rather than
   the line box, so it reads as part of the label instead of floating above it. */
#gamesSum .dlmark{display:none;vertical-align:-3px;margin-right:7px}
#gamesSum .dlmark-dark{display:inline-block}
body[data-theme="light"] #gamesSum .dlmark-dark{display:none}
body[data-theme="light"] #gamesSum .dlmark-light{display:inline-block}
body[data-theme="dark"] #gamesSum .dlmark-dark{display:inline-block}
body[data-theme="dark"] #gamesSum .dlmark-light{display:none}
@media (prefers-color-scheme: light){
  body:not([data-theme="dark"]):not([data-theme="light"]) #gamesSum .dlmark-dark{display:none}
  body:not([data-theme="dark"]):not([data-theme="light"]) #gamesSum .dlmark-light{display:inline-block}
}
.dlmark{display:none;flex:none}
.dlmark-dark{display:block}
@media(prefers-color-scheme:light){
  body:not([data-theme="dark"]):not([data-theme="light"]) .dlmark-dark{display:none}
  body:not([data-theme="dark"]):not([data-theme="light"]) .dlmark-light{display:block}
}
body[data-theme="light"] .dlmark-dark{display:none}
body[data-theme="light"] .dlmark-light{display:block}
body[data-theme="dark"] .dlmark-dark{display:block}
body[data-theme="dark"] .dlmark-light{display:none}
.cchmark{display:flex;align-items:center;gap:9px}
.cchmark span{min-width:0}
.htpmark{display:flex;justify-content:center;padding:14px 0 4px}
