/* The parent-facing app.
 *
 * This file is the design system. It used to be 123 lines that owned almost
 * nothing, with roughly a thousand lines of CSS living inside nineteen
 * templates — `ul.tasks` was duplicated character-for-character between
 * home.html and tasks.html, `.chat-head` between rooms.html and settings.html,
 * and `.note` meant two different things depending on which page you were on.
 * A component that lives in one template cannot be reused, so the next page
 * copies it, and then they drift.
 *
 * `--brand` and `--accent` are NOT defined here. They are injected per request
 * by templates/_head.html from web/branding.py, because they are the club's,
 * not the app's. This file therefore cannot be loaded without _head.html.
 *
 * Admin has its own stylesheet (static/admin.css) and shares nothing with this
 * one but that injected block. Changing this file cannot break the admin.
 */

:root {
  /* --- colour ---------------------------------------------------------- */
  --bg: #f6f7f9;
  --surface: #ffffff;
  /* One step up from --surface, for something raised *on* a card. */
  --surface-2: #f2f4f7;
  --text: #1a1c1f;
  --muted: #5c636b;
  --line: #e3e6ea;
  --ok-bg: #e8f6ec; --ok-fg: #16642c;
  --warn-bg: #fff4e5; --warn-fg: #8a4b00;
  --err-bg: #fdecec; --err-fg: #96201d;

  /* --- team identity ----------------------------------------------------
   * Which team this is, and nothing else. Six cool-leaning colours kept clear
   * of `--brand` (the club is waiting on you) and of the green/orange/red the
   * RSVP chips own, because a colour that means two things means neither.
   *
   * Allocated per PARENT rather than per club: most have one to three teams,
   * so six can guarantee that theirs are always far apart, which an
   * assignment across thirty teams cannot. See docs/design-system.md.
   *
   * Never the only signal — every mark carries its age number or initials, or
   * it is decoration. Roughly one man in twelve cannot tell teal from plum.
   *
   * **No fixed palette can guarantee separation from `--brand`**, because
   * brand is a per-club environment variable — a club with a purple one would
   * sit on top of --team-2. These avoid the defaults (#ff6600 orange and
   * #0099ff blue) and the label is what carries a clash. A sixth clay-orange
   * was drawn and dropped for exactly that reason. */
  --team-1: #0d7d74; --team-2: #6d4aca; --team-3: #a03582;
  --team-4: #2f7d45; --team-5: #4a5b8c; --team-6: #1b6b8f;

  /* --- type ------------------------------------------------------------
   * 16px stays the base and every text input stays at 1rem. iOS Safari zooms
   * the whole page when you focus an input under 16px and does not zoom back
   * out, so "compact" applied to form fields makes the app unusable one tap
   * in. The scale below is for labels, meta and captions — the places where
   * density is actually won. */
  --t-xs: .75rem;     /* 12px — timestamps, counts */
  --t-sm: .8125rem;   /* 13px — meta lines, captions */
  --t-md: .875rem;    /* 14px — secondary body */
  --t-base: 1rem;     /* 16px — body, inputs, message bodies. Do not shrink. */
  --t-lg: 1.125rem;   /* 18px — card titles */
  --t-xl: 1.375rem;   /* 22px — page title */

  /* --- space -----------------------------------------------------------
   * The old stylesheet had no scale, so padding was picked per template:
   * .7rem, .8rem, .85rem, .9rem and 1rem all appeared for the same job. */
  --s1: .25rem; --s2: .5rem; --s3: .75rem;
  --s4: 1rem;   --s5: 1.5rem; --s6: 2rem;

  /* --- shape ----------------------------------------------------------- */
  --r-sm: 8px; --r-md: 12px; --r-lg: 16px; --r-full: 999px;
  --radius: var(--r-lg);          /* kept: templates still name it */

  /* --- depth and motion ------------------------------------------------ */
  --e1: 0 1px 2px rgb(0 0 0 / .06);
  --e2: 0 8px 24px rgb(0 0 0 / .12);
  --dur-fast: 120ms; --dur: 200ms;
  --ease: cubic-bezier(.2, .7, .3, 1);

  /* --- chrome ---------------------------------------------------------- */
  --page-max: 34rem;
  --shell-max: 42.5rem;
  --tap: 44px;                    /* the smallest thing a thumb should hit */
  --tabbar-h: 3.25rem;
  --rail-w: 5rem; --drawer-w: 16.5rem;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #14161a; --surface: #1d2026; --surface-2: #262a31;
    --text: #eceef1; --muted: #a2abb5; --line: #2c313a;
    --ok-bg: #14301d; --ok-fg: #7fdc9b;
    --warn-bg: #33260f; --warn-fg: #f0c07a;
    --err-bg: #3a1a1a; --err-fg: #f3a3a0;
    /* Lifted, not inverted: the same six hues need more light to carry on a
       dark ground, and a white monogram has to stay legible on each. */
    --team-1: #3aa89d; --team-2: #9a7cf0; --team-3: #c96bad;
    --team-4: #4fa86a; --team-5: #7d8dc0; --team-6: #58a6c4;
    --e1: 0 1px 2px rgb(0 0 0 / .4);
    --e2: 0 8px 24px rgb(0 0 0 / .5);
  }
}

/* ====================================================================== */
/* Base                                                                    */
/* ====================================================================== */

* { box-sizing: border-box; }

/* The app draws its own pressed states (:active on buttons and rows); the
   grey flash mobile browsers paint on top of them reads as web, not app. */
* { -webkit-tap-highlight-color: transparent; }

/* html is the scrolling element, so the guard below must apply here too.
   It also carries the page background: only `body` had it, so the blank
   frame between two documents — and the canvas showing through a
   view-transition crossfade at its midpoint — painted the browser's
   default white. Against the grey it read as a flash on every navigation
   between chats; against dark mode it was a flashbulb. */
html { overflow-x: clip; background: var(--bg); }

body {
  margin: 0;
  padding: 0 var(--s4) var(--s6);
  font: var(--t-base)/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
        Helvetica, Arial, sans-serif;
  background: var(--bg);
  color: var(--text);
  -webkit-text-size-adjust: 100%;
  /* Wide content lives in its own .scroll-x box, but a sticky child inside one
     still counts toward the document's scroll width and gives the whole page a
     horizontal scrollbar. `clip` suppresses that without creating a scroll
     container, so position:sticky keeps working. */
  overflow-x: clip;
}

/* There was no link colour at all on the public side, so every link on every
   parent page rendered in the browser's default blue — next to the club's own
   accent, which is what the buttons use. */
a { color: var(--accent); }

/* One visible focus ring for everything, rather than the single rule .choices
   used to carry. Keyboard and switch-control users get nothing otherwise. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Every navigation rides the View Transitions API where the browser has
   one. Two kinds, one look. Boosted swaps go through htmx's
   startViewTransition (the htmx-config meta in base.html) — including into
   and out of a conversation now that boost_guard.js carries the body class
   across. The remaining REAL navigations — an errand link followed from an
   email, boost_guard's no-body-class bail — get the same short crossfade
   from the cross-document rule below instead of a white flash. Browsers
   without the API simply swap or load, and reduced motion opts out of the
   UA transition explicitly because the global rule above cannot reach it. */
@view-transition { navigation: auto; }
@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
  ::view-transition-old(root),
  ::view-transition-new(root) { animation: none !important; }
}
::view-transition-old(root),
::view-transition-new(root) { animation-duration: .15s; }
/* The overlay the two snapshots fade on. Page-coloured so whatever shows
   through mid-fade is the page, not white. */
::view-transition { background: var(--bg); }

/* ====================================================================== */
/* Layout                                                                  */
/* ====================================================================== */

.wrap { max-width: var(--page-max); margin: 0 auto; }

/* --- The app shell ------------------------------------------------------
 * Three bands, and only the middle one scrolls: who you are talking to stays
 * put and the box you type in stays under your thumb. This lived inside
 * conversation.html, which meant it was the one native-feeling screen in the
 * app and no other page could reach it.
 *
 * Every value here is load-bearing:
 *  - `dvh` with `vh` as the fallback, because a phone's address bar comes and
 *    goes; with `vh` the bottom band sits under it, or a gap opens beneath the
 *    keyboard.
 *  - `padding: 0` on the body, because the page padding above includes a deep
 *    bottom margin meant for a footer the shell does not have.
 *  - `env(safe-area-inset-*)` on the outer bands; base.html sets
 *    `viewport-fit=cover`, so without these the bottom band sits under the iOS
 *    home indicator and the top one behind the notch in landscape.
 */
body.shell {
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
  padding: 0;
}
body.shell .wrap {
  height: 100%;
  /* Full width, unlike a document page, because the bands and the wallpaper
     behind them are chrome: they should reach the edges of whatever room the
     sidebar leaves. The *content* is still held to --shell-max, by the gutter
     below — so a conversation stays a readable column on a monitor without
     the club's texture stopping in mid-air two thirds of the way across. */
  max-width: none;
  /* One value, used by all three bands. `100%` here resolves against this
     element, which is the container, so each band pads itself out to centre a
     --shell-max column while its own background and border still span the
     full width. */
  --gutter: max(var(--s3), (100% - var(--shell-max)) / 2);
  margin: 0 auto;
  padding: 0;
  display: flex;
  flex-direction: column;
  min-height: 0;
  position: relative;
  isolation: isolate;
}
/* The club's mark behind a conversation, the way a chat app has wallpaper.
   On the shell rather than on the scroller, so it stays put while the thread
   moves over it — a texture that scrolls with the messages reads as part of
   them. The bands above and below carry their own --bg and cover it, and the
   bubbles sit on --surface, so it is never behind anybody's words. */
body.shell .wrap::before {
  content: ""; position: absolute; inset: 0; z-index: -1;
  background: url("/branding/texture.svg") repeat;
  background-size: 150px 150px;
  opacity: .03;
}
@media (prefers-color-scheme: dark) {
  body.shell .wrap::before { opacity: .045; }
}

.band-top {
  flex: 0 0 auto;
  padding: var(--s2) var(--s3);
  padding-left: max(var(--gutter, var(--s3)), env(safe-area-inset-left));
  padding-right: max(var(--gutter, var(--s3)), env(safe-area-inset-right));
  padding-top: max(var(--s2), env(safe-area-inset-top));
  border-bottom: 1px solid var(--line);
  background: var(--bg);
}

/* The only band that scrolls. `contain` stops a flick at the top turning into
   a pull-to-refresh of the whole page. */
.band-scroll {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  /* The browser corrects scrollTop by itself when content is inserted above
     the viewport, and `chat_older.js` corrects it too -- so loading earlier
     messages moved the reader by twice the height of what arrived, which is a
     long way when it is thirty messages. One correction, ours, because ours
     is the one that also knows about the prepend it just did. */
  overflow-anchor: none;
  padding: var(--s3);
  padding-left: max(var(--gutter, var(--s3)), env(safe-area-inset-left));
  padding-right: max(var(--gutter, var(--s3)), env(safe-area-inset-right));
}

/* The composer. `flex: 0 0 auto` means it takes what it needs — and what it
   needs is not bounded, because a compose form can grow: the poll block with
   twenty options is 1405px of band. In a body that is `height: 100dvh;
   overflow: hidden`, that does not scroll, it is simply *gone*. Measured on
   2026-08-02 in an 844px viewport: "Post the poll" sat 630px below the bottom
   of the screen with no way to reach it, so the poll the twenty-option cap
   exists for could not be posted at all, and the conversation above was
   squeezed to 12px.

   So the band is capped and scrolls itself. This is the backstop rather than
   the fix — the poll form keeps its own actions in view (see
   conversation.html) — but it is what makes "the controls are cut off"
   structurally impossible for the photo block and for whatever is added
   beside them next. 75% leaves a quarter of the screen showing the
   conversation, which is what says the composer is part of a page.

   `vh` first, `dvh` second: the same pair `body.shell` uses, because a
   browser without dvh must still get a cap. */
.band-bottom {
  flex: 0 0 auto;
  max-height: 75vh;
  max-height: 75dvh;
  overflow-y: auto;
  /* A flick that runs out of composer must not start scrolling the thread
     behind it. */
  overscroll-behavior: contain;
  padding: var(--s2) var(--s3);
  padding-left: max(var(--gutter, var(--s3)), env(safe-area-inset-left));
  padding-right: max(var(--gutter, var(--s3)), env(safe-area-inset-right));
  padding-bottom: max(var(--s2), env(safe-area-inset-bottom));
  border-top: 1px solid var(--line);
  background: var(--bg);
}

/* ====================================================================== */
/* Typography                                                              */
/* ====================================================================== */

header.page:not(.appbar) { padding: var(--s5) 0 var(--s2); }
h1 { font-size: var(--t-xl); line-height: 1.2; margin: 0 0 var(--s1); }
h2 { font-size: var(--t-lg); margin: var(--s5) 0 var(--s2); }
.sub { color: var(--muted); font-size: var(--t-md); margin: 0; }
.muted { color: var(--muted); font-size: var(--t-md); }

/* Metadata and captions — a byline, a timestamp, the line under a title that
   says where something came from. `--t-sm` and `--muted`, which is what this
   class has meant since the style guide was written (`public/style.html`,
   under Type: *Meta and captions — --t-sm*).
 *
 * It only ever existed as `.list .meta`, so the rule held inside a list row
 * and nowhere else — while `.meta` is on 38 elements across fourteen
 * templates. A post's byline and its "Sent to people invited to Presentation
 * Day" line therefore rendered at body size in body colour, immediately
 * above the body, and read as the post's own first two lines (reported
 * 2026-08-11). The same failure this file records twice already: a component
 * styled only where it was first used. */
.meta { font-size: var(--t-sm); color: var(--muted); }

/* A section label rather than a heading: small, upper, quiet, with room for a
   count beside it. Three templates had their own copy of this. */
h2.section, .section-head {
  font-size: var(--t-md);
  text-transform: uppercase;
  letter-spacing: .05em;
  color: var(--muted);
  margin: var(--s5) 0 var(--s2);
  display: flex;
  align-items: center;
  gap: var(--s2);
}
.section-head .count, h2.section .count {
  background: var(--brand); color: #fff; border-radius: var(--r-full);
  font-size: var(--t-xs); padding: .05rem .45rem; letter-spacing: 0;
}

/* Preformatted prose typed by a human — kept because a coach's note has line
   breaks in it. Was redefined in three templates. */
.pre { white-space: pre-wrap; }

/* `summary` renders tiny and with a default marker otherwise; five templates
   carried an identical override. */
summary { cursor: pointer; font-size: var(--t-base); }

/* --- The app bar --------------------------------------------------------
 * One bar, the same shape on every screen inside the app: an optional Up
 * arrow at the leading edge, then the title.
 *
 * It is sticky rather than fixed. Fixed would need the page to reserve its
 * height, and every screen would have to agree on that number; sticky needs
 * nothing from the page and cannot get out of step with it.
 *
 * Both platforms' guidelines want the same three things and this satisfies
 * both: the arrow at the leading edge, a 44px/48dp tap target, and the title
 * beside it rather than under it. Where they differ is centring -- iOS centres
 * a title, Material leads it -- and a leading title is the one that survives
 * "Under-11 Lions vs Redmond Rovers" on a 375px screen, so that is what this
 * does on both. */
