/* site/app/ui.css — the Apple interface layer, in The Rally Grounds' colours.
 * Depends on tokens.css. Load tokens.css first.
 *
 * The components here are the iOS vocabulary: a large title that collapses into a compact
 * translucent bar, grouped inset lists, a segmented control, a bottom tab bar, sheets.
 * They are here because they are what people already know — familiarity is a design
 * principle, not a shortcut. A player who has used any iPhone app can already use this one.
 */

/* ⚠⚠ THE ONE !important IN THIS FILE, AND IT IS FIXING A HOLE IN THE UA STYLESHEET.
   The browser's own rule is a bare `[hidden] { display: none }` — no class, no element name —
   so ANY rule of ours that sets `display` on a class outranks it and the element stays on
   screen with `hidden` sitting on it doing nothing. That is not hypothetical: the pro shop's
   search field shipped with a clear button that could not be hidden, offering to clear an
   empty field, because its own rule said `display: grid`. `el.hidden = true` is used in
   login.js, flow.js and cafe.js as well, and every one of them is one `display:` away from
   the same silent failure. One line here closes it everywhere instead of per component. */
[hidden] { display: none !important; }

/* ── The device frame, for the preview only ───────────────────────────────── */
/* ⚠ The literal colours in .stage and .phone are the ONLY sanctioned raw hex in this file,
   and only because they are a photograph of a black phone on a dark desk: they are the same
   plastic under both themes, so a theme token would be wrong here, not right. Nothing inside
   .screen may follow this precedent — everything a player actually looks at is a token. */
