/* ==========================================================================
   Design system
   Light and dark are both first-class; every colour is a token so the whole
   surface can be rethened from this one block.
   ========================================================================== */

:root {
  --bg: #f4f6f8;
  --surface: #ffffff;
  --surface-2: #eef1f4;
  --surface-3: #e4e8ec;
  --border: #dfe4e9;
  --border-strong: #c8d0d8;

  --text: #10161c;
  --text-dim: #5d6b78;
  --text-faint: #8a97a3;

  --accent: #04a06a;
  --accent-hover: #038759;
  --accent-fg: #ffffff;
  --accent-soft: #e2f5ed;
  --accent-line: #9adcc3;

  --danger: #d1344b;
  --danger-soft: #fdeaed;
  --warn: #b4690e;
  --warn-soft: #fdf1de;

  /* Categorical series colours, for telling one *thing* from another rather
     than more from less. Assigned in this fixed order and never cycled, so a
     sport keeps its colour whatever it ranks. Deliberately not the accent:
     these carry identity, and the accent means "this is the club". The order
     is the colourblind-safety mechanism — adjacent pairs are the ones checked,
     so re-order only against the same check. */
  --series-1: #2a78d6;
  --series-2: #eb6834;
  --series-3: #1baf7a;
  --series-4: #eda100;
  --series-5: #e87ba4;
  --series-6: #008300;
  --series-rest: #8a97a3;

  --radius-sm: 8px;
  --radius: 12px;
  --radius-lg: 18px;
  --radius-full: 999px;

  --shadow-sm: 0 1px 2px rgba(16, 22, 28, 0.06);
  --shadow: 0 4px 16px -4px rgba(16, 22, 28, 0.12);
  --shadow-lg: 0 24px 60px -12px rgba(16, 22, 28, 0.28);

  --header-h: 60px;
  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);

  color-scheme: light;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0b0e11;
    --surface: #14191e;
    --surface-2: #1b2127;
    --surface-3: #232a31;
    --border: #262e36;
    --border-strong: #38424c;

    --text: #e9edf1;
    --text-dim: #98a5b1;
    --text-faint: #6d7a86;

    --accent: #12b880;
    --accent-hover: #2fcf98;
    --accent-fg: #04120c;
    --accent-soft: #10291f;
    --accent-line: #1c5a42;

    --danger: #f06a7e;
    --danger-soft: #2c1418;
    --warn: #dda43f;
    --warn-soft: #2b2013;

    /* The same six hues, re-stepped for the dark surface. Not an automatic
       flip of the light values: a colour that reads at 3:1 on white does not
       on near-black, so each step was chosen and checked against this surface. */
    --series-1: #3987e5;
    --series-2: #d95926;
    --series-3: #199e70;
    --series-4: #c98500;
    --series-5: #d55181;
    --series-6: #008300;
    --series-rest: #6d7a86;

    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
    --shadow: 0 4px 16px -4px rgba(0, 0, 0, 0.5);
    --shadow-lg: 0 24px 60px -12px rgba(0, 0, 0, 0.7);

    color-scheme: dark;
  }
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  padding: 0;
}

body {
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto,
    "Helvetica Neue", Arial, sans-serif;
  font-size: 15px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  min-height: 100vh;
}

h1, h2, h3, h4 {
  margin: 0;
  font-weight: 640;
  letter-spacing: -0.018em;
  line-height: 1.25;
}

h1 { font-size: 1.6rem; }
h2 { font-size: 1.18rem; }
h3 { font-size: 1rem; }

p { margin: 0 0 0.75rem; }
p:last-child { margin-bottom: 0; }

a {
  color: var(--accent);
  text-decoration: none;
}
a:hover { text-decoration: underline; }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 4px;
}

.tnum { font-variant-numeric: tabular-nums; }

.muted { color: var(--text-dim); }
.faint { color: var(--text-faint); }
.small { font-size: 0.82rem; }

.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;
}

/* ==========================================================================
   Layout
   ========================================================================== */

.shell {
  max-width: 1180px;
  margin: 0 auto;
  padding: 0 20px 80px;
}

.shell--narrow { max-width: 460px; }
.shell--mid { max-width: 780px; }

.page-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  padding: 28px 0 18px;
}

.page-head p { color: var(--text-dim); margin: 4px 0 0; font-size: 0.9rem; }

.stack { display: grid; gap: 14px; }
.row { display: flex; align-items: center; gap: 10px; }
.row--wrap { flex-wrap: wrap; }
.spacer { flex: 1; }

/* ==========================================================================
   Header
   ========================================================================== */

/* The host the script renders the bar into, boxless. A sticky element can only
   travel within its own parent, and this div is exactly one bar tall — so the
   bar had nowhere to travel to and rode the page out of view, `position:
   sticky` notwithstanding. `display: contents` drops the wrapper's box and
   hands the bar the body to move within. */
/* Every screen here is its own document, so moving between them replaces the
   whole page at once — header included — and that swap is what reads as the
   site reloading rather than as a menu being used. Where the browser supports
   it, it now holds the outgoing screen until the incoming one is ready and
   crosses between them instead.

   The header is named, which lifts it out of that crossing and carries it over
   as one continuous element. It is drawn from the remembered session (see
   common.js) so it is already there, and identical, on both sides — the two
   halves together are what make it stay put rather than blink.

   Ignored wholesale by browsers that do not know these rules, which then get
   exactly the behaviour they had before. */
@view-transition { navigation: auto; }

.header { view-transition-name: app-header; }

/* Short. This is a page swap being smoothed over, not an effect, and anything
   longer turns a navigation the reader asked for into a wait. */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 130ms;
}

/* The reduced-motion block further down cannot reach these: it works on
   elements, and a view transition animates pseudo-elements that sit outside
   the document. Turned off at the source instead. */
@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
}

#chrome { display: contents; }

/* Until the script paints the bar this host is empty, and a boxless empty div
   is worth no height at all — so every page drew its heading hard against the
   top of the window and then jolted it 60px down the moment /api/me came back
   and the header arrived. Jumping between menu items was one of these per
   click. `:empty` stops matching as soon as the bar is appended, so this holds
   exactly the bar's height, surface and rule in the meantime and the first
   paint already has the page where it stays. */
#chrome:empty {
  display: block;
  height: var(--header-h);
  background: var(--surface);
  border-bottom: 1px solid var(--border);
}

.header {
  position: sticky;
  top: 0;
  z-index: 40;
  height: var(--header-h);
  background: color-mix(in srgb, var(--surface) 88%, transparent);
  backdrop-filter: saturate(180%) blur(14px);
  -webkit-backdrop-filter: saturate(180%) blur(14px);
  border-bottom: 1px solid var(--border);
}

.header__inner {
  max-width: 1180px;
  margin: 0 auto;
  padding: 0 20px;
  height: 100%;
  display: flex;
  align-items: center;
  gap: 18px;
}

.brand {
  display: flex;
  align-items: center;
  gap: 10px;
  color: var(--text);
  font-weight: 660;
  letter-spacing: -0.02em;
  white-space: nowrap;
}
.brand:hover { text-decoration: none; }

.brand__mark {
  width: 28px;
  height: 28px;
  border-radius: 9px;
  background: linear-gradient(145deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #0a7fb8));
  display: grid;
  place-items: center;
  color: var(--accent-fg);
  font-size: 15px;
  flex: none;
  box-shadow: var(--shadow-sm);
}

/* An uploaded logo, in place of .brand__mark. Same footprint so swapping one
   for the other never shifts the name beside it. `contain` rather than
   `cover`, because a club's logo is rarely square and cropping it is worse
   than letting it sit inside the box. */
.brand__logo {
  width: 28px;
  height: 28px;
  border-radius: 9px;
  object-fit: contain;
  flex: none;
}

.brand__sub {
  font-size: 0.72rem;
  font-weight: 500;
  color: var(--text-faint);
  letter-spacing: 0.01em;
}

.nav {
  display: flex;
  align-items: center;
  gap: 2px;
  margin-left: 6px;
}

.nav a {
  padding: 7px 12px;
  border-radius: var(--radius-sm);
  color: var(--text-dim);
  font-size: 0.88rem;
  font-weight: 520;
  transition: background 0.14s var(--ease), color 0.14s var(--ease);
}
.nav a:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
.nav a[aria-current="page"] { background: var(--surface-2); color: var(--text); }

.avatar {
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--accent-soft);
  color: var(--accent);
  border: 1px solid var(--accent-line);
  display: grid;
  place-items: center;
  font-size: 0.74rem;
  font-weight: 680;
  letter-spacing: 0.02em;
  flex: none;
}

