/*
 * Chorverwaltung — global stylesheet. Plain CSS, no build step, no
 * framework (concept/design.md §5): the token set plus the component
 * classes below cover the app. Component-specific rules are grouped in
 * sections and documented where non-obvious.
 *
 * Fonts: gemeinde-app's source (app/frontend/src/styles.scss) bundles
 * Manrope/Inter via @fontsource npm packages so it ships true font files
 * (needed there for Cyrillic support). This app has no npm and no CDN
 * (design.md §1/§5 — no external requests at runtime), so the @fontsource
 * files could not be carried over faithfully. The stacks below name
 * Manrope/Inter first for a browser that already has them (e.g. installed
 * as a system/PWA font) and fall back to the platform's UI sans-serif
 * otherwise — see the report for this gap.
 */

/* ---------- Design tokens ("Nordlicht" theme, ported from gemeinde-app) ---------- */

:root {
  /* Light — warm, calm, domestic.
   *
   * DELIBERATELY DARKER THAN THE ORIGINAL MOCK the values were designed
   * against (gemeinde-app/app/frontend/src/styles.scss, concept #372): the
   * mock's own colours fail WCAG AA for normal text against --surface; these
   * pass. Do not "restore" them to lighter values — that is an accessibility
   * regression, not a fidelity fix. Measured contrast (gemeinde-app
   * frontend/docs/conventions.md):
   *
   *   --accent    mock #9c8471 → 3.53:1   ours #6b5240 → 7.23:1  (white on it)
   *   --trust     mock #6f9a7d → 3.18:1   ours #34694a → 6.43:1
   *   --ink-soft  mock #8d8378 → 3.72:1   ours #6f6355 → 5.85:1
   *
   * Hue, weight and role are the mock's; only luminance moved. Copied
   * verbatim from gemeinde-app — do not re-derive these values.
   */
  --bg: #efe9e1;
  --surface: #ffffff;
  --surface-soft: #f7f3ec;
  --ink: #2b2118;
  --ink-soft: #6f6355;
  --accent: #6b5240;
  --accent-ink: #ffffff;
  --accent-soft: #c9b6a4;
  --trust: #34694a;
  --trust-soft: #e3efe7;
  --rose: #c97b6b;
  --rose-soft: #f4e3de;
  --danger: #a63d2f;
  --danger-ink: #ffffff;

  --radius-card: 14px;
  --radius-pill: 999px;

  --shadow-card: 0 1px 2px rgba(63, 48, 35, 0.06), 0 4px 14px rgba(63, 48, 35, 0.07);
  --shadow-raised: 0 2px 6px rgba(63, 48, 35, 0.1), 0 10px 28px rgba(63, 48, 35, 0.12);
  --backdrop: rgba(43, 33, 24, 0.45);
  --border: rgba(107, 82, 64, 0.18);

  /* Height reserved for the mobile bottom tab bar; the shell adds
     env(safe-area-inset-bottom) on top of this, not instead of it. */
  --tab-bar-height: 5rem;

  /* NOT loaded via @fontsource (no npm/CDN here — see the file header
     comment): named first for a browser/OS that already has them, falling
     back to the platform UI font otherwise. */
  --font-display: 'Manrope', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  --font-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;

  color-scheme: light;
}

/* Dark — true warm dark, not a gray inversion. System preference only,
   unless an explicit choice (data-theme="dark") overrides it below. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    --bg: #1e1913;
    --surface: #2a241d;
    --surface-soft: #332c24;
    --ink: #f0e9df;
    --ink-soft: #a99c8c;
    --accent: #c9a88c;
    --accent-ink: #2b2118;
    --accent-soft: #4a3d30;
    --trust: #7fbf98;
    --trust-soft: #2c3a31;
    --rose: #d99383;
    --rose-soft: #43302b;
    --danger: #e07b6c;
    --danger-ink: #2b2118;

    --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.25), 0 4px 14px rgba(0, 0, 0, 0.28);
    --shadow-raised: 0 2px 6px rgba(0, 0, 0, 0.3), 0 10px 28px rgba(0, 0, 0, 0.35);
    --backdrop: rgba(12, 9, 6, 0.6);
    --border: rgba(201, 168, 140, 0.22);

    color-scheme: dark;
  }
}

/* Explicit choice wins in both directions, regardless of system preference. */
:root[data-theme='dark'] {
  --bg: #1e1913;
  --surface: #2a241d;
  --surface-soft: #332c24;
  --ink: #f0e9df;
  --ink-soft: #a99c8c;
  --accent: #c9a88c;
  --accent-ink: #2b2118;
  --accent-soft: #4a3d30;
  --trust: #7fbf98;
  --trust-soft: #2c3a31;
  --rose: #d99383;
  --rose-soft: #43302b;
  --danger: #e07b6c;
  --danger-ink: #2b2118;

  --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.25), 0 4px 14px rgba(0, 0, 0, 0.28);
  --shadow-raised: 0 2px 6px rgba(0, 0, 0, 0.3), 0 10px 28px rgba(0, 0, 0, 0.35);
  --backdrop: rgba(12, 9, 6, 0.6);
  --border: rgba(201, 168, 140, 0.22);

  color-scheme: dark;
}

/* ---------- Base ---------- */

* {
  box-sizing: border-box;
}

html {
  height: 100%;
}

body {
  margin: 0;
  min-height: 100%;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 1rem;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}

h1,
h2,
h3,
h4 {
  font-family: var(--font-display);
  font-weight: 700;
  line-height: 1.25;
  color: var(--ink);
  margin: 0 0 0.75rem;
}

h1 {
  font-size: 1.728rem;
}
h2 {
  font-size: 1.44rem;
}
h3 {
  font-size: 1.2rem;
}

p {
  margin: 0 0 0.75rem;
}

a {
  color: var(--accent);
}

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

::selection {
  background: var(--accent-soft);
  color: var(--ink);
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    transition: none !important;
    animation: none !important;
  }
}

/* Alpine's boot flicker guard: hidden until Alpine has initialised the
   element (its x-cloak attribute is removed once bound). */
[x-cloak] {
  display: none !important;
}

/* ---------- Buttons ---------- */

button,
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-body);
  font-size: 0.9375rem;
  font-weight: 600;
  min-height: 44px;
  padding: 0.5rem 1.125rem;
  border-radius: var(--radius-pill);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--ink);
  text-decoration: none;
  cursor: pointer;
  transition: background-color 120ms ease;
}

button:hover:not(:disabled),
.btn:hover:not(:disabled) {
  background: var(--surface-soft);
}

button:disabled,
.btn:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

button[type='submit'],
.btn-primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}

button[type='submit']:hover:not(:disabled),
.btn-primary:hover:not(:disabled) {
  background: color-mix(in srgb, var(--accent) 88%, var(--ink));
}

/* The `button.` variants are not decoration: `button[type='submit']` above is
   (0,1,1) — one attribute selector plus one type — and a bare `.btn-danger` is
   only (0,1,0), so on a submit button the accent fill won and these two
   modifiers rendered as the primary button. Every button they are used on is a
   submit button, so until this line they had no effect at all. Matching the
   specificity and sitting later in the file is what makes them apply.

   .btn-quiet additionally needs `color`, which it never declared: with the
   background transparent, the accent-ink colour it was still inheriting from
   the rule above would be near-invisible text.

   Enumerated by grepping `btn-danger|btn-quiet` across internal/view/*.templ in
   this commit, the buttons whose appearance this changes are the seven that
   predate it — the header logout and the "Kein Zugriff" logout, "Chor
   archivieren" and "Chor wiederherstellen", "Überall abmelden", "Entfernen" and
   "Zurückziehen" — plus the two this commit's Stimmen rows add. All nine are
   type="submit". */
.btn-danger,
button.btn-danger {
  background: transparent;
  border-color: var(--danger);
  color: var(--danger);
}

.btn-danger:hover:not(:disabled),
button.btn-danger:hover:not(:disabled) {
  background: var(--rose-soft);
}

.btn-quiet,
button.btn-quiet {
  border-color: transparent;
  background: transparent;
  color: var(--ink);
}

.btn-quiet:hover:not(:disabled),
button.btn-quiet:hover:not(:disabled) {
  background: var(--surface-soft);
}


/* ---------- Links that stand on their own (#57) ---------- */
/* **design.md §3's 44px floor, for the one control that could never inherit
   it.** #57 measured three links on the member detail screen at 19–20px high —
   the `tel:` link at 138x19 on a 360px viewport, which is the most one-handed
   tap in the app: a conductor mid-rehearsal ringing a singer.

   THE CAUSE, because "add a class" would fix these three and leave the next
   one. Every 44px floor in this file arrives one of two ways: an element
   selector over the form controls (`button`, and the
   `input`/`select`/`textarea` rule below), or a component class — `.btn`,
   `.tab-bar__item`, `.shell-header__menu a`, `.chip`,
   `.member-filter__toggle`, `.member-row`, `.termin-row`,
   `.termin-row__abmeldungen`, `.heute-next__termin`, `.heute-recent__all`,
   `.mappe-row`, and the five checkbox labels that carry one
   (`.member-erase__confirm`, `.import-toggle`, `.termin-delete__confirm`,
   `.termin-form__check`, `.termin-stimmen__box` — grepped for
   `min-height: 2.75rem` in this commit; this comment said "four" and named
   none, which is the same shape as the "45 anchors" slip below). **A bare `<a>`
   matches none of
   them, and it could not be rescued by adding one more `min-height` to a
   selector list: `min-height` does not apply to a non-replaced inline box
   (CSS 2.2 §10.5), which is exactly what an unstyled link is.** So a link only
   ever gets the floor together with a `display` change, and every rule above
   that does it was hand-written for one component. These links were the case
   where nobody wrote one — not a violated convention, an unreachable one.

   So the floor is attached HERE by POSITION rather than by memory: a link that
   is the only element in a `<p>` or a `<dd>` inside the app shell is a
   standalone action, not prose, and gets a target whatever class it does or
   does not carry. WCAG 2.5.8's inline exception is why the selector is this
   narrow and not `a` — a link inside a sentence is exempt and would be wrecked
   by a `display` change.

   **`:only-child` is a weaker fence than that sentence implies, and this is the
   correction rather than the claim.** Text nodes are not element siblings, so
   `<p>Weiter zu <a>den Einstellungen</a> und zurueck.</p>` matches: measured in
   review of #181 by injecting exactly that into `.shell-main`, the link becomes
   `display: flex`, 144x44, and the paragraph grows from one line to 93.6px with
   the link on its own line. **It is latent and not live**: every `<p>` and
   `<dd>` containing an `<a>` in `internal/view/*.templ` was enumerated at
   `e49f442` — nine, of which this rule matches six and all six are intended,
   and the other three carry `class="btn"` and are excluded by `:not([class])`.
   `internal/view/tap_targets_test.go`'s regexp demands `<p><a …>…</a></p>` with
   nothing between the tags, so it does not catch the prose case either. A
   template that puts a link inside a sentence is the shape to watch for.

   `:not([class])` is the opt-out, and it is the defect's own words: #57's three
   links "carry no class at all". The moment a link is given a class it has
   opted into a component that sets its own box, and this rule steps aside
   rather than fighting it — without it, `.qr-preprint`'s and
   `import.templ`'s `<p><a class="btn">` would be stretched full-width by a
   (0,2,2) selector beating `.btn`'s (0,1,0).

   **What this reaches, enumerated by grepping `<a\b` across
   `internal/view/*.templ` in this commit** — 45 hits, of which 2 are prose
   comments about nested anchors (`events.templ`, `heute.templ`) and 43 are
   anchor elements. Four of the 43 match the `<p>` half:
   `members.templ`'s "Zurück zur Liste" (#57's), the identical foot-of-page
   links in `mappen.templ` and `events.templ`, and — **not a foot-of-page link,
   which this comment used to call it** — `import.templ`'s `import.to_settings`,
   which sits inside the `!data.HasVoiceParts` alert near the TOP of the import
   form. #57 measured none of these three, because its probe rendered only the
   member screens. Two more match the `<dd>` half, both on the member detail
   screen: the `mailto:` and `tel:` links.
   `TestTheFootOfPageLinkHasTheSameShapeOnEveryScreenThatHasOne` in
   `internal/view/tap_targets_test.go` covers the first three and not the
   import one, for the same reason: it is not at the foot of a page.

   **What it does NOT reach, and is a finding rather than an omission:**

   - `admin.templ`'s choir-row link is a bare `<a>` among siblings inside a
     `<div>`, so it is neither `:only-child` of a `<p>`/`<dd>` nor covered by a
     component class — measured at 112x25 at 360px and 320px on the admin
     choirs screen, which is admin-only and outside #57.
   - `.member-head__export-contact`, X1's "mit Kontaktdaten" control on the
     member LIST screen (`memberExport`, inside `.member-head__actions`), is a
     `<label>` wrapping a checkbox inside `.shell-main` with **no floor of its
     own** (see its rule below: `display`, `align-items`, `gap`, `font-size`).
     It measures 168x24 at 360px and at 320px, on `origin/main` and here alike.
     The enumeration above said the remaining sub-44px boxes were "the 18px
     checkbox inputs whose wrapping label is the documented target", and for
     this one the wrapping label IS the 24px box — review of #181 found it, and
     it is named here rather than left inside a sentence that denies it.

   Neither is fixed here. Both are pre-existing and unchanged by #57.

   The `<p>` case is `fit-content` and the `<dd>` case is not, deliberately: a
   fact row's value column IS the target #57 asks for ("making the whole `<dd>`
   row a 44px tap target would serve the actual use better than padding the
   anchor"), whereas a full-width invisible band under a foot-of-page link would
   navigate on a tap 200px away from any ink. */
.shell-main p > a:only-child:not([class]),
.shell-main dd > a:only-child:not([class]) {
  display: flex;
  align-items: center;
  min-height: 44px;
  max-width: 100%;
}

.shell-main p > a:only-child:not([class]) {
  width: fit-content;
}


/* ---------- Forms ---------- */

label {
  display: block;
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--ink-soft);
  margin-bottom: 0.25rem;
}

input:not([type='checkbox']):not([type='radio']),
select,
textarea {
  font-family: var(--font-body);
  font-size: 1rem;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  min-height: 44px;
  padding: 0.5rem 0.75rem;
  width: 100%;
  max-width: 26rem;
}

input:disabled,
input[readonly],
select:disabled,
textarea:disabled,
textarea[readonly] {
  background: var(--surface-soft);
  color: var(--ink-soft);
}

input[type='checkbox'],
input[type='radio'] {
  width: 1.125rem;
  height: 1.125rem;
  accent-color: var(--accent);
}

.field {
  margin-bottom: 1rem;
}

.field-error {
  color: var(--danger);
  font-size: 0.875rem;
  margin: 0.25rem 0 0;
}

/* ---------- Shared components ---------- */

.card {
  background: var(--surface);
  border-radius: var(--radius-card);
  border: 1px solid var(--border);
  box-shadow: var(--shadow-card);
  padding: 1.25rem;
  margin-bottom: 1rem;
}

.pill {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  border-radius: var(--radius-pill);
  padding: 0.125rem 0.75rem;
  font-size: 0.8125rem;
  font-weight: 600;
  background: var(--surface-soft);
  color: var(--ink-soft);
  text-decoration: none;
}