.stage {
  min-height: 100dvh;
  background:
    radial-gradient(120% 90% at 50% -10%, #0A3057 0%, transparent 60%),
    var(--navy);
  padding: var(--s-6) var(--s-4) var(--s-8);
}

.phone {
  width: 390px; max-width: 100%;
  height: 844px; max-height: 84dvh;
  border-radius: 46px;
  background: var(--bg);
  position: relative; overflow: hidden;
  box-shadow:
    0 0 0 11px #0B1B2B,
    0 0 0 13px #223448,
    0 40px 90px -20px rgba(0,0,0,.7);
  display: flex; flex-direction: column;
  isolation: isolate;
  /* ⚠ A flex item's default min-width is `auto`, not 0 — it refuses to shrink below its
     content no matter what max-width says. Without this, the frame hangs off the LEFT of a
     355px screen where nothing can scroll to reach it. See the matching note in shell.css. */
  min-width: 0;
}

/* The notch. Present because the safe-area padding below only reads as considered when
   there is visibly something to avoid. */
.phone::before {
  content: ""; position: absolute; z-index: 60;
  top: 9px; left: 50%; transform: translateX(-50%);
  width: 122px; height: 33px; border-radius: 20px; background: #000;
}

.screen { flex: 1; display: flex; flex-direction: column; min-height: 0; position: relative; }

/* ── Navigation: the large title that collapses ───────────────────────────── */
/* ⚠ The compact bar is TRANSLUCENT and content scrolls UNDER it — not an opaque strip that
   eats 44px of a phone screen. The bright top edge is light catching the material. */
.navbar {
  position: absolute; inset: 52px 0 auto 0; z-index: 40;
  height: 46px; display: flex; align-items: center; justify-content: center;
  background: var(--chrome);
  backdrop-filter: var(--chrome-blur); -webkit-backdrop-filter: var(--chrome-blur);
  border-bottom: 1px solid transparent;
  opacity: 0; pointer-events: none;
  transition: opacity 200ms var(--ease-out), border-color 200ms var(--ease-out);
}
.navbar.is-shown { opacity: 1; pointer-events: auto; border-bottom-color: var(--rule); }
.navbar h2 {
  font: 600 17px/1 var(--font-ui); letter-spacing: -0.012em;
  color: var(--ink); margin: 0;
}

.scroll {
  flex: 1; overflow-y: auto; overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  padding: 0 0 calc(var(--s-8) + 64px);
}

.largetitle {
  padding: 8px 20px 10px;
  font-family: var(--font-display); font-weight: 600;
  font-size: 34px; line-height: 1.08; letter-spacing: -0.02em;
  color: var(--ink); text-wrap: balance;
}
.largetitle .sub {
  display: block; margin-top: 4px;
  font: 400 15px/1.4 var(--font-ui); letter-spacing: 0;
  color: var(--ink-tertiary);
}

/* ── Grouped inset list ───────────────────────────────────────────────────── */
.group { margin: 0 16px 22px; }
.group-label {
  font: 600 12px/1 var(--font-ui); letter-spacing: .06em; text-transform: uppercase;
  color: var(--ink-tertiary); padding: 0 6px 8px;
}
.list {
  background: var(--bg-raised); border-radius: var(--r-lg);
  overflow: hidden; box-shadow: var(--shadow-1);
}
.row {
  display: flex; align-items: center; gap: 12px;
  padding: 13px 16px; min-height: var(--tap-min);
  border-bottom: 1px solid var(--rule);
  transition: transform var(--t-press) var(--ease-out),
              background-color var(--t-press) var(--ease-out);
  touch-action: manipulation; -webkit-tap-highlight-color: transparent;
}
.row:last-child { border-bottom: 0; }
.row .grow { flex: 1; min-width: 0; }
.row .title { font: 500 16px/1.3 var(--font-ui); letter-spacing: -.006em; color: var(--ink); }
.row .meta  { font: 400 13px/1.35 var(--font-ui); color: var(--ink-tertiary); margin-top: 2px; }
.row .value { font: 400 16px/1 var(--font-ui); color: var(--ink-tertiary); font-variant-numeric: tabular-nums; }

/* ⚠ THE CHEVRON IS A GLYPH, NOT A BUTTON. 8×13px is a decoration the size of a full stop; if
   it is ever the thing being tapped it is a 13px target. The whole .row is the target — it is
   44px tall — so the chevron must stay pointer-transparent, or a thumb landing on the arrow
   (which is exactly where people aim) hits nothing.
   Opacity .3 on --ink was also doing what --ink-tertiary used to do: it made the one glyph
   that says "this row goes somewhere" the faintest mark on the row. .55 keeps it quiet and
   keeps it visible on a sunlit court. */
.chev { width: 8px; height: 13px; flex: none; opacity: .55; pointer-events: none; }

/* Icon-only control — a close button, an Undo, a stepper. It looks 34px and taps 44px.
   ⚠ Give it an aria-label. An icon with no accessible name is a button a screen reader
   announces as "button", which is the same as announcing nothing. */
.icon-btn {
  position: relative;
  display: inline-grid; place-items: center;
  width: 34px; height: 34px; flex: none;
  border: 0; padding: 0; border-radius: var(--r-full);
  background: var(--bg-sunken); color: var(--ink); cursor: pointer;
  touch-action: manipulation; -webkit-tap-highlight-color: transparent;
  transition: transform var(--t-press) var(--ease-out),
              background-color var(--t-press) var(--ease-out);
}
.icon-btn::after {
  content: ""; position: absolute;
  top: 50%; left: 50%; transform: translate(-50%, -50%);
  width: var(--tap-min); height: var(--tap-min);
}
.icon-btn:active { transform: scale(0.94); }
.icon-btn svg { width: 17px; height: 17px; }

@media (hover: hover) and (pointer: fine) {
  .row[data-press]:hover { background: var(--bg-sunken); }
}

/* ── Segmented control ────────────────────────────────────────────────────── */
.segmented {
  display: flex; margin: 0 16px 20px; padding: 2px; gap: 2px;
  background: var(--bg-sunken); border-radius: 10px; position: relative;
}
/* ⚠ 30px PAINTED, 44px TAPPED. The Cafe Coffee/Cold/Food switcher and the History filter are
   both this control, and both measured 30px tall — a switcher you have to look at twice while
   holding a tray. Making the control 48px would turn a light iOS segment into a slab, so the
   missing height goes on the invisible overlay instead (see .tap-min in tokens.css).
   ⚠ Vertical only. The segments are side by side, so an overlay wider than the button would
   steal the neighbour's edge; flex:1 keeps every segment far wider than 44px anyway. */
.segmented button {
  position: relative;
  flex: 1; border: 0; background: transparent; z-index: 1;
  font: 500 14px/1 var(--font-ui); letter-spacing: -.006em; color: var(--ink);
  padding: 8px 4px; border-radius: 8px; cursor: pointer;
  touch-action: manipulation; -webkit-tap-highlight-color: transparent;
}
.segmented button::after {
  content: ""; position: absolute; left: 0; right: 0;
  top: 50%; transform: translateY(-50%);
  height: var(--tap-min);
}
/* Selected carries weight and ink, not just the moving thumb behind it — the thumb is a
   surface tint, and a surface tint is the first thing to disappear in direct sun. */
.segmented button[aria-selected="true"] { font-weight: 600; color: var(--ink); }
.segmented .thumb {
  position: absolute; top: 2px; bottom: 2px; left: 2px;
  background: var(--bg-raised); border-radius: 8px; box-shadow: var(--shadow-1);
  z-index: 0; will-change: transform, width;
}

/* ── Buttons ──────────────────────────────────────────────────────────────── */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  min-height: var(--tap-min); padding: 13px 22px; border: 0; border-radius: var(--r-full);
  font: 600 17px/1 var(--font-ui); letter-spacing: -.012em; cursor: pointer;
  touch-action: manipulation; -webkit-tap-highlight-color: transparent;
  transition: transform var(--t-press) var(--ease-out), background-color var(--t-press) var(--ease-out);
}
.btn-primary { background: var(--action); color: var(--action-ink); }
.btn-quiet   { background: var(--bg-sunken); color: var(--ink); }
.btn-full    { display: flex; width: 100%; }

/* ⚠ A NAVY FILLED BUTTON, AND DELIBERATELY NOT ORANGE. Orange in this app means "this costs
   money and commits you", so a free waitlist place, a finished-with-this Done and a new game
   on a court all get navy instead.
   ⚠⚠ IT USED TO BE `.bk .btn-navy` IN book.css, WHICH MADE IT A LIE THE MOMENT A SECOND
     SCREEN USED THE CLASS. event.js's "New match" and "Scan a player" carried the name and
     none of the styling — invisible on Chrome, where an unstyled <button> is grey and passes
     for a quiet variant, and obvious on Safari, where it is grey with the UA's blue link text.
     A generic class name belongs in the generic stylesheet.
   ⚠ THE DARK BRANCH IS NOT OPTIONAL. --navy IS the page ground at night, so without it this
     button is a cream label floating on nothing. --navy-70 is a step up from the page; the
     inset hairline gives it an edge, because a 1.6:1 fill on its own reads as a gap.
   ⚠ `:root:not([data-theme="light"])` MUST SIT INSIDE THE prefers-color-scheme QUERY. On its
     own it also matches a light-mode page — "no data-theme attribute" is not
     "data-theme=light" — so the dark treatment would apply in BOTH themes and the button
     would never be the colour this comment says it is. */