.appbar, .chat-head {
  display: flex; gap: var(--s2); align-items: center;
  min-height: var(--tap);
  background: var(--bg);
  border-bottom: 1px solid var(--line);
}
/* Only the document form needs to be told to stay at the top and to bleed
   past the page gutter. A conversation's header is already the top band of a
   shell that does not scroll -- giving it these would pull it out of its own
   band and leave a hairline of messages sliding past above it. */
.appbar {
  position: sticky; top: 0; z-index: 20;
  margin: 0 calc(var(--s4) * -1) var(--s3);
  padding: var(--s2) var(--s4);
}
/* The arrow is a 44px target with a 24px glyph inside it -- the smallest thing
   both platforms will accept, and the reason it is padding rather than a
   bigger icon. */
.appbar .back, .chat-head .back {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--tap); height: var(--tap);
  margin-left: calc(var(--s3) * -1);   /* optically flush with the gutter */
  text-decoration: none; color: inherit; flex: 0 0 auto;
  border-radius: var(--r-full);
}
.appbar .back svg, .chat-head .back svg { width: 1.5rem; height: 1.5rem; }
.appbar .back:active, .chat-head .back:active { background: var(--surface-2); }
/* Me, since it moved out of the tab bar: the gear-side slot, pushed to the
   bar's far edge. The same 44px target discipline as the back arrow. */
.appbar-me {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--tap); height: var(--tap);
  margin-left: auto; margin-right: calc(var(--s3) * -1);
  border-radius: var(--r-full);
  text-decoration: none; color: var(--muted); flex: 0 0 auto;
}
.appbar-me svg { width: 1.4rem; height: 1.4rem; }
.appbar-me:active { background: var(--surface-2); }
/* The door to this page's twin, left of Me. Sized and coloured exactly as Me
   is — the two sit side by side, and a pair of 44px targets that disagreed
   about weight would read as one control and one decoration. `.chat-head-act`
   is the same thing on the conversation's own header; they are kept apart
   because that header is bespoke, and identical because the two bars are a
   mirror. */
.appbar-act {
  position: relative;
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--tap); height: var(--tap);
  border-radius: var(--r-full);
  text-decoration: none; color: var(--muted); flex: 0 0 auto;
}
.appbar-act svg { width: 1.4rem; height: 1.4rem; }
.appbar-act:active { background: var(--surface-2); }
/* The unread count, in the tab bar's shape because it is the same number:
   `nav_badges.js` fills both from one breakdown, and two spellings of one
   count is how they come to disagree. Right-hand corner rather than the tab
   bar's centre-top, because this glyph has a neighbour 44px to its right. */
.appbar-badge {
  position: absolute; top: 2px; right: 0;
  min-width: 1.05rem; padding: 0 .2rem;
  background: var(--brand); color: #fff; border-radius: var(--r-full);
  font-size: .625rem; line-height: 1.05rem; text-align: center;
  font-weight: 700;
}
/* `[hidden]` is a UA rule and loses to any author `display` — the trap
   `.chat-count` records. This sets none, so it is safe; the comment is here
   so the next person to add one does not learn it the hard way. */
.appbar-badge[hidden] { display: none; }
/* The role chip beside a role surface's title — a label, not a theme. */
.role-pill {
  vertical-align: middle; margin-left: var(--s1);
  background: var(--accent); color: #fff; border: 0;
}
.appbar-title { min-width: 0; flex: 1; }
.appbar h1, .chat-head h1 {
  margin: 0; font-size: var(--t-lg); line-height: 1.2;
  /* One line. A two-line title pushes the whole page down on the screens
     where space is tightest, and the full text is always in the content. */
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.appbar .sub, .chat-head .sub {
  margin: 0; font-size: var(--t-sm); color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* ====================================================================== */
/* Components                                                              */
/* ====================================================================== */

.card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: var(--s3);
  margin: 0 0 var(--s2);
}
.card h3 { margin: 0 0 var(--s1); font-size: var(--t-lg); }

/* --- A list of rows -----------------------------------------------------
 * The single commonest thing in this app: a stack of bordered rows you can
 * tap. It existed six times under six names — ul.news, ul.week, ul.tasks,
 * ul.rooms, ul.mine and ul.chats — with the same border, radius, background
 * and gap written out each time. */
.list { list-style: none; margin: 0; padding: 0; }
.list > li {
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface);
  margin-bottom: var(--s2);
}
/* The whole row is the tap target when it is a link. */
.list > li > a {
  display: block; padding: var(--s3); text-decoration: none; color: inherit;
}
.list > li.pad { padding: var(--s3); }
/* Something needing attention: unread, unanswered, overdue. */
.list > li.flagged { border-left: 3px solid var(--brand); }

/* --- A conversation row -------------------------------------------------
 * Title and preview left, time and count right. The preview is one line,
 * ellipsised — a paste should cost the paster their ending, not the list its
 * shape.
 *
 * Here rather than in a page's own <style> because `_chats_list.html` renders
 * on two surfaces now: the chats list, and the groups and rooms under the
 * teams. A component on a second surface belongs in the design system —
 * left local, the rooms on /teams would have arrived unstyled, which is
 * exactly the class of thing that only shows up when somebody looks. */
/* .list > li > a sets display:block at (0,1,2); match it or lose. */
.list > li > a.chat-row { display: flex; gap: var(--s2); align-items: flex-start; }
.chat-main { flex: 1; min-width: 0; }
.chat-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-who { color: var(--text); font-weight: 600; }
.chat-cam {
  width: .95em; height: .95em; vertical-align: -.12em; margin-right: .15rem;
}
.chat-side {
  flex: none; display: flex; flex-direction: column; align-items: flex-end;
  gap: .3rem; padding-top: .1rem;
}
.chat-when { font-size: var(--t-xs); color: var(--muted); }
.chat-when.is-new { color: var(--brand); font-weight: 700; }
.chat-count {
  min-width: 1.25rem; height: 1.25rem; padding: 0 .3rem;
  border-radius: var(--r-full); background: var(--brand); color: #fff;
  font-size: var(--t-xs); font-weight: 700;
  display: inline-flex; align-items: center; justify-content: center;
}
/* These badges are now in the page at zero, `hidden`, so `nav_badges.js` can
   fill one while the page sits open. `[hidden]` is a UA rule and loses to any
   author `display`, so the line above would otherwise render an empty brand
   pill beside every conversation. `.tab-badge` never needed this because it
   sets no display of its own -- which is exactly why it is easy to miss. */
.chat-count[hidden] { display: none; }
/* The same edge on an event row the club is still waiting on.
 *
 * Not a new convention -- the unread announcement above it already means
 * exactly this, and having two ways to say "waiting on you" on one page is
 * how a reader learns neither. It sits outside the kind-spine, so the spine
 * keeps saying which team and this keeps saying what needs doing.
 *
 * **On the card, not on the list item.** An announcement's list item IS its
 * card — border, radius, background — so an edge on it is part of the card and
 * takes the corner. An event row's list item is a bare margin wrapper and the
 * card is .ev-open inside it, so the same declaration one level out drew a
 * square bar floating beside a rounded card. One convention, and it looked
 * like an afterthought on one of the two because structurally it was one. */
li.ev.flagged .ev-open { border-left: 3px solid var(--brand); }
/* Size and colour come from the base `.meta` above now; what is left here is
   what a *row's* meta line needs and a page's does not. */
.list .meta { display: block; margin-top: .15rem; }
/* Whose row this is — a child's name. Brand-coloured because in a family with
   three children it is the thing being scanned for. */
.list .whose { display: block; margin-top: .1rem; font-size: var(--t-sm);
               font-weight: 600; color: var(--brand); }

/* --- Badges and pills ---------------------------------------------------
 * `.tag` meant four different things across four templates: a checkbox chip,
 * an outline pill, a Home/Away marker and a brand-filled role badge. */
.pill, .tag {
  display: inline-block; padding: .05rem .4rem;
  border: 1px solid var(--line); border-radius: var(--r-full);
  font-size: var(--t-xs); color: var(--muted); vertical-align: .1em;
}
.pill--brand { background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 700; }
.pill--ok { background: var(--ok-bg); border-color: var(--ok-fg); color: var(--ok-fg); }
.pill--warn { background: var(--warn-bg); border-color: var(--warn-fg); color: var(--warn-fg); }
.pill--err { background: var(--err-bg); border-color: var(--err-fg); color: var(--err-fg); }

/* --- A tickable chip ----------------------------------------------------
 * A checkbox that looks like a chip: buddy_preferences and cards both had one,
 * differing only in font size. */
.chip {
  display: flex; align-items: center; gap: var(--s2);
  margin: 0; font-weight: 500; font-size: var(--t-md);
  padding: var(--s2) var(--s2); border: 1px solid var(--line);
  border-radius: var(--r-sm); background: var(--surface); cursor: pointer;
}
.chip input { width: 1.2rem; height: 1.2rem; flex: 0 0 auto; }
.chip:has(input:checked) {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 12%, var(--surface));
}
.chip-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: var(--s1); margin-top: var(--s2);
}

/* --- A done/not-done circle --------------------------------------------- */
.tick {
  flex: none; width: 1.75rem; height: 1.75rem; border-radius: var(--r-full);
  border: 1px solid var(--line); display: flex; align-items: center;
  justify-content: center; font-weight: 700; color: var(--ok-fg);
}
.is-done .tick { border-color: var(--ok-fg); background: var(--ok-bg); }

/* --- The task list ------------------------------------------------------
 * What the club still needs from a family. home.html and tasks.html each
 * carried an identical thirty-line copy of this, so changing the component
 * meant editing two files and noticing you had to. */
/* **A task is a line that opens where it stands** (2026-08-06). The row's
   flex layout moved onto the summary, so the closed state is one line and the
   open state puts the card underneath it rather than beside it.

   Measured before: two thirteen-option polls were 1,216px of a 2,510px page,
   so a family with three things to do got one screen per thing and no way to
   see what the three were. */
.tasks > li.task { display: block; }
/* **Keyed on the fold, not on the list it is in.** Scoped to
   `.tasks > li.task` first, which meant the same markup on the Club tab
   arrived with no layout at all and drew a 300px chevron — the third time
   this repo has paid for "markup copied to a second surface arrives naked".
   A component's own class carries its own rules. */
.task-fold > summary {
  display: flex; gap: var(--s3); align-items: center;
  list-style: none; cursor: pointer;
  /* The whole line is the target, and it has to clear the 44px both
     platforms ask for — a chevron alone is a 16px tap on a phone. */
  min-height: var(--tap);
}
.task-fold > summary::-webkit-details-marker { display: none; }
.task-line { flex: 1; min-width: 0; }
/* What it wants, in the fewest words that let somebody decide whether to open
   it. Under the label rather than beside it: a title and a clause side by
   side wrap unpredictably at 390px, and the wrap point moves with the length
   of somebody's poll question. */
.task-say { display: block; font-size: var(--t-sm); color: var(--muted); }
.task-chev {
  flex: none; width: 1.15rem; height: 1.15rem; color: var(--muted);
  transition: transform var(--dur-fast) var(--ease);
}
/* An inline SVG with no intrinsic size falls back to 300x150. Sized here
   rather than trusted to a reset, because that is what drew the giant
   chevron on the Club tab. */
.task-chev svg { display: block; width: 100%; height: 100%; }
.task-fold[open] > summary .task-chev { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) { .task-chev { transition: none; } }
/* Indented to the label, so an open card reads as belonging to the row it
   came out of rather than as a new one. */
.task-fold > .body { margin: var(--s2) 0 0 calc(1.75rem + var(--s3)); }

/* An ask filed under the thing it is about — a question on a notice, or on
   an occasion. Nested one level, so it reads as belonging to the row above
   rather than as a sibling of it.

   **The indent is this list's own, never the host's.** It had none, and got
   away with it on an occasion card because that `<li>` happened to carry
   `.pad`; a notice's `<li>` does not — its `<a>` pads itself and the asks
   are the anchor's *sibling* — so the same component sat 15px in under one
   host and 1px in under the other. Measured, after somebody noticed one of
   them looked wrong. A component that takes its shape from whatever it is
   dropped into is one that looks different everywhere it goes, which is the
   third time this app has paid for it (`admin.css`, the fold's chevron). */
.list.asks { margin: var(--s2) var(--s3) var(--s3); }
.list.asks > li.ask-row {
  padding: 0 var(--s3);
  background: var(--surface-2); border-color: transparent;
}
.list.asks > li.ask-row.flagged { border-left-color: var(--brand); }

/* The same ask, **expanded on the page that hosts it** — a post, an
   occasion. `.list.asks` above is its collapsed twin, one line and a chevron
   in a list; this is what that opens into, and the two wear the same raised
   surface on purpose, so a question is recognisable as one thing in both
   places.

   **The heading goes inside the block.** `post-ask` was a marker class with
   no treatment at all: a post carrying an occasion's question and a helper
   sheet rendered them as two bare blocks separated by nothing but a small
   muted label, so they ran together into one wall of controls with a caption
   in the middle — and the rows *inside* the helper sheet, which do have
   borders, read as better contained than the asks holding them. Reported on
   a post with two of them (2026-08-11). Binding a question to what answers
   it is the fix; the surface is what makes the binding visible.

   `--r-md` rather than the card's `--r-lg`: a box inside a box takes the
   smaller radius, or the corners fight. */
.ask-block {
  background: var(--surface-2);
  border-radius: var(--r-md);
  padding: var(--s3) var(--s4) var(--s4);
  margin: var(--s4) 0;
}
.ask-block > h2.section { margin-top: 0; }
/* `.seg-btn` draws its edge in `--line`, a fixed colour tuned against
   `--surface`; on the raised surface here it all but vanishes and the three
   answers lose their shape. This is the failure the poll card already
   records — currentColor and `color-mix`, never a fixed colour, for anything
   that sits on more than one background — and 22% is `.helpers-job`'s own
   value, so the two controls inside one block agree.

   Scoped rather than changed at source: the answer buttons are on five other
   surfaces, all of them `--surface`, and re-drawing the app's answer control
   everywhere is not a fix to a post's layout. If a third raised host ever
   appears, that is the moment to move it. */
.ask-block .seg-btn {
  border-color: color-mix(in srgb, currentColor 22%, transparent);
}

/* --- Authored markup, wherever somebody wrote some ----------------------
 * The rendered half of `utils/markdown_lite.py` — a closed set of tags
 * (p, br, strong, em, ul, li, h3, h4, a) and nothing an author types can add
 * to it. Styled rather than left to the browser's defaults, which set an h3
 * larger than the title above it.
 *
 * This lived in `public/post.html`'s own `<style>` while three templates used
 * the class, so a club event's description and the admin's post preview were
 * being handed the markup with none of the styling — a heading rendering
 * bigger than the page title on two surfaces nobody had looked at. The
 * house rule this file's header states: a component on a second surface
 * belongs in the design system. It is on a fourth now (a club document). */