.alert-error {
  background: var(--rose-soft);
  color: var(--danger);
  border-radius: 10px;
  padding: 0.625rem 0.875rem;
  font-weight: 500;
}

.status-ok {
  color: var(--trust);
  font-weight: 500;
}

.muted {
  color: var(--ink-soft);
}

.empty-state {
  text-align: center;
  color: var(--ink-soft);
  padding: 2rem 1rem;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* ---------- Icons ---------- */
/* Inline SVG only (design.md §5) — no icon font, no sprite CDN. Stroke
   colour tracks currentColor so every icon follows its surrounding text
   colour and the active theme automatically. */

.tab-icon {
  width: 1.35rem;
  height: 1.35rem;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.icon-menu-dots {
  width: 1.25rem;
  height: 1.25rem;
  flex: none;
}

.icon-menu-dots circle {
  fill: currentColor;
}

/* ---------- App shell ---------- */
/* Mobile-first: the rail/tab-bar wrappers "dissolve" (display: contents) so
   the tab bar keeps its fixed-bottom-bar styling and the header/main flow
   normally. The rail only materialises as a left column from 48rem up
   (design.md §2 — same breakpoint and mechanism as gemeinde-app's shell). */

.shell-body,
.shell-content,
.rail {
  display: contents;
}

.rail__logo {
  display: none;
}

.shell-main {
  padding: 1.25rem 1rem 2rem;
  width: 100%;
  max-width: 64rem;
  margin: 0 auto;
}

.tab-bar {
  display: flex;
}

.tab-bar__item {
  flex: 1 1 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.125rem;
  min-height: 48px;
  padding: 0.25rem;
  color: var(--ink-soft);
  text-decoration: none;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 0.75rem;
}

.tab-bar__item:hover {
  color: var(--ink);
}

.tab-bar__item--active {
  color: var(--accent);
}

@media (max-width: 47.9375rem) {
  .tab-bar {
    position: fixed;
    z-index: 10;
    bottom: 0;
    left: 0;
    right: 0;
    border-top: 1px solid var(--border);
    background: var(--surface);
    padding: 0.375rem calc(0.5rem + env(safe-area-inset-right))
      calc(0.375rem + env(safe-area-inset-bottom)) calc(0.5rem + env(safe-area-inset-left));
  }

  .shell-main {
    padding-bottom: calc(var(--tab-bar-height) + env(safe-area-inset-bottom));
  }
}

@media (min-width: 48rem) {
  .shell-body {
    display: flex;
    align-items: flex-start;
  }

  .shell-content {
    display: block;
    flex: 1 1 auto;
    min-width: 0;
  }

  .rail {
    display: flex;
    flex-direction: column;
    align-items: center;
    flex: none;
    width: 88px;
    padding: 14px 6px;
    border-right: 1px solid var(--border);
    /* Below the fixed header (z 20, see .shell-header): sticky rather than
       fixed so it scrolls with shell-content's natural document flow but
       stays put while the page is short enough not to need it to. */
    position: sticky;
    top: 0;
    align-self: stretch;
  }

  .rail__logo {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 40px;
    flex: none;
    border-radius: 12px;
    margin-bottom: 12px;
    background: linear-gradient(
      150deg,
      color-mix(in srgb, var(--accent) 78%, var(--surface)),
      var(--accent)
    );
    color: var(--accent-ink);
    font-family: var(--font-display);
    font-weight: 800;
    font-size: 1.125rem;
  }

  .tab-bar {
    flex-direction: column;
    gap: 6px;
    width: 100%;
  }

  .tab-bar__item {
    flex-direction: column;
    gap: 4px;
    width: 100%;
    padding: 8px 2px;
    border-radius: 13px;
    font-size: 0.6875rem;
  }

  .tab-bar__item:hover {
    background: var(--surface-soft);
  }

  .tab-bar__item--active {
    background: color-mix(in srgb, var(--accent) 13%, transparent);
  }
}

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

.shell-header {
  display: flex;
  align-items: center;
  gap: 0.75rem 1rem;
  flex-wrap: wrap;
  padding: calc(0.625rem + env(safe-area-inset-top)) calc(1rem + env(safe-area-inset-right))
    0.625rem calc(1rem + env(safe-area-inset-left));
  background: var(--surface);
  border-bottom: 1px solid var(--border);
}

.shell-header__brand {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.wordmark {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.2rem;
  letter-spacing: -0.01em;
  color: var(--ink);
}

.shell-header__choir {
  margin-left: auto;
  color: var(--ink-soft);
  font-size: 0.875rem;
  font-weight: 600;
}

.shell-header__controls {
  position: relative;
  display: flex;
  align-items: center;
  margin-left: auto;
}

.shell-header__menu-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  min-height: 44px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  background: var(--surface);
  color: var(--ink);
}

.shell-header__menu-btn:hover {
  background: var(--surface-soft);
}

.shell-header__menu-btn[aria-expanded='true'] {
  background: var(--accent-soft);
}

.shell-header__menu {
  position: absolute;
  z-index: 30;
  top: calc(100% + 0.5rem);
  right: 0;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 0.25rem;
  min-width: 13rem;
  padding: 0.5rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  background: var(--surface);
  box-shadow: var(--shadow-raised);
}

.shell-header__user {
  padding: 0.375rem 0.75rem;
  color: var(--ink-soft);
  font-size: 0.8125rem;
  font-weight: 600;
}

.shell-header__menu a,
.shell-header__menu .btn-quiet {
  display: flex;
  align-items: center;
  min-height: 44px;
  padding: 0.375rem 0.75rem;
  border-radius: 10px;
  color: var(--ink);
  text-decoration: none;
  font-size: 0.9375rem;
  font-weight: 500;
  justify-content: flex-start;
}

.shell-header__menu a:hover,
.shell-header__menu .btn-quiet:hover {
  background: var(--surface-soft);
}

/* ---------- Choir switcher ---------- */
/* Only rendered at all when the caller can see more than one choir
   (design.md §2 — hidden entirely for a conductor with exactly one choir). */

.choir-switcher {
  display: flex;
  flex-wrap: wrap;
  gap: 0.375rem;
  margin-left: auto;
}

.choir-switcher__item {
  text-decoration: none;
}

.choir-switcher__item--active {
  background: var(--accent);
  color: var(--accent-ink);
}

/* ---------- Admin area ---------- */
/* Behind the header/profile menu, never a tab (design.md §2). */

.admin-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

/* flex-wrap is what stops the actions crushing the name: a wrapped flex line
   breaks against each item's content-based base size rather than its shrunk
   size, so the buttons drop to their own line instead of squeezing the name to
   nothing. The phone layout is the primary one (design.md §1). */
.admin-list__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.75rem 1rem;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface-soft);
}

/* The basis does NOT prevent the overlap — flex-wrap above does, and deleting
   this line alone still measures no overlap from 360 to 1280. What it prevents
   is the wrap firing at DESKTOP widths: without it a long name wraps to a
   two-line row at 768 where the pre-#37 layout is one line, so the no-basis
   variant is the one that changes the desktop layout rather than preserving it.

   overflow-wrap lets an unbroken address break instead of running under the
   buttons: a ~90-character email still overlaps them around 500–520px without
   it. That case is the invite row, which predates #37. */
.admin-list__main {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem;
  flex: 1 1 12rem;
  min-width: 0;
  overflow-wrap: anywhere;
}

.admin-list__main a {
  text-decoration: none;
  color: var(--ink);
}

.admin-list__main p {
  flex-basis: 100%;
  margin: 0.125rem 0 0;
  font-size: 0.8125rem;
}

.admin-list__action {
  flex: none;
  margin: 0;
}

/* Two actions on one row. The conductor rows carry "Entfernen" plus #37's
   "Überall abmelden"; .admin-list__row is space-between, so without a wrapper
   the two buttons would drift apart to opposite ends of the row. */
.admin-list__actions {
  flex: 0 1 auto;
  display: flex;
  /* Two German labels side by side are ~317px, which still overflows the card
     on a 360px phone; below that they stack rather than spilling out of it. */
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: 0.5rem;
  /* On the wrapped line the row's space-between would otherwise strand the
     buttons at the left edge, under the name they act on. */
  margin-left: auto;
}

.admin-list__actions form {
  margin: 0;
}

.admin-assign-form {
  display: flex;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: 0.75rem;
  padding-top: 0.75rem;
  border-top: 1px solid var(--border);
}

.admin-settings__meta {
  margin: 0.75rem 0 0;
  font-size: 0.8125rem;
}

/* ---------- Auth screens (login, "Kein Zugriff") ---------- */

.auth-screen {
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
}

.auth-card {
  width: 100%;
  max-width: 26rem;
  text-align: center;
}

.auth-card .btn {
  width: 100%;
  margin-top: 0.5rem;
}

/* ---------- The public /m/<label> return page (P9, P10; ADR 0003) ---------- */
/* The app's one unauthenticated screen, and the only one whose reader has never
   seen this app: somebody holding a Liedermappe they found. design.md §4a asks
   for it to "look finished rather than like an error page", so it borrows the
   centred single-card shape of the auth screens above rather than inventing one
   — and it deliberately shares no CLASS with them either, because those are
   about signing in and this one must never grow a button that suggests it.

   There is no .tab-bar and no .shell-header on this page (ADR 0003 clause (f)),
   so nothing here allows for their height or their safe-area insets. */

.public-return {
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
}

.public-return-card {
  width: 100%;
  max-width: 26rem;
  text-align: center;
}

.public-return-eyebrow {
  margin: 0;
  color: var(--ink-soft);
  font-size: 0.9375rem;
}

/* The label is the largest thing on the page for the same reason it is the
   largest thing on the printed cell (P8, design.md §4): it is what a person
   matches against the folder in their hand.

   **It must never break across two lines, and at a fixed 2.25rem it did.**
   A hyphen is an ordinary line-break opportunity in CSS, so `ABCDE-999` — a
   five-letter acronym, which choir.acronym's own check permits and
   view.ParseMappeLabel accepts — wrapped after the hyphen on a 320 px screen,
   splitting the one string this page exists to show. `nowrap` forbids the
   break and the clamp is what keeps forbidding it from overflowing instead:
   the grammar bounds the label at nine characters, so the narrow end of the
   clamp is sized for the longest one this app can print rather than for the
   fixture's JC-001. Measured in a browser at 320 px by
   e2e/tests/mappe-public.spec.ts, which is the only place that can see it. */
.public-return-label {
  margin: 0.25rem 0 0;
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 8vw, 2.25rem);
  font-weight: 700;
  letter-spacing: 0.04em;
  line-height: 1.1;
  white-space: nowrap;
}

.public-return-choir {
  margin: 0.75rem 0 0;
  font-size: 1.375rem;
}

.public-return-heading {
  margin: 1.5rem 0 0;
  color: var(--ink-soft);
  font-size: 0.9375rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

/* app_settings.return_address is free MULTI-LINE text (S1) rendered as one
   escaped string, so its line breaks are real newlines in the text node rather
   than markup the template built. `pre-line` is what makes them visible; `wrap`
   keeps a long single line from overflowing the card on a phone. Changing this
   to <br> generation in the template would put hand-built markup between
   admin-entered text and a public page, which stack-decisions.md §5 rules out. */
.public-return-address {
  margin: 0.5rem 0 0;
  font-size: 1.0625rem;
  line-height: 1.5;
  white-space: pre-line;
  overflow-wrap: anywhere;
}

/* P10's fallback line. Quieter than a real address, because it is an apology
   rather than an answer — but not an error colour: the choir name above it is
   still a usable next step. */
.public-return-fallback {
  color: var(--ink-soft);
  font-size: 0.9375rem;
}

.public-return-notfound {
  margin: 0.5rem 0 0;
  color: var(--ink-soft);
}

/* ---------- Chor tab: members (MG1) ---------- */
/* The phone layout is the primary one (design.md §1) and this is the first
   screen a conductor really uses, with 70–100 rows scanned one-handed. Every
   rule below is written so that no element can end up painted over a singer's
   name: the row is a single link with nothing beside the name but its own
   status pills, and every horizontal group wraps rather than shrinking. */

.member-head {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.75rem;
  margin-bottom: 1rem;
}

/* The basis is what the head wraps against: below it the action drops to its
   own line instead of squeezing a long singer's name (design.md §1). */
.member-head__titles {
  flex: 1 1 14rem;
  min-width: 0;
  overflow-wrap: anywhere;
}

.member-head__titles h1 {
  margin: 0;
}

.member-head__choir {
  margin: 0.125rem 0 0;
  font-size: 0.875rem;
}

.member-head__action {
  flex: none;
}

/* Two actions since #13 ("Importieren" beside "Mitglied anlegen"), so they need
   a wrapping group of their own: at 360px the two buttons plus a long choir
   name do not fit one line, and without the wrap the pair would shrink below
   the 44px tap minimum rather than dropping to the next line. */
.member-head__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

/* The .xlsx export (#34) is a form rather than a link, because ADR 0007's
   opt-in needs a checkbox beside the button. It is one flex item of the group
   above and lays its own two controls out the same way, so at 360px the
   checkbox and the button wrap together instead of the button drifting away
   from the tick that changes what it downloads. */
.member-head__export {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

.member-head__export-contact {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  font-size: 0.875rem;
}

/* --- filter form --- */

.member-filter {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  margin-bottom: 1rem;
}

.member-filter .field {
  margin-bottom: 0;
}

.member-filter__search input {
  max-width: none;
}

.member-filter__chips {
  border: 0;
  margin: 0;
  padding: 0;
  min-width: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.375rem;
}

.member-filter__chips legend {
  float: left;
  width: 100%;
  padding: 0;
  margin-bottom: 0.25rem;
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--ink-soft);
}

/* One filter chip: a pill wrapped around a real, visible radio.

   The radio is not hidden, and that is the correctness decision this rule
   exists to record. htmx swaps the LIST and never re-renders the filter form,
   so anything the server rendered to say "this chip is selected" goes stale the
   moment a chip is tapped — measured in headless Chromium at 360px, a
   server-rendered .chip--active left the old chip and the new one both painted
   as selected. The radio's own checked state is the only marker that follows
   the tap, and it brings its own focus ring with it, which a visually-hidden
   input cannot. */
.chip {
  display: inline-flex;
  align-items: center;
  gap: 0.4375rem;
  /* 44px, matching .member-row and design.md §3's touch-target floor. §3 is
     written about the attendance roster, but a filter chip is the second-most
     tapped control on this screen and there is no reason for it to be the one
     exception. */
  min-height: 2.75rem;
  padding: 0.25rem 0.875rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  background: var(--surface);
  color: var(--ink);
  font-size: 0.875rem;
  font-weight: 600;
  margin-bottom: 0;
  cursor: pointer;
}

/* Enhancement only, and deliberately not the sole marker: a browser without
   :has() still shows the selection through the radio itself. The tint is
   --accent-soft rather than --accent so the radio inside stays legible against
   it in both themes. */
@supports selector(:has(input)) {
  .chip:has(input:checked) {
    background: var(--accent-soft);
    border-color: var(--accent);
  }
}

/* The label is the tap target, not the 18px checkbox inside it, so it carries
   the same 44px floor as the chips and the rows. Measured at 24px before this
   line — found by widening the tap-target probe from "chips" to every tappable
   control on the screen, which is the check the chip fix should have had. */
.member-filter__toggle {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-height: 2.75rem;
  margin-bottom: 0;
  font-weight: 500;
  color: var(--ink);
}

.member-filter__apply {
  align-self: flex-start;
}

/* --- the list --- */

.member-list__total {
  margin: 0 0 0.5rem;
  font-size: 0.875rem;
}

.member-group {
  margin-bottom: 0.75rem;
}

.member-group__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin: 0 0 0.5rem;
  font-size: 1.0625rem;
}

