diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index f2a4794..22dae68 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -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**.
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 5105a0c..799b0fb 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -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):
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index 6b7b339..164cb04 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -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
diff --git a/frontend/index.html b/frontend/index.html
index 741a626..bd81fbd 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -112,6 +112,10 @@
+
+
+ 0
+
diff --git a/frontend/src/app.js b/frontend/src/app.js
index dff8efb..9b3a285 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 } 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 = `${esc(t('empty'))}
`;
+ 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){
diff --git a/frontend/src/model.js b/frontend/src/model.js
index 5ce6dc9..308830c 100644
--- a/frontend/src/model.js
+++ b/frontend/src/model.js
@@ -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;
+}
diff --git a/frontend/src/render.js b/frontend/src/render.js
index fc403a5..ab3d2c0 100644
--- a/frontend/src/render.js
+++ b/frontend/src/render.js
@@ -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,'&').replace(//g,'>');
}
@@ -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 `` +
- nodeHtml(k, cheapCls(k, opts.cheapSet), opts) +
+ nodeHtml(k, extraCls(k, opts), opts) +
renderChildren(k, warnings, opts) +
` `;
}).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 `` +
- nodeHtml(root, ('root-node ' + cheapCls(root, opts.cheapSet)).trim(), opts) +
+ nodeHtml(root, ('root-node ' + extraCls(root, opts)).trim(), opts) +
renderChildren(root, warnings, opts) +
` `;
}).join('');
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 120b528..e606f5e 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -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}
}
diff --git a/frontend/tests/fresh.test.js b/frontend/tests/fresh.test.js
new file mode 100644
index 0000000..9eb0d36
--- /dev/null
+++ b/frontend/tests/fresh.test.js
@@ -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']);
+ });
+});