# Lessons

## Layout
- **Arbeitsbreite von App-Schalen nicht marketing-zentriert denken.** Zweimal
  kam die Rueckmeldung "zu schmal / wirkt wie 80% Zoom": Eine Desktop-App-Shell
  haelt die linke Kante fest und laeuft nach rechts weiter (grosszuegige
  Kappung nur gegen Ausfransen auf Ultrabreit) — nicht `margin: 0 auto` mit
  konservativem max-width wie eine Landingpage. Vor dem Abliefern bei 1920px
  UND 2560px pruefen, nicht nur bei 1440.
- **Kappungen von Kopfzeile und Inhalt muessen dasselbe Box-Modell rechnen:**
  max-width inkl. Polster (border-box) vs. Innen-Wrapper ohne Polster lief um
  exakt 2x Polster auseinander. Kanten immer messen (getBoundingClientRect),
  nie nur ablesen.

## CSS-Mechanik
- **Ein Flex-Kind mit `<input>` schrumpft ohne `min-width: 0` nicht.** Die
  Standardbreite eines Eingabefeldes (20 Zeichen) ist seine Min-Content-Breite
  und schob auf 400px die ganze Werkzeuggruppe aus dem Fenster — sichtbar
  erst im Ueberlauf-Sweep, nicht im Entwurf.
- **Custom Properties werden vererbt, nicht global aufgeloest.** Das
  Kontextmenue liegt bewusst als Geschwister NEBEN `.dsh` (damit `position:
  fixed` in keinem Rahmen klemmt) — und verlor damit `--dsh-pop`; die ganze
  `box-shadow`-Deklaration fiel still aus. Tokens auf jedem Wurzelelement
  definieren, das sie braucht, und per getComputedStyle gegenpruefen.
