/* site/app/shell.css — the app shell and the floating nav.
 * Depends on tokens.css + ui.css.
 */

/* ⚠ THE NAV'S FOOTPRINT, PUBLISHED AS A VARIABLE — USE IT, DO NOT RE-DERIVE IT.
   Two screens park something directly above the floating nav: the cafe's cart bar and book's
   commit bar. Both had hard-coded their own number worked out from the nav's height (84px and
   78px), and book's was already 2px INSIDE the nav before anyone noticed. When the nav grew
   16px taller to fit its expanded labels, all of those numbers became wrong at the same time
   and none of them had an owner. This is the number.
   It is the EXPANDED footprint (14px offset + 72px expanded bar = 86px), not the collapsed
   one: a cart total that is only readable while the nav happens to be collapsed is not
   readable. The 10px left over is deliberate slack — this number is read by four screens
   that do not watch this file, so it is sized to be generous rather than exact. Shrink it
   only alongside them. */
/* ⚠ +8 ON EVERY VALUE OF THIS (2026-09-13): the cafe's and the shop's commit buttons now STICK
   at exactly this distance from the bottom, and the old numbers were sized against the
   COLLAPSED bar. While the bar is expanded after a tap — until the next scroll since 2026-09-14,
   when nav.js lost its 3.2s timer, so this is a resting state, not a passing one — it is 15–19px taller, and a
   control parked at the old distance sat 1px into the live pill and 6px into the player-code
   button. Measured at 360×780 in the expanded state; generous by 8 now in both states. */
:root { --fnav-clear: calc(104px + var(--bottom-inset)); }
/* The live pill's width once it has collapsed to its glyph. ⚠ DECLARED HERE AND READ BY
   active.js — see the dodge note there. Two copies of this number is one that goes stale. */
/* ⚠ 44, NOT 42: the collapsed pill is its 32px plate + 5px of padding each side + a 1px border each
   side, and the 42 left the dodge and the dock's reservation 2px off in every state (review,
   2026-09-14). The phone's 40 is the same sum with a 28px plate. */
:root { --lp-mini-w: 44px; }
/* ⚠⚠ THE LIVE PILL'S OPEN SHAPE IS TOKENS, NOT DECLARATIONS ON THE PILL (2026-09-15). Its padding, gap
   and chevron width change with the scroll now — folding toward the glyph in step with the bar — and
   nav.js is their ONE writer, inline, every frame, between these open values and `--lp-mini-pad` all
   round. Declared on the pill as well, they would be a second writer that silently does nothing
   (inline wins), and scripts/checks/riders.js fails the build on one. `--lp-mini-pad` + the plate
   + the border is `--lp-mini-w` above, at both breakpoints: 5 + 32 + 5 + 2 = 44, 5 + 28 + 5 + 2 = 40.
   The phone's values are in the phone block. */
:root { --lp-py: 5px; --lp-pr: 11px; --lp-pl: 5px; --lp-gap: 9px; --lp-go-w: 16px; --lp-mini-pad: 5px; }

/* ══ HOW THE FLOATING CHROME MOVES — ONE CURVE, MEASURED OFF INSTAGRAM ══════════
 * SPEC (Syd, 2026-09-14), with a screen recording of Instagram on iOS: "the NAV is TOO REACTIVE or
 * to twitchy … observe how smooth and premium the nav animation feels."
 *
 * ⚠⚠ MEASURED FRAME BY FRAME, NOT TASTED. The recording is 449 frames at 59.5fps. When the feed
 *   scrolls, Instagram's bar does ONE thing: the whole pill — glass, icons, avatar — scales to 0.833
 *   about its centre, and back. Tracking its two red badges in every frame, a shrink is 90% done in
 *   about 230ms and settled by about 340ms, with no visible overshoot, and it sets off at a steady
 *   rate rather than lurching. A spring fitted to both directions has damping 0.89 and a response of
 *   0.43s; `--float-ease` is that spring sampled into `linear()`.
 * ⚠⚠ OURS, MEASURED THE SAME WAY, WAS THREE MOTIONS ON THREE CLOCKS. The bar's width eased in about
 *   150ms with 60% of it in the first 50ms, its height ran on another curve, and the live pill
 *   collapsed from 233px to 40 on a third. Growing back, the width hit full size in two frames,
 *   because `max-width` was animating toward 420px on a bar only 386 wide. All of it was layout,
 *   under a blurred backdrop — and a tap on any tab made the live pill leap 17px in one frame. That
 *   is what "twitchy" was.
 * ⚠ SO EVERYTHING THAT MOVES WITH THE BAR MOVES BY `scale` AND `translate`: the bar, the code button
 *   riding on it, the live pill, the commit dock, the profile's pill. Colour changes and press feedback
 *   keep their own short timings; they are not the bar moving.
 *
 * ⚠⚠ AND SINCE 2026-09-15 THE SCROLL ITSELF MOVES IT, NOT A CURVE STARTED BY THE SCROLL. Syd: "right now
 *   it feels like the nav bar hits a certain scroll amount then it suddenly minimizes, the one i want …
 *   gradually minimizes as a i scroll (like its proportional)". So minimising is no longer a class that
 *   flips at 14px and a 360ms transition after it: 120px of a person's scroll takes the bar from full
 *   size to minimised in step with the finger, the same way back, and when the scroll lets go it settles
 *   to an end on a spring carrying the scroll's speed (navscrub.js). nav.js writes `scale` and
 *   `translate` inline on every piece, once per frame — ONE writer — and NOTHING HERE TRANSITIONS THEM:
 *   a transition on a property written every frame restarts on every write and the piece crawls behind
 *   the bar. scripts/checks/riders.js fails the build if one comes back.
 *   `--float-ease` below stays the curve for what is NOT scroll-driven — the label rows opening, the
 *   selected pill's corners — and nav.js runs the same spring (motion.js `SPRING.float`) for a tab tap,
 *   the band following the labels, and the live pill's dodge, so those still move as one object.
 *   ⚠ THE LIVE PILL'S FOLD TO ITS GLYPH IS DRIVEN WITH THE SCROLL TOO, and it is layout — padding, gap,
 *     the words' width — on one small fixed element, because a pill whose words leave cannot keep its
 *     width. Its words fade out in the first 15% of the fold and its width closes in a straight line, so
 *     it has no moment to be sudden at (navscrub.js pillShape). */
:root {
  --float-dur: 360ms;
  /* The fallback where linear() is unsupported (Safari before 17.2, Chromium before 113): a cubic fitted
     to the same spring samples, within 1% of it everywhere. ⚠ The first fallback here was
     cubic-bezier(.25, .7, .25, 1), which put 13% of the move in the first frame and was 90% done in
     169ms — the lurching ease-out this block exists to replace (review, 2026-09-14). */
  --float-ease: cubic-bezier(.2, .05, .3, 1);
  /* How small the bar gets while you read: Instagram's own ratio, 300 of 360. */
  --fnav-mini-scale: .833;
}
@supports (transition-timing-function: linear(0, 1)) {
  :root {
    --float-ease: linear(0, 0.008, 0.0296, 0.0616, 0.1014, 0.1468, 0.1958, 0.2469, 0.299, 0.351,
      0.4022, 0.4518, 0.4996, 0.5451, 0.5881, 0.6284, 0.6661, 0.701, 0.7333, 0.7629, 0.79, 0.8147,
      0.8371, 0.8573, 0.8755, 0.8918, 0.9064, 0.9193, 0.9308, 0.941, 0.95, 0.9578, 0.9647, 0.9707,
      0.9759, 0.9803, 0.9842, 0.9875, 0.9902, 0.9926, 1);
  }
}

/* ══ HOW TALL THE TAB BAR IS, AND HOW THE BAND ABOVE IT MOVES ═══════════════
 * SPEC (Syd, 2026-09-04): the floating pills above the bar have to "synch in size with the
 * nav bar".
 *
 * ⚠⚠ EVERY FLOATING THING ABOVE THE BAR USED TO HARD-CODE ITS OWN OFFSET, AND THEY DRIFTED.
 *   The live pill said `bottom: 82px`, which is 14 + 60 + 8 — the bar's offset, the bar, and a
 *   gap — worked out by hand and then re-worked out by hand for the phone, and again for the
 *   expanded bar as a `translate: 0 -18px` correction stacked on top. Three numbers describing
 *   one measurement. The day the bar's height changed, each of them was wrong separately, and
 *   this file already records that happening twice ("current reservation floating button status
 *   and the nav bar overlap when nav titles appear").
 *   The height is now stated once and everything above the bar derives from it, so the gap is
 *   the same 8px whatever the bar is doing.
 *
 * ⚠⚠ THE BAND'S `bottom` NO LONGER CHANGES WITH THE BAR'S STATE; THE BAND MOVES BY `translate`
 *   (2026-09-14). `--band-bottom` used to be re-derived from a per-state `--fnav-h`, so opening the
 *   labels changed `bottom` on the live pill — which had no transition on it — and the pill leapt
 *   17px in a single frame while the bar took 200ms to grow into the space (measured: its bottom edge
 *   went 802 → 785 on the first frame after the tap). Now `--fnav-h` is the collapsed bar only, and
 *   the band moves from there: up by what the labels add, or down by what the scale takes off the
 *   bar's top edge.
 *
 * ⚠⚠ THE BAND IS MOVED BY nav.js NOW, NOT BY `--band-shift` (2026-09-15). The shift used to be a
 *   variable switched per state (`.nav-open`, `.nav-mini`) and transitioned; with the bar's scale
 *   following the scroll it has to follow it too, every frame, and a variable read through calc() would
 *   restyle every one of `.screen`'s descendants each time it changed. So nav.js computes it
 *   (navscrub.js `band()`): y = −o·(Hopen − H) + (1 − k)·h / 2, where k is the bar's scale, o how far
 *   the labels are open, and h = H + o·(Hopen − H) — which keeps the gap between the bar and anything
 *   above it at exactly 8px for every k and o (tests/navscrub.test.js sweeps it at each breakpoint).
 *   It reads the two heights below, `--fnav-mini-scale`, and nothing else.
 *
 * ⚠ THESE ARE MEASURED, NOT GUESSED. Collapsed: a 44px button + 14 of padding + 2 of border.
 *   Open: the same button once a label opens under the glyph. Minimised is not a height any more —
 *   the bar scales. */
/* ⚠ 79, NOT THE 73 THIS FILE CARRIED FOR TWO WEEKS. Measured with the labels open at desk size: each
   button is 62.84px tall and the opened bar 78.84px, so a band placed for 73 sat 2.16px off the bar
   instead of 8 (review, 2026-09-14) — and since the labels now stay open until a scroll, that gap was
   on screen for as long as anyone left them. */
.screen { --fnav-h: 60px; --fnav-h-open: 79px; }

/* ══ WHY SO LITTLE OF THE MINIMISE ANIMATES ════════════════════════════════
 * ⚠⚠ SUPERSEDED ON 2026-09-14: the bar no longer resizes at all when it minimises — it scales, and
 *   the band above it translates (see "HOW THE FLOATING CHROME MOVES" at the top of this file). What
 *   follows is kept for its measurements and for why sixteen layout transitions had to go.
 * SPEC (Syd, 2026-09-05): "the adjustment of the size of the nav bar is very stuttery / laggy
 * / choppy".
 *
 * ⚠⚠ IT WAS SIXTEEN TRANSITIONS AND EVERY ONE OF THEM WAS A LAYOUT PROPERTY. width, height,
 *   max-width, padding, min-height, gap, font-size — across the bar, eight buttons, the avatar,
 *   the code button, the commit dock, the commit pill, its glyph and two CTA chips. Roughly
 *   twenty elements each forcing a layout pass, sixty times a second, for 320ms — and this
 *   state flips on every change of scroll DIRECTION, which is the most frequent thing anyone
 *   does in this app. DESIGN.md §4.1 is exactly about this: a frequent action loses its
 *   animation. It did not lose one, it grew fourteen.
 *
 * ⚠ WHAT IS LEFT, AND WHY. The bar's `max-width` and `padding` and the buttons' `min-height`
 *   ARE the layout change — they cannot be faked, and they are one subtree so the browser lays
 *   them out once per frame. Everything else that only LOOKS smaller now animates `scale`,
 *   which the compositor handles without touching layout at all. `font-size` transitions are
 *   gone outright: reflowing text every frame is the most expensive of the lot and a 1-2px type
 *   change over a third of a second is not visible mid-scroll.
 *
 * ⚠ `scale`, NOT `transform: scale()`. motion.js's pressable() writes `el.style.transform` on
 *   pointer-down and blanks it on release, so a transform here would be wiped by the first
 *   press — the same reason the live pill's offsets use `translate`. `scale` is its own
 *   property and composes with both.
 *
 * ⚠ 220ms, DOWN FROM 320. Fewer frames of the layout that is left, and DESIGN.md's own budget
 *   for a state change this frequent. */
/* ⚠ `--qr-d` DOES NOT CHANGE ANY MORE, and that is deliberate rather than an oversight. The
   code button's BOX is a constant 40px now — only its `scale` changes, so it shrinks without a
   layout pass — which means the room the commit dock keeps for it should be constant too. It
   used to drop to 34 here and take the dock's reservation with it, so the clearance tracked a
   button that was no longer resizing. Reserve for the box, not for the drawing. */
/* The band every floating pill sits in: clear of the bar by the same gap in every state. */
/* ⚠ --safe-bottom, not --bottom-inset: the band is read by FIXED elements — see the .fnav note. */
.screen { --band-bottom: calc(14px + var(--fnav-h) + 8px + var(--safe-bottom)); }
/* The player-code button's diameter. ⚠ ON `:root`, NOT ONLY ON `.fnav`, because it is not just
   the button's own size — it is how much room ANYTHING ELSE in that band has to leave on the
   right. The court booking's commit pill reads it for exactly that. Nothing overrides it: minimised,
   the button (riding the bar) and the dock that reserves room for it scale by the same factor. */
:root { --qr-d: 40px; }

/* ══ THE TAB BAR'S OWN BOX ═════════════════════════════════════════════════
 * ⚠ THE BAR'S INSET AND ITS MAX-WIDTH, NAMED, BECAUSE THREE OTHER THINGS NEED THEM.
 *   `.fnav` sets its geometry from these; the commit dock is the same box; and active.js reads
 *   them — with `--fnav-mini-scale` — to work out how far the live-session pill has to travel to
 *   park at the bar's left edge. That last one is why they are variables rather than literals in
 *   `.fnav`: JS cannot read a value that only exists inside a rule.
 * ⚠ THERE IS NO MINIMISED MAX-WIDTH ANY MORE. The bar used to narrow to `--fnav-max-mini`; it
 *   scales now, so its minimised width is simply this one times the scale. */
