Der mit der Cursor-Zeile geteilte Ring war zu leise — „das erkennt man zu wenig" (Nutzer). Damit fällt auch die in D32 als Preis notierte Zweideutigkeit weg: Sie war das Zugeständnis für die Sparsamkeit, und wenn die Sparsamkeit nicht funktioniert, ist das Zugeständnis nicht mehr zu rechtfertigen. Gebaut wie der gelbe Kranz aus D28 (Schein nach außen, Füllung bleibt dem Status, kein Blinken, `z-index:2` gegen das Abschneiden durch später gemalte Geschwister) — aber in PETROL, nicht in Gelb. Die Frage stand ausdrücklich im Raum; die Antwort ist nein, weil die beiden Verschiedenes bedeuten und gleichzeitig auftreten: Gelb ist eine stehende Tatsache über den Plan („seit deinem letzten Besuch live gegangen"), Petrol ein Zuruf („schau jetzt hierhin"). Im Pad-Betrieb ist die Kombination der Normalfall — man zeigt auf das, was sich geändert hat. Wären beide gelb, könnte ein Knoten nicht beides sagen. Der Schein ist helles Teal (#14B8A6), der Ring dunkles Petrol. Die erste Fassung nahm für beides `--or` und wirkte trotz identischer Geometrie leiser als das Gelb. Der Grund ist nicht Geschmack: Ein dunkler Schein auf weißem Grund liest sich als Schatten, ein heller als Licht. Gegen die Stationspunkte des günstigsten Pfads (D18) geprüft statt angenommen — zwei Marken gezielt auf Pfad-Endknoten (mit Punkt), eine daneben (ohne): Der Kranz sitzt eng am Knoten und hat einen harten Ring, der Stationspunkt ist ein großer blasser Fleck ohne Rand. Verwechseln kann man das nicht. Kombinationen ausbuchstabiert, weil sie real vorkommen: `focusmark.current` (Tinte innen, Teal außen) und `fresh.focusmark` (Petrol-Ring innen, Gelb außen — zwei Scheine nach außen gehen nicht). Letztere steht nach der ersten: gleiche Spezifität, und bei allen drei sollen Zuruf und Neuheit gewinnen. SPEC §9 und D32 nachgezogen.
21 KiB
21 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 (sonst kollidiert
@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).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/example-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: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). 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. - 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. - 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) stapelt Diagramm/Editor mit stufenlosem Splitter (kein Snap/Collapse wie auf Desktop): der Gutter-Drag ruftsetMobileDrow()(klemmt--drowzwischen den Kopfhöhen--pmin-d/--pmin-e, persyncPanelMins()gemessen), ein Tipp auf eine Titelzeile maximiert das Panel.applyLayoutruft auf Mobil keinapplySplit(das würde--drowlöschen). 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).