.member-group__rows {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

/* The whole row is the link, and it is a flex line that WRAPS: a wrapped line
   breaks against each item's content-based base size rather than its shrunk
   size, so a long name pushes the status pills onto their own line instead of
   having them painted over it. min-height keeps the tap target at 44px. */
.member-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  min-height: 44px;
  padding: 0.5rem 0.75rem;
  border-radius: 10px;
  color: var(--ink);
  text-decoration: none;
}

.member-row:hover {
  background: var(--surface-soft);
}

/* overflow-wrap lets an unbroken name — "Charlottenburg-Wilmersdorf" is a real
   German surname shape — break rather than run out of the card at 360px. */
.member-row__name {
  flex: 1 1 10rem;
  min-width: 0;
  overflow-wrap: anywhere;
}

.member-row__mark {
  flex: none;
}

/* --- detail --- */

/* `align-items: center` arrived with #57 and is about the term column, not the
   values: the contact rows' `<dd>` now holds a 44px link (see the standalone-link
   rule above), and a grid's default `stretch` leaves "Telefon" painted at the top
   of a 44px row while the number it labels sits centred 12px lower.

   It changes nothing on a row whose two cells are the same height, which is
   every row that has no link in it — so the effect on the two OTHER users of
   this grid is confined to a value that WRAPS: `MemberLifecycle`'s Eintritt and
   Austritt dates never do, and the CSV preview's (import.templ) long note or
   email now centres its term instead of top-aligning it. Enumerated by grepping
   `member-facts` across internal/view/*.templ in this commit: three call sites,
   these two and the member detail screen itself. */
.member-facts {
  display: grid;
  grid-template-columns: minmax(6rem, auto) 1fr;
  align-items: center;
  gap: 0.375rem 1rem;
  margin: 0;
}

.member-facts dt {
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--ink-soft);
}

.member-facts dd {
  margin: 0;
  min-width: 0;
  overflow-wrap: anywhere;
}

.member-note {
  margin: 0;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

/* --- the erasure card (#14, requirement G3) --- */
/* Administrator-only and irreversible, so it is set apart from the reading
   cards above it rather than blending in: the border is the danger colour, and
   the paragraphs sit above the control rather than beside it, so the
   consequences are read before the checkbox is reachable. */
.member-erase {
  border-color: color-mix(in srgb, var(--danger) 45%, var(--border));
}

.member-erase p {
  margin: 0 0 0.5rem;
}

/* The one line in this card naming what the administrator cannot get back: the
   free-text notes about the person are cleared too (ADR 0001 decision 10),
   including the harmless ones a conductor typed on purpose. Full ink weight
   rather than --ink-soft, so it does not read as more of the same small print.
   Until #65 the same rule carried the opposite sentence — that the note
   survived — which was then the only control on that residue. */
.member-erase__caveat {
  color: var(--ink);
  font-weight: 600;
}

/* The label is the tap target, not the 18px checkbox inside it, so it carries
   design.md §3's 44px floor — the same correction .member-filter__toggle
   records. `align-items: flex-start` rather than center because this label
   wraps to three lines on a 360px phone and a centred checkbox would then
   float beside the middle of the sentence. */
.member-erase__confirm {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  min-height: 2.75rem;
  margin: 0 0 0.75rem;
  font-weight: 500;
  color: var(--ink);
}

/* The checkbox must not shrink when the sentence beside it wraps: a flex item
   with a default `flex-shrink: 1` and an intrinsic 18px width is squeezed to a
   sliver at 360px, which is both unhittable and unreadable as a state. */
.member-erase__confirm input {
  flex: 0 0 auto;
  margin-top: 0.5rem;
}

/* --- forms --- */

.field-hint {
  margin: 0.25rem 0 0;
  font-size: 0.8125rem;
}

.member-form__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

/* ---------- Chor-Einstellungen (#12) ---------- */
/* The conductor's own screen for one choir: voice parts (C2) and the two C3
   numbers. Reached from the profile menu, never a tab (design.md §2). */

.stimmen-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

/* flex-wrap for the reason .admin-list__row records: a wrapped flex line breaks
   against each item's content-based base size rather than its shrunk size, so
   the actions drop to their own line instead of squeezing the Stimme name to
   nothing. This is the same shape as the row that shipped that defect in
   wave 1 — a list row with per-row actions — so it is wrapped from the start.
   The phone layout is the primary one (design.md §1). */
.stimmen-list__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  padding: 0.75rem 1rem;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface-soft);
}

/* Both forms dissolve, the same display:contents trick the rail uses above, so
   that the two fields and the TWO buttons are items of one wrapping flex line
   instead of two independently-wrapping boxes. Without it the row is three
   ragged lines on a phone — fields, then "Speichern" alone at the left, then
   "Löschen" alone at the right — because a form can only wrap within itself.
   Rename and delete have to be separate forms (forms cannot nest), and this is
   what stops that markup constraint from reaching the layout.

   The hidden choir-id inputs are display:none by the UA stylesheet, so
   dissolving their forms adds no flex items with boxes. */
.stimmen-list__edit,
.stimmen-list__delete {
  display: contents;
}

.stimmen-list__field {
  min-width: 0;
}

.stimmen-list__field--name {
  flex: 1 1 8rem;
}

/* Wide enough for three digits plus the browser's spinner; the field is a
   position, not a quantity anyone types at length. */
.stimmen-list__field--sort {
  flex: 0 1 5.5rem;
}

.stimmen-list__row button {
  flex: 0 0 auto;
}

/* margin-left: auto keeps the destructive action at the row's end, on its own
   line as much as beside the others — never under the name it acts on, and
   never adjacent to "Speichern" by accident on a phone. */
.stimmen-list__row .btn-danger {
  margin-left: auto;
}

.stimmen-list__note {
  margin: 0.75rem 0 0;
  font-size: 0.8125rem;
}

/* Same shape as .admin-assign-form: a secondary form separated from the list
   above it, wrapping rather than overflowing the card. */
.stimmen-add {
  display: flex;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: 1rem;
  padding-top: 1rem;
  border-top: 1px solid var(--border);
}

.stimmen-add h3 {
  flex: 1 1 100%;
  margin: 0;
}

.stimmen-add .field {
  margin-bottom: 0;
}

.stimmen-add__name {
  flex: 1 1 12rem;
  min-width: 0;
}

.stimmen-add__sort {
  flex: 0 1 7rem;
}

/* ---------- Member lifecycle: Eintritt / Austritt / Pause (#11) ---------- */
/* The member-detail section the five lifecycle writes swap. Two cards inside
   one wrapper, because a written Pause changes the "Pause" mark on the record
   as well as the list of Pausen underneath it. */

.member-lifecycle__status {
  margin: 0.75rem 0 0;
}

/* The Eintritt and Austritt forms stack — field, then button — rather than
   laying the two out as a flex row. Two reasons, and the second is the one a
   flex row gets wrong: at 360px a date input and "Austritt speichern" do not
   fit on one line anyway, and the Austritt field carries a hint underneath, so
   any cross-axis alignment leaves the button either floating above the hint or
   dangling a line below the input it belongs to. */
.member-lifecycle__form {
  margin-top: 1rem;
  padding-top: 1rem;
  border-top: 1px solid var(--border);
}

.member-lifecycle__form .field {
  margin-bottom: 0.75rem;
}

.pausen-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

/* Same wrapping row as .stimmen-list__row and .admin-list__row — a list row
   with per-row actions is the shape that has twice shipped buttons painted over
   the text beside them. Here the facts additionally take a whole line of their
   own (flex-basis 100% below), so the two buttons can never share a line with
   the dates they act on however narrow the screen gets. */
.pausen-list__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  padding: 0.75rem 1rem;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface-soft);
}

.pausen-list__facts {
  flex: 1 1 100%;
  min-width: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

.pausen-list__range {
  font-weight: 600;
}

.pausen-list__mark {
  flex: none;
}

/* The reason is free text a conductor typed, capped at 200 characters, so it
   gets its own line and breaks inside a word rather than pushing anything out
   of the card. */
.pausen-list__reason {
  flex: 1 1 100%;
  min-width: 0;
  overflow-wrap: anywhere;
  font-size: 0.875rem;
  color: var(--ink-soft);
}

/* Both forms dissolve, the trick .stimmen-list__edit records: "beenden" and
   "löschen" cannot nest as forms, and without display:contents each would wrap
   only within itself — three ragged blocks instead of one wrapping row. */
.pausen-list__end,
.pausen-list__delete {
  display: contents;
}

.pausen-list__field {
  flex: 1 1 9rem;
  min-width: 0;
  margin-bottom: 0;
}

.pausen-list__row button {
  flex: 0 0 auto;
}

/* The destructive action sits at the row's end, never adjacent to "Pause
   beenden" by accident on a phone. */
.pausen-list__row .btn-danger {
  margin-left: auto;
}

/* Same shape as .stimmen-add: a secondary form under the list it adds to,
   wrapping rather than overflowing the card. */
.pausen-add {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: 1rem;
  padding-top: 1rem;
  border-top: 1px solid var(--border);
}

.pausen-add h3 {
  flex: 1 1 100%;
  margin: 0;
}

.pausen-add .field {
  margin-bottom: 0;
}

.pausen-add__date {
  flex: 1 1 9rem;
  min-width: 0;
}

.pausen-add__reason {
  flex: 1 1 14rem;
  min-width: 0;
}

/* A line of its own, so the submit button does not follow the tallest hint
   around as the fields rewrap. */
.pausen-add__actions {
  flex: 1 1 100%;
}

/* ---------- CSV import (#13) ---------- */
/* The two-step dialog MG6 models on LibreOffice's: the format form stays put,
   the preview is swapped in beneath it.

   There is no table anywhere in here. Seven columns of a member row do not fit
   a phone, and the phone layout is the primary one (design.md §1), so a preview
   row is a card with a definition list — the same .member-facts grid the member
   detail screen uses.

   **Measured, not assumed, and re-measured after the #11/#14 rebase put a
   lifecycle section and an erasure card in this stylesheet.** Four screens —
   the import step-1 form, the preview with every section populated, and the
   member list with and without the completion banner — were rendered to HTML
   and driven through chrome-headless-shell at 360px and again at 320px. On all
   eight combinations: documentElement.scrollWidth == clientWidth (no horizontal
   overflow), no element's bounding box crossing the viewport edge, and no
   tappable control under 44px high — checked over every a, button, input,
   select, textarea and every label wrapping a control, which is a wider net
   than the first pass used. The fixture carried the longest realistic values on
   purpose: a 35-character double-barrelled name, a 38-character email, and a
   two-line note.

   What that check did NOT cover: a real touch device, and any viewport between
   360px and the 720px rail breakpoint. The dark theme was read visually rather
   than measured, and only before the rebase.

   **The paragraph that used to close this comment has been withdrawn.** It
   recorded the same sweep flagging two sub-44px links on the MEMBER DETAIL
   screen — the `tel:` link and "Zurück zur Liste" — and told the next person
   running the sweep not to go looking for a regression, on the argument that
   they "sit outside the 44px convention `button, .btn` sets rather than
   violating it". That reading is what #57 rejected: a floor no rule can reach
   is not a convention with an exception, it is a gap. They are fixed by the
   standalone-link rule near `button, .btn`, which also names the two the sweep
   missed (`mailto:`, and the same foot-of-page link on three other screens) and
   the one nothing here fixes (`admin.templ`'s choir row). */

.import-columns {
  font-size: 0.875rem;
  overflow-wrap: anywhere;
}

/* Same shape and the same 44px minimum as .member-filter__toggle: an 18px
   checkbox is not a tap target one-handed in a rehearsal room. */
.import-toggle {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-height: 2.75rem;
  margin-bottom: 0;
  font-weight: 500;
  color: var(--ink);
}

/* Wrapping, not a row: at 360px "Vorschau anzeigen" and "Abbrechen" do not fit
   side by side, and a wrapped flex line keeps each of them full-size instead of
   shrinking both below the tap minimum. */
.import-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

.import-actions--confirm {
  margin-top: 1rem;
}

.import-counts {
  list-style: none;
  margin: 0.75rem 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.375rem 1rem;
  font-size: 0.9375rem;
}

.import-counts__bad {
  color: var(--danger);
  font-weight: 600;
}

.import-rows {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.import-row {
  padding: 0.75rem 1rem;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface-soft);
}

.import-row__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.375rem 0.5rem;
  margin: 0 0 0.5rem;
}

/* flex-basis 100% is what keeps a long name on its own line rather than
   squeezing the two pills beside it to nothing — the .admin-list__row lesson,
   applied before it can bite. */
.import-row__name {
  flex: 1 1 100%;
  font-weight: 600;
  overflow-wrap: anywhere;
}

.import-row__status {
  background: var(--trust-soft);
  color: var(--trust);
}

.import-row__status--skip {
  background: var(--rose-soft);
  color: var(--danger);
}

.import-row__note {
  display: block;
  font-size: 0.8125rem;
}

.import-section {
  margin-top: 1.25rem;
  padding-top: 1rem;
  border-top: 1px solid var(--border);
}

.import-section h3 {
  margin: 0 0 0.25rem;
}

/* #66's Stimmen mapping. The hint sits BETWEEN the label and the select rather
   than after it — it says which lines the decision covers, which is what the
   conductor needs before choosing — so the select needs the gap .field-hint's
   own top-only margin does not leave it. */
.import-mapping .field-hint {
  margin-bottom: 0.375rem;
}

.import-flags {
  list-style: none;
  margin: 0.5rem 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.import-flags > li {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.25rem 0.5rem;
  overflow-wrap: anywhere;
}

.import-flags__reasons {
  flex: 1 1 100%;
  margin: 0;
  padding-left: 1.25rem;
  font-size: 0.875rem;
  color: var(--ink-soft);
}

/* The post-import summary on the member list. --trust rather than --accent:
   this is a completion report, and it sits above a roster that has just changed
   by ~100 rows. */
.import-summary {
  border-color: var(--trust);
  background: var(--trust-soft);
}

.import-summary h2 {
  margin-top: 0;
}

.import-summary p {
  margin: 0 0 0.5rem;
}

/* ---------- Termine tab (#15) ---------- */
/* The Termin list, one Termin's detail and the create/edit form. No new colour
   is invented here: every state below reuses the existing tokens, and every one
   of them carries its own LABEL TEXT as well, so design.md §3's "never colour
   alone" holds whatever a reader's vision or printer does. */

/* Mirrors .member-head, including the wrapping basis: below it the action drops
   to its own line rather than squeezing a long choir name (design.md §1). */
.termin-head {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.75rem;
  margin-bottom: 1rem;
}

.termin-head__titles {
  flex: 1 1 14rem;
  min-width: 0;
  overflow-wrap: anywhere;
}

.termin-head__titles h1 {
  margin: 0;
}

.termin-head__choir {
  margin: 0.125rem 0 0;
  font-size: 0.875rem;
}

.termin-head__action {
  flex: none;
}

/* One card holding every row, rather than a card per Termin: a season is dozens
   of rows and a stack of shadowed cards at 390px is mostly gaps. */
.termin-list {
  list-style: none;
  margin: 0;
  padding: 0.25rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-card);
}