.post-body h3 { font-size: var(--t-md); margin: var(--s4) 0 var(--s1); }
.post-body h4 { font-size: var(--t-base); margin: var(--s3) 0 var(--s1); }
.post-body p { margin: 0 0 var(--s3); }
.post-body ul { margin: 0 0 var(--s3); padding-left: var(--s5); }
.post-body li { margin: .15rem 0; }
.post-body a { color: var(--accent); }
/* The last block sets the page's own bottom margin, and a trailing gap under
   the final paragraph reads as something missing below it. */
.post-body > :last-child { margin-bottom: 0; }

/* A picture in the words (`img01`, fixed in `img02`).
 *
 * **`max-width` is the whole reason this block exists**, and its absence was a
 * live bug rather than a rough edge: `markdown_lite._figure` writes the
 * intrinsic `width`/`height` so the page does not reflow as each photograph
 * lands, and *nothing in either stylesheet constrained an image*. Measured in
 * a 390px container: the picture laid out at 1600x1067 CSS px and scrolled
 * the content to 1640px. One photograph pushed the whole family page
 * sideways on every phone.
 *
 * `height: auto` has to come with it, or the intrinsic `height` attribute
 * survives the width being clamped and the picture is squashed — which is the
 * usual way this fix is half-made.
 *
 * The margin belongs to the figure rather than the image so a caption sits
 * against its own photograph, and `figure`'s UA default indent is cleared
 * because the reading column is already the indent. */
.post-figure { margin: 0 0 var(--s3); }
.post-figure img {
  display: block; max-width: 100%; height: auto; border-radius: var(--r-md);
}
.post-figure figcaption {
  margin-top: var(--s1); font-size: var(--t-sm); color: var(--muted);
}

/* --- An occasion, on the Club tab's What's on --------------------------
 * It was a bold title with the answer folded under it, so **when and where**
 * — the two facts the list exists to give — were behind a tap.
 *
 * The date is a **tile in the slot the kind disc holds everywhere else**,
 * which is the kind rule followed rather than broken: a mark earns that slot
 * by distinguishing, and on a list where every row is an occasion a kind mark
 * distinguishes nothing while the date distinguishes every row. Read as a
 * tile it is read; read as prose it is parsed.
 *
 * Ink on quiet, never brand — brand is already spent on this row's left edge
 * when the family owes an answer, and a second brand thing inside it would be
 * two ways of saying one thing. Same reasoning as a game's disc against
 * training's, and it survives greyscale for the same reason. */
/* Selected as `.list > li > a.occ-head`, not `.occ-head`: `.list > li > a` is
   two classes and two types, so a bare class loses to its `display: block`
   and the tile stacked above the title instead of standing beside it. Same
   trap the divider one page down records, and `.chat-row` two hundred lines
   up already answers it the same way. */
.list > li > a.occ-head {
  display: flex; align-items: flex-start; gap: var(--s3);
  text-decoration: none; color: inherit;
}
.occ-date {
  flex: 0 0 auto; display: inline-flex; flex-direction: column;
  align-items: center; justify-content: center;
  width: 2.75rem; height: 2.75rem; border-radius: var(--r-md);
  background: var(--text); color: var(--surface);
  line-height: 1;
}
.occ-month {
  font-size: .625rem; font-weight: 700; letter-spacing: .06em;
  opacity: .75;
}
.occ-day { font-size: var(--t-lg); font-weight: 700; margin-top: .1rem; }
/* No date yet — "presentation day, date to be confirmed" is a real thing a
   club posts, and the one case where this slot has to say "an occasion"
   because it cannot say when. Quiet, because an unknown date is not news. */
.occ-date--none { background: var(--surface-2); color: var(--muted); }
.occ-date--none svg { width: 1.35rem; height: 1.35rem; }
.occ-body { flex: 1; min-width: 0; }
.occ-title { display: block; font-size: var(--t-md); }
.occ-meta {
  display: block; margin-top: .1rem;
  font-size: var(--t-sm); color: var(--muted);
}
/* Called off: struck through and drained, the same treatment a cancelled
   fixture wears, because "where did it go" is a worse question than "why is
   this still here" — and a family who answered has to see it was called
   off. The answers below stay legible on purpose. */
li.occ--off .occ-title { text-decoration: line-through; }
li.occ--off .occ-date { background: var(--surface-2); color: var(--muted); }
.occ-off {
  display: inline-block; margin-top: .15rem;
  padding: .05rem .35rem; border-radius: var(--r-sm);
  background: var(--err-bg); color: var(--err-fg);
  font-size: var(--t-xs); font-weight: 700;
  text-transform: uppercase; letter-spacing: .03em;
}
.tasks > li.is-done { border-color: var(--ok-fg); }
.tasks > li.info .tick { color: var(--muted); }
.tasks .body { flex: 1; min-width: 0; }
.tasks .state { margin: .3rem 0 0; color: var(--ok-fg); font-size: var(--t-sm); }
.tasks .hint { margin: .3rem 0 0; }
.tasks .act { flex: none; align-self: center; }
.tasks .act form { margin: 0; }
@media (max-width: 560px) {
  .tasks .act { width: 100%; }
  .tasks .act .btn, .tasks .act button { width: 100%; }
}

/* --- Segmented answers --------------------------------------------------
 * A compact row of mutually exclusive answers — the RSVP buttons. Generalised
 * from `button.on-btn`, which lived in conversation.html and was therefore
 * unavailable to the front page that most needs it. */
.seg { display: flex; gap: var(--s1); flex-wrap: wrap; }
.seg form { margin: 0; }
.seg-btn, button.seg-btn {
  width: auto; margin: 0; padding: var(--s1) var(--s3);
  font-size: var(--t-md); font-weight: 500; min-height: var(--tap);
  border: 1px solid var(--line); border-radius: var(--r-full);
  background: transparent; color: inherit; cursor: pointer;
}
.seg-btn.chosen, button.seg-btn.chosen {
  border-color: var(--accent); background: var(--accent); color: #fff;
}
/* An RSVP answered here wears the colour that answer wears on the family
   page. Accent for all three said "selected" and nothing else, so Going and
   Can't looked identical once pressed — on the one control where *which* one
   is the entire content. The values are the three in models/rsvp.py and
   guessing them is how a colour silently stops appearing. */
.seg-btn--attending.chosen, button.seg-btn--attending.chosen {
  background: var(--ok-bg); color: var(--ok-fg); border-color: var(--ok-fg);
}
.seg-btn--not_attending.chosen, button.seg-btn--not_attending.chosen {
  background: var(--err-bg); color: var(--err-fg); border-color: var(--err-fg);
}
.seg-btn--maybe.chosen, button.seg-btn--maybe.chosen {
  background: var(--warn-bg); color: var(--warn-fg);
  border-color: var(--warn-fg);
}

/* --- Author avatar ------------------------------------------------------
 * Initials, not an image: nobody uploads a photo, and a letter in a coloured
 * circle is what makes a thread scannable without reading every name. */
.avatar {
  flex: 0 0 auto; width: 1.75rem; height: 1.75rem; border-radius: var(--r-full);
  display: inline-flex; align-items: center; justify-content: center;
  font-size: var(--t-xs); font-weight: 700; color: #fff;
  background: var(--muted);
}

/* --- The bottom tab bar -------------------------------------------------
 * Fixed rather than a band, because most parent pages are ordinary scrolling
 * documents. A conversation deliberately does not get one: it already has a
 * composer pinned to the bottom, and a second bar under that would take a line
 * of a phone screen permanently to offer what the back arrow already does.
 */
.tabbar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
  display: flex; align-items: stretch;
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  -webkit-backdrop-filter: saturate(180%) blur(12px);
  backdrop-filter: saturate(180%) blur(12px);
  border-top: 1px solid var(--line);
  /* The iOS home indicator sits on top of this otherwise; base.html sets
     viewport-fit=cover, which is what makes the inset non-zero. */
  padding-bottom: env(safe-area-inset-bottom);
}
.tab {
  flex: 1; display: flex; flex-direction: column; align-items: center;
  justify-content: center; gap: 2px;
  min-height: var(--tabbar-h); padding: var(--s1) 0 var(--s1);
  text-decoration: none; color: var(--muted);
  font-size: var(--t-xs); font-weight: 600;
  transition: color var(--dur-fast) var(--ease);
}
.tab.is-active { color: var(--accent); }
.tab-icon { position: relative; display: block; width: 24px; height: 24px; }
.tab-icon svg { width: 100%; height: 100%; display: block; }
.tab-label { line-height: 1; }
.tab-badge {
  position: absolute; top: -4px; left: 50%; margin-left: 2px;
  min-width: 1.05rem; padding: 0 .2rem;
  background: var(--brand); color: #fff; border-radius: var(--r-full);
  font-size: .625rem; line-height: 1.05rem; text-align: center;
  font-weight: 700;
}
/* The room the fixed bar occupies, so the last line of a page is never behind
   it. `env()` twice — once here and once on the bar — because the bar's own
   padding is inside its box. */
body.has-tabs {
  padding-bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom) + var(--s4));
}

/* A muted conversation. Sized to the text beside it and given the muted
   colour rather than the brand's: it is a state, not an alert. */
.muted-mark {
  display: inline-flex; align-items: center; flex: 0 0 auto;
  color: var(--muted);
}
.muted-mark svg { width: 1em; height: 1em; display: block; }
.list .muted-mark { margin-left: var(--s1); vertical-align: -.12em; }

/* --- The wide-screen sidebar --------------------------------------------
 * Three layouts, one set of markup, and the breakpoints are the ones both
 * platforms already teach people:
 *
 *   < 600px   bottom tab bar          (a phone; the thumb is at the bottom)
 *   600-839   navigation rail         (a small tablet; icons, no thread list)
 *   >= 840px  permanent drawer        (labels, and conversations under Chats)
 *
 * The sidebar is `fixed` and the body carries a matching `padding-left`, so
 * `.wrap` keeps centring itself inside whatever room is left and no page has
 * to know the sidebar exists. Doing it as a flex row instead would have meant
 * body.shell -- which is 100dvh with its own flex column -- needing a second
 * layout of its own.
 */
.sidenav { display: none; }

@media (min-width: 600px) {
  .sidenav {
    position: fixed; top: 0; left: 0; bottom: 0; z-index: 30;
    display: flex; flex-direction: column;
    width: var(--rail-w);
    padding: var(--s3) 0 calc(env(safe-area-inset-bottom) + var(--s3));
    background: var(--bg);
    border-right: 1px solid var(--line);
    overflow-y: auto;
    overscroll-behavior: contain;
  }
  body.has-side { padding-left: var(--rail-w); }
  /* The bottom bar and the sidebar are the same navigation in two forms, so
     exactly one of them is ever on screen. */
  .tabbar { display: none; }
  body.has-tabs { padding-bottom: var(--s4); }

  .sidenav-brand {
    display: flex; flex-direction: column; align-items: center; gap: var(--s1);
    padding: var(--s2); margin-bottom: var(--s3);
    text-decoration: none; color: var(--muted); font-size: var(--t-xs);
    font-weight: 700; text-align: center;
  }
  .sidenav-brand img, .sidenav-brand svg { width: 28px; height: 28px; }
  .sidenav-brand span { display: none; }

  .sidenav-list { list-style: none; margin: 0; padding: 0 var(--s2); }
  .sidenav-link {
    display: flex; flex-direction: column; align-items: center; gap: 2px;
    padding: var(--s2) 0; margin-bottom: var(--s1);
    border-radius: var(--r-md);
    text-decoration: none; color: var(--muted);
    font-size: var(--t-xs); font-weight: 600;
  }
  .sidenav-link.is-active { color: var(--accent); background: var(--surface-2); }
  .sidenav-icon { position: relative; display: block; width: 24px; height: 24px; }
  .sidenav-icon svg { width: 100%; height: 100%; display: block; }
  /* A navigation rail has no room for a list of threads, nor for the
     headings over them; the drawer turns them back on. */
  .sidenav-tree, .sidenav-subs, .sidenav-subhead { display: none; }
}

