:root {
  --bg: #f7f8fb;
  --surface: #ffffff;
  /* One step up from `--surface`, for a panel sitting *on* a card (an inline
     action box inside a form). Flat borders alone stopped reading once
     surfaces gained elevation. */
  --surface-raised: #ffffff;
  --text: #14161b;
  --muted: #667085;
  --border: #e6e8ee;
  /* A hairline for dividers *inside* a card, where the full `--border`
     contrast reads as a hard rule cutting the card in two. */
  --border-subtle: #eef0f4;
  --brand: #3b5bdb;
  --brand-dark: #2f49af;
  /* §8.10 — which of near-black or white reads on `--brand` as a
     background. Overridden alongside `--brand`/`--brand-dark` by
     `app.js`'s `applyBranding` for an admin-chosen colour; this default
     matches the default `--brand` above. */
  --brand-ink: #ffffff;
  --accent: #f59f00;
  --accent-dark: #d98c00;
  --accent-ink: #16181d;
  /* §8.10 — a very subtle brand-tinted surface, for panels that should read
     as "highlighted" without competing with real content (roster preview,
     inline action forms). Computed from --brand/--surface rather than a
     palette-supplied value, so it never needs a separate light/dark variant
     — it always tracks whichever --brand and --surface are active. */
  --surface-tint: color-mix(in srgb, var(--brand) 6%, var(--surface));
  /* Same idea as `--surface-tint` but neutral gray instead of brand-tinted,
     for spots (weekend calendar cells) that should read as "quieter", not
     "highlighted" — a brand tint there implied significance that wasn't
     intended. */
  --surface-tint-neutral: color-mix(in srgb, var(--muted) 10%, var(--surface));

  /* Form-field fill and border, for every text input/select/textarea plus a
     couple of other "sunken control on a surface" spots (`.view-switcher`,
     `.waiver-text`).
     Bug this fixes: those all used to fill with `--bg` at rest — the exact
     same colour as the page canvas behind them (`body` uses `--bg`, and
     `main` never overrides it), so a field placed directly on the page had
     zero fill contrast and read as blank until `:focus` swapped it to
     `--surface`. A plain `--border`-vs-`--surface` mix doesn't fix it either:
     `--border` (#e6e8ee) is only ~2% darker than `--surface` (#ffffff) in the
     light theme, so mixing it in barely moves the needle (measured — even a
     60% mix landed at a 1.06:1 luminance ratio against `--bg`, essentially
     imperceptible). Tinting with `--text` instead gives real, controllable
     separation at low percentages while staying visually "near white"/"near
     the dark surface," the same way `--surface-tint` above tints with brand.
     Computed rather than hand-picked per theme, so it auto-adapts wherever
     `--text`/`--surface`/`--border` are redefined (dark theme included) —
     verified: light 1.11–1.18:1 against bg/surface, dark 1.23–1.35:1 (dark
     starts with more headroom since its bg/surface are already further
     apart). The border needs its own, stronger mix — the ambient `--border`
     measured only ~1.04:1 against the new fill, i.e. it would've vanished as
     an edge around it. 16% lands both themes at a crisp ~1.4–1.6:1 against
     their own field-bg. */
  --field-bg: color-mix(in srgb, var(--text) 8%, var(--surface));
  --field-border: color-mix(in srgb, var(--text) 16%, var(--border));

  --danger: #c92a2a;
  --ok-bg: #e7f5ec;
  --ok-border: #a5d6b7;
  --warn-bg: #fff6e0;
  --warn-border: #ffd48a;
  --error-bg: #fdecec;
  --error-border: #f3b7b7;

  /* ---- Radius scale ----------------------------------------------------
     One value can't serve a 0.9rem palette dot and a full-width card. `--radius`
     keeps its old meaning (the card/control default) so no existing rule
     changes behaviour; the others give small and large elements a shape that
     matches their size. */
  --radius-sm: 7px;
  --radius: 11px;
  --radius-lg: 16px;
  --radius-pill: 999px;

  /* ---- Elevation -------------------------------------------------------
     The app was entirely flat: every surface was separated from the page by a
     1px border and nothing else, so a card, a popover and a table all sat on
     exactly the same visual plane. These are deliberately soft and low-spread
     — enough to lift a surface off the background, not enough to read as a
     drop shadow. Two layers each: a tight contact shadow for the edge, and a
     wider ambient one for the lift. */
  --shadow-xs: 0 1px 2px rgba(16, 20, 32, 0.04);
  --shadow-sm: 0 1px 2px rgba(16, 20, 32, 0.05), 0 2px 6px rgba(16, 20, 32, 0.04);
  --shadow-md: 0 2px 4px rgba(16, 20, 32, 0.05), 0 6px 16px rgba(16, 20, 32, 0.07);
  --shadow-lg: 0 4px 10px rgba(16, 20, 32, 0.07), 0 16px 40px rgba(16, 20, 32, 0.11);

  /* ---- Motion ----------------------------------------------------------
     Nothing in the app transitioned before, so every hover and focus change
     landed instantly. Short and eased — this should register as responsive,
     not as animation. Honoured by the `prefers-reduced-motion` block at the
     end of this file. */
  --ease: cubic-bezier(0.4, 0, 0.2, 1);
  --t-fast: 120ms;
  --t-base: 180ms;

  /* The focus ring, defined once so keyboard focus looks identical on a
     button, an input and a table row. */
  --ring: 0 0 0 3px color-mix(in srgb, var(--brand) 32%, transparent);
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0f1115;
    --surface: #181b21;
    /* Lighter than `--surface`, not darker. On a dark theme elevation reads
       as *more* light, not more shadow — a raised panel that got darker
       would look like a hole punched in the card. */
    --surface-raised: #1e222a;
    --text: #e9eaee;
    --muted: #9aa1ad;
    --border: #2a2f39;
    --border-subtle: #23272f;
    --brand: #7d94f5;
    --brand-dark: #6b83ec;
    /* Near-black, not white. This token is defined as "whichever of near-black
       or white reads on `--brand`" (see the light-theme comment above), and
       the dark theme's lighter `--brand` fails that test with white: #fff on
       #7d94f5 is 2.83:1, under the 4.5:1 AA floor, while near-black is 7.4:1.
       It only shows pre-sign-in — `applyBranding` overrides both tokens
       together from the studio's palette once a session exists, and computes
       the ink with `readableInkOn`, which already picks correctly. This just
       makes the static default agree with that function. */
    --brand-ink: #16181d;
    --accent: #f59f00;
    --accent-dark: #d98c00;
    --accent-ink: #16181d;
    --danger: #ff8787;
    --ok-bg: #16301f;
    --ok-border: #2f6b45;
    --warn-bg: #33280d;
    --warn-border: #7a5f1d;
    --error-bg: #3a1d1d;
    --error-border: #7d3535;

    /* Much heavier than the light-theme values. A shadow tuned for a white
       page is invisible against #0f1115 — the alpha has to climb roughly
       fourfold before the same lift reads at all. */
    --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0, 0, 0, 0.28);
    --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.38), 0 6px 16px rgba(0, 0, 0, 0.34);
    --shadow-lg: 0 4px 10px rgba(0, 0, 0, 0.45), 0 16px 40px rgba(0, 0, 0, 0.5);
  }
}

[x-cloak] { display: none !important; }

/* Boot skeleton (see the block comment on `#boot-skeleton` in index.html).
   Deliberately styled with literal values rather than the custom properties
   below: this has to paint correctly before anything else does, and the
   branding tokens are overwritten at runtime by `applyBranding` anyway. It
   approximates the real header's height and bottom rule so the swap to the
   live header isn't a visible jump. */
/* Splash screen (see the block comment on `#splash` in index.html).

   Literal values for the same reason the skeleton below uses them: this has
   to paint correctly in the first frame, before `applyBranding` has run.
   `--surface` is the one token it does read, and that one *is* replayed from
   localStorage by the inline script in <head>, so it's right from the start.

   Covers the whole viewport rather than sitting in the layout: the app boots
   underneath it and is fully settled by the time this fades, so there is no
   swap to make smooth — which is also why the skeleton's careful
   header-height matching isn't needed here. */
#splash {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--surface);
  /* The fade is on the way out only. Fading *in* would mean the first frame
     is blank, which is the thing this exists to prevent. */
  transition: opacity 0.25s ease;
}
#splash.leaving { opacity: 0; pointer-events: none; }
#splash img {
  width: min(45vw, 45vh, 240px);
  height: auto;
}
/* The splash is up, so the skeleton must not also be. Class on <html> rather
   than a removed node, because the inline script runs while the parser is
   still upstream of the skeleton and can't touch it yet. */
.has-splash #boot-skeleton { display: none; }

#boot-skeleton .boot-header {
  height: 53px;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  box-shadow: var(--shadow-xs);
}
#boot-skeleton .boot-note {
  margin: 0;
  padding: 1.35rem 1rem;
  font-size: 0.9rem;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  font-size: 15px;
  /* Was 1.35 — tight enough that stacked paragraphs and table cells ran
     together. 1.5 is the readable default; headings pull their own line
     height back down below so they don't gap out. */
  line-height: 1.5;
  color: var(--text);
  background: var(--bg);
  /* Softens the heavier strokes system-ui renders at this size, which is
     what makes the difference between "a form" and "a designed form". */
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* Proportional figures look uneven in a UI full of times, counts and
     money. `tnum` lines the digits up in a column everywhere at once. */
  font-variant-numeric: tabular-nums;
}

main {
  max-width: 900px;
  margin: 0 auto;
  padding: 1.35rem 1rem 3.5rem;
}

/* Headings get tighter leading and slightly negative tracking — at display
   sizes the default spacing reads loose and the letters read disconnected. */
h1 { font-size: 1.6rem; line-height: 1.2; letter-spacing: -0.02em; margin: 0 0 0.6rem; font-weight: 650; }
h2 { font-size: 1.06rem; line-height: 1.3; letter-spacing: -0.01em; margin: 1.35rem 0 0.45rem; font-weight: 620; }
h3 { font-size: 0.93rem; line-height: 1.35; margin: 0.9rem 0 0.3rem; font-weight: 600; }

a { color: var(--brand); text-underline-offset: 2px; transition: color var(--t-fast) var(--ease); }
a:hover { color: var(--brand-dark); }

.muted { color: var(--muted); }
.back { display: inline-block; margin-bottom: 0.4rem; font-size: 0.9rem; text-decoration: none; }

/* Tightens a long stacked-section detail pane (e.g. Members admin) without
   affecting heading/spacing anywhere else. */
.compact-detail h2 { margin: 0.55rem 0 0.2rem; }
.compact-detail > p,
.compact-detail > .grid,
.compact-detail > .table,
.compact-detail table { margin-bottom: 0.35rem; }
.compact-detail .actions { margin-top: 0.15rem; margin-bottom: 0.5rem; }
.compact-detail .grid { margin-top: 0.15rem; }
hr { border: none; border-top: 1px solid var(--border); margin: 1rem 0; }
code { background: var(--border); padding: 0.1em 0.35em; border-radius: 4px; font-size: 0.9em; }

