diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index a2f625c..f2f6b0e 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -850,3 +850,57 @@ Rückfallwert 50 % hält alle übrigen Zellen ohne Messung richtig, und die
transponierten Modi setzen `left`/`right` ohnehin fest und bleiben unberührt.
Gemessen wird — wie in `drawCheapPath()` — durch `zoom` zurückgerechnet, sodass
der Wert beim Zoomen gültig bleibt.
+
+**Nachtrag 3 — Treppe für mehrere optionale Endknoten.** Im horizontalen Fächer
+kostet jedes optionale Geschwister eine eigene Spalte — Breite für gerade das,
+was am entbehrlichsten ist. Aufeinanderfolgende optionale Endknoten werden
+deshalb als **Kaskade** gestapelt (Nutzerwunsch: „mehrere optionale Knoten in
+einer diagonalen Linie an eine Anschlussstelle").
+
+Verworfen wurde die naheliegendere **senkrechte Spalte** unter einem
+Anschlusspunkt: Sie wäre schmaler und in einem Bruchteil der Zeit gebaut, sähe
+aber fast genau aus wie eine **any-of-Gruppe** (gestapelte Spalte an
+gestrichelter Leiste), unterschieden nur durch Tinte statt Grau. Genau diese
+Verwechslung zu vermeiden ist der Zweck von Kreis und Farbgebung. Die Treppe
+kauft Eindeutigkeit für etwas Breite — schon die Form ist eine andere.
+
+Ebenfalls verworfen: eine **echte** Diagonale. Rahmenkanten sind achsenparallel;
+sie bräuchte die SVG-Ebene (die es für den Pfad-Spline gibt) und wäre damit eine
+**zweite Zeichenebene** neben allen anderen Linien, nachzuführen bei jedem
+Rendern, Moduswechsel und Zoom. Der gestufte Anschluss aus rechten Winkeln gibt
+denselben Kaskaden-Eindruck im vorhandenen Mechanismus.
+
+**Nur Endknoten.** Der Platzgewinn entsteht gerade daraus, dass kein Teilbaum
+mitgestapelt werden muss — ein optionaler Knoten *mit* Kindern spart in der
+Treppe nichts und behält seine Spalte. Technisch kommt dasselbe heraus: Die
+Stufengeometrie rechnet mit einem festen Abstand von Knotenunterkante zur
+nächsten Stufe und setzt deshalb voraus, dass die Zelle so hoch ist wie ihr
+Knoten (kein Teilbaum, kein Geister-Knoten). Geprüft wird genau das: das `
`
+hat exakt ein Element-Kind, und das ist der Knoten.
+
+**Gruppiert wird in app.js, nicht im Renderer.** Die Gruppierung
+(`li.opt-group > ul.opt-stair`, Stufennummer als `--i`) ist reine Darstellung.
+Im Renderer hätte sie eine DOM-Ebene erzeugt, die es semantisch nicht gibt —
+und die in den **drei übrigen Anordnungen** (vertikal, kompakt, all-of unter
+any-of) wieder hätte neutralisiert werden müssen, jede mit hand-getunter
+Geometrie. `display:contents` löst das nicht: Es richtet die Boxen, aber die
+`>`-Selektoren jener Regeln greifen weiter auf dem DOM und passen dann nicht
+mehr. `applyOptStairs()` baut die Gruppe deshalb nur im Fächer und löst sie beim
+Moduswechsel wieder auf — dieselbe Kategorie wie `alignStems()` und
+`drawCheapPath()`, und SPEC §9 („der Modus ändert nur die Anordnung") bleibt für
+den **Renderer** wörtlich wahr. Lese- und Fokusreihenfolge bleiben unberührt,
+weil nur umgehängt und nichts umsortiert wird.
+
+**Der Export folgt der Kaskade.** Erste Fassung reihte alle Stufen flach als
+Kinder ein und ließ den Export selbst routen (er zieht Linien ohnehin unabhängig
+vom Darstellungsmodus neu). Ergebnis: Die Linie zur dritten Stufe lief **hinter
+der zweiten hindurch** und las sich wie eine Eltern-Kind-Beziehung — keine
+Schönheitsfrage, sondern eine falsche Aussage über die Struktur. Jetzt hängt nur
+die **erste** Stufe an der Sammelleiste, die übrigen bekommen denselben Winkel
+wie am Bildschirm.
+
+**Nicht durch Tests gedeckt:** `applyOptStairs()` arbeitet auf dem DOM, und für
+`app.js` gibt es keine Testumgebung (die Vitest-Suite prüft die headless-Module).
+Geprüft wurde im Browser — auch der Moduswechsel hin und zurück, mehrfach: Die
+Gruppe entsteht und löst sich rückstandsfrei auf, Knotenzahl und Dokumentordnung
+bleiben in allen drei Modi gleich.
diff --git a/docs/SPEC.md b/docs/SPEC.md
index eeef18e..3c18bb8 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -153,6 +153,19 @@ Siehe D29.
- **all of:** Kinder nebeneinander, klassischer Organigramm-Fächer.
- **any of:** Alternativen untereinander; gestrichelte graue Sammelleiste links
unterhalb des Parents, gestrichelte graue Abzweige zu den Alternativen.
+- **Treppe für optionale Endknoten:** Stehen **zwei oder mehr** optionale
+ **Endknoten** (`+`, §3) unmittelbar nebeneinander, bekommen sie nicht je eine
+ eigene Spalte, sondern werden als **Kaskade** gestapelt: Die erste Stufe hängt
+ am Stiel von oben wie ein gewöhnliches Kind, jede weitere steht eine Stufe
+ tiefer und weiter rechts und hängt an einem gestrichelten Winkel, der an der
+ linken Kante der vorigen Stufe herabfällt und waagerecht in ihren Kreis
+ einbiegt. Das spart Breite genau dort, wo das Entbehrlichste steht.
+ Es bleiben **Geschwister** — die Treppe ist eine Anordnung, keine Ebene, und
+ ändert weder Lese-/Fokusreihenfolge noch `aria-label`. Nur Endknoten, weil der
+ Platzgewinn gerade daher rührt, dass kein Teilbaum mitgestapelt wird; ein
+ optionaler Knoten **mit** Kindern behält seine Spalte. Die transponierten Modi
+ kennen die Treppe nicht — dort stehen die Kinder ohnehin untereinander. Der
+ Grafikexport folgt der Kaskade.
### Vertikal (transponiert)
- **all of:** exakter transponierter Organigramm-Fächer (horizontal um 90°
diff --git a/docs/examples/example-werkbaum.werkbaum b/docs/examples/example-werkbaum.werkbaum
index 1787275..889b58c 100644
--- a/docs/examples/example-werkbaum.werkbaum
+++ b/docs/examples/example-werkbaum.werkbaum
@@ -37,8 +37,8 @@
- [^] 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)
- [ ] Open and save .werkbaum files (S)
+ + [^] Show what is new since your last visit (S)
+ [?] A pastel colour per person (S)
- [?] Dates and milestones (M)
| [?] An attribute in the line (S)
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index 7588bb8..42e4a90 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -197,6 +197,18 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
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 „``
+ 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 ``. 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
diff --git a/frontend/src/app.js b/frontend/src/app.js
index 1ae7bc7..837db2c 100644
--- a/frontend/src/app.js
+++ b/frontend/src/app.js
@@ -84,6 +84,7 @@ function render(){
warnings = warnings.slice().sort((a, b) => (a.line || 0) - (b.line || 0));
warnBox.innerHTML = warnings.map(w => `⚠ ${formatWarning(w, t)}
`).join('');
+ applyOptStairs(); /* muss vor dem Messen laufen — es verschiebt Knoten */
alignStems();
drawCheapPath();
/* Der Baum ist neu gebaut — die Markierung der Cursor-Zeile neu setzen (D25).
@@ -120,6 +121,56 @@ function overlaySvg(cls, w, h){
return svgEl('svg', {class:'cheap-overlay ' + cls, width:w, height:h,
viewBox:`0 0 ${w.toFixed(1)} ${h.toFixed(1)}`});
}
+/* ---------- Treppe für optionale Endknoten (D29, Nachtrag 3) ----------
+ Im horizontalen Fächer kostet jedes optionale Geschwister eine eigene Spalte
+ — Breite für gerade das, was am wenigsten wichtig ist. Aufeinanderfolgende
+ optionale Endknoten werden deshalb zu einer Kaskade gestapelt, die an einem
+ Punkt hängt.
+
+ Warum hier und nicht im Renderer: Die Gruppierung ist reine Darstellung. So
+ bleiben der Renderer-String (SPEC §9: der Modus ändert nur die Anordnung) und
+ die hand-getunte Geometrie der drei übrigen Anordnungen unberührt — dort gibt
+ es das Platzproblem gar nicht, die Kinder stehen ohnehin untereinander.
+
+ Nur ENDknoten: Der Platzgewinn entsteht gerade daraus, dass kein Teilbaum
+ mitgestapelt werden muss; außerdem setzt die Stufengeometrie voraus, dass die
+ Zelle so hoch ist wie ihr Knoten (kein Teilbaum, kein Geister-Knoten). */
+const STAIR_MIN = 2;
+function applyOptStairs(){
+ /* Erst auflösen: applyLayout() arbeitet auf einem bereits gruppierten Baum. */
+ out.querySelectorAll('li.opt-group').forEach(group => {
+ const stair = group.firstElementChild;
+ while(stair.firstElementChild){
+ const li = stair.firstElementChild;
+ li.style.removeProperty('--i');
+ group.parentNode.insertBefore(li, group);
+ }
+ group.remove();
+ });
+ if(out.classList.contains('vertical') || out.classList.contains('kompakt')) return;
+ out.querySelectorAll('ul.and').forEach(ul => {
+ let run = [];
+ const flush = () => {
+ if(run.length >= STAIR_MIN){
+ const group = document.createElement('li');
+ group.className = 'opt-group';
+ const stair = document.createElement('ul');
+ stair.className = 'opt-stair';
+ ul.insertBefore(group, run[0]);
+ group.appendChild(stair);
+ run.forEach((li, i) => { li.style.setProperty('--i', i); stair.appendChild(li); });
+ }
+ run = [];
+ };
+ for(const li of [...ul.children]){
+ const leaf = li.classList.contains('opt') && li.children.length === 1
+ && li.firstElementChild.classList.contains('node');
+ if(leaf) run.push(li); else flush();
+ }
+ flush();
+ });
+}
+
/* Stielposition der all-of-Abzweige im horizontalen Fächer (siehe style.css).
Nur `li.has-or` braucht die Messung: dort steht der Knoten linksbündig, das
ist aber so breit wie sein any-of-Teilbaum — der Stiel bei 50 % liefe am
@@ -130,8 +181,10 @@ function alignStems(){
out.querySelectorAll('ul.and>li').forEach(li => li.style.removeProperty('--stem-x'));
if(out.classList.contains('vertical') || out.classList.contains('kompakt')) return;
const z = zoom || 1;
- out.querySelectorAll('ul.and>li.has-or').forEach(li => {
- const node = li.querySelector(':scope > .node');
+ /* `li.opt-group` (Treppe) hat keinen eigenen Knoten — der Stiel zielt auf den
+ ERSTEN Knoten der Kaskade. */
+ out.querySelectorAll('ul.and>li.has-or, ul.and>li.opt-group').forEach(li => {
+ const node = li.querySelector(':scope > .node') || li.querySelector('.node');
if(!node) return;
const lr = li.getBoundingClientRect(), nr = node.getBoundingClientRect();
if(!lr.width) return; /* Panel eingeklappt */
@@ -206,8 +259,23 @@ function diagramToSvg(){
const gate = childUl.classList.contains('or') ? 'or' : 'and';
const stroke = gate === 'or' ? '#6B7A8C' : '#41556E';
const dash = gate === 'or';
- const kidEls = [...childUl.children]
- .map(cli => cli.querySelector(':scope > .node, :scope > a.node')).filter(Boolean);
+ /* Die Treppe (D29) ist eine Anordnung, keine Ebene: alle Stufen sind Kinder
+ DIESES Elternknotens. An die Sammelleiste kommt nur die erste Stufe; die
+ übrigen hängen an der Kaskade und werden unten nachgezogen. Zöge man jede
+ Stufe einzeln an die Leiste, liefe die Linie zur dritten Stufe hinter der
+ zweiten hindurch — und läse sich wie eine Eltern-Kind-Beziehung. */
+ const kidEls = [], stairs = [];
+ for(const cli of childUl.children){
+ if(cli.classList.contains('opt-group')){
+ const st = [...cli.querySelectorAll(':scope > ul.opt-stair > li > .node')];
+ if(!st.length) continue;
+ kidEls.push(st[0]);
+ if(st.length > 1) stairs.push(st);
+ } else {
+ const n = cli.querySelector(':scope > .node');
+ if(n) kidEls.push(n);
+ }
+ }
const kids = kidEls.map(R);
if(!kids.length) return;
const isOpt = i => kidEls[i].classList.contains('opt');
@@ -239,6 +307,16 @@ function diagramToSvg(){
if(o) optMarks.push({x: k.cx, y});
});
}
+ /* Kaskade ab der zweiten Stufe: an der linken Kante der vorigen Stufe
+ herab, dann waagerecht in die eigene — dieselbe Führung wie am Bildschirm. */
+ stairs.forEach(st => {
+ for(let j = 1; j < st.length; j++){
+ const a = R(st[j-1]), b = R(st[j]);
+ parts.push(seg(a.x, a.b, a.x, b.cy, stroke, true));
+ parts.push(seg(a.x, b.cy, b.x, b.cy, stroke, true));
+ optMarks.push({x: b.x, y: b.cy});
+ }
+ });
});
/* 1b) Günstigster-Pfad: kräftige Linie hinter den Knoten */
@@ -552,6 +630,7 @@ function applyLayout(mode){
out.classList.toggle('kompakt', mode === 'kompakt');
app.classList.toggle('side', mode !== 'horizontal');
if(!isMobile()) applySplit(); /* Desktop: Preset neu setzen. Mobil: freie --drow-Aufteilung behalten */
+ applyOptStairs(); /* Treppe gilt nur im Fächer — beim Moduswechsel bauen/auflösen */
alignStems(); /* Stiel gilt nur im Fächer — beim Moduswechsel neu setzen/löschen */
drawCheapPath(); /* Blatt-Positionen ändern sich mit dem Modus */
}
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 8395e04..14e319a 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -678,6 +678,46 @@
left:-5px;top:50%;transform:translateY(-50%);
}
+ /* --- TREPPE: aufeinanderfolgende optionale Endknoten (D29, Nachtrag 3) ---
+ Nur im horizontalen Fächer, wo jedes Geschwister sonst eine eigene Spalte
+ kostet. `applyOptStairs()` in app.js gruppiert sie in
+ `li.opt-group > ul.opt-stair` und nummeriert die Stufen als `--i` (0-basiert).
+ Geometrie: Stufe 0 hängt am Stiel von oben wie ein gewöhnliches Kind
+ (`--stem-x` zielt auf sie). Jede weitere Stufe steht `--stair-step` weiter
+ rechts und hängt an einem gestrichelten Winkel, der bündig an der linken
+ Kante der VORIGEN Stufe herabfällt und waagerecht in den eigenen Kreis
+ einbiegt. Das setzt voraus, dass die Zelle so hoch ist wie ihr Knoten —
+ deshalb nur Endknoten. */
+ .tree:not(.vertical):not(.kompakt) ul.opt-stair{
+ --stair-step:26px; --stair-gap:12px;
+ display:flex;flex-direction:column;align-items:flex-start;position:relative;
+ }
+ .tree:not(.vertical):not(.kompakt) ul.opt-stair>li{
+ align-items:flex-start;
+ padding:var(--stair-gap) 0 0 calc(var(--i) * var(--stair-step));
+ }
+ .tree:not(.vertical):not(.kompakt) ul.opt-stair>li:first-child{padding-top:0}
+ /* Winkel zur vorigen Stufe: senkrecht herab, dann waagerecht in den Knoten.
+ Der Knick liegt auf der linken Kante der vorigen Stufe. */
+ .tree:not(.vertical):not(.kompakt) ul.opt-stair>li+li::before,
+ .tree:not(.vertical):not(.kompakt) ul.opt-stair>li+li::after{
+ content:'';position:absolute;
+ left:calc((var(--i) - 1) * var(--stair-step));
+ }
+ .tree:not(.vertical):not(.kompakt) ul.opt-stair>li+li::after{
+ top:0;height:calc(var(--stair-gap) + 16px);
+ border-left:2px dashed var(--line);
+ }
+ .tree:not(.vertical):not(.kompakt) ul.opt-stair>li+li::before{
+ top:calc(var(--stair-gap) + 16px);width:var(--stair-step);
+ border-top:2px dashed var(--line);
+ }
+ /* Stufe 0 wird von oben getroffen (Kreis oben mittig), alle weiteren von
+ links — das ist bereits der Grundfall von `.node.opt::before`. */
+ .tree:not(.vertical):not(.kompakt) ul.opt-stair>li:first-child>.node.opt::before{
+ left:50%;top:-5px;transform:translateX(-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)}