# Werkbaum · Frontend

Editor: Text links, Diagramm rechts, Toggles für transponierte Ansicht und
verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
**Vite-Entry** (lädt im Dev-Server `src/app.js` als `<script type="module">`).

## Build & Entwicklung (D19)
- **Vite** ist Bündler + Testrunner (nur Dev-Abhängigkeiten, keine Laufzeit-
  Abhängigkeit — das Ergebnis ist framework-freies HTML/CSS/JS).
- `npm --prefix frontend run dev` — Dev-Server (Port 8137, `.claude/launch.json`).
  Direktes Öffnen von `frontend/index.html` per `file://` funktioniert **nicht**
  mehr (ES-`import` braucht http); stattdessen Dev-Server oder die gebaute Datei.
- `npm --prefix frontend run build` — `vite-plugin-singlefile` inlint JS + CSS +
  Favicon (als `data:`-URI, via `transformIndexHtml`-Plugin in `vite.config.js`)
  in **eine** self-contained `dist/index.html` — die bleibt `file://`-tauglich
  (D16) und ist die Deploy-Artefakt-Quelle (Pages-Workflow, siehe README).
- `npm --prefix frontend test` — Vitest (`tests/**/*.test.js`).
- `npm --prefix frontend run build:prod` — Produktions-Build **ohne** den
  Build-Hinweis hinter dem Titel (Vite-Modus `prod`, `.env.prod` setzt
  `VITE_BUILD_BADGE=none`). Nur die echte produktive Installation nutzt diesen
  Weg; Dev-Server (🔧) und Default-`build` (🚧, u. a. Pages-Deploy) zeigen den
  Hinweis. Logik: `mountBuildBadge()` in `app.js` (D16).
- `node_modules/` und `dist/` sind ge-`.gitignore`-t; `.env.prod` und
  `package-lock.json` sind eingecheckt (der Workflow nutzt `npm ci`).

## Konventionen
- Vanilla HTML/CSS/JS, ES-Module; keine Frameworks. Testwerkzeug: Vitest.
- Parser und Renderer müssen headless (ohne Editor-UI) nutzbar bleiben —
  Basis für SVG-Export und Mermaid-Plugin (docs/ROADMAP.md). `app.js` ist der
  DOM-/UI-Einstieg; reine Logik gehört in `parser.js`/`model.js`/`render.js`.
- Design: Farben/Typografie beibehalten (CSS-Variablen, IBM Plex);
  Statusfarben sind in SPEC §4 normiert. Marke nach ../brand/BRAND.md;
  Pastelltöne nie im Logo.
- IBM Plex ist **lokal eingebettet** (`src/fonts/*.woff2`, `@font-face` in
  `style.css`), **nicht** von Google Fonts geladen — kein externer Request,
  keine IP an Dritte (Datenschutz, D20). Keinen `googleapis`-`<link>` wieder
  einführen. Neue Schnitte: `woff2` per `npm pack @fontsource/…` beziehen (keine
  Projekt-Abhängigkeit, Dateien einchecken) und `@font-face` ergänzen.

## Stolperfallen
- Abzweig-Linien zielen auf die **Knotenmitte** (fester 23-px-Offset,
  `line-height: 1.3`), nicht auf die Mitte des Teilbaums — bei Layout-
  Änderungen alle drei Modi (horizontal/vertikal/kompakt) prüfen.
  **Seit D64 brechen lange Labels um** (`wrapLabel()` in render.js setzt `\n`,
  `white-space:pre-line` macht sie sichtbar): Bei mehrzeiligen Knoten trifft
  der 23-px-Abzweig die Mitte der **ersten Zeile**; der Optional-Kreis sitzt
  deshalb fest bei `top:18px` (= Abzweighöhe), nicht bei 50 % (Ausnahme:
  vertikal zentrierte all-of-Zwischenknoten). Nichts in den Knoten darf die
  Zeilenbox über die festen ~18,3 px heben — der Falt-Chip hat darum
  `line-height:14px` ohne vertikales Padding (mit 15 px wuchs der Knoten
  gemessen um 0,7 px). Der Export zeichnet je gerenderter Zeile ein `<text>`
  (`labelLines()`, misst zeichenweise per Range am Live-Knoten).
  Vertikal + kompakt teilen die transponierte Basis-CSS; nur vertikal
  bekommt den Rechts-Ausgang für „all of“, kompakt führt auch „all of“
  nach unten. Any-of ist in allen Modi grau: Linien gestrichelt grau und
  Alternative-Rahmen grau (Basis-CSS `ul.or`). Kein Petrol im Diagramm mehr;
  `var(--or)` nur noch für UI-Akzente/Logo (SPEC §9, D15).
- Extraktionsreihenfolge im Parser nicht umstellen: Kommentar → Zeichen/
  Status → URL → Größe → Tags → Knoten-ID → Abhängigkeiten → Fokusmarke
  (sonst kollidieren `@` und `#` in URLs).
- Fehlertoleranz (SPEC §4): der Parser erfasst die Statusbox als *beliebiges*
  Einzelzeichen `\[([^\]])\]` und validiert gegen `STATUS_BY_CODE`; unbekannte
  Codes → `parse().warnings` als `{type:'unknownStatus', line, code}`, Knoten
  neutral. `render()` in app.js führt Parser- und Renderer-Warnungen zusammen
  (nach Zeile sortiert) und zeigt sie via `formatWarning` (warnings.js). Neue
  Warnungstypen dort + i18n-Key in allen 9 Sprachen ergänzen.
- **Zwei Senken, ein `switch`:** `formatWarning()` liefert HTML (Warnungsbereich),
  `warningText()` Klartext (der `title` der Zeilennummer, D33-Nachtrag); beide
  bauen auf `build(w, t, esc)` auf. Einen neuen Typ **nur dort** eintragen —
  einen zweiten Formatierer danebenzustellen heißt, dass einer veraltet.
- Modulteilung (D19): `parser.js` (Text→Baum, headless), `model.js` (Baum-/
  Kostenlogik: `gateOf`, `needsBreakdown`, `visibleChildren(n, showDiscarded)`,
  `computeCheapSet`, `cheapCls`), `render.js` (HTML-String via
  `renderTreeHtml(roots, {t, showDiscarded, cheapPath, cheapSet})`, headless),
  `app.js` (DOM/Events/i18n/Persistenz/Export). Modell/Renderer bekommen UI-State
  (verworfene einblenden, Pfad an/aus) als **Parameter** — keine Globals; nur
  `cheapPathOn` lebt als UI-State in `app.js`. Tests: `tests/*.test.js`.
- **Was entscheidbar ist, gehört in ein eigenes Modul** — auch bei Features, die
  wie reine UI aussehen: `remote.js` (Pad-URLs normalisieren, D31),
  `warnings.js` (Warnung → Text), `snapshots.js` (frühere Stände: wann entsteht
  ein Stand, was fliegt bei Platzmangel raus, wie sieht der Speicherinhalt aus).
  Dort steht **was gilt**, in `app.js` bleibt **woher die Werte kommen und wohin
  sie gehen**; Speicher (`{setItem, removeItem}`) und Uhr (`Date.now()`) werden
  hereingereicht, damit der Test sie stellen kann. Anlass war ein Fehler, der bis
  in Produktion kam und den ein Test in einer Zeile gefunden hätte
  (D54-Nachtrag 3). Faustregel: Sobald du eine Regel im Browser „nachmisst",
  gehört sie in ein Modul.