/* ---------------------------------------------------------------- header */

/* Deliberately NOT sticky. It was, briefly — but with 17 nav links this bar
   wraps to ~120px at 1280px wide, i.e. 17% of the viewport pinned to the top
   of every screen, and it made the calendar's own sticky offset (which has to
   clear the header) unreliable because the wrapped height moves with the
   viewport. A static header costs a scroll on the two long admin screens;
   sticking it costs a sixth of every screen. */
.app-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.9rem;
  padding: 0.7rem 1.15rem;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  box-shadow: var(--shadow-xs);
}

.brand {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-weight: 650;
  font-size: 1.05rem;
  text-decoration: none;
  color: var(--text);
}
/* §8.10 — an admin-uploaded logo, capped to the header's row height rather
   than a fixed size, so a wide or tall source image never blows the header
   out. */
.brand-logo { display: block; height: 28px; max-width: 8rem; object-fit: contain; }

.logo-row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem; margin-bottom: 0.5rem; }
/* Sits inline in `.logo-row` next to the preview/Remove button, rather than
   its own stacked row below — the file input's global `width: 100%` (see
   `input, select, textarea` above) would otherwise force it wide enough to
   wrap onto a new line by itself, so both are pinned back to auto width
   here (2026-08-16). */
.logo-upload-label { display: inline-flex; align-items: center; gap: 0.4rem; width: auto; margin: 0; font-size: 0.8rem; font-weight: 550; color: var(--muted); }
.logo-upload-label input[type="file"] { width: auto; }
.logo-preview {
  height: 3rem;
  max-width: 10rem;
  object-fit: contain;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--surface);
  padding: 0.25rem;
}

/* Splash preview in Settings. Bigger than `.logo-preview` and on the app's
   own page background rather than `--surface`, because the point of looking
   at it here is judging how the animation reads at the size and against the
   backdrop it will actually appear on. */
.splash-row { display: flex; align-items: center; flex-wrap: wrap; gap: 1rem; margin-bottom: 0.5rem; }
.splash-row .actions { margin: 0.3rem 0 0; }
.splash-preview {
  width: 8rem;
  height: 8rem;
  object-fit: contain;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg);
  padding: 0.75rem;
}

/* Build identity, sat next to the studio name. Deliberately small and quiet —
   it's a diagnostic, not part of the branding — but always present, because a
   version you have to go and look for doesn't answer "what am I looking at?".
   `flex: 0 0 auto` and `nowrap` so it never wraps or gets squeezed by a long
   studio name; `tabular-nums` so the digits don't jitter between builds. */
.build-tag {
  flex: 0 0 auto;
  align-self: center;
  display: inline-flex;
  align-items: baseline;
  gap: 0.3rem;
  font-size: 0.68rem;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  white-space: nowrap;
  color: var(--muted);
  padding: 0.2rem 0.45rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
}
/* Faint enough to read as punctuation rather than content. */
.build-sep { opacity: 0.4; }
/* The layout mode is the part being watched while rotating a phone, so it
   gets the emphasis the surrounding numbers don't. */
.build-mode { font-weight: 700; color: var(--text); }
/* A preview deploy gets the same warning tint the app uses elsewhere for
   "this isn't the normal state", so it reads as different at a glance rather
   than needing to be read character by character. */
.build-tag.preview {
  color: var(--text);
  background: var(--warn-bg);
  border-color: color-mix(in srgb, var(--text) 18%, var(--warn-bg));
  font-weight: 600;
}

.nav { display: flex; gap: 0.15rem; flex: 1; flex-wrap: wrap; align-items: center; }
.nav-group { display: contents; }
.app-version { font-size: 0.78rem; align-self: center; }
/* Pill-shaped hit areas rather than a 2px underline: the underline gave the
   link no hover target of its own and left the active item reading as a
   stray rule under one word. The pill also gives the notification badge
   something to sit inside. */
.nav a {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  text-decoration: none;
  color: var(--muted);
  font-size: 0.9rem;
  font-weight: 500;
  padding: 0.32rem 0.6rem;
  border-radius: var(--radius-sm);
  transition: color var(--t-fast) var(--ease), background-color var(--t-fast) var(--ease);
}
.nav a:hover { color: var(--text); background: color-mix(in srgb, var(--text) 6%, transparent); }
.nav a.active {
  color: var(--brand);
  background: color-mix(in srgb, var(--brand) 11%, transparent);
  font-weight: 600;
}

.auth { display: flex; align-items: center; gap: 0.5rem; }
.who { display: flex; align-items: center; gap: 0.6rem; font-size: 0.9rem; }
/* A segmented control: the segments share one border and one radius, so each
   button drops its own shadow, radius and press-nudge. */
.view-switcher {
  display: inline-flex;
  /* `--field-border`/`--field-bg` — same "sunken control on a surface" look
     as a form field, since that's exactly what this is: a control sitting on
     `.app-header`'s `--surface`, which plain `--bg` didn't read against for
     the identical reason inputs didn't (see that token's comment). */
  border: 1px solid var(--field-border);
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: var(--field-bg);
  box-shadow: var(--shadow-xs);
}
.view-switcher button {
  font: inherit;
  font-size: 0.78rem;
  font-weight: 550;
  padding: 0.28rem 0.7rem;
  background: transparent;
  color: var(--muted);
  border: none;
  border-right: 1px solid var(--border);
  border-radius: 0;
  cursor: pointer;
  box-shadow: none;
}
.view-switcher button:hover:not(:disabled):not(.active) { background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text); box-shadow: none; }
.view-switcher button:active:not(:disabled) { transform: none; }
.view-switcher button:last-child { border-right: none; }
.view-switcher button.active { background: var(--brand); color: var(--brand-ink); box-shadow: none; }
.view-switcher button:disabled { opacity: 0.4; cursor: not-allowed; }
.account-menu { position: relative; }
.account-menu-trigger {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  font: inherit;
  font-size: 0.9rem;
  color: var(--text);
  background: transparent;
  border: none;
  box-shadow: none;
  padding: 0.2rem 0.3rem;
  cursor: pointer;
}
.account-menu-trigger:hover { color: var(--brand); }
/* The remembered-name placeholder shown while auth is still restoring (see
   `$store.session.resuming`). Borrows the trigger's geometry so the real one
   can replace it without moving anything, but it isn't a control: nothing to
   click, and dimmed so it reads as "settling" rather than as a menu that
   ignores taps. */
.account-menu-trigger.resuming { cursor: default; opacity: 0.55; }
.account-menu-trigger.resuming:hover { color: var(--text); }
.account-menu-trigger .caret { font-size: 0.7rem; color: var(--muted); transition: transform 0.15s ease; }
.account-menu-trigger .caret.up { transform: rotate(180deg); }
.account-menu-panel {
  position: absolute;
  top: calc(100% + 0.4rem);
  right: 0;
  min-width: 11rem;
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-md);
  padding: 0.35rem;
  z-index: 60;
}
.account-menu-panel a,
.account-menu-panel button.link {
  display: block;
  width: 100%;
  text-align: left;
  padding: 0.4rem 0.6rem;
  border-radius: var(--radius-sm);
  color: var(--text);
  font-size: 0.88rem;
  text-decoration: none;
}
.account-menu-panel a:hover,
.account-menu-panel button.link:hover {
  background: color-mix(in srgb, var(--text) 6%, transparent);
  box-shadow: none;
}
.account-menu-panel a.active { color: var(--brand); font-weight: 600; }
.account-menu-panel hr { border: none; border-top: 1px solid var(--border); margin: 0.3rem 0; }
.badge {
  display: inline-flex;
  align-items: center;
  background: var(--brand);
  color: var(--brand-ink);
  font-size: 0.66rem;
  font-weight: 650;
  text-transform: uppercase;
  padding: 0.13rem 0.45rem;
  border-radius: var(--radius-pill);
  letter-spacing: 0.05em;
  line-height: 1.4;
}
/* Distinguishes the Teacher badge from the Admin one at a glance — both used
   to render identically in --brand since there was only one theme colour. */
.badge.accent { background: var(--accent); color: var(--accent-ink); }
/* Flags a class/occurrence that falls inside the studio's Vacation period
   (Settings). Deliberately squarer than the pill-shaped .badge — a small
   flat tag rather than a fully rounded chip. */
.badge.vacation {
  background: var(--accent);
  color: var(--accent-ink);
  border-radius: 4px;
}

/* §8.10 — Theme Configuration: a small brand/accent dot preview next to the
   button that opens the popup, and the colour-picker + hex-input row inside
   it. Replaced the old fixed 10-swatch palette grid (2026-08-15). */
.theme-config-row { display: flex; align-items: center; gap: 0.7rem; margin: 0.5rem 0; }
.theme-config-preview { display: flex; }
.theme-config-preview span {
  width: 1.4rem;
  height: 1.4rem;
  border-radius: 50%;
  border: 1px solid var(--border);
}
.theme-config-preview span + span { margin-left: -0.5rem; }
.theme-color-rows { display: flex; flex-direction: column; gap: 0.9rem; margin: 0.8rem 0; }
.theme-color-row {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 0.7rem;
}
.theme-color-row input[type="color"] {
  width: 2.6rem;
  height: 2.6rem;
  padding: 0.15rem;
  border: 1px solid var(--field-border);
  border-radius: var(--radius-sm);
  background: var(--field-bg);
  cursor: pointer;
}
.theme-color-row span { display: flex; flex-direction: column; gap: 0.1rem; }
.theme-color-row .muted { font-size: 0.82rem; }
.theme-color-hex { width: 7rem; font-family: monospace; }

/* --------------------------------------------------------------- buttons */

/* Vertical padding was 0.26rem (~4px), which made every control read as
   cramped and gave touch targets almost nothing to hit. ~0.42rem lands the
   default button near a 32px box without changing any layout that depends on
   button width. */
button, .button {
  font: inherit;
  font-size: 0.9rem;
  font-weight: 550;
  line-height: 1.25;
  padding: 0.42rem 0.85rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--text);
  cursor: pointer;
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.35rem;
  white-space: nowrap;
  box-shadow: var(--shadow-xs);
  transition:
    background-color var(--t-fast) var(--ease),
    border-color var(--t-fast) var(--ease),
    box-shadow var(--t-fast) var(--ease),
    transform var(--t-fast) var(--ease),
    color var(--t-fast) var(--ease);
}
button:hover:not(:disabled), .button:hover {
  /* Previously only shifted border colour, which reads as no change at all
     on a plain surface-coloured button — the background itself never moved.
     Same mix strategy as `--field-bg`: tint toward `--text` so it darkens in
     both themes without a separate light/dark value. */
  background: color-mix(in srgb, var(--text) 5%, var(--surface));
  border-color: color-mix(in srgb, var(--text) 22%, var(--border));
  box-shadow: var(--shadow-sm);
}
/* A 1px drop on press. Small enough to feel mechanical rather than bouncy,
   and paired with removing the shadow so the control reads as pushed in. */