.termin-list > li + li {
  border-top: 1px solid var(--border);
}

/* The whole row is the link and carries nothing but its own text and pills —
   the .member-row rule, for the same reason: nothing beside the text can end up
   painted over it at 360px, because there is nothing beside it. 44px is
   design.md §3's touch-target floor. */
.termin-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.25rem 0.5rem;
  min-height: 44px;
  padding: 0.5rem 0.75rem;
  border-radius: 10px;
  color: var(--ink);
  text-decoration: none;
}

.termin-row:hover {
  background: var(--surface-soft);
}

/* tabular-nums so a column of dates lines up rather than jittering by digit
   width, which is what makes a reverse-chronological list scannable. */
.termin-row__when {
  flex: 0 0 auto;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* The basis lets the date and the description share a line when there is room
   and drop apart when there is not; overflow-wrap keeps a long Ort inside the
   card at 360px rather than running out of it. */
.termin-row__what {
  flex: 1 1 12rem;
  min-width: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.125rem 0.5rem;
  overflow-wrap: anywhere;
}

.termin-row__kind {
  color: var(--ink-soft);
}

.termin-row__title {
  font-weight: 500;
}

.termin-row__location {
  font-size: 0.875rem;
}

/* The badges always take their own line: they are the row's state and must not
   compete with the name of the Termin for horizontal space. */
.termin-row__marks {
  flex: 1 1 100%;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.375rem;
}

/* `.termin-row__state` and `.termin-row__quota` deliberately have NO rule of
   their own: both are `.pill` and are meant to look like one — the base class
   carries the appearance and these two are hooks, one of which the modifier
   below tints. Said here because a class with no rule is otherwise
   indistinguishable from a rule somebody forgot (this stylesheet has two such
   orphans predating #15, `.form-errors` and `.field__hint`, which are reported
   rather than fixed here: giving them rules would restyle five other screens). */

/* An OPEN Termin is the live one — design.md §2's "the open one badged".
   --trust, not --danger: an open Probe before the rehearsal is the normal state
   and colouring it as a problem would cry wolf on every row. A closed Termin
   keeps the plain .pill. */
.termin-row__state--open {
  background: var(--trust-soft);
  color: var(--trust);
}

/* A5's unmarked count: attention, not alarm. --accent-soft is the same tint the
   selected filter chip uses, and it is legible against --ink in both themes for
   the reason recorded there. */
.termin-row__unmarked {
  background: var(--accent-soft);
  color: var(--ink);
}

/* A4's one tap on an upcoming Termin (#17): a second link, on its OWN LINE below
   the row.

   On its own line and not beside the row's text, which is the rule
   .termin-row's comment states — "nothing beside the text can end up painted
   over it at 360px, because there is nothing beside it". A nested <a> is invalid
   HTML anyway, so this is a sibling; making it a full-width row of its own keeps
   both properties and gives the link a 44px target (design.md §3's floor) that
   no thumb can confuse with the row above it. Indented to the row's own padding
   so the two read as one Termin rather than as two list items. */
.termin-row__abmeldungen {
  display: flex;
  align-items: center;
  min-height: 44px;
  padding: 0 0.75rem 0.25rem;
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--accent);
}

/* --- detail --- */

/* STACKED on a phone, two columns above 30rem — and that is where it departs
   from .member-facts, deliberately. That grid gives the term column `auto`,
   which is right for terms like "Kontakt" and "E-Mail" but not for this
   screen's: "Zählt für die Anwesenheitsquote" is 30 characters, and measured in
   the e2e runner at 390px it squeezed the value column so hard that "Zählt für
   die Quote" and "Alle Stimmen erwartet" each wrapped to two lines. Stacking
   removes the squeeze for any term length rather than for the ones that fit
   today. */
.termin-facts {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.125rem 1rem;
  margin: 0;
}

.termin-facts dt:not(:first-of-type) {
  margin-top: 0.625rem;
}

@media (min-width: 30rem) {
  .termin-facts {
    grid-template-columns: minmax(6rem, 12rem) 1fr;
    gap: 0.375rem 1rem;
  }

  .termin-facts dt:not(:first-of-type) {
    margin-top: 0;
  }
}

.termin-facts dt {
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--ink-soft);
}

.termin-facts dd {
  margin: 0;
  min-width: 0;
  overflow-wrap: anywhere;
  white-space: pre-wrap;
}

/* The link into the roll-call screen (#16). Its own paragraph so the button is
   not baked into the state sentence above it: on an open Termin this is the
   primary action, and .btn-primary already carries the 44px floor. */
.termin-roster {
  margin: 0.75rem 0 0;
}

/* The delete cascades to every Anwesenheit recorded at the Termin and cannot be
   undone, so it is set apart from the reading cards above it rather than
   blending in — the .member-erase treatment, for the same reason and with the
   same token. */
.termin-delete {
  border-color: color-mix(in srgb, var(--danger) 45%, var(--border));
}

.termin-delete p {
  margin: 0 0 0.5rem;
}

/* The LABEL is the tap target, not the 18px checkbox inside it, so it carries
   the 44px floor — the correction .member-erase__confirm and
   .member-filter__toggle both record. flex-start rather than center because the
   sentence wraps to three lines at 360px and a centred checkbox would then
   float beside the middle of it. */
.termin-delete__confirm {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  min-height: 2.75rem;
  margin: 0 0 0.75rem;
  font-weight: 500;
  color: var(--ink);
}

/* The checkbox must not shrink when the sentence beside it wraps: a flex item
   with the default flex-shrink and an intrinsic 18px width is squeezed to a
   sliver at 360px, which is both unhittable and unreadable as a state. */
.termin-delete__confirm input {
  flex: 0 0 auto;
  margin-top: 0.5rem;
}

/* --- the form, and ADR 0002's sectional picker --- */

.termin-form__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

/* Requirement T2's checkbox. It is the input to every M5 quota, so its hint sits
   under it at full width rather than beside it, where a wrapped line would end
   up under the box. */
.termin-form__quota .field-hint {
  margin-top: 0;
}

.termin-form__check {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  min-height: 2.75rem;
  font-weight: 500;
}

.termin-form__check input {
  flex: 0 0 auto;
  margin-top: 0.5rem;
}

/* The expected-Stimmen picker. A fieldset rather than a bare group of labels, so
   the legend names the set for a screen reader as well as for the eye — the
   .member-filter__chips pattern. */
.termin-stimmen {
  border: 1px solid var(--border);
  border-radius: 10px;
  margin: 0 0 1rem;
  padding: 0.75rem;
  min-width: 0;
}

.termin-stimmen legend {
  padding: 0 0.25rem;
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--ink-soft);
}

/* The sentence that says an empty selection means every Stimme (ADR 0002
   decision 2). Full ink weight rather than --ink-soft, like
   .member-erase__caveat: it is a rule the conductor has to read, not small
   print, because a mis-ticked picker silently changes who a Probe expects. */
.termin-stimmen__hint {
  margin: 0 0 0.5rem;
  color: var(--ink);
}

.termin-stimmen__boxes {
  display: flex;
  flex-wrap: wrap;
  gap: 0.375rem 0.75rem;
}

/* Each Stimme is its own 44px target, and the box keeps its width when the name
   beside it wraps. */
.termin-stimmen__box {
  display: inline-flex;
  align-items: center;
  gap: 0.4375rem;
  min-height: 2.75rem;
  margin: 0;
  font-weight: 500;
  cursor: pointer;
}

.termin-stimmen__box input {
  flex: 0 0 auto;
}

/* ---------- Anwesenheit: the roll-call roster (#16) ---------- */
/* design.md §3 — "the screen everything else is easier than". 70–100 rows,
   marked one-handed mid-rehearsal, so the rules below are about two things
   only: nothing moves under a finger, and nothing is smaller than a finger.

   **No new token.** The four states map onto --trust / --surface-soft /
   --danger exactly as design.md §3's table prescribes, and the text on a filled
   button is --accent-ink, which is the stylesheet's "ink on a filled semantic
   colour" role (#ffffff light, #2b2118 dark — the same pair as --danger-ink).
   Inventing a colour here would be inventing it in two themes.

   **Never colour alone** (design.md §3): every state carries its WORD, and `da`
   is four times the area of either alternative (A2), so the row survives
   greyscale and red-green deficiency. That is not a precaution — it is the
   measured state of these two tokens. Relative luminance, computed from the
   token block: light --trust #34694a = 0.1133, light --danger #a63d2f = 0.1165.
   **A difference of 0.003**, so in greyscale the `da` and `gefehlt` fills are the
   same colour and the label is the only thing left. Contrast of the text on them,
   same computation: white on --trust 6.43:1 (which is the figure the token
   block's own comment records), white on --danger 6.31:1, --ink-soft on
   --surface-soft 5.29:1, and in the dark theme --accent-ink on --trust 7.36:1 and
   --danger-ink on --danger 5.41:1. All above WCAG AA's 4.5:1 for normal text.

   **The specificity is deliberate, and the precedent is .btn-danger's own
   comment above.** `button[type='submit']` is (0,1,1), so a bare `.anw-btn`
   class would lose to it and all three state buttons would render as the accent
   primary — the defect M2's review found on .btn-danger, which is why every
   rule here that has to beat it is written as `button.anw-…`. */

.anw-head {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.75rem;
  margin-bottom: 0.75rem;
}

/* The basis is what the head wraps against, exactly as .member-head__titles'
   comment records: below it the "Zurück" action drops to its own line instead
   of squeezing a long Termin title. */
.anw-head__titles {
  flex: 1 1 14rem;
  min-width: 0;
  overflow-wrap: anywhere;
}

.anw-head__titles h1 {
  margin: 0;
}

.anw-head__termin {
  margin: 0.125rem 0 0;
  font-weight: 600;
}

.anw-head__when,
.anw-head__where {
  margin: 0.125rem 0 0;
  font-size: 0.875rem;
}

.anw-head__action {
  flex: none;
}

/* The counters. This paragraph is the aria-live region and is NOT swapped —
   the span inside it is (hx-swap-oob), and a region that were itself replaced
   would announce nothing. Same trade as the member list's wrapper. */
.anw-head__counts {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.375rem;
  margin: 0 0 1rem;
}

.anw-open {
  background: var(--surface-soft);
  color: var(--ink);
}

/* Requirement A8's unsynced count (#19), which design.md §3 asks for by name:
   "the header shows the unsynced count in a warning style while the queue is
   non-empty".

   **IT IS ALWAYS IN THE DOCUMENT AND ONLY ITS VISIBILITY CHANGES, AND THAT IS A3
   RATHER THAN A STYLE.** This pill sits in the header's wrapping flex row, ABOVE
   the list — so a version that appeared and disappeared would resize or re-wrap
   that row and push every row of the roster down the page, which is A3's harm
   arriving from above exactly as `.anw-msg` below measured it at 101px. Two
   mechanisms would have done that and the first one shipped:

     - **A 1px border. Measured: every row moved 2px**, caught by
       e2e/tests/offline-queue.spec.ts' rowPositions the first time it ran
       (`y: 645 → 647` and the same on all six rows) — the border made this pill
       2px taller than the two beside it and the flex row grew with it. The
       outline is an inset box-shadow now, which paints inside the existing box
       and takes part in no layout at all.
     - **`display: none` while the count is zero. Measured: every row moved
       30px, at 320px, on the six-singer fixture** — the pill joining the
       header's wrapping flex row tipped it onto a second line, and the row
       leaving again lifted the whole roster back up (`y: 781 → 751` and the same
       on every row). At 390px the three pills still fit one line and nothing
       moved, which is why the assertion is taken at 320px: the narrowest phone
       still in use, and the width `roster.spec.ts` already measures for the same
       reason. At the pilot's 70–100 singers the neighbours read "100 erwartet"
       and "97 offen", so the margin is smaller still. So the box is permanent
       and `visibility` is what toggles: the row's height AND its wrap are then
       the same before and after the first offline tap, at every width.

   The cost is that the reserved slot is always as wide as the badge — trailing
   space at the END of a left-aligned row, which reads as nothing, and one extra
   line in the header at the widest counts. That is the trade A3 buys, and the
   space above the first singer is the thing it is paid out of (see
   `.anw-controls__view`, which defends the same space for a different reason).

   `visibility: hidden` also takes it out of the accessibility tree, so the count
   is ANNOUNCED when it appears — the enclosing `.anw-head__counts` paragraph is
   the `aria-live` region, exactly as it is for the two counters.

   --rose, not --danger and not --accent, and both halves of that are decisions.
   Not --danger, because nothing is wrong: taps waiting for reception are the
   NORMAL state of this screen in a rehearsal room, and a red badge every time a
   conductor walks behind the organ teaches them to ignore red on the one screen
   where --danger also means `gefehlt`. Not --accent either, because "warning
   style" has to be distinguishable at a glance from the two neutral pills beside
   it. --rose/--rose-soft is the stylesheet's existing third semantic pair and
   sits between the two. Contrast, computed from the token block the same way the
   state buttons' figures were: light --rose #c97b6b on --rose-soft #f4e3de is
   2.36:1, which fails AA for text — so the TEXT is --danger on --rose-soft
   (5.06:1 light, and in dark --danger #e07b6c on --rose-soft #43302b is 5.71:1)
   and --rose carries the hue in the outline. */
.anw-unsynced {
  visibility: hidden;
  background: var(--rose-soft);
  color: var(--danger);
  box-shadow: inset 0 0 0 1px var(--rose);
}

.anw-unsynced--on {
  visibility: visible;
}

/* An unconfirmed row keeps "a subtle pending mark" (design.md §3).

   **An inset box-shadow, because it is the only marking that cannot move a
   row.** A border, an outline with an offset, padding or an inserted element all
   change either the row's box or its neighbours' — and A3's harm is precisely a
   row moving under a finger that is still tapping, so a sync indicator that
   nudged the list would be worse than no indicator. An inset shadow paints
   inside the existing box and takes part in no layout at all.
   e2e/tests/offline-queue.spec.ts measures rowPositions across an offline tap for
   exactly this reason.

   **And it is never colour alone**, which on this screen is a rule rather than a
   preference (design.md §3): the row also carries a visually-hidden "noch nicht
   gesendet" and aria-busy, both applied to the same rows in the same pass, so a
   greyscale screen and a screen reader each have something that is not the hue.
   The sentence is rendered by the server and revealed here, so no German string
   lives in the script. */
.anw-row[data-pending] .anw-row__form {
  box-shadow: inset 3px 0 0 var(--rose);
}

.anw-row:not([data-pending]) .anw-row__pending {
  display: none;
}

/* A5's stamp, stated on the roster. Not an error and not a success: the Probe
   is finished, and single rows may still be corrected (plan D6). */
.anw-closed {
  border-left: 3px solid var(--accent);
  background: var(--surface);
  border-radius: 10px;
  padding: 0.625rem 0.875rem;
  margin: 0 0 1rem;
  font-size: 0.9375rem;
}