- Günstigster Pfad: `computeCheapPlan()`/`cheapestCost()`/`cheapCls()` (in
  `model.js`) markieren
  die nötigen Knoten (Klassen `cheap`, `cheap-leaf`); `drawCheapPath()` (app.js)
  zeichnet nach jedem
  `render()` **und** nach `applyLayout()` zwei Overlay-SVGs in `#out` (hinten
  kräftige Linie, vorne abgetönte Kopie + Stationspunkte). Overlays erben den
  CSS-`zoom` von `#out`, Punkte in unskalierte `#out`-Koordinaten umrechnen
  (`/zoom`). `diagramToSvg()` zeichnet dieselbe Linie/Punkte nach (SPEC §9, D18).
  **Die Inversion `.cheap-on .node:not(.cheap)` hat vier Ausnahmen** — `.fresh`
  (D28), `.focusmark` (D32), `.current` (D25) und `.done` (D46-Nachtrag: `[x]`
  oder `[^]`, gesetzt in `render.js` per `isDone()`). Wer eine fünfte Aussage an
  einen Knoten hängt, prüft, ob sie ausgeblasst noch etwas sagt; die ersten drei
  sind je einzeln nachgereicht worden, nachdem sie unsichtbar waren. Der
  **Grafikexport** blasst ohnehin nie aus (er liest `backgroundColor`, nicht
  `opacity`) — er war damit schon immer die Ansicht ohne Inversion.
- Zerlegt eine any-of-Alternative selbst all-of, wird der Teilbaum **nur
  horizontal** schmal transponiert (`ul.or>li.has-and>ul.and`, siehe D18) —
  sonst schiebt der breite Fächer den Elternbaum nach rechts. Bei Layout-
  Umbauten dieses Nesting mitprüfen.
- „verworfen" ist per Default ausgeblendet; Filterlogik steckt in
  `visibleChildren()` und muss bei Renderer-Umbauten erhalten bleiben.
- Barrierefreiheit (SPEC §9): `render.js` baut je Knoten einen sprechenden
  `aria-label` (Label + Status + Aufwand + Zuständige + Link, lokalisiert via
  `t`); die visuellen Badges (Größe, Tags, ↗) sind `aria-hidden`. Neue
  Knoten-Eigenschaften dort in `nodeAria()` mitpflegen und dafür a11y-i18n-Keys
  (`a11y*`) in **allen 9 Sprachen** anlegen. Knoten sind `tabindex="0"`
  (Fokus = Lesereihenfolge), `#warn` ist eine Live-Region.
- Zustand wird im `localStorage` gehalten (noch kein Backend): `werkbaum-lang`
  (Sprache), `werkbaum-docs` (JSON-Array der Dokumente `[{id,name,text}]`),
  `werkbaum-active` (id des aktiven Dokuments), `werkbaum-src` (Spiegel des
  aktiven Texts, Abwärtskompatibilität), `werkbaum-ui` (JSON: Modus, verworfene,
  günstigster Pfad, Split-Zustand inkl. `--col`/`--drow`, Zoom, Vollbild). Neue
  GUI-Einstellungen in `saveUI()`/`restoreState()` mitführen; `saveUI` liefert
  während `restoring===true` nichts, damit das Wiederherstellen nicht sofort
  zurückschreibt.