button:active:not(:disabled), .button:active {
  transform: translateY(1px);
  box-shadow: none;
}
button:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }

/* `:focus-visible`, not `:focus` — a mouse click shouldn't leave a ring
   behind, but keyboard tabbing must always show one. */
button:focus-visible, .button:focus-visible {
  outline: none;
  box-shadow: var(--ring);
  border-color: var(--brand);
}

button.primary, .button.primary {
  background: var(--brand);
  border-color: var(--brand);
  color: var(--brand-ink);
  box-shadow: var(--shadow-xs);
}
button.primary:hover:not(:disabled), .button.primary:hover {
  background: var(--brand-dark);
  border-color: var(--brand-dark);
  box-shadow: var(--shadow-sm);
}
button.accent, .button.accent {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}
button.accent:hover:not(:disabled) { background: var(--accent-dark); border-color: var(--accent-dark); }

/* Outline-only by design elsewhere in the app — the payments queue overrides
   this to a filled red where it needs to compete with a filled primary. */
button.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
button.danger:hover:not(:disabled) {
  background: color-mix(in srgb, var(--danger) 9%, transparent);
  border-color: var(--danger);
}

button.link {
  background: none;
  border: none;
  padding: 0;
  color: var(--brand);
  text-decoration: underline;
  box-shadow: none;
  font-weight: 500;
}
button.link:hover:not(:disabled) { color: var(--brand-dark); background: none; box-shadow: none; }
button.link:active:not(:disabled) { transform: none; }
button.link.danger { color: var(--danger); }

/* --------------------------------------------------------------- banners */

/* Banners fade in rather than snapping onto the page — they appear after an
   action completes, and an instant jump reads as a layout shift rather than
   as feedback. Pairs with `lib/noticeTimer.js`, which now clears them on a
   timer. */
.banner {
  padding: 0.6rem 0.85rem;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  margin: 0.7rem 0;
  font-size: 0.9rem;
  box-shadow: var(--shadow-xs);
  animation: banner-in var(--t-base) var(--ease);
}
@keyframes banner-in {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: none; }
}
.banner.ok { background: var(--ok-bg); border-color: var(--ok-border); }
.banner.warn { background: var(--warn-bg); border-color: var(--warn-border); }
.banner.error { background: var(--error-bg); border-color: var(--error-border); }

/* -------------------------------------------------------------- schedule */

.filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-bottom: 0.75rem;
}
.filters label { display: flex; flex-direction: column; font-size: 0.8rem; color: var(--muted); gap: 0.15rem; }
/* A bare <select> defaults to a taller box than `.title-filter`'s 2rem, so a
   row mixing search and dropdown filters (e.g. Subscriptions) looked
   uneven — sized to match. */
.filters select {
  height: 2rem;
  padding: 0 0.5rem;
  font-size: 0.85rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: inherit;
}
/* Matches `.filters label`'s layout so the horizon toggle (a `.list-tabs`,
   not a form control) lines up with the other filters beside it. */
.filter-tabs-group { display: flex; flex-direction: column; font-size: 0.8rem; color: var(--muted); gap: 0.15rem; }
.filter-tabs-group .list-tabs { margin: 0; }

.day h2 {
  font-size: 0.85rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--muted);
  margin: 0.8rem 0 0.3rem;
}

/* ------------------------------------------------- schedule week + calendar */

/* The week's classes on the left, a month picker pinned on the right. The
   list is the thing being read, so it takes the flexible column; the
   calendar is a fixed-width control. */
.schedule-layout {
  display: flex;
  align-items: flex-start;
  gap: 2.5rem;
}
.schedule-list { flex: 1 1 auto; min-width: 0; }
.calendar {
  flex: 0 0 15rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.85rem;
  position: sticky;
  top: 0.75rem;
  box-shadow: var(--shadow-sm);
}
/* Widened from 29rem — at the old size, seven-column weeks plus the new
   per-day dot row felt cramped. */
.calendar-two-up { flex-basis: 34rem; }
.calendar-months {
  display: flex;
  gap: 1.25rem;
}
.calendar-month { flex: 1 1 0; min-width: 0; }
.calendar-month > strong {
  display: block;
  font-size: 0.9rem;
  text-align: center;
  margin-bottom: 0.5rem;
}

.calendar-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.4rem;
  margin-bottom: 0.4rem;
  font-size: 0.9rem;
}
.calendar-head button { font-size: 1.1rem; line-height: 1; text-decoration: none; }
.calendar-head button.link {
  font-size: 0.82rem;
  font-weight: 600;
  padding: 0.3rem 0.7rem;
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--brand) 12%, transparent);
  color: var(--brand);
}
.calendar-head button.link:hover:not(:disabled) {
  background: color-mix(in srgb, var(--brand) 20%, transparent);
}

