/* ==========================================================================
   THE HOUSE PALETTE.  Two scales; :root IS the dark one and light overrides it.

   V578 (CR-14, the enforcement pass) moved three of these numbers, and only
   three, each by the smallest step the WCAG 2.1 AA floor allows. The reason they
   are HERE and not sprayed over the nine tools that consume them is that they
   were never nine bugs: --accent at #9a7300 measured 3.66:1 on --btn and 4.35:1
   on white, so EVERY control that reads it -- Admin in the shell drawer, the
   install bar, the cookie bar, the sudoku pad, chess, minesweeper, snake, the
   maze HUD, tictactoe's score, version history, the 2FA buttons -- was below the
   floor at once, in light theme, on about twenty distinct colour pairs. One token
   was the whole list. tests/test_v578_contrast.py measures all of them and is
   what these values were chosen against; the ratios in the comments are its
   output, not an estimate.

   WHAT WAS *NOT* DONE: the hue did not move, and nothing was lightened toward
   its own ground to make a number pass. --accent is the same 45deg gold at the
   same 100% saturation, one step darker in L.
   ========================================================================== */

/* ==========================================================================
   V1062 --- `hidden` MEANS HIDDEN. SITE-WIDE, AND IT HAD STOPPED MEANING IT.

   THREE OF HIS REPORTS IN ONE EVENING WERE THIS ONE RULE MISSING:

     *"we don't need this black screen on always .. it should come up only
       when people click on scan"*   -- the QR camera, permanently open
     *"Back to your practice table is not working"*   -- it was SHOWN when
       there was no table to go back to, so it asked the server, found none,
       and returned him to the lobby looking like a dead button
     and in that same screenshot, "Open a practice table" and "Back to your
       practice table" sitting side by side -- two buttons the code makes
       mutually exclusive.

   THE CAUSE. `[hidden] { display: none }` comes from the BROWSER's own
   stylesheet, which every author rule outranks. So one line --
   `#cardgames .btn { display: inline-flex }` -- quietly switched the attribute
   off for every button on that page, and `.scanwrap{display:flex}` did the
   same for the camera. The markup was right. The JavaScript was right. The
   stylesheet overruled both, and nothing on any screen said so.

   There are 820 `hidden` attributes across these templates. Every one is a
   promise that something stays out of the way until it is asked for, and every
   one was a single careless `display` away from breaking like these three.

   SO THE PLATFORM CONTRACT IS RESTORED ONCE, HERE, rather than patched per
   tool as each symptom is reported. `!important` is deliberate and is the
   whole point: this rule has to outrank author `display` declarations, because
   outranking them is the bug it exists to fix. An element that genuinely needs
   to paint while carrying the attribute does not exist on this site -- and if
   one ever does, it wants a class, not a `hidden` that lies.
   ========================================================================== */
[hidden] { display: none !important; }

:root {
  --bg: #0b2e22; --panel: #0f3d2d; --line: #1d5642; --line2: #1d6a4f;
  --btn: #14503c; --text: #d9f2e5; --muted: #9fd6bc; --muted2: #7fc8a8;
  /* V578: was #6f9c88, which measured 3.03:1 on --btn and 3.93:1 on --panel --
     under the floor everywhere it carried text (opinion's field labels and hints,
     /admin's .muted spans, the bullion notes, the PDF hints). #a1bcae is the
     LIGHTEST it can be and still clear 4.5:1 on --btn, the brightest surface it
     lands on: 4.60 on --btn, 5.98 on --panel, 7.23 on --bg.
     It is still the faintest of the three muted steps (--muted 8.98 on --bg,
     --muted2 7.51, --muted3 7.23) -- the scale survives, compressed. */
  --muted3: #a1bcae;
  --accent: #ffcf5c; --accent-fg: #3d2c05; --good: #8fd45e; --good-line: #2e7d5b;
  --backdrop: rgba(4, 20, 14, 0.72); --backdrop2: rgba(4, 20, 14, 0.82);
  --board: #0e3a2b; --trace: rgba(140,230,190,0.07); --pad: rgba(140,230,190,0.12);
  --wire-outline: #07241a; --soln-bg: rgba(7,36,26,0.85); --soln-fg: #ffcf5c;
  /* --gold is the MEDAL colour: the leaderboard's own-row rule, the podium
     border, the calendar's reminder dot. It is a decoration token and it is not
     legible as body text on a light ground (#c79600 measures 2.70:1 on white), so
     V578 moved the four places that used it AS TEXT onto --accent rather than
     darkening it into a second copy of --accent. */
  --gold: #ffd700;
  /* V578: THE DANGER RED, which this site did not have and therefore had five of.
     #ff5252 (the maze's "live" pill), #e0524d (Clear all WhatsApp settings, the
     bullion alert badge), #e5484d (Discard), #e07a6a (Purge raw technical data)
     and #ff8b6b (the required-field star) were five hand-picked reds, none of them
     theme-aware, measuring 2.09:1 to 3.83:1. A warning nobody can read is worse
     than no warning, so there is now ONE token for red TEXT -- per theme, because a
     red legible on #0f3d2d is not one legible on #f4f4f7 -- and one for a red FILL
     with its own ink. A fill dark enough for white text works on either theme,
     which is why --bad-fill and --bad-fg are declared once and not overridden.
       --bad dark   #f1a19e   4.61 on --btn, 5.99 on --panel, 7.23 on --bg
       --bad light  #cc231d   4.62 on --btn, 5.00 on --panel, 5.49 on --bg
       --bad-fill   #e52019   4.61 under --bad-fg (white), in both themes */
  --bad: #f1a19e; --bad-fill: #e52019; --bad-fg: #ffffff;
  /* V578: THE FURNITURE'S ACCENT, and the one token a tool may not repaint.
     templates/raffle.html says it out loud -- "base.html's own chrome (the drawer,
     the back button, the admin chip) reads the house tokens, so repainting them
     globally would repaint the furniture" -- and then declares `#rh { --accent:
     #0f62d6 }` on the element that CONTAINS the back button, so the back button was
     repainted anyway: RaffleHub's product blue on the house green --btn, 1.67:1 in
     dark theme. templates/finance.html and templates/splits.html do the same with
     their teal (3.67:1). Custom properties inherit, which is the whole point of
     them, so "house chrome" and "the tool's accent" cannot be the same name. This
     is declared at :root only, and a tool that overrides --accent inside itself no
     longer changes the chrome sitting in its own subtree. */
  --chrome-accent: #ffcf5c;
}
/* ====================================================================== V987
   THIS BLOCK, AND THE `--hub-sheet` LINE BELOW, ARE THE ONLY TWO RULES IN THE
   TREE STILL KEYED ON THE NAME "light" -- and they must be, because they are
   what DEFINES that theme. Every other rule that used to say
   `html[data-theme="light"] <selector>` now says `html:not([data-dark])`.

   WHY: parchment is a LIGHT theme whose name is not "light". Sixty-nine rules
   across twenty-four files asked the wrong question -- "is the theme called
   light" rather than "is this a light ground" -- so under parchment every one
   of them handed a tool its DARK colours on a warm ivory background. Measured
   before the change: the top bar's title at 1.04:1, Kids Corner's star count at
   1.36, To-Do's add-subtask at 1.42, and more.

   SAME SPECIFICITY, so nothing in the cascade moved: `html` (0,0,1) plus one
   attribute (0,1,0) is (0,1,1) either way.

   THIS IS V985'S ONE BIT USED THE OTHER WAY ROUND. That release added
   `data-dark` so a stylesheet could ask whether the theme is dark; the absence
   of it is the answer to "is it light", and it stays correct for every theme
   added after this one without anybody editing twenty-four files again. */
html[data-theme="light"] {
  --bg: #ffffff; --panel: #f4f4f7; --line: #dcdce4; --line2: #c8c8d2;
  --btn: #ebebf1; --text: #1a1a22; --muted: #4e4e5e; --muted2: #66667a;
  /* --good is re-declared at the end of this block, with its reason. */
  /* V578: was #8a8a98 (2.87:1 on --btn, 3.10:1 on --panel, 3.40:1 on white).
     #686876 is the lightest that clears 4.5:1 on --btn: 4.62 on --btn, 4.99 on
     --panel, 5.48 on white. It lands very close to --muted2 (4.72 on --btn) and
     that is not sloppiness -- between --muted2 and the 4.5:1 floor there is
     0.22 of a ratio point left, so a third distinct step is not available on
     this ground. The name is kept because the ROLE is (faintest text, and the
     ghost-button boundary V526 chose it for). */
  --muted3: #686876;
  /* V578: was #9a7300 -> 3.66:1 on --btn, 3.96:1 on --panel, 4.35:1 on white,
     i.e. below the floor on all three of the grounds it is used on, and below it
     BOTH ways round (as ink on --btn, and under white --accent-fg as a fill).
     #866400 is the lightest 45deg gold that clears 4.5:1 on --btn:
        --accent on --btn   4.61:1     (Admin, sudoku pad, chess, stocks, .back)
        --accent on --panel 4.99:1     (version history "See all", ms-hint)
        --accent on --bg    5.47:1     (maze HUD timer, chess status)
        --accent-fg on --accent 5.47:1 (install bar, cookie bar, save buttons) */
  --accent: #866400; --accent-fg: #ffffff; --good-line: #7cc78f;
  --backdrop: rgba(0, 0, 0, 0.45); --backdrop2: rgba(0, 0, 0, 0.55);
  --board: #e4e7ec; --trace: rgba(0,0,0,0.10); --pad: rgba(0,0,0,0.12);
  --wire-outline: #33333c; --soln-bg: rgba(255,255,255,0.9);
  /* the solution overlay's ink sits on a 90%-white scrim, so it tracks --accent */
  --soln-fg: #866400;
  --gold: #c79600;
  --bad: #cc231d;      /* --bad-fill / --bad-fg are theme-independent, see :root */
  --chrome-accent: #866400;    /* tracks --accent; see the note in :root */
  /* V578: was #1f8a3d -- 3.71:1 on --btn, 4.02:1 on --panel, 4.41:1 on white, so
     every "saved"/"ok"/"positive" message in the light theme was under the floor.
     #1c7935 is the same 137deg green one step darker: 4.61 on --btn, 4.99 on
     --panel, 5.47 on white (and 5.47 under white, as a fill). */
  --good: #1c7935;
}

/* ==========================================================================
   V910 — THE THREE NEW DARK SCALES.  Same shape as the light block above.

   HIS FOUR, and where each one lives:
     green           :root, above. NOT REPEATED HERE and that is the point --
                     it needs no CSS, which also makes it the fallback of last
                     resort for a saved row that has gone empty, a browser with
                     localStorage blocked, and a page rendered before the
                     AppSetting row exists at all.
     zinc hub gold   the zinc neutral scale carrying the house gold.
     graphite        cooler and bluer, surfaces one step lighter.
     glass           translucent panels over a blurred ground. THE ONLY ONE OF
                     THE FOUR THAT IS NOT A PURE TOKEN SWAP -- see its block.

   THE ATTRIBUTE IS STILL `data-theme`. Widening the VALUE set rather than
   introducing a second attribute is what keeps the ~4 existing
   `html[data-theme="light"] …` rules in this file, and every one in the nine
   tool stylesheets, working with no edit. A legacy stored "dark" matches none
   of these blocks and therefore lands on :root, which is green: correct by
   construction rather than by a migration.

   --accent IS THE SAME 45deg GOLD IN ALL THREE, deliberately. V578's whole
   finding was that --accent is one token feeding about twenty colour pairs at
   once, and --chrome-accent is declared at :root ONLY because templates/raffle,
   finance and splits each repainted the furniture by overriding --accent inside
   themselves (1.67:1 on the back button, measured). Three new accents would be
   three new copies of that audit. If a theme is meant to carry its own accent
   it is one line in its block plus a re-run of test_v578_contrast.py, and the
   ratios in that suite's output -- not an estimate -- are what the value has to
   be chosen against.

   V916: THESE ARE THE PALETTES, not placeholders any more. They were pasted in
   under that label in V910 and nothing measured them for six releases, because
   the contrast sweep looped over ("dark", "light") -- written before these three
   existed and never widened. V916 widened it to every theme the stylesheet
   declares, and these came back the STRONGEST of the five: body text 13-17:1
   where 4.5 is the floor, and not one pair below it anywhere.

   So the label came off rather than the colours being replaced. What the widened
   sweep DID find was three faults none of these blocks caused -- RaffleHub and
   Tune Book printing the house --text on their own light ground, and the back
   button's chip compositing to near-white wherever --btn is translucent. All
   three are fixed where they live; all three had been invisible.

   Each theme is still exactly one block, so a future repaint is one paste per
   theme and touches nothing else. test_v910 asserts the SHAPE (every block
   declares the same token set the light block does) and test_v578_contrast now
   MEASURES every one of them, so a swap is guarded on both axes.
   ========================================================================== */
/* V918 --- GLASS IS GONE, ON HIS INSTRUCTION OF 02-Oct-2026, AND THIS NOTE IS
   WHY RATHER THAN A GAP.
   It was the only one of the four darks that was not a pure token swap: it made
   --panel TRANSLUCENT, which only reads as glass if the surface is also blurred,
   so it carried a property the token set had never needed -- `--hub-blur`, read
   through `backdrop-filter` by a dozen panel rules. A blurred layer is
   recomposited on the GPU every frame it moves, and a full-screen blur under a
   scrolling list on a mid-range phone is the one thing in this stylesheet that
   could cost frames. The note here used to say "measure it on Bingo before
   enabling it for the games". He decided not to carry the question.
   WHAT WENT WITH IT, so nobody hunts for it: this block, the `--hub-blur` and
   `--hub-bg-img` tokens, the `body { background-image }` rule that painted its
   gradients, and the backdrop-filter rule over .card/.panel/.pane and the rest.
   None had another reader -- glass was the only theme that set either token, so
   every one of those rules was a no-op for the other four. `--hub-sheet` STAYS:
   zinc, light and :root all set it, and it has nothing to do with blur.
   A FIFTH THEME IS STILL ONE BLOCK HERE PLUS ONE NAME IN theme_admin.DARKS,
   exactly as before. Nothing about removing this one narrowed that. */
/* ====================================================================== V987
   HIS THREE, 10-Oct-2026: *"oxyblood, forest night and parchmnet pls.. drop
   others and build these (keep current light and green theme)"*.

   AND ZINC AND GRAPHITE ARE GONE IN THE SAME RELEASE, on his word of the same
   day: *"delete all existing themes other than current light and green.. others
   are not as effective as was demoed"*. (Glass went in V918, on his instruction
   of 02-Oct, so two went here and not three.)

   WHY THOSE THREE DID NOT WORK, which is the whole argument for the shape of
   these: zinc, graphite and glass were the same neutral grey a few points apart
   and all three carried the SAME GOLD. The ground barely moved and the accent
   never did, so on a screen full of tables, chips and muted sub-text they read
   as one theme with a slightly different panel -- which is exactly what he
   said, that the demo and the site did not match.

   SO EACH OF THESE MOVES TWO THINGS: the HUE OF THE GROUND and the ACCENT.
     oxblood    deep wine, brass accent. Warm, and the furthest from anything
                that was here.
     forest     deep pine -- much darker and greyer than the house green, on
                purpose, so the two cannot read as one theme at two
                brightnesses.
     parchment  A LIGHT THEME THAT IS NOT WHITE. Warm ivory, brown-grey ink.
                That is the gap his pair actually had, and it is why this block
                has the LIGHT shape (dark overlays, a dark wire outline, the
                lighter gold) rather than the dark one.

   MEASURED BEFORE THEY WERE DRAWN, not adjusted by eye afterwards: body text
   and all three muted steps against all three grounds, plus the accent and its
   own ink. The worst pair across the three is parchment's 4.55:1 against a
   4.5 floor; the other two sit above 6.5. test_v578_contrast measures every
   theme the stylesheet declares, so these are now in that sweep by existing.

   THE SECTION BARS DO NOT CHANGE. --hdr is deliberately one navy in every
   theme; the note above that block says why and this release does not reopen
   it. */
html[data-theme="oxblood"] {
  --bg: #1c0f12; --panel: #271519; --line: #3a2128; --line2: #4e2d35;
  --btn: #2e1920; --text: #f5e8ea; --muted: #d2b2b8; --muted2: #c6a4ab;
  --muted3: #be9ba3;
  --accent: #e8b765; --accent-fg: #2a1c03; --good: #8fd29c; --good-line: #4a6b52;
  --backdrop: rgba(6, 6, 8, 0.74); --backdrop2: rgba(6, 6, 8, 0.84);
  --board: #221216; --trace: rgba(255,255,255,0.06); --pad: rgba(255,255,255,0.11);
  --wire-outline: #120809; --soln-bg: rgba(9,9,11,0.85); --soln-fg: #e8b765;
  --gold: #ffd700; --bad: #f4a39f; --chrome-accent: #e8b765;
  --hub-sheet: #2d181e;
}
html[data-theme="forest"] {
  --bg: #101a15; --panel: #17241d; --line: #233429; --line2: #2f4637;
  --btn: #1c2a22; --text: #e6f0e8; --muted: #b2c8b9; --muted2: #a6bdad;
  --muted3: #9eb6a6;
  --accent: #e3c17a; --accent-fg: #2a2105; --good: #86d49a; --good-line: #3c6b4e;
  --backdrop: rgba(6, 6, 8, 0.74); --backdrop2: rgba(6, 6, 8, 0.84);
  --board: #13201a; --trace: rgba(255,255,255,0.06); --pad: rgba(255,255,255,0.11);
  --wire-outline: #0a110d; --soln-bg: rgba(9,9,11,0.85); --soln-fg: #e3c17a;
  --gold: #ffd700; --bad: #f2a29e; --chrome-accent: #e3c17a;
  --hub-sheet: #1b2a22;
}
html[data-theme="parchment"] {
  --bg: #faf6ee; --panel: #f4efe4; --line: #e0d6c3; --line2: #cdbfa6;
  /* V987: --btn and --panel sit one step lighter than the first draft
     (#eae1cf / #f2ebdd). The SHARED inks -- the ones a tool declares once and
     prints on whatever ground it lands on, like FinTrack's #0a7859 and
     To-Do's #80660c -- measured 4.21 and 4.23 against a 4.5 floor there.
     Lifting the ground was the one-line fix; darkening two tools' inks would
     have been two tools changed for one theme. */
  --btn: #f1ebdf; --text: #241f17; --muted: #534a3a; --muted2: #5e5443;
  --muted3: #665c4a;
  --accent: #8a5a12; --accent-fg: #ffffff; --good: #1f6b46; --good-line: #7cc78f;
  --backdrop: rgba(0, 0, 0, 0.45); --backdrop2: rgba(0, 0, 0, 0.55);
  --board: #ece3d2; --trace: rgba(0,0,0,0.10); --pad: rgba(0,0,0,0.12);
  --wire-outline: #3a3226; --soln-bg: rgba(255,255,255,0.9); --soln-fg: #8a5a12;
  --gold: #c79600; --bad: #a8271f; --chrome-accent: #8a5a12;
  --hub-sheet: #fffaf2;
}

/* --hub-sheet is the one token from that group that OUTLIVES glass: zinc, light
   and :root all set it, and it is a flat colour with no blur in it. */
:root { --hub-sheet: #123b2d; }
html[data-theme="light"] { --hub-sheet: #ffffff; }

/* ==========================================================================
   V910 — THE THEME SHEET (long press on #themebtn).

   IN THIS FILE, not in a new stylesheet, for findialog's reason stated further
   up: "its CSS is in line_escape.css, the one stylesheet this file loads,
   because the component is site furniture rather than one tool's." It is the
   theme control, which the head of base.html calls the only control on the site
   that changes the theme. A second site-wide stylesheet is also the exact thing
   test_v455 exists to refuse (V764 shipped one by accident and it caught it).

   AN ACTION SHEET AND NOT A MENU. His words were "iPhone style drop down", and
   on iOS that control is anchored to the bottom of the screen with a separate
   Cancel below it -- which is also the only shape that stays reachable one-
   handed on the phone this is used on, given #themebtn floats at the bottom
   right. Nothing here is fixed to the TOP, so it never argues with the frozen
   tab row (V653) or the notch.
   ========================================================================== */
/* pointer-events, NOT `hidden` or `display`, and this is the bit that bites.
   The scrim is built once and then lives in the DOM at `inset: 0` forever. At
   opacity 0 it is invisible and STILL SWALLOWS EVERY CLICK ON THE PAGE -- a
   whole site made unusable by one long press, with nothing on screen to show
   why. Toggling it on the same class that fades it means the two can never
   disagree, and both directions still animate (display: none would kill the
   fade-out, and a timer to flip it would be a race against the transition). */
#hub-theme-scrim {
  position: fixed; inset: 0; z-index: 2147483000; background: var(--backdrop2);
  opacity: 0; pointer-events: none; transition: opacity .2s ease;
}
#hub-theme-scrim.in { opacity: 1; pointer-events: auto; }
#hub-theme-sheet {
  position: fixed; z-index: 2147483001;
  left: 10px; right: 10px; bottom: calc(10px + env(safe-area-inset-bottom, 0px));
  max-width: 460px; margin: 0 auto;
  transform: translateY(116%); transition: transform .26s cubic-bezier(.2,.9,.25,1);
}
#hub-theme-sheet.in { transform: translateY(0); }
#hub-theme-sheet .hts-grp {
  background: var(--hub-sheet); border: 1px solid var(--line2); border-radius: 14px;
  overflow: hidden;
  -webkit-backdrop-filter: saturate(170%) blur(22px);
  backdrop-filter: saturate(170%) blur(22px);
}
#hub-theme-sheet .hts-ttl {
  text-align: center; font-size: 12px; color: var(--muted3);
  padding: 10px 12px 9px; border-bottom: 1px solid var(--line);
}
/* 48px of row, not 44: this is a list read and tapped in the dark, and the
   v455 rule 1.1 floor is a floor. */
#hub-theme-sheet button.hts-item {
  display: flex; align-items: center; gap: 10px; width: 100%; min-height: 48px;
  text-align: left; background: none; border: 0;
  border-bottom: 1px solid var(--line); color: var(--text);
  font: inherit; font-size: 15px; padding: 12px 14px; cursor: pointer;
}
#hub-theme-sheet button.hts-item:last-child { border-bottom: 0; }
#hub-theme-sheet button.hts-item:active { background: var(--btn); }
#hub-theme-sheet button.hts-item:focus-visible {
  outline: 2px solid var(--accent); outline-offset: -2px;
}
#hub-theme-sheet .hts-nm { margin-right: auto; }
#hub-theme-sheet .hts-sub { font-size: 11px; color: var(--muted3); }
#hub-theme-sheet .hts-tick { color: var(--accent); font-weight: 800; }
#hub-theme-sheet button.hts-cancel {
  margin-top: 8px; width: 100%; min-height: 48px; background: var(--hub-sheet);
  border: 1px solid var(--line2); border-radius: 14px; color: var(--accent);
  font: inherit; font-weight: 700; font-size: 15px; padding: 12px; cursor: pointer;
  -webkit-backdrop-filter: saturate(170%) blur(22px);
  backdrop-filter: saturate(170%) blur(22px);
}
/* The half-second hold, shown rather than guessed at. Without it a long press
   reads as a dead button until the sheet arrives. */
#themebtn.hub-armed {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent);
}
@media (prefers-reduced-motion: reduce) {
  #hub-theme-scrim, #hub-theme-sheet { transition: none; }
}

* { margin: 0; padding: 0; box-sizing: border-box; }

html, body {
  /* v486: was `height: 100%`. THIS was the bug behind "the Admin and Add buttons
     start at the bottom, then ride up the screen as I scroll and stop in the
     middle" on iPhone.
     
     With height:100% the body is exactly one viewport tall and the content
     overflows it. iOS Safari then resolves a bottom-anchored position:fixed
     element against that one-viewport-tall box rather than against the visual
     viewport, so it behaves like position:absolute — correctly pinned at scroll
     zero, then scrolling away upward with everything else. It affected every
     bottom-fixed chrome on the site at once: the 🛠 Admin chip injected in
     base.html, FinTrack's #fin-fab (＋ Add), and #refreshbtn.
     
     min-height:100% keeps the ONLY thing height:100% was there for — a full-bleed
     background when a page is shorter than the screen — while letting the body
     grow to its real content height, which is what makes position:fixed resolve
     against the viewport again. Nothing depended on the hard constraint: the
     game shells size themselves with 100dvh on #game-root, not with a percentage
     of the body. */
  min-height: 100%;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
  overscroll-behavior: none;
  /* V573: user-select: none was HERE and is gone. See the V542 block below for
     what it cost and why removing it is safe. -webkit-tap-highlight-color stays:
     it suppresses the grey flash on tap, which is unrelated to selection. */
  -webkit-tap-highlight-color: transparent;
}

