/* ============================================================================
   KOS admin — the panel's whole stylesheet.

   Hand-written. No Tailwind, no build step, no framework. The public site
   compiles its CSS; this file is served exactly as it is typed, because the
   panel has to drop onto shared hosting and run.

   IT IS THE SITE, RECOLOURED. Not "inspired by" — the colour variables, the
   type roles and the spacing rhythm are lifted out of src/app/globals.css and
   src/lib/type.ts and rewritten here as plain CSS. Two things change and only
   two: the band is the orange wash rather than the navy ink wash, and the
   accent is orange-deep rather than navy. Everything else is the same studio.

   THREE THINGS THE CLUB CHANGED AFTER THE FIRST BUILD, and every one of them is
   theirs rather than a preference of this file's:

     1. ONE BAND, AND A THICKER ONE. It was the site's header height, 82px at
        1280, with a second empty band closing the page. The club has since taken
        the closing band out and asked for the remaining one to carry that
        weight: 112px at width, 144 on a phone where it has always been two rows,
        and cream all the way to the floor.
     2. THE FACES ARE THE SITE'S FACES IN THE SITE'S ROLES. Alte Haas for
        headings, page titles and the wordmark; KOS Condensed for absolutely
        everything else. The first build had this the other way round. See the
        note above the @font-face rules.
     3. Lesser separation. Rules, boxes and dividers that were doing no work are
        gone — the hierarchy is scale and space, which is CLAUDE.md's own rule.
        What stayed is what WCAG needs: a field's border still has to reach 3:1
        against its ground, and that one is not decoration.

   WHAT IS DELIBERATELY NOT COPIED IS THE MOTION. No preloader, no Lenis, no
   GSAP, no scroll reveals, no rolling button labels. The site is a brochure and
   is allowed to take its time; this is a form that gets filled in between heats
   at the side of a track, and it must feel instant. Copy everything visual,
   copy nothing kinetic.

   THE FORM CONTROLS ARE STILL DRAWN FROM SCRATCH, because they have to be.
   There is not a single input, select or textarea anywhere in src/ — the public
   site has no form at all — so there is no existing treatment to match. What
   changed is that they are now drawn INSIDE this type system rather than beside
   it: the same faces, the same rhythm.

   One real breakpoint, at 812px, as the house rules ask.
   ========================================================================== */

/* ---------------------------------------------------------------- the faces

   The site's three faces, self-hosted here rather than reached for through
   next/font — the panel has no build step and no hashed asset names, so it
   serves them itself from assets/fonts/.

   TWO FAMILIES, THREE FILES, AND NO MIDDLE WEIGHT ANYWHERE. Alte Haas Grotesk
   ships 400 and 700; KOS Condensed ships a single 400. Nothing in this file may
   ask for 500 or 600 — the browser would synthesise it, and on an unkerned
   condensed face a faux bold is a smear. `font-synthesis-weight: none` on
   `body` refuses the request outright rather than trusting every rule below to
   remember. NOTHING IN THIS FILE MAY ASK KOS CONDENSED FOR 700 EITHER, and
   after the role swap below that is very nearly everything on the screen: the
   emphasis a bold used to carry is now carried by scale, by caps, or by solid
   ink against muted ink.

   THE ROLES ARE THE SITE'S ROLES, AT THE CLIENT'S DIRECTION. Alte Haas takes
   headings, page titles and the wordmark. KOS Condensed takes everything else —
   body copy, labels, hints, inputs, buttons, meta lines, figures, the lot.

   THIS IS A REVERSAL OF THE FIRST BUILD, and the argument that used to sit here
   is overruled rather than merely edited out. That argument was a measurement:
   KOS Condensed has no kerning table and eight distinct advance widths across
   the alphabet, so it was taken for figures and labels only and Alte Haas was
   given the running copy, on the grounds that a mistyped phone number has to be
   spotted at a glance. The measurement is still true. It is not the deciding
   question. The panel exists to edit one site, the club reads the two next to
   each other, and a tool set in a different face from the thing it publishes
   reads as a different product — which is a bigger cost than a marginally
   tighter letterfit in a field the user is looking straight at. The client
   asked for parity and parity is what ships. src/app/layout.tsx has carried the
   same instruction all along: "EVERYTHING THAT IS NOT A HEADING — the condensed
   face. Body copy, meta lines, eyebrows, captions, figures: all of it."

   SO THE BODY COPY IS SIZED UP A STEP, exactly as the site sizes its own. KOS
   Condensed has an x-height of 0.488em against Alte Haas's 0.517em and is far
   narrower, so a size ported across unchanged reads a size smaller than it
   measures. Every running-copy role below therefore moves up one step from what
   the first build shipped — 16/17 becomes 17/19, 14/15 becomes 15/17 — and the
   0.012em tracking on `body` stands in for the kerning table the face does not
   have. Neither is decoration; both are the compensations globals.css already
   applies on the site, for the same reason. */