.btn-navy { background: var(--navy); color: var(--cream); }
.btn-navy:active { background: var(--navy-90); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .btn-navy {
    background: var(--navy-70); color: var(--cream);
    box-shadow: inset 0 0 0 1px var(--rule);
  }
}
:root[data-theme="dark"] .btn-navy {
  background: var(--navy-70); color: var(--cream);
  box-shadow: inset 0 0 0 1px var(--rule);
}

/* ── Live badge. Chartreuse is ENERGY, never "success". ───────────────────── */
.live {
  display: inline-flex; align-items: center; gap: 6px;
  font: 600 11px/1 var(--font-ui); letter-spacing: .08em; text-transform: uppercase;
  color: var(--navy); background: var(--live);
  padding: 5px 9px 5px 7px; border-radius: var(--r-full);
}
.live .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--navy); }
@media (prefers-reduced-motion: no-preference) {
  .live .dot { animation: pulse 2s var(--ease-in-out) infinite; }
}
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .25 } }

/* ── Status tags. The replacement for orange-as-state. ────────────────────── */
/* ⚠ THIS EXISTS SO NO SCREEN HAS TO REACH FOR --action AGAIN. "2 LEFT", "CART · NOT PAID",
   "SOLD OUT" were all orange words on a pale ground — 2.24 against --bg, the lowest-contrast
   text on the checkout screen, on the one flow where the copy has to be believed. A tag is
   not a button; it should not wear the button's colour.
   Default to .tag. Reach for .tag-attn only when a status genuinely has to pull the eye —
   if everything is attention, nothing is. */
.tag {
  display: inline-flex; align-items: center; gap: 5px;
  font: 600 11px/1 var(--font-ui); letter-spacing: .07em; text-transform: uppercase;
  padding: 5px 9px; border-radius: var(--r-full);
  background: var(--tag-bg); color: var(--tag-ink);
}
.tag-attn { background: var(--attn-bg); color: var(--attn-ink); }
.tag-warn { background: transparent; color: var(--warn); box-shadow: inset 0 0 0 1px var(--warn); }
.tag-ok   { background: transparent; color: var(--ok);   box-shadow: inset 0 0 0 1px var(--ok); }

/* ── Selected ─────────────────────────────────────────────────────────────── */
/* ⚠ COLOUR *AND* A WORD, always — a picked slot marked only by a tick has no word at all,
   because the tick replaced it. Keep "Picked" next to the glyph. */
.selected      { background: var(--selected-bg); color: var(--selected-ink); }
.selected-soft {
  background: var(--selected-soft-bg); color: var(--selected-soft-ink);
  box-shadow: inset 0 0 0 1.5px var(--selected-edge);
}

/* ── Tab bar ──────────────────────────────────────────────────────────────── */
.tabbar {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 45;
  display: flex; padding: 8px 8px calc(8px + 18px);
  background: var(--chrome);
  backdrop-filter: var(--chrome-blur); -webkit-backdrop-filter: var(--chrome-blur);
  border-top: 1px solid var(--rule);
}
/* ⚠ EQUAL WIDTH, FIXED ICON. flex:1 with a fixed basis, not flex:1 alone: without the basis
   the seven buttons are sized by their labels, so the gaps between them come out uneven and
   the icons get squeezed by different amounts — and when the bar expands, the icon you just
   pressed slides out from under your finger. */
.tab {
  flex: 1 1 0; min-width: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 3px;
  border: 0; background: transparent; cursor: pointer; padding: 5px 0;
  min-height: var(--tap-min);
  font: 500 10px/1 var(--font-ui); letter-spacing: .01em;
  color: var(--ink-tertiary);
  touch-action: manipulation; -webkit-tap-highlight-color: transparent;
  transition: color var(--t-press) var(--ease-out);
}

/* ⚠ THE ACTIVE TAB WAS THE FAINTEST THING IN THE BAR. Orange on pale chrome measures 2.24
   against --bg — genuinely harder to read than the grey tabs either side of it — and when
   the bar collapses to icons there is no label, so hue was the ONLY signal of where you are.
   DESIGN.md §2: every state has a colour, a word, and where it is an icon a glyph.
   So: full-strength ink, a heavier label, and a filled pill behind the glyph. The pill is
   drawn with box-shadow spread rather than padding, so the icon's own geometry never moves —
   padding here would shrink the glyph under `box-sizing: border-box` and re-introduce the
   squashed-icon problem this bar already has. Read it in greyscale: the pill still wins. */
.tab[aria-selected="true"] { color: var(--ink); font-weight: 600; }
.tab[aria-selected="true"] svg {
  border-radius: var(--r-full);
  background: var(--selected-soft-bg);
  color: var(--selected-soft-ink);
  box-shadow: 0 0 0 6px var(--selected-soft-bg),
              0 0 0 7px var(--selected-edge);
}
/* ⚠ Fixed, not min/max. An icon that is allowed to flex is an icon that renders 41% narrower
   than it is tall at 320px and visibly looks squashed. */
/* The margin reserves the room the pill's 7px spread needs. Applied to every tab, not only
   the selected one, so the labels stay on one baseline as the selection moves. */
.tab svg { width: 25px; height: 25px; flex: none; margin: 4px 0 5px; }

