/* RALLY base + app shell layout. Uses tokens.css variables only. Mobile-first. */

/* touch-action: WKWebView holds every tap ~350ms while it waits to rule out a
   double-tap zoom. `manipulation` (pan + pinch only, no double-tap) removes
   the hold — this is what made buttons and the country <select> feel laggy on
   device (2026-08-17). Pinch-zoom still works. */
*, *::before, *::after { box-sizing: border-box; touch-action: manipulation; }

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
}

body {
  font-family: var(--font);
  color: var(--ink);
  background: var(--bg);
  font-size: var(--fs-16);
  line-height: 1.4;
  -webkit-font-smoothing: antialiased;
  /* Disable text selection callouts for a native feel in the WKWebView. */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}

/* App is a single fixed-viewport shell: a scrolling screen area above a fixed
 * bottom tab bar. Only .screen scrolls. */
#app {
  display: flex;
  flex-direction: column;
  height: 100dvh;
  overflow: hidden;
}

/* THE DOCUMENT ITSELF MUST NOT SCROLL WHILE THE APP SHELL IS UP.
 * app.js adds .app-mode alongside showApp(); the marketing landing (which does
 * scroll as a normal page) never has it.
 *
 * Why: #app is 100dvh but html/body are 100%, and on iOS those two resolve to
 * different heights while the URL bar is on screen. The leftover few pixels
 * were enough to make the whole shell — tab bar included — drift up and down
 * as you scrolled the feed, which is exactly what a bottom tab bar must never
 * do. Locking the document also stops the URL bar collapsing, which is what
 * made 100dvh a moving target in the first place. */
/* MUST be on <html> as well as <body>. On iOS the scrolling element is the
 * documentElement, so `overflow: hidden` on body alone does not hold it — and
 * html kept `height: 100%`, i.e. the LARGE viewport, leaving the document
 * taller than the visible area by exactly the URL bar. That is why the shell
 * still slid up on a fresh load even with this rule present.
 * Pinning both to 100dvh makes them agree with #app.
 * NB this cannot be reproduced in a desktop browser at a fixed viewport: with
 * no dynamic URL bar, 100% and 100dvh are the same number. */
html.app-mode, body.app-mode {
  height: 100dvh;
  overflow: hidden;
  overscroll-behavior: none;
}

/* ---- Nav bar ---- */
.navbar {
  flex: 0 0 auto;                      /* pinned by the flex column, not fixed */
  position: relative;
  display: flex; align-items: center; justify-content: center;
  /* 52px gutters keep a long title centred against the back chevron. The 11px
     top/bottom put the title in a 44pt bar below whatever inset the device
     reports — on the web that inset is 0, which is why this used to sit under a
     54px block of nothing (see tokens.css). */
  padding: calc(var(--safe-top) + 11px) 52px 11px;
  background: var(--bg);
  border-bottom: 1px solid var(--border);
}
.navbar[hidden] { display: none; }     /* explicit: flex would beat [hidden] */
.navbar__title {
  font-size: 18px; font-weight: var(--w-semi); letter-spacing: -0.01em;
  margin: 0; color: var(--ink);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.navbar__back {
  position: absolute; left: 4px; bottom: 2px;
  width: 44px; height: 44px;           /* full 44pt tap target, per iOS */
  display: flex; align-items: center; justify-content: center;
  background: none; border: 0; cursor: pointer;
  font-family: var(--font); font-size: 30px; line-height: 1;
  color: var(--accent-2); padding: 0 0 4px;
}
.navbar__back[hidden] { display: none; }
/* Primary screen action, top-right — where iOS puts it. The wrapper holds it
   and, on the Clubs tab, a second one to its left (NAV.set `extra`). */
.navbar__head { min-width: 0; text-align: center; }
.navbar__actions { position: absolute; right: 8px; bottom: 6px;
  display: flex; align-items: center; gap: 6px; }
.navbar__action {
  min-height: 36px; padding: 6px 4px;
  background: none; border: 0; cursor: pointer;
  font-family: var(--font); font-size: var(--fs-16); font-weight: var(--w-semi);
  color: var(--accent-2); white-space: nowrap;
}
.navbar__action[hidden] { display: none; }
/* An icon action (the gear) gets a full 44pt square. */
.navbar__action:has(svg) { min-width: 44px; min-height: 44px; display: flex;
  align-items: center; justify-content: center; padding: 0; color: var(--accent); }

/* ── The nav redesign (design_handoff_rally_nav_redesign, 2026-09-18) ────────
   The three tab roots: a big title on the left, actions on the right. */
.navbar--large { justify-content: flex-start; padding-left: 20px; padding-right: 20px;
  padding-top: calc(var(--safe-top) + 14px); padding-bottom: 12px; border-bottom-color: transparent; }
.navbar--large .navbar__head { text-align: left; }
.navbar--large .navbar__title { font-size: 27px; font-weight: var(--w-bold); letter-spacing: -0.5px; }
.navbar--large .navbar__actions { right: 16px; bottom: 10px; }
/* A title with a line under it sits LEFT, beside the back chevron — a centred
   two-line block reads as a heading floating in the middle of the bar. */
.navbar--sub { justify-content: flex-start; }
.navbar--sub .navbar__head { text-align: left; }
.navbar--sub .navbar__title { font-weight: var(--w-bold); line-height: 22px; }
.navbar__sub { font-size: var(--fs-13); color: var(--muted); line-height: 22px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.navbar__sub[hidden] { display: none; }
/* "+ New": the one filled button in a header. */
.navbar__action--pill { background: var(--green-primary); color: #fff;
  border-radius: var(--r-pill); padding: 8px 16px; min-height: 44px; }

/* ---- Screen area ---- */
.screen-host {
  flex: 1 1 auto;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  /* Rubber-banding this scroller must not chain to the document — that hands
     the bounce to the whole shell and drags the tab bar with it. */
  overscroll-behavior: contain;
  padding: calc(var(--safe-top) + 8px) var(--pad-screen) var(--pad-screen);
}
/* The bar already owns the safe area when it's showing, so don't pad twice. */
.navbar:not([hidden]) + .screen-host { padding-top: 14px; }
.screen { max-width: 560px; margin: 0 auto; }
.screen[hidden] { display: none; }

.screen-title {
  font-size: var(--fs-title);
  font-weight: var(--w-bold);
  margin: 6px 0 14px;
}

/* ---- Bottom tab bar ---- */
.tabbar {
  flex: 0 0 auto;
  display: flex;
  background: var(--surface);
  border-top: 1px solid var(--border);
  padding-bottom: var(--safe-bottom);
}
/* `display:flex` overrides the [hidden] UA rule, so hide the tab bar explicitly
   (shown only in the main app, not during auth/onboarding). Same for individual
   feature-gated tab buttons (ladder-era tabs). */
.tabbar[hidden] { display: none; }
.tabbar__btn[hidden] { display: none; }
.tabbar__btn {
  flex: 1;
  appearance: none;
  border: 0;
  background: none;
  font-family: var(--font);
  font-size: var(--fs-12);
  font-weight: var(--w-semi);
  color: var(--muted);
  /* 5/5 with a 22px icon lands the bar at ~52pt of content. iOS standard is
     49pt, and the extra 3 is the 18px label floor (CLAUDE.md) — a 10pt tab
     label is not something this app is allowed to ship. It replaces a 16px
     solid dot, which read heavier than a line icon at the same height. */
  padding: 5px 4px 5px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  cursor: pointer;
  position: relative;
}
.tabbar__btn[aria-selected="true"] { color: var(--accent); }
.tabbar__icon {
  width: 22px; height: 22px;
  display: block;
  /* Inherits the tab's colour, so selected/muted needs no icon-specific rule. */
  color: currentColor;
}
.tabbar__dot {
  width: 16px; height: 16px;
  border-radius: 50%;
  background: currentColor;
  opacity: 0.9;
}
/* unread badge on a tab (e.g. Challenges) */
.tabbar__btn[data-badge="true"] .tabbar__dot::after {
  content: "";
  position: absolute;
  top: 6px; right: calc(50% - 14px);
  width: 8px; height: 8px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--surface);
}

/* ---- Generic card ---- */
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-card);
  padding: var(--pad-card);
}

/* ---- Loading (UI.loading) ---- */
/* Every async screen paints this before its first await, so a tap always shows
   something within a frame. Ring, not text: it reads at a glance and needs no
   copy. */
.loading { display: flex; justify-content: center; padding: 72px 0; }
.loading__ring {
  width: 28px; height: 28px; border-radius: 50%;
  border: 3px solid var(--border); border-top-color: var(--accent);
  animation: loading-spin 0.8s linear infinite;
}
@keyframes loading-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .loading__ring { animation-duration: 1.6s; }
}

/* ---- Placeholder (Slice 0 stub screens) ---- */
.placeholder {
  color: var(--muted);
  font-size: var(--fs-15);
  text-align: center;
  padding: 40px 10px;
}

/* ---- Auth / onboarding screens ---- */
/* The bottom padding is load-bearing on iOS: the keyboard and its accessory bar
   cover the lower ~45% of the screen without shrinking the layout viewport, so
   without somewhere to scroll TO, the submit button stays hidden underneath it.
   Paired with UI.keepVisibleOnFocus(). Invisible when the keyboard is closed —
   nothing renders below the form. */
.auth { max-width: 400px; margin: 0 auto; padding-top: 8vh; padding-bottom: 55vh; }
/* "‹ Back" on the sign-in screen — returns to the marketing landing. */
.auth-back { display: block; background: none; border: 0; cursor: pointer;
  font-family: var(--font); font-size: var(--fs-16); font-weight: var(--w-semi);
  color: var(--accent-2); padding: 8px 0; margin: -4vh 0 10px; }
.auth--form { padding-top: 2vh; }
.auth--centered { text-align: center; padding-top: 14vh; }
.auth__brand { font-size: 40px; font-weight: var(--w-bold); letter-spacing: 2px; margin: 0 0 4px; }
.auth__tag { color: var(--muted); font-size: var(--fs-15); margin: 0 0 24px; }
/* The question IS the field label ("What's your number?" / "Enter the code we
   texted to…"), so it carries label weight and colour and the field below it
   has no visible label of its own. */
.auth__ask { color: var(--ink); font-size: var(--fs-16); font-weight: var(--w-semi);
  line-height: 1.45; margin: 0 0 14px; }
/* Country picker + number, side by side. The select is sized to its content
   ("GB +44") so the number field takes everything else; it must not shrink on a
   320px screen, hence flex: 0 0 auto. */
.phone-row { display: flex; gap: 8px; align-items: stretch; }
/* Descendant selectors, not bare classes: `.input { width: 100% }` is declared
   further down this file and would otherwise win on source order, stretching the
   select to the full row and squeezing the number field to nothing. */
.phone-row .phone-row__cc { flex: 0 0 auto; width: auto; padding-left: 12px; padding-right: 8px;
  font-variant-numeric: tabular-nums; cursor: pointer; }
.phone-row .phone-row__num { flex: 1 1 auto; width: auto; min-width: 0; }

/* Carrier consent disclosure, shown at the number field. Quiet but legible —
   it must stay readable (min 13px per the UI rules), not fine print. */
/* SANCTIONED EXCEPTION to the 18px floor (George, 2026-08-17): legal
   disclaimers ONLY — carrier-consent copy ("By continuing you agree…"), and
   the landing footer's legal line below. Half the floor, by instruction.
   Nothing that isn't a disclaimer may take this size; the next "but it's just
   a caption" is how the floor gets lost (CLAUDE.md UI rules). */
.auth__consent { color: var(--muted); font-size: 9px; line-height: 1.6; margin: 20px 0 0; }
/* Was an inline font-size:13px in login.js, which is exactly how a floor
   gets quietly undercut. Same colour, no size override. */
.auth__hint { color: var(--muted); }
.auth__consent b { color: var(--muted); font-weight: var(--w-bold); }
.status-emoji { font-size: 48px; margin-bottom: 8px; }

/* ---- Forms ---- */
.field { display: block; margin-bottom: 16px; }
.field__label { display: block; font-size: var(--fs-14); font-weight: var(--w-semi); margin-bottom: 6px; }
.field__hint { display: block; font-size: var(--fs-13); color: var(--muted); margin-top: 5px; }
.opt { color: var(--muted); font-weight: 400; }
.input {
  width: 100%;
  font-family: var(--font);
  font-size: var(--fs-16);
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 12px 14px;
}
.input:focus { outline: none; border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(58,82,51,0.10); }
/* <select> wearing .input. `appearance: none` drops the platform chrome, so the
   chevron is drawn here — inline SVG as a data URI keeps it a single file with
   no extra request. Padded on the right so a long option can't run under it. */
.input--select {
  appearance: none; -webkit-appearance: none;
  padding-right: 36px; cursor: pointer;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%235C7A4F' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 13px center;
}
textarea.input { resize: vertical; }
.input--code { font-size: 28px; letter-spacing: 8px; text-align: center; }