@font-face {
  font-family: "Alte Haas Grotesk";
  src: url("fonts/AlteHaasGrotesk-Regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Alte Haas Grotesk";
  src: url("fonts/AlteHaasGrotesk-Bold.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "KOS Condensed";
  src: url("fonts/KOSCondensed-Regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* -------------------------------------------------------------- the colours

   Lifted from src/app/globals.css. The values are the club's own and are not
   re-cut here; a shade that drifts between the site and the panel is the one
   failure this whole exercise exists to prevent. */

:root {
  /* ground — warm, never pure #fff */
  --cream: #faf7f0;
  --cream-2: #f2ece0;
  --beige-soft: #ece1cd;
  --beige: #e3d5bb;

  /* the logo's two colours, exactly as drawn */
  --navy: #202858;
  --orange: #f88020;

  /* text */
  --ink: #202858;
  --ink-muted: #625b50;
  --ink-on-navy: #faf7f0;

  /* THE PANEL'S ACCENT, and the one colour decision that differs from the site.
     The site keeps navy as its primary and treats orange as a mark; the club
     asked for the panel to be led by orange. --orange itself cannot carry a
     word on a light ground — it is 2.42:1 on cream — so the accent here is the
     site's own --orange-deep, the same hue at 29% lightness, which measures
     6.09:1 on cream, 5.54:1 on cream-2, 5.03:1 on beige-soft and 4.50:1 on
     beige. It carries buttons, links, focus rings and required markers.

     --accent-deep is the same colour a step further down, for the hover state
     of a filled button: cream on it is 8.60:1 against 6.09:1, so the button
     gets darker rather than lighter and the label never loses ground. */
  --accent: #904c04;
  --accent-deep: #6f3a03;

  /* A red deep enough to read on cream at 7.78:1. Destructive actions only. */
  --danger: #96201f;
  --danger-tint: #f6e9e6;

  /* THE HAIRLINE, SINGULAR. There were two — a 14% rule for separators and a
     28% one for the few places that needed to be firmer — and the 14% one is
     gone with the twenty-odd borders it drew. The club asked for lesser
     separation; a token whose every consumer has been deleted is not a token,
     it is a temptation to draw the lines again. What is left is used three
     times, and each time it is doing something a space cannot: the outline of a
     state chip, the border of a field that cannot be typed in, and the left
     rule that colours a flash. */
  --rule-strong: rgba(32, 40, 88, 0.28);
  /* And the hairline that survives the orange band. Navy at 32% over the wash
     reads as a warm brown line rather than disappearing the way a cream one
     would; it is 1.69:1 against the band's floor, which is to say it is a
     separator and never a boundary something is identified by. */
  --rule-on-band: rgba(32, 40, 88, 0.32);

  --field-bg: #fffdf8;

  /* The lift a focused field takes. A shade above --field-bg and still warm:
     CLAUDE.md's first design rule is off-white, never pure #fff, and a field
     that goes true white on focus is the one moment the panel would stop
     looking like the site it edits. */
  --field-bg-focus: #fffefb;

  /* THE FIELD'S OUTLINE IS A COMPONENT BOUNDARY, NOT A HAIRLINE, so it is not
     --rule-strong. A text input here sits on #fffdf8 against a #faf7f0 page —
     1.02:1, which is to say invisible — so the border is the only thing that
     says a field is there, and WCAG 1.4.11 asks 3:1 of it. --rule-strong
     measures 1.74:1 against the field and would have failed. Navy at 55%
     resolves to #8488a0 and measures 3.44:1 against the field ground and
     3.27:1 against the page. --field-line-strong is the hover step. */
  --field-line: rgba(32, 40, 88, 0.55);
  --field-line-strong: rgba(32, 40, 88, 0.72);

  /* faces. TWO, AND ONLY TWO — the club's instruction, word for word: Alte for
     titles and the wordmark, the condensed face for all else. A monospace stack
     used to sit here for the one `<code>` on event.php; it was a third family
     on a two-family brief, so it went with the rest of the surplus. See `code`
     below for what carries that string now. */
  --font: "Alte Haas Grotesk", "Helvetica Neue", Helvetica, Arial, sans-serif;
  --font-condensed: "KOS Condensed", "Arial Narrow", "Helvetica Neue Condensed", sans-serif;

  --ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

/* ----------------------------------------------------------------- the base */

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

/* THE !IMPORTANT IS LOAD-BEARING AND IS THE ONLY ONE IN THE FILE OUTSIDE THE
   REDUCED-MOTION BLOCK. The browser hides `[hidden]` from its own stylesheet,
   and a user-agent declaration loses to any author declaration whatsoever — so
   the moment an element that carries the attribute also carries a class with a
   `display` on it, the attribute silently stops working. That is not
   hypothetical here: admin.js sets `hidden` on the Set-as-cover button of the
   photograph that already is the cover, and the button carries `.btn`, whose
   `display: inline-flex` beat the user agent and left it on the screen saying
   "Set as cover" about the cover.

   Fixing the one button would have left the trap armed for the next one, and
   the panel hides half a dozen things this way — the crop hint, the crop
   window, the live date reading, and the strip that offers Undo after a delete —
   which is the same trap set a second time, because `.undo` carries a `display`
   of its own and index.php renders it on every page load with `hidden` on it.
   Without this line it would sit on the floor of the dashboard from the moment
   the page arrived, offering to undo nothing. So the attribute is made to mean
   what it says, once, for everything. Anything
   that genuinely has to be reachable while invisible is clipped instead, the
   way the file input in `.add-photos` is. */
[hidden] {
  display: none !important;
}

html {
  -webkit-text-size-adjust: 100%;
}

/* THE PAGE IS A COLUMN, AND THE SESSION LINE IS ITS LAST ROW. This was here to
   keep the closing band off the middle of a short screen; the band is gone, and
   the column stays for the small print that was sitting above it. Without it the
   signed-in line lands wherever the content happens to stop, which on the login
   screen or an empty Events list is halfway up a field of cream. `.wrap` takes
   the slack, so the line is always on the floor and the content still starts at
   the top. */
body {
  margin: 0;
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  min-height: 100svh;
  background: var(--cream);
  color: var(--ink);

  /* THE BODY FACE IS THE CONDENSED ONE — the club's direction, and the site's
     own arrangement. See the long note above the @font-face rules. */
  font-family: var(--font-condensed);

  /* Body — 17 → 19, one step up from the 16 → 17 the first build shipped in
     Alte Haas, because the face under it changed and this one reads small. The
     site runs 15 → 19 → 21 across four breakpoints; the panel has one, so it
     takes the top two thirds of that ramp. */
  font-size: 1.0625rem;
  line-height: 1.6875rem;

  /* The tracking is the missing kerning table, not a style. globals.css sets
     the same 0.012em on the site's body for the same reason: set solid, this
     face's caps and round pairs collide at reading size. */
  letter-spacing: 0.012em;

  /* NO FAUX WEIGHTS, EVER. KOS Condensed is a single 400 and any rule that
     reached it with a bold would get a smeared outline. Refused here once
     rather than remembered everywhere — which matters far more now that the
     condensed face carries the whole page rather than the figures in it. */
  font-synthesis-weight: none;

  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* The heading face, claimed on the elements rather than only on the roles —
   `.legend` and `.card-title` are headings whatever class they carry, and a
   heading that quietly inherited the body face would be the one place the
   reversal above went wrong without anybody noticing. */
h1,
h2,
h3 {
  margin: 0;
  font-family: var(--font);
  font-weight: 700;
  letter-spacing: normal;
  text-wrap: balance;
}

p {
  margin: 0;
  text-wrap: pretty;
}

ul {
  margin: 0;
}

img {
  max-width: 100%;
}

a {
  color: inherit;
}

/* ONE `<code>` ON THE WHOLE PANEL — event.php prints the address a published
   event will live at, `/events/<slug>/`. It used to be set in a system monospace
   stack on the argument that an l and a 1 must never be confusable in a path.
   Two fonts is the brief, so the stack is gone; what the string actually needs
   survives without it. A slug is lowercase words and hyphens, not a token, and
   the reader has just typed it: the ambiguity the mono was insuring against is
   not in this string. What is left is the tint, which is what marks it as a
   literal rather than as prose, plus tabular figures and a little extra tracking
   so the segments of a path stay separable in a condensed face. --ink-muted on
   --cream-2 is 5.70:1. */
code {
  font-size: 1em;
  letter-spacing: 0.05em;
  font-variant-numeric: tabular-nums;
  background: var(--cream-2);
  padding: 0.1em 0.35em;
  border-radius: 0.25em;
  word-break: break-all;
}

/* THE SITE'S ONE LINK TREATMENT, ported from the `.text-underline-simple`
   utility in globals.css: always underlined, the colour of the underline
   revealed on hover over 300ms. It is the reason a link on this panel feels
   like a link on the site rather than like a link in a CMS. */
.link,
.back,
.aside a,
.foot a,
.hint a,
.note a,
.legend-note a,
.login-note a,
.err a {
  color: var(--accent);
  text-decoration-line: underline;
  text-decoration-color: transparent;
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
  transition: text-decoration-color 300ms var(--ease-out);
}
.link:hover,
.back:hover,
.aside a:hover,
.foot a:hover,
.hint a:hover,
.note a:hover,
.legend-note a:hover,
.login-note a:hover,
.err a:hover {
  text-decoration-color: currentColor;
}

/* The focus ring is the accent, offset so it never sits on the control it is
   marking. `outline` and not a box-shadow, so it follows a wrapped inline link
   around the corner instead of boxing it. #904c04 against cream is 6.09:1,
   twice what WCAG 1.4.11 asks of a non-text indicator. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 2px;
}

/* ------------------------------------------------------------- type roles

   src/lib/type.ts, ported. The site's DISPLAY_H1 runs to 100px and is a
   magazine cover; the roles below start one step down from it, because the
   largest thing on an admin screen is a page title and not a statement. The
   shape is the site's: display roles state their weight and their tracking,
   and the tracking tightens as the size grows, because the defect it corrects
   is optical. */

/* THE DISPLAY ROLES ARE THE ONLY PLACE ALTE HAAS APPEARS, apart from the
   wordmark. All three name the family outright rather than leaning on the h1–h3
   rule above, and they keep naming it now that every element they land on
   happens to be a heading. `.progress-label` was a `<p>`, and it sat in this
   list until the screen it belonged to was taken out; a role that only works
   when it lands on the right tag is a role that will one day land on the wrong
   one, and that one had already proved it. */

/* Display 1 — page titles. type.ts DISPLAY_H2. */
.page-title,
.login h1 {
  font-family: var(--font);
  font-size: 2rem;
  line-height: 2.25rem;
  letter-spacing: -0.02em;
  font-weight: 700;
}

/* Display 2 — section heads inside a form. type.ts DISPLAY_H3. */
.legend {
  font-family: var(--font);
  font-size: 1.5rem;
  line-height: 1.9375rem;
  letter-spacing: -0.015em;
  font-weight: 700;
}

/* Display 3 — the head of a panel that is not a section. */
.danger-zone h2 {
  font-family: var(--font);
  font-size: 1.1875rem;
  line-height: 1.5rem;
  letter-spacing: -0.01em;
  font-weight: 700;
}

/* Small print — meta lines, hints, captions. type.ts SMALL, which runs 15 → 17
   and is exactly what this is now: 14 → 15 was the Alte Haas measurement and it
   went up a step with the face, like everything else that carries a sentence. */
.hint,
.note,
.err,
.foot,
.aside,
.legend-note,
.login-lede,
.login-note,
.card-dek,
.card-meta,
.filter-count,
.shot-label,
.shot-crop-hint,
.token,
.progress-banner-detail,
/* The undo strip's sentence. It sits on navy rather than on cream, which is a
   colour question and not a size one — as small print it takes the same two
   steps as every other notice on the panel, from the one place they are set. */
.undo p,
.file-name,
.danger-zone p,
/* event.php sets the announced flag's hint as an `em` inside the label rather
   than as the `p.hint` kos_field() would have written. It is the same sentence
   doing the same job, so it is named in the same place — see `.choice-option`
   below for why the two spellings are held together rather than drawn twice. */
.choice-option em {
  font-size: 0.9375rem;
  line-height: 1.4375rem;
}

/* Eyebrow — 12px tracked caps, exactly as type.ts sets it. It is the label
   role: the panel's own name for a thing, sitting above or beside the thing
   itself. Neither the family nor the weight is stated any more — the page is
   already this face at its only weight, and a declaration that restates the
   inherited value is a line that has to be kept in step for no return. What is
   here is what actually differs from the copy around it: the size, the caps and
   the tracking those caps need. */
.wordmark-role,
.login-mark,
.status,
.shot-badge,
.preview-title {
  font-size: 0.75rem;
  line-height: 1.2;
  letter-spacing: 0.2em;
  text-transform: uppercase;
}

/* Figures — tabular widths and the wider tracking a column of digits wants,
   ported from the `.font-condensed` utility in globals.css. Dates, counts,
   character counters, session times: everything that should line up in a
   column rather than read as a sentence. One fixed advance per digit is why a
   list of dates scans down the page instead of shimmering.

   The face used to be the mark of this role and now cannot be — the whole page
   is set in it. Tabular figures and the step up in tracking are what is left,
   and they are the two properties that were ever doing the work. */
time,
.card-count,
.counter,
/* Both of these are the panel showing somebody a value it worked out for them —
   a written-out date, a column of session times — and both are figures. The
   `<strong>` the PHP wraps them in resolves to 400 here rather than smearing,
   which is why neither needed a weight to be marked out. */
.date-reads strong,
.preview-list dd {
  letter-spacing: 0.04em;
  font-variant-numeric: tabular-nums;
}

/* -------------------------------------------------------------- the band

   The one thing that differs from the site, and the club's own instruction: an
   orange painted wash where the site lays its navy ink wash. Structurally it is
   src/components/site/Footer.tsx — a relative, isolated, overflowing band with a
   texture layer and a ground layer stacked behind the content — and the
   isolation is not decoration, it is what stops the blend below from reaching
   the page underneath.

   THERE IS ONE OF THEM NOW. A second, empty band used to close the page the way
   the site's navy footer does; the club has asked for it to come out, so the
   panel opens on the wash and ends on cream. Nothing else moved with it — the
   session line was always on cream above the band rather than on it, so removing
   the band left it exactly where it already read.

   THE HEIGHT IS NO LONGER THE SITE'S HEADER. It was 82px at 1280, matched to the
   site so the page could be held between two identical rules; with the closing
   rule gone the club asked for the remaining one to carry that weight, so it is
   112px at width: 34 + 44 + 34, and nothing here forces it with a `height`. The
   nav links are 44px because a thumb needs 44px and the padding is the rest of
   it. The wordmark is sized to come in under that 44 rather than over it (24 + 4
   + 14 = 42 at width), so the row is set by the touch target and the padding,
   never by the type, and the band cannot creep further the next time the mark is
   retouched.

   ON A PHONE IT IS 144, AND IT IS TWO ROWS, NOT ONE. Below 812 the band-inner
   wraps: the wordmark takes a row, the nav takes another, and 20 + 44 + 16 + 44
   + 20 is the 144. It was 136 before this change, never the 82 the desktop was —
   which is why the phone takes a 4px step where the desktop takes 30. The band
   was already the thick one down there.

   THE INK ON THE BAND IS NAVY, AND THAT INVERSION IS MEASURED, NOT CHOSEN. On
   the navy wash the site sets cream because cream is the only thing that
   survives it. On this wash cream measures 3.90:1 at its worst and fails;
   navy measures 5.38:1 at its worst and passes. Nothing cream goes on this
   band. Neither does the accent — #904c04 against the wash's brightest passage
   is 5.04:1 but against its floor is 2.52:1, under even the 3:1 a graphic
   needs, so the active-page rule under a nav link is navy too.

   THE FLOOR IS WHY THE 5.38 IS TRUE. The supplied wash is a watercolour and has
   no single colour: its darkest passages are rgb(215,68,34) and navy on that
   is 3.15:1, a fail, over about 2% of the artwork. Rather than washing the
   whole band out with a pale scrim — cream at 30% is what it would take, and
   it turns the orange to peach — the texture is blended `lighten` over the
   club's own logo orange. Every channel is floored at #f88020 and nothing
   brighter is touched: 44% of pixels move at all, by an average of under three
   levels per channel across the three, so the painted strokes survive and the
   worst case on the whole band becomes navy-on-#f88020, which is 5.38:1 — the
   same measured pair the site already uses the other way round.

   MUTED IS NOT AN OPACITY HERE, EVER. Navy knocked back to 62% composites to
   2.95:1 against the band's floor and fails outright. Every word on this band is
   solid navy; the difference between the section you are in and the ones you are
   not is the 2px rule under it, present or absent — which is a difference in
   more than colour, as WCAG 1.4.1 asks, and `aria-current="page"` carries it for
   anyone not looking. It used to be 700 against 400 as well. It cannot be: this
   nav is set in KOS Condensed now and KOS Condensed has one weight. */

.band {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background-color: var(--orange);
  color: var(--navy);
}

.band-wash {
  position: absolute;
  inset: 0;
  background-image: url("band-orange.webp");
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  mix-blend-mode: lighten;
}

.band-inner {
  position: relative;
  z-index: 1;
  max-width: 60rem;
  margin: 0 auto;
  /* 20px top and bottom on a phone, 34px at width — see the note above for the
     arithmetic. The phone gets the smaller step on purpose: below 812 the
     wordmark and the nav are already on two rows, so this padding is spent
     twice over and 20 puts the band at 144 where 34 would put it at 172, which
     is a fifth of a 390 screen given over to decoration on a form the club
     fills in at the side of a track. */
  padding: 1.25rem 1.25rem;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 1rem 2rem;
}

/* THE MARK REPLACED THE NAME, at the club's direction, so this is now a row
   rather than two stacked lines: the running figure with "Admin" beside it.

   IT IS ALSO HALF OF WHAT HOLDS THE BAND AT 112px. The band has no height of its
   own — it is the tallest thing in it plus the padding — so the mark is capped
   at 44px, the same as a nav link's touch target. Any taller and the mark, not
   the padding, would set the height, and the band would drift every time the
   mark is retouched instead of staying at the number the club asked for. */
.wordmark {
  display: flex;
  align-items: center;
  gap: 0.625rem;
  text-decoration: none;
  color: inherit;
}

.wordmark-logo {
  display: block;
  width: auto;
  height: 44px;
  /* The file is a navy silhouette on transparency, so nothing here recolours
     it. On the band's floor that navy is 5.38:1 — see the band note above. */
}

/* "Admin" now sits BESIDE the mark rather than under the club's name, so it is
   the only word in the masthead and can carry the tracked-caps treatment the
   panel uses for every other label. Solid navy, never a tint — see the band
   note above; opacity on this ground fails. */
.wordmark-role {
  display: block;
  color: var(--navy);
  font-size: 0.8125rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  white-space: nowrap;
}

.band-nav ul {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0 1.5rem;
}

.band-nav a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0.25rem 0;
  text-decoration: none;
  color: var(--navy);
  font-size: 0.9375rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  border-bottom: 2px solid transparent;
  transition: border-color 300ms var(--ease-out);
}
.band-nav a:hover {
  border-bottom-color: var(--rule-on-band);
}

/* The section you are in, and the rule is now the whole of it — see the band
   note above for why the 700 that used to sit here had to go. A 2px navy rule
   against no rule at all is a difference of presence, not of colour, so it
   still answers 1.4.1 on its own. */
.band-nav a[aria-current="page"] {
  border-bottom-color: var(--navy);
}

/* Sign out is not a section, so it is set apart from the run of them rather
   than faded out of it. Same solid navy, same size: it is quieter because of
   where it is, not because it has lost contrast.

   The divider is a rule at width and a gap on a phone. Once the list wraps, a
   vertical rule ends up at the START of the second line, pointing at nothing;
   pushing the item to the far end of whatever line it lands on says the same
   thing without drawing anything. */
.band-out {
  margin-left: auto;
}

/* THE SKIP LINK IS NOT IN THE BAND, and the descendant selector this rule used
   to carry — `.band .skip` — therefore matched nothing at all. lib/render.php
   echoes it immediately after `<body>` and before `kos_band()`, so it is the
   band's SIBLING, not its child; it only looks as though it is inside because
   it is `position: absolute` at `top: 0.75rem` with `z-index: 99`, which lands
   it on top of a band 112px tall. Where it PAINTS and where it SITS are two
   different questions, and only the second one the cascade can see.

   So the ring has to be claimed by the link itself. It matters because the
   ground under it is the wash: `outline-offset: 3px` opens a gap that shows the
   band through, and the ring is drawn outside that, so both of the ring's
   neighbours are orange. The accent measures 2.51:1 there and fails WCAG
   1.4.11's 3:1 outright — which is precisely what was shipping. Navy is 5.38:1
   against the band's floor, and 13.03:1 in the unlikely event a taller viewport
   ever drops the ring's lower edge onto the cream page instead. */
.skip:focus-visible,
.band :focus-visible {
  outline-color: var(--navy);
}

/* ------------------------------------------------------------------- shell */

/* Caps and tracking rather than a bold, for the reason every other emphasis in
   this file changed: the face has one weight. Cream on navy is 13.03:1. */
.skip {
  position: absolute;
  left: -9999px;
  top: 0.75rem;
  z-index: 99;
  padding: 0.75rem 1.25rem;
  border-radius: 2.5em;
  background: var(--navy);
  color: var(--ink-on-navy);
  text-decoration: none;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.skip:focus {
  left: 0.75rem;
}

/* THE FORM'S DEFAULT BUTTON, WHICH HAS TO EXIST AND MUST NOT BE SEEN.
   Enter in any single-line field activates the FIRST submit button in the form.
   On the event editor that was Upload, halfway down the page, so pressing Enter
   after the headline posted a staging request with no files: no dialog, no
   flash, no error, nothing saved, and a page that came back identical. The cure
   is a Save button that comes first in the source.
   Not display:none and not disabled — both are skipped when the browser looks
   for a default button, which is the one job this has. Clipped, so it is out of
   the page for everyone including a screen reader, which already has the real
   Save at the foot of the form. */
.default-submit {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* `flex: 1 0 auto` is what pins the session line to the floor — see `body`. */
.wrap {
  flex: 1 0 auto;
  width: 100%;
  max-width: 60rem;
  margin: 0 auto;
  padding: 2rem 1.25rem 4rem;
}

/* The signed-in line and the session warning — now the last thing on the page,
   and unmoved by the closing band's removal, because they were never on it. This
   is where they already read best: quiet small print at the end of the document,
   on cream, --ink-muted at 6.27:1.

   The hairline that used to sit on top of it is gone. It was separating small
   print from the content above it, which is what the 4rem of clearance under
   `.wrap` was already doing. */
.foot {
  flex: none;
  width: 100%;
  max-width: 60rem;
  margin: 0 auto;
  padding: 0.5rem 1.25rem 2rem;
  color: var(--ink-muted);
}

.back {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  min-height: 44px;
  margin-bottom: 0.5rem;
}

/* The rhythm under a page title, and most of the reason the panel does not need
   a box around anything: scale and air do the separating. */
.page-title {
  margin: 0 0 2rem;
}
/* A lede belongs to the title above it, so the title gives up most of its own
   space when one follows. Without :has() the pair simply sits a step further
   apart, which is why nothing else depends on it. */
.page-title:has(+ .page-lede) {
  margin-bottom: 0.75rem;
}

.page-lede {
  color: var(--ink-muted);
  margin: 0 0 2rem;
  max-width: 42rem;
}

/* A title with an action beside it. The action drops under the title on a
   phone rather than squeezing it. */
.page-head {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem 2rem;
  align-items: flex-start;
  justify-content: space-between;
  margin-bottom: 2rem;
}
.page-head .page-title,
.page-head .page-lede {
  margin-bottom: 0;
}
.page-head .page-title:has(+ .page-lede) {
  margin-bottom: 0.75rem;
}

/* A section head opens on space alone now. The rule that used to sit over it was
   saying the same thing the 30px jump in type size already says, one line
   earlier — so the clearance took the rule's job and the rule went. Same trade
   in `.aside`, `.actions`, the session rows, the photo strip, the reading and
   the login note: the club asked for lesser separation, and this is most of it.
   The distance went up as the line came out, because a boundary that is space
   has to be bigger than a boundary that is drawn. */
.legend {
  margin: 4rem 0 0;
}
.legend + * {
  margin-top: 1rem;
}
.legend-note {
  color: var(--ink-muted);
  margin: 0.5rem 0 0;
  max-width: 40rem;
}
.legend-note + * {
  margin-top: 1.5rem;
}

.aside {
  margin-top: 4rem;
  color: var(--ink-muted);
}

.empty {
  color: var(--ink-muted);
  padding: 2.5rem 0;
}

/* ----------------------------------------------------------------- notices */

/* A flash is a rule and a tint, not a card: the same left-hand rule the site
   uses to open a column, in the colour of whatever happened. */
.flash {
  padding: 0.875rem 1.25rem;
  margin-bottom: 1rem;
  border-left: 3px solid var(--rule-strong);
  background: var(--cream-2);
}
.flash-ok {
  border-left-color: var(--navy);
  background: var(--beige-soft);
}
.flash-error {
  border-left-color: var(--danger);
  background: var(--danger-tint);
  color: var(--danger);
}
.flash-note {
  border-left-color: var(--accent);
  background: var(--beige-soft);
}

.token {
  color: var(--ink-muted);
  padding: 0.75rem 1rem;
  background: var(--cream-2);
  margin-bottom: 1.5rem;
}
.token-warn {
  color: var(--accent);
  background: var(--beige-soft);
  border-left: 3px solid var(--accent);
}
.token-error {
  color: var(--danger);
  background: var(--danger-tint);
  border-left: 3px solid var(--danger);
}

/* ------------------------------------------------- what the site is doing

   ┌────────────────────────────────────────────────────────────────────────┐
   │  A BANNER UNDER THE MASTHEAD, ON EVERY PAGE, THAT ANSWERS ONE THING:   │
   │  "I pressed Save. Is anything happening?"                              │
   └────────────────────────────────────────────────────────────────────────┘

   This was one line of grey text on the dashboard, and only on the dashboard.
   That was the right size for a thing that worked. It did not work: every
   update the club asked for failed at a step nothing on this screen could see,
   and the line said the site was up to date, and there was nowhere else to
   look. Somebody who saved an event and then went to the club-details page had
   no way of knowing whether the first change had landed.

   So it is bigger, it follows you across the panel, it sticks to the top of the
   window when you scroll, and it counts through three named steps instead of
   saying "working". The size is the point: this is the only thing on any screen
   that knows whether the website is currently changing.

   THE WORDS ARE THE STATE, and the colour is a second signal on top of a first
   one rather than the only one. Every one of the four reads differently — up to
   date, step 2 of 3, could not be updated — so it still says which state it is
   in on a greyscale screen, which is what WCAG 1.4.1 asks and what a status
   line that changes colour and nothing else fails. The dot and the bar are the
   same argument again: they are there so the eye finds it and can see it move,
   never so they can tell one state from another.

   Measured on cream: idle --ink-muted 6.27:1, working --accent 6.09:1, live
   --navy 13.03:1, error --danger 7.78:1. Live is navy rather than a green or a
   second accent because `.flash-ok` already answers "this worked" in navy, and
   two vocabularies for one idea is one too many. */

.progress-banner {
  position: sticky;
  top: 0;
  z-index: 30;
  border-bottom: 1px solid var(--rule);
  background: var(--cream-2);
  color: var(--ink-muted);
}

.progress-banner-inner {
  display: flex;
  align-items: flex-start;
  gap: 0.625rem;
  width: 100%;
  max-width: 60rem;
  margin: 0 auto;
  padding: 0.6875rem 1.25rem;
}

/* The dot is a real element and not a pseudo-element, which is the opposite of
   what the old line did and for a reason that changed under it: the script used
   to rewrite the whole line with textContent, so any child would have been
   thrown away on the first poll. It now writes into two named spans instead, so
   a sibling survives — and a real element can carry the animation without the
   state selector having to reach through a `::before`.

   flex-start and a measured offset rather than centre: the detail sentence
   wraps on a phone, and a dot centred on a two-line block floats into the gap
   between the lines. 1.375rem is the label's line box and the dot is 0.5rem, so
   half the difference lands it on the middle of the FIRST line and keeps it
   there however many lines follow. */
.progress-banner-dot {
  flex: none;
  width: 0.5rem;
  height: 0.5rem;
  margin-top: 0.4375rem;
  border-radius: 50%;
  background: currentColor;
}

.progress-banner-text {
  flex: 1 1 auto;
  margin: 0;
  min-width: 0;
}

.progress-banner-label {
  display: block;
  font-family: var(--font-condensed);
  font-size: 1rem;
  line-height: 1.375rem;
  letter-spacing: 0.01em;
  color: var(--ink);
}

.progress-banner-detail {
  display: block;
  font-family: var(--font-condensed);
  font-size: 0.9375rem;
  line-height: 1.375rem;
  color: var(--ink-muted);
}

/* Empty on most states, and an empty block still takes a line box. */
.progress-banner-detail:empty {
  display: none;
}

.progress-banner-dismiss {
  flex: none;
  width: 1.75rem;
  height: 1.75rem;
  margin: -0.1875rem -0.375rem 0 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--ink-muted);
  font-size: 1.25rem;
  line-height: 1;
  cursor: pointer;
}
.progress-banner-dismiss:hover {
  background: var(--beige-soft);
  color: var(--ink);
}

/* THE BAR. Three pixels, full bleed, and it is the only thing on the screen
   that moves while an update runs.

   It is under the words rather than beside them so that it can be the full
   width of the window: a progress bar that is honest about being slow needs
   room to show a small change, and 3px across a phone screen shows a percent.
   The track is always drawn — a bar that appears and disappears makes the
   banner jump by its own height every time an update starts. */
.progress-track {
  height: 3px;
  background: var(--beige-soft);
}

.progress-fill {
  display: block;
  height: 100%;
  width: 0;
  background: var(--accent);
  transition: width 600ms var(--ease-out);
}

/* THE WIDTH IS A STEP, NOT A STYLE, AND THAT IS FORCED ON US.

   The panel sends `style-src 'self'` with no 'unsafe-inline'. Under that policy
   a `style="width:63%"` on the fill is accepted into the DOM and then refused:
   the attribute reads back exactly as written and the computed width stays 0.
   Assigning `.style.width` from JavaScript fails identically. Measured in a
   browser, not assumed — and the failure is silent, which on a progress bar
   means it simply never moves.

   So the percentage is rounded to the nearest five, put on the banner as a data
   attribute, and matched here. Twenty-one rules for what one inline style would
   have done, and it is the only version that survives the policy. Nobody reads
   a bar to finer than five percent, and the transition above slides between the
   steps, so it still moves as one motion rather than in visible jumps. */
.progress-banner[data-percent="0"] .progress-fill {
  width: 0%;
}
.progress-banner[data-percent="5"] .progress-fill {
  width: 5%;
}
.progress-banner[data-percent="10"] .progress-fill {
  width: 10%;
}
.progress-banner[data-percent="15"] .progress-fill {
  width: 15%;
}
.progress-banner[data-percent="20"] .progress-fill {
  width: 20%;
}
.progress-banner[data-percent="25"] .progress-fill {
  width: 25%;
}
.progress-banner[data-percent="30"] .progress-fill {
  width: 30%;
}
.progress-banner[data-percent="35"] .progress-fill {
  width: 35%;
}
.progress-banner[data-percent="40"] .progress-fill {
  width: 40%;
}
.progress-banner[data-percent="45"] .progress-fill {
  width: 45%;
}
.progress-banner[data-percent="50"] .progress-fill {
  width: 50%;
}
.progress-banner[data-percent="55"] .progress-fill {
  width: 55%;
}
.progress-banner[data-percent="60"] .progress-fill {
  width: 60%;
}
.progress-banner[data-percent="65"] .progress-fill {
  width: 65%;
}
.progress-banner[data-percent="70"] .progress-fill {
  width: 70%;
}
.progress-banner[data-percent="75"] .progress-fill {
  width: 75%;
}
.progress-banner[data-percent="80"] .progress-fill {
  width: 80%;
}
.progress-banner[data-percent="85"] .progress-fill {
  width: 85%;
}
.progress-banner[data-percent="90"] .progress-fill {
  width: 90%;
}
.progress-banner[data-percent="95"] .progress-fill {
  width: 95%;
}
.progress-banner[data-percent="100"] .progress-fill {
  width: 100%;
}

.progress-banner[data-state="idle"] .progress-fill {
  background: var(--rule-strong);
}
.progress-banner[data-state="live"] {
  background: var(--cream);
  color: var(--navy);
}
.progress-banner[data-state="live"] .progress-fill {
  background: var(--navy);
}
.progress-banner[data-state="working"] {
  background: var(--beige-soft);
  color: var(--accent);
}
.progress-banner[data-state="error"] {
  background: var(--danger-tint);
  color: var(--danger);
}
.progress-banner[data-state="error"] .progress-fill {
  background: var(--danger);
}
.progress-banner[data-state="error"] .progress-banner-label {
  color: var(--danger);
}

/* MOVEMENT, AND NOTHING THAT COSTS ANYTHING TO MAKE.

   Working is the one state that has to read as ongoing rather than as a fact,
   and the honest way to say so is that something is moving. What moves is the
   opacity of an 8px dot — a compositor property on a single tiny box, which is
   as cheap as motion gets and cannot reflow anything — and not a spinner or a
   sweeping gradient this file would have to lay out sixty times a second on a
   phone at the side of a track.

   IT MAY SIMPLY STOP, and it is built so that it can. The keyframes open and
   close at full opacity, so when the reduced-motion block collapses every
   animation in the file to nothing, what is left is a solid dot rather than a
   half-faded one. The state is still in the words either way. */
@keyframes progress-banner-working {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.3;
  }
}
.progress-banner[data-state="working"] .progress-banner-dot {
  animation: progress-banner-working 1400ms var(--ease-out) infinite;
}

/* ------------------------------------------------------ asking, and telling

   ONE BOX, TWO JOBS: a question before something happens, and a report after.

   It exists because the flash line was missed. Not hypothetically — every event
   the club tried to add failed, and the reports back were "I pressed Save and
   nothing happened", from screens that had printed a sentence saying otherwise
   above a fold the reader had already scrolled past. A line at the top of a long
   form is not a message. A box you have to close is.

   It replaces window.confirm, which cannot carry two sentences, prints the
   domain above whatever you wrote, and labels the two answers "OK" and "Cancel"
   — two words that describe nothing. Here the buttons say what they do.

   The dialog element brings the top layer, the focus trap, Escape and the inert
   background with it, so none of that is reimplemented here and none of it can
   be got subtly wrong. */

.ask {
  width: min(28rem, calc(100vw - 2rem));
  padding: 0;
  border: 1px solid var(--rule-strong);
  border-radius: 0.75rem;
  background: var(--cream);
  color: var(--ink);
  box-shadow: 0 1.5rem 3rem rgba(32, 40, 88, 0.22);
}

.ask::backdrop {
  background: rgba(32, 40, 88, 0.42);
}

.ask-inner {
  padding: 1.5rem;
}

.ask-title {
  margin: 0 0 0.625rem;
  font-family: var(--font);
  font-weight: 700;
  font-size: 1.25rem;
  line-height: 1.75rem;
  color: var(--ink);
}

.ask-body {
  margin: 0;
  font-family: var(--font-condensed);
  font-size: 1.0625rem;
  line-height: 1.625rem;
  color: var(--ink-muted);
}

/* THE SAFE ANSWER IS ON THE LEFT AND THE ACT IS ON THE RIGHT once there is room
   for a row, and they are in that order in the markup, so tab order and reading
   order agree with the layout.

   ON A PHONE THEY STACK AND THE ORDER INVERTS — column-reverse puts the act on
   top and the safe answer at the bottom. That is the right way round and not an
   accident of the property: the bottom of a phone screen is where a thumb
   rests, so the easiest button to hit by mistake should be the one that changes
   nothing. Tab order is untouched by any of this — it follows the markup, and
   the markup still reads "keep it, delete it". */
.ask-actions {
  display: flex;
  flex-direction: column-reverse;
  gap: 0.625rem;
  margin-top: 1.5rem;
}

.ask-actions .btn {
  width: 100%;
  justify-content: center;
}

/* A rule of the state's own colour along the top, so the box says which of the
   three kinds it is before a word of it is read. */
.ask.is-tell {
  border-top: 3px solid var(--navy);
}
.ask.is-tell.is-error {
  border-top-color: var(--danger);
}
.ask.is-tell.is-note {
  border-top-color: var(--accent);
}
.ask.is-danger {
  border-top: 3px solid var(--danger);
}

/* -------------------------------------------------- checking the setup

   A LIST OF READINGS, EACH WITH ITS OWN FIX UNDER IT.

   It is the only screen in the panel written for somebody trying to repair
   something rather than to publish something, and it is laid out accordingly:
   the reading is the answer, the fix is the next sentence, and nothing is
   hidden behind a disclosure that has to be found first. A diagnostic that
   makes you click to see the problem is a diagnostic nobody reads.

   The verdict is carried by a rule down the left AND by the words of the fix,
   never by colour alone — a row that is failing says so in a sentence, so the
   page still works read aloud or in greyscale. */

.check-list {
  list-style: none;
  margin: 1.5rem 0 0;
  padding: 0;
}

.check-row {
  padding: 0.9375rem 0 0.9375rem 0.9375rem;
  border-left: 3px solid var(--rule-strong);
  border-bottom: 1px solid var(--rule);
}
.check-row[data-verdict="warn"] {
  border-left-color: var(--accent);
}
.check-row[data-verdict="bad"] {
  border-left-color: var(--danger);
  background: var(--danger-tint);
}

.check-name {
  margin: 0;
  font-family: var(--font);
  font-weight: 700;
  font-size: 1rem;
  line-height: 1.5rem;
  color: var(--ink);
}

.check-reading {
  margin: 0.125rem 0 0;
  font-family: var(--font-condensed);
  font-size: 1rem;
  line-height: 1.5rem;
  color: var(--ink-muted);
}
.check-row[data-verdict="bad"] .check-reading {
  color: var(--danger);
}

.check-fix {
  margin: 0.5rem 0 0;
  font-family: var(--font-condensed);
  font-size: 0.9375rem;
  line-height: 1.5rem;
  color: var(--ink);
}

.check-heading {
  margin: 2.5rem 0 0;
  font-family: var(--font);
  font-weight: 700;
  font-size: 1.25rem;
  line-height: 1.75rem;
  color: var(--ink);
}

.check-empty {
  margin: 0.5rem 0 0;
  font-family: var(--font-condensed);
  font-size: 1rem;
  line-height: 1.5rem;
  color: var(--ink-muted);
}

.problem-list {
  list-style: none;
  margin: 1rem 0 0;
  padding: 0;
}

.problem {
  padding: 0.75rem 0;
  border-bottom: 1px solid var(--rule);
}

.problem-when {
  margin: 0;
  font-family: var(--font-condensed);
  font-size: 0.875rem;
  line-height: 1.25rem;
  color: var(--ink-muted);
}

.problem-where {
  margin-left: 0.5rem;
  padding: 0.0625rem 0.375rem;
  border-radius: 0.25rem;
  background: var(--beige-soft);
  color: var(--ink);
}

/* THE ONE PLACE IN THE PANEL THAT PRINTS MACHINE TEXT. A monospace face and a
   scroll rather than a wrap, because these are exception messages and API
   responses: they carry paths and quoted fragments where a line break in the
   wrong place changes what the line says. It scrolls in its own box so that a
   long one cannot push the page sideways. */
.problem-what {
  margin: 0.25rem 0 0;
  overflow-x: auto;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 0.8125rem;
  line-height: 1.375rem;
  color: var(--ink);
}

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

/* A BUTTON IN A FACE WITH ONE WEIGHT. `font-weight: 700` used to be most of what
   made these read as buttons rather than as links in a box, and it is not
   available any more — asked of KOS Condensed it would be synthesised, and
   `font-synthesis-weight: none` on `body` correctly refuses. Caps and 0.06em of
   tracking do the same work, and they are what the site's own buttons already
   wear: every Button on src/ is uppercase (see components/site/Button.tsx). The
   size goes up a step with everything else the face swap touched. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  font: inherit;
  font-size: 1rem;
  line-height: 1.2;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  padding: 0.75rem 1.5rem;
  /* The house pill. */
  border-radius: 2.5em;
  border: 1px solid var(--field-line);
  background: transparent;
  color: var(--ink);
  cursor: pointer;
  text-decoration: none;
  /* A thumb target, not a mouse target. */
  min-height: 44px;
  transition:
    background-color 150ms var(--ease-out),
    border-color 150ms var(--ease-out),
    color 150ms var(--ease-out);
}
.btn:hover {
  border-color: var(--navy);
  background: var(--cream-2);
}

.btn-primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--cream);
}
.btn-primary:hover {
  background: var(--accent-deep);
  border-color: var(--accent-deep);
  color: var(--cream);
}

/* The quiet one is a step smaller and no longer a step lighter — there is no
   lighter. Scale carries the whole difference. */
.btn-quiet {
  font-size: 0.9375rem;
  padding: 0.5rem 1.125rem;
  background: var(--cream-2);
  border-color: var(--field-line);
}
.btn-quiet:hover {
  background: var(--beige-soft);
}

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

/* Disabled is a solid, not an opacity. A faded label is a label whose contrast
   nobody has measured; beige under --ink-muted is 4.63:1 and still legible,
   which matters because "why has that gone grey" is a question the club will
   ask of the button itself. */
.btn:disabled,
.btn[aria-disabled="true"] {
  background: var(--beige);
  border-color: transparent;
  color: var(--ink-muted);
  cursor: not-allowed;
}
.btn:disabled:hover,
.btn[aria-disabled="true"]:hover {
  background: var(--beige);
  border-color: transparent;
}

/* A save is in flight — which is now the same sentence as a publish is. */
.btn.is-busy {
  pointer-events: none;
  background: var(--beige);
  border-color: transparent;
  color: var(--ink-muted);
}

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

/* ------------------------------------------------------------------ fields */

.field {
  margin-bottom: 1.5rem;
}

/* A LABEL OUTRANKS ITS OWN HINT WITHOUT A BOLD, and that is the one real
   casualty of the face swap. The first build put the label at 700 and the hint
   at 400 in Alte Haas, and that difference is simply gone: KOS Condensed has one
   weight.

   Caps replace it — the same device the site uses for a label, at the same
   0.12em kind of tracking its eyebrows carry, and the device this file already
   used on `.wordmark-role` and `.shot-badge`. Three things now separate the two
   lines rather than one: caps against sentence case, solid --ink at 13.03:1
   against --ink-muted at 6.27:1, and 14 against 15. Case is the load-bearing
   one, because it survives greyscale, low vision and a bad screen — colour
   alone would not answer 1.4.1, and there is no weight left to fall back on.

   THE CHECKBOX SENTENCES ARE NOT LABELS IN THIS SENSE and are excluded by name.
   Both checkboxes on the panel wrap the whole row in the label — that is the
   point of them, the target is the sentence and not the box — so `label.check`
   and `label.choice-option` are themselves direct children of a `.field` and
   would have been swept up by a bare `.field label`. They hold whole sentences:
   "This hasn't happened yet", and an `em` of guidance under it. A sentence set
   in caps is not a label, it is shouting, and `text-transform` inherits, so the
   `em` would have gone with it. They keep sentence case at reading size. */
.field label:not(.check):not(.choice-option),
.shot-alt-label {
  display: block;
  font-size: 0.875rem;
  line-height: 1.25rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  margin-bottom: 0.5rem;
}

.check > span,
.choice-option > span {
  display: block;
  margin-bottom: 0.375rem;
}

/* The required marker rides inside a caps label, so it gives the tracking back —
   an asterisk with 0.12em after it floats away from the word it belongs to. */
.req {
  color: var(--accent);
  letter-spacing: normal;
}

.hint,
.choice-option em {
  color: var(--ink-muted);
  margin-bottom: 0.5rem;
  max-width: 40rem;
}

input[type="text"],
input[type="date"],
input[type="time"],
input[type="url"],
input[type="tel"],
input[type="email"],
input[type="password"],
input[type="number"],
input[type="search"],
textarea,
select {
  display: block;
  width: 100%;
  font: inherit;
  /* 17px. The floor is 16 — under it, iOS zooms the page when the field takes
     focus — and the step above it is what the condensed face needs to read at
     the same size the old 16px in Alte Haas did. The two constraints happen to
     point the same way, which is lucky rather than clever: had the face wanted
     15 this rule would have had to refuse it. */
  font-size: 1.0625rem;
  color: var(--ink);
  background: var(--field-bg);
  border: 1px solid var(--field-line);
  border-radius: 10px;
  padding: 0.625rem 0.875rem;
  min-height: 44px;
  transition:
    border-color 150ms var(--ease-out),
    background-color 150ms var(--ease-out);
}

textarea {
  resize: vertical;
  line-height: 1.625rem;
  padding-top: 0.75rem;
}

/* A date, a time or a phone number is a figure, and figures line up. The face
   used to change here; the whole page is that face now, so what is left is the
   part that was always doing the work — one fixed advance per digit, and the
   wider tracking a column of them wants. */
input[type="date"],
input[type="time"],
input[type="tel"],
input[type="number"] {
  letter-spacing: 0.04em;
  font-variant-numeric: tabular-nums;
}

/* The native arrow is a different grey on every platform, so it is drawn here
   in navy instead — inline, because the panel may not fetch a second file for
   a triangle. */
select {
  appearance: none;
  -webkit-appearance: none;
  padding-right: 2.5rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5 11 1.5' fill='none' stroke='%23202858' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 1rem center;
  background-size: 0.75rem;
}

input:hover,
textarea:hover,
select:hover {
  border-color: var(--field-line-strong);
}

input:focus,
textarea:focus,
select:focus {
  border-color: var(--navy);
  background-color: var(--field-bg-focus);
}

/* The ring is never removed and never replaced with nothing. It is the accent
   at 6.41:1 against the field ground, offset so the field's own border stays
   readable underneath it. */
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* A disabled field steps back to the hairline rather than the 3:1 boundary —
   WCAG 1.4.11 exempts an inactive component, and the point of the weaker line
   is that this one cannot be typed in. The text inside it stays --ink-muted at
   5.70:1, because a value you cannot edit is still a value you have to read. */
input[readonly],
input:disabled,
textarea:disabled,
select:disabled {
  background: var(--cream-2);
  color: var(--ink-muted);
  border-color: var(--rule-strong);
  cursor: not-allowed;
}

/* Solid, not a tint of the value colour. A placeholder at 60% opacity measures
   3.44:1 here and fails; --ink-muted is 6.60:1. It reads a little heavier than
   a placeholder usually does, which is the right trade on a form where every
   field already carries a real label above it — the placeholder is an example,
   never the label. */
::placeholder {
  color: var(--ink-muted);
  opacity: 1;
}

.field.has-error input,
.field.has-error textarea,
.field.has-error select,
[aria-invalid="true"] {
  border-color: var(--danger);
  background-color: #fdf7f6;
}

.err {
  color: var(--danger);
  margin-top: 0.5rem;
}
.err-block {
  background: var(--danger-tint);
  border-left: 3px solid var(--danger);
  padding: 0.875rem 1.25rem;
  margin-bottom: 1rem;
  color: var(--danger);
}

.note {
  color: var(--ink-muted);
  margin-top: 0.5rem;
}
.note-warn {
  color: var(--accent);
}

.counter {
  color: var(--ink-muted);
  margin-top: 0.375rem;
  font-size: 0.875rem;
}
/* Soft, not hard: past the guide the count goes to the accent and nothing is
   blocked. #904c04 on cream is 6.09:1. */
.counter.over {
  color: var(--accent);
}

/* Two fields side by side above the single breakpoint. */
.row {
  display: grid;
  gap: 0 1.5rem;
}

/* A checkbox — the announced flag, and anything else that is one yes/no.
   The whole row is the label, so the target is the sentence and not the box.

   `label.check` and not `.check`, because the row IS a label and `.field label`
   above has already claimed it at the same weight; the tag has to be named here
   to win the tie.

   TWO SPELLINGS OF THE SAME CONTROL, HELD IN ONE RULE. lib/render.php's
   `kos_field(type => 'checkbox')` writes `label.check` with a `span` and a
   `p.hint`; event.php hand-rolls the announced flag instead and writes
   `.choice > label.choice-option` with a `span` holding a `strong` and an `em`.
   Same control, same job, different markup — and the panel has exactly two
   checkboxes, on two screens, which is the worst possible number for keeping
   two treatments in step by hand. So the second spelling is added to the first
   one's selectors rather than given a treatment of its own: there is no rule
   here that can be changed for one and forgotten for the other. */
label.check,
.check,
label.choice-option,
.choice-option {
  display: flex;
  align-items: flex-start;
  gap: 0.75rem;
  min-height: 44px;
  padding: 0.5rem 0;
  cursor: pointer;
  margin-bottom: 0.5rem;
}
.check > span,
.choice-option > span {
  margin-bottom: 0;
}
.check .hint,
.choice-option em {
  margin: 0.25rem 0 0;
}

/* The wrapper `label.check` does not have. It is given no box at all rather
   than a box with nothing in it, because a stray margin here is how the two
   checkboxes end up sitting at different heights on their own pages. */
.choice {
  display: contents;
}

/* The two halves of the hand-rolled span, brought level with the `span` and the
   `p.hint` they stand in for. Both are inline elements standing in for blocks,
   so both are told to be blocks; the `em` is additionally set upright, because
   `p.hint` is upright and identical is the whole point. Nothing is emphasised
   by the slant here anyway — the element was reached for as a container, not as
   a stress. */
.choice-option strong {
  display: block;
  /* Stated at 400 rather than left to be refused. `font-synthesis-weight: none`
     would have swallowed a 700 here silently, which is the same pixels and a
     worse file: a declaration nobody can tell is dead. */
  font-weight: 400;
}
.choice-option em {
  display: block;
  font-style: normal;
}

input[type="checkbox"],
input[type="radio"] {
  width: 1.25rem;
  height: 1.25rem;
  margin: 0.1875rem 0 0;
  flex: none;
  accent-color: var(--accent);
  cursor: pointer;
}

/* ------------------------------------------------------------- the date group

   The picker, what the picker turns into, and the way out of both. Three
   elements, one thought — so the group carries the field rhythm and the picker
   gives up its own. Left alone, `.field`'s bottom margin drops the sentence a
   whole field's clearance below the field it is describing, where it reads as a
   note about the next question instead of an answer to this one. */

.date-group {
  margin-bottom: 1.5rem;
}
.date-group > .field {
  margin-bottom: 0;
}

/* The live reading. The sentence around it is ordinary muted small print at
   6.27:1; the value inside it is the only part that changes as you type, so it
   goes to solid --ink at 13.03:1 and to the figures face — see the role above.
   A date IS a figure here, and the face change is what marks it out, which is
   why nothing was lost by the `<strong>` resolving to 400. */
.date-reads strong {
  color: var(--ink);
}

/* ------------------------------------------------------------- disclosures

   Four of these across two screens, and every one of them is the same offer:
   the panel worked something out for you, and here is how to overrule it. The
   dates that ran over more than one day on event.php, and the three derived
   spellings — the phone number, the WhatsApp number, the Instagram handle — on
   details.php. One treatment, because they are one idea.

   A SUMMARY IS A CONTROL AND WAS BEING SHIPPED AS BROWSER FURNITURE. It arrived
   with the platform's own disclosure triangle, which is drawn in the system UI
   face beside a page set in Alte Haas, sized by the browser rather than by this
   file, and — being a list marker — sat outside the box, so the target was a
   line of text 22px tall in a panel where nothing else is under 44. The marker
   is refused three ways because three engines have three answers: `list-style`
   for Firefox and current Chrome, `::-webkit-details-marker` for Safari, and
   the `display: block` that removes it in every WebKit derivative that ignores
   both.

   It is coloured as the accent because it is the one thing on these screens
   that is neither a field nor a button and still does something when pressed —
   6.09:1 on cream, deepening to 8.60:1 under the pointer. It takes the panel's
   own focus ring from `:focus-visible` above; what changed is `width:
   fit-content`, so that ring closes around the words instead of around the
   whole column, which is what a ring around a 40rem-wide invisible box looks
   like when you cannot see the box. */

.reveal > summary,
.derived-summary {
  display: block;
  position: relative;
  width: fit-content;
  max-width: 100%;
  /* 0.6875rem twice plus a 1.375rem line is 44px exactly, without a min-height
     doing it by force and leaving the indicator floating off the text. */
  padding: 0.6875rem 0 0.6875rem 1.25rem;
  font-size: 1.0625rem;
  line-height: 1.375rem;
  color: var(--accent);
  cursor: pointer;
  list-style: none;
  transition: color 150ms var(--ease-out);
}
.reveal > summary::-webkit-details-marker,
.derived-summary::-webkit-details-marker {
  display: none;
}
.reveal > summary:hover,
.derived-summary:hover {
  color: var(--accent-deep);
}

/* THE INDICATOR IS THE SELECT'S ARROW, character for character — the same path,
   the same 1.6 stroke, the same round caps — because a panel that draws two
   different chevrons has two design systems in it. It is carried as a mask
   rather than as a background so it takes `currentColor` and deepens with the
   words on hover, which the select's inline navy fill cannot do.

   The box is a full line tall and the mask is centred in it, so the chevron
   lines up with the first line of the summary at any size without a magic
   offset. IT TURNS WITHOUT A TRANSITION, deliberately: this file animates
   colours and one width and nothing else, and the direction the chevron points
   is the state itself rather than a flourish on top of it. There is no motion
   here for the reduced-motion block to have to undo. */
.reveal > summary::before,
.derived-summary::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.6875rem;
  width: 0.75rem;
  height: 1.375rem;
  background-color: currentColor;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5 11 1.5' fill='none' stroke='%23202858' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5 11 1.5' fill='none' stroke='%23202858' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-size: 0.75rem;
  mask-size: 0.75rem;
  transform: rotate(-90deg);
}
.reveal[open] > summary::before,
.derived[open] > .derived-summary::before {
  transform: none;
}

/* THE INDENT IS THE WHOLE OF IT NOW. There was a rule down the left of this
   box, and it was drawing a bracket around something already bracketed twice —
   by the chevron above it, which has turned to point at it, and by the 20px
   step in from the summary's own first letter. Space says "this belongs to
   that"; the line only said it again.

   The 20px is kept exactly: it lands the field flush under the first letter of
   the summary above. The field inside gives up its own bottom margin, or an open
   disclosure would sit two field-heights clear of whatever follows it and a shut
   one would not. */
.reveal-body,
.derived-body {
  padding: 0.25rem 0 0 1.25rem;
}
.reveal-body > .field,
.derived-body > .field {
  margin-bottom: 0;
}

.reveal {
  margin-top: 0.25rem;
}

/* A derived spelling belongs to the number above it, so that field gives up
   most of its own space when one follows — the same device, and the same
   caveat, as `.page-title:has(+ .page-lede)`: without `:has()` the pair simply
   sits a step further apart, so nothing depends on it. */
.field:has(+ .derived) {
  margin-bottom: 0.25rem;
}
.derived {
  margin-bottom: 1.5rem;
  max-width: 42rem;
}

/* --------------------------------------------------- the training sessions

   One row per session: the days, the time it starts, the time it ends, and the
   button that takes the row away. Rows are separated by space and by nothing
   else — a form that draws a border around every group stops reading as a
   document and starts reading as a spreadsheet, and the hairline that used to
   open each row was the last of that spreadsheet still showing. Each row
   restates its own three labels, DAYS / STARTS / ENDS, so where one row ends is
   never in doubt; the clearance went from 24px to 40px as the line came out.

   `.rows-row` is the same rule under its former name. lib/render.php does not
   own details.php or admin.js, and the repeater in admin.js still finds its
   rows by that class, so both selectors have to answer. */
[data-session],
.rows-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 0 1.5rem;
  padding-top: 2.5rem;
  margin-bottom: 0.5rem;
}
/* One field per line on a phone, three across on a laptop. A time field that
   has been squeezed to half a thumb is the fastest way to type 6.30 into the
   box that wanted 4.45. */
