/* 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. */
:root { --fnav-clear: calc(96px + var(--bottom-inset)); }

/* ── 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 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. */
.fnav {
  position: absolute; z-index: 60;
  /* The pill resizes as one object. Width and padding are not compositor properties, so this
     is the one place in the shell that transitions layout — it is a 320ms change on a single
     element with no children that reflow, and the alternative (transform) costs the tap
     targets. See the .is-mini block below. */
  transition: max-width 320ms var(--ease-out), padding 320ms var(--ease-out);
  left: 10px; right: 10px; bottom: calc(14px + var(--bottom-inset));
  width: auto; max-width: 420px; 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;
  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;
  transition: color 160ms var(--ease-out), background-color 160ms var(--ease-out),
              border-radius 260ms 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;
  transition: width 320ms var(--ease-out), height 320ms var(--ease-out); }
.fnav-ico svg { width: 23px; height: 23px; max-width: none; display: block; flex: none;
  transition: width 320ms var(--ease-out), height 320ms var(--ease-out); }

/* ⚠ 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;
  transition: grid-template-rows 260ms var(--ease-out),
              margin-top 260ms var(--ease-out),
              opacity 180ms 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;
}

/* ⚠⚠ 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; }

/* ── MINI · the bar physically shrinks while you read ─────────────────────────
   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." Scrolling down takes the pill to this size;
   scrolling up, or reaching the top, brings it back. nav.js owns when.

   ⚠ WIDTH AND PADDING, NOT `transform: scale()`. Scaling is one line and it would have been
     wrong: it scales the HIT AREAS with the pixels, and tokens.css calls 44px "the floor, not
     a target" for a phone held one-handed at a venue. At the scale factor this reads as, every
     tab would have become a 38px target — smaller than a thumb, on the control the whole app
     is navigated with. Shrinking the box while `min-height: var(--tap-min)` holds the buttons
     open keeps all seven at 44px tall in both states.
   ⚠ WHAT ACTUALLY CHANGES, MEASURED ON A 390px PHONE: 323 × 60 becomes 300 × 46, and the
     glyphs go 23px → 21px. The height is where the shrink is legible — the first attempt at
     this set `max-width: 316px` against a bar that was only 323px wide to begin with, so it
     lost SEVEN PIXELS and the state was invisible on screen while the class was toggling
     perfectly. Measured, not looked at.
     The seven buttons stay `flex: 1 1 0`, so they narrow together and nothing re-sorts — the
     same reason the expand animation was made height-only.
   ⚠ `--fnav-clear` IS NOT TOUCHED. It is the padding at the bottom of every scrollport, so
     changing it here would resize the scrolling box DURING a scroll: the content would jump
     under the finger at the exact moment the bar moved, on every direction change. The bar
     hovers over the content; how much room it leaves is deliberately a constant. */
.fnav.is-mini {
  max-width: 300px;
  padding: 1px 4px;
}
.fnav.is-mini .fnav-ico,
.fnav.is-mini .fnav-ico svg { width: 21px; height: 21px; }

/* ⚠ 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. */
@media (max-width: 380px) {
  .fnav-lab { font-size: 10px; }
}

/* ⚠ 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. */
  .fnav-lab { transition-duration: 1ms; }
  .fnav-btn { transition-duration: 1ms; }
}
@media (prefers-reduced-transparency: reduce) {
  .fnav { background: var(--bg-raised); backdrop-filter: none; -webkit-backdrop-filter: none; }
}

/* ── Shared screen furniture ──────────────────────────────────────────────── */
.screen-head { padding: 6px 20px 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.
   ⚠ 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. Putting a second control back
   in this corner is a layout change, not an addition — measure the titles again first. */
.pane .screen-head { padding-right: 76px; }

/* The Shop is the one exemption, and it is a pair with app.js's setHeaderVisible(). proshop.css
 * already parks a cart button in that corner and cannot move it — the floating nav owns the
 * bottom band — so app.js hides the avatar for this tab. Reserving space here as well would be
 * a strip of dead air beside a button that is already there. If that decision ever flips, these
 * two lines flip together.
 * ⚠ `.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, loses silently to the line above, and
 *   the Shop's title sits 92px off the cart it was supposed to make room for. Both spellings
 *   are here so it survives the day the shop is wrapped in something. */
.pane.shop .screen-head, .pane .shop .screen-head { padding-right: 20px; }

/* ══ 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. */
.fnav-me {
  flex: 0 0 34px; width: 34px; min-width: 34px;
  padding: 9px 0;
}
.fnav-av {
  width: 26px; height: 26px; flex: none;
  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;
}
.fnav-me.is-out { flex: 0 0 46px; width: 46px; min-width: 46px; }

/* ══ THE PLAYER-CODE BUTTON ═════════════════════════════════════════════════
   Syd, 2026-09-04: "the qr code can be a floating button in that position but 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 not awkwardly floating there like it was
   unintentional."

   ⚠⚠ 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 {
  position: absolute; z-index: 55;
  /* ⚠ 55px = the 52px status bar in the desk mock + 3.28 of centring. The disc is 40px and the
     title's line box is 34.56, so it sits (34.56 − 40) / 2 below that line's top, plus the 6px
     of `.screen-head` padding above it. The phone's own value is in ui.css and is the same sum
     with the safe-area padding in place of the status bar. */
  top: 55px; right: 12px;
  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;
  visibility: visible; opacity: 1; scale: 1; translate: 0 0;
  transition: opacity 200ms var(--ease-out), scale 200ms var(--ease-out),
              translate 200ms var(--ease-out), visibility 0s linear 0s;
}
/* ⚠ 40px PAINTED, 44px TAPPED — the .tap-min pattern from tokens.css. A 44px navy disc beside
   a 32px title is a slab; the hit area is extended past the drawing instead. */
