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 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 wird bewusst nichts überschrieben, nur der Merker ersetzt. Wer aus dieser kurzen
Zwischenfassung kommt, holt den aktuellen Stand über den Reset. 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, Ausrichtungen getrennt erhalten. Die Legende belegt höchstens 85 % des Panels,
damit das Textfeld nie ganz verschwindet. Siehe D26. 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 ### Sprung zwischen Diagramm und Text
Jeder Knoten kennt seine Zeilennummer im Notationstext; beide Richtungen sind Jeder Knoten kennt seine Zeilennummer im Notationstext; beide Richtungen sind
verknüpft (siehe D25): 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 `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 `max-width`/`max-height` im CSS — die gespeicherte px-Größe würde den Editor
sonst erdrücken, wenn das Panel später schrumpft. 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 - Kleiner Bildschirm: `body.mobile` (per `matchMedia`, ≤ 640 px) stapelt
Diagramm/Editor mit **stufenlosem** Splitter (kein Snap/Collapse wie auf Diagramm/Editor mit **stufenlosem** Splitter (kein Snap/Collapse wie auf
Desktop): der Gutter-Drag ruft `setMobileDrow()` (klemmt `--drow` zwischen den 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"> <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> <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>
<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"> <div class="seg" role="radiogroup" aria-label="Darstellung">
<label data-i18n-title="modeHorizontal" title="Horizontal Organigramm, Diagramm über dem Editor"> <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"> <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 './style.css';
import { parse } from './parser.js'; import { parse } from './parser.js';
import { computeCheapSet } from './model.js'; import { computeCheapSet, freshProdSet } from './model.js';
import { esc, renderTreeHtml } from './render.js'; import { esc, renderTreeHtml } from './render.js';
import { formatWarning } from './warnings.js'; import { formatWarning } from './warnings.js';
/* Werkbaum, mit Werkbaum geplant — als mitgeliefertes Dokument „Werkbank" (D27). /* Werkbaum, mit Werkbaum geplant — als mitgeliefertes Dokument „Werkbank" (D27).
@@ -40,6 +40,12 @@ const warnBox = document.getElementById('warn');
let cheapPathOn = true; let cheapPathOn = true;
/* Warnung des ?sourceUrl-Ladens (D23) — zeilenlos und persistent, siehe render(). */ /* Warnung des ?sourceUrl-Ladens (D23) — zeilenlos und persistent, siehe render(). */
let sourceWarning = null; 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) ---------- /* ---------- Renderer (Anbindung an den DOM) ----------
parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren -> parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren ->
@@ -60,10 +66,18 @@ function render(){
if(!roots.length){ if(!roots.length){
out.innerHTML = `<div class="empty">${esc(t('empty'))}</div>`; out.innerHTML = `<div class="empty">${esc(t('empty'))}</div>`;
freshSet = new Set();
} else { } else {
const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set(); const cheapSet = cheapPathOn ? computeCheapSet(roots) : new Set();
out.classList.toggle('cheap-on', cheapPathOn); 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; out.innerHTML = r.html;
warnings = warnings.concat(r.warnings); warnings = warnings.concat(r.warnings);
} }
@@ -74,6 +88,7 @@ function render(){
/* Der Baum ist neu gebaut — die Markierung der Cursor-Zeile neu setzen (D25). /* Der Baum ist neu gebaut — die Markierung der Cursor-Zeile neu setzen (D25).
Ohne Scrollen: beim Tippen soll das Diagramm stehen bleiben. */ Ohne Scrollen: beim Tippen soll das Diagramm stehen bleiben. */
highlightCurrentNode(false); highlightCurrentNode(false);
updateFreshBtn(); /* Zähler folgt der gerade gerenderten Menge (D28) */
} }
/* ---------- Günstigster-Pfad-Linie ---------- /* ---------- Günstigster-Pfad-Linie ----------
@@ -777,6 +792,7 @@ const I18N = {
agenda:"Agenda", discarded:"verworfene", agenda:"Agenda", discarded:"verworfene",
gutterTooltip:"Ziehen zum Verschieben, Doppelklick setzt zurück", gutterAria:"Bereiche größenverändern", gutterTooltip:"Ziehen zum Verschieben, Doppelklick setzt zurück", gutterAria:"Bereiche größenverändern",
hintGutterAria:"Editor und Legende 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", modeHorizontal:"Horizontal Organigramm, Diagramm über dem Editor",
modeKompakt:"Kompakt alles nach unten, platzsparend", modeKompakt:"Kompakt alles nach unten, platzsparend",
modeVertikal:"Vertikal Baum nach rechts, Diagramm neben dem Editor", modeVertikal:"Vertikal Baum nach rechts, Diagramm neben dem Editor",
@@ -827,6 +843,7 @@ const I18N = {
agenda:"Legend", discarded:"discarded", agenda:"Legend", discarded:"discarded",
gutterTooltip:"Drag to resize, double-click resets", gutterAria:"Resize the areas", gutterTooltip:"Drag to resize, double-click resets", gutterAria:"Resize the areas",
hintGutterAria:"Resize editor and legend", 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", modeHorizontal:"Horizontal org chart, diagram above the editor",
modeKompakt:"Compact everything downward, space-saving", modeKompakt:"Compact everything downward, space-saving",
modeVertikal:"Vertical tree to the right, diagram beside the editor", modeVertikal:"Vertical tree to the right, diagram beside the editor",
@@ -877,6 +894,7 @@ const I18N = {
agenda:"Leyenda", discarded:"descartados", agenda:"Leyenda", discarded:"descartados",
gutterTooltip:"Arrastra para redimensionar, doble clic restablece", gutterAria:"Redimensionar las áreas", gutterTooltip:"Arrastra para redimensionar, doble clic restablece", gutterAria:"Redimensionar las áreas",
hintGutterAria:"Redimensionar editor y leyenda", 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", modeHorizontal:"Horizontal organigrama, diagrama sobre el editor",
modeKompakt:"Compacto todo hacia abajo, ahorra espacio", modeKompakt:"Compacto todo hacia abajo, ahorra espacio",
modeVertikal:"Vertical árbol hacia la derecha, diagrama junto al editor", modeVertikal:"Vertical árbol hacia la derecha, diagrama junto al editor",
@@ -927,6 +945,7 @@ const I18N = {
agenda:"Légende", discarded:"abandonnés", agenda:"Légende", discarded:"abandonnés",
gutterTooltip:"Glisser pour redimensionner, double-clic pour réinitialiser", gutterAria:"Redimensionner les zones", gutterTooltip:"Glisser pour redimensionner, double-clic pour réinitialiser", gutterAria:"Redimensionner les zones",
hintGutterAria:"Redimensionner l'éditeur et la légende", 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", modeHorizontal:"Horizontal organigramme, diagramme au-dessus de l'éditeur",
modeKompakt:"Compact tout vers le bas, gain de place", modeKompakt:"Compact tout vers le bas, gain de place",
modeVertikal:"Vertical arbre vers la droite, diagramme à côté de l'éditeur", modeVertikal:"Vertical arbre vers la droite, diagramme à côté de l'éditeur",
@@ -977,6 +996,7 @@ const I18N = {
agenda:"Legenda", discarded:"odrzucone", agenda:"Legenda", discarded:"odrzucone",
gutterTooltip:"Przeciągnij, aby zmienić rozmiar; dwuklik przywraca", gutterAria:"Zmień rozmiar obszarów", gutterTooltip:"Przeciągnij, aby zmienić rozmiar; dwuklik przywraca", gutterAria:"Zmień rozmiar obszarów",
hintGutterAria:"Zmień rozmiar edytora i legendy", 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", modeHorizontal:"Poziomo schemat organizacyjny, diagram nad edytorem",
modeKompakt:"Kompaktowo wszystko w dół, oszczędza miejsce", modeKompakt:"Kompaktowo wszystko w dół, oszczędza miejsce",
modeVertikal:"Pionowo drzewo w prawo, diagram obok edytora", modeVertikal:"Pionowo drzewo w prawo, diagram obok edytora",
@@ -1027,6 +1047,7 @@ const I18N = {
agenda:"Легенда", discarded:"отклонённые", agenda:"Легенда", discarded:"отклонённые",
gutterTooltip:"Потяните, чтобы изменить размер; двойной щелчок сбрасывает", gutterAria:"Изменить размер областей", gutterTooltip:"Потяните, чтобы изменить размер; двойной щелчок сбрасывает", gutterAria:"Изменить размер областей",
hintGutterAria:"Изменить размер редактора и легенды", hintGutterAria:"Изменить размер редактора и легенды",
freshTooltip:"Новое в продакшене с прошлого раза: {n}. Нажмите, чтобы отметить как просмотренное.",
modeHorizontal:"Горизонтально – оргсхема, диаграмма над редактором", modeHorizontal:"Горизонтально – оргсхема, диаграмма над редактором",
modeKompakt:"Компактно – всё вниз, экономит место", modeKompakt:"Компактно – всё вниз, экономит место",
modeVertikal:"Вертикально – дерево вправо, диаграмма рядом с редактором", modeVertikal:"Вертикально – дерево вправо, диаграмма рядом с редактором",
@@ -1077,6 +1098,7 @@ const I18N = {
agenda:"लेजेंड", discarded:"अस्वीकृत", agenda:"लेजेंड", discarded:"अस्वीकृत",
gutterTooltip:"आकार बदलने के लिए खींचें, डबल-क्लिक रीसेट करता है", gutterAria:"क्षेत्रों का आकार बदलें", gutterTooltip:"आकार बदलने के लिए खींचें, डबल-क्लिक रीसेट करता है", gutterAria:"क्षेत्रों का आकार बदलें",
hintGutterAria:"संपादक और लेजेंड का आकार बदलें", hintGutterAria:"संपादक और लेजेंड का आकार बदलें",
freshTooltip:"पिछली बार से उत्पादन में नया: {n}. देखा गया चिह्नित करने के लिए क्लिक करें।",
modeHorizontal:"क्षैतिज – संगठन-चार्ट, संपादक के ऊपर आरेख", modeHorizontal:"क्षैतिज – संगठन-चार्ट, संपादक के ऊपर आरेख",
modeKompakt:"सघन – सब नीचे की ओर, जगह बचाता है", modeKompakt:"सघन – सब नीचे की ओर, जगह बचाता है",
modeVertikal:"लंबवत – पेड़ दाईं ओर, संपादक के बगल में आरेख", modeVertikal:"लंबवत – पेड़ दाईं ओर, संपादक के बगल में आरेख",
@@ -1127,6 +1149,7 @@ const I18N = {
agenda:"图例", discarded:"已放弃", agenda:"图例", discarded:"已放弃",
gutterTooltip:"拖动可调整大小,双击可重置", gutterAria:"调整区域大小", gutterTooltip:"拖动可调整大小,双击可重置", gutterAria:"调整区域大小",
hintGutterAria:"调整编辑器和图例大小", hintGutterAria:"调整编辑器和图例大小",
freshTooltip:"自上次查看以来新上线:{n}。点击标记为已查看。",
modeHorizontal:"横向——组织结构图,图表在编辑器上方", modeHorizontal:"横向——组织结构图,图表在编辑器上方",
modeKompakt:"紧凑——全部向下,节省空间", modeKompakt:"紧凑——全部向下,节省空间",
modeVertikal:"纵向——树向右展开,图表在编辑器旁边", modeVertikal:"纵向——树向右展开,图表在编辑器旁边",
@@ -1177,6 +1200,7 @@ const I18N = {
agenda:"凡例", discarded:"破棄", agenda:"凡例", discarded:"破棄",
gutterTooltip:"ドラッグでサイズ変更、ダブルクリックでリセット", gutterAria:"領域のサイズを変更", gutterTooltip:"ドラッグでサイズ変更、ダブルクリックでリセット", gutterAria:"領域のサイズを変更",
hintGutterAria:"エディターと凡例のサイズを変更", hintGutterAria:"エディターと凡例のサイズを変更",
freshTooltip:"前回以降に本番化されたもの:{n} 件。クリックで既読にします。",
modeHorizontal:"横 — 組織図、ダイアグラムはエディターの上", modeHorizontal:"横 — 組織図、ダイアグラムはエディターの上",
modeKompakt:"コンパクト — すべて下方向、省スペース", modeKompakt:"コンパクト — すべて下方向、省スペース",
modeVertikal:"縦 — ツリーを右へ、ダイアグラムはエディターの横", modeVertikal:"縦 — ツリーを右へ、ダイアグラムはエディターの横",
@@ -1367,6 +1391,10 @@ function seedShippedDocs(){
doc.text = WERKBAUM_DOC; doc.text = WERKBAUM_DOC;
} }
try{ localStorage.setItem(LS_SEEDED, fp); }catch(_){} 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 /* 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 /* Beim Wechseln/Anlegen/Löschen zuerst den aktuellen Editortext ins aktive
Dokument sichern, dann das Ziel laden und neu rendern. */ Dokument sichern, dann das Ziel laden und neu rendern. */
function flushActive(){ const d = activeDoc(); if(d) d.text = src.value; } 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){ function switchDoc(id){
if(id === activeId) return; if(id === activeId) return;
flushActive(); flushActive();
@@ -1614,6 +1692,7 @@ async function loadFromSourceUrl(){
d.source = url.href; d.source = url.href;
activeId = id; activeId = id;
sourceWarning = null; sourceWarning = null;
computeFresh(id, text); /* was ist seit dem letzten Ansehen in Produktion? (D28) */
loadActiveIntoEditor(); loadActiveIntoEditor();
persistDocs(); persistDocs();
}catch(err){ }catch(err){
+41
View File
@@ -68,3 +68,44 @@ export function cheapCls(n, cheapSet){
const leaf = !pathChildren(n).some(k => cheapSet.has(k)); const leaf = !pathChildren(n).some(k => cheapSet.has(k));
return leaf ? 'cheap cheap-leaf' : 'cheap'; 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? showDiscarded, // verworfene einblenden?
cheapPath, // günstigster Pfad aktiv? (steuert das implizite M-Badge) cheapPath, // günstigster Pfad aktiv? (steuert das implizite M-Badge)
cheapSet, // Set der nötigen Knoten (leer, wenn Pfad aus) 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'; 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){ export function esc(s){
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); 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 vk = visibleChildren(k, opts.showDiscarded);
const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : ''; const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
return `<li${liCls}>` + return `<li${liCls}>` +
nodeHtml(k, cheapCls(k, opts.cheapSet), opts) + nodeHtml(k, extraCls(k, opts), opts) +
renderChildren(k, warnings, opts) + renderChildren(k, warnings, opts) +
`</li>`; `</li>`;
}).join(''); }).join('');
@@ -101,7 +110,7 @@ export function renderTreeHtml(roots, opts){
const vk = visibleChildren(root, opts.showDiscarded); const vk = visibleChildren(root, opts.showDiscarded);
const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : ''; const liCls = vk.length ? (gateOf(vk) === 'or' ? ' class="has-or"' : ' class="has-and"') : '';
return `<li${liCls}>` + 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) + renderChildren(root, warnings, opts) +
`</li>`; `</li>`;
}).join(''); }).join('');
+17
View File
@@ -237,6 +237,15 @@
.agenda .hint{border-top:0;flex:1 1 auto;min-height:0;overflow:auto} .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 zwischen Editor und Legende — dieselbe Sprache wie der große
Splitter, nur schmaler. Ohne offene Legende gibt es nichts zu teilen. */ 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{ .hint-gutter{
flex:0 0 10px;align-self:stretch; flex:0 0 10px;align-self:stretch;
cursor:col-resize;touch-action:none; cursor:col-resize;touch-action:none;
@@ -463,6 +472,13 @@
(weiter unten) ist spezifischer als `.node.current` und schluckte den Ring (weiter unten) ist spezifischer als `.node.current` und schluckte den Ring
sonst überall unterhalb einer any-of-Gruppe. */ sonst überall unterhalb einer any-of-Gruppe. */
#out .node.current{box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--ink)} #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 /* Alt gedrückt = Sprung-Geste scharf: Cursor an allen Knoten, Petrol-Ring am
Knoten unter dem Zeiger. Auf verlinkten Knoten ist das die einzige Knoten unter dem Zeiger. Auf verlinkten Knoten ist das die einzige
Rückmeldung, weil dort der einfache Klick dem Link gehört (D25). */ Rückmeldung, weil dort der einfache Klick dem Link gehört (D25). */
@@ -792,5 +808,6 @@
/* Knoten nicht über den Seitenrand zerschneiden */ /* Knoten nicht über den Seitenrand zerschneiden */
#out li{break-inside:avoid} #out li{break-inside:avoid}
.node.current{box-shadow:none!important} /* Editierhilfe, nicht drucken (D25) */ .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} @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']);
});
});