[data-session] > .field,
.rows-row > .field {
  flex: 1 1 100%;
  min-width: 0;
}
[data-session] > .btn,
[data-session] > [data-remove-session],
.rows-row > .btn,
.rows-row > .rows-remove {
  margin-bottom: 1.5rem;
}
[data-session] > .err,
.rows-row > .err {
  flex-basis: 100%;
  margin: 0 0 1.5rem;
}

[data-sessions] + .btn,
.rows + .btn {
  margin-top: 0.5rem;
}

/* details.php is another agent's file and may or may not hang `.btn` on these.
   They are buttons either way, so they get the button's shape either way — and
   the button's caps and tracking with it, or a hand-rolled Add another session
   would be the one control on the panel still set in sentence case. */
[data-add-session]:not(.btn),
[data-remove-session]:not(.btn) {
  font: inherit;
  font-size: 0.9375rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  padding: 0.5rem 1.125rem;
  min-height: 44px;
  border-radius: 2.5em;
  border: 1px solid var(--field-line);
  background: var(--cream-2);
  color: var(--ink);
  cursor: pointer;
}

/* Removing the last row is allowed, so this is what is left when it goes. It
   takes the clearance of the row it replaces, because a list that empties to
   nothing at all reads as a list that has broken rather than a list that is
   waiting. Written by admin.js, which hangs `.note` on it too, so the small
   print and the muted colour are already there. */
