Alle 10 Minuten wird der Text des aktiven Dokuments weggelegt — aber nur, wenn er sich seit dem letzten Stand geändert hat. Aufgehoben werden die letzten 20 je Dokument (Nutzer-Entscheidung; rund 3,5 Stunden, max. ~800 kB beim größten Dokument). Angeboten werden sie über einen Knopf rechts neben dem Dokumenten-Wähler, neueste zuoberst, je mit Uhrzeit und Zeilenzahl. „Nur bei Änderung" heißt: gegen den letzten Stand, und solange es keinen gibt, gegen den Text beim Aktivieren des Dokuments. Ohne das legte der erste Takt nach dem Öffnen auch ein unverändertes Dokument weg. Die Dokumente sind wichtiger als ihre Stände: Beide teilen sich den localStorage, also wirft persistSnaps() bei Überlauf den jeweils ältesten Stand weg, bis es passt, notfalls alle — statt das Speichern der Dokumente zu gefährden. Ein gelöschtes Dokument nimmt seine Stände mit. Pad-Dokumente (D31) sammeln keine: Ihr Feld ist schreibgeschützt, ein alter Stand ließe sich dort nicht einsetzen. Zurückgeholt wird undo-fähig (replaceTextUndoable, D53) — ein Griff daneben kostet ein Strg+Z, keine Rückfrage. Vorher wird der aktuelle Stand weggelegt, sonst wäre gerade er das Einzige, was das Zurückholen verlöre. Beim Bauen korrigiert: Das Menü war an der Gruppe ausgerichtet (right:0) und begann 190 px weiter links, also außerhalb des Panels (gemessen: linke Kante bei -65 px). Bezug ist jetzt der Knopf. Nachgemessen (Takt für die Prüfung auf 2 s verkürzt, danach zurückgestellt): ohne Eingabe kein Eintrag, nach einer Eingabe genau einer, ohne weitere keiner dazu; nach 29 Änderungen 20 (gedeckelt, Zeitstempel aufsteigend). Klick auf einen älteren Eintrag lädt ihn (36 -> 32 Zeilen), Diagramm zeichnet neu, undo holt den vorherigen Stand zurück. Plan: 155 Knoten, 0 Warnungen. 240 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3585 lines
220 KiB
Markdown
3585 lines
220 KiB
Markdown
# Entscheidungen (ADR-Kurzform)
|
||
|
||
Festgehaltene Design-Entscheidungen samt Begründung. Bei Änderungen: Eintrag
|
||
ergänzen, nicht löschen.
|
||
|
||
## D1 — `|` für Oder-Zerlegung, `-` für Und-Zerlegung
|
||
`|` bedeutet in Regex und BNF-Grammatiken bereits „oder“ und ist damit
|
||
selbsterklärend. `-` ist das gewohnte Markdown-Listenzeichen für „normale“
|
||
Teilpakete. Das Gate steckt im Aufzählungszeichen der Kinder, nicht in einer
|
||
Annotation am Parent — lokal lesbar, trivial parsebar.
|
||
|
||
## D2 — Status als erweiterte Markdown-Task-Checkbox
|
||
GitHub-Markdown kennt `[ ]`/`[x]`; die Erweiterung auf `[?] [~] [/] [^] [-]`
|
||
wirkt sofort vertraut. Mnemonik: `?` vage, leeres Kästchen = todo, `~` gängige
|
||
In-progress-Konvention, `/` = halbes `x`, `x` = erledigt, `^` = „nach oben
|
||
deployed“, `-` = durchgestrichen.
|
||
|
||
## D3 — Begriff „Durchstich“ für den Zwischenstatus
|
||
Zustand zwischen „in Arbeit“ und „fertig“: durchgängig funktionsfähig und
|
||
vorführbar, Feinarbeiten offen. „Durchstich“ ist als Fachbegriff (Tunnelbau,
|
||
End-to-End-Slice in der Softwarearchitektur) etabliert. Verworfene
|
||
Alternativen: „Feinschliff“, „Richtfest“, „vorführbereit“, „Beta“.
|
||
|
||
## D4 — Begriff „verworfen“ statt „gestrichen“
|
||
Beschreibt die bewusste Entscheidung gegen eine Option — besonders passend
|
||
für Any-of-Alternativen. Default ausgeblendet, Toggle „verworfene einblenden“.
|
||
|
||
## D5 — Farb-Logik folgt dem Risiko, nicht dem Fortschritt
|
||
Rosé = Kosten investiert, Risiko hoch (in Arbeit). Pastellgelb = läuft,
|
||
Restrisiko klein (Durchstich). Flieder = geplant (Absicht, nichts investiert,
|
||
zwischen Grau der Idee und Rosé der Arbeit). Ergebnis-Skala:
|
||
grau → flieder → rosé → gelb → grün → blau.
|
||
|
||
## D6 — Größe in Klammern, URL nackt, Personen mit `@`
|
||
`(M)` liest sich wie ein Kleidungsetikett. URLs werden ohne Link-Syntax
|
||
erkannt (einfach einfügbar); die URL wird **vor** den `@`-Tags extrahiert,
|
||
damit `https://user@host/…` nicht kollidiert. `@` ist die universelle
|
||
Mention-Konvention (GitHub, Slack, Jira).
|
||
|
||
## D7 — `%%` als Kommentarzeichen
|
||
Mermaid-Konvention; hält den Weg zum Mermaid-Plugin frei. `//` scheidet wegen
|
||
URLs aus, `#` bleibt für Referenzen/Tags reserviert, `<!-- -->` ist zu sperrig.
|
||
|
||
## D8 — Untergliederungspflicht ab M sichtbar machen
|
||
Fehlende Zerlegung wird nicht nur gemeldet, sondern **gezeigt**: Geister-Knoten
|
||
„Untergliederung fehlt“ hängt genau dort, wo der Ast weitergehen müsste.
|
||
Verworfene Elemente sind ausgenommen. Werden alle Kinder eines M+-Elements
|
||
verworfen und ausgeblendet, erscheint der Platzhalter wieder (es braucht eine
|
||
neue Zerlegung) — gewollt.
|
||
|
||
## D9 — Transponierte Darstellung mit unterschiedlichen Austrittsseiten
|
||
Im vertikalen Modus tritt all of **rechts** aus dem Parent aus (LR-Baum),
|
||
any of **unten links**. Austrittsseite + Linienstil (durchgezogen/gestrichelt)
|
||
codieren das Gate doppelt; Beschriftungen werden nie rotiert.
|
||
|
||
## D10 — Abzweige zielen auf Knotenmitte, nicht Teilbaummitte
|
||
Feste Knoten-Zeilenhöhe macht die Anschlusshöhe deterministisch (23 px).
|
||
Behebt „ins Leere laufende“ Linien bei eingerückten Unterbäumen.
|
||
|
||
## D11 — Technologie: Vanilla HTML/CSS/JS, eine Datei als Prototyp
|
||
Keine Frameworks, kein Build-Zwang. Parser ~30 Zeilen, Renderer erzeugt
|
||
verschachtelte `ul.and`/`ul.or`; Linien via CSS-Pseudo-Elemente. Modularisierung
|
||
(Parser/Renderer/UI getrennt + Tests) ist der geplante nächste Schritt.
|
||
|
||
## D12 — Name „Werkbaum" und Marke aus der Notation
|
||
Name: „Werk" + „Baum" = wörtlich der WBS-Baum; funktioniert als npm-Paket,
|
||
CLI-Befehl und Mermaid-Schlüsselwort (`werkbaum`, kleingeschrieben in
|
||
Code-Kontexten). Verworfene Kandidaten: Astrein, undoder, Aufriss, Gabelung.
|
||
Marke: Miniatur des Diagramms (K2) in zwei Orientierungen analog zu den
|
||
Darstellungsmodi — Hochformat als Primärzeichen, transponiert als
|
||
Sekundärzeichen. Nur Tinte + Petrol; durchgezogen = und, gestrichelt = oder;
|
||
Pastelltöne bleiben Statusfarben. Details: brand/BRAND.md.
|
||
|
||
## D13 — Backend in Kotlin/Spring Boot, Monorepo-Struktur
|
||
Backend als Kotlin/Spring-Boot-Anwendung (Gradle Kotlin DSL, JDK 21),
|
||
entwickelt in IntelliJ IDEA Ultimate. Monorepo mit `frontend/` und `backend/`;
|
||
CLAUDE.md dreistufig: Wurzel für Querschnitt, je eine pro Teilprojekt
|
||
(wird von Claude Code pfadbezogen geladen). Kein Node- oder Python-Backend.
|
||
|
||
## D14 — Parser-Hoheit liegt beim Frontend, SPEC ist normativ
|
||
Der Notationstext ist das führende Datenformat. Das Backend parst die
|
||
Notation nicht; es speichert den Text als Ganzes und bedient Integrationen
|
||
über explizite Metadaten. Wird Backend-Parsen später doch erforderlich,
|
||
gilt: docs/SPEC.md ist normativ, beide Parser testen gegen dieselben
|
||
Fixtures (SPEC §10) — es darf keine zweite, abweichende Grammatik entstehen.
|
||
|
||
## D15 — Kompakter Modus + graue Any-of-Linien in allen Modi
|
||
Der vertikale Modus (D9) lässt „all of“ rechts austreten — formal sauber,
|
||
aber breit. Der zusätzliche Modus **kompakt** führt beide Gates nach unten
|
||
(minimale Breite) und unterscheidet sie nur noch über den Linienstil:
|
||
durchgezogen = und, gestrichelt = oder. Auswahl dreistufig
|
||
„horizontal/vertikal/kompakt“ statt Toggle, da es nun drei sich
|
||
ausschließende Darstellungen sind.
|
||
|
||
Zugleich wird das **Petrol im Diagramm ganz aufgegeben**: die any-of-Linien
|
||
(Sammelleiste *und* Abzweige) und auch der **Rahmen der Alternative-Knoten**
|
||
sind in **allen** Modi **gestrichelt bzw. gerahmt in Grau** (`#6B7A8C`). Die
|
||
frühere durchgezogene Petrol-Sammelleiste und der petrolfarbene Knotenrahmen
|
||
entfallen. Begründung: Das Gate ist bereits durch den Linienstil (gestrichelt)
|
||
und die Anordnung codiert; die zusätzliche Signalfarbe wirkte laut und ließ die
|
||
durchgezogene Hauptlinie optisch mit „all of“ konkurrieren. Grau tritt zurück,
|
||
die gestrichelte Führung bleibt eindeutig. D12/BRAND — „durchgezogen = und,
|
||
gestrichelt = oder“ — gilt für das **Markenzeichen** unverändert (Logo behält
|
||
Petrol); im **Diagramm** trägt allein der Linienstil die Gate-Codierung.
|
||
|
||
## D16 — Deployment über GitHub Pages via Actions-Workflow
|
||
Der Editor ist eine einzelne statische Datei (D11) — GitHub Pages genügt, kein
|
||
eigener Server nötig. Veröffentlicht wird über den offiziellen Actions-Weg
|
||
(`actions/upload-pages-artifact` + `actions/deploy-pages`, `permissions:
|
||
pages/id-token`, `concurrency: pages`) statt über den `gh-pages`-Branch: kein
|
||
Zusatzbranch, OIDC statt Deploy-Key, Trigger bei Push auf `main` und manuell.
|
||
Der Test-Step (Vitest, Phase 1) ist als Platzhalter auskommentiert vorbereitet.
|
||
|
||
**Pfad-Entscheidung:** `frontend/index.html` referenziert Favicon und
|
||
MIT-Lizenz relativ mit `../` (`../docs/brand/favicon.svg`, `../LICENSE`) — von
|
||
der Wurzel-URL aus zeigten diese über die Site hinaus. Statt die Quelldatei zu
|
||
ändern (D14/CLAUDE: „Editor nicht refaktorieren“) zieht der Workflow die
|
||
`../`-Pfade **nur auf der Site-Kopie** gerade (`sed`) und legt die referenzierten
|
||
Dateien passend ab: `index.html` an die Wurzel, `docs/brand/` und `LICENSE`
|
||
daneben. So bleibt die Quelle unverändert (lokal weiter per `file://` und
|
||
Dev-Server nutzbar), und veröffentlicht wird nur das Nötige — `backend/` und die
|
||
übrigen `docs/` bleiben außen vor.
|
||
|
||
**Versionsnummer:** `Major.Minor` steht in der Datei `VERSION` und wird per
|
||
bewusstem **Bump-Commit** gepflegt; die **Micro-Stelle** leitet der Build aus
|
||
der Anzahl der Commits seit dem letzten VERSION-Bump ab
|
||
(`git rev-list --count <VERSION-Commit>..HEAD`) und ersetzt sie per `sed` nur
|
||
auf der Site-Kopie im Footer. Bewusst aus der Commit-Historie statt aus der
|
||
Run-Nummer (Vorentscheidung, verworfen): vollständig aus dem Repo
|
||
reproduzierbar, monoton, ohne zurückgeschriebenen Commit/Tag und ohne
|
||
selbstauslösenden Deploy-Zyklus. Die Micro-Stelle beginnt nach jedem Bump wieder
|
||
bei 0 (SemVer-artig). Nötig ist ein vollständiger Klon (`fetch-depth: 0`), sonst
|
||
zählt der flache CI-Klon nur einen Commit. `1.0` bleibt die Version beim lokalen
|
||
Öffnen (Platzhalter im Quelltext). Im Footer sind Name und Nummer **getrennte
|
||
Links**: „Werkbaum" → Repo-Startseite, die Versionsnummer (`<a class="ver">`) →
|
||
exakt der deployte Commit (`…/commit/<sha>`, im Build via `git rev-parse HEAD`).
|
||
|
||
**Build-Hinweis (Vorschau / „latest build"):** Nicht-produktive Builds tragen
|
||
hinter dem Titel ein kleines Symbol samt Tooltip, damit klar ist, dass es nicht
|
||
die *eigentliche* (stabile) Instanz ist. Drei Zustände, gesteuert per Vite-Env
|
||
`VITE_BUILD_BADGE` (Auswertung in `app.js`, `mountBuildBadge`):
|
||
|
||
- **Dev-Server** (`import.meta.env.DEV`) → 🔧 „Vorschau – lokaler Entwicklungsstand".
|
||
- **Default-Build** `npm run build` (Env ungesetzt) → 🚧 „latest build …". Der
|
||
GitHub-Pages-Deploy nutzt genau diesen Default und trägt den Hinweis dadurch
|
||
automatisch — **keine** `sed`-Injektion mehr nötig.
|
||
- **Produktions-Build** `npm run build:prod` (Vite-Modus `prod`, `frontend/.env.prod`
|
||
setzt `VITE_BUILD_BADGE=none`) → **kein** Badge; esbuild eliminiert den Zweig
|
||
als toten Code, das Symbol steht dann nicht einmal mehr im Ausgabe-Quelltext.
|
||
|
||
Damit trägt einzig die echte produktive Installation keinen Hinweis. **Warum in
|
||
die App-Quelle statt per Workflow-`sed` (frühere Lösung):** Nur so sieht der
|
||
Dev-Server den Hinweis ebenfalls — ein Post-Build-`sed` erreicht den Dev-Server
|
||
nicht, der die Quelle direkt ausliefert. Die Umkehrung „Hinweis ist der
|
||
Normalfall, Prod schaltet ab" passt zudem zur Anforderung (nur Prod bleibt sauber)
|
||
und macht den Default sicher: wer den Prod-Schritt vergisst, veröffentlicht einen
|
||
sichtbar als Entwicklungsstand markierten Build, nicht versehentlich einen als
|
||
stabil wirkenden. Bewusst **kein** i18n-UI-Text im `I18N`-Objekt (kein
|
||
Produkt-Feature, sondern Build-Metainformation; D14) — der `title` ist knapp
|
||
zweisprachig (DE · EN).
|
||
|
||
**Self-hosted Deploy (`scripts/deploy-prod.sh`):** Für eine eigene produktive
|
||
Installation (nicht GitHub Pages) baut das Skript per `npm run build:prod`
|
||
(badge-frei), stellt lokal dasselbe zusammen wie der Pages-Workflow (LICENSE
|
||
danebenlegen + `../LICENSE`-Link geradeziehen, Footer-Version + Commit-Link) und
|
||
spiegelt es per `rsync --delete` über SSH ins Zielverzeichnis (nichts Altes bleibt
|
||
stehen). Die Zusammenstell-Schritte (dieselben drei `sed`-Regeln) liegen bewusst
|
||
in **beiden** — Workflow und Skript —, weil GitHub Pages die `dist/` selbst baut
|
||
und der SSH-Weg keinen Pages-Runner hat; bei Änderungen an den `sed`-Regeln beide
|
||
Stellen nachziehen (Anker `<a class="ver">` / `../LICENSE`).
|
||
|
||
(Nummerierung: D15 war bereits für den kompakten Modus vergeben, daher D16.)
|
||
|
||
## D17 — Kleiner Bildschirm: ein Bereich, kompakte Legende & Sprachwahl
|
||
Auf schmalen Viewports (≤ 640 px) ist für beide Bereiche kein Platz. Diagramm
|
||
und Editor werden gestapelt und über den **Splitter stufenlos** geteilt (frei
|
||
ziehbar, jederzeit erneut verstellbar); die beiden **Titelzeilen bleiben immer
|
||
stehen** (Grid-Zeilen-Minima = gemessene Kopfhöhen), ein **Tipp auf eine
|
||
Titelzeile klappt dieses Panel ganz aus**. Das frühere diskrete Modell (Snap in
|
||
Minimier-Zustände 'a'/'b') führte auf dem Smartphone zu zwei Fehlern: nach dem
|
||
ersten Ziehen rastete der Splitter in eine feste Aufteilung und war nicht mehr
|
||
zu bewegen (die Titelzeile im Snap-Rand fing jeden weiteren Zug ab), und der
|
||
Titelzeilen-Tap funktionierte nur im `collapsed`-Zustand — nach einem freien Zug
|
||
also gar nicht. Deshalb auf Mobil ein **kontinuierliches** Modell ohne Snap/
|
||
Collapse (`--drow` frei geklemmt, Tap setzt sie aufs Extrem); Desktop behält das
|
||
diskrete Modell samt Fenster-Buttons.
|
||
Die Legende wird von der Bereichs-
|
||
Umschaltung **entkoppelt** und über einen eigenen Umschalter im Editor-Kopf
|
||
gesteuert — das dauerhafte „AGENDA“-Band kostet sonst zu viel Höhe. Die
|
||
Sprachleiste zeigt eingeklappt nur die **aktive** Sprache; ein Tipp klappt die
|
||
volle Leiste als **Overlay über die Kopfzeile** auf (rechtsbündig, `z-index`,
|
||
verdeckt die übrigen Elemente statt die Zeile umzubrechen), nach der Auswahl —
|
||
oder einem Tipp daneben — klappt sie wieder ein. So bleibt die Kopfzeile schmal
|
||
und die vollständige Sprachliste dennoch erreichbar (frühere Lösung
|
||
„EN + aktive + …“ ersetzt). Voreinstellung ist **Vollbild** (maximale Fläche). Umgesetzt über die Klasse `body.mobile` (per
|
||
`matchMedia`), damit CSS und JS denselben Schwellwert teilen; die Vollbild-
|
||
Voreinstellung greift nur, wenn noch **keine** gespeicherte Nutzerwahl vorliegt
|
||
(eine bewusste Abschaltung bleibt so erhalten, siehe localStorage-Persistenz).
|
||
|
||
**Nachtrag — auf Mobil ist jetzt immer genau EIN Bereich zu sehen; der Splitter
|
||
entfällt dort ganz.** Die oben beschriebene stufenlose Aufteilung hat das
|
||
eigentliche Problem nur verwaltet, statt es zu lösen: Auf 375 px ist für zwei
|
||
Bereiche kein Platz. Jede Aufteilung war ein Kompromiss, in dem **beide**
|
||
Bereiche zu klein waren, und die praktisch einzigen sinnvollen Stellungen waren
|
||
ohnehin die Extreme — also genau das, was ein Umschalter direkt anbietet. Der
|
||
Splitter kostete außerdem dauerhaft eine zweite Titelzeile plus 14 px Griff für
|
||
etwas, das man auf dem Telefon nicht dosiert, sondern wechselt.
|
||
|
||
**Der Umschalter zeigt das Ziel, nicht den Zustand.** Damit weicht er bewusst
|
||
vom Modus-Wähler daneben ab, der das aktive Icon zeigt und reihum schaltet:
|
||
Der hat drei Zustände, die man ohne Anzeige nicht auseinanderhält; hier gibt es
|
||
zwei, und welcher gerade gilt, sieht man am ganzen Bildschirm. Ein Knopf, der
|
||
den Zustand anzeigt, den man ohnehin vor sich hat, sagt nichts — einer, der
|
||
das Ziel zeigt, sagt, was passiert. Umgesetzt als **zwei feste Knöpfe**, je
|
||
einer pro Titelzeile: Weil immer nur eine Zeile sichtbar ist, braucht keiner
|
||
sein Icon zu wechseln.
|
||
|
||
**Der Dokumenten-Wähler steht neben dem Umschalter** und damit nur im
|
||
Textbereich. Das ist keine Platzentscheidung, sondern die richtige Zuordnung:
|
||
Er bestimmt, *welchen Text* man bearbeitet (D22) — im Diagrammbereich wäre er
|
||
ein Fremdkörper. Die übrigen Aktions-Knöpfe bleiben, wo sie sind, und werden
|
||
dadurch von selbst mitgeschaltet; ihre komprimierte Mobil-Form (Modus-Wähler
|
||
als Reihum-Icon, Download als Overlay) bleibt unverändert.
|
||
|
||
**Die Falle beim Bauen: ein `display:none`-Panel misst sich zu null.** Der
|
||
verborgene Bereich wird wirklich ausgeblendet — nur so bekommt der sichtbare
|
||
die ganze Höhe. Alles, was aus der Live-Geometrie zeichnet, liefert dann aber
|
||
Unsinn, und `render()` läuft bei **jedem Tastendruck** im Textbereich.
|
||
Nachgemessen nach einer Eingabe bei verborgenem Diagramm: Pfad-Linie **weg**,
|
||
null Stationspunkte, kein `--stem-x`. Das Umschalten zeichnet deshalb neu —
|
||
zum Diagramm hin dieselben vier Schritte wie ein Moduswechsel
|
||
(`applyOptStairs`, `alignStems`, `drawCheapPath`, `drawDepLinks`), zum Text hin
|
||
der Zeilennummern-Streifen, der ebenso am Spiegel misst (D33). Danach wieder
|
||
48 px Pfadlänge, 5 Punkte, `--stem-x: 67,4px`.
|
||
|
||
Der sichtbare Bereich wird in `werkbaum-ui` gemerkt — global über alle
|
||
Dokumente wie der übrige Ansichts-Zustand (D22). Die Sprünge zwischen Diagramm
|
||
und Text (D25) holen den nötigen Bereich selbst nach vorn: `revealEditor()`
|
||
schaltet auf Text (wie es auf dem Desktop ein zugeklapptes Panel aufklappt),
|
||
`focusNodeOfCaret()` auf das Diagramm — und zwar **vor** dem Zentrieren, sonst
|
||
misst sich der Zielknoten noch zu null. Entfallen sind damit `--drow` auf
|
||
Mobil, die Grid-Minima `--pmin-d`/`--pmin-e`, `syncPanelMins()`,
|
||
`setMobileDrow()` und das Titelzeilen-Tippen; Desktop behält Splitter,
|
||
Presets und Fenster-Buttons unverändert.
|
||
|
||
**Nachtrag 2 — auf Mobil sind die Inhalte grundsätzlich ~25 % kleiner, und der
|
||
Debug-Kasten minimiert sich statt zu verschwinden.**
|
||
|
||
**Verkleinerung als Faktor, nicht als neuer Anfangswert.** `MOBILE_ZOOM = 0.75`
|
||
multipliziert den Nutzer-Zoom (`effZoom()`), statt ihn beim ersten Start auf
|
||
0,75 zu setzen. Der Unterschied zählt: Ein Anfangswert wäre nach dem ersten
|
||
Zoomen weg, und „Zurücksetzen" führte zurück auf eine Größe, die auf dem
|
||
Telefon zu groß ist. Als Faktor bleibt die Verkleinerung eine Eigenschaft des
|
||
Geräts, und der Regler arbeitet relativ dazu weiter. Die Anzeige nennt den
|
||
**effektiven** Wert (75 % statt 100 %) — sie soll beschreiben, was man sieht,
|
||
nicht was man eingestellt hat.
|
||
|
||
**Die drei Messstellen mussten mit.** `alignStems()`, `drawCheapPath()` und
|
||
`drawDepLinks()` rechnen gemessene Pixel durch den CSS-`zoom` zurück; sie lesen
|
||
jetzt `effZoom()` statt `zoom`. Nachgemessen bei 0,75: alle fünf
|
||
Stationspunkte liegen exakt (0 px Abweichung) auf ihren Blattknoten.
|
||
|
||
**Der Text kann kein `zoom` bekommen.** Zeilennummern-Streifen und Spiegel
|
||
messen am Textfeld (D33), und der Streifen rechnet seine Breite in `ch` — beide
|
||
folgen der **Schriftgröße**, nicht einem Zoom auf einem Vorfahren. Also
|
||
`font-size: .64rem` (= 0,85 × 0,75) an Textfeld **und** Streifen gemeinsam;
|
||
`line-height` ist einheitenlos und skaliert mit. Der Innenabstand des
|
||
Textfelds schrumpft im selben Verhältnis mit (14/16 → 10/12 px), sonst wäre der
|
||
Rand auf 375 px unverhältnismäßig breit. Ergebnis: 27 statt 19 Zeilen im Bild.
|
||
|
||
**Dabei aufgefallen: der Grafikexport war schon immer zoom-abhängig falsch.**
|
||
Die Schriftgrößen im Ausgabe-SVG sind feste Zahlen (14 für Labels, 9–11 für
|
||
Badges), die Kästen kommen aus der Live-Messung — bei jedem Zoom ≠ 1 passten
|
||
Text und Kasten nicht zueinander. Das fiel nie auf, weil 100 % der Normalfall
|
||
war; mit der Mobil-Verkleinerung wäre es der Regelfall geworden. `diagramToSvg()`
|
||
stellt den Zoom für die Messung deshalb kurz auf 1 und danach zurück —
|
||
derselbe Griff wie bei der `exporting`-Klasse (D25-Nachtrag), und die Funktion
|
||
läuft synchron, es wird nichts davon gezeichnet. Nachgemessen: Knoten auf dem
|
||
Schirm 143 px, im SVG 189,7 px — also die unskalierte Größe, passend zur festen
|
||
Schrift.
|
||
|
||
**Nachtrag 3 — der Umschalter trägt die Navigation zum Knoten; ein langer
|
||
Druck im Textfeld verbietet sich.** Gemeldet als Fehler: „Wenn man in der
|
||
Smartphone-Ansicht den Cursor in den Text setzt, klappt das Text-Edit-Fenster
|
||
zusammen." Nachgestellt auf der **deployten** Instanz (1.1.39, noch das alte
|
||
Splitter-Modell von D17): Ein Tipp auf die **Titelzeile des Diagramms** —
|
||
ein 49 px hoher Streifen unmittelbar über dem Text — schrumpfte den Editor von
|
||
594 px auf 44 px. Beim Zielen auf die oberen Textzeilen ist der leicht zu
|
||
treffen. Das Ein-Bereich-Modell aus Nachtrag 1 nimmt dem Fehler die Grundlage:
|
||
Es gibt keinen Splitter und kein Titelzeilen-Tippen mehr, den Bereich wechselt
|
||
**allein** der Umschalter. Im aktuellen Stand ließ sich der Fehler nicht mehr
|
||
auslösen (Maus-Klick, emulierter Touch und synthetische Touch-Folge, jeweils
|
||
ohne Bereichswechsel).
|
||
|
||
**Das war aber nur die halbe Wahrheit — siehe Nachtrag 4.** Der Nutzer meldete
|
||
denselben Fehler auf dem Pages-Build, der das Ein-Bereich-Modell bereits
|
||
enthielt. Die obige Diagnose war für die alte Fassung richtig und trotzdem
|
||
nicht die Ursache seiner Beobachtung; die eigentliche stand woanders und war
|
||
mit Emulation grundsätzlich nicht zu finden.
|
||
|
||
Der zweite Teil der Meldung deckte aber eine echte Lücke auf: Für die Richtung
|
||
**Text → Diagramm** gibt es nur Alt+Klick bzw. Alt+Enter (D25) — und Alt gibt
|
||
es auf dem Telefon nicht. Die Gegenrichtung hat dort ihren langen Druck, diese
|
||
hatte nichts.
|
||
|
||
**Vorgeschlagen war ein langer Druck im Textfeld — verworfen.** Dort gehört er
|
||
dem Betriebssystem: Wort markieren, Auswahlgriffe, Einfügen-Leiste. D25 konnte
|
||
sich diese Geste im Diagramm nehmen (`user-select:none`), weil es da nichts zu
|
||
markieren gibt; in einem **editierbaren** Feld ist sie die Bedien-Grundlage
|
||
zum Bearbeiten. Sie zu überschreiben löste ein Navigationsproblem auf Kosten
|
||
des Bearbeitens — in einer Meldung, deren Kern gerade lautet, dass Bearbeiten
|
||
nicht bestraft werden darf. Dazu kommt, dass sich das Zusammenspiel mit der
|
||
nativen Auswahl nach der D25-Lehre nur auf echter Hardware beurteilen ließe.
|
||
|
||
**Gewählt: der Umschalter tut es nebenbei.** Wer aus dem Text ins Diagramm
|
||
wechselt, will nachsehen — und zwar bei dem, woran er gerade geschrieben hat.
|
||
Der Knopf wird in genau diesem Moment ohnehin gedrückt; er zentriert deshalb
|
||
den Knoten der Cursor-Zeile und hebt ihn hervor (`focusNodeOfCaret()`, also
|
||
identisch zum Alt+Klick am Schreibtisch). Kostet kein Bedienelement, keine
|
||
Geste und kann nicht versehentlich auslösen. Steht der Cursor auf einer Zeile
|
||
ohne Knoten (Kommentar, Leerzeile), wird nur umgeschaltet — dieselbe stille
|
||
Regel wie überall sonst bei dieser Geste.
|
||
|
||
**Nachtrag 4 — die eigentliche Ursache: `--app-height` folgte der
|
||
Bildschirmtastatur.** Nach Nachtrag 3 blieb der Fehler auf dem Pages-Build
|
||
bestehen („dort klappt der Text komplett zusammen, sobald ich irgendwo den
|
||
Cursor reinsetze"). Gefunden über die einzige Frage, die nach zwei
|
||
fehlgeschlagenen Emulations-Versuchen noch trägt: **Was ist auf einem echten
|
||
Telefon anders?** Antwort: die Tastatur.
|
||
|
||
`setAppHeight()` schreibt `window.visualViewport.height` nach `--app-height`,
|
||
und `body{height:var(--app-height)}` macht daraus die Höhe der ganzen Seite.
|
||
Genau diesen Wert verkleinert die Bildschirmtastatur — dafür ist
|
||
`visualViewport` gemacht. Nachgemessen ist der Zusammenhang linear:
|
||
Textfeldhöhe = `--app-height` − rund 206 px feste Aufbauten (Kopfzeile 57,
|
||
Titelzeile 44, Fußzeile 36, Innenabstände). Bei 812 px bleiben 606 px Text,
|
||
bei 440 px noch 234, bei 260 px — realistisch für ein kleines Gerät mit
|
||
Tastatur und Browserleiste — nur **54 px**, also drei Zeilen. Das ist das
|
||
gemeldete „komplett zusammengeklappt", und es tritt **ausschließlich auf
|
||
echten Geräten** auf: In der Emulation gibt es keine Tastatur, deshalb liefen
|
||
alle drei Reproduktionsversuche aus Nachtrag 3 ins Leere.
|
||
|
||
**Unterschieden wird am Fokus, nicht an der Größe.** Die Tastatur und eine
|
||
überlagernde Browserleiste (Brave — der ursprüngliche Anlass des Mechanismus)
|
||
erzeugen dieselbe Signatur: `visualViewport.height` fällt, `innerHeight`
|
||
bleibt. An den Zahlen sind sie nicht zu trennen. Am Zustand schon: Die
|
||
Tastatur steht nur, wenn ein **editierbares** Feld den Fokus hat. Solange das
|
||
so ist, bleibt die zuletzt tastaturfreie Höhe stehen. Die Seite behält damit
|
||
ihre Größe, und der Browser schiebt den sichtbaren Ausschnitt zur
|
||
Schreibmarke — das Verhalten jeder anderen App. Nachgemessen mit
|
||
nachgebildetem `visualViewport`: mit Fokus im Textfeld 812/606 px (unverändert),
|
||
ohne Fokus 440/234 px (die Brave-Leiste wirkt also weiter).
|
||
|
||
**Drehen muss die Sperre durchbrechen** (`setAppHeight(true)` bei
|
||
`orientationchange`) — sonst behielte die Seite beim Drehen während des
|
||
Tippens die Höhe des alten Hochformats. Dazu ein `focusout`-Nachzug für
|
||
Browser, die das Schließen der Tastatur nicht als `resize` melden. Und der
|
||
Timer dort ruft `() => setAppHeight()`, nicht `setAppHeight` direkt: Ein
|
||
durchgereichtes Argument wäre wahr und hebelte die Fokus-Sperre aus.
|
||
|
||
**Lehre, schon zweimal bezahlt:** D25 hielt fest, dass synthetische
|
||
`TouchEvent`s nur die eigene Ereignis-Logik beweisen. Dieselbe Grenze gilt für
|
||
alles, was die **Geräteumgebung** stellt — Bildschirmtastatur, Browserleisten,
|
||
Nutzergesten-Regeln. Ein „lässt sich nicht reproduzieren" aus dem Emulator ist
|
||
bei solchen Meldungen kein Befund, sondern nur die Feststellung, dass das
|
||
Werkzeug die Ursache nicht enthält.
|
||
|
||
**Das Debug-Panel minimiert sich jetzt, statt sich zu schließen.** Ein Klick
|
||
entfernte es bisher ganz — was nichts half, weil der 15-Sekunden-Takt es sofort
|
||
wieder aufbaute; auf dem Telefon verdeckte es damit dauerhaft die untere rechte
|
||
Ecke. Jetzt schaltet der Klick zwischen Kasten und einem 26-px-Icon (⟳) unten
|
||
rechts um. Der Zustand liegt im **localStorage**, nicht am Element: Das Panel
|
||
wird bei jedem Takt neu bespielt, ein Zustand am DOM-Knoten wäre also beim
|
||
nächsten Tick weg. Nachgeprüft über einen echten Intervall-Durchlauf und über
|
||
den `visibilitychange`-Pfad — es bleibt minimiert. (Test-Hilfe, im Prod-Build
|
||
ohnehin unterdrückt; die Gelegenheit genutzt, dem aufgeklappten Kasten
|
||
`white-space: pre-wrap` zu geben — die mit `\n` gefügten Zeilen liefen bisher
|
||
zu einem Absatz zusammen.)
|
||
|
||
**Nachtrag 5 — die Titelzeile bricht nicht mehr um: die Lücken waren zu
|
||
breit, nicht die Knöpfe zu viele.** Gemeldet, als der Falt-Umschalter (D44)
|
||
den achten Knopf in den Diagramm-Kopf brachte: Die Zeile ging auf drei Reihen
|
||
und 86 statt 49 px — und fraß damit genau die Fläche, für die es den
|
||
Ein-Bereich-Modus (Nachtrag 1) überhaupt gibt.
|
||
|
||
Nachgemessen war die Diagnose eindeutig, und sie lag nicht bei den Knöpfen:
|
||
Deren Breiten summieren sich bei 375 px auf 243 px. Die **Abstände** brachten
|
||
den Rest — `gap:14px` mal sieben plus 2×16 px Innenabstand sind 130 px, also
|
||
mehr als ein Drittel des Bildschirms. Der Nutzer hat das richtig gesehen:
|
||
„eigentlich ist dort auch genug Platz für alle Buttons".
|
||
|
||
Auf Mobil deshalb `gap:8px` und `padding-inline:10px`, dazu `flex-wrap:nowrap`
|
||
als Riegel. Die **Knöpfe selbst bleiben unangetastet** — 29 px sind für einen
|
||
Finger ohnehin die Untergrenze; verkleinert wird der Zwischenraum, nicht das
|
||
Ziel. Nachgemessen mit allen acht Bedienelementen (der „Was ist neu?"-Knopf
|
||
ist der, der die Zeile kippen ließ, weil er nur bei fremden Dokumenten mit
|
||
Neuigkeiten erscheint): bei 375 px eine Reihe, 49 px, letztes Element bei
|
||
357 von 359; bei 320 px ebenfalls eine Reihe mit 10 px Luft. Dass es auch bei
|
||
320 px reicht, liegt am `margin-right:auto` des Bereichs-Umschalters — es
|
||
nimmt den Rest auf, wenn welcher da ist, und verschwindet, wenn keiner da ist.
|
||
|
||
**Warum `nowrap` und nicht einfach engere Lücken:** Ohne den Riegel wäre der
|
||
Umbruch nur weiter hinausgeschoben — der nächste Knopf oder eine längere
|
||
Sprache brächte ihn zurück, und zwar wieder unbemerkt. Mit `nowrap` wird aus
|
||
einem stillen Layout-Wechsel ein sichtbarer Überlauf; das ist der ehrlichere
|
||
Fehler, und die Messung oben zeigt, dass bis 320 px keiner auftritt. Der
|
||
Editor-Kopf trägt dieselbe Regel (er hat nur vier Elemente und war nie
|
||
gefährdet, aber die Zeile soll in beiden Bereichen gleich hoch bleiben);
|
||
Desktop bleibt bei 14 px, dort ist Platz.
|
||
|
||
## D18 — Günstigsten Pfad per Inversion zeigen, fehlende Größe = M
|
||
Der kostengünstigste Weg durch den Baum wird hervorgehoben (Umschalter im
|
||
Diagramm-Kopf, Default an, Zustand persistiert). Nötig sind bei **all of** alle
|
||
Kinder, bei **any of** nur die günstigste Alternative (kleinste rekursive
|
||
Kosten; Gleichstand ⇒ erste).
|
||
|
||
**Darstellung per Inversion, nicht per Betonung:** Getestet wurde zuerst ein
|
||
doppelt dicker Rand an den nötigen Knoten. Da der Baum aber fast überall all-of
|
||
ist, war damit *fast alles* dick umrandet — das Signal lag ohnehin nur in der
|
||
*Abwesenheit* des Rands bei den ausgeschlossenen Alternativen. Deshalb wird
|
||
invertiert: die **nicht benötigten** Knoten (nicht-gewählte any-of-Alternativen
|
||
samt Teilbaum) treten zurück (blass, entsättigt), der Pfad hebt sich von selbst
|
||
ab. Kein Zusatzrahmen, weil die Knoten-Ecken (Größe, Tags, ↗) schon dicht sind.
|
||
|
||
**Fehlende T-Shirt-Größe wird für die Kostenschätzung als `M` gewertet** — die
|
||
kleinste Größe mit Untergliederungspflicht (§5/D8), also die konservative
|
||
Annahme „mindestens M". Damit sind auch ungrößte Alternativen vergleichbar.
|
||
Wo `M` nur implizit angenommen ist, zeigt der Knoten ein **invertiertes**
|
||
Größen-Badge (weiß mit Petrol-Rand statt gefüllt) samt Tooltip, damit die
|
||
Annahme sichtbar und von einer echten `(M)`-Angabe unterscheidbar bleibt. Die
|
||
Größen-Semantik in SPEC §5 bleibt unberührt (die Wertung gilt nur der
|
||
Pfad-Kostenrechnung).
|
||
|
||
**Pfad-Linie + Stationspunkte (U-Bahn-Plan):** Zusätzlich zur Inversion fädelt
|
||
eine gestrichelte, geschwungene Petrol-Linie durch die **Endknoten** des Pfads.
|
||
Sie liegt **hinter** den Knoten (kräftig in den Lücken), mit einer **abgetönten
|
||
Kopie davor** (deutet den Verlauf beim Durchschreiten eines Knotens nur an —
|
||
Alternative zum harten „über allem"). Problem dabei: läuft die Linie durch
|
||
Fremd-/Zwischenknoten, ist nicht unterscheidbar, ob diese dazugehören
|
||
(besonders im kompakten Modus). Lösung: ein **blasser, großer Stationspunkt**
|
||
nur an den echten Blättern — durchquerte Knoten bleiben punktlos. Blass+groß
|
||
statt klein+satt, damit der Knotentext lesbar bleibt. Alles auch im
|
||
Grafikexport (hinten/vorne-Schichtung nachgezeichnet).
|
||
|
||
**Layout: all-of unter any-of (horizontal).** Zerlegt eine any-of-Alternative
|
||
selbst wieder all-of (z. B. „Web+Nativ" → Web + Android + iOS), würde der
|
||
breite horizontale Fächer den Alternativknoten zentrieren und samt Elternbaum
|
||
weit nach rechts schieben. Deshalb wird dieser Teilbaum **nur horizontal**
|
||
schmal **transponiert** gestapelt (Kinder untereinander, linker solider
|
||
Verteiler — wie im kompakten Modus), passend zur gestapelten any-of-Spalte
|
||
darüber. Kompakt führt all-of ohnehin nach unten (Knoten bleibt oben,
|
||
Abzweig bei 23 px passt).
|
||
|
||
**Vertikal** dagegen behält den zentrierten Rechts-Fächer: `li.has-and` legt
|
||
den Alternativknoten `align-items:center` **vertikal mittig** zu seiner
|
||
Kindergruppe. Dann sitzt aber auch der **einkommende** any-of-Abzweig nicht mehr
|
||
bei 23 px, sondern muss auf die **Knotenmitte (50 %)** zeigen — sonst ist der
|
||
Knoten von seiner Linie von oben abgetrennt. Fix: `ul.or>li.has-and` bekommt
|
||
vertikal **symmetrisches** Padding (Mitte bleibt bei 50 %), der Abzweig
|
||
(`::before`) und die Rail-Endkante (`:last-child::after`) werden auf 50 %
|
||
gesetzt — analog zu den bereits zentrierten all-of-Zwischenknoten.
|
||
|
||
## D19 — Modularisierung mit Vite-Bündelung zu einer self-contained Datei
|
||
Das Nahziel „tragfähige Codebasis" (ROADMAP) verlangt, den Single-HTML-
|
||
Prototyp in headless nutzbare Module (`parser`, `model`, `render`, `app`) zu
|
||
zerlegen und gegen SPEC zu testen. Die Zwickmühle: **modulare Einzeldateien**,
|
||
**`file://`-Tauglichkeit** (D16) und **kein Build** (D11) sind nicht gleichzeitig
|
||
erfüllbar — ES-`import` über `file://` blockt der Browser (CORS).
|
||
|
||
**Entscheidung: Vite als Bündler/Testrunner.** `frontend/src/*.js` sind echte
|
||
ES-Module und die Quelle der Wahrheit; `frontend/index.html` lädt im Dev-Server
|
||
per `<script type="module">`. `npm run build` (Vite + `vite-plugin-singlefile`)
|
||
inlint **alle** Module, das CSS und das Favicon (als `data:`-URI) in **eine**
|
||
`dist/index.html` — damit bleibt das `file://`-Versprechen aus D16 erhalten
|
||
(die gebaute Datei ist standalone), und der Deploy lädt nichts extern nach.
|
||
|
||
Damit wird **D11 („kein Build-Zwang") bewusst aufgeweicht**: Zum *Weiterent
|
||
wickeln/Testen* braucht es nun Node + Vite (Dev-Abhängigkeiten, keine
|
||
Laufzeit-Abhängigkeiten — das Ergebnis ist reines HTML/CSS/JS ohne Framework).
|
||
Das *Ergebnis* bleibt im Geist von D11: eine einzelne, framework-freie Datei,
|
||
die überall ohne Server läuft. Verworfene Alternativen:
|
||
- **ES-Module ohne Build:** bräche `file://` (Dev-Server-Zwang lokal) — verwarf
|
||
der Nutzer, weil das lokale Öffnen erhalten bleiben soll.
|
||
- **Klassische `<script>`+Globals:** hielte `file://` ohne Build, ist aber kein
|
||
echtes ESM und erschwert headless-Tests/Tree-Shaking.
|
||
|
||
**Deploy (D16-Fortschreibung):** Der Pages-Workflow richtet Node ein, führt
|
||
`npm ci` + `npm test` (Vitest) + `npm run build` aus und nimmt
|
||
`frontend/dist/index.html`. Die frühere `sed`-Kur der `../docs/brand/`-Pfade
|
||
entfällt (Favicon inline); nur der Laufzeit-Link `../LICENSE` und die
|
||
Footer-Version werden weiter auf der Site-Kopie gesetzt. Schlägt der Testlauf
|
||
fehl, wird nicht deployt (der frühere auskommentierte Platzhalter ist nun
|
||
aktiv).
|
||
|
||
(Favicon-Inlining: ein kleiner `transformIndexHtml`-Plugin in `vite.config.js`
|
||
liest `../docs/brand/favicon.svg` und ersetzt den `<link rel="icon">` durch eine
|
||
`data:`-URI — so bleibt die Brand-Quelle unverändert und die Ausgabe eine
|
||
einzige Datei.)
|
||
|
||
## D20 — Schriften lokal einbetten statt von Google Fonts laden
|
||
Die Seite lud IBM Plex Sans/Mono über einen `<link>` von `fonts.googleapis.com`
|
||
(zzgl. Font-Dateien von `fonts.gstatic.com`). Das überträgt bei **jedem**
|
||
Seitenaufruf die **IP-Adresse des Besuchers an Google (USA)** — ohne
|
||
Einwilligung und ohne Notwendigkeit (vgl. LG München I, 3 O 17493/20). Das
|
||
widerspricht einer datensparsamen Datenschutzerklärung ohne Drittanbieter-
|
||
Einbindung und zugleich dem Selbstverständnis aus D19/SPEC §9 („keine externen
|
||
Ressourcen").
|
||
|
||
**Entscheidung: Schriften self-hosten.** Die tatsächlich genutzten Schnitte
|
||
(Sans 400/500/600, Mono 400/500) liegen als `woff2` unter
|
||
`frontend/src/fonts/`, eingebunden per `@font-face` in `style.css` mit
|
||
`font-display:swap`. Bezogen aus den OFL-lizenzierten `@fontsource`-Paketen
|
||
(nur per `npm pack` gezogen — **keine** neue Projekt-Abhängigkeit; die Dateien
|
||
sind eingecheckte Assets), Lizenztext in `fonts/OFL.txt`. Subsets **latin +
|
||
latin-ext** mit den `unicode-range`-Werten von fontsource decken DE/EN/ES/FR/PL;
|
||
nicht-lateinische UI-Sprachen (RU/HI/ZH/JA) fielen schon vorher auf `system-ui`
|
||
zurück (IBM Plex latin enthält diese Schriftsysteme nicht), also keine
|
||
Regression. Der Dev-Server liefert die `woff2` lokal aus; `vite build` inlint
|
||
sie als `data:font/woff2`-URIs in die eine `dist/index.html` (D19) — der
|
||
deployte Stand macht damit **keinen** externen Request mehr (verifiziert: 0
|
||
Treffer `googleapis`/`gstatic`, 10 inline-`woff2`). Kostet ~204 KB Fonts
|
||
(dist 89 → 320 KB, gzip ~208 KB) — bewusst in Kauf genommen für Datenschutz und
|
||
Standalone-Tauglichkeit (`file://`).
|
||
|
||
Verworfene Alternative: Google-Link ersatzlos streichen und nur die System-
|
||
Schrift (`system-ui`) nutzen — spart die 204 KB, gibt aber die einheitliche
|
||
Markentypografie (IBM Plex, BRAND) auf.
|
||
|
||
## D21 — UI-Default-Sprache aus der Browsersprache, Fallback Deutsch
|
||
Ohne gespeicherte Nutzerwahl (`werkbaum-lang`) richtet sich die Anzeigesprache
|
||
nach der **Browsersprache**: die erste aus `navigator.languages`, für die eine
|
||
Übersetzung existiert (nur der Primär-Subtag zählt, `de-AT`→`de`,
|
||
`zh-Hans-CN`→`zh`); trifft keine zu, bleibt **Deutsch** der Fallback
|
||
(`detectLang()` in `app.js`). Zuvor war der Default fest `'de'`, unabhängig vom
|
||
Browser.
|
||
|
||
Begründung: Ein spanisch- oder englischsprachiger Erstbesucher sah bislang ohne
|
||
Not eine deutsche Oberfläche. Die Erkennung greift auch **nach dem Reset**
|
||
(löscht `werkbaum-lang`, lädt neu → selber Pfad). **Deutsch bleibt Quellsprache**
|
||
(CLAUDE: neue UI-Texte zuerst auf Deutsch) — das betrifft die Autoren-/Pflege
|
||
seite und ist unabhängig vom Anzeige-Default für Besucher. Eine bewusste
|
||
Sprachwahl überschreibt die Erkennung dauerhaft (Persistenz in `werkbaum-lang`).
|
||
|
||
## D22 — Mehrere Dokumente client-seitig, Wähler in der Editor-Titelzeile
|
||
Der Editor kann mehrere Notationstexte halten, zwischen denen umgeschaltet wird
|
||
(z. B. verschiedene Projekte/Bäume). Umgesetzt **ohne Backend** (noch keins,
|
||
D13): die Dokumente liegen als `[{id, name, text}]` im localStorage
|
||
(`werkbaum-docs`), das aktive per `id` in `werkbaum-active`. Der aktive Text
|
||
wird zusätzlich in `werkbaum-src` gespiegelt (Abwärtskompatibilität + Migration).
|
||
|
||
**Platzierung: Dropdown in der Editor-Titelzeile.** Der Name des aktiven
|
||
Dokuments **ersetzt** die statische Beschriftung „Struktur (Text)" und ist
|
||
zugleich der Auslöser eines Dropdowns zum Wechseln, Anlegen (`+ Neu`),
|
||
Umbenennen und Löschen. Begründung: Der Wähler bestimmt, *welchen Text* man
|
||
bearbeitet — er gehört auf das Textpanel, nicht ins Diagramm. Verworfen:
|
||
**Kopfzeile oben** (schon eng, besonders mobil) und **Tab-Leiste** (kostet eine
|
||
ganze Zeile Höhe, D17, und skaliert nicht über ~5 Dokumente). Ein Dropdown
|
||
skaliert und passt zum bereits etablierten Overlay-Idiom (Sprache/Download, D17).
|
||
Auf kleinem Bildschirm öffnet dasselbe Menü als absolut positioniertes Overlay
|
||
unter der Titelzeile.
|
||
|
||
**Umbenennen inline, nicht per `window.prompt`.** Das Umbenennen ersetzt den
|
||
Namen des aktiven Dokuments im Menü durch ein Textfeld (Enter bestätigt, Esc
|
||
bricht ab, Fokusverlust bestätigt). Grund: `window.prompt` ist in manchen
|
||
Browser-Kontexten unterdrückt/deaktiviert — dort „funktionierte Umbenennen
|
||
nicht". Das Inline-Feld hängt an keiner nativen Dialog-API und ist zudem die
|
||
rundere UX.
|
||
|
||
**Vereinbarkeit mit D14 (Text ist das führende Format):** Jedes Dokument ist nur
|
||
ein Notationstext plus `name` (Metadatum) — **kein** erfundenes Strukturformat.
|
||
Damit ist das Modell vorwärtskompatibel zum geplanten Persistenz-/Taiga-Backend
|
||
(D13/D14: „Text als Ganzes + explizite Metadaten"): „mehrere Dokumente" bildet
|
||
1:1 auf „mehrere gespeicherte Notationstexte mit Name/id" ab; der localStorage-
|
||
Array ist der client-seitige Platzhalter, bis das Backend existiert.
|
||
|
||
**Beispiel-Dokument mit reservierter id + festem englischem Namen.** Das
|
||
Beispiel trägt die reservierte id `example` und heißt fest **„Example"** —
|
||
**unabhängig von der UI-Sprache** (nicht lokalisiert), passend dazu, dass der
|
||
Beispieltext selbst nur noch englisch ist (breiteres Publikum). Die reservierte
|
||
id macht den Reset **zielgenau** (siehe unten). Alt-Zustände aus der ersten
|
||
Fassung (zufällige id, lokalisierter Name „Beispiel"/…) werden beim Laden
|
||
**adoptiert**: Ein noch **unverändertes** erstes Dokument (`text === INITIAL`)
|
||
bekommt nachträglich `id: example` und den Namen „Example"; echte, bereits
|
||
bearbeitete Inhalte werden nie adoptiert.
|
||
|
||
**Migration:** Fehlt `werkbaum-docs`, wird der bestehende Einzeltext (oder
|
||
`INITIAL`) verlustfrei in **ein** Beispiel-Dokument gepackt. Wird das letzte
|
||
Dokument gelöscht, entsteht wieder das Beispiel-Dokument (INITIAL).
|
||
|
||
**Reset ist auf das Beispiel begrenzt.** Der Reset (Debug-Knopf, nur außerhalb
|
||
des Prod-Builds) setzt **nur das Beispiel-Dokument** auf `INITIAL`/„Example"
|
||
zurück und verwirft die Ansichts-/Metadaten-Schlüssel (`werkbaum-ui`,
|
||
`werkbaum-lang`, Update-Flags) — **alle anderen Dokumente bleiben unangetastet**.
|
||
Zuvor löschte er pauschal **alle** `werkbaum-*`-Schlüssel und damit auch fremde
|
||
Dokumente; das war zu grob, sobald man mehrere Dokumente pflegt.
|
||
|
||
Die GUI-Ansichts-Einstellungen (Modus, Zoom, Aufteilung; `werkbaum-ui`) bleiben
|
||
bewusst **global** über alle Dokumente — pro-Dokument-Ansichtszustand wäre eine
|
||
spätere Erweiterung.
|
||
|
||
**Nachtrag — „Original wiederherstellen“ ist jetzt ein Produkt-Feature im
|
||
Dokumenten-Menü.** Auslöser: Auf der Prod-Instanz gab es **keinen** Weg, ein
|
||
bearbeitetes mitgeliefertes Dokument wieder auf den Auslieferungsstand zu
|
||
bringen — der Reset ist ein Debug-Knopf außerhalb des Prod-Builds, und das
|
||
Fingerabdruck-Nachziehen (D27) fasst bearbeitete Texte grundsätzlich nicht an.
|
||
Wer im Beispiel herumprobiert hatte, sah neue Beispiel-Fassungen also nie.
|
||
Erwogen und **verworfen**: die Alternative, dass eine neue Fassung lokale
|
||
Änderungen still überschreibt — das wäre überraschender Datenverlust und
|
||
bräche die D22/D27-Linie („bearbeitete Inhalte nie anfassen“). Stattdessen ein
|
||
Menü-Eintrag, sichtbar nur für die **mitgelieferten** Dokumente und nur, wenn
|
||
Text oder Name vom Auslieferungsstand abweichen; mit Rückfrage, die den
|
||
Verlust benennt. Wiederhergestellt werden Text **und** Name (der Eintrag sagt
|
||
„Original“); Falt-Eingriffe werden mit verworfen. Der Debug-Reset bleibt
|
||
daneben bestehen — er setzt zusätzlich Einstellungen und Merker zurück.
|
||
|
||
## D23 — Notationstext per `?sourceUrl=` von einer URL laden
|
||
Der Editor kann den Notationstext aus einer **externen Textdatei** beziehen:
|
||
`…/index.html?sourceUrl=https://example.org/plan.txt`. Damit lässt sich ein
|
||
Diagramm teilen/verlinken, ohne den Text in die URL zu packen, und die Quelle
|
||
kann anderswo (Git, Wiki, Server) gepflegt werden.
|
||
|
||
**Die URL ist der Titel.** Das geladene Dokument wird als eigenes Dokument im
|
||
Sinne von D22 geführt; sein **Name ist die URL**. Die **id leitet sich aus der
|
||
URL ab** (`url:<href>`), damit derselbe Link dieses eine Dokument *aktualisiert*,
|
||
statt bei jedem Aufruf ein neues anzulegen. Eigene Dokumente des Nutzers bleiben
|
||
unberührt. Da der Name in der Titelzeile mit Ellipse abgeschnitten wird, steht
|
||
die vollständige URL zusätzlich im Tooltip.
|
||
|
||
**Die URL ist die Quelle der Wahrheit:** Ist der Parameter gesetzt, wird bei
|
||
**jedem** Laden neu geholt und der Dokumenttext überschrieben. Lokale Änderungen
|
||
an einem URL-Dokument überleben ein Neuladen also nicht — bewusst, weil
|
||
`sourceUrl` eine *Ansicht auf eine entfernte Datei* ist, nicht deren Kopie. (Eine
|
||
konfliktbewusste Variante — lokale Änderungen erkennen und behalten — wäre eine
|
||
mögliche Erweiterung.)
|
||
|
||
**CORS ist die eigentliche Einschränkung.** Der Browser lädt fremde Hosts nur,
|
||
wenn die Zielseite `Access-Control-Allow-Origin` sendet. Das tun u. a.
|
||
`raw.githubusercontent.com` und GitLab-Raw-Links; ein beliebiger Webserver
|
||
oft **nicht**. Scheitert das Laden (CORS, 404, Netz), bleibt der bisherige Stand
|
||
stehen und es erscheint eine **Warnung** im Warnbereich (Typ `sourceLoad`,
|
||
zeilenlos ⇒ zuoberst), die CORS ausdrücklich als wahrscheinliche Ursache nennt.
|
||
Bewusst kein Proxy-Dienst als Ausweg: das würde fremde Inhalte über einen
|
||
Dritt-Host leiten und dem Datenschutz-Anspruch aus D20 widersprechen.
|
||
|
||
**Verhältnis zu D20 („keine externen Requests"):** Der Grundsatz bleibt — die App
|
||
lädt von sich aus **nichts** nach (Schriften inline, kein CDN). Der Request
|
||
entsteht **nur**, wenn der Nutzer selbst eine URL angibt, und geht **nur** an
|
||
genau diesen Host; `credentials:'omit'` verhindert das Mitsenden von Cookies.
|
||
Erlaubt sind nur `http:`/`https:` (relative Angaben werden gegen die Seite
|
||
aufgelöst); andere Schemata (`file:`, `data:`, `javascript:`) werden abgewiesen.
|
||
Fremder Text ist ungefährlich: Labels werden escaped, und der Parser erkennt als
|
||
Knoten-Link ohnehin nur `https?://…` (SPEC §1/§6) — kein `javascript:`-Vektor.
|
||
|
||
Nebenbefund: `checkForUpdates()` hängte den Cache-Buster als `location.href +
|
||
'?t=…'` an, was mit vorhandenem Query-String ein zweites `?` erzeugt hätte; es
|
||
baut die URL nun über `URL`/`searchParams`.
|
||
|
||
## D24 — Eigene Dateiendung `.werkbaum`
|
||
Notationstexte tragen die Endung **`.werkbaum`** (UTF-8, LF). Bislang lag das
|
||
einzige Beispiel als `.txt` — eine Endung, die nichts über den Inhalt sagt und
|
||
in einem Verzeichnis mit Notizen, Logs und Exporten untergeht.
|
||
|
||
**Begründung:** Die Endung macht Dateien maschinell und für Menschen
|
||
zuordenbar — Voraussetzung für spätere Editor-Zuordnung (Öffnen mit Werkbaum),
|
||
Syntax-Highlighting (VS Code/Vim-Modus, `*.werkbaum`) und ein künftiges
|
||
Öffnen/Speichern im Editor (dann als `accept`-Filter und Download-Endung).
|
||
Ausgeschrieben statt kurz, weil kurze Endungen belegt/mehrdeutig sind: `.wbs`
|
||
wird von diversen Projektplanungswerkzeugen und generisch für „Work Breakdown
|
||
Structure" benutzt, `.wb` ist ebenfalls mehrfach vergeben, `.tree` sagt nichts
|
||
über die Notation. Die Länge stört nicht: die Dateien werden selten getippt und
|
||
meist als URL geteilt.
|
||
|
||
**Kein registrierter MIME-Typ nötig.** Server liefern unbekannte Endungen als
|
||
`application/octet-stream` oder `text/plain` aus; für das Laden per
|
||
`?sourceUrl=` (D23) ist das gleichgültig, weil der Loader den `Content-Type`
|
||
**nicht** auswertet, sondern `response.text()` liest. Empfehlung für eigene
|
||
Server dennoch `text/plain; charset=utf-8`, damit die Datei im Browser lesbar
|
||
statt als Download erscheint. `raw.githubusercontent.com` liefert `.werkbaum`
|
||
als `text/plain; charset=utf-8` mit `Access-Control-Allow-Origin: *`.
|
||
|
||
**`.txt` bleibt zulässig** — die Endung ist Konvention, kein Vertrag. Der
|
||
Parser sieht ohnehin nur Text (SPEC §1), und `?sourceUrl=` lädt jede per
|
||
http(s) erreichbare Textdatei unabhängig von Endung und Content-Type. Es gibt
|
||
also keinen Bruch für bestehende Links.
|
||
|
||
**Beispieldateien liegen unter `docs/examples/`** (`example-plan-0…3.werkbaum`,
|
||
`werkbaum.werkbaum`) statt einzeln in `docs/`. Mehrere Beispiele, weil
|
||
sich das Umschalten zwischen Dokumenten (D22) erst mit mehreren *geladenen*
|
||
Dokumenten zeigen lässt: jeder `?sourceUrl=`-Link legt ein eigenes Dokument an
|
||
(id aus der URL, D23), nacheinander geöffnet stehen sie danach alle im Wähler.
|
||
`werkbaum.werkbaum` beschreibt Werkbaum selbst (Bestand + mögliche
|
||
Weiterentwicklung, destilliert aus ROADMAP/TASKS/DECISIONS) — zugleich
|
||
Beispiel und lebende Projektübersicht; bei größeren Änderungen mitpflegen.
|
||
|
||
## D25 — Sprung Diagramm ↔ Text per Alt+Klick, Gegenrichtung per Cursor-Zeile
|
||
Ein Knoten im Diagramm und „seine" Zeile im Texteditor sind nun verknüpft. Der
|
||
Parser hängte die Zeilennummer ohnehin schon an jeden Knoten (`{…, line}`,
|
||
bislang nur für Warnungen genutzt); der Renderer gibt sie als `data-line` aus.
|
||
|
||
**Alt+Klick statt einfachem Klick.** Ein Knoten mit URL ist als `<a>` gerendert
|
||
und belegt mit dem einfachen Klick bereits den ganzen Kasten (SPEC §6, D6).
|
||
Erwogen und verworfen:
|
||
|
||
- **↗ wird der Link, Knotenfläche springt** — klarste Regel, alles per
|
||
einfachem Klick; ändert aber SPEC §6 und schrumpft die Link-Trefferfläche auf
|
||
ein Symbol.
|
||
- **Klick springt, Strg-Klick öffnet** — behält die große Trefferfläche, macht
|
||
das Öffnen aber zur unsichtbaren Geste und ändert §6 ebenfalls.
|
||
- **Gewählt: Alt+Klick springt** — das Verlinkungs-Verhalten bleibt exakt wie
|
||
bisher, SPEC §6 unverändert. Preis ist die geringe Auffindbarkeit; dagegen
|
||
steht der **Tooltip an jedem Knoten** („Alt+Klick: zur Zeile im Text",
|
||
i18n-Key `jumpHint`, in allen 9 Sprachen), der bisher nur den Statusnamen
|
||
zeigte.
|
||
|
||
Wichtig: Der Klick-Handler **muss `preventDefault()`** rufen — Alt+Klick auf
|
||
einen Link lädt sonst in Chrome/Firefox das Ziel herunter. Tastatur-Pendant ist
|
||
**Alt+Enter** am fokussierten Knoten (Enter allein bleibt dem Link). Auf Touch
|
||
gibt es kein Alt: dort **langer Druck** (500 ms), Wischen bricht ab; der
|
||
folgende Klick und das Kontextmenü werden unterdrückt (`-webkit-touch-callout`
|
||
aus), sonst öffnete ein Link-Knoten zusätzlich seine URL.
|
||
|
||
**Ganze Zeile markieren statt nur Cursor setzen.** Ein `<textarea>` kennt keine
|
||
Zeilen-Hervorhebung (kein Rich-Text-Markup); die native Auswahl ist die einzige
|
||
Betonung, die es gibt — und sie verschwindet beim ersten Tippen von selbst.
|
||
|
||
**Scrollen über einen Spiegel-`div`, nicht über Zeilenhöhe × n.** Lange Zeilen
|
||
brechen weich um und belegen mehrere Bildzeilen; die naive Rechnung lag im Test
|
||
bei 60 Zeilen um bis zu 525 px daneben. Gemessen wird deshalb an einem
|
||
unsichtbaren `div` mit gleicher Typografie und Breite plus Marker-Span
|
||
(`offsetTop`). Gescrollt wird nur, wenn die Zeile nicht ohnehin sichtbar ist.
|
||
|
||
**Gegenrichtung (Cursor-Zeile → Knoten)** ist bei großen Bäumen die nützlichere
|
||
Hälfte: `werkbaum.werkbaum` hat 75 sichtbare Knoten, ohne Markierung
|
||
verliert man beim Tippen die Orientierung. Der Knoten der Cursor-Zeile bekommt
|
||
die Klasse `current`; ins Bild gescrollt wird **nur beim Zeilenwechsel**, sonst
|
||
ruckelte das Diagramm bei jedem Tastendruck. Vor der ersten Cursor-Bewegung ist
|
||
`caretLine === null` — sonst wäre direkt nach dem Laden ungefragt die Wurzel
|
||
markiert.
|
||
|
||
**Darstellung: weißer Halo + Ring in Tinte** (`box-shadow`, kein Rahmen). Hebt
|
||
sich von allen Pastell-Status *und* vom dunklen Wurzelknoten ab, ist vom
|
||
petrolfarbenen Fokusring (`:focus-visible`) unterscheidbar und rührt die
|
||
Knoten-Ecken nicht an (die sind laut D18 schon dicht). Die Regel braucht den
|
||
`#out`-Präfix: `ul.or .node{box-shadow:none}` ist spezifischer als
|
||
`.node.current` und schluckte den Ring sonst überall unterhalb einer
|
||
any-of-Gruppe (im Test zuerst passiert). Rein visuelle Editierhilfe — im Druck
|
||
abgeschaltet; im Grafikexport erscheint sie ohnehin nicht, weil `diagramToSvg()`
|
||
nur Hintergrund, Rahmen, Farbe und Textdekoration ausliest, nie `box-shadow`.
|
||
|
||
**Auffindbarkeit (Nachtrag).** Eine Modifier-Geste, die niemand kennt, ist
|
||
keine Funktion. Gegenmaßnahmen, absteigend nach Wirkung:
|
||
|
||
- **Alt-Modus sichtbar machen:** Solange Alt gedrückt ist, trägt `#out` die
|
||
Klasse `alt`; alle Knoten bekommen `cursor:alias` und der Knoten unter dem
|
||
Zeiger einen Petrol-Ring. Das wirkt auch auf **verlinkten** Knoten, wo der
|
||
einfache Klick dem Link gehört und es sonst gar keine Rückmeldung gäbe. Der
|
||
Modus muss zusätzlich am `blur` des Fensters zurückgesetzt werden — bei
|
||
Alt+Tab kommt kein `keyup` mehr an und er bliebe hängen.
|
||
- **Tooltip an jedem Knoten** (siehe oben, `jumpHint`).
|
||
- **Legende:** Die aufklappbare Legende endet mit einer abgesetzten
|
||
Bedienungs-Zeile (`hint_jump`, `.hint-op` mit gestricheltem Trenner), damit
|
||
sie ihre Rolle als *Notations*-Legende behält.
|
||
|
||
Erwogen und **verworfen: der einfache Klick springt** (auf Knoten ohne URL, wo
|
||
nichts kollidiert — das wären ~97 % der Knoten der Beispieldateien). Hätte die
|
||
Geste beiläufig auffindbar gemacht und auf Mobil den langen Druck erspart,
|
||
bedeutet aber, dass derselbe Klick je nach Knoten Verschiedenes tut. Ebenfalls
|
||
verworfen: ein **einmaliger Hinweis** nach dem ersten Sprung (localStorage-Flag)
|
||
— zusätzlicher i18n-Text in 9 Sprachen für einen Effekt, den Cursor und Legende
|
||
bereits abdecken.
|
||
|
||
**Touch: der Sprung passiert beim Loslassen, nicht nach 500 ms (Nachtrag).**
|
||
Erste Fassung führte den Sprung im 500-ms-Timer aus. Auf echten Touch-Geräten
|
||
flackerte die Markierung im Editor dann nur kurz auf und der Fokus fiel sofort
|
||
wieder heraus: **`focus()` aus einem Timer-Callback gilt in mobilen Browsern
|
||
nicht als Nutzergeste**, das Textfeld darf sich so nicht selbst fokussieren
|
||
(sonst könnte jede Seite ungefragt die Bildschirmtastatur aufziehen).
|
||
`touchend` **ist** eine Nutzergeste — dort bleibt der Fokus. Der Timer macht
|
||
seither nur noch die Rückmeldung („scharf": Petrol-Ring am Zielknoten, dieselbe
|
||
Sprache wie unter dem Alt-Zeiger), der eigentliche Sprung hängt am Loslassen.
|
||
|
||
Zweiter Beitrag zum selben Symptom: Die **native Textauswahl bzw. das
|
||
Link-Callout** des Browsers startet bei etwa derselben Druckdauer und riss die
|
||
Auswahl an sich. Dagegen drei Schichten, weil keine allein überall wirkt:
|
||
`preventDefault()` auf `contextmenu` während des Drucks (überall),
|
||
`user-select:none` an `.node` für grobe Zeiger (`@media (hover:none) and
|
||
(pointer:coarse)`) und `-webkit-touch-callout:none` (nur iOS/Safari; Chrome
|
||
kennt die Eigenschaft nicht mehr). Auf Touch lässt sich Knotentext damit nicht
|
||
mehr markieren — bewusst in Kauf genommen, die Geste ist dort vergeben.
|
||
|
||
Lehre für die Prüfung: Synthetische `TouchEvent`s beweisen nur die eigene
|
||
Ereignis-Logik. Weder die Nutzergesten-Regel noch die nativen Langdruck-Gesten
|
||
lassen sich so auslösen — beides fiel erst auf echter Hardware auf.
|
||
|
||
**Der Sprung holt keine Bildschirmtastatur (Nachtrag).** Auf Touch-Geräten
|
||
erschien nach dem Sprung sofort die virtuelle Tastatur und nahm den halben
|
||
Bildschirm — auch bei angeschlossener Bluetooth-Tastatur. Das ist kein Fehler
|
||
der App: Eine Webseite erfährt nichts über verbundene Tastaturen; sie fordert
|
||
nur **Fokus** an, alles Weitere entscheidet das Betriebssystem (Android hat
|
||
dafür den Schalter „Bildschirmtastatur anzeigen" unter *Physische Tastatur*,
|
||
auf vielen Geräten voreingestellt an; iPadOS unterdrückt sie von selbst).
|
||
|
||
Entscheidung: Der Sprung **fordert sie erst gar nicht an**. `jumpToLine()` setzt
|
||
vor dem Fokussieren `inputmode="none"` am Textfeld — das unterdrückt nur die
|
||
**virtuelle** Tastatur, Hardware-Tastaturen tippen unverändert. Die Sperre fällt,
|
||
sobald der Nutzer das Textfeld **selbst antippt** (`pointerdown`, läuft vor dem
|
||
Fokus). Damit ist der Sprung „hinschauen" und der erste Tipp ins Textfeld
|
||
„bearbeiten". `newDoc()` hebt die Sperre ausdrücklich auf — bei einem neuen,
|
||
leeren Dokument ist Tippen gemeint.
|
||
|
||
Verworfen: die Tastatur zuzulassen und auf die OS-Einstellung zu verweisen (löst
|
||
es nur auf einem Gerät), sowie ein eigener Umschalter dafür (weiteres
|
||
Bedienelement in einer engen Kopfzeile plus i18n in 9 Sprachen, für ein
|
||
Verhalten, das kaum jemand umstellen will).
|
||
|
||
**Nachtrag — Alt+Klick gibt es jetzt auch im Textfeld.** Gemeldet als „das
|
||
Alt+Klick muss im Text-Editor wieder funktionieren, um den Knoten im Diagramm zu
|
||
fokussieren". Nachgesehen: Die Geste hat es dort nie gegeben — die Rückrichtung
|
||
lief bisher **allein** über die Cursor-Zeile. Das ist trotzdem kein
|
||
Missverständnis, sondern ein Befund: Die Rückrichtung fühlt sich schwächer an,
|
||
weil sie es ist.
|
||
|
||
Der Grund steckt in ihrer eigenen Auslegung. Die Cursor-Zeile scrollt bewusst
|
||
**nur beim Zeilenwechsel** und **nur `nearest`** — beides notwendig, sonst
|
||
ruckelte das Diagramm bei jedem Tastendruck. Genau das macht sie aber als *Zeig
|
||
mir das* untauglich: Wer denselben Knoten noch einmal sucht, bekommt nichts, und
|
||
wer ihn am Bildrand hat, bekommt ihn an den Bildrand. Es fehlte also nicht die
|
||
Verknüpfung, sondern eine **ausdrückliche** Geste daneben.
|
||
|
||
Deshalb: **Alt+Klick im Textfeld** (Tastatur **Alt+Enter**) zentriert den Knoten
|
||
der Cursor-Zeile und gibt ihm den **Tastaturfokus**. Derselbe Modifier wie in der
|
||
Gegenrichtung — eine Geste, zwei Richtungen, nichts Neues zu lernen; die
|
||
vorhandene Legenden-Zeile (`hint_jump`) nennt jetzt beide Richtungen, statt einen
|
||
zehnten i18n-Schlüssel in neun Sprachen aufzumachen.
|
||
|
||
**Der Fokus wandert wirklich mit** (`el.focus({preventScroll:true})`, dann
|
||
bewusst `scrollIntoView({block:'center'})`). Erwogen war, nur zu scrollen und den
|
||
Cursor im Text zu lassen — weniger störend beim Tippen. Dagegen sprechen zwei
|
||
Dinge: Die Gegenrichtung nimmt den Fokus ebenso mit (in den Text hinein), und ein
|
||
Screenreader erfährt vom Scrollen nichts. Mit Fokus wird daraus ein
|
||
vollständiger Hin- und Rückweg: Alt+Klick in den Baum, dort mit Tab/Pfeil
|
||
weiter, Alt+Enter zurück in die Zeile.
|
||
|
||
`preventDefault()` beim Tastaturweg ist Pflicht, sonst bekommt der Text einen
|
||
Umbruch. Auf einer Zeile ohne Knoten (Kommentar, Leerzeile, ausgeblendetes
|
||
Verworfenes) geschieht nichts — dieselbe stille Regel wie bei der Cursor-Zeile.
|
||
|
||
**Nachtrag — die Cursor-Zeile hebt sich jetzt aus der Ebene, und die fehlende
|
||
Ausnahme gegen die Pfad-Inversion.** Gemeldet als „müsste etwas deutlicher
|
||
sein". Beim Nachsehen kam zuerst etwas anderes heraus, das keine
|
||
Gestaltungsfrage ist:
|
||
|
||
**Auf einem guten Drittel der Knoten war der Ring gar nicht zu sehen.** Die
|
||
Pfad-Inversion (D18) setzt `.cheap-on .node:not(.cheap){opacity:.32;
|
||
filter:saturate(.4)}` — und das trifft den `box-shadow` mit. Genau diese Falle
|
||
haben D28 (gelber Kranz) und D32 (Fokusmarke) je für sich gefunden und mit
|
||
einer Ausnahmeregel behoben; `.current` hat seine nie bekommen. Gemessen im
|
||
mitgelieferten Beispiel mit dem Cursor auf `+ [?] Dark mode`: Deckkraft 0,32.
|
||
Der Fall ist zudem der häufigste der drei — der Pfad-Umschalter ist
|
||
voreingestellt an, und jeder optionale Knoten und jede nicht gewählte
|
||
Alternative fällt darunter (im Beispiel 7 von 18 Knoten). Anders als beim
|
||
optionalen Knoten (D29), wo das Zurücktreten die **Aussage** ist, wird hier
|
||
überhaupt keine Aussage über den Plan gemacht: Es ist eine Editierhilfe, und wo
|
||
der Cursor steht, muss sichtbar bleiben, egal wie der Pfad entschieden hat.
|
||
|
||
**Warum der Ring auch ungedimmt leise wirkt:** Er benutzt denselben Kanal, den
|
||
schon jeder Knoten belegt — **jeder Status hat einen Rahmen** (SPEC §4). Ein
|
||
weiterer Ring in einem Feld aus lauter gerahmten Kästchen ist ein Unterschied
|
||
im Grad, nicht in der Art. Die beiden Strahlenkränze entkommen dem, weil sie
|
||
einen Kanal benutzen, den sonst nichts hat (Leuchten nach außen); die
|
||
Cursor-Zeile war auf dem Rahmen-Kanal sitzen geblieben.
|
||
|
||
**Gewählt: Tiefe** — Ring behalten, dazu Schlagschatten und `scale(1.04)`. Der
|
||
Knoten hebt sich aus der Ebene. Das ist der einzige Kanal, den im Diagramm noch
|
||
gar nichts belegt (nichts anderes wirft Schatten oder bewegt sich), kollidiert
|
||
also mit **keiner** Farbcodierung und wirkt über allen acht Statusfarben
|
||
gleich. Zwei angenehme Eigenschaften, beide nachgemessen: `transform` ändert
|
||
kein Layout (die Linien bleiben stehen), und weil um die **Mitte** skaliert
|
||
wird, bleibt die Knotenmitte punktgenau erhalten (dx = dy = 0) — `alignStems()`
|
||
(D29) und die Stationspunkte des günstigsten Pfads (D18) messen genau die und
|
||
bleiben unberührt. Nur `drawDepLinks()` (D41) setzt auf Knoten**kanten** auf und
|
||
verschiebt sich für diesen einen Knoten um ~4 px; das ist flüchtig und fällt
|
||
nicht auf. Ein Spalt zur Anschlusslinie entsteht nicht — der Knoten wächst
|
||
darüber, statt sich zurückzuziehen.
|
||
|
||
*(Die Prüfung übersah eine Wirkung des `z-index`, die nicht die Geometrie
|
||
betrifft: Er hebt den Knoten auch über die vorderen Zeichenebenen. Korrigiert
|
||
in Nachtrag 3.)*
|
||
|
||
**Dazu ein einmaliger Puls beim Zeilenwechsel:** Man verliert den Knoten beim
|
||
**Bewegen**, nicht im Stillstand — also meldet er sich genau dann einmal, mit
|
||
einem kurzen Hüpfer und einem auslaufenden Ring. Kein WCAG-Problem: 2.2.2 zielt
|
||
auf Blinkendes, 2.3.1 auf Flackern über 3 Hz, ein einzelner Durchlauf ist
|
||
keines von beidem (dieselbe Prüfung wie in D28); `prefers-reduced-motion`
|
||
schaltet ihn ab. Ausgelöst wird er an derselben Bedingung, an der schon das
|
||
Scrollen hängt (`caretLine` hat sich geändert) — sonst pulste er bei jedem
|
||
Tastendruck. Bewusst **nicht** über `box-shadow` animiert: Die Kombinationen
|
||
mit `.fresh` und `.focusmark` haben je eigene Schatten-Listen, eine Animation
|
||
darauf ließe den gelben bzw. petrolfarbenen Kranz für die Dauer des Pulses
|
||
verschwinden. Stattdessen `transform` plus das freie `.node::after`
|
||
(`::before` gehört dem Optional-Kreis, D29).
|
||
|
||
**Der Export brauchte eine eigene Behandlung** — und das ist die Stelle, an der
|
||
die Erhebung anders liegt als der Ring. D25 konnte sich darauf verlassen, dass
|
||
`diagramToSvg()` nie `box-shadow` ausliest; die Vergrößerung schlägt aber über
|
||
`getBoundingClientRect()` durch, mit dem der Export die Live-Geometrie nachzieht
|
||
— genau ein Knoten stünde 4 % zu groß im Bild. Während des Messens trägt `#out`
|
||
deshalb die Klasse `exporting`, die Erhebung und Puls neutralisiert.
|
||
Nachgemessen: 155,6 statt 161,9 px, also die unskalierte Breite. Per Klasse
|
||
statt durch Abnehmen von `.current`/`.pulse`, damit der Export keine laufende
|
||
Animation abreißt und hinterher neu startet. Im Druck fällt beides ebenso weg.
|
||
|
||
**Verworfene Alternativen:** ein **dritter Strahlenkranz** (ein dunkles
|
||
Tinte-Leuchten liest sich auf hellem Grund als Schatten — die Lehre steht schon
|
||
im D32-Nachtrag —, und ein heller Schein bräuchte eine dritte Signalfarbe neben
|
||
Gelb und Teal); **Invertieren** des Knotens auf Tinte-Füllung mit weißer Schrift
|
||
(das stärkste Signal, kostet aber für die Cursor-Zeile die Statusfarbe — genau
|
||
die Regel, wegen der D28 und D32 ihre Kränze nach außen gelegt haben, und für
|
||
eine Editierhilfe wäre sie schlecht gebrochen); ein **Zeiger-Dreieck** links am
|
||
Knoten (`::after` wäre frei, bräuchte aber wie der Optional-Kreis eigene
|
||
Geometrie für alle drei Darstellungsmodi); und **die Umgebung zurücktreten
|
||
lassen** (flackerte bei jedem Pfeiltastendruck durch den ganzen Baum).
|
||
|
||
Nebenbefund beim Aufräumen: Der Kommentar über der Regel behauptete noch, die
|
||
Fokusmarke `!!!` trage „bewusst DIESELBE Hervorhebung" — überholt, seit der
|
||
D32-Nachtrag ihr den eigenen Petrol-Kranz gegeben hat. Ersetzt.
|
||
|
||
**Nachtrag 2 — Alt+Klick im Textfeld pulst jetzt auch.** Die erste Fassung des
|
||
Pulses hing an derselben Bedingung wie das Scrollen, und `focusNodeOfCaret()`
|
||
gab dort bewusst `false` weiter, um ein doppeltes Scrollen zu vermeiden
|
||
(erst `nearest`, dann `center`). Folge: Ausgerechnet die **ausdrückliche**
|
||
Geste — „ich will diesen Knoten jetzt sehen" — kam stiller an als das
|
||
beiläufige Tippen. Das ist verkehrt herum.
|
||
|
||
Getrennt sind die beiden Dinge jetzt sauber: `highlightCurrentNode(moved,
|
||
scroll)` nimmt zusätzlich, **wie** ins Bild geholt wird — `'nearest'` beim
|
||
gewöhnlichen Zeilenwechsel, `'center'` beim Alt+Klick, `false` beim Neubau.
|
||
Die Hervorhebung ist in allen Fällen dieselbe, Puls eingeschlossen; der
|
||
Unterschied liegt allein im Scroll-Modus. Der Fokus wandert weiter mit
|
||
(D25-Nachtrag 1, `focus({preventScroll:true})` **vor** dem Scrollen) — er
|
||
verändert das Bild nicht, weil `:focus-visible` nach einem Mausklick nicht
|
||
greift (nachgemessen: `false`).
|
||
|
||
Der Fall, den man beim Bauen leicht übersieht: Alt+Klick trifft oft den Knoten,
|
||
der **schon** die Cursor-Zeile ist. Dann ist es dasselbe DOM-Element, und der
|
||
Puls muss trotzdem neu anlaufen — genau dafür steht das Lesen von
|
||
`offsetWidth` zwischen Entfernen und Setzen der Klasse. Nachgemessen: vor dem
|
||
Klick keine laufende Animation, 80 ms danach beide bei `currentTime ≈ 67 ms`.
|
||
|
||
**Nachtrag 3 — „vorn" heißt vorn: die vorderen Zeichenebenen brauchen einen
|
||
eigenen `z-index`.** Gemeldet als „wenn der Knoten der aktive ist, fehlt der
|
||
blasse Kreis für den Lean Path". Die Ursache ist der `z-index:3`, den Nachtrag 1
|
||
dem Knoten gegeben hat — richtig begründet (ohne ihn beschneiden später gemalte
|
||
Geschwister den Schlagschatten), aber mit einer zweiten Wirkung, die dort nicht
|
||
mitgedacht wurde: Er hebt den Knoten nicht nur über seine Geschwister, sondern
|
||
über **jede** Ebene ohne eigene Stapelposition — und genau das waren die
|
||
Overlay-SVGs. `svg.cheap-front` trug nur `position:absolute`; die
|
||
Zeichenreihenfolge kam allein aus der DOM-Position. Damit lag ausgerechnet der
|
||
hervorgehobene Knoten über seinem eigenen Stationspunkt.
|
||
|
||
Der Fehler ist **nicht** auf die Cursor-Zeile beschränkt: Die Strahlenkränze
|
||
haben aus demselben Grund `z-index:2` (D28/D32), verdeckten den Punkt also
|
||
ebenso. Und `svg.dep-front` (D41) hing daran mit — die hervorgehobenen
|
||
Abhängigkeits-Kanten gehören per Definition zum ausgewählten Knoten und endeten
|
||
deshalb unter ihm, also genau dort, wo man hinsieht.
|
||
|
||
Behoben an der Ebene, nicht am Knoten: `.dep-front{z-index:4}` und
|
||
`.cheap-front{z-index:5}` — beide über der höchsten Knoten-Stufe. Die
|
||
**hinteren** Ebenen bleiben ohne `z-index`; sie sollen hinter den Knoten
|
||
liegen, und dort funktioniert die DOM-Reihenfolge. Den `z-index` am Knoten zu
|
||
senken wäre der falsche Griff gewesen: Er hat seinen Grund, und die
|
||
Overlay-Ebenen heißen nicht ohne Absicht „front".
|
||
|
||
Export und Druck waren nie betroffen — dort zeichnet `diagramToSvg()` die
|
||
Punkte nach den Knoten (Reihenfolge statt Stapelung), und die Cursor-Zeile
|
||
erscheint ohnehin nicht (D25 oben).
|
||
|
||
## D26 — Legende scrollbar: eigener Container statt `<details>`, plus Splitter
|
||
Die Legende („Agenda") im Editor-Panel war zu hoch für ihren Platz und wurde
|
||
**abgeschnitten** statt scrollbar zu sein — obwohl `.hint` seit jeher
|
||
`flex:1 1 auto; min-height:0; overflow:auto` trug.
|
||
|
||
**Ursache:** Chrome legt den Inhalt eines `<details>` seit einiger Zeit in das
|
||
Pseudo-Element **`::details-content`**. Damit ist `.hint` **kein Flex-Kind** von
|
||
`.agenda` mehr; die Flex-Begrenzung greift nicht, `.hint` wächst auf seine
|
||
Inhaltshöhe (gemessen 585 px in einem 282 px hohen Container) und wird vom
|
||
`overflow:hidden` der Agenda geclippt. Ein Gegenmittel wäre
|
||
`.agenda::details-content{display:flex;…}` (im Test wirksam: 585 → 248 px,
|
||
scrollbar), aber das Pseudo-Element ist Chrome-eigen — Firefox und Safari kennen
|
||
es nicht, dort hinge dieselbe Layout-Kette an einem anderen anonymen Kasten.
|
||
|
||
**Entscheidung: kein `<details>` mehr.** Die Legende ist ein gewöhnliches
|
||
`<div class="agenda">` mit `<button class="agenda-summary">`; der Auf-/Zu-Zustand
|
||
hängt an der Klasse `open` (`aria-expanded` am Button). Damit ist `.hint` wieder
|
||
ein echtes Flex-Kind und die Begrenzung in **allen** Browsern dieselbe. Die
|
||
native Aufklapp-Mechanik war ohnehin nur halb genutzt — mobil steuert seit D17
|
||
der `#legendBtn` im Kopf, das `toggle`-Ereignis diente nur der Synchronisierung.
|
||
|
||
**Splitter Editor|Legende.** Die Aufteilung ist nun frei ziehbar, im selben
|
||
Idiom wie der große Splitter (`pointerdown`/`pointermove`/Pointer-Capture,
|
||
Doppelklick setzt zurück). Die Ausrichtung folgt derselben Fallunterscheidung
|
||
wie dort: horizontal nebeneinander → Legendenbreite `--hcol`, gestapelt (`side`
|
||
oder mobil) → Legendenhöhe `--hrow`. **Beide Werte werden getrennt gehalten und
|
||
persistiert** (`werkbaum-ui`), sodass ein Moduswechsel die jeweils andere
|
||
Aufteilung nicht zerstört; der Auf-/Zu-Zustand der Legende wird mitgesichert.
|
||
Grenzen: mindestens 90 px, höchstens 85 % — die Obergrenze steht **zusätzlich
|
||
als `max-width`/`max-height` im CSS**, weil die gespeicherte Größe ein fester
|
||
px-Wert ist: schrumpft das Panel später (Zug am großen Splitter, Drehung,
|
||
Bildschirmtastatur), würde der Editor sonst auf 0 gedrückt.
|
||
|
||
## D27 — „Werkbaum": der eigene Plan als mitgeliefertes Dokument
|
||
Neben dem Beispiel (D22) liegt ein zweites mitgeliefertes Dokument im Wähler:
|
||
**„Werkbaum"** — Werkbaum selbst, mit Werkbaum geplant (Bestand + mögliche
|
||
Weiterentwicklung). Damit ist der interessanteste Beispielbaum ohne Umweg über
|
||
einen `?sourceUrl=`-Link erreichbar; zugleich dient er als lebende
|
||
Projektübersicht.
|
||
|
||
**Eine Quelle, keine Kopie.** Der Text wird per `?raw`-Import aus
|
||
`examples/werkbaum.werkbaum` gezogen — derselben Datei, die auch
|
||
der `?sourceUrl=`-Link lädt. Vite bettet sie beim Build in die eine Ausgabedatei
|
||
ein (D19), es wird nichts nachgeladen (D20). Eine zweite, abgetippte Fassung im
|
||
Quelltext würde unweigerlich auseinanderlaufen. (Nebenwirkung: die Beispieldatei
|
||
ist damit **Build-Eingabe** — Umbenennen bricht den Build. `vite.config.js`
|
||
erlaubt den Zugriff außerhalb des Roots bereits über `server.fs.allow: ['..']`,
|
||
eingeführt für das Favicon.)
|
||
|
||
**Genau einmal angelegt.** `seedShippedDocs()` fügt das Dokument auch
|
||
Bestandsnutzern hinzu, die schon eine Dokumentenliste haben, und merkt sich das
|
||
in `werkbaum-seeded`. Ohne diesen Merker gäbe es nur schlechte Alternativen:
|
||
entweder bekämen bestehende Nutzer es nie, oder ein bewusst gelöschtes Dokument
|
||
kehrte bei **jedem** Laden zurück. Das aktive Dokument bleibt beim Anlegen
|
||
unverändert — niemand wird aus seinem Text gerissen.
|
||
|
||
**Fester Name, nicht lokalisiert** — wie „Example" (D22): Dokumentnamen sind
|
||
Nutzerdaten. (Die erste Fassung hieß durch einen Tippfehler „Werkbank";
|
||
`loadDocs()` zieht den Namen nach — aber **nur**, solange er unverändert der
|
||
ausgelieferte ist, damit eine eigene Umbenennung stehen bleibt.) Der Reset (D22) setzt jetzt **beide** mitgelieferten Dokumente auf
|
||
ihren Auslieferungsstand zurück; eigene Dokumente bleiben weiterhin unangetastet.
|
||
|
||
**Nachziehen bei neuer Fassung (nachgereicht).** Zuerst wurde der Text nur
|
||
einmalig angelegt — eine spätere Ergänzung des Plans erreichte niemanden mehr.
|
||
Das fiel sofort auf, als der Plan um den Abschnitt „gemeinsam arbeiten"
|
||
(ROADMAP) wuchs. `werkbaum-seeded` hält deshalb nicht mehr '1', sondern den
|
||
**Fingerabdruck** (FNV-1a) der zuletzt ausgelieferten Fassung. Beim Laden gilt:
|
||
|
||
- kein Merker → Dokument einmalig anlegen (auch für Bestandsnutzer);
|
||
- Merker ≠ aktueller Fingerabdruck **und** der Text des Nutzers hat noch genau
|
||
den gemerkten Fingerabdruck → Text nachziehen;
|
||
- Text **verändert** → nie anfassen (dieselbe Adoptions-Regel wie beim Beispiel
|
||
in D22: nur Unverändertes wird adoptiert);
|
||
- Dokument gelöscht → bleibt gelöscht.
|
||
|
||
Der Altwert `'1'` aus der ersten Fassung sagt nichts über den Textstand; dort
|
||
wird bewusst nichts überschrieben, nur der Merker ersetzt. Wer aus dieser kurzen
|
||
Zwischenfassung kommt, holt den aktuellen Stand über den Reset.
|
||
|
||
**Nachtrag — das Nachziehen gilt jetzt auch für das Beispiel-Dokument.** Als
|
||
das Beispiel die neuen Notations-Features (IDs, Abhängigkeiten, `=`, Falten,
|
||
Beschreibungen) vorführen sollte, stand es vor demselben Problem wie damals
|
||
der Werkbaum-Plan: Ohne Merker erreicht eine neue INITIAL-Fassung
|
||
Bestandsnutzer nie — ihr unverändertes Beispiel sieht nur wie „bearbeitet“
|
||
aus. Ein zweiter Fingerabdruck (`werkbaum-seeded-example`) überträgt die
|
||
D27-Regeln wörtlich: nachgezogen wird nur, solange der Text exakt die zuletzt
|
||
ausgelieferte Fassung ist; bearbeitete Texte werden nie angefasst; ein
|
||
gelöschtes Beispiel wird vom Nachziehen nicht wiederbelebt (das macht bei
|
||
Bedarf `deleteDoc`/Reset). Der Reset setzt beide Merker mit.
|
||
|
||
## D28 — „Was ist neu?": neu in Produktion, gelber Strahlenkranz
|
||
Dokumente von außen (mitgeliefert, D27; per `?sourceUrl=`, D23) ändern sich,
|
||
ohne dass der Betrachter es merkt. Sie zeigen deshalb, was sich seit seinem
|
||
letzten Besuch getan hat.
|
||
|
||
**„Neu" heißt: neu in Produktion.** Nicht „Zeile hinzugefügt". Ein Zeilendiff
|
||
meldet vor allem Rauschen — jede neu notierte Idee, jede Umformulierung. Die
|
||
Nachricht, die einen Plan-Leser wirklich angeht, ist: *was ist tatsächlich
|
||
live gegangen*. Also gilt ein Knoten als neu, wenn er **jetzt `[^]`** trägt und
|
||
es in der zuletzt gesehenen Fassung **nicht** tat (weil er anders stand oder
|
||
noch fehlte). Das macht die Hervorhebung zugleich sparsam: In einem großen Plan
|
||
leuchten typischerweise eine Handvoll Knoten, nicht dreißig.
|
||
|
||
**Basis ist die zuletzt GESEHENE Fassung, nicht die letzte Auslieferung.**
|
||
`werkbaum-seen` hält je Dokument-id den Text, den der Betrachter zuletzt
|
||
bestätigt hat. Wer drei Fassungen übersprungen hat, sieht alles seither. Die
|
||
Basis wird **erst beim Bestätigen** fortgeschrieben — schriebe man sie beim
|
||
Laden fort, wäre die Meldung nach einem Neuladen verschwunden, bevor sie jemand
|
||
bemerkt hat. Beim **Erstkontakt** leuchtet nichts (sonst strahlte beim ersten
|
||
Ansehen der gesamte fertige Teil des Plans auf); es wird nur die Basis gesetzt.
|
||
|
||
**Knoten-Identität ist der Label-Pfad**, nicht die Zeilennummer: Umeinrücken und
|
||
Umsortieren erzeugen so keine Falschmeldungen. Gleichnamige Geschwister werden
|
||
über einen Index unterschieden; ein umbenanntes Label gilt als neuer Knoten —
|
||
gewollt, der Text ist der Vertrag (D14).
|
||
|
||
**Darstellung: gelber Strahlenkranz nach außen.** Bewusst **kein Blinken** (vom
|
||
Nutzer erwogen): WCAG 2.2.2 verlangt, dass blinkende Inhalte abschaltbar sind,
|
||
2.3.1 begrenzt Flackern wegen des Anfallsrisikos — und vor allem zöge Blinken
|
||
*dauerhaft* den Blick, statt einmal zu melden. Der Schein liegt **außen**, weil
|
||
die Knotenfüllung dem Status gehört (SPEC §4) und lesbar bleiben muss. Ein
|
||
Knoten, der zugleich die Cursor-Zeile ist (D25), bekommt beides: Tinte innen,
|
||
Gelb außen. Im **Druck und im Grafikexport erscheint der Kranz nicht** — er
|
||
hängt an *deinem* letzten Besuch, ein Export damit hieße für jeden Betrachter
|
||
etwas anderes.
|
||
|
||
Ein **Knopf im Diagramm-Kopf** erscheint nur, wenn es etwas gibt, nennt die
|
||
Anzahl und bestätigt per Klick („gesehen"). Kein Dauer-Umschalter: Die Meldung
|
||
soll verschwinden, wenn sie ihren Zweck erfüllt hat.
|
||
|
||
**Zurückhaltung bei bearbeitetem Text.** Hat der Nutzer das mitgelieferte
|
||
Dokument geändert, wird es nicht mehr nachgezogen (D27) — dann gibt es keine
|
||
saubere Vergleichsbasis, und es wird nichts hervorgehoben.
|
||
|
||
**Zwei Nachbesserungen nach dem ersten Blick auf die deployte Seite.** Gemeldet
|
||
war „nur die linke Hälfte gelb, kein Rahmen drum herum":
|
||
|
||
- **`z-index:2` am neuen Knoten.** Der Schein liegt *außerhalb* der Knotenbox.
|
||
Ohne eigene Stapelordnung malt jedes **später kommende Geschwister** seinen
|
||
undurchsichtigen Hintergrund darüber und schneidet den Kranz einseitig ab —
|
||
übrig bleibt eine gelbe Kante, die wie ein halber Hintergrund aussieht statt
|
||
wie ein Rahmen. Die Abstände (24–25 px) liegen nur knapp über der Reichweite
|
||
des Scheins (Blur 16 + Spread 5 = 21 px); in engeren Layouts, bei Zoom oder
|
||
längeren Labels reicht das nicht.
|
||
- **Die Pfad-Inversion darf „neu" nicht wegdimmen.**
|
||
`.cheap-on .node:not(.cheap)` setzt `opacity:.32; filter:saturate(.4)` — das
|
||
trifft auch den gelben Kranz und macht ihn praktisch unsichtbar. Gerade bei
|
||
einer *nicht gewählten* Alternative ist „das ist jetzt live" aber die
|
||
interessantere Nachricht. Deshalb `.cheap-on .node:not(.cheap).fresh
|
||
{opacity:1;filter:none}`.
|
||
|
||
**Stolperfalle (beim Bauen hineingelaufen):** Die Menge der neuen Knoten muss aus
|
||
**denselben Knotenobjekten** gebildet werden, die gerade gerendert werden. Zuerst
|
||
wurde sie beim Laden aus einem eigenen Parse-Durchlauf berechnet — der Zähler
|
||
stimmte, aber kein einziger Knoten leuchtete, weil `Set.has()` auf
|
||
Objektidentität prüft und die gerenderten Knoten aus einem anderen Parse kamen.
|
||
`render()` bildet die Menge daher bei jedem Durchlauf neu; vorgehalten wird nur
|
||
der **geparste Basisbaum**.
|
||
|
||
## D29 — `+` für optionale Knoten: Zugaben sind weder Pflicht noch Alternative
|
||
Die Notation kannte bisher nur zwei Beziehungen zwischen Geschwistern:
|
||
erforderlich (`-`) und wählbar (`|`). Für ein einzelnes zusätzliches Feature,
|
||
das weder nötig ist noch eine Alternative zu etwas anderem, passte keine von
|
||
beiden. Man schrieb es als normales `-`-Kind — und log damit.
|
||
|
||
**Die Lücke hat einen Namen.** Feature-Modelle (FODA) unterscheiden seit den
|
||
90ern *mandatory*, *optional* und *alternative*. Werkbaum hatte die erste und
|
||
die dritte; `+` ergänzt die zweite. Mnemonik in der Reihe: `-` Teilpaket,
|
||
`+` Zugabe, `|` Alternative. Deckt sich mit MoSCoW (Must / Could / Auswahl).
|
||
|
||
**Der eigentliche Anlass ist der günstigste Pfad (D18), nicht die Optik.**
|
||
`markCheapest()` lief bei all-of über *alle* Kinder — jede Zugabe steckte damit
|
||
im errechneten Minimum. Das Ergebnis war systematisch zu groß, und zwar umso
|
||
mehr, je ehrlicher der Plan auch Kür notierte. `pathChildren()` filtert
|
||
optionale Knoten jetzt mit heraus; da beide Nutzer (`cheapestCost`,
|
||
`markCheapest`) über diese eine Funktion gehen, gilt das samt Teilbaum. Sichtbar
|
||
wird es beim Vergleich von Alternativen: eine Alternative mit teurer Zugabe
|
||
verlor vorher gegen eine schlichtere, obwohl die Zugabe gar nicht dazugehört.
|
||
|
||
**`+` gehört zum Knoten, nicht zur Gruppe** — anders als `-` und `|`. Der Parser
|
||
setzt deshalb `optional:true` und lässt `type:'and'` stehen. Zwei Dinge fallen
|
||
dadurch von selbst richtig aus: `gateOf()` bleibt unverändert, und die
|
||
`mixedGate`-Warnung schlägt weiterhin genau dann an, wenn `|` mit `-`/`+`
|
||
gemischt wird — `-` neben `+` ist erlaubt und still. Genau so soll es sein:
|
||
„diese drei sind nötig, das hier wäre schön" ist der Normalfall, nicht der
|
||
Fehlerfall. Die Regel dahinter: eine Gruppe ist entweder **konjunktiv**
|
||
(`-`/`+` frei gemischt) oder **disjunktiv** (`|`).
|
||
|
||
**Darstellung: hohler Kreis am Abzweig, kein dritter Linienstil.** Erwogen und
|
||
verworfen war eine **gepunktete** Abzweiglinie. Sie wäre pro Kind trivial zu
|
||
setzen gewesen (den Abzweig zeichnet ohnehin ein `li`-Pseudoelement), kollidiert
|
||
aber mit D15: Im **kompakten** Modus laufen beide Gates nach unten und werden
|
||
*allein* über den Linienstil unterschieden. Ein dritter Stil müsste sich dort
|
||
gegen „gestrichelt grau" behaupten — zu wenig Abstand für ein Merkmal, das man
|
||
auf einen Blick lesen können muss. Der Kreis dagegen ist **orthogonal** zum
|
||
Linienstil und lässt D15 unangetastet; er ist zudem die etablierte
|
||
FODA-Konvention (gefüllter Punkt = erforderlich, hohler = optional).
|
||
|
||
Er sitzt **mittig auf der Knotenkante**, wo der Abzweig auftrifft, und
|
||
unterbricht die Linie dort sichtbar. Grundfall im CSS ist die **gestapelte**
|
||
Anordnung (links auf halber Höhe) — sie deckt vertikal, kompakt und die
|
||
any-of-Gruppen ab; die **eine** Ausnahme ist der horizontale Fächer (oben
|
||
mittig), die **eine** Rück-Ausnahme davon der gestapelte all-of-Teilbaum unter
|
||
einer any-of-Gruppe (D18). Umgekehrt herum aufgezogen wären es vier Ausnahmen
|
||
statt zwei. `.node::before`/`::after` waren beide frei; die `li`-Pseudoelemente
|
||
sind von Abzweig und Sammelleiste belegt.
|
||
|
||
**Im Export** wird der Kreis **nach** den Knoten gezeichnet. Er liegt zur Hälfte
|
||
außerhalb der Knotenbox — in der Zeichenreihenfolge der Linien (Schritt 1)
|
||
hätte das Knoten-Rechteck ihn später halb überdeckt. Die Auftreffpunkte werden
|
||
beim Linienzeichnen gesammelt und in einem eigenen Schritt 3a ausgegeben.
|
||
|
||
**Bekannte Schwäche:** Bei aktivem Günstigster-Pfad-Umschalter (Default an) wird
|
||
der optionale Knoten ausgeblasst (`opacity:.32`) — und mit ihm sein Kreis, der
|
||
die Erklärung *dafür* wäre. Undoen lässt sich das nicht: `opacity` am Elternteil
|
||
schlägt auf jedes Kind durch, auch auf ein Pseudoelement. Bewusst in Kauf
|
||
genommen, weil das Zurücktreten hier die *Hauptaussage* ist (dieselbe Logik wie
|
||
bei nicht gewählten Alternativen) und Tooltip, `aria-label` und Legende die
|
||
Begründung nachliefern. Bei ausgeschaltetem Umschalter steht der Kreis in voller
|
||
Stärke.
|
||
|
||
**Verworfene Alternativen:**
|
||
- **Den Status `[?]` (Idee) dafür nehmen** — falsche Achse. Status ist
|
||
Fortschritt, `+` ist Notwendigkeit; eine Zugabe kann längst `[^]` sein (genau
|
||
der Fall, der die Frage ausgelöst hat). SPEC §3 hält beide Achsen getrennt.
|
||
- **`@optional` als Personen-Tag** — missbraucht §7 für etwas Strukturelles.
|
||
- **`#optional` als Schlagwort** (§11 reserviert) — hätte keine Syntaxänderung
|
||
gekostet, bringt aber weder Darstellung noch die Korrektur am Kostenmodell,
|
||
also gerade das nicht, wofür sich der Aufwand lohnt.
|
||
- **`*` statt `+`** — in Regex „null oder mehr" und damit nah an der Begründung
|
||
von D1. Verworfen, weil `*` in Markdown zugleich Betonung auszeichnet und
|
||
eher wie eine Fußnote gelesen wird; `+` liest sich als „Zugabe".
|
||
|
||
**Verhaltensänderung:** Ein `+` am Zeilenanfang ist jetzt ein Zeichen und
|
||
gehört nicht mehr zum Label (`+ 5 % Puffer` ergibt das Label „5 % Puffer").
|
||
Test-abgedeckt, damit es niemanden unbemerkt trifft.
|
||
|
||
**Nachtrag 1 — der Abzweig wird doch gestrichelt.** Der hohle Kreis allein war
|
||
zu leise; „ein gestrichelter Ast wäre deutlicher" (Nutzer). Der oben notierte
|
||
Einwand gegen einen dritten Linienstil bleibt richtig, greift hier aber nicht
|
||
so weit wie gedacht: Gestrichelt wird **nur der Abzweig zum Knoten**, nicht die
|
||
Sammelleiste, und zwar in **Tinte** — die any-of-Linien sind gestrichelt in
|
||
**Grau**. Entscheidend ist, dass beide sich in einer Gruppe nie begegnen können:
|
||
`|` darf nach §3 nicht mit `-`/`+` gemischt werden, ein gestrichelt-grauer und
|
||
ein gestrichelt-blauer Ast hängen also nie am selben Verteiler. Der Kreis bleibt
|
||
zusätzlich — er sagt, **welcher** Knoten gemeint ist, und trägt die Kennzeichnung
|
||
auch dort, wo der Strich im Gedränge untergeht.
|
||
|
||
Umgesetzt an den vorhandenen Pseudoelementen, ohne neue Zeichenebene: Im Fächer
|
||
ist der Abzweig der senkrechte Stiel (`border-left` von `::after`, beim letzten
|
||
Kind `border-right` von `::before`), in den gestapelten Anordnungen der
|
||
waagerechte Ast (`border-top` von `::before`) — die jeweils andere Kante ist die
|
||
Leiste und bleibt durchgezogen. Dafür braucht auch das **`<li>`** die Klasse
|
||
`opt` (den Abzweig zeichnet es, nicht der Knoten).
|
||
|
||
**Nachtrag 2 — Stiel trifft die Knotenmitte auch waagerecht (`--stem-x`).**
|
||
Beim Prüfen fiel ein älterer Fehler auf: Im horizontalen Fächer lief der Stiel
|
||
zu einem Knoten mit any-of-Kindern **neben dem Knoten vorbei** (gemessen 13,4 px).
|
||
Ursache ist dieselbe Verwechslung, die D10 schon senkrecht behoben hat, nur in
|
||
der anderen Achse: Der Stiel saß bei 50 % der **Zelle**, und das ist nur dann
|
||
die Knotenmitte, wenn der Knoten in der Zelle zentriert steht. `li.has-or` ist
|
||
aber `align-items:flex-start` — der Knoten steht links, damit die
|
||
any-of-Sammelleiste unter ihm aufsetzt —, und die Zelle ist so breit wie der
|
||
Teilbaum.
|
||
|
||
Rein in CSS ist das nicht lösbar: Die nötige Größe ist die **Knotenbreite**, und
|
||
kein Selektor kann sie einer Elternregel zugänglich machen (Anchor Positioning
|
||
ist Chrome-only). Deshalb misst `alignStems()` nach jedem Rendern die Knotenmitte
|
||
der betroffenen Zellen und setzt sie als `--stem-x`; die Pseudoelemente rechnen
|
||
über `left:var(--stem-x, 50%)` / `right:calc(100% - var(--stem-x, 50%))`. Der
|
||
Rückfallwert 50 % hält alle übrigen Zellen ohne Messung richtig, und die
|
||
transponierten Modi setzen `left`/`right` ohnehin fest und bleiben unberührt.
|
||
Gemessen wird — wie in `drawCheapPath()` — durch `zoom` zurückgerechnet, sodass
|
||
der Wert beim Zoomen gültig bleibt.
|
||
|
||
**Nachtrag 3 — Treppe für mehrere optionale Endknoten.** Im horizontalen Fächer
|
||
kostet jedes optionale Geschwister eine eigene Spalte — Breite für gerade das,
|
||
was am entbehrlichsten ist. Aufeinanderfolgende optionale Endknoten werden
|
||
deshalb als **Kaskade** gestapelt (Nutzerwunsch: „mehrere optionale Knoten in
|
||
einer diagonalen Linie an eine Anschlussstelle").
|
||
|
||
Verworfen wurde die naheliegendere **senkrechte Spalte** unter einem
|
||
Anschlusspunkt: Sie wäre 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. Die Treppe
|
||
kauft Eindeutigkeit für etwas Breite — schon die Form ist eine andere.
|
||
|
||
Ebenfalls verworfen: eine **echte** Diagonale. Rahmenkanten sind achsenparallel;
|
||
sie bräuchte die SVG-Ebene (die es für den Pfad-Spline gibt) und wäre damit eine
|
||
**zweite Zeichenebene** neben allen anderen Linien, nachzuführen bei jedem
|
||
Rendern, Moduswechsel und Zoom. Der gestufte Anschluss aus rechten Winkeln gibt
|
||
denselben Kaskaden-Eindruck im vorhandenen Mechanismus.
|
||
|
||
**Nur Endknoten.** Der Platzgewinn entsteht gerade daraus, dass kein Teilbaum
|
||
mitgestapelt werden muss — ein optionaler Knoten *mit* Kindern spart in der
|
||
Treppe nichts und behält seine Spalte. Technisch kommt dasselbe heraus: Die
|
||
Stufengeometrie rechnet mit einem festen Abstand von Knotenunterkante zur
|
||
nächsten Stufe und setzt deshalb voraus, dass die Zelle so hoch ist wie ihr
|
||
Knoten (kein Teilbaum, kein Geister-Knoten). Geprüft wird genau das: das `<li>`
|
||
hat exakt ein Element-Kind, und das ist der Knoten.
|
||
|
||
**Gruppiert wird in app.js, nicht im Renderer.** Die Gruppierung
|
||
(`li.opt-group > ul.opt-stair`, Stufennummer als `--i`) ist reine Darstellung.
|
||
Im Renderer hätte sie eine DOM-Ebene erzeugt, die es semantisch nicht gibt —
|
||
und die in den **drei übrigen Anordnungen** (vertikal, kompakt, all-of unter
|
||
any-of) wieder hätte neutralisiert werden müssen, jede mit hand-getunter
|
||
Geometrie. `display:contents` löst das nicht: Es richtet die Boxen, aber die
|
||
`>`-Selektoren jener Regeln greifen weiter auf dem DOM und passen dann nicht
|
||
mehr. `applyOptStairs()` baut die Gruppe deshalb nur im Fächer und löst sie beim
|
||
Moduswechsel wieder auf — dieselbe Kategorie wie `alignStems()` und
|
||
`drawCheapPath()`, und SPEC §9 („der Modus ändert nur die Anordnung") bleibt für
|
||
den **Renderer** wörtlich wahr. Lese- und Fokusreihenfolge bleiben unberührt,
|
||
weil nur umgehängt und nichts umsortiert wird.
|
||
|
||
**Der Export folgt der Kaskade.** Erste Fassung reihte alle Stufen flach als
|
||
Kinder ein und ließ den Export selbst routen (er zieht Linien ohnehin unabhängig
|
||
vom Darstellungsmodus neu). Ergebnis: Die Linie zur dritten Stufe lief **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, die übrigen bekommen denselben Winkel
|
||
wie am Bildschirm.
|
||
|
||
**Nicht durch Tests gedeckt:** `applyOptStairs()` arbeitet auf dem DOM, und für
|
||
`app.js` gibt es keine Testumgebung (die Vitest-Suite prüft die headless-Module).
|
||
Geprüft wurde im Browser — auch der Moduswechsel hin und zurück, mehrfach: Die
|
||
Gruppe entsteht und löst sich rückstandsfrei auf, Knotenzahl und Dokumentordnung
|
||
bleiben in allen drei Modi gleich.
|
||
|
||
## D30 — `[x]` → `[^]` per Beförderungs-Commit vor dem Deploy
|
||
Der mitgelieferte Werkbaum-Plan (D27) behauptete `[^]` („in Produktion") für
|
||
Funktionen, die nur auf der automatisch deployten Pages-Instanz lagen, nicht auf
|
||
der stabilen Installation `werkbaum.javagil.de` (manueller Deploy, D16). Damit
|
||
war ausgerechnet das Dokument ungenau, das den Stand beschreiben soll — und die
|
||
„Was ist neu?"-Anzeige (D28) meldete Dinge als live, die es dort nicht waren.
|
||
|
||
**Die Unterscheidung gibt es längst.** SPEC §4 trennt `[x]` fertig
|
||
(abgeschlossen) von `[^]` in Produktion (deployed/live). Der Plan hat diese
|
||
Trennung ausgerechnet für sich selbst nie benutzt und sprang direkt auf `[^]`.
|
||
Die Konvention lautet daher ab jetzt: **beim Mergen `[x]`**, und der Deploy
|
||
befördert. Nur der Deploy weiß, wann die Aussage wahr wird.
|
||
|
||
**Umgesetzt als Commit, nicht als Rewrite beim Bauen.** `scripts/promote-shipped.sh`
|
||
schreibt die Statusboxen am Zeilenanfang von `[x]` auf `[^]` und hält das in
|
||
einem eigenen Commit fest; `scripts/deploy-prod.sh` ruft es als Schritt 0 auf
|
||
(abschaltbar mit `--no-promote`). Gründe:
|
||
|
||
- Ein Rewrite beim Bauen macht **genau eine** Installation ehrlich. Pages
|
||
untertriebe dauerhaft, und die Neu-Anzeige wäre dort **für immer stumm** —
|
||
das Feature ließe sich nur noch künstlich prüfen. Der Commit dagegen wird von
|
||
**beiden** Pipelines gesehen: Pages baut ihn beim Push, prod beim nächsten
|
||
rsync.
|
||
- Das Deployment-Artefakt bleibt **inhaltsgleich mit dem Repo**. Die
|
||
vorhandenen `sed`-Regeln (D16) fassen nur Pfade und die Versionsnummer an —
|
||
Infrastruktur. Ein Status-Rewrite wäre der erste Schritt, der ändert, was das
|
||
Dokument *aussagt*; die per `?sourceUrl=` geladene Rohdatei wäre dann eine
|
||
dritte, wieder abweichende Fassung.
|
||
- Es folgt dem Präzedenzfall aus D16: Die Version wird per **bewusstem
|
||
Bump-Commit** gepflegt, „vollständig aus dem Repo reproduzierbar". Ein Deploy
|
||
ist ein bewusster Akt; ihn in der Historie festzuhalten passt dazu — und der
|
||
Plan bekommt nebenbei eine Chronik, wann was live ging.
|
||
|
||
**Verworfen:** Rewrite nur im Prod-Deploy (siehe oben) und Rewrite in **beiden**
|
||
Pipelines. Letzteres wäre testbar, aber `[^]` hieße auf Pages faktisch nur
|
||
„gemerged", und das kollidiert damit, dass D16 die Pages-Instanz bewusst als
|
||
nicht-produktiven Build markiert (🚧-Badge).
|
||
|
||
**Bekannter Preis:** Zwischen Beförderungs-Commit und rsync sagt Pages `[^]` für
|
||
etwas, das auf prod noch nicht liegt. Das Fenster ist kurz und liegt in der Hand
|
||
dessen, der deployt — gegenüber einer *dauerhaften* Ungenauigkeit bei der
|
||
Build-Rewrite-Variante der bessere Tausch.
|
||
|
||
**Einmalige Nachholung.** Welche Knoten zu früh `[^]` trugen, ließ sich exakt
|
||
bestimmen statt zu schätzen: Der Footer der stabilen Instanz verlinkt den
|
||
deployten Commit (`4061362`), und alles danach ist dort nicht enthalten. Es
|
||
waren **genau zwei** Knoten — „Optional nodes" (D29) und „Show what is new since
|
||
your last visit" (D28) —, nicht das Dutzend, das vorher grob geschätzt worden
|
||
war. Beide stehen jetzt auf `[x]` und leuchten beim nächsten Prod-Deploy als neu
|
||
auf, was genau der Wahrheit entspricht.
|
||
|
||
**Umfang: genau eine Datei, bewusst kein Muster.** Befördert wird nur
|
||
`examples/werkbaum.werkbaum` — allein der Werkbaum-eigene Plan sagt
|
||
etwas über das Deployment aus. `[x]` steht im Repo an mehreren Stellen, wo eine
|
||
Beförderung falsch bis unsinnig wäre:
|
||
|
||
- Die **Legende („Agenda")** zeigt `[x] fertig` als *Anschauungsmaterial* für die
|
||
Notation (`frontend/index.html`, `chip('fertig','[x]')` in `app.js`). Daraus
|
||
würde `[^] fertig` — Unsinn, den beim Durchsehen eines Diffs niemand bemerkt.
|
||
- Das mitgelieferte **„Example"-Dokument** (`INITIAL` in `app.js`) und die
|
||
übrigen `docs/examples/*.werkbaum` sind erfunden.
|
||
- **SPEC §10** (kanonisches Beispiel, zugleich Test-Fixture) und die Checkboxen
|
||
in `docs/TASKS.md`.
|
||
|
||
Weil eine spätere „Verallgemeinerung" auf ein Glob naheliegt und der Schaden
|
||
still wäre, bleibt es nicht bei der Zusage: Der Lauf vergleicht `git status` vor
|
||
und nach dem Schreiben und **bricht ab**, sobald mehr als die Plandatei neu
|
||
geändert ist — die Datei wird zurückgesetzt, nichts wird committet. Geprüft mit
|
||
einem absichtlich ausgeweiteten `sed` in einem Wegwerf-Worktree.
|
||
|
||
**Nicht gepusht.** Das Skript committet, pusht aber nicht — Veröffentlichen
|
||
bleibt eine bewusste Handlung. `deploy-prod.sh` warnt stattdessen, wenn HEAD
|
||
noch nicht auf `origin` liegt: der Footer-Versionslink zeigt sonst auf einen
|
||
Commit, den GitHub nicht kennt.
|
||
|
||
## D31 — Echtzeit-Zusammenarbeit über ein Etherpad, eigener Parameter `?etherpad=`
|
||
Werkbaum hat kein Backend (D13 ist Plan, nicht Bestand), und der Plan setzt
|
||
Zusammenarbeit als `[?] Live editing, several people at once (XL)` an — mit
|
||
`[!] Merging simultaneous edits (L)` als der eigentlichen Arbeit. Genau diese
|
||
Arbeit ist in Etherpad seit Jahren getan. Also wird sie geliehen statt
|
||
nachgebaut: **Das Pad ist die Schreibfläche, Werkbaum die Ansicht.**
|
||
|
||
**Das Fundament war schon da.** Etherpad liefert pro Pad einen Klartext-Export
|
||
(`/p/<pad>/export/txt`), und `?sourceUrl=` (D23) lädt jede Textdatei über
|
||
http(s), ohne Endung oder `Content-Type` zu prüfen (D24). Nachgemessen an
|
||
`pad.hostsharing.net`:
|
||
|
||
- `Access-Control-Allow-Origin: *` und `Content-Type: text/plain; charset=utf-8`
|
||
— die eigentliche Hürde aus D23 (CORS) fällt also weg;
|
||
- das kanonische Beispiel aus SPEC §10 kommt **byte-identisch** zurück
|
||
(führende Leerzeichen, `-`/`+`/`|`, Statusboxen, `%%`, UTF-8);
|
||
- der HTML-Export zeigt **kein** Listen-Markup: Etherpad speichert die
|
||
Einrückung als echte Leerzeichen und deutet das `-` nicht zur Aufzählung um.
|
||
Das war das Risiko, das die Idee hätte erledigen können.
|
||
|
||
Das war zunächst nur Import → Speicher → Export, also **nicht** das Tippen — und
|
||
genau das war der offene Punkt (Tab-Einrückung, mögliches Auto-Bullet), im Geist
|
||
der Lehre aus D25: synthetische Ereignisse beweisen nur die eigene Logik.
|
||
**Nachgeholt an echter Eingabe:** Der Nutzer hat im Pad ` - [ ] Layout`
|
||
eingerückt eingetippt und eine Kommentarzeile geändert; der Export gibt beides
|
||
zeichengenau zurück (`> - [ ] Layout$` — vier echte Leerzeichen, kein Tab,
|
||
kein Listen-Markup). Der Etherpad-Editor fasst die Notation beim Tippen also
|
||
nicht an.
|
||
|
||
**Eigener Parameter statt `?sourceUrl=`.** Drei Gründe, der erste ist der
|
||
schwächste:
|
||
|
||
1. Die URL, die ein Mensch in der Hand hat, ist die **Pad**-URL — die aus der
|
||
Adresszeile. `/export/txt` ist eine Implementierungseinzelheit und gehört
|
||
nicht in die Schnittstelle; Werkbaum hängt sie selbst an.
|
||
2. Der Parameter **lizenziert anderes Verhalten**. `sourceUrl` heißt „statische
|
||
Datei, einmal pro Laden geholt" — das ist D23 wörtlich und bleibt
|
||
unangetastet. `etherpad` heißt „lebendes Pad", und daran hängen der
|
||
Schreibschutz, der „im Pad bearbeiten"-Knopf und der Neu-laden-Knopf. Ohne
|
||
die Trennung müsste D23 seine Semantik ändern und bestehende Links bekämen
|
||
Verhalten, um das niemand gebeten hat.
|
||
3. Die **Pad**-URL ist mehr wert als die Export-URL: nur mit ihr sind der
|
||
„im Pad bearbeiten"-Knopf und ein späteres Einbetten (siehe unten) ohne
|
||
weiteren Parameter erreichbar, und Identität/Name des Dokuments werden aus
|
||
ihr gebildet — derselbe Pad ergibt so genau **ein** Dokument, auch wenn
|
||
jemand versehentlich die Export-URL einträgt (sie wird normalisiert).
|
||
|
||
**Der Dokumentname ist die vollständige Pad-URL**, nicht der bloße Pad-Name.
|
||
Kurz wäre schöner (`mein-plan` statt 45 Zeichen in einer schmalen Titelzeile),
|
||
aber Pad-Namen sind nur **pro Instanz** eindeutig, nicht global: zwei Hosts mit
|
||
je einem Pad `plan` ergäben zwei gleichnamige Dokumente im Wähler, ohne
|
||
Möglichkeit sie zu unterscheiden. Damit gilt dieselbe Regel wie in D23 — und der
|
||
Ellipsen-Schnitt in der Titelzeile samt vollständiger URL im Tooltip ist dafür
|
||
schon eingerichtet.
|
||
|
||
Der Name `?etherpad=` statt des neutraleren `?pad=`: Das Anhängen von
|
||
`/export/txt` **ist** produktspezifisch. Das ehrlich zu benennen ist besser, als
|
||
Allgemeinheit vorzutäuschen, die beim nächsten Werkzeug (HedgeDoc, CryptPad —
|
||
andere Export-Pfade) doch einen Typ-Diskriminator bräuchte.
|
||
|
||
**Das Textfeld ist schreibgeschützt.** Ohne das verschwände getippter Text beim
|
||
nächsten Abruf — Datenverlust, und zwar überraschend, weil nichts darauf
|
||
hindeutet. Der Schutz ist zugleich die ehrliche Aussage: Werkbaum kann
|
||
gleichzeitige Änderungen nicht zusammenführen, das Pad kann es. Deshalb
|
||
erscheint in der Editor-Titelzeile ein Knopf, der das Pad im neuen Tab öffnet
|
||
(nur bei solchen Dokumenten sichtbar).
|
||
|
||
**Geholt wird auf Knopfdruck, nicht selbsttätig — Etherpad drosselt.** Die erste
|
||
Fassung holte alle 2,5 s im Hintergrund, mit einem Stabilitätstakt (erst
|
||
übernehmen, wenn zwei Abrufe denselben Text liefern) gegen das Mitlesen halb
|
||
getippter Zeilen. In der Praxis kam damit fast nichts an: im Netzwerk-Mitschnitt
|
||
stapelten sich Anfragen und wurden abgebrochen („cancelled").
|
||
|
||
Die Ursache ist nicht Langsamkeit, sondern ein **Rate Limit**:
|
||
`importExportRateLimiting` ist in Etherpad serienmäßig an und lässt **10 Abrufe
|
||
je 90 s und IP** zu — der Takt wollte 36. Danach antwortet die Gegenseite nicht
|
||
mit `429`, sondern **hält die Verbindung offen** (keine Kopfzeilen, keine
|
||
Antwort), bis der eigene Abbruch sie abreißt. Nachgemessen:
|
||
|
||
```
|
||
12:48:12 Abbruch nach 25 s (0 Bytes)
|
||
Abbruch nach 25 s (0 Bytes)
|
||
12:50:23 HTTP 200 nach 0,436 s <- nach ~40 s Pause
|
||
```
|
||
|
||
Zwei Minuten totgestellt, dann sofort in 0,4 s da. Gegen eine Drosselung kann ein
|
||
Takt nicht gewinnen — er *erzeugt* sie. Also: **ein Knopf**. Damit entfallen
|
||
Stabilitätstakt, Sichtbarkeits-Wächter, Anti-Stapel-Riegel und Wiederanlauf; was
|
||
bleibt, ist ein Abruf, wenn jemand ihn will. Das ist auch die ehrlichere Haltung
|
||
gegenüber fremder Infrastruktur, und es greift gut mit „Was ist neu?" (D28)
|
||
zusammen: drücken, und was seither in Produktion ging, leuchtet auf.
|
||
|
||
Zwei Dinge, die der Knopf braucht und ein stiller Takt nicht:
|
||
|
||
- **Rückmeldung während des Abrufs** (das Symbol dreht). Bei gedrosselter
|
||
Gegenseite sind das bis zu 20 s; ohne Zeichen wirkt der Knopf kaputt.
|
||
- **Eine Antwort im Fehlerfall.** Ein Hintergrund-Takt durfte stumm scheitern,
|
||
eine bewusste Handlung nicht. Der Abbruch bekommt dafür einen **eigenen**
|
||
Warnungstyp `sourceTimeout`: Die `sourceLoad`-Meldung zeigt auf CORS und
|
||
schickte hier auf die falsche Fährte — richtig ist „warte einen Moment".
|
||
|
||
**Der erste Abruf darf scheitern, ohne alles zu verlieren.** In der ersten Fassung
|
||
standen `padSource` und der Takt *hinter* dem `await` des ersten Abrufs: Ein
|
||
einziger Fehlschlag — bei dieser Gegenseite der Normalfall — ließ das Dokument
|
||
tot liegen, ohne Knopf und ohne Wiederversuch, bis zum Neuladen der Seite.
|
||
Jetzt wird `padSource` **vor** dem Abruf gesetzt; der Knopf erscheint auch, wenn
|
||
es das Dokument noch nicht gibt, und legt es beim ersten Erfolg an.
|
||
|
||
**Wer das Pad-Dokument löscht, meint es.** `deleteDoc()` beendet die Pad-Quelle.
|
||
Ohne das hätte der (damalige) Takt es wieder angelegt — und beim Anlegen auch
|
||
gleich aktiviert, den Nutzer also aus dem Dokument gerissen, in das er gewechselt
|
||
war.
|
||
|
||
**Verhältnis zu D20 („keine externen Requests").** Unverändert wie bei D23: Die
|
||
App lädt von sich aus nichts; der Request entsteht nur, weil der Nutzer eine
|
||
URL angibt, und geht nur an genau diesen Host (`credentials:'omit'`, nur
|
||
`http`/`https`). Neu und ausdrücklich zu benennen ist die andere Richtung:
|
||
**Der Plantext liegt jetzt auf fremder Infrastruktur**, und ein Pad ist für
|
||
jeden lesbar, der die Adresse kennt. Das ist bei einem Projektplan etwas
|
||
anderes als bei einer Schriftart. Es bleibt die Entscheidung dessen, der den
|
||
Link baut — Werkbaum legt von sich aus kein Pad an.
|
||
|
||
**Verworfene und aufgeschobene Alternativen:**
|
||
- **`?sourceUrl=` um wiederholtes Abrufen erweitern** — hätte bestehenden Links
|
||
ungefragt Requests verpasst und D23 seine klare Semantik gekostet.
|
||
- **Das Pad einbetten** — nicht verworfen, sondern gebaut; siehe Nachtrag unten.
|
||
- **Echter Etherpad-Client** (socket.io + Easysync-Changesets): Rettet D25 und
|
||
erlaubt Schreiben aus Werkbaum heraus, kostet aber zwei **Laufzeit**-
|
||
Abhängigkeiten und damit „eine self-contained Datei ohne
|
||
Laufzeit-Abhängigkeiten" (D11/D19/D20) — und die Diff-Hälfte müsste man
|
||
selbst schreiben. Das ist der XL-Knoten aus dem Plan, nur mit fremdem
|
||
Protokoll statt fremdem CRDT. Wenn, dann zusammen mit dem eigenen Backend
|
||
(D13) und dann besser mit einem Text-CRDT, wie der Plan es vorsieht.
|
||
- **Etherpads HTTP-API** (`/api/1/getText?apikey=…`) — der API-Schlüssel ist ein
|
||
Administrationsschlüssel für **alle** Pads der Instanz und hat in einer
|
||
Client-Anwendung nichts zu suchen. Der Export-Endpunkt braucht ihn nicht.
|
||
|
||
**Nachtrag — das Pad wird eingebettet, der Textspiegel bleibt daneben.**
|
||
Ob der Server das Einbetten zulässt, war die erste Frage. Nachgemessen:
|
||
`pad.hostsharing.net` sendet **kein** `X-Frame-Options` und keine CSP mit
|
||
`frame-ancestors`, und im Versuch hat das Pad in einem fremdstämmigen Rahmen
|
||
seine inneren Editorframes aufgebaut (`contentWindow.length === 2`, das sind
|
||
`ace_outer`/`ace_inner`) — samt Werkzeugleiste, heiler Einrückung und lebender
|
||
Socket-Verbindung (der Anwesenden-Zähler sprang auf 2). Technisch geht es also.
|
||
|
||
**Aber Einbetten heißt nicht Ersetzen.** Der naheliegende Schritt wäre, das
|
||
Textfeld durch den Rahmen zu ersetzen. Das kostete **beide** Richtungen von D25
|
||
— kein Alt+Klick → Zeile, keine Cursor-Zeile → Knoten —, weil in einen
|
||
cross-origin-iframe kein DOM-Zugriff führt. Bei 75 Knoten ist das die
|
||
Orientierung. Deshalb **drei Ansichten** statt einer Entscheidung, reihum über
|
||
einen Wähler in der Editor-Titelzeile:
|
||
|
||
- **Pad und Text**, geteilt durch einen eigenen Splitter (Idiom und Mechanik wie
|
||
beim Legenden-Splitter aus D26: von der Spiegelseite gezogen, Doppelklick
|
||
setzt zurück, `--pcol`/`--prow` getrennt gehalten und persistiert). Der Spiegel
|
||
darf schmal werden — er trägt weiter die Sprünge, denn die arbeiten auf
|
||
**unserem** `<textarea>`.
|
||
- **nur Pad** — der Spiegel ist ausgeblendet. Ein Sprung aus dem Diagramm holt
|
||
ihn selbst zurück, so wie `revealEditor()` ein zugeklapptes Panel aufklappt
|
||
(D25). Ohne das zeigte der Sprung ins Nichts.
|
||
- **nur Text** — wie bisher, kein Rahmen.
|
||
|
||
**Ein Wähler statt dreier Knöpfe:** Die Editor-Titelzeile trägt schon Dokument,
|
||
Pad, Neu laden, Kopieren, Legende und die Fensterknöpfe; auf kleinem Bildschirm
|
||
(D17) ist sie dreifach eng. Derselbe Reihum-Griff wie beim Modus-Wähler dort. Der
|
||
Zustand steckt im Symbol (geteilter Rahmen / links gefüllt / rechts gefüllt), der
|
||
Tooltip nennt ihn im Klartext.
|
||
|
||
**Der Rahmen wird nur geladen, wenn er sichtbar ist** (`about:blank` sonst). Das
|
||
ist kein Geiz um Bytes: Ein geladenes Pad verbindet sich per Socket und macht
|
||
dich in dessen Anwesenden-Liste sichtbar. „Nur Text" ist damit die Ansicht, die
|
||
nichts von dir verrät — und die Voreinstellung „beide" ist eine bewusste
|
||
Entscheidung, weil wer `?etherpad=` aufruft, das Pad auch will.
|
||
|
||
**Der Preis, der bleibt: `Set-Cookie: token=…; SameSite=Lax`.** Dieses Cookie ist
|
||
Etherpads Autoren-Identität und wird in einem fremdstämmigen Rahmen **nicht
|
||
mitgesendet** (Safari blockt Dritt-Cookies grundsätzlich, Chrome je nach
|
||
Einstellung). Bearbeiten geht, aber man ist bei jedem Laden ein neuer Autor —
|
||
Name und Farbe halten nicht. Reparieren lässt sich das nur **serverseitig**
|
||
(`cookie.sameSite: "None"` in Etherpads `settings.json`), nicht in Werkbaum.
|
||
|
||
Auf kleinem Bildschirm wurde „beide" nachgemessen statt geschätzt: bei 375 × 812
|
||
bleiben Pad 317 px und Spiegel 180 px, per Splitter verschiebbar — knapp, aber
|
||
brauchbar. Eine Sonderregel für Mobil braucht es deshalb nicht.
|
||
|
||
## D32 — `!!!` als Fokusmarke: ein geteilter Zeigefinger im Text
|
||
Beim gemeinsamen Arbeiten an einem Pad (D31) fehlt das Naheliegendste: „schau
|
||
mal hier". Etherpad zeigt die Cursor der anderen im **Pad**, aber Werkbaum
|
||
kommt nicht an sie heran — ein fremdstämmiger Rahmen gibt keinen DOM-Zugriff,
|
||
und der Klartext-Export überträgt ohnehin nur Text. Eine Marke **im Text** ist
|
||
damit nicht der Notbehelf, sondern der einzige Weg.
|
||
|
||
Sie hat sogar etwas, das ein Cursor nicht hat: **alle sehen dieselbe Stelle.**
|
||
Ein Cursor ist privat, `!!!` ist eine Aussage im gemeinsamen Dokument.
|
||
|
||
**Syntax `!!!`.** Vorgeschlagen und **verworfen** war `%%!` — eine
|
||
Kommentar-Variante (`- [ ] Backend %%! schau mal hier`). Deren Vorzug: Bleibt die
|
||
Marke liegen, ist sie harmlos, denn `%%` ist längst ein Kommentar (SPEC §8);
|
||
jedes andere Werkzeug und jeder Leser überliest sie, und der Resttext hätte die
|
||
Begründung tragen können. Der Nutzer hat `!!!` gewählt, „am einfachsten schnell
|
||
mal zu tippen" — und das ist das entscheidende Kriterium, weil der Moment des
|
||
Zeigens genau der Moment ist, in dem man **nicht** über Syntax nachdenken will.
|
||
Bewusst in Kauf genommen: Die Marke bleibt sichtbar im Plan stehen, bis jemand
|
||
sie löscht (Werkbaum kann sie nicht entfernen — auf das Pad gibt es keinen
|
||
Schreibzugriff), und in einer committeten Datei sieht sie nach einer Aussage über
|
||
den Knoten aus. Ebenfalls verworfen: `@@` als Pseudo-Tag — `@` bedeutet in dieser
|
||
Notation „zuständig" (SPEC §7), ein `@@` daneben lädt zur Verwechslung ein.
|
||
|
||
**Nur alleinstehend.** Erkannt wird `!!!` am Zeilenanfang/-ende oder von Leerraum
|
||
umgeben. Ohne diese Einschränkung verlöre jedes `Achtung!!!` seine
|
||
Ausrufezeichen — und niemand fände den Grund, weil die Zeile im Diagramm nur
|
||
stumm anders aussieht. `!!` und `!!!!` zählen nicht, `!!!` innerhalb einer URL
|
||
bleibt Teil der URL. Regex bewusst **ohne Lookbehind** (`(^|\s)!!!(?=\s|$)`,
|
||
führender Leerraum wird mitgefangen und wieder eingesetzt): Safari kennt
|
||
Lookbehind erst ab 16.4, und die Notation soll nicht an einer Browserversion
|
||
hängen.
|
||
|
||
Position in der Extraktionsreihenfolge (SPEC §1): **nach** den Tags, vor dem
|
||
Label. Kollisionsfrei, weil `!!!` weder `@` noch Klammern noch `:` enthält. Der
|
||
Kommentar fällt als Erstes weg — eine Marke **im** Kommentar wirkt deshalb nicht,
|
||
was richtig ist: Was hinter `%%` steht, ist Beiwerk.
|
||
|
||
**Darstellung: dieselbe wie die Cursor-Zeile** (weißer Halo + Ring in Tinte,
|
||
D25), plus Scrollen ins Bild. Erwogen war ein eigenes Aussehen (Petrol-Ring mit
|
||
Sprechblasen-Spitze); der Nutzer entschied für die vorhandene Hervorhebung. Das
|
||
ist die sparsamere Wahl: Es gibt nur **einen** Begriff „hier schauen", und neben
|
||
Fokusrahmen (Petrol), Strahlenkranz (gelb, D28) und Cursor-Ring wäre ein vierter
|
||
Ring ein Zeichen zu viel. **Preis:** Im Bild ist nicht zu unterscheiden, ob ein
|
||
Knoten wegen der eigenen Cursor-Zeile oder wegen einer fremden Marke leuchtet.
|
||
Für Screenreader wird der Unterschied benannt (`a11yFocusMark`, „hierhin
|
||
schauen"), weil ein `box-shadow` dort ohnehin nicht ankommt.
|
||
|
||
**Ins Bild geholt wird nur bei Änderung.** Schlüssel ist der **Label-Text** des
|
||
markierten Knotens, nicht die Zeilennummer: Umsortieren im Pad soll nicht als
|
||
neue Marke gelten. Ohne diese Bedingung zöge jeder Neubau des Baums den Blick
|
||
zurück — man könnte nicht wegscrollen, und bei einem Pad-Dokument wird oft neu
|
||
gebaut.
|
||
|
||
**Beim Hinsehen gefunden: die Pfad-Inversion verschluckte den Zeigefinger.**
|
||
`.cheap-on .node:not(.cheap)` setzt `opacity:.32; filter:saturate(.4)` — und die
|
||
erste markierte Alternative im Test war genau ein nicht gewählter any-of-Zweig
|
||
(„Headless CMS"). Gemessen: Deckkraft 0,32, der Ring praktisch unsichtbar. Der
|
||
Zeiger zeigte auf nichts. Dieselbe Ausnahme wie für „neu in Produktion" (D28)
|
||
behebt es, und hier ist sie noch zwingender: „Sollten wir diese verworfene
|
||
Alternative noch einmal ansehen?" ist einer der wahrscheinlichsten Gründe,
|
||
überhaupt zu zeigen. Anders als beim optionalen Knoten (D29), wo das
|
||
Zurücktreten die Aussage **ist** — dort bleibt die Schwäche bewusst stehen.
|
||
|
||
**Nicht im Druck, nicht im Grafikexport** — wie die Cursor-Zeile. Die Marke sagt
|
||
„schau jetzt hierhin", nicht „so ist der Plan"; ein Export wäre für jeden
|
||
Betrachter etwas anderes. Der Export erbt das ohnehin, weil `diagramToSvg()` nie
|
||
`box-shadow` ausliest (D25).
|
||
|
||
**Dritte, unabhängige Achse.** Die Marke sagt nichts über Fortschritt (§4) oder
|
||
Notwendigkeit (§3) — test-abgedeckt, damit niemand später Status oder `optional`
|
||
daran koppelt. **Nicht** in das kanonische Beispiel (SPEC §10) aufgenommen: Das
|
||
ist zugleich Test-Fixture, und ein dauerhafter Zeigefinger darin wäre eine
|
||
Aussage, die niemand gemacht hat.
|
||
|
||
**Nachtrag zu D31 — „der eingebettete Rahmen lässt sich nach einer Weile nicht
|
||
mehr bearbeiten".** Gemeldet vom Nutzer, mit dem Verdacht, es hänge am
|
||
Update-Poller. **Der ist es nicht**, und das ist belegbar statt vermutet:
|
||
|
||
- `checkForUpdates()` holt `location.href`, also den **Werkbaum**-Origin. Es kann
|
||
die Drosselung des Pads (10 Abrufe je 90 s, siehe oben) gar nicht auslösen.
|
||
- Es lädt die Seite nicht neu, sondern zeigt nur ein Banner;
|
||
`checkAndShowUpdateNotification()` und `showUpdateDebug()` hängen ausschließlich
|
||
`position:fixed`-Elemente an `<body>` — kein Neuaufbau eines Containers, in dem
|
||
der Rahmen steckt (das *würde* ihn neu laden, denn ein Umhängen im DOM lädt
|
||
jeden `<iframe>` neu).
|
||
- Gemessen über ~6 Minuten mit sichtbarem Tab: Marker auf `window` überlebt
|
||
(kein Seiten-Reload), ein `load`-Zähler am Rahmen bleibt bei **0** (kein
|
||
Rahmen-Reload), Sichtbarkeit durchgehend `visible`.
|
||
|
||
**Reproduziert wurde der Fehler nicht.** Nach sechs Minuten war die Verbindung
|
||
noch lebendig — geprüft ohne Tippen, indem das Pad von außen geändert wurde: Der
|
||
Rahmen übernahm die Änderung sofort, seine Socket-Verbindung lief also. Etherpads
|
||
eigene Meldungen sind von außen nicht lesbar (fremdstämmiger Rahmen, eigener
|
||
Konsolen-Kontext), eine Instrumentierung von unserer Seite gibt es dafür nicht.
|
||
|
||
**Verdacht, ausdrücklich unbewiesen:** das `SameSite=Lax`-Cookie (siehe oben).
|
||
Die erste Verbindung gelingt, aber Etherpads Autoren-Token wird im
|
||
fremdstämmigen Rahmen nicht mitgesendet. Bricht die Socket-Verbindung später
|
||
einmal ab (Netzwechsel, Standby, Timer-Drosselung eines Hintergrund-Tabs), fehlt
|
||
beim Wiederaufbau die Identität — und ein Etherpad ohne gültige Sitzung ist genau
|
||
das: sichtbar, aber nicht mehr beschreibbar. Das passt zu „nach einer Weile" und
|
||
dazu, dass es im eigenen Tab (erstanbieter-Kontext, Cookie wird gesendet) nicht
|
||
auftritt.
|
||
|
||
**Der Test, der es entscheidet**, gehört in die Hand dessen, der es sieht: Wenn
|
||
es wieder klemmt, das Pad über den „im Pad bearbeiten"-Knopf im **eigenen Tab**
|
||
öffnen. Geht es dort, ist es der Dritt-Kontext (dann hilft nur serverseitig
|
||
`cookie.sameSite: "None"`). Klemmt es dort auch, liegt es am Pad selbst.
|
||
|
||
**Behelf, der schon eingebaut ist:** Den Ansichts-Wähler einmal durchschalten
|
||
lädt den Rahmen neu — „nur Text" setzt `src` auf `about:blank`, zurück auf „beide"
|
||
setzt die Pad-Adresse wieder ein, und das ist ein vollständiger Neuaufbau samt
|
||
Verbindung. Bewusst **nicht** in den Neu-laden-Knopf gelegt: Dessen Zweck ist,
|
||
nach dem Tippen im Pad Spiegel und Diagramm nachzuziehen — würde er dabei den
|
||
Rahmen neu laden, verlöre man bei jedem Diagramm-Update die Schreibmarke im Pad.
|
||
|
||
**Haltung daraus:** Der Rahmen ist zum **Mitlesen** gut; für längeres Schreiben
|
||
ist der eigene Tab die verlässliche Fläche, solange das Cookie nicht
|
||
serverseitig auf `SameSite=None` steht.
|
||
|
||
**Nachtrag zu D32 — eigener Strahlenkranz in Petrol statt geteilter Ring.**
|
||
Der geteilte Ring mit der Cursor-Zeile war zu leise: „Das erkennt man zu wenig"
|
||
(Nutzer). Damit fällt die oben als Preis notierte Zweideutigkeit weg — sie war
|
||
das Zugeständnis für die Sparsamkeit, und wenn die Sparsamkeit nicht funktioniert,
|
||
ist das Zugeständnis auch 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 („oder würdest du dafür auch das gelbe Leuchten nehmen?"), und die Antwort
|
||
ist nein:
|
||
|
||
- Die beiden bedeuten Verschiedenes und treten **gleichzeitig** auf. 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, und bei einem gelben Knoten wüsste niemand mehr,
|
||
welche der beiden Aussagen gemeint ist. Das wäre genau die Verwechslung, die
|
||
dieser Nachtrag behebt, nur mit der anderen Farbe.
|
||
- Die Farblogik ist schon vergeben: Petrol ist im Diagramm die Farbe für
|
||
Interaktion und Aufmerksamkeit (Fokusring, Alt-Ring, „scharf"-Ring, günstigster
|
||
Pfad — D18), Gelb die für Status-Neuigkeit. D15 gab Petrol nur für die
|
||
**Gate-Codierung** auf, nicht als Signalfarbe.
|
||
|
||
**Der Schein ist helles Teal (`#14B8A6`), der Ring dunkles Petrol (`--or`).**
|
||
Die erste Fassung nahm für beides `--or` und wirkte trotz identischer Geometrie
|
||
leiser als der gelbe Kranz. Der Grund ist nicht Geschmack: Ein **dunkler** Schein
|
||
auf weißem Grund liest sich als Schatten, ein **heller** als Licht. Reichweite
|
||
kommt daher vom hellen Teal, die Zuordnung zur Akzentfarbe vom dunklen Ring
|
||
(3 px, damit er gegen den helleren Schein besteht).
|
||
|
||
**Gegen die Stationspunkte geprüft, nicht angenommen.** Der günstigste Pfad legt
|
||
blasse Petrol-Punkte hinter die Endknoten (D18) — ein petrolfarbener Kranz hätte
|
||
damit verschwimmen können. Deshalb wurden zwei Marken gezielt auf Pfad-Endknoten
|
||
gesetzt (mit Punkt) und eine daneben (ohne): Der Kranz sitzt eng am Knoten und
|
||
hat einen harten Ring, der Stationspunkt ist ein großer blasser Fleck ohne Rand.
|
||
Sie sind auseinanderzuhalten.
|
||
|
||
Kombinationen sind 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, einer muss der Ring sein). Die Regel für
|
||
`fresh.focusmark` steht **nach** der für `focusmark.current`: gleiche Spezifität,
|
||
und wenn alles drei zutrifft, sollen Zuruf und Neuheit gewinnen, nicht die eigene
|
||
Cursor-Zeile.
|
||
|
||
**Nachtrag zu D31 — Recherche: Wie kann sich die Ansicht von selbst aktualisieren?**
|
||
Wunsch: automatisch, mit höchstens ~2 s Verzögerung, „dafür könnte man die
|
||
Websockets von Etherpad verwenden". Recherchiert und **gemessen** statt geschätzt;
|
||
die Zahlen stammen aus Etherpads `settings.json.template` und aus Versuchen gegen
|
||
`pad.hostsharing.net`:
|
||
|
||
| Befund | Messung |
|
||
|---|---|
|
||
| `importExportRateLimiting` | `{windowMs: 90000, max: 10}` — 10 Abrufe je 90 s **und IP** |
|
||
| `commitRateLimiting` | `{duration: 1, points: 10}` (betrifft Schreiben, nicht uns) |
|
||
| `cookie.sameSite` | Voreinstellung `"Lax"` — Ursache der Rahmen-Probleme |
|
||
| socket.io | Server v4; `/socket.io/?EIO=4` antwortet `0{"sid":…}`, `EIO=3` wird abgelehnt |
|
||
| Pad-**Seite** `/p/<pad>` | **kein** `Access-Control-Allow-Origin` |
|
||
| Pad-**Export** `/export/txt` | `Access-Control-Allow-Origin: *` |
|
||
|
||
Daraus folgt die Kernbeobachtung, die alles andere erklärt: **Der einzige
|
||
Endpunkt, den wir fremdstämmig lesen dürfen, ist genau der gedrosselte.**
|
||
|
||
**Zwei naheliegende Wege sind gemessen versperrt:**
|
||
|
||
- **Pad-HTML statt Export lesen.** Die Seite enthält den vollen Text in
|
||
`clientVars` und unterliegt dem Export-Limit nicht — aber sie sendet keinen
|
||
CORS-Header, der Browser blockt es. Sackgasse.
|
||
- **Eigener Socket zum Pad.** `wss://…/socket.io/?EIO=4&transport=websocket`
|
||
scheitert mit Code 1006 **ohne** `open`. Gegenprobe gegen einen fremden
|
||
Echo-Server aus derselben Seite: `OPEN`, saubere Schließung — die Umgebung kann
|
||
also WebSockets, dieser Server nimmt uns nur nicht. Zugleich verbindet sich das
|
||
**eingebettete** Pad problemlos (nachgewiesen: es übernahm eine von außen
|
||
eingespielte Änderung sofort) — der Unterschied ist der Origin. Warum genau,
|
||
sagt 1006 nicht (Origin-Prüfung, fehlendes Session-Cookie oder ein Proxy, der
|
||
nur gleichstämmig upgradet); alle drei liegen serverseitig, die Folgerung hält
|
||
also unabhängig davon. **Der vorgeschlagene Websocket-Weg scheitert damit nicht
|
||
an unserer Bereitschaft, eine Abhängigkeit aufzunehmen, sondern an der
|
||
Gegenseite** — und die Easysync-Frage (Changesets anwenden) stellt sich gar
|
||
nicht mehr.
|
||
|
||
**Der Fund, den die Suche gebracht hat:** Es gibt Etherpad-Plugins, die per
|
||
`postMessage` mit der einbettenden Seite reden — `ep_iframeinsert` schickt
|
||
periodisch `{func:"none", context:"ep_iframeinsert", data:<ganzer Pad-Text>}` ans
|
||
Elternfenster (und nimmt umgekehrt `insert`-Befehle an, könnte also sogar
|
||
Schreiben aus Werkbaum heraus erlauben); `ep_resize` belegt dasselbe Muster für
|
||
Größenänderungen. Das ist der einzige Weg, der **live** ist, **kein** Polling
|
||
braucht, das Rate Limit nicht berührt und auf unserer Seite nur einen
|
||
`message`-Zuhörer mit Origin-Prüfung kostet — also **keine** neue
|
||
Laufzeit-Abhängigkeit. Preis: Das Plugin muss auf der Instanz installiert sein.
|
||
|
||
**Damit hängt alles an einer einzigen Frage: Kommt man an die Konfiguration der
|
||
Pad-Instanz?** Jede der wirksamen Möglichkeiten ist eine Server-Änderung —
|
||
Rate Limit höher, Plugin installieren, `cookie.sameSite: "None"`, oder eine
|
||
socket.io-CORS-Freigabe. Ohne Zugriff bleibt nur, innerhalb des Budgets zu
|
||
pollen: höchstens **einmal je 9 s** (10 je 90 s), und das teilen sich alle
|
||
Betrachter hinter derselben IP. Das ist ehrlich machbar, aber es sind nicht die
|
||
gewünschten 2 s — und es ist schlechter als der Knopf, sobald mehrere zuschauen.
|
||
|
||
Die Alternativen stehen als any-of-Gruppe im mitgelieferten Plan
|
||
(`examples/werkbaum.werkbaum`, unter „Update by itself"); die beiden
|
||
gemessenen Sackgassen als `[-]` mit dem Messergebnis im Kommentar, damit niemand
|
||
sie erneut aufmacht. Der günstigste Pfad wählt dort von selbst die
|
||
Rate-Limit-Anhebung — die billigste wirksame Änderung.
|
||
|
||
## D33 — Zeilennummern im Texteditor, gemessen statt gerechnet
|
||
Die Warnungen nennen Zeilennummern („Zeile 12: unbekannter Statuscode", SPEC §4)
|
||
— und das Textfeld zeigte keine. Man musste abzählen. Der Streifen links vom
|
||
Textfeld schließt diese Lücke; sie ist umso spürbarer, je größer der Plan ist
|
||
(der mitgelieferte hat 143 Zeilen).
|
||
|
||
**Ein eigener Kasten neben dem Textfeld, kein Markup im Text.** Ein `<textarea>`
|
||
kennt keine Auszeichnung — man kann in seinen Textfluss nichts einfügen. Also
|
||
ein zweiter Kasten, der nicht selbst scrollt, sondern gegen `src.scrollTop`
|
||
verschoben wird (`translateY`). So kann er nicht auseinanderlaufen: Es gibt nur
|
||
eine Scrollposition, nicht zwei.
|
||
|
||
**Die Zahlen stehen auf gemessenen Höhen, nicht auf „Zeilenhöhe × n".** Der Text
|
||
bricht weich um; eine lange Zeile belegt mehrere Bildzeilen, behält aber **eine**
|
||
Nummer. Gerechnet liefe der Streifen deshalb schon nach der ersten langen Zeile
|
||
davon — im Test bei 375 px Breite brechen 134 der 143 Zeilen um, meist vierfach.
|
||
Gemessen wird am **Spiegel-`div`**, das es für das Scrollen beim Sprung schon
|
||
gibt (D25): ein Marker je Zeile, einmal schreiben, dann alle `offsetTop` in einem
|
||
Durchgang lesen — sonst erzwingt jede einzelne Messung ein eigenes Neu-Layout.
|
||
|
||
**Dabei fiel ein Fehler im Spiegel auf, der schon D25 betraf.** Der Spiegel bekam
|
||
`width = src.clientWidth`, war aber `content-box`: `clientWidth` **enthält** die
|
||
Innenabstände, der Spiegel war also um genau 32 px breiter als das Textfeld und
|
||
brach später um. Mit `box-sizing:border-box` stimmen die Umbrüche jetzt
|
||
nachweislich überein — Zeilen im Spiegel und Bildzeilen im Textfeld ergeben
|
||
dieselbe Gesamthöhe (gemessen: 170 Zeilen beide). Vorher war der Sprung zu einer
|
||
langen Zeile um die Höhe der übersprungenen Umbrüche daneben.
|
||
|
||
**Zwei Zeilen heben sich ab: die Cursor-Zeile und Zeilen mit Warnung** (in
|
||
`--warn`). Genau die beiden Fälle, für die man in den Streifen sieht. Die
|
||
Warnungsmenge kommt aus **derselben** Liste, die im Warnungsbereich steht —
|
||
`render()` reicht sie weiter, statt sie ein zweites Mal zu ermitteln; sonst
|
||
liefen Text und Streifen irgendwann auseinander.
|
||
|
||
**Verworfen: `wrap="off"`.** Ohne weichen Umbruch wäre jede logische Zeile genau
|
||
eine Bildzeile, die Rechnung trivial und der Spiegel überflüssig. Preis wäre
|
||
waagerechtes Scrollen — im schmalen Spiegel neben einem eingebetteten Pad (D31)
|
||
oder auf dem Telefon (D17) wäre der Text damit unlesbar. Der Umbruch ist die
|
||
wichtigere Eigenschaft.
|
||
|
||
*(Diese Abwägung ist später umgekehrt worden: **D49** schaltet den Umbruch ab.
|
||
Übersehen war, was ein weicher Umbruch mit der **Einrückung** macht — und die
|
||
trägt hier die Hierarchie.)*
|
||
|
||
**Kein Umschalter.** Die Kopfzeile ist eng (D17), und ein Bedienelement kostet
|
||
i18n in neun Sprachen für etwas, das keinen Zustand hat, den jemand pflegen
|
||
will. Der Streifen ist so schmal wie die Ziffern es verlangen
|
||
(`calc(<Stellen>ch + 12px)`), auf dem Telefon sind das 36 px.
|
||
|
||
**Stolperfalle bei der Prüfung:** Ein programmatisch gesetztes `scrollTop` löst
|
||
`scroll` erst im nächsten Bild aus — in einem nicht gezeichneten Tab womöglich
|
||
gar nicht. `scrollEditorToOffset()` zieht die Zahlen deshalb selbst gleich mit,
|
||
statt sich auf das Ereignis zu verlassen.
|
||
|
||
## D34 — Abhängigkeiten, IDs, XOR, Falten, Beschreibungen: erst reserviert, dann gebaut
|
||
Fünf Erweiterungen auf einmal — Knoten-**IDs** (`#auth`), **Abhängigkeiten**
|
||
(`:#auth,#api`) samt effektivem Status, **XOR** (`x`), ein- und ausklappbare
|
||
**Teilbäume** (`>` / `<`) und **Knotenbeschreibungen**. Das ist der größte
|
||
Zuwachs an Notation, seit die Sprache steht, und er kommt aus dem Ziel, Lean
|
||
Pathfinding vollständig zu unterstützen (docs/LEAN-PATHFINDING.md).
|
||
|
||
**Entscheidung: alles zuerst nach SPEC §11, nichts vorab gebaut.** Das ist die
|
||
Hausregel (CLAUDE.md: „Syntaxänderungen: SPEC zuerst, dann Code"), hat hier aber
|
||
einen eigenen Grund: Vier der fünf Erweiterungen belegen Zeichen, und **drei
|
||
davon kollidieren mit etwas, das es schon gibt**. Wer eine davon baut, ohne die
|
||
Kollision vorher aufzulösen, entscheidet sie stillschweigend mit.
|
||
|
||
**Was entschieden ist:**
|
||
|
||
- **Abhängigkeiten sagen etwas über den Status, nicht über die Reihenfolge.**
|
||
Sie legen nicht fest, wann jemand anfangen darf — das trennt Werkbaum von
|
||
einem Netzplan und hält den Baum als Zerlegung lesbar.
|
||
- **Der effektive Status wird gerechnet, nie geschrieben.** Im Text steht der
|
||
intrinsische; alles andere gäbe zwei Quellen der Wahrheit für dieselbe
|
||
Aussage (D14: der Text ist das führende Format).
|
||
- **Zyklen sind zulässig**, keine Warnung: Sie bedeuten „wird gemeinsam fertig"
|
||
und sind bei sich gegenseitig bedingenden Teilen die ehrliche Aussage. Ein
|
||
Werkzeug, das sie verbietet, zwingt zum Lügen.
|
||
- **Faltmarken im Text sind der erste Darstellungs-Hinweis in der Notation.**
|
||
Ansichtszustand (Modus, Zoom, Aufteilung) liegt bisher bewusst **außerhalb**
|
||
des Textes, im localStorage und global über alle Dokumente (D22). `>` bricht
|
||
damit — vertretbar, weil es etwas anderes sagt: nicht „so sehe *ich* das
|
||
gerade", sondern „so wird dieses Dokument **eröffnet**". Das ist eine Aussage
|
||
des Autors über das Dokument, gehört also hinein. Der Betrachter bleibt frei:
|
||
im Diagramm wird danach unabhängig gefaltet.
|
||
|
||
**Was ausdrücklich offen bleibt** (jeweils in SPEC §11 notiert, damit es
|
||
niemand beim Bauen überliest):
|
||
|
||
- **`#` trägt drei Bedeutungen** — Ticket (`#123`), Schlagwort (`#tag`),
|
||
Knoten-ID (`#auth`). Das Ticket ist numerisch und bleibt unterscheidbar; ID
|
||
und Schlagwort sind formgleich. Solange die Trennregel fehlt, ist auch `#tag`
|
||
blockiert — die ältere Reservierung ist die, die weichen oder sich fügen muss.
|
||
- **`x` für XOR** kollidiert nicht beim Parsen (das Zerlegungszeichen steht vor
|
||
der Statusbox), aber `x [x] …` liest sich schlecht.
|
||
- **Kurze Beschreibungen können keine eingerückte Folgezeile sein.** Einrückung
|
||
ist in dieser Notation Hierarchie (SPEC §2) — eine eingerückte Zeile *ist* ein
|
||
Kindknoten. Hier liegt die eigentliche Arbeit dieser Erweiterung, nicht im
|
||
Anzeigen eines Tooltips.
|
||
|
||
**Die Folge, die am weitesten reicht: D18 wird schwerer.** Der günstigste Pfad
|
||
rechnet heute rekursiv über den Baum — bei `any of` gewinnt die Alternative mit
|
||
den kleinsten Eigenkosten, Gleichstand entscheidet die erste. Mit
|
||
Abhängigkeiten zählt nicht mehr der Teilbaum, sondern die **Dependency
|
||
Closure**, und gemeinsam benötigte Abhängigkeiten zählen **nur einmal**. Damit
|
||
ist die Wahl nicht mehr lokal:
|
||
|
||
> Eine Gruppe hat die Alternativen `A (S) :#db` und `B (M)`. Für sich genommen
|
||
> gewinnt `B`, sobald `#db` mehr als `S` kostet. Wird `#db` aber ohnehin von
|
||
> einem erforderlichen Knoten anderswo im Baum gebraucht, ist es bezahlt — die
|
||
> **zusätzlichen** Kosten von `A` sind dann nur `S`, und `A` gewinnt.
|
||
|
||
Was billig ist, hängt also davon ab, was der Rest des Plans schon einkauft; bei
|
||
mehreren Alternativgruppen mit geteilten Abhängigkeiten hängen die Wahlen
|
||
zusätzlich voneinander ab. Das ist die Bauform eines Überdeckungsproblems und
|
||
im Allgemeinen nicht mehr gierig optimal zu lösen. Für die Baumgrößen, um die
|
||
es hier geht (ein Plan hat Dutzende, nicht Millionen Knoten), ist eine exakte
|
||
Suche machbar; wird sie zu langsam, bleibt die gierige Rechnung — dann aber
|
||
**benannt**, nicht stillschweigend. Genau darum steht es hier und nicht erst im
|
||
Code.
|
||
|
||
**Nebenbefund:** `docs/LEAN-PATHFINDING.md` hatte für Abhängigkeiten `→ Feature`
|
||
vorgeschlagen (Verweis auf den **Titel**, mit rot gestrichelten Linien). Das ist
|
||
mit `:#id` überholt — ein Verweis auf den Titel bricht beim Umbenennen, und Rot
|
||
ist in dieser Palette nicht vergeben (SPEC §4 nutzt Pastelltöne für Status,
|
||
`--warn` für Hinweise). Die Datei ist entsprechend korrigiert; die dortigen
|
||
Phasen bleiben, was sie sind: eine Wunschliste, kein Beschluss.
|
||
|
||
**Nachtrag — die `#`-Doppelrolle ist aufgelöst, durch Wegfall statt Trennregel.**
|
||
Entschieden: **Ticket-Referenzen haben auf `#` Vorrang** — `#123` ist die
|
||
etablierte Kurzschreibweise, und oft ist die Ticket-Nummer ohnehin die
|
||
natürliche Knoten-ID; die beiden Rollen vertragen sich (numerisch = Ticket als
|
||
Heuristik, notfalls Präfix-Konvention wie `#t123`). Knifflig bleibt allein das
|
||
heuristische Erzeugen von Taiga-Links — das ist benannt und liegt beim
|
||
Taiga-Spike.
|
||
|
||
**Freie Schlagworte verlassen `#` und gehen auf `&tag`** — als **niedrig
|
||
priorisierte Idee**, ausdrücklich ungebaut. Der Schritt zurück („wofür brauchen
|
||
wir überhaupt Tags?") ergab: Es gibt bisher keinen Konsumenten. Die
|
||
Reservierung stammt aus D7 als Beifang der Kommentarzeichen-Entscheidung; kein
|
||
geplantes Feature wertet Schlagworte aus. Ihr einziges echtes Argument ist,
|
||
dass der Baum genau **eine** Zerlegung ist — ein Tag benennt eine **Menge** von
|
||
Knoten quer dazu (die ID benennt einen, Abhängigkeiten verbinden Paare). Ohne
|
||
Auswerter (Filter-/Hervorheben-Linse im Diagramm, Taiga-Label-Sync) sind Tags
|
||
nur Kommentare mit Extra-Syntax; `%% frontend` sagt dem Leser heute dasselbe.
|
||
Gebaut werden sie deshalb erst **zusammen mit** dem ersten Konsumenten.
|
||
|
||
Zeichenwahl `&` nach den D32-Kriterien (schnell zu tippen, kollisionsfrei,
|
||
keine Markdown-Bedeutung): Shift-Taste auf DE- wie US-Layout, nie ein Dead-Key
|
||
— anders als `~`, das auf deutschen Macs hinter Alt+N klemmt und mit
|
||
Zirka-Angaben („~3 Wochen") kollidiert. „R&D" und „Drag & Drop" bleiben durch
|
||
die Alleinstehend-Regel (wie `!!!`) Labels. Der Einwand „`&` heißt und" trägt
|
||
nicht: Das Und der Notation ist `-` am Zeilenanfang — `&` ist gerade deshalb
|
||
frei. Verworfen: `$` (Preis-Kollision), `%` (ein Tippfehler vom
|
||
Auskommentieren), `:` (zu nah an `:#…`), `§` (fehlt auf US-Tastaturen), `*`
|
||
(Markdown, schon in D29 verworfen).
|
||
|
||
**Nachtrag — Ticket-Kennungen sind auch alphanumerisch; die
|
||
Numerik-Heuristik ist gestrichen.** Der ältere Nachtrag oben nahm an, als
|
||
Ticket-Link genüge heuristisch das rein numerische `#123`. Das trägt nicht:
|
||
Taiga schreibt `#US-123` für eine User Story, Jira `#ABC-123` — Kennungen
|
||
sind alphanumerisch (Nutzer-Hinweis). Statt die Form-Heuristik auszuweiten
|
||
(jedes `GROSS-123` als Ticket zu raten, kollidierte mit gewöhnlichen
|
||
Knoten-IDs), wird ein Token künftig am **Muster des angebundenen Trackers**
|
||
erkannt — konfigurierbar je Integration, z. B. `US-\d+` und `\d+` bei Taiga.
|
||
Die Zeichenmenge der Knoten-ID (§1: Buchstaben, Ziffern, `.`, `_`, `-`)
|
||
deckt solche Kennungen bereits ab; am Parser ändert sich nichts, die
|
||
Festlegung des Musters liegt beim Taiga-Spike. SPEC §11 ist entsprechend
|
||
umformuliert.
|
||
|
||
**Nachtrag — das XOR-Zeichen ist entschieden: `=`.** Das in §11 zunächst
|
||
vorgeschlagene `x` ist verworfen; ausschlaggebend war, **wann** seine
|
||
Glyph-Kollision auftritt: `x` teilt sich das Zeichen mit dem Statuscode für
|
||
*fertig*, und genau eine Alternative einer XOR-Gruppe **soll** fertig werden —
|
||
`x [x] …` wäre also der Endzustand jeder entschiedenen Gruppe und stünde
|
||
dauerhaft in jedem Plan. Dazu kam ein zweiter, bis dahin unbenannter Befund:
|
||
Das Zerlegungszeichen wird ohne erzwungenes Leerzeichen erkannt (`\s*` in der
|
||
Referenz-Regex darf leer sein); ein Buchstabe als Gate frisst damit
|
||
Label-Anfänge (`XSS-Schutz` → Gate `X`, Label „SS-Schutz“).
|
||
|
||
`=` ist der einzige Kandidat **ohne jede** Kollision: kein Statuscode, kein
|
||
reserviertes Zeichen, keine Markdown-Bedeutung an dieser Position, auf DE-
|
||
(Shift+0) wie US-Layout (eigene Taste) direkt tippbar, nirgends Dead-Key. Seine
|
||
beiden Schwächen sind benannt und akzeptiert:
|
||
|
||
- **Die Mnemonik trägt sich nicht selbst** („genau eine“ muss die Legende
|
||
sagen) — aber das gilt für `|` („mindestens eine“) genauso; jedes Gate steht
|
||
ohnehin in der Legende.
|
||
- **Optisch steht `=` neben der konjunktiven Familie** (ein doppeltes `-`),
|
||
gehört aber semantisch zu `|`. Dagegen arbeiten zwei Mechanismen: Im Diagramm
|
||
bleiben die Linien gestrichelt-grau wie bei any-of (das Bild korrigiert die
|
||
Lesart sofort), und jede Mischung mit `=` in einer Gruppe gibt die
|
||
`mixedGate`-Warnung.
|
||
|
||
Die **Leerraum-Regel** (Gate `=` nur mit folgendem Leerraum, §11) ist die
|
||
erste ihrer Art; sie hält Labels wie `=SUMME(A1:B2)` heraus und wäre bei jedem
|
||
der Kandidaten nötig gewesen.
|
||
|
||
**Verworfene Kandidaten**, je am entscheidenden Kriterium gescheitert:
|
||
|
||
- **`x`** — siehe oben; die XOR-Mnemonik (für Informatiker stark) wiegt die
|
||
dauerhafte `x [x]`-Paarung nicht auf.
|
||
- **`^`** — XOR in C/Python, aber der Dead-Key schlechthin auf deutschen
|
||
Tastaturen (nicht nur Mac); ihn zu nehmen widerspräche dem im
|
||
`&tag`-Nachtrag festgehaltenen Kriterium. Zudem dieselbe Endzustands-
|
||
Kollision wie `x`, nur mit `[^]`: Per D30 befördert der Deploy die gewählte
|
||
Alternative auf `[^]` — `^ [^] …` stünde ausgerechnet im Vorzeigedokument.
|
||
- **`|1`** (Zweizeichen-Gate in der Pipe-Familie, „genau 1“) — semantisch die
|
||
ehrlichste Form und kollisionsfrei, dem Nutzer aber schlicht nicht gefällig;
|
||
Geschmack ist bei einem Zeichen, das man täglich tippt, ein zulässiges
|
||
Kriterium.
|
||
- **`/`** — das Alltags-Entweder/Oder (ja/nein, m/w/d); teilt sich die Glyphe
|
||
mit `[/]` *Durchstich* (immerhin nur ein Durchgangszustand) und mit
|
||
Pfad-Labels (`/api/…`).
|
||
- **`⊕`** — das echte XOR-Symbol, aber ohne Zeichenpicker auf keiner Tastatur
|
||
tippbar; der Moment des Tippens ist der Moment, in dem man nicht über Syntax
|
||
nachdenken will (D32).
|
||
- **`°`** — fehlt auf US-Layouts, und der kleine Kreis kollidierte semantisch
|
||
mit dem hohlen Kreis, der im Diagramm bereits *optional* bedeutet (D29).
|
||
|
||
**Nachtrag — die Faltmarken sind entschieden: hinter dem Gate, Export folgt
|
||
der Faltung.** Die beiden in §11 offenen Fragen zu `>` / `<`:
|
||
|
||
**Stellung: hinter dem Zerlegungszeichen, vor der Statusbox** —
|
||
`- > [x] Backend`, nicht `> - [x] Backend`. Der Grund ist visuell, nicht
|
||
technisch: Die **Spalte der Zerlegungszeichen** ist das, woran das Auge die
|
||
Hierarchie abliest. Stünde `>` davor, rückte das `-` der gefalteten Zeile
|
||
gegenüber seinen Geschwistern um zwei Zeichen ein — die Gate-Spalte zerfiele
|
||
genau an den Stellen, die man gerade aufgeräumt hat. Hinter dem Gate
|
||
verschiebt sich nur die Statusbox, und die trägt keine
|
||
Ausrichtungs-Information. Dazu kommt: `>` am Zeilenanfang ist die
|
||
Markdown-Blockquote-Konvention und würde falsch gelesen (dieselbe Sorte
|
||
Kollision, wegen der D29 `*` verwarf — Markdown-Betonung). Wurzelknoten haben
|
||
kein Gate — dort steht die Marke am Zeilenanfang; Wurzeln sind selten, und
|
||
die Regel „hinter dem Zeichen“ bleibt trotzdem einheitlich formulierbar.
|
||
|
||
*(Der Satz „die Statusbox trägt keine Ausrichtungs-Information“ ist falsch —
|
||
sie trägt sie sehr wohl. Korrigiert in Nachtrag 2 am Ende dieses Eintrags;
|
||
die Stellung ist jetzt hinter der Statusbox.)*
|
||
|
||
**Export und Druck folgen der sichtbar eingeklappten Struktur — mit
|
||
Kennzeichnung.** Der Präzedenzfall steht in §9: „Es wird genau die sichtbare
|
||
Struktur exportiert (der ‚verworfene einblenden‘-Filter wirkt auch hier).“
|
||
Dieselbe Regel für die Faltung ist konsistent und praktisch richtig: Wer für
|
||
eine Präsentation die Detailebenen zuklappt, will genau dieses Bild teilen.
|
||
Der geprüfte Einwand: Der interaktive Faltzustand ist *persönlicher*
|
||
Ansichtszustand, und Persönliches (Cursor-Ring, gelber Kranz, Fokusmarke —
|
||
D25/D28/D32) wird bewusst **nicht** exportiert. Der Unterschied: Jene Marken
|
||
sagen „schau hin“ bzw. „für dich neu“ — eine Aussage über den Betrachter.
|
||
Faltung sagt „diese Detailtiefe“ — eine Aussage über das Bild, das der
|
||
Exportierende zeigen will. Damit das Bild dabei nicht lügt, wird ein
|
||
eingeklappter Knoten sichtbar gekennzeichnet (etwa „▸“ oder die Anzahl der
|
||
verborgenen Kinder); die genaue Form entscheidet sich beim Bauen, die
|
||
SPEC-Aussage ist nur: sichtbare Struktur, Einklappung gekennzeichnet.
|
||
|
||
**Nachtrag — die Knotenbeschreibungen sind entschieden: `"`-Zeilen und ein
|
||
`---`-Beschreibungsteil.** Die letzte offene Schreibweise der fünf
|
||
Erweiterungen. Entschieden vom Nutzer in zwei Schritten:
|
||
|
||
**Kurzform: eine neue Zeile mit `"`.** Ein eigenes einleitendes Zeichen war
|
||
die einzige Möglichkeit — Einrückung bedeutet Hierarchie (§2), eine
|
||
eingerückte Folgezeile ist ein Kindknoten. `"` liest sich als Zitat („was der
|
||
Autor dazu sagt“), ist auf DE- (Shift+2) wie US-Layout direkt tippbar und an
|
||
dieser Position frei. Die Leerraum-Regel (wie `=`, `>`/`<`) hält gequotete
|
||
Labels (`"Zitat"`) heraus; auf Zeilen mit Zerlegungszeichen gilt das Zeichen
|
||
nicht, ein `- " Zitat" …`-Label bleibt also unberührt.
|
||
|
||
**Langform: hinter einem `---`-Trenner, nur mit Einrückung — ohne weitere
|
||
Zeichen.** Vorgeschlagen waren zeilenweise `"`-Präfixe (robust, aber lästig
|
||
beim Einfügen längerer Texte) und ein `"""`-Zaun (einfügefreundlich, aber ein
|
||
vergessener Schlusszaun verschluckte den Rest des Dokuments — der hässlichste
|
||
Fehlermodus in einem bis dahin zeilenlokalen Format). Der Nutzer wählte die
|
||
dritte, bessere Form: **ein `---`-Trenner nach YAML-/Frontmatter-Vorbild**
|
||
beendet den Baumteil; dahinter eröffnen ID-Zeilen (`#auth`) Blöcke, deren
|
||
eingerückte Zeilen der Text sind. Das nimmt dem Zaun beide Schwächen
|
||
zugleich: Es gibt **keinen Schlusszaun, den man vergessen könnte** (der
|
||
Beschreibungsteil läuft planmäßig bis zum Dateiende), und der Parser-Zustand
|
||
ist ein einziger Einweg-Schalter statt offen/zu. Die Wurzelknoten-Regel gilt
|
||
hinter dem Trenner nicht mehr — darum braucht dort keine Zeile ein Zeichen.
|
||
|
||
Der Fehlermodus „versehentlicher Trenner mitten im Plan“ ist bewusst laut
|
||
gemacht: Uneingerückte Nicht-ID-Zeilen und verwaiste eingerückte Zeilen im
|
||
Beschreibungsteil geben je eine Warnung mit Zeilennummer — verschluckte
|
||
Knotenzeilen melden sich also zeilengenau selbst, statt still zu
|
||
verschwinden (dieselbe Haltung wie bei `unknownStatus`, §4).
|
||
|
||
Zwei bewusste Verhaltensänderungen, beide dokumentiert (§11): `---` ergab
|
||
bisher einen Knoten mit Label `--`, und eine zeichenlose Zeile, die mit
|
||
`" ` beginnt, war bisher ein Wurzelknoten mit `"`-Label.
|
||
|
||
**Nachtrag 2 — die Faltmarke rückt hinter die Statusbox: `- [x] > Backend`.**
|
||
Der erste Nachtrag oben stellte sie zwischen Zeichen und Box und begründete
|
||
das damit, dass sich dabei „nur die Statusbox verschiebt, und die trägt keine
|
||
Ausrichtungs-Information“. **Dieser Satz ist falsch**, und der Fehler war im
|
||
Text nicht zu sehen, sondern erst im gefalteten Plan: Die Marke ist zwei
|
||
Zeichen breit — genau eine Einrückungsstufe. Die Box einer gefalteten Zeile
|
||
rückt dadurch exakt in die Spalte der Boxen ihrer **eigenen Kinder**:
|
||
|
||
```
|
||
- > [ ] erster Schritt %% Box bei Spalte 6
|
||
- [ ] Schritt 1a %% Box bei Spalte 6 ← dieselbe Spalte
|
||
```
|
||
|
||
Die Box-Spalte ist damit sehr wohl Ausrichtungs-Information: Sie ist die
|
||
zweite Spalte, an der das Auge die Ebene abliest, und der Fehler tritt
|
||
ausgerechnet dort auf, wo man ohnehin schon weniger sieht — an einem
|
||
eingeklappten Knoten, dessen Kinder gerade verborgen sind. Hinter der Box
|
||
steht die Marke dagegen vor dem **Label**, und Labels sind ohnehin ausgefranst
|
||
(unterschiedlich lange IDs, Größen, Tags) — dort kostet eine Verschiebung
|
||
nichts:
|
||
|
||
```
|
||
- [ ] > erster Schritt
|
||
- [ ] Schritt 1a
|
||
```
|
||
|
||
**Die Begründung des ersten Nachtrags bleibt im Übrigen gültig:** Die
|
||
Gate-Spalte ist der wichtigste Anker, `>` davor (`> - [x] …`) bliebe falsch,
|
||
und die Markdown-Blockquote-Kollision am Zeilenanfang ebenfalls. Die neue
|
||
Stellung greift keines der beiden an — sie verschiebt die Marke nur um eine
|
||
Position weiter nach rechts.
|
||
|
||
**Nebengewinn: Die Regel wird einfacher, nicht komplizierter.** Bisher hieß
|
||
sie „zwischen Zeichen und Statusbox, bei Wurzelknoten am Zeilenanfang“ — mit
|
||
einer Ausnahme für Wurzeln. Jetzt heißt sie **„unmittelbar vor dem Label“**,
|
||
und das gilt ohne Ausnahme: Fehlt die Statusbox, rückt die Marke von selbst an
|
||
deren Stelle; fehlen Box und Zeichen (Wurzelknoten), steht sie am
|
||
Zeilenanfang. Für Zeilen **ohne** Statusbox ändert sich dadurch gar nichts.
|
||
|
||
**Die alte Stellung wird weiter gelesen, aber nie mehr geschrieben.** Ein
|
||
harter Schnitt wäre vertretbar gewesen — die Faltmarken sind wenige Wochen alt
|
||
—, aber er träfe genau die Dokumente, die wir **nicht** migrieren können:
|
||
Pads (D31) und `?sourceUrl=`-Quellen (D23) liegen auf fremden Servern. Der
|
||
Parser hat deshalb zwei Marken-Gruppen (die erste gewinnt), `setFoldMark()`
|
||
schreibt immer die neue und löst eine alte dabei auf. Die Toleranz kostet eine
|
||
optionale Gruppe im Regex und eine Zeile in der SPEC; die Zeile *muss* dort
|
||
stehen, sonst ist die alte Schreibweise stillschweigend geduldete Magie statt
|
||
dokumentiertes Verhalten.
|
||
|
||
**Der Preis, benannt:** Die Marke steht jetzt direkt vor dem Label, also dort,
|
||
wo ein Label anfangen kann. Ein Label wie `> 100 Nutzer` wird zur Marke plus
|
||
`100 Nutzer`. Neu ist das nicht — für Zeilen ohne Statusbox galt es schon
|
||
immer —, aber es ist jetzt der Regelfall statt der Ausnahme. Der Ausweg ist
|
||
derselbe wie bei `=SUMME(A1:B2)` (D34): kein Leerzeichen, also `>100 Nutzer`.
|
||
|
||
Migriert sind die drei Marken in `docs/examples/werkbaum.werkbaum`, die eine
|
||
im `INITIAL`-Beispiel und das Beispiel in `llms.md`; die Legenden-Zeile
|
||
`hint_fold` nennt die neue Form in allen neun Sprachen.
|
||
|
||
## D35 — XOR (`=`) umgesetzt: „realisiert“ definiert, „1“-Plakette, keine neue Linienart
|
||
Das in D34 entschiedene XOR-Gate ist gebaut (SPEC §1/§3/§9); beim Bauen waren
|
||
drei Dinge zu entscheiden, die die SPEC bis dahin offen ließ:
|
||
|
||
**„Realisiert“ heißt: Kosten investiert oder mehr** — Status `[~]`, `[/]`,
|
||
`[x]`, `[^]`. Die XOR-Regel („genau eine Alternative darf realisiert werden“)
|
||
soll genau dann anschlagen, wenn der Plan tatsächlich doppelt einkauft — und
|
||
das beginnt mit `[~]`: Wer an zwei Alternativen zugleich **arbeitet**, verletzt
|
||
das „genau eine“ bereits, nicht erst beim zweiten `[x]`. `[?]`, `[ ]` und `[!]`
|
||
sind Absicht ohne Investition, `[-]` ist die Entscheidung dagegen, neutral sagt
|
||
nichts — alle fünf zählen nicht. Gemeldet wird **jede weitere** realisierte
|
||
Alternative einzeln (Warnung `xorConflict` mit ihrer Zeilennummer und ihrem
|
||
Label): Die Meldung zeigt so auf die Zeile, die man ansehen muss, statt
|
||
pauschal auf die Gruppe. Bewusst eine **Warnung, kein Fehler** — die Notation
|
||
bleibt fehlertolerant (§4), der Baum wird unverändert dargestellt.
|
||
|
||
**Kennzeichnung: „1“-Plakette am Austritt der Sammelleiste.** Der D34-Nachtrag
|
||
hielt fest, dass `=` optisch bei der konjunktiven Familie steht und das
|
||
Diagramm die Lesart korrigieren muss. Die Linien übernehmen das schon
|
||
(gestrichelt grau wie any-of); die Plakette — kleiner weißer Kreis, graue
|
||
Ziffer, auf dem Leitungsstück zwischen Elternknoten und erstem Abzweig — sagt
|
||
das „genau eine“, das der Linienstil allein nicht sagen kann. Grau statt
|
||
Petrol, weil sie zur Gate-Codierung gehört (D15: keine Signalfarbe im
|
||
Diagramm); an der Sammelleiste statt am Knoten, weil sie eine Aussage über die
|
||
**Gruppe** ist und die Knoten-Ecken belegt sind (D18). Sie erscheint auch im
|
||
Grafikexport (dort nach den Knoten gezeichnet, wie der Optional-Kreis aus D29).
|
||
|
||
**Keine neue Linienart, keine neue CSS-Familie:** Der Renderer gibt
|
||
XOR-Gruppen als `<ul class="or xor">` aus — die gesamte any-of-Geometrie
|
||
(alle drei Modi, D18-Sonderfälle, Export-Routing) gilt damit automatisch;
|
||
`.xor` ergänzt nur die Plakette. Die Alternative — ein eigener Gruppentyp mit
|
||
kopierten Regeln — hätte jede künftige Layoutänderung doppelt pflegen lassen.
|
||
Im Modell ist `'xor'` ein eigener Gate-Wert (`gateOf`), damit die
|
||
`mixedGate`-Warnung Mischungen mit `|` von selbst meldet; alle
|
||
Disjunktiv-Abfragen prüfen `!== 'and'`.
|
||
|
||
## D36 — Knoten-IDs (`#auth`) umgesetzt: eng gefasst, sichtbar nur im Tooltip
|
||
Der erste Baustein der Phase-4-Kette (ohne IDs keine Abhängigkeiten, ohne die
|
||
kein effektiver Status). Vier Festlegungen, die §11 offen ließ:
|
||
|
||
**Zeichenmenge wie `@name`, nicht „whitespace-frei“.** §11 sagte „ein
|
||
whitespace-freier Bezeichner“; umgesetzt ist die engere Menge aus §7
|
||
(Unicode-Buchstaben, Ziffern, `.`, `_`, `-`). Drei Gründe: Konsistenz mit den
|
||
beiden Nachbarn (`@name` heute, `&tag` reserviert mit derselben Menge, D34);
|
||
ein `#a/b` oder `#a:b` liefe sonst in dieselben Kollisionen, vor denen §11 bei
|
||
`:` und Pfaden gerade ausweicht; und enger → weiter ist später kompatibel
|
||
möglich, umgekehrt nicht.
|
||
|
||
**Nur alleinstehend angesetzt** (`(^|\s)#…`, wie beim reservierten `&tag`):
|
||
„C#“ bleibt ein Label, und — entscheidend für den nächsten Schritt — die
|
||
Abhängigkeits-Schreibweise `:#a,#b` wird **nicht** als ID gefressen, weil dort
|
||
`:` bzw. `,` vor dem `#` steht. Die ID-Extraktion muss beim Bau der
|
||
Abhängigkeiten also nicht angefasst werden.
|
||
|
||
**Das erste Token ist die ID, weitere bleiben im Label.** Die ID benennt genau
|
||
einen Knoten — mehr als eine pro Zeile ergibt keinen Sinn. Alles nach dem
|
||
ersten Treffer bleibt unangetastet stehen, denn dort wohnt die reservierte
|
||
Ticket-Referenz (`… #123 …`, §11): Sie soll sichtbar im Label bleiben, bis das
|
||
Taiga-Feature sie auflöst. Deshalb wurden auch die drei `#`-Vorkommen im
|
||
mitgelieferten Plan eingeklammert (`(#auth)`, `(#123)`) — als Erwähnungen sind
|
||
sie keine IDs, und `#123` wäre sonst doppelt vergeben gewesen (Zeile 25/162).
|
||
|
||
**Sichtbar im Tooltip und `aria-label`, sonst nirgends.** Die ID gehört nicht
|
||
zum Label (sonst änderte das spätere Entfernen die Knoten-Identität der
|
||
„Was ist neu?“-Anzeige, D28). Ganz unsichtbar wäre aber nutzerfeindlich —
|
||
getippter Text verschwände spurlos. Der Tooltip zeigt `#id` als erste Zeile,
|
||
der Screenreader bekommt `a11yId`; ein eigenes Badge bekommt sie erst, wenn
|
||
etwas darauf zeigt (Querverbindungen, §11) — die Knoten-Ecken sind belegt
|
||
(D18).
|
||
|
||
**Doppelte ID: Warnung an der späteren Zeile, mit Nennung der ersten.** Die
|
||
Meldung zeigt dorthin, wo man eingreifen muss, und `{firstLine}` erspart das
|
||
Suchen. Die spätere ID gilt trotzdem am Knoten (fehlertolerant wie §4);
|
||
welcher Knoten bei Verweisen „gewinnt“, entscheidet erst die
|
||
Abhängigkeits-Auflösung — dort ist die Warnung dann schon da. Eine Zeile, die
|
||
**nur** aus einer ID besteht, wird wie jede leere Zeile ignoriert und belegt
|
||
die ID nicht.
|
||
|
||
**Nachtrag — die übliche Stellung ist vor dem Titel, mit trennendem
|
||
Doppelpunkt: `#auth: Backend`.** Die Stellung war frei und blieb es auch; was
|
||
fehlte, war eine **Konvention**. In den Beispielen stand die ID mal hinten
|
||
(`Documents on the server #docs (L)`), mal irgendwo dazwischen — lesbar, aber
|
||
ohne feste Stelle, an der das Auge sie sucht. Vorn ist sie dort, wo sie
|
||
hingehört: Sie **benennt** den Knoten, und der Titel ist die Erläuterung dazu.
|
||
Damit liest sich eine Zeile wie ein Wörterbucheintrag, und Zeilen mit ID
|
||
richten sich untereinander aus.
|
||
|
||
**Der Doppelpunkt ist optional und reines Trennzeichen.** Ohne ihn stünden
|
||
zwei Bezeichner unmittelbar nebeneinander (`#auth Backend`) — das liest sich
|
||
wie ein zweiteiliger Name, nicht wie Adresse plus Titel. Er gehört weder zur
|
||
ID noch zum Label und wird nie gerendert; im Diagramm steht genau wie bisher
|
||
nur der Titel. Ein Werkzeug, das ihn als Teil der ID führte, machte aus
|
||
`#auth` und `#auth:` zwei verschiedene Adressen — deshalb fällt er beim Parsen
|
||
weg, nicht erst beim Anzeigen.
|
||
|
||
**Geschluckt wird er nur mit folgendem Leerraum oder Zeilenende** — dieselbe
|
||
Leerraum-Regel wie bei `=`, `>`/`<` und `"` (D34), hier aber mit einem
|
||
konkreten Grund: `#auth:#db` bliebe sonst nicht als ID plus Abhängigkeit
|
||
erhalten. Mit der Regel liest der Parser dort `#auth` als ID, lässt den
|
||
Doppelpunkt stehen, und die Abhängigkeits-Extraktion (§1, Schritt 7) findet
|
||
`:#db` — genau wie ohne die Neuerung. Ein Doppelpunkt **im Titel** bleibt
|
||
ebenfalls unangetastet: `#auth: Regel: nur mit Token` ergibt das Label
|
||
„Regel: nur mit Token“, weil nur der **unmittelbar** an die ID anschließende
|
||
Doppelpunkt gemeint ist.
|
||
|
||
**Die ID-Erkennung selbst ist unverändert geblieben** — die Doppelpunkt-Gruppe
|
||
im Regex ist optional und verlangt nichts. Ein zusätzlich verlangtes
|
||
`(?=\s|$)` **hinter der ID** wäre der naheliegende, aber falsche Weg gewesen:
|
||
Es hätte bestehende Zeilen umgedeutet, weil der Ausdruck bei einem Fehlschlag
|
||
weiterwandert und dann ein *späteres* `#`-Token zur ID erklärt hätte.
|
||
|
||
Im **Beschreibungsteil** (`---`, §1) ist ein angehängter Doppelpunkt ebenfalls
|
||
zugelassen (`#auth:` als Block-Kopf). Dort folgt kein Titel, die Konvention
|
||
greift also nicht — aber wer sie gewohnt ist, soll nicht über eine
|
||
`descStray`-Warnung stolpern. Die mitgelieferten Beispiele schreiben
|
||
Block-Köpfe weiterhin ohne Doppelpunkt.
|
||
|
||
Umgestellt sind alle mitgelieferten Beispiele (`docs/examples/`, das
|
||
`INITIAL`-Dokument und das Beispiel in `llms.md`); SPEC §10 hat keine IDs und
|
||
bleibt unberührt. Die Legenden-Zeile `hint_id` nennt die neue Form in allen
|
||
neun Sprachen.
|
||
|
||
## D37 — Abhängigkeiten (`:#a,#b`) geparst: ein Token, alleinstehend, IDs als Strings
|
||
Der zweite Baustein der Phase-4-Kette. Gebaut ist die **Schreibweise** (SPEC
|
||
§1); die Konsumenten — effektiver Status, Querverbindungen, Closure-Rechnung —
|
||
bleiben eigene Schritte (§11). Vier Festlegungen:
|
||
|
||
**Die Liste ist ein zusammenhängendes Token, ohne Leerraum.** `:#a,#b` — jede
|
||
ID mit `#`, kommagetrennt, kein Leerzeichen. Das ist die wörtliche Lesart von
|
||
§11 („Doppelpunkt mit unmittelbar folgendem `#`“) und macht das Zeilenformat
|
||
robust: Wo das Token endet, beginnt wieder gewöhnlicher Text. Der bekannte
|
||
Stolperstein steht in der SPEC: `:#a, #b` liest nur `#a`, und das ` #b`
|
||
dahinter ist ein alleinstehendes Token — also die Knoten-ID. Ein Rest wie ein
|
||
verwaistes `,b` bleibt sichtbar im Label stehen und verrät den Tippfehler,
|
||
statt verschluckt zu werden.
|
||
|
||
**Nur alleinstehend angesetzt** (`(^|\s):#…`) — **enger als §11**, das nur den
|
||
folgenden `#` verlangte. Der Ausschlag: die **Zitier-Konvention**. Bei den IDs
|
||
(D36) wurden Erwähnungen im mitgelieferten Plan eingeklammert (`(#auth)`), und
|
||
genau das muss auch für Abhängigkeiten funktionieren — `(:#auth,#api)` als
|
||
Erwähnung im Label wäre mit der reinen `:#`-Regel trotzdem geparst worden
|
||
(die Klammer steht ja vor dem `:`, nicht vor dem `#`). Mit der
|
||
Alleinstehend-Regel schützen Klammern einheitlich alles: `(#auth)`,
|
||
`(:#a,#b)`, künftig `(&tag)`. Nebeneffekt: Konstrukte wie `1:#2` im Label
|
||
bleiben Label.
|
||
|
||
**`deps` sind ID-Strings, keine Knoten-Referenzen.** Der Parser prüft nur
|
||
**Existenz** (`unknownDep` je fehlender ID, an der Zeile des abhängigen
|
||
Knotens); aufgelöst wird erst beim Konsumenten. So bleibt der Parse-Baum
|
||
serialisierbar und die Auflösungsfragen (doppelte IDs — die `duplicateId`-
|
||
Warnung steht dann schon da) liegen dort, wo sie beantwortet werden können.
|
||
**Zyklen werden bewusst nicht einmal erkannt**: Sie sind zulässig (§1/D34,
|
||
„wird gemeinsam fertig“), eine Zyklenprüfung hätte also keinen Abnehmer —
|
||
auch die Selbst-Abhängigkeit ist stumm.
|
||
|
||
**Sichtbar als `→ #a, #b` im Tooltip und als `a11yDeps` im `aria-label`** —
|
||
dieselbe Zurückhaltung wie bei den IDs (D36): keine eigene Diagramm-
|
||
Darstellung, bis die Querverbindungen (§11) gebaut sind; der Pfeil im Tooltip
|
||
sagt die Richtung („hängt ab von“), ohne ein neues Zeichen einzuführen.
|
||
|
||
## D38 — Faltmarken gebaut: `<` wandert die Faltung hinunter, Eingriffe sind flüchtig
|
||
Die in D34 entschiedene Schreibweise (`- [x] > …`, Export folgt der Faltung)
|
||
ist umgesetzt (SPEC §1/§9). Die Bau-Entscheidungen:
|
||
|
||
**`<` holt seinen Teilbaum hervor, indem die Faltung die Pfad-Ebenen
|
||
hinunterwandert.** Die naive Lesart — `<` öffnet einfach alle Vorfahren —
|
||
machte das `>` am Vorfahren wirkungslos: Öffnen zeigt **alle** Kinder samt
|
||
Teilbäumen, nicht den einen gemeinten. Die andere naheliegende Lesart — nur
|
||
den Pfad einblenden, alles andere desselben Vorfahren verbergen — bräuchte
|
||
Knoten, die ohne ihre sichtbaren Eltern gezeichnet werden, oder Kanten, die
|
||
Ebenen überspringen: Beides log über die Struktur (dieselbe Sorte Fehler, die
|
||
D29 beim Treppen-Export korrigiert hat). Das Hinunterwandern vermeidet beides:
|
||
Der eingeklappte Vorfahr öffnet sich, seine **übrigen** Kinder werden
|
||
stattdessen eingeklappt — sichtbar ist der Pfad samt Teilbaum, die Geschwister
|
||
stehen als je ein Knoten mit „▸ n“ da, und jede gezeichnete Kante ist eine
|
||
echte. Ein `>` innerhalb des hervorgeholten Teilbaums bleibt respektiert.
|
||
Ausdrückbar ist das alles in einem schlichten Je-Knoten-Zustand
|
||
(`initialCollapsed()` in model.js, headless getestet).
|
||
|
||
**Auch die Fokusmarke `!!!` holt sich hervor** — über denselben Mechanismus
|
||
(sie geht als zweite Rettungsmarke in `initialCollapsed()` ein). Ein
|
||
Zeigefinger auf etwas Unsichtbares zeigte ins Leere; und weil Nutzer-Eingriffe
|
||
den Anfangszustand **überlagern**, kann man den Bereich danach trotzdem wieder
|
||
zuklappen — die Marke reißt ihn nicht bei jedem Neubau wieder auf.
|
||
|
||
**Interaktive Eingriffe: je Knoten, Label-Pfad-Identität, nur für die
|
||
Sitzung.** Der Baum wird bei jedem Tastendruck neu geparst — ein Eingriff muss
|
||
Neu-Renderings überleben, also hängt er nicht am Knotenobjekt, sondern am
|
||
**Label-Pfad** (dieselbe Identität wie „Was ist neu?“, D28: Umsortieren
|
||
überlebt, Umbenennen gilt als neuer Knoten). **Nicht persistiert**: Die
|
||
dauerhafte Aussage über den Anfangszustand steht im Text (D34 — „so wird
|
||
dieses Dokument eröffnet“); der localStorage-Ansichtszustand ist zudem global
|
||
über alle Dokumente (D22), ein Je-Dokument-Faltzustand wäre dort ein
|
||
Fremdkörper. Dokumentwechsel setzt zurück.
|
||
|
||
**Bedienung: Falt-Zeichen ▾/„▸ n“ vor dem Label, Pfeiltasten ←/→.** Der
|
||
einfache Klick ist der Link (§6), Alt+Klick der Sprung (D25), der lange Druck
|
||
ebenso — für das Falten blieb nur ein eigenes Klickziel oder eine weitere
|
||
Modifier-Geste; das sichtbare Zeichen ist zugleich die Auffindbarkeit (die
|
||
Lehre aus D25: eine Geste, die niemand sieht, ist keine Funktion). ←/→ am
|
||
fokussierten Knoten ist das WAI-ARIA-Baum-Idiom; Enter bleibt dem Link,
|
||
Alt+Enter dem Sprung. Der Zähler nennt **alle** verborgenen Knoten (nicht nur
|
||
die direkten Kinder) — er sagt, wie viel Plan dort zusammengefaltet liegt.
|
||
Nach dem Umklappen wird der Fokus auf denselben Knoten zurückgesetzt (der
|
||
Neubau hätte ihn sonst verschluckt und die Tastaturbedienung abgerissen).
|
||
|
||
**Faltung ist reine Ansicht — mit zwei bewussten Konsequenzen.** Warnungen
|
||
aus eingeklappten Teilbäumen werden weiter gemeldet: `renderChildren()`
|
||
überspringt nur das HTML, ein eigener Lauf (`walkFolded`) sammelt Warnungen
|
||
und zählt zugleich die verborgenen Knoten — sonst verschwände eine
|
||
`mixedGate`-Meldung je nach Faltzustand, obwohl sie den **Text** betrifft.
|
||
Und der günstigste Pfad rechnet unverändert über den ganzen Baum; seine
|
||
Spline-Linie läuft ohnehin nur durch DOM-Knoten, führt also durch die
|
||
sichtbaren Endknoten. Ein eingeklappter Zweig kann Pfadknoten verbergen —
|
||
hinnehmbar, die Inversion an den sichtbaren Knoten bleibt richtig.
|
||
**Das war falsch — siehe den Nachtrag am Ende dieses Eintrags.**
|
||
|
||
**Export und Druck:** Verborgene Kinder stehen nicht im DOM — Export,
|
||
Stiel-Messung, Treppe und Pfadlinie sind damit ohne Zusatzcode konsistent
|
||
(derselbe Grund, aus dem der Renderer sie gar nicht erst erzeugt, statt sie
|
||
per CSS zu verstecken). Die Kennzeichnung „▸ n“ ist Teil des Knotentexts und
|
||
wandert von selbst in den SVG-Export; das ▾ offener Knoten wird dort und im
|
||
Druck entfernt — es ist Bedienelement, keine Aussage über den Plan.
|
||
|
||
**Nachtrag 2 — Umklappen im Diagramm schreibt jetzt in den Text.** Damit wird
|
||
die oben getroffene Festlegung („der Eingriff gilt für die Sitzung; er wird
|
||
nicht gespeichert — die dauerhafte Aussage steht im Text") **umgekehrt**, und
|
||
mit ihr die D34-Formulierung, die Marken bestimmten nur den Anfangszustand.
|
||
Anlass ist der Wunsch nach bidirektionaler Interaktion zwischen Diagramm und
|
||
Text; der Faltzustand ist dafür der richtige erste Fall: verlustfrei umkehrbar,
|
||
ohne inhaltliche Aussage, und die Notation dafür gibt es schon.
|
||
|
||
Die Entscheidungen im Einzelnen, alle vom Nutzer getroffen:
|
||
|
||
- **Bei jedem Umschalten**, nicht auf Ansage. Der direkteste Zusammenhang
|
||
zwischen Bild und Text; der Preis (unten) wurde bewusst in Kauf genommen.
|
||
- **`<` bleibt erhalten, wo es noch stimmt.** Nicht auf reine `>`
|
||
normalisieren: Handgeschriebene Marken sollen stehen bleiben.
|
||
- **Geschrieben wird, wo der Text beschreibbar ist** — auch bei `?sourceUrl=`,
|
||
wo es wie jede lokale Änderung bis zum nächsten Laden hält (D23). Nur beim
|
||
Pad bleibt es sitzungsweise, weil das Textfeld dort schreibgeschützt ist
|
||
(D31).
|
||
- **Falten gilt als Änderung.** Ein mitgeliefertes Dokument wird dadurch als
|
||
bearbeitet geführt, bekommt also keine neuen Fassungen mehr und zeigt
|
||
„Original wiederherstellen" (D27). Bewusst keine Sonderregel: Wer faltet,
|
||
ändert den Text, und der Text ist der Vertrag (D14).
|
||
- **Undo-fähig geschrieben.** Nachgemessen: `value =` **und** `setRangeText`
|
||
machen Strg+Z wirkungslos — wer tippt und dann faltet, käme an sein
|
||
Getipptes nicht mehr heran. Nur `document.execCommand('insertText')` erhält
|
||
die Historie; die API gilt als veraltet, funktioniert aber überall. Falten
|
||
ist damit ein eigener Undo-Schritt (geprüft: Strg+Z nimmt die Faltung
|
||
zurück, ein zweites das Getippte).
|
||
|
||
**Das Verfahren: minimal patchen, dann nachrechnen.** Die Ableitung Text →
|
||
Zustand ist **nicht umkehrbar** — mehrere Markensätze ergeben denselben
|
||
Zustand, und `<` erzeugt Faltungen an Knoten, die gar keine Marke tragen
|
||
(oben). Statt sie zu invertieren, wird der Kandidat schlicht **befragt**:
|
||
`>` an der einen Zeile setzen oder entfernen, `initialCollapsed()` darauf
|
||
laufen lassen, mit dem Sollzustand vergleichen. Stimmt es, bleibt alles andere
|
||
unangetastet — das ist der `<`-Erhalt. Stimmt es nicht, werden alle Marken neu
|
||
gesetzt und erneut geprüft. Stimmt auch das nicht — etwa weil ein `!!!` seinen
|
||
Knoten immer wieder hervorholt und „eingeklappt" dort gar nicht ausdrückbar
|
||
ist —, wird **nicht geschrieben** und die Sitzungs-Überlagerung bleibt stehen.
|
||
So kann nie ein Text entstehen, der etwas anderes sagt als das Bild.
|
||
`initialCollapsed()` bleibt dadurch die einzige Stelle, die die Bedeutung der
|
||
Marken kennt.
|
||
|
||
**Zwei Fallen, beide gemessen statt vermutet.** Erstens: Auf kleinem
|
||
Bildschirm ist der Editor `display:none`, wenn das Diagramm vorn ist — und
|
||
`execCommand` tut dann **nichts**, es liefert `false`, obwohl `activeElement`
|
||
das Textfeld meldet. Für die Dauer des synchronen Schreibens wird der Editor
|
||
deshalb absolut positioniert aus dem Bild geschoben sichtbar geschaltet
|
||
(Klasse `writing-fold`, derselbe Griff wie `exporting` im Grafikexport);
|
||
gezeichnet wird davon nichts. Zweitens: `execCommand` braucht den Fokus im
|
||
Textfeld, und der zöge auf dem Telefon die Bildschirmtastatur hoch — dagegen
|
||
`inputmode="none"` wie beim Sprung (D25), das der erste echte Tipp ins Feld
|
||
wieder aufhebt. Anschließend geht der Fokus an den Knoten zurück.
|
||
|
||
Schreibmarke und Scrollstand werden gesichert und zurückgesetzt; verschoben
|
||
wird nur, was **hinter** der Änderung lag. Gelingt das Schreiben, werden die
|
||
Sitzungs-Überlagerungen geleert — sonst könnten sie den Text maskieren, der
|
||
jetzt die Wahrheit ist.
|
||
|
||
**Nachtrag — der eingeklappte Knoten vertritt seinen Teilbaum auch auf dem
|
||
günstigsten Pfad.** Oben steht, ein eingeklappter Zweig dürfe Pfadknoten
|
||
verbergen, „die Inversion an den sichtbaren Knoten bleibt richtig". Das war zu
|
||
kurz gedacht, und der Nutzer hat es benannt: Die **Linie** blieb eben nicht
|
||
richtig. Sie fädelt durch die `cheap-leaf`-Knoten im DOM; ein eingeklappter
|
||
Pfadknoten ist `cheap`, aber kein Blatt (seine Pfad-Kinder sind ja im Modell
|
||
vorhanden) — also bekam er keine Station, und die Linie **umging den ganzen
|
||
Zweig**. Im mitgelieferten Beispiel nachgemessen: „Concept" war
|
||
`node cheap folded`, verbarg zwei Pfadknoten und trug keinen Punkt; die Linie
|
||
begann erst bei „PWA". Das Bild behauptete damit, in diesem Zweig sei nichts
|
||
zu tun — und das ist eine Aussage über den Plan, keine über die Ansicht. Genau
|
||
die Grenze, die dieser Eintrag sonst zieht.
|
||
|
||
**Regel: Eingeklappt ist der Knoten die tiefste noch sichtbare Station.**
|
||
`cheapCls(n, cheapSet, collapsed)` überspringt für eingeklappte Knoten die
|
||
Blatt-Prüfung und fragt stattdessen den Teilbaum: Liegt darin etwas auf dem
|
||
Pfad (oder der Knoten selbst), ist er `cheap cheap-leaf`. Beim Aufklappen
|
||
geben die Kinder die Station zurück — nachgemessen 6 → 7 → 6 Stationen beim
|
||
Hin- und Herklappen, Punkte jeweils gleich.
|
||
|
||
**Auch ohne eigene Pfad-Mitgliedschaft.** Ein per `:#…` gezogenes Ziel kann in
|
||
einem Zweig liegen, dessen Wurzel selbst nicht gebraucht wird (D42, etwa unter
|
||
einem `+`-Knoten). Ist dieser Zweig eingeklappt, ist seine Wurzel der einzige
|
||
sichtbare Griff auf nötige Arbeit — sie wird deshalb Station **und** zählt als
|
||
`cheap`, tritt also nicht zurück. Das ist keine Ausnahme, sondern dieselbe
|
||
Regel: Der eingeklappte Knoten **steht für** seinen Teilbaum, und der enthält
|
||
Nötiges. Ihn auszublassen wäre die Lüge.
|
||
|
||
Der Preis ist benannt und klein: Ein eingeklappter Zweig zeigt **eine** Station
|
||
statt mehrerer. Das ist keine Ungenauigkeit, sondern die Aussage der Faltung —
|
||
„hier drin liegt noch Pfad", genauer geht es ohne Aufklappen nicht.
|
||
|
||
**Nachtrag 3 — eine einzelne Station ist ein gültiger Pfad.** Der Nachtrag
|
||
oben behob den Fall „eingeklappter Zweig wird übersprungen“ an der
|
||
Klassenvergabe (`cheapCls`) — und ließ dabei eine zweite, ältere Schranke
|
||
stehen: `drawCheapPath()` stieg bei weniger als **zwei** Stationen ganz aus.
|
||
Solange jeder eingeklappte Knoten nur *einen* Zweig vertrat, fiel das nicht
|
||
auf. Es fällt auf, sobald weit oben gefaltet wird: Klappt man den
|
||
**Wurzelknoten** des Werkbaum-Plans zu, bleibt genau eine sichtbare Station —
|
||
und damit verschwand nicht nur die Linie (richtig, durch einen Punkt führt
|
||
keine), sondern auch der **Stationspunkt** (falsch). Der Pfad war ausgerechnet
|
||
dort ganz weg, wo die Faltung ihn am nötigsten vertreten muss. Nachgemessen
|
||
vor der Korrektur: Knoten `root-node cheap cheap-leaf folded`, Stationspunkte
|
||
**0**; danach 1 Punkt, 0 Linien, im Grafikexport dasselbe (1 Kreis, keine
|
||
Pfadlinie).
|
||
|
||
Die Schranke gehört an die **Linie**, nicht an die Zeichenfunktion: `catmullRom`
|
||
braucht zwei Punkte, die Punkte brauchen einander nicht. Dieselbe Trennung im
|
||
Export (`cheapPts.length >= 2` nur noch für die Linie).
|
||
|
||
**Dabei gefunden: der zugeklappte Wurzelknoten ließ sich nicht wieder
|
||
aufklappen.** Ein eigener Fehler, nur über denselben Weg erreichbar. Im
|
||
Werkbaum-Plan ist „Wurzel eingeklappt“ **nicht in Marken ausdrückbar** — das
|
||
`<` in Zeile 160 holt seinen Teilbaum immer wieder hervor —, der Zustand liegt
|
||
also in der Sitzungs-Überlagerung (der dritte Fall aus Nachtrag 2). Beim
|
||
**Auf**klappen berechnete `writeFoldToText()` dann einen Text, der mit dem
|
||
vorhandenen identisch war: nichts zu schreiben. `replaceTextUndoable()` meldete
|
||
dafür Erfolg — aber ohne Textänderung feuert kein `input`-Ereignis, also lief
|
||
kein `render()`, und das Bild blieb stehen, wie es war. Jeder weitere Klick tat
|
||
dasselbe. Jetzt meldet die Funktion „nichts geschrieben“, und der Aufrufer
|
||
zeichnet selbst neu. Nachgemessen: 144 Knoten/69 Punkte → 1/1 → 144/69 → 1/1
|
||
über mehrere Klicks.
|
||
|
||
## D39 — Effektiver Status: die Farbe sagt die Wahrheit, die Marke die Box
|
||
Mit den Abhängigkeiten (D37) gibt es zwei Aussagen je Knoten: was in der
|
||
Statusbox steht (intrinsisch) und wie weit er wirklich ist (effektiv, §4).
|
||
Erwogen waren drei Darstellungen — Diskrepanz-Kennzeichnung bei intrinsischer
|
||
Farbe (die ursprüngliche Empfehlung), ein Umschalter, oder Farbe = effektiv.
|
||
**Entschieden (Nutzer): Die Knotenfarbe zeigt den effektiven Status; wo der
|
||
eigene Status weiter ist, kommt eine Diskrepanz-Marke dazu.** Das ist die
|
||
stärkere Wahl: Das Diagramm beantwortet „wie weit ist das wirklich?“ — genau
|
||
die Frage, für die man auf einen Plan schaut. Ein grünes `[x]`, das auf ein
|
||
rosé `[~]` wartet, wäre die hübschere Lüge.
|
||
|
||
**Die Rechenregel ist ein Minimum über die Abhängigkeits-Hülle.** Jeder Status
|
||
bekommt einen Fortschritts-Rang entlang der Ergebnis-Skala (D5); effektiv ist
|
||
das Minimum des intrinsischen Rangs über den Knoten selbst und alles, was er
|
||
direkt oder mittelbar braucht. Diese Form hat zwei angenehme Folgen: **Zyklen
|
||
brauchen keine Sonderregel** — alle Knoten eines Zyklus teilen ihr Minimum,
|
||
und das ist wörtlich das „wird gemeinsam fertig“ aus D34; und die Rechnung ist
|
||
eine schlichte Fixpunkt-Iteration (Ränge sinken nur, Abbruch garantiert).
|
||
Außerhalb der Skala: neutral und `[-]` zählen als 0 — wer auf etwas
|
||
Verworfenes oder nie Begonnenes zeigt, ist effektiv am Anfang, und genau das
|
||
soll auffallen —, `[!]` als 1 (die Absicht-ohne-Investition-Gruppe aus D35).
|
||
Bei doppelter ID gilt die **erste** Vergabe — das löst das in D36 offen
|
||
gelassene „wer gewinnt bei Verweisen“ konsistent zur `duplicateId`-Warnung.
|
||
|
||
**Die Diskrepanz-Marke ist die eigene Statusbox in den eigenen Farben.**
|
||
Unten links (die letzte freie Knoten-Ecke: oben rechts Größe, unten rechts
|
||
Tags, oben links ⚠) sitzt ein kleines `[x]`-Etikett in den §4-Farben des
|
||
intrinsischen Status — die Notation kennzeichnet sich mit ihrem eigenen
|
||
Vokabular, niemand lernt ein neues Symbol, und die Farben tragen die ganze
|
||
Geschichte: Knoten rosé (effektiv in Arbeit), Etikett grün (selbst fertig).
|
||
Tooltip („effektiv … — selbst schon …, wartet auf Abhängigkeiten“) und
|
||
`a11yEffective` sagen es in Worten. Marke und Färbung gehören in Export und
|
||
Druck — sie sind eine Aussage über den Plan, nicht über den Betrachter.
|
||
|
||
**Was beim intrinsischen Status bleibt:** die XOR-Regel (§3) — „realisiert“
|
||
heißt Kosten investiert, und investiert ist investiert, auch wenn
|
||
Abhängigkeiten den Knoten zurückhalten; und „Was ist neu?“ (D28) — der gelbe
|
||
Kranz meldet das `[^]` im Text, also den Deploy des Knotens selbst. Beide
|
||
Prüfungen laufen im Parser bzw. auf dem Text und bleiben unberührt.
|
||
|
||
## D40 — Knotenbeschreibungen gebaut: Tooltip statt Pop-up, ”-Marke, laute Strays
|
||
Die in D34 entschiedene Schreibweise (`"`-Zeilen, `---`-Beschreibungsteil) ist
|
||
umgesetzt (SPEC §1/§9). Die Bau-Entscheidungen:
|
||
|
||
**Anzeige im Tooltip, kein eigenes Pop-up.** SPEC §11 ließ „Tooltip oder
|
||
Pop-up“ offen. Ein Pop-up bräuchte eine eigene Geste (Klick ist der Link,
|
||
Alt+Klick der Sprung, der lange Druck ebenso — es bliebe nur ein weiteres
|
||
Klickziel neben dem Falt-Zeichen), Positionierung, Schließen-Logik und
|
||
Mobil-Verhalten. Der Tooltip kostet nichts davon: Der Beschreibungstext steht
|
||
**zuerst** (mehrzeilig — `title` zeigt Zeilenumbrüche), danach die
|
||
Kurz-Fakten (ID, Abhängigkeiten, Status). Bekannte Grenze: Auf Touch-Geräten
|
||
gibt es keine Tooltips — dort bleibt der Text vorerst nur im `aria-label`;
|
||
ein Pop-up kann später ergänzt werden, die Syntax ändert sich dadurch nicht.
|
||
|
||
**Auffindbarkeit: die ”-Marke.** Eine Beschreibung, die nur im Tooltip lebt,
|
||
wäre unsichtbar (die D25-Lehre: was niemand sieht, ist keine Funktion). Ein
|
||
Knoten mit Beschreibung trägt deshalb ein kleines ” hinter dem Label — es
|
||
spiegelt das `"`-Zeichen der Notation, wie die `[x]`-Marke (D39) deren
|
||
Statusboxen spiegelt; kein neues Symbol zu lernen. **Nicht im Export**: Der
|
||
Text selbst kann im statischen Bild nicht erscheinen, eine Marke ohne Ziel
|
||
wäre Rauschen — anders als „▸ n“ (D38), das eine nachprüfbare Aussage über
|
||
verborgene Knoten trifft. Screenreader bekommen den Text im `aria-label`.
|
||
|
||
**Strays warnen einzeln, Blöcke unter unbekannter ID schlucken still.** Die
|
||
beiden Fehlerfälle sind verschieden: Eine verwaiste Zeile (uneingerückt und
|
||
keine ID-Zeile, oder eingerückt ohne offenen Block) ist wahrscheinlich ein
|
||
**verrutschter Knoten** — genau der Fall des versehentlichen Trenners mitten
|
||
im Plan — und meldet sich je Zeile (`descStray`), damit nichts still
|
||
verschwindet. Ein Block unter einer **unbekannten ID** dagegen ist als
|
||
Beschreibung erkennbar und schon mit `unknownDesc` gemeldet; seine Textzeilen
|
||
zusätzlich einzeln anzuprangern wäre nur Lärm (SKIP-Ziel im Parser).
|
||
|
||
**Freitext heißt Freitext:** In Beschreibungszeilen findet keine
|
||
§1-Extraktion statt — `(M)`, `@name`, `#id` oder URLs im Text bleiben Text.
|
||
Einzige Ausnahme ist `%%`: Der Kommentar fällt im ganzen Dokument als
|
||
Erstes weg (einheitliche Regel, §1) — so lassen sich auch Beschreibungen
|
||
kommentieren.
|
||
|
||
**Nachtrag — Beschreibung und Kurz-Fakten werden im Tooltip getrennt.** Die
|
||
erste Fassung hängte alles an dieselbe ` · `-Aufzählung: Auf die letzte Zeile
|
||
des Fließtexts folgte unmittelbar `· #cms · geplant · Alt+Klick: …`. Gemeldet
|
||
als „einfach hinten drangeklatscht“, und das trifft es — der Übergang war
|
||
nicht zu sehen, obwohl dort die Art der Aussage wechselt: vorn ein Satz, den
|
||
jemand geschrieben hat, hinten Metadaten, die das Werkzeug kennt. Jetzt trennt
|
||
eine **Leerzeile plus Trennstrich**.
|
||
|
||
Der Strich ist aus `─` (U+2500) gebaut, nicht aus Bindestrichen: Das
|
||
Box-Drawing-Zeichen stößt gapless aneinander und liest sich als Linie, `---`
|
||
liest sich als Text. Ein `title` kann nichts anderes — es gibt darin kein
|
||
Markup, und ein eigenes Pop-up wollten wir gerade nicht (siehe oben). Zwei
|
||
Randbedingungen sind bewusst gesetzt: Der Strich steht **nur**, wenn beide
|
||
Teile vorhanden sind (ohne Beschreibung bleibt der Tooltip wie er war), und er
|
||
ist mit 24 Zeichen schmaler als die Fakten-Zeile, die den Sprung-Hinweis
|
||
enthält — er verbreitert den Tooltip also nie.
|
||
|
||
**Nicht in den `aria-label`.** Dort bleibt es bei der Kommaliste mit
|
||
whitespace-normalisierter Beschreibung: Ein Screenreader läse vierundzwanzig
|
||
Striche einzeln vor, und die Trennung, die ein Auge braucht, braucht ein Ohr
|
||
nicht — die Aufzählung ist dort ohnehin schon gegliedert.
|
||
|
||
**Nachtrag 2 — der Cursor in einer Beschreibung wählt ihren Knoten aus.**
|
||
Bisher fiel die Hervorhebung (D25) weg, sobald der Cursor eine Zeile
|
||
weiterrückte: Die `"`-Zeile trägt keinen Knoten, also fand die Zeilensuche
|
||
nichts. Das ist die falsche Auskunft — die Zeile trägt keinen **eigenen**
|
||
Knoten, gehört aber zu einem, und wer in ihr schreibt, arbeitet an genau
|
||
diesem Knoten.
|
||
|
||
**Beide Formen, nicht nur die Kurzform.** Gefragt war nach der `"`-Zeile; die
|
||
Zuordnung entsteht aber an derselben Stelle im Parser, an der auch die
|
||
`---`-Blöcke landen, und dieselbe Begründung trägt dort sogar weiter: Der
|
||
Langtext steht am **Dateiende**, weit weg von seinem Knoten — die
|
||
Hervorhebung ist die einzige Anzeige, an welchem man gerade schreibt.
|
||
Zugeordnet werden die eingerückten Textzeilen, die **Kopfzeile** `#auth`
|
||
(sie nennt den Knoten) und Leerzeilen **innerhalb** eines Blocks; nicht der
|
||
`---`-Trenner selbst und nichts unter einer unbekannten ID (dort gibt es
|
||
keinen Knoten, und `unknownDesc` steht schon).
|
||
|
||
**Die Zeilennummern liegen am Knoten, nicht in einer Nebenrechnung.**
|
||
`node.descLines` entsteht im Parser (`ownLine()`), der Renderer gibt sie als
|
||
`data-desc-lines="3 4 5"` aus, app.js findet den Knoten per
|
||
`[data-desc-lines~="N"]` — der Attribut-Selektor trifft die Nummer als Glied
|
||
der Liste, es braucht keine eigene Datenstruktur im UI. Bewusst **getrennt**
|
||
von `descLines` (dem Text): Dort fallen aufeinanderfolgende Leerzeilen zu
|
||
einem Absatztrenner zusammen und Blocktext unter unbekannter ID kommt gar
|
||
nicht erst an — für die Zeilenzuordnung wäre beides falsch.
|
||
|
||
Beide Richtungen gehen jetzt über **eine** Auflösung (`nodeOfLine()`): das
|
||
Mitlaufen der Cursor-Zeile und der ausdrückliche Alt+Klick (D25-Nachtrag 1).
|
||
Sonst wäre die Geste aus einer Beschreibung heraus stumm geblieben — und das
|
||
ist genau der Ort, an dem man sie braucht.
|
||
|
||
## D41 — Querverbindungen: Krümmung statt Farbe, Pfeil auf das Gebrauchte
|
||
Die Abhängigkeits-Kanten (SPEC §9) sind die erste Linienart, die nicht der
|
||
Zerlegung folgt — §11 verlangte dafür eine eigene Zeichenebene. Gebaut wie der
|
||
Pfad-Spline (D18): Overlay-SVGs in `#out`, die den CSS-`zoom` erben, neu
|
||
gezeichnet nach Render und Moduswechsel. Die Bau-Entscheidungen:
|
||
|
||
**Das Unterscheidungsmerkmal ist die Krümmung, nicht eine neue Farbe.** Die
|
||
Palette ist vergeben: Tinte und Grau gehören den Baumlinien, Petrol der
|
||
Interaktion und dem günstigsten Pfad, `--warn` dem Geister-Knoten — und Rot
|
||
ist bewusst nicht vergeben (D34 zu LEAN-PATHFINDING). Also tragen die Kanten
|
||
dasselbe Blassgrau wie die any-of-Führung, aber **geschwungen** (quadratische
|
||
Kurve mit seitlichem Bauch): Alle Zerlegungslinien sind achsenparallel, jede
|
||
gekrümmte Linie ist damit auf einen Blick „keine Zerlegung“ — dasselbe Mittel,
|
||
mit dem sich der Pfad-Spline absetzt. Dünn (1,5 px) und blass (0,35), denn der
|
||
Baum trägt die Hauptaussage; die **Pfeilspitze zeigt auf das Gebrauchte**
|
||
(„braucht“-Richtung), Endpunkte auf den Knotenkanten statt -mitten, damit die
|
||
Spitze sichtbar bleibt.
|
||
|
||
**„Ausgewählt“ hat zwei Lesarten, beide gelten:** der Knoten mit
|
||
Tastaturfokus im Diagramm, sonst der Knoten der Cursor-Zeile (D25). Dessen
|
||
ein- und ausgehende Kanten wandern auf eine vordere Ebene in Tinte (2 px,
|
||
0,9) — vorn dürfen sie den Baum überlagern, denn die Hervorhebung ist
|
||
flüchtig und gerade angefragt. Kein Klick-Auswahlzustand: Der einfache Klick
|
||
ist der Link (§6), und Knoten fokussieren sich beim Klicken ohnehin.
|
||
|
||
**Kanten enden an sichtbaren Knoten oder gar nicht.** Ziel eingeklappt (D38)
|
||
oder als verworfen ausgeblendet → keine Kante; eine Kante zum eingeklappten
|
||
Vorfahren wäre eine falsche Aussage über das Ziel. Bei doppelter ID zielt die
|
||
Kante auf die erste Vergabe — dieselbe Auflösung wie beim effektiven Status
|
||
(D39). Die Basis-Kanten gehören in Export und Druck (Aussage über den Plan);
|
||
die Hervorhebung nicht (Interaktion). Selbst-Abhängigkeiten zeichnen keine
|
||
Kante — ein Kreis von einem Knoten zu sich selbst sagte nichts.
|
||
|
||
**Nicht durch Tests gedeckt:** `drawDepLinks()` arbeitet wie `alignStems()`
|
||
auf dem DOM; headless getestet sind die `data-id`/`data-deps`-Attribute des
|
||
Renderers, geprüft wurde im Browser (alle drei Modi, Fokus- und
|
||
Cursor-Hervorhebung, Export).
|
||
|
||
**Nachtrag — die Basis-Kanten sind jetzt gepunktet.** „Zurückhaltender“
|
||
(Nutzer): Die durchgezogene Kurve konkurrierte trotz Blässe noch mit dem
|
||
Baum. Punktiert (runde Punkte, `stroke-dasharray: .1 5`) tritt sie weiter
|
||
zurück und ist zugleich **dreifach** von den Baumlinien unterschieden —
|
||
Krümmung, Blässe, Punktierung; durchgezogen gehört „und“, gestrichelt „oder“.
|
||
Der D29-Einwand gegen einen dritten Linienstil greift hier nicht: Er galt den
|
||
**Baum-Abzweigen**, die sich im kompakten Modus allein über den Stil
|
||
unterscheiden müssen — die Querverbindungen liegen auf ihrer eigenen,
|
||
gekrümmten Ebene und begegnen den Rahmenkanten nie. Die **Hervorhebung**
|
||
(fokussierter Knoten) bleibt durchgezogen in Tinte: Sie ist ausdrücklich
|
||
angefordert und soll sich nach vorn drängen. Export identisch nachgezogen.
|
||
|
||
Zweiter Schritt derselben Rückmeldung: Auch die **Pfeilspitze** ist jetzt
|
||
**offen** (ein Winkel aus zwei dünnen grauen Strichen) statt eines gefüllten
|
||
Dreiecks — das Dreieck stach als einziger satter Fleck aus der gepunkteten
|
||
Linie heraus. Die Hervorhebung bekommt denselben Winkel in Tinte.
|
||
|
||
## D42 — Closure-Pfad: erschöpfend über die gekoppelten Gruppen, gierig nur benannt
|
||
Die letzte Baustelle aus D34: Mit Abhängigkeiten zählt der günstigste Pfad
|
||
nicht mehr den gewählten Teilbaum, sondern die **Hülle** — jeder nötige Knoten
|
||
zieht seine Ziele samt Realisierung nach, gemeinsam Gebrauchtes zählt über die
|
||
Mengen-Vereinigung nur einmal. Damit ist die Wahl je Alternativgruppe nicht
|
||
mehr lokal optimal (das D34-Beispiel: `A (S) :#db` schlägt `B (M)`, sobald
|
||
`#db` ohnehin bezahlt wird). Die Entscheidungen:
|
||
|
||
**Verfahren: erschöpfende Suche — aber nur über die gekoppelten Gruppen.**
|
||
D34 stellte „exakt, die Bäume sind klein“ gegen „gierig, aber benannt“;
|
||
gebaut sind beide, mit einer Beobachtung dazwischen, die die exakte Suche
|
||
praktisch immer billig macht: **Nur Gruppen, in deren Teilbäumen
|
||
Abhängigkeiten stehen oder auf deren Knoten welche zeigen, koppeln
|
||
überhaupt.** Alle übrigen wählen weiterhin lokal (kleinste rekursive Kosten,
|
||
Gleichstand ⇒ erste) — ohne Abhängigkeiten gibt es null gekoppelte Gruppen
|
||
und genau eine Auswertung, also exakt das alte Verhalten zum alten Preis.
|
||
Über die gekoppelten Gruppen läuft ein lexikografischer Odometer (frühe
|
||
Gruppen wechseln zuletzt, strikt `<` gewinnt — bei Gleichstand bleibt so die
|
||
erste Alternative, §9). Übersteigt das Produkt der Gruppengrößen das
|
||
Suchlimit (20 000), fällt die Rechnung auf die gierige lokale Wahl zurück
|
||
und **sagt es**: zeilenlose Warnung `cheapApprox` — die in D34 verlangte
|
||
Benennung, statt stillschweigend Optimalität zu suggerieren.
|
||
|
||
**Abhängigkeiten ziehen, was sie brauchen — auch Optionales und nicht
|
||
Gewähltes.** `+` heißt „für das Ganze entbehrlich“, aber wer per `:#…` darauf
|
||
zeigt, braucht es eben doch; und ein Ziel in einer nicht gewählten
|
||
Alternative wird trotzdem realisiert, wenn etwas Nötiges davon abhängt. Im
|
||
Diagramm bleibt so ein einzelner heller Knoten im zurückgetretenen Zweig
|
||
stehen — sichtbar „das hier wird gebraucht, egal wie ihr wählt“. Nur
|
||
**verworfene** Ziele werden nie gezogen (§9: verworfen zählt nie): Sie werden
|
||
nicht realisiert, und dass der Abhängige deshalb nie fertig wird, sagt schon
|
||
der effektive Status (D39) — der Pfad muss die Lüge nicht einpreisen.
|
||
Gezogen wird das Ziel samt **Abwärts**-Realisierung, nicht seine Vorfahren:
|
||
Der Elternknoten braucht sein Kind, nicht umgekehrt.
|
||
|
||
**API: `computeCheapPlan(roots)` → `{set, exact}`.** `computeCheapSet` bleibt
|
||
als Hülle für Tests und Kompatibilität; `render()` liest `exact` für die
|
||
Warnung. `markCheapest` entfällt — die Menge entsteht jetzt in einem
|
||
Worklist-Durchlauf je Belegung (Zyklen enden über die Mengen-Prüfung von
|
||
selbst; erste ID-Vergabe gewinnt, D36/D39).
|
||
|
||
## D43 — `llms.txt`: die Notation für KI-Agenten, unter der Site-Wurzel
|
||
KI-Agenten sollen Werkbaum-Texte lesen **und schreiben** können, ohne die
|
||
deutsche SPEC durchzuarbeiten. Dafür liefert jede Instanz eine englische
|
||
Markdown-Kurzfassung der Notation unter `/llms.txt` aus
|
||
(`https://werkbaum.javagil.de/llms.txt`). Die Entscheidungen:
|
||
|
||
**Der Name folgt der llms.txt-Konvention** (llmstxt.org): eine Markdown-Datei
|
||
an der Site-Wurzel, die die Site für Sprachmodelle beschreibt — genau der
|
||
Zweck, und zunehmend der Ort, an dem Agenten und Werkzeuge von sich aus
|
||
nachsehen. Ein hübscherer Name (`notation.md`) wäre nicht auffindbar. Im
|
||
Footer steht der **Dateiname selbst als Link** — dadurch braucht er keine
|
||
Übersetzung in neun Sprachen; der Tooltip ist knapp zweisprachig (DE · EN),
|
||
wie beim Build-Hinweis (D16: Metainformation, kein Produkt-Feature-Text).
|
||
|
||
**Englisch, obwohl die Doku deutsch ist.** „Doku auf Deutsch" (CLAUDE.md)
|
||
gilt der Projektdokumentation; die Agenten-Fassung ist ein **ausgeliefertes
|
||
Produkt-Artefakt** mit weltweitem Publikum — dieselbe Logik, nach der das
|
||
Beispiel-Dokument englisch ist (D22).
|
||
|
||
**Quelle in `frontend/public/`** — damit erledigt Vite die halbe Arbeit: Der
|
||
Dev-Server liefert `/llms.txt` direkt aus, `vite build` kopiert es nach
|
||
`dist/`, und beide Deploy-Wege (Pages-Workflow und `deploy-prod.sh`, die die
|
||
Site je von Hand zusammenstellen, D16) kopieren es mit einer Zeile weiter.
|
||
Bewusst **nicht** in die eine `index.html` eingebettet: Agenten holen eine
|
||
URL, kein Bundle.
|
||
|
||
**Drift ist das Hauptrisiko** — dieselbe Sorge wie bei einem zweiten Parser
|
||
(D14). Gegenmittel: Die Datei erklärt selbst, dass die SPEC normativ ist, und
|
||
die Hausregel in CLAUDE.md lautet jetzt „SPEC zuerst, dann Code, **dann die
|
||
Agenten-Fassung nachziehen**" (SPEC §13 verweist zurück). Inhaltlich
|
||
beschreibt sie nur die **Notation samt Semantik** (Gates, Status, Ränge,
|
||
Extraktionsreihenfolge, Schreibregeln, ein vollständiges Beispiel) — keine
|
||
Editor-UI: Die braucht ein schreibender Agent nicht, und sie ändert sich
|
||
öfter.
|
||
|
||
**Nachtrag — `llms.md` statt `llms.txt`, Link neben der Versionsnummer.**
|
||
Entschieden vom Nutzer: Die Endung `.md` ist die zum Inhalt ehrliche (es IST
|
||
Markdown; die Konvention nutzt `.txt` nur als kleinsten gemeinsamen Nenner),
|
||
und der Footer-Link rückt zwischen Versionsnummer und Copyright — zur
|
||
Werkzeug-Ecke des Footers statt ans Ende hinter die Rechtstexte. Der
|
||
Dateiname bleibt als Linktext (übersetzungsfrei), die Wurzel-Lage bleibt.
|
||
|
||
## D44 — Falt-Umschalter: „ab M abwärts“ statt einer Tiefenzahl
|
||
Einzeln zu falten (D38) reicht für einen Plan mit 167 Knoten nicht — man will
|
||
den Baum am Stück auf eine Arbeitshöhe bringen. Der Diagramm-Kopf bekommt
|
||
dafür einen **Umschalter** neben „verworfene einblenden“ und „günstigster
|
||
Pfad“: gedrückt ist alles **ab Größe M abwärts** zugeklappt, nicht gedrückt
|
||
ist der ganze Baum offen.
|
||
|
||
**Das Kriterium ist die Größe, nicht die Tiefe.** Die naheliegende Alternative
|
||
wäre „bis Ebene n aufklappen“ gewesen. Die Ebene ist aber eine Eigenschaft der
|
||
Schreibweise, nicht der Sache: Wie tief etwas steht, hängt davon ab, wie fein
|
||
der Autor zerlegt hat, und ein sorgfältig aufgeschlüsselter Zweig verschwände
|
||
bei derselben Zahl früher als ein grob notierter. Die T-Shirt-Größe sagt
|
||
dagegen etwas über die **Arbeit** — und weil `M` die kleinste Größe ist, die
|
||
überhaupt eine Zerlegung verlangt (§5/D8), ist „M und kleiner“ genau die
|
||
Menge, deren Inneres Detail ist. Übrig bleibt der Plan auf der Höhe von `L`
|
||
aufwärts, also dort, wo die großen Brocken stehen.
|
||
|
||
*(Zuerst gebaut war die Schwelle **unter** M — offen blieb dann auch jedes
|
||
`M`. Der Nutzer hat sie auf `M` einschließlich korrigiert: Ein M-Paket ist
|
||
selbst noch die Einheit, die man als Ganzes plant; seine Zerlegung ist schon
|
||
das Innere.)*
|
||
|
||
**Ohne Größenangabe wird nicht zugeklappt.** Der günstigste Pfad wertet eine
|
||
fehlende Größe als `M` (D18) — das wäre hier der falsche Präzedenzfall: Dort
|
||
ist es eine bewusst konservative **Kostenannahme** („mindestens M“), hier wäre
|
||
es eine Aussage über den Willen des Autors. Wer keine Größe angegeben hat, hat
|
||
nichts gesagt; einen Zweig deswegen zu verbergen, behandelte eine Vermutung
|
||
wie eine Angabe. Ein Knoten ohne Größe bleibt also offen — und fällt dadurch
|
||
angenehmerweise auf.
|
||
|
||
**Ein Umschalter, kein Menü.** Zuerst gebaut war ein Aufklapp-Menü mit zwei
|
||
Einträgen („alle aufklappen“ / „zuklappen“); der Nutzer wollte einen
|
||
Umschalter. Das ist die bessere Form, und der Grund ist nicht nur Platz: Die
|
||
beiden Einträge waren nie unabhängige Befehle, sondern die **zwei Stellungen
|
||
einer Sache** — jeder beschreibt einen vollständigen Faltzustand, und
|
||
zusammen decken sie ihn ganz ab. Genau das ist ein Umschalter. Ein Menü
|
||
verlangte zwei Klicks für etwas, das einen braucht, und der Diagramm-Kopf hat
|
||
das Idiom längst zweimal (D18, D4).
|
||
|
||
**Der Zustand wird abgelesen, nicht gemerkt.** Ein gemerktes Flag würde lügen,
|
||
sobald jemand danach einen einzelnen Knoten umklappt. Stattdessen prüft
|
||
`render()` bei jedem Durchlauf, ob **jeder** faltbare Knoten so steht, wie die
|
||
Voreinstellung ihn stellen würde; nur dann ist der Knopf gedrückt. Er springt
|
||
damit von selbst heraus, wenn man von Hand etwas aufklappt, und von selbst
|
||
wieder hinein, wenn man es zurücknimmt (beides nachgemessen). Deshalb wird er
|
||
auch **nicht** in `werkbaum-ui` persistiert: Der Faltzustand steht im Text
|
||
(D38-Nachtrag 2), der Knopf liest ihn nur ab. Sonderfall: Gäbe es gar keinen
|
||
Knoten bis `M`, wären beide Stellungen derselbe Zustand — dann bleibt der
|
||
Knopf heraußen, statt gedrückt dazusitzen, ohne dass etwas zugeklappt ist.
|
||
|
||
**Beide Stellungen beschreiben einen vollständigen Zustand.** Zuklappen klappt
|
||
also alles **andere auf**. Sonst wäre die Wirkung vom Vorzustand abhängig und
|
||
zweimal Drücken ergäbe zweierlei — für einen Umschalter die falsche
|
||
Eigenschaft. Der Preis: Ein von Hand gesetztes `>` an einem großen Knoten wird
|
||
dabei aufgelöst. Vertretbar, weil der Nutzer gerade ausdrücklich eine
|
||
Gesamtansicht angefordert hat, und weil **Rückgängig** den ganzen Vorgang in
|
||
einem Schritt zurücknimmt (nachgemessen: `replaceTextUndoable` schreibt genau
|
||
einmal, Strg+Z stellt Text und Diagramm wieder her).
|
||
|
||
**Umgesetzt über denselben Weg wie das einzelne Umklappen** — die
|
||
Sitzungs-Überlagerungen setzen, dann in den Text schreiben (D38-Nachtrag 2).
|
||
Dafür wanderte der Voll-Rewrite aus `writeFoldToText()` in ein eigenes
|
||
`writeAllFoldMarks(roots, want)`: Für eine Voreinstellung gibt es keinen
|
||
minimalen Patch, sie fasst den ganzen Baum an. Damit gilt auch hier die
|
||
Rückfall-Kette unverändert — ist der Zustand in Marken **nicht ausdrückbar**,
|
||
wird nicht geschrieben und die Überlagerung trägt ihn für die Sitzung.
|
||
|
||
Genau der Fall tritt im mitgelieferten Werkbaum-Plan auf und ist beim Bauen
|
||
nachgemessen worden: In Zeile 103 steht eine Fokusmarke `!!!` unterhalb eines
|
||
Knotens, der zugeklappt werden soll — die holt ihren Knoten immer wieder
|
||
hervor, dieser Markensatz ist also nicht schreibbar. Das Bild stimmt trotzdem
|
||
(die Überlagerung gewinnt in `render()`), der Text bleibt unangetastet. In
|
||
einem Plan ohne `!!!` werden die Marken geschrieben: `- [ ] > Mittel (M)`,
|
||
`- [ ] > Klein (S)` und `- [ ] > Teil 3 (M)`, während `(L)`, `(XL)` und der
|
||
Knoten ohne Größe offen bleiben.
|
||
|
||
## D45 — Update-Prüfung vergleicht gegen den laufenden Build, nicht gegen einen gemerkten Abruf
|
||
Gemeldet: „Oft wird der Hinweis, dass eine neue Version vorliege, oben
|
||
angezeigt, obwohl genau die bereits geladen wurde“ — mit dem Verdacht auf eine
|
||
Race-Condition und der Vermutung, dass es in der Vorschau-Instanz deshalb
|
||
immer wieder auftritt. Beides trifft zu, und es sind **zwei** Fehler, die
|
||
dieselbe Wurzel haben: Die Prüfung verglich nie mit dem Stand, der gerade
|
||
läuft.
|
||
|
||
**Wurzel: ein Relais statt eines Vergleichs.** `checkForUpdates()` holte die
|
||
Seite und verglich ihren Inhalts-Hash mit `werkbaum-html-hash` — dem Hash des
|
||
**zuletzt abgerufenen** Stands. Über den läuft die Aussage „neu“ also
|
||
indirekt: Sie sagt „der Server liefert etwas anderes als beim letzten Abruf“,
|
||
nicht „der Server liefert etwas anderes als das, was du vor dir hast“. Das
|
||
sind verschiedene Aussagen, sobald etwas zwischen Laden und Abruf dazwischen
|
||
kommt — und genau das tut ein CDN. Der Kommentar im Code hielt schon fest,
|
||
dass GitHub Pages je Cache-Knoten abweichende ETags liefert; dasselbe gilt
|
||
zeitlich für den **Inhalt**: Während ein Deploy durchläuft, antworten Knoten
|
||
unterschiedlich, aufeinanderfolgende Abrufe wechseln zwischen alt und neu. Der
|
||
Hash im localStorage wurde dabei bei **jedem** Abruf nachgeführt, jeder Wechsel
|
||
schlug also erneut an — auch wenn der laufende Tab längst den neuen Stand
|
||
hatte. Dazu kommt, dass sich alle Tabs denselben Schlüssel teilen: Zwei
|
||
geöffnete Tabs schreiben abwechselnd ihren Stand hinein und melden einander
|
||
Updates.
|
||
|
||
**Zweiter Fehler, der eigentliche Dauerbrenner: das Flag blieb kleben.**
|
||
`werkbaum-update-available` wurde bei einem Fund gesetzt und **nur** vom Knopf
|
||
„Jetzt laden“ wieder entfernt. Wer statt dessen F5 drückte — oder „Später“, das
|
||
lediglich das Banner-Element entfernte —, behielt das Flag; und beim Laden
|
||
stand:
|
||
|
||
```js
|
||
if(!document.hidden && localStorage.getItem('werkbaum-update-available')){
|
||
checkAndShowUpdateNotification();
|
||
}
|
||
```
|
||
|
||
Also erschien das Banner ausgerechnet auf der Fassung, die es gerade eingespielt
|
||
hatte, und danach bei **jedem** weiteren Laden, bis irgendwann jemand den
|
||
richtigen Knopf traf. Nachgestellt: Flag setzen, neu laden → Banner; „Später“ →
|
||
Flag steht weiter; neu laden → Banner. Endlos.
|
||
|
||
**Entscheidung: die laufende Seite ist der Vergleichsmaßstab, und sie kennt
|
||
sich selbst.** Beide Deploy-Wege spritzen den Commit in den
|
||
Footer-Versionslink (D16, `<a class="ver" href="…/commit/<sha>">`) — die
|
||
laufende Seite trägt ihre Identität also im DOM, die abgerufene im HTML-Text.
|
||
Zwei Werte, die im selben Moment vorliegen; dazwischen kein Speicher, der
|
||
altern könnte. Damit sind alle drei Ursachen weg: kein Relais (CDN-Flattern
|
||
meldet nichts mehr, solange der gelieferte Commit der laufende ist), keine
|
||
Kopplung zwischen Tabs, und nichts, was ein Neuladen überdauert. Nebengewinn:
|
||
Schon die **erste** Prüfung nach dem Laden ist aussagekräftig — der alte Weg
|
||
konnte beim ersten Mal grundsätzlich nichts sagen, weil er erst einen
|
||
Vergleichsstand anlegen musste.
|
||
|
||
**Der Zustand lebt nur noch im Speicher.** `werkbaum-update-available` und
|
||
`werkbaum-html-hash` werden nicht mehr geschrieben (der Reset räumt sie noch
|
||
weg, falls sie aus einer früheren Fassung herumliegen). Beim Laden wird
|
||
grundsätzlich **nichts** gemeldet: Was der Browser gerade geholt hat, *ist* der
|
||
aktuelle Stand, bis eine Prüfung etwas anderes zeigt — und die läuft zwei
|
||
Sekunden später ohnehin. Damit kann die Meldung nicht mehr klemmen: Gilt sie
|
||
noch, kommt sie sofort wieder; gilt sie nicht, bleibt sie weg. „Später“ darf
|
||
deshalb weiterhin nur das Element entfernen.
|
||
|
||
**Die Meldung wird auch wieder eingesammelt.** Sagt eine spätere Prüfung
|
||
„aktuell“, während das Banner steht, verschwinden Banner und Footer-Symbol.
|
||
Das deckt den Rollback ab und den Fall, dass ein einzelner Abruf doch einmal
|
||
gegen einen veralteten Knoten lief.
|
||
|
||
**Rückfall für Builds ohne Marker** (Dev-Server, `file://`, lokales
|
||
`npm run preview`): dort steht im Footer der Platzhalter `…/commit/main`, also
|
||
keine Commit-Kennung. Dann wird weiter der Inhalts-Hash verglichen — aber gegen
|
||
den **ersten Abruf dieser Seiten-Sitzung**, der als Vergleichsstand stehen
|
||
bleibt, statt gegen einen fortlaufend nachgeführten Wert im localStorage. Der
|
||
Preis ist ein blindes Fenster von zwei Sekunden zwischen Laden und Erstprüfung;
|
||
auf dem Dev-Server ist das gleichgültig, weil dort HMR arbeitet.
|
||
|
||
**Nachgemessen** (Dev-Server, Marker zum Prüfen von Hand eingespritzt):
|
||
|
||
| Fall | vorher | nachher |
|
||
|---|---|---|
|
||
| Altes Flag im localStorage, neu laden | Banner, bei jedem Laden erneut | kein Banner, drei Takte „✓ Alles aktuell“ |
|
||
| Gespeicherter Hash ≠ Auslieferung, Seite unverändert | „✅ NEUE VERSION ERKANNT!“ | „✓ Alles aktuell“ |
|
||
| Server liefert anderen Commit | — | „✅ Neuer Build 2222222“, Banner + Footer-Symbol |
|
||
| Danach wieder derselbe Commit | Banner blieb stehen | Banner und Symbol verschwinden |
|
||
| „Später“, dann F5 | Banner sofort wieder da | weg und bleibt weg |
|
||
|
||
## D46 — Der günstigste Pfad zeigt die offene Front: `[x]` kostet nichts mehr
|
||
Der Pfad (D18/D42) rechnete rein aus T-Shirt-Größen — `ownCost()` war
|
||
`SIZE_RANK[size] + 1`, der **Status kam in der Kostenrechnung überhaupt nicht
|
||
vor** (nur `[-]` flog heraus). Zwei Folgen, beide falsch für die Frage, für die
|
||
man auf den Pfad schaut:
|
||
|
||
- Längst Erledigtes wurde voll eingepreist und lag weiter hell auf dem Pfad. Im
|
||
mitgelieferten Werkbaum-Plan zeichnete die Linie damit überwiegend **fertige
|
||
Arbeit** nach: 69 Stationen, fast alle auf `[^]`-Knoten.
|
||
- In einer Alternativgruppe hatte eine **bereits realisierte** Alternative
|
||
keinerlei Kostenvorteil. Steht in einer `=`-Gruppe eine auf `[x]` und daneben
|
||
eine billigere auf `[?]`, empfahl der Pfad die billigere — obwohl die Wahl
|
||
faktisch getroffen und bezahlt ist. Die `xorConflict`-Warnung (D35) meldete
|
||
den Widerspruch bereits, die Pfadrechnung ignorierte ihn.
|
||
|
||
**Entschieden: Erledigtes kostet 0.** Der Pfad beantwortet damit „was ist als
|
||
Nächstes am günstigsten?" statt „was hätte der Plan von vorn gekostet?".
|
||
|
||
**Die Schwelle liegt bei `[x]` fertig** (Nutzer). Begründung: Die Beförderung
|
||
auf `[^]` ist keine Kostenfrage — sie sagt etwas über das Deployment, und das
|
||
tut per D30 ohnehin ein eigener Commit. Wer fertig ist, hat bezahlt.
|
||
|
||
**Angefangenes (`[~]`, `[/]`) zählt weiterhin voll.** Erwogen und verworfen
|
||
waren anteilige Restkosten (etwa 2/3 bzw. 1/3): Die Bruchteile wären erfunden —
|
||
die Größen sind **ordinal, nicht additiv** (ROADMAP, Aufwands-Rollup: `S+S ≠ M`),
|
||
eine Skala für „ein Drittel von L" gibt es nicht. Ebenfalls verworfen: alles ab
|
||
`[~]` als bezahlt zu werten („versunkene Kosten sind versunken"). Das zöge den
|
||
Pfad in jeden angefangenen Zweig, auch wenn dort noch fast alles offen ist —
|
||
und `[~]` heißt laut §4 gerade „Kosten investiert, **Risiko hoch**".
|
||
|
||
**Maßgeblich ist der intrinsische Status, nicht der effektive.** Ein `[x]`, das
|
||
von einer Abhängigkeit zurückgehalten wird, ist effektiv weiter unten (D39) —
|
||
die **Arbeit daran** ist trotzdem getan. Dieselbe Linie, die schon die XOR-Regel
|
||
(D35: „investiert ist investiert") und „Was ist neu?" (D28: `[^]` im Text ist
|
||
die Deploy-Aussage) ziehen. Nebenbei verhindert es doppeltes Zählen: Die
|
||
Abhängigkeit steht mit ihren eigenen Kosten ohnehin selbst auf dem Pfad.
|
||
|
||
**Abgezogen werden nur die eigenen Kosten, nicht der Teilbaum.** Ein `[x]`-Knoten
|
||
mit offenen Kindern bleibt also teuer — was stimmt, und was eine unstimmige
|
||
Stelle im Plan sichtbar lässt, statt sie zuzudecken.
|
||
|
||
**Darstellung: Farbe bleibt, nur Linie und Punkte lassen Erledigtes aus**
|
||
(Nutzer). Erledigte Knoten bleiben `cheap` und behalten ihre volle Statusfarbe —
|
||
grün bzw. blau sagt bereits „hier ist nichts mehr zu tun". Verworfen war,
|
||
sie wie nicht benötigte Knoten auszublassen: Das ist der Kanal für „gehört nicht
|
||
zum Plan" (D18), und ein fertiger Knoten sähe damit aus wie eine verworfene
|
||
Alternative. Ebenfalls verworfen: eine **zweite, schwächere** Abblendstufe — sie
|
||
müsste sich von der Pfad-Inversion unterscheiden lassen, und der Unterschied
|
||
zwischen 32 % und, sagen wir, 65 % Deckkraft ist kein Unterschied in der Art.
|
||
Drei Zustände sind so ohne neuen Farbkanal unterscheidbar: nicht nötig (blass),
|
||
nötig und erledigt (voll, ohne Punkt), nötig und offen (voll, mit Punkt und
|
||
Linie).
|
||
|
||
**Station ist der tiefste noch OFFENE Knoten eines Zweigs.** Die alte Regel
|
||
(„kein Kind liegt auf dem Pfad") reicht dafür nicht: Sind alle Kinder erledigt
|
||
und der Elternknoten nicht, hätte der Zweig gar keine Station, obwohl dort noch
|
||
Arbeit liegt — nämlich seine. `hidesOpenCheap()` fragt deshalb den **Teilbaum**
|
||
statt nur die direkten Kinder. Der eingeklappte Knoten (D38-Nachtrag) erbt
|
||
dieselbe Verschärfung: Er vertritt seinen Teilbaum nur noch, solange darin etwas
|
||
offen ist; ein fertig zusammengefalteter Zweig bekommt keinen Punkt mehr.
|
||
|
||
**Das implizite M-Badge entfällt an erledigten Knoten.** Es macht eine
|
||
**Kostenannahme** sichtbar (D18) — wo keine getroffen wird, gibt es nichts zu
|
||
zeigen. Gilt für Badge und `aria-label` gemeinsam.
|
||
|
||
**Kein neuer Umschalter** (Nutzer). Der vorhandene „günstigster Pfad" ändert
|
||
seine Bedeutung; erwogen war ein zweiter Knopf „offene Front ⇄ Gesamtplan", der
|
||
auch „was kostet der Plan insgesamt?" beantwortet hätte. Verworfen: ein neunter
|
||
Knopf im Diagramm-Kopf plus i18n in neun Sprachen für eine Frage, die man beim
|
||
Planen selten stellt — und die Kopfzeile wurde gerade erst entlastet
|
||
(D17-Nachtrag 5).
|
||
|
||
**Nachgemessen am mitgelieferten Werkbaum-Plan:** 110 Knoten auf dem Pfad
|
||
(unverändert), Stationen **69 → 24**. Die verbleibenden sind exakt die offene
|
||
Front — Ticket-Referenzen, Öffnen/Speichern, Backend-Gerüst samt REST und
|
||
Persistenz, Websocket-Transport, Text-CRDT, die Mermaid-Layout-Teile, das
|
||
IDEA-Plugin. Die Linie wird weiterhin gezogen (zwei Pfade: kräftig hinten,
|
||
abgetönt vorn). Grafikexport und Druck folgen ohne Zusatzcode — beide lesen
|
||
`.node.cheap-leaf` aus dem DOM. 236 Tests grün, davon 14 neue
|
||
(`tests/frontier.test.js`); der Snapshot des kanonischen Beispiels ändert sich
|
||
um genau zwei Knoten (`[x] Zielgruppenanalyse`, `[x] Sitemap` verlieren
|
||
`cheap-leaf`).
|
||
|
||
**Was das für den gestaffelten Pfad aus der ROADMAP bedeutet:** Dessen
|
||
Status-Hälfte ist damit gebaut. Offen bleibt die **Nutzen-Achse** (Ausbaustufen:
|
||
ist eine Gruppe komplett realisiert, zur nächsten per Nutzen gewählten Stufe
|
||
weiterspringen) — die braucht erst ein Nutzen-Attribut und den Aufwands-Rollup.
|
||
|
||
**Nachtrag — „erledigt tritt nicht zurück" galt nur auf dem Pfad; jetzt gilt es
|
||
überall.** Gemeldet als Frage: „Manche Knoten, die schon in Produktion sind,
|
||
werden blau dargestellt und manche grau." Beispiel `#ed.fresh` — grau, außer
|
||
wenn der Cursor darauf steht.
|
||
|
||
Die Ursache war nicht der Status, sondern die Pfad-Inversion: Der Knoten ist
|
||
eine **Zugabe** (`+`), und optionale Knoten liegen per D29 nie auf dem
|
||
günstigsten Pfad — `.cheap-on .node:not(.cheap)` blasste ihn also aus (Deckkraft
|
||
0,32, Sättigung 0,4; Pastellblau `#DBEAF8` liest sich so als Grau). Dass er beim
|
||
Anklicken blau wurde, war die Cursor-Ausnahme aus D25.
|
||
|
||
**Der Widerspruch liegt in diesem Eintrag selbst.** Oben steht „sie auszublassen
|
||
hieße, sie sähen aus wie eine verworfene Alternative" — die Regel griff aber nur
|
||
für erledigte Knoten **auf** dem Pfad, weil sie an `cheapCls()` hing. Durch das
|
||
Raster fielen die fertige Zugabe und die fertige, nicht gewählte Alternative:
|
||
`#ed.fresh` ist gebaut und deployed und sah aus wie etwas, das nie passieren
|
||
wird. Das Argument von oben trägt dort genauso weit; die Regel war zu eng
|
||
gefasst, nicht falsch begründet.
|
||
|
||
**Entschieden (Nutzer): Was `[x]` oder `[^]` trägt, wird nie ausgeblasst.**
|
||
Damit hat die Blässe genau **eine** Bedeutung: „hier ist nichts getan und wird
|
||
auch nichts getan". Vorher hieß sie manchmal auch „ist längst fertig" — und die
|
||
beiden Fälle sind das Gegenteil voneinander.
|
||
|
||
**Umgesetzt als eigene Klasse `done`, nicht über die Statusklassen.**
|
||
`.cheap-on .node:not(.cheap).st-fertig, …st-prod` hätte ohne neue Klasse
|
||
funktioniert, wäre aber **falsch**: `st-…` trägt den **effektiven** Status
|
||
(D39), und ein von Abhängigkeiten zurückgehaltenes `[x]` heißt dort z. B.
|
||
`st-arbeit`. Die Ausnahme muss dem **intrinsischen** Status folgen — dieselbe
|
||
Linie wie bei der XOR-Regel (D35), bei „Was ist neu?" (D28) und bei der
|
||
Kostenrechnung dieses Eintrags: Geleistete Arbeit ist geleistet. `render.js`
|
||
setzt die Klasse deshalb per `isDone(n)`, also aus derselben Funktion, die auch
|
||
die Kosten auf 0 zieht. Gefärbt wird unverändert nach dem effektiven Status —
|
||
ein zurückgehaltenes `[x]` steht jetzt in **voller** Stärke in seiner
|
||
effektiven Farbe, und die Diskrepanz-Marke (D39) erklärt den Unterschied.
|
||
|
||
Es ist die **vierte** Ausnahme von der Inversion, nach `.fresh` (D28),
|
||
`.focusmark` (D32) und `.current` (D25-Nachtrag) — und alle vier sind
|
||
nachgereicht worden, nachdem jemand einen unsichtbaren Knoten gemeldet hat. Das
|
||
ist inzwischen ein Muster und steht als Prüffrage in `frontend/CLAUDE.md`: Wer
|
||
eine neue Aussage an einen Knoten hängt, fragt zuerst, ob sie ausgeblasst noch
|
||
etwas sagt.
|
||
|
||
**Nebenbefund: Der Grafikexport hatte den Fehler nie.** `diagramToSvg()` liest
|
||
`backgroundColor`, nicht `opacity`/`filter` — im exportierten SVG standen diese
|
||
Knoten also immer schon in voller Farbe. Der Bildschirm zieht damit nach, statt
|
||
dass etwas Neues entsteht.
|
||
|
||
**Nachgemessen** am mitgelieferten Werkbaum-Plan (149 sichtbare Knoten, Pfad
|
||
an): Vorher blass und fertig waren genau **drei** Knoten — `#ed.fresh`,
|
||
`#ed.export.print` (beide `+`) und `#ed.closure.greedy` (die nicht gewählte
|
||
Alternative neben `#ed.closure.exact`, das als gewähltes schon voll stand).
|
||
Alle drei jetzt `opacity 1`, `filter none`, Füllung `rgb(219, 234, 248)` — also
|
||
identisch mit den fertigen Knoten auf dem Pfad. Weiterhin blass bleiben **31**
|
||
Knoten, darunter **kein einziger** fertiger. 240 Tests grün, davon 4 neue in
|
||
`tests/frontier.test.js`; drei Snapshots und eine Zusicherung in
|
||
`effective.test.js` (`node root-node held done st-arbeit` — genau der Fall
|
||
„intrinsisch fertig, effektiv zurückgehalten") sind um die Klasse ergänzt.
|
||
|
||
## D47 — Von Station zu Station: ein Knopf, der geht, statt zu schalten
|
||
Mit dem status-bewussten Pfad (D46) zeigt das Diagramm die offene Front — im
|
||
mitgelieferten Plan 24 Stationen, verteilt über einen Baum von über 20 000 px
|
||
Breite. Sie **zu sehen** ist damit gelöst, sie **abzugehen** nicht: Man müsste
|
||
jede von Hand suchen. Der Knopf im Diagramm-Kopf holt sie der Reihe nach in die
|
||
Mitte.
|
||
|
||
**Der einzige Knopf im Diagramm-Kopf, der kein Umschalter ist.** „Verworfene
|
||
einblenden" (D4), „günstigster Pfad" (D18) und die Falt-Voreinstellung (D44)
|
||
beschreiben je einen Zustand; dieser löst eine **Bewegung** aus. Deshalb kein
|
||
`aria-pressed` und keine gedrückte Optik — er sieht aus wie „kopieren" und
|
||
„herunterladen", die ebenfalls etwas *tun*.
|
||
|
||
**Der Sprung fasst nur das Diagramm an** (Nutzer): zentrieren, hervorheben,
|
||
Tastaturfokus — buchstäblich die Behandlung des ausdrücklichen Alt+Klicks
|
||
(D25-Nachtrag 1), inklusive Puls und der Hervorhebung der
|
||
Abhängigkeits-Kanten (D41). Erwogen war, zusätzlich die Schreibmarke auf die
|
||
Zeile zu setzen; das kostete aber den Fokus im Baum (der nächste Druck müsste
|
||
ihn zurückholen) und wäre auf dem Telefon bei jedem Sprung ein Bereichswechsel
|
||
weg vom Diagramm. Der Weg in den Text steht direkt daneben offen: **Alt+Enter**
|
||
am fokussierten Knoten, dieselbe Geste wie überall.
|
||
|
||
**Nach der letzten wieder die erste** (Nutzer). Ein Knopf, der am Ende
|
||
aufhört, wirkt kaputt und bräuchte eine eigene Rückstell-Geste; dass man wieder
|
||
oben ist, sieht man am Baum.
|
||
|
||
**Kein Zähler am Knopf** (Nutzer). Der „Was ist neu?"-Knopf trägt einen, hier
|
||
wäre er ein zehntes Element in einer Zeile, die schon knapp ist — die Zahl der
|
||
offenen Stationen steht stattdessen im **Tooltip** („… (24 offen)"), wo sie
|
||
nichts kostet.
|
||
|
||
**Kein eigener Zustand: fortgesetzt wird am hervorgehobenen Knoten.** Ein
|
||
gemerkter Index wäre die naheliegende Lösung und die schlechtere: Der Baum wird
|
||
bei **jedem Tastendruck** neu gebaut, die Stationsliste ändert sich unter dem
|
||
Index, und er zeigte danach auf etwas anderes. `currentNodeEl` (D25) überlebt
|
||
den Neubau dagegen von selbst, weil es aus der Cursor-Zeile neu abgeleitet
|
||
wird. Liegt der hervorgehobene Knoten nicht auf einer Station — etwa weil
|
||
jemand zwischendurch im Text getippt hat —, beginnt der Gang wieder vorn. Das
|
||
ist kein Notbehelf, sondern die richtige Antwort auf „zeig mir, was als
|
||
Nächstes dran ist".
|
||
|
||
**Verborgen, solange es nichts anzuspringen gibt** — bei ausgeschaltetem Pfad
|
||
(dann gibt es keine Stationen) ebenso wie bei einem durchweg erledigten Plan.
|
||
Dieselbe Zurückhaltung wie beim „Was ist neu?"-Knopf (D28).
|
||
|
||
**Die Kopfzeile hat das nicht mehr getragen — und das war kein Fehler des
|
||
Knopfes.** D17-Nachtrag 5 hatte die Zeile gerade erst auf acht Elemente
|
||
zurechtgemessen und dabei `flex-wrap:nowrap` als Riegel gesetzt, damit aus
|
||
einem stillen Umbruch ein sichtbarer Überlauf wird. Der neunte Knopf hat den
|
||
Riegel prompt ausgelöst — nachgemessen aber anders als erwartet:
|
||
|
||
- Bei **375 px** passte es weiterhin, nachdem die Lücke von 8 auf 6 px und der
|
||
Innenabstand von 10 auf 8 px ging: 345 px Inhalt, 359 px Platz, **14 px
|
||
Luft**, eine Reihe.
|
||
- Bei **320 px** ist es Arithmetik: 345 px Inhalt gegen 304 px Platz. Neun
|
||
fingergroße Ziele passen dort nicht, und unter 29 px zu gehen hat
|
||
D17-Nachtrag 5 ausdrücklich abgelehnt.
|
||
|
||
**Dabei ein älterer, stiller Fehler gefunden:** Bei Platzmangel schrumpfte
|
||
nicht etwa irgendetwas gleichmäßig — der **Modus-Wähler** ist das einzige
|
||
Element ohne feste Größe und wurde auf einen **2-px-Strich** zusammengedrückt,
|
||
während sein Icon 38 px breit darüber hinausragte. Das Bedienelement war
|
||
unbenutzbar und sah aus wie ein Trennstrich. `flex:0 0 auto` an allen Kindern
|
||
der Zeile stellt das ab: Niemand wird mehr zerdrückt.
|
||
|
||
**Darum schiebt die Zeile jetzt, statt zu zerdrücken oder abzuschneiden:**
|
||
`overflow-x:auto` auf dem Kopf (Scrollbalken ausgeblendet — auf Touch-Geräten
|
||
sind sie ohnehin Overlay). Bei 375 px ändert die Regel nichts, weil nichts
|
||
überläuft; bei 320 px bleiben alle neun Elemente in voller Größe erreichbar
|
||
(nachgemessen: 41 px Schiebeweg, Kopfhöhe unverändert 49 px, Modus-Wähler
|
||
wieder 40 px breit). Das ist die ehrliche Fortschreibung der D17-Regel: Der
|
||
Riegel sollte einen unbemerkten Layout-Wechsel verhindern, nicht ein
|
||
Bedienelement opfern.
|
||
|
||
*(Das `overflow-x:auto` war ein Fehler und ist zurückgenommen — es machte aus
|
||
der Kopfzeile einen Scroll-Container und klippte damit die beiden
|
||
Aufklapp-Menüs, die als absolut positionierte Kinder darin hängen. Was an
|
||
seine Stelle tritt und warum es niemandem auffiel: **D50**. `flex:0 0 auto`
|
||
gegen den zerdrückten Modus-Wähler bleibt.)*
|
||
|
||
## D48 — Der Werkbaum-Plan erklärt sich selbst: ID und Beschreibung an jedem Knoten
|
||
Der mitgelieferte Plan (D27) ist zugleich Vorzeigedokument und Projektübersicht
|
||
— und war für Fremde weitgehend stumm. Ein Knoten wie „Stay greedy, but say so"
|
||
oder „Cycles are legal" sagt jemandem, der die DECISIONS nicht gelesen hat,
|
||
nichts. Jetzt trägt **jeder** der 172 Knoten eine **ID** und einen
|
||
**Beschreibungsblock** hinter dem `---`-Trenner.
|
||
|
||
**Warum die Langform und nicht `"`-Zeilen:** Bei 172 Knoten verdreifachte die
|
||
Kurzform die Höhe des Baumteils und machte ihn unlesbar — gerade das, was der
|
||
Plan zeigen soll. Hinter dem Trenner bleibt der Baum so kompakt wie vorher;
|
||
die Erklärung findet man über die ID. Genau der Fall, für den die Langform
|
||
gebaut wurde (D40). Die eine vorhandene `"`-Zeile an „Collaborating" bleibt
|
||
stehen: Sie führt die Kurzform weiterhin vor, und beide Formen zum selben
|
||
Knoten hängen sich planmäßig aneinander (§1).
|
||
|
||
**ID-Schema `#bereich.task`, dritte Stufe nur wo nötig** (Nutzer). Acht
|
||
Bereiche mit kurzen Kürzeln — `not` Notation, `ed` Editor, `bld` Build,
|
||
`be` Backend, `col` Zusammenarbeit, `mmd` Mermaid, `idea` IDEA-Plugin,
|
||
`trk` Tracker; die Wurzel ist `#wb`. Wo ein Teilbaum sonst Kunstwörter
|
||
bräuchte, kommt eine dritte Stufe dazu (`#ed.closure.union`,
|
||
`#mmd.place.horiz`) — vier gibt es nirgends. Vier bereits vergebene IDs sind
|
||
ins Schema gewandert (`#closure` → `#ed.closure`, `#scaffold` →
|
||
`#be.scaffold`, `#docs` → `#be.docs`, `#resolve` → `#trk.resolve`), die fünf
|
||
`:#…`-Verweise darauf mit.
|
||
|
||
**Englisch wie der Plan** (Nutzer). „Doku auf Deutsch" (CLAUDE.md) gilt der
|
||
Projektdokumentation; der Plan ist ein ausgeliefertes Artefakt mit weltweitem
|
||
Publikum, und deutsche Blöcke unter einem englischen Baum läsen sich gebrochen
|
||
— dieselbe Logik wie beim Beispiel-Dokument (D22) und bei `llms.md` (D43).
|
||
|
||
**Ein bis zwei Sätze je Knoten** (Nutzer): was es ist und warum es im Plan
|
||
steht. Ein Satz ohne das Warum verlöre bei den interessanten Knoten gerade die
|
||
Hälfte, die man nicht erraten kann; ein Absatz je Knoten machte den Plan zu
|
||
einer zweiten, veraltenden Fassung von DECISIONS.
|
||
|
||
**Der Preis, gemessen:** Die Datei wächst von 189 auf 902 Zeilen, das Bundle
|
||
von 443 auf 506 kB (gzip 247 → 270 kB). Dafür beantwortet das Diagramm jetzt
|
||
im Tooltip, was ein Knoten bedeutet — und der Screenreader liest es mit
|
||
(`aria-label`).
|
||
|
||
**Nachgemessen:** 172 Knoten, 172 eindeutige IDs, kein Knoten ohne
|
||
Beschreibung, **0 Warnungen**, Pfad weiterhin exakt gerechnet mit 24
|
||
Stationen. Der Cursor in einem Beschreibungsblock wählt den beschriebenen
|
||
Knoten aus (D40-Nachtrag 2) — auf Kopf- wie Textzeile geprüft; der
|
||
`---`-Trenner selbst wählt nichts.
|
||
|
||
## D49 — Das Textfeld bricht nicht mehr um: `wrap="off"` plus waagerechter Balken
|
||
Gewünscht vom Nutzer, und damit die ausdrückliche Umkehrung der D33-Abwägung
|
||
(„Verworfen: `wrap=off` … der Umbruch ist die wichtigere Eigenschaft"). Der
|
||
dort notierte Preis bleibt richtig und wird in Kauf genommen; was dort **nicht**
|
||
bedacht war, ist die andere Seite:
|
||
|
||
**Ein weicher Umbruch zerstört die Einrückung — und die trägt hier die
|
||
Hierarchie.** In dieser Notation ist der linke Rand keine Formsache, sondern
|
||
die Ebene (§2). Bricht eine Zeile um, beginnt ihre Fortsetzung am linken Rand
|
||
und sieht damit aus wie ein Wurzelknoten; wer die Struktur überfliegt, liest
|
||
eine Ebene, die es nicht gibt. Das trifft genau die langen Zeilen, also die mit
|
||
ID, Größe und Kommentar — und seit D48 ist das praktisch jede Zeile des
|
||
mitgelieferten Plans. Waagerechtes Scrollen kostet Bequemlichkeit; der Umbruch
|
||
kostete Lesbarkeit der Struktur.
|
||
|
||
**Umgesetzt am Element, nicht in CSS:** `wrap="off"` am `<textarea>`. Es ist
|
||
die Eigenschaft des Feldes, nicht seiner Gestaltung, und es wirkt zuverlässig
|
||
in allen Engines; `overflow:auto` steht daneben im Stylesheet.
|
||
|
||
**Der Spiegel musste mit.** `syncMirror()` (D25/D33) maß mit `white-space:
|
||
pre-wrap` und **fester Breite** — genau die Kombination, die im Spiegel Zeilen
|
||
umbräche, die im Textfeld ungebrochen stehen. Jede Zeile darunter läge dann zu
|
||
tief, und die Zeilennummern wanderten weg. Der Spiegel ist deshalb jetzt `pre`
|
||
und ohne Breitenvorgabe.
|
||
|
||
**Gemessen wird trotzdem weiter.** Ohne Umbruch wäre `Zeilenhöhe × n` richtig
|
||
und der Spiegel überflüssig — so hat D33 den Fall beschrieben. Behalten wurde
|
||
die Messung dennoch: Die Schriftgröße unterscheidet sich zwischen Telefon und
|
||
Schreibtisch (D17-Nachtrag 2), und die Messung stimmt in beiden Fällen von
|
||
selbst, ohne eine zweite Stelle, die dieselbe Zahl kennen muss.
|
||
|
||
**Der Sprung setzt die waagerechte Verschiebung zurück.** `jumpToLine()`
|
||
markiert die **ganze** Zeile (D25) — der Browser scrollt dann von sich aus an
|
||
deren **Ende**, und man landete am rechten Rand, ohne Einrückung, Zeichen und
|
||
Statusbox zu sehen. Also `scrollLeft = 0` nach dem Markieren: Der Sprung zeigt
|
||
auf eine Zeile, nicht auf ihr Ende.
|
||
|
||
**Der Zeilennummern-Streifen bleibt stehen.** Er verschiebt sich weiterhin nur
|
||
gegen `src.scrollTop`; den waagerechten Balken macht er nicht mit. Das war
|
||
schon so gebaut und ist jetzt das gewünschte Verhalten statt eines
|
||
Nebenprodukts — nachgemessen: linke Kante unverändert, während der Text um
|
||
180 px verschoben ist.
|
||
|
||
**Nachgemessen** am mitgelieferten Plan (903 Zeilen, längste 122 Zeichen):
|
||
|
||
| | Schreibtisch | Telefon (375 px) |
|
||
|---|---|---|
|
||
| Bildzeilen zu logischen Zeilen | 903 : 903 | 903 : 903 |
|
||
| waagerechter Schiebeweg | 211 px | 432 px |
|
||
| sichtbare Zeichen | rund 110 | rund 53 |
|
||
| Abweichung der Zahlen am Dateiende | — | 2 px auf 903 Zeilen |
|
||
|
||
Die zwei Pixel stammen daher, dass `offsetTop` ganzzahlig rundet, die
|
||
Zeilenhöhe aber 17,408 px beträgt; sie sammeln sich nicht auf (jede Zahl wird
|
||
absolut gemessen, nicht fortgeschrieben).
|
||
|
||
**Der D33-Einwand bleibt bestehen, nur nicht mehr entscheidend:** Im schmalen
|
||
Spiegel neben einem eingebetteten Pad (D31) und auf dem Telefon sieht man jetzt
|
||
rund 53 Zeichen und muss schieben. Wer dort viel liest, zieht den Splitter auf
|
||
oder wechselt in die Textansicht. Ein Umschalter dafür wurde nicht gebaut — aus
|
||
demselben Grund wie in D33: ein Bedienelement plus neun Übersetzungen für einen
|
||
Zustand, den kaum jemand umstellen will.
|
||
|
||
## D50 — Kein `overflow` an der Titelzeile: sie trägt die Aufklapp-Menüs
|
||
Gemeldet: „Dokumente aufklappen/auswählen funktioniert in Mobilansicht nicht."
|
||
Eine Regression aus D47, einen Tag alt.
|
||
|
||
**Ursache.** D47 gab der Titelzeile auf Mobil `overflow-x:auto`, damit bei
|
||
320 px alle neun Bedienelemente erreichbar bleiben. Damit wird sie zum
|
||
**Scroll-Container** — und beide Aufklapp-Menüs hängen als absolut
|
||
positionierte Kinder genau darin: `#docMenu` (Dokumente) im Editor-Kopf,
|
||
`.dlmenu` (Download) im Diagramm-Kopf. Beide standen anschließend im
|
||
abgeschnittenen Bereich.
|
||
|
||
**Der Teil, der beim Bauen übersehen wurde, ist eine CSS-Regel:** `overflow-x`
|
||
auf etwas anderes als `visible` zu setzen hebt ein `visible` der **anderen
|
||
Achse** auf `auto`. Gemessen: `overflow-x:auto` ⇒ `overflow-y:auto`. Geklippt
|
||
wurde also nicht seitlich, wo man es beabsichtigt hatte, sondern **nach
|
||
unten** — dorthin, wo die Menüs aufklappen. Deshalb war der Fehler auch nicht
|
||
auf schmale Geräte beschränkt: Ein Scroll-Container klippt, ob er überläuft
|
||
oder nicht, also auf **jeder** Mobilbreite.
|
||
|
||
Nachgestellt bei 375 px: Das Dokumenten-Menü ist 153 px hoch und beginnt 156 px
|
||
unterhalb der Kopfunterkante (`clientHeight` 43, `scrollHeight` 200);
|
||
`elementFromPoint` an seiner Stelle liefert das Textfeld. Das Download-Menü
|
||
ebenso — dort kam der Diagramm-Hintergrund zurück. Aufklappen ging also nicht
|
||
bloß „nicht gut", das Menü war gar nicht da.
|
||
|
||
**Behoben durch Wegnahme, nicht durch einen Umweg.** Erwogen war, die Menüs per
|
||
`position:fixed` aus dem Container zu heben und beim Öffnen aus dem
|
||
Trigger-Rechteck zu positionieren — das hätte beides gerettet, aber JS-Geometrie
|
||
für etwas eingeführt, das CSS bisher allein konnte, samt Nachführen bei jeder
|
||
Größenänderung. Der Preis stand in keinem Verhältnis zum Gewinn: Das `overflow`
|
||
diente **ausschließlich** Breiten unter rund 360 px, kaputt waren die Menüs
|
||
**überall**.
|
||
|
||
**An seine Stelle tritt Umbrechen — aber nur dort, wo es rechnerisch nicht
|
||
passt.** Unter 360 px (`@media (max-width:360px)`) darf die Zeile umbrechen;
|
||
darüber bleibt `nowrap` als Riegel. Das nimmt D17-Nachtrag 5 nicht zurück: Der
|
||
Riegel sollte einen **unbemerkten** Layout-Wechsel verhindern, und ein Umbruch
|
||
bei einer Breite, bei der neun Fingerziele arithmetisch nicht nebeneinander
|
||
passen, ist keine Überraschung, sondern die einzige ehrliche Möglichkeit.
|
||
Nachgemessen: 375 px eine Reihe zu 49 px, alles innerhalb; 320 px zwei Reihen zu
|
||
78 px, alle neun Elemente vollständig sichtbar, Modus-Wähler weiterhin 40 px
|
||
breit (`flex:0 0 auto` aus D47 bleibt und ist unabhängig richtig).
|
||
|
||
**Geprüft ist jetzt die Bedienung, nicht die Geometrie.** Der D47-Nachweis
|
||
bestand aus Breiten und Höhen — und genau darin war der Fehler unsichtbar, weil
|
||
die Kopfzeile ja weiterhin 49 px hoch war und alle Knöpfe an ihrem Platz saßen.
|
||
Gemessen wird deshalb jetzt, ob das Menü nach dem Öffnen an seiner eigenen
|
||
Stelle auch **getroffen** wird (`elementFromPoint` landet auf `.docitem` bzw.
|
||
`.dlmenu`) und ob ein Klick darauf das Dokument wirklich wechselt (Werkbaum →
|
||
Example: Titel, `werkbaum-active` und der neu gebaute Baum). Beides bei 375 px
|
||
und bei 320 px.
|
||
|
||
**Lehre, im selben Geist wie D25 und D17-Nachtrag 4:** Wer einer Leiste
|
||
`overflow` gibt, entscheidet damit über alles, was aus ihr herausragen soll —
|
||
Menüs, Tooltips, Overlays. In `frontend/CLAUDE.md` steht das jetzt bei der
|
||
Kopfzeilen-Stolperfalle.
|
||
|
||
## D51 — Ein neues Dokument beginnt mit seinem Namen
|
||
Bisher legte „+ Neu" das Dokument unter „Unbenannt" an und setzte den Cursor
|
||
ins leere Textfeld. Das Umbenennen war ein eigener, zweiter Gang durchs Menü —
|
||
und wurde entsprechend oft nicht gemacht: Wer drei Pläne führt, hat drei
|
||
„Unbenannt". Jetzt öffnet sich unmittelbar das Inline-Umbenennen (D22) mit
|
||
**ausgewähltem** Vorschlag; tippen ersetzt ihn, Enter bestätigt.
|
||
|
||
**Das Dokument existiert vorher.** Angelegt, gespeichert und aktiviert wird wie
|
||
bisher; nur der Fokus geht ins Namensfeld statt ins Textfeld. Ein **Abbruch
|
||
verwirft nichts** — Esc behält den Vorschlag „Unbenannt", so wie es vorher der
|
||
Normalfall war. Der Gegenentwurf (erst benennen, dann anlegen) hätte ein
|
||
Dokument im Schwebezustand gebraucht und die Frage aufgeworfen, was bei Esc
|
||
passiert; das ist mehr Mechanik für weniger Verlässlichkeit.
|
||
|
||
**Nach dem Benennen geht es im Textfeld weiter.** Anlegen heißt schreiben
|
||
wollen — den Namen zu vergeben ist die erste Hälfte der Geste, nicht ihr Zweck.
|
||
Der Merker `renameIsNew` unterscheidet diesen Fall vom gewöhnlichen Umbenennen
|
||
aus dem Menü, das unverändert bleibt (Menü bleibt offen, Fokus wandert nicht).
|
||
Er wird in `closeDocMenu()` mit zurückgesetzt, sonst trüge ein abgebrochener
|
||
Vorgang seine Sonderbehandlung in den nächsten hinein.
|
||
|
||
Für alle drei Wege aus dem Eingabefeld gilt dasselbe: **Enter**, **Esc** und
|
||
**Fokusverlust** benennen (bzw. behalten den Vorschlag), schließen das Menü und
|
||
setzen den Cursor in den Text. Auf dem Telefon ist das zugleich der Moment, in
|
||
dem die Bildschirmtastatur gebraucht wird — `keyboardOnJump(false)` steht schon
|
||
da, aus demselben Grund (D25: neues, leeres Dokument = tippen ist gemeint).
|
||
|
||
**Nachgemessen:** Nach „+ Neu" trägt das Eingabefeld den Fokus, der Vorschlag
|
||
ist vollständig markiert (`selectionStart` 0 bis Länge). Enter mit „Sprint 15":
|
||
Titelzeile, `werkbaum-docs` und der Fokus im leeren Textfeld stimmen. Esc:
|
||
Vorschlag bleibt, Menü zu, Fokus im Text. Umbenennen eines bestehenden
|
||
Dokuments: Menü bleibt offen, Fokus bleibt, wo er war.
|
||
|
||
**Grenze der Prüfung, benannt:** Der Fokusverlust ließ sich nur als
|
||
**zugestelltes Ereignis** prüfen, nicht als echter Fokuswechsel — ein
|
||
synthetisches `.blur()` löst im Automaten keinen aus. Gegenprobe am
|
||
**unveränderten** Pfad (bestehendes Dokument umbenennen): dort passiert
|
||
ebenfalls nichts, es ist also die Werkzeuggrenze und keine Regression. Dieselbe
|
||
Lehre wie in D25 und D17-Nachtrag 4 — was die Geräteumgebung stellt, beweist
|
||
der Emulator nicht.
|
||
|
||
## D52 — Auf Touch öffnet der einfache Tipp das Knoten-Fenster, nicht den Link
|
||
Die Beschreibungen (D40) lebten im `title` — und ein `title` braucht einen
|
||
Zeiger. D40 hat das als bekannte Grenze notiert („auf Touch-Geräten gibt es
|
||
keine Tooltips — dort bleibt der Text vorerst nur im `aria-label`; ein Pop-up
|
||
kann später ergänzt werden"). Auf dem Telefon war die Beschreibung damit **gar
|
||
nicht** zu sehen, und seit D48 hängt an jedem der 172 Plan-Knoten eine.
|
||
|
||
**Die eine echte Frage war der Link.** Ein Knoten mit URL ist ein `<a>` und
|
||
belegt den einfachen Klick vollständig (§6, D6); ein Tipp kann nicht beides
|
||
tun. Entschieden (Nutzer): **Auf Touch öffnet der Tipp immer das Fenster, und
|
||
der Link steht darin als Knopf.** Damit hat die Geste dort **eine** Bedeutung —
|
||
„ansehen" —, und die Tooltips sind auf **allen** Knoten erreichbar. Preis:
|
||
SPEC §6 bekommt eine Touch-Ausnahme, und der Link kostet einen zweiten Tipp.
|
||
|
||
**Verworfen:**
|
||
- **Nur unverlinkte Knoten** — hätte §6 unangetastet gelassen, aber derselbe
|
||
Tipp hätte je nach Knoten Verschiedenes getan. Genau das hat D25 für diese
|
||
Geste ausdrücklich abgelehnt, und ausgerechnet die verlinkten Knoten (oft die
|
||
interessanten) wären ohne Beschreibung geblieben.
|
||
- **Erster Tipp Fenster, zweiter Tipp Link** — spart den Knopf, führt aber eine
|
||
unsichtbare Zusatzregel ein: Nichts zeigt an, dass ein zweiter Tipp etwas
|
||
anderes tut.
|
||
|
||
**Es hängt an den Touch-Ereignissen, nicht an einer Media Query.** Damit
|
||
verhält sich ein Gerät mit **beidem** (Touch-Notebook) richtig, ohne
|
||
Sonderfall: Der Finger öffnet das Fenster, die Maus öffnet weiterhin den Link
|
||
und zeigt den Tooltip. Nachgemessen am Schreibtisch: Klick auf den verlinkten
|
||
Wurzelknoten öffnet den Link, das Fenster bleibt zu, der `title` steht
|
||
unverändert am Knoten.
|
||
|
||
**Die drei Touch-Gesten unterscheiden sich am vorhandenen Zustand, ohne neues
|
||
Merkerfeld.** Der lange Druck (D25) setzt nach 500 ms `armedEl`, und jedes
|
||
`touchmove` räumt den Timer weg. Bei `touchend` gilt also: `armedEl` gesetzt ⇒
|
||
Sprung; Timer läuft noch ⇒ kurzer Tipp ohne Wischen ⇒ Fenster; beides weg ⇒ es
|
||
wurde gescrollt ⇒ nichts. **Ausgenommen ist das Falt-Zeichen** (D38): Der Tipp
|
||
darauf muss weiter umklappen, und weil das Fenster `preventDefault()` braucht
|
||
(sonst öffnete der Link-Knoten zusätzlich seine URL), hätte es den folgenden
|
||
Klick sonst verschluckt — Falten wäre auf Touch unbedienbar geworden.
|
||
Nachgemessen: Tipp auf das ▾ des Wurzelknotens 149 → 1 Knoten, kein Fenster.
|
||
|
||
**Es ist ein Fenster, kein zweiter Tooltip — also nutzt es, was ein `title`
|
||
nicht kann.** Der Trennstrich zwischen Beschreibung und Kurz-Fakten musste
|
||
dort aus 24 `─` gebaut werden (D40-Nachtrag), weil `title` kein Markup kennt;
|
||
hier ist er eine echte Linie, und ohne Beschreibung entfällt er ganz. Zerlegt
|
||
wird der `title` **an genau diesem Strich** (`TIP_RULE`, jetzt exportiert). Die
|
||
Alternative wäre ein zweites data-Attribut mit derselben Beschreibung gewesen —
|
||
im Werkbaum-Plan rund 20 kB DOM-Text als reine Verdopplung.
|
||
|
||
**Einzelne Zeilenumbrüche werden zu Leerzeichen, Leerzeilen zu Absätzen.** Das
|
||
ist die Lesart von SPEC §1 („Leerzeilen bleiben als Absatztrenner"), und es war
|
||
im Fenster sofort zu sehen: Die Beschreibungen im Plan sind bei ~76 Zeichen
|
||
umgebrochen, und in einem 336 px breiten Fenster stand der Text dadurch
|
||
ausgefranst („…a textual notation for work breakdown / structures, a browser
|
||
editor…"). Der `title` zeigt die harten Umbrüche weiterhin — dort haben wir
|
||
keine Wahl.
|
||
|
||
**Der Sprung-Hinweis nennt hier den langen Druck.** Der Tooltip endet mit
|
||
„Alt+Klick: zur Zeile im Text"; Alt gibt es auf dem Telefon nicht. Neuer
|
||
i18n-Schlüssel `jumpHintTouch` in allen neun Sprachen, plus `tipClose` und
|
||
`tipOpenLink`.
|
||
|
||
**`position:fixed` auf `<body>`, nicht in `#out`.** Ein Kind von `#out` erbte
|
||
dessen CSS-`zoom` (der Fenstertext skalierte mit dem Diagramm) und würde von
|
||
dessen `overflow` beschnitten — die Falle aus D50. Gesetzt wird aus
|
||
`getBoundingClientRect()`, das den Zoom schon enthält; waagerecht an den
|
||
Fensterrand geklemmt, nach oben ausweichend, wenn unten kein Platz ist. Die
|
||
Spitze bleibt über `--tipx` am Knoten, auch wenn geklemmt wurde (nachgemessen:
|
||
0 px Abweichung von der Knotenmitte im Normalfall).
|
||
|
||
**Zu macht es alles, was seine Aussage hinfällig macht:** Tipp daneben, zweiter
|
||
Tipp auf denselben Knoten, Esc, das ×, **Scrollen des Diagramms** (das Fenster
|
||
ist `fixed`, der Knoten wandert — es zeigte danach auf etwas anderes), der
|
||
**Bereichswechsel** auf Mobil (das Ziel ist dann `display:none`), der **Sprung
|
||
in den Text** (er führt weg) und jeder **Neubau** (er ersetzt das Element, an
|
||
dem es hängt). Die letzten drei fehlten in der ersten Fassung und fielen erst
|
||
im Durchspielen auf.
|
||
|
||
**Nachgemessen** (375 × 812, mitgelieferter Werkbaum-Plan): Tipp auf den
|
||
Wurzelknoten öffnet 336 × 220 px vollständig im Bild, mit Beschreibung als
|
||
einem Absatz ohne harte Umbrüche, Fakten `#wb · in Arbeit · Langer Druck: zur
|
||
Zeile im Text` und Knopf „↗ Link öffnen"; der Knoten trägt den Petrol-Ring, die
|
||
URL wird **nicht** geöffnet. Knoten ohne Beschreibung: nur die Faktenzeile,
|
||
39 px, ohne Trennstrich. Zweiter Tipp, Esc, Tipp daneben und eine **echte**
|
||
Wischgeste (`scrollLeft` 0 → 300) schließen; der lange Druck springt und
|
||
schließt mit.
|
||
|
||
**Werkzeuggrenze, wie in D25 und D17-Nachtrag 4:** Synthetische `TouchEvent`s
|
||
beweisen nur die eigene Ereignis-Logik — dass der Tipp den Link unterdrückt,
|
||
dass die drei Gesten auseinandergehalten werden, dass Position und Inhalt
|
||
stimmen. Sie beweisen **nicht**, wie ein echter Finger mit dem 300-ms-Klick,
|
||
dem Doppeltipp-Zoom und der Textauswahl des Systems zusammenspielt. Zwei
|
||
Messungen liefen deshalb auffällig ins Leere und sind hier festgehalten, damit
|
||
sie niemand für Befunde nimmt: Ein programmatisch gesetztes `scrollLeft` löste
|
||
**kein** `scroll`-Ereignis aus (die D33-Falle — geprüft wurde daraufhin mit
|
||
einer echten Wischgeste), und ein synthetischer Tipp trifft einen Knoten auch
|
||
dann, wenn er 8968 px **außerhalb** des Bildes liegt — dort zeigte das Fenster
|
||
folgerichtig auf nichts. Ein Finger kann das nicht.
|
||
|
||
**Nicht im Druck** (`.nodetip` und der `.tipped`-Ring ausgeblendet) und nicht
|
||
im Grafikexport — Bedienung, keine Aussage über den Plan; der Export liest
|
||
ohnehin nur `#out`.
|
||
|
||
## D53 — Tab rückt Zeilen ein, statt die Auswahl zu ersetzen — und zerstört kein Undo mehr
|
||
Zwei gemeldete Fehler, eine Zeile. Der Tab-Handler schrieb:
|
||
|
||
```js
|
||
src.value = value.slice(0, s) + ' ' + value.slice(eEnd);
|
||
```
|
||
|
||
**Fehler 1: Mehrere markierte Zeilen wurden durch zwei Leerzeichen ersetzt.**
|
||
Der Ausdruck schneidet den Bereich zwischen Auswahlanfang und -ende heraus.
|
||
Ohne Auswahl (`s === eEnd`) fügt er nur ein — deshalb ist es nie aufgefallen,
|
||
solange niemand mehrere Zeilen auf einmal einrücken wollte. In einer Notation,
|
||
in der die Einrückung die **Hierarchie** ist (SPEC §2), ist das die
|
||
naheliegendste Geste überhaupt.
|
||
|
||
**Fehler 2: Undo war danach tot.** `src.value = …` löscht die Undo-Historie
|
||
eines Textfelds vollständig — das steht seit D38-Nachtrag 2 im Projekt
|
||
(„nachgemessen: `value =` und `setRangeText` machen Strg+Z wirkungslos"), war
|
||
aber nur für das Zurückschreiben der Faltung beherzigt worden. Hier erneut
|
||
gemessen, mit der alten Zeile nachgestellt: Nach dem Schreiben ändert das
|
||
erste `undo` **nichts** (Text unverändert), das zweite liefert **`false`** —
|
||
der Stapel ist leer. Betroffen ist damit nicht nur das Einrücken selbst,
|
||
sondern **alles davor Getippte**. Das ist die Antwort auf „wann geht Undo
|
||
kaputt": bei **jedem** Tab-Druck, und sonst nirgends im laufenden Bearbeiten.
|
||
Die übrigen drei `src.value =` im Code laden ein **anderes** Dokument
|
||
(Dokumentwechsel, Wiederherstellen, Pad-Abruf) — dorthin gibt es nichts
|
||
zurückzunehmen, dort ist es richtig.
|
||
|
||
**Die neue Regel, bewusst einfach:**
|
||
|
||
- **Ohne Auswahl** zwei Leerzeichen an der Schreibmarke (Tab zählt in dieser
|
||
Notation als zwei, SPEC §2); **Shift+Tab** nimmt der Zeile den Einzug wieder
|
||
und zieht die Schreibmarke um dasselbe Stück mit, damit sie am selben
|
||
Zeichen stehen bleibt.
|
||
- **Mit Auswahl** wird **jede berührte Zeile** ein- bzw. ausgerückt.
|
||
|
||
Erwogen war die Editor-übliche Feinregel „nur bei mehrzeiliger Auswahl
|
||
einrücken, sonst die Auswahl ersetzen" (so macht es VS Code). Verworfen: Wer
|
||
**eine** ganze Zeile markiert und Tab drückt, meint auch dann Einrücken — und
|
||
die einfache Regel hat die bessere Eigenschaft, dass Tab **niemals Text
|
||
löschen kann**. Der Preis ist, dass ein markiertes Wort nicht mehr durch
|
||
Leerzeichen ersetzt wird; in einem Notationseditor ist das kein Verlust.
|
||
|
||
Nach dem Zug ist der **ganze Zeilenblock** ausgewählt, sodass wiederholtes Tab
|
||
weiter einrückt. Endet die Auswahl genau auf einem Zeilenanfang, gehört diese
|
||
Zeile **nicht** mehr dazu — sonst rückte ein Zug bis zum nächsten Zeilenbeginn
|
||
eine Zeile zu viel ein. **Leerzeilen** bekommen keinen Einzug (er wäre
|
||
unsichtbarer Weißraum), und beim Ausrücken fällt wahlweise die
|
||
Zwei-Leerzeichen-Stufe, ein Tabulator oder ein einzelnes Leerzeichen — sonst
|
||
bliebe eine ungerade Einrückung hängen.
|
||
|
||
**Nebenbefund, mitbehoben: Tab war eine Tastenfalle.** Der Handler nahm die
|
||
Taste bedingungslos; wer nur mit der Tastatur arbeitet, kam aus dem Textfeld
|
||
nicht mehr heraus (WCAG 2.1.2 „No Keyboard Trap"). **Esc** hebt sie jetzt für
|
||
den nächsten Tastendruck auf — der übliche Ausweg. Das kollidiert nicht mit
|
||
dem Esc, das das Knoten-Fenster schließt (D52): Das hängt an `document` und
|
||
läuft weiter.
|
||
|
||
**Nachgemessen** an einem Wegwerf-Dokument, mit **echten** Tastendrücken:
|
||
Drei markierte Zeilen, Tab → alle drei von 2 auf 4 Leerzeichen, **nichts
|
||
gelöscht**, Block bleibt ausgewählt, Fokus bleibt im Feld, Diagramm weiter
|
||
4 Knoten. Shift+Tab → zurück auf 2. Undo-Kette: tippen `(XL)`, dann Tab, dann
|
||
zweimal `undo` → erst der Einzug zurück, dann das Getippte; beide Zustände
|
||
zeichengenau wie zuvor.
|
||
|
||
**Werkzeuggrenze, die dabei fast zu einem Fehlschluss geführt hätte:** Ein
|
||
synthetisches `ctrl+z` aus der Automatisierung löst **kein** natives Undo aus
|
||
— der Text blieb stehen, was zunächst wie „Undo weiterhin kaputt" aussah. Im
|
||
selben Moment griff `document.execCommand('undo')` einwandfrei. Geprüft wird
|
||
Undo deshalb über `execCommand('undo')`; das steht jetzt auch in
|
||
`frontend/CLAUDE.md`. Dieselbe Lehre wie D25 (synthetische `TouchEvent`s) und
|
||
D17-Nachtrag 4 (Bildschirmtastatur): Was die Umgebung stellt, stellt der
|
||
Emulator nicht.
|
||
|
||
## D54 — Frühere Stände: alle zehn Minuten, nur bei Änderung, die letzten zwanzig
|
||
Ein Sicherheitsnetz gegen Versehen — und der Anlass war ein echtes: Der
|
||
Tab-Fehler aus D53 konnte eine ganze Auswahl löschen und nahm dabei auch noch
|
||
das Rückgängig mit. Ohne Netz war der Text dann weg.
|
||
|
||
**Aufbewahrt werden die letzten 20 je Dokument** (Nutzer-Entscheidung), also
|
||
rund 3½ Stunden bei gleichmäßigen Abständen und höchstens ~800 kB beim größten
|
||
Dokument. Erwogen und verworfen: **ausgedünnt** (alle der letzten Stunde,
|
||
stündlich für einen Tag, täglich für eine Woche — reicht weiter zurück, kostet
|
||
aber eine Ausdünn-Regel, die man beim Lesen erst verstehen muss) und
|
||
**lückenlos 24 Stunden** (bis zu 144 Stände, beim großen Plan ~5,8 MB und damit
|
||
über dem localStorage-Limit — es bräuchte doch wieder eine Notbremse). Der
|
||
Anspruch ist bewusst klein: Wer weiter zurück will, hat Git.
|
||
|
||
**„Nur bei Änderung" heißt: gegen den letzten Stand, nicht gegen das Laden.**
|
||
Gibt es noch keinen Stand, wird gegen den Text beim Aktivieren des Dokuments
|
||
verglichen (`snapBase`). Ohne das legte der erste Takt nach dem Öffnen auch ein
|
||
**unverändertes** Dokument weg — beim Herumklicken durch mehrere Dokumente
|
||
sammelte sich so Ballast, den niemand erzeugt hat. Nachgemessen: Ohne Eingabe
|
||
entsteht der Schlüssel im localStorage gar nicht erst.
|
||
|
||
**Die Dokumente sind wichtiger als ihre Stände.** Beide teilen sich den
|
||
localStorage. Läuft er über, wirft `persistSnaps()` deshalb so lange den
|
||
jeweils ältesten Stand weg, bis es passt, notfalls alle — statt eine Ausnahme
|
||
hochzureichen und damit womöglich das Speichern der **Dokumente** zu
|
||
gefährden. Ein gelöschtes Dokument nimmt seine Stände mit.
|
||
|
||
**Zurückgeholt wird undo-fähig** (`replaceTextUndoable`, D53): ein Griff
|
||
daneben kostet ein Strg+Z, keine Rückfrage — dieselbe Haltung wie beim Falten
|
||
(D38-Nachtrag 2). Vorher wird der **aktuelle** Stand weggelegt, falls er noch
|
||
nicht drin ist; sonst wäre ausgerechnet er das Einzige, was das Zurückholen
|
||
verlöre.
|
||
|
||
**Pad-Dokumente (D31) bleiben außen vor.** Ihr Textfeld ist schreibgeschützt —
|
||
ein alter Stand ließe sich dort gar nicht einsetzen, und Stände zu sammeln,
|
||
die niemand laden kann, wäre nur Ballast. Der Knopf ist dort verborgen
|
||
(`src.readOnly` bewacht Sammeln, Laden und Sichtbarkeit).
|
||
|
||
**Platzierung: rechts neben dem Dokumenten-Wähler** (Nutzer-Vorgabe), beide in
|
||
einer Gruppe, die das `margin-right:auto` trägt. Der Positionsbezug des Menüs
|
||
ist der **Knopf**, nicht die Gruppe: An der Gruppe ausgerichtet (`right:0`)
|
||
begann es 190 px weiter links und lief aus dem Panel heraus — in der ersten
|
||
Fassung gebaut, im Bild sofort zu sehen und nachgemessen (linke Kante bei
|
||
−65 px). Jetzt `position:relative` an einer Hülle um den Knopf und `left:0` am
|
||
Menü: linke Kante bei 99 px, gleichauf mit dem Knopf, ganz im Panel.
|
||
|
||
Jeder Eintrag nennt **Uhrzeit und Zeilenzahl** — die Zeilenzahl sagt auf einen
|
||
Blick, welchen der ähnlich benannten Stände man greift. Neueste zuoberst,
|
||
danach sucht man zuerst.
|
||
|
||
**Nachgemessen** (Takt für die Prüfung auf 2 s verkürzt, danach zurückgestellt
|
||
und geprüft): ohne Eingabe kein Eintrag; nach einer Eingabe genau einer; ohne
|
||
weitere Eingabe kommt keiner dazu; nach 29 Änderungen sind es **20** (gedeckelt,
|
||
Zeitstempel aufsteigend, der älteste ist der kleinste Text). Menü: 20 Einträge,
|
||
neueste zuoberst. Klick auf einen älteren Eintrag lädt ihn (36 → 32 Zeilen),
|
||
schließt das Menü, das Diagramm zeichnet neu (30 Knoten) — und ein `undo` holt
|
||
den vorherigen Stand zeichengenau zurück. Der **Pad-Fall** ist durch die
|
||
`readOnly`-Wächter im Code abgedeckt, aber nicht live durchgespielt (er
|
||
bräuchte ein echtes Pad).
|