.calendar-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 3px;
}
.calendar-dow {
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
  text-align: center;
  padding-bottom: 0.25rem;
}
.calendar-day {
  position: relative;
  padding: 0.5rem 0 0.4rem;
  border: none;
  background: none;
  border-radius: var(--radius-sm);
  font-size: 0.88rem;
  font-weight: 500;
  color: var(--text);
  text-align: center;
  box-shadow: none;
  transition: background-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.calendar-day.out-of-month { color: var(--muted); opacity: 0.45; }
/* A soft tint for Saturday/Sunday columns, distinct from the brand-tinted
   `.in-week`/`.is-today` states below, which still need to read through it. */
.calendar-day.weekend { background: color-mix(in srgb, var(--muted) 16%, var(--surface)); }
.calendar-day:hover:not(:disabled) {
  background: color-mix(in srgb, var(--brand) 12%, transparent);
  box-shadow: none;
}
/* The generic `button:active` nudge would make the whole calendar grid jitter
   as days are clicked through. */
.calendar-day:active:not(:disabled) { transform: none; }
/* The whole displayed week is tinted, not just the clicked day — the left
   pane shows a week, so highlighting a single date would misdescribe it. */
.calendar-day.in-week { background: color-mix(in srgb, var(--brand) 15%, transparent); color: var(--text); }
.calendar-day.is-today {
  background: var(--brand);
  font-weight: 700;
  color: #fff;
}
/* Fallback for a day with classes at a place that has no color yet — the
   colored `.calendar-day-dots` row below is what shows once every place has
   one. */
.calendar-day.has-classes::after {
  content: "";
  position: absolute;
  bottom: 3px;
  left: 50%;
  transform: translateX(-50%);
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: var(--brand);
}
.calendar-day-dots {
  display: flex;
  justify-content: center;
  gap: 2px;
  margin-top: 2px;
}
.calendar-dot { width: 4.5px; height: 4.5px; border-radius: 50%; flex: none; }
.calendar-hint { font-size: 0.75rem; margin: 0.4rem 0 0; }

/* ---- Week strip (project decision, 2026-08-11) ------------------------- */

.week-strip { margin-bottom: 1rem; padding-bottom: 0.85rem; border-bottom: 1px solid var(--border); }
.week-strip-nav {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  font-size: 0.85rem;
  margin-bottom: 0.5rem;
}
.week-strip-days {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 0.4rem;
}
.week-strip-day {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  min-width: 0;
  padding: 0.3rem 0.25rem;
  border-radius: var(--radius-sm);
}
.week-strip-day.weekend { background: var(--surface-tint-neutral); }
.week-strip-day.is-today { background: color-mix(in srgb, var(--brand) 12%, transparent); }
.week-strip-heading {
  display: flex;
  flex-direction: column;
  align-items: center;
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
}
.week-strip-day.is-today .week-strip-heading { color: var(--brand); font-weight: 700; }
.week-strip-heading span:last-child { font-size: 0.95rem; color: var(--text); font-weight: 600; }
.week-strip-day.is-today .week-strip-heading span:last-child { color: var(--brand); }
/* A little rounded block, matching the reference design's pill classes —
   just a start time and a title, no grid lines, no duration bar. */
.week-block {
  display: flex;
  flex-direction: column;
  gap: 0.05rem;
  padding: 0.3rem 0.4rem;
  border-radius: var(--radius-sm);
  font-size: 0.68rem;
  font-weight: 600;
  line-height: 1.25;
  text-align: center;
  text-decoration: none;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.week-strip-empty { text-align: center; font-size: 0.75rem; }

/* ---- Place color picker ------------------------------------------------ */

.color-picker { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.2rem; }
.color-picker input[type="color"] { width: 1.8rem; height: 1.8rem; padding: 0; border: none; cursor: pointer; }
.color-picker input[type="text"] { width: 6.5rem; }
.color-picker input.invalid { border-color: var(--danger, #e03131); }

/* Places form (§ single-row layout, 2026-08-13) — name, address, color,
   and the active toggle all fit on one line instead of stacking. */
.place-form-row {
  display: flex;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: 0.6rem;
}
.place-form-row label { display: flex; flex-direction: column; }
.place-form-row .field-name input { width: 11.375rem; }
.place-form-row .field-address { flex: 1 1 160px; min-width: 128px; }
.place-form-row .field-address input { width: 100%; }
.place-form-row .field-color .color-picker input[type="color"] { width: 1.55rem; height: 1.55rem; }
.place-form-row .field-color .color-picker input[type="text"] { width: 5.5rem; }
.place-form-row label.inline { margin-bottom: 0.7rem; }
.color-dot { display: inline-block; width: 0.75rem; height: 0.75rem; border-radius: 50%; }

@media (max-width: 720px) {
  .schedule-layout { flex-direction: column-reverse; }
  .calendar { flex: 1 1 auto; width: 100%; position: static; }
  .calendar-months { flex-direction: column; }
}

.class-row {
  display: grid;
  grid-template-columns: 8.5rem 1fr auto auto;
  gap: 0.85rem;
  align-items: center;
  padding: 0.62rem 0.8rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-left: 3px solid var(--border);
  border-radius: var(--radius);
  margin-bottom: 0.4rem;
  text-decoration: none;
  color: var(--text);
  box-shadow: var(--shadow-xs);
  transition:
    background-color var(--t-fast) var(--ease),
    border-color var(--t-fast) var(--ease),
    box-shadow var(--t-base) var(--ease),
    transform var(--t-base) var(--ease);
}
/* Series/event header vs. its individual occurrences (project decision,
   2026-08-14): the header row (the collapsed series/event) reads as its own
   tier via a tinted background and a brand-colour left edge; an expanded
   occurrence underneath it is plain surface with an accent-colour edge, so
   the two never look like the same kind of row even though they share
   `.class-row`'s base geometry. */
.class-row.header-row { background: var(--surface-tint); border-left-color: var(--brand); }
.class-row.occurrence-row { border-left-color: var(--accent); }
/* The row lifts very slightly toward the reader on hover. This is the one
   place the app has a genuine list of browsable things, so it's worth the
   affordance — it makes the whole row read as clickable, which the previous
   border-colour-only change did not. */
.class-row:hover {
  background: color-mix(in srgb, var(--text) 4%, var(--surface));
  border-color: color-mix(in srgb, var(--brand) 45%, var(--border));
  box-shadow: var(--shadow-md);
  transform: translateY(-1px);
}
/* `.header-row` sits on `--surface-tint`, not plain `--surface` — darken
   from that base instead, or the hover background would be a visible flash
   back to plain white/dark-surface rather than a darkening of what's there. */
.class-row.header-row:hover {
  background: color-mix(in srgb, var(--text) 4%, var(--surface-tint));
}
.class-row:focus-visible { outline: none; box-shadow: var(--ring); border-color: var(--brand); }
.class-row.cancelled { opacity: 0.55; text-decoration: line-through; }
/* §8.5/4B — the elevated schedule view (Admin/Teacher) uses a <button> here
   instead of an <a>: clicking selects the row to preview its roster rather
   than navigating away, which an anchor's default click behavior fights. */
/* `white-space: normal` undoes the base button's `nowrap`, which would
   otherwise stop a long class title from wrapping inside its grid cell. */
button.class-row { font: inherit; font-size: inherit; font-weight: inherit; width: 100%; text-align: left; cursor: pointer; white-space: normal; }
button.class-row:active { transform: none; }
.class-row.selected { border-color: var(--brand); box-shadow: 0 0 0 1px var(--brand); }

/* 4B — the read-only roster preview shown under a selected class row.
   Deliberately plain: no actions live here, only a link to the real
   roster screen (`Manage roster`) where anything actually happens. */
/* Sits flush under the selected row: pulled up by the row's own bottom margin
   so the two read as one connected panel, with only the lower corners
   rounded. */
.roster-preview {
  padding: 0.75rem 0.9rem 0.85rem;
  margin: -0.55rem 0 0.4rem;
  background: var(--surface-tint);
  border: 1px solid var(--border);
  border-top: none;
  border-radius: 0 0 var(--radius) var(--radius);
  box-shadow: var(--shadow-xs);
  animation: banner-in var(--t-base) var(--ease);
}
/* Whole-series booking confirmation's date list (§ redesign, 2026-08-14) —
   scrolls rather than pushing the Confirm/Back buttons off-screen on a
   long series. */
.plain-list {
  list-style: none;
  margin: 0 0 0.6rem;
  padding: 0;
  max-height: 12rem;
  overflow-y: auto;
  font-size: 0.88rem;
}
.plain-list li { padding: 0.15rem 0; border-bottom: 1px solid var(--border-subtle); }
.plain-list li:last-child { border-bottom: none; }

.roster-preview-list { list-style: none; margin: 0 0 0.5rem; padding: 0; }
.roster-preview-list li {
  display: flex;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.2rem 0;
  font-size: 0.88rem;
  border-bottom: 1px solid var(--border);
}
.roster-preview-list li:last-child { border-bottom: none; }

/* My Packs usage roster — one cell per slot the pack holds, sized to the pack
   itself so an unused credit reads as a visibly empty box, not just a number. */
.pack-roster {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 0.5rem;
  margin: 0.75rem 0 1rem;
}
.pack-slot {
  min-height: 4.5rem;
  padding: 0.5rem 0.6rem;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 0.15rem;
  font-size: 0.82rem;
  background: var(--surface);
  border: 1px dashed var(--border);
  border-radius: var(--radius);
}
.pack-slot.filled {
  border-style: solid;
  border-color: var(--brand);
  background: var(--surface-tint);
}
.pack-slot strong { font-size: 0.85rem; }
.class-row .time { font-variant-numeric: tabular-nums; color: var(--muted); font-size: 0.9rem; }
.class-row .title { display: flex; flex-direction: column; }
.class-row .title small { color: var(--muted); }
.class-row .meta { display: flex; gap: 0.75rem; font-size: 0.85rem; color: var(--muted); flex-wrap: wrap; }
.class-row .spots { font-size: 0.85rem; color: var(--muted); white-space: nowrap; }
/* Pushes the Vacation flag to the right edge of the meta cell, right before
   the booked-count/spots column, instead of crowding the place name. */
.class-row .meta .badge.vacation { margin-left: auto; }
/* Member occurrence rows (§8, 2026-08-14 layout pass): the status reads as
   general info alongside type/place rather than a trailing column, and
   there's a real Book button instead of the whole row acting as the link —
   3 columns, not the shared 4, so this needs its own track sizing. */
.class-row.occurrence-row { grid-template-columns: 9.5rem 1fr auto; }
.class-row .status-label { font-weight: 600; color: var(--text); }
.class-row .book-button { justify-self: end; }

/* Admin Classes list's expanded occurrence rows (§7.2): time, place/meta,
   booking status, and a Manage class button — back to the shared 4-column
   base geometry rather than the 3-column member/admin-schedule variant
   above, since this row always carries the extra trailing action. */
.class-row.occurrence-row.manage-row { grid-template-columns: 9.5rem 1fr auto auto; }

/* ---------------------------------------------------------------- detail */

.facts {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 0.7rem;
  margin: 0.9rem 0;
  padding: 0.9rem 1rem;
  background: var(--surface-tint);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.facts dt {
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  font-weight: 620;
  color: var(--muted);
}
.facts dd { margin: 0.2rem 0 0; font-weight: 550; }

/* The class/event overview is a scannable header, not a data table — it wants
   to sit on one line. Its values are short (a time, a duration, a price), so
   a much narrower track fits all six across before wrapping, unlike the
   checkout `.facts` above where amounts need the room. */
.facts.overview {
  grid-template-columns: repeat(auto-fit, minmax(108px, 1fr));
  column-gap: 0.85rem;
  row-gap: 0.3rem;
  padding: 0.5rem 0.6rem;
  margin: 0.5rem 0;
}
.facts.overview dt { font-size: 0.68rem; }
.facts.overview dd { font-size: 0.92rem; }

/* An inline panel that opens under the row it belongs to. The left brand rule
   ties it visually back to that row and marks it as a nested context rather
   than a new top-level card. */
.action-box {
  margin-top: 0.85rem;
  padding: 0.9rem 1rem;
  background: var(--surface-tint);
  border: 1px solid var(--border);
  border-left: 3px solid var(--brand);
  border-radius: var(--radius-sm);
  animation: banner-in var(--t-base) var(--ease);
}
.action-box p { margin-top: 0; }

/* Promo code edit panel (§ Promo codes layout pass, 2026-08-16) — tighter
   than the default `.action-box` since it's a dense field grid, not prose,
   and the discount value never needs a full-width field like Code or the
   selects next to it. */
.action-box.promo-edit { padding: 0.6rem 0.8rem; }
.action-box.promo-edit .grid { row-gap: 0; }
.action-box.promo-edit .grid > label { margin-bottom: 0.4rem; }
.field-narrow { max-width: 50%; }

/* Promo code + Apply/Remove, side by side with the button's own baseline
   pinned to the input's bottom edge rather than the label text above it —
   `.grid`'s auto-fit columns left this ambiguous depending on container
   width, so it's an explicit flex row instead (§ Subscribe form pass,
   2026-08-16). */
.promo-row { display: flex; align-items: flex-end; gap: 0.6rem; margin: 0.5rem 0; }
.promo-row label { flex: 1 1 auto; margin-bottom: 0; min-width: 0; }
.promo-row .actions { margin: 0; flex: 0 0 auto; }

/* The subscribe form's price preview stacks a lot of small facts — tighten
   its rhythm so it reads as one compact card instead of several
   full-height blocks stacked on top of each other. */
.subscription-preview { padding: 0.55rem 0.8rem; margin: 0.5rem 0; }
.subscription-preview p { margin: 0.35rem 0; }
.subscription-preview .facts { margin: 0.5rem 0; padding: 0.6rem 0.8rem; gap: 0.5rem; }

/* The stacked booking triggers (Book this Class / Book the Series /
   Subscribe / Book a Guest) — tighter than the browser default <p> margin
   so the block reads as one compact group instead of four spread-out
   paragraphs. */
.action-trigger { margin: 0.35rem 0; }

/* Checkout (§4.4). The total is the number people actually read, so it gets
   its own row across the full width rather than sitting in the auto-fit grid
   alongside the price it was derived from. */
.checkout h2, .checkout h3 { margin-top: 0; }
.checkout .facts { margin-top: 0.75rem; }
.checkout .facts .total {
  grid-column: 1 / -1;
  padding-top: 0.6rem;
  border-top: 1px solid var(--border);
}
.checkout .facts .total dd { font-size: 1.25rem; font-weight: 600; }

/* Notifications inbox (§8.1). Same row geometry as .class-row, minus the link
   affordances — these aren't navigable. */
.notification {
  display: grid;
  grid-template-columns: 10rem 1fr auto;
  gap: 0.85rem;
  align-items: start;
  padding: 0.65rem 0.85rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  margin-bottom: 0.4rem;
  box-shadow: var(--shadow-xs);
}
/* Unread gets a brand rule *and* a faint tint — the 3px bar alone was easy to
   miss when scanning a full inbox. */
.notification.unread {
  border-left: 3px solid var(--brand);
  background: var(--surface-tint);
}
.notification .time { font-variant-numeric: tabular-nums; color: var(--muted); font-size: 0.9rem; }
.notification .title { display: flex; flex-direction: column; gap: 0.15rem; }
.notification .title small { color: var(--muted); }
.notification .meta { font-size: 0.8rem; color: var(--muted); white-space: nowrap; }

/* A payment left behind by a booking the member already cancelled (§4.4). */
.table tr.warn { background: var(--warn-bg); }
.table td small { display: block; margin-top: 0.2rem; font-size: 0.8rem; }

/* Payments queue (§5.8) — tint by outcome, so a long list reads at a glance:
   still owed, already settled, or written off. `row-orphaned` is the awkward
   one §4.4 keeps deliberately visible — the member cancelled, but the money
   may already have changed hands, so it's owed *and* has no seat behind it. */
.table tr.row-paid { background: var(--ok-bg); }
.table tr.row-cancelled { background: var(--border); opacity: 0.7; }
.table tr.row-orphaned { background: var(--warn-bg); }
/* Keeps the row a Remove/edit action-box is open for identifiable once its
   own row scrolls out of view above the panel. */
.table tr.row-selected > td { background: var(--surface-tint); }
/* The row highlight alone was too subtle a cue for which action opened the
   panel below — mark the actual Cancel/Remove button that's open too,
   same "pressed" treatment as `.list-tabs button.active`. */
.row-actions button.active {
  background: var(--brand);
  border-color: var(--brand);
  color: var(--brand-ink);
}
.payments-table .col-type { width: 12rem; }
.payments-table .col-member { width: 14rem; }
.payments-table .col-actions { white-space: nowrap; }
.payments-table .col-date { white-space: nowrap; }

/* Wider than the default reading-width `main` — this table has more columns
   plus an actions cell that needs Mark Paid and Cancel side by side, which
   the 900px default was too narrow for (they wrapped onto their own lines). */
main:has(.payments-table) { max-width: 1300px; }

/* Same reasoning as `.payments-table` above — the two-month calendar next to
   the class list needs more room than the 900px default, or the class-row
   grid squeezes until its cells wrap onto a second line. */
main:has(.schedule-layout) { max-width: 1300px; }

/* The record-lifecycle tables (Classes, Places, Teachers, Events) carry a
   Status column plus a row of actions (Edit/Cancel/Remove and friends) that
   the 900px default squeezes past the point `.row-actions`'s nowrap can
   absorb — `.table-scroll` was covering for that by scrolling instead of
   wrapping, but a scrollbar that appears on every page load because the
   container is too narrow is worse than just giving it more room. */
main:has(.table-scroll) { max-width: 1300px; }

/* The promo codes table has a Window column plus three actions
   (Edit/Deactivate/History) that wrap onto two lines at the 900px default. */
main:has(.promo-table) { max-width: 1150px; }

/* Settings has a lot of short fields (a timezone code, a 3-letter currency,
   a couple of hour counts) that were stretching to fill the 900px default
   for no reason — wider container, but the fields themselves stay compact
   via `.field-rows`/`.grid-compact` below rather than growing to match. */
main:has(.settings-page) { max-width: 1100px; }

/* Denser rows than the rest of the app's tables — a payments queue is a
   working list an Admin scans through many rows of, not read prose.

   Chained as `.table.payments-table` rather than `.payments-table` alone,
   which is what it used to be: that had the *same* specificity as the
   `.table th, .table td` base rule but sits earlier in this file, so the
   base rule won on source order and this override silently did nothing.
   (Latent before — the old base padding was close enough to hide it — and
   only became visible once the base padding grew.) The extra class outranks
   the base rule regardless of order. */
.table.payments-table th, .table.payments-table td { padding: 0.28rem 0.6rem; }

/* A denser row height for tables with several inline follow-up rows (an
   edit form, a confirmation) per record — those already add their own
   vertical rhythm, so the base row shouldn't also be roomy. */
.table.compact th, .table.compact td { padding: 0.32rem 0.45rem; }

.row-actions { display: inline-flex; gap: 0.35rem; }

/* Every button in this queue gets a real fill, not just an outline — the
   base `.danger` class only sets a red border/text elsewhere in the app,
   which reads as inert next to a filled `.primary` Mark Paid. */
.payments-table button.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.payments-table button.danger:hover:not(:disabled) { opacity: 0.85; }
.payments-table button:not(.primary):not(.danger) {
  background: var(--border);
  border-color: var(--border);
}

/* Sign-in gate — every route is behind this now (project decision, 2026-08). */
/* The first screen anyone sees, and until now the plainest — a bordered box
   with no elevation. Given the most lift in the app, since it's the only
   thing on the page. */
.sign-in-gate {
  max-width: 26rem;
  margin: 4rem auto;
  padding: 2.5rem 2rem;
  text-align: center;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  animation: gate-in 320ms var(--ease);
}
@keyframes gate-in {
  from { opacity: 0; transform: translateY(8px) scale(0.99); }
  to   { opacity: 1; transform: none; }
}
.sign-in-gate h1 { margin-top: 0; font-size: 1.75rem; }
.sign-in-gate .muted { margin-bottom: 1.5rem; }
/* Bigger than a default button: it's the single call to action on the page. */
.sign-in-gate button {
  margin-top: 0.5rem;
  padding: 0.6rem 1.4rem;
  font-size: 0.95rem;
  border-radius: var(--radius);
}

/* Place photos (§1.1.2a, §8.11) — admin management grid and the member-facing
   gallery on class detail share this sizing so a photo looks the same in
   both places. */
.photo-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 0.4rem;
  margin: 0.4rem 0;
}
.photo-thumb { position: relative; }
.photo-thumb img {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  display: block;
}

/* Class detail's place card: a member is scanning this on the way to
   booking, not browsing a gallery, so it stays a compact strip rather than
   the full admin-style card + grid. */
.action-box.place.compact { padding: 0.6rem 0.8rem; }
.action-box.place.compact h2 { margin: 0; font-size: 1rem; }
.action-box.place.compact .muted { margin: 0.15rem 0 0; font-size: 0.85rem; }

/* Horizontally-scrolling filmstrip, snapping one photo at a time — bigger
   and easier to browse than the old wrapped grid of tiny thumbnails. */
.photo-carousel {
  display: flex;
  gap: 0.5rem;
  margin: 0.6rem 0 0;
  padding-bottom: 0.3rem;
  overflow-x: auto;
  scroll-snap-type: x proximity;
}
.photo-carousel .photo-thumb {
  flex: 0 0 auto;
  scroll-snap-align: start;
}
.photo-carousel .photo-thumb img {
  width: 180px;
  aspect-ratio: 4 / 3;
}
/* A thumbnail is cropped to 4:3 — clicking opens the real thing. */
.photo-thumb img[data-zoomable] { cursor: zoom-in; }
.photo-thumb img[data-zoomable]:hover { border-color: var(--brand); }

/* ------------------------------------------------------------- lightbox */

/* One overlay lives in the app shell and is driven by `$store.lightbox`, so
   any screen showing photos gets zoom for free rather than each growing its
   own copy of the markup and the escape/backdrop handling. */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
  background: rgba(0, 0, 0, 0.78);
}
.lightbox img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  border-radius: var(--radius);
  box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4);
}
.lightbox-close {
  position: absolute;
  top: 0.75rem;
  right: 0.9rem;
  font-size: 1.4rem;
  line-height: 1;
  padding: 0.25rem 0.6rem;
  background: var(--surface);
  border-radius: 999px;
}

/* -------------------------------------------------------------- modal --- */

/* A small, focused popup for one document (the subscription form's waiver/
   terms read-and-accept step, 2026-08-15) — same backdrop convention as
   `.lightbox`, but a bounded card rather than a full-bleed image. */
.modal-overlay {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
  background: rgba(0, 0, 0, 0.5);
}
.modal {
  background: var(--surface);
  border-radius: var(--radius);
  box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35);
  padding: 1.25rem;
  width: 100%;
  max-width: 32rem;
  max-height: 85vh;
  overflow-y: auto;
  /* Draggable from the bottom-right corner so a long waiver/terms document
     can be read without the cramped default size (2026-08-15). `resize`
     needs an explicit `overflow` (already `auto` above) and a floor so it
     can't be shrunk down to nothing. */
  resize: both;
  min-width: 16rem;
  min-height: 10rem;
}
/* `.waiver-text`'s own scrollbar only helps once the modal is as big as it
   gets — growing the modal via the resize handle should hand that extra
   room straight to the text, not leave it stuck at 16rem with dead space
   below. */