.sessions-empty {
  padding-top: 2.5rem;
  margin-top: 0;
}

/* --------------------------------------------------------- the page's reading

   THIS ONE IS NOT PART OF THE FORM AND MUST NOT LOOK LIKE IT. Everything else
   on details.php is the club telling the panel something; this is the panel
   showing the club what the SITE will print back. Set in the form's own faces
   at the form's own sizes it was indistinguishable from the rows above it,
   which is not a cosmetic complaint — a preview that reads as another field is
   a preview nobody checks, and the whole reason it exists is so that two
   Saturday rows written apart can be seen failing to merge into one heading.

   So it wears the site's treatment rather than the panel's, taken from
   src/components/site/ScheduleCard.tsx: a ground of its own, the days as a
   body-face heading, and the times at display scale in the condensed face. The
   card sets those times at 28 → 32px against a navy ground; here they run 24 →
   28px on cream-2, one step down, because on the site it is the subject of a
   section and here it is a quotation inside a form. --ink on --cream-2 is
   11.85:1 and the eyebrow above it is --ink-muted at 5.70:1. */

.preview {
  background: var(--cream-2);
  border-radius: 16px;
  padding: 1.5rem;
  margin: 2rem 0 0;
}

.preview-title {
  color: var(--ink-muted);
  margin: 0;
}

