# Entscheidungen (ADR-Kurzform) Festgehaltene Design-Entscheidungen samt Begründung. Bei Änderungen: Eintrag ergänzen, nicht löschen. ## D1 — `|` für Oder-Zerlegung, `-` für Und-Zerlegung `|` bedeutet in Regex und BNF-Grammatiken bereits „oder“ und ist damit selbsterklärend. `-` ist das gewohnte Markdown-Listenzeichen für „normale“ Teilpakete. Das Gate steckt im Aufzählungszeichen der Kinder, nicht in einer Annotation am Parent — lokal lesbar, trivial parsebar. ## D2 — Status als erweiterte Markdown-Task-Checkbox GitHub-Markdown kennt `[ ]`/`[x]`; die Erweiterung auf `[?] [~] [/] [^] [-]` wirkt sofort vertraut. Mnemonik: `?` vage, leeres Kästchen = todo, `~` gängige In-progress-Konvention, `/` = halbes `x`, `x` = erledigt, `^` = „nach oben deployed“, `-` = durchgestrichen. ## D3 — Begriff „Durchstich“ für den Zwischenstatus Zustand zwischen „in Arbeit“ und „fertig“: durchgängig funktionsfähig und vorführbar, Feinarbeiten offen. „Durchstich“ ist als Fachbegriff (Tunnelbau, End-to-End-Slice in der Softwarearchitektur) etabliert. Verworfene Alternativen: „Feinschliff“, „Richtfest“, „vorführbereit“, „Beta“. ## D4 — Begriff „verworfen“ statt „gestrichen“ Beschreibt die bewusste Entscheidung gegen eine Option — besonders passend für Any-of-Alternativen. Default ausgeblendet, Toggle „verworfene einblenden“. ## D5 — Farb-Logik folgt dem Risiko, nicht dem Fortschritt Rosé = Kosten investiert, Risiko hoch (in Arbeit). Pastellgelb = läuft, Restrisiko klein (Durchstich). Flieder = geplant (Absicht, nichts investiert, zwischen Grau der Idee und Rosé der Arbeit). Ergebnis-Skala: grau → flieder → rosé → gelb → grün → blau. ## D6 — Größe in Klammern, URL nackt, Personen mit `@` `(M)` liest sich wie ein Kleidungsetikett. URLs werden ohne Link-Syntax erkannt (einfach einfügbar); die URL wird **vor** den `@`-Tags extrahiert, damit `https://user@host/…` nicht kollidiert. `@` ist die universelle Mention-Konvention (GitHub, Slack, Jira). ## D7 — `%%` als Kommentarzeichen Mermaid-Konvention; hält den Weg zum Mermaid-Plugin frei. `//` scheidet wegen URLs aus, `#` bleibt für Referenzen/Tags reserviert, `` ist zu sperrig. ## D8 — Untergliederungspflicht ab M sichtbar machen Fehlende Zerlegung wird nicht nur gemeldet, sondern **gezeigt**: Geister-Knoten „Untergliederung fehlt“ hängt genau dort, wo der Ast weitergehen müsste. Verworfene Elemente sind ausgenommen. Werden alle Kinder eines M+-Elements verworfen und ausgeblendet, erscheint der Platzhalter wieder (es braucht eine neue Zerlegung) — gewollt. ## D9 — Transponierte Darstellung mit unterschiedlichen Austrittsseiten Im vertikalen Modus tritt all of **rechts** aus dem Parent aus (LR-Baum), any of **unten links**. Austrittsseite + Linienstil (durchgezogen/gestrichelt) codieren das Gate doppelt; Beschriftungen werden nie rotiert. ## D10 — Abzweige zielen auf Knotenmitte, nicht Teilbaummitte Feste Knoten-Zeilenhöhe macht die Anschlusshöhe deterministisch (23 px). Behebt „ins Leere laufende“ Linien bei eingerückten Unterbäumen. ## D11 — Technologie: Vanilla HTML/CSS/JS, eine Datei als Prototyp Keine Frameworks, kein Build-Zwang. Parser ~30 Zeilen, Renderer erzeugt verschachtelte `ul.and`/`ul.or`; Linien via CSS-Pseudo-Elemente. Modularisierung (Parser/Renderer/UI getrennt + Tests) ist der geplante nächste Schritt. ## D12 — Name „Werkbaum" und Marke aus der Notation Name: „Werk" + „Baum" = wörtlich der WBS-Baum; funktioniert als npm-Paket, CLI-Befehl und Mermaid-Schlüsselwort (`werkbaum`, kleingeschrieben in Code-Kontexten). Verworfene Kandidaten: Astrein, undoder, Aufriss, Gabelung. Marke: Miniatur des Diagramms (K2) in zwei Orientierungen analog zu den Darstellungsmodi — Hochformat als Primärzeichen, transponiert als Sekundärzeichen. Nur Tinte + Petrol; durchgezogen = und, gestrichelt = oder; Pastelltöne bleiben Statusfarben. Details: brand/BRAND.md. ## D13 — Backend in Kotlin/Spring Boot, Monorepo-Struktur Backend als Kotlin/Spring-Boot-Anwendung (Gradle Kotlin DSL, JDK 21), entwickelt in IntelliJ IDEA Ultimate. Monorepo mit `frontend/` und `backend/`; CLAUDE.md dreistufig: Wurzel für Querschnitt, je eine pro Teilprojekt (wird von Claude Code pfadbezogen geladen). Kein Node- oder Python-Backend. ## D14 — Parser-Hoheit liegt beim Frontend, SPEC ist normativ Der Notationstext ist das führende Datenformat. Das Backend parst die Notation nicht; es speichert den Text als Ganzes und bedient Integrationen über explizite Metadaten. Wird Backend-Parsen später doch erforderlich, gilt: docs/SPEC.md ist normativ, beide Parser testen gegen dieselben Fixtures (SPEC §10) — es darf keine zweite, abweichende Grammatik entstehen. ## D15 — Kompakter Modus + graue Any-of-Linien in allen Modi Der vertikale Modus (D9) lässt „all of“ rechts austreten — formal sauber, aber breit. Der zusätzliche Modus **kompakt** führt beide Gates nach unten (minimale Breite) und unterscheidet sie nur noch über den Linienstil: durchgezogen = und, gestrichelt = oder. Auswahl dreistufig „horizontal/vertikal/kompakt“ statt Toggle, da es nun drei sich ausschließende Darstellungen sind. Zugleich wird das **Petrol im Diagramm ganz aufgegeben**: die any-of-Linien (Sammelleiste *und* Abzweige) und auch der **Rahmen der Alternative-Knoten** sind in **allen** Modi **gestrichelt bzw. gerahmt in Grau** (`#6B7A8C`). Die frühere durchgezogene Petrol-Sammelleiste und der petrolfarbene Knotenrahmen entfallen. Begründung: Das Gate ist bereits durch den Linienstil (gestrichelt) und die Anordnung codiert; die zusätzliche Signalfarbe wirkte laut und ließ die durchgezogene Hauptlinie optisch mit „all of“ konkurrieren. Grau tritt zurück, die gestrichelte Führung bleibt eindeutig. D12/BRAND — „durchgezogen = und, gestrichelt = oder“ — gilt für das **Markenzeichen** unverändert (Logo behält Petrol); im **Diagramm** trägt allein der Linienstil die Gate-Codierung. ## D16 — Deployment über GitHub Pages via Actions-Workflow Der Editor ist eine einzelne statische Datei (D11) — GitHub Pages genügt, kein eigener Server nötig. Veröffentlicht wird über den offiziellen Actions-Weg (`actions/upload-pages-artifact` + `actions/deploy-pages`, `permissions: pages/​id-token`, `concurrency: pages`) statt über den `gh-pages`-Branch: kein Zusatz­branch, OIDC statt Deploy-Key, Trigger bei Push auf `main` und manuell. Der Test-Step (Vitest, Phase 1) ist als Platzhalter auskommentiert vorbereitet. **Pfad-Entscheidung:** `frontend/index.html` referenziert Favicon und MIT-Lizenz relativ mit `../` (`../docs/brand/favicon.svg`, `../LICENSE`) — von der Wurzel-URL aus zeigten diese über die Site hinaus. Statt die Quelldatei zu ändern (D14/CLAUDE: „Editor nicht refaktorieren“) zieht der Workflow die `../`-Pfade **nur auf der Site-Kopie** gerade (`sed`) und legt die referenzierten Dateien passend ab: `index.html` an die Wurzel, `docs/brand/` und `LICENSE` daneben. So bleibt die Quelle unverändert (lokal weiter per `file://` und Dev-Server nutzbar), und veröffentlicht wird nur das Nötige — `backend/` und die übrigen `docs/` bleiben außen vor. **Versionsnummer:** `Major.Minor` steht in der Datei `VERSION` und wird per bewusstem **Bump-Commit** gepflegt; die **Micro-Stelle** leitet der Build aus der Anzahl der Commits seit dem letzten VERSION-Bump ab (`git rev-list --count ..HEAD`) und ersetzt sie per `sed` nur auf der Site-Kopie im Footer. Bewusst aus der Commit-Historie statt aus der Run-Nummer (Vorentscheidung, verworfen): vollständig aus dem Repo reproduzierbar, monoton, ohne zurückgeschriebenen Commit/Tag und ohne selbstauslösenden Deploy-Zyklus. Die Micro-Stelle beginnt nach jedem Bump wieder bei 0 (SemVer-artig). Nötig ist ein vollständiger Klon (`fetch-depth: 0`), sonst zählt der flache CI-Klon nur einen Commit. `1.0` bleibt die Version beim lokalen Öffnen (Platzhalter im Quelltext). Im Footer sind Name und Nummer **getrennte Links**: „Werkbaum" → Repo-Startseite, die Versionsnummer (``) → exakt der deployte Commit (`…/commit/`, im Build via `git rev-parse HEAD`). **Build-Hinweis (Vorschau / „latest build"):** Nicht-produktive Builds tragen hinter dem Titel ein kleines Symbol samt Tooltip, damit klar ist, dass es nicht die *eigentliche* (stabile) Instanz ist. Drei Zustände, gesteuert per Vite-Env `VITE_BUILD_BADGE` (Auswertung in `app.js`, `mountBuildBadge`): - **Dev-Server** (`import.meta.env.DEV`) → 🔧 „Vorschau – lokaler Entwicklungsstand". - **Default-Build** `npm run build` (Env ungesetzt) → 🚧 „latest build …". Der GitHub-Pages-Deploy nutzt genau diesen Default und trägt den Hinweis dadurch automatisch — **keine** `sed`-Injektion mehr nötig. - **Produktions-Build** `npm run build:prod` (Vite-Modus `prod`, `frontend/.env.prod` setzt `VITE_BUILD_BADGE=none`) → **kein** Badge; esbuild eliminiert den Zweig als toten Code, das Symbol steht dann nicht einmal mehr im Ausgabe-Quelltext. Damit trägt einzig die echte produktive Installation keinen Hinweis. **Warum in die App-Quelle statt per Workflow-`sed` (frühere Lösung):** Nur so sieht der Dev-Server den Hinweis ebenfalls — ein Post-Build-`sed` erreicht den Dev-Server nicht, der die Quelle direkt ausliefert. Die Umkehrung „Hinweis ist der Normalfall, Prod schaltet ab" passt zudem zur Anforderung (nur Prod bleibt sauber) und macht den Default sicher: wer den Prod-Schritt vergisst, veröffentlicht einen sichtbar als Entwicklungsstand markierten Build, nicht versehentlich einen als stabil wirkenden. Bewusst **kein** i18n-UI-Text im `I18N`-Objekt (kein Produkt-Feature, sondern Build-Metainformation; D14) — der `title` ist knapp zweisprachig (DE · EN). **Self-hosted Deploy (`scripts/deploy-prod.sh`):** Für eine eigene produktive Installation (nicht GitHub Pages) baut das Skript per `npm run build:prod` (badge-frei), stellt lokal dasselbe zusammen wie der Pages-Workflow (LICENSE danebenlegen + `../LICENSE`-Link geradeziehen, Footer-Version + Commit-Link) und spiegelt es per `rsync --delete` über SSH ins Zielverzeichnis (nichts Altes bleibt stehen). Die Zusammenstell-Schritte (dieselben drei `sed`-Regeln) liegen bewusst in **beiden** — Workflow und Skript —, weil GitHub Pages die `dist/` selbst baut und der SSH-Weg keinen Pages-Runner hat; bei Änderungen an den `sed`-Regeln beide Stellen nachziehen (Anker `` / `../LICENSE`). (Nummerierung: D15 war bereits für den kompakten Modus vergeben, daher D16.) ## D17 — Kleiner Bildschirm: ein Bereich, kompakte Legende & Sprachwahl Auf schmalen Viewports (≤ 640 px) ist für beide Bereiche kein Platz. Diagramm und Editor werden gestapelt und über den **Splitter stufenlos** geteilt (frei ziehbar, jederzeit erneut verstellbar); die beiden **Titelzeilen bleiben immer stehen** (Grid-Zeilen-Minima = gemessene Kopfhöhen), ein **Tipp auf eine Titelzeile klappt dieses Panel ganz aus**. Das frühere diskrete Modell (Snap in Minimier-Zustände 'a'/'b') führte auf dem Smartphone zu zwei Fehlern: nach dem ersten Ziehen rastete der Splitter in eine feste Aufteilung und war nicht mehr zu bewegen (die Titelzeile im Snap-Rand fing jeden weiteren Zug ab), und der Titelzeilen-Tap funktionierte nur im `collapsed`-Zustand — nach einem freien Zug also gar nicht. Deshalb auf Mobil ein **kontinuierliches** Modell ohne Snap/ Collapse (`--drow` frei geklemmt, Tap setzt sie aufs Extrem); Desktop behält das diskrete Modell samt Fenster-Buttons. Die Legende wird von der Bereichs- Umschaltung **entkoppelt** und über einen eigenen Umschalter im Editor-Kopf gesteuert — das dauerhafte „AGENDA“-Band kostet sonst zu viel Höhe. Die Sprachleiste zeigt eingeklappt nur die **aktive** Sprache; ein Tipp klappt die volle Leiste als **Overlay über die Kopfzeile** auf (rechtsbündig, `z-index`, verdeckt die übrigen Elemente statt die Zeile umzubrechen), nach der Auswahl — oder einem Tipp daneben — klappt sie wieder ein. So bleibt die Kopfzeile schmal und die vollständige Sprachliste dennoch erreichbar (frühere Lösung „EN + aktive + …“ ersetzt). Voreinstellung ist **Vollbild** (maximale Fläche). Umgesetzt über die Klasse `body.mobile` (per `matchMedia`), damit CSS und JS denselben Schwellwert teilen; die Vollbild- Voreinstellung greift nur, wenn noch **keine** gespeicherte Nutzerwahl vorliegt (eine bewusste Abschaltung bleibt so erhalten, siehe localStorage-Persistenz). **Nachtrag — auf Mobil ist jetzt immer genau EIN Bereich zu sehen; der Splitter entfällt dort ganz.** Die oben beschriebene stufenlose Aufteilung hat das eigentliche Problem nur verwaltet, statt es zu lösen: Auf 375 px ist für zwei Bereiche kein Platz. Jede Aufteilung war ein Kompromiss, in dem **beide** Bereiche zu klein waren, und die praktisch einzigen sinnvollen Stellungen waren ohnehin die Extreme — also genau das, was ein Umschalter direkt anbietet. Der Splitter kostete außerdem dauerhaft eine zweite Titelzeile plus 14 px Griff für etwas, das man auf dem Telefon nicht dosiert, sondern wechselt. **Der Umschalter zeigt das Ziel, nicht den Zustand.** Damit weicht er bewusst vom Modus-Wähler daneben ab, der das aktive Icon zeigt und reihum schaltet: Der hat drei Zustände, die man ohne Anzeige nicht auseinanderhält; hier gibt es zwei, und welcher gerade gilt, sieht man am ganzen Bildschirm. Ein Knopf, der den Zustand anzeigt, den man ohnehin vor sich hat, sagt nichts — einer, der das Ziel zeigt, sagt, was passiert. Umgesetzt als **zwei feste Knöpfe**, je einer pro Titelzeile: Weil immer nur eine Zeile sichtbar ist, braucht keiner sein Icon zu wechseln. **Der Dokumenten-Wähler steht neben dem Umschalter** und damit nur im Textbereich. Das ist keine Platzentscheidung, sondern die richtige Zuordnung: Er bestimmt, *welchen Text* man bearbeitet (D22) — im Diagrammbereich wäre er ein Fremdkörper. Die übrigen Aktions-Knöpfe bleiben, wo sie sind, und werden dadurch von selbst mitgeschaltet; ihre komprimierte Mobil-Form (Modus-Wähler als Reihum-Icon, Download als Overlay) bleibt unverändert. **Die Falle beim Bauen: ein `display:none`-Panel misst sich zu null.** Der verborgene Bereich wird wirklich ausgeblendet — nur so bekommt der sichtbare die ganze Höhe. Alles, was aus der Live-Geometrie zeichnet, liefert dann aber Unsinn, und `render()` läuft bei **jedem Tastendruck** im Textbereich. Nachgemessen nach einer Eingabe bei verborgenem Diagramm: Pfad-Linie **weg**, null Stationspunkte, kein `--stem-x`. Das Umschalten zeichnet deshalb neu — zum Diagramm hin dieselben vier Schritte wie ein Moduswechsel (`applyOptStairs`, `alignStems`, `drawCheapPath`, `drawDepLinks`), zum Text hin der Zeilennummern-Streifen, der ebenso am Spiegel misst (D33). Danach wieder 48 px Pfadlänge, 5 Punkte, `--stem-x: 67,4px`. Der sichtbare Bereich wird in `werkbaum-ui` gemerkt — global über alle Dokumente wie der übrige Ansichts-Zustand (D22). Die Sprünge zwischen Diagramm und Text (D25) holen den nötigen Bereich selbst nach vorn: `revealEditor()` schaltet auf Text (wie es auf dem Desktop ein zugeklapptes Panel aufklappt), `focusNodeOfCaret()` auf das Diagramm — und zwar **vor** dem Zentrieren, sonst misst sich der Zielknoten noch zu null. Entfallen sind damit `--drow` auf Mobil, die Grid-Minima `--pmin-d`/`--pmin-e`, `syncPanelMins()`, `setMobileDrow()` und das Titelzeilen-Tippen; Desktop behält Splitter, Presets und Fenster-Buttons unverändert. **Nachtrag 2 — auf Mobil sind die Inhalte grundsätzlich ~25 % kleiner, und der Debug-Kasten minimiert sich statt zu verschwinden.** **Verkleinerung als Faktor, nicht als neuer Anfangswert.** `MOBILE_ZOOM = 0.75` multipliziert den Nutzer-Zoom (`effZoom()`), statt ihn beim ersten Start auf 0,75 zu setzen. Der Unterschied zählt: Ein Anfangswert wäre nach dem ersten Zoomen weg, und „Zurücksetzen" führte zurück auf eine Größe, die auf dem Telefon zu groß ist. Als Faktor bleibt die Verkleinerung eine Eigenschaft des Geräts, und der Regler arbeitet relativ dazu weiter. Die Anzeige nennt den **effektiven** Wert (75 % statt 100 %) — sie soll beschreiben, was man sieht, nicht was man eingestellt hat. **Die drei Messstellen mussten mit.** `alignStems()`, `drawCheapPath()` und `drawDepLinks()` rechnen gemessene Pixel durch den CSS-`zoom` zurück; sie lesen jetzt `effZoom()` statt `zoom`. Nachgemessen bei 0,75: alle fünf Stationspunkte liegen exakt (0 px Abweichung) auf ihren Blattknoten. **Der Text kann kein `zoom` bekommen.** Zeilennummern-Streifen und Spiegel messen am Textfeld (D33), und der Streifen rechnet seine Breite in `ch` — beide folgen der **Schriftgröße**, nicht einem Zoom auf einem Vorfahren. Also `font-size: .64rem` (= 0,85 × 0,75) an Textfeld **und** Streifen gemeinsam; `line-height` ist einheitenlos und skaliert mit. Der Innenabstand des Textfelds schrumpft im selben Verhältnis mit (14/16 → 10/12 px), sonst wäre der Rand auf 375 px unverhältnismäßig breit. Ergebnis: 27 statt 19 Zeilen im Bild. **Dabei aufgefallen: der Grafikexport war schon immer zoom-abhängig falsch.** Die Schriftgrößen im Ausgabe-SVG sind feste Zahlen (14 für Labels, 9–11 für Badges), die Kästen kommen aus der Live-Messung — bei jedem Zoom ≠ 1 passten Text und Kasten nicht zueinander. Das fiel nie auf, weil 100 % der Normalfall war; mit der Mobil-Verkleinerung wäre es der Regelfall geworden. `diagramToSvg()` stellt den Zoom für die Messung deshalb kurz auf 1 und danach zurück — derselbe Griff wie bei der `exporting`-Klasse (D25-Nachtrag), und die Funktion läuft synchron, es wird nichts davon gezeichnet. Nachgemessen: Knoten auf dem Schirm 143 px, im SVG 189,7 px — also die unskalierte Größe, passend zur festen Schrift. **Nachtrag 3 — der Umschalter trägt die Navigation zum Knoten; ein langer Druck im Textfeld verbietet sich.** Gemeldet als Fehler: „Wenn man in der Smartphone-Ansicht den Cursor in den Text setzt, klappt das Text-Edit-Fenster zusammen." Nachgestellt auf der **deployten** Instanz (1.1.39, noch das alte Splitter-Modell von D17): Ein Tipp auf die **Titelzeile des Diagramms** — ein 49 px hoher Streifen unmittelbar über dem Text — schrumpfte den Editor von 594 px auf 44 px. Beim Zielen auf die oberen Textzeilen ist der leicht zu treffen. Das Ein-Bereich-Modell aus Nachtrag 1 nimmt dem Fehler die Grundlage: Es gibt keinen Splitter und kein Titelzeilen-Tippen mehr, den Bereich wechselt **allein** der Umschalter. Im aktuellen Stand ließ sich der Fehler nicht mehr auslösen (Maus-Klick, emulierter Touch und synthetische Touch-Folge, jeweils ohne Bereichswechsel). **Das war aber nur die halbe Wahrheit — siehe Nachtrag 4.** Der Nutzer meldete denselben Fehler auf dem Pages-Build, der das Ein-Bereich-Modell bereits enthielt. Die obige Diagnose war für die alte Fassung richtig und trotzdem nicht die Ursache seiner Beobachtung; die eigentliche stand woanders und war mit Emulation grundsätzlich nicht zu finden. Der zweite Teil der Meldung deckte aber eine echte Lücke auf: Für die Richtung **Text → Diagramm** gibt es nur Alt+Klick bzw. Alt+Enter (D25) — und Alt gibt es auf dem Telefon nicht. Die Gegenrichtung hat dort ihren langen Druck, diese hatte nichts. **Vorgeschlagen war ein langer Druck im Textfeld — verworfen.** Dort gehört er dem Betriebssystem: Wort markieren, Auswahlgriffe, Einfügen-Leiste. D25 konnte sich diese Geste im Diagramm nehmen (`user-select:none`), weil es da nichts zu markieren gibt; in einem **editierbaren** Feld ist sie die Bedien-Grundlage zum Bearbeiten. Sie zu überschreiben löste ein Navigationsproblem auf Kosten des Bearbeitens — in einer Meldung, deren Kern gerade lautet, dass Bearbeiten nicht bestraft werden darf. Dazu kommt, dass sich das Zusammenspiel mit der nativen Auswahl nach der D25-Lehre nur auf echter Hardware beurteilen ließe. **Gewählt: der Umschalter tut es nebenbei.** Wer aus dem Text ins Diagramm wechselt, will nachsehen — und zwar bei dem, woran er gerade geschrieben hat. Der Knopf wird in genau diesem Moment ohnehin gedrückt; er zentriert deshalb den Knoten der Cursor-Zeile und hebt ihn hervor (`focusNodeOfCaret()`, also identisch zum Alt+Klick am Schreibtisch). Kostet kein Bedienelement, keine Geste und kann nicht versehentlich auslösen. Steht der Cursor auf einer Zeile ohne Knoten (Kommentar, Leerzeile), wird nur umgeschaltet — dieselbe stille Regel wie überall sonst bei dieser Geste. **Nachtrag 4 — die eigentliche Ursache: `--app-height` folgte der Bildschirmtastatur.** Nach Nachtrag 3 blieb der Fehler auf dem Pages-Build bestehen („dort klappt der Text komplett zusammen, sobald ich irgendwo den Cursor reinsetze"). Gefunden über die einzige Frage, die nach zwei fehlgeschlagenen Emulations-Versuchen noch trägt: **Was ist auf einem echten Telefon anders?** Antwort: die Tastatur. `setAppHeight()` schreibt `window.visualViewport.height` nach `--app-height`, und `body{height:var(--app-height)}` macht daraus die Höhe der ganzen Seite. Genau diesen Wert verkleinert die Bildschirmtastatur — dafür ist `visualViewport` gemacht. Nachgemessen ist der Zusammenhang linear: Textfeldhöhe = `--app-height` − rund 206 px feste Aufbauten (Kopfzeile 57, Titelzeile 44, Fußzeile 36, Innenabstände). Bei 812 px bleiben 606 px Text, bei 440 px noch 234, bei 260 px — realistisch für ein kleines Gerät mit Tastatur und Browserleiste — nur **54 px**, also drei Zeilen. Das ist das gemeldete „komplett zusammengeklappt", und es tritt **ausschließlich auf echten Geräten** auf: In der Emulation gibt es keine Tastatur, deshalb liefen alle drei Reproduktionsversuche aus Nachtrag 3 ins Leere. **Unterschieden wird am Fokus, nicht an der Größe.** Die Tastatur und eine überlagernde Browserleiste (Brave — der ursprüngliche Anlass des Mechanismus) erzeugen dieselbe Signatur: `visualViewport.height` fällt, `innerHeight` bleibt. An den Zahlen sind sie nicht zu trennen. Am Zustand schon: Die Tastatur steht nur, wenn ein **editierbares** Feld den Fokus hat. Solange das so ist, bleibt die zuletzt tastaturfreie Höhe stehen. Die Seite behält damit ihre Größe, und der Browser schiebt den sichtbaren Ausschnitt zur Schreibmarke — das Verhalten jeder anderen App. Nachgemessen mit nachgebildetem `visualViewport`: mit Fokus im Textfeld 812/606 px (unverändert), ohne Fokus 440/234 px (die Brave-Leiste wirkt also weiter). **Drehen muss die Sperre durchbrechen** (`setAppHeight(true)` bei `orientationchange`) — sonst behielte die Seite beim Drehen während des Tippens die Höhe des alten Hochformats. Dazu ein `focusout`-Nachzug für Browser, die das Schließen der Tastatur nicht als `resize` melden. Und der Timer dort ruft `() => setAppHeight()`, nicht `setAppHeight` direkt: Ein durchgereichtes Argument wäre wahr und hebelte die Fokus-Sperre aus. **Lehre, schon zweimal bezahlt:** D25 hielt fest, dass synthetische `TouchEvent`s nur die eigene Ereignis-Logik beweisen. Dieselbe Grenze gilt für alles, was die **Geräteumgebung** stellt — Bildschirmtastatur, Browserleisten, Nutzergesten-Regeln. Ein „lässt sich nicht reproduzieren" aus dem Emulator ist bei solchen Meldungen kein Befund, sondern nur die Feststellung, dass das Werkzeug die Ursache nicht enthält. **Das Debug-Panel minimiert sich jetzt, statt sich zu schließen.** Ein Klick entfernte es bisher ganz — was nichts half, weil der 15-Sekunden-Takt es sofort wieder aufbaute; auf dem Telefon verdeckte es damit dauerhaft die untere rechte Ecke. Jetzt schaltet der Klick zwischen Kasten und einem 26-px-Icon (⟳) unten rechts um. Der Zustand liegt im **localStorage**, nicht am Element: Das Panel wird bei jedem Takt neu bespielt, ein Zustand am DOM-Knoten wäre also beim nächsten Tick weg. Nachgeprüft über einen echten Intervall-Durchlauf und über den `visibilitychange`-Pfad — es bleibt minimiert. (Test-Hilfe, im Prod-Build ohnehin unterdrückt; die Gelegenheit genutzt, dem aufgeklappten Kasten `white-space: pre-wrap` zu geben — die mit `\n` gefügten Zeilen liefen bisher zu einem Absatz zusammen.) ## D18 — Günstigsten Pfad per Inversion zeigen, fehlende Größe = M Der kostengünstigste Weg durch den Baum wird hervorgehoben (Umschalter im Diagramm-Kopf, Default an, Zustand persistiert). Nötig sind bei **all of** alle Kinder, bei **any of** nur die günstigste Alternative (kleinste rekursive Kosten; Gleichstand ⇒ erste). **Darstellung per Inversion, nicht per Betonung:** Getestet wurde zuerst ein doppelt dicker Rand an den nötigen Knoten. Da der Baum aber fast überall all-of ist, war damit *fast alles* dick umrandet — das Signal lag ohnehin nur in der *Abwesenheit* des Rands bei den ausgeschlossenen Alternativen. Deshalb wird invertiert: die **nicht benötigten** Knoten (nicht-gewählte any-of-Alternativen samt Teilbaum) treten zurück (blass, entsättigt), der Pfad hebt sich von selbst ab. Kein Zusatzrahmen, weil die Knoten-Ecken (Größe, Tags, ↗) schon dicht sind. **Fehlende T-Shirt-Größe wird für die Kostenschätzung als `M` gewertet** — die kleinste Größe mit Untergliederungspflicht (§5/D8), also die konservative Annahme „mindestens M". Damit sind auch ungrößte Alternativen vergleichbar. Wo `M` nur implizit angenommen ist, zeigt der Knoten ein **invertiertes** Größen-Badge (weiß mit Petrol-Rand statt gefüllt) samt Tooltip, damit die Annahme sichtbar und von einer echten `(M)`-Angabe unterscheidbar bleibt. Die Größen-Semantik in SPEC §5 bleibt unberührt (die Wertung gilt nur der Pfad-Kostenrechnung). **Pfad-Linie + Stationspunkte (U-Bahn-Plan):** Zusätzlich zur Inversion fädelt eine gestrichelte, geschwungene Petrol-Linie durch die **Endknoten** des Pfads. Sie liegt **hinter** den Knoten (kräftig in den Lücken), mit einer **abgetönten Kopie davor** (deutet den Verlauf beim Durchschreiten eines Knotens nur an — Alternative zum harten „über allem"). Problem dabei: läuft die Linie durch Fremd-/Zwischenknoten, ist nicht unterscheidbar, ob diese dazugehören (besonders im kompakten Modus). Lösung: ein **blasser, großer Stationspunkt** nur an den echten Blättern — durchquerte Knoten bleiben punktlos. Blass+groß statt klein+satt, damit der Knotentext lesbar bleibt. Alles auch im Grafikexport (hinten/vorne-Schichtung nachgezeichnet). **Layout: all-of unter any-of (horizontal).** Zerlegt eine any-of-Alternative selbst wieder all-of (z. B. „Web+Nativ" → Web + Android + iOS), würde der breite horizontale Fächer den Alternativknoten zentrieren und samt Elternbaum weit nach rechts schieben. Deshalb wird dieser Teilbaum **nur horizontal** schmal **transponiert** gestapelt (Kinder untereinander, linker solider Verteiler — wie im kompakten Modus), passend zur gestapelten any-of-Spalte darüber. Kompakt führt all-of ohnehin nach unten (Knoten bleibt oben, Abzweig bei 23 px passt). **Vertikal** dagegen behält den zentrierten Rechts-Fächer: `li.has-and` legt den Alternativknoten `align-items:center` **vertikal mittig** zu seiner Kindergruppe. Dann sitzt aber auch der **einkommende** any-of-Abzweig nicht mehr bei 23 px, sondern muss auf die **Knotenmitte (50 %)** zeigen — sonst ist der Knoten von seiner Linie von oben abgetrennt. Fix: `ul.or>li.has-and` bekommt vertikal **symmetrisches** Padding (Mitte bleibt bei 50 %), der Abzweig (`::before`) und die Rail-Endkante (`:last-child::after`) werden auf 50 % gesetzt — analog zu den bereits zentrierten all-of-Zwischenknoten. ## D19 — Modularisierung mit Vite-Bündelung zu einer self-contained Datei Das Nahziel „tragfähige Codebasis" (ROADMAP) verlangt, den Single-HTML- Prototyp in headless nutzbare Module (`parser`, `model`, `render`, `app`) zu zerlegen und gegen SPEC zu testen. Die Zwickmühle: **modulare Einzeldateien**, **`file://`-Tauglichkeit** (D16) und **kein Build** (D11) sind nicht gleichzeitig erfüllbar — ES-`import` über `file://` blockt der Browser (CORS). **Entscheidung: Vite als Bündler/Testrunner.** `frontend/src/*.js` sind echte ES-Module und die Quelle der Wahrheit; `frontend/index.html` lädt im Dev-Server per `