.modal:has(.waiver-text) {
  display: flex;
  flex-direction: column;
}
/* Overlays the thumbnail's bottom-right corner, so it stays compact and keeps
   its own small radius rather than the button default. */
.photo-thumb button {
  position: absolute;
  bottom: 0.4rem;
  right: 0.4rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 0.18rem 0.5rem;
  font-size: 0.75rem;
  box-shadow: var(--shadow-sm);
}

.waiver h2 { margin-top: 0; }
.waiver-text {
  white-space: pre-wrap;
  font: inherit;
  max-height: 16rem;
  /* `scroll` (not `auto`) so the bar is always on screen — the whole point
     is a visible cue that there's more to read below the fold, which an
     auto-hiding bar can't give you until you've already found the edge and
     started dragging (2026-08-15). Paired with `.modal`'s resize handle:
     `flex: 1` below lets this box grow to fill the modal as it's resized,
     instead of staying pinned at 16rem with dead space under it. */
  overflow-y: scroll;
  flex: 1;
  /* `--field-bg`/`--field-border` — this box has no `:focus` state to ever
     rescue it (it's a `<pre>`, not a control), so unlike an input it can't
     fall back on "click it and it goes white." It sits inside `.action-box`
     (`--surface-tint`) in both places it's used, which is only a hair
     different from plain `--bg` — the same near-invisible pairing every
     other fix in this token addresses. */
  background: var(--field-bg);
  border: 1px solid var(--field-border);
  border-radius: var(--radius-sm);
  padding: 0.7rem 0.8rem;
  margin: 0 0 0.5rem;
}
/* The generic thin/`--border` scrollbar theme (§ scrollbars, below) reads as
   near-invisible against `--field-bg` here — this is the one scrollbar in the
   app that's load-bearing as a "there's more below" cue (see `scroll` above),
   so it gets its own thicker, darker treatment instead (2026-08-16). */
.waiver-text { scrollbar-width: auto; scrollbar-color: var(--muted) transparent; }
.waiver-text::-webkit-scrollbar { width: 14px; height: 14px; }
.waiver-text::-webkit-scrollbar-thumb { background: var(--muted); border-width: 3px; }
.waiver-text::-webkit-scrollbar-thumb:hover { background: var(--ink); }

/* ------------------------------------------------------------------ tabs */

.tabs { display: flex; gap: 0.35rem; margin-bottom: 0.85rem; }
.tabs button.active { background: var(--brand); border-color: var(--brand); color: var(--brand-ink); box-shadow: var(--shadow-xs); }

/* ---------------------------------------------------------------- tables */

.table {
  width: 100%;
  border-collapse: collapse;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  margin-bottom: 0.85rem;
  box-shadow: var(--shadow-xs);
}
.table th, .table td {
  text-align: left;
  padding: 0.5rem 0.7rem;
  /* `--border-subtle`, not `--border` — a full-contrast rule between every
     row turned a long table into a stack of boxes. The outer border still
     uses the stronger colour, so the table's own edge stays defined. */
  border-bottom: 1px solid var(--border-subtle);
  font-size: 0.9rem;
}
/* A tinted, slightly stickier header strip so the column names read as a
   header band rather than as the first row of data. */
.table th {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-weight: 620;
  color: var(--muted);
  background: color-mix(in srgb, var(--text) 3.5%, var(--surface));
  border-bottom-color: var(--border);
}
.table tr:last-child td { border-bottom: none; }
/* Row hover, scoped to tbody rows so it can't tint the header. Makes a wide
   admin table scannable across its full width. */
.table tbody tr:hover > td { background: color-mix(in srgb, var(--brand) 4%, transparent); }
/* Admin Classes' series rows (§7.2) expand in place — the row itself is the
   toggle, so it needs its own pointer/expanded affordance distinct from the
   plain hover tint above. */
.table tr.clickable { cursor: pointer; }
.table tr.clickable.expanded > td { background: var(--surface-tint); }
/* Paid-green is a payment signal, not decoration, so it outranks both the
   hover and expanded tints — otherwise a paid Subscriptions row would stop
   reading as paid the moment the admin opened it. The `.clickable` selector
   is there to match the expanded rule's specificity above and win on order;
   without it the tint would take precedence. */