.usermenu { position: relative; }

.usermenu__panel {
  position: absolute;
  right: 0;
  top: calc(100% + 8px);
  min-width: 208px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-lg);
  padding: 6px;
  display: none;
}
.usermenu__panel[data-open="true"] { display: block; }

.usermenu__head {
  padding: 8px 10px 10px;
  border-bottom: 1px solid var(--border);
  margin-bottom: 6px;
}
.usermenu__head strong { display: block; font-size: 0.9rem; }
.usermenu__head span {
  display: block;
  font-size: 0.78rem;
  color: var(--text-faint);
  overflow: hidden;
  text-overflow: ellipsis;
}

.usermenu__panel button,
.usermenu__panel a {
  display: block;
  width: 100%;
  text-align: left;
  padding: 8px 10px;
  border: 0;
  background: none;
  border-radius: var(--radius-sm);
  font: inherit;
  font-size: 0.88rem;
  color: var(--text);
  cursor: pointer;
}
.usermenu__panel button:hover,
.usermenu__panel a:hover { background: var(--surface-2); text-decoration: none; }

/* ==========================================================================
   Buttons
   ========================================================================== */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  padding: 9px 16px;
  border-radius: var(--radius-sm);
  border: 1px solid transparent;
  background: var(--surface-2);
  color: var(--text);
  font: inherit;
  font-size: 0.89rem;
  font-weight: 560;
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.14s var(--ease), border-color 0.14s var(--ease),
    transform 0.1s var(--ease), opacity 0.14s var(--ease);
}
.btn:hover { background: var(--surface-3); text-decoration: none; }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }

.btn--primary {
  background: var(--accent);
  color: var(--accent-fg);
  box-shadow: var(--shadow-sm);
}
.btn--primary:hover { background: var(--accent-hover); }

.btn--ghost { background: transparent; border-color: var(--border); color: var(--text-dim); }
.btn--ghost:hover { background: var(--surface-2); color: var(--text); }

.btn--danger { background: transparent; border-color: var(--border); color: var(--danger); }
.btn--danger:hover { background: var(--danger-soft); border-color: var(--danger); }

.btn--block { width: 100%; }
.btn--sm { padding: 6px 11px; font-size: 0.82rem; border-radius: 7px; }
.btn--lg { padding: 12px 22px; font-size: 0.95rem; }

/* ==========================================================================
   Cards, badges, notices
   ========================================================================== */

.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 20px;
  box-shadow: var(--shadow-sm);
}

.card--flush { padding: 0; overflow: hidden; }

.card__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  margin-bottom: 14px;
}

.badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 3px 9px;
  border-radius: var(--radius-full);
  font-size: 0.73rem;
  font-weight: 620;
  letter-spacing: 0.01em;
  background: var(--surface-2);
  color: var(--text-dim);
  border: 1px solid var(--border);
  white-space: nowrap;
}
/* Same trap as `.field[hidden]` below: `display: inline-flex` above is an
   author rule and outranks the browser's own `[hidden]` one whatever the
   specificity, so a badge switched off in script stayed on screen as an empty
   pill instead of disappearing. */
.badge[hidden] { display: none; }

.badge--accent { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); }
.badge--danger { background: var(--danger-soft); color: var(--danger); border-color: transparent; }
.badge--warn { background: var(--warn-soft); color: var(--warn); border-color: transparent; }

/* Earned, so it is allowed to look like it. The one gold in the palette —
   deliberately not the accent, which means "this is the club" rather than
   "this is you". Both themes get their own pair so the gold stays legible on
   a dark surface instead of glowing. */
.badge--vip {
  background: #fdf3d7;
  color: #8a6100;
  border-color: #e8cf94;
}
@media (prefers-color-scheme: dark) {
  .badge--vip { background: #3a2f14; color: #f0c65c; border-color: #6b5522; }
}
:root[data-theme="dark"] .badge--vip {
  background: #3a2f14; color: #f0c65c; border-color: #6b5522;
}
:root[data-theme="light"] .badge--vip {
  background: #fdf3d7; color: #8a6100; border-color: #e8cf94;
}

/* The week planner's grid of weekday columns. Scrolls sideways rather than
   letting the page do it, which is the rule for every wide thing here. */
.weekgrid { overflow-x: auto; }

/* …and stops being a scroll container entirely when the seven days already
   fit, which is what lets the weekday headings stick: a sticky element sticks
   inside its nearest scrolling box, and while this div was one, `top` was
   measured against a scrollport that never moved vertically. `clip` clips
   without becoming that box. week.js sets it whenever the week fits. */
.weekgrid--fits { overflow: clip; }

/* The weekdays stay put while the times run under them. On a thirty-row grid
   the columns are unlabelled by the time you reach the evening, and "is this
   Thursday or Friday?" is not a question to answer by scrolling back up. */
.weekgrid__row--head {
  position: sticky;
  top: var(--header-h);
  z-index: 10;
  /* Opaque against the card it sits in, or the rows would read straight
     through it. */
  background: var(--surface);
  padding-bottom: 4px;
}
.weekgrid__row {
  display: grid;
  row-gap: 3px;
  /* Each weekday holds one column per court, so the grid reads the way the
     main booking grid does: courts across, time down. */
  grid-template-columns: 68px repeat(7, minmax(78px, 1fr));
  gap: 11px;
  /* A comfortable floor, not a demand. Stated flat it forced a sideways
     scrollbar on any card narrower than 800px — and a grid that scrolls
     sideways cannot pin its weekday headings, because sticky is measured
     against the nearest scrolling box. The column minimums above are the real
     legibility guard and they hold either way; this just stops the comfort
     figure overruling the space actually available. */
  min-width: min(800px, 100%);
}

/* The courts inside one weekday. */
.weekgrid__day {
  display: grid;
  grid-template-columns: repeat(var(--week-courts, 2), minmax(0, 1fr));
  gap: 2px;
}
.weekgrid__courts {
  display: grid;
  grid-template-columns: repeat(var(--week-courts, 2), minmax(0, 1fr));
  gap: 2px;
  margin-top: 2px;
  font-size: 0.66rem;
  color: var(--text-faint);
}
.weekgrid__head {
  font-size: 0.74rem;
  color: var(--text-dim);
  text-align: center;
  padding: 4px 0;
}
.weekgrid__time {
  font-size: 0.74rem;
  color: var(--text-faint);
  text-align: right;
  padding-right: 8px;
  align-self: center;
}
.weekcell {
  border: 1px solid var(--border);
  background: var(--surface);
  border-radius: 6px;
  /* No label: with a column per court the cell's position says which court it
     is, and its fill says whether it is yours. Height alone carries it. */
  height: 26px;
  padding: 0;
  cursor: pointer;
}

/* ---- the phone view: one day at a time ---------------------------------
   A seven-column grid of thirty rows is unusable on a handset — it is all
   horizontal scrolling and mis-taps. Same picks, shown a day at a time with
   full-width targets. */

.dayview { display: none; }

/* Seven across the width, never a scroller. A day picker you have to scroll
   sideways to reach Sunday is worse than no day picker. */
.daypick { display: flex; gap: 4px; }
.daypick__btn {
  flex: 1 1 0;
  min-width: 0;
  padding: 7px 2px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text-dim);
  font: inherit;
  font-size: 0.72rem;
  line-height: 1.3;
  text-align: center;
  cursor: pointer;
}
.daypick__day { font-weight: 620; }
.daypick__date { font-size: 0.78rem; }
.daypick__btn[aria-pressed="true"] {
  background: var(--accent-soft);
  border-color: var(--accent-line);
  color: var(--accent);
  font-weight: 640;
}
/* Height is reserved whether or not there is a count, so the chips do not
   jump about as picks come and go. */
.daypick__count {
  display: block;
  min-height: 0.95em;
  font-size: 0.68rem;
  color: var(--accent);
  font-weight: 700;
}
.daypick__btn[aria-pressed="true"] .daypick__count { color: inherit; }

/* Earlier / range / Later, as three columns that cannot overflow: the middle
   one takes what is left and truncates rather than pushing the arrows off. */
