feat: Faltmarken (>/<) — Teilbäume ein-/ausklappbar (SPEC §1/§9, D38)

Parser liest die Marke zwischen Zeichen und Statusbox (Leerraum-Regel,
`- >Achtung` bleibt Label); den Anfangszustand rechnet initialCollapsed()
in model.js: `>` klappt ein, `<` (und die Fokusmarke !!!) holt seinen
Teilbaum hervor, indem die Faltung die Pfad-Ebenen hinunterwandert —
Geschwister stehen als einzelne eingeklappte Knoten da, jede Kante bleibt
eine echte. Im Diagramm: Falt-Zeichen ▾/„▸ n" vor dem Label (n = alle
verborgenen Knoten), Klick oder ←/→ am fokussierten Knoten klappt um;
Eingriffe je Label-Pfad, nur für die Sitzung, Dokumentwechsel setzt
zurück. Eingeklappte Kinder werden nicht gerendert (Export, Messungen,
Pfadlinie automatisch konsistent), ihre Warnungen aber weiter gemeldet.
Export/Druck: „▸ n" bleibt, das ▾ offener Knoten fällt weg. Legende +
a11y in allen 9 Sprachen; SPEC-§11-Abschnitt in §1/§9 überführt;
15 neue Tests (tests/fold.test.js), Snapshots aktualisiert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-22 16:47:34 +02:00
co-authored by Claude Fable 5
parent c778e3e267
commit cbe798d746
15 changed files with 480 additions and 76 deletions
+58 -25
View File
@@ -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,'&quot;'); }
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'
? `<span class="risk" aria-hidden="true" title="${attr(t('riskTooltip'))}">⚠︎</span>`
: '';
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
? `<span class="fold" aria-hidden="true">${fold.collapsed ? '▸ ' + fold.count : '▾'}</span>`
: '';
const expanded = fold ? ` aria-expanded="${!fold.collapsed}"` : '';
const inner = foldHtml +
esc(n.label) +
(n.url ? '<span class="ext" aria-hidden="true">↗</span>' : '') +
riskMark +
sizeBadge +
tagsHtml;
const aria = ` aria-label="${attr(nodeAria(n, opts))}"`;
const aria = ` aria-label="${attr(nodeAria(n, opts, fold))}"`;
const html = n.url
? `<a class="${cls}" href="${attr(n.url)}" target="_blank" rel="noopener"${lineAttr}${aria}${title}>${inner}</a>`
: `<div class="${cls}" tabindex="0"${lineAttr}${aria}${title}>${inner}</div>`;
? `<a class="${cls}" href="${attr(n.url)}" target="_blank" rel="noopener"${lineAttr}${aria}${expanded}${title}>${inner}</a>`
: `<div class="${cls}" tabindex="0"${lineAttr}${aria}${expanded}${title}>${inner}</div>`;
const ghostTip = attr(t('ghostTooltip'));
const ghost = `<div class="ghost-node" aria-label="${ghostTip}" title="${ghostTip}">${esc(t('ghost'))}</div>`;
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 <li> 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 <li>: 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 `<li${liCls}>` +
nodeHtml(n, extra, opts, fold) +
(collapsed ? '' : renderChildren(n, warnings, opts)) +
`</li>`;
}
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 <li>: den Abzweig zeichnen dessen Pseudoelemente, er wird
für optionale Knoten gestrichelt (D29). */
const liCls = liClass(vk, opts, k.optional);
return `<li${liCls}>` +
nodeHtml(k, extraCls(k, opts), opts) +
renderChildren(k, warnings, opts) +
`</li>`;
}).join('');
const items = kids.map(k => itemHtml(k, extraCls(k, opts), warnings, opts)).join('');
return `<ul class="${ulCls}">${items}</ul>`;
}
@@ -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 `<li${liCls}>` +
nodeHtml(root, ('root-node ' + extraCls(root, opts)).trim(), opts) +
renderChildren(root, warnings, opts) +
`</li>`;
}).join('');
const html = roots.map(root =>
itemHtml(root, ('root-node ' + extraCls(root, opts)).trim(), warnings, opts)
).join('');
return { html, warnings };
}