.table tr.row-paid > td,
.table tr.clickable.row-paid > td,
.table tbody tr.row-paid:hover > td { background: var(--ok-bg); }

/* A table's last column, when it's a run of buttons (Edit/Cancel/Remove and
   the like), wraps onto a second line by default — a `<td>` with several
   loose inline elements has no more wrapping control than a paragraph of
   text. `.row-actions` gives that cell its own nowrap flex row instead, so
   the row stays one line; `.table-scroll` lets the table scroll sideways on
   a narrow viewport rather than the actions wrapping anyway. */
.table-scroll { overflow-x: auto; }
/* `width: auto` (not 100%) so the table hugs its content instead of the
   browser stretching extra space into gaps between narrow columns. */
.table-scroll .table { width: auto; min-width: max-content; }
/* An action-box's content (e.g. Places' photo grid, sized `auto-fill`) has no
   inherent width cap of its own, so inside a table forced to `max-content` it
   was free to blow up to however wide the browser could lay out empty grid
   tracks for — stretching the whole table into a horizontal scrollbar with
   nothing in it. A fixed-unit cap (percentages don't apply during
   `max-content` sizing) bounds the box to a sane form width regardless of
   what's inside it (§ Places edit-panel pass, 2026-08-16). */
.table-scroll .action-box { max-width: 50rem; }
.row-actions { display: flex; gap: 0.35rem; flex-wrap: nowrap; align-items: center; white-space: nowrap; }

/* Active/Archived (and similar) list tabs — visually separates retired
   records from live ones instead of interleaving them in the same table. */
.list-tabs { display: flex; gap: 0.35rem; margin: 0.6rem 0; }
.list-tabs button.active { background: var(--brand); border-color: var(--brand); color: var(--brand-ink); box-shadow: var(--shadow-xs); }

/* Groups the place filter with the horizon tabs on the right of the Classes
   list toolbar, so both controls share one row and line up on height. */
.right-controls { display: flex; align-items: center; gap: 0.5rem; }
.right-controls .list-tabs { margin: 0; }
/* A plain select rather than the `.filters label` treatment used elsewhere —
   it sits inline next to the horizon buttons, so it's sized and aligned to
   match them instead of stacking a label above it. */
.place-filter {
  height: 2rem;
  padding: 0 0.5rem;
  font-size: 0.85rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: inherit;
}
/* Same sizing as .place-filter so the two sit flush together inline. */
.title-filter {
  height: 2rem;
  width: 11rem;
  padding: 0 0.5rem;
  font-size: 0.85rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: inherit;
}

.waitlist { padding-left: 1.5rem; }
.row-between { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }

/* ------------------------------------------------------------ master/detail */

/* A long left-hand list (e.g. Members) with the selected record's detail
   pinned to the right instead of pushed below the fold — the list scrolls
   on its own, the detail pane doesn't, so picking a different record never
   means scrolling back up past everything just shown. */
.master-detail {
  display: flex;
  align-items: flex-start;
  gap: 0.85rem;
}
.list-pane {
  flex: 0 0 15rem;
  max-height: 70vh;
  overflow-y: auto;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-sm);
}
.detail-pane {
  flex: 1 1 auto;
  min-width: 0;
}
.member-list { list-style: none; margin: 0; padding: 0.3rem; }
.member-row {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: center;
  gap: 0.1rem;
  width: 100%;
  text-align: left;
  padding: 0.45rem 0.6rem;
  border: none;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--text);
  box-shadow: none;
  white-space: normal;
}
/* The generic button press-nudge would make the whole list twitch while
   picking through members. */
.member-row:active:not(:disabled) { transform: none; box-shadow: none; }
.member-row strong { font-size: 0.9rem; }
.member-row small { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The selected row must out-rank hover. `:hover:not(:disabled)` is three
   selectors deep, so a plain `.member-row.active` loses to it — hovering the
   selected row then repainted it light while its text stayed white, i.e.
   invisible. Both states are spelled out at equal-or-higher specificity. */
.member-row:hover:not(:disabled):not(.active) { background: var(--bg); }
.member-row.active,
.member-row.active:hover:not(:disabled) { background: var(--brand); }
.member-row.active strong,
.member-row.active small,
.member-row.active:hover strong,
.member-row.active:hover small { color: var(--brand-ink); }

@media (max-width: 720px) {
  .master-detail { flex-direction: column; }
  .list-pane { flex: 1 1 auto; width: 100%; max-height: 40vh; }
}

/* ---------------------------------------------------------------- charts */

/* Hand-drawn SVG/CSS bars rather than a charting library — no build step and
   no CDN dependency (see README), and four bar charts don't justify
   vendoring one. Geometry comes from `lib/reportRules.js`. */

.chart-box {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1rem 1.1rem;
  margin-bottom: 0.9rem;
  box-shadow: var(--shadow-sm);
}
.chart-box h3 {
  font-size: 0.95rem;
  margin: 0 0 0.5rem;
}
.chart-box > p:last-child { margin-bottom: 0; }

.chart-scroll { overflow-x: auto; }
.chart { display: block; width: 100%; min-width: 420px; height: auto; }
.chart .bar { fill: var(--brand); }
.chart .bar-label { font-size: 10px; fill: var(--muted); }

/* Horizontal bars carry a label and a value alongside them, which a vertical
   axis has no room for — used wherever the category name matters more than
   the shape of a trend. */
.hbar-row {
  display: grid;
  grid-template-columns: 9rem 1fr 5rem;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 0.25rem;
  font-size: 0.88rem;
}
.hbar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hbar-label.wide { font-size: 0.82rem; }
.hbar-track {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 999px;
  height: 0.85rem;
  overflow: hidden;
}
.hbar-fill {
  display: block;
  height: 100%;
  background: var(--brand);
  border-radius: 999px;
  min-width: 2px;
}
.hbar-fill.warn { background: var(--danger); }
.hbar-fill.neutral { background: var(--muted); }
/* Over capacity is legitimate (§2.3, and walk-ins per §8.4) — flag it rather
   than letting a clamped 100% bar hide it. Uses `--danger` for contrast on
   both grounds; the warn-tinted border colours are too pale against the
   track to read as a fill. */
.hbar-fill.over { background: var(--danger); }
.hbar-value { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }

@media (max-width: 640px) {
  .hbar-row { grid-template-columns: 6.5rem 1fr 4rem; font-size: 0.8rem; }
}

/* ----------------------------------------------------------------- forms */

.form {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1.15rem;
  margin-bottom: 1rem;
  box-shadow: var(--shadow-sm);
}
.form > *:first-child { margin-top: 0; }
.form > label, .grid > label {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  margin-bottom: 0.7rem;
  font-size: 0.8rem;
  font-weight: 550;
  letter-spacing: 0.01em;
  color: var(--muted);
}
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(175px, 1fr));
  gap: 0 0.6rem;
}
/* Same idea as `.grid`, but columns stop growing past a sensible field
   width instead of stretching to fill the row — for short values (a
   timezone, a currency code, an hour count) where a wide container just
   means a wide, mostly-empty input (§ Settings layout pass, 2026-08-14). */
.grid-compact {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 220px));
  gap: 0.4rem 0.9rem;
}
/* Fixed-count rows for forms with many short fields (a select, a number, a
   date) where even `.grid-compact`'s 220px cap only fits 3 across — forcing
   an exact column count packs them onto one row instead of wrapping
   (§ Class series form pass, 2026-08-15). */
.grid-row-4, .grid-row-6 {
  display: grid;
  gap: 0.4rem 0.6rem;
}
.grid-row-4 { grid-template-columns: repeat(4, 1fr); }
.grid-row-6 { grid-template-columns: repeat(6, 1fr); }
@media (max-width: 640px) {
  .grid-row-4, .grid-row-6 { grid-template-columns: repeat(2, 1fr); }
}

/* Studio settings' top row (§7.2) — explicit unequal columns, not
   `.grid-compact`'s auto-fill, so all 5 fields hold to one row instead of
   wrapping to two. Studio name/Timezone keep normal width; Currency/Default
   language/Week starts on are short-value fields (a 3-letter code, a
   2-option select) that don't need it, so they're sized to about half a
   normal field to make room. */
.grid-row-studio-basics {
  display: grid;
  grid-template-columns: minmax(160px, 1.4fr) minmax(160px, 1.4fr) minmax(70px, 0.7fr) minmax(110px, 0.9fr) minmax(70px, 0.7fr);
  gap: 0.4rem 0.9rem;
}
@media (max-width: 900px) {
  .grid-row-studio-basics { grid-template-columns: repeat(2, 1fr); }
}
/* A label-left, field-right row — for a flat list of policy fields where
   stacking the label above the input (the default `label` layout) wastes
   vertical space without adding clarity. */
.field-rows { display: flex; flex-direction: column; }
.field-row {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 0.6rem;
  padding: 0.45rem 0;
  border-bottom: 1px solid var(--border-subtle);
}
.field-row:last-child { border-bottom: none; }
.field-row input, .field-row select { width: 9rem; flex: 0 0 auto; }
/* Default policies (Settings > Studio) — five short numeric/text fields that
   don't need `.field-row`'s normal padding/border, which was built for
   heavier rows elsewhere (§ Settings layout pass, 2026-08-16). */
.field-rows-compact .field-row { padding: 0.2rem 0; border-bottom: none; }
label.inline {
  display: inline-flex;
  flex-direction: row;
  align-items: center;
  gap: 0.3rem;
  margin-right: 0.6rem;
  color: var(--text);
  white-space: nowrap;
}

/* Book-a-guest (§ redesign, 2026-08-14) — name field and payment choice
   share one row instead of each getting a full-width block of its own;
   this is a two-field mini-form, not the multi-section checkout it was
   sitting inside. */
.guest-compact-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 0.6rem 1.25rem;
}
.guest-name-field {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  font-size: 0.8rem;
  font-weight: 550;
  color: var(--muted);
  flex: 0 1 12rem;
}
.guest-rate-options {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.3rem 0.9rem;
  padding-bottom: 0.55rem;
}
.guest-rate-options .rate-label { font-size: 0.8rem; }

input, select, textarea {
  font: inherit;
  font-size: 0.9rem;
  color: var(--text);
  /* `--field-bg`/`--field-border`, not `--bg`/`--border` — see the comment
     where those tokens are defined. This is what makes a resting field
     readable instead of blending into whatever it's sitting on. */
  background: var(--field-bg);
  border: 1px solid var(--field-border);
  border-radius: var(--radius-sm);
  padding: 0.45rem 0.6rem;
  width: 100%;
  transition:
    border-color var(--t-fast) var(--ease),
    box-shadow var(--t-fast) var(--ease),
    background-color var(--t-fast) var(--ease);
}
input:hover:not(:disabled), select:hover:not(:disabled), textarea:hover:not(:disabled) {
  border-color: color-mix(in srgb, var(--text) 20%, var(--border));
}
input:disabled, select:disabled, textarea:disabled {
  background: var(--bg);
  color: var(--muted);
  cursor: not-allowed;
}
input[type="checkbox"], input[type="color"] { width: auto; }
/* Checkboxes and radios inherit the brand colour so a ticked box matches the
   rest of the theme instead of rendering in the OS default blue. */