.weeknav {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 8px;
}
.weeknav__label {
  text-align: center;
  font-size: 0.9rem;
  font-weight: 620;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A VIP's planner is pinned to the released week, so the two paging buttons
   are not drawn and the dates are the bar's only occupant. One child of a
   three-column grid would otherwise sit in the first column and read as
   left-aligned; spanning the row is what leaves it centred, which is where it
   was when it had buttons either side of it. */
.weeknav__label--only { grid-column: 1 / -1; }

/* On a wide screen this is an ordinary block under the cards; on a phone the
   media query below lifts it out and pins it to the viewport. */
.weekactions { margin-top: 16px; }
.weekactions #summary { margin-bottom: 10px; }
.weekactions__buttons { display: flex; gap: 10px; }

/* Two columns, so a fifteen-hour day is one scroll rather than three. The
   evening — when a club actually books — should not be four swipes away. */
/* A saved template: what it is on the left, what you can do with it on the
   right — until the screen is too narrow for both, where the buttons drop
   underneath rather than squeezing the description into a column two words
   wide. */
.tmplrow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border);
}
.tmplrow:last-child { border-bottom: 0; }
.tmplrow__what { min-width: 0; }

/* The name and what the week is played as, on one line. Wraps rather than
   truncates: a long template name should push the badge down, not off. */
.tmplrow__title {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}
.tmplrow__do { display: flex; gap: 8px; flex: none; align-items: center; }
.input--sm { padding: 6px 9px; font-size: 0.82rem; width: auto; }

@media (max-width: 700px) {
  .tmplrow { flex-direction: column; align-items: stretch; gap: 10px; }
  .tmplrow__do .btn { flex: 1; }
}

.dayslots { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }

/* One time, every court beside it. */
.dayrow {
  display: grid;
  grid-template-columns: 62px repeat(var(--day-courts, 2), minmax(0, 1fr));
  gap: 6px;
  align-items: stretch;
}
.dayrow--head {
  position: sticky;
  top: var(--header-h);
  z-index: 2;
  background: var(--surface);
  padding: 4px 0;
}
.dayrow__time {
  font-size: 0.78rem;
  color: var(--text-dim);
  align-self: center;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.dayrow__court {
  font-size: 0.72rem;
  font-weight: 620;
  color: var(--text-dim);
  text-align: center;
}

.dayslot {
  /* 44px: the smallest target a fingertip reliably hits, and the figure the
     rest of the app's touch sizing works to. */
  min-height: 44px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  cursor: pointer;
  padding: 0;
}

/* ---- cell states, shared by the grid and the phone rows -----------------
   A cell has no label: its column says which court, its fill says what it
   is. So the fills carry the whole story.

     free      white, light border   — yours to take
     taken     solid grey, no border — someone else's
     pending   dashed accent         — a start waiting for its end
     picked    solid accent          — yours
     clashed   solid warn            — you asked for it and lost the race    */

.weekcell:hover:not(:disabled),
.dayslot:hover:not(:disabled) { border-color: var(--border-strong); }

.weekcell:disabled,
.dayslot:disabled {
  background: var(--surface-2);
  border-color: transparent;
  cursor: not-allowed;
}

.weekcell[data-pending="true"],
.dayslot[data-pending="true"] {
  background: var(--accent-soft);
  border: 1px dashed var(--accent);
}

.weekcell[aria-pressed="true"],
.dayslot[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
}

/* Still picked, so it keeps its place in the plan and stays tappable — but
   somebody else booked it in the seconds before the button was pressed. Last
   in the file so it wins over the picked fill above without needing a heavier
   selector: these cells are always picked too, and this is the more urgent of
   the two things true about them. */
.weekcell[data-clash="true"],
.dayslot[data-clash="true"] {
  background: var(--warn-soft);
  border: 1px solid var(--warn);
}

@media (max-width: 700px) {
  .weekgrid { display: none; }
  .dayview { display: block; }

  /* The phone's answer to the weekday headings above: the day chips are the
     day picker *and* the label for everything below them, so they stay in
     reach while the times scroll. Switching day halfway down the evening is
     the whole point of the chips, and scrolling back to the top to do it is
     the thing this page was built to avoid. */
  .daypick {
    position: sticky;
    top: var(--header-h);
    z-index: 12;
    background: var(--surface);
    padding: 6px 0;
  }

  /* Which leaves the court headings to park under the chips rather than under
     the app bar. Measured by week.js, because the chips grow a line when a day
     carries a count. */
  .dayrow--head { top: calc(var(--header-h) + var(--daypick-h, 0px)); }

  /* Fixed to the viewport, not sticky inside a card. Sticky would only hold
     while its own card was on screen — and that card sits below thirty time
     rows, so it would be off the bottom of the page for exactly as long as
     somebody was using it. */
  .weekactions {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 30;
    padding: 10px max(14px, env(safe-area-inset-right))
             calc(10px + env(safe-area-inset-bottom))
             max(14px, env(safe-area-inset-left));
    background: color-mix(in srgb, var(--surface) 94%, transparent);
    backdrop-filter: saturate(140%) blur(10px);
    border-top: 1px solid var(--border);
  }
  .weekactions #summary { margin-bottom: 8px; font-size: 0.76rem; }
  .weekactions__buttons .btn { flex: 1; }

  /* Room for that bar. Measured by the script rather than guessed: the
     summary line wraps at some pick counts and not others, so the bar's
     height is not a constant — and a guess that is 19px short leaves the
     last card unreachable behind it. The fallback covers the moment before
     the first measurement lands. */
  /* Two class names, not one: the generic `.shell` rule further down this
     same media query carries equal specificity and would otherwise win on
     source order alone, putting the page's bottom padding back to 56px and
     leaving the last card stranded behind the bar. */
  .shell.shell--planner {
    padding-bottom: calc(var(--weekbar-h, 120px) + 24px + env(safe-area-inset-bottom));
  }

  #hint { font-size: 0.76rem; margin: 0; }
  .weeknav__word { display: none; }
}

.notice {
  padding: 11px 14px;
  border-radius: var(--radius);
  font-size: 0.87rem;
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-dim);
}
.notice--error { background: var(--danger-soft); border-color: transparent; color: var(--danger); }
.notice--accent { background: var(--accent-soft); border-color: var(--accent-line); color: var(--accent); }
.notice--warn { background: var(--warn-soft); border-color: var(--warn); color: var(--warn); }

.empty {
  text-align: center;
  padding: 44px 20px;
  color: var(--text-faint);
}
.empty__icon { font-size: 1.9rem; opacity: 0.55; margin-bottom: 8px; }
.empty strong { display: block; color: var(--text-dim); font-weight: 560; margin-bottom: 3px; }

/* ==========================================================================
   Forms
   ========================================================================== */

.field { display: grid; gap: 6px; }

/* `display: grid` above outranks the browser's own [hidden] rule, so a field
   switched off by setFieldVisible needs this to actually disappear. */
.field[hidden] { display: none; }

.field > label {
  font-size: 0.82rem;
  font-weight: 560;
  color: var(--text-dim);
}

.input,
select.input,
textarea.input {
  width: 100%;
  padding: 10px 12px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border-strong);
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: 0.92rem;
  transition: border-color 0.14s var(--ease), box-shadow 0.14s var(--ease);
}

.input::placeholder { color: var(--text-faint); }

.input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}

textarea.input { resize: vertical; min-height: 66px; }

/* A figure the dialog reports rather than asks for. Reads as a value, not as
   an empty box waiting to be filled in. */
.input--readonly {
  background: transparent;
  border-style: dashed;
  color: var(--text-dim);
  cursor: default;
}

.input--readonly:focus { outline: none; box-shadow: none; border-color: var(--border-strong); }

select.input {
  appearance: none;
  background-image: linear-gradient(45deg, transparent 50%, var(--text-faint) 50%),
    linear-gradient(135deg, var(--text-faint) 50%, transparent 50%);
  background-position: calc(100% - 17px) 50%, calc(100% - 12px) 50%;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  padding-right: 34px;
}

.field__hint { font-size: 0.78rem; color: var(--text-faint); }

/* Short fields sharing a line where the screen allows, stacking where it does
   not. `auto-fit` rather than a stated pair, because some of these are shown
   to staff only: when one is hidden the other takes the whole row instead of
   sitting beside the gap where it would have been. The 230px floor is what
   drops them to one column on a handset without needing a breakpoint. */