:root { --fnav-inset: 10px; --fnav-max: 420px; }
/* ⚠⚠ A SHORT BAR FOR A SHORT LIST (launch clean-up, 2026-10-05). The 420px cap was set for seven
 *   tabs and the avatar. With launch.js showing two, the same cap stretched three columns across the
 *   phone, ~120px each, and the bar read as a strip of empty space with an icon in each third. So the
 *   cap follows the count app.js writes as `data-tabs` (tabs shown, not counting the avatar): about
 *   80px a column, the width a labelled column needs to breathe without looking like a gap.
 * ⚠ PLAIN PIXELS, NOT A calc() OF THE COUNT: active.js reads `--fnav-max` with parseFloat to park the
 *   live pill at the bar's left edge, and an unresolved calc() string reads as NaN there.
 * ⚠ FIVE AND UP KEEP THE 420px CAP, which is what sample mode and the full app always had. */
:root[data-tabs="1"] { --fnav-max: 180px; }
:root[data-tabs="2"] { --fnav-max: 252px; }
:root[data-tabs="3"] { --fnav-max: 330px; }
:root[data-tabs="4"] { --fnav-max: 400px; }

/* ── Preview frame (not part of the app) ──────────────────────────────────── */
/* ⚠ THE FRAME SHRINKS TO FIT. IT MUST NEVER OVERFLOW A CENTRED CONTAINER.
   ui.css draws `.phone` at a fixed 390px. On an iPhone SE, a 13 mini or most Android
   handsets the viewport is narrower than that, and a box that overflows a container which
   CENTRES its contents hangs off BOTH edges — the left half of which no scrollbar can ever
   reach, because scrolling only ever exposes overflow to the right. The first preview shipped
   exactly that: measured at 355px wide, the frame started at x = -17 and the "Q" of "Queue"
   was sliced in half with nothing on screen to say so.
   `.phone` carries its own `max-width: 100%`, so all this rule has to do is make sure the
   flex item is `.phone` itself and that nothing in between refuses to shrink. min-width:0 is
   the half people forget: a flex item's automatic minimum size is its min-content width, and
   without this a wrapper pins itself open at 390px no matter what max-width says. */
.stage { display: flex; justify-content: center; }
.stage > * { min-width: 0; max-width: 100%; }

/* ── Screens ──────────────────────────────────────────────────────────────── */
.pane { display: none; flex: 1; min-height: 0; flex-direction: column; }
.pane.on { display: flex; }

.pane-scroll {
  flex: 1; overflow-y: auto; overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  /* Room for the floating nav, which hovers over the content rather than reserving a strip. */
  padding-bottom: var(--fnav-clear);
  /* ⚠⚠ THE PANE NEVER SCROLLS SIDEWAYS, AND THIS IS A FLOOR UNDER EVERY SCREEN RATHER THAN A FIX
     FOR ONE. `overflow-y: auto` alone makes the x axis `auto` too, so any descendant that pokes
     one pixel past the right edge turns the whole tab into a horizontal scroller — and on a phone
     that reads as the page being broken, because a finger dragging a list a few degrees off
     vertical starts walking the screen sideways. It has happened three times in three different
     screens (a 424px rail in a 402px pane, a 44px hit-overlay hanging 1px off a 30px arrow, a
     pushed view mid-transition 34px to the right). Each was fixed at the source; this is what
     makes the fourth one invisible instead of a bug report. Rails scroll INSIDE their own
     scrollport, which this does not touch. */
  overflow-x: hidden;
}

/* ══ THE STATUS BAR IS GLASS ═════════════════════════════════════════════════
 * SPEC (Syd, 2026-10-04, in the iPhone app): "currently the top strip behind the dynamic island is
 * just a solid white color, i want the elements to flow behind it like instagram".
 *
 * ⚠⚠ THE TOP INSET IS PADDING INSIDE EACH TAB'S SCROLLER, NOT A BAND ABOVE IT. It used to be
 *   `.screen`'s padding (ui.css), so every scroller began below the island and the 62px above it was
 *   `.screen`'s own flat --bg — content could never enter it. Inside the scroller the page looks the
 *   same at rest (the title still starts below the island) and the content scrolls up behind the
 *   clock. Where there is no inset — a desk browser — this is 0 and nothing moves.
 * ⚠ DIRECT CHILDREN ONLY. The event page's scroller (`.ev-page > .ev > .pane-scroll`) sits under a bar
 *   of its own (event.css) and a sheet's body starts well below the island; neither wants the inset.
 * ⚠⚠ position: sticky NEEDS NOTHING, AND `top: var(--safe-top)` WOULD BE WRONG. Both engines measure
 *   a sticky box's `top` from inside its scroller's padding, so the Cafe rail, the Shop tools and the
 *   News week keep `top: 0` and pin just under the island — measured in Chromium and in a macOS
 *   WKWebView, 2026-10-04. The inset written into them as well pinned them a whole inset lower.
 * ⚠ ANYTHING THAT MEASURES "THE TOP OF THE SCROLLER" MUST ADD ITS PADDING NOW. The Cafe's and Shop's
 *   section jumps and the Cafe's outgoing page did not, and landed under the rail or jumped 62px —
 *   cafe.js and proshop.js carry the fix and say so. */
.panes > .pane > .pane-scroll {
  padding-top: var(--safe-top);
  scroll-padding-top: var(--safe-top);
}

/* ⚠⚠ AND THE CLOCK SITS ON A STRIP OF THE TAB BAR'S OWN GLASS, OR IT SITS ON THE CONTENT. Text
 *   scrolling under the time and the battery is unreadable, so the strip behind them is frosted —
 *   iOS 26's own "soft" scroll-edge effect, drawn in CSS because the document is pinned and only the
 *   panes scroll, which leaves the system's edge effect (on the web view's own scroll view) nothing to
 *   act on.
 *   · ALWAYS ON, NOT SWITCHED BY THE SCROLL. Its tint is the page's own colour at 72%, so over the
 *     page at rest it is invisible and only reads once content passes under it. A strip that faded
 *     in from a scroll listener would be one more thing moving at the page's 60 fps over a 120 Hz
 *     scroll — the exact mismatch Syd reported the same day.
 *   · ::after IS THE EDGE: full glass behind the clock and the status icons, fading to nothing over
 *     the inset's last 14px, so there is no line where it ends (DESIGN.md §5, "scroll edge effects,
 *     not 1px dividers").
 *     ⚠⚠ THE FADE STARTS BELOW THE GLYPHS, NOT AT A FRACTION OF THE INSET. A masked backdrop is only
 *       frosted where the mask is whole; where it fades, the content shows through SHARP and merely
 *       tinted. It used to fade from 55% (34pt of 62) and the clock's digits reach ~39pt and the
 *       battery and signal ~38: a scrolled title ran crisp under the foot of "11:24" and through the
 *       no-service dots, in both themes (review, 2026-10-04, zoomed from the simulator). Now it is
 *       whole to 48pt of an iPhone 17 Pro's 62. `min(14px, 25%)` keeps a 20–24pt status bar (an SE, an
 *       iPad) whole through its glyphs too. ⚠ Not taller than the inset: at rest it would frost the
 *       top of every title.
 *   · ::before IS THE SAME GLASS WITHOUT THE FADE, shown only while a pinned bar (the Cafe rail, the
 *     Shop tools, the News week) is stuck under it. With the fade alone, content passing between the
 *     glass and the bar showed as a band of sharp text; with both, the strip and the bar read as one.
 *     stuck.js marks the bar.
 *   · z-index 55: over the panes' content (their pinned bars are 5), under the docks (58), the
 *     floating pills (59), the tab bar (60), the event page (65) and every sheet and scrim (70+),
 *     which bring their own top edge.
 *   · height is the inset itself, so on a desk browser both are 0px tall and draw nothing. */
.screen::before, .screen::after {
  content: ""; position: absolute; z-index: 55;
  top: 0; left: 0; right: 0; height: var(--safe-top);
  pointer-events: none;
  background: var(--chrome);
  -webkit-backdrop-filter: var(--chrome-blur); backdrop-filter: var(--chrome-blur);
}
.screen::after {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - min(14px, 25%)), transparent);
          mask-image: linear-gradient(to bottom, #000 calc(100% - min(14px, 25%)), transparent);
}
.screen::before {
  opacity: 0; visibility: hidden;
  transition: opacity var(--t-exit) var(--ease-out), visibility 0s linear var(--t-exit);
}
.screen:has(> .panes > .pane.on .is-stuck)::before {
  opacity: 1; visibility: visible;
  transition: opacity var(--t-menu) var(--ease-out), visibility 0s;
}
/* The 1px marker stuck.js watches, just above each pinned bar. Takes no room: the bars' parents are
   all block layout, so the negative margin cancels its height exactly. */
.stuck-probe { height: 1px; margin-bottom: -1px; visibility: hidden; pointer-events: none; }
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
  .screen::before, .screen::after {
    background: var(--bg); -webkit-backdrop-filter: none; backdrop-filter: none;
    -webkit-mask-image: none; mask-image: none;
  }
}
/* Reduced motion: nothing here moves. The pinned-bar layer only cross-fades, and opacity that helps
   the clock stay readable is kept under that setting (DESIGN.md §4.8), so it needs no block of its own. */

/* ── The floating nav ─────────────────────────────────────────────────────── */
/* ⚠ CENTRED WITH left/right + margin:auto, NOT `left:50%; translateX(-50%)`.
   The translate version looks equivalent and is not. An absolutely positioned box with only
   `left` set is shrink-to-fit, and its available width is the containing block MINUS that
   left offset — so `left:50%` gave the bar half a phone, 195px, to fit six buttons into. Flex
   items shrink by default, so it fit them: the icons were squeezed to 0px wide and the whole
   nav rendered as an empty pill with one orange dot in it. Nothing errored, nothing warned;
   it just looked like the icons had failed to load. Found by measuring an <svg> and getting
   width 0, height 23. Anchoring both edges gives the bar the full width to shrink-to-fit
   inside, and margin-inline:auto does the centring without owning `transform`.

   ⚠ THE BAR IS THE SAME WIDTH COLLAPSED AND EXPANDED, AND THAT ONE CHANGE FIXES FOUR BUGS.
   It used to be `width: fit-content` collapsed and edge-to-edge expanded. Because the seven
   buttons were then sized by their own content rather than shared evenly, four separate
   symptoms came out of it and every one of them was reported as its own defect:
     · icons were squeezed by DIFFERENT amounts — measured at 320px: 15.7, 17.3, 14.6, 22.6,
       23, 23, 18.9 px wide against a fixed 23px height, so the Play icon rendered 41% narrower
       than it was tall and visibly looked squashed;
     · the gaps came out uneven — 30.6px between the first three, 40.5px between the last four,
       which reads as a rendering fault rather than a layout;
     · the buttons measured 41px tall, under the 44px floor tokens.css calls "the floor, not a
       target";
     · and tapping a tab RE-SORTED the widths, so the icon slid sideways out from under the
       finger that was still on it, by up to two thirds of an icon width.
   Equal-width buttons in both states removes the cause of all four. Expanding now changes the
   bar's HEIGHT only, which is movement the finger is not resting on.

   ⚠ THE max-width IS FINDING 16, AND IT IS NOT COSMETIC. Without a cap, `left`/`right` alone
   made the bar 748px wide on a tablet — nearly double its phone width, with ~40px of dead air
   either side of every icon. notes/APP-SHAPE.md is explicit that this is "a floating pill
   above the content, not a bar welded to the bottom edge". Capped, a wider screen makes the
   pill taller when it expands and never wider. */
/* ⚠⚠ FIXED, NOT ABSOLUTE, AND `--safe-bottom` RATHER THAN `--bottom-inset`. Everything that
   floats at the foot of the screen — this bar, the live pill, the three docks, both sheets —
   is anchored to the VISIBLE bottom of the viewport now, which is the one edge every mobile
   browser agrees on. It used to be anchored to `.screen`, which is 100lvh tall so the app can
   paint under Safari's glass bar, and lifted clear of that bar by `100lvh − 100dvh`. That
   arithmetic assumes the whole of a browser's collapsible chrome is at the BOTTOM. Safari's is;
   Chrome and Firefox on iOS keep an address bar at the top AND a toolbar at the bottom, and
   Safari itself can be set to a top address bar — in all three the difference is real but not
   where the formula puts it, and the bar floated 40–50px too high with nothing under it.
   `position: fixed` + `env(safe-area-inset-bottom)` is what native-feeling web apps use for
   the same reason: the engine puts the element at the visible edge in every toolbar state, and
   env() reports the home indicator only when the toolbar is not already covering it.
   ⚠ `--bottom-inset` survives for the SCROLLERS' padding (`--fnav-clear`), where over-padding
     by a top bar's height costs nothing and under-padding hides the last card.
   ⚠ THE DESK PREVIEW STILL WORKS because ui.css gives `.phone` `contain: paint`, which makes
     the mock the containing block for fixed descendants. The phone breakpoint sets it back to
     `none`, so on a real device fixed means the viewport.
   ⚠ THE SAFE-AREA SIDES ARE FOLDED IN for a phone held sideways, where the notch takes 59px of
     the left or right edge. */
.fnav {
  position: fixed; z-index: 60;
  /* ⚠⚠ ONE PROPERTY MOVES WHEN IT MINIMISES: `scale`, about the centre. It was `max-width` and
     `padding` on 220ms of strong ease-out, which reflowed the bar under its own backdrop blur every
     frame and, growing back, reached full width in two frames while the height took ten. The tap
     targets are kept at 44px by the overlay in `.fnav.is-mini` — see there.
     ⚠⚠ nav.js WRITES THAT `scale`, INLINE, IN STEP WITH THE SCROLL (2026-09-15) — so it is not
       declared here and `transition` is `none`, explicitly: a transition on it would restart on every
       frame's write and the bar would crawl a curve behind the finger; and left at the initial
       `transition-property: all`, tokens.css's reduced-motion `160ms !important` would do the same
       where nobody looks. scripts/checks/riders.js holds both lines.
     ⚠ `scale`, NOT `transform`: pressable() writes `transform` on the buttons inside, and one
       property per job is what keeps a press from ever fighting the bar's own size. */
  transform-origin: 50% 50%;
  transition: none;
  left: calc(var(--fnav-inset) + var(--safe-left)); right: calc(var(--fnav-inset) + var(--safe-right));
  bottom: calc(14px + var(--safe-bottom));
  width: auto; max-width: var(--fnav-max); margin-inline: auto;
  /* ⚠ gap 0, NOT 1px. Every button already carries 2px of its own horizontal padding, so the
     gap was buying separation that was already there — and with an eighth item in the bar it
     was costing 7px of label width to do it. */
  display: flex; align-items: stretch; gap: 0;
  padding: 7px 5px;
  /* ⚠ THE BAR'S RIGHT-HAND END IS THREE NUMBERS, DECLARED ONCE. The avatar disc, the initials
     inside it and the code button hovering above the bar all have to shrink together when the
     bar minimises, and each state below re-declares only what changes. Nothing outside this
     file reads them. */
  --av-d: 24px;   /* the avatar disc */
  --av-t: 9px;    /* the initials drawn inside it — see .fnav-av */
  /* ⚠ `--qr-d` IS DECLARED ON `:root`, NOT HERE. The commit pill on the Courts screen has to
     leave room for the code button, and it cannot read a variable scoped to the tab bar. */
  border-radius: var(--r-full);
  background: var(--chrome);
  backdrop-filter: var(--chrome-blur) ; -webkit-backdrop-filter: var(--chrome-blur);
  border: 1px solid var(--chrome-edge);
  box-shadow: var(--shadow-3);
}