@media (min-width: 840px) {
  .sidenav { width: var(--drawer-w); padding-left: var(--s2); padding-right: var(--s2); }
  body.has-side { padding-left: var(--drawer-w); }

  .sidenav-brand {
    flex-direction: row; align-items: center; gap: var(--s2);
    font-size: var(--t-base); color: inherit; text-align: left;
    padding: var(--s2) var(--s3);
  }
  .sidenav-brand span { display: inline; }

  .sidenav-link {
    flex-direction: row; align-items: center; gap: var(--s3);
    padding: var(--s2) var(--s3);
    font-size: var(--t-md);
  }
  .sidenav-icon { flex: 0 0 24px; }

  /* The conversations hang off their heading along a faint rail, the same
     shape /admin uses to nest a group's pages under its group name.

     The heading sits *outside* the rail, flush with the tab labels above it,
     and only the conversations are indented — the heading names the branch,
     it is not on it. This is exactly how /admin reads: a group's summary at
     the base indent, its pages on the rail beneath. */
  .sidenav-tree { display: block; margin: var(--s1) 0 var(--s2); }
  .sidenav-subs {
    display: block; list-style: none;
    /* Aligned under the icon rather than under the text, so the rail
       continues the line the icons already make down the sidebar. */
    margin: 0 0 var(--s2) calc(var(--s3) + 11px);
    padding: 0 0 0 var(--s2);
    border-left: 1px solid var(--line);
  }
  /* Quieter than a heading: at this width these are dividers between three
     short lists, not titles over three pages. Turned back on together with
     the rail and the list — hiding one and not the others is how the rail's
     rule leaked into the drawer the first time. */
  .sidenav-subhead {
    display: block;
    margin: var(--s3) 0 var(--s1);
    /* Lined up with the icons above them, not with the conversations below:
       a heading names the branch, so it sits at the level of the tab it hangs
       off rather than on the rail with the things it labels. */
    padding-left: var(--s3);
    font-size: var(--t-xs); font-weight: 700; color: var(--muted);
    text-transform: uppercase; letter-spacing: .04em;
  }
  .sidenav-subhead:first-child { margin-top: 0; }
  .sidenav-sub {
    display: flex; align-items: center; gap: var(--s2);
    /* The rail carries the nesting now, so this only needs its own padding. */
    padding: var(--s1) var(--s2);
    border-radius: var(--r-md);
    text-decoration: none; color: var(--muted); font-size: var(--t-sm);
  }
  .sidenav-sub.is-active { color: var(--text); background: var(--surface-2); font-weight: 600; }
  .sidenav-sub-title {
    flex: 1; min-width: 0;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .sidenav-count {
    flex: 0 0 auto; min-width: 1.15rem; padding: 0 .3rem;
    background: var(--brand); color: #fff; border-radius: var(--r-full);
    font-size: var(--t-xs); line-height: 1.15rem; text-align: center;
    font-weight: 700;
  }
  /* A team row is two doors: the team itself, and its conversation. Two
     sibling anchors rather than one nested in the other, which is invalid
     and which browsers resolve by silently dropping one. */
  .sidenav-team { display: flex; align-items: center; gap: var(--s1); }
  .sidenav-team .sidenav-sub { flex: 1; min-width: 0; }
  .sidenav-chat {
    flex: 0 0 auto; display: inline-flex; align-items: center; gap: .2rem;
    padding: var(--s1) .35rem; border-radius: var(--r-md);
    color: var(--muted); text-decoration: none;
  }
  .sidenav-chat svg { width: .95rem; height: .95rem; }
  .sidenav-chat:hover, .sidenav-chat.is-active { color: var(--text); }
  /* The count keeps its own colour beside a muted glyph — it is the one
     thing on the row that is news. */
  .sidenav-chat .sidenav-count { margin-left: .1rem; }
}

/* The shell centres itself in whatever the sidebar leaves, exactly as a
   document does -- there is nothing shell-specific to say here, which is the
   point of doing this with padding rather than a flex row. */

/* --- What is on, and who is coming ---------------------------------------
 * One row for training and for a game, on the family page and inside a team's
 * conversation alike. They used to be two renderings of the same fact and
 * disagreed about all three things that matter on a phone: which kind of thing
 * it is, whether the detail fits, and how close a child's name is to their
 * answer.
 *
 * The row is a button. Everything anybody wants to do here is "change an
 * answer", so a separate affordance would only be a second thing to find.
 */
ul.events { list-style: none; margin: 0; padding: 0; }
li.ev { margin: 0 0 var(--s2); }

/* The row carries no padding of its own and clips its children, so the kind
   spine can reach the left, top and bottom edges and take the card's radius.
   `.ev-main` puts the padding back for everything else. Without the clip the
   spine's square corners poke out of the rounded card. */
/* One height for every row, and one x for every column.
 *
 * Both were measured wrong on 2026-08-02 and both had the same cause: the
 * spine was sized by its own contents, so it competed with the row's content
 * to decide the row.
 *
 * HEIGHT. The rotated word is what a row is as tall as, whenever the word is
 * long enough. "Training" is 56px of vertical type and "Game" is 32px, so a
 * training row stood 92px against a game's 81 — for no reason a reader could
 * ever infer, since the game was the row with MORE in it (three lines against
 * two). A list whose rhythm is set by the length of a word is a list that
 * looks broken. `--ev-row` is the floor, and it fits the longest kind word
 * with its glyph; a row with more in it than that may still grow, which is
 * legible because the extra height means extra content.
 *
 * WIDTH. `--ev-spine` is fixed for the same reason: the spine was content-
 * width, and a game's rail is 3px against a session's 1px, so the body of a
 * game started two pixels to the right of the body of a session. Two pixels
 * is invisible on one row and is exactly what makes a column of them look
 * unset. `box-sizing: border-box` is global, so the heavier rail eats into
 * the same 2rem instead of adding to it. */
.ev-open, .ev-open--off { --ev-row: 6rem; --ev-spine: 2rem; }
.ev-open {
  display: flex; align-items: stretch;
  width: 100%; margin: 0; padding: 0; overflow: hidden;
  min-height: var(--ev-row);
  background: var(--surface); color: var(--text);
  border: 1px solid var(--line); border-radius: var(--r-md);
  text-align: left; font: inherit; cursor: pointer;
}
.ev-open:active { background: var(--surface-2); }
.ev-main {
  flex: 1; min-width: 0;
  display: flex; align-items: center; gap: var(--s3);
  padding: var(--s2) var(--s3);
}

/* --- The kind, on the edge ------------------------------------------------
 * A rail rather than a filled block: a full-bleed slab of ink repeated eight
 * times down a Saturday stripes the list, and the weight that separates a
 * game from a session is already carried by 3px of border against 1px plus
 * the glyph. Brand is deliberately absent — it means "this needs you"
 * everywhere else, and a game already answered needs nothing.
 *
 * The word is rotated because kind is a CATEGORY: the thing that repeats
 * down a list, which is what vertical type is good at. The team name stays
 * horizontal in the body, because an identifier is read rather than
 * recognised. */
.ev-spine {
  /* Fixed, not content-width — see the note on --ev-spine above. */
  flex: 0 0 var(--ev-spine); align-self: stretch;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: .3rem;
  padding: .45rem .35rem;
  color: var(--muted);
  border-right: 1px solid var(--line);
  /* The word clips rather than widening the column, which is the same
     decision `.ev-spine-word`'s max-height makes vertically. */
  overflow: hidden;
}
.ev-spine-glyph { display: inline-flex; }
.ev-spine-glyph svg { display: block; width: 1rem; height: 1rem; }
/* vertical-rl + rotate(180deg) reads bottom-to-top. `sideways-lr` is the
   tidier property and Safari does not have it. Capped and clipped rather
   than shrunk: a longer kind word should lose its tail, not make every row
   in the list taller. */
.ev-spine-word {
  writing-mode: vertical-rl; transform: rotate(180deg);
  font-size: .5625rem; font-weight: 800; letter-spacing: .14em;
  text-transform: uppercase; white-space: nowrap;
  /* Sized so the spine can never be taller than `--ev-row`: 3.5rem of word
     plus the glyph, the gap and the padding comes to 91px inside a 96px row.
     The old 5.2rem was loose enough that "Training" (56px) set the height of
     every training row in the app — the bug this pair of numbers exists to
     make impossible. Change one and check the other. */
  max-height: 3.5rem; overflow: hidden;
}
/* A game's rail is heavier than a session's — 3px against 1px — and that
   weight is the whole distinction, along with the glyph.

   The COLOUR is currentColor, not `--text`. It was `--text`, which is
   near-black on a light ground and reads as a firm rail, and near-WHITE in
   dark mode where it read as a stray line through the card — worse once the
   spine gained a team tint behind it. Following the spine's own colour keeps
   the weight doing the work and makes the rail part of the thing it edges,
   in both themes and with or without a team colour. */
.ev--match .ev-spine {
  color: var(--text);
  border-right-width: 3px;
  /* Softened, and that is the fix rather than the colour choice.
     `currentColor` alone still resolves to `--text` on a spine with no team
     tint — the chat's what's-on panel — so the white line came back exactly
     where nobody was looking. At 45% it stays plainly heavier than the 1px
     `--line` a session gets, tints with the team where there is one, and can
     never render as a hard rule through the card. */
  border-right-color: color-mix(in srgb, currentColor 45%, transparent);
}
li.ev.is-off .ev-spine {
  color: var(--muted);
  border-right-width: 1px; border-right-color: var(--line);
}

/* Training and a game must not look alike: a game is the fixture a family
   organises the week around. Weight and glyph, not the brand colour -- brand
   means "this needs you" everywhere else (badges, unread, needs-an-answer),
   and a game the family has already answered needs nothing. A game is
   *important*, so it gets ink: a solid disc against training's quiet one,
   which also survives for anybody who cannot see colour.
   A third kind (a gala day, a club event) slots in beside these with its
   own `.ev--<kind>` pair; without one it inherits training's quiet look,
   and its tag word comes from the map at the top of _event_row.html. */
.ev-kind {
  flex: 0 0 auto; display: inline-flex; align-items: center;
  justify-content: center;
  width: 2rem; height: 2rem; border-radius: var(--r-full);
  background: var(--surface-2); color: var(--muted);
}
.ev-kind svg { width: 1.15rem; height: 1.15rem; }
/* A game's glyph is inverted wherever it appears — `.ev--match` rows on
   the family page, `.ev-kind--game` on the corner's rows — so the two
   pages agree about what a game looks like.

   An announcement takes the same ink for the same reason, and this is why
   the pattern was worth having: news and an announcement were told apart by
   a brand pill, which is the colour that means "this needs you" everywhere
   else — so on a list where every row was also unread, the kind and the
   unread rail were the same colour and neither read as anything. Ink is a
   second channel: it survives 9px type, a greyscale screenshot, and not
   being able to tell orange from grey. */
.ev--match .ev-kind, .ev-kind--game,
.ev-kind--notice { background: var(--text); color: var(--surface); }

/* The way back from an errand page that was arrived at from inside the
   app (public/_back_link.html). Quiet, above the content, tap-sized. */
.back-link { margin: 0 0 var(--s2); }
.back-link a {
  display: inline-flex; align-items: center; gap: .3rem;
  min-height: var(--tap); color: var(--muted); text-decoration: none;
  font-size: var(--t-sm); font-weight: 600;
}
.back-link a:hover { color: var(--text); }

.ev-body { flex: 1; min-width: 0; display: block; }
.ev-when {
  display: flex; align-items: baseline; gap: var(--s2); flex-wrap: wrap;
  font-size: var(--t-md);
}
.ev-time { color: var(--muted); }
.ev-tag {
  padding: .05rem .35rem; border-radius: var(--r-sm);
  background: var(--surface-2); color: var(--muted);
  font-size: var(--t-xs); font-weight: 700;
  text-transform: uppercase; letter-spacing: .03em;
}
.ev--match .ev-tag {
  background: var(--text); color: var(--surface);
}
.ev-tag--quiet, .ev--match .ev-tag--quiet {
  background: var(--surface-2); color: var(--muted);
}
/* A game that moved, and is this week. Loud on purpose and only ever briefly
   — the flag is dropped once the game is played or the change is three weeks
   old, so it cannot become wallpaper that nobody reads. Its own colour rather
   than the danger red a cancellation uses: this game IS happening, and
   confusing "moved" with "off" is the expensive mistake. */
.ev-tag--moved, .ev--match .ev-tag--moved {
  background: var(--warn-bg); color: var(--warn-fg);
}
/* One line. The old row let the title, the team and the ground compete for
   the same line and then cut whichever lost, which is how a parent ended up
   knowing there was a game but not where. */
.ev-what {
  display: block; margin-top: .1rem;
  font-size: var(--t-sm); color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The team leads line two in ink. On a family page it is the fastest way to
   tell whose row this is; on a team page there is no team name to show and
   the line simply starts with the opponent. */
.ev-team { color: var(--text); font-weight: 700; }

/* Line three: the ground, at the same x on every row, which is what makes a
   column of them scannable — the actual Saturday-morning question for a
   parent with three children. The field trails it in muted: you read the
   park to plan the morning and the field when you park the car. */
.ev-venue {
  display: flex; align-items: center; gap: .3rem;
  margin-top: .15rem; min-width: 0;
  font-size: var(--t-sm); font-weight: 600; color: var(--text);
}
.ev-venue svg { flex: 0 0 auto; width: .9em; height: .9em; color: var(--muted); }
.ev-venue-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ev-field { font-weight: 400; color: var(--muted); }

/* --- A two-state switch ---------------------------------------------------
 * Says what is true, not what tapping will do. "Mute"/"Unmute" is a button
 * that tells you the consequence and leaves you to infer the state by
 * inverting it, so a column of them reads as instructions rather than as
 * settings.
 *
 * Sized to its words. These sat as full-width buttons on a phone, which is
 * right for "Save" and wrong for a switch: filling the screen made a two-word
 * control look like it did more than it does.
 */
.switch {
  display: inline-flex; align-items: center; gap: var(--s1);
  width: auto; margin: 0; padding: var(--s1) var(--s3);
  min-height: 2.25rem;
  background: var(--surface-2); color: var(--muted);
  border: 1px solid var(--line); border-radius: var(--r-full);
  font: inherit; font-size: var(--t-sm); font-weight: 700;
  cursor: pointer; white-space: nowrap;
}
.switch::before {
  content: ""; width: .5rem; height: .5rem; border-radius: var(--r-full);
  background: currentColor; opacity: .55;
}
.switch.is-on {
  background: color-mix(in srgb, var(--ok-fg) 12%, transparent);
  color: var(--ok-fg); border-color: color-mix(in srgb, var(--ok-fg) 35%, transparent);
}
.switch.is-on::before { opacity: 1; }
.switch[disabled] { opacity: .6; }

/* --- Tabs within a page ---------------------------------------------------
 * The team page answers two different questions, and mixing them meant
 * scrolling past a fortnight of fixtures to find out who somebody's mum is.
 *
 * Links, not buttons: each tab is a URL, so it can be shared, bookmarked and
 * reached with the back button, and it works before any JavaScript has run.
 * The bottom bar is the app's navigation; this is navigation *within* one
 * screen, so it is a different shape on purpose.
 */
nav.tabs {
  display: flex; gap: var(--s1);
  margin: 0 0 var(--s4); padding: var(--s1);
  background: var(--surface-2); border-radius: var(--r-full);
}
.tab-link {
  flex: 1; display: inline-flex; align-items: center; justify-content: center;
  gap: var(--s2);
  padding: var(--s2) var(--s3); min-height: 2.25rem;
  border-radius: var(--r-full);
  text-decoration: none; color: var(--muted);
  font-size: var(--t-sm); font-weight: 600; text-align: center;
  /* A label never wraps: "Who's in it" split over two lines made three tabs
     three heights. The pill row can afford the width; a fourth tab cannot
     fit a phone anyway. */
  white-space: nowrap;
}
/* The coach's sheets ship every tab and show one (`sheet_tabs.js`). Written
   out rather than left to the UA's `[hidden]` rule, which loses to any author
   `display` — that mistake ships as "the tab did nothing", and this file has
   paid for it before on the admin composers. */
.tab-panel[hidden] { display: none; }

.tab-link.is-on {
  background: var(--surface); color: var(--text); box-shadow: var(--e1);
}
/* The cone, the ball, the calendar, two people — the tab shows the mark the
   rows beneath it wear, so a word and a glyph a reader has already learned
   agree. Sized under the label deliberately: it is a hint about what the
   word means, not a second thing to read. */
.tab-glyph { flex: none; display: inline-flex; }
.tab-glyph svg { width: .95rem; height: .95rem; }

/* A glyph on a button, at the same weight as the label beside it. Three of
   these on one row (the corner's Plan & arrange) is where it earns its
   keep — "Arrange training" and "Arrange games" are the same six letters
   until the last word. */
.iconed-btn { display: inline-flex; align-items: center; gap: var(--s2); }
.iconed-btn svg { flex: none; width: 1rem; height: 1rem; }
/* Its own rule rather than leaning on `.count`, which is scoped to headings
   and would have given this the colour and none of the shape. */
.tab-link .count {
  background: var(--line); color: var(--muted); border-radius: var(--r-full);
  font-size: var(--t-xs); padding: .05rem .4rem; font-weight: 700;
}
.tab-link.is-on .count { background: var(--surface-2); }

/* --- The second level, under a tab strip --------------------------------
 * Upcoming / Completed inside the corner's Training page: a view of the page,
 * not another page. It was `nav.tabs`, so two identical filled pill rows sat
 * stacked and a reader had to work out which one they were "in" — the classic
 * two-level-tabs failure. Text with a rule under the live one instead: the
 * hierarchy is legible at a glance and it groups with the list it belongs to.
 * Left-aligned rather than stretched, because these are a choice about the
 * list and not the page's own navigation. */
nav.subtabs {
  display: flex; gap: var(--s4); align-items: baseline;
  margin: 0 0 var(--s3); padding: 0 var(--s1);
  border-bottom: 1px solid var(--line);
}
.subtab-link {
  display: inline-flex; align-items: center; gap: var(--s1);
  padding: var(--s2) 0; min-height: var(--tap);
  text-decoration: none; color: var(--muted);
  font-size: var(--t-sm); font-weight: 600; white-space: nowrap;
  /* Sits ON the container's rule, so the live one replaces it rather than
     drawing a second line 1px below. */
  border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.subtab-link.is-on { color: var(--text); border-bottom-color: var(--accent); }
.subtab-link .count {
  background: var(--line); color: var(--muted); border-radius: var(--r-full);
  font-size: var(--t-xs); padding: .05rem .4rem; font-weight: 700;
}

/* The one action between a tab strip and the list it belongs to — "Add a
   session", "Add a game", "Drill deck". Four templates carry it now and three
   defined it themselves, one of them not at all (the coaches hub, which was
   relying on a bare `<p>` looking close enough). §11: on a second surface it
   moves here.

   Left-aligned, which is a change for a team's page: it was right-aligned to
   sit where a section heading used to float it, and under a left-aligned
   sub-tab row that read as an action belonging to nothing. */
.tab-act { margin: 0 0 var(--s3); }

/* The line between what is owed and what is merely shown, in a family's task
   list. Not a heading: it divides one list rather than starting a second, and
   a second `<h2>` here would read as another section of work.

   Same *type* as `h2.section` all the same — `--t-md`, uppercase, .05em,
   muted. It was `--t-xs`, which is the count-badge size and appears nowhere
   else on the page as a label, so "No answer needed" sat two steps smaller
   than the "Things to do" directly above it and read as a different class of
   thing rather than the same kind of label. What keeps it subordinate is that
   it carries no count, sits inside the list, and follows rows instead of
   opening a section — not a smaller font.

   Selected as `.list > li.task-divider`, not `.task-divider`: `.list > li` is
   a class plus a type, so a bare class loses to it and every one of the
   de-carding declarations below was silently discarded from the day they were
   written. The label had been rendering as a white rounded card with a border
   — indistinguishable from the task rows it separates, which is the exact
   opposite of what it is for. Specificity rather than `!important`: naming the
   rule being overridden is the thing a reader can check. */
/* `.post-divider` is the same label doing the same job one tab over — the
   line between what you have not read and what you have, on the Club tab's
   News page — so it shares the rule rather than copying it. Named separately
   because "Already read" is not a task state and a reader of the markup
   should not have to work out that it is. Both are listed in the selector
   for the specificity reason above: a bare class loses to `.list > li`. */
.list > li.task-divider, .list > li.post-divider {
  list-style: none; padding: var(--s3) 0 var(--s1);
  font-size: var(--t-md); font-weight: 700; text-transform: uppercase;
  letter-spacing: .05em; color: var(--muted);
  /* `.list li` is a card here — border, radius, surface — and a label wearing
     one reads as another task rather than as the line between two kinds. */
  background: none; border: 0; border-radius: 0; box-shadow: none; margin: 0;
}
.list > li.task-divider:first-child,
.list > li.post-divider:first-child { padding-top: 0; }
/* The held-back count under a capped or paged list — "Load more (7 older)",
   "2 earlier notices". It lived in `home.html`'s page-local `<style>` while
   being used on `/club` twice and in the home strip, so on the Club tab both
   links rendered as an unstyled paragraph: the third-surface rule
   (§ a component reaching a second surface moves to app.css), paid late. */
.more-news { margin: calc(-1 * var(--s1)) 0 var(--s4); font-size: var(--t-sm); }
/* The same badge, but saying "you owe this" rather than "there are this
   many". Brand, because that is what brand means here — and a tab count is
   otherwise the neutral grey above, which is right for "14 players" and
   wrong for "2 games you have not answered". Kept `.count` too, so the shape
   and position stay identical and only the meaning changes colour. */
.tab-link .count--owed, .tab-link.is-on .count--owed {
  background: var(--brand); color: #fff;
}

/* --- Answering, wherever the question is asked --------------------------
 * `.on-child` / `.on-name` lived in conversation.html until the control
 * reached its third surface: the chat's what's-on panel, the settled row
 * behind it, and now a row on the team's own schedule page. §11 — a
 * component on a second surface moves here. The buttons are `.seg`/`.seg-btn`
 * and always were. */
.on-child { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap;
            padding: .25rem 0; }
.on-name { flex: 1; font-weight: 600; min-width: 4rem; }

/* The block on a team's diary row. Set apart from the row above it, because
   the diary says what is on and this asks you something — and a question that
   looks like the paragraph above it does not get answered. Quiet until it is
   actually waiting on you: an answered row keeps the control (so it can be
   changed) and loses the emphasis. */
.team-ans { margin: var(--s2) 0 0; padding-top: var(--s2);
            border-top: 1px solid var(--line); }
.team-ans-ask { margin: 0 0 var(--s1); font-size: var(--t-sm);
                font-weight: 700; color: var(--brand); }

/* --- The week, above a conversation -------------------------------------
 * Folded away, because the thread is what people opened the app to read. Open
 * only while the club is still waiting on this family: it is a question then,
 * and a question that has been answered should not keep taking the top third
 * of a phone screen.
 *
 * The summary has to look tappable, which is the lesson the chat header
 * taught — static text with a disclosure behind it is a feature nobody finds.
 */
.whats-on { margin: 0; }
details.week > summary {
  display: flex; align-items: center; gap: var(--s2);
  list-style: none; cursor: pointer;
  padding: var(--s2) var(--s3);
  font-size: var(--t-sm); font-weight: 600;
}
details.week > summary::-webkit-details-marker { display: none; }
.week-what { flex: 0 0 auto; }
.week-hint { flex: 1; color: var(--muted); font-weight: 400; }
details.week > summary .pill { flex: 0 0 auto; }
.week-go {
  width: 1rem; height: 1rem; flex: 0 0 auto; margin-left: auto;
  color: var(--muted); transition: transform var(--dur-fast) var(--ease);
}
details.week[open] > summary .week-go { transform: rotate(180deg); }
details.week > ul.events { padding: 0 var(--s3) var(--s2); }
@media (prefers-reduced-motion: reduce) { .week-go { transition: none; } }

/* Something called off: still on the list, plainly not happening. */
.ev-open--off {
  display: flex; align-items: stretch;
  padding: 0; overflow: hidden;
  /* The same floor as a live row. A cancelled session that was shorter than
     the rows around it would read as a different kind of thing, and it is
     the same thing with an answer nobody has to give. */
  min-height: var(--ev-row);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r-md);
}
li.ev.is-off { opacity: .8; }
li.ev.is-off .ev-when strong, li.ev.is-off .ev-what,
li.ev.is-off .ev-venue-text {
  text-decoration: line-through;
}
li.ev.is-off .ev-kind { background: var(--surface-2); color: var(--muted); }
.ev-tag--off, .ev--match .ev-tag--off {
  background: var(--err-bg); color: var(--err-fg);
}

.ev-note {
  display: inline-flex; vertical-align: -.15em; margin-right: .2rem;
  color: var(--muted);
}
.ev-note svg { width: .95rem; height: .95rem; }
/* The coach's own words, so `pre-wrap` keeps the line breaks they typed. The
   cap is generous — the form allows 500 characters and most notes are one
   line — and it scrolls rather than truncating, because cutting somebody's
   sentence in half is worse than making them scroll. */
.ev-note-text {
  margin: 0 0 var(--s3); padding: var(--s2) var(--s3);
  background: var(--surface-2); border-radius: var(--r-sm);
  font-size: var(--t-sm); white-space: pre-wrap;
  max-height: 9rem; overflow-y: auto; overscroll-behavior: contain;
}

/* What changed, in the sheet where somebody decides whether they can make it.
   Above the coach's note deliberately: the note is advice, this is the
   fixture itself moving under them. The old value is struck through rather
   than dropped, because a parent who arranged a lift around 9:00 needs to
   recognise which time they planned for. */
.ev-moved {
  margin: 0 0 var(--s3); padding: var(--s2) var(--s3);
  background: var(--warn-bg); color: var(--warn-fg);
  border-radius: var(--r-sm); font-size: var(--t-sm);
}
.ev-moved-line { display: block; margin-top: .15rem; }
.ev-moved s { opacity: .7; }
.ev-moved .muted { display: block; margin-top: .25rem; opacity: .8; }

/* The association's own page for this game, in the answer sheet. Quiet and
   last: it is somebody else's site, useful for checking a ground or a ladder
   and never the thing a family came here to do. */
.ev-source { margin: 0 0 var(--s3); font-size: var(--t-sm); }

/* The venue as a maps link, in the answer sheet. The pin glyph carries the
   "this is a place" meaning; the trailing "opens maps" is there because a
   link that silently leaves the app reads as a bug to somebody mid-task. */
.ev-where { margin: 0 0 var(--s3); font-size: var(--t-sm); }
.ev-where a {
  display: inline-flex; align-items: center; gap: .3rem;
  font-weight: 600; text-decoration: none; color: var(--brand);
}
.ev-where svg { width: 1em; height: 1em; }

/* Name and answer as one object. A name on the left and an answer on the
   right reads as two facts, and with two children the question is always
   which one. */
.ev-who { display: flex; flex-wrap: wrap; gap: var(--s1); flex: 0 0 auto; }
.tag {
  display: inline-flex; align-items: center; gap: .2rem;
  padding: .1rem .45rem .1rem .3rem; border-radius: var(--r-full);
  font-size: var(--t-xs); font-weight: 700;
  border: 1px solid transparent;
}
.tag .rsvp-icon { width: .85rem; height: .85rem; flex: 0 0 auto; }
.tag--attending { background: var(--ok-bg); color: var(--ok-fg); }
.tag--not_attending { background: var(--err-bg); color: var(--err-fg); }
.tag--maybe { background: var(--warn-bg); color: var(--warn-fg); }
/* Not yet answered: outlined rather than filled, so a row the club is still
   waiting on reads as an open question at a glance. */
.tag--none {
  background: transparent; color: var(--muted); border-color: var(--line);
}

/* An answer as a state on a row, rather than as a chip. The coach sheets
   scan a column of seventeen of these looking for the one nobody has
   answered, so it wears the colour and nothing else — no pill, no
   background, because seventeen filled chips down one edge is a decoration.
   `.tag` above is the same glyph when it is a thing being read rather than a
   column being scanned.

   The three colours are the ones the style guide reserves ("ok / warn / err
   — going, maybe, can't. RSVP answers, and nothing else"), and no-answer is
   muted rather than coloured, because an open question should look empty. */
/* The drawn pin inline in a sentence ("· Redmond Oval"), where the family's
   own row gives it a column of its own. Nudged down a hair because a glyph
   sitting on the text baseline reads as taller than the words beside it. */
.where-pin { display: inline-flex; margin-right: .15rem; }
.where-pin svg { width: .95em; height: .95em; vertical-align: -.12em; }

/* What a row says after it has saved itself, where there is no Save button
   to have been pressed. "Saved" fades out on its own — a column of it on
   seventeen rows is wallpaper, and it has done its job once it is read.
   A refusal does not fade: it is the one state somebody has to act on.

   `.save-failed` is `feedback.js`'s mark for a request that never reached the
   server at all — no signal on a touchline, which is the ordinary Saturday
   and the one failure the route cannot answer for itself. */
.row-state { flex: 0 0 auto; align-self: center; font-size: var(--t-xs);
             white-space: nowrap; }
.row-saved { color: var(--ok-fg); animation: row-saved 2.4s var(--ease) forwards; }
.row-failed { color: var(--err-fg); font-weight: 600; }
@keyframes row-saved { 0%, 55% { opacity: 1; } 100% { opacity: 0; } }
[data-saves].save-failed .row-state::after {
  content: "Not saved"; color: var(--err-fg); font-weight: 600;
}
[data-saves].save-failed .row-state > * { display: none; }
@media (prefers-reduced-motion: reduce) {
  .row-saved { animation: none; opacity: .8; }
}

.rsvp-state { flex: 0 0 auto; display: inline-flex; color: var(--muted); }
.rsvp-state svg { width: 1.1rem; height: 1.1rem; }
.rsvp-state--attending { color: var(--ok-fg); }
.rsvp-state--not_attending { color: var(--err-fg); }
.rsvp-state--maybe { color: var(--warn-fg); }

/* --- the team mark ------------------------------------------------------
 *
 * Identity, never state. A filled disc with the team's age number in it, used
 * where there is no kind-spine to tint: the chats list, a conversation header
 * and the sidebar. On an event row the spine carries the team instead, and a
 * chip beside the title would be the same fact twice.
 *
 * The character is not optional. Colour alone fails for about one man in
 * twelve, and two of a parent's teams can be a pair this palette renders
 * close. Sized off the label so `12R` fits without a second shape.
 */
.team-mark {
  flex: none; display: inline-grid; place-items: center;
  min-width: 2rem; height: 2rem; padding: 0 .3rem;
  /* A rounded SQUARE, and that is the point. `.count` is a brand-coloured
     pill with a number in it, and a team mark drawn the same way reads as a
     quantity — "12" beside a heading is either an age group or twelve
     outstanding things, and the reader should not have to work out which.
     Shape separates identity from counting; colour is not doing that job. */
  border-radius: var(--r-sm);
  background: var(--team-1); color: #fff;
  font-size: var(--t-xs); font-weight: 800; letter-spacing: .01em;
}
.team-mark--sm { min-width: 1.35rem; height: 1.35rem; font-size: .5625rem;
                 border-radius: 6px; }
.team-1 { background: var(--team-1); } .team-2 { background: var(--team-2); }
.team-3 { background: var(--team-3); } .team-4 { background: var(--team-4); }
.team-5 { background: var(--team-5); } .team-6 { background: var(--team-6); }

/* The spine, tinted rather than filled. A filled spine reads as loudly as the
   brand does and starts competing with "needs an answer"; a wash groups the
   week by team while leaving every signal on the row alone. */
.ev-spine.team-1 { background: color-mix(in srgb, var(--team-1) 15%, transparent); color: var(--team-1); }
.ev-spine.team-2 { background: color-mix(in srgb, var(--team-2) 15%, transparent); color: var(--team-2); }
.ev-spine.team-3 { background: color-mix(in srgb, var(--team-3) 15%, transparent); color: var(--team-3); }
.ev-spine.team-4 { background: color-mix(in srgb, var(--team-4) 15%, transparent); color: var(--team-4); }
.ev-spine.team-5 { background: color-mix(in srgb, var(--team-5) 15%, transparent); color: var(--team-5); }
.ev-spine.team-6 { background: color-mix(in srgb, var(--team-6) 15%, transparent); color: var(--team-6); }

/* On a narrow phone the chips drop under the detail rather than squeezing it.
 *
 * The wrap belongs to `.ev-main`, NOT to `.ev-open`. `.ev-open` is now just
 * the spine and the main column, and wrapping it would drop the whole column
 * below the spine. `.ev-who` asks for `flex: 1 1 100%` — in a container that
 * does not wrap, that is not "give me my own line", it is "give me all the
 * width", and it squeezed `.ev-body` to **zero**: the date wrapped a word per
 * line and every `nowrap; overflow: hidden` line clipped to nothing, leaving
 * a row with a date and a lone map pin on it.
 *
 * No indent any more either: the spine sits outside `.ev-main`, so the
 * column already starts under the words. */
@media (max-width: 26rem) {
  .ev-main { flex-wrap: wrap; gap: var(--s2); }
  .ev-who { flex: 1 1 100%; margin-left: 0; }
}

/* Announcement rows borrow the event row's anatomy — disc, then two lines —
   so the family page keeps one rhythm whatever kind of thing a row is. The
   ev-* classes name the anatomy, not a claim that an announcement is an
   event. */
.news > li > a {
  display: flex; align-items: center; gap: var(--s3);
  padding: var(--s2) var(--s3);
}
/* The Club tab's rows carry three lines, not two — title, summary, when —
   so the disc sits at the top of the words rather than beside the middle
   one, which had it floating against the summary. `align-items` on the row
   and not on `.ev-body`, so the home strip's two-line rows are untouched. */
.news > li > a:has(.meta + .meta) { align-items: flex-start; }
.news > li > a:has(.meta + .meta) .ev-kind { margin-top: .1rem; }
/* An urgent post says so in the list as well as on its own page: a list of
   four gave no hint which one had texted everybody at 9pm. Smaller here
   than the page's own flag — it is a mark on a row, not a banner. */
.ev-when .urgent-flag { margin: 0; font-size: var(--t-xs); }

/* --- Answering ----------------------------------------------------------
 * Lifted into the shared sheet by rsvp_sheet.js. Without it these are an
 * ordinary disclosure on the row, which is the whole no-JavaScript path.
 */
.ev-answers > summary {
  list-style: none; cursor: pointer;
  padding: var(--s1) var(--s3); font-size: var(--t-sm); color: var(--accent);
}
.ev-answers > summary::-webkit-details-marker { display: none; }
html.has-rsvp-sheet .ev-answers { display: none; }

.ans { margin: 0 0 var(--s3); }
.ans-name { display: block; font-weight: 700; margin-bottom: var(--s1); }
.ans-choices { display: flex; gap: var(--s2); }
/* One form per child with three submit buttons, not three forms — the note
   below has to travel with whichever answer is pressed. */
.ans-choices form, .ans-choices > .ans-btn { flex: 1; margin: 0; }
/* The optional word of context. Deliberately quiet: it is the third thing in
   the block, after the child and the answer, and most taps never need it. */
.ans-note { display: block; margin-top: var(--s2); }
.ans-note input { width: 100%; }
/* An icon with its word under it. The icon carries it at a glance; the word
   is there because a tick, a question mark and a cross are only obvious once
   somebody has been told what they mean -- and because a colour is not
   information for everyone. */
.ans-btn {
  display: flex; flex-direction: column; align-items: center; gap: .15rem;
  width: 100%; margin: 0; padding: var(--s2) var(--s1);
  min-height: var(--tap);
  background: var(--surface-2); color: var(--muted);
  border: 1px solid transparent; border-radius: var(--r-md);
  font: inherit; font-size: var(--t-xs); font-weight: 700; cursor: pointer;
}
.ans-btn .rsvp-icon { width: 1.35rem; height: 1.35rem; }
.ans-btn--attending.chosen { background: var(--ok-bg); color: var(--ok-fg);
                             border-color: var(--ok-fg); }
.ans-btn--not_attending.chosen { background: var(--err-bg); color: var(--err-fg);
                                 border-color: var(--err-fg); }
.ans-btn--maybe.chosen { background: var(--warn-bg); color: var(--warn-fg);
                         border-color: var(--warn-fg); }

/* The sheet itself was defined inside conversation.html, which is why the
   family page had nowhere to put one. */
.sheet { position: fixed; inset: 0; z-index: 40; }
.sheet[hidden] { display: none; }
.sheet-backdrop {
  position: absolute; inset: 0; background: rgb(0 0 0 / .35);
  opacity: 0; transition: opacity var(--dur) var(--ease);
}
.sheet.is-open .sheet-backdrop { opacity: 1; }
.sheet-body {
  position: absolute; left: 0; right: 0; bottom: 0;
  background: var(--bg); border-radius: var(--r-lg) var(--r-lg) 0 0;
  padding: var(--s4) var(--s4) calc(var(--s4) + env(safe-area-inset-bottom));
  box-shadow: var(--e2);
  transform: translateY(100%);
  transition: transform var(--dur) var(--ease);
  max-height: 85vh; overflow-y: auto;
}
.sheet.is-open .sheet-body { transform: translateY(0); }
/* On a wide screen a bar across the bottom of a 1400px window is absurd, so
   it becomes a centred dialog. Same markup, same script. */
@media (min-width: 40rem) {
  .sheet-body {
    left: 50%; bottom: auto; top: 50%; right: auto;
    width: min(26rem, calc(100vw - var(--s6)));
    border-radius: var(--r-lg);
    transform: translate(-50%, -46%);
    opacity: 0;
    transition: opacity var(--dur) var(--ease),
                transform var(--dur) var(--ease);
  }
  .sheet.is-open .sheet-body { transform: translate(-50%, -50%); opacity: 1; }
}
.sheet-who { margin: 0; font-weight: 700; font-size: var(--t-lg); }
.sheet-item {
  display: block; width: 100%; margin: 0 0 var(--s1); padding: var(--s3);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r-sm); color: var(--text);
  font-size: var(--t-base); font-weight: 500; text-align: center;
  cursor: pointer; min-height: var(--tap);
}
.sheet-cancel { color: var(--muted); }
.sheet .hint { margin: 0 0 var(--s4); }

@media (prefers-reduced-motion: reduce) {
  .sheet-backdrop, .sheet-body { transition: none; }
}

/* Changing or calling off a session. On the session's own page and nowhere
   else — "where do I cancel Tuesday" has one answer, and every list that
   shows a session links to it. It used to be a disclosure on each of those
   rows, which is why this once carried a `> summary` rule. */
.call-off { margin-top: var(--s2); }
/* A column, not a row. It was one line with a label and a field side by side
   back when the only thing to fill in was a reason; it now asks a question and
   offers three answers, and a flex row squeezed the reason field to half a
   sentence. */
.call-off form { margin-top: var(--s2); }
.call-off input[type="text"], .call-off input:not([type]) { width: 100%; }
.call-off .off-who {
  margin: var(--s4) 0 var(--s2); font-weight: 700; font-size: var(--t-sm);
}
/* Why an option is not on offer. An option silently missing reads as a bug;
   one that explains itself teaches how the thing works. */
.call-off .off-why {
  margin: 0 0 var(--s2); padding: var(--s2) var(--s3);
  background: var(--surface-2); border-radius: var(--r-sm);
  font-size: var(--t-sm); color: var(--muted);
}
.call-off .off-why strong { color: var(--text); }
.call-off .hint { margin-top: var(--s2); }

/* On or off, as one decision at the top of the form.
 *
 * It was two whole forms stacked in one disclosure, and because both carried
 * the shared "Who to tell" block the panel showed two identical sets of
 * checkboxes with nothing tying either to a verb. A radio pair is the honest
 * shape: a session's status is a field of it, not a choice of destination.
 *
 * `.chip` already draws a checked control, so the pair needs layout only —
 * and it is the same chip the notification options below use, which is what
 * makes the form read as one thing. */
.call-off .onoff {
  display: flex; gap: var(--s2);
  margin: 0 0 var(--s3); padding: 0; border: 0;
}
.call-off .onoff .chip { flex: 1; }
.call-off .onoff legend {
  padding: 0; margin-bottom: var(--s1);
  font-weight: 600; font-size: var(--t-md);
}
/* A legend is not a flex item, so it must leave the row. */
.call-off .onoff { flex-wrap: wrap; }
.call-off .onoff legend { flex: 1 0 100%; }

/* Which half of the form the radio is asking for — in CSS, so it is true
   before any script has run.
 *
 * This began as a small script and would have left a coordinator with
 * JavaScript off pressing a button labelled "Save" to cancel training for
 * eleven families. The words on a destructive control are not an
 * enhancement, so they are not one here.
 *
 * Where `:has()` is missing, none of this applies and both halves show with
 * both words on the button: longer, and never ambiguous. That is the same
 * fallback the form is designed around, which is why the radio pair and the
 * single "Who to tell" carry the correctness and this only carries the
 * tidiness. */
.call-off-form:has(input[value="off"]:checked) [data-call-off-on],
.call-off-form:has(input[value="on"]:checked) [data-call-off-off] {
  display: none;
}

/* Calling it off is the quieter button, as it was before the merge.
 *
 * The two acts share one submit now, so the styling has to follow the radio
 * or the destructive one inherits the filled accent that everything else in
 * the app uses for "this is the thing to press". `--err` would say it more
 * loudly and is not available: the design record reserves ok/warn/err for
 * RSVP answers and nothing else, and spending one here is exactly the
 * "colour twice" it exists to prevent. Outlined is the app's own word for a
 * lesser action, and it is what the old Call-it-off button already used. */
.call-off-form:has(input[value="off"]:checked) button[type="submit"] {
  background: transparent; color: var(--accent);
  border: 1px solid var(--accent);
}

/* Something called off. Muted rather than red: it is a fact about the
   schedule, not a warning — and the row is struck through so the list reads
   at a glance without anybody having to find the word. */
.pill--off { background: var(--surface-2); color: var(--muted); }
li.is-off > div > strong, li.is-off .meta { text-decoration: line-through; }
li.is-off { opacity: .75; }

/* --- The club's own texture ---------------------------------------------
 * A faint tiling ball motif in the club's brand colour, served from
 * /branding/texture.svg so it follows CLUB_BRAND_COLOR rather than being
 * baked into this file.
 *
 * Two rules about where it may go, and they are the whole reason it does not
 * look cheap: never behind body text, and never above 6% opacity. It marks a
 * surface as the club's; it is not decoration for its own sake.
 */
/* On a layer rather than on the element: `opacity` on the element itself would
   fade its contents along with the pattern. */
.textured { position: relative; isolation: isolate; }
.textured::before {
  content: ""; position: absolute; inset: 0; z-index: -1;
  background: url("/branding/texture.svg") repeat;
  background-size: 120px 120px;
  opacity: .035;
}
/* The same ink reads stronger on a dark surface, so it is dialled back. */
@media (prefers-color-scheme: dark) { .textured::before { opacity: .05; } }

/* --- Empty state --------------------------------------------------------
 * Textured, because an empty state is the one place with room for the club's
 * mark and no small type to sit under it. */
.empty {
  padding: var(--s5) var(--s4); text-align: center; color: var(--muted);
  border: 1px dashed var(--line); border-radius: var(--r-lg);
  position: relative; isolation: isolate; overflow: hidden;
}
.empty::before {
  content: ""; position: absolute; inset: 0; z-index: -1;
  background: url("/branding/texture.svg") repeat;
  background-size: 120px 120px;
  opacity: .04;
}
@media (prefers-color-scheme: dark) { .empty::before { opacity: .06; } }
.empty p { margin: 0 0 var(--s1); }

/* --- Callouts and flashes ----------------------------------------------
 * `.note` is the accent-bordered callout — a message from the club inside a
 * page. It is NOT small print: half a dozen templates used `.note` for muted
 * captions and only worked because each redefined it locally. That meaning is
 * `.hint`. */
.note {
  border-left: 4px solid var(--accent);
  background: color-mix(in srgb, var(--accent) 10%, var(--surface));
  padding: var(--s3) var(--s4); border-radius: var(--r-sm); margin: var(--s4) 0;
}
.note strong { display: block; margin-bottom: .2rem; }
/* A note that offers a way out of itself. Left-aligned and inline rather than
   the full-width button a form ends with: this is a step on the way to the
   thing, not the thing. */
.note-act { margin: var(--s3) 0 0; }

.hint { margin: .2rem 0 var(--s2); font-size: var(--t-sm); color: var(--muted); }

.flash {
  padding: var(--s3) var(--s4); border-radius: var(--r-sm);
  margin: var(--s3) 0; font-size: var(--t-md);
}
.flash.ok { background: var(--ok-bg); color: var(--ok-fg); }
.flash.warn { background: var(--warn-bg); color: var(--warn-fg); }
.flash.err { background: var(--err-bg); color: var(--err-fg); }

/* ====================================================================== */
/* Forms                                                                   */
/* ====================================================================== */

label { display: block; font-weight: 600; margin: var(--s3) 0 var(--s1); font-size: var(--t-md); }

/* Named by what is NOT a field rather than by what is — see the note in the
   admin shell. Enumerating the types missed number/time/date/tel here (two
   public pages had copied this rule verbatim to patch that back), and missed
   any `<input>` with no type attribute everywhere. A parent typing a jersey
   measurement should not get a differently-styled box from the one above it. */
input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=button]):not([type=reset]):not([type=file]):not([type=image]):not([type=color]):not([type=range]):not([type=hidden]),
textarea, select {
  width: 100%; padding: var(--s2) var(--s3);
  /* 1rem exactly. Below 16px, iOS zooms the page on focus and never zooms
     back. This is the one place the type scale must not be used. */
  font-size: var(--t-base);
  font-family: inherit;
  color: var(--text); background: var(--surface);
  border: 1px solid var(--line); border-radius: var(--r-sm);
}
textarea { min-height: 4.5rem; resize: vertical; }