.fieldrow {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
  gap: 14px;
  align-items: start;
}

/* File picker — the native control, tidied to sit with the other inputs */

.file__input {
  padding: 7px 10px;
  font-size: 0.86rem;
  cursor: pointer;
}

.file__input::file-selector-button {
  margin-right: 10px;
  padding: 6px 12px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border-strong);
  background: var(--surface-2, var(--surface));
  color: var(--text);
  font: inherit;
  font-size: 0.86rem;
  font-weight: 560;
  cursor: pointer;
}

.file__input::file-selector-button:hover { border-color: var(--accent); }

/* Inline filter toggle — lighter than .check, sized to sit in a filter row */

.toggle {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 0.82rem;
  color: var(--text-dim);
  cursor: pointer;
  user-select: none;
}

.toggle__box {
  width: 15px;
  height: 15px;
  flex: none;
  accent-color: var(--accent);
  cursor: pointer;
}

/* Checkbox — label sits beside the box, so the whole row is the hit target */

.check {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 9px 12px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border-strong);
  background: var(--surface);
  font-size: 0.92rem;
  cursor: pointer;
}

.check:hover { border-color: var(--accent); }

.check__box {
  width: 17px;
  height: 17px;
  flex: none;
  accent-color: var(--accent);
  cursor: pointer;
}

.check:has(.check__box:focus-visible) {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}

/* Segmented control — duration and sport pickers */

.segmented {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  padding: 4px;
  background: var(--surface-2);
  border-radius: var(--radius);
  border: 1px solid var(--border);
}

.segmented__opt {
  flex: 1 1 auto;
  min-width: 52px;
  padding: 9px 10px;
  border: 0;
  border-radius: 8px;
  background: none;
  font: inherit;
  font-size: 0.87rem;
  font-weight: 570;
  color: var(--text-dim);
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.14s var(--ease), color 0.14s var(--ease);
}
.segmented__opt:hover:not(:disabled):not([aria-pressed="true"]) {
  background: var(--surface-3);
  color: var(--text);
}
.segmented__opt[aria-pressed="true"] {
  background: var(--accent);
  color: var(--accent-fg);
  box-shadow: var(--shadow-sm);
}
.segmented__opt:disabled {
  opacity: 0.34;
  cursor: not-allowed;
  text-decoration: line-through;
}

.auth-card { margin-top: 8vh; }
.auth-card h1 { margin-bottom: 4px; }

/* Google's branding guidelines ask for its own colours rather than ours, so
   this is the one control on the site that does not follow the theme: a white
   face and the four-colour mark in both light and dark, which is what the
   guidelines actually require. The G itself is inline SVG — the page loads
   nothing from Google, which is what lets script-src stay at 'self'. */
.btn--google {
  background: #ffffff;
  color: #1f1f1f;
  border-color: #747775;
}
.btn--google:hover { background: #f2f2f2; }
.btn--google svg { flex: none; }

/* "or" with a rule either side, for the seam between the two ways in. */
.auth-or {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 18px 0;
  color: var(--text-faint);
  font-size: 0.8rem;
}
.auth-or::before,
.auth-or::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--border);
}
.auth-card__foot {
  margin-top: 18px;
  padding-top: 16px;
  border-top: 1px solid var(--border);
  font-size: 0.87rem;
  color: var(--text-dim);
  text-align: center;
}

/* ==========================================================================
   Date strip
   ========================================================================== */

/* The day being booked stays in sight while the times scroll past it, because
   "which day am I looking at?" is a question that arrives halfway down a
   fifteen-hour grid, and answering it used to mean scrolling back up.
   It parks under the app bar, and the grid's column headings park under it —
   which is why their offset is read from a variable book.js measures rather
   than a constant: the strip is a different height on a phone.

   Opaque, or the grid rows would scroll straight through it. Still
   `position`ed, as `relative` had it, so the edge fades and arrows that
   anchor to this box are unaffected. */
.datestrip {
  position: sticky;
  top: var(--header-h);
  z-index: 20;
  background: var(--bg);
  padding-top: 10px;
  margin-bottom: 16px;
}

.datestrip__track {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  scroll-snap-type: x proximity;
  padding: 3px 1px 12px;
  scrollbar-width: thin;
}

/* The pills only exist once availability has been fetched, and an empty track
   is worth its padding alone — so the strip opened 75px short and everything
   under it dropped when the days arrived. One pill's height, held until there
   are pills. paint() clears and refills the track in a single statement, so
   this is never seen once the page is live: only on the way in. */
#dateStrip:empty { min-height: 75px; }
.datestrip__track::-webkit-scrollbar { height: 6px; }
.datestrip__track::-webkit-scrollbar-thumb {
  background: var(--border-strong);
  border-radius: 3px;
}

.daypill {
  flex: none;
  scroll-snap-align: start;
  min-width: 74px;
  padding: 9px 12px 8px;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-dim);
  cursor: pointer;
  font: inherit;
  text-align: center;
  transition: border-color 0.14s var(--ease), background 0.14s var(--ease),
    transform 0.1s var(--ease);
}
.daypill:hover { border-color: var(--border-strong); transform: translateY(-1px); }

/* The placeholder pill is a div rather than a button — there is nothing to
   pick yet — so it declines the hover lift and the pointer that promise one. */
.daypill--skel { cursor: default; }
.daypill--skel:hover { border-color: var(--border); transform: none; }
.daypill--skel .daypill__free { width: 44px; margin: 0 auto; }

.daypill__dow {
  font-size: 0.68rem;
  font-weight: 650;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--text-faint);
}
.daypill__num {
  font-size: 1.16rem;
  font-weight: 650;
  color: var(--text);
  line-height: 1.25;
  font-variant-numeric: tabular-nums;
}
.daypill__free {
  font-size: 0.7rem;
  font-weight: 560;
  color: var(--text-faint);
  font-variant-numeric: tabular-nums;
}

.daypill[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
}
.daypill[aria-pressed="true"] .daypill__dow,
.daypill[aria-pressed="true"] .daypill__num,
.daypill[aria-pressed="true"] .daypill__free {
  color: var(--accent-fg);
}
.daypill[aria-pressed="true"] .daypill__dow,
.daypill[aria-pressed="true"] .daypill__free { opacity: 0.82; }

.daypill--full .daypill__free { color: var(--danger); }
.daypill[aria-pressed="true"].daypill--full .daypill__free { color: var(--accent-fg); }

.datestrip__edge {
  position: absolute;
  top: 0;
  bottom: 12px;
  width: 40px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.16s var(--ease);
}
.datestrip__edge--right {
  right: 0;
  background: linear-gradient(to right, transparent, var(--bg) 78%);
}
.datestrip__edge--left {
  left: 0;
  background: linear-gradient(to left, transparent, var(--bg) 78%);
}

/* The fades are claims about hidden content, so they only appear when there
   really is more strip in that direction — book.js sets these as it scrolls. */
.datestrip[data-more-left="true"] .datestrip__edge--left,
.datestrip[data-more-right="true"] .datestrip__edge--right { opacity: 1; }

/* A mouse has no way to flick a scroller, so a fine pointer gets buttons.
   Touch keeps the bare strip — a thumb already does this better. */
.datestrip__nav { display: none; }

@media (hover: hover) and (pointer: fine) and (min-width: 900px) {
  .datestrip__nav {
    position: absolute;
    top: 50%;
    /* Centre on the pills, not on the track's scrollbar gutter. */
    margin-top: -6px;
    transform: translateY(-50%);
    z-index: 2;
    display: grid;
    place-items: center;
    width: 30px;
    height: 30px;
    padding: 0 0 2px;
    border-radius: 50%;
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text-dim);
    font: inherit;
    font-size: 1.15rem;
    line-height: 1;
    cursor: pointer;
    box-shadow: var(--shadow);
    transition: color 0.14s var(--ease), border-color 0.14s var(--ease);
  }
  .datestrip__nav:hover { color: var(--text); border-color: var(--border-strong); }
  .datestrip__nav[hidden] { display: none; }
  .datestrip__nav--prev { left: -8px; }
  .datestrip__nav--next { right: -8px; }
}

/* ==========================================================================
   Booking grid
   ========================================================================== */