.preview-list {
  margin: 0;
}

/* Every group used to open on a hairline. Inside a panel that already has a
   ground of its own, against a 24px reading of the times, three rules across
   three groups were the busiest 3px on the panel — and the reading is the one
   thing here that has to be scanned rather than read. Space alone, and the caps
   the days are set in, which is the label device the rest of the file now uses:
   TUESDAY & THURSDAY at 14px over 7.00 PM — 8.30 PM at 24px. --ink on --cream-2
   is 11.85:1. */
.preview-list dt {
  margin-top: 1.5rem;
  font-size: 0.875rem;
  line-height: 1.25rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
.preview-list dt:first-child {
  margin-top: 0.75rem;
}

/* The `dl`'s indent is the browser's, and it would step the times in from the
   days they belong to. The face and the tabular figures come from the figures
   role above; only the scale is set here. */
.preview-list dd {
  margin: 0.25rem 0 0;
  font-size: 1.5rem;
  line-height: 1.2;
}

/* ------------------------------------------------------------ the year filter

   The club asked to be able to narrow the list "by date, by year, something like
   that", and a year is the answer: it is the only axis on this data with few
   enough values to fit in a row of pills and enough of them to be worth
   narrowing. admin.js does the hiding, in the browser, with no reload — the
   whole list is already on the page.

   THE ACTIVE PILL IS NOT DISTINGUISHED BY COLOUR ALONE, and that is 1.4.1
   rather than taste. The obvious answer — fill the one you are on — is a colour
   difference and nothing else, and the usual second signal, a bolder label, does
   not exist in a face with one weight. So the active pill fills AND takes a tick
   in front of its label: a mark that is there or is not there, which survives
   greyscale, a bad screen and a photocopier. `aria-pressed` carries the same
   fact to anyone not looking at it, and admin.js keeps the two in step.

   Cream on navy is 13.03:1 filled; navy on cream is 13.03:1 unfilled; the
   unfilled pill's own border composites to 3.39:1 against the page, which is
   what 1.4.11 asks of the boundary of a control. */

.filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 2rem 0 0;
}

