Files
werkbaum/frontend/CLAUDE.md
T
mhoennigandClaude Opus 4.8 091e8849d8 frontend: optionale Abzweige gestrichelt + Stiel trifft die Knotenmitte
Zwei Nachbesserungen aus dem ersten Blick auf `+` (D29).

**Gestrichelter Abzweig.** Der hohle Kreis allein war zu leise. Der Einwand
gegen einen dritten Linienstil bleibt richtig, greift aber nicht so weit wie
gedacht: Gestrichelt wird NUR der Abzweig zum Knoten (nicht die Sammelleiste —
die gehört der ganzen Gruppe) und zwar in TINTE, während die any-of-Linien
gestrichelt in Grau sind. Entscheidend ist, dass beide sich nie am selben
Verteiler treffen können: | darf nach SPEC §3 nicht mit -/+ gemischt werden.
Der Kreis bleibt zusätzlich — er sagt, WELCHER Knoten gemeint ist.

Umgesetzt an den vorhandenen Pseudoelementen, ohne neue Zeichenebene: im
Fächer der senkrechte Stiel (border-left von ::after, beim letzten Kind
border-right von ::before), gestapelt 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. Der SVG-Export zieht mit (dash || isOpt).

**Stiel trifft die Knotenmitte auch waagerecht (`--stem-x`).** Dabei fiel ein
älterer Fehler auf, gemeldet als „die Linie zu Wahl trifft den Knoten nicht":
Im horizontalen Fächer lief der Stiel zu einem Knoten mit any-of-Kindern neben
dem Knoten vorbei (gemessen 13,4 px). Dieselbe Verwechslung, die D10 senkrecht
schon behoben hat, nur in der anderen Achse — der Stiel saß bei 50 % der ZELLE,
und das ist nur die Knotenmitte, solange der Knoten darin zentriert steht.
`li.has-or` ist aber 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 nicht lösbar: gebraucht wird die Knotenbreite, und kein Selektor
macht sie einer Elternregel zugänglich (Anchor Positioning ist Chrome-only).
`alignStems()` misst deshalb nach jedem render() und in applyLayout() die
Knotenmitte der betroffenen Zellen und setzt `--stem-x`; die Pseudoelemente
rechnen über left:var(--stem-x, 50%) bzw. right:calc(100% - var(--stem-x, 50%)).
Der Rückfallwert 50 % hält alle übrigen Zellen ohne Messung richtig, die
transponierten Modi setzen left/right ohnehin fest. Messwerte werden wie in
drawCheapPath() durch `zoom` zurückgerechnet.

Verifiziert: Vitest 60/60 (2 neue Tests: `opt` auch am <li> neben dem Gate der
eigenen Kinder; <li> ohne Attribut, wenn weder Kinder noch optional).
Im Browser gemessen: Abweichung Stiel↔Knotenmitte bei „Wahl" 13,4 px → 0,0 px,
alle übrigen Zellen unverändert 0,0 px ohne gesetzte Variable. Angesehen in
horizontal und kompakt: gestrichelter Ast in Tinte zum Kreis, Sammelleiste
durchgezogen, deutlich unterscheidbar vom gestrichelt-grauen any-of-Ast
daneben. SVG-Export gerendert geprüft: 2 von 9 Tinte-Linien gestrichelt, beide
Kreise am Auftreffpunkt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 21:11:20 +02:00

15 KiB
Raw Blame History

Werkbaum · Frontend

Editor: Text links, Diagramm rechts, Toggles für transponierte Ansicht und verworfene Elemente. Quelle sind ES-Module unter src/; index.html ist der Vite-Entry (lädt im Dev-Server src/app.js als <script type="module">).