/* The home indicator. */
.homebar {
  position: absolute; z-index: 46; bottom: 7px; left: 50%; transform: translateX(-50%);
  width: 138px; height: 5px; border-radius: 3px; background: var(--ink); opacity: .28;
}

/* ── Sheet ────────────────────────────────────────────────────────────────── */
.scrim {
  position: absolute; inset: 0; z-index: 70; background: rgba(0,10,22,.42);
  opacity: 0; backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
}
.sheet-panel {
  position: absolute; z-index: 71; left: 0; right: 0; bottom: 0;
  background: var(--bg-raised);
  border-radius: var(--r-xl) var(--r-xl) 0 0;
  box-shadow: 0 -8px 40px rgba(0,0,0,.28);
  padding: 10px 20px calc(20px + 22px);
  will-change: transform; touch-action: none;
}
.grabber {
  width: 36px; height: 5px; border-radius: 3px; background: var(--ink);
  opacity: .18; margin: 0 auto 14px;
}

/* ── Cards ────────────────────────────────────────────────────────────────── */
.card {
  background: var(--bg-raised); border-radius: var(--r-lg);
  box-shadow: var(--shadow-1); padding: 18px; margin: 0 16px 16px;
}
/* ⚠ A FIXED-NAVY CARD IS THE ONE PLACE A BRAND COLOUR IS ALLOWED TO BE A SURFACE — it is an
   identity moment (DESIGN.md §2, "navy ground, cream ink"), navy in both themes on purpose.
   Which means everything on it has to come from the on-navy pair, not from --ink: --ink is
   cream in dark mode, and cream text on a navy card is right, but --ink-tertiary is a pale
   blue-grey chosen against a navy *page* and it does not hold up here. */
.card.navy { background: var(--navy); color: var(--on-navy); box-shadow: var(--shadow-2); }
.card.navy .muted { color: var(--on-navy-dim); }

/* ⚠ AT NIGHT --bg IS ALSO --navy, SO THIS CARD STOPS BEING A CARD — a flat rectangle with a
   shadow and nothing else, which is exactly how finding 6's "YOU'RE NEXT UP" card came to sit
   invisibly on the page. Two screens had already patched their own copy (book's explainer,
   proshop's receipt) and a third could not, because it is plain markup in preview-v1.html
   with no stylesheet of its own. Fixed here so every navy card behaves the same after dark;
   the two screen-level copies are now redundant but harmless — they declare the same thing.
   The ink stays --on-navy: cream still reads on the lifted surface. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .card.navy {
    background: var(--bg-raised); border: 1px solid var(--rule);
  }
}
:root[data-theme="dark"] .card.navy {
  background: var(--bg-raised); border: 1px solid var(--rule);
}

.hero-number {
  font-family: var(--font-display); font-weight: 600;
  font-size: 76px; line-height: .9; letter-spacing: -.04em;
  font-variant-numeric: tabular-nums;
}

.divider { height: 1px; background: var(--rule); margin: 14px 0; }
.muted { color: var(--ink-tertiary); }
.mono-nums { font-variant-numeric: tabular-nums; }

/* Avatar */
.avatar {
  width: 40px; height: 40px; border-radius: 50%; flex: none;
  display: grid; place-items: center; overflow: hidden;
  background: var(--navy-70); color: var(--cream);
  font: 600 15px/1 var(--font-ui); letter-spacing: -.01em;
}
.avatar.lg { width: 108px; height: 108px; font-size: 40px; }

/* ── Reduced motion ───────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
  .navbar { transition-duration: 1ms; }
  .live .dot { animation: none; }
  /* Gentler, not absent: the press still answers, it just answers with colour instead of
     movement. Losing the feedback entirely would read as a dead button. */
  .icon-btn:active { transform: none; background: var(--rule); }
  * { scroll-behavior: auto !important; }
}
@media (prefers-reduced-transparency: reduce) {
  .navbar, .tabbar { background: var(--bg); backdrop-filter: none; -webkit-backdrop-filter: none; }
}


/* ── Status bar ───────────────────────────────────────────────────────────── */
.statusbar {
  height: 52px; flex: none; display: flex; align-items: flex-end;
  justify-content: space-between; padding: 0 26px 2px;
  font: 600 14px/1 var(--font-ui); letter-spacing: -0.01em;
  color: var(--ink); position: relative; z-index: 50;
}
.statusbar .glyphs { display: flex; gap: 5px; align-items: center; }
.statusbar svg { display: block; }