/* A4's sentence on the roster of a Termin that is still ahead (#17). Same block
   treatment as .anw-closed and deliberately so: both say which STATE the screen
   is in, neither is an error, and a conductor scanning the top of the roster
   should read them the same way. --ink-soft rather than a second accent, because
   "this Termin has not happened yet" is context and not a warning. */
.anw-lead {
  border-left: 3px solid var(--border);
  background: var(--surface);
  border-radius: 10px;
  padding: 0.625rem 0.875rem;
  margin: 0 0 0.75rem;
  color: var(--ink-soft);
  font-size: 0.9375rem;
}

/* A7's status line (#17): what the batch just did, or nothing.

   **IT FLOATS WHEN IT ARRIVES BY SWAP, AND THAT IS REQUIREMENT A3 RATHER THAN
   STYLING.** Measured with it in normal flow above the list, by
   e2e/tests/alle-da.spec.ts on the first run of this feature: the sentence
   appearing pushed **every row down 101px** — a whole row's height — at exactly
   the moment the conductor's next tap is travelling towards a row. A3's harm is a
   row moving under a finger that is still tapping, and it does not care whether
   the list moved because a row vanished or because a box above it appeared. Out
   of the flow, nothing moves.

   **THE FLOATING IS UNCONDITIONAL, AND A CLASS THAT DIFFERS BETWEEN THE PAGE'S
   COPY AND THE SWAPPED ONE IS THE SHAPE THAT LOOKS SAFE AND IS NOT.** htmx settles
   attributes: for every `[id]` in the incoming fragment it puts the OLD element's
   attributes back on the new node and restores the new ones one settle delay
   later (`Ae`/`Oe` in web/static/vendor/htmx.min.js, 20ms by default) — the
   mechanism that lets a swap be transitioned. So a `.anw-msg--float` that only
   the fragment carried arrived 20ms after the sentence did, and for those 20ms
   the sentence was an ordinary block in the flow: measured by
   e2e/tests/alle-da.spec.ts at 390x844, **every row moved down 101px and back**
   on every batch. The A3 harm is the movement, not its duration. One class on
   both copies is what makes that impossible, and it is why the sentence a page
   LOAD carries goes in a different element (.anw-msg-flow below) instead of in a
   differently-classed version of this one.

   **THE DISAPPEARANCE IS A TIMEOUT IN JAVASCRIPT, NOT AN ANIMATION, AND THE
   CASCADE IS WHY** (PR #102, review round 1). It was
   `animation: anw-msg-timeout 0s 12s forwards` — a delay-only animation whose
   `to` set `visibility: hidden` — under a comment claiming "there is no motion
   for prefers-reduced-motion to reduce". That claim was wrong about this very
   file: the `@media (prefers-reduced-motion: reduce)` block above declares
   `animation: none !important` for `*`, `*::before` and `*::after`, and
   `!important` in the same origin beats a later normal declaration — so with
   Reduce Motion on there was no animation, hence no `forwards` fill, hence no
   `visibility: hidden`. Measured at 320x720 under
   `emulateMedia({ reducedMotion: 'reduce' })`: a 90px strip whose top sat at
   y=538 of a 720px viewport, still up when the assertion gave up 25 s later, one
   row of the scrolled roster underneath it and `pointer-events: none` leaving
   that row tappable while invisible. internal/view/anwesenheit.templ's
   rosterMessageTimeout replaces it, and it is motion-independent rather than
   reduced-motion-conditional: a visibility change riding on an animation is the
   defect class here, so nothing in this rule rides on one.

   **Only a SWAP ever fills this element**, which is what makes leaving its
   removal to a script sound: it is on screen because htmx put it there, so a
   script is running. The one path that has no script — the plain form post, which
   redirects — renders its sentence in .anw-msg-flow instead.

   `pointer-events: none`, so it cannot swallow a tap meant for the row behind it,
   and it goes after 12 s — long enough to read a two-line German sentence twice,
   short enough to be gone before the conductor has scrolled to the next Stimme. A
   permanent strip over the foot of a list somebody is tapping down would be a
   worse trade than the 101px it replaces.

   `:empty` is load-bearing rather than tidy: the element is rendered on every
   load whether or not there is a sentence, because it is an out-of-band swap
   target and htmx cannot swap into an element that is not in the document. It is
   also how the timeout removes the sentence — clearing the text rather than the
   element, which htmx would need again on the next batch.

   z-index 9 sits under the fixed tab bar's 10 — the bar is navigation and stays
   reachable — and the bottom offset clears it on the phone layout, where the bar
   is fixed at the bottom (above 48rem it is the left rail instead). */
.anw-msg {
  position: fixed;
  z-index: 9;
  left: 0;
  right: 0;
  bottom: 0;
  width: calc(100% - 2rem);
  max-width: 62rem;
  margin: 0 auto 0.75rem;
  padding: 0.625rem 0.875rem;
  border-left: 3px solid var(--trust);
  border-radius: 10px;
  background: var(--surface);
  box-shadow: var(--shadow-card);
  font-size: 0.9375rem;
  pointer-events: none;
}

@media (max-width: 47.9375rem) {
  .anw-msg {
    bottom: calc(var(--tab-bar-height) + env(safe-area-inset-bottom));
  }
}

.anw-msg:empty {
  display: none;
}

/* The same sentence when a page LOAD carries it: the plain form post redirects
   back to the roster with a marker, and that screen is laid out with the sentence
   already in it — so an ordinary block moves no row by being there and needs no
   self-hide, which is the point. It is a SEPARATE element rather than this rule's
   in-flow variant because of htmx's attribute settling above: two classes on one
   id is the 101px flash, two ids is nothing at all. Same box as .anw-msg, so the
   two read as one notice in two places rather than as two different ones — and
   where htmx works this copy is only reached by loading a roster URL that still
   carries the marker. */
.anw-msg-flow {
  margin: 0 0 0.75rem;
  padding: 0.625rem 0.875rem;
  border-left: 3px solid var(--trust);
  border-radius: 10px;
  background: var(--surface);
  font-size: 0.9375rem;
}

/* --- view controls (plan D8) and A7's batch (#17) --- */

.anw-controls {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  padding: 0.875rem 1rem;
}

/* One flat, wrapping row: the search box and the toggle sit side by side above
   the roster rather than stacked under a legend. Measured at 360x720 — stacked,
   the first singer's name was at the fold and their buttons below it, on the one
   screen whose whole purpose is tapping down a list. The basis is what decides
   the wrap: below ~10rem for the search box the toggle drops to its own line
   instead of both being squeezed. */
.anw-controls__view {
  min-width: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 0.5rem 0.75rem;
}

/* 44px on the LABEL, not only on the checkbox: the whole "nur offene zeigen"
   row is the target, which is what a thumb aims at. */
.anw-controls__toggle {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-height: 44px;
  font-size: 0.9375rem;
  font-weight: 500;
}

.anw-controls__toggle input {
  width: 1.25rem;
  height: 1.25rem;
  min-height: 0;
}

.anw-controls__search {
  flex: 1 1 10rem;
  min-width: 0;
  margin-bottom: 0;
}

.anw-controls__search input {
  max-width: none;
}

.anw-controls__hint {
  margin: 0;
  font-size: 0.8125rem;
}

/* A7's "alle da" (#17). It sits in the controls card, above the first Stimme,
   because that is the one part of this screen that survives the swap the button
   itself triggers — everything inside .anw-sections is replaced by the response.

   The button keeps the accent fill `button[type='submit']` gives every submit in
   this stylesheet, and that is deliberate rather than unnoticed: "alle da" acts
   on every row at once, so it must not read like one of the three state buttons
   in a row. Enumerated here because the .btn-danger comment above records what
   happens when a modifier silently loses to that (0,1,1) selector — this rule
   adds no colour and therefore cannot lose.

   The hint wraps under the button on a narrow phone (flex-wrap), so the sentence
   that says the batch does not overwrite an Abmeldung is never clipped. */
.anw-batch {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.375rem 0.75rem;
  padding-top: 0.75rem;
  border-top: 1px solid var(--border);
}

.anw-batch__submit {
  flex: none;
}

.anw-batch__hint {
  flex: 1 1 14rem;
  min-width: 0;
  margin: 0;
  font-size: 0.8125rem;
}

/* --- the list --- */
/* `.anw-sections` wraps every Stimme block and carries RosterSectionsID: it is
   the element A7's batch swaps and it needs no rule of its own — each .card
   inside it keeps its own margin, so the wrapper changes no spacing. It is a
   wrapper rather than the whole .anw-list because the view controls must survive
   the swap: the search term and plan D8's toggle are client state the server
   never sees. */

/* A flex column, because plan D8's "Erledigte anzeigen" moves the already-marked
   rows to the foot of their own Stimme with `order` — visually, without touching
   a node. Moving DOM nodes would fight the row swap; `order` cannot. */
.anw-group__rows {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}

.anw-group {
  padding: 0.25rem 0.75rem 0.5rem;
  overflow: clip;
}

/* Sticky, so the Stimme and its live counter stay readable while the conductor
   scrolls its 30 rows. z-index 2 sits under the fixed tab bar's 10. */
.anw-group__head {
  position: sticky;
  top: 0;
  z-index: 2;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  min-height: 44px;
  padding: 0.5rem 0;
  background: var(--surface);
  cursor: pointer;
  font-family: var(--font-display);
  font-weight: 700;
}

.anw-group__name {
  overflow-wrap: anywhere;
}

.anw-count {
  background: var(--surface-soft);
  flex: none;
}

/* One singer. Column layout on purpose: a name plus three 44px targets does not
   fit 320px on one line, and shrinking the targets is the one trade design.md §3
   forbids ("cramming more rows in by shrinking them trades the one thing that
   must not fail"). */
.anw-row__form {
  display: flex;
  flex-direction: column;
  gap: 0.375rem;
  padding: 0.5rem 0;
  border-top: 1px solid var(--border);
}

.anw-row:first-child .anw-row__form {
  border-top: 0;
}

.anw-row__name {
  font-weight: 600;
  overflow-wrap: anywhere;
}

/* A2, and it is a MEASURED layout rather than a chosen one.
   `da` takes half the row's width and both of its rows; `entschuldigt` and
   `gefehlt` stack in the other half. So the dominant action is half the width
   (design.md §3) and twice the height — four times the area of either
   alternative, which is A2's "visibly larger tap target" in two channels rather
   than one.

   **Why not three buttons on one line, which is what this was first.** Measured
   in the e2e rig at 390px: "Entschuldigt" lays out **75px** wide, and a
   quarter-width button is a **78px** border box with a **68px** content box — so
   the word rendered over its own padding, touching both borders. At **320px** the
   overflow assertion failed outright: the button is narrower than the word.

   The three ways out of that were all worse than this one: shrinking the type
   below 13px on the screen design.md §3 calls "mostly tap targets"; breaking the
   word mid-syllable ("Entschuldi/gt"), since Chromium on Linux cannot be relied
   on to hyphenate German; or abbreviating the label, which is the one thing
   design.md §3 forbids outright ("Never colour alone" — the word IS the state).

   **What keeps it caught is e2e/tests/roster.spec.ts' labelDefects(), and the
   first version of that guard did not.** It measured the Range's WIDTH only,
   which the `overflow-wrap: anywhere` net below turns NEGATIVE as soon as the
   word wraps — so reverting this block to `grid-template-columns: 2fr 1fr 1fr`
   left all four roster specs green while shipping the mid-syllable break this
   comment calls rejected. Measured on that revert, at 390px:
   `{label: "Entschuldigt", lines: 2, overflow: -8}` — a wrap, reported as
   "fits". The guard now counts the Range's client RECTS too, one per line box,
   at 320px and 360px as well as the rig's 390px, and was watched failing under
   exactly that revert. */
.anw-row__states {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.375rem;
}

button.anw-btn {
  min-width: 0;
  min-height: 44px;
  padding: 0.5rem 0.375rem;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--ink);
  font-size: 0.875rem;
  font-weight: 600;
  /* A last-resort net for a label longer than these three: it wraps or breaks
     rather than leaving the button. The grid above is what makes that
     unnecessary today. */
  white-space: normal;
  overflow-wrap: anywhere;
  line-height: 1.15;
}

button.anw-btn--da {
  grid-column: 1;
  grid-row: 1 / span 2;
  font-size: 1rem;
}

/* The filled states. Only the ACTIVE one is filled — "not recorded" is outline
   only (design.md §3's table), which is what makes an unmarked row visibly
   unmarked at a glance down 100 rows. */
button.anw-btn--da.anw-btn--active {
  background: var(--trust);
  border-color: var(--trust);
  color: var(--accent-ink);
}

button.anw-btn--entschuldigt.anw-btn--active {
  background: var(--surface-soft);
  border-color: var(--ink-soft);
  color: var(--ink-soft);
}

button.anw-btn--gefehlt.anw-btn--active {
  background: var(--danger);
  border-color: var(--danger);
  color: var(--danger-ink);
}

/* --- the note (A1) --- */

.anw-note {
  font-size: 0.875rem;
}

.anw-note__summary {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  color: var(--ink-soft);
  cursor: pointer;
}

textarea.anw-note__input {
  width: 100%;
  max-width: none;
}

/* The two footers of the note disclosure are the SAME height, and that is an A3
   rule rather than a tidiness one: a marked row shows "Notiz speichern" and an
   unmarked one shows the hint, so a cell write that changes the state swaps one
   for the other. If they differed, every mark on a row with its note open would
   resize the row and shift every row below it — the -111px case
   internal/view/anwesenheit.templ's rosterAlpine comment measures. The 44px is
   the button's own floor, so matching it costs nothing. */
.anw-note__save,
.anw-note__hint {
  margin: 0.25rem 0 0;
  min-height: 44px;
}

.anw-note__hint {
  display: flex;
  align-items: center;
  font-size: 0.8125rem;
}

/* --- plan D8: "nur offene zeigen", compacted ON DEMAND --- */
/* The stamps are the client's (rosterAlpine in internal/view/anwesenheit.templ):
   `data-collapsed` is applied to the rows that were ALREADY marked at the moment
   the toggle was switched on, so a row marked afterwards keeps its place — the
   whole point of D8, because a row that vanishes under a moving finger is how
   the next singer gets mis-marked.

   A swapped fragment arrives from the server without either stamp, and the CLIENT
   is what puts them back — rosterAlpine's restore() for the one row a cell write
   replaces, its recollapse() for the whole collapsed SET when A7's batch replaces
   every row. Re-applying the set that was already collapsed is not the same as
   re-deriving compaction from the new statuses: the batch marks every row, so
   re-deriving would collapse the list and empty the screen. */
.anw-row[data-collapsed] {
  display: none;
}

/* A3 wants the marked rows checkable at will rather than gone, so they come back
   at the foot of their own Stimme (order: 1) and read as done. */
.anw-list[data-show-done] .anw-row[data-collapsed]:not([data-nomatch]) {
  display: block;
  order: 1;
  opacity: 0.72;
}

/* The name search (design.md §3). Last, and matching the reveal rule's
   `:not()`, so a hidden non-match cannot be brought back by "Erledigte
   anzeigen". */
.anw-row[data-nomatch] {
  display: none;
}