Build & Entwicklung (D19)

  • Vite ist Bündler + Testrunner (nur Dev-Abhängigkeiten, keine Laufzeit- Abhängigkeit — das Ergebnis ist framework-freies HTML/CSS/JS).
  • npm --prefix frontend run dev — Dev-Server (Port 8137, .claude/launch.json). Direktes Öffnen von frontend/index.html per file:// funktioniert nicht mehr (ES-import braucht http); stattdessen Dev-Server oder die gebaute Datei.
  • npm --prefix frontend run buildvite-plugin-singlefile inlint JS + CSS + Favicon (als data:-URI, via transformIndexHtml-Plugin in vite.config.js) in eine self-contained dist/index.html — die bleibt file://-tauglich (D16) und ist die Deploy-Artefakt-Quelle (Pages-Workflow, siehe README).
  • npm --prefix frontend test — Vitest (tests/**/*.test.js).
  • npm --prefix frontend run build:prod — Produktions-Build ohne den Build-Hinweis hinter dem Titel (Vite-Modus prod, .env.prod setzt VITE_BUILD_BADGE=none). Nur die echte produktive Installation nutzt diesen Weg; Dev-Server (🔧) und Default-build (🚧, u. a. Pages-Deploy) zeigen den Hinweis. Logik: mountBuildBadge() in app.js (D16).
  • node_modules/ und dist/ sind ge-.gitignore-t; .env.prod und package-lock.json sind eingecheckt (der Workflow nutzt npm ci).