.form-err { color: #8a2e34; font-size: var(--fs-14); margin-top: 10px; line-height: 1.5; }
/* The recovery link inside an error (e.g. "Text START to +1…"). Sized as a real
   tap target rather than a run of inline text — it is the only way out of an
   opt-out, so it must not read as part of the sentence above it. */
.form-err__action { display: inline-block; margin-top: 8px; padding: 9px 14px;
  border: 1px solid currentColor; border-radius: var(--r-pill);
  color: #8a2e34; font-weight: var(--w-semi); text-decoration: none; }

/* ---- Buttons ---- */
.btn {
  /* .btn is worn by <a> as well as <button> (the join success screen). Kill the
     link underline here or the anchor renders as underlined text in a pill. */
  text-decoration: none;
  font-family: var(--font);
  font-size: var(--fs-16);
  font-weight: var(--w-semi);
  border-radius: 12px;
  padding: 13px 18px;
  border: 1px solid transparent;
  cursor: pointer;
  background: none;
  color: var(--ink);
}
.btn:disabled { opacity: 0.55; cursor: default; }
.btn--block { display: block; width: 100%; margin-top: 10px; }
.btn--primary { background: var(--accent); color: #fff; }
.btn--ghost { background: var(--surface); border-color: var(--border); color: var(--accent); }
.btn--text { background: none; color: var(--muted); }
.btn--sm { font-size: var(--fs-14); padding: 8px 12px; }

/* ---- Admin approvals ---- */
.admin { max-width: 480px; margin: 0 auto; }
.admin-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--gap-card); }
.admin-row__name { font-size: var(--fs-name); font-weight: var(--w-semi); }
.admin-row__meta { font-size: var(--fs-14); color: var(--muted); }
.admin-row__actions { display: flex; gap: 8px; }

/* ---- Invite output ---- */
.invite-out { margin-top: 12px; }
.invite-code { display: block; word-break: break-all; background: var(--surface-tint);
  border: 1px solid var(--border-tint); border-radius: 8px; padding: 10px; font-size: var(--fs-13); margin-top: 4px; }

/* ============================ Ladder screen ============================ */

/* ---- Location-lens pill (centered header) ---- */
.lens-header { display: flex; justify-content: center; position: relative; margin: 2px 0 4px; }
.lens-pill {
  font-family: var(--font); font-size: var(--fs-15); font-weight: var(--w-semi);
  color: var(--ink); background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-pill); padding: 8px 16px; cursor: pointer;
  display: inline-flex; align-items: center; gap: 7px;
}
.lens-pill[aria-expanded="true"] { background: var(--accent); color: #fff; border-color: var(--accent); }
.lens-pill__caret { font-size: 13px; opacity: 0.8; }
.lens-menu {
  position: absolute; top: 46px; left: 50%; transform: translateX(-50%);
  background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
  box-shadow: 0 8px 28px rgba(30,42,32,0.14); padding: 6px; min-width: 210px; z-index: 20;
}
.lens-menu[hidden] { display: none; }
.lens-opt {
  display: flex; justify-content: space-between; align-items: center; width: 100%;
  font-family: var(--font); font-size: var(--fs-16); color: var(--ink);
  background: none; border: 0; border-radius: 8px; padding: 11px 12px; cursor: pointer; text-align: left;
}
.lens-opt--active { background: var(--surface-tint); font-weight: var(--w-semi); }
.lens-opt__check { color: var(--accent); }
.lens-caption { text-align: center; font-size: var(--fs-13); color: var(--muted); margin: 8px 0 2px; }

/* ---- Hero rank ---- */
.hero { margin: 10px 0 18px; }
.hero__rank { font-size: var(--fs-hero); font-weight: var(--w-hero); line-height: 1; letter-spacing: -1px; }
.hero__place { font-size: 18px; color: var(--muted); margin-top: 2px; }
.hero__range { font-size: var(--fs-16); font-weight: var(--w-semi); color: var(--accent-2); margin-top: 12px; }

/* ---- Player cards ---- */
.pcard {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-card);
  padding: var(--pad-card); margin-bottom: var(--gap-card); cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.pcard--chall { border-left: 3px solid var(--accent); box-shadow: var(--shadow-lift); }
.pcard--you { background: var(--surface-tint); border-color: var(--border-tint); }
.pcard--pending { border-style: dashed; border-color: var(--border-dashed); }
.pcard--calm { color: var(--ink); opacity: 0.92; }
.pcard--dormant { opacity: 0.55; }

.pcard__top { display: flex; align-items: center; gap: 12px; }
.pcard__pos { font-size: var(--fs-pos); font-weight: var(--w-semi); min-width: 26px; text-align: center; }
.pcard__avatar {
  width: 44px; height: 44px; border-radius: 50%; flex: 0 0 auto;
  background: var(--surface-tint); border: 1px solid var(--border-tint);
  display: flex; align-items: center; justify-content: center;
  font-size: var(--fs-16); font-weight: var(--w-semi); color: var(--accent);
}
.pcard--you .pcard__avatar { background: var(--accent); color: #fff; border-color: var(--accent); }
.pcard__id { flex: 1 1 auto; min-width: 0; }
.pcard__name { font-size: var(--fs-name); font-weight: var(--w-semi); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pcard__loc { font-size: var(--fs-14); color: var(--muted); }
.pcard__aff { flex: 0 0 auto; display: flex; align-items: center; }
.pcard__chev { font-size: 22px; color: var(--accent); }
.pcard--calm .pcard__chev { color: var(--muted-3); }

.chip { font-size: var(--fs-13); font-weight: var(--w-semi); border-radius: var(--r-pill); padding: 4px 10px; white-space: nowrap; }
.chip--you { background: var(--accent); color: #fff; }
.chip--deadline { background: var(--surface); border: 1px dashed var(--border-dashed); color: var(--muted); }

.pcard__div { height: 1px; background: var(--border); margin: 12px 0 10px; }
.pcard--you .pcard__div { background: var(--border-tint); }
.pcard__bottom { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.form-bars { display: flex; align-items: flex-end; gap: 4px; height: 16px; }
.bar { width: 5px; border-radius: 2px; }
.bar--w { height: 16px; background: var(--accent); }
.bar--l { height: 9px;  background: var(--muted-3); }
.pcard__avail { font-size: var(--fs-13); color: var(--muted); text-align: right; }

.ladder-empty { text-align: center; color: var(--muted); padding: 30px 12px; }

/* ---- Bottom sheet (card actions) ---- */
.sheet__backdrop { position: fixed; inset: 0; background: rgba(30,42,32,0.35); z-index: 40; }
/* THE VISIBLE BOX IS .sheet__drag, NOT THIS. The panel is a transparent
   fixed-position scroll container; the white card, its radius, padding and
   shadow all live on the inner wrapper. That is because swipe-to-dismiss
   transforms .sheet__drag — a `transform` on a position:fixed element
   mispaints in WKWebView (CLAUDE.md), so the panel can never be the thing that
   moves. With the background on the panel, swiping slid the CONTENT down and
   left the white card sitting there (George, 2026-08-17). */
.sheet__panel {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 41;
  background: none;
  /* Sheets now carry real forms (join, invite-from-group), which overflow a
     small phone once the keyboard is up. Scroll inside rather than clipping the
     submit button off the bottom. */
  max-height: 88dvh; overflow-y: auto; overscroll-behavior: contain;
  /* Appended to <html> (WKWebView overlay gotcha), so set the font/colour here —
     it's outside <body>'s cascade and would fall back to serif otherwise. */
  font-family: var(--font); color: var(--ink);
}
.sheet__drag {
  position: relative;            /* the X is positioned against this */
  background: var(--surface); border-radius: 18px 18px 0 0;
  padding: 8px 22px calc(22px + var(--safe-bottom));
  box-shadow: 0 -6px 30px rgba(30,42,32,0.18);
}
.sheet__grip { width: 40px; height: 4px; border-radius: 2px; background: var(--border); margin: 6px auto 14px; }
.sheet__title { font-size: var(--fs-title); font-weight: var(--w-bold); margin: 0 0 2px; }
.sheet__sub { font-size: var(--fs-14); color: var(--muted); margin: 0 0 16px; }

/* ============================ Challenges screen ============================ */
.cx-section { margin-bottom: 22px; }
.cx-section__label { font-size: var(--fs-13); font-weight: var(--w-bold); letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--muted); margin: 0 0 10px; }

.cx-card {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-card);
  padding: var(--pad-card); margin-bottom: var(--gap-card);
}
.cx-card--incoming { border-left: 3px solid var(--accent); }
.cx-card--scheduled { background: var(--surface-tint); border-color: var(--border-tint); }
.cx-card--outgoing { border-style: dashed; border-color: var(--border-dashed); }
.cx-card--confirm { border-left: 3px solid var(--accent-2); }

.cx-head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.cx-avatar {
  width: 40px; height: 40px; border-radius: 50%; flex: 0 0 auto;
  background: var(--surface); border: 1px solid var(--border-tint);
  display: flex; align-items: center; justify-content: center;
  font-weight: var(--w-semi); color: var(--accent);
}
.cx-who { flex: 1 1 auto; min-width: 0; }
.cx-name { font-size: var(--fs-name); font-weight: var(--w-semi); }
.cx-note { font-size: var(--fs-14); color: var(--muted); }
.cx-msg { font-style: italic; color: var(--accent-2); font-size: var(--fs-15); margin: 6px 0 12px; }
.cx-actions { display: flex; gap: 10px; }
.cx-actions .btn { flex: 1; margin-top: 4px; }

/* History: flat rows, no cards */
.hx-row { display: flex; align-items: center; gap: 12px; padding: 12px 2px; border-bottom: 1px solid var(--border); }
.hx-who { flex: 1 1 auto; min-width: 0; }
.hx-name { font-size: var(--fs-16); font-weight: var(--w-semi); }
.hx-meta { font-size: var(--fs-13); color: var(--muted); }
.hx-result { font-size: var(--fs-16); font-weight: var(--w-bold); text-align: right; }
.hx-result--w { color: var(--accent); }
.hx-result--l { color: var(--muted-3); }

/* ============================ Report Result screen ============================ */
.report { max-width: 480px; margin: 0 auto; padding-bottom: 20px; }
.report__sub { color: var(--muted); font-size: var(--fs-15); margin: -8px 0 18px; }
.seg { display: flex; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 4px; margin-bottom: 18px; }
.seg__btn { flex: 1; font-family: var(--font); font-size: var(--fs-16); font-weight: var(--w-semi);
  color: var(--muted); background: none; border: 0; border-radius: 9px; padding: 11px; cursor: pointer; }
.seg__btn[aria-selected="true"] { background: var(--accent); color: #fff; }

.setrow { display: flex; align-items: center; gap: 12px; background: var(--surface);
  border: 1px solid var(--border); border-radius: var(--r-card); padding: 14px 16px; margin-bottom: var(--gap-card); }
.setrow__label { flex: 1; font-size: var(--fs-16); font-weight: var(--w-semi); color: var(--muted); }
.setrow__score { display: flex; align-items: center; gap: 10px; }
.score-in { width: 58px; text-align: center; font-size: 20px; font-weight: var(--w-semi);
  border: 1px solid var(--border); border-radius: 10px; padding: 10px 4px; font-family: var(--font); color: var(--ink); background: var(--bg); }
.score-in:focus { outline: none; border-color: var(--accent-2); }
.score-dash { color: var(--muted-3); }
.add-set { width: 100%; border: 1px dashed var(--border-dashed); background: none; border-radius: var(--r-card);
  padding: 13px; color: var(--accent); font-weight: var(--w-semi); font-family: var(--font); font-size: var(--fs-16); cursor: pointer; }
/* Inline (not fixed) so it never overlaps the tab bar — the report screen
   renders inside the Challenges tab. */
.report__submit { margin-top: 8px; padding: 0 0 8px; }
.setrow__hint { font-size: var(--fs-13); color: var(--muted); text-align: center; }

/* ============================ Availability screen ============================ */
.avail-intro { color: var(--muted); font-size: var(--fs-15); margin: -8px 0 18px; }
.avail-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-card);
  padding: 4px 16px; margin-bottom: var(--gap-card); }
.avail-day { border-bottom: 1px solid var(--border); }
.avail-day:last-child { border-bottom: 0; }
.avail-day__head { display: flex; align-items: center; justify-content: space-between; padding: 14px 0; }
.avail-day__name { font-size: var(--fs-16); font-weight: var(--w-semi); }
.avail-day--off .avail-day__name { color: var(--muted); font-weight: 400; }
.avail-day__times { display: flex; align-items: center; gap: 12px; padding: 0 0 16px; }
.time-in { font-family: var(--font); font-size: var(--fs-16); font-weight: var(--w-semi); color: var(--ink);
  background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; flex: 1; }
.time-in:focus { outline: none; border-color: var(--accent-2); }
.time-sep { color: var(--muted); }

/* iOS-style toggle */
.toggle { position: relative; width: 48px; height: 28px; flex: 0 0 auto; }
.toggle input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; }
.toggle__track { position: absolute; inset: 0; background: var(--border); border-radius: 999px; transition: background .15s; }
.toggle__knob { position: absolute; top: 3px; left: 3px; width: 22px; height: 22px; background: #fff; border-radius: 50%;
  box-shadow: 0 1px 3px rgba(0,0,0,0.2); transition: transform .15s; }
.toggle input:checked + .toggle__track { background: var(--accent); }
.toggle input:checked + .toggle__track .toggle__knob { transform: translateX(20px); }

.section-label { font-size: var(--fs-13); font-weight: var(--w-bold); letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--muted); margin: 24px 0 10px; }
.venue-row { display: flex; align-items: center; justify-content: space-between;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-card); padding: 14px 16px; margin-bottom: var(--gap-card); }
.venue-row__name { font-size: var(--fs-16); font-weight: var(--w-semi); }
.venue-row__area { font-size: var(--fs-13); color: var(--muted); }
.venue-row__pending { font-size: var(--fs-12); color: var(--accent-2); }
.venue-add { width: 100%; border: 1px dashed var(--border-dashed); background: none; border-radius: var(--r-card);
  padding: 14px; color: var(--accent); font-weight: var(--w-semi); font-family: var(--font); font-size: var(--fs-16); cursor: pointer; }

/* ============================ Profile screen ============================ */
.pf { text-align: center; }
/* The camera badge hangs off THIS, outside .pf-avatar's circular clip — inside
   it, the badge was sliced in half by the same overflow that keeps a photo
   round. */
.pf-avatar-wrap { position: relative; width: 96px; height: 96px; margin: 6px auto 12px; }
.pf-avatar { width: 96px; height: 96px; border-radius: 50%; margin: 0; position: relative;
  background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center;
  font-size: 40px; font-weight: var(--w-semi); overflow: hidden; cursor: default; }
.pf-avatar img { width: 100%; height: 100%; object-fit: cover; }
.pf-avatar--editable, .pf-avatar-wrap[role="button"] { cursor: pointer; }
/* A BUTTON, NOT A BORDER. This was a 15px pencil on a white disc with a hairline
   border, sitting on a white page — indistinguishable from decoration, and
   George did not know the feature existed (2026-08-22). Filled in the accent
   colour, ringed in the page background so it separates from the avatar behind
   it, and big enough to look tappable. */
.pf-avatar__edit { position: absolute; bottom: -2px; right: -2px;
  background: var(--accent); color: #fff; border: 3px solid var(--bg);
  border-radius: 50%; width: 36px; height: 36px;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 2px 8px rgba(30,42,32,0.28); }
/* Named for a screen reader without taking any space on screen. */
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.pf-name { font-size: 26px; font-weight: var(--w-bold); }
.pf-rank { font-size: var(--fs-16); font-weight: var(--w-semi); color: var(--accent); margin-top: 2px; }

.stat-tiles { display: flex; gap: 10px; margin: 22px 0; }
.stat-tile { flex: 1; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-card); padding: 16px 8px; }
.stat-tile__num { font-size: 24px; font-weight: var(--w-bold); }
.stat-tile__label { font-size: var(--fs-13); color: var(--muted); margin-top: 2px; }

.rank-chart { text-align: left; }
.rank-chart__title { font-size: var(--fs-13); font-weight: var(--w-bold); letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin-bottom: 4px; }
.rank-chart__cap { font-size: var(--fs-13); color: var(--muted); margin: 0 0 14px; }
.rank-bars { display: flex; align-items: flex-end; gap: 8px; height: 120px; }
.rank-bar { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 100%; gap: 6px; }
.rank-bar__fill { width: 100%; background: var(--muted-3); border-radius: 6px 6px 0 0; min-height: 8px; }
.rank-bar--now .rank-bar__fill { background: var(--accent); }
.rank-bar__label { font-size: var(--fs-12); color: var(--muted); font-weight: var(--w-semi); }
.rank-bar--now .rank-bar__label { color: var(--accent); }