/* --- A5's close and reopen (#18) --- */
/* The card at the FOOT of the roster, after the last Stimme: the roll-call runs
   top to bottom and this is the action taken when that journey ends, so it sits
   where the finger arrives rather than where it starts. It is outside
   `.anw-list`, so A7's swap cannot replace it and no client stamp reaches it.

   Same flex shape as `.anw-batch` above and deliberately so: one submit with its
   consequence beside it, wrapping under the button on a narrow phone so the
   sentence naming the count is never clipped. It keeps the accent fill
   `button[type='submit']` gives every submit in this stylesheet — this is the
   screen's finishing action, and it must not read like one of the three state
   buttons in a row.

   No --danger treatment, and that is design.md §3 plus plan D6 rather than an
   oversight: the close is reopenable and the reopen removes exactly what the
   close created, so the red reserved for irreversible actions (the Termin delete,
   requirement G3's erasure) would misdescribe it. What carries the weight is the
   sentence under the button. */
.anw-finish__form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.375rem 0.75rem;
  margin: 0;
}

.anw-finish__submit {
  flex: none;
}

.anw-finish__hint {
  flex: 1 1 14rem;
  min-width: 0;
  margin: 0;
  font-size: 0.8125rem;
}

/* Plan D11's reason (#19): why "Probe abschließen" is refused right now.

   A full-width line of its own below the button and its consequence sentence, so
   the card grows downwards. That is free here and nowhere else on this screen:
   the card is at the FOOT of the roster, after the last Stimme, so a box that
   appears inside it pushes nothing a finger is aiming at — the same argument
   RosterCloseHintID's comment makes for swapping the sentence above it.

   --danger text, unlike .anw-finish__hint's --ink-soft, because this one is not
   context: the conductor is being told they cannot finish yet, and the harm it
   prevents is a fabricated `gefehlt` against a singer they marked present. It
   still carries no fill and no icon — the sentence is the signal, and it names
   the count. */
.anw-finish__blocked {
  flex: 1 1 100%;
  min-width: 0;
  margin: 0;
  color: var(--danger);
  font-size: 0.8125rem;
}

/* ---------- Mappen tab (#21) ---------- */
/* The folder list with its holder names, the number search, the two create
   forms and one folder's detail. No new colour and no new token: every state
   below reuses .pill and carries its own LABEL TEXT, so design.md §3's "never
   colour alone" holds whatever a reader's vision or printer does.

   The row is the screen's whole risk at 360px. It carries a label, up to
   `mappe_slots` holder names (C3 caps that at 5) and two pills — more content
   per row than the member list has — so every group below WRAPS rather than
   shrinking, and the names get the flexible basis. A wrapped flex line breaks
   against each item's content-based base size, so a long name pushes the pills
   onto their own line instead of having them painted over it. */

/* Mirrors .member-head and .termin-head, including the wrapping basis: below it
   the actions drop to their own line rather than squeezing the choir name. */
.mappe-head {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.75rem;
  margin-bottom: 1rem;
}

.mappe-head__titles {
  flex: 1 1 14rem;
  min-width: 0;
  overflow-wrap: anywhere;
}

.mappe-head__titles h1 {
  margin: 0;
}

.mappe-head__choir {
  margin: 0.125rem 0 0;
  font-size: 0.875rem;
}

.mappe-head__action {
  flex: none;
}

/* Wraps, exactly like .member-head__actions since #13: at 360px the actions plus
   a long choir name do not fit one line, and without the wrap they would shrink
   below the 44px tap minimum rather than dropping to the next line.

   **This comment said "Two actions" and named them, and was wrong twice over by
   the time #25 read it**: #26 added "QR-Bogen" and #25 adds "Nummer eingeben".
   The count is gone rather than corrected — the rule is about wrapping, which
   holds for any number of them, and a numeral here is one more thing for the
   next task to update from a wrong base (the same reason
   internal/api/validate.go's header gives for not counting its own list). */
.mappe-head__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

/* --- the number search --- */

.mappe-search {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  margin-bottom: 1rem;
}

.mappe-search .field {
  margin-bottom: 0;
}

.mappe-search__field input {
  max-width: none;
}

.mappe-search__apply {
  align-self: flex-start;
}

/* --- the list --- */

.mappe-list__total {
  margin: 0 0 0.5rem;
  font-size: 0.875rem;
}

.mappe-list__rows {
  list-style: none;
  margin: 0;
  padding: 0;
}

.mappe-list__rows > li + li {
  border-top: 1px solid var(--border);
}

/* The whole row is the link and carries nothing but its own text and pills —
   .member-row's rule, for the same reason. 44px is design.md §3's touch-target
   floor, and #57 is the reminder to check it rather than assume it. */
.mappe-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.25rem 0.75rem;
  min-height: 44px;
  padding: 0.5rem 0.75rem;
  border-radius: 10px;
  color: var(--ink);
  text-decoration: none;
}

.mappe-row:hover {
  background: var(--surface-soft);
}

/* tabular-nums so a column of labels lines up rather than jittering by digit
   width — JC-001 above JC-100 is the whole point of a list "by number", and the
   suffix is fixed-width by construction (migration 00008 pads to three). */
.mappe-row__label {
  flex: 0 0 auto;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* The names get the flexible basis, and overflow-wrap lets an unbroken one —
   "Charlottenburg-Wilmersdorf" is a real German surname shape — break rather
   than run out of the card at 360px. */
.mappe-row__holders {
  flex: 1 1 12rem;
  min-width: 0;
  overflow-wrap: anywhere;
}

.mappe-row__marks {
  flex: none;
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
}

/* tabular-nums again: this pill is "%d von %d belegt", and the two figures are
   read down the column. It is deliberately the quiet .pill and not a warning
   colour even when the folder is over capacity — that state is legal
   (data-model.md §6) and nobody did anything wrong to reach it. */
.mappe-row__slots {
  font-variant-numeric: tabular-nums;
}

/* P3's outstanding return: attention, not alarm — a folder to chase, not an
   error. So it reuses .termin-row__unmarked's pair verbatim rather than
   inventing a third one, and that is a contrast decision as much as a visual
   one: --accent-soft with --ink is already the measured, legible combination in
   both themes (see the selected filter chip's comment, which carries the
   figures). The LABEL is what says so in any case; the tint only helps a reader
   who can see it. */
.mappe-row__offen {
  background: var(--accent-soft);
  color: var(--ink);
}

/* --- the bulk helper's outcome --- */

.mappe-summary {
  margin-bottom: 1rem;
}

.mappe-summary h2 {
  margin-top: 0;
}

.mappe-summary p {
  margin: 0.25rem 0 0;
}

/* --- the two create forms --- */

.mappe-form__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

/* Von and Bis side by side while there is room: they are one range and reading
   them as a pair is what makes a mis-ordered one obvious before it is
   submitted. The basis drops them apart at 360px. */
.mappe-range {
  display: flex;
  flex-wrap: wrap;
  gap: 0 1rem;
}

.mappe-range .field {
  flex: 1 1 8rem;
  min-width: 0;
}

/* --- one folder's detail --- */

.mappe-facts {
  display: grid;
  grid-template-columns: minmax(6rem, auto) 1fr;
  gap: 0.375rem 1rem;
  margin: 0;
}

.mappe-facts dt {
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--ink-soft);
}

.mappe-facts dd {
  margin: 0;
  min-width: 0;
  overflow-wrap: anywhere;
  white-space: pre-wrap;
}

/* --- the handover (#22) --- */
/* No new colour and no new token here either: the occupancy pill is the quiet
   `.pill` the list row already uses, and the outcome sentence borrows the
   status colour the note's "Gespeichert." has. Every state below carries its own
   LABEL TEXT, so design.md §3's "never colour alone" holds. */

/* What one handover turned out to be. Above the fold, in the flow rather than
   floating: it arrives on a page LOAD (the write answers 303), so nothing needs
   hiding again and nothing moves under a finger — the argument
   .anw-msg-flow's comment makes one screen over. */
.mappe-msg {
  margin: 0 0 1rem;
  color: var(--trust);
  font-weight: 500;
}

/* The heading and the occupancy pill on one line, wrapping apart at 360px
   rather than squeezing either. Same shape as .mappe-head. */
.mappe-holders__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5rem;
}

.mappe-holders__head h2 {
  margin: 0;
}

/* tabular-nums for the reason .mappe-row__slots has it: this is "%d von %d
   belegt" and the two figures are read as a pair. Deliberately not a warning
   colour when the folder is over capacity — that state is legal
   (data-model.md §6) and nobody did anything wrong to reach it. */
.mappe-holders__slots {
  flex: none;
  font-variant-numeric: tabular-nums;
}

.mappe-holders__history {
  margin: 1.25rem 0 0.5rem;
  font-size: 0.9375rem;
  color: var(--ink-soft);
}

.mappe-holders__list {
  list-style: none;
  margin: 0.75rem 0 0;
  padding: 0;
}

.mappe-holders__list > li + li {
  margin-top: 0.5rem;
  padding-top: 0.5rem;
  border-top: 1px solid var(--border);
}

/* Name and dates side by side while there is room, stacked at 360px. The name
   gets the flexible basis and breaks inside itself:
   "Charlottenburg-Wilmersdorf" is a real German surname shape, and the date
   beside it must not be pushed out of the card. */
.mappe-holder {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.25rem 0.75rem;
}

.mappe-holder__name {
  flex: 1 1 10rem;
  min-width: 0;
  overflow-wrap: anywhere;
}

.mappe-holder__date {
  flex: 0 1 auto;
  font-size: 0.8125rem;
  font-variant-numeric: tabular-nums;
}

/* --- one holder's state and the return beside it (#23, M4's T4) --- */

/* The badge and the button ride at the end of a current holder's line. At 360px
   the pair drops onto a line of its own — that wrap is .mappe-holder's, not this
   box's — which is what keeps "Charlottenburg-Wilmersdorf" from pushing either
   off the card. `align-self: center` overrides .mappe-holder's baseline
   alignment for this one child: a 44px tap target has no baseline worth lining a
   name up against.

   **`flex: 0 1 auto` and not `flex: none`, and the difference is whether the
   `flex-wrap` above ever fires.** `flex: none` is `0 0 auto`, so the box freezes
   at max-content and cannot shrink — its own wrap is then unreachable at every
   width, and the badge can never drop below the button. This shipped as
   `flex: none` in the first draft of #23 and was measured in review against the
   real component and this stylesheet: at 320px the frozen box stayed 275x44 and
   overflowed the card's content box by 29px, where `0 1 auto` wraps to 246x72
   and overflows by 0. At 360px — the repo's stated floor — both forms overflow
   by 0, so this changes nothing there and rescues the narrower widths app.css
   has swept two prior components at.

   Not measured by this task: the numbers above come from a metric font a phone
   will have (no webfont ships, so the fallback in the wild is Roboto or SF).
   Review measured the wide-font case too — `fc-match system-ui` and
   `fc-match sans-serif` both resolve to DejaVu Sans on the build host — and it
   is the case this fix rescues rather than the one it strands: at 360px DejaVu
   goes from 24.4px of overflow to 0, and at 320px from a 64.4px overflow and a
   horizontally scrolling document to 0. The residual is not a thin margin but a
   different shape: a wide fallback sends this block two-line earlier (286x72 at
   360px) where a Roboto/SF-metric font stays on one. */
.mappe-holder__marks {
  flex: 0 1 auto;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  align-self: center;
  gap: 0.25rem 0.5rem;
  margin-left: auto;
}

/* P3's outstanding return on ONE holder's row. Deliberately the SAME pair
   .mappe-row__offen uses on the list rather than a second one: it is the same
   state derived from the same SQL expression (M4's plan D6), and a conductor
   moving from the list into a folder should meet one badge, not two designs of
   it. Attention and not alarm — a folder to chase is not an error and nobody did
   anything wrong to reach the state.

   **The label is what carries the state** (design.md §3): the tint only helps a
   reader who can see it, and this list is printed and screenshotted, so
   "Rückgabe offen" has to survive greyscale on its own. It does, because it is a
   word. */
.mappe-holder__offen {
  background: var(--accent-soft);
  color: var(--ink);
}

/* --- P4's overview (#24) --- */
/* Four figures above the search: the total on its own line, then issued, free
   and outstanding returns.

   **The total is separated by a rule rather than sat in the same row as the
   three below it**, and that separation is the layout's whole job. Issued plus
   free does not equal the total when a folder is retired (data-model.md §6,
   M4's plan D5's seam), so a row of four equal-looking chips would read as a
   breakdown of something and invite the arithmetic. One number, a line, then
   the three.

   **The border reads as a ledger subtotal at least as much as a separator, and
   that is accepted rather than unnoticed** — see MappenOverviewCard's comment
   in mappen.templ for what it does and does not close.

   No new colour, no bar, no meter: a proportion is a picture of a division, and
   M4 divides nothing (plan D16). */
.mappe-overview {
  margin-bottom: 1rem;
}

.mappe-overview h2 {
  margin-top: 0;
}

/* tabular-nums for the same reason .mappe-row__slots has it: these figures are
   read together and a proportional 1 makes a column of them jitter. */
