diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index 72a5e92..b8ab4e8 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -1771,3 +1771,66 @@ auch die Selbst-Abhängigkeit ist stumm.
dieselbe Zurückhaltung wie bei den IDs (D36): keine eigene Diagramm-
Darstellung, bis die Querverbindungen (§11) gebaut sind; der Pfeil im Tooltip
sagt die Richtung („hängt ab von“), ohne ein neues Zeichen einzuführen.
+
+## D38 — Faltmarken gebaut: `<` wandert die Faltung hinunter, Eingriffe sind flüchtig
+Die in D34 entschiedene Schreibweise (`- > [x] …`, Export folgt der Faltung)
+ist umgesetzt (SPEC §1/§9). Die Bau-Entscheidungen:
+
+**`<` holt seinen Teilbaum hervor, indem die Faltung die Pfad-Ebenen
+hinunterwandert.** Die naive Lesart — `<` öffnet einfach alle Vorfahren —
+machte das `>` am Vorfahren wirkungslos: Öffnen zeigt **alle** Kinder samt
+Teilbäumen, nicht den einen gemeinten. Die andere naheliegende Lesart — nur
+den Pfad einblenden, alles andere desselben Vorfahren verbergen — bräuchte
+Knoten, die ohne ihre sichtbaren Eltern gezeichnet werden, oder Kanten, die
+Ebenen überspringen: Beides log über die Struktur (dieselbe Sorte Fehler, die
+D29 beim Treppen-Export korrigiert hat). Das Hinunterwandern vermeidet beides:
+Der eingeklappte Vorfahr öffnet sich, seine **übrigen** Kinder werden
+stattdessen eingeklappt — sichtbar ist der Pfad samt Teilbaum, die Geschwister
+stehen als je ein Knoten mit „▸ n“ da, und jede gezeichnete Kante ist eine
+echte. Ein `>` innerhalb des hervorgeholten Teilbaums bleibt respektiert.
+Ausdrückbar ist das alles in einem schlichten Je-Knoten-Zustand
+(`initialCollapsed()` in model.js, headless getestet).
+
+**Auch die Fokusmarke `!!!` holt sich hervor** — über denselben Mechanismus
+(sie geht als zweite Rettungsmarke in `initialCollapsed()` ein). Ein
+Zeigefinger auf etwas Unsichtbares zeigte ins Leere; und weil Nutzer-Eingriffe
+den Anfangszustand **überlagern**, kann man den Bereich danach trotzdem wieder
+zuklappen — die Marke reißt ihn nicht bei jedem Neubau wieder auf.
+
+**Interaktive Eingriffe: je Knoten, Label-Pfad-Identität, nur für die
+Sitzung.** Der Baum wird bei jedem Tastendruck neu geparst — ein Eingriff muss
+Neu-Renderings überleben, also hängt er nicht am Knotenobjekt, sondern am
+**Label-Pfad** (dieselbe Identität wie „Was ist neu?“, D28: Umsortieren
+überlebt, Umbenennen gilt als neuer Knoten). **Nicht persistiert**: Die
+dauerhafte Aussage über den Anfangszustand steht im Text (D34 — „so wird
+dieses Dokument eröffnet“); der localStorage-Ansichtszustand ist zudem global
+über alle Dokumente (D22), ein Je-Dokument-Faltzustand wäre dort ein
+Fremdkörper. Dokumentwechsel setzt zurück.
+
+**Bedienung: Falt-Zeichen ▾/„▸ n“ vor dem Label, Pfeiltasten ←/→.** Der
+einfache Klick ist der Link (§6), Alt+Klick der Sprung (D25), der lange Druck
+ebenso — für das Falten blieb nur ein eigenes Klickziel oder eine weitere
+Modifier-Geste; das sichtbare Zeichen ist zugleich die Auffindbarkeit (die
+Lehre aus D25: eine Geste, die niemand sieht, ist keine Funktion). ←/→ am
+fokussierten Knoten ist das WAI-ARIA-Baum-Idiom; Enter bleibt dem Link,
+Alt+Enter dem Sprung. Der Zähler nennt **alle** verborgenen Knoten (nicht nur
+die direkten Kinder) — er sagt, wie viel Plan dort zusammengefaltet liegt.
+Nach dem Umklappen wird der Fokus auf denselben Knoten zurückgesetzt (der
+Neubau hätte ihn sonst verschluckt und die Tastaturbedienung abgerissen).
+
+**Faltung ist reine Ansicht — mit zwei bewussten Konsequenzen.** Warnungen
+aus eingeklappten Teilbäumen werden weiter gemeldet: `renderChildren()`
+überspringt nur das HTML, ein eigener Lauf (`walkFolded`) sammelt Warnungen
+und zählt zugleich die verborgenen Knoten — sonst verschwände eine
+`mixedGate`-Meldung je nach Faltzustand, obwohl sie den **Text** betrifft.
+Und der günstigste Pfad rechnet unverändert über den ganzen Baum; seine
+Spline-Linie läuft ohnehin nur durch DOM-Knoten, führt also durch die
+sichtbaren Endknoten. Ein eingeklappter Zweig kann Pfadknoten verbergen —
+hinnehmbar, die Inversion an den sichtbaren Knoten bleibt richtig.
+
+**Export und Druck:** Verborgene Kinder stehen nicht im DOM — Export,
+Stiel-Messung, Treppe und Pfadlinie sind damit ohne Zusatzcode konsistent
+(derselbe Grund, aus dem der Renderer sie gar nicht erst erzeugt, statt sie
+per CSS zu verstecken). Die Kennzeichnung „▸ n“ ist Teil des Knotentexts und
+wandert von selbst in den SVG-Export; das ▾ offener Knoten wird dort und im
+Druck entfernt — es ist Bedienelement, keine Aussage über den Plan.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index dd7fb6b..5a0b5bc 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -164,6 +164,7 @@ Pfad, der die Wahrheit sagt.
Anfangszustand — im Diagramm wird danach unabhängig gefaltet. Schreibweise
und Export-Verhalten sind entschieden (D34-Nachtrag): `- > [x] …`,
Export/Druck folgen der sichtbaren Faltung mit Kennzeichnung.
+ **Umgesetzt** (SPEC §1/§9, D38).
Der gestaffelte „günstigste Pfad" aus *Kleinere Ideen* (Status-Bewusstsein,
Ausbaustufen) baut hierauf auf: Er braucht denselben Umbau der Kostenrechnung
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 48e7f32..eccc482 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -7,15 +7,16 @@ Syntaxänderungen werden zuerst hier dokumentiert, dann implementiert.
## 1. Zeilenformat
```
-[Einrückung][Zeichen] [Statusbox] Label (Größe) URL @tag … !!! %% Kommentar
+[Einrückung][Zeichen] [Faltmarke] [Statusbox] Label (Größe) URL @tag … !!! %% Kommentar
```
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; `=` nur mit folgendem Leerraum (§3).
+2. Einrückung, Zeichen (`-` / `+` / `|` / `=`), Faltmarke (`>` / `<`) und
+ Statusbox `[…]` per Zeilen-Regex; `=` nur mit folgendem Leerraum (§3),
+ die Faltmarke ebenso (siehe unten).
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.
@@ -24,6 +25,19 @@ dieser Reihenfolge (wichtig für Kollisionsfreiheit):
8. Fokusmarke: `!!!` als **alleinstehendes** Token (siehe unten).
9. Rest, whitespace-normalisiert = Label. Leeres Label ⇒ Zeile ignorieren.
+**Faltmarke `>` / `<`** — bestimmt, wie das Dokument **eröffnet** wird:
+
+- Steht **zwischen** Zerlegungszeichen und Statusbox (`- > [x] Backend`); bei
+ Wurzelknoten (ohne Zeichen) am Zeilenanfang. Erkannt nur mit **folgendem
+ Leerraum** — `- >Achtung` bleibt damit ein Label. Begründung der Stellung:
+ D34-Nachtrag.
+- `>` heißt: der Teilbaum dieses Knotens ist beim Öffnen **eingeklappt**.
+- `<` innerhalb eines eingeklappten Bereichs holt den **eigenen Teilbaum**
+ gezielt wieder hervor (Mechanik: §9).
+- Die Marken bestimmen nur den **Anfangszustand** — im Diagramm wird danach
+ unabhängig vom Text gefaltet (§9). Sie sagen nichts über Fortschritt (§4)
+ oder Notwendigkeit (§3) und ändern weder Kosten noch Warnungen.
+
**Knoten-ID `#name`** — benennt einen Knoten im **ganzen Dokument** eindeutig;
sie ist die Adresse für Abhängigkeiten und Beschreibungsblöcke (§11).
@@ -83,7 +97,7 @@ hat etwas, das ein Cursor nicht hat: **alle** sehen dieselbe Stelle.
Referenz-Regex der Implementierung:
```
-^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:\[([ ?~xX^/-])\]\s*)?(.*)$
+^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:([><])(?=[ \t])\s*)?(?:\[([ ?~xX^/-])\]\s*)?(.*)$
```
Für die Knoten-ID (Schritt 6, nur der erste Treffer):
@@ -508,6 +522,36 @@ gehört und lesbar bleibt. Kein Blinken.
- Wie die Cursor-Zeile erscheint sie **nicht** im Druck und **nicht** im
Grafikexport: Sie sagt „schau jetzt hierhin", nicht „so ist der Plan".
+### Ein- und ausklappbare Teilbäume (`>` / `<`, §1)
+Jeder Knoten mit sichtbaren Kindern trägt ein kleines **Falt-Zeichen** vor dem
+Label: **▾** offen, **„▸ n“** eingeklappt (n = Zahl der verborgenen Knoten).
+Klick auf das Zeichen klappt um — der einfache Klick auf den Knoten selbst
+bleibt der Link (§6); Tastatur: **←** klappt zu, **→** klappt auf am
+fokussierten Knoten (WAI-ARIA-Baum-Idiom).
+
+- **Anfangszustand aus dem Text (§1):** `>` klappt ein. `<` holt seinen
+ Teilbaum hervor, indem die Faltung die Pfad-Ebenen **hinunterwandert**:
+ Jeder eingeklappte Vorfahr öffnet sich, seine übrigen Kinder stehen
+ stattdessen als einzelne eingeklappte Knoten da — sichtbar ist genau der
+ Pfad samt Teilbaum, der Rest bleibt kompakt. Ein `>` **innerhalb** des
+ hervorgeholten Teilbaums bleibt respektiert.
+- Auch ein **`!!!`-markierter Knoten** (§1) holt sich auf diese Weise hervor —
+ ein Zeigefinger auf etwas Unsichtbares zeigte ins Leere.
+- Der interaktive Eingriff gilt **je Knoten** (Identität = Label-Pfad, wie bei
+ „Was ist neu?“) und **für die Sitzung**; er wird nicht gespeichert — die
+ dauerhafte Aussage steht im Text (D34). Ein Dokumentwechsel setzt zurück.
+- Faltung ist **reine Ansicht**: Warnungen aus eingeklappten Teilbäumen werden
+ weiter gemeldet (sie gelten dem Text), und der günstigste Pfad rechnet
+ unverändert über den ganzen Baum — seine Linie zeigt nur die sichtbaren
+ Endknoten.
+- **Export und Druck folgen der sichtbaren Faltung** (dieselbe Regel wie beim
+ „verworfene einblenden“-Filter): Verborgene Teilbäume fehlen, eingeklappte
+ Knoten behalten die Kennzeichnung „▸ n“ — das Bild behauptet damit keine
+ Vollständigkeit. Das ▾ offener Knoten ist ein Bedienelement und erscheint
+ weder im Export noch im Druck.
+
+Siehe D38.
+
### Grafikexport des Diagramms
Das Diagramm wird aus der Live-Geometrie in ein eigenständiges SVG (nur Formen
+ Text, keine externen Ressourcen) nachgezeichnet. Zwei Icon-Schaltflächen:
@@ -646,26 +690,6 @@ ist, damit der gewählte Knoten effektiv fertig werden kann. Gemeinsam
benötigte Abhängigkeiten werden dabei **nur einmal** gezählt. Genau das macht
die Rechnung schwerer als heute — siehe D34.
-### Ein- und ausklappbare Teilbäume (`>` / `<`)
-
-- `>` an einem Knoten heißt: **ab hier eingeklappt** — sein Teilbaum ist beim
- Öffnen des Dokuments zunächst verborgen.
-- `<` innerhalb eines eingeklappten Bereichs holt einen einzelnen Teilbaum
- gezielt wieder hervor.
-- Im Diagramm wird danach **unabhängig vom Text** interaktiv auf- und
- zugeklappt; die Marken im Text bestimmen nur den Anfangszustand.
-- **Stellung im Zeilenformat (entschieden):** **hinter** dem Zerlegungszeichen,
- vor der Statusbox — `- > [x] Backend`. Die Spalte der Zerlegungszeichen
- bleibt so bündig (an ihr liest das Auge die Hierarchie ab), und ein `>` am
- Zeilenanfang wäre die Markdown-Blockquote-Konvention. Wurzelknoten (ohne
- Zeichen) tragen die Marke am Zeilenanfang. Begründung: D34-Nachtrag.
-- **Grafikexport und Druck (entschieden):** folgen der **sichtbar
- eingeklappten** Struktur — dieselbe Regel wie beim „verworfene
- einblenden“-Filter (§9: „Es wird genau die sichtbare Struktur exportiert“).
- Damit das Bild nicht stillschweigend Vollständigkeit behauptet, wird ein
- eingeklappter Knoten **gekennzeichnet** (z. B. „▸“ oder die Anzahl der
- verborgenen Kinder; genaue Form wird beim Bauen festgelegt).
-
### Knotenbeschreibungen
Erläuternder Text zu einem Knoten, im Diagramm als Tooltip oder Pop-up.
diff --git a/docs/TASKS.md b/docs/TASKS.md
index 9f73be0..938fc28 100644
--- a/docs/TASKS.md
+++ b/docs/TASKS.md
@@ -91,10 +91,14 @@ entscheiden, **bevor** Code entsteht.
**benennen**.
- [ ] Querverbindungen zeichnen (eigene SVG-Ebene, optisch sekundär);
bei ausgewähltem Knoten ein-/ausgehende hervorheben.
-- [ ] Faltmarken `>` / `<` parsen; interaktives Auf-/Zuklappen im Diagramm.
+- [x] Faltmarken `>` / `<` parsen; interaktives Auf-/Zuklappen im Diagramm.
→ Schreibweise und Export-Verhalten entschieden (D34-Nachtrag): Marke
hinter dem Zerlegungszeichen (`- > [x] …`), Export/Druck folgen der
sichtbar eingeklappten Struktur mit Kennzeichnung am Knoten.
+ Umgesetzt (D38): `initialCollapsed()` (`<` wandert die Faltung die
+ Pfad-Ebenen hinunter, `!!!` holt sich mit hervor); Falt-Zeichen ▾/„▸ n"
+ + Tastatur ←/→; Eingriffe je Label-Pfad, nur für die Sitzung; Warnungen
+ aus eingeklappten Teilbäumen bleiben; `tests/fold.test.js`.
- [x] XOR-Gruppe (`=`) parsen (nur mit folgendem Leerraum, Mischung →
`mixedGate`), zwei realisierte Alternativen melden.
→ Zeichen entschieden: `=` statt `x` (D34-Nachtrag). Umgesetzt (D35):
diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum
index c58490f..66ea85b 100644
--- a/docs/examples/werkbaum.werkbaum
+++ b/docs/examples/werkbaum.werkbaum
@@ -60,9 +60,9 @@
- [^] Load a document from ?sourceUrl= (S)
- [^] Jump between diagram and text (S)
- [x] Line numbers in the text editor (XS) %% the warnings name them
- - [?] Collapsible subtrees (M)
- - [?] Initial state from the text: > collapses, < re-opens (S)
- - [?] Fold and unfold in the diagram, independently of the text (S)
+ - [x] Collapsible subtrees (M)
+ - [x] Initial state from the text: > collapses, < re-opens (S)
+ - [x] Fold and unfold in the diagram, independently of the text (S)
- [ ] Open and save .werkbaum files (S)
+ [^] Show what is new since your last visit (S)
+ [?] A pastel colour per person (S)
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index 93b7644..8eb82e9 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -267,6 +267,19 @@ 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`.
+- 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 ``) 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)
diff --git a/frontend/index.html b/frontend/index.html
index 3791225..54fc7c0 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -122,6 +122,7 @@
Personen mit @name — erscheinen unten rechts am Knoten.
Knoten-ID mit #name — erscheint im Tooltip des Knotens.
Abhängigkeiten mit :#name,#name — erscheinen im Tooltip.
+ Falten: - > [x] … startet eingeklappt, < holt hervor; ▾/▸ am Knoten klappt um (Tastatur: ←/→).
diff --git a/frontend/src/app.js b/frontend/src/app.js
index 64a5d9d..b771052 100644
--- a/frontend/src/app.js
+++ b/frontend/src/app.js
@@ -1,6 +1,6 @@
import './style.css';
import { parse } from './parser.js';
-import { computeCheapSet, freshProdSet } from './model.js';
+import { computeCheapSet, freshProdSet, initialCollapsed, nodeKeys } from './model.js';
import { esc, renderTreeHtml } from './render.js';
import { formatWarning } from './warnings.js';
import { padUrls } from './remote.js';
@@ -61,6 +61,12 @@ let sourceWarning = null;
Vergleichsfassung. `freshBaseline` ist der Text, gegen den verglichen wurde;
er wird erst beim Bestätigen fortgeschrieben. */
let freshSet = new Set(), freshDocId = null, freshBaseline = null, freshPrevRoots = null;
+/* Faltung (SPEC §9, D38): `foldOverrides` sind die interaktiven Eingriffe des
+ Nutzers (Schlüssel = Label-Pfad wie bei D28, damit sie das Neu-Parsen bei
+ jedem Tastendruck überleben); sie überlagern den Anfangszustand aus den
+ Textmarken, gelten nur für die Sitzung und fallen beim Dokumentwechsel weg.
+ `foldByLine` ist der Zustand des letzten Renders für Klick/Tastatur. */
+let foldOverrides = new Map(), foldByLine = new Map();
/* ---------- Renderer (Anbindung an den DOM) ----------
parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren ->
@@ -82,6 +88,7 @@ function render(){
if(!roots.length){
out.innerHTML = `${esc(t('empty'))}
`;
freshSet = new Set();
+ foldByLine = new Map();
} else {
const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set();
out.classList.toggle('cheap-on', cheapPathOn);
@@ -91,8 +98,21 @@ function render(){
Objektidentität nie zu (D28). */
freshSet = (freshDocId === activeId && freshPrevRoots)
? freshProdSet(freshPrevRoots, roots) : new Set();
+ /* Faltung (D38): Anfangszustand aus den Textmarken (`!!!` holt sich mit
+ hervor), überlagert von den Sitzungs-Eingriffen des Nutzers. Wie bei
+ `freshSet` muss die Menge aus den gerade geparsten Knoten bestehen. */
+ const initFold = initialCollapsed(roots, true);
+ const keys = nodeKeys(roots);
+ const collapsedSet = new Set();
+ foldByLine = new Map();
+ keys.forEach((key, n) => {
+ const ov = foldOverrides.get(key);
+ const collapsed = (ov !== undefined ? ov : initFold.has(n)) && n.children.length > 0;
+ if(collapsed) collapsedSet.add(n);
+ foldByLine.set(n.line, {key, collapsed, canFold: n.children.length > 0});
+ });
const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet,
- freshSet});
+ freshSet, collapsedSet});
out.innerHTML = r.html;
warnings = warnings.concat(r.warnings);
}
@@ -378,7 +398,11 @@ function diagramToSvg(){
const dashed = cs.borderTopStyle === 'dashed';
parts.push(``);
const clone = node.cloneNode(true);
- clone.querySelectorAll('.size,.tags,.ext,.risk').forEach(e => e.remove());
+ /* Das „▸ n"-Kennzeichen eingeklappter Knoten gehört in den Export (SPEC
+ §9/D38 — das Bild darf keine Vollständigkeit behaupten); das ▾ offener
+ Knoten ist Bedienelement und fällt weg. */
+ const stripFold = node.classList.contains('folded') ? '' : ',.fold';
+ clone.querySelectorAll('.size,.tags,.ext,.risk' + stripFold).forEach(e => e.remove());
const label = clone.textContent.replace(/\s+/g,' ').trim();
const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : '';
parts.push(`${esc(label)}`);
@@ -685,6 +709,29 @@ function nodeFromEvent(e){
return el && out.contains(el) ? el : null;
}
+/* Faltung umklappen (SPEC §9, D38). Der Eingriff überlagert den Anfangszustand
+ aus dem Text; nach dem Neubau bekommt derselbe Knoten den Fokus zurück,
+ sonst risse die Tastaturbedienung ab (das alte Element ist weg). */
+function toggleFold(el){
+ const st = foldByLine.get(+el.dataset.line);
+ if(!st || !st.canFold) return;
+ foldOverrides.set(st.key, !st.collapsed);
+ render();
+ const again = out.querySelector('.node[data-line="' + el.dataset.line + '"]');
+ if(again) again.focus({preventScroll: true});
+}
+
+/* Klick auf das Falt-Zeichen ▾/▸ klappt um. preventDefault, weil das Zeichen
+ bei Link-Knoten IM sitzt — sonst öffnete der Klick zusätzlich die URL. */
+out.addEventListener('click', e => {
+ const f = e.target && e.target.closest ? e.target.closest('.fold') : null;
+ if(!f || !out.contains(f) || e.altKey) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const el = f.closest('.node[data-line]');
+ if(el) toggleFold(el);
+});
+
out.addEventListener('click', e => {
if(!e.altKey) return;
const el = nodeFromEvent(e);
@@ -705,6 +752,20 @@ out.addEventListener('keydown', e => {
jumpToLine(+el.dataset.line);
});
+/* Tastatur-Faltung (SPEC §9, D38): ← klappt zu, → klappt auf — das
+ WAI-ARIA-Baum-Idiom. Nur ohne Modifier, damit nichts anderes kollidiert. */
+out.addEventListener('keydown', e => {
+ if(e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
+ if(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
+ const el = nodeFromEvent(e);
+ if(!el) return;
+ const st = foldByLine.get(+el.dataset.line);
+ if(!st || !st.canFold) return;
+ e.preventDefault();
+ const want = e.key === 'ArrowLeft';
+ if(st.collapsed !== want) toggleFold(el);
+});
+
/* Mobil gibt es kein Alt: langer Druck (500 ms) auf einen Knoten springt.
Der Sprung passiert erst beim LOSLASSEN: `focus()` aus einem Timer heraus
gilt in mobilen Browsern nicht als Nutzergeste — der Fokus fiel sofort wieder
@@ -1109,7 +1170,7 @@ const I18N = {
unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.",
sourceLoadWarn:"„{url}“ konnte nicht geladen werden ({error}). Die Datei muss per http(s) erreichbar sein und CORS erlauben (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"„{url}“ hat innerhalb von {seconds} s nicht geantwortet — der Abruf wurde abgebrochen. Etherpad begrenzt, wie oft der Export geholt werden darf (serienmäßig 10-mal pro 90 s); warte einen Moment und lade dann erneut.",
- a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt",
+ a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yFolded:"eingeklappt, {n} verborgen", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt",
hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.",
hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen",
hint_xor:"Alternative, genau eine",
@@ -1123,6 +1184,7 @@ const I18N = {
hint_people:"Personen mit @name — erscheinen unten rechts am Knoten.",
hint_id:"Knoten-ID mit #name — erscheint im Tooltip des Knotens.",
hint_deps:"Abhängigkeiten mit :#name,#name — erscheinen im Tooltip.",
+ hint_fold:"Falten: - > [x] … startet eingeklappt, < holt hervor; ▾/▸ am Knoten klappt um (Tastatur: ←/→).",
hint_jump:"Alt+Klick auf einen Knoten (mobil: langer Druck) springt zur zugehörigen Textzeile; Alt+Klick im Text holt den Knoten ins Bild."
},
en: {
@@ -1179,7 +1241,7 @@ const I18N = {
unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.",
sourceLoadWarn:"Could not load “{url}” ({error}). The file must be reachable via http(s) and allow CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"“{url}” did not answer within {seconds} s — the request was aborted. Etherpad limits how often the export may be fetched (10 times per 90 s by default); wait a moment, then reload.",
- a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link",
+ a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yFolded:"collapsed, {n} hidden", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link",
hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.",
hint_all:"sub-task, all required", hint_any:"alternative, choose one",
hint_xor:"alternative, exactly one",
@@ -1193,6 +1255,7 @@ const I18N = {
hint_people:"People with @name — shown at the bottom-right of the node.",
hint_id:"Node ID with #name — shown in the node's tooltip.",
hint_deps:"Dependencies with :#name,#name — shown in the tooltip.",
+ hint_fold:"Folding: - > [x] … starts collapsed, < brings it back; ▾/▸ on a node toggles (keyboard: ←/→).",
hint_jump:"Alt+click a node (long press on touch) jumps to its line in the text; Alt+click in the text brings the node into view."
},
es: {
@@ -1249,7 +1312,7 @@ const I18N = {
unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.",
sourceLoadWarn:"No se pudo cargar «{url}» ({error}). El archivo debe ser accesible por http(s) y permitir CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"«{url}» no respondió en {seconds} s — se canceló la petición. Etherpad limita la frecuencia de descarga del export (10 veces por 90 s de forma predeterminada); espera un momento y vuelve a cargar.",
- a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace",
+ a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yFolded:"plegado, {n} ocultos", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace",
hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.",
hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una",
hint_xor:"alternativa, exactamente una",
@@ -1263,6 +1326,7 @@ const I18N = {
hint_people:"Personas con @nombre — aparecen abajo a la derecha del nodo.",
hint_id:"ID de nodo con #nombre — visible en el tooltip del nodo.",
hint_deps:"Dependencias con :#nombre,#nombre — visibles en el tooltip.",
+ hint_fold:"Plegado: - > [x] … empieza plegado, < lo recupera; ▾/▸ en el nodo alterna (teclado: ←/→).",
hint_jump:"Alt+clic en un nodo (pulsación larga en táctil) salta a su línea en el texto; Alt+clic en el texto trae el nodo a la vista."
},
fr: {
@@ -1319,7 +1383,7 @@ const I18N = {
unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.",
sourceLoadWarn:"Impossible de charger « {url} » ({error}). Le fichier doit être accessible en http(s) et autoriser CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"« {url} » n’a pas répondu en {seconds} s — la requête a été interrompue. Etherpad limite la fréquence de récupération de l’export (10 fois par 90 s par défaut) ; attends un instant, puis recharge.",
- a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien",
+ a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yFolded:"replié, {n} masqués", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien",
hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.",
hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une",
hint_xor:"alternative, exactement une",
@@ -1333,6 +1397,7 @@ const I18N = {
hint_people:"Personnes avec @nom — affichées en bas à droite du nœud.",
hint_id:"ID de nœud avec #nom — visible dans l’infobulle du nœud.",
hint_deps:"Dépendances avec :#nom,#nom — visibles dans l’infobulle.",
+ hint_fold:"Pliage : - > [x] … démarre replié, < le fait ressortir ; ▾/▸ sur le nœud bascule (clavier : ←/→).",
hint_jump:"Alt+clic sur un nœud (appui long sur tactile) saute à sa ligne dans le texte ; Alt+clic dans le texte amène le nœud à l’écran."
},
pl: {
@@ -1389,7 +1454,7 @@ const I18N = {
unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.",
sourceLoadWarn:"Nie udało się wczytać „{url}” ({error}). Plik musi być dostępny przez http(s) i zezwalać na CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"„{url}” nie odpowiedział w ciągu {seconds} s — żądanie przerwano. Etherpad ogranicza częstość pobierania eksportu (domyślnie 10 razy na 90 s); odczekaj chwilę i wczytaj ponownie.",
- a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem",
+ a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yFolded:"zwinięte, ukrytych: {n}", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem",
hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.",
hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną",
hint_xor:"alternatywa, dokładnie jedna",
@@ -1403,6 +1468,7 @@ const I18N = {
hint_people:"Osoby z @nazwa — pokazywane w prawym dolnym rogu węzła.",
hint_id:"ID węzła przez #nazwa — widoczne w podpowiedzi węzła.",
hint_deps:"Zależności przez :#nazwa,#nazwa — widoczne w podpowiedzi.",
+ hint_fold:"Zwijanie: - > [x] … zaczyna zwinięte, < przywraca; ▾/▸ na węźle przełącza (klawiatura: ←/→).",
hint_jump:"Alt+kliknięcie węzła (długie naciśnięcie na dotyku) przechodzi do jego wiersza w tekście; Alt+kliknięcie w tekście pokazuje węzeł na diagramie."
},
ru: {
@@ -1459,7 +1525,7 @@ const I18N = {
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"«{url}» не ответил за {seconds} с — запрос прерван. Etherpad ограничивает частоту загрузки экспорта (по умолчанию 10 раз за 90 с); подождите немного и обновите снова.",
- a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой",
+ a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yFolded:"свёрнуто, скрыто: {n}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой",
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
hint_xor:"альтернатива, ровно одна",
@@ -1473,6 +1539,7 @@ const I18N = {
hint_people:"Люди через @имя — показываются справа внизу узла.",
hint_id:"ID узла через #имя — виден во всплывающей подсказке узла.",
hint_deps:"Зависимости через :#имя,#имя — видны в подсказке.",
+ hint_fold:"Сворачивание: - > [x] … открывается свёрнутым, < возвращает; ▾/▸ на узле переключает (клавиши: ←/→).",
hint_jump:"Alt+клик по узлу (долгое нажатие на сенсоре) переходит к его строке в тексте; Alt+клик в тексте показывает узел на диаграмме."
},
hi: {
@@ -1529,7 +1596,7 @@ const I18N = {
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।",
sourceTimeoutWarn:"„{url}“ ने {seconds} स॰ में उत्तर नहीं दिया — अनुरोध रद्द कर दिया गया। Etherpad सीमित करता है कि एक्सपोर्ट कितनी बार लिया जा सके (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार); कुछ क्षण रुकें, फिर दोबारा लोड करें।",
- a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित",
+ a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित",
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
hint_xor:"विकल्प, ठीक एक",
@@ -1543,6 +1610,7 @@ const I18N = {
hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।",
hint_id:"#नाम से नोड आईडी — नोड के टूलटिप में दिखती है।",
hint_deps:":#नाम,#नाम से निर्भरताएँ — टूलटिप में दिखती हैं।",
+ hint_fold:"फ़ोल्डिंग: - > [x] … समेटा हुआ खुलता है, < वापस लाता है; नोड पर ▾/▸ टॉगल करता है (कीबोर्ड: ←/→)।",
hint_jump:"किसी नोड पर Alt+क्लिक (टच पर लंबा दबाव) टेक्स्ट में उसकी पंक्ति पर ले जाता है; टेक्स्ट में Alt+क्लिक उस नोड को आरेख में दिखाता है।"
},
zh: {
@@ -1599,7 +1667,7 @@ const I18N = {
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORS(Access-Control-Allow-Origin)。",
sourceTimeoutWarn:"“{url}” 在 {seconds} 秒内没有响应 — 请求已中止。Etherpad 会限制导出的获取频率(默认每 90 秒 10 次);请稍候再重新加载。",
- a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yId:"ID:#{id}", a11yDeps:"依赖:{ids}", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接",
+ a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yId:"ID:#{id}", a11yDeps:"依赖:{ids}", a11yFolded:"已折叠,隐藏 {n} 项", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接",
hint_indent:"缩进(2 个空格或制表符)定义层级。",
hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
hint_xor:"备选项,恰好一个",
@@ -1613,6 +1681,7 @@ const I18N = {
hint_people:"用 @姓名 表示人员——显示在节点右下角。",
hint_id:"用 #名称 指定节点 ID——显示在节点提示中。",
hint_deps:"用 :#名称,#名称 表示依赖——显示在提示中。",
+ hint_fold:"折叠:- > [x] … 打开时即折叠,< 将其展开;节点上的 ▾/▸ 切换(键盘:←/→)。",
hint_jump:"Alt+点击节点(触摸屏为长按)可跳转到文本中对应的行;在文本中 Alt+点击则把该节点带入视野。"
},
ja: {
@@ -1669,7 +1738,7 @@ const I18N = {
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORS(Access-Control-Allow-Origin)を許可する必要があります。",
sourceTimeoutWarn:"「{url}」が {seconds} 秒以内に応答しませんでした — 要求を中止しました。Etherpad はエクスポートの取得回数を制限します(既定で 90 秒あたり 10 回)。少し待ってから再読み込みしてください。",
- a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり",
+ a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり",
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
hint_xor:"選択肢、ちょうど1つ",
@@ -1683,6 +1752,7 @@ const I18N = {
hint_people:"@名前 で担当者 — ノードの右下に表示されます。",
hint_id:"#名前 でノード ID — ノードのツールチップに表示されます。",
hint_deps:":#名前,#名前 で依存関係 — ツールチップに表示されます。",
+ hint_fold:"折りたたみ:- > [x] … は折りたたんだ状態で開き、< は呼び戻します。ノードの ▾/▸ で切替(キー:←/→)。",
hint_jump:"ノードを Alt+クリック(タッチでは長押し)すると、テキストの該当行へ移動します。テキスト内で Alt+クリックすると、そのノードが図の中央に表示されます。"
}
};
@@ -1717,6 +1787,7 @@ function buildHint(){
${esc(t('hint_people'))}
${esc(t('hint_id'))}
${esc(t('hint_deps'))}
+ ${esc(t('hint_fold'))}
!!! ${esc(t('hint_focus'))}
${esc(t('hint_jump'))}
`;
}
@@ -2076,6 +2147,7 @@ function switchDoc(id){
if(id === activeId) return;
flushActive();
activeId = id;
+ foldOverrides.clear(); /* Falt-Eingriffe gelten je Dokument-Sitzung (D38) */
loadActiveIntoEditor();
persistDocs();
}
@@ -2084,6 +2156,7 @@ function newDoc(){
const d = { id: uid(), name: uniqueName(t('docNewName')), text: '' };
docs.push(d);
activeId = d.id;
+ foldOverrides.clear();
loadActiveIntoEditor();
persistDocs();
keyboardOnJump(false); /* neues, leeres Dokument = tippen ist gemeint */
@@ -2112,6 +2185,7 @@ function deleteDoc(){
docs = docs.filter(x => x.id !== d.id);
if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }];
activeId = docs[0].id;
+ foldOverrides.clear();
loadActiveIntoEditor();
persistDocs();
closeDocMenu();
diff --git a/frontend/src/model.js b/frontend/src/model.js
index 948ab8d..c310a89 100644
--- a/frontend/src/model.js
+++ b/frontend/src/model.js
@@ -100,6 +100,46 @@ function walkKeys(nodes, parentKey, fn){
walkKeys(n.children, key, fn);
}
}
+/* Knoten -> stabiler Schlüssel (Label-Pfad) über den ganzen Baum. Dieselbe
+ Identität wie bei „Was ist neu?" — sie überlebt Umsortieren und Neu-Parsen;
+ genutzt für die interaktiven Falt-Eingriffe (D38). */
+export function nodeKeys(roots){
+ const map = new Map();
+ walkKeys(roots, '', (key, n) => map.set(n, key));
+ return map;
+}
+
+/* ---------- Faltmarken (SPEC §1/§9, D38) ----------
+ Anfangszustand der Faltung aus den Textmarken: `>` klappt den Knoten ein.
+ `<` (und mit `rescueFocus` auch die Fokusmarke `!!!`) holt den eigenen
+ Teilbaum hervor, indem die Faltung die Pfad-Ebenen HINUNTERWANDERT: Jeder
+ eingeklappte Vorfahr wird geöffnet, seine Nicht-Pfad-Kinder werden
+ stattdessen eingeklappt. Sichtbar ist genau der Pfad samt Teilbaum, die
+ Geschwister stehen als einzelne eingeklappte Knoten da — und jede
+ gezeichnete Kante bleibt eine echte. Ein `>` innerhalb des hervorgeholten
+ Teilbaums bleibt respektiert. */
+export function initialCollapsed(roots, rescueFocus){
+ const set = new Set();
+ const paths = [];
+ const walk = (n, path) => {
+ const p = path.concat(n);
+ if(n.fold === '>') set.add(n);
+ if(n.fold === '<' || (rescueFocus && n.focus)) paths.push(p);
+ n.children.forEach(c => walk(c, p));
+ };
+ roots.forEach(r => walk(r, []));
+ for(const p of paths){
+ for(let i = 0; i < p.length - 1; i++){
+ if(!set.has(p[i])) continue;
+ set.delete(p[i]);
+ for(const c of p[i].children)
+ if(c !== p[i+1] && c.children.length) set.add(c);
+ }
+ set.delete(p[p.length - 1]); /* der geholte Knoten selbst ist offen */
+ }
+ return set;
+}
+
/* key -> Status-Schlüssel ('' für neutrale Knoten) über den ganzen Baum. */
export function statusByKey(roots){
const map = new Map();
diff --git a/frontend/src/parser.js b/frontend/src/parser.js
index 3bc566a..a5d0e54 100644
--- a/frontend/src/parser.js
+++ b/frontend/src/parser.js
@@ -25,8 +25,10 @@ export const STATUS_BY_CODE = {
const REALIZED = new Set(['arbeit', 'durchstich', 'fertig', 'prod']);
/* Parst den Notationstext zu { roots, warnings }.
- Jeder Knoten: {label, type:'and'|'or'|'xor', optional, status, url, size,
- tags, id, deps, focus, children, line}.
+ Jeder Knoten: {label, type:'and'|'or'|'xor', optional, fold, status, url,
+ size, tags, id, deps, focus, children, line}.
+ `fold` ('>'|'<'|null, SPEC §1/D38) ist nur der ANFANGSZUSTAND der Faltung —
+ den wirksamen Zustand rechnet `initialCollapsed()` in model.js.
`deps` sind ID-Strings, keine Knoten-Referenzen — aufgelöst wird erst beim
Konsumenten (D37); der Parser prüft nur die Existenz (`unknownDep`).
`type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3)
@@ -51,14 +53,18 @@ export function parse(text){
Statusposition. Gültige Codes -> Status; unbekannte -> Warnung + neutral
(fehlertolerant: die Zeile geht nicht verloren). */
/* `=` (XOR, SPEC §3) nur mit folgendem Leerraum — die Leerraum-Regel hält
- Labels wie `=SUMME(A1:B2)` heraus; `-`/`+`/`|` bleiben wie bisher. */
- const m = raw.match(/^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:\[([^\]])\]\s*)?(.*)$/);
+ Labels wie `=SUMME(A1:B2)` heraus; `-`/`+`/`|` bleiben wie bisher.
+ Die Faltmarke `>`/`<` (SPEC §1, D38) steht zwischen Zeichen und
+ Statusbox (bei Wurzeln am Zeilenanfang), ebenfalls nur mit folgendem
+ Leerraum — `- >Achtung` bleibt ein Label. */
+ const m = raw.match(/^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:([><])(?=[ \t])\s*)?(?:\[([^\]])\]\s*)?(.*)$/);
const width = m[1].replace(/\t/g,' ').length;
const type = m[2] === '|' ? 'or' : m[2] === '=' ? 'xor' : 'and';
const optional = m[2] === '+';
- const boxChar = m[3]; // undefined, wenn keine Statusbox
+ const fold = m[3] || null;
+ const boxChar = m[4]; // undefined, wenn keine Statusbox
- let rest = m[4], url = null, size = null;
+ let rest = m[5], url = null, size = null;
const tags = [];
rest = rest.replace(/https?:\/\/\S+/i, s => { url = s; return ''; });
rest = rest.replace(/\((XXL|XS|XL|S|M|L)\)/i, (s, g) => { size = g.toUpperCase(); return ''; });
@@ -102,7 +108,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, optional, status, url, size, tags, id, deps, focus, children:[], line:i+1};
+ const node = {label, type, optional, fold, status, url, size, tags, id, deps, focus, 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 7c20d72..11958a3 100644
--- a/frontend/src/render.js
+++ b/frontend/src/render.js
@@ -10,6 +10,7 @@
cheapPath, // günstigster Pfad aktiv? (steuert das implizite M-Badge)
cheapSet, // Set der nötigen Knoten (leer, wenn Pfad aus)
freshSet, // optional: Knoten, die neu in Produktion sind (D28)
+ collapsedSet, // optional: eingeklappte Knoten (Faltung, SPEC §9/D38)
} */
import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js';
@@ -44,9 +45,12 @@ function attr(s){ return esc(String(s)).replace(/"/g,'"'); }
Link. Die visuellen Badges (Größe, Tags, ↗) sind aria-hidden — ihre
Information steckt hier, sonst würde der Screenreader Kryptisches („M",
„anna", „↗") vorlesen. */
-function nodeAria(n, opts){
+function nodeAria(n, opts, fold){
const { t, cheapPath } = opts;
const parts = [n.label];
+ /* Eingeklappt (SPEC §9/D38): das ▾/▸-Zeichen ist aria-hidden — ohne diese
+ Ansage wüsste ein Screenreader nicht, dass hier etwas verborgen ist. */
+ if(fold && fold.collapsed) parts.push(t('a11yFolded', {n: fold.count}));
if(n.status) parts.push(t('a11yStatus', {status: t('st_' + n.status.key)}));
if(n.size) parts.push(t('a11ySize', {size: n.size}));
else if(cheapPath) parts.push(t('a11ySizeImplicit'));
@@ -65,10 +69,11 @@ function nodeAria(n, opts){
return parts.join(', ');
}
-function nodeHtml(n, extra, opts){
+function nodeHtml(n, extra, opts, fold){
const { t, cheapPath } = opts;
const need = needsBreakdown(n);
- const cls = ['node', extra || '', n.status ? 'st-' + n.status.key : '']
+ const cls = ['node', extra || '', fold && fold.collapsed ? 'folded' : '',
+ n.status ? 'st-' + n.status.key : '']
.filter(Boolean).join(' ');
/* Zeilennummer am Knoten (D25): Grundlage für den Sprung ins Textfeld und
für die Gegenrichtung (Cursor-Zeile -> Knoten hervorheben). Der Hinweis im
@@ -92,20 +97,62 @@ function nodeHtml(n, extra, opts){
const riskMark = n.status && n.status.key === 'highrisk'
? `⚠︎`
: '';
- const inner = esc(n.label) +
+ /* Falt-Zeichen (SPEC §9/D38): ▾ offen, „▸ n" eingeklappt — das Klickziel
+ fürs Umklappen (der einfache Klick auf den Knoten bleibt der Link, §6).
+ aria-hidden: die Information steht im aria-label (a11yFolded). */
+ const foldHtml = fold
+ ? `${fold.collapsed ? '▸ ' + fold.count : '▾'}`
+ : '';
+ const expanded = fold ? ` aria-expanded="${!fold.collapsed}"` : '';
+ const inner = foldHtml +
+ esc(n.label) +
(n.url ? '↗' : '') +
riskMark +
sizeBadge +
tagsHtml;
- const aria = ` aria-label="${attr(nodeAria(n, opts))}"`;
+ const aria = ` aria-label="${attr(nodeAria(n, opts, fold))}"`;
const html = n.url
- ? `${inner}`
- : `
${inner}
`;
+ ? `${inner}`
+ : `${inner}
`;
const ghostTip = attr(t('ghostTooltip'));
const ghost = `${esc(t('ghost'))}
`;
return html + (need ? ghost : '');
}
+/* Eingeklappter Teilbaum (SPEC §9/D38): Das HTML entfällt, aber die
+ Warnungen des verborgenen Teils werden trotzdem gemeldet — sie sind eine
+ Aussage über den TEXT, nicht über die Ansicht. Derselbe Lauf zählt die
+ verborgenen Knoten für das „▸ n"-Kennzeichen. */
+function walkFolded(node, warnings, opts){
+ const kids = visibleChildren(node, opts.showDiscarded);
+ if(!kids.length) return 0;
+ const types = new Set(kids.map(k => k.type));
+ if(types.size > 1){
+ warnings.push({type: 'mixedGate', line: kids[0].line, label: node.label});
+ }
+ let count = kids.length;
+ for(const k of kids) count += walkFolded(k, warnings, opts);
+ return count;
+}
+
+/* Ein Knoten samt und (sofern nicht eingeklappt) seiner Kinder. */
+function itemHtml(n, extra, warnings, opts){
+ const vk = visibleChildren(n, opts.showDiscarded);
+ const canFold = vk.length > 0;
+ const collapsed = canFold && !!(opts.collapsedSet && opts.collapsedSet.has(n));
+ const fold = canFold
+ ? {collapsed, count: collapsed ? walkFolded(n, warnings, opts) : 0}
+ : null;
+ /* `opt` auch am : den Abzweig zeichnen dessen Pseudoelemente, er wird
+ für optionale Knoten gestrichelt (D29). Eingeklappt ist der Knoten ein
+ Blatt — kein has-*-Layout, keine Kinderliste. */
+ const liCls = liClass(collapsed ? [] : vk, opts, n.optional);
+ return `` +
+ nodeHtml(n, extra, opts, fold) +
+ (collapsed ? '' : renderChildren(n, warnings, opts)) +
+ ``;
+}
+
function renderChildren(node, warnings, opts){
const kids = visibleChildren(node, opts.showDiscarded);
if(!kids.length) return '';
@@ -122,16 +169,7 @@ function renderChildren(node, warnings, opts){
die Klasse `or` (alle Modi, Export-Routing); `xor` ergänzt nur die
„1"-Plakette an der Sammelleiste (D35). */
const ulCls = gate === 'xor' ? 'or xor' : gate;
- const items = kids.map(k => {
- const vk = visibleChildren(k, opts.showDiscarded);
- /* `opt` auch am : den Abzweig zeichnen dessen Pseudoelemente, er wird
- für optionale Knoten gestrichelt (D29). */
- const liCls = liClass(vk, opts, k.optional);
- return `` +
- nodeHtml(k, extraCls(k, opts), opts) +
- renderChildren(k, warnings, opts) +
- ``;
- }).join('');
+ const items = kids.map(k => itemHtml(k, extraCls(k, opts), warnings, opts)).join('');
return ``;
}
@@ -140,13 +178,8 @@ function renderChildren(node, warnings, opts){
Leere Wurzelliste ⇒ leerer String. */
export function renderTreeHtml(roots, opts){
const warnings = [];
- const html = roots.map(root => {
- const vk = visibleChildren(root, opts.showDiscarded);
- const liCls = liClass(vk, opts, root.optional);
- return `` +
- nodeHtml(root, ('root-node ' + extraCls(root, opts)).trim(), opts) +
- renderChildren(root, warnings, opts) +
- ``;
- }).join('');
+ const html = roots.map(root =>
+ itemHtml(root, ('root-node ' + extraCls(root, opts)).trim(), warnings, opts)
+ ).join('');
return { html, warnings };
}
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 1b48ca6..84b531e 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -549,6 +549,18 @@
}
a.node{text-decoration:none;cursor:pointer}
a.node:hover{box-shadow:0 2px 8px rgba(36,52,71,.22)}
+ /* Falt-Zeichen (SPEC §9, D38): ▾ offen / „▸ n" eingeklappt, Klickziel fürs
+ Umklappen. Gedeckt in Grau — es ist Bedienelement, keine Statusaussage;
+ beim Zeigen färbt es sich zur Tinte. */
+ .node .fold{
+ cursor:pointer;color:var(--muted);
+ font-size:.72em;margin-right:6px;
+ user-select:none;-webkit-user-select:none;
+ }
+ .node .fold:hover{color:var(--line)}
+ /* Der dunkle Wurzelknoten braucht ein helleres Zeichen als das Grau. */
+ .root-node .fold{color:rgba(255,255,255,.75)}
+ .root-node .fold:hover{color:#fff}
/* Sichtbarer Tastatur-Fokus (Knoten sind fokussierbar: Fokusreihenfolge =
Lese-/Dokumentreihenfolge; Screenreader liest den aria-label). */
.node:focus-visible{outline:2px solid var(--or);outline-offset:2px;box-shadow:0 2px 8px rgba(36,52,71,.22)}
@@ -1040,5 +1052,8 @@
/* Editierhilfe bzw. Zuruf, nicht drucken (D25, Fokusmarke SPEC §1) */
.node.current,.node.focusmark{box-shadow:none!important}
.node.fresh{box-shadow:none!important} /* „neu seit dem letzten Besuch" ist persönlich (D28) */
+ /* Faltung (D38): das ▾ offener Knoten ist Bedienelement — nicht drucken;
+ das „▸ n" eingeklappter Knoten ist Kennzeichnung und bleibt. */
+ .node:not(.folded) .fold{display:none!important}
@page{margin:12mm}
}
diff --git a/frontend/tests/__snapshots__/render.test.js.snap b/frontend/tests/__snapshots__/render.test.js.snap
index 13df568..9b01342 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`] = `"Website-Relaunch↗XLKonzeptionM
ZielgruppenanalyseS
SitemapXS
UmsetzungXL
HostingM
"`;
+exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"▾Website-Relaunch↗XL▾KonzeptionM
ZielgruppenanalyseS
SitemapXS
▾UmsetzungXL
▾HostingM
"`;
-exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"Website-Relaunch↗XLKonzeptionM
ZielgruppenanalyseS
SitemapXS
UmsetzungXL
HostingM
"`;
+exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"▾Website-Relaunch↗XL▾KonzeptionM
ZielgruppenanalyseS
SitemapXS
▾UmsetzungXL
▾HostingM
"`;
-exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"Website-Relaunch↗XLKonzeptionM
ZielgruppenanalyseS
SitemapXS
UmsetzungXL
- Frontend↗Sanna
BackendLbencarla
ghost
Dark ModeS
CMS-AnbindungM
WordPress
Headless CMS
Eigenentwicklung
HostingM
"`;
+exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"▾Website-Relaunch↗XL▾KonzeptionM
ZielgruppenanalyseS
SitemapXS
▾UmsetzungXL
- Frontend↗Sanna
BackendLbencarla
ghost
Dark ModeS
▾CMS-AnbindungM
WordPress
Headless CMS
Eigenentwicklung
▾HostingM
"`;
diff --git a/frontend/tests/fold.test.js b/frontend/tests/fold.test.js
new file mode 100644
index 0000000..832efb5
--- /dev/null
+++ b/frontend/tests/fold.test.js
@@ -0,0 +1,128 @@
+import { describe, it, expect } from 'vitest';
+import { parse } from '../src/parser.js';
+import { initialCollapsed, computeCheapSet } from '../src/model.js';
+import { renderTreeHtml } from '../src/render.js';
+
+const t = key => key;
+const roots = txt => parse(txt).roots;
+const render = (txt, collapsedSet) => renderTreeHtml(roots(txt),
+ {t, showDiscarded: false, cheapPath: false, cheapSet: new Set(),
+ collapsedSet: collapsedSet || new Set()});
+/* Rendert mit dem Anfangszustand aus den Textmarken (wie app.js, ohne Overrides). */
+const renderFolded = txt => {
+ const r = roots(txt);
+ return renderTreeHtml(r, {t, showDiscarded: false, cheapPath: false,
+ cheapSet: new Set(), collapsedSet: initialCollapsed(r, true)});
+};
+const collapsedLabels = (txt, rescueFocus = true) =>
+ [...initialCollapsed(roots(txt), rescueFocus)].map(n => n.label).sort();
+
+/* Faltmarken `>`/`<` (SPEC §1/§9, D38): Anfangszustand der Faltung im Text. */
+describe('Parser — Faltmarke zwischen Zeichen und Statusbox', () => {
+ it('erkennt `>` und `<` und nimmt sie aus dem Label', () => {
+ const [wurzel] = roots(`[ ] Wurzel\n - > [x] Zu (M)\n - < [ ] Auf`);
+ expect(wurzel.children.map(k => [k.label, k.fold, k.status?.key]))
+ .toEqual([['Zu', '>', 'fertig'], ['Auf', '<', 'geplant']]);
+ expect(wurzel.fold).toBe(null);
+ });
+
+ it('erkennt die Marke am Wurzelknoten (ohne Zeichen) am Zeilenanfang', () => {
+ const [wurzel] = roots(`> [~] Kapitel\n - [ ] Kind`);
+ expect([wurzel.fold, wurzel.label]).toEqual(['>', 'Kapitel']);
+ });
+
+ it('verlangt folgenden Leerraum — `- >Achtung` bleibt ein Label', () => {
+ const [wurzel] = roots(`[ ] Wurzel\n - >Achtung`);
+ expect(wurzel.children.map(k => [k.label, k.fold]))
+ .toEqual([['>Achtung', null]]);
+ });
+
+ it('lässt ein `>` mitten im Label unberührt', () => {
+ const [wurzel] = roots(`[ ] a > b`);
+ expect([wurzel.label, wurzel.fold]).toEqual(['a > b', null]);
+ });
+});
+
+describe('initialCollapsed — `>` klappt ein, `<` wandert die Faltung hinunter', () => {
+ it('klappt `>`-Knoten ein', () => {
+ expect(collapsedLabels(`[ ] W\n - > [ ] A\n - [ ] A1\n - [ ] B`))
+ .toEqual(['A']);
+ });
+
+ it('holt einen `<`-Teilbaum hervor: Vorfahr öffnet, Geschwister klappen ein', () => {
+ const txt = `> [ ] W
+ - [ ] A
+ - [ ] A1
+ - [ ] B
+ - < [ ] B1
+ - [ ] B1a
+ - [ ] C`;
+ /* W öffnet sich (Pfad zu B1), A klappt stattdessen ein; B öffnet den Weg,
+ B1 samt Teilbaum ist sichtbar. C ist Blatt und braucht keine Faltung. */
+ expect(collapsedLabels(txt)).toEqual(['A']);
+ });
+
+ it('respektiert ein `>` innerhalb des hervorgeholten Teilbaums', () => {
+ const txt = `> [ ] W
+ - < [ ] B
+ - > [ ] B1
+ - [ ] B1a`;
+ expect(collapsedLabels(txt)).toEqual(['B1']);
+ });
+
+ it('holt auch einen `!!!`-markierten Knoten hervor', () => {
+ const txt = `> [ ] W\n - [ ] A\n - [ ] A1\n - [ ] B !!!\n - [ ] B1`;
+ expect(collapsedLabels(txt)).toEqual(['A']);
+ /* … aber nur mit rescueFocus — headless-Aufrufer können es abschalten. */
+ expect(collapsedLabels(txt, false)).toEqual(['W']);
+ });
+
+ it('lässt Bäume ohne Marken vollständig offen', () => {
+ expect(collapsedLabels(`[ ] W\n - [ ] A\n - [ ] B`)).toEqual([]);
+ });
+});
+
+describe('Renderer — eingeklappte Teilbäume', () => {
+ const TXT = `[ ] W\n - > [~] A (M)\n - [ ] A1\n - [ ] A2\n - [ ] A2a\n - [ ] B`;
+
+ it('lässt die Kinder eines eingeklappten Knotens weg', () => {
+ const {html} = renderFolded(TXT);
+ expect(html).not.toContain('A1');
+ expect(html).toContain('>B<');
+ });
+
+ it('kennzeichnet mit Klasse `folded` und „▸ n" (n = alle verborgenen Knoten)', () => {
+ const {html} = renderFolded(TXT);
+ expect(html).toContain('folded');
+ expect(html).toContain('▸ 3');
+ expect(html).toContain('aria-expanded="false"');
+ expect(html).toContain('a11yFolded');
+ });
+
+ it('gibt offenen Eltern das ▾, Blättern gar kein Falt-Zeichen', () => {
+ const {html} = render(`[ ] W\n - [ ] Blatt`);
+ expect((html.match(/class="fold"/g) || []).length).toBe(1);
+ expect(html).toContain('>▾');
+ expect(html).toContain('aria-expanded="true"');
+ });
+
+ it('macht das eingeklappte zum Blatt (kein has-*-Layout)', () => {
+ /* Nur W verzweigt sichtbar; das eingeklappte A steht als klassenloses . */
+ const {html} = renderFolded(TXT);
+ expect((html.match(/has-and|has-or/g) || []).length).toBe(1);
+ expect(html).toContain(' {
+ const txt = `[ ] W\n - > [ ] A\n | [ ] X\n - [ ] Y`;
+ const {html, warnings} = renderFolded(txt);
+ expect(html).not.toContain('>X<');
+ expect(warnings).toEqual([{type: 'mixedGate', line: 3, label: 'A'}]);
+ });
+
+ it('lässt den günstigsten Pfad unberührt — Faltung ist reine Ansicht', () => {
+ const r = roots(`[ ] W (XS)\n - > [ ] A (S)\n | [ ] A1 (XL)\n | [ ] A2 (S)`);
+ expect([...computeCheapSet(r)].map(n => n.label).sort())
+ .toEqual(['A', 'A2', 'W']);
+ });
+});
diff --git a/frontend/tests/render.test.js b/frontend/tests/render.test.js
index 2f3b86e..53b1f92 100644
--- a/frontend/tests/render.test.js
+++ b/frontend/tests/render.test.js
@@ -110,7 +110,9 @@ describe('renderTreeHtml — Moduswechsel ist CSS, nicht Renderer', () => {
(die als
gerendert werden) und Knoten in any-of-Gruppen. */
describe('renderTreeHtml — data-line je Knoten (D25)', () => {
const lineOf = (html, label) => {
- const re = new RegExp('<(?:a|div) class="node[^"]*"[^>]*?data-line="(\\d+)"[^>]*>' + label);
+ /* Eltern-Knoten tragen seit D38 das Falt-Zeichen (▾/▸) vor dem Label. */
+ const re = new RegExp('<(?:a|div) class="node[^"]*"[^>]*?data-line="(\\d+)"[^>]*>'
+ + '(?:]*>[^<]*)?' + label);
const m = html.match(re);
return m ? Number(m[1]) : null;
};