/* A file picker, which the rule above deliberately excludes — its box is not
   a text field and giving it the field's padding puts a native button inside
   a fake one.
 *
 * It still has to be styled, and was not: the photo picker in a conversation
 * rendered as the browser's raw "Choose File" at 13px in a 21px-tall control,
 * in the middle of an app where every other control is 1rem and `--tap` high.
 * Measured 2026-08-02.
 *
 * The 1rem is not cosmetic here either. iOS zooms the page when a form
 * control under 16px takes focus and does not zoom back — the same reason
 * the rule above says what it says. */
input[type=file] {
  width: 100%;
  font-family: inherit; font-size: var(--t-base);
  color: var(--muted);
}
input[type=file]::file-selector-button {
  margin-right: var(--s3); padding: var(--s2) var(--s3);
  font: inherit; font-size: var(--t-sm); font-weight: 600;
  color: var(--text); background: var(--surface-2);
  border: 1px solid var(--line); border-radius: var(--r-sm);
  cursor: pointer;
}
input[type=file]::file-selector-button:hover { filter: brightness(1.06); }

button, .btn {
  display: block; width: 100%; padding: var(--s3) var(--s4);
  font-size: var(--t-base); font-weight: 600; min-height: var(--tap);
  font-family: inherit; text-align: center; text-decoration: none;
  color: #fff; background: var(--accent); border: 0; border-radius: var(--r-sm);
  cursor: pointer; margin-top: var(--s4);
  transition: filter var(--dur-fast) var(--ease);
}
button:hover, .btn:hover { filter: brightness(1.06); }
button:disabled { opacity: .5; cursor: default; filter: none; }

