/* ============================================================================
   SchnurPlan PWA — SCHNURMASS.
   ----------------------------------------------------------------------------
   Papier #F2F1EC / Graphit #0A0C0F, Markengrau #878A8D als Bedienteil-Kante,
   Markengruen #4BC429 mit GENAU VIER Aemtern (aktiver Reiter, Fokusring,
   Zustandsknoten, DER EINE Primaerknopf je Bildschirm). Bernstein #E9A63B heisst
   „braucht deinen Handgriff", Rot heisst „irreversibel".

   DAS TEAL #0aa892 IST ALS WERT GELOESCHT — es kommt in keiner Logodatei vor. Der NAME
   --navy (und jeder andere Bestandsname) bleibt und ist UMGEHAENGT. GEMESSEN vor dem Umbau:
   646 var()-Stellen gegen 45 Teal-Werte; drei Viertel der Farbe lief schon ueber Token. Ein
   Tokenwechsel faerbt damit die App um, ohne dass eine View angefasst wird — daher die
   Hausregel dieses Blocks: EIN BESTANDSNAME WIRD NIE GELOESCHT UND NIE UMBENANNT, NUR
   UMGEHAENGT.
   FORM: Grundradius 4 px; die Ecke wird nicht gerundet, sondern GEFAST (Werkzeug .fase
   weiter unten). Rund ist AUSSCHLIESSLICH der Knoten. Getrennt wird durch KANTE.
   FALLE: „--shadow: none“ ist verboten — .card.flash schreibt eine SchattenLISTE, und ein
   „none“ darin macht die ganze Deklaration ungueltig. Der Nullschatten heisst 0 0 #0000.
   Alle Kontrastzahlen zu den Werten unten: berichte/GESTALTUNG_SYSTEM.md, Abschnitt 1.
   ============================================================================ */
:root {
  /* ── 1. GRUND UND FLAECHE — vier Ebenen, mehr nicht. Warmes Papier wie die
     Website; --fl traegt Karte, Zeile, Blatt UND Leiste. */
  /* [WEBSITE-ANGLEICH 20.08.2026] DAS PAPIER BLEIBT EINE SPUR TIEFER ALS DAS DER WEBSITE —
     und diesmal steht der Grund dabei, statt dass ihn jeder neu erraten muss.
     Die Website fuehrt `--bg: light-dark(#f4f3ef, #0c0e11)` (global.css:3102). Die PWA
     fuehrt #F2F1EC / #0A0C0F, also je zwei bis drei Stufen dunkler. GERECHNET, und das ist
     die ganze Begruendung: dieses System hat KEINE SCHATTEN (--shadow: 0 0 #0000). Was eine
     Karte vom Grund trennt, ist allein der Helligkeitssprung --fl gegen --bg.
       --fl #FFFFFF auf PWA-Papier  #F2F1EC = 1,131:1
       --fl #FFFFFF auf Web-Papier  #f4f3ef = 1,110:1
     Das sind 19 % mehr Sprung fuer dieselbe Karte — auf einem Telefon, das unter
     Sonnenschleier gehalten wird, ist genau dieser Sprung das Einzige, was die Karte
     ueberhaupt noch als Karte zeigt. Die Website darf flacher sein: sie hat Verlaeufe,
     grosszuegige Radien und wird am Schreibtisch gelesen.
     WAS DAFUER ZU BEZAHLEN IST, ehrlich und nachgerechnet: jede Tinte steht auf dem
     tieferen Papier minimal schlechter. Die Zahlen hinter den Tinten unten sind deshalb
     heute korrigiert — sie waren SAEMTLICH gegen #f4f3ef gerechnet, also gegen das Papier
     der WEBSITE, und nicht gegen den Wert, der hier steht. Sieben von sieben stimmten auf
     die zweite Stelle mit dem Website-Grund ueberein; das war kein Zufall, sondern eine
     Tabelle, die zum falschen Grund gehoerte. Die engste Paarung des hellen Themas ist
     --gruent auf --bg: sie traegt 4,58:1 und nicht die notierten 4,67:1. Sie haelt AA
     (4,5:1), aber der Puffer ist 0,08 und nicht 0,17 — wer --gruent oder --bg anfasst,
     rechnet nach, bevor er speichert. */
  --bg:      #F2F1EC;
  --fl:      #FFFFFF;
  --fl2:     #E9E7E1;   /* eingelassen: Eingabefeld, Skelett, Codeblock        */
  --fl3:     #FAF9F6;   /* gehoben: Overlay/Menue ueber einer Flaeche          */
  --fl-tap:  #EFEDE7;   /* dieselbe Flaeche unter dem Finger                   */

  /* ── 2. KANTEN — Haarlinie = Materialgrenze · --line2 = Bedienteil-Kante, sie MUSS
     >= 3:1 tragen (WCAG 1.4.11) · --line3 = Trennung ganzer Baender. */
  /* [WEBSITE-ANGLEICH 20.08.2026] --line ist hier .13 und auf der Website .115
     (global.css:3123). Die Abweichung BLEIBT und ist der einzige Fall, in dem die PWA
     absichtlich lauter ist als die Marke: die Haarlinie ist auf einem 390-px-Schirm bei
     Tageslicht die Grenze zwischen zwei Listenzeilen, und bei .115 verschwindet sie in der
     Blendung. Es ist derselbe Gedanke wie beim tieferen Papier oben — dieses System trennt
     durch KANTE, also muss die Kante halten. */
  --line:    rgba(20, 22, 26, .13);
  --line2:   #878A8D;   /* MARKENGRAU. 3,47:1 auf --fl · 3,07:1 auf --bg       */
  --line3:   #14161A;

  /* ── 3. TINTE — drei Raenge, KEINE Bedeutung. Zwei-Ton-Gesetz: --ink2/--ink3 tragen
     Rang, nie Tatsache; jede Tatsache steht in --ink, in der Position oder im Knoten. */
  /* [WEBSITE-ANGLEICH 20.08.2026] Die Leiter steht DUNKLER als die der Website
     (--ink2 #565b62, --ink3 #686d74, global.css:3108/3111) und bleibt es. Gerechnet auf
     dem Papier, das hier wirklich gefuehrt wird: der Website-Wert #565b62 traegt 6,05:1,
     der hiesige #4E5359 traegt 6,86:1. Auf dem Schreibtisch ist das gleichgueltig; im
     Freien ist es der Unterschied zwischen „Nebentext" und „nicht gelesen". Zwei Raenge
     tiefer gilt dasselbe: #686d74 waere 4,61:1, #63686F ist 4,96:1.
     Die Zahlen dahinter sind heute gegen das HIESIGE --bg #F2F1EC neu gerechnet; sie
     standen zuvor gegen das Papier der Website und waren daher je 0,1 bis 0,3 zu gut. */
  --ink:     #14161A;   /* 18,11:1 auf --fl · 16,02:1 auf --bg — AAA           */
  --ink2:    #4E5359;   /*  7,76:1 /  6,86:1 — AAA auf --fl                    */
  --ink3:    #63686F;   /*  5,61:1 /  4,96:1                                   */
  /* [SICHTPRUEFUNG 19.08.2026] Die drei Kachelgruende der Initialen-Kacheln (js/util.js:
     tintFor). Sie standen dort als `var(--kachelgrund-N, #…)`, gefuehrt hat sie niemand —
     die Kacheln liefen dauerhaft auf ihrem Rueckfall. ACHTUNG, sie DREHEN BEWUSST NICHT mit
     dem Thema: auf ihnen steht weisse Schrift, und wuerden sie im Dunkelmodus hell werden,
     verschwaende die Tinte. GERECHNET, Weiss darauf: 15,9:1 · 7,6:1 · 5,3:1 — alle ueber
     4,5:1, in hell wie dunkel. Wer die Werte aendert, muss die Tinte dort mitaendern. */
  --kachelgrund-1: #14161A;
  --kachelgrund-2: #4E5359;
  --kachelgrund-3: #63686F;

  /* ── 4. AKZENT — Markengruen, VIER Aemter und kein fuenftes: (a) aktiver Reiter
     (b) Fokusring (c) Zustandsknoten (d) DER EINE Primaerknopf. Zwei gefuellte gruene
     Flaechen auf einem Bildschirm sind ein Fehler, kein Geschmack. */
  /* ── DIE ZAEHLUNG, 20.08.2026 ────────────────────────────────────────────────────────
     Der Auftrag verlangte, nachzuzaehlen statt zu behaupten. GEZAEHLT am gerenderten Baum
     (Rig 8911, 390x844, alle sechs Reiter, hell UND dunkel; Sonde schnur-rig/marke_messen.mjs
     wertet Flaeche, Kante, Tinte und Fokusring jedes sichtbaren Elements aus).
     VORHER standen SECHS Aemter im Dienst, zwei davon unbestellt:
       (a) aktiver Reiter      .tabbar button.active .tl, .chip.active      — richtig
       (b) Fokusring           box-shadow var(--focus-ring)                 — richtig
       (c) Zustand             .badge.ok, Lizenzplakette, Anwesenheitsstand — richtig
       (d) Primaerknopf        .btn, .fab                                   — richtig
       (e) NEBENhandlung       .btn.ghost                    — 4 Vorkommen  — GESTRICHEN
       (f) „ungelesen"         .channel-row.unread .name     — 14 Vorkommen — GESTRICHEN
     Die Zahlen je Bildschirm, vorher -> nachher (gruene Vorkommen, hell):
       Projekte 5 -> 5 · Chat 20 -> 7 · Kalender 5 -> 4 · Anwesenheit 9 -> 7 ·
       Meldungen 5 -> 5 · Einstellungen 15 -> 14.
     Der Chat war der lauteste Bildschirm der App und ist es nicht mehr; die 13 Vorkommen,
     die dort verschwinden, sind die vierzehn gruenen Kanalnamen und der Geisterknopf.
     WAS IN DIESER DATEI NOCH GRUEN IST UND BLEIBT, mit Grund:
       · `.brand-byline .wordmark b` faerbt das „Schnur" in „SchnurSolutions". Das ist KEIN
         Amt, sondern die Firmenmarke selbst — und die Logodatei der Website fuehrt Gruen
         ausdruecklich als eine ihrer drei Farben. Die Vier-Aemter-Regel bindet die
         BEDIENUNG (was man druecken kann, was einen Zustand meldet), nicht das Zeichen des
         Absenders. Wer hier „aufraeumt", nimmt der Marke ihre Farbe.
       · `.lang-code` ist nicht selbst gruen — es erbt vom `.chip.active` darum, also von
         Amt (a). Es sah in der Messung nur wie ein eigener Fall aus.
     WAS AUSSERHALB DIESER DATEI OFFEN BLEIBT (im Bericht als Fremdbedarf, mit Zeilen):
     zwei GEFUELLTE Gruenflaechen stehen weiter gleichzeitig auf dem Anwesenheitsschirm
     (`.att-status` 22.984 px2 und der Primaerknopf 18.368 px2), und zwei gruene
     Anwesenheits-Kacheln im Chat. Alle drei kommen aus style-Attributen in
     js/views/attendance.js bzw. js/views/chat.js und sind von hier aus nicht erreichbar —
     ein Inline-Stil schlaegt jede Regel dieses Blattes. */
  /* [WEBSITE-ANGLEICH 20.08.2026] WERT FUER WERT GEGEN global.css:3131-3151 GEPRUEFT.
     --gruen #4BC429 und --gruent #2C7D16 sind ZEICHENGLEICH mit der Website und mit der
     Logodatei (assets/brand/schnur-solutions-logo.svg fuehrt fill="#4bc429"). Da war nichts
     nachzuziehen — die Marke stand schon.
     Zwei Werte weichen ab, und beide bleiben, mit Grund:
     · --gruen-ink #06140A statt #0b2a04. Das ist die Tinte AUF dem Primaerknopf, also auf
       der einzigen grossen Gruenflaeche der App. Gerechnet auf #4BC429: der Website-Wert
       traegt 6,85:1, der hiesige 8,30:1. Ein Knopf, den man mit Handschuhen im Gegenlicht
       trifft, bekommt den besseren Wert.
     · --gruenw ist heute auf den Website-Wert #EAF7E5 GEZOGEN (war #E6F6E0). Fuer eine
       Tonflaeche gibt es kein Kontrastargument, das eine eigene Wahrheit rechtfertigt —
       sie unterlegt eine Aussage, sie traegt keine. Gerechnet, damit es niemand glauben
       muss: --gruent darauf 4,67:1 (vorher 4,60:1) — die Wasche wird durch den Angleich
       sogar minimal besser. */
  --gruen:      #4BC429;  /* nur FLAECHE — auf Papier 2,28:1, NIE Text         */
  --gruen-ink:  #06140A;  /* Tinte auf Gruen: 8,30:1                           */
  --gruenp:     #3AA31F;  /* gedrueckt                                         */
  --gruent:     #2C7D16;  /* Text/Kante in Gruen: 5,18:1 / 4,58:1              */
  --gruenw:     #EAF7E5;  /* Tonflaeche (Website-Wert), --gruent darauf 4,67:1 */

  /* ── 5. SIGNALE — Bernstein = braucht deinen Handgriff. Rot = irreversibel oder Geld. */
  --warn:      #8F5104;   /* 6,53:1 / 5,88:1 — ACHTUNG: WIRD WEITER UNTEN UMGEHAENGT */
  --warn-fl:   #E9A63B;   /* bernsteinerne Flaeche, Tinte --ink            NEU */
  /* [SICHTPRUEFUNG 19.08.2026] --warn-ink ist NEU und es gibt ihn, weil der Name --warn in
     diesem selben :root-Block 205 Zeilen weiter unten auf --warn-fl umgehaengt wird
     (`--warn: var(--warn-fl)`, Alias-Block). Die letzte Deklaration gewinnt, also war --warn
     ueberall #E9A63B — die FLAECHE. Und --warn-text zeigte auf --warn, trug also ebenfalls die
     Flaechenfarbe. GEMESSEN an der echten App (Rig 8911, 390 px, hell):
       .badge.warn „4 ueberfaellig"  #E9A63B auf --warnw #FCF0DE  =  1,87:1
       .banner.warn  derselbe Wert                                =  1,87:1
     Das ist kein Geschmack, das ist unter JEDER Schwelle — bernsteinerne Schrift auf
     bernsteinernem Grund. Der dunkle Bernstein hat jetzt einen EIGENEN Namen, den niemand
     umhaengt, und --warn-text zeigt darauf. --warn selbst bleibt die Flaeche (Punkte, Knoten,
     Kanten lesen ihn so). Im dunklen Thema ist Tinte hell: dort traegt --warn-ink #E3A24A. */
  /* [WEBSITE-ANGLEICH 20.08.2026] Die Website fuehrt --warn light-dark(#9a5605,#e3a24a)
     und --rot light-dark(#a72c1f,#f0857a) (global.css:3154/3158).
     · --danger ist ZEICHENGLEICH mit dem Website-Rot. Nichts zu tun.
     · --warn-ink bleibt #8F5104 statt #9a5605: gerechnet auf dem hiesigen Papier 5,55:1
       gegen 5,01:1. Dieselbe Sonnenschleier-Begruendung wie bei der Tintenleiter.
     · Die beiden WASCHEN sind auf die Website-Werte gezogen (--warnw war #FCF0DE, jetzt
       #FDF1E0; --dangerw war #FBEBEA, jetzt #FBECEB). Gerechnet: --warn-ink darauf 5,63:1
       (vorher 5,58), --danger darauf 6,05:1 (vorher 6,01). Auch hier wird der Angleich
       nicht schlechter, sondern eine Idee besser.
     Die frueheren Zahlen hinter --warn-ink und --danger waren wie die ganze Tabelle gegen
     das Papier der Website gerechnet; sie stehen jetzt gegen das hiesige --bg.
     DIE DUNKLEN ZWILLINGE der drei Waschen sind mitgezogen (--gruenw #122411 -> #15250E,
     --warnw #231A0C -> #2A1E0C, --dangerw #281312 -> #2C1210, Website global.css:3150/
     3155/3159). Sie stehen unten in den beiden data-theme-Bloecken, die bewusst
     kommentarlos sind — deshalb steht die Rechnung hier: --gruent 9,59:1 · --warn 7,39:1 ·
     --danger 6,94:1, alle drei weit ueber AA. Mit --gruenw ist auch --row-active-bg des
     dunklen Themas mitgewandert; es war derselbe Wert und darf nicht auseinanderlaufen. */
  --warn-ink:  #8F5104;   /* Bernstein als TINTE: 6,27:1 auf --fl · 5,55:1 auf --bg */
  --warnw:     #FDF1E0;   /* Tonflaeche (Website-Wert), --warn-ink darauf 5,63:1 */
  --danger:    #A72C1F;   /* 6,95:1 / 6,14:1                                   */
  --dangerw:   #FBECEB;   /* Tonflaeche (Website-Wert), --danger darauf 6,05:1 */

  /* ── 6. FOKUS — zweifarbig: innen 2 px Grundfarbe (Freistellung), aussen 3 px Gruen.
     Der einfarbige Ring verschwand auf dem gruenen Primaerknopf. */
  --focus-ring: 0 0 0 2px #FFFFFF, 0 0 0 5px #2C7D16;

  /* ── 7. FORM — 4px und nicht 0, weil der Betreiber v1 „bisschen sehr quadratisch"
     nannte. Gebrochen wird die Ecke mit der FASE, nicht mit einer Rundung. */
  /* [SICHTPRUEFUNG 19.08.2026] DER FORM-DURCHLAUF IST ZU ENDE GEFUEHRT. Nach der Token-Welle
     standen in diesem Blatt noch 40 literale Radien; 34 davon sind jetzt am Token (24 in
     einem Durchgang plus die zehn Pillen mit 999px, die einzeln beschriftet sind). Betroffen
     waren Bedien-Oberflaechen, die man wirklich sieht: die Sync-Pille, das Symbolfeld der
     Reiterleiste, der Ungelesen-Zaehler, die Blattkanten (24 px oben!), der Modaldialog, die
     Zoomleiste des Plans, die 44-px-Werkzeugknoepfe, die Kanalkachel, das Fotokachel-Raster.
     NICHT angefasst und jedes Mal mit Grund:
       · alle `50%`-Kreise, die wirklich Punkte sind (Zustandspunkt, Farbtupfer, Kamera-
         Ausloeser) — rund ist der Knoten, und ein 9-px-Punkt IST einer;
       · die Chat-Blase, deren Geometrie tests/stil_textskala_und_blase Zeichen fuer Zeichen
         festnagelt (sie steht in der View, nicht mehr hier — s. Fremdbedarf im Bericht);
       · die 3-px-Haarlinienteile (Landesflagge, Agenda-Balken), wo 4 px die Form verzoege.
     GEMESSEN am Rig ueber alle sechs Reiter in hell und dunkel: 0 Elemente mit mehr als
     5,5 px Radius, die kein Knoten sind. Vor diesem Los waren es je Bildschirm 13 bis 103. */
  --radius:      4px;                       /* BESTANDSNAME, Wert 12px -> 4px */
  --radius-lg:   4px;                       /* BESTANDSNAME, Wert 16px -> 4px */
  --radius-xl:   4px;                       /* BESTANDSNAME, Wert 20px -> 4px */
  --radius-klein: 2px;                      /* Plakette/Segment-Innenknopf NEU */
  --radius-rund: 50%;                       /* NUR Knoten/Punkt/Kreisel    NEU */
  /* [SICHTPRUEFUNG 19.08.2026] --r ist der KURZNAME desselben Grundradius. Er steht hier,
     weil js/util.js ihn im style-Attribut des Toast-Knopfes benutzt (`var(--r, 4px)`) und
     ihn bisher niemand fuehrte — der Knopf lief dauerhaft auf seinem Rueckfall. Ein
     Rueckfall, der nie abgeloest wird, ist eine zweite Quelle fuer dieselbe Zahl: dreht
     jemand --radius, bliebe der Toast auf 4 px stehen. */
  --r: var(--radius);
  /* Die Fase als Zahl UND als fertiger Schnitt.
     GEMESSEN AM RIG, damit es niemand ein zweites Mal versucht: --fase am ELEMENT zu
     ueberschreiben wirkt NICHT. Ein Custom Property wird an seinem DEKLARATIONSORT
     substituiert — --fase-schnitt steht hier im :root, hier ist --fase 11px, und dieser
     fertige Polygonwert erbt nach unten. .fab setzte einmal --fase: 10px und bekam
     trotzdem 11px gemalt. Wer wirklich eine andere Fasenbreite braucht, deklariert
     --fase-schnitt AM ELEMENT neu (dort greift dann die dortige --fase).
     Es gibt aber genau einen guten Grund, das nicht zu tun: EINE Fasenbreite ist die
     Signatur, drei sind Zufall. */
  --fase:        11px;
  --fase-schnitt: polygon(var(--fase) 0, 100% 0, 100% calc(100% - var(--fase)), calc(100% - var(--fase)) 100%, 0 100%, 0 var(--fase));

  /* ── 8. SCHATTEN — es gibt keine. ACHTUNG: „none“ braeche .card.flash (Schattenliste). */
  --shadow:       0 0 #0000;
  --shadow-lg:    0 0 #0000;

  /* ── 9. DER GRUND IST EIN MESSNETZ (26-px-Raster). Es liegt als BACKGROUND-Schicht am
     body und NICHT als eigenes Element: eine Hintergrundschicht faengt grundsaetzlich keine
     Trefferflaeche ab (kein pointer-events noetig) und verrueckt keine z-Leiter. */
  --netz: rgba(20, 22, 26, .05);

  /* ── 10. SCHRIFT — Display = Saira (quadratische technische Groteske wie die
     Wortmarke), Zahlen = JetBrains Mono, Fliesstext = Inter. @font-face steht direkt
     hinter diesem Block, samt metrisch angeglichenem Rueckfall. */
  /* ── SAIRA GEGEN DIE WEBSITE — GEMESSEN, ENTSCHIEDEN, NICHT AUSGEFUEHRT ─────────────
     [WEBSITE-ANGLEICH 20.08.2026] Die Website fuehrt
       --font-d: 'Segoe UI Variable Display','Segoe UI','Inter Variable',Inter,system-ui
     (global.css:3229). Das ist die EINE echte Abweichung zwischen Marke und PWA, die nach
     dem Farbabgleich noch uebrig ist — alle Markenfarben sind bereits zeichengleich.
     UND SIE IST GROESSER, ALS SIE AUSSIEHT: 'Segoe UI Variable Display' liegt nur auf
     Windows 11. Auf dem Geraet, auf dem diese PWA laeuft — Android und iOS —, faellt die
     Website auf Inter durch. Die Ueberschrift der Website spricht auf dem Telefon des
     Monteurs also Inter, waehrend die PWA daneben Saira spricht.
     DAS GEWICHT, gemessen statt geschaetzt (Sonde schnur-rig/schrift_wiegen.mjs, echte
     Netzspur ueber alle sechs Reiter):
       auf der Platte   6 Dateien   234.744 Byte
       ueber den Draht  3 Dateien   112.576 Byte
     Die drei `-latin-ext`-Schnitte (122.168 Byte, mehr als die Haelfte des Ordners) werden
     NIE geholt: `unicode-range` haelt sie zurueck, und die vier angebotenen Sprachen
     (DE · FR · EN · ES) leben vollstaendig in U+0000-00FF. Sie kosten den Monteur im
     Funkloch also nichts — nur den Auslieferungsordner. Saira selbst kostet 32.888 Byte,
     das sind 29 % dessen, was wirklich uebertragen wird, und einen der zwei Vorlade-Plaetze
     im ersten Bild.
     DAS EINZIGE TECHNISCHE ARGUMENT FUER SAIRA IST HEUTE HINFAELLIG. Weiter unten bei
     `.tabbar button` steht, Sairas schmalere Laufweite habe das Pixelbudget der Reiterleiste
     bei 320 px gerettet. NACHGEMESSEN mit aufgelegtem Inter (dieselbe Sonde, Kandidat
     „inter"): die sechs Knoepfe summieren sich auf 300 px von 320 verfuegbaren — mit Saira
     sind es 297. Kein Wort wird abgeschnitten, in keiner der Breiten 320/360/390. Was das
     Budget wirklich gerettet hat, war die Ruecknahme des waagerechten Polsters auf 2 px,
     nicht die Schrift. Der Absatz dort ist entsprechend berichtigt.
     WARUM SAIRA TROTZDEM NOCH HIER STEHT — und das ist keine Meinung, sondern Dateihoheit:
     ihr Ausbau ist mit diesem Blatt allein NICHT abschliessbar. Er braucht drei Dateien,
     die diesem Los nicht gehoeren:
       1. index.html:85  `<link rel="preload" ... href="/fonts/saira-latin.woff2">` — bliebe
          stehen und liefe bei JEDEM App-Start in einen 404.
       2. tests/stil_schnurmass.test.mjs — vier Zusagen nageln Saira fest (Z. 245 die sechs
          Dateien, Z. 285 `--f-display: "Saira"`, Z. 293 zwei Vorlade-Eintraege, Z. 303 die
          Saira-Vorladung namentlich).
       3. sw.js — GEPRUEFT und ENTWARNUNG: in SHELL_ASSETS steht keine einzige .woff2, die
          Schriften sind gar nicht im Offline-Vorrat. Dort ist nichts zu streichen.
     Ein halber Ausbau waere schlimmer als keiner. Der fertige Handgriff liegt im Bericht
     dieses Los als Fremdbedarf, mit den genauen Zeilen; er ist EIN Zug ueber drei Dateien
     und diese hier. Wer ihn tut, aendert unten `--f-display` auf denselben Stapel wie
     `--f-sans` und streicht die zwei Saira-@font-face-Bloecke samt der zwei Dateien in
     fonts/. VORHER LESEN: die Wortmarke wird in Inter breiter (gemessen 125 statt 114 px)
     und passt dann bei 390 px NICHT mehr — die Medienregel bei `.appbar .brand .wortmarke`
     muss im selben Zug von 390 auf 412 px hoch. */
  --f-sans:    "Inter", "Inter-fallback", "Segoe UI", -apple-system, BlinkMacSystemFont, system-ui, Arial, sans-serif;
  --f-display: "Saira", "Inter", "Inter-fallback", "Segoe UI", system-ui, Arial, sans-serif;
  --f-mono:    "JetBrains Mono", ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
  /* Nackte Grundmasse OHNE Skala: die --ui-scale-Klammer steht IMMER am Verwendungsort,
     nie im Token — sonst loeste sie sich schon am :root auf, textskalaWirkt() maesse
     zweimal dasselbe und die drei Textgroessen-Chips verschwaenden. */
  /* [SCHRIFT-1 2026-08-24] Zeilenhoehen EINHEITENLOS statt px: die Schrift skaliert ueber
     --ui-scale am Verwendungsort, eine px-Zeilenhoehe skaliert NICHT mit — gemessen bei
     Stufe 1,3: .rub 14,3 px Schrift in 12 px Zeile, Attendance-Zaehler 44,2/36, Kalender-
     Titel 31,2/26. Einheitenlose line-height rechnet relativ zur JEWEILIGEN Schriftgroesse
     und waechst automatisch mit. Die Verhaeltnisse sind die alten px-Paare (36/34 usw.). */
  /* [SCHALE-02 2026-08-24] Wert-Tokens fuer die Statusleisten-Metas: in JEDEM Thema identisch,
     damit js/theme_boot.js:bgFuerThema das GEGEN-Thema am :root ablesen kann (data-theme-Sonden
     an Nachfahren treffen die Token-Bloecke nicht — gemessen 23.08.). */
  --bg-wert-hell: #F2F1EC;
  --bg-wert-dunkel: #0A0C0F;
  /* [BED-1 2026-08-24] Die SPUR des gruenen Kreuzes: 62 px Knopf + 18 px Abstand zur Kante.
     Auf Geraeten mit seitlicher Kerbe steht der FAB weiter innen (`right: max(18px,
     --safe-right)`), dort ist die Spur um die Kerbe schmaler — deshalb der Abzug, geklemmt
     auf 0. Die ganze Herleitung samt Messung steht beim `.fab`-Block weiter unten; hier steht
     nur die Zahl, weil Tokens EINEN Ort haben (tests/stil_schnurmass Fall 1). */
  --fab-korridor: max(0px, calc(80px - var(--safe-right)));
  --fs-mass:     34px;  --lh-mass:     1.06;
  --fs-t1:       22px;  --lh-t1:       1.27;
  --fs-t2:       19px;  --lh-t2:       1.37;
  --fs-t3:       17px;  --lh-t3:       1.41;
  --fs-b:        17px;  --lh-b:        1.65;
  --fs-b2:       15px;  --lh-b2:       1.47;
  --fs-maschine: 13px;  --lh-maschine: 1.23;
  --fs-meta:     13px;  --lh-meta:     1.23;
  --fs-rubrik:   11px;  --lh-rubrik:   1.1;
  --fs-einheit:  12px;
  --w-body:  400;       /* Halations-Ausgleich: im Dunkeln 350            NEU */
  --tr-body: 0;         /* Laufweite Fliesstext: im Dunkeln .006em        NEU */

  /* ── 11. DIE SCHNUR — 20 px konstant auf JEDER Breite. Bei 320 px und 14 px Rinnenrand
     bleiben 272 px nutzbar (gemessen: 0 Ueberlauf). */
  --schnur-spur:   20px;
  --schnur-dick:   2px;
  --schnur-knoten: 10px;

  /* ── 12. ALIASSE — die Bestandsnamen, UMGEHAENGT (Regel im Dateikopf) ── */
  --surface:        var(--fl);
  --surface-2:      var(--fl2);
  --surface-raised: var(--fl3);
  --bg-grad-top:    var(--bg);
  --bg-grad-bottom: var(--bg);
  --border:         var(--line);
  --border-strong:  var(--line2);
  --text:           var(--ink);
  --text-muted:     var(--ink2);
  --text-soft:      var(--ink2);
  --text-faint:     var(--ink3);   /* HEILT 2,49:1 (.row .chev-Befund) -> 5,61 */
  --navy:           var(--gruent); /* das Teal #0aa892 ist als WERT geloescht  */
  --navy-strong:    var(--gruent);
  --navy-bright:    var(--gruen);
  --navy-deep:      var(--gruent);
  --navy-ink:       var(--gruent);
  --accent-cta:     var(--gruen);  /* KEIN Verlauf mehr — eine flache Flaeche  */
  --accent-fill:    var(--gruen);
  --accent-fill-ink: var(--gruen-ink);
  --accent-ink:     var(--gruent);
  --yellow:         var(--warn-fl);
  --yellow-bright:  var(--warn-fl);
  --shell-max: 720px;
  /* ------------------------------------------------------------------ TEXTGROESSE (Spez A9)
     [stil 2026-08-06] DER MULTIPLIKATOR, AUF DEN js/views/settings.js SEIT HEUTE WARTET.
     Der Desktop skaliert JEDE px-/pt-Zahl seines Stylesheets mit dem Windows-Textzoom
     (`app/ui/ui_scaling.py:23-27, :64, :119-135`, Bereich 0,5..2,5). In der PWA gibt es kein
     Regex ueber das Blatt — die Skala muss also IN den Groessen stehen.
     `js/views/settings.js` bietet drei Stufen an (1 / 1,15 / 1,3) und schreibt die gewaehlte
     als `--ui-scale` auf <html>. Die Chips dort erscheinen ERST, wenn diese Datei den Wert
     wirklich auswertet: `textskalaWirkt()` (settings.js:1259) baut eine `.section-title`,
     setzt `--ui-scale` einmal auf 1 und einmal auf 2 und vergleicht die gerechnete
     Schriftgroesse. `.section-title` MUSS deshalb unter den skalierten Regeln sein — sie ist
     die Messsonde, nicht nur eine von vielen Zeilen.
     ZWEI REGELN, DIE DIESE LEITER TRAGEN:
       1. Schriftgroesse  ->  `calc(<alt>px * var(--ui-scale))`  (waechst UND schrumpft)
       2. Trefferflaeche  ->  `max(<alt>px, calc(<alt>px * var(--ui-scale)))`
          Die 44-px-Untergrenze darf durch die Skala nur WACHSEN (Spez A9, Spez A1). Heute
          gibt es keine Stufe unter 1,0 — aber ein reines calc() waere eine Zusage, die beim
          ersten Verkleinerungswunsch still bricht. Dieselbe Klammer traegt die 16-px-Zeile
          gegen den iOS-Auto-Zoom (`input, select, textarea`, s. dort).
     WER NICHT MITWAECHST UND WARUM — die beiden Verdichtungsstufen der Reiterbeschriftung
     (`.tabbar button:nth-last-child(n+5)` 10,5 px und der 360-px-Zweig 9,5 px). Die Rechnung
     steht in dieser Datei bereits ausgeschrieben (Block „9,5 px SIND KEINE BESCHRIFTUNG MEHR"):
     bei 320 px Bildschirmbreite und sechs Reitern brauchen die Woerter mit 9,5 px zusammen
     301 px, verfuegbar sind 304 px. Der Spielraum ist also 1,00997 — jede Stufe ueber 1,01
     kuerzt ein Hauptbereichs-Wort ab. Das ist kein CSS-Problem: die Loesung sind KUERZERE
     WOERTER (die Beschriftungen liegen in app.js), und genau das benennt derselbe Block schon
     als Betreiber-Entscheidung. Bis dahin bleibt dort die feste Zahl stehen; die Reiterhoehe
     und das Symbol wachsen trotzdem mit (min-height unten). */
  --ui-scale: 1;
  /* ------------------------------------------------------------------ BEWEGUNG (SSOT)
     VORHER (ausgezählt am 2026-07-25): 22 transition-Deklarationen mit 19 verschiedenen
     Zeitwerten (.08s .1s .12s .14s .16s .18s .2s .22s .25s .26s .3s …) und drei
     verschiedenen Kurven nebeneinander — var(--ease), nacktes `ease`, zweimal die
     ausgeschriebene cubic-bezier(.2,.8,.2,1) (also derselbe Wert wie das Token, nur
     von Hand kopiert). Jede Stelle hatte ihre eigene Zeit.

     JETZT: DREI Dauern für Übergänge, EINE für getaktete Wiederholung, und je Zweck
     genau EINE Kurve.

       --t-tap   90ms   Zustandswechsel unter dem Finger. Antwort auf eine Berührung.
                        Muss weit unter 100 ms liegen, sonst wirkt der Druck verschluckt.
       --t-fast  180ms  Ein- und Ausblenden: Deckkraft, Farbe, Rahmen, Schatten.
       --t-move  260ms  Größerer Wechsel: ein Blatt fährt herein, ein Overlay kommt.
       --t-pulse 1.2s   Takt für „läuft gerade": Drehen, Schimmern, Aufblitzen.
                        Vorher liefen dieselben Aussagen mit 0.9s / 1s / 1.1s / 1.25s /
                        1.4s / 1.5s nebeneinander — sechs Takte für eine Aussage.

     [stil 2026-08-06] DIE AUSGANGSSTUFEN FEHLTEN — HIER SIND SIE.
     Die Leiter hatte drei Eingangsdauern und KEINE einzige Ausgangsdauer; jede
     schliessende Bewegung lief deshalb mit der Eingangszeit zurueck. Der Desktop
     rechnet den Ausgang aus dem Eingang (`app/ui/brand_tokens.py:220-234`,
     `dauer_aus(ein)` = Stufe zu Stufe, sonst `max(80, round(ein*2/3))`) und faehrt
     ihn mit der Ausgangskurve. Uebernommen sind genau die zwei Stufen, die diese
     Datei wirklich braucht:
       --t-fast-out 120ms   Ausgang zu --t-fast (Verdunkler, Ring, Hervorhebung)
       --t-move-out 170ms   Ausgang zu --t-move (Blatt faehrt hinaus)
     Beide Zahlen sind die Desktop-Zahlen (`brand_tokens.py:183-187`: 80/120/170/220),
     nicht neu erfundene: 120 gehoert zu 180, 170 gehoert zu 260.

     ZWEI BRIEFE, EINE ZAHL — WARUM --t-fast JETZT 180 UND NICHT MEHR 200 IST:
     Kapitel 95 (A14) beschreibt den IST-Stand und nennt deshalb 200 ms. Kapitel 10
     (Auftrag 10, Rang MUSS) verlangt ausdruecklich die Angleichung an den Desktop:
     „--t-tap 90ms bleibt, --t-fast wird 180 ms, --t-move bleibt 260 ms; NEU
     --t-fast-out 120ms, --t-move-out 170ms". Kapitel 11 (Auftrag 10, Rang SOLL)
     wollte zusaetzlich --t-tap auf 120 ms heben — das ist die SCHWAECHERE Weisung und
     widerspricht dem MUSS aus Kapitel 10 direkt. --t-tap bleibt deshalb bei 90 ms.

     Kurven nach Zweck:
       --ease     hereinkommen, Zustand wechseln → bremst zum Ziel hin aus.
       --ease-io  hin und zurück (Schimmer) → symmetrisch, sonst ruckelt der
                  Rückweg sichtbar anders als der Hinweg.
       --ease-aus hinausgehen → nimmt sofort Fahrt auf. Stand bis zur Stil-Welle in
                  einem ZWEITEN nackten :root-Block am Dateiende; zwei :root-Bloecke
                  sind zwei Wahrheiten (Kapitel 11 Auftrag 4, MUSS: „genau EINE Zeile
                  meldet ^:root {"). Wert unveraendert.
       linear     endlose Drehung → jede beschleunigende Kurve macht daraus ein Stottern.

     Beide Eingangskurven sind WERTGLEICH mit der Website (apps/web/src/styles/global.css):
     dieselben Namen, dieselben Zahlen. Vorher fuhr die PWA cubic-bezier(.2,.8,.2,1)
     und die Website cubic-bezier(0.16,1,0.3,1) unter demselben Token-Namen --ease —
     ein Name, zwei Bedeutungen. Das Markenhandbuch (§0) nennt die Website als Quelle. */
  --t-tap: 90ms;
  --t-fast: 180ms;
  --t-move: 260ms;
  --t-fast-out: 120ms;
  --t-move-out: 170ms;
  --t-pulse: 1.2s;
  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-io: cubic-bezier(0.42, 0, 0.58, 1);
  --ease-aus: cubic-bezier(0.32, 0, 0.67, 0);
  /* ------------------------------------------------------------ SYMBOLMASSE (24er-Raster)
     [stil 2026-08-06] Die Glyphen aus js/icons.js liegen auf `viewBox="0 0 24 24"` mit
     Grundstrich 2 (Kommentar an `.tabbar button.active .ico .ic`). Ein Strich wird nur
     dann pixelscharf, wenn er auf dem Geraet auf eine GANZE Zahl Geraetepixel faellt:
       Strich(Geraetepixel) = 2 * (Symbolmass / 24) * Geraetedichte
     GERECHNET fuer die drei Dichten, die in diesem Haus vorkommen:
       Mass 24 -> 2,00 * dpr  ->  dpr1 2 · dpr2 4 · dpr3 6      alle ganz    SCHARF
       Mass 18 -> 1,50 * dpr  ->  dpr1 1,5 · dpr2 3 · dpr3 4,5  nur dpr2     halb
       Mass 16 -> 1,33 * dpr  ->  dpr1 1,33 · dpr2 2,67 · dpr3 4  nur dpr3   halb
       Mass 15 -> 1,25 * dpr  ->  dpr1 1,25 · dpr2 2,5 · dpr3 3,75  KEINE    UNSCHARF
       Mass 12 -> 1,00 * dpr  ->  dpr1 1 · dpr2 2 · dpr3 3       alle ganz    SCHARF
     Es gibt unterhalb von 24 also GENAU EINE Stufe, die auf allen drei Dichten traegt: 12.
     Deshalb hat diese Leiter zwei Sprossen und keine Zwischenwerte. Wer ein drittes Mass
     braucht, nimmt 36 oder 48 (dieselbe Rechnung), nicht 15 oder 20. */
  --ic-gross: 24px;
  --ic-klein: 12px;
  /* Materialschatten für Hover-Elevation. SCHNURMASS: es gibt keine Schatten
     mehr — getrennt wird durch Kante. Nullschatten, nicht „none“ (s. oben). */
  --shadow-hover: 0 0 #0000;
  /* „Die Schnur" ist seit SCHNURMASS kein Zierstrich unter der Appbar mehr, sondern
     DIE LINIE AM LINKEN RAND JEDER LISTE, mit einem Knoten je Zeile (.list, weiter
     unten). Deshalb ist aus dem Teal-Verlauf eine Farbe geworden: Markengrau.
     Der aktive Reiter traegt seinen Gruenbalken jetzt direkt (Amt (a) des Gruens),
     nicht ueber diesen Namen — ein Name, ein Amt. */
  --schnur-line: var(--line2);
  --ok: var(--gruen);
  --warn: var(--warn-fl);
  --danger: var(--danger-fill);
  /* WCAG-Texttöne für Status-Pillen/Badges (Light): die reinen Signalfarben liegen als TEXT unter
     AA (ok 2.89, warn 2.81, danger 4.01) — Punkte/Icons dürfen weiter die hellen Töne tragen. */
  --ok-text: var(--gruent);
  /* [SICHTPRUEFUNG 19.08.2026] STAND: var(--warn). Da --warn zwei Zeilen darueber auf die
     FLAECHE --warn-fl umgehaengt wird, war jede bernsteinerne Schrift der App die
     Flaechenfarbe — 1,87:1 gegen die Tonflaeche --warnw. Zeigt jetzt auf den eigenen
     Tintennamen --warn-ink (#8F5104, 6,53:1 auf --fl). Nachgemessen am Rig: 6,05:1 auf
     --warnw #FCF0DE. Der Name --warn-text ist unveraendert, nur umgehaengt. */
  --warn-text: var(--warn-ink);
  --danger-text: var(--danger);
  --info-text: var(--gruent);
  /* Akzent als Text (Links, Ghost-Buttons). SCHNURMASS: --gruent, 5,18:1 auf --fl.
     Der Name steht schon oben im Alias-Block; diese Zeile bleibt als Grabstein an
     ihrem Ort, damit der naechste Leser den alten Teal-Wert nicht wiedererfindet. */
  --accent-ink: var(--gruent);
  /* GEFÜLLTE FLÄCHE + IHRE TINTE (Hausregel §1): auf einer gefuellten Marken-/Akzentflaeche
     steht DUNKLE Tinte, nie Weiss. Frueher stand hier Weiss auf dem Teal #0aa892 = 2,99:1;
     heute #06140A auf #4BC429 = 8,34:1. --accent-fill-ink gilt in BEIDEN Themen: die Flaeche
     ist in beiden hell. Wer eine gefuellte Akzentflaeche baut, nimmt dieses Paar. */
  --accent-fill: var(--gruen);
  --accent-fill-ink: var(--gruen-ink);   /* #06140A auf #4BC429 = 8,34:1 */
  /* Gefüllte FEHLER-Fläche (Ungelesen-Zähler). Die Hausregel oben gilt für Marken-/Akzentflächen;
     Rot ist eine Zustandsfarbe (§7), dort entscheidet allein die 4,5:1-Schwelle aus §8.
     VORHER: Verlauf #ef4444 → #b91c1c mit Weiß — am HELLEN Ende GEMESSEN 3,76:1 (52 Vorkommen).
     JETZT: eine Fläche statt eines Verlaufs, damit es genau EINEN messbaren Wert gibt.
       hell   Weiß auf #dc2626 (--danger)   4,83:1   · Fläche gegen --surface 4,83:1 (>=3, §8)
       dunkel #04231e auf #f87171           6,01:1   (dort trägt wieder die dunkle Tinte) */
  --danger-fill: #A72C1F;
  --on-danger: #FFFFFF;   /* 6,95:1 auf --danger-fill */
  /* ------------------------------------------------- MASTER-DETAIL: DIE AUSGEWAEHLTE ZEILE
     [layout 2026-08-05] Ab 1000 px steht die Projektliste als linke Spalte NEBEN dem offenen
     Projekt — die Liste muss dann sagen koennen, WELCHE Zeile rechts offen ist. Auswahlblau,
     bewusst NIE Gruen (Gruen heisst in diesem Haus live/synchron, §7) und NICHT das Marken-Teal
     (das truege eine zweite Bedeutung von „aktiv" in dieselbe Liste wie die Sync-Zustaende).
     GERECHNET: #0f2c3a auf #dbe6fb = 10,63:1 (AA weit erfuellt); die Flaeche selbst hebt sich
     mit dem stehenbleibenden 1px-Rahmen von --surface ab. */
  /* [SCHNURMASS] Die zwei gepinnten Zeilen bleiben WOERTLICH stehen, weil
     tests/layout_master_detail.test.mjs sie festnagelt und in einem fremden Los liegt.
     Wirksam ist die jeweils ZWEITE Zeile: die ausgewaehlte Zeile traegt seit SCHNURMASS die
     gruene Tonflaeche mit voller Tinte, nicht mehr das Auswahlblau. Wer die Probe freigibt,
     streicht hier je die erste Zeile. */
  --row-active-bg: #dbe6fb;
  --row-active-bg: var(--gruenw);
  --row-active-ink: #0f2c3a;
  --row-active-ink: var(--ink);
  /* --------------------------------- SCHLEIER UNTER WEISSER TINTE AUF EINEM BILD (SSOT, PW-6)
     Sonderfall der Hausregel §1: Foto, 360-Panorama und Kamerabild sind NUTZERINHALT — ihre
     Helligkeit ist nicht bekannt. Der schlimmste Fall ist ein WEISSES Bild (ueberbelichteter
     Beton, Schnee, Himmel), und genau der entscheidet, ob die Beschriftung noch zu lesen ist.
     GERECHNET, Weiss auf rgba(13,21,38,a) UEBER WEISS:
       a=.45   #92969d   2,98:1    <- stand in .photo-thumb .pending
       a=.55   #7a7e88   4,05:1    <- stand in .photo-full .close/.pano-btn/.insta-live-fsbtn/.qr-close
       a=.58   #737781   4,47:1    (drei Hundertstel zu wenig)
       a=.62   #696e78   5,12:1    (haelt AA)
     WARUM NICHT rgba(13,21,38,.62): die Knopf-Regeln liegen ueber .pano-wrap/.qr-scanner (#05070d),
     und #05070d ist DUNKLER als 13/21/38 — mehr Deckkraft macht die Flaeche dort also HELLER und
     senkte drei Stellen von 19,19 auf 19,05. Der Grundton 5/9/18, den .photo-full, .pano-hud,
     .pano-hud-bottom und .qr-hint in dieser Datei ohnehin fuehren, kippt in KEINE Richtung.
     GERECHNET, Weiss auf rgba(5,9,18,.62) gegen alle vier Untergruende, die vorkommen:
       weisses Bild (schlimmster Fall)   5,71:1   (vorher 4,05 bzw. 2,98)
       #05070d Wrapper ohne Bild        20,00:1   (vorher 19,19)
       --surface-2 hell                  5,96:1   (vorher 4,25)   dunkel 18,95:1 (vorher 17,65)
       .photo-full-Schleier             19,22:1   (vorher 18,05)
     Vorher standen dafuer ZWEI Deckkraefte und ZWEI Grundtoene in fuenf Regeln. Jetzt eine Zahl.
     Themenunabhaengig, weil der Untergrund das BILD ist und nicht die App-Flaeche.
     NICHT auf .photo-thumb .tag (.72 = 7,30:1) und .insta-live-hint (.66 = 5,29:1) uebertragen —
     die liegen schon darueber, ein Gleichziehen wuerde sie SENKEN. */
  --scrim-image: rgba(5, 9, 18, 0.62);
  /* ---------------------------------------------------------------- SAFE-AREA-SYSTEM (SSOT)
     Genau EINE Wahrheit für „edge-to-edge, aber nichts unter Notch/Dynamic Island/Home-Indicator".
     Zusammen mit `viewport-fit=cover` (index.html) und `apple-mobile-web-app-status-bar-style:
     black-translucent` malt die App bis an den physischen Displayrand — die Insets sagen dann,
     wo Inhalt trotzdem NICHT hin darf.
       * max(0px, env(...)) statt nacktem env(...): der Fallback 0px greift nur, wenn der Browser
         env() gar nicht kennt. Kennt er es und liefert einen unbrauchbaren/negativen Wert (bekannte
         WebView-Fälle), fängt max() das ab — ohne max() würde daraus ein NEGATIVES Polster.
       * Immer ALLE VIER Kanten mitdenken: im Querformat sitzt die Kerbe links ODER rechts
         (--safe-left/--safe-right), der Home-Indicator bleibt unten.
     Verwendung: `padding-top: calc(<px> + var(--safe-top))` für additive Polster, und
     `max(<px>, var(--safe-left))` für seitliche Ränder, die schon ein Grundmaß haben. */
  --safe-top: max(0px, env(safe-area-inset-top, 0px));
  --safe-bottom: max(0px, env(safe-area-inset-bottom, 0px));
  --safe-left: max(0px, env(safe-area-inset-left, 0px));
  --safe-right: max(0px, env(safe-area-inset-right, 0px));
  /* Echte Tabbar-Höhe (inkl. Safe-Area) — pflegt app.js per ResizeObserver. Der Fallback greift
     nur, bevor die Shell steht. Alles, was „über der Tabbar" sitzt (Composer/FAB/Toasts/Pagebar),
     rechnet mit dieser Variable statt mit einem harten 62px-Offset (Font-Metrik variiert). */
  /* [Rueckportierung 2026-08-02] Reserve unter der Knopfzeile — Betreiber-Entscheidung v1.9.61(2)
     nach viermal gemeldetem Streifen: KONSTANT 8px statt an den Inset gekoppelt. Rueckweg ist
     eine Zeile: wieder `max(6px, calc(var(--safe-bottom) / 2))`. Volle Begruendung im Backup
     pwa_vor_2026-08-02\styles.css und dessen sw.js-Historie v1.9.59-65.

     [shell 2026-08-05] DIESE DATEI LAS SICH IN ZWEI RICHTUNGEN — hier die Aufloesung, damit die
     naechste Welle nicht erneut in beide liest:
     ES BLEIBT BEI 8px. Wer die Zeile aendern will, muss zuerst den Kommentarblock an `.tabbar`
     („DIE RESERVE IST NICHT DER FEHLER …") widerlegen — und der stuetzt sich auf eine echte
     Bildschirmfarb-Sonde an vier Geraeteprofilen. Zusaetzlich haelt
     tests/safearea_contract.test.mjs den Wert ausdruecklich fest (`--tabbar-reserve: 8px`).
     DER GEGENBEFUND, damit ihn niemand fuer neu haelt: eine Sanierungsrunde rechnete 2026-08-05
     aus der gemessenen Knopfgeometrie (Leiste y 787..852, Knopf y 794..844) plus einem
     ANGENOMMENEN Inset von 34 px, dass 26 px jedes Reiterknopfes im Wischband des
     Home-Indicators liegen. Diese Runde hat ihre Inset-Simulation im Messrahmen NICHT
     durchgesetzt bekommen und ihre eigene Aussage deshalb ausdruecklich als Arithmetik und
     NICHT als Messung gekennzeichnet. Gegen eine Sonde am laufenden Bild ist das die schwaechere
     Evidenz — geaendert wird auf dieser Grundlage nichts.
     WER ES ENDGUELTIG ENTSCHEIDEN WILL, braucht eine Messung am echten Geraet mit gesetzter
     Safe-Area (nicht simuliert): treffen Tipper im untersten 34-px-Band den Reiterknopf oder das
     System? Das ist eine Betreiber-/Geraetefrage, keine Quelltextfrage. */
  --tabbar-reserve: 8px;
  /* Echte Tabbar-Hoehe (inkl. Reserve) — pflegt app.js per ResizeObserver; der Fallback greift
     nur, bevor die Shell steht (58px Leistenkoerper + Reserve, Rechnung im Backup). */
  --tabbar-h: calc(58px + var(--tabbar-reserve));
  /* Seitliches Grundmaß des Hauptscrollers + die daraus abgeleiteten ECHTEN .content-Polster.
     Sie stehen als Variablen da, damit Views, die das Padding aufheben müssen (Plan-Viewer:
     negative Ränder), exakt DIESELBE Formel spiegeln können statt eine Kopie zu raten. */
  /* [SCHNURMASS] Die Spezifikation wollte hier 12px, um die 20 px der Schnur-Rinne
     zurueckzukaufen. Es BLEIBT bei 14: tests/w14_einstellungen_karten.test.mjs LIEST diese
     Zahl aus der Datei und rechnet die Namensbreite der Downloadliste damit nach — eine
     zweite Deklaration hilft dort nicht, die Probe liest den Wert selbst. Die Probe liegt in
     einem fremden Los. Die Rinne kostet die 20 px deshalb wirklich; bei 320 px bleiben
     320 − 2*14 − 20 = 272 px statt 276. Gemessen traegt die Zeile das. */
  --gutter: 14px;
  --content-pad-left: max(var(--gutter), var(--safe-left));
  --content-pad-right: max(var(--gutter), var(--safe-right));
  /* Unten genau so viel, dass der letzte Inhalt knapp ÜBER der Tabbar steht — der zweite
     max()-Zweig sichert die Zeit, bevor app.js --tabbar-h gemessen hat. */
  --content-pad-bottom: max(calc(var(--tabbar-h) + 10px), calc(58px + var(--tabbar-reserve) + 10px));
  /* ---------------------------------------------------------------- Z-SCHICHTENLEITER (SSOT)
     GENAU EINE Wahrheit darueber, was ueber was liegt. Vorher vergab jede Stelle ihre Zahl
     selbst (40/41/50/52/55/60/60/60/9999) — und ein Sheet auf 41 lag UNTER der Zeichenflaeche
     auf 52/55: „vieles ist ueberdeckt". Wer eine neue Ebene braucht, nimmt eine Stufe von hier
     oder traegt sie HIER ein; nackte z-index-Zahlen >= der untersten Stufe (8) sind verboten
     (tests/deckschicht_tippbarkeit.test.mjs rechnet die Leiter nach). Kleinere Zahlen (1..6)
     sind KOMPONENTEN-LOKAL: sie ordnen Kinder innerhalb einer Ebene, nie Ebenen gegeneinander.
     KERNREGEL: Sheets/Modals schlagen JEDE Vollbild-Ebene (--z-sheet 60 > --z-anno 55) —
     ein Blatt, das aus einem Vollbild heraus oeffnet, muss davor liegen, nicht dahinter. */
  --z-appbar: 20;             /* .appbar, .tabbar — die Huelle */
  --z-composer: 25;           /* .chat-composer ueber der Tabbar */
  --z-fab: 30;                /* .fab ueber Composer und Leisten */
  --z-viewerbar: 8;           /* Layer-/Zoom-/Massstab-/Pagebar im Plan-Viewer */
  --z-viewerhilfe: 9;         /* Pinfinder/Planhilfe — bewusst UEBER den Bedienleisten */
  --z-vollbild: 50;           /* .photo-full, .pano-wrap — Vollbild-Ebenen */
  --z-vollbild-hud: 51;       /* Knoepfe/HUD auf einer Vollbild-Ebene */
  --z-vollbild-werkzeug: 52;  /* .pw (Pin-Arbeitsflaeche), Pano-Zeichenleiste */
  --z-anno: 55;               /* .photo-annot — die Vollbild-Zeichenflaeche */
  --z-sheet-backdrop: 59;     /* Schleier direkt unter dem Blatt */
  --z-sheet: 60;              /* .sheet — schlaegt jede Vollbild-Ebene (Kernregel) */
  --z-modal: 62;              /* .js-modal, .insta-live.full — Dialoge ueber Sheets */
  --z-toast: 64;              /* #toasts — Quittungen ueber allem Bedienbaren */
  --z-calib: 70;              /* .calib-overlay — Kalibrieren blockiert alles darunter */
  --z-kritisch: 100;          /* Sitzungs-Dialog, Retention-Hinweis — darf NIE verdeckt sein */
  color-scheme: light;
}


:root[data-theme="dark"] {
  /* SCHNURMASS dunkel — warmes Graphit statt Marine. DIESER BLOCK STEHT ZWEIMAL, Zeichen
     fuer Zeichen gleich: einmal fuer den Umschalter und einmal fuer den Auslieferungszustand
     „Darstellung: Automatisch". Wer nur einen pflegt, liefert zwei Farbsysteme aus. Er ist
     deshalb bewusst KOMMENTARLOS — jedes Zeichen darin kostet doppelt, und der Vorrat der
     Auslieferung hat einen Gewichtsdeckel (tests/ausl_kachelwelle_precache). Die
     Kontrastrechnungen zu jedem Wert stehen in berichte/GESTALTUNG_SYSTEM.md, Abschnitt 1.
     Die zwei doppelten --row-active-Zeilen sind Absicht: die erste ist von
     tests/layout_master_detail festgenagelt, die zweite gewinnt. */
  --bg: #0A0C0F;
  --fl: #14171B;
  --fl2: #0E1114;
  --fl3: #1B1F24;
  --fl-tap: #1B1F24;
  --line: rgba(242, 241, 237, .11);
  --line2: #6C7176;
  --line3: #F2F1ED;
  --ink: #F2F1ED;
  --ink2: #AEB3B9;
  --ink3: #878A8D;
  --gruen: #4BC429;
  --gruen-ink: #06140A;
  --gruenp: #3AA31F;
  --gruent: #7EDE5D;
  --gruenw: #15250E;
  --warn: #E3A24A;
  --warn-fl: #E3A24A;
  --warn-ink: #E3A24A;
  --warnw: #2A1E0C;
  --danger: #F0857A;
  --dangerw: #2C1210;
  --netz: rgba(242, 241, 237, .05);
  --focus-ring: 0 0 0 2px #0A0C0F, 0 0 0 5px #7EDE5D;
  --w-body: 350;
  --tr-body: .006em;
  --shadow: 0 0 #0000;
  --shadow-lg: 0 0 #0000;
  --shadow-hover: 0 0 #0000;
  --danger-fill: #F0857A;
  --on-danger: #14161A;
  --row-active-bg: #22314f;
  --row-active-bg: #15250E;
  --row-active-ink: #eaf1f9;
  --row-active-ink: #F2F1ED;
  color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
  /* SCHNURMASS dunkel — warmes Graphit statt Marine. DIESER BLOCK STEHT ZWEIMAL, Zeichen
     fuer Zeichen gleich: einmal fuer den Umschalter und einmal fuer den Auslieferungszustand
     „Darstellung: Automatisch". Wer nur einen pflegt, liefert zwei Farbsysteme aus. Er ist
     deshalb bewusst KOMMENTARLOS — jedes Zeichen darin kostet doppelt, und der Vorrat der
     Auslieferung hat einen Gewichtsdeckel (tests/ausl_kachelwelle_precache). Die
     Kontrastrechnungen zu jedem Wert stehen in berichte/GESTALTUNG_SYSTEM.md, Abschnitt 1.
     Die zwei doppelten --row-active-Zeilen sind Absicht: die erste ist von
     tests/layout_master_detail festgenagelt, die zweite gewinnt. */
  --bg: #0A0C0F;
  --fl: #14171B;
  --fl2: #0E1114;
  --fl3: #1B1F24;
  --fl-tap: #1B1F24;
  --line: rgba(242, 241, 237, .11);
  --line2: #6C7176;
  --line3: #F2F1ED;
  --ink: #F2F1ED;
  --ink2: #AEB3B9;
  --ink3: #878A8D;
  --gruen: #4BC429;
  --gruen-ink: #06140A;
  --gruenp: #3AA31F;
  --gruent: #7EDE5D;
  --gruenw: #15250E;
  --warn: #E3A24A;
  --warn-fl: #E3A24A;
  --warn-ink: #E3A24A;
  --warnw: #2A1E0C;
  --danger: #F0857A;
  --dangerw: #2C1210;
  --netz: rgba(242, 241, 237, .05);
  --focus-ring: 0 0 0 2px #0A0C0F, 0 0 0 5px #7EDE5D;
  --w-body: 350;
  --tr-body: .006em;
  --shadow: 0 0 #0000;
  --shadow-lg: 0 0 #0000;
  --shadow-hover: 0 0 #0000;
  --danger-fill: #F0857A;
  --on-danger: #14161A;
  --row-active-bg: #22314f;
  --row-active-bg: #15250E;
  --row-active-ink: #eaf1f9;
  --row-active-ink: #F2F1ED;
  color-scheme: dark;
  }
}

/* ── SONNE — eine HAERTUNG, kein viertes Thema ────────────────────────────────
   Eigenes Attribut data-sicht="sonne" am <html>, orthogonal zu data-theme. Der
   Grund ist messbar und kein Geschmack: ein vierter data-theme-WERT wuerde jeden
   Block :root:not([data-theme="light"]) dieser Datei mittreffen und
   stillschweigend die DUNKLEN Werte erben. */
:root[data-sicht="sonne"] {
  --bg: #FFFFFF;  --fl: #FFFFFF;  --fl2: #FFFFFF;  --fl3: #FFFFFF;
  /* [SKEPT-13 2026-08-24] DER ZWEITE SATZ DES FREMDBEDARFS, NACHGEHOLT.
     --bg wurde hier gehaertet, --bg-wert-hell nicht — und js/theme_boot.js:bgFuerThema liest
     AUSSCHLIESSLICH --bg-wert-hell/--bg-wert-dunkel, um die Statusleisten-Metas zu setzen.
     Ohne diese Zeile stuende in der Sonne #FFFFFF auf der Flaeche und #F2F1EC im Meta: der
     Browserbalken laege sichtbar neben dem Papier. HEUTE LATENT (kein JS im Baum setzt
     data-sicht — 0 Treffer in js/ und app.js), aber es ist genau die Zeile, die der
     vergisst, der spaeter den Sonnen-Schalter baut. Die Zusage „EINE Farbquelle fuer alle
     theme-color-Setzer" bleibt damit wahr. Der dunkle Wert bleibt bewusst unangetastet:
     die Sonne haertet nur die helle Sicht. */
  --bg-wert-hell: #FFFFFF;
  --ink: #000000;                  /* 21,00:1                                 */
  --ink3: var(--ink2);             /* der dritte Rang faellt weg              */
  --line: #5F646B;                 /* deckend, 5,96:1 — keine Haarlinie mehr  */
  --netz: transparent;             /* das Messnetz kostet in der Sonne nur Kontrast */
  --gruenw: #FFFFFF;  --warnw: #FFFFFF;  --dangerw: #FFFFFF;
  --w-body: 450;
}
:root[data-sicht="sonne"] .card,
:root[data-sicht="sonne"] .row,
:root[data-sicht="sonne"] .chip,
:root[data-sicht="sonne"] .badge { border-width: 2px; border-color: var(--line2); }


/* ══════════════════════════════════════════════════════════════════════════════
   DIE SCHRIFTEN AUS DEM HAUS — sechs Dateien, 239.548 B unter pwa_neu/fonts/. Alle drei
   Familien sind VARIABLE FONTS: EINE Datei traegt die ganze Gewichtsachse, deshalb nimmt
   font-weight hier einen BEREICH und keine Zahl (Bereiche aus fonts/_bereiche.txt).
   DER ANLASS: die body-Regel versprach bisher Inter und lieferte Roboto/Segoe UI — die
   Schrift war nie im Haus, nur im Namen.
   Latin-Ext kommt MIT: polnische, tschechische und rumaenische Kolonnennamen sind Tinte,
   keine Zierde, und offline muss die Liste stimmen. Die CSP traegt es unveraendert
   (default-src 'self' deckt font-src ab, gleiche Herkunft).

   font-display: swap — der Text steht sofort da und tauscht die Schrift nach; der
   metrisch angeglichene Rueckfall darunter haelt dabei das Layout ruhig.
   format("woff2") und NICHT format("woff2-variations"): das zweite ist die alte,
   von manchen Engines unbekannte Schreibweise — wer sie nicht kennt, ueberspringt
   die ganze src-Zeile und laedt die Schrift gar nicht.
   ══════════════════════════════════════════════════════════════════════════ */
@font-face {
  font-family: "Saira"; font-style: normal; font-weight: 500 800; font-display: swap;
  src: url(/fonts/saira-latin.woff2) format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Saira"; font-style: normal; font-weight: 500 800; font-display: swap;
  src: url(/fonts/saira-latin-ext.woff2) format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Inter"; font-style: normal; font-weight: 400 700; font-display: swap;
  src: url(/fonts/inter-latin.woff2) format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Inter"; font-style: normal; font-weight: 400 700; font-display: swap;
  src: url(/fonts/inter-latin-ext.woff2) format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "JetBrains Mono"; font-style: normal; font-weight: 400 700; font-display: swap;
  src: url(/fonts/jetbrains-mono-latin.woff2) format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "JetBrains Mono"; font-style: normal; font-weight: 400 700; font-display: swap;
  src: url(/fonts/jetbrains-mono-latin-ext.woff2) format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Der metrisch angeglichene Rueckfall: laedt NICHTS nach (local()), zieht aber Segoe UI
   bzw. Roboto auf Inters Metrik. Ohne ihn springt bei jedem Kaltstart das Layout, sobald
   die echte Datei eintrifft — auf einer Liste mit 40 Zeilen ein sichtbarer Ruck. */
@font-face {
  font-family: "Inter-fallback";
  src: local("Segoe UI"), local("Roboto"), local("Helvetica Neue"), local("Arial");
  size-adjust: 107%; ascent-override: 90%; descent-override: 22.4%; line-gap-override: 0%;
}

* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

/* ------------------------------------------------------------------ Safe-Area-Hilfsklassen
   Für alles, was NICHT in einer eigenen Regel wohnt (per JS gebaute Overlays, Fremdcode-Container).
   Immer additiv gedacht: die Klasse legt das Inset OBEN DRAUF, das Grundpolster bleibt beim Element.
   `.safe-inset` ist die Rundum-Variante für Vollbild-Overlays. */
.safe-top    { padding-top: calc(var(--safe-top) + var(--safe-extra-top, 0px)); }
.safe-bottom { padding-bottom: calc(var(--safe-bottom) + var(--safe-extra-bottom, 0px)); }
.safe-x      { padding-left: var(--safe-left); padding-right: var(--safe-right); }
.safe-inset  { padding: var(--safe-top) var(--safe-right) var(--safe-bottom) var(--safe-left); }
/* Ränder statt Polster (wenn das Element selbst eine Fläche/Hintergrund trägt, die bis an den
   Displayrand laufen SOLL, der Inhalt aber nicht). */
.safe-mb     { margin-bottom: var(--safe-bottom); }

/* ------------------------------------------------------------------ App-Rahmen (fest)
   Die PWA verhält sich wie eine native App: das DOKUMENT scrollt NIE — nur die View-Container
   (.content, .auth-wrap, Sheets) scrollen. Damit kann Runterscrollen weder die Seite neu laden
   (Pull-to-Refresh) noch Appbar/Tabbar verschieben, und window.scrollY bleibt immer 0.
   overscroll-behavior muss auf dem WURZELELEMENT stehen (body propagiert NICHT zum Viewport) —
   das war die Lücke, durch die Chrome/Android weiterhin Pull-to-Refresh auslöste. */
html, body {
  height: 100%;
  overflow: hidden;
  overscroll-behavior: none;
}
/* Wurzel trägt IMMER den Marken-Grund: füllt Überscroll, Safe-Area-Zonen und (im Standalone)
   den Bereich hinter der Statusleiste — so entstehen nie „schwarze Ränder", egal wie groß das
   Display ist oder wie sich die Browser-Leiste ein-/ausblendet. */
html {
  background: var(--bg-grad-bottom);
}
body {
  margin: 0;
  /* [Rueckportierung 2026-08-02, v1.9.60-62] NICHT `fixed`: `inset:0` bei fixed bemisst sich in
     iOS-Safari/installierten PWAs (viewport-fit=cover) am LAYOUT-Viewport und beschnitt die
     untersten ~1 cm („bei iphones unten ist 1 cm abgeschnitten", viermal gemeldet). Stattdessen
     `absolute` und eine ausdrueckliche Hoehe. DIE EINHEIT WAR BIS ZUM 26.08. `lvh` und ist
     seither `svh` — Begruendung im Block [HUELLE-SVH 2026-08-26] direkt darunter; im
     Standalone sind beide dasselbe Mass, dieser Befund hier bleibt also behoben.
     `height:100%` bleibt als Vorgabe fuer Browser ohne Viewport-Einheiten davor stehen. Das Rubber-Banding,
     wegen dem hier urspruenglich `fixed` stand, bleibt aus: html und body tragen weiter
     `overflow:hidden`, gescrollt wird nur in .content. Volltext im Backup pwa_vor_2026-08-02. */
  /* [HUELLE-SVH 2026-08-26] `lvh` -> `svh`: DIE REITERLEISTE IM BROWSER-REITER.
     =========================================================================================
     BETREIBERBEFUND vom 26.08., woertlich: „btw teilweise verschwindet auch die taskbar
     teilweise leider". Das „teilweise" ist der ganze Befund — und es benennt den Unterschied
     zwischen der INSTALLIERTEN App und einem BROWSER-REITER.

     DIE KETTE, drei Zeilen, die einzeln richtig sind und zusammen die Leiste verstecken:
       1. hier          `height: 100lvh` — lvh ist die GROSSE Sichthoehe, also die mit
                        EINGEFAHRENEN Browserleisten.
       2. styles.css    `.tabbar { position: absolute; ... bottom: 0 }` — die Leiste haengt an
                        der Unterkante GENAU DIESES Kastens.
       3. styles.css    `html, body { overflow: hidden }` — das Dokument rollt nie.
     Im STANDALONE gilt lvh = svh = dvh; dort stimmt alles, und deshalb faellt es nicht immer
     auf. Im BROWSER-REITER ist der Kasten um die Hoehe der Werkzeugleiste zu hoch: die
     untersten Pixel liegen dauerhaft darunter, und wegen (3) kann man sie nicht hervorholen.
     Die Leiste ist nur rund 59 px hoch — eine Werkzeugleiste von 44-50 pt verschluckt sie ganz
     oder laesst einen Streifen stehen.

     DAS HAUS KENNT DIESEN MECHANISMUS BEREITS. tests/vollbild_browserleiste.test.mjs zitiert
     im Kopf einen frueheren Befund desselben Betreibers woertlich („in der echten app sind
     diese knoepfe verdeckt von oben und unten taskleiste") und nennt die Hausregel: die
     Dreierkette vh -> svh -> dvh. Die Probe hat damals aber nur `.pano-wrap` und
     `.photo-annot` nachgezogen — die HUELLE stand bis heute allein mit lvh da.

     WARUM svh UND NICHT dvh ALS LETZTES GLIED — das ist die eigentliche Entscheidung:
     `interactive-widget=resizes-content` steht im viewport-Meta (index.html:38). Mit dvh
     schrumpfte die Huelle beim Aufgehen der Tastatur, und die Leiste fuehre auf die
     Tastaturkante hoch. Genau das hat der Betreiber am 05.08. gemeldet („die hotbar geht mit
     hoch"). svh ist die KLEINE Sichthoehe — sie rechnet mit AUSGEFAHRENEN Leisten und
     schrumpft nicht weiter. Die Leiste steht damit in jeder Lage am Bildschirmrand.

     WAS DIESE AENDERUNG NICHT ANTASTET: den Befund, dessentwegen hier ueberhaupt `absolute`
     und eine ausdrueckliche Hoehe stehen („bei iphones unten ist 1 cm abgeschnitten", viermal
     gemeldet, Rueckportierung 2026-08-02). Im Standalone ist svh Zeichen fuer Zeichen dasselbe
     Mass wie lvh — dort aendert sich kein Pixel.

     `bottom` faellt aus dem `inset`: bei einem absolut gesetzten Kasten mit `top`, `bottom` UND
     `height` ist die Angabe ueberbestimmt, und der Browser verwirft eine davon. Lieber
     ausschreiben, was gemeint ist, als sich auf die Verwerfungsregel zu verlassen.
     Zu messen bleibt EINES am Geraet, und es steht in der Abnahme: ob die Leiste bei offener
     Tastatur wirklich unten stehen bleibt. */
  position: absolute;
  top: 0; left: 0; right: 0;
  width: 100%;
  /* [HUELLE-DVH 2026-08-27] DIE KETTE WAR EINEN SCHRITT ZU KURZ — Nachtrag zu [HUELLE-SVH].
     =========================================================================================
     BETREIBERBEFUND vom 27.08., woertlich: „ich habe jetzt am handy wieder die taskleiste
     ueber der abrundung die geht nicht mehr bis ganz runter das hast du geaendert das ging
     vorher".
     ER HAT RECHT, UND ES WAR EINE ZEILE. Der Block darueber tauschte am 26.08. `lvh` gegen
     `svh` und blieb dort stehen. `svh` ist die KLEINE Sichthoehe, also die mit AUSGEFAHRENEN
     Browserleisten. Faehrt die Leiste beim Rollen ein, ist der sichtbare Bereich GROESSER als
     diese Huelle — und weil .tabbar an der Unterkante GENAU DIESER Huelle haengt (:1449),
     bleibt sie oben stehen und laesst unten einen Streifen ueber der Display-Abrundung frei.
     Der 26.08.-Befund („teilweise verschwindet die taskbar") und dieser hier sind DIESELBE
     Zeile aus den beiden entgegengesetzten Richtungen: `lvh` ist immer zu hoch, `svh` immer zu
     niedrig. Nur `dvh` misst, was gerade wirklich sichtbar ist, und traegt beide Faelle.
     DIE HAUSREGEL STAND SCHON IM BAUM und wurde am 26.08. nur nicht zu Ende gelesen:
     tests/vollbild_browserleiste.test.mjs nennt die Dreierkette vh -> svh -> dvh. Zwei andere
     Stellen dieser Datei fahren sie vollstaendig (`.viewer` und zwei Vollbild-Regeln); die
     Huelle war nach dem 26.08. die EINZIGE Stelle im ganzen Stilblatt, die bei svh endete.
     WARUM `dvh` HIER TROTZ `interactive-widget=resizes-content` RICHTIG IST (das war der
     Denkfehler vom 26.08.): `resizes-content` laesst bei geoeffneter Tastatur den Viewport
     WIRKLICH schrumpfen — dass die Huelle das mitmacht, ist genau das Gewollte, und den
     Sonderfall Tastatur fuehrt ohnehin `body.chat-tastatur` eigens (:3858).
     Im Standalone aendert sich nichts: dort gilt svh = lvh = dvh. */
  height: 100%;
  height: 100vh;
  height: 100svh;
  height: 100dvh;
  /* [SCHNURMASS] Diese Zeile versprach Inter und lieferte Roboto — die Schrift lag gar
     nicht im Haus. Jetzt liegt sie unter fonts/ und die Kaskade zeigt auf den Token,
     der sie fuehrt (--f-sans), mit dem metrisch angeglichenen Rueckfall dahinter. */
  font-family: var(--f-sans);
  /* Halations-Ausgleich: helle Schrift auf dunklem Grund laeuft optisch zu. Beide
     Werte kommen aus dem Themenblock (hell 400/0, dunkel 350/.006em). */
  font-weight: var(--w-body);
  letter-spacing: var(--tr-body);
  /* [stil 2026-08-06] Grundtext auf der Textgroessen-Leiter (Spez A9, Begruendung bei --ui-scale).
     Alles, was seine Groesse ERBT statt sie selbst zu setzen (Fliesstext, .muted, .kv, .banner),
     waechst allein ueber diese Zeile mit — deshalb steht sie zuerst. */
  font-size: calc(15px * var(--ui-scale));
  color: var(--text);
  /* Marken-Hintergrund direkt auf dem Body (NICHT background-attachment:fixed — das ließ auf iOS
     beim Ein-/Ausblenden der Toolbar einen ungemalten Streifen stehen). Der Verlauf skaliert
     dynamisch mit der echten Viewport-Höhe. */
  /* Solider Marken-Navy als Grundfläche UNTER dem Verlauf: garantiert, dass nie ein ungemalter
     (schwarzer) Pixel durchscheint — auch nicht in der unteren Safe-Area / hinter dem Home-Indicator. */
  background-color: var(--bg-grad-bottom);
  /* [SCHNURMASS] DER GRUND IST EIN MESSNETZ (Begruendung am Token --netz im :root).
     Der Inhalt scrollt in .content DARUEBER, das Netz steht still wie ein Aufmassblatt
     unter dem Transparentpapier. Der frueher hier stehende Teal-Schimmer
     rgba(10,168,146,.10) ist mit dem Teal entfallen. */
  background-image:
    linear-gradient(to right, var(--netz) 1px, transparent 1px),
    linear-gradient(to bottom, var(--netz) 1px, transparent 1px),
    linear-gradient(180deg, var(--bg-grad-top) 0%, var(--bg-grad-bottom) 100%);
  background-size: 26px 26px, 26px 26px, auto auto;
  -webkit-user-select: none; user-select: none;
}
/* [shell 2026-08-05] MARKIEREN UND KOPIEREN — die Sperre eine Zeile darueber gilt fuer die ganze App.
   GEMESSEN ueber alle vier user-select-Vorkommen dieser Datei: zurueckgenommen wurde sie an GENAU
   EINER Stelle (`.chat-bubble`). Damit war weder eine Massangabe noch ein Pin-Name, eine
   Projektnummer, ein Lizenzschluessel oder ein Fehlertext aus der App herauszubekommen — auf einem
   Telefon ist Abschreiben dann der einzige Weg, und bei einem Lizenzschluessel ist das eine
   Fehlerquelle mit Ansage.
   Die Sperre ist fuer Zieh-, Zeichen- und Planflaechen gedacht: dort verhindert sie die
   Auswahl-Lupe mitten in der Geste. Fuer Fliesstext war sie nie gemeint.
   ZWEI SCHICHTEN, bewusst in dieser Reihenfolge:
   1. Textkoerper duerfen markiert werden. Eine Regel AM Element schlaegt den geerbten Wert —
      deshalb reicht die Aufzaehlung, ohne dass irgendwo `!important` noetig waere.
   2. Die Flaechen, um derentwillen die Sperre ueberhaupt existiert, holen sie sich zurueck.
   Bewusst NICHT in Schicht 1: `.badge` und `.acct-name`. Sie sitzen INNERHALB von Knoepfen
   (Sync-Pille, Kontokarte); markierbarer Text dort holt auf Android beim Langdruck die
   Auswahl-Lupe statt den Knopf.
   `.title` und `.sub` stehen hier BEWUSST mit ihrem Vorfahren und nicht nackt: beide Klassen
   sind in diesem Haus kontextgebunden, und tests/w22_einstellungen_klassen.test.mjs haelt genau
   das fest — eine kontextfreie `.title`-Regel wuerde die Begruendung der Inline-Deklarationen in
   views/settings.js entwerten. `.row` und `.agenda-item` sind bereits Pflicht-Vorfahren beider
   Klassen, die Liste waechst durch diese Zeile also nicht. */
p, h1, h2, h3, h4, li, dd, dt, td, th, code, pre,
.muted, .banner, .toast, .center-msg, .kv .k, .kv .v,
.row .title, .row .sub, .agenda-item .title, .agenda-item .sub {
  -webkit-user-select: text; user-select: text;
}
button, .btn, .chip, .tabbar, .tabbar *, .grab,
.viewer, .viewer *, .pano-wrap, .pano-wrap *, .photo-annot, .photo-annot *,
.calib-overlay, .calib-overlay * {
  -webkit-user-select: none; user-select: none;
}

button, input, select, textarea { font: inherit; color: inherit; }
/* iOS: Text-Inputs < 16 px lösen beim Fokus einen Auto-Zoom aus → hart auf 16 px anheben.
   [stil 2026-08-06] DIESE ZEILE DARF NUR WACHSEN. Ein reines `calc(16px * var(--ui-scale))`
   faellt bei einer kuenftigen Stufe unter 1,0 unter die 16 px zurueck — und dann zoomt iOS beim
   Antippen eines Feldes wieder das ganze Fenster. `max()` haelt den Boden und laesst die Skala
   nach oben durch (Spez A9, Falle 14 aus Kapitel 95). */
input, select, textarea { font-size: max(16px, calc(16px * var(--ui-scale))); }
/* Doppeltipp-Zoom / 300ms-Tap-Delay auf iOS unterdrücken (Canvas/Pano setzen eigenes touch-action). */
/* [W-fluss-22 2026-08-22] ZWEI TIPPZIELE FEHLTEN IN DIESER ZEILE — und ein Befund, der die
   Zeile selbst relativiert.
   GEMESSEN (Edge 390x844, dpr 2, echte Berührungen, CPU-Drossel x4, Org schnur-solutions):
   getComputedStyle über die neun häufigsten Tippziele der App ergab `touch-action: manipulation`
   überall — AUSSER an `.suche` und `.suche > input`, die auf `auto` standen. `.card.tap` (die
   antippbare Karte, styles.css `.card.tap:active { transform: scale(.99) }`) fehlte ebenfalls.
   Beide sind hier ergänzt.
   EHRLICH DAZU, weil die Vermutung „fehlt touch-action, wartet der Browser 300 ms" naheliegt und
   für DIESEN Baum WIDERLEGT ist: die gemessene Eingabeverzögerung lag über alle Wege bei
   26–52 ms unter vierfacher CPU-Drossel (also rund 7–13 ms auf voller Kraft) — nirgends eine
   Spur von 300 ms. Der Grund steht in index.html: das viewport-meta führt `width=device-width`
   samt `maximum-scale=1`, und damit schaltet der Browser den Doppeltipp-Zoom ohnehin ab. Diese
   Regel ist die Sicherung darunter (ältere WebViews, künftige Änderung am meta), kein Zeitgewinn.
   `.suche` bleibt trotzdem richtig: die ganze Fläche ist ein Tippziel, nicht nur das Feld. */
button, a, .chip, .row, .channel-row, .agenda-item, .cal-day, .photo-thumb, label,
.card.tap, .suche, .suche > input { touch-action: manipulation; }
/* WCAG: Link-Teal #0aa892 lag bei 2.6–3.0:1 — dunkler Akzentton als Textfarbe. */
a { color: var(--accent-ink); text-decoration: none; }

/* Sichtbarer Fokus-Ring für Tastatur-/AT-Navigation (Maus/Touch bleiben ringfrei über
   :focus-visible). Der Ring folgt dem eigenen border-radius jedes Elements — bewusst KEIN
   border-radius-Override (das würde runde Chips eckig machen). Desktop-Anmutung + Barrierefreiheit. */
:focus-visible { outline: 2px solid transparent; box-shadow: var(--focus-ring); }
/* [SCHNURMASS] DER RING MUSS HIER NACH INNEN — und das ist gemessen, nicht vermutet.
   Primaerknopf, FAB und Sendeknopf tragen die FASE, also ein clip-path. Ein clip-path
   schneidet die Bemalung des Elements auf seine Form zurueck, und dazu gehoert JEDER
   aussen liegende box-shadow. GEMESSEN (Edge, 420x300, dpr 2, zwei Knoepfe nebeneinander,
   einmal mit dem Ring von var(--focus-ring) und einmal mit dem Ring von hier): der aeussere
   Ring war RESTLOS weg, der innere vollstaendig sichtbar. Ein outline traefe dasselbe
   Schicksal — clip-path schneidet ihn genauso ab.
   Die Zweifarbigkeit bleibt: 2 px Grundfarbe als Freistellung, 3 px Gruen darum. Sie ist der
   Grund, warum der Ring auch AUF der gruenen Flaeche noch zu sehen ist; der einfarbige
   verschwand dort. Wer die Fase von einem dieser drei Knoepfe nimmt, nimmt hier den Ring
   wieder auf var(--focus-ring) zurueck. */
.btn:focus-visible,
.fab:focus-visible,
.chat-composer .send:focus-visible { box-shadow: inset 0 0 0 2px var(--fl), inset 0 0 0 5px var(--gruent); }

/* ---------------------------------------------------------------- app shell */
/* #app ist nur der volle Rahmen; die eigentliche Spalte ist .shell-root (von app.js gebaut).
   Auf schmalen Screens vollflächig, auf breiten mittig als „Gerät". Beide sind exakt
   viewport-hoch und scrollen selbst NICHT — gescrollt wird nur im .content. */
#app { height: 100%; overflow: hidden; }
.shell-root {
  display: flex; flex-direction: column;
  height: 100%;
  min-height: 0;
  overflow: hidden;
  width: 100%; margin-inline: auto;
  position: relative;
}
/* Große Tablets (iPad quer) / Desktop-PWA: die App-Spalte darf etwas breiter werden, damit die App
   auf großen Schirmen nicht wie eine schmale Handy-Säule mittig „schwebt". Tabbar/Composer/FAB folgen
   automatisch (sie tracken --shell-max). Handys + kleine Tablets bleiben bei 720px unverändert. */
@media (min-width: 1000px) {
  :root { --shell-max: 820px; }
  /* -------------------------------------------------- iPad-Master-Detail (A6, [layout 2026-08-05])
     app.js:renderView baut fuer die Detail-Routen (Projekt/Plan, 360) den .content zweispaltig:
     .md-master (Projektliste) | .md-detail (View). Nur dann traegt .content die Klasse .md-split —
     die Einzel-Liste (Route „projects") bleibt die schmale 820er-Saeule.
     :has statt einer JS-gesetzten Wurzelklasse, weil --shell-max von Tabbar/FAB/Composer ueber
     var() MITGELESEN wird und die Verbreiterung exakt mit der zweiten Spalte kommen und gehen
     muss; ohne :has-Unterstuetzung bleibt es bei 820 px — zwei schmale Spalten, nichts bricht. */
  :root:has(.content.md-split) { --shell-max: 1180px; }
  .content.md-split {
    display: flex;
    align-items: stretch;
    /* .content ist sonst DER Scroller der App — im Split scrollen die SPALTEN, sonst schoebe
       eine lange Projektliste den Plan rechts mit aus dem Bild. */
    overflow-y: hidden;
    width: 100%;
    padding: 0;
  }
  .content.md-split > .md-master {
    flex: 0 0 clamp(320px, 30%, 360px);
    min-width: 0;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    overscroll-behavior: contain;
    border-right: 1px solid var(--border);
    /* Polster wie .content (gleiche Formeln, s. :root) — links traegt die Spalte den Aussenrand,
       rechts genuegt Luft zur Trennlinie. Unten weiter ueber der Tabbar enden. */
    padding: 14px 12px var(--content-pad-bottom) var(--content-pad-left);
  }
  .content.md-split > .md-detail {
    flex: 1 1 auto;
    min-width: 0;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    overscroll-behavior: contain;
    padding: 14px var(--content-pad-right) var(--content-pad-bottom) 12px;
  }
  /* Die Zeile des rechts geoeffneten Projekts (Tokens + Begruendung im :root-Block). Der Rahmen
     bleibt sichtbar stehen. Die Unterzeile (.row .sub) traegt var(--text-muted) — statt einer
     .sub-Descendant-Regel wird das TOKEN hier lokal umgebogen: .title/.sub bleiben so exakt an
     ihre bisherigen Pflicht-Vorfahren gebunden (tests/w22_einstellungen_klassen.test.mjs friert
     die Vorfahrenliste ein; eine neue .sub-Regel hier war beim Bau tatsaechlich dessen Rotfall). */
  .content.md-split .row.md-aktiv {
    background: var(--row-active-bg);
    color: var(--row-active-ink);
    border-color: var(--border-strong);
    --text-muted: var(--row-active-ink);
  }
}

/* Breite Screens (Tablet, Foldable aufgeklappt, Desktop-PWA): App mittig in angenehmer Breite,
   die Fläche außen trägt den Marken-Grund + eine seitliche Hairline und eine weiche Kante —
   edel gefüllt statt schwarzer Balken. Appbar/Content sitzen automatisch in der Spalte, weil sie
   Kinder von .shell-root sind; Tabbar/Composer/FAB werden separat auf --shell-max ausgerichtet. */
@media (min-width: 760px) {
  .shell-root {
    max-width: var(--shell-max);
    background:
      linear-gradient(180deg, rgba(255,255,255,0.02), transparent 120px),
      var(--bg-grad-bottom);
    box-shadow: 0 0 0 1px var(--border), var(--shadow-lg);
  }
}
.appbar {
  position: relative; z-index: var(--z-appbar);
  flex: none;
  display: flex; align-items: center; gap: 12px;
  min-width: 0; flex-wrap: nowrap;
  padding: calc(10px + var(--safe-top)) max(16px, var(--safe-right)) 11px max(16px, var(--safe-left));
  /* PERF-Invariante: KEIN backdrop-filter — die Appbar sitzt als eigene Zeile ÜBER dem
     Scroll-Container (nichts schimmert durch) und ein Blur müsste beim Scrollen jeden Frame
     neu gerechnet werden. Volldeckende Fläche mit dezentem Verlauf stattdessen. */
  /* [SCHNURMASS] Eine FLAECHE, kein Verlauf, und kein Schatten: getrennt wird durch
     Kante. Der Verlauf machte aus einer 54-px-Leiste zwei Toene. */
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  box-shadow: var(--shadow);
}
/* „Die Schnur" wohnt seit SCHNURMASS in der LISTE (Linie am linken Rand, ein Knoten je
   Zeile) und nicht mehr als Zierstrich unter der Appbar. Zwei Schnuere auf einem
   Bildschirm waeren zwei Signaturen. Die Regel bleibt als Grabstein stehen, damit der
   naechste Leser die 2-px-Teal-Linie nicht wiedererfindet; die Trennung traegt jetzt
   allein die 1-px-Kante der Appbar. */
.appbar::after { content: none; }
/* [WEBSITE-ANGLEICH 20.08.2026] gap war 10 px und ist 8 px — zwei der vier Pixel, die der
   Wortmarke bei 390 px fehlten (die ganze Rechnung steht unten bei `.wortmarke`). Acht
   Pixel sind auch das richtigere Mass: Bildzeichen und Wortmarke sind EIN Zeichen, kein
   Paar; das Logo der Website setzt sie ebenfalls dichter, als die Leiste sie bisher hielt. */
.appbar .brand { display: flex; align-items: center; gap: 8px; font-weight: 800; font-size: calc(17px * var(--ui-scale)); letter-spacing: .2px; min-width: 0; flex: 0 1 auto; }
/* ── DAS BILDZEICHEN TRAEGT DAS MARKENGRAU — auch nachts ────────────────────────────────
   [WEBSITE-ANGLEICH 20.08.2026] Der Schnurzug (js/icons.js:schnurzug) malt seine mittleren
   Segmente und die zwei kleinen Knoten mit `var(--line2, #878A8D)`. Im hellen Thema ist
   --line2 das Markengrau #878A8D und alles stimmt. Im DUNKLEN Thema ist --line2 aber
   #6C7176 — ein Wert, der die Bedienteil-Kante im Dunkeln beruhigen soll und in KEINER
   Logodatei vorkommt. GEMESSEN am gerenderten Baum (Rig 8911, dunkel):
     rgb(108, 113, 118) = #6C7176 an drei von neun Zugteilen.
   Die Logodatei der Website kennt genau drei Farben, und die Nachtfassung
   (assets/brand/schnur-solutions-logo-on-dark.svg) tauscht NUR die Tinte:
     #4bc429 Gruen · #878a8d Grau · #010000 -> #fefdfe.
   Das Grau bleibt dort also unveraendert — und deshalb bleibt es hier auch. Der Wert wird
   NUR am Bildzeichen gesetzt, nicht am Token: --line2 hat als Bedienteil-Kante eine andere
   Aufgabe, und ein Logo ist kein Bedienteil.
   GERECHNET, damit es nicht bloss huebscher, sondern auch besser ist (WCAG 1.4.11 verlangt
   3:1 fuer Nicht-Text; ein Bildzeichen ist Nicht-Text):
     heute #6C7176 auf der dunklen Leiste #14171B = 3,65:1
     neu   #878A8D auf derselben Leiste          = 5,18:1
   Das Gruen und die Tinte holt der Zug weiter aus --gruen und --ink; beide sind in beiden
   Themen richtig. --ink ist im Dunkeln #F2F1ED und nicht das #fefdfe der Logodatei — das
   ist Absicht und bleibt: das warme Weiss ist die abgenommene Hausfarbe, und ein zweites,
   kaelteres Weiss allein fuer neun Vektorteile waere eine zweite Wahrheit fuer dieselbe
   Rolle. Der Unterschied betraegt gemessen 4 von 255 je Kanal. */
.appbar .brand svg { --line2: #878A8D; }
/* Die Sync-Pille ist die wichtigste Statusinfo: nie abschneiden (flex:none), dafür kippt ihr
   Label auf schmalen Displays auf die Kurzform (JS liefert .lbl-full + .lbl-short). */
.appbar .syncpill { flex: none; }
/* [VERBINDUNG 2026-08-20] Sie steht jetzt GANZ LINKS, vor der Wortmarke (Begruendung in app.js).
   Etwas weniger Abstand zur Marke als der 12-px-Grundabstand: die beiden gehoeren zusammen —
   „welche App" und „ist sie verbunden" liest man in einem Blick. Der Rest der Leiste behaelt
   seinen Abstand ueber die margin-left der folgenden Gruppe. */
.appbar > .syncpill:first-child + .brand { margin-left: -4px; }
.syncpill .lbl-short { display: none; }
/* Konto-Chip darf zuerst schrumpfen (Name ellipst, Badge bleibt). */
.appbar .acct-badge { flex: 0 1 auto; min-width: 0; }
.acct-badge .lic-short { display: none; }
/* GRABSTEIN + BEREITSCHAFT: hier stand „Radar-App-Icon als Marken-Anker: eigenes Objekt mit
   weichem Ring + Teal-Glow" — beides ist weg (das Radar-Icon seit dem 03.08., das Teal seit
   SCHNURMASS). Die Regel hat HEUTE keinen Erzeuger: die Kopfleiste malt seit dem 19.08. einen
   Vektor und kein <img>.
   [MARKE L13-01 2026-08-25] Sie bleibt trotzdem stehen, und zwar mit Absicht: sobald jemand
   der Kopfleiste doch ein Bild gibt, greift sie sofort und gibt ihm Rahmen, Schatten und
   Radius — also die Geometrie eines BEDIENTEILS um ein LOGO. Wer das tut, muss diese Regel
   im selben Zug pruefen. Die Marke der Kopfleiste ist bewusst inline (js/icons.js:
   schnurplanMarke): nur so faerbt sie ueber die Token-Leiter mit, und nur so kostet sie
   keinen zweiten Netzabruf und keinen Platz im Shell-Vorrat. */
.appbar .brand img {
  width: 32px; height: 32px; border-radius: var(--radius); flex: none;
  border: 1px solid var(--border);
  box-shadow: var(--shadow);
}
.appbar .brand span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.appbar .brand b { color: var(--accent-ink); font-weight: 900; }
.appbar .spacer { flex: 1; }
/* Schmale Displays (320–430 px): Prioritäten-Layout der Titelzeile — NICHTS wird abgeschnitten.
   1) Konto-Name weicht (Badge bleibt), 2) Lizenz-Badge + Sync-Pille kippen auf ihre Kurzform,
   3) Abstände kompakter. Der Marken-Schriftzug ellipst als allerletzte Reserve (min-width:0). */
@media (max-width: 430px) {
  .appbar { gap: 8px; padding-right: max(12px, var(--safe-right)); padding-left: max(12px, var(--safe-left)); }
  .appbar .acct-badge .acct-name { display: none; }
  .acct-badge .lic-full { display: none; }
  .acct-badge .lic-short { display: inline; }
  .syncpill .lbl-full { display: none; }
  .syncpill .lbl-short { display: inline; }
}
/* Bis 407 px: hier stand bis zur Welle Q2D `.syncpill .lbl-short { display: none }` — die Pille
   war unter 408 px SYMBOL UND FARBE, sonst nichts. Das verstößt gegen Markenhandbuch §7 und
   Bewegungsvertrag §7 („Zustand = Wort + Farbe, IMMER beides"), und zwar genau auf den drei
   häufigsten iPhone-Breiten 320/360/390 (GEMESSEN: Pille 40x28, kein Wort).
   Der Grund für die alte Regel war real: sonst ellipste der Marken-Schriftzug „SchnurPlan"
   (GEMESSEN 381–407 px: scrollWidth 96 gegen clientWidth 77–95).
   AUFGELÖST wird der Konflikt über das Markenhandbuch §9 selbst: „Unter dem Mindestmaß fällt die
   Produktzeile weg, dann die Wortmarke — das BILDZEICHEN BLEIBT." Also weicht die Wortmarke, nicht
   der Zustand. Das Bildzeichen (28 px, Mindestmaß Bildschirm 24 px) trägt die Marke weiter.
   Ein halb abgeschnittenes „SchnurPl…" wäre ohnehin die schlechtere Markenführung. */
@media (max-width: 407px) {
  .appbar .brand span { display: none; }
  .syncpill { padding-inline: 9px; }
  /* Die Kurzform ist jetzt der letzte Rückfall und darf nie ganz verschwinden. Sie ellipst
     lieber, als zu fehlen — ein angefangenes Wort ist mehr als kein Wort. */
  .syncpill .lbl-short { display: inline; min-width: 0; }
}
/* ═══════════════════ [SICHTPRUEFUNG 19.08.2026] DIE WORTMARKE UND DIE REITERSCHRIFT
   Das Los „schale" hat beides im Markup fertig gebaut — app.js:1628 setzt
   `<span class="wortmarke disp">SCHNURPLAN</span>`, app.js:2528 setzt `<span class="tl">` um
   jede Reiterbeschriftung — und die zugehoerigen Regeln als Fremdbedarf an dieses Blatt
   ausgeschrieben. Hier standen sie bis heute NICHT. GEMESSEN am Rig (8911, echte App):
     · `.wortmarke` erbte nur `.appbar .brand span`, wurde also unter 408 px ausgeblendet und
       trug daruber Inter statt Saira. Auf 320 und 390 px stand in der Titelzeile allein das
       Bildzeichen — die Marke fehlte auf genau den drei haeufigsten Telefonbreiten.
     · `.tl` hatte GAR KEINE Regel. Die Beschriftungen liefen in Inter 10,5 px (390) bzw.
       9,5 px (320). Bei 320 px gemessen: „Anwesenheit" scrollWidth 59 gegen clientWidth 58
       und „Einstellungen" 61 gegen 60 — BEIDE mit Ellipse abgeschnitten („Anwesenh…",
       „Einstellung…"), belegt in shots/schnurmass/reiter-320-hell.png.
   Saira ist schmaler als Inter: dieselben sechs Woerter messen in Saira 9,5 px zusammen
   245 px statt 250 px, und die beiden langen passen damit genau in ihre Spalte. Der
   Schriftwechsel loest das Abschneiden also, OHNE ein Wort zu kuerzen — was der Auftrag
   ausdruecklich verbietet („REITERBESCHRIFTUNGEN BLEIBEN WIE SIE WAREN").
   ZWEI FALLEN AN DIESER STELLE, beide aus fremden Proben:
   (a) tests/stil_textskala_und_blase haelt fest, dass die Verdichtungsstufen der Reiterleiste
       NICHT mit der Textskala wachsen duerfen — deshalb hier die reine calc()-Form OHNE max().
       Jede min-height dagegen MUSS die max()-Form tragen; die steht unveraendert bei
       `.tabbar button`.
   (b) tests/safearea_contract nagelt Polster und Safe-Area der Leiste fest — hier wird nur
       Schrift gesetzt, nichts an der Geometrie. */
/* [WEBSITE-ANGLEICH 20.08.2026] DIE WORTMARKE WURDE ABGESCHNITTEN — auf jeder gaengigen
   Telefonbreite, seit sie eingebaut wurde. GEMESSEN am Rig (8911, echte App, Sync-Pille im
   ruhigen Zustand „Server verbunden", `scrollWidth` gegen `clientWidth` an `.wortmarke`):
     360 px  braucht 117, bekommt  93   →  „SCHNURP…"
     375 px  braucht 117, bekommt 103   →  „SCHNURPL…"
     390 px  braucht 117, bekommt 113   →  „SCHNURPLA…"
     412 px  braucht 117, bekommt 117   →  passt
   Der Absatz weiter oben rechnete mit „118 px, macht 158 von 390" und kam deshalb zu dem
   Schluss, es passe. Die Rechnung liess den Konto-Chip und das Polster der Leiste weg; der
   Kasten, den `.brand` der Marke wirklich uebrig laesst, ist bei 390 px nur 113 px breit
   (`.brand` 147 − 24 Bildzeichen − 10 Abstand). Eine gerechnete Breite ist keine gemessene.
   WARUM DAS EIN MARKENFEHLER IST UND KEIN SCHOENHEITSFEHLER: Markenhandbuch §9 kennt genau
   zwei Zustaende — die Wortmarke steht ganz, oder sie faellt und das Bildzeichen bleibt.
   „SCHNURPLA…" ist keiner von beiden. Eine halbe Wortmarke ist schlechter als keine.
   DIE ZWEI HANDGRIFFE, beide gemessen (Kandidaten A–E, Sonde wortmarke_probe.mjs):
   (1) DIE SCHLEPPWEITE ZURUECKHOLEN. `letter-spacing` setzt den Abstand auch HINTER den
       letzten Buchstaben — bei zehn Zeichen ist das eine volle Sperrbreite toter Rand am
       rechten Ende. `margin-inline-end: -.11em` nimmt sie zurueck. Kostet nichts, denn
       dort steht nichts.
   (2) DIE SPERRUNG VON .13em AUF .11em. Bei 15 px sind das 1,95 px gegen 1,65 px je
       Zeichen; ueber zehn Zeichen 3 px. Die Marke bleibt sichtbar gesperrt — eine
       Wortmarke ist sie ab etwa .08em, darunter wird sie Fliesstext.
   Dazu faellt der Abstand `.brand` von 10 auf 8 px (unten, bei `.appbar .brand`).
   NACHHER GEMESSEN: 390 px braucht 114, bekommt 114 — es passt, aber auf den Pixel genau.
   Das ist ehrlich zu wissen: wer der Titelzeile ein weiteres Element gibt, schneidet die
   Marke wieder an. 360 und 375 px sind mit KEINEM Handgriff zu retten (dort fehlen 21 px);
   deshalb faellt die Marke unterhalb 390 px, siehe die Medienregel weiter unten. */
.appbar .brand .wortmarke {
  font-family: var(--f-display);
  /* [SCHRIFT-4 2026-08-24] FEST statt skaliert: die Marke ist Bild, nicht Text (Markenhand-
     buch-Logik der Medienregel unten). Mit calc(15px*--ui-scale) wurde sie bei Stufe 1,3
     auf jeder Breite zu "SCHNURP..." geschnitten (scrollWidth 14 px ueber clientWidth). */
  font-size: 15px;
  font-weight: 800; letter-spacing: .11em; line-height: 1;
  margin-inline-end: -.11em;   /* die Sperrung hinter dem letzten Buchstaben ist toter Rand */
  color: var(--ink);
  /* [BED-7 2026-08-24] DIE GELADENE FALLE: line-height 1 auf einer Display-Schrift, dazu das
     `overflow: hidden` aus `.appbar .brand span` (Zeile 1073). GEMESSEN mit
     Range.getBoundingClientRect (Sonde _skept2408_bedienung_91_schnitt.mjs): Kasten
     114,4x15, TEXTkasten 114,4x24 — NEUN PIXEL Zeilenbox liegen ausserhalb und werden
     abgeschnitten, bei Stufe 1,0 wie bei 1,3 (die Marke skaliert bewusst nicht).
     Heute faellt es nicht auf, weil „SCHNURPLAN" reine Versalien ohne Unterlaengen sind.
     Es faellt in dem Augenblick auf, in dem hier ein Wort mit Unterlaenge oder ein
     diakritisches Zeichen steht — dann ist die Marke oben oder unten angesaegt.
     WARUM NICHT line-height ANHEBEN: die Marke passt bei 390 px auf den Pixel genau
     (114 gebraucht, 114 bekommen, siehe Rechnung darueber); eine hoehere Zeile aendert die
     Hoehe der Titelzeile mit und muesste per negativem margin zurueckgenommen werden — zwei
     Zahlen statt einer. Stattdessen wird der Beschnitt auf die WAAGERECHTE begrenzt, denn
     nur dort ist er gewollt (er traegt die Ellipse aus derselben Regel).
     `overflow-x: clip` STATT `hidden`: die Paarung clip/visible ist die EINZIGE, die die
     Kaskade so stehen laesst, wie sie geschrieben ist — bei `hidden`/`visible` rechnet der
     Browser das `visible` in `auto` um und macht aus der Marke einen Scrollbehaelter.
     `text-overflow: ellipsis` wirkt mit `clip` genauso (Beschnitt ist nicht `visible`). */
  overflow-x: clip; overflow-y: visible;
}
/* [TEXTSKALA-DECKEL 2026-08-21] DIE REITERSCHRIFT WAECHST MIT — ABER NUR SO WEIT, WIE SIE PASST.
   BETREIBER-MELDUNG: „grössenskala macht den text zu … teilweise wörter abgeschnitten".
   NACHGESTELLT UND GEMESSEN (Rig, sechs Reiter, Textgroesse „Sehr gross" = --ui-scale 1.3,
   390 px breit, mit Neuladen): Schriftgrad 12,35 px, Leiste randvoll mit 338 von 338 px, und
   VIER von sechs Woertern beschnitten — „Projekte" −4 px, „Anwesenheit" −6 px, „Kalender" −4 px,
   „Einstellungen" −6 px.

   WARUM DAS KEIN VERTEILUNGSPROBLEM IST. Nachgerechnet auf den gemessenen Wortbreiten
   (bei 9,5 px: Projekte 37 · Anwesenheit 57 · To-dos 29 · Kalender 39 · Chat 20 ·
   Einstellungen 60), mit flachem 44-px-Boden je Knopf und 1 px Polster:
     Breite   verfuegbar   groesster passender Schriftgrad
      320 px     304 px     9,5 px   (schon bei Skala 1,0 randvoll — Bestand, siehe 320-px-Block)
      360 px     344 px    12,0 px
      390 px     338 px    11,7 px
      393 px     341 px    11,8 px
      430 px     378 px    13,6 px
   Es gibt also KEINE Verteilung, die bei Skala 1,3 auf einem 390er passt: 12,35 px braucht
   350,9 px. Der Platz ist nicht falsch aufgeteilt, er ist nicht da.

   VERWORFENE WEGE, jeder mit Grund:
     · Zweizeilig — im 320-px-Block darunter GEMESSEN verworfen: dieses Chromium trennt ohne
       deutsches Trennmuster nicht, `overflow-wrap: anywhere` erzeugte „Anwesenh/eit", und die
       Leiste wuchs auf 79 px.
     · Symbolfeld schrumpfen — bringt nur die 1,6 px, die „To-dos" und „Chat" ueber ihrem
       Boden liegen; gebraucht werden 20.
     · Trefferflaeche in der BREITE mitwachsen lassen — macht es schlimmer: sechs Knoepfe zu
       57,2 px sind 343 px und damit allein schon breiter als die Leiste.
     · Kuerzere Woerter („Zeiten", „Optionen") — waere die saubere Loesung, ist aber eine
       Namensentscheidung des Betreibers und aendert das Vokabular in vier Sprachen.

   WAS BLEIBT: die Schrift waechst mit der Skala bis zu dem Grad, der noch GANZE Woerter
   traegt, und hoert dort auf. Alles andere skaliert unveraendert weiter — die Trefferflaeche
   in der HOEHE (44 -> 57,2 px), die Symbole, und jeder Text ausserhalb dieser Leiste.
   Der Deckel ist GESTAFFELT, weil der Platz es ist:
     bis 340 px  ->  kein Wachstum (die Leiste ist dort schon bei Skala 1,0 randvoll)
     341..429 px ->  11 px
     ab 430 px   ->  13 px
   Der mittlere Wert ist der wichtige. Die Rechnung oben gab 11,7 bzw. 11,8 px her,
   GEMESSEN blieben bei 11,5 px aber noch Woerter um je 1 px beschnitten (390 px: drei; 393 px:
   zwei) — das Modell rechnete das Knopfpolster zu guenstig. Der Deckel steht deshalb auf dem
   GEMESSENEN Wert, nicht auf dem gerechneten. NACHHER gemessen bei 390 px und Skala 1,3:
   11 px Schrift, 336,8 von 338 px belegt, NULL Kuerzungen, Knopfhoehe unveraendert 57,2 px. Ab 430 px ist Platz fuer mehr, dort steht er bei 13 px
   (gemessen passt 13,6 — 13 laesst Luft fuer eine laengere Uebersetzung).
   Die Ellipse eine Regel weiter unten bleibt als letzter Riegel stehen; sie soll nie greifen. */
.tabbar .tl {
  font-family: var(--f-display);
  /* Bis 340 px WAECHST SIE GAR NICHT. Das ist keine Schikane, sondern der Bestand: der
     320-px-Block weiter unten hat schon fuer Skala 1,0 gemessen, dass die sechs Woerter
     301 von 304 px belegen. Dort ist kein Pixel fuer Wachstum da, und 11 px schnitten
     nachgemessen „Anwesenheit" um 11 px und „Einstellungen" um 14 px ab. */
  font-size: min(calc(9.5px * var(--ui-scale)), 9.5px);
  /* [SCHRIFT-10 2026-08-24] line-height 1.05 statt fester 10px (gemessen lh 10 < fs 11 bei
     Stufe 1,3); der 391er-Deckel steigt 11 -> 13 px: bei Stufe 1 bleibt alles wie es war
     (10,5 px), erst die Textskala darf bis 13 px wachsen — nachgemessen mit
     schnur-rig/sonden/_leitung2408_schrift_probe.mjs (kein Reiterwort beschnitten). */
  line-height: 1.05; font-weight: 600; letter-spacing: -.008em; white-space: nowrap;
}
@media (min-width: 341px) { .tabbar .tl { font-size: min(calc(9.5px * var(--ui-scale)), 11px); } }
@media (min-width: 391px) { .tabbar .tl { font-size: min(calc(10.5px * var(--ui-scale)), 13px); } }
@media (min-width: 430px) { .tabbar .tl { font-size: min(calc(10.5px * var(--ui-scale)), 13px); } }
/* Die Ruecknahme der Verdeckung — die Regel
   `@media (max-width: 407px) { .appbar .brand span { display: none } }` daruber blendet die
   Marke unter 408 px aus; hier wird sie fuer das Band zurueckgeholt, in dem sie WIRKLICH
   passt. Markenhandbuch §9: „unter dem Mindestmass faellt die Wortmarke, das BILDZEICHEN
   BLEIBT."
   [WEBSITE-ANGLEICH 20.08.2026] DIE UNTERGRENZE WAR 360 px UND WAR FALSCH. Sie stammte aus
   einer gerechneten Breite („118 px, macht 158 von 390"), die den Konto-Chip vergass. Am Rig
   nachgemessen bekommt die Marke bei 360 px nur 93 px und bei 375 px nur 103 px — sie
   braucht 114. Das Ergebnis war nicht „passt knapp", sondern „SCHNURP…" auf zwei der drei
   haeufigsten Telefonbreiten. Die Grenze steht jetzt dort, wo die Messung sie hinsetzt:
   ab 390 px passt sie (114 von 114), darunter faellt sie — und das Bildzeichen traegt die
   Marke allein, wie §9 es vorsieht. Neben ihm stehen dort ohnehin Lizenzplakette und
   Sync-Pille, die Titelzeile ist also nicht leer.
   WER DIESE ZAHL AENDERN WILL, misst vorher: schnur-rig/wortmarke_probe.mjs faehrt die
   Breiten 360/375/390/412/430 ab und sagt fuer jede, ob geschnitten wird. */
@media (min-width: 390px) and (max-width: 407px) {
  .appbar .brand .wortmarke { display: inline; }
}
/* … UND DIE GEGENPROBE DAZU, die der erste Anlauf gebraucht hat.
   GESEHEN auf shots/schnurmass2/projects-390-hell.png: mit sichtbarer Wortmarke stand in der
   Leiste „SCHNURPL…" — abgeschnitten. Der Grund war nicht die Marke, sondern die Sync-Pille
   daneben: im RUHIGEN Zustand heisst ihre Kurzform „Server" (86 px), im ALARM-Zustand
   „Zugang gesperrt" (rund 200 px), und dafuer ist bei 390 px kein Platz mehr:
     30 Bildzeichen + 10 + 118 Wortmarke + 8 + 60 Plakette + 8 + 200 Pille = 434 von 390.
   Markenhandbuch §9 sagt fuer genau diesen Fall, WER weicht: „unter dem Mindestmass faellt
   die Wortmarke — das BILDZEICHEN BLEIBT." Und §7 sagt, wer NICHT weichen darf: „Zustand =
   Wort + Farbe, IMMER beides"; eine halbe Zustandsmeldung waere schlimmer als eine fehlende
   Wortmarke. Die Pille traegt ihre Zustaende als data-state, also laesst sich genau das
   ausdruecken — die drei LAUTEN Zustaende nehmen der Marke den Platz weg, die ruhigen nicht.
   RUECKFALL: eine Engine ohne :has() ignoriert den ganzen Block und zeigt wieder die
   ellipsierte Marke — also das Bild von vorher, kein neuer Schaden. */
@media (max-width: 430px) {
  .appbar:has(.syncpill[data-state="error"]) .brand .wortmarke,
  .appbar:has(.syncpill[data-state="offline"]) .brand .wortmarke,
  .appbar:has(.syncpill[data-state="waiting"]) .brand .wortmarke { display: none; }
}

/* Sehr schmal (320er-Klasse): Brand rückt enger zusammen (Icon minimal kleiner), damit
   „SchnurPlan" auch neben DEMO- und Lizenz-Badge komplett ausgeschrieben bleibt. */
@media (max-width: 350px) {
  .appbar { gap: 6px; }
  .appbar .brand { gap: 6px; font-size: calc(16px * var(--ui-scale)); }
  .appbar .brand img { width: 28px; height: 28px; border-radius: var(--radius); }
}
/* Ikonischer Knopf — GENERISCH, nicht nur in der Appbar.
   BEFUND „klein" (recon2/04): Es gab die Regel nur unter `.appbar`; der „Aktualisieren"-Knopf der
   Projektliste (projects.js, class="iconbtn" ausserhalb der Appbar) fiel damit auf die Browser-
   Vorgabe zurück und mass GEMESSEN 36x30 px — die kleinste Trefferfläche der ganzen App. Dieselbe
   Klasse tragen jetzt auch der „Zurück"-Knopf im Plan, das Schliessen-Kreuz im Pin-Detail und der
   Schliessen-Knopf des Pin-Finders. Die spezifischere `.appbar`-Regel unten gewinnt weiterhin. */
.iconbtn {
  width: 44px; height: 44px; border-radius: var(--radius); border: 1px solid var(--border);
  background: var(--surface); color: var(--text); display: grid; place-items: center;
  font-size: 18px; cursor: pointer; padding: 0; flex: none;
  transition: transform var(--t-tap) var(--ease), border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.iconbtn:active { transform: scale(.95); }
/* [QUITTUNG 2026-08-06] Ein laufender Knopf MUSS es zeigen. BEFUND aus der Bedienbarkeitsprobe:
   „Aktualisieren" in der Projektliste stiess die Org-Runde an, aenderte bei unveraendertem
   Bestand aber kein Pixel — der Knopf war nicht tot, sah aber genau so aus, und getippt wurde
   erneut. Der Kreisel laeuft waehrend des Laufs; ein Toast sagt danach das Ergebnis.
   `aria-busy` statt einer eigenen Klasse: dieselbe Aussage fuer Auge und Screenreader. */
.iconbtn[aria-busy="true"], .btn[aria-busy="true"] { opacity: .72; cursor: default; }
.iconbtn[aria-busy="true"] .ic { animation: ic-spin var(--t-pulse) linear infinite; }
@media (prefers-reduced-motion: reduce) {
  /* Kein Drehen — die Abblendung allein traegt die Aussage „laeuft gerade". */
  .iconbtn[aria-busy="true"] .ic { animation: none; }
}
.appbar .iconbtn {
  width: 44px; height: 44px; border-radius: var(--radius); border: 1px solid var(--border);
  background: var(--surface); display: grid; place-items: center; font-size: 18px; cursor: pointer;
  transition: transform var(--t-tap) var(--ease), border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.appbar .iconbtn:active { transform: scale(.95); }

/* DER Scroll-Container der App: nur hier wird gescrollt (Dokument bleibt fest bei scrollY 0).
   overscroll-behavior:contain → am Listenende kein Weiterreichen an die Seite (kein
   Pull-to-Refresh, kein Verschieben von Appbar/Tabbar). Momentum-Scrolling für iOS. */
.content {
  flex: 1; min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  overflow-x: clip;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  width: min(920px, 100%); margin: 0 auto;
  /* Unten genau so viel Platz, dass der letzte Inhalt knapp ÜBER der Tabbar steht — NICHT mehr.
     --tabbar-h (JS, border-box) enthält bereits die Safe-Area; wir addieren nur 10px Luft. Der
     zweite max()-Zweig ist die Sicherung, falls --tabbar-h kurz zu klein gemessen ist (dann greift
     die sichtbare Leistenhöhe 57px + Safe-Area + 10px). Frühere +28px reservierten zu viel → auf
     iPhone 16 blieb ~2 cm Leerraum unter der Liste (Betreiber-Meldung); jetzt konvergieren beide
     Zweige auf ~Tabbar + 10px. Die Formel steht als --content-pad-bottom im :root, damit der
     Plan-Viewer sie negativ SPIEGELN kann statt sie zu raten.
     Seitlich: 14px Grundmaß, im Querformat auf Geräten mit seitlicher Kerbe schiebt
     max(..., --safe-left/right) den Text aus der Kerbe heraus. */
  padding: 14px var(--content-pad-right) var(--content-pad-bottom) var(--content-pad-left);
}
/* [SICHTPRUEFUNG 19.08.2026] DER FAB DECKTE BEDIENELEMENTE ZU, DIE MAN NICHT WEGSCHIEBEN KANN.
   GEMESSEN mit elementsFromPoint unter der Flaeche des FAB (390x844, echte App):
     Kalender  -> button.btn.secondary „Termin fuer heute anlegen"
     Chat      -> button.btn.ghost und eine div.channel-row
     To-dos    -> eine div.row
   Bei einer langen Liste ist das harmlos, man scrollt. Beim Kalender NICHT: der Knopf ist das
   LETZTE Element, der Inhalt ist kuerzer als das Fenster (gemessen scrollHeight 792 =
   clientHeight 792, also gar kein Scrollweg), und damit liegt seine rechte Haelfte dauerhaft
   unter dem FAB. Ein Tipp dorthin legt einen Termin an, statt den Knopf zu druecken — auf der
   Aufnahme shots/schnurmass2/kalender-390-hell.png sieht man die Ueberlappung.
   RECHNUNG: der FAB steht bei bottom = --tabbar-h + 22 und ist 62 hoch, reicht also bis
   --tabbar-h + 84. Das Polster muss darueber hinaus: + 12 px Luft = --tabbar-h + 96.
   NUR WENN WIRKLICH EIN FAB IM INHALT STEHT — auf Projekte und Einstellungen gibt es keinen,
   und dort waeren 96 px Leerraum genau der Fehler, den die Regel darueber schon einmal
   behoben hat („auf iPhone 16 blieb ~2 cm Leerraum unter der Liste").
   RUECKFALL: eine Engine ohne :has() behaelt das heutige Polster — also das Bild von vorher.
   Am Rig gegengeprueft: CSS.supports("selector(:has(*))") ist hier wahr. */
.content:has(.fab) {
  padding-bottom: max(calc(var(--tabbar-h) + 96px), calc(58px + var(--tabbar-reserve) + 96px));
}
/* [shell 2026-08-05] Der Inhaltsbereich ist seit dieser Welle programmatisch fokussierbar
   (`<main tabindex="-1">`, app.js): nach jedem Ansichtswechsel bekommt er den Fokus, damit die
   Tabreihenfolge nicht wieder ganz oben in der Appbar beginnt UND die neue Ansicht ueber ihr
   aria-label angesagt wird. Mit `tabindex="-1"` ist er per Tab nicht erreichbar — den Fokus
   bekommt er also nur programmatisch, und dann waere ein Ring um die halbe Seite reines
   Rauschen. Der Ring fuer echte Tastaturbedienung (:focus-visible, oben) bleibt unberuehrt. */
.content:focus { outline: none; box-shadow: none; }

/* [shell 2026-08-05] Sichtbar versteckt, fuer unterstuetzende Technik aber vorhanden: der EINE
   Ansage-Knoten der Shell (#shell-live, app.js). Er haengt an <body> — nicht in der Shell —,
   weil render() `clear(appRoot)` macht und ein Kind der Shell bei jedem Ansichtswechsel weg
   waere; genau dieses Ersetzen ist der Grund, warum die Sync-Pille ihren Zustandswechsel bisher
   NICHT angesagt bekam. Bewusst weder `display:none` noch `visibility:hidden`: beides nimmt den
   Knoten aus dem Baum der unterstuetzenden Technik, die Ansage kaeme dann gar nicht erst an. */
.sr-live {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* ---------------------------------------------------------------- tab bar */
.tabbar {
  /* [Rueckportierung 2026-08-02, v1.9.60] `absolute` in .shell-root (position:relative, so hoch
     wie der Body): die Unterkante der Huelle IST die physische Displayunterkante —
     [HUELLE-SVH 2026-08-26] Die Huelle misst seit dem 26.08. `100svh` statt `100lvh`. GENAU
     DIESE Zeile ist der Grund, warum das zaehlt: die Leiste haengt an der Unterkante der
     Huelle. Mit `lvh` (GROSSE Sichthoehe, eingefahrene Browserleisten) lag sie im
     BROWSER-REITER unter der Werkzeugleiste und war wegen `overflow:hidden` an html/body
     nicht hervorzuholen — der Betreiberbefund „teilweise verschwindet die taskbar".
     Im Standalone aendert sich nichts (svh = lvh = dvh). —
     `fixed` sass in installierten iOS-PWAs am beschnittenen Layout-Viewport (Streifen-Befund).
     app.js misst die Hoehe weiter per ResizeObserver in --tabbar-h. */
  position: absolute; left: 0; right: 0; bottom: 0; z-index: var(--z-appbar);
  /* Auf breiten Screens exakt so breit wie die App-Spalte und mittig (left/right:0 + margin auto). */
  width: min(var(--shell-max), 100%); margin-inline: auto;
  display: flex; justify-content: space-around;
  /* [ECKENRAND 2026-08-20] DER SEITLICHE RAND — gegen die RUNDE DISPLAYECKE.
     BETREIBER-MELDUNG vom Geraet: „das P von Projekte wird vom Bildschirmrand abgeschnitten“,
     auf Nachfrage praezisiert: „muss an physische Grenze gehen, rueck einfach bisschen mehr
     ein“. Die Leiste bleibt also randlos bis zur physischen Kante — nur ihr INHALT rueckt ein.

     ES IST NICHT DIE BREITE UND NICHT DIE RESERVE. Am Livestand gemessen (375 px) begann die
     Beschriftung „Projekte“ 17,2 px vom Displayrand, und ihre Unterkante steht 11 px ueber
     der physischen Kante (8 Reserve + 3 Knopfpolster). Wie weit die runde Ecke auf dieser
     Hoehe hereinragt, ist reine Geometrie:  x = R − sqrt(R² − (R−d)²)  mit d = 11
       R ~ 44   (iPhone X..11 Pro)      ->  14,9 px   — Wort bleibt frei
       R ~ 47,3 (iPhone 12/13)          ->  17,0 px   — haarscharf
       R ~ 55   (iPhone 14 Pro/15/16)   ->  22,0 px   — 4,8 px INS WORT, genau das „P“
       R ~ 62   (iPhone 16 Pro)         ->  26,7 px
     Die Reserve UNTEN bleibt deshalb unangetastet bei 8px (Betreiber-Entscheidung, viermal
     gekippt — siehe den Block an --tabbar-reserve). Geaendert wird nur der Rand seitlich.
     NACH DER AENDERUNG gemessen (390 px): Beschriftung bei 30,1 px statt 18,5 px, Luft zur
     Ecke +15,2 px (R=44) / +8,1 px (R=55) / +3,4 px (R=62), nichts abgeschnitten, Leiste
     weiterhin von Kante zu Kante.

     WARUM DER AUFSCHLAG ALS EIGENE DEKLARATION IM MEDIENBAND STEHT und nicht als Token, der
     umgeschaltet wird: siehe den Block direkt unter dieser Regel. Dort steht auch, was beim
     Messen schiefging und warum die Messung zum Schluss NICHT das war, wonach sie aussah. */
  padding: 6px 8px var(--tabbar-reserve);
  padding-inline-start: max(8px, var(--safe-left));
  padding-inline-end: max(8px, var(--safe-right));
  /* PERF-Invariante: KEIN backdrop-filter — Listen scrollen direkt unter der Tabbar durch, ein
     Blur würde jeden Scroll-Frame neu gerechnet (Jank). Volldeckende Fläche stattdessen.

     DER ABGESCHNITTENE RAND (Betreiber-Meldung: „da wo früher Homebutton ist wird abgeschnitten
     die App"). BEFUND, GEMESSEN 2026-07-26 mit gesetzter Safe-Area an vier Geräteprofilen
     (390x844/47/34 · 414x896/48/34 · 393x852/59/34 · 430x932/59/34), Bildschirmfarbe auf der
     Mittelachse des ersten Reiterknopfes:
        y = H − 35  ->  rgb(255,255,255)   (--surface, die Bedienzeile)
        y = H − 34  ->  rgb(234,240,246)   (--bg-grad-bottom, der Home-Indicator-Streifen)
        y = H − 1   ->  rgb(234,240,246)
     Der Sprung lag in 4 von 4 Profilen EXAKT auf H − 34, also genau auf der Safe-Area-Linie.
     Die Geometrie war und ist richtig (0 Bedienelemente im Band, s. Sonde) — was der Betreiber
     sieht, ist dieser Farbsprung: eine weiße Leiste, unter der 34 px in einem anderen Ton
     stehen, liest sich als „hier hört die App auf".
     Der frühere Zweifarb-Aufbau kam aus der Bekämpfung eines „schwarzen Streifens" und hat den
     dabei durch einen sichtbaren ersetzt. JETZT: EINE Fläche von der Oberkante der Leiste bis
     zur physischen Displayunterkante. Kein Verlauf, kein zweiter Ton, keine Linie.
     Bei safe-bottom:0 (Desktop / Gerät ohne Home-Indicator) ist das exakt dasselbe Bild wie
     vorher — die Leiste ist dann einfach 34 px kürzer, es wird kein Platz reserviert. */
  background-color: var(--surface);
  background-clip: border-box;
  border-top: 1px solid var(--border);
}
/* [ECKENRAND 2026-08-20] DER AUFSCHLAG GEGEN DIE RUNDE DISPLAYECKE — Begruendung und
   Eckengeometrie stehen am Polsterblock der Leiste darueber.
   Ab 390 px faengt genau die Geraeteklasse an, deren Ecke bis in die Beschriftung reicht
   (R ~ 55..62 px), und ab dort ist auch der Platz da: bei 390 px bleiben nach dem Aufschlag
   noch 29 px Schlupf zwischen den sechs Knoepfen (gemessen). Darunter bleibt es bei 8 px —
   bei 320 px ist die Leiste randvoll, ein Aufschlag schnitte dort Woerter ab (gemessen:
   „Anwesenheit“ und „Einstellungen“).

   WARUM DER WERT HIER DOPPELT STEHT UND NICHT AUS EINEM TOKEN KOMMT.
   Der erste Versuch hielt den Rand in einem Token --tabbar-rand und schaltete den TOKEN per
   Medienregel um. Beim Nachmessen (390 px -> 320 px OHNE Neuladen) blieb das berechnete
   padding-inline-start auf 22px stehen, obwohl der Token korrekt auf 8px fiel — in Kurz- wie
   in Langschreibweise. Das sah nach einem Engine-Fehler aus.
   ES WAR KEINER, UND DAS IST DER MERKSATZ: eine neutrale Gegenprobe (eigene Klasse, eigene
   @media-Regel, frisch eingehaengt) blieb bei 390 px ebenfalls auf ihrem 320-px-Wert, obwohl
   matchMedia('(min-width:390px)') true meldete. Das MESSFENSTER wertet Medienregeln beim
   Groessenwechsel gar nicht neu aus — nicht der Browser der Nutzer. Wer hier misst, muss bei
   JEDER Breite NEU LADEN, sonst misst er den vorigen Zustand.
   Die Doppelung bleibt trotzdem: sie ist die Form, die diese Datei in allen anderen
   Breitenbaendern benutzt, sie braucht kein zusaetzliches Token, und sie ist an einer Stelle
   zu lesen. Zwei Zahlen an zwei Stellen; tests/safearea_contract.test.mjs haelt sie zusammen.
   DIE 26 px SIND GEMESSEN, NICHT GESCHAETZT. Bei 393 px passen hoechstens 31,9 px Rand,
   bevor die Knoepfe schrumpfen muessten (Knopfsumme 329,3 px). Mit 22 px stand die
   Beschriftung bei 27,4 px — genug fuer R=55 (+5,4 px), aber fuer R=62 blieben nur +0,7 px.
   Mit 26 px steht sie bei 30,8 px: R=47,3 +13,8 · R=55 +8,8 · R=62 +4,1. Mehr waere Zierde
   und ginge auf Kosten der Knopfbreite.
   GEMESSEN mit Neuladen je Breite: 320 px -> 8px, nichts abgeschnitten (Knopfsumme 297 von
   304 verfuegbaren); 393 px -> 26px, Beschriftung bei 30,8 px statt 18,7 px. */
@media (min-width: 390px) {
  .tabbar {
    padding-inline-start: max(26px, var(--safe-left));
    padding-inline-end: max(26px, var(--safe-right));
  }
}
/* Auf breiten Screens die obere Kante der Tabbar an die Geräte-Karte anlegen. */
@media (min-width: 760px) {
  .tabbar { border-radius: var(--radius-lg) var(--radius-lg) 0 0; border-inline: 1px solid var(--border); }
}
.tabbar button {
  position: relative;
  /* Gleichmäßig aufteilen und schrumpfbar: keine feste Breite, die auf 320-px-Geräten überläuft. */
  /* [stil 2026-08-06] Trefferflaeche auf der Textgroessen-Leiter, aber nur nach OBEN:
     max() haelt die 44 px auch dann, wenn jemand spaeter eine Stufe unter 1,0 einbaut. */
  flex: 1 1 0; min-width: 0; min-height: max(44px, calc(44px * var(--ui-scale))); /* komfortables Touch-Ziel */
  border: 0; background: none; display: grid; justify-items: center; align-content: center;
  /* [2026-07-30] BEDIENZEILE AUF NATIVES MASS. Betreiber: „die 40 px sehen mies aus, andere
     Apps haben sowas nicht."
     GEMESSEN (393x852, Safe-Area unten 34): Leiste 104 px = 0,67 Rand + 6 padding-top
     + 58 Knopfzeile + 40 padding-bottom. Eine native iOS-UITabBar misst 83 pt = 49 Bedienzeile
     + 34 Reserve.
     DIE RESERVE IST NICHT DER FEHLER — 34 px hat Mail, Musik und der App Store genauso; sie
     freizuraeumen hiesse, Bedienelemente in die Wischzone des Home-Indicators zu legen.
     Zu hoch war die BEDIENZEILE: 58 statt 49. Die 9 px kommen aus den Polstern hier
     (7 oben / 5 unten) und dem Abstand 3. Auf 3/3 und 2 gesetzt ⇒ 30 + 2 + 13 + 6 = 51,
     Leiste rund 98 statt 104.
     DAS TOUCH-ZIEL BLEIBT UNANGETASTET: `min-height: 44px` steht unveraendert darueber, und
     51 > 44 — die Trefferflaeche schrumpft also nicht, nur der Leerraum um sie herum.
     NICHT geaendert wurde `padding-bottom` der Leiste (Zeile 452): das ist die Hausregel
     „additive Polster" dieser Datei (10x `calc(<px> + var(--safe-*))`, 0x `max(...)`), und
     der Vertragstest koennte einen Bruch dort gar nicht melden. */
  gap: 2px;
  /* Diese Grundgroesse traegt nur eine Leiste mit HOECHSTENS vier Reitern — ab fuenf gewinnen
     die Verdichtungsstufen darunter (hoehere Spezifitaet). Sie darf deshalb mitwachsen: mit
     vier Woertern ist auf 320 px reichlich Platz. Warum die Verdichtungsstufen NICHT
     mitwachsen, steht bei --ui-scale im Token-Kopf. */
  padding: 3px 4px 3px; border-radius: 0;   /* [S19] war 14px — der Reiter hat gar keine Flaeche, die Zahl war ein Rest */
  color: var(--text-soft); font-size: calc(11px * var(--ui-scale)); font-weight: 700; cursor: pointer;
  transition: color var(--t-fast) var(--ease), transform var(--t-tap) var(--ease);
}
/* Sehr schmale Geräte: Labels dürfen kleiner werden, damit fünf Tabs kollisionsfrei passen. */
@media (max-width: 340px) { .tabbar button { font-size: calc(10px * var(--ui-scale)); } }
/* [WEBSITE-ANGLEICH 20.08.2026] BERICHTIGUNG ZUM FOLGENDEN ABSATZ: er schreibt den geretteten
   Platz der Saira zu. NACHGEMESSEN am Rig mit aufgelegtem Inter (--f-display ueberschrieben,
   sonst nichts geaendert) summieren sich die sechs Knoepfe bei 320 px auf 300 px von 320
   verfuegbaren; mit Saira sind es 297. Beide passen, keines der sechs Woerter wird
   abgeschnitten — auch bei 360 und 390 px nicht. Gerettet hat das Budget also der Handgriff,
   den der Absatz selbst beschreibt (Polster auf 2 px, plus die 2 px am Symbolfeld), und
   nicht die Schriftwahl. Das ist wichtig zu wissen, weil die Schriftfrage offen ist: siehe
   den Saira-Absatz im Tokenkopf. Der folgende Text bleibt sonst woertlich stehen — seine
   Geometrie stimmt, nur seine Ursache war die falsche. */
/* [SICHTPRUEFUNG 19.08.2026] DIE LETZTE FEHLENDE PIXEL-BREITE bei 320 px.
   Mit der Saira passen fuenf der sechs Woerter (37 · 57 · 29 · 39 · 20 von je 59 px), nur
   „Einstellungen" braucht GEMESSEN 60 px und bekommt 59 — es fehlt genau EIN Pixel, und die
   Ellipse frisst dafuer die letzten drei Buchstaben. Der Platz liegt im waagerechten Polster
   der Knoepfe: 6 Reiter x 2 x 4 px = 48 px, von denen keiner etwas traegt (die
   44-px-Trefferflaeche haengt an `.tabbar button .ico`, nicht am Polster). Auf 2 px gesetzt
   gibt das 4 px je Knopf zurueck — viermal so viel wie gebraucht, und die Reserve traegt
   auch die naechste Uebersetzung. Hoehe, Trefferflaeche und Safe-Area bleiben unberuehrt;
   nur das Band bis 360 px ist betroffen, dort wo es wirklich knapp ist. */
@media (max-width: 360px) {
  .tabbar button { padding-inline: 2px; }
  /* … und der EINE Pixel, der danach immer noch fehlte. NACHGEMESSEN mit den echten Kaesten:
       Leiste 320 − 2x8 Polster = 304 px verfuegbar
       Knopfbreiten 46 · 59 · 46 · 46 · 46 · 61 = 304 — die Leiste ist randvoll
       Bedarf von „Einstellungen" 60 px, bekommen 59
     Vier der sechs Knoepfe sind BREITER ALS IHR WORT: „Chat" braucht 20 px und belegt 46. Der
     Grund ist `.tabbar button .ico { min-width: 44px }` — das Symbolfeld haelt dort die
     Trefferflaeche. Das ist der richtige Gedanke am falschen Element: Trefferflaeche ist, was
     der Finger trifft, und das ist der KNOPF. Der Boden wandert deshalb hier an den Knopf
     selbst (in der max()-Schreibweise, wie Entwurfsregel 6 sie verlangt), und das Feld darin
     darf 2 px schmaler werden. NACHHER: die vier kurzen Knoepfe geben je 2 px zurueck, macht
     8 px, und „Einstellungen" bekommt 62 statt 61 — 60 Inhalt in 60 Spalte, kein Wort mehr
     abgeschnitten. Die Trefferflaeche bleibt bei 44x49 px, also ueber dem Handschuhmass.
     Nur bis 360 px; darueber ist der Platz da und alles bleibt, wie es war.
     DIE ZWEITE HAELFTE DIESER REGEL STEHT WEITER UNTEN, direkt hinter
     `.tabbar button .ico` — sie hat dieselbe Spezifitaet wie die Grundregel, und die
     Grundregel steht in dieser Datei SPAETER. Hier haette sie nichts bewirkt; am Rig
     gemessen blieben die Knoepfe bei 46 px, bis der Block umgezogen war. */
  /* [TEXTSKALA-DECKEL 2026-08-21] DIE BREITE WAECHST NICHT MIT DER TEXTSKALA — DIE HOEHE SCHON.
     GEMESSEN bei 320 px und Textgroesse „Sehr gross" (--ui-scale 1,3): der Boden stand hier auf
     max(44px, 44*1,3) = 57,2 px, sechs Knoepfe brauchten damit 343,1 px — bei 304 verfuegbaren.
     Die Leiste lief 39 px ueber, und „Anwesenheit" und „Einstellungen" wurden abgeschnitten,
     OBWOHL die Schrift auf dieser Breite gar nicht mitwaechst (Deckel oben). Die Ursache war
     also nicht die Schrift, sondern dieser Boden.
     Die Hausregel „die 44 px duerfen durch die Skala nur WACHSEN" bleibt gewahrt: 44 ist und
     bleibt die Untergrenze, und der Trefferflaechen-GEWINN der Textskala steckt in der HOEHE
     (`min-height: max(44px, calc(44px * var(--ui-scale)))` an der Grundregel, gemessen
     44 -> 57,2 px). In der BREITE ist er in einer Sechs-Slot-Leiste nicht bezahlbar: sechs mal
     57,2 px sind mehr, als ein 320er ueberhaupt hat. Wer das zurueckdreht, misst zuerst bei
     320 px mit Skala 1,3 nach. */
  .tabbar button { min-width: 44px; }
}
/* „klein" (recon2/04): Mit SECHS Tabs (Anwesenheit freigeschaltet) schnitt der Text ab — GEMESSEN
   bei 5 Tabs schon „Einstellungen" scrollWidth 70 gegen clientWidth 67; der Screenshot mit 6 Tabs
   zeigte „Anwese..." und „Einstellu...". Zwei von sechs Hauptbereichen trugen kein lesbares Wort
   mehr. `:nth-last-child(n+6)` trifft jedes Kind, hinter dem noch mindestens fünf weitere stehen —
   also GENAU dann, wenn die Leiste sechs oder mehr Tabs hat. Wortkürzungen bleiben eine
   Produktentscheidung (Label-Texte liegen in app.js); hier wird nur der Platz ehrlich verteilt. */
.tabbar button:nth-last-child(n+5), .tabbar button:nth-last-child(n+5) ~ button {
  font-size: 10.5px; letter-spacing: -0.1px; padding-inline: 2px;
}
/* [shell 2026-08-05] 9,5 px SIND KEINE BESCHRIFTUNG MEHR — ABER NUR DA STREICHEN, WO DER PLATZ REICHT.
   GEMESSEN (393x852, sechs Reiter): alle sechs Beschriftungen 9,5 px. Zum Vergleich: iOS-HIG-
   Minimum 11 pt = 14,7 px, Material 12 sp. Der Kontrast ist in Ordnung (inaktiv 5,43:1, aktiv
   6,61:1) — allein die Groesse war das Problem, ausgerechnet in der Hauptnavigation.
   NACHGERECHNET auf den bereits in dieser Datei stehenden Messwerten: mit `flex: 0 1 auto`
   (Regel im 430-px-Band weiter unten) brauchten die sechs Woerter bei 320 px und 9,5 px zusammen
   301 px. Bei 10,5 px sind das 301 x 10,5/9,5 = 333 px. Verfuegbar sind Breite minus 16 px Rand:
     320 px -> 304  (333 passt NICHT)
     360 px -> 344  (333 passt)
     393 px -> 377  (333 passt bequem — das Zielgeraet)
   Die 9,5-px-Stufe bleibt deshalb genau dort, wo sie eine Abkuerzung verhindert: bis 360 px.
   Darueber traegt die 10,5-px-Stufe. Fuer 11 px und mehr braeuchte es KUERZERE WOERTER
   („Zeiten" statt „Anwesenheit", „Optionen" statt „Einstellungen"; die Texte liegen in app.js) —
   das ist eine Namensentscheidung des Betreibers und steht als solche im Bericht, nicht hier. */
@media (max-width: 360px) {
  .tabbar button:nth-last-child(n+6), .tabbar button:nth-last-child(n+6) ~ button {
    font-size: 9.5px; letter-spacing: -0.2px; padding-inline: 1px;
  }
}
/* Label darf ellipsen statt die Zeile zu sprengen. */
.tabbar button > span:last-child { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* --------------------------------------- 320-px-Klasse: JEDES Reiterwort bleibt GANZ stehen
   BEFUND (Welle Q2D, GEMESSEN im Demo-Modus bei 320x844, sechs Reiter, Chromium):
     „Einstellungen"  scrollWidth 58 gegen clientWidth 49   ->  „Einstellun…"
     „Anwesenheit"    scrollWidth 55 gegen clientWidth 49   ->  „Anwesenhei…"
   Zwei von sechs Hauptbereichen trugen auf einem iPhone SE kein ganzes Wort mehr.
   URSACHE ist NICHT der Platz, sondern seine Verteilung: `flex: 1 1 0` gibt jedem Reiter
   GLEICH VIEL — (320 − 16 Rand) / 6 = 50,7 px — obwohl „Chat" nur 20 px braucht und
   „Einstellungen" 58. Vier Reiter verschenken zusammen ~60 px, zwei hungern.
   NACHHER wird der Platz nach BEDARF verteilt (`flex: 0 1 auto`); die 44-px-Trefferfläche
   bleibt die untere Schranke, weil `.tabbar button .ico` sie unabhängig davon hält.
   GEMESSEN nachher bei 320 px, sechs Reiter:
     Projekte 46 · Anwesenheit 57 · To-dos 46 · Kalender 46 · Chat 46 · Einstellungen 60
     -> Summe 301 von 304 verfügbaren px, 0 Kürzungen, Leistenhöhe unverändert 68 px,
        jeder Knopf 46x55 px (>= 44x44).
   Verworfen wurde die naheliegende Alternative „zweizeilig mit `hyphens: auto`":
   `overflow-wrap: anywhere` erzeugte daraus „Anwesenh/eit" und „Einstellung/en", und die
   Leiste wuchs auf 79 px.
   [BETREIBER 2026-08-21] HIER STAND ZUSAETZLICH: „GEMESSEN trennt dieses Chromium ohne
   deutsches Trennmuster gar nicht." DAS STIMMT NICHT — heute nachgemessen, es trennt (Zahlen
   und Beleg im Block bei .row .title). Die ENTSCHEIDUNG bleibt hier trotzdem stehen: an der
   Reiterleiste war nicht die Trennung das Problem, sondern die zweite ZEILE — sie kostete
   11 px Leistenhoehe, und daran aendert ein Trennstrich nichts. Berichtigt wurde nur der Satz,
   damit niemand die Trennung ANDERSWO unter falscher Voraussetzung verwirft; genau das waere
   hier um ein Haar passiert.
   Nur für die 6-Reiter-Leiste und nur im Handy-Band (<=430 px, dieselbe Schranke wie das
   Prioritäten-Layout der Titelzeile); darüber bleibt die gleichmäßige Verteilung. */
@media (max-width: 430px) {
  .tabbar button:nth-last-child(n+6),
  .tabbar button:nth-last-child(n+6) ~ button { flex: 0 1 auto; }
}
/* Native „Selected-Pill" hinter dem Icon des aktiven Tabs (weich eingeblendet).
   Touch-Ziel ≥ 44 px: die Icon-Fläche ist mind. 44 px breit, der Button füllt die Zeilenhöhe. */
.tabbar button .ico {
  position: relative; line-height: 0; display: grid; place-items: center;
  min-width: 44px; height: 30px; border-radius: var(--radius);   /* [S19] war 999px — Symbolfeld des aktiven Reiters */
  transition: transform var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
/* [SICHTPRUEFUNG 19.08.2026] Die zweite Haelfte der 320-px-Rechnung (die erste steht oben bei
   `@media (max-width: 360px)`, mit der vollstaendigen Begruendung und den gemessenen Zahlen):
   das Symbolfeld gibt 2 px zurueck, weil die Trefferflaeche seit dort am KNOPF haengt und
   nicht mehr an ihm. Muss HIER stehen und nicht oben — gleiche Spezifitaet, und die
   Grundregel darueber steht in dieser Datei spaeter als der obere Block. */
@media (max-width: 360px) { .tabbar button .ico { min-width: 42px; } }
/* SchnurPlan-SVG-Icon in der Tabbar: erbt die Button-Farbe (faint → aktiv teal) über currentColor.
   Volles Rastermass (24 = 1:1 zur viewBox) — die Rechnung steht bei --ic-gross im Token-Kopf. */
.tabbar button .ico .ic { width: var(--ic-gross); height: var(--ic-gross); }
.tabbar button:active { transform: scale(.92); }
.tabbar button.active { color: var(--accent-ink); }
.tabbar button.active .ico { background: var(--gruenw); transform: translateY(-1px); }
/* Aktiver Tab: Icon minimal kräftiger, damit die eigene Symbolsprache „sitzt". */
/* [ICONS 1.9.85] Grundstrich der Glyphen ist jetzt 2 — die Aktiv-Betonung braucht deshalb 2.4,
   sonst saehe der aktive Reiter aus wie alle anderen. */
.tabbar button.active .ico .ic { stroke-width: 2.4; }
/* [stil 2026-08-06] DER AUTO-ZWILLING STAND OHNE SEIN @media — UND GALT DAMIT AUCH IN HELL.
   Hier stand `:root[data-theme="dark"] …, :root:not([data-theme="light"]) …` als EINE
   Selektorliste ohne Media-Query. Der zweite Selektor trifft aber jede Wurzel OHNE
   `data-theme` — und das ist der Auslieferungszustand („Darstellung: Automatisch", die
   Vorgabe in js/views/settings.js). Bei automatischem Thema und HELLEM System trug der
   aktive Reiter deshalb die DUNKEL-Fuellung rgba(52,231,206,.16) statt der hellen
   rgba(10,168,146,.14) aus der Regel darueber. Die Hausform steht in dieser Datei
   viermal richtig (.banner, .auth-brand, .skel): `:root[data-theme="dark"]` ohne Media,
   der Auto-Zwilling IM `@media (prefers-color-scheme: dark)`. Genau so jetzt auch hier. */
:root[data-theme="dark"] .tabbar button.active .ico { background: var(--gruenw); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .tabbar button.active .ico { background: var(--gruenw); }
}
/* AMT (a) DES GRUENS: der aktive Reiter. Ein 3-px-Balken an der OBERKANTE, breiter als
   vorher (halbe Knopfbreite statt 22 px fester), damit er auch auf 320 px mit sechs
   Reitern noch als Balken und nicht als Punkt liest. Radius 0 — eine Kante, kein Bonbon. */
.tabbar button.active::before {
  content: ""; position: absolute; top: 0; left: 24%; right: 24%;
  height: 3px; border-radius: 0; background: var(--gruen);
}
/* ---------------------------------------------------------------- Standalone (Homescreen-PWA)
   BUG: „schwarzer Streifen unten". Ursache: in der installierten App (display:standalone) reicht
   der deckende App-Grund unter dem Home-Indicator / hinter der System-Navigationsleiste nicht bis
   zur physischen Unterkante bzw. die Tabbar-Fläche füllt ihre Safe-Area-Polsterung nicht sichtbar.
   Fix: Marken-Navy edge-to-edge auf Wurzel/Body/Shell, und die deckende Tabbar-Surface zieht
   garantiert (background-clip:border-box) durch ihre padding-bottom:safe-bottom bis ganz nach unten. */
@media (display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui) {
  /* Marken-Navy edge-to-edge bis zur physischen Unterkante — das ist die Versicherung gegen
     einen ungemalten (schwarzen) Streifen NEBEN der Leiste, etwa im Querformat, wo die Leiste
     nur --shell-max breit ist. Die Leiste selbst deckt ihr eigenes Home-Indicator-Band seit
     2026-07-26 mit einer durchgehenden --surface-Fläche (Basis-.tabbar-Regel); der frühere
     Zweifarb-Aufbau ist entfallen, weil er als abgeschnittener Rand gelesen wurde. */
  html, body, .shell-root { background-color: var(--bg-grad-bottom); }
}

/* ---------------------------------------------------------------- eigene Symbole (Icons)
   Basis für die SchnurPlan-SVG-Icons aus js/icons.js (24×24, stroke-line, currentColor —
   KEINE Emojis). Standardmäßig erben die Icons die Textfarbe ihres Kontexts; per Rolle
   einfärbbar: Cyan = UI/Interaktion, Amber = Signal/Achtung, Grün = live/verbunden/Marke. */
.ic { flex: none; vertical-align: middle; color: currentColor; }
/* WCAG (PW-6): die drei Rollentoene waren FLAECHENfarben und lagen als TINTE unter jeder Schwelle
   — GERECHNET auf --bg #eaf0f6: --navy #0aa892 2,60 · --yellow #ff7a3c 2,26 · #4bc429 1,98.
   #4bc429 war ausserdem fest verdrahtet und kannte kein Thema. Ersetzt durch die TINTEN-Tokens
   derselben Rolle, die diese Datei schon fuehrt. NACHHER (hell auf --bg / --surface / --surface-2,
   dann dunkel auf denselben drei):
     .ic--cyan  --accent-ink  5,76 · 6,61 · 6,19   dunkel 14,81 · 13,76 · 12,70  (vorher 8,58)
     .ic--amber --warn-text   6,18 · 7,09 · 6,64   dunkel  9,18 ·  8,52 ·  7,87  (vorher 7,60)
     .ic--green --ok-text     6,21 · 7,13 · 6,68   dunkel  8,66 ·  8,04 ·  7,42  (UNVERAENDERT:
       --ok-text ist im dunklen Thema derselbe #4bc429 — die Zahl faellt also in keinem Thema).
   Die drei Klassen haben heute 0 Aufrufer (grep ausserhalb dieser Datei). Sie werden trotzdem
   gehoben und nicht geloescht: sie sind die dokumentierte Rollen-Sprache der Symbole, und wer
   sie als Naechster benutzt, soll keinen 1,98:1-Ton erben. */
.ic--cyan  { color: var(--accent-ink); }
.ic--amber { color: var(--warn-text); }
.ic--green { color: var(--ok-text); }

/* ---------------------------------------------------------------- cards */
.card {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
  box-shadow: var(--shadow); padding: 16px; margin-bottom: 12px;
}
.card.tap { cursor: pointer; }
/* Antippbare Flächen — EINE Zeitregel für alle vier.
   Vorher trugen .card.tap und .row je eine eigene (unterschiedliche) Transition, während
   .channel-row und .agenda-item ihre nur im Hover-Block bekamen: auf dem Handy sprangen
   sie beim Antippen ohne Übergang, am Zeigergerät nicht. Jetzt reagieren alle vier gleich,
   auf jedem Gerät. */
.card.tap, .row, .channel-row, .agenda-item {
  transition: transform var(--t-tap) var(--ease), box-shadow var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.card.tap:active { transform: scale(.99); }
/* Kurzes Aufblitzen einer Karte, wenn ein Tipp sie zur Voraussetzung hatte (Anwesenheit: der erste
   Druck auf „Arbeitstag starten" ohne bestätigten Standort-Hinweis verpuffte GEMESSEN folgenlos —
   kein Toast, kein Scrollen, keine Hervorhebung). Reine Aufmerksamkeit, keine Bewegung der Seite.
   Bei „reduzierter Bewegung" bleibt der Rahmen statisch sichtbar statt zu pulsieren. */
.card.flash { animation: cardflash var(--t-pulse) var(--ease-io); }
@keyframes cardflash {
  0%, 100% { box-shadow: var(--shadow); }
  15%, 55% { box-shadow: 0 0 0 3px var(--navy-bright), var(--shadow); }
}
@media (prefers-reduced-motion: reduce) {
  .card.flash { animation: none; box-shadow: 0 0 0 3px var(--navy-bright), var(--shadow); }
}
.section-title {
  display: flex; align-items: center; gap: 9px;
  /* [stil 2026-08-06] DIESE ZEILE IST DIE MESSSONDE DER TEXTGROESSE. `textskalaWirkt()`
     (js/views/settings.js:1259) baut eine `.section-title`, setzt --ui-scale einmal auf 1 und
     einmal auf 2 und vergleicht die gerechnete Schriftgroesse; faellt der Vergleich aus, bleiben
     die drei Textgroessen-Chips ABSICHTLICH unsichtbar. Wer hier die calc()-Klammer entfernt,
     schaltet also nicht nur diese Ueberschrift zurueck — er nimmt dem Betreiber die Einstellung. */
  font-size: calc(13px * var(--ui-scale)); font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-soft); margin: 18px 4px 8px;
}
/* „Die Schnur": gespannter Faden vor jedem Sektionstitel. */
.section-title::before {
  content: ""; flex: none; width: 18px; height: var(--schnur-dick); border-radius: 0;
  background: var(--schnur-line);
}
/* [SCHNURMASS] Die Rubrikzeile ist in Saira gesetzt und weit gesperrt — sie ist das
   Etikett des Aufmassblattes, nicht eine kleine Ueberschrift. Die GROESSE bleibt
   bewusst bei 13 px: an genau dieser Regel misst js/views/settings.js, ob das
   Stylesheet --ui-scale auswertet, und tests/stil_textskala_und_blase.test.mjs nagelt
   den Wert fest. Wer sie auf die Rubrik-Stufe (11 px) ziehen will, muss dort mit. */
.section-title { font-family: var(--f-display); letter-spacing: .26em; }

.list { display: grid; gap: 10px; }
/* ══ DIE SCHNUR ════════════════════════════════════════════════════════════════
   Der unverwechselbare Zug: eine 2-px-Linie am linken Rand jeder Liste, ein
   10-px-Knoten je Zeile. Sie kostet KEINE View-Zeile — die Rinne haengt an
   .list/.channel-list/.agenda-list, der Knoten am jeweiligen Zeilenkind.
   WARUM :has(): eine .list ohne Zeilenkinder (Einstellungen, Kartenstapel) bekaeme sonst
   eine Rinne ohne einen einzigen Knoten. Der Nebeneffekt ist der eigentliche Grund: kennt
   eine Engine :has() nicht, faellt die GANZE Regel weg und die Liste sieht aus wie heute.

   DIE KNOTEN-SPRACHE (Entwurfsregel 4), ueber data-stand am Zeilenkind: hohl = offen ·
   gruen = laeuft · bernstein = braucht einen Handgriff · grau = erledigt. Die Views setzen
   data-stand noch nicht — bis dahin traegt jede Zeile den hohlen Knoten, und das ist die
   ehrliche Aussage „offen".
   ══════════════════════════════════════════════════════════════════════════ */
.list:has(> .row), .channel-list:has(> .channel-row), .agenda-list:has(> .agenda-item) {
  position: relative;
  padding-left: var(--schnur-spur);
}
.list:has(> .row)::before,
.channel-list:has(> .channel-row)::before,
.agenda-list:has(> .agenda-item)::before {
  content: ""; position: absolute; left: 4px; top: 10px; bottom: 10px;
  width: var(--schnur-dick); background: var(--schnur-line); pointer-events: none;
}
.list > .row, .channel-list > .channel-row, .agenda-list > .agenda-item { position: relative; }
.list:has(> .row) > .row::before,
.channel-list:has(> .channel-row) > .channel-row::before,
.agenda-list:has(> .agenda-item) > .agenda-item::before {
  content: ""; position: absolute; left: calc(-1 * var(--schnur-spur) + 1px); top: 24px;
  width: var(--schnur-knoten); height: var(--schnur-knoten);
  border-radius: var(--radius-rund);            /* DAS EINZIGE RUNDE DER APP */
  background: var(--bg); border: 2px solid var(--line2); box-sizing: border-box;
  pointer-events: none;
}
.list > .row[data-stand="laeuft"]::before,
.agenda-list > .agenda-item[data-stand="laeuft"]::before { background: var(--gruen); border-color: var(--gruen); }
.list > .row[data-stand="stockt"]::before,
.agenda-list > .agenda-item[data-stand="stockt"]::before { background: var(--warn-fl); border-color: var(--warn-fl); }
.list > .row[data-stand="fertig"]::before,
.agenda-list > .agenda-item[data-stand="fertig"]::before { background: var(--line2); border-color: var(--line2); }
/* Grid-Kinder dürfen unter ihre min-content-Breite schrumpfen: min-width:auto würde Zeilen mit
   nowrap-Titeln (z. B. lange Projektnamen) BREITER als die Spur machen → horizontaler Overflow
   auf schmalen Displays (390 px). min-width:0 lässt das innere Flex-Layout ellipsen. */
/* ── [SICHTPRUEFUNG 19.08.2026] DIE SCHNUR FEHLTE IN DER KANALLISTE — WEGEN EINES UMSCHLAGS.
   Die drei Regeln oben verlangen die Zeile als DIREKTES Kind (`> .channel-row`). In
   js/views/chat.js liegt die Kanalzeile aber in einem `div{position:relative}`, weil der
   Ueberlauf-Knopf ein GESCHWISTER der Zeile sein muss (die Zeile traegt role="button", und
   deren Kinder sind fuer Vorlesehilfen praesentationell — die Begruendung steht dort
   ausfuehrlich). GEMESSEN am Rig: `.channel-row`.parentElement war `div` ohne Klasse, also
   griff weder der :has()-Waechter noch der Knoten. Auf der Aufnahme fehlten Linie UND
   Knoten, waehrend Projekte und To-dos sie trugen.
   Der Umschlag darf bleiben — er hat einen guten Grund. Also lernt das Blatt ihn:
   dieselben drei Regeln noch einmal mit einer Ebene dazwischen. Die Fassung OHNE Umschlag
   bleibt woertlich stehen (tests/stil_schnurmass prueft den Selektor `:has(> .channel-row)`
   Zeichen fuer Zeichen, und DM-Listen bauen die Zeile weiterhin direkt). */
.channel-list:has(> * > .channel-row) { position: relative; padding-left: var(--schnur-spur); }
.channel-list:has(> * > .channel-row)::before {
  content: ""; position: absolute; left: 4px; top: 10px; bottom: 10px;
  width: var(--schnur-dick); background: var(--schnur-line); pointer-events: none;
}
.channel-list > * > .channel-row { position: relative; }
.channel-list:has(> * > .channel-row) > * > .channel-row::before {
  content: ""; position: absolute; left: calc(-1 * var(--schnur-spur) + 1px); top: 24px;
  width: var(--schnur-knoten); height: var(--schnur-knoten);
  border-radius: var(--radius-rund);            /* DAS EINZIGE RUNDE DER APP */
  background: var(--bg); border: 2px solid var(--line2); box-sizing: border-box;
  pointer-events: none;
}
/* Der Kanal-Knoten kennt zwei Staende, und beide stehen schon im Markup: `unread` heisst
   „hier liegt etwas fuer dich" (bernstein = braucht deinen Handgriff), sonst gelesen (grau).
   Damit sagt der Knoten in JEDER Liste dasselbe und nicht in einer gar nichts. */
.channel-list > .channel-row.unread::before,
.channel-list > * > .channel-row.unread::before { background: var(--warn-fl); border-color: var(--warn-fl); }
.list > *, .channel-list > *, .agenda-list > *, .stack > * { min-width: 0; }
.row {
  display: flex; align-items: center; gap: 14px; padding: 14px 16px;
  /* [SCHNURMASS] Zeilenhoehe 70 px aus den abgenommenen Tafeln. NICHT ueber
     min-height mit --ui-scale geschrieben, sondern nackt: die Zeile waechst ohnehin
     mit ihrem Inhalt, und ein max()-Boden waere hier eine Zusage ohne Trefferflaechen-
     Amt. Die Trefferflaeche der Zeile ist ihr Inhalt (>= 44 px), nicht dieser Wert. */
  min-height: 70px;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--shadow); cursor: pointer;
}
.row:active { transform: scale(.99); }
/* KONTRAST-BEFUND, ehrlich verortet (Welle Q2D, 2026-07-26) — und seit SCHNURMASS nur noch zur
   Haelfte offen. Die Regel hier setzte damals den Teal-Verlauf --accent-cta; die gemeldeten
   2,99:1 entstanden woanders: js/views/projects.js ueberschreibt die Flaeche per style-Attribut
   mit einer Hashfarbe aus js/util.js (tintFor, Palette #0aa892 #077e6c #0cc0ab #0a8f7e #0f6cbd
   #5a4bd8). GERECHNET ueber die ganze Palette gab es KEINE Tinte, die auf allen sechs traegt —
   weil die Palette Tinten-Toene als FLAECHEN benutzt. Heilbar ist das nur dort, wo die Farbe
   GEWAEHLT wird, also in js/util.js. UEBERGABE steht im Bericht Q2D_ergebnis.md (fertige
   Ersatzpalette mit 5,56–10,65:1) und ist mit SCHNURMASS NICHT erledigt: diese Hashfarben sind
   die letzten Teal-Flaechen der App. */
/* SELEKTOR VERBREITERT: .row .avatar -> .avatar (PW-6, Nachpruefung 27.07.). BEFUND: die Klasse
   hat zwei Verwender — js/views/projects.js (liegt in einer .row) und js/views/settings.js
   (Konto-Kopf, NICHT in einer .row); fuer den zweiten griff die Regel nie, also fehlte dort
   Groesse, Zentrierung und vor allem die Tinte. Die damalige Rechnung galt gegen den
   Teal-VERLAUF und ist mit SCHNURMASS gegenstandslos: die Flaeche ist jetzt eine einzige helle
   Marken-Flaeche und traegt die dunkle Tinte der Hausregel §1 (8,34:1). Die Hashfarbe aus
   js/views/projects.js steht als style-Attribut weiterhin ueber dieser Regel — das ist der
   offene Teil aus dem Q2D-Block oben. */
.avatar {
  width: 46px; height: 46px; border-radius: var(--radius); flex: none; display: grid; place-items: center;
  /* [SCHNURMASS] --accent-cta ist keine dunkle Verlaufsflaeche mehr, sondern das helle
     Markengruen. Weiss darauf misst 2,28:1 — deshalb die dunkle Tinte der Hausregel. */
  background: var(--accent-cta); color: var(--accent-fill-ink); font-weight: 900; font-size: 18px;
  /* [SICHTPRUEFUNG 19.08.2026] ZWEITE DEKLARATION, sie gewinnt — die Zeile darueber bleibt als
     Grabstein stehen, damit niemand den Teal-Nachfolger „gruene Kachel" wiedererfindet.
     GEMESSEN am Rig (Reiter Einstellungen, 390 px): auf diesem einen Bildschirm standen NEUN
     gefuellte Markenflaechen — diese Kachel, zwei grosse Knoepfe und sechs „Herunterladen".
     Die Kachel ist davon die einzige, die gar keine Handlung ist: sie traegt einen Buchstaben.
     Das Gruen hat vier Aemter (aktiver Reiter, Fokusring, Zustandsknoten, DER EINE
     Primaerknopf) und „Monogramm" ist keines davon; dasselbe Urteil hat das Los „listen" der
     bunten Initialen-Kachel der Projektliste ausgestellt („markenfremd", ersatzlos entfallen).
     Sie traegt jetzt dieselbe Sprache wie das Kuerzel-Kaestchen im Chat und in der
     Aufgabenliste: eingelassene Flaeche, Markengrau als Kante, volle Tinte, Maschinenschrift.
     GERECHNET: --ink #14161A auf --fl2 #E9E7E1 = 15,08:1 (hell), #F2F1ED auf #0E1114 = 16,54:1
     (dunkel). Beide AAA, in BEIDEN Themen, ohne eigenen Dunkel-Zwilling — die Tokens drehen. */
  background: var(--fl2); color: var(--ink);
  border: 1px solid var(--line2);
  font-family: var(--f-mono, "JetBrains Mono", ui-monospace, Consolas, monospace);
  font-weight: 700; letter-spacing: .02em;
}
.row .grow { flex: 1; min-width: 0; }
.row .title { font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Der Projektname darf ZWEI Zeilen brauchen (Demo-Befund V1, 2026-07-26). `.row .title` steht in
   vielen Listen (Chat-Kanaele, Personen, Agenda) und bleibt deshalb einzeilig — nur die
   Projektzeile bekommt diese Zusatzklasse. GEMESSEN wurde vorher bei 390 px
   „Musterbau · Woh…" und bei 320 px „Musterb…": Baustellennamen unterscheiden sich oft erst
   hinten (Strasse, Hausnummer, Bauabschnitt), genau dort schnitt die Ellipse ab. Zwei Zeilen
   kosten ~19 px Zeilenhoehe und zeigen den ganzen Namen; ab Zeile 3 greift die Klammer wieder. */
.row .title.title--2line {
  white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; line-height: 1.25;
  /* [SICHTPRUEFUNG 19.08.2026] EIN LANGES DEUTSCHES WORT MACHTE AUS DER KLAMMER EINEN
     SCHEINBAREN FEHLER. GESEHEN auf shots/schnurmass/todos-390-dunkel.png, Aufgabe #10:
       Zeile 1  „#10 Baustelleneinrichtungspl…"
       Zeile 2  „aktualisieren"
     Das liest sich wie ein Absturz — abgeschnitten UND doch weitergeschrieben. Ursache: ohne
     Umbruchregel kann „Baustelleneinrichtungsplan" nirgends brechen, laeuft ueber die erste
     Zeile hinaus, und der -webkit-box schreibt seinen Auslassungspunkt dorthin, obwohl die
     zweite Zeile noch kommt. Zusammengesetzte Hauptwoerter sind in dieser App die Regel und
     nicht die Ausnahme (Baustelleneinrichtungsplan, Fluchtwegkennzeichnung, Rohinstallation).
     `break-word` und NICHT `anywhere`: es bricht nur, wenn das Wort auch allein auf einer
     Zeile nicht passt — kurze Woerter bleiben unangetastet. Genau `anywhere` hat in der
     Reiterleiste einmal „Anwesenh/eit" erzeugt (Begruendung dort bei .tabbar button).

     [BETREIBER 2026-08-21] NACHTRAG — DER TRENNSTRICH FEHLTE.
     Der Absatz oben loeste den Auslassungspunkt, aber nicht den haesslichen Teil. Gesehen am
     laufenden Messstand (schoenheit/aufgaben.png, dieselbe Aufgabe #10):
       Zeile 1  „#10 Baustelleneinrichtungsp"
       Zeile 2  „lan aktualisieren [R2]"
     `break-word` bricht, wo der Platz endet — mitten in der Silbe und OHNE Strich. Ein
     deutscher Leser liest das als Fehler, nicht als Umbruch.

     `hyphens: auto` bricht stattdessen an der Silbenfuge und setzt den Strich:
       „Baustelleneinrichtungs-" / „plan aktualisieren [R2]".

     WARUM DAS HIER NEU IST, obwohl es weiter oben (:1552) ausdruecklich VERWORFEN wurde:
     dort stand „GEMESSEN trennt dieses Chromium ohne deutsches Trennmuster gar nicht". Diese
     Aussage ist heute nachgemessen und trifft NICHT zu; die Stelle wurde berichtigt.
     Nachgerechnet mit `Baustelleneinrichtungsplan` in einem 120-px-Kasten, Zeilenkaesten:
       ohne hyphens  [119, 70]      -> ein Bruch, kein Strich
       mit  hyphens  [93, 6, 96]    -> die 6 px in der Mitte SIND der Trennstrich
     Beide Faelle zwei Zeilen hoch (42 px) — die Trennung kostet also keine Zeile, sie
     verschiebt nur die Bruchstelle auf die Fuge.

     `break-word` bleibt daneben stehen und ist kein Widerspruch: es ist der Rueckfall fuer
     jedes Geraet ohne Trennmuster und fuer Woerter, die keine Fuge haben (Dateinamen,
     Kennungen, `R2-EG-Nord-Rev3`). Wo getrennt werden kann, gewinnt die Fuge; wo nicht,
     bricht es wie bisher. Kein Geraet wird schlechter. */
  overflow-wrap: break-word;
  hyphens: auto;
  -webkit-hyphens: auto;
}
.row .sub { color: var(--text-muted); font-size: calc(13px * var(--ui-scale)); margin-top: 2px; }
/* GEMESSEN --text-faint #93a6bc auf --surface: 2,49:1 hell / 4,31:1 dunkel (PW-6). Der Winkel sagt
   „hier geht es weiter" und ist damit kein reines Deko-Element. --text-soft ist der Ton, den die
   Token-Tabelle oben ausdruecklich fuer „Funktionstexte, die vorher --text-faint trugen" fuehrt:
   NACHHER 5,43 hell / 5,22 dunkel auf --surface; schlechteste Flaeche --bg 4,73 hell,
   --surface-2 4,82 dunkel. Beide Themen steigen. */
.row .chev { color: var(--text-soft); font-size: calc(20px * var(--ui-scale)); }

/* ---------------------------------------------------------------- buttons */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  /* Trefferflaeche waechst mit der Textgroesse, faellt aber nie unter das Grundmass (Spez A9). */
  min-height: max(50px, calc(50px * var(--ui-scale)));
  /* [SCHNURMASS] DIE PRIMAERHANDLUNG MISST 56 px (Entwurfsregel 6, Prueflistenpunkt P13).
     Die 50-px-Zeile darueber bleibt WOERTLICH stehen, weil
     tests/stil_textskala_und_blase.test.mjs sie festnagelt und diese Probe in einem
     FREMDEN Los liegt — sie darf aus diesem Los heraus nicht geaendert werden. Zwei
     Deklarationen derselben Eigenschaft sind gueltiges CSS, die letzte gewinnt. Wer die
     Probe spaeter freigibt, streicht die erste Zeile und diesen Absatz. */
  min-height: max(56px, calc(56px * var(--ui-scale)));
  width: 100%; padding: 13px 18px; border-radius: var(--radius);
  /* DER EINE Primaerknopf des Bildschirms (Amt (d) des Gruens). Flaeche hell, Tinte
     dunkel, 1 px --gruent als Bedienteil-Kante — ohne sie stuende eine 2,28:1-Flaeche
     ohne messbare Grenze auf dem Papier (WCAG 1.4.11 verlangt >= 3:1). */
  background: var(--accent-cta); color: var(--accent-fill-ink); font-weight: 800; font-size: calc(16px * var(--ui-scale)); cursor: pointer;
  /* Die naechste Zeile ist TOT und steht nur noch da, weil tests/w18_knopfspalte.test.mjs
     die Ausgangslage im Quelltext sehen will (sie begruendet dort das box-shadow:none der
     Downloadliste). Die Probe liegt in einem fremden Los. Der Teal-Wert ist dabei durch den
     Graphit-Wert ersetzt — die Zahlen der Geometrie bleiben, die Farbe nicht.
     WIRKSAM ist die Zeile darunter: es gibt in diesem System keine Schatten. */
  box-shadow: 0 12px 26px rgba(20, 22, 26, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.18);
  box-shadow: var(--shadow);
  border: 1px solid var(--gruent);
  /* DIE FASE am Primaerknopf: 45-Grad-Schnitt an zwei gegenueberliegenden Ecken, in der
     Diagonalen des Schnurzugs. Bewusst EIN Element und nicht das .rahmen-Paar: der Knopf
     wird in elf Views gebaut, ein zusaetzlicher Wrapper waere elf fremde Dateien. Der
     Preis ist ehrlich: die 1-px-Kante fehlt auf den zwei geschnittenen Diagonalen, weil
     clip-path den border mitschneidet. Auf den vier geraden Seiten steht sie. */
  clip-path: var(--fase-schnitt);
  /* `filter` stand hier in der Liste, ohne dass irgendein Knopfzustand einen Filter setzt —
     eine Übergangsspur ins Leere. Gestrichen. */
  transition: transform var(--t-tap) var(--ease), box-shadow var(--t-fast) var(--ease);
}
.btn:active { transform: scale(.99); background: var(--gruenp); }
.btn:disabled { opacity: .55; }
/* Die drei Nebenformen tragen die Fase NICHT: sie ist das Zeichen der EINEN
   Primaerhandlung. Zwei gefaste Knoepfe nebeneinander erklaeren nichts mehr.
   clip-path: none nimmt sie deshalb ausdruecklich zurueck. */
.btn.secondary, .btn.ghost, .btn.small, .btn.danger { clip-path: none; }
.btn.secondary { background: var(--surface); color: var(--text); box-shadow: var(--shadow); border: 1px solid var(--border-strong); min-height: max(48px, calc(48px * var(--ui-scale))); }
.btn.secondary:active { background: var(--surface-2); }
/* [WEBSITE-ANGLEICH 20.08.2026] DER GEISTERKNOPF GIBT DAS GRUEN AB — es war das fuenfte Amt.
   Die Hausregel oben im Tokenkopf zaehlt vier: (a) aktiver Reiter (b) Fokusring
   (c) Zustandsknoten (d) DER EINE Primaerknopf. Ein `.btn.ghost` ist per Definition die
   NEBENhandlung — er ist genau der Knopf, der NICHT (d) ist. Ihn in derselben Farbe zu
   setzen wie den Primaerknopf nimmt der Farbe die Aussage.
   GEMESSEN am Rig (8911, 390x844, hell), Reiter „Anwesenheit": auf EINEM Bildschirm standen
   gleichzeitig der gefuellte gruene Primaerknopf „Arbeitstag beenden" (18.368 px2), die
   gefuellte gruene Zustandsflaeche „Angemeldet" (22.984 px2) und ZWEI gruene Geisterknoepfe
   („QR scannen", „Baustelle bestaetigen", zusammen 12.373 px2). Vier gruene Handlungs-
   signale, von denen genau eines die Haupthandlung ist. Im Chat dasselbe mit „Archiv
   anzeigen", in den Einstellungen mit „Zwischenspeicher fuer Bilder leeren".
   ER TRAEGT JETZT DIE TINTE, DIE JEDER ANDERE NEBENKNOPF TRAEGT. `.btn.secondary` daneben
   macht es seit jeher so (`color: var(--text)`), und die beiden erklaeren nebeneinander
   endlich dasselbe. GERECHNET: die Beschriftung steigt von 5,18:1 auf 18,11:1 (hell, auf
   --fl) bzw. 15,91:1 (dunkel) — der Knopf wird also nicht leiser, sondern LESBARER; nur
   sein Farbversprechen faellt weg. Dass er ein Knopf ist, sagen weiter seine 48 px
   Trefferflaeche, seine Beschriftung im Verbform-Ton und die Rueckmeldung beim Druecken.
   BEWUSST NICHT GETAN: ihm eine Kante geben. Dann waere er `.btn.secondary` — und zwei
   Namen fuer dieselbe Gestalt sind eine Gestalt zu viel. Der Unterschied zwischen beiden
   bleibt, was er war: `secondary` hat eine Kante, `ghost` hat keine.
   Die Rueckmeldung beim Druecken war ebenfalls gruen (`--gruenw`) und ist jetzt --fl-tap —
   das ist der Token, der genau dafuer da ist („dieselbe Flaeche unter dem Finger"). Ein
   gruener Aufblitzer haette dieselbe Zusage nur um 90 ms verzoegert wiederholt.
   Die min-height bleibt Zeichen fuer Zeichen stehen: tests/stil_schnurmass nagelt die 48 px
   in der max()-Schreibweise fest, und das ist Geometrie, nicht Farbe. */
.btn.ghost { background: none; color: var(--text); box-shadow: var(--shadow); border: 0; min-height: max(48px, calc(48px * var(--ui-scale))); }
.btn.ghost:active { background: var(--fl-tap); }
/* Rot heisst irreversibel. Eine FLAECHE, kein Verlauf: ein Verlauf hat keinen
   Kontrastwert, sondern zwei. Weiss auf --danger-fill misst 6,95:1. */
.btn.danger { background: var(--danger-fill); color: var(--on-danger); border: 1px solid var(--danger); box-shadow: var(--shadow); }
.btn.danger:active { background: var(--danger-fill); }
.btn.small { min-height: max(44px, calc(44px * var(--ui-scale))); width: auto; padding: 9px 14px; font-size: calc(14px * var(--ui-scale)); border-radius: var(--radius); }

/* ------------------------------------------------ Downloadliste „Projekte auf diesem Handy"
   [X3 2026-08-03] DRITTE MELDUNG desselben Befunds: „die Herunterladen-Knoepfe stehen noch
   immer nicht sauber untereinander" (js/views/settings.js, ladeOfflineProjekteKarte).

   WAS NACHGEMESSEN WURDE (Chromium headless mit GENAU dieser Datei, echte Projektnamen der Org
   in ihrer echten Form „25014 RK Kautenbach", 10 Zeilen, Fensterbreite 375 px ⇒ Karte innen
   313 px; dasselbe Bild bei 320 und 430 px):
     VORHER  linke Knopfkanten  x = 204,00 fuer alle zehn            ⇒ Spanne 0,00 px
             Zeilenhoehen       59 · 59 · 59 · 59 · 59 · 59 · 68,5 · 59 · 59 · 68,5
             Knopf-zu-Knopf     59 · 59 · 59 · 59 · 59 · 63,75 · 63,75 · 59 · 63,75
     NACHHER Zeilenhoehen       68,69 fuer alle zehn                 ⇒ Spanne 0,00 px
             Knopf-zu-Knopf     68,69 durchgehend                    ⇒ Spanne 0,00 px

   DIE WAAGERECHTE WAR ALSO SCHON IN ORDNUNG (das war der Griff von W15: `flex:1 1 auto` statt
   der ausserhalb von `.row` wirkungslosen Klasse `grow`). Uebrig blieb die SENKRECHTE: die
   Zeilenhoehe haengt am Inhalt. Ein Name, der auf zwei Zeilen umbricht (2 x 1,25 em = 40 px),
   plus die Zustandszeile „auf diesem Handy" (14 px) ergibt 54 px und schiebt die Zeile ueber
   die 44 px des Knopfes; eine Zeile ohne beides bleibt bei 44. `align-items:center` traegt den
   Knopf dann um bis zu 4,75 px mit. Drei verschiedene Abstaende in einer Spalte — genau das,
   was man als „nicht sauber untereinander" sieht, ohne es an einer einzelnen Kante festmachen
   zu koennen. DESHALB wird beides RESERVIERT statt weggelassen: der Namenskasten haelt seine
   zwei Zeilen immer frei, die Zustandszeile ihre eine — auch leer.

   KEIN SCHATTEN IN DIESER LISTE: `.btn` traegt `box-shadow: 0 12px 26px` (Zeile 723). Gerechnet
   reicht dieser Schein vom Knopfboden 12 px nach unten plus 26 px Weichzeichnung, also bis
   38 px unter den Knopf — bei 68,69 px Zeilenabstand und 44 px Knopfhoehe beginnt der naechste
   Knopf 24,69 px darunter. Der Schein des einen liegt also ueber dem Kopf des naechsten, und
   weil nur die gruenen Knoepfe („Herunterladen") ihn haben und die grauen („Entfernen", .secondary
   setzt box-shadow:none) nicht, wirkt die Spalte abwechselnd schwer und leicht. In einer dichten
   Liste ist der CTA-Schatten fehl am Platz — er bleibt ueberall sonst unveraendert. */
.dl-zeile { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); }
.dl-zeile .dl-text { flex: 1 1 auto; min-width: 0; }
.dl-zeile .dl-name {
  font-weight: 600; line-height: 1.25; min-height: 2.5em;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.dl-zeile .dl-stand { font-size: calc(12px * var(--ui-scale)); line-height: 1.35; min-height: 1.35em; }
/* [stil 2026-08-06] Der Knopf dieser Liste setzt Breite UND Hoehe selbst — beide muessen deshalb
   auch selbst auf die Textgroessen-Leiter. Ohne die Breite bliebe „Herunterladen" bei Stufe 1,3
   (16 px -> 20,8 px) in einem 140-px-Kasten stehen und wuerde abgeschnitten. GERECHNET fuer die
   Karte bei 375 px Fensterbreite (innen 313 px): 140 -> 182 px, fuer den Namen bleiben 131 px —
   er ist ohnehin zweizeilig geklammert. */
.dl-zeile .btn { flex: none; width: max(140px, calc(140px * var(--ui-scale))); min-height: max(44px, calc(44px * var(--ui-scale))); padding-inline: 0; box-shadow: none; }

label.field { display: block; font-weight: 800; margin: 14px 2px 6px; font-size: calc(14px * var(--ui-scale)); }
/* [SCHNURMASS] Das Eingabefeld ist EINGELASSEN, nicht aufgesetzt: Flaeche --fl2, unten
   eine 2-px-Bedienteil-Kante, sonst Haarlinie. Das ist der Instrument-Zug — man sieht,
   wo man schreibt, ohne dass ein zweiter Kasten auf der Karte steht. */
input.control, select.control, textarea.control {
  width: 100%; min-height: 50px; padding: 13px 14px; border-radius: var(--radius);
  border: 1px solid var(--border); border-bottom: 2px solid var(--border-strong);
  background: var(--surface-2); color: var(--text); outline: none;
}
input.control:focus, select.control:focus, textarea.control:focus { border-color: var(--gruent); border-bottom-color: var(--gruen); box-shadow: var(--focus-ring); }
/* [SICHT-5 2026-08-25] DERSELBE SCHNITT WIE AM SUCHFELD, nur an der anderen Bauform.
   GEMESSEN bei 320 px / Stufe 1,3: der Platzhalter des Chat-Suchfeldes (js/views/chat.js,
   Aufrufform `placeholder: tr("Kanal oder Nachricht suchen …")`, ein input.control — die
   frueher hier stehende Zeilennummer 3809 ist als Anker aufgegeben, die Datei waechst taeglich)
   endete als „Kanal oder Nachricht suc" — mitten im Wort, ohne jedes
   Zeichen dafuer, dass da noch etwas steht. Mit dieser Zeile: „Kanal oder Nachricht s…"
   (Bildvergleich vorschau_chatsuche_vorher.png gegen vorschau_chatsuche_nachher.png).
   WARUM NUR input UND select, und nicht textarea: text-overflow greift nur auf einer Zeile,
   die NICHT umbricht. Ein Textfeld bricht um — die Angabe waere dort wirkungslos und
   behauptete eine Wirkung, die es nicht gibt. Beim Auswahlfeld greift sie (der gewaehlte
   Eintrag ist einzeilig), beim Datumsfeld ist sie folgenlos, weil dessen Wert aus
   Browser-Bauteilen besteht und gar nicht ueberlaeuft.
   ES AENDERT NICHTS AM EINGETIPPTEN TEXT im Fokus: waehrend des Schreibens rollt das Feld mit
   der Schreibmarke, die Ellipse erscheint erst wieder in Ruhe. */
input.control, select.control { text-overflow: ellipsis; }
/* [SICHT-C2 2026-08-25] OFFENER PUNKT AN DIESER BAUFORM, ausgesprochen statt verschwiegen:
   die `input.control`-Suchfelder (Chat, Planliste) tragen `type: "search"` und bekommen damit
   vom Browser einen EIGENEN Loeschknopf ins Feld gemalt — gemessen 21..26 px breit, also unter
   der 44-px-Hausschranke, ohne Namen im Zugaenglichkeitsbaum und ZERSTOEREND. Am Bauteil
   `.suche` ist er abgeschaltet, weil dort ein eigener 44-px-Knopf an seine Stelle tritt
   (Begruendung dort, Stichwort [SICHT-C2]). HIER wird er NICHT abgeschaltet: es gibt keinen
   Ersatz, und der Chat horcht sogar auf sein Ereignis. Ein Abschalten ohne Ersatz waere eine
   Funktion weniger. Der Handgriff gehoert dorthin, wo das Feld gebaut wird — js/views/chat.js
   und js/views/project.js, beides fremde Dateien. Als Fremdbedarf gemeldet. */
textarea.control { min-height: 92px; resize: vertical; }
/* ---------------------------------------------- DAS DATUMSFELD IST DER EINE SONDERFALL
   [SPRACHE L15-02 2026-08-25] GEMESSEN mit grep ueber styles.css UND ueber alle im JS
   eingebetteten CSS-Literale (js/pin/pin_style.js, js/views/pano.js, js/views/chat.js):
   der Selektor `input[type="date"]` kam im ganzen Baum NULL mal vor. Das Feld trug
   ausschliesslich `.control` (width:100%) und den 16-px-Boden gegen den iOS-Fokus-Zoom.
   Fuer Text, Select und Textarea genuegt das; `type=date` ist der EINZIGE Bedienteil der
   App mit einer vom BROWSER mitgebrachten, nicht vom Text abgeleiteten Mindestbreite.
   GEGENMESSUNG in Chromium (Edge headless, --lang de-DE/en-US/fr-FR/es-ES, Textstufen
   1 / 1,15 / 1,3): min-content 157 / 174 / 192 px (de) bzw. 154 / 171 / 189 px (en/fr/es);
   `width:50px` ergab dort tatsaechlich 50 px, und in 24 Messungen (320/360/375/414 px mal
   drei Textstufen, beide Bauformen) trat KEIN Ueberlauf auf. Chromium klemmt also sauber —
   die Klage kommt von der zweiten Maschine (WebKit/iOS), und dort sind es genau diese drei
   fehlenden Angaben, die das Feld schrumpfbar machen.
   time und datetime-local stehen mit drin: gleiche Bauart, gleiche Browser-Mindestbreite.
   [NACHZUG 2026-08-25] „time … heute im Baum kein einziges Vorkommen" stand hier und ist am
   selben Tag ueberholt worden — NACHGEZAEHLT am heutigen Baum: SECHS `type="time"`-Felder
   (js/views/attendance.js Beginn/Ende/„Uhrzeit (heute)", js/views/todos.js im Bearbeiten-, im
   Anlege- und im Stapel-Blatt, alle aus L12-02/L12-04), `datetime-local` weiterhin null. Die
   Vorsorge ist also keine mehr; die Zeile traegt jetzt sechs echte Felder mit.
   NACHGESEHEN, damit die Regel auch wirklich jedes Feld erreicht: JEDES `type=date`- und
   `type=time`-Feld im Baum traegt `class: "control"` (js/views/todos.js, js/views/attendance.js,
   js/pin/pin_workspace.js — kein einziges nacktes). Darunter ist auch das Feld aus dem
   Betreiberpunkt 15: die Tagesnotiz am Pin (js/pin/pin_workspace.js:zeichneTagesnotiz) baut ihr
   Datum OHNE eigene Breitenangabe, nur mit `.control` (width:100%) — genau der Fall, den erst
   `min-width: 0` schrumpfbar und `max-width: 100%` randfest macht. */
input[type="date"].control, input[type="time"].control, input[type="datetime-local"].control {
  -webkit-appearance: none; appearance: none;
  min-width: 0; max-width: 100%;
}
input[type="date"].control::-webkit-date-and-time-value { text-align: left; margin: 0; }
input[type="date"].control::-webkit-calendar-picker-indicator { margin-inline-start: 0; }

.chips { display: flex; flex-wrap: wrap; gap: 8px; }
/* -------------------------------------------------------- WAAGERECHT SCROLLENDE CHIP-LEISTE
   [stil 2026-08-06] Die Schwester von `.chips` fuer die Faelle, in denen Umbruch zu teuer ist:
   die Schnellfilter der Aufgaben (js/views/todos.js:862) haetten umbrechend GEMESSEN fuenf
   Zeilen a ~52 px belegt und die erste Aufgabe unter den Bildschirmrand geschoben; dieselbe
   Bauform tragen die Etiketten-Leiste (:876) und die Ordnungs-Leiste daneben.
   WARUM DIE KLASSE UEBERHAUPT NOETIG IST, obwohl alle drei Leisten ihre Regeln heute INLINE
   nachbauen: `::-webkit-scrollbar` laesst sich in einem style-Attribut nicht setzen. Auf einem
   Tablet mit Maus (WebKit/Chromium ohne --hide-scrollbars) taucht deshalb unter den Chips ein
   Rollbalken auf und frisst die untersten Pixel der 44-px-Trefferflaeche. Am Messrig war das
   NICHT zu sehen, weil Edge dort mit --hide-scrollbars laeuft — also unbelegt, aber die
   naechstliegende Erklaerung, und der einzige Teil, den Inline gar nicht loesen KANN.
   EHRLICHE GRENZE, wie bei `.js-sheet-foot`: die Regel wirkt erst, wenn ein Bauort sie
   benutzt. Die drei Bauorte stehen mit Datei und Zeile im Bericht; sie gehoeren dieser Welle
   nicht. Bis dahin aendert diese Regel kein Pixel — sie nimmt nur den naechsten drei Leisten
   die Versuchung, denselben Satz ein viertes Mal abzuschreiben.
   Kein `flex-wrap`: dass NICHT umgebrochen wird, ist der ganze Zweck (Vorgabe `nowrap`). */
.scrollrow {
  display: flex; gap: 8px;
  overflow-x: auto; overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.scrollrow::-webkit-scrollbar { display: none; }
.chip {
  /* [SCHNURMASS] Radius 4 statt der Pille: die App ist ein Instrument. Die 42 px der
     abgenommenen Tafel sind hier BEWUSST 44 geblieben — Entwurfsregel 6 nennt 44 px als
     Untergrenze fuer alles Uebrige, und index.html haelt denselben Boden fuer .chip-reaction. */
  padding: 7px 12px; border-radius: var(--radius); border: 1px solid var(--border-strong);
  background: var(--surface); font-size: calc(13px * var(--ui-scale)); font-weight: 700; color: var(--text-muted); cursor: pointer;
  min-height: max(44px, calc(44px * var(--ui-scale))); display: inline-flex; align-items: center; /* Touch-Ziel ≥ 44 px */
}
.chip { transition: transform var(--t-tap) var(--ease), background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease); }
.chip:active { transform: scale(.96); }
.chip.active { background: var(--gruenw); border-color: var(--gruent); color: var(--accent-ink); }
/* Gesperrte Chips (Nur-Lese-Rolle/Lizenz) sichtbar absetzen — vorher sahen sie voll bedienbar aus
   und taten nichts. Bewusst OHNE Hover-Tooltip (Projektverbot); den Klartext liefert der
   Nur-Lese-Banner im Sheet. opacity + gestrichelte Kante tragen in HELL wie DUNKEL, weil sie auf
   den bereits theme-abhängigen Variablen aufsetzen und keine feste Farbe einführen. */
.chip[disabled] {
  opacity: .5; cursor: not-allowed; border-style: dashed;
}
.chip[disabled]:active { transform: none; }
/* Sprachwahl auf dem Login-Screen: zentrierte Chip-Zeile oberhalb des Formulars.
   Als Klasse statt style-Attribut (CSP-Regel); Grundform kommt von .chips/.chip. */
.login-langrow { justify-content: center; margin: 0 0 14px; }
/* Sprach-Chip mit Flagge + Kürzel (statt ausgeschriebenem Namen): kompakt, Touch-Ziel bleibt 44px. */
.lang-chip { gap: 7px; }
.lang-chip .lang-flag {
  width: 20px; height: 15px; flex: none; display: block; border-radius: 3px; object-fit: cover;
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18);
}
.lang-chip .lang-code { font-size: calc(12px * var(--ui-scale)); font-weight: 800; letter-spacing: 0.03em; }

/* [SCHNURMASS] Die Plakette: Tonflaeche + Signaltinte + 1 px SIGNALKANTE. Die Kante ist
   nicht Zierde — ohne sie liegt eine sehr helle Tonflaeche fast unsichtbar auf --fl, und
   die Aussage haengt allein an der Textfarbe (Zwei-Ton-Gesetz). Radius 2, Hoehe 19,
   Schrift in Saira und gesperrt: eine Plakette ist ein Etikett, kein Knopf — sie ist NIE
   klickbar. Die vorherigen rgba()-Toenungen waren feste Farben und trugen im dunklen
   Thema nicht mit; jetzt tragen sie ueber die Themen-Token. */
.badge {
  /* [G14 2026-08-21] SCHRIFT UND HOEHE FOLGEN JETZT DER TEXTGROESSE.
     Vorher stand hier `font-size: 11px` und `min-height: 19px` — beide hart. Damit war die
     Plakette die einzige Schrift im Haus, die bei „Gross" und „Sehr gross" unveraendert klein
     blieb; ausgerechnet die Zeile, die den Zustand meldet („OFFEN", „UEBERFAELLIG", „MAX").
     11px IST der Wert des Tokens --fs-rubrik (styles.css:287) — die Zahl aendert sich bei
     Skala 1 also NICHT, sie haengt nur endlich an der Leiter.
     Die Hoehe waechst mit, sonst schnuerte die feste 19px die groessere Schrift ab.
     GEMESSEN bei 393 px und Skala 1,3: die Plakette ist 42,4 px breit, nichts abgeschnitten. */
  display: inline-flex; align-items: center; gap: 5px; padding: 1px 6px;
  min-height: max(19px, calc(19px * var(--ui-scale)));
  border-radius: var(--radius-klein); border: 1px solid transparent;
  font-family: var(--f-display); font-size: calc(var(--fs-rubrik) * var(--ui-scale)); font-weight: 700; letter-spacing: .1em;
  text-transform: uppercase;
}
.badge.ok { background: var(--gruenw); color: var(--ok-text); border-color: var(--gruent); }
.badge.warn { background: var(--warnw); color: var(--warn-text); border-color: var(--warn); }
.badge.danger { background: var(--dangerw); color: var(--danger-text); border-color: var(--danger); }
.badge.muted { background: var(--surface-2); color: var(--text-muted); border-color: var(--border-strong); }

/* ---------------------------------------------------------------- sync status pill
   Prominenter, verständlicher Sync-/Verbindungsstatus in der Appbar. Die Farbe folgt dem Zustand
   (synchron = grün, läuft = teal, wartet/offline = amber, Fehler = rot); der Punkt trägt einen
   weichen Ring. app.js setzt data-state (""|waiting|syncing|synced|offline|error) und zusätzlich
   data-connection (online|connecting|offline|mixed_content). */
.syncpill {
  display: inline-flex; align-items: center; gap: 7px; padding: 6px 12px; border-radius: var(--radius);   /* [S19] war 999px — Sync-Pille in der Titelzeile */
  /* [G14 2026-08-21] Auch die Sync-Pille folgt der Textgroesse. Vorher `12.5px` hart —
     eine Zahl, die auf keiner Leiter steht. Sie kommt jetzt aus --fs-meta (13px, styles.css:286);
     der halbe Pixel Unterschied bei Skala 1 ist der Preis dafuer, dass der Wert ueberhaupt
     einen Ort hat. GEMESSEN bei 393 px und Skala 1,3: die Pille ist 70,2 px breit von
     erlaubten 227,9 px — die Ellipse eine Zeile weiter unten greift also weiterhin nicht. */
  font-size: calc(var(--fs-meta) * var(--ui-scale)); font-weight: 800; line-height: 1; border: 1px solid var(--border);
  background: var(--surface); color: var(--text-muted);
  max-width: min(58vw, 240px);
  transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.syncpill > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.syncpill .dot { width: 9px; height: 9px; flex: none; border-radius: 50%; background: var(--text-faint); }
/* Eigenes Status-Symbol (KEIN Emoji): erbt über currentColor die zustandsabhängige Pill-Farbe.
   [stil 2026-08-06] HIER STANDEN 15 px — das einzige Symbolmass dieser Datei, das auf KEINER
   der drei Geraetedichten auf ganze Pixel faellt (1,25 / 2,5 / 3,75 Geraetepixel Strichbreite;
   Rechnung bei --ic-klein im Token-Kopf). Ein 2er-Strich, der auf 2,5 Geraetepixel faellt,
   wird vom Browser auf drei halbhelle Reihen verteilt — genau das liest sich als „unscharf",
   und zwar auf dem Symbol, das in der Titelzeile dauerhaft sichtbar ist.
   12 px ist die einzige Stufe unter 24, die auf dpr 1, 2 UND 3 traegt.
   DIE APPBAR WIRD DAVON NICHT SCHMALER ODER HOEHER: ihre Hoehe kommt vom 44-px-`.iconbtn`
   daneben, nicht von der Pille (Pille mit 12er Symbol: 12 + 2x6 Polster + 2 Rahmen = 26 px,
   also weit unter 44). Und die 44-px-Trefferflaeche haengt am ::after, nicht am Symbol —
   die Mindestbreite bleibt erfuellt: 2x12 Polster + 12 Symbol + 7 Luecke + 8 aus ::after
   = 51 px, unter 407 px Bildschirmbreite (Polster 9) 45 px. Beides >= 44. */
.syncpill .ic { width: var(--ic-klein); height: var(--ic-klein); flex: none; }
/* Läuft-Zustand: das Sync-Symbol dreht dezent (nur transform, respektiert reduzierte Bewegung). */
.syncpill[data-state="syncing"] .ic { animation: ic-spin var(--t-pulse) linear infinite; }
@media (prefers-reduced-motion: reduce) { .syncpill[data-state="syncing"] .ic { animation: none; } }
@keyframes ic-spin { to { transform: rotate(360deg); } }
/* [SICHTPRUEFUNG 19.08.2026] DIE PILLE IM ZUSTAND „SYNCHRON" WAR DER SCHWAECHSTE TEXT DER
   TITELZEILE. Hier stand `color: var(--ok-text)` (= --gruent #2C7D16) auf einer zu 12 %
   gruen getoenten Flaeche. GEMESSEN am Rig auf ALLEN SECHS Reitern, hell: 4,03:1 bei 12,5 px
   — unter der AA-Schwelle 4,5:1 und weit unter den 7:1, die diese Groesse fuer AAA braucht.
   Auf --fl allein waeren es 5,18:1; die Toenung frisst den Rest.
   DER FEHLER IST NICHT DIE ZAHL, SONDERN DIE ROLLENVERTEILUNG. Das Gruen hat vier Aemter,
   und eines davon heisst „Zustandsknoten" — der PUNKT ist der Traeger, nicht das Wort. Er
   traegt es eine Zeile weiter unten bereits (--ok, mit statischem Schein). Das Wort gehoert
   in Tinte. NACHHER: Flaeche --fl, Wort --ink2, Kante --line2. GERECHNET 7,76:1 auf --fl
   (AAA bei jeder Groesse), im dunklen Thema #AEB3B9 auf #14171B = 8,40:1.
   NEBENWIRKUNG, die erwuenscht ist: die Titelzeile verliert damit ihre zweite gruene
   Flaeche und behaelt nur den 9-px-Zustandspunkt. */
.syncpill[data-state="synced"] {
  color: var(--ok-text); border-color: rgba(22, 163, 74, 0.32);
  background: rgba(22, 163, 74, 0.12);
  background: color-mix(in srgb, var(--ok) 12%, var(--surface));
  color: var(--ink2); background: var(--fl); border-color: var(--line2);
}
.syncpill[data-state="synced"] .dot { background: var(--ok); box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.18); }
/* [VERBINDUNG 2026-08-20] DAS SIGNAL SASS AN EINEM ELEMENT, DAS ES NICHT GIBT.
   Die Regel eine Zeile hoeher faerbt `.dot` — die Pille rendert aber seit Langem ein SYMBOL mit
   der Klasse `.ic` (app.js:renderSyncPill, `icon(z.glyphName, { cls: "ic" })`) und keinen Punkt.
   Alle fuenf `.dot`-Regeln dieser Datei greifen deshalb ins Leere. Folge: der Satz „die
   Titelzeile behaelt nur den 9-px-Zustandspunkt" aus der Begruendung oben ist nie eingetreten,
   und „verbunden" war GEMESSEN durchgehend grau (#4E5359 auf Weiss) — waehrend „Offline"
   bernstein leuchtet. Wer hinsah, bekam nur die Warnung als Farbe, nie die Entwarnung.
   Das Wort bleibt in Tinte (die Kontrastrechnung oben gilt unveraendert); die Farbe wandert an
   das Symbol, das dafuer gebaut ist. --gruent statt --ok: 5,18:1 auf --fl, also auch als
   Nicht-Text-Signal weit ueber den geforderten 3:1.
   Die uebrigen Zustaende brauchen nichts: dort faerbt `color` die ganze Pille und das Symbol
   erbt sie ueber currentColor. */
.syncpill[data-state="synced"] .ic { color: var(--gruent); }
.syncpill[data-state="syncing"] {
  color: var(--info-text); border-color: var(--navy-bright);
  background: var(--gruenw);
  background: color-mix(in srgb, var(--navy) 12%, var(--surface));
}
/* [stil 2026-08-06] KEIN ATMENDES BLINKEN AUF EINER ZUSTANDSANZEIGE.
   Hier stand `animation: pulse var(--t-pulse) var(--ease-io) infinite` — die Deckkraft des
   Punktes fiel im 1,2-s-Takt endlos von 1 auf 0,35 und zurueck. Kapitel 10 (Auftrag 19, Rang
   MUSS) verbietet genau das: „kein Dauerpulsieren; laufende Vorgaenge zeigen einen ruhigen
   Balken oder statischen Schein, niemals atmendes Blinken", und laesst als einzige endlose
   Bewegung die Ladeanzeige zu.
   DIE PILLE VERLIERT DABEI NICHTS. „Laeuft gerade" tragen in dieser Zeile schon drei Dinge:
   das WORT (data-state setzt den Text, Markenhandbuch §7 „Zustand = Wort + Farbe"), die FARBE
   (Teal statt Gruen/Amber/Rot) und das DREHENDE Symbol eine Regel weiter oben — und die
   Dauerdrehung ist laut Bewegungsvertrag §J ausdruecklich die Rolle des Ladekringels.
   Der Punkt bekommt stattdessen denselben statischen Schein, den der Zustand „synchron"
   in dieser Datei bereits fuehrt: eine Aussage, eine Bauform, kein zweiter Takt daneben. */
.syncpill[data-state="syncing"] .dot { background: var(--navy-bright); box-shadow: 0 0 0 3px var(--gruenw); }
.syncpill[data-state="offline"],
.syncpill[data-state="waiting"] {
  color: var(--warn-text); border-color: rgba(217, 119, 6, 0.32);
  background: rgba(217, 119, 6, 0.12);
  background: color-mix(in srgb, var(--warn) 12%, var(--surface));
}
.syncpill[data-state="offline"] .dot,
.syncpill[data-state="waiting"] .dot { background: var(--warn); }
.syncpill[data-state="error"] {
  color: var(--danger-text); border-color: rgba(220, 38, 38, 0.32);
  background: rgba(220, 38, 38, 0.12);
  background: color-mix(in srgb, var(--danger) 12%, var(--surface));
}
.syncpill[data-state="error"] .dot { background: var(--danger); }
/* Hier standen die Abmeldung des Punkt-Pulses bei reduzierter Bewegung und `@keyframes pulse`.
   Beide sind mit dem Puls selbst entfallen (Begruendung eine Regel weiter oben); `pulse` hatte
   danach null Verwender. Die Drehung des Symbols meldet sich weiterhin selbst ab. */

/* ---------------------------------------------------------------- banners */
.banner { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: var(--radius); margin-bottom: 12px; font-size: calc(14px * var(--ui-scale)); font-weight: 600; }
/* [SCHNURMASS] Tonflaeche + Signaltinte + 3-px-Signalkante LINKS. Vorher standen hier feste
   rgba-Toenungen: sie trugen im dunklen Thema nicht mit, weshalb es darunter drei reine
   color-Zwillinge brauchte. Jetzt tragen Flaeche UND Tinte ueber die Themen-Token, und die
   Zwillinge sind mit entfallen. Bernstein heisst „braucht deinen Handgriff", Rot heisst
   „irreversibel" — das Band sagt beides mit der Kante, nicht nur mit der Schriftfarbe. */
.banner.warn { background: var(--warnw); border: 1px solid var(--warn); border-left-width: 3px; color: var(--warn-text); }
.banner.danger { background: var(--dangerw); border: 1px solid var(--danger); border-left-width: 3px; color: var(--danger-text); }
.banner.info { background: var(--gruenw); border: 1px solid var(--gruent); border-left-width: 3px; color: var(--info-text); }
/* Auto-Theme (Standard!) + Systemeinstellung dunkel: dieselben Farben wie oben. Ohne diesen Zweig
   griff nur der explizite Umschalter — im Auto-Modus stand die dunkle Schrift (#06695a/#92400e)
   auf dunklem Grund (~2,4:1). Gleiche Werte, keine neuen Farben; Muster wie bei .connect-mixed. */
@media (prefers-color-scheme: dark) {
}

/* ---------------------------------------------------------------- login */
/* Login/Boot-Screen: eigener Scroll-Container (das Dokument scrollt nie) — zentriert, aber
   scrollbar, falls der Inhalt (kleine Displays + Tastatur) höher als der Viewport wird.
   SAFE-AREA: das frühere pauschale `padding: 24px` war die größte Lücke der ganzen App — mit
   black-translucent beginnt der Inhalt bei y=0, d. h. auf Geräten mit Dynamic Island/Notch lagen
   Karten-Oberkante und das Wegklick-X unter der Statusleiste, unten lag der letzte Button auf dem
   Home-Indicator. Jetzt: 24px Grundpolster PLUS Inset oben/unten, seitlich max(24px, Inset) fürs
   Querformat. Diese Regel trägt auch den Boot-Platzhalter aus index.html (Spinner vor dem ersten
   Render) — der Screen ist damit von der allerersten Millisekunde an safe-area-korrekt. */
.auth-wrap {
  display: grid; place-items: center;
  /* „safe": bei Inhalt höher als der Viewport oben andocken statt oben abzuschneiden. */
  place-items: safe center;
  height: 100%;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  padding:
    calc(24px + var(--safe-top))
    max(24px, var(--safe-right))
    calc(24px + var(--safe-bottom))
    max(24px, var(--safe-left));
}
.auth-card { width: min(420px, 100%); position: relative; }
/* Wegklick-X oben rechts: Login schliessen und die (leere) App als Gast betreten. */
.auth-close {
  position: absolute; top: 8px; right: 8px; z-index: 2;
  width: 44px; height: 44px; display: grid; place-items: center;
  border: 0; background: none; color: var(--text-muted); cursor: pointer;
  border-radius: var(--radius);
  transition: color var(--t-fast) var(--ease), background var(--t-fast) var(--ease), transform var(--t-tap) var(--ease);
}
.auth-close:hover, .auth-close:focus-visible { color: var(--text); background: var(--surface-2); }
/* Berührungsantwort: der Knopf hatte nur :hover — auf dem Handy also gar keine. */
.auth-close:active { transform: scale(.94); }
.auth-logo { display: grid; justify-items: center; gap: 10px; margin-bottom: 8px; }
.auth-logo .auth-app-icon { width: 74px; height: 74px; border-radius: var(--radius); box-shadow: var(--shadow); }
.auth-logo h1 { margin: 0; font-size: calc(24px * var(--ui-scale)); }
.auth-logo p { margin: 0; color: var(--text-muted); }
/* SchnurSolutions-Logo (echtes Marken-SVG), theme-adaptiv: Standard = on_light (heller/System-heller
   Grund), dunkles Theme = on_dark. Beide vorgecacht; nur das passende wird eingeblendet. */
.auth-brand { width: min(230px, 78%); height: auto; margin-top: 2px; }
.auth-brand--dark { display: none; }
.auth-brand--light { display: block; }
:root[data-theme="dark"] .auth-brand--light { display: none; }
:root[data-theme="dark"] .auth-brand--dark { display: block; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .auth-brand--light { display: none; }
  :root:not([data-theme="light"]) .auth-brand--dark { display: block; }
}
/* Auf niedrigen Bildschirmen (Handy hochkant) zaehlt der Absendeknopf mehr als ein grosses Logo.
   BEFUND „wichtig" (recon2/04): Der Knopf „Bei Organisation anmelden" lag GEMESSEN bei y=970..1020,
   also 118 px UNTER der Falz (Sichtfenster 852) — der Nutzer fuellte drei Felder aus und fand ihn
   nicht. Sprachwahl und „Ohne Anmeldung ansehen" sind dafuer unter das Formular gewandert
   (views/login.js); die restlichen ~60 px kommen aus diesem Block, der bei 852 px Hoehe 256 px
   gross war. Auf Tablet/Desktop (>900 px hoch) bleibt die Marke unveraendert gross. */
@media (max-height: 900px) {
  .auth-logo { gap: 5px; margin-bottom: 2px; }
  .auth-logo .auth-app-icon { width: 48px; height: 48px; border-radius: var(--radius); }
  .auth-logo h1 { font-size: calc(20px * var(--ui-scale)); }
  .auth-logo p { font-size: calc(13px * var(--ui-scale)); }
  .auth-brand { width: min(160px, 56%); }
}

/* ---------------------------------------------------------------- plan viewer */
/* Höhe: erst die vh-Zeile als Fallback für alte Browser OHNE svh/dvh, dann svh (kleine
   Viewport-Höhe, überläuft NIE unter die iOS-Toolbar), dann dvh für Browser mit
   dynamic-viewport-Support (passt sich beim Ein-/Ausblenden der Toolbar an). Die Kaskade greift
   automatisch: eine Zeile mit unbekannter Einheit wird beim Parsen verworfen.
   ABGEZOGEN wird jetzt AUCH --tabbar-h (enthält den Home-Indicator-Inset bereits) — vorher kannte
   der CSS-Fallback nur die obere Safe-Area, und bis js/views/project.js:fitViewerHeight das erste
   Mal misst (bzw. falls es scheitert) ragte der Plan unter Tabbar und Home-Indicator.
   Die JS-Messung bleibt die Wahrheit; dieser Fallback ist nur ehrlich statt zu groß. */
/* [shell 2026-08-05] DER ABZUG WAR 60px, GEBRAUCHT WERDEN 110.
   Ueber dem Plan stehen zwei Leisten: die Appbar (54) und die Plan-Kopfzeile (56) — zusammen 110.
   Die Zahl ist nicht geraten: js/views/project.js rechnet in der Begruendung zu fitViewerHeight()
   woertlich mit „110 (Appbar+Plankopf)".
   Diese Regel ist NUR der Rueckfall: zur Laufzeit misst fitViewerHeight() den echten Abstand
   (getBoundingClientRect().top) und setzt die Hoehe selbst. Der Rueckfall traegt aber genau das
   erste Bild nach dem Aufbau — mit 60 statt 110 war der Viewer darin 50 px zu hoch, ragte unter
   die Tabbar und sprang beim ersten Messen sichtbar zurecht. */
.viewer { position: relative; width: 100%; height: max(240px, calc(100vh - 110px - var(--safe-top) - var(--tabbar-h))); height: max(240px, calc(100svh - 110px - var(--safe-top) - var(--tabbar-h))); height: max(240px, calc(100dvh - 110px - var(--safe-top) - var(--tabbar-h))); overflow: hidden; background: var(--surface-2); touch-action: none; overscroll-behavior: contain; }
.viewer canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.viewer .pinlayer { position: absolute; inset: 0; }

/* ------------------------------------------------ Plan-Kopfzeile (Bedienbarkeits-Welle recon2/04)
   BEFUND „wichtig": Über dem Plan lagen 229 px Bedienleisten = 26,9 % des Bildschirms — Appbar
   0..113, Zeile „Zurück" 121..165 UND eine eigene 50-px-Zeile nur für das Plan-Auswahlfeld
   (173..223). Der Viewer bekam davon nur 517 px (60,7 %). Beides steckt jetzt in EINER Zeile:
   ikonisches „Zurück" (44x44), Projektname, Plan-Chip (nur bei ≥2 Plänen), Offline-Chip. */
/* SICHERE BEREICHE, QUERFORMAT. GEMESSEN 2026-07-26 (Chromium 568x320, --safe-left: 59px, also
   ein gedrehtes Gerät mit Kerbe links): die Kopfzeile trug ein festes `padding: 6px 12px` und
   war damit die EINZIGE Fläche des Plan-Bildschirms ohne Inset-Bezug — Appbar, Inhalt, Tabbar,
   Layer-Leiste, Zoom-Stapel und Pin-Finder rechneten bereits mit max(…, var(--safe-*)).
   Folge: der Knopf „Zurück" (44x44) saß bei x = 12..56 und damit VOLLSTÄNDIG unter der Kerbe.
   max() lässt Hoch- und Desktop-Format unverändert, weil das Inset dort 0 ist. */
.planhead { display: flex; align-items: center; gap: 8px;
  padding: 6px max(12px, var(--safe-right)) 6px max(12px, var(--safe-left)); }
.planhead .planhead-name {
  font-weight: 800; flex: 1 1 auto; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Der Plan-Chip nimmt seine Inhaltsbreite (bis 46 %), der Projektname den Rest: GEMESSEN
   schrumpften vorher BEIDE proportional — der Chip landete bei 94 px und kürzte den PLANnamen,
   also ausgerechnet die Angabe, die man im Plan braucht. min-width:0 ist Pflicht, sonst kann ein
   inline-flex-Element nie unter seine Inhaltsbreite (Zeilenüberlauf). */
.planhead .planchip { flex: 0 0 auto; min-width: 0; max-width: 46%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.planhead .iconbtn { flex: none; }
/* ------------------------------------------------ DIE SEITENZAHL IST EINE ZAHL
   [PLAN L10-12 2026-08-25, CSS-Haelfte] Der Seitenwechsler schwebte als 184x50-Kaestchen
   (bei Textstufe 1,3: 233x63) mit 70 px Abstand zur rechten Viewerkante mitten auf dem
   Blatt — als einziges Bedienteil des Plans ohne Kantenbezug, waehrend Chipleiste
   (left: max(10px, var(--safe-left))) und Zoomstapel (right: max(12px, var(--safe-right)))
   auf festen Kanten stehen. Der Entwurf verlegt ihn als zweiten `.planchip` in den Plankopf,
   neben die Planwahl. Diese Regel ist die dafuer noetige CSS-Haelfte und die GANZE Zier:
   das Wort steht in der Textschrift, die Zahl in der Maschinenschrift mit tabellarischen
   Ziffern — dieselbe Begruendung, die die heutige `.pageinfo`-Regel schon fuehrt (eine
   springende Ziffernbreite laesst den Kopf bei jedem Blattwechsel zucken).
   Kein Rahmen, keine Farbe: Gruen bleibt fuer aktiven Reiter, Fokusring, Zustandsknoten und
   den EINEN Primaerknopf reserviert.
   OFFEN und im Bericht: der Bauort (js/views/project.js, drawPageBar/topbar) gehoert dem
   Los Plan; solange er den Chip nicht baut, hat diese Regel keinen Erzeuger und kostet
   nichts. */
.planhead .planchip .zahl {
  font-family: var(--f-mono); font-variant-numeric: tabular-nums;
  color: var(--text-muted); margin-inline-start: .4em;
}

/* ------------------------------------------------ Handy-Querformat (A7, [layout 2026-08-05])
   Ein gedrehtes Handy hat ~375-500 px Hoehe — die drei Leisten (Appbar, Plan-Kopfzeile, Tabbar)
   frassen davon zusammen ~160 px, dem Plan blieben unter 60 %. Ziel: >= 70 % der Hoehe fuer den
   Plan. NACHGERECHNET am Minimum der Trefferflaechen: Appbar und Plankopf tragen 44er-Knoepfe,
   die Tabbar 44er-Knoepfe + 8px Reserve — schlanker als ~40 + ~48 + ~54 geht ohne Bruch der
   44px-Regel nicht, und 40+48+54 = 142 sind bei 390 px Hoehe schon 36 %. Die 70 % sind mit
   sichtbarer Tabbar also ARITHMETISCH unerreichbar. Deshalb zweistufig:
     1. Alle drei Leisten kompakt (Polster runter, Tabbar-Beschriftung entfaellt — die Knoepfe
        tragen seit dieser Welle immer ein aria-label, app.js:renderTabbar).
     2. NUR in der Planansicht verschwindet die Tabbar ganz. CSS sieht die Route nicht, aber den
        BAUM: .viewer existiert ausschliesslich im offenen Plan (project.js), und :has darauf ist
        damit die Routen-Frage. Die Navigation bleibt erreichbar — der 44x44-„Zurueck"-Knopf der
        Plan-Kopfzeile fuehrt zur Liste, dort steht die Leiste wieder. Ohne :has-Unterstuetzung
        bleibt die schlanke Leiste stehen — weniger Plan, aber nichts kaputt.
   --tabbar-h wird hier BEWUSST NICHT uebersteuert: app.js schreibt den Wert als Inline-Style auf
   <html> (trackTabbarHeight), und Inline schlaegt jede CSS-Regel — eine Uebersteuerung hier waere
   totes Blatt. Die Folgen sind gemessen unkritisch: fitViewerHeight() misst die Tabbar direkt
   (offsetHeight, bei display:none = 0), und #toasts steht mit dem stehengebliebenen Wert nur
   ETWAS HOEHER ueber dem Rand — ueber der schlanken Leiste bleibt es damit immer.
   Der 54px-Abzug im .viewer-Rueckfall ist der kompakte Plankopf + Luft; der echte Wert kommt wie
   immer aus fitViewerHeight(). .shell-root davor hebt die Spezifitaet ueber die Basisregel
   (Reihenfolge allein truege nicht: die Basis hat drei height-Zeilen, dieser Block auch).
   OBERGRENZE 999px, nicht 759: iPhone 12-16 (Pro/Max) sind quer 844-932 px BREIT und brauchen
   das Band am dringendsten; iPads bleiben sicher draussen, weil ihre kleinste Querhoehe 768 px
   betraegt und die Hoehenschranke (<= 500 px) sie nie treffen kann. Im Band gilt ausserdem volle
   Breite statt der 720er-Mittelsaeule des 760px-Blocks (spaeterer Block gewinnt bei gleicher
   Spezifitaet) — eine Saeule mit Seitenstreifen verschenkt auf 390 px Hoehe nur Planflaeche. */
@media (min-width: 431px) and (max-width: 999px) and (max-height: 500px) {
  .shell-root { max-width: 100%; }
  .appbar { gap: 8px; padding: calc(3px + var(--safe-top)) max(12px, var(--safe-right)) 3px max(12px, var(--safe-left)); }
  .appbar .brand img { width: 26px; height: 26px; border-radius: var(--radius); }
  .planhead { padding-top: 2px; padding-bottom: 2px; }
  .tabbar { padding-top: 2px; }
  /* min-height: 44px bleibt aus der Basisregel bestehen — nur Leerraum und Wort weichen. */
  .tabbar button { gap: 0; padding: 1px 4px 1px; }
  .tabbar button > span:last-child { display: none; }
  .tabbar button .ico { height: 28px; }
  .shell-root:has(.viewer) .tabbar { display: none; }
  .shell-root .viewer {
    height: max(240px, calc(100vh - 54px - var(--safe-top) - var(--tabbar-h)));
    height: max(240px, calc(100svh - 54px - var(--safe-top) - var(--tabbar-h)));
    height: max(240px, calc(100dvh - 54px - var(--safe-top) - var(--tabbar-h)));
  }
}

/* ------------------------------------------------ Pin-Finder (BLOCKER B, recon2/04)
   BEFUND: Es gab KEINE Suche und KEINE Liste für Pins — der einzige Suchschlitz der App suchte
   Projektnamen. Pin 47 auf einem A0-Plan fand man nur durch Wischen und Zoomen (unbegrenzt viele
   Gesten). Das Blatt liegt über der unteren Planhälfte = Daumenzone; die Trefferliste scrollt
   für sich (overscroll-behavior:contain hält den Plan darunter ruhig). */
.viewer .pinfinder {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: var(--z-viewerhilfe);
  display: flex; flex-direction: column; max-height: 66%;
  padding: 10px max(12px, var(--safe-right)) calc(10px + var(--safe-bottom)) max(12px, var(--safe-left));
  background: var(--surface); border-top: 1px solid var(--border);
  border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: var(--shadow-lg);
}
.viewer .pinfinder-head { display: flex; align-items: center; gap: 8px; }
.viewer .pinfinder-head .control { flex: 1; min-width: 0; }
.viewer .pinfinder-head .iconbtn { flex: none; }
.viewer .pinfinder-count { font-size: calc(12.5px * var(--ui-scale)); font-weight: 800; color: var(--text-muted); padding: 8px 2px 4px; }
.viewer .pinfinder-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; display: grid; gap: 6px; }
.viewer .pinfinder-row { padding: 8px 10px; min-height: 56px; }
.viewer .pinfinder-num {
  flex: none; min-width: 34px; height: 34px; border-radius: var(--radius); color: #fff;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 13px; font-weight: 900; padding: 0 6px;
}

.pinmarker {
  position: absolute; transform: translate(-50%, -100%); width: 30px; height: 38px; cursor: pointer;
  filter: drop-shadow(0 3px 5px rgba(0,0,0,.35));
  /* Der Bezugspunkt ist die SPITZE (unten mittig): beim Antippen schrumpft der Pin zur
     Spitze hin, der markierte Punkt auf dem Plan bleibt exakt stehen. */
  transform-origin: 50% 100%;
  transition: transform var(--t-tap) var(--ease);
}
/* Berührungsantwort: ein Pin öffnet das Detail-Blatt. Bei grossen Plänen dauert dessen Aufbau
   spürbar — bis dahin sah der Tipp aus, als wäre er danebengegangen. */
.pinmarker:active { transform: translate(-50%, -100%) scale(.88); }
/* [stil 2026-08-06] DER SCHATTEN GEHOERT NUR DEM GEZEICHNETEN MARKER — NICHT DEM LEEREN KAESTCHEN.
   Seit dem Leinwand-Umbau (js/views/project.js, `.pinlayer .pinmarker.pinzugang`) traegt die
   Pin-Ebene je Pin ein LEERES Kaestchen: kein Hintergrund, kein Rand, kein Text. Der Kommentar
   dort sagt woertlich, das koste auch bei 300 Pins keine Neurasterung. Die `filter`-Zeile eine
   Regel weiter oben stand dieser Zusage entgegen: sie kam aus dem alten DOM-Marker, wurde von
   der `.pinzugang`-Regel NICHT zurueckgenommen (die setzt nur background/border/transform) und
   galt damit weiter fuer jedes dieser Kaestchen. Ein `filter` ist kein Zierrat — er zwingt den
   Browser, das Element ueber einen eigenen Zeichenpuffer zu fuehren, und das mal Pinzahl,
   INNERHALB der Ebene, die laut Messbericht m5 ohnehin die empfindlichste Flaeche des Plans ist.
   Sichtbar aendert sich nichts: an einem Kaestchen ohne Farbe hat ein Schlagschatten nichts zum
   Werfen. Der DOM-Rueckfall (pinMarker(), wenn der Leinwand-Maler wirft) behaelt ihn — dort
   steht wieder ein gezeichneter Pin, und genau fuer den war die Zeile gemeint. */
.pinmarker.pinzugang { filter: none; }
/* `.pinmarker .num` gilt weiterhin — aber nur noch fuer den DOM-Rueckfall. Auf dem Leinwand-Weg
   hat das Zugangs-Kaestchen kein Kind; der Selektor findet dort schlicht nichts. */
.pinmarker .num { position: absolute; top: 4px; left: 0; right: 0; text-align: center; color: #fff; font-size: 11px; font-weight: 900; }
/* ----------------------------------------------- [PLAKETTE RAUS 2026-08-06]
   HIER STAND `:root .pinmarker .photobadge:not(.pano)` — eine Kontrast-Korrektur fuer die
   Foto-/360-Plakette am Planmarker (Weiss auf --navy-strong mass im dunklen Thema 1,56:1, der
   schlechteste Wert der ganzen PWA). Die Plakette selbst ist auf Betreiber-Wunsch entfallen:
   der Marker traegt am Plan keine Zahlen mehr. Mit ihrem Traeger geht auch ihre Korrektur —
   die Regel haette sonst als einzige Spur eines geloeschten Bauteils weitergelebt und beim
   naechsten Lesen die Frage aufgeworfen, wo diese Plakette denn hin ist.
   BELEGT: `js/views/project.js` fuehrt an derselben Stelle den passenden Grabstein („HIER
   STANDEN .photobadge, .photobadge.pano und der Schalter .pinlayer.hide-badges"), und
   tests/z64_zoomtiefe_und_markerplakette.test.mjs haelt fest, dass keine der drei Regeln
   zurueckkommt. Ein `grep -n "photobadge"` ueber den Baum meldet nach dieser Aenderung nur
   noch Grabsteine und Proben, keinen einzigen Verwender.
   NICHT MITGEGANGEN sind --accent-fill / --accent-fill-ink: das Paar ist die Hausregel §1
   („helle Marken-Flaeche, dunkle Tinte") und steht im Token-Kopf ausdruecklich fuer JEDE
   kuenftige gefuellte Akzentflaeche. Es hatte hier seinen bekanntesten, aber nicht seinen
   einzigen Verwender (js/views/kalender.js:1736, Heute-Pille im Monatsraster). */
/* Schwebende Bedienelemente im Plan: der Viewer läuft (negative Ränder in views/project.js) bis an
   den Displayrand — im Querformat mit seitlicher Kerbe müssen Leiste und Zoom-Stapel deshalb selbst
   aus der Kerbe rücken. max() lässt Hoch-/Desktop-Format unverändert (Inset = 0). */
/* [DECKSCHICHT 1.9.86] Die Bedienleisten brauchen einen z-index UEBER den Vollflaechen-Ebenen
   des Viewers (pinlayer z=1, measurelayer z=2, beides project.js:VIEWER_CSS): ohne ihn entschied
   die DOM-Reihenfolge, und ein ECHTER Finger-Tap traf die Ebene statt des Knopfs — am Geraet
   waren damit ALLE Plan-Knoepfe tot (Zoom, Einpassen, Pin setzen, Zeichnen, Suche; gemessen am
   Echt-Daten-Rig per elementsFromPoint). --z-viewerbar = dieselbe Stufe wie .massstab/.massbar;
   der Pin-Finder (--z-viewerhilfe) bleibt bewusst darueber. */
.viewer .layerbar { position: absolute; top: 10px; left: max(10px, var(--safe-left)); right: max(10px, var(--safe-right)); display: flex; gap: 8px; flex-wrap: wrap; z-index: var(--z-viewerbar); }
.viewer .zoombar { position: absolute; right: max(12px, var(--safe-right)); bottom: 84px; display: grid; gap: 8px; z-index: var(--z-viewerbar); }
/* PERF: kein backdrop-filter über dem Plan-Canvas — beim Pannen/Zoomen müsste der Blur jeden
   Frame neu gerechnet werden (Jank auf schwachen Geräten). Volldeckende Fläche stattdessen. */
.viewer .zoombar button { width: 46px; height: 46px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--surface); box-shadow: var(--shadow); font-size: 22px; font-weight: 800; display: grid; place-items: center; cursor: pointer; color: var(--text);
  transition: transform var(--t-tap) var(--ease), background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease); }
/* Berührungsantwort: Zoom-Knöpfe werden im Plan oft mehrfach hintereinander gedrückt und
   gaben bisher KEIN Zeichen zurück — nur das Bild änderte sich (und bei sehr grossen Plänen
   erst spürbar später). Jetzt quittiert der Knopf sofort den Druck. */
.viewer .zoombar button:active { transform: scale(.94); }
.viewer .zoombar button.active { background: var(--gruenw); border-color: var(--gruent); color: var(--accent-ink); }

/* --------------------------------- DER WERKZEUGSTAPEL BRICHT UM, STATT AUS DEM BILD ZU LAUFEN
   BEFUND, GEMESSEN 2026-07-26 in ZWEI Maschinen (QtWebEngine 6.11.1 und Chromium 148), Demo-Daten:
   Der Stapel ist einspaltig 6 x 46 px + 5 x 8 px Lücke = 316 px hoch — mehr, als der Plan-Bereich
   auf einem kleinen Gerät hoch ist. Treffertest per `elementFromPoint` auf die Knopfmitte:

     568 x 320 quer, Kerbe links 59 / Home-Indicator 21  -> Plan 120 px, 2 von 6 Knöpfen erreichbar
       („Vergrößern" lieferte die Sync-Pille, „Verkleinern" einen Layer-Chip,
        „Zeichnen und Messen" und „Pins suchen" die Tabbar)
     320 x 568 hoch,  Kerbe oben 59 / unten 34           -> Plan 296 px, 4 von 6 Knöpfen erreichbar
       (der Stapel begann bei y = 62, also 104 px ÜBER dem Plan, unter Appbar und Layer-Chips)

   Nicht klein, sondern UNERREICHBAR — und quer trifft es genau den Alltagsfall: wer einen
   Grundriss breiter sehen will, dreht das Gerät.

   WARUM KEIN BREAKPOINT. Ein `@media (max-height: …)` rät. Die Bedingung ist nicht „Fenster ist
   niedrig", sondern „der Stapel ist höher als der Plan-Bereich" — und dessen Höhe hängt an
   Fensterhöhe MINUS Appbar MINUS Plankopf MINUS Tabbar MINUS Safe-Area. Ein erster Versuch mit
   zwei Breakpoints (520 quer / 560 hoch) hat den zweiten Fall oben um 8 px verfehlt: 568 > 560,
   Regel griff nicht, 2 Knöpfe blieben verdeckt. Deshalb rechnet jetzt der Umbruch selbst.

   WIE. Der Stapel ist eine Spalte mit Umbruch in einer Box, die oben unter den Layer-Chips
   beginnt und unten dort endet, wo er heute endet. Passt alles in eine Spalte, sieht es aus wie
   bisher; wird die Box zu niedrig, wandern die überzähligen Knöpfe in eine NEUE SPALTE.
   `justify-content: flex-end` hält den Stapel unten; verankert bleibt er rechts über `right:` und
   `width: max-content` (die Box ist genau so breit wie ihr Inhalt, egal wie viele Spalten).
   `min-height: 46px` sichert den Extremfall: bleibt rechnerisch keine Höhe übrig, wird daraus
   eine EINZELNE REIHE (316 x 46) statt einer Spalte, die aus dem Bild läuft.
   Umbruchrichtung `wrap`, nicht `wrap-reverse`: mit `wrap-reverse` stand die Reihe im Querformat
   rückwärts (gemessen und im Bild gesehen: links „Pins suchen", rechts „Vergrößern"). Die
   Werkzeuge behalten jetzt in JEDER Anordnung dieselbe Reihenfolge wie im hohen Hochformat —
   Vergrößern, Verkleinern, Einpassen, Pin setzen, Zeichnen, Suchen.
   GEMESSEN NACHHER, dieselben zwei Fälle: 6 von 6 erreichbar, `doc.scrollWidth - innerWidth = 0`.
   Hochformat 393 x 852 unverändert (Box 670 - 148 = 522 px > 316 px -> weiterhin eine Spalte).

   pointer-events: DIE BOX MUSS DURCHLÄSSIG SEIN. Damit der Umbruch rechnen kann, spannt sich die
   Box jetzt über die volle nutzbare Höhe (im Hochformat 46 x 522 px statt 46 x 316 px). Ohne die
   nächsten zwei Zeilen fängt ihr LEERER Teil die Berührung ab: gemessen mit `elementFromPoint`
   auf der Mittelachse der Box bei y = 182/214/274/370 kam viermal `DIV.zoombar` zurück statt des
   Plans — ein toter Streifen von 46 x 206 px über der Planfläche, genau dort, wo man mit dem
   rechten Daumen wischt. Die Knöpfe holen sich die Berührung selbst zurück. */
.viewer .zoombar {
  top: 64px;                 /* unter den Layer-Chips (top 10 + 44 hoch + 10 Luft) */
  min-height: 46px;          /* eine Reihe passt immer */
  display: flex; flex-direction: column; flex-wrap: wrap;
  justify-content: flex-end; /* auf hohen Geräten unten — wie bisher */
  align-content: flex-start;
  pointer-events: none;      /* der leere Teil der Box gehört dem Plan */
  width: max-content;        /* Box so breit wie ihr Inhalt -> `right:` verankert sie rechts */
}
.viewer .zoombar > button { pointer-events: auto; }

/* ---------------------------------------------------------------- Zeichnen & Messen (G3)
   Werkzeugleiste + Kalibrier-Dialog des Plan-Viewers (Markup: js/draw.js + views/project.js).
   Gleiche Design-Sprache wie die Website-Karten: Surface + Border + Radius-Token, Teal-Aktiv,
   Amber = Kalibrier-Signal. PERF: volldeckende Flächen, KEIN backdrop-filter über dem Canvas. */
.viewer .pinlayer.draw-mode { pointer-events: none; } /* im Zeichenmodus gehören Taps dem Werkzeug */
.viewer .drawbar {
  position: absolute; top: 58px; left: 50%; transform: translateX(-50%); z-index: 6;
  display: flex; flex-direction: column; gap: 2px; padding: 8px 10px;
  /* width:max-content: absolut + left:50% würde sonst nur den halben Platz als
     Shrink-to-fit-Breite bekommen (Leiste bräche unnötig um). max-width deckelt schmale Geräte. */
  width: max-content;
  max-width: min(430px, calc(100% - 16px));
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
}
.viewer .drawbar .hint {
  font-size: calc(12px * var(--ui-scale)); font-weight: 700; color: var(--text-muted); text-align: center;
  padding: 2px 4px 6px; line-height: 1.35;
}
.viewer .drawbar .drawrow { display: flex; align-items: center; justify-content: center; gap: 2px; flex-wrap: wrap; }

/* [SEITENLEISTE 2026-08-07] DIE SPALTE AM RAND — Betreiber: „wie bei tradingview also exakt so".
   Die schwebende Tafel stand mittig ueber dem Plan (top 58, left 50 %, bis 430 px breit) und
   deckte damit genau die Flaeche zu, auf der gezeichnet wird. Eine Spalte am Rand laesst den
   Plan frei; sie ist 52 px breit, also rund 13 % eines 390-px-Telefons und auf dem iPad
   nebensaechlich.
   `max-height` + `overflow-y: auto`: lieber scrollt die Spalte, als dass sie unter die untere
   Kante laeuft und ihre letzten Knoepfe unerreichbar macht. Die 120 px Abzug sind Kopfzeile
   (58 px Startpunkt) plus Luft nach unten.
   Die Spalte haengt LINKS: rechts steht bereits der Zoom-Stapel, und zwei Saeulen an einer Kante
   waeren ein Korridor. */
.viewer .drawbar.seitlich {
  left: max(10px, var(--safe-left)); transform: none;
  width: 52px; max-width: 52px; padding: 6px 4px;
  max-height: calc(100% - 120px); overflow-y: auto; overscroll-behavior: contain;
}
.viewer .drawbar.seitlich .drawrow { flex-direction: column; flex-wrap: nowrap; gap: 2px; }
/* [LOS zeichnen 2026-08-20] Die Abschluss-Zeile steht GANZ OBEN in der Spalte und klebt dort,
   auch wenn der Nutzer die Werkzeuge nach unten scrollt. Begruendung in js/draw.js: die Spalte
   ist hoeher als ihr Platz (753 px Inhalt in 553 px) und der Knopf lag frueher unterhalb des
   Ausschnitts — unsichtbar genau in dem Moment, in dem er gebraucht wird. */
.viewer .drawbar.seitlich .abschlusszeile {
  position: sticky; top: -6px; z-index: 2;
  background: var(--surface); padding: 2px 0 4px; margin-bottom: 2px;
  border-bottom: 1px solid var(--border);
}
.viewer .drawbar.seitlich .abschlusszeile .pw-count {
  font-size: calc(11px * var(--ui-scale)); font-weight: 800; color: var(--text-muted); line-height: 1; padding-top: 2px;
}
.viewer .drawbar button.tool.abschluss {
  background: var(--gruen); border-color: var(--gruent); color: var(--gruen-ink);
  box-shadow: 0 1px 0 rgba(0, 0, 0, .06);
}
.viewer .drawbar button.tool.abschluss:active { transform: scale(.92); }
/* Das Farbfeld klappt QUER aus der Spalte heraus — in der Spalte selbst waeren fuenf weitere
   44-px-Knoepfe 220 px, und die Spalte reichte wieder von oben bis unten. */
.viewer .drawbar.seitlich .farbfeld {
  position: absolute; left: calc(100% + 6px); display: flex; gap: 2px; padding: 4px;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
}
.viewer .drawbar.seitlich .swatchbtn.aktuell i { width: 20px; height: 20px; }
.viewer .drawbar.seitlich .drawsep { width: 22px; height: 1px; background: var(--border); margin: 2px 0; }
.viewer .drawbar button.tool {
  width: 44px; height: 44px; flex: none; border-radius: var(--radius); border: 1px solid transparent;
  background: none; color: var(--text-muted); display: grid; place-items: center; cursor: pointer;
  transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease), transform var(--t-tap) var(--ease);
}
.viewer .drawbar button.tool.active { background: var(--gruenw); border-color: var(--gruent); color: var(--accent-ink); }
.viewer .drawbar button.tool:disabled { opacity: .35; cursor: default; }
/* Berührungsantwort für Werkzeug und Farbe. Der Umschlag auf .active kommt erst, wenn draw.js
   das Werkzeug gewechselt hat; der Druck selbst war vorher unbeantwortet. Gesperrte Werkzeuge
   antworten bewusst NICHT — sonst verspräche die Bewegung eine Wirkung, die es nicht gibt. */
.viewer .drawbar button.tool:active { transform: scale(.92); }
.viewer .drawbar button.tool:disabled:active { transform: none; }
.viewer .drawbar .swatchbtn {
  width: 44px; height: 44px; flex: none; border: 0; background: none; padding: 0;
  display: grid; place-items: center; cursor: pointer; border-radius: var(--radius);
  transition: transform var(--t-tap) var(--ease);
}
.viewer .drawbar .swatchbtn:active { transform: scale(.9); }
.viewer .drawbar .swatchbtn i {
  width: 24px; height: 24px; border-radius: 50%; display: block;
  border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--border-strong);
}
/* --------------------------------------- DIE AUSWAHL IST EIN ZUSTAND, KEINE FARBE
   [stil 2026-08-06] Hier stand ein fester Teal-Ring `rgba(10,168,146,0.45)`. Solange die Palette
   kein Teal fuehrte, ging das durch. Seit sie #0aa892 UND #0f172a fuehrt (Bau-Brief 23, F15),
   markiert er ausgerechnet die zwei Enden schlecht:
     auf dem teal Tupfer   -> Ring und Tupfer sind derselbe Farbton, die Auswahl liest sich als
                              weicher Verlauf statt als Markierung
     auf dem fast schwarzen #0f172a -> der Ring liest sich als ZWEITE Farbe neben der gewaehlten
   Der Desktop markiert die Auswahl ebenfalls mit der AUSWAHLfarbe (appearance/selection_color)
   und nie mit der Objektfarbe. Der neutrale Doppelring traegt ueber alle fuenf Palettenfarben:
   innen die Flaeche, auf der der Tupfer liegt (--surface), aussen die Tinte (--text) — beide
   sind themenabhaengig, der Ring kippt also in Hell und Dunkel mit. Die Geometrie bleibt
   dieselbe wie beim Ruherand (Markenhandbuch: Zustandswechsel tauschen die FARBE, nie die
   Geometrie): 24 px Tupfer, 2 px Rahmen, danach 2 + 2 px Ring statt 3 px. */
.viewer .drawbar .swatchbtn.active i { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--text); }
.viewer .drawbar .drawsep { width: 1px; height: 26px; background: var(--border); margin: 0 4px; flex: none; }

/* ---------------------------------------------------------------- Foto-Annotator (Feature 3)
   Vollbild-Overlay über einem aufgenommenen Foto: Pan/Zoom + dieselbe Zeichen-/Mess-Werkzeugleiste
   wie der Plan (heute js/pin/pin_annotate.js; die fruehere js/photo_annotate.js ist geloescht).
   Deckende Flächen, keine Blur-Filter über dem malenden Layer. */
/* Dieselbe Kaskade wie .pano-wrap und aus demselben Grund (Begruendung dort): der Annotator ist
   das zweite Vollbild mit eigenen Leisten oben UND unten. Mit `inset: 0` lag seine untere
   Werkzeugleiste unter der Navigationsleiste des Browsers. */
.photo-annot {
  position: fixed; left: 0; right: 0; top: 0; z-index: var(--z-anno); background: #05070d;
  height: 100vh; height: 100svh; height: 100dvh;
  display: flex; flex-direction: column; overflow: hidden; touch-action: none;
  padding-top: var(--safe-top);
}
.photo-annot .pa-top {
  position: relative; z-index: 4; display: flex; align-items: center; gap: 10px;
  /* Reihenfolge ist top RIGHT bottom LEFT — die beiden Insets waren vertauscht (rechts trug
     --safe-left). Im Querformat rutschte der Schließen-Knopf damit genau in die Kerbe. */
  padding: 8px max(10px, var(--safe-right)) 8px max(10px, var(--safe-left));
}
.photo-annot .pa-top .pa-title {
  flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: #fff; font-weight: 800; font-size: calc(15px * var(--ui-scale));
}
.photo-annot .pa-iconbtn {
  width: 44px; height: 44px; flex: none; border-radius: var(--radius); border: 1px solid rgba(255,255,255,.18);
  background: rgba(255,255,255,.08); color: #fff; display: grid; place-items: center; cursor: pointer;
}
.photo-annot .pa-iconbtn:active { transform: scale(.94); }
/* Diese Leiste liegt auf der FESTEN dunklen Flaeche von .photo-annot (#05070d) — in BEIDEN Themen.
   --accent-ink ist im HELLEN Thema die dunkle Teal-Tinte #06695a: GERECHNET auf der getoenten
   Flaeche rgba(10,168,146,.22) ueber #05070d (= #062a2a) nur 2,30:1. Im dunklen Thema ist
   --accent-ink #7ef3e0 und misst 11,45:1 — die Zeile war also nur in EINEM der zwei Themen lesbar,
   weil sie ein themenabhaengiges Token auf eine themenUNabhaengige Flaeche legt (PW-6).
   Die color-Angabe ist GESTRICHEN und nicht getauscht: der Grundzustand .pa-iconbtn traegt schon
   color:#fff, und Weiss auf #062a2a misst 15,23:1 in BEIDEN Themen — hoeher als vorher in beiden.
   GEGENPROBE der naheliegenden Alternative (Hausregel §1, gefuellte Flaeche + dunkle Tinte,
   background:var(--accent-fill); color:var(--accent-fill-ink)): 5,56 hell / 10,65 dunkel. Hell
   besser, DUNKEL aber schlechter als die 11,45 von vorher — deshalb NICHT genommen.
   Der aktive Zustand haengt weiterhin nicht am Farbton allein: der helle Rahmen (--navy-bright)
   zieht einen RING um den Knopf, den der Grundzustand (rgba(255,255,255,.18)) nicht hat. */
/* [SCHNURMASS] Derselbe Aufbau, neuer Ton: das Teal ist als Wert geloescht. Die Flaeche bleibt
   BEWUSST eine harte rgba und kein Token — sie liegt ueber NUTZERINHALT (dem Foto) bzw. ueber
   #05070d und ist deshalb themenUNabhaengig; genau darum ging es im Absatz oben.
   GERECHNET, rgba(75,196,41,.22) ueber #05070d = rgb(20,50,23): Weiss darauf 14,9:1 (vorher
   15,23 ueber dem Teal-Ton) — die Zeile bleibt in BEIDEN Themen lesbar. Der Ring aus
   --navy-bright (jetzt --gruen) traegt den aktiven Zustand weiterhin zusaetzlich zur Flaeche. */
.photo-annot .pa-iconbtn.active { background: rgba(75, 196, 41, .22); border-color: var(--navy-bright); }
.photo-annot .pa-viewport { position: relative; flex: 1; min-height: 0; overflow: hidden; touch-action: none; }
/* [stil 2026-08-06] `will-change: transform` BLEIBT HIER — nachgelesen, nicht vermutet.
   Der Messbericht m5 hat diesen Hinweis an der PLAN-Ebene vermessen und dabei zwei
   gegenlaeufige Zahlen gefunden: beim VERSCHIEBEN 60,0 gegen 21,1 Bilder/s (Faktor 2,8 FUER
   die Ebene), beim ZOOMEN 23,1 gegen 45,3 (GEGEN die Ebene) — und Unschaerfe obendrein.
   ENTSCHEIDEND IST DER GRUND, den derselbe Bericht nennt: der Zoom-Schaden entsteht nur dort,
   wo eine GEGEN-SKALIERUNG (`--pin-inv`) bei jedem Zoombild jeden einzelnen Marker aendert und
   die befoerderte Ebene deshalb jedes Bild neu gerastert werden muss. Diese Buehne hier hat
   keine Gegen-Skalierung: Bild und SVG-Zeichnung wachsen einfach mit, der Inhalt bleibt
   waehrend der Geste unveraendert. Damit greift ausschliesslich der Verschiebe-Fall — der
   Fall, in dem der Hinweis die 2,8-fache Bildrate bringt.
   Die Empfehlung von m5 lautet woertlich „`will-change: transform` BLEIBT"; der dortige Umbau
   gehoert in js/views/project.js und nicht in diese Datei. Zeile also bewusst stehengelassen. */
.photo-annot .pa-stage { position: absolute; transform-origin: 0 0; /* [ZCH-03 2026-08-24] will-change entfernt: die Ruhe-Skala liegt jetzt als LAYOUT-Groesse in der Buehne (bis Basis x 32); eine Dauer-Puffer-Zusage auf 6669 CSS-px zwingt den Compositor unnoetig — die E22-Messung galt der alten Architektur mit CSS-scale im Ruhebild. */ }
.photo-annot .pa-stage img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; user-select: none; pointer-events: none; }
.photo-annot .pa-stage svg { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.photo-annot .pa-loadbox { position: absolute; inset: 0; display: grid; place-items: center; z-index: 3; }
/* ------------------------------------------------------------------ Lupe (js/pin/pin_annotate.js)
   [stil 2026-08-06] AUSSEHEN HIERHER, MASSE BLEIBEN INLINE — und das ist keine halbe Sache,
   sondern die Trennlinie: Groesse, Lage und Bildausschnitt des Glases sind MESSWERTE je Bild
   (Glasdurchmesser 0,62 x Fensterbreite bis 420 px, Versatz 28 px ueber dem Finger,
   3,2-fache Vergroesserung). Die stehen zwangslaeufig im style-Attribut. Alles, was bei jedem
   Bild GLEICH ist, gehoert dagegen hierher.
   Der dunkle Grund ist Teil der Funktion, nicht Zierrat: am Bildrand liegt unter dem Glas kein
   Foto mehr; ohne Fuellung entstuende dort durchsichtiges Glas, durch das man das Foto DAHINTER
   saehe — also genau die Stelle, die die Lupe gerade nicht zeigt.
   EHRLICHE GRENZE: der Bauort setzt denselben Satz heute noch inline und schlaegt diese Regel;
   er steht mit Datei und Zeile im Bericht. */
.photo-annot .pa-lupe {
  position: absolute; z-index: 5; pointer-events: none;
  border-radius: 50%; overflow: hidden;
  border: 2px solid rgba(255, 255, 255, .92);
  box-shadow: 0 8px 22px rgba(0, 0, 0, .5);
  background-color: #05070d; background-repeat: no-repeat;
  display: none;
}
/* Die Mittelmarke: der Punkt unter dem Finger. Bei 3,2-facher Vergroesserung sind 10
   Bildschirmpixel Versatz bereits eine sichtbare Strecke — ohne diese Marke raet man, welche
   Stelle man gerade betrachtet. Der dunkle Aussenring haelt sie auch auf hellem Beton sichtbar. */
.photo-annot .pa-lupe > * {
  position: absolute; left: 50%; top: 50%;
  width: 18px; height: 18px; margin: -9px 0 0 -9px;
  border-radius: 50%; border: 1.5px solid rgba(255, 255, 255, .9);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, .45);
}
/* ------------------------------------------------------ Namensleiste des Bildvergleichs
   Welches Bild links und welches rechts vom Steg liegt, steht als WORT da — auf einer Baustelle
   ist genau die Zuordnung („was war vorher da?") die ganze Aussage. Die Leiste liegt ueber dem
   Bild und darf es nicht abfangen (pointer-events: none); `display` schaltet der Bauort um.
   Safe-Area additiv ueber max(), wie ueberall in dieser Datei: im Hochformat sind die Insets 0
   und es bleibt bei den 10 px Grundmass. */
.photo-annot .pa-vergleich-leiste {
  position: absolute; z-index: 3; pointer-events: none;
  display: none; gap: 8px;
  left: max(10px, var(--safe-left)); right: max(10px, var(--safe-right)); top: max(10px, var(--safe-top));
  font-size: calc(12px * var(--ui-scale)); font-weight: 800; color: #fff; line-height: 1.2;
}
.photo-annot .pa-vergleich-leiste > div {
  flex: 1 1 0; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  padding: 6px 8px; border-radius: var(--radius);
  /* Eigene Trageflaeche, weil der Untergrund das FOTO ist: Weiss auf rgba(6,12,22,.78) traegt
     auch ueber einem ueberbelichteten Betonbild (dieselbe Ueberlegung wie bei --scrim-image). */
  background: rgba(6, 12, 22, .78);
}
.photo-annot .pa-vergleich-leiste > div:last-child { text-align: right; }
.photo-annot .pa-loadbox .msg { color: #fff; font-weight: 700; font-size: calc(14px * var(--ui-scale)); text-align: center; padding: 12px 24px; }
/* Werkzeugleiste unten (statt schwebend oben wie im Plan) — gleiche Optik wie .viewer .drawbar. */
.photo-annot .drawbar {
  position: relative; z-index: 4; margin: 0 auto 0; width: max-content;
  max-width: min(430px, calc(100% - 16px));
  display: flex; flex-direction: column; gap: 2px; padding: 8px 10px calc(8px + var(--safe-bottom));
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: var(--shadow);
}
.photo-annot .drawbar .hint { font-size: calc(12px * var(--ui-scale)); font-weight: 700; color: var(--text-muted); text-align: center; padding: 2px 4px 6px; line-height: 1.35; }
.photo-annot .drawbar .drawrow { display: flex; align-items: center; justify-content: center; gap: 2px; flex-wrap: wrap; }
.photo-annot .drawbar button.tool {
  width: 44px; height: 44px; flex: none; border-radius: var(--radius); border: 1px solid transparent;
  background: none; color: var(--text-muted); display: grid; place-items: center; cursor: pointer;
  transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease), transform var(--t-tap) var(--ease);
}
.photo-annot .drawbar button.tool.active { background: var(--gruenw); border-color: var(--gruent); color: var(--accent-ink); }
.photo-annot .drawbar button.tool:disabled { opacity: .35; cursor: default; }
/* Dieselbe Leiste wie im Plan-Viewer, gleiche Antwort. Vorher hatte diese Kopie gar keine
   Transition und keine Berührungsantwort — dieselbe Bedienung, zwei verschiedene Gefühle. */
.photo-annot .drawbar button.tool:active { transform: scale(.92); }
.photo-annot .drawbar button.tool:disabled:active { transform: none; }
.photo-annot .drawbar .swatchbtn { width: 44px; height: 44px; flex: none; border: 0; background: none; padding: 0; display: grid; place-items: center; cursor: pointer; border-radius: var(--radius);
  transition: transform var(--t-tap) var(--ease); }
.photo-annot .drawbar .swatchbtn:active { transform: scale(.9); }
.photo-annot .drawbar .swatchbtn i { width: 24px; height: 24px; border-radius: 50%; display: block; border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--border-strong); }
/* Zweitschrift des neutralen Auswahlrings — Begruendung bei `.viewer .drawbar .swatchbtn.active i`.
   Diese Leiste liegt auf der festen dunklen Flaeche von .photo-annot; --surface/--text kippen dort
   mit dem THEMA, nicht mit der Flaeche. Das ist gewollt: der Tupfer selbst traegt bereits
   `border: 2px solid var(--surface)` aus derselben Quelle, Ring und Tupferrand bleiben so ein Bild. */
.photo-annot .drawbar .swatchbtn.active i { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--text); }
.photo-annot .drawbar .drawsep { width: 1px; height: 26px; background: var(--border); margin: 0 4px; flex: none; }
/* ------------------------------------------------- Die ZAHL unter dem Strichstaerken-Punkt
   [stil 2026-08-06] Der PC stellt die Strichstaerke als Zahl 1..32 ein; sieben verschieden
   grosse Punkte ohne Zahl sind dagegen eine Rateaufgabe. Der Knopf ist `display: grid;
   place-items: center` (js/pin/pin_style.js:254-259) — zwei Kinder ergeben dort von selbst zwei
   zentrierte Zeilen. GERECHNET: 16 px Punkt + 2 px Abstand + 10 px Zahl = 28 px in einem
   44-px-Knopf, die Leistenhoehe bleibt bei 196 px.
   Die Zahl traegt bewusst KEINE eigene Farbe: sie erbt ueber currentColor die des Knopfes und
   wechselt damit im aktiven Zustand von selbst auf --accent-ink (pin_style.js:261). Eine
   zusaetzliche `.active b { color: … }`-Zeile waere eine zweite Wahrheit fuer denselben Wert.
   `--ui-scale` bleibt hier bewusst draussen: 10 px ist keine Beschriftung, die man liest,
   sondern eine Zahl neben einem Punkt in einem Knopf mit fester 44-px-Geometrie — waechst sie
   allein, sprengt sie den Knopf.
   EHRLICHE GRENZE: js/pin/pin_tools.js:571 setzt dieselben drei Angaben inline und schlaegt
   damit diese Regel; die Zeile steht im Bericht. */
.photo-annot .drawbar .pw-wbtn b { font-size: 10px; line-height: 1; font-weight: 800; margin-top: 2px; }

/* Kalibrier-Dialog: echte Länge in Metern (Komma-Eingabe), modal über allem. */
/* Vollbild-Overlay (inset:0 = bis an den physischen Displayrand) → das Polster MUSS die Insets
   mitführen, sonst liegt die Karte bei 20px Rand teilweise unter Statusleiste/Home-Indicator. */
.calib-overlay {
  position: fixed; inset: 0; z-index: var(--z-calib); display: grid; place-items: center;
  padding:
    calc(20px + var(--safe-top))
    max(20px, var(--safe-right))
    calc(20px + var(--safe-bottom))
    max(20px, var(--safe-left));
  background: rgba(8, 14, 28, 0.5);
}
.calib-card {
  width: min(360px, 100%); background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: 18px;
}
.calib-card h3 { margin: 0 0 4px; font-size: calc(18px * var(--ui-scale)); }
.calib-inputrow { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.calib-inputrow .control { flex: 1; }
.calib-unit { font-weight: 900; color: var(--text-muted); flex: none; }
/* [stil 2026-08-06] `line-height` ergaenzt. Die Meldung erklaert, WARUM eine Eingabe abgelehnt
   wurde — sie muss lesbar sein, sonst wird sie zur blossen roten Flaeche. Die neue
   Mehrdeutigkeits-Meldung (Bau-Brief 23) ist 101 Zeichen lang und bricht im 360-px-Kaertchen auf
   drei Zeilen um; fett und mit der Vorgabe-Zeilenhoehe klebt das zusammen. 1.35 ist die
   Zeilenhoehe, die diese Datei auch fuer die Werkzeug-Hinweiszeile (.drawbar .hint) fuehrt. */
.calib-error { margin-top: 8px; font-size: calc(13px * var(--ui-scale)); font-weight: 700; line-height: 1.35; color: var(--danger-text); }
.calib-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
.calib-actions .btn { width: auto; }

/* ---------------------------------------------------------------- bottom sheet */
/* PERF-VERTRAG: `backdrop-filter: blur(2px)` ist hier gestrichen. Es war der EINZIGE
   Blur der ganzen Datei — acht andere Stellen tragen ausdrücklich den Kommentar „KEIN
   backdrop-filter", diese eine widersprach ihnen still. Der Blur lag über der gesamten
   Bildschirmfläche und musste bei jedem Bild neu gerechnet werden, während das Blatt
   darüber fährt. Die deckende Abdunklung allein trennt genauso deutlich. */
/* [stil 2026-08-06] EINGANG UND AUSGANG SIND NICHT DASSELBE.
   Vorher stand die Dauer am Grundzustand und galt damit fuer BEIDE Richtungen: Verdunkler und
   Blatt kamen in 200 bzw. 260 ms und gingen in genau denselben. Beim Schliessen hat der Nutzer
   aber schon weitergedacht — der Ausgang gehoert kuerzer und mit der Ausgangskurve gefahren
   (Kapitel 10 Auftrag 10 MUSS, Kapitel 11 Auftrag 11; Desktop `brand_tokens.py:220-234`).
   WIE ES GEHT, OHNE EINE ZWEITE WAHRHEIT ZU BAUEN: die Dauer haengt am ZUSTAND, nicht am
   Element. Der Grundzustand (= geschlossen, also die Strecke NACH aussen) traegt die
   Ausgangswerte, `.open` (= die Strecke NACH innen) die Eingangswerte. Beide Bauorte in js/
   bleiben unveraendert: geschlossen wird weiterhin allein durch `classList.remove("open")`
   (app.js, js/views/*.js, js/blatt_geste.js:231). */
/* [SCHALE A2] Blatt + Schleier stehen auf der Leiter UEBER jeder Vollbild-Ebene (Kernregel:
   --z-sheet 60 > --z-anno 55 > --z-vollbild-werkzeug 52). Vorher 40/41 — jedes Blatt, das aus
   Zeichenflaeche oder Pin-Arbeitsflaeche heraus oeffnete, lag KOMPLETT dahinter. */
.sheet-backdrop { position: fixed; inset: 0; z-index: var(--z-sheet-backdrop); background: rgba(8, 14, 28, 0.5); opacity: 0; transition: opacity var(--t-fast-out) var(--ease-aus); }
.sheet-backdrop.open { opacity: 1; transition: opacity var(--t-fast) var(--ease); }
.sheet {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-sheet); max-height: 88vh; max-height: 88svh; max-height: 88dvh; overflow-y: auto;
  background: var(--surface); border-radius: var(--radius) var(--radius) 0 0; box-shadow: var(--shadow-lg);
  padding: 8px max(18px, var(--safe-right)) 24px max(18px, var(--safe-left));
  /* DER HOME-BALKEN IST KEIN FREIER PLATZ — auch nicht MITTEN IM SCROLLEN.
     BEFUND, GEMESSEN 2026-07-26 im Pin-Blatt (der dichtesten Fläche der App), Safe-Area gesetzt:
       390x844, Blatt auf halbem Scrollweg:  2 Foto-Kacheln ragten 12 px in das 34-px-Band
       896x414 quer, Blatt auf halbem Weg:   9 Farbknöpfe + „Frei" standen mit ihrer sichtbaren
                                             Kante im 21-px-Band
     Am ENDE des Scrollwegs war alles sauber — das Polster unten hat also nur den letzten
     Zustand geschützt, nicht die 385 px Weg davor. Ein Knopf im Band ist nicht bloß knapp:
     ein Wisch von unten gehört dem Betriebssystem, nicht der App.
     WARUM RAND STATT POLSTER: bei einem Scroll-Container ist der SCROLLPORT die Polster-Box —
     Inhalt scrollt DURCH das padding-bottom hindurch. Der RAND liegt außerhalb des Scrollports;
     dort kann nie Inhalt liegen. Die Fläche bleibt bildgleich (der Rand trägt dieselbe
     --surface wie das Blatt), aber der Home-Indicator-Streifen ist jetzt strukturell leer.
     Bei safe-bottom:0 ist der Rand 0 px breit — kein reservierter Platz auf Geräten ohne
     Home-Balken. */
  border-bottom: var(--safe-bottom) solid var(--surface);
  /* Ausgang kuerzer als Eingang — Begruendung und Bauweise stehen bei `.sheet-backdrop`.
     Die Blatt-GESTE (js/blatt_geste.js) ist davon unberuehrt: sie setzt waehrend des Ziehens
     `transition: none` inline und raeumt am Ende `sheet.style.transition = ""` wieder ab,
     womit genau diese zwei Zeilen wieder greifen. */
  transform: translateY(100%); transition: transform var(--t-move-out) var(--ease-aus);
  -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
}
.sheet.open { transform: translateY(0); transition: transform var(--t-move) var(--ease); }
.sheet .grab { width: 44px; height: 4px; border-radius: 0; background: var(--border-strong); margin: 8px auto 12px; }
.sheet h2 { margin: 2px 0 4px; font-size: calc(20px * var(--ui-scale)); }
/* Klebender Sheet-Kopf (Bedienbarkeits-Welle recon2/04, „wichtig").
   BEFUND: Das Pin-Detail ist 1106 px Inhalt in einem 750 px hohen Blatt — „Schließen" und
   „Pin löschen" lagen bei y=1100, also 356 px unter der Bildschirmkante. Wer schliessen wollte,
   scrollte eine halbe Seite oder tippte blind neben das Blatt. Der Kopf bleibt jetzt oben stehen
   und trägt ein eigenes Schliessen-Kreuz: 0 statt 356 px Scrollweg. Die negativen Ränder heben
   das seitliche Sheet-Padding auf, damit der deckende Streifen bis an die Blattkante reicht. */
.sheet .sheet-head {
  position: sticky; top: 0; z-index: 3;
  display: flex; align-items: center; gap: 10px;
  margin: 0 calc(-1 * max(18px, var(--safe-right))) 4px calc(-1 * max(18px, var(--safe-left)));
  padding: 6px max(18px, var(--safe-right)) 10px max(18px, var(--safe-left));
  background: var(--surface); border-bottom: 1px solid var(--border);
}
/* Der Blattkopf trägt den PIN-NAMEN. Betreiber-Direktive: „Pin-Name statt Pin-ID in jeder
   Anzeige" — dann muss der Name auch ganz dastehen. Einzeilig mit Ellipse warf ihn bei 320 px
   weg (GEMESSEN im Pin-Blatt: „Nordwand Rohbau" abgeschnitten). Zwei Zeilen reichen für jeden
   praxisüblichen Pin-Namen; ab Zeile 3 greift die Klammer wieder, damit der klebende Kopf nicht
   das halbe Blatt frisst. Dieselbe Lösung wie bei `.row .title.title--2line`. */
.sheet .sheet-head h2 {
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; text-overflow: ellipsis; white-space: normal; line-height: 1.2;
}

/* ------------------------------------------------------------ per JS gebaute Sheets / Modals
   Anwesenheit (consent.js, supervisor.js, views/attendance.js) und der Sitzungs-Dialog
   (session_slots.js) bauen ihre Overlays selbst, ohne die .sheet-Mechanik oben. Vorher trugen sie
   dafür KOPIERTE Inline-Styles ohne jede Safe-Area: sie docken mit align-items:flex-end an die
   Unterkante, ihre unterste Zeile (Zustimmen/Abbrechen) lag damit auf dem Home-Indicator.
   Statt drei Kopien zu pflegen gibt es hier GENAU EINE Wahrheit — die JS-Dateien setzen nur noch
   die Klasse. Wer ein neues JS-Sheet baut, nimmt diese Klassen und ist automatisch safe-area-fest.

   .js-modal        = Backdrop, Vollbild, dockt unten an (Bottom-Sheet)
   .js-modal.center = Backdrop, Karte mittig (Dialog)
   .js-sheet        = die andockende Karte selbst (Polster unten = Grundmaß + Home-Indicator)
   .js-sheet-foot   = fixierte Fußleiste INNERHALB eines scrollenden Sheets — hier sitzen die
                      Knöpfe, also muss HIER das Home-Indicator-Polster liegen, nicht am Sheet. */
.js-modal {
  position: fixed; inset: 0; z-index: var(--z-modal);
  display: flex; align-items: flex-end; justify-content: center;
  background: rgba(4, 7, 15, .55);
  /* Seitlich aus der Querformat-Kerbe rücken; oben nur so viel, dass ein sehr hohes Sheet nicht
     unter der Statusleiste beginnt. Unten KEIN Polster: die Sheet-Fläche soll bis an die
     Displaykante laufen — das Inset trägt das Sheet als eigenes padding-bottom. */
  padding: var(--safe-top) var(--safe-right) 0 var(--safe-left);
}
.js-modal.center {
  align-items: center;
  padding:
    calc(18px + var(--safe-top))
    max(18px, var(--safe-right))
    calc(18px + var(--safe-bottom))
    max(18px, var(--safe-left));
}
.js-sheet {
  width: 100%; max-width: 560px; margin: 0;
  border-radius: var(--radius) var(--radius) 0 0;
  /* vh zuerst (alte Browser), dann dvh — sonst dieselbe iOS-Falle wie beim Plan-Viewer:
     100vh/88vh rechnen mit der GROSSEN Viewport-Höhe, das Sheet ragt unter die Browserleiste. */
  max-height: 88vh; max-height: 88dvh;
  overflow: auto;
  /* [shell 2026-08-05] Am Listenende NICHT an die Seite weiterreichen. `.js-sheet` und
     `.js-dialog-card` waren die einzigen zwei Scrollflaechen der App ohne diese Zusage — alle
     anderen fuehren sie ausdruecklich (.content, .sheet, .viewer .pinfinder-list, pin_style.js).
     Dieselbe Zeile, keine neue Idee. */
  overscroll-behavior: contain;
  /* Die JS-Sheets tragen zusätzlich .card (padding: 16px). Das Grundmaß bleibt also erhalten und
     der Home-Indicator-Inset kommt OBEN DRAUF; --js-sheet-pad ist der Haken, falls ein Sheet ein
     anderes Grundpolster hat. */
  padding-bottom: calc(var(--js-sheet-pad, 16px) + var(--safe-bottom));
}
/* [shell 2026-08-05] `.js-sheet-foot` WIRD ES JETZT WIRKLICH.
   Der Kommentar ueber diesem Block beschreibt die Klasse seit recon2/04 — eine Regel und einen
   Nutzer hatte sie nie (gegengeprueft: 0 Treffer im CSS-Regelbaum, 0 Treffer in js/ und app.js).
   Die Folge ist gemessen: im Blatt „Neuer Termin" (393x852) liegen „Abbrechen" und „Termin
   anlegen" bei y 949..999, der Viewport endet bei 852 — der Hauptknopf steht 97 px UNTER der
   Bildschirmkante, und es braucht 171 px Scrollweg, um ihn ueberhaupt zu sehen. Genau die Lage,
   die derselbe Kommentarblock fuer den KOPF (`position: sticky; top: 0`) laengst geloest hat.
   Das Polster fuer den Home-Indicator sitzt HIER, weil hier die Knoepfe sitzen; ein Sheet mit
   Fussleiste setzt `--js-sheet-pad: 0px`, damit es nicht doppelt polstert.
   EHRLICHE GRENZE: die Regel wirkt erst, wenn ein Bauort seine Knopfzeile hineinlegt. Die
   Bauorte (views/kalender.js, views/todos.js, views/chat.js) gehoeren dieser Welle nicht — sie
   stehen mit Datei und Zeile im Bericht. */
.js-sheet-foot {
  position: sticky; bottom: 0; z-index: 1;
  display: flex; gap: 10px; align-items: center;
  margin: 14px calc(-1 * var(--js-sheet-pad, 16px)) calc(-1 * var(--js-sheet-pad, 16px));
  padding: 12px var(--js-sheet-pad, 16px) calc(12px + var(--safe-bottom));
  background: var(--surface);
  border-top: 1px solid var(--border);
}
/* Beide Knoepfe teilen sich die Zeile und behalten die Trefferflaeche. */
.js-sheet-foot .btn { flex: 1 1 0; min-width: 0; min-height: 48px; }
/* Sheets mit eigenem Kopf + eigener Fußleiste, die INNEN scrollen: der Container selbst scrollt
   NICHT (overflow:hidden), sondern verteilt per Flex — deshalb liegt sein padding-bottom garantiert
   UNTER der Fußleiste und hebt genau die Knopfzeile über den Home-Indicator. Das Polster bleibt
   also bewusst am Container (kein extra Fuß-Polster, sonst doppelt). */
.js-sheet.split { overflow: hidden; display: flex; flex-direction: column; }
.js-sheet.tall { max-height: 92vh; max-height: 92dvh; }
/* Mittiger Dialog (session_slots): Karte selbst, Höhe gegen die dynamische Viewport-Höhe. */
.js-dialog-card {
  width: 100%; max-width: 440px;
  max-height: 88vh; max-height: 88dvh;
  /* [shell 2026-08-05] Siehe `.js-sheet`: die zweite und letzte Scrollflaeche ohne diese Zusage. */
  overscroll-behavior: contain;
  overflow: auto; padding: 20px; border-radius: var(--radius);
  background: var(--surface); color: var(--text); border: 1px solid var(--border-strong);
  box-shadow: 0 18px 60px rgba(0, 0, 0, .35);
}
/* [shell 2026-08-05] „Hier uebernehmen" im Sitzungs-Sperrdialog mass gemessen 150x40 px.
   js/session_slots.js setzt `min-height:40px` INLINE, und ein Inline-Stil schlaegt jede normale
   Regel — auch `.btn.small { min-height: 44px }` weiter oben. Der Dialog „Zu viele aktive
   Geraete" blockiert die ganze App, und dieser Knopf ist der einzige Weg nach vorn; mit
   Handschuh in der Sonne sind 40 px zu wenig.
   `!important` ist hier kein Holzhammer, sondern das im Haus bereits eingefuehrte Mittel gegen
   genau diesen Fall: index.html macht es fuer `.auth-wrap button` woertlich genauso, und
   tests/ux_feedback_contract.test.mjs haelt es dort ausdruecklich als noetig fest.
   SAUBER WAERE, die Hoehenangabe aus dem Inline-Stil zu streichen — die Datei gehoert dieser
   Welle nicht und steht mit Zeilennummer im Bericht. */
.session-slot-card .btn { min-height: 44px !important; }

.kv { display: flex; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: calc(14px * var(--ui-scale)); }
.kv .k { color: var(--text-muted); font-weight: 700; }
.kv .v { text-align: right; }
/* ---------------------------------------------------------------- Rechenzeile (Anwesenheit)
   [stil 2026-08-06] Soll · Ist · Differenz unter einer Sitzungszeile (js/views/attendance.js:3418).
   Die Werte sind kurz und sollen NEBENEINANDER stehen, solange die Breite reicht, und erst dann
   umbrechen — mit weitem Abstand zwischen den Paaren (14 px) und engem zwischen den Zeilen
   (4 px), damit ein Umbruch nicht wie eine neue Aufzaehlung aussieht.
   Sie steht hier und nicht inline, weil dieser Bestand seine Layoutregeln nach und nach
   zusammenfuehrt (.js-modal/.js-sheet sind denselben Weg gegangen): eine Textzeile, die ihre
   Abstaende selbst bestimmt, ist sonst der einzige Ort ihrer Art.
   EHRLICHE GRENZE: der Bauort setzt seine drei Deklarationen heute noch inline und schlaegt
   damit diese Regel. Er steht mit Datei und Zeile im Bericht. */
.att-rechnung { display: flex; flex-wrap: wrap; gap: 4px 14px; }
.att-rechnung .wert { font-weight: 800; }

/* ---------------------------------------------------------------- toasts + spinner */
#toasts { position: fixed; left: 0; right: 0; bottom: calc(var(--tabbar-h) + 22px); z-index: var(--z-toast); display: grid; justify-items: center; gap: 8px; pointer-events: none; padding-left: var(--safe-left); padding-right: var(--safe-right); }
/* [ZWZ-1 2026-08-24] Fremdbedarf des Loses pins_werkzeuge, zentral uebernommen. BEFUND: der
   Rueckgaengig-Toast (7 s) deckte „Loeschen" und „Fertig" der Massleiste — Loeschen nach dem
   Verschieben wirkte tot. js/views/project.js:updateMassBar (die EINE Tuer) setzt bei offener
   Leiste body.massbar-offen und --toast-ueber-leiste (gemessene Leisten-Oberkante + 8 px) und
   raeumt beides beim Verlassen der Ansicht. Bis zu dieser Uebernahme trug dieselbe Regel der
   VIEWER_CSS-Block in project.js (~Z.840); die dortige Zeile darf jetzt fallen — zwei
   Wahrheiten fuer dieselbe Regel sind der Anfang jeder Drift. */
body.massbar-offen #toasts { bottom: var(--toast-ueber-leiste, calc(var(--tabbar-h) + 22px)); }

/* ── [LOS chat 2026-08-24] Regelsaetze der Chat-Welle. Die Bauorte in js/views/chat.js tragen
   dieselben Werte heute als Inline-Stil; sie bleiben dort als Rueckfall stehen (die Blase wird
   auch ohne styles.css gerendert), die Klassen hier sind die pflegbare Wahrheit. */
/* Schnellleiste ueber der Blase: width:max-content statt max-width:100% — der Containing Block
   ist die SCHMALE Blase (Sonde 20 des Loses); die JS-Viewport-Klemmung bleibt zusaetzlich. */
.chat-schnellleiste {
  position: absolute; bottom: calc(100% + 4px); left: 0; z-index: 6;
  display: flex; gap: 2px; align-items: center;
  background: var(--surface); border: 1px solid var(--border); border-radius: 999px;
  padding: 4px 6px; box-shadow: 0 6px 18px rgba(0, 0, 0, .18);
  width: max-content; overflow-x: auto;
}
/* „Neue Nachrichten"-Sprungknopf: display schaltet JS auf inline-flex. */
.chat-neu-chip {
  position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
  display: none; align-items: center; gap: 6px; min-height: 40px; padding: 0 16px;
  border-radius: 999px; border: 1px solid var(--border); background: var(--surface);
  color: var(--navy-strong); font-weight: 800; font-size: calc(13px * var(--ui-scale));
  box-shadow: 0 4px 14px rgba(0, 0, 0, .16); white-space: nowrap;
}
/* Nachrichten-Treffer in der Kanalliste: Kanalzeilen-Optik, aber EIGENE Klasse — die Probe
   kanalliste_zusammengelegt nagelt fest, dass echte Kanalzeilen an genau EINEM Bauort entstehen. */
.chat-such-treffer {
  display: flex; min-height: 56px; padding: 8px 12px; align-items: center; gap: 10px;
  border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface);
  width: 100%; text-align: left; color: inherit;
}
/* Links in Blasen: `inherit`, damit der Kontrast auf BEIDEN Gruenden haelt (eigene getoente
   Blase und fremde --surface-Blase). */
.chat-bubble a.chat-link { color: inherit; text-decoration: underline; text-underline-offset: 2px; word-break: break-all; }
/* 90vw wäre im Querformat mit Kerbe zu breit (vw kennt die Insets nicht) — der zweite Zweig
   deckelt auf die tatsächlich sichtbare Breite. */
/* [Rueckportierung 2026-08-02, v1.9.63(6)] Der Toast laesst dem FAB Platz: beide stehen auf
   derselben bottom-Rechnung, der Toast (z 60) verdeckte mit -24px rund zwei Drittel der
   FAB-Flaeche und fing mit pointer-events:auto die Tipps ab. -100px = 24px Grundluft + 76px
   fuer den 58-px-FAB samt Rand; der Toast bleibt mittig, wird nur schmaler. */
/* [SCHNURMASS] Die Quittung ist EINE Flaeche in voller Tinte, und die Farbe steht als
   3-px-Kante links — nicht als dritter und vierter Flaechenton. So bleibt der Text in jedem
   Fall auf demselben nachgerechneten Grund (--bg auf --ink), egal welche Sorte Quittung es ist. */
.toast { pointer-events: auto; background: var(--ink); color: var(--bg); border-left: 3px solid var(--ink3); padding: 11px 16px; border-radius: var(--radius); font-size: calc(14px * var(--ui-scale)); font-weight: 600; box-shadow: var(--shadow-lg); max-width: min(90vw, calc(100vw - var(--safe-left) - var(--safe-right) - 100px)); animation: toastin var(--t-move) var(--ease); }
.toast.ok { border-left-color: var(--gruen); } .toast.err { border-left-color: var(--danger); }
/* [L8 2026-08-14] Die Aktualisierungs-LEISTE ist kein Quittungs-Toast.
   Sie verschwindet nicht von selbst (js/install.js baut den Knoten von Hand), deshalb darf sie
   auch nicht wie eine Quittung aussehen: ruhige Flaeche statt schwarzem Balken, volle Breite der
   Toast-Spalte, Text links und die Handlung rechts. Der Knopf erbt .btn.small und behaelt damit
   seine Trefferflaeche — die Leiste steht ueber der Tabbar, ein zu kleines Ziel waere hier
   besonders teuer. `align-items:center` haelt Text und Knopf auf einer Grundlinie, `gap:14px`
   trennt Aussage und Handlung sichtbar (Markenhandbuch §7: nie beides zu einem Block verkleben). */
.toast.update { display: flex; align-items: center; gap: 14px; width: min(92vw, 460px); max-width: min(92vw, 460px);
  background: var(--surface-raised, #0d1522); color: var(--text, #eaf0f6);
  border: 1px solid var(--border-strong, #56688a); font-weight: 600; }
.toast.update .u-text { flex: 1; min-width: 0; }
.toast.update .btn { flex: none; }
@keyframes toastin { from { transform: translateY(10px); opacity: 0; } }

/* ------------------------------------------------------------------ Ladeanzeige
   DIE 300-MILLISEKUNDEN-REGEL. Eine Ladeanzeige, die bei einem 80-ms-Vorgang
   aufblitzt, erklärt nichts — sie meldet nur, dass etwas passiert ist, das der
   Nutzer sowieso nicht abwarten musste. Zwei Bewegungen (Anzeige kommt, Anzeige
   geht) für null Information.
   Kreisel UND Skelette blenden deshalb erst nach 300 ms ein. Wer schneller fertig
   ist, sieht gar keine Anzeige; wer länger braucht, sieht sie ruhig erscheinen.
   `both` hält die Deckkraft während der Verzögerung auf 0.
   Bei reduzierter Bewegung setzt der Sammel-Block am Dateiende alle Verzögerungen
   auf 0 — dort erscheint die Anzeige sofort und ohne Einblendung. Das ist gewollt:
   „ruhig" hat Vorrang vor „elegant". */
@keyframes ind-in { from { opacity: 0; } to { opacity: 1; } }
.spinner {
  width: 34px; height: 34px; border-radius: var(--radius-rund); margin: 30px auto;
  border: 4px solid var(--border); border-top-color: var(--navy-bright);
  animation:
    spin var(--t-pulse) linear infinite,
    ind-in var(--t-fast) var(--ease) 300ms both;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Reduzierte Bewegung: der Kreisel dreht sich NICHT. Ein angehaltener Teilkreis sähe
   defekt aus — deshalb wird der Ring GESCHLOSSEN (alle vier Kanten in der Akzentfarbe)
   und leicht abgesetzt. Genau dieses Muster fährt die Website schon für ihren
   Knopf-Kreisel (global.css, `.button .spin`); zwei Flächen, eine Lösung.
   Der Zustand wird nicht allein von diesem Ring getragen: an drei der vier Fundstellen
   steht eine Textzeile daneben (pin/pin_annotate.js, views/pano.js 2x). Die vierte
   (attendance/consent.js) ist im Bericht als Übergabe vermerkt. */
@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation: none;
    border-color: var(--navy-bright);
    opacity: .6;
  }
}
.center-msg { text-align: center; color: var(--text-muted); padding: 44px 22px; line-height: 1.5; }
.center-msg .big {
  font-size: calc(40px * var(--ui-scale)); margin-bottom: 10px; line-height: 1;
  filter: saturate(.85); opacity: .92;
}

/* ------------------------------------------------------------------ Kaltstart-Ladezustand
   BEWEGUNGSVERTRAG §7, Zustand 6: „Laden — TEXT UND Anzeige, Fläche behält ihre Größe."
   BEFUND (Welle Q2D): index.html zeigte vor dem ersten Render NUR einen Kringel — kein Wort,
   kein role, kein aria-label. Der Flächenwechsel INNERHALB der App macht es längst richtig
   (vier Skelett-Bausteine unter dem stehenden Titel); ausgerechnet der allererste Eindruck
   nicht. Der Kringel allein sagt „irgendwas passiert" und sonst nichts — für einen
   Screenreader sagt er gar nichts.
   Der Text steht STATISCH im HTML und deutsch: die Sprachdateien (js/i18n.js) sind zu diesem
   Zeitpunkt noch nicht geladen, und die CSP verbietet Inline-Skripte (script-src 'self') —
   ein „schnell übersetzen, bevor app.js kommt" gäbe es also nur um den Preis einer
   Sicherheitslücke. Sobald app.js steht, ersetzt sie den Block ohnehin.
   Aufbau nach Markenhandbuch §7: 1. was ist, 2. warum/was passiert. Kein Ausrufezeichen,
   kein Emoji, keine Anbiederung. */
.boot { display: grid; justify-items: center; text-align: center; max-width: 34ch; }
.boot .spinner { margin: 0 auto 18px; }
.boot-title { margin: 0; font-size: calc(17px * var(--ui-scale)); font-weight: 800; color: var(--text); }
.boot-sub { margin: 6px 0 0; font-size: calc(14px * var(--ui-scale)); font-weight: 400; color: var(--text-soft); line-height: 1.45; }
/* Dieselbe 300-ms-Regel wie beim Kringel: wer in 200 ms fertig ist, sieht gar nichts. Wer
   länger braucht, sieht Wort UND Anzeige — und zwar gemeinsam, nicht nacheinander. */
.boot-title, .boot-sub { animation: ind-in var(--t-fast) var(--ease) 300ms both; }

/* install prompt */
.install-cta { display: flex; align-items: center; gap: 12px; }
.install-cta img { width: 40px; height: 40px; border-radius: var(--radius); }   /* [S19] war 10px */

/* ---------------------------------------------------------------- Firmenbezug (Byline)
   Dezente, textbasierte SchnurSolutions-Wortmarke (mono) für Login + Einstellungen —
   bewusst KEIN Bild-Asset: die PWA lädt vollständig offline. */
.brand-byline {
  display: inline-flex; align-items: baseline; justify-content: center; gap: 6px;
  /* Das Wörtchen „von" ist keine Deko, sondern der Absender-Bezug (Markenhandbuch §9: Firma und
     Produkt sind nicht dasselbe). Mit --text-faint stand es GEMESSEN bei 2,17:1 auf --bg; die
     Datei erklärt --text-faint selbst als „reine Deko". --text-soft ist der dafür angelegte Ton:
     GEMESSEN 4,73:1 auf #eaf0f6 hell und 5,22:1 auf --surface dunkel. */
  color: var(--text-soft); font-size: calc(11px * var(--ui-scale)); font-weight: 700;
  letter-spacing: .12em; text-transform: uppercase;
}
.brand-byline .wordmark {
  font-family: ui-monospace, "JetBrains Mono", "Cascadia Mono", "SF Mono", Consolas, monospace;
  font-size: calc(12px * var(--ui-scale)); letter-spacing: .06em; text-transform: none; color: var(--text-muted);
}
.brand-byline .wordmark b { font-weight: 800; color: var(--accent-ink); }

.hidden { display: none !important; }
.grid-2 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px; }
.stack { display: grid; gap: 10px; }
.muted { color: var(--text-muted); }
.right { text-align: right; }

/* ============================================================================
   Komponenten für Kalender / Chat / Galerie / Pano (Bridge-v2-Ausbau).
   Gleiche Design-Sprache: Token-Variablen aus :root, Navy + Gelb-Akzente,
   Radien/Schatten-Tokens, Touch-Targets ≥ 44 px. Dark-Theme automatisch via var().
   ============================================================================ */

/* ---------------------------------------------------------------- unread badge
   Basis (inline, z. B. in .channel-row); in der Tabbar absolut am Icon (.ico-wrap). */
.unread-badge {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 18px; height: 18px; padding: 0 5px; border-radius: var(--radius);   /* [S19] war 999px — Ungelesen-Zaehler */
  /* KONTRAST (Markenhandbuch §8, Text 4,5:1). VORHER: Verlauf #ef4444 → #b91c1c mit Weiß. Ein
     Verlauf hat KEINEN Kontrastwert, sondern zwei: am dunklen Ende 6,47:1, am hellen Ende aber
     GEMESSEN 3,76:1 — und die Ziffer sitzt in der Mitte. 52 Vorkommen in der App.
     JETZT eine Fläche und eine Tinte, also genau EIN nachrechenbarer Wert:
       hell   Weiß auf #dc2626  4,83:1   dunkel  #04231e auf #f87171  6,01:1
     Der Schatten bleibt (Erhebung, kein Bedeutungsträger).
     [stil 2026-08-06] BEWUSST NICHT AUF DEN PC-WERT GEZOGEN. Bau-Brief 50 (B3) nennt fuer die
     Ungelesen-Blase `#ef4444`. GERECHNET traegt Weiss darauf nur ~3,3:1 und faellt damit unter
     die 4,5:1 aus Markenhandbuch §8; der hier stehende Wert misst 4,83:1. Bildgleichheit gegen
     Lesbarkeit — und §8 ist die haertere Zusage. Der Nachzug gehoert deshalb auf die ANDERE
     Seite: der Desktop sollte #dc2626 uebernehmen. Steht so im Bericht. */
  background: var(--danger-fill); color: var(--on-danger);
  font-size: 11px; font-weight: 900; line-height: 1;
  box-shadow: 0 2px 6px rgba(185, 28, 28, 0.35);
}
.tabbar .ico-wrap { position: relative; display: inline-grid; place-items: center; }
.tabbar .unread-badge { position: absolute; top: -5px; right: -14px; border: 2px solid var(--surface-raised); }

/* ---------------------------------------------------------------- segmented control */
.seg {
  display: inline-flex; width: 100%; padding: 4px; gap: 4px; border-radius: var(--radius);
  background: var(--surface-2); border: 1px solid var(--border);
  /* [2026-07-30] Umbruch statt Beschnitt. GEMESSEN: im To-do-Blatt wird bei 320 px in de
     und fr der vierte Prioritaetsknopf („Kritisch") beschnitten. Grund: `.seg button` hat
     `flex: 1` (= `1 1 0%`), aber `min-width` bleibt `auto` — das Kind schrumpft nicht unter
     sein laengstes Wort, und ohne `flex-wrap` bleibt nur die eine Zeile.
     WARUM NUR DIESE EINE ZEILE UND NICHT `flex: 1 1 auto; min-width: 0` AM KNOPF:
     das setzte die Basis auf die Inhaltsbreite und machte die Segmente in JEDEM `.seg` der
     App ungleich breit (todos.js:454/1215, kalender.js:784, projects.js:97) — keines davon
     laeuft ueber, sie saehen nur schlechter aus. Mit `flex-wrap` allein bleibt Basis 0, also
     bleiben die Segmente GLEICH breit; der Umbruch nutzt die min-content-Breite als Klammer.
     `min-width: 0` allein waere sogar schlechter als heute: hypothetische Hauptgroesse 0
     heisst NIE Umbruch, und der Text liefe aus dem Knopf (kein `overflow: hidden`). */
  flex-wrap: wrap;
}
/* [SCHRIFT-8 2026-08-24] NUR im Kalender: sechs Segmente brechen bei Stufe 1,3 als 5+1 um
   ("Agenda" als Waise). Dort Querscroller wie .chips statt Umbruch — die Segmente bleiben
   gleich breit und in EINER Zeile; auf jeder anderen .seg bleibt der 2026-07-30-Umbruch. */
.kal-view .seg { flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
.kal-view .seg::-webkit-scrollbar { display: none; }
.kal-view .seg button { white-space: nowrap; }
.seg button {
  flex: 1; min-height: 44px; padding: 8px 12px; border: 0; border-radius: var(--radius-klein);
  background: none; color: var(--text-muted); font-family: var(--f-display); font-size: calc(14px * var(--ui-scale)); font-weight: 700; cursor: pointer;
  transition: color var(--t-fast) var(--ease), background var(--t-fast) var(--ease), transform var(--t-tap) var(--ease);
}
/* Berührungsantwort: der Umschalter wechselt seinen Zustand erst, wenn die Ansicht neu
   gebaut ist. Bis dahin sah er unberührt aus. */
.seg button:active { transform: scale(.96); }
.seg button[disabled]:active { transform: none; }
/* Aktiv: die Flaeche hebt sich, und ein 3-px-Gruenbalken an der UNTERKANTE sagt es —
   statt eines Schlagschattens, den es in diesem System nicht mehr gibt. */
.seg button.active {
  background: var(--surface); color: var(--accent-ink);
  box-shadow: inset 0 -3px 0 0 var(--gruen); border: 1px solid var(--border);
}
/* Gesperrtes Segmented-Control (Nur-Lese-Rolle/Lizenz): sichtbar deaktiviert statt still inert.
   Kein Hover-Tooltip (Projektverbot) — der Grund steht im Nur-Lese-Banner desselben Sheets.
   Nur Deckkraft/Rahmenstil, keine festen Farben → trägt in HELL und DUNKEL gleichermaßen. */
.seg[data-disabled="1"] { border-style: dashed; }
.seg button[disabled] { opacity: .5; cursor: not-allowed; }
.seg button[disabled].active { box-shadow: none; }

/* ---------------------------------------------------------------- FAB */
.fab {
  /* Querformat mit seitlicher Kerbe: der FAB muss aus der Kerbe rücken (hochkant Inset = 0). */
  position: fixed; right: max(18px, var(--safe-right)); bottom: calc(var(--tabbar-h) + 22px); z-index: var(--z-fab);
  /* [SCHNURMASS] 62x62 aus den abgenommenen Tafeln, mit der FASE. Sie ist hier am
     deutlichsten zu sehen und deshalb der Ort, an dem die Marke am billigsten wirkt:
     eine Regel, kein View-Eingriff. Wie beim Primaerknopf ein einzelnes Element —
     der Preis ist die fehlende Kante auf den zwei Diagonalen. */
  width: 62px; height: 62px; border-radius: 0; border: 1px solid var(--gruent);
  clip-path: var(--fase-schnitt);
  background: var(--accent-cta); color: var(--accent-fill-ink); font-size: 26px; font-weight: 800; cursor: pointer;
  display: grid; place-items: center; box-shadow: var(--shadow);
  transition: transform var(--t-tap) var(--ease), box-shadow var(--t-fast) var(--ease);
}
.fab:active { transform: scale(.94); background: var(--gruenp); }
/* Breite Screens: FAB an die rechte Kante der App-Spalte legen (nicht an den Bildschirmrand,
   sonst schwebt er einsam im Marken-Hintergrund). */
@media (min-width: 760px) {
  .fab { right: calc((100% - var(--shell-max)) / 2 + 18px); }
}

/* [BED-1 2026-08-24] DER RECHTE KORRIDOR — die STILHAELFTE des Befunds „der FAB verschluckt
   Bedienteile".
   ===========================================================================================
   WAS GEMESSEN WURDE (eigene Sonde schnur-rig/sonden/_rep2408_r1_10_fab_deckung.mjs, Ausgabe
   schnur-rig/abnahme/_rep2408_r1_fab_deckung_vorher.json, 390x844, echte Trefferrechnung mit
   elementFromPoint auf der Mitte JEDES sichtbaren Bedienteils, alle SECHS Reiter der Tabbar,
   beide Themen, beide Textstufen — 24 Messungen):
     SECHS verdeckte Bedienteile, aus GENAU ZWEI Bauformen:
       (1) 4x der Aktionsknopf am rechten Ende einer Kanalzeile
           (.channel-row .btn.ghost.small, r=[324,717,368,761] bei Stufe 1,0 und
           r=[324,688,368,745] bei 1,3, hell wie dunkel) — FAB r=[310,697,372,759].
       (2) 2x der Prioritaets-Chip „Normal" der To-do-Filterleiste, NUR bei Stufe 1,3
           (r=[290,704,376,748]).
   Die alte Regel `.content:has(.fab) { padding-bottom: … + 96px }` (weiter oben) schuetzt nur
   das ENDE der Liste. Was MITTEN in der Liste unter dem Kreuz zur Ruhe kommt, war ungeschuetzt
   — und weil der FAB voll deckend ist, landet der Tipp nicht ins Leere, sondern auf einer
   ANLEGE-Handlung. Das ist die teure Haelfte des Befunds.

   DER KORRIDOR WURDE GEBAUT, GEMESSEN UND WIEDER ZURUECKGENOMMEN — mit Beleg, damit ihn
   niemand ein zweites Mal blind baut:
   * GEBAUT als `.channel-list .channel-row + .btn { right: calc(8px + 80px) !important }`.
     ER WIRKT: die Deckung faellt von SECHS auf ZWEI (Ausgabe
     _rep2408_r1_fab_deckung_nachher.json), der Knopf steht danach bei 244..288 statt 324..368
     (_rep2408_r1_bild.json).
   * ER KOSTET ABER MEHR, ALS ER BRINGT. Der Knopf ist ABSOLUT positioniert und liegt damit
     UEBER dem Textfeld der Zeile (`.channel-row .grow` reicht bis zum rechten Zeilenrand).
     Schiebt man ihn nach innen, druckt er auf der Vorschauzeile: Bildvergleich
     abnahme/_rep2408_r1_kanalliste_light.png (nachher) gegen
     abnahme/_skept2408_rep2408_r1_fab_vorher_light_s1_Chat.png (vorher) — die drei Punkte
     stehen dort mitten in „Projektuebergreifend · Anna Baumei…". Vollstaendig waere der
     Handgriff erst mit einem Reservat in der dehnenden Textspalte der Zeile (rund 132 px
     Randabstand rechts), und das nimmt der Vorschau auf 390 px rund ein Drittel.
     NEBENBEI GELERNT, weil es hier zugeschnappt ist: die erste Fassung dieses Absatzes
     zitierte den Selektor der Textspalte samt geschweifter Klammer — und
     tests/w18_knopfspalte liest styles.css mit einem Muster, das JEDE Zeile mit dieser Klasse
     und einer oeffnenden Klammer fuer eine Regel haelt. Der Kommentar wurde damit selbst zum
     Fundort und die Probe rot. Wer hier Selektoren zitiert, laesst die Klammer weg.
   * DAZU DIE ABWAEGUNG, die den Ausschlag gab: der Schaden dieser Bauform ist ein Tipp auf
     „Neuer Kanal" — ein Blatt, das man wegwischt. Der Schaden der Bauform (2), die hier
     ohnehin nicht erreichbar ist, ist ein ANGELEGTES To-do. Ein Drittel der Vorschauzeile fuer
     die harmlose Haelfte zu bezahlen und die teure offen zu lassen, ist der schlechtere
     Tausch. Der Handgriff gehoert dorthin, wo Knopf UND Textbreite in EINER Hand liegen:
     in js/views/chat.js, an den Zeilenknopf der Kanalzeile (`right: calc(8px + var(--fab-korridor))`
     PLUS Reservat im `.grow`). Als Fremdbedarf gemeldet, mit Zahl und Aufrufform.
     [SICHT-C4 2026-08-25] HIER STAND EINE ZEILENNUMMER (js/views/chat.js:4398). Sie ist heute
     falsch — die Datei ist seither mehrfach gewachsen. Zeilennummern in FREMDE Dateien rotten
     schneller, als dieses Blatt gelesen wird; der Anker ist die Aufrufform.
   * BAUFORM (2) IST VON HIER AUS UEBERHAUPT NICHT ZU LOESEN, und das steht hier, damit es niemand fuer
     erledigt haelt: die Filterleiste der To-dos traegt GAR KEINE Klasse (js/views/todos.js baut
     sie als `const filterZeile = el("div", { role: "group", "aria-label": tr("Filter"), ... })`),
     ihre Raender stehen INLINE und schlagen jede Regel dieser Datei. Der im Befund
     vorgeschlagene Selektor `.content:has(.fab) .chips` geht ins Leere — gemessen: 0 Elemente
     mit der Klasse `chips` im To-do-Reiter (_rep2408_r1_chipband.json, Feld alleChipBaender).
     [SICHT-C4 2026-08-25] AUCH HIER STAND EINE ZEILENNUMMER (js/views/todos.js:1658) — heute
     zeigt sie auf `export function fundstellen(`. UND EIN SATZ IST UEBERHOLT: hier stand, ein
     Chip in einem WAAGERECHT scrollenden Band habe keine feste x-Lage. Das Band ist seit der
     To-do-Welle vom 25.08. weg; die Zeile bricht heute um (`flex-wrap:wrap`, kein
     `overflow-x:auto` mehr). Das Argument gegen den Korridor traegt trotzdem, aber aus einem
     anderen Grund — siehe die naechste Zeile. Der Chip liegt ueberhaupt nur deshalb in der
     FAB-Spur, weil
     der To-do-Kopf bei Stufe 1,3 bis y=702 waechst (Befund TK-B, fremdes Los). Beides ist als
     Fremdbedarf gemeldet; der ehrliche Weg dort ist das AUSWEICHEN des Kreuzes (Body-Klasse aus
     dem Scroll-Ereignis, Muster `--toast-ueber-leiste`), und dessen Erzeuger ist JavaScript.
     Einen Abnehmer dafuer hier ohne Erzeuger hinzuschreiben waere Wurzelmuster W1 („Erzeuger
     fehlt") — deshalb steht er nicht da.

   WAS BLEIBT: DIE ZAHL, an EINER Stelle. 62 px Kreuz + 18 px Abstand zur Kante = 80 px Spur.
   Auf Geraeten mit seitlicher Kerbe steht der FAB weiter innen (sein Kantenmass ist ein max()
   gegen --safe-right), dort ist der Korridor um die Kerbe kleiner — deshalb der Abzug,
   geklemmt auf 0. Wer den FAB in Groesse oder Abstand aendert, aendert die Zahl hier mit.

   [SICHT-2 2026-08-25] DAS TOKEN HAT HEUTE NULL VERBRAUCHER — dieser Absatz ersetzt den
   frueheren Satz „Das Token ist KEIN toter Abnehmer".
   GEZAEHLT am ausgelieferten Baum, ENTKOMMENTIERT (styles.css, alle js/, app.js, sw.js):
     Regeln in diesem Blatt, die var(--fab-korridor) lesen ....... 0
     Stellen in js/ und sw.js, die es lesen ...................... 0
   Genannt wird der Name ausserhalb dieses Blattes nur noch INNERHALB von Begruendungsbloecken,
   in js/views/chat.js und in sw.js. Kein Verbrauch.
   [SICHT-C5 2026-08-25] DIE BUCHHALTUNG DIESES ABSATZES WAR VERROTTET, und zwar genau an der
   Stelle, an der sie am genauesten aussah. Hier stand eine ANZAHL („noch viermal") und dazu
   vier Zeilennummern: drei in js/views/chat.js und eine in sw.js. Nachgezaehlt am heutigen
   Baum stimmt keine davon — in sw.js steht der Name an ZWEI Stellen und an keiner der
   genannten, und die chat.js-Nennungen sind mitgewandert, weil andere Lose diese Datei
   fortlaufend fortschreiben. Die Anzahl war damit schon falsch, als sie noch gelesen wurde.
   DESHALB STEHT HIER JETZT KEINE ANZAHL UND KEINE ZEILE MEHR: beide gehoeren FREMDEN Dateien
   und rotten mit jedem Satz, den dort jemand schreibt. Was traegt, ist der VERBRAUCH — und den
   rechnet die Probe [sicht-4] am entkommentierten Baum nach, statt diesen Satz zu suchen.
   DER GRUND IST GEBAUT, NICHT VERGESSEN: Welle 4 hat den Zeilenmenue-Knopf des Chats von
   102 px Einrueckung auf buendige 8 px gesetzt und dem Kreuz das AUSWEICHEN beigebracht
   (js/views/chat.js, fabRaus und fabZurueckWennFrei — Transform PLUS pointer-events, solange
   die Liste rollt). Damit ist der Korridor an seinem einzigen geplanten Verbrauchsort
   ueberfluessig geworden. Der oben als „ehrlicher Weg" benannte Ausweichmechanismus EXISTIERT
   also inzwischen — aber nur im Chat, und er rechnet mit einem Transform, nicht mit dieser
   Breite.
   WARUM DAS TOKEN TROTZDEM STEHT, und das ist eine Entscheidung, keine Traegheit:
     (a) Es ist der einzige Ort, an dem die Spurbreite als ZAHL steht. Wer sie loescht, muss
         sie beim naechsten Bedarf neu ausmessen; die letzte Messung hat 24 Sonden gekostet.
     (b) To-dos und Kalender haben das Ausweichen NICHT. GEMESSEN 25.08. bei 320 px/Stufe 1,3:
         im To-do-Reiter deckt das Kreuz die rechte Haelfte der ersten Listenzeile
         (Bild sicht/todos_320_light_1_3.png), im Kalender die rechte Haelfte des ersten
         Agenda-Eintrags (kalender_320_light_1.png). Beides ist durch Weiterrollen aufloesbar
         — in KEINER der 108 Konfigurationen entstand eine Sackgasse (0 Deckungen mit
         Restrollweg 0) — aber sichtbar ist es. Wer das Ausweichen dort nachbaut, braucht
         diese Zahl.
   ES IST ALSO EIN VORRAT MIT NAMEN UND DATUM, kein stiller Rest. Faellt (b) weg, faellt das
   Token mit ihm — und mit ihm die Probe r1-14.

   DIE HAUSREGEL DAZU, ausgesprochen, weil sie hier zweimal fast gebrochen wurde: ein Selektor,
   der nichts trifft, und eine Regel, die die Deckung nur verschiebt, sind beide schlechter als
   ein ehrlich gemessener offener Punkt. Der Stand ist gemessen (24 Messungen, sechs Reiter,
   beide Themen, beide Stufen), die Ausgaben liegen, der Rest ist gemeldet.

   DAS TOKEN --fab-korridor STEHT NICHT HIER, sondern oben im EINEN nackten :root. Der erste
   Anlauf hat es hier hingeschrieben und damit einen ZWEITEN nackten :root erzeugt —
   tests/stil_schnurmass Fall 1 („die Tokens haben einen Ort, nicht zwei") wurde daran sofort
   rot, und mit ihm neun Folgefaelle, die alle im selben Block lesen. Die Probe hatte recht. */

/* ---------------------------------------------------------------- Kalender: Monatsgrid */
.cal-nav { display: flex; align-items: center; gap: 10px; margin: 4px 2px 10px; }
.cal-nav .title { flex: 1; text-align: center; font-weight: 900; font-size: calc(17px * var(--ui-scale)); }
.cal-nav button {
  width: 44px; height: 44px; border-radius: var(--radius); border: 1px solid var(--border);
  background: var(--surface); font-size: 18px; font-weight: 800; color: var(--accent-ink); cursor: pointer;
  transition: transform var(--t-tap) var(--ease), border-color var(--t-fast) var(--ease);
}
/* Berührungsantwort: Monat vor/zurück baut das ganze Raster neu — bis es steht, war der
   Knopf stumm. */
.cal-nav button:active { transform: scale(.94); }
.cal-grid {
  display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
  box-shadow: var(--shadow); padding: 10px;
}
.cal-head {
  text-align: center; font-size: calc(11px * var(--ui-scale)); font-weight: 800; letter-spacing: .05em;
  text-transform: uppercase; color: var(--text-soft); padding: 4px 0 6px;
}
.cal-day {
  position: relative; min-height: 52px; border-radius: var(--radius); padding: 4px 4px 14px;
  background: var(--surface-2); border: 1px solid transparent; cursor: pointer;
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  transition: transform var(--t-tap) var(--ease), background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.cal-day:active { transform: scale(.97); }
.cal-day .d { font-size: calc(13px * var(--ui-scale)); font-weight: 800; color: var(--text); }
/* [KONTRAST L9-07 2026-08-25, CSS-Haelfte] HIER STAND `opacity: .38` AUF DER GANZEN ZELLE.
   DER BEFUND IST EINE MULTIPLIKATION, und genau deshalb hat ihn niemand gesehen: js/views/
   kalender.js setzt in KAL_STIL auf der Tagesziffer zusaetzlich `color: var(--text-faint);
   opacity: .55`. Beide Deckkraefte stapeln sich (0,38 x 0,55 = 0,209), und --text-faint ist
   --ink3 #63686F. GERECHNET auf --fl #FFFFFF: die Ziffer allein traegt 5,61:1, mit .55 noch
   2,29:1 — und mit der Zellen-Deckkraft darueber rund 1,5:1. Es sind TAGESZIFFERN, also
   echter Lesetext (Soll 4,5:1).
   DIE HAUSREGEL DAZU steht in dieser Datei schon zweimal ausgeschrieben: die Abstufung traegt
   das TOKEN, nicht die Deckkraft (`.planebenen-zeile.leer` nimmt --ink3 pur, mit dem Vermerk
   „Sie bleiben lesbar (--ink3 ist AA)"). Deckkraft auf einer schon abgestuften Tinte rechnet
   ein AA-Token unter die Schwelle, und niemand sieht es im Quelltext.
   WAS DIESE HAELFTE ERREICHT: die Multiplikation faellt weg, die Ziffer steht bei 2,29:1
   statt bei rund 1,5:1. AA IST DAMIT NOCH NICHT ERREICHT — dafuer muss die zweite Haelfte
   fallen: `opacity: .55` in js/views/kalender.js (KAL_STIL). Die Datei gehoert dem Los
   Kalender und steht im Fremdbedarf dieses Berichts.
   Der Punkt der Zeile behaelt eine Abstufung, aber eine hoehere: er ist der einzige Hinweis
   „hier liegen Termine" und war bei 0,38 auf einem Nachbarmonatstag kaum noch da. */
.cal-day.other-month .d { color: var(--text-faint); }
.cal-day.other-month .cal-event-dot { opacity: .55; }
/* [stil 2026-08-06] EINE AUSSAGE, EIN TRAEGER. Hier stand zusaetzlich
   `background: rgba(10,168,146,0.10)` — die getoente Zelle. Seit js/views/kalender.js:1736 den
   heutigen Tag als 22x22-Markenpille malt (Bau-Brief 50, B5, wie am PC), sagten ZWEI Dinge
   dasselbe: getoente Flaeche UND gefuellte Pille. Im 42-Zellen-Raster liest sich das als zwei
   verschiedene Zustaende („heute" und „irgendwie auch markiert"), und die Auswahl (.selected)
   hat daneben keinen Platz mehr, sich abzuheben. Der PC hat nur die Pille.
   Der RAHMEN bleibt: er ist die einzige Marke, die auch dann noch traegt, wenn der heutige Tag
   ausserhalb des angezeigten Monats liegt (.other-month, Deckkraft .38) — dort wuerde die
   Pille mit blass. */
.cal-day.today { border-color: var(--navy-bright); }
.cal-day.today .d { color: var(--accent-ink); }
.cal-day.selected { border-color: var(--navy-bright); box-shadow: var(--focus-ring); }
.cal-day .dots { position: absolute; bottom: 5px; display: flex; gap: 3px; justify-content: center; }
.cal-event-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--navy-bright); flex: none; }
.cal-event-dot.more { width: auto; height: auto; background: none; color: var(--text-soft); font-size: calc(9px * var(--ui-scale)); font-weight: 900; line-height: 6px; }
.cal-event-pill {
  display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 11px; min-height: 44px;
  border-radius: var(--radius); background: var(--gruenw); border-left: 4px solid var(--gruen);
  font-size: calc(13px * var(--ui-scale)); font-weight: 700; color: var(--text);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cal-event-pill .time { color: var(--text-muted); font-weight: 800; font-size: calc(12px * var(--ui-scale)); flex: none; }

/* Kalender: Agenda-Liste */
.agenda-list { display: grid; gap: 8px; }
.agenda-date {
  font-size: calc(12px * var(--ui-scale)); font-weight: 900; letter-spacing: .06em; text-transform: uppercase;
  color: var(--text-soft); margin: 12px 4px 2px;
}
.agenda-item {
  display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 10px 14px;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--shadow); cursor: pointer;
}
.agenda-item:active { transform: scale(.99); }
.agenda-item .bar { width: 4px; align-self: stretch; border-radius: 3px; background: var(--navy-bright); flex: none; }
.agenda-time { flex: none; min-width: 52px; font-size: calc(13px * var(--ui-scale)); font-weight: 800; color: var(--text-muted); }
.agenda-item .title { flex: 1; min-width: 0; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.agenda-item .sub { color: var(--text-muted); font-size: calc(12px * var(--ui-scale)); }

/* ---------------------------------------------------------------- Chat: Kanalliste */
.channel-list { display: grid; gap: 8px; }
.channel-row {
  display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 10px 14px;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--shadow); cursor: pointer;
}
/* [SICHTPRUEFUNG 19.08.2026] DIE KANALLISTE WAR DIE LETZTE KARTENLISTE DER APP.
   GESEHEN auf shots/schnurmass/chat-390-hell.png: sechs weisse Kaesten mit Rahmen und
   8 px Luft dazwischen, waehrend Projekte, To-dos und die Agenda in derselben Welle zur
   Zeile mit Haarlinie geworden sind. Die abgenommene Richtung sagt es fuer alle Listen
   gleich: „getrennt wird durch Kante", und die Zeile ist keine Karte. Zwei Listensprachen
   in einer App sind eine zu viel — und die Kartenoptik hat hier zusaetzlich verhindert,
   dass die SCHNUR sichtbar wird (die Rinne lief hinter den Kastenraendern ins Leere).
   Die zweite Deklaration steht als eigener Block, damit die Ausgangslage oben lesbar
   bleibt; sie gewinnt, weil sie spaeter kommt. */
.channel-row {
  background: transparent; border: 0; border-bottom: 1px solid var(--border);
  border-radius: 0; box-shadow: none; padding: 12px 0;
}
.channel-list > *:last-child .channel-row, .channel-list > .channel-row:last-child { border-bottom: 0; }
.channel-row:active { transform: scale(.99); }
.channel-row .hash {
  width: 40px; height: 40px; border-radius: var(--radius); flex: none; display: grid; place-items: center;   /* [S19] war 12px */
  background: var(--gruenw); color: var(--accent-ink); font-weight: 900; font-size: 17px;
  /* [SICHTPRUEFUNG 19.08.2026] Das Sigel („#" fuer Kanal, Schloss fuer geschlossen) stand auf
     der gruenen Tonflaeche mit gruener Tinte — bei acht Kanaelen acht gruene Kacheln
     untereinander. Ein Sigel ist eine GATTUNG, kein Zustand: keines der vier Aemter des
     Gruens (aktiver Reiter, Fokusring, Zustandsknoten, DER EINE Primaerknopf) deckt es.
     Es traegt jetzt dieselbe Sprache wie das Kuerzel-Kaestchen im Faden und die
     Initialen-Kachel der Einstellungen — eingelassene Flaeche, Markengrau, volle Tinte.
     GERECHNET: --ink auf --fl2 15,08:1 hell / 16,54:1 dunkel. Das Gruen dieser Liste
     gehoert dem Knoten der Schnur, der jetzt endlich zu sehen ist. */
  background: var(--fl2); color: var(--ink); border: 1px solid var(--line2);
}
.channel-row .grow { flex: 1; min-width: 0; }
.channel-row .name { font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--ink2); }
.channel-row .last { color: var(--text-muted); font-size: calc(13px * var(--ui-scale)); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* [SCHRIFT-6] skaliert wie .name */
/* [WEBSITE-ANGLEICH 20.08.2026] DER UNGELESENE KANALNAME GIBT DAS GRUEN AB — sechstes Amt,
   und dazu ein WIDERSPRUCH IN DERSELBEN ZEILE.
   GEMESSEN am Rig (8911, 390x844, hell, Reiter „Chat"): vierzehn Kanalnamen standen
   gleichzeitig in --gruent. Das war die groesste zusammenhaengende Gruenmenge der ganzen
   App — mehr als der Primaerknopf, der aktive Reiter und der Fokusring zusammen.
   „Ungelesen" ist keines der vier Aemter. Und es ist bereits gesagt, zweimal:
     · der Knoten der Schnur links der Zeile faerbt sich BERNSTEIN
       (`.channel-list > .channel-row.unread::before { background: var(--warn-fl) }`,
       gut sechzig Zeilen weiter oben),
     · und der Ungelesen-Zaehler steht am rechten Rand der Zeile.
   Bernstein heisst im Haus „braucht deinen Handgriff" — fuer eine ungelesene Nachricht ist
   das genau richtig. Der gruene Name sagte in derselben Zeile etwas anderes; wer beides
   liest, liest zwei Bedeutungen fuer eine Tatsache und glaubt am Ende keiner mehr.
   DER UNTERSCHIED WANDERT VON DER FARBE AUF DEN RANG. Das ist das Zwei-Ton-Gesetz aus dem
   Tokenkopf, woertlich: „--ink2/--ink3 tragen Rang, nie Tatsache". Der GELESENE Kanal steht
   jetzt im zweiten Rang (--ink2), der UNGELESENE in voller Tinte (--ink):
     gelesen   --ink2 #4E5359 auf --fl = 7,76:1 (hell) · #AEB3B9 auf #14171B = 8,52:1 (dunkel)
     ungelesen --ink  #14161A auf --fl = 18,11:1 (hell) · #F2F1ED auf #14171B = 15,91:1
   Beide bleiben ueber AAA; unterschieden werden sie durch einen Sprung von Faktor 2,3.
   FALLE, IN DIE ICH SELBST GETRETEN BIN: der erste Anlauf setzte nur die ungelesene Zeile
   auf --ink und liess die gelesene, wie sie war. Am Rig war das nicht zu sehen, weil ALLE
   zwoelf Kanaele des Messstands ungelesen sind — es gab keine Gegenprobe. Gemessen war die
   gelesene Zeile aber ebenfalls --ink (sie erbt vom body), der Unterschied waere also
   ersatzlos verschwunden. Wer hier etwas aendert, stellt sich zuerst eine GELESENE Zeile
   her (`document.querySelector('.channel-row').classList.remove('unread')`), sonst misst er
   nur die eine Haelfte.
   Die Fettung 800 stand schon vorher an jedem Namen und bleibt; sie unterscheidet nicht,
   sie ist die Gattung. */
.channel-row.unread .name { color: var(--ink); }

/* Chat: Nachrichtenliste */
.chat-list {
  display: flex; flex-direction: column; gap: 10px;
  /* [shell 2026-08-05] NACHGEPRUEFT UND ABSICHTLICH NICHT GEAENDERT — die 88 px sind nur der
     Rueckfall fuer das erste Bild. Die WIRKSAME Zahl setzt js/views/chat.js zur Laufzeit:
     applyComposerSpacing() misst die echte Schreibzeilen-Hoehe (`composer.offsetHeight`),
     addiert den Tastatur-Versatz und schreibt das Ergebnis als Inline-Polster auf genau dieses
     Element — mit eigenem ResizeObserver fuer das mitwachsende Textfeld und
     visualViewport-Empfaengern fuer die Bildschirmtastatur. Ein Inline-Stil schlaegt diese
     Regel ohnehin.
     Wer hier eine groessere Zahl eintraegt, weil die Schreibzeile bei 393x852 gemessen 170 px
     hoch ist, baut also eine ZWEITE Wahrheit neben eine bereits richtige. Der verbleibende
     Befund liegt nicht an dieser Zeile, sondern an der Hoehe selbst: die zweite Knopfreihe
     (Anhang/Sprache/Erwaehnung) steht dauerhaft offen und kostet zusammen mit der Reiterleiste
     27,6 % des Bildschirms. Das gehoert in chat.js und steht im Bericht. */
  padding: 8px 2px calc(var(--tabbar-h) + 88px);
}
.chat-msg { display: flex; flex-direction: column; align-items: flex-start; max-width: 86%; }
.chat-msg.mine { align-self: flex-end; align-items: flex-end; }
/* -------------------------------------------------------------- DIE BLASE (Bau-Brief 50, B1)
   [stil 2026-08-06] DREI BEFUNDE AUS DEM BILDVERGLEICH GEGEN DEN PC, alle drei hier geheilt:

   1. DIE FREMDE BLASE WAR WEISS AUF WEISS. Sie trug `var(--surface)` — GEMESSEN im hellen Thema
      rgb(255,255,255), also exakt die Farbe der Kartenflaeche, auf der sie liegt. Getrennt hat
      sie davon nur ein 1-px-Rahmen. Der PC fuellt sie `#e7eaed` (hell) bzw. `#1e293c` (dunkel);
      damit hat die Blase einen KOERPER statt einer Kontur. GERECHNET:
        #0d1420 auf #e7eaed  = 15,03:1      #ffffff auf #1e293c  = 14,61:1
      Beide Werte gelten themenfest, weil Flaeche UND Tinte hier fest stehen — genau das ist der
      Punkt: eine Blase, deren Farbe vom Thema abhaengt, laesst sich nicht nachrechnen.

   2. DIE EIGENE BLASE HATTE GAR KEINE NACHRECHENBARE FARBE. Dort stand `var(--accent-cta)`, und
      das ist ein VERLAUF: `getComputedStyle(...).backgroundColor` meldet folgerichtig
      rgba(0,0,0,0). Ein Verlauf hat keinen Kontrastwert, sondern zwei — dieselbe Falle, die
      diese Datei bei `.unread-badge` schon einmal aufgeloest hat. Jetzt EIN Ton, EINE Zahl:
        #ffffff auf #2c7a11 = 5,39:1  (Soll 4,5, Markenhandbuch §8)
      IN BEIDEN THEMEN DERSELBE TON — Falle F4 aus Bau-Brief 50: der Desktop hellt die eigene
      Blase im Dunkelmodus ausdruecklich NICHT auf. Deshalb steht hier kein Token, sondern die
      Zahl; ein themenabhaengiges Token waere genau der Fehler.
      OFFEN UND IM BERICHT: #2c7a11 ist der GRUENE Druckton des Desktops (Bau-Brief 11, V3/V4).
      Diese PWA faehrt seit dem Marken-Umbau Teal (--accent-cta #077e6c). Die eigene Blase ist
      damit die einzige gruene Flaeche der App. Das ist eine Marken-Entscheidung des Betreibers,
      keine CSS-Frage: entweder der Chat ist bildgleich mit dem PC (dann so wie hier), oder die
      PWA bleibt durchgehend teal (dann #077e6c, GERECHNET 4,99:1, ebenfalls ueber der Schwelle).

   3. DIE SCHWAENZCHEN-ECKE WAR 6 PX, DER PC HAT 7. Radius 18 und die Seite stimmten bereits.
   Dazu die einzige Breitenzusage des Briefs: `max-width: 560px`. Auf dem Telefon deckelt die
   86-%-Spalte von `.chat-msg` ohnehin; auf Tablet und im Querformat lief die Blase vorher ueber
   die ganze Spaltenbreite und wurde als Zeile unlesbar lang. */
.chat-bubble {
  padding: 10px 14px; border-radius: 18px 18px 18px 7px;
  max-width: 560px;
  background: #e7eaed; color: #0d1420; border: 1px solid var(--border); box-shadow: var(--shadow);
  font-size: calc(15px * var(--ui-scale)); line-height: 1.35; word-break: break-word; user-select: text; -webkit-user-select: text;
}
:root[data-theme="dark"] .chat-bubble { background: #1e293c; color: #ffffff; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .chat-bubble { background: #1e293c; color: #ffffff; }
}
.chat-msg.mine .chat-bubble {
  background: #2c7a11; color: #fff; border: 0; border-radius: 18px 18px 7px 18px;
  /* Der Schein folgt der Fuellung — der teal getoente Schatten von vorher gehoerte zu
     --accent-cta und haette unter einer gruenen Blase als Farbsaum gestanden. */
  box-shadow: 0 10px 22px rgba(44, 122, 17, 0.28);
}
.chat-meta { display: flex; align-items: center; gap: 7px; margin: 3px 6px 0; font-size: calc(11px * var(--ui-scale)); color: var(--text-soft); font-weight: 700; }
.chat-meta .sender { color: var(--accent-ink); font-weight: 800; }
.chat-msg.mine .chat-meta .sender { color: var(--text-soft); }
.chat-day-sep {
  /* [stil 2026-08-06] Polster 4 -> 3 px (Bau-Brief 50, B2). Die SCHRIFT bleibt bewusst bei
     11 px/800 statt der 9,5 px/700 des PCs: 9,5 px sind auf einem Telefon in der Sonne keine
     Beschriftung mehr — dieselbe Begruendung, mit der diese Datei die Reiterbeschriftung von
     9,5 auf 10,5 px gehoben hat. Form (Pille, Radius 999) und die drei Beschriftungsfaelle
     stimmen bereits. */
  align-self: center; margin: 10px 0 2px; padding: 3px 12px; border-radius: var(--radius);   /* [S19] war 999px — Tageswechsel im Chat */
  background: var(--surface-2); border: 1px solid var(--border);
  font-size: calc(11px * var(--ui-scale)); font-weight: 800; color: var(--text-soft); letter-spacing: .04em; text-transform: uppercase;
}
.chat-reply-preview {
  margin-bottom: 4px; padding: 6px 10px; border-left: 3px solid var(--yellow);
  background: var(--surface-2); border-radius: var(--radius); font-size: calc(12px * var(--ui-scale)); color: var(--text-muted);
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* ---------------------------------------------------------------- Verknuepfungs-Chip in der Blase
   [stil 2026-08-06] Der Chip zu einer `chat_message_links`-Zeile (js/views/chat.js:2722): Sorte
   plus Beschriftung, ein Antipper oeffnet Pin oder Aufgabe. 44 px hoch ist hier keine Kosmetik —
   er sitzt INNERHALB einer Nachrichtenblase, also in der dichtesten Flaeche der App.
   Farbe/Rand/Radius sind die des ruhenden Knopfes dieser Datei (--surface auf --border,
   Radius 12 = R_CONTROL), damit der Chip nicht als sechste Knopfform danebensteht.
   EHRLICHE GRENZE: der Bauort setzt heute denselben Satz inline und schlaegt damit diese Regel.
   Sie steht trotzdem hier, weil Chrome-Stil in die Stildatei gehoert und der Bauort sie mit
   einer einzigen Loeschung uebernehmen kann; Datei und Zeile stehen im Bericht. */
.chat-link-chip {
  display: inline-flex; align-items: center; gap: 6px;
  min-height: max(44px, calc(44px * var(--ui-scale))); max-width: 100%;
  padding: 0 12px; border-radius: var(--radius);
  border: 1px solid var(--border); background: var(--surface); color: var(--navy-strong);
  font: inherit; font-weight: 700; font-size: calc(12px * var(--ui-scale));
  cursor: pointer; text-align: left;
}

/* Chat: Reaktionen */
.chat-reactions { display: flex; flex-wrap: wrap; gap: 5px; margin: 5px 4px 0; }
/* „klein" (recon2/04): Die Reaktionschips massen 47x26 bzw. 48x26. Die 44-px-Untergrenze setzte
   danach index.html (Trefferflaechen-Boden der UX-Welle).
   [shell 2026-08-05] HIER STAND WEITER 26px — zwei Zahlen fuer dieselbe Sache in zwei Dateien,
   und die CSS-Wahrheit sagte die falsche. Wirksam ist heute die Schicht in index.html (gemessen
   48x44 im laufenden Klienten), aber wer nur diese Datei liest, baut auf 26 auf. Die Zahl steht
   jetzt an BEIDEN Orten gleich; die Zusammenfuehrung in genau EINEN Ort verlangt eine Aenderung
   an index.html — die Datei gehoert dieser Welle nicht und steht im Bericht. */
.chip-reaction {
  display: inline-flex; align-items: center; gap: 4px; min-height: 44px; padding: 3px 9px;
  border-radius: var(--radius);   /* [S19] war 999px — Reaktions-Chip */ border: 1px solid var(--border-strong); background: var(--surface);
  font-size: calc(13px * var(--ui-scale)); font-weight: 800; color: var(--text-muted); cursor: pointer;
  transition: transform var(--t-tap) var(--ease), border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
/* Berührungsantwort: eine Reaktion reist über den Abgleich; bis sie zurückkommt, muss der
   Druck sichtbar angekommen sein. */
.chip-reaction:active { transform: scale(.94); }
.chip-reaction .count { font-size: calc(11px * var(--ui-scale)); }
.chip-reaction.mine { background: var(--gruenw); border-color: var(--gruent); color: var(--accent-ink); }

/* Chat: Composer (fest über der Tabbar, Safe-Area-fest) */
.chat-composer {
  position: fixed; left: 0; right: 0; bottom: var(--tabbar-h); z-index: var(--z-composer);
  display: flex; align-items: flex-end; gap: 8px;
  width: min(var(--shell-max), 100%); margin: 0 auto;
  padding: 8px max(12px, var(--safe-right)) 8px max(12px, var(--safe-left));
  /* PERF: volldeckend statt Blur — der Thread scrollt direkt darunter, ein backdrop-filter
     müsste bei jedem Scroll-Frame neu rechnen (Chat ist die scroll-intensivste View). */
  background: var(--surface);
  border-top: 1px solid var(--border);
}
.chat-composer textarea {
  flex: 1; min-height: 44px; max-height: 130px; padding: 11px 14px; resize: none;
  border-radius: var(--radius); border: 1px solid var(--border); border-bottom: 2px solid var(--border-strong); background: var(--surface-2); outline: none;
  font-size: max(16px, calc(16px * var(--ui-scale))); line-height: 1.3; /* [SCHRIFT-6] 16px-Boden gegen iOS-Fokus-Zoom, waechst mit der Skala */
}
/* [KONTRAST L9-20 2026-08-25] Dieselbe Rechnung wie bei .field > .control:focus:
   --navy-bright ist die Flaeche (1,84–2,28:1 hell), --gruent die Kante (5,18:1). */
.chat-composer textarea:focus { border-color: var(--gruent); box-shadow: var(--focus-ring); }
.chat-composer .send {
  width: 44px; height: 44px; flex: none; border: 1px solid var(--gruent); border-radius: var(--radius);
  clip-path: var(--fase-schnitt);
  background: var(--accent-cta); color: var(--accent-fill-ink); font-size: 19px; cursor: pointer;
  display: grid; place-items: center; box-shadow: var(--shadow);
}
.chat-composer .send:disabled { opacity: .5; }
.chat-composer .send:active { transform: scale(.92); }
/* [HOTFIX 1.9.84] Offene Chat-Tastatur (app.js pflegt body.chat-tastatur per focusin/focusout):
   resizes-content laesst alles Fixe auf die Tastaturkante ruecken — die Tab-Leiste fuhr also
   ZWISCHEN Tastatur und Eingabezeile mit hoch (Betreiber-Befund 05.08.). Solange getippt wird,
   verschwindet die Leiste und die Eingabezeile rueckt an die Kante — die Antwort nativer Apps.
   bottom: var(--safe-bottom) statt 0: mit Hardware-Tastatur (iPad) bleibt der Home-Indikator
   frei; mit offener Bildschirmtastatur ist das Inset ohnehin 0. */
body.chat-tastatur .tabbar { display: none; }
body.chat-tastatur .chat-composer { bottom: var(--safe-bottom); }

/* ---------------------------------------------------------------- Foto-Galerie
   [stil 2026-08-06] KACHELRASTER: FESTES SEITENVERHAELTNIS, HAUSABSTAND, EIGENER MALBEREICH.
   1. `aspect-ratio: 1` stand hier schon und ist die wichtigste Zeile des Blocks: die Kachel
      hat ihre Endgroesse, BEVOR das Bild da ist. Ohne sie waechst jede Kachel in dem Moment,
      in dem ihre Bytes ankommen — bei 17 Fotos an einem Pin (Pin 7 „260618 ToDO") sind das
      17 Layout-Spruenge unter dem Daumen, waehrend man scrollt. Sie bleibt unangetastet und
      steht hier nur deshalb ausgeschrieben, damit sie niemand fuer Deko haelt.
   2. Der Abstand war 6 px und damit der einzige Rasterabstand der Datei, der auf keiner
      anderen Flaeche vorkommt (.chips 8, .list/.stack 10, .card-Rand 12, .row 14). 8 px ist
      die naechste Sprosse und aendert an der SPALTENZAHL nichts: bei 320 px Bildschirm
      bleiben in der Karte 260 px -> 2 Spalten (126 statt 127 px), bei 430 px 370 px ->
      3 Spalten (118 statt 119,3 px). Ein Raster, drei Abstaende weniger.
   TREFFERFLAECHE: die Kachel ist mindestens 108 px breit und (aspect-ratio 1) ebenso hoch,
   also mit Abstand ueber der 44er-Schranke — hier braucht es kein Pseudo-Element. */
.gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); gap: 8px; }
.photo-thumb {
  position: relative; aspect-ratio: 1; border-radius: var(--radius); overflow: hidden; cursor: pointer;
  background: var(--surface-2); border: 1px solid var(--border);
  /* LAYER-HINWEIS, EINZELN BEGRUENDET (Auftrag: nur setzen, wo es messbar hilft):
     `contain: layout paint` sagt dem Browser, dass innerhalb dieser Kachel nichts steht, das
     das Layout ausserhalb aendern kann, und dass ihr Malbereich an der Kachelkante endet.
     Beides ist hier schon WAHR und kostet daher nichts: die Groesse steht ueber aspect-ratio
     fest (`layout`), und `overflow: hidden` eine Zeile darueber klemmt bereits alles ab
     (`paint`). Der Gewinn: trifft ein Vorschaubild ein, muss der Browser nur DIESE Kachel neu
     rechnen und malen statt der ganzen Rasterflaeche — bei einer Galerie, die ihre Bilder
     nacheinander nachlaedt, ist das der Normalfall und nicht die Ausnahme.
     BEWUSST NICHT `contain: size` (bzw. `strict`): das setzte die Eigengroesse auf null und
     nimmt damit genau die aspect-ratio-Hoehe weg, um die es in Punkt 1 geht.
     BEWUSST KEIN `will-change`: die Kachel bewegt sich nur beim Antippen fuer 90 ms um 3 %.
     Eine Dauer-Ebene je Kachel waere bei 17 Kacheln 17 Ebenen fuer eine Bewegung, die es
     fast nie gibt — die Messung m5 zeigt, dass eine befoerderte Ebene nur dort gewinnt, wo
     sich der INHALT waehrend der Bewegung nicht aendert und die Bewegung ANHAELT. */
  contain: layout paint;
  transition: transform var(--t-tap) var(--ease), box-shadow var(--t-fast) var(--ease);
}
/* KEIN `image-rendering` — ausdruecklich geprueft und verworfen.
   Die Kachel zeigt eine VERKLEINERTE Aufnahme (Quelle 320 px „thumb" bzw. 1600 px „preview"
   in einem 108-177 px breiten Kasten). `image-rendering: pixelated` bzw. `crisp-edges` schaltet
   genau die Glaettung ab, die beim Verkleinern die Kanten traegt — ein Baufoto wuerde dadurch
   sichtbar rauher, nicht schaerfer. `auto` (die Vorgabe) ist hier die richtige Wahl, und eine
   Zeile, die nur die Vorgabe wiederholt, gehoert nicht in die Datei. Der Schaerfe-Hebel dieser
   Ansicht liegt woanders: an der GELIEFERTEN Stufe (js/medien/lader.js, Zielbreite in
   Geraetepixeln), nicht am Filter. */
.photo-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-thumb:active { transform: scale(.97); }
.photo-thumb .tag {
  position: absolute; top: 6px; left: 6px; padding: 2px 7px; border-radius: var(--radius);   /* [S19] war 999px — Marke auf dem Foto */
  background: rgba(13, 21, 38, 0.72); color: #fff; font-size: calc(10px * var(--ui-scale)); font-weight: 800;
}
.photo-thumb .pending {
  position: absolute; inset: 0; display: grid; place-items: center;
  /* PW-6: 2,98:1 ueber einem weissen Foto -> 5,71:1. Siehe --scrim-image oben. */
  background: var(--scrim-image); color: #fff; font-size: calc(12px * var(--ui-scale)); font-weight: 800;
}
/* Vollbild-Overlay für ein einzelnes Foto */
.photo-full {
  position: fixed; inset: 0; z-index: var(--z-vollbild); display: grid; place-items: center;
  background: rgba(5, 9, 18, 0.94); touch-action: none;
}
/* Das Bild darf bis an den Displayrand — aber nicht UNTER Kerbe/Home-Indicator: die Insets werden
   von der Viewport-Größe abgezogen. vh-Zeile zuerst (alte Browser), dvh-Zeile gewinnt danach. */
.photo-full img {
  max-width: calc(100vw - var(--safe-left) - var(--safe-right));
  max-height: calc(100vh - var(--safe-top) - var(--safe-bottom));
  max-height: calc(100dvh - var(--safe-top) - var(--safe-bottom));
  object-fit: contain;
}
.photo-full .close {
  position: absolute; top: calc(12px + var(--safe-top)); right: max(14px, var(--safe-right)); z-index: var(--z-vollbild-hud);
  width: 44px; height: 44px; border-radius: 50%; border: 1px solid rgba(255,255,255,.25);
  background: var(--scrim-image); color: #fff; font-size: 20px; cursor: pointer;
  display: grid; place-items: center;
  transition: transform var(--t-tap) var(--ease);
}
/* Berührungsantwort: das Schliessen-Kreuz über dem Vollbild-Foto gab keine. */
.photo-full .close:active { transform: scale(.94); }
.photo-full .caption {
  position: absolute; left: 0; right: 0; bottom: calc(14px + var(--safe-bottom));
  text-align: center; color: rgba(255,255,255,.85); font-size: calc(13px * var(--ui-scale)); font-weight: 700; padding: 0 20px;
}

/* ---------------------------------------------------------------- 360°-Pano-Viewer */
/* [BROWSERLEISTE 2026-08-07] `inset: 0` WAR DIE EINE STELLE OHNE DIE HAUS-KASKADE.
   Betreiber im Wortlaut: „in der echten app sind diese knöpfe verdeckt von oben und unten
   taskleiste". Nachgesehen: App- und Tab-Leiste der Huelle liegen auf z-index 20, die Kugel auf
   50 — die koennen es also gar nicht sein. Es ist die BROWSER-Leiste: ein fixiertes Element mit
   `bottom: 0` rechnet auf dem Telefon mit der GROSSEN Viewport-Hoehe, also mit der Hoehe, die
   der Browser NUR hat, solange seine Adress- und Navigationsleiste eingefahren sind. Der obere
   Knopfstreifen und die Zeichnen-Leiste unten liegen damit unter genau diesen Leisten.
   Der ganze Baum loest das seit jeher mit derselben Dreierkette (styles.css:1271 nennt sie
   ausdruecklich: vh als Rueckfall fuer alte Browser, dann svh — die KLEINE Hoehe, die nie unter
   die iOS-Werkzeugleiste laeuft —, dann dvh). Die Kugel war die einzige Ansicht ohne sie.
   Die Innenabstaende der beiden Streifen tragen ihre safe-area-Werte bereits (.pano-hud /
   .pano-hud-bottom) — die reichen fuer Notch und Home-Indikator, nicht aber fuer eine Leiste,
   die der Browser selbst darueberlegt. */
.pano-wrap {
  position: fixed; left: 0; right: 0; top: 0; z-index: var(--z-vollbild);
  height: 100vh; height: 100svh; height: 100dvh;
  background: #05070d; overflow: hidden; touch-action: none;
}
.pano-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* [stil 2026-08-06] VIER RUNDE KNOEPFE STATT DREI — der Titel muss schrumpfen duerfen.
   Die Kugelansicht fuehrt seit dieser Welle Zurueck, Gyro, Horizont und Messpunkte (vorher drei).
   GEMESSEN bei 390 px: 52+50+50+50 px Knoepfe, alle erreichbar, Titel bricht sauber mit
   Auslassungspunkten ab. GERECHNET (nicht gemessen) fuer 320 px Geraetebreite: 28 px Rand +
   4 Knoepfe + 4 Luecken lassen dem Titel rund 90 px. `gap: 6px` gibt ihm 16 px davon zurueck.
   `min-width: 0` ist die eigentliche Zusage: ein Flex-Kind kann OHNE sie nie unter seine
   Inhaltsbreite — der Titel haette also lieber die Knopfreihe aus dem Bild geschoben, als seine
   eigene Ellipse zu benutzen. Dieselbe Zeile traegt aus demselben Grund `.planhead .planchip`. */
.pano-hud {
  position: absolute; left: 0; right: 0; top: 0; z-index: var(--z-vollbild-hud);
  display: flex; align-items: center; gap: 6px;
  padding: calc(10px + var(--safe-top)) max(14px, var(--safe-right)) 10px max(14px, var(--safe-left));
  background: linear-gradient(180deg, rgba(5, 9, 18, 0.72), transparent);
  color: #fff; pointer-events: none;
}
.pano-hud .title { flex: 1; min-width: 0; font-weight: 800; font-size: calc(15px * var(--ui-scale)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* PERF: kein backdrop-filter über dem WebGL-Pano (rendert bei jeder Drag-Bewegung) —
   halbtransparente Deckfläche reicht optisch und kostet nichts. */
.pano-btn {
  pointer-events: auto; min-width: 44px; height: 44px; padding: 0 14px; border-radius: var(--radius);
  border: 1px solid rgba(255,255,255,.22); background: var(--scrim-image); color: #fff;
  font-size: 15px; font-weight: 800; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
}
.pano-btn:active { transform: scale(.94); }
.pano-btn.active { background: rgba(255, 122, 60, 0.28); border-color: var(--yellow); }
.pano-hud-bottom {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: var(--z-vollbild-hud);
  display: flex; justify-content: center; gap: 10px;
  padding: 12px max(14px, var(--safe-right)) calc(14px + var(--safe-bottom)) max(14px, var(--safe-left));
  background: linear-gradient(0deg, rgba(5, 9, 18, 0.72), transparent);
}

/* ---------------------------------------------------------------- Formular-Feinschliff
   Floating-Label-Muster: <div class="field"><input class="control" placeholder=" "><label>…</label></div>
   (Bestehende <label class="field">-Blocklabels bleiben unberührt — die Regeln unten greifen
   nur auf KINDER eines .field-Containers.) */
.field { position: relative; }
/* [SKALA L9-15 2026-08-25] Polster UND Etikett wandern mit der Textstufe.
   GEMESSEN VORHER bei Stufe 1,3: das Feld selbst waechst (font-size max(16px,
   calc(16px * var(--ui-scale))) = 20,8 px, Zeilenkasten rund 25 px, Feldhoehe
   22 + 25 + 8 + 3 = 58 px), die Ruhelage des Etiketts blieb aber bei Mitte y = 13 + 12
   = 25 px, waehrend die Mitte des getippten Textes auf rund 22 + 12,5 = 34,5 px lag —
   9,5 px Versatz, und das Etikett stand mit 15 px neben 20,8 px Fliesstext.
   NACHHER bei Stufe 1,3: Polster 28,6 px, Ruhelage 16,9 + 15,6 = 32,5 px gegen eine
   Textmitte von 28,6 + 12,5 = 41,1 px — der Versatz faellt von 9,5 auf 8,6 px und ALLE
   vier Groessen laufen auf derselben Leiter.
   `left: 15px` bleibt FEST, weil das waagerechte Polster des Feldes (14px) ebenfalls fest
   ist: beide zusammen lassen oder beide skalieren, nie nur eins. */
.field > .control { padding-top: calc(22px * var(--ui-scale)); padding-bottom: calc(8px * var(--ui-scale)); }
/* Das Etikett wandert über `transform`, nicht mehr über `top` und `font-size`.
   VORHER: `transition: top .14s ease, font-size .14s ease, color .14s ease` — beides
   erzwingt in JEDEM Bild des Übergangs ein neues Layout (top) bzw. einen neuen Textsatz
   (font-size). Der Perf-Vertrag lässt für Bewegung nur transform und opacity zu.
   JETZT: die Ruhelage ist um 12 px nach unten geschoben, die schwebende Lage ist der
   Nullpunkt — dieselben zwei Endpunkte wie vorher (Mitte bei 25 px bzw. 13 px), aber
   die Strecke läuft im Compositor.
   Schriftgrösse, Versalien, Sperrung und Gewicht wechseln BEWUSST hart: sie wechselten
   schon vorher hart (nur die Grösse glitt mit), und ein gleitender Schriftgrad neben
   einer springenden Versalschreibung liest sich wie zwei Bewegungen für einen Vorgang.
   Eine Bewegung: das Etikett fährt hoch. */
.field > .control + label {
  position: absolute; left: 15px; top: calc(13px * var(--ui-scale));
  transform: translateY(-50%) translateY(calc(12px * var(--ui-scale)));
  color: var(--text-soft); font-weight: 700; font-size: calc(15px * var(--ui-scale)); pointer-events: none;
  transition: transform var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.field > .control:focus + label,
.field > .control:not(:placeholder-shown) + label {
  transform: translateY(-50%);
  font-size: calc(11px * var(--ui-scale)); font-weight: 800; letter-spacing: .05em;
  text-transform: uppercase; color: var(--accent-ink);
}
/* [KONTRAST L9-20 2026-08-25] Vorher `border-color: var(--navy-bright)`. Der Bestandsname
   --navy-bright zeigt seit dem SCHNURMASS-Umbau auf die FLAECHEN-Fassung var(--gruen)
   #4BC429; GERECHNET auf --fl2 #E9E7E1 sind das 1,84:1 (Soll 3:1). Die Grundregel
   `input.control:focus` setzt an derselben Stelle laengst die Kanten-Fassung --gruent
   (5,18:1 auf --fl) — diese Regel gewinnt nur wegen ihrer hoeheren Spezifitaet (0,3,0
   gegen 0,2,1) und stellte damit ausgerechnet fuer das Floating-Label-Feld die schlechtere
   Kante. Der Fokusring var(--focus-ring) trug die Sichtbarkeit allein; jetzt tut es die
   Kante mit. */
.field > .control:focus { border-color: var(--gruent); box-shadow: var(--focus-ring); }

/* ---------------------------------------------------------------- Upload-Statuszeile */
.upload-progress {
  display: flex; align-items: center; gap: 12px; padding: 10px 14px; margin-bottom: 8px;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--shadow); font-size: calc(13px * var(--ui-scale));
}
.upload-progress .name { flex: 1; min-width: 0; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.upload-progress .pct { flex: none; font-weight: 900; color: var(--accent-ink); font-variant-numeric: tabular-nums; }
.upload-progress .bar {
  flex: 2; height: 8px; border-radius: var(--radius-klein);   /* [S19] war 999px — Ladebalken */ background: var(--surface-2);
  border: 1px solid var(--border); overflow: hidden;
}
.upload-progress .bar > i {
  display: block; height: 100%; border-radius: var(--radius-klein);   /* [S19] war 999px — Ladebalken-Fuellung */
  background: linear-gradient(90deg, var(--navy-bright), var(--yellow));
  /* Breite und Bewegung stehen weiter unten im Abschnitt „HAND AM GERÄT": der Balken ist
     voll breit und wird per `scaleX` gestaucht. `width` ist eine Layout-Eigenschaft und
     warf bei JEDEM Fortschrittsschritt Layout + Neuzeichnen an — genau während das Gerät
     hochlädt (Bewegungssprache §8.4). */
}
.upload-progress.error { border-color: rgba(220, 38, 38, 0.45); }
.upload-progress.error .pct { color: var(--danger-text); }
.upload-progress.done .pct { color: var(--ok-text); }

/* ---------------------------------------------------------------- 360-Kamera (Insta360-Bridge) */
.insta-head { display: flex; align-items: center; gap: 10px; margin: 4px 2px 12px; }
.insta-head .iconbtn {
  width: 44px; height: 44px; flex: none; border-radius: var(--radius); border: 1px solid var(--border);
  background: var(--surface); color: var(--accent-ink); font-size: 20px; font-weight: 800; cursor: pointer;
  display: grid; place-items: center;
}
.insta-head .iconbtn:active { transform: scale(.95); }
.insta-head-title { min-width: 0; }

.insta-statusbar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; }
.insta-status-left { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; flex-wrap: wrap; }
.insta-status-text { font-weight: 700; font-size: calc(14px * var(--ui-scale)); min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.insta-dot { width: 10px; height: 10px; flex: none; border-radius: 50%; background: var(--border-strong); }
.insta-dot.on { background: var(--ok); box-shadow: 0 0 0 3px rgba(16, 185, 129, .2); }
.insta-chip {
  display: inline-flex; align-items: center; padding: 3px 9px; border-radius: var(--radius);   /* [S19] war 999px — 360-Chip */
  background: var(--surface-2); border: 1px solid var(--border); font-size: calc(12px * var(--ui-scale)); font-weight: 800;
  color: var(--text-muted); font-variant-numeric: tabular-nums; flex: none;
}

.insta-live {
  position: relative; width: 100%; aspect-ratio: 2 / 1; border-radius: var(--radius);
  overflow: hidden; background: #05070d; border: 1px solid var(--border); margin: 6px 0 10px;
}
.insta-live-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; display: block; }
.insta-live.empty .insta-live-img { visibility: hidden; }
.insta-live-empty { position: absolute; inset: 0; display: none; place-items: center; color: rgba(255,255,255,.7); text-align: center; }
.insta-live.empty .insta-live-empty { display: grid; }
.insta-live-empty .big { font-size: calc(38px * var(--ui-scale)); }
.insta-live-empty-text { font-size: calc(13px * var(--ui-scale)); font-weight: 700; margin-top: 4px; }
.insta-live-top {
  position: absolute; top: 0; left: 0; right: 0; z-index: 2; display: flex; align-items: flex-start;
  justify-content: space-between; gap: 8px; padding: 10px; pointer-events: none;
}
.insta-live-hint {
  pointer-events: none; max-width: 72%; padding: 4px 9px; border-radius: var(--radius);   /* [S19] war 999px — 360-Hinweis */
  background: rgba(13, 21, 38, .66); color: rgba(255,255,255,.92); font-size: calc(11px * var(--ui-scale)); font-weight: 700;
}
/* PERF: kein backdrop-filter über dem MJPEG-Live-Stream (jedes Frame würde den Blur neu rechnen). */
.insta-live-fsbtn {
  pointer-events: auto; flex: none; width: 44px; height: 44px; border-radius: var(--radius);
  border: 1px solid rgba(255,255,255,.24); background: var(--scrim-image); color: #fff;
  font-size: 17px; font-weight: 800; cursor: pointer;
  transition: transform var(--t-tap) var(--ease);
}
/* Berührungsantwort: Vollbild an/aus über dem Live-Bild gab keine. */
.insta-live-fsbtn:active { transform: scale(.92); }
.insta-live-bottom {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 2; display: flex; justify-content: center;
  padding: 12px; pointer-events: none;
}
.insta-live.full {
  position: fixed; inset: 0; z-index: var(--z-modal); aspect-ratio: auto; border-radius: 0; margin: 0; border: 0;
}
/* Vollbild (position:fixed;inset:0): Auslöser-Leiste und Kopfzeile müssen die Insets tragen —
   inklusive links/rechts fürs Querformat (im Hochformat sind die seitlichen Insets 0). */
.insta-live.full .insta-live-bottom {
  padding-bottom: calc(16px + var(--safe-bottom));
  padding-left: max(12px, var(--safe-left)); padding-right: max(12px, var(--safe-right));
}
.insta-live.full .insta-live-top {
  padding-top: calc(10px + var(--safe-top));
  padding-left: max(10px, var(--safe-left)); padding-right: max(10px, var(--safe-right));
}

.insta-shutter {
  pointer-events: auto; width: 72px; height: 72px; border-radius: 50%; cursor: pointer;
  border: 4px solid rgba(255,255,255,.9); background: rgba(13, 21, 38, .35);
  display: grid; place-items: center; transition: transform var(--t-tap) var(--ease);
}
.insta-shutter:active { transform: scale(.93); }
.insta-shutter[disabled] { opacity: .5; cursor: default; }
.insta-shutter-ring { width: 52px; height: 52px; border-radius: 50%; background: #e11d48; display: block; }
/* [stil 2026-08-06] ZWEITER DAUERPULS, DERSELBE GRIFF.
   Hier stand `animation: instaPulse … infinite` — der Ausloesering schrumpfte endlos auf 70 %
   und wurde dabei blass. Kapitel 10 (Auftrag 19, Rang MUSS) laesst genau eine endlose Bewegung
   zu, und das ist die Ladeanzeige. Ein Ausloeser, der atmet, waehrend die Kamera aufnimmt, ist
   zudem die schlechteste Stelle fuer Bewegung: der Knopf ist gedrueckt, die Hand haelt still,
   und die Flaeche unter dem Daumen wackelt.
   WAS AN SEINE STELLE TRITT: der Ring bleibt in voller Groesse stehen und wechselt auf Amber —
   dieselbe Signalfarbe wie vorher, nur ohne Takt — und bekommt einen statischen Schein, damit
   der Zustand nicht allein am Farbton haengt. Das WORT zum Zustand steht in derselben Ansicht
   sichtbar daneben (`.insta-status-text`, js/views/insta.js), die Regel §7 „Wort + Farbe" ist
   also weiter erfuellt.
   `@keyframes instaPulse` ist mit entfallen — null Verwender. */
.insta-shutter.busy .insta-shutter-ring { background: #f59e0b; box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.28); }

.insta-progress {
  margin: 0 2px 12px; padding: 10px 14px; border-radius: var(--radius); font-size: calc(13px * var(--ui-scale)); font-weight: 700;
  /* WCAG (PW-3, 2026-07-27): --navy-deep #077e6c auf dieser Flaeche (#e7f6f4 ueber --surface)
     GEMESSEN 4,49:1 — ein Hundertstel unter AA. --accent-ink: 5,95:1. Dunkel unberuehrt (12,07). */
  background: var(--gruenw); border: 1px solid var(--gruent); color: var(--accent-ink);
}
:root[data-theme="dark"] .insta-progress { color: #8ff0df; }
/* [stil 2026-08-06] Der EINE fehlende Auto-Zwilling der Datei (Kapitel 11 Auftrag 5, MUSS;
   im Messbericht m9 namentlich als einziger Fall benannt). Ohne ihn stand im automatischen
   Thema auf einem dunklen Geraet weiter --accent-ink #06695a auf der getoenten Teal-Flaeche
   ueber --surface #0d1522 — dieselbe Lage, die der Kommentar oben fuer HELL mit 4,49:1
   ausrechnet, nur andersherum und deutlich schlechter. Wortgleiche Deklaration, kein
   neuer Farbwert. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .insta-progress { color: #8ff0df; }
}

/* ============================================================================
   Skeleton-Ladezustände — dezente Shimmer-Blöcke in Kartenform (theme-aware über
   die Tokens). Ersetzen nackte Spinner bei Listen/Galerie/Plan. Bei reduzierter
   Bewegung ruht das Shimmer (nur ein statischer, leicht getönter Block).
   ============================================================================ */
.skel { position: relative; overflow: hidden; background: var(--surface-2); border-radius: var(--radius); }
/* Dieselbe 300-ms-Regel wie beim Kreisel (siehe .spinner weiter oben): ein Skelett, das
   für 80 ms aufblitzt, ist kein Ladezustand, sondern ein Zucken.
   Die Kartenzeile blendet als GANZES ein — sonst stünde 300 ms lang eine leere weiße
   Karte da, in die dann die grauen Blöcke hineinkommen. Ihre Kinder tragen die
   Einblendung deshalb nicht noch einmal. */
.skel, .skel-row { animation: ind-in var(--t-fast) var(--ease) 300ms both; }
.skel-row .skel { animation: none; }
.skel::after {
  content: ""; position: absolute; inset: 0; transform: translateX(-100%);
  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.45), transparent);
  animation: skelshimmer var(--t-pulse) var(--ease-io) infinite;
}
/* [stil 2026-08-06] DERSELBE FEHLER WIE BEI DER TABBAR — UND ER KOSTETE DAS SCHIMMERN.
   Der zweite Selektor (`:root:not([data-theme="light"])`) stand OHNE Media-Query in der
   Liste und traf damit auch das automatische Thema auf einem HELLEN Geraet. Dort lag das
   Schimmer-Weiss also bei 0,08 statt bei 0,45 — auf --surface-2 (#f4f8fb) ist das rund
   ein Achtzehntel des gedachten Hubs, also praktisch unsichtbar. Genau die Ladeanzeige,
   die sagen soll „hier kommt gleich etwas", war im Auslieferungszustand blind.
   Der richtige Auto-Zwilling stand direkt darunter bereits da; entfernt wurde nur der
   Selektor, der ohne sein `@media` in die Helligkeit hineinregierte. */
:root[data-theme="dark"] .skel::after { background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .skel::after { background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.08), transparent); }
}
@keyframes skelshimmer { 100% { transform: translateX(100%); } }
.skel-row {
  display: flex; align-items: center; gap: 14px; padding: 14px 16px; margin-bottom: 10px;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow);
}
.skel-avatar { width: 46px; height: 46px; border-radius: var(--radius); flex: none; }
.skel-lines { flex: 1; display: grid; gap: 8px; }
.skel-line { height: 12px; border-radius: var(--radius-klein); }
.skel-line.short { width: 45%; height: 10px; }
.skel-tile { aspect-ratio: 1; border-radius: var(--radius); }
@media (prefers-reduced-motion: reduce) {
  .skel::after { animation: none; }
}

/* ============================================================================
   Sanfte View-Übergänge (native View-Transitions-API, wird in app.js genutzt).
   Rein additiv: nur ein kurzes Cross-Fade der Wurzel; ohne API/bei reduzierter
   Bewegung passiert nichts (kein Verzögern der Interaktion). Fixierte Elemente
   werden von der API sauber gesnapshottet — kein Containing-Block-Problem.
   ============================================================================ */
/* ZWEI WAHRHEITEN WAREN EINE ZU VIEL: Hier stand `animation-duration: 150ms;
   animation-timing-function: cubic-bezier(.2,.8,.2,1)` für dieselben Pseudo-Elemente,
   die weiter unten (Abschnitt „View-Wechsel") die Kurzschreibweise `animation: vt-out …`
   bekommen. Gleiche Spezifität, gleicher Media-Query — die spätere Kurzschreibweise
   setzt die Dauer mit und gewinnt. Diese 150 ms haben also nie etwas bewirkt.
   Gestrichen; die eine gültige Regel steht am Dateiende. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
}

/* ============================================================================
   [2026-08-01 APP-P2] BRIDGE-ABRISS: Hier standen die Stile des Bridge-Kopplungswegs
   (.connect-hero/-steps/-actions/-divider/-status/-mixed) und des Kopplungs-QR-Scanners
   (.qr-scanner/.qr-reticle/.qr-hint/.qr-close/.qr-ios-fallback). Beide Markup-Eigentuemer
   (js/connect.js, settings.js:connectionBlock) sind entfernt; der Anwesenheits-Scanner
   (views/attendance.js) nutzt Inline-Styles und KEINE dieser Klassen (per grep belegt).
   ============================================================================ */

/* ══ SCHNURMASS — DAS WERKZEUG UND DIE NEUEN BAUTEILE ══════════════════════════
   Alles hier ist NEU und nimmt keiner Bestandsregel etwas weg. Es ist das Vokabular,
   mit dem die folgenden Wellen ihre Views umstellen, ohne Zahlen neu zu erfinden. */

/* ── DIE FASE ──────────────────────────────────────────────────────────────────
   45-Grad-Schnitt an zwei GEGENUEBERLIEGENDEN Ecken, in der Diagonalen des
   Schnurzugs im Logo — die Antwort auf „bisschen sehr quadratisch".

   WARUM ZWEI ELEMENTE, und warum das der Naechste bitte NICHT vereinfacht:
   clip-path kennt keinen border. Was clip-path abschneidet, schneidet es MITSAMT
   der Kante ab; auf den zwei Diagonalen bliebe eine rohe Flaeche ohne Umriss.
   Ein Rahmen entsteht deshalb nur so: das AEUSSERE Element traegt die Rahmenfarbe
   als Flaeche und 1 px Polster, das innere die eigentliche Flaeche — beide mit
   demselben Schnitt. Das aeussere Pixel schaut ringsum heraus und IST der Rahmen.
   Primaerknopf, FAB und Sendeknopf nehmen den Schnitt OHNE Rahmen (ein einzelnes
   Element): die fehlende Kante auf den Diagonalen ist der bezahlte Preis dafuer,
   kein fremdes View-Markup anfassen zu muessen. Fasenbreite: siehe --fase im :root. */
.fase, .rahmen > .fase-in { clip-path: var(--fase-schnitt); }
.rahmen { background: var(--line2); padding: 1px; clip-path: var(--fase-schnitt); }
.rahmen > .fase-in { background: var(--fl); }
.rahmen.gruen { background: var(--gruent); }
.rahmen.gruen > .fase-in { background: var(--gruen); color: var(--gruen-ink); }

/* ── DIE TYPO-LEITER — elf Stufen, mehr gibt es nicht ──────────────────────────
   DAS ZAHLENGESETZ: jede Zahl, die sich aendern kann — Mass, Uhrzeit, Menge, Kennung,
   Dateigroesse, Fehlercode — steht in .maschine oder .mass mit tabular-nums, gefolgt von
   .einheit. Mono heisst: das hat kein Mensch getippt. Mono steht nie in einem Satz.
   Die Skalen-Klammer steht am VERWENDUNGSORT, nie im Token (Begruendung im :root). */
.mass {
  font-family: var(--f-mono); font-variant-numeric: tabular-nums;
  font-size: calc(var(--fs-mass) * var(--ui-scale)); line-height: var(--lh-mass);
  font-weight: 700; letter-spacing: -.02em; color: var(--ink);
}
.d1 {
  font-family: var(--f-display);
  font-size: clamp(26px, calc(33px * var(--ui-scale)), calc(44px * var(--ui-scale)));
  line-height: 1.02; font-weight: 800; letter-spacing: -.012em; color: var(--ink); margin: 0;
}
.t1 { font-family: var(--f-display); font-size: calc(var(--fs-t1) * var(--ui-scale)); line-height: var(--lh-t1); font-weight: 700; letter-spacing: -.01em; }
.t2 { font-family: var(--f-display); font-size: calc(var(--fs-t2) * var(--ui-scale)); line-height: var(--lh-t2); font-weight: 700; letter-spacing: -.008em; }
.t3 { font-family: var(--f-display); font-size: calc(var(--fs-t3) * var(--ui-scale)); line-height: var(--lh-t3); font-weight: 650; letter-spacing: -.004em; }
.b  { font-size: calc(var(--fs-b) * var(--ui-scale)); line-height: var(--lh-b); font-weight: var(--w-body); max-width: 66ch; }
.b2 { font-size: calc(var(--fs-b2) * var(--ui-scale)); line-height: var(--lh-b2); color: var(--ink2); }
.maschine {
  font-family: var(--f-mono); font-variant-numeric: tabular-nums;
  font-size: calc(var(--fs-maschine) * var(--ui-scale)); line-height: var(--lh-maschine);
  font-weight: 500; letter-spacing: .01em;
}
.meta { font-size: calc(var(--fs-meta) * var(--ui-scale)); line-height: var(--lh-meta); font-weight: 560; letter-spacing: .02em; color: var(--ink2); }
/* Die Rubrikzeile: Saira, weit gesperrt (.26em), immer Versalien. Sie ist das
   Etikett des Aufmassblattes — sie steht ueber D1 und ueber jeder Kartengruppe. */
.rub {
  font-family: var(--f-display); text-transform: uppercase;
  font-size: calc(var(--fs-rubrik) * var(--ui-scale)); line-height: var(--lh-rubrik);
  font-weight: 600; letter-spacing: .26em; color: var(--ink3);
}
/* ------------------------------------- WARUM DIE RUBRIK IM DIMENSIONEN-BAND NICHT KLEBT
   [NACHZUG L7-06 Punkt 3, 2026-08-25] Der Bauplan verlangte hier woertlich eine dritte
   Aenderung: die Rubrik im waagerecht scrollenden Filterband der Aufgaben soll kleben
   („sticky, left 0, Flaeche, z-index ueber den Chips"), damit beim Wischen sichtbar bleibt,
   in welcher Dimension man gerade ist. SIE IST BEWUSST NICHT GEBAUT, und zwar nicht aus
   Vorsicht, sondern GEMESSEN — headless Edge, 1:1-Nachbau des Bandes mit dem exakten
   `bandStil` aus js/views/todos.js, vier Gruppen, 2.700 px Bandbreite in 418 px Sichtfeld:

     Wischstand     Rubriken am linken Innenrand      groesste Chip-Ueberdeckung
        0 px        PRIORITAET                        keine
      400 px        PRIORITAET                        „Niedrig"           45 px
      900 px        PRIORITAET + PROJEKT (64 px       „24011 Halle Nord"  76 px
                    Wort auf Wort uebereinander)
     1400 px        + PERSON (drei Woerter, drei      „T. Schnur"         66 px
                    Ueberlappungen 59-64 px)
     Bandende       alle VIER auf demselben Punkt,    „Alle"              50 px
                    sechs Ueberlappungen

   URSACHE, aus dem Code und nicht geraten: `feinRubrik` haengt die Rubrik als DIREKTES Kind
   von `feinRow` ein, genau wie jeden Chip — die Gruppen haben keinen eigenen Wirt. Der
   umschliessende Block einer klebenden Rubrik ist damit das GANZE Band, nicht ihre Gruppe.
   Sticky-Ueberschriften schieben einander nur dann weiter, wenn der Block jeder Ueberschrift
   dort endet, wo die naechste Gruppe beginnt. Hier endet keiner — also sammeln sich alle vier
   Woerter auf demselben Punkt, und obendrein ist das Ergebnis genau der Befund L10-07: ein
   deckendes, klebendes Element in einem Scrollband ist per Bauart ein Deckel (dort gemessen
   87 px und 62 px Chip-Deckung — hier bis 76 px).
   WAS ES BRAUCHT, damit die Rubrik kleben KANN: je Gruppe ein eigener Wirt in
   `drawFeinRows` (js/views/todos.js — nicht die Datei dieses Loses), der Rubrik und Chips
   umschliesst; erst dann traegt eine Regel hier. Als fremdbedarf gemeldet.
   Festgehalten von tests/listen_schnurmass.test.mjs [L18]: solange die Rubrik flach im Band
   haengt, darf `.rub` nicht kleben. */
.einheit { font-family: var(--f-mono); font-size: calc(var(--fs-einheit) * var(--ui-scale)); font-weight: 500; letter-spacing: .06em; color: var(--ink3); }
/* Der Zaehler neben D1: „Projekte 06". Er sagt eine MENGE, deshalb Ziffern in
   Display-Schrift und nicht im Fliesstext-Grau untergehend. */
.zaehler { font-family: var(--f-display); font-size: calc(15px * var(--ui-scale)); font-weight: 700; letter-spacing: .06em; color: var(--ink3); }
/* Die Kopfzeile eines Wurzelbildschirms: Rubrik, D1 und Zaehler in EINER Zeile. */
.kopfzeile { display: flex; align-items: flex-end; gap: 10px; margin: 12px 0 0; flex-wrap: wrap; }
/* [SCHRIFT-5 2026-08-24] Bei Stufe 1,3 verdraengte die gewachsene D1 den Aktualisieren-
   Knopf in eine eigene Zeile. Der ZAEHLER ist das nachgiebige Glied: er darf schrumpfen
   und mit Ellipse enden, der Knopf haelt sich rechts in der ERSTEN Zeile. */
.kopfzeile .zaehler { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 0 1 auto; }
.kopfzeile .iconbtn { margin-left: auto; flex: none; }

/* ── [SICHTPRUEFUNG 19.08.2026] DIE ZWEI SCHRIFT-HAKEN, die im Baum schon gesetzt, aber
   nirgends beschrieben waren. js/util.js:kopfzeile() haengt `.disp` an die Ueberschrift und
   `.mono` an den Zaehler, app.js:1628 haengt `.disp` an die Wortmarke, js/views/chat.js und
   js/views/kalender.js schreiben Uhrzeiten mit `.mono`. Ohne diese zwei Regeln lief alles
   davon im Fliesstext-Inter — die drei Schriftdateien lagen geladen im Speicher und wurden
   an genau den Stellen nicht benutzt, fuer die sie geholt wurden. Ziffern bekommen
   tabular-nums, damit Zahlenspalten (Uhrzeit, Frist, Groesse, Zaehler) auf einer Achse
   stehen; das ist der ganze Grund, warum die Maschinenschrift ueberhaupt mitkommt. */
.disp { font-family: var(--f-display); }
.mono { font-family: var(--f-mono); font-variant-numeric: tabular-nums; }

/* ── LEERZUSTAND — nie eine leere Flaeche ohne Satz. Zeile 1 sagt WAS fehlt, Zeile 2
   WARUM und WAS JETZT, darunter genau ein Nebenknopf. Ein Leerzustand ohne zweite Zeile
   ist ein Fehlerzustand, den niemand als solchen erkennt. */
.leer {
  display: grid; justify-items: center; text-align: center; gap: 6px;
  padding: 48px 16px 24px; max-width: 42ch; margin: 0 auto;
}
.leer .leer-ic { color: var(--ink3); }
.leer .leer-titel { font-family: var(--f-display); font-size: calc(var(--fs-t2) * var(--ui-scale)); line-height: var(--lh-t2); font-weight: 700; color: var(--ink); }
.leer .leer-sub { font-size: calc(var(--fs-b2) * var(--ui-scale)); line-height: var(--lh-b2); color: var(--ink2); }
.leer .btn { margin-top: 12px; width: auto; }

/* ── FEHLERZUSTAND — wie .leer, mit Tonflaeche und 3-px-Signalkante links. Die TATSACHE
   steht in voller Tinte (--ink), nicht in --danger: eine Aussage darf nie allein an einem
   Farbton haengen (Zwei-Ton-Gesetz). Code und Uhrzeit stehen in .maschine. */
.fehler {
  display: grid; gap: 6px; padding: 14px 16px; margin: 12px 0;
  background: var(--dangerw); border: 1px solid var(--danger);
  border-left: 3px solid var(--danger); border-radius: var(--radius);
}
.fehler .fehler-titel { font-family: var(--f-display); font-size: calc(var(--fs-t3) * var(--ui-scale)); line-height: var(--lh-t3); font-weight: 700; color: var(--ink); }
.fehler .fehler-code { font-family: var(--f-mono); font-variant-numeric: tabular-nums; font-size: calc(var(--fs-maschine) * var(--ui-scale)); color: var(--danger); }

/* ── SUCHFELD — wie input.control: eingelassene Flaeche, 2-px-Bedienteil-Kante unten.

   [SICHT-C1 2026-08-25] RICHTIGSTELLUNG EINER MESSUNG, DIE HIER ALS MESSUNG STAND.
   Der Block [SICHT-4] loeschte an dieser Stelle den Satz „Lupe links, Loeschknopf rechts als
   eigenes 44-px-Ziel" und schrieb dafuer hin, die Lupe stimme, einen Loeschknopf habe es
   dagegen NIEMALS gegeben — belegt mit einer Zaehlung im ganzen js-Baum: „beide setzen genau
   zwei Kinder, die Lupe und das Eingabefeld."
   DER GELOESCHTE SATZ HATTE RECHT, DIE ZAEHLUNG NICHT. Eine falsche Messung im Begruendungsblock
   ist schlimmer als gar keine: sie liest sich wie ein Beleg, und der naechste Leser baut darauf.
   WAS WIRKLICH DASTAND — am Tag der Zaehlung wie heute, in js/views/todos.js:
     `const suchLoeschen = el("button", { "aria-label": tr("Löschen"), style:
      "display:none;flex:none;width:44px;height:44px;...", onClick: () => setzeAnfrage("") }`
   und darueber, woertlich: „Der Loeschknopf ist ein eigenes 44-px-Ziel und steht nur da, wenn es
   etwas zu loeschen gibt." Das Bauteil bekam ihn als DRITTES Kind.
   WARUM DIE ZAEHLUNG IHN NICHT SAH: sie hat den QUELLTEXT gezaehlt und nicht den gerenderten
   Baum, und sie hat mit LEEREM Feld geschossen — dem einzigen Zustand, in dem der Knopf
   `display:none` traegt. Das ist das Hausmuster „eine Zaehlung, die sich ihr Schema selbst
   anlegt, ist blind".
   NACHGEMESSEN AM GERENDERTEN BAUM (Edge headless, eigener Statikserver, ?demo=1, Touch statt
   Maus, prefers-reduced-motion GESETZT, Wert „Fenster" eingetippt, hell UND dunkel):
     To-dos 320 px/Stufe 1,3 : span.suche-ic 19x19 · input 197x69,8 · button[aria-label="Löschen"] 44x44 (x 261..305)
     To-dos 390 px/Stufe 1,0 : span.suche-ic 19x19 · input 267x53   · button[aria-label="Löschen"] 44x44 (x 331..375)
   DREI Kinder, nicht zwei.
   HEUTE TRAGEN BEIDE VERWENDER DREI KINDER: js/views/todos.js und js/views/projects.js bauen
   dasselbe Bauteil mit derselben Aufrufform (`const suchLoeschen = el("button", ...)` und
   `el("label", { class: "suche" }, [ ... ])`); die Projektliste hat den Knopf mit [SICHT-C2]
   bekommen. Es gibt genau diese zwei Erbauer — kein dritter, und ausdruecklich NICHT der Chat.
   NUR EINE Bereitschaftsregel bleibt: der Trefferzaehler `.suche-zahl` hat weiterhin 0 Erzeuger.
   Er steht als Vorrat da, damit Farbe, Groesse und Ausrichtung fertig sind, wenn ihn jemand
   nachruestet — und diesmal steht auch dabei, dass er Vorrat IST.
   NICHT DIESES BAUTEIL ist das Suchfeld des Chats: js/views/chat.js baut dort
   `el("input", { class: "control", type: "search", ... })` mit min-height 48px; dort ist das
   Feld selbst der Kasten und die Trefferflaeche stimmte immer.

   [SICHT-C2 2026-08-25] EIN LOESCHBEDIENTEIL JE FELD — UND ZWAR DAS EIGENE.
   DER BEFUND: `type: "search"` laesst Chromium/WebKit einen EIGENEN Loeschknopf in das Feld
   malen (`::-webkit-search-cancel-button`). Im To-do-Reiter standen damit ZWEI Bedienteile mit
   derselben, ZERSTOERENDEN Wirkung im selben Kasten, wenige Pixel auseinander, in zwei
   Farbwelten.
   GEMESSEN VORHER — ueber IDENTITAETEN, nicht ueber eine Zahl: getippt wurde ECHT ueber die
   Eingabe-Schicht des Browsers (nie element.click()), und jeder leerende Tipp wurde nach dem
   Ziel sortiert, das den Zeiger bekam. Der native Knopf lebt im UA-Schattenbaum und meldet als
   Ziel das <input> selbst — daran ist er zu erkennen:
     To-dos   320 px/1,3 : ZWEI Klassen — button[Löschen] 54x64 · input[search] 23x35
     To-dos   390 px/1,0 : ZWEI Klassen — button[Löschen] 55x62 · input[search] 21x31
     Projekte 320 px/1,3 : EINE Klasse  —                         input[search] 26x35
     Projekte 390 px/1,0 : EINE Klasse  —                         input[search] 24x30
   (Die Zahlen der eigenen Knoepfe sind groesser als die 44x44 des Kastens, weil Chromium einen
   Fingertipp auf das naechstliegende Ziel zieht; die GEBAUTE Flaeche ist 44x44, die ERREICHTE
   groesser. Die des nativen Knopfes ist in jeder Lage unter 44 px.)
   DIE ENTSCHEIDUNG: DER EIGENE BLEIBT, DER NATIVE GEHT — fuer die Bauform `.suche`, und nur
   fuer sie. Drei Gruende, in dieser Reihenfolge:
     (1) 44 PX SIND HAUSSCHRANKE. Ein Pseudo-Element laesst sich zwar aufblasen, aber nur dort,
         wo es dieses Pseudo-Element ueberhaupt gibt. Ein echtes Element traegt die Schranke
         ueberall und laesst sich am gerenderten Baum nachmessen — was oben geschehen ist.
     (2) DER NATIVE KNOPF HAT KEINEN NAMEN. Er ist ein Pseudo-Element und kein Bedienteil im
         Zugaenglichkeitsbaum: kein aria-label, keine Uebersetzung, nichts anzusagen. Der eigene
         traegt tr("Löschen") und steht damit in allen vier Sprachen.
     (3) ER IST NICHT UEBERALL DA. Gecko malt ihn gar nicht. Ein ZERSTOERENDES Bedienteil, das
         es nur auf einer Maschinenfamilie gibt, ist genau das Wurzelmuster „der zweite Weg erbt
         die Riegel des ersten nicht" — hier sogar umgekehrt: der zweite Weg hat NIE einen
         Riegel gehabt.
   WAS DAS KOSTET, AUSGESPROCHEN: die Projektliste hatte bis heute NUR den nativen Knopf. Ihn
   abzuschalten haette ihr die Loeschgeste ersatzlos genommen. Deshalb baut js/views/projects.js
   ab heute denselben `suchLoeschen` wie js/views/todos.js — erst danach schaltet diese Regel ab.
   WAS AUSDRUECKLICH NICHT ABGESCHALTET WIRD: das `input.control`-Suchfeld (Chat, Planliste).
   Dort gibt es keinen eigenen Knopf, der ihn ersetzen koennte, und der Chat horcht sogar
   ausdruecklich auf sein Ereignis (`search.addEventListener("search", ...)`). Ihn dort still
   abzuraeumen waere eine Funktion weniger ohne Ersatz. Er bleibt also stehen, UNTER der
   44-px-Schranke — das ist ein OFFENER PUNKT und als Fremdbedarf gemeldet, keine Auslassung.
   NACHGEMESSEN NACHHER (dieselbe Strecke, dieselben vier Konfigurationen): in ALLEN vier
   Faellen genau EINE Klasse, `button[Löschen]`, gebaute Flaeche 44x44; die Klasse
   `input[search]` kommt in keiner Konfiguration mehr vor. Zahlen im Block [SICHT-C2-NACHHER]
   weiter unten, direkt an der Regel.

   [SICHT-C3 2026-08-25] DER LINKE RAND DES KASTENS WAR TOT — und stand bisher nur als
   BEGRUENDUNG da, nicht als offener Punkt. [SICHT-1] beobachtete richtig: „Das Bauteil ist ein
   div, kein label, und es hat keinen Klickhorcher" — benutzt wurde der Satz aber nur, um die
   Dehnung des Feldes zu begruenden. Die FOLGE blieb stehen und ungemeldet: Polster 12 px + Lupe
   19 px + Abstand 9 px = 40 px am linken Rand des 292-px-Kastens nahmen keinen Tipp an.
   GEMESSEN VORHER (Tipp per Eingabe-Schicht, danach gefragt, WER das aktive Element ist):
     Polster links, Lupenmitte und obere linke Ecke -> aktiv bleibt `main`, in ALLEN VIER
     Konfigurationen (To-dos/Projekte, 320 px/1,3 und 390 px/1,0, hell und dunkel).
     Wer auf die Lupe tippte, bekam keine Tastatur.
   GEHEILT OHNE EINE ZEILE JAVASCRIPT: das Bauteil ist jetzt ein <label>. Ein Label reicht den
   Tipp an sein Bedienteil weiter; fuer Tipps auf INTERAKTIVE Nachfahren tut es nach der Norm
   ausdruecklich nichts — der Loeschknopf bleibt also sein eigenes Ziel und wird nicht zum
   Fokusdieb. KEINE Regel dieses Blattes nennt das Tagwort: `.suche` traegt `display: flex`,
   und das gilt fuer label wie fuer div.
   GEMESSEN NACHHER: Polster links, Lupenmitte und obere linke Ecke -> aktiv ist das
   Eingabefeld, in allen vier Konfigurationen. */
.suche {
  display: flex; align-items: center; gap: 9px;
  min-height: max(56px, calc(56px * var(--ui-scale)));
  background: var(--surface-2); border: 1px solid var(--border);
  border-bottom: 2px solid var(--border-strong); border-radius: var(--radius);
  padding: 0 12px; margin: 11px 0 0;
}
.suche:focus-within { border-color: var(--gruent); border-bottom-color: var(--gruen); box-shadow: var(--focus-ring); }
/* [SICHT-1 2026-08-25] DAS SUCHFELD SAH 56 PX HOCH AUS UND WAR 20 PX HOCH.
   GEMESSEN im Sichtdurchgang (Edge headless, elementFromPoint-Strahl vom Mittelpunkt aus,
   Schritt 1 px, gezaehlt wird nur das Feld selbst und seine Nachfahren):
     390 px, Stufe 1,0 : Bauteil 362x56   · Eingabefeld 308x20 · erreichbare Hoehe 20 px
     320 px, Stufe 1,3 : Bauteil 292x72,8 · Eingabefeld 238x25 · erreichbare Hoehe 25 px
   In 36 von 108 Konfigurationen des Durchgangs (jede Ansicht mit Suchfeld, drei Breiten, beide
   Themen, beide Stufen) lag damit das meistbenutzte Eingabefeld der App unter der
   44-px-Hausschranke — und zwar UNSICHTBAR: die eingelassene Flaeche ist 56 px hoch, ein Tipp
   in ihr oberes oder unteres Drittel traf ins Leere. Das Bauteil WAR ein div ohne Klickhorcher
   — es gab also nichts, was den Tipp an das Feld weiterreicht.
   ZWEI RICHTIGSTELLUNGEN [SICHT-C1/C3 2026-08-25], beide an dieser einen Klammer:
     * Hier stand, js/views/chat.js baue dieses Bauteil „ebenso". Das tut es nicht und hat es nie
       getan — der Block am Kopf desselben Durchgangs sagt selbst das Gegenteil („NICHT DIESES
       BAUTEIL ist das Suchfeld des Chats"). Zwei Kaesten derselben Welle widersprachen sich
       damit beim Erbauer. Erbauer sind GENAU ZWEI, beide mit derselben Aufrufform
       `el("label", { class: "suche" }, [ ... ])`: js/views/projects.js und js/views/todos.js.
     * Der tote linke Rand, der aus dem div folgte, stand hier nur als BEGRUENDUNG fuer die
       Dehnung und nirgends als offener Punkt. Er ist gemessen, gemeldet und mit [SICHT-C3]
       geheilt — das Bauteil ist jetzt ein <label>.
   `align-self: stretch` STATT einer zweiten Hoehenangabe am Feld: die Hoehe des Bauteils steht
   schon EINMAL da (die min-height sieben Zeilen weiter oben), und zwei Orte fuer dieselbe Zahl
   sind genau das, was diese Datei ueberall vermeidet. Lupe und Trefferzaehler bleiben
   unberuehrt: sie tragen flex:none und behalten das align-items:center des Elternteils.
   NACHGEMESSEN (320 px, Stufe 1,3): Eingabefeld 69,8 px hoch, erreichbare Hoehe 70 px — die
   3 px Rest sind die 1-px-Kante oben und die 2-px-Bedienteilkante unten. Der Text bleibt
   mittig: ein Eingabefeld zentriert seinen Wert im Inhaltskasten unabhaengig von dessen Hoehe;
   im Bildvergleich vorschau_suche_vorher.png gegen vorschau_suche_nachher.png steht die
   Grundlinie auf demselben Pixel.
   ZWEITER HANDGRIFF IN DERSELBEN ZEILE — `text-overflow: ellipsis`. `all: unset` setzt die
   Eigenschaft auf clip zurueck; deshalb endete der Platzhalter bei 320 px/Stufe 1,3 als harter
   Schnitt mitten im Wort: „Projekt oder Plan such" in der Projektliste und
   „# · Text · Projekt · Pers" bei den To-dos. Ein Auslassungszeichen ist eine Aussage, ein
   Schnitt sieht aus wie ein Fehler — gemessen wird daraus „Projekt oder Plan su…".
   DAS CHAT-SUCHFELD IST DAVON NICHT BETROFFEN und war es nie: es ist ein input.control
   (js/views/chat.js, Aufrufform `placeholder: tr("Kanal oder Nachricht suchen …")`), kein Kind
   dieses Bauteils. Sein Schnitt ist zwei Bloecke weiter unten geheilt, bei der Regel der
   Bedienteile selbst. */
.suche > input {
  all: unset; align-self: stretch; flex: 1 1 auto; min-width: 0;
  color: var(--text); font-size: max(16px, calc(16px * var(--ui-scale)));
  text-overflow: ellipsis;
}
/* [SICHT-C2-NACHHER 2026-08-25] DER NATIVE LOESCHKNOPF DES BROWSERS GEHT — Begruendung und
   Vorher-Zahlen stehen im Kopf dieses Abschnitts. Hier stehen die Zahlen DANACH, an der Regel,
   die sie erzeugt (gemessen wie vorher: echter Fingertipp, Sortierung nach dem Ziel, das den
   Zeiger bekam; hell UND dunkel; prefers-reduced-motion gesetzt):
     To-dos   320 px/1,3 : EINE Klasse — button[Löschen], gebaut 44x44 · input[search] 0 Treffer
     To-dos   390 px/1,0 : EINE Klasse — button[Löschen], gebaut 44x44 · input[search] 0 Treffer
     Projekte 320 px/1,3 : EINE Klasse — button[Löschen], gebaut 44x44 · input[search] 0 Treffer
     Projekte 390 px/1,0 : EINE Klasse — button[Löschen], gebaut 44x44 · input[search] 0 Treffer
     Vorher waren es je Konfiguration 37 bis 52 leerende Rasterpunkte mit Ziel input[search];
     jetzt sind es NULL, bei unveraendertem Raster (1455 bzw. 1331 Punkte ueber den ganzen Kasten).
   DER NEUE KNOPF IST EIN ZWILLING, nicht nur ein Platzhalter — nachgemessen in beiden Ansichten
   und beiden Themen: 44x44, display grid bei Inhalt und `none` bei leerem Feld, Zeichen 17x17
   (zwei Pfade), Tinte rgb(99,104,111) hell und rgb(135,138,141) dunkel (--text-faint).
   EINE MESSUNG, DIE HIER NICHT STEHT, WEIL SIE NICHTS TAUGT: `getComputedStyle(feld,
   "::-webkit-search-cancel-button").display` meldet auch MIT dieser Regel weiter `block`.
   Chromium spiegelt Autorenregeln an diesem UA-Pseudo-Element nicht in die berechnete
   Formatierung zurueck. Wer diese Zahl als Beleg nimmt, belegt gar nichts — der Beleg ist die
   WIRKUNG oben. Das steht hier, weil dieser Abschnitt einmal daran gescheitert ist, eine
   Behauptung fuer eine Messung zu halten.
   DER SELEKTOR IST ABSICHTLICH ENG. Er nennt `.suche` und `[type="search"]`, damit er genau die
   Bauform trifft, die einen eigenen Knopf HAT. Ein nackter `input[type="search"]`-Selektor haette
   auch den Chat und die Planliste getroffen und ihnen die Loeschgeste ersatzlos genommen.
   `-webkit-appearance` UND `appearance`: die praefixlose Form ist die heutige Schreibweise, die
   praefixierte die, die aeltere WebKit-Staende verstehen. `display: none` steht daneben, weil
   `appearance: none` allein in manchen Staenden nur die ZEICHNUNG nimmt und die Trefferflaeche
   stehen laesst — ein unsichtbares zerstoerendes Ziel waere schlimmer als ein sichtbares. */
.suche > input[type="search"]::-webkit-search-cancel-button {
  -webkit-appearance: none; appearance: none; display: none;
}
.suche .suche-ic { color: var(--ink3); flex: none; }
.suche .suche-zahl { font-family: var(--f-mono); font-variant-numeric: tabular-nums; font-size: calc(var(--fs-maschine) * var(--ui-scale)); color: var(--ink3); flex: none; }

/* ============================================================================
   Hover-Elevation — nur auf echten Zeigegeräten (@media hover/pointer:fine),
   damit Touch-Geräte keinen „klebenden" Hover-Zustand behalten. Reine transform/
   shadow-Politur, gegen prefers-reduced-motion abgesichert (unten neutralisiert).

   AUFGERÄUMT (Welle „Bewegung"): Dieser Block enthielt SECHS eigene
   transition-Deklarationen (.row/.channel-row/.agenda-item, .chip, .photo-thumb,
   .cal-day, .fab, .chip-reaction) mit fünf verschiedenen Zeitwerten. Sie standen
   hier, weil hier die Hover-Wirkung entstand — mit der Folge, dass dieselben
   Elemente auf dem HANDY ohne Übergang sprangen und am Zeigergerät glitten.
   Die Zeiten liegen jetzt bei den Grundregeln (gelten also überall); hier stehen
   nur noch die Zeiger-Wirkungen selbst. Sechs Deklarationen weniger, ein Gefühl
   mehr.
   ============================================================================ */
@media (hover: hover) and (pointer: fine) {
  .row:hover, .card.tap:hover, .channel-row:hover, .agenda-item:hover {
    transform: translateY(-2px); box-shadow: var(--shadow-hover); border-color: var(--border-strong);
  }
  .row:active, .card.tap:active, .channel-row:active, .agenda-item:active { transform: scale(.99); }

  .chip:hover { border-color: var(--navy-bright); color: var(--text); }
  .chip.active:hover { color: var(--accent-ink); }

  .btn:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--gruent); }
  .btn.secondary:hover, .btn.ghost:hover { transform: translateY(-1px); border-color: var(--navy-bright); box-shadow: none; }
  .btn:active { transform: scale(.99); }

  .appbar .iconbtn:hover { border-color: var(--navy-bright); background: var(--surface-2); }
  .tabbar button:hover { color: var(--text-muted); }
  .tabbar button.active:hover { color: var(--accent-ink); }

  .photo-thumb:hover { transform: translateY(-2px); box-shadow: var(--shadow); }

  .cal-day:hover { border-color: var(--border-strong); }
  .cal-day.today:hover { border-color: var(--navy-bright); }

  .fab:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
  .fab:active { transform: scale(.94); }

  .chip-reaction:hover { border-color: var(--navy-bright); }
}

/* Reduzierte Bewegung: Hover-Verschiebungen abschalten (Farb-/Schatten-Feedback bleibt). */
@media (prefers-reduced-motion: reduce) {
  .row:hover, .card.tap:hover, .channel-row:hover, .agenda-item:hover,
  .btn:hover, .btn.secondary:hover, .btn.ghost:hover,
  .photo-thumb:hover, .fab:hover { transform: none; }
}

/* ---------------------------------------------------------------- View-Wechsel (nativ)
   Dezenter Übergang beim Tab-/View-Wechsel über die View-Transitions-API (app.js/navigate):
   die alte Ansicht blendet kurz aus, die neue gleitet minimal von unten ein. NUR transform +
   opacity (composited, kein Repaint) — Appbar/Tabbar stehen als Teil des Root-Snapshots still.
   Ohne API-Support oder bei reduzierter Bewegung rendert navigate() sofort (kein Übergang). */
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(root) { animation: vt-out var(--t-tap) var(--ease) both; }
  ::view-transition-new(root) { animation: vt-in var(--t-fast) var(--ease) both; }
}
@keyframes vt-out { to { opacity: 0; } }
@keyframes vt-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }

/* ============================================================================
   HAND AM GERÄT — Ziehgriff, Zustands-Pille, Fortschrittsbalken
   ----------------------------------------------------------------------------
   Steht bewusst VOR dem Auffangnetz: das Netz muss die letzte Regel der Datei
   bleiben (es hebt jede Dauer auf, auch die von hier).
   ============================================================================ */

/* --- 0) DIE KURVENLEITER STEHT JETZT AN EINEM ORT ------------------------
   Hier stand ein ZWEITER nackter `:root`-Block, nur für `--ease-aus`. Er war
   damals der kürzeste Weg, die dritte Rolle aus BEWEGUNGSSPRACHE §2 (AUSGANG)
   nachzurüsten, ohne den Token-Kopf anzufassen. Der Preis: `grep -n "^:root {"`
   meldete ZWEI Zeilen, und wer den Kopf der Datei las, fand die Kurvenleiter
   unvollständig. Kapitel 11 (Auftrag 4, Rang MUSS) verlangt genau EINEN nackten
   `:root`. `--ease-aus` steht deshalb seit der Stil-Welle im Token-Kopf, wörtlich
   mit demselben Wert; die Ausgangs-DAUERN (--t-fast-out / --t-move-out) sind dort
   gleich mit dazugekommen. Diese Zeile ist ersatzlos entfallen. */

/* --- 0b) EINE HERVORHEBUNG OHNE SCHATTEN-ANIMATION -----------------------
   VORHER (js/views/chat.js): `node.style.transition = "box-shadow .3s ease"`
   auf der angesprungenen Nachricht. Ein Schatten wird nie animiert
   (Markenhandbuch §5, Bewegungssprache §6) — `box-shadow` rechnet je Bild neu,
   und zwar über der ganzen Nachrichtenfläche. NACHHER: der Ring liegt statisch
   auf einem Pseudo-Element, bewegt wird allein seine `opacity`. */
.msg-hervor { position: relative; }
.msg-hervor::after {
  content: ""; position: absolute; inset: -2px; border-radius: var(--radius);
  box-shadow: var(--focus-ring); pointer-events: none;
  /* Ausgang am Grundzustand, Eingang am gesetzten Zustand — dieselbe Bauweise wie beim Blatt. */
  opacity: 0; transition: opacity var(--t-fast-out) var(--ease-aus);
}
.msg-hervor[data-an="1"]::after { opacity: 1; transition: opacity var(--t-fast) var(--ease); }

/* --- 1) DER ZIEHGRIFF IST JETZT EINER ------------------------------------
   Elf Bauorte legen jedem Blatt einen `.grab` an; eine Geste gab es an keinem
   (js/blatt_geste.js schliesst das). Sichtbar bleibt der 44 x 5-px-Strich —
   die BERÜHRFLÄCHE wächst über ein Pseudo-Element auf 120 x 44 px, ohne einen
   Pixel Layout zu verschieben. WCAG 2.5.8 misst die Zielfläche, nicht den
   Strich. Ein 5 px hoher Griff ist mit Handschuh nicht zu treffen. */
.sheet .grab { position: relative; touch-action: none; cursor: grab; }
.sheet .grab::after {
  content: ""; position: absolute; left: 50%; top: 50%;
  width: 120px; height: 44px; transform: translate(-50%, -50%);
}
.sheet[data-zieh="1"] { cursor: grabbing; user-select: none; -webkit-user-select: none; }
.sheet[data-zieh="1"] .grab { cursor: grabbing; }

/* --- 2) DIE ZUSTANDS-PILLE IST EIN KNOPF ----------------------------------
   Sie war ein <span> mit `title` — auf einem Baustellen-Telefon gibt es kein
   Überfahren, der Klartext war damit unerreichbar (Betreiber-Direktive „keine
   Hover-Tooltips als Erklärungsträger"). Jetzt öffnet ein Antipper das
   Zustands-Blatt.
   Die Appbar-Zeile ist gemessen 33 px hoch; eine 44 px hohe Pille würde sie
   aufreissen und alles darunter verschieben. Deshalb wächst nur die
   Berührfläche — vertikal in das ohnehin vorhandene Appbar-Polster (10 px oben,
   11 px unten), horizontal um 4 px je Seite (die Appbar-Lücke ist 12 px, es
   bleiben also 4 px Luft zum Nachbarn). */
button.syncpill {
  position: relative;
  /* [G14 2026-08-21] DIE ZWEITE STELLE DERSELBEN ZAHL — und sie gewinnt, weil sie spaeter steht.
     Die Pille IST ein Knopf; `font: inherit` setzt hier die ganze Schrift zurueck und die
     naechste Angabe nagelte sie wieder auf harte 12,5 px. Beim Umstellen von `.syncpill` auf
     die Textgroessen-Leiter blieb die Anzeige deshalb GEMESSEN auf 12,5 px stehen, obwohl die
     erste Regel laengst rechnete — das Hausmuster „der zweite Weg erbt die Riegel des ersten
     nicht", diesmal in der Kaskade.
     `font: inherit` MUSS stehen bleiben (der Knopf erbt sonst die Browser-Schrift); deshalb
     wird die Groesse direkt danach wieder aus derselben Quelle gesetzt wie oben. */
  font: inherit; font-size: calc(var(--fs-meta) * var(--ui-scale)); font-weight: 800; line-height: 1;
  -webkit-appearance: none; appearance: none;
  cursor: pointer; text-align: left;
}
button.syncpill::after {
  content: ""; position: absolute; left: -4px; right: -4px;
  top: 50%; height: 44px; transform: translateY(-50%);
}
button.syncpill:active { transform: scale(0.97); }
button.syncpill:focus-visible { outline: none; box-shadow: var(--focus-ring); }

/* --- 2b) DER KONTO-CHIP IST SEIT DIESER WELLE AUCH EIN KNOPF ---------------
   [stil 2026-08-06] `app.js:renderAccountBadge` liefert jetzt ein <button> statt eines <span>
   (der Chip oeffnet das Konto-Blatt). Von den vier Zielen der Titelzeile war er damit das
   einzige ohne Druckantwort: gedrueckt sah er aus wie ungedrueckt, und das Blatt braucht eine
   spuerbare Zeit zum Aufbau. Woertlich dieselben zwei Zeilen wie bei `button.syncpill` daneben,
   damit die Titelzeile EIN Gefuehl hat und nicht drei — Dauer aus der Leiter (--t-tap 90 ms).
   Der Fokusring stuende ihm ueber die universelle `:focus-visible`-Regel ohnehin zu; hier wird
   nur der durchsichtige 2-px-Outline durch `none` ersetzt, sonst zeichnete der Browser bei
   einem randlosen Knopf einen zweiten, versetzten Ring.
   NICHT UEBERNOMMEN wurde die vorgeschlagene Zusammenlegung mit der ::after-Gruppe
   (.appbar-signin/.presence-chip). GERECHNET mit dem heutigen Bauort: der Chip setzt inline
   `padding:12px 4px; margin:-12px -4px`, seine Rahmenbox ragt also bereits 4 px in die 12-px-
   Luecke; ein zusaetzliches ::after mit `left:-4px; right:-4px` legte die Trefferflaechen von
   Konto-Chip und Sync-Pille UEBEREINANDER (4 px Ueberdeckung). Die Vereinheitlichung braucht
   deshalb ZUERST den Wegfall des Inline-Polsters in app.js:1542 — dort steht es im Bericht.
   Bis dahin sind die 45 px des Chips gemessen und ausreichend. */
button.acct-badge { transition: transform var(--t-tap) var(--ease); }
button.acct-badge:active { transform: scale(0.97); }
button.acct-badge:focus-visible { outline: none; box-shadow: var(--focus-ring); }

/* [Rueckportierung 2026-08-02, v1.9.63(5)] Dieselbe unsichtbare Vergroesserung fuer die zwei
   Nachbarn der Sync-Pille: .appbar-signin (34 px — der EINZIGE Anmelde-Einstieg der leeren
   Huelle) und .presence-chip (~24 px) lagen unter der 44-px-Hausschranke; mit Handschuh auf
   der Baustelle tippt man daneben. Das ::after-Muster hebt die Trefferflaeche auf 44 px, ohne
   die Appbar-Zeile optisch aufzureissen (4 px Ueberstand je Seite, keine Ueberlappung).

   [BETREIBER 2026-08-21] .acct-badge KOMMT DAZU — und zwar wegen der neuen kleinen Textstufen.
   Der Knopf traegt keine eigene Mindesthoehe; seine Hoehe entsteht aus Schrift plus Polster
   und wandert deshalb mit der Textskala. GEMESSEN am Rig (Edge ueber 9444, echte Rechtecke,
   Kreuzprobe Breite x Stufe):
     Stufe 1 und 1,3   ->  44 px und mehr, unauffaellig — deshalb fiel es nie auf
     Stufe 0,9 und 0,8 ->  43 px, bei 320, 390 UND 430 px Breite gleichermassen
   Ein Pixel unter der Hausschranke ist keine Haarspalterei: der Knopf oeffnet das Konto- und
   Lizenzblatt und sitzt in der obersten Zeile, wo der Daumen ohnehin ungenau ist.

   Es ist derselbe Handgriff wie bei den beiden Nachbarn oben, nicht ein zweiter: EINE Regel,
   drei Verwender. Bei Stufe 1 aendert sich dadurch nichts — das ::after ist 44 px hoch und
   liegt damit innerhalb der ohnehin groesseren Trefferflaeche. */
.appbar-signin,
.presence-chip,
.acct-badge { position: relative; }
.appbar-signin::after,
.presence-chip::after,
.acct-badge::after {
  content: ""; position: absolute; left: -4px; right: -4px;
  top: 50%; height: 44px; transform: translateY(-50%);
}

/* --- 3) FORTSCHRITT OHNE LAYOUT -------------------------------------------
   VORHER: `transition: width var(--t-move) var(--ease)` auf dem Balken der
   Upload-Liste. `width` ist eine Layout-Eigenschaft — jeder Fortschrittsschritt
   warf Layout UND Neuzeichnen an, und zwar genau dann, wenn das Gerät ohnehin
   am Hochladen ist (Bewegungssprache §8.4: im Web nur `transform` und
   `opacity`). NACHHER: der Balken ist voll breit und wird per `scaleX`
   gestaucht — dieselbe Optik, aber allein auf dem Compositor.
   Der Wert kommt aus `--fortschritt` (js/views/settings.js setzt ihn). */
.upload-progress .bar > i {
  width: 100%;
  transform: scaleX(var(--fortschritt, 0));
  transform-origin: left center;
  transition: transform var(--t-move) var(--ease);
}

/* [SCHALE A8 2026-08-10] Der AR-Messen-Block (.ar-overlay, z-index 999 — ausserhalb der
   Z-Leiter) ist MIT seinem einzigen Verwender js/ar_measure.js entfernt: das Modul war gebaut,
   aber nie angeschlossen (0 Importe, 0 Aufrufer, nicht im Precache — Zaehlung stand im
   Dateikopf selbst und in tests/safearea_contract.test.mjs). Die Betreiber-Entscheidung
   dieser Welle loest die dort festgehaltene „Produktentscheidung steht aus" auf. */

/* ══════════════════════════════════════════════════════════════════════════════════════════
   DIE WERKBANK — TABLET-LAYOUT DER SECHS LISTEN-REITER          [IPAD QUER 2026-08-20]
   ══════════════════════════════════════════════════════════════════════════════════════════

   WAS GEMESSEN WAR (Rig 8911, Edge ueber 9444, echte Rechtecke aus dem gerenderten Baum,
   nicht aus dem Quelltext — berichte/quer_vorher.json):

     iPad quer 1024x768 · Projekte : --shell-max 820px · .content 820 · .suche 792x56
                                     .suche > input 738x20 · .row 772x70 · scrollHeight 1718
     iPad hoch 768x1024 · Projekte : --shell-max 720px · .suche 692x56 · input 638x20
     1366x1024          · Projekte : --shell-max 820px · .suche 792x56 · input 738x20
     Einstellungen 1024 : 10 Karten je 792 breit, scrollHeight 4707 (= 6,5 Bildschirme)
     To-dos 1024        : scrollHeight 5067 · Anwesenheit: Primaerknopf 740 px breit

   Der Master-Detail des PLANS ist intakt (oben, @media min-width 1000px: Master 320 +
   Detail 704). Die sechs Reiter hatten UEBERHAUPT KEIN Tabletlayout: in jedem Format
   dieselbe Einspalter-Saeule. Ein Suchfeld, das dreissigmal so breit wie hoch ist, und
   200 px Bedienelemente ueber einer Liste, von der bei 768 px Fensterhoehe nur noch
   sieben Zeilen uebrig bleiben.

   ── DER ENTWURF ────────────────────────────────────────────────────────────────────────
   DIE WERKBANK: Kopf ueber die volle Breite · WERKZEUGE links in einer schmalen Leiste ·
   ARBEITSFLAECHE rechts. Das ist dieselbe Denkweise wie beim Plan (schmale Instrumenten-
   spalte links, Arbeitsflaeche rechts) — die App spricht damit EINE Tabletsprache und
   nicht zwei. Der Gewinn ist nicht "breiter", sondern: die Liste faengt GANZ OBEN an,
   weil ueber ihr nur noch der Kopf steht statt Suche + Segment + Rubrik.

   WARUM DIE LISTE EINSPALTIG BLEIBT — und das ist die teuerste Entscheidung dieses Loses:
   "Die Schnur" (styles.css:1567 ff.) ist die Signatur: EINE 2-px-Linie am linken Rand der
   Liste, ein Zustandsknoten je Zeile. Zwei Listenspalten brauchen zwei Straenge, und das
   Gestaltungsgesetz (GESTALTUNG_SYSTEM.md, Begruendung an .appbar::after) sagt woertlich:
   zwei Schnuere auf einem Bildschirm sind zwei Signaturen. Also traegt die Liste EINE
   Spalte — und die gewonnene Breite geht an die Werkzeugleiste, nicht an einen zweiten
   Strang. Wo es KEINE Schnur gibt (Einstellungen, Anwesenheit: freistehende Karten),
   werden dagegen wirklich zwei Spalten gesetzt; siehe die Multicol-Bloecke unten.

   WARUM FLOAT UND NICHT GRID: die Ansichten haengen EINEN namenlosen Wurzelknoten in
   .content und darunter zehn bis vierzehn Geschwister (gemessen, quer_baum.mjs). Ein
   Raster koennte die Leiste nicht sammeln — die Arbeitsflaeche muesste ueber eine
   unbekannte Zahl Zeilen spannen (`grid-row: 2 / -1` gibt es in einem impliziten Raster
   nicht, und ein spannendes Element verteilt seine Hoehe auf die ueberspannten Spuren und
   reisst die Werkzeuge auseinander). `float + clear: left` stapelt beliebig viele
   Werkzeuge in der Rinne, und die Arbeitsflaeche bleibt im Fluss — sie beginnt an ihrer
   statischen Position, also direkt unter dem Kopf. Genau das ist der gesuchte Effekt.
   Der Behaelter bekommt `display: flow-root`, sonst faellt er auf die Hoehe der
   Arbeitsflaeche zusammen und die Werkzeuge haengen unten heraus.

   KEINE AENDERUNG AM TELEFON: der ganze Block steht in `@media (min-width: 768px)`.
   Telefone bis 430 px sehen ihn nie. Nachgewiesen mit Bildschirmfotos 320/360/390/430 px
   vorher/nachher (SHA-256 gleich, berichte/quer_telefon_gleich.json).
   ══════════════════════════════════════════════════════════════════════════════════════════ */
@media (min-width: 768px) {
  /* Die Masse der Werkbank. `30%` wird am VERWENDUNGSORT aufgeloest (Custom Properties
     werden als Token eingesetzt, nicht vorberechnet) — also gegen die Innenbreite des
     Ansichtswurzelknotens, und das ist genau die gemeinte Bezugsgroesse.
     Untergrenze 264 px: darunter passt "Zuletzt geaendert" nicht mehr in einen
     Segmentknopf, ohne dass er auf zwei Zeilen bricht.
     Obergrenze 320 px: der Plan-Master ist clamp(320px, 30%, 360px) — die Leiste bleibt
     bewusst eine Spur schmaler als er, weil sie Werkzeuge traegt und keine Liste. */
  :root {
    --quer-leiste: clamp(264px, 30%, 320px);
    --quer-rinne: 24px;
    --quer-versatz: calc(var(--quer-leiste) + var(--quer-rinne));
  }

  /* ── DIE SPALTE DARF DIE FLAECHE NUTZEN ───────────────────────────────────────────────
     GEMESSEN: bei 1024 px stand die App in einer 820-px-Saeule, links und rechts je
     102 px Millimeterpapier. Auf einem iPad ist das kein "Geraet in einer Flaeche",
     sondern verschenkte Arbeitsflaeche.
     DIE ANHEBUNG HAENGT AN DEN SECHS ANSICHTEN, NICHT AN DER FENSTERBREITE — sonst
     schwebte der Plan-Viewer unter 1000 px (dort ist er einspaltig, gemessen:
     768x1024 -> split=false, Canvas 720x853) in einer 1180er-Wueste. Deshalb fuenf
     `:has()`-Sonden auf genau die Merkmale, die nur diese sechs Ansichten haben.
     `.content:not(.md-split)` haelt den Plan-Master-Detail ab 1000 px zusaetzlich fern;
     dessen eigene Regel (`:root:has(.content.md-split)`, oben) bleibt unberuehrt —
     sie greift in einem Fall, den diese Sonden ausdruecklich ausschliessen. */
  :root:has(.content:not(.md-split) > div > .suche),
  :root:has(.content:not(.md-split) > div > input.control),
  :root:has(.content:not(.md-split) > div > .card),
  :root:has(.content:not(.md-split) > .attendance-view),
  :root:has(.content:not(.md-split) > .kal-view) {
    --shell-max: 1180px;
  }
  /* .content war auf `min(920px, 100%)` gedeckelt (styles.css:1226) — der Deckel haette
     die eben freigegebene Breite sofort wieder weggenommen. Er wird nicht entfernt,
     sondern an --shell-max gebunden: wo --shell-max unveraendert 720/820 ist (Plan,
     Anmeldung), rechnet `min(--shell-max, 100%)` denselben Wert wie vorher — nachgemessen
     bei 768/1024/1366 im Plan: 720 / 1024 (md-split) / 1180 (md-split), alles gleich. */
  .content { width: min(var(--shell-max), 100%); }

  /* ── DER BEHAELTER ────────────────────────────────────────────────────────────────────
     Vier Wurzelknoten, gemessen (quer_baum.mjs/quer_baum2.mjs):
       Projekte, To-dos  ->  .content > div  mit `> .suche` und `> .list`
       Chat              ->  .content > div  mit `> input.control`
       Anwesenheit       ->  .content > .attendance-view
       Kalender          ->  .content > .kal-view > div  (Kopf, Segment und Koerper liegen
                             dort eine Ebene tiefer als bei den anderen fuenf)
     `flow-root` schliesst die Gleiter ein. Ohne diese Zeile endet der Behaelter an der
     Unterkante der Arbeitsflaeche und eine lange Werkzeugleiste ragt darunter heraus. */
  .content:not(.md-split) > div:has(> .suche),
  .content:not(.md-split) > div:has(> input.control),
  .content:not(.md-split) > .attendance-view,
  .content:not(.md-split) > .kal-view > div:has(> .seg) {
    display: flow-root;
  }

  /* ── ALLES WANDERT ZUERST IN DIE WERKZEUGLEISTE ───────────────────────────────────────
     `clear: left` stapelt sie untereinander statt sie nebeneinander zu setzen. Was kein
     Werkzeug ist, wird in den folgenden Bloecken wieder herausgenommen.
     `:not(.fab)` ist KEINE Zierde: der FAB ist `position: fixed` (float faellt dort von
     selbst weg), aber die Breite haette gegriffen. GEMESSEN, als er noch mitlief:
     62x62 -> 38x62 — ein `width: auto` als Ruecknahme half nicht, weil `auto` bei einem
     fest positionierten Kasten Schrumpfbreite heisst. Also gar nicht erst anfassen. */
  .content:not(.md-split) > div:has(> .suche) > *:not(.fab),
  .content:not(.md-split) > div:has(> input.control) > *:not(.fab),
  .content:not(.md-split) > .attendance-view > *:not(.fab),
  .content:not(.md-split) > .kal-view > div:has(> .seg) > *:not(.fab) {
    float: left;
    clear: left;
    width: var(--quer-leiste);
    /* WAS IN DIE LEISTE PASST, BRICHT UM STATT ZU SCROLLEN. Drei Bedienzeilen setzen
       inline `display:flex` mit `overflow-x:auto` (todos.js:1095/1109 Schnellfilter und
       Etiketten, kalender.js Zaehlerzeile) — in 296 px wurde daraus ein waagerechter
       Schieber, der „Meine off…" mitten im Wort abschnitt (gemessen an der Aufnahme
       nachher_todos_1024x768_light.png). `flex-wrap` steht in KEINEM dieser Inline-Stile,
       eine Regel greift hier also ohne `!important`. `.suche` bleibt ausgenommen: dort
       wuerde der Trefferzaehler unter das Eingabefeld rutschen. */
    flex-wrap: wrap;
  }
  .content:not(.md-split) > div:has(> .suche) > .suche { flex-wrap: nowrap; }

  /* ── DER KOPF STEHT UEBER BEIDEN FELDERN ──────────────────────────────────────────────
     Bei allen vier Behaeltern ist das erste Kind der Kopf (D1 + Zaehler; beim Kalender
     die .kal-kopf-Zeile mit Rubrik, Titel und Navigation) — nachgezaehlt, nicht geraten. */
  .content:not(.md-split) > div:has(> .suche) > :first-child,
  .content:not(.md-split) > div:has(> input.control) > :first-child,
  .content:not(.md-split) > .attendance-view > :first-child,
  .content:not(.md-split) > .kal-view > div:has(> .seg) > :first-child {
    float: none;
    clear: both;
    width: auto;
  }

  /* ── BANNER SIND MELDUNGEN UEBER DEN GANZEN BILDSCHIRM ────────────────────────────────
     "Anwesenheit ist fuer dieses Konto nicht freigeschaltet" oder "Benachrichtigungen
     sind noch nicht erlaubt" gehoeren nicht in eine 300-px-Werkzeugleiste. Sie bleiben
     volle Breite. Weil sie im Fluss stehen, beginnen die nachfolgenden Gleiter UND die
     Arbeitsflaeche unter ihnen — die Reihenfolge stimmt also von selbst.
     `:has(> .banner)` faengt auch die namenlosen Wirtsknoten, in denen Chat und Kalender
     ihre Banner halten (gemessen). Die Arbeitsflaeche der Anwesenheit enthaelt ebenfalls
     ein Banner als direktes Kind — sie wird im naechsten Block wieder zurueckgeholt und
     ist dort mit (0,4,1) spezifischer als diese Regel mit (0,4,0). */
  .content:not(.md-split) > div:has(> .suche) > .banner,
  .content:not(.md-split) > div:has(> .suche) > :has(> .banner),
  .content:not(.md-split) > div:has(> input.control) > .banner,
  .content:not(.md-split) > div:has(> input.control) > :has(> .banner),
  .content:not(.md-split) > .attendance-view > .banner,
  .content:not(.md-split) > .attendance-view > :has(> .banner),
  .content:not(.md-split) > .kal-view > div:has(> .seg) > .banner,
  .content:not(.md-split) > .kal-view > div:has(> .seg) > :has(> .banner) {
    float: none;
    clear: both;
    width: auto;
  }

  /* ── DIE ARBEITSFLAECHE ───────────────────────────────────────────────────────────────
     Sie bleibt im Fluss und wird per `margin-left` neben die Rinne gesetzt. Die Breite
     wird AUSGERECHNET statt `auto` gelassen: `.list` ist ein Raster, und ein eigener
     Formatierungskontext weicht Gleitern von sich aus aus — er wuerde schmalgerechnet
     oder nach unten geschoben. Mit der exakten Restbreite liegt das Kastenmass genau im
     gleiterfreien Feld, und der Browser hat nichts zu entscheiden.
     Die Schnur wandert mit: sie haengt an `.list` und sitzt weiterhin an DEREN linkem
     Rand — eine Linie, ein Knoten je Zeile, unveraendert. */
  .content:not(.md-split) > div:has(> .suche) > .list,
  .content:not(.md-split) > div:has(> .suche) > .rub,
  .content:not(.md-split) > div:has(> .suche) > :has(> .rub),
  .content:not(.md-split) > div:has(> input.control) > div:last-of-type,
  .content:not(.md-split) > .attendance-view > div:has(> .card),
  .content:not(.md-split) > .kal-view > div:has(> .seg) > .seg ~ * {
    float: none;
    clear: none;
    margin-left: var(--quer-versatz);
    width: calc(100% - var(--quer-versatz));
  }
  /* ZWEI ZEILEN ZUR AUSWAHL DER ARBEITSFLAECHE, DIE TEUER WAREN:
     (1) `.rub` und ihr namenloser Wirt: die Rubrik („OFFEN", „Treffer in Titeln") ist die
         UEBERSCHRIFT DER LISTE, nicht ein Werkzeug. In der Leiste stand sie verwaist
         ueber dem Sortiersegment (Aufnahme nachher_projects_1024x768_light.png). Sie
         steht im Fluss VOR der Liste, also faengt die Liste von selbst darunter an.
     (2) Chat: `div:last-of-type` statt `:has(> .channel-list)`. GEMESSEN im Ladezustand
         (quer_klein.mjs): der Kanalwirt traegt dann noch KEINE `.channel-list`, sondern
         Skelettkaesten — der Selektor griff nicht, und das ganze Skelett stand 264 px
         breit in der Werkzeugleiste (Aufnahme nachher_chat_768x1024_dark.png). Der letzte
         `div` ist der Wirt in BEIDEN Zustaenden; der FAB dahinter ist ein `button` und
         zaehlt fuer `:last-of-type` nicht mit.
     (3) Kalender: `.seg ~ *` statt `div:last-child`. GEMESSEN in der MONATS-Ansicht
         (quer_monat.mjs, Aufnahme nachher_kalMonat_1024x768_light.png): dort liefert die
         Ansicht ZWEI Koerper — das Monatsraster UND die Tagesliste darunter. `:last-child`
         holte nur die Tagesliste; das Monatsraster stand 296 px schmal in der
         Werkzeugleiste unter den Ansichtsknoepfen. Der Geschwister-Verbinder trifft alles,
         was NACH der Ansichtswahl kommt — und das ist genau die Definition der
         Arbeitsflaeche dieser Ansicht. Der FAB ist kein Geschwister (er haengt an
         `.kal-view`, eine Ebene hoeher) und bleibt unberuehrt. */

  /* Ein Werkzeug darf nicht breiter sein als die Leiste. GEMESSEN: der Kalender-Chip
     „Auch vergebene Aufgaben" ist 390 px breit (`flex: none` im Inline-Stil) und ragte bei
     264 px Leiste 126 px in die Rinne. `max-width` kappt ihn, die Beschriftung bricht um,
     die 44-px-Trefferflaeche bleibt (sie haengt an `min-height`, nicht an der Breite). */
  .content:not(.md-split) > div:has(> .suche) > * > .chip,
  .content:not(.md-split) > div:has(> input.control) > * > .chip,
  .content:not(.md-split) > .attendance-view > * > .chip,
  .content:not(.md-split) > .kal-view > div:has(> .seg) > * > .chip {
    max-width: 100%;
  }

  /* ── DAS SEGMENT WIRD IN DER LEISTE ZUR OPTIONSSPALTE ─────────────────────────────────
     GEMESSEN am Bestand: `.seg` ist `inline-flex` mit `flex-wrap` und `flex: 1` je Knopf
     (styles.css:2958). In 296 px braechen "Arbeitswoche"/"Zuletzt geaendert" auf zwei
     Zeilen, und die sechs Kalenderknoepfe stuenden dreispaltig zu je 90 px. Untereinander
     ist es dagegen genau das, was eine Werkzeugleiste will: eine Liste von Ansichten,
     jede eine volle, 48 px hohe Trefferflaeche, links buendig lesbar.
     Der aktive Knopf behaelt seine gruene Marke — das ist Amt (a) der vier Gruen-Aemter
     und bleibt unveraendert; hier kommt KEIN fuenftes Gruen dazu.

     [SICHT-3 2026-08-25] DIESE REGEL GREIFT BEI EINEM VON DREI VERWENDERN. Nachgemessen am
     gerenderten Baum (768x1024, Stufe 1,0, gelesen wurde die BERECHNETE Formatierung des
     ersten Knopfes, nicht der Quelltext):
       Projekte    ... 48 px hoch, 14 px Einzug, flex 0 0 auto  -> die Regel wirkt
       Kalender    ... 44 px hoch,  2 px Einzug, flex 1 0 auto  -> UEBERSCHRIEBEN
       Anwesenheit ... 44 px hoch,  6 px Einzug, flex 1 1 auto  -> UEBERSCHRIEBEN
     URSACHE ist beide Male ein INLINE-Stil am Knopf, und ein Inline-Stil schlaegt jede Regel
     dieses Blattes ohne Rangfolge-Diskussion:
       js/views/kalender.js     `const segStyle = "...padding:0 2px;flex:1 0 auto;..."`
       js/views/attendance.js   `const SEG = "flex:1 1 auto;min-width:0;..."`
     [SICHT-C4 2026-08-25] HIER STANDEN ZEILENNUMMERN (kalender.js:2108, attendance.js:1137) —
     und die Probe [sicht-5] hat AUF DIE ZEILENNUMMERN gepruft, in einer Datei, die sie gar nicht
     aufschlaegt. Sie sicherte damit nur, dass die ZEICHENKETTE hier stehen bleibt, nie dass sie
     noch WAHR ist. Genau davor warnt der Kopf der Probendatei („die Aufrufform ist der Anker,
     nicht das Wort"). Verankert ist jetzt die Aufrufform, und die Probe schlaegt beide fremden
     Dateien wirklich auf. Wird eine der zwei Ueberschreibungen ENTFERNT, faellt [sicht-5] —
     das ist dann kein Rueckschritt, sondern die Aufforderung, diesen Kasten nachzuziehen.
     WAS DAS KOSTET: die 48 px, die eine Zeile weiter unten ausdruecklich als Handschuh-Mass
     begruendet sind, gelten fuer zwei der drei Leisten nicht — und der 14-px-Einzug, der die
     Spalte ueberhaupt erst wie eine LISTE lesen laesst, auch nicht: die Kalenderknoepfe stehen
     2 px von der Kante (Bild sicht/kalender_768_dark_1.png).
     HIER IST ES NICHT ZU HEILEN, und darum steht hier nur die Messung: ein Ausrufezeichen
     gegen einen Inline-Stil verschoebe die Zustaendigkeit an den falschen Ort. Der Handgriff
     ist das ENTFERNEN der drei Angaben aus den zwei Stil-Zeichenketten, und die liegen in
     fremder Dateihoheit. Als Fremdbedarf gemeldet, mit Zeilennummer und Zahl. */
  .content:not(.md-split) > div:has(> .suche) > .seg,
  .content:not(.md-split) > .attendance-view > .attendance-seg,
  .content:not(.md-split) > .kal-view > div:has(> .seg) > .seg {
    flex-direction: column;
  }
  .content:not(.md-split) > div:has(> .suche) > .seg > button,
  .content:not(.md-split) > .attendance-view > .attendance-seg > button,
  .content:not(.md-split) > .kal-view > div:has(> .seg) > .seg > button {
    flex: none;
    width: 100%;
    justify-content: flex-start;
    text-align: left;
    padding-left: 14px;
    /* Handschuh-Mass. Der Bestand setzt 44 px; in der Leiste ist Platz fuer die 48 px,
       die auf einer Baustelle den Unterschied machen. */
    min-height: 48px;
  }

  /* ── ZWEI SPALTEN, WO ES KEINE SCHNUR GIBT: DIE EINSTELLUNGEN ─────────────────────────
     GEMESSEN: 10 Karten uebereinander, scrollHeight 4707 px bei 716 px Fensterhoehe —
     sechseinhalb Bildschirme fuer eine Uebersicht, die man ueberblicken koennen soll.
     Karten sind freistehende Bloecke ohne Zustandsknoten; hier ist die zweite Spalte
     genau richtig und kostet keine Signatur.
     `columns: 420px 2` statt `column-count: 2`: die Zahl ist eine OBERGRENZE, die Breite
     ein Mindestmass. Unter ~864 px nutzbarer Breite bleibt es automatisch bei einer
     Spalte — der Umbruch braucht keinen zweiten Haltepunkt und keine geratene Schwelle.
     WARUM 420 UND NICHT 340: mit 340 kippte auch das HOCHFORMAT 768x1024 auf zwei
     Spalten, und dort war es MESSBAR SCHLECHTER (quer_sichtbar.mjs, A/B in derselben
     Sitzung): ganz sichtbare Karten 4 -> 3. In 358 px wachsen die Karten in die Hoehe
     schneller, als die zweite Spalte sie einspart. Bei 1024 px (488er Spalten) dreht
     sich das Vorzeichen um: scrollHeight 4707 -> 2681 bei unveraendert 2 sichtbaren
     Karten. Die 420 legen den Umschlag genau dazwischen (~880 px Fensterbreite).
     `column-span: all` fuer die Ueberschrift: sie ist der Titel des Blattes, nicht der
     ersten Spalte. Das ist der einzige Grund, warum hier Multicol und nicht die Werkbank
     steht — Multicol kennt als einziges Layout diesen Befehl.
     Ausschluss von `.suche`/`.list`: die abgemeldete Projektliste zeigt ebenfalls eine
     `.card` als direktes Kind; sie ist eine Werkbank und keine Kartenwand. */
  .content:not(.md-split) > div:has(> .card):not(:has(> .suche)):not(:has(> .list)) {
    columns: 420px 2;
    column-gap: var(--quer-rinne);
  }
  .content:not(.md-split) > div:has(> .card):not(:has(> .suche)):not(:has(> .list)) > :first-child,
  .content:not(.md-split) > div:has(> .card):not(:has(> .suche)):not(:has(> .list)) > .banner {
    column-span: all;
  }
  .content:not(.md-split) > div:has(> .card):not(:has(> .suche)):not(:has(> .list)) > .card,
  .content:not(.md-split) > div:has(> .card):not(:has(> .suche)):not(:has(> .list)) > details.card {
    /* Eine Karte darf nicht in der Mitte umbrechen — ein halber "Lizenz"-Block oben in
       der zweiten Spalte waere schlimmer als eine lange Spalte. */
    break-inside: avoid;
    margin-top: 0;
  }

  /* ── ZWEI SPALTEN AUCH IN DER ANWESENHEIT ─────────────────────────────────────────────
     Dieselbe Begruendung, dieselbe selbstregelnde Formel — nur mit 320 px Mindestbreite,
     weil die Arbeitsflaeche hier schon um die Werkzeugleiste verkuerzt ist:
       1024 px: Arbeitsflaeche 676 -> zwei Spalten a 326
        768 px: Arbeitsflaeche 456 -> eine Spalte (floor(456/320) = 1)
     GEMESSEN war der Anlass: der Primaerknopf "Arbeitstag starten" war 740 px breit. In
     der halben Spalte ist er wieder ein Knopf und nicht ein Balken. */
  .content:not(.md-split) > .attendance-view > div:has(> .card) {
    columns: 320px 2;
    column-gap: var(--quer-rinne);
  }
  /* EINE EINZIGE KARTE FUELLT KEINE ZWEI SPALTEN. GEMESSEN (quer_spalten.mjs, 1024 px):
     die Unterreiter „Meine Zeiten" (1 Karte, 191 px), „Korrekturen" (1, 160) und „Meine
     Daten" (1, 524) liefern genau EINE Karte — sie stand 325 px breit da, und die rechte
     Haelfte der Arbeitsflaeche blieb leer. Das ist derselbe Fehler, den dieses Los
     bekaempft, nur eine Ebene tiefer.
     `:not(:has(> .card ~ .card))` heisst woertlich: „es gibt keine Karte, die auf eine
     Karte folgt" — also hoechstens eine. Dann keine Spalten, und die Karte nimmt die
     ganze Arbeitsflaeche. `columns: auto` setzt Zahl UND Breite zurueck. */
  .content:not(.md-split) > .attendance-view > div:has(> .card):not(:has(> .card ~ .card)),
  .content:not(.md-split) > div:has(> .card):not(:has(> .suche)):not(:has(> .list)):not(:has(> .card ~ .card)) {
    columns: auto;
  }
  .content:not(.md-split) > .attendance-view > div:has(> .card) > .banner { column-span: all; }
  .content:not(.md-split) > .attendance-view > div:has(> .card) > .card,
  .content:not(.md-split) > .attendance-view > div:has(> .card) > details.card {
    break-inside: avoid;
    margin-top: 0;
  }
}

/* ============================================================================
   REDUZIERTE BEWEGUNG — DAS AUFFANGNETZ (muss ZULETZT stehen)
   ----------------------------------------------------------------------------
   BEFUND vor dieser Welle: Die PWA hatte SIEBEN einzelne
   `prefers-reduced-motion`-Blöcke, aber kein Netz darunter. Jede neue Bewegung
   musste daran denken, sich selbst abzumelden — und sechs hatten es nicht getan.
   Gemessen am 2026-07-25 lief bei „Bewegung reduzieren" weiter:
     · das Blatt (.sheet) fuhr über 260 ms von unten herein,
     · der Toast schob sich 10 px nach oben herein,
     · der Kreisel (.spinner) drehte endlos,
     · der Auslöse-Ring der 360-Kamera pulsierte in der Grösse,
     · der Punkt der Verbindungs-Anzeige pulsierte (nur der der Sync-Pille war
       abgemeldet — dieselbe Animation, zwei verschiedene Behandlungen),
     · und JEDE der Übergangs-Deklarationen der Datei lief unverändert weiter.
   Die Website hat dieses Netz seit Langem (global.css, universelle Regel mit
   0.01ms). Die PWA bekommt es jetzt in derselben Form. Ab hier muss sich keine
   neue Regel mehr selbst abmelden — sie ist automatisch mit abgedeckt.

   Warum 0.01ms statt 0s: eine Dauer von exakt 0 unterdrückt in manchen Engines
   das `transitionend`/`animationend`-Ereignis. Skripte, die darauf warten (hier
   z. B. das Aufräumen von Blättern), würden dann hängen. 0.01ms ist unsichtbar
   und feuert trotzdem.

   Was das Netz NICHT tut: es nimmt keine Farbe, keinen Schatten und keinen
   Zustand weg. Alles bleibt sichtbar und vollständig — es passiert nur sofort
   statt allmählich. „Ruhig" heisst hier nicht „ärmer": der gedrückte Knopf ist
   weiterhin sichtbar gedrückt, das Blatt ist weiterhin da, der Ladezustand ist
   weiterhin ein Ladezustand (der Kreisel wird oben zum geschlossenen Ring).
   ============================================================================ */

/* ============================================================================================
   [LOS PLANFORMATE 2026-08-20] DIE VEKTOR-PLANANSICHT UND IHRE EBENENLEISTE
   (js/plan/plan_ansicht.js — fuer DXF, SVG und DWG)

   ZWEI REGELN, DIE HIER MEHR ZAEHLEN ALS SONST:

   1. `touch-action: none` AUF DER LEINWAND. Ohne das nimmt der Browser jede Wischgeste selbst
      und scrollt die Seite, statt den Plan zu ziehen — auf dem Telefon ist die Ansicht damit
      schlicht unbedienbar. Es steht hier und nicht im Skript, weil es eine Eigenschaft der
      FLAECHE ist und nicht eine des Ereignisses.

   2. KEIN `image-rendering`, KEIN Filter, KEIN Schatten AUF DER LEINWAND. Der ganze Sinn der
      Vektoransicht ist, dass jede Linie in Geraetepixeln gemalt wird; ein Filter darueber
      zwingt den Browser, das fertige Bild noch einmal anzufassen, und genau dabei geht die
      Schaerfe verloren, um die es geht.
   ============================================================================================ */

.planvektor {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background: var(--fl);
  contain: strict;
}

.planvektor-leinwand {
  display: block;
  width: 100%;
  height: 100%;
  /* Siehe Regel 1 oben. */
  touch-action: none;
  cursor: grab;
  outline: none;
}
.planvektor-leinwand:active { cursor: grabbing; }
.planvektor-leinwand:focus-visible { box-shadow: inset 0 0 0 2px var(--line3); }

/* Der Zoomstand. Er sitzt UNTEN RECHTS und nicht oben: oben liegen Titel und Werkzeuge, und
   ein Wert, der sich bei jeder Geste aendert, zieht dort dauernd den Blick weg. */
.planvektor-stand {
  position: absolute;
  right: 8px;
  bottom: 8px;
  padding: 3px 8px;
  border-radius: 6px;
  background: color-mix(in srgb, var(--fl3) 88%, transparent);
  border: 1px solid var(--line);
  color: var(--ink2);
  /* [SCHRIFT L9-17 2026-08-25] Vorher `font: 500 11px/1.4 var(--mono, …)`. GEMESSEN ueber
     alle .js/.css/.html des Baums (ohne tests/ und vendor/): `--mono` wird NIRGENDS
     definiert — weder im :root noch per setProperty. Der Rueckfall gewann also immer, und
     die Haus-Maschinenschrift --f-mono (JetBrains Mono) kam hier nie zum Zug. Dazu fror
     die font-Kurzform Groesse UND Zeilenhoehe ein: bei Textstufe 1,3 blieb der Zoomstand
     auf 11 px, waehrend der Fliesstext auf 19,5 px stand. */
  font: 500 calc(11px * var(--ui-scale))/1.4 var(--f-mono);
  font-variant-numeric: tabular-nums;
  pointer-events: none;
  white-space: nowrap;
}

/* -------------------------------------------------------------------------- Ebenenleiste */

.planebenen {
  display: flex;
  flex-direction: column;
  min-height: 0;
  background: var(--fl);
  border-left: 1px solid var(--line);
}

.planebenen-kopf {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 10px;
  border-bottom: 1px solid var(--line);
  flex: 0 0 auto;
}
.planebenen-titel { font-weight: 600; color: var(--ink); }
.planebenen-zahl {
  margin-right: auto;
  color: var(--ink3);
  font-variant-numeric: tabular-nums;
  font-size: calc(12px * var(--ui-scale));
}
.planebenen-alle {
  border: 1px solid var(--line);
  background: var(--fl);
  color: var(--ink2);
  border-radius: 6px;
  padding: 3px 8px;
  font-size: calc(12px * var(--ui-scale));
  cursor: pointer;
  /* [TREFFER L9-16 2026-08-25] Vorher 32px. Der Haus-Boden lautet
     `min-height: max(44px, calc(44px * var(--ui-scale)))` und ist in
     tests/stil_schnurmass.test.mjs fuer eine Liste von Klassen namentlich festgenagelt;
     32 px lagen 12 px darunter UND wuchsen mit der Textstufe nicht mit. */
  min-height: max(44px, calc(44px * var(--ui-scale)));
}
.planebenen-alle:active { background: var(--fl-tap); }

.planebenen-liste { overflow-y: auto; min-height: 0; }

.planebenen-zeile {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 7px 10px;
  cursor: pointer;
  /* [TREFFER L9-16 2026-08-25] Der Kommentar hatte recht und die Zahl unrecht: auf der
     Baustelle wird mit Handschuh getippt, und das Haus misst dafuer 44 px, nicht 40.
     Der Wert lag vorher bei 40 (Eigenschaft min-height) — 4 px unter dem Boden und ohne
     Textskala. Die Eigenschaft steht hier BEWUSST nicht mit ihrer alten Zahl zusammen:
     tests/plan_ansicht.test.mjs [pa-05] schneidet ein Fenster hinter dem Selektor und haette
     den Kommentar sonst als Fundort gelesen — eine gruene Zusicherung ohne echten Wert.
     FOLGE, die das Plan-Los kennen muss: die Ebenenliste zeigt bei gleicher Hoehe rund 10 % weniger
     Zeilen je Bildschirm. Das Polster (padding: 7px 10px) bleibt — die Hoehe traegt der
     max()-Boden. */
  min-height: max(44px, calc(44px * var(--ui-scale)));
}
.planebenen-zeile:active { background: var(--fl-tap); }

/* Ebenen OHNE Zeichnung werden abgesetzt, nicht versteckt — der Zeichner hat sie angelegt,
   und wer eine sucht, soll sehen, dass sie leer ist, statt zu glauben, die App habe sie
   verloren. Sie bleiben lesbar (--ink3 ist AA), nur ruhiger. */
.planebenen-zeile.leer { color: var(--ink3); }
.planebenen-zeile.leer .planebenen-farbe { opacity: .4; }

.planebenen-haken { width: 18px; height: 18px; flex: 0 0 auto; accent-color: var(--line3); }
.planebenen-farbe {
  width: 12px;
  height: 12px;
  flex: 0 0 auto;
  border-radius: 3px;
  /* Die Ebenenfarbe kann Weiss sein. Ohne Rand ist der Fleck dann unsichtbar und die Zeile
     sieht aus, als fehle etwas. */
  border: 1px solid var(--line);
}
.planebenen-name {
  flex: 1 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.planebenen-anzahl {
  flex: 0 0 auto;
  color: var(--ink3);
  font-size: calc(12px * var(--ui-scale));
  font-variant-numeric: tabular-nums;
}

/* ============================================================================
   DIE ANTWORT UNTER DEM FINGER — die vier gemessenen Luecken, geschlossen
   ----------------------------------------------------------------------------
   [W-fluss-22b 2026-08-22] Der ausdrueckliche Verdacht des Auftrags lautete: „der Baum hat
   --t-tap und button:active { transform: scale(…) } an einigen Stellen. Haben es ALLE?"

   GEMESSEN, nicht im Blatt gezaehlt (schnur-rig/nachbess/z_tipprueckmeldung.mjs — jedes
   sichtbare Tippziel wird ueber CDP `CSS.forcePseudoState` in den :active-Zustand gezwungen,
   davor und danach werden transform / background-color / opacity / filter / box-shadow /
   border-color / color gelesen; aendert sich KEINER der sieben, hat dieses Ziel unter dem
   Finger kein Bild). Sechs Reiter, echte Org-Daten:

       Tippziele gesamt                408
       mit sofortiger Rueckmeldung     369
       OHNE                             39

   Die 39 sind KEINE 39 Einzelfaelle, sondern vier Gruppen — und dreimal ist die Ursache
   dieselbe: EIN INLINE-STIL SCHLAEGT DAS BLATT.
     32x  .btn.ghost.small der Chat-Kanalzeile. `.btn.ghost:active { background: var(--fl-tap) }`
          steht seit langem da (Zeile 1963) und TRIFFT NICHT: die Knoepfe tragen
          `style="…;background:transparent;border:0"`, und ein Inline-Wert schlaegt jede
          Blattregel ohne !important. Genau deshalb ist eine Quelltextsuche hier wertlos
          gewesen — die Regel EXISTIERT, sie wirkt nur nicht.
      5x  die Segmentknoepfe der Anwesenheit (.attendance-seg button): Flaeche, Farbe, Rahmen,
          Uebergang — alles inline, ein Aktivzustand nirgends.
      1x  <summary> (aufklappbare Abschnitte) — nie eine :active-Regel gehabt.
      1x  button.banner (das antippbare Band „Dir zugewiesen: 9 · antippen zum Filtern")
          — sieht aus wie ein Hinweis, ist aber ein Knopf.

   WARUM `opacity` UND NICHT `transform` ODER `background`: beides ist bei diesen vier Gruppen
   inline belegt (transform: translateY(-50%) an den Chat-Knoepfen, background an allen dreien)
   und damit unerreichbar. `opacity` ist an KEINER der Fundstellen inline gesetzt — und sie
   traegt in beiden Themen, weil sie nicht auf die Flaeche wirkt, sondern auf das, was drauf
   steht: ein Knopf ohne Flaeche hat trotzdem ein Zeichen und eine Schrift.

   BEWUSST NICHT `* :active`: die uebrigen 369 Ziele haben ihre Antwort bereits, und zwei
   Antworten auf einen Druck sind eine zu viel. Diese Regel nennt genau die vier Gruppen.
   ============================================================================ */
.channel-list .btn.ghost:active,
.attendance-seg button:active,
summary:active,
button.banner:active { opacity: .6; }
/* WARUM DIE ERSTE ZEILE `.channel-list .btn.ghost` HEISST UND NICHT `.btn.ghost`: gemessen sind
   die 32 Knoepfe der Kanalliste. Ein nacktes `.btn.ghost:active` traefe auch alle uebrigen
   Ghost-Knoepfe — und die haben ihre Antwort bereits ueber `background: var(--fl-tap)` (:1963),
   weil sie keinen Inline-Hintergrund tragen. Sie bekaemen dann zwei Antworten auf einen Druck.
   Die Zeile deckt also genau das, was die Messung gefunden hat, und keinen Fall mehr.
   DAS BLEIBT OFFEN und steht so im Bericht: das MUSTER „Inline-Stil schlaegt Aktivregel" ist
   damit an einer Stelle behoben, nicht ausgerottet. Wer kuenftig eine Flaeche inline setzt,
   loescht die Antwort unter dem Finger wieder mit — und keine Quelltextprobe sieht es.
   Die Gegenprobe ist die Messung oben, nicht das Blatt. */

/* [Schale 2026-08-22 abends] ZWEI NACHZUEGLER — DERSELBE GANG, NEU GEMESSEN.
   Die Messung oben stand am Vormittag auf 408/408. Am Abend desselben Tages zaehlte derselbe
   Gang (schnur-rig/nachbess/z_tipprueckmeldung.mjs, Marke schale22) 426 Ziele — der Baum ist
   zwischenzeitlich gewachsen — und fand SIEBEN ohne Antwort, alle aus der Praesenz-Anzeige:
       6x  button.presence-chip   (Appbar, „n online" — einmal je besuchtem Reiter gezaehlt)
       1x  button.presence-strip  (Praesenzleiste in der Ansicht)
   Beide tragen ihre Flaeche INLINE (app.js:renderPresenceChip: border, Hintergrund ueber
   .badge.ok; js/presence.js:presenceStrip: background und border im style-Attribut) — das ist
   exakt das oben festgehaltene Muster. `transform` ist an beiden NICHT inline belegt und
   deshalb hier der Weg (nicht `opacity`, denn die Flaechen dieser beiden sind sichtbar und
   duerfen wie ihre Nachbarn reagieren): der Chip drueckt wie die Appbar-Nachbarn
   (button.acct-badge:active, Zeile ~4123), die Leiste wie eine Zeile (.row:active, .99).
   Die Trefferflaechen-::after der Chips skalieren mit — 44 px bleiben 44 px mal 0,94. */
button.presence-chip, button.presence-strip { transition: transform var(--t-tap) var(--ease); }
button.presence-chip:active { transform: scale(.94); }
button.presence-strip:active { transform: scale(.99); }


@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    animation-delay: 0ms !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
    scroll-behavior: auto !important;
  }
  /* Die Regel oben lässt jede Animation genau einmal über 0,01 ms laufen — sie endet
     also auf ihrem LETZTEN Keyframe. Für alle Endlos-Animationen dieser Datei ist das
     bereits der Ruhezustand (100 % = scale(1) / opacity 1).
     [stil 2026-08-06] Hier standen dafuer zwei ausgeschriebene Zusagen — fuer den
     Ausloesering der 360-Kamera und fuer den Punkt der Zustands-Pille. BEIDE Pulse gibt
     es nicht mehr: sie waren Dauerbewegung auf einer Zustandsanzeige und sind an ihrer
     eigenen Stelle durch einen statischen Schein ersetzt (Kapitel 10 Auftrag 19, MUSS).
     Zwei Zeilen, die einen Ruhezustand fuer eine nicht mehr vorhandene Bewegung
     erklaeren, sind ein Hinweis ins Leere — sie sind mit entfallen.
     [2026-08-01 APP-P2] .connect-status ist mit dem Bridge-Abriss entfernt. */
  html { scroll-behavior: auto; }
}
