diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index fdc2ec1..580d119 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -3002,6 +3002,65 @@ Status-Hälfte ist damit gebaut. Offen bleibt die **Nutzen-Achse** (Ausbaustufen ist eine Gruppe komplett realisiert, zur nächsten per Nutzen gewählten Stufe weiterspringen) — die braucht erst ein Nutzen-Attribut und den Aufwands-Rollup. +**Nachtrag — „erledigt tritt nicht zurück" galt nur auf dem Pfad; jetzt gilt es +überall.** Gemeldet als Frage: „Manche Knoten, die schon in Produktion sind, +werden blau dargestellt und manche grau." Beispiel `#ed.fresh` — grau, außer +wenn der Cursor darauf steht. + +Die Ursache war nicht der Status, sondern die Pfad-Inversion: Der Knoten ist +eine **Zugabe** (`+`), und optionale Knoten liegen per D29 nie auf dem +günstigsten Pfad — `.cheap-on .node:not(.cheap)` blasste ihn also aus (Deckkraft +0,32, Sättigung 0,4; Pastellblau `#DBEAF8` liest sich so als Grau). Dass er beim +Anklicken blau wurde, war die Cursor-Ausnahme aus D25. + +**Der Widerspruch liegt in diesem Eintrag selbst.** Oben steht „sie auszublassen +hieße, sie sähen aus wie eine verworfene Alternative" — die Regel griff aber nur +für erledigte Knoten **auf** dem Pfad, weil sie an `cheapCls()` hing. Durch das +Raster fielen die fertige Zugabe und die fertige, nicht gewählte Alternative: +`#ed.fresh` ist gebaut und deployed und sah aus wie etwas, das nie passieren +wird. Das Argument von oben trägt dort genauso weit; die Regel war zu eng +gefasst, nicht falsch begründet. + +**Entschieden (Nutzer): Was `[x]` oder `[^]` trägt, wird nie ausgeblasst.** +Damit hat die Blässe genau **eine** Bedeutung: „hier ist nichts getan und wird +auch nichts getan". Vorher hieß sie manchmal auch „ist längst fertig" — und die +beiden Fälle sind das Gegenteil voneinander. + +**Umgesetzt als eigene Klasse `done`, nicht über die Statusklassen.** +`.cheap-on .node:not(.cheap).st-fertig, …st-prod` hätte ohne neue Klasse +funktioniert, wäre aber **falsch**: `st-…` trägt den **effektiven** Status +(D39), und ein von Abhängigkeiten zurückgehaltenes `[x]` heißt dort z. B. +`st-arbeit`. Die Ausnahme muss dem **intrinsischen** Status folgen — dieselbe +Linie wie bei der XOR-Regel (D35), bei „Was ist neu?" (D28) und bei der +Kostenrechnung dieses Eintrags: Geleistete Arbeit ist geleistet. `render.js` +setzt die Klasse deshalb per `isDone(n)`, also aus derselben Funktion, die auch +die Kosten auf 0 zieht. Gefärbt wird unverändert nach dem effektiven Status — +ein zurückgehaltenes `[x]` steht jetzt in **voller** Stärke in seiner +effektiven Farbe, und die Diskrepanz-Marke (D39) erklärt den Unterschied. + +Es ist die **vierte** Ausnahme von der Inversion, nach `.fresh` (D28), +`.focusmark` (D32) und `.current` (D25-Nachtrag) — und alle vier sind +nachgereicht worden, nachdem jemand einen unsichtbaren Knoten gemeldet hat. Das +ist inzwischen ein Muster und steht als Prüffrage in `frontend/CLAUDE.md`: Wer +eine neue Aussage an einen Knoten hängt, fragt zuerst, ob sie ausgeblasst noch +etwas sagt. + +**Nebenbefund: Der Grafikexport hatte den Fehler nie.** `diagramToSvg()` liest +`backgroundColor`, nicht `opacity`/`filter` — im exportierten SVG standen diese +Knoten also immer schon in voller Farbe. Der Bildschirm zieht damit nach, statt +dass etwas Neues entsteht. + +**Nachgemessen** am mitgelieferten Werkbaum-Plan (149 sichtbare Knoten, Pfad +an): Vorher blass und fertig waren genau **drei** Knoten — `#ed.fresh`, +`#ed.export.print` (beide `+`) und `#ed.closure.greedy` (die nicht gewählte +Alternative neben `#ed.closure.exact`, das als gewähltes schon voll stand). +Alle drei jetzt `opacity 1`, `filter none`, Füllung `rgb(219, 234, 248)` — also +identisch mit den fertigen Knoten auf dem Pfad. Weiterhin blass bleiben **31** +Knoten, darunter **kein einziger** fertiger. 240 Tests grün, davon 4 neue in +`tests/frontier.test.js`; drei Snapshots und eine Zusicherung in +`effective.test.js` (`node root-node held done st-arbeit` — genau der Fall +„intrinsisch fertig, effektiv zurückgehalten") sind um die Klasse ergänzt. + ## D47 — Von Station zu Station: ein Knopf, der geht, statt zu schalten Mit dem status-bewussten Pfad (D46) zeigt das Diagramm die offene Front — im mitgelieferten Plan 24 Stationen, verteilt über einen Baum von über 20 000 px diff --git a/docs/SPEC.md b/docs/SPEC.md index b6284cb..b7a70d5 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -507,10 +507,16 @@ Darstellung per **Inversion**: nicht benötigte Knoten (nicht-gewählte 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. **Erledigte Knoten treten nicht zurück**: Sie -gehören zum Pfad und behalten ihre volle Statusfarbe (§4) — grün bzw. blau sagt -bereits „hier ist nichts mehr zu tun"; sie auszublassen hieße, sie sähen aus wie -eine verworfene Alternative. Wo die Größe **implizit** als `M` angenommen wird, +ohnehin dichten Knoten-Ecken. **Erledigte Knoten treten nie zurück**: Ein Knoten +mit `[x]` oder `[^]` behält seine volle Statusfarbe (§4) — grün bzw. blau sagt +bereits „hier ist nichts mehr zu tun"; ihn auszublassen hieße, er sähe aus wie +eine verworfene Alternative. Das gilt **unabhängig davon, ob er auf dem Pfad +liegt**: auch die fertige Zugabe (`+`, §3) und die fertige, nicht gewählte +Alternative behalten ihre Farbe. Blass heißt damit einheitlich „hier ist nichts +getan und wird auch nichts getan". Maßgeblich ist der **intrinsische** Status +(die eigene Box), gefärbt wird weiterhin nach dem effektiven (oben) — ein von +Abhängigkeiten zurückgehaltenes `[x]` steht also in voller Stärke in seiner +effektiven Farbe. Wo die Größe **implizit** als `M` angenommen wird, zeigt der Knoten ein **invertiertes** Größen-Badge (weiß mit petrolfarbenem Rand/Text statt gefüllt) mit erläuterndem Tooltip — an einem **erledigten** Knoten entfällt es, dort wird keine Kostenannahme mehr getroffen. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index e1323b5..6871717 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -62,13 +62,21 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der `app.js` (DOM/Events/i18n/Persistenz/Export). Modell/Renderer bekommen UI-State (verworfene einblenden, Pfad an/aus) als **Parameter** — keine Globals; nur `cheapPathOn` lebt als UI-State in `app.js`. Tests: `tests/*.test.js`. -- Günstigster Pfad: `markCheapest()`/`cheapestCost()` (in `model.js`) markieren +- Günstigster Pfad: `computeCheapPlan()`/`cheapestCost()`/`cheapCls()` (in + `model.js`) markieren die nötigen Knoten (Klassen `cheap`, `cheap-leaf`); `drawCheapPath()` (app.js) zeichnet nach jedem `render()` **und** nach `applyLayout()` zwei Overlay-SVGs in `#out` (hinten kräftige Linie, vorne abgetönte Kopie + Stationspunkte). Overlays erben den CSS-`zoom` von `#out`, Punkte in unskalierte `#out`-Koordinaten umrechnen (`/zoom`). `diagramToSvg()` zeichnet dieselbe Linie/Punkte nach (SPEC §9, D18). + **Die Inversion `.cheap-on .node:not(.cheap)` hat vier Ausnahmen** — `.fresh` + (D28), `.focusmark` (D32), `.current` (D25) und `.done` (D46-Nachtrag: `[x]` + oder `[^]`, gesetzt in `render.js` per `isDone()`). Wer eine fünfte Aussage an + einen Knoten hängt, prüft, ob sie ausgeblasst noch etwas sagt; die ersten drei + sind je einzeln nachgereicht worden, nachdem sie unsichtbar waren. Der + **Grafikexport** blasst ohnehin nie aus (er liest `backgroundColor`, nicht + `opacity`) — er war damit schon immer die Ansicht ohne Inversion. - Zerlegt eine any-of-Alternative selbst all-of, wird der Teilbaum **nur horizontal** schmal transponiert (`ul.or>li.has-and>ul.and`, siehe D18) — sonst schiebt der breite Fächer den Elternbaum nach rechts. Bei Layout- diff --git a/frontend/src/render.js b/frontend/src/render.js index b026147..1120ec3 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -92,8 +92,13 @@ function nodeHtml(n, extra, opts, fold){ /* 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; + /* `done` = erledigt laut eigener Statusbox (`[x]`/`[^]`). Trägt allein die + Ausnahme von der Pfad-Inversion (D46-Nachtrag): Was getan ist, wird nie + ausgeblasst — auch als optionaler Knoten oder nicht gewählte Alternative. + Der INTRINSISCHE Status entscheidet, wie überall dort, wo es um geleistete + Arbeit geht (D35/D28/D46); die Farbe bleibt die des effektiven (D39). */ const cls = ['node', extra || '', fold && fold.collapsed ? 'folded' : '', - effKey ? 'held' : '', + effKey ? 'held' : '', isDone(n) ? 'done' : '', n.status ? 'st-' + (effKey || n.status.key) : ''] .filter(Boolean).join(' '); /* Zeilennummer am Knoten (D25): Grundlage für den Sprung ins Textfeld und diff --git a/frontend/src/style.css b/frontend/src/style.css index 3225178..093e707 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -771,6 +771,14 @@ Zurücktreten die Aussage IST, ist hier gar keine Aussage über den Plan gemeint — es ist eine Editierhilfe. */ .cheap-on .node:not(.cheap).current{opacity:1;filter:none} + /* Erledigtes tritt NIE zurück (D46-Nachtrag). D46 hatte das schon entschieden + — „sie auszublassen hieße, sie sähen aus wie eine verworfene Alternative" —, + die Regel griff aber nur für erledigte Knoten AUF dem Pfad. Ein fertiger + OPTIONALER Knoten (`+`, D29: nie auf dem Pfad) und eine fertige, nicht + gewählte Alternative fielen durch das Raster: `#ed.fresh` ist gebaut und + deployed, sah aber aus wie etwas, das nie passieren wird. Blassheit heißt + jetzt einheitlich „hier ist nichts getan und wird auch nichts getan". */ + .cheap-on .node:not(.cheap).done{opacity:1;filter:none} /* implizit als M angenommene Größe: invertiertes Badge (weiß statt petrol) */ .size.implicit{background:var(--card);color:var(--or);border-color:var(--or)} /* Günstigster-Pfad-Linie: gestrichelt, geschwungen, petrol — fädelt durch diff --git a/frontend/tests/__snapshots__/render.test.js.snap b/frontend/tests/__snapshots__/render.test.js.snap index e6eab39..e87264c 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
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > Grundzustand (Pfad aus, verworfene aus): Struktur-Snapshot 1`] = `"
  • Website-Relaunch
  • "`; -exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"
  • Website-Relaunch
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > günstigster Pfad an: cheap/cheap-leaf + implizite M-Badges 1`] = `"
  • Website-Relaunch
  • "`; -exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"
  • Website-Relaunch
  • "`; +exports[`renderTreeHtml — kanonisches Beispiel > verworfene einblenden: Eigenentwicklung erscheint (durchgestrichen) 1`] = `"
  • Website-Relaunch
  • "`; diff --git a/frontend/tests/effective.test.js b/frontend/tests/effective.test.js index 0992883..c684ed4 100644 --- a/frontend/tests/effective.test.js +++ b/frontend/tests/effective.test.js @@ -68,7 +68,10 @@ describe('Darstellung — Farbe effektiv, Marke intrinsisch', () => { it('färbt den Knoten mit dem effektiven Status und markiert ihn als held', () => { const {html} = render(TXT); - expect(html).toContain('class="node root-node held st-arbeit" tabindex="0" data-line="2"'); + /* `done` steht daneben: Es folgt dem INTRINSISCHEN Status (D46-Nachtrag) — + die Arbeit ist getan, auch wenn die Abhängigkeit den Knoten effektiv + zurückhält. Die Farbe bleibt die effektive. */ + expect(html).toContain('class="node root-node held done st-arbeit" tabindex="0" data-line="2"'); expect(html).not.toContain('st-fertig" tabindex="0" data-line="2"'); }); diff --git a/frontend/tests/frontier.test.js b/frontend/tests/frontier.test.js index a1cc335..2ddabe5 100644 --- a/frontend/tests/frontier.test.js +++ b/frontend/tests/frontier.test.js @@ -3,6 +3,7 @@ import { parse } from '../src/parser.js'; import { isDone, ownCost, cheapestCost, cheapCls, computeCheapSet, } from '../src/model.js'; +import { renderTreeHtml } from '../src/render.js'; /* Status-bewusster günstigster Pfad (SPEC §9, D46): Erledigtes kostet nichts mehr und ist keine Station; hervorgehoben wird der günstigste noch OFFENE @@ -144,3 +145,50 @@ describe('Eingeklappt — der Knoten vertritt offene Arbeit, nicht erledigte', ( expect(cheapCls(zweig, set, true)).toBe('cheap'); /* auf dem Pfad, aber nichts zu tun */ }); }); + +/* Die Klasse `done` trägt allein die Ausnahme von der Pfad-Inversion + (D46-Nachtrag): Erledigtes wird nie ausgeblasst, auch wenn es nicht auf + dem Pfad liegt. Sie folgt dem INTRINSISCHEN Status. */ +describe('Klasse `done` — Erledigtes tritt nie zurück', () => { + const html = txt => { + const rs = roots(txt); + return renderTreeHtml(rs, {t: k => k, showDiscarded: false, + cheapPath: true, cheapSet: computeCheapSet(rs)}).html; + }; + + it('setzt sie an [x] und [^], nicht an offenen Knoten', () => { + const h = html(`[ ] W (XS) + - [x] Fertig (S) + - [^] Live (S) + - [~] Offen (S)`); + expect(h).toContain('class="node cheap done st-fertig"'); + expect(h).toContain('class="node cheap done st-prod"'); + expect(h).toContain('class="node cheap cheap-leaf st-arbeit"'); + }); + + it('setzt sie auch am fertigen OPTIONALEN Knoten, der nie auf dem Pfad liegt', () => { + const rs = roots(`[ ] W (XS) + - [ ] Pflicht (S) + + [^] Zugabe (S)`); + const set = computeCheapSet(rs); + const zugabe = rs[0].children[1]; + expect(set.has(zugabe)).toBe(false); /* `+` ist nie auf dem Pfad (D29) */ + const h = renderTreeHtml(rs, {t: k => k, showDiscarded: false, + cheapPath: true, cheapSet: set}).html; + expect(h).toContain('class="node opt done st-prod"'); + }); + + it('setzt sie an der fertigen, nicht gewählten Alternative', () => { + const h = html(`[ ] W (XS) + | [^] Alt A (XS) + | [^] Alt B (XS)`); + expect(h.match(/class="node[^"]*done[^"]*st-prod"/g)).toHaveLength(2); + }); + + it('setzt sie nicht am verworfenen Knoten', () => { + const h = html(`[ ] W (XS) + - [ ] A (S) + - [-] Weg (S)`); + expect(h).not.toContain('done st-verworfen'); + }); +});