/* ══ ON A REAL PHONE, THERE IS NO PHONE ══════════════════════════════════════
   Syd, 2026-09-03: "i want to preview the app on my phone, like i want it to be the actual
   app. not a phone on my phone."

   ⚠ EVERYTHING ABOVE THIS BLOCK IS A PHOTOGRAPH OF A DEVICE, AND A PHOTOGRAPH OF A DEVICE
     SHOWN ON THAT DEVICE IS ABSURD — a 390px mock inside a 390px screen, with a drawn notch
     under the real notch and a painted 7:41 under the real clock. Worse than absurd: it
     shrinks every target and every line of type by the width of two bezels, so the one thing
     the preview exists to test — whether this is comfortable in a hand — is the one thing it
     cannot answer.
   ⚠ THE BREAKPOINT IS 700px BECAUSE THAT IS WHERE THE MOCK STOPS FITTING ANYWAY. Below it the
     frame is already being squeezed by shell.css's own shrink-to-fit rule, so nothing is lost;
     above it there is room for the desk, and the desk is genuinely useful for reviewing.
   ⚠⚠ THIS BLOCK IS AT THE END OF THE FILE AND THAT IS NOT TIDINESS. It first sat next to
     `.screen`, forty lines ABOVE `.statusbar` — so `.statusbar { display: flex }` came later
     at equal specificity and won, and the painted clock stayed on screen while the frame
     around it correctly vanished. A media query does not raise specificity; only order and
     selector weight do. Overrides live last.
   ⚠ AND `.previewbar` IS NOT HIDDEN FROM HERE AT ALL. Its rules are in index.html's own
     <style>, which the browser reads after every stylesheet this file is in, so nothing here
     can beat them at equal weight. That hide belongs beside its own rules, and it is there.
   ⚠ THE FAKE STATUS BAR GOES AND ITS 52px MUST BE REPLACED, NOT SIMPLY DROPPED. It was doing
     real work: every screen's first line sits under it. `.screen` takes the device's own
     safe-area inset instead, with an 8px floor for phones that report none — otherwise the
     first heading starts hard against the top edge on a flat-topped Android. */
@media (max-width: 700px) {
  .stage {
    min-height: 100dvh; padding: 0; background: var(--bg);
  }
  /* ⚠⚠ 100lvh, NOT 100dvh, AND THE DIFFERENCE IS THE WHOLE ANSWER TO ONE OF SYD'S COMPLAINTS.
     2026-09-04: "for safari on the newer iphones specifically … the area behind the address
     bar is not transparent." Safari 26 floats its address bar over the page as glass and
     blurs whatever is behind it — so a page that stops exactly where the bar begins gives it
     nothing to blur, and the result is a flat slab of --bg that reads as an opaque strip.
     100dvh is precisely "stop where the bar begins". 100lvh is the viewport WITH the bar
     collapsed, so the app paints the 40px underneath it and the glass finally has content to
     be glass over. Verified on an iPhone 17 / iOS 26.5: 100lvh 754 against 100dvh 714.
     ⚠ THE 40px IS NOT LOST — it was never usable. The tab bar and the live pill both clear it
       through `--bottom-inset` (tokens.css), which is the same live 100lvh − 100dvh.
     ⚠ AND THE dvh LINE STAYS ABOVE IT. A browser without lvh drops the second declaration and
       keeps the first, which is the behaviour this had all along. */
  .phone {
    width: 100%; max-width: none;
    height: 100dvh;
    height: 100lvh;
    max-height: none;
    border-radius: 0; box-shadow: none;
  }
  .phone::before { display: none; }        /* the drawn notch */
  .statusbar { display: none; }            /* the painted clock */
  .screen { padding-top: max(var(--safe-top), 12px); }
  /* ⚠⚠ THE CORNER BUTTON MOVES WITH THE TITLE, AND FORGETTING IT WAS VISIBLE IMMEDIATELY.
     Its desktop `top` means "below the 52px status bar". Take the status bar away and the
     title rises 52px while the button stays put — it landed a row lower, on the month strip.
     It is not enough to delete the thing above something; anything positioned against it has
     to be told.
     ⚠⚠ `.codebtn`, AND IT WAS `.hdr` UNTIL THE PROFILE MOVED INTO THE TAB BAR ON 2026-09-04.
       The rename left this override matching NOTHING, so the phone fell back to the desktop
       offset and the button sat about 40px too low, directly on the Courts month chips. A
       selector that stops matching is silent — nothing errors, nothing warns, the control just
       appears somewhere else. Renaming a class means grepping every stylesheet for it.
     ⚠ THE ARITHMETIC, AND IT MOVED WITH THE BUTTON'S SIZE. The title's first line starts at
       (`.screen`'s padding-top) + 6px of `.screen-head` padding and is 34.56px tall, so a
       40px disc CENTRED on that line sits (34.56 − 40) / 2 = −2.72px from the line's top.
       6 − 2.72 = 3.28 → +3px on top of whatever the padding turns out to be. It was +1 when
       the corner was 44px tall; the same sum, re-run.
     ⚠ `.screen .codebtn` (0,2,0) NOT `.codebtn` (0,1,0): screen stylesheets load AFTER this
       file, so a bare class here can lose the cascade silently. */
  .screen .codebtn { top: calc(max(var(--safe-top), 12px) + 3px); }
}

/* Installed to the home screen, the same is true at ANY width — a tablet running this
   standalone is the app too, not a review page. `display-mode: standalone` is the only
   signal that distinguishes the two, and it is worth honouring: somebody who added it to
   their home screen has already said which one they meant. */
@media (display-mode: standalone) {
  .stage { min-height: 100dvh; padding: 0; background: var(--bg); }
  /* Installed there is no browser bar, so lvh and dvh are the same number — the second line
     changes nothing here and is kept so the two blocks cannot drift apart. */
  .phone {
    width: 100%; max-width: none;
    height: 100dvh;
    height: 100lvh;
    max-height: none;
    border-radius: 0; box-shadow: none;
  }
  .phone::before, .statusbar { display: none; }
  .screen { padding-top: max(var(--safe-top), 12px); }
  /* Same arithmetic as the width query above — see the note there. */
  .screen .codebtn { top: calc(max(var(--safe-top), 12px) + 3px); }
}