- **Eine flaechige "ganze Karte klicken"-Schaltflaeche muss UEBER dem Text
  liegen**, sonst faengt der Text den Klick (Playwright: "intercepts pointer
  events"). Inhalt `pointer-events: none`, echte Knoepfe darin wieder `auto`
  — verschachtelte `<button>` gibt es im HTML nicht.
- **`auto-fit`-Raster lassen Waisen stehen:** vier Karten in einem
  Dreierraster ergeben 3+1. Container-Queries entscheiden nach der
  tatsaechlichen Arbeitsflaeche (mit Detailspalte bleibt weniger) und
  schalten 4 oder 2 — nie 3.
- **`overscroll-behavior: contain` auf einem meist NICHT scrollbaren
  Scroll-Container schluckt das Mausrad** — die Seite dahinter scrollt nicht
  mehr. Fuer "Fuss soll bei niedrigen Fenstern erreichbar sein" reicht
  `overflow-y: auto` allein.
- **`opacity: 0` versteckt nur optisch:** Elemente bleiben fokussierbar und
  per Enter ausloesbar (pointer-events blockt keine Tastatur-Klicks). Menues
  beim Schliessen zusaetzlich `visibility: hidden` geben (mit verzoegertem
  Flip, damit die Ausblend-Transition sichtbar bleibt).
- **Zustand ueber Farbe statt Deckkraft anzeigen:** geteilte `opacity: 0.5`-
  Rezepte reissen Kontrastgrenzen (0.5 auf Text ~3:1).

## Prozess
- **CSS ohne Markup ist unsichtbar — ZWEIMAL passiert in einer Session:**
  Sort-Etikett (.dsort-lbl) und "22 h left" (.dhero-big .u/.l) bekamen neue
  Regeln, aber das Markup behielt die alte Struktur; beide Male bemaengelte
  die naechste Feedback-Runde denselben Punkt erneut, und erst der
  Screenshot-Abgleich der konkreten Stelle hat es gezeigt. Regel: Wer einen
  Selektor neu einfuehrt oder umbenennt, grept ihn sofort im erzeugten
  Markup — und prueft den Fix im Render (Close-up-Screenshot der Stelle),
  nie nur im Stylesheet.
- **Zwei getrennte Grids fluchten nur bei identischen Spurbreiten:** eine
  auto-Spalte bemisst sich je Grid an ihrem eigenen Inhalt ("Action" vs.
  "Copy link") und verschiebt alle Spalten davor. Kopfzeile und Zeilen brauchen
  feste gemeinsame Breiten — und eine Messung per getBoundingClientRect.
- **Suchkorpus = alles, was die Seite behauptet.** Ein Hero/Brennpunkt, der
  nicht in der Suchmenge liegt, laesst die Suche den prominentesten Inhalt
  verleugnen ("No drops match that" bei sichtbarer Datei).
- **Erfolgsmeldungen nur nach echtem Erfolg:** `writeText().then(done, done)`
  meldet "Copied" auch im Fehlerfall. Fallback (execCommand) + ehrlicher
  Fehlzustand.
- **Regex-Ersetzungen ueber HTML/CSS hinterlassen `$1`-Artefakte, die per
  CSS-Fehler-Recovery auch die FOLGENDE Regel verschlucken.** Guards muessen
  die ganze Datei scannen, nicht nur den eigenen Abschnitt.
- **Alte Generatoren stilllegen, sobald sie ueberholt sind** (SystemExit-
  Guard): ein lauffaehiges Skript, das aus eingefrorenen Baselines baut, ist
  ein stiller Regressionsvektor fuer jedes Redesign.
- **Seiten-CSS/-JS gehoeren in echte Dateien, nicht in Python-Strings.** In
  einem `"""..."""`-Literal frisst Python die Escapes: `join('\\n')` wurde
  zum Zeilenumbruch, `…` zum Zeichen. Nach dem Umzug in .css/.js gilt
  wieder JS-Semantik — beim Verschieben jede Backslash-Stelle pruefen.
- **Ein `#hash` allein beim Start gelesen ist kein Zustand.** Weder
  `page.goto(url + '#x')` noch der Zurueck-Knopf laden die Seite neu; ohne
  `hashchange`-Listener zeigt die Ansicht dann etwas, das die Adresse
  bestreitet.
- **`width: 0` reicht bei einem Flex-Kind nicht:** `flex: 1` (also
  `flex-basis: 0%; flex-grow: 1`) laesst es trotzdem auf die Restbreite
  wachsen. Die ausgeblendete Beschriftung der eingeklappten Leiste schob so
  jedes Symbol 19px aus der Mitte — beim Verstecken immer `flex: none`
  mitgeben und die Mitte messen, nicht ansehen.
- **`gap` zaehlt auch zu unsichtbaren Kindern.** Drei Flex-Kinder, zwei davon
  auf Breite 0, ergeben trotzdem 2x gap Vorschub: das Monogramm sass 10px
  links der Mitte.
- **Niemals `git add -A`, waehrend Agenten parallel an denselben Dateien
  schreiben.** Acht Commits tragen jetzt fremde Zwischenstaende unter falscher
  Botschaft — kein Datenverlust, aber die Historie luegt. Beim parallelen
  Arbeiten nur explizite Pfade committen, oder warten, bis der Durchgang fertig
  ist.
- **Ein Bild, das nur als Maske dient, ist kein Logo.** Der Icon-Bau nahm das
  groesste eingebettete PNG der Seite — das war die CTA-Maske: richtige Form,
  aber reines Schwarz, weil eine Maske ueber die Deckkraft wirkt und die Farbe
  aus dem CSS kommt. "Groesstes Vorkommen" ist keine Quellenwahl. Farbe und
  Form getrennt bestimmen und die Silhouetten gegeneinander pruefen.
- **Pillow skaliert beim `.ico`-Speichern selbst.** Uebergibt man ein grosses
  Bild plus `sizes=`, entstehen die kleinen Fassungen aus Pillows eigenem
  Resize — jede vorher angewandte Kantenkorrektur ist weg. Jede Groesse
  einzeln via `append_images` uebergeben, und aus der GROESSTEN speichern:
  Pillow verwirft jede angeforderte Groesse ueber der des Traegerbildes.
- **"Idempotent" muss man messen, nicht behaupten.** `build_footer.py` hing bei
  jedem Lauf zwei Leerzeilen an; aufgefallen erst, als 17 Dateien ohne Grund
  als geaendert dastanden. Bei Generatoren, die zwischen Markern ersetzen,
  gehoert das `rstrip` vor die Einfuegung — und ein zweiter Lauf in den Test.
- **Eine Wand vor ALLEM ist eine Wand auch vor `/.well-known/`.** Daran haengt
  die Zertifikatspruefung einer neuen Domain und security.txt. Der Fehler
  waere erst beim Domainwechsel sichtbar geworden und dort niemals vermutet.
  Bei jeder Sperre zuerst fragen, was ausserhalb liegen MUSS.
- **Eine Zusage auf der Seite ist Teil des Systems.** Die Zugangswand setzte
  ein Cookie, waehrend privacy.html "No cookies" versprach. Wer Verhalten
  aendert, muss die Seiten mitaendern, die darueber reden — sonst luegt das
  Produkt genau dort, wo es um Vertrauen wirbt.
- **Geloeschte Deployments sind der Preis fuer eine dichte Wand.** Jedes
  fruehere Pages-Deployment behaelt eine eigene, dauerhaft erreichbare Adresse
  und laeuft ohne die neuen Secrets weiter — gemessen: beide lieferten 200,
  waehrend die Hauptadresse 401 gab. Eine Wand ohne Aufraeumen ist keine.
- **Wrangler kann abstuerzen und trotzdem so aussehen, als haette es gewirkt.**
  `pages deployment delete` endete in einer libuv-Assertion; geloescht war
  nichts. Nach jedem Eingriff das Ergebnis messen, nicht die Ausgabe lesen.
- **`goto()` auf dieselbe Adresse ist kein Neuladen.** Ein Test legte einen
  abgelaufenen Drop an und rief danach `page.goto('…/index.html#app')` auf —
  dieselbe URL wie zuvor, also nur ein Fragmentwechsel. Das Startskript lief
  nie erneut, und der Aufraeumlauf sah eine leere Datenbank. Der Code war in
  Ordnung, der Test war es nicht. Fuer einen echten Neustart `page.reload()`.
- **Ein `catch (e) {}` verbirgt genau das, was man sucht.** Der Aufraeumlauf
  schluckt Fehler mit Absicht (er darf die App nie am Starten hindern) — beim
  Debuggen war damit aber nicht zu sehen, ob er lief, nichts fand oder
  scheiterte. Zwei Wegwerf-Marker (`window.__gestartet`, `window.__gesehen`)
  haben die Frage in einem Lauf beantwortet. Erst messen, wo der Ablauf
  abbiegt, dann Hypothesen bilden.
- **Ein Fehler, der nur auf einem Knopf steht, ist kein lauter Fehler.**
  "Tampering fails loudly" stand auf der Startseite, waehrend ein manipuliertes
  Chiffrat 1,8 Sekunden lang das Wort "Failed" zeigte und danach so tat, als
  waere nichts gewesen. Wer eine Zusage in die Oberflaeche schreibt, muss den
  Pfad nachlaufen, der sie einloest — nicht nur den Erfolgsfall.
- **Zahlen im Text altern schneller als der Code.** Nach dem Einbau der
  waehlbaren Frist behaupteten sechs Textstellen weiter "seven days", eine
  Tabelle sagte "Delete a drop early: yes" und die FAQ drei Zeilen weiter
  "not in this build". Wer ein Verhalten aendert, muss dieselbe Suche ueber
  alle Seiten fahren, die der Kundentest sonst fuer einen macht.
- **Aufraeumen gehoert an den Anfang, nicht ans Ende.** Der Schluessel wurde
  aus der Adresszeile genommen — aber erst NACH erfolgreichem Entschluesseln.
  Da ein weitergeleiteter Link beim Empfaenger immer auf einer Fehlerseite
  landet, blieb er ausgerechnet in dem Verlauf stehen, in den er am wenigsten
  gehoert. Der aufgeraeumte Fall war der einzige, der praktisch nie eintrat.
  Bei Massnahmen gegen Datenabfluss zuerst fragen, welcher Pfad der HAEUFIGE
  ist, nicht welcher der erfolgreiche.
- **`display:` in einer Klassenregel schlaegt das `hidden`-Attribut.** Die
  Speicherzeile hatte `display:flex` und im Markup `hidden` — sichtbar war sie
  trotzdem, weil die Klasse spezifischer ist. Jedes andere versteckbare
  Element im Projekt hatte dafuer eine `[hidden]`-Regel, diese eine nicht.
  Wer `display` auf etwas setzt, das versteckt werden soll, schreibt die
  `[hidden]`-Regel in derselben Minute dazu.
- **Zwei Zustandswechsel, zwei Auffrischungen.** Die Speicherzeile wurde beim
  Zurueckgehen aus der Uebersicht neu berechnet, aber nicht nach "Start a new
  drop" — also stand nach dem Anlegen eines Drops eine veraltete Zahl da. Wer
  eine abgeleitete Anzeige einfuehrt, muss ALLE Wege auflisten, die ihre
  Grundlage aendern, nicht nur den, an dem man sie gebaut hat.
- **Eine dokumentierte Entscheidung ist kein Argument.** HSTS stand auf 24
  Stunden, mit ausfuehrlicher Begruendung im Code. Sechs von zehn Testern
  haben dieselbe Zeile bemaengelt, und ihr Grund war besser als meiner. Eine
  eigene Begruendung im Kommentar macht eine Entscheidung nachvollziehbar,
  nicht richtig.
- **Eine neue Funktion aendert die alten Wege mit.** Das Passwort brachte ein
  drittes Feld ins Fragment. Das Einfuegefeld holte sich per Ausdruck aber nur
  `t` und `k` — passwortgeschuetzte Links liessen sich darueber also gar nicht
  mehr oeffnen, waehrend ueber die Adresszeile alles lief. Wer ein Datenformat
  erweitert, muss JEDE Stelle suchen, die es liest, nicht nur die, an der man
  gerade schreibt. `grep` nach dem alten Feldnamen kostet eine Minute.
- **WebCrypto ist grosszuegiger als die eigene Zusage.** `importKey` nimmt 16,
  24 und 32 Byte klaglos an. Ein vom Chatprogramm gekuerzter Schluessel war
  gueltiges base64, wurde als AES-128 angenommen und scheiterte erst beim
  Entschluesseln — ausgerechnet der Fall, den die Meldung "That is not a key"
  beschreibt, bekam die andere Meldung. Wenn eine Schnittstelle mehr erlaubt
  als man selbst vergibt, gehoert die Pruefung davor, nicht dahinter.
- **Meldungen an die Oberflaeche brauchen keinen Escaper, wenn man
  `textContent` nimmt.** Beim Hinweis auf einen fremden Host stand zuerst
  `innerHTML` mit einer Escape-Funktion, die es im Projekt gar nicht gab. Mit
  `textContent` gibt es keine Einschleusstelle, die man zunageln muesste — der
  sicherere Weg war auch der kuerzere.
- **`page.goto()` auf dasselbe Dokument mit anderem Fragment laedt nicht neu.**
  Zum zweiten Mal an einem Tag darauf hereingefallen: die App blieb im
  Empfangsmodus, die Panels waren versteckt, und der Test scheiterte an einer
  Stelle, die mit der Sache nichts zu tun hatte. Wer im Test einen echten
  Neustart braucht, schreibt `reload()` dahinter.
- **Die eigene Pruefung zuerst gegen sich selbst richten.** Die erste Fassung
  von `build_public.py` schlug bei `build_dist.py` an, weil dort der
  Platzhalter "not filled in" im Zusicherungstext steht — also genau in der
  Zeile, die das Versehen verhindert. Und die Zugangsdaten wurden ueber ihren
  Inhalt gesucht statt ueber ihren Dateinamen. Eine Regel, die bei ihrer
  eigenen Gegenmassnahme anschlaegt, misst das Falsche.
- **Beim Aufraeumen einer Zusage nachsehen, was die Oberflaeche verraet.** Die
  Drop-Uebersicht nannte Anzahl und Groesse ungefragt auf dem ersten Schirm.
  Die Betriebsraetin hat es auf den Punkt gebracht: ihre erste Frage war, wer
  sieht, DASS sie etwas verschickt hat — und die Antwort stand auf ihrem
  eigenen Bildschirm. Verschluesselte Inhalte schuetzen nicht davor, dass die
  Anzeige daneben alles erzaehlt.
- **Eine Lizenz schreibt man nicht aus dem Gedaechtnis.** Der AGPL-Text wurde
  von gnu.org geholt und gegen Laenge, Ueberschrift und Abschnitt 13 geprueft.
  Eine verstuemmelte Lizenz ist schlimmer als gar keine, weil sie rechtlich
  wirkungslos aussieht und trotzdem Vertrauen einwirbt.
- **Die Reihenfolge des Schreibens entscheidet, was ein Absturz hinterlaesst.**
  Der Datensatz wurde zuletzt geschrieben, die Bruchstuecke zuerst — wer
  mittendrin neu lud, hinterliess Chiffrat, das ueber keinen Leseweg mehr
  auftauchte. Zehn von zehn Testern haben es gefunden. Wer Daten in zwei
  Schritten ablegt, schreibt zuerst das, was das Aufraeumen spaeter FINDET,
  und markiert es als unfertig. Sonst ist der halbe Zustand ein Zustand, den
  die eigene Software nicht kennt.
- **Eine Programmierschnittstelle, die nichts wirft, meldet keinen Fehler.**
  `a.click()` fuer vierzig Downloads sieht aus wie vierzig Downloads. Chromium
  deckelt bei zehn, und weil nichts wirft, hat die App nie erfahren, dass
  dreissig Dateien fehlten. Wo eine Schnittstelle keinen Rueckgabewert und
  keine Ausnahme hat, ist "es hat geklappt" eine Annahme, keine Messung.
- **Ein selbstgeschriebenes Dateiformat braucht einen fremden Leser als
  Pruefer.** Der ZIP-Schreiber wird nicht von der eigenen Oberflaeche
  bestaetigt, sondern davon, dass Pythons zipfile ihn oeffnet, die Pruefsummen
  bestaetigt und 14 Dateien Byte fuer Byte zurueckgibt. Alles andere waere die
  Behauptung, dass der eigene Code richtig ist, geprueft mit demselben Code.
- **Ein Versprechen ohne Frist ist von einem langsamen nicht zu unterscheiden.**
  Bei erschoepftem Speicher nahm die IndexedDB-Transaktion gar keinen Zustand
  mehr an — vier Tester haben zwischen 104 und 240 Sekunden gewartet, und der
  laengst geschriebene Fehlerzweig wurde nie erreicht. Wo eine Zusage von aussen
  kommt, gehoert eine Frist darum.
- **Derselbe Fehler sitzt fast nie nur an einer Stelle.** Das fehlende Salz war
  im Einfuegefeld behoben und im Rettungslink nicht — fuenf Tester haben die
  zweite Stelle gefunden, einen Tag spaeter. Nach einer Formataenderung nicht
  die gefundene Stelle reparieren, sondern nach dem alten Muster suchen und
  ALLE Treffer durchgehen.
- **Fremde Grafik im Baum ist vor einer Veroeffentlichung ein eigenes
  Problem.** Acht App-Icons lagen eingebettet im Kanal-Band, obendrein als PNG
  deklariert und tatsaechlich JPEG. Solange nichts veroeffentlicht wird, faellt
  das niemandem auf; unter der AGPL waere es in jeden Fork gewandert. Namen
  darf man nennen, Logos nicht benutzen — und der Punkt lag ohnehin bei den
  Namen.
- **Zahlen im Fliesstext altern mit jeder Aenderung.** Das Glossar sagte
  "Sixteen terms", nachdem zwei dazukamen. In drei Runden ist dreimal genau
  diese Sorte Zahl aufgefallen. Wer eine Liste ergaenzt, sucht im selben
  Arbeitsgang nach der Zahl, die sie zaehlt — und schreibt am besten eine
  Zusicherung daneben, so wie build_dist.py es fuer die Seitenzahl tut.
- **Eine Fehlerfunktion gehoert zu einer Seite, nicht zu einer App.**
  `showError()` war fuer den Empfaenger gebaut und rief `setMode('receive')`.
  Vier Aufrufstellen auf der Senderseite haben damit jeden Tippfehler in eine
  fremde Ansicht geworfen und die Dateiauswahl geloescht. Sechs von zehn
  Testern. Wer eine Meldefunktion wiederverwendet, muss pruefen, was sie neben
  dem Melden noch tut.
- **Eine Schaetzung darf keinen Weg versperren, den die Messung freigeben
  wuerde.** Die Platzvorpruefung rechnete mit `estimate().usage` und brach ab.
  Dass diese Zahl nach dem Loeschen nicht faellt, stand im Bericht der
  VORIGEN Runde. Damit war der funktionierende Quota-Zweig unerreichbar, und
  aus einem kosmetischen Aergernis wurde eine Sackgasse. Vor jeder neuen
  Pruefung nachsehen, ob die verwendete Zahl schon einmal als unzuverlaessig
  aufgefallen ist.
- **Zwei Wuensche koennen einander widersprechen — dann ist Zuschneiden die
  falsche Antwort.** Ein umgebrochener Link braucht das Entfernen von
  Whitespace, ein Link am Satzende das Abschneiden ab dem Leerzeichen. Beides
  am Rand zu tun loest immer nur eines; die erste Fassung klebte "viele
  Gruesse" ans Salz. Die Loesung war, nicht zu schneiden, sondern die Felder
  zu LESEN — base64url endet an jedem Satzzeichen von selbst.
- **Barrierefreiheit faellt nur auf, wenn jemand danach sucht.** Vier Runden
  lang hat niemand mit einer Vorlesefunktion getestet, und die App hatte null
  Live-Bereiche, waehrend das Kontaktformular und die Glossarsuche korrekte
  haben. Der Tester hat den Nagel getroffen: "Das ist keine Unfaehigkeit, das
  ist eine Prioritaetenentscheidung." Ein Testfeld ohne Screenreader misst
  nicht die Zahl der Betroffenen, sondern die eigene Zusammensetzung.
- **Ein `<button>` zentriert seinen Inhalt, ein `<span>` nicht.** Die
  44-px-Regel fuer Tippziele aus Runde 3 hat die doppelt gezeichnete
  Beschriftung der Umschaltpille um exakt 3,5 px auseinandergezogen: 13,5 px
  Zentrierung gegen 10 px Innenabstand. Wer zwei Ebenen deckungsgleich haelt,
  muss beiden dasselbe Layoutmodell geben, nicht dieselbe Groesse.
- **Ein gescheiterter Bauschritt darf keinen Deploy durchlassen.**
  `build_dist.py` scheiterte am Loeschen von `dist/` unter Windows, nachdem
  der Inhalt bereits weg war — und der Deploy im selben Aufruf lud
  stillschweigend den alten Stand hoch. Zwei Befehle mit `&&` verkettet
  haetten es verhindert; robuster ist, den Ordner zu leeren statt ihn zu
  entfernen. Nach jedem Deploy nachmessen, ob wirklich das Neue live steht.
- **Der Test, den man beim Aendern rot macht, hat seine Arbeit getan.** Die
  Pruefung auf ein kurzes Passwort erwartete die Fehleransicht — genau das
  Verhalten, das als Fund A abgeschafft wurde. Rot heisst hier nicht kaputt,
  sondern: die Zusage hat sich geaendert, schreib sie neu auf.