Konventionen

  • Vanilla HTML/CSS/JS, ES-Module; keine Frameworks. Testwerkzeug: Vitest.
  • Parser und Renderer müssen headless (ohne Editor-UI) nutzbar bleiben — Basis für SVG-Export und Mermaid-Plugin (docs/ROADMAP.md). app.js ist der DOM-/UI-Einstieg; reine Logik gehört in parser.js/model.js/render.js.
  • Design: Farben/Typografie beibehalten (CSS-Variablen, IBM Plex); Statusfarben sind in SPEC §4 normiert. Marke nach ../brand/BRAND.md; Pastelltöne nie im Logo.
  • IBM Plex ist lokal eingebettet (src/fonts/*.woff2, @font-face in style.css), nicht von Google Fonts geladen — kein externer Request, keine IP an Dritte (Datenschutz, D20). Keinen googleapis-<link> wieder einführen. Neue Schnitte: woff2 per npm pack @fontsource/… beziehen (keine Projekt-Abhängigkeit, Dateien einchecken) und @font-face ergänzen.

Stolperfallen

  • Abzweig-Linien zielen auf die Knotenmitte (fester 23-px-Offset, line-height: 1.3), nicht auf die Mitte des Teilbaums — bei Layout- Änderungen alle drei Modi (horizontal/vertikal/kompakt) prüfen. Vertikal + kompakt teilen die transponierte Basis-CSS; nur vertikal bekommt den Rechts-Ausgang für „all of“, kompakt führt auch „all of“ nach unten. Any-of ist in allen Modi grau: Linien gestrichelt grau und Alternative-Rahmen grau (Basis-CSS ul.or). Kein Petrol im Diagramm mehr; var(--or) nur noch für UI-Akzente/Logo (SPEC §9, D15).
  • Extraktionsreihenfolge im Parser nicht umstellen: Kommentar → Zeichen/ Status → URL → Größe → Tags (sonst kollidiert @ in URLs).
  • Fehlertoleranz (SPEC §4): der Parser erfasst die Statusbox als beliebiges Einzelzeichen \[([^\]])\] und validiert gegen STATUS_BY_CODE; unbekannte Codes → parse().warnings als {type:'unknownStatus', line, code}, Knoten neutral. render() in app.js führt Parser- und Renderer-Warnungen zusammen (nach Zeile sortiert) und zeigt sie via formatWarning (warnings.js). Neue Warnungstypen dort + i18n-Key in allen 9 Sprachen ergänzen.
  • Modulteilung (D19): parser.js (Text→Baum, headless), model.js (Baum-/ Kostenlogik: gateOf, needsBreakdown, visibleChildren(n, showDiscarded), computeCheapSet, cheapCls), render.js (HTML-String via renderTreeHtml(roots, {t, showDiscarded, cheapPath, cheapSet}), headless), app.js (DOM/Events/i18n/Persistenz/Export). Modell/Renderer bekommen UI-State (verworfene einblenden, Pfad an/aus) als Parameter — keine Globals; nur cheapPathOn lebt als UI-State in app.js. Tests: tests/*.test.js.
  • Günstigster Pfad: markCheapest()/cheapestCost() (in model.js) markieren die nötigen Knoten (Klassen cheap, cheap-leaf); drawCheapPath() (app.js) zeichnet nach jedem render() und nach applyLayout() zwei Overlay-SVGs in #out (hinten kräftige Linie, vorne abgetönte Kopie + Stationspunkte). Overlays erben den CSS-zoom von #out, Punkte in unskalierte #out-Koordinaten umrechnen (/zoom). diagramToSvg() zeichnet dieselbe Linie/Punkte nach (SPEC §9, D18).
  • Zerlegt eine any-of-Alternative selbst all-of, wird der Teilbaum nur horizontal schmal transponiert (ul.or>li.has-and>ul.and, siehe D18) — sonst schiebt der breite Fächer den Elternbaum nach rechts. Bei Layout- Umbauten dieses Nesting mitprüfen.
  • „verworfen" ist per Default ausgeblendet; Filterlogik steckt in visibleChildren() und muss bei Renderer-Umbauten erhalten bleiben.
  • Barrierefreiheit (SPEC §9): render.js baut je Knoten einen sprechenden aria-label (Label + Status + Aufwand + Zuständige + Link, lokalisiert via t); die visuellen Badges (Größe, Tags, ↗) sind aria-hidden. Neue Knoten-Eigenschaften dort in nodeAria() mitpflegen und dafür a11y-i18n-Keys (a11y*) in allen 9 Sprachen anlegen. Knoten sind tabindex="0" (Fokus = Lesereihenfolge), #warn ist eine Live-Region.
  • Zustand wird im localStorage gehalten (noch kein Backend): werkbaum-lang (Sprache), werkbaum-docs (JSON-Array der Dokumente [{id,name,text}]), werkbaum-active (id des aktiven Dokuments), werkbaum-src (Spiegel des aktiven Texts, Abwärtskompatibilität), werkbaum-ui (JSON: Modus, verworfene, günstigster Pfad, Split-Zustand inkl. --col/--drow, Zoom, Vollbild). Neue GUI-Einstellungen in saveUI()/restoreState() mitführen; saveUI liefert während restoring===true nichts, damit das Wiederherstellen nicht sofort zurückschreibt.
  • Dokumente (D22): mehrere umschaltbare Notationstexte. loadDocs() migriert bei fehlendem werkbaum-docs den bestehenden werkbaum-src (oder INITIAL) in ein Dokument; initDocs() (Aufruf nach applyLang) holt den aktiven Text in den Editor. saveSrc() schreibt den Editortext ins aktive Dokument. Der Wähler ist ein Dropdown in der Editor-Titelzeile (#docTrigger/#docMenu, ersetzt die frühere feste „Struktur (Text)"-Beschriftung); Wechseln/Neu/ Umbenennen/Löschen in switchDoc/newDoc/renameDoc/deleteDoc. Jedes Dokument ist nur Text + Name (kein Strukturformat, D14) — vorwärtskompatibel zum Backend (D13). Ansichts-State (werkbaum-ui) bleibt global über alle Dokumente. Ein leerer Editortext bleibt leer.
  • Beispiel-Dokument (D22): reservierte id EXAMPLE_ID = 'example', fester englischer Name EXAMPLE_NAME = 'Example' (nicht lokalisiert). loadDocs() adoptiert einen Alt-Zustand (zufällige id, „Beispiel") nur, wenn dessen text === INITIAL (nie echte Nutzerinhalte). resetToDefaults() setzt nur das Beispiel-Dokument (id example) auf INITIAL/„Example" zurück und verwirft werkbaum-ui/werkbaum-lang/Update-Flags — andere Dokumente bleiben stehen (nicht mehr pauschal alle werkbaum-* löschen!). Das letzte gelöschte Dokument wird als Beispiel neu gesät.
  • Mitgeliefertes Dokument „Werkbaum" (D27): app.js importiert ../../docs/examples/example-werkbaum.werkbaum?raw — die Beispieldatei ist damit Build-Eingabe, Umbenennen/Verschieben bricht den Build (Zugriff außerhalb des Roots erlaubt server.fs.allow:['..']). seedShippedDocs() legt es einmalig an (auch für Bestandsnutzer); ohne den Merker werkbaum-seeded käme ein gelöschtes Dokument bei jedem Laden zurück. Der Merker hält den Fingerabdruck der ausgelieferten Fassung: Ändert sich die Datei, wird der Text nur nachgezogen, wenn der Nutzer ihn nicht bearbeitet hat. Wer die Beispieldatei ändert, ändert damit das mitgelieferte Dokument mit. resetToDefaults() setzt beide mitgelieferten Dokumente und den Merker.
  • Umbenennen ist inline (kein window.prompt — in manchen Browser-Kontexten unterdrückt): renameDoc() setzt renamingId, renderDocMenu() rendert dann ein <input class="docrename"> (Enter = commitRename, Esc = cancelRename, Blur = commit). Doc-Namen sind Nutzerdaten und werden nicht übersetzt.
  • ?sourceUrl= (D23): loadFromSourceUrl() (Aufruf am Ende des Starts, async) holt eine externe Textdatei und führt sie als Dokument mit id: 'url:<href>', name/source = URL. Nur http(s), credentials:'omit'; bei jedem Laden wird neu geholt (URL = Quelle der Wahrheit, lokale Edits daran gehen verloren). Fehler landen als zeilenlose Warnung {type:'sourceLoad', url, error} in sourceWarning — ein persistenter Kanal, den render() jedem Warnungs-Satz voranstellt (überlebt Neu-Renderings, bis das Laden gelingt). Hauptstolperfalle ist CORS: fremde Hosts brauchen Access-Control-Allow-Origin (raw.github… ja, beliebiger Webserver oft nicht) — der Warntext nennt das ausdrücklich. updateDocName() setzt für solche Dokumente den vollen URL-Tooltip und muss deshalb nach dem data-i18n-title-Durchlauf in applyLang() laufen. Weder Endung noch Content-Type werden geprüft (response.text()); die Endung .werkbaum ist reine Konvention (D24, SPEC §12). Beispieldateien zum Ausprobieren: docs/examples/*.werkbaum (nacheinander geöffnet ergeben sie mehrere Dokumente im Wähler).
  • Sprung Diagramm ↔ Text (D25): render.js schreibt die Parser-Zeilennummer als data-line an jeden Knoten (Geister-Knoten bekommen keine). jumpToLine() in app.js klappt bei Bedarf das Editor-Panel auf (revealEditor()), markiert die ganze Zeile und scrollt über einen Spiegel-div (offsetTopInEditor()) — Zeilenhöhe × n scheitert an weichen Umbrüchen. Ausgelöst per Alt+Klick, Alt+Enter und langem Druck; der Klick-Handler muss preventDefault() rufen, sonst lädt Alt+Klick auf einen Link-Knoten das Ziel herunter. Gegenrichtung: syncCaret() setzt die Klasse current auf den Knoten der Cursor-Zeile; render() stellt sie nach jedem Neubau wieder her (ohne zu scrollen). Die CSS-Regel braucht den #out-Präfix (ul.or .node{box-shadow:none} ist spezifischer). Auffindbarkeit: setAltMode() setzt bei gedrückter Alt-Taste die Klasse alt an #out (Cursor + Ring am Knoten unter dem Zeiger) — der blur-Handler ist Pflicht, sonst bleibt der Modus nach Alt+Tab hängen; dazu jumpHint im Knoten-Tooltip und hint_jump als letzte Zeile der Legende.
  • Touch-Langdruck (D25): Der Timer (500 ms) setzt nur die Klasse armed (Petrol-Ring); jumpToLine() läuft im touchend-Handler. Nicht zurück in den Timer verlegen — focus() aus einem Timer gilt in mobilen Browsern nicht als Nutzergeste, das Textfeld verliert den Fokus sofort wieder (Symptom: die Markierung flackert nur auf). Gegen die native Langdruck-Auswahl braucht es alle drei Schichten: contextmenu-preventDefault während des Drucks, user-select:none per @media (hover:none) and (pointer:coarse) und -webkit-touch-callout:none (nur iOS). Synthetische TouchEvents prüfen davon nichts — nur die eigene Ereignis-Logik.
  • Bildschirmtastatur (D25): jumpToLine() setzt vor dem Fokus inputmode="none" (keyboardOnJump(true)) — der Sprung ist „hinschauen". Der pointerdown- Handler am Textfeld hebt das wieder auf (läuft vor dem Fokus). Wer sonst irgendwo src.focus() ergänzt und dabei Tippen meint, muss vorher keyboardOnJump(false) rufen — so wie newDoc().
  • Legende (D26): kein <details> — Chrome legt Details-Inhalt in ::details-content, dadurch war .hint kein Flex-Kind mehr und ließ sich nicht begrenzen (Inhalt geclippt statt scrollbar). Jetzt div.agenda + button.agenda-summary, Zustand an der Klasse open (setAgendaOpen() hält Klasse, aria-expanded, #legendBtn und die Sichtbarkeit des Splitters zusammen). Wer hier wieder ein natives Aufklapp-Element einsetzt, bricht das Scrollen. Der Splitter #hintGutter schreibt --hcol (nebeneinander) bzw. --hrow (gestapelt: side oder mobil) an #app; beide werden getrennt in werkbaum-ui gesichert. Die 85-%-Obergrenze steht zusätzlich als max-width/max-height im CSS — die gespeicherte px-Größe würde den Editor sonst erdrücken, wenn das Panel später schrumpft.
  • „Was ist neu?" (D28): freshProdSet(prevRoots, currRoots) in model.js liefert die Knoten, die neu [^] sind (Identität = Label-Pfad, nicht Zeile). render() bildet die Menge bei jedem Durchlauf neu aus den gerade geparsten roots — eine vorab berechnete Menge stammte aus einem anderen Parse-Durchlauf und träfe per Objektidentität nie zu (genau dieser Fehler ist passiert: Zähler stimmte, nichts leuchtete). Vorgehalten wird nur freshPrevRoots (Basis, einmal geparst). Basis je Dokument in werkbaum-seen, fortgeschrieben erst beim Bestätigen über #freshBtn.
  • Optionale Knoten + (D29): Der Parser setzt optional:true und lässt type:'and'+ gehört zum Knoten, nicht zur Gruppe. Deshalb bleiben gateOf() und die mixedGate-Warnung unverändert richtig (sie meldet nur | neben -/+). Aus dem günstigsten Pfad fallen optionale Knoten über pathChildren() heraus — die eine Stelle, die cheapestCost() und markCheapest() gemeinsam nutzen; deshalb wirkt sie samt Teilbaum. Der hohle Kreis ist .node.opt::before: Grundfall links/50 % (gestapelt), Ausnahme oben/50 % im horizontalen Fächer, Rück-Ausnahme wieder links für ul.or>li.has-and>ul.and>li (D18). Im SVG-Export muss er nach den Knoten gezeichnet werden (optMarks, Schritt 3a) — er liegt halb außerhalb der Box und würde sonst vom Knoten-Rechteck überdeckt. Der Abzweig ist zusätzlich gestrichelt (Tinte); dafür trägt auch das <li> die Klasse opt, denn den Abzweig zeichnen dessen Pseudoelemente. Gestrichelt wird nur die Kante zum Knoten, nie die Sammelleiste — im Fächer border-left/-right, gestapelt border-top.
  • --stem-x (D29, Nachtrag 2): Im horizontalen Fächer sitzt der Stiel bei 50 % des <li>. Das ist nur dann die Knotenmitte, wenn der Knoten in der Zelle zentriert steht — li.has-or ist aber linksbündig und die Zelle so breit wie der any-of-Teilbaum. alignStems() misst deshalb nach jedem render() (und in applyLayout) die Knotenmitte und setzt --stem-x; Fallback im CSS ist 50%. Messwerte durch zoom zurückrechnen, sonst stimmt es nur bei 100 %.
  • Kleiner Bildschirm: body.mobile (per matchMedia, ≤ 640 px) stapelt Diagramm/Editor mit stufenlosem Splitter (kein Snap/Collapse wie auf Desktop): der Gutter-Drag ruft setMobileDrow() (klemmt --drow zwischen den Kopfhöhen --pmin-d/--pmin-e, per syncPanelMins() gemessen), ein Tipp auf eine Titelzeile maximiert das Panel. applyLayout ruft auf Mobil kein applySplit (das würde --drow löschen). Dazu eigener Legenden-Umschalter (#legendBtn), schlanke Sprachwahl, Download-Overlay; Default Vollbild + Diagramm maximiert. Layout-CSS hängt an body.mobile, nicht an einer eigenen @media-Regel — beide Seiten müssen denselben 640-px-Schwellwert nutzen (SPEC §9, D17).