/* Both, deliberately: `button, .btn` above styles anchors as buttons too, but
   this rule only ever matched <button>, so every secondary *link* on a public
   page rendered as a primary one — two identical blue bars where one was the
   action and the other was navigation. */
button.secondary, .btn.secondary {
  background: transparent; color: var(--accent); border: 1px solid var(--accent);
}

/* The one that cannot be undone. **This did not exist in the parent app at
   all** — `.danger` was defined only in `admin.css`, so two controls that
   destroy something were rendering as ordinary blue primary buttons: calling
   an occasion off (`club_event_who.html`) and replacing a family's links
   (`settings.html`). The admin's version hardcodes `#c0392b`; this uses the
   error token, so it follows the dark scheme like everything else here.

   Both selectors for the reason `.secondary` records above: `button, .btn`
   styles anchors as buttons too, and a rule matching only `<button>` leaves
   any destructive *link* looking like the safe one beside it. */
button.danger, .btn.danger {
  background: var(--err-fg); color: #fff; border: 0;
}
button.danger.secondary, .btn.danger.secondary {
  background: transparent; color: var(--err-fg);
  border: 1px solid var(--err-fg);
}

/* Inline, auto-width. The default above is a full-width block because that is
   what a form's primary action wants on a phone; a button sitting in a row
   beside other things wants the opposite, and eight templates were each
   undoing the default by hand with `width: auto; margin: 0`. */