/* =========================================================================
   V547 — EVERY ADMIN SETTING IN ITS OWN BLOCK
   The admin pages were a flat run of headings and controls with nothing between
   them, so a radio group could belong to the heading above it or the one below
   and there was no way to tell by looking. The owner's report was exactly that.
   The boundary already existed -- an <h2> starts a new setting -- so the card is
   drawn around it rather than a new one being invented.
   tool_admin_blocks.js does the wrapping; this only paints it.
   ====================================================================== */
.ta-block {
  background: var(--panel);
  border: 1px solid var(--line2);
  border-radius: 12px;
  padding: 14px 16px 16px;
  margin: 0 0 16px;
}
/* The heading is the card's header, so it loses its own outside margin and gains
   a rule under it -- which is what makes "these controls belong to this label"
   readable at a glance, and is the whole point of the change. */
.ta-block > h2 {
  margin: 0 0 12px;
  padding-bottom: 10px;
  border-bottom: 1px solid var(--line);
  font-size: 16px;
}
.ta-block > h2:only-child { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; }
/* V968: a BAND-ONLY heading (V966's h2.sechd, used where the template could not
   be wrapped) is still a boundary for tool_admin_blocks.js, so .ta-block now
   draws its card body. The band must sit FLUSH at the card's top rather than
   floating inside its padding, hence the negative margins, and it keeps the
   band's own type -- the generic rules above would give it a 16px title and a
   rule underneath, which is a second divider directly below a solid navy bar. */
.ta-block > h2.sechd {
  margin: -14px -16px 12px;
  padding: 10px 14px;
  border-bottom: 0;
  border-radius: 11px 11px 0 0;
  font-size: 14.5px;
}
.ta-block > h2.sechd:only-child { margin-bottom: -16px; }
/* A .panel inside a card would be a box inside a box saying the same thing. */
.ta-block > .panel { border: 0; padding: 0; background: none; margin-bottom: 10px; }
/* A SUB-SETTING inside a card. Settings holds "Page layout"; FinTrack holds three.
   CSS cannot wrap an h3 and its controls the way the script wraps an h2, so the
   separation is drawn as a rule ABOVE each sub-heading -- which is enough to say
   "a new setting starts here" and is what was missing from the owner's screenshot,
   where Page layout ran straight into the download setting below it. */
.ta-block > h3 {
  margin: 18px 0 8px; font-size: 14px;
  padding-top: 14px; border-top: 1px solid var(--line);
  color: var(--accent);
}
.ta-block > h3:first-of-type { margin-top: 4px; padding-top: 0; border-top: 0; }
/* Sub-settings inside one card -- FinTrack has three -- get a light left rule so
   a card holding several is still legible as several. */
.ta-block .ta-sub {
  border-left: 2px solid var(--line2);
  padding-left: 12px; margin: 12px 0;
}
@media (max-width: 640px) { .ta-block { padding: 12px 12px 14px; border-radius: 10px; } }
@media print { .ta-block { break-inside: avoid; } }

/* =========================================================================
   V542 / V573 — TEXT YOU ARE MEANT TO READ IS SELECTABLE AGAIN
   The body USED TO SET user-select: none. That came from the drag-based games,
   where an accidental text selection ruins a swipe -- but it applied to the
   WHOLE app, so no table cell, error message, GL code or chord name anywhere on
   the site could be selected or copied. The owner hit it on the taxonomy
   import preview: a row saying which category was a problem, and no way to copy
   it into a message.

   V542 restored selection for the surfaces below -- an allow-list, which is why
   the owner kept finding new places he could not copy from: a paragraph, a
   heading and a definition are all things you read, and none of them is a
   <table>. V573 removes the rule from `body` instead, so the default is
   "selectable" and only the boards opt out.

   THIS CORRECTS THE V542 WORDING, which said "The body above sets
   user-select: none" -- true when it was written, false since V573, and a
   sentence a future reader would have trusted.

   THE BOARDS ARE UNAFFECTED, and each was checked rather than assumed:

       #ms-board   minesweeper.css:73     #sk-board  sudoku.css:72
       .ch-piece   chess.css:76           .tt-cell   tictactoe.css:16
       .ws-tile    wordsweeper.css:16     .sn-pad    snake.css:127

   Note the first two are ID selectors; V542's note wrote them as classes. All
   six win twice over: their stylesheets load from {% block head %}, which
   base.html renders AFTER this file, and an id or class beats a bare `body`
   on specificity anyway. So removing the body rule cannot re-enable selection
   on a board even if the load order ever changed.

   The rules below are now belt-and-braces rather than the mechanism, and are
   kept: `input`/`textarea`/`[contenteditable]` are the one group where a UA
   stylesheet or a future container rule could still turn selection off, and
   losing selection in a text field is the worst version of this bug.
   ====================================================================== */
table, th, td,
pre, code, kbd, samp,
input, textarea, [contenteditable="true"],
.fb-modal, .bio-modal,
.tb-app:not(.tb-stage) {
  -webkit-user-select: text;
  user-select: text;
}

#game-root {
  height: 100dvh;
  display: flex;
  flex-direction: column;
}

#hud {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: calc(10px + env(safe-area-inset-top)) 16px 10px;
  color: var(--muted);
}

#hud-level { font-size: 18px; font-weight: 600; letter-spacing: 0.5px; line-height: 1.2; }
#hud-band { font-size: 12px; font-weight: 500; color: var(--muted2); }
#hud-stars { color: var(--accent); letter-spacing: 1px; }
#hud-wires { color: var(--text); font-weight: 600; }

#hud-hint, #hud-solution {
  background: var(--btn);
  color: var(--accent);
  border: 1px solid var(--line2);
  border-radius: 10px;
  font-size: 14px;
  font-weight: 600;
  padding: 8px 14px;
  cursor: pointer;
}
#hud-hint:active, #hud-solution:active { transform: scale(0.96); }
#hud-hint.used, #hud-solution.used {
  opacity: 0.35;
  color: var(--muted3);
  cursor: default;
}
#hud-solution.hidden { display: none; }
#hud-level.tappable { text-decoration: underline dotted; cursor: pointer; }

#hud-lives {
  font-size: 17px; letter-spacing: 2px;
  background: var(--btn); border: 1px solid var(--line2);
  border-radius: 10px; padding: 6px 12px;
}
#hud-lives .life { color: var(--accent); }
#hud-lives .life.lost { color: var(--line); }

#board-wrap {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 6px 10px calc(14px + env(safe-area-inset-bottom));
  min-height: 0;
}

#board { touch-action: none; display: block; border-radius: 12px; }

#overlay {
  position: fixed;
  inset: 0;
  background: var(--backdrop);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 10;
}
#overlay.hidden { display: none; }

#overlay-card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: 28px 32px;
  text-align: center;
  color: var(--text);
  max-width: 320px;
}
#overlay-card h2 { font-size: 22px; margin-bottom: 8px; }
#overlay-card p { font-size: 14px; color: var(--muted); margin-bottom: 18px; }

#overlay-card p { white-space: pre-line; }

#overlay-btn, #overlay-btn2 {
  background: var(--accent);
  color: var(--accent-fg);
  border: 0;
  border-radius: 10px;
  font-size: 16px;
  font-weight: 600;
  padding: 12px 28px;
  cursor: pointer;
  margin: 4px 6px 0;
}
#overlay-btn:active, #overlay-btn2:active { transform: scale(0.97); }
#overlay-btn2 {
  background: var(--btn);
  color: var(--accent);
  border: 1px solid var(--line2);
}
#overlay-btn2.hidden { display: none; }

/* ---------- HUD row 2: timer + best times ---------- */

#hud-row2 {
  position: relative;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 10px;
  padding: 0 16px 6px;
  color: var(--muted);
  font-size: 13px;
}
#hud-timer {
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  color: var(--accent);
  font-size: 15px;
}
#hud-best { text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#hud-best .mine { color: var(--text); font-weight: 600; }
#hud-best .flag, .flag { vertical-align: -1px; border-radius: 2px; }

#hud-levels {
  background: var(--btn);
  color: var(--text);
  border: 1px solid var(--line2);
  border-radius: 10px;
  font-size: 13px;
  font-weight: 600;
  padding: 8px 10px;
  cursor: pointer;
}
#hud-levels:active { transform: scale(0.96); }

#hud-checks {
  background: var(--btn);
  color: var(--muted);
  border: 1px solid var(--line2);
  border-radius: 10px;
  font-size: 13px;
  font-weight: 600;
  padding: 8px 10px;
}
#hud-checks.used { opacity: 0.35; }
#hud-lives .bonus { color: var(--good); font-size: 13px; margin-left: 4px; letter-spacing: 0; }

/* ---------- toast ---------- */

#toast {
  position: fixed;
  left: 50%;
  bottom: calc(24px + env(safe-area-inset-bottom));
  transform: translateX(-50%) translateY(20px);
  background: var(--panel);
  border: 1px solid var(--line2);
  color: var(--text);
  font-size: 13px;
  padding: 10px 16px;
  border-radius: 10px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s, transform 0.25s;
  z-index: 30;
  max-width: 90vw;
  text-align: center;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }

/* ---------- level browser ---------- */

#picker {
  position: fixed;
  inset: 0;
  background: var(--backdrop2);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 20;
}
#picker.hidden { display: none; }

#picker-card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 14px;
  width: min(560px, 94vw);
  max-height: 86dvh;
  display: flex;
  flex-direction: column;
  padding: 16px;
  color: var(--text);
}
#picker-head { display: flex; align-items: center; gap: 8px; }
#picker-head h2 { font-size: 18px; margin-right: auto; }
#picker-head select, #picker-head input {
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 7px 8px;
  font-size:var(--fld-fs,13px);
}
#picker-head input { width: 78px; }
#picker-head button {
  background: var(--btn);
  color: var(--accent);
  border: 1px solid var(--line2);
  border-radius: 8px;
  padding: 7px 12px;
  font-weight: 600;
  cursor: pointer;
}
#picker-sub { color: var(--muted2); font-size: 12px; margin: 8px 0 12px; }

#picker-grid {
  overflow-y: auto;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
  gap: 8px;
  padding-bottom: 4px;
}
.chip {
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 10px;
  color: var(--text);
  padding: 8px 4px;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
}
.chip .n { font-weight: 700; font-size: 15px; }
.chip .t { font-size: 11px; color: var(--muted2); }
.chip.solved { border-color: var(--good-line); }
.chip.solved .t { color: var(--good); }
.chip.current { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
.chip.current .t { color: var(--accent); font-weight: 600; }
.chip.locked { cursor: not-allowed; }
.chip.locked .n { filter: blur(4px); opacity: 0.5; }
.chip.locked .t { filter: none; }
.chip.more { grid-column: 1 / -1; color: var(--muted2); }
.chip.pulse { animation: chip-pulse 0.4s ease 3; }
@keyframes chip-pulse {
  50% { transform: scale(1.08); border-color: var(--accent); }
}

#hud-replay { color: var(--accent); font-size: 13px; font-weight: 600; }


/* ---------- back + trophy buttons ---------- */

#hud-back {
  background: var(--btn);
  color: var(--text);
  border: 1px solid var(--line2);
  border-radius: 10px;
  font-size: 16px;
  font-weight: 700;
  padding: 7px 12px;
  text-decoration: none;
  line-height: 1;
}
#hud-back:active { transform: scale(0.96); }

#hud-lb {
  background: var(--btn);
  border: 1px solid var(--line2);
  border-radius: 10px;
  font-size: 14px;
  padding: 8px 10px;
  cursor: pointer;
}
#hud-lb:active { transform: scale(0.96); }

/* ---------- in-game leaderboard panel ---------- */

#lbpanel {
  position: fixed;
  inset: 0;
  background: var(--backdrop2);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 20;
}
#lbpanel.hidden { display: none; }

#lbpanel-card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 14px;
  width: min(480px, 94vw);
  max-height: 86dvh;
  overflow-y: auto;
  padding: 16px;
  color: var(--text);
}
#lbpanel-head { display: flex; align-items: center; gap: 10px; }
#lbpanel-head h2 { font-size: 18px; margin-right: auto; }
/* V578: was a hard-coded #ff5252 -- 2.91:1 on the light panel, 3.81:1 on the
   dark one, i.e. a "live" indicator that was not legible in either theme. */
#lb-live { color: var(--bad); font-size: 11px; animation: um-live 1.1s ease-in-out infinite; }
@keyframes um-live { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
#lb-close {
  background: var(--btn);
  color: var(--accent);
  border: 1px solid var(--line2);
  border-radius: 8px;
  padding: 6px 11px;
  font-weight: 600;
  cursor: pointer;
}
#lb-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; margin-top: 12px; }
#lb-table th, #lb-table td { padding: 8px 8px; border-bottom: 1px solid var(--line); text-align: left; }
#lb-table th { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
#lb-table th:nth-child(3), #lb-table td:nth-child(3),
#lb-table th:nth-child(4), #lb-table td:nth-child(4) { text-align: right; font-variant-numeric: tabular-nums; padding-right: 26px; }
#lb-table td:first-child { font-size: 15px; }
/* Top-3 rows: medals only (🥇🥈🥉 in the first column) — no gold/silver/
   bronze row tint or coloured stripe. */
#lb-table tr.me td {
  background: var(--btn); font-weight: 700; font-size: 15px; color: var(--text);
  border-top: 1.5px solid var(--gold); border-bottom: 1.5px solid var(--gold);
  padding-top: 11px; padding-bottom: 11px;
  animation: um-meglow 2.2s ease-in-out infinite;
}
/* V578: the BORDER stays --gold (that is the medal, and a 1.5px rule is
   decoration, not text) but the TEXT does not: --gold measures 2.55:1 on --btn in
   light theme. --accent is the same family and clears the floor on this row.
   See the --gold comment at the top of this file. */
#lb-table tr.me td:first-child {
  border-left: 1.5px solid var(--gold); border-radius: 10px 0 0 10px; color: var(--accent);
}
#lb-table tr.me td:last-child {
  border-right: 1.5px solid var(--gold); border-radius: 0 10px 10px 0; color: var(--accent);
}
@keyframes um-meglow {
  0%, 100% { box-shadow: 0 -6px 14px -8px rgba(255,215,0,0.45), 0 6px 14px -8px rgba(255,215,0,0.45); }
  50% { box-shadow: 0 -8px 18px -6px rgba(255,215,0,0.85), 0 8px 18px -6px rgba(255,215,0,0.85); }
}
#lb-table tr.gap td { text-align: center; color: var(--muted3); border-bottom: 0; padding: 3px; }
#lb-me { margin-top: 12px; color: var(--muted); font-size: 13px; }


#hud-wires2 { color: var(--text); font-weight: 600; font-size: 13px; }

/* ---------- mobile: compact HUD so timer + records stay visible ---------- */

@media (max-width: 520px) {
  #hud {
    padding: calc(6px + env(safe-area-inset-top)) 8px 6px;
    gap: 4px;
    flex-wrap: wrap;
  }
  #hud-level { font-size: 14px; }
  #hud-back, #hud-levels, #hud-lb, #hud-hint, #hud-checks {
    font-size: 12px;
    padding: 5px 7px;
    border-radius: 8px;
  }
  #hud-lives { font-size: 13px; letter-spacing: 1px; padding: 5px 8px; }
  #hud { gap: 8px; }
  #hud-row2 {
    flex-wrap: wrap;
    padding: 0 8px 4px;
    font-size: 11px;
    gap: 6px;
  }
  #hud-timer { font-size: 13px; }
  #hud-best { white-space: normal; text-align: right; flex: 1; }
  #board-wrap { padding: 4px 6px calc(8px + env(safe-area-inset-bottom)); }
}

/* small difficulty stars under the level number */
#hud-stars2 { font-size: 10px; color: var(--accent); letter-spacing: 2px; line-height: 1.3; }
@media (max-width: 520px) {
  #hud-stars2 { font-size: 8px; letter-spacing: 1px; }
}

/* ---------- cookie consent bar ---------- */
#cookiebar {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  background: var(--panel);
  border-top: 1px solid var(--line2);
  color: var(--text);
  font-size: 13px;
  padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: center;
  justify-content: center;
  z-index: 50;
  font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
}
#cookiebar.hidden { display: none; }
#cookiebar a { color: var(--muted2); }
#cookiebar .cb-btns { display: flex; gap: 8px; }
#cookiebar button {
  border-radius: 8px; padding: 8px 14px; font-weight: 600; cursor: pointer; font-size: 13px;
}
#cb-essential { background: var(--btn); color: var(--text); border: 1px solid var(--line2); }
#cb-all { background: var(--accent); color: var(--accent-fg); border: 0; }

/* ---------- V671: a 44px tap area for a link that must not change size --------
 *
 * HIS ASK, after seeing the before/after mockup: "ok,.fix all".
 *
 * The floor in the (pointer: coarse) block below covers `button`, `.mini`, `.back`
 * and a handful of named controls. It never covered a BARE <a> -- and the links it
 * misses are the ones in running text and in footers, measured at 14 to 17px tall.
 *
 * PADDING, PULLED BACK BY AN EQUAL NEGATIVE MARGIN. The visible text does not move
 * and the line it sits on does not grow; only the hit box does. `min-width` is
 * deliberately NOT set: a text link given a 44px minimum width becomes a block and
 * shoves its neighbours apart, which is a worse bug than the one being fixed.
 *
 * `.tap-row` is the other half. Three 44px boxes on one line would otherwise stretch
 * that line to 44px; the row keeps its own line-height and lets the boxes overlap it,
 * which is safe precisely because they are siblings on a single line.
 *
 * ONLY ON A TOUCH SCREEN. On a mouse a 14px link is a 14px target and that is fine --
 * this is the same condition the floor below uses, for the same reason.
 */
/* ===========================================================================
 * V1034 --- THE KEYBOARD RING, WHERE IT HAD GONE MISSING
 * ===========================================================================
 * Tabbed through all 82 no-argument pages and read the computed style of
 * whatever had focus. TABBED, not `element.focus()` from script: this site
 * styles `:focus-visible`, which matches on keyboard focus, so a sweep that
 * focuses programmatically measures a state nobody is ever in.
 *
 * 35 controls on 17 pages showed nothing at all. Two causes, and the first
 * was the bigger one:
 *
 *   1  `SEL:focus { outline: none; ... }` -- fifteen rules in the tool
 *      stylesheets and five more in templates' inline <style> blocks, every
 *      one of them killing the ring for the keyboard as well as the mouse.
 *      Each is now a pair: the rule keeps what it had, and the outline is
 *      dropped only on `:focus:not(:focus-visible)`. Clicking stays clean and
 *      tabbing gets the ring back.
 *
 *   2  a handful of tab strips and search boxes that end up with
 *      `outline-width: 0` from somewhere and never had a focus style of
 *      their own. They are named here rather than hunted one stylesheet at a
 *      time, because the fix is identical and the list is the finding.
 *      `.fl-tab`, `.mm-tab` and `#fm-root .fm-tabs button` already carry
 *      exactly this rule in their own sheets -- these are the ones nobody
 *      had got to.
 */
.rh-tab:focus-visible, .fr-tab:focus-visible, .cat-tab:focus-visible,
.sam-sidetab:focus-visible, .conv-seg-btn:focus-visible,
.refresh-btn:focus-visible, .tn-input:focus-visible,
#dashSearch:focus-visible, #mp-q:focus-visible {
  outline: 2px solid var(--accent, #e0913f);
  outline-offset: 2px;
}

/* ===========================================================================
 * V1034 --- NOTHING GOES OFF THE RIGHT EDGE AT 320px
 * ===========================================================================
 * Measured, at 320px, over all 82 no-argument pages, TWICE -- once with the
 * sweep's `reveal` on and once with the page as a visitor actually gets it.
 * That difference is the finding:
 *
 *     revealed      8 pages scroll sideways
 *     as shipped    4, and three of them by 2 to 4 pixels
 *
 * The other four only overflow when a pane nobody has opened is forced
 * visible -- Noise Lab's info popup at 227px, TuneBook's import label at 96.
 * Those are not faults a person meets, and "fixing" them would be changing
 * layout nobody sees on the strength of a measurement of a state nobody is
 * in. They are recorded in PENDING instead.
 *
 * WHAT IS LEFT IS SMALL AND IT IS ALWAYS THE SAME SHAPE: something with a
 * width of its own -- an input, a canvas, a tab strip -- inside a row that
 * will not let it shrink. `min-width:0` is the half that is usually missing:
 * a flex item refuses to go below its content width without it, which is why
 * a field with `flex:1` still pushes the page four pixels wide.
 */
@media (max-width: 380px) {
  /*  Anything with an intrinsic width stops at the box it is in. `canvas` is
      in this list because a chart sizes itself from its attribute and will
      happily draw 300px inside a 296px column. */
  input, select, textarea, canvas, img, video { max-width: 100%; }
  /*  And the flex rows that hold them are allowed to shrink. Scoped to rows
      that already wrap, so this cannot flatten a layout that depends on not
      wrapping. */
  .row > input, .row > select, .fld > input, label > input { min-width: 0; }
}

/* ===========================================================================
 * V1034 --- THE TOUCH FLOOR, MEASURED RATHER THAN ASSUMED
 * ===========================================================================
 * Opened a real phone context over all 82 no-argument pages -- `is_mobile` and
 * `has_touch`, so Chromium reports `pointer: coarse` and the rules below this
 * one actually apply -- and measured every operable thing on them.
 *
 * THE FIRST MEASUREMENT WAS WRONG AND IT IS WORTH RECORDING WHY. A 320px
 * window driven by a MOUSE reported 1,859 undersized targets, `a.tap44`
 * among them at 42x15 -- the very mechanism built to fix this, "failing",
 * because its own `@media (pointer: coarse)` never matched. A desktop window
 * made narrow is not a phone, and a sweep that forgets that sends somebody to
 * repair a thing that works.
 *
 * ON A REAL TOUCH CONTEXT, AT THE 24px AA FLOOR: 152 on 35 pages, in eleven
 * shapes. Nearly all of them are one of two things -- a bare `input` or
 * `select` with no height of its own, and a text link in a sentence.
 *
 * 24 AND NOT 44, which is a judgement and not a shortcut. WCAG 2.2 puts
 * 24x24 at AA (2.5.8) and 44x44 at AAA (2.5.5). This site is dense on
 * purpose -- admin tables, chip rows, a Bingo board -- and a blanket 44
 * would not be a fix, it would be a different site. 24 is the line under
 * which a target is a defect by anybody's reading. `.tap44` above still
 * gives 44 where a link stands alone and can afford it.
 *
 * ONLY ON A TOUCH SCREEN, the same condition and the same reason as
 * `.tap44`: on a mouse a 14px control is a 14px target and that is fine.
 */
@media (pointer: coarse) {
  /*  A control with no height of its own. `min-height` rather than `height`
      so nothing that already sets one is overridden, and the three types are
      named rather than using a bare element selector so a checkbox or radio
      -- hit through its label, and 13x13 by design -- is left alone. */
  input:not([type=checkbox]):not([type=radio]):not([type=hidden]),
  select, textarea, button { min-height: 24px; }

  /*  A LINK INSIDE A SENTENCE is the other half, and it cannot take a
      min-height without pushing the line apart -- which is the fault
      `.tap44`'s own note says it refused to cause. Vertical padding with a
      matching negative margin grows the hit box and leaves the line where it
      was; `inline-block` is what makes the padding count at all on an inline
      element. */
  .tap-floor, a.tbar-lnk, a.vn-see, a.ins-back, a.bd-template-link,
  a#hub-wx-link, a#joinChangeUid,
  /*  AN UNCLASSED LINK IS A LINK IN A SENTENCE, which is exactly the case
      this treatment is for -- the back-links, the footer address, the two
      policy pages. Anything with a class is a component and has been given a
      size by whoever built it, so this leaves those alone and cannot flatten
      a button or a chip. */
  a:not([class]) {
    display: inline-block; padding-top: 5px; padding-bottom: 5px;
    margin-top: -5px; margin-bottom: -5px;
  }

  /*  The small button. `.btn.sm` exists to be small and it measured 20px
      high; 24 is the floor, and four pixels do not make it a big button.
      `#bingo` is named because that sheet sets its own height and would
      otherwise win on specificity -- which it did, the first time this was
      written without it. */
  .btn.sm, #bingo .btn.sm { min-height: 24px; }
}

