/**
 * Modul: Layout
 * Zweck: App-Shell-Layout, Navigation (Bottom Mobile / Sidebar Desktop), Responsive Grid
 * Abhängigkeiten: tokens.css, reset.css
 *
 * Navigation:
 *   Mobile (<1024px):  Bottom-Nav-Bar, 5 Haupt-Module
 *   Desktop (≥1024px): Expanded Sidebar mit Labels (220px)
 *
 * Referenz: Apple HIG Sidebar-Pattern
 */

/* --------------------------------------------------------
 * App-Shell
 * -------------------------------------------------------- */
.app-shell {
  display: flex;
  flex-direction: column;
  /* Drei Zeilen, und jede gilt fuer andere Browser (#1276). Dieselbe Kette
   * steht an body in reset.css - an beiden Stellen, weil fill-available sich
   * am Containing Block misst: haette body 100vh, maesse die Shell auf iOS
   * wieder die grosse Hoehe.
   *   - 100dvh: wer dvh kennt (Chrome ab 108, Safari ab 15.4), nimmt die
   *     letzte Zeile, die beiden davor sind dort ueberstimmt.
   *   - -webkit-fill-available: wer dvh nicht kennt, aber das hier (Safari
   *     13.4 bis 15.3, Chromium), fuellt den Containing Block - auf iOS die
   *     SICHTBARE Hoehe. 100vh waere dort die grosse, bei eingefahrener
   *     Adressleiste, und die untere Navigation laege unter der Leiste.
   *   - 100vh: wer beides nicht kennt. Ohne jede Hoehe waechst #main-content
   *     auf Inhaltshoehe, und der Hauptbereich scrollt nicht mehr - die
   *     Seitenleiste daneben schon (Chrome 91 auf einem Chromebook, #1276).
   * Die Reihenfolge ist die Regel: umgestellt gewaenne auf alten Browsern
   * die falsche Zeile. */
  height: 100vh;
  height: -webkit-fill-available;
  height: 100dvh;
  /* Containing-Block für den lebenden Backdrop (.lg-backdrop, position: absolute;
   * inset: 0). Bewusst KEIN isolation/z-index → app-shell bleibt kein eigener
   * Stacking-Context, damit die globale z-Reihenfolge (Toast 300 > Modal 200 etc.)
   * unverändert bleibt. Der Backdrop malt mit z-index: 0 über die opaken
   * html/body-Hintergründe; .app-content liegt via position: relative darüber. */
  position: relative;
}

/* --------------------------------------------------------
 * Loading-Screen
 * -------------------------------------------------------- */
.app-loading {
  display: flex;
  align-items: center;
  justify-content: center;
  /* vh-Zwilling fuer Browser ohne dvh (#1276), siehe .app-shell. */
  min-height: 100vh;
  min-height: 100dvh;
  width: 100%;
  background-color: var(--color-bg);
}

.app-loading__logo {
  font-size: var(--text-2xl);
  font-weight: var(--font-weight-bold);
  color: var(--color-accent);
  animation: pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

/* --------------------------------------------------------
 * Seiten-Übergang (Rückfall-Blende und View Transition)
 * -------------------------------------------------------- */
/* Basis: unsichtbar vor Animation */
.page-transition {
  opacity: 0;
  /* Höhe durchreichen: dieser Wrapper steht zwischen .app-content und jedem
   * Modul-Root. Ohne eigene Höhe ist er `auto`, und ein `height: 100%` am
   * Modul-Root darunter hat keinen definiten Bezug - die Prozentangabe fällt
   * dann still auf `auto` zurück und der Root wächst mit seinem Inhalt statt
   * sich in den verfügbaren Platz zu fügen.
   *
   * Genau daran hingen die acht `height: calc(100dvh - ...)`-Formeln in den
   * Modul-CSS: sie rechneten die Viewport-Höhe nach, weil die Kette sie nicht
   * lieferte. Nachgerechnete Höhen kennen aber nur, was zum Zeitpunkt des
   * Schreibens unter der Seite lag - die FAB-Zone (--fab-safe-zone) kam später
   * dazu und fehlte in allen acht. Mit durchgereichter Höhe stimmt jede
   * Änderung an .app-content automatisch überall.
   *
   * Seiten, die länger sind als der Platz (kein overflow am Root), laufen wie
   * bisher heraus und werden von .app-content gescrollt. */
  height: 100%;
}

/* RUECKFALL ohne View Transitions: eine reine Blende, kein Versatz, keine
 * Feder (Critique 2026-09-26, P2-1). Hier stand ein 20px-Slide nach
 * Nav-Reihenfolge, den glass.css zusaetzlich mit der Glas-Feder ueberschwingen
 * liess - Feder gehoert dem Glas, nicht dem Inhalt. Wo der Browser View
 * Transitions kann, sieht man diese Klasse nie; der Wechsel steht unten. */
.page-transition--in {
  opacity: 1;
  animation: page-fade-in var(--duration-md) var(--ease-out);
}

@keyframes page-fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* --------------------------------------------------------
 * Seitenwechsel als View Transition (utils/view-transition.js)
 *
 * Grundsatz: Kontinuitaet vor Effekt. Navigation, Kopf und Tab-Leisten
 * STEHEN, nur der Inhalt wechselt - wie Apples Tab-Wechsel, der gar nicht
 * gleitet. Das Wurzelbild blendet 200ms mit `--ease-out` ueber; der Browser
 * haelt dabei das alte Bild, bis das neue steht - der leere Frame zwischen
 * altem und neuem Inhalt ist damit weg.
 *
 * Was steht, traegt einen Namen und faellt aus dem Wurzelbild heraus:
 *   - `nav-sidebar`, `nav-bottom`: nur das LEBENDE neue Bild, ohne Blende.
 *     Die Pille der Kapsel gleitet seit dem Tipp (updateNav) - ein
 *     eingefrorenes altes Bild darueber waere ihr Geist.
 *   - `kitchen-tabs` (kitchen-tabs.css): derselbe Knoten vorher und nachher,
 *     ebenso nur lebend - seine Kapsel gleitet dort zum Ziel.
 *   - `page-toolbar` (per Inline-Stil am ersten Kopf): Kopf steht, sein
 *     Inhalt blendet mit der Wurzel ueber. Die Gruppe selbst bewegt sich
 *     nicht (`animation: none`) - kein Kopf gleitet.
 * Kommt ein Name nur auf EINER Seite vor (Kueche betreten/verlassen), blendet
 * er wie die Wurzel (`:only-child` laesst die Standard-Blende stehen).
 * -------------------------------------------------------- */
/* Die Namen `nav-sidebar` und `nav-bottom` stehen an den Basisregeln der
 * beiden Leisten (weiter unten) - dort, wo man sie sucht. */

/* Gedacht war: Tipps gehen waehrend der Blende an das echte Dokument darunter.
 * Chromium trifft waehrend einer Wurzel-Transition aber nur <html>, trotz
 * dieser Regel (gemessen 2026-09-27). Deshalb bricht swapPage
 * (utils/view-transition.js) die Blende beim ersten Tipp ab - der naechste
 * Tipp erreicht die Seite. Die Regel bleibt fuer Engines, die sie ehren. */
::view-transition {
  pointer-events: none;
}

::view-transition-old(root),
::view-transition-new(root),
::view-transition-old(page-toolbar),
::view-transition-new(page-toolbar) {
  animation-duration: var(--duration-md);
  animation-timing-function: var(--ease-out);
}

::view-transition-group(page-toolbar),
::view-transition-group(nav-sidebar),
::view-transition-group(nav-bottom),
::view-transition-group(kitchen-tabs) {
  animation: none;
}

::view-transition-old(nav-sidebar):not(:only-child),
::view-transition-old(nav-bottom):not(:only-child),
::view-transition-old(kitchen-tabs):not(:only-child) {
  display: none;
}

::view-transition-new(nav-sidebar):not(:only-child),
::view-transition-new(nav-bottom):not(:only-child),
::view-transition-new(kitchen-tabs):not(:only-child) {
  animation: none;
}

::view-transition-old(kitchen-tabs):only-child,
::view-transition-new(kitchen-tabs):only-child {
  animation-duration: var(--duration-md);
  animation-timing-function: var(--ease-out);
}

@media (prefers-reduced-motion: reduce) {
  .page-transition--in {
    animation: none;
    opacity: 1;
  }
  #main-content {
    transition: none;
  }
  /* utils/view-transition.js startet unter reduzierter Bewegung gar keine
   * Transition; das hier haelt auch den Fall still, in dem doch eine laeuft. */
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

/* --------------------------------------------------------
 * Layout: Mobile (Standard, < 1024px)
 * -------------------------------------------------------- */
.app-content {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-gutter: stable;
  overscroll-behavior-y: contain;
  /* `pinch-zoom` gehoert dazu (#1276): ein blosses `pan-y` verbietet ihn, und
   * Chromium verwirft dann jeden Bildlauf, der mit zwei Fingern beginnt. Das
   * passt auf die Meldung vom Chromebook-Touchscreen (Hauptinhalt starr,
   * Seitenleiste ohne touch-action daneben nicht). Aufziehen zum Zoomen ging
   * hier ebenfalls nicht, obwohl der Viewport es zusagt.
   * Guard: test:mobile-scroll-layout. */
  touch-action: pan-y pinch-zoom;
  -webkit-overflow-scrolling: touch;
  padding-top: var(--offline-banner-height, 0px);
  /* Bewusste Layout-Transition (Detector layout-transition, geprüft): das Offline-
   * Banner schiebt den Inhalt hinunter - echte Layout-Höhe, per transform nicht
   * animierbar. Selten (nur bei Offline-Wechsel); reduced-motion schaltet sie ab. */
  transition: padding-top var(--duration-md) ease;
  background-color: var(--color-bg);
}

/* Der Scrollport endet über dem FAB.
 *
 * Der FAB ist `position: fixed` über einem Scrollport und überdeckt damit
 * zwangsläufig, was gerade unter ihm steht - bei `scrollTop = 0` gemessen bis
 * 80,6% einer Zeilenaktion (phoneSm /pantry, Critique P1 2026-07-30). Die
 * beiden bisherigen Antworten griffen beide zu kurz: eine reservierte Spur pro
 * Zeile kostete bei 320px 24% der Viewportbreite (zurückgebaut), und
 * `padding-bottom` am Listenende scrollt mit, wirkt also nur, wenn der Nutzer
 * schon unten ist.
 *
 * PADDING, NICHT MARGE - und hier stand bis 2026-08-12 das Gegenteil, mit
 * derselben Emphase. Die Marge verkürzte den Scrollport um 96px über die volle
 * Breite. Der Preis dafür war nicht abstrakt: das Dashboard-Raster brach 96px
 * über der Fensterkante mitten in einer Widget-Reihe ab, mit totem Band
 * darunter - 12 % eines 900er-Fensters und 25 % zusätzlicher Scrollweg auf dem
 * Standard-Board, gemessen. Ein Raster, dessen Zusage bündige, gleich hohe
 * Karten sind, kann seine Unterkante nicht an eine unsichtbare Reserve
 * abgeben.
 *
 * WAS DER WECHSEL WIRKLICH ÄNDERT, IST DIE INVARIANTE, und das ist die
 * eigentliche Korrektur. Die Marge sicherte zu: „bei KEINEM Scrollstand liegt
 * etwas Bedienbares unter dem Knopf". Nötig ist: „nichts ist UNERREICHBAR".
 * Der gemessene Schaden lag beide Male am SCROLL-ENDE (`.pantry-stepper__btn`
 * und `.contact-more-menu`, 2026-08-10 mobil; 8 Ziele mit verdecktem Zentrum
 * am Desktop, 2026-08-12) - dort, wo sich nichts mehr wegscrollen lässt. Genau
 * dort legt ein Padding 96px leeren Nachlauf unter die letzte Zeile. Mitten im
 * Scrollen läuft Inhalt unter dem Knopf durch, und das ist kein Mangel, sondern
 * was ein schwebender Knopf ist: er lässt sich in beide Richtungen
 * freischieben.
 *
 * UND DIE GEFAHRENRICHTUNG STIMMT. Der FAB liegt oben, ein Fehlgriff landet
 * also auf ihm und nicht auf der Zeilenaktion darunter - auf „Neu" statt auf
 * „Löschen". Läge es umgekehrt, wäre die strenge Invariante ihren Preis wert.
 *
 * ALS SUMMAND, NICHT ALS FERTIGES PADDING (2026-08-20). Bis dahin schrieb
 * diese Regel den Nachlauf direkt an `.app-content` - mit der Begründung, die
 * App habe zwei Scrollport-Architekturen und das Padding treffe beide richtig,
 * nur auf zwei Wegen. Der zweite Weg war der falsche: wo der Modul-Root
 * `height: 100%; overflow: hidden` trägt, verkürzt ein Padding an
 * `.app-content` dessen Bezugshöhe, statt am Inhaltsende zu reiten. Die
 * Sichtfläche geht verloren UND der Streifen ist nicht scrollbar. Was dort
 * gebraucht wird, ist ein Nachlauf im echten Scrollport, und den kennt nur die
 * Seite - deshalb `.page-scrollport` (Regel weiter unten).
 *
 * Sieben `padding-bottom: *-fab-clearance` in vier Dateien bleiben abgeschafft,
 * samt der drei auseinandergedrifteten Kopien des Tokens.
 *
 * NUR MIT FAB: `:has()` fragt die Seite, statt eine Klasse zu verlangen, die
 * beim nächsten neuen Modul vergessen wird. `:not([hidden])`, weil eine Seite
 * ihren FAB zeitweise verbergen kann - dann gehört ihr der Platz.
 *
 * Gefragt wird an der SHELL, nicht am Scrollport: der FAB hängt seit #634 in
 * `.fab-layer` neben `.app-content`, nicht mehr darin. */
.app-shell:has(.fab-layer .page-fab:not([hidden])) {
  --fab-tail: var(--fab-safe-zone);
}

/* ── UND DER DRITTE FIXIERTE KÖRPER: DER INSTALL-BANNER ──
 *
 * FAB und Sammelaktions-Pille bekamen ihren Nachlauf, der Banner nie - obwohl
 * er dieselbe Bauart hat (fixiert, über dem Scrollport, verdeckt das Ende der
 * Seite). Gemessen auf /rewards bei 375px: 89px der letzten Punktestandszeile
 * lagen bei maximalem Scroll unter ihm, mit Balken und „Einlösen" darin. Kein
 * Scrollweg führte dorthin.
 *
 * ALS SUMMAND, NICHT ALS VIERTE `:has()`-REGEL. Der Nachlauf hat schon drei
 * Fassungen (FAB, Pille, beides), und jede weitere fixierte Fläche verdoppelt
 * sie. Der Banner steht deshalb als Variable in allen Fassungen - ohne ihn ist
 * sie 0 und die Regeln rechnen wie bisher.
 *
 * DIE HÖHE KOMMT AUS DEM BAUTEIL (`--install-prompt-height`, gesetzt per
 * ResizeObserver in fam-install-prompt.js): sie hängt am Text, und der
 * bricht in 24 Sprachen unterschiedlich um.
 *
 * DER FALLBACK IST GEMESSEN, keine Schätzung: 89px ist die deutsche
 * iOS-Fassung mit zweizeiligem Untertitel, also der höhere der beiden
 * Bannertexte. Er trägt allein - der Nachlauf stimmt auch dann, wenn die
 * Meldung ausbleibt, weil das Banner nach einem Re-Render neu aufgebaut wurde.
 * Ein Fallback, der nur den kurzen Fall kennt, wäre genau in den Sprachen
 * falsch, in denen der Text umbricht. */
/* BEIDE ALS `:root`, SONST GEWINNT DIE BASIS. Die erste Fassung schrieb den
 * Zustand als `html:has(fam-install-prompt)` - Typ plus :has() mit einem
 * Typ-Argument ist Spezifitaet (0,0,2) und liegt damit UNTER dem `:root` der
 * Basis (0,1,0). Gemessen: `--install-prompt-height` stand korrekt auf 89px,
 * der Nachlauf blieb 0, und die letzte Zeile lag weiter 97px unter dem Banner.
 * Dieselbe Falle, vor der die Pillen-Regel weiter unten im Kommentar warnt. */
/* DIE DREI SUMMANDEN DES NACHLAUFS, jeder 0, solange seine Fläche fehlt.
 *
 * Sie stehen als Variablen und nicht als vier `:has()`-Kombinationen an der
 * Nachlauf-Regel: mit drei fixierten Flächen wären das acht Regeln, von denen
 * jede die Spezifitätsfalle von `:has()` mitzurechnen hätte. Als Summanden
 * bleibt EINE Regel übrig, und die vierte Fläche kostet eine Zeile statt einer
 * Verdopplung.
 *
 * `--fab-tail` und `--bulk-pill-tail` werden an `.app-shell` gesetzt (beide
 * Flächen hängen dort), `--install-prompt-tail` an `:root` (das Banner ist ein
 * Kind von `body`). Die Summe zieht `.app-content`, siehe dort - sie MUSS
 * unterhalb aller drei Setzer stehen, weil eine Custom Property ihre `var()`
 * an der Stelle auflöst, an der sie DEKLARIERT wird, nicht dort, wo sie
 * benutzt wird. An `:root` deklariert hätte sie alle drei als 0 gelesen. */
:root {
  --install-prompt-tail: 0px;
  --fab-tail: 0px;
  --bulk-pill-tail: 0px;
  --nav-tail: 0px;
}

/* AM ZUSTAND, NICHT AN DER ANWESENHEIT - und das ist gemessen, nicht
 * vorsorglich. Hier stand `:root:has(fam-install-prompt)`, also die blosse
 * Existenz des Elements. Das Element steht statisch in index.html und ist
 * damit NIE abwesend: es rendert 0x0 mit leerem Shadow-Root, solange keine
 * seiner Anzeigebedingungen erfuellt ist, und auf iOS feuert
 * `beforeinstallprompt` ueberhaupt nie. Der Fallback von 89px sprang damit
 * dauerhaft an, und jeder Scrollport der App reservierte 105px fuer ein
 * Banner, das keiner je gesehen hat - gemessen im Kalender (21,2% der
 * Rasterhoehe bei 390px, 23,9% bei 320px) und in den Notizen.
 *
 * Der Kommentar drei Absaetze weiter oben versprach das Gegenteil: „jeder 0,
 * solange seine Flaeche fehlt". Genau das tat der Selektor nicht.
 *
 * `[data-shown]` setzt das Bauteil selbst, wenn das Banner im Shadow-Root
 * steht (fam-install-prompt.js). Spezifitaet bleibt ueber der `:root`-Basis:
 * `:has()` zaehlt sein staerkstes Argument, hier Typ+Attribut. */
:root:has(fam-install-prompt[data-shown]) {
  --install-prompt-tail: calc(var(--install-prompt-height, 89px) + var(--space-4));
}

/* Die Pille ist der zweite Summand - dieselbe Bauart wie der FAB: fixierte
 * Shell-Fläche über dem Scrollport, die am Scroll-Ende sonst genau die Zeilen
 * verdeckt, auf die sie sich bezieht. Am Finger fällt --fab-safe-zone auf 0
 * (der FAB sitzt dort in der Nav-Kapsel), dort ist sie der ganze Nachlauf. */
.app-shell:has(.list-bulkbar) {
  --bulk-pill-tail: var(--bulk-pill-safe-zone);
}

/* DER VIERTE SUMMAND IST DIE TAB-LEISTE SELBST (Kopfregel mobil, 2026-09-26).
 *
 * Bis hierher war die Leiste ein Flex-Kind der Shell und nahm dem Scrollport
 * ihre Zone ab: unter dem Glas lief nie Inhalt, 76px (plus Safe-Area) waren
 * tote Buehne unter einer Kapsel, deren Material genau das zeigen soll, was
 * darunter liegt (Critique 2026-09-26, A1 P1-3 / A8). Jetzt liegt sie ueber
 * dem Inhalt (siehe `.nav-bottom`), der Scrollport reicht bis zur Unterkante,
 * und damit die letzte Zeile trotzdem UEBER der Kapsel endet, ist ihre Zone
 * ein Nachlauf wie FAB, Pille und Banner - dieselbe Invariante: nichts ist
 * unerreichbar, und mitten im Scrollen darf Inhalt unter dem Glas laufen.
 *
 * NUR, WO DIE LEISTE STEHT: unter 1024px und nicht im Wand-Modus, der sie
 * ausblendet (dashboard.css). Die Summanden von Pille und Banner rechnen
 * weiter ab der Oberkante der Leiste - sie haengen ueber ihr, und der
 * Nachlauf addiert jetzt, was darunter liegt, statt es wegzuschneiden.
 *
 * `--nav-bottom-height` ist die GEMESSENE Zone, nicht die Token-Hoehe (Review
 * zu #1475): die Kapsel waechst mit umbrechenden Labels ueber 60px (320px,
 * lange Sprachen), und eine feste Reserve liess die letzte Zeile 4px unter dem
 * Glas enden. Die Messung steht in tokens.css an der Variable. */
@media (max-width: 1023px) {
  .app-shell {
    --nav-tail: var(--nav-bottom-height);
  }

  [data-wall-mode] .app-shell {
    --nav-tail: 0px;
  }
}

/* Die Summe. AN .app-content UND NICHT AN :root - sie liest die Summanden
 * an der Stelle, an der sie deklariert wird (siehe :root oben), und alle
 * sind erst hier fertig. */
.app-content {
  --shell-tail: calc(var(--nav-tail) + var(--fab-tail) + var(--bulk-pill-tail) + var(--install-prompt-tail));
}

/* --------------------------------------------------------
 * DER NACHLAUF GEHÖRT AN DAS, WAS WIRKLICH SCROLLT
 *
 * Die App hat zwei Scrollport-Architekturen. In Belohnungen, Aufgaben,
 * Dokumenten, Gesundheit und auf der Übersicht scrollt `.app-content` die ganze
 * Seite. In Küche, Budget, Kontakten, Notizen und Kalender trägt der Modul-Root
 * `height: 100%; overflow: hidden` und genau ein Kind darin scrollt - acht
 * Seiten, gemessen an ihren `*-page`-Regeln.
 *
 * BIS 2026-08-20 BEKAMEN BEIDE DENSELBEN NACHLAUF AN `.app-content`, und in der
 * zweiten Architektur war das jedes Mal falsch: das Padding verkürzt dort die
 * Bezugshöhe des Modul-Roots, statt am Inhaltsende zu reiten. Was entsteht, ist
 * ein Streifen UNTERHALB des Scrollports - er scrollt nicht mit, er trägt
 * nichts, und die Fläche, die er kostet, fehlt der Liste bei jedem Scrollstand.
 * Gemessen mit sichtbarer Sammelaktions-Pille, in allen drei Modulen gleich:
 *
 *   1440x900    Scrollport 641 statt 717px, 76px totes Band darunter
 *   1680x1050   Scrollport 791 statt 867px, 76px
 *   390x844     Scrollport 575 statt 655px, 80px
 *
 * Der Defekt war zweimal repariert worden, ohne dass die Ursache fiel: 2026-08-13
 * kam eine zweite Regel für den echten Scrollport dazu (`.has-bulk-safe-zone`),
 * die alte blieb daneben stehen, und seitdem zahlten die drei Module mit Pille
 * sie doppelt. Das Muster ist der eigentliche Befund - der Nachlauf gehört
 * NICHT an die Box, die den Scrollport enthält, sondern an den Scrollport.
 *
 * `.page-scrollport` ist die Rolle „ich bin der Scrollport meiner Seite". Sie
 * steht bewusst nicht als Liste von Modul-Selektoren hier: eine Allowlist über
 * N Dateien deckt keine Regel ab, und dieses Repo hat das schon dreimal
 * bezahlt. Jeder Scrollport trägt sie selbst, und zwei Guards prüfen beide
 * Richtungen (test-frontend-audit.js): jede Seite mit eigenem Scrollport
 * vergibt sie, und keine `.app-content`-Regel setzt den Nachlauf mehr selbst.
 *
 * `:not(:has(.page-scrollport))` an BEIDEN Selektoren, und das ist keine
 * Symmetrie um ihrer selbst willen. Am Default-Port beantwortet es „bringt die
 * Seite ihren eigenen mit?". An der Rolle beantwortet es dieselbe Frage eine
 * Ebene tiefer: im Budget tragen Panel und `.budget-list` BEIDE die Rolle -
 * unter 640px scrollt nur das Panel, darüber die Liste (und seit #904 bei
 * Überlauf zusätzlich das Panel) -, und der Nachlauf gehört in jeder dieser
 * Konstellationen dem inneren Träger. Ohne den Ausschluss
 * polsterte das Panel die Liste, die es enthält, aus dem sichtbaren Bereich -
 * derselbe Fehler wie oben, nur eine Ebene tiefer.
 *
 * `--scrollport-pad` ist das EIGENE Bodenpolster eines Scrollports, das der
 * Nachlauf nicht wegnehmen darf. Wochengitter und Agenda haben eines; ohne
 * diesen Summanden hätte die Rolle es überschrieben.
 * -------------------------------------------------------- */
.app-content:not(:has(.page-scrollport)),
.app-shell .page-scrollport:not(:has(.page-scrollport)) {
  padding-block-end: calc(var(--scrollport-pad, 0px) + var(--shell-tail));
  /* Fokus und scrollIntoView landen nie unter der Kapsel (WCAG 2.4.11): wer
   * per Tastatur ans Seitenende tabbt, bekommt das Ziel ueber dem Glas statt
   * dahinter. Nur die Leiste, nicht der ganze Nachlauf - der Desktop-FAB
   * schwebt seitlich und verdeckt keine volle Zeile. */
  scroll-padding-block-end: var(--nav-tail);
}

/* DER SCROLLPORT IST AUCH CONTAINING BLOCK (Critique 2026-09-26).
 *
 * Ein Scrollport clippt nur die absolut positionierten Nachfahren, deren
 * Containing Block er ist. `.sr-only` steht ohne Versatz an seiner statischen
 * Stelle, gehoert aber dem naechsten POSITIONIERTEN Vorfahren - im Vorrat war
 * das `#main-content`: 21 "Bearbeiten"-Spans unten in der Liste entkamen dem
 * Clipping und machten den Hauptbereich zu einem zweiten Scroller (1766px bei
 * 768px Hoehe); eine Wischgeste neben der Liste schob Kopf und Liste weg. Die
 * Rolle bringt die Positionierung deshalb selbst mit, statt sie jedem Modul
 * einzeln abzuverlangen. Gemessen auf allen Seiten mit eigenem Scrollport
 * (Kalender in allen vier Ansichten, 390 und 1440): kein Element verschiebt
 * sich. Guard: test-frontend-audit.js. */
.page-scrollport {
  position: relative;
}

/* UND AM DEFAULT-PORT REITET ER AUF EINEM PLATZHALTER, NICHT AUF DEM POLSTER
 * (Critique 2026-09-23).
 *
 * Das Polster oben greift an `.app-content` nur zur Haelfte. Ein Scrollport
 * haengt sein Endpolster an das Ende seines FLUSS-Kindes, und das ist
 * `.page-transition` - mit `height: 100%` (siehe dort), also genau so hoch wie
 * die Inhaltsbox. Eine Seite, die laenger ist, laeuft ueber diese Box hinaus,
 * und ihr Ueberlauf bekommt kein Polster mehr. Gemessen bei 1440x900 auf der
 * Uebersicht: 24px statt 96px unter dem letzten Widget, der FAB lag am
 * Seitenende auf der rechten Spalte; dasselbe auf Einstellungen (32px) und
 * Haushaltshilfe (64px), sobald eine fixierte Flaeche einen Nachlauf verlangt.
 * Die Regel galt nur dort, wo eine Seite zufaellig selbst genug Bodenpolster
 * mitbrachte.
 *
 * Der Platzhalter steht IM Fluss der Seite, hinter ihrem Root - und der waechst
 * in dieser Architektur mit dem Inhalt. Er reitet damit am echten Inhaltsende.
 *
 * DAS POLSTER BLEIBT TROTZDEM, und die beiden rechnen sich nicht doppelt. Es
 * haelt `.page-transition` um den Nachlauf kuerzer als das Fenster; eine Seite,
 * die nur ihren Platz fuellt (`min-height: 100%` wie Belohnungen und Entsorgung),
 * endet deshalb mit dem Platzhalter genau an der Fensterkante statt um den
 * Nachlauf darueber hinaus. Die Scrollhoehe ist in jedem Fall
 * max(Fensterhoehe, Inhaltsende + Nachlauf) - das Polster zaehlt nur bis zur
 * Kante der Box, der Platzhalter nur ab dem Inhalt.
 *
 * Die Hoehe des Fluss-Kindes selbst anzufassen (`min-height` statt `height`),
 * hiesse die Bezugshoehe fuer jedes `min-height: 100%` darunter aufzugeben:
 * eine Prozenthoehe loest sich nur gegen eine DEFINITE Hoehe auf. Seiten mit
 * eigenem Scrollport sind ausgenommen wie oben - dort reitet der Nachlauf an
 * ihrem Port. Gemessen: `test:dashboard-surface-browser`. */
.app-content:not(:has(.page-scrollport)) > .page-transition::after {
  content: "";
  display: block;
  block-size: var(--shell-tail);
}

/* #main-content wird nach jeder Navigation programmatisch fokussiert, damit
 * Tastatur-/Screenreader-Fokus in die neue Seite springt (Skip-Link-Muster).
 * Das ist ein Region-Fokus-Ziel, kein interaktives Element — es darf keinen
 * sichtbaren Rahmen zeigen. Beim ersten Laden (ohne vorherige Pointer-
 * Interaktion) würde der Browser den programmatischen Fokus sonst als
 * :focus-visible werten und ein Outline um den ganzen Inhaltsbereich zeichnen. */
.app-content:focus,
.app-content:focus-visible {
  outline: none;
}

/* --------------------------------------------------------
 * DIE SCHALTERZEILE - EINE FORM, APP-WEIT
 *
 * Sie stand bis 2026-08-28 in `settings.css`, weil dort ihr Anlass lag: vier
 * Schalterformen nebeneinander fuer dieselbe Boolean-Entscheidung (Critique
 * 2026-07-27). Der Ort war trotzdem falsch, und das ist gemessen aufgefallen,
 * nicht vermutet: `settings.css` laedt nur auf der Einstellungsroute (eine
 * Page-CSS je Seite). Als das Filter-Blatt des Kalenders `toggleRowHtml()`
 * benutzte, kam sein Markup ohne Regeln an - neun Zeilen liefen inline
 * ineinander, die Farbscheiben hatten keine Groesse.
 *
 * Eine geteilte Form, deren Regeln in einem Modul-Stylesheet wohnen, ist nur
 * in diesem Modul eine Form. Hier gilt sie ueberall.
 * -------------------------------------------------------- */
.toggle-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: background-color var(--transition-fast);
  min-height: var(--target-lg);
}

.toggle-row:hover {
  background-color: var(--color-surface-hover);
}

.toggle-row:focus-within {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}

.toggle-row input[type="checkbox"] {
  width: 18px;
  height: 18px;
  accent-color: var(--color-accent);
  cursor: pointer;
  flex-shrink: 0;
}

.toggle-row span {
  font-size: var(--text-sm);
  font-weight: var(--font-weight-medium);
  color: var(--color-text-primary);
}

/* DIE SCHALTERZEILE (Komponenten-Kanon, 2026-09-26): eine Boolean-Einstellung
 * ist ein Schalter wie in Apples Einstellungen - Label links und fuellend, die
 * geteilte `.toggle`-Bahn rechts (toggleRowHtml({ control: 'switch' })). Die
 * ganze Zeile bleibt das Label und damit die Trefferflaeche. Der Fokusring
 * steht an der Zeile und nur bei Tastaturfokus: `:focus-within` allein zeigte
 * ihn auch nach jedem Mausklick, und die Bahn truege sonst einen zweiten. */
.toggle-row--switch .toggle-row__label {
  flex: 1;
  min-width: 0;
}

.toggle-row--switch .toggle {
  margin-inline-start: auto;
}

.toggle-row--switch .toggle input[type="checkbox"] {
  width: 1px;
  height: 1px;
}

.toggle-row--switch:focus-within {
  outline: none;
}

.toggle-row--switch:has(input:focus-visible) {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}

.toggle-row--switch .toggle input:focus-visible + .toggle__track {
  outline: none;
}

/* DIE FARBSCHEIBE EINER ZEILE, DEREN GEGENSTAND EINE EIGENE FARBE HAT
   (eine Person, eine Kalenderebene).

   Sie ist der Dot der User-Farben-Regel in seiner groessten Form: die Farbe
   steht NEBEN dem Text, nie als Flaeche darunter. Auf einer frei gewaehlten
   Helligkeit gibt es keine Tinte, die traegt - ein schwarzer Termin lag
   gemessen bei 1,22:1.

   DER RING IST DER EIGENTLICHE PUNKT DIESER REGEL, und er ist gemessen: eine
   freie Farbe schafft ihre eigenen 3:1 (WCAG 1.4.11) nicht zuverlaessig. Von
   den neun Seed-Terminfarben fielen FUENF gegen die helle Arbeitsflaeche
   durch - Amber 2,15:1, Cyan 2,43:1, Teal 2,49:1, Gruen 2,54:1, Orange
   2,80:1. Der Ring in --color-border-strong traegt die Abgrenzung deshalb
   selbst; die Farbe muss sie nicht mehr leisten, und keine Nutzerfarbe wird
   dafuer verbogen. Dieselbe Bauart wie am Aufgaben-Chip des Kalenders.

   Die Initialen erscheinen erst ab 20px (Initialen-Schwelle-Regel); diese
   Scheibe ist 24px und traegt sie deshalb. */
.toggle-row__swatch {
  inline-size: var(--space-6);
  block-size: var(--space-6);
  flex-shrink: 0;
  border-radius: var(--radius-full);
  background-color: var(--swatch-color);
  box-shadow: inset 0 0 0 var(--space-px) var(--color-border-strong);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: var(--text-2xs);
  font-weight: var(--font-weight-bold);
  /* Die Tinte kommt per Inline-Style aus getReadableTextColor() - siehe
     toggleRowHtml(). Hier steht nur der Notnagel fuer eine Scheibe ohne
     berechnete Tinte. */
  color: var(--color-ink-on-vivid);
  overflow: hidden;
}

/* Sidebar auf Mobile verstecken */
.nav-sidebar {
  display: none;
  /* Steht beim Seitenwechsel (View Transition, Abschnitt Seiten-Uebergang). */
  view-transition-name: nav-sidebar;
}

/* --------------------------------------------------------
 * Bottom Navigation (Mobil + Tablet)
 *
 * Kompakte Bar mit pillenförmigen Icon-Wells und lesbaren Labels.
 * Leichter Blur-Effekt für Glassmorphismus.
 * -------------------------------------------------------- */
.nav-bottom {
  /* Absolut an der Unterkante von .app-shell statt position: fixed -
   * zuverlässiger auf iOS PWA. position: fixed kann durch will-change:
   * transform in iOS-Compositor-Layern falsch referenziert werden. Die Shell
   * ist 100dvh hoch und `position: relative`, ihre Unterkante IST der
   * Viewport-Bottom - dieselbe Garantie wie als Flex-Kind.
   *
   * UEBER DEM INHALT, NICHT DARUNTER (Kopfregel mobil, 2026-09-26). Bis dahin
   * stand hier ein Flex-Kind (`position: relative; flex-shrink: 0`): es nahm
   * dem Scrollport seine Zone ab, unter dem Glas lief nie Inhalt, und die
   * Kapsel war Glas ueber leerem Grund (A1 P1-3, A8). Aus dem Fluss genommen
   * reicht `.app-content` bis zur Unterkante, die Kapsel schwebt ueber dem,
   * was darunter scrollt, und `--nav-tail` (oben bei den Summanden) haelt das
   * Seitenende ueber ihr. FAB, Toasts und Mehr-Blatt rechnen weiter mit
   * `--nav-bottom-height` ab dem Viewport-Bottom und stehen unveraendert.
   * Guard: test:mobile-chrome.
   *
   * Liquid Glass: die Bar selbst ist eine transparente Zone; das
   * Glas-Material trägt die schwebende Kapsel .nav-bottom__items. Der
   * gleitende Indikator bleibt Kind von .nav-bottom und misst echte Rects
   * (positionTabIndicator) — die Kapsel-Margins rechnen sich automatisch ein. */
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  width: 100%;
  background: transparent;
  display: flex;
  flex-direction: column;
  /* Steht beim Seitenwechsel und zeigt dort nur ihr lebendes Bild - die Pille
   * gleitet seit dem Tipp (View Transition, Abschnitt Seiten-Uebergang). */
  view-transition-name: nav-bottom;
  /* Der seitliche Rand der ZONE ist auf --space-2 gefallen, als der FAB in die
   * Kapsel zog: sechs Ziele teilen sich jetzt eine Breite, die vorher fuenf
   * trugen, und 8px Rand sehen auf einem 375er-Telefon genauso freistehend aus
   * wie 12 - sie sind nur nicht mehr da, wo die Labels sie brauchen. */
  padding: var(--space-2) var(--nav-bar-gutter) calc(var(--safe-area-inset-bottom) + var(--space-2));
  z-index: var(--z-nav);
  /* Die Zone ist Luft, keine Flaeche: seit Inhalt unter ihr laeuft, gehoert
   * ein Tipp auf den Streifen neben oder ueber der Kapsel der Zeile darunter.
   * Treffer nimmt nur die Kapsel an (`.nav-bottom__items`). */
  pointer-events: none;
}

/* ── Items-Reihe: die schwebende Glas-Kapsel ── */
.nav-bottom__items {
  display: flex;
  /* min-height statt fixer Höhe: lange Labels (de/fr, z. B. „Haushaltsbuch",
   * „Paramètres") dürfen die Bar auf zwei Zeilen wachsen lassen, statt zu
   * clippen. Kurze Labels behalten die kompakte Höhe. */
  min-height: var(--nav-height-mobile);
  width: 100%;
  max-width: var(--nav-capsule-max);
  margin-inline: auto;
  border-radius: var(--radius-full);
  border: 1px solid var(--glass-border);
  /* Das hintere Ende gehoert dem FAB. Er liegt als fixiertes Kind der
   * Shell-Layer darueber (die #634-Begruendung fuer seinen Wohnort gilt
   * unveraendert), also kann die Kapsel ihm keinen Flex-Platz geben - sie
   * macht ihn ueber Innenabstand frei. Beide rechnen mit --fab-size, damit
   * die Reserve nicht von der Knopfgroesse wegdriften kann.
   *
   * VORNE STEHT DIE RUNDUNG IM WEG, und das ist Geometrie, kein Geschmack.
   * Die Kapsel ist eine Pille (Radius = halbe Hoehe = 30px). Ein Label sitzt
   * rund 10px ueber ihrer Unterkante, und dort liegt ihre linke Begrenzung
   * nicht bei 0, sondern bei 30 - sqrt(30^2 - 20^2) = 7,6px. „Übersicht"
   * begann bei 5px und ragte damit sichtbar aus der Rundung heraus.
   * --space-2 (8px) raeumt genau diese Sehne frei. Hinten braucht es nichts:
   * dort deckt die FAB-Reserve die Kurve laengst ab. */
  padding-inline-start: var(--space-2);
  pointer-events: auto;
}

/* NUR WO ES EINEN FAB GIBT. Die Frage stellt dieselbe `:has()`-Bedingung, die
 * schon den Scrollport verkuerzt (`.app-shell:has(.fab-layer .page-fab...)`,
 * siehe oben) - sie fragt die SEITE, statt eine Klasse zu verlangen, die beim
 * naechsten neuen Modul vergessen wird. Ohne sie klaffte in den Einstellungen
 * eine 52px breite Luecke am Ende der Leiste, reserviert fuer einen Knopf, den
 * diese Seite nicht hat.
 *
 * Die Luft zwischen letztem Tab und Knopf ist --space-1 und nicht --space-2:
 * gemessen ueber die Geraetebreiten 320/360/375/390/414 war das der eine Wert,
 * der auf einem 360px-Android den zweizeiligen Umbruch von „Übersicht" gerade
 * noch verhindert. Der Knopf steht durch die Rundung ohnehin frei. */
.app-shell:has(.fab-layer .page-fab:not([hidden])) .nav-bottom__items {
  padding-inline-end: calc(var(--fab-size) + var(--space-1));
}

/* ── More-Button (Button-Basis, ansonsten wie nav-item) ── */
.nav-item--more,
.nav-item--kitchen {
  background: none;
  border: none;
  cursor: pointer;
  font-family: inherit;
}

.nav-item--active {
  color: var(--color-accent);
}

/* ── More-Backdrop ── */
.more-backdrop {
  display: none;
  position: fixed;
  inset: 0;
  background-color: var(--color-overlay);
  z-index: calc(var(--z-nav) + 1);
  backdrop-filter: var(--blur-2xs);
  -webkit-backdrop-filter: var(--blur-2xs);
}

.more-backdrop--visible {
  display: block;
  animation: fade-in var(--duration-sm) ease;
}

/* DIE KAPSEL BLEIBT HELL, SOLANGE IHR EIGENES BLATT OFFEN IST (A1, Kleinkram
 * der Re-Critique 2026-09-27). Der Backdrop lag ueber der Leiste und dimmte
 * den Mehr-Knopf mit, der das Blatt gerade traegt. Leiste und FAB heben sich
 * ueber ihn; ein Tipp daneben trifft weiter den Backdrop (die Zone ist
 * `pointer-events: none`, nur die Kapsel nimmt Treffer), ein Tipp auf einen
 * anderen Tab navigiert, und `navigate()` schliesst das Blatt. */
html:has(.more-sheet[aria-hidden="false"]) .nav-bottom {
  z-index: calc(var(--z-nav) + 2);
}

/* Der FAB liegt IN der Kapsel und darum eine Stufe ueber ihr - dieselbe
 * Ordnung wie sonst (--z-nav + 1 ueber --z-nav), nur beide gehoben. */
html:has(.more-sheet[aria-hidden="false"]) :is(.page-fab, .page-fab-group) {
  z-index: calc(var(--z-nav) + 3);
}

@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* Der persistente Install-Promo-Banner (z-toast) weicht JEDEM fokussierten
 * Overlay — regulären Modals (.modal-overlay: Hilfe, Änderungen, Löschdialoge,
 * Formulare …), dem More-Sheet und dem Such-Overlay — statt deren Inhalt zu
 * überlagern. Deklarativ via :has() (im Projekt etabliert: auth.css,
 * user-multi-select.css); reaktiv beim Ein-/Ausblenden, ohne JS-Hooks.
 *
 * SEIT 2026-08-27 WEICHT ER AUCH DEN BEIDEN HANDLUNGS-ZUSTAENDEN DER
 * BOTTOM-ZONE (Critique 2026-08-27, P1; Backlog-P1 vom 17.08.): dem offenen
 * FAB-Speed-Dial (.fab-actions--visible - „Einkauf" und „Notiz" lagen mobil
 * hinter dem Banner, Screenshot-Beleg) und der Sammelaktions-Pille
 * (.list-bulkbar - „In den Vorrat / Löschen" war halb verdeckt, das
 * Vorrats-Killer-Feature unsichtbar). Ein Banner, das die App bewirbt, darf
 * nicht die Handlung verdecken, fuer die Casey sie gerade geoeffnet hat; es
 * kehrt zurueck, sobald der Zustand endet. */
html:has(.modal-overlay) fam-install-prompt,
html:has(.more-sheet[aria-hidden="false"]) fam-install-prompt,
html:has(.search-overlay--visible) fam-install-prompt,
html:has(.fab-actions--visible) fam-install-prompt,
html:has(.list-bulkbar) fam-install-prompt {
  display: none;
}

/* HIER STAND EINE SONDERREGEL, DIE DEN BANNER UM DEN FAB HERUMSCHOB.
 *
 * Beide waren unten rechts fixiert und ihre Kästen überlappten: der Banner
 * liegt auf der Toast-Ebene, der FAB unter der Navigation, also gewann der
 * Banner jeden Treffer - „etwas anlegen" war mobil tot, solange er stand, und
 * das ausgerechnet beim ersten Öffnen. Die Regel hob den Banner deshalb um
 * `--fab-offset-bottom + --fab-size` an.
 *
 * Sie ist mit dem Umzug des FAB entfallen: er sitzt in der Nav-Kapsel und damit
 * unter dem Banner, sobald dieser die Nav-Zone freilässt. Genau das tut er
 * jetzt aus EINEM Wert (`--nav-bottom-height + --space-2`, in
 * fam-install-prompt.js). Die alte Formel würde hier sogar schaden: mit dem
 * in die Leiste gerückten Offset landete der Banner bei 70px und damit auf der
 * Tab-Bar. */

/* ── More-Sheet ── */
.more-sheet {
  position: fixed;
  /* DAS BLATT HOERT UEBER DER LEISTE AUF, DIE ES SONST VERDECKT.
   *
   * Vorher lag es auf `bottom: 0` und damit UEBER der Tab-Leiste. Gemessen bei
   * 390x844: `elementFromPoint` lieferte am Mittelpunkt des Mehr-Knopfs
   * (x 266,6-329,0 / y 776,9-835,0) nach dem Oeffnen `.more-item--logout` -
   * derselbe Pixel oeffnete das Blatt und beendete danach die Sitzung
   * (Critique 2026-08-13, P0). Der Bestaetigungsdialog fing es ab; die Stelle
   * blieb trotzdem falsch.
   *
   * EINE UNTERKANTE UEBER DER LEISTE MACHT DEN FEHLER UNMOEGLICH statt ihn
   * abzupolstern: in dem Streifen liegt jetzt entweder der Mehr-Knopf selbst
   * (tippt man ihn erneut, schliesst das Blatt) oder der Backdrop (der
   * schliesst es auch). Derselbe Pixel oeffnet und schliesst - und kein
   * Bedienelement des Blattes steht mehr dort, wo ein Ziel der Leiste war.
   * Das ist zugleich das native Muster: der More-Tab legt sich nicht auf seine
   * eigene Leiste. Ein Sicherheitsabstand als padding-bottom haette dieselben
   * 76px gekostet und den Fehler nur eine Zeile weiter geschoben. */
  bottom: var(--nav-bottom-height);
  /* EIN BLATT, EINE FORM (Re-Critique 2026-09-27, A1 P2-3): Einrueckung,
   * Material und Radius wie das Dialog-Sheet (`.modal-overlay`-Polster,
   * `.modal-panel` + glass.css) - vorher randlos, oben 26px, nur eine
   * Oberkante. Die Unterkante bleibt ueber der Leiste (Absatz darueber). */
  left: max(var(--space-3), env(safe-area-inset-left));
  right: max(var(--space-3), env(safe-area-inset-right));
  background-color: var(--color-surface);
  border: 1px solid var(--glass-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--glass-shadow-lg);
  /* DER SEITENRAND IST HIER SPALTENBREITE, KEIN LUXUS. Seit das Raster vier
   * statt drei Spalten fuehrt, entscheidet jeder Pixel Innenabstand darueber,
   * ob ein deutscher Modulname einzeilig steht: die 4px, die von --space-4 auf
   * --space-3 fallen, gehen zur Haelfte in jede Zelle. Unten bleibt der volle
   * Wert - dort schliesst das Blatt gegen die Tab-Leiste ab, und die Fuge
   * zwischen zwei schwebenden Flaechen ist der einzige Rand, den man sieht. */
  padding: var(--space-2) var(--space-3) var(--space-4);
  z-index: calc(var(--z-nav) + 2);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  /* EIN BLATT, DAS AM UNTEREN RAND KLEBT, WAECHST NACH OBEN AUS DEM SCHIRM.
   * Ohne diese Grenze mass es bei 320x568 eine Oberkante von -142,6px, und das
   * Suchfeld lag komplett ausserhalb - per Tab erreichbar, per Auge nicht, und
   * ohne jeden Scrollweg dorthin (Critique 2026-08-13, P0). Die --space-8 sind
   * kein Rand, sondern der Beweis, dass es ein Blatt ist: der Streifen Seite
   * darueber sagt, dass hinter dem Blatt noch etwas liegt, und ist die Flaeche,
   * auf die man tippt, um es zu schliessen. dvh statt vh, weil die mobile
   * Adressleiste die Hoehe waehrend der Sitzung aendert - ueber den Token, weil
   * die Rechnung var() traegt und ein vh-Zwilling dann nichts rettet (#1276). */
  max-block-size: calc(var(--viewport-height) - var(--nav-bottom-height) - var(--space-8));
  /* Die Ausfahr-Distanz rechnet die Leiste MIT. `translateY(100%)` schob das
   * Blatt frueher exakt aus dem Bild, weil seine Unterkante der Bildschirmrand
   * war; seit sie ueber der Tab-Leiste liegt, blieben genau deren 76px oben
   * sichtbar - ein Streifen fremdes Blatt unter jeder Seite. Gefunden hat das
   * der Screenshot, nicht die Sonde: die Sonde mass das OFFENE Blatt. */
  transform: translateY(calc(100% + var(--nav-bottom-height)));
  /* `translate` traegt den Zug des Fingers (utils/sheet-drag.js) und federt
   * mit derselben Kurve zurueck; `transform` bleibt dem Ein-/Ausfahren. */
  transition:
    transform var(--duration-lg) var(--ease-out),
    translate var(--duration-lg) var(--ease-out);
}

/* Der Deckel gehoert dem Blatt, das Scrollen dem Koerper: Griff und Suche
 * bleiben stehen, wo die Hand sie sucht, und nur die Gruppen wandern.
 * `min-block-size: 0` ist Pflicht - ein Flex-Kind schrumpft sonst nicht unter
 * seinen Inhalt, und die Grenze oben bliebe wirkungslos. */
.more-sheet__body {
  flex: 1 1 auto;
  min-block-size: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  /* Der Sichtbarkeitsbeweis am Rand: die zwei Pixel Luft verhindern, dass der
   * Fokusring der obersten Kachel am Scrollport abgeschnitten wird. */
  padding-block: var(--space-0h);
  margin-block: calc(-1 * var(--space-0h));
}

/* App-Launcher-Raster: EINE Matrix, vier Spalten, keine Bereichskoepfe.
 *
 * HIER STANDEN DREI SPALTEN, weil bei vier die Zelle (~80px auf 375px)
 * schmaler war als „Haushaltshilfe" und der Name gebrochen wurde. Der Befund
 * stimmt weiter, die Antwort war nur die teuerste von zweien: drei Spalten
 * kosten bei zehn Modulen eine ganze Reihe extra, und zusammen mit den vier
 * Bereichskoepfen war das Blatt hoeher als die halbe Seite. Die andere Antwort
 * steht seit Etappe 8 zwei Regeln weiter unten in DIESER Datei - die Tab-Leiste
 * hatte exakt dasselbe Problem (fuenf Slots, 58px, „Übersicht") und hat es mit
 * Caption 2 plus zweizeiligem Clamp plus `hyphens: auto` geloest, nicht mit
 * mehr Breite. Das Label unten fuehrt jetzt dasselbe Rezept; die Breite geht
 * ans Raster.
 *
 * Die Kachel behaelt ihren Kasten: sie macht die ganze Zelle zum Ziel statt
 * nur Icon plus Text (die Grosseltern-Seite der Zielgruppe) und gibt dem
 * Zaehlbadge eine Ecke. Der dritte Grund - „im gruppierten Raster trennt sie
 * die Bereiche mit" - ist mit den Koepfen entfallen; die zwei uebrigen tragen.
 * Das farbige Siegel bleibt der Held: --shadow-xs auf Surface ist die leiseste
 * Flaeche der App und tritt hinter es zurueck. */
.more-sheet__grid {
  display: grid;
  /* minmax(0, 1fr), nicht 1fr: `1fr` ist minmax(AUTO, 1fr) und laesst die
   * laengste Kachel ihre Spalte breiter ziehen - bei 320px massen die drei
   * Spalten 93,8 / 89,1 / 89,1px statt dreimal gleich. */
  grid-template-columns: repeat(4, minmax(0, 1fr));
  /* Die Fuge ist eine Spaltenbreite in Verkleidung: bei vier Spalten gehen
   * drei Fugen von der Zeile ab, nicht zwei. --space-1h statt --space-2 gibt
   * jeder Zelle 1,5px zurueck - und die Kachelkanten stehen weiterhin klar
   * getrennt, weil der Schatten die Trennung fuehrt, nicht der Abstand. */
  gap: var(--space-1h);
}

.more-sheet[aria-hidden="false"] {
  transform: translateY(0);
}

/* ── More-Sheet Drag-Handle ── */
.more-sheet__handle {
  grid-column: 1 / -1;
  /* flex-shrink: 0, weil das Blatt seit der Obergrenze ein schrumpfender
   * Flex-Container ist: bei 320px war der Griff auf 2,3px zusammengedrueckt. */
  flex-shrink: 0;
  /* Derselbe Griff wie am Dialog-Sheet (layout.css, Mobile-Block der
   * `.modal-panel`): 36x5, 8px unter der Oberkante, Farbe `--sheet-grabber`. */
  width: 36px;
  height: 5px;
  border-radius: var(--radius-full);
  background-color: var(--sheet-grabber);
  margin: 0 auto var(--space-2);
}

/* ── More-Sheet Suchleiste ── */
.more-sheet__search {
  grid-column: 1 / -1;
  flex-shrink: 0;
  appearance: none;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  width: 100%;
  padding: var(--space-2) var(--space-3);
  /* Sieht aus wie das EINE Suchfeld (page-search.css), weil es sich wie eins
   * anfuehlt: gefuellte Kapsel statt 16px-Ecke mit Kante (Komponenten-Kanon,
   * 2026-09-26). Es ist ein Knopf, der die globale Suche oeffnet. */
  border-radius: var(--radius-full);
  background-color: var(--color-fill-field);
  border: 1.5px solid transparent;
  color: var(--color-text-secondary);
  cursor: text;
  font-family: inherit;
  text-align: left;
  margin-bottom: var(--space-1);
  -webkit-tap-highlight-color: transparent;
  transition: border-color var(--transition-fast), background-color var(--transition-fast), transform 100ms ease;
  user-select: none;
}

/* Der Hover eines FELDES lebt an seiner Kante, nicht an seiner Flaeche
 * (Dark-Kur 2026-08-17): ein Flaechenschritt drueckte den Platzhalter im Dark
 * unter AA (gemessen 3.82:1). Seit dem Komponenten-Kanon (2026-09-26) ist die
 * Kante in Ruhe transparent und der Hover faerbt sie ein - dieselbe Regel wie
 * am EINEN Suchfeld (page-search.css); der Platzhalter steht in
 * Sekundaertext auf --color-fill-field. */
.more-sheet__search:hover {
  border-color: var(--color-border-control);
}

.more-sheet__search:active {
  border-color: var(--color-accent);
  transform: scale(0.985);
}

.more-sheet__search:focus-visible {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
  border-color: var(--color-accent);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) var(--tint-shadow), transparent);
}

.more-sheet__search-icon {
  width: var(--space-4);
  height: var(--space-4);
  flex-shrink: 0;
  transition: color var(--transition-fast);
}

.more-sheet__search:hover .more-sheet__search-icon,
.more-sheet__search:active .more-sheet__search-icon {
  color: var(--color-accent);
}

.more-sheet__search-placeholder {
  font-size: var(--text-sm);
  color: var(--color-text-secondary);
  flex: 1;
}

/* ── Das Markensiegel (Block 2, 2026-08-10; ein Gesicht seit 2026-08-17) ──
 * Fams Ausweisform, abgeleitet aus der Bildmarke (drei transluzente
 * Kreise mit Sheen): eine kreisrunde Vollton-Scheibe, die die Herkunft eines
 * Objekts benennt - Flaeche im Familienton, Tinte --color-ink-on-vivid,
 * Sheen-Lichtkante als Gradient aus --glass-sheen.
 * KEIN Glas (kein backdrop-filter): der Sheen-Stop kippt in
 * prefers-reduced-transparency und prefers-contrast mit dem Token auf
 * transparent, der Fallback ist die flache Vollton-Scheibe.
 * Einsatzgesetz (Herkunfts-Regel, .impeccable/block2-brief.md): Siegel
 * stehen an Mischstellen (Suche, Heute wichtig, Widget-Koepfe, Mehr) und
 * im eigenen Modul genau einmal im Kopf - nie als Dauer-Dekor in den
 * Listen eines einzelnen Moduls.
 * Farbe pro Instanz ueber --seal-accent (moduleAccentVar bzw.
 * var(--module-*)); ohne Angabe erbt das Siegel den aktiven Modul-Akzent.
 *
 * HIER STAND EINE 16-%-TOENUNG AUF EINEM PARAMETRISCHEN GRUND (--seal-base),
 * UND SIE IST ERSATZLOS ENTFALLEN - mit ihr das --vivid-Gesicht, das es
 * daneben gab. Der Rueckbau ist die Verallgemeinerung der Widget-Kopf-Kur vom
 * selben Tag: dark-chroma.mjs hat gemessen, dass eine Beimischung im Dark fast
 * nur AUFHELLT (Buntheit 4-8 von 24-73 des Volltons), also gerade nicht das
 * traegt, wofuer das Siegel da ist. Im Light ist der Befund noch schaerfer und
 * er stand im Mehr-Blatt: Notizen, Dokumente und Inventar teilen sich die
 * Familie „records", und bei 16 % war ihr Scheibengrund BITWEISE derselbe
 * (#E1E4EA) - die Toenung loeschte genau den Unterschied, den sie zeigen soll.
 * Der Kommentar der Kachelreihe hatte den Satz schon: zwei Siegel-Gesichter
 * auf einem Board waeren zwei Wahrheiten. Es gibt jetzt eines.
 *
 * GEMESSEN UEBER ALLE NEUN FAMILIENTOENE, Glyph gegen Scheibe, und zwar an der
 * UNGUENSTIGSTEN Stelle: unter dem Sheen, wo 16 % Weiss den Ton aufhellen und
 * eine helle Tinte am wenigsten traegt. Light 3,65-5,18:1, Dark 7,42-12,24:1 -
 * ueberall ueber der 3:1-Grafikschwelle. Ohne den Sheen liegt der Light-Bereich
 * bei 5,04-7,17:1; die Spanne dazwischen ist der Preis des Materials, und er
 * ist bezahlt. */
.module-seal {
  --seal-accent: var(--module-accent, var(--color-accent));
  --seal-icon: var(--icon-md);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: var(--target-sm);
  height: var(--target-sm);
  border-radius: var(--radius-full);
}

/* ── DAS MATERIAL EINER MARKE, EINMAL ──
 * Die Vollton-Regel (DESIGN.md, Colors) sagt: was eine Identitaet NENNT,
 * traegt seine Farbe im Vollton. Das Siegel war der erste Traeger dieser
 * Regel; die Modulseiten hatten daneben sieben eigene Marken in der
 * zurueckgenommenen BLASSEN Fassung - 16-%-Waschung im Grund, dieselbe Farbe
 * blass darueber. Genau die Bauart, die `--vivid` und `--seal-base` gekostet
 * hat, nur unter anderen Klassennamen; der Guard von damals nannte eine
 * Klasse, nicht die Regel, und hat sie deshalb nie gesehen.
 *
 * WARUM ZWEI SELEKTOREN UND NICHT EINE KLASSE: das Siegel besitzt seine
 * GEOMETRIE (Scheibe, drei Groessenrollen). Die Marken einer Modulseite haben
 * ihre eigene - 42px-Quadrat auf der Dokumentenkarte, 40px-Scheibe im
 * Widget - und borgen nur die HAUT. Eine gemeinsame Regel statt zweier Kopien,
 * damit der Sheen und die Tinte nicht auseinanderlaufen koennen; getrennte
 * Geometrie, damit keine Flaeche ihre Form aendern muss, um Farbe zu tragen.
 *
 * `--seal-accent` ist der einzige Parameter. Wer eine andere Quelle fuehrt
 * (Kategorie, Termin, Mitglied), setzt sie dort - siehe die Fundstellen in
 * contacts.css, documents.css, settings.css, split-expenses.css. */
.module-seal,
.vivid-mark {
  background:
    linear-gradient(to bottom, var(--glass-sheen), transparent 55%),
    var(--seal-accent);
  color: var(--color-ink-on-vivid);
}

.vivid-mark {
  --seal-accent: var(--module-accent, var(--color-accent));
}

.module-seal svg {
  width: var(--seal-icon);
  height: var(--seal-icon);
}

/* ── EINE HAND FÜR ALLE MODULZEICHEN ──
 * Wo ein Modul sich zu erkennen gibt - Siegel, Leiste, Sidebar, Mehr-Blatt,
 * Modul-Liste in den Einstellungen -, zeichnet dieselbe Strichstärke,
 * unabhängig davon, ob das Zeichen aus Fams eigenem Satz (nav-icons.js, 1.6
 * auf viewBox 24) oder aus Lucide (2 auf viewBox 24) kommt. Ohne
 * `non-scaling-stroke` skaliert der Strich mit der Zeichengröße, und dieselbe
 * Zahl bedeutete dann in jeder Größe etwas anderes: genau daran hing die
 * bisherige Übereinstimmung (siehe --icon-stroke). CSS schlägt das
 * `stroke-width`-Attribut, das beide Bibliotheken setzen - deshalb reicht diese
 * eine Regel für beide Hände.
 *
 * DER SELEKTOR IST EINE MARKE AM ZEICHEN, KEINE LISTE VON ORTEN. Er war eine
 * Liste (`.module-seal svg, .nav-item__icon, .more-item__icon`), und eine Liste
 * hätte mit jedem neuen Ort eine Zeile gebraucht - die Modul-Liste der
 * Einstellungen war der erste, der sie schon nicht mehr hatte. `moduleIconEl`
 * und `moduleIconHTML` hängen `.module-glyph` an JEDES Zeichen, das sie
 * ausgeben, den Lucide-Rückfall eingeschlossen (`lucide.createIcons()`
 * übernimmt die Klassen des Platzhalters auf das erzeugte <svg>). Die beiden
 * Nav-Selektoren bleiben daneben stehen: die Sidebar-Suche baut ihr Zeichen als
 * rohen Lucide-Platzhalter, gehört aber in dieselbe Leiste. */
.module-glyph,
.nav-item__icon,
.more-item__icon {
  stroke-width: var(--icon-stroke);
  vector-effect: non-scaling-stroke;
}

/* Kleine Rolle: Inline-Siegel in Koepfen und Sektionszeilen. */
.module-seal--sm {
  width: var(--space-6);
  height: var(--space-6);
}

/* HIER STAND `.module-seal--vivid`. Die Variante hat die Regel gewonnen, statt
 * neben ihr zu bestehen: sie war fuer die eine umgekehrte Flaeche der App
 * gedacht (der Toast, in beiden Themes die Umkehrung der Seite), hat dann den
 * Widget-Kopf und die Kennzahl-Kacheln bekommen und am Ende gab es zwei
 * Gesichter fuer eine Rolle. Der Vollton steht jetzt in der Basisregel, die
 * Variante ist gestrichen - inklusive `--seal-base`, dem Parameter, den nur
 * die Toenung brauchte. Was bleibt, ist ihre Messung: Glyph auf Scheibe
 * 5,1-9,8:1 in beiden Themes ueber alle neun Familientoene.
 * Der Sheen bleibt, er ist der Materialbeweis - dasselbe Licht wie auf dem FAB. */

/* ── Kopfrolle: das Siegel als ABSENDER des Moduls ──
 * Die zweite der beiden Groessenrollen des Briefs (Listenzeile, Modulkopf).
 *
 * WARUM GENAU HIER EINES UND SONST KEINES: die Herkunfts-Regel zeigt Herkunft
 * nur, wo sie nicht selbstverstaendlich ist. In den Listen eines Moduls ist sie
 * das - dort spraeche ein wiederholtes Siegel den Modul-Tint ueber die
 * etablierten Elemente (Gesundheit trug vorher 14 Vorkommen, Dokumente null).
 * Im Kopf ist es der Absender: einmal, neben dem Seitentitel, wie die Marke auf
 * einem Briefkopf.
 *
 * ES GEHOERT DER SHELL, NICHT DEM MODUL. Angehaengt wird es von
 * `wireCollapsingHeader` (utils/ux.js) - derselbe Ort, der den angedockten
 * Titel anlegt, und aus demselben Grund: der Kopf ist die eine Komponente, die
 * alle Module teilen, und ein Opt-in, das jedes Modul selbst setzen muesste,
 * fehlt beim naechsten.
 *
 * HIER STAND SEIN EIGENER GRUND (--seal-base: --color-bg), weil die Toenung
 * auf dem Seitengrund sonst bei 1,06:1 lag. Mit dem Vollton hat das Siegel
 * ueberhaupt keinen Grund mehr, gegen den es mischt - der Ton IST die Flaeche,
 * und die Pro-Hintergrund-Frage stellt sich fuer die Scheibe nicht mehr,
 * sondern nur noch fuer die Tinte darauf (--color-ink-on-vivid, gemessen).
 *
 * DAS SIEGEL NIMMT DEN RANG SEINES TITELS AN. Die beiden Schnitte des
 * Canonical Page Head (Large Title 34 / Inline 22, typography.css) haben hier
 * ihre Entsprechung in EINEM Wertepaar am Kopf: --seal-head-size /
 * --seal-head-icon. Deshalb steht die Groesse nicht am Siegel, sondern an der
 * Leiste - so entscheidet ueber den Rang genau eine Stelle, und die drei
 * Zustaende unten sind dieselben drei, die typography.css fuehrt. */
.module-seal--head {
  /* Der Rang kommt von der Leiste, in der das Siegel steht. Der Fallback ist
   * der Inline-Rang und gilt fuer den zweiten Traeger dieser Rolle: die
   * Kuechen-Leiste, die nach der Leisten-Regel selbst die Kopf-Navigation ist
   * und deshalb ihren Absender fuehrt (sub-tabs.css). */
  width: var(--seal-head-size, var(--space-6));
  height: var(--seal-head-size, var(--space-6));
  --seal-icon: var(--seal-head-icon, var(--icon-md));
  transition:
    width var(--duration-sm) var(--ease-out),
    height var(--duration-sm) var(--ease-out);
}

@media (prefers-reduced-motion: reduce) {
  .module-seal--head {
    transition: none;
  }
}

/* ── More-Item: Modulkachel (Siegel = Held, Fläche = Ziel) ── */
.more-item {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Siegel und Name sind EIN Ding, kein Paar: bei --space-2 stand zwischen
   * ihnen so viel Luft wie zwischen zwei Kacheln, und das Raster las sich als
   * Reihen von Scheiben mit Bildunterschriften. --space-1h bindet sie. */
  gap: var(--space-1h);
  /* Der Innenabstand ist die zweite Haelfte der Spaltenbreite: --space-1
   * seitlich statt --space-1h gibt dem Namen 4px zurueck, ohne dass die
   * Kachelkante das Siegel beruehrt (das misst 40px in einer ~83px-Zelle). */
  padding: var(--space-2h) var(--space-1) var(--space-2);
  background-color: var(--color-surface);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-xs);
  color: var(--color-text-secondary);
  text-decoration: none;
  border: none;
  cursor: pointer;
  font-family: inherit;
  transition: color var(--transition-fast), background-color var(--transition-fast), transform var(--duration-xs) var(--ease-out);
  -webkit-tap-highlight-color: transparent;
}

/* Gedrückt wird jetzt die KACHEL, nicht mehr nur das Siegel darin: das Ziel ist
 * die ganze Fläche, also muss sie auch die Rückmeldung geben. */
.more-item:active {
  transform: scale(0.96);
}

/* Nur an einem echten Zeiger. Ohne die Abfrage blieb die zuletzt angetippte
 * Kachel nach dem Tap auf --color-surface-hover stehen und behauptete "du bist
 * hier", waehrend aria-current auf einer anderen lag - zwei widersprechende
 * Ortsangaben, eine davon ein Rest (Critique 2026-08-13). */
@media (hover: hover) {
  .more-item:hover {
    background-color: var(--color-surface-elevated-hover);
  }
}

@media (prefers-reduced-motion: reduce) {
  .more-item:active {
    transform: none;
  }
}

/* Zählbadge: was in diesem Modul wartet. Grund --tint-state, Tinte --tint-ink,
 * beide aus dem Modulton der Kachel - dieselbe Leiter wie überall. */
.more-item__badge {
  position: absolute;
  inset-block-start: var(--space-1);
  inset-inline-end: var(--space-1);
  min-width: 18px;
  padding-inline: var(--space-1);
  border-radius: var(--radius-full);
  font-size: var(--text-2xs);
  font-weight: var(--font-weight-semibold);
  line-height: 18px;
  text-align: center;
  font-variant-numeric: tabular-nums;
  /* NEUTRAL, obwohl das Mehr-Blatt eine Mischstelle ist: die Herkunft nennt in
   * dieser Zelle bereits das Vollton-Siegel darueber. Die ZAHL nennt keine -
   * sie zaehlt, und eine Toenung des Modultons unter einer Toenung derselben
   * Farbe sagte sie zweimal blass (Skalen-Regel, Gegenrichtung). */
  background-color: var(--color-fill-well);
  color: var(--color-text-secondary);
}

.more-item[aria-current="page"] {
  color: var(--color-accent);
  font-weight: var(--font-weight-semibold);
}

/* Das Markensiegel traegt das Modul (Block 2): getoenter Kreis + Icon im
 * vollen Familienton statt der frueheren soliden App-Icon-Kachel. Deren
 * Begruendung ("solide Flaechen trennen aehnliche Hues besser als blasse
 * Pastelle") ist strukturell beantwortet: aehnliche Hues gibt es seit den
 * Familientoenen nicht mehr, innerhalb einer Familie unterscheidet das
 * Icon. Form und Material kommen vom .module-seal-Baustein; hier stehen
 * nur die Grid-Groesse und die Akzent-Weiterleitung. */
.more-item__icon-well {
  --seal-accent: var(--item-module-accent, var(--color-text-secondary));
  --seal-icon: var(--icon-lg);
  /* 40px statt 48px, und das ist eine Proportion, keine Sparmassnahme: in der
   * ~83px-Zelle des Vierer-Rasters nahm die 48er-Scheibe 58 % der Breite und
   * druckte den Namen an die Kante. 40px sind knapp die Haelfte - dieselbe
   * Beziehung, die die 48er zur 109px-Zelle des Dreier-Rasters hatte. Der
   * Glyph faellt mit (--icon-lg statt --icon-xl), damit das Verhaeltnis von
   * Zeichen zu Scheibe dasselbe bleibt wie an jedem anderen Siegel. */
  width: 40px;
  height: 40px;
  flex-shrink: 0;
  /* Der Inset-Specular kommt aus den Tokens, nicht von Hand. Hier standen 30/12
   * gegen die 28/12 der Tokens - dieselbe Absicht, eine Ziffer daneben, und
   * damit die dritte Schreibweise eines Effekts, den es schon zweimal gab.
   * Wichtiger als die Ziffer: die Tokens hängen am --glass-inset-strength-
   * Schalter und fallen unter prefers-reduced-transparency / prefers-contrast
   * mit; ein handgeschriebener Wert bliebe dort stehen. */
  box-shadow:
    var(--glass-inset-elevated),
    var(--glass-inset-bottom-base);
  transition: transform var(--duration-xs) var(--ease-out);
}

/* Aktives Modul (Sheet auf dieser Route geöffnet): Akzent-Ring statt Tint. */
.more-item[aria-current="page"] .more-item__icon-well {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

.more-item__icon {
  width: var(--icon-lg);
  height: var(--icon-lg);
  flex-shrink: 0;
}

/* DASSELBE REZEPT WIE DIE TAB-LEISTE, UND ZWAR WEIL ES DASSELBE PROBLEM IST.
 *
 * Hier stand „Modulnamen werden nie silbengetrennt", und der Anlass war echt:
 * `overflow-wrap` zerhackte „Haushaltshil-fe". Was dabei nie geprueft wurde,
 * ist der Unterschied zwischen einem BRUCH und einer TRENNUNG. `break-word`
 * und `anywhere` brechen per Definition irgendwo im Wort; `hyphens: auto`
 * fragt das Silben-Woerterbuch der Dokumentsprache (lang steht auf der
 * Wurzel, lang-init.js/i18n.js). Genau diese Korrektur hat
 * `.nav-bottom .nav-item__label` bereits bekommen, mit demselben Satz: die
 * Regel war nie ueber Zeilen, sie war ueber Deutsch.
 *
 * ABER `hyphens: auto` ALLEIN REICHT NICHT, UND DAS IST GEMESSEN. Bei 375px
 * wollte „Haushaltshilfe" 76,5px und bekam 75,3 - eineinhalb Pixel zu wenig.
 * Der Browser nimmt dann die LETZTE erlaubte Trennstelle, die noch passt, und
 * die deutschen Trennmuster erlauben „Haushaltshil-fe": wortrichtig getrennt
 * und trotzdem genau der Anblick, den die alte Regel verbieten wollte. Eine
 * Trennung mit zwei Zeichen auf der zweiten Zeile ist kein Zeilenumbruch mehr,
 * sondern ein abgerissener Buchstabe. `hyphenate-limit-chars` setzt die
 * Untergrenze: mindestens 6 Zeichen im Wort, mindestens 4 vor und 4 NACH dem
 * Strich. Damit faellt „…hil-fe" weg und es bleibt „Haushalts-hilfe" - der
 * Bruch, den die Regel immer gemeint hat. (Browser ohne die Eigenschaft
 * verhalten sich wie bisher; sie ist eine Verschaerfung, kein Fundament.)
 *
 * Die Groesse folgt derselben Quelle wie die Leiste: Caption 2 (11px) ist die
 * kleinste TEXTROLLE der Skala - darunter beginnt --text-2xs, und das ist eine
 * Badge-Groesse. Es ist zugleich der Plattform-Wert (iOS setzt Icon-Labels auf
 * 11pt), also kein Notnagel fuer die vierte Spalte.
 *
 * ZWEI ZEILEN SIND RESERVIERT, NICHT ERLAUBT. Ohne die Untergrenze massen die
 * Reihen bei 375px 89,3 und 76,6px: dieselbe Kachel in zwei Groessen, je
 * nachdem ob zufaellig ein langer Name in der Reihe steht - ein Raster, dessen
 * Kanten nicht fluchten, liest sich als Fehler, nicht als Rhythmus. Die
 * Reservierung kostet auf einem 390er zwei mal 12,7px und kauft dafuer eine
 * Matrix. Der Text bleibt oben in seinem Kasten, damit die ERSTEN Zeilen aller
 * Namen einer Reihe auf derselben Linie sitzen. */
.more-item__label {
  font-size: var(--type-micro);
  font-weight: var(--font-weight-medium);
  text-align: center;
  line-height: 1.15;
  min-block-size: 2.3em;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  hyphens: auto;
  hyphenate-limit-chars: 6 4 4;
  overflow-wrap: anywhere;
}

.more-item[aria-current="page"] .more-item__label {
  font-weight: var(--font-weight-semibold);
}

/* ── More-Sheet Divider: trennt Modul-Raster von der System-Reihe ──
 * Seit die Bereichskoepfe weg sind, ist diese Haarlinie die EINZIGE Zaesur im
 * Blatt - vorher lag sie zwischen zwei ohnehin verschiedenen Bloecken und
 * durfte flach anliegen. Der Abstand ist deshalb ihr Auftrag, nicht ihr Rand:
 * ohne ihn stossen die Kachelreihe und die Systemreihe im selben Rhythmus
 * aneinander wie zwei Kachelreihen untereinander. */
.more-sheet__divider {
  height: 1px;
  background-color: var(--color-border-subtle);
  margin-block: var(--space-2h);
}

/* ── System-Reihe: Einstellungen | Hilfe | Änderungen | Abmelden ──
 * Dieselben vier Spalten wie das Modul-Raster darueber, damit das Blatt EINE
 * Matrix ist und nicht zwei Raster mit verschiedenen Massen. Getrennt wird
 * ueber Material, nicht ueber Geometrie: die Module tragen Kasten und farbiges
 * Siegel, diese vier stehen monochrom auf blankem Grund.
 * Die Spaltenzahl kommt aus der Besetzung (router.js setzt --more-system-cols),
 * weil Einstellungen ein abschaltbares Modul ist und drei Ziele in vier Spalten
 * eine Luecke lassen, die wie ein fehlendes Element aussieht. */
.more-sheet__system {
  display: grid;
  grid-template-columns: repeat(var(--more-system-cols, 4), minmax(0, 1fr));
  gap: var(--space-1h);
}

/* System-Item: Icon-über-Label, monochrom — leichter als die farbigen
 * Modul-App-Icons, klar als „Meta/System" lesbar. */
.more-action {
  position: relative; /* Anker für den Update-Punkt (.nav-dot, #490) */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  padding: var(--space-1h) var(--space-1);
  border-radius: var(--radius-md);
  background-color: transparent;
  color: var(--color-text-secondary);
  text-decoration: none;
  border: none;
  cursor: pointer;
  font-family: inherit;
  text-align: center;
  min-height: var(--target-base);
  -webkit-tap-highlight-color: transparent;
  transition: background-color var(--transition-fast), color var(--transition-fast);
}

.more-action:active {
  background-color: var(--color-surface-hover);
  transform: scale(0.96);
}

/* Aktives System-Ziel (z. B. Einstellungen offen) markieren. */
.more-action[aria-current="page"] {
  color: var(--color-accent);
  font-weight: var(--font-weight-semibold);
}

.more-action__icon {
  width: var(--icon-lg);
  height: var(--icon-lg);
  flex-shrink: 0;
  color: var(--color-text-tertiary);
}

.more-action[aria-current="page"] .more-action__icon {
  color: var(--color-accent);
}

/* Wortgleich .more-item__label: dieselbe Spaltenbreite, dieselbe Sprache,
 * dieselbe Antwort. „Einstellungen" ist mit 13 Zeichen fast so lang wie
 * „Haushaltshilfe" und braucht dieselbe Trennung, nicht eine zweite Regel.
 * OHNE die reservierte zweite Zeile: die System-Ziele sind EINE Reihe, und
 * innerhalb einer Reihe gleicht das Raster die Hoehen ohnehin an. Was die
 * Kacheln oben brauchen (fluchtende Kanten ueber mehrere Reihen), gaebe hier
 * nur leere Hoehe. */
.more-action__label {
  font-size: var(--type-micro);
  font-weight: var(--font-weight-medium);
  line-height: 1.15;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  hyphens: auto;
  hyphenate-limit-chars: 6 4 4;
  overflow-wrap: anywhere;
}

/* ── Such-Overlay ── */
/* Mobile: bildschirmfüllendes Sheet, das von unten einfährt (nativer Pattern
 * für Touch). Am Desktop (≥768px, weiter unten) wird daraus eine zentrierte
 * Command-Palette über einem geblurten Scrim — dort ist das Overlay nur noch
 * die Scrim-Ebene und das .search-overlay__panel die Karte. */
.search-overlay {
  position: fixed;
  inset: 0;
  /* Leichte Tönung statt randlosem Weiß: die Suche gehört sichtbar zur
   * App-Fläche (Audit A1-14). Sie sucht app-weit, nicht im Modul - deshalb
   * traegt sie die Stimme (Eine-Stimme-Regel). */
  background-color: color-mix(in srgb, var(--color-accent) var(--tint-wash), var(--color-bg));
  z-index: calc(var(--z-nav) + 3);
  display: flex;
  flex-direction: column;
  transform: translateY(100%);
  transition: transform var(--duration-lg) var(--ease-out);
}

.search-overlay--visible {
  transform: translateY(0);
}

/* Gruppiert Kopf + Treffer + Schließen. Auf Mobile transparent und
 * bildschirmfüllend (das Overlay trägt die Tönung), Bezugsrahmen für den
 * absolut positionierten Schließen-Button. */
.search-overlay__panel {
  position: relative;
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.search-overlay__header {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  /* Rechts Raum für den absolut positionierten Schließen-Button. */
  padding: calc(var(--space-3) + var(--safe-area-inset-top))
           calc(var(--space-4) + var(--target-base) + var(--space-3))
           var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--color-border-subtle);
}

/* Flaeche, Kante, Form und Fokus teilt die globale Suche mit dem EINEN
 * Suchfeld (page-search.css, Komponenten-Kanon 2026-09-26); hier steht nur,
 * was ihr eigen ist: sie fuellt die Kopfzeile und hat keine Lupe davor. */
.search-overlay__input {
  flex: 1;
  height: var(--target-lg);
  padding: 0 var(--space-4);
  font-size: var(--text-base);
}

.search-overlay__close {
  /* Liegt im DOM hinter den Treffern (Tab-Weg: Suchfeld -> Ergebnisse,
   * Audit A1-14), visuell aber weiterhin oben rechts im Kopfbereich. */
  position: absolute;
  top: calc(var(--space-3) + var(--safe-area-inset-top));
  right: var(--space-4);
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--target-base);
  height: var(--target-base);
  border-radius: var(--radius-full);
  border: none;
  background: var(--color-surface-elevated);
  color: var(--color-text-secondary);
  cursor: pointer;
  flex-shrink: 0;
  -webkit-tap-highlight-color: transparent;
}

.search-overlay__close-icon {
  width: var(--space-5);
  height: var(--space-5);
}

.search-overlay__results {
  flex: 1;
  /* Verschachteltes Flex-Kind (overlay > panel > results): ohne min-height:0
   * greift overflow-y nicht und lange Trefferlisten würden im höhen-gedeckelten
   * Desktop-Panel abgeschnitten statt gescrollt. */
  min-height: 0;
  overflow-y: auto;
  padding: var(--space-3) var(--space-4) calc(var(--space-4) + var(--safe-area-inset-bottom));
}

/* Hinweis-, Fehler- und Kein-Treffer-Zustand der Suche sind seit Block 2 die
 * geteilte .empty-state--compact (emptyHintEl, utils/empty-state.js). */

.search-section {
  margin-bottom: var(--space-5);
}

.search-result__meta {
  display: block;
  margin-top: var(--space-0h);
  font-size: var(--text-xs);
  color: var(--color-text-secondary);
}

/* Sektionskopf einer Mischstelle (Herkunfts-Regel, Block 2): das Siegel
 * benennt das Herkunftsmodul der Gruppe, der Text bleibt das
 * Navigations-Gruppierungslabel. Er steht UEBER dem Traeger auf dem
 * Grund, nie in ihm (Zeilenlisten-Regel). */
.search-section__heading {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-semibold);
  color: var(--color-text-secondary);
  letter-spacing: var(--tracking-normal);
  margin-bottom: var(--space-2);
}

/* Zeilenlisten-Regel: eine Folge gleichartiger Treffer liegt in GENAU
 * EINEM Traeger; vorher trug jede Trefferzeile Surface + Radius + Abstand
 * und war damit eine Karte pro Zeile (Critique 2026-08-10, P2). */
.search-section__rows {
  background: var(--color-surface);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
  overflow: hidden;
}

.search-result {
  display: block;
  width: 100%;
  text-align: left;
  padding: var(--space-3);
  border: none;
  background: none;
  color: var(--color-text-primary);
  font-size: var(--text-sm);
  cursor: pointer;
  transition: background-color var(--transition-fast);
  -webkit-tap-highlight-color: transparent;
}

.search-result + .search-result {
  border-top: 1px solid var(--color-border-subtle);
}

.search-result:active {
  background-color: var(--color-surface-hover);
}

/* Zeile an der geclippten Traegerkante: der Fokusring rueckt nach innen,
 * statt unter overflow:hidden zu verschwinden. */
.search-result:focus-visible {
  outline-offset: var(--focus-ring-offset-inset);
}

/* Scope-Launcher im Leerzustand: „Direkt öffnen"-Kacheln, die zugleich zeigen,
 * was durchsucht wird, und ins jeweilige Modul springen (Critique P1). */
.search-scopes {
  margin-top: var(--space-5);
}

.search-scopes__list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: var(--space-2);
  margin-top: var(--space-2);
}

.search-scope {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  /* Grid-Item: ohne min-width:0 sprengen lange Labels (andere Locales) die
   * Kachel statt umzubrechen. */
  min-width: 0;
  padding: var(--space-3);
  border: 1px solid var(--color-border-subtle);
  border-radius: var(--radius-md);
  background: var(--color-surface-elevated);
  color: var(--color-text-primary);
  font-size: var(--text-sm);
  font-weight: var(--font-weight-medium);
  text-align: left;
  overflow-wrap: break-word;
  cursor: pointer;
  transition: background-color var(--transition-fast), border-color var(--transition-fast);
  -webkit-tap-highlight-color: transparent;
}

@media (hover: hover) {
  /* Wie .more-sheet__search: erhoehte Ruheflaeche, also die Stufe darueber. */
  .search-scope:hover {
    background-color: var(--color-surface-elevated-hover);
    border-color: var(--color-border);
  }
}

.search-scope:active {
  background-color: var(--color-surface-hover);
}

/* Scope-Kacheln sind eine Mischstelle: das Siegel traegt den Familienton
 * des Zielmoduls (--seal-accent setzt der Aufbau in router.js).
 *
 * HIER STAND `--seal-base: var(--color-surface-elevated)`, und der Grund war
 * eine Messung: die Kachel steht nicht auf --color-surface, und ohne den
 * Parameter mischte die 16-%-Toenung gegen den falschen Grund (im Dark
 * 1,02-1,15:1 gegen den echten Untergrund, health 1,02). Mit dem Vollton
 * mischt das Siegel gar nicht mehr - die Zeile ist mit ihrer Ursache
 * entfallen, nicht vergessen worden. */
.search-scope__seal {
  flex-shrink: 0;
}

/* ── Such-Overlay: Desktop-Command-Palette (≥768px) ──
 * Aus dem Mobile-Vollbild-Sheet wird eine zentrierte, oben verankerte Glas-
 * Karte über einem geblurten Modul-Scrim — dieselbe Grammatik wie .modal-overlay
 * (die App bleibt als weiches Bild sichtbar, statt durch eine flache Fläche
 * ersetzt zu werden). Kein Bottom-Slide: Ein-/Ausblenden über opacity. */
@media (min-width: 768px) {
  .search-overlay {
    /* Scrim statt Vollflächen-Tönung (gespiegelt aus .modal-overlay). */
    background-color: color-mix(in srgb, var(--color-accent) var(--tint-wash), var(--color-overlay-glass));
    backdrop-filter: var(--blur-xs) saturate(120%);
    -webkit-backdrop-filter: var(--blur-xs) saturate(120%);
    align-items: center;          /* Panel horizontal zentriert */
    justify-content: flex-start;  /* oben verankert */
    padding: 12vh var(--space-6) var(--space-6);
    /* Kein translateY-Vollhub am Desktop; über opacity/visibility togglen. */
    transform: none;
    opacity: 0;
    visibility: hidden;
    transition: opacity var(--duration-md) var(--ease-out),
                visibility 0s linear var(--duration-md);
  }

  .search-overlay--visible {
    transform: none;
    opacity: 1;
    visibility: visible;
    transition: opacity var(--duration-md) var(--ease-out);
  }

  .search-overlay__panel {
    flex: 0 0 auto;
    width: min(640px, 92vw);
    max-height: calc(var(--viewport-height) - 12vh - var(--space-6));
    background-color: var(--color-surface);
    border: 1px solid var(--color-border-subtle);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-lg);
    overflow: hidden;
    /* Auftakt gespiegelt aus @keyframes modal-scale-in: Fade + scale(0.95→1)
     * mit ease-out-expo, statt eines vom Bildschirmrand hochfahrenden Sheets.
     * Als Transition (nicht Keyframe), weil das Overlay persistent ist und nur
     * per --visible getoggelt wird. */
    opacity: 0;
    transform: scale(0.95);
    transition: opacity var(--duration-md) var(--ease-out),
                transform var(--duration-md) var(--ease-out);
  }

  .search-overlay--visible .search-overlay__panel {
    opacity: 1;
    transform: none;
  }

  /* Am Desktop sitzt das Panel frei im Raum — Notch-/Safe-Area-Reserven aus
   * Kopf, Schließen-Button und Treffer-Boden entfallen. */
  .search-overlay__header {
    padding-top: var(--space-3);
  }

  .search-overlay__close {
    top: var(--space-3);
  }

  .search-overlay__results {
    padding-bottom: var(--space-4);
  }
}

/* ── Nav-Item (Bottom-Bar): Basis-State ── */
.nav-item {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-0h);
  padding: var(--space-1) var(--space-1);
  color: var(--color-text-tertiary);
  transition: color var(--transition-fast);
  -webkit-tap-highlight-color: transparent;
  min-height: var(--target-lg);
  min-width: var(--target-lg);
  text-decoration: none;
  position: relative;
}

.nav-bottom .nav-item {
  padding-block: var(--space-0h);
  /* Kein Innenabstand seitlich: der Slot IST der Abstand. Bei sechs Zielen in
   * der Kapsel (fuenf Tabs plus Knopf) kosteten 2px je Seite zusammen 20px -
   * genug, um „Übersicht" auf einem 360px-Geraet in die zweite Zeile zu
   * schieben. Die Labels stehen zentriert und beruehren sich nicht: zwischen
   * dem laengsten (53px) und seinem Slot (57px) bleiben 4px. */
  padding-inline: 0;
  min-width: 0;
}

.nav-item__icon-wrap {
  position: relative;
  display: inline-flex;
  flex-shrink: 0;
}

.nav-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 18px;
  height: 18px;
  padding: 0 var(--space-1);
  border-radius: var(--radius-full);
  background-color: var(--color-danger);
  color: var(--color-ink-on-vivid);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-bold);
}

/* Die Valenz der Zahl (nav-badges.js, paint): Danger ist die gerissene Frist,
 * Warning die ablaufende, Accent die Nachricht. Ein Geburtstag in drei Tagen
 * stand vorher im selben Alarm-Rot wie eine ueberfaellige Aufgabe - und das
 * Mehr-Blatt zaehlte dieselbe Zahl neutral (Critique 2026-08-27). Tinte
 * bleibt --color-ink-on-vivid: dieselbe Paarung wie FAB (accent) und
 * Kuechen-Warnzaehler (warning). */
.nav-badge--accent {
  background-color: var(--color-accent);
}

.nav-badge--warning {
  background-color: var(--color-warning);
}

.nav-item__icon-wrap .nav-badge {
  position: absolute;
  top: calc(-1 * var(--space-1));
  right: calc(-1 * var(--space-1));
}

/* Punkt ohne Zahl: markiert eine ungelesene Neuigkeit, keine Menge (#490).
   Akzentviolett statt Danger-Rot - eine neue Version ist eine Nachricht, kein
   Alarm. Der Ring in der Nav-Fläche trennt ihn vom Icon darunter. */
.nav-dot {
  position: absolute;
  top: calc(-1 * var(--space-0h));
  right: calc(-1 * var(--space-0h));
  width: var(--space-2);
  height: var(--space-2);
  border-radius: var(--radius-full);
  background-color: var(--color-accent);
  box-shadow: 0 0 0 2px var(--color-surface);
  pointer-events: none;
}

/* Eintrag „Aenderungen" im Konto-Menue der Seitenleiste: der Punkt steht am
 * Zeilenende, wie ein Hinweis in einem macOS-Menue - im Fluss, nicht schwebend. */
.popover-menu__item > .nav-dot {
  position: static;
  flex-shrink: 0;
  margin-inline-start: auto;
}

/* Listenzeile im „Mehr"-Sheet: kein Icon-Well, der Punkt hängt an der Zeile. */
.more-action > .nav-dot {
  top: var(--space-1);
  right: var(--space-1);
}

/* ── Nav-Item: Aktiv ── */
.nav-item[aria-current="page"] {
  color: var(--color-accent);
}

/* ── Icon-Well (Bottom-Nav + Sidebar) ── */
.nav-item__icon-well {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  border-radius: var(--radius-sm);
  background: var(--color-surface-elevated);
  color: var(--color-text-secondary);
  box-shadow: inset 0 var(--space-px) 0 var(--glass-highlight-subtle);
  transition: background-color var(--transition-fast), color var(--transition-fast);
}

.nav-bottom .nav-item__icon-well {
  width: var(--target-base);
  /* Sichtbare Tap-Fläche 44×40 statt 44×32 — füllt die 44×44-Komfortzone für
   * Ein-Daumen-Nutzung besser aus und senkt das Fehl-Tap-Risiko. */
  height: var(--target-md);
  border-radius: var(--radius-full);
  /* Inaktiv: nacktes Icon, keine gefüllte Well-Kachel (auch im No-Glass-
   * Fallback). Die aktive Pille trägt den Zustand. */
  background: transparent;
  box-shadow: none;
  /* UND DAS ZEICHEN TRAEGT SEINEN MODULTON - dieselbe Legende wie in der
   * Sidebar, und aus demselben Grund (2026-08-17, Betreiber-Befund „die Icons
   * im mobilen Navigationsmenue unterscheiden sich farblich deutlich von denen
   * in der Webansicht").
   *
   * Die Legende war bis dahin eine DESKTOP-Regel, obwohl sie nie eine sein
   * wollte: die Sidebar hat sie bekommen, weil dort alle Module nebeneinander
   * stehen, und die Tab-Bar ist genau dieselbe Komponente in der anderen Form -
   * eine App, in der die Navigation je nach Fenstergroesse eine andere Sprache
   * spricht, hat zwei Wahrheiten. Auf Telefonen war ausserdem GAR KEIN Modulton
   * sichtbar, und das Telefon ist die Hauptbuehne (PRODUCT.md).
   *
   * Es ist kein Siegel: die Herkunfts-Regel verbietet der Leiste zu Recht die
   * Scheibe - die Leiste beantwortet „wo bin ich", nicht „woher". Sie bekommt
   * den Ton auf dem nackten Zeichen, wie die Sidebar, und aus demselben
   * Kontrast-Grund: ein Zeichen ist Grafik (3:1 nach 1.4.11), ein Label waere
   * Text und muesste 4.5:1 halten - was sieben der neun Familientoene reissen.
   *
   * GEMESSEN GEGEN DIE KAPSEL, NICHT GEGEN DIE SEITE: die Leiste ist Glas
   * (rgba .86 plus backdrop-filter), der echte Grund des Zeichens also die
   * Mischung aus Kapsel und Seite - im Light #F9F9FA, im Dark #2B2825. Ueber
   * alle neun Familientoene: Light 4,79-6,81:1, Dark 4,57-8,41:1. Beide Bereiche
   * liegen nicht nur ueber der 3:1-Grafikschwelle, sondern ueber der
   * 4.5:1-Textschwelle - der Ton ist hier also nicht die Grenze.
   *
   * Die Eine-Stimme-Regel bleibt heil: Kapsel, gleitender Indikator, Label,
   * Fokusring und FAB tragen weiter --color-accent. Gewechselt hat das
   * ZEICHEN, das schon immer sagte, wohin es fuehrt. */
  color: var(--item-module-accent, var(--color-text-tertiary));
  transition:
    background-color var(--transition-fast),
    color var(--transition-fast),
    transform var(--duration-xs) var(--ease-out),
    opacity var(--duration-xs) var(--ease-out);
}

.nav-sidebar .nav-item__icon-well {
  width: 26px;
  height: 26px;
}

/* Der äußere Indikator ist die einzige aktive Fläche; das Icon trägt die Stimme.
 * Bis 2026-08-10 trug es --item-module-accent - damit war die Leiste in jedem
 * Modul anders eingefaerbt, und der Rahmen der App wechselte mit dem Zimmer. */
.nav-item[aria-current="page"] .nav-item__icon-well,
.nav-item--active .nav-item__icon-well {
  background: transparent;
  color: var(--color-accent);
  box-shadow: none;
}

/* Aktive Icons als „filled variant": Lucide bleibt Stroke-Bibliothek, die
 * Tab-Bar-Sprache des Kanons (gefülltes Symbol im aktiven Tab) entsteht über
 * eine getönte Innenfüllung unter vollem Stroke. Wirkt nur auf geschlossene
 * Pfade; Linien-Icons bleiben unverändert.
 *
 * DIE REGEL GILT FÜR JEDEN AUSGEWÄHLTEN ZUSTAND, nicht nur für die Tab-Bar
 * (Redesign Runde 4, C-6). Bis dahin trug ausgewählt-sein in Segmenten, Tabs
 * und Chips allein die FLÄCHE; das Symbol darin blieb dasselbe wie im
 * unausgewählten Nachbarn. Apple wechselt an dieser Stelle die Symbolvariante,
 * und der Kanon kennt dafür nur eine Sprache - also spricht sie überall.
 *
 * Ein zweiter Kanal, kein Ersatz: die Fläche trägt den Zustand weiter, die
 * Füllung verstärkt ihn. Kontrast kann sie nicht kosten - sie macht das Symbol
 * dichter, nicht blasser, und zwar in seiner eigenen Farbe. */
.nav-item[aria-current="page"] .nav-item__icon-well svg,
.nav-item--active .nav-item__icon-well svg,
.more-item[aria-current="page"] .more-item__icon-well svg,
.sub-tab--active svg,
.list-tab--active svg,
.filter-chip--active svg,
.contact-filter-chip--active svg,
.group-toggle__btn--active svg,
.documents-view-toggle__btn--active svg,
.segmented__item.is-active svg,
.cal-toolbar__view-btn--active svg,
.cal-toolbar__search-btn--active svg,
.perm-modeswitch__btn.is-active svg,
.perm-seg__opt.is-active svg {
  /* 30 % statt 16 %: auf Tab-Bar-Größe (20px) las die schwächere Füllung
   * weiter als Outline (Finish-Verdict Fundament-Phase, Fix 8). */
  fill: color-mix(in srgb, currentColor 30%, transparent);
}

/* Bottom-Nav aktiv: die gleitende Indikatorfläche übernimmt den Zustand. */
.nav-bottom .nav-item[aria-current="page"],
.nav-bottom .nav-item--active {
  background: none;
  box-shadow: none;
  border-radius: 0;
}

.nav-item__icon {
  width: var(--space-5);
  height: var(--space-5);
  flex-shrink: 0;
}

.nav-item__label {
  font-size: var(--text-xs);
  font-weight: var(--font-weight-medium);
  line-height: 1.2;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Bottom-Bar-Labels dürfen zweizeilig umbrechen statt einzeilig zu ellipsieren.
 * In 5 gleichen Flex-Slots clippt eine Single-Line lange Komposita (de/fr) zu
 * „Einst…"; zwei zentrierte Zeilen erhalten die Recognition.
 * overflow-wrap:anywhere bricht auch fugenlose Langwörter.
 *
 * EINE STUFE KLEINER ALS DIE UEBRIGEN LABELS (2026-08-10), UND DAS IST DER
 * PLATTFORM-WERT, NICHT DIE NOTLOESUNG. iOS setzt Tab-Bar-Titel auf 10pt; die
 * App stand bei 12px und damit ueber dem Kanon. Der Anlass war der FAB, der in
 * die Kapsel gezogen ist und ihr 60px abnimmt: bei 375px blieben 58px je Slot,
 * „Übersicht" wollte 58 und bekam 54 - die Bar wuchs auf zwei Zeilen und 74px
 * Hoehe. --type-micro (Caption 2, 11px) ist die kleinste TEXTROLLE der Skala
 * und damit die richtige Stufe; darunter faengt --text-2xs an, und das ist eine
 * Badge-Groesse. Bei 11px will das laengste deutsche Label 53px und passt
 * einzeilig. Der Umbruch bleibt trotzdem: er faengt die Sprachen ab, fuer die
 * auch das nicht reicht - deshalb steht er in DERSELBEN Regel wie die Groesse
 * und nicht in einer zweiten mit gleichem Selektor. */
.nav-bottom .nav-item__label {
  font-size: var(--type-micro);
  white-space: normal;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  text-align: center;
  /* MIT TRENNSTRICH, NICHT MITTEN IM WORT (Critique 2026-08-13). Der Umbruch
   * hier ist richtig und war es immer; geprüft hat nur nie jemand, WIE er
   * bricht. Bei 320px standen „Übersicht" und „Aufgaben" als „Übersich / t"
   * und „Aufgabe / n" - `overflow-wrap: anywhere` bricht per Definition
   * innerhalb eines Wortes, und bei `hyphens: manual` ohne jedes Zeichen.
   *
   * Es ist dieselbe Antwort, die die Zeilennamen in Etappe 7 bekommen haben,
   * und der Anlass war derselbe: die Regel war nie über Zeilen, sie war über
   * Deutsch. `hyphens: auto` macht aus dem Bruch eine Trennung („Über-sicht"),
   * `anywhere` bleibt als Rückfall für fugenlose Langwörter darunter stehen. */
  hyphens: auto;
  overflow-wrap: anywhere;
  line-height: 1.1;
}

/* Das Label steht auf der getönten Indikator-Pille, also auf einer Tönung
 * SEINER EIGENEN FARBE - genau der Fall, für den tokens.css die Konvention
 * „Akzent-Text auf akzent-getöntem Grund" führt (70 % Akzent + 30 %
 * text-primary). Zehn Stylesheets wenden sie an; die Tab-Bar war die einzige
 * Stelle, die den rohen Akzent schrieb.
 *
 * Gemessen am gerenderten Pixel im Dark, vorher → nachher: Übersicht 3.41 →
 * 4.92:1, Kalender 4.14 → 5.51:1, Aufgaben 5.88 → 6.58:1, Einkauf 4.85 →
 * 5.94:1. Zwei der vier lagen unter AA, und kein Token-Guard konnte das sehen:
 * das Paar entsteht erst aus Fläche plus Tint plus Glas im Dokument.
 *
 * Das ICON behält den vollen Akzent - für Nicht-Text gilt 3:1, und die
 * Konvention sagt das ausdrücklich. */
/* Und dieselbe Konvention in der SIDEBAR, aus demselben Grund (Runde 6,
 * Phase 3). Der gleitende Indikator tönt dort dieselbe Farbe, die das Label
 * schreibt; gemessen im Dark auf der Übersicht: 4.20:1 gegen #29292D, also
 * unter AA.
 *
 * Warum das Phase 0b entging, obwohl sie genau diesen Fall behandelte: die
 * Kontrast-Sonde maß, BEVOR der Indikator an seiner Position angekommen war
 * (§6, „Wandernder Sidebar-Indikator"). Erst nachdem der Harness auf Ruhe
 * wartet statt auf das erste Kind, stand die Pille beim Messen da. Ein Guard
 * misst nur, was zum Messzeitpunkt existiert - und das ist eine Eigenschaft
 * des Guards, nicht der Regel. */
.nav-bottom .nav-item[aria-current="page"] .nav-item__label,
.nav-bottom .nav-item--active .nav-item__label,
.nav-sidebar .nav-item[aria-current="page"] .nav-item__label,
.nav-sidebar .nav-item--active .nav-item__label {
  color: color-mix(
    in srgb,
    var(--color-accent) 70%,
    var(--color-text-primary)
  );
  font-weight: var(--font-weight-semibold);
}

/* Fokusring außen um die Icon-Well legen (nicht innen ins Item) — so ist er
 * für Tastatur-/Sehbeeinträchtigte klar zu orten, statt hinter Icon+Label zu
 * verschwinden. */
.nav-bottom .nav-item:focus-visible {
  outline: none;
}

/* HIER STAND EINE AUSNAHME VOM FOKUSRING-TOKEN: der Ring trug den Ton des
 * Moduls, auf das das Item zeigt (--item-module-accent). Sie ist mit der
 * Eine-Stimme-Regel entfallen - die Leiste ist Shell und traegt die Stimme,
 * und ein Fokusring, der pro Tab die Farbe wechselt, macht aus der einen
 * Tastatur-Affordanz fuenf. Position und Ort bleiben: der Ring liegt weiter
 * aussen um die Icon-Well statt innen im Item. */
.nav-bottom .nav-item:focus-visible .nav-item__icon-well {
  --focus-ring-color: var(--color-accent);
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}

.nav-bottom .nav-item:active .nav-item__icon-well {
  transform: translateY(var(--space-px)) scale(0.96);
  opacity: var(--opacity-glass-elevated);
}

/* --------------------------------------------------------
 * Page FAB - Schwebender Erstellen-Button (alle Breakpoints)
 *
 * Einheitlicher runder Plus-Button auf allen Unterseiten.
 * Mobile: über der Bottom-Nav. Desktop: unten rechts im Content.
 * Toolbar-"Neu"-Buttons werden überall versteckt.
 * -------------------------------------------------------- */
@keyframes fab-in {
  from  { transform: scale(0.5) translateY(12px); opacity: 0; }
  65%   { transform: scale(1.07) translateY(-2px); opacity: 1; }
  to    { transform: scale(1)   translateY(0);    opacity: 1; }
}

/* Wohnort des FAB: ein Shell-Kind neben dem Scrollport, kein Kind von ihm.
 *
 * Der FAB ist fixiert, hing aber im Modul-Root und damit im scrollenden
 * `.app-content`. Ein fixiertes Kind eines Scrollers ist auf iOS nicht
 * verlässlich viewport-fest (Compositor-Layer des Scrollers) - dieselbe Falle,
 * die die Bottom-Nav schon aus `position: fixed` geholt hat, siehe dort. Es war
 * das letzte fixierte Element im Scrollport (#634).
 *
 * Die Layer selbst trägt KEIN Layout: 0×0 an der Shell-Ecke. Sichtbar ist allein
 * ihr fixiertes Kind, dessen Geometrie unverändert aus den --fab-*-Tokens kommt.
 * Bewusst kein z-index → kein eigener Stacking-Context, die Reihenfolge
 * gegenüber Nav/Modal/Toast bleibt exakt die alte.
 *
 * HIER STAND `--module-accent: var(--active-module-accent, ...)`, damit der FAB
 * den Ton der offenen Route bekam - die Vererbung über den Modul-Root endet an
 * dieser Layer. Mit der Eine-Stimme-Regel liest weder der Knopf noch sein
 * Speed-Dial den Modulton, und ein Token ohne Leser ist eine Einladung, das
 * alte Muster zurückzuholen. */
.fab-layer {
  position: absolute;
  bottom: 0;
  right: 0;
  width: 0;
  height: 0;
}

/* Die Verankerung des FAB - einmal, für den Knopf UND für die Gruppe.
 *
 * Ein Speed-Dial ist ein FAB mit ausklappbarer Liste, und die Liste muss mit
 * ihm wandern. Deshalb trägt in diesem Fall die Gruppe die Fixierung und der
 * Knopf darin gibt seine ab (siehe `.page-fab-group`) - die Geometrie steht
 * aber weiterhin nur an DIESER Stelle. Die zweite Handschrift war genau der
 * Weg, auf dem sie auseinanderlief: das Dashboard schrieb 52px von Hand und
 * kannte die Touch-Stufe von --fab-size (tokens.css) dadurch nicht. */
.page-fab,
.page-fab-group {
  position: fixed;
  /* Geometrie aus tokens.css (--fab-size / --fab-offset-bottom): der Freiraum
   * unter Listen (--fab-safe-zone, oben an `.app-content`) leitet sich daraus
   * ab und kann so nicht mehr vom FAB wegdriften. */
  bottom: var(--fab-offset-bottom);
  /* LOGISCHE Achse, nicht `right`. Die Kapsel reserviert ihr Ende ueber
   * `padding-inline-end`, und das kippt unter `dir="rtl"` nach links. Ein
   * physisches `right` liesse den Knopf dort auf dem ERSTEN Tab liegen,
   * waehrend die reservierte Luecke auf der anderen Seite leer steht (Codex-
   * Review zu PR #719). Betroffen waren ar und fa, also zwei der 24 Locales. */
  inset-inline-end: var(--space-4);
  z-index: calc(var(--z-nav) - 1);
}

/* Solange die Bottom-Nav steht, sitzt der FAB IN ihrer Kapsel statt darueber.
 * Er ist fixiert, sein enthaltender Block ist damit der Viewport - deshalb
 * rechnet --nav-capsule-inset dieselbe Zentrierung nach, die die Kapsel ueber
 * `margin-inline: auto` bekommt, plus 4px Innenabstand an ihrer Rundung.
 * Wer die Kapselbreite aendert, aendert beide: der Wert steht einmal.
 *
 * z-index UEBER der Nav (statt darunter): der Knopf liegt jetzt auf dem Glas,
 * nicht daneben. Das Sheet und der Toast bleiben darueber, ihre Stufen sind
 * hoeher als --z-nav. */
@media (max-width: 1023px) {
  .page-fab,
  .page-fab-group {
    inset-inline-end: calc(var(--nav-capsule-inset) + var(--space-1));
    z-index: calc(var(--z-nav) + 1);
  }
}

/* Der FAB einer Speed-Dial-Gruppe positioniert sich nicht selbst: er ist das
 * einzige Kind im Fluss, die Gruppe schrumpft dadurch exakt auf seine Fläche,
 * und die Aktionsliste hängt absolut darüber, ohne die Gruppe aufzublähen.
 * Das ersetzt das frühere `pointer-events: none` am Kasten - ein Kasten, der
 * nur so gross ist wie der Knopf, fängt nichts ab, was ihm nicht gehört. */
.page-fab-group {
  display: flex;
}

.page-fab-group > .page-fab {
  position: static;
}

.page-fab {
  width: var(--fab-size);
  height: var(--fab-size);
  border-radius: var(--radius-full);
  background-color: var(--color-accent);
  color: var(--color-ink-on-vivid);
  /* Specular über die Inset-Tokens, nicht als rohe rgba: sie tragen den Faktor
   * --glass-inset-strength, der unter prefers-reduced-transparency und
   * prefers-contrast auf 0 fällt. Der rohe Wert überlebte den Schalter genau
   * dort, wo dieser Fallback greift - ohne backdrop-filter überschreibt
   * glass.css die Zeile nicht. Der Fallback bleibt bewusst eine Stufe schwächer
   * als der Glaspfad (medium statt strong): dort trägt die Kante das Material,
   * hier nur die Kapselform. */
  box-shadow: var(--shadow-lg), var(--glass-inset-medium), var(--glass-inset-bottom-base);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  border: none;
  transition: transform var(--transition-base), background-color var(--transition-fast);
  -webkit-tap-highlight-color: transparent;
  animation: fab-in 420ms var(--ease-out) backwards;
}

.page-fab:hover {
  background-color: color-mix(in srgb, var(--color-accent) 85%, black);
}

.page-fab:active {
  transform: scale(0.92);
}

/* AUSNAHME vom Fokusring-Token (tokens.css §7b), und die einzige mit einer
 * optischen Begründung: der FAB ist selbst akzentfarben. Ein Ring im
 * Modulakzent läge Farbe auf Farbe und wäre unsichtbar. Er wird deshalb
 * invertiert - heller Ring direkt am Knopf, der Akzent als äußerer Halo per
 * box-shadow. Überschrieben wird nur die FARBE, Breite und Offset kommen aus
 * den Tokens: so wandert der FAB mit, wenn der Ring app-weit dicker wird. */
.page-fab:focus-visible {
  --focus-ring-color: var(--color-bg);
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
  box-shadow: 0 0 0 4px var(--color-accent);
}

/* Long Loop: FAB-Animation nach N Aufrufen deaktivieren */
html.fab-anim-done .page-fab {
  animation: none;
}

/* HIER STAND `.page-fab--retracted` (#634).
 *
 * Der FAB fuhr beim Abwärtsscrollen weg, um die Zeilenaktion freizugeben, über
 * der er sonst lag (bis 53.2% Überdeckung, Critique 2026-07-30). Diese
 * Begründung war schon entfallen, bevor der Mechanismus fiel: `--fab-safe-zone`
 * verkürzt den Scrollport, sodass bei JEDEM Scrollstand nichts Bedienbares mehr
 * unter dem FAB liegt (0 Überdeckungen nachgemessen). Übrig blieb Sicht-Komfort
 * beim Scrollen - zu wenig für den Preis, den er hatte: der Zustand hing an
 * einer Klasse, die nur ein weiteres Scroll-Ereignis wieder abnahm, und ein
 * einziges Abwärts-Delta ohne Nutzergeste (iOS-Adressleiste, Scroll-Anchoring
 * beim Nachladen einer Liste) machte die Primäraktion des Moduls unerreichbar.
 *
 * Kein Ersatz nötig, und keiner erwünscht: --fab-safe-zone trägt den Freiraum
 * bereits, und der FAB darf nichts mehr haben, was ihn verschwinden lässt.
 * test-frontend-audit.js hält das fest. */

/* Desktop: FAB Position anpassen (keine Bottom-Nav) und etwas kleiner */
@media (min-width: 1024px) {
  .page-fab,
  .page-fab-group {
    bottom: var(--space-8);
    right: var(--space-8);
  }

  .page-fab {
    width: var(--target-lg);
    height: var(--target-lg);
  }
}

/* ================================================================
 * Geteilte Page-Toolbar (Modul-Kopf)
 *
 * Vereinheitlicht NUR das Gerüst aller Modul-Köpfe (Höhe, Padding,
 * Akzent-/Trennrand, Sticky-Verhalten, Titel-Typografie), damit der
 * Kopf beim Modulwechsel nicht „springt". Modul-spezifischer Inhalt
 * (Suche, Filter-Chips, Views, Monats-Nav, Tabs) lebt weiter in den
 * Modul-CSS-Dateien als direkte Flex-Children dieser Shell.
 *
 * BREITE UND BLEED (Issue #577): Der Kopf ist ein Full-bleed-Rail. Das
 * Chrome — Akzentstreifen, Trennlinie, Hintergrund, Sticky-Fläche —
 * läuft bis an die Shell-Kante; der INHALT sitzt über
 * --page-inline-pad in derselben zentrierten Content-Spalte wie der
 * Seiten-Body. Vorher trug der Modul-Root das max-width, der Kopf lag
 * mit drin, und der 3px-Streifen endete 210px vor der Kante.
 * Bedingung: .page-toolbar muss DIREKTES Kind des Modul-Roots sein,
 * sonst löst das `100%` in --page-inline-pad gegen einen anderen
 * Containing Block auf und die Fluchtlinie bricht.
 * ================================================================ */
.page-toolbar {
  /* Die Naht der Leiste als Wert, weil die Titelzeile mit ihr rechnen muss:
   * das Siegel und der Large Title teilen sich eine Zeile nur, wenn die Basis
   * des Titels exakt um Siegel PLUS Naht kleiner als 100 % ist (siehe die
   * Large-Title-Zone unten). Bis 640px verdichtet sich die Naht - eine
   * zweite Zahl an der Rechenstelle waere genau die Sorte stiller Drift, die
   * ein Umbruch erst beim naechsten Locale zeigt. */
  --page-toolbar-gap: var(--space-2);
  /* Rang des Absender-Siegels. Voreinstellung ist der INLINE-Rang: er gilt ab
   * 1024px (Sidebar-Welt) und in der kompakten Hoehenklasse - beides Zustaende,
   * in denen der Titel selbst inline steht. Die Large-Title-Zone hebt ihn. */
  --seal-head-size: var(--space-6);
  --seal-head-icon: var(--icon-md);
  /* Was das Siegel der Titelzeile wegnimmt. Ohne Siegel ist es nichts, und
   * die Titelbasis unten rechnet unveraendert mit 100 %. */
  --seal-head-lead: 0px;
  display: flex;
  align-items: center;
  gap: var(--page-toolbar-gap);
  padding-block: var(--space-3);
  padding-inline: var(--page-inline-pad);
  /* HIG: kein farbiger Akzentbalken an der Nav-Bar — Modul-Identität
   * spricht über den Tint der Controls und den Titel. Der 3px-border-top der
   * Alt-Welt ist bewusst entfallen (Redesign 2026-08). */
  border-bottom: 1px solid var(--color-border);
  /* Opaker Hintergrund, bewusst OHNE backdrop-filter: position:sticky plus
   * backdrop-filter innerhalb eines overflow:auto-Containers leert auf
   * iOS/WebKit (ab iOS 26) den gesamten Scroll-Container. Der Hinweis stand
   * bis #577 an einem Vier-Modul-Override in glass.css, der mit dem Bleed-Fix
   * entfallen ist — die Warnung gilt weiterhin für jeden Modul-Kopf. */
  background-color: var(--color-bg);
  position: sticky;
  /* Das Andocken der Large-Title-Leiste (Redesign Runde 4, C-1) ist eine Sache
   * der KOMPAKTEN Grössenklasse und steht deshalb in deren Media-Query weiter
   * unten. Hier bleibt die Leiste bei top: 0. */
  top: 0;
  z-index: var(--z-sticky);
  /* Wirkt nur in Flex-Spalten-Pages (Notizen/Kalender), verhindert dort
   * das Stauchen des Kopfes; in Block-Pages ist es ein No-op. */
  flex-shrink: 0;
}

.page-toolbar:has(> .module-seal--head) {
  --seal-head-lead: calc(var(--seal-head-size) + var(--page-toolbar-gap));
}

.page-toolbar__title {
  /* Größe/Weight kommen aus der Canonical-Page-Head-Rolle in typography.css
   * (.page-toolbar__title → --type-toolbar-title, 22px). */
  white-space: nowrap;
  /* min-width:0 allein kürzt bei nowrap NICHT — ohne overflow/text-overflow
   * läuft der Titel in langen Locales („Haushaltsbuch", „Paramètres") aus
   * seiner Box statt sauber zu ellipsieren. */
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}

/* Der angedockte Titel (Shell: `wireCollapsingHeader`). Er gehört allein der
 * kompakten Grössenklasse und dort allein der scrollenden Architektur - ab
 * 1024px trägt der Kopf ohnehin durchgehend seinen Inline-Titel, und in der
 * gedeckelten Architektur klappt der echte ein. Die Sichtbarkeitsregel steht
 * unten im Media-Block; hier steht nur, dass er im Normalfall nicht existiert. */
.page-toolbar__dock-title {
  display: none;
}

.page-toolbar__actions {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  /* Drängt die Aktionen an den rechten Rand, wenn kein wachsender
   * Mittelteil (z.B. Suche mit flex:1) vorhanden ist. */
  margin-left: auto;
  /* Ohne das behält der Slot die max-content-Breite seiner Kinder und schiebt
   * den ganzen Kopf über die Viewport-Kante — im Kalender gemessen (uk, 375px:
   * 431px breit, 72px hinaus). Das Clipping von `main.app-content` macht den
   * Bruch unsichtbar, nicht harmlos. */
  min-width: 0;
}

/* Unter 1024px dockt nichts in den Slot (dockFabIntoToolbar), er bleibt in
 * Modulen mit Tab-Zeile leer - bricht im umbrechenden Kopf aber auf eine
 * eigene Null-Zeile und bringt deren row-gap mit: +8px Kopf in Gesundheit und
 * Belohnungen, gemessen 375px. Das schob "Fasten beenden" unter die
 * Glas-Tab-Leiste (test:fasting-browser, der Tipp traf den Kalender-Tab).
 * `:empty` statt `[hidden]`: ein gefuellter Slot bleibt immer sichtbar
 * (test:hidden-cascade). */
@media (max-width: 1023px) {
  .page-toolbar__actions:empty {
    display: none;
  }
}

/* --------------------------------------------------------
 * Tab-Leiste im Modulkopf (Redesign Runde 6, Phase 0)
 *
 * REGEL: Der Modulkopf ist genau EINE `.page-toolbar`, verdrahtet von der
 * Shell. Kein Modul setzt `--page-toolbar-lead`, keine zweite Titelgrösse und
 * KEINE EIGENE FLEX-RICHTUNG. Eine Tab-Leiste im Kopf ist eine eigene,
 * horizontal scrollende Zeile.
 *
 * WARUM ALS SHELL-REGEL: zwei Module (Haushaltshilfe, Belohnungen) kippten
 * ihren Kopf unter dem Modul-Breakpoint auf `flex-direction: column`, während
 * die Large-Title-Zone `flex-wrap: wrap` und `flex-basis: 100%` setzt. In
 * Spaltenrichtung bedeutet `flex-basis` die HÖHE und `wrap` erzeugt eine zweite
 * SPALTE - der Kopf der Haushaltshilfe ragte bei 375px 79px über die rechte
 * Kante (uk 129px, vi 117px), sichtbar wurde es nur nicht, weil
 * `main.app-content` horizontal clippt. Die Richtung des Kopfes ist damit keine
 * Modulentscheidung mehr.
 *
 * WARUM ÜBER `[role="tablist"]` UND NICHT ÜBER KLASSENNAMEN: die vier Leisten
 * heissen `.housekeeping-tabs`, `.rewards-tabs`, `.cal-toolbar__views` und
 * `.budget-tabs`. Eine Regel über diese Liste gilt für vier Dateien, nicht für
 * die Bauart - und beim fünften Modul fehlt sie. Die ARIA-Rolle ist das
 * Merkmal, das die Leiste zur Leiste macht, und sie ist im Markup ohnehin
 * Pflicht.
 *
 * Guards: Ebene 3 (`test:frontend-audit`, kein Modul-CSS setzt eine
 * Flex-Richtung auf einer Kopf-Klasse) und Ebene 4
 * (`test:document-guards`, Sonde 1: kein Kopf-Nachfahre ragt bei 375px hinaus).
 * -------------------------------------------------------- */
.page-toolbar [role="tablist"] {
  min-width: 0;
  flex-wrap: nowrap;
  overflow-x: auto;
  scrollbar-width: none;
}

.page-toolbar [role="tablist"]::-webkit-scrollbar {
  display: none;
}

/* --------------------------------------------------------
 * Die Bar-Zeile des Modulkopfs (Werkzeugzeilen-Regel, 2026-08-27)
 *
 * Der Kommentar oben versprach seit Runde 6 "eine Tab-Leiste im Kopf ist eine
 * eigene, horizontal scrollende Zeile" - die Regel darunter gab aber nur das
 * Scrollen, nie die Zeile. Jede Modulnavigation quetschte sich deshalb in die
 * Titelzeile: die Budget-Tabs hatten bei 1280px 138px clientWidth fuer 606px
 * Inhalt (1 von 7 Tabs sichtbar), das Kalender-Segment 212px fuer 245px
 * ("Agenda" unsichtbar, das Monatslabel ellipsiert), Gesundheit zeigte mobil
 * 3 von 6, die Haushaltshilfe 3 von 4 Tabs. Die Modulstruktur musste ERINNERT
 * statt gesehen werden (Critique 2026-08-27, P1).
 *
 * `.page-toolbar__bar` ist der vierte Slot des Canonical Page Head: die volle
 * Zeile UNTER Titel/Center/Aktionen, in der die Werkzeug-Leiste des Moduls
 * wohnt. Die Klasse sitzt direkt an einer Pillen-Leiste (budget-tabs,
 * sub-tabs-bar, housekeeping-tabs, rewards-tabs) oder als neutraler Wrapper
 * um einen Segment-Traeger, dessen Well nicht die ganze Zeile faerben darf
 * (Kalender-Views). `order: 4` stellt sie hinter den mobilen Center-Slot
 * (order: 3 in .page-toolbar--wrap); das :has() erspart jedem Kopf das
 * --wrap-Opt-in, sobald er eine Bar-Zeile traegt.
 * -------------------------------------------------------- */
.page-toolbar:has(> .page-toolbar__bar) {
  flex-wrap: wrap;
  row-gap: var(--space-2);
}

.page-toolbar__bar {
  flex-basis: 100%;
  order: 4;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--space-1);
}

/* EIN GEDECKELTER KOPF MIT BAR-ZEILE DECKELT UEBER SEIN PADDING, nicht ueber
 * den ::after-Rest-Slot (siehe .page-toolbar--narrow::after weiter unten):
 * der Slot kann nur EINE Zeile fuellen, und im Wrap-Kopf schwamm er in die
 * Bar-Zeile und schob die Tab-Leiste rechtsbuendig an die Kopf-Kante
 * (Sonde 19: Kopfende 996px statt 720px Lesemass, bei 1960px sogar 1280px).
 * Das Padding deckelt BEIDE Zeilen zugleich - Titelzeile und Bar enden auf
 * derselben Fluchtlinie wie der Koerper -, das Chrome (Grund, Linie, Sticky)
 * bleibt full-bleed, und die ::after-Basis faellt rechnerisch auf 0.
 * Prozent-Padding liest die Breite des Containing Blocks (der Kopf ist
 * full-bleed, also die Shell-Spalte); die Formel zieht deshalb beide
 * Innenabstaende ab, und max() haelt den regulaeren Abstand, wo die Spalte
 * schmaler als das Mass ist.
 *
 * DAS MASS IST DAS DER SEITE, nicht das Lesemass: die erste Fassung rechnete
 * mit `--content-max-width-narrow` und deckelte damit den Kopf der
 * Hauswirtschaft (`data`, 960px) auf 720px - Titelzeile und Reiter endeten
 * 240px vor ihren Karten. Auf einer Seite ohne Mass (`full`/`split`,
 * `--page-measure: 100%`) faellt die Formel auf den regulaeren Abstand zurueck. */
.page-toolbar--narrow:has(> .page-toolbar__bar) {
  padding-inline-end: max(
    var(--page-inline-pad),
    calc(100% - var(--page-measure, var(--content-max-width-narrow)) - var(--page-inline-pad))
  );
}

/* Scroll-Snap der Bar-Leisten: gemeinsame Bauart, vorher Budget-privat. */
.page-toolbar__bar[role="tablist"],
.page-toolbar__bar [role="tablist"] {
  scroll-snap-type: x proximity;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior-x: contain;
}

.page-toolbar__bar .sub-tab {
  scroll-snap-align: start;
}

/* Scroll-Affordanz der Bar-Zeile: wenn sie doch ueberlaeuft (schmale Geraete,
 * lange Locales), zeigt sie den Anschnitt statt ihn zu schlucken - die scharfe
 * Tab-Leisten-Maske steht bei der Masken-Familie in filter-chip.css
 * (.page-toolbar__bar / .sub-tabs-bar, inkl. RTL). Hier nur die engeren
 * Tab-Polster unter 640px, die mehr Tabs in den Viewport lassen (Bauart aus
 * budget.css, Audit P2 - jetzt geteilt, weil dieselbe Leiste in sechs
 * Moduldateien lebt). */
@media (max-width: 639px) {
  .page-toolbar__bar .sub-tab {
    padding: 0 var(--space-2);
  }
}

/* Center-Slot des Canonical Page Head: wachsende Mitte (Suche, Datums-Nav).
 * flex:1 schiebt die Aktionen via deren margin-left:auto an den Rand. Auf
 * schmalen Viewports darf der Kopf umbrechen — der Center-Slot nimmt dann
 * eine eigene Zeile ein (siehe .page-toolbar--wrap). */
.page-toolbar__center {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

/* Opt-in: Kopf darf umbrechen; Center-Slot rutscht auf Zeile 2 unter
 * Titel + Aktionen, sobald der Platz in einer Zeile nicht reicht. */
.page-toolbar--wrap {
  flex-wrap: wrap;
  row-gap: var(--space-2);
}

.page-toolbar--wrap .page-toolbar__center {
  flex-basis: 100%;
  order: 3;
}

/* EIN BAR-KOPF TEILT SEINE TITELZEILE, STATT EINE VIERTE ZU OEFFNEN.
 *
 * `flex-basis: 100%` daueber gibt dem Center-Slot eine eigene Zeile - und zwar
 * unabhaengig davon, wie wenig darin steht. Gemessen am Kalender: nachdem
 * seine fuenf Ebenen-Chips ins Filter-Blatt gezogen waren, blieben im
 * Aktions-Slot 100px uebrig, und der Kopf stand trotzdem unveraendert bei
 * 230,1px auf 390px - 27,3 % der Viewporthoehe, waehrend 258px der Zeile
 * daneben leer waren.
 *
 * `flex-basis: 0` ist dieselbe Antwort, die dieser Kopf ab 1024px schon gibt
 * (Regel weiter unten, mit derselben Begruendung): mit Basis 0 meldet der Slot
 * eine kleine HYPOTHETISCHE Groesse, bleibt damit in der Zeile und waechst per
 * `flex-grow` real in allen Restplatz. Gemessen ueber acht Module bei 390px
 * und 767px: Kalender 230,1 -> 174,1px, Budget 178,1 -> 170,1px, die sechs
 * uebrigen unveraendert, kein horizontaler Overflow.
 *
 * NUR FUER KOEPFE MIT BAR-ZEILE, und das ist keine Vorsicht, sondern die
 * Bedingung: wo keine Bar liegt, ist die Zeile des Centers ohnehin die letzte,
 * und ein Zusammenruecken spart dort nichts, waehrend es Platz kostet.
 *
 * DER PREIS IST GEMESSEN UND BEZAHLT: ohne Gegenmassnahme kappt der engere
 * Slot das Zeitraum-Label auf seine 7ch-Untergrenze („August 2026" auf 75 von
 * 98px).
 *
 * BIS #1200 gab es hier eine Gegenmassnahme: der „Heute"-Knopf in calendar.js
 * gab per `hidden` (= aus dem Fluss entfernt) genau die 66px frei, die dem
 * Label fehlten, sobald der aktuelle Zeitraum stand. Genau dieses Freigeben
 * war der Bug hinter #1200: das Nachbar-Label wuchs in den frei gewordenen
 * Platz, und der naechste/vorherige Pfeil ruckte um die Knopfbreite. Seit
 * `.is-current` (layout.css, `.btn.is-current`) den Slot per `visibility`
 * PERMANENT reserviert, gibt es diese 66px nie mehr frei - der Slot ist auf
 * jedem Zeitraum gleich breit. Das Label hat dadurch dauerhaft 66px weniger
 * Raum als vor #1200, unabhaengig vom Reset-Zustand; das ist der Preis fuer
 * den ruhigen Pfeil und wird hier bewusst in Kauf genommen. Die 7ch-Untergrenze
 * (calendar.css) bleibt ein Sicherheitsnetz fuer eine noch schmalere Kombination
 * aus Locale und Viewport - in den gemessenen Layouts (PR #1200 Review Runde 3)
 * greift sie nirgends, das Label bleibt ueberall breiter als 7ch. */
/* NUR IM AUSGEKLAPPTEN ZUSTAND, und das ist gemessen worden, nicht vermutet.
 *
 * Der eingeklappte Kopf (`is-collapsed`) hat eine ANDERE Zeilenordnung: der
 * Titel steht wieder neben den Slots, und der Rechtsschub der Basisregel gilt.
 * Mit Basis 0 wurden Center und Aktionen dort in eine Zeile gepresst, in die
 * sie nicht passen - und sie brachen nicht um, sie ueberlappten: bei 390px lag
 * der Filterknopf (274-322) auf dem Weiter-Chevron (276-324), bei 320px ragte
 * der Chevron mit r=324 ueber den 320px-Viewport hinaus. Aufgefallen ist es
 * nicht am Aussehen, sondern daran, dass ein Klick auf den Chevron nicht mehr
 * ankam („subtree intercepts pointer events") - eine Ueberlappung, die ein
 * Screenshot nicht zeigt und eine Box-Messung erst, wenn man nach ihr sucht.
 *
 * Die drei Nachbarregeln weiter unten unterscheiden dieselben zwei Zustaende
 * schon; diese hier tat es als einzige nicht. */
@media (max-width: 767px) {
  .page-toolbar--wrap:has(> .page-toolbar__bar) > .page-toolbar__center {
    flex-basis: 0;
    /* `min-content` STATT DES GEERBTEN `min-width: 0` - DAS IST DIE ZEILE, DIE
     * DIE UEBERLAPPUNG VERHINDERT.
     *
     * Mit Basis 0 UND `min-width: 0` darf der Slot schmaler werden als sein
     * eigener Inhalt; die Knoepfe darin schrumpfen aber nicht mit, sondern
     * laufen ueber ihn hinaus. Im eingeklappten Kopf lag der Filterknopf
     * dadurch auf dem Weiter-Chevron (390px: 274-322 ueber 276-324), bei 320px
     * ragte der Chevron mit r=324 ueber den Viewport. Sichtbar wurde es nicht
     * am Aussehen, sondern daran, dass der Klick nicht mehr ankam.
     *
     * `min-content` ist die Breite, unter die der Slot nicht darf. Reicht der
     * Platz nicht, bricht die Flex-ZEILE um - der Kopf wird eine Zeile hoeher,
     * statt seine eigenen Bedienelemente uebereinanderzulegen. Eine Zeile mehr
     * ist ein Preis, eine unklickbare Schaltflaeche ist ein Defekt. */
    min-width: min-content;
  }
}

@media (min-width: 768px) {
  .page-toolbar--wrap .page-toolbar__center {
    flex-basis: auto;
    order: 0;
  }
}

/* --------------------------------------------------------
 * PAGE COMPOSITION SYSTEM (PAGE-COMPOSITION.md)
 *
 * Modules declare a composition mode on `.app-page`; geometry lives here,
 * not in module CSS. See public/utils/page-layout.js for markup helpers.
 * -------------------------------------------------------- */
.app-page {
  display: flex;
  flex-direction: column;
  min-height: 0;
  flex: 1;
  width: 100%;
}

.app-page__body {
  display: flex;
  flex-direction: column;
  min-height: 0;
  flex: 1;
}

/* Kopf-Koerper-Rhythmus gehoert der Seite, nicht dem Modul (docs/PAGE-COMPOSITION.md):
 * Leiste und .app-page__body sind Geschwister auf .app-page, und der Abstand
 * zwischen ihnen wohnt HIER. Als birthdays.css sein `gap` abgab, ersetzte es
 * nichts - `.app-page--* > .app-page__body { gap }` spaciert nur das Innere
 * des Koerpers. Begrenzt auf Wurzeln aus renderAppPage, damit eine Seite, die
 * ihren Abstand noch selbst traegt, ihn nicht doppelt bekommt. */
.app-page:has(> .app-page__body) {
  gap: var(--space-3);
}

.app-page--reading,
.app-page--form,
.page-measure--narrow {
  --page-measure: var(--layout-reading);
}

.app-page--data {
  --page-measure: var(--layout-content);
}

.app-page--dashboard {
  --page-measure: var(--layout-wide);
}

/* OHNE MASS HEISST `100%`, NICHT `none`. Die Variable steht nicht nur in
 * `max-width` (wo `none` ginge), sondern in den Kopf-Formeln von
 * `.page-toolbar--narrow` (`calc(100% - var(--page-measure))`), und in einem
 * calc() ist `none` kein Wert: die ganze Deklaration wird erst beim Rechnen
 * ungueltig und faellt auf `unset` - beim Bar-Padding also auf 0, nicht auf
 * den regulaeren Abstand. Mit `100%` ergeben dieselben Formeln 0 bzw. den
 * Rueckfall, und ein `--narrow`-Kopf ist auf diesen Seiten ein No-op statt
 * ein Fehler. Fuer `max-width` ist `100%` an einem 100%-breiten Block
 * dasselbe wie `none`. */
.app-page--split,
.app-page--full {
  --page-measure: 100%;
}

/* `page.width` from an extension manifest refines the measure INSIDE a
 * measured mode (router.js sets data-page-width on the module's page root).
 * split/full have no measure and are not listed here, so a width declared on
 * them stays without effect instead of capping a page that owns its width. */
.app-page:is(.app-page--reading, .app-page--form, .app-page--data, .app-page--dashboard)[data-page-width="reading"] {
  --page-measure: var(--layout-reading);
}

.app-page:is(.app-page--reading, .app-page--form, .app-page--data, .app-page--dashboard)[data-page-width="content"] {
  --page-measure: var(--layout-content);
}

.app-page:is(.app-page--reading, .app-page--form, .app-page--data, .app-page--dashboard)[data-page-width="wide"] {
  --page-measure: var(--layout-wide);
}

/* Calendar agenda (and similar): temporarily adopt reading measure without
 * switching composition mode declaration (PAGE-001). */
.app-page.is-reading-measure {
  --page-measure: var(--layout-reading);
}

.page-measure {
  box-sizing: border-box;
  width: 100%;
  max-width: var(--page-measure, none);
  /* Left-aligned with the reading/data column (not centered): matches
   * .row-carrier / historical page-measure--narrow behaviour. */
}

/* Der Kopf hat KEIN Rail-Element (docs/PAGE-COMPOSITION.md). Titel, Mitte und
 * Aktionen sind direkte Kinder von .page-toolbar - die Large-Title-Regeln
 * (typography.css) und Siegel/Dock-Titel (`:scope > .page-toolbar__title` in
 * ux.js) haengen daran. Die Kante am Lesemass haelt .page-toolbar--narrow::after
 * (weiter unten), ein Pseudo-Element, damit kein Wrapper noetig ist. Die erste
 * Fassung von #995 hatte hier einen `--measured`-Modifier mit einem Rail-Kind
 * als "echte Flex-Box fuer neue Seiten" - eine Box, die den Titel zum Enkel
 * machte; der Helper baut sie in keiner Kombination mehr (sechste Runde), und
 * ein Guard wird rot, sobald einer der beiden Klassennamen unter public/
 * wieder auftaucht - auch in einem Kommentar wie diesem, deshalb steht er hier
 * nicht ausgeschrieben. */

.app-page--reading > .app-page__body,
.app-page--form > .app-page__body,
.app-page--data > .app-page__body {
  padding-inline: var(--page-inline-pad);
  gap: var(--space-3);
}

.app-page--dashboard > .app-page__body {
  padding-inline: var(--page-inline-pad);
  gap: var(--space-4);
}

.page-section {
  min-width: 0;
}

.page-section--bleed {
  margin-inline: calc(-1 * var(--page-inline-pad));
  padding-inline: var(--page-inline-pad);
}

/* DAS SPLIT-RASTER LIEGT AM KOERPER, NICHT AN DER SEITE. Die erste Fassung
 * machte `.app-page--split` selbst zum Raster - und renderAppPage() legt Kopf
 * und Koerper als Geschwister darunter. Auto-Platzierung setzte dann den Kopf
 * in die linke Spalte und den ganzen Koerper in die rechte, statt den Kopf
 * ueber beide zu spannen und den Koerper zu teilen. Jetzt sind die beiden
 * ersten Kinder von .app-page__body die Rails (Master links auf Lesemass,
 * Detail rechts); der Kopf bleibt eine Zeile darueber. Unter 1024px bleibt der
 * Koerper die Flex-Spalte aus der Basisregel und stapelt. */
/* Und der Koerper traegt das Polster wie in reading/data/dashboard: der Kopf
 * polstert sich ueber .page-toolbar selbst, und ohne dieselbe Kante am Koerper
 * begannen die Rails bei x=0 - gestapelt an der Shell-Kante, im Raster links
 * vom Titel (Codex, dritte Runde an #995). `full` bleibt bewusst ohne: dort
 * gehoert die Kante der Seite (Kalender-Raster, Notizen-Masonry). Das `gap`
 * hier gilt dem Stapel unter 1024px; das Raster darunter setzt seines. */
.app-page--split > .app-page__body {
  padding-inline: var(--page-inline-pad);
  gap: var(--space-3);
}

/* DAS RASTER MISST DIE SEITE, NICHT DEN VIEWPORT - dieselbe Lehre wie in
 * split-expenses.css: neben der ausgeklappten Sidebar (220px) hat die Seite
 * bei 1024px Viewport rund 804px; eine Viewport-Query bei 1024px schaltete
 * dort zweispaltig, und ein Master-Track bis 720px liess dem Detail nach
 * Polster und Luecke ein paar Pixel (Codex, fuenfte Runde an #995). Deshalb
 * ist die Wurzel ein Container, und der Master nimmt hoechstens die Haelfte:
 * bei 768px Seitenbreite je ~350px, ab ~1500px das volle Lesemass links und
 * der Rest rechts. Die Query ist namenlos, damit ein Seiten-CSS, das seinen
 * Container bereits benennt (split-page), nicht mit ihr ringt. */
.app-page--split {
  container-type: inline-size;
}

@container (min-width: 768px) {
  .app-page--split > .app-page__body {
    display: grid;
    grid-template-columns: minmax(0, min(var(--layout-reading), 50%)) minmax(0, 1fr);
    grid-auto-rows: minmax(0, 1fr);
    gap: var(--space-4);
  }
}

/* DIE HOEHE KOMMT DURCH DIE WURZEL, wenn eine Seite sie verspricht. `flex: 1`
 * an .app-page wirkt nur in einem Flex-Elternteil, und .page-transition ist
 * ein Block mit `height: 100%` - die Wurzel blieb inhaltshoch, und der
 * `flex: 1`-Koerper darunter hatte nichts zu fuellen. Kalender und Notizen
 * merkten davon nichts, weil ihr Modul-CSS `height: 100%` selbst setzt; eine
 * Erweiterung darf das nicht (MODULES.md), also stellt `full`/`split` die
 * Shell-Hoehe hier bereit. Nur fuer Helper-Wurzeln, aus demselben Grund wie
 * das `gap` oben: eine Kernseite, die ihre Hoehe noch selbst traegt, soll sie
 * nicht doppelt bekommen. */
.app-page--full:has(> .app-page__body),
.app-page--split:has(> .app-page__body) {
  height: 100%;
}

/* --------------------------------------------------------
 * DIE BREITENREGEL: DREI REGIME (DESIGN.md „Breitenregel", 2026-09-26)
 *
 * Jede Seite hinter der Shell steht in genau EINEM von drei Regimen:
 *
 *   Lesemass       `reading` / `form`      eine Spalte, 720px (--layout-reading)
 *   Liste + Detail `.app-page--list-detail` unter der Schwelle wie Lesemass,
 *                  (bzw. `split`)           ab 75rem Modulflaeche Liste links,
 *                                           Detail der Auswahl rechts
 *   Flaeche        `full` / `dashboard`    die volle Hauptspalte
 *
 * Das fruehere vierte Mass (`data`, 960px - Haushaltshilfe, Inventar) war
 * keine Entscheidung, sondern ein Zwischenstand: es liess bei 1440px 228px
 * leer, ohne dass die Flaeche etwas trug. Keine Kernseite fuehrt es mehr;
 * `.app-page--data` bleibt nur fuer Erweiterungs-Manifeste, die es erklaeren
 * (PAGE-COMPOSITION.md). Guard: PAGE-017 bis PAGE-019 in test-frontend-audit.
 * -------------------------------------------------------- */

/* LISTE + DETAIL (Regime 2) - der geteilte Baustein, utils/master-detail.js.
 *
 * DIE ABFRAGE MISST DIE MODULFLAECHE, NICHT DEN VIEWPORT UND NICHT DIE LISTE.
 * Die Seitenwurzel ist der Container: sie ist so breit wie die Hauptspalte
 * neben der Seitenleiste (1220px bei 1440, 1060px bei 1280, eingeklappt
 * 1224px). Eine Viewport-Abfrage wuesste nichts von der Seitenleiste, und ein
 * Container auf der Liste selbst zoege das Seitenpolster ab. Benannt, damit
 * die unbenannten Abfragen der Module nicht an ihm haengen bleiben.
 *
 * DIE SCHWELLE IST `--layout-split-threshold` (tokens.css). `@container` kann
 * keine Variable lesen; PAGE-019 haelt Literal und Token gleich. */
.app-page--list-detail {
  container: module-surface / inline-size;
}

/* Unter der Schwelle ist der Baustein unsichtbar: die Liste steht, wie sie
 * stand, und die Zeile oeffnet ihr Modal, Sheet oder ihren Aufklapper. Die
 * Huelle ist eine Flex-Spalte, die den Platz des bisherigen Scrollports
 * uebernimmt (`flex: 1; min-height: 0`) - der Port selbst wird zu
 * `.split-view__list` und behaelt seine Klassen. */
.split-view {
  /* Rechte Kante der Detailspalte (siehe dort); ein Baustein in einem schon
   * gepolsterten Koerper setzt sie auf 0. */
  --split-view-end-pad: var(--page-inline-pad);
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
}

.split-view__list {
  min-width: 0;
}

.split-view__detail {
  display: none;
}

@container module-surface (min-width: 75rem) {
  /* Ab der Schwelle ist die Seite eine zweispaltige Flaeche: ihr Kopf laeuft
   * ueber beide Spalten (wie die Werkzeugleiste in Mail), und alles, was sonst
   * am Lesemass endet, endet jetzt an seiner Spalte. `100%` statt `none` aus
   * dem Grund an `.app-page--full` (calc im Kopf). */
  .app-page--list-detail > * {
    --page-measure: 100%;
  }

  /* DIE SPALTEN FLUCHTEN MIT DEM KOPF. Der Kopf zieht seinen Inhalt ueber
   * `--page-inline-pad` auf die zentrierte Content-Spalte (1280px); ab einer
   * Modulflaeche ueber 1280 + 2 x Gutter liefe der Baustein sonst voll, und bei
   * 1920 begann die Liste 178px links vom Siegel und das Detail endete 178px
   * rechts der Aktionen. Gedeckelt auf Content-Spalte plus die beiden Gutter,
   * die Liste und Detail selbst als Polster tragen: dann loest
   * `--page-inline-pad` darin zum Gutter auf, und beide Kanten stehen unter dem
   * Kopf. Darunter (1440: 1220px Flaeche) greift die Deckelung nicht. Seiten,
   * deren Koerper schon polstert (Aufgaben), sind schmaler als der Deckel. */
  .split-view {
    display: grid;
    grid-template-columns:
      clamp(var(--layout-list-min), 40%, var(--layout-list-max))
      minmax(0, 1fr);
    grid-template-rows: minmax(0, 1fr);
    column-gap: var(--space-6);
    inline-size: 100%;
    max-inline-size: calc(var(--content-max-width) + 2 * var(--page-gutter));
    margin-inline: auto;
  }

  /* Beide Spalten scrollen fuer sich - das Detail bleibt stehen, waehrend man
   * durch die Liste geht. Voraussetzung: `.split-view` steht in einer
   * Flex-Spalte mit fester Hoehe (Seiten mit eigenem Scrollport; Rezeptkarte
   * in master-detail.js). */
  .split-view__list {
    min-height: 0;
    overflow-y: auto;
  }

  .split-view__detail {
    display: flex;
    flex-direction: column;
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
    background-color: var(--color-surface);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-sm);
    margin-block-end: var(--space-4);
    /* Die Spalte endet am Seitenpolster wie der Kopf darueber. Der Baustein
     * steht an der Stelle des Scrollports, und der ist full-bleed und polstert
     * innen; wer ihn in einen schon gepolsterten Koerper setzt, stellt
     * `--split-view-end-pad: 0` (gesetzt an `.split-view`). */
    margin-inline-end: var(--split-view-end-pad);
  }

  .split-view__detail:focus-visible {
    outline-offset: var(--focus-ring-offset-inset);
  }

  /* Die ausgewaehlte Zeile: dieselbe Flaeche wie die Aktiv-Pille der
   * Seitenleiste - „das steht rechts". Nur in der Spaltenform; darunter gibt
   * es keine Auswahl, nur ein Oeffnen. */
  .split-view [data-md-id].is-selected {
    background-color: color-mix(in srgb, var(--color-accent) var(--tint-state), var(--color-surface));
  }
}

/* Leerzustand: ruhig, mittig, ohne Aktion - er erklaert nur die Spalte. */
.split-view__empty {
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-8) var(--space-6);
}

.split-view__empty[hidden],
.split-view__detail-body[hidden] {
  display: none;
}

.split-view__detail-body {
  display: flex;
  flex-direction: column;
  min-height: 0;
}

/* Der eigene Kopf der Detailspalte: Titel links, Aktionen rechts. Klebt oben,
 * waehrend das Detail darunter scrollt. */
.split-view__detail-head {
  position: sticky;
  top: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-height: calc(var(--target-md) + (2 * var(--space-3)));
  padding: var(--space-3) var(--space-5);
  background-color: var(--color-surface);
  border-block-end: 1px solid var(--color-border-subtle);
}

.split-view__detail-title {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  font-size: var(--text-lg);
  font-weight: var(--font-weight-semibold);
  color: var(--color-text-primary);
  overflow-wrap: anywhere;
}

.split-view__detail-actions {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-shrink: 0;
}

.split-view__detail-footer {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  padding: var(--space-4) var(--space-5) var(--space-5);
}

/* --------------------------------------------------------
 * DAS LESEMASS IST EINE EIGENSCHAFT DER SEITE, NICHT DES TRAEGERS
 * (Critique 2026-08-13, zweite Runde)
 *
 * Es hing an `.list-rows` und `.row-carrier`, also an der untersten Schicht.
 * Damit galt es fuer den Koerper und fuer nichts sonst: gemessen bei 1440px lag
 * die Filterreihe der Kontakte 489px, der Gruppierungsschalter der Aufgaben
 * 434px und die Monatsnavigation des Budgets 436px rechts neben der Liste, auf
 * die sie wirken. Sieben von zehn Routen ohne gemeinsame rechte Kante zwischen
 * Kopf und Koerper - und der Fix davor hatte genau eine Schicht erreicht, die
 * angedockte Primaeraktion.
 *
 * WARUM EINE VARIABLE UND KEIN `max-width` AM ROOT: der Kopf ist absichtlich
 * full-bleed. Hintergrund, Trennlinie und Sticky-Flaeche der `.page-toolbar`
 * laufen bis zur Shell-Kante, nur die ZEILE darin wird kuerzer (siehe
 * `.page-toolbar--narrow` unten). Ein `max-width` am Modul-Root haette diese
 * Flaechen mitgekappt und aus dem Rail eine schwebende Karte gemacht.
 *
 * Die Rolle steht deshalb an EINER Stelle - dem Modul-Root - und jede Schicht,
 * die mitfluchten muss, liest dieselbe Variable. Wer sie nicht liest, faellt auf
 * `none` zurueck und ist damit ausdruecklich full-bleed, nicht versehentlich.
 * -------------------------------------------------------- */

/* Die Bedienzeilen und Inhaltsbloecke einer solchen Seite. Sie stehen zwischen
 * Kopf und Koerper oder ueber ihm und wirken auf ihn, also enden sie mit ihm.
 * `justify-self`/`margin` bleiben unberuehrt: gekappt wird die Breite, nicht
 * die Ausrichtung.
 *
 * DIESE LISTE IST EINE LISTE UND BLEIBT EINE - ABER SIE DRIFTET NICHT MEHR
 * STILL. Der Commit, der das Lesemass vom Traeger auf die Seite hob, hat es auf
 * eine Namensliste gehoben, und was nicht darin stand, war full-bleed durch
 * Auslassung statt durch Entscheidung: gemessen bei 1440px endete auf
 * `/rewards` die Anfragenliste bei 1408 und der Traeger direkt darunter bei
 * 972, auf `/contacts` der Gruppenkopf bei 1408 ueber seiner eigenen Liste bei
 * 972, auf `/budget` der Diagrammabschnitt bei 1408 ueber der Buchungsliste bei
 * 972 (Critique 2026-08-13). Vier Nachtraege unten schliessen die gemessenen
 * Faelle: `/contacts`, `/rewards`, `/budget` und `/housekeeping` haben danach
 * EINE Inhaltskante.
 *
 * WAS BEWUSST BREIT BLEIBT: das Kennzahlband (`.metric-card`) auf Budget und
 * Hauswirtschaft ist ein Band ueber der Seite, kein Lesestoff, und die
 * Gesundheitspanele fuehren ihr eigenes Raster.
 *
 * EIN WAECHTER FEHLT HIER WEITER, UND ZWAR WISSENTLICH. Der Versuch, die
 * rechten Kanten am gerenderten Dokument zu messen, scheiterte an der
 * Definition von "Block": die Sonde zaehlte `.swipe-reveal` und
 * `.quick-add__input` mit, und auf `/shopping` kippte die Mehrheitskante
 * dadurch auf die Zeileninterna - der echte Koerper wurde zum Ausreisser
 * gemeldet. Ein Guard, den man mit Ausnahmen ruhigstellt, bis er nichts mehr
 * bedeutet, ist schlimmer als keiner; dieses Repo hat den Fehler mehrfach
 * gemacht. Was er braucht, ist ein Begriff von "Seitenblock", der Zeileninterna
 * strukturell ausschliesst - das ist eigene Arbeit, kein Anhang an einen Fix.
 * Bis dahin bleibt diese Liste eine Liste, die man von Hand pflegt. */
/* NICHT in diese Liste gehoert ein Element, das sich selbst horizontal mit
 * `--page-inline-pad` polstert: bei `box-sizing: border-box` zieht dieses
 * Polster von der Kappung ab statt zu ihr zu addieren. Gemessen bei 1907px
 * blieben von 720px genau 313px fuer die Chips uebrig, waehrend der Koerper
 * darunter die vollen 720px fuehrte - die Reiter brachen mitten im Namen ab
 * (#758). Die beiden Faelle von damals (`.list-tabs-bar`, `.contacts-filters`)
 * polstern seit der Kopfregel mobil (2026-09-26) nicht mehr selbst: die
 * Listenleiste steht im __center-Slot des Einkaufskopfs, die Kontakt-Chips im
 * Port, und Kontakte kappt seine Reihe in contacts.css. Wer hier ein Element
 * eintraegt, das anderswo horizontal polstert, kappt es stattdessen in seiner
 * eigenen Datei und rechnet das Polster ein. Guard: `die Lesemass-Liste kappt
 * kein selbstpolsterndes Element` in test/test-frontend-audit.js - er liest
 * beide Seiten aus dem Stylesheet und findet die Ueberschneidung von allein. */
.page-measure--narrow :is(
  .rewards-tabs,
  /* Die Karten der Hauswirtschaft, deren Zeilen per Bleed an ihrer Kante
   * fluchten: die Liste selbst hat keine eigene Flaeche, die Karte hat sie,
   * also traegt die Karte das Mass. */
  .housekeeping-card,
  /* Ein Gruppenkopf endet mit der Gruppe, die er benennt. */
  .contact-group__header,
  /* Der Traeger der offenen Anfragen, direkt ueber dem Traeger der
   * Punktestaende - zwei gestapelte Bloecke auf einem Bildschirm. */
  .rw-pending-panel,
  /* Der Diagrammabschnitt steht ueber der Buchungsliste und ist selbst eine
   * Zeilenfolge (Kategoriezeilen), also gilt dasselbe Mass. */
  .budget-chart-section,
  /* Kennzahlenband teilt die Lesebahn mit Kopf und Liste (PAGE-002). */
  .metric-grid,
  /* Die Mitarbeiterleiste ueber den Karten der Hauswirtschaft. */
  .housekeeping-worker-strip
),
.app-page :is(
  .rewards-tabs,
  .housekeeping-card,
  .contact-group__header,
  .rw-pending-panel,
  .budget-chart-section,
  .metric-grid,
  .housekeeping-worker-strip
) {
  max-width: var(--page-measure, none);
}

/* Der Monatsstepper des Budgets sitzt im `page-toolbar__center` und ist damit
 * nicht `:last-child` - der `--narrow`-Modifier erreicht ihn nicht, und ein
 * blosses `max-width` auch nicht: er ist schmaler als das Lesemass und steht
 * ZENTRIERT in der vollen Kopfzeile, also weiter rechts als die Liste endet
 * (gemessen 436px daneben, mit Kappung immer noch 113px). Er braucht dieselbe
 * Marge wie das letzte Kind, damit sein Bezugsrahmen die Lesebahn wird statt
 * der Rail. */
.page-measure--narrow .page-toolbar .budget-nav__month {
  margin-inline-end: max(0px, calc(100% - var(--page-measure, var(--layout-reading))));
}

/* Opt-in: der Kopf sitzt UNTER einer Tab-Gruppenleiste (.sub-tabs-bar).
 *
 * Historisch gab die Variante genau eine Eigenschaft ab: den 3px-Akzentstreifen,
 * den die Gruppenleiste schon trug (Doppelstreifen aus Issue #577). Seit dem
 * HIG-Rollout trägt weder `.page-toolbar` noch `.kitchen-tabs-bar` einen
 * Streifen - die Deklaration ist ein No-op und bleibt nur als benannter Ort,
 * falls die Gruppen-Variante wieder etwas abzugeben hat. Der Wert der Klasse
 * liegt heute in ihrem zweiten Teil: dem `top`-Hinweis unten.
 *
 * Das `top` NICHT verschieben. Naheliegend wäre, den Kopf um die Höhe der
 * Gruppenleiste nach unten zu versetzen, damit er „darunter" klebt. Das
 * unterstellt aber, der Scrollport sei das Viewport. In den Küchen-Tabs ist der
 * Modul-Root eine Flex-Spalte mit `overflow: hidden` und damit SELBST der
 * Scrollport; die Gruppenleiste liegt außerhalb davon. Ein `top: 56px` schob
 * den Kopf dort 56px nach UNTEN über den folgenden Inhalt - im Vorrat lag er
 * über der Filter-Chip-Leiste, im Essensplan über der ersten Slot-Reihe
 * (gemessen 2026-07-29). Innerhalb eines nicht scrollenden Roots ist `top: 0`
 * korrekt, und die Leiste darüber kann den Kopf ohnehin nicht überlaufen.
 *
 * Nutzer: die vier Küchen-Tabs. Für health-tabs.js ebenso verwendbar. */
.page-toolbar--in-group {
  border-top: none;
}

/* Opt-in: der Kopf-INHALT endet dort, wo der Körper endet - beim schmalen
 * Lesemaß (--content-max-width-narrow) statt an der Content-Spalte.
 *
 * Nutzer: JEDER Kopf über einer Zeilenliste. Zuerst waren es die drei
 * Küchen-Listen (Rezepte, Einkaufen, Vorrat), deren Körper über .list-scroller
 * auf 720px gekappt ist, während ihr Kopf bis an die Content-Spalte lief:
 * gemessen bei 1280px Fensterbreite endete die Liste bei x=972, der
 * Lagerort-Knopf im Vorrat und die drei Listen-Aktionen im Einkauf aber bei
 * x=1248 - 276px über leerer Fläche. DESIGN.md macht die gemeinsame Fluchtlinie
 * zur Bedingung, und zwar für BEIDE Kanten.
 *
 * Seit `.row-carrier` dasselbe Lesemaß trägt wie `.list-rows` (list-row.css,
 * Critique 2026-08-13), gilt derselbe Satz für Aufgaben, Kontakte, Budget,
 * Geburtstage und die Hauswirtschaft - dort war der Versatz sogar größer:
 * auf 1440px endete die Aufgabenliste bei x=972 und „+ Aufgabe" begann bei
 * x=1297, also 325px daneben. NICHT gesetzt wird der Modifier bei Köpfen, die
 * sich eine Fläche mit einer BREITEN Ansicht teilen (Kalender: Monatsgitter;
 * Dokumente: Kachelansicht) - dort wäre die schmale Flucht die falsche.
 *
 * WARUM NICHT `> * { max-width: ... }`: genau so stand die Regel bisher, je
 * einmal in shopping.css und pantry.css. Sie griff nie. Gekappt wurde die
 * Breite jedes Slots - die Slots sind aber schmaler als das Lesemaß (im Vorrat
 * misst der Aktions-Slot 40px), und `.page-toolbar__actions { margin-left:
 * auto }` schob sie danach unverändert an die äußere Kante. Das Maß ist nicht
 * die Breite der Slots, sondern das ENDE der Zeile.
 *
 * Das Prozent löst gegen die Content-Box des Kopfes auf und wandert damit von
 * selbst mit der Fensterbreite mit; `max()` nimmt den Abstand zurück, sobald die
 * Spalte schmaler als das Lesemaß ist. Deshalb ohne Breakpoint - die
 * Vorgängerregeln standen in `@media (min-width: 1024px)` und ließen den
 * Versatz zwischen 720px und 1024px stehen, wo der Körper längst gekappt ist.
 *
 * Der Rail bleibt full-bleed: Hintergrund, Trennlinie und Sticky-Fläche laufen
 * weiter bis zur Shell-Kante, nur die Zeile darin wird kürzer. */

/* DER ABSTAND IST EIN SLOT, KEINE MARGE - WEIL ER NACHGEBEN MUSS.
 *
 * Als `margin-inline-end` am letzten Slot war er unnachgiebig und zaehlte
 * trotzdem in die ZEILENBELEGUNG des Flex-Containers. Gemessen bei 1960px
 * Fensterbreite: Kopfzeile innen 1280px, Aktions-Slot 406px Inhalt plus 560px
 * Marge, verbleibend fuer Siegel, Titel und Suche 315px, gebraucht 441px. Der
 * Umbruch war damit rechnerisch unvermeidlich statt vom Inhalt abhaengig -
 * Aufgaben, Kontakte, Budget und die Kalender-Agenda bauten bei JEDER
 * Fensterbreite zwei Zeilen (#882).
 *
 * Als schrumpfbares Flex-Item gibt derselbe Abstand nach, sobald der Inhalt ihn
 * braucht: `flex-shrink` verteilt den Mangel im Verhaeltnis der Basen, und mit
 * 560px hat dieser Slot die groesste - er gibt zuerst und am meisten ab. Ist
 * Platz da, steht er voll und die Aktionen enden exakt am Lesemass, wie zuvor.
 * Ist keiner da, weicht er ganz, und die Aktionen stehen am Rail-Rand - das ist
 * der richtige Notausgang, denn die Alternative war ein gekuerzter Seitentitel
 * ("Budget" auf 8px gemessen), und der Titel ist das Letzte, was gehen darf.
 *
 * KEIN ZUSAETZLICHES ELEMENT IM MARKUP: der Slot ist ein Pseudo-Element, damit
 * die Zeilenordnung eine Entscheidung der Shell bleibt und kein Modul ihn
 * vergessen oder falsch einsortieren kann. */
.page-toolbar--narrow::after {
  content: '';
  /* UNNACHGIEBIG, UND DAS IST DER PUNKT - gemessen an beiden Fassungen. Mit
   * `flex-shrink` gab dieser Slot als erster nach, weil er die groesste Basis
   * traegt; der Kopf blieb einzeilig, aber sein Ende lief dem Koerper um 69 bis
   * 87px davon (je Modul gemessen, Koerperkante 720px). Damit waere die eine
   * Invariante gegen die andere getauscht: die gemeinsame Fluchtlinie ist der
   * GRUND, aus dem es diesen Abstand gibt. Er steht deshalb, und der Mangel
   * landet bei den Slots, die dafuer gebaut sind - Suchfeld und Tab-Leisten
   * geben nach, der Seitentitel zuletzt (siehe unten). */
  flex: 0 0 max(0px, calc(100% - var(--page-measure, var(--layout-reading))));
  min-width: 0;
  /* DEN EIGENEN ABSTAND ZURUECKNEHMEN. Als Marge am letzten Slot lag der
   * Rueckhalt INNERHALB von dessen Box und beruehrte den `gap` der Leiste nie.
   * Als eigenes Flex-Item bekommt er einen davor - und der schoebe die Aktionen
   * um genau eine Luecke vor die Koerperkante (gemessen 712 statt 720px, bei
   * <=640px 4px). Der negative Rand hebt ihn auf und wirkt auch dort noch, wo
   * `max()` die Basis auf 0 klemmt: sonst verloere die schmale Spalte eine
   * Luecke Zeilenbreite, die sie vorher nicht verlor. */
  margin-inline-start: calc(-1 * var(--page-toolbar-gap));
}

/* IN DER REGULAEREN GROESSENKLASSE BRICHT DER KOPF NICHT UM.
 *
 * Umbrechen und Nachgeben sind zwei Antworten auf dieselbe Enge, und wer beide
 * gibt, gibt keine: Flexbox teilt die Zeilen nach den HYPOTHETISCHEN Groessen
 * auf, also bevor irgendein Slot geschrumpft ist. Ein Kopf mit `wrap` bricht
 * deshalb um, waehrend daneben Slots stehen, die bereitwillig Platz abgegeben
 * haetten - im Kalender die Ansichts-Umschalter und die Ebenen-Chips, in den
 * gedeckelten Koepfen der Lesemass-Slot darueber, der dort sogar in voller
 * Breite auf Zeile zwei gewandert waere (#882).
 *
 * Dass die Slots das koennen, ist die Bedingung dieser Regel und keine Annahme:
 * `__title`, `__center` und `__actions` tragen `min-width: 0`, die Tab-Leisten
 * `overflow-x: auto`, und der Lesemass-Slot gibt zuerst und ganz nach.
 *
 * DIE GRENZE IST DIE GROESSENKLASSE, KEINE LAYOUT-ZAHL. Unterhalb von 1024px
 * regiert die Large-Title-Zone (die Regel weiter unten), und die setzt den
 * Umbruch selbst - dort steht der Titel bewusst auf einer eigenen Zeile. */
@media (min-width: 1024px) {
  .page-toolbar--wrap {
    flex-wrap: nowrap;
  }

  /* DER SEITENTITEL GIBT ZULETZT NACH.
   *
   * In einer Zeile, die nicht mehr umbricht, verteilt Flexbox den Mangel auf
   * alle Slots - und der Titel gab mit ab, obwohl er der einzige ist, der nicht
   * zurueckkann: eine Tab-Leiste scrollt (`overflow-x: auto`), ein Suchfeld
   * ist an seinem Symbol erkennbar, ein ellipsierter Seitentitel ist nur noch
   * Rest ("Budget" auf 45px gemessen). Mit `flex-shrink: 0` steht er, und der
   * Mangel landet dort, wo er getragen werden kann.
   *
   * NUR IN DER ZEILE, DIE NICHT MEHR UMBRICHT, und das ist gemessen: auf JEDEN
   * Kopf gesetzt lief der Gesundheitskopf bei 1024px um 64px ueber die Kante.
   * Der bricht nie um und gibt seinen Platz anderswo her; er braucht den Titel
   * als Reserve. Die Haerte gehoert dorthin, wo der Umbruch abgeschaltet ist.
   *
   * Nur ab 1024px: darunter steht der Titel als Large Title auf einer eigenen
   * Zeile und teilt sie mit niemandem - dort gibt es nichts zu verteilen. */
  .page-toolbar--wrap > .page-toolbar__title {
    flex-shrink: 0;
  }

  /* IM BAR-KOPF BLEIBT DER KOPF WRAP (die Bar ist seine zweite Zeile) - die
   * TITELZEILE darf trotzdem nicht brechen. Flexbox teilt Zeilen nach den
   * HYPOTHETISCHEN Groessen (siehe oben), und der Center-Slot (Monats-/
   * Datums-Navigation) meldet als einziger eine grosse: mit Basis 0 bleibt
   * er hypothetisch klein, waechst real per flex-grow in allen Restplatz der
   * Zeile, und die Titelzeile bleibt die eine Zeile, die #882 verlangt
   * (gemessen am Kalender: 1049px hypothetisch gegen 996px Innenraum bei
   * 1280 - die Aktionen brachen unter das Datum). Das Label-Verschwinden von
   * damals („flex:1 hiess Basis 0", #882) betraf ein Label in einer Zeile
   * OHNE freien Raum; hier ist der Center der einzige Wachser seiner Zeile.
   * Bei 1024px darf der Kopf weiter brechen (Sonde 19, rows: false) - dort
   * ist der Platzmangel echt. */
  .page-toolbar:has(> .page-toolbar__bar) > .page-toolbar__center {
    flex-basis: 0;
  }
}


/* --------------------------------------------------------
 * Large-Title-Zone des Modulkopfes (HIG, Redesign Runde 4 / C-1)
 *
 * In der KOMPAKTEN Grössenklasse - der Welt mit Tab-Bar unten, unter 1024px -
 * steht der Seitentitel als Large Title auf einer eigenen Zeile über der
 * Bar-Zeile. Beim Scrollen wandert er aus dem Scrollport, die Bar-Zeile bleibt
 * (siehe das negative `top` an der Basisregel). Ab 1024px regiert die Sidebar;
 * dort ist der Titel Teil der Leiste und bleibt beim Inline-Schnitt, genau wie
 * Apples reguläre Grössenklasse es hält.
 *
 * WER: jeder Kopf mit eigenem Seitentitel. Die Gruppen-Variante ist
 * ausgenommen - ihr `.page-toolbar__title` ist kein Seitentitel, sondern der
 * umbenennbare Name der gerade offenen Liste (Einkauf), und über ihr steht
 * bereits die Tab-Leiste des Moduls.
 *
 * WARUM ALS REGEL statt als Modul-Opt-in: der Kopf ist die einzige Komponente,
 * die alle 17 Module teilen. Eine Klasse, die jedes Modul selbst setzen müsste,
 * fehlt beim achtzehnten.
 * -------------------------------------------------------- */
@media (max-width: 1023px) {
  .page-toolbar:not(.page-toolbar--in-group):has(> .page-toolbar__title) {
    flex-wrap: wrap;
    row-gap: var(--space-2);
    /* Large-Title-Rang des Absender-Siegels - die Entsprechung zu den 34px,
     * die typography.css dem Titel in genau diesem Zustand gibt. */
    --seal-head-size: var(--target-sm);
    --seal-head-icon: var(--icon-lg);
  }

  /* Im eingeklappten Zustand faellt der Titel auf den Inline-Schnitt zurueck
   * (typography.css) - das Siegel mit ihm. Gleiche Spezifitaet wie die Regel
   * darueber, entschieden durch die Quellreihenfolge; genau so steht das
   * Paar auch in typography.css. */
  .page-toolbar--capped.is-collapsed:has(> .page-toolbar__title) {
    --seal-head-size: var(--space-6);
    --seal-head-icon: var(--icon-md);
  }

  /* Der Kopf klebt nicht mit seiner ganzen Höhe, sondern erst, wenn seine
   * oberen Zeilen aus dem Scrollport gewandert sind: `--page-toolbar-lead` ist
   * genau deren Höhe, gemessen von `wireCollapsingHeader` (utils/ux.js). Übrig
   * bleibt die Bar-Zeile - Apples Navigationsleiste, in die der Large Title
   * hineinscrollt.
   *
   * NEGATIVES top STATT KLASSEN-UMSCHALTER: eine Klasse, die beim Scrollen
   * Zeilen ausblendet, ändert die Höhe eines Elements im Fluss; der Inhalt
   * darunter rutscht nach, der Scroll-Offset verschiebt sich und der Kopf
   * oszilliert um seine eigene Schwelle. Hier ändert sich die Höhe nie.
   * Ohne Lead (einzeiliger Kopf) ist der Wert 0 und die Leiste klebt bei 0. */
  .page-toolbar--stacked {
    top: calc(-1 * var(--page-toolbar-lead, 0px));
  }

  /* In der gedeckelten Architektur wandert der Kopf nicht; dort klappt er ein
   * (siehe unten) und muss bei 0 kleben bleiben. */
  .page-toolbar--capped {
    top: 0;
  }

  /* Nur der UMBRUCH steht hier. Welche Schriftgrösse die beiden Zustände der
   * Zone tragen, entscheidet die Canonical-Page-Head-Rolle in typography.css -
   * dort liegen alle Titel-Schnitte an EINEM Ort, und ein Guard hält das
   * (test:typography, „globale Toolbar- und Kartentitel folgen den
   * semantischen Rollen"). */
  .page-toolbar:not(.page-toolbar--in-group) > .page-toolbar__title {
    /* Die ganze Zeile - abzueglich dessen, was das Absender-Siegel davor
     * belegt. `100%` allein haette den Titel unter das Siegel geschoben und
     * ihm eine eigene Zeile gegeben: die Lead-Zone waere um eine Zeile
     * gewachsen und das Siegel stuende allein ueber dem Titel. */
    flex-basis: calc(100% - var(--seal-head-lead));
    /* NOWRAP BLEIBT, DIE ELLIPSE DER BASISREGEL IST DIE ANTWORT - gemessen,
     * nicht geraten (Runde 6, Phase 0). Bei 375px stehen 343px zur Verfügung;
     * von 360 Paaren aus 24 Locales und 15 Modultiteln reisst GENAU EINES die
     * Zeile: `id`/Haushaltshilfe („Asisten Rumah Tangga", 351px, 8px zu breit).
     *
     * Umbrechen wäre für diesen einen Fall der teurere Weg: die kollabierende
     * Leiste misst `--page-toolbar-lead` EINMAL, und ein Titel, der je nach
     * Sprache ein- oder zweizeilig ist, verschiebt die Schwelle, an der der
     * Kopf andockt. Ein höhenstabiler Kopf ist die Voraussetzung von C-1; die
     * Ellipse kostet in einer Sprache acht Pixel Text, den die Navigation
     * ohnehin ein zweites Mal führt. */
  }

  /* Steht die Tab-Leiste direkt im Kopf, ist sie die Bar-Zeile UNTER dem Large
   * Title - dieselbe Rollenverteilung wie bei Titel + Aktionen, nur dass die
   * Leiste die ganze Zeile führt. Ihr `margin-inline-start: auto`
   * (Belohnungen) und ihr `justify-content: flex-end` (Haushaltshilfe) waren
   * richtig, solange sie sich die Zeile mit dem Titel teilte, und sind falsch,
   * sobald sie eine eigene hat. Die Zeilenordnung entscheidet die Shell. */
  .page-toolbar:not(.page-toolbar--in-group):has(> .page-toolbar__title) > [role="tablist"] {
    flex-basis: 100%;
    margin-inline-start: 0;
    justify-content: flex-start;
  }

  /* Der Center-Slot (Suche, Zeitraum-Navigation) gehört zur Lead-Zone und
   * scrollt mit weg. Die --wrap-Variante schob ihn bisher UNTER die Aktionen;
   * mit eigener Titelzeile ist die DOM-Reihenfolge die richtige: Titel, dann
   * Kontext, dann Bar-Zeile. */
  .page-toolbar--wrap:not(.page-toolbar--in-group):has(> .page-toolbar__title) > .page-toolbar__center {
    order: 0;
  }

  /* Das `margin-left: auto` der Basisregel drängt die Aktionen an den rechten
   * Rand - richtig, solange sie sich die Zeile mit dem Titel teilen. Steht der
   * Titel auf eigener Zeile, hat die Bar-Zeile nur noch diesen einen Slot, und
   * der Rechtsschub liess das Segment mit einer leeren halben Zeile links
   * daneben stehen. Ohne Titel in der Zeile beginnt sie an der Fluchtlinie.
   * Im eingeklappten Zustand steht der Titel wieder daneben - dort gilt der
   * Rechtsschub der Basisregel weiter.
   *
   * Dasselbe für den Center-Slot und die geteilte Suche: Notizen schob sie mit
   * demselben Auto-Rand ans Zeilenende und deckelte sie auf 280px - beides
   * richtig, solange sie sich die Zeile mit dem Titel teilte, beides falsch,
   * sobald sie eine eigene hat. Die Zeilenordnung ist eine Entscheidung der
   * Shell, nicht des Moduls; also nimmt die Shell sie zurück. */
  .page-toolbar--stacked:not(.is-collapsed) > .page-toolbar__actions,
  .page-toolbar--stacked:not(.is-collapsed) > .page-toolbar__center,
  .page-toolbar--stacked:not(.is-collapsed) > .page-search {
    margin-inline-start: 0;
  }

  .page-toolbar--stacked:not(.is-collapsed) > .page-search {
    max-width: none;
  }

  /* DER ANGEDOCKTE TITEL beantwortet in der scrollenden Architektur das „wo bin
   * ich", das der weggewanderte Large Title nicht mehr beantwortet. Er erscheint
   * mit derselben Schwelle wie die Trennlinie darunter.
   *
   * `flex: 1 1 0` IST DIE BEDINGUNG, UNTER DER ER UNGEFÄHRLICH IST: mit
   * Basis 0 passt er in jede Zeile, wächst nur in den Platz, den die Aktionen
   * übrig lassen (auf 375px gemessen 127px), und kann die Bar-Zeile damit nie
   * umbrechen. Ein Umbruch wäre eine Höhenänderung, und die ist genau das, was
   * das negative `top` oben vermeidet. Die Ellipse kürzt statt umzubrechen -
   * dieselbe Antwort wie beim Large Title selbst. */
  .page-toolbar--stacked.is-docked > .page-toolbar__dock-title {
    display: block;
    flex: 1 1 0;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* Die Trennlinie erscheint erst, wenn die Leiste angedockt ist - am
   * Scroll-Anfang steht der Large Title nahtlos auf dem Seitengrund.
   * `--stacked` setzt der Helfer nur, wenn es überhaupt eine Lead-Zone gibt;
   * ein einzeiliger Kopf behält seine Linie durchgehend. */
  .page-toolbar--stacked:not(.is-docked) {
    border-bottom-color: transparent;
  }

  /* GEDECKELTE ARCHITEKTUR (Budget, Kalender, Notizen, Kontakte): dort ist der
   * Modul-Root `overflow: hidden` und eine innere Liste scrollt - der Kopf
   * liegt ausserhalb des Scrollports und bewegt sich nie, ein negatives `top`
   * bliebe wirkungslos. Hier klappt die Large-Title-Zeile beim Scrollen
   * tatsächlich ein, und das ist genau hier gefahrlos: der Höhenwechsel
   * verlängert nur den inneren Port, ohne dessen Scroll-Offset anzufassen (in
   * der scrollenden Architektur wäre er die Quelle einer Oszillation).
   *
   * WAS KOLLABIERT, ENTSCHEIDET DER INHALT DES SLOTS - dieselbe Abgrenzung wie
   * beim Kopf zwischen Bereich und Gruppe:
   *   ZEITRAUM der Seite (Monat im Budget, Datum im Kalender) → bleibt stehen.
   *     Er beantwortet beim Scrollen weiter „wo bin ich".
   *   SUCHE (Kontakte) → klappt mit ein, genau wie sie in der scrollenden
   *     Architektur aus dem Bild wandert (Apples `hidesSearchBarWhenScrolling`).
   *     Nicht aber, solange sie den Fokus hat oder einen Wert trägt - eine
   *     Suche, die einem beim Scrollen der eigenen Treffer unter den Händen
   *     verschwindet, wäre der falsche Gehorsam gegenüber der Regel. */
  .page-toolbar--capped.is-collapsed > .page-toolbar__title {
    flex-basis: auto;
  }

  .page-toolbar--capped.is-collapsed
    > .page-search.page-toolbar__center:not(:focus-within):not(:has(input:not(:placeholder-shown))) {
    display: none;
  }

  .page-toolbar--capped > .page-toolbar__title {
    transition: font-size var(--duration-sm) var(--ease-out);
  }
}

@media (max-width: 1023px) and (prefers-reduced-motion: reduce) {
  .page-toolbar--capped > .page-toolbar__title {
    transition: none;
  }
}

/* EIN KOPF MIT ZEITRAUM KLAPPT DIE TITELZEILE GANZ EIN (Kalender-Critique
 * 2026-09-24, P1; Budget-Critique 2026-09-25; DESIGN.md, Die Chrome-Regel).
 *
 * `--period` traegt ein Kopf, dessen Center-Slot den Zeitraum der Seite fuehrt
 * (Kalender: Tag/Woche/Monat, Budget: Monat). Dort beantwortet der Zeitraum
 * mit Vor/Zurueck beim Scrollen weiter „wo bin ich", die Tab-Leiste darunter
 * den Modulnamen - der Large Title hat eingeklappt nichts mehr zu sagen, was
 * nicht schon dasteht. Fiel er nur auf den Inline-Schnitt, blieb er auf seiner
 * eigenen Zeile, weil der Zeitraum mobil die ganze Zeile fuellt: der Kollaps
 * sparte im Kalender 5-14px, im Budget 14px (170 -> 156px).
 *
 * Eingeklappt verlaesst der Titel deshalb das Bild, nicht den Baum: das <h1>
 * bleibt die Seitenueberschrift fuer Screenreader, geclippt wie `.sr-only`.
 * Das Siegel geht mit. Koepfe ohne Zeitraum (Notizen, Kontakte) behalten den
 * Inline-Schnitt - bei ihnen verankert nichts anderes die Seite. */
@media (max-width: 1023px) {
  .page-toolbar--period.page-toolbar--capped.is-collapsed > .page-toolbar__title {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  .page-toolbar--period.page-toolbar--capped.is-collapsed > .module-seal--head {
    display: none;
  }
}

/* Mobil liegen Titel, Zeitraum und Bar-Zeile mit halbem Zeilenabstand
 * uebereinander: Zeitraum und Tab-Leiste bringen ihre Luft in 48px-
 * Trefferflaechen mit, der Large Title in seiner Zeilenhoehe. Selektorform der
 * Large-Title-Regel oben plus die Modifier-Klasse - mit blossem
 * `.page-toolbar--period` verlor der Wert still gegen 0,3,0. */
@media (max-width: 767px) {
  .page-toolbar.page-toolbar--period:not(.page-toolbar--in-group):has(> .page-toolbar__title) {
    row-gap: var(--space-1);
  }
}

/* --------------------------------------------------------
 * KOMPAKTE HÖHENKLASSE: der Kopf trägt EINE Zeile.
 *
 * DIE CHROME-REGEL (DESIGN.md): über dem Inhalt stehen der Kopf und höchstens
 * eine Bedienzeile. In der kompakten Höhe ist das keine Empfehlung mehr,
 * sondern die Bedingung, unter der überhaupt Inhalt zu sehen ist.
 *
 * Der Large Title kostet eine eigene Zeile, und die ist ein Luxus der Höhe,
 * nicht der Breite. Wo sie fehlt, frisst der Kopf den Inhalt: auf 640x400 -
 * ein 1280x800-Laptop bei 200 % Browserzoom, und damit WCAG 1.4.4 - misst der
 * Scrollport von /tasks 231px, während Kopf und Filterreihe zusammen 296px
 * belegen. Die erste Aufgabe stand bei y=334 und war nie zu sehen, ohne dass
 * ein einziges Pixel Inhalt daran schuld gewesen wäre.
 *
 * Dieselbe Lage kennt die Plattform: in der kompakten Höhenklasse (iPhone
 * quer) zeigt iOS keinen Large Title, sondern führt den Titel inline. Die
 * Grenze steht bei 500px (--bp-short, tokens.css §11c), weil darunter keine
 * der vier Viewport-Breiten mehr genug Höhe für Kopf plus zwei Inhaltszeilen
 * hat.
 *
 * DER LARGE TITLE ALLEIN REICHTE NICHT, und das war der Messfehler dieser
 * Stelle bis 2026-08-10: sein Wegfall spart 35px, gemessen wurden aber 162px
 * Kopf. Den Rest trugen zwei Umbrüche, die aus der BREITE stammen -
 * `.page-toolbar--wrap` gibt dem Center-Slot unter 768px eine eigene Zeile,
 * und die Aktionen rutschen auf eine dritte. Beide sind auf einem 375px
 * breiten Telefon richtig und auf einem 640px breiten Fenster falsch, weil
 * dort die Breite reicht und die Höhe fehlt. Die Höhenachse nimmt sie deshalb
 * zurück: nowrap, und der Kopf steht in einer Zeile.
 *
 * WELCHE GRÖSSE der Titel dann trägt, steht in typography.css bei der
 * Canonical-Page-Head-Rolle - hier steht nur der Umbruch. Der angedockte
 * Titel wird damit gegenstandslos: ohne eigene Zeile gibt es keine Lead-Zone,
 * `lead` fällt auf 0, und die Shell legt ihn gar nicht erst an.
 * -------------------------------------------------------- */
@media (max-width: 1023px) and (max-height: 499px) {
  .page-toolbar:not(.page-toolbar--in-group) > .page-toolbar__title {
    flex-basis: auto;
  }

  /* Kein Large Title, also auch kein Large-Title-Siegel: beide fallen auf den
   * Inline-Rang zurueck (dieselbe Paarung wie in typography.css). */
  .page-toolbar:not(.page-toolbar--in-group):has(> .page-toolbar__title) {
    --seal-head-size: var(--space-6);
    --seal-head-icon: var(--icon-md);
  }

  /* Die Zeilen-Rücknahme gilt in JEDER Breite unter der Desktop-Grenze, auch
   * unter 768px: ein 375x375-Fenster (Telefon quer, 200 % Zoom) hat dieselbe
   * Not wie ein 640x400-Fenster.
   *
   * DIE SELEKTORFORM IST DIE DER REGEL, DIE SIE ZURÜCKNIMMT, und das ist keine
   * Kosmetik: die Large-Title-Regel oben trägt `:not()` plus `:has()` und
   * damit Spezifität 0,3,0. Ein schlichtes `.page-toolbar` (0,1,0) verlor
   * gegen sie lautlos - gemessen blieb `flex-wrap: wrap` auf allen zehn
   * Köpfen stehen, und der Kalender baute weiter vier Zeilen. */
  .page-toolbar:not(.page-toolbar--in-group):has(> .page-toolbar__title),
  .page-toolbar:not(.page-toolbar--in-group) {
    flex-wrap: nowrap;
  }

  /* Ein Kopf bricht nicht um, weil er darf, sondern weil ein Slot eine ganze
   * Zeile beansprucht: `.page-toolbar--wrap` gibt dem Center-Slot unter 768px
   * `flex-basis: 100%`, und die Large-Title-Regel tut dasselbe mit dem Titel.
   * `flex-wrap` ist die Erlaubnis, die Basis der Grund - beide gehoeren
   * zurueckgenommen.
   *
   * DIE ARBEITSTEILUNG IST GEMESSEN, nicht vermutet (Gegenprobe zu Sonde 15,
   * je Regel einzeln zurueckgedreht): die Icon-Form der Suche traegt die
   * Module MIT Suche (Aufgaben, Dokumente, Notizen, Kontakte, Geburtstage,
   * 163 → 63px), das `nowrap` oben traegt die mit Zeitraum-Navigation
   * (Kalender 161 → 61, Budget 157 → 57), und das Leisten-Padding je 8px.
   * Keine der drei allein reicht - wer eine davon fuer entbehrlich haelt,
   * dreht sie zurueck und misst nach. */
  .page-toolbar:not(.page-toolbar--in-group) > .page-toolbar__center,
  .page-toolbar--wrap:not(.page-toolbar--in-group) > .page-toolbar__center {
    flex-basis: auto;
    order: 0;
  }

  /* Die Tab-Leiste im Kopf (Gesundheit, Belohnungen, Haushaltshilfe) IST die
   * eine erlaubte Bedienzeile - sie behält ihre eigene Zeile, nur der Titel
   * gibt seine ab. Ohne diese Zeile zöge `nowrap` sie neben den Titel und
   * beide würden sich die Breite streitig machen. */
  .page-toolbar:not(.page-toolbar--in-group):has(> .page-toolbar__title):has(> [role="tablist"]) {
    flex-wrap: wrap;
  }

}

/* --------------------------------------------------------
 * DIE SUCHE WECHSELT IN IHRE ICON-FORM, WO DER PLATZ FEHLT
 *
 * Zwei Groessenklassen haben denselben Mangel und deshalb dieselbe Antwort:
 * das TELEFON (<=640px, zu schmal fuer Feld plus Aktionen in einer Zeile) und
 * die KOMPAKTE HOEHE (<=1023x499, zu niedrig fuer eine dritte Kopfzeile).
 * Bis 2026-08-10 galt die Regel nur fuer die zweite - und damit ausgerechnet
 * nicht dort, wo die App am haeufigsten benutzt wird. Gemessen auf 375x812:
 * der Kopf von /tasks baute drei Zeilen (Titel 49, Suche 56, Aktionen 62) und
 * stand mit der Filterreihe zusammen bei 341px, 42 % des Geraets, bevor die
 * erste Aufgabe kam. Die Chrome-Regel (DESIGN.md) laesst ueber dem Inhalt den
 * Kopf und HOECHSTENS EINE Bedienzeile; die Suche in ihrer Icon-Form gibt eine
 * der drei Kopfzeilen zurueck.
 *
 * Die Media-Liste nennt beide Faelle EINMAL, statt den Block zu verdoppeln:
 * eine zweite Kopie waere die zweite Handschrift derselben Regel.
 *
 * DIE GRENZE IST 768, NICHT 640 (Kopfregel mobil, 2026-09-26): die Regel
 * gilt fuer jeden Modulkopf der kompakten Klasse unter --bp-tablet, und
 * zwischen 640 und 767px stand das volle Feld neben Titel und Aktionen und
 * brach den Kopf in eine dritte Zeile.
 *
 * UND SIE FRAGT NACH DEM KOPF, NICHT NACH DEM ELTERNTEIL. Hier stand
 * `.page-toolbar > .page-search`: eine Suche in einem Wrapper-Slot
 * (`div.page-toolbar__center` im Inventar) blieb damit ein 248px-Feld und
 * baute die dritte Zeile (A6 P1-1). Der bevorzugte Weg bleibt, die Suche
 * SELBST zum Slot zu machen (`renderPageSearch({ className: '...
 * page-toolbar__center' })`); ein Wrapper, der nur die Suche traegt,
 * schrumpft unten mit ihr.
 * -------------------------------------------------------- */
@media (max-width: 767px), (max-width: 1023px) and (max-height: 499px) {
  /* Die Bauart ist die Label-Verlust-Regel: ein Bedienelement, dem der Platz
   * ausgeht, wird zum Einstieg seiner Familie und behaelt --target-base. Kein
   * neues Markup und kein JS - `.page-search` IST ein `<label for>`, ein Klick
   * darauf fokussiert den Input, und `:focus-within` klappt das Feld wieder
   * auf. Der Einstieg ist damit derselbe Knoten wie das Feld.
   *
   * Die Bedingung ist woertlich die der gedeckelten Architektur: eine Suche,
   * die einem beim Scrollen der eigenen Treffer unter den Haenden
   * verschwindet, waere der falsche Gehorsam gegenueber der Regel. Also nur
   * ohne Fokus UND ohne Wert. */
  .page-toolbar .page-search:not(:focus-within):not(:has(input:not(:placeholder-shown))) {
    flex: 0 0 auto;
    width: var(--target-base);
    max-width: var(--target-base);
    /* min-width GEHOERT DAZU, und es zu vergessen sah aus wie ein Fehler
     * anderswo: `.documents-toolbar__search` fuehrt `min-width: 180px`, und
     * min-width schlaegt width. Das Icon stand danach mittig in einem 180px
     * breiten unsichtbaren Feld - ein Knopf, der ohne Grund in der Zeile
     * schwebt. Die Icon-Form ist ein QUADRAT, also nennt sie alle drei
     * Breitenachsen. */
    min-width: var(--target-base);
  }

  .page-toolbar
    .page-search:not(:focus-within):not(:has(input:not(:placeholder-shown)))
    .page-search__input {
    padding-inline: 0;
    /* Der Text ist leer (kein Wert, sonst griffe die Regel nicht), unsichtbar
     * bleibt damit nur der Placeholder - das Icon sagt dasselbe kuerzer. */
    color: transparent;
    /* DIE ICON-FORM IST DIE DER FAMILIE, nicht ein leeres Feld. Der Feldkanon
     * (1.5px --color-border auf Surface) sagt "hier wird getippt"; in der
     * Icon-Rolle waere das ein Versprechen, das der Knopf nicht haelt, und
     * daneben stuenden drei echte `.btn--icon` in Ghost-Optik. Also uebernimmt
     * er ihre: keine Kante, kein Grund, und der Fokusring der Basisregel
     * kommt zurueck, sobald das Feld wieder eins ist. */
    border-color: transparent;
    background: transparent;
  }

  .page-toolbar
    .page-search:not(:focus-within):not(:has(input:not(:placeholder-shown)))
    .page-search__input::placeholder {
    color: transparent;
  }

  /* RICHTUNGSUNABHAENGIG ZENTRIERT, nicht ueber inset + negativen Translate.
   * Die alte Fassung mischte eine logische Kante (`inset-inline-start: 50%`)
   * mit einer physischen Verschiebung (`translate(-50%)`): unter `dir="rtl"`
   * misst die Kante von rechts, die Verschiebung geht aber weiter nach links,
   * und das Symbol landete um seine eigene Breite neben der Mitte. Sie stand
   * bisher nur in der kompakten Hoehe; mit der Ausweitung auf jedes Telefon
   * haette sie ar und fa flaechendeckend getroffen (Codex-Review zu PR #719).
   * `inset-inline: 0` plus `margin-inline: auto` zentriert in beiden
   * Richtungen und braucht die X-Achse des Translates gar nicht. */
  .page-toolbar
    .page-search:not(:focus-within):not(:has(input:not(:placeholder-shown)))
    .page-search__icon {
    inset-inline: 0;
    margin-inline: auto;
    transform: translateY(-50%);
  }

  /* Ein Slot, der NUR die Suche traegt, gibt ihre Breite mit ab - sonst
   * stuende das Icon in einem leeren, flex-wachsenden Kasten. Dieselbe
   * Bedingung wie oben (ohne Fokus, ohne Wert), aber ohne `:has()` im
   * `:has()`: verschachtelt ist der Selektor ungueltig, und der Browser
   * verwirft die ganze Regel lautlos. */
  .page-toolbar > :not(.page-search):not(:focus-within):has(> .page-search:only-child input:placeholder-shown) {
    flex: 0 0 auto;
    width: auto;
    min-width: 0;
    max-width: none;
  }

  /* Im aufgeklappten Zustand nimmt die Suche die Zeile, die sie braucht - sie
   * ist dann das, was der Nutzer gerade tut. */
  .page-toolbar .page-search:focus-within {
    flex: 1 1 auto;
  }
}

/* --------------------------------------------------------
 * KOMPAKTE HÖHE: jede Leiste über dem Inhalt gibt eine Padding-Stufe ab.
 *
 * Der Kopf allein reicht nicht, wo mehrere Leisten übereinander stehen. Die
 * Küche trägt zwei Kopfebenen (Raum-Leiste plus Modulkopf), Einkauf dazu die
 * Listenwahl, Vorrat und Dokumente je eine Filterzeile. Gemessen auf 640x400:
 * /shopping 178px, /pantry 181px und /documents 190px Chrome über 323px
 * Sichtfläche - mehr als die Hälfte des Bildes, bevor das erste Objekt kommt.
 *
 * WAS HIER NICHT PASSIERT, und zwar bewusst: keine Leiste verschwindet, und
 * keine Zielgröße schrumpft. Die Tabs bleiben --target-base, sie verlieren nur
 * die Luft um sich herum. Eine Leiste wegzunehmen hieße, eine Navigationsebene
 * zu verstecken, die es nur in dieser Größenklasse nicht gäbe - genau die Art
 * Sonderfall, die die Kopf-Regel oben vermeidet.
 *
 * DER `.app-shell`-ANKER IST PFLICHT UND KEINE ZIERDE. Der Router lädt je
 * Seite genau ein Modul-Stylesheet, und zwar NACH layout.css: eine Regel mit
 * derselben Spezifität verliert dort gegen jedes `padding-block`, das ein
 * Modul für seinen eigenen Kopf schreibt. Ohne den Anker blieb die erste
 * Fassung dieser Zeilen wirkungslos - gemessen an /documents und /pantry, die
 * ihre 8px unverändert behielten. Die Begründung ist dieselbe wie bei „die
 * Grösse des Icon-Knopfs gehört der Shell": wie hoch eine Leiste baut, ist
 * eine Entscheidung der Shell über alle 17 Module, nicht siebzehn Antworten.
 * -------------------------------------------------------- */
@media (max-height: 499px) {
  .app-shell .page-toolbar,
  .app-shell .sub-tabs-bar,
  .app-shell .list-tabs-bar {
    padding-block: var(--space-1);
  }

  :root {
    --kitchen-tabs-height: var(--target-lg);
    --sub-tabs-height: var(--target-lg);
  }
}

@media (max-width: 639px) {
  .page-toolbar {
    /* Nur die Bauhöhe verdichten. Der horizontale Rand bleibt bewusst
     * --page-inline-pad: er muss mit dem Seiten-Body fluchten, und ein
     * eigener Wert hier war genau die Quelle des 16px-Versatzes. */
    padding-block: var(--space-2);
    /* Engere Nähte, damit Titel + Aktionen EINE Zeile teilen statt zu
     * umbrechen - der gestapelte Kopf hielt die erste Karte bei ~40 %
     * Viewport (Finish-Verdict Fundament-Phase, Fix 6). Wirkt shell-weit
     * auf alle .page-toolbar-Module; wer trotzdem nicht passt, bricht
     * weiter kontrolliert um (flex-wrap bleibt). */
    --page-toolbar-gap: var(--space-1);
  }

  .page-toolbar__actions {
    gap: var(--space-1);
  }
}

/* --------------------------------------------------------
 * Formular-Checkbox (geteilt)
 *
 * Nutzer: die Küchen-Dialoge („Artikel von der Einkaufsliste löschen" im
 * Übernahme-Dialog, die Mahlzeit-Typen im Rezept-Formular).
 *
 * WARUM: Beide waren nackte `<input type="checkbox">` - System-Größe, System-
 * Akzentfarbe, keine eigene Trefferfläche -, während die Liste daneben
 * 44×44-Custom-Controls benutzt und SECHS andere Module (Geburtstage, Kontakte,
 * Dokumente, Gesundheit, Belohnungen, Einstellungen) ihre Checkboxen bereits per
 * `accent-color` und fester Größe einkleiden. Die folgenreichste Checkbox des
 * Moduls - sie löscht die eingekauften Artikel von der Liste und ist
 * standardmäßig aktiv - war damit die unauffälligste (Critique 2026-07-30, P2).
 *
 * DER DEFAULT BLEIBT AKTIV. Wer eingekauft hat und die Ware einräumt, will sie
 * nicht ein zweites Mal kaufen; das Häkchen wegzunehmen wäre der seltene Fall. Was
 * fehlte, war nicht der andere Default, sondern ein Control, das man sieht.
 *
 * NATIVE CHECKBOX, KEIN NACHBAU: das ist die App-Konvention (sechs Module) und die
 * native Box bringt Tastatur, Indeterminate-Zustand und Plattform-Semantik mit.
 * `.toggle` daneben ist bewusst etwas anderes - ein Schalter für „Zustand an/aus",
 * keine Auswahl aus mehreren.
 * -------------------------------------------------------- */

.form-check {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  cursor: pointer;
  /* Die Trefferfläche sitzt am LABEL, nicht an der Box: eine 20px-Box auf 44px
   * aufzublasen würde sie visuell verfälschen, das Label ist ohnehin klickbar. */
  min-height: var(--target-md);
}

@media (hover: none) {
  .form-check {
    min-height: var(--target-lg);
  }
}

.form-check input[type="checkbox"] {
  width: var(--space-5);
  height: var(--space-5);
  /* Die Stimme, ohne Kette: eine Checkbox ist ein Zustandsschalter und tut in
   * jedem Modul dasselbe (Eine-Stimme-Regel). Hier stand
   * `--module-accent, --active-module-accent, --color-accent` - eine
   * dreistufige Kette, die es nur gab, weil das Modal im Top-Layer ausserhalb
   * der Modul-Wurzel haengt und die Checkbox darum sonst violett blieb,
   * waehrend der Dialog um sie herum Kuechen-Orange trug. Mit der einen Stimme
   * ist violett die richtige Antwort und die Kette entfaellt. */
  accent-color: var(--color-accent);
  flex-shrink: 0;
  cursor: pointer;
  margin: 0;
}

/* --------------------------------------------------------
 * Überlaufmenü (geteilt, native Popover-API)
 *
 * Nutzer: der Kopf der Einkaufsliste auf Touch (utils/popover-menu.js). Er trug
 * dort fünf Bedienelemente in 173px Höhe, drei davon unbeschriftete Icons -
 * darunter „Liste löschen" für die Liste des ganzen Haushalts (Critique
 * 2026-07-30).
 *
 * Diese Grammatik existiert im Repo bereits zweimal privat:
 * `.contact-more-menu__panel` + `.contact-menu-item` (contacts.css) und
 * `.documents-context-menu` + `__item` (documents.css). Beide sind bis auf die
 * Klassennamen deckungsgleich mit dem hier. Sie sind bewusst nicht mitmigriert -
 * der Auftrag war die Küche, und zwei fremde Module umzustellen ist ein eigener
 * Schritt. Wer ihn geht, löscht dort rund 60 Zeilen und ändert hier nichts.
 * -------------------------------------------------------- */

/* `display: none` als Grundzustand, `flex` erst bei :popover-open: das UA-Blatt
 * setzt `display: none` auf `[popover]`, und eine eigene display-Deklaration
 * ohne Popover-Bedingung würde es schlagen - dieselbe Falle wie die
 * [hidden]-Durchsetzung ein paar Zeilen weiter unten. */
.popover-menu {
  position: fixed;
  margin: 0;
  inset: auto;
  z-index: var(--z-dropdown);
  min-width: 200px;
  flex-direction: column;
  align-items: stretch;
  gap: var(--space-0h);
  padding: var(--space-1);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background-color: var(--color-surface);
  box-shadow: var(--shadow-md);
  /* Die Position rechnet JS erst im `toggle`-Ereignis; ohne die Startopazität
   * blitzt das Panel eine Frame lang oben links auf. */
  opacity: 0;
  transition: opacity var(--transition-fast);
}

.popover-menu:popover-open {
  display: flex;
}

/* Icon UND Textlabel, immer. Das ist der ganze Zweck des Menüs: die drei
 * Kopf-Aktionen des Einkaufs waren mobil nackte Glyphen. */
.popover-menu__item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  width: 100%;
  padding: var(--space-2) var(--space-3);
  border: none;
  border-radius: var(--radius-sm);
  background: none;
  font: inherit;
  font-size: var(--text-sm);
  color: var(--color-text-primary);
  text-align: start;
  cursor: pointer;
  /* --target-md, nicht --target-lg: die Einträge stehen in einer Liste mit
   * Trennung durch Abstand, nicht als isolierte Ziele in einer Zeile. */
  min-height: var(--target-md);
  transition: background-color var(--transition-fast);
}

@media (hover: none) {
  .popover-menu__item {
    min-height: var(--target-lg);
  }
}

/* Ein Eintrag darf ein Link sein (Budget: CSV-Export) - dann ohne Unterstrich,
 * sonst sieht er anders aus als seine Knopf-Nachbarn. */
a.popover-menu__item {
  text-decoration: none;
}

.popover-menu__item:hover,
.popover-menu__item:focus-visible {
  background-color: var(--color-surface-hover);
}

.popover-menu__item i,
.popover-menu__item svg {
  flex-shrink: 0;
  color: var(--color-text-secondary);
}

.popover-menu__item--danger,
.popover-menu__item--danger i,
.popover-menu__item--danger svg {
  color: var(--color-danger);
}

.popover-menu__item--danger:hover,
.popover-menu__item--danger:focus-visible {
  background-color: color-mix(in srgb, var(--color-danger) var(--tint-state), transparent);
}

/* Einfachauswahl-Variante (role=menuitemradio): Häkchen nur bei der aktiven
 * Option - `visibility: hidden` statt `display: none`, damit die Textspalte
 * für alle Einträge an derselben Stelle beginnt. Stand als Lokalkopie in
 * recipes.css (Quellenfilter); mit dem Personen-Umschalter der Gesundheit
 * (2026-08-31) hat das Muster einen zweiten Nutzer und wohnt beim Rest des
 * popover-menu-Vokabulars. */
.popover-menu__item-check--hidden {
  visibility: hidden;
}

/* Gruppen mit Ueberschrift und Trenner - fuer ein Menue, das mehr als eine
 * Frage stellt (Dokumente: Sortierung, Richtung, Auswahl; Critique
 * 2026-09-25). Die Ueberschrift ist kein Eintrag: sie traegt keine
 * `.popover-menu__item`-Klasse und faellt damit aus Pfeiltasten und Fokus
 * heraus; die Gruppe verweist per `aria-labelledby` auf sie. */
.popover-menu__group {
  display: flex;
  flex-direction: column;
  gap: var(--space-0h);
}

.popover-menu__label {
  padding: var(--space-1) var(--space-3) 0;
  color: var(--color-text-tertiary);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-medium);
}

.popover-menu__separator {
  height: 1px;
  margin: var(--space-1) var(--space-2);
  background: var(--color-border);
}

/* Ein Zeichen am Zeilenende (etwa die Richtungspfeile), rechtsbuendig. */
.popover-menu__item-trail {
  margin-inline-start: auto;
}

@media (prefers-reduced-motion: reduce) {
  .popover-menu,
  .popover-menu__item {
    transition: none;
  }
}

/* --------------------------------------------------------
 * KOPFREGEL MOBIL - DIE GETEILTEN BAUSTEINE (2026-09-26)
 *
 * Die Regel steht in DESIGN.md („Kopfregel mobil"): hoechstens zwei
 * Kopfzeilen, keine losen Verwaltungs-Icons, Filter nicht fest ueber dem
 * Port, Suche als Icon, Inhalt unter der Kapsel. Was die Module dafuer
 * brauchen, steht hier und nicht im Modul-Stylesheet - der Router laedt je
 * Seite genau eines, und eine Form, deren Regeln dort wohnen, gilt nur in
 * diesem einen Modul (Lehre von `.toggle-row`, oben).
 *
 *   `.page-tools-btn`  das EINE Werkzeugmenue („..."), utils/popover-menu.js
 *                      `pageToolsMenuHtml()`. Braucht keine eigene Geometrie:
 *                      `.btn--secondary.btn--icon` wie `documents-tools-btn`.
 *   `.page-filter-btn` „Filter (n)", utils/filter-sheet.js. Vorbild ist der
 *                      Filterknopf des Kalenders (calendar.css).
 *   `.filter-sheet`    das Blatt dazu: benannte Gruppen.
 *   `.page-chip-row`   eine Chipreihe IM Scrollport - sie scrollt mit weg.
 * -------------------------------------------------------- */

/* Der aktive Knopf traegt den Modulton als Tinte; die Zahl ist der Zustand
 * (Begruendung am Kalender-Vorbild). */
.page-filter-btn {
  position: relative;
}

.page-filter-btn--active {
  color: var(--module-accent, var(--color-accent));
}

.page-filter-btn--labeled {
  gap: var(--space-1);
}

.page-filter-btn__count {
  position: absolute;
  top: var(--space-1);
  inset-inline-end: var(--space-1);
  min-width: var(--space-4);
  height: var(--space-4);
  padding-inline: var(--space-0h);
  border-radius: var(--radius-full);
  background: var(--module-accent, var(--color-accent));
  color: var(--color-ink-on-vivid);
  font-size: var(--text-2xs);
  font-weight: var(--font-weight-bold);
  line-height: var(--space-4);
  text-align: center;
  pointer-events: none;
}

/* Mit Wort steht die Zahl IN der Zeile statt als Ecke - eine Ecke ueber
 * Text liest sich als Fussnote, nicht als Zustand. */
.page-filter-btn--labeled .page-filter-btn__count {
  position: static;
}

.page-filter-btn__count[hidden] {
  display: none;
}

.filter-sheet__group + .filter-sheet__group {
  margin-top: var(--space-5);
}

.filter-sheet__heading {
  font-size: var(--text-xs);
  font-weight: var(--font-weight-semibold);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--color-text-tertiary);
  margin: 0 0 var(--space-2);
}

/* DIE CHIPREIHE IM SCROLLPORT.
 *
 * Sie ist das ERSTE Kind des `.page-scrollport` (oder, wo die Seite selbst
 * scrollt, das erste Kind nach dem Kopf) und scrollt senkrecht mit dem
 * Inhalt weg - Apples Suchleiste in einer Liste. Fest ueber dem Port kostete
 * sie in Notizen, Kontakten und Vorrat dauerhaft 60-65px, die kein
 * Kopf-Kollaps zurueckholte (A8). Deshalb hier bewusst KEIN `position:
 * sticky`: eine klebende Reihe waere dieselbe feste Zeile mit einem Umweg,
 * und der Guard (test:mobile-chrome) sagt nein dazu.
 *
 * Waagerecht scrollt sie selbst; `wireScrollFade(row)` (utils/ux.js) setzt
 * die Fade-Klassen aus filter-chip.css. Der Kopf-Kollaps ignoriert ihren
 * waagerechten Scroll (ux.js `onInnerScroll`: kein senkrechter Ueberlauf,
 * also nicht der Port der Seite). Seitenrand: keiner - sie steht in der
 * Inhaltsspalte ihres Ports, die das Polster schon traegt (#577, genau
 * einmal pro Ahnenkette). */
.page-chip-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  /* Luft fuer den Fokusring: ein waagerechter Scroller clippt senkrecht mit. */
  padding-block: var(--space-2);
  /* DIE HOEHE STEHT ALS ZAHL, weil ein Port auch ein Grid sein kann, das hoeher
   * waechst als er selbst (`.list-scroller`, Vorrat): eine Spur ohne Groesse
   * bekommt dort nur den Mindestbeitrag ihres Elements, und der ist bei einem
   * Scroll-Container (die Reihe scrollt waagerecht) NULL. Gemessen stand die
   * Reihe 16px hoch - nur ihr Polster -, die Chips ragten in die erste Gruppe;
   * `min-content`/`fit-content` aendern daran in Chrome nichts. Chiphoehe
   * (`.filter-chip`, filter-chip.css) plus Polster schon. `flex-shrink: 0`
   * gilt fuer Ports, die Flex-Spalten sind. */
  flex-shrink: 0;
  min-height: calc(var(--target-lg) + 2 * var(--space-2));
}

/* Dieselben Stufen wie `.filter-chip` im Querformat (filter-chip.css): die
 * Reihe folgt ihrer Chiphoehe, sonst stuende im flachen Fenster ein leerer
 * Streifen unter den Chips. */
@media (max-height: 499px) {
  .page-chip-row {
    min-height: calc(var(--target-md) + 2 * var(--space-2));
  }
}

@media (max-height: 499px) and (hover: none) {
  .page-chip-row {
    min-height: calc(var(--target-base) + 2 * var(--space-2));
  }
}

.page-chip-row::-webkit-scrollbar {
  display: none;
}

.page-chip-row > * {
  flex-shrink: 0;
}

/* Anlegen im Kopf: auf dem Handy schwebt der FAB, auf dem Zeigergerät dockt
 * die Shell DENSELBEN Knopf in `.page-toolbar__actions` (dockFabIntoToolbar in
 * router.js). Einen eigenen Kopfknopf je Modul gibt es seit Runde 7 nicht mehr
 * (D3, `toolbar-new-btn` im Ratchet von test-control-dialect.js steht auf 0):
 * zwei Wege zur selben Aktion brauchten eine Weiche, die entscheidet, wer
 * gewinnt, und eine Nur-lesen-Regel für jeden der beiden. */
@media (min-width: 1024px) {
  /* Der angedockte FAB IST der Toolbar-Knopf (siehe dockFabIntoToolbar in
   * router.js): dieselbe Node, dieselbe Verdrahtung, nur ohne die schwebende
   * Geometrie. Die Kapsel und die Stimme kommen von `.btn--primary`. */
  .page-fab--docked {
    position: static;
    width: auto;
    height: auto;
    min-height: var(--target-base);
    padding-inline: var(--space-4);
    gap: var(--space-1);
    box-shadow: var(--shadow-sm);
  }
}

/* [hidden] durchsetzen: Klassen wie `.page-fab`/`.btn` setzen `display: flex`
 * bzw. `inline-flex` und schlagen damit das UA-`[hidden] { display: none }` bei
 * gleicher Spezifität — das Element bleibt sichtbar, obwohl es ausgeblendet
 * wurde. Bisher wurde dieselbe Falle punktuell in documents.css, contacts.css
 * und budget.css entschärft; hier steht sie einmal für alle geteilten
 * Bedienelemente. `hidden` heißt versteckt, ausnahmslos. */
/* Seit 2026-07-30 auch `.page-toolbar`: ohne aktive Liste versteckt die
 * Einkaufsliste ihren #list-head, und dessen `display: flex` schlug das
 * UA-`[hidden]`. Übrig blieb ein 25px hoher leerer Streifen über dem
 * Leerzustand. Die Selektorliste bleibt bewusst lückenlos - der Guard in
 * test-budget-ui.js prüft die Nähe von Selektor und Deklaration. */
/* Und `.recipe-detail`: das Aufklapp-Panel der Rezeptzeile trägt `display: grid`
 * und war deshalb dauerhaft offen, obwohl `hidden` gesetzt war und das Chevron
 * „zu" zeigte - dieselbe Falle, drittes Mal. Ein Prüfskript, das nur die
 * DOM-Property `hidden` liest, sieht sie NICHT; es braucht das berechnete
 * `display`. */
/* `.list-bulkbar` stand hier als vierte Fundstelle derselben Falle, solange sie
 * ein dauerhafter, leerer Knoten im Seitenfluss war. Seit Etappe 5 wird sie
 * ANGELEGT und ENTFERNT (utils/bulk-pill.js) und trägt nie `hidden` - und ein
 * Eintrag für einen Zustand, den niemand setzt, ist keine Absicherung, sondern
 * eine Behauptung über den Bestand. */
/* Seit #1340 haelt test:hidden-cascade die Regel fuer den ganzen Baum. Der erste
 * Lauf fand zwei weitere geteilte Klassen: `.form-field` (Freitextfeld der
 * Aktivitaet und Name der Vorsorge in health.js) und `.row-action` (der
 * Entfernen-Knopf der ersten Telefon-/Mail-Zeile im Kontakt).
 *
 * `.page-toolbar__actions` steht hier BEWUSST NICHT. In diesen Slot dockt der
 * Router auf dem Desktop den FAB (dockFabIntoToolbar); wer ihn ausblendet,
 * blendet die Primaeraktion der Seite mit aus. Die Rezepte taten genau das mit
 * ihrem Quellenfilter, und nur `display: flex` hier hielt den Knopf sichtbar -
 * eine Rettungsregel an dieser Stelle haette ihn versteckt (Review zu #1347).
 * Der Slot wird deshalb nie `hidden`; test:hidden-cascade verbietet es. */
.page-fab[hidden],
.btn[hidden],
.form-group[hidden],
.form-field[hidden],
.row-action[hidden],
.page-toolbar[hidden],
.recipe-detail[hidden],
p.field-hint--warn[hidden] {
  display: none !important;
}

/* DER RESET DER ZEITRAUM-NAVIGATION (Kalender „Heute", Essensplan „Heute",
 * Budget „Aktuell") BRAUCHT `hidden` GERADE NICHT - er braucht das Gegenteil.
 *
 * `hidden` loest oben in `display: none` auf und entfernt die Box komplett aus
 * dem Fluss. Der Nachbar in der Zeile ist aber ein flexibles Label
 * (`flex: 1`/`flex: 1 1 auto`): faellt die Box weg, waechst das Label in genau
 * den frei gewordenen Platz - und "›" ruckt um exakt die Breite des Knopfes
 * nach links. Ein Klick auf "›", der den aktuellen Zeitraum verlaesst, laesst
 * den Reset an GENAU DER STELLE erscheinen, an der eben noch "›" stand: ein
 * zweiter Klick ohne Mausbewegung trifft den Reset statt des Pfeils (PR #1200
 * Review, gemessen 11/11 im Budget, 7/11 im Kalender, 5/11 im Essensplan ueber
 * 33 Layouts).
 *
 * `.is-current` reserviert die Box stattdessen dauerhaft: `visibility: hidden`
 * blendet nur die MALEREI aus, die Breite bleibt im Fluss und "›" bewegt sich
 * nie. Die Zeiger-, Fokus- und Barrierefreiheits-Seite uebernimmt das
 * `inert`-Attribut, das dieselben drei Module am Element setzen (siehe
 * calendar.js/meals.js/budget.js `syncTodayButton`/`syncCurrentButton`) -
 * dieselbe Kombination, die router.js und modal.js fuer geparkte Elemente
 * schon nutzen. */
.btn.is-current {
  visibility: hidden;
}

/* ================================================================
 * Sidebar Navigation - Desktop (≥ 1024px)
 *
 * Design: Flach, kein Neumorphismus. Dezenter Seitenrand.
 * Aktiver State: Hintergrund-Highlight + Akzentstreifen links.
 * Hover: Subtile Hintergrundfarbe, kein Shadow.
 * ================================================================ */
@media (min-width: 1024px) {
  :root {
    --sidebar-width: var(--sidebar-width-expanded);
  }

  .app-shell {
    flex-direction: row;
  }

  .app-content {
    flex: 1;
    min-height: 0;
    padding-bottom: 0;
    margin-left: var(--sidebar-width);
    /* Begleitet den Sidebar-Collapse (siehe .nav-sidebar width): der Inhalt rückt
     * nach - bewusste Layout-Transition, reduced-motion schaltet sie ab. */
    transition: margin-left var(--transition-slow);
  }

  .nav-bottom {
    display: none;
  }

  /* ── Sidebar anzeigen ── */
  .nav-sidebar {
    display: flex;
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    width: var(--sidebar-width);
    background: var(--sidebar-bg);
    border-right: 1px solid var(--color-border-subtle);
    flex-direction: column;
    z-index: var(--z-nav);
    /* 12px statt 16px oben und unten: die Leiste muss auf 1280x800 alle Module
     * ohne Scrollen zeigen (Critique 2026-09-26, P1-2, Rechnung an
     * --sidebar-row-height in tokens.css). */
    padding: var(--space-3) 0;
    /* Bewusste Layout-Transition (Detector layout-transition, geprüft):
     * der Collapse ändert echte Inhaltsbreite - transform kann Breite nicht
     * animieren, ein Overlay-Umbau stünde in keinem Verhältnis. Läuft nur
     * beim manuellen Ein-/Ausklappen; reduced-motion schaltet sie ab. */
    transition: width var(--transition-slow);
    overflow: hidden;
  }

  @media (prefers-reduced-motion: reduce) {
    .nav-sidebar,
    .app-content {
      transition: none;
    }
  }

  .nav-sidebar__logo {
    display: flex;
    justify-content: flex-start;
    align-items: center;
    /* Feste Höhe verhindert vertikales Springen beim Ein-/Ausklappen.
     * padding-left 14px zentriert den 28px-Logomark auf X=28 — fluchtet mit
     * den Nav-Item-Icon-Wells (siehe .nav-sidebar .nav-item). Krumme px-Werte,
     * weil exakte Pixel-Zentrierung nicht auf die 4px-Token-Skala fällt. */
    height: var(--target-base);
    padding: 0 var(--space-2) 0 14px;
    margin-bottom: var(--space-1);
    flex-shrink: 0;
    gap: var(--space-3);
  }

  /* Suche und Einklappen stehen als Werkzeuge rechts in der Logo-Zeile
   * (Critique 2026-09-26, P1-2) - vorher je eine eigene 40px-Zeile. Zwei
   * gleichartige Ziele nebeneinander: ein Reihen-Bauteil, WCAG 2.5.8 genuegt,
   * 32px halten es trotzdem gut treffbar. */
  .nav-sidebar__logo-actions {
    display: flex;
    align-items: center;
    margin-inline-start: auto;
    flex-shrink: 0;
  }

  .nav-sidebar__tool {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--target-sm);
    height: var(--target-sm);
    padding: 0;
    border: none;
    /* Ein quadratischer Icon-Knopf ist ein Kreis (Kapsel-Regel der Shell). */
    border-radius: var(--radius-full);
    background: none;
    color: var(--color-text-tertiary);
    cursor: pointer;
    transition: color var(--transition-fast), background-color var(--transition-fast);
  }

  .nav-sidebar__tool:hover {
    color: var(--color-text-secondary);
    background-color: var(--color-surface-3);
  }

  .nav-sidebar__tool svg {
    width: 16px;
    height: 16px;
    flex-shrink: 0;
  }

  /* SVG-Logomark (via DOM API in router.js injiziert, Gradient im SVG selbst) */
  .nav-sidebar__logomark {
    width: 28px;
    height: 28px;
    flex-shrink: 0;
  }

  .nav-sidebar__logomark svg {
    width: 100%;
    height: 100%;
    display: block;
  }

  .nav-sidebar__brand-text {
    display: flex;
    flex-direction: column;
    min-width: 0;
    line-height: 1.1;
  }

  .nav-sidebar__brand-name {
    font-size: var(--text-lg);
    font-weight: var(--font-weight-bold);
    letter-spacing: var(--tracking-tight);
    color: var(--color-text-primary);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .nav-sidebar__version {
    margin-top: var(--space-0h);
    font-size: var(--text-xs);
    font-weight: var(--font-weight-medium);
    letter-spacing: var(--tracking-label);
    color: var(--color-text-tertiary);
  }

  /* ── Nav-Items Container ── */
  .nav-sidebar__items {
    display: flex;
    flex-direction: column;
    gap: var(--space-0h);
    /* Kein seitliches Container-Padding: Item-Padding steuert die Icon-Flucht,
     * die schwebende Indikator-Pille zieht ihren Einzug über left/right. Unten
     * ein kleiner Puffer, damit das letzte Item (Settings) beim Scrollen nicht an
     * die Footer-Haarlinie stößt. */
    padding: 0 0 var(--space-2);
    flex: 1;
    /* Ohne min-height:0 schrumpft ein Flex-Item nicht unter seine Inhaltshöhe -
     * overflow-y:auto bliebe wirkungslos und die Liste liefe über den Footer. */
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior-y: contain;
    /* SICHTBARER BALKEN AB HIER, und das ist eine Rücknahme (#970).
     *
     * Bis v2.64.1 stand hier `scrollbar-width: none` samt verstecktem
     * `::-webkit-scrollbar`, und der Fade darunter war der Ersatz. Der Anlass
     * dafür war ein anderer als der, den das Verstecken verursacht hat: der
     * Fade beantwortet „ist da noch mehr?" (Audit F-01, sonst waren Budget/
     * Gesundheit/Einstellungen auf 800er-Laptops unauffindbar) - er beantwortet
     * nicht „womit ziehe ich?". Ein Anriss ist eine Andeutung, kein Griff.
     * Am Telefon ist das gleichgültig, weil dort die Geste zieht; am Desktop
     * mit Maus ist der Balken das Bedienelement, und diese Regel steht in
     * `@media (min-width: 1024px)` - sie hat ihn genau dort weggenommen, wo er
     * gebraucht wird. Beides zusammen ist kein Widerspruch: der Fade sagt, DASS
     * mehr da ist, der Balken sagt, WO man ist und lässt einen hin.
     *
     * `--tint-hint` ist laut tokens.css die Stufe für „Kante, Linie,
     * Scrollbar-Daumen"; die Schreibweise folgt den Modul-Scrollbars in
     * budget.css, nur ohne `--module-accent` - die Seitenleiste gehört keinem
     * Modul und darf nicht die Farbe des gerade offenen annehmen.
     *
     * OFFEN, und bewusst nicht behauptet: die `mask-image`-Fades weiter unten
     * liegen auf DIESEM Element, und eine Maske erfasst die Elementbox samt
     * Scrollbalken. Auf Plattformen mit klassischem, Platz nehmendem Balken
     * (Windows - dort kam die Meldung her) verblasst sein unterstes Stück
     * deshalb im Fade-Bereich. Die Bedienung bleibt: eine Maske ändert die
     * Deckkraft, nicht das Hit-Testing. Nachgemessen werden konnte es hier
     * nicht - macOS zeichnet Overlay-Balken, die dabei gar nicht erscheinen.
     * Wenn es stört, ist der Weg nicht `scrollbar-width: none` zurück, sondern
     * den Fade von der Maske auf ein überlagertes Gradient-Pseudoelement
     * umzustellen; dann liegt er über dem Inhalt statt über der Box. */
    scrollbar-width: thin;
    scrollbar-color: color-mix(in srgb, var(--color-border) var(--tint-hint), transparent) transparent;
  }
  .nav-sidebar__items::-webkit-scrollbar { width: 10px; }
  .nav-sidebar__items::-webkit-scrollbar-track { background: transparent; }
  .nav-sidebar__items::-webkit-scrollbar-thumb {
    background: color-mix(in srgb, var(--color-border) var(--tint-hint), transparent);
    /* Transparente Border + padding-box: der Daumen wirkt schmaler als die
     * 10px-Spur, ohne dass die Trefffläche schrumpft. */
    border: 3px solid transparent;
    border-radius: var(--radius-full);
    background-clip: padding-box;
  }

  /* Scroll-Affordanz (Audit F-01): wireScrollFade (utils/ux.js) setzt
   * has-fade-start/-end, sobald oberhalb/unterhalb Einträge verborgen sind.
   * Der weiche Anriss ersetzt den bewusst versteckten Scrollbalken — ohne ihn
   * waren Budget/Gesundheit/Einstellungen auf 800er-Laptops unauffindbar. */
  .nav-sidebar__items.has-fade-end {
    mask-image: linear-gradient(to bottom, var(--mask-opaque) calc(100% - var(--space-8)), transparent);
    -webkit-mask-image: linear-gradient(to bottom, var(--mask-opaque) calc(100% - var(--space-8)), transparent);
  }

  .nav-sidebar__items.has-fade-start {
    mask-image: linear-gradient(to bottom, transparent, var(--mask-opaque) var(--space-8));
    -webkit-mask-image: linear-gradient(to bottom, transparent, var(--mask-opaque) var(--space-8));
  }

  .nav-sidebar__items.has-fade-start.has-fade-end {
    mask-image: linear-gradient(to bottom, transparent, var(--mask-opaque) var(--space-8), var(--mask-opaque) calc(100% - var(--space-8)), transparent);
    -webkit-mask-image: linear-gradient(to bottom, transparent, var(--mask-opaque) var(--space-8), var(--mask-opaque) calc(100% - var(--space-8)), transparent);
  }

  /* DIE KONTOZEILE (Critique 2026-09-26, P1-2). Sie ersetzt die Fussleiste mit
   * Hilfe, Aenderungen und Abmelden (104px): die drei stehen jetzt im
   * Konto-Menue hinter dem Avatar (router.js, sidebarAccountEl). Eine
   * nicht-scrollende Region mit Haarlinie, wie vorher der Fuss. */
  .nav-sidebar__account {
    flex-shrink: 0;
    display: flex;
    flex-direction: column;
    padding-top: var(--space-1);
    margin-top: var(--space-1);
    border-top: 1px solid var(--color-border-subtle);
  }

  /* Doppelte Klasse: die spaetere `.nav-sidebar .nav-item`-Regel setzte die
   * Hoehe sonst auf die Listenzeile zurueck. Die Kontozeile steht allein am
   * Fuss und haelt deshalb die Zielgroesse des Zeigers. */
  .nav-sidebar .nav-item.nav-sidebar__account-trigger {
    min-height: var(--target-md);
    width: 100%;
    border: none;
    background: none;
    font-family: inherit;
    text-align: start;
    cursor: pointer;
  }

  .nav-sidebar .nav-sidebar__account-trigger:hover,
  .nav-sidebar .nav-sidebar__account-trigger[aria-expanded="true"] {
    color: var(--color-text-primary);
    background-color: var(--color-surface-3);
  }

  /* Die Scheibe hat die Groesse des Icon-Wells (26px) und steht damit auf
   * derselben Flucht wie die Modul-Icons darueber, auch eingeklappt. */
  .nav-sidebar__avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 26px;
    height: 26px;
    border-radius: var(--radius-full);
    overflow: hidden;
    /* Ohne eigene Farbe steht die Scheibe auf dem Akzent, und dessen Tinte
     * kippt mit dem Theme (hell: Weiss, dunkel: das helle Violett traegt
     * dunkle Schrift). Eine Nutzerfarbe setzt syncSidebarAccount() inline und
     * waehlt die Schrift per Kontrastrechnung (--on-color / --ink). */
    background-color: var(--color-accent);
    color: var(--color-ink-on-vivid);
    font-size: var(--text-xs);
    font-weight: var(--font-weight-semibold);
    line-height: 1;
    flex-shrink: 0;
  }

  .nav-sidebar__avatar--on-color {
    color: var(--color-text-on-accent);
  }

  .nav-sidebar__avatar--ink {
    color: var(--color-ink-on-bright);
  }

  .nav-sidebar__avatar img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
  }

  .nav-sidebar__account-name {
    flex: 1 1 auto;
    min-width: 0;
    color: var(--color-text-primary);
  }

  .nav-sidebar__account-chevron {
    width: 14px;
    height: 14px;
    flex-shrink: 0;
    color: var(--color-text-tertiary);
  }

  .nav-sidebar__account-menu {
    min-width: calc(var(--sidebar-width-expanded) - (2 * var(--space-2)));
  }

  /* ── Nav-Item: Sidebar ── */
  .nav-sidebar .nav-item {
    flex: 0 0 auto;
    flex-direction: row;
    justify-content: flex-start;
    align-items: center;
    border-radius: var(--radius-sm);
    /* padding-left 15px zentriert das 26px-Icon-Well auf X=28 — fluchtet mit
     * dem Logomark. Krumm, weil exakte Zentrierung neben der 4px-Token-Skala liegt. */
    padding: var(--space-0h) var(--space-3) var(--space-0h) 15px;
    gap: var(--space-3);
    /* Zeilenhoehe der Seitenleiste statt --target-md (Critique 2026-09-26,
     * P1-2): mit 40px lagen auf 1440x900 drei Module unter der Falz, auf
     * 1280x800 rund 270px. Die Zeilen sind ein Reihen-Bauteil - Begruendung
     * und Rechnung am Token in tokens.css. Die Bottom-Nav behaelt 48px. */
    min-height: var(--sidebar-row-height);
    font-size: var(--text-sm);
    color: var(--color-text-secondary);
    background: transparent;
    transition:
      color var(--transition-fast),
      background-color var(--transition-fast);
    position: relative;
  }

  /* DIE SIDEBAR IST DIE LEGENDE DER MODULTOENE (Eine-Stimme-Regel, 2026-08-10).
   *
   * Die Stimme haelt seit der Regel den ganzen Rahmen zusammen - aktive Pille,
   * Label, Streifen, FAB. Damit war die Frage offen, wo der Modulton noch
   * SICHTBAR wird, ohne den Rahmen wieder umzufaerben. Antwort: hier, wo alle
   * dreizehn nebeneinander stehen. Ein Verzeichnis, in dem jedes Modul sein
   * Zeichen in seinem Ton traegt, ist Apples eigenes Settings-Muster - und es
   * zeigt die Toene EINMAL statt in jedem Zimmer.
   *
   * Der Ton sitzt auf dem ICON, nie auf Label oder Flaeche: ein Icon ist
   * Grafik (3:1 nach 1.4.11), ein Label waere Text und muesste 4.5:1 gegen
   * die Sidebar-Flaeche halten - was sieben der neun Familientoene reissen
   * wuerden. --item-module-accent setzt der Router pro Item (router.js). */
  .nav-sidebar .nav-item__icon {
    width: 18px;
    height: 18px;
    flex-shrink: 0;
    color: var(--item-module-accent, currentColor);
  }

  /* Aktiv gewinnt die Stimme auch ueber dem Zeichen: die Zeile ist dann als
   * Ganzes violett, und ein Icon, das dabei als einziges seine Familienfarbe
   * behielte, laese sich als „nicht mitgemeint". */
  .nav-sidebar .nav-item[aria-current="page"] .nav-item__icon {
    color: var(--color-accent);
  }

  .nav-sidebar .nav-item__label {
    display: block;
    font-size: var(--text-sm);
    font-weight: var(--font-weight-medium);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* ── Hover: dezente Hintergrundfarbe ── */
  .nav-sidebar .nav-item:hover:not([aria-current="page"]) {
    color: var(--color-text-primary);
    background-color: var(--color-surface-3);
  }

  /* ── Aktiv: Hintergrund-Highlight + Akzent-Indikator ── */
  .nav-sidebar .nav-item[aria-current="page"] {
    color: var(--color-accent);
    font-weight: var(--font-weight-semibold);
    background-color: color-mix(in srgb, var(--color-accent) var(--tint-state), transparent);
  }

  /* Akzentstreifen links */
  .nav-sidebar .nav-item[aria-current="page"]::before {
    content: '';
    position: absolute;
    left: 0;
    top: var(--space-2);
    bottom: var(--space-2);
    width: 3px;
    border-radius: 0 var(--radius-full) var(--radius-full) 0;
    background: var(--color-accent);
  }

  /* Active-Press auf Desktop */
  .nav-sidebar .nav-item:active {
    transform: scale(0.97);
  }

  /* ── Einstellungen ans Ende ──
   *
   * HIER STAND `margin-top: auto` AN `.nav-sidebar__items > …`. Ein Auto-Rand
   * verteilt FREIEN Raum, und bei Ueberlauf gibt es keinen - der Pin fiel also
   * genau in dem Fall aus, fuer den er gedacht war (gemessen 1280x720:
   * scrollHeight 666 in clientHeight 448, Einstellungen 218px unter der Falz).
   *
   * Der Eintrag haengt seit 2026-08-10 als Geschwister der Liste in der
   * Sidebar-Spalte (router.js) und braucht deshalb gar keinen Rand mehr: was
   * nicht im Scroller liegt, scrollt nicht weg. Bleibt nur die Naht zur Liste
   * darueber, damit er nicht an ihrem letzten Eintrag klebt. */
  .nav-sidebar > .nav-item--pinned-end {
    margin-top: var(--space-1);
    flex-shrink: 0;
  }

  /* UND EINE KANTE, SOBALD DIE LISTE UEBERLAEUFT. Ohne sie liest sich der
   * gepinnte Eintrag als naechster Listeneintrag - im Screenshot stand
   * „Einstellungen" direkt unter „Belohnungen", waehrend „Menschen" und
   * „Finanzen" dazwischen weggescrollt waren. Die Fade-Maske sagt „hier geht es
   * weiter" und wird dadurch zur Falle: was darunter steht, gehoert nicht mehr
   * dazu.
   *
   * `wireScrollFade` setzt `has-fade-end` genau dann, wenn unterhalb noch
   * Eintraege liegen - dieselbe Bedingung, aus der die Naht ihre Berechtigung
   * zieht. Passt die Liste, bleibt die Sidebar ununterbrochen. */
  .nav-sidebar__items.has-fade-end + .nav-item--pinned-end {
    margin-top: var(--space-2);
    padding-top: var(--space-2);
    border-top: 1px solid var(--color-border-subtle);
  }

  /* ── Sidebar eingeklappt (icon-only) ── */
  html.sidebar-collapsed {
    --sidebar-width: 56px;
  }

  html.sidebar-collapsed .nav-sidebar .nav-sidebar__logo {
    padding-right: 14px;
  }

  html.sidebar-collapsed .nav-sidebar .nav-sidebar__brand-text {
    display: none;
  }

  /* Eingeklappt hat die Logo-Zeile 56px Breite - zu wenig fuer Logo UND zwei
   * Werkzeuge. Sie stapelt dann: Logo, darunter Einklappen und Suche auf der
   * Icon-Flucht der Module (X=28). Der Platz dafuer ist da, weil die vier
   * Sektionslabels eingeklappt auf null Hoehe fallen. */
  html.sidebar-collapsed .nav-sidebar .nav-sidebar__logo {
    flex-direction: column;
    align-items: flex-start;
    height: auto;
    gap: var(--space-1);
    padding-block: var(--space-1h) 0;
  }

  html.sidebar-collapsed .nav-sidebar .nav-sidebar__logo-actions {
    flex-direction: column-reverse;
    margin-inline-start: calc(-1 * var(--space-0h));
  }

  /* Eingeklappt: Labels bleiben im Layout (Sidebar-Breite ist fix, overflow:hidden
   * beschneidet den Überhang), nur per Opacity verborgen. So kein „display:block"-
   * Pop, der den Text mitten in der Breiten-Animation auf Einzelzeichen klippt —
   * und der Accessible Name des Items bleibt erhalten (besser für Screenreader). */
  html.sidebar-collapsed .nav-sidebar .nav-item__label {
    opacity: 0;
    transition: opacity var(--duration-xs) ease;
  }

  html.sidebar-collapsed .nav-sidebar .nav-sidebar__account-chevron {
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--duration-xs) ease;
  }

  /* Eingeklappt: anders als die Nav-Item-Labels (die neben ihrem Icon in
   * derselben Zeile sitzen und nur ausgeblendet werden müssen) belegt ein
   * Sektionslabel eine eigene Zeile — bliebe es nur per opacity verborgen,
   * klaffte zwischen den Icon-Gruppen eine leere Zeile (#501). Daher zusätzlich
   * die Box auf null Höhe kollabieren; der Flyout (Hover/Fokus) stellt sie her. */
  html.sidebar-collapsed .nav-sidebar .nav-section-label {
    opacity: 0;
    height: 0;
    padding-block: 0;
    overflow: hidden;
    transition: opacity var(--duration-xs) ease;
  }

  /* ── Eingeklappt: Rail expandiert bei Hover/Fokus und zeigt Labels ──
   * Wandelt den Icon-only-Recall in Recognition-on-demand für Maus (hover) und
   * Tastatur (focus-within). Touch ist bewusst NICHT abgedeckt: ein Tap
   * navigiert sofort und verweilt nicht im Fokus — auf Touch bleibt der
   * eingeklappte Rail icon-only (bei ≥1024px jedoch selten). Die Sidebar ist
   * fixed und überlagert Inhalt beim Aufklappen wie ein Flyout — --sidebar-width
   * (und damit der .app-content-Margin) bleibt 56px, also kein Reflow. Die
   * Breite animiert über die bestehende width-Transition. */
  html.sidebar-collapsed:not(.sidebar-collapse-pointer-lock) .nav-sidebar:hover,
  html.sidebar-collapsed .nav-sidebar:focus-within {
    width: var(--sidebar-width-expanded);
    box-shadow: var(--shadow-lg);
  }

  /* HOVER-ABSICHT (Re-Critique 2026-09-27, A1 P3-1): der Zeiger, der nur auf
   * dem Weg zum Inhalt ueber die Leiste streicht, klappte sie jedes Mal aus -
   * 400ms `ease`, ohne Verzoegerung. Jetzt wartet das Aufklappen
   * `--sidebar-hover-intent` (200ms); verlaesst der Zeiger die Leiste vorher,
   * beginnt die Transition nie. Die Kurve ist die der Shell (`--ease-out`).
   * Nur fuer den Zeiger: der Tastaturfokus (`:focus-within`) klappt sofort
   * auf, dort gibt es keine Durchfahrt. Das Einklappen (Basisregel) wartet
   * nicht. */
  html.sidebar-collapsed:not(.sidebar-collapse-pointer-lock) .nav-sidebar:hover:not(:focus-within) {
    transition:
      width var(--duration-xl) var(--ease-out) var(--sidebar-hover-intent),
      box-shadow var(--duration-xl) var(--ease-out) var(--sidebar-hover-intent);
  }

  html.sidebar-collapsed:not(.sidebar-collapse-pointer-lock) .nav-sidebar:hover .nav-sidebar__brand-text,
  html.sidebar-collapsed .nav-sidebar:focus-within .nav-sidebar__brand-text {
    display: flex;
  }

  /* Flyout: die Logo-Zeile nimmt wieder ihre Zeilenform an - Logo, Name und
   * die zwei Werkzeuge nebeneinander, wie ausgeklappt. */
  html.sidebar-collapsed:not(.sidebar-collapse-pointer-lock) .nav-sidebar:hover .nav-sidebar__logo,
  html.sidebar-collapsed .nav-sidebar:focus-within .nav-sidebar__logo {
    flex-direction: row;
    align-items: center;
    height: var(--target-base);
    gap: var(--space-3);
    padding-block: 0;
  }

  html.sidebar-collapsed:not(.sidebar-collapse-pointer-lock) .nav-sidebar:hover .nav-sidebar__logo-actions,
  html.sidebar-collapsed .nav-sidebar:focus-within .nav-sidebar__logo-actions {
    flex-direction: row;
    margin-inline-start: auto;
  }

  /* Beim Aufklappen die Labels erst einblenden, NACHDEM die Breite gewachsen ist
   * (Delay ≈ Dauer der width-Transition 0.4s). So erscheint der Text bei voller
   * Breite statt beschnitten mitzuwachsen. Ausblenden (un-hover) läuft ohne Delay
   * über die Basis-Regel oben. */
  html.sidebar-collapsed:not(.sidebar-collapse-pointer-lock) .nav-sidebar:hover .nav-item__label,
  html.sidebar-collapsed .nav-sidebar:focus-within .nav-item__label,
  html.sidebar-collapsed:not(.sidebar-collapse-pointer-lock) .nav-sidebar:hover .nav-sidebar__account-chevron,
  html.sidebar-collapsed .nav-sidebar:focus-within .nav-sidebar__account-chevron,
  html.sidebar-collapsed:not(.sidebar-collapse-pointer-lock) .nav-sidebar:hover .nav-section-label,
  html.sidebar-collapsed .nav-sidebar:focus-within .nav-section-label {
    opacity: 1;
    /* Nach Absicht UND Breite: erst wenn die Leiste steht, kommt der Text. */
    transition: opacity var(--duration-sm) var(--ease-out) calc(var(--sidebar-hover-intent) + var(--duration-md));
  }

  /* Flyout: die eingeklappt auf null Höhe kollabierten Sektionslabels wieder
   * als eigene Zeile aufspannen (Gegenstück zur Kollaps-Regel oben). */
  html.sidebar-collapsed:not(.sidebar-collapse-pointer-lock) .nav-sidebar:hover .nav-section-label,
  html.sidebar-collapsed .nav-sidebar:focus-within .nav-section-label {
    height: auto;
    padding-block: var(--space-1h) var(--space-0h);
  }

  /* ── Fokus-Ring der Nav-Items/Toggle nach innen versetzt ──
   * Die Items sitzen flush-links an der geclippten Sidebar-Kante
   * (.nav-sidebar overflow:hidden, .nav-sidebar__items overflow-y:auto → auch
   * horizontal beschnitten). Ein positiver --focus-ring-offset würde den Ring an
   * der linken Kante abschneiden; der Inset-Wert bleibt vollständig sichtbar -
   * genau der Fall, für den tokens.css §7b das zweite Offset-Token führt. */
  .nav-sidebar .nav-item:focus-visible,
  .nav-sidebar__tool:focus-visible {
    outline-offset: var(--focus-ring-offset-inset);
  }
}


/* --------------------------------------------------------
 * Cards
 *
 * Einheitliches Card-Pattern für alle Module:
 * - Padding: 16px (mobile), 20px (desktop)
 * - Radius: 12px
 * - Shadow: subtle (sm)
 * - Internes Spacing: 8–12px
 * - Hover auf Desktop: leichter Lift (1px)
 *
 * Varianten:
 * .card              - Basis (kein Padding)
 * .card--padded      - Mit Padding
 * .card--compact     - Enges Padding (12px)
 * .card--flat        - Kein Shadow, nur Border
 * .card--interactive - Hover-Lift + Cursor
 * -------------------------------------------------------- */
.card {
  background-color: var(--color-surface);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm);
  overflow: hidden;
}

.card--padded {
  padding: var(--space-4);
}

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

.card--flat {
  box-shadow: none;
  border: 1px solid var(--color-border-subtle);
}

.card--interactive {
  cursor: pointer;
  transition:
    transform var(--transition-fast),
    box-shadow var(--transition-fast),
    background-color var(--transition-fast);
}

.card--interactive:hover {
  transform: translateY(-3px) scale(1.002);
  box-shadow: var(--shadow-lg);
}

.card--interactive:active {
  transform: scale(0.98);
  background-color: var(--color-surface-3);
  box-shadow: var(--shadow-sm);
}

.card--interactive:focus-visible {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}

@media (min-width: 1024px) {
  .card--padded {
    padding: var(--space-5);
  }
}

/* ── Card-Inhalts-Utilities ── */
.card__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  padding-bottom: var(--space-3);
  margin-bottom: var(--space-3);
  border-bottom: 1px solid var(--color-border-subtle);
}

.card__title {
  font-size: var(--type-card-title);
  font-weight: var(--font-weight-semibold);
  color: var(--color-text-primary);
  text-wrap: balance;
}

.card__body {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

/* --------------------------------------------------------
 * Modal-Basis
 *
 * Einheitliches Modal-Pattern für alle Module:
 * - Mobile: Bottom-Sheet (gerundet oben)
 * - Desktop: Zentriert (max-width variabel)
 * - Overlay: var(--color-overlay)
 * - Padding: 16px
 * - Radius: 16px
 * -------------------------------------------------------- */
.modal-overlay {
  position: fixed;
  inset: 0;
  /* Modul-getönter, leicht geblurter Backdrop: das Modul bleibt als
   * weiches, eingefärbtes Bild hinter dem Panel sichtbar statt durch eine
   * flache Dim-Fläche ersetzt zu werden. --blur-xs wird unter
   * prefers-reduced-transparency/-contrast automatisch zu blur(0) — der
   * Fallback unten stellt dann ein volldeckenderes Overlay wieder her. */
  background-color: color-mix(in srgb, var(--color-accent) var(--tint-wash), var(--color-overlay-glass));
  backdrop-filter: var(--blur-xs) saturate(120%);
  -webkit-backdrop-filter: var(--blur-xs) saturate(120%);
  z-index: var(--z-modal);
  display: flex;
  align-items: center;
  justify-content: center;
  /* clip, NICHT hidden (#805): dieselbe Falle wie beim Panel, nur eine Ebene
   * hoeher. hidden macht auch das Overlay zu einer Scroll-Box ohne Scrollbar -
   * unbedienbar fuer den Nutzer, programmatisch scrollbar fuer den Browser.
   * Bekam das Overlay einen absolut positionierten Nachfahren (das .sr-only-
   * Input des Aufgaben-Formulars, siehe .modal-panel), wuchs seine Scroll-Box
   * auf 1259px und Chrome schob beim Oeffnen eines <select> das komplette
   * Panel samt Kopfzeile und Schliessen-X aus dem Bild. */
  overflow: clip;
  padding: max(var(--space-3), env(safe-area-inset-top))
           max(var(--space-3), env(safe-area-inset-right))
           max(var(--space-3), env(safe-area-inset-bottom))
           max(var(--space-3), env(safe-area-inset-left));
  animation: modal-overlay-in var(--duration-md) ease forwards;
  /* iOS Safari: click-Events auf non-interactive divs erfordern cursor:pointer */
  cursor: pointer;
}

@media (min-width: 768px) {
  .modal-overlay {
    padding: var(--space-6);
  }
}

.modal-panel {
  background-color: var(--color-surface);
  width: 100%;
  /* Mobile: bewusst auf ~88dvh gedeckelt, damit oben ein Streifen des
   * geblurten Moduls sichtbar bleibt — das Sheet wirkt als Karte über dem
   * Modul, nicht als Vollbild-Takeover. Safe-Areas bleiben respektiert. */
  max-height: min(calc(var(--viewport-height) * 0.88), calc(var(--viewport-height) - (2 * var(--space-3)) - env(safe-area-inset-top) - env(safe-area-inset-bottom)));
  /* clip, NICHT hidden (#805): hidden erzeugt eine Scroll-Box, die der Nutzer
   * mangels Scrollbar nicht bedienen kann, der Browser aber programmatisch
   * scrollt - Chrome ruft beim Fokussieren eines <select> scrollIntoView auf
   * allen Vorfahren auf. Das Panel rutschte dadurch samt Kopfzeile und
   * Schliessen-X aus dem Sichtfeld, ohne Weg zurueck. Ausloeser war ein
   * .sr-only-Input (position:absolute, im Fluss), das dem overflow:auto des
   * Bodys entkommt und die Scroll-Box des Panels auf 1245px aufblies.
   * Gescrollt wird strukturell nur im Body; clip nimmt dem Panel die
   * Scroll-Box, ist aber visuell deckungsgleich mit hidden. */
  overflow: clip;
  /* Breakpoint-UNABHAENGIG (#805, zweite Runde): das Panel muss der Containing
   * Block seiner absolut positionierten Nachfahren sein, sonst fangen die eine
   * Ebene hoeher am fixed .modal-overlay an und blaehen DESSEN Box auf. Bis
   * v2.21.1 stand das nur in der Mobile-Media-Query; ab 768px hielt allein der
   * transform-Endwert der Einfahr-Animation das Panel zufaellig in der Rolle -
   * und genau den nimmt `prefers-reduced-motion: reduce` weg. Eine Zusage, die
   * an einer Animation haengt, ist keine. */
  position: relative;
  display: flex;
  flex-direction: column;
  border-radius: var(--radius-lg);
  border: 1px solid var(--color-border-subtle);
  box-shadow: var(--shadow-lg);
  /* Dezentes Einfahren: kurzer Hub + leichte Skalierung statt vollem
   * translateY(100%)-Vollhub. Wirkt leichter und weniger einnehmend. */
  animation: modal-sheet-in 280ms var(--ease-out) forwards;
  /* Cursor vom Overlay zurücksetzen */
  cursor: default;
}

@media (min-width: 768px) {
  .modal-panel {
    max-width: 520px;
    max-height: calc(var(--viewport-height) - (2 * var(--space-6)));
    border-radius: var(--radius-lg);
    animation: modal-scale-in var(--duration-md) var(--ease-out) forwards;
  }
  .modal-panel--sm { max-width: 400px; }
  .modal-panel--lg { max-width: 680px; }
  .modal-panel--xl { max-width: min(960px, 95vw); max-height: calc(var(--viewport-height) - (2 * var(--space-6))); }
}

.modal-panel__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-4);
  border-bottom: 1px solid var(--color-border-subtle);
  background-color: var(--color-surface);
  flex-shrink: 0;
}

.modal-panel__title {
  font-size: var(--type-section-title);
  font-weight: var(--font-weight-semibold);
  text-wrap: balance;
}

.modal-panel__close {
  width: var(--target-base);
  height: var(--target-base);
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-full);
  color: var(--color-text-secondary);
  transition: background-color var(--transition-fast);
  position: relative;
  flex-shrink: 0;
}

.modal-panel__close:hover {
  background-color: var(--color-surface-3);
}

/* Kopf-Bedienelemente als Gruppe: das Schließen-X behält seinen Platz in der
 * Ecke, eine optionale Kopf-Aktion („Bearbeiten") setzt sich links daneben. */
.modal-panel__header-actions {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  flex-shrink: 0;
}

/* Textbutton im Kopf. Bewusst Text statt Icon: „Bearbeiten" ist eine benannte
 * Absicht, in die man nicht hineinstolpern soll - ein Stiftsymbol wäre ein
 * Rätsel an der Stelle, an der die Ansicht ihren Modus wechselt. */
.modal-panel__action {
  min-height: var(--target-base);
  padding: 0 var(--space-3);
  border-radius: var(--radius-full);
  /* Die Stimme fuehrt (Eine-Stimme-Regel): „Bearbeiten" tut in jedem Modul
   * dasselbe. Bis 2026-08-10 stand hier der Modulton mit der Begruendung, die
   * Kopf-Aktion gehoere zum Modul - sie gehoert zur Bedienung. */
  color: var(--color-accent);
  font-size: var(--type-body);
  font-weight: var(--font-weight-medium);
  white-space: nowrap;
  transition: background-color var(--transition-fast);
}

/* Sobald eine Flaeche darunter faehrt, zieht der Text auf primary - dieselbe
 * Bewegung wie bei .btn--ghost:hover. Der Modul-Akzent haelt seine Zusage
 * ">=5:1 auf weiss" (tokens.css) auf dem Kopf-Grund, aber nicht mehr auf dem
 * Hover-Grau: dort standen elf der achtzehn Tints zwischen 4,24 und 4,31.
 * Die Modul-Identitaet traegt der Ruhezustand, nicht der Hover. */
.modal-panel__action:hover {
  background-color: var(--color-surface-3);
  color: var(--color-text-primary);
}

.modal-panel__body {
  padding: var(--space-4);
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  /* Kein Scroll-Chaining: Am Ende des Modal-Inhalts weiterzuwischen darf nicht
   * die Seite dahinter bewegen. Fällt beim Sheet am stärksten auf, weil dort
   * gewischt statt gescrollt wird und die Detailansicht kurz genug ist, um
   * schnell am Ende anzukommen. */
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  /* Container für Formular-Raster: 2-Spalten-Felder collabieren nach
   * MODALBREITE (sm 400 / md 520 / lg 680), nicht Viewport. Ein sm-Modal
   * auf großem Desktop bleibt so einspaltig statt gequetscht. */
  container: modal-body / inline-size;
}

/* Modal-Content-Grids (z. B. 2 Spalten in Formularen) */
.modal-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-3);
  align-items: stretch;
}

.modal-grid--2 {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.modal-grid > * {
  min-width: 0;
}

.modal-grid > .form-group {
  margin-bottom: 0;
}

/* Gleich hohe Karten/Boxen in Modal-Grids */
.modal-panel__body .grid {
  align-items: stretch;
}

.modal-panel__body .grid > .card {
  height: 100%;
  display: flex;
  flex-direction: column;
}

@container modal-body (max-width: 480px) {
  .modal-grid--2 {
    grid-template-columns: 1fr;
  }
}

/* Live-Changelog modal */
.changelog-modal {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

.changelog-summary {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: var(--space-2);
}

.changelog-summary__item {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding: var(--space-3);
  border: 1px solid var(--color-border-subtle);
  border-radius: var(--radius-sm);
  background: var(--color-surface-2);
  min-width: 0;
}

.changelog-summary__item span {
  font-size: var(--text-xs);
  font-weight: var(--font-weight-medium);
  color: var(--color-text-tertiary);
}

.changelog-summary__item strong {
  font-size: var(--text-lg);
  color: var(--color-text-primary);
  overflow-wrap: anywhere;
}

.changelog-version-note {
  margin: 0;
  color: var(--color-text-secondary);
  font-size: var(--text-sm);
}

.changelog-version-note--warning {
  color: var(--color-warning, var(--color-text-secondary));
}

/* „Version X ist verfügbar" - dieselbe Nachricht wie der Punkt an der
   Navigation, deshalb derselbe Akzent statt einer Warnfarbe (#490). */
.changelog-version-note--update {
  color: var(--color-accent);
  font-weight: var(--font-weight-medium);
}

.changelog-status {
  padding: var(--space-4);
  border: 1px solid var(--color-border-subtle);
  border-radius: var(--radius-sm);
  font-size: var(--text-sm);
}

.changelog-status--muted {
  color: var(--color-text-secondary);
  background: var(--color-surface-2);
}

.changelog-status--error {
  color: var(--color-danger);
  background: color-mix(in srgb, var(--color-danger) var(--tint-wash), var(--color-surface));
}

.changelog-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

.changelog-release {
  padding: var(--space-4);
  border: 1px solid var(--color-border-subtle);
  border-radius: var(--radius-sm);
  background: var(--color-surface);
}

.changelog-release--current {
  border-color: color-mix(in srgb, var(--color-accent) var(--tint-hint), var(--color-border));
  background: color-mix(in srgb, var(--color-accent) var(--tint-state), var(--color-surface));
}

.changelog-release__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  margin-bottom: var(--space-3);
}

.changelog-release__version {
  margin: 0;
  font-size: var(--text-xl);
  color: var(--color-text-primary);
}

.changelog-release__badge {
  flex: 0 0 auto;
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-full);
  /* Eine Versionsnummer nennt weder eine Identitaet noch eine Stufe - sie ist
   * ein Datum in Ziffernform. Hier stand die Stimme zweimal: als 16-%-Flaeche
   * und als Schrift darauf. Neutral, wie jedes Etikett ohne Aussage. */
  background: var(--color-fill-well);
  color: var(--color-text-primary);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-semibold);
}

.changelog-section + .changelog-section {
  margin-top: var(--space-3);
}

.changelog-section__title {
  margin: 0 0 var(--space-2);
  font-size: var(--text-sm);
  color: var(--color-text-secondary);
}

.changelog-section__list {
  margin: 0;
  padding-left: var(--space-5);
  color: var(--color-text-primary);
}

.changelog-section__list li + li {
  margin-top: var(--space-1);
}

.changelog-release__empty {
  margin: 0;
  color: var(--color-text-tertiary);
  font-size: var(--text-sm);
}

/* Der Verweis aufs Nutzerhandbuch im Hilfe-Dialog (#799). Abgesetzt unter den
 * Kuerzeln, weil er etwas anderes ist als sie: die Kuerzel gehoeren zur App,
 * das Handbuch gehoert jemand anderem. */
.help-guide {
  margin: var(--space-4) 0 0;
  padding-top: var(--space-3);
  border-top: 1px solid var(--color-border-subtle);
  font-size: var(--text-sm);
}

/* Der Reset nimmt jedem `a` Farbe und Unterstreichung (reset.css) - ein Link
 * im Fliesstext ist danach von Text nicht zu unterscheiden. Hier steht beides
 * wieder: die Akzentfarbe UND die Unterstreichung, weil Farbe allein kein
 * Erkennungsmerkmal ist, auf das sich jeder verlassen kann. */
.help-guide a {
  color: var(--color-accent);
  text-decoration: underline;
  text-underline-offset: 3px;
}

.help-guide__hint {
  display: block;
  margin-top: var(--space-1);
  color: var(--color-text-tertiary);
}

/* „Neu in deiner App" (#496): der Block, mit dem die Ansicht oeffnet. Traegt
 * als einziger die Akzentfarbe - er ist die Antwort auf die Frage, mit der
 * jemand herkommt, die Versionsliste darunter ist das Archiv. */
.changelog-whats-new {
  padding: var(--space-4);
  border: 1px solid color-mix(in srgb, var(--color-accent) var(--tint-hint), var(--color-border));
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--color-accent) var(--tint-state), var(--color-surface));
}

.changelog-whats-new__title {
  margin: 0;
  font-size: var(--text-lg);
  color: var(--color-text-primary);
}

.changelog-whats-new__since {
  margin: var(--space-1) 0 var(--space-3);
  font-size: var(--text-sm);
  color: var(--color-text-secondary);
}

.changelog-whats-new__more {
  margin: var(--space-2) 0 0;
  font-size: var(--text-sm);
  color: var(--color-text-tertiary);
}

/* Der Vorspann ist die Zeile, die man scannt - er traegt das Gewicht, die
 * Begruendung darunter nicht. Das Dreieck steht nur, wo es etwas aufklappt;
 * Eintraege ohne Begruendung sind schlichte Listenpunkte. */
.changelog-entry__lead {
  cursor: pointer;
  font-weight: var(--font-weight-semibold);
  color: var(--color-text-primary);
}

.changelog-entry__lead::marker {
  color: var(--color-text-tertiary);
}

.changelog-entry__detail {
  margin: var(--space-1) 0 0;
  color: var(--color-text-secondary);
  font-size: var(--text-sm);
}

.changelog-entry + .changelog-entry {
  margin-top: var(--space-2);
}

.form-stack {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

/* --------------------------------------------------------
 * Rückmeldung eines Formulars
 *
 * Beide Boxen standen bis zur Namensbereinigung als `.login-error` /
 * `.login-success` im Auth-Stylesheet, obwohl der Fehler-Kasten längst auch in
 * Aufgaben, Dokumenten und Belohnungen stand. Ein Formular kann überall stehen,
 * also steht seine Rückmeldung in der Shell-Ebene.
 * -------------------------------------------------------- */
.form-error {
  padding: var(--space-3) var(--space-4);
  background-color: var(--color-danger-light);
  color: var(--color-danger-ink);
  border-radius: var(--radius-sm);
  font-size: var(--text-sm);
  margin-bottom: var(--space-4);
}

/* Die Fehlerregion wird nach einem Fehlschlag programmatisch fokussiert
 * (tabindex="-1"), damit sehende Tastaturnutzer sie bemerken. Die rote Box
 * kommuniziert den Zustand bereits - kein zusätzlicher Fokusring nötig. */
.form-error:focus {
  outline: none;
}

/* Gegenstück zur Fehlerbox: die Erfolgsmeldung nach dem Einlösen einer
 * Einladung oder eines Reset-Links stand bisher unausgezeichnet im Fließtext. */
.form-success {
  padding: var(--space-3) var(--space-4);
  background-color: var(--color-success-light);
  color: var(--color-success-ink);
  border-radius: var(--radius-sm);
  font-size: var(--text-sm);
  margin-bottom: var(--space-4);
}

/* --------------------------------------------------------
 * Progressive Disclosure: „Weitere Einstellungen"
 * Einklappbarer Block für Sekundärfelder in Modal-Formularen.
 * -------------------------------------------------------- */
.form-advanced {
  border-top: 1px solid var(--color-border-subtle);
}

.form-advanced__summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  min-height: var(--target-base);
  padding: var(--space-2) 0;
  cursor: pointer;
  list-style: none;
  font-size: var(--type-secondary);
  font-weight: var(--font-weight-medium);
  color: var(--color-text-secondary);
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  transition: color var(--transition-fast);
}

/* Default-Disclosure-Dreieck entfernen (Cross-Browser) */
/* Mit `hint`: die Beschriftung und darunter, was hinter ihr liegt. Die zweite
 * Zeile ist eine Stufe leiser (Caption-Groesse, tertiaer) und bricht um, statt
 * den Chevron aus der Zeile zu schieben. */
.form-advanced__label {
  display: flex;
  flex-direction: column;
  gap: var(--space-0h);
  min-width: 0;
}

.form-advanced__hint {
  font-size: var(--text-xs);
  font-weight: var(--font-weight-regular);
  color: var(--color-text-tertiary);
}

.form-advanced__summary::-webkit-details-marker { display: none; }
.form-advanced__summary::marker { content: ''; }

.form-advanced__summary:hover {
  color: var(--color-text-primary);
}

.form-advanced__summary:focus-visible {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
  border-radius: var(--radius-sm);
}

.form-advanced__chevron {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
  transition: transform var(--transition-base);
}

.form-advanced[open] .form-advanced__chevron {
  transform: rotate(180deg);
}

.form-advanced__body {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  padding-top: var(--space-2);
  padding-bottom: var(--space-2);
}

@media (prefers-reduced-motion: reduce) {
  .form-advanced__chevron { transition: none; }
}

/* Dieselbe Not, dieselbe Antwort wie bei `.modal-panel__footer` weiter unten
 * (#872): eine Knopfzeile im Modal-Rumpf laeuft sonst genauso ueber ihren
 * Container hinaus. Die Begruendung steht dort, damit sie EINMAL steht. */
.modal-actions {
  display: flex;
  justify-content: flex-end;
  flex-wrap: wrap;
  gap: var(--space-2);
}

.modal-actions .btn {
  white-space: normal;
}

/* Folgen-Erklärung unter der Bestätigungsfrage (confirmModal detail-Slot). */
.modal-confirm__detail {
  margin: 0 0 var(--space-4);
  color: var(--color-text-secondary);
  font-size: var(--text-sm);
  text-wrap: pretty;
}

.modal-actions--stack {
  flex-direction: column;
  align-items: stretch;
}

/* EINE FUSSZEILE LAEUFT NICHT UEBER - SIE BRICHT UM (#872).
 *
 * WAS GENAU PASSIERTE, gemessen an der Aufgaben-Detailansicht auf 390px
 * Telefonbreite: die drei Aktionen (Löschen · Starten · Aufgabe archivieren)
 * brauchen zusammen rund 440px, die Fusszeile hat 364. Gequetscht werden die
 * Knoepfe dabei NICHT - ein Flex-Item traegt `min-width: auto` und schrumpft
 * deshalb nicht unter seine Inhaltsbreite. Stattdessen waechst die ZEILE ueber
 * den Container hinaus, und weil sie rechtsbuendig ausgerichtet ist, tut sie
 * das nach LINKS. Das Panel traegt `overflow: clip`, also faellt dort ab, was
 * heraussteht: der Löschen-Knopf sass bei x = -11, das Panel beginnt bei
 * x = 12 - dreiundzwanzig Pixel fehlten, und aus „Löschen" wurde sichtbar
 * „öschen".
 *
 * Ein abgeschnittener Knopf sagt nicht, dass er abgeschnitten ist. Es gibt
 * keine Ellipse, keinen Scrollbalken - nur ein Wort, das falsch anfaengt.
 *
 * DER UMBRUCH IST DIE ANTWORT UND KEINE UEBERLAUFLEISTE. Ein „…"-Menue wie in
 * SAP Fiori (Vorschlag des Melders) verschoebe die Grenze nur: es misst in
 * Pixeln, was in 20 Locales unterschiedlich lang ist, und braeuchte fuer den
 * Rest doch wieder einen Umbruch. Der Umbruch traegt jede Sprache ohne
 * Messung, und im Sheet ist die Hoehe da.
 *
 * DER ZWEITE TEIL GEHOERT DAZU: bricht die ZEILE, kann ein EINZELNER Knopf
 * immer noch breiter sein als das Panel (lange Locale, 200 % Zoom). Dann hat
 * die Zeile nichts mehr umzubrechen, und der Ueberlauf ist zurueck - also muss
 * der Knopf INNEN umbrechen duerfen. Das nimmt das `white-space: nowrap` von
 * `.btn` fuer genau diesen Ort zurueck. */
.modal-panel__footer {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  flex-wrap: wrap;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border-top: 1px solid var(--color-border-subtle);
}

/* JEDER Knopf in der Fusszeile, nicht nur das direkte Kind: mehrere Module
 * legen Abbrechen/Speichern in eine eigene Flex-Gruppe (Kalender, Budget-Plaene,
 * Kontakte), und deren Knoepfe traegen dasselbe `nowrap`. */
.modal-panel__footer .btn {
  white-space: normal;
}

/* Und die Gruppe selbst darf umbrechen. Fuer die Fusszeile ist sie EIN
 * Flex-Item und damit unteilbar - ohne diese Zeile laeuft sie als Ganzes ueber
 * den Rand, und der Umbruch der Fusszeile hilft nichts mehr. `flex-wrap` an
 * einem Nicht-Flex-Kind ist folgenlos, deshalb reicht der schlichte Selektor. */
.modal-panel__footer > div {
  flex-wrap: wrap;
}

/* Fußzeile INNERHALB des scrollenden Bodys (kein direktes Panel-Kind): dort
 * trägt der Modal-Rahmen die Trennlinie schon, eine zweite säße mitten im
 * Inhalt. Ersetzt 20 gleichlautende Inline-Styles, die diese Neutralisierung
 * je Modul nachbauten — mit drei verschiedenen Abständen (space-4/5/6) für
 * denselben Footer. Doppelklasse für Spezifität (0,2,0) gegen die Basisregel. */
.modal-panel__footer.modal-panel__footer--plain {
  border-top: none;
  padding: 0;
  margin-top: var(--space-4);
}

/* Nach dem strukturellen Move (modal.js) liegt die Fußzeile direkt im Panel,
 * außerhalb des scrollenden Bodys. Die höhere Spezifität (0,2,0) stellt das
 * kanonische Layout auch gegen die alten In-Body-Neutralisierer der
 * Modul-Klassen (contact-modal__footer, subscriptions-modal-footer) sicher. */
.modal-panel > .modal-panel__footer {
  border-top: 1px solid var(--color-border-subtle);
  padding: var(--space-3) var(--space-4);
  margin-top: 0;
  background-color: var(--color-surface);
  flex-shrink: 0;
}

@keyframes modal-overlay-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@keyframes modal-slide-up {
  from { transform: translateY(100%); }
  to   { transform: translateY(0); }
}

@keyframes modal-scale-in {
  from { opacity: 0; transform: scale(0.95); }
  to   { opacity: 1; transform: scale(1); }
}

/* Mobile-Sheet: sanfter kurzer Hub + leichte Skalierung (kein Vollhub). */
@keyframes modal-sheet-in {
  from { opacity: 0; transform: translateY(24px) scale(0.985); }
  to   { opacity: 1; transform: translateY(0)    scale(1); }
}

/* ── Bottom Sheet: Handle + Closing Animation (Mobile < 768px) ── */
@media (max-width: 767px) {
  /* Sheet am unteren Rand verankern: oben bleibt ein Streifen des geblurten
   * Moduls sichtbar (Karte über dem Modul statt zentrierter Vollbild-Dialog). */
  .modal-overlay {
    align-items: flex-end;
  }

  /* DER GRIFF SITZT IN DER KOPFZONE, NICHT DARUEBER (Re-Critique 2026-09-27,
   * P1 #1). Hier stand ein 36px-Polster (`--space-4 + 20px`) fuer einen 44px
   * hohen Griff-Streifen, und der Griff darin war im hellen Theme Glas-Weiss
   * auf weisser Tafel: uebrig blieb ein leerer Streifen ueber jedem Dialog,
   * der wie ein Renderfehler wirkte. Jetzt liegt der Griff (36x5, Apples
   * Grabber) im oberen Polster des Kopfes: Griff-Oberkante bis Titel-
   * Oberkante 16px (8px Luft, 5px Griff, der Rest ist die Zentrierung des
   * Titels neben dem 48px-Schliessen-X). Ein Blatt ohne Kopf behaelt ein
   * kleines Polster, damit der Griff nicht auf dem Inhalt liegt.
   * Die Farbe ist ein Token (`--sheet-grabber`): hell die kraeftigere Kante,
   * dunkel das Glas-Weiss. Die Zugflaeche ist keine Frage der Zeichnung - sie
   * steht als Griffzone (48px) in utils/sheet-drag.js. */
  .modal-panel {
    padding-top: 0;
    /* Rueckfedern nach einem Zug (utils/sheet-drag.js schreibt `translate`,
     * nicht `transform` - die Einfahrt haelt `transform` per `forwards`). */
    transition: translate var(--duration-lg) var(--ease-out);
  }

  .modal-panel:not(:has(> .modal-panel__header)) {
    padding-top: var(--space-5);
  }

  .modal-panel > .modal-panel__header {
    padding-top: var(--space-3);
  }

  .modal-panel::before {
    content: '';
    position: absolute;
    top: var(--space-2);
    left: 50%;
    transform: translateX(-50%);
    width: 36px;
    height: 5px;
    border-radius: var(--radius-full);
    background-color: var(--sheet-grabber);
    pointer-events: none;
    z-index: 1;
  }

}

/* Waehrend der Finger zieht, fuehrt er - keine Transition. Ohne die Marke
 * federt `translate` zurueck (Dialog-Sheet oben, Mehr-Blatt an `.more-sheet`).
 * Geteilte Grammatik: utils/sheet-drag.js. */
.modal-panel[data-sheet-drag="drag"],
.more-sheet[data-sheet-drag="drag"] {
  transition: none;
}

/* ── Ausgang: Tafel UND Overlay, auf jeder Breite (Critique 2026-09-26, P1-1) ──
 *
 * ZWEI KLASSEN IM SELEKTOR, NICHT EINE. Die Einfahrt steht auch in glass.css,
 * das NACH dieser Datei laedt; mit gleicher Spezifitaet gewann dort
 * `.modal-panel { animation: glass-sheet-in }`, die Tafel stand 400ms still
 * und verschwand dann hart. glass.css nimmt seine Einfahrt inzwischen selbst
 * zurueck (`:not(.modal-panel--closing)`), und diese Regel gewinnt zusaetzlich
 * aus eigener Kraft - ein Ausgang, der an der Ladereihenfolge haengt, ist
 * keiner. Guard: test-motion.js rechnet die Kaskade fuer beide Breiten nach.
 *
 * Mobil faellt das Blatt 24px (derselbe Hub wie die Einfahrt) und blendet aus.
 * Nach einer Wischgeste startet es dort, wohin der Finger es gezogen hat: der
 * Zug liegt als `translate` auf der Tafel (utils/sheet-drag.js) und setzt sich
 * mit dem `transform` der Keyframes zusammen - sonst spraenge die Tafel beim
 * Loslassen erst nach oben zurueck.
 *
 * Desktop hatte gar keinen Ausgang (modal.js raeumte sofort ab): jetzt ein
 * kurzes Zuruecknehmen, Scale 0.98 + Blende, 150ms.
 *
 * Das Overlay blendet mit aus und laesst waehrenddessen Tipps durch - ein
 * zweiter Tipp traf vorher das noch stehende Overlay und verpuffte. */
.modal-panel.modal-panel--closing {
  animation: modal-scale-out var(--duration-sm) var(--ease-out) forwards;
}

.modal-overlay.modal-overlay--closing {
  animation: modal-overlay-out var(--duration-sm) var(--ease-out) forwards;
  pointer-events: none;
}

@media (max-width: 767px) {
  .modal-panel.modal-panel--closing {
    animation: sheet-out var(--duration-md) var(--ease-out) forwards;
  }

  .modal-overlay.modal-overlay--closing {
    animation-duration: var(--duration-md);
  }
}

@keyframes sheet-out {
  from { transform: translateY(0); }
  to   { transform: translateY(24px); opacity: 0; }
}

@keyframes modal-scale-out {
  from { opacity: 1; transform: scale(1); }
  to   { opacity: 0; transform: scale(0.98); }
}

@keyframes modal-overlay-out {
  from { opacity: 1; }
  to   { opacity: 0; }
}

/* Unter reduzierter Bewegung schliesst modal.js ohne Ausgang (sofort); die
 * Regeln hier halten auch den Fall still, in dem die Klassen doch gesetzt
 * werden. */
@media (prefers-reduced-motion: reduce) {
  .modal-panel {
    animation: none;
  }
  .modal-panel.modal-panel--closing,
  .modal-overlay.modal-overlay--closing {
    animation: none;
  }
}

/* --------------------------------------------------------
 * Buttons
 * -------------------------------------------------------- */
/* BUTTONFORM, app-weit EINE (HIG-Rollout Runde 3): die Kapsel. Der Direction
 * Contract nennt „Kapsel-Controls" ausdrücklich, und die Liquid-Glass-Ära hat
 * Apples Buttons durchgehend auf capsule gestellt. Bis Runde 3 stand hier
 * --radius-md, während glass.css .btn--primary/.btn--secondary auf
 * --radius-full zog (gleiche Spezifität, spätere Datei) - Modul-Aktionen
 * rendern deshalb als Vollkapsel, Ghost- und Danger-Buttons als 12px-Ecke.
 * Zwei Sprachen aus einer Ladereihenfolge; jetzt eine aus einer Regel. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-full);
  font-size: var(--text-sm);
  font-weight: var(--font-weight-medium);
  min-height: var(--target-lg);
  transition:
    background-color var(--transition-fast),
    box-shadow var(--transition-fast),
    transform var(--transition-fast);
  cursor: pointer;
  white-space: nowrap;
}

@media (min-width: 1024px) {
  .btn {
    min-height: var(--target-md);
  }
}

.btn:active {
  transform: scale(0.98);
}

.btn:focus-visible {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}

/* EINE STIMME: der Primärknopf trägt app-weit Violett, nicht den Modulton.
 * `--color-accent` IST die Stimme; der Modulton läuft über
 * `--active-module-accent`, das der Router aufs Dokument setzt, und erreicht
 * diese Regel nicht.
 *
 * Hier stand bis v2.2.0 die umgekehrte Ansage („Modul-Akzent führt, wie FAB und
 * Fokusringe; Violett bleibt Shell-Flächen vorbehalten"). Die Eine-Stimme-Regel
 * hat sie abgelöst - der Kommentar hat sie überlebt, die Deklaration darunter
 * nicht. Die 88%/76%-Abdunklung bleibt: sie reproduziert die
 * „Action"-Abstufung des bisherigen --color-btn-primary. */
.btn--primary {
  background-color: color-mix(in srgb, var(--color-accent) 88%, var(--neutral-950));
  color: var(--color-ink-on-vivid);
  box-shadow: var(--shadow-sm);
}

.btn--primary:hover {
  background-color: color-mix(in srgb, var(--color-accent) 76%, var(--neutral-950));
  box-shadow: var(--shadow-md);
}

.btn--secondary {
  background-color: transparent;
  color: var(--color-accent);
  border: 1.5px solid var(--color-border);
}

.btn--secondary:hover {
  background-color: color-mix(in srgb, var(--color-accent) var(--tint-state), transparent);
  border-color: var(--color-accent);
}

.btn--danger {
  background-color: var(--color-danger);
  color: var(--color-ink-on-vivid);
}

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

/* Zerstörende Aktionen mit sekundärem Gewicht (Trennen, Widerrufen, Abmelden).
   Ohne diese Regel fiel die Klasse auf die UA-Farbe `buttontext` zurück - im
   Dark Mode schwarz auf Fast-Schwarz (1.32:1). */
.btn--danger-outline {
  background-color: transparent;
  color: var(--color-danger);
  border: 1.5px solid var(--color-danger);
}

.btn--danger-outline:hover {
  background-color: var(--color-danger);
  color: var(--color-ink-on-vivid);
  border-color: var(--color-danger);
}

/* AUSNAHME vom Fokusring-Token: der Knopf verspricht eine destruktive Folge,
 * und die Gefahrenfarbe trägt er schon in Rand und Text. Über
 * `--focus-ring-color` statt `outline-color`, damit die Ausnahme dieselbe
 * Stellschraube benutzt wie die übrigen fünf. */
.btn--danger-outline:focus-visible {
  --focus-ring-color: var(--color-danger);
}

.btn--ghost {
  background-color: transparent;
  color: var(--color-text-secondary);
}

/* EINE Hover-Deklaration. Bis zur Aufräumrunde standen hier --color-surface-2
 * und in glass.css derselbe Selektor mit dieser Tönung plus backdrop-filter -
 * zwei Farbwerte für denselben Zustand, von denen faktisch immer der zweite
 * gewann. Der Blur ist entfallen (Glas gehört dem Chrome), der Farbwert ist
 * geblieben. */
.btn--ghost:hover {
  background-color: color-mix(in srgb, var(--color-text-tertiary) 10%, transparent);
  color: var(--color-text-primary);
}

/* Zerstörende Aktion mit tertiärem Gewicht: roter Text, aber ohne Rahmen/Füllung,
   damit „Löschen" im Event-Popover hinter „Bearbeiten" zurücktritt statt gleich
   gewichtet daneben zu stehen (Critique-Minor). */
.btn--danger-ghost {
  background-color: transparent;
  color: var(--color-danger);
}

.btn--danger-ghost:hover {
  background-color: var(--color-surface-2);
  color: var(--color-danger-hover);
}

/* AUSNAHME wie .btn--danger-outline: Gefahrenfarbe statt Modul-Akzent. */
.btn--danger-ghost:focus-visible {
  --focus-ring-color: var(--color-danger);
}

/* Eingerasteter Zustand eines Umschalt-Buttons (z. B. Mehrfachauswahl in
   Aufgaben). Trägt `aria-pressed="true"`; ohne diese Regel war der aktive
   Zustand nur für Screenreader erkennbar, visuell aber identisch zum Ruhezustand.
   Steht nach allen Varianten, damit es sie bei gleicher Spezifität überschreibt. */
.btn--active {
  background-color: var(--color-accent);
  color: var(--color-ink-on-vivid);
  border-color: transparent;
}

/* DER KLEINE KNOPF fuer dichte Aktionsreihen (Kartenfuss, Zeilenaktion,
 * Hinzufuegen unter einer Liste). Er stand bis 2026-09-24 in rewards.css,
 * und das laedt der Router nur auf /rewards: an rund hundert Stellen in
 * Einstellungen, Aufgaben, Gesundheit, Inventar, Dokumenten und Kalender
 * trug `btn--sm` nichts, und der Knopf war ein normaler - derselbe Fehler wie
 * `.form-hint` in settings.css (test-frontend-audit: „global geladen").
 *
 * KLEIN HEISST SCHMALER, NICHT UNTER DIE ZIELGROESSE. In rewards.css stand
 * `min-height: var(--target-sm)` (32px) - ein Token, das tokens.css selbst
 * „kein Touch-Target" nennt. Solange die Regel nur auf /rewards galt, fiel
 * das nicht auf; global haette sie die Einnehmen-Knoepfe der Gesundheit, die
 * in schmalen Karten nur ihr Symbol zeigen, auf 36x32px gedrueckt - ein
 * freistehendes Ziel unter den 40px am Zeiger (Zielgroessen-Regel). Der
 * kleine Knopf spart deshalb Polsterung, Luecke und die 48px, die `.btn`
 * unter 1024px nach der BREITE traegt; die Hoehe haelt die Zielgroesse der
 * Geraetewelt (`--target-md` am Zeiger, `--target-base` am Finger).
 *
 * VOR `.btn--icon`, nicht dahinter: `btn--icon btn--sm` steht an einem
 * Dutzend Stellen, und der Icon-Knopf behaelt dort seine quadratische
 * 44px-Flaeche - der kleine Knopf verdichtet Text, kein Symbol. */
.btn--sm {
  min-height: var(--target-md);
  padding: var(--space-1) var(--space-3);
  font-size: var(--text-sm);
  gap: var(--space-1);
}

@media (pointer: coarse), (max-width: 639px) {
  .btn--sm {
    min-height: var(--target-base);
  }
}

.btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
  pointer-events: none;
}

/* Inaktiv, aber erreichbar. `disabled` nimmt einen Button aus der Tab-Ordnung -
   wer per Tastatur navigiert, erfährt dann nie, dass es die Aktion gibt, und
   der Grund für die Sperre bleibt ihm verborgen. `aria-disabled` meldet den
   Zustand, ohne den Fokus zu verweigern; der Klick wird im Handler verworfen.
   Statt Opazität (gemessen 1.9:1) eine echte gedeckte Farbe. */
.btn[aria-disabled='true'] {
  color: var(--color-text-secondary);
  border-color: var(--color-border);
  background-color: transparent;
  cursor: not-allowed;
}

.btn[aria-disabled='true']:hover {
  color: var(--color-text-secondary);
  border-color: var(--color-border);
  background-color: transparent;
}

/* ================================================================
 * Zeilen-Aktionen — geteilte Grammatik (Audit F1)
 *
 * EIN Stil für Bearbeiten/Löschen/Mehr-Aktionen in allen Listen und Karten:
 * nackter Icon-Button, ruhiger Grundzustand, klare 44px-Touch-Fläche, ohne
 * Hover-Reveal lesbar (Touch). Ersetzt die zuvor pro Modul gestreuten Stile
 * (mal Fill-Kreis, mal nacktes Icon, mal zu blass). Delete trägt --danger,
 * semantische Sonderfälle (z.B. Anrufen) setzen nur die Farbe.
 * ================================================================ */
.row-actions {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  flex-shrink: 0;
}

.row-action {
  width: var(--target-lg);
  height: var(--target-lg);
  min-width: var(--target-lg);
  min-height: var(--target-lg);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  border: none;
  background: transparent;
  color: var(--color-text-tertiary);
  border-radius: var(--radius-full);
  cursor: pointer;
  transition: background-color var(--transition-fast), color var(--transition-fast);
}

.row-action:hover {
  background: var(--color-surface-hover);
  color: var(--color-text-primary);
}

.row-action:active {
  transform: scale(0.94);
}

.row-action:focus-visible {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}

.row-action--danger:hover {
  background: var(--color-danger-light);
  color: var(--color-danger-ink);
}

.row-action--success {
  color: var(--color-success);
}

.row-action--success:hover {
  background: var(--color-success-light);
  color: var(--color-success-ink);
}

.row-action svg {
  width: 18px;
  height: 18px;
}

/* Gesperrte Zeilenaktion - dieselben zwei Zustaende wie beim `.btn`, weil
 * seit dem Komponenten-Kanon (2026-09-26) die Verwalter (Kategorien, Abo-
 * Kategorien, Zahlungsarten) ihre Pfeile am Listenrand hier sperren und jede
 * Stelle die Regel sonst selbst mitbrachte (category-manager.css,
 * subscriptions.css). `disabled` = nicht erreichbar, blass wie `.btn:disabled`;
 * `aria-disabled` = fokussierbar, der Klick wird im Handler verworfen, also eine
 * echte gedeckte Farbe statt Opazitaet und kein Hover-Grund, der eine Wirkung
 * verspricht. */
.row-action:disabled {
  opacity: 0.4;
  cursor: not-allowed;
  pointer-events: none;
}

.row-action[aria-disabled='true'],
.row-action[aria-disabled='true']:hover {
  color: var(--color-text-disabled);
  background: transparent;
  cursor: not-allowed;
}

.row-action[aria-disabled='true']:active {
  transform: none;
}

/* Auch der Icon-Button ist eine Kapsel, hier also ein Kreis - Apples eigene
 * Icon-Buttons sind rund. Die frühere --radius-sm-Ecke war der letzte Rest der
 * zwei Buttonformen: sie überschrieb die Basisregel und stand damit neben der
 * Kapsel, die dieselbe Datei zwei Bildschirme weiter oben setzt. */
/* EINE Größe, und sie schaltet nach der ZEIGERFÄHIGKEIT: --target-base ist am
 * Zeiger 44px und am Finger 48px (tokens.css). Vorher stand hier --target-lg
 * mit einem Wechsel auf --target-md ab 1024px — also nach der Breite, während
 * tokens.css als Kanon führt „das Kriterium ist die Zeigerfähigkeit, nicht die
 * Breite". Ein Tablet ab 1024px bekam damit 40px, und die Aufgaben- und die
 * Abo-Leiste hatten den Shell-Fehler je für sich lokal repariert: derselbe
 * Kopf-Icon-Knopf mass 40px in Kalender und Kontakten und 44px in Aufgaben und
 * Dokumenten. Zwei Antworten auf eine Frage, entstanden aus einem Kriterium,
 * das keines war. */
.btn--icon {
  padding: var(--space-2);
  min-height: var(--target-base);
  min-width: var(--target-base);
}


/* FAB und page-fab ausblenden wenn virtuelle Tastatur offen (nur Mobile) */
@media (max-width: 1023px) {
  .keyboard-visible .fab,
  .keyboard-visible .page-fab {
    visibility: hidden;
    pointer-events: none;
  }
}

/* --------------------------------------------------------
 * Form-Elemente
 *
 * `.input` und `.form-input` sind derselbe Alias auf dieselbe Regel (~308 vs.
 * ~155 Fundstellen, Polish-Audit PLAN.md #7) - ohne dass bisher irgendwo
 * stand, welcher Name fuer NEUEN Code gilt. Kanonisch ist `.form-input`: es
 * ist der Name, den DESIGN.md ("Inputs / Fields") und `.form-group`/
 * `.form-field`/`.form-label` schon fuehren. Bestehende `.input`-Stellen
 * bleiben unangetastet (Umbenennen aller ~308 Fundstellen kostet mehr Review
 * als es traegt) - neuer Code schreibt `.form-input`.
 * -------------------------------------------------------- */
.input,
.form-input {
  width: 100%;
  min-width: 0;
  box-sizing: border-box;
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  border: 1.5px solid var(--color-border-control);
  background-color: var(--color-surface);
  color: var(--color-text-primary);
  font-size: var(--text-base);
  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
  min-height: var(--target-lg);
}

@media (min-width: 1024px) {
  .input,
  .form-input {
    min-height: var(--target-md);
    font-size: var(--text-base);
  }
}

/* Ein Select rendert sein Chevron INNERHALB der Box, rechtsbündig. Mit dem
 * Feld-Padding von 12px läuft ein langer Optionstext darunter und wird
 * mittendrin gekappt („Nächstes Fälligkei|"). Das Polster ist deshalb kein
 * Modul-Detail, sondern gehört zum Feldkanon - app-weit, damit es nicht in
 * jedem Modul neu entdeckt werden muss. */
select.input,
select.form-input {
  padding-inline-end: var(--space-8);
  text-overflow: ellipsis;
}

.input:focus,
.form-input:focus {
  outline: none;
  border-color: var(--color-accent);
  box-shadow: 0 0 0 3px var(--color-accent-light);
}

/* Auf ALLE Textfelder, nicht nur auf .input/.form-input.
 *
 * Vorher griff die Regel nur über die zwei Klassen. Jedes Feld ohne sie fiel auf
 * Chromes UA-Default #757575 zurück, der im Dark Mode nicht mitkippt: gemessen
 * 3.46:1 an den beiden Quick-Add-Feldern der Einkaufsliste und 3.12:1 am
 * globalen Suchfeld - unter den 4.5:1, die WCAG 1.4.3 auch für Platzhaltertext
 * fordert (Critique 2026-07-29). Der Elementselektor deckt jedes künftige Feld
 * mit ab, statt auf die richtige Klasse zu hoffen. */
input::placeholder,
textarea::placeholder {
  color: var(--color-text-placeholder);
  opacity: 1; /* Firefox setzt ::placeholder sonst auf opacity < 1 und senkt den Kontrast erneut */
}

.label, .form-label {
  display: block;
  font-size: var(--text-sm);
  font-weight: var(--font-weight-medium);
  color: var(--color-text-secondary);
  margin-bottom: var(--space-1);
}

/* Nebensatz IM Label - der Hinweis, der zum Feld gehoert, aber nicht sein Name
 * ist („(Markdown-Formatierung moeglich)"). Eine Stufe leiser als das Label:
 * eine Groesse kleiner, regulaeres Gewicht, tertiaerer Text.
 *
 * WARUM ALS KLASSE: die Stelle trug ihre drei Werte inline im
 * JS-Template-String (`font-weight:400;color:var(--text-tertiary);
 * font-size:.85em`) - mit einem Token, das es nie gab (es heisst
 * `--color-text-tertiary`), also erbte der Hinweis die Label-Farbe und stufte
 * gar nicht ab. Der Typo-Guard sieht Stylesheets, keine Template-Strings; die
 * Werte kamen so aus dem System heraus (Audit 2026-08-08, P3-1). */
.form-label__hint {
  font-size: var(--text-xs);
  font-weight: var(--font-weight-regular);
  color: var(--color-text-tertiary);
}

/* DIE HINWEISZEILE UNTER EINEM FELD - global, weil sie global benutzt wird.
 *
 * Die Regel lebte bis 2026-09-24 in settings.css. Der Router laedt aber genau
 * EIN Seiten-Blatt pro Route, und `.form-hint` steht in fast jedem Modul:
 * ueberall ausser in den Einstellungen fiel sie auf den geerbten Koerpertext
 * zurueck (16px, volle Primaertinte) - lauter als das Label und das Feld, zu
 * denen sie gehoert (Critique 2026-09-24, P2, gemessen im Termin-Dialog).
 * Kalender, Budget, Kontakte, Dokumente, Inventar und die Fasten-Bausteine
 * trugen dafuer eine eigene Kopie oder eine Ersatzklasse. Die Regel
 * steht deshalb hier; `test:frontend-audit` haelt sie in einem Blatt, das
 * index.html fuer jede Route verlinkt. Nur die Zeilenlaenge der
 * Einstellungen (`.settings-page .form-hint`) bleibt in settings.css. */
.form-hint {
  font-size: var(--text-sm);
  color: var(--color-text-secondary);
}

/* Tonlagen für Hinweistexte. Vorher setzte `admin-backup.js` die Farbe des
   Verbindungstests inline (`style.color = 'var(--color-success)'`), was den
   Wert am Token vorbei ins Markup schrieb. */
.form-hint--success { color: var(--color-success); }
.form-hint--danger { color: var(--color-danger); }

.required-marker {
  color: var(--color-danger);
  margin-left: var(--space-0h);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-bold);
  line-height: 1;
}

.form-group {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  margin-bottom: var(--space-4);
}

/* ── Inline-Validierung ── */
.form-field {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

.form-field--error .input,
.form-field--error .form-input {
  border-color: var(--color-danger);
}

.form-field--valid .input,
.form-field--valid .form-input {
  border-color: var(--color-success);
}

.form-field__error {
  display: none;
  font-size: var(--text-sm);
  color: var(--color-danger);
  gap: var(--space-1);
  align-items: center;
}

.form-field--error .form-field__error {
  display: flex;
}

/* Pulse-Animation bei wiederholtem Fehler */
@keyframes field-error-pulse {
  0%, 100% { box-shadow: none; }
  40%       { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 35%, transparent); }
}

.form-field--error-repeat .input,
.form-field--error-repeat .form-input {
  animation: field-error-pulse 500ms var(--ease-out);
}

@media (prefers-reduced-motion: reduce) {
  .form-field--error-repeat .input,
  .form-field--error-repeat .form-input { animation: none; }
}

/* --------------------------------------------------------
 * Zutaten-Zeile (geteilt: Mahlzeiten- + Rezept-Modal)
 * Global statt modul-scoped, weil Modals im geteilten Overlay rendern und der
 * Router immer nur EIN Modul-Stylesheet aktiv hält. Markup: ingredient-row.js.
 * -------------------------------------------------------- */
.ingredient-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  border-radius: var(--radius-glass-inner);
}

.ingredient-row__name {
  flex: 2;
}

.ingredient-row__qty {
  flex: 1;
  min-width: 0;
}

.ingredient-row__cat {
  flex: 1.5;
  min-width: 0;
  font-size: var(--type-body);
}

.meal-type-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-medium);
  padding: var(--space-0h) var(--space-2);
  border-radius: var(--radius-full);
}

/* Die Beschriftung nimmt die lesbare Stufe, nicht die Vollfarbe: auf der eigenen
   blassen Füllung trug die Vollfarbe keine 4,5 mehr (Frühstück 4,42:1 light,
   Abendessen 4,46:1 dark). Siehe tokens.css, "die LESBARE STUFE". */
.meal-type-badge--breakfast { background: var(--meal-breakfast-light); color: var(--meal-breakfast-ink); }
.meal-type-badge--lunch     { background: var(--meal-lunch-light);    color: var(--meal-lunch-ink); }
.meal-type-badge--dinner    { background: var(--meal-dinner-light);   color: var(--meal-dinner-ink); }
.meal-type-badge--snack     { background: var(--meal-snack-light);    color: var(--meal-snack-ink); }

/* „Keine Mahlzeit" (#750) trägt bewusst keine Familienfarbe: Es benennt die
   Abwesenheit einer Zuordnung und darf sich nicht wie eine fünfte Mahlzeit
   lesen. Neutrale Fläche, ruhige Schrift, gleiche Form. */
.meal-type-badge--none      { background: var(--color-surface-2);     color: var(--color-text-secondary); }

/* --------------------------------------------------------
 * Toggle-Switch
 * Custom iOS-style toggle, ersetzt native Checkboxen.
 * Usage: <label class="toggle"><input type="checkbox"><span class="toggle__track"></span></label>
 * -------------------------------------------------------- */
.toggle {
  /* position:relative macht dieses Label zum Containing-Block der visuell
   * versteckten, absolut positionierten .toggle-Checkbox. Ohne das positioniert
   * sich die Checkbox relativ zum nächsten positionierten Vorfahren (dem
   * overflow:hidden .modal-panel); beim Antippen scrollt der Browser dann das
   * Panel statt des Modal-Bodys in die Checkbox — es entsteht ein großer leerer
   * Block und die folgenden Felder werden unerreichbar (#483, nur mobil, hier
   * beim Erinnerungs-Toggle im Termin-Modal). */
  position: relative;
  display: inline-flex;
  align-items: center;
  cursor: pointer;
  gap: var(--space-3);
}

.toggle input {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
}

.toggle__track {
  position: relative;
  width: 44px;
  height: 26px;
  background-color: var(--neutral-300);
  border-radius: var(--radius-full);
  transition: background-color var(--transition-fast);
  flex-shrink: 0;
}

.toggle__track::after {
  content: '';
  position: absolute;
  top: 3px;
  left: 3px;
  width: 20px;
  height: 20px;
  background: var(--color-surface);
  border-radius: var(--radius-full);
  box-shadow: var(--shadow-sm);
  /* Dauer UND Kurve einzeln, nicht `var(--transition-fast) var(--ease-out)`:
   * das Token ist schon „150ms ease", dahinter eine zweite Kurve macht die
   * ganze Deklaration ungueltig - der Knopf sprang beim Ausschalten zurueck,
   * waehrend er beim Einschalten federte (Critique 2026-09-26, P2-2). */
  transition: transform var(--duration-sm) var(--ease-out);
}

.toggle input:checked + .toggle__track {
  background-color: var(--color-accent);
}

.toggle input:checked + .toggle__track::after {
  transition: transform var(--duration-xl) var(--ease-glass);
}

.toggle input:checked + .toggle__track::after {
  transform: translateX(18px);
}

.toggle input:focus-visible + .toggle__track {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}

.toggle input:disabled + .toggle__track {
  opacity: 0.4;
  cursor: not-allowed;
}

@media (prefers-reduced-motion: reduce) {
  .toggle__track,
  .toggle__track::after {
    transition: none;
  }

  .fab,
  .page-fab { animation: none; }

  .swipe-row--hint > :first-child { animation: none; }

  .btn--loading::after { animation: none; }
}

/* --------------------------------------------------------
 * Skeleton-Loading
 * -------------------------------------------------------- */
.skeleton {
  /* Linienfarbe aus Surface + Ink gemischt: garantierter Kontrast in beiden
   * Themes mit EINER Definition (zuvor border-subtle/surface-2 — im Dark Mode
   * deckungsgleich mit der Kartenfläche #222220, dadurch unsichtbar). */
  background: linear-gradient(
    90deg,
    color-mix(in srgb, var(--color-text-primary) 18%, var(--color-surface)) 25%,
    color-mix(in srgb, var(--color-text-primary) 30%, var(--color-surface)) 50%,
    color-mix(in srgb, var(--color-text-primary) 18%, var(--color-surface)) 75%
  );
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.5s infinite;
  border-radius: var(--radius-sm);
}

@keyframes skeleton-shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

/* Reduced-Motion: Shimmer aus, statische Fläche bleibt sichtbar. */
@media (prefers-reduced-motion: reduce) {
  .skeleton { animation: none; }
}

/* ── Geteilte Skeleton-Liste (alle Module) ──
 * EINE Lade-Sprache statt modul-eigener „Lädt…"-Texte. Global definiert,
 * damit die Breiten-Varianten auf JEDER Seite greifen (zuvor lagen sie nur
 * in dashboard.css und waren außerhalb des Dashboards wirkungslos).
 * Markup-Hilfe: public/utils/skeleton.js → renderSkeletonList(). */
.skeleton-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

.skeleton-card {
  background-color: var(--color-surface);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm);
  padding: var(--space-4);
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

.skeleton-line {
  height: 14px;
  border-radius: var(--radius-xs);
}

.skeleton-line--title  { height: 18px; width: 45%; }
.skeleton-line--short  { width: 35%; }
.skeleton-line--medium { width: 60%; }
.skeleton-line--full   { width: 100%; }

/* --------------------------------------------------------
 * Leer-Zustände (Empty States)
 * -------------------------------------------------------- */
.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  padding: var(--space-12) var(--space-6);
  text-align: center;
  animation: empty-state-in var(--duration-2xl) var(--ease-out) both;
  /* Immer oben ansetzen, nie in der Restfläche zentriert.
   *
   * In einem Grid-Elternteil mit `align-content: start` (Rezepte, Vorrat) saß der
   * Leerzustand oben, in einer gestreckten Flex-Spalte (Einkaufen) zentrierte
   * ihn `justify-content: center` in der ganzen Resthöhe: gemessen lag der Titel
   * bei y=174 / 242 / 482, also drei Achsen für denselben Zustand (Critique
   * 2026-07-29). `flex: 0 0 auto` verhindert das Strecken; in Grid-Eltern ist es
   * ein No-op. Die 48px Innenabstand oben geben den Abstand zum Kopf. */
  flex: 0 0 auto;
}

@keyframes empty-state-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .empty-state { animation: none; }
  .empty-state__cta { animation: none; }
}

.empty-state__icon {
  width: 56px;
  height: 56px;
  color: color-mix(in srgb, var(--module-accent, var(--color-accent)) var(--tint-hint), var(--color-text-disabled));
}

/* Kreis-Ikonografie (Block 2): im Flächen-Leerzustand sitzt das Icon in einem
 * getönten Kreis-Well - das Kreis-Motiv der Bildmarke, gedimmt auf
 * Leerzustands-Lautstärke. Die Fläche nimmt die Objekt-Stufe (--tint-surface:
 * „die Tönung IST das Element - Icon-Well"), die Tinte bleibt die
 * Leerzustands-Stufe --tint-hint (oben). Eingewandert aus
 * .health-empty__icon, dem einzigen Modul, das den Kreis schon hatte.
 * NUR im .empty-state-Träger: Inline-Hinweise (z. B. die Widget-Leerzeile)
 * tragen weiter das nackte Icon - ein Kreis-Well braucht die zentrierte
 * Fläche, um nicht als Button zu lesen. */
.empty-state .empty-state__icon {
  padding: var(--space-4);
  border-radius: var(--radius-full);
  background: color-mix(in srgb, var(--module-accent, var(--color-accent)) var(--tint-surface), transparent);
}

.empty-state__title {
  font-size: var(--text-base);
  font-weight: var(--font-weight-semibold);
  color: var(--color-text-primary);
  /* Der Titel ist seit dem Audit ein <h2> (Überschriften-Semantik für
   * Screenreader). Der UA-Margin einer Überschrift würde die gap-basierte
   * Abstandsfolge des Leerzustands verdoppeln. */
  margin: 0;
}

.empty-state__description {
  font-size: var(--text-sm);
  color: var(--color-text-secondary);
  max-width: 280px;
  line-height: var(--line-height-base);
}

.empty-state__hint {
  font-size: var(--text-xs);
  color: var(--color-text-tertiary);
  max-width: 280px;
  line-height: var(--line-height-relaxed);
  padding: var(--space-2) var(--space-3);
  background-color: var(--color-surface-2);
  border-radius: var(--radius-sm);
  border: 1px solid var(--color-border-subtle);
  text-align: center;
}

.empty-state__cta {
  margin-top: var(--space-2);
  animation: list-item-in var(--duration-xl) var(--ease-out) var(--duration-xl) both;
}

/* Zwei Wege aus demselben Zustand (Dokumente: „Suche löschen" neben „Filter
 * zurücksetzen"). Sie stehen in einer Reihe, weil zwei Blöcke untereinander
 * gleich stark läsen und damit die Rangfolge verlören - die Tonwerte tragen
 * sie, nicht die Position. Auf schmalen Schirmen bricht die Reihe um.
 * Die Regel ersetzt .documents-empty-state__actions, das dasselbe für ein
 * einzelnes Modul tat. */
.empty-state__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-2);
  margin-top: var(--space-4);
}

/* Der Eigenabstand der Buttons entfällt in der Reihe: ihn trägt dort der
 * gemeinsame margin-top oben. */
.empty-state__actions > .empty-state__cta {
  margin-top: 0;
}

/* Technische Fehlerdetails: zugeklappt unauffällig, aufgeklappt lesbar und
 * kopierbar. Der Stack darf nicht die Seite horizontal schieben - er scrollt
 * in seinem eigenen Kasten. */
.empty-state__details {
  max-width: min(100%, 560px);
  width: 100%;
  margin-top: var(--space-2);
  text-align: left;
}

.empty-state__details > summary {
  font-size: var(--text-xs);
  color: var(--color-text-tertiary);
  cursor: pointer;
  text-align: center;
  padding: var(--space-1);
  border-radius: var(--radius-sm);
}

.empty-state__details > summary:hover {
  color: var(--color-text-secondary);
}

.empty-state__details > summary:focus-visible {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}

.empty-state__details > pre {
  margin-top: var(--space-2);
  padding: var(--space-3);
  background-color: var(--color-surface-2);
  border: 1px solid var(--color-border-subtle);
  border-radius: var(--radius-sm);
  color: var(--color-text-secondary);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  line-height: var(--line-height-relaxed);
  white-space: pre;
  overflow-x: auto;
  user-select: text;
}

.empty-state--compact {
  padding: var(--space-4) var(--space-3);
  gap: var(--space-2);
}
.empty-state--compact .empty-state__description {
  font-size: var(--text-sm);
}

/* --------------------------------------------------------
 * Responsive Grid (Utility)
 * -------------------------------------------------------- */
.grid {
  display: grid;
  gap: var(--space-4);
  grid-template-columns: 1fr;
}

@media (min-width: 640px) {
  .grid--2 { grid-template-columns: repeat(2, 1fr); }
}

@media (min-width: 1024px) {
  .grid--3 { grid-template-columns: repeat(3, 1fr); }
}

@media (min-width: 1440px) {
  .grid--4 { grid-template-columns: repeat(4, 1fr); }
}

/* --------------------------------------------------------
 * Layout-Primitives
 *
 * Wiederverwendbare Content-Area-Patterns für Desktop.
 * Mobile: immer single-column (Stacking).
 *
 * .layout-master-detail  - Liste links, Detail rechts (Aufgaben, Einkauf)
 * .layout-content-aside  - Hauptinhalt + schmale Seitenleiste (Kalender)
 * .layout-center         - Zentrierter schmaler Content (Settings, Login)
 * .layout-wide           - Volle Breite mit max-width (Dashboard)
 * -------------------------------------------------------- */

/* ── Master-Detail ──
 * Mobile: gestapelt (Detail wird programmatisch ein-/ausgeblendet).
 * Desktop: 2 Spalten - Liste ~40%, Detail ~60%.
 */
.layout-master-detail {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  max-width: var(--content-max-width);
  margin: 0 auto;
}

.layout-master-detail__master {
  flex: 1;
  min-width: 0;
}

.layout-master-detail__detail {
  flex: 1;
  min-width: 0;
}

@media (min-width: 1024px) {
  .layout-master-detail {
    flex-direction: row;
    gap: var(--space-6);
  }

  .layout-master-detail__master {
    flex: 0 0 380px;
    max-width: 420px;
    overflow-y: auto;
    max-height: calc(var(--viewport-height) - var(--space-16));
    position: sticky;
    top: var(--space-8);
  }

  .layout-master-detail__detail {
    flex: 1;
  }
}

@media (min-width: 1440px) {
  .layout-master-detail__master {
    flex: 0 0 420px;
    max-width: 460px;
  }
}

/* ── Content + Aside ──
 * Mobile: gestapelt.
 * Desktop: Hauptinhalt nimmt Platz ein, Aside ist 280–320px.
 */
.layout-content-aside {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  max-width: var(--content-max-width);
  margin: 0 auto;
}

.layout-content-aside__main {
  flex: 1;
  min-width: 0;
}

.layout-content-aside__aside {
  min-width: 0;
}

@media (min-width: 1024px) {
  .layout-content-aside {
    flex-direction: row;
    gap: var(--space-6);
  }

  .layout-content-aside__main {
    flex: 1;
  }

  .layout-content-aside__aside {
    flex: 0 0 280px;
    overflow-y: auto;
    max-height: calc(var(--viewport-height) - var(--space-16));
    position: sticky;
    top: var(--space-8);
  }
}

@media (min-width: 1440px) {
  .layout-content-aside__aside {
    flex: 0 0 320px;
  }
}

/* ── Center ──
 * Schmaler, zentrierter Content-Bereich (max. 720px).
 * Ideal für Settings, Formulare, Einzelansichten.
 */
.layout-center {
  max-width: var(--layout-reading);
  margin: 0 auto;
}

/* ── Wide ──
 * Volle verfügbare Breite mit max-width.
 * Für Dashboard und andere Multi-Column-Ansichten.
 */
.layout-wide {
  max-width: var(--content-max-width);
  margin: 0 auto;
}

/* ── Prose ──
 * Text-Content mit optimaler Lesebreite.
 */
.prose {
  max-width: var(--layout-reading);
}

/* ── Sticky-Header (Modul-Toolbars) ──
 * Klebt am oberen Rand beim Scrollen.
 */
.sticky-header {
  position: sticky;
  top: 0;
  z-index: var(--z-sticky);
  /* Opaque background required: backdrop-filter on position:sticky inside overflow:auto
   * triggers a WebKit compositor bug that blanks the entire scroll container (iOS 26+). */
  background-color: var(--color-bg);
  padding-bottom: var(--space-3);
  margin-bottom: var(--space-4);
}

/* Hier stand ein prefers-reduced-transparency-Fallback für fünf Modul-Köpfe -
 * der letzte Rest einer Annahme, die seit Issue #577 nicht mehr gilt. Er
 * schaltete ein `backdrop-filter` ab, das keiner von ihnen mehr trägt (der
 * Vier-Modul-Override entfiel mit dem Canonical Page Head, glass.css Sektion 28),
 * und färbte sie dabei auf `--color-surface` um, während `.page-toolbar`
 * überall sonst auf `--color-bg` steht. Ein Fallback für eine Eigenschaft, die
 * niemand setzt, ist keine Absicherung, sondern eine zweite Antwort auf eine
 * Frage, die die Regel darüber schon beantwortet: der Modulkopf ist opak, in
 * jedem Medienzustand. */

/* --------------------------------------------------------
 * Die untere Shell-Zone
 *
 * EIN STAPEL, KEIN ÜBEREINANDER. Die Geometrie stand bis Etappe 5 an
 * `.toast-container` - also zweimal, an zwei Knoten mit demselben `bottom`.
 * Solange nur je einer von beiden gefüllt war, fiel das nicht auf; eine
 * höfliche und eine bestimmte Meldung zugleich lagen aufeinander. Mit der
 * Sammelaktions-Pille (`.bulk-pill-layer`) wäre eine dritte Fläche auf
 * denselben Punkt gekommen, und die steht DAUERHAFT.
 *
 * Als Spalte rechnet Flexbox das Ausweichen selbst, und daraus folgt die
 * Reihenfolge: die Spalte ist unten verankert, wer zuerst im DOM steht, steht
 * oben. Die Pille steht zuerst - der Toast bleibt damit, wo Toasts immer
 * stehen, und die Pille weicht ihm nach oben aus. DER MIT DER FRIST BEWEGT
 * SICH NICHT: die Rücknahme des Toasts läuft nach fünf Sekunden ab, die
 * Aktionen der Pille warten, solange die Teilmenge steht.
 * -------------------------------------------------------- */
.shell-bottom-stack {
  position: fixed;
  bottom: calc(var(--nav-bottom-height) + var(--safe-area-inset-bottom) + var(--space-4));
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  z-index: var(--z-toast);
  pointer-events: none;
  width: min(calc(100% - var(--space-8)), 380px);
}

/* Eine leere Zelle zieht trotzdem ihre Lücke: `gap` gilt zwischen ALLEN
 * Kindern, auch zwischen zwei Knoten der Höhe null. Ohne diese Regel stünde
 * ein einzelner Toast um zwei Lücken (16px) zu hoch, weil Pillen-Schicht und
 * bestimmter Container leer daneben liegen. */
.shell-bottom-stack > :empty {
  display: none;
}

.toast-container,
.bulk-pill-layer {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

/* ZWISCHEN 768 UND 1023px STEHT DIE TAB-LEISTE NOCH.
 *
 * Hier ersetzte `bottom: var(--space-8)` den Abstand der Basisregel, der die
 * Leistenhöhe einrechnet - der Stapel rutschte damit über sie. Der Stapel
 * selbst ist harmlos (`pointer-events: none`), der Erinnerungs-Toast darin ist
 * es nicht: er nimmt Klicks an, um sich verwerfen zu lassen. Gemessen bei
 * 900x900 und 768x900 lag er über den Nav-Zielen, und `elementFromPoint`
 * lieferte an DREI von fünf (Kalender, Aufgaben, Küche) den Toast statt des
 * Ziels. Für die dreißig Sekunden seiner Standzeit war die halbe
 * Primärnavigation eines Tablets tot (Codex-Review zu PR #754 - dort der Pille
 * zugeschrieben, gemessen ist es der Toast).
 *
 * Die Sidebar löst die Leiste erst ab 1024px ab; bis dahin gilt derselbe
 * Abstand wie auf dem Telefon, nur ohne dessen FAB-Nachlauf. */
@media (min-width: 768px) and (max-width: 1023px) {
  .shell-bottom-stack {
    bottom: calc(var(--nav-bottom-height) + var(--safe-area-inset-bottom) + var(--space-4));
  }
}

@media (min-width: 1024px) {
  .shell-bottom-stack {
    bottom: var(--space-6);
    left: calc(var(--sidebar-width) + (100% - var(--sidebar-width)) / 2);
  }
}

/* DER STAPEL WEICHT DEN KNOEPFEN EINES OFFENEN DIALOGS AUS (#1160).
 *
 * Er steht ueber Dialogen (`--z-toast` ueber `--z-modal`), damit eine Meldung
 * waehrend eines Dialogs sichtbar bleibt - positioniert ist er aber fuer die
 * Seite. Bei 1280x900 lag der Erinnerungs-Toast genau auf "Speichern" des
 * Kalenderdialogs und nahm den Klick: die Erinnerung war verworfen, der Termin
 * nicht gespeichert. Welche Lage frei ist (ueber dem Dialog, unter ihm, ueber
 * seiner Aktionsleiste), steht erst gemessen fest; das entscheidet
 * utils/toast-placement.js und schreibt es als Variablen hierher, immer als
 * Oberkante im Bild. `measure` ist der Zwischenstand, in dem es die Hoehe in
 * der Dialogbreite abliest.
 * Attribut plus Klasse schlaegt die Media-Queries oben ohne `!important`. */
.shell-bottom-stack {
  --toast-dock-gap: var(--space-3);
}

.shell-bottom-stack[data-dock] {
  left: var(--toast-dock-center);
  width: var(--toast-dock-width);
}

.shell-bottom-stack[data-dock="placed"] {
  top: var(--toast-dock-top);
  bottom: auto;
}

/* Kein freier Platz neben dem Dialog: nur der juengste Toast bleibt sichtbar.
 * Die uebrigen bleiben im Dokument, in der Schreibweise von `.sr-only`
 * (reset.css) und `inert` (utils/toast-placement.js), damit ihre Knoepfe nicht
 * unsichtbar per Tab erreichbar sind. Sie kommen zurueck, sobald Platz ist. */
.shell-bottom-stack .toast.toast--tucked {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
  pointer-events: none;
}

.toast {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  background-color: var(--neutral-800);
  color: var(--neutral-50);
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-sm);
  font-size: var(--text-sm);
  box-shadow: var(--shadow-lg);
  pointer-events: auto;
  animation: toast-in var(--duration-lg) var(--ease-out) forwards;
  /* Der Wisch zum Verwerfen ist waagerecht - ohne diese Zeile hielt der Browser
   * sich die Deutung offen, uebernahm die Geste als Bildlauf und beendete den
   * Zeiger mit `pointercancel`, noch bevor die Schwelle erreicht war. Gemessen
   * an #821: auf dem Telefon war der Wisch damit nie ausloesbar. Senkrecht
   * bleibt beim Browser, damit die Seite unter dem Toast scrollbar bleibt;
   * `pinch-zoom` dazu, sonst verwirft er dort jeden Zwei-Finger-Bildlauf (#1276). */
  touch-action: pan-y pinch-zoom;
}

.toast__icon {
  flex-shrink: 0;
  width: 16px;
  height: 16px;
}

/* DIE BESCHRIFTETE AKTION AUF SHELL-MATERIAL - eine Schreibweise, zwei Träger.
 * Der Toast hatte sie als einziger; die Sammelaktions-Pille steht auf demselben
 * Material im selben Stapel und hätte sich sonst eine zweite gebaut. */
.toast__undo,
.list-bulkbar__action {
  flex-shrink: 0;
  padding: var(--space-1) var(--space-2);
  background: transparent;
  border: 1px solid currentColor;
  border-radius: var(--radius-full);
  color: inherit;
  font-size: var(--text-xs);
  font-weight: var(--font-weight-semibold);
  cursor: pointer;
  white-space: nowrap;
  -webkit-tap-highlight-color: transparent;
  transition: opacity var(--transition-fast), transform var(--duration-2xs) ease;
}

/* DIE MARKE AN DER KAPSEL steht standardmäßig nicht da: solange das Subjekt
 * links die Zahl trägt, wäre sie deren Echo. Sie tritt erst ein, wo das Subjekt
 * wegfällt (siehe die Container-Regel bei .list-bulkbar__subject).
 *
 * Sie NIMMT die Rezeptur der Zähler-Marke, sie TEILT ihre Regel nicht: die
 * Marke der Listen-Tabs sitzt auf heller Fläche mit dunkler Schrift, diese auf
 * dem dunklen Shell-Material mit heller. Genau die Verwechslung, die den Toast
 * im Reduced-Transparency-Fallback des Filter-Chips auf 1,08:1 gebracht hat -
 * zwei Selektoren in einer Regel behaupten denselben Grund darunter.
 * `currentColor 15%` rechnet hier deshalb gegen die Schrift der Kapsel, und die
 * dreht sich mit dem Thema: --neutral-800 ist dunkel im hellen Modus und hell
 * im dunklen, --neutral-50 umgekehrt. Am gerenderten Pixel gemessen (320px,
 * die einzige Breite, auf der die Marke steht): 10,04:1 hell, 12,54:1 dunkel. */
.list-bulkbar__action-count {
  display: none;
  margin-inline-start: var(--space-1);
  padding: var(--space-px) var(--space-1);
  border-radius: var(--radius-full);
  background-color: color-mix(in srgb, currentColor 15%, transparent);
  font-variant-numeric: tabular-nums;
}

/* DIE GEFAHR HAT EINE TINTE, NICHT EINE FORM (Critique 2026-08-13, P0).
 *
 * „Löschen" und „In den Vorrat" standen als zwei zeichengleiche Kapseln
 * nebeneinander, 40px auseinander, und 8px darunter stand das „Verwerfen" des
 * Toasts in derselben Schreibweise. Drei Knöpfe, drei sehr verschiedene Folgen,
 * ein Aussehen.
 *
 * Die Kapsel bleibt die Kapsel - sie ist das Vokabular dieser Fläche, und eine
 * zweite Form wäre ein zweites Vokabular für dieselbe Rolle. Was wechselt, ist
 * die Tinte, und mit `currentColor` an der Kante wechselt die Kante mit.
 *
 * --shell-danger-ink und nicht --color-danger: der Grund unter dieser Kapsel
 * dreht sich mit dem Thema (--neutral-800 ist hell im Dunkelmodus), die
 * semantischen Farben drehen in dieselbe Richtung. Begründung und Messwerte am
 * Token in tokens.css.
 *
 * SIE IST DER ZWEITE KANAL, NICHT DER ERSTE. Der erste ist das Wort auf der
 * Kapsel, der dritte die Rückfrage dahinter - Farbe allein trägt hier keine
 * Aussage, die sonst fehlte (WCAG 1.4.1). */
.list-bulkbar__action--danger {
  color: var(--shell-danger-ink);
}

/* Nur der Toast: seine Rücknahme sitzt am Zeilenende, und sie ist die Fußnote
 * einer Meldung, die von selbst geht. Die Pille steht dauerhaft - ihre
 * Aktionen sind das, wofür sie da ist, und treten nicht zurück. */
.toast__undo {
  margin-left: auto;
  opacity: 0.85;
}

.toast__undo:hover {
  opacity: 1;
}

.toast__undo:active,
.list-bulkbar__action:active {
  transform: scale(0.94);
  opacity: 1;
}

.toast__undo:focus-visible,
.list-bulkbar__action:focus-visible {
  outline: var(--focus-ring-width) solid currentColor;
  outline-offset: var(--focus-ring-offset);
  opacity: 1;
}

/* GESPERRT WÄHREND DER ÜBERGABE, und das kam mit dem Verzicht auf `.btn` als
 * Lücke mit: `sendToShopping` setzt `btn.disabled = true`, damit ein zweiter
 * Tipp nicht einen zweiten Transfer samt eigenem Undo-Toast auslöst - auf der
 * Button-Basis war das sichtbar, auf dem Shell-Material stand der Knopf
 * unverändert da und sah anfassbar aus. Der Toast braucht es nicht: seine
 * Rücknahme entfernt sich selbst mit dem Klick. */
.list-bulkbar__action:disabled {
  opacity: 0.5;
  cursor: default;
}

.list-bulkbar__action:disabled:active {
  transform: none;
}

.toast--success { background-color: var(--color-success); color: var(--toast-success-text); }
.toast--danger  { background-color: var(--color-danger);  color: var(--toast-danger-text); }
.toast--warning { background-color: var(--color-warning); color: var(--toast-warning-text); }

@keyframes toast-in {
  from { opacity: 0; transform: translateY(6px) scale(0.98); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}

/* --------------------------------------------------------
 * Sammelaktions-Pille
 *
 * „Diese Liste hat eine Teilmenge, und für die gibt es eine Aktion."
 *
 * SIE WAR EIN BLOCK IM INHALT und ist jetzt eine Fläche der Shell. Gemessen bei
 * 390x844 auf /shopping: 358x103px bei y=113, der Scroller begann dadurch erst
 * bei y=216 statt 113 - 103 von 552px Listenfläche, ausgelöst von einem
 * einzigen abgehakten Artikel. Die 103px waren `flex-wrap`: die erklärende
 * Zeile allein auf Zeile eins, beide Knöpfe auf Zeile zwei.
 *
 * EINZEILIG IST DIE BEDINGUNG, NICHT DIE HOFFNUNG. Deshalb keine `.btn`-Knöpfe
 * mit Icon, sondern die Kapseln, die auf diesem Material schon stehen
 * (`.toast__undo`, siehe oben), und deshalb kein `flex-wrap`. Der Rang bei
 * Enge: die Aktionen schrumpfen NIE, das Label gibt nach - es führt mit der
 * Zahl, und die überlebt die Kürzung. Dieselbe Rangfolge, die die Agenda-Zeile
 * ihrer Metazeile gegeben hat.
 *
 * Material und Geometrie kommen vom Toast, bis auf eine Sache: dieser Knoten
 * trägt KEINE Einblend-Animation. Ein Toast kommt an, eine Pille steht da; ein
 * Federn bei jedem Häkchen wäre Bewegung ohne Anlass.
 * -------------------------------------------------------- */
.list-bulkbar {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  /* Die Zusicherung hinter --bulk-pill-safe-zone (tokens.css): der Nachlauf am
   * Scroll-Ende rechnet mit diesem Wert, also muss die Pille ihn halten. */
  min-height: var(--bulk-pill-height);
  box-sizing: border-box;
  background-color: var(--neutral-800);
  color: var(--neutral-50);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-lg);
  pointer-events: auto;
}

/* --------------------------------------------------------
 * Bestätigungszustand: die Pille FRAGT
 *
 * DIE RANGFOLGE DREHT SICH HIER UM. Im Ruhezustand gibt das Subjekt nach und
 * die Aktionen schrumpfen nie - es benennt nur, worauf sie wirken, und dieselbe
 * Aussage steht in der Liste darüber. Die Frage steht nirgendwo sonst: eine
 * gekappte Frage verlangt eine Antwort, die sie nicht mehr benennt.
 *
 * ALSO BRICHT SIE UM, WENN ES SEIN MUSS - UND NUR DANN. Keine
 * Container-Schwelle: die 21rem des Subjekts sind an DESSEN Bedarf gemessen,
 * und die Frage braucht mehr. Gemessen bei 390px, wo Deutsch mit 115 von 149px
 * bequem passt: Niederländisch verlangt „9 artikelen verwijderen?" neben
 * „Annuleren" und „Verwijderen" und reisst mit 153 von 149px. Eine Zahl, die
 * für Deutsch stimmt und für Niederländisch nicht, ist keine Schwelle, sondern
 * eine Annahme - `flex-shrink: 0` plus `flex-wrap` fragt stattdessen den
 * echten Bedarf, in jeder der 24 Sprachen.
 * -------------------------------------------------------- */
.list-bulkbar--confirming {
  flex-wrap: wrap;
}

/* Nicht schrumpfen und nicht kappen - das ist der ganze Unterschied zum
 * Ruhezustand. `overflow: visible` hebt die Ellipse auf, die das Subjekt
 * mitbringt; ohne sie stünde die Frage weiter abgeschnitten da, nur eben in
 * einer Zeile, die Platz hätte. */
.list-bulkbar--confirming .list-bulkbar__subject {
  flex: 1 0 auto;
  overflow: visible;
}

/* Die Wahl ist ein Paar. Der Knoten hält Abbrechen und Bestätigen zusammen,
 * damit beim Umbruch nicht eine Kapsel oben stehen bleibt (gemessen: Frage
 * plus „Annuleren" in Zeile eins, „Verwijderen" allein darunter). Er schrumpft
 * nicht, er wandert als Ganzes. */
.list-bulkbar__choices {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-shrink: 0;
  /* Bleibt die Wahl neben der Frage, sitzt sie am Ende der Zeile; rutscht sie
   * darunter, steht sie am Ende ihrer eigenen. Eine Schreibweise für beides. */
  margin-inline-start: auto;
}

/* Das einzige Element der Pille, das nachgibt. `min-width: 0` ist die
 * Bedingung dafür, dass die Kürzung überhaupt greift - ohne sie wächst der
 * Text über die Pille hinaus, statt nachzugeben. */
.list-bulkbar__subject {
  flex: 1 1 auto;
  min-width: 0;
  font-size: var(--text-sm);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Die Schicht ist der Container, nicht die Pille selbst: gefragt wird nach der
 * Breite, die zur Verfügung STEHT, und ein Element kann sich nicht selbst
 * fragen, wie breit es sein dürfte. */
.bulk-pill-layer {
  container-type: inline-size;
  container-name: bulk-pill;
}

/* SIE FÄLLT WEG, SIE WIRD NICHT GEKÜRZT. Gemessen bei 320px: die Pille hat
 * 264px Innenbreite, die beiden Kapseln nehmen 169 davon, für das Label bleiben
 * 87 - „3 Artikel abgehakt." braucht 138 und stand dort als „3 Artike…". Das
 * ist genau der Fall, den der Vorrat für sein MHD schon entschieden hat: eine
 * Ellipse macht aus einer Aussage etwas, das wie ein Fehler aussieht. Die Zahl
 * ist damit nicht verloren - sie steht im aria-label der Löschen-Kapsel und in
 * der Liste darüber, deren Häkchen man gerade gesetzt hat.
 *
 * 21rem ist die Grenze, unter der es in KEINER Sprache mehr aufgeht (bei 390px
 * ist die Pille 358px breit und deutsch passt mit 19px Luft). Die Kürzung oben
 * bleibt als Schranke für eine Übersetzung, die auch darüber zu lang ist - sie
 * ist die Notbremse, nicht der Plan. */
@container bulk-pill (max-width: 21rem) {
  /* AUSSER SIE IST DIE FRAGE (Critique 2026-08-13). Der Absatz oben gilt für
   * das Subjekt: es benennt, worauf die Kapseln wirken, und dieselbe Aussage
   * steht in der Liste darüber. Die Rückfrage steht nirgendwo sonst - fiele
   * sie weg, stünden „Abbrechen" und „Löschen" ohne die Frage da, die sie
   * beantworten, und das wäre schlimmer als der Zustand ohne Rückfrage. */
  .list-bulkbar:not(.list-bulkbar--confirming) .list-bulkbar__subject {
    display: none;
  }

  /* UND DIE ZAHL WECHSELT DIE SEITE (Etappe 6, 2026-08-13). Der Absatz darüber
   * sagte, die Zahl sei nicht verloren - sie stehe im aria-label und in der
   * Liste darüber. Für einen Screenreader stimmte das. Für das Auge blieben
   * „In den Vorrat" und „Löschen" ohne genanntes Objekt, über einer Liste mit
   * 23 Artikeln und ohne Rückfrage vor dem Löschen: „Löschen" konnte dort
   * gelesen werden wie „die Liste löschen". Ein Zustand, den nur der Undo
   * wieder aufhebt.
   *
   * Sie geht an die Kapsel, nicht zurück in die Zeile: die Zeile passt hier
   * nicht (gemessen 138 von 87px), die Marke schon - „Löschen" plus Marke misst
   * rund 191 von 264px Innenbreite, die Pille bleibt einzeilig. */
  .list-bulkbar__action-count {
    display: inline;
  }

  /* Ohne das Label gibt es kein wachsendes Element mehr, und die Kapseln
   * standen als schmale Gruppe an der linken Kante einer 288px breiten Fläche.
   * Die Pille behält ihre Breite (sie ist die des Toasts darunter, eine
   * schmalere wäre eine zweite Zone) - also rückt ihr Inhalt in die Mitte.
   * Im Bestätigungszustand steht die Frage und wächst, also gilt das nicht. */
  .list-bulkbar:not(.list-bulkbar--confirming) {
    justify-content: center;
  }
}

.toast--out {
  animation: toast-out var(--duration-md) ease forwards;
}

@keyframes toast-out {
  from { opacity: 1; transform: scale(1); }
  to   { opacity: 0; transform: scale(0.95) translateY(4px); }
}

/* --------------------------------------------------------
 * RRULE-Felder (Wiederholungs-Formular, shared Tasks + Kalender)
 * -------------------------------------------------------- */

.rrule-fields {
  margin-top: var(--space-4);
  border-top: 1px solid var(--color-border-subtle);
  padding-top: var(--space-4);
}

.rrule-details {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  margin-top: var(--space-3);
  padding: var(--space-3);
  background: var(--color-surface-2);
  border-radius: var(--radius-sm);
}

/* Progressive Disclosure: bei „keine Wiederholung" trägt das Element das
 * hidden-Attribut — display:flex oben darf es nicht überstimmen. */
.rrule-details[hidden] {
  display: none;
}

.rrule-row {
  display: flex;
  align-items: flex-end;
  gap: var(--space-3);
  flex-wrap: wrap;
}

.rrule-interval-wrap {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.rrule-interval-unit {
  font-size: var(--text-sm);
  color: var(--color-text-secondary);
  white-space: nowrap;
}

.rrule-until-field {
  flex: 1;
  min-width: 220px;
}

.rrule-day-grid {
  display: flex;
  gap: var(--space-1);
  flex-wrap: wrap;
  margin-top: var(--space-1);
}

/* Ankerschalter „ab Erledigung" (#658). Sitzt unter dem Takt, weil er ihn
 * verankert und nicht selbst einer ist: erst „jede Woche", dann „ab wann". */
/* "Am letzten Tag des Monats" (#960) steht dort, wo bei WEEKLY die Wochentage
 * stehen: beide beantworten dieselbe Frage - WANN im Takt -, und beide gibt es
 * nur bei ihrer Frequenz. Kein Trennstrich wie beim Anker darunter, der eine
 * andere Frage stellt (woran haengt der Takt). */
.rrule-monthday {
  margin-top: var(--space-3);
}

.rrule-anchor {
  border-top: 1px solid var(--color-border-subtle);
  padding-top: var(--space-3);
}

.rrule-anchor__hint {
  margin: var(--space-1) 0 0;
  font-size: var(--text-sm);
  color: var(--color-text-secondary);
}

/* Gegenstueck zur Progressive Disclosure oben: solange keine Frequenz gewaehlt
 * ist, sagt die Liste nichts darueber, dass der Takt danach frei einstellbar
 * ist - sie liest sich wie vier feste Werte (#862). Der Hinweis steht genau in
 * dem Zustand, in dem der Irrtum moeglich ist, und verschwindet mit ihm. */
.rrule-hint {
  margin: var(--space-1) 0 0;
  font-size: var(--text-sm);
  color: var(--color-text-secondary);
}

.rrule-day {
  width: var(--target-md);
  height: var(--target-md);
  border-radius: var(--radius-sm);
  border: 1.5px solid var(--color-border);
  background: transparent;
  color: var(--color-text-secondary);
  font-size: var(--text-sm);
  font-weight: var(--font-weight-medium);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all var(--transition-fast);
  position: relative;
}

.rrule-day::before {
  content: '';
  position: absolute;
  inset: -4px;
}

.rrule-day:hover {
  border-color: var(--color-accent);
  color: var(--color-accent);
}

.rrule-day--active {
  background: var(--color-accent);
  border-color: var(--color-accent);
  color: var(--color-ink-on-vivid);
}

.rrule-day--active:hover {
  background: var(--color-accent-hover);
  border-color: var(--color-accent-hover);
}

@media (max-width: 1023px) {
  .rrule-day {
    width: var(--target-base);
    height: var(--target-base);
  }
  .rrule-day-grid {
    gap: var(--space-0h);
    justify-content: space-between;
  }
}

/* ── Submit-Feedback Animationen ── */

/* Checkbox-Pop: kanonische Definition - shopping.css + tasks.css verwenden diese */
@keyframes check-pop {
  0%   { transform: scale(1); }
  30%  { transform: scale(0.8); }
  60%  { transform: scale(1.3); }
  80%  { transform: scale(0.95); }
  100% { transform: scale(1); }
}

@keyframes btn-shake {
  0%, 100% { transform: translateX(0); }
  20%       { transform: translateX(-4px); }
  40%       { transform: translateX(4px); }
  60%       { transform: translateX(-4px); }
  80%       { transform: translateX(4px); }
}

.btn--shaking {
  animation: btn-shake var(--duration-xl) ease;
}

/* prefers-reduced-motion: kein Schütteln, nur Farb-Feedback */
.btn--error-static {
  background-color: var(--color-danger) !important;
  color: var(--color-ink-on-vivid) !important;
  pointer-events: none;
}

.btn--success {
  background-color: var(--color-success) !important;
  color: var(--color-ink-on-vivid) !important;
  pointer-events: none;
}

/* Button loading state: hides text, shows spinner */
@keyframes btn-spin {
  to { transform: rotate(360deg); }
}

.btn--loading {
  position: relative;
  color: transparent !important;
  pointer-events: none;
}

.btn--loading::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 14px;
  height: 14px;
  margin-top: -7px;
  margin-left: -7px;
  border: 2px solid var(--color-glass-hover);
  border-top-color: var(--color-text-on-accent);
  border-radius: var(--radius-full);
  animation: btn-spin 550ms linear infinite;
}

/* For non-primary buttons (secondary, ghost) */
.btn--secondary.btn--loading::after,
.btn--ghost.btn--loading::after {
  border-color: var(--color-border);
  border-top-color: var(--color-accent);
}

/* --------------------------------------------------------
 * Swipe-Wrapper - Gemeinsame Basis (Aufgaben, Einkauf, Geburtstage)
 * Modulsache bleibt allein, wie die KARTE in der Zeile sich verhält
 * (.swipe-row .task-card, .swipe-row .shopping-item, .swipe-row
 * .birthday-item); die Geste, die Reveal-Rollen und der Hinweis sind geteilt.
 * Der JS-Teil liegt in utils/swipe-row.js.
 * -------------------------------------------------------- */
/* Der Wischwrapper ist eine reine Buehne fuer die Geste: KEIN eigener Radius,
 * KEIN eigener Abstand. Beides gehoert dem Traeger der Zeilenliste, der die
 * Wischflaeche mit seinem `overflow: hidden` ohnehin beschneidet.
 *
 * Die Zusage stand vorher dreimal: die Geburtstage setzten sie zurueck, die
 * Aufgaben trugen sie mit (Karte pro Zeile), und im Einkauf blieb sie
 * unbemerkt stehen - gemessen 8px Luft ZWISCHEN Haarlinie und Zeile, in
 * genau der Liste, die als Vorbild der Zeilenlisten-Regel gilt. Eine geteilte
 * Regel ohne Guard ist eine wandernde Annahme; hier traegt sie die Basis. */
.swipe-row {
  position: relative;
  overflow: hidden;
  /* Verhindert ungewolltes Flackern auf iOS */
  -webkit-backface-visibility: hidden;
}

/* Waehrend der Geste traegt die bewegte Zeile die Traegerflaeche: das
 * Reveal-Panel liegt unter ihr und darf nur dort sichtbar werden, wo sie
 * weggewischt ist. Solange jede Zeile eine eigene Kartenflaeche hatte, fiel
 * das nicht auf - in dem Moment, in dem sie flach wird, laege der Zeilentext
 * ueber dem aufgedeckten Panel. Geteilt statt je Modul: die drei Wischlisten
 * hatten dafuer drei Regeln, die alle nur den Schatten setzten.
 *
 * NICHT `:first-child`: die Reveal-Panels stehen im Markup VOR der Karte, und
 * absolut positioniert liegen sie ohnehin darunter. Der Selektor traf damit
 * das fuehrende Panel (0,2,0 schlaegt .swipe-reveal--done mit 0,1,0) - das
 * Panel verlor seine Aktionsfarbe, und die bewegte Karte blieb ohne Traeger.
 * `:not(.swipe-reveal)` benennt, was gemeint war: alles, was kein Panel ist. */
.swipe-row--swiping > :not(.swipe-reveal) {
  background-color: var(--color-surface-work);
  box-shadow: var(--shadow-lg);
}

/* DAS COMPOSITOR-VERSPRECHEN HAENGT AN DER GESTE, NICHT AN DER ZEILE.
 *
 * `will-change: transform` stand fest auf `.swipe-row .shopping-item` und
 * `.swipe-row .task-card`: im Demo-Seed trugen es 26 Einkaufszeilen und 11
 * Aufgabenkarten GLEICHZEITIG, im Ruhezustand, ohne dass ein Finger die Liste
 * beruehrt haette - jede mit eigener Compositor-Ebene samt Speicher (gemessen
 * 54 Ebenen / ~15,2 MB auf /tasks, Audit 2026-08-08, P2-1). Die Last waechst
 * linear mit der Zeilenzahl, und eine echte Familienliste ist laenger als der
 * Seed - auf genau den aelteren Telefonen, die laut PRODUCT.md die Hauptszene
 * sind.
 *
 * `--armed` setzt swipe-row.js bei `touchstart`, also BEVOR die erste
 * Bewegung kommt: das haelt den ersten Frame ruhig, ohne mehr als die eine
 * beruehrte Zeile zu promoten. `--swiping` waere zu spaet (es entsteht erst,
 * wenn die Richtung feststeht), eine Dauerregel zu frueh.
 *
 * Geteilt statt je Modul: die Geste ist geteilt, die Geburtstage hatten das
 * Versprechen ueberhaupt nie und wischen dieselbe Bewegung.
 *
 * `:not(.swipe-reveal)` aus demselben Grund wie oben: bewegt wird die Karte,
 * nicht das Panel - `:first-child` promotete das falsche Element. */
.swipe-row--armed > :not(.swipe-reveal) {
  will-change: transform;
}

/* Reveal-Panels hinter der Karte */
.swipe-reveal {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  font-size: var(--text-xs);
  font-weight: var(--font-weight-semibold);
  opacity: 0;
  pointer-events: none;
  z-index: 0;
  transition: opacity 50ms linear;
}

/* --------------------------------------------------------
 * Listen-Eintritt
 * `.list-stagger` (Container-Klasse mit gestaffelten Delays) ist mit dem
 * Toter-Selektoren-Aufraeumen 2026-08-31 entfallen - null Nutzer im Markup.
 * Die Keyframes bleiben: der No-Results-Reset der Dokumente (weiter oben)
 * blendet mit ihnen ein.
 * -------------------------------------------------------- */
@keyframes list-item-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}


/* --------------------------------------------------------
 * Swipe Affordance Hint
 * Einmalig per JS: .swipe-row--hint auf das erste Element.
 * Gibt nach Seitenladezeit einen kurzen Nudge-Hinweis.
 * -------------------------------------------------------- */
/* Der Nudge zeigt in die Richtung, in die gewischt werden kann - und die ist
 * in RTL die andere. Ein Vorzeichen statt einer zweiten Keyframe-Liste: die
 * Bewegung ist dieselbe, nur ihre Achse folgt der Schreibrichtung. */
@keyframes swipe-nudge {
  0%   { transform: translateX(0); }
  20%  { transform: translateX(calc(-18px * var(--swipe-nudge-dir, 1))); }
  45%  { transform: translateX(0); }
  60%  { transform: translateX(calc(-9px * var(--swipe-nudge-dir, 1))); }
  80%  { transform: translateX(0); }
  100% { transform: translateX(0); }
}

.swipe-row--hint > :first-child {
  animation: swipe-nudge 800ms var(--ease-out) 1200ms both;
}

[dir='rtl'] .swipe-row--hint > :first-child {
  --swipe-nudge-dir: -1;
}

/* Swipe-Affordanz: permanente Chevron-Hints an den Kartenrändern */
.swipe-row::after {
  content: '';
  position: absolute;
  inset-inline-end: var(--space-2);
  top: 50%;
  transform: translateY(-50%);
  width: 12px;
  height: 20px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 20' fill='none' stroke='%23888' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3,4 9,10 3,16'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-size: contain;
  opacity: 0.3;
  pointer-events: none;
  z-index: 1;
  transition: opacity var(--transition-fast);
}

.swipe-row:hover::after,
.swipe-row:focus-within::after {
  opacity: 0.55;
}

/* Der Chevron ist eine Richtungsangabe, kein Ornament: er zeigt zum
 * Zeilenende, und das liegt in RTL links. */
[dir='rtl'] .swipe-row::after {
  transform: translateY(-50%) scaleX(-1);
}

/* Eine Zeile OHNE Geste (Nur-lesen, #1265): der Chevron versprache einen Wisch,
 * den keine Verdrahtung beantwortet. Die Zeile behaelt `.swipe-row` fuer ihre
 * Form und ihre Nachschlage-Selektoren; nur die Richtungsangabe faellt weg.
 * Steht NACH der Basisregel: gleiche Spezifitaet, die Reihenfolge entscheidet. */
.swipe-row--static::after {
  content: none;
}

@media (prefers-reduced-motion: reduce) {
  .swipe-row::after { display: none; }
}

@media (min-width: 1024px) {
  .swipe-row::after { display: none; }
}

/* Reveal-Panels: ZWEI ACHSEN, getrennt gehalten.
 *
 * Die SEITE ist die Kante, an der das Panel liegt (`--leading` am Zeilenanfang,
 * `--trailing` am Zeilenende), die ROLLE trägt allein die Bedeutung. Bis
 * Runde 4 steckte beides in einer Klasse - `--delete` hiess „löschen UND
 * links", weil der einzige Nutzer, der Einkauf, nach rechts löschte. Die
 * Geburtstage löschen zur anderen Seite, und dieselbe Rolle war damit nicht
 * wiederverwendbar, ohne eine vierte zu erfinden.
 *
 * Anfang und Ende sind LOGISCH, nicht links und rechts: in `ar` und `fa` setzt
 * die App `dir=rtl`, und bis Runde 6 trugen die logischen Namen physische
 * Eigenschaften - dort fielen Name und Wirkung auseinander, ohne dass jemand
 * etwas ändert. `swipe-row.js` leitet die Fingerbewegung aus derselben
 * Schreibrichtung ab.
 *
 * Beides sind geteilte Rollen, keine Modulsache: dieselbe Geste soll in jeder
 * Liste dasselbe bedeuten. */
.swipe-reveal--leading {
  inset-inline-start: 0;
  border-start-start-radius: var(--radius-md);
  border-end-start-radius: var(--radius-md);
}

.swipe-reveal--trailing {
  inset-inline-end: 0;
  border-start-end-radius: var(--radius-md);
  border-end-end-radius: var(--radius-md);
}

.swipe-reveal--done   { background-color: var(--color-success); color: var(--color-ink-on-vivid); }
.swipe-reveal--edit   { background-color: var(--color-accent);  color: var(--color-ink-on-vivid); }
.swipe-reveal--delete { background-color: var(--color-danger);  color: var(--color-ink-on-vivid); }

/* --------------------------------------------------------
 * Reduced Transparency — layout.css Fallbacks
 * glass.css deckt Glass-Komponenten ab; diese drei Elemente
 * nutzen backdrop-filter außerhalb von @supports und brauchen
 * eigene Fallbacks.
 * -------------------------------------------------------- */
@media (prefers-reduced-transparency: reduce) {
  .nav-bottom {
    background-color: var(--color-surface);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  .more-backdrop {
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  /* Ohne Blur braucht der Modal-Backdrop wieder volle Deckkraft, damit das
   * dahinterliegende Modul nicht unscharf-frei durchscheint. */
  .modal-overlay {
    background-color: var(--color-overlay);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
}

/* --------------------------------------------------------
 * Keyboard Shortcut Modal
 * -------------------------------------------------------- */
.shortcuts-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}

.shortcuts-row,
.help-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) 0;
  border-bottom: 1px solid var(--color-border-subtle);
}

.shortcuts-row:last-child,
.help-row:last-child {
  border-bottom: none;
}

/* Mobile-Hilfe-Zeilen tragen ein Lucide-Icon statt einer Taste. */
.help-row__icon {
  flex-shrink: 0;
  color: var(--color-text-secondary);
}

.shortcut-kbd {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 32px;
  padding: var(--space-0h) var(--space-2);
  background: var(--color-surface-3);
  border: 1px solid var(--color-border);
  border-bottom: 2px solid var(--color-border);
  border-radius: var(--radius-xs);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--color-text-primary);
  white-space: nowrap;
  flex-shrink: 0;
}

.shortcut-desc {
  font-size: var(--text-sm);
  color: var(--color-text-secondary);
}

/* --------------------------------------------------------
 * Offline-Banner
 * -------------------------------------------------------- */
.offline-banner {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: calc(var(--z-toast) + 1);
  display: flex;
  align-items: center;
  gap: var(--space-2);
  /* Die Oberkante liegt unter der Statusleiste (viewport-fit=cover): ohne den
   * Inset stand der Text als installierte App hinter Uhr und Notch (Critique
   * 2026-09-26, Minor). Dieselbe Formel wie der Kopf des Such-Overlays; die
   * gemessene Hoehe (router.js, --offline-banner-height) schliesst ihn ein. */
  padding: calc(var(--space-2) + var(--safe-area-inset-top)) var(--space-4) var(--space-2);
  background-color: var(--color-warning-light);
  color: var(--color-warning-ink);
  font-size: var(--text-sm);
  font-weight: var(--font-weight-medium);
  border-bottom: 1px solid color-mix(in srgb, var(--color-warning) var(--tint-hint), transparent);
  animation: offline-banner-in var(--duration-md) var(--ease-out);
}

.offline-banner[hidden] {
  display: none;
}

@keyframes offline-banner-in {
  from { opacity: 0; transform: translateY(-100%); }
  to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .offline-banner { animation: none; }
}

/* --------------------------------------------------------
 * Print-Styles
 * -------------------------------------------------------- */
@media print {
  .nav-sidebar,
  .nav-bottom,
  .fab,
  .toast-container,
  /* Der ganze Stapel, nicht nur die Toasts darin: seit die Sammelpille dort
   * wohnt, druckte eine laufende Auswahl (Kontakte im Auswahlmodus, abgehakte
   * Artikel, knapper Vorrat) ihre dunkle Kapsel mit aufs Blatt, am Fuss jeder
   * Seite (Codex-Review zu PR #754). Chrome gehoert nicht auf Papier. */
  .shell-bottom-stack,
  .modal-overlay,
  .modal-backdrop {
    display: none !important;
  }

  .app-content {
    margin-left: 0 !important;
    padding-bottom: 0 !important;
  }

  .app-shell {
    display: block;
  }

  /* Gedruckt wird hell. Die Farbwelt regelt das nicht hier, sondern an der
   * Quelle: beide Dark-Blöcke in tokens.css stehen unter `@media screen` und
   * greifen auf Papier gar nicht erst (die Begründung und die Messung stehen
   * dort). Bis 2026-08-09 färbte dieser Block nur den `body`, während jede
   * Fläche darunter ihren Dark-Token behielt - schwarze Tinte auf schwarzem
   * Grund.
   *
   * Hier bleibt allein, was Papier-Eigenheit ist und keine Theme-Frage: der
   * Bogen ist weiß, nicht der Grouped-Grund. */
  :root {
    color-scheme: light;
    --color-bg: #ffffff;
  }

  .card {
    box-shadow: none;
    /* Die Kante trägt hier die Trennung, die der Schatten nicht mehr leistet -
     * und dafür ist jedes Kanten-Token zu hell: sie sind für feine Linien auf
     * einem leuchtenden Display gebaut und drucken als nichts. Der Wert steht
     * deshalb lokal statt als Token-Override. `--color-border-subtle` global
     * anzuheben war der erste Versuch und ein Regress: es ist an vier Stellen
     * auch FLÄCHE (Kalenderraster, Dokument-Trenner), und der dunklere Wert
     * drückte den Text darauf auf 3.73:1. */
    border: 1px solid #c9c9c9;
    break-inside: avoid;
  }

  a[href]::after {
    content: " (" attr(href) ")";
    font-size: var(--text-sm);
    color: var(--color-text-secondary);
  }

  .nav-item,
  a[data-route]::after {
    content: none;
  }
}

/* --------------------------------------------------------
 * Skip-Link (sichtbar bei Keyboard-Focus)
 * -------------------------------------------------------- */
.sr-only:focus-visible {
  position: fixed;
  top: 0;
  left: 0;
  z-index: var(--z-skip-link);
  width: auto;
  height: auto;
  padding: var(--space-2) var(--space-4);
  margin: 0;
  clip: auto;
  white-space: normal;
  overflow: visible;
  background: var(--color-accent);
  color: var(--color-ink-on-vivid);
  font-size: var(--text-sm);
  border-radius: 0 0 var(--radius-sm) 0;
}

/* --------------------------------------------------------
 * Icon-Größen-Utilities
 * Größen für Lucide-Icons (<i data-lucide>). Klassen werden
 * vom Lucide-Renderer auf das erzeugte <svg> übertragen.
 *
 * Genau ein Name pro Stufe — die Werte stehen als --icon-* in
 * tokens.css. Die früheren Aliase (.icon-xs, .icon-11, .icon-base,
 * .icon-2xl) trugen dieselben Werte wie sm/md/xl und sind entfernt;
 * Icon-Größen niemals inline per style="width:…" setzen.
 * -------------------------------------------------------- */
.icon-sm { width: var(--icon-sm); height: var(--icon-sm); flex-shrink: 0; }
.icon-md { width: var(--icon-md); height: var(--icon-md); flex-shrink: 0; }
.icon-lg { width: var(--icon-lg); height: var(--icon-lg); flex-shrink: 0; }
.icon-xl { width: var(--icon-xl); height: var(--icon-xl); flex-shrink: 0; }

/* Icons innerhalb von Buttons sollen keine Pointer-Events fangen */
button i[data-lucide],
button svg { pointer-events: none; }

/* Tabular numbers: gleichbreite Ziffern für Zahlen/Beträge */
.tabular-nums { font-variant-numeric: tabular-nums; }

/* Kompakter Icon-Button: 44px Klickfläche, optisch kompakt durch geringes
 * Padding. Die Form kommt aus der .btn-Basisregel (Kapsel), nicht von hier -
 * eine eigene Ecke waere die zweite Buttonsprache. */
.btn--icon-sm {
  padding: var(--space-1);
  min-height: var(--target-base);
  min-width: var(--target-base);
}

/* Textarea: vertikale Größenänderung ist nutzbar */
textarea.input { resize: vertical; }

/* --------------------------------------------------------
 * High Contrast (prefers-contrast: more)
 * Tokens in tokens.css stellen bereits opake Glass-Werte bereit.
 * Hier kommen komponentenspezifische Korrekturen hinzu.
 * -------------------------------------------------------- */
@media (prefers-contrast: more) {
  /* Fokus: dicker und weiter versetzt.
   *
   * Über die Tokens, nicht über die Properties. `outline-width` auf
   * `:focus-visible` (0,1,0) verliert gegen jede Komponentenregel wie
   * `.page-fab:focus-visible` (0,2,0) - die Verstärkung griff deshalb nur beim
   * Basisring und ausgerechnet nicht bei den rund 45 Komponenten, auf denen
   * tatsächlich bedient wird. Als Token-Neudefinition ziehen alle mit, auch die
   * begründeten Ausnahmen, die Breite und Offset ohnehin aus den Tokens lesen. */
  :root {
    --focus-ring-width: 3px;
    --focus-ring-offset: 4px;
    --focus-ring-offset-inset: -3px;
  }

  /* Ghost- und Secondary-Buttons: explizite Umrandung */
  .btn--ghost,
  .btn--secondary {
    border: 1.5px solid currentColor;
  }

  .btn--ghost:hover {
    background-color: var(--color-surface-3);
  }

  /* Karten: Schatten entfernen, Border verstärken */
  .card {
    box-shadow: none;
    border: 1px solid var(--color-border);
  }

  .card--flat {
    border-color: var(--color-text-secondary);
  }

  /* Formulareingaben: kräftigere Border */
  .input,
  .form-input {
    border-color: var(--color-text-primary);
    border-width: 2px;
  }

  /* Aktiver Nav-Eintrag: zusätzliche Unterstreichung als farb-unabhängiger Indikator */
  .nav-item[aria-current="page"],
  .nav-item--active {
    text-decoration: underline;
    text-underline-offset: 3px;
  }
}

/* --------------------------------------------------------
 * Signature Moment: Eingabefeld-Glow nach erfolgreichem Item-Add
 * -------------------------------------------------------- */
@keyframes input-add-flash {
  0%   { box-shadow: none; }
  25%  { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 45%, transparent); }
  100% { box-shadow: none; }
}

.quick-add__input--flash {
  animation: input-add-flash 550ms var(--ease-out) forwards;
}

@media (prefers-reduced-motion: reduce) {
  .quick-add__input--flash { animation: none; }
}

/* --------------------------------------------------------
 * Windows High Contrast / Forced Colors
 * -------------------------------------------------------- */
@media (forced-colors: active) {
  .btn,
  .page-fab,
  .fab          { border: 1px solid ButtonText; }
  .card         { border: 1px solid CanvasText; }
  .modal-panel  { border: 2px solid CanvasText; }
  .nav-item[aria-current="page"],
  .nav-item--active { border-bottom: 2px solid Highlight; }
}

/* ============================================================
 * Liquid Nav — Custom Icons, Indikatoren, Abschnittsbezeichnung
 * ============================================================ */

/* SVG-Icons füllen ihren Eltern-Container */
.nav-item__icon svg,
.more-item__icon svg {
  width: 100%;
  height: 100%;
  display: block;
}

/* ── Sidebar: Indikator-Container braucht position:relative ── */
.nav-sidebar__items {
  position: relative;
}

/* ── Sidebar: Morphende Indikator-Pille (Strukturell) ──
 * Persistente Aktiv-Pille: verankert am aktiven Item, gleitet nur bei echtem
 * Routenwechsel (Signature-Moment) — nicht beim Hover. */
.nav-sidebar__indicator {
  position: absolute;
  /* Schwebend zentriert: seitlicher Einzug statt voller Breite */
  left: var(--space-1);
  right: var(--space-1);
  top: 0;
  height: var(--sidebar-row-height); /* füllt die Sidebar-Zeile (tokens.css) */
  border-radius: var(--radius-sm);
  pointer-events: none;
  /* Die Feder bleibt (Glas-Element, ignore.md), aber in der Dauer der
   * anderen Auswahl-Bewegungen: hier standen 450ms als Literal, und die Pille
   * lief noch ~250ms, als der Inhalt laengst stand (Re-Critique 2026-09-27,
   * A1 P2-2). --duration-xl ist die Obergrenze (300ms). */
  transition:
    transform var(--duration-xl) var(--ease-sidebar-glide),
    opacity var(--duration-lg) var(--ease-out);
  will-change: transform;
  z-index: 0;
}

/* ── Sidebar: Zarte Hover-/Fokus-Vorschau (Strukturell) ──
 * Entkoppelt vom Aktiv-Marker: eine ruhige Fläche (kein Glas-Blur, kein Akzent),
 * die dem Hover/Fokus folgt, damit die Aktiv-Pille beim Erkunden stehen bleibt. */
.nav-sidebar__hover {
  position: absolute;
  left: var(--space-1);
  right: var(--space-1);
  top: 0;
  height: var(--sidebar-row-height); /* füllt die Sidebar-Zeile (tokens.css) */
  border-radius: var(--radius-sm);
  background: var(--color-surface-3);
  opacity: 0;
  pointer-events: none;
  transition:
    transform var(--duration-lg) var(--ease-sidebar-glide),
    opacity var(--duration-sm) var(--ease-out);
  will-change: transform, opacity;
  z-index: 0;
}

/* ── Sidebar: Sektions-Gruppe (SR-Semantik) ──
 * role="group" + aria-labelledby macht die visuelle Gruppierung für Screenreader
 * sichtbar. display:flex bewahrt das Item-Stacking + gemeinsame Lücke. */
.nav-sidebar__group {
  display: flex;
  flex-direction: column;
  gap: var(--space-0h);
}

/* Nav-Items über dem Sidebar-Indikator */
.nav-sidebar__items--liquid .nav-item {
  z-index: 1;
}

/* Kein linker Akzentbalken wenn Liquid-Indikator aktiv */
.nav-sidebar__items--liquid .nav-item[aria-current="page"]::before {
  display: none;
}

/* Kein statischer Hintergrund und kein Shadow-Highlight wenn Indikator aktiv */
.nav-sidebar__items--liquid .nav-item[aria-current="page"] {
  background: transparent !important;
  box-shadow: none !important;
}

/* Kein statischer Hover-Hintergrund wenn Liquid-Indikator aktiv —
 * das separate .nav-sidebar__hover-Element übernimmt die Vorschau (via router.js). */
.nav-sidebar__items--liquid .nav-item:hover:not([aria-current="page"]) {
  background: transparent !important;
  box-shadow: none !important;
}

/* ── Sidebar: Abschnittsbezeichnung (z.B. "Haushalt") ──
 * Sentence-Case Gruppierungslabel (Typo-Rolle in typography.css). Tertiär
 * gefärbt, damit es als ruhige Über-Beschriftung liest und nicht mit den
 * Nav-Item-Labels (secondary, 14px) um Gewicht konkurriert. #6A6964 auf
 * --sidebar-bg ≈ 5:1 (AA ✓). */
.nav-section-label {
  color: var(--color-text-tertiary);
  /* 22px Zeile statt 27 (Critique 2026-09-26, P1-2): 6px Luft ueber dem
   * Label trennt die Gruppe, 2px darunter binden es an seine Zeilen. */
  padding: var(--space-1h) var(--space-3) var(--space-0h);
  user-select: none;
  pointer-events: none;
  position: relative;
  z-index: 1;
}

/* ── Mobile: Tab-Indikator (Strukturell) ──
 * Kapsel hinter dem Icon, nicht hinter Icon+Label: die bar-hohe Variante
 * schnitt die Label-Grundlinie an und stieß im ersten/letzten Slot an die
 * Bar-Kante, wo ihre Rundung gekappt wurde (#569-Nachtrag). Die exakte
 * Geometrie (Höhe/Breite/Versatz) setzt positionTabIndicator() aus dem
 * Icon-Well-Rect; die Werte hier sind der Stand vor dem ersten Messen. */
.nav-bottom__indicator {
  position: absolute;
  top: 0;
  bottom: auto;
  left: 0;
  height: var(--target-md);
  border-radius: var(--radius-full);
  pointer-events: none;
  transition:
    transform var(--duration-md) var(--ease-out),
    opacity var(--transition-fast);
  will-change: transform;
  z-index: 0;
}

.nav-bottom__indicator::before {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline: var(--space-1);
  border-radius: inherit;
}

/* Nav-Items im Bottom-Bar über dem Indikator */
.nav-bottom .nav-item {
  position: relative;
  z-index: 1;
}

@media (prefers-reduced-motion: reduce) {
  .nav-sidebar__indicator,
  .nav-sidebar__hover,
  .nav-bottom__indicator {
    transition: opacity 0.01ms;
  }

  /* Die Flyout-Labels standen hier mit `transition: opacity 0.01ms` und dem
   * Hinweis „sofort sichtbar statt 0.22s Delay". Beides trug nicht: `reset.css`
   * setzt in diesem Medienzustand `transition-duration: 0s !important`, und
   * einen `transition-delay` gibt es in der ganzen App an keiner Stelle - die
   * Basisregeln beider Labels schreiben `transition: opacity var(--duration-xs)
   * ease`, also eine Dauer ohne Verzögerung. Eine wirkungslose Zeile, die eine
   * Feinsteuerung behauptet, kostet beim Lesen mehr als sie leistet. */

  .nav-bottom .nav-item:active .nav-item__icon-well {
    transform: none;
    opacity: 1;
  }

  /* More-Sheet: kein vestibulär belastender Slide-up. Der Transform snappt
   * ohne Bewegung; das Erscheinen trägt ein sanfter Opacity-Fade (die
   * kanonische Reduced-Motion-Alternative). Backdrop faded weiterhin nur
   * Opacity (bewegungsfrei) und bleibt daher unverändert. */
  .more-sheet {
    transition: opacity var(--transition-fast);
    opacity: 0;
  }

  .more-sheet[aria-hidden="false"] {
    opacity: 1;
  }

  .more-sheet__search:active,
  .more-action:active {
    transform: none;
  }

  .more-item:active .more-item__icon-well {
    transform: none;
  }

  /* Das aus der More-Sheet-Suche geöffnete Voll-Overlay teilt denselben
   * translateY-Slide — ebenfalls durch einen bewegungsfreien Fade ersetzen. */
  .search-overlay {
    transition: opacity var(--transition-fast);
    opacity: 0;
  }

  .search-overlay--visible {
    opacity: 1;
  }

  /* Desktop-Palette: kein Scale-Auftakt — das Panel erscheint ohne Bewegung,
   * der Scrim übernimmt den (reduzierten) Fade. */
  .search-overlay__panel {
    transition: none;
    transform: none;
    opacity: 1;
  }
}

/* Read-only-Modul-Modus (#467): Bei „Nur lesen"-Rechten die Anlege-Affordance
   ausblenden und einen erklärenden Hinweis oben in der Seite zeigen. Die
   verbindliche Sperre liegt am Server; dies ist die ehrliche UI-Entsprechung. */
/* EIN ANLEGE-WEG, schwebend wie angedockt: der angedockte FAB behaelt
 * `.page-fab`, also erfasst ihn dieselbe Regel. Bis Runde 7 stand hier ein
 * zweiter Selektor fuer den handgeschriebenen Kopfknopf der fuenf Module
 * (#467, Codex-Review zu PR #754) - der Knopf ist weg, der Selektor mit ihm. */
html[data-module-readonly] .page-fab,
html[data-module-readonly] .notes-manage-categories {
  display: none !important;
}

/* Und ohne Knopf kein Nachlauf - die Paarung aus Etappe 6 (jede
   CSS-Ausblendung des FAB nullt --fab-safe-zone). Ein „Nur lesen"-Modul reservierte sonst 96px für eine
   Anlege-Affordance, die es gerade ausdrücklich nicht anbietet. */
html[data-module-readonly] {
  --fab-safe-zone: 0px;
}

.module-readonly-banner {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin: 0 0 var(--space-4);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-surface-raised);
  color: var(--color-text-secondary);
  font-size: var(--text-sm);
  font-weight: var(--font-weight-medium);
}

/* Warn-Variante für Formular-Hinweise (#474 Sichtbarkeits-Guard): hebt einen
   Hinweis mit überraschender Konsequenz von neutralem Hilfetext ab, ohne zu
   schreien. Als Zusatzklasse neben .form-hint / .task-field-hint. Der p-Selektor
   erhöht die Spezifität, damit die später geladene Page-CSS-Hint-Farbe nicht gewinnt. */
p.field-hint--warn {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  color: var(--color-warning);
}

p.field-hint--warn i,
p.field-hint--warn svg {
  width: 14px;
  height: 14px;
  flex-shrink: 0;
}

/* ── Das Ueberlappungszeichen: wer ∩ was ──
 *
 * Der dritte Teil der Formfamilie (Block-2-Brief), neben dem Siegel und seiner
 * Herkunfts-Regel. Zwei Kreise, die sich ueberlappen - das Motiv der
 * Drei-Kreise-Bildmarke, auf die kleinste Aussage gebracht: einer sagt, aus
 * welchem Raum das Objekt kommt, der andere, wen es angeht.
 *
 * DIE UEBERLAPPUNG IST DAS ZEICHEN, nicht die Nachbarschaft. Zwei Kreise
 * nebeneinander waeren zwei Angaben; erst der Schnitt macht daraus eine. Der
 * Versatz betraegt deshalb ein Drittel des Avatars - genug, dass die Kreise
 * einander sichtbar schneiden, wenig genug, dass beide als ganze Scheibe
 * lesbar bleiben.
 *
 * DER RING IST KEINE ZIERDE, sondern die Trennung: ohne ihn laufen zwei
 * gesaettigte Flaechen ineinander, und bei aehnlichem Ton (ein violetter
 * Avatar auf dem Kalender-Siegel) verschwindet die Kante. Seit dem Vollton ist
 * er noetiger als vorher, nicht weniger.
 *
 * SEIN NAME SAGT JETZT, WAS ER IST. Er stand auf --seal-base, dem Parameter,
 * mit dem das Siegel seinen Mischgrund kannte - und der ist mit der Toenung
 * entfallen. Der Ring braucht aber weiter einen: er wird in die Flaeche
 * geschnitten, auf der das PAAR steht, nicht auf der das Siegel mischt. Zwei
 * verschiedene Fragen, die sich einen Namen geteilt haben; --seal-pair-ground
 * beantwortet nur noch die zweite.
 *
 * Wer wann eines bekommt, steht in utils/seal-pair.js. */
.seal-pair {
  /* Der Grund, in den der Ring geschnitten wird - die Flaeche, auf der das PAAR
   * steht. Voreinstellung ist die Kartenflaeche; wer es woanders hinsetzt,
   * ueberschreibt hier und nicht am Ring. */
  --seal-pair-ground: var(--color-surface);
  display: inline-flex;
  align-items: center;
  flex-shrink: 0;
  /* Der Avatar ragt aus der Zeile heraus; ohne das ueberlappt er die Nachbarn
   * statt das Siegel. */
  position: relative;
}

.seal-pair__who {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: var(--space-5);
  height: var(--space-5);
  margin-inline-start: calc(-1 * var(--space-2));
  border-radius: var(--radius-full);
  box-shadow: 0 0 0 2px var(--seal-pair-ground);
  font-size: var(--text-2xs);
  font-weight: var(--font-weight-semibold);
  line-height: 1;
  overflow: hidden;
}

.seal-pair__who img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Neben einem Siegel der kleinen Rolle schrumpft auch das Zeichen mit - sonst
 * waere der Avatar groesser als das, was er ueberlappt. */
.seal-pair:has(> .module-seal--sm) .seal-pair__who {
  width: var(--space-4);
  height: var(--space-4);
  margin-inline-start: calc(-1 * var(--space-1) - 2px);
}