button.inline, .btn.inline {
  display: inline-flex; align-items: center; justify-content: center;
  width: auto; margin: 0; padding: var(--s2) var(--s3);
  font-size: var(--t-md);
}
button.linkish, .linkish {
  background: none; border: 0; padding: .05rem var(--s1); width: auto; margin: 0;
  min-height: 0; color: var(--muted); font-size: var(--t-sm); cursor: pointer;
  text-decoration: underline; display: inline;
}

/* --- A quiet action -----------------------------------------------------
 * An outlined pill with a glyph and a word: small, recognisably a control,
 * and deliberately lighter than `.btn`.
 *
 * It began as `summary.compose-open` in conversation.html for Photo and Poll,
 * and the team page used underlined `.linkish` text for the same job — so
 * "Who's coming", "Change or cancel" and "Nudge the unanswered" read as
 * footnotes while the two controls in the composer read as controls. One
 * idiom, in the design system rather than in one page's style block.
 *
 * Why not `.btn`: `_team_event.html` has argued since it was written that a
 * boxed button on every row of a diary stops any of them reading as an
 * action. That still holds — this is the middle ground the argument implies,
 * not a reversal of it. Weight comes from the outline and the glyph; the
 * type stays `--t-sm` and `--muted` until something is open.
 *
 * Works as a `<summary>`, a `<button>` or an `<a>`; a disclosure that uses it
 * gets the accent when open, which is the one state worth colouring.
 */
.quiet-action {
  display: inline-flex; align-items: center; gap: .35rem;
  width: auto; margin: 0; padding: .25rem .7rem; min-height: 0;
  font: inherit; font-size: var(--t-sm); font-weight: 500;
  color: var(--muted); text-align: left; text-decoration: none;
  background: none; border: 1px solid var(--line);
  border-radius: var(--r-full); cursor: pointer;
  list-style: none;
}
.quiet-action::-webkit-details-marker { display: none; }
.quiet-action svg { flex: none; width: 1rem; height: 1rem; }
.quiet-action:hover { border-color: var(--muted); }

/* A row's controls, side by side. Two `.quiet-action`s while they are shut;
   a disclosure that opens takes the row to itself, because one sharing a row
   with another opens into half the width its form needs.
   Here rather than in `team.html`'s own `<style>` because the Teams page uses
   it for its own two doors — §11. */
.row-acts {
  display: flex; flex-wrap: wrap; gap: var(--s2); margin-top: var(--s2);
}
.row-acts > details { margin: 0; }
.row-acts > details[open] { flex: 1 0 100%; }

/* A schedule row with a glyph on the left: the coaches corner's hub, and the
   Training and Games pages. Here rather than in the hub's own `<style>`
   because `public/_coach_rows.html` renders on all three now — a partial's
   layout living in one page's block is what §11 of docs/design-system.md is
   about, and the rows would have arrived unstyled on two of them. */
.iconed { display: flex; align-items: center; gap: var(--s3); }
.iconed .row-body { flex: 1; min-width: 0; }
[open] > .quiet-action { color: var(--accent); border-color: var(--accent); }

/* The same control saying what pressing it does *now* rather than what it did
   a moment ago — "Photo" becomes "Cancel", "Change or cancel" becomes
   "Close". Shared for the same reason the pill is. */
.when-open { display: none; }
[open] > * .when-open, [open] > .when-open { display: inline; }
[open] > * .when-shut, [open] > .when-shut { display: none; }

/* Segmented radio group — large tap targets, no JS */
.choices { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--s1); margin-top: var(--s1); }
.choices input { position: absolute; opacity: 0; pointer-events: none; }
.choices label {
  margin: 0; padding: var(--s2) var(--s1); text-align: center;
  font-size: var(--t-md); font-weight: 600; min-height: var(--tap);
  display: flex; align-items: center; justify-content: center;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r-sm); cursor: pointer;
}
.choices input:checked + label { background: var(--accent); border-color: var(--accent); color: #fff; }

.scale { display: flex; gap: var(--s1); }
.scale input { position: absolute; opacity: 0; pointer-events: none; }
.scale label {
  flex: 1; margin: 0; padding: var(--s2) .2rem; text-align: center;
  font-size: 1.3rem; background: var(--surface);
  border: 1px solid var(--line); border-radius: var(--r-sm); cursor: pointer;
}
/* The colour is pinned rather than inherited. --brand does not change with the
   theme but --text does, so a chosen rating was near-black on orange in light
   mode (5.8:1) and near-white on the same orange in dark (2.5:1) — the one
   that was picked became the one you could not read. .choices above pins its
   colour for the same reason. */
.scale input:checked + label {
  background: var(--brand); border-color: var(--brand); color: #1a1c1f;
}
.scale-legend {
  display: flex; justify-content: space-between;
  margin-top: .3rem; font-size: var(--t-xs); color: var(--muted);
}

/* --- Getting a link when you have no link -------------------------------
 * `public/_get_link.html` is on two pages — the club's front page and the
 * dead end a family's own link lands on when it stops working — so its
 * layout cannot live in a <style> block in either of them. Address and
 * button side by side where there is room; stacked, with the full-width
 * button a phone expects, where there is not. */
.get-link { display: flex; gap: var(--s2); margin: var(--s3) 0 var(--s2); }
.get-link input { flex: 1; min-width: 0; }
/* `width: auto` because the base rule is `button { width: 100% }`, and a
   flex item at `flex: none` takes its basis from that — so the button ate
   the row and left the address box two characters wide on anything wider
   than a phone. It has been that way since the form was written; the
   stacked phone layout below is the one anybody looked at. */
.get-link button { flex: none; width: auto; margin: 0; }
@media (max-width: 480px) {
  .get-link { flex-wrap: wrap; }
  .get-link button { width: 100%; }
}

/* ====================================================================== */
/* Utilities                                                               */
/* ====================================================================== */

.row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--s3); }
.player-row { padding: var(--s2) 0; border-top: 1px solid var(--line); }
.player-row:first-of-type { border-top: 0; }

/* Wide content scrolls inside its own box; the page body never does. */
.scroll-x { overflow-x: auto; -webkit-overflow-scrolling: touch; margin-bottom: var(--s2); }

/* Was defined three times under two names (.sr-only and .visually-hidden). */
.sr-only, .visually-hidden {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

footer.page {
  margin-top: var(--s6); text-align: center; color: var(--muted);
  font-size: var(--t-xs); --logo-size: 2.25rem;
}
/* Quiet by default so the mark reads as a sign-off rather than competing with
   the page's own content; full strength on hover is a nice touch for anyone
   who looks. */
footer.page .club-mark { opacity: .55; transition: opacity var(--dur-fast); }
footer.page:hover .club-mark { opacity: 1; }

/* --- Answering the finger before the server answers the page -------------
 * Three layers, in time order. (1) :active responds in the same frame as
 * the touch. (2) htmx puts `.htmx-request` on the element that started a
 * request, the moment it starts — the element itself shows it is working,
 * and pointer-events:none makes a second press do nothing rather than
 * abort-and-restart the request (the "keep pressing the dead button"
 * spiral, structurally removed). (3) feedback.js raises #pgbar for boosted
 * navigations that outlive 100ms. Without these, a Cloud Run round-trip
 * was most of a second of *nothing happening*, which reads as broken. */

.tab:active .tab-icon { transform: scale(.85); }
.tab .tab-icon { transition: transform var(--dur-fast) var(--ease); }
button:active:not(:disabled), .btn:active { transform: scale(.97); }
button, .btn { transition: filter var(--dur-fast) var(--ease),
                           transform var(--dur-fast) var(--ease); }
/* Row-shaped links — a chat in the list, a drawer thread, a task, an event
   row — press like rows everywhere else press (.ev-open set the pattern).
   This is the same-frame answer for the taps that are not buttons. */
.list a:active, .sidenav-sub:active, a.chat-row:active {
  background: var(--surface-2);
}

/* EVERY in-flight or leaving link and button shows it, uniformly. The first
   version pulsed only tab and sidebar icons, which taught a precise lesson:
   the one tap with no working state (a chat row) was the one reported as
   "feels slow". The state is the element's, not the chrome's — so it is on
   the selector for the element kind, never on a per-surface class. */
a.htmx-request, button.htmx-request, .btn.htmx-request,
a.is-leaving {
  pointer-events: none;
  animation: working .9s var(--ease) infinite;
}
@keyframes working {
  0%, 100% { opacity: 1; }
  50% { opacity: .45; }
}
/* A form in flight — through htmx or as a plain document post
   (feedback.js marks the latter `.is-leaving`): its submit already fired,
   so every button in it holds still and pulses. */
form.htmx-request button, form.htmx-request .btn,
form.is-leaving button, form.is-leaving .btn {
  pointer-events: none;
  animation: working .9s var(--ease) infinite;
}

#pgbar {
  position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 100;
  background: transparent; pointer-events: none; opacity: 0;
}
#pgbar.on { opacity: 1; }
#pgbar.on::after {
  content: ""; position: absolute; top: 0; bottom: 0; left: -40%;
  width: 40%; border-radius: var(--r-full);
  background: var(--accent);
  animation: pgslide 1.1s var(--ease) infinite;
}
@keyframes pgslide {
  0% { left: -40%; width: 40%; }
  60% { width: 55%; }
  100% { left: 100%; width: 30%; }
}
/* The page itself acknowledging the tap, not only a hairline at the very top.
 *
 * A 3px bar sits under the status bar and the notch on a phone, which is
 * exactly where nobody is looking — they are looking at the thing they just
 * pressed. So a soft sweep passes across the content while the answer is in
 * flight. It rides on the same 100ms timer as the bar, so a fast reply still
 * shows nothing: a shimmer that flashes for 40ms is worse than stillness.
 *
 * Deliberately NOT a skeleton. A skeleton replaces the content with a guess
 * at the next page's shape, and this app boosts between pages of genuinely
 * different shapes — so it would be a lie half the time, and a flicker of
 * wrong furniture reads worse than the real page held for another 80ms.
 * The content stays; light moves over it. */
/* TEMPORARY, with the switch in feedback.js: the sheet treatments. `page`
   leaves top/bottom at 0, `content` sets them to the gap the chrome leaves.
   Delete with the switch once the call is made. */
#pgshim {
  position: fixed; left: 0; right: 0; z-index: 99;
  pointer-events: none; opacity: 0;
  transition: opacity var(--dur-fast) var(--ease);
}
#pgshim.on { opacity: 1; }
#pgshim.on::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(
    100deg,
    transparent 30%,
    color-mix(in srgb, var(--text) 7%, transparent) 48%,
    transparent 66%);
  background-size: 220% 100%;
  animation: pgsweep 1.15s var(--ease) infinite;
}

.is-shim { position: relative; }
.is-shim::after {
  content: ""; position: absolute; inset: 0; z-index: 2;
  pointer-events: none;
  /* The block's own corners, whatever they are. A square sweep over a
     rounded card is the same mistake the brand edge made one level out. */
  border-radius: inherit;
  background: linear-gradient(
    100deg,
    transparent 30%,
    color-mix(in srgb, var(--text) 9%, transparent) 48%,
    transparent 66%);
  background-size: 220% 100%;
  animation: pgsweep 1.15s var(--ease) infinite;
}
/* A card that clips its own children would clip the sweep to its padding
   box; these do not, so the sweep reaches the border. Nothing to do for
   `overflow: hidden` blocks (an event row) — inheriting the radius already
   lands the sweep in the right place. */
@keyframes pgsweep {
  0%   { background-position: 140% 0; }
  100% { background-position: -40% 0; }
}

@media (prefers-reduced-motion: reduce) {
  a.htmx-request, button.htmx-request, .btn.htmx-request, a.is-leaving,
  #pgbar.on::after, .is-shim::after, #pgshim.on::after { animation: none; }
  /* The state must survive with the motion gone. */
  a.htmx-request, button.htmx-request, .btn.htmx-request, a.is-leaving {
    opacity: .6;
  }
  #pgbar.on::after { left: 0; width: 100%; }
  /* A flat wash rather than a sweep: still says "working", asks nothing of
     anybody who has told their phone they do not want movement. Per block,
     as the sweep is — so it still says *which* part of the page is waiting. */
  .is-shim::after, #pgshim.on::after {
    background: color-mix(in srgb, var(--text) 6%, transparent);
  }
}

/* --- Polls ---------------------------------------------------------------
 *
 * Here rather than in conversation.html's style block because the card is not
 * local to a conversation: the same partial renders in a team's chat and on a
 * family's to-do list. It lived in the page for exactly one afternoon, which
 * was long enough for the /home copy to render as a stack of solid blue
 * buttons with bullet points and no bars — every option looking like a primary
 * action, and no way to see which was winning.
 */
/* The poll card.

   Every option is a full-width row with the bar *behind* the label, because
   the whole row is the tap target — a 44px row beats a chip a thumb has to
   aim at, and this is answered one-handed on a sideline.

   **Everything here is currentColor and transparency**, which is not a
   stylistic preference: a card sits inside a bubble, and your own bubble is
   the accent with white text while everybody else's is the surface with dark
   text. A fixed pale bar under a white label is invisible — it shipped that
   way for exactly one screenshot. `.quoted` above solves the same problem
   the same way, and this follows it so a poll reads correctly in either
   bubble and in either theme with no second set of colours to maintain. */