/* ══ THE LAUNCH SCREEN ══════════════════════════════════════════════════════
   Syd, 2026-09-04: the paceui dot-loader, "in brand colors for both light and dark mode …
   also include the brand logo". Markup in index.html, animated by app/preloader.js.

   ⚠ IN ui.css, NOT A FILE OF ITS OWN, BECAUSE IT MUST BE IN THE FIRST STYLESHEET THE BROWSER
   ALREADY HAS. A launch screen that arrives one request late is a flash of unstyled crest —
   the exact thing it exists to prevent.

   ⚠ IT COVERS `.screen`, NOT THE VIEWPORT. On a phone those are the same thing; on the desk
   preview `.screen` is the inside of the device frame, which is where a launch screen belongs
   — a loader spread across the navy desk would be loading the PAGE, and the page is already
   there. `.phone` carries `overflow: hidden` and its own radius, so nothing spills.

   ⚠ z-index 80 — OVER EVERYTHING THE SHELL OWNS. The nav is 60, the live pill 59, the event
   page 65, a sheet 71. A launch screen under any of them is a launch screen with a tab bar
   drawn on top of it. */
.boot {
  position: absolute; inset: 0; z-index: 80;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 20px;
  background: var(--bg);
  opacity: 1;
  transition: opacity 320ms var(--ease-out), scale 420ms var(--ease-out);
}
/* ⚠ IT SCALES UP AS IT GOES, NOT DOWN. Leaving "into" the screen is how a launch screen hands
   over to the app behind it; shrinking away would read as the app being dismissed. `scale` and
   not `transform`, so nothing here can be clobbered by a press animation. */
.boot.is-out { opacity: 0; scale: 1.04; pointer-events: none; }

/* ⚠⚠ THE CREST IS THE OVAL, NOT THE OVAL IN A BADGE. Syd, 2026-09-04: "dont put the logo in a
   badge." The first version used crest-400.png at 96px with a 22% radius and a shadow, which
   made an app icon out of a logo that already has its own shape.
   ⚠ IT NEEDED A NEW ASSET AND THAT IS WHY THE BADGE EXISTED. Every crest PNG in this repo is
     a SOLID NAVY PLATE, alpha 255 in every pixel, with the oval printed on it — checked, not
     assumed. Dropping the rounding alone would have left a hard navy square on the cream page.
     `crest-oval.png` was cut from the 4500px master: measured the ring's outer bounds
     (x 62–337, y 107–292 of the 400px art, dead centre), cropped to it, and made everything
     outside the ellipse transparent with 4×4 supersampling so the edge is not a staircase.
   ⚠ AND IT WORKS IN BOTH THEMES BECAUSE OF WHAT IS INSIDE THE RING. The oval's own fill is
     navy: on cream it reads as the logo on its own, and on the navy page the fill merges with
     the ground and leaves the orange ring and the cream lettering floating — "navy ground,
     cream ink", which is DESIGN.md's own instruction for an identity moment.
   ⚠⚠ 116px, DOWN FROM 156, DOWN FROM 232. Syd asked for smaller twice; the reason is
     proportion, not taste. At 232 the mark was 62% of a 375px phone and the 54px board under
     it was less than a quarter of its width, so the crest read as the SCREEN and the animation
     as a footnote. At 116 it is 31% — the eye takes it in whole instead of reading across it,
     and the mark and the game finally sit in a sane ratio to each other. That is also where
     launch marks generally land: small enough to be a signature rather than an illustration.
   ⚠ NO SHADOW. A shadow is what a badge sitting ON something has. */
.boot-crest {
  width: 116px; max-width: 34%; height: auto;
  display: block;
}

/* ── The rally ──────────────────────────────────────────────────────────────
   ⚠⚠ A COURT, NOT A DOT GRID. Syd, 2026-09-04: "make it look like an actual pickleball game
   is being played not a hybrid pong." The dot version could not get there by tuning: a square
   board with paddles on two edges is pong by construction. This is the real court — same
   geometry as courtSvg() in event.js, 2 units per foot — with four players in their service
   boxes and a ball in a four-shot rally.

   ⚠⚠ THE WHOLE THING IS CSS, WHICH IS NOT A STYLE CHOICE. Every script on the page is a
   module and therefore deferred, so a JS-driven loader cannot start until after the parse it
   exists to cover. A CSS animation on static markup is already running at first paint. The
   dot version had this backwards and spent its first frames sitting still.

   ⚠ ONE 2.2s TIMELINE SHARED BY FIVE ELEMENTS. The ball's four shots are at 0, 25, 50 and 75%;
   each player's lunge is the SAME keyframes at a delay matching their own contact. Sharing one
   duration is what keeps them a rally — separate durations drift apart within a few loops and
   the players start lunging at nothing. */
/* ⚠⚠ EXACTLY THE CREST'S WIDTH, AND THE max-width MATCHES TOO. Syd, 2026-09-04: "make the
   animation smaller (same width as the logo)". Both numbers have to agree or they only line up
   at one screen width — the percentage is what keeps them equal on a narrow phone, where the
   cap is what is actually governing. If the crest is ever resized, this moves with it. */
.boot-court {
  width: 116px; max-width: 34%; height: auto; display: block;
  overflow: visible;
}

.bc-floor { fill: var(--bg-sunken); stroke: var(--ink-tertiary); stroke-width: .8; }
/* ⚠ 7%, NOT 11%. The kitchen is a marked area, not a wall — at 11% on this small a court the
   two zones read as a dark slab down the middle with the net lost inside it. */
