/* Mergoscia — application stylesheet.
   Implements the Mergoscia design system against the existing markup:
   every class name here already exists in the Jinja templates, so this file
   is a drop-in replacement for the previous app/static/css/style.css.
   Tokens are inlined (single file, no build step, no @import chain). */

/* ── Fonts ─────────────────────────────────────────────────────────────── */
/* Self-hosted, latin-only (this app only ever renders German/English text).
   Variable fonts, so one file per style covers the whole weight range used
   below — no external request, so no CSP allowance needed for it. */
@font-face {
  font-family: 'Newsreader';
  font-style: normal;
  font-weight: 500 600;
  font-display: swap;
  src: url('/static/fonts/newsreader-500-600.woff2') format('woff2');
}
@font-face {
  font-family: 'Newsreader';
  font-style: italic;
  font-weight: 500;
  font-display: swap;
  src: url('/static/fonts/newsreader-italic-500.woff2') format('woff2');
}
@font-face {
  font-family: 'Work Sans';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url('/static/fonts/work-sans-400-700.woff2') format('woff2');
}

/* ── Tokens ────────────────────────────────────────────────────────────── */
:root {
  --stone-50: oklch(97.5% 0.005 80);
  --stone-100: oklch(95% 0.007 78);
  --stone-200: oklch(90% 0.009 76);
  --stone-300: oklch(83% 0.011 75);
  --stone-400: oklch(70% 0.013 74);
  --stone-500: oklch(56% 0.014 73);
  --stone-600: oklch(44% 0.014 73);
  --stone-700: oklch(34% 0.013 74);
  --stone-800: oklch(25% 0.011 76);
  --stone-900: oklch(18% 0.009 78);

  --moss-400: oklch(62% 0.08 162);
  --moss-500: oklch(48% 0.08 162);
  --moss-600: oklch(38% 0.07 162);

  --terracotta-400: oklch(66% 0.09 42);
  --terracotta-500: oklch(52% 0.09 42);
  --terracotta-600: oklch(42% 0.08 42);

  /* Lightness follows the terracotta ramp rather than sitting a step
     brighter: white on a 58%-light ochre measures 4.19:1, under AA for the
     13px name chip, and 52% brings it to 5.4:1. Ochre and moss then separate
     by hue rather than by lightness, which at 85° against 162° is still
     unmistakable at the bar's 5px. */
  --ochre-400: oklch(66% 0.10 85);
  --ochre-500: oklch(52% 0.10 85);

  --color-bg: var(--stone-50);
  --color-surface: oklch(99% 0.003 80);
  --color-surface-sunken: var(--stone-100);
  --color-border: var(--stone-200);
  --color-border-strong: var(--stone-300);
  --color-text: var(--stone-900);
  --color-text-muted: var(--stone-600);
  --color-text-faint: var(--stone-500);

  --color-accent: var(--moss-500);
  --color-accent-hover: var(--moss-600);
  --color-accent-soft: oklch(93% 0.03 162);
  --color-accent-contrast: oklch(99% 0.005 162);
  /* Text on an accent-soft ground. Its own token rather than reusing
     --color-accent-hover: the soft ground is 93% light here and 34% in the
     dark theme, so the foreground has to move with it. Every dark pairing
     that read moss-on-moss measured under 4.5:1 before this existed. */
  --color-accent-soft-contrast: var(--moss-600);
  /* The fill a browser paints into a checkbox or radio. moss-500's 0.08 chroma
     is read across a card-sized area; in a 16px box the same value desaturates
     into dark grey, so the mark gets a fraction more chroma and lightness to
     land as the same green. Small areas need more colour to read as coloured. */
  --color-accent-mark: oklch(52% 0.11 162);

  --color-open: var(--ochre-500);
  --color-open-contrast: oklch(99% 0.005 85);

  --color-warn: var(--terracotta-500);
  --color-warn-hover: var(--terracotta-600);
  --color-warn-soft: oklch(94% 0.035 42);
  /* Text on a filled warn ground. Light in the light theme, dark in the dark
     one — terracotta-400 is too light to carry white at badge size. Mirrors
     the accent/open pairs so every filled ground has a stated foreground. */
  --color-warn-contrast: oklch(99% 0.005 42);
  /* Text on a warn-soft ground — same reason as the accent pair: the soft
     ground moves between themes, so its foreground has to move with it. */
  --color-warn-soft-contrast: var(--terracotta-600);

  --color-focus-ring: oklch(60% 0.09 162 / 40%);

  --font-ui: "Work Sans", system-ui, sans-serif;
  --font-display: "Newsreader", Georgia, serif;

  --text-xs: 0.8125rem;
  --text-sm: 0.9375rem;
  --text-base: 1rem;
  --text-lg: 1.1875rem;
  --text-xl: 1.5rem;
  --text-2xl: 1.875rem;

  --leading-tight: 1.2;
  --leading-normal: 1.5;
  --leading-relaxed: 1.65;

  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.5rem;
  --space-6: 2rem;

  --radius-xs: 3px;
  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-pill: 999px;

  --shadow-md: 0 2px 8px oklch(20% 0.01 76 / 8%), 0 1px 2px oklch(20% 0.01 76 / 6%);

  --tap-min: 44px;

  /* Native controls — the unchecked checkbox, the date field's own picker
     glyph, select popups, scrollbars — are painted by the browser from
     color-scheme, not from these tokens. Without it a dark page renders them
     in light chrome, which is where the white checkbox on a dark card came
     from. Stated per theme, exactly like the tokens. */
  color-scheme: light;
}

@media (prefers-color-scheme: dark) {
  :root {
    color-scheme: dark;
    --color-bg: var(--stone-900);
    --color-surface: oklch(21% 0.011 77);
    --color-surface-sunken: oklch(17% 0.009 78);
    --color-border: var(--stone-700);
    --color-border-strong: var(--stone-600);
    --color-text: var(--stone-50);
    --color-text-muted: var(--stone-300);
    --color-text-faint: var(--stone-400);
    --color-accent: var(--moss-400);
    --color-accent-hover: oklch(70% 0.08 162);
    /* At 28% this sat 7 points of lightness off the 21% surface — 1.3:1, so a
       selected row, an active filter and a badge all but disappeared. 34%
       reads as a ground without becoming a second surface. */
    --color-accent-soft: oklch(34% 0.055 162);
    --color-accent-soft-contrast: oklch(88% 0.06 162);
    --color-accent-mark: oklch(68% 0.10 162);
    --color-accent-contrast: oklch(15% 0.02 162);
    --color-open: var(--ochre-400);
    --color-open-contrast: oklch(15% 0.025 85);
    --color-warn: var(--terracotta-400);
    --color-warn-hover: oklch(72% 0.09 42);
    /* Lifted off the 21% surface for the same reason as the accent ground. */
    --color-warn-soft: oklch(34% 0.06 42);
    --color-warn-soft-contrast: oklch(88% 0.06 42);
    --color-warn-contrast: oklch(15% 0.025 42);
    --color-focus-ring: oklch(70% 0.09 162 / 45%);
  }
}

[data-theme="light"] {
  color-scheme: light;
  --color-accent-soft-contrast: var(--moss-600);
  --color-accent-mark: oklch(52% 0.11 162);
  --color-bg: var(--stone-50);
  --color-surface: oklch(99% 0.003 80);
  --color-surface-sunken: var(--stone-100);
  --color-border: var(--stone-200);
  --color-border-strong: var(--stone-300);
  --color-text: var(--stone-900);
  --color-text-muted: var(--stone-600);
  --color-text-faint: var(--stone-500);
  --color-accent: var(--moss-500);
  --color-accent-hover: var(--moss-600);
  --color-accent-soft: oklch(93% 0.03 162);
  --color-accent-contrast: oklch(99% 0.005 162);
  --color-open: var(--ochre-500);
  --color-open-contrast: oklch(99% 0.005 85);
  --color-warn: var(--terracotta-500);
  --color-warn-hover: var(--terracotta-600);
  --color-warn-soft: oklch(94% 0.035 42);
  --color-warn-contrast: oklch(99% 0.005 42);
  --color-warn-soft-contrast: var(--terracotta-600);
  /* The explicit theme scopes carry every token the media query above sets.
     Omitting the focus ring here left an explicitly-dark page focusing with
     the light theme's ring. */
  --color-focus-ring: oklch(60% 0.09 162 / 40%);
}

