Im horizontalen Fächer kostet jedes optionale Geschwister eine eigene Spalte — Breite für gerade das, was am entbehrlichsten ist. Zwei oder mehr aufeinanderfolgende optionale ENDknoten werden deshalb als Kaskade gestapelt: erste Stufe am Stiel von oben wie ein gewöhnliches Kind, jede weitere eine Stufe tiefer und weiter rechts, an einem gestrichelten Winkel, der an der linken Kante der vorigen Stufe herabfällt und waagerecht in ihren Kreis einbiegt. Verworfen: die naheliegendere SENKRECHTE Spalte unter einem Anschlusspunkt. Schmaler und in einem Bruchteil der Zeit gebaut — sähe aber fast genau aus wie eine any-of-Gruppe (gestapelte Spalte an gestrichelter Leiste), unterschieden nur durch Tinte statt Grau. Genau diese Verwechslung zu vermeiden ist der Zweck von Kreis und Farbgebung. Ebenfalls verworfen: eine echte Diagonale — Rahmenkanten sind achsenparallel, sie bräuchte die SVG-Ebene und wäre eine zweite Zeichenebene neben allen anderen Linien, nachzuführen bei jedem Rendern, Moduswechsel und Zoom. Rechte Winkel geben denselben Kaskaden-Eindruck im vorhandenen Mechanismus. - Nur Endknoten: Der Platzgewinn entsteht gerade daraus, dass kein Teilbaum mitgestapelt wird — ein optionaler Knoten MIT Kindern spart nichts und behält seine Spalte. Technisch dasselbe: die Stufengeometrie setzt Zellenhöhe == Knotenhöhe voraus. Geprüft wird genau das (ein Element-Kind, und das ist der Knoten) — schließt auch den Geister-Knoten aus. - Gruppiert wird in app.js, nicht im Renderer. Die DOM-Ebene gibt es semantisch nicht und müsste in den drei übrigen Anordnungen (vertikal, kompakt, all-of unter any-of) wieder neutralisiert werden, jede mit hand-getunter Geometrie. `display:contents` löst das nicht: richtet die Boxen, aber die >-Selektoren jener Regeln greifen weiter auf dem DOM. applyOptStairs() baut die Gruppe nur im Fächer und löst sie beim Moduswechsel auf — dieselbe Kategorie wie alignStems() und drawCheapPath(); SPEC §9 bleibt für den RENDERER wörtlich wahr, Lese- und Fokusreihenfolge unberührt. - Der Export folgt der Kaskade. Erste Fassung reihte alle Stufen flach als Kinder ein und ließ den Export selbst routen — die Linie zur dritten Stufe lief dann HINTER der zweiten hindurch und las sich wie eine Eltern-Kind- Beziehung. Keine Schönheitsfrage, sondern eine falsche Aussage über die Struktur. Jetzt hängt nur die erste Stufe an der Sammelleiste. Nicht durch Tests gedeckt: applyOptStairs() arbeitet auf dem DOM, und für app.js gibt es keine Testumgebung (die Suite prüft die headless-Module). Verifiziert im Browser: Kaskade in horizontal (drei Stufen, Winkel treffen die Kreise), Moduswechsel horizontal↔kompakt↔vertikal mehrfach hin und zurück — Gruppe entsteht und löst sich rückstandsfrei auf, Knotenzahl (9) und Dokumentordnung in allen drei Modi unverändert, keine übrig gebliebenen --i-Variablen. SVG-Export gerendert geprüft: drei Kreise, Winkelkette statt kreuzender Linien. Vitest 60/60 (Renderer unverändert). Im mitgelieferten Werkbaum-Plan stehen die beiden Editor-Zugaben jetzt nebeneinander und zeigen die Treppe. Reihenfolgeänderung ist für „Was ist neu?" folgenlos — die Knoten-Identität ist der Label-Pfad (D28), test-abgedeckt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
228 lines
16 KiB
Markdown
228 lines
16 KiB
Markdown
# Werkbaum · Frontend
|
||
|
||
Editor: Text links, Diagramm rechts, Toggles für transponierte Ansicht und
|
||
verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
||
**Vite-Entry** (lädt im Dev-Server `src/app.js` als `<script type="module">`).
|
||
|
||
## Build & Entwicklung (D19)
|
||
- **Vite** ist Bündler + Testrunner (nur Dev-Abhängigkeiten, keine Laufzeit-
|
||
Abhängigkeit — das Ergebnis ist framework-freies HTML/CSS/JS).
|
||
- `npm --prefix frontend run dev` — Dev-Server (Port 8137, `.claude/launch.json`).
|
||
Direktes Öffnen von `frontend/index.html` per `file://` funktioniert **nicht**
|
||
mehr (ES-`import` braucht http); stattdessen Dev-Server oder die gebaute Datei.
|
||
- `npm --prefix frontend run build` — `vite-plugin-singlefile` inlint JS + CSS +
|
||
Favicon (als `data:`-URI, via `transformIndexHtml`-Plugin in `vite.config.js`)
|
||
in **eine** self-contained `dist/index.html` — die bleibt `file://`-tauglich
|
||
(D16) und ist die Deploy-Artefakt-Quelle (Pages-Workflow, siehe README).
|
||
- `npm --prefix frontend test` — Vitest (`tests/**/*.test.js`).
|
||
- `npm --prefix frontend run build:prod` — Produktions-Build **ohne** den
|
||
Build-Hinweis hinter dem Titel (Vite-Modus `prod`, `.env.prod` setzt
|
||
`VITE_BUILD_BADGE=none`). Nur die echte produktive Installation nutzt diesen
|
||
Weg; Dev-Server (🔧) und Default-`build` (🚧, u. a. Pages-Deploy) zeigen den
|
||
Hinweis. Logik: `mountBuildBadge()` in `app.js` (D16).
|
||
- `node_modules/` und `dist/` sind ge-`.gitignore`-t; `.env.prod` und
|
||
`package-lock.json` sind eingecheckt (der Workflow nutzt `npm ci`).
|
||
|
||
## Konventionen
|
||
- Vanilla HTML/CSS/JS, ES-Module; keine Frameworks. Testwerkzeug: Vitest.
|
||
- Parser und Renderer müssen headless (ohne Editor-UI) nutzbar bleiben —
|
||
Basis für SVG-Export und Mermaid-Plugin (docs/ROADMAP.md). `app.js` ist der
|
||
DOM-/UI-Einstieg; reine Logik gehört in `parser.js`/`model.js`/`render.js`.
|
||
- Design: Farben/Typografie beibehalten (CSS-Variablen, IBM Plex);
|
||
Statusfarben sind in SPEC §4 normiert. Marke nach ../brand/BRAND.md;
|
||
Pastelltöne nie im Logo.
|
||
- IBM Plex ist **lokal eingebettet** (`src/fonts/*.woff2`, `@font-face` in
|
||
`style.css`), **nicht** von Google Fonts geladen — kein externer Request,
|
||
keine IP an Dritte (Datenschutz, D20). Keinen `googleapis`-`<link>` wieder
|
||
einführen. Neue Schnitte: `woff2` per `npm pack @fontsource/…` beziehen (keine
|
||
Projekt-Abhängigkeit, Dateien einchecken) und `@font-face` ergänzen.
|
||
|
||
## Stolperfallen
|
||
- Abzweig-Linien zielen auf die **Knotenmitte** (fester 23-px-Offset,
|
||
`line-height: 1.3`), nicht auf die Mitte des Teilbaums — bei Layout-
|
||
Änderungen alle drei Modi (horizontal/vertikal/kompakt) prüfen.
|
||
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 (sonst kollidiert `@` 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.
|
||
- 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`.
|
||
- Günstigster Pfad: `markCheapest()`/`cheapestCost()` (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).
|
||
- Zerlegt eine any-of-Alternative selbst all-of, wird der Teilbaum **nur
|
||
horizontal** schmal transponiert (`ul.or>li.has-and>ul.and`, siehe D18) —
|
||
sonst schiebt der breite Fächer den Elternbaum nach rechts. Bei Layout-
|
||
Umbauten dieses Nesting mitprüfen.
|
||
- „verworfen" ist per Default ausgeblendet; Filterlogik steckt in
|
||
`visibleChildren()` und muss bei Renderer-Umbauten erhalten bleiben.
|
||
- Barrierefreiheit (SPEC §9): `render.js` baut je Knoten einen sprechenden
|
||
`aria-label` (Label + Status + Aufwand + Zuständige + Link, lokalisiert via
|
||
`t`); die visuellen Badges (Größe, Tags, ↗) sind `aria-hidden`. Neue
|
||
Knoten-Eigenschaften dort in `nodeAria()` mitpflegen und dafür a11y-i18n-Keys
|
||
(`a11y*`) in **allen 9 Sprachen** anlegen. Knoten sind `tabindex="0"`
|
||
(Fokus = Lesereihenfolge), `#warn` ist eine Live-Region.
|
||
- Zustand wird im `localStorage` gehalten (noch kein Backend): `werkbaum-lang`
|
||
(Sprache), `werkbaum-docs` (JSON-Array der Dokumente `[{id,name,text}]`),
|
||
`werkbaum-active` (id des aktiven Dokuments), `werkbaum-src` (Spiegel des
|
||
aktiven Texts, Abwärtskompatibilität), `werkbaum-ui` (JSON: Modus, verworfene,
|
||
günstigster Pfad, Split-Zustand inkl. `--col`/`--drow`, Zoom, Vollbild). Neue
|
||
GUI-Einstellungen in `saveUI()`/`restoreState()` mitführen; `saveUI` liefert
|
||
während `restoring===true` nichts, damit das Wiederherstellen nicht sofort
|
||
zurückschreibt.
|
||
- Dokumente (D22): mehrere umschaltbare Notationstexte. `loadDocs()` migriert bei
|
||
fehlendem `werkbaum-docs` den bestehenden `werkbaum-src` (oder `INITIAL`) in
|
||
**ein** Dokument; `initDocs()` (Aufruf **nach** `applyLang`) holt den aktiven
|
||
Text in den Editor. `saveSrc()` schreibt den Editortext ins aktive Dokument.
|
||
Der Wähler ist ein Dropdown in der Editor-Titelzeile (`#docTrigger`/`#docMenu`,
|
||
ersetzt die frühere feste „Struktur (Text)"-Beschriftung); Wechseln/Neu/
|
||
Umbenennen/Löschen in `switchDoc/newDoc/renameDoc/deleteDoc`. Jedes Dokument ist
|
||
nur Text + Name (kein Strukturformat, D14) — vorwärtskompatibel zum Backend
|
||
(D13). Ansichts-State (`werkbaum-ui`) bleibt global über alle Dokumente. Ein
|
||
leerer Editortext bleibt leer.
|
||
- Beispiel-Dokument (D22): reservierte id `EXAMPLE_ID = 'example'`, fester
|
||
englischer Name `EXAMPLE_NAME = 'Example'` (nicht lokalisiert). `loadDocs()`
|
||
adoptiert einen Alt-Zustand (zufällige id, „Beispiel") nur, wenn dessen
|
||
`text === INITIAL` (nie echte Nutzerinhalte). `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/example-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).
|
||
- 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:
|
||
`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). 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.
|
||
- Touch-Langdruck (D25): Der Timer (500 ms) setzt **nur** die Klasse `armed`
|
||
(Petrol-Ring); `jumpToLine()` läuft im `touchend`-Handler. Nicht zurück in den
|
||
Timer verlegen — **`focus()` aus einem Timer gilt in mobilen Browsern nicht als
|
||
Nutzergeste**, das Textfeld verliert den Fokus sofort wieder (Symptom: die
|
||
Markierung flackert nur auf). Gegen die native Langdruck-Auswahl braucht es
|
||
alle drei Schichten: `contextmenu`-`preventDefault` während des Drucks,
|
||
`user-select:none` per `@media (hover:none) and (pointer:coarse)` und
|
||
`-webkit-touch-callout:none` (nur iOS). Synthetische `TouchEvent`s prüfen
|
||
davon **nichts** — nur die eigene Ereignis-Logik.
|
||
- Bildschirmtastatur (D25): `jumpToLine()` setzt vor dem Fokus `inputmode="none"`
|
||
(`keyboardOnJump(true)`) — der Sprung ist „hinschauen". Der `pointerdown`-
|
||
Handler am Textfeld hebt das wieder auf (läuft vor dem Fokus). Wer sonst
|
||
irgendwo `src.focus()` ergänzt und dabei Tippen meint, muss vorher
|
||
`keyboardOnJump(false)` rufen — so wie `newDoc()`.
|
||
- Legende (D26): **kein `<details>`** — Chrome legt Details-Inhalt in
|
||
`::details-content`, dadurch war `.hint` kein Flex-Kind mehr und ließ sich nicht
|
||
begrenzen (Inhalt geclippt statt scrollbar). Jetzt `div.agenda` +
|
||
`button.agenda-summary`, Zustand an der Klasse `open` (`setAgendaOpen()` hält
|
||
Klasse, `aria-expanded`, `#legendBtn` und die Sichtbarkeit des Splitters
|
||
zusammen). Wer hier wieder ein natives Aufklapp-Element einsetzt, bricht das
|
||
Scrollen. Der Splitter `#hintGutter` schreibt `--hcol` (nebeneinander) bzw.
|
||
`--hrow` (gestapelt: `side` oder mobil) an `#app`; beide werden getrennt in
|
||
`werkbaum-ui` gesichert. Die 85-%-Obergrenze steht **zusätzlich** als
|
||
`max-width`/`max-height` im CSS — die gespeicherte px-Größe würde den Editor
|
||
sonst erdrücken, wenn das Panel später schrumpft.
|
||
- „Was ist neu?" (D28): `freshProdSet(prevRoots, currRoots)` in `model.js` liefert
|
||
die Knoten, die **neu `[^]`** sind (Identität = Label-Pfad, nicht Zeile).
|
||
`render()` bildet die Menge **bei jedem Durchlauf neu** aus den gerade
|
||
geparsten `roots` — eine vorab berechnete Menge stammte aus einem anderen
|
||
Parse-Durchlauf und träfe per Objektidentität nie zu (genau dieser Fehler ist
|
||
passiert: Zähler stimmte, nichts leuchtete). Vorgehalten wird nur
|
||
`freshPrevRoots` (Basis, einmal geparst). Basis je Dokument in `werkbaum-seen`,
|
||
fortgeschrieben **erst beim Bestätigen** über `#freshBtn`.
|
||
- 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 %.
|
||
- Kleiner Bildschirm: `body.mobile` (per `matchMedia`, ≤ 640 px) stapelt
|
||
Diagramm/Editor mit **stufenlosem** Splitter (kein Snap/Collapse wie auf
|
||
Desktop): der Gutter-Drag ruft `setMobileDrow()` (klemmt `--drow` zwischen den
|
||
Kopfhöhen `--pmin-d`/`--pmin-e`, per `syncPanelMins()` gemessen), ein Tipp auf
|
||
eine Titelzeile maximiert das Panel. `applyLayout` ruft auf Mobil **kein**
|
||
`applySplit` (das würde `--drow` löschen). 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).
|