input[type="checkbox"], input[type="radio"] { accent-color: var(--brand); }
input::placeholder, textarea::placeholder { color: var(--muted); opacity: 0.75; }

/* A soft ring plus a brand border, replacing the hard 2px outline. Same
   `--ring` token the buttons use, so focus is visually identical across
   every control type. The field also lifts to `--surface` so the focused
   input is the brightest thing in the form. */
input:focus, select:focus, textarea:focus {
  outline: none;
  border-color: var(--brand);
  background: var(--surface);
  box-shadow: var(--ring);
}

fieldset {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  margin: 0 0 0.5rem;
  padding: 0.4rem 0.6rem;
}
fieldset > legend + * { margin-top: 0; }
legend { font-size: 0.8rem; color: var(--muted); padding: 0 0.35rem; }

details { margin-bottom: 0.5rem; }
details > .grid, details > div > .grid { margin-top: 0.4rem; }
summary { cursor: pointer; color: var(--brand); font-size: 0.9rem; }

.actions { display: flex; gap: 0.5rem; margin-top: 0.6rem; flex-wrap: wrap; align-items: center; }

/* Every "Loading…" message in the app (2026-08-14) — fixed to the bottom of
   the viewport, out of document flow, so it can never push content around
   while it's shown and can never leave a gap/shift when it disappears. Was
   previously inline in the page flow, which is exactly what caused content
   to visibly jump once loading finished. */
.loading-note {
  position: fixed;
  left: 50%;
  bottom: 1rem;
  transform: translateX(-50%);
  margin: 0;
  padding: 0.4rem 0.9rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 999px;
  box-shadow: var(--shadow-md);
  z-index: 40;
}

@media (max-width: 640px) {
  .class-row { grid-template-columns: 6rem 1fr; }
  .class-row .meta, .class-row .spots { grid-column: 2; }
  .class-row .book-button { grid-column: 2; justify-self: start; margin-top: 0.3rem; }
  .notification { grid-template-columns: 1fr; }
  .notification .meta { grid-column: 1; }
}

/* ------------------------------------------------------------ scrollbars */

/* Themed to match the surface they sit on — the OS default light scrollbar
   on a dark panel is one of the louder things left once everything else is
   themed. Firefox uses the standard property; WebKit/Blink needs its own. */
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: var(--border);
  border-radius: var(--radius-pill);
  /* Inset via a transparent border so the thumb reads as a slim pill rather
     than filling the whole gutter. */
  border: 2px solid transparent;
  background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--muted); background-clip: content-box; }

/* ------------------------------------------------------- reduced motion */

/* Everything added above — hover lifts, the press nudge, banner and gate
   entrances — is decorative. Honour the OS setting and drop all of it rather
   than leaving someone who asked for stillness with a page that moves. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  .class-row:hover, button:active:not(:disabled), .button:active { transform: none !important; }
}

/* ---- Landing page (project decision, 2026-08-13) — content only, design
   deferred (§5.2 of that decision). Just enough layout to be usable. ------ */

.landing-page { max-width: 640px; margin: 0 auto; text-align: center; }
.landing-logo { max-width: 200px; max-height: 120px; margin: 0 auto 1rem; display: block; }
.landing-intro { color: var(--muted); white-space: pre-wrap; }
/* Sits directly under the studio name (landing page h1 and the sign-in
   gate's, `index.html`) — pulled tight against it rather than inheriting
   the normal paragraph/`.muted` spacing above, so it reads as one unit with
   the name instead of a separate line of body text. */
.studio-subtitle { margin-top: -0.5rem; font-size: 0.95rem; }
.announcement-list { display: grid; gap: 1rem; margin-top: 2rem; text-align: left; }
.announcement-card { border: 1px solid var(--border); border-radius: 8px; padding: 1rem; background: var(--surface); }
.announcement-carousel { position: relative; margin-bottom: 0.75rem; }
.announcement-carousel img { width: 100%; max-height: min(60vh, 420px); height: auto; object-fit: contain; background: var(--bg); border-radius: 6px; display: block; }
.announcement-carousel-nav { position: absolute; inset: 0; display: flex; align-items: center; justify-content: space-between; padding: 0 0.5rem; }
.announcement-carousel-nav button { background: rgba(0, 0, 0, 0.4); color: #fff; border: none; border-radius: 50%; width: 2rem; height: 2rem; cursor: pointer; }

/* Irreversible account actions (§8.7), set apart from the settings above them
   so "delete my account" can't be mistaken for one more toggle on the form. */
.danger-zone { margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px solid var(--border); }

/* Connection / update notices. Sit directly under the header, full-bleed
   rather than inside `main`'s reading column, so they read as app chrome
   rather than as something a particular screen is saying. */
.app-status-banner {
  margin: 0;
  border-radius: 0;
  border-left: none;
  border-right: none;
  text-align: center;
  font-size: 0.9rem;
}

/* ========================================================== mobile layer ==
   Everything below is the viewport-based layer from
   `cys-app-MOBILE-PERFORMANCE-ANALYSIS.md`. Two rules govern it:

   1. It never changes *what* a role may see. Role gating stays where it has
      always been — the `x-if`/`x-show` checks in `index.html`. This layer only
      decides how much of that is shown at once on a narrow screen.
   2. It never touches the desktop rendering. Every rule here sits inside a
      `max-width` query, so the hand-tuned desktop layout above is untouched.

   Breakpoints reuse the two the file already had (720px / 640px) rather than
   introducing a third scale. 720px is "not a desktop"; 640px is "phone".
   ========================================================================= */

/* ---- Touch ergonomics vs. layout ----------------------------------------
 *
 * The two blocks below are keyed on `(pointer: coarse), (max-width: 720px)` —
 * "the pointer is a finger, OR the viewport is narrow" — while every layout
 * rule in this file is keyed on width alone. That split is deliberate.
 *
 * Width answers "how much room is there". It does NOT answer "what is the
 * person pointing with", and these two rules are entirely about the latter.
 * Keying them on width alone produced a real bug: rotating a phone to
 * landscape makes it ~844px wide, which is past the 720px breakpoint, so the
 * app concluded "desktop" and reverted to 33px buttons and 14.4px inputs —
 * bringing the iOS zoom back — while the viewer was still tapping with a
 * thumb. Measured at 844x390: controls 33/38px, inputs 14.4px.
 *
 * `pointer: coarse` (primary input is touch) covers phones and tablets in any
 * orientation. Deliberately not `any-pointer: coarse`, which would also match
 * a touchscreen laptop driven by its trackpad and inflate the desktop UI for
 * it.
 *
 * The `max-width` half is kept alongside so a narrow desktop window still
 * shows the touch sizing — that's the existing behaviour, and it's how this
 * gets tested without a physical device.
 */

/* ---- iOS zoom-on-focus (blocker 1) -------------------------------------- */

/* Safari on iOS zooms into any focused field whose font-size is under 16px,
   and never zooms back out — leaving the member scrolled sideways mid-
   checkout. The desktop 0.9rem is deliberate (see `input, select, textarea`
   above), so this lifts it only where it actually breaks. 16px exactly, not
   1rem: the trigger is the computed pixel size, and the root size is a
   browser/user setting we don't control. */
@media (pointer: coarse), (max-width: 720px) {
  input, select, textarea { font-size: 16px; }

  /* Two rules earlier in this file set a smaller size on a *more specific*
     selector, so the bare-element rule above loses to them and those two
     controls kept triggering the zoom — `.filters select` on the schedule,
     which is the most-used screen in the app. Matched here at the same
     specificity. Their fixed heights are released too, so the 44px touch
     minimum below can actually apply. */
  .filters select,
  .title-filter { font-size: 16px; height: auto; }
}

/* ---- Touch targets (blocker 2) ------------------------------------------ */

/* Buttons render ~30px tall at the desktop sizing above (0.9rem text +
   0.42rem padding). Apple's minimum touch target is 44px, Material's 48px.
   Raised with `min-height` rather than more padding so the label stays
   centred and nothing reflows.

   `button.link` is deliberately exempt: those are inline text links sitting
   inside sentences ("· Export roster CSV"), not tap targets, and giving them
   a 44px box would wreck the line box they sit in.

   Pointer-keyed as well as width-keyed — see the note above the previous
   block for why. */
@media (pointer: coarse), (max-width: 720px) {
  button, .button, select { min-height: 44px; }
  button.link, button.link.danger { min-height: 0; }
  /* Checkboxes/radios get a bigger hit area without growing the control
     itself, which `accent-color` styling depends on staying native. */
  input[type="checkbox"], input[type="radio"] { min-width: 22px; min-height: 22px; }
  /* Stacked action rows get real separation — at 44px tall, the desktop
     0.5rem gap puts two destructive buttons within a thumb's slop of
     each other. */
  .actions { gap: 0.6rem; }
}

/* ---- Wide tables (blocker 3) -------------------------------------------- */

/* 26 of the app's 32 tables have no scroll wrapper, so on a phone they either
   force the whole page to scroll sideways or squash their columns into
   unreadability.

   Done in CSS rather than by adding 26 `.table-scroll` wrappers to the markup
   for two reasons: a wrapper edit is 26 chances to break a `<template>` in a
   file with no tests behind it, and `main:has(.table-scroll)` (above) widens
   `main` to 1300px — so reusing that class would have silently changed the
   desktop width of fourteen screens as a side effect.

   `display: block` turns the table itself into the scroll container; the rows
   inside are re-wrapped in an anonymous table box that sizes to its content.
   `min-width: max-content` on that content is what keeps columns from
   collapsing, and `width: auto` overrides the base `width: 100%`, which would
   otherwise fight the intrinsic sizing. */
@media (max-width: 720px) {
  .table {
    display: block;
    width: auto;
    overflow-x: auto;
    /* Momentum scrolling on iOS, and a hint that there's more to the right. */
    -webkit-overflow-scrolling: touch;
    overscroll-behavior-x: contain;
  }
  /* The already-wrapped six keep their wrapper doing the scrolling — nesting
     a scroll container inside a scroll container gives two scrollbars and a
     table that can't reach its own right edge. */
  .table-scroll .table { display: table; width: auto; overflow-x: visible; }
}

/* ---- Tables that become cards ------------------------------------------- */

/* A scrolling table (above) keeps a phone *working*; it doesn't make it
   pleasant. Swiping sideways to read your own booking is the thing members
   notice first, and a teacher marking attendance shouldn't have to scroll
   horizontally to reach the one control the screen exists for.

   So the member-facing tables and the roster restack: each row becomes a
   card, each cell becomes a label/value line. The label comes from
   `data-label` on the cell (added in index.html, mirroring that table's own
   <th>) because the real <thead> is hidden here — there's no header row left
   to line up against once the columns are gone.

   Only tables carrying `.table-cards` do this. Admin/Tier-3 tables keep the
   scroll treatment: they're desk screens, and a 12-column report is not
   improved by becoming twelve stacked label/value lines. */
@media (max-width: 720px) {
  .table.table-cards {
    /* Undoes the scroll-container treatment above — a card stack has nothing
       to scroll sideways, and the chrome moves to the individual cards. */
    display: block;
    overflow-x: visible;
    border: none;
    background: none;
    box-shadow: none;
    margin-bottom: 0;
  }
  .table-cards thead { display: none; }
  .table-cards tbody { display: block; }

  .table-cards tbody tr {
    display: block;
    padding: 0.6rem 0.75rem;
    margin-bottom: 0.5rem;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    box-shadow: var(--shadow-xs);
  }
  /* Row tints (paid / cancelled / orphaned / selected) are set on `td` in the
     base styles, which no longer paints anything useful once each cell is its
     own line — move the signal to the card itself. */
  .table-cards tbody tr.row-paid { background: var(--ok-bg); }
  .table-cards tbody tr.row-cancelled { background: var(--border); opacity: 0.7; }
  .table-cards tbody tr.row-orphaned,
  .table-cards tbody tr.warn { background: var(--warn-bg); }
  .table-cards tbody tr.row-selected { border-color: var(--brand); }
  .table-cards tbody tr > td { background: none !important; }
  .table-cards tbody tr:hover > td { background: none; }

  .table-cards td {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 1rem;
    padding: 0.2rem 0;
    border-bottom: none;
    text-align: right;
  }
  .table-cards td[data-label]::before {
    content: attr(data-label);
    flex: 0 0 auto;
    text-align: left;
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    font-weight: 620;
    color: var(--muted);
    /* Same treatment the real <th> gets, so a card reads as the same table
       rotated rather than as a different component. */
  }
  /* The first labelled cell is the record's identity (member name, class
     title, pack name) — it gets to be the card's heading rather than one
     more label/value line. */
  .table-cards td[data-label]:first-child {
    display: block;
    text-align: left;
    font-weight: 600;
    font-size: 0.95rem;
    margin-bottom: 0.3rem;
  }
  .table-cards td[data-label]:first-child::before {
    display: block;
    margin-bottom: 0.1rem;
    font-weight: 620;
  }
  /* Cells with no label are the actions column: full width, left-aligned,
     separated from the facts above them. */
  .table-cards td:not([data-label]) {
    display: block;
    text-align: left;
    margin-top: 0.4rem;
  }
  /* Inline follow-up rows (refund panel, remove confirmation) span the table
     and must not be squeezed into the label/value grid. */
  .table-cards td[colspan] { display: block; text-align: left; padding: 0; }
  .table-cards td[colspan]::before { content: none; }
  .table-cards .row-actions { flex-wrap: wrap; }
  /* An attendance select inside a card has the full width to use. */
  .table-cards td[data-label] select { width: 100%; min-width: 0; }
}

/* ---- Header and navigation (blocker 4) ---------------------------------- */

/* The header is the single worst thing about this app on a phone. The comment
   on `.app-header` above already notes the nav wrapping to ~120px at 1280px
   with 17 links; at 375px an Admin's 13 links plus the brand, the role
   badges, the view switcher and the account menu is roughly 250px of chrome
   before any content — over a third of the viewport.

   The fix is a single-row horizontally scrollable strip. Chosen over a bottom
   tab bar deliberately: a tab bar means a second copy of the nav links in the
   markup, which is exactly the "doubling" this project ruled out, and it
   would have to hide the less-used routes behind a disclosure. A scroll strip
   needs no new markup, hides nothing from any role, and collapses the header
   from ~250px to ~52px. */
@media (max-width: 720px) {
  .app-header {
    padding: 0.5rem 0.75rem;
    gap: 0.5rem;
    /* Brand and account on row one, the nav strip on its own row beneath. */
    flex-wrap: wrap;
    row-gap: 0.35rem;
  }
  /* The build/viewport readout doesn't fit beside the studio name and the
     account menu at phone widths — it wrapped between them and pushed the
     header from 107px to 141px. Given its own row instead: `flex-basis: 100%`
     forces the line break, `max-width: max-content` keeps the pill hugging its
     text rather than stretching across the full width. Costs ~25px of header
     while the mobile UI is being worked on; drop this rule and the tag when
     the readout is no longer wanted. */
  .build-tag { order: 2; flex: 0 0 100%; max-width: max-content; }

  .brand { font-size: 1rem; flex: 0 1 auto; min-width: 0; }
  .brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .auth { margin-left: auto; }

  .nav {
    order: 3;
    flex: 1 0 100%;
    flex-wrap: nowrap;
    overflow-x: auto;
    /* Nav links are the one place a 44px target would cost more than it buys:
       it would make the strip 44px tall on every screen for links that are
       already comfortably wide. 38px is the compromise. */
    gap: 0.1rem;
    padding-bottom: 0.15rem;
    scrollbar-width: none;
    scroll-snap-type: x proximity;
  }
  .nav::-webkit-scrollbar { display: none; }
  /* `display: contents` (desktop) makes the admin group's children lay out as
     direct flex children of `.nav` — which is what we want here too, so the
     strip scrolls through every link rather than one indivisible block. */
  .nav a {
    flex: 0 0 auto;
    min-height: 38px;
    scroll-snap-align: start;
  }

  /* Admin-only chrome that costs more than it's worth at this width. The role
     is still visible in the account menu; the view switcher is an Admin
     debugging aid (§8.5.1), not something needed mid-class on a phone. */
  .view-switcher { display: none; }
  .auth .badge { display: none; }
  .account-menu-trigger { max-width: 9rem; }
  .account-menu-trigger > span:first-child {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
}

/* ---- Loading note ------------------------------------------------------- */

/* Fixed to the bottom of the viewport, which on iOS is behind the browser's
   own chrome and the home indicator. */
@media (max-width: 720px) {
  .loading-note { bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px)); }
}