/* --- A helper sheet -----------------------------------------------------
 * Lives in a message bubble now and on a club-wide post next, so it is here
 * rather than in conversation.html's style block — the rule the action pill
 * learned the hard way.
 *
 * Everything is `currentColor` and `color-mix`, never a fixed colour: the
 * card sits in the accent bubble when it is yours and on the surface when it
 * is not, and a pale bar under a white label is invisible. The poll card
 * shipped that way once. */
.helpers { margin: .35rem 0 .1rem; }
.helpers form { margin: 0; }
.helpers-head {
  display: flex; align-items: baseline; gap: var(--s2);
  margin: 0 0 .35rem; font-size: var(--t-md);
}
.helpers-note {
  margin-left: auto; flex: none;
  font-size: var(--t-xs); font-weight: 700;
  color: color-mix(in srgb, currentColor 70%, transparent);
}
.helpers-note--done { color: currentColor; }
.helpers-jobs {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: .25rem;
}
.helpers-job {
  display: flex; align-items: center; gap: var(--s2);
  padding: .3rem .5rem;
  border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
  border-radius: var(--r-sm);
}
/* Yours reads as filled rather than ticked: on a roster the question is
   "am I down for this", and a whole row answering it is quicker than a
   glyph to find. */
.helpers-job.is-mine {
  border-color: color-mix(in srgb, currentColor 55%, transparent);
  background: color-mix(in srgb, currentColor 10%, transparent);
}
.helpers-job.is-full { opacity: .62; }
.helpers-what { flex: 1; min-width: 0; }
.helpers-label { display: block; font-size: var(--t-md); font-weight: 600; }
.helpers-count {
  font-size: var(--t-xs); font-variant-numeric: tabular-nums;
  color: color-mix(in srgb, currentColor 68%, transparent);
}
.helpers-names {
  display: block; font-size: var(--t-xs);
  color: color-mix(in srgb, currentColor 68%, transparent);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.helpers-take {
  flex: none; width: auto; margin: 0; min-height: 0;
  padding: .25rem .6rem; font: inherit; font-size: var(--t-xs);
  font-weight: 700; color: inherit; cursor: pointer;
  background: none; border-radius: var(--r-full);
  border: 1px solid color-mix(in srgb, currentColor 45%, transparent);
}
/* Yours, and it says so in the app's one colour for that.
 *
 * It was `currentColor` at 18% — correct about the card sitting in an accent
 * bubble sometimes and on the surface others, and unreadable as a *signal*:
 * a slightly darker pill among five identical ones is not something you find
 * by glancing. Brand is what this app already uses for "this one is about
 * you", and a filled pill is legible on either ground. */
.helpers-take.is-mine {
  background: var(--brand); border-color: var(--brand);
  color: #fff; font-weight: 700;
}
.helpers-take[disabled] { opacity: .5; cursor: default; }

/* The one way in to changing a sheet.
 *
 * The +/- and the bin used to be on the rows. Two problems, and the second is
 * the serious one: this card is read by eleven families and edited by one, so
 * controls most readers must learn to ignore are noise on every render — and
 * a minus beside "I can help" is one mis-tap from quietly reducing what the
 * club is asking for, which nobody notices until Saturday.
 *
 * `currentColor`, never `--muted`: the card sits in the accent bubble when
 * the message is yours and on the plain surface when it is not, and a fixed
 * grey read as a muddy orange on the accent. */
.helpers-edit { margin: .15rem 0 0; font-size: var(--t-xs); }
.helpers-edit a { color: color-mix(in srgb, currentColor 72%, transparent); }
.helpers-edit a:hover { color: inherit; }

/* "This moved under you." Only ever shown to the people down for that job,
   and only for a week (`RENAMED_FOR_DAYS`) — the treatment a moved fixture
   gets, applied to the other thing here that quietly reschedules somebody's
   morning. Warn-coloured rather than brand: brand means "this needs you", and
   this needs reading, not doing. */
.helpers-changed {
  margin-left: .35rem; padding: 0 .3rem; border-radius: var(--r-sm);
  font-size: var(--t-xs); font-weight: 700;
  background: var(--warn-bg); color: var(--warn-fg);
}
.helpers-foot {
  margin: .35rem 0 0; font-size: var(--t-xs);
  color: color-mix(in srgb, currentColor 62%, transparent);
}
.helpers--closed .helpers-job { opacity: .78; }

/* --- Changing a sheet --------------------------------------------------
 * `/helpers/{uuid}/edit`. It lives here rather than in the page's own <style>
 * because it shares controls with the composer, and the design system's rule
 * is that a component used on a second surface belongs in app.css. Breaking
 * that took one step: `.poll-drop` stayed in `conversation.html` while the
 * edit page used the class, so its buttons fell back to the app default — a
 * full-width blue bar that ate the row. */
.sheet-edit .section:first-of-type { margin-top: 0; }
.edit-rows { list-style: none; margin: 0 0 var(--s4); padding: 0; }
.edit-row + .edit-row { margin-top: var(--s3); }
/* The name takes what is left and the count is a fixed box — the same
   proportions the composer uses, so a sheet looks the same being written
   and being changed. */
.edit-line { display: flex; gap: var(--s2); align-items: center; }
.edit-line input[name="job"] { flex: 1; min-width: 0; }
.edit-line .helper-slots { flex: 0 0 4.25rem; text-align: center; }
/* Take this row off the form. **Shared by the composer and the edit page**
 * — one glyph, one size, one place, on the two forms that do the same job.
 *
 * It lived in `conversation.html`'s own <style> block, so the moment the
 * edit page used the class the button fell back to the app's default: a
 * full-width filled blue bar with a × in it, which ate the row and left
 * the job name with no width at all. That is the design system's own rule
 * failing in one step — a component used on a second surface belongs
 * here. */
.poll-drop {
  flex: none; width: 2rem; min-height: 2rem; margin: 0; padding: 0;
  border: 1px solid var(--line); border-radius: var(--r-sm);
  background: transparent; color: var(--muted); font-size: 1.1rem;
  line-height: 1; cursor: pointer;
}
.poll-drop[hidden] { display: none; }
.poll-drop[disabled] { opacity: .35; cursor: not-allowed; }
/* A spacer where the spare row has nothing to remove, so the column of
   crosses stays a column and the fields above and below line up. */
.poll-drop--ghost { visibility: hidden; }
.edit-held { margin: var(--s1) 0 0; }
/* Not `--err-fg`: nothing has gone wrong. It is the consequence of what
   they are about to type, which is a different tone from a refusal. */
.edit-warn { color: var(--warn-fg); }
.sheet-close { margin-top: var(--s5); }
.sheet-close .section { margin-top: 0; }
.sheet-close p { margin: 0 0 var(--s3); }

/* In the band it is a panel, not a card: the band is already a surface, and
   a card inside it is a box inside a box. */
.chat-compose .sheet-edit, .chat-compose .sheet-close {
  margin: var(--s2) 0 0; padding: 0; border: 0; background: none;
}
.chat-compose .sheet-close { border-top: 1px solid var(--line);
                             padding-top: var(--s3); }
/* The rows scroll and the actions stay put, exactly as the poll composer's
   do — the band is bounded, and the button is the point. */
.chat-compose .edit-rows {
  max-height: 34vh; max-height: 34dvh; overflow-y: auto;
  overscroll-behavior: contain; padding-right: var(--s1);
}
.edit-actions { display: flex; align-items: center; gap: var(--s3); }
.edit-actions button[type="submit"] { flex: 1; }

/* A button that has been pressed and is waiting.
 *
 * Only after 120ms (`quick_post.js`), because an indicator that flashes for
 * 40ms is worse than stillness — the lesson the page shimmer taught. A pulse
 * rather than a spinner: these controls are small, and a spinner inside one
 * is three pixels of nothing. Without it a slow post looked ignored and the
 * thumb pressed again, and a second press on a slot toggle takes the slot and
 * hands it straight back. */
button.is-posting, .is-posting {
  animation: post-pulse 900ms ease-in-out infinite;
  cursor: progress;
}
@keyframes post-pulse { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) {
  button.is-posting, .is-posting { animation: none; opacity: .6; }
}

/* Composing one. The count sits beside the label rather than under it: a job
   is "what" and "how many", and stacking them doubles the height of a form
   that may be twenty rows. */
.helper-row { display: flex; gap: var(--s2); align-items: center; }
.helper-row input[name="job"] { flex: 1; min-width: 0; }
.helper-slots { flex: 0 0 4.25rem; text-align: center; }

.poll { margin: .35rem 0 .1rem; }
.poll form { margin: 0; }
.poll-opts { list-style: none; margin: 0; padding: 0;
             display: flex; flex-direction: column; gap: .25rem; }
.poll-opt {
  position: relative; overflow: hidden;
  display: flex; align-items: center; gap: var(--s2);
  width: 100%; margin: 0; min-height: 2.1rem;
  /* A fixed gutter on the left for the tick, present whether or not there is
     one, so every label starts at the same x. Letting the tick push the label
     across indents exactly the row you are looking for and breaks the column
     you scan down -- the fixture row's rule, for the same reason. */
  padding: .35rem .55rem .35rem 1.95rem; text-align: left;
  border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
  border-radius: var(--r-sm);
  background: transparent; color: inherit; font-size: var(--t-sm);
}
/* A sized element rather than a background gradient: it survives
   forced-colours mode, and there is one width to reason about. */
.poll-bar {
  position: absolute; inset: 0 auto 0 0; z-index: 0;
  background: color-mix(in srgb, currentColor 16%, transparent);
  border-radius: inherit; transition: width .2s ease;
}
.poll-label { position: relative; z-index: 1; flex: 1; }

/* Your own choice.
 *
 * A tick, because nothing else on this row can carry it. The background
 * belongs to the bar, which means "how many" — giving it a second meaning
 * makes neither readable at a glance — so the first attempt said "yours" with
 * a slightly darker border and a semibold label. On a phone, next to a bar
 * that is already drawing the eye, that is invisible: parents could not tell
 * they had voted, let alone what for.
 *
 * The ring is an inset shadow rather than a thicker border: changing
 * border-width from 1px to 2px moves every other row by a pixel, so the list
 * twitches each time somebody changes their mind. */
.poll-opt.mine {
  border-color: currentColor;
  box-shadow: inset 0 0 0 1px currentColor;
}
.poll-opt.mine .poll-label { font-weight: 600; }
.poll-tick {
  position: absolute; z-index: 1; left: .5rem; top: 50%;
  transform: translateY(-50%);
  width: 1rem; height: 1rem;
}
.poll-count { position: relative; z-index: 1; opacity: .75;
              font-variant-numeric: tabular-nums; }
/* Above the options: what you need before pressing. Below: what there is to
   know afterwards, and the way to the names. Splitting them that way is the
   whole layout -- instructions read first, results read last. */
.poll-lead { margin: 0 .2rem .3rem; opacity: .8; font-size: var(--t-sm); }
.poll-foot { margin: .3rem .2rem 0; opacity: .8; font-size: var(--t-sm); }
.poll-who { margin: .2rem .2rem 0; font-size: var(--t-sm); }
.poll-anon { white-space: nowrap; }
/* Where the poll came from (poll02) -- present only away from its host, so
   it reads as provenance rather than navigation. */
.poll-src { margin: 0 .2rem .3rem; font-size: var(--t-sm); }

/* An urgent post says so, once, above its body. Ported from the retired
   announcement page (post07) -- two templates could want it now, so it
   lives here rather than in either. */
.urgent-flag {
  display: inline-block; margin: 0 0 .4rem; padding: .1rem .5rem;
  background: var(--brand); color: #fff; border-radius: 999px;
  font-size: .75rem; font-weight: 700; text-transform: uppercase;
  letter-spacing: .04em;
}

/* --- An invitation, answered ---------------------------------------------
 * The third card (`evt01`), beside the poll and the helper sheet, and it
 * obeys their rules because it renders in the same four places — a
 * conversation, a family's to-do list, an occasion's own page, and a post.
 *
 * Deliberately quiet: it sits inside a row that already has a title and a
 * brand edge when something is owed, so a second loud thing here would be
 * two claims on the same attention. Brand means "this needs you", and the
 * edge already said it. */
.rsvp { margin: var(--s2) 0 0; }
/* **`currentColor` throughout, never a fixed colour.** This card sits in the
   accent bubble when the invitation is yours and on the surface when it is
   not — so `var(--muted)` is grey-on-blue and unreadable in the first case,
   which is exactly what the poll card's own note warns about ("a pale bar
   under a white label is invisible; it shipped that way once"). It shipped
   that way again here, and a browser is what found it. */
.rsvp-lead { margin: 0 0 .2rem; font-size: var(--t-sm); font-weight: 600; }
.rsvp-what { margin: 0 0 .2rem; font-size: var(--t-sm);
             color: color-mix(in srgb, currentColor 75%, transparent); }
/* How many are coming, when it is worth saying. Quieter than the lead,
   because it is context for a decision rather than the decision. */
.rsvp-tally { margin: 0 0 .4rem; font-size: var(--t-xs);
              color: color-mix(in srgb, currentColor 65%, transparent); }
.rsvp-note { margin: .2rem 0 0; font-size: var(--t-xs);
             color: color-mix(in srgb, currentColor 65%, transparent); }

/* The head count, above the buttons. Above, because the buttons *are* the
   submit — a number field under them is a field nobody fills in. */
.rsvp-heads { display: flex; gap: var(--s3); margin: 0 0 var(--s2); }
.rsvp-heads label {
  display: flex; align-items: center; gap: var(--s2);
  font-size: var(--t-sm); font-weight: 600;
  color: color-mix(in srgb, currentColor 75%, transparent);
}
/* 1rem exactly, never smaller: iOS Safari zooms the page when you focus an
   input under 16px and does not zoom back, which makes the app unusable one
   tap in. Density comes from the width, not the type size. */
/* The box stays a box in both places: a border of `currentColor` at low
   alpha reads on blue and on white, and the field's own background is the
   page surface either way so typed numbers are legible. */
.rsvp-heads input {
  width: 3.4rem; font-size: 1rem; text-align: center;
  padding: var(--s2);
  border: 1px solid color-mix(in srgb, currentColor 35%, transparent);
  border-radius: var(--r-sm); background: var(--surface); color: var(--text);
}

/* The same segmented control every other RSVP in this app uses (`.seg-btn`),
   so "Going / Maybe / Can't" looks identical whether the thing being answered
   is a training session, a game or a presentation day. */
.rsvp-answers { display: flex; gap: var(--s2); flex-wrap: wrap; }
.rsvp-answers .seg-btn { flex: 1 1 5rem; min-height: var(--tap); }
.rsvp--closed { opacity: .85; }
