frontend: „Was ist neu?" — neu in Produktion mit gelbem Strahlenkranz

Dokumente von außen (mitgeliefert D27, ?sourceUrl= D23) ändern sich, ohne dass
der Betrachter es merkt. Sie zeigen jetzt, was sich seit seinem letzten Besuch
getan hat.

- „Neu" heißt bewusst NICHT „Zeile hinzugefügt", sondern **neu in Produktion**:
  ein Knoten trägt jetzt [^] und tat es in der zuletzt gesehenen Fassung nicht.
  Ein Zeilendiff meldete vor allem Rauschen; die Nachricht, die zählt, ist was
  live gegangen ist. Nebeneffekt: es leuchtet eine Handvoll Knoten, nicht dreißig.
- Basis ist die zuletzt GESEHENE Fassung je Dokument (`werkbaum-seen`), nicht
  die letzte Auslieferung — wer Fassungen überspringt, sieht alles seither.
  Fortgeschrieben wird erst beim Bestätigen, sonst wäre die Meldung nach einem
  Neuladen weg, bevor sie jemand bemerkt. Beim Erstkontakt leuchtet nichts.
- Knoten-Identität ist der Label-Pfad, nicht die Zeilennummer: Umeinrücken und
  Umsortieren erzeugen keine Falschmeldungen; gleichnamige Geschwister per
  Index; Umbenennen gilt als neuer Knoten (der Text ist der Vertrag, D14).
- Darstellung: gelber Strahlenkranz nach außen, kein Blinken (WCAG 2.2.2/2.3.1;
  Blinken zöge dauerhaft den Blick statt einmal zu melden). Außen, weil die
  Füllung dem Status gehört (SPEC §4). Zusammen mit der Cursor-Zeile: Tinte
  innen, Gelb außen. Nicht im Druck und nicht im Grafikexport — die Markierung
  hängt am persönlichen Besuchsstand.
- Knopf im Diagramm-Kopf nur, wenn es etwas gibt; nennt die Anzahl, Klick
  bestätigt. Kein Dauer-Umschalter.
- Bei selbst bearbeitetem mitgeliefertem Dokument wird nichts hervorgehoben —
  dort fehlt die saubere Vergleichsbasis (D27).

Fehler beim Bauen, der zuerst durchrutschte: Die Menge wurde beim Laden aus
einem eigenen Parse-Durchlauf berechnet. Der Zähler stimmte, aber kein Knoten
leuchtete — `Set.has()` prüft Objektidentität, und die gerenderten Knoten kamen
aus einem anderen Parse. `render()` bildet die Menge jetzt bei jedem Durchlauf
aus den gerade geparsten Wurzeln; vorgehalten wird nur der geparste Basisbaum.