.filter {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  font: inherit;
  font-size: 0.9375rem;
  line-height: 1.2;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  padding: 0.5rem 1.125rem;
  min-height: 44px;
  border-radius: 2.5em;
  border: 1px solid var(--field-line);
  background: transparent;
  color: var(--ink);
  cursor: pointer;
  font-variant-numeric: tabular-nums;
  transition:
    background-color 150ms var(--ease-out),
    border-color 150ms var(--ease-out),
    color 150ms var(--ease-out);
}
.filter:hover {
  border-color: var(--navy);
  background: var(--cream-2);
}

.filter.is-on,
.filter.is-on:hover {
  background: var(--navy);
  border-color: var(--navy);
  color: var(--cream);
}

/* The tick. Drawn the same way the disclosure chevron is — a mask taking
   `currentColor`, same 0.75rem box, same round caps — because a panel that
   draws its marks two different ways has two design systems in it. */
.filter.is-on::before {
  content: "";
  flex: none;
  width: 0.75rem;
  height: 0.75rem;
  background-color: currentColor;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 10'%3E%3Cpath d='M1 5.2 4.4 8.6 11 1.6' fill='none' stroke='%23202858' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 10'%3E%3Cpath d='M1 5.2 4.4 8.6 11 1.6' fill='none' stroke='%23202858' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-size: contain;
  mask-size: contain;
}

/* How many are showing. It is `aria-live` in the markup, so a filter press is
   announced rather than only seen — a screen-reader user who presses 2024 and
   hears nothing has no way to tell whether anything happened. */
.filter-count {
  color: var(--ink-muted);
  margin-top: 1rem;
}

/* -------------------------------------------------------- the event cards

   THE SITE'S OWN GRID, copied rather than approximated:
   src/components/home/EventsPreview.tsx sets `grid gap-x-5 gap-y-12
   md:grid-cols-3`, and each card is a 16:9 figure at `rounded-[16px]`, then the
   date, then the headline. The club asked for the panel's list to look like the
   one on the site — "one per line and that's a waste" — and the fastest way to
   be sure it does is to take the numbers off the component instead of matching
   them by eye: 20px between columns, 48px between rows, three across.

   Three columns at width, one below 812. Nothing in between, because there is
   nothing in between in this file. */

.cards {
  list-style: none;
  /* Stated rather than inherited from `.legend + *`: a list sets its own margin,
     so it wins that cascade whatever it happens to follow. */
  margin: 2.5rem 0 0;
  padding: 0;
  display: grid;
  gap: 3rem 1.25rem;
}

/* A grid item refuses to go narrower than its contents unless it is told it may,
   and a long unbroken headline is exactly the content that will try.

   The column is the delete control's doing. A card is now two things stacked —
   the link that opens the event, and the one button that removes it — and the
   button has to sit on the floor of the card rather than under whatever the
   headline happened to leave. See `.card-delete` below. */
.card {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.card-link {
  display: block;
  text-decoration: none;
  color: inherit;
}

.card-thumb {
  margin: 0 0 1.25rem;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: 16px;
  background: var(--beige-soft);
}
.card-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.card-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 0.875rem;
  align-items: center;
  color: var(--ink-muted);
  margin: 0;
}

.status {
  padding: 0.25rem 0.625rem;
  border-radius: 2.5em;
  border: 1px solid var(--rule-strong);
  color: var(--ink-muted);
}
/* Announced is the state that has not happened yet, so it is the one the eye
   should find. #904c04 on cream is 6.09:1, and the pill it sits in is the same
   colour, which is 1.4.11's 3:1 twice over for a non-text mark. */
.status-announced {
  color: var(--accent);
  border-color: var(--accent);
}

/* The site sets its card headline at 24 → 28 and turns it to orange-deep under
   the pointer; both are copied. It is an `h3`, so the heading face and its 700
   arrive from the element rule near the top of this file. */
.card-title {
  margin-top: 0.75rem;
  font-size: 1.5rem;
  line-height: 1.25;
  letter-spacing: -0.015em;
  transition: color 150ms var(--ease-out);
}
.card-link:hover .card-title {
  color: var(--accent);
}

.card-dek {
  display: -webkit-box;
  color: var(--ink-muted);
  margin-top: 0.75rem;
  overflow: hidden;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
}