.mappe-overview__total {
  margin: 0;
  padding-bottom: 0.5rem;
  border-bottom: 1px solid var(--border);
  font-size: 1.125rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* Wraps rather than shrinks, .mappe-head's rule: at 360px "31 Mappen
   ausgegeben" and its two siblings do not fit one line, and each is a whole
   sentence that must not be broken mid-figure.

   **`flex-wrap: wrap` here is what drops a long sentence to its own line**, and
   this comment used to credit `flex: 0 1 auto` on the item below with it. That
   declaration governs SHRINK — it is what stops an item being frozen at
   max-content, which is the failure .mappe-holder__marks records having shipped
   as `flex: none`. The two work together and only one of them wraps; measured
   at 320/360/390px across three font stacks with no overflow. */
.mappe-overview__figures {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 1.25rem;
  list-style: none;
  margin: 0.5rem 0 0;
  padding: 0;
  font-variant-numeric: tabular-nums;
}

.mappe-overview__figures > li {
  flex: 0 1 auto;
}

/* --- P6's manual number entry (#25, M4's T8) --- */
/* One declaration, and the smallness is the finding rather than an omission.
   The screen is a text input and two buttons, so design.md §3's 44px floor
   arrives through `input:not([type='checkbox'])` and `button, .btn` in the Forms
   and Buttons sections above — inherited, not re-stated, and #57 is the reason
   that was checked rather than assumed. The actions reuse .mappe-form__actions,
   which already wraps at 360px, and the two regions are plain .card.

   What is left is the second card's heading, which needs the same reset
   .mappe-summary h2 gets one screen over. */
.mappe-number__camera h2 {
  margin-top: 0;
}

/* ---------- the printed QR label sheet (#26) ---------- */
/* The app's one print surface (design.md §4, requirements P6/P8/P10, ADR 0004).
   Two regions with opposite jobs: `.qr-preprint` is read on screen and never
   printed, and `.qr-sheet` is the artifact.

   **Everything about the sheet is specified in millimetres and pinned to its own
   font**, and both are the same decision: what this block produces gets cut up
   with scissors, so its geometry has to be a length rather than a ratio of a
   viewport, and its metrics have to be the same on every machine. `rem` here
   would make the printed label depend on a browser's root font size. */

.qr-preprint__warning {
  margin: 0.75rem 0;
  padding: 0.75rem 0.875rem;
  border-radius: var(--radius-card);
  border: 1px solid var(--rose);
  background: var(--rose-soft);
  color: var(--ink);
}

.qr-preprint__origin {
  margin: 0.75rem 0 0.25rem;
}

/* The encoded origin, in the pre-print region (gate G2(c)). Monospace and
   breakable: it is a URL a conductor has to READ character by character to
   decide whether to spend the paper, and `chor.mbgnrts.de` versus
   `staging.chor.mbgnrts.de` is exactly the comparison being asked for. */
.qr-preprint__origin-value {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  overflow-wrap: anywhere;
}

/* **The sheet pins its own font family — gate G3(c), and #38 is why.**
   `--font-display`/`--font-body` name Manrope and Inter, no font files are
   vendored and no CDN is allowed, so the app renders in `system-ui` — which
   resolves to a DIFFERENT FACE ON EVERY MACHINE. The risk that matters here is
   not the wrong typeface, it is non-deterministic metrics: design.md §4 requires
   the cell to "survive three or four lines without pushing the ID off the
   label", and a label laid out on one computer can overflow on another. Pinning
   a stack that is present everywhere makes the printed artifact reproducible
   whatever #38 decides, so #38 does not block #26.

   It is pinned on the component rather than only inside `@media print`, so the
   on-screen preview is the same layout as the paper. `e2e/tests/qr-sheet.spec.ts`
   asserts the computed family, because pinning only pays if somebody reproduces
   it — and the geometric assertions beside it are what reproduce it.

   Black on white regardless of theme (design.md §4), here rather than in the
   print block, for the same WYSIWYG reason. */
.qr-sheet {
  font-family: Helvetica, Arial, sans-serif;
  background: #fff;
  color: #000;
  padding: 4mm;
  border-radius: var(--radius-card);
}

/* Gate G2(c)'s second site: the encoded origin on the sheet itself, OUTSIDE the
   cells, so it survives on the uncut margin and is thrown away with it. */
.qr-sheet__header {
  display: flex;
  flex-wrap: wrap;
  gap: 1mm 4mm;
  margin: 0 0 4mm;
  font-size: 3mm;
  line-height: 1.3;
}

.qr-sheet__header-choir {
  font-weight: 700;
}

.qr-sheet__header-origin {
  overflow-wrap: anywhere;
}

/* A fixed grid, because the sheet is cut along it: every cell the same size, so
   one pair of scissors does the whole page. `auto-fill` with a fixed track
   rather than `1fr` columns — a cell that stretched to the window would print at
   a different size than it previewed. */
.qr-sheet__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, 58mm);
  justify-content: start;
  gap: 4mm;
}

/* One cut-out label. design.md §4's three elements, in its order of visual
   weight, in a column pinned to the TOP.

   **`justify-content: flex-start` and the ID being first are together what makes
   "the address must not push the ID off the label" true**, and they are worth a
   comment because centring the content of a fixed-height cell is the obvious
   thing to write and is the version that fails: with content taller than the
   box, centred overflow is split between the two ends and the ID goes off the
   top edge — i.e. off the label, after cutting.

   `overflow: hidden` is the other half. Without it a long address does not stop
   at the cut line: it prints across into the neighbouring label, which is a
   defect discovered with scissors in hand. Clipping the last line of an address
   is bad; printing it onto somebody else's folder is worse. */
.qr-label {
  box-sizing: border-box;
  width: 58mm;
  height: 52mm;
  padding: 2.5mm;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  gap: 1.5mm;
  overflow: hidden;
  border: 1px dashed #999;
  background: #fff;
  color: #000;
}

/* P8's first element: `JC-001`, large, and the one that survives a stack of
   folders on a shelf.

   `nowrap` for the reason the public page's label carries it: a hyphen is an
   ordinary line-break opportunity, `ABCDE-999` is the longest label the grammar
   admits, and an ID broken in half costs the label its purpose. `flex: none` so
   it is never the thing that shrinks. */
.qr-label__id {
  flex: none;
  margin: 0;
  font-size: 6.5mm;
  line-height: 1.05;
  font-weight: 700;
  letter-spacing: 0.02em;
  white-space: nowrap;
}

/* P8's second element. Sized in millimetres and square: the viewBox carries the
   module grid plus ADR 0004 decision 7's quiet zone, so the printed module size
   is this length divided by the extent — 22mm / 37 units ≈ 0.59mm per module for
   a two-letter acronym on the production origin, and 22mm / 41 ≈ 0.54mm for a
   three-letter one, which crosses the version boundary
   (internal/qr's TestTheModuleCountIsAMeasurementOfTheWholePayload carries that
   measurement, and it is the reason this length is not smaller).

   **22mm is the number the vertical budget was solved for**, not a round one.
   The cell is 52mm tall so that five rows fit an A4 page at 10mm margins
   (5 x 52 + 4 x 4 = 276 <= 277); 47mm of that is inside the padding, the ID
   takes ~6.8mm and the two gaps 3mm, which leaves ~15mm for the return line —
   about four and a half lines at 2.6mm. Making the QR bigger takes those lines
   away from the address; making it smaller takes module width away from a phone
   camera in bad light.

   `shape-rendering: crispEdges` because the modules are axis-aligned squares:
   antialiasing their edges is exactly the softening ADR 0004 clause (b) chose a
   vector path to avoid. `fill: #000` on the path rather than on the svg, so the
   symbol is black on white whatever the theme decided elsewhere. */
.qr-label__qr {
  flex: none;
  width: 22mm;
  height: 22mm;
  shape-rendering: crispEdges;
}

.qr-label__qr path {
  fill: #000;
}

/* P8's third element: the small return line, "for a stranger who finds a lost
   folder, and it is *text* on purpose".

   `flex: 0 1 auto` with `min-height: 0` and `overflow: hidden`: it is the only
   element in the cell that may shrink, and it clips rather than pushing.

   **How much it holds, measured: 14.55 mm, which is four rows at the default
   size.** This element's `clientHeight` caps at **55 px** once its content is
   taller than the box — 55 px ÷ (96/25.4) = 14.55 mm, read in Chromium in the
   e2e image. The cell's own arithmetic gives 55.35 px = 14.65 mm, and
   `internal/view/qr_fit.go` deliberately takes the smaller of the two; neither
   changes a row capacity. **This paragraph said 14.66 mm**, which was the
   derived figure rounded up rather than the measured one. The budget does not
   move with the type size: the ID and the QR are `flex: none`, so this block is
   the only child that may shrink and what is left of the 52 mm cell after them
   is its.

   **#119 was that four rows is not enough for the documented bound, and that
   nothing said so.** At 200 characters over four lines —
   `check (length(return_address) <= 200)`, data-model.md §1 — this block wanted
   **28.05 mm, nine rows**, and got four: the A4 PDF carried the choir name and
   four address rows, ending mid-address, and **the street and the postcode never
   reached the paper**. P10's guard covers only the EMPTY address, so nothing
   warned.

   **Two numbers in the withdrawn version of this paragraph were wrong, and they
   were wrong in the same way.** It said the 200-character address "wraps to 11
   visual lines in a 53 mm box and only 6 survive", and that the seeded address
   "wraps to 5 visual lines, all 5 visible, with ~5 mm of headroom". Both came
   from `range.getClientRects()`, which under `white-space: pre-line` returns a
   rect per line box **plus one per hard newline** — so 8 wrapped rows plus 3
   newlines read as 11, and 3 rows plus 2 newlines read as 5. The paper's counts
   are 8 and 3. The same arithmetic is why the "~5 mm of headroom" was really
   ~2.1 mm: 47 px of content in the 55 px this block is afforded.
   `e2e/tests/qr-sheet.spec.ts` measures with the same call and carried the same
   figures; both are corrected there. Nothing about the DEFECT changes — it was
   found on the paper, and the paper is where it is now fixed.

   **The fix is the ladder below** (#119): an address either reaches the paper
   whole or the pre-print region warns before the paper is spent. It steps the
   type down to 2.2 mm and then to 2.0 mm, which is the readability floor —
   print below ~2 mm is not readable by the stranger the return line exists for,
   so a fourth rung would trade a visible defect for an invisible one.
   `internal/view/qr_fit.go` picks the rung on the server, sums the pinned
   stack's per-rune advances to do it (`qr_metrics.go`), and warns when even
   2.0 mm cannot hold the address.

   **This paragraph used to promise "the column admits 200 characters, so the
   sheet fits 200 or refuses to pretend", and that promise is withdrawn.** The
   measurement under it is intact and narrow: **this** 200-character address —
   `qr_fit_test.go`'s `boundAddress`, the e2e suite's `LONGEST_ADDRESS` — prints
   whole at 2.0 mm, 11.99 mm wanted against 14.55 mm afforded, five rows of ink
   on the PDF, ending in the postcode. What is false is the generalisation to
   the column: **200 characters is not one shape.** Review of #181 measured a
   185-character address of four long lines wanting seven rows at 2.0 mm against
   six afforded — it does not fit at any rung, and what the sheet owes it is the
   warning. `internal/view/qr_fit_corpus_test.go` carries it, and 26 more.

   **The numbers here and in `qr_fit.go` are the same numbers, and that IS now
   enforced**: `internal/view/qr_fit_css_test.go` reads this stylesheet out of
   the embedded tree and fails when a `font-size`, a rung's name, the leading, or
   the cell geometry the budget is derived from stops matching `returnLineSteps`.
   It runs inside `just check`, where the previous disclosure ("nothing enforces
   that") ran nowhere.

   `overflow: hidden` stays, and stays for its original reason: the alternative
   to clipping is bleeding across the cut line into the neighbouring label, which
   is a defect discovered with scissors in hand. It is now the backstop rather
   than the mechanism. */
.qr-label__return {
  flex: 0 1 auto;
  min-height: 0;
  overflow: hidden;
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  font-size: 2.6mm;
  line-height: 1.2;
  text-align: center;
}

/* The two lower rungs of #119's ladder. `internal/view/qr_fit.go` decides which
   one a given address gets, on the server, from these same two lengths — there
   is no client-side measurement and no `@media print` variant, because the
   preview on screen has to be the paper.

   2.2 mm holds five rows and 2.0 mm holds six. Nothing below 2.0 mm: that is
   #119's stated floor for text a stranger reads off a folder, and the pre-print
   warning is what happens instead of a 1.8 mm rung.

   **Both the lengths AND these two class names are duplicated in
   `returnLineSteps`**, and `internal/view/qr_fit_css_test.go` is what compares
   them. A rung renamed here without renaming it there is the worst of the two
   failures: the server emits a class that matches nothing, every address lays
   out at 2.6 mm, and the long ones clip while the pre-print region stays silent
   — because the server believes it stepped down. */
.qr-label__return--tight {
  font-size: 2.2mm;
}

.qr-label__return--tightest {
  font-size: 2mm;
}

.qr-label__choir {
  font-weight: 700;
}

/* The address is ONE interpolated string with real newlines in it (S1's free
   multi-line text), printed verbatim (P8). `pre-line` is what makes those
   newlines visible — the same mechanism the public return page uses, and the
   same reason no template here builds `<br>`. */
.qr-label__address {
  white-space: pre-line;
  overflow-wrap: anywhere;
}

/* ---------- Heute tab (#31, M5's T5) ---------- */
/* The landing screen: the next or current Termin with its roster action, the
   last few Termine with their percentage (Q4), and the Mappen figures (Q5).

   **No new colour and no new token.** The one state on this screen is the open
   Termin's pill, which reuses `.termin-row__state--open` from the Termine tab
   verbatim — same object, same appearance, and design.md §3's "never colour
   alone" holds because the pill carries the word "offen" inside it.

   **No bar, no meter, no red.** A percentage here is per TERMIN, and the
   below-threshold highlight has a different subject: **Q3** enumerates the
   figures it applies to — per member, per voice part, per choir — and Q4 asks
   for none. (C3 is the wrong citation for that and this comment made it: C3
   names the threshold and its per-choir setting, and no subject at all.)
   `concept/design.md` §3's closing bullet — "below-quota values elsewhere use
   `--danger` text" — is the one sentence that could be read as reaching this
   screen, and it was narrowed to Q3's subjects with #31 rather than left to be
   read both ways. Painting a Probe red because 55 % of the choir came would
   apply a rule about a person to an event. The Zahlen tab is where a threshold
   gets a colour.

   Plan D11 still binds: the percentage is ROUNDED for display, which is the half
   of D11 that is about the number rather than about the colour. */

/* Mirrors .termin-head and .mappe-head, minus the action row: this screen has no
   primary action beside its title. */
.heute-head {
  margin-bottom: 1rem;
}

.heute-head h1 {
  margin: 0;
}

.heute-head__choir {
  margin: 0.125rem 0 0;
  font-size: 0.875rem;
}

.heute-next {
  margin-bottom: 1rem;
}

/* The h2 reset .mappe-overview h2 and .mappe-summary h2 already carry: a .card's
   own padding is the top spacing, and the heading's default margin doubles it. */
.heute-next h2,
.heute-mappen h2 {
  margin-top: 0;
}

/* The Termin is the link and the action is a button BELOW it, never beside it —
   .termin-row's rule and .termin-row__abmeldungen's, for their reason: a nested
   <a> is invalid HTML, and a full-width action gets a 44px target (design.md
   §3's floor) that no thumb can confuse with the row above it. */
.heute-next__termin {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.25rem 0.75rem;
  min-height: 44px;
  margin: 0 -0.5rem;
  padding: 0.5rem;
  border-radius: 10px;
  color: var(--ink);
  text-decoration: none;
}

.heute-next__termin:hover {
  background: var(--surface-soft);
}

/* tabular-nums so the date does not jitter by digit, .termin-row__when's rule.
   Its own line at any width: this is the one date on the screen a conductor
   reads before anything else. */
.heute-next__when {
  flex: 1 0 100%;
  font-size: 1.125rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.heute-next__what {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.25rem 0.5rem;
  min-width: 0;
  overflow-wrap: anywhere;
}

.heute-next__kind {
  font-weight: 600;
}

.heute-next__location {
  font-size: 0.875rem;
}

.heute-next__marks {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
}

/* Full width, so the one action on this screen is a thumb-sized target rather
   than a chip in a row of text. */
.heute-next__action {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  margin-top: 0.75rem;
}

.heute-recent {
  margin-bottom: 1rem;
}

.heute-recent > h2 {
  margin-top: 0;
}

.heute-recent__list {
  margin-bottom: 0.25rem;
}

/* The link to the Termine tab, on its own line under the list. 44px for the same
   reason .termin-row__abmeldungen has it. */
.heute-recent__all {
  display: flex;
  align-items: center;
  min-height: 44px;
  padding: 0 0.75rem;
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--accent);
}

/* The figure block on a recent row. It sits at the END of a wrapping
   .termin-row, so at 360px it drops to its own line under the Termin rather than
   squeezing the kind and title — the wrap rule .mappe-overview__figures records
   measuring. */
.heute-quote__figures {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  margin-left: auto;
  font-variant-numeric: tabular-nums;
}

.heute-quote__percent {
  font-size: 1.125rem;
  font-weight: 600;
}

/* Plan D12's dash, which is a WORD here rather than a glyph. It is quieter than
   a figure and deliberately not --danger: "keine Quote" is the answer for a
   Termin nobody was expected at, which is not a problem with anybody. */
