Files
werkbaum/frontend/CLAUDE.md
T
mhoennigandClaude Opus 5 ea1979d567 feat(frontend): "Auf den Server legen" im Dokumenten-Menue (D76-Nachtrag 8)
Bis hierher kam ein Plan nur per curl auf den Server — der Menueintrag war im
Konzept vorgesehen und fehlte. Jetzt legt der Knopf das aktive Dokument an,
schaltet dorthin um und schreibt den Link in die Adresszeile und in die
Zwischenablage. Die Adresszeile IST der Link: dort sucht man ihn, und ein
Neuladen fuehrt ins selbe Dokument zurueck.

Die Basis-Adresse ist die eigene Herkunft — produktiv liegt das Backend hinter
derselben Domain, wer nichts konfiguriert bekommt also das Richtige. Darueber
liegen ?server= (Entwicklung) und die Adresse des offenen Server-Dokuments.
Traegt nichts, wird gefragt.

Das lokale Dokument bleibt: Wer sein einziges Exemplar einem Server
anvertraut, soll es nicht im selben Zug verlieren. Dafuer nennt der Waehler
jetzt den Host neben Server-Dokumenten — im Test standen sonst zwei Eintraege
"Nur lokal" da, unterscheidbar nur am Tooltip. Und der Knopf verschwindet bei
Dokumenten, die schon auf einem Server liegen.

Geprueft im Browser gegen ein echtes Backend: Knopf sichtbar bei einem lokalen
Dokument, nach dem Klick steht ?live=... in der Adresszeile, das lokale
Dokument ist noch da, der Knopf verborgen — und eine getippte Zeile erreicht
den Server als Version 2.

523 Frontend-Tests (5 neue fuer serverBase/documentsUrl).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:39:28 +02:00

48 KiB
Raw Blame History

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 buildvite-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), live.js (Server-Dokumente: Adressen, Zeilen-Diff, Cursor-Rechnung, wann eine Feed-Antwort angewendet werden darf — D76), 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.
  • Server-Dokumente (?live=, D76): live.js hält die entscheidbare Hälfte (Adressen, Zeilen-Diff, Rebasen, Cursor-Rechnung, Feed-Regel), app.js die Verdrahtung: loadLive() holt Text und Version und merkt beides als Schattenkopie, scheduleLivePush() schickt nach 1,5 s Ruhe das Diff, runFeed() hält einen Abruf offen (nur im sichtbaren Tab), putOnServer() legt das aktive Dokument an („Auf den Server legen"). Die Basis-Adresse bestimmt serverBase() (live.js): ?server= vor dem offenen Dokument vor der eigenen Herkunft — die Vorgabe stimmt damit ohne Konfiguration, weil das Backend produktiv hinter derselben Domain liegt. Zwei Fallen, beide im Live-Test gefunden (D76-Nachtrag 7): clientId und seq gehören in den sessionStorage (je Tab, überlebt Neuladen — sonst hält der Server die erste Änderung nach einem Reload für eine Wiederholung und tut nichts), und der Konflikt entsteht beim Tippen, nicht erst beim Senden — der Server kennt den ungesendeten Text nicht. Fremde Änderungen werden bewusst nicht undo-fähig eingespielt.
  • Zustand wird im localStorage gehalten (Server-Dokumente ausgenommen): 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 TouchEvents 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 nachrechnensetFoldMark() 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 nullsetMobilePane() 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.