- Dokumente (D22): mehrere umschaltbare Notationstexte. `loadDocs()` migriert bei
  fehlendem `werkbaum-docs` den bestehenden `werkbaum-src` (oder `INITIAL`) in
  **ein** Dokument; `initDocs()` (Aufruf **nach** `applyLang`) holt den aktiven
  Text in den Editor. `saveSrc()` schreibt den Editortext ins aktive Dokument.
  Der Wähler ist ein Dropdown in der Editor-Titelzeile (`#docTrigger`/`#docMenu`,
  ersetzt die frühere feste „Struktur (Text)"-Beschriftung); Wechseln/Neu/
  Umbenennen/Löschen in `switchDoc/newDoc/renameDoc/deleteDoc`. Jedes Dokument ist
  nur Text + Name (kein Strukturformat, D14) — vorwärtskompatibel zum Backend
  (D13). Ansichts-State (`werkbaum-ui`) bleibt global über alle Dokumente. Ein
  leerer Editortext bleibt leer.
- Beispiel-Dokument (D22): reservierte id `EXAMPLE_ID = 'example'`, fester
  englischer Name `EXAMPLE_NAME = 'Example'` (nicht lokalisiert). `loadDocs()`
  adoptiert einen Alt-Zustand (zufällige id, „Beispiel") nur, wenn dessen
  `text === INITIAL` (nie echte Nutzerinhalte). Eine neue INITIAL-Fassung wird
  wie beim Werkbaum-Dokument per Fingerabdruck nachgezogen
  (`werkbaum-seeded-example`, D27-Nachtrag) — nur bei unverändertem Text; der
  Reset setzt beide Merker. Bearbeitete mitgelieferte Dokumente holt der
  Nutzer über „Original wiederherstellen" im Dokumenten-Menü zurück
  (`restoreDoc()`, D22-Nachtrag) — sichtbar nur bei Abweichung vom
  Auslieferungsstand, stellt Text UND Name wieder her. `resetToDefaults()` setzt **nur**
  das Beispiel-Dokument (id `example`) auf `INITIAL`/„Example" zurück und verwirft
  `werkbaum-ui`/`werkbaum-lang`/Update-Flags — **andere Dokumente bleiben stehen**
  (nicht mehr pauschal alle `werkbaum-*` löschen!). Das letzte gelöschte Dokument
  wird als Beispiel neu gesät.
- Mitgeliefertes Dokument „Werkbaum" (D27): `app.js` importiert
  `../../docs/examples/werkbaum.werkbaum?raw` — die Beispieldatei ist
  damit **Build-Eingabe**, Umbenennen/Verschieben bricht den Build (Zugriff
  außerhalb des Roots erlaubt `server.fs.allow:['..']`). `seedShippedDocs()` legt
  es **einmalig** an (auch für Bestandsnutzer); ohne den Merker `werkbaum-seeded`
  käme ein gelöschtes Dokument bei jedem Laden zurück. Der Merker hält den
  **Fingerabdruck** der ausgelieferten Fassung: Ändert sich die Datei, wird der
  Text nur nachgezogen, wenn der Nutzer ihn **nicht** bearbeitet hat. Wer die
  Beispieldatei ändert, ändert damit das mitgelieferte Dokument mit.
  `resetToDefaults()` setzt beide mitgelieferten Dokumente **und** den Merker.
- Umbenennen ist **inline** (kein `window.prompt` — in manchen Browser-Kontexten
  unterdrückt): `renameDoc()` setzt `renamingId`, `renderDocMenu()` rendert dann
  ein `<input class="docrename">` (Enter = `commitRename`, Esc = `cancelRename`,
  Blur = commit). Doc-Namen sind Nutzerdaten und werden **nicht** übersetzt.
- `?sourceUrl=` (D23): `loadFromSourceUrl()` (Aufruf am Ende des Starts, async)
  holt eine externe Textdatei und führt sie als Dokument mit `id: 'url:<href>'`,
  `name`/`source` = URL. Nur `http(s)`, `credentials:'omit'`; bei jedem Laden wird
  neu geholt (URL = Quelle der Wahrheit, lokale Edits daran gehen verloren).
  Fehler landen als zeilenlose Warnung `{type:'sourceLoad', url, error}` in
  `sourceWarning` — ein **persistenter** Kanal, den `render()` jedem Warnungs-Satz
  voranstellt (überlebt Neu-Renderings, bis das Laden gelingt). Hauptstolperfalle
  ist **CORS**: fremde Hosts brauchen `Access-Control-Allow-Origin` (raw.github…
  ja, beliebiger Webserver oft nicht) — der Warntext nennt das ausdrücklich.
  `updateDocName()` setzt für solche Dokumente den vollen URL-Tooltip und muss
  deshalb **nach** dem `data-i18n-title`-Durchlauf in `applyLang()` laufen.
  Weder Endung noch `Content-Type` werden geprüft (`response.text()`); die
  Endung `.werkbaum` ist reine Konvention (D24, SPEC §12). Beispieldateien zum
  Ausprobieren: `docs/examples/*.werkbaum` (nacheinander geöffnet ergeben sie
  mehrere Dokumente im Wähler).
- `?etherpad=` (D31): `remoteSource()` liefert **einen** Beschreiber für beide
  Eingänge (`?sourceUrl=` und `?etherpad=`), `loadRemoteSource()` holt und legt
  das Dokument an — ein Fetch-Pfad, ein Warnkanal. Die Normalisierung der
  Pad-Adresse steht headless in **`remote.js`** (`padUrls`, Tests in
  `tests/remote.test.js`): Export-/Timeslider-Pfad, Query, Fragment und
  Schrägstriche fallen weg, verlangt wird `/p/<name>` am Ende. Name und id sind
  die **vollständige** Pad-URL (Pad-Namen sind nur pro Instanz eindeutig).
  `padSource` muss **vor** dem ersten Abruf und vor `loadActiveIntoEditor()`
  gesetzt werden: Es trägt den Schreibschutz, und scheitert der erste Abruf
  (Normalfall bei dieser Gegenseite), bleibt so der Neu-laden-Knopf erreichbar
  statt das Dokument tot liegen zu lassen. Geholt wird **nur auf Knopfdruck**
  (`refreshPad`) — **kein Hintergrund-Takt wieder einbauen**: Etherpads
  `importExportRateLimiting` lässt serienmäßig 10 Abrufe je 90 s und IP zu, und
  jenseits davon hält die Gegenseite die Verbindung ohne Antwort offen (kein
  `429`), bis der eigene Abbruch sie abreißt — gemessen: zwei Minuten tot, danach
  0,4 s. Ein Takt gewinnt dagegen nicht, er erzeugt die Drosselung. Deshalb auch
  `setPadBusy()` (Symbol dreht, bis zu 20 s) und ein **eigener** Warnungstyp
  `sourceTimeout` für den Abbruch — `sourceLoad` zeigt auf CORS und schickte hier
  auf die falsche Fährte. `deleteDoc()` ruft `stopPad()`, sonst legte ein späterer
  Abruf das gelöschte Dokument wieder an und aktivierte es auch gleich.
  Beim Prüfen im Vorschau-Browser: HMR lädt bei jeder Quelländerung neu, und ein
  Reload holt den Text beim Erstabruf — sieht wie eine geglückte Übernahme aus.
  Marker auf `window` setzen und hinterher prüfen, sonst beweist der Test nichts.
- Pad einbetten, drei Ansichten (D31): `padView` ('both'|'pad'|'text') +
  `applyPadView()`. Der Rahmen bekommt **nur sichtbar** ein `src`, sonst
  `about:blank` — ein geladenes Pad verbindet sich per Socket und zeigt dich in
  dessen Anwesenden-Liste. `PAD_VIEWS`/`padView` stehen **oben** bei
  `cheapPathOn`, nicht beim übrigen Pad-Code: `saveUI()` liest sie und kann schon
  aus `applySplit()` heraus laufen, weiter unten stünden sie noch in der
  temporalen Todeszone. Der Splitter Pad|Spiegel (`#padGutter`, `--pcol`/`--prow`)
  ist eine Kopie des Legenden-Splitters (D26) samt getrennter Persistenz.
  `revealEditor()` schaltet 'pad' → 'both', sonst zeigte der Sprung (D25) ins
  Nichts. Das Markup: `#srcArea` umschließt Rahmen + Splitter + `#src`, damit die
  Legenden-Aufteilung unberührt bleibt — `.editor-body textarea` ist ein
  Nachfahren-Selektor und greift weiter. Nebenwirkung, die als Behelf taugt:
  Einmal durch die Ansichten schalten **lädt den Rahmen neu** (über
  `about:blank`) — das ist der Ausweg, wenn ein eingebettetes Pad nicht mehr
  beschreibbar ist. Nicht in den Neu-laden-Knopf legen: der zieht nach dem Tippen
  Spiegel und Diagramm nach, und ein Rahmen-Reload dabei kostete jedes Mal die
  Schreibmarke im Pad. **Umhängen im DOM lädt jeden `<iframe>` neu** — wer
  Container um `#srcArea` herum neu aufbaut, wirft die Pad-Sitzung weg.
- Fokusmarke `!!!` (D32, SPEC §1): Parser setzt `focus`, Renderer die Klasse
  `focusmark`, CSS gibt ihr einen **eigenen Strahlenkranz in Petrol** (Nachtrag zu
  D32; der geteilte Ring mit `.current` war zu leise). Schein in **hellem** Teal
  `#14B8A6`, Ring in dunklem `--or`: ein dunkler Schein auf Weiß liest sich als
  Schatten, ein heller als Licht. Wie bei `.fresh` braucht die Regel `z-index:2`,
  sonst schneidet ein später gemaltes Geschwister den Kranz ab (D28). Die
  Kombinationen `focusmark.current` und `fresh.focusmark` sind ausbuchstabiert;
  letztere steht bewusst **danach** (gleiche Spezifität). Erkannt **nur alleinstehend** — `(^|\s)!!!(?=\s|$)`, bewusst ohne
  Lookbehind (Safari erst ab 16.4), sonst verlöre `Achtung!!!` seine
  Ausrufezeichen. Extraktion **nach** den Tags; der Kommentar fällt vorher weg,
  eine Marke hinter `%%` wirkt also nicht. `revealFocusMark()` scrollt **nur bei
  Änderung** (Schlüssel = Label-Text, nicht Zeilennummer — Umsortieren im Pad soll
  nicht neu scrollen), sonst zöge jeder Neubau den Blick zurück. Die Regel braucht
  den `#out`-Präfix wie `.current` (`ul.or .node{box-shadow:none}` ist
  spezifischer) **und** die Ausnahme
  `.cheap-on .node:not(.cheap).focusmark{opacity:1;filter:none}` — sonst blasst die
  Pfad-Inversion genau den Knoten aus, auf den gezeigt wird (bei einer nicht
  gewählten Alternative gemessen: Deckkraft 0,32).
- Sprung Diagramm ↔ Text (D25): `render.js` schreibt die Parser-Zeilennummer als
  `data-line` an jeden Knoten (Geister-Knoten bekommen keine). `jumpToLine()` in
  `app.js` klappt bei Bedarf das Editor-Panel auf (`revealEditor()`), markiert die
  **ganze Zeile** und scrollt über einen Spiegel-`div` (`offsetTopInEditor()`) —
  Zeilenhöhe × n scheitert an weichen Umbrüchen. Ausgelöst per Alt+Klick,
  Alt+Enter und langem Druck; der Klick-Handler **muss `preventDefault()`** rufen,
  sonst lädt Alt+Klick auf einen Link-Knoten das Ziel herunter. Gegenrichtung:
  Welcher Knoten zu einer Zeile gehört, entscheidet **eine** Stelle:
  `nodeOfLine()` — erst `data-line`, sonst `data-desc-lines~=` (Cursor in einer
  Beschreibung wählt ihren Knoten, D40-Nachtrag 2). Beide Richtungen gehen
  darüber; wer eine neue Zeilensuche schreibt, nimmt sie ebenfalls.
  `syncCaret()` setzt die Klasse `current` auf den Knoten der Cursor-Zeile;
  `render()` stellt sie nach jedem Neubau wieder her (ohne zu scrollen). Die
  CSS-Regel braucht den `#out`-Präfix (`ul.or .node{box-shadow:none}` ist
  spezifischer) **und** — wie `.fresh`/`.focusmark` — die Ausnahme
  `.cheap-on .node:not(.cheap).current{opacity:1;filter:none}`; sie fehlte lange
  als einzige, obwohl der Fall der häufigste ist (Pfad-Umschalter default an,
  jeder `+`-Knoten und jede nicht gewählte Alternative betroffen).
  Zum Ring kommen **Schlagschatten + `scale(1.04)`** (D25-Nachtrag): Tiefe ist der
  einzige noch freie Kanal. Um die **Mitte** skalieren ist Bedingung — `alignStems()`
  und die Stationspunkte messen die Knotenmitte, die dadurch unverändert bleibt.
  **`diagramToSvg()` muss die Erhebung abschalten** (Klasse `exporting` an `#out`,
  am Ende wieder ab): Der Export zieht die Live-Geometrie per
  `getBoundingClientRect()` nach, anders als beim `box-shadow` schlägt die
  Vergrößerung dort durch. Der **Puls** (`pulse`, Hüpfer + Ring auf dem freien
  `.node::after`) wird nur bei geändertem `caretLine` gesetzt — sonst pulst es bei
  jedem Tastendruck — und braucht das Lesen von `offsetWidth` als Neustart-Trick,
  weil dieselbe Aufgabe die Klasse vorher entfernt. Nicht über `box-shadow`
  animieren: Das löschte für die Dauer des Pulses den gelben bzw. Petrol-Kranz der
  Kombinationen. Auffindbarkeit: `setAltMode()` setzt bei gedrückter Alt-Taste
  die Klasse `alt` an `#out` (Cursor + Ring am Knoten unter dem Zeiger) — der
  `blur`-Handler ist Pflicht, sonst bleibt der Modus nach Alt+Tab hängen; dazu
  `jumpHint` im Knoten-Tooltip und `hint_jump` als letzte Zeile der Legende.
- Alt+Klick **im Textfeld** (D25, Nachtrag): `focusNodeOfCaret()` zentriert den
  Knoten der Cursor-Zeile und gibt ihm den Fokus (`focus({preventScroll:true})`
  **vor** dem Scrollen — sonst scrollt der Browser zweimal). Die Hervorhebung ist
  **dieselbe wie beim Zeilenwechsel**, Puls eingeschlossen; den Unterschied trägt
  allein der zweite Parameter `highlightCurrentNode(moved, scroll)`:
  `'nearest'` beim gewöhnlichen Zeilenwechsel (`syncCaret()`), `'center'` hier,
  `false` beim Neubau aus `render()`. Nicht wieder `false` für den Puls
  mitgeben — dann kommt die ausdrückliche Geste stiller an als das Tippen. Beim Tastaturweg
  (Alt+Enter) ist `preventDefault()` Pflicht, sonst bekommt der Text einen
  Umbruch. Die Legenden-Zeile `hint_jump` nennt **beide** Richtungen — kein
  eigener i18n-Schlüssel.
- Zeilennummern (D33): `renderLineNos()` misst die Zeilenoberkanten am
  Spiegel-`div` (`lineTops()`) — **nicht** `Zeilenhöhe × n`, das läuft beim
  ersten weichen Umbruch davon. Der Streifen scrollt nicht selbst, sondern wird
  per `translateY(-src.scrollTop)` mitgezogen (`syncLineNoScroll()`); wer
  `src.scrollTop` **selbst** setzt, muss ihn gleich mitziehen — das
  `scroll`-Ereignis kommt erst im nächsten Bild (`scrollEditorToOffset()` tut es).
  Der Spiegel braucht `box-sizing:border-box`: `src.clientWidth` enthält die
  Innenabstände, ohne das ist er 32 px zu breit und bricht später um (betraf
  vorher auch schon das Scrollen beim Sprung, D25). Der Marker ist ein
  Inline-Kasten und sitzt in seiner Zeilenbox mittig — die Zahlen müssen um
  diesen Versatz (`drop`) nach oben, sonst stehen sie durchgehend zu tief.
  Aufgerufen wird aus `render()` (dort kommt auch die Warnungsmenge her) und aus
  einem `ResizeObserver` auf `#src` (Umbruch hängt an der Breite). Markup:
  `#srcWrap` umschließt Streifen + Textfeld; die Pad-Regeln (D31) teilen gegen
  **diese Spalte** (`.src-area.pv-both>.src-wrap`), nicht gegen das nackte
  Textfeld. `caretLine` steht deshalb **oben** bei `padView` — der Streifen liest
  sie und hängt an `render()` (temporale Todeszone, siehe D31).
- Touch-Langdruck (D25): Der Timer (500 ms) setzt **nur** die Klasse `armed`
  (Petrol-Ring); `jumpToLine()` läuft im `touchend`-Handler. Nicht zurück in den
  Timer verlegen — **`focus()` aus einem Timer gilt in mobilen Browsern nicht als
  Nutzergeste**, das Textfeld verliert den Fokus sofort wieder (Symptom: die
  Markierung flackert nur auf). Gegen die native Langdruck-Auswahl braucht es
  alle drei Schichten: `contextmenu`-`preventDefault` während des Drucks,
  `user-select:none` per `@media (hover:none) and (pointer:coarse)` und
  `-webkit-touch-callout:none` (nur iOS). Synthetische `TouchEvent`s prüfen
  davon **nichts** — nur die eigene Ereignis-Logik.
- Bildschirmtastatur (D25): `jumpToLine()` setzt vor dem Fokus `inputmode="none"`
  (`keyboardOnJump(true)`) — der Sprung ist „hinschauen". Der `pointerdown`-
  Handler am Textfeld hebt das wieder auf (läuft vor dem Fokus). Wer sonst
  irgendwo `src.focus()` ergänzt und dabei Tippen meint, muss vorher
  `keyboardOnJump(false)` rufen — so wie `newDoc()`.
- Legende (D26): **kein `<details>`** — Chrome legt Details-Inhalt in
  `::details-content`, dadurch war `.hint` kein Flex-Kind mehr und ließ sich nicht
  begrenzen (Inhalt geclippt statt scrollbar). Jetzt `div.agenda` +
  `button.agenda-summary`, Zustand an der Klasse `open` (`setAgendaOpen()` hält
  Klasse, `aria-expanded`, `#legendBtn` und die Sichtbarkeit des Splitters
  zusammen). Wer hier wieder ein natives Aufklapp-Element einsetzt, bricht das
  Scrollen. Der Splitter `#hintGutter` schreibt `--hcol` (nebeneinander) bzw.
  `--hrow` (gestapelt: `side` oder mobil) an `#app`; beide werden getrennt in
  `werkbaum-ui` gesichert. Die 85-%-Obergrenze steht **zusätzlich** als
  `max-width`/`max-height` im CSS — die gespeicherte px-Größe würde den Editor
  sonst erdrücken, wenn das Panel später schrumpft.
- „Was ist neu?" (D28): `freshProdSet(prevRoots, currRoots)` in `model.js` liefert
  die Knoten, die **neu `[^]`** sind (Identität = Label-Pfad, nicht Zeile).
  `render()` bildet die Menge **bei jedem Durchlauf neu** aus den gerade
  geparsten `roots` — eine vorab berechnete Menge stammte aus einem anderen
  Parse-Durchlauf und träfe per Objektidentität nie zu (genau dieser Fehler ist
  passiert: Zähler stimmte, nichts leuchtete). Vorgehalten wird nur
  `freshPrevRoots` (Basis, einmal geparst). Basis je Dokument in `werkbaum-seen`,
  fortgeschrieben **erst beim Bestätigen** über `#freshBtn`.
- Faltmarken `>`/`<` (SPEC §1/§9, D38): Parser setzt nur `fold` ('>'|'<'|null,
  Leerraum-Regel). **Stellung ist unmittelbar vor dem Label**, also hinter der
  Statusbox (`- [x] > …`, D34-Nachtrag 2); die alte Stellung **davor**
  (`- > [x] …`) wird weiter gelesen — daher zwei Marken-Gruppen im Zeilen-Regex,
  die erste gewinnt —, aber `setFoldMark()` schreibt immer die neue und löst
  eine alte dabei auf. Wer die Gruppen anfasst: Der Rest der Zeile ist `m[6]`,
  nicht `m[5]`. Den wirksamen Anfangszustand rechnet `initialCollapsed()`
  in model.js — `<` (und die Fokusmarke) wandert die Faltung die Pfad-Ebenen
  **hinunter** statt Vorfahren bloß zu öffnen. `render()` überlagert ihn mit
  `foldOverrides` (Schlüssel = Label-Pfad via `nodeKeys()`, Sitzung, beim
  Dokumentwechsel geleert) und übergibt `collapsedSet` an den Renderer; der
  lässt eingeklappte Kinder **weg** (nicht CSS-verstecken — Export, Messungen
  und Pfadlinie bleiben so von selbst konsistent), meldet deren Warnungen aber
  weiter (`walkFolded`, zählt zugleich fürs „▸ n").
  **Auf dem günstigsten Pfad vertritt ein eingeklappter Knoten seinen
  Teilbaum** (D38-Nachtrag): `cheapCls(n, cheapSet, collapsed)` überspringt für
  ihn die Blatt-Prüfung und fragt `hidesCheap()` — sonst umginge die Pfad-Linie
  den ganzen Zweig, als wäre dort nichts zu tun. Gilt auch ohne eigene
  Pfad-Mitgliedschaft (ein per `:#…` gezogenes Ziel unter einem `+`-Knoten):
  Dann ist er der einzige sichtbare Griff darauf und zählt als `cheap`.
  `extraCls()` muss dieselbe Eingeklappt-Bedingung bilden wie `itemHtml`
  (nur mit sichtbaren Kindern). **Eine einzelne Station ist ein gültiger Pfad**
  (D38-Nachtrag 3): `drawCheapPath()` steigt nur bei **null** Stationen aus,
  die Zwei-Punkte-Schranke gilt allein der Linie (`catmullRom`) — im Export
  ebenso. Sonst verschwindet der Pfad beim eingeklappten Wurzelknoten ganz.
  **Die vorderen Overlay-Ebenen brauchen einen `z-index`** (D25-Nachtrag 3):
  `.cheap-front{z-index:5}`, `.dep-front{z-index:4}` — sonst deckt jeder
  Knoten mit eigener Stapelposition (`current` 3, `fresh`/`focusmark` 2) den
  Stationspunkt und die hervorgehobenen Dep-Kanten zu. Die **hinteren** Ebenen
  bleiben ohne, dort trägt die DOM-Reihenfolge.
  **Umklappen schreibt in den TEXT zurück** (`writeFoldToText`, D38-Nachtrag 2):
  Die Ableitung Text → Zustand ist nicht umkehrbar (mehrere Markensätze ergeben
  denselben Zustand; `<` faltet Knoten ohne eigene Marke), deshalb **minimal
  patchen und nachrechnen** — `setFoldMark()` auf die eine Zeile, dann
  `initialCollapsed()` auf dem Kandidaten gegen den Soll vergleichen; erst bei
  Abweichung alle Marken neu setzen; passt auch das nicht (z. B. `!!!` holt den
  Knoten immer hervor), gar nicht schreiben. Nie eine eigene Umkehrfunktion
  bauen — `initialCollapsed()` bleibt die einzige Stelle, die die Marken
  versteht. Geschrieben wird mit `execCommand('insertText')`: `value =` und
  `setRangeText` zerstören die Undo-Historie (gemessen). Zwei Fallen: Ein
  Textfeld mit `display:none`-Vorfahr nimmt **kein** `execCommand` an (Klasse
  `writing-fold` schaltet es kurz sichtbar), und der nötige Fokus zieht auf
  Mobil die Tastatur hoch (`keyboardOnJump(true)`). Bei `src.readOnly` (Pad,
  D31) wird nicht geschrieben, dort trägt weiter `foldOverrides` — die
  Überlagerungen werden nach erfolgreichem Schreiben geleert, sonst maskieren
  sie den Text. Der **Umschalter** im Diagramm-Kopf (`applyFoldPreset`, D44)
  geht denselben Weg: Überlagerungen für ALLE faltbaren Knoten setzen
  (`atMostM(n)` bzw. `false`), dann `writeAllFoldMarks()` — der aus
  `writeFoldToText()` herausgelöste Voll-Rewrite; einen minimalen Patch gibt es
  hier nicht. Ein Knoten **ohne** Größe zählt nicht als „bis M" (anders als
  bei den Pfadkosten, D18). Sein `aria-pressed` wird **in `render()` am Baum
  abgelesen**, nicht gemerkt und nicht persistiert — sonst behauptete er nach
  einem einzelnen Umklappen etwas Falsches.
  **`replaceTextUndoable()` meldet bei unveränderten Text
  `false`** (D38-Nachtrag 3): Ohne Textänderung feuert kein `input`, also läuft
  kein `render()` — der Aufrufer muss dann selbst zeichnen. Sonst bleibt das
  Bild stehen (aufgefallen beim Aufklappen eines nur per Überlagerung
  gefalteten Knotens: der Klick tat sichtbar nichts mehr). Umklappen: Klick aufs
  `.fold`-Zeichen (preventDefault — es sitzt bei Link-Knoten im `<a>`) oder
  ←/→ am fokussierten Knoten; nach `render()` den Fokus per `data-line`
  wiederherstellen. Export/Druck: „▸ n" bleibt, das ▾ offener Knoten fällt weg
  (`stripFold` in `diagramToSvg`, Print-Regel `.node:not(.folded) .fold`).
- Knoten-IDs `#name` (SPEC §1/D36): nur **alleinstehend angesetzt** und nur
  der **erste** Treffer der Zeile (kein `/g`!) — weitere `#`-Token bleiben im
  Label (reservierte Ticket-Referenzen), und `:#a,#b` (künftige Abhängigkeiten)
  darf nicht gefressen werden. Zeichenmenge wie `@name`. Doppelte ID →
  `{type:'duplicateId', line, id, firstLine}`; die spätere gilt trotzdem.
  Keine eigene Darstellung — nur Tooltip (erste Position) und `a11yId`.
  **Übliche Schreibweise ist `#id: Titel`** (D36-Nachtrag): Der trennende
  Doppelpunkt ist optional, gehört weder zur ID noch zum Label und fällt beim
  Parsen weg — sonst wären `#auth` und `#auth:` zwei Adressen. Geschluckt nur
  mit folgendem **Leerraum oder Zeilenende**, sonst bliebe von `#auth:#db` die
  Abhängigkeit nicht übrig. Die ID-Erkennung selbst NICHT verschärfen: ein
  verlangtes `(?=\s|$)` hinter der ID deutet bestehende Zeilen um (der Ausdruck
  wandert bei Fehlschlag weiter und erklärt ein späteres `#`-Token zur ID).
  Der Block-Kopf im Beschreibungsteil nimmt den Doppelpunkt ebenfalls an.
- Abhängigkeiten `:#a,#b` (SPEC §1/D37): EIN zusammenhängendes Token ohne
  Leerraum, nur **alleinstehend angesetzt** — `(:#a,#b)` bleibt Label
  (Zitier-Konvention wie `(#auth)`). `node.deps` sind **ID-Strings**, keine
  Knoten-Referenzen; der Parser prüft nur Existenz (`unknownDep`), Zyklen
  werden bewusst nicht einmal erkannt (zulässig, „gemeinsam fertig"). Keine
  Diagramm-Darstellung — nur Tooltip (`→ #a, #b`) und `a11yDeps`.
- Querverbindungen (SPEC §9, D41): `drawDepLinks()` zeichnet die
  Abhängigkeits-Kanten als Overlay-SVGs (wie `drawCheapPath`, `/zoom`
  umrechnen) aus den Renderer-Attributen `data-id`/`data-deps` — gekrümmt
  und GEPUNKTET (doppelt unterschieden von den orthogonalen Baumlinien,
  D41-Nachtrag), blassgrau hinter den Knoten, Pfeil aufs Gebrauchte; Hervorhebung (Fokus bzw. Cursor-Zeile,
  `activeDepNode()`) in Tinte auf der vorderen Ebene. Aufgerufen aus
  `highlightCurrentNode()` (läuft in jedem `render()`), `applyLayout()` und
  den focusin/focusout-Handlern — NICHT doppelt in `render()` einhängen.
  Export zeichnet nur die Basis-Kanten (Schritt 1a).
- Effektiver Status (SPEC §4/§9, D39): `effectiveStatus(roots)` in model.js
  liefert NUR die Diskrepanzen (Map Knoten → effektiver Status-Key; Minimum
  des Fortschritts-Rangs über die Abhängigkeits-Hülle, Fixpunkt — Zyklen
  teilen ihr Minimum, erste ID-Vergabe gewinnt). Der Renderer färbt dann
  `st-<effKey>` statt des intrinsischen und hängt die Marke
  `.chip.ownst.st-<ownKey>` (eigene Statusbox, eigene Farben) unten links an —
  im Export via `drawBadge`, aus dem Label-Klon entfernen (`.ownst`). XOR-
  „realisiert" (Parser) und „Was ist neu?" (`[^]` im Text) bleiben bewusst
  **intrinsisch**.
- XOR-Gruppen `=` (SPEC §3/D35): Der Parser setzt `type:'xor'` (nur mit
  folgendem Leerraum — `=SUMME(…)` bleibt Label); der Renderer gibt
  `<ul class="or xor">` aus, damit die **gesamte** any-of-Geometrie (alle drei
  Modi, D18-Sonderfälle, Export-Routing) automatisch gilt — `.xor` ergänzt nur
  die „1"-Plakette (`ul.xor::after`, im Export `xorMarks`). Disjunktiv-Abfragen
  auf `gateOf` prüfen `!== 'and'`, nie `=== 'or'`. Die `xorConflict`-Warnung
  (mehr als eine realisierte Alternative: `[~]`/`[/]`/`[x]`/`[^]`) entsteht im
  **Parser** (Post-Pass), nicht im Renderer.
- Optionale Knoten `+` (D29): Der Parser setzt **`optional:true` und lässt
  `type:'and'`** — `+` gehört zum Knoten, nicht zur Gruppe. Deshalb bleiben
  `gateOf()` und die `mixedGate`-Warnung unverändert richtig (sie meldet nur
  `|` neben `-`/`+`). Aus dem günstigsten Pfad fallen optionale Knoten über
  **`pathChildren()`** heraus — die eine Stelle, die `cheapestCost()` und
  `markCheapest()` gemeinsam nutzen; deshalb wirkt sie samt Teilbaum. Der hohle
  Kreis ist `.node.opt::before`: **Grundfall links/50 %** (gestapelt), Ausnahme
  **oben/50 %** im horizontalen Fächer, Rück-Ausnahme wieder links für
  `ul.or>li.has-and>ul.and>li` (D18). Im SVG-Export muss er **nach** den Knoten
  gezeichnet werden (`optMarks`, Schritt 3a) — er liegt halb außerhalb der Box
  und würde sonst vom Knoten-Rechteck überdeckt. Der **Abzweig** ist zusätzlich
  gestrichelt (Tinte); dafür trägt auch das **`<li>`** die Klasse `opt`, denn den
  Abzweig zeichnen dessen Pseudoelemente. Gestrichelt wird nur die Kante zum
  Knoten, nie die Sammelleiste — im Fächer `border-left`/`-right`, gestapelt
  `border-top`.
- Treppe optionaler Endknoten (D29, Nachtrag 3): `applyOptStairs()` gruppiert
  **in app.js**, nicht im Renderer — die DOM-Ebene `li.opt-group > ul.opt-stair`
  gibt es semantisch nicht und müsste in den drei übrigen Anordnungen wieder
  neutralisiert werden. Deshalb: nur im Fächer bauen, beim Moduswechsel
  auflösen (die Funktion räumt immer zuerst auf, `applyLayout` arbeitet auf dem
  bereits gruppierten Baum). Reihenfolge: `applyOptStairs()` **vor**
  `alignStems()`/`drawCheapPath()`, es verschiebt Knoten. Bedingung ist „`<li>`
  hat genau ein Element-Kind, und das ist der Knoten" — also kein Teilbaum und
  kein Geister-Knoten, weil die Stufengeometrie Zellenhöhe == Knotenhöhe
  voraussetzt. Der SVG-Export hängt nur die **erste** Stufe an die Leiste und
  zieht die übrigen als Winkelkette nach; flach eingereiht lief die Linie zur
  dritten Stufe hinter der zweiten hindurch.
- `--stem-x` (D29, Nachtrag 2): Im horizontalen Fächer sitzt der Stiel bei 50 %
  des `<li>`. Das ist nur dann die Knotenmitte, wenn der Knoten in der Zelle
  zentriert steht — `li.has-or` ist aber linksbündig und die Zelle so breit wie
  der any-of-Teilbaum. `alignStems()` misst deshalb nach jedem `render()` (und
  in `applyLayout`) die Knotenmitte und setzt `--stem-x`; Fallback im CSS ist
  `50%`. Messwerte durch `zoom` zurückrechnen, sonst stimmt es nur bei 100 %.
  **Beim einzigen Kind trägt keine Sammelleiste** (`:only-child` schaltet sie
  ab): Der Stiel aus dem Elternknoten sitzt bei 50 % der Gruppe, der zum Kind
  bei `--stem-x` — fallen die auseinander, riss die Linie ab (D29-Nachtrag 4).
  Die `:only-child`-Regeln ziehen deshalb ein Leiterstück zwischen beide und
  müssen **hinter** den first/last-Regeln stehen: Ein einziges Kind ist auch
  das letzte, und bei gleicher Spezifität gewinnt die spätere Regel — genau
  daran war `:only-child::after` jahrelang wirkungslos.
- `--vrail-ext` (D65): Im **vertikalen** Modus dockt der Eltern-Stub bei 50 %
  der Gruppenhöhe an, die Sammelleiste endet aber am 23-px-Abzweig des letzten
  Kindes — trägt das einen großen Teilbaum, liegt die Gruppenmitte darunter
  und der Stub hinge in der Luft. `alignVRails()` (läuft an denselben drei
  Stellen wie `alignStems()`) misst das und verlängert die Leiste per
  `--vrail-ext`; das **einzige** Kind löst CSS allein
  (`:only-child::after{top:23px;height:calc(50% - 23px)}` — dort ist
  Gruppenhöhe == Kindhöhe). Kein `border:0` mehr am nicht-has-and-Einzelkind:
  Genau das war die Lücke („kurze vertikale Linien fehlen"). has-and-Kinder
  brauchen beides nie (Abzweig bei 50 % der Zelle). Wer an den
  vertikalen first/last/only-Regeln dreht, prüft mit dem Geometrie-Scanner
  (Pseudo-Element-Rects: Leisten-Segmente vs. Stub-Höhe) statt mit dem Auge.
- Kleiner Bildschirm: `body.mobile` (per `matchMedia`, ≤ 640 px) zeigt **genau
  einen** Bereich — `body.pane-diagram` bzw. `body.pane-text`, umgeschaltet über
  je einen festen Knopf pro Titelzeile (`#paneToText`/`#paneToDiagram`,
  `setMobilePane()`); kein Splitter, keine Fenster-Buttons (D17-Nachtrag).
  **Nur der Umschalter darf den Bereich wechseln** — im alten Splitter-Modell
  kostete ein Tipp auf die Diagramm-Titelzeile (49-px-Streifen über dem Text)
  den Editor; genau das war der gemeldete Fehler (D17-Nachtrag 3). `#paneToDiagram`
  ruft `focusNodeOfCaret()`, wenn die Cursor-Zeile einen Knoten hat: Das ist die
  **einzige** Touch-Navigation Text → Diagramm (Alt gibt es dort nicht). Keinen
  langen Druck ins Textfeld legen — der gehört dem OS (Wortauswahl,
  Auswahlgriffe), anders als im Diagramm, wo D25 ihn sich nehmen konnte.
  **Die Titelzeile muss einzeilig bleiben** (`body.mobile .panel-head`:
  `gap:8px`, `padding-inline:10px`, `flex-wrap:nowrap` — D17-Nachtrag 5). Beim
  Prüfen eines neuen Kopf-Knopfes den **`#freshBtn` einblenden**: Er ist
  serienmäßig `hidden` und erscheint nur bei fremden Dokumenten mit Neuem —
  genau er brachte die Zeile zum Umbrechen, und ohne ihn sieht die Messung
  heil aus. Nicht die Knöpfe verkleinern (29 px sind Fingergröße), sondern die
  Lücken; bis 320 px reicht es gemessen.
- `--app-height` (`setAppHeight()`, D17-Nachtrag 4): `body{height:var(--app-height)}`
  kommt aus `visualViewport.height` — nötig gegen überlagernde Browserleisten
  (Brave). **Die Bildschirmtastatur verkleinert denselben Wert** und quetschte
  damit den Editor zusammen, sobald der Cursor ins Textfeld kam (Textfeldhöhe =
  `--app-height` − ~206 px feste Aufbauten; bei 260 px Viewport bleiben 54 px).
  Deshalb die Fokus-Sperre `editingNow()`: Solange ein editierbares Feld den
  Fokus hat, bleibt die letzte tastaturfreie Höhe stehen. Tastatur und
  Browserleiste sind an den **Zahlen** nicht zu unterscheiden (beide: `vv.height`
  fällt, `innerHeight` bleibt) — nur am Fokus. `orientationchange` ruft
  `setAppHeight(true)` und durchbricht die Sperre; der `focusout`-Timer muss
  `() => setAppHeight()` sein, sonst reicht er ein wahres `force` durch.
  **Nur auf echten Geräten sichtbar** — in der Emulation gibt es keine Tastatur.
  **Der verborgene Bereich ist `display:none` und misst sich damit zu null** —
  `setMobilePane()` MUSS neu zeichnen: zum Diagramm hin dieselben vier Schritte
  wie `applyLayout` (`applyOptStairs`/`alignStems`/`drawCheapPath`/
  `drawDepLinks`), zum Text hin `renderLineNos()`. Ohne das ist nach dem Tippen
  im Textbereich die Pfad-Linie weg (gemessen). Die Sprünge (D25) schalten den
  Bereich selbst um: `revealEditor()` auf Text, `focusNodeOfCaret()` aufs
  Diagramm — **vor** dem Zentrieren. Zustand in `werkbaum-ui` (`mobilePane`),
  Variable oben bei `padView` deklariert (saveUI liest sie).
  `applyLayout` ruft auf Mobil **kein** `applySplit`.
  **Inhalte ~25 % kleiner** (D17-Nachtrag 2): `MOBILE_ZOOM = 0.75` als **Faktor**
  auf den Nutzer-Zoom — `effZoom()` ist die Wahrheit, die `applyZoom()` setzt und
  die die drei Messstellen (`alignStems`, `drawCheapPath`, `drawDepLinks`)
  zurückrechnen; wer dort wieder `zoom` einsetzt, verschiebt Linien und
  Stationspunkte. `applyMobile()` ruft `applyZoom()` in **beiden** Zweigen, sonst
  bliebe der Faktor beim Wechsel auf Desktop stehen. Der **Text** bekommt kein
  `zoom`, sondern `font-size` (Streifen und Textfeld gemeinsam) — Spiegel und
  `ch`-Breite hängen an der Schriftgröße (D33). **`diagramToSvg()` stellt den
  Zoom für die Messung auf 1**: Die Schriftgrößen im Ausgabe-SVG sind feste
  Zahlen, die Kästen gemessen — bei Zoom ≠ 1 passt sonst beides nicht zusammen
  (galt schon vorher, wurde durch den Mobil-Faktor nur zum Regelfall).
  Dazu eigener Legenden-Umschalter
  (`#legendBtn`), schlanke Sprachwahl, Download-Overlay; Default Vollbild +
  Diagramm maximiert. Layout-CSS hängt an `body.mobile`, nicht an einer eigenen
  `@media`-Regel — beide Seiten müssen denselben 640-px-Schwellwert nutzen
  (SPEC §9, D17).
- **Update-Prüfung (D45):** verglichen wird der Commit aus dem
  Footer-Versionslink der **laufenden** Seite (`runningBuildId()`, DOM) gegen
  den aus der abgerufenen HTML (`buildIdFromHtml()`). Kein localStorage
  dazwischen — `werkbaum-update-available`/`werkbaum-html-hash` werden **nicht
  mehr geschrieben** (der Reset räumt sie nur noch weg). Wer hier etwas
  „merken" will: Genau das war der Fehler — ein gemerktes „Update verfügbar"
  überlebt das Neuladen, das es einspielt, und meldet dieselbe Fassung endlos
  weiter. Beim Laden wird deshalb **nichts** gemeldet; die Prüfung nach zwei
  Sekunden holt es nach. Zum Testen: Auf dem Dev-Server steht im Footer der
  Platzhalter `…/commit/main` (keine Commit-Kennung) — dort läuft der
  Hash-Rückfall, der Marker-Pfad ist nur zu prüfen, wenn man einen echten
  `commit/<sha>` in `index.html` einspritzt (danach zurücknehmen!) und
  `window.fetch` überschreibt.
- **PWA (D73):** `public/sw.js` fasst NUR die Navigation zur App-Wurzel an
  und beantwortet sie **network-first** — der Cache ist reiner
  Offline-Rückfall. Wer daraus cache-first macht, bricht die Update-Prüfung
  (D45): deren `fetch()` läuft nur deshalb ans echte Netz, und „Jetzt laden"
  bekommt nur deshalb die frische Fassung. Keine Registrierung im Dev-Server
  (`!import.meta.env.DEV` — sonst cacht der Worker die HMR-Seite). Manifest,
  Icons und `sw.js` sind public/-Assets neben der einen Datei; **jedes neue
  public/-Asset muss in BEIDE Deploy-Wege** (pages.yml und deploy-prod.sh
  stellen die Site je von Hand zusammen) und ggf. in `scripts/prod.htaccess`
  (Apache kennt `.webmanifest` nicht, D43-Falle). Der `launchQueue`-Empfänger
  (Dateidoppelklick der installierten App) reicht Handles an `adoptFile()`
  (D72) weiter — Installieren/Doppelklick sind nur auf echter Hardware
  prüfbar.
- **Neuigkeiten (D58):** `NEWS` kommt aus dem virtuellen Modul
  `virtual:werkbaum-news` (Vite-Plugin in `vite.config.js`), gefüllt zur
  **Bauzeit** aus `docs/CHANGELOG.md` (die Notizen) und der git-Historie des
  mitgelieferten Plans (die Knoten je Tag). Die Regeln stehen in
  `scripts/news.mjs` und sind getestet; `app.js` verdrahtet nur. Drei Dinge, die
  beim Anfassen auffallen: Der **Dev-Server liest einmal beim Start** — neue
  Changelog-Zeilen erscheinen erst nach einem Neustart, nicht per HMR. Ein
  vorgeführter Tag muss **nach** `switchDoc()` gesetzt werden, denn das räumt
  ihn ausdrücklich weg. Und die Backtick-Ersetzung läuft **nach** `esc()`, damit
  aus einer Changelog-Zeile kein Markup werden kann. Ein Feature ohne Zeile in
  `docs/CHANGELOG.md` geschieht für den Benutzer unsichtbar (Regel in der
  Wurzel-CLAUDE.md).
- **Titelzeilen tragen die Aufklapp-Menüs — kein `overflow` daran (D50):**
  `#docMenu` und `.dlmenu` hängen als absolut positionierte Kinder im
  `.panel-head`. Ein `overflow` dort macht ihn zum Scroll-Container und klippt
  sie; `overflow-x` hebt dabei das `visible` der **anderen** Achse auf `auto`,
  geklippt wird also nach unten — genau dorthin, wo die Menüs aufklappen. Ein
  Scroll-Container klippt unabhängig davon, ob er überläuft, der Fehler trifft
  also jede Breite. Er ist rein geometrisch **nicht** zu sehen: Kopfhöhe und
  Knopfpositionen bleiben korrekt. Prüfen heißt hier: Menü öffnen und mit
  `elementFromPoint` nachsehen, ob man es trifft.
- **Diagramm-Kopfzeile auf Mobil (D17-Nachtrag 5, D47, D50):** Die Zeile ist
  voll — bei 375 px bleiben mit neun Bedienelementen noch 14 px Luft, bei
  320 px passt es arithmetisch nicht mehr und die Zeile **bricht um**
  (`@media (max-width:360px)`, zwei Reihen zu 78 px). Wer ein zehntes Element
  hinzufügt, misst nach (`scrollWidth - clientWidth` am `.panel-head`) und
  rechnet damit, dass auf 375 px etwas weichen muss.
  Nicht anfassen: die 29 px Knopfgröße (Fingerziel) und `flex:0 0 auto` an den
  Kindern — ohne das schrumpft bei Platzmangel ausgerechnet der Modus-Wähler
  (das einzige Element ohne feste Größe) auf einen 2-px-Strich, während sein
  Icon darüber hinausragt. Das sieht aus wie ein Trennstrich und ist
  unbenutzbar.
- **Die drei Touch-Gesten am Knoten (D25, D38, D52)** teilen sich `touchstart`/
  `touchend` und werden am vorhandenen Zustand unterschieden: `armedEl` gesetzt
  (500 ms um) ⇒ Sprung, Timer läuft noch ⇒ kurzer Tipp ⇒ Knoten-Fenster, beides
  weg (jedes `touchmove` räumt auf) ⇒ es wurde gescrollt ⇒ nichts. Kein eigenes
  Merkerfeld nötig. Wer hier eine vierte Geste einhängt, denkt an das
  **Falt-Zeichen**: Es ist ausgenommen, weil das Fenster `preventDefault()`
  braucht (sonst öffnet ein Link-Knoten zusätzlich seine URL) — und das
  verschluckt den Klick, mit dem gefaltet wird.
- **Das Knoten-Fenster liegt auf `<body>`, nicht in `#out` (D52):** In `#out`
  erbte es dessen CSS-`zoom` und würde von dessen `overflow` beschnitten (D50).
  Es schließt bei allem, was seine Aussage hinfällig macht — Tipp daneben,
  zweiter Tipp, Esc, ×, Diagramm-Scroll, Bereichswechsel, Sprung in den Text,
  `render()`. Die letzten drei sind beim Bauen zuerst vergessen worden.
- **Nie `src.value = …` während des Bearbeitens (D53).** Es löscht die
  Undo-Historie des Textfelds **komplett** — nicht nur den eigenen Schritt,
  sondern alles davor Getippte. Gemessen: nach so einem Schreiben ändert das
  erste `undo` nichts und das zweite liefert `false`. Jede Änderung am Text des
  **aktuellen** Dokuments geht deshalb über `execCommand('insertText')`
  (`replaceTextUndoable()` für ganze Texte, `writeAt()` für Bereiche).
  `src.value =` ist nur beim **Laden eines anderen** Dokuments richtig
  (`loadActiveIntoEditor`, Dokumentwechsel, Pad-Abruf) — dorthin gibt es nichts
  zurückzunehmen.
- **ID-Vorschläge `:#` (D63):** Die Regeln stehen headless in `autocomplete.js`
  (`depFragment`/`collectIds`/`matchIds`, Tests); app.js verdrahtet nur Popup,
  Tasten, Einfügen (`writeAt`, undo-fähig). Daneben liegen `depIdAt`/`idLine`
  (D67): Strg/Cmd+Klick bzw. Strg+Enter im Textfeld springt von einer
  Abhängigkeits-ID zur Zeile ihrer ersten Vergabe — die Schreibmarke ist der
  Treffer (`selectionStart`), kein eigenes Hit-Testing. Ein synthetischer
  Strg+Enter der Automatisierung kommt mit `e.key === ""` an (Werkzeuggrenze
  wie beim synthetischen Strg+Z) — mit korrekt gebautem `KeyboardEvent`
  prüfen. Der Tasten-Handler hängt an
  `document` in der **Capture-Phase** — die Textfeld-Handler (Tab rückt ein,
  Esc löst die Tab-Falle, D53) sind früher registriert und kämen sonst zuerst;
  `stopPropagation` hält sie nur bei **offener** Liste heraus. `acSuppress`
  hält denselben Kontext nach Übernahme/Esc geschlossen — ohne das öffnet ihn
  das nächste keyup sofort wieder. Popup auf `<body>` mit `position:fixed`
  (Klipp-Falle D50, wie das Knoten-Fenster).
- **Undo lässt sich hier nicht per Tastendruck prüfen.** Ein synthetisches
  `ctrl+z` aus der Automatisierung löst **kein** natives Undo aus (gemessen:
  Text unverändert), während `document.execCommand('undo')` im selben Moment
  greift. Wer Undo prüft, nimmt `execCommand('undo')` — sonst hält man eine
  Werkzeuggrenze für einen Befund. Dieselbe Lehre wie D25 und D17-Nachtrag 4.