.heute-quote__percent--none {
  font-size: 0.875rem;
  font-weight: 400;
  color: var(--ink-soft);
}

.heute-quote__counts {
  font-size: 0.875rem;
}

/* ---------- Zahlen tab (#33, #29, #30, #32) ---------- */
/* The season picker, Q3's roll-up per Stimme and for the whole choir, one row
   per member with Q2's three figures, and Q5's Mappen headline
   (design.md §3a, requirements Q1/Q2/Q3/Q5/C3/C4).

   **Every viewport claim in this block is REASONING, not a measurement**, and it
   is marked so because other blocks in this file carry measured ones and an
   unmarked claim beside those reads as one of them. The two exemplars, cited by
   selector rather than by distance so they can be re-read:
   `.mappe-overview__figures` ("measured at 320/360/390px across three font
   stacks with no overflow") and `.qr-label__return` ("this element's
   `clientHeight` caps at 55 px … read in Chromium in the e2e image"). **This
   sentence used to cite "wraps to 5 visual lines" as one of them**, and after
   #119's retraction that phrase survives in this file only INSIDE the
   withdrawal — a citation pointing at a retracted number and holding it up as
   the standard of rigour, which is `docs/agents/claims-discipline.md` rule 5 on
   top of rule 2. It also said the exemplars were "~40 lines above": the nearer
   of the two is ~210 lines above this comment and the other ~470, which is the
   second reason the anchors are now selectors. M5 ships no
   browser spec and no e2e run (plan D10), so nothing in this milestone opened
   this screen at a width. What the sentences below describe is what the
   declarations are FOR — the intent a later reader should check against, and the
   thing to measure first if this screen ever gets an e2e spec. Raised as N4 in
   review at bb4ca24.

   **No bar, no meter, no ring.** A proportion drawn as a picture invites the
   reader to compare two singers by eye, and this screen's numbers are read as
   statements about named people — the figure and the counts it came from are
   what a conductor can check. The one thing drawn rather than written is C3's
   red, and it never travels alone. */

/* Mirrors .mappe-head and .member-head, including the wrapping basis. */
.zahlen-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.25rem 0.75rem;
}

.zahlen-head h1 {
  margin-bottom: 0.25rem;
}

.zahlen-head__choir {
  margin: 0;
}

/* The .xlsx export (#35) sits in this tab's head, which is the nearest thing
   the Zahlen tab has to the secondary-action rows .member-head__actions and
   .mappe-head__actions carry (gate G3). `margin-left: auto` puts it at the
   trailing edge while the head's own `flex-wrap` still drops it onto its own
   line when the choir name is long — the same wrap those two rely on, and the
   reason neither of them shrinks a control below the 44px tap minimum at 360px.

   No rule here sets a width, so nothing in this block can force a horizontal
   scroll. Unmeasured in a browser, like the rest of this file's layout claims. */
.zahlen-head__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  margin-left: auto;
}

.zahlen-lead,
.zahlen-threshold,
.zahlen-note {
  margin: 0 0 0.75rem;
}

/* --- the season picker (C4) --- */
/* Intended: label, select and button on one line while there is room, stacking
   when there is not. Unmeasured — see this block's header.

   The 44px floor on the control and the button is the one claim here that is
   NOT about rendering: it arrives by inheritance from
   `input:not([type='checkbox'])`, `select` and `button, .btn` in the Forms and
   Buttons sections above, which is readable in this file rather than in a
   browser. Inherited rather than re-stated, which is #57's lesson. */
.zahlen-season {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 0.75rem;
}

.zahlen-season__field {
  flex: 0 1 auto;
  margin-bottom: 0;
}

.zahlen-season__apply {
  flex: 0 0 auto;
}

/* --- the member rows --- */
.zahlen-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* Each row is a .card rather than a table row. The reasoning: six columns of
   figures at 70–100 rows is a horizontal scroll on a phone, and this app is used
   one-handed in a rehearsal room (design.md §3's sizing note). Unmeasured — see
   this block's header. What IS checkable without a browser is that no rule here
   sets a width or a min-width, so nothing in this block can force one. */
.zahlen-row {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.zahlen-row__who {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.25rem 0.5rem;
}

.zahlen-row__name {
  font-weight: 600;
}

/* --- design.md §3a: one primary figure, two smaller companions --- */
/* The weighting IS the requirement. §3a: "never as three equally-weighted
   numbers, which would leave the reader guessing which one 'the' quota is." So
   the Anwesenheitsquote gets the display face and roughly twice the size, and
   the two companions ride beside it at body size.

   They are set to wrap rather than shrink, following .mappe-head's rule, on the
   reasoning that "Unentschuldigt-Quote" plus its figure is a whole label that
   must not break mid-word and cannot share a narrow line with the other two.
   Unmeasured — see this block's header; .mappe-head's version of this sentence
   carries widths because somebody put it in front of a browser. */
.zahlen-row__figures {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.25rem 1.5rem;
}

.zahlen-row__primary,
.zahlen-row__companion {
  display: flex;
  flex: 0 1 auto;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.375rem;
  margin: 0;
  /* tabular-nums for the reason .mappe-row__slots has it: a column of these is
     read down the page and a proportional 1 makes it jitter. */
  font-variant-numeric: tabular-nums;
}

.zahlen-row__primary .zahlen-row__value {
  font-family: var(--font-display);
  font-size: 1.75rem;
  font-weight: 700;
  line-height: 1.1;
}

.zahlen-row__companion .zahlen-row__value {
  font-weight: 600;
}

.zahlen-row__companion .zahlen-row__label,
.zahlen-row__primary .zahlen-row__label {
  color: var(--ink-soft);
  font-size: 0.8125rem;
}

.zahlen-row__companion {
  font-size: 0.9375rem;
}

/* --- C3's below-threshold treatment --- */
/* `--danger` as TEXT, which is what design.md §3's last bullet specifies for
   below-quota values ("Below-quota values elsewhere use `--danger` text") —
   deliberately not a filled badge, which is the roster's treatment for a
   `gefehlt` tap and would read as a state somebody chose.

   **It is never colour alone.** The modifier below tints the number; the
   `.zahlen-row__flag` beside it carries the words "unter der Mindestquote", and
   design.md §3's "never colour alone" rule is about exactly this pair. Greyscale
   printing, a red-green deficiency and a phone in bright sunlight each defeat
   the colour on its own, and the reader is a conductor about to have a
   conversation with the person the row names. */
.zahlen-row__primary--below .zahlen-row__value {
  color: var(--danger);
}

/* §3a's accent on the Unentschuldigt figure "when it is high" — the figure that
   "identifies an unplannable singer". Weight rather than a second red: two reds
   on one row would make the primary figure's meaning ambiguous, and this one
   answers a different question. It carries its own flag for the same reason the
   one above does. */
.zahlen-row__companion--accent .zahlen-row__value {
  color: var(--ink);
  font-weight: 700;
}

/* Both flags. Quiet by size and loud by position: they sit immediately after
   the label they qualify, so the colour and the words are read together. */
.zahlen-row__flag {
  color: var(--danger);
  font-size: 0.8125rem;
  font-weight: 600;
}

.zahlen-row__companion--accent .zahlen-row__flag {
  color: var(--ink-soft);
}

/* D12: expected at nothing this season. A word rather than a bare dash — a dash
   is not announced by a screen reader — and no red anywhere on the row: this is
   the absence of a figure, not a bad one. */
.zahlen-row__no-quota {
  margin: 0;
  color: var(--ink-soft);
  font-weight: 600;
}

/* The counts under the figures. Set to wrap rather than shrink, following
   .mappe-overview__figures' rule (unmeasured here — see this block's header),
   and tabular-nums for the reason the figures have it. */
.zahlen-row__counts {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 1rem;
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: 0.8125rem;
  font-variant-numeric: tabular-nums;
}

.zahlen-row__counts > li {
  flex: 0 1 auto;
}

/* --- Q3's roll-up: one row per Stimme and one for the whole choir (#30) --- */
/* **Every viewport claim below is REASONING and not a measurement**, the same
   marking this block's header applies to the member rows: M5 ships no browser
   spec and no e2e run (plan D10), so nothing has opened this section at a width.

   The section sits ABOVE the member list because design.md §2 lists this tab's
   contents in that order — "Season quota, per-voice-part quota, member ranking".

   **No bar, no meter, no ring here either**, and here the reason is sharper than
   it is for a member row: these figures are POOLED (`sum(da)/sum(expected)`,
   plan D5), and a picture of a proportion invites a reader to compare two
   Stimmen by eye as though the bars were built the same way. A Stimme of three
   who joined in September and a Stimme of twelve who did not are not comparable
   by area. The counts under each figure are what makes the pooling visible at
   all. */
.zahlen-section-title {
  margin: 0 0 0.5rem;
  font-size: 1.0625rem;
}

.zahlen-rollup {
  /* The card's own bottom margin comes from .card; this only spaces the title
     from the rows under it. */
  margin-bottom: 0.75rem;
}

.zahlen-groups {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* A hairline between groups rather than a card each: these rows are read as one
   table of the same season, where a member row is read on its own. */
.zahlen-group {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  padding: 0.625rem 0;
  border-bottom: 1px solid var(--border);
}

.zahlen-group:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}

/* The whole-choir row. It leads the list and carries slightly more weight than a
   Stimme, because it is the one figure that answers "how is the choir doing" —
   design.md §2's "season quota". Weight and a word, never colour: the only
   colour in this block is C3's. */
.zahlen-group--choir .zahlen-group__label {
  font-size: 1rem;
}

.zahlen-group__label {
  font-weight: 600;
}

/* Intended: the three figures on one line while there is room, wrapping whole
   rather than shrinking — .zahlen-row__figures' rule, at the smaller size that
   keeps a group from reading like a person. Unmeasured; see above. */
.zahlen-group__figures {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.25rem 1.25rem;
}

.zahlen-group__primary,
.zahlen-group__companion {
  display: flex;
  flex: 0 1 auto;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.375rem;
  margin: 0;
  /* tabular-nums for the reason .zahlen-row__primary has it: these are read
     down the page against each other. */
  font-variant-numeric: tabular-nums;
}

.zahlen-group__primary .zahlen-group__value {
  font-family: var(--font-display);
  font-size: 1.25rem;
  font-weight: 700;
  line-height: 1.2;
}

.zahlen-group__companion {
  font-size: 0.9375rem;
}

.zahlen-group__companion .zahlen-group__value {
  font-weight: 600;
}

.zahlen-group__quota {
  color: var(--ink-soft);
  font-size: 0.8125rem;
}

/* C3's highlight on a Stimme and on the choir. Requirement Q3 names all three
   subjects and asks for the threshold highlight across them, so this is the same
   treatment as .zahlen-row__primary--below and deliberately not a second visual
   language: `--danger` as TEXT (design.md §3's last bullet), never a filled
   badge, and **never colour alone** — .zahlen-group__flag beside it carries the
   words. A whole Stimme painted red is a sentence about several named people at
   once, which is if anything the stronger case for the word. */
.zahlen-group__primary--below .zahlen-group__value {
  color: var(--danger);
}

/* design.md §3a's accent, applied to a group by the same helper that applies it
   to a member (`unexcusedIsHigh`; its threshold is gate G2(a) and is not settled
   here). Weight rather than a second red, for the reason the member row's
   version gives. */
.zahlen-group__companion--accent .zahlen-group__value {
  color: var(--ink);
  font-weight: 700;
}

.zahlen-group__flag {
  color: var(--danger);
  font-size: 0.8125rem;
  font-weight: 600;
}

.zahlen-group__companion--accent .zahlen-group__flag {
  color: var(--ink-soft);
}

/* D12 at the group level: a Stimme whose members were expected at nothing. A
   word, no red, no figure. */
.zahlen-group__no-quota {
  margin: 0;
  color: var(--ink-soft);
  font-weight: 600;
}

/* The pooled counts. They are not decoration: `8 anwesend / 12 erwartet` is the
   arithmetic a conductor can redo, and it is the only thing on the row that
   distinguishes a pooled figure from an averaged one. */
.zahlen-group__counts {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 1rem;
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: 0.8125rem;
  font-variant-numeric: tabular-nums;
}

.zahlen-group__counts > li {
  flex: 0 1 auto;
}

/* Q5's Mappen headline (#32, M5's T4), last on the screen. Same h2 reset
   .heute-next h2 and .heute-mappen h2 already carry, for the same reason: a
   .card's own padding is the top spacing and the heading's default margin
   would double it. */
.zahlen-mappen h2 {
  margin-top: 0;
}

/* ---------- print (design.md §4) ---------- */
/* **The app's first and only `@media print` block**, and it is deliberately at
   the foot of the one stylesheet rather than in a `print.css`: design.md §5 says
   "Plain CSS, one stylesheet", a second file would need a second `<link>` in
   layout.templ, and these rules need the same token block the screen rules use.

   Everything here is a subtraction. The sheet's own geometry, colours and font
   are declared above, in the component block, so that the on-screen preview and
   the paper are the same layout — a print stylesheet that RESHAPED the sheet
   would mean nobody sees what they are about to print until it is printed.

   No specificity tricks and no `!important`: every selector below is a single
   class, matching the ones it overrides, and this block is last in the file. */
@media print {
  /* **Verified on a real A4 PDF in review of #26**, not only in the e2e window:
     `page.pdf({preferCSSPageSize: true})` honours this — 594.96 x 841.92 pt with
     the leftmost text at 10.05 mm — and 50 cells lay out as 4 pages of
     12/15/15/8 with no cell split across a boundary. That is the
     `5 x 52 + 4 x 4 = 276 <= 277` budget the cell height was solved for, holding
     on paper. `emulateMedia` does NOT apply @page, so no committed test
     reproduces this. */
  @page {
    size: A4 portrait;
    margin: 10mm;
  }

  /* "No chrome, no tab bar" (design.md §4), plus the pre-print region, which
     exists to be read before printing and never on the paper. Hiding the
     ELEMENTS rather than not rendering them is the point: the page a conductor
     reads is the app, and only the print stylesheet strips it.

     **`.rail` is in this list because of a measurement, not for symmetry.**
     Above 48rem the shell turns it into a real left COLUMN, and hiding only its
     contents left an empty 88px gutter down every printed page — measured in
     the e2e rig at an A4-width window. On paper that is ~23mm off a 190mm
     content box, which is enough to drop the label grid from three columns to
     two and waste half the sheet. */
  .shell-header,
  .tab-bar,
  .rail,
  .rail__logo,
  .qr-preprint {
    display: none;
  }

  /* The page is the sheet: no page padding, no reading measure, no reserved
     space for the bottom tab bar that is no longer there. */
  body {
    background: #fff;
    color: #000;
  }

  .shell-main {
    padding: 0;
    max-width: none;
  }

  .qr-sheet {
    padding: 0;
    border-radius: 0;
  }

  /* Two rules on one selector, and they are not related.
     `break-inside: avoid` because a label may not be split across two sheets of
     paper: half a folder label at the foot of page 1 and half at the top of
     page 2 cannot be cut into anything. `border-color` because the cut guides
     are printed ON the artifact and then cut through — light grey is visible
     enough to follow and quiet enough not to frame every folder in the choir. */
  .qr-label {
    break-inside: avoid;
    border-color: #bbb;
  }
}
