Die Legende wurde abgeschnitten statt zu scrollen — obwohl `.hint` seit jeher `flex:1 1 auto; min-height:0; overflow:auto` trägt. Ursache: Chrome legt den Inhalt eines <details> in das Pseudo-Element `::details-content`. Damit war `.hint` kein Flex-Kind von `.agenda` mehr, die Begrenzung griff nicht und der Kasten wuchs auf seine Inhaltshöhe (gemessen 585 px in einem 282 px hohen Container) und wurde geclippt. Eine Regel für `::details-content` wirkte im Test, ist aber Chrome-eigen — Firefox und Safari kennen das Pseudo-Element nicht. - Die Legende ist deshalb kein <details> mehr, sondern `div.agenda` + `button.agenda-summary`; der Zustand hängt an der Klasse `open` (`aria-expanded` am Button, `#legendBtn` und Splitter-Sichtbarkeit über `setAgendaOpen()` synchron). `.hint` ist wieder echtes Flex-Kind und scrollt in allen Browsern. - Neuer Splitter `#hintGutter` im selben Idiom wie der große (Pointer-Capture, Doppelklick setzt zurück): horizontal nebeneinander -> Breite `--hcol`, gestapelt (side/mobil) -> Höhe `--hrow`. Beide getrennt gehalten und in `werkbaum-ui` persistiert (samt Auf-/Zu-Zustand), damit ein Moduswechsel die jeweils andere Aufteilung nicht zerstört. Grenzen 90 px bis 85 %. - Die 85-%-Obergrenze steht zusätzlich als max-width/max-height im CSS: die gespeicherte Größe ist ein fester px-Wert und würde den Editor sonst auf 0 drücken, wenn das Panel später schrumpft (großer Splitter, Drehung, Bildschirmtastatur). Verifiziert mit --hrow:9999px -> gekappt bei 85 %. - Nebenbei: der große Splitter war als einziges Bedienelement nicht lokalisiert (fest deutsche title/aria-label). Beide teilen sich nun `gutterTooltip`, dazu `gutterAria`/`hintGutterAria` — 9 Sprachen. Verifiziert im Browser: Legende scrollt (247 px Fenster, 607 px Inhalt); Ziehen in beiden Ausrichtungen inkl. Unter-/Obergrenze und Doppelklick-Reset; `--hcol` und `--hrow` überleben den Moduswechsel; nach Neuladen sind Auf-Zustand, Breite und beide Variablen wiederhergestellt; auf Mobil ist die Summary-Zeile aus und der Kopf-Knopf steuert. Vitest 37/37. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
12 KiB
Werkbaum · Frontend
Editor: Text links, Diagramm rechts, Toggles für transponierte Ansicht und
verworfene Elemente. Quelle sind ES-Module unter src/; index.html ist der
Vite-Entry (lädt im Dev-Server src/app.js als <script type="module">).
Build & Entwicklung (D19)
- Vite ist Bündler + Testrunner (nur Dev-Abhängigkeiten, keine Laufzeit- Abhängigkeit — das Ergebnis ist framework-freies HTML/CSS/JS).
npm --prefix frontend run dev— Dev-Server (Port 8137,.claude/launch.json). Direktes Öffnen vonfrontend/index.htmlperfile://funktioniert nicht mehr (ES-importbraucht http); stattdessen Dev-Server oder die gebaute Datei.npm --prefix frontend run build—vite-plugin-singlefileinlint JS + CSS + Favicon (alsdata:-URI, viatransformIndexHtml-Plugin invite.config.js) in eine self-containeddist/index.html— die bleibtfile://-tauglich (D16) und ist die Deploy-Artefakt-Quelle (Pages-Workflow, siehe README).npm --prefix frontend test— Vitest (tests/**/*.test.js).npm --prefix frontend run build:prod— Produktions-Build ohne den Build-Hinweis hinter dem Titel (Vite-Modusprod,.env.prodsetztVITE_BUILD_BADGE=none). Nur die echte produktive Installation nutzt diesen Weg; Dev-Server (🔧) und Default-build(🚧, u. a. Pages-Deploy) zeigen den Hinweis. Logik:mountBuildBadge()inapp.js(D16).node_modules/unddist/sind ge-.gitignore-t;.env.produndpackage-lock.jsonsind eingecheckt (der Workflow nutztnpm ci).
Konventionen
- Vanilla HTML/CSS/JS, ES-Module; keine Frameworks. Testwerkzeug: Vitest.
- Parser und Renderer müssen headless (ohne Editor-UI) nutzbar bleiben —
Basis für SVG-Export und Mermaid-Plugin (docs/ROADMAP.md).
app.jsist der DOM-/UI-Einstieg; reine Logik gehört inparser.js/model.js/render.js. - Design: Farben/Typografie beibehalten (CSS-Variablen, IBM Plex); Statusfarben sind in SPEC §4 normiert. Marke nach ../brand/BRAND.md; Pastelltöne nie im Logo.
- IBM Plex ist lokal eingebettet (
src/fonts/*.woff2,@font-faceinstyle.css), nicht von Google Fonts geladen — kein externer Request, keine IP an Dritte (Datenschutz, D20). Keinengoogleapis-<link>wieder einführen. Neue Schnitte:woff2pernpm pack @fontsource/…beziehen (keine Projekt-Abhängigkeit, Dateien einchecken) und@font-faceergänzen.
Stolperfallen
- Abzweig-Linien zielen auf die Knotenmitte (fester 23-px-Offset,
line-height: 1.3), nicht auf die Mitte des Teilbaums — bei Layout- Änderungen alle drei Modi (horizontal/vertikal/kompakt) prüfen. Vertikal + kompakt teilen die transponierte Basis-CSS; nur vertikal bekommt den Rechts-Ausgang für „all of“, kompakt führt auch „all of“ nach unten. Any-of ist in allen Modi grau: Linien gestrichelt grau und Alternative-Rahmen grau (Basis-CSSul.or). Kein Petrol im Diagramm mehr;var(--or)nur noch für UI-Akzente/Logo (SPEC §9, D15). - Extraktionsreihenfolge im Parser nicht umstellen: Kommentar → Zeichen/
Status → URL → Größe → Tags (sonst kollidiert
@in URLs). - Fehlertoleranz (SPEC §4): der Parser erfasst die Statusbox als beliebiges
Einzelzeichen
\[([^\]])\]und validiert gegenSTATUS_BY_CODE; unbekannte Codes →parse().warningsals{type:'unknownStatus', line, code}, Knoten neutral.render()in app.js führt Parser- und Renderer-Warnungen zusammen (nach Zeile sortiert) und zeigt sie viaformatWarning(warnings.js). Neue Warnungstypen dort + i18n-Key in allen 9 Sprachen ergänzen. - Modulteilung (D19):
parser.js(Text→Baum, headless),model.js(Baum-/ Kostenlogik:gateOf,needsBreakdown,visibleChildren(n, showDiscarded),computeCheapSet,cheapCls),render.js(HTML-String viarenderTreeHtml(roots, {t, showDiscarded, cheapPath, cheapSet}), headless),app.js(DOM/Events/i18n/Persistenz/Export). Modell/Renderer bekommen UI-State (verworfene einblenden, Pfad an/aus) als Parameter — keine Globals; nurcheapPathOnlebt als UI-State inapp.js. Tests:tests/*.test.js. - Günstigster Pfad:
markCheapest()/cheapestCost()(inmodel.js) markieren die nötigen Knoten (Klassencheap,cheap-leaf);drawCheapPath()(app.js) zeichnet nach jedemrender()und nachapplyLayout()zwei Overlay-SVGs in#out(hinten kräftige Linie, vorne abgetönte Kopie + Stationspunkte). Overlays erben den CSS-zoomvon#out, Punkte in unskalierte#out-Koordinaten umrechnen (/zoom).diagramToSvg()zeichnet dieselbe Linie/Punkte nach (SPEC §9, D18). - Zerlegt eine any-of-Alternative selbst all-of, wird der Teilbaum nur
horizontal schmal transponiert (
ul.or>li.has-and>ul.and, siehe D18) — sonst schiebt der breite Fächer den Elternbaum nach rechts. Bei Layout- Umbauten dieses Nesting mitprüfen. - „verworfen" ist per Default ausgeblendet; Filterlogik steckt in
visibleChildren()und muss bei Renderer-Umbauten erhalten bleiben. - Barrierefreiheit (SPEC §9):
render.jsbaut je Knoten einen sprechendenaria-label(Label + Status + Aufwand + Zuständige + Link, lokalisiert viat); die visuellen Badges (Größe, Tags, ↗) sindaria-hidden. Neue Knoten-Eigenschaften dort innodeAria()mitpflegen und dafür a11y-i18n-Keys (a11y*) in allen 9 Sprachen anlegen. Knoten sindtabindex="0"(Fokus = Lesereihenfolge),#warnist eine Live-Region. - Zustand wird im
localStoragegehalten (noch kein Backend):werkbaum-lang(Sprache),werkbaum-docs(JSON-Array der Dokumente[{id,name,text}]),werkbaum-active(id des aktiven Dokuments),werkbaum-src(Spiegel des aktiven Texts, Abwärtskompatibilität),werkbaum-ui(JSON: Modus, verworfene, günstigster Pfad, Split-Zustand inkl.--col/--drow, Zoom, Vollbild). Neue GUI-Einstellungen insaveUI()/restoreState()mitführen;saveUIliefert währendrestoring===truenichts, damit das Wiederherstellen nicht sofort zurückschreibt. - Dokumente (D22): mehrere umschaltbare Notationstexte.
loadDocs()migriert bei fehlendemwerkbaum-docsden bestehendenwerkbaum-src(oderINITIAL) in ein Dokument;initDocs()(Aufruf nachapplyLang) holt den aktiven Text in den Editor.saveSrc()schreibt den Editortext ins aktive Dokument. Der Wähler ist ein Dropdown in der Editor-Titelzeile (#docTrigger/#docMenu, ersetzt die frühere feste „Struktur (Text)"-Beschriftung); Wechseln/Neu/ Umbenennen/Löschen inswitchDoc/newDoc/renameDoc/deleteDoc. Jedes Dokument ist nur Text + Name (kein Strukturformat, D14) — vorwärtskompatibel zum Backend (D13). Ansichts-State (werkbaum-ui) bleibt global über alle Dokumente. Ein leerer Editortext bleibt leer. - Beispiel-Dokument (D22): reservierte id
EXAMPLE_ID = 'example', fester englischer NameEXAMPLE_NAME = 'Example'(nicht lokalisiert).loadDocs()adoptiert einen Alt-Zustand (zufällige id, „Beispiel") nur, wenn dessentext === INITIAL(nie echte Nutzerinhalte).resetToDefaults()setzt nur das Beispiel-Dokument (idexample) aufINITIAL/„Example" zurück und verwirftwerkbaum-ui/werkbaum-lang/Update-Flags — andere Dokumente bleiben stehen (nicht mehr pauschal allewerkbaum-*löschen!). Das letzte gelöschte Dokument wird als Beispiel neu gesät. - Umbenennen ist inline (kein
window.prompt— in manchen Browser-Kontexten unterdrückt):renameDoc()setztrenamingId,renderDocMenu()rendert dann ein<input class="docrename">(Enter =commitRename, Esc =cancelRename, Blur = commit). Doc-Namen sind Nutzerdaten und werden nicht übersetzt. ?sourceUrl=(D23):loadFromSourceUrl()(Aufruf am Ende des Starts, async) holt eine externe Textdatei und führt sie als Dokument mitid: 'url:<href>',name/source= URL. Nurhttp(s),credentials:'omit'; bei jedem Laden wird neu geholt (URL = Quelle der Wahrheit, lokale Edits daran gehen verloren). Fehler landen als zeilenlose Warnung{type:'sourceLoad', url, error}insourceWarning— ein persistenter Kanal, denrender()jedem Warnungs-Satz voranstellt (überlebt Neu-Renderings, bis das Laden gelingt). Hauptstolperfalle ist CORS: fremde Hosts brauchenAccess-Control-Allow-Origin(raw.github… ja, beliebiger Webserver oft nicht) — der Warntext nennt das ausdrücklich.updateDocName()setzt für solche Dokumente den vollen URL-Tooltip und muss deshalb nach demdata-i18n-title-Durchlauf inapplyLang()laufen. Weder Endung nochContent-Typewerden geprüft (response.text()); die Endung.werkbaumist reine Konvention (D24, SPEC §12). Beispieldateien zum Ausprobieren:docs/examples/*.werkbaum(nacheinander geöffnet ergeben sie mehrere Dokumente im Wähler).- Sprung Diagramm ↔ Text (D25):
render.jsschreibt die Parser-Zeilennummer alsdata-linean jeden Knoten (Geister-Knoten bekommen keine).jumpToLine()inapp.jsklappt bei Bedarf das Editor-Panel auf (revealEditor()), markiert die ganze Zeile und scrollt über einen Spiegel-div(offsetTopInEditor()) — Zeilenhöhe × n scheitert an weichen Umbrüchen. Ausgelöst per Alt+Klick, Alt+Enter und langem Druck; der Klick-Handler musspreventDefault()rufen, sonst lädt Alt+Klick auf einen Link-Knoten das Ziel herunter. Gegenrichtung:syncCaret()setzt die Klassecurrentauf den Knoten der Cursor-Zeile;render()stellt sie nach jedem Neubau wieder her (ohne zu scrollen). Die CSS-Regel braucht den#out-Präfix (ul.or .node{box-shadow:none}ist spezifischer). Auffindbarkeit:setAltMode()setzt bei gedrückter Alt-Taste die Klassealtan#out(Cursor + Ring am Knoten unter dem Zeiger) — derblur-Handler ist Pflicht, sonst bleibt der Modus nach Alt+Tab hängen; dazujumpHintim Knoten-Tooltip undhint_jumpals letzte Zeile der Legende. - Touch-Langdruck (D25): Der Timer (500 ms) setzt nur die Klasse
armed(Petrol-Ring);jumpToLine()läuft imtouchend-Handler. Nicht zurück in den Timer verlegen —focus()aus einem Timer gilt in mobilen Browsern nicht als Nutzergeste, das Textfeld verliert den Fokus sofort wieder (Symptom: die Markierung flackert nur auf). Gegen die native Langdruck-Auswahl braucht es alle drei Schichten:contextmenu-preventDefaultwährend des Drucks,user-select:noneper@media (hover:none) and (pointer:coarse)und-webkit-touch-callout:none(nur iOS). SynthetischeTouchEvents prüfen davon nichts — nur die eigene Ereignis-Logik. - Bildschirmtastatur (D25):
jumpToLine()setzt vor dem Fokusinputmode="none"(keyboardOnJump(true)) — der Sprung ist „hinschauen". Derpointerdown- Handler am Textfeld hebt das wieder auf (läuft vor dem Fokus). Wer sonst irgendwosrc.focus()ergänzt und dabei Tippen meint, muss vorherkeyboardOnJump(false)rufen — so wienewDoc(). - Legende (D26): kein
<details>— Chrome legt Details-Inhalt in::details-content, dadurch war.hintkein Flex-Kind mehr und ließ sich nicht begrenzen (Inhalt geclippt statt scrollbar). Jetztdiv.agenda+button.agenda-summary, Zustand an der Klasseopen(setAgendaOpen()hält Klasse,aria-expanded,#legendBtnund die Sichtbarkeit des Splitters zusammen). Wer hier wieder ein natives Aufklapp-Element einsetzt, bricht das Scrollen. Der Splitter#hintGutterschreibt--hcol(nebeneinander) bzw.--hrow(gestapelt:sideoder mobil) an#app; beide werden getrennt inwerkbaum-uigesichert. Die 85-%-Obergrenze steht zusätzlich alsmax-width/max-heightim CSS — die gespeicherte px-Größe würde den Editor sonst erdrücken, wenn das Panel später schrumpft. - Kleiner Bildschirm:
body.mobile(permatchMedia, ≤ 640 px) stapelt Diagramm/Editor mit stufenlosem Splitter (kein Snap/Collapse wie auf Desktop): der Gutter-Drag ruftsetMobileDrow()(klemmt--drowzwischen den Kopfhöhen--pmin-d/--pmin-e, persyncPanelMins()gemessen), ein Tipp auf eine Titelzeile maximiert das Panel.applyLayoutruft auf Mobil keinapplySplit(das würde--drowlöschen). Dazu eigener Legenden-Umschalter (#legendBtn), schlanke Sprachwahl, Download-Overlay; Default Vollbild + Diagramm maximiert. Layout-CSS hängt anbody.mobile, nicht an einer eigenen@media-Regel — beide Seiten müssen denselben 640-px-Schwellwert nutzen (SPEC §9, D17).