.settings-list { margin-top: 26px; text-align: left; }
.settings-row { display: flex; align-items: center; justify-content: space-between; width: 100%;
  background: none; border: 0; border-bottom: 1px solid var(--border); padding: 16px 2px; cursor: pointer;
  font-family: var(--font); font-size: var(--fs-16); color: var(--ink); }
.settings-row__chev { color: var(--muted-3); font-size: 20px; }
.settings-row--danger { color: #8a2e34; }
/* Destructive confirm — filled red so it never reads as the safe default. */
.btn--danger { background: #8a2e34; border-color: #8a2e34; color: #fff; }
.btn--danger:disabled { opacity: 0.6; }
.del-list { margin: 0 0 16px; padding-left: 20px; color: var(--muted);
  font-size: var(--fs-14); line-height: 1.7; }
.app-version { text-align: center; color: var(--muted-3); font-size: var(--fs-12); margin: 28px 0 8px; }

/* ============================ Open Games ============================ */
/* Slot avatars: filled = tinted circle w/ 2 initials; empty = dashed + thin "+" */
.slots { display: flex; align-items: center; gap: 8px; }
/* ---- Slots (player avatars) ----
   THE ONE EXEMPTION TO THE 18px FLOOR (tokens.css). Everything in here is a
   glyph inside a fixed-size circle — two initials, a tick, a plus, a step
   number — not text anyone reads a sentence of. An 18px "+" in a 38px avatar
   overflows its own circle. Each is sized as large as its shape allows instead.
   If you add a new one, size it to the container, and do not reach for a token. */
/* The photo sits OVER the initials rather than replacing them, so a slow
   network or a dead URL degrades to initials instead of an empty disc — see
   window.SLOTS.face. `overflow:hidden` + `position:relative` are what let it. */
.slot__img { position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; border-radius: 50%; }
.slot { position: relative; overflow: hidden;
  width: 38px; height: 38px; border-radius: 50%; display: flex; align-items: center;
  justify-content: center; font-size: 16px; font-weight: var(--w-semi); color: var(--accent);
  background: var(--surface-tint); border: 1px solid var(--border-tint); flex: 0 0 auto; }
/* ⚠️ A DASHED RING MEANS "EMPTY, WAITING FOR SOMEBODY" — AND NOTHING ELSE.
   (George, 2026-08-30: the rings came off the settings icons, "that should be a
   design decision that should exist throughout".)

   .slot--empty and .slot--pending KEEP theirs, because there the dashes carry
   the meaning: a place in a game with nobody in it, and a person who has
   answered but is not seated. The same is true of the dashed CARD borders
   (.pcard--pending, .cx-card--outgoing, .gcard--cancelled) — provisional or
   called off.

   What must never have one again is an ICON. .slot--add lost its ring on
   2026-08-30; the archived-game rows in renderPlayed briefly borrowed
   .slot--empty for a decorative ▦ and made every finished game read faintly
   like a player who had not turned up. That icon is now simply gone — there is
   nothing to put in that column until photos exist, and a placeholder for a
   feature that does not exist yet is decoration wearing a meaning. */
.slot--empty { background: none; border: 1.5px dashed var(--border-dashed); color: var(--muted-4); font-weight: 300; font-size: 20px; }
.slot--you { background: var(--accent); border-color: var(--accent); color: #fff; }
.slot--lg { width: 52px; height: 52px; font-size: 21px; }
/* PENDING — answered, but the game has not picked its time yet, so there is no
   spot to hold. Before this, everyone who had answered a range game appeared
   nowhere at all: one filled circle for the organiser, three empty pluses, and
   no trace of the person reading it. Jim reported that as the app not letting
   him join, which is exactly how it looked.

   No new colour. The palette already had this language — --accent-2 is
   "in-progress" and --border-dashed is "pending, used with dashed style" — so
   pending is a real person's INITIALS in an accented dashed circle, against an
   empty slot's grey dashed PLUS. Different glyph, different weight of border;
   the two cannot be confused, and nothing new had to be invented. */
.slot--pending { background: none; border: 1.5px dashed var(--accent-2); color: var(--accent-2); }
/* The line under the circles: names and hours, one person per line. */
/* The label and the hours are the quiet half — the NAMES are what is read. */
.slots__count { margin-left: auto; font-size: var(--fs-13); color: var(--muted); font-weight: var(--w-semi); }

/* Game cards (feed): see "The Games tab (nav redesign)" at the end. */
.chip--full { background: var(--bg); border: 1px solid var(--border); color: var(--muted); }
.chip--urgent { background: var(--accent); color: #fff; }

/* Header row with inline action */
.screen-head { display: flex; align-items: center; justify-content: space-between; margin: 6px 0 14px; }
.screen-head .screen-title { margin: 0; }

/* Day chips (create) */
.day-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
.day-chip { font-family: var(--font); font-size: var(--fs-14); font-weight: var(--w-semi);
  border: 1px solid var(--border); background: var(--surface); color: var(--ink);
  border-radius: var(--r-pill); padding: 10px 14px; cursor: pointer; }
.day-chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }

/* Invitee rows (create) */
.inv-row { display: flex; align-items: center; gap: 12px; padding: 11px 2px; border-bottom: 1px solid var(--border); cursor: pointer; }
/* Transparent, not removed: `border-bottom: 0` makes the last row 1px shorter
   than every other one. Nothing shows either way (rows have no background), but
   the rows stay literally the same height. */
.inv-row:last-child { border-bottom-color: transparent; }
/* A row that opens something is a <button>, so it needs the browser's button
   styling taken back off — otherwise it centres its text, draws a border and
   picks its own font. Same metrics as the div version, so a list can mix them. */
.inv-row.hx-open { width: 100%; background: none; border-width: 0 0 1px 0; border-style: solid;
  border-color: var(--border); text-align: left; font: inherit; color: inherit; }
.inv-row.hx-open:last-child { border-bottom-color: transparent; }
.inv-row__go { color: var(--muted-4); font-size: 20px; flex: 0 0 auto; margin-left: auto; }

/* A card whose children are ONLY .inv-row: the rows own the vertical rhythm,
   and the card contributes NOTHING at the ends — each row's own 11px padding
   is the whole story, so a name sits 11px from the card's top edge exactly as
   it sits 11px from its separator. Horizontal padding stays. Do not use on a
   card that also holds a heading or prose.

   ZERO, not "whatever makes avatar-to-avatar distances equal" (the first
   version used 12px for that): the eye measures content against the nearest
   visible LINE — separator or card edge — not against the neighbouring
   avatar, so any card padding at all reads as extra space around the first
   and last person (George, twice, 2026-08-16/17). */
.card--rows { padding-top: 0; padding-bottom: 0; }
.inv-row__name { flex: 1; font-size: var(--fs-16); font-weight: var(--w-semi); }
.inv-row__meta { font-size: var(--fs-12); color: var(--muted); }
.inv-check { width: 24px; height: 24px; border-radius: 50%; border: 1.5px solid var(--border-dashed);
  display: flex; align-items: center; justify-content: center; color: #fff; font-size: 15px; flex: 0 0 auto; }
.inv-row[aria-checked="true"] .inv-check { background: var(--accent); border-color: var(--accent); }
/* "Who can see it?" was a card of `.who-row`s until 2026-09-21; it is now a
   plain `.settings-list` like every other list you press, and the tick is the
   only thing it kept. The tick is NOT an icon — it is the answer to "which one
   is chosen", and every row has one. See www/js/games.js whoSheet. */
.settings-row[aria-checked="true"] .inv-check { background: var(--accent); border-color: var(--accent); }
/* A guest row carries two lines (name + "Sam's guest") beside the same 38px
   avatar as everyone else — compact line heights make the text block exactly
   38px (20 + 18) so guest rows sit at the SAME height as member rows. Both
   lines stay ≥18px type; only the leading is tightened. */
/* 0105: moved onto .inv-row itself (see "EVERY ROW THE SAME HEIGHT" at the end
   of this file). Kept as a no-op comment rather than deleted so the reasoning
   above — which is the original working-out — is not orphaned. */
/* ---- Game screen: invited disclosure + the invite flow ---- */

/* "Which times work ›" — one tappable phrase, chevron INLINE with the words
   (George, 2026-08-17: pushed to the right it reads as a row, and this is a
   link). The app's tappable green rather than --muted, which measured 2.4:1
   against the background and was genuinely hard to read.
   `border: 0` matters: without it a <button> keeps its user-agent border and
   the phrase renders as a grey input box, which is exactly what happened when
   this rule was briefly deleted as unused (2026-08-23). */
.fold-toggle {
  display: inline-flex; align-items: center; gap: 5px;
  background: none; border: 0; cursor: pointer; font-family: var(--font);
  font-size: var(--fs-14); font-weight: var(--w-semi); color: var(--accent-2);
  padding: 14px 2px 6px;
}

/* The RSVP list is deliberately NOT a card and has no avatars: someone who was
   asked is not a player, and giving them the same treatment as the seated card
   put them on equal footing. Quiet type, status right-aligned. */
.rsvp { padding: 2px 2px 4px; }
.rsvp__row { display: flex; align-items: baseline; gap: 10px; padding: 7px 0; }
.rsvp__name { font-size: var(--fs-14); color: var(--muted); }
.rsvp__st { font-size: var(--fs-14); color: var(--muted-2); margin-left: auto; }
.rsvp__row--no .rsvp__name { color: var(--muted-2); }

/* "not reached" is the only row on this screen that is an OPEN PROBLEM rather
   than settled information, so it is the only one that carries colour. It is
   deliberately NOT styled like `--no`: a decline is an answer, this is the
   absence of one, and greying it out would bury the row the organiser is the
   only person able to act on. Same red as the other destructive/attention
   affordances (.inv-row--danger) rather than a new hue. */
.rsvp__row--unreached .rsvp__st { color: #A8443A; font-weight: 600; }

/* The invite chooser's three routes were `.route` rows — a 38px icon circle, a
   title and a one-clause line, in a card. Since 2026-09-21 they are ordinary
   `.settings-row`s (George: no outlined box, no icons), so nothing needs its
   own class any more. Deleted rather than left behind: dead CSS is how a
   second way to draw a list survives long enough for somebody to reuse it. */

/* "‹ Invite players" — back to step 1 inside the same sheet. */
.sheet-back {
  background: none; border: 0; cursor: pointer; font-family: var(--font);
  font-size: var(--fs-14); font-weight: var(--w-semi); color: var(--accent-2);
  padding: 0 0 8px;
}

/* `.inv-row--danger` styled the destructive line of the old card-and-icon
   sheets (the gear, the club member sheet, the notice menu). Every one of them
   is a `.settings-list` now and uses `.settings-row--danger`, so it is gone.
   The comment on `.rsvp__row--unreached` above still points at this red; the
   value is unchanged. */

/* ✕ on a guest row (organiser or the guest's host). The glyph sits in a fixed
   36px shape, same exemption class as .slot glyphs — but it's 18px anyway. */
.guest-x { width: 36px; height: 36px; margin-left: auto; flex: 0 0 auto; border: 0;
  border-radius: var(--r-pill); background: none; color: var(--muted);
  font-size: 18px; line-height: 1; cursor: pointer; font-family: var(--font); }
.select-all { font-size: var(--fs-13); color: var(--accent-2); background: none; border: 0; cursor: pointer; font-family: var(--font); font-weight: var(--w-semi); }
/* "Add someone new" — a <button> wearing .inv-row, so it needs the UA button
   styles stripped back to match the rows above it. The row above already draws
   the separator, so this one draws none. */
/* "5 others invited" — the card's last row, and the only one that is a control
   rather than a person. No avatar: every row above opens with a circle, so a
   circle here read as one more slot on the game (George, 2026-08-22). The
   label starting at the card's edge, where nothing else does, is the whole
   signal — with the chevron on the right saying it opens. */
/* `border: 0` matters: a <button> keeps its user-agent border otherwise, and
   stacked on the preceding row's own bottom rule it drew a heavier, differently
   coloured line than every other separator in the card. */
.inv-row--others { width: 100%; background: none; border: 0;
  font-family: var(--font); text-align: left; cursor: pointer; }
/* Chevron INLINE with the words, not pushed right — George, 2026-08-17, on the
   control this replaces: "it reads as a link that way, which is what it is".
   It also keeps the far right free, which is where every player row puts its
   chip, so the two never look like the same kind of thing. */
/* THE CHEVRON IS ROTATED, NOT REPLACED. Opening a fold used to swap the glyph
   for a different one, and the two do not share vertical metrics — so the open
   state sat noticeably lower than the closed one (George, 2026-08-24). One
   glyph in one box, turned 90 degrees, cannot move. */
.chev { display: inline-block; transition: transform .15s ease; }
.chev--open { transform: rotate(90deg); }

/* `center`, not `baseline`: the chevron is a shape next to words, not another
   word, and a baseline is the wrong thing to line a shape up on. */
.inv-row--others .inv-row__others { display: inline-flex; align-items: center; gap: 5px;
  color: var(--accent-2); font-weight: var(--w-semi); font-size: var(--fs-15); }

/* Name and its chip on one line, so the quiet line beneath them gets the full
   width of the row. A right-pushed chip reserved its width down the whole row
   and wrapped the organiser's hours (2026-08-22). */
.inv-row__top { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
/* THE CHIP MUST NOT DECIDE HOW TALL THIS LINE IS. It is 35px against the name's
   own 25px — its padding plus a border on some variants — and as a flex item
   that made the organiser's row 10px taller than everybody else's, with his
   hours sitting 10px further below his name than Mary's did below hers. George
   spotted it as "a larger gap between George and his times than Mary and her
   times" (2026-08-22).
   Fixed to the NAME's own line (1.4em of the name's font size) rather than
   cancelling the chip's padding by hand: chips carry padding AND, on some
   variants, a border, so subtracting a number would be right for one chip and
   two pixels out for the next. Given a height it cannot set, the chip simply
   overflows it, centred, and draws exactly as before while the row stays the
   height of a row. Guarded by tests/browser/time_window.test.js, which measures
   both rows — a screenshot only catches this if you look at the right two. */
.inv-row__top { height: 1.4em; }
.inv-row__top .chip { line-height: 1.4; }
/* ANY <button> DRAWN AS A ROW needs the browser's own button styling turned
   off, not just the "add" variant. George, 2026-08-29, on the Where sheet:
   "Tarratine is appearing in blue and with a grey background for some reason."
   That is Safari's default button chrome — blue text, grey fill, centred —
   showing through, because the reset lived on .inv-row--add and the club rows
   are plain .inv-row buttons. On the class itself it cannot be missed again. */
button.inv-row { width: 100%; background: none; border: 0; color: inherit;
  font-family: var(--font); font-size: inherit; text-align: left;
  -webkit-appearance: none; appearance: none; }

.inv-row--add { width: 100%; background: none; border: 0; font-family: var(--font); text-align: left; }
.inv-row--add .inv-row__name { color: var(--accent-2); }
/* NO RING (George, 2026-08-30: "would the icons look better without the dotted
   circles around them?" — yes). The dashed circle means one specific thing
   everywhere else in this app: .slot--empty, an unfilled place in a game
   waiting for a person. On a menu row there is no person and no place, so the
   ring was a shape borrowed for its looks, and it made every action read
   faintly like a player who had not turned up.
   THE 38px BOX STAYS so the text still lines up with the people rows in the
   same sheets; only the outline goes. .slot--empty keeps its ring, because
   there it is carrying the meaning. */
.slot--add { background: none; border: 0; color: var(--accent-2); font-size: 22px; }
.inv-empty { font-size: var(--fs-14); color: var(--muted); padding: 12px 2px; margin: 0; }

/* Landing / detail hero */
/* The quiet half of the heading, above the hours. Both halves are printed by
   the server (when_lead / when_span, migration 0086) — the page never splits
   window_text itself, because the day word has to be computed in the GROUP's
   timezone, not the device's. */
.hero-lead { color: var(--muted); font-size: var(--fs-15); margin-bottom: 2px; }
.hero-when { font-size: 42px; font-weight: 200; letter-spacing: -0.03em; line-height: 1.08; text-wrap: balance; }
.hero-when--muted { color: var(--accent-2); }
.hero-sub { color: var(--muted); font-size: var(--fs-15); margin-top: 6px; }
/* Standalone use (join.html mounts this straight into <body>, so it supplies
   its own side padding). */
/* --safe-top matters here now that join.html is reachable INSIDE the native
   app: a tapped Universal Link lands on it, and a flat 26px put the brand under
   the Dynamic Island. It already respected --safe-bottom, from when this was
   web-only and the home indicator was the only inset that could bite. */
.landing { max-width: 460px; margin: 0 auto;
  padding: calc(26px + var(--safe-top)) var(--pad-screen) calc(22px + var(--safe-bottom)); }
/* Inside the app shell, .screen-host already pads the sides and .screen sets
   the width — so don't pad or cap twice (this is what made game detail feel
   narrow: 22px + 22px inset and a 420px ceiling). */
.screen-host .landing { max-width: none; padding-left: 0; padding-right: 0;
  /* .screen-host already adds the top inset, so drop the one above or the
     notch is paid for twice on every screen that uses .landing in the shell. */
  padding-top: 26px;
  /* ⚠️ AND THE BOTTOM ONE FOR THE SAME REASON, 2026-09-04. The base rule ends in
     calc(22px + var(--safe-bottom)) — written when this block WAS the whole
     invite page, with nothing under it. Inside the shell the tab bar carries the
     home-indicator inset, so that is the third place the same 34px was being
     paid for.

     IT ALSO MOVED THE ANSWER BAR. George: "when scrolling the screen, that
     section is fixed, but when i reach the end it pulls up a little bit." A
     sticky element stops sticking once it reaches its place in the flow, and its
     place in the flow was 56px above the edge — so the bar sat flush on the tab
     bar all the way down and then lifted at the last inch. Measured at exactly
     56px, which is this padding. .screen-host still contributes its own 15px,
     which is what .join-cta cancels. */
  padding-bottom: 0; }
/* THE GEAR ON THE LINK PAGE (0118). The page has no nav bar — it is a landing
   block, not an app screen — so the one action slot the app puts top-right of
   a title bar goes top-right of the block instead. Same glyph, same corner,
   same meaning; a person is often on both surfaces within a day. It sits below
   the safe-area inset because .landing already pays for the notch. */
.landing__gear {
  position: absolute; top: calc(20px + var(--safe-top)); right: 0;
  width: 40px; height: 40px; border-radius: 50%; border: 0; background: none;
  color: var(--accent); display: flex; align-items: center; justify-content: center;
  padding: 0; cursor: pointer;
}
.landing__brand { font-size: 18px; font-weight: var(--w-bold); letter-spacing: 0.24em; color: var(--accent); margin-bottom: 26px; }
.landing .btn--primary { height: 58px; font-size: 18px; box-shadow: 0 6px 18px rgba(58,82,51,0.22); }
.landing__reassure { text-align: center; color: var(--muted-2); font-size: var(--fs-13); margin-top: 12px; }
.landing__aslink { text-align: center; color: var(--accent-2); font-size: var(--fs-14); background: none; border: 0;
  font-family: var(--font); cursor: pointer; text-decoration: underline; display: block; margin: 14px auto 0; }
.joining-as { background: var(--surface-tint); border: 1px solid var(--border-tint); border-radius: 10px;
  padding: 10px 14px; font-size: var(--fs-14); color: var(--ink); margin-bottom: 12px; text-align: center; }
.success-check { width: 64px; height: 64px; border-radius: 50%; background: var(--accent); color: #fff;
  display: flex; align-items: center; justify-content: center; font-size: 30px; margin: 24px auto 14px; }
/* ⚠️ THIS SHARES A CORNER WITH .landing__gear, WHICH SITS AT right:0 AND IS
   40px WIDE. Both are absolutely positioned into the top right, so on a game
   that is FULL and that you are IN — the commonest state there is, once a game
   fills — the chip and the gear drew on top of each other. Seen on Josiah's
   screen, 2026-09-01.

   Also `top` now carries --safe-top like the gear does. Without it the two were
   vertically offset by the notch inset on exactly the phones that have one, so
   the pair looked crooked even when they did not collide. The 40px height is
   what makes a ~30px chip share a centre line with a 40px button. */
.badge-topright { position: absolute; top: calc(20px + var(--safe-top)); right: 22px;
  height: 40px; display: inline-flex; align-items: center; }
/* Only when the gear is actually drawn — otherwise the chip would sit oddly
   far in on every game nobody is in. */
.badge-topright--gear { right: 46px; }

/* ---- Share CTA (game detail) ----
   A game with an empty seat is a game that might not happen, so the share link
   is the most valuable control on the screen. It used to be a 13px text link
   tucked at the end of the "2 spots open" row; this is the same action given
   the weight it earns. */
.share-cta {
  background: var(--surface-tint); border: 1px solid var(--border-tint);
  border-radius: var(--r-card); padding: 16px; margin: 0 0 14px;
}
.share-cta__title { font-size: var(--fs-name); font-weight: var(--w-bold); color: var(--accent); }
.share-cta__sub { margin: 4px 0 0; font-size: var(--fs-14); color: var(--accent-2); line-height: 1.45; }
/* .btn--ghost inside the tinted card needs the tint's border, not the plain
   one, or it reads as a misaligned white box. */
.share-cta .btn--ghost { border-color: var(--border-tint); }

/* Waitlist / texted cards */
.dash-card { border: 1.5px dashed var(--border-dashed); border-radius: var(--r-card); padding: 14px 16px;
  color: var(--muted); font-size: var(--fs-14); margin: 12px 0; background: none; }

/* Sheets (add friend / share link) reuse .sheet__* from ladder; form grid */
.name-grid { display: flex; gap: 10px; }
.name-grid .field { flex: 1; }

/* ---- Segmented control (create-game: who can join) ---- */
.seg { display: flex; gap: 6px; background: var(--surface-tint); border: 1px solid var(--border-tint);
  border-radius: var(--r-pill); padding: 4px; }
.seg__btn { flex: 1; appearance: none; border: 0; background: none; cursor: pointer;
  font-family: var(--font); font-size: var(--fs-14); font-weight: var(--w-semi);
  color: var(--accent-2); padding: 9px 10px; border-radius: var(--r-pill); }
.seg__btn[aria-checked="true"] { background: var(--accent); color: #fff; }

/* Notification settings: one block per category — label, hint, then its own
   channel control. Replaced the on/off switch rows (migration 0045), because
   "off" is now just another channel rather than a separate axis. */
.np-cat { padding: 14px 0; border-bottom: 1px solid var(--border); }
.np-cat:last-of-type { border-bottom: 0; }
.np-cat .seg { margin-top: 2px; }

/* ---- Public landing (site root, signed-out web visitors) ---- */
/* #app is display:flex, so [hidden] alone can't hide it (known pitfall). */
#app[hidden], .site-landing[hidden] { display: none; }

.site-landing {
  /* Scrolls as a normal document (NOT an inner scroller) while #app is hidden —
     an inner scroll container blocks the mobile URL-bar collapse and breaks
     anchor/scroll behaviour on a marketing page. */
  position: relative;
  /* The rotated court ornament is 330px wide and overhangs the viewport on a
     375px phone, which gave the whole page ~11px of horizontal scroll. It is
     decoration, so clip it rather than shrink it. */
  overflow-x: clip;
  -webkit-user-select: text;
  user-select: text;
}
.site-landing a { color: var(--accent-2); }

/* Court-line ornament: fixed behind the hero, fading out down the page. */
.ls-court {
  position: absolute; top: 88px; left: 50%; transform: translateX(-50%) rotate(-9deg);
  width: 330px; height: 460px; color: var(--accent);
  opacity: 0.06; pointer-events: none; z-index: 0;
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 46%, transparent 88%);
          mask-image: linear-gradient(to bottom, #000 0%, #000 46%, transparent 88%);
}

/* Marketing page keeps its own roomier gutter — it isn't the app shell. */
/* Safe-area insets on both ends: the landing is the welcome screen on the
   native shell too (app.js no longer skips it there), where 40px of flat
   padding puts the brand line under the notch and the footer under the home
   indicator. Harmless on web — the env() values resolve to 0px. */
/* Flex column at full viewport height so the footer pins to the bottom of the
   screen (margin-top:auto on .ls-foot) instead of trailing the steps. On short
   phones the content is taller than the viewport and the page scrolls; the
   footer then sits at the natural end, which is the same thing. */
.ls { position: relative; z-index: 1; max-width: 440px; margin: 0 auto;
  min-height: 100dvh; display: flex; flex-direction: column;
  padding: calc(40px + var(--safe-top)) 20px calc(24px + var(--safe-bottom)); }

.ls-brand { display: flex; align-items: center; gap: 9px; font-size: var(--fs-13);
  font-weight: var(--w-bold); letter-spacing: 0.26em; color: var(--accent); margin-bottom: 44px; }
.ls-brand__ball { width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
  box-shadow: inset -3px -3px 0 rgba(0,0,0,0.22); }

.ls-h1 { font-size: clamp(42px, 13vw, 54px); font-weight: 800; letter-spacing: -0.038em;
  line-height: 0.98; margin: 0 0 14px; }
.ls-lede { font-size: var(--fs-20); color: var(--accent-2); font-weight: var(--w-semi);
  margin: 0 0 28px; letter-spacing: -0.01em; }

.ls-cta { width: 100%; height: 58px; font-size: 18px; border-radius: 14px;
  box-shadow: 0 8px 22px rgba(58,82,51,0.24); }

/* ---- Steps ---- */
/* Bottom margin matters now the footer is auto-pinned: when the page is taller
   than the viewport the auto margin collapses to zero, and this is the gap. */
.ls-steps { list-style: none; margin: 44px 0 40px; padding: 0;
  display: flex; flex-direction: column; gap: 18px; }
/* Headline-only now (the explanatory sub-text was removed), so the row centres
   on its number rather than top-aligning to a two-line block. */
.ls-steps li { display: grid; grid-template-columns: 30px 1fr; gap: 13px; align-items: center;
  font-size: var(--fs-16); line-height: 1.5; color: var(--ink); font-weight: var(--w-semi); }
.ls-steps__n { width: 30px; height: 30px; border-radius: 50%; background: var(--surface-tint);
  border: 1px solid var(--border-tint); color: var(--accent); font-size: 17px;
  font-weight: var(--w-bold); display: flex; align-items: center; justify-content: center; }

/* No text-wrap:balance here — balancing split it as "…schedule / your pickleball
   games", orphaning "your". Plain greedy wrapping fills the first line, which
   reads better for this specific line. */
.ls-kicker { margin: 40px 0 0; font-size: var(--fs-20); font-weight: var(--w-semi);
  letter-spacing: -0.015em; line-height: 1.35; }

/* ---- Footer (compliance lives here — required by the carrier registration) ---- */
.ls-foot { margin-top: auto; padding-top: 22px; border-top: 1px solid var(--border);
  color: var(--muted); font-size: var(--fs-13); line-height: 1.65; }
.ls-foot__links { display: flex; flex-wrap: wrap; gap: 8px 18px; margin: 0 0 14px; }
.ls-foot__links a { font-weight: var(--w-semi); text-decoration: none; }
.ls-foot__links a:hover { text-decoration: underline; }
/* 9px: legal-disclaimer exception to the font floor — see .auth__consent. */
.ls-foot__legal { margin: 0; color: var(--muted-2); font-size: 9px; }
.ls-foot__legal b { color: var(--muted); font-weight: var(--w-semi); }

/* Respect reduced-motion for any future animations. */
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

/* ---- Force-update gate ---- */
/* Appended to <html> and paired with body{display:none}, the same approach the
   sheet uses for the WKWebView paint bug. No dismiss control: the whole point is
   that this build cannot proceed. */
.gate {
  position: fixed; inset: 0; z-index: 9999;
  background: var(--bg); color: var(--ink);
  font-family: var(--font);
  display: flex; align-items: center; justify-content: center;
  padding: var(--pad-screen);
}
.gate__inner { max-width: 340px; text-align: center; }
.gate__brand { font-size: 22px; font-weight: var(--w-bold); letter-spacing: 2px;
  color: var(--accent); margin-bottom: 22px; }
.gate__title { font-size: var(--fs-title); font-weight: var(--w-bold); margin: 0 0 8px; }
.gate__body { color: var(--muted); font-size: var(--fs-15); line-height: 1.6; margin: 0 0 24px; }
.gate__cta { height: 54px; text-decoration: none; display: flex;
  align-items: center; justify-content: center; }

/* Contacts confirm list: a row that cannot be added. Greyed and untappable, but
   still SHOWN with its reason where the number goes — silently dropping people
   reads as a bug, and "already in Tarratine" answers the question before it is
   asked. */
.inv-row--off { opacity: 0.55; }
.inv-row--off .inv-row__meta { font-style: italic; }

/* ---- Linked games (a "sitting") ---- */
.linked-banner { background: var(--surface-tint); border: 1px solid var(--border-tint);
  border-radius: var(--r-card); padding: 12px 14px; margin-bottom: 18px;
  font-size: var(--fs-14); line-height: 1.5; color: var(--muted); }
.linked-banner b { display: block; color: var(--ink); font-weight: var(--w-semi);
  font-size: var(--fs-15); margin-bottom: 2px; }
.session-chip { display: inline-block; margin: 0 0 12px; padding: 4px 10px;
  border-radius: var(--r-pill); background: var(--surface-tint);
  border: 1px solid var(--border-tint); color: var(--accent);
  font-size: var(--fs-12); font-weight: var(--w-bold); }
/* A sibling game row. Reuses .inv-row's rhythm so a sitting reads as one list. */
.sib { display: flex; align-items: center; gap: 12px; width: 100%;
  padding: 11px 2px; border-bottom: 1px solid var(--border);
  background: none; border-left: 0; border-right: 0; border-top: 0;
  font-family: var(--font); text-align: left; cursor: pointer; }
.sib:last-child { border-bottom: 0; }
.sib__title { font-size: var(--fs-16); font-weight: var(--w-semi); color: var(--ink); }
.sib__meta { font-size: var(--fs-13); color: var(--muted); margin-top: 2px; }
.sib > :last-child { margin-left: auto; flex: 0 0 auto; }
/* Locked invitee rows: already playing or already invited, so a tick here means
   "covered", not "will be texted". Muted and not tappable. */
.inv-row--locked { opacity: 0.55; cursor: default; }
.inv-row--locked .inv-check { background: var(--muted-2); border-color: var(--muted-2); }

/* Close affordance on every sheet. Positioned over the panel's own padding so
   it never collides with the title. */
.sheet__close {
  position: absolute; top: 10px; right: 12px; z-index: 1;
  width: 36px; height: 36px; border-radius: 50%;
  background: var(--bg); border: 1px solid var(--border); color: var(--muted);
  font-family: var(--font); font-size: 20px; line-height: 1; cursor: pointer;
  display: flex; align-items: center; justify-content: center; padding: 0;
}
.sheet__close:hover { color: var(--ink); }
/* THE X HAS TO CLEAR THE NOTCH ON A FULL-SCREEN SHEET (George, 2026-08-30:
   "I get a full screen page that I can't exit because the 'x' is offscreen").
   A bottom sheet starts partway down, so `top: 10px` is always in free space;
   a full one starts at y=0, which on any modern iPhone is under the status
   bar. Every existing full sheet happened to hide this because .fs__head pads
   itself by --safe-top — so the FIRST full sheet built without that wrapper
   was the first one nobody could close. Fixed here rather than in the markup,
   so the next one is safe whether or not it remembers. */
.sheet__panel--full .sheet__close { top: calc(var(--safe-top) + 10px); }

/* Full-screen variant, for lists. ONE scroller (.fs__body) with the header,
   search and action pinned — a bottom sheet with an inner scrolling list means
   two scrollers competing, and on iOS the outer one wins and the list feels
   stuck. */
.sheet__panel--full {
  /* max-height on the base .sheet__panel is 88dvh so a bottom sheet never
     covers the screen — which is exactly what this variant needs to do, so it
     has to be cleared or the panel stops short and leaks backdrop at the
     bottom. */
  top: 0; height: 100dvh; max-height: none; border-radius: 0;
  padding: 0; display: flex; flex-direction: column;
}
/* Full-screen variant never swipes, so its wrapper drops the card styling and
   just fills the panel. */
.sheet__panel--full .sheet__drag {
  display: flex; flex-direction: column; height: 100%; min-height: 0;
  padding: 0; border-radius: 0; box-shadow: none;
}
.fs { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.fs__head { padding: calc(var(--safe-top) + 14px) 52px 10px 22px; border-bottom: 1px solid var(--border); }
.fs__head .sheet__title { margin: 0; }
.fs__head .sheet__sub { margin: 4px 0 0; }
.fs__search { padding: 12px 22px 0; }
.fs__body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch; padding: 12px 22px 4px; }
.fs__foot { flex: 0 0 auto; padding: 12px 22px calc(14px + var(--safe-bottom));
  border-top: 1px solid var(--border); background: var(--surface); }
.fs__foot .btn--block { margin-top: 0; }
.fs__foot .field__hint { margin: 0 0 10px; }

/* A settings row with no control — "Reminders & cancellations" is on and not
   negotiable, and saying so beats leaving it off the screen and letting people
   wonder why they got a text they never opted into. */

/* Select-all sits under the search field in the contacts picker, so it acts on
   whatever the search is currently showing. */
.fs__tools { display: flex; justify-content: flex-end; padding: 8px 0 0; }

/* ---------------------------------------------------------------------------
   Desktop: keep the app a phone-width column
   ---------------------------------------------------------------------------
   Everything in the shell — nav bar, screen area, tab bar — is a child of
   #app, so capping #app is the entire fix. Capping each screen's inner content
   instead would leave the bars stretched edge to edge and the whole thing
   reading as a website with a narrow paragraph in it.

   Sheets are capped separately because they are NOT inside #app: they are
   appended to <html> (the WKWebView overlay gotcha in CLAUDE.md), so they
   would otherwise still span the full window.

   Nothing here applies below 640px — a phone, and a small tablet in portrait,
   stay edge to edge, which is what touch wants. The marketing landing is left
   alone too: full-bleed with centred content is correct for a web page, and it
   already caps .ls at 440px. */
@media (min-width: 640px) {
  /* Scoped to .app-mode so the landing keeps its own cream full-bleed. */
  body.app-mode { background: var(--canvas); }

  #app {
    max-width: 480px;
    margin: 0 auto;
    background: var(--bg);
    border-left: 1px solid var(--border);
    border-right: 1px solid var(--border);
  }

  /* left:0/right:0 plus auto margins centres the panel over the column. */
  .sheet__panel { max-width: 480px; margin-left: auto; margin-right: auto; }
}

/* ── Picking a start time on a window game (0064) ───────────────────────────
   A window offers up to 17 half hours, so these wrap into a grid rather than a
   row — a horizontal scroller hides the later ones, which are exactly the ones
   somebody choosing "late afternoon" is looking for. */
.win-slots {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
  gap: 8px;
  margin: 8px 0 6px;
}
.win-slot {
  appearance: none;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--ink);
  border-radius: var(--r-card);
  padding: 12px 6px;
  font: inherit;
  font-size: var(--fs-16);
  font-weight: var(--w-bold);
  cursor: pointer;
}
.win-slot:hover { border-color: var(--accent); }
.win-slot:active { transform: scale(.97); }
.win-slot:disabled { opacity: .45; }
/* The same slots on the SMS landing page, where most people will meet them. */
.landing__pick {
  font-size: var(--fs-16);
  font-weight: var(--w-bold);
  margin: 18px 0 0;
}

/* ── Saying when you could play (0066) ──────────────────────────────────────
   Two dropdowns, not a grid of ticks: two taps whatever the window's length,
   and finer granularity costs nothing. See the note above availabilityBlock(). */
.av-range { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 6px 0 4px; }
/* Day and time side by side in the edit sheet (0130). Its own class rather
   than borrowing .av-range, which carries that control's own margins. */
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.av-range__end { display: grid; gap: 2px; }
.av-range__end .input--select { width: 100%; }

/* The overlap. Bars rather than numbers alone: "who can make when" is a shape
   you read at a glance, and the one that reaches capacity has to stand out. */
.heat { display: grid; gap: 4px; margin: 6px 0; }
/* Wider label column: rows name the whole slot now ("4:15\u20135:15pm"), not
   just its start, and 4.2rem clipped it into the bar. */
.heat__row { display: grid; grid-template-columns: 10.5rem 1fr; align-items: center; gap: 8px; }
.heat__t { font-size: var(--fs-13); color: var(--muted); font-variant-numeric: tabular-nums;  white-space: nowrap; }
.heat__bar { position: relative; height: 22px; border-radius: 6px; background: var(--surface-tint); overflow: hidden; }
.heat__fill { position: absolute; inset: 0 auto 0 0; background: var(--accent); opacity: .28; }
.heat__fill--full { opacity: 1; }
.heat__n {
  position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
  font-size: var(--fs-13); color: var(--ink); font-variant-numeric: tabular-nums;
}
.heat__fill--full ~ .heat__n { color: var(--surface); }

/* The one line that turns the bars into something you can act on (0070). */
.av-nudge {
  background: var(--surface-tint);
  border-left: 3px solid var(--accent);
  border-radius: 0 var(--r-card) var(--r-card) 0;
  padding: 12px 14px;
  margin: 10px 0 2px;
  font-size: var(--fs-16);
  line-height: 1.45;
}
.av-nudge strong { display: block; }

/* ── The start row on the create screen (2026-08-20) ────────────────────────
   One column for a fixed time, two for a window. The two ends of a window are
   ONE answer, and stacking them read as two unrelated questions. */
.time-row { display: grid; grid-template-columns: 1fr; gap: 10px; max-width: 260px; }
.time-row--range { grid-template-columns: 1fr 1fr; max-width: none; }
.time-row__end { display: grid; gap: 2px; }
.time-row__end .input--select { width: 100%; }


/* ── `hidden` must actually hide ─────────────────────────────────────────────
   The browser's own rule is `[hidden] { display: none }`, and ANY class that
   sets `display` outranks it — `.field__hint { display: block }` and
   `.time-row__end { display: grid }` both did. The element stays on screen
   while the code believes it is hidden, and nothing errors.

   That trap has now cost two bugs in one day (the "Free until" column and the
   "Free from" caption), and the note above `.select-all` records an earlier
   one. So it is fixed once, here, rather than remembered class by class.
   `!important` is deliberate: the whole point is to outrank whatever set
   display, and `hidden` has no legitimate meaning other than "not shown".      */
[hidden] { display: none !important; }

/* ── The create screen's quiet line (2026-08-20) ─────────────────────────────
   Day and time are the screen. The length, the venue and the range option are
   CORRECTIONS, not decisions — so they read as one line of ordinary English
   with tappable words, not as three more form fields. Anything that looks like
   a decision gets read like one. */
.input--big {
  font-size: 1.6rem;
  font-weight: var(--w-bold);
  padding-top: 10px;
  padding-bottom: 10px;
}
/* THE SETTINGS LIST (2026-09-13) replaced the quiet line's sentence: one
   setting per line, a grey icon, green tappable text, bold when the setting is
   missing. Every line the same height (the rows rule), and a long note is cut
   with an ellipsis rather than wrapping to a second, taller line. The whole
   line is the button, so a thumb anywhere along it works.
   .quiet-line and .quiet-tap below stay: the "N left" line in Text all
   players and the "Edit" beside your hours still use them. */
/* ── "How you'll play" (window.SHAPE in util.js; 2026-09-13) ───────────────
   Every control row the same height (the rows rule). The stepper glyphs and
   the court dots sit in fixed shapes, the one exemption the 18px floor has. */
.shape-row { display: flex; align-items: center; justify-content: space-between; gap: 12px;
  min-height: 62px; border-bottom: 1px solid var(--border); }
.shape-row__name { font-size: var(--fs-16); font-weight: var(--w-semi); color: var(--ink); }
/* ⚠️ THE OLDER .seg RULE CARRIES margin-bottom: 18px, which pushed this
   control above the middle of its row (George, 2026-09-16: "On each court tab
   isn't vertically centred"). The row centres what is in it; the margin was
   the only thing stopping it. */
.shape-row__seg { width: 132px; flex: 0 0 auto; margin-bottom: 0; }
.stepper { display: flex; align-items: center; gap: 4px; }
.stepper__btn { width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--border-tint);
  background: var(--surface-tint); color: var(--accent); font-family: var(--font); font-size: 22px;
  line-height: 1; cursor: pointer; padding: 0; }
.stepper__btn:disabled { color: var(--muted-4); cursor: default; }
.stepper__n { min-width: 40px; text-align: center; font-size: var(--fs-20); font-weight: var(--w-bold); }
.switch { appearance: none; -webkit-appearance: none; flex: 0 0 auto; width: 52px; height: 32px;
  border-radius: 16px; background: var(--border-tint); position: relative; cursor: pointer; margin: 0; }
.switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 26px; height: 26px;
  border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
.switch:checked { background: var(--accent); }
.switch:checked::after { left: 23px; }
.shape-total { display: flex; align-items: baseline; justify-content: space-between;
  background: var(--bg); border-radius: var(--r-card); padding: 12px 14px; margin-top: 14px; }
.shape-total b { font-size: var(--fs-title); }

.set-list { list-style: none; margin: 12px 0 0; padding: 0; }
.set-list__row { display: flex; align-items: center; gap: 10px; height: 38px; }
.set-list__icon { width: 18px; height: 18px; flex: 0 0 auto; color: var(--muted); }
.set-list__tap {
  appearance: none; background: none; border: 0; padding: 0; margin: 0;
  flex: 1; min-width: 0; height: 100%; text-align: left; cursor: pointer;
  font: inherit; font-size: var(--fs-15); font-weight: var(--w-reg);
  color: var(--accent-tap);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.set-list__tap--add { font-weight: var(--w-semi); }
.quiet-line {
  font-size: var(--fs-15);
  color: var(--muted);
  margin: 8px 0 0;
  line-height: 1.7;
}
.quiet-tap {
  appearance: none;
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: var(--accent);
  border-bottom: 1px dashed var(--border);
  cursor: pointer;
}
.quiet-tap:hover { border-bottom-style: solid; }

/* A heading with a control on the same line — the label and the Time/Range
   toggle belong together, and stacking them cost a whole row for two words. */
.head-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-top: 18px;
}
.head-row .section-label { margin: 0; }
.seg--mini { padding: 2px; flex: 0 0 auto; }
.seg--mini .seg__btn { padding: 5px 12px; font-size: var(--fs-13); }

/* ── Settling a game early (2026-08-21) ─────────────────────────────────────
   "Play with 3" is a number; "You, Sam and Riley — not Jo" is the consequence.
   The pills are the decision, so they sit above the button rather than being
   something you could go and look up. */
.who-line {
  display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
  margin-top: 10px;
}
.pill {
  border: 1px solid var(--border); border-radius: var(--r-pill);
  padding: 3px 10px; font-size: var(--fs-13);
}
.pill--in { background: var(--accent); color: var(--surface); border-color: var(--accent); }
.pill--out { color: var(--muted); text-decoration: line-through; }


/* The row the button refers to, marked in the chart it refers to. */
.heat__row--pick .heat__bar { box-shadow: inset 0 0 0 2px var(--accent); }
.heat__row--pick .heat__t { color: var(--ink); font-weight: var(--w-semi); }

/* Your own answer, once given: a statement, not a form still holding it. */
.answered-note {
  display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
  background: var(--surface-tint); border-radius: var(--r-card);
  padding: 12px 14px; margin-top: 14px; font-size: var(--fs-16);
}

/* A bar opens to the names behind it. */
.heat__row { appearance: none; background: none; border: 0; padding: 0; width: 100%;
             font: inherit; text-align: left; cursor: pointer; }
.heat__who { margin: 2px 0 6px 10.5rem; font-size: var(--fs-13); color: var(--muted); }

/* Somebody who has given their availability. They ANSWERED — they were being
   labelled "waiting" underneath their own answer until 2026-08-21. */
.rsvp__row--yes .rsvp__st { color: var(--accent); }
.chip--in { background: var(--accent); color: var(--surface); border-color: var(--accent); }

/* THE SURNAME CARRIES THE ROW (George, 2026-08-24). The group list is ordered
   by surname and scanned by surname, so the first name is the quiet half.
   .inv-row__name is semibold for the whole row, which is why BOTH halves are
   named here rather than only the bold one. */
.nm-first { font-weight: var(--w-reg); }
.nm-last  { font-weight: var(--w-bold); }

/* ---- Clubs (0105) --------------------------------------------------------
   A club is a place, so its card carries a photograph and one line of where it
   is — not a preview of its members (George, 2026-08-29: "don't need a preview
   of their members"). The card never grows: courts and join requests land on
   the same place line.

   The empty state is the one that matters, because it is the state every club
   starts in, Tarratine included. A tinted block in the app's own green with the
   club's initial, EXACTLY as tall as a photo so nothing jumps when one is
   added. Never a grey box and never a broken-image icon. */
.club-card {
  display: block; width: 100%; text-align: left; padding: 0;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-card); overflow: hidden;
  margin-bottom: var(--gap-card); cursor: pointer; font-family: var(--font);
  -webkit-tap-highlight-color: transparent;
}
.club-card__img {
  /* 16:5, LOCKED TO window.CROP.BANNER. It was a fixed 104px height, which on a
     narrow phone is 3.0:1 and on a wide one 3.9:1 — so `cover` shaved a
     different slice off every device and the crop somebody chose was never
     quite what they got. Matching the ratio the cropper outputs makes the
     frame in the sheet literally true. */
  aspect-ratio: 16 / 5; background-size: cover; background-position: center;
  background-color: var(--surface-tint); border-bottom: 1px solid var(--border-tint);
  display: flex; align-items: center; justify-content: center;
}
/* The monogram is a glyph inside a fixed-size shape, so it is exempt from the
   18px floor for the same reason avatar initials are (see .slot). */
.club-card__mono { font-size: 38px; font-weight: var(--w-semi); color: var(--accent); opacity: .55; letter-spacing: .06em; }
.club-card__body { display: flex; align-items: center; gap: 12px; padding: var(--pad-card); }
.club-card__text { flex: 1; min-width: 0; }
.club-card__name { font-size: var(--fs-20); font-weight: var(--w-bold); color: var(--ink); letter-spacing: -.01em; }
.club-card__place { font-size: var(--fs-14); color: var(--muted); }
.club-card__chev { color: var(--muted-4); font-size: 24px; font-weight: 300; flex: 0 0 auto; }


/* EVERY ROW THE SAME HEIGHT, whether or not it carries a second line. Both
   lines stay at 18px — the leading is what changes, the type is never shrunk
   (CLAUDE.md, George 2026-08-29). Lifted off .inv-row--guest, which has done
   exactly this for guest rows since 0048 and was simply never made general.

   ⚠️ THE FIRST VERSION HAD NO LEADING AT ALL, and George saw it: "the gap
   between the two isn't quite large enough". It was 20 + 18 — an 18px line
   box on 18px type, so the descenders of the name and the ascenders of the
   line below it very nearly touched. That 38 was chosen to fit INSIDE the
   38px avatar, which is the wrong constraint to solve for: it let the picture
   set the typography.

   He asked whether the second line should be smaller. NO, and not only because
   of the 18px floor he set — a smaller sub-line would be the third signal
   saying one thing. The line is already muted and regular against a semibold
   name, and colour and weight are what mark text as secondary. Size as well is
   redundant, and it is how the floor was lost the first time.

   So: 22px on both, which is ~1.22 and inside the normal 1.25-1.4 range for a
   two-line list row, and the 44px block now sets the row height rather than
   the avatar. .inv-row__name carries the minimum so a ONE-line row is exactly
   as tall as a two-line one and its single line sits centred — the equal-height
   half of the rule, which is the half a bigger gap would otherwise break.
   Guarded by tests/browser/sheet_rows.test.js. */
.inv-row__name { line-height: 22px; }
.inv-row__meta { line-height: 22px; }
/* THE MINIMUM GOES ON THE ROW, not on the text block. Making .inv-row__name a
   flex column to centre a single line looked equivalent and was not: its
   children include .inv-row__top, a span carrying the name AND a chip, which
   as a flex item stopped sharing a line with what follows it — rows went to
   89, 67 and 119px in one card. The row is already `align-items: center`, so a
   minimum here centres a one-line block for free and changes nothing else.
   66 = 11 + 44 + 11, the padding either side of a two-line block. */
.inv-row { min-height: 66px; }

/* Five options rather than four, so the segmented control needs to wrap on a
   narrow phone instead of squeezing each button below a comfortable tap. */
.seg--wrap { flex-wrap: wrap; }
.seg--wrap .seg__btn { flex: 1 0 18%; }

/* The organiser's line at the top of a game (0107). Set by a club's admins and
   written FOR the person holding the invite, so it appears identically on the
   app's game screen and the link page — the standing rule that the organiser
   and the recipient are not two audiences. Quoted rather than styled as a
   banner: it is somebody talking, not the app announcing something. */
.game-note {
  font-size: var(--fs-15); color: var(--ink); line-height: 1.5;
  border-left: 3px solid var(--border-tint); padding: 2px 0 2px 12px;
  margin: 14px 0 0;
}

/* "+3" where the home card runs out of room (0107). Reads as a count rather
   than a person: no dashed border (that means an open spot) and no face. The
   glyph sits in the same fixed 38px shape as avatar initials, so it takes the
   same exemption from the 18px floor. */
.slot--more { background: var(--bg); border: 1px solid var(--border); color: var(--muted); font-size: 15px; }

/* The profile's 96px avatar uses window.SLOTS.face like every other avatar in
   the app (0108), so the image inside it is .slot__img — absolutely positioned
   rather than a percentage-sized flex item. The initial behind it is sized for
   this circle rather than the 38px one. */
.pf-avatar .slot__img { border-radius: 50%; }

/* ---- The link page's answer, always reachable (0111) --------------------
   George, 2026-08-30, watching Jan RSVP: "she opened the game, there were 5
   other players so the 'I'm in' was off the bottom of the screen and she
   couldn't see it. So she tried to tap one of the 'open' slots and was confused
   when nothing happened."

   The page is built context-first — when, where, who is coming — and the answer
   sits under all of it. That is right for reading and wrong for answering: the
   taller the game, the further the one thing she came to do slides off the
   screen. A six-person game is enough to lose it, and open play makes six
   ordinary.

   So the answer stops scrolling. It sits on the bottom edge of the screen with
   the page's own background behind it, and the page gains matching padding so
   nothing is ever trapped underneath. Not a change in what the page says — the
   same button, the same words, in a place it cannot leave. */
.join-cta {
  /* ⚠️ IT SITS ON THE TAB BAR, NOT 61px ABOVE IT. George, 2026-09-04: "small
     gap between fixed bar (with I'm in/invite other players) and tab bar
     below." Two separate strips of nothing, added independently:

     1. THE SAFE AREA, COUNTED TWICE. `padding-bottom: 12px + var(--safe-bottom)`
        was right when this bar was the last thing on the screen — join.html had
        no tab bar under it. That page is gone (2026-09-04) and the CTA now
        always renders inside the app shell, where `.tabbar` already carries
        `padding-bottom: var(--safe-bottom)` for the home indicator. On a phone
        reporting a 34px inset that was 34px of empty bar stacked on 34px of
        empty tab bar. Invisible on the web, where the inset is 0 — which is
        why it survived every desktop check.
     2. THE HOST'S OWN BOTTOM PADDING. `.screen-host` ends in `var(--pad-screen)`,
        and a `sticky; bottom: 0` element parks on the padding edge, leaving a
        15px band below it. Pulling `bottom` and the bottom margin negative by
        the same amount lets the bar reach the real edge. */
  position: sticky; bottom: calc(var(--pad-screen) * -1); z-index: 5;
  margin: 0 calc(var(--pad-screen) * -1) calc(var(--pad-screen) * -1);
  padding: 12px var(--pad-screen);
  background: var(--bg);
}
/* ⚠️ THE SHADOW IS ON THE STUCK STATE, NOT ON THE BAR (George, 2026-09-11:
   "always sticky but only on screens when it would be off screen").

   A SHADOW RATHER THAN A LINE (George, 2026-08-30: "would a bit of shadow
   behind the modal help imply there's more behind?"). It does two jobs a
   hairline cannot: it says the list continues underneath rather than ending,
   and it hides the flicker of white card edges that iOS repaints late during
   a flick — "I can occasionally see the table appearing behind 'can't make
   it'". Upward only, so it reads as the bar lifting off the page.
   No transform and no will-change: a GPU-promoted layer in WKWebView can
   paint above any z-index (CLAUDE.md), and this sits over a scrolling list.

   Both of those jobs are about page passing UNDERNEATH the bar. On a screen
   short enough that nothing is under it, the shadow is a promise of more that
   does not exist — a strip floating in a half-empty screen, which is what a
   four-player game with a time looked like when the bar was first turned on in
   the app. The class is put on and taken off by wireStickiness(). */
.join-cta--stuck {
  box-shadow: 0 -10px 20px -8px rgba(30, 42, 32, 0.16),
              0 -1px 0 rgba(30, 42, 32, 0.06);
}
/* The marker wireStickiness() watches. Zero height and no ink: it exists to be
   looked for, never to be seen, and any height of its own would be a gap under
   the bar on every screen. */
.join-cta__end { height: 0; }
/* The joining-as line rides with the button rather than staying where it was:
   "Joining as Jan" belongs to the control it labels.

   NO PILL IN HERE (George, 2026-08-30: "'joining as hilary tower' appears in a
   pill, but I don't think a pill is needed, can't it just be the sticky modal
   background instead"). He is right — a tinted, bordered box inside another
   box, six pixels from a filled button, is three surfaces stacked to say one
   quiet thing. It keeps the pill everywhere ELSE it is used ("This one's full",
   "You're in"), where it sits alone on the page background and earns it. */
.join-cta .joining-as {
  margin: 0 0 8px;
  background: none; border: 0; border-radius: 0; padding: 0;
  color: var(--muted);
}
.join-cta .btn { margin: 0; }
/* THE WHOLE ASK LIVES IN HERE on a range-game link (George, 2026-09-11: "we
   want everything needed for a response to be in the sticky bar") — the
   question, the two dropdowns, the button. Both elements carry margins tuned
   for sitting in the page flow with a card above and below them; inside a
   bar that already has its own padding those read as slack, and every pixel
   of slack here is a pixel of the game the bar covers. Tightened, not
   removed: the question still needs air above the controls it asks about. */
.join-cta .landing__pick { margin: 0 0 8px; }
/* .landing__pick carries an 18px top margin for sitting under a card on the
   page; inside the bar that is slack, and the rule above removes it. */
.join-cta .av-range { margin: 0 0 12px; }
/* Both answers in the bar (0112). The decline keeps its quiet treatment — a
   text link, not a second button — so reachability is equal without the
   emphasis being equal, and so it cannot be mis-tapped under the green one. */
.join-cta .landing__aslink { display: block; width: 100%; margin: 10px 0 0; }


/* ── Cropping a photo (crop.js) ──────────────────────────────────────────────
   The stage IS the crop: what sits inside this box is exactly what is written
   to the canvas, which is why there is no separate preview under the button.
   touch-action:none because every drag and pinch in here is ours — without it
   the page pans and the photo does not. */
.crop {
  position: relative; overflow: hidden; margin-top: 14px;
  border-radius: var(--r-card); background: var(--surface-tint);
  touch-action: none; user-select: none; -webkit-user-select: none;
  cursor: grab;
}
.crop:active { cursor: grabbing; }
.crop__img {
  position: absolute; left: 50%; top: 50%;
  /* The width/height are set in px by paint(); a global img{max-width:100%}
     would silently cap them and the photo would stop covering the frame. */
  max-width: none; will-change: auto; pointer-events: none;
  -webkit-user-drag: none;
}
/* Only the round shape needs a mask: for the banner the stage and the crop are
   the same rectangle, so there is nothing outside it to dim.
   ⚠️ CLOSEST-SIDE, or the circle lies. A bare "circle" gradient is sized to the
   FARTHEST CORNER, so 50% of it was a radius of 0.35 of the frame — a circle
   70% as wide as the square — while the saved photo is the WHOLE square, drawn
   in a circle that touches its edges. The frame showed less than you got, and
   read as "can't zoom out far enough" (George, 2026-09-23, with a screenshot of
   his own head clipped by it). closest-side makes 50% the frame's half-width,
   so the circle is exactly the avatar. Guarded by tests/browser/crop.test.js. */
.crop--round .crop__mask {
  position: absolute; inset: 0; pointer-events: none;
  background: rgba(20, 24, 19, .55);
  -webkit-mask: radial-gradient(circle closest-side at 50% 50%, transparent 0 99%, #000 100%);
  mask: radial-gradient(circle closest-side at 50% 50%, transparent 0 99%, #000 100%);
}
.crop__zoom { display: block; width: 100%; margin: 16px 0 0; accent-color: var(--accent); }


/* ── The games screen, reordered (0119) ──────────────────────────────────────
   George, 2026-08-30: one game coming up, nine already played, and the nine
   had the screen. The soonest game keeps the full card; everything after it is
   one of these. */

/* ── The club noticeboard (0121) ─────────────────────────────────────────────
   George, 2026-08-30: admins post photos, news and notices to the club. It is
   not about games, which is why it has its own screen and its own strip rather
   than sharing the game card's shapes. */

.nb-post { display: flex; flex-direction: column; gap: 0; position: relative;
  padding: 11px 2px; border-bottom: 1px solid var(--border); }
.nb-post:last-child { border-bottom-color: transparent; }
/* ONE PHOTO GETS THE WIDTH; SEVERAL SHARE IT. Stacked full-size, a two-photo
   post filled the entire screen and the words under it were never seen — so a
   post with more than one goes to a square grid, which is also why the cropper
   uses a fixed 4:3 (a mixed-orientation grid goes ragged). */
.nb-photos { margin-bottom: 10px; }
.nb-photos--grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.nb-photo { width: 100%; aspect-ratio: 4 / 3; border-radius: var(--r-card);
  background-size: cover; background-position: center;
  background-color: var(--surface-tint); }
.nb-photos--grid .nb-photo { aspect-ratio: 1 / 1; }
/* The game a notice is about. UNDERLINED because George asked for it to be
   obvious it can be tapped ("Underlined so its clear it can be tapped to view
   the game info") — and because on a board of grey bylines, colour alone is a
   weak signal and colour-blind readers get nothing from it. */
/* REGULAR WEIGHT (George, 2026-09-01: "the linked games on announcements are in
   bold, isn't regular text fine?"). Semibold plus colour plus an underline was
   three signals for one link, and it outshouted the notice it belonged to. The
   underline alone is enough, and is the one that survives being colour-blind. */
.nb-game { display: block; margin: 8px 0 0; padding: 0; background: none; border: 0;
  font: inherit; font-size: var(--fs-16); color: var(--accent);
  text-decoration: underline; text-underline-offset: 3px; cursor: pointer; text-align: left; }
.nb-game__off { font-weight: var(--w-reg); color: var(--muted); text-decoration: none; }
/* On the no-account page there is nowhere to tap through to, so the same line
   is shown without the underline that would promise one. */
.nb-game--flat { color: var(--ink); text-decoration: none; cursor: default; }
/* THREE OPTIONS, NOT TWO. "In app only / Text attendees / Text club members"
   does not fit the two-up segmented control at 390px, so the labels wrap rather
   than truncate — a truncated audience is the one control here where guessing
   wrong costs money. */
.seg--three .seg__btn { font-size: var(--fs-14); padding-left: 6px; padding-right: 6px;
  line-height: 20px; white-space: normal; }
/* A photo is a control now, so the browser's button styling comes back off. */
.nb-photo { padding: 0; border: 0; cursor: pointer; display: block; }

/* ── A photo, full screen ─────────────────────────────────────────────────
   Fixed and appended to <html> with <body> hidden — see window.PHOTO for why a
   z-index alone is not enough in a WKWebView. The transform lives on the IMG,
   never on .ph itself. */
.ph { position: fixed; inset: 0; z-index: 100; background: #000;
  display: flex; align-items: center; justify-content: center;
  overflow: hidden; touch-action: none; }
.ph__stage { width: 100%; height: 100%; display: flex; align-items: center;
  justify-content: center; overflow: hidden; }
.ph__img { max-width: 100%; max-height: 100%; transform-origin: center center;
  will-change: transform; user-select: none; -webkit-user-drag: none; }
.ph__x { position: absolute; top: calc(var(--safe-top) + 10px); right: 14px; z-index: 2;
  width: 40px; height: 40px; border-radius: 50%; border: 0; cursor: pointer;
  background: rgba(0, 0, 0, .45); color: #fff; font-size: 20px; line-height: 1; }

.nb-body { min-width: 0; }
.nb-text { font-size: var(--fs-16); line-height: 24px; white-space: pre-wrap; }
.nb-by { font-size: var(--fs-12); color: var(--muted); line-height: 22px; margin-top: 2px; }
/* Taking one down is a rare, destructive action, so it is quiet and to the
   side rather than a row of its own — and it only renders for somebody who may
   actually do it (the server checks again regardless). */
/* ROOM FOR THE ✕, and only where there is one. It is absolutely positioned, so
   without this the words ran underneath it — "Sign up at the desk" with the
   cross sitting on the "at". Padding every post instead would cost 32px of
   width on the ones that do not have it. */
.nb-post--mine .nb-body { padding-right: 32px; }
.nb-x { position: absolute; top: 8px; right: 0; width: 32px; height: 32px; padding: 0;
  border: 0; background: none; color: var(--muted-4); font-size: 15px; cursor: pointer; }
.nb-x:hover { color: #A8443A; }


/* The composer */
.nb-write { min-height: 96px; resize: none; line-height: 24px; }
.nb-adds { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.nb-add { width: 62px; height: 62px; border-radius: var(--r-card); cursor: pointer;
  /* A dashed outline HERE is correct: it is an empty place waiting to be
     filled, which is the one thing the dashes are allowed to mean. */
  border: 1.5px dashed var(--border-dashed); background: none;
  color: var(--accent-2); font-size: 22px; }
.nb-thumb { width: 62px; height: 62px; border-radius: var(--r-card); position: relative;
  background-size: cover; background-position: center; border: 1px solid var(--border); }
.nb-thumb__x { position: absolute; top: -6px; right: -6px; width: 22px; height: 22px;
  border-radius: 50%; border: 1px solid var(--border); background: var(--bg);
  color: var(--muted); font-size: 11px; padding: 0; cursor: pointer; line-height: 1; }
.nb-cost { font-size: var(--fs-12); color: #8A6D1F; background: rgba(138, 109, 31, .10);
  border-radius: var(--r-card); padding: 9px 12px; margin: 10px 0 0; }


/* ── The Games tab (nav redesign, 2026-09-18) ────────────────────────────────
   design_handoff_rally_nav_redesign, mocks 6a / 7a / 7b / 7c / 7f.

   ⚠️ THE 18px FLOOR HOLDS HERE TOO. The handoff sets its metadata at 12.5px,
   its overlines at 10-11px and its date stamps at 9.5px; every one of those is
   raised to the floor (CLAUDE.md), and the hierarchy comes from weight, colour
   and the monospace face instead. Flagged to George 2026-09-18. */

/* A small-caps label in the monospace face: month headings, "Previous". */
.overline { font-family: var(--font-mono); font-size: var(--fs-13); font-weight: var(--w-semi);
  letter-spacing: 1.4px; text-transform: uppercase; color: var(--muted);
  margin: 22px 0 10px; }

/* THE NEXT GAME. */
.ghero { background: var(--green-deep); color: #fff; border-radius: 22px;
  padding: 20px; margin-bottom: 18px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.ghero__top { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.ghero__over { font-family: var(--font-mono); font-size: var(--fs-13); font-weight: var(--w-semi);
  letter-spacing: 1.4px; text-transform: uppercase; color: var(--on-green-2); min-width: 0; }
.ghero__chip { flex: 0 0 auto; font-size: var(--fs-13); font-weight: var(--w-semi);
  background: rgba(255, 255, 255, .16); color: #fff; border-radius: var(--r-pill); padding: 3px 10px; }
.ghero__time { font-size: 38px; line-height: 1; font-weight: var(--w-bold); letter-spacing: -1.4px;
  margin-top: 10px; }
.ghero--cancelled .ghero__time { text-decoration: line-through; opacity: .7; }
/* Wraps rather than truncating (design 7f), and wraps nicely. */
.ghero__what { font-size: var(--fs-15); font-weight: 500; line-height: 1.4; color: var(--on-green);
  margin-top: 7px; text-wrap: pretty; }
.ghero__foot { display: flex; align-items: center; justify-content: space-between; gap: 12px;
  border-top: 1px solid rgba(255, 255, 255, .18); margin-top: 20px; padding-top: 15px; }
.ghero__count { font-size: var(--fs-14); font-weight: var(--w-semi); color: var(--on-green);
  white-space: nowrap; }
.ghero__act { margin-top: 14px; }
.ghero__join { width: 100%; background: #fff; color: var(--green-deep); border: 0; }
.ghero .fstack__f { border-color: var(--green-deep); background: rgba(255, 255, 255, .16); color: #fff; }

/* Overlapping faces. The initials are glyphs inside a fixed-size shape, so they
   take the same exemption from the 18px floor as every avatar (see .slot). */
.fstack { display: inline-flex; align-items: center; flex: 0 0 auto; }
.fstack__f { width: 32px; height: 32px; font-size: 11px; font-weight: var(--w-semi);
  border: 2px solid var(--bg); margin-left: -9px; }
.fstack__f:first-child { margin-left: 0; }
.fstack__more { background: var(--surface-tint); color: var(--accent); }

/* NOTHING COMING UP. Dashed, because it is an empty place waiting to be
   filled — the one thing a dashed outline is allowed to mean (CLAUDE.md). */
.gempty { border: 1.5px dashed rgba(30, 42, 32, .22); border-radius: 22px;
  padding: 26px 22px; text-align: center; margin-bottom: 18px; }
.gempty__t { font-size: var(--fs-20); font-weight: var(--w-bold); }
.gempty__go { display: block; width: 100%; margin-top: 18px; min-height: 46px;
  background: var(--green-primary); color: #fff; border: 0; border-radius: 12px; }

/* ONE GROUPED LIST: the rows sit on the page colour and the gaps between them
   are a 1px hairline showing through, not borders. */
.glist { display: flex; flex-direction: column; gap: 1px; background: var(--hairline);
  border-radius: 14px; overflow: hidden; }
.grow, .gplayed { display: flex; align-items: center; gap: 14px; width: 100%; text-align: left;
  background: var(--bg); border: 0; border-radius: 0; margin: 0;
  padding: 11px 14px; min-height: 66px; cursor: pointer; font: inherit; color: inherit;
  -webkit-tap-highlight-color: transparent; }
/* The date column never wraps (design 7f) — the title beside it does. */
.grow__d { flex: 0 0 auto; min-width: 84px; white-space: nowrap; display: flex; flex-direction: column; }
.grow__d span { font-family: var(--font-mono); font-size: var(--fs-13); font-weight: var(--w-semi);
  letter-spacing: 1px; color: var(--muted); line-height: 22px; }
.grow__d b { font-size: var(--fs-15); font-weight: var(--w-bold); line-height: 22px; }
.grow__t { flex: 1; min-width: 0; font-size: var(--fs-16); font-weight: var(--w-semi); line-height: 22px;
  text-wrap: pretty; }
.grow__m { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px;
  font-weight: 500; color: var(--muted); line-height: 22px; }
.grow__m .chip { line-height: 20px; padding: 0 9px; }
.grow__go, .gplayed__c { flex: 0 0 auto; color: var(--muted); font-size: 20px; }
.grow--off .grow__d b { text-decoration: line-through; color: var(--muted); }
/* THE ONE WARM THING ON THE SCREEN. A game waiting on your answer is the only
   row with a task on it — marked in place rather than floated to the top,
   because an order that rearranges itself is an order you cannot trust. */
/* (The cream background it used to carry is gone — George, 2026-09-18: on
   the redesign's flat rows it read as a different surface, "one looks green
   one looks like card". The green "Answer" pill on the row marks it alone.) */
/* The archive row: the tail of the list, quieter than a game. */
.gplayed__t { flex: 1; font-size: var(--fs-16); font-weight: 500; color: var(--muted); line-height: 22px; }

/* A title with a line under it: the chevron centres on the two-line block. */
.navbar--sub .navbar__back { bottom: 11px; }

/* ── The Clubs tab (nav redesign, 2026-09-18) ────────────────────────────────
   Mocks 7h / 7e / 7g / 7d / 7i / 5c. */
.cchips { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 14px; }
/* A long club name fills the row and the next chip wraps (design 7f), rather
   than being cut — a club you cannot read the name of cannot be picked. */
.cchip { min-height: 36px; padding: 4px 12px; border: 0; border-radius: 9px; cursor: pointer;
  font: inherit; font-size: var(--fs-14); font-weight: var(--w-semi); text-align: left;
  background: rgba(30, 42, 32, .06); color: var(--muted); -webkit-tap-highlight-color: transparent; }
.cchip[aria-selected="true"] { background: var(--green-primary); color: #fff; }

/* The cover keeps the 16:5 the cropper produces (window.CROP.BANNER) rather
   than the handoff's fixed 152px: a frame of any other shape shaves a
   different slice off on every phone, and the crop somebody chose is not what
   they get. Only the corners and the border change. */
.ccover .club-card__img { border-radius: 16px; border: 0; overflow: hidden; }
.cname { font-size: var(--fs-22); font-weight: var(--w-bold); letter-spacing: -.01em; margin: 13px 0 0; }
.cplace { font-size: var(--fs-14); font-weight: 500; color: var(--muted); margin-top: 3px; }

.cmembers { display: flex; align-items: center; gap: 12px; width: 100%; margin-top: 14px;
  padding: 13px 0; min-height: 60px; background: none; cursor: pointer; font: inherit; color: inherit;
  border: 0; border-top: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline);
  text-align: left; -webkit-tap-highlight-color: transparent; }
.cmembers .fstack__f { width: 30px; height: 30px; background: var(--surface-tint); color: var(--accent);
  border-color: var(--bg); }
.cmembers__n { flex: 1; font-size: var(--fs-14); font-weight: var(--w-semi); }
.cmembers__go { color: var(--muted); font-size: 20px; }

/* The posts: flat on the page, a hairline between each. */
.cposts .nb-post { padding: 18px 0 16px; border-bottom: 1px solid var(--hairline); }
.cposts .nb-post:last-child { border-bottom-color: var(--hairline); }
.cposts .nb-text { font-weight: 500; line-height: 1.42; }
.cempty { margin-top: 18px; }
/* A member's empty state: words only, because a member has nothing to do. */
.cquiet { text-align: center; padding: 34px 12px; font-size: var(--fs-15);
  font-weight: var(--w-semi); color: var(--ink); }
.cnone { text-align: center; padding: 48px 12px; }
.cnone__t { font-size: var(--fs-20); font-weight: var(--w-bold); }

/* Several photos in a post go three to a row; one gets the width. */
.nb-photos { margin: 12px 0 0; }
.nb-photos--grid { grid-template-columns: repeat(3, 1fr); gap: 8px; }

/* "You" on your own row of a member list: a quiet tint, not the filled pill
   that means "your game". */
.chip--me { background: var(--surface-tint); color: var(--accent); flex: 0 0 auto; }

/* The settings sheet (7i): the club's name, then what the sheet is. */
.sheet__kicker { font-size: var(--fs-13); color: var(--muted); margin: 0 0 6px; }
.csheet { margin-top: 10px; }
.settings-row__end { display: inline-flex; align-items: center; gap: 10px; flex: 0 0 auto; }
.settings-row__val { color: var(--muted); }
.settings-row__end .fstack__f { width: 28px; height: 28px; background: var(--surface-tint);
  color: var(--accent); font-size: 10px; }

/* Friends, nobody yet (5e): a tinted card, and a real way to find people. */
.fempty { background: var(--surface-tint); border-radius: 18px; padding: 22px; }
.fempty__t { font-size: var(--fs-20); font-weight: var(--w-bold); }

/* ── Profile (nav redesign, 6d) ─────────────────────────────────────────────
   The photo on the LEFT with the name and clubs beside it, rather than a big
   centred avatar; then three counts; then the rows. */
.pf--top { display: flex; align-items: center; gap: 16px; text-align: left; margin-top: 6px; }
.pf--top .pf-avatar-wrap { width: 68px; height: 68px; margin: 0; flex: 0 0 auto; }
.pf--top .pf-avatar { width: 68px; height: 68px; font-size: 24px; }
.pf--top .pf-avatar__edit { width: 28px; height: 28px; bottom: -4px; right: -4px; background: var(--green-primary); }
.pf--top .pf-avatar__edit svg { width: 14px; height: 14px; }
.pf-id { min-width: 0; }
.pf--top .pf-name { font-size: var(--fs-20); line-height: 1.2; }
.pf-clubs { font-size: var(--fs-14); font-weight: 500; color: var(--muted); line-height: 1.35; margin-top: 2px; }
.pf-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 20px 0 0; }
.pf-stat { background: var(--surface); border-radius: 14px; padding: 14px; min-width: 0; }
.pf-stat b { display: block; font-size: var(--fs-22); font-weight: var(--w-bold); line-height: 1; }
.pf-stat span { display: block; font-size: var(--fs-12); font-weight: 500; color: var(--muted);
  line-height: 1.2; margin-top: 6px; }
.pf-stats + .pf .settings-list { margin-top: 18px; }
.navbar--sub .navbar__actions { bottom: 11px; }
.navbar--sub .navbar__action { min-height: 44px; }
/* Somebody who has answered but is not seated, on the dark card: dashed, as
   on the game screen, in a colour that shows on green. */
.ghero .fstack__f.slot--pending { background: none; border: 1.5px dashed var(--on-green-2); color: var(--on-green); }
/* EVERY SETTINGS ROW THE SAME HEIGHT (CLAUDE.md). The chevron is 20px and sat
   taller than the words, so a row without one (Leave club, Delete account) was
   2px shorter than its neighbours; the Friends row's faces made it 5px taller.
   A fixed minimum with everything centred inside it settles both — found by
   tests/browser/sheet_rows.test.js once it was made to look at these lists. */
.settings-row { min-height: 60px; padding-top: 7px; padding-bottom: 7px; }
/* 7px, not 10: the Friends row's two 22px lines (44) plus padding must still
   come in at 60 — at 10px it was 65 and the check above caught it. */
/* "any time between" in front of a window game's hours: the server's words,
   quieter than the hours themselves. */
.ghero__pre { display: block; font-size: var(--fs-15); font-weight: 500; letter-spacing: 0;
  color: var(--on-green); margin-bottom: 6px; }

/* The Friends row (design 6d): the count UNDER the label and the faces on the
   right, in the same 60px as every other row — two 22px lines fit inside it.
   It is the one captioned row in an otherwise bare action list, which CLAUDE.md
   forbids; George asked for it by name (2026-09-18), so it is a named exception
   in tests/browser/sheet_rows.test.js rather than a quiet one. */
.settings-row__two { display: flex; flex-direction: column; text-align: left; line-height: 22px; }
.settings-row__sub { color: var(--muted); font-weight: 500; }
/* "✕" on the notice composer's linked-game row: take the game off again. A
   glyph in a fixed shape, so it takes the same exemption from the 18px floor
   as `.slot` initials and the guest row's ✕ (CLAUDE.md). It is NOT the icon
   column the settings-sheet rule bans — that sits to the LEFT of the words and
   is the same on every row; this is on the right and is a control. */
.settings-row__x { color: var(--muted); font-size: 16px; padding: 0 2px; }

/* ── 0157: unread, joining, adding friends ─────────────────────────────────── */
/* The Clubs tab's count. The number is a glyph inside a fixed-size shape, so it
   takes the same exemption from the 18px floor as avatar initials (CLAUDE.md). */
.tabbar__btn[data-count]:not([data-count=""])::after {
  content: attr(data-count); position: absolute; top: 2px; left: calc(50% + 4px);
  min-width: 17px; height: 17px; padding: 0 5px; border-radius: 9px; box-sizing: border-box;
  background: var(--green-primary); color: #fff; font-size: 10px; font-weight: var(--w-bold);
  line-height: 17px; text-align: center; }
/* The dot on a club's chip: something new there. */
.cchip__dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%;
  background: var(--green-primary); margin-left: 8px; vertical-align: middle; }
/* "+ Join": a thin solid edge, not the design's dashed outline — dashed means an
   empty place waiting for somebody, never a button (CLAUDE.md). */
.cchip--join { background: none; border: 1px solid rgba(30, 42, 32, .22); color: var(--accent); }
.cmembers__ask { color: var(--accent); }

/* A club you could join (design 7d). */
/* ONE LINE EACH (George, 2026-09-18: "why does a row like this look so bad").
   At the 18px floor the name and the place wrapped into four ragged lines
   beside a 56px picture and a wide button. Now: a 44px picture, a tighter
   button, and each line cut with an ellipsis rather than wrapped. The member
   count leads the second line, so what gets cut is the town — the name above
   it usually says where the club is anyway. */
.cfind { display: flex; align-items: center; gap: 10px; background: var(--surface);
  border-radius: 16px; padding: 10px 10px 10px 12px; margin-bottom: 10px; min-height: 66px; }
.cfind__img { width: 44px; height: 44px; flex: 0 0 auto; border-radius: 10px; overflow: hidden; }
.cfind__img .club-card__img { aspect-ratio: auto; height: 44px; border: 0; }
.cfind__img .club-card__mono { font-size: 20px; }
.cfind__t { flex: 1; min-width: 0; }
.cfind__name, .cfind__meta { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 22px; }
.cfind__name { font-size: var(--fs-15); font-weight: var(--w-bold); }
.cfind__meta { font-size: var(--fs-13); font-weight: 500; color: var(--muted); }
.cfind__go { flex: 0 0 auto; min-height: 40px; padding: 0 11px; border: 0; border-radius: 9px;
  background: rgba(30, 42, 32, .06); color: var(--accent); font: inherit;
  font-size: var(--fs-14); font-weight: var(--w-semi); cursor: pointer; }
.cfind__go--done { background: var(--surface-tint); }

/* A person row's trailing mark: a ＋ to add, or "Friend" once you have. The ＋
   is a glyph in a fixed 32px circle (the avatar exemption). */
.befriend { flex: 0 0 auto; width: 32px; height: 32px; border-radius: 50%; padding: 0;
  border: 1px solid rgba(30, 42, 32, .22); background: none; color: var(--accent);
  font-size: 18px; line-height: 1; cursor: pointer; }
.friend-mark { flex: 0 0 auto; color: var(--muted); font-size: var(--fs-13); font-weight: 500; }
#club-q { margin-bottom: 4px; }
.pf-id { flex: 1; }
/* "Edit" beside the name (design 6d): a text button with a full 44pt target. */
.pf-edit { flex: 0 0 auto; min-height: 44px; padding: 0 4px; background: none; border: 0;
  color: var(--accent); font: inherit; font-size: var(--fs-14); font-weight: var(--w-semi); cursor: pointer; }

/* Answering a join request: a round ✕ and a round ✓, the same 32px size as the
   ＋ on a member row, with a 44pt row around them. Glyphs in a fixed shape (the
   avatar exemption from the 18px floor). */
.answer { flex: 0 0 auto; width: 38px; height: 38px; border-radius: 50%; padding: 0;
  font-size: 17px; line-height: 1; cursor: pointer; }
.answer--no { background: none; border: 1px solid rgba(30, 42, 32, .22); color: var(--muted); }
.answer--yes { background: var(--green-primary); border: 0; color: #fff; }
/* A long name is cut with an ellipsis rather than pushing the row to three
   lines — caught by clubs_find_friends.test.js on "Alexandra Montgomery-Smythe". */
[data-request] .inv-row__name { min-width: 0; }
.one-line { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* The place a postcode finds, under the box (0159). Muted while it looks, the
   app's green once found, the danger red when nothing matched. */
.zip-found { display: block; min-height: 22px; margin-top: 6px; line-height: 22px;
  font-size: var(--fs-14); font-weight: 500; color: var(--muted); }
.zip-found--ok { color: var(--accent); }
.zip-found--bad { color: #8C3A2E; }
.zip-found:empty { min-height: 0; margin-top: 0; }  /* no gap when there is nothing to say */

/* ═══════════════════════════════════════════════════════════════════════════
   EVENTS AND LADDERS (0161, design_handoff_events_ladder, 2026-09-21)
   ═══════════════════════════════════════════════════════════════════════════ */
/* YOUR ROW, everywhere a list of people is drawn: tinted, name in bold. The
   avatar stays as it is. No "You" chip (it came off the club roster).
   SQUARE, EXCEPT WHERE THE LIST ITSELF IS ROUND (George, 2026-09-23: "corners
   are curved. Think that should only happen at top/bottom of a list not in the
   middle of it"). The tint runs edge to edge, so it is a band across the card,
   and a band in the middle of a list with rounded ends reads as a pill laid on
   top of it. First in the list takes the card's top corners, last its bottom
   ones — the card's radius less its 1px border, so the tint meets the outline
   rather than poking a corner past it. Guarded by tests/browser/events.test.js. */
.inv-row--you { background: var(--surface-tint); border-radius: 0;
  margin: 0 calc(var(--pad-card) * -1); padding-left: var(--pad-card); padding-right: var(--pad-card); }
.inv-row--you:first-child { border-top-left-radius: calc(var(--r-card) - 1px); border-top-right-radius: calc(var(--r-card) - 1px); }
.inv-row--you:last-child { border-bottom-left-radius: calc(var(--r-card) - 1px); border-bottom-right-radius: calc(var(--r-card) - 1px); }
.inv-row--you .inv-row__name, .inv-row--you .nm-first { font-weight: var(--w-bold); }
.settings-list .inv-row--you { margin: 0; }

.ev-list { margin-top: 6px; }
.ev-list--after { margin-top: 26px; }
.ev-empty { margin-top: 6px; }
/* A <span>, not a <button>, so nothing centres its text in the row's 38px:
   it sat at the top, above its icon. The line-height is the centring. */
.set-list__tap--fixed { color: var(--ink); cursor: default; line-height: 38px; }

/* The standings: a rank column before the face. The second line never wraps —
   a row with more to say is still exactly as tall as one with nothing. */
.lad-row { cursor: pointer; }
.lad-row .inv-row__name { min-width: 0; }
.lad-row .inv-row__meta { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lad-rank { flex: 0 0 22px; font-family: var(--font-mono); font-size: var(--fs-15);
  font-weight: var(--w-semi); color: var(--muted); text-align: center; }
.lad-playing { color: var(--accent); }
/* "Challenge" is a chip you press — tinted like a quiet button, never dashed. */
.lad-chip { flex: 0 0 auto; margin-left: auto; background: var(--surface-tint); color: var(--green-primary);
  border: 0; border-radius: 10px; padding: 9px 12px; font: inherit; font-size: var(--fs-15);
  font-weight: var(--w-semi); cursor: pointer; -webkit-tap-highlight-color: transparent; }
.lad-invited { margin: 12px 0 0; }

/* Your live match, above the standings. */
.lmatch { background: var(--surface-tint); border-radius: 18px; padding: 16px; margin-bottom: 16px; }
.lmatch__head { display: flex; align-items: center; gap: 12px; }
.lmatch__t { flex: 1; min-width: 0; }
.lmatch__who { font-size: var(--fs-16); font-weight: var(--w-bold); line-height: 22px; }
.lmatch__by { color: var(--muted); font-weight: 500; line-height: 22px; }
.lmatch__text { flex: 0 0 auto; background: var(--surface); border: 0; border-radius: 10px; padding: 8px 12px;
  font: inherit; font-size: var(--fs-15); font-weight: var(--w-semi); color: var(--accent); cursor: pointer; }
.lmatch .cx-actions { margin-top: 14px; }
.lmatch .dash-card { margin: 14px 0 0; background: var(--surface); }
.lad-reported { font-weight: 500; margin: 12px 0 0; line-height: 1.4; }

/* The challenge and incoming sheets: a face beside the question. */
.lsheet__head { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.lsheet__p { color: var(--ink); font-size: var(--fs-16); line-height: 1.45; margin: 8px 0 16px; }

/* The two texted-link pages. */
.lhead { padding: 6px 0 12px; }
.lhead__name { font-size: 34px; font-weight: var(--w-bold); letter-spacing: -0.02em; line-height: 1.1;
  margin: 4px 0 6px; text-wrap: balance; }
.lad-you { color: var(--accent); font-weight: var(--w-semi); }
.lad-score { margin-left: auto; font-family: var(--font-mono); font-size: var(--fs-16); font-weight: var(--w-bold);
  color: var(--muted); white-space: nowrap; }
.lad-score--won { color: var(--ink); }

/* Report result: a row per player, a box per game, sideways when there are
   more than fit. An empty box is dashed: it is waiting for a number. */
.score-scroll { overflow-x: auto; padding-bottom: 4px; }
.score-row { margin-top: 12px; }
.score-who { display: flex; align-items: center; gap: 10px; font-size: var(--fs-16); font-weight: var(--w-semi); }
.score-boxes { display: flex; gap: 10px; margin-top: 8px; padding-left: 50px; }
.score-in { width: 84px; height: 56px; flex: 0 0 auto; text-align: center; font: inherit;
  font-size: var(--fs-20); font-weight: var(--w-bold); color: var(--ink);
  background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
.score-in:placeholder-shown { border-style: dashed; border-color: var(--border-dashed); }
.score-more { margin-top: 6px; }

/* A second row under the members row on the club page: its events. */
.cmembers + .cmembers { margin-top: 0; border-top: 0; }
.cmembers--events .cmembers__n { padding-left: 2px; }
.csports { color: var(--muted); font-weight: 500; margin-top: 2px; }

/* ── The zone a time is in (0163) ────────────────────────────────────────────
   "10:00 AM EDT": shown ONLY when the game's zone is not this phone's (George,
   2026-09-23: "ONLY when viewing from other timezones"). Quiet, on the time's
   own line — a third line would make that one row taller than its neighbours.
   18px like everything anyone reads; it is smaller than the time only because
   the time is big. */
.tz-tag { font-style: normal; font-size: var(--fs-15); font-weight: var(--w-semi);
  letter-spacing: 0; color: var(--muted); white-space: nowrap; }
.ghero .tz-tag { color: inherit; opacity: .75; }
.hero-when .tz-tag { font-weight: var(--w-semi); }
.tz-tag--form { display: block; }
.tz-tag--form[hidden] { display: none; }

/* A row that states a fact and cannot be pressed (0164: somebody "Away from
   everything" seen by a club admin — theirs to change, not the club's). Shown
   as unavailable rather than explained (CLAUDE.md). */
.settings-row:disabled { cursor: default; color: var(--muted); }