@media (pointer: coarse) {
  a.tap44 {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    padding: 0 6px;
    margin: -12px -6px;
    vertical-align: middle;
  }
  .tap-row { line-height: 1.25; }
}

/* ---------- theme toggle (dark / light / system) ---------- */
#themebtn {
  /* V910: the long press must not also raise iOS's text-selection callout.
     FOLDED IN HERE rather than written as a second rule for this same selector
     further up the file, which is how V910 first shipped it. Two rules for one
     selector is the shape that cost V909 a release -- the second wins or loses
     on source order -- and test_v417 reads "the themebtn block" expecting one.
     NO BRACES IN THIS COMMENT, either: that suite finds the block with a
     non-greedy match to the first closing brace, so a brace written in prose
     ends the block early and the properties below go unread. */
  -webkit-touch-callout: none;
  position: fixed;
  left: 12px;
  bottom: calc(12px + env(safe-area-inset-bottom));
  width: 40px; height: 40px;
  border-radius: 50%;
  background: var(--panel);
  border: 1px solid var(--line2);
  font-size: 17px;
  cursor: pointer;
  z-index: 60;  /* above the cookie banner */
  box-shadow: 0 3px 10px rgba(0,0,0,0.3);
  /* v417: emoji glyphs carry their own side bearings and baseline, which left
     the moon/sun sitting low and right of centre — flex-centre it instead */
  display: flex; align-items: center; justify-content: center;
  padding: 0; line-height: 1; text-align: center; box-sizing: border-box;
}
#themebtn:active { transform: scale(0.94); }
/* when docked into a game toolbar it stops floating over the board */
#themebtn.inbar {
  position: static; left: auto; bottom: auto;
  width: 34px; height: 34px; font-size: 15px;
  box-shadow: none; margin: 0; flex: 0 0 auto;
}
/* V672: 34x44 MEASURED -- short in WIDTH only, which is why the floor further down
   this file never caught it: that rule sets min-height AND min-width, and the width is
   overridden right here by the `width: 34px` this docked variant exists for.

   AND 34x44 IS ALSO AN ELLIPSE. `border-radius: 50%` on a box the floor has made 44
   tall and this rule keeps 34 wide is not a circle, and nobody noticed because the
   floor arrived in a different release from the width. So 44 square is the fix for
   both: the target reaches the floor and the button is round again.

   PADDING WAS THE FIRST CUT AND IT DID NOTHING -- box-sizing is border-box site-wide,
   so 5px of padding inside `width: 34px` is absorbed and the box never grows. That is
   the same trap as `button.idot` in V671. Twice in two releases is why test_v672
   measures rendered boxes on a phone instead of reading declarations.

   THE COST: the chrome row grows by 10px per button. V651's note on `.tbar-r`'s
   `flex: 0 0 auto` is the record of what that row does under pressure, so it was
   measured rather than assumed -- at 390px, with the words already hidden by the
   breakpoint below, the row still fits on one line. */
@media (pointer: coarse) {
  #themebtn.inbar, #refreshbtn.inbar { width: 44px; height: 44px; }
}

/* close X on dismissible overlays */
#overlay-card { position: relative; }
#overlay-x {
  position: absolute;
  top: 8px; right: 10px;
  background: none;
  border: 0;
  color: var(--muted2);
  font-size: 16px;
  cursor: pointer;
  padding: 4px 6px;
}
#overlay-x.hidden { display: none; }

/* ---------- install-app pill ---------- */
#installbar {
  position: fixed;
  right: 12px;
  bottom: calc(12px + env(safe-area-inset-bottom));
  display: flex;
  align-items: center;
  gap: 8px;
  background: var(--panel);
  border: 1px solid var(--line2);
  border-radius: 24px;
  padding: 8px 10px 8px 14px;
  color: var(--text);
  font-size: 13px;
  z-index: 55;
  box-shadow: 0 4px 14px rgba(0,0,0,0.35);
  max-width: min(92vw, 380px);
  font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
}
#installbar.hidden { display: none; }
#ib-install {
  background: var(--accent);
  color: var(--accent-fg);
  border: 0;
  border-radius: 16px;
  padding: 7px 14px;
  font-weight: 700;
  font-size: 13px;
  cursor: pointer;
}
#ib-install.hidden { display: none; }
#ib-later {
  background: none;
  border: 0;
  color: var(--muted2);
  font-size: 14px;
  cursor: pointer;
  padding: 4px 6px;
}


/* ---------------- daily challenge / streak / challenge bar (v25) ------- */
#hud-daily {
  background: var(--btn); color: var(--text); border: 1px solid var(--line2);
  border-radius: 8px; width: 34px; height: 30px; font-size: 15px;
  cursor: pointer; line-height: 1;
}
#hud-daily:active { transform: scale(0.96); }
#hud-streak { position: absolute; top: 50%; transform: translateY(-50%); color: var(--accent); font-weight: 700; font-size: 13px; white-space: nowrap; }
#hud-streak.hidden { display: none; }
#challbar {
  text-align: center; font-size: 13px; padding: 4px 10px;
  background: var(--btn); color: var(--accent);
  border-bottom: 1px solid var(--line);
}
#challbar.hidden { display: none; }
#dailypanel {
  position: fixed; inset: 0; z-index: 55; display: flex;
  align-items: center; justify-content: center;
  background: rgba(0,0,0,0.55);
}
#dailypanel.hidden { display: none; }
#dailypanel-card {
  background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
  padding: 16px 18px; width: min(420px, 92vw); max-height: 84vh; overflow: auto;
  color: var(--text);
}
#dailypanel-head { display: flex; align-items: center; gap: 10px; }
#dailypanel-head h2 { font-size: 18px; margin-right: auto; }
#daily-date { color: var(--muted2); font-size: 13px; }
#daily-close {
  background: none; border: 0; color: var(--muted); font-size: 16px; cursor: pointer;
}
#daily-sub { color: var(--muted2); font-size: 12px; margin: 6px 0 10px; }
#daily-play {
  width: 100%; padding: 10px; border-radius: 10px; border: 0; cursor: pointer;
  /* V578: was `color: #123`, a near-black picked for the DARK theme's yellow
     --accent and left to sit on the light theme's dark-gold one at 3.71:1. This is
     the same mistake .auth-btn made in V526 ("a dark colour chosen for a YELLOW
     fill"), and the fix is the same: a filled button takes the fill's own ink
     token, so it follows --accent wherever --accent goes. 5.47:1 light,
     10.36:1 dark. */
  background: var(--accent); color: var(--accent-fg); font-weight: 700; font-size: 15px;
  margin-bottom: 10px;
}
#daily-play:active { transform: scale(0.98); }
#daily-table { width: 100%; border-collapse: collapse; font-size: 14px; }
#daily-table th, #daily-table td {
  padding: 7px 8px; border-bottom: 1px solid var(--line); text-align: left;
}
#daily-table th { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
#daily-table tr.me { background: var(--btn); font-weight: 600; }
#daily-me { margin-top: 10px; color: var(--muted); font-size: 13px; }
#celebrate {
  position: fixed; inset: 0; z-index: 90; display: flex;
  align-items: center; justify-content: center;
  background: rgba(0,0,0,0.6);
}
#celebrate.hidden { display: none; }
#celebrate-card {
  text-align: center; background: var(--panel); border: 1px solid var(--accent);
  border-radius: 16px; padding: 26px 30px; max-width: 88vw;
  box-shadow: 0 10px 40px rgba(0,0,0,0.5);
}
#celebrate .balloons span {
  display: inline-block; font-size: 34px; margin: 0 4px;
  animation: um-float 1.6s ease-in-out infinite;
}
#celebrate .balloons span:nth-child(2) { animation-delay: 0.2s; }
#celebrate .balloons span:nth-child(3) { animation-delay: 0.4s; }
#celebrate .balloons span:nth-child(4) { animation-delay: 0.6s; }
#celebrate .balloons span:nth-child(5) { animation-delay: 0.8s; }
@keyframes um-float {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-14px); }
}
#celebrate-msg { color: var(--text); font-size: 17px; font-weight: 700; margin: 12px 0 8px; }
#celebrate .tap-hint { color: var(--muted2); font-size: 12px; }
@media (max-width: 520px) {
  #hud-daily { width: 30px; height: 28px; font-size: 13px; }
}


/* v26: hint/checks/lives clustered tight on the right */
#hud-right { display: flex; align-items: center; gap: 6px; margin-left: auto; }
/* v26: daily board shares the podium/me styling */
#daily-table { border-collapse: separate; border-spacing: 0; }
#daily-table td:first-child { font-size: 15px; }
/* Top-3: medals only, no gold/silver/bronze tint or stripe. */
#daily-table tr.me td {
  background: var(--btn); font-weight: 700; color: var(--text);
  border-top: 1.5px solid var(--gold); border-bottom: 1.5px solid var(--gold);
}
#daily-table tr.me td:first-child { border-left: 1.5px solid var(--gold); border-radius: 10px 0 0 10px; }
#daily-table tr.me td:last-child { border-right: 1.5px solid var(--gold); border-radius: 0 10px 10px 0; }

/* v60: day/night button dims on all pages, brightens on hover/tap/focus */
#themebtn { opacity: .4; transition: opacity .2s ease; }
#themebtn:hover, #themebtn:focus, #themebtn:active { opacity: 1; }
#themebtn.inbar { opacity: 1; }

/* v123: global keyboard focus ring (a11y) — visible on tab, invisible on mouse.
   Applies site-wide since this stylesheet loads on every page. */
:focus-visible {
  outline: 2px solid var(--accent, #e0913f);
  outline-offset: 2px;
  border-radius: 4px;
}

/* ===========================================================================
 * v455 — GLOBAL RESPONSIVE LAYER (phase 1 of the responsive rework)
 * ===========================================================================
 * Appended to this file rather than shipped as a second stylesheet ON PURPOSE:
 * measured on 28 Jul 2026, /static is served by Flask, so every extra asset
 * request spawns a Python worker (see v454 and README_DEPLOY "OPTIONAL, ONCE
 * EVER"). One more <link> on all 44 pages is one more worker per page load.
 *
 * Breakpoints, fixed for the whole rework:
 *     520   phones  — already load-bearing in this file, so it stays
 *     768   drawer becomes a sidebar (phase 2)
 *    1024   sidebar always visible (phase 2)
 *    1440   max content width
 * 520 was kept precisely BECAUSE three blocks below already use it; renaming it
 * would mean re-tuning CSS that currently works, for no user-visible gain.
 * ------------------------------------------------------------------------- */

/* --- 1.1 TAP TARGETS ------------------------------------------------------
 * The @media(max-width:520px) block above shrinks #hud-back, #hud-levels,
 * #hud-lb, #hud-hint and #hud-checks to 12px/5px-7px, which is a ~26px hit box.
 * Apple asks for 44pt, Android for 48dp.
 *
 * TWO DECISIONS WORTH KNOWING:
 * (a) `pointer: coarse` only. A mouse can hit 26px all day; forcing 44px on
 *     desktop would inflate every dense admin table for no benefit. Touch and
 *     stylus get the bigger boxes, cursors keep the density.
 * (b) min-height/min-width, NOT a transparent ::after overlay. The overlay
 *     trick keeps the visual size unchanged, which is prettier — but dozens of
 *     controls in this codebase already use ::after for chips, chevrons and
 *     badges, and silently replacing theirs would break them. Growing the box
 *     is honest and cannot collide.
 * NOTE: this is what forces the Untangle HUD overflow sheet in the same
 * release. Six 44px controls plus the level text do not fit across 390px, so
 * without the sheet this rule would make the HUD wrap MORE, not less. */
@media (pointer: coarse) {
  button,
  [role="button"],
  summary,
  a.mini, .mini,
  a.back, .back,
  .fin-tab, .qr-tab, .pt-tab, .bl-tab, .sp-tab, .sam-tab, .cat-tab, .atab,
  .tabgrp-h,
  #hud-back, #hud-levels, #hud-lb, #hud-hint, #hud-checks, #hud-daily, #hud-chall,
  /* V651: .tbar-adm, the gold Admin chip. Under 520px its word is hidden and only
     ⚙ remains, which measured about 26x24 -- the exact failure the note beside
     .tbar-r's `flex: 0 0 auto` describes. A control on the chrome row must clear
     this floor like every other one on it. */
  /*  V1071 (V1061 #23) ADDS #admin-hub. /admin's 🏠 Home is a bare <a> in a
      `.sub` row -- 13px text plus 6px padding, about 30px tall -- sitting
      beside #admin-theme, which is a <button> and therefore matched the
      `button` selector at the top of this list and got its 44. Two controls
      on one header row, one of them two-thirds the size of the other, and the
      small one is the way out.

      ADDED HERE RATHER THAN AS A CLASS ON THE ELEMENT, and that is not
      stylistic: test_v1060 pins that anchor's markup as `<a href="…"
      id="admin-hub">` with href before id, and test_v488 matches on the same
      shape. A selector costs nothing and breaks nothing. */
  #themebtn, #sg-social > a, .tbar-adm, #admin-hub {
    min-height: 44px;
    min-width: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  /* Controls that legitimately sit inside flowing text or a table cell keep
     their width — only the vertical reach is enforced, so a row of them does
     not blow the column apart. */
  td button, td .mini, td a.mini,
  .fin-tbl button, .fin-tbl .mini,
  .admintable button, .admintable .mini {
    min-width: 0;
    padding-left: 10px;
    padding-right: 10px;
  }
  /* A checkbox/radio cannot be resized reliably across engines, so the LABEL
     carries the target and the box keeps its native look. */
  label:has(> input[type="checkbox"]),
  label:has(> input[type="radio"]) { min-height: 44px; display: inline-flex; align-items: center; }
  input[type="checkbox"], input[type="radio"] { min-width: 20px; min-height: 20px; }
}

/* --- 1.2 iOS FOCUS ZOOM --------------------------------------------------
 * Safari zooms the viewport when a focused field is under 16px and does NOT
 * zoom back out — the user is left on a page that is too wide, mid-form.
 * finance.html sets .fin-in and friends to 15px; market_pulse uses 13px;
 * messages 14px; bullion 14px. max() keeps any LARGER intended size and only
 * raises the ones below the threshold, so nothing shrinks. */
input, select, textarea,
.fin-in, .fin-fld input, .fin-fld select, .fin-fld textarea {
  font-size: max(16px, 1rem);
}
/* A file input's own text is not typed into, so it never triggers the zoom and
   is left alone — 16px there just makes a long filename wrap. */
input[type="file"] { font-size: inherit; }

/* --- 1.3 THE SAME ZOOM, FROM THE OTHER SIDE (V919) -----------------------
 * HIS REPORT: *"mainly in score keeper in iphone - why does the screen zoom in
 * when i click on seacrh game or search a player"*.
 *
 * The guard above is sound and was being beaten everywhere. It is `input,
 * select, textarea` -- specificity (0,0,1) -- and ANY tool rule that names its
 * own field beats it: `.cp input[type=text]` is (0,2,1), `#bingo input[type=
 * text]` is (1,1,1). FIFTY-ONE rules across twenty-four stylesheets typed a
 * size below 16px, so twenty-four tools zoom on tap and none of them meant to.
 *
 * MATCHING FIFTY-ONE WEIGHTS IS NOT A FIX, and `!important` is not one either
 * (V736's rule). A custom property sidesteps the cascade entirely: it is
 * INHERITED, so where it is set has nothing to do with how specific the rule
 * that reads it is. Each rule keeps its own size as the fallback and the
 * switch below is the only place a phone is mentioned.
 *
 * HIS CHOICE, 02-Oct, shown as three rendered options and picked: the laptop
 * keeps the dense 13.5px he asked to keep, and a touch screen gets 16px --
 * which is the ONLY size iOS does not zoom into. There is no third answer:
 * sub-16px text in a field on iOS zooms, and the only other lever is switching
 * off pinch-zoom for everybody, which is a worse trade for one tidy screen. */
@media (pointer: coarse) {
  :root { --fld-fs: 16px; }
}

/* --- 1.4 SAFE AREA -------------------------------------------------------
 * With apple-mobile-web-app-status-bar-style black-translucent and
 * viewport-fit=cover, a page with no inset padding renders UNDER the notch in
 * standalone mode.
 *
 * The rework brief listed 16 templates as missing this. Re-audited against
 * V454: most of them get it from their STYLESHEET rather than their template —
 * auth.css covers login/register/profile/leaderboard/tools, and every game css
 * covers its own page. Eight pages genuinely had nothing, and they each start
 * with a different root element, so the inset goes on <body> for exactly those
 * eight, opted in by name in base.html. Opt-IN, not opt-out: a page that
 * already handles it and got this too would sit ~47px below the notch. */
body.sa-pad {
  padding-top: env(safe-area-inset-top);
  padding-bottom: env(safe-area-inset-bottom);
  box-sizing: border-box;
}

/* --- 1.6 CSS HYGIENE ----------------------------------------------------
 * The brief reported two @media(max-width:520px) blocks in this file; there are
 * three (the third holds #hud-stars2 and #hud-daily), and one of them declares
 * `#hud { gap }` TWICE — 4px, then 8px eleven lines later, so the first is dead.
 * Consolidating them by moving rules would reshuffle a working cascade for a
 * cosmetic gain, so instead the duplicate is settled explicitly here, at the
 * end of the file where the winner is unambiguous, and the dead declaration is
 * left in place per the no-deletion rule. 8px is the value that was already
 * winning, so computed output is unchanged. */
@media (max-width: 520px) {
  #hud { gap: 8px; }
}

/* --- 1.3 HUD OVERFLOW SHEET ----------------------------------------------
 * WHY, precisely — because the brief's stated reason does not hold on a phone
 * in portrait, and it is worth writing down so nobody "fixes" this back.
 *
 * layout() derives the cell from min(availW/gridW, availH/gridH, 76), and the
 * grid is a FIXED 34x52 for every level (proved by tests/level_golden.json), so
 * the board's aspect is always 0.654 while a 390x844 phone offers 0.51. Measured
 * across HUD heights of 56 / 84 / 112px at 390x844, the cell stays at 10px and
 * the board at 340x520 with 184-240px of vertical slack: the board is WIDTH
 * bound, and a wrapped HUD row costs it nothing at all.
 *
 * Where the height genuinely matters:
 *   844x390 landscape  3 rows -> cell 5 to 4, board 170x260 to 136x208 (-36% area)
 *   768x1024 iPad      2 rows -> cell 18 to 17
 *   320x568 phone      3 rows -> becomes height-bound with 12px to spare
 * And the reason it ships NOW rather than later: rule 1.1 gives every control a
 * 44px box, and six of those plus the level text do not fit across 390px.
 * Without this sheet, the tap-target fix would make the HUD wrap MORE. */
#hud-more { }
#hud-sheet {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin: 0 12px 6px;
  padding: 6px;
  background: var(--panel);
  border: 1px solid var(--line2);
  border-radius: 12px;
  box-shadow: 0 10px 28px rgba(0, 0, 0, .45);
}
#hud-sheet[hidden] { display: none !important; }
#hud-sheet > * {
  justify-content: flex-start !important;
  gap: 10px;
  width: 100%;
  min-height: 44px;
  padding: 0 12px;
  font-size: 15px;
}
/* The sheet spells out what each icon does — there is room for words here, and
   an icon-only menu is a memory test. The label is a real <span> appended by
   syncHudOverflow(), not ::after content: same reasoning as rule 1.1, never
   take over a pseudo-element that something else might already be using. */
#hud-sheet .hud-sheet-lbl { font-weight: 600; font-size: 15px; color: var(--text); }

/* ===========================================================================
 * v457 — APP SHELL (phase 2)
 * ===========================================================================
 * Sidebar on a laptop, drawer on a phone, nothing at all on a canvas game.
 * No bottom tab bar — decided, not omitted.
 *
 * THE ONE STRUCTURAL DECISION. The shell is `position: fixed` and the page is
 * moved over with `padding-left` on BODY. The alternative — a flex wrapper
 * around the content — would have meant editing the root container of all 44
 * templates, each of which has its own (`.fin`, `.main`, `#game-root`, `.qr-wrap`,
 * `.legal`, …). Padding on body needs one rule and cannot break a page's
 * internal layout. It does mean a 100dvh child is measured against the viewport
 * rather than the remaining column, which is exactly why canvas games are
 * `full` and get no sidebar at all.
 * ------------------------------------------------------------------------ */
/* v478: +10% on the panel width, on the owner's instruction — 248 → 273px, and the
 * wide-screen step 268 → 295px (below). The max-width is the important half of the
 * change: 273px of a 320px phone is 85% of the screen, so the drawer would read as a
 * full-screen takeover with a sliver of page behind it. Capped at 88vw, the backdrop
 * stays visible on the narrowest phone — you can always see what you are covering,
 * and tapping it still closes. */
#shell-nav {
  position: fixed;
  top: 0; bottom: 0; left: 0;
  width: 273px;
  max-width: 88vw;
  z-index: 90;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: calc(10px + env(safe-area-inset-top)) 10px calc(14px + env(safe-area-inset-bottom));
  background: var(--panel);
  border-right: 1px solid var(--line2);
  overflow-y: auto;
  overscroll-behavior: contain;
}
.shell-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.shell-home {
  flex: 1 1 0; min-width: 0;            /* v476: two of these share the row, so both may shrink */
  display: inline-flex; align-items: center; min-height: 44px;
  padding: 0 10px; border-radius: 10px; text-decoration: none;
  color: var(--text); font-weight: 800; font-size: 15px; background: var(--btn);
  border: 1px solid var(--line2);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* v476: the admin button is the same shape as Main hub but reads as the owner control
   it is — gold text rather than a gold fill, so it does not compete with the gold
   round buttons directly below it in Quick actions. */
.shell-adm { color: var(--accent); border-color: var(--accent); justify-content: center; }
.shell-btn {
  min-width: 44px; min-height: 44px;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--btn); color: var(--text);
  border: 1px solid var(--line2); border-radius: 10px;
  font-size: 17px; font-family: inherit; cursor: pointer;
}
.shell-sec { margin-bottom: 10px; }
.shell-sec-t {
  font-size: 11px; font-weight: 800; text-transform: uppercase;
  letter-spacing: .04em; color: var(--muted2); padding: 6px 10px 4px;
}
.shell-link {
  display: flex; align-items: center; gap: 9px;
  min-height: 44px; padding: 0 10px; border-radius: 10px;
  color: var(--text); text-decoration: none; font-size: 14px; font-weight: 600;
}
.shell-link:hover { background: var(--btn); }
.shell-link.here { background: var(--btn); color: var(--accent); }
.shell-link.here .shell-nm { font-weight: 800; }
.shell-ic { width: 20px; text-align: center; flex: 0 0 auto; }
.shell-nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shell-hid { margin-left: auto; opacity: .8; }
#shell-backdrop {
  position: fixed; inset: 0; z-index: 85;
  background: rgba(0, 0, 0, .55);
}
/* V512: with the \2630 on the left, give page headers room on screens narrow
   enough for them to meet it (the v468 lesson, solved with clearance instead
   of moving the button back). Wide screens centre their content well clear
   of the viewport edge, so nothing changes there. */
@media (max-width: 900px) {
  .top-bar { padding-left: calc(66px + env(safe-area-inset-left)) !important; }
  .tools-head { padding-left: 56px; }
}
#shell-backdrop[hidden] { display: none !important; }