/* ⚠ THE LABEL SITS UNDER THE ICON, NOT BESIDE IT, AND THAT IS ARITHMETIC RATHER THAN TASTE.
   Six labels beside six icons need about 225px of text on top of 339px of icons, padding and
   gaps: 564px of bar inside a 390px phone. Laid out that way the bar hit its max-width and
   flex took the missing 200px out of the only elastic thing in it — the labels — so every
   one of them computed to between 0 and 10px wide. Tapping a tab appeared to do nothing.
   Measured: labels [0, 10, 0, 7, 7, 2]. Stacked, the widest button is "Proshop" at about
   66px and the whole bar lands near 355px, inside the 366px it is given.
   The column direction is set ALWAYS, not only when expanded, so the bar never changes flex
   direction mid-transition — collapsed, the label contributes zero height and zero width and
   the button is exactly the icon it was before. */
/* ⚠ `flex: 1 1 0` IN BOTH STATES, NOT ONLY EXPANDED. See the block comment on .fnav: seven
   buttons sharing the width equally is what pins the icons to one size, evens the gaps, and
   stops the pressed icon sliding sideways when the bar opens. min-width:0 is required with
   it — a flex item's automatic minimum is its min-content width, and the label inside would
   otherwise hold each button open at a different width all over again.
   ⚠ min-height IS THE 44px FLOOR. The old padding arithmetic (9 + 23 + 9) came to 41px, and
   41px is a tab bar you miss with a thumb on a phone held one-handed at a venue — which
   tokens.css already says is every use of this app. */
.fnav-btn {
  position: relative; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 0;
  flex: 1 1 0; min-width: 0; min-height: var(--tap-min);
  border: 0; background: transparent; cursor: pointer;
  padding: 9px 2px; border-radius: var(--r-full);
  color: var(--ink-tertiary);
  touch-action: manipulation; -webkit-tap-highlight-color: transparent;
  /* ⚠ NO min-height OR padding IN HERE ANY MORE: minimising no longer resizes the buttons, it
     scales the whole bar (2026-09-14). The corner radius rides the shared curve so the selected
     pill reshapes in step with the labels opening.
     ⚠ `transform` IS IN HERE, AND IT WAS NOT. pressable() writes an inline scale on pointer-down and
       clears it on release; with nothing to transition it, every tab tap snapped the icon to 0.9 and
       back in one frame each way — a twitch on the control the whole app is driven from. */
  transition: color 160ms var(--ease-out), background-color 160ms var(--ease-out),
              border-radius var(--float-dur) var(--float-ease),
              transform 160ms var(--ease-out);
}

/* ⚠ THE SELECTED TAB IS A FILLED PILL, NOT AN ORANGE GLYPH, AND THAT IS A LEGIBILITY FIX.
   It used to be `color: var(--action)` and nothing else. In light mode that put orange on
   pale cream chrome — which measured FAINTER than the plain grey of the tabs beside it, so
   the one tab you are actually on was the hardest thing in the bar to read. Worse, the bar
   spends most of a session collapsed, and collapsed there is no label, so hue was the only
   signal of where you were: invisible in greyscale, to a colourblind player, and in direct
   sun on a court. DESIGN.md §2 — "every state has a colour, a word, and where it is an icon
   a glyph" — and a colour that is only a colour fails all three readings.
   Filling the pill with --ink and inverting the glyph to --bg gives the state a SHAPE. It
   survives a greyscale screenshot, it is the highest contrast pair the theme owns in either
   direction, and it deliberately is not orange: DESIGN.md reserves orange for the one thing
   on a screen you are meant to press, and "where you already are" is not that thing. */
.fnav-btn[aria-current="page"] {
  background: var(--ink);
  color: var(--bg);
}

/* ⚠ NO BIG CENTRE BUTTON. Syd, 2026-09-02: open play and private courts are "two different
   normal sized buttons", and no tab is emphasised over the others. The .is-big rule that used
   to live here is gone rather than left unused — a styling hook nobody applies is one a later
   reader applies by accident. */

/* ⚠ THE ICON NEVER SHRINKS; THE LABEL ABSORBS EVERYTHING.
   The bar is allowed to be squeezed — that is how the expanded labels truncate instead of
   pushing the bar off the phone. But when it is squeezed, flex must take the space out of the
   text, never out of the glyph. Without flex:none here the icons were the first thing to go,
   and a tab bar with no icons still looks like a tab bar, so nothing about the layout said
   it had failed. */
/* ⚠ THE BOX IS PINNED TO 23px AND max-width IS EXPLICITLY DISARMED HERE.
   `flex: none` was already on both of these and the icons were squeezed anyway, because
   neither of them is a flex item on the axis that was doing the squeezing: .fnav-ico is a
   grid, and the crushing came from ui.css's blanket `img, svg, video { max-width: 100% }`.
   That rule outranks nothing — it just wins by being unopposed — so when a button came out
   narrow the svg quietly followed it down to 15.7px wide while its height stayed 23px. A
   squashed icon and a correct one look alike until you measure them, which is why this sat
   in the app for weeks. Fixing the button widths removes the cause; disarming max-width here
   means the next narrow container cannot bring it back silently. */
.fnav-ico { display: grid; place-items: center; flex: none; width: 23px; height: 23px; }
.fnav-ico svg { width: 23px; height: 23px; max-width: none; display: block; flex: none; }

/* ⚠ A 0fr GRID ROW THAT BECOMES 1fr. `height: auto` cannot be transitioned and a max-height
   guess is wrong at some font size or in some language — this animates to the text's real
   height, whatever it turns out to be. The inner span needs min-height:0 and overflow:hidden
   or the row refuses to collapse.
   ⚠⚠ THIS WAS REWRITTEN TO max-height ON 2026-09-04 AND THE REWRITE WAS WRONG. Chasing Syd's
     "the tab titles get cut off" I measured `grid-template-rows` computing to 0px with
     `is-expanded` on the element, at 1s, 2s, 3s and 4s — and concluded the 0fr → 1fr
     transition never advanced. It does. The measurement was taken in a browser pane that was
     not being displayed, where the whole animation clock is frozen: every CSSTransition on
     the element sat at `currentTime: 0` indefinitely, and calling `.finish()` jumped it
     straight to the target. Turning the transition off "fixed" it only because there was then
     no animation left to be frozen.
     The real defect was the line-height below, which is provable without running anything.
     Recorded because the failure mode is subtle and I nearly shipped a rewrite of a working
     mechanism on the strength of it: an animation measured in a hidden tab is not measured. */
.fnav-lab {
  display: grid; grid-template-columns: 1fr; grid-template-rows: 0fr;
  /* ⚠ THE ROW ON THE SHARED CURVE, THE WORDS ON THEIR OWN CLOCK. Closing, the words fade out in
     120ms, before their row has closed around them. Opening, `.fnav.is-expanded .fnav-lab` holds them
     back 170ms, until the row is most of the way open.
     ⚠ 170, NOT THE 90 FIRST WRITTEN: at 90ms a label's row was 16% open, and by the time the words
       were 70% opaque "Shop" still had the lower third of its letters cut off (review, 2026-09-14,
       measured). */
  transition: grid-template-rows var(--float-dur) var(--float-ease),
              margin-top var(--float-dur) var(--float-ease),
              opacity 120ms var(--ease-out);
  opacity: 0; margin-top: 0;
  /* ⚠⚠ line-height 1.35, AND IT WAS 1 — THIS is what cut the tab titles off. `font: 600 11px/1`
     makes the line box exactly the font size, and Inter's ascenders and descenders do not fit
     inside their own em; the span carries `overflow: hidden` (it must, for the collapse and
     the ellipsis), so the top of the T and the tail of the y in "Play" were sliced. On the
     selected tab the inverted fill draws a hard edge right through the letters, which is why
     that is the one Syd noticed. Measured first: every label's scrollWidth equalled its
     clientWidth, so nothing was truncated horizontally — the clipping was vertical.
     ⚠ UNITLESS, so the 10px override in the narrow query below scales with it rather than
       inheriting a fixed pixel value and clipping again at the smaller size. */
  font: 600 11px/1.35 var(--font-ui); letter-spacing: -.004em;
}
/* Both axes must be allowed to collapse. min-height:0 is the one people forget: without it
   the row track never reaches 0 and every button carries a permanent line of empty space. */
.fnav-lab > span { min-width: 0; min-height: 0; overflow: hidden; white-space: nowrap; }

.fnav.is-expanded .fnav-lab {
  grid-template-rows: 1fr; opacity: 1; margin-top: 6px;
  transition-duration: var(--float-dur), var(--float-dur), 150ms;
  transition-delay: 0ms, 0ms, 170ms;
}

/* ⚠⚠ THE SELECTED PILL STOPS BEING A STADIUM ONCE IT HOLDS TWO THINGS. Syd, 2026-09-04, on a
   screenshot of the Courts tab: "this looks super cramped … make it look not too tight."
   Measured before touching it: expanded, the button is 48.1 x 53.5, so `--r-full` clamps to a
   24px radius — the top and bottom are semicircular caps and THE WHOLE LABEL SITS INSIDE THE
   BOTTOM ONE. At the label's centre line the pill had narrowed to 43.5px for a 32.4px word,
   and it kept closing below that. The word was not near the edge; the edge was curving through
   the word.
   ⚠ 14px IS CHOSEN FROM THAT ARITHMETIC, NOT PICKED. It puts the corner's centre at 39.5px
     from the top against a label centred at 41.8 — so the label sits on the pill's STRAIGHT
     sides at very nearly its full 48px width, and the curve happens below the text instead of
     through it.
   ⚠ COLLAPSED IT STAYS `--r-full`, WHICH IS A CIRCLE ON A 48 x 44 BOX AND IS NOT WHAT WAS
     COMPLAINED ABOUT. The shape adapts to what it contains — a capsule around one glyph, a
     rounded rectangle around a stack — which is the behaviour, not an inconsistency. The
     radius is in the transition above so it morphs with the bar rather than snapping. */
.fnav.is-expanded .fnav-btn { border-radius: 14px; }

/* ══ MINIMISED · the whole bar scales while you read ═══════════════════════════
 * SPEC (Syd, 2026-09-03): "the main nav at the bottom for all pages, doesnt get smaller in the same
 * way instagram does, i want it to be identical." And 2026-09-14: "the NAV is TOO REACTIVE or to
 * twitchy", with a recording of Instagram to match. And 2026-09-15: "gradually minimizes as a i scroll
 * (like its proportional)". Scrolling down takes the bar toward this size in step with the scroll;
 * scrolling up, or reaching the top, brings it back the same way. nav.js writes the scale; nothing here
 * declares it.
 *
 * ⚠⚠ ONE UNIFORM SCALE, AND IT REPLACES A WIDTH, A PADDING, A BUTTON HEIGHT AND AN ICON SCALE.
 *   Those four resized the bar into the shape of a different object — 22% narrower, 26% shorter,
 *   the glyphs 19% smaller — each on its own clock and each a layout pass. The recording shows
 *   Instagram's pill scaling as one thing, to 0.833, and a bar that keeps its own proportions while
 *   it shrinks is what reads as the same object stepping back rather than a layout rearranging
 *   itself. Everything inside — icons, the avatar, the code button riding above — scales with it
 *   for free, so their own minimised rules are gone.
 * ⚠⚠ THE 44px TAP FLOOR IS KEPT BY THE OVERLAY, NOT BY THE PAINT. An earlier version of this note
 *   refused `transform: scale()` because it shrinks the hit areas with the pixels. It does — so the
 *   overlay below is drawn 44 ÷ 0.833 tall in the button's own scaled space, which lands at 44px on
 *   the glass. The painted button is 36.7px tall, about what the old minimised bar's 36–38 was.
 * ⚠ `--fnav-clear` IS STILL NOT TOUCHED. It is the room every scrollport leaves at its bottom; if it
 *   tracked the bar it would resize the scrolling box mid-scroll and jump the content under the
 *   finger. The bar shrinks; the room kept for it does not.
 * ⚠ `is-mini` NOW MEANS "COMMITTED TO MINIMISED", NOT "DRAWN MINIMISED" (2026-09-15). nav.js switches
 *   it with hysteresis on the drawn scale — on past 60% of the way, off below 40%, and at once toward
 *   a settle's end — so the overlay is right at every resting state and switches once per scroll, not
 *   per frame (an overlay sized per frame would lay out eight pseudo-elements inside a blurred bar on
 *   every one). Mid-scrub between the two, a tab's target on the glass is 39.6–49.2px at the .833
 *   scale (measured at 375, 402 and 1280, 2026-09-19: 39.6 just before the overlay comes on at 0.6,
 *   49.2 just before it goes at 0.4) and 41.4–46.9px at 320's .9 (the 41.4 measured, the 46.9 the
 *   same arithmetic: 44 ÷ .9 × the scale at 0.4); at rest it is 44 at both ends.
 *   Nobody is tapping a bar a finger is scrolling. */
.fnav.is-mini .fnav-btn::after {
  content: ""; position: absolute; left: 0; right: 0; top: 50%;
  transform: translateY(-50%); height: calc(var(--tap-min) / var(--fnav-mini-scale));
}

/* ⚠ NOTHING ABOUT THE BAR'S WIDTH CHANGES ON EXPAND ANY MORE — see the .fnav comment. The
   geometry rules that used to live here (`left`, `right`, `width`, `margin-inline`, a second
   `flex: 1 1 0`) are gone because the collapsed bar now has all of them already. They are not
   left in place as harmless duplicates: a second copy of a value is the thing that gets
   changed on its own and silently disagrees with the first.

   ⚠ THE LABEL CLIPS WITH AN ELLIPSIS; IT DOES NOT SPILL. This was `overflow: visible`, on
   the reasoning that a label should never be half a word. It bought that at a price nobody
   priced: on a narrow handset the column is thinner than "Players", and a visible overflow
   does not warn, it PAINTS OVER THE NEXT TAB. "Open play" ran straight through "Courts" that
   way once already. An ellipsis is not half a word either, and it is the only version of
   this that fails where you can see it. */
.fnav.is-expanded .fnav-lab > span {
  overflow: hidden; text-overflow: ellipsis; display: block; text-align: center;
}

