Der Hinweis erschien oft für genau die Fassung, die gerade geladen war. Zwei Fehler mit derselben Wurzel — die Prüfung verglich nie mit dem laufenden Stand: - Relais statt Vergleich: der abgerufene Inhalts-Hash wurde gegen den ZULETZT ABGERUFENEN gehalten (localStorage), nicht gegen den laufenden. Beim Deploy antworten CDN-Knoten unterschiedlich; jeder Wechsel schlug an, und der Schlüssel wird von allen Tabs geteilt. - Klebendes Flag: `werkbaum-update-available` entfernte nur „Jetzt laden". Nach F5 oder „Später" blieb es stehen, und der Load-Check zeigte das Banner bei jedem weiteren Laden erneut. Verglichen wird jetzt der Commit aus dem Footer-Versionslink der laufenden Seite gegen den der abgerufenen HTML (D16 spritzt ihn in beiden Deploy-Wegen ein). Kein localStorage dazwischen; beim Laden wird nichts gemeldet, die Prüfung zwei Sekunden später holt es nach. Eine spätere „aktuell"-Meldung sammelt Banner und Footer-Symbol wieder ein. Rückfall auf den Inhalts-Hash für Builds ohne Marker (Dev-Server), Vergleichsstand dort der erste Abruf der Seiten-Sitzung. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
37 KiB
37 KiB
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 vonfrontend/index.htmlperfile://funktioniert nicht mehr (ES-importbraucht http); stattdessen Dev-Server oder die gebaute Datei.npm --prefix frontend run build—vite-plugin-singlefileinlint JS + CSS + Favicon (alsdata:-URI, viatransformIndexHtml-Plugin invite.config.js) in eine self-containeddist/index.html— die bleibtfile://-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-Modusprod,.env.prodsetztVITE_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()inapp.js(D16).node_modules/unddist/sind ge-.gitignore-t;.env.produndpackage-lock.jsonsind eingecheckt (der Workflow nutztnpm 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.jsist der DOM-/UI-Einstieg; reine Logik gehört inparser.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-faceinstyle.css), nicht von Google Fonts geladen — kein externer Request, keine IP an Dritte (Datenschutz, D20). Keinengoogleapis-<link>wieder einführen. Neue Schnitte:woff2pernpm pack @fontsource/…beziehen (keine Projekt-Abhängigkeit, Dateien einchecken) und@font-faceergä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. 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-CSSul.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 gegenSTATUS_BY_CODE; unbekannte Codes →parse().warningsals{type:'unknownStatus', line, code}, Knoten neutral.render()in app.js führt Parser- und Renderer-Warnungen zusammen (nach Zeile sortiert) und zeigt sie viaformatWarning(warnings.js). Neue Warnungstypen dort + i18n-Key in allen 9 Sprachen ergänzen. - Modulteilung (D19):
parser.js(Text→Baum, headless),model.js(Baum-/ Kostenlogik:gateOf,needsBreakdown,visibleChildren(n, showDiscarded),computeCheapSet,cheapCls),render.js(HTML-String viarenderTreeHtml(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; nurcheapPathOnlebt als UI-State inapp.js. Tests:tests/*.test.js. - Günstigster Pfad:
markCheapest()/cheapestCost()(inmodel.js) markieren die nötigen Knoten (Klassencheap,cheap-leaf);drawCheapPath()(app.js) zeichnet nach jedemrender()und nachapplyLayout()zwei Overlay-SVGs in#out(hinten kräftige Linie, vorne abgetönte Kopie + Stationspunkte). Overlays erben den CSS-zoomvon#out, Punkte in unskalierte#out-Koordinaten umrechnen (/zoom).diagramToSvg()zeichnet dieselbe Linie/Punkte nach (SPEC §9, D18). - 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.jsbaut je Knoten einen sprechendenaria-label(Label + Status + Aufwand + Zuständige + Link, lokalisiert viat); die visuellen Badges (Größe, Tags, ↗) sindaria-hidden. Neue Knoten-Eigenschaften dort innodeAria()mitpflegen und dafür a11y-i18n-Keys (a11y*) in allen 9 Sprachen anlegen. Knoten sindtabindex="0"(Fokus = Lesereihenfolge),#warnist eine Live-Region. - Zustand wird im
localStoragegehalten (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 insaveUI()/restoreState()mitführen;saveUIliefert währendrestoring===truenichts, damit das Wiederherstellen nicht sofort zurückschreibt. - Dokumente (D22): mehrere umschaltbare Notationstexte.
loadDocs()migriert bei fehlendemwerkbaum-docsden bestehendenwerkbaum-src(oderINITIAL) in ein Dokument;initDocs()(Aufruf nachapplyLang) 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 inswitchDoc/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 NameEXAMPLE_NAME = 'Example'(nicht lokalisiert).loadDocs()adoptiert einen Alt-Zustand (zufällige id, „Beispiel") nur, wenn dessentext === 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 (idexample) aufINITIAL/„Example" zurück und verwirftwerkbaum-ui/werkbaum-lang/Update-Flags — andere Dokumente bleiben stehen (nicht mehr pauschal allewerkbaum-*löschen!). Das letzte gelöschte Dokument wird als Beispiel neu gesät. - Mitgeliefertes Dokument „Werkbaum" (D27):
app.jsimportiert../../docs/examples/werkbaum.werkbaum?raw— die Beispieldatei ist damit Build-Eingabe, Umbenennen/Verschieben bricht den Build (Zugriff außerhalb des Roots erlaubtserver.fs.allow:['..']).seedShippedDocs()legt es einmalig an (auch für Bestandsnutzer); ohne den Merkerwerkbaum-seededkä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()setztrenamingId,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 mitid: 'url:<href>',name/source= URL. Nurhttp(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}insourceWarning— ein persistenter Kanal, denrender()jedem Warnungs-Satz voranstellt (überlebt Neu-Renderings, bis das Laden gelingt). Hauptstolperfalle ist CORS: fremde Hosts brauchenAccess-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 demdata-i18n-title-Durchlauf inapplyLang()laufen. Weder Endung nochContent-Typewerden geprüft (response.text()); die Endung.werkbaumist 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 inremote.js(padUrls, Tests intests/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).padSourcemuss vor dem ersten Abruf und vorloadActiveIntoEditor()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: EtherpadsimportExportRateLimitinglässt serienmäßig 10 Abrufe je 90 s und IP zu, und jenseits davon hält die Gegenseite die Verbindung ohne Antwort offen (kein429), 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 auchsetPadBusy()(Symbol dreht, bis zu 20 s) und ein eigener WarnungstypsourceTimeoutfür den Abbruch —sourceLoadzeigt auf CORS und schickte hier auf die falsche Fährte.deleteDoc()ruftstopPad(), 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 aufwindowsetzen und hinterher prüfen, sonst beweist der Test nichts.- Pad einbetten, drei Ansichten (D31):
padView('both'|'pad'|'text') +applyPadView(). Der Rahmen bekommt nur sichtbar einsrc, sonstabout:blank— ein geladenes Pad verbindet sich per Socket und zeigt dich in dessen Anwesenden-Liste.PAD_VIEWS/padViewstehen oben beicheapPathOn, nicht beim übrigen Pad-Code:saveUI()liest sie und kann schon ausapplySplit()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:#srcAreaumschließt Rahmen + Splitter +#src, damit die Legenden-Aufteilung unberührt bleibt —.editor-body textareaist ein Nachfahren-Selektor und greift weiter. Nebenwirkung, die als Behelf taugt: Einmal durch die Ansichten schalten lädt den Rahmen neu (überabout: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#srcAreaherum neu aufbaut, wirft die Pad-Sitzung weg. - Fokusmarke
!!!(D32, SPEC §1): Parser setztfocus, Renderer die Klassefocusmark, CSS gibt ihr einen eigenen Strahlenkranz in Petrol (Nachtrag zu D32; der geteilte Ring mit.currentwar 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.freshbraucht die Regelz-index:2, sonst schneidet ein später gemaltes Geschwister den Kranz ab (D28). Die Kombinationenfocusmark.currentundfresh.focusmarksind ausbuchstabiert; letztere steht bewusst danach (gleiche Spezifität). Erkannt nur alleinstehend —(^|\s)!!!(?=\s|$), bewusst ohne Lookbehind (Safari erst ab 16.4), sonst verlöreAchtung!!!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.jsschreibt die Parser-Zeilennummer alsdata-linean jeden Knoten (Geister-Knoten bekommen keine).jumpToLine()inapp.jsklappt 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 musspreventDefault()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()— erstdata-line, sonstdata-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 Klassecurrentauf 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 (Klasseexportingan#out, am Ende wieder ab): Der Export zieht die Live-Geometrie pergetBoundingClientRect()nach, anders als beimbox-shadowschlägt die Vergrößerung dort durch. Der Puls (pulse, Hüpfer + Ring auf dem freien.node::after) wird nur bei geändertemcaretLinegesetzt — sonst pulst es bei jedem Tastendruck — und braucht das Lesen vonoffsetWidthals Neustart-Trick, weil dieselbe Aufgabe die Klasse vorher entfernt. Nicht überbox-shadowanimieren: 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 Klassealtan#out(Cursor + Ring am Knoten unter dem Zeiger) — derblur-Handler ist Pflicht, sonst bleibt der Modus nach Alt+Tab hängen; dazujumpHintim Knoten-Tooltip undhint_jumpals 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 ParameterhighlightCurrentNode(moved, scroll):'nearest'beim gewöhnlichen Zeilenwechsel (syncCaret()),'center'hier,falsebeim Neubau ausrender(). Nicht wiederfalsefür den Puls mitgeben — dann kommt die ausdrückliche Geste stiller an als das Tippen. Beim Tastaturweg (Alt+Enter) istpreventDefault()Pflicht, sonst bekommt der Text einen Umbruch. Die Legenden-Zeilehint_jumpnennt beide Richtungen — kein eigener i18n-Schlüssel. - Zeilennummern (D33):
renderLineNos()misst die Zeilenoberkanten am Spiegel-div(lineTops()) — nichtZeilenhöhe × n, das läuft beim ersten weichen Umbruch davon. Der Streifen scrollt nicht selbst, sondern wird pertranslateY(-src.scrollTop)mitgezogen (syncLineNoScroll()); wersrc.scrollTopselbst setzt, muss ihn gleich mitziehen — dasscroll-Ereignis kommt erst im nächsten Bild (scrollEditorToOffset()tut es). Der Spiegel brauchtbox-sizing:border-box:src.clientWidthenthä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 ausrender()(dort kommt auch die Warnungsmenge her) und aus einemResizeObserverauf#src(Umbruch hängt an der Breite). Markup:#srcWrapumschließt Streifen + Textfeld; die Pad-Regeln (D31) teilen gegen diese Spalte (.src-area.pv-both>.src-wrap), nicht gegen das nackte Textfeld.caretLinesteht deshalb oben beipadView— der Streifen liest sie und hängt anrender()(temporale Todeszone, siehe D31). - Touch-Langdruck (D25): Der Timer (500 ms) setzt nur die Klasse
armed(Petrol-Ring);jumpToLine()läuft imtouchend-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-preventDefaultwährend des Drucks,user-select:noneper@media (hover:none) and (pointer:coarse)und-webkit-touch-callout:none(nur iOS). SynthetischeTouchEvents prüfen davon nichts — nur die eigene Ereignis-Logik. - Bildschirmtastatur (D25):
jumpToLine()setzt vor dem Fokusinputmode="none"(keyboardOnJump(true)) — der Sprung ist „hinschauen". Derpointerdown- Handler am Textfeld hebt das wieder auf (läuft vor dem Fokus). Wer sonst irgendwosrc.focus()ergänzt und dabei Tippen meint, muss vorherkeyboardOnJump(false)rufen — so wienewDoc(). - Legende (D26): kein
<details>— Chrome legt Details-Inhalt in::details-content, dadurch war.hintkein Flex-Kind mehr und ließ sich nicht begrenzen (Inhalt geclippt statt scrollbar). Jetztdiv.agenda+button.agenda-summary, Zustand an der Klasseopen(setAgendaOpen()hält Klasse,aria-expanded,#legendBtnund die Sichtbarkeit des Splitters zusammen). Wer hier wieder ein natives Aufklapp-Element einsetzt, bricht das Scrollen. Der Splitter#hintGutterschreibt--hcol(nebeneinander) bzw.--hrow(gestapelt:sideoder mobil) an#app; beide werden getrennt inwerkbaum-uigesichert. Die 85-%-Obergrenze steht zusätzlich alsmax-width/max-heightim 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)inmodel.jsliefert die Knoten, die neu[^]sind (Identität = Label-Pfad, nicht Zeile).render()bildet die Menge bei jedem Durchlauf neu aus den gerade geparstenroots— 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 nurfreshPrevRoots(Basis, einmal geparst). Basis je Dokument inwerkbaum-seen, fortgeschrieben erst beim Bestätigen über#freshBtn. - Faltmarken
>/<(SPEC §1/§9, D38): Parser setzt nurfold('>'|'<'|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 —, abersetFoldMark()schreibt immer die neue und löst eine alte dabei auf. Wer die Gruppen anfasst: Der Rest der Zeile istm[6], nichtm[5]. Den wirksamen Anfangszustand rechnetinitialCollapsed()in model.js —<(und die Fokusmarke) wandert die Faltung die Pfad-Ebenen hinunter statt Vorfahren bloß zu öffnen.render()überlagert ihn mitfoldOverrides(Schlüssel = Label-Pfad vianodeKeys(), Sitzung, beim Dokumentwechsel geleert) und übergibtcollapsedSetan 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 fragthidesCheap()— 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 alscheap.extraCls()muss dieselbe Eingeklappt-Bedingung bilden wieitemHtml(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 einenz-index(D25-Nachtrag 3):.cheap-front{z-index:5},.dep-front{z-index:4}— sonst deckt jeder Knoten mit eigener Stapelposition (current3,fresh/focusmark2) 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, danninitialCollapsed()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 mitexecCommand('insertText'):value =undsetRangeTextzerstören die Undo-Historie (gemessen). Zwei Fallen: Ein Textfeld mitdisplay:none-Vorfahr nimmt keinexecCommandan (Klassewriting-foldschaltet es kurz sichtbar), und der nötige Fokus zieht auf Mobil die Tastatur hoch (keyboardOnJump(true)). Beisrc.readOnly(Pad, D31) wird nicht geschrieben, dort trägt weiterfoldOverrides— 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), dannwriteAllFoldMarks()— der auswriteFoldToText()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). Seinaria-pressedwird inrender()am Baum abgelesen, nicht gemerkt und nicht persistiert — sonst behauptete er nach einem einzelnen Umklappen etwas Falsches.replaceTextUndoable()meldet bei unveränderten Textfalse(D38-Nachtrag 3): Ohne Textänderung feuert keininput, also läuft keinrender()— 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; nachrender()den Fokus perdata-linewiederherstellen. Export/Druck: „▸ n" bleibt, das ▾ offener Knoten fällt weg (stripFoldindiagramToSvg, 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) unda11yId. Ü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#authund#auth:zwei Adressen. Geschluckt nur mit folgendem Leerraum oder Zeilenende, sonst bliebe von#auth:#dbdie 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.depssind 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) unda11yDeps. - Querverbindungen (SPEC §9, D41):
drawDepLinks()zeichnet die Abhängigkeits-Kanten als Overlay-SVGs (wiedrawCheapPath,/zoomumrechnen) aus den Renderer-Attributendata-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 aushighlightCurrentNode()(läuft in jedemrender()),applyLayout()und den focusin/focusout-Handlern — NICHT doppelt inrender()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 dannst-<effKey>statt des intrinsischen und hängt die Marke.chip.ownst.st-<ownKey>(eigene Statusbox, eigene Farben) unten links an — im Export viadrawBadge, 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 setzttype:'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 —.xorergänzt nur die „1"-Plakette (ul.xor::after, im ExportxorMarks). Disjunktiv-Abfragen aufgateOfprüfen!== 'and', nie=== 'or'. DiexorConflict-Warnung (mehr als eine realisierte Alternative:[~]/[/]/[x]/[^]) entsteht im Parser (Post-Pass), nicht im Renderer. - Optionale Knoten
+(D29): Der Parser setztoptional:trueund lässttype:'and'—+gehört zum Knoten, nicht zur Gruppe. Deshalb bleibengateOf()und diemixedGate-Warnung unverändert richtig (sie meldet nur|neben-/+). Aus dem günstigsten Pfad fallen optionale Knoten überpathChildren()heraus — die eine Stelle, diecheapestCost()undmarkCheapest()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ürul.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 Klasseopt, denn den Abzweig zeichnen dessen Pseudoelemente. Gestrichelt wird nur die Kante zum Knoten, nie die Sammelleiste — im Fächerborder-left/-right, gestapeltborder-top. - Treppe optionaler Endknoten (D29, Nachtrag 3):
applyOptStairs()gruppiert in app.js, nicht im Renderer — die DOM-Ebeneli.opt-group > ul.opt-stairgibt 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,applyLayoutarbeitet auf dem bereits gruppierten Baum). Reihenfolge:applyOptStairs()voralignStems()/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-orist aber linksbündig und die Zelle so breit wie der any-of-Teilbaum.alignStems()misst deshalb nach jedemrender()(und inapplyLayout) die Knotenmitte und setzt--stem-x; Fallback im CSS ist50%. Messwerte durchzoomzurückrechnen, sonst stimmt es nur bei 100 %.- Kleiner Bildschirm:
body.mobile(permatchMedia, ≤ 640 px) zeigt genau einen Bereich —body.pane-diagrambzw.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).#paneToDiagramruftfocusNodeOfCaret(), 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#freshBtneinblenden: Er ist serienmäßighiddenund 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 ausvisualViewport.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-SperreeditingNow(): 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.heightfällt,innerHeightbleibt) — nur am Fokus.orientationchangeruftsetAppHeight(true)und durchbricht die Sperre; derfocusout-Timer muss() => setAppHeight()sein, sonst reicht er ein wahresforcedurch. Nur auf echten Geräten sichtbar — in der Emulation gibt es keine Tastatur. Der verborgene Bereich istdisplay:noneund misst sich damit zu null —setMobilePane()MUSS neu zeichnen: zum Diagramm hin dieselben vier Schritte wieapplyLayout(applyOptStairs/alignStems/drawCheapPath/drawDepLinks), zum Text hinrenderLineNos(). 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 inwerkbaum-ui(mobilePane), Variable oben beipadViewdeklariert (saveUI liest sie).applyLayoutruft auf Mobil keinapplySplit. Inhalte ~25 % kleiner (D17-Nachtrag 2):MOBILE_ZOOM = 0.75als Faktor auf den Nutzer-Zoom —effZoom()ist die Wahrheit, dieapplyZoom()setzt und die die drei Messstellen (alignStems,drawCheapPath,drawDepLinks) zurückrechnen; wer dort wiederzoomeinsetzt, verschiebt Linien und Stationspunkte.applyMobile()ruftapplyZoom()in beiden Zweigen, sonst bliebe der Faktor beim Wechsel auf Desktop stehen. Der Text bekommt keinzoom, sondernfont-size(Streifen und Textfeld gemeinsam) — Spiegel undch-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 anbody.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-hashwerden 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 echtencommit/<sha>inindex.htmleinspritzt (danach zurücknehmen!) undwindow.fetchüberschreibt.