Files
werkbaum/frontend/CLAUDE.md
T
mhoennigandClaude Opus 5 b6e83b4f2a fix: Erledigte Knoten treten nie zurück (D46-Nachtrag)
Gemeldet: manche Knoten in Produktion werden grau dargestellt, etwa
`#ed.fresh`. Ursache war nicht der Status, sondern die Pfad-Inversion:
Der Knoten ist eine Zugabe (`+`) und liegt damit per D29 nie auf dem
günstigsten Pfad — `.cheap-on .node:not(.cheap)` blasste ihn aus
(Deckkraft 0,32, Sättigung 0,4; Pastellblau liest sich so als Grau).

D46 hatte „erledigt tritt nicht zurück" bereits entschieden, die Regel
hing aber an `cheapCls()` und griff nur für Knoten AUF dem Pfad. Durch
das Raster fielen die fertige Zugabe und die fertige, nicht gewählte
Alternative. Jetzt gilt sie unabhängig vom Pfad; Blässe heißt damit
einheitlich „hier ist nichts getan und wird auch nichts getan".

Umgesetzt als eigene Klasse `done` per `isDone(n)` in render.js, nicht
über `st-fertig`/`st-prod`: Die Statusklasse trägt den EFFEKTIVEN Status
(D39), die Ausnahme muss dem intrinsischen folgen — dieselbe Linie wie
bei D35, D28 und der Kostenrechnung aus D46. Gefärbt wird unverändert
nach dem effektiven Status.

Nachgemessen (Werkbaum-Plan, 149 sichtbare Knoten, Pfad an): vorher
blass und fertig waren genau drei Knoten (#ed.fresh, #ed.export.print,
#ed.closure.greedy) — jetzt alle drei opacity 1, filter none, Füllung
rgb(219,234,248) wie die fertigen Knoten auf dem Pfad. Weiterhin blass:
31 Knoten, darunter kein einziger fertiger. Der Grafikexport hatte den
Fehler nie (er liest backgroundColor, nicht opacity).

240 Tests grün (4 neue in tests/frontier.test.js).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 06:15:22 +02:00

535 lines
39 KiB
Markdown
Raw Blame History

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