/* ⚠ THE LABEL DROPS A PIXEL ON A NARROW HANDSET, AND THE ALTERNATIVE IS WORSE.
   Seven equal columns inside a 360px phone leave about 37px of text each; the longest label at
   11px semibold is 39px, so it truncated. A truncated tab label is a wayfinding failure — it is
   the word that tells a colourblind player, and anyone in sunlight, which tab they are looking
   at. 10px buys the ~9% needed for every label to survive whole. Measured, not guessed: at
   360px the widest label then comes to 35.45px inside 42px of column.
   The bar tracks the window here rather than a container, which is correct for this element
   and only this element — in the shipped app the window IS the frame, and in the preview the
   phone frame only stops being 390px once the window itself is narrower than that.
   ⚠ THE LONGEST LABEL CHANGED ON 2026-09-03 AND THIS QUERY WAS RE-MEASURED, NOT ASSUMED.
   History left the bar for the profile and Players took the slot. Seven characters either way,
   so nothing here looked like it needed checking — but "Players" sets wider than both "History"
   and "Tickets", which makes it the word this breakpoint now exists for. Re-measured after the
   swap: 320px → 35.45 of text in 36.29 of column, 0.84 spare; 360px → 6.55 spare; 375px → 8.70
   spare; 390px, above the query at 11px → 38.99 in 46.29, 7.30 spare. Nothing clips.
   ⚠ Below 314px "Players" loses its last letter to an ellipsis — at 313px it is 0.16px over.
   That is the floor being honest rather than a bug being hidden: it fails visibly, in the one
   direction that cannot paint over the tab next door, and no shipping handset is that narrow.
   If a phone that narrow ever matters, the fix is a shorter word, not a smaller type size —
   10px is already the bottom.
   ⚠⚠ THE MEASUREMENTS ABOVE ARE FOR SEVEN COLUMNS AND ARE NO LONGER THE BAR (re-measured 2026-10-05,
   headless Chrome, Inter loaded). The avatar became an eighth column, and since every tab came back
   on (launch.js) a real account has all eight: at 320 the bar is 304 wide, 4px padding, so 36.75 a
   column and 32.75 of label inside the button's 2px sides. "Tickets" sets 35.11 and "People" 32.92,
   so both clipped ("Tick…", and "People" by 0.17). So at 340px and under the button drops its 2px
   sides and the label has the whole column — declared after the phone block, whose `.fnav-btn`
   padding would otherwise win (see the end of it). The type size is untouched — 10px is the bottom. */
@media (max-width: 380px) {
  .fnav-lab { font-size: 10px; }
  /* The shorter opened bar this gives is declared after the phone block — see the end of it. */
}

/* ⚠ EXCLUDE THE CURRENT TAB. This rule and the aria-current rule have identical specificity
   (0,2,0), so whichever is written second wins — and this one is second. Without the :not()
   a mouse resting anywhere on the bar erased the selected pill, which is the one moment the
   person is most likely to be looking for it. Touch devices never saw it; a desk review did. */
@media (hover: hover) and (pointer: fine) {
  .fnav-btn:not([aria-current="page"]):hover { background: var(--bg-sunken); color: var(--ink); }
}

@media (prefers-reduced-motion: reduce) {
  /* Names its own animations: the label's row/margin/opacity open, and the colour and
     background fade on .fnav-btn. */
  /* ⚠⚠ A DURATION HERE DOES NOTHING: tokens.css forces every transition-duration to 160ms under reduced
     motion, with !important. What CAN be chosen is which properties move. So the label rows snap —
     with the band, which snaps too (.livepill and .commitdock are opacity-only below) — and only the
     words fade. Written as a duration, as it first was, the rows went on growing the bar over 160ms
     under a band that had already jumped (review, 2026-09-14). The `.is-expanded` selector is repeated
     because its own delay list outranks the plain one. */
  .fnav-lab, .fnav.is-expanded .fnav-lab { transition-property: opacity; transition-delay: 0ms; }
  .fnav-btn { transition-property: color, background-color, transform; }   /* the press stays; it is feedback */
  /* The bar still changes size — it has to, or it sits over the content — but without moving. */
  .fnav { transition: none; }
}
@media (prefers-reduced-transparency: reduce) {
  .fnav { background: var(--bg-raised); backdrop-filter: none; -webkit-backdrop-filter: none; }
}

/* ── Shared screen furniture ──────────────────────────────────────────────── */
.screen-head { padding: 6px var(--gutter) 12px; }

/* ⚠ THE CORNER IS RESERVED HERE, ONCE, FOR EVERY TAB. header.js mounts the avatar into
 *   `.screen` at z-index 55, absolutely positioned over the top-right — so without this the
 *   title of a screen simply runs underneath it. It belongs in shell.css and not in the
 *   profile's stylesheet, because the thing being reserved for is the SHELL's furniture and
 *   six of the seven screens have never heard of it.
 * ⚠ 92px IS SIZED FOR THE WIDER OF THE CORNER'S TWO STATES, AND THE ONE YOU NEVER SEE.
 *   Signed in it is a 44px circle — 44 + 12 offset + 20 so a descender never touches it = 76.
 *   Signed OUT it is a "Sign in" pill about 78px wide. 76 looks right for the whole of
 *   development, because you are signed in the whole time, and clips the title the first time
 *   a stranger opens the app.
 * ⚠ SCOPED TO `.pane`, so it cannot reach a `.screen-head` inside a sheet. A sheet's panel
 *   sits ABOVE the avatar (z-index 71 to 55) and covers it, so reserving space in there is a
 *   squeezed subtitle for a button that is not on screen. */
/* ⚠ 76px, DOWN FROM 124, BECAUSE THE CORNER IS ONE CONTROL AGAIN. The profile button moved
   into the tab bar on 2026-09-04 and only the player code is left up here: a 40px disc + the
   12px offset + 20px so a descender never touches it + a little slack. The reserve is not
   decoration — `.codebtn` is absolutely positioned, so without it a long title simply runs
   underneath the button.

   ⚠⚠ IT IS THE SHOP'S ALONE NOW, AND THE POLARITY OF THIS PAIR OF RULES HAS FLIPPED. It used
   to be `.pane .screen-head { padding-right: 76px }` for every screen, with the Shop exempted
   back down to 20px because its cart already sat there. On 2026-09-04 the player-code button
   left this corner for the tab bar (see `.qrbtn`), which leaves the cart as the only thing in
   any screen's top-right — so every OTHER screen was reserving 56px of dead air beside nothing,
   and their titles were wrapping early for a button that is not there.
   ⚠ MEASURED AGAINST THE LONGEST TITLE THE APP HAS, "Open play" at 153px: 375 − 20 − 76 leaves
   279px, so nothing wraps on the narrowest phone this ships to even where the reserve applies.
   Putting a second control back in this corner is a layout change, not an addition — measure
   the titles again first.
   ⚠ `.pane.shop`, NOT `.pane .shop`. proshop.js's render() does `root.classList.add("shop")`
   and `root` IS the pane — one element wearing both class names, not one inside the other.
   Written as a descendant this rule matches nothing and fails silently. Both spellings are
   here so it survives the day the shop is wrapped in something. */
/* (no rule: the corner is empty on every screen — see the `.codebtn` note below) */

/* ══ THE PROFILE BUTTON, IN THE TAB BAR ═════════════════════════════════════
   Syd, 2026-09-04: "just make the profile button with profile picture to the nav (the same way
   instagram does)". Last in the bar, after Cafe — Instagram's position.

   ⚠⚠ `flex: none` AND NO LABEL, WHICH IS WHAT PAYS FOR THE ROOM. The seven real tabs share the
   bar with `flex: 1 1 0`; an eighth equal column would have taken every one of them from 48px
   to 42 — TIGHTER THAN THE CRAMPING SYD HAD JUST ASKED ME TO FIX, and the two requests would
   have cancelled out. An avatar needs no word under it, so this one is a fixed 38px and the
   seven keep the rest. Measured after: 44.7px per tab against 48.1 before.
   ⚠ IT IS NOT A TAB AND IT NEVER TAKES THE SELECTED FILL. It opens a sheet over whatever you
   were looking at; `aria-current` stays off it, and the filled pill that means "the screen you
   are on" would be a lie on a control that does not change screens. */
/* ⚠ 34px, AND EVERY PIXEL HERE IS A PIXEL THE SEVEN TABS DO NOT GET. An avatar needs no word
   under it, so this is as narrow as a 26px circle and its press target allow. */
/* ⚠⚠ IT IS AN ORDINARY COLUMN NOW — NO WIDTH OF ITS OWN, NO PADDING OF ITS OWN. It was pinned
     at 34px on the reasoning that "an avatar needs no word under it, so this is as narrow as a
     26px circle and its press target allow". It does need a word: Syd, 2026-09-04, "there is no
     nav title for the profile picture it should say profile". And the narrow column was visibly
     wrong on its own terms — measured at 402px, the News icon sat 18.9px in from the bar's left
     edge while the avatar sat 9px from its right, so the bar looked like it had been cut off on
     one side. Syd again: "the profile picture is too close to the right end of the nav bar, if
     you see the news one its good."
     Sharing `flex: 1 1 0` with the other seven fixes both at once: eight equal columns of
     ~47px, the avatar centred in its own like every glyph is, and a label that collapses and
     ellipsises through exactly the same rules.
   ⚠ THE DISC CAME DOWN 26 → 24 TO FINISH THE JOB. A filled circle reads heavier than a 21px
     outlined glyph, so matching their BOX sizes does not match their weight; 24px puts the
     avatar's clearance at 11.5px against the icons' 13, which is within a pixel and a half of
     "the news one". */
.fnav-me { padding-inline: 0; }
/* ⚠ THE DISC NEEDS A RING ONCE THE PILL BEHIND IT IS FILLED. Selected, the button's background
   becomes `--ink` — and the avatar's own background is `--navy-70`, which against navy is two
   dark circles with no edge between them. A 2px `--bg` ring is the same trick `.qrbtn` uses to
   hold itself off whatever it crosses, and it is what makes the photo read as a photo rather
   than a hole in the pill. */
.fnav-me[aria-current="page"] .fnav-av { box-shadow: 0 0 0 2px var(--bg); }
/* ⚠⚠ THE TYPE SCALES WITH THE CIRCLE, AND IT DID NOT UNTIL 2026-09-04. `.avatar` in ui.css is
     written for a 40px disc — `font: 600 15px/1` — and this rule overrode the WIDTH and HEIGHT
     to 26px while leaving the 15px behind. Measured on the deployed build at 402px: "ST" set
     19.27px wide inside a 26px circle, so the letters covered 74% of the disc against the 48%
     the 40px version was drawn for. Syd, 2026-09-04: "the profile picture looks cropped, also
     scale down the letters independently (it should look smaller in the circle)" — the letters
     were not merely large, they were running into the rim, which is what reads as a crop.
   ⚠ 10px AT 26px IS THE 0.375 font-to-diameter RATIO `.avatar` USES AT 40px, CARRIED ACROSS.
     Mini relaxes it to 9px on a 22px disc (0.409) rather than the strict 8.25px: small type
     needs proportionally MORE size to stay legible, not less — apple-design §15 — and two
     capitals at 8px on a moving bar are a smudge.
   ⚠ `.fnav .fnav-av`, NOT `.fnav-av`. Bare, it ties (0,1,0) with `.avatar` and wins only on
     load order — which is true today and is exactly the kind of fact that stops being true
     when somebody reorders two <link> tags. */
.fnav .fnav-av {
  width: var(--av-d); height: var(--av-d); flex: none;
  font-size: var(--av-t);
  box-shadow: 0 0 0 1.5px var(--chrome-edge);
}
.fnav-me .avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* ⚠ SIGNED OUT IT IS A WORD, NOT AN EMPTY CIRCLE. A grey disc with nothing in it reads as a
   photo that failed to load, so a signed-out player taps it expecting themselves. */
.fnav-me-word {
  font: 600 10px/1.35 var(--font-ui); letter-spacing: -.004em; color: var(--ink);
  white-space: nowrap;
}
/* Signed out the column holds a word instead of a disc, and "Sign in" is wider than "Profile".
   It keeps a floor so the two capitals never ellipsise, but it no longer OVERRIDES the shared
   column — `flex-basis` alone would take the space back off the seven tabs for a state most
   players never see. */
.fnav-me.is-out { min-width: 46px; }

/* ══ THE COMMIT DOCK ═══════════════════════════════════════════════════════
 * SPEC (Syd, 2026-09-04): the private booking's floating cart, then "do the same thing for
 * the proshop and the cafe cart", then "it needs to synch in size with the nav bar".
 *
 * ⚠⚠ THREE SCREENS WANTED THE SAME OBJECT, SO IT IS ONE OBJECT. The Courts screen's commit
 *   pill, the Cafe's cart bar and the Pro shop's cart are the same thing: a filled pill that
 *   hovers above the tab bar, says what is in the basket and what it costs, and takes you to
 *   pay. They were about to be three copies of the same eight numbers — the offset above the
 *   bar, the room left for the code button, the room left for the live pill, the fill, the
 *   radius, the shadow, the size in each nav state. The Cafe's version had already drifted:
 *   it floated ABOVE the live pill rather than beside it, and it did not clear the code button
 *   at all. Everything shared lives here; each screen builds only its own contents.
 *
 * ⚠ THE DOCK POSITIONS, THE PILL IS SKIN. `.commitdock` is the band and takes no taps;
 *   `.commitpill` is the button inside it. Splitting them is what lets a screen put a note
 *   above its pill (the Courts screen's hours limit) without that note becoming part of the
 *   button.
 *
 * ⚠ IT SHARES ITS ROW WITH TWO THINGS AND LEAVES ROOM FOR BOTH. On the right, the player-code
 *   button hovers over the end of the tab bar. On the left, the live-session pill collapses to
 *   its glyph and slides out of the way — see the dodge above, which watches for a non-empty
 *   dock rather than being told about one.
 */
.commitdock {
  position: fixed; z-index: 58;
  /* ⚠⚠ THE DOCK IS THE SAME BOX AS THE TAB BAR — same insets, same max-width, same centring —
     AND THAT IS THE ONLY VERSION OF THIS THAT SURVIVES THE MINI STATE. It started as the
     screen's own insets with a fixed right reservation for the code button, and that is wrong
     the moment the bar narrows: minimised the bar goes 386 → 300 and re-centres, so the code
     button rides 37px LEFT with it while a screen-anchored dock stays where it was. Measured
     before this change: the pill overlapped the button by 30px in mini and cleared it by 7 in
     collapsed, from one unchanged rule. Matching the bar's box means the button's inset is a
     constant inside it, in every state, at both breakpoints. */
  left: calc(var(--fnav-inset) + var(--safe-left)); right: calc(var(--fnav-inset) + var(--safe-right));
  max-width: var(--fnav-max); margin-inline: auto;
  bottom: var(--band-bottom);
  /* The code button's own inset inside the bar, plus the button, plus a gap. */
  padding-right: calc(13px + var(--qr-d));
  display: grid; justify-items: center; gap: 6px;
  /* ⚠ THE BAND TAKES NO TAPS; only the pill inside it does. It spans the screen so a note can
     centre over the pill, and an invisible full-width box swallowing touches over the content
     is a screen that stops responding for no visible reason. */
  pointer-events: none;
  /* ⚠⚠ `translate` AND `scale`, NOT `bottom` AND `max-width` (2026-09-14). It follows the bar with
     the same two properties: the band's shift up or down, and the bar's own scale about the dock's
     bottom edge. Same box, same centre, same factor, so the code button's clearance scales with it and
     stays true in every state. `bottom` on its own 220ms clock ran out of step with the bar, and
     `max-width` reflowed the pill inside every frame.
     ⚠⚠ nav.js WRITES BOTH, INLINE, EVERY FRAME THE BAR MOVES (2026-09-15), so neither is declared here
       and `transition` is explicitly `none` — see `.fnav`. `.cafe .cartbar` outranks this rule with
       its own transition list, and that list must not name them either; riders.js checks both.
     ⚠ `will-change` IS PERMANENT, because promoting the layer when a scrub starts costs a hitch on
       its first frame. It makes the dock a containing block for fixed descendants; it has none. */
  transform-origin: 50% 100%;
  transition: none;
  will-change: translate, scale;
}

