D38 hielt fest, ein eingeklappter Zweig dürfe Pfadknoten verbergen, „die Inversion an den sichtbaren Knoten bleibt richtig". Zu kurz gedacht: Die LINIE blieb eben nicht richtig. Sie fädelt durch die cheap-leaf-Knoten im DOM; ein eingeklappter Pfadknoten ist zwar `cheap`, aber kein Blatt (seine Pfad-Kinder existieren im Modell) — also bekam er keine Station, und die Linie umging den ganzen Zweig. Im Beispiel nachgemessen: „Concept" war `node cheap folded`, verbarg zwei Pfadknoten, trug keinen Punkt; die Linie begann erst bei „PWA". Das Bild behauptete damit, in diesem Zweig sei nichts zu tun — eine Aussage über den Plan, nicht über die Ansicht. Genau die Grenze, die D38 sonst zieht. Regel: Eingeklappt überspringt cheapCls() die Blatt-Prüfung und fragt den Teilbaum (hidesCheap). Liegt darin etwas auf dem Pfad, ist der Knoten dessen tiefste noch sichtbare Station. Beim Aufklappen geben die Kinder sie zurück — gemessen 6 -> 7 -> 6 Stationen beim Hin- und Herklappen. Auch ohne eigene Pfad-Mitgliedschaft: Ein per :#… gezogenes Ziel kann unter einem +-Knoten liegen, der selbst nicht gebraucht wird (D42). Eingeklappt ist dieser der einzige sichtbare Griff auf nötige Arbeit — er wird Station und zählt als cheap, tritt also nicht zurück. Ihn auszublassen wäre die Lüge. 4 neue Tests (208 gesamt). SPEC §9, D38 (Nachtrag) und frontend/CLAUDE.md nachgezogen; die alte „hinnehmbar"-Stelle in D38 ist als falsch markiert statt gelöscht. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
158 KiB
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 — keinesed-Injektion mehr nötig. - Produktions-Build
npm run build:prod(Vite-Modusprod,frontend/.env.prodsetztVITE_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
TouchEvents 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.)
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: hieltefile://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
#outdie Klassealt; alle Knoten bekommencursor:aliasund 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 amblurdes Fensters zurückgesetzt werden — bei Alt+Tab kommt keinkeyupmehr 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-opmit 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 TouchEvents 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 Knotenkanten 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.
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.
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:2am 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)setztopacity:.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. @optionalals Personen-Tag — missbraucht §7 für etwas Strukturelles.#optionalals 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] fertigals Anschauungsmaterial für die Notation (frontend/index.html,chip('fertig','[x]')inapp.js). Daraus würde[^] fertig— Unsinn, den beim Durchsehen eines Diffs niemand bemerkt. - Das mitgelieferte „Example"-Dokument (
INITIALinapp.js) und die übrigendocs/examples/*.werkbaumsind 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: *undContent-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:
- Die URL, die ein Mensch in der Hand hat, ist die Pad-URL — die aus der
Adresszeile.
/export/txtist eine Implementierungseinzelheit und gehört nicht in die Schnittstelle; Werkbaum hängt sie selbst an. - Der Parameter lizenziert anderes Verhalten.
sourceUrlheißt „statische Datei, einmal pro Laden geholt" — das ist D23 wörtlich und bleibt unangetastet.etherpadheiß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. - 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: DiesourceLoad-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/--prowgetrennt 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()holtlocation.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()undshowUpdateDebug()hängen ausschließlichposition: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), einload-Zähler am Rahmen bleibt bei 0 (kein Rahmen-Reload), Sichtbarkeit durchgehendvisible.
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
clientVarsund 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=websocketscheitert mit Code 1006 ohneopen. 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.
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#tagblockiert — die ältere Reservierung ist die, die weichen oder sich fügen muss.xfür XOR kollidiert nicht beim Parsen (das Zerlegungszeichen steht vor der Statusbox), aberx [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) :#dbundB (M). Für sich genommen gewinntB, sobald#dbmehr alsSkostet. Wird#dbaber ohnehin von einem erforderlichen Knoten anderswo im Baum gebraucht, ist es bezahlt — die zusätzlichen Kosten vonAsind dann nurS, undAgewinnt.
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 diemixedGate-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 dauerhaftex [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 wiex, 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.
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.
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 — 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.
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.