/* ---- Schedule (Tier 1 — the browsing path) ------------------------------ */

@media (max-width: 720px) {
  /* Four selects and a tab group sitting on one flex line. Stacked, each
     filter gets the full width instead of a 6rem stub. */
  .filters { flex-direction: column; align-items: stretch; gap: 0.5rem; }
  .filters label { width: 100%; }
  .filter-tabs-group { justify-content: space-between; }
  .list-tabs { flex-wrap: wrap; }

  /* The second month is hidden here as a layout decision; `ui/schedule.js`
     also stops *computing* it at this width (see `calendarMonths`), which is
     where the actual saving is — this rule alone would still pay to build and
     lay out a grid nobody sees. */
  .calendar-two-up { flex-basis: auto; }
}

/* The collapsed filter control. Only ever rendered below the mobile
   breakpoint (`x-show="isNarrowViewport"`), so it needs no desktop rules. */
.filters-toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  margin-bottom: 0.6rem;
}
.filters-toggle .caret { transition: transform var(--t-fast) var(--ease); }
.filters-toggle .caret.up { transform: rotate(180deg); }

/* ---- The decisive action, kept in reach (sticky CTA) -------------------- */

/* Every step that commits a member to something — the rate picker, checkout,
   the pack confirm, the series checkout — puts its buttons in an `.actions`
   row at the bottom of an `.action-box`. On a phone those panels are long
   enough (facts list, promo field, credit field, policy copy) that Confirm
   sits below the fold, so the member scrolls to find the thing they already
   decided to do.

   `sticky`, not `fixed`: it pins to the bottom of the viewport only while its
   own panel is on screen, and returns to normal flow at the panel's end. A
   fixed bar would hover over unrelated screens and fight `.loading-note`.

   Scoped to `.action-box .actions` on purpose — the page's other `.actions`
   rows (cancel-scope prompts, the waiver/terms steps) are short enough not to
   need it, and pinning several at once would stack them. */
@media (max-width: 720px) {
  .action-box > .actions {
    position: sticky;
    bottom: 0;
    z-index: 5;
    margin: 0 -0.8rem -0.6rem;
    padding: 0.6rem 0.8rem calc(0.6rem + env(safe-area-inset-bottom, 0px));
    background: var(--surface);
    border-top: 1px solid var(--border);
    /* The panel's own rounding, so the pinned row doesn't square off a
       corner it's sitting inside. */
    border-radius: 0 0 var(--radius) var(--radius);
  }
  /* The primary action takes the width it deserves; the escape hatch stays
     secondary rather than sitting at equal weight beside it. */
  .action-box > .actions > button.primary,
  .action-box > .actions > .button.primary { flex: 1 1 auto; justify-content: center; }
  /* An `.actions` row the layout has already pinned to the panel's end (the
     inline promo/apply pair) must not also stick — it isn't the commit step. */
  .action-box .grid .actions,
  .action-box > .actions[style*="align-self"] { position: static; margin: 0; padding: 0; border-top: none; background: none; }
}

@media (max-width: 640px) {
  /* Seven columns inside ~343px of usable width is ~44px per day. Tightening
     the gap and the type is what keeps the day cells from clipping their
     event blocks. */
  .week-strip-days { gap: 0.2rem; }
  .week-strip-day { padding: 0.25rem 0.1rem; }
  .week-strip-nav { font-size: 0.8rem; }
}

/* ---- Roster (Tier 1 — the teacher's in-studio screen) ------------------- */

@media (max-width: 720px) {
  /* The attendance <select> is the control this whole screen exists for, and
     the base `width: 100%` makes it stretch to whatever the column allows.
     Pinned to a real, thumb-sized target instead. */
  .table select { width: auto; min-width: 8.5rem; }
  /* Keeps Refund/Remove side by side rather than stacking into a tall row. */
  .row-actions { flex-wrap: nowrap; }
}

/* ---- Forms and grids ---------------------------------------------------- */

@media (max-width: 720px) {
  /* `auto-fit` grids collapse to one column rather than two half-width
     fields that can't fit their own labels. */
  .grid, .grid-compact { grid-template-columns: 1fr; }
  .grid-row-studio-basics { grid-template-columns: 1fr; }
  /* A label-left/field-right row has nowhere to put a 9rem field at this
     width — stack it. */
  .field-row { flex-direction: column; align-items: stretch; gap: 0.25rem; }
  .field-row input, .field-row select { width: 100%; }
  /* Wide-by-design containers: harmless as max-widths, but the horizontal
     padding still applies, so trim it back. */
  main { padding: 1rem 0.75rem 3rem; }
}

@media (max-width: 640px) {
  .grid-row-4, .grid-row-6 { grid-template-columns: 1fr; }
  /* The two-up month grid stacks rather than squeezing two 7-column grids
     side by side. */
  .calendar-months { gap: 0.75rem; }
}
.danger-zone h2 { color: var(--danger); margin-bottom: 0.25rem; }
.danger-zone code { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 0.05rem 0.3rem; }