/* -------------------------------------------------- taking an event off the site

   ONE PRESS AND IT IS GONE, at the club's direction, with nothing asked and
   nothing to confirm. That single fact decides everything about how this control
   is drawn.

   A button that asks a question can afford to be loud, because the question is
   the safety net. A button that acts on the press has no such cover, so it has
   to be quiet enough that nobody arrives on it on the way to somewhere else, and
   plain enough that nobody presses it thinking it is something else. Quiet is a
   muted word in an outline on the page's own cream; plain is that the word is
   Delete. The safety net moved to after the fact and is the `.undo` strip
   further down this file.

   IT SAYS THE WORD RATHER THAN DRAWING A CROSS. index.php puts "Delete" inside
   the button and the whole event's name in the aria-label, which is the right
   way round — twelve identical crosses read out in a list tell nobody which
   event they are on, and a cross is a symbol the reader has to already know. The
   visible word is also contained in the accessible name, which is what WCAG
   2.5.3 asks of any control whose label is spoken.

   IT IS A SIBLING OF THE CARD'S LINK, NEVER A CHILD OF IT. The card is covered
   by one `<a>` to the editor, and a form — let alone a submit button — inside an
   anchor is not markup any parser will keep. So index.php writes the form after
   the link inside the same `<li>`, and it is simply laid out after it: outside
   the link's box, which is the whole of what "not swallowed by the card's own
   click target" needs, with no positioning and nothing painted over anything.

   IT IS NOT PUT IN THE CORNER OF THE COVER, and that is the one decision here
   worth writing down. A control floating on the photograph would be compact and
   conventional and it would also be sitting on a ground nobody has seen, where
   the 3:1 WCAG 1.4.11 asks between a control and its neighbours cannot be
   measured at all — only argued around with a backplate. Under the picture it is
   on cream, every pair is a number, and it has stopped overlapping the one
   target a thumb is actually aiming for. --ink-muted on cream is 6.27:1 and the
   outline composites to 3.39:1 against it.

   `margin-top: auto` is what lands it on the FLOOR of the card rather than under
   whatever the headline left. Grid stretches every card in a row to the tallest
   of them, so the three buttons line up across the row however long the three
   headlines run — and the reader's hand learns one place rather than three.

   Hover and focus are one treatment deliberately: --danger on --danger-tint is
   7.02:1 for the word and the same for the outline, and a keyboard has to be
   told what a pointer is told. */

.card-delete {
  /* A form carries a bottom margin from the browser's own stylesheet. */
  margin: 0;
  margin-top: auto;
  /* Clearance from the card above it, kept as padding rather than margin so the
     `auto` above stays the only thing deciding where the row lands. */
  padding-top: 1.25rem;
  /* The trailing action goes to the far end of the card — the same argument as
     `.band-out` and `.shot-remove`, and the same reason: it is set apart from
     what it belongs to rather than sitting in the run of it. */
  align-self: flex-end;
}

/* Drawn from the class, the way `[data-add-session]:not(.btn)` is: index.php
   hangs no `.btn` on this and a control that is only a button when somebody
   remembers to say so is not a control. The shape is the panel's quiet button —
   the house pill, the 44px target, caps and 0.06em because the face has one
   weight and there is no bold to reach for. */
.card-delete-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font: inherit;
  font-size: 0.875rem;
  line-height: 1.2;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  padding: 0.5rem 1.125rem;
  min-height: 44px;
  border-radius: 2.5em;
  border: 1px solid var(--field-line);
  background: transparent;
  color: var(--ink-muted);
  cursor: pointer;
  transition:
    background-color 150ms var(--ease-out),
    border-color 150ms var(--ease-out),
    color 150ms var(--ease-out);
}
.card-delete-btn:hover,
.card-delete-btn:focus-visible {
  border-color: var(--danger);
  background: var(--danger-tint);
  color: var(--danger);
}

/* ------------------------------------------------------------- photo strip */

.strip {
  list-style: none;
  margin: 1.5rem 0;
  padding: 0;
  display: grid;
  gap: 2.5rem;
}

/* A row is a photograph and the words about it, and the photograph is a 16:9
   block of image on a page of cream — there is no risk whatever of two rows
   being read as one. So the hairline over each of them went, and the 24px it
   sat in became 40px of grid gap. */
.shot {
  display: grid;
  gap: 1.25rem;
  position: relative;
}

.shot.is-missing .shot-frame {
  background: var(--danger-tint);
  box-shadow: inset 0 0 0 2px var(--danger);
}

.shot.is-dragging {
  opacity: 0.55;
}

.shot-figure {
  position: relative;
  margin: 0;
}

/* THE COVER IS MARKED ONCE. It used to be marked twice — this badge, and a 2px
   accent rule across the top of the first row instead of the hairline the others
   had. Two marks for one fact is one mark too many, and the rule was the weaker
   of the two: it said "this row is different" without saying how. The badge says
   COVER. Cream on --accent is 6.09:1. */
.shot-badge {
  display: none;
  position: absolute;
  top: 0.5rem;
  left: 0.5rem;
  z-index: 2;
  background: var(--accent);
  color: var(--cream);
  padding: 0.25rem 0.625rem;
  border-radius: 2.5em;
}
.shot:first-child .shot-badge {
  display: block;
}

.shot-frame {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  background: var(--beige);
  border-radius: 10px;
}
.shot-frame-empty::after {
  content: "no preview";
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  color: var(--ink-muted);
  font-size: 0.875rem;
}

.shot-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
  user-select: none;
  -webkit-user-drag: none;
}

/* A croppable cover: the frame IS the crop, so the image fills it and slides.
   JS sets the width, height and offset; this only changes the fit and the
   cursor so the control reads as draggable before it is touched. */
.shot.is-cropping .shot-img {
  inset: auto;
  width: auto;
  height: auto;
  max-width: none;
  object-fit: fill;
  cursor: grab;
  touch-action: none;
}
.shot.is-cropping.is-grabbing .shot-img {
  cursor: grabbing;
}

.shot-window {
  position: absolute;
  inset: 0;
  pointer-events: none;
  box-shadow: inset 0 0 0 2px rgba(250, 247, 240, 0.85);
  border-radius: 10px;
}
.shot-window-box {
  position: absolute;
  inset: 0;
  background-image: linear-gradient(
      to right,
      transparent calc(33.333% - 1px),
      rgba(250, 247, 240, 0.45) calc(33.333% - 1px),
      rgba(250, 247, 240, 0.45) 33.333%,
      transparent 33.333%,
      transparent calc(66.666% - 1px),
      rgba(250, 247, 240, 0.45) calc(66.666% - 1px),
      rgba(250, 247, 240, 0.45) 66.666%,
      transparent 66.666%
    ),
    linear-gradient(
      to bottom,
      transparent calc(33.333% - 1px),
      rgba(250, 247, 240, 0.45) calc(33.333% - 1px),
      rgba(250, 247, 240, 0.45) 33.333%,
      transparent 33.333%,
      transparent calc(66.666% - 1px),
      rgba(250, 247, 240, 0.45) calc(66.666% - 1px),
      rgba(250, 247, 240, 0.45) 66.666%,
      transparent 66.666%
    );
  opacity: 0;
  transition: opacity 150ms var(--ease-out);
}
.shot.is-grabbing .shot-window-box {
  opacity: 1;
}

/* The right-hand column of the row: the caption, the description, the crop hint
   and the actions. Everything inside it already carries its own rhythm, so this
   sets one thing and one thing only — and it is the thing a grid item silently
   gets wrong. A grid item's `min-width` is `auto`, which means it refuses to go
   narrower than its contents; the textarea in here is `width: 100%` over an
   intrinsic column count, so at the 18rem-and-the-rest layout the item pushes
   the track wider than the strip and the photograph beside it shrinks. */
.shot-body {
  min-width: 0;
}

/* NOT A CONTROL, despite reading like one next to `.shot-crop-hint` and
   `.shot-window`. It is the hidden input that carries the crop offset back to
   the server, written by admin.js as the cover is dragged. Named here so that
   nobody dressing the strip mistakes it for the crop button — there isn't one;
   the frame itself is the control — and floored at `display: none` so that a
   browser which ever stops hiding `input[type="hidden"]` cannot drop a stray
   box into the middle of the grid. */
.shot-crop {
  display: none;
}

.shot-label {
  color: var(--ink-muted);
  margin-bottom: 0.5rem;
  font-variant-numeric: tabular-nums;
}

.shot-crop-hint {
  color: var(--ink-muted);
  margin-top: 0.5rem;
}

.shot-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.75rem;
}
.shot-actions .btn {
  font-size: 0.875rem;
  padding: 0.5rem 1rem;
}

/* The three that rearrange the strip. event.php hangs `.btn .btn-quiet` on all
   of them, so the pill, the 44px target and the disabled solid are already
   there; what is named here is what a button in general cannot know about this
   particular row.

   THE ROW IS BEING READ BY SOMEBODY WHOSE THUMB IS STILL ON IT. All three
   states change the moment a photograph moves: Move up greys out on the first,
   Move down on the last, and Set as cover disappears outright on the one that
   already is — see `[hidden]` at the top of this file for why that last one had
   stopped working. So nothing here may resize. `.btn:disabled` only ever swaps
   colours and keeps its 1px border, which holds the box; `flex: none` holds it
   across the wrap as well, so a greyed-out Move up cannot shrink by a hair and
   drag Remove leftwards under a thumb that is already coming down on it. */
.shot-up,
.shot-down,
.shot-cover {
  flex: none;
}

/* Remove is the only thing in this row that cannot be undone, so it is set
   apart from the run of them rather than sitting fourth in it — the same
   argument and the same device as `.band-out`, and for the same reason: once
   the row wraps on a phone a vertical divider would end up at the start of a
   line pointing at nothing, while pushing the item to the far end of whatever
   line it lands on says it without drawing anything.

   It carries `.btn-danger` in the markup and keeps it: --danger reads 7.78:1 on
   cream as a label and the same as its border, well past the 3:1 the outline of
   a control needs. `flex: none` for the reason above — this is the one button
   in the row that must never move by a pixel between one glance and the next. */
.shot-remove {
  flex: none;
  margin-left: auto;
}

.shot-handle {
  cursor: grab;
}

/* ------------------------------------------------------------- add photos */

/* THE DASHED BOX IS GONE, and it was the clearest case in the file. A dashed
   border means "drop something here" and nothing on this panel accepts a drop —
   there is a Choose photographs button, a filename and a sentence of guidance,
   which is a row of controls rather than a target. The box was drawing a
   dropzone that does not exist. What is left is the row and its clearance. */
.add-photos {
  margin-top: 2rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  align-items: center;
}
.add-photos .hint {
  flex-basis: 100%;
  margin: 0.25rem 0 0;
}

/* The native file button is unstyleable and says the wrong thing; the label
   beside it is the control. The input still has to be reachable by keyboard,
   so it is clipped rather than display:none. */
.add-photos input[type="file"] {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}
.add-photos input[type="file"]:focus-visible + .btn,
.add-photos label.btn:focus-within {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}
.add-photos .file-name {
  color: var(--ink-muted);
}

/* -------------------------------------------------------------- danger zone */

.danger-zone {
  margin-top: 4rem;
  padding-top: 1.5rem;
  border-top: 2px solid var(--danger);
}
.danger-zone h2 {
  color: var(--danger);
}
.danger-zone p {
  color: var(--ink-muted);
  margin: 0.5rem 0 1.25rem;
  max-width: 40rem;
}

/* -------------------------------------------------------------------- undo

   THE ONLY WAY BACK FROM A DELETE, and deliberately not a dialog.

   The club asked for one press and gone: no confirmation, nothing to dismiss,
   nothing to read before the thing happens. The safety net had to move to after
   the fact, so the card disappears at once and this strip sits on the floor of
   the screen for ten seconds offering to put it back. Nothing waits on it,
   nothing is blocked by it, and ignoring it is a valid answer — which is the
   difference between a net and a gate.

   IT OVERLAYS, IT DOES NOT RESERVE. A strip that pushed into the flow would move
   the list under a thumb at the exact moment that thumb has just pressed
   something, which is how one mis-tap becomes two. Fixed to the bottom of the
   viewport it costs the page no layout at all — the club's list does not so much
   as reflow — and it is the same 16px radius the cards and the reading carry,
   which is still the only panel radius in this file.

   THE TEN SECONDS ARE THE SCRIPT'S AND THIS FILE DOES NOT KEEP A SECOND CLOCK.
   admin.js counts them, adds `is-leaving`, and sets `hidden` a beat later; all
   that is drawn here is the arriving and the leaving. The temptation was to give
   the strip one ten-second animation and be done with it, and it is worth saying
   why that is wrong rather than merely not done: the script shows the strip by
   clearing `hidden`, so a SECOND delete inside the first ten seconds never takes
   the element out of `display: none` and never restarts a CSS animation. The
   window would silently keep the first delete's clock while the script kept the
   second's. One timer, in the place that can see both presses.

   `backwards` and not `both`, for a reason that only shows up on the way out: an
   animation holding its last frame outranks a transition, so a strip that ended
   `both` would sit at the opacity the keyframes left it and quietly ignore
   `is-leaving`. Held only at the front, it lets go the moment it has arrived. */