/* ⚠ ROOM FOR THE LIVE PILL'S GLYPH, IN EVERY STATE. It parks at the BAR's left edge, which is
   this dock's left edge too, so the reservation is the same whatever the bar is doing. It was
   briefly conditional — no reservation while minimised, on the reasoning that the bar had
   already narrowed past a pill parked at the SCREEN's edge. That reasoning went when the pill
   started parking against the bar instead, which is what stopped the three of them looking
   like two aligned objects and one stray. */
.screen.has-livepill .commitdock { padding-left: calc(var(--lp-mini-w) + 8px); }

.commitpill {
  pointer-events: auto;
  display: flex; align-items: center; gap: 11px;
  /* ⚠⚠ min-width: 0, AND WITHOUT IT `max-width: 100%` DOES NOTHING. As a grid item this pill
     gets an automatic minimum equal to its MIN-CONTENT width, and a minimum beats a maximum —
     so a long line held it open past its container and it ran under the code button. The
     container was right the whole time; the item was refusing to be constrained by it. The
     same automatic minimum made the pro shop's section rail scroll the page sideways, one
     layout mode over. */
  /* ⚠⚠ NO `width: fit-content` HERE, AND ITS ABSENCE IS THE FIX. The dock's `justify-items:
     center` already sizes a grid item to fit-content; asking for it EXPLICITLY as well is what
     WebKit sizes differently — on iOS the pill ignored `max-width` entirely and ran past the
     player-code button off the right of the screen, while Chromium capped it correctly at the
     dock's 287px. Measured both: same markup, same rules, 287 in one engine and 349 in the
     other. Leave the width to the alignment and both agree.
     ⚠ min-width: 0 STAYS. A grid item's automatic minimum is its MIN-CONTENT width and a
     minimum beats a maximum, so without it a long line holds the pill open past its container
     in every engine. The same automatic minimum made the pro shop's section rail scroll the
     page sideways, one layout mode over. */
  max-width: 100%; min-width: 0;
  min-height: var(--tap-min); padding: 8px 12px 8px 10px;
  border: 0; border-radius: var(--r-full);
  background: var(--ink); color: var(--bg);
  box-shadow: var(--shadow-3);
  cursor: pointer; text-align: left;
  touch-action: manipulation; -webkit-tap-highlight-color: transparent;
  /* Only the press. Its size follows the dock's scale now, so nothing in the pill itself resizes. */
  transition: transform 160ms var(--ease-out);
}
.commitpill:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }

/* ⚠⚠ IT SHRINKS WITH THE BAR. Syd: "it needs to synch in size with the nav bar." Since 2026-09-14 it
   does that by the dock's scale — the bar's own factor, on the bar's own curve — rather than by a
   second set of smaller paddings, heights and type sizes that snapped in on every scroll direction
   change. A pill that keeps its proportions reads as the same pill; one whose type and padding
   change mid-scroll reads as a different control arriving.
   ⚠ THE TAP TARGET GOES WITH IT, and that is allowed here where it is not on the tab bar: this
   pill is 250-plus pixels wide, so a 36.7px height is still an enormous target. The 44px floor
   protects a 36px glyph in a row of eight, not a full-width bar. */

/* The glyph disc every one of them opens with — a low-alpha disc of the page colour, read
   through the inverted palette. */