/* A day's grid is over a thousand pixels tall and arrives a fetch late, so
   with nothing standing in for it the legend rendered just under the heading
   and was then flung off the bottom of the screen. There is no honest number
   to reserve — the grid is as tall as the club's opening hours, which the page
   does not know yet — so this reserves a screenful instead: enough that the
   legend starts below the fold on a phone and stays there, which makes the
   arrival of the grid something that happens off screen rather than a lurch. */
#gridHost:empty { min-height: 70vh; }

/* A screenful is still a guess, and a club with short opening hours gets a
   grid shorter than the space held for it. The legend is the only thing under
   the grid, so it is the only thing that guess can throw about — and it means
   nothing without a grid to read it against. Held back until there is one, so
   whichever way the guess lands, nothing the reader can see moves. */
#gridHost:empty ~ .legend { display: none; }

/* The grid is as tall as the day is long, and the page scrolls anyway — so it
   is never given a second scrollbar of its own to scroll inside the first.
   Capping its height meant a reader who had scrolled the page to the bottom
   still had a day's worth of grid left to scroll, in a different box, with a
   different scrollbar.

   Sideways is the one axis it does keep, for a club with more courts than fit:
   the page must never scroll horizontally, which is the rule every wide thing
   here follows. There is no way to ask for one axis and not the other — a
   non-visible `overflow-x` forces `overflow-y` to compute to `auto` as well —
   but with no height cap there is nothing to scroll vertically, so the second
   scrollbar never appears. */
.gridwrap {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow-x: auto;
  box-shadow: var(--shadow-sm);
}

/* …and when even that is not needed, the box stops being a scroll container at
   all. That is what lets the column headings stick to the app bar: a sticky
   element sticks inside its nearest scrolling ancestor, so while this div was
   one, `top` was measured against a scrollport that never moved and the
   headings simply rode the page out of view. book.js sets this whenever the
   courts fit, which for a two-court club is always.

   `clip` rather than `visible`, because the corners are the other half of the
   job: a table has square ones, and the card it sits in is rounded, so without
   something clipping the overhang the header row squares off the top two
   corners and the last row squares off the bottom two. `clip` still clips —
   to the radius — while creating no scroll container, which `auto` and
   `scroll` both would. A browser too old to know the keyword drops this rule
   and keeps the `overflow-x: auto` above: rounded corners, one scrollbar, and
   headings that do not stick. */
.gridwrap--fits { overflow: clip; }

.grid {
  border-collapse: separate;
  border-spacing: 0;
  width: 100%;
  min-width: max-content;
}

.grid th,
.grid td {
  padding: 0;
  border-bottom: 1px solid var(--border);
  border-right: 1px solid var(--border);
}
.grid tr > *:last-child { border-right: 0; }
.grid tr:last-child > * { border-bottom: 0; }

.grid thead th {
  position: sticky;
  /* Under the app bar and under the date strip, both of which are parked above
     it, now that the page is the thing doing the scrolling. */
  top: calc(var(--header-h) + var(--strip-h, 0px));
  z-index: 3;
  background: var(--surface);
  padding: 12px 14px;
  text-align: left;
  font-size: 0.78rem;
  font-weight: 620;
  letter-spacing: 0.02em;
  color: var(--text-dim);
  white-space: nowrap;
  box-shadow: inset 0 -1px 0 var(--border);
}

.grid thead th small {
  display: block;
  font-size: 0.68rem;
  font-weight: 520;
  color: var(--text-faint);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  margin-top: 1px;
}

.grid .timecol {
  position: sticky;
  left: 0;
  z-index: 2;
  background: var(--surface);
  padding: 0 16px;
  width: 96px;
  min-width: 96px;
  text-align: right;
  font-size: 0.8rem;
  font-weight: 560;
  color: var(--text-dim);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  box-shadow: inset -1px 0 0 var(--border);
}

.grid thead .timecol { z-index: 4; }

.grid tbody tr.is-past .timecol { color: var(--text-faint); opacity: 0.6; }

.grid td { height: var(--row-h); }

/* Half-hour granularity doubles the row count, so rows get shorter and the
   :30 lines recede — the hours stay the readable rhythm. */
.grid { --row-h: 56px; }
.grid--dense { --row-h: 38px; }

.grid--dense tbody tr.is-half > * { border-bottom-style: dashed; }
.grid--dense tbody tr.is-half .timecol {
  color: var(--text-faint);
  font-size: 0.72rem;
  font-weight: 500;
}

.cell {
  position: relative;
  width: 100%;
  min-width: 118px;
  height: 100%;
  min-height: var(--row-h);
  border: 0;
  background: none;
  font: inherit;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  color: var(--text-faint);
  font-size: 0.82rem;
  font-weight: 540;
  transition: background 0.12s var(--ease), color 0.12s var(--ease);
}

/* A booking spanning several slots is one tall block, not repeated squares. */
.td--spanned .cell { border-radius: var(--radius-sm); }

.cell__title { font-weight: 620; }
.cell__sub {
  font-size: 0.73rem;
  font-weight: 500;
  opacity: 0.78;
  text-transform: capitalize;
  font-variant-numeric: tabular-nums;
}

.cell--open {
  color: var(--text-faint);
  flex-direction: row;
  gap: 6px;
}
.cell--open::after {
  content: "";
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--accent);
  opacity: 0.4;
  transition: opacity 0.14s var(--ease), transform 0.14s var(--ease);
}
.cell--open:hover {
  background: var(--accent-soft);
  color: var(--accent);
}
.cell--open:hover::after { opacity: 1; transform: scale(1.25); }
.cell--open:focus-visible { outline-offset: -2px; }

.cell--booked,
.cell--past,
.cell--blocked {
  cursor: default;
}

.cell--booked {
  /* Named so the hover-less override at the bottom of the file can put it
     back without restating it. */
  --stripe: repeating-linear-gradient(
    -45deg,
    transparent,
    transparent 6px,
    color-mix(in srgb, var(--text-faint) 9%, transparent) 6px,
    color-mix(in srgb, var(--text-faint) 9%, transparent) 12px
  );
  background: var(--stripe);
  color: var(--text-faint);
  font-size: 0.76rem;
}

.cell--past { background: var(--surface-2); color: transparent; }
.cell--past::before { content: "·"; color: var(--text-faint); opacity: 0.5; font-size: 1.2rem; }

.cell--blocked {
  background: var(--warn-soft);
  color: var(--warn);
  font-size: 0.72rem;
  font-weight: 600;
  padding: 0 8px;
  text-align: center;
  line-height: 1.2;
}

.cell--mine {
  background: var(--accent-soft);
  color: var(--accent);
  font-weight: 640;
  cursor: pointer;
  box-shadow: inset 0 0 0 1.5px var(--accent);
}
.cell--mine:hover { background: color-mix(in srgb, var(--accent-soft) 70%, var(--accent) 12%); }

.cell--admin-booked { cursor: pointer; }
.cell--admin-booked:hover { background: var(--surface-3); color: var(--text-dim); }

/* ---- drag to select a time range ---------------------------------------- */

body.is-dragging {
  cursor: ns-resize;
  user-select: none;
  -webkit-user-select: none;
}
body.is-dragging .cell { cursor: ns-resize; }

.cell--selected,
.cell--open.cell--selected:hover {
  background: var(--accent);
  color: var(--accent-fg);
}
.cell--selected::after { display: none; }

/* The run reads as one continuous block: only its ends are rounded, and the
   internal boundaries are softened rather than drawn as separate boxes. */
.cell--selected { border-radius: 0; box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--accent-fg) 22%, transparent); }
.cell--selected-head { border-radius: var(--radius-sm) var(--radius-sm) 0 0; }
.cell--selected-tail { border-radius: 0 0 var(--radius-sm) var(--radius-sm); box-shadow: none; }
.cell--selected-head.cell--selected-tail { border-radius: var(--radius-sm); box-shadow: none; }

.dragpill {
  position: fixed;
  z-index: 90;
  transform: translate(-50%, 0);
  padding: 7px 13px;
  border-radius: var(--radius-full);
  background: var(--text);
  color: var(--bg);
  font-size: 0.8rem;
  font-weight: 600;
  white-space: nowrap;
  pointer-events: none;
  box-shadow: var(--shadow-lg);
  font-variant-numeric: tabular-nums;
}