/* ---- v468: THE MENU BUTTON IS A FLOATING ACTION BUTTON --------------------
 * v467 made the sidebar an overlay but still reserved 56px of page width for the
 * button and parked it in the top-left corner. Both were wrong:
 *
 *   * 56px of reserved gutter is not "floating" — the owner's point exactly. A
 *     floating button sits OVER the content; it does not push it. Pages now get
 *     the full width and content scrolls underneath.
 *   * The top-left corner is where every tool already puts its own "← Hub" link
 *     and title, so the button landed on top of them (visible on Bullion, where
 *     the back link and the page heading were half-covered).
 *
 * Bottom-left solves both: no page element lives there, and it is the easier
 * reach on a phone anyway. It clears #installbar, which is bottom-RIGHT.
 * ------------------------------------------------------------------------ */
/* v472: BACK TO THE TOP, AND IT FADES.
 * v468 put it bottom-left to stop it covering each tool's own "← Hub" link and heading.
 * That collision is gone for a different reason now: v469 moved the five round buttons
 * off the top-right of every page and into the menu, so the top-RIGHT corner — where
 * they used to live — is empty. The button goes there: top of the page as asked, and
 * nowhere near the headings on the left. It also inherits the same slow pulse those
 * buttons had, so it recedes when you are reading and is still findable when you look. */
#shell-open {
  position: fixed !important;
  /* V507: was 89, and Samaya's sticky top-bar (z:100) sat ON TOP of it - the
     only theme/menu control on that page, hidden by its own banner. 900 clears
     every sticky bar (all <=100) and stays under modals (1000+). */
  z-index: 900;
  /* V512: top-LEFT by default (owner's ask) - and the coherent side anyway,
     since the drawer it opens slides in from the left. v468's collision with
     each tool's own "\2190 Hub" link is handled by the narrow-screen top-bar
     clearance right below this block. */
  left: calc(12px + env(safe-area-inset-left));
  right: auto !important;
  top: calc(10px + env(safe-area-inset-top)) !important;
  bottom: auto !important;
  width: 48px; height: 48px; min-width: 48px; min-height: 48px;
  border-radius: 50%;
  font-size: 19px;
  /* v475: fill, border, shadow and opacity are all set in the v475 block below —
     they were left here as dead declarations for one build and read as if the
     button were still panel-coloured. */
}
/* ---- v475: GOLDEN, AND IT FADES ON A TIMER RATHER THAN PULSING ------------
 * v472 gave it a 5s pulse. A pulse never settles: it is moving whenever you look
 * at it, and it is at its brightest half the time whether or not you want it.
 * What was asked for is different and better — bright while you are using it,
 * gone ~1.5s after you stop. That is a state, not an animation, so it is a class
 * that shell.js adds on an idle timer and removes on any interaction.
 *
 * It does NOT go to zero. A control you cannot find is worse than one you can see,
 * so idle is .26 — legible as a shape against any of the page backgrounds, and
 * still fully clickable (opacity does not affect hit-testing).
 * ------------------------------------------------------------------------ */
#shell-open {
  background: linear-gradient(180deg, #f5d454 0%, #e0ab0c 100%);
  border: 1px solid rgba(120, 85, 0, .55);
  color: #2a2100;
  box-shadow: 0 4px 14px rgba(0, 0, 0, .45), 0 0 0 1px rgba(255, 226, 122, .35) inset;
  opacity: 1;
  touch-action: none;          /* the button is draggable; the page must not scroll under the drag */
  cursor: grab;
}
#shell-open.fab-idle { opacity: .26; }
#shell-open:hover, #shell-open:focus, #shell-open.fab-drag {
  opacity: 1 !important; border-color: rgba(120, 85, 0, .8);
}
#shell-open.fab-drag {
  cursor: grabbing;
  transform: scale(1.06);
  box-shadow: 0 10px 26px rgba(0, 0, 0, .55), 0 0 0 3px rgba(234, 189, 35, .35);
}
/* The fade is the whole point, so it stays under reduced-motion — what goes is the
   transition, i.e. the movement, not the change of state. */
@media (prefers-reduced-motion: no-preference) {
  #shell-open { transition: opacity .45s ease, transform .12s ease, box-shadow .15s ease; }
}
@media (prefers-reduced-motion: reduce) {
  #shell-open { animation: none; }
  #shell-open.fab-drag { transform: none; }
}
/* Dragged to a chosen spot, shell.js writes left/top and this stops the default
   top-right rule from fighting it. */
#shell-open.fab-placed { right: auto !important; bottom: auto !important; }
/* While the drawer is open the button would sit on top of the drawer itself. */
body.shell-drawer-open #shell-open { display: none !important; }
/* ---- V1069 (V1061 #27) ----------------------------------------------------
 * ...AND THE SAME IS TRUE OF A FULL-SCREEN SHEET. Raffle Hub's Quick setup is
 * `aria-modal="true"` and covers the viewport; at 390px the ☰ landed on its
 * subtitle, which is the line that says which step you are on.
 *
 * THE SELECTOR IS GENERIC ON PURPOSE. The rule above was written for the
 * drawer and has been the answer to "this button is on top of my panel" ever
 * since; a per-tool z-index would have to be re-won in every tool that grows a
 * sheet. Any page that opens one adds `rh-sheet-open` to <body> and inherits
 * this, and the one obligation it carries -- remove the class when the sheet
 * closes -- is the same obligation the drawer already meets. */
body.rh-sheet-open #shell-open { display: none !important; }
@media (prefers-reduced-motion: no-preference) {
  #shell-open { transition: transform .12s ease, opacity .12s ease; }
  #shell-open:active { transform: scale(.94); }
}

/* ---- V579 (CR-17): NINE SLOTS, ONE CLASS EACH -----------------------------
 * "if show then show in top right top left top central mid central mid left mid
 * right bottom right bottom left bottom central on each refresh".
 *
 * The SERVER picks the slot and puts the class on the button (fabmenu.py, and
 * base.html renders it), so the position is a fact about the response rather than
 * something JavaScript decides after paint. What lives here is only the geometry.
 *
 * WHY !important, AND WHY THESE SELECTORS BEAT THE BLOCKS ABOVE. The base
 * #shell-open block declares `top` and `bottom` with !important (v512, so that a
 * dragged left/top could not be undone by a stale rule). Two !important
 * declarations are settled by specificity, and `#shell-open.fab-at-*` is one class
 * more specific than `#shell-open`, so these win. The base rule is deliberately
 * left alone: test_v512 reads it, and more importantly it is still the fallback
 * for any page rendered before this feature existed (a cached HTML page carries no
 * fab-at-* class and must still put the button somewhere sane).
 *
 * WHY THE CENTRES USE NEGATIVE MARGINS AND NOT translate(-50%). `.fab-drag` sets
 * `transform: scale(1.06)` and `:active` sets `scale(.94)`; a centring transform
 * would be overwritten by both, so the button would jump 24px sideways the instant
 * you touched it. Margins are not in the transform channel and cannot be
 * clobbered. The 24px is half of the button's fixed 48px width/height, declared in
 * the base block above.
 *
 * WHY THE TOP-RIGHT SLOT SITS 56px LOWER THAN THE OTHER TWO TOP SLOTS. The
 * top-right corner is already occupied by #sg-social (theme, refresh, friends,
 * messages) at z-index 99996 -- four orders of magnitude above this button's 900.
 * Placed at the same y the button would be UNDERNEATH that row and unclickable,
 * which is exactly why v512 moved the default from top-right to top-left in the
 * first place. So "top right" means below that row: still the top-right corner,
 * still reachable, and it cannot be buried by furniture that outranks it.
 *
 * SAFE AREA, EVERY EDGE. Each edge offset is env(safe-area-inset-<edge>) plus a
 * gap, so no slot can land under a notch, a home bar or a rounded corner in
 * standalone mode. The mid row needs none: it is centred, which is as far from
 * every inset as the viewport allows.
 *
 * MID-CENTRE FLOATS OVER THE CONTENT, AND THAT IS THE ONE SLOT WITH A REAL COST.
 * Three things keep it from covering something you need, and only the third is new
 * this release: it fades to .26 when idle (v475), it can be dragged anywhere and
 * the drag then wins for the session (v475 + the note in shell.js), and shell.js
 * now looks at what is UNDERNEATH the button on load and steps it clear of any
 * link, button or field it is sitting on. See fabStepAside() there.
 * ------------------------------------------------------------------------ */
/* EXACTLY ONE RULE PER SLOT, and no shared "reset" rule listing all nine. There
 * was one for a build (a `margin: 0` normaliser) and it was wrong twice over: the
 * base #shell-open block sets no margin, so it reset nothing, and a selector list
 * naming all nine slots made every mechanical check of these rules ambiguous --
 * splitting the stylesheet on "#shell-open.fab-at-top-left {" landed in the list
 * instead of the rule. The suite's join checks were reading the words "left" and
 * "top" out of a selector and calling it a position. One rule per slot, appearing
 * once, is what makes those checks mean anything.
 * --- top row --- */
#shell-open.fab-at-top-left {
  left: calc(12px + env(safe-area-inset-left)) !important; right: auto !important;
  top: calc(10px + env(safe-area-inset-top)) !important; bottom: auto !important;
}
#shell-open.fab-at-top-centre {
  left: 50% !important; right: auto !important; margin-left: -24px;
  top: calc(10px + env(safe-area-inset-top)) !important; bottom: auto !important;
}
#shell-open.fab-at-top-right {
  right: calc(12px + env(safe-area-inset-right)) !important; left: auto !important;
  /* 56px = the 44px cluster plus a 12px gap. See the note above. */
  top: calc(66px + env(safe-area-inset-top)) !important; bottom: auto !important;
}
/* --- middle row --- */
#shell-open.fab-at-mid-left {
  left: calc(12px + env(safe-area-inset-left)) !important; right: auto !important;
  top: 50% !important; bottom: auto !important; margin-top: -24px;
}
#shell-open.fab-at-mid-centre {
  left: 50% !important; right: auto !important; margin-left: -24px;
  top: 50% !important; bottom: auto !important; margin-top: -24px;
}
#shell-open.fab-at-mid-right {
  right: calc(12px + env(safe-area-inset-right)) !important; left: auto !important;
  top: 50% !important; bottom: auto !important; margin-top: -24px;
}
/* --- bottom row --- */
#shell-open.fab-at-bottom-left {
  left: calc(12px + env(safe-area-inset-left)) !important; right: auto !important;
  bottom: calc(12px + env(safe-area-inset-bottom)) !important; top: auto !important;
}
#shell-open.fab-at-bottom-centre {
  left: 50% !important; right: auto !important; margin-left: -24px;
  bottom: calc(12px + env(safe-area-inset-bottom)) !important; top: auto !important;
}
#shell-open.fab-at-bottom-right {
  right: calc(12px + env(safe-area-inset-right)) !important; left: auto !important;
  bottom: calc(12px + env(safe-area-inset-bottom)) !important; top: auto !important;
}
/* THE PILL GETS OUT OF THE BUTTON'S WAY, rather than the other way round.
 * The floating "🛠 Admin" pill lives at bottom-right on tool pages at z-index 60;
 * the button is at 900, so on the three bottom slots the button would sit ON the
 * pill and the pill would be the one you could not click. The pill is the thing
 * that moves because it has somewhere to go (straight up, out of a corner nothing
 * else uses) whereas the button's position is the setting under test.
 * !important because it is beating the pill's own inline style.
 *
 * ONE SLOT, NOT THREE. bottom-left and bottom-centre are deliberately absent: the
 * pill is anchored to the RIGHT edge and is about 86px wide, so at 320px -- the
 * narrowest viewport this site supports -- it occupies x 222-308 while a
 * bottom-centre button occupies 136-184. They cannot touch. Moving the pill for
 * those two would be a rule that never fires, which is worse than no rule because
 * the next reader has to work out whether it was needed. */
body.fabpos-bottom-right .fab-pill {
  bottom: calc(72px + env(safe-area-inset-bottom)) !important;
}
/* ---- phone / tablet: a drawer, closed until asked ---- */
@media (max-width: 1023px) {
  #shell-nav { transform: translateX(-100%); transition: transform .18s ease; }
  #shell-nav.open { transform: none; box-shadow: 0 0 40px rgba(0, 0, 0, .5); }
  /* v468: see the FAB block below — one position, both breakpoints. */
  body.has-shell { padding-left: 0; }
  body.shell-drawer-open { overflow: hidden; }
}
@media (prefers-reduced-motion: reduce) {
  #shell-nav { transition: none; }
}

/* ---- v467: THE SIDEBAR IS AN OVERLAY AT EVERY WIDTH ----------------------
 * It used to be a 248px column that every page paid for permanently, on the theory
 * that a laptop has the room. In practice it was 248px of tool list beside a data
 * table that wanted the width, on every page, whether or not you were navigating —
 * the owner's words were that it ate the left side unnecessarily.
 *
 * Now: the drawer behaviour phones always had, everywhere. The ☰ floats over the page
 * and the nav slides in above it with a backdrop. Pages keep a 56px left inset — the
 * BUTTON's width, not the sidebar's — so nothing sits underneath it. That returns
 * about 190px to every table on the site.
 *
 * v461's fold control is now redundant (folded and closed became the same state); it
 * is hidden rather than deleted so nothing that refers to it breaks.
 * ------------------------------------------------------------------------ */
@media (min-width: 1024px) {
  #shell-nav { transform: translateX(-100%); visibility: hidden;
               transition: transform .18s ease; }
  #shell-nav.open { transform: none; visibility: visible;
                    box-shadow: 0 0 40px rgba(0, 0, 0, .5); }
  #shell-close { display: inline-flex !important; }
  #shell-fold { display: none !important; }
  body.has-shell, body.shell-collapsed.has-shell { padding-left: 0; }
  body.shell-collapsed #shell-nav { transform: translateX(-100%); visibility: hidden; }
}
@media (min-width: 1440px) {
  #shell-nav { width: 295px; }        /* v478: 268 + 10% */
  /* v468: nothing reserved — the button floats over the page (see the FAB block). */
  body.has-shell { padding-left: 0; }
}

/* ===========================================================================
 * v458 — RESPONSIVE TABLES (phase 3)
 * ===========================================================================
 * `.pf-tbl` in finance.html was already right — `min-width: 720px` inside an
 * `overflow-x: auto` container — and this generalises that pattern to every
 * table on the site. tablekit.js adds the wrapper (or adopts an existing scroll
 * container) and copies each column's header into `data-label` on its cells;
 * everything below is CSS reading those, so no render function branches on the
 * mode. Rules as data.
 * ------------------------------------------------------------------------ */
.tblwrap {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;   /* momentum scrolling on iOS */
  max-width: 100%;
}
/* A scroll container is a focus scope for the keyboard: without a tabindex a
   keyboard user cannot scroll it at all, and its content becomes unreachable. */
.tblwrap:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* --- sticky first column -------------------------------------------------
 * The label column is what makes a scrolled row readable — without it you are
 * looking at numbers with no idea which row they belong to. Applied by default
 * to the wide tables that have a text first column, and opt-out per table with
 * data-no-sticky, because a table whose first column is a checkbox gains
 * nothing and pays for it in paint cost. */
.tblwrap table:not([data-no-sticky]) > thead > tr > th:first-child,
.tblwrap table:not([data-no-sticky]) > tbody > tr > td:first-child,
.tblwrap table:not([data-no-sticky]) > tbody > tr > th:first-child {
  position: sticky;
  left: 0;
  z-index: 1;
  background: var(--card, var(--panel,  var(--panel)));
}
/* A sticky cell sits over its neighbour, so it needs its own right edge or the
   numbers appear to slide underneath it. */
.tblwrap table:not([data-no-sticky]) > thead > tr > th:first-child::after,
.tblwrap table:not([data-no-sticky]) > tbody > tr > td:first-child::after {
  content: "";
  position: absolute;
  top: 0; bottom: 0; right: 0;
  width: 1px;
  background: var(--line);
}

/* --- card mode, opt-in per tool -----------------------------------------
 * `table_mode = cards`: below 560px each row becomes a card with its column
 * names down the left. Only worth it for tables that are read rather than
 * compared — a comparison needs columns lined up, which is what `scroll` keeps.
 * Scoped to a body class so one admin setting switches every table on the page
 * and no renderer has to know. */
@media (max-width: 560px) {
  body.tables-cards .tblwrap { overflow-x: visible; }
  body.tables-cards .tblwrap table { min-width: 0 !important; width: 100%; }
  body.tables-cards .tblwrap table > thead { display: none; }
  body.tables-cards .tblwrap table > tbody > tr {
    display: block;
    margin-bottom: 10px;
    padding: 4px 2px;
    border: 1px solid var(--line2);
    border-radius: 10px;
    background: var(--card, var(--panel,  var(--panel)));
  }
  body.tables-cards .tblwrap table > tbody > tr > td {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 12px;
    width: auto !important;
    text-align: right !important;
    white-space: normal !important;
    border-bottom: 1px solid var(--line);
    /* the sticky rules above must not survive into a stacked card */
    position: static !important;
  }
  body.tables-cards .tblwrap table > tbody > tr > td::after { display: none; }
  body.tables-cards .tblwrap table > tbody > tr > td:last-child { border-bottom: 0; }
  /* the column name, from the data-label tablekit.js copied off the <th> */
  body.tables-cards .tblwrap table > tbody > tr > td[data-label]::before {
    content: attr(data-label);
    flex: 0 0 auto;
    max-width: 45%;
    text-align: left;
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: .03em;
    color: var(--muted2);
  }
  /* A cell with no label is a spacer or an action column — no point in a blank
     label, and an empty one would still take its 45%. */
  body.tables-cards .tblwrap table > tbody > tr > td:not([data-label]) {
    justify-content: flex-end;
  }
}

/* --- v459: contextual sidebar + hub groups -------------------------------- */
/* v461: one folding header per section, replacing the single "All tools" toggle.
   Same 44px hit target as a link, and it inherits .shell-sec-t's typography so a
   closed sidebar reads as exactly the four section names it did before. */
.shell-alltoggle, .shell-subtoggle {
  display: flex; align-items: center; gap: 7px; width: 100%;
  background: transparent; border: 0; cursor: pointer; font-family: inherit;
  min-height: 44px; padding: 0 10px; border-radius: 10px; text-align: left;
}
.shell-alltoggle:hover, .shell-subtoggle:hover { background: var(--btn); }
/* v462: the fold arrow was 10px — a decorative glyph on a 44px row, which the
   owner (rightly) could barely see or aim at. 15px in a 22px box makes it a target
   as well as an indicator, without changing the row height. */
.shell-alltoggle .tabgrp-ic, .shell-subtoggle .tabgrp-ic, #shell-toolname .tabgrp-ic {
  font-size: 15px; line-height: 1; width: 22px; text-align: center;
  opacity: .95; transition: transform .15s ease; flex: 0 0 auto;
}
.shell-subtoggle[aria-expanded="true"] .tabgrp-ic,
#shell-toolname[aria-expanded="true"] .tabgrp-ic { transform: rotate(90deg); }
.shell-subtoggle .shell-subt { flex: 1 1 auto; overflow: hidden;
                               text-overflow: ellipsis; white-space: nowrap; }
.shell-subtoggle .shell-subn {
  /* V578: was `opacity: .7` with no colour of its own, so the count inherited
     --muted2 and then had 30% of it thrown away -- 2.72:1 light, 3.19:1 dark, on a
     10px 700-weight numeral, which is the smallest text in the drawer and the one
     that could least afford it. A pill that paints its own --btn background has to
     state its own ink; --muted keeps it a secondary count (6.87:1 light, 5.72:1
     dark) without the opacity trick. */
  flex: 0 0 auto; font-size: 10px; font-weight: 700; color: var(--muted);
  background: var(--btn); border-radius: 9px; padding: 1px 7px;
}
.shell-subbody[hidden] { display: none !important; }
#shell-alllist[hidden] { display: none !important; }
.shell-sub { margin-bottom: 2px; }

/* ---- v469: the quick-action row, last in the menu ------------------------
 * The buttons keep their own look — round, gold, auto-fading — because they were
 * never the problem; their PLACEMENT was. Everything here does one job: undo the
 * `position: fixed` that pinned them to the top-right corner of every page, and
 * lay them out as a row inside the drawer instead.
 * ------------------------------------------------------------------------ */
#shell-quicksec[hidden] { display: none !important; }
#shell-quick { display: flex; flex-wrap: wrap; gap: 10px; padding: 4px 10px 2px; }
#shell-quick #sg-social.in-shell {
  position: static !important;
  top: auto !important; right: auto !important;
  z-index: auto !important;
  display: flex; gap: 10px; flex-wrap: wrap;
  justify-content: flex-start;
}

/* ---- v472: ALL FIVE IDENTICAL --------------------------------------------
 * Inside the menu these five arrived looking like two different families: refresh and
 * theme were 34px outlined squares from their own page-level styling, while the admin,
 * friends and messages buttons were 44px gold circles. And all of them inherited the
 * slow opacity pulse that exists to make them unobtrusive when they FLOAT over a page —
 * which is pointless in a drawer you deliberately opened, and just makes the row look
 * like it is loading.
 *
 * One rule for all five: 44px, round, gold, fully opaque, no animation. The single
 * selector list is the point — three near-identical rules is how they drifted apart in
 * the first place.
 * ------------------------------------------------------------------------ */
#shell-quick #refreshbtn.in-shell,
#shell-quick #themebtn.in-shell,
#shell-quick #sg-social.in-shell > a {
  position: static !important;
  width: 44px !important; height: 44px !important;
  min-width: 44px !important; min-height: 44px !important;
  border-radius: 50% !important;
  display: inline-flex !important; align-items: center; justify-content: center;
  font-size: 20px !important;
  background: var(--accent, #ffcf5c) !important;
  border: 1px solid var(--accent, #ffcf5c) !important;
  color: var(--accent-fg, #3d2c05) !important;
  box-shadow: 0 3px 10px rgba(0, 0, 0, .35);
  opacity: 1 !important;
  animation: none !important;          /* no pulse inside an opened drawer */
  transform: none;
}
#shell-quick #refreshbtn.in-shell:hover,
#shell-quick #themebtn.in-shell:hover,
#shell-quick #sg-social.in-shell > a:hover,
/* The unread badge must stay legible on gold. */
#shell-quick #sg-social.in-shell .sg-badge { box-shadow: 0 0 0 2px var(--panel, #10241d); }
/* The admin sub-menu pops UP inside a drawer — downwards would run off the panel. */
.shell-tab { width: 100%; background: transparent; border: 0; cursor: pointer;
             font-family: inherit; font-size: 14px; font-weight: 600; text-align: left; }
.shell-tab[hidden] { display: none !important; }
#shell-toolsec[hidden] { display: none !important; }
#shell-tabs[hidden] { display: none !important; }

/* v462: THE TOOL'S OWN TABS, INDENTED UNDER THE TOOL'S NAME.
   They used to render as a bare list at the same indentation as the section
   headers and above everything else, so on Splits and FinTrack the tabs read as
   top-level site navigation rather than as that tool's own tabs. The header is now
   the same folding control as the four tool-list sections — arrow, name, count —
   and the tabs are stepped in under it so the hierarchy is visible at a glance. */
#shell-toolname {
  display: flex; align-items: center; gap: 7px; width: 100%;
  min-height: 44px; padding: 0 10px; border-radius: 10px;
  color: var(--accent); font-size: 11px; font-weight: 800;
  text-transform: uppercase; letter-spacing: .04em;
  cursor: pointer; user-select: none; background: transparent; border: 0;
  font-family: inherit; text-align: left;
}
#shell-toolname:hover { background: var(--btn); }
#shell-toolname .shell-subt { flex: 1 1 auto; overflow: hidden;
                              text-overflow: ellipsis; white-space: nowrap; }
#shell-toolname .shell-subn {
  /* same pill, same reason as .shell-subtoggle .shell-subn above (V578) */
  flex: 0 0 auto; font-size: 10px; font-weight: 700; color: var(--muted);
  background: var(--btn); border-radius: 9px; padding: 1px 7px;
}
/* The indent: 22px arrow + 7px gap, so a tab lines up with its header's TEXT. */
#shell-tabs { margin-left: 29px; padding-left: 8px;
              border-left: 1px solid var(--line2); }
#shell-tabs .shell-tab { padding-left: 8px; }
.shell-sec#shell-toolsec { margin-bottom: 14px; }