.commitpill-ico {
  flex: none; display: grid; place-items: center;
  width: 30px; height: 30px; border-radius: 50%;
  background: color-mix(in srgb, var(--bg) 16%, transparent);
}
.commitpill-ico svg { display: block; width: 19px; height: 19px; max-width: none; }
.commitpill-body { flex: 1 1 auto; min-width: 0; }
.commitpill-t {
  font: 600 14px/1.25 var(--font-ui); letter-spacing: -.008em; color: var(--bg);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ⚠ THE SECOND LINE IS NOT DIMMED WITH `opacity`, which would also soften the edges of every
   glyph. A colour mix keeps the type crisp and lands where `--ink-tertiary` lands on the
   light side. */
.commitpill-s {
  margin-top: 1px;
  font: 400 11.5px/1.3 var(--font-ui);
  color: color-mix(in srgb, var(--bg) 68%, transparent);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.commitpill-p {
  flex: none;
  font-family: var(--font-display); font-weight: 600; font-size: 19px; line-height: 1;
  letter-spacing: -.02em; color: var(--bg); font-variant-numeric: tabular-nums;
}
.commitpill-chev {
  flex: none; display: grid; place-items: center;
  color: color-mix(in srgb, var(--bg) 62%, transparent);
}
/* ⚠⚠ MINIMISED, THE WORDS NO LONGER GO (2026-09-14). They were hidden because the minimised dock was
   a 300px box with about 75px left for text. The dock now keeps its full-size box and scales, so the
   text keeps its full room and simply draws smaller — and a `display: none` that flipped on every
   change of scroll direction was the most abrupt change in the whole band: words vanishing and the
   price jumping sideways in a single frame. */

@media (max-width: 700px) {
  .commitpill-t { font-size: 13.5px; }
  .commitpill-p { font-size: 18px; }
}
@media (prefers-reduced-motion: reduce) {
  .commitdock, .commitpill, .commitpill-ico, .commitpill-ico svg, .commitpill-p {
    transition-property: opacity;
  }
}
@media (prefers-contrast: more) {
  .commitpill { outline: 1px solid var(--bg); outline-offset: -2px; }
}

/* ══ THE PLAYER-CODE BUTTON ═════════════════════════════════════════════════
   Syd, 2026-09-04: "make it a floating button above the nav bar (ontop of the profile picture
   icon)… it should scale properly and in synch with the nav bar - make it look natural."

   ⚠⚠ IT IS A CHILD OF `.fnav`, AND THAT IS THE WHOLE TRICK. The bar re-centres itself when it
   minimises — 359px wide becomes 300px, so its right edge moves 29.5px inward — and it changes
   height at the same time. A button anchored to the VIEWPORT would need a different `right` and
   a different `bottom` for every state, kept in step by hand with the bar's own numbers, which
   is exactly the class of duplicated constant this file keeps a record of getting wrong.
   Anchored inside the bar, `right` and `bottom: 100%` track it for free, in both states and at
   both breakpoints, and it inherits the bar's z-index so it cannot be painted over.
   ⚠ `backdrop-filter` ON `.fnav` MAKES IT A CONTAINING BLOCK, which is what lets an absolutely
   positioned child resolve against the bar rather than the screen. Nothing clips it: the bar
   has a border-radius but no `overflow`, so the button escapes upward. Verified on the deployed
   build — the button hit-tests at its own centre 7px above the bar's top edge.
   ⚠ IT IS WITHIN 2px OF CENTRED ON THE AVATAR AND THAT IS ARITHMETIC, NOT LUCK. `right: 5px`
   clears the bar's 1px border and 4px padding, so the button's centre sits `--qr-d`/2 inside
   the bar's content edge while the avatar's column centre sits col/2 inside it. Measured at
   375px: collapsed 20 against 21.8, minimised 17 against 18.1. Making it exact would mean
   feeding the column width into CSS, which is a variable that has to be recomputed on every
   resize; 1.8px is below the threshold where anyone can see the difference.

   Syd, earlier the same day: "make it constrast it more naturally from the white BG (for both
   modes), make it clearly a distinction and make it look like it belongs there."

   ⚠⚠ SOLID `--ink`, AND IT COULD ONLY BECOME SOLID ONCE IT WAS ALONE. While it sat beside the
   avatar the pair needed a shared translucent capsule to read as one family with the tab bar —
   which is exactly why it was faint against the cream page. On its own the answer is the
   opposite: fill it. `--ink` on `--bg` is the highest-contrast pair the palette owns in either
   theme, it is navy-on-cream by day and cream-on-navy at night without a second rule, and it
   is the SAME treatment the selected tab uses — DESIGN.md §2, a state gets a shape rather than
   a tint. A filled disc is unmistakably an object placed there on purpose.
   ⚠ NOT ORANGE. DESIGN.md reserves the action colour for the one thing on a screen you are
   meant to press, and on most screens that is a Join or a Pay button. Two orange things make
   both weaker.
   ⚠ THE RING IS THE PAGE COLOUR. A 2px `--bg` ring holds the disc off whatever is behind it at
   the moment it crosses a card edge — the same trick the avatar used to carry, and it matters
   more now that the fill is solid and the collision would be hard-edged. */
/* ⚠⚠ `.codebtn` WAS HERE AND IS GONE, WITH THE CORNER ITSELF. It began as the player-code
   button in every screen's top-right, then became "a solid disc in a screen's top-right
   corner" when the pro shop's cart borrowed it, and then had no users at all: the code moved
   to the tab bar and the shop's cart moved to the floating dock beside it. `.screen.head-gone
   .codebtn`, header.js's watchHead() and syncHeader(), the HEAD_GONE_AT scroll test and the
   `.pane.shop .screen-head` title reserve all existed only to serve it and went with it.
   Grepped before deleting: no .js file under site/ writes the class.
   ⚠ NOTHING FLOATS IN A SCREEN'S TOP-RIGHT NOW. If something is put back there, the title
   reserve is what stops a long heading running underneath it — measure the headings first.

/* ⚠ THE `.is-hidden` RULE THAT USED TO SIT HERE IS GONE, WITH setHeaderVisible() IN header.js.
   It existed for one reason — two circles fighting over one corner on the Shop tab — and the
   player code has left that corner. A visibility hook nobody applies is one a later reader
   applies by accident, which is the same reasoning that removed `.is-big` from the nav.

   ══ THE PLAYER-CODE BUTTON, ABOVE THE BAR ══════════════════════════════════
   ⚠ z-index INSIDE `.fnav`'s STACKING CONTEXT, not the screen's. The bar is z-index 60, so any
     positive value here already clears the live-session pill at 59; 2 keeps it above the bar's
     own translucent background without inventing a number that looks like it competes with the
     shell's layers. */
.qrbtn {
  position: absolute; z-index: 2;
  right: 5px; bottom: calc(100% + 9px);
  width: 40px; height: 40px;
  display: grid; place-items: center;
  border: 0; padding: 0; cursor: pointer;
  border-radius: var(--r-full);
  background: var(--ink); color: var(--bg);
  box-shadow: 0 0 0 2px var(--bg), var(--shadow-2);
  touch-action: manipulation; -webkit-tap-highlight-color: transparent;
  /* ⚠ ITS BOX STAYS 40px, which is what the commit dock reserves for it. It has no scale of its own:
     minimised it rides the bar's, moving with the bar's right end (24.5px inward at 402px) — which is
     why the dock scales by the same factor about the same centre. The corner origin is for the press. */
  transform-origin: bottom right;
}
/* ⚠ 40px PAINTED, 44px TAPPED. The overlay is what keeps the target from shrinking with the drawing.
   ⚠ MINIMISED IT RIDES THE BAR'S SCALE — it is the bar's child — so it no longer has a scale of its
     own (which would now compound to 0.71), and its overlay is drawn 44 ÷ 0.833 so it still lands at
     44px on the glass. */
.qrbtn::after {
  content: ""; position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: var(--tap-min); height: var(--tap-min);
}
.fnav.is-mini .qrbtn::after {
  width: calc(var(--tap-min) / var(--fnav-mini-scale));
  height: calc(var(--tap-min) / var(--fnav-mini-scale));
}
.qrbtn svg { width: 20px; height: 20px; max-width: none; display: block; }
[data-ready] .qrbtn { transition: transform var(--t-press) var(--ease-out); }
.qrbtn:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
/* ⚠ IT DOES NOT LEAVE ON SCROLL, AND THAT IS THE DIFFERENCE FROM THE CORNER IT CAME FROM. In
   the corner it belonged to the screen's title and went when the title went. On the bar it
   belongs to the BAR, which never leaves — it only minimises, and the button minimises with
   it. Syd asked for exactly that: "in synch with the nav bar". */
.screen-head h1 {
  font-family: var(--font-display); font-weight: 600;
  font-size: 32px; line-height: 1.08; letter-spacing: -.02em; margin: 0; color: var(--ink);
}
.screen-head p {
  margin: 5px 0 0; font: 400 14px/1.45 var(--font-ui); color: var(--ink-tertiary);
}

.sec { margin: 0 0 22px; }
.sec-head {
  display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
  padding: 0 var(--gutter) 9px;
}
.sec-head h2 {
  font: 600 12px/1 var(--font-ui); letter-spacing: .07em; text-transform: uppercase;
  color: var(--ink-tertiary); margin: 0; font-family: var(--font-ui);
}
.sec-head a, .sec-head button {
  border: 0; background: none; padding: 0; cursor: pointer;
  font: 600 12px/1 var(--font-ui); color: var(--action);
}
/* The count or aside on the right of a section heading. ⚠ GLOBAL, because two screens render
   it — book.js and event.js. It was `.bk .sec-note` in book.css until 2026-09-04, which meant
   the event page's five of them rendered at inherited body size with no colour and nothing
   anywhere said why; the note in book.css records it. A class named for a PART of `.sec-head`
   belongs beside `.sec-head`. */
.sec-note {
  font: 500 12px/1 var(--font-ui); color: var(--ink-tertiary);
  letter-spacing: .01em; white-space: nowrap;
}

.empty {
  margin: 0 var(--gutter); padding: 26px 20px; text-align: center;
  background: var(--bg-sunken); border-radius: var(--r-lg);
}
.empty p { margin: 0; font: 400 14px/1.5 var(--font-ui); color: var(--ink-tertiary); }

/* ⚠ Every price in the app is centavos rendered here. Tabular figures so a column of
   prices lines up, which is the whole reason the venue can scan a receipt. */
.price { font-variant-numeric: tabular-nums; }

/* A pill that carries a state word as well as a colour. */
.tag {
  display: inline-flex; align-items: center; gap: 5px;
  font: 600 11px/1 var(--font-ui); letter-spacing: .04em; text-transform: uppercase;
  padding: 5px 9px; border-radius: var(--r-full);
  background: var(--bg-sunken); color: var(--ink-secondary);
}
.tag.live { background: var(--live); color: var(--navy); }
.tag.paid { background: color-mix(in srgb, var(--ok) 16%, transparent); color: var(--ok); }
/* ⚠ "CART · NOT PAID" IS A STATUS, NOT THE ACTION. This was orange type on a 16% orange
   tint — the lowest-contrast pair in the app, on the one badge the payment-first rule needs
   a customer to actually read, and a second orange object competing with the pay button on
   the same screen. --attn-bg/--attn-ink is the measured pair tokens.css ships for exactly
   this (6.76 light · 8.80 dark) and is a different hue from --action, so the badge can never
   be mistaken for something to press. Fixed here rather than scoped inside .shop, because
   every screen that shows an unpaid cart had the same badge. */
.tag.due  { background: var(--attn-bg); color: var(--attn-ink); }

/* ── The live-session pill ────────────────────────────────────────────────────
   Syd, 2026-09-03: "i want a floating button / indicator that lets the user know that okay
   they are registered with a court". Then, having lived with it: "make the active booking
   LESS obstructive, like when im browsing the menu and clickning buttons it really gets in
   the way."

   ⚠⚠ SYD IS RIGHT AND THE FIRST VERSION WAS THE WRONG SHAPE OF THING. It was a 56px two-line
     bar spanning the width, permanently, on all seven tabs — and on the Cafe tab it was the
     THIRD floating layer at the bottom, under the cart bar and over the menu rows, so the
     rows you were trying to tap were the rows it was sitting on. A reminder is not a control
     you are using; it must not take the space of one.

     Three changes, in order of how much each gives back:
       1 · ONE LINE, SHRINK-TO-FIT. 56px → 42px, and the width is the text rather than the
           screen. Most of the fix, and it costs nothing: the second line was a restatement.
       2 · IT COLLAPSES TO ITS GLYPH AS YOU SCROLL, in step with the nav shrinking — nav.js
           folds it by the same number that scales the bar (navscrub.js pillShape). Scrolling a
           menu IS "I am browsing, not checking my booking", which is exactly the moment it
           should stop being a bar and become a dot. Scroll back up and it says its piece again.
       3 · IT NEVER SPANS THE WIDTH, so even open there is menu either side of it.

   ⚠ ITS `bottom` IS COMPUTED FROM THE NAV AT FULL SIZE AND NEVER CHANGES. It follows the bar's top
     edge by `translate` instead, written by nav.js in the same frame as the bar's scale — on a phone
     4.5px down once the bar is minimised and 17px up while the labels are open — so it moves WITH the
     bar, as one object, rather than as a second target the eye has to track.
   ⚠ z-index 59 — UNDER the nav at 60. If they ever overlap during a transition, the control
     you navigate with is the one that stays on top. */
.screen > .livepill {
  position: fixed; z-index: 59;
  left: calc(10px + var(--safe-left)); right: calc(10px + var(--safe-right)); bottom: var(--band-bottom);
  /* ⚠⚠ THE CAP LEAVES ROOM FOR THE PLAYER-CODE BUTTON, WHICH SHARES THIS BAND. `.qrbtn` hovers
     above the RIGHT END of the tab bar, at the same height as this pill, and the pill is
     `fit-content` — so a short session title clears it and a long one runs straight underneath.
     Solved by capping rather than by shifting the pill left: an off-centre pill looks like a
     mistake on the screens where the button is hidden, a narrower one never does.
     Measured at 402px: the button's left edge is at 349, so the pill must end before it. Centred,
     a 280px pill ends at 341. At 375px the numbers are 322 and 314. Both clear. */
  width: fit-content; max-width: calc(100% - 122px); margin-inline: auto;
  /* ⚠ NO `padding` AND NO `gap` HERE: they fold with the scroll, and nav.js is their one writer, from
     the `--lp-*` tokens at the top of this file. */
  display: flex; align-items: center;
  min-height: 42px;
  border: 1px solid var(--chrome-edge); border-radius: var(--r-full);
  background: var(--chrome);
  backdrop-filter: var(--chrome-blur); -webkit-backdrop-filter: var(--chrome-blur);
  box-shadow: var(--shadow-2);
  color: var(--ink); text-align: left; cursor: pointer;
  touch-action: manipulation; -webkit-tap-highlight-color: transparent;
  /* ⚠⚠ THE BAND'S SHIFT, THE BAR'S SCALE AND THE DODGE ARE nav.js's, INLINE, EVERY FRAME (2026-09-15).
     `translate` carries the band's shift — up when the labels open, down a little as the bar
     minimises — plus the dodge sideways; `scale`, about the pill's own bottom edge, is the bar's
     factor. Neither is declared here and the transition lists only the press: see `.fnav` for why a
     transition on a property written every frame is a crawl, and riders.js for what enforces it.
     ⚠ `transform` is only the press: pressable() writes it, and it composes with both of these. */
  transform-origin: 50% 100%;
  transition: transform 160ms var(--ease-out);
}

/* ⚠ THE LIFT THAT USED TO BE HERE IS GONE — by way of a per-state `--fnav-h`, which moved `bottom`
   with no transition and made the pill leap, and then `--band-shift`, a variable switched per state.
   It read `translate: 0 -18px` on `.nav-open`, hand-derived from "the nav is 60px tall with icons
   alone and 73px once the labels open, and it is anchored to the bottom, so those 13px come
   off the top" — a correction stacked on a hard-coded offset to describe one measurement in
   two places. The band now moves by a shift nav.js derives from the bar's two heights and its
   scale (navscrub.js `band()`), so the pill is 8px clear of it in every state and every frame
   between. `translate` carries that shift; the dodge adds the horizontal half.
   ⚠ THE ORIGINAL NOTE IS KEPT BELOW because the BUG it records is the reason to be careful
   here, and it will read as an unexplained deletion otherwise.
   ⚠⚠ IT LIFTED WHEN THE TABS WERE LABELLED, BECAUSE THE BAR BELOW GROWS UPWARD INTO IT.
   Measured on the deployed build: the nav is 60px tall with icons alone and 73px once the
   labels open, and it is anchored to the bottom, so those 13px come off the top. The pill's
   `bottom: 82px` clears a 60px bar by 8px and a 73px bar by MINUS FIVE — Syd, 2026-09-03:
   "current reservation floating button status and the nav bar overlap when nav titles
   appear." Lifting 13px restores the same 8px gap in both states.
   ⚠ `translate`, NOT `transform`. motion.js's pressable() writes `el.style.transform =
     scale(...)` on pointer-down; a transform here would be overwritten by the first press and
     the pill would drop onto the nav under the finger. `translate` is its own property, it
     composes with transform, and it is still compositor-friendly.
   ⚠ AND NOT `bottom`, which would be a layout property animating every frame. This is the
     one piece of chrome in the app whose position is allowed to change at all, and it may not
     cost a reflow to do it. */
/* ⚠⚠ 18px, RE-MEASURED AGAIN. The lift is the bar's expanded height minus its collapsed one,
   so it has to be re-derived every time either changes — and it has now changed twice in a
   day: the label's line-height fix added 4px, and giving the pill room to breathe added 3
   more. Measured on the deployed build at desktop size: 60 collapsed, 77.8 expanded.
   ⚠ A NUMBER THAT CAME FROM A MEASUREMENT IS WRONG THE MOMENT THE MEASUREMENT MOVES, and this
     one fails silently — the pill simply sits on the tab bar, which is the exact defect Syd
     reported once already ("current reservation floating button status and the nav bar
     overlap"). If you touch the bar's padding, the icon size or the label, re-measure this. */
/* (the .nav-open lift is now nav.js's band shift; see the note above) */

/* ⚠ THE CLEARANCE IS FOR THE PILL AT FULL SIZE, AND IT IS SET ONCE AT MOUNT.
   `--fnav-clear` is the room the scrollports and the screen-owned bottom pills leave for the
   floating nav — cafe's cart, the payment bar on Play, the profile pill — and none of those
   files watch this one, so overriding the variable here moves all of them up together.
   It does NOT follow the pill down when it collapses: that would resize a scrollport in the
   middle of a scroll and jump the content under the finger. 96px was nav + slack; a 42px pill
   and its 8px gap add 50. */
.screen.has-livepill { --fnav-clear: calc(154px + var(--bottom-inset)); }

/* The glyph sits in a soft plate rather than loose on the chrome — at 19px on a translucent
   surface a bare stroke icon reads as a smudge. Collapsed, this plate IS the pill. */
.livepill .lp-ico {
  position: relative; flex: none; display: grid; place-items: center;
  width: 32px; height: 32px; border-radius: 50%;
  background: var(--selected-soft-bg); color: var(--selected-soft-ink);
}
.livepill .lp-ico svg { width: 19px; height: 19px; max-width: none; display: block; }

/* ⚠⚠ THE WORDS' WIDTH AND OPACITY ARE nav.js's, INLINE, FOLLOWING THE SCROLL (2026-09-15). This was a
   0fr → 1fr grid column on a 360ms transition, with the words held back 190ms opening and faded in
   120ms closing. A fr column inside a fit-content pill opens roughly as the SQUARE of its progress —
   measured in entry 017: 26% wide at 110ms, "Court 4 · 11:0…" growing letter by letter — so the words
   sat still and then rushed, and the whole fold was set off at a threshold. Now the width is a px
   max-width measured off the words, and the pill closes in a straight line as the bar minimises: the
   words fade out over the first 15% of it and back in over the last 15%, and their column holds its
   width while any of them show, so no visible letter is ever cut (navscrub.js pillShape).
   ⚠ `transition: none`, EXPLICITLY — an opacity or max-width transition here would restart on every
     frame's write. The reduced-motion block below puts an opacity fade back, where writes are rare.
   The inner span keeps min-width:0 and overflow:hidden, or the text refuses to be squeezed. */
.livepill .lp-text {
  display: grid; grid-template-columns: 1fr; min-width: 0;
  transition: none;
}
/* ⚠ `text-overflow` IS nav.js's: an ellipsis while any of the words show — the column is not narrowing
   then, so it never rewrites them — and a plain clip only once they have faded, over a column nobody
   can see closing. */
.livepill .lp-line {
  min-width: 0; overflow: hidden; white-space: nowrap;
  font: 600 13.5px/1.2 var(--font-ui); letter-spacing: -.004em; color: var(--ink);
}
/* The chevron's width (`--lp-go-w` open) and opacity are nav.js's too, for the same reason. */
.livepill .lp-go {
  flex: none; display: grid; place-items: center; color: var(--ink-tertiary);
  transition: none;
  overflow: hidden;
}
.livepill .lp-go svg { width: 16px; height: 16px; }

/* ── Collapsed. ⚠ THE `.screen.nav-mini > .livepill` RULES THAT FOLDED IT ARE GONE (2026-09-15):
   folded at a threshold, they were the one sudden thing left in the band. Minimised, nav.js has taken
   everything but the glyph to zero width and given the pill the bar's scale, by the scroll. ── */

/* ══ IT GETS OUT OF THE WAY OF THE COURT BOOKING'S PILL ═════════════════════
 * SPEC (Syd, 2026-09-04): "the active session blob or button should skirt around the private
 * booking cart, like make it like a reactive blob that moves out of the way but still looks
 * natural when the cart button appears."
 *
 * ⚠⚠ THE TRIGGER IS THE DOM'S OWN ANSWER, WHICH MEANS THERE IS NO STATE TO KEEP IN STEP AND NO
 *   LIFECYCLE TO GET WRONG. The obvious build is a class the Courts screen toggles — and it is wrong
 *   in a way that only shows up later: pick a court, switch to the Cafe, and the class is still on
 *   the screen with no commit pill anywhere, so the live pill sits collapsed in a corner
 *   dodging nothing. Asking the DOM the actual question — is there a non-empty commit container
 *   inside the pane that is currently SHOWING — cannot drift, because it is not a copy of the
 *   answer, it IS the answer. `:not(:empty)` is what distinguishes "a court is picked" from
 *   "the container exists but nothing is in it".
 *   ⚠ IT WAS `:has()` HERE UNTIL 2026-09-15, AND nav.js NOW ASKS THE SAME QUESTION — a MutationObserver
 *     on the screen, `.pane.on .commitdock` matching `:not(:empty)` — because the dodge has to compose
 *     with the bar's scale and the band's shift in ONE `translate`, written every frame the bar moves.
 *     A stylesheet rule and an inline write cannot both own one property.
 *
 * ⚠⚠ IT COLLAPSES AS WELL AS MOVING, AND THAT IS WHY THEY FIT. Full width the two pills cannot
 *   share a row on any phone. Collapsed the live pill is its glyph — 42px, or 38 on a handset —
 *   which leaves the commit pill the rest of the band. The collapse is not a new state invented
 *   for this: it is exactly what the pill already does while the tab bar is minimised — one fold,
 *   c = 1 − (1 − bar)(1 − dodge), so the two compose rather than disagreeing (navscrub.js fold()).
 *
 * ⚠ THE HORIZONTAL DISTANCE IS MEASURED, NOT WRITTEN. `--live-dodge` is set by active.js from
 *   the screen's real width, because the pill is centred by `margin-inline: auto` inside a
 *   left/right box and CSS cannot express "half of my containing block" in a translate — a
 *   percentage there resolves against the ELEMENT. A vw-based guess is wrong the moment the app
 *   is not the full viewport width, which is exactly what the desk preview's phone frame is.
 *
 * ⚠ `translate`, NOT `transform` — the same reason the lift uses it. pressable() writes
 *   `el.style.transform` on pointer-down and blanks it on release, so a transform would be
 *   wiped by the first press and the pill would drop back over the commit button under the
 *   finger. `translate` is its own property and composes with it. The dodge and the band's shift
 *   are summed into it by nav.js rather than fighting over one declaration.
 *
 * ⚠ NO OVERSHOOT ANY MORE, AND THERE NEVER VISIBLY WAS ONE. Syd asked for "a reactive blob", and this
 *   note used to say the curve below overshot by about 6%. Solved numerically it peaked at 0.54% —
 *   under a pixel on the full dodge — and its faster clock is what pulled the pill out of line with the
 *   bar. The blob moves on the bar's own fitted spring (motion.js SPRING.float, nav.js's `d`): it
 *   reacts by arriving with the bar, not ahead of it. */
/* ⚠⚠ TWO DODGE VALUES, ONE PER BAR SCALE, BOTH COMPUTED UP FRONT BY active.js. The distance depends
   on how wide the bar is on screen, and it scales — so a single value is wrong at every scale but one.
   active.js writes both: (glyph − full) / 2 at full size, and s × that at the minimised scale, since the
   bar and this pill scale by the same factor. The exact offset at any scale k is k·(glyph − full)/2,
   which is a straight line between those two, so nav.js draws the line (navscrub.js dodgeX) — exact at
   every point of a scrub, where the stylesheet used to pick one of two values by a class.
   ⚠ NOT A ResizeObserver ON THE BAR. Its callbacks ride the rendering steps, so a hidden tab never
     gets one, and it would rewrite the value on every frame of the bar's own motion.
   ⚠⚠ THE DODGE RIDES THE BAR'S SPRING, AND ITS OLD OVERSHOOT CURVE IS GONE (review, 2026-09-14). On a
     420ms overshoot curve it ran ahead of everything it lines up with — 81% done at 100ms against the
     spring's 46% — so the glyph slid ~10px off the bar's edge and over the cart's count disc for about
     130ms. The overshoot it was kept for was never visible: that curve peaks at 0.54%, under a pixel on
     the full 174px dodge, not the 6% an older note claimed. */

/* ⚠ CHARTREUSE IS ENERGY, NOT SUCCESS — the same rule as the Today chip on a session card.
   ⚠ AND IT MOVED ONTO THE GLYPH. It used to sit before the title, which meant that collapsed
     — the state the pill is in most of the time — the one mark saying "this is happening now"
     was the first thing to disappear. On the plate's edge it survives the collapse, which is
     when it is doing the most work: a dot on a small circle is the whole message. */
.livepill .lp-dot {
  position: absolute; right: -1px; top: -1px;
  width: 9px; height: 9px; border-radius: 50%; background: var(--live);
  box-shadow: 0 0 0 2px var(--bg);
}
@media (prefers-reduced-motion: no-preference) {
  .livepill.is-live .lp-dot { animation: pulse 2s var(--ease-in-out) infinite; }
}
@media (prefers-reduced-motion: reduce) {
  /* ⚠ THE PRESS SNAPS, AND THE PILL CHANGES SHAPE IN ONE STEP — nav.js moves nothing in between under
     reduced motion (navscrub.js: the bar switches at 14px of a run, as entry 017 did). It still has to
     get out of the commit pill's way; it just arrives without animating there. */
  .screen > .livepill { transition-property: opacity; }
  /* ⚠ GENTLER, NOT ABSENT (DESIGN.md §4.8): the words FADE instead of blinking. nav.js writes their
     opacity once per change here, not every frame, so a transition on it is a fade and not a crawl.
     Folding, the width is already zero, so the fade is moot; opening, the words come up over 160ms in
     a column that is already open. tokens.css sets the duration. */
  .livepill .lp-text, .livepill .lp-go { transition: opacity 160ms var(--ease-out); }
}

/* ══ THE PLAYER'S CODE SHEET ════════════════════════════════════════════════
   mycode.js, opened from the corner. It lives in shell.css rather than a stylesheet of its
   own because the corner is shell furniture — the same reasoning that keeps `.hdr` out of
   every screen's file.

   ⚠ THE PLATE IS ALWAYS CREAM, IN BOTH THEMES, BECAUSE THE CODE IS ALWAYS NAVY. qr.js draws
   with --navy on --cream whatever the page is doing, since a code inverted for dark mode is a
   code a camera reads as its own negative. The ticket's plate carries the identical note. */
/* ══ THE POPUP IT LIVES IN ═════════════════════════════════════════════════
 * SPEC (Syd, 2026-09-04): "the QR should not be a whole page, it should popup like this"
 * (watermelon.sh's show-qr).
 *
 * ⚠⚠ THE ANCHOR IS WRITTEN BY mycode.js, NOT HERE. `right` and `bottom` are set inline from the
 *   trigger's measured rect at open time, so the card's bottom-right corner lands exactly on the
 *   button's — and `transform-origin: bottom right` then scales it out of that point. Putting
 *   those offsets in CSS would mean restating every number `.qrbtn` derives from the tab bar,
 *   including the ones that change when the bar minimises. The values below are only the
 *   fallback for a call with no trigger.
 *
 * ⚠ SCALE + BLUR + OPACITY TOGETHER, WHICH IS THE PART OF THE REFERENCE WORTH KEEPING. A plain
 *   opacity fade reads as a picture appearing; blurring in while it grows reads as a surface
 *   arriving — apple-design §12, "materialize, don't just fade". The duration is DESIGN.md's
 *   popover budget (125-200ms) rather than the reference's 350ms spring, and there is no
 *   overshoot: DESIGN.md reserves that for motion a gesture actually threw.
 *
 * ⚠ EXIT IS FASTER THAN ENTER (--t-exit, 160ms) and mycode.js's teardown timer is the same
 *   number. Removing the node before its own transition ends makes the card vanish rather than
 *   leave. */
.qrpop-scrim {
  position: absolute; inset: 0; z-index: 70;
  /* ⚠ THE SAME SCRIM THE SHEET USES, LITERALLY THE SAME NUMBERS — profile.css `.pf-scrim`.
     It was `var(--scrim)` for one commit, which is not a token this project has: an unknown
     custom property makes the declaration invalid at computed-value time, so the scrim rendered
     fully transparent while still swallowing every tap. It looked like a popup that had frozen
     the page. scripts/checks/cssvars.js now fails the build on that. */
  background: rgba(0, 10, 22, .42);
  /* And no backdrop-filter, the same as the sheet's — profile.css `.pf-scrim` says why (2026-10-04). */
  opacity: 0; transition: opacity var(--t-exit) var(--ease-out);
}
.qrpop-scrim.is-in { opacity: 1; transition-duration: 200ms; }

.qrpop {
  position: absolute; z-index: 71;
  right: var(--gutter); bottom: 74px;
  width: 272px; max-width: calc(100% - 2 * var(--gutter));
  /* The tallest it can be before it would reach the status bar on the shortest phone this
     ships to. The plate inside shrinks first — qr.js is explicit that scaling the code down
     whole is correct and clipping it is not. */
  max-height: calc(100% - 96px);
  overflow: auto; overscroll-behavior: contain;
  padding: 16px 16px 12px;
  border-radius: 26px;
  background: var(--bg-raised);
  border: 1px solid var(--chrome-edge);
  box-shadow: var(--shadow-3);
  transform-origin: bottom right;
  opacity: 0; scale: .82; filter: blur(6px);
  transition: opacity var(--t-exit) var(--ease-out), scale var(--t-exit) var(--ease-out),
              filter var(--t-exit) var(--ease-out);
}
.qrpop.is-in {
  opacity: 1; scale: 1; filter: blur(0);
  transition-duration: 200ms;
}
/* Half of a two-part pattern — mycode.js focuses the card so the trap has somewhere to start,
   and Safari 26 paints :focus-visible on a programmatic focus. A 2px ring around the whole
   card is a box drawn around the popup. profile.css needed the same pairing for the sheet. */
.qrpop:focus, .qrpop:focus-visible { outline: none; }

/* ⚠ `justify-self: stretch` BECAUSE `.mycode` IS A GRID WITH `justify-items: center`. Every
   child of that grid is shrink-to-fit by default, so the row came out as wide as its contents
   and "Copy code" wrapped onto two lines inside a button with 100px to spare beside it. The
   `flex: 1 1 auto` on the button was doing its job perfectly — on a row that was already the
   wrong width. */
.qrpop-row {
  display: flex; align-items: center; gap: 8px; margin-top: 14px;
  justify-self: stretch; width: 100%;
}
.qrpop-btn {
  min-height: 38px; padding: 0 16px; border-radius: var(--r-full);
  border: 1px solid var(--rule); background: var(--bg); color: var(--ink);
  font: 600 14px/1 var(--font-ui); letter-spacing: -.006em; cursor: pointer;
  touch-action: manipulation; -webkit-tap-highlight-color: transparent;
  flex: 1 1 auto;
  transition: background-color 160ms var(--ease-out), color 160ms var(--ease-out),
              border-color 160ms var(--ease-out);
}
/* Confirmation is a FILL, not a colour swap on the word — it survives greyscale and it is the
   same "a state gets a shape" rule the selected tab follows. */
.qrpop-btn.is-done { background: var(--ink); border-color: var(--ink); color: var(--bg); }
.qrpop-x { flex: 0 0 38px; padding: 0; display: grid; place-items: center; }
.qrpop-x svg { width: 15px; height: 15px; display: block; }
.qrpop-btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
/* "Copy code" with no code to copy (mycode.js, the door code switched off): the sample's button, its
   words in the tertiary ink and no pointer — not faded, so it is still read as the sample's control. */
.qrpop-btn.is-off:disabled { cursor: default; color: var(--ink-tertiary); }

/* ⚠ THE PLATE IS CAPPED BY THE CARD NOW, NOT BY THE SHEET. 272 − 32 of padding = 240 of room
   against a 251px plate, so the plate takes 240 and the canvas 220 — smaller than the 231 it
   had in the sheet, and deliberately so: qr.js says "the stylesheet caps it at the plate's
   width and squares it with aspect-ratio, so the code scales down whole. Do not put
   style.width/height back." */
@media (prefers-reduced-motion: reduce) {
  .qrpop { transition-property: opacity; scale: 1; filter: none; }
}

.mycode { display: grid; justify-items: center; padding: 2px 0 8px; }
.mycode-plate {
  /* ⚠ MIRRORS QR_SIZE IN qr.js. Change one, change the other. The cap matters on a narrow
     phone: without it a 231px canvas plus padding overflows a 320px screen and the code is
     CLIPPED rather than shrunk — and a clipped code is not a code. */
  --code-size: 231px;
  width: 100%; max-width: calc(var(--code-size) + 20px); padding: 10px;
  background: var(--cream); border-radius: var(--r-md);
}
.mycode-canvas {
  width: var(--code-size); max-width: 100%; height: auto; aspect-ratio: 1 / 1; display: block;
}
.mycode-who { margin-top: 16px; display: grid; justify-items: center; gap: 3px; }
.mycode-name {
  font: 600 19px/1.2 var(--font-ui); letter-spacing: -.012em; color: var(--ink);
  text-align: center;
}
.mycode-handle { font: 500 14px/1 var(--font-ui); color: var(--ink-tertiary); }
/* ⚠ THE STRING IS UNDER THE PICTURE, NOT INSTEAD OF IT. A camera is the fast path and a
   readable code is the fallback for the night the camera will not focus — the ticket makes
   the same trade. Letter-spaced because it is read aloud across a court, not scanned by eye. */
.mycode-str {
  margin-top: 14px; font: 600 13px/1 var(--font-ui); letter-spacing: .12em;
  color: var(--ink-secondary);
}
/* While launch.js's DOOR_CODE is off (mycode.js): the plate is a skeleton — `.skel`'s ink at 8%, in
   both themes — and "Coming soon" stands at the code string's height. */
.mycode-plate.is-soon { background: color-mix(in srgb, var(--ink) 8%, transparent); }
.mycode-soon { margin-top: 14px; }
@media (prefers-contrast: more) {
  .mycode-plate.is-soon { background: color-mix(in srgb, var(--ink) 16%, transparent); }
}
.mycode-fine {
  margin: 12px 0 0; max-width: 30ch; text-align: center;
  font: 500 12px/1.45 var(--font-ui); color: var(--ink-tertiary);
}

/* ══ ON A REAL PHONE ════════════════════════════════════════════════════════
   Syd, 2026-09-04: "on mobile you can make the nav and status button a bit smaller it takes
   up SO MUCH of the screen."

   ⚠⚠ THIS BLOCK IS IN shell.css AND NOT IN ui.css'S MOBILE QUERY, AND THAT IS THE CASCADE,
   NOT A FILING PREFERENCE. index.html links ui.css BEFORE shell.css, and a media query does
   not raise specificity — so `.fnav { padding: … }` written over in ui.css loses to the
   plain `.fnav` here at equal weight and the edit looks like it never happened. Every rule
   that overrides shell furniture belongs beside the rule it overrides. ui.css carries the
   same warning about `.hdr` from the other direction.

   ⚠⚠ HOW MUCH IS ACTUALLY SAVED, AND WHY IT IS NOT MORE. Measured on the deployed build, the
   bottom of the screen was 60px of bar + 14px of offset + a 42px pill + the 8px between them
   = 124px of a 714px viewport, before the browser's own 40px bar underneath it. The floor on
   all of it is `--tap-min`: tokens.css calls 44px "the floor, not a target", so the seven
   buttons cannot shrink and the bar can only lose the padding AROUND them. It loses 6px of
   that, 2px of icon, and 4px of the offset; the pill loses 4px of height and 4px of glyph.
   That is 24px back — not dramatic, and dramatic was never available without making the
   tab bar harder to hit one-handed at a venue, which is the thing tokens.css refuses.
   The bigger win is not in this block at all: it is `.phone` growing to 100lvh in ui.css, so
   the app now paints THROUGH the browser's floating bar instead of stopping above it.

   ⚠ ONE QUERY FOR BOTH, unlike ui.css's two. Everything here is about the size of a hand and
   a thumb, which is equally true on a home-screen install at any width. */
@media (max-width: 700px), (display-mode: standalone) {
  /* The footprint the scrollports leave. 10 offset + 61 expanded bar + 13 slack. The four
     screens that read this variable do not watch this file, so it stays generous. */
  :root { --fnav-clear: calc(92px + var(--bottom-inset)); }
  :root { --lp-mini-w: 40px; --fnav-inset: 8px; }
  /* The phone's bar: 44 + 8 + 2 collapsed, and about 16 more once the labels open (70.8 at 402px,
     69.5 below 380px). Minimised it keeps that layout and scales, to 45px on screen. Its offset from
     the bottom edge is 10 here, not 14. */
  .screen { --fnav-h: 54px; --band-bottom: calc(10px + var(--fnav-h) + 8px + var(--safe-bottom)); }
  /* ⚠ 71, NOT THE 66.5 THIS FILE USED TO CLAIM. That number came from a note written when the
     phone breakpoint's labels were the only ones at 10px; above 380 they are 11px and the bar
     opens to 70.8. Measured on the deployed build at 402: the pill sat 3.7px off the bar
     instead of 8 until this was corrected. Below 380 the smaller label takes it back down —
     see the query just after this block. */
  .screen { --fnav-h-open: 71px; }

  /* ⚠ `bottom` NOW CLEARS THE BROWSER'S BAR AS WELL AS THE HOME INDICATOR — see
     --bottom-inset in tokens.css. Without it the tab bar sits UNDER Safari's address capsule
     on every modern iPhone: still tappable in theory, and covered by glass in practice. */
  .fnav {
    padding: 4px 4px;
    /* ⚠ 8px FROM THE EDGE, NOT 10 — bought back to pay for the eighth item. It is still
       clearly a floating pill rather than a bar welded to the bottom edge, which is the line
       notes/APP-SHAPE.md draws; below about 6 it stops reading that way. */
    bottom: calc(10px + var(--safe-bottom));
    /* ⚠ A TIGHTER SHADOW THAN THE DESK'S, AND IT WAS MEASURED, NOT TASTED. Safari 26 floats a
       glass slab 10px under this bar, and the slab is painted a shade BRIGHTER than the page.
       `--shadow-3` blurs 32px, so its darkest 11px land in that gap and then stop dead at the
       brighter slab — sampled off the simulator: 208 under the bar against 246 for the page and
       244 for the slab. That hard step is what read as a "second pill" ghosting under the tab
       bar. `--shadow-2` fades inside the gap instead. */
    box-shadow: var(--shadow-2);
  }
  /* ⚠ 8px, UP FROM 7 — the other half of "not too tight". The radius above stops the corners
     eating the label; this is the air around it. It costs the expanded bar about 2px and the
     collapsed bar nothing, because `min-height: var(--tap-min)` still governs there. */
  .fnav-btn { padding: 8px 2px; }
  .fnav-ico, .fnav-ico svg { width: 21px; height: 21px; }
  /* ⚠ THE PHONE HAS NO MINIMISED RULES OF ITS OWN ANY MORE. It used to shrink to 300 × 40 by its
     own paddings, button heights and icon scale; it scales now, like the desk bar — 386 × 54
     becomes 321.6 × 45 at 402px, the same proportions it had at full size. */

  /* ⚠ THE PILL'S OFFSET IS THE SAME ARITHMETIC AS BEFORE, RE-RUN ON THE NEW BAR: 10px of
     offset + a 54px bar + the 8px gap = 72. And the lift is re-derived too — a button is
     21 + 5 + 11 + 14 = 51px once its label opens against 44 collapsed, so the bar grows by
     7px here where it grew by 13 at desktop sizes. Copying the 13 across would have put the
     pill 6px too high on a phone, which is the version of this bug Syd already reported once
     ("current reservation floating button status and the nav bar overlap"). */
  .screen > .livepill { min-height: 38px; }
  /* The phone pill's open padding and gap — tokens, because nav.js folds them (see the top of this
     file). The glyph pill is `--lp-mini-pad` all round at both sizes: 5 + 28 + 5 + 2 = `--lp-mini-w`. */
  :root { --lp-py: 4px; --lp-pr: 10px; --lp-pl: 4px; --lp-gap: 8px; }
  /* Same re-derivation as the desktop rule, measured on the deployed build at 375px: the bar
     is 54 collapsed and 66.5 expanded, so it grows 12.5. Rounded UP rather than down — half a
     pixel of extra gap is invisible and half a pixel of overlap is the bug. */
  /* (the .nav-open lift is now nav.js's band shift; see the note in the desk block) */
  .livepill .lp-ico { width: 28px; height: 28px; }
  .livepill .lp-ico svg { width: 17px; height: 17px; }
  .livepill .lp-line { font-size: 13px; }
  /* 72 offset + 38 pill + 8 gap = 118, then the same slack as above. */
  .screen.has-livepill { --fnav-clear: calc(134px + var(--bottom-inset)); }
}
/* A 10px label is a shorter line box: the opened bar measures 69.5px below 380px, against 70.8 above.
   ⚠ AFTER THE PHONE BLOCK, NOT INSIDE THE 380px QUERY ABOVE IT. Every phone under 380px matches
   both, and the later rule wins — so while this lived up there, the phone block's 71 always won
   and the band was placed for a bar 1.5px taller than the one on screen.
   ⚠ 69.5, MEASURED, NOT THE 68 FIRST WRITTEN HERE, which missed by the same 1.5px the other way. */
@media (max-width: 380px) { .screen { --fnav-h-open: 69.5px; } }
/* ⚠ THE NARROWEST PHONES MINIMISE LESS. At 320px a tab column is 36.75px wide at full size, and 0.833 of
   that is 30.6px — the smallest tap target in the app, on the control the app is navigated with
   (review, 2026-09-14). 0.9 keeps it at 33px — the old minimised bar kept 36.25 there, and 33 is the middle ground between that and the Instagram scale. active.js
   and the band shift both read the variable, so nothing else has to change with it. */
@media (max-width: 340px) { :root { --fnav-mini-scale: .9; } }
/* ⚠ AND THEIR TAB LABELS TAKE THE WHOLE COLUMN (the ⚠⚠ over the 380px label query says why). Measured
   2026-10-05 with all eight columns on, Inter loaded: 320 → "Tickets" 35.11 in 36.75, 1.64 spare, and
   every label whole down to 307px (35.13 of column; at 306 "Tickets" is 0.11 over); 340 → 39.25. Above 340
   the 2px sides stay: 341 → 35.38 of label for 35.11, 360 → 37.75, 375 → 39.63, and 390, at 11px,
   "Tickets" 38.61 in 41.50. ⚠ AFTER THE PHONE BLOCK, NOT BESIDE THE LABEL QUERY: the phone block's
   `.fnav-btn { padding: 8px 2px }` is the later rule, and up there it won — the first try at this
   measured 320 unchanged. */
@media (max-width: 340px) { .fnav-btn { padding-inline: 0; } }
/* ⚠ 12px FROM THE EDGE ON A 390–700 PHONE, 8 BELOW IT (the page gutter, 2026-10-07). The bar was the
   closest thing to the edge on every screen. Measured with all eight columns on: 44.5px each at 390
   ("Tickets", 38.61 at 11px, in the 40.5 its 2px sides leave), 46 at 402. Below 390 the eighth column
   has nothing to spare (43.6 at 375), so the 8 stays. The commit dock, the code button and active.js's pill dodge all
   read `--fnav-inset`, so they move with it. */
@media (min-width: 390px) and (max-width: 700px) { :root { --fnav-inset: 12px; } }

/* ══ THE INVITE PICKER ══════════════════════════════════════════════════════
   invite.js, shared by the Courts tab's paid sheet and the session screen.

   ⚠ IN shell.css AND NOT IN EITHER SCREEN'S FILE, BECAUSE IT APPEARS IN BOTH. book.css is
   scoped under `.bk` and event.css under `.ev`; whichever one owned these rules, the picker
   would have rendered as a column of unstyled rows in the other screen with nothing anywhere
   explaining it. Markup shared between screens has its styles where the markup's module is. */
.invite-find {
  display: flex; align-items: center; gap: 2px;
  margin: 0 0 14px; padding: 0 14px;
  min-height: var(--tap-min); border-radius: var(--r-full);
  background: var(--bg-sunken); border: 1px solid var(--rule);
  transition: border-color 160ms var(--ease-out), background-color 160ms var(--ease-out);
}
.invite-find:focus-within { border-color: var(--focus); background: var(--bg); }
.invite-at { font: 600 16px/1 var(--font-ui); color: var(--ink-tertiary); flex: none; }
.invite-in {
  flex: 1; min-width: 0; border: 0; background: transparent; outline: none; padding: 0;
  /* ⚠ 16px, NOT SMALLER. iOS Safari zooms the whole page in when a focused field's text is
     under 16px, and it does not zoom back out — so the app is left scaled up with the tab bar
     off the side of the screen. Every text input in this app is 16px for this reason. */
  font: 500 16px/1 var(--font-ui); color: var(--ink);
}
.invite-in::placeholder { color: var(--ink-tertiary); }

.invite-list { display: grid; }
.invite-row {
  display: flex; align-items: center; gap: 11px; width: 100%;
  min-height: var(--tap-min); padding: 9px 4px;
  border: 0; background: transparent; text-align: left; cursor: pointer;
  border-bottom: 1px solid var(--rule);
  touch-action: manipulation; -webkit-tap-highlight-color: transparent;
  color: var(--ink);
}
.invite-row:last-child { border-bottom: 0; }
.invite-av {
  flex: none; display: grid; place-items: center;
  width: 34px; height: 34px; border-radius: 50%;
  background: var(--bg-sunken); color: var(--ink-secondary);
  font: 700 12px/1 var(--font-ui); letter-spacing: .01em;
}
.invite-t { flex: 1; min-width: 0; display: grid; gap: 2px; }
.invite-n {
  font: 600 15px/1.2 var(--font-ui); letter-spacing: -.008em; color: var(--ink);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.invite-h { font: 500 12.5px/1 var(--font-ui); color: var(--ink-tertiary); }
.invite-tag { flex: none; }
/* Invited: marked, never disabled — a second tap takes them off again, and a disabled row is
   a dead end with no way back. */
.invite-row.is-on .invite-tag { background: var(--selected-soft-bg); color: var(--selected-soft-ink); }
/* On a court: the one case that really cannot be undone from here, so it says so and stops
   responding. Dimmed AND labelled — colour alone would leave a row that just felt broken. */
.invite-row.is-fixed { cursor: default; }
.invite-row.is-fixed .invite-n, .invite-row.is-fixed .invite-h { color: var(--ink-tertiary); }
.invite-row:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; border-radius: var(--r-sm); }
@media (hover: hover) {
  .invite-row:not(.is-fixed):hover { background: var(--bg-sunken); }
}

/* ══ THE REAL BUILD'S SHELL: PLACEHOLDERS, THE DOOR'S FULL SCREENS, THE TOP CORNER ═══════════════
 * SPEC (Syd, 2026-10-04): "i will sign up as aplayer and view the site completely blank".
 * placeholder.js and syncpill.js draw these; app.js mounts them. One block, at the end of the file,
 * so nothing above it moves.
 *
 * ── A tab not built on real data yet ──────────────────────────────────────────────────────────
 * ⚠ THE TICKETS SCREEN'S EMPTY STATE, NOT `.empty`. bookings.css `.tkt-empty` is a heading that
 *   names the state and a line that states the rule, left-aligned under the title; `.empty` is a
 *   centred box, the visual grammar for "there is nothing here". "Not in the app yet." is the
 *   first kind of sentence, so it takes the first kind of layout, with the same numbers. */
.ph-box { padding: 14px var(--gutter) 22px; }
.ph-h {
  margin: 0 0 4px;
  font-family: var(--font-display); font-weight: 600;
  font-size: 19px; line-height: 1.2; letter-spacing: -.014em; color: var(--ink);
}
.ph-p { margin: 0; color: var(--ink-secondary); font: 400 13.5px/1.45 var(--font-ui); }
.ph-p + .ph-p { margin-top: 4px; }

/* ── A tab open with nothing real to fill it (Syd, 2026-10-05: "i dont want a blank page") ──────
 * placeholder.js draws both. ⚠ NOTHING HERE MOVES — no transition, no keyframes — so there is no
 *   animation for a reduced-motion block to turn down (DESIGN.md §4.8): a skeleton that shimmered
 *   would say "loading", and these tabs are not loading, they are waiting for a feature.
 *
 * The notice: a raised card under the screen's title, on the page gutter like every card (ui.css `.card`),
 * "Coming soon" as the attention tag, then the placeholder's heading-and-line grammar (`.ph-h`,
 * `.ph-p`) at the same sizes, so a tab that is waiting reads in the voice every empty tab uses. */
.soon {
  margin: 0 var(--gutter) 20px; padding: 14px 16px 15px;
  background: var(--bg-raised); border-radius: var(--r-lg); box-shadow: var(--shadow-1);
}
.soon-tag { margin: 0 0 10px; }
.soon-t {
  margin: 0 0 3px;
  font-family: var(--font-display); font-weight: 600;
  font-size: 19px; line-height: 1.2; letter-spacing: -.014em; color: var(--ink);
}
.soon-p { margin: 0; color: var(--ink-secondary); font: 400 13.5px/1.45 var(--font-ui); }

/* A skeleton block. ⚠ THE INK AT 8%, NOT A GREY: it is a faint navy on cream and a faint cream on
   navy, so one rule reads in both themes and on both the page and a raised card. */
.skel {
  display: block; flex: none;
  background: color-mix(in srgb, var(--ink) 8%, transparent);
  border-radius: 6px;
}
/* A line of text that is not there: the height of a word at body size, rounded like one. */
.skel-line { height: 10px; border-radius: 5px; }

/* An empty state that has a heading as well as a line — the News feed, Friends. The shell's `.empty`
   (a centred sunken box) with a title over its line, so it is the same box as "Nothing on this day." */
.empty .empty-t {
  margin: 0 0 3px; font: 600 15px/1.3 var(--font-ui); letter-spacing: -.006em; color: var(--ink-secondary);
}
@media (prefers-contrast: more) {
  .skel { background: color-mix(in srgb, var(--ink) 16%, transparent); }
  .soon { box-shadow: none; border: 1px solid var(--ink-tertiary); }
}

/* ── The door's full screens: a blocked account, or no connection and nothing cached ──────────
 * ⚠ OVER EVERYTHING THE SHELL OWNS — the panes, the tab bar (60), the pills (59), the event page
 *   (65) and every sheet (70–73) — and UNDER the launch screen (ui.css `.boot`, 80), which has to
 *   leave over it: at 80 this came later in the document and covered the crest, so the launch
 *   screen faded out behind it. None of the shell is usable on an account the server has closed,
 *   and a tab bar left tappable over "This account is suspended." is a control that leads nowhere.
 * ⚠ IT BRINGS ITS OWN TOP INSET. It is not a pane, so the rule that pads a pane's scroller under
 *   the status area does not reach it; the desk mock's painted status bar is 52px. */
.stopscreen {
  position: absolute; inset: 0; z-index: 79;
  display: flex; flex-direction: column;
  background: var(--bg);
  padding-top: 52px;
}
.stopscreen > .pane-scroll { padding-bottom: 24px; }

/* ── The top corner: the sync pill (real mode) and "Sample data" (sample mode) ─────────────────
 * ⚠⚠ FIXED UNDER THE STATUS AREA, NEVER RIDING THE TAB BAR. nav.js writes the bar's riders every
 *   frame and scripts/checks/riders.js keeps every other stylesheet off them; neither of these is
 *   one, and neither moves with the scroll.
 * ⚠ THE TOP-RIGHT, BECAUSE EVERY SCREEN LEAVES IT EMPTY ("the corner is empty on every screen",
 *   above) and every title is left-aligned. Centred, "Offline · as of 7:41 PM" ran through
 *   "Open play". The arithmetic is the old corner button's: the title's line starts 6px below the
 *   top inset and is 34.56px tall, and a 26px capsule centred on it sits 4px lower, so 10px.
 * ⚠ pointer-events: none. Both are labels; a label that swallowed a tap meant for whatever has
 *   scrolled under it would be a dead spot on the glass.
 * ⚠ z-index 59, the floating pills' layer: over the panes' pinned bars and the status strip, under
 *   the tab bar (60), the event page (65) and every sheet (70+), each of which has its own top. */
.syncpill, .samplechip {
  position: absolute; z-index: 59;
  top: calc(52px + 10px); right: var(--gutter);
  pointer-events: none;
  white-space: nowrap;
  border-radius: var(--r-full);
}
.syncpill {
  padding: 6px 11px;
  font: 600 12px/1 var(--font-ui); letter-spacing: -.004em;
  background: var(--bg-raised); color: var(--ink-secondary);
  border: 1px solid var(--rule); box-shadow: var(--shadow-2);
  /* Off: out of the accessibility tree as well as out of sight, after the fade. */
  visibility: hidden; opacity: 0; transform: translate3d(0, -4px, 0);
  transition: opacity var(--t-exit) var(--ease-out), transform var(--t-exit) var(--ease-out),
              visibility 0s linear var(--t-exit);
}
.syncpill.is-on {
  visibility: visible; opacity: 1; transform: none;
  transition: opacity var(--t-menu) var(--ease-out), transform var(--t-menu) var(--ease-out),
              visibility 0s;
}
/* ⚠ "Not sent" IS THE ONE THAT MUST NEVER READ AS FINE: the same warning tint as the profile's
   "Waiting for signal" (profile.css `.pf-pill[data-state="queued"]`). A held answer is a notice,
   not a fault, and keeps the neutral capsule; the words already carry the time. */
.syncpill[data-tone="notsent"] {
  background: color-mix(in srgb, var(--warn) 18%, var(--bg-raised));
  border-color: color-mix(in srgb, var(--warn) 42%, transparent);
  color: var(--warn);
}
.samplechip {
  padding: 5px 8px;
  font: 650 10.5px/1 var(--font-ui); letter-spacing: .08em; text-transform: uppercase;
  background: var(--attn-bg); color: var(--attn-ink);
}
@media (prefers-reduced-motion: reduce) {
  .syncpill, .syncpill.is-on { transform: none; }
}

/* On a phone, installed, or a touch tablet the painted status bar is gone (ui.css) and the device's
   own inset takes its place, with the same 12px floor `.screen` keeps. */
@media (max-width: 700px), (display-mode: standalone), (hover: none) and (pointer: coarse) {
  .stopscreen {
    padding-top: max(var(--safe-top), 12px);
    padding-left: var(--safe-left); padding-right: var(--safe-right);
  }
  .syncpill, .samplechip {
    top: calc(max(var(--safe-top), 12px) + 10px);
    right: calc(var(--gutter) + var(--safe-right));
  }
}