[data-theme="dark"] {
  color-scheme: dark;
  --color-bg: var(--stone-900);
  --color-surface: oklch(21% 0.011 77);
  --color-surface-sunken: oklch(17% 0.009 78);
  --color-border: var(--stone-700);
  --color-border-strong: var(--stone-600);
  --color-text: var(--stone-50);
  --color-text-muted: var(--stone-300);
  --color-text-faint: var(--stone-400);
  --color-accent: var(--moss-400);
  --color-accent-hover: oklch(70% 0.08 162);
  --color-accent-soft: oklch(34% 0.055 162);
  --color-accent-soft-contrast: oklch(88% 0.06 162);
  --color-accent-mark: oklch(68% 0.10 162);
  --color-accent-contrast: oklch(15% 0.02 162);
  --color-open: var(--ochre-400);
  --color-open-contrast: oklch(15% 0.025 85);
  --color-warn: var(--terracotta-400);
  --color-warn-hover: oklch(72% 0.09 42);
  --color-warn-soft: oklch(34% 0.06 42);
  --color-warn-soft-contrast: oklch(88% 0.06 42);
  --color-warn-contrast: oklch(15% 0.025 42);
  --color-focus-ring: oklch(70% 0.09 162 / 45%);
}

/* ── Base ──────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

html {
  font-family: var(--font-ui);
  font-size: 16px;
  line-height: var(--leading-normal);
  -webkit-font-smoothing: antialiased;
  background: var(--color-bg);
}

body {
  max-width: 600px;
  margin: 0 auto;
  padding: var(--space-4);
  padding-bottom: 5.5rem;
  background: var(--color-bg);
  color: var(--color-text);
  text-wrap: pretty;
}

main { min-height: 80vh; }

h1, h2, h3 {
  font-family: var(--font-display);
  font-weight: 600;
  line-height: var(--leading-tight);
  letter-spacing: -0.01em;
}

h1 { font-size: var(--text-2xl); margin-bottom: var(--space-4); }
h2 { font-size: var(--text-lg); margin: var(--space-5) 0 var(--space-2); }
h3 { font-size: var(--text-base); margin: var(--space-4) 0 var(--space-2); }

p { margin: 0 0 var(--space-3); }
p:last-child { margin-bottom: 0; }

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

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

/* ── Forms ─────────────────────────────────────────────────────────────── */
label {
  display: block;
  margin-bottom: var(--space-3);
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--color-text-muted);
}

input:not([type="checkbox"]):not([type="file"]), select, textarea, button {
  font-family: inherit;
  font-size: var(--text-base);
  min-height: var(--tap-min);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  background: var(--color-surface);
  color: var(--color-text);
}

/* A field takes the width of its container; a button takes the width of its
   label, which is why only the field half of that pair is listed here. */
input:not([type="checkbox"]):not([type="file"]), select, textarea { display: block; width: 100%; margin-top: var(--space-1); font-weight: 400; }
textarea { line-height: var(--leading-normal); resize: vertical; }

input:focus, select:focus, textarea:focus {
  outline: none;
  border-color: var(--color-accent);
  box-shadow: 0 0 0 3px var(--color-focus-ring);
}

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

/* WebKit sizes a date field from the text it draws and lets that beat
   `width: 100%`, so on iOS — where every browser is WebKit, Brave included —
   both fields in the booking panel hung past the panel's right edge while
   every other field sat inside it. Dropping the native appearance puts the
   field back under the page's own box model, and `min-width` clears the
   intrinsic floor underneath it; the border, radius and padding above already
   draw the field, and a tap still opens the system picker. */
input[type="date"] { -webkit-appearance: none; appearance: none; min-width: 0; }

/* Native checkboxes and radios draw themselves in the browser's own blue,
   which is the one colour on the page that belongs to no theme. */
input[type="checkbox"], input[type="radio"] { accent-color: var(--color-accent-mark); }

/* A file input's own "Choose file" button and "No file chosen" status text
   come from the browser's UI locale, not the page's — invisible to this
   app's own translations regardless of `lang`, and (per issue #89) visibly
   the wrong language whenever a user's browser/OS locale disagrees with
   their chosen app locale. Visually hidden rather than display:none, so it
   stays in the tab order; `.file-picker-btn` is the sighted, translated
   stand-in a click or Enter on the hidden input's own label passes through
   to. */
.file-input-native {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* «Fotos» above «Fotos hinzufügen» says the same thing twice — the button
   already names what it does. The label's own text is a bare text node, so
   zero the label's font-size and restore it on the button inside. The label
   still wraps the input, so the click target and its accessible name are
   unchanged.

   Opted into by class, not matched structurally on
   `label:has(> .file-picker-btn)`: the structural form silences everything
   else in the label too, so a hint or helper span added later would vanish
   with nothing in the markup to explain why. */
.label-silent { font-size: 0; }
.label-silent > .file-picker-btn { font-size: var(--text-base); margin-top: 0; }

.file-picker-btn {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: var(--tap-min);
  margin-top: var(--space-1);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  background: var(--color-surface);
  color: var(--color-text);
  font-weight: 400;
  cursor: pointer;
}

.file-input-native:focus-visible + .file-picker-btn {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

/* A screen has one action worth shouting about, and it is the one that submits
   the form in front of you. Every other button is quiet: an outline that reads
   as a control without competing with the content it acts on. */
button {
  background: none;
  color: var(--color-text);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  font-weight: 500;
  cursor: pointer;
  margin-top: var(--space-2);
  transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
  /* A button carries a label, not a text cursor — the 44px field box reads as
     oversized on it. Touch raises it back to a full tap target below. */
  min-height: 36px;
  padding: var(--space-2) var(--space-4);
  font-size: var(--text-sm);
}

@media (pointer: coarse) {
  button { min-height: 44px; }
}

button:hover { background: var(--color-surface-sunken); }
button:active { background: var(--color-surface-sunken); }

/* Primary is an explicit opt-in, never a position in the markup. A structural
   rule (`main > form button[type="submit"]`) made two identical save buttons
   render differently depending on how deeply a template nested the form. */
button.btn-primary {
  background: var(--color-accent);
  color: var(--color-accent-contrast);
  border-color: transparent;
}
button.btn-primary:hover, button.btn-primary:active { background: var(--color-accent-hover); }
button:disabled { background: var(--color-surface-sunken); color: var(--color-text-faint); cursor: not-allowed; }

/* A link that carries out the page's action rather than sitting inside a
   sentence. An action set as a bare paragraph link is invisible next to a
   heading, and the members page's «Mitglied einladen» is the page's whole
   reason for existing beyond the list itself. Standalone actions only — an
   inline link in running text stays a link.
   The button block above is scoped to the `button` element deliberately, so
   the shape is restated here rather than that scope being loosened. */
a.btn-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 36px;
  padding: var(--space-2) var(--space-4);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--color-text);
  text-decoration: none;
  transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
}
a.btn-link:hover { background: var(--color-surface-sunken); color: var(--color-text); text-decoration: none; }
a.btn-link.btn-primary { background: var(--color-accent); color: var(--color-accent-contrast); border-color: transparent; }
a.btn-link.btn-primary:hover { background: var(--color-accent-hover); color: var(--color-accent-contrast); }
@media (pointer: coarse) { a.btn-link { min-height: 44px; } }

/* ── Feedback ──────────────────────────────────────────────────────────── */
.error {
  color: var(--color-warn);
  font-size: var(--text-sm);
  margin-bottom: var(--space-4);
}

.banner {
  padding: var(--space-3);
  border-radius: var(--radius-md);
  margin-bottom: var(--space-4);
  font-size: var(--text-sm);
}

.banner-warning {
  background: var(--color-warn-soft);
  border: 1px solid var(--color-warn);
  color: var(--color-text);
}

.notice {
  background: var(--color-surface-sunken);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: var(--space-2) var(--space-3);
  margin-bottom: var(--space-2);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

.badge {
  display: inline-flex;
  align-items: center;
  background: var(--color-accent-soft);
  color: var(--color-accent-soft-contrast);
  padding: 0.1rem 0.45rem;
  border-radius: var(--radius-pill);
  font-size: var(--text-xs);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  line-height: 1.5;
}

.badge-unread {
  background: var(--color-warn);
  color: var(--color-warn-contrast);
  margin-left: var(--space-1);
  min-width: 1.25rem;
  justify-content: center;
}

.empty { color: var(--color-text-faint); font-size: var(--text-sm); }
.meta { color: var(--color-text-muted); font-size: var(--text-xs); }
.inline-form { display: inline; }

/* ── Icons ─────────────────────────────────────────────────────────────── */
/* The sprite itself never renders — only its <symbol>s, referenced by <use>. */
.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }

/* Sprite symbols are stroke-only; colour comes from the nearest text colour. */
.icon {
  display: block;
  flex-shrink: 0;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* ── Bottom nav ────────────────────────────────────────────────────────── */
/* The rail wordmark lives inside the nav element but belongs to the desktop
   rail only — hidden here so it never eats space in the mobile bar. */
.rail-brand { display: none; }

/* ── Login ────────────────────────────────────────────────────────────────
   The one screen with no chrome competing for attention, so the full mark
   earns real size here: centred above the form, in the accent colour, the
   house name beneath it in the display face. */
.auth-brand {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
  margin: var(--space-6) 0 var(--space-5);
  color: var(--color-text);
}
.auth-brand .brand-mark { width: 84px; height: 84px; color: var(--color-accent); }
.auth-brand span {
  font-family: var(--font-display);
  font-size: var(--text-xl);
  font-weight: 600;
  letter-spacing: -0.01em;
}

.bottom-nav {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  justify-content: space-around;
  gap: var(--space-1);
  background: var(--color-surface);
  border-top: 1px solid var(--color-border);
  padding: var(--space-1) var(--space-2);
  padding-bottom: max(var(--space-1), env(safe-area-inset-bottom));
}

.nav-item {
  text-decoration: none;
  color: var(--color-text-muted);
  font-size: var(--text-xs);
  font-weight: 500;
  text-align: center;
  min-width: 48px;
  min-height: var(--tap-min);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  border-radius: var(--radius-sm);
  transition: color 120ms ease, background-color 120ms ease;
}

.nav-item:hover { color: var(--color-text); text-decoration: none; }
.nav-item:active, .nav-item[aria-current="page"] { color: var(--color-accent-soft-contrast); background: var(--color-accent-soft); }
.nav-item[aria-current="page"] .icon { stroke-width: 2; }
.nav-label { white-space: nowrap; }

/* The unread dot floats over the icon rather than pushing the label. */
.nav-item { position: relative; }
.nav-item .nav-dot {
  position: absolute;
  top: 2px;
  left: 50%;
  margin-left: 4px;
}

/* ── Appearance switcher ───────────────────────────────────────────────── */
.app-chrome {
  display: flex;
  justify-content: flex-end;
  margin-bottom: var(--space-3);
}

.theme-switch { border: 0; padding: 0; margin: 0; min-width: 0; }
.theme-switch-legend { padding: 0 0 var(--space-1); font-size: var(--text-xs); font-weight: 500; color: var(--color-text-faint); }
.theme-switch-compact .theme-switch-legend { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.theme-switch-track {
  display: inline-flex;
  gap: 2px;
  padding: 2px;
  background: var(--color-surface-sunken);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-pill);
}

.theme-switch-opt { margin: 0; font-weight: 500; }
/* input[type=radio] rather than a bare `input`: the base forms rule matches at
   (0,2,1) since the file-input change added its :not() pair, so a plain
   `.theme-switch-opt input` loses `width` to `width: 100%` and the absolutely
   positioned radios stack ~1200px off to the right — a phantom horizontal
   scrollbar on every page. Class + attribute + element ties that specificity
   and wins on order. */
.theme-switch-opt input[type="radio"] { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; }

.theme-switch-opt > span {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  min-height: 34px;
  padding: 0 var(--space-3);
  border-radius: var(--radius-pill);
  font-size: var(--text-xs);
  color: var(--color-text-muted);
  cursor: pointer;
  transition: background-color 120ms ease, color 120ms ease;
}

.theme-switch-opt:hover > span { color: var(--color-text); }

.theme-switch-opt input:checked + span {
  background: var(--color-surface);
  color: var(--color-accent);
  box-shadow: var(--shadow-md);
}

.theme-switch-opt input:checked + span .icon { stroke-width: 2; }
.theme-switch-opt input:focus-visible + span { outline: 2px solid var(--color-accent); outline-offset: 2px; }

/* Compact form sits in the page chrome: icons only, 44px targets. */
.theme-switch-compact .theme-switch-label { display: none; }
.theme-switch-compact .theme-switch-opt > span { min-width: var(--tap-min); min-height: var(--tap-min); padding: 0; }

/* ── Swipe navigation (touch only) ───────────────────────────────────────
   The app is a flat line of views rather than a tree, so on touch a
   horizontal drag walks it (see static/js/swipe-nav.js). The neighbouring
   page rides in under the finger: the viewport carries both the current
   <main> and two ghost slots parked one screen out on either side, and the
   whole viewport translates as one, so the peek moves in lockstep with no
   per-element bookkeeping. The incoming page then slides in from the same
   side, continuing the movement the finger started. */
.swipe-viewport { position: relative; }
.swipe-ghost { display: none; }

@media (pointer: coarse) {
  /* The viewport itself never moves — it is the window. Its <main> and the
     ghosts slide across underneath, so the frame stays put and can clip them.
     (An earlier version translated the viewport and clipped on the body; a
     moving element cannot clip its own overflow, which is why ghosts leaked
     along the edges.) The clip is only switched on for the duration of a
     gesture, so nothing that legitimately overhangs is affected at rest. */
  .swipe-viewport.is-armed,
  .swipe-viewport.is-dragging,
  .swipe-viewport.is-settling { overflow-x: clip; overflow-clip-margin: 0; }

  .swipe-viewport.is-dragging > main,
  .swipe-viewport.is-dragging > .swipe-ghost,
  .swipe-viewport.is-settling > main,
  .swipe-viewport.is-settling > .swipe-ghost { transform: translate3d(var(--swipe-x, 0px), 0, 0); }

  /* Promoted on touchstart rather than on first move, so the opening frame of
     the drag is not the one that pays for the layer. */
  .swipe-viewport.is-armed > main,
  .swipe-viewport.is-dragging > main,
  .swipe-viewport.is-dragging > .swipe-ghost { will-change: transform; }

  .swipe-viewport.is-settling > main,
  .swipe-viewport.is-settling > .swipe-ghost { transition: transform 160ms cubic-bezier(0.32, 0.72, 0, 1); }

  .swipe-ghost {
    display: block;
    position: absolute;
    top: 0;
    bottom: 0;
    width: 100%;
    overflow: hidden;
    /* Decoration under the finger: never focusable, never a hit target, and
       clipped so a taller neighbour cannot spill past the fold. */
    pointer-events: none;
    background: var(--color-bg);
  }
  /* Parked a hair beyond one screen, so a fractional viewport width cannot
     round the seam open. The gap is hidden under the drag and never seen. */
  .swipe-ghost[data-side="prev"] { left: calc(-100% - 1px); }
  .swipe-ghost[data-side="next"] { left: calc(100% + 1px); }
  /* Beats .swipe-ghost's display:block, so the hidden attribute still hides. */
  .swipe-ghost[hidden] { display: none; }
}

@media (pointer: coarse) and (prefers-reduced-motion: no-preference) {
  @keyframes swipe-in-forward { from { transform: translateX(20px); opacity: 0 } }
  @keyframes swipe-in-back { from { transform: translateX(-20px); opacity: 0 } }
  html[data-swipe-in="forward"] main { animation: swipe-in-forward 180ms ease-out both; }
  html[data-swipe-in="back"] main { animation: swipe-in-back 180ms ease-out both; }
}

/* ── Back link ────────────────────────────────────────────────────────────
   Profil → Mitglieder → Einladen is a chain, not a tree, so each step past
   the first names where it came from. Sits above the heading, quiet enough
   not to compete with it. */
.back-link {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  margin-bottom: var(--space-3);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  text-decoration: none;
  min-height: 44px;
  padding-right: var(--space-2);
}
.back-link:hover { color: var(--color-text); text-decoration: underline; }
.back-link svg { width: 16px; height: 16px; flex: none; fill: none; stroke: currentcolor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }

/* ── Lists (admin, stays, tasks, notes) ──────────────────────────────────── */
.admin-list, .stay-list, .task-list, .note-list {
  list-style: none;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

/* A member row is one person and the action on that person. Laid out inline,
   a long name plus address wraps and pushes «Deaktivieren» onto its own line,
   where it reads as belonging to whatever follows. The grid keeps the action
   pinned to the row: text wraps inside its own column (min-width:0) and the
   button holds the right edge at every width.
   Matched on structure, not a class — the member <li> in the template carries
   none. The treatment goes on every row of the list, not only the rows that
   happen to have a form, so one list reads as one component. */
main ul:has(> li > form.inline-form) { list-style: none; padding-left: 0; margin: 0; }
.admin-item,
main ul:has(> li > form.inline-form) > li {
  border-bottom: 1px solid var(--color-border);
  padding: var(--space-2) 0;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: start;
  gap: var(--space-2) var(--space-3);
}
.admin-item p,
.admin-list > li > p,
main ul:has(> li > form.inline-form) > li p { margin: 0 0 0.15rem; font-size: var(--text-sm); grid-column: 1; overflow-wrap: anywhere; }
/* A person is a name; the address is how to reach them. Set as one run —
   «Anna Bianchi (anna@…)» — the two read as a single long string and the name,
   which is what the eye is scanning for, loses its start position. Name over
   address in muted small type gives the list one scannable left column.
   The pair is wrapped in a <div> rather than left as two <li> children: as two
   grid items they took two rows, and a spanning action button then sized the
   first of them, opening a gap that broke the pair apart. One wrapper makes
   the person one item and the row one line of content and one action. */
main ul:has(> li > form.inline-form) > li > div { grid-column: 1; min-width: 0; }
main ul:has(> li > form.inline-form) > li p.meta { font-size: var(--text-xs); margin-bottom: 0; }
/* The name is what the eye scans this column for, so it carries slightly more
   weight than the address beneath it. `<strong>` would say something stronger
   than is meant — no name here is more important than another. */
main ul:has(> li > form.inline-form) > li > div > p:first-child { font-weight: 500; }
/* The action row sits under the heading with the list's own breathing room
   below it, not a paragraph's. */
main p:has(> a.btn-link) { margin-bottom: var(--space-5); }
/* A badge is a grid item on these rows, and a grid item stretches to fill its
   column — which turned «unbestätigt» into a full-width bar. */
main ul:has(> li > form.inline-form) > li > .badge { justify-self: start; }
/* The row is a grid, so this form is a grid item; the `display: inline` above
   must be overridden and the placement made explicit. The person's name is a
   bare text node, so column 1 fills by auto-placement — no <p> is assumed. */
.admin-item > form,
main ul:has(> li > form.inline-form) > li > form.inline-form {
  display: block;
  grid-column: 2;
  grid-row: 1 / -1;
  align-self: center;
}
/* A button's own top margin belongs to a form's vertical flow, not to a row
   whose height it is otherwise setting — left in, it made every row with an
   action taller than the rows without one. */
.admin-item > form > button,
main ul:has(> li > form.inline-form) > li > form.inline-form > button { margin-top: 0; }
/* A visitor can be made a member as well as deactivated, so that row carries
   two forms — and the rule above pins every form to the same cell, which drew
   them on top of each other. Only the rows that actually have two get a third
   track, so every other row keeps its two-column measure unshifted. */
main ul:has(> li > form.inline-form) > li:has(> form.inline-form + form.inline-form) {
  grid-template-columns: minmax(0, 1fr) auto auto;
}
main ul:has(> li > form.inline-form) > li > form.inline-form + form.inline-form {
  grid-column: 3;
}

/* ── Calendar ──────────────────────────────────────────────────────────── */
/* The month arrows are a paired control used in alternation, so they sit
   together at one end (Fitts): moving between them is a few px, not the full
   header width. */
.cal-head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-bottom: var(--space-3);
}
.cal-head .cal-title { order: -1; margin-right: auto; }
.cal-head .cal-page + .cal-page { margin-left: calc(var(--space-1) * -1); }

.cal-page {
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--tap-min);
  min-height: var(--tap-min);
  padding: 0;
  margin: 0;
  background: none;
  border: 1px solid var(--color-border);
  color: var(--color-text-muted);
  font-size: var(--text-lg);
}