.legend {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  padding: 14px 2px 0;
  font-size: 0.78rem;
  color: var(--text-faint);
}
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend i {
  width: 12px;
  height: 12px;
  border-radius: 4px;
  border: 1px solid var(--border);
  display: inline-block;
}
.legend .sw-open { background: var(--surface); }
.legend .sw-open::after {
  content: "";
  display: block;
  width: 4px; height: 4px;
  margin: 3px auto;
  border-radius: 50%;
  background: var(--accent);
}
.legend .sw-mine { background: var(--accent-soft); border-color: var(--accent); }
.legend .sw-booked { background: var(--surface-3); }
.legend .sw-blocked { background: var(--warn-soft); border-color: var(--warn); }

.legend__tip { margin-left: auto; font-style: italic; }
/* Drag is a fine-pointer interaction, so the hint is keyed to the pointer and
   not to the width: a tablet is wide enough to show it but can't do it, and
   the dialog does the same job there. */
@media (max-width: 700px), (pointer: coarse) {
  /* Qualified, because `.legend span` above sets a display too and carries one
     more type selector — enough to win against a bare `.legend__tip` however
     far down the file it sits, media query or not. Left as it was, the hint
     told every phone in the club to drag something a finger cannot drag. */
  .legend .legend__tip { display: none; }
}

/* Sport filter */
.chips { display: flex; gap: 7px; flex-wrap: wrap; }

.chip {
  padding: 5px 13px;
  border-radius: var(--radius-full);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-dim);
  font: inherit;
  font-size: 0.82rem;
  font-weight: 540;
  cursor: pointer;
  text-transform: capitalize;
  transition: all 0.14s var(--ease);
}
.chip:hover { border-color: var(--border-strong); color: var(--text); }
.chip[aria-pressed="true"] {
  background: var(--text);
  border-color: var(--text);
  color: var(--bg);
}

/* ==========================================================================
   Modal / sheet
   ========================================================================== */

.overlay {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: rgba(8, 12, 16, 0.5);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  animation: fade 0.16s var(--ease);
}

.modal {
  width: 100%;
  max-width: 420px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  padding: 22px;
  animation: pop 0.18s var(--ease);
}

.modal--wide { max-width: 560px; }

.modal__title { margin-bottom: 3px; }
.modal__sub { color: var(--text-dim); font-size: 0.87rem; margin-bottom: 16px; }

.modal__summary {
  background: var(--surface-2);
  border-radius: var(--radius);
  padding: 14px 16px;
  margin-bottom: 16px;
  display: grid;
  gap: 9px;
}

.kv {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 14px;
  font-size: 0.88rem;
}
.kv dt { color: var(--text-dim); }
.kv dd { margin: 0; font-weight: 580; text-align: right; }

.modal__actions {
  display: flex;
  gap: 9px;
  margin-top: 18px;
}
.modal__actions .btn { flex: 1; }

@keyframes fade { from { opacity: 0; } }
@keyframes pop {
  from { opacity: 0; transform: translateY(10px) scale(0.98); }
}

@media (max-width: 560px) {
  .overlay { align-items: flex-end; padding: 0; }
  .modal {
    max-width: none;
    border-radius: var(--radius-lg) var(--radius-lg) 0 0;
    padding-bottom: max(22px, env(safe-area-inset-bottom));
    animation: slideup 0.2s var(--ease);
  }
  @keyframes slideup { from { transform: translateY(100%); } }
}

/* ==========================================================================
   Toasts
   ========================================================================== */

.toasts {
  position: fixed;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 80;
  display: grid;
  gap: 8px;
  width: max-content;
  max-width: calc(100vw - 32px);
  pointer-events: none;
}

.toast {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 11px 16px;
  border-radius: var(--radius-full);
  background: var(--text);
  color: var(--bg);
  font-size: 0.87rem;
  font-weight: 540;
  box-shadow: var(--shadow-lg);
  animation: toastin 0.22s var(--ease);
}
.toast--error { background: var(--danger); color: #fff; }
.toast--ok::before { content: "✓"; font-weight: 700; }

@keyframes toastin { from { opacity: 0; transform: translateY(14px); } }

/* ==========================================================================
   Booking list
   ========================================================================== */

.blist { display: grid; gap: 10px; }

/* "My bookings" arrives empty and fills a fetch later, and History sits
   directly under it — so the History heading drew a little way down the page
   and was then shoved past the fold as the reservations landed on top of it.
   It is the one section nobody arrives looking for, and it means nothing
   before the bookings above it exist, so it waits for them. render() always
   leaves something in #upcoming, an empty-state card included, which is what
   makes :empty a sound test for "not loaded yet" rather than "nothing to
   show". */
#upcoming:empty ~ section { display: none; }

.bitem {
  display: flex;
  /* Stretch, not centre: the rule down the side of the date is the card's
     spine, and a spine that covers a third of a four-line card reads as a
     leftover rather than a divider. The rail centres its own text instead. */
  align-items: stretch;
  gap: 16px;
  padding: 15px 18px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-sm);
}

.bitem--past { opacity: 0.62; box-shadow: none; }

/* The gate's on-hold list puts a Collect button on every unpaid session, and
   those want to line up: a column of amounts with the buttons ragged behind
   them is a column nobody can run an eye down. The body is content-width
   everywhere else — where it holds one line and a rail beside it, that is
   right — so it grows here rather than for every card in the app. */
.bitem--hold .bitem__body { flex: 1; min-width: 0; }

.bitem__date {
  flex: none;
  width: 54px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  text-align: center;
  padding-right: 16px;
  border-right: 1px solid var(--border);
}
.bitem__date .dow {
  font-size: 0.66rem;
  font-weight: 660;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--text-faint);
}
.bitem__date .num {
  font-size: 1.35rem;
  font-weight: 660;
  line-height: 1.15;
  font-variant-numeric: tabular-nums;
}
.bitem__date .mon { font-size: 0.7rem; color: var(--text-faint); font-weight: 560; }

.bitem__main { flex: 1; min-width: 0; }

/* The headline of a row — a time, its length, and what it cost. A flex line
   rather than a float, so the price is pushed to the right edge by the layout
   instead of being taken out of it: a float that meets a long name on a narrow
   card leaves the text wrapping around a price hanging in mid-air. */
.bitem__time {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 7px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.bitem__meta {
  font-size: 0.82rem;
  color: var(--text-dim);
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  align-items: center;
  margin-top: 1px;
}
.bitem__cost {
  margin-left: auto;
  font-weight: 660;
  font-variant-numeric: tabular-nums;
}

/* Whatever can be done about this row. Its own line under the content on a
   phone, beside it on a desktop. */
.bitem__do { display: flex; gap: 8px; align-items: center; align-self: center; }

.bitem__note {
  font-size: 0.82rem;
  color: var(--text-faint);
  margin-top: 3px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ==========================================================================
   Admin
   ========================================================================== */

.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
  gap: 12px;
}

.stat {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 15px 17px;
  box-shadow: var(--shadow-sm);
}
.stat--warn { border-color: var(--warn); background: var(--warn-soft); }
.stat--warn .stat__value { color: var(--warn); }
.stat--danger { border-color: var(--danger); background: var(--danger-soft); }
.stat--danger .stat__value { color: var(--danger); }

.stat__label {
  font-size: 0.73rem;
  font-weight: 620;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-faint);
}
.stat__value {
  font-size: 1.75rem;
  font-weight: 660;
  letter-spacing: -0.025em;
  font-variant-numeric: tabular-nums;
  line-height: 1.2;
  margin-top: 3px;
}
.stat__sub { font-size: 0.8rem; color: var(--text-dim); }

.meter {
  height: 5px;
  border-radius: 3px;
  background: var(--surface-3);
  overflow: hidden;
  margin-top: 9px;
}
.meter > span {
  display: block;
  height: 100%;
  border-radius: 3px;
  background: var(--accent);
  transition: width 0.4s var(--ease);
}

.ratecard {
  display: flex;
  align-items: flex-start;
  gap: 14px;
  padding: 14px 16px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-2);
}
.ratecard__main { flex: 1; min-width: 0; }

.tabs {
  display: flex;
  gap: 3px;
  padding: 3px;
  background: var(--surface-2);
  border-radius: var(--radius);
  width: max-content;
  max-width: 100%;
  overflow-x: auto;
}

