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:
co-authored by
Claude Opus 4.8
parent
68abdc6625
commit
7a52277b84
@@ -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**.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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){
|
||||
|
||||
@@ -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
@@ -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,'<').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 `<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('');
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user