.bc-kitchen { fill: color-mix(in srgb, var(--ink) 7%, transparent); }
/* ⚠ --ink-tertiary, NOT --rule. `--rule` is a 20%-alpha hairline for separating rows of text;
   at 0.8 units in an 88-wide box that is a quarter-opacity half-pixel and the court comes out
   as a grey smudge with no lines in it. event.css carries the same note for the same reason. */
.bc-line { stroke: var(--ink-tertiary); stroke-width: .8; opacity: .75; }
.bc-net { stroke: var(--ink); stroke-width: 1.5; }

/* ⚠⚠ ONE TEAM FILLED, ONE OUTLINED, BECAUSE FOUR IDENTICAL DOTS IS NOT TWO TEAMS. Filled
   against hollow is the distinction that survives greyscale, colourblindness and a 6px mark —
   DESIGN.md §2's rule applied to a shape rather than a colour, which is the only version of it
   that works at this size.
   ⚠ r 3.4, DOWN FROM 4.2. At the larger radius each player was a fifth of the court's width
   and the four of them crowded out the lines they are meant to be standing on. */
.bc-p { fill: var(--ink); opacity: .9; }
.bc-p3, .bc-p4 { fill: var(--bg-sunken); stroke: var(--ink); stroke-width: 1.2; }

/* ⚠ CHARTREUSE, WITH AN OUTLINE. A pickleball IS this colour and the crest above it has two of
   them; the outline is what stops a --live dot dissolving into the cream page, which is the
   contrast problem that kept chartreuse off the dot version. */
.bc-ball circle { fill: var(--live); stroke: var(--navy); stroke-width: .7; }

/* ⚠⚠ THE BALL TRAVELS STRAIGHT, BECAUSE THIS IS A PLAN VIEW. Drawing a curved arc here would
   be a side-on shot pasted into a top-down court. Height is shown the only way a plan can show
   it — the ball SWELLS in flight and shrinks at contact, which is also what sells the four
   moments of paddle contact without drawing a paddle.
   ⚠ THE FOUR SHOTS ALTERNATE CROSS-COURT AND DOWN-THE-LINE. Four identical exchanges read as a
   metronome; this is the pattern a real rally falls into, and it is why all four players get
   the ball rather than two of them watching. */
/* ⚠⚠ THE SHOTS ARE NOT EQUAL LENGTHS OF TIME, AND THAT IS MOST OF WHAT MAKES IT A RALLY. Syd,
   2026-09-04: "the ball can vary in speed between targets." Four equal quarters is a
   metronome. The cross-court shots take 22% and 20% of the cycle over ~39 units; the
   down-the-line shots take 30% and 28% over 36. That is roughly 50% more pace on the crosses —
   a drive and a dink, which is the pattern a real rally alternates between.

   ⚠⚠ AND EACH SHOT IS FAST-THEN-SLOW, NOT SLOW-FAST-SLOW. It was `ease-in-out` on the whole
   animation, which decelerates INTO every player and accelerates back out — so the ball
   loitered at the paddle exactly where Syd said it should not: "the ball should not hover over
   the player for too long since they are hitting the ball." A struck ball leaves at its top
   speed and bleeds pace the whole way; the paddle is where the speed jumps, not where it
   settles.
   ⚠⚠ AND THE CURVES WERE CHOSEN BY MEASURING THE ARRIVAL SPEED, NOT BY EYE. The first attempt
     at "fast then slow" used curves whose control points sat at .94 and .98 — near enough to
     (1,1) that the tail went flat. Driving the animation's own clock and sampling the ball's
     transform 220 times across the cycle: it ARRIVED AT THE PADDLE AT 5% AND 1% OF ITS PEAK
     SPEED. That is the hover Syd described, just produced by a different curve than the one it
     replaced. Four candidates were measured the same way; these keep 57% of peak on the drive
     and 35% on the dink, so the ball is unmistakably still travelling when it is struck and
     the contact is a reversal rather than a stop.
     ⚠ THE TWO RATIOS DIFFER ON PURPOSE. A drive arrives hot; a dink drops in soft. That is a
       second way of saying what the timings already say, and it is why the pair is not one
       curve used twice.
     · drive — cubic-bezier(.3,.38,.7,.8),  peak 1.02 → arrives 0.58
     · dink  — cubic-bezier(.25,.45,.6,.76), peak 0.95 → arrives 0.33 */
@keyframes bc-rally {
  0%   { transform: translate(26px, 12px); animation-timing-function: cubic-bezier(.3,.38,.7,.8); }
  22%  { transform: translate(62px, 28px); animation-timing-function: cubic-bezier(.25,.45,.6,.76); }
  52%  { transform: translate(26px, 28px); animation-timing-function: cubic-bezier(.3,.38,.7,.8); }
  72%  { transform: translate(62px, 12px); animation-timing-function: cubic-bezier(.25,.45,.6,.76); }
  100% { transform: translate(26px, 12px); }
}
/* ⚠⚠ THE BALL DOES NOT CHANGE SIZE, AND IT DID. Syd, 2026-09-04: "the ball doesnt need to
   change size, its confusing." It swelled mid-flight and shrank at contact, on the reasoning
   that a plan view has no other way to show height — a lofted dink read as bigger, a flat
   drive as smaller. The reasoning was sound and the result was not: at this size a growing
   circle is just as readable as the ball coming CLOSER, or as a pulse, and an ambiguous signal
   is worse than an absent one. The difference between a drive and a dink is already carried by
   the timing, unambiguously, and once is enough.
   ⚠ linear ON THE ANIMATION ITSELF: the per-keyframe timing functions above are the whole
     point, and a shorthand easing here would apply on top of them. */
