diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c3d9997..03b26b6 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -734,3 +734,81 @@ 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. +- **`@optional` als Personen-Tag** — missbraucht §7 für etwas Strukturelles. +- **`#optional` als 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. diff --git a/docs/SPEC.md b/docs/SPEC.md index 799b0fb..e37c05e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -14,7 +14,7 @@ Alle Bestandteile außer dem Label sind optional. Die Extraktion erfolgt in dieser Reihenfolge (wichtig für Kollisionsfreiheit): 1. Kommentar entfernen: alles ab `%%` bis Zeilenende. -2. Einrückung, Zeichen (`-` / `|`) und Statusbox `[…]` per Zeilen-Regex. +2. Einrückung, Zeichen (`-` / `+` / `|`) und Statusbox `[…]` per Zeilen-Regex. 3. URL: erstes Token, das auf `https?://\S+` passt (dadurch stören `@` in URLs nicht). 4. Größe: erstes `(XS|S|M|L|XL|XXL)`, Groß-/Kleinschreibung egal. 5. Tags: alle `@name`-Vorkommen. @@ -23,7 +23,7 @@ dieser Reihenfolge (wichtig für Kollisionsfreiheit): Referenz-Regex der Implementierung: ``` -^([ \t]*)([-|])?\s*(?:\[([ ?~xX^/-])\]\s*)?(.*)$ +^([ \t]*)([-|+])?\s*(?:\[([ ?~xX^/-])\]\s*)?(.*)$ ``` ## 2. Hierarchie @@ -31,7 +31,7 @@ Referenz-Regex der Implementierung: - Die Einrückung bestimmt die Ebene. Es gibt keine feste Schrittweite: Elternknoten ist die nächste vorangehende Zeile mit **kleinerer** Einrückungsbreite (Tab zählt als 2 Leerzeichen). -- Zeilen ohne Zeichen (`-`/`|`) sind Wurzelknoten. Mehrere Wurzeln = mehrere +- Zeilen ohne Zeichen (`-`/`+`/`|`) sind Wurzelknoten. Mehrere Wurzeln = mehrere Bäume nebeneinander. ## 3. Zerlegungsart (Gate) @@ -39,12 +39,21 @@ Referenz-Regex der Implementierung: | Zeichen | Bedeutung | Semantik | |---|---|---| | `-` | all of (Und-Zerlegung) | Alle Teilpakete sind erforderlich. | +| `+` | optional (Zugabe) | Einzelnes zusätzliches Teilpaket, nicht erforderlich. | | `\|` | any of (Oder-Zerlegung) | Mindestens eine Alternative wird gewählt. | -- Das Gate ist eine Eigenschaft der Geschwistergruppe; alle Geschwister sollen - dasselbe Zeichen tragen. -- Gemischte Geschwister: Darstellung nach dem **ersten** Kind, plus Warnung - mit Zeilennummer. +- `-` und `|` sind Eigenschaften der **Geschwistergruppe**; `+` ist eine + Eigenschaft des **einzelnen Knotens** (er hängt an derselben Und-Zerlegung, + ist darin aber entbehrlich). +- Daraus folgt die Mischregel: Eine Gruppe ist entweder **konjunktiv** — dann + dürfen `-` und `+` frei nebeneinander stehen — oder **disjunktiv** (`|`). + `|` mit `-`/`+` zu mischen ist ungültig: Darstellung nach dem **ersten** Kind, + plus Warnung `mixedGate` mit Zeilennummer. +- Ein `+`-Knoten zerlegt sich weiter wie jeder andere; das Gate seiner eigenen + Kinder ist davon unabhängig. Optionalität vererbt sich nicht ausdrücklich — + wer unter einem `+`-Knoten hängt, ist mit ihm zusammen entbehrlich. +- `+` sagt nichts über den Fortschritt: Eine Zugabe kann längst `[^]` sein. Die + beiden Achsen (Status §4, Notwendigkeit §3) sind unabhängig. ## 4. Status @@ -122,6 +131,18 @@ nebeneinander (schmales Diagramm rechts). ist grau (`#6B7A8C`) — kein Petrol mehr im Diagramm. Der Modus ändert nur die **Anordnung**, nicht die Linienfarbe. +**Optionale Knoten (`+`, §3):** Sie hängen an der normalen all-of-Linie — +Anordnung und Linienstil bleiben unverändert. Gekennzeichnet wird der Knoten +selbst durch einen **kleinen hohlen Kreis** (weiß gefüllt, Rand in Tinte) genau +dort, wo der Abzweig ihn trifft: in der horizontalen Fächer-Anordnung **oben +mittig**, in den gestapelten Anordnungen (vertikal, kompakt, unterhalb einer +any-of-Gruppe) **links auf halber Höhe**. Übernommen aus den Feature-Diagrammen +(FODA: gefüllter Punkt = erforderlich, hohler Punkt = optional). Bewusst **kein +weiterer Linienstil**: gestrichelt gehört der any-of-Zerlegung, und im kompakten +Modus trägt allein der Linienstil die Gate-Codierung (D15) — ein dritter Stil +wäre dort nicht mehr sicher unterscheidbar. Der Kreis erscheint auch im +Grafikexport. Siehe D29. + ### Horizontal (Normalmodus) - **all of:** Kinder nebeneinander, klassischer Organigramm-Fächer. - **any of:** Alternativen untereinander; gestrichelte graue Sammelleiste links @@ -190,13 +211,18 @@ werden die für die günstigste Realisierung **nötigen** Knoten: - **any of:** nur die **günstigste** Alternative ist nötig. „Günstig" = kleinste rekursive Kosten (eigene T-Shirt-Größe plus — je Gate — Summe bzw. Minimum der Kinder). Bei Gleichstand gewinnt die **erste** Alternative. +- **Optionale Knoten (`+`, §3) sind nie nötig** — sie zählen weder zu den + Kosten ihres Elternknotens noch liegen sie auf dem Pfad, und der Teilbaum + unter ihnen ebenso wenig. Genau dafür gibt es das Zeichen: Ohne `+` rechnet + der günstigste Pfad jede Zugabe ins Minimum ein und überschätzt es. - Verworfene Knoten zählen nie mit (unabhängig vom „verworfene einblenden"- Filter). - **Fehlende Größe wird als `M` gewertet** (nur für diese Kostenschätzung; die SPEC-Semantik der Größen in §5 bleibt unberührt). Darstellung per **Inversion**: nicht benötigte Knoten (nicht-gewählte -any-of-Alternativen samt Teilbaum) treten zurück (blass, entsättigt); der +any-of-Alternativen und optionale Knoten, je samt Teilbaum) treten zurück +(blass, entsättigt); der günstige Pfad hebt sich dadurch von selbst ab — kein zusätzlicher Rahmen an den ohnehin dichten Knoten-Ecken. Wo die Größe **implizit** als `M` angenommen wird, zeigt der Knoten ein **invertiertes** Größen-Badge (weiß mit petrolfarbenem @@ -296,8 +322,8 @@ Das Diagramm wird aus der Live-Geometrie in ein eigenständiges SVG (nur Formen ### Barrierefreiheit Die visuell codierten Knoten-Eigenschaften werden für Screenreader in einem sprechenden **`aria-label`** je Knoten zusammengefasst — Label, Status, Aufwand -(inkl. „(angenommen)“ beim impliziten M), Zuständige und ob der Knoten verlinkt -ist —, alles in der aktuellen UI-Sprache. Die rein visuellen Beiwerke +(inkl. „(angenommen)“ beim impliziten M), Zuständige, ob der Knoten optional +(§3) und ob er verlinkt ist —, alles in der aktuellen UI-Sprache. Die rein visuellen Beiwerke (Größen-Badge, Tags, ↗-Pfeil) sind `aria-hidden`, damit sie nicht kryptisch doppelt vorgelesen werden. **Alle** Knoten sind fokussierbar (`tabindex="0"` bzw. der Link selbst); die **Fokusreihenfolge entspricht der Dokument-/ @@ -327,6 +353,7 @@ Druckdialog „an Seite anpassen“ bzw. Querformat wählen. - [~] Umsetzung (XL) - [/] Frontend (S) https://git.example.de/frontend @anna - [ ] Backend (L) @ben @carla + + [?] Dark Mode (S) %% Zugabe, nicht erforderlich - [ ] CMS-Anbindung (M) | [ ] WordPress | [?] Headless CMS diff --git a/docs/examples/example-werkbaum.werkbaum b/docs/examples/example-werkbaum.werkbaum index 6379028..1787275 100644 --- a/docs/examples/example-werkbaum.werkbaum +++ b/docs/examples/example-werkbaum.werkbaum @@ -8,6 +8,7 @@ - [^] T-shirt size and the "decompose from M" rule (S) - [^] People tags, bare URLs, %% comments (XS) - [^] And/or decomposition (S) + - [^] Optional nodes — neither required nor an alternative (S) - [-] A separate storage format for the structure (L) %% the text is the format - [ ] Ticket references #123 (S) - [?] Benefit per node, not only cost (M) @@ -26,7 +27,7 @@ - [^] Export (M) - [^] SVG and PNG download (S) - [^] PNG to the clipboard (S) - - [^] Print stylesheet (XS) + + [^] Print stylesheet (XS) - [^] Nine interface languages (M) - [^] Translations (S) - [^] Default taken from the browser (XS) @@ -36,9 +37,9 @@ - [^] Switcher in the editor title bar (S) - [^] Load a document from ?sourceUrl= (S) - [^] Jump between diagram and text (S) - - [^] Show what is new since your last visit (S) + + [^] Show what is new since your last visit (S) - [ ] Open and save .werkbaum files (S) - - [?] A pastel colour per person (S) + + [?] A pastel colour per person (S) - [?] Dates and milestones (M) | [?] An attribute in the line (S) | [?] A separate timeline view (L) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 164cb04..32b9394 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -182,6 +182,17 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der 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. - 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 diff --git a/frontend/index.html b/frontend/index.html index bd81fbd..8d990b8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -79,8 +79,9 @@
- Teilpaket, alle erforderlich (all of)+ Zugabe, nicht erforderlich (optional)| Alternative, eine wählen (any of)| nicht mit -/+ mischen.- [~] Frontend:
- ${esc(t('hint_all'))} (all of)+ ${esc(t('hint_opt'))} (optional)| ${esc(t('hint_any'))} (any of)- [~] Frontend:
diff --git a/frontend/src/model.js b/frontend/src/model.js
index 308830c..5f6354e 100644
--- a/frontend/src/model.js
+++ b/frontend/src/model.js
@@ -28,9 +28,14 @@ export function visibleChildren(n, showDiscarded){
any-of ⇒ nur die günstigste Alternative. „Günstig" = kleinste rekursive
Kosten (eigene Größe + Kinder; any-of das Minimum). Verworfene zählen nie
mit (unabhängig vom Einblenden-Toggle). Gleichstand ⇒ erste. Fehlende
- Größe = M. */
+ Größe = M.
+ Optionale Kinder (`+`, SPEC §3/D29) fallen hier ebenfalls heraus — sie sind
+ per Definition entbehrlich, also weder Kostenanteil noch Pfadknoten. Da beide
+ Nutzer (`cheapestCost`, `markCheapest`) über diese Funktion gehen, gilt das
+ samt Teilbaum. */
export function pathChildren(n){
- return n.children.filter(k => !k.status || k.status.key !== 'verworfen');
+ return n.children.filter(k =>
+ !k.optional && (!k.status || k.status.key !== 'verworfen'));
}
/* fehlende Größe wird als M interpretiert */
export function ownCost(n){ return SIZE_RANK[n.size || 'M'] + 1; }
diff --git a/frontend/src/parser.js b/frontend/src/parser.js
index cad691e..e322b9c 100644
--- a/frontend/src/parser.js
+++ b/frontend/src/parser.js
@@ -20,7 +20,13 @@ export const STATUS_BY_CODE = {
};
/* Parst den Notationstext zu { roots, warnings }.
- Jeder Knoten: {label, type:'and'|'or', status, url, size, tags, children, line}.
+ Jeder Knoten: {label, type:'and'|'or', optional, status, url, size, tags,
+ children, line}.
+ `type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3)
+ eine Eigenschaft des einzelnen Knotens: er hängt an derselben Und-Zerlegung
+ (`type:'and'`), ist darin aber entbehrlich. Dadurch bleibt die
+ Gemischt-Warnung unverändert richtig — sie schlägt nur an, wenn `|` mit
+ `-`/`+` gemischt wird.
Extraktionsreihenfolge (SPEC §1): Kommentar -> Zeichen/Status -> URL -> Größe
-> Tags -> Label. Hierarchie über Einrückungsbreite (Tab = 2 Leerzeichen);
Elternknoten ist die nächste vorangehende Zeile mit kleinerer Breite. */
@@ -35,9 +41,10 @@ export function parse(text){
/* Statusbox tolerant erfassen: irgendein einzelnes Zeichen in [ ] an der
Statusposition. Gültige Codes -> Status; unbekannte -> Warnung + neutral
(fehlertolerant: die Zeile geht nicht verloren). */
- const m = raw.match(/^([ \t]*)([-|])?\s*(?:\[([^\]])\]\s*)?(.*)$/);
+ const m = raw.match(/^([ \t]*)([-|+])?\s*(?:\[([^\]])\]\s*)?(.*)$/);
const width = m[1].replace(/\t/g,' ').length;
const type = m[2] === '|' ? 'or' : 'and';
+ const optional = m[2] === '+';
const boxChar = m[3]; // undefined, wenn keine Statusbox
let rest = m[4], url = null, size = null;
@@ -57,7 +64,7 @@ export function parse(text){
while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
const parent = stack[stack.length-1].node;
- const node = {label, type, status, url, size, tags, children:[], line:i+1};
+ const node = {label, type, optional, status, url, size, tags, children:[], line:i+1};
parent.children.push(node);
stack.push({node, width});
});
diff --git a/frontend/src/render.js b/frontend/src/render.js
index ab3d2c0..3c4a38e 100644
--- a/frontend/src/render.js
+++ b/frontend/src/render.js
@@ -14,12 +14,13 @@
import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js';
-/* Zusatzklassen eines Knotens: günstigster Pfad (D18) + „neu in Produktion"
- gegenüber der zuletzt gesehenen Fassung (D28, `freshSet` optional). */
+/* Zusatzklassen eines Knotens: günstigster Pfad (D18), „neu in Produktion"
+ gegenüber der zuletzt gesehenen Fassung (D28, `freshSet` optional) und
+ optionale Knoten (`+`, SPEC §3/D29 — trägt den hohlen Kreis am Abzweig). */
function extraCls(n, opts){
const cheap = cheapCls(n, opts.cheapSet);
const fresh = opts.freshSet && opts.freshSet.has(n) ? 'fresh' : '';
- return [cheap, fresh].filter(Boolean).join(' ');
+ return [cheap, fresh, n.optional ? 'opt' : ''].filter(Boolean).join(' ');
}
export function esc(s){
@@ -39,6 +40,7 @@ function nodeAria(n, opts){
if(n.size) parts.push(t('a11ySize', {size: n.size}));
else if(cheapPath) parts.push(t('a11ySizeImplicit'));
if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')}));
+ if(n.optional) parts.push(t('a11yOptional'));
if(n.url) parts.push(t('a11yLink'));
return parts.join(', ');
}
@@ -52,7 +54,8 @@ function nodeHtml(n, extra, opts){
für die Gegenrichtung (Cursor-Zeile -> Knoten hervorheben). Der Hinweis im
Tooltip macht die sonst unsichtbare Alt-Klick-Geste auffindbar. */
const lineAttr = n.line ? ` data-line="${n.line}"` : '';
- const tip = [n.status ? t('st_' + n.status.key) : '', t('jumpHint')]
+ const tip = [n.status ? t('st_' + n.status.key) : '',
+ n.optional ? t('a11yOptional') : '', t('jumpHint')]
.filter(Boolean).join(' · ');
const title = ` title="${attr(tip)}"`;
const tagsHtml = n.tags && n.tags.length
@@ -84,6 +87,9 @@ function nodeHtml(n, extra, opts){
function renderChildren(node, warnings, opts){
const kids = visibleChildren(node, opts.showDiscarded);
if(!kids.length) return '';
+ /* Gemischte Gates (SPEC §3): Da `+` nur `optional` setzt und `type:'and'`
+ behält, schlägt das hier weiterhin genau dann an, wenn `|` mit `-`/`+`
+ gemischt wird — `-` neben `+` ist erlaubt und still. */
const types = new Set(kids.map(k => k.type));
if(types.size > 1){
/* strukturierte Warnung (Typ + Zeile); Formatierung in warnings.js */
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 76e56dc..b2a93e4 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -631,6 +631,30 @@
font-weight:400;
}
+ /* --- OPTIONAL (`+`, SPEC §3/D29): hohler Kreis am Abzweig ---
+ Kein dritter Linienstil: gestrichelt gehört any-of, und im kompakten Modus
+ codiert allein der Stil das Gate (D15). Stattdessen die FODA-Konvention —
+ gefüllter Punkt = erforderlich (hier: gar keiner), hohler Punkt = optional.
+ Der Kreis sitzt MITTIG AUF der Knotenkante, wo der Abzweig auftrifft.
+ Grundfall ist die gestapelte Anordnung (vertikal, kompakt, unter any-of):
+ Abzweig kommt von links auf halber Knotenhöhe. */
+ .node.opt::before{
+ content:'';position:absolute;
+ left:-5px;top:50%;transform:translateY(-50%);
+ width:10px;height:10px;border-radius:50%;
+ background:var(--card);border:2px solid var(--line);
+ box-sizing:border-box;
+ }
+ /* Horizontaler Fächer: der Abzweig kommt von oben mittig. */
+ .tree:not(.vertical):not(.kompakt) ul.and>li>.node.opt::before{
+ left:50%;top:-5px;transform:translateX(-50%);
+ }
+ /* …ausgenommen der gestapelte all-of-Teilbaum unter einer any-of-Gruppe
+ (siehe oben): dort kommt der Abzweig wieder von links. */
+ .tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li>.node.opt::before{
+ left:-5px;top:50%;transform:translateY(-50%);
+ }
+
/* ---------- Status (Pastell) ---------- */
.node.st-idee {background:#EBEDEF;border-color:#A2ABB5;color:var(--ink)}
.node.st-geplant {background:#EBE4F6;border-color:#A991D4;color:var(--ink)}
diff --git a/frontend/tests/__snapshots__/render.test.js.snap b/frontend/tests/__snapshots__/render.test.js.snap
index 39218d2..ff1a780 100644
--- a/frontend/tests/__snapshots__/render.test.js.snap
+++ b/frontend/tests/__snapshots__/render.test.js.snap
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"