.tabs button {
  padding: 7px 15px;
  border: 0;
  border-radius: 9px;
  background: none;
  font: inherit;
  font-size: 0.87rem;
  font-weight: 560;
  color: var(--text-dim);
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.14s var(--ease), color 0.14s var(--ease);
}
.tabs button:hover { color: var(--text); }
.tabs button[aria-selected="true"] {
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow-sm);
}

.table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }

.table th {
  text-align: left;
  padding: 10px 16px;
  font-size: 0.72rem;
  font-weight: 640;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-faint);
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}

.table td {
  padding: 11px 16px;
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
}
.table tbody tr:last-child td { border-bottom: 0; }
.table tbody tr:hover { background: var(--surface-2); }
.table .num { font-variant-numeric: tabular-nums; }
.table__actions { text-align: right; white-space: nowrap; }

.tablewrap { overflow-x: auto; }

/* Inline bar under a figure in a table cell. Each column is scaled to its own
   maximum, so these compare a month against other months of the SAME measure —
   never income against hours, which share no axis and never should. Kept thin
   and recessive: the number is the value, this is only its shape over time. */

.colbar {
  height: 3px;
  border-radius: 2px;
  background: var(--surface-3);
  overflow: hidden;
  margin-top: 5px;
  margin-left: auto;
  max-width: 92px;
}

.colbar__fill {
  height: 100%;
  border-radius: 2px;
  background: var(--accent);
}

/* For the columns where a tall bar is bad news. Paired with a heading that
   says which measure it is, so the meaning never rests on the colour. */
.colbar__fill--warn { background: var(--warn); }

.bars { display: grid; gap: 7px; }
.bar { display: grid; grid-template-columns: 78px 1fr 34px; align-items: center; gap: 10px; font-size: 0.82rem; }
.bar__track { height: 7px; border-radius: 4px; background: var(--surface-3); overflow: hidden; }
.bar__fill { height: 100%; border-radius: 4px; background: var(--accent); }
.bar__label { color: var(--text-dim); white-space: nowrap; font-variant-numeric: tabular-nums; }
.bar__num { text-align: right; color: var(--text-dim); font-variant-numeric: tabular-nums; }

/* Part-to-whole: how the club's court time divides between the sports it runs.
   A ring rather than a solid pie — the hole carries the total, which is the
   number the desk actually reads, and the slices answer "how does it split"
   at a glance. The legend beside it is not decoration: it names every slice
   and prints its hours, so identity and value never rest on colour alone. */

.pie {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: 22px;
}

@media (max-width: 560px) {
  .pie { grid-template-columns: 1fr; justify-items: center; gap: 16px; }
}

.pie__disc {
  position: relative;
  width: 158px;
  height: 158px;
  border-radius: 50%;
  /* The wedges themselves come from the script: only it knows the shares. */
}

/* Sits on the ring, not beside it, so the total reads as the sum of what
   surrounds it. Matches the card so the segments look cut, not stacked. */
.pie__hole {
  position: absolute;
  inset: 28%;
  border-radius: 50%;
  background: var(--surface);
  display: grid;
  align-content: center;
  justify-items: center;
  text-align: center;
}

.pie__total {
  font-size: 1.3rem;
  font-weight: 640;
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
}

.pie__caption { font-size: 0.68rem; color: var(--text-dim); margin-top: 2px; }

/* Capped: over a wide card an uncapped legend strands the hours a foot away
   from the name they belong to. */
.pie__legend { display: grid; gap: 9px; width: 100%; max-width: 420px; }

.pie__row {
  display: grid;
  grid-template-columns: 10px minmax(0, 1fr) auto auto;
  align-items: center;
  gap: 10px;
  font-size: 0.86rem;
}

.pie__swatch { width: 10px; height: 10px; border-radius: 3px; }
.pie__name { text-transform: capitalize; }
.pie__hours { font-variant-numeric: tabular-nums; }
.pie__share {
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
  min-width: 44px;
  text-align: right;
}

/* ==========================================================================
   Skeletons
   ========================================================================== */

/* The two unknowns in the placeholder grid book.js draws while availability is
   in flight: the court names, and every square under them. Everything else in
   that table — the element, the classes, the times down the left — is the real
   thing, so it needs no styling of its own and comes out exactly the height
   the finished grid will be. Inset a little so the shimmer reads as a column
   of squares rather than one unbroken sheet. */
.cell--skel {
  margin: 3px;
  width: auto;
  min-height: calc(var(--row-h) - 6px);
  border-radius: var(--radius-sm);
  cursor: default;
}
/* As tall as the line of text it stands in for, so the heading strip is the
   height it will be once the court names arrive. */
.skel--text { height: 19px; width: 62px; margin: 0 auto; }

/* A withheld line of text: it keeps the line box of whatever it is standing in
   for — and so that element's height — and only narrows to look like writing
   rather than a filled row. */
.skel--line { border-radius: var(--radius-sm); color: transparent; }
.stat__label.skel--line { width: 58%; }
.stat__value.skel--line { width: 42%; }
.stat__sub.skel--line { width: 78%; }
/* Full width where the withheld line has real wording in it: a narrower box
   would wrap that wording somewhere else, and the wrap is the whole point. */
.stat__sub.skel--wrap { width: 100%; }

.skel {
  background: linear-gradient(
    90deg,
    var(--surface-2) 25%,
    var(--surface-3) 37%,
    var(--surface-2) 63%
  );
  background-size: 400% 100%;
  animation: shimmer 1.4s linear infinite;
  border-radius: var(--radius-sm);
}

@keyframes shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

/* ==========================================================================
   Responsive
   ========================================================================== */

/* ==========================================================================
   Mobile and touch
   Two separate concerns, deliberately keyed differently: `max-width` handles
   layout on a narrow screen, `pointer: coarse` handles finger-sized targets.
   A small laptop window should not get 44px buttons, and a tablet should.
   ========================================================================== */

@media (max-width: 700px) {
  .shell {
    padding: 0 14px;
    /* Clear the home indicator on notched phones. */
    padding-bottom: calc(56px + env(safe-area-inset-bottom));
  }
  .header__inner {
    padding: 0 max(14px, env(safe-area-inset-left)) 0 max(14px, env(safe-area-inset-right));
    gap: 8px;
  }
  .brand__sub { display: none; }
  .brand__mark, .brand__logo { width: 30px; height: 30px; }
  .nav a { padding: 8px 10px; font-size: 0.86rem; }

  /* An admin who is also a VIP carries five destinations plus a badge, which
     is more than a handset has room for. Left alone the links wrapped onto
     two and three lines each and shoved the account button clean off the
     right-hand edge. The strip scrolls instead: every item keeps one line and
     its full width, and the account button stays where it belongs. */
  .header__inner > .nav {
    min-width: 0;
    overflow-x: auto;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
    margin-left: 0;
  }
  .header__inner > .nav::-webkit-scrollbar { display: none; }
  .nav a, .nav .badge { flex: none; white-space: nowrap; }
  /* The brand and the account button hold their size; only the middle gives. */
  .header__inner > .brand, .header__inner > .usermenu { flex: none; }
  .page-head { padding: 18px 0 12px; }
  h1 { font-size: 1.3rem; }
  .page-head p { font-size: 0.86rem; }

  .grid .timecol { width: 68px; min-width: 68px; padding: 0 9px; font-size: 0.75rem; }
  .cell { min-width: 0; }

  /* A booking row on a phone: the date rail beside the content, and whatever
     can be done about it on its own line underneath.

     `flex-wrap` did this badly, and the reason is worth keeping: a flex item
     wraps on its *base* size, which here is a member's name plus a time plus
     three badges laid end to end — far wider than a handset — so the content
     block went to line two on nearly every row, leaving the 46px rail alone
     beside 300px of nothing. `min-width: 0` could not save it, because that
     governs shrinking within a line and not the decision to wrap. Naming the
     two columns instead means the content shrinks into place rather than
     wrapping out of it. */
  .bitem {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: stretch;
    column-gap: 12px;
    row-gap: 10px;
    padding: 13px 14px;
  }
  .bitem__date {
    width: 46px;
    padding-right: 12px;
    /* Top, not centred as on a wide screen: spanning the buttons too, a centred
       date drifts away from the time it belongs to. */
    justify-content: flex-start;
  }

  /* The rule runs the whole height of the card. Left to its own row it stopped
     where the buttons began, putting a seam across what is one booking — and
     the court view, whose buttons sit inside the content rather than beside
     it, would have drawn the same card differently.

     `span 2` and not `1 / -1`: -1 counts back from the end of the *explicit*
     grid, and these rows are all implicit, so it silently spans nothing. Only
     for a row that has a second child to span, or the span would open an empty
     implicit row and pay the row-gap for it. */
  .bitem:has(> .bitem__do) .bitem__date { grid-row: 1 / span 2; }
  /* Overrides the centring the wide layout gives it, which in a grid would
     shrink the row to the buttons' own height and strand them mid-cell. */
  .bitem__do { align-self: stretch; }
  /* Both under the same left edge as the title, so the card has one spine. */
  .bitem__main,
  .bitem__do { grid-column: 2; }
  /* Full-width targets, as the stacked admin tables give their actions. */
  .bitem__do .btn { flex: 1; }

  .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
  .stat { padding: 12px 13px; }
  .stat__value { font-size: 1.45rem; }

  .card { padding: 16px 14px; border-radius: var(--radius); }
  .card__head { gap: 10px; }
  .modal__summary { padding: 12px 13px; }
}