Verifiziert: 9 neue Modelltests (Statuswechsel, neuer [^]-Knoten, nicht-[^]
ignoriert, unverändert ignoriert, Erstkontakt leer, Umsortieren/Tabs ohne
Falschmeldung, gleiches Label unter verschiedenen Eltern, gleichnamige
Geschwister, verworfene). Im Browser: Erstkontakt setzt nur die Basis (Knopf
versteckt); zurückgedrehte Basis lässt genau die erwarteten Knoten leuchten
(Zähler 3, dann 1); Bestätigen räumt auf und überlebt das Neuladen; ohne
Bestätigen bleibt die Meldung über ein Neuladen stehen. Vitest 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-07-27 15:18:20 +02:00
co-authored by Claude Opus 4.8
parent 68abdc6625
commit 7a52277b84
9 changed files with 293 additions and 5 deletions
+52
View File
@@ -665,3 +665,55 @@ Das fiel sofort auf, als der Plan um den Abschnitt „gemeinsam arbeiten"
Der Altwert `'1'` aus der ersten Fassung sagt nichts über den Textstand; dort
wird bewusst nichts überschrieben, nur der Merker ersetzt. Wer aus dieser kurzen
Zwischenfassung kommt, holt den aktuellen Stand über den Reset.
## D28 — „Was ist neu?": neu in Produktion, gelber Strahlenkranz
Dokumente von außen (mitgeliefert, D27; per `?sourceUrl=`, D23) ändern sich,
ohne dass der Betrachter es merkt. Sie zeigen deshalb, was sich seit seinem
letzten Besuch getan hat.
**„Neu" heißt: neu in Produktion.** Nicht „Zeile hinzugefügt". Ein Zeilendiff
meldet vor allem Rauschen — jede neu notierte Idee, jede Umformulierung. Die
Nachricht, die einen Plan-Leser wirklich angeht, ist: *was ist tatsächlich
live gegangen*. Also gilt ein Knoten als neu, wenn er **jetzt `[^]`** trägt und
es in der zuletzt gesehenen Fassung **nicht** tat (weil er anders stand oder
noch fehlte). Das macht die Hervorhebung zugleich sparsam: In einem großen Plan
leuchten typischerweise eine Handvoll Knoten, nicht dreißig.
**Basis ist die zuletzt GESEHENE Fassung, nicht die letzte Auslieferung.**
`werkbaum-seen` hält je Dokument-id den Text, den der Betrachter zuletzt
bestätigt hat. Wer drei Fassungen übersprungen hat, sieht alles seither. Die
Basis wird **erst beim Bestätigen** fortgeschrieben — schriebe man sie beim
Laden fort, wäre die Meldung nach einem Neuladen verschwunden, bevor sie jemand
bemerkt hat. Beim **Erstkontakt** leuchtet nichts (sonst strahlte beim ersten
Ansehen der gesamte fertige Teil des Plans auf); es wird nur die Basis gesetzt.
**Knoten-Identität ist der Label-Pfad**, nicht die Zeilennummer: Umeinrücken und
Umsortieren erzeugen so keine Falschmeldungen. Gleichnamige Geschwister werden
über einen Index unterschieden; ein umbenanntes Label gilt als neuer Knoten —
gewollt, der Text ist der Vertrag (D14).
**Darstellung: gelber Strahlenkranz nach außen.** Bewusst **kein Blinken** (vom
Nutzer erwogen): WCAG 2.2.2 verlangt, dass blinkende Inhalte abschaltbar sind,
2.3.1 begrenzt Flackern wegen des Anfallsrisikos — und vor allem zöge Blinken
*dauerhaft* den Blick, statt einmal zu melden. Der Schein liegt **außen**, weil
die Knotenfüllung dem Status gehört (SPEC §4) und lesbar bleiben muss. Ein
Knoten, der zugleich die Cursor-Zeile ist (D25), bekommt beides: Tinte innen,
Gelb außen. Im **Druck und im Grafikexport erscheint der Kranz nicht** — er
hängt an *deinem* letzten Besuch, ein Export damit hieße für jeden Betrachter
etwas anderes.
Ein **Knopf im Diagramm-Kopf** erscheint nur, wenn es etwas gibt, nennt die
Anzahl und bestätigt per Klick („gesehen"). Kein Dauer-Umschalter: Die Meldung
soll verschwinden, wenn sie ihren Zweck erfüllt hat.
**Zurückhaltung bei bearbeitetem Text.** Hat der Nutzer das mitgelieferte
Dokument geändert, wird es nicht mehr nachgezogen (D27) — dann gibt es keine
saubere Vergleichsbasis, und es wird nichts hervorgehoben.
**Stolperfalle (beim Bauen hineingelaufen):** Die Menge der neuen Knoten muss aus
**denselben Knotenobjekten** gebildet werden, die gerade gerendert werden. Zuerst
wurde sie beim Laden aus einem eigenen Parse-Durchlauf berechnet — der Zähler
stimmte, aber kein einziger Knoten leuchtete, weil `Set.has()` auf
Objektidentität prüft und die gerenderten Knoten aus einem anderen Parse kamen.
`render()` bildet die Menge daher bei jedem Durchlauf neu; vorgehalten wird nur
der **geparste Basisbaum**.
+11
View File
@@ -235,6 +235,17 @@ Doppelklick stellt die Vorgabe wieder her; die Aufteilung bleibt für beide
Ausrichtungen getrennt erhalten. Die Legende belegt höchstens 85 % des Panels,
damit das Textfeld nie ganz verschwindet. Siehe D26.
### Was ist neu? (Dokumente von außen)
Bei Dokumenten, die von außen kommen (mitgeliefert oder per `?sourceUrl=`), wird
gezeigt, was sich seit dem letzten Besuch getan hat. **„Neu" heißt: neu in
Produktion** — ein Knoten trägt jetzt `[^]` und tat es in der zuletzt gesehenen
Fassung nicht. Solche Knoten bekommen einen **gelben Strahlenkranz** nach außen
(die Füllung bleibt die Statusfarbe aus §4). Ein Knopf im Diagramm-Kopf nennt
die Anzahl und bestätigt per Klick; danach ist die aktuelle Fassung die neue
Vergleichsbasis. Beim ersten Ansehen eines Dokuments leuchtet nichts. Der Kranz
erscheint weder im Druck noch im Grafikexport — er hängt am persönlichen
Besuchsstand. Siehe D28.
### Sprung zwischen Diagramm und Text
Jeder Knoten kennt seine Zeilennummer im Notationstext; beide Richtungen sind
verknüpft (siehe D25):
+8
View File
@@ -174,6 +174,14 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
`werkbaum-ui` gesichert. Die 85-%-Obergrenze steht **zusätzlich** als
`max-width`/`max-height` im CSS — die gespeicherte px-Größe würde den Editor
sonst erdrücken, wenn das Panel später schrumpft.
- „Was ist neu?" (D28): `freshProdSet(prevRoots, currRoots)` in `model.js` liefert
die Knoten, die **neu `[^]`** sind (Identität = Label-Pfad, nicht Zeile).
`render()` bildet die Menge **bei jedem Durchlauf neu** aus den gerade
geparsten `roots` — eine vorab berechnete Menge stammte aus einem anderen
Parse-Durchlauf und träfe per Objektidentität nie zu (genau dieser Fehler ist
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`.
- Kleiner Bildschirm: `body.mobile` (per `matchMedia`, ≤ 640 px) stapelt
Diagramm/Editor mit **stufenlosem** Splitter (kein Snap/Collapse wie auf
Desktop): der Gutter-Drag ruft `setMobileDrow()` (klemmt `--drow` zwischen den
+4
View File
@@ -112,6 +112,10 @@
<button type="button" class="copybtn togglebtn" id="cheapBtn" aria-pressed="true" data-i18n-title="cheapTooltip" data-i18n-aria="cheapTooltip" title="Günstigsten Pfad hervorheben nicht benötigte Alternativen treten zurück" aria-label="Günstigsten Pfad hervorheben nicht benötigte Alternativen treten zurück">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.5 6H14a3 3 0 0 1 0 6H10a3 3 0 0 0 0 6h6.5"/></svg>
</button>
<button type="button" class="copybtn freshbtn" id="freshBtn" title="Neu in Produktion" aria-label="Neu in Produktion" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/></svg>
<span class="freshcount" id="freshCount" aria-hidden="true">0</span>
</button>
<div class="seg" role="radiogroup" aria-label="Darstellung">
<label data-i18n-title="modeHorizontal" title="Horizontal Organigramm, Diagramm über dem Editor">
<input type="radio" name="layout" value="horizontal" checked data-i18n-aria="modeHorizontal" aria-label="Horizontal Organigramm">
+82 -3
View File
@@ -1,6 +1,6 @@
import './style.css';
import { parse } from './parser.js';
import { computeCheapSet } from './model.js';
import { computeCheapSet, freshProdSet } from './model.js';
import { esc, renderTreeHtml } from './render.js';
import { formatWarning } from './warnings.js';
/* Werkbaum, mit Werkbaum geplant — als mitgeliefertes Dokument „Werkbank" (D27).
@@ -40,6 +40,12 @@ const warnBox = document.getElementById('warn');
let cheapPathOn = true;
/* Warnung des ?sourceUrl-Ladens (D23) — zeilenlos und persistent, siehe render(). */
let sourceWarning = null;
/* „Was ist neu?" (D28): Knoten, die gegenüber der zuletzt GESEHENEN Fassung neu
in Produktion sind. Gilt immer für genau ein Dokument (`freshDocId`) — nur
Dokumente von außen (mitgeliefert oder ?sourceUrl=) haben eine
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;
/* ---------- Renderer (Anbindung an den DOM) ----------
parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren ->
@@ -60,10 +66,18 @@ function render(){
if(!roots.length){
out.innerHTML = `<div class="empty">${esc(t('empty'))}</div>`;
freshSet = new Set();
} else {
const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set();
out.classList.toggle('cheap-on', cheapPathOn);
const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet});
/* Die Menge MUSS aus denselben Knotenobjekten gebildet werden, die gerade
gerendert werden — `freshProdSet` liefert Knoten aus `roots`. Eine früher
berechnete Menge stammte aus einem anderen Parse-Durchlauf und träfe per
Objektidentität nie zu (D28). */
freshSet = (freshDocId === activeId && freshPrevRoots)
? freshProdSet(freshPrevRoots, roots) : new Set();
const r = renderTreeHtml(roots, {t, showDiscarded, cheapPath: cheapPathOn, cheapSet,
freshSet});
out.innerHTML = r.html;
warnings = warnings.concat(r.warnings);
}
@@ -74,6 +88,7 @@ function render(){
/* Der Baum ist neu gebaut — die Markierung der Cursor-Zeile neu setzen (D25).
Ohne Scrollen: beim Tippen soll das Diagramm stehen bleiben. */
highlightCurrentNode(false);
updateFreshBtn(); /* Zähler folgt der gerade gerenderten Menge (D28) */
}
/* ---------- Günstigster-Pfad-Linie ----------
@@ -777,6 +792,7 @@ const I18N = {
agenda:"Agenda", discarded:"verworfene",
gutterTooltip:"Ziehen zum Verschieben, Doppelklick setzt zurück", gutterAria:"Bereiche größenverändern",
hintGutterAria:"Editor und Legende größenverändern",
freshTooltip:"Neu in Produktion seit dem letzten Ansehen: {n}. Klicken markiert sie als gesehen.",
modeHorizontal:"Horizontal Organigramm, Diagramm über dem Editor",
modeKompakt:"Kompakt alles nach unten, platzsparend",
modeVertikal:"Vertikal Baum nach rechts, Diagramm neben dem Editor",
@@ -827,6 +843,7 @@ const I18N = {
agenda:"Legend", discarded:"discarded",
gutterTooltip:"Drag to resize, double-click resets", gutterAria:"Resize the areas",
hintGutterAria:"Resize editor and legend",
freshTooltip:"New in production since you last looked: {n}. Click to mark them as seen.",
modeHorizontal:"Horizontal org chart, diagram above the editor",
modeKompakt:"Compact everything downward, space-saving",
modeVertikal:"Vertical tree to the right, diagram beside the editor",
@@ -877,6 +894,7 @@ const I18N = {
agenda:"Leyenda", discarded:"descartados",
gutterTooltip:"Arrastra para redimensionar, doble clic restablece", gutterAria:"Redimensionar las áreas",
hintGutterAria:"Redimensionar editor y leyenda",
freshTooltip:"Nuevo en producción desde la última vez: {n}. Haz clic para marcarlo como visto.",
modeHorizontal:"Horizontal organigrama, diagrama sobre el editor",
modeKompakt:"Compacto todo hacia abajo, ahorra espacio",
modeVertikal:"Vertical árbol hacia la derecha, diagrama junto al editor",
@@ -927,6 +945,7 @@ const I18N = {
agenda:"Légende", discarded:"abandonnés",
gutterTooltip:"Glisser pour redimensionner, double-clic pour réinitialiser", gutterAria:"Redimensionner les zones",
hintGutterAria:"Redimensionner l'éditeur et la légende",
freshTooltip:"Nouveau en production depuis votre dernière visite : {n}. Cliquez pour marquer comme vu.",
modeHorizontal:"Horizontal organigramme, diagramme au-dessus de l'éditeur",
modeKompakt:"Compact tout vers le bas, gain de place",
modeVertikal:"Vertical arbre vers la droite, diagramme à côté de l'éditeur",
@@ -977,6 +996,7 @@ const I18N = {
agenda:"Legenda", discarded:"odrzucone",
gutterTooltip:"Przeciągnij, aby zmienić rozmiar; dwuklik przywraca", gutterAria:"Zmień rozmiar obszarów",
hintGutterAria:"Zmień rozmiar edytora i legendy",
freshTooltip:"Nowe na produkcji od ostatniego razu: {n}. Kliknij, aby oznaczyć jako zobaczone.",
modeHorizontal:"Poziomo schemat organizacyjny, diagram nad edytorem",
modeKompakt:"Kompaktowo wszystko w dół, oszczędza miejsce",
modeVertikal:"Pionowo drzewo w prawo, diagram obok edytora",
@@ -1027,6 +1047,7 @@ const I18N = {
agenda:"Легенда", discarded:"отклонённые",
gutterTooltip:"Потяните, чтобы изменить размер; двойной щелчок сбрасывает", gutterAria:"Изменить размер областей",
hintGutterAria:"Изменить размер редактора и легенды",
freshTooltip:"Новое в продакшене с прошлого раза: {n}. Нажмите, чтобы отметить как просмотренное.",
modeHorizontal:"Горизонтально – оргсхема, диаграмма над редактором",
modeKompakt:"Компактно – всё вниз, экономит место",
modeVertikal:"Вертикально – дерево вправо, диаграмма рядом с редактором",
@@ -1077,6 +1098,7 @@ const I18N = {
agenda:"लेजेंड", discarded:"अस्वीकृत",
gutterTooltip:"आकार बदलने के लिए खींचें, डबल-क्लिक रीसेट करता है", gutterAria:"क्षेत्रों का आकार बदलें",
hintGutterAria:"संपादक और लेजेंड का आकार बदलें",
freshTooltip:"पिछली बार से उत्पादन में नया: {n}. देखा गया चिह्नित करने के लिए क्लिक करें।",
modeHorizontal:"क्षैतिज – संगठन-चार्ट, संपादक के ऊपर आरेख",
modeKompakt:"सघन – सब नीचे की ओर, जगह बचाता है",
modeVertikal:"लंबवत – पेड़ दाईं ओर, संपादक के बगल में आरेख",
@@ -1127,6 +1149,7 @@ const I18N = {
agenda:"图例", discarded:"已放弃",
gutterTooltip:"拖动可调整大小,双击可重置", gutterAria:"调整区域大小",
hintGutterAria:"调整编辑器和图例大小",
freshTooltip:"自上次查看以来新上线:{n}。点击标记为已查看。",
modeHorizontal:"横向——组织结构图,图表在编辑器上方",
modeKompakt:"紧凑——全部向下,节省空间",
modeVertikal:"纵向——树向右展开,图表在编辑器旁边",
@@ -1177,6 +1200,7 @@ const I18N = {
agenda:"凡例", discarded:"破棄",
gutterTooltip:"ドラッグでサイズ変更、ダブルクリックでリセット", gutterAria:"領域のサイズを変更",
hintGutterAria:"エディターと凡例のサイズを変更",
freshTooltip:"前回以降に本番化されたもの:{n} 件。クリックで既読にします。",
modeHorizontal:"横 — 組織図、ダイアグラムはエディターの上",
modeKompakt:"コンパクト — すべて下方向、省スペース",
modeVertikal:"縦 — ツリーを右へ、ダイアグラムはエディターの横",
@@ -1367,6 +1391,10 @@ function seedShippedDocs(){
doc.text = WERKBAUM_DOC;
}
try{ localStorage.setItem(LS_SEEDED, fp); }catch(_){}
/* Nur vergleichen, solange der Text der ausgelieferte ist — hat der Nutzer ihn
bearbeitet, gibt es keine saubere Vergleichsbasis (D28). */
const shipped = docs.find(d => d.id === WERKBAUM_ID);
if(shipped && shipped.text === WERKBAUM_DOC) computeFresh(WERKBAUM_ID, WERKBAUM_DOC);
}
/* Aus dem localStorage laden; bei fehlender Dokumentenliste den bestehenden
@@ -1517,7 +1545,57 @@ function toggleDocMenu(){ docMenu.hidden ? openDocMenu() : closeDocMenu(); }
/* Beim Wechseln/Anlegen/Löschen zuerst den aktuellen Editortext ins aktive
Dokument sichern, dann das Ziel laden und neu rendern. */
function flushActive(){ const d = activeDoc(); if(d) d.text = src.value; }
function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : ''; render(); updateDocName(); }
/* ---------- „Was ist neu?" (D28) ----------
Verglichen wird gegen die zuletzt **gesehene** Fassung eines Dokuments von
außen (mitgeliefert oder ?sourceUrl=), nicht gegen die letzte Auslieferung:
Wer mehrere Fassungen übersprungen hat, sieht alles seither. Die Basis wird
deshalb erst beim Bestätigen fortgeschrieben — sonst wäre die Meldung nach
einem Neuladen verschwunden, bevor sie jemand bemerkt hat. */
const LS_SEEN = 'werkbaum-seen';
function readSeen(){
try{ const o = JSON.parse(localStorage.getItem(LS_SEEN) || '{}'); return o && typeof o === 'object' ? o : {}; }
catch(_){ return {}; }
}
function markSeen(id, text){
const o = readSeen();
o[id] = text;
try{ localStorage.setItem(LS_SEEN, JSON.stringify(o)); }catch(_){}
}
/* Nach dem Laden eines Dokuments von außen aufrufen. Beim Erstkontakt wird nur
die Basis gesetzt und nichts hervorgehoben — sonst leuchtete beim ersten
Ansehen der gesamte fertige Teil des Plans auf. */
function computeFresh(id, text){
const prev = readSeen()[id];
freshDocId = id;
freshBaseline = text;
if(typeof prev !== 'string'){
freshPrevRoots = null; /* Erstkontakt: nur Basis setzen, nichts leuchtet */
markSeen(id, text);
} else {
freshPrevRoots = parse(prev).roots; /* einmal parsen, render() vergleicht dagegen */
}
}
function acknowledgeFresh(){
if(freshDocId && freshBaseline !== null) markSeen(freshDocId, freshBaseline);
freshPrevRoots = null;
render(); /* render() bildet die Menge neu und meldet dem Knopf */
}
const freshBtn = document.getElementById('freshBtn');
const freshCount = document.getElementById('freshCount');
function updateFreshBtn(){
const n = freshDocId === activeId ? freshSet.size : 0;
if(!freshBtn) return;
freshBtn.hidden = !n;
if(!n) return;
freshCount.textContent = String(n);
const tip = t('freshTooltip', {n: n});
freshBtn.title = tip;
freshBtn.setAttribute('aria-label', tip);
}
freshBtn.addEventListener('click', acknowledgeFresh);
function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : ''; render(); updateDocName(); updateFreshBtn(); }
function switchDoc(id){
if(id === activeId) return;
flushActive();
@@ -1614,6 +1692,7 @@ async function loadFromSourceUrl(){
d.source = url.href;
activeId = id;
sourceWarning = null;
computeFresh(id, text); /* was ist seit dem letzten Ansehen in Produktion? (D28) */
loadActiveIntoEditor();
persistDocs();
}catch(err){
+41
View File
@@ -68,3 +68,44 @@ export function cheapCls(n, cheapSet){
const leaf = !pathChildren(n).some(k => cheapSet.has(k));
return leaf ? 'cheap cheap-leaf' : 'cheap';
}
/* ---------- Was ist neu? (D28) ----------
„Neu" heißt hier bewusst nicht „Zeile hinzugefügt", sondern **neu in
Produktion**: ein Knoten, der jetzt `[^]` trägt und es in der zuletzt
gesehenen Fassung noch nicht tat (weil er anders stand oder fehlte). Das ist
die Änderungsmeldung, die einen Plan-Leser wirklich interessiert.
Knoten-Identität ist der **Pfad der Labels** von der Wurzel, nicht die
Zeilennummer: Umeinrücken oder Umsortieren erzeugt so keine Falschmeldungen.
Gleichnamige Geschwister werden über einen Index unterschieden. Ein
umbenanntes Label gilt als neuer Knoten — gewollt, der Text ist der Vertrag
(D14). */
function walkKeys(nodes, parentKey, fn){
const seen = new Map();
for(const n of nodes){
const base = parentKey + ' > ' + n.label;
const i = seen.get(base) || 0;
seen.set(base, i + 1);
const key = i ? base + '#' + i : base;
fn(key, n);
walkKeys(n.children, key, fn);
}
}
/* key -> Status-Schlüssel ('' für neutrale Knoten) über den ganzen Baum. */
export function statusByKey(roots){
const map = new Map();
walkKeys(roots, '', (key, n) => map.set(key, n.status ? n.status.key : ''));
return map;
}
/* Menge der Knoten, die gegenüber `prevRoots` NEU in Produktion sind.
`prevRoots == null` (noch keine Vergleichsfassung) ⇒ leere Menge — sonst
leuchtete beim ersten Ansehen der ganze fertige Teil des Plans auf. */
export function freshProdSet(prevRoots, currRoots){
const set = new Set();
if(!prevRoots) return set;
const before = statusByKey(prevRoots);
walkKeys(currRoots, '', (key, n) => {
if(n.status && n.status.key === 'prod' && before.get(key) !== 'prod') set.add(n);
});
return set;
}
+11 -2
View File
@@ -9,10 +9,19 @@
showDiscarded, // verworfene einblenden?
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)
} */
import { gateOf, needsBreakdown, visibleChildren, cheapCls } from './model.js';
/* Zusatzklassen eines Knotens: günstigster Pfad (D18) + „neu in Produktion"
gegenüber der zuletzt gesehenen Fassung (D28, `freshSet` optional). */
function extraCls(n, opts){
const cheap = cheapCls(n, opts.cheapSet);
const fresh = opts.freshSet && opts.freshSet.has(n) ? 'fresh' : '';
return [cheap, fresh].filter(Boolean).join(' ');
}
export function esc(s){
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
@@ -85,7 +94,7 @@ function renderChildren(node, warnings, opts){
const vk = visibleChildren(k, opts.showDiscarded);
const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
return `<li${liCls}>` +
nodeHtml(k, cheapCls(k, opts.cheapSet), opts) +
nodeHtml(k, extraCls(k, opts), opts) +
renderChildren(k, warnings, opts) +
`</li>`;
}).join('');
@@ -101,7 +110,7 @@ export function renderTreeHtml(roots, opts){
const vk = visibleChildren(root, opts.showDiscarded);
const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
return `<li${liCls}>` +
nodeHtml(root, ('root-node ' + cheapCls(root, opts.cheapSet)).trim(), opts) +
nodeHtml(root, ('root-node ' + extraCls(root, opts)).trim(), opts) +
renderChildren(root, warnings, opts) +
`</li>`;
}).join('');
+17
View File
@@ -237,6 +237,15 @@
.agenda .hint{border-top:0;flex:1 1 auto;min-height:0;overflow:auto}
/* Splitter zwischen Editor und Legende — dieselbe Sprache wie der große
Splitter, nur schmaler. Ohne offene Legende gibt es nichts zu teilen. */
/* Neuheiten-Knopf im Diagramm-Kopf (D28): Sternchen + Anzahl, nur sichtbar,
wenn es etwas gibt. Gelb wie der Strahlenkranz am Knoten. */
.freshbtn{position:relative;color:#A16207;border-color:#FACC15;background:#FEFCE8}
.freshbtn:hover{color:#854D0E;border-color:#EAB308}
.freshbtn[hidden]{display:none}
.freshcount{
font-family:'IBM Plex Mono',monospace;font-size:.62rem;font-weight:600;
margin-left:3px;vertical-align:2px;
}
.hint-gutter{
flex:0 0 10px;align-self:stretch;
cursor:col-resize;touch-action:none;
@@ -463,6 +472,13 @@
(weiter unten) ist spezifischer als `.node.current` und schluckte den Ring
sonst überall unterhalb einer any-of-Gruppe. */
#out .node.current{box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--ink)}
/* Neu in Produktion seit dem letzten Besuch (D28): gelber Strahlenkranz um den
Knoten. Bewusst ein Schein nach AUSSEN statt Farbe im Knoten — die Füllung
gehört dem Status (SPEC §4) und bleibt lesbar. Kein Blinken: WCAG 2.2.2/2.3.1
und es zöge dauerhaft den Blick. `#out`-Präfix wegen `ul.or .node`. */
#out .node.fresh{box-shadow:0 0 0 2px #FACC15,0 0 16px 5px rgba(234,179,8,.65)}
/* Beides zugleich: Cursor-Zeile innen (Tinte), Neuheit außen (gelb). */
#out .node.fresh.current{box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--ink),0 0 18px 6px rgba(234,179,8,.65)}
/* Alt gedrückt = Sprung-Geste scharf: Cursor an allen Knoten, Petrol-Ring am
Knoten unter dem Zeiger. Auf verlinkten Knoten ist das die einzige
Rückmeldung, weil dort der einfache Klick dem Link gehört (D25). */
@@ -792,5 +808,6 @@
/* Knoten nicht über den Seitenrand zerschneiden */
#out li{break-inside:avoid}
.node.current{box-shadow:none!important} /* Editierhilfe, nicht drucken (D25) */
.node.fresh{box-shadow:none!important} /* „neu seit dem letzten Besuch" ist persönlich (D28) */
@page{margin:12mm}
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { parse } from '../src/parser.js';
import { freshProdSet, statusByKey } from '../src/model.js';
const roots = txt => parse(txt).roots;
const labels = set => [...set].map(n => n.label).sort();
/* „Neu" heißt: jetzt [^] in Produktion, vorher nicht (D28). */
describe('freshProdSet — neu in Produktion', () => {
const VORHER = `[~] Werkzeug (L)
- [x] Parser (S)
- [ ] Export (M)
- [ ] SVG (S)
- [ ] PNG (S)`;
it('meldet einen Knoten, dessen Status auf [^] gewechselt ist', () => {
const NACHHER = VORHER.replace('- [x] Parser (S)', '- [^] Parser (S)');
expect(labels(freshProdSet(roots(VORHER), roots(NACHHER)))).toEqual(['Parser']);
});
it('meldet einen neu hinzugekommenen [^]-Knoten', () => {
const NACHHER = VORHER + '\n - [^] Doku (S)';
expect(labels(freshProdSet(roots(VORHER), roots(NACHHER)))).toEqual(['Doku']);
});
it('meldet neue Knoten, die NICHT in Produktion sind, nicht', () => {
const NACHHER = VORHER + '\n - [ ] Doku (S)\n - [?] Idee (S)';
expect(freshProdSet(roots(VORHER), roots(NACHHER)).size).toBe(0);
});
it('meldet unveränderte [^]-Knoten nicht (nur den Übergang)', () => {
const A = '[^] Fertig (S)';
expect(freshProdSet(roots(A), roots(A)).size).toBe(0);
});
it('ohne Vergleichsfassung ist nichts neu — sonst leuchtete beim Erstkontakt alles', () => {
expect(freshProdSet(null, roots('[^] A (S)\n[^] B (S)')).size).toBe(0);
});
it('Umeinrücken und Umsortieren erzeugen keine Falschmeldung (Identität = Label-Pfad)', () => {
const A = '[~] Wurzel (L)\n - [^] Eins (S)\n - [x] Zwei (S)';
const B = '[~] Wurzel (L)\n\t- [x] Zwei (S)\n\t- [^] Eins (S)'; /* Tab statt Leerzeichen, getauscht */
expect(freshProdSet(roots(A), roots(B)).size).toBe(0);
});
it('gleicher Label unter verschiedenen Eltern wird auseinandergehalten', () => {
const A = '[~] W (L)\n - [ ] Alpha (M)\n - [ ] Test (S)\n - [ ] Beta (M)\n - [ ] Test (S)';
const B = A.replace('- [ ] Beta (M)\n - [ ] Test (S)', '- [ ] Beta (M)\n - [^] Test (S)');
const set = freshProdSet(roots(A), roots(B));
expect(set.size).toBe(1);
/* der gemeldete „Test" hängt unter Beta, nicht unter Alpha */
const key = [...statusByKey(roots(B)).keys()].find(k => k.includes('Beta') && k.includes('Test'));
expect(key).toBeTruthy();
});
it('gleichnamige Geschwister bekommen getrennte Identitäten', () => {
const A = '[~] W (L)\n - [ ] Doppelt (S)\n - [ ] Doppelt (S)';
const B = '[~] W (L)\n - [ ] Doppelt (S)\n - [^] Doppelt (S)';
expect(freshProdSet(roots(A), roots(B)).size).toBe(1);
});
it('erfasst auch verworfene Teilbäume nicht als Produktion', () => {
const A = '[~] W (L)\n - [-] Weg (S)';
const B = '[~] W (L)\n - [-] Weg (S)\n - [^] Da (S)';
expect(labels(freshProdSet(roots(A), roots(B)))).toEqual(['Da']);
});
});