@keyframes undo-in {
  from {
    opacity: 0;
    transform: translateY(0.75rem);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* Both spellings answer, the way `[data-session]` and `.rows-row` do above: the
   contract hangs the class and the attribute on the same element, and a strip
   that is styled only by whichever one somebody remembered is a strip that will
   one day arrive naked. */
.undo,
[data-undo] {
  position: fixed;
  z-index: 50;
  left: 1.25rem;
  right: 1.25rem;
  bottom: 1.25rem;
  /* `left` and `right` together with a max-width and auto margins is what
     centres it on a laptop and lets it run the width of a phone. */
  margin: 0 auto;
  max-width: 32rem;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem 1.25rem;
  padding: 0.875rem 0.875rem 0.875rem 1.25rem;
  border-radius: 16px;
  /* Navy and cream, the site's own inversion, at 13.03:1. A layer over the page
     has to read as a layer, and solid navy on cream is the one pair in this
     file that could never be mistaken for part of the document. */
  background: var(--navy);
  color: var(--ink-on-navy);
  animation: undo-in 300ms var(--ease-out) backwards;
}

/* The sentence takes the slack and gives up its intrinsic width, so a long
   headline wraps inside the strip instead of pushing Undo off the end of it. */
.undo p,
[data-undo] p {
  flex: 1 1 10rem;
  min-width: 0;
}

/* The ten seconds are up. admin.js adds this, waits 400ms and then sets
   `hidden`, so the fade has 300ms and a hundred to spare.

   `visibility` goes with it, delayed by exactly the length of the fade: the
   button stays reachable while it is still there to be seen and leaves the tab
   order the instant it is not, rather than lying invisible in wait for a
   keyboard. Under a reduced-motion setting the script does not add this class at
   all — it hides the strip outright — which is the correct reading of the
   setting and the reason there is nothing about undo in the block at the foot of
   this file. */
.undo.is-leaving,
[data-undo].is-leaving {
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 300ms var(--ease-out),
    visibility 0s linear 300ms;
}

/* THE BUTTON HAS NO CLASS, so it is drawn from the attribute — the same
   arrangement as `[data-add-session]:not(.btn)` above, and for the same reason:
   this file does not own the markup and a control that is only a button when
   somebody remembers to say so is not a control.

   Outlined rather than filled. The strip is already the loudest thing on the
   page and a filled button inside a filled strip is two emphases for one offer.
   Cream on navy is 13.03:1 as a label and the same as the border, which is four
   times what WCAG 1.4.11 asks of a control's outline; under the pointer the
   fill and the label swap and navy on cream is 13.03:1 again. */
[data-undo-btn] {
  flex: none;
  font: inherit;
  font-size: 0.9375rem;
  line-height: 1.2;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  padding: 0.5rem 1.125rem;
  min-height: 44px;
  border-radius: 2.5em;
  border: 1px solid var(--ink-on-navy);
  background: transparent;
  color: var(--ink-on-navy);
  cursor: pointer;
  transition:
    background-color 150ms var(--ease-out),
    color 150ms var(--ease-out);
}
[data-undo-btn]:hover {
  background: var(--ink-on-navy);
  color: var(--navy);
}

/* The ring cannot be the accent in here, for the reason the band's cannot be:
   #904c04 on navy is 2.14:1 — under even the 3:1 a non-text indicator needs —
   and would be a ring nobody can see on the one control that has ten seconds to
   be found. Cream is 13.03:1, and the 3px offset opens onto the strip's own navy
   on every side. */
.undo :focus-visible,
[data-undo] :focus-visible {
  outline-color: var(--ink-on-navy);
}

/* ------------------------------------------------------------------- login

   The band across the top, the form under it, nothing else on the page. The
   panel's identity is the band, so the login screen wears it too rather than
   inventing a second treatment for the one screen everybody sees first. */

/* The column and the full height are `body`'s now, for every screen — see the
   note there. What is left here is the one thing the login screen does
   differently with them: the form does NOT take the slack, it floats in the
   middle of it. `flex: none` undoes `.wrap`'s own `flex: 1 0 auto`, and the auto
   margins then centre what is left over. */
body.is-login .wrap {
  flex: none;
  margin: auto;
  width: 100%;
  max-width: 27rem;
  padding: 2.5rem 1.25rem;
}
/* There is nobody signed in to name, and the session sentence belongs to a
   session that has not started, so the login screen is the band and the form and
   nothing else. */
body.is-login .foot {
  display: none;
}

.login-mark {
  color: var(--ink-muted);
  margin-bottom: 0.75rem;
}
.login-lede {
  color: var(--ink-muted);
  margin: 0.75rem 0 2rem;
}
.login .btn {
  width: 100%;
  margin-top: 0.5rem;
}
.login-note {
  margin-top: 3rem;
  color: var(--ink-muted);
}

/* ================================================== the one real breakpoint */

@media (min-width: 812px) {
  /* 34 + 44 + 34 = 112. It was 19 + 44 + 19 = 82, the site header's height, back
     when a second band closed the page; the club took that one out and asked for
     this one to be thicker in its place. The 44 is still the nav's own touch
     target and not a number typed in here; see the band note. Changing this
     padding changes the band's height, and it is the only thing that does —
     which is why the wordmark is kept under 44px tall rather than left to grow
     into it. */
  .band-inner {
    padding: 2.125rem 2rem;
  }

  /* The mark does not grow with the viewport. It is capped at 44px so the nav's
     touch target, not the logo, sets the row — the same 44 at 390 as at 1280.
     What that buys is room: the mark is 38px wide where the club's name was
     366px of caps, so the band can take a fourth section without silently
     wrapping to two rows and adding another 44 to a band already at 112. */

  .band-nav a {
    font-size: 1.0625rem;
  }

  .wrap {
    padding: 3rem 2rem 5rem;
  }

  .foot {
    padding: 0.5rem 2rem 2.5rem;
  }

  body {
    font-size: 1.1875rem;
    line-height: 1.875rem;
  }

  .page-title,
  .login h1 {
    font-size: 3rem;
    line-height: 3.375rem;
    letter-spacing: -0.025em;
  }

  .legend {
    font-size: 1.875rem;
    line-height: 2.3125rem;
    letter-spacing: -0.02em;
  }

  .danger-zone h2 {
    font-size: 1.3125rem;
    line-height: 1.6875rem;
  }

  .hint,
  .note,
  .err,
  .foot,
  .aside,
  .legend-note,
  .login-lede,
  .login-note,
  .card-dek,
  .card-meta,
  .filter-count,
  .shot-label,
  .shot-crop-hint,
  .token,
  .progress-banner-detail,
  .undo p,
  .file-name,
  .danger-zone p,
  .choice-option em {
    font-size: 1.0625rem;
    line-height: 1.625rem;
  }

  /* The label takes the same one step, and the dot follows its line box up.
     1.625rem less the 0.5rem dot, halved — the same arithmetic as the base
     rule, restated because the number it is derived from changed here. */
  .progress-banner-label {
    font-size: 1.0625rem;
    line-height: 1.5rem;
  }
  .progress-banner-dot {
    margin-top: 0.5rem;
  }

  /* Side by side once there is room for both to be read in one line, which is
     what turns two stacked sentences on a phone into one line on a laptop. */
  .ask-actions {
    flex-direction: row;
    justify-content: flex-end;
  }
  .ask-actions .btn {
    width: auto;
  }

  /* The caps labels take the same one step the copy under them takes, so the
     gap between a label and its hint holds at every width. Same exclusions as
     the base rule — a checkbox's sentence is not one of these. */
  .field label:not(.check):not(.choice-option),
  .shot-alt-label,
  .preview-list dt {
    font-size: 0.9375rem;
  }

  /* A field's value is body copy typed by hand, so it moves with body copy. */
  input[type="text"],
  input[type="date"],
  input[type="time"],
  input[type="url"],
  input[type="tel"],
  input[type="email"],
  input[type="password"],
  input[type="number"],
  input[type="search"],
  textarea,
  select {
    font-size: 1.125rem;
  }

  .reveal > summary,
  .derived-summary {
    font-size: 1.125rem;
  }

  /* The site's link offset opens up a step at width, and so does this one. */
  .link,
  .back {
    text-underline-offset: 4px;
  }

  .row {
    grid-template-columns: 1fr 1fr;
  }

  /* Three across, the site's own `md:grid-cols-3`. `minmax(0, 1fr)` rather than
     plain `1fr` for the same reason `.card` sets `min-width: 0`: a track sized
     `1fr` will not go below its content's intrinsic width, and one long headline
     would then push the other two columns narrow. */
  .cards {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

  .card-title {
    font-size: 1.75rem;
  }

  /* The reading opens up the way the card it quotes does. ScheduleCard goes 28
     → 32px at its own breakpoint; this stays one step under it throughout. */
  .preview {
    padding: 1.75rem 2rem;
  }
  .preview-list dd {
    font-size: 1.75rem;
  }

  .shot {
    grid-template-columns: 18rem 1fr;
    align-items: start;
  }

  [data-session] > .field,
  .rows-row > .field {
    flex: 1 1 11rem;
  }

  /* The remove button lines up with the inputs, not with their labels. The
     labels are 14px caps on a 20px line with 8px under them, so 1.75rem is the
     distance from the top of a label to the top of the field beside it. */
  [data-session] > .btn,
  [data-session] > [data-remove-session],
  .rows-row > .btn,
  .rows-row > .rows-remove {
    margin-top: 1.75rem;
  }

  /* Sign out keeps the 24px of clear air `.band-out`'s `margin-left: auto`
     already opens, and has given up the rule that used to be drawn in it. The
     rule was 1.69:1 against the band — visible enough to count as clutter,
     never firm enough to be a boundary — and the space on its own says the same
     thing, which is the whole of the club's fourth instruction. */
  .band-out {
    padding-left: 1.5rem;
  }
}

/* ==================================================== reduced motion, required

   Every transition in this file is a colour, every animation in it is an opacity
   over a few hundred milliseconds, and none of them carry meaning. So they can
   all simply stop, and nothing here has a static fallback to restore, because
   nothing here was ever hidden or moved by motion in the first place — which is
   the point of not copying the site's kinetics.

   That claim is checked rather than inherited, and the two things added since
   were both built to survive it. The disclosure indicator turns 90° between shut
   and open and is given no transition at all, so that the direction it points —
   which is the state, not a decoration on it — arrives the same way for
   everybody. The status dot's pulse opens and closes at full opacity, so a
   duration collapsed to nothing leaves a solid dot rather than a half-faded one,
   and the state it belongs to is in the words beside it either way.

   THERE IS NOTHING IN HERE ABOUT UNDO, AND THAT IS DELIBERATE. Its ten seconds
   are a timer in admin.js, never an animation, so there is no duration in this
   file for the blanket to collapse and no way for a reduced-motion setting to
   shorten the one window a delete can be taken back in. The script reads the
   same setting and simply takes the strip away without the fade. A reduced
   motion preference must never remove a function; the surest way to hold that
   line was to keep the function out of the motion. */

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

  /* Not asked for at all, rather than asked for and cut short. The blanket above
     would already stop it; this says so in one place, so the dot's resting
     opacity is a value in this file rather than a consequence of one. */
  .progress-banner[data-state="working"] .progress-banner-dot {
    animation: none;
    opacity: 1;
  }

  /* THE BAR STILL MOVES, IT JUST STOPS SLIDING. The blanket rule above cuts
     every transition to nothing, which is exactly right here: the fill jumps
     from one width to the next instead of gliding, and the reader still sees it
     advance. Removing the bar as well would take away the one wordless signal
     that an update is going somewhere, which is not what a motion preference
     asks for. */
  .progress-fill {
    transition: none;
  }
}