@media (max-width: 560px) {
  /* Reclaim the width the facility name costs; the tab title still carries it. */
  .brand > span > div:first-child { display: none; }
  .nav { margin-left: 0; }
  .nav a { padding: 8px 9px; }
}

/* ---- finger-sized targets, whatever the screen width -------------------- */

@media (pointer: coarse) {
  /* Anything under 16px makes iOS zoom the whole page when the field takes
     focus, which then leaves the layout scrolled sideways. */
  .input, select.input, textarea.input { font-size: 16px; padding: 11px 12px; }
  select.input { padding-right: 34px; }

  .btn { min-height: 44px; padding: 11px 16px; }
  .btn--sm { min-height: 38px; padding: 9px 13px; font-size: 0.85rem; }
  .btn--lg { min-height: 50px; }

  .segmented__opt { min-height: 42px; }
  .chip { min-height: 36px; padding: 8px 14px; }
  .tabs button { min-height: 40px; }

  .daypill { min-width: 78px; padding: 11px 12px 10px; }

  /* Roomier rows beat denser ones when the pointer is a fingertip. */
  .grid { --row-h: 46px; }
  .grid--dense { --row-h: 44px; }

  .usermenu__panel button,
  .usermenu__panel a { padding: 12px 10px; }

  .table td { padding: 13px 16px; }

  /* The grey flash on tap is replaced by the components' own :active states. */
  a, button, .cell, .daypill, .chip { -webkit-tap-highlight-color: transparent; }
}

/* ---- admin tables become stacked cards on a narrow screen --------------- */

/* Keyed wider than the other narrow-screen rules: the admin tables run to
   seven columns, which a tablet in portrait can only show by scrolling
   sideways — cards are readable at a width where a real table is not. */
@media (max-width: 900px) {
  .tablewrap { overflow-x: visible; }

  .table, .table tbody, .table tr, .table td { display: block; width: 100%; }
  .table thead { display: none; }

  .table tr {
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 4px 0;
    margin-bottom: 10px;
    background: var(--surface);
  }
  .table tbody tr:hover { background: var(--surface); }

  .table td {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 14px;
    border-bottom: 1px solid var(--border);
    padding: 9px 14px;
    text-align: left;
  }
  .table tr td:last-child { border-bottom: 0; }

  /* The column heading rides along as a label, stamped on by admin.js. */
  .table td::before {
    content: attr(data-label);
    flex: none;
    font-size: 0.7rem;
    font-weight: 640;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--text-faint);
  }
  /* A cell with no heading and no content is noise once stacked. The action
     column is the exception: its heading is deliberately blank, but it holds
     the row's buttons and hiding it strands the card with nothing to do. */
  .table td:empty,
  .table td[data-label=""]:not(.table__actions) { display: none; }

  .table__actions { justify-content: flex-end; }
  .table__actions::before { content: none; }
  .table__actions .row { width: 100%; justify-content: stretch; }
  .table__actions .btn { flex: 1; }
}

/* ---- scrolling behaviour ------------------------------------------------ */

/* On the root only, and that is not a detail. `hidden` here propagates to the
   viewport, which is the thing that actually stops the page being dragged
   sideways. Repeating it on <body> was not belt-and-braces: a non-visible
   `overflow-x` forces `overflow-y` to compute to `auto`, so <body> became a
   scroll container — one that never scrolls, because the page scrolls instead.
   Every `position: sticky` in the app was then measuring itself against that
   scrollport, and a scrollport that cannot move can never push anything to its
   edge. The app bar, the booking grid's column headings and the week planner's
   court row all stuck to nothing at all. */
html { overflow-x: hidden; }
body { -webkit-text-size-adjust: 100%; }

.gridwrap, .tablewrap, .datestrip__track, .tabs {
  -webkit-overflow-scrolling: touch;
  overscroll-behavior-x: contain;
}

.tabs::-webkit-scrollbar { display: none; }
.tabs { scrollbar-width: none; }

/* A dialog taller than the screen must scroll inside itself, not push its
   own buttons off the bottom. */
.modal {
  max-height: min(88vh, 780px);
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}

/* ==========================================================================
   Desktop
   The mobile rules above subtract from a desktop baseline; these add back the
   things only a big screen and a mouse can use.
   ========================================================================== */

/* Court columns divide whatever width they are handed, so a two-court grid on
   a wide monitor becomes two enormous, near-empty strips. Sizing the page from
   the column count instead keeps a cell roughly the shape of the thing it
   stands for, and the page stays centred rather than sprawling. book.js
   publishes --courts; the 136px covers the time column and the shell padding.
   No media query needed — below the result the cap simply never binds, so a
   phone and a tablet are untouched. */
.shell--book {
  max-width: clamp(720px, calc(136px + var(--courts, 4) * 320px), 1180px);
}

@media (min-width: 1024px) {
  h1 { font-size: 1.75rem; }
  .page-head { padding: 34px 0 22px; }
  .page-head p { font-size: 0.92rem; }
}

/* On a large monitor the admin tables are the only thing that wants the extra
   width — reading columns is easier than scrolling them. The reading-width
   pages (:not) keep their own caps. */
@media (min-width: 1400px) {
  .shell:not(.shell--narrow):not(.shell--mid):not(.shell--book),
  .header__inner { max-width: 1320px; }
}

/* ---- hover is a capability, not an assumption --------------------------- */

/* Without this a tablet keeps the hover paint after a tap — the last cell
   touched stays lit as though it were still under a cursor. Neutralising here
   rather than at each rule keeps the desktop styles above readable. */
@media (hover: none) {
  .btn:hover { background: var(--surface-2); }
  .btn--primary:hover { background: var(--accent); }
  .btn--ghost:hover { background: transparent; color: var(--text-dim); }
  .btn--danger:hover { background: transparent; border-color: var(--border); }

  .nav a:not([aria-current="page"]):hover { background: none; color: var(--text-dim); }
  .usermenu__panel button:hover,
  .usermenu__panel a:hover { background: none; }

  .cell--open:hover { background: none; color: var(--text-faint); }
  .cell--open:hover::after { opacity: 0.4; transform: none; }
  .cell--mine:hover { background: var(--accent-soft); }
  /* Always carries .cell--booked too, so it is the stripe that goes back —
     not "no background", which would flatten the square. */
  .cell--admin-booked:hover { background: var(--stripe); color: var(--text-faint); }

  /* Excluding the selected pill, whose border is part of its accent fill. */
  .daypill:not([aria-pressed="true"]):hover { border-color: var(--border); transform: none; }
  .daypill:hover { transform: none; }

  .chip:not([aria-pressed="true"]):hover { border-color: var(--border); color: var(--text-dim); }
  .segmented__opt:hover:not(:disabled):not([aria-pressed="true"]) {
    background: none;
    color: var(--text-dim);
  }
  .tabs button:not([aria-selected="true"]):hover { color: var(--text-dim); }
}

/* Only above the stacking breakpoint: below it the rows are cards on their own
   surface, and the narrow-screen block already cancels the hover tint there. */
@media (hover: none) and (min-width: 901px) {
  .table tbody tr:hover { background: none; }
}
