Auf der Prod-Instanz gab es keinen Weg, ein bearbeitetes mitgeliefertes Dokument wieder auf den Auslieferungsstand zu bringen — der Reset ist ein Debug-Knopf, und das Fingerabdruck-Nachziehen (D27) fasst bearbeitete Texte nie an. Stilles Überschreiben beim Laden wäre überraschender Datenverlust (verworfen); stattdessen ein Menü-Eintrag, sichtbar nur für mitgelieferte Dokumente und nur bei Abweichung vom Auslieferungsstand, mit Rückfrage. Stellt Text und Name wieder her, verwirft Falt-Eingriffe. i18n in allen 9 Sprachen; im Browser durchgespielt (erscheint nach Edit, stellt her, verschwindet wieder). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
370 lines
27 KiB
Markdown
370 lines
27 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 → Knoten-ID → Abhängigkeiten → Fokusmarke
|
||
(sonst kollidieren `@` und `#` 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). Eine neue INITIAL-Fassung wird
|
||
wie beim Werkbaum-Dokument per Fingerabdruck nachgezogen
|
||
(`werkbaum-seeded-example`, D27-Nachtrag) — nur bei unverändertem Text; der
|
||
Reset setzt beide Merker. Bearbeitete mitgelieferte Dokumente holt der
|
||
Nutzer über „Original wiederherstellen" im Dokumenten-Menü zurück
|
||
(`restoreDoc()`, D22-Nachtrag) — sichtbar nur bei Abweichung vom
|
||
Auslieferungsstand, stellt Text UND Name wieder her. `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/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).
|
||
- `?etherpad=` (D31): `remoteSource()` liefert **einen** Beschreiber für beide
|
||
Eingänge (`?sourceUrl=` und `?etherpad=`), `loadRemoteSource()` holt und legt
|
||
das Dokument an — ein Fetch-Pfad, ein Warnkanal. Die Normalisierung der
|
||
Pad-Adresse steht headless in **`remote.js`** (`padUrls`, Tests in
|
||
`tests/remote.test.js`): Export-/Timeslider-Pfad, Query, Fragment und
|
||
Schrägstriche fallen weg, verlangt wird `/p/<name>` am Ende. Name und id sind
|
||
die **vollständige** Pad-URL (Pad-Namen sind nur pro Instanz eindeutig).
|
||
`padSource` muss **vor** dem ersten Abruf und vor `loadActiveIntoEditor()`
|
||
gesetzt werden: Es trägt den Schreibschutz, und scheitert der erste Abruf
|
||
(Normalfall bei dieser Gegenseite), bleibt so der Neu-laden-Knopf erreichbar
|
||
statt das Dokument tot liegen zu lassen. Geholt wird **nur auf Knopfdruck**
|
||
(`refreshPad`) — **kein Hintergrund-Takt wieder einbauen**: Etherpads
|
||
`importExportRateLimiting` lässt serienmäßig 10 Abrufe je 90 s und IP zu, und
|
||
jenseits davon hält die Gegenseite die Verbindung ohne Antwort offen (kein
|
||
`429`), bis der eigene Abbruch sie abreißt — gemessen: zwei Minuten tot, danach
|
||
0,4 s. Ein Takt gewinnt dagegen nicht, er erzeugt die Drosselung. Deshalb auch
|
||
`setPadBusy()` (Symbol dreht, bis zu 20 s) und ein **eigener** Warnungstyp
|
||
`sourceTimeout` für den Abbruch — `sourceLoad` zeigt auf CORS und schickte hier
|
||
auf die falsche Fährte. `deleteDoc()` ruft `stopPad()`, sonst legte ein späterer
|
||
Abruf das gelöschte Dokument wieder an und aktivierte es auch gleich.
|
||
Beim Prüfen im Vorschau-Browser: HMR lädt bei jeder Quelländerung neu, und ein
|
||
Reload holt den Text beim Erstabruf — sieht wie eine geglückte Übernahme aus.
|
||
Marker auf `window` setzen und hinterher prüfen, sonst beweist der Test nichts.
|
||
- Pad einbetten, drei Ansichten (D31): `padView` ('both'|'pad'|'text') +
|
||
`applyPadView()`. Der Rahmen bekommt **nur sichtbar** ein `src`, sonst
|
||
`about:blank` — ein geladenes Pad verbindet sich per Socket und zeigt dich in
|
||
dessen Anwesenden-Liste. `PAD_VIEWS`/`padView` stehen **oben** bei
|
||
`cheapPathOn`, nicht beim übrigen Pad-Code: `saveUI()` liest sie und kann schon
|
||
aus `applySplit()` heraus laufen, weiter unten stünden sie noch in der
|
||
temporalen Todeszone. Der Splitter Pad|Spiegel (`#padGutter`, `--pcol`/`--prow`)
|
||
ist eine Kopie des Legenden-Splitters (D26) samt getrennter Persistenz.
|
||
`revealEditor()` schaltet 'pad' → 'both', sonst zeigte der Sprung (D25) ins
|
||
Nichts. Das Markup: `#srcArea` umschließt Rahmen + Splitter + `#src`, damit die
|
||
Legenden-Aufteilung unberührt bleibt — `.editor-body textarea` ist ein
|
||
Nachfahren-Selektor und greift weiter. Nebenwirkung, die als Behelf taugt:
|
||
Einmal durch die Ansichten schalten **lädt den Rahmen neu** (über
|
||
`about:blank`) — das ist der Ausweg, wenn ein eingebettetes Pad nicht mehr
|
||
beschreibbar ist. Nicht in den Neu-laden-Knopf legen: der zieht nach dem Tippen
|
||
Spiegel und Diagramm nach, und ein Rahmen-Reload dabei kostete jedes Mal die
|
||
Schreibmarke im Pad. **Umhängen im DOM lädt jeden `<iframe>` neu** — wer
|
||
Container um `#srcArea` herum neu aufbaut, wirft die Pad-Sitzung weg.
|
||
- Fokusmarke `!!!` (D32, SPEC §1): Parser setzt `focus`, Renderer die Klasse
|
||
`focusmark`, CSS gibt ihr einen **eigenen Strahlenkranz in Petrol** (Nachtrag zu
|
||
D32; der geteilte Ring mit `.current` war zu leise). Schein in **hellem** Teal
|
||
`#14B8A6`, Ring in dunklem `--or`: ein dunkler Schein auf Weiß liest sich als
|
||
Schatten, ein heller als Licht. Wie bei `.fresh` braucht die Regel `z-index:2`,
|
||
sonst schneidet ein später gemaltes Geschwister den Kranz ab (D28). Die
|
||
Kombinationen `focusmark.current` und `fresh.focusmark` sind ausbuchstabiert;
|
||
letztere steht bewusst **danach** (gleiche Spezifität). Erkannt **nur alleinstehend** — `(^|\s)!!!(?=\s|$)`, bewusst ohne
|
||
Lookbehind (Safari erst ab 16.4), sonst verlöre `Achtung!!!` seine
|
||
Ausrufezeichen. Extraktion **nach** den Tags; der Kommentar fällt vorher weg,
|
||
eine Marke hinter `%%` wirkt also nicht. `revealFocusMark()` scrollt **nur bei
|
||
Änderung** (Schlüssel = Label-Text, nicht Zeilennummer — Umsortieren im Pad soll
|
||
nicht neu scrollen), sonst zöge jeder Neubau den Blick zurück. Die Regel braucht
|
||
den `#out`-Präfix wie `.current` (`ul.or .node{box-shadow:none}` ist
|
||
spezifischer) **und** die Ausnahme
|
||
`.cheap-on .node:not(.cheap).focusmark{opacity:1;filter:none}` — sonst blasst die
|
||
Pfad-Inversion genau den Knoten aus, auf den gezeigt wird (bei einer nicht
|
||
gewählten Alternative gemessen: Deckkraft 0,32).
|
||
- 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.
|
||
- Alt+Klick **im Textfeld** (D25, Nachtrag): `focusNodeOfCaret()` zentriert den
|
||
Knoten der Cursor-Zeile und gibt ihm den Fokus (`focus({preventScroll:true})`
|
||
**vor** `scrollIntoView({block:'center'})` — sonst scrollt der Browser zweimal).
|
||
Das ist nicht dasselbe wie das Mitlaufen der Cursor-Zeile: `syncCaret()` scrollt
|
||
absichtlich nur `nearest` und nur beim Zeilenwechsel. Beim Tastaturweg
|
||
(Alt+Enter) ist `preventDefault()` Pflicht, sonst bekommt der Text einen
|
||
Umbruch. Die Legenden-Zeile `hint_jump` nennt **beide** Richtungen — kein
|
||
eigener i18n-Schlüssel.
|
||
- Zeilennummern (D33): `renderLineNos()` misst die Zeilenoberkanten am
|
||
Spiegel-`div` (`lineTops()`) — **nicht** `Zeilenhöhe × n`, das läuft beim
|
||
ersten weichen Umbruch davon. Der Streifen scrollt nicht selbst, sondern wird
|
||
per `translateY(-src.scrollTop)` mitgezogen (`syncLineNoScroll()`); wer
|
||
`src.scrollTop` **selbst** setzt, muss ihn gleich mitziehen — das
|
||
`scroll`-Ereignis kommt erst im nächsten Bild (`scrollEditorToOffset()` tut es).
|
||
Der Spiegel braucht `box-sizing:border-box`: `src.clientWidth` enthält die
|
||
Innenabstände, ohne das ist er 32 px zu breit und bricht später um (betraf
|
||
vorher auch schon das Scrollen beim Sprung, D25). Der Marker ist ein
|
||
Inline-Kasten und sitzt in seiner Zeilenbox mittig — die Zahlen müssen um
|
||
diesen Versatz (`drop`) nach oben, sonst stehen sie durchgehend zu tief.
|
||
Aufgerufen wird aus `render()` (dort kommt auch die Warnungsmenge her) und aus
|
||
einem `ResizeObserver` auf `#src` (Umbruch hängt an der Breite). Markup:
|
||
`#srcWrap` umschließt Streifen + Textfeld; die Pad-Regeln (D31) teilen gegen
|
||
**diese Spalte** (`.src-area.pv-both>.src-wrap`), nicht gegen das nackte
|
||
Textfeld. `caretLine` steht deshalb **oben** bei `padView` — der Streifen liest
|
||
sie und hängt an `render()` (temporale Todeszone, siehe D31).
|
||
- 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`.
|
||
- Faltmarken `>`/`<` (SPEC §1/§9, D38): Parser setzt nur `fold` ('>'|'<'|null,
|
||
Leerraum-Regel); den wirksamen Anfangszustand rechnet `initialCollapsed()`
|
||
in model.js — `<` (und die Fokusmarke) wandert die Faltung die Pfad-Ebenen
|
||
**hinunter** statt Vorfahren bloß zu öffnen. `render()` überlagert ihn mit
|
||
`foldOverrides` (Schlüssel = Label-Pfad via `nodeKeys()`, Sitzung, beim
|
||
Dokumentwechsel geleert) und übergibt `collapsedSet` an den Renderer; der
|
||
lässt eingeklappte Kinder **weg** (nicht CSS-verstecken — Export, Messungen
|
||
und Pfadlinie bleiben so von selbst konsistent), meldet deren Warnungen aber
|
||
weiter (`walkFolded`, zählt zugleich fürs „▸ n"). Umklappen: Klick aufs
|
||
`.fold`-Zeichen (preventDefault — es sitzt bei Link-Knoten im `<a>`) oder
|
||
←/→ am fokussierten Knoten; nach `render()` den Fokus per `data-line`
|
||
wiederherstellen. Export/Druck: „▸ n" bleibt, das ▾ offener Knoten fällt weg
|
||
(`stripFold` in `diagramToSvg`, Print-Regel `.node:not(.folded) .fold`).
|
||
- Knoten-IDs `#name` (SPEC §1/D36): nur **alleinstehend angesetzt** und nur
|
||
der **erste** Treffer der Zeile (kein `/g`!) — weitere `#`-Token bleiben im
|
||
Label (reservierte Ticket-Referenzen), und `:#a,#b` (künftige Abhängigkeiten)
|
||
darf nicht gefressen werden. Zeichenmenge wie `@name`. Doppelte ID →
|
||
`{type:'duplicateId', line, id, firstLine}`; die spätere gilt trotzdem.
|
||
Keine eigene Darstellung — nur Tooltip (erste Position) und `a11yId`.
|
||
- Abhängigkeiten `:#a,#b` (SPEC §1/D37): EIN zusammenhängendes Token ohne
|
||
Leerraum, nur **alleinstehend angesetzt** — `(:#a,#b)` bleibt Label
|
||
(Zitier-Konvention wie `(#auth)`). `node.deps` sind **ID-Strings**, keine
|
||
Knoten-Referenzen; der Parser prüft nur Existenz (`unknownDep`), Zyklen
|
||
werden bewusst nicht einmal erkannt (zulässig, „gemeinsam fertig"). Keine
|
||
Diagramm-Darstellung — nur Tooltip (`→ #a, #b`) und `a11yDeps`.
|
||
- Querverbindungen (SPEC §9, D41): `drawDepLinks()` zeichnet die
|
||
Abhängigkeits-Kanten als Overlay-SVGs (wie `drawCheapPath`, `/zoom`
|
||
umrechnen) aus den Renderer-Attributen `data-id`/`data-deps` — gekrümmt
|
||
und GEPUNKTET (doppelt unterschieden von den orthogonalen Baumlinien,
|
||
D41-Nachtrag), blassgrau hinter den Knoten, Pfeil aufs Gebrauchte; Hervorhebung (Fokus bzw. Cursor-Zeile,
|
||
`activeDepNode()`) in Tinte auf der vorderen Ebene. Aufgerufen aus
|
||
`highlightCurrentNode()` (läuft in jedem `render()`), `applyLayout()` und
|
||
den focusin/focusout-Handlern — NICHT doppelt in `render()` einhängen.
|
||
Export zeichnet nur die Basis-Kanten (Schritt 1a).
|
||
- Effektiver Status (SPEC §4/§9, D39): `effectiveStatus(roots)` in model.js
|
||
liefert NUR die Diskrepanzen (Map Knoten → effektiver Status-Key; Minimum
|
||
des Fortschritts-Rangs über die Abhängigkeits-Hülle, Fixpunkt — Zyklen
|
||
teilen ihr Minimum, erste ID-Vergabe gewinnt). Der Renderer färbt dann
|
||
`st-<effKey>` statt des intrinsischen und hängt die Marke
|
||
`.chip.ownst.st-<ownKey>` (eigene Statusbox, eigene Farben) unten links an —
|
||
im Export via `drawBadge`, aus dem Label-Klon entfernen (`.ownst`). XOR-
|
||
„realisiert" (Parser) und „Was ist neu?" (`[^]` im Text) bleiben bewusst
|
||
**intrinsisch**.
|
||
- XOR-Gruppen `=` (SPEC §3/D35): Der Parser setzt `type:'xor'` (nur mit
|
||
folgendem Leerraum — `=SUMME(…)` bleibt Label); der Renderer gibt
|
||
`<ul class="or xor">` aus, damit die **gesamte** any-of-Geometrie (alle drei
|
||
Modi, D18-Sonderfälle, Export-Routing) automatisch gilt — `.xor` ergänzt nur
|
||
die „1"-Plakette (`ul.xor::after`, im Export `xorMarks`). Disjunktiv-Abfragen
|
||
auf `gateOf` prüfen `!== 'and'`, nie `=== 'or'`. Die `xorConflict`-Warnung
|
||
(mehr als eine realisierte Alternative: `[~]`/`[/]`/`[x]`/`[^]`) entsteht im
|
||
**Parser** (Post-Pass), nicht im Renderer.
|
||
- 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`.
|
||
- Treppe optionaler Endknoten (D29, Nachtrag 3): `applyOptStairs()` gruppiert
|
||
**in app.js**, nicht im Renderer — die DOM-Ebene `li.opt-group > ul.opt-stair`
|
||
gibt es semantisch nicht und müsste in den drei übrigen Anordnungen wieder
|
||
neutralisiert werden. Deshalb: nur im Fächer bauen, beim Moduswechsel
|
||
auflösen (die Funktion räumt immer zuerst auf, `applyLayout` arbeitet auf dem
|
||
bereits gruppierten Baum). Reihenfolge: `applyOptStairs()` **vor**
|
||
`alignStems()`/`drawCheapPath()`, es verschiebt Knoten. Bedingung ist „`<li>`
|
||
hat genau ein Element-Kind, und das ist der Knoten" — also kein Teilbaum und
|
||
kein Geister-Knoten, weil die Stufengeometrie Zellenhöhe == Knotenhöhe
|
||
voraussetzt. Der SVG-Export hängt nur die **erste** Stufe an die Leiste und
|
||
zieht die übrigen als Winkelkette nach; flach eingereiht lief die Linie zur
|
||
dritten Stufe hinter der zweiten hindurch.
|
||
- `--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).
|