/* v461: THE HUB NO LONGER COLLAPSES ITS SECTIONS.
   v459 made them collapsible and closed by default. In use that meant the hub —
   the page whose entire job is to show you what exists — opened as four words, and
   the folding lived in two places at once: here and in the sidebar. The owner's
   call, and the right one: the MENU folds, the launcher does not. The rules below
   are kept as inert history rather than deleted so that a future reader searching
   for `.hub-room.open` finds out what happened to it instead of finding nothing.

   .hub-room > .room-title           { cursor: pointer; }        (removed v461)
   .hub-room > .room-title::before   { content: "\25B8"; ... }   (removed v461)
   .hub-room:not(.open) > *          { display: none !important; } (removed v461)

   Nothing replaces them: with no rule hiding the children, every section renders
   open, which is also what happens with JavaScript disabled. */

/* ===========================================================================
 * v478 — THE GUEST GATE: blurred, deactivated, and asked to sign in
 * ===========================================================================
 * Paired with static/js/guestgate.js. Nothing here is tool-specific on purpose: nine
 * tools were given the same instruction, so they share one look and one definition of
 * "deactivated".
 *
 * THE SELECTOR IS `>` AND THAT IS THE WHOLE DESIGN. A blur applies to a subtree, so an
 * overlay inside the blurred element would be blurred too. Rather than move the target
 * into a wrapper — which breaks any `.parent > .child` rule the tool already had — the
 * container keeps its place, its CHILDREN are blurred, and the veil is one more direct
 * child that opts out. Add a class, append a node; remove them to undo.
 *
 * 7px, not 2px. At a light blur the text is still legible if you lean in, which makes
 * the gate look like a bug rather than a boundary. At 7px it reads as "there is
 * something here", which is the honest message.
 * ------------------------------------------------------------------------ */
.gg-locked { position: relative; }
.gg-locked > * {
  filter: blur(7px);
  pointer-events: none;
  user-select: none;
  /* A blurred control must not also be a focus target. `inert` on the container does
     this properly and JS sets it; this is the belt to that braces, for the one browser
     that ignores the attribute. */
  -webkit-user-select: none;
}
/* The tool's own topbar — its back-to-hub link — is exempt. JS marks it (see KEEP in
   guestgate.js); it has to be a DIRECT child because `filter` on an element establishes
   a containing group, so nothing nested inside a blurred child can be un-blurred. A
   gate that removed the way out would be a trap rather than a gate. */
.gg-locked > .gg-keep { filter: none; pointer-events: auto; user-select: auto; }
/* The veil is a direct child, so it needs to say it is not part of the blur. */
.gg-locked > .gg-veil {
  filter: none;
  pointer-events: auto;
  user-select: auto;
  position: absolute;
  inset: 0;
  z-index: 5;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 14px;
  box-sizing: border-box;
  /* No dark wash. The blur already separates the layers, and a scrim on top of a blur
     makes a phone screen unreadable rather than teasing — the owner's instruction was
     to keep the content ON the screen. */
  background: transparent;
}
.gg-card {
  max-width: 320px;
  text-align: center;
  background: var(--panel);
  border: 1px solid var(--line2);
  border-radius: 14px;
  padding: 16px 18px;
  box-shadow: 0 10px 34px rgba(0, 0, 0, .45);
}
.gg-msg { font-size: 15px; font-weight: 800; color: var(--text); line-height: 1.35; }
.gg-sub { font-size: 12.5px; color: var(--muted2); margin-top: 6px; line-height: 1.5; }
.gg-btn {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: 44px; margin-top: 12px; padding: 0 18px;
  border-radius: 10px;
  background: var(--accent); color: var(--accent-fg, #211903);
  font-size: 14px; font-weight: 800; text-decoration: none;
}
.gg-btn:hover { filter: brightness(1.06); }
/* The owner asked for a button AND a link. The second route is the one a first-time
   visitor actually needs, so it is present but quieter — a member is signing IN, a
   visitor is signing UP, and one of those is a bigger ask. */
.gg-alt {
  display: block; margin-top: 9px;
  font-size: 12.5px; font-weight: 700;
  color: var(--accent); text-decoration: underline;
}
/* A veil over a short strip (a settings row, one card) would clip its own card. Below
   about 150px the card drops its padding rather than overflow. */
@media (max-height: 480px) {
  .gg-card { padding: 12px 14px; }
  .gg-btn { min-height: 40px; margin-top: 9px; }
}
/* ---- v478: the item-level gate (Global Pulse, Word Lab) -------------------
 * Same blur, no veil. These two tools were told a guest may READ part of the page, so
 * covering the list would hide the allowed part along with the rest. Individual items
 * are dimmed and one card sits in the flow where the readable part stops.
 * ------------------------------------------------------------------------ */
.gg-dim {
  filter: blur(7px);
  pointer-events: none;
  user-select: none;
  -webkit-user-select: none;
}
.gg-inline { display: flex; justify-content: center; padding: 16px 8px; }
.gg-inline .gg-card { max-width: 360px; }

/* ===========================================================================
 * V564 — THE ⓘ INFO DOT
 *
 * The owner asked for every description, example and instruction to move behind
 * a small button beside its heading. infodot.js does the moving; this is how the
 * result looks and, more importantly, how it behaves at the edges.
 *
 * IT LIVES IN THE GLOBAL SHEET, and uses only house tokens (--text, --panel,
 * --line2, --muted). Every tool re-points its own --rh-* / --tb-* / --fin-*
 * scale on its own root, so a component painted from those would render with no
 * background the moment it appeared in a different tool — the V558 rule. House
 * tokens are declared on :root and are the same everywhere.
 *
 * THERE IS DELIBERATELY NO `[data-info] { display: none }` RULE. Hiding the
 * prose in CSS would hide it for anyone the script never reached — no JS, or a
 * 404 on infodot.js after a partial deploy, which is exactly how V552's `esc`
 * fallback bit. The script removes the node itself, so the page degrades to the
 * old one instead of to a blank space.
 * ======================================================================== */
.idot-w {
  position: relative;
  display: inline-flex;
  align-items: center;
  /* A heading is often a flex row (`<h3><span>Title</span><button>…</button>`),
     and a flex child's min-width defaults to its content — but line_escape's
     own (pointer: coarse) rule below sets min-width: 44px on every button,
     which REPLACES min-width:auto. That is precisely how V558's TuneBook tabs
     collapsed on top of each other. 0 0 auto keeps this one its own size. */
  flex: 0 0 auto;
  vertical-align: middle;
  margin-left: 6px;
}
button.idot {
  width: 20px; height: 20px;
  min-width: 20px; min-height: 20px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--muted2, #66667a);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  opacity: .75;
  /* A heading's font-weight is 700+; the glyph inherits it and reads as a bullet
     rather than a control. */
  font-weight: 400;
}
/* V671: 20x20 measured, on 8 pages. The `min-width: 20px` above stays exactly
   where it is -- the comment on `.idot-w` records why an unqualified 44px min-width
   is dangerous here (it collapsed TuneBook's tabs in V558). Padding instead: the
   circle is still 20px, the tap area is 44px, and because the padding is pulled back
   by an equal margin the heading row it sits in does not move.

   `box-sizing: content-box` IS THE FIX, AND THE FIRST CUT OF THIS RULE DID NOTHING.
   Line 103 of this file sets `box-sizing: border-box` on everything, and the coarse
   block further down pins this control to `width/height: 24px` with matching minimums.
   Under border-box the padding is therefore absorbed INSIDE those 24 pixels: the rule
   applied, the declaration was in the computed style, and the tap area stayed 24x24.
   Caught by measuring the box after the change rather than by reading the diff -- the
   same lesson as the 552 that turned out to be nine.
   content-box lets the 12px sit outside the circle: 24 + 24 = 48, over the floor,
   with the negative margin giving the layout its 24px back. Nothing is painted on
   this element (`background: none; border: 0`), so a bigger box is invisible. */
@media (pointer: coarse) {
  button.idot { box-sizing: content-box; padding: 12px; margin: -12px; }
}
button.idot:hover, button.idot:focus-visible { opacity: 1; color: var(--text); }
button.idot[aria-expanded="true"] { opacity: 1; color: var(--text); }
button.idot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ---- V568: [hidden] MUST WIN, and hover opens it on a mouse ---------------
   THE BUG. The rule below set `display: block`, and an author display rule
   BEATS the [hidden] attribute — the exact trap this codebase recorded in V516
   ("[hidden] loses to any author display rule"). infodot.js was setting
   pop.hidden = true correctly and the browser was ignoring it, so every popover
   rendered permanently open, on top of whatever was under it. The owner's
   screenshot showed one covering the Session name field.

   `.idot-pop[hidden] { display: none }` is the fix: same specificity race, but
   now stated explicitly instead of left to the attribute.

   THE BEHAVIOUR HE ASKED FOR, on top of that: "should be visible only when we
   scroll ove it in desktop view and when we click on it in mobile view."
   Hover is CSS, not JS — a hover handler would have to guess when the pointer
   left, and :hover already knows. Click keeps working everywhere, including on
   desktop, so the popover can be PINNED open long enough to select the text in
   it; hover alone would close the moment you moved towards it.

   :focus-within is in the same rule because a keyboard user tabbing to the ⓘ
   gets no :hover, and a control that only opens for a mouse is not reachable. */
.idot-pop {
  position: absolute;
  top: calc(100% + 7px);
  left: 0;
  z-index: 60;
  display: block;
  width: max-content;
  /* Two bounds, both needed. max-width keeps a long paragraph readable; the
     min() clamps it to the viewport so the popover cannot be the thing that
     makes a 390px phone scroll sideways. */
  max-width: min(340px, calc(100vw - 32px));
  padding: 10px 12px;
  border: 1px solid var(--line2);
  border-radius: 10px;
  background: var(--panel);
  color: var(--text);
  box-shadow: 0 8px 24px rgba(0, 0, 0, .28);
  font-size:var(--fld-fs,13px);
  font-weight: 400;
  line-height: 1.5;
  /* Headings set their own tracking and case; the prose inside must not inherit
     either, or a description under an uppercase label renders shouting. */
  letter-spacing: normal;
  text-transform: none;
  text-align: left;
  white-space: normal;
  /* V584: a long note must not run off the bottom of the window. Several are
     300+ characters, which is ~20 lines in a 340px box -- taller than a phone in
     landscape, and the end of the note was simply unreachable. Scrolling the box
     is the honest fix; infodot.js also flips it above the dot when there is more
     room up there, and tightens this ceiling when there is not. */
  max-height: min(60vh, 420px);
  overflow-y: auto;
  overscroll-behavior: contain;
}
/* The attribute wins again. (0,2,0) beats the (0,1,0) of `.idot-pop` above. */
.idot-pop[hidden] { display: none; }

/* Mouse and stylus: hovering the wrapper opens it, and so does keyboard focus.
   (0,2,1) beats `.idot-pop[hidden]`, which is what lets hover re-open a popover
   the script is holding closed.
   NOT applied under (pointer: coarse): on a touch screen `:hover` sticks after
   a tap and the popover would never close again — which is how a "hover" menu
   becomes a permanently open panel on a phone. There, the click handler in
   infodot.js is the whole interaction, which is what he asked for. */
@media (hover: hover) and (pointer: fine) {
  .idot-w:hover .idot-pop,
  .idot-w:focus-within .idot-pop { display: block; }
}

.idot-pop a { color: var(--accent); }
.idot-pop p:first-child { margin-top: 0; }
.idot-pop p:last-child { margin-bottom: 0; }

/* Near the right-hand edge the popover would otherwise run off.
   V584: THIS OPT-IN HAD ZERO USERS. Not one template ever set the attribute, so
   every ⓘ in the right-hand half of a row opened a 340px box with its right edge
   off the screen -- the note was in the DOM and unreadable, which is what the
   owner reported as "not all notes fit into the ⓘ button".
   The argument against measuring was that a flip "has to re-run on every resize
   and on every repaint". It does not: infodot.js measures when a popover OPENS
   and never otherwise, so a closed one costs nothing. The attribute is KEPT and
   still honoured -- it is a valid manual override -- but nothing now depends on
   somebody remembering to write it. */
.idot-w[data-info-side="right"] .idot-pop { left: auto; right: 0; }

@media (max-width: 560px) {
  /* On a phone the box is pinned to the tool's own width rather than to the
     button, because a button near the right edge would otherwise open a popover
     that is mostly off-screen. */
  .idot-pop { max-width: calc(100vw - 32px); }
}

@media (pointer: coarse) {
  /* The 44px rule above matches `button` and would make this 44x44, which
     inside an <h3> pushes the whole heading row to 44px tall on every page in
     the site. The tap target is honoured the way the existing exemption for
     in-text controls does it — the reach is enforced, the visual box is not
     inflated — via padding on the wrapper, which is inside the label's hit area
     but outside its painted circle. */
  button.idot {
    width: 24px; height: 24px;
    min-width: 24px; min-height: 24px;
    font-size: 17px;
  }
  .idot-w { padding: 10px 6px; margin-left: 2px; }
}

@media (prefers-reduced-motion: no-preference) {
  .idot-pop { animation: idot-in .12s ease-out; }
}
@keyframes idot-in {
  from { opacity: 0; transform: translateY(-3px); }
  to   { opacity: 1; transform: none; }
}

/* ---- V616: the [hidden] guard for .mini ----------------------------------
   `.mini` and `a.mini` are display:inline-flex, and three attribute-toggled
   elements wear the class: #wa-encnow on the admin WhatsApp panel, #jt-coldone
   in JobTrack's column menu, and #rh-lb-act. An author display rule beats the
   UA sheet's `[hidden] { display: none }` (V516), so all three ignored the
   attribute and showed permanently.

   `.mini[hidden]` is (0,2,0) and beats `a.mini` at (0,1,1), so no !important --
   which matters, because a blanket `[hidden] { display: none !important }` here
   would break `.idot-w:hover .idot-pop` a few hundred lines up, where a hidden
   popover is deliberately re-opened on a mouse. */
.mini[hidden] { display: none; }

/* ---- V639: the centred dialog (static/js/findialog.js) --------------------
   IT LIVES HERE, in the one stylesheet base.html loads, because the component
   is site furniture -- every page that used to raise a browser confirm() can
   raise this one instead, and a dialog that is styled on four pages and naked
   on twenty is worse than the browser's own.

   AND IT LEANS ON NOTHING PAGE-LEVEL. `.mini`, `.fin-in` and `.panel` are
   declared inside finance.html, jobtrack.html, profile.html and splits.html --
   four templates out of seventy-nine. The first draft of this component reused
   `.mini` for its Cancel button and `.fin-in` for its text field, which would
   have rendered correctly on FinTrack and as raw browser defaults everywhere
   else: exactly the bug this release is fixing in the teach block, reintroduced
   by the fix. Every selector below is `fd-` prefixed and self-contained.

   The centring is the whole point of the release: `inset: 0` plus
   `place-items: center` puts it in the middle of the VIEWPORT on both axes, and
   `position: fixed` keeps it there whatever the page behind has scrolled to.
   Chrome's confirm() offers no equivalent -- it is pinned to the top and the
   page cannot reach it. */
.fd-ov {
  position: fixed; inset: 0; z-index: 9999;
  background: var(--backdrop2);
  display: flex; align-items: center; justify-content: center;
  padding: 16px;
}
/* [hidden] loses to the display rule above (the V516/V616 finding, twice over
   in this file already), so the attribute needs saying again here. Same
   specificity as the rule it corrects, winning on order, so no !important --
   which matters because !important here would also beat the .fd-lock rule. */
.fd-ov[hidden] { display: none; }

/* 9999 is the band rowmenu.css already reserved and documented: under #fin-fab
   at 10000. Deliberately NOT above it -- the FAB is the control that opens the
   Add form and a dialog that buried it would strand somebody mid-entry. */
.fd-box {
  background: var(--panel);
  border: 1px solid var(--line2);
  border-radius: 15px;
  padding: 19px 21px;
  max-width: 430px; width: 100%;
  max-height: calc(100vh - 32px); overflow-y: auto;
  box-shadow: 0 22px 60px rgba(0, 0, 0, .55);
  color: var(--text);
}
@media (prefers-reduced-motion: no-preference) {
  .fd-box { animation: fd-in .13s ease-out; }
}
@keyframes fd-in {
  from { opacity: 0; transform: translateY(9px) scale(.985); }
  to   { opacity: 1; transform: none; }
}
.fd-h { font-size: 16.5px; font-weight: 800; margin-bottom: 7px; line-height: 1.3; }
.fd-h[hidden] { display: none; }
.fd-b { font-size: 13.5px; color: var(--muted); line-height: 1.55; }
.fd-b[hidden] { display: none; }
/* The explanatory second line. --muted3 is the faintest step that still clears
   4.5:1 on --panel (V578 measured it at 5.98), so this reads as secondary
   without dropping under the floor. */
.fd-note { display: block; margin-top: 5px; color: var(--muted3); font-size: 12.5px; }
.fd-i { margin-top: 11px; }
.fd-i[hidden] { display: none; }
.fd-in {
  background: var(--btn); border: 1px solid var(--line2); border-radius: 9px;
  padding: 10px 12px; color: var(--text); font-size: 15px;
  font-family: inherit; width: 100%; box-sizing: border-box;
}
.fd-a {
  display: flex; gap: 8px; justify-content: flex-end;
  margin-top: 17px; flex-wrap: wrap;
}
/* One rule for all three button kinds, so they cannot disagree about height the
   way .fin-act and its <label> sibling did before V556 fixed that. (0,1,1)
   out-specifies any page-level `.mini` at (0,1,0), so a FinTrack dialog and an
   admin dialog are the same dialog. */
.fd-a > button {
  min-height: 38px; padding: 0 15px; font-size: 13.5px;
  border-radius: 9px; font-weight: 700; cursor: pointer;
  font-family: inherit; border: 1px solid transparent;
}
@media (pointer: coarse) { .fd-a > button { min-height: 44px; } }
.fd-a > button.fd-cancel {
  background: var(--btn); border-color: var(--line2); color: var(--text);
}
.fd-a > button.fd-cancel:hover { filter: brightness(1.12); }
/* --bad-fill / --bad-fg are the V578 pair declared once and never overridden
   per theme, precisely so a destructive FILL works on either ground. */
.fd-a > button.fd-danger { background: var(--bad-fill); color: var(--bad-fg); }
.fd-a > button.fd-go     { background: var(--fg, var(--text,  var(--accent))); color: var(--fg-ink, var(--accent-fg)); font-weight: 800; }
.fd-a > button:active { transform: translateY(1px); }
/* The focus ring has to survive on a dark panel; the UA default outline is
   nearly invisible on --panel. */
.fd-a > button:focus-visible, .fd-in:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}
/* Scroll lock. On <html> rather than <body> because iOS Safari ignores
   overflow:hidden on body while a fixed overlay is up. */
html.fd-lock { overflow: hidden; }

/* --- 1.65 VISUALLY HIDDEN (V651) -----------------------------------------
 * `.vh` -- text for a screen reader that no eye should see. It existed in exactly
 * ONE stylesheet, raffle.css, and V651's hub tiles started using it: the tile's
 * accessible name carries ", new" / ", updated" / ", hidden from players" so a
 * reader hears one sentence instead of three fragments.
 *
 * /tools LOADS auth.css AND THIS FILE, NOT raffle.css. So the class did nothing
 * there and every tile printed its screen-reader text as visible body copy under
 * the name -- and with V651 naming 26 tools as majorly updated, that would have
 * been ", updated" under almost every tile on the first load after deploy.
 *
 * WORSE, AND THE PART WORTH KEEPING: tests/test_v651.py measured the tiles in
 * Chromium and was GREEN, because its synthetic page injected a .vh rule of its
 * own. A harness that supplies the thing it is testing tests nothing -- v410's
 * lesson in a new costume. That suite now reads this declaration out of this file
 * instead of writing its own.
 *
 * Declared here, in the one sheet base.html loads on every page, because it is
 * site furniture. raffle.css keeps its copy: identical properties, and deleting a
 * working rule in the release that stops depending on it is the churn this
 * project's no-deletion rule exists to avoid. test_v651 asserts they agree.
 */