.bc-ball {
  animation: bc-rally 2.2s linear infinite;
}

/* Each player pushes toward the net as the ball reaches them and recovers. 3 units is about a
   third of their own width — a shift, not a jump. */
@keyframes bc-lunge-r { 0%, 22%, 100% { translate: 0 0; } 6% { translate: 3px 0; } }
@keyframes bc-lunge-l { 0%, 22%, 100% { translate: 0 0; } 6% { translate: -3px 0; } }
/* ⚠⚠ THE DELAYS ARE THE NEW, UNEVEN CONTACT TIMES — 0%, 22%, 52% and 72% of 2.2s. They were
   evenly spaced quarters and had to move with the shot timings above; a lunge that fires when
   the ball is still two thirds of the way across is a player swinging at nothing, and it is
   the kind of thing that looks merely "off" rather than broken. */
.bc-p1 { animation: bc-lunge-r 2.2s var(--ease-out) infinite; }          /* hits at 0%    */
.bc-p4 { animation: bc-lunge-l 2.2s var(--ease-out) infinite .484s; }    /* receives at 22% */
.bc-p2 { animation: bc-lunge-r 2.2s var(--ease-out) infinite 1.144s; }   /* 52% */
.bc-p3 { animation: bc-lunge-l 2.2s var(--ease-out) infinite 1.584s; }   /* 72% */

/* ⚠ REDUCED MOTION STOPS THE RALLY AND KEEPS A SIGN OF LIFE. The setting is about movement, so
   nothing travels; the ball breathes in place instead, slower than one cycle per five seconds —
   the rate the accessibility guidance flags for looping oscillation. */
@media (prefers-reduced-motion: reduce) {
  .bc-ball, .bc-p { animation: none; }
  /* ⚠ AT THE SERVE, NOT ON THE NET. translate(44,20) is the middle of the net line, which
     reads as a ball stuck in it. */
  .bc-ball { transform: translate(26px, 12px); }
  /* ⚠ OPACITY, NOT SIZE — the same reason the loft was removed above, and here it is the only
     sign of life left on a screen where nothing is allowed to travel. */
  .bc-ball circle { animation: bc-breathe 5s var(--ease-in-out) infinite; }
}
@keyframes bc-breathe { 0%, 100% { opacity: .45; } 50% { opacity: 1; } }

/* ── The progress track ────────────────────────────────────────────────────
   Syd, 2026-09-04: "ALSO make it feel more like a loading screen." The court answers "whose
   app is this"; nothing on the screen answered "is anything happening". A rally is character,
   and character is not status — DESIGN.md §2 asks for the state to be readable rather than
   inferred from the fact that something is moving.

   ⚠⚠ IT IS DETERMINATE WHILE THE WAIT IS KNOWN AND INDETERMINATE WHEN IT IS NOT, WHICH IS THE
   ONLY HONEST WAY TO DRAW ONE. The minimum hold is a real number this app chose, so the fill
   animates across it at a real rate. If the app is STILL not ready when it lands — fonts on a
   bad connection — the bar does not sit at 100% pretending: preloader.js marks it and it
   becomes a shuttle, which is what "we no longer know how long" looks like. A progress bar
   frozen at full is the single most familiar lie in software.

   ⚠ linear, NOT AN EASE. Easing a progress bar is a claim about the RATE of the work, and this
   one is measuring a clock. Every other animation on this screen is eased; this one must not
   be.

   ⚠ THE SAME WIDTH AS THE CREST AND THE COURT, cap included, so the three read as one object
   rather than three things that happen to be centred. */
.boot-bar {
  width: 116px; max-width: 34%; height: 2px;
  border-radius: 1px; overflow: hidden;
  background: color-mix(in srgb, var(--ink) 12%, transparent);
}
.boot-bar i {
  display: block; width: 100%; height: 100%; border-radius: inherit;
  background: var(--ink);
  transform-origin: left center;
  scale: 0 1;
  /* ⚠ --boot-hold IS SET BY preloader.js, because only it knows whether this is the first
     launch on the device (two rallies) or a later one (one). The fallback is the shorter of
     the two, so a browser that somehow never runs the module still gets a bar that finishes
     rather than one stuck at zero. */
  animation: boot-fill var(--boot-hold, 2200ms) linear forwards;
}
@keyframes boot-fill { from { scale: 0 1; } to { scale: 1 1; } }

/* Still waiting after the minimum: a shuttle, because the remaining time is genuinely unknown.
   ⚠ `alternate` RATHER THAN A LOOP THAT SNAPS BACK. A bar that jumps to the left every cycle
     reads as a stutter; sweeping back is movement that admits it is going nowhere. */
.boot.is-waiting .boot-bar i {
  width: 35%; scale: 1 1;
  animation: boot-shuttle 1.15s var(--ease-in-out) infinite alternate;
}
@keyframes boot-shuttle { from { translate: 0 0; } to { translate: 186% 0; } }

/* ⚠ REDUCED MOTION KEEPS THE TRACK AND DROPS THE TRAVEL. A progress bar is information, not
   decoration, so removing it would take away the one thing on this screen that says the app is
   coming. It fills in one step instead of sliding. */
@media (prefers-reduced-motion: reduce) {
  .boot-bar i { animation: none; scale: 1 1; opacity: .55; }
  .boot.is-waiting .boot-bar i { width: 100%; animation: bc-breathe 5s var(--ease-in-out) infinite; }
}