.codebtn::after {
  content: ""; position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: var(--tap-min); height: var(--tap-min);
}
.codebtn svg { width: 20px; height: 20px; display: block; max-width: none; }
[data-ready] .codebtn { transition: transform var(--t-press) var(--ease-out),
                                    opacity 200ms var(--ease-out), scale 200ms var(--ease-out),
                                    translate 200ms var(--ease-out), visibility 0s linear 0s; }
.codebtn:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }

/* ⚠⚠ IT LEAVES WHEN THE TITLE ROW DOES, AND THAT IS A POSITION TEST RATHER THAN A DIRECTION
   ONE. It used to hide on `.nav-mini`, which the tab bar sets by scroll DIRECTION — so nudging
   the page up fifteen pixels anywhere brought it back over the middle of a list, which is the
   state Syd screenshotted. The button belongs to the screen's title: `.screen-head` reserves
   room in that row for it, and it is visible when that row is. header.js owns the test.
   ⚠ visibility, NOT OPACITY ALONE, AND THE 200ms DELAY IS THE POINT. An invisible button that
     is still focusable is a keyboard trap. `visibility` is discrete, so with a delay equal to
     the fade it stays `visible` all the way out and flips at the end; coming back it flips at
     once, because the rule above carries no delay. */
.screen.head-gone .codebtn {
  opacity: 0; scale: .9; translate: 0 -6px;
  visibility: hidden; pointer-events: none;
  transition: opacity 200ms var(--ease-out), scale 200ms var(--ease-out),
              translate 200ms var(--ease-out), visibility 0s linear 200ms;
}
/* The Shop owns this corner with its cart — app.js hides the code button for that tab. */
.codebtn.is-hidden { visibility: hidden; pointer-events: none; }
.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 20px 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 20px; 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."

   ⚠⚠ HE 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 THE MOMENT YOU SCROLL, on the same signal that shrinks
           the nav — nav.js marks `.screen` and this reads it. 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 MOVES. The nav shrinks on
     scroll; anchored to the nav's live height this would slide up and down on every direction
     change, which is a moving target the eye tracks and the finger chases.
   ⚠ 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: absolute; z-index: 59;
  left: 10px; right: 10px; bottom: calc(82px + var(--bottom-inset));
  width: fit-content; max-width: calc(100% - 20px); margin-inline: auto;
  display: flex; align-items: center; gap: 9px;
  min-height: 42px; padding: 5px 11px 5px 5px;
  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;
  translate: 0 0;
  transition: padding 280ms var(--ease-out), gap 280ms var(--ease-out),
              translate 260ms var(--ease-out);
}

/* ⚠⚠ IT LIFTS WHEN THE TABS ARE 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. */
.screen.nav-open > .livepill { translate: 0 -18px; }

/* ⚠ 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(146px + 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; }

/* ⚠ A 0fr GRID COLUMN THAT BECOMES 1fr — the same trick as the nav's labels, for the same
   reason: `width: auto` cannot be transitioned and a max-width guess is wrong at some text
   size or in some language. The inner span needs 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: grid-template-columns 280ms var(--ease-out), opacity 200ms var(--ease-out);
}
.livepill .lp-line {
  min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
  font: 600 13.5px/1.2 var(--font-ui); letter-spacing: -.004em; color: var(--ink);
}
.livepill .lp-go {
  flex: none; display: grid; place-items: center; color: var(--ink-tertiary);
  transition: opacity 200ms var(--ease-out), width 280ms var(--ease-out);
  width: 16px; overflow: hidden;
}
.livepill .lp-go svg { width: 16px; height: 16px; }

/* ── Collapsed. Everything but the glyph goes to zero width. ── */
.screen.nav-mini > .livepill { padding: 5px; gap: 0; }
.screen.nav-mini > .livepill .lp-text { grid-template-columns: 0fr; opacity: 0; }
.screen.nav-mini > .livepill .lp-go { width: 0; opacity: 0; }

/* ⚠ 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) {
  .screen > .livepill, .livepill .lp-text, .livepill .lp-go { transition-property: opacity; }
}

/* ══ 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. */
.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);
}
.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(84px + var(--bottom-inset)); }

  /* ⚠ `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. */
    left: 8px; right: 8px;
    bottom: calc(10px + var(--bottom-inset));
  }
  /* ⚠ 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; }
  /* Mini keeps the 44px buttons and gives up the last of the padding: 54 → 46. */
  .fnav.is-mini { padding: 0 4px; max-width: 292px; }
  .fnav.is-mini .fnav-ico, .fnav.is-mini .fnav-ico svg { width: 19px; height: 19px; }

  /* ⚠ 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 {
    bottom: calc(72px + var(--bottom-inset));
    min-height: 38px; padding: 4px 10px 4px 4px; 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. */
  .screen.nav-open > .livepill { translate: 0 -13px; }
  .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(126px + var(--bottom-inset)); }
}

/* ══ 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); }
}
