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>
216 lines
15 KiB
Markdown
216 lines
15 KiB
Markdown
# Werkbaum · Frontend
|
||
|
||
Editor: Text links, Diagramm rechts, Toggles für transponierte Ansicht und
|
||
verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
||
**Vite-Entry** (lädt im Dev-Server `src/app.js` als `<script type="module">`).
|
||
|
||
## Build & Entwicklung (D19)
|
||
- **Vite** ist Bündler + Testrunner (nur Dev-Abhängigkeiten, keine Laufzeit-
|
||
Abhängigkeit — das Ergebnis ist framework-freies HTML/CSS/JS).
|
||
- `npm --prefix frontend run dev` — Dev-Server (Port 8137, `.claude/launch.json`).
|
||
Direktes Öffnen von `frontend/index.html` per `file://` funktioniert **nicht**
|
||
mehr (ES-`import` braucht http); stattdessen Dev-Server oder die gebaute Datei.
|
||
- `npm --prefix frontend run build` — `vite-plugin-singlefile` inlint JS + CSS +
|
||
Favicon (als `data:`-URI, via `transformIndexHtml`-Plugin in `vite.config.js`)
|
||
in **eine** self-contained `dist/index.html` — die bleibt `file://`-tauglich
|
||
(D16) und ist die Deploy-Artefakt-Quelle (Pages-Workflow, siehe README).
|
||
- `npm --prefix frontend test` — Vitest (`tests/**/*.test.js`).
|
||
- `npm --prefix frontend run build:prod` — Produktions-Build **ohne** den
|
||
Build-Hinweis hinter dem Titel (Vite-Modus `prod`, `.env.prod` setzt
|
||
`VITE_BUILD_BADGE=none`). Nur die echte produktive Installation nutzt diesen
|
||
Weg; Dev-Server (🔧) und Default-`build` (🚧, u. a. Pages-Deploy) zeigen den
|
||
Hinweis. Logik: `mountBuildBadge()` in `app.js` (D16).
|
||
- `node_modules/` und `dist/` sind ge-`.gitignore`-t; `.env.prod` und
|
||
`package-lock.json` sind eingecheckt (der Workflow nutzt `npm ci`).
|
||
|
||
## Konventionen
|
||
- Vanilla HTML/CSS/JS, ES-Module; keine Frameworks. Testwerkzeug: Vitest.
|
||
- Parser und Renderer müssen headless (ohne Editor-UI) nutzbar bleiben —
|
||
Basis für SVG-Export und Mermaid-Plugin (docs/ROADMAP.md). `app.js` ist der
|
||
DOM-/UI-Einstieg; reine Logik gehört in `parser.js`/`model.js`/`render.js`.
|
||
- Design: Farben/Typografie beibehalten (CSS-Variablen, IBM Plex);
|
||
Statusfarben sind in SPEC §4 normiert. Marke nach ../brand/BRAND.md;
|
||
Pastelltöne nie im Logo.
|
||
- IBM Plex ist **lokal eingebettet** (`src/fonts/*.woff2`, `@font-face` in
|
||
`style.css`), **nicht** von Google Fonts geladen — kein externer Request,
|
||
keine IP an Dritte (Datenschutz, D20). Keinen `googleapis`-`<link>` wieder
|
||
einführen. Neue Schnitte: `woff2` per `npm pack @fontsource/…` beziehen (keine
|
||
Projekt-Abhängigkeit, Dateien einchecken) und `@font-face` ergänzen.
|
||
|
||
## Stolperfallen
|
||
- Abzweig-Linien zielen auf die **Knotenmitte** (fester 23-px-Offset,
|
||
`line-height: 1.3`), nicht auf die Mitte des Teilbaums — bei Layout-
|
||
Änderungen alle drei Modi (horizontal/vertikal/kompakt) prüfen.
|
||
Vertikal + kompakt teilen die transponierte Basis-CSS; nur vertikal
|
||
bekommt den Rechts-Ausgang für „all of“, kompakt führt auch „all of“
|
||
nach unten. Any-of ist in allen Modi grau: Linien gestrichelt grau und
|
||
Alternative-Rahmen grau (Basis-CSS `ul.or`). Kein Petrol im Diagramm mehr;
|
||
`var(--or)` nur noch für UI-Akzente/Logo (SPEC §9, D15).
|
||
- Extraktionsreihenfolge im Parser nicht umstellen: Kommentar → Zeichen/
|
||
Status → URL → Größe → Tags (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 `TouchEvent`s prüfen
|
||
davon **nichts** — nur die eigene Ereignis-Logik.
|
||
- Bildschirmtastatur (D25): `jumpToLine()` setzt vor dem Fokus `inputmode="none"`
|
||
(`keyboardOnJump(true)`) — der Sprung ist „hinschauen". Der `pointerdown`-
|
||
Handler am Textfeld hebt das wieder auf (läuft vor dem Fokus). Wer sonst
|
||
irgendwo `src.focus()` ergänzt und dabei Tippen meint, muss vorher
|
||
`keyboardOnJump(false)` rufen — so wie `newDoc()`.
|
||
- Legende (D26): **kein `<details>`** — Chrome legt Details-Inhalt in
|
||
`::details-content`, dadurch war `.hint` kein Flex-Kind mehr und ließ sich nicht
|
||
begrenzen (Inhalt geclippt statt scrollbar). Jetzt `div.agenda` +
|
||
`button.agenda-summary`, Zustand an der Klasse `open` (`setAgendaOpen()` hält
|
||
Klasse, `aria-expanded`, `#legendBtn` und die Sichtbarkeit des Splitters
|
||
zusammen). Wer hier wieder ein natives Aufklapp-Element einsetzt, bricht das
|
||
Scrollen. Der Splitter `#hintGutter` schreibt `--hcol` (nebeneinander) bzw.
|
||
`--hrow` (gestapelt: `side` oder mobil) an `#app`; beide werden getrennt in
|
||
`werkbaum-ui` gesichert. Die 85-%-Obergrenze steht **zusätzlich** als
|
||
`max-width`/`max-height` im CSS — die gespeicherte px-Größe würde den Editor
|
||
sonst erdrücken, wenn das Panel später schrumpft.
|
||
- „Was ist neu?" (D28): `freshProdSet(prevRoots, currRoots)` in `model.js` liefert
|
||
die Knoten, die **neu `[^]`** sind (Identität = Label-Pfad, nicht Zeile).
|
||
`render()` bildet die Menge **bei jedem Durchlauf neu** aus den gerade
|
||
geparsten `roots` — eine vorab berechnete Menge stammte aus einem anderen
|
||
Parse-Durchlauf und träfe per Objektidentität nie zu (genau dieser Fehler ist
|
||
passiert: Zähler stimmte, nichts leuchtete). Vorgehalten wird nur
|
||
`freshPrevRoots` (Basis, einmal geparst). Basis je Dokument in `werkbaum-seen`,
|
||
fortgeschrieben **erst beim Bestätigen** über `#freshBtn`.
|
||
- 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).
|