feat: Effektiver Status — Knotenfarbe zeigt ihn, Diskrepanz-Marke unten links (SPEC §4/§9, D39)

Jeder Status bekommt einen Fortschritts-Rang entlang der Ergebnis-Skala
(D5); effektiv ist das Minimum über die Abhängigkeits-Hülle (Fixpunkt —
Zyklen teilen ihr Minimum, wörtlich das „wird gemeinsam fertig" aus D34).
Neutral und [-] zählen als 0, [!] als 1; bei doppelter ID gilt die erste
Vergabe. Entschieden (Nutzer): Die Knotenfarbe zeigt den effektiven
Status — das Diagramm beantwortet „wie weit ist das wirklich?". Wo der
eigene Status weiter ist, sitzt unten links die eigene Statusbox als
Marke in den eigenen §4-Farben (auch im Export/Druck); Tooltip und
a11yEffective benennen beides. XOR-„realisiert" und „Was ist neu?"
bleiben bewusst intrinsisch. i18n in allen 9 Sprachen; 12 neue Tests
(tests/effective.test.js).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-22 17:15:04 +02:00
co-authored by Claude Fable 5
parent cbe798d746
commit 15c3e85e26
12 changed files with 297 additions and 40 deletions
+21 -3
View File
@@ -11,6 +11,8 @@
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)
effStatus, // optional: Map Knoten -> effektiver Status-Key, nur
// Diskrepanzen (effectiveStatus() in model.js, D39)
} */
import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js';
@@ -52,6 +54,10 @@ function nodeAria(n, opts, fold){
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)}));
/* Diskrepanz (D39): die Farbe zeigt den effektiven Status — der Screenreader
bekommt beide, wie der Tooltip. */
const effKey = opts.effStatus ? opts.effStatus.get(n) : undefined;
if(effKey) parts.push(t('a11yEffective', {status: t('st_' + effKey)}));
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(', ')}));
@@ -72,8 +78,12 @@ function nodeAria(n, opts, fold){
function nodeHtml(n, extra, opts, fold){
const { t, cheapPath } = opts;
const need = needsBreakdown(n);
/* Die Knotenfarbe zeigt den EFFEKTIVEN Status (SPEC §9/D39); bei Diskrepanz
trägt die Marke unten links die eigene Statusbox in den eigenen Farben. */
const effKey = opts.effStatus ? opts.effStatus.get(n) : undefined;
const cls = ['node', extra || '', fold && fold.collapsed ? 'folded' : '',
n.status ? 'st-' + n.status.key : '']
effKey ? 'held' : '',
n.status ? 'st-' + (effKey || 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
@@ -81,7 +91,9 @@ function nodeHtml(n, extra, opts, fold){
const lineAttr = n.line ? ` data-line="${n.line}"` : '';
const tip = [n.id ? '#' + n.id : '',
n.deps && n.deps.length ? '→ ' + n.deps.map(d => '#' + d).join(', ') : '',
n.status ? t('st_' + n.status.key) : '',
effKey
? t('heldTooltip', {eff: t('st_' + effKey), own: t('st_' + n.status.key)})
: (n.status ? t('st_' + n.status.key) : ''),
n.optional ? t('a11yOptional') : '', t('jumpHint')]
.filter(Boolean).join(' · ');
const title = ` title="${attr(tip)}"`;
@@ -104,12 +116,18 @@ function nodeHtml(n, extra, opts, fold){
? `<span class="fold" aria-hidden="true">${fold.collapsed ? '▸ ' + fold.count : '▾'}</span>`
: '';
const expanded = fold ? ` aria-expanded="${!fold.collapsed}"` : '';
/* Diskrepanz-Marke: die eigene Statusbox in den eigenen §4-Farben —
„selbst schon [x], wartet auf Abhängigkeiten" (D39). */
const ownChip = effKey
? `<span class="chip ownst st-${n.status.key}" aria-hidden="true">[${n.status.code}]</span>`
: '';
const inner = foldHtml +
esc(n.label) +
(n.url ? '<span class="ext" aria-hidden="true">↗</span>' : '') +
riskMark +
sizeBadge +
tagsHtml;
tagsHtml +
ownChip;
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}${expanded}${title}>${inner}</a>`