.cal-page:hover { background: var(--color-surface-sunken); color: var(--color-text); }

.cal-title {
  font-family: var(--font-display);
  font-size: var(--text-xl);
  font-weight: 600;
  letter-spacing: -0.01em;
}

.dow {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  text-align: center;
  color: var(--color-text-faint);
  font-size: var(--text-xs);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin-bottom: var(--space-1);
}

.cal-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 2px;
  margin-bottom: var(--space-5);
}

.cal-cell {
  position: relative;
  min-height: var(--tap-min);
  padding: var(--space-1);
  border-radius: var(--radius-sm);
  background: var(--color-surface);
  border: 1px solid var(--color-border);
}

.cal-empty { background: none; border-color: transparent; }
.cal-num { font-size: var(--text-sm); font-variant-numeric: tabular-nums; color: var(--color-text-muted); }

.cal-cell.is-today .cal-num {
  color: var(--color-accent);
  font-weight: 600;
  outline: 1.5px solid var(--color-accent);
  outline-offset: 2px;
  border-radius: var(--radius-pill);
  padding: 0 0.3rem;
}

/* The bar belongs to the day above it, so it sits inside the cell's padding.
   Flush to the bottom border it read as belonging to the next row's cell.
   A mid-stay day is a continuation, so its bar runs square from edge to edge;
   only the two ends of a stay are rounded (below).

   It carries its own label rather than repeating a chip above it, which is
   what the height is for. Nothing wraps: a cell that cannot fit the text
   clips it, and the two label spans below decide which text is even offered. */
.cal-bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: var(--space-1);
  display: flex;
  align-items: center;
  height: 16px;
  /* 3px, not --space-1: an arrival day's bar is about 22px wide on a phone,
     and 4px of padding either side left too little for even two initials —
     they clipped to "C…", which is worse than the colour on its own. */
  padding: 0 3px;
  border-radius: 0;
  font-size: 11px;
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  overflow: hidden;
  background: var(--color-warn);
  color: var(--color-warn-contrast);
}

/* The label is initials or a single short word at every width, so it fits the
   bar rather than needing one. The ellipsis is the backstop for the one case
   that still overflows: a translated "Occupied" on an arrival day's half bar. */
.cal-bar-label { overflow: hidden; text-overflow: ellipsis; }

/* Terracotta is someone else's private stay — the base fill above, since it is
   the one state with no name to show and therefore no rule of its own. Moss is
   yours. Ochre marks a stay its booker opened to joiners: it follows the
   stay's own flag, never the viewer's eligibility to ask, so a stay stays
   ochre once the viewer has asked and once its arrival has passed. */
.cal-cell.state-own .cal-bar { background: var(--color-accent); color: var(--color-accent-contrast); }
.cal-cell.state-open .cal-bar { background: var(--color-open); color: var(--color-open-contrast); }

/* Arrival is an afternoon event, so its bar only covers the day's second
   half; the departure marker (.cal-bar-checkout, below) mirrors it on the
   morning half of the checkout day. The stay opens at the arrival bar's left
   end and finishes at the checkout marker's right end — those two are the
   only rounded ends. Every day between, including the last full night, runs
   square from edge to edge.

   The 1px either side of the midpoint is what keeps a same-day changeover
   legible: without it the departing stay's rounded end and the arriving
   stay's meet exactly, and the two read as one continuous bar. */
.cal-cell.span-start .cal-bar { left: calc(50% + 1px); border-radius: var(--radius-sm) 0 0 var(--radius-sm); }
.cal-cell.span-end .cal-bar { right: 0; border-radius: 0; }

.cal-bar-checkout {
  position: absolute;
  left: 0;
  right: calc(50% + 1px);
  bottom: var(--space-1);
  height: 16px;
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  /* The marker is the last segment of the same bar, so it carries the same
     fill and no outline of its own. It never carries the label: there is no
     room on half a cell, and a stay that is ending has already been named. */
  background: var(--color-warn);
}

.cal-bar-checkout.state-own { background: var(--color-accent); }
.cal-bar-checkout.state-open { background: var(--color-open); }

/* Hick: the screen's one question is "which days are free?", so a free cell
   answers it by ground alone rather than by the absence of a marker. Taken
   and own days sit back on the sunken surface. */
.cal-cell.state-free { background: var(--color-surface); border-color: var(--color-border-strong); }
.cal-cell.state-occupied, .cal-cell.state-own, .cal-cell.state-open { background: var(--color-surface-sunken); }

.cal-cell.state-free[role="button"] { cursor: pointer; transition: background-color 120ms ease; }
.cal-cell.state-free[role="button"]:hover,
.cal-cell.state-free[role="button"]:active { background: var(--color-accent-soft); border-color: var(--color-accent); }

/* A booked day is a control too — it opens its own stay in the column beside
   the grid. It says so by the cursor and the border rather than by the ground,
   which already carries the free/taken distinction and cannot also carry this. */
.cal-cell[role="button"]:not(.state-free) { cursor: pointer; transition: border-color 120ms ease; }
.cal-cell[role="button"]:not(.state-free):hover { border-color: var(--color-border-strong); }

/* Selection is on every cell of the run, not just the one clicked: a stay is
   the thing being looked at, and half-highlighting it would read as a
   different, shorter one. */
.cal-cell.is-selected { border-color: var(--color-accent); box-shadow: 0 0 0 1px var(--color-accent); }

/* ── Stays ─────────────────────────────────────────────────────────────── */
.stay-row, .task-row {
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-surface);
  padding: var(--space-3);
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-2);
  align-items: baseline;
}

.stay-row.is-own { border-left: 3px solid var(--color-accent); }

/* Same treatment as .badge-unread: this is that dot's accent colour, moved
   onto the card it is naming rather than a count badge. Shared with
   .task-row, which reuses this and .unread-dot below for issue rows. */
.stay-row.is-unread, .task-row.is-unread { border-left: 3px solid var(--color-warn); }

/* A stay card's stripe is the calendar bar restated, so it carries the bar's
   weight rather than a hairline's. Only the stay rows: a task row's 3px is not
   answering to anything in a grid. The padding gives back exactly what the
   border took, so a card with a stripe and one without still line their text
   up in the same list. */
.stay-row.is-own, .stay-row.is-unread {
  border-left-width: 6px;
  padding-left: calc(var(--space-3) - 5px);
}

/* The card for the stay just clicked on the grid. Base block, not the 900px
   one that carries `.task-row.is-selected`: the calendar's column is on screen
   at every width, so its selection has to be. The left stripe rules above win
   on that one edge by source order, which is right — a selected stay of your
   own is still yours, and still says so in moss. */
.stay-row.is-selected { border-color: var(--color-accent); background: var(--color-accent-soft); }
.unread-dot {
  display: inline-block;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: var(--color-warn);
  margin-right: var(--space-1);
}

/* A checkbox sits beside its label, not above it — the base `label` rule is
   written for a field under its caption. `start` rather than `center`: the
   copy wraps to two lines at narrow widths, and the box belongs with the
   first of them. Making the text its own flex item is what keeps that
   second line in the text column instead of hanging under the box. */
.check { display: flex; align-items: start; gap: var(--space-2); }
/* start-aligned so a second line stays in the text column, but that pins the
   box to the top of the line box rather than centring it on the first line.
   The offset is half the leading, which puts it on that line's centre. */
.check input[type="checkbox"] { flex: none; margin: calc((1em * var(--leading-normal) - 1em) / 2) 0 0; }
.stay-openness { margin-top: var(--space-2); }
.stay-openness .check { margin-bottom: 0; }
/* The booking panel's "someone else…" fields, revealed by the select's own
   checked option rather than by script.
   No JS for two reasons. This app's CSP blocks `hx-on:` and inline handlers,
   and they fail silently; and the panel is re-rendered from the server on
   every #calendar-block swap, so any JS-held toggle state would be destroyed
   on each one. A rule reading the select's `:checked` option is derived from
   server state, so it is correct the instant the swap lands with nothing to
   re-bind. `:has()` is already load-bearing in this stylesheet.
   The fallback is deliberately "both visible": a browser without `:has()` that
   hid the field would make "someone else…" quietly unusable. */
.book-for-email { display: block; }
@supports selector(:has(*)) {
  .book-for ~ .book-for-email { display: none; }
  .book-for:has(select[name="owner_id"] option[value="__other__"]:checked) ~ .book-for-email { display: block; }
}
/* The booking form's six fields are not six equal things. Arrival and
   departure are one decision made twice, and a pair on one row is read once;
   stacked, they are two consecutive rows of identical box with nothing to tell
   them apart but their captions. A guest count is one or two digits, and a
   field the width of the panel invites the reader to expect more of it.
   Wrapping flex rather than grid: only the date pair wants a track of its own,
   and a two-column grid would hold «Abbrechen» out at the panel's far edge,
   half a panel away from the button it belongs beside. Here every other field
   claims a full row, the buttons claim only their labels, and no wrapper
   element is needed.
   Matched on the fields' own names: the panel's form carries no class, and this
   is the only form in the app with an arrival date. */
.panel form:has(input[name="arrival"]) {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 0 var(--space-3);
}
.panel form:has(input[name="arrival"]) > * { flex: 0 0 100%; }
.panel form:has(input[name="arrival"]) > label:has(input[name="arrival"]),
.panel form:has(input[name="arrival"]) > label:has(input[name="departure"]) {
  flex: 1 1 calc(50% - var(--space-3) / 2);
  min-width: 0;
}
/* Left in the flow at full width so nothing sits in a hole beside it; only the
   field itself is cut back to the measure the value actually needs. */
.panel form:has(input[name="arrival"]) > label:has(input[name="guest_count"]) input { max-width: 7rem; }
.panel form:has(input[name="arrival"]) > button { flex: 0 0 auto; }
/* Below this the two date fields are narrower than the value plus the system
   picker's own glyph, and the pair costs more than it saves. */
@media (max-width: 380px) {
  .panel form:has(input[name="arrival"]) > label:has(input[name="arrival"]),
  .panel form:has(input[name="arrival"]) > label:has(input[name="departure"]) { flex: 0 0 100%; }
}

/* The invite's member-or-visitor choice. A fieldset is the right grouping for
   a radio pair, but it arrives with a browser border and a legend notched into
   it; both are undone here so the group reads as one more field in the form,
   the same way `.theme-switch` undoes them.
   The radios need undoing too: the base forms rule matches every input that is
   not a checkbox or a file, so left alone each one renders as a tap-target-
   sized bordered box and its label is pushed to the far edge. Scoped here
   rather than fixed in that rule, which the theme switch already works around
   in its own way. */
.invite-role { border: 0; padding: 0; margin: 0 0 var(--space-3); min-width: 0; }
.invite-role legend {
  padding: 0 0 var(--space-1);
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--color-text-muted);
}
.invite-role .check { margin-bottom: var(--space-2); font-weight: 400; color: var(--color-text); }
.invite-role input[type="radio"] {
  /* `width: auto` is the load-bearing one, and the same trap `.theme-switch`
     documents: the base rule gives every non-checkbox input `width: 100%`, so
     `flex: none` (basis auto) resolves to the container's full width and shoves
     the label off to the right edge. */
  flex: none;
  width: auto;
  min-height: 0;
  padding: 0;
  border: 0;
  background: none;
  /* Half the leading, as `.check input[type="checkbox"]` does: start-aligned
     so a wrapped second line stays in the text column, offset so the control
     still centres on the first. */
  margin: calc((1em * var(--leading-normal) - 1em) / 2) 0 0;
}
.stay-who { font-weight: 600; font-size: var(--text-sm); }
.stay-dates { color: var(--color-text); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
.stay-guests, .stay-note { color: var(--color-text-muted); font-size: var(--text-xs); }

/* ── Boxes ───────────────────────────────────────────────────────────────
   One frame per level of nesting. A container (.panel, .manual-form,
   .manual-toc, .note, .notes-form) carries the single hairline border on the
   page surface; anything nested inside it separates by ground and space
   instead of drawing a second frame, so the box-in-box stacking stays
   legible. */
.panel, .manual-form, .manual-toc, .note, .notes-form {
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-surface);
  padding: var(--space-4);
}

/* A box inside a box drops its frame and sits back on the sunken ground. */
.panel .note, .manual-form .note, .panel .manual-toc {
  border-color: transparent;
  background: var(--color-surface-sunken);
  box-shadow: none;
}

/* A door into another area is not content — it is a destination, so it never
   competes with the page's own material. No elevation, accent ground, and the
   whole tile is the target (Fitts), not just the words in the heading: the
   link stretches over the card and everything else in it is inert text.
   Verwaltung on the profile and each row of the admin index (Nachrichten,
   Mitglieder, Einladen) share it, so a destination reads the same wherever it
   is offered. The href-matched selector is a shim for templates that have not
   yet been given the class — remove it once they carry .link-tile. */
.link-tile, .panel:has(> h2 > a[href="/admin"]) {
  box-shadow: none;
  background: var(--color-accent-soft);
  border-color: transparent;
  padding: var(--space-3);
  margin: 0 0 var(--space-3);
  border-radius: var(--radius-md);
  position: relative;
  cursor: pointer;
}
/* The profile's door closes a section before the user's own settings begin. */
.panel:has(> h2 > a[href="/admin"]) { margin-bottom: var(--space-5); }
.link-tile:hover, .panel:has(> h2 > a[href="/admin"]):hover { border-color: var(--color-accent); }
.link-tile h2 a::after, .panel:has(> h2 > a[href="/admin"]) h2 a::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }
.link-tile h2 a:focus-visible::after, .panel:has(> h2 > a[href="/admin"]) h2 a:focus-visible::after { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.link-tile h2, .panel:has(> h2 > a[href="/admin"]) h2 { margin: 0; font-size: var(--text-base); font-family: inherit; font-weight: 600; }
.link-tile h2 a, .panel:has(> h2 > a[href="/admin"]) h2 a { color: var(--color-accent-soft-contrast); text-decoration: none; }
.link-tile h2 a:hover, .panel:has(> h2 > a[href="/admin"]) h2 a:hover { text-decoration: underline; }
.link-tile .meta, .panel:has(> h2 > a[href="/admin"]) .meta { margin: var(--space-1) 0 0; }

/* The two message actions sit on one line: retrying is the affirmative one and
   keeps the accent, clearing stays quiet next to it. */
#admin-messages .failed-retry, #admin-messages .failed-clear { margin-right: var(--space-2); }

/* Label above value, not beside it: the values here are addresses and URLs
   that wrap, and a two-column layout gives them a column too narrow to read
   at the width this app is used at. */
.config-list { margin: 0 0 var(--space-3); }
.config-list dt {
  color: var(--color-text-muted);
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.config-list dd {
  margin: 0 0 var(--space-3);
  font-size: var(--text-sm);
  overflow-wrap: anywhere;
}
.config-warn { color: var(--color-warn); font-weight: 600; }

/* Sending a test mail proves the configuration above works. It is a
   diagnostic, not the page's business, so it stays a quiet outline button and
   sits tight against the note that explains it. */
.test-mail { margin-bottom: var(--space-2); }
.test-mail + .meta { margin-top: 0; }

.panel {
  box-shadow: var(--shadow-md);
  margin-top: var(--space-4);
}

.panel button[type="button"] {
  background: none;
  border-color: var(--color-border-strong);
  color: var(--color-text-muted);
}

.panel button[type="button"]:hover { background: var(--color-surface-sunken); color: var(--color-text); }

/* Destructive actions do not advertise themselves. At rest they are the
   quietest thing in their row — borderless, muted, small. The warn colour
   appears on hover and focus, once the pointer is already committed, so a
   list of stays is not dominated by its cancel buttons. */
.stay-cancel, .task-delete, .section-delete, .photo-delete, .note-delete, .failed-clear, .member-deactivate {
  background: none;
  border-color: transparent;
  color: var(--color-text-muted);
  font-weight: 400;
  text-decoration: underline;
  text-decoration-color: var(--color-border-strong);
  text-underline-offset: 3px;
}

.stay-cancel:hover, .task-delete:hover, .section-delete:hover, .photo-delete:hover, .note-delete:hover, .failed-clear:hover, .member-deactivate:hover,
.stay-cancel:focus-visible, .task-delete:focus-visible, .section-delete:focus-visible, .photo-delete:focus-visible, .note-delete:focus-visible, .failed-clear:focus-visible, .member-deactivate:focus-visible {
  background: var(--color-warn-soft);
  color: var(--color-warn-soft-contrast);
  text-decoration-color: transparent;
}

.stay-cancel { padding: var(--space-1) var(--space-2); margin-top: 0; font-size: var(--text-xs); }

/* Joining is the affirmative action, so it keeps a button's shape and the
   accent — different in form from cancel, not merely a different hue. */
.stay-join {
  background: var(--color-accent-soft);
  border-color: var(--color-accent);
  color: var(--color-accent-soft-contrast);
  font-weight: 500;
  padding: var(--space-1) var(--space-3);
  margin-top: 0;
  font-size: var(--text-xs);
}

.stay-join:hover { background: var(--color-accent); color: var(--color-accent-contrast); }

.join-req {
  border-top: 1px solid var(--color-border);
  margin-top: var(--space-2);
  padding-top: var(--space-2);
  width: 100%;
  font-size: var(--text-sm);
}

.join-req .join-message { display: block; color: var(--color-text-muted); font-style: italic; }
.join-req button { margin: var(--space-2) var(--space-2) 0 0; padding: var(--space-2) var(--space-3); min-height: var(--tap-min); font-size: var(--text-sm); }

/* ── Tasks / issues ────────────────────────────────────────────────────── */
/* The row is the navigation target, so the title link covers it — at every
   width, not only where the split view puts a pointer cursor on the row.
   Buttons and other links inside lift back above it. */
.task-row, .stay-row { position: relative; }
.task-row .task-title::after { content: ""; position: absolute; inset: 0; }
.task-row a:not(.task-title), .task-row button { position: relative; z-index: 1; }

.task-row.is-done { opacity: 0.55; }

.task-title {
  font-weight: 600;
  /* The title is the subject of the row; at --text-sm it sat below the
     buttons acting on it. */
  font-size: var(--text-base);
  line-height: var(--leading-tight);
  color: var(--color-text);
  text-decoration: none;
  min-height: var(--tap-min);
  display: flex;
  align-items: center;
}

.task-title:hover { color: var(--color-accent); text-decoration: none; }

/* Age and who has it are the row's provenance, not part of its title, so each
   takes its own line under it — previously they trailed the title and only
   dropped when the title happened to run long, which made two rows with the
   same information look like two different components. Full-width flex items
   rather than one shared line: at the 340px list width the two of them never
   fit together, so a shared line was a wrap waiting to happen. */
.task-row .task-title { flex: 1 0 100%; }
/* Both halves keep .meta's muted grey. Setting the age to --color-text-faint
   put it at 3.85:1 on a selected row, and made one provenance line two
   different weights of grey. */
.task-row .task-age, .task-row .task-claimed { flex: 1 0 100%; }

/* The detail heading is the subject of its page — or of the split pane — not a
   section within it, so it takes page-title size. `.task-title` cannot: the
   same class sizes a list row, where --text-base is deliberate. Kept an <h2>
   rather than promoted: /tasks would otherwise carry a second <h1> in the
   split pane beside its own. The 2.25rem step lives beside `h1`'s in the
   900px block — two places, one size: change them together. */
.detail-title { font-size: var(--text-2xl); margin: 0 0 var(--space-4); }

.task-claimed { color: var(--color-accent); font-size: var(--text-xs); font-weight: 500; }
.task-filters {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-bottom: var(--space-4);
}
.task-filter {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: 0;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--color-text-muted);
  font-size: var(--text-xs);
  font-weight: 500;
  cursor: pointer;
}
.task-filter:hover, .task-filter:focus-visible { border-color: var(--color-border-strong); }
.task-filter.is-active {
  border-color: var(--color-accent);
  background: var(--color-accent-soft);
  color: var(--color-accent-soft-contrast);
}
.task-filter-count {
  min-width: 1.5em;
  padding: 0 0.35em;
  border-radius: var(--radius-pill);
  background: var(--color-border);
  color: inherit;
  font-variant-numeric: tabular-nums;
  text-align: center;
}
/* The disclosure sits between the page heading and the list it adds to, so it
   needs air on both sides whether it is open or shut — .panel's margin-top
   alone left the collapsed bar flush against the rows below it. */
.form-card { padding: 0; margin: var(--space-4) 0 var(--space-5); }
.form-card-summary {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-3) var(--space-4);
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--color-accent);
  cursor: pointer;
  list-style: none;
}
/* Safari draws its own triangle through ::-webkit-details-marker, which
   `list-style: none` alone does not remove. */
.form-card-summary::-webkit-details-marker { display: none; }
.form-card-summary::after {
  content: "+";
  margin-left: auto;
  font-size: var(--text-base);
  line-height: 1;
}
.form-card[open] .form-card-summary::after { content: "\2212"; }
.form-card[open] .form-card-summary { border-bottom: 1px solid var(--color-border); }
.form-card > form { padding: var(--space-4); }
.form-card > form > *:first-child { margin-top: 0; }
.task-group {
  margin: var(--space-5) 0 var(--space-2);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--color-text-muted);
}
.task-group-more {
  margin: var(--space-2) 0 0;
  font-size: var(--text-xs);
  color: var(--color-text-muted);
}
.task-body { white-space: pre-wrap; margin: var(--space-2) 0; font-size: var(--text-sm); line-height: var(--leading-relaxed); }
.task-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-4); }
.task-actions button { margin-top: 0; }
/* The edit form is a block, not an action: sitting inline between «Erledigt»
   and «Löschen» it read as a stray fragment. Order it after every button and
   give it its own boundary. */
.task-actions > form {
  order: 2;
  flex: 1 0 100%;
  /* Beats the inherited 30rem form cap, which otherwise keeps this item on the
     button line. The measure moves to the fields instead. */
  max-width: none;
  margin-top: var(--space-2);
  padding-top: var(--space-4);
  border-top: 1px solid var(--color-border);
}
.task-actions > form label { max-width: 30rem; }

/* ── Task notes ────────────────────────────────────────────────────────── */
.notes { margin-top: var(--space-5); }

.note {
  padding: var(--space-3);
}

.note-body { white-space: pre-wrap; margin: 0; font-size: var(--text-sm); line-height: var(--leading-relaxed); }
.note .meta { display: flex; align-items: center; gap: var(--space-1); margin: var(--space-2) 0 0; }

/* Inline in a meta line, so it must not inherit the button's own tap height. */
.note-delete {
  min-height: 0;
  margin: 0;
  padding: var(--space-1) var(--space-2);
  font-size: var(--text-xs);
}

/* The reported problem, then what it looked like: one block, so the photos
   read as evidence for the text above rather than a gallery of their own. */
.report-description { margin-bottom: var(--space-5); }
.report-description > .task-body:first-child { margin-top: 0; }

/* Adding a note is a container in its own right — the same hairline frame as
   the note it will become, so the box the user types into matches the boxes
   above it. */
.notes-form { margin-top: var(--space-3); padding: var(--space-3); }
.notes-form label:last-of-type { margin-bottom: var(--space-2); }
.notes-form button { margin-top: 0; }

/* ── Summary (the detail pane before anything is selected) ───────────────
   Not gated on width: the fragment is one component wherever it renders, and
   a stack with a gap needs no breakpoint to be right. */
.summary { display: flex; flex-direction: column; gap: var(--space-4); }

/* ── Photos ────────────────────────────────────────────────────────────── */
.photo-grid { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-3); }
.photo { margin: 0; display: flex; flex-direction: column; gap: var(--space-1); }

.photo img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: var(--radius-sm);
  border: 1px solid var(--color-border);
  background: var(--color-surface-sunken);
}

.photo-sm { width: 72px; }
.photo-sm img, .photo-sm .photo-empty { height: 72px; }
.photo-md { width: 120px; }
.photo-md img, .photo-md .photo-empty { height: 120px; }
.photo-wide { width: 100%; }
.photo-wide img, .photo-wide .photo-empty { aspect-ratio: 3 / 2; height: auto; }

/* Empty slot: hairline dashed frame, icon, and what belongs there. */
.photo-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  padding: var(--space-2);
  text-align: center;
  border: 1px dashed var(--color-border-strong);
  border-radius: var(--radius-sm);
  background: var(--color-surface-sunken);
  color: var(--color-text-faint);
}

.photo-empty-label { font-size: var(--text-xs); line-height: var(--leading-tight); }
.photo-sm .photo-empty-label { display: none; }
.photo-caption { font-size: var(--text-xs); color: var(--color-text-muted); line-height: var(--leading-tight); }
.photo-delete-form { margin: 0; }

.photo .photo-delete {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  width: 100%;
  margin: 0;
  padding: var(--space-1);
  font-size: var(--text-xs);
  min-height: var(--tap-min);
}

/* The release form's carry-over picks: a native checkbox beside each
   thumbnail, so ticking one needs no JS — just :checked + the adjacent img. */
.photo-picks { display: flex; flex-wrap: wrap; gap: var(--space-2); margin: var(--space-2) 0; padding: 0; border: none; }
.photo-picks legend { font-size: var(--text-xs); color: var(--color-text-muted); padding: 0; margin-bottom: var(--space-1); }
.photo-picks label { display: flex; flex-direction: column; align-items: center; gap: var(--space-1); width: 72px; }
.photo-picks img {
  width: 72px;
  height: 72px;
  object-fit: cover;
  border-radius: var(--radius-sm);
  border: 1px solid var(--color-border);
  background: var(--color-surface-sunken);
}
.photo-picks input:checked + img { outline: 2px solid var(--color-accent); outline-offset: 1px; }

/* ── Manual ────────────────────────────────────────────────────────────── */
.manual-toc {
  padding: var(--space-2) var(--space-3);
  margin-bottom: var(--space-5);
}

.manual-toc ul { list-style: none; padding: 0; margin: 0; }
.manual-toc li + li { border-top: 1px solid var(--color-border); }

.manual-toc a {
  display: flex;
  align-items: center;
  min-height: var(--tap-min);
  text-decoration: none;
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--color-text);
}

.manual-toc a:hover { color: var(--color-accent); text-decoration: none; }

.manual-section { margin-bottom: var(--space-6); }

/* Not a button so much as the next empty slot in the manual: full width,
   dashed, and sitting where the section it creates will appear. A solid
   button here would read as an action on the section above it. */
.manual-add {
  display: block;
  width: 100%;
  margin-top: var(--space-4);
  padding: var(--space-4);
  border: 1px dashed var(--color-border-strong);
  border-radius: var(--radius-md);
  background: none;
  color: var(--color-text-muted);
  font-weight: 500;
}

.manual-add:hover {
  border-style: solid;
  border-color: var(--color-accent);
  background: var(--color-accent-soft);
  color: var(--color-accent-soft-contrast);
}
.manual-body { line-height: var(--leading-relaxed); font-size: var(--text-base); }
.manual-body h2, .manual-body h3, .manual-body h4 { margin: var(--space-5) 0 var(--space-2); }
.manual-body ul, .manual-body ol { padding-left: 1.25rem; margin-bottom: var(--space-3); }
.manual-body li { margin-bottom: var(--space-1); }

.manual-body pre {
  background: var(--color-surface-sunken);
  border: 1px solid var(--color-border);
  padding: var(--space-3);
  border-radius: var(--radius-sm);
  overflow-x: auto;
  font-size: var(--text-sm);
}

.manual-body code { font-size: 0.9em; }

.task-title .icon, .manual-toc a .icon, .task-claimed .icon, .badge .icon { color: inherit; }
.task-title, .manual-toc a { gap: var(--space-2); }
.badge { gap: 0.2rem; }
.stay-cancel, .stay-join, .task-delete, .section-delete {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
}

/* ── Markdown editor ─────────────────────────────────────────────────────
   Toolbar and textarea are one control, so they share one frame rather than
   stacking a row of bordered buttons above a bordered field inside the
   already-bordered form. */
.md-editor {
  margin-bottom: var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  background: var(--color-surface);
  overflow: hidden;
}
.md-editor:focus-within { border-color: var(--color-accent); }
.md-toolbar {
  display: flex;
  gap: var(--space-1);
  padding: var(--space-1);
  background: var(--color-surface-sunken);
  border-bottom: 1px solid var(--color-border);
}

.md-toolbar button {
  width: var(--tap-min);
  min-height: var(--tap-min);
  margin: 0;
  padding: 0;
  background: none;
  color: var(--color-text-muted);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  font-weight: 600;
}

.md-toolbar button:hover, .md-toolbar button:active { background: var(--color-accent-soft); color: var(--color-accent-soft-contrast); border-color: transparent; }
.md-editor textarea {
  width: 100%;
  font-family: inherit;
  margin-top: 0;
  border: 0;
  border-radius: 0;
  background: none;
  display: block;
}
.md-editor textarea:focus, .md-editor textarea:focus-visible { outline: none; }

.manual-form {
  margin-bottom: var(--space-4);
}

.manual-actions { display: flex; gap: var(--space-2); flex-wrap: wrap; margin-top: var(--space-3); }
.manual-actions button { min-height: var(--tap-min); padding: var(--space-2) var(--space-3); margin: 0; }

/* ═══ Desktop ═══════════════════════════════════════════════════════════
   The mobile layout above is the base and stays untouched. Everything from
   here is additive and gated on width. 900px is where a 340px list plus a
   readable detail column stops being cramped.

   Markup this expects (all additive, no existing element changes meaning):
     nav.bottom-nav   > div.rail-brand as its first child
     main             > div.split > (div.split-list + div.split-detail)
     manual page      > div.manual-layout wrapping the TOC and the sections
   Each degrades to normal block flow below 900px, so a template carrying
   them still renders the current mobile design exactly as it does now. */