.vh {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* --- 1.7 THE ONE TOOL HEADER (V651) --------------------------------------
 * His ask: "Home | tool icon & Tool name (in top center, bold text) | Theme
 * button ... no need to add any colour in that row". The markup is
 * templates/_toolbar.html; this is the whole of its look.
 *
 * IN THIS FILE, not a new stylesheet, and not twenty-eight <style> blocks. It is
 * the one sheet base.html loads on every page, which is the same argument the
 * findialog component above is here for: site furniture belongs with the site,
 * and a header that needed a per-page <link> would be a header twenty-eight
 * pages could forget.
 *
 * A GRID, not a flex row with space-between. `1fr auto 1fr` is what makes the
 * name sit at the TRUE centre of the row rather than at the centre of whatever
 * is left over after the two side controls -- with space-between, a page with a
 * wide right slot (Snake's two buttons, RaffleHub's four) pushes the name left
 * and every page centres it differently. RaffleHub's own bar already used this
 * grid, which is why it was the one page whose title looked right.
 *
 * NO BACKGROUND AND ONE HAIRLINE. The four bars that painted a block are gone
 * with their markup (market_pulse --navy, Samaya --sm-card, TuneBook glass, MCQ
 * border) -- and the hairline stays because without it the row and the page
 * behind it are the same colour and the header stops reading as a header.
 */
.tbar {
  display: grid;
  /* minmax(0, 1fr), NOT 1fr. A bare 1fr is minmax(auto, 1fr): it will not shrink
     below its content's min-content width, so a wide right slot pushes the row
     past the window instead of squeezing the side cells. FinTrack's slot is the
     widest on the site -- the "Advanced accounting view" checkbox and its label --
     and it is the case this guards.

     HONEST ABOUT WHAT WAS MEASURED: a horizontal overflow WAS measured on
     /tools/finance at 900px (1036px of scrollWidth) and on /tools/formula-lab
     /admin at 390px (535px), and NEITHER is this row. The first is the dashboard's
     third donut panel, the second is the coverage table before tablekit wraps it;
     both predate V651 and both are still there. This is a guard against a shape
     that can happen, not a fix for one that did -- and saying so is the difference
     between a comment and a claim. test_v651 measures scrollWidth on every page,
     with those two known cases named, so the next overflow is attributable. */
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  grid-template-areas: "l m r" "x x x";
  align-items: center;
  column-gap: 10px;
  row-gap: 6px;
  background: none;
  border-bottom: 1px solid var(--line);
  margin: 0 auto 12px;
  /* THE GUTTER IS NOT PAID FOR TWICE. Twenty-three of the thirty-one pages put
     this row inside a wrapper that already has side padding (.qr-wrap 14px,
     .mcq-wrap 16px, the game shells 12px), so a padding here as well made the
     header the widest-gutter row on the site -- 24px a side on a 390px phone,
     in the release whose other half is about the space his tiles do not have.
     So: the row pays only for the notch, and the eight pages with no wrapper of
     their own ask for the gutter with `pad=true` (which `cap` implies). Every
     page's real gutter is MEASURED in tests/test_v651.py, so a page that gets
     this wrong fails rather than looking slightly off. */
  padding: calc(11px + env(safe-area-inset-top)) env(safe-area-inset-right) 11px
           env(safe-area-inset-left);
}
.tbar.tbar-pad {
  padding-right: max(14px, env(safe-area-inset-right));
  padding-left: max(14px, env(safe-area-inset-left));
}
/* THE NOTCH IS PAID FOR ONCE. V651 shipped this as `body.sa-pad .tbar` -- correct
   for the two pages that carry the inset on <body>, and blind to the twelve whose
   WRAPPER carries it (.sn-wrap, .ch-wrap, .ludo-wrap, .ms-wrap, .sk-wrap, .rh-app,
   .mcq-wrap, .wl-wrap, .oh-wrap, #mm, #fl). Those pages put 10 + 47 above the row
   and another 11 + 47 inside it, which is the gap he photographed on an iPhone 12.

   `.tbar-nonotch` is now the whole mechanism -- the macro sets it from `notch=False`
   and the two sa-pad pages pass the same flag, so there is ONE rule to read instead
   of a class rule plus a body-scoped exception. See _toolbar.html. */
.tbar.tbar-nonotch { padding-top: 11px; }
/* V512's ☰ CLEARANCE IS DELIBERATELY *NOT* CARRIED OVER, and this is the note
   saying so rather than an omission.
   `.top-bar { padding-left: calc(66px + inset) }` at <=900px kept the fixed 48px
   gold ☰ (left:12px, top:10px) off each tool's back link -- but only FIVE pages had
   that class. The other twenty-six put their back link at 14px and always relied on
   shell.js's fabStepAside(), which detects a link under the button and nudges the
   button instead of the page.
   Adding the padding to the shared row was tried and MEASURED: it moved the centred
   name 33px off centre on all thirty-one pages, because padding a `1fr auto 1fr`
   grid moves the middle column. That trades his main ask -- "Tool name in top
   center" -- for a control the ☰ already steps aside from. So the row keeps its
   gutter and fabStepAside keeps its job. What the five pages lose is a head start;
   what every page gains is the same centre. */
/* flex-wrap on BOTH side cells. Without it the side content cannot fit inside its
   own track -- `flex: 0 0 auto` below says it may not shrink -- so it overflowed
   sideways and RaffleHub's four header controls printed straight over the centred
   title ("Raffle Hu" with ✦ New raffle on top of the "b"). Wrapping keeps every
   control at full size and lets the ROW grow instead, which is the one direction
   there is room in. Caught by a screenshot, not by a check: the title was measured
   as perfectly centred, because it was -- underneath a button. test_v651 now
   measures the three cells for overlap as well as for position. */
.tbar-l { grid-area: l; display: flex; align-items: center; gap: 8px;
          justify-self: start; min-width: 0; flex-wrap: wrap; }
.tbar-mid { grid-area: m; display: flex; flex-direction: column;
            align-items: center; justify-self: center; min-width: 0; }
.tbar-r { grid-area: r; display: flex; align-items: center;
          justify-content: flex-end; gap: 6px; justify-self: end; min-width: 44px;
          /* V753 TRIED `nowrap` HERE AND MEASURED IT BACK OUT. With the Main
             Admin chip added, nowrap stopped the row growing -- and instead
             printed the right cell ON TOP of the centred title on 28 pages,
             which is the exact failure V651's overlap check was written for
             ("position is not occupancy"). Wrap stays; the chip moved to the
             LEFT cell instead, beside Home. */
          flex-wrap: wrap; }
/* The page's own controls. Its own row, centred, and gone entirely when a page has
   none -- :empty catches the twenty-two pages that pass no call block, and a page
   whose only extra hides itself at a breakpoint (Global Pulse's Live Feed chip
   under 620px) leaves a row of zero height, which the row-gap then closes. */
.tbar-x { grid-area: x; display: flex; align-items: center; justify-content: center;
          gap: 6px; flex-wrap: wrap; min-width: 0; }
.tbar-x:empty { display: none; }
.tbar-title { display: flex; align-items: center; gap: 9px; font-size: 17px;
              font-weight: 800; letter-spacing: -0.02em; white-space: nowrap;
              color: var(--text); }
/* The name is an <h1> on the one page whose only heading it is (Global Pulse, which
   is in the public sitemap). A heading brings the UA's 2em and 0.67em margins with
   it, which made that one row 48px taller than every other -- measured. The element
   changes; the look does not. */
.tbar-title h1.tbar-name { font: inherit; margin: 0; }
.tbar-ico { font-size: 19px; line-height: 1; }
/* `width: 0; min-width: 100%` -- the subtitle FILLS the centre column but does not
   SIZE it. The middle grid track is `auto`, so without this Formula Lab's strapline
   ("Excel & DAX, taken apart argument by argument") set the column to its own 250px
   at 390px, squeezing both side cells until their controls wrapped and the row was
   135px tall. Now the column is sized by the name, and a strapline longer than the
   name is ellipsised rather than being allowed to reshape the header. */
.tbar-sub { font-size: 11.5px; color: var(--muted2); white-space: nowrap;
            overflow: hidden; text-overflow: ellipsis;
            width: 0; min-width: 100%; text-align: center; }
/* HIS PICK, D2: the gold chip. The one control on this row that not everybody
   sees, and gold is how it says so without a label explaining itself.
   --accent with --accent-fg is the V578 pair -- the ink that belongs to that
   ground in BOTH themes, which is why this is not "#3d2c05 on gold". */
/*  V1062 --- THE TOOL CHIP IS AN OUTLINE, SO THE TWO ADMINS READ APART.

    His report: *"Why is the currency wallet and games taking me to central
    admin page?"*. They were not -- he was tapping the OTHER admin button. At
    390px this bar showed two identical solid-gold circles, 🛡️ at the left
    beside Home and ⚙ at the right, one going to Central Admin and one to this
    tool's own admin, with the labels hidden at that width and a `title` that
    needs a pointer he has not got.

    AND I MADE THEM IDENTICAL. Before V1058 the shield was a dark glyph on a
    dark chip and could not be seen at all; that release put it on the accent,
    which is right and stays. What it did not notice is that the chip it now
    matched was the one meaning something else.

    SO THIS ONE GIVES THE GROUND BACK. ⚙ is a TEXT glyph, not an emoji, so it
    takes `color` -- which is exactly what 🛡️ cannot do, and the reason the
    fix has to be this way round rather than the other. Zero height cost, which
    matters: labelling them instead was measured at +40px on Card Games against
    a 110px ceiling. The words still appear from 391px up. */
.tbar-adm {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 6px 12px; border-radius: 999px;
  background: transparent; border: 1px solid var(--accent);
  color: var(--accent); font-size: 12.5px; font-weight: 700;
  text-decoration: none; white-space: nowrap;
}
.tbar-adm:active { transform: translateY(1px); }
/* V930 — THE HOME LINK GETS A GROUND. His pick, option S1, from mk_gold_buttons.
   "Semi-highlighted" was the word, and the measurement that decided the shape is
   that its TEXT IS ALREADY GOLD (--chrome-accent, #ffcf5c): there is no colour
   left to give it, so what S1 adds is a 12% wash and a warmer edge.

   WHY IT IS HERE AND NOT ON FIFTY PAGES. `.tbar-home` is rendered once, by the
   shared header macro, so this is one rule — the whole reason V651 pulled those
   twenty-eight bars into one.

   THREE THINGS MEASURED RATHER THAN ASSUMED, because this row has a hard 110px
   ceiling that test_v651 enforces and already carries Home, the shield, the name,
   Admin and the theme button:
     * the padding below is 5px/10px, INSIDE the 44px coarse-pointer floor that
       `.back` already claims, so the control does not grow on a phone;
     * at 390px the word "Home" is hidden (`.tbar-word`), leaving 🏠 alone in the
       wash — which is why the radius is a pill and not a box;
     * the LIGHT theme needs its own value. --accent there is #6fd3a9, a pale mint,
       and a 12% wash of it on a near-white ground is invisible; .tbar-adm already
       carries a separate light-mode colour for exactly this reason, and this
       follows it to #178055. */
/* V1012 --- AND FOR TWO YEARS' WORTH OF RELEASES IT PAINTED NOTHING AT ALL.
   Everything above this line was written at V930 and was correct about what it
   wanted. It simply never won.

   WHAT WAS WRONG. base.html carries `a.back, .back-link, .header-back, #hud-back`
   with `!important` on background, border, border-radius and padding -- the V476
   rule that makes the back chip opaque in any theme -- and this element is
   `class="back tbar-home"`. Those are the exact four properties S1 sets, and
   `!important` beats an ordinary declaration whatever its specificity. So the
   link kept the 9px box, the 6/12 padding, the --btn ground and the --line2 edge:
   the pre-V930 chip, on all fifty pages, for every release since.

   MEASURED, NOT INFERRED, on /tools/bingo at 1180px and 390px before anything was
   touched: radius 9px, padding 6px/12px, background
   `linear-gradient(--btn, --btn)` over --bg, border rgb(44,65,57). The word was
   gold, which is what made it look like the change had landed -- the ink comes
   from that same base rule and was gold before V930 too.

   WHY NOTHING CAUGHT IT. test_v651 measures this row's HEIGHT and its gutter;
   test_v578 measures INK against the ground it sits on. Neither asks whether a
   ground is the one a release decided on, so a rule that loses the cascade
   outright reads as green everywhere. test_v1012 measures the PAINTED values in
   a real browser, which is the only place a cascade exists.

   THE FIX IS SPECIFICITY PLUS `!important`, AND BOTH HALVES ARE NEEDED. Among
   `!important` declarations the normal cascade still applies, and `a.back` is
   (0,1,1) against `.tbar-home`'s (0,1,0) -- so marking these important without
   also naming `a.back` here would STILL lose. `a.back.tbar-home` is (0,2,1).

   WHAT WAS NOT DONE, and why. The tidier-looking change is to take this element
   out of the base rule -- `a.back:not(.tbar-home)`. Measured in a browser: the
   wash arrives and THE WORD TURNS BROWSER-DEFAULT BLUE on all fifty pages,
   because that rule is also the only place the gold ink is set. Four important
   declarations here touch nothing else; that one touches the ink on every page
   and would need the colour restated in the same edit. His call, option A.

   THE COLOUR IS DELIBERATELY NOT RESTATED HERE. It is already correct, measured
   at 9.31:1 dark and 4.61:1 light, and it comes from the rule this one is
   overriding in four places. A fifth declaration repeating a value that is
   already right is a second place to keep it in step. */
/*  THE WASH GOES **ON** THE OPAQUE CHIP, NOT INSTEAD OF IT -- and the first cut of
    this release got that wrong, in the exact way V916's note above warns about.

    A plain `background: color-mix(--accent 12%, transparent)` is a TRANSLUCENT
    ground, so the page behind shows through. On the twenty-eight ordinary tools
    that is the bar, and it looks right. On the three that paint their own
    full-bleed ground it is not: the contrast sweep measured the gold word at
    **1.16:1 on RaffleHub** (#ffcf5c on #dbe6f6), 1.37 and 1.46 in the other two
    dark themes, and under the floor again on QrScan and Samaya -- seven pairs,
    three pages. That is precisely the fault V916 spent a release finding, arriving
    from the other direction: it kept the chip opaque so the ink could never
    composite over a page's ground, and overriding `background` threw that away.

    SO THE WASH IS A LAYER. `linear-gradient(<wash>, <wash>)` paints the tint as
    its own layer over `linear-gradient(--btn, --btn)` over `--bg` -- the base
    rule's own stack, kept whole underneath. The result is opaque at every theme
    and on every page, and the gold word measures what it measured before, because
    the ground beneath it is the same ground plus a tint. */
a.back.tbar-home {
  /*  5px/12px, AND THE 12 IS NOT COSMETIC. S1 was drawn at 5px/10px and that is
      what the first cut of this release shipped; test_v672's census measured the
      link at **42x44 on a phone** and went red -- the project's 44px tap floor,
      which V671 and V672 exist to hold. The `min-width: 44px` in the coarse-pointer
      block computes to 0px on this element (it has done since before this release;
      V1011 measured 46x44 and cleared the floor on padding alone), so the width
      here IS the floor. 12px keeps it at 46. The pill comes from the radius, not
      from two pixels of side padding. */
  border-radius: 999px !important; padding: 5px 12px !important;
  background:
    linear-gradient(color-mix(in srgb, var(--accent) 12%, transparent),
                    color-mix(in srgb, var(--accent) 12%, transparent)),
    linear-gradient(var(--btn), var(--btn)), var(--bg) !important;
  border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line2)) !important;
}
/*  THE LIGHT OVERRIDE NEEDS THE SAME TREATMENT. It lost to the same base rule for
    the same reason, so deepening the mint here was never visible either -- and it
    needs the same opaque stack, for the same three pages. */
html:not([data-dark]) a.back.tbar-home {
  background:
    linear-gradient(color-mix(in srgb, #178055 10%, transparent),
                    color-mix(in srgb, #178055 10%, transparent)),
    linear-gradient(var(--btn), var(--btn)), var(--bg) !important;
  border-color: color-mix(in srgb, #178055 28%, var(--line2)) !important;
}
.tbar-home:active { transform: translateY(1px); }
/* V753: the Main Admin chip. Deliberately quieter and narrower than its
   neighbour: two identical gold chips read as two equal doorways, and from a
   TOOL page the tool's own admin is the one you want. Icon only at every width
   -- see the note in _toolbar.html for why the word is not here. */
/*  V1058: 🛡️ IS AN EMOJI, so `color` barely touches it and a dark glyph on
    `--btn` was invisible in all three dark themes -- on all 28 toolbars, not
    just the page he reported it from. The accent gives the glyph a ground.
    NOT a wash of it: the first try used `--bg-hot`, declared only by two tool
    skins, so 26 toolbars would have run on the fallback and a frozen gold is
    what V882 forbids. The border carries NO colour on purpose -- it falls to
    `currentColor`, which is already the right ink and keeps this rule shorter
    than the one it replaces, so test_v834's blocking-head ratchet goes DOWN.
    Full note: AGREED.md `tbar-admin-chip-visible`. */
.tbar-adm2 { background: var(--accent); color: var(--accent-fg);
             border: 1px solid; padding: 5px 8px; }
html:not([data-dark]) .tbar-adm2 { background: var(--btn); color: #178055;
             border-color: var(--line2); }
/* V736: in LIGHT theme --accent (#6fd3a9) is too pale to carry the chip's white
   ink — 1.82:1, measured. Deepen just this chip's ground here (the dark-theme gold
   chip is untouched and already clears the floor); white on #178055 is 4.94:1. */
html:not([data-dark]) .tbar-adm { background: #178055; border-color: #178055; color: #fff; }
/* The docked theme button is a 34px circle (`#themebtn.inbar`, section above),
   which is taller than the chip; the row's align-items keeps them concentric. */
@media (max-width: 520px) {
  .tbar { gap: 6px; }
  .tbar.tbar-pad { padding-right: max(10px, env(safe-area-inset-right));
                   padding-left: max(10px, env(safe-area-inset-left)); }
  .tbar-title { font-size: 15px; gap: 7px; }
  .tbar-ico { font-size: 16px; }
  .tbar-adm { padding: 5px 9px; font-size: 11.5px; }
  /* 390px is where a name, two words of chrome and up to four extra controls
     stop fitting. The WORDS go and the icons stay: 🏠 and ⚙ are both already
     doing the explaining, and the alternative -- shrinking the name -- spends
     the one thing he asked to be readable. */
  .tbar-word { position: absolute; width: 1px; height: 1px; overflow: hidden;
               clip-path: inset(50%); white-space: nowrap; }
  /*  V1062: THE ADMIN WORDS WERE TRIED HERE AND MEASURED OUT.
      The two admin chips are indistinguishable at this width -- two identical
      solid-gold circles, 🛡️ and ⚙, going to Central Admin and to this tool's
      own admin, which is what he walked into. Labelling them was the obvious
      answer, so it was built: "Main" and "Tool", four characters each.

      MEASURED AT 390px: Bingo's bar stayed at 63px and CARD GAMES WENT FROM 63
      TO 103, against test_v651's 110px ceiling -- that page carries an extra
      controls row and the words pushed it onto another line. Seven pixels of
      headroom on the busiest bar is not headroom, so the words do not ship
      here. They still show from 391px up, where they cost nothing.

      WHAT TELLS THEM APART ON A PHONE IS SHAPE, which costs no height at all:
      the shield keeps the solid accent V1058 gave it (the 🛡️ emoji carries its
      own dark slate and needs a light ground to be seen at all), and the tool
      chip below becomes an outline. See `.tbar-adm` for that half. */
}
/* Two shapes the extra slot needs, named once here rather than per page:
   .tbar-acts groups controls that must stay side by side (RaffleHub's ✦ New
   raffle and its skin switch), and .tbar-lnk is a plain text link for the two
   pages whose extra is a way BACK rather than an action (Samaya's day page, Noise
   Lab's admin). Both were previously inline styles or a per-page class. */
.tbar-acts { display: flex; align-items: center; gap: 6px; }
/* Carried over from RaffleHub's .rh-actions, which is the row this header
   generalises: a control in the right slot may not be squashed to fit. On a
   coarse pointer a button that has been shrunk to 26px is a button that gets
   missed, and the grid would happily do it -- minmax(0, 1fr) means the side cells
   CAN go to zero, which is right for the cell and wrong for its contents. */
.tbar-r > * { flex: 0 0 auto; }
.tbar-lnk { color: var(--chrome-accent); text-decoration: none; font-size: 12.5px;
            font-weight: 700; white-space: nowrap; }
/* V651: the floating social cluster and the tool header. MEASURED at 390px and
   900px with shell.js absent: #sg-social's 44px circles (fixed, top 10px, right
   12px, z 99996) sat over .tbar-r, and a press aimed at the theme button hit
   Messages -- on Samaya and RaffleHub at 390px they covered the gold Admin chip.
   THE OFFSET IS NOT HERE. A number in this file would be a guess about where the
   row ends, and it was wrong the first time I wrote it: on the Noise Lab admin
   page the row does not start at the top of the page, so a fixed `top: 64px` put
   the cluster back on top of the theme button. base.html measures the row it can
   see and sets the cluster's top from that -- one mechanism, no second number to
   keep in step. `.in-shell` is left alone there for the same reason: when the
   shell is on, shell.js moves the cluster into the menu (v469). */

/* ==========================================================================
   V653 · THE TAB ROW, AND THE FROZEN PANES
   --------------------------------------------------------------------------
   His two asks, in his words:

     "put the tabs inside a section box. remove the Tabs *** row completerly..
      just give an option to group or expand the tabs..whole row is unnecessary
      taking more space"
     "give admin toggle option to freeze pane till tabs in all pages: separate
      page separate option to freeze panes or unfreeze the panes"

   He was shown four mockups and picked B: one line of tabs, the rest behind
   "+7 more". Then four answers: freeze ON THE TABS and not the table header,
   default ON, laptops only, one switch per page.

   THESE RULES LIVE IN A CSS FILE AND NOT IN tabgroup.js, which is where the old
   ones lived. test_v578_contrast reads CSS files to measure 4.5:1; a colour
   typed into a JavaScript string is a colour nothing checks.
   ========================================================================== */
.tabrow {
  position: relative;
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0 0 14px;
  min-width: 0;
}
.tabrow[hidden] { display: none !important; }
/* The bar must not wrap: wrapping is the thing being replaced, and a wrapped row
   makes the width arithmetic in layout() meaningless.
   `.tabrow-fit` ONLY -- a row the page already scrolls horizontally (Global
   Pulse's category strip) keeps its own overflow and its own tabs, and takes the
   wrapper purely for the freeze. */
.tabrow-fit > :first-child {
  display: flex;
  flex-wrap: nowrap;
  min-width: 0;
  overflow: hidden;
  flex: 1 1 auto;
}
/* THE TABS MUST NOT SHRINK, and this line is the difference between the feature
   working and looking like it works. A flex item's default is `flex-shrink: 1`, so
   in a nowrap row twenty-nine tabs SQUASH to fit instead of overflowing -- every
   measured width then adds up to exactly the container, layout() concludes that
   everything fits, no "+N more" appears, and the row is a line of unreadable
   slivers. Measured on Central admin's twenty-two-tool row, which is one of the two
   pages he photographed. */
.tabrow-fit > :first-child > * { flex: 0 0 auto; }
/* OURS, and only ours. A tool hides its own tabs with the `hidden` attribute or
   display:none; this class is how layout() says "this one is in the menu", and
   the two are read back separately so one pass cannot swallow the other's. */
.tabrow .tg-off { display: none !important; }

.tabrow-more {
  flex: 0 0 auto;
  background: var(--panel);
  border: 1px solid var(--line2);
  border-radius: 999px;
  color: var(--muted);
  font-family: inherit;
  font-size: 13px;
  font-weight: 700;
  padding: 8px 14px;
  cursor: pointer;
  white-space: nowrap;
}
.tabrow-more:hover { border-color: var(--accent); color: var(--text); }
.tabrow-more[hidden] { display: none !important; }

.tabrow-menu {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 40;
  min-width: 190px;
  max-width: min(320px, 90vw);
  max-height: 62vh;
  overflow: auto;
  padding: 6px;
  background: var(--panel);
  border: 1px solid var(--line2);
  border-radius: 12px;
  box-shadow: 0 18px 44px rgba(0, 0, 0, .28);
}
.tabrow-menu[hidden] { display: none !important; }
.tabrow-mi {
  display: block;
  width: 100%;
  text-align: left;
  background: transparent;
  border: 0;
  border-radius: 8px;
  color: var(--text);
  font-family: inherit;
  font-size: 13.5px;
  font-weight: 650;
  padding: 9px 11px;
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.tabrow-mi:hover, .tabrow-mi:focus-visible { background: var(--btn); }

/* ---- the freeze ----------------------------------------------------------
   LAPTOPS ONLY, and that is measured rather than stylistic: a frozen row is
   ~54px, which is a third of the usable height of a 390px phone once the app
   shell and the keyboard-safe area are counted. He asked for it that way.

   `min-width: 1000px` AND `hover: hover`: width alone would freeze the row on a
   tablet held in landscape, where the same third-of-the-screen argument applies.

   The attribute is written by the server (base.html, from tabfreeze.py) and is
   `on` or `off` per page, so switching it off for one tool leaves the rest
   frozen -- his "separate page separate option". */
@media (min-width: 1000px) and (hover: hover) {
  [data-tabfreeze="on"] .tabrow {
    position: sticky;
    top: 0;
    z-index: 30;
    /* Opaque, or the table scrolls through the tabs. --bg is the page ground on
       both themes; the padding gives the row its own edge to sit on. */
    background: var(--bg);
    padding: 8px 0 6px;
    margin-bottom: 8px;
    box-shadow: 0 8px 14px -12px rgba(0, 0, 0, .5);
  }
}
/* Reduced motion has nothing to say about sticky, but a person who cannot see a
   sticky row move is helped by the edge staying visible; nothing to disable. */
/* A category label from the row (Central admin groups its twenty-two tool tabs by
   the hub's own sections). It is a heading in the menu, not a choice. */
.tabrow-cat {
  padding: 8px 11px 4px;
  font-size: 10.5px;
  font-weight: 800;
  letter-spacing: .05em;
  text-transform: uppercase;
  color: var(--muted2);
}

/* =========================================================================
 * V654 — A BAR FIXED TO THE BOTTOM OF THE WINDOW RESERVES ITS HEIGHT.
 *
 * HIS REPORT: "this section is not working.. menu" -- the ⋮ button at the end of
 * a table row on Raffle Hub did nothing. It was not the menu. The cookie consent
 * bar is `position: fixed; bottom: 0; z-index: 50` and NOTHING reserved its
 * height, so the last ~58px of every page on this site was painted over by it.
 * A click there reaches the bar, not the page. The menu was fine; the click
 * never arrived. Anything at the foot of a page was affected the same way --
 * the last table row's actions, a Save button at the end of a long form, the
 * final item of any list -- on every page, for every visitor who had not yet
 * answered the cookie question. It read as "that control is broken" because
 * the thing eating the click is the same colour as the page furniture.
 *
 * THE FIX IS GENERAL, NOT LOCAL. Raising the menu's z-index would have fixed the
 * one control he happened to click and left the other thousand. The rule this
 * release adopts is the one that scales: a bar fixed to the bottom of the
 * viewport must RESERVE its height rather than paint over the page.
 *
 * --um-botbar IS MEASURED, NOT ASSUMED. base.html sets it from the bar's own
 * rect (`innerHeight - top`, so a bar held clear of the edge reserves the gap
 * too), on show, on hide, on resize and on the bar's own resize -- the text
 * wraps to three lines at 320px and to one at 1400px, so a hard-coded 58px
 * would be wrong at both ends. It is unset (falling back to 0px) whenever the
 * bar is dismissed, absent, or display:none -- which is what makes the reserve
 * cost nothing on the projector walls, where raffle_present.css hides it.
 *
 * WHY IT IS DECLARED HERE, at the end of the file: #themebtn, #refreshbtn,
 * #installbar and #toast set their own `bottom` further up, and a rule that
 * lifts them has to come after theirs to win. The cookiebar block itself stays
 * where it is -- this is the lift, not a second copy of the bar. */
/* EVERY PAGE, and the first draft of this rule was wrong about that. It was
   scoped to body.has-shell on the theory that `full` pages are all viewport-
   locked boards that must not grow -- but driving them showed only ONE template
   in the site is built that way (line_escape.html, the `#game-root` rule below).
   Snake, Minesweeper and Word Sweeper are ordinary scrolling documents that
   merely opt out of the sidebar, and under the narrow scope their d-pad and
   action buttons stayed exactly as unclickable as the ⋮ he reported. A rule
   whose stated purpose is "every page at once" has to actually be every page.

   THE COST, STATED: while the bar is up, a page that used to fit the window can
   now scroll by its height. That is the correct trade -- a control you can see
   and cannot press is worse than one you scroll to -- and it lasts until the
   visitor answers the cookie question once. */
/* `html body` AND NOT `body`, which is the second thing driving the pages taught
   me. admin.html carries a page-level <style> with `body { padding: ... 20px }`
   -- a shorthand, so it sets padding-bottom too -- and a bare `body` rule here
   ties it on specificity and loses on source order, because a page's own <style>
   comes after the stylesheet link. /admin reserved nothing while every other
   page reserved 58px, which is exactly the kind of hole a "general fix" is
   supposed to not have. Two type selectors (0,0,2) beat one (0,0,1) regardless
   of order, and still lose to body.sa-pad (0,1,1) below, which is the ordering
   this needs. raffle_present.css's `body.rp-body { padding: 0 !important }`
   still wins over both -- correctly: that page hides the bar entirely. */
/* DECLARED, not only referenced. Every use below carries the `, 0px` fallback so
   the page is right before any script runs -- but a fallback standing in for a
   token that is declared NOWHERE is a typo that happens to work, and V578's
   suite fails the build for it on purpose. The declaration is the record that
   0px is the resting value and base.html is what changes it. */
:root { --um-botbar: 0px; }
html body {
  padding-bottom: var(--um-botbar, 0px);
}
/* The eight pages that take their notch inset from <body> (see body.sa-pad
   above) already own padding-bottom, so theirs is the sum rather than a
   replacement. Without this line those eight would reserve nothing -- and it
   has to come AFTER the has-shell rule, which it ties with on specificity. */
body.sa-pad {
  padding-bottom: calc(env(safe-area-inset-bottom) + var(--um-botbar, 0px));
}
/* THE ONE VIEWPORT-LOCKED PAGE. line_escape.html is the only template in the
   site whose root is `height: 100dvh`, and there the padding above would grow
   the document past the window instead of moving anything: the board shortens
   instead. 100dvh - 0px is 100dvh, so a visitor who has already answered the
   cookie question sees the same board this line was written against. */
#game-root {
  height: calc(100dvh - var(--um-botbar, 0px));
}
/* The floating chrome sits ABOVE the bar (z-index 55-900 against its 50), so it
   was never unclickable -- it was overlapping the bar's text. Same lift, for the
   same reason: 0px when there is no bar, so the resting look is unchanged. */
#themebtn, #refreshbtn, #installbar {
  bottom: calc(12px + env(safe-area-inset-bottom) + var(--um-botbar, 0px));
}
#toast {
  bottom: calc(24px + env(safe-area-inset-bottom) + var(--um-botbar, 0px));
}
/* The 🛠 Admin pill's geometry is an inline style written by base.html, so this
   needs !important for the same reason body.fabpos-bottom-right's does. */
.fab-pill {
  bottom: calc(12px + env(safe-area-inset-bottom) + var(--um-botbar, 0px)) !important;
}
body.fabpos-bottom-right .fab-pill {
  bottom: calc(72px + env(safe-area-inset-bottom) + var(--um-botbar, 0px)) !important;
}
#shell-open.fab-at-bottom-left,
#shell-open.fab-at-bottom-centre,
#shell-open.fab-at-bottom-right {
  bottom: calc(12px + env(safe-area-inset-bottom) + var(--um-botbar, 0px)) !important;
}

/* ==========================================================================
   V828 — APP FEEL, LAYER 1 AND LAYER 2.

   THE SYMPTOM. Every tap on this site is a real page load. The browser discards
   the current document -- header, drawer, everything -- asks the server for the
   next one, waits, and repaints from scratch. For that window the screen is
   white. In the installed PWA it is worse than in a browser tab, because there
   is no browser chrome left to look at while it happens: the app appears to
   blink out and come back.

   WHAT THIS IS NOT. Not a router, not a framework, not a single-page app. Every
   route, template and tool renders exactly as it did in V827. The browser does
   the work; this stylesheet only asks it to.
   ========================================================================== */

/* CROSS-DOCUMENT VIEW TRANSITIONS. The browser holds the OUTGOING page on screen
   while the next one loads, then crossfades between them -- so a navigation
   never shows a blank. Chrome/Edge 126+ and Safari 18.2+ honour it. Firefox has
   not shipped cross-document transitions (bugzil.la/1860854) and simply ignores
   the at-rule, which is today's behaviour exactly -- so there is no fallback to
   write and nothing to feature-detect. That is the whole reason this is CSS and
   not a script: the unsupported path is the status quo, for free. */
@view-transition { navigation: auto; }

/* NAMING THE ONE HEADER THAT GENUINELY PERSISTS.
   An element with a view-transition-name is treated as the SAME element on both
   documents, so it stays put instead of crossfading with itself.

   IT IS `.tbar` AND NOT `.shell-head`, and the distinction cost an hour to find.
   `.shell-head` (base.html, inside <nav id="shell-nav">) is the DRAWER's head
   row -- Home / Admin / ✕. It is off-canvas and closed during every navigation,
   so naming it would name something the user cannot see. `.tbar` is the one
   shared tool header _toolbar.html renders, on 41 templates, exactly once each --
   which is also what makes it legal here: a duplicated view-transition-name
   aborts the whole transition SILENTLY, so "once per document" is not a style
   preference, it is the requirement.

   THE HUB IS THE DELIBERATE EXCEPTION. tools.html uses `.tools-head`, not
   `.tbar`, so hub -> tool has no matched header and crossfades whole. That is
   correct rather than missing: the hub's head and a tool's head are different
   objects, and pinning one to the other would slide a title into a title. Tool
   -> tool is where the match pays, and that is the journey the drawer makes. */
.tbar { view-transition-name: hub-header; }

/* THE TAP FEEDBACK BAR. Layer 1 has a gap: during the hold, NOTHING VISIBLY
   HAPPENS -- the old page just sits there looking finished. A person who taps
   and sees no response taps again. So the white flash is traded for "looks
   unresponsive", which is not a win. static/js/nav_feedback.js paints this the
   moment a navigation starts. 2px, accent-coloured, top of the viewport, over
   everything: it is the only thing on screen saying the tap landed. */
#nav-prog {
  position: fixed; top: 0; left: 0; height: 2px; width: 0;
  background: var(--chrome-accent, #ffcf5c);
  z-index: 100000;                 /* above the drawer (90) and the cookie bar (50) */
  opacity: 0; pointer-events: none;
  transition: opacity .16s linear;
  /* The notch does NOT inset this. A hairline under the status bar is the one
     place a safe-area gap would read as a rendering fault rather than respect. */
}
#nav-prog.run { opacity: 1; animation: nav-prog-run 2.6s cubic-bezier(.05,.7,.25,1) forwards; }
/* It never reaches 100%: the bar's job is "something is happening", and a bar
   that completes while the page has not arrived is a lie the user can see. The
   navigation itself removes the element, which is the only thing that ends it. */
@keyframes nav-prog-run { from { width: 2% } 60% { width: 76% } to { width: 94% } }

/* THE TAPPED THING DIMS. Belt and braces with the bar above -- on a phone the
   thumb is usually covering the top of the screen, so the control that was
   pressed has to answer for itself. Kept to opacity, deliberately: a transform
   here would fight `.tool-card:active`'s scale, which is a different signal
   (touched) from this one (accepted, now loading). */
.nav-going { opacity: .45; transition: opacity .12s linear; }

/* SKELETON LOADING. Grey shimmering shapes in the layout the real content will
   occupy, instead of a spinner or an empty hole -- the same wait, reading as
   "assembling" rather than "nothing here". It earns its place ONLY where content
   arrives AFTER the document does, i.e. something a script fetches; on a
   server-rendered page the markup and the paint arrive together and a skeleton
   would be a flash of fake content. Layer 1 covers those.

   WHAT WAS VERIFIED BEFORE BUILDING THIS, because the build brief guessed wrong
   about one of them: the hub's clock strip does NOT wait on anything. Every
   clock is computed client-side from server-rendered data-tz attributes and
   painted on the first frame. (hubtoday.js does call fetch three times -- the
   timezone list, add-a-city, drop-a-city -- but all three are inside click
   handlers, so none of them runs at load. A skeleton here would cover markup
   that was never empty.) The two real ones on
   the hub are the currency card (#hub-cc, GET /api/rates, ships `.hidden` and
   pops in) and the weather value (#hub-wx-v, GET /api/weather, ships as an em
   dash). Both are handled in tools.html. The class is declared here, in the one
   stylesheet every page loads, so Bingo, Stocks and the rest can adopt it in a
   later release without a second copy of the rule. */
.sk {
  border-radius: 12px;
  background: linear-gradient(90deg, var(--panel) 25%, var(--btn) 50%, var(--panel) 75%);
  background-size: 200% 100%;
  animation: sk-shim 1.1s linear infinite;
}
@keyframes sk-shim { from { background-position: 200% 0 } to { background-position: -200% 0 } }
/* Match the skeleton's box to the real content's or the page jumps when it
   fills, which is the jank a skeleton exists to prevent. */
.sk-line { height: 12px; margin: 6px 0; }
.sk-row  { height: 34px; margin: 8px 0; }

/* REDUCED MOTION IS A HARD REQUIREMENT, NOT A COURTESY. Vestibular disorders are
   real and page-sized movement can make people physically unwell. This block is
   the reason the whole feature can never be dropped by accident: tests/test_v828
   asserts it exists, so removing it turns the suite red.

   The navigation still WORKS with motion off -- `navigation: none` returns the
   browser to V827's behaviour, which is a plain load. The progress bar keeps its
   colour and position and simply stops animating; it is information, not motion,
   and taking it away would leave this user with the silent wait Layer 1 was
   built to remove. Follows the six existing reduced-motion blocks in this file
   rather than inventing a seventh pattern. */
@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
  #nav-prog.run { animation: none; width: 94%; }
  .sk { animation: none; }
  .nav-going { transition: none; }
}

/* ==========================================================================
   V829 — THE BOTTOM TAB BAR. Layer 3, option C: the tabs AND the floating ☰.

   WHAT IT IS FOR, IN ONE LINE: the drawer is a good list of thirty-six tools and
   a bad way to move between four rooms. The tabs are the four rooms.

   AND IT REPAIRS THE ONE EXPENSIVE THING ABOUT V579'S HIDE SWITCH. Hiding the
   floating menu removes the whole shell block -- button, backdrop and drawer --
   because #shell-open is the only control that opens #shell-nav, and fabmenu.py
   is explicit that "with the floating menu hidden, navigation is each page's own
   Hub/Back link". The tab bar does NOT depend on FAB_ON (see base.html), so from
   this release `hide` means "the tabs are the navigation" rather than "there
   isn't much navigation". Hidden ☰ + tabs is option A; shown ☰ + tabs is C.
   ========================================================================== */

/* PHONES ONLY, AND 700px IS NOT AN ARBITRARY NUMBER -- it is the breakpoint
   tools.html already uses for "this is a phone" (the head-row rules that hide
   the Support and Install words so four chips fit 390px). Above it, the drawer
   is already a good answer and a bar pinned across a 1400px window is a phone
   idiom on the wrong screen. `display: none` rather than not rendering it,
   because the same document is what a rotating tablet re-lays-out. */
.tabbar { display: none; }

@media (max-width: 700px) {
  .tabbar {
    display: flex;
    position: fixed; left: 0; right: 0; bottom: 0;
    /* Under the drawer (90) and its backdrop, over ordinary page furniture.
       A drawer that opened BEHIND the tab bar would be the v476 mistake in a new
       place: two navigation surfaces arguing about which is in front. */
    z-index: 45;
    background: var(--panel);
    border-top: 1px solid var(--line);
    /* THE HOME INDICATOR. Without this the last row of an iPhone's tabs sits
       under the bar the OS draws, and the tap target is gone rather than small.
       `env()` with a 0px fallback, so an Android with no inset pays nothing. */
    padding-bottom: env(safe-area-inset-bottom, 0px);
    padding-left: env(safe-area-inset-left, 0px);
    padding-right: env(safe-area-inset-right, 0px);
  }
  .tab {
    flex: 1 1 0; min-width: 0;                 /* five equal columns at 320px */
    display: flex; flex-direction: column;
    align-items: center; justify-content: center; gap: 2px;
    /* 48px of BAR, plus whatever the notch asks for. V671's floor for a touch
       target is 44px and this clears it -- a navigation control that has to be
       aimed at is worse than no navigation control. */
    min-height: 48px;
    padding: 6px 2px 5px;
    text-decoration: none;
    color: var(--muted3);
    font-size: 10px; font-weight: 700; line-height: 1.1;
    -webkit-tap-highlight-color: transparent;
  }
  .tab-ic { font-size: 18px; line-height: 1; }
  .tab-nm { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  /* --chrome-accent, NOT --accent. V578 declared that token precisely so a tool
     which repaints --accent inside itself (RaffleHub's blue, FinTrack's teal)
     cannot repaint the house chrome sitting in its own subtree. This bar is
     house chrome on every one of those pages. */
  /*  V898: `hidden` HAS TO WIN OVER `display:flex`, and it did not.
      The rule above sets `display:flex` on every `.tab`, which beats the browser's
      own `[hidden] { display:none }` on specificity -- so a tab marked hidden in
      the markup was drawn anyway. Found on Device Link, where a tab set
      `hidden` by script (`$("dl-tab-bin").hidden = ...`) stayed on screen for
      everybody. The tab did nothing when pressed -- the route behind it is
      gated -- but a control that is drawn and refuses is its own bug.

      Per-element, matching the five `[hidden] { display:none !important }` rules
      already in this file, rather than a blanket `[hidden]` selector: a global one
      would also override deliberate `display` on elements that use `hidden` for
      something else, and test_v616 exists to catch exactly that class of
      collision rather than to invite a bigger one. */
  .tab[hidden] { display: none !important; }
  .tab.on { color: var(--chrome-accent, #ffcf5c); }
  .tab:active { transform: scale(.94); }
  @media (prefers-reduced-motion: reduce) { .tab:active { transform: none; } }

  /* THE COOKIE BAR RESTS ON TOP OF THE TAB BAR rather than over it. Both are
     `position: fixed; bottom: 0`, and the cookie bar wins on z-index (50), so
     without this it would cover the tabs completely and the consent question
     would eat the navigation until it was answered. base.html's measurer then
     reserves the HIGHER of the two tops, which with this lift is the cookie
     bar's -- so the page still clears both with one number. */
  body.has-tabbar #cookiebar {
    bottom: calc(48px + env(safe-area-inset-bottom, 0px));
  }
}

/* NOTHING BELOW THIS LINE LIFTS THE FLOATING CONTROLS, AND THAT IS THE POINT.
   `--um-botbar` already does it. V654 built it as "a bar fixed to the bottom of
   the viewport must RESERVE its height rather than paint over the page", and
   every fixed control on this site -- #shell-open in all three bottom
   positions, .fab-pill, #themebtn, #refreshbtn, #installbar, #toast -- already
   reads it, as does `html body { padding-bottom }`. base.html now feeds it the
   higher of the cookie bar and the tab bar, so the tab bar cost exactly one
   argument to a function that existed, and the ☰ lifts above it on every page
   without a single new rule. The alternative -- a second variable and a sweep
   through eight selectors -- is how the two would have drifted apart. */

/* ==========================================================================
   V881 — THE PAGE WIDTH RULE, IN ONE PLACE.

   MEASURED BEFORE IT WAS WRITTEN, and the measurement changed the plan. The
   design doc assumed a shared container class every template would inherit
   from; the grep says otherwise. `.wrap` appears in 62 of 100 templates and is
   NOT a width cap anywhere -- it has no max-width rule at all. Every page's
   width lives in that page's OWN <style> block or its own tool stylesheet:
   `.fin` 1000, `.sp` 1000, `.st-wrap` 900, `.ml` 1150, `#bingo` 1180,
   `.oa-wrap`/`.od-wrap` 1180, `admin`/`tool_admin` body 1100. Sixteen numbers,
   each typed once and never compared with the others, which is why a Fin Track
   table and an owner console that do the same job are 180px apart.

   Rendered and measured in a real browser at 1920 before this block existed:
   the widest content on /tools/finance was 1000px on a 1920px screen. 920px of
   the monitor was margin.

   WHAT THIS BLOCK IS: two numbers and a gutter, declared once. A page that
   holds a table, a list, a dashboard or an admin console reads --page-wide; a
   page that holds prose, a form or one record reads --page-read. Nothing else
   changes -- no new wrapper element, no rewritten markup, no grid rework. A
   page opts in by pointing its EXISTING wrapper's max-width at the token, which
   is a one-line edit per page and reversible by the same one line.

   WHY 1600 AND NOT 100%: a table stretched to 2560px is not more readable, it
   is a row your eye cannot track from date to amount. 1600 is the point where
   a twelve-column money table stops scrolling on a 1920 screen and still has a
   left and right edge you can see at once.

   THE MIN-WIDTHS DO NOT BLOCK THIS, and that is worth recording because the
   plan said they would. Every table min-width on the site (#bingo table 520,
   .oh-tbl 560, .pf-tbl 720, .iv-t 790, #ohi table 840, .px-tbl 860) is BELOW
   the old cap, let alone the new one. They govern the phone, where a wide table
   is meant to scroll, and they are untouched here. The sweep the plan budgeted
   for is not a sweep: it is this sentence.

   .page-wide / .page-read are declared for pages written AFTER this release
   (Splits V4 first) so they never have to type a number at all.
   ========================================================================== */
:root {
  --page-wide: 1600px;
  --page-read: 820px;
  --page-gut: clamp(12px, 2vw, 28px);
  /* Phones and tablets: the page and the ☰ share the left edge, exactly as they
     have since v467. --page-max is the cap a page actually uses; below 1024 it
     is the cap itself and nothing about those widths changes. */
  --page-max: var(--page-wide);
  --page-max-read: var(--page-read);
}

/* THE ☰ IS A FIXED OVERLAY AND IT DOES NOT MOVE. `#shell-open` sits at left
   6-12px and is 48px wide, so it covers the page's first 60px at EVERY width
   (v467: "the button floats over the page", and `body.has-shell{padding-left:0}`
   deliberately reserves nothing for it). A page centred at 1000px never noticed,
   because on a laptop its left edge was 200px away. Widen that page to 1600 and
   its first row walks straight under the button: MEASURED, /tools/finance and
   /tools/stocks at 1600, 1440, 1366 and 1100 all had the Home pill starting at
   x=22-28 with the button's right edge at 54.

   /tools/raffle has had exactly this collision at 1366 and below since it was
   built -- it is full-bleed and nothing centred it away. So this is not a new
   class of bug, it is an old one that widening would have spread to nine more
   pages.

   THE FIX IS THE CAP, NOT A LEFT PAD. Padding the left by 64px and the right by
   the gutter leaves the content visibly off-centre, which is a worse-looking
   page than the one being fixed. Capping at `100vw - 128px` keeps the box
   CENTRED and simply stops it reaching the button: at 1920 it is still the full
   1600 (160px clear each side); at 1440 it is 1312 with 64 each side; at 1024 it
   is 896, which is NARROWER than the 1000 /tools/finance used to use there -- and
   that is the right answer, because 1000 at 1024 was already colliding.

   DESKTOP ONLY. Below 1024 the drawer, the button and the page behave as before. */
@media (min-width: 1024px) {
  :root {
    --page-max: min(var(--page-wide), calc(100vw - 128px));
    --page-max-read: min(var(--page-read), calc(100vw - 128px));
  }
}
.page-wide { width: 100%; max-width: var(--page-max); margin-inline: auto;
             padding-inline: var(--page-gut); }
.page-read { width: 100%; max-width: var(--page-max-read); margin-inline: auto;
             padding-inline: var(--page-gut); }

/* ==========================================================================
   V904 — TWO THINGS EVERY TOOL DOES, SAID ONCE.

   1 · NO SPINNERS ON NUMBER FIELDS. His note, with a screenshot of the Hands
   box: the stepper arrows sit inside the field, squeeze the number, and a
   mis-tap on a phone changes a score. They are a desktop affordance on forms
   that are filled in at a card table. Every tool loses them, rather than each
   one remembering to.

   Typing, arrow keys, `step` and mobile numeric keypads are unaffected — this
   removes the rendered control, not the input type.
   ========================================================================== */
input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

/* 2 · A DEFAULT SHOWS AS A PLACEHOLDER, so it reads as grey and needs no
   deleting before typing. His rule from the hand-entry box — "0 should be
   placeholder and grey..user should not derete before writing numbers" — and
   the settings forms now follow it. Some browsers dim placeholders by their own
   amount; this pins it to the theme's own muted colour so grey means grey in
   both themes. */
input::placeholder, textarea::placeholder { color: var(--muted3); opacity: 1; }

/* =========================================================================
   V912 -- THE CALENDAR ICON INSIDE EVERY DATE BOX, SITE-WIDE.

   `<input type="date">` draws its own little calendar glyph, and the browser
   draws it almost black. On a white form that is right. On this site's dark
   fields it is a dark mark on a dark ground -- his report, from Event Expenses:
   *"the calendars are not contrast friendly"*.

   IT WAS NEVER AN EVENT EXPENSES BUG. FinTrack hit this and fixed it in its own
   stylesheet, scoped to `.fin-fld` and `.fin-in`, and nobody lifted the fix out.
   An audit of every template with a date box found 18 of the 19 still carrying
   it -- Event Expenses, To-Do, Family Medicine (shipped the day before with the
   same fault), Bingo, Score Keeper, Splits, Raffle, Job Track, Bulk Mailer, the
   Nepali calendar, Tunebook, and seven owner screens. One page's local fix for a
   site-wide fault is a fix that only ever helps one page.

   INVERT, NOT A COLOUR. The indicator is a browser-drawn image rather than text,
   so `color` cannot reach it; inverting and brightening is the only lever. Put
   back for the light theme, where near-black is correct.

   FinTrack's own copy is REMOVED in the same edit -- two rules doing one job is
   how they drift apart, which is the whole reason this one went unnoticed. */
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
input[type="month"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator {
  filter: invert(1) brightness(1.6); opacity: .85; cursor: pointer;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover,
input[type="datetime-local"]::-webkit-calendar-picker-indicator:hover,
input[type="month"]::-webkit-calendar-picker-indicator:hover,
input[type="time"]::-webkit-calendar-picker-indicator:hover { opacity: 1; }
html:not([data-dark]) input[type="date"]::-webkit-calendar-picker-indicator,
html:not([data-dark]) input[type="datetime-local"]::-webkit-calendar-picker-indicator,
html:not([data-dark]) input[type="month"]::-webkit-calendar-picker-indicator,
html:not([data-dark]) input[type="time"]::-webkit-calendar-picker-indicator {
  filter: none; opacity: .75;
}


/* =========================================================================
   V915 --- namepop, FOLDED IN FROM static/css/namepop.css.
   It was its own sheet in V914, loaded by the two pages that used it. V915
   needs the panel on EVERY page (the owner-only email reveal), and base.html
   carries exactly two stylesheets by a rule with a reason -- this one and the
   compact.css density layer -- which three suites assert and V910 restated.
   A third site-wide sheet is a third request on every page load on a host
   whose binding limit is CPU, so the rule is right and the component comes
   here instead. The argument for every declaration is at the top of
   static/js/namepop.js.
   ========================================================================= */
#namepop{position:fixed;z-index:90;max-width:min(340px,calc(100vw - 16px));
  display:flex;flex-wrap:wrap;gap:6px;padding:10px 11px;border-radius:12px;
  background:var(--panel);border:1px solid var(--line2);
  box-shadow:0 14px 34px rgba(0,0,0,.42);
  /* OPACITY, never display or visibility: text that vanishes is text a screen
     reader loses halfway through reading it. */
  opacity:0;transform:translateY(-4px);
  transition:opacity .22s ease,transform .22s ease}
#namepop.vis{opacity:1;transform:translateY(0)}
#namepop[hidden]{display:none}
#namepop .np{display:inline-flex;align-items:center;gap:5px;font-size:12px;
  font-weight:700;border-radius:999px;padding:4px 9px;background:var(--btn);
  border:1px solid var(--line2);color:var(--muted2);white-space:nowrap}
/* The one carrying a note -- "paid", "won" -- is the one the reader is looking
   for, so it states its own ink with its own ground rather than being the same
   chip with a word stuck on. */
#namepop .np.note{border-color:var(--accent);color:var(--accent)}
#namepop .np small{font-weight:600;color:var(--muted3)}
@media (prefers-reduced-motion:reduce){
  #namepop{transition:opacity .01s linear}
  #namepop.vis{transform:none}
}

/* V915 --- a username the OWNER may tap to see the address behind it.
   Marked only for the owner (whois.js adds the class), so nobody else gets a
   control that would refuse them -- his standing "dont grey it out.. just dont
   show them". The dotted underline is the only hint a phone can be given. */
.whois{cursor:pointer;border-bottom:1px dotted currentColor}
.whois:hover,.whois:focus-visible{color:var(--accent)}

/*  V921, HIS ASK: *"no need to give dotted underline against name in scorekeeper
    as well as event expenses"*. Those two tools only -- the rule above still
    marks names everywhere else, because he named two tools and not the site.

    WHAT IT COSTS, written down because it is a real loss and not a tidy-up: the
    dots were the ONLY sign a name could be tapped. Hover turns it accent-colour,
    but a phone has no hover, so on a phone there is now nothing at all. The name
    still opens the address panel; nobody is told that it does. He is the only
    person who ever sees these marks (whois.js adds the class for the owner
    alone), so the person losing the hint is the person who asked to lose it.

    `.cp .whois` is (0,2,0) against the rule above's (0,1,0), so it wins on
    specificity and needs no !important -- V736's rule. */
.cp .whois,.ev .whois{border-bottom:0}

/* V917 --- SHOW EMAILS. The address sits BESIDE the name, never inside it: the
   name cell is what every table lays out and what sorting and the click itself
   read, so growing it would move the column and change what a click returns.
   A sibling leaves all of that alone and wraps on a phone instead of pushing
   the row sideways --- the same arithmetic that cost V912 a day. */
.whois-em{display:inline-block;margin-left:6px;font-size:11px;font-weight:600;
  padding:1px 6px;border-radius:999px;white-space:nowrap;
  border:1px solid var(--line);background:var(--panel);color:var(--muted3)}

/* The pill draws itself only on pages that have a marked name, so it is never
   a control sitting on a page it cannot act on. Fixed, because these names live
   inside scrolling boxes --- rowmenu.js's precedent. Below namepop (z 90) so an
   open panel is never covered by the button that asked for it. */
.whois-pill{position:fixed;right:12px;z-index:80;
  /* V654's RULE, AND IT IS NOT OPTIONAL: every fixed control on this site
     clears the bottom bars by the MEASURED `--um-botbar` rather than by a
     number somebody guessed. Without it this pill sits on top of the phone tab
     bar -- which is what the first render of it did. */
  bottom:calc(12px + env(safe-area-inset-bottom, 0px) + var(--um-botbar, 0px));
  /* LONGHANDS, NOT THE `font` SHORTHAND. `font: 600 12px/1.2 inherit` puts a
     CSS-wide keyword in the family slot, which makes the whole declaration
     invalid -- test_v650 exists because that has been written here before. */
  font-weight:600;font-size:12px;line-height:1.2;
  padding:8px 12px;border-radius:999px;cursor:pointer;
  border:1px solid var(--line);background:var(--panel);color:var(--text);
  box-shadow:0 2px 10px rgba(0,0,0,.18)}
.whois-pill:hover,.whois-pill:focus-visible{border-color:var(--accent)}
.whois-pill.on{background:var(--accent);border-color:var(--accent);color:#fff}
@media print{.whois-pill{display:none}}

/* ======================================================================= V922
   PRINTING. Until this release, printing any page on this site produced a
   nearly blank sheet, and the number is the argument:

       table text on paper   1.18 : 1
       column headings       2.03 : 1
       readable needs        4.50 : 1

   WHY, and it is not a bug anybody wrote. Browsers leave page BACKGROUNDS out
   when they print unless the person ticks a box most people never find. This
   site's text is pale because it is designed against a dark panel; take the
   panel away and the pale text lands on white paper. Measured in print media on
   the Family Medicine report, which is the page this tool's own comment says
   somebody will print and hand to a doctor.

   THE VISIT SHEET WAS NEVER AFFECTED and is left alone -- it is generated as a
   PDF by fpdf2, black on white, and it was rendered and looked at. This is for
   every OTHER page, which until now nobody could print at all.

   WHAT IS HIDDEN, AND WHY IT IS A NAMED LIST RATHER THAN `button`. Hiding every
   button would take the content with it: Score Keeper's game rows are buttons,
   `.nmcut` column headings are buttons, Bingo's prize ladder is buttons. So this
   names the furniture -- the shell, the floating menu, the social cluster, the
   cookie and install bars, toasts and popovers -- and leaves everything a page
   actually says. The toolbar keeps its NAME and loses its controls, so a printed
   page still says which tool it came from.

   `!important` IS USED HERE AND V736'S RULE STILL HOLDS. That rule is about
   winning specificity fights inside the cascade; this block has to beat inline
   styles and every tool's own colours at once, for one medium, in rules that
   cannot affect the screen. There is no specificity answer to "every colour on
   the site", and a print rule that loses is a blank page. */
@media print {
  :root, html, body {
    background: #fff !important;
    color: #111 !important;
  }
  /* Every surface loses its fill; every word goes dark. The border colour is set
     once so panels still read as panels on paper rather than dissolving. */
  body *, body *::before, body *::after {
    background-image: none !important;
    box-shadow: none !important;
    text-shadow: none !important;
  }
  body :not(svg):not(svg *) {
    background-color: transparent !important;
    color: #111 !important;
    border-color: #bbb !important;
  }
  /* Muted text stays muted, or a printed page has no hierarchy at all -- but at
     7.0:1 rather than 2.0:1. */
  .muted, .small.muted, .sub, small, th, caption, figcaption, .fm-sub, .fm-dose-sub {
    color: #555 !important;
  }
  /* THE FURNITURE. Named, not guessed -- see the note above on why `button` is
     not in this list. */
  #shell-open, #shell-nav, #shell-backdrop, #sg-social,
  #cookiebar, #installbar, #namepop, .whois-pill,
  .tbar-l, .tbar-r, .tbar-x,
  [role="dialog"], .modal, .toast, .fm-toast, #pToast {
    display: none !important;
  }
  /* The toolbar keeps the tool's NAME, so a sheet says where it came from. */
  .tbar { border-bottom: 1px solid #bbb !important; padding: 0 0 6px !important;
          margin-bottom: 10px !important; display: block !important; }
  .tbar-mid, .tbar-title { display: block !important; text-align: left !important; }
  .tbar-name { font-size: 15px !important; font-weight: 700 !important; }
  /* A link's href is not printed, so the blue means nothing on paper. */
  a, a:visited { text-decoration: none !important; }
  /* Rows should not be torn across a page break. */
  tr, .card, .panel, .fm-panel { break-inside: avoid; }
}


/* V966 --- THIS LIVES IN THE CORE SHEET, NOT IN A THIRD STYLESHEET, AND THAT
   WAS NOT THE FIRST PLAN. It was built as static/css/sections.css and linked
   from base.html, and the full run turned up test_v910 and test_version_history
   together: *"base.html carries the two it has carried since V793 and no third
   -- a third is what test_v455 refuses and what V764 shipped by accident"*.
   The rule is right and this was not a special case. V910's own sentence says
   where furniture goes: the theme scales and the long-press sheet are in here
   *because they are furniture*, and a section header on every admin page is
   furniture by exactly that test. Moved, and the third sheet deleted. */

/* ============================ V966: THE BLUE SECTION HEADER, AND THE CARD UNDER IT.
   ---------------------------------------------------------------------------
   HIS ASK, 06-Oct-2026, with a screenshot of an airline ticket email: *"blue
   background with white colour (check for contrast) in different theme.. lets
   start with admin pages central and inside each tool.. Also see how each block
   is cleanly segregated from one another"*.

   WHAT IT REPLACES. Central admin is 46 bare <h2> headings down one scrolling
   page, and a tool's admin page is another 40. Nothing draws where a section
   ends, so the reader decides it from the spacing -- which is exactly the job a
   heading is bad at and a border is good at.

   THE COLOUR IS HIS, SAMPLED FROM HIS OWN SCREENSHOT: #0b2451, the navy bar on
   the airline ticket email he sent. ONE VALUE, ALL FOUR THEMES, no per-theme
   override -- *"A . Your navy, everywhere ... every where this only pls"*.

     white on the band          15.16   in every theme, because the band is opaque
     band against the card      light 13.81 . green 1.25 . zinc 1.17 . graphite 1.10

   THE DARK THEMES ARE THE INTERESTING PART AND THE NUMBER IS MISLEADING. 1.17
   against zinc's card looks like a failure and is not, because a contrast ratio
   compares BRIGHTNESS ONLY and is blind to hue. Navy against near-black is a
   small step in brightness and an obvious one in colour; with the hairline above
   and below, the bar is in no doubt. This was not reasoned -- all four candidates
   were rendered on a near-black page and looked at, which is also how the
   alternative died: lifting the navy until it passed a 3:1 brightness test turned
   it into the bright blue he had just rejected. "Passes the test" and "is the
   colour he asked for" pointed opposite ways, and the test was measuring the
   wrong thing. I had recommended the lifted version from the numbers and changed
   it after looking.

   WHAT THE RULE IS, THEN: white on the band must clear 4.5:1 -- that one is real,
   it is the text, and 15.16 clears it with room to spare in every theme. The
   band's EDGE is drawn by its own hairline, not by a brightness gap, so it does
   not depend on the card underneath. On the dark themes the header reads as a
   quiet inset strip rather than a bold bar, and that is the design rather than a
   compromise.

   TWO LEVELS, NOT THREE. His screenshot used a second, different bar for the
   block nested inside the first. With navy as the one colour, the nested band is
   a LIGHTER navy (#1d4e79, white 8.68) so depth still reads. A third level would
   stop reading as depth and start reading as decoration. */

/* One value for every theme. There is deliberately no per-theme override here:
   see the note above for why the obvious one was built, rendered and thrown out. */
:root { --hdr:#0b2451; --hdr-fg:#ffffff; --hdr2:#1d4e79;
        /* V977: the heavier of the TWO WEIGHTS he chose. A tab title and a
           section inside it were taking the same bar, and with one bar there
           is no hierarchy left -- his option B. Same family, one step down in
           lightness and one up in size, so the page still reads top-down. */
        --hdr0:#061731; }

.sec{
  background:var(--panel);
  border:1px solid var(--line);
  border-radius:12px;
  /* overflow:hidden is what lets the band sit flush in the rounded corner
     without its own radius -- change it and the band's square top-left pokes
     out of the card. */
  overflow:hidden;
  margin:0 0 14px;
}

.sechd{
  background:var(--hdr);
  color:var(--hdr-fg);
  padding:10px 14px;
  display:flex; align-items:center; gap:9px;
  font-weight:700; font-size:14.5px; letter-spacing:.01em;
  /* THE HAIRLINE GREEN LEANS ON. See the note above. */
  box-shadow:inset 0 -1px 0 rgba(0,0,0,.30), inset 0 1px 0 rgba(255,255,255,.14);
}

/* The heading keeps being an <h2> for screen readers and for the page outline;
   only its looks change. Resetting here rather than in the template means no
   page has to carry an inline style to undo its own h2 rule. */
.sechd, .sechd h2, h2.sechd{ margin:0; }
h2.sechd{ font-size:14.5px; font-weight:700; }
/* COLOUR AND FONT MUST BE FORCED ON THE INNER <h2>, not inherited and hoped for.
   admin.html carries its own `h2 { color: var(--muted); font-size:16px }` in a
   page <style>, and an element selector beats inheritance from the parent -- so
   the first render put a GREY title on the navy band while every band-only
   heading beside it was white, because there the .sechd class sat on the <h2>
   itself and won. Caught by screenshotting the real page; the counts all said
   48 of 48 banded and were right, and the page was still wrong. */
/* EVERYTHING INSIDE THE BAND TAKES THE BAND'S INK, and the doubled class is
   deliberate rather than a typo. `.sechd h2` is specificity (0,1,1) -- exactly
   what a page's own `.fca h2 { color: ... }` scores, and a page <style> block is
   parsed AFTER this sheet, so the page wins the tie. That is not theory: the
   first fix here used the single class, and test_v578_contrast then found GREY
   TITLES ON THE NAVY at 1.86:1 on the Family Calls and Noise Lab admin pages,
   plus the count in a heading's right-hand tail at 2.54:1 -- the same fault as
   the one already fixed on /admin, in three more places, every one of them a
   heading nobody could read. `.sechd.sechd` scores (0,2,0) and takes the tie out
   of play for any ordinary page rule, without reaching for !important. */
/* ...and a SECOND round of the same measurement moved it again, twice.
   `.sechd.sechd *` whitened the ⓘ button too -- and that button paints its own
   background, so white ink on it measured 1.47:1 on gold in three themes. An
   element that brings its own ground keeps its own ink; the band only owns the
   colour of text that sits directly on the band. And one page rule still tied
   and won on source order (`.nla .lede`, (0,2,0)), so the :not() pair -- which
   is doing real work excluding the button -- also carries the specificity past
   any ordinary page rule without reaching for !important. */
.sechd.sechd, .sechd.sechd h2,
/* :not([data-info]) was in this list for one run and was a GUESS, added while
   fixing the button rather than measured. It excluded exactly the thing that
   still failed: Noise Lab's heading carries `<span class="lede" data-info>`,
   which is plain text sitting on the band -- data-info marks prose to fold into
   a ⓘ, it does not mean the element paints itself. The button is the real case
   and :not(button) already covers it. */
/* ...and a THIRD round, V978, from the site's own measured contrast test rather
   than from looking. Raffle puts counters and a filter chip INSIDE its section
   headings -- `<h3 class="sechd"><span>Raffle list</span><span class="rh-chip"
   id="rh-ev-count">` -- and a chip paints its own pale ground. Whitening its ink
   measured 1.11:1 in the light theme, on five of them. It is the same rule the
   ⓘ button taught at V966 and the same words are the right ones for it: AN
   ELEMENT THAT BRINGS ITS OWN GROUND KEEPS ITS OWN INK. "chip" is this site's
   word for that, used by eight tools, so the exclusion is one idea rather than a
   list of pages that would need a new line every release. */
/* AND THEN THE BLANKET `:not(button)` FAILED THE OTHER WAY, on the same V978 run.

   Finance's ⓘ on Monthly budgets is a <button> with `background:transparent`, so
   it brings NO ground of its own and sat grey on the navy at 2.70:1. Taking the
   exclusion off instead whitened Raffle's `button.mini`, which DOES paint a pale
   ground -- 1.11:1, six of them. So neither "all buttons" nor "no buttons" is the
   rule.

   LETTING THE COLOUR INHERIT WAS THE NEXT ANSWER AND IT WAS ALSO WRONG: a `.hint`,
   a `.muted` and Raffle's `#rh-snd-now` each carry a grey chosen for a pale panel
   and keep it on the navy -- 2.54:1 and 2.70:1. They paint no ground at all; they
   are the quiet half of a heading.

   WHAT THE RULE ALWAYS MEANT, stated at last: everything on the band takes the
   band's ink, EXCEPT the few things that bring their own ground -- the ⓘ pill, a
   chip, and a filled button. Four names and one idea rather than a list of pages,
   and every one of them put here by a measurement. */
.sechd.sechd :not(.idot):not([class*="chip"]):not(.mini):not(.btn){
  color:var(--hdr-fg); }
.sechd h2{ font-size:inherit; font-weight:inherit;
           line-height:inherit; letter-spacing:inherit; }

/* NO filter on the icon. The mockup's first draft had grayscale+brightness to
   make the emoji "match" the white text, and it turned the shield, the clock
   and the envelope into three identical white blobs -- it destroyed the one
   thing an icon is for. Colour emoji on a blue band read fine. */
.sechd .ic{ font-size:15px; line-height:1; }

/* The right-hand tail: a count, a legend, a breadcrumb. Lighter than the title
   so it reads as an aside and does not compete with it. */
.sechd .tail{ margin-left:auto; font-weight:500; font-size:12px; opacity:.92; }

/* A BAND INSIDE A ROW MUST STILL BE A BAND. Several tools put a heading and a
   control on one line -- `<div style="display:flex"><h3>Income vs Expenses</h3>
   <span class="fin-seg">...` -- and a flex item shrinks to its own text, so the
   bar came out 162px wide inside a 985px panel: a navy chip, not a heading. It
   takes the free space instead, which keeps the control beside it exactly where
   it was. `min-width:0` because a flex item will not shrink below its content
   otherwise and a long title would push the control off the row on a phone.
   (17 bands across Finance, Opinion Hub and Raffle; found by measuring each
   band against its own box, not by reading the markup.) */
.sechd{ flex:1 1 auto; min-width:0; }
/* A <legend> IS NOT A FLEX ITEM and never will be: the rule above cannot reach it,
   so To-Do's "Ends" came out a 38px navy chip inside a 602px fieldset. It takes the
   width directly. (Measured, like the rest of this block.) */
legend.sechd{ display:block; width:100%; box-sizing:border-box; }

/* ---------------------------------------------------------------------------
   V978 --- A BAND ON A <summary> HAS TO OUTRANK THE TOOL THAT ALREADY STYLED IT.

   Folds are real section headings -- V977 settled that when "Call order", which
   is a button because it opens a section, turned out to have been excused by a
   rule about buttons. But a <summary> is the one heading shape tools reliably
   style for themselves, and two of them beat the band on specificity alone:

     `.oh-acc > summary`            (0,1,1) painted its own background over
                                    `.sechd` (0,1,0) -- white ink on a pale panel,
                                    1.19:1 in the light theme.
     `.rh-app .rh-fold > summary`   (0,2,1) set a colour that beat `.sechd.sechd`
                                    (0,2,0) -- dark ink on the navy, 1.12:1.

   Both were MEASURED on the rendered page in both themes, not noticed by reading.
   The tripled class scores (0,3,0), which clears an ordinary tool rule without
   reaching for !important, and it is stated here once rather than patched into
   each tool's own stylesheet -- where the next tool to style a fold would repeat
   the fault. */
.sechd.sechd.sechd{ background:var(--hdr); color:var(--hdr-fg); }
.sechd.sechd.sechd.sub{ background:var(--hdr2); }
.sechd.sechd.sechd.tab{ background:var(--hdr0); }
summary.sechd{ list-style:none; }
summary.sechd::-webkit-details-marker{ display:none; }

.secbd{ padding:14px; }
/*  V1039 --- ONE GAP UNDER A SECTION BAR, SITE-WIDE.

    HIS ASK, after V1036 did it in Score Keeper: *"lets apply in this tool
    first then in next release we can apply in all tools all pages"*.

    AND IT IS A DIFFERENT RULE REACHING THE SAME NUMBER, which is worth saying
    rather than hiding. In Score Keeper the fix was SUBTRACTIVE -- `.pad`'s top
    padding came off and what was left was the bar's own 8px plus a 1px
    hairline. A `.sechd` is a FILLED BAND with no hairline and no padding of its
    own below it, so there is nothing to subtract: the 9px has to be written.
    Measured first, both of them, in a browser: 14px here against Score
    Keeper's 9.

    ONLY WHERE A BODY SITS DIRECTLY UNDER A BAR. The sides and the bottom keep
    their 14px, or content would touch the edge -- the same half of the rule
    that made the Score Keeper version safe.  */
.sechd + .secbd{ padding-top:9px; }
/*  Nested sections carry a tighter padding of their own (`.sec .sec .secbd`,
    three classes), so the pairing has to be written at that weight too or the
    inner ones would quietly keep 10px and the site would be consistent
    everywhere except where somebody nested a section. */
.sec .sec .sechd + .secbd{ padding-top:9px; }
.secbd > :first-child{ margin-top:0; }
.secbd > :last-child{ margin-bottom:0; }

/* A block inside a block. Its body is tighter: at 14px all round the first
   render left a visible dead band under the navy bar. */
.sechd.sub{ background:var(--hdr2); font-size:13px; padding:8px 14px; }
.sec .sec{ margin:12px 0 0; }
.sec .sec:last-child{ margin-bottom:0; }
.sec .sec .secbd{ padding:10px 12px; }

@media (max-width:760px){
  .sechd{ font-size:14px; padding:9px 12px; }
  .secbd{ padding:12px; }
  .sechd + .secbd{ padding-top:9px; }
}

/* A sheet that is hidden stays hidden -- .sec must not resurrect it. */
.sec[hidden]{ display:none; }

/* ---------------------------------------------------------------------------
   THE BAND WITHOUT A CARD, and why it exists rather than being hidden.

   Most headings own the markup beneath them, so the card is a clean wrap. A
   handful do not: their content sits inside a <div> that was opened for an
   earlier heading and closes after a later one, so there is no span of the file
   that is exactly this section. Forcing a card there produces markup that looks
   right in the one state somebody tested and comes apart in another -- the fault
   that broke three tool admin pages in this release's first attempt, found only
   because the pages were rendered rather than read.

   So those headings take the band alone: same colour, same weight, same place in
   the page, no box. It is rounded on all four corners so it reads as a deliberate
   full-width bar rather than a card with its body missing. Converting one of them
   later is a markup change in that template and nothing here. */
h2.sechd{ border-radius:10px; margin:18px 0 10px; }
h2.sechd:first-child{ margin-top:0; }
.sec h2.sechd{ border-radius:0; margin:0; }

/* ---------------------------------------------------------------------------
   V977 --- THE SECOND WEIGHT, AND THE BAND-ONLY FORM FOR EVERY HEADING LEVEL.

   `h2.sechd` above was written when h2 was the only level that ever took a band
   on its own. The census (tests/heading_census.py) then found 196 <h3> and 16
   <h4> across the site that are real sections, so the band-only rule is stated
   for the levels rather than for one tag. Same shape, same place in the page.

   `.tab` is the heavier weight: the tab you are ON, with the sections inside it
   taking the ordinary bar. Darker and larger rather than a different colour --
   a second hue would read as a second kind of thing, and it is the same kind of
   thing one level up. */
h3.sechd, h4.sechd{ border-radius:10px; margin:18px 0 10px; }
h3.sechd:first-child, h4.sechd:first-child{ margin-top:0; }
.sec h3.sechd, .sec h4.sechd{ border-radius:0; margin:0; }
h3.sechd, h4.sechd{ font-size:13.5px; font-weight:700; }

.sechd.tab{
  background:var(--hdr0);
  font-size:17px; padding:13px 16px; border-radius:12px;
  letter-spacing:.005em;
}
h2.sechd.tab{ font-size:17px; margin:0 0 12px; }
/* The eyebrow that sits above a tab title is its caption, so it rides INSIDE the
   bar on the right rather than floating above it in grey. One line instead of
   two, and the caption can no longer drift away from the title it explains. */
.sechd.tab .tail{ font-weight:500; font-size:12px; opacity:.80; }
@media (max-width:760px){
  .sechd.tab{ font-size:15.5px; padding:11px 13px; }
  /* Under 560px the caption would squeeze the title to two words, so it drops to
     its own line under it rather than fighting for the row. */
  .sechd.tab{ flex-wrap:wrap; }
  .sechd.tab .tail{ margin-left:0; flex:1 0 100%; }
}

/*  V1079 --- THE FIRST-PAINT GUARD'S ONE RULE. See the note in base.html.
    An element carrying `data-boot-hide` is not drawn while <html> is still
    `booting`; the class is removed on DOMContentLoaded, on load, and after
    3 seconds whatever happens, so nothing here can be hidden permanently by
    a script that failed to arrive. `!important` because these elements are
    shown by a tool's own stylesheet and this has to win for the one moment
    it applies. */
html.booting [data-boot-hide] { display: none !important; }