@media (min-width: 900px) {
  body {
    max-width: none;
    padding: 0;
    padding-bottom: 0;
    min-height: 100vh;
    display: grid;
    grid-template-columns: 236px minmax(0, 1fr);
    grid-template-rows: auto 1fr;
    column-gap: var(--space-6);
  }

  /* ── Nav becomes a left rail ─────────────────────────────────────────
     Same element, same six links, same aria-current — only the axis and
     the anchoring change, so no template edit is needed for the rail
     itself. */
  .bottom-nav {
    position: sticky;
    top: 0;
    bottom: auto;
    left: auto;
    right: auto;
    grid-column: 1;
    grid-row: 1 / -1;
    align-self: start;
    height: 100vh;
    flex-direction: column;
    justify-content: flex-start;
    gap: 2px;
    padding: var(--space-5) var(--space-3);
    border-top: 0;
    border-right: 1px solid var(--color-border);
  }

  .rail-brand {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    font-family: var(--font-display);
    font-size: var(--text-lg);
    font-weight: 600;
    letter-spacing: -0.01em;
    color: var(--color-text);
    padding: 0 var(--space-3) var(--space-5);
  }
  /* The mark draws in currentColor, so it takes the rail's text colour in
     both themes with no per-theme asset. */
  .rail-brand .brand-mark { width: 26px; height: 26px; flex: none; color: var(--color-accent); }

  .nav-item {
    flex-direction: row;
    justify-content: flex-start;
    width: 100%;
    min-height: 40px;
    gap: var(--space-3);
    padding: 0 var(--space-3);
    font-size: var(--text-sm);
    text-align: left;
  }

  /* In a row the dot belongs at the far end, not floating on the icon. */
  .nav-item .nav-dot { position: static; margin: 0 0 0 auto; }

  .app-chrome {
    grid-column: 2;
    grid-row: 1;
    max-width: 1100px;
    padding: var(--space-4) var(--space-5) 0;
  }

  /* main is nested inside .swipe-viewport, not a direct child of the body
     grid — .swipe-viewport is the actual grid item that needs placing.
     Without this, a page rendered with no nav (e.g. the logged-out /auth/login)
     has nothing claiming column 1, so .swipe-viewport auto-placed there instead
     of column 2, and the whole page rendered in the 236px rail slot. */
  .swipe-viewport {
    grid-column: 2;
    grid-row: 2;
  }

  main {
    max-width: 1100px;
    min-height: 0;
    padding: 0 var(--space-5) var(--space-6);
  }

  /* The login page is short and has no list beside it to fill the wide
     content column, so it reads as a card centred in the screen rather than
     text docked to the left edge. */
  main:has(> .auth-page) {
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  h1 { font-size: 2.25rem; }
  .detail-title { font-size: 2.25rem; }
  h2 { font-size: var(--text-xl); }

  /* ── Form measure ────────────────────────────────────────────────────
     A single-column form does not get more usable as it gets wider — past
     roughly 30rem the label sits a long way from the value. Cap the form,
     not each control, so fields keep filling their container. */
  form { max-width: 30rem; }

  /* ── Master–detail ───────────────────────────────────────────────────
     The grid itself is gated higher, below — at this width a 340px list
     would leave the detail column too narrow to read. */
  /* `.stay-row.is-selected` is not here: the calendar's column is on screen at
     every width, so it lives in the base block. */
  .task-row.is-selected {
    border-color: var(--color-accent);
    background: var(--color-accent-soft);
  }

  /* Rows are the navigation here, so the whole row takes the click. */
  .split-list .task-row, .split-list .stay-row { cursor: pointer; }
  .split-list .task-row:hover, .split-list .stay-row:hover { border-color: var(--color-border-strong); }

  /* The detail fragments are the roots of tasks/_detail_block.html.jinja2 and
     issues/_detail_block.html.jinja2, so styling them directly means neither
     fragment needs a wrapper or an edit. */
  .detail-card,
  .split-detail > #task-detail,
  .split-detail > #issue-detail {
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    background: var(--color-surface);
    padding: var(--space-5);
  }

  .detail-card > h2:first-child,
  .split-detail > #task-detail > h2:first-child,
  .split-detail > #issue-detail > h2:first-child { margin-top: 0; }

  /* The report disclosure sits at the top of the list column, above the rows
     it adds to. In one column it had the page to itself; in two it shares a
     narrow column, so it loses the outer margin the full-width version needed. */
  .split-list > .form-card:first-child { margin-top: 0; }

  /* ── Calendar ────────────────────────────────────────────────────────
     Cells get tall enough to give the occupancy bar real weight, and the
     panel and card list move alongside the grid instead of under it. */
  .split-cal .cal-side { position: sticky; top: var(--space-5); }
  .split-cal .cal-side .panel { margin-top: 0; }
  .split-cal .cal-grid { margin-bottom: 0; }

  .cal-grid { gap: var(--space-1); }

  .cal-cell {
    min-height: 104px;
    padding: var(--space-2);
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
  }

  .cal-num { font-size: var(--text-sm); }

  /* A taller bar and a legible label, in step with .cal-bar-checkout — that
     marker is the same bar's last segment and has to match its height. The
     label itself does not grow a longer form here: an arrival day's bar covers
     half a cell at any width, so initials are what fit everywhere. */
  .cal-bar, .cal-bar-checkout { height: 20px; }
  .cal-bar { bottom: var(--space-1); padding: 0 var(--space-1); font-size: var(--text-xs); }

  /* ── Manual ──────────────────────────────────────────────────────────
     TOC parks on the left and tracks position; the body keeps a measure
     rather than running the full 1100px. */
  .manual-layout {
    display: grid;
    grid-template-columns: 260px minmax(0, 1fr);
    gap: var(--space-6);
    align-items: start;
  }

  .manual-toc {
    position: sticky;
    top: var(--space-5);
    margin-bottom: 0;
    max-height: calc(100vh - var(--space-6));
    overflow-y: auto;
  }

  .manual-toc a { min-height: 36px; color: var(--color-text-muted); }
  .manual-toc a[aria-current="true"] { color: var(--color-accent); font-weight: 600; }
  .manual-body, .manual-section > h2 { max-width: 68ch; }
  .manual-section { scroll-margin-top: var(--space-5); }

  /* .task-actions is a mixed bag — two buttons, a release form, an edit form
     and a delete link — held in one flex row. On a 600px column nothing ever
     fit beside anything, so it read as a stack; given room, the row filled up
     and the buttons stretched to the height of the tallest form beside them.
     Buttons share a row, each form takes its own, nothing stretches. */
  .task-actions { align-items: flex-start; }
  .task-actions > form { flex-basis: 100%; }

  /* Forms stop spanning the full column once there is a column to span.
     #task-detail rather than .split-detail: the same fragment is the whole
     page at /tasks/{id}, where the measure needs capping just as much. */
  #task-detail form label, #issue-detail form label, .manual-form label { max-width: 34rem; }

  /* Prose in the detail keeps the same measure as the fields under it —
     a note set across the full 1100px is a line nobody wants to read, and
     the ragged edge against the capped forms is what makes it look wrong. */
  #task-detail .task-body, #task-detail .notes,
  #issue-detail .task-body, #issue-detail .notes { max-width: 34rem; }
  /* Not .form-card: the "Add a task"/"Report a problem" disclosure lives in
     the list column and should match the row width below it. Its own <form>
     still caps at 30rem via the plain `form` rule above — the card widens,
     the fields don't. */
  .panel:not(.form-card) { max-width: 34rem; }
}

/* The list holds its place while the detail scrolls, which is the whole point
   of the split — the eye never loses the row it came from. Gated above the
   rail's own breakpoint: two columns need more room than a rail does. */
@media (min-width: 1080px) {
  .split {
    display: grid;
    grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
    gap: var(--space-5);
    align-items: start;
  }

  .split-list {
    position: sticky;
    top: var(--space-5);
    max-height: calc(100vh - var(--space-6));
    overflow-y: auto;
    /* Room for the focus ring, which would otherwise clip against overflow. */
    padding: 2px;
    margin: -2px;
  }

  .split-detail { min-width: 0; }
  /* The calendar's second column is .cal-side, not .split-detail — the panel
     and the stay list inside it must stay visible on mobile, where
     .split-detail is hidden. Plain flow below the breakpoint. */
  .split-cal { grid-template-columns: minmax(0, 1fr) 340px; }
  .split-cal .split-list { position: static; max-height: none; overflow: visible; padding: 0; margin: 0; }
  .cal-side { min-width: 0; }
}

/* Narrower than that, the split is plain flow and the detail pane is not
   rendered at all — the window navigates to the item's own page instead. */
@media (max-width: 1079px) {
  .split-detail { display: none; }
}
