diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index aad21ec..5a6943d 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -3784,3 +3784,61 @@ DOM), und dafür gibt es keine Testumgebung — dieselbe Lücke wie bei
`applyOptStairs()` (D29) und `drawDepLinks()` (D41). Ein Fehler dieser Art
fällt deshalb erst im Browser auf, und das ist der Preis dafür, dass die
Zustandslogik im UI-Modul sitzt statt in `model.js`.
+
+**Nachtrag 3 — die Regeln ziehen nach `snapshots.js` um, damit sie prüfbar
+sind.** Nachtrag 2 endete mit dem Satz, die Stände seien „nicht durch Tests
+gedeckt … das ist der Preis dafür, dass die Zustandslogik im UI-Modul sitzt".
+Der Preis war zu hoch: Der Fehler kam bis in Produktion, und ein Test hätte
+ihn in einer Zeile gefunden. Also wird nicht der Preis bezahlt, sondern die
+Ursache beseitigt.
+
+**Geschnitten wird nach dem Vorbild von `remote.js` (D31): die entscheidbare
+Hälfte heraus, die I/O bleibt.** `snapshots.js` beantwortet, **was gilt** —
+wann ein Stand entsteht (`addSnapshot`), was bei Platzmangel zuerst fliegt
+(`dropOldestSnap`, `persistSnaps`), was aus dem Speicher überhaupt gelesen
+werden darf (`parseSnaps`) und wie ein Eintrag heißt (`snapLabel`). In
+`app.js` bleibt, **woher die Werte kommen** (aktives Dokument, Schreibschutz,
+Textfeld) und **wohin sie gehen** (`localStorage`, Menü). Der Speicher wird
+als `{setItem, removeItem}` hereingereicht, die Uhr als Zahl — genau die
+beiden Abhängigkeiten, an denen die Prüfbarkeit vorher scheiterte. `app.js`
+verliert dabei 55 Zeilen; `snapshotNow()` schrumpft auf sechs.
+
+**Kein Store-Objekt, keine Klasse:** Die Nachbarmodule sind schlichte
+Funktionsmodule, und `snaps` als Parameter durchzureichen kostet nichts. Wo
+das Original den Zustand verändert hat, tut es das weiter (`addSnapshot`
+hängt an die Liste an) — das ist im Test genauso ablesbar und hält den Diff
+klein.
+
+**Die Gegenprobe zählt, nicht die Zahl der Tests.** Baut man exakt den
+ausgelieferten Fehler wieder ein (`manual ? null : base` → `base`), fällt
+**genau eine** Zusicherung — die, die nach ihm benannt ist —, und die
+übrigen 27 bleiben grün. Ein Test, von dem man das nicht geprüft hat, ist nur
+eine Behauptung.
+
+28 Tests decken jetzt ab: Knopf gegen Takt (beide Richtungen, inklusive des
+gemeldeten Falls), kein Doppel-Eintrag, Deckelung bei 20, dokumentübergreifende
+Verdrängung des Ältesten, das Aufgeben bei vollem Speicher samt Wegräumen des
+Schlüssels, sechs Formen beschädigten Speichers und die Beschriftung (heute
+nur Uhrzeit, sonst mit Datum, Kalendertag statt 24 Stunden, Rückfall bei
+unbekannter Sprache).
+
+**Zwei Verhaltensänderungen, beide bewusst.** `parseSnaps()` wirft Einträge
+weg, die nicht die erwartete Form haben (Liste kein Array, `t` keine Zahl,
+`text` kein String) — vorher wären sie stehen geblieben und hätten beim Lesen
+zugeschlagen. Und ein Array statt eines Objekts im Speicher ergibt jetzt `{}`
+statt eines halb benutzbaren Zustands. Ein Sicherheitsnetz darf die App nicht
+umbringen.
+
+**Was Unit-Tests weiterhin nicht abdecken, ist die Verdrahtung** — dass
+`app.js` `base` und `manual` richtig durchreicht. Dafür bleibt die Messung im
+Browser, und sie ist nach dem Umbau wiederholt worden: Knopf ohne jede
+Eingabe → ein Stand; Takt (für die Prüfung auf 2 s) bei bloß angesehenem
+Dokument → **0**, nach einer Änderung → 1, weitere Takte → 1.
+
+**Nebenbefund, als Werkzeugfalle notiert:** Die Konsole des Browser-Werkzeugs
+puffert kumulativ — `console.clear()` und ein Neuladen räumen sie nicht. Eine
+`ReferenceError`-Meldung aus einer HMR-Zwischenfassung stand deshalb noch da,
+als der Fehler längst weg war. Auseinandergehalten hat es der `?t=`-Stempel
+im Stacktrace gegen den der geladenen Datei (`performance.getEntriesByType`):
+`…961240` gegen `…049984`. Dieselbe Sorte Lehre wie D25 und D17-Nachtrag 4 —
+die Meldung des Werkzeugs ist noch kein Befund.
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index c43a392..9d9e3c4 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -66,6 +66,16 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
`app.js` (DOM/Events/i18n/Persistenz/Export). Modell/Renderer bekommen UI-State
(verworfene einblenden, Pfad an/aus) als **Parameter** — keine Globals; nur
`cheapPathOn` lebt als UI-State in `app.js`. Tests: `tests/*.test.js`.
+- **Was entscheidbar ist, gehört in ein eigenes Modul** — auch bei Features, die
+ wie reine UI aussehen: `remote.js` (Pad-URLs normalisieren, D31),
+ `warnings.js` (Warnung → Text), `snapshots.js` (frühere Stände: wann entsteht
+ ein Stand, was fliegt bei Platzmangel raus, wie sieht der Speicherinhalt aus).
+ Dort steht **was gilt**, in `app.js` bleibt **woher die Werte kommen und wohin
+ sie gehen**; Speicher (`{setItem, removeItem}`) und Uhr (`Date.now()`) werden
+ hereingereicht, damit der Test sie stellen kann. Anlass war ein Fehler, der bis
+ in Produktion kam und den ein Test in einer Zeile gefunden hätte
+ (D54-Nachtrag 3). Faustregel: Sobald du eine Regel im Browser „nachmisst",
+ gehört sie in ein Modul.
- Günstigster Pfad: `computeCheapPlan()`/`cheapestCost()`/`cheapCls()` (in
`model.js`) markieren
die nötigen Knoten (Klassen `cheap`, `cheap-leaf`); `drawCheapPath()` (app.js)
diff --git a/frontend/src/app.js b/frontend/src/app.js
index abaecc3..68e63d9 100644
--- a/frontend/src/app.js
+++ b/frontend/src/app.js
@@ -4,6 +4,8 @@ import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveSt
import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js';
import { padUrls } from './remote.js';
+import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel }
+ from './snapshots.js';
/* Werkbaum, mit Werkbaum geplant — als mitgeliefertes Dokument „Werkbank" (D27).
Dieselbe Datei, die auch per ?sourceUrl= geladen werden kann; `?raw` bettet
sie beim Build in die eine Ausgabedatei ein (D19), es wird nichts nachgeladen
@@ -2926,65 +2928,24 @@ function flushActive(){ const d = activeDoc(); if(d) d.text = src.value; }
wenn er sich seit dem letzten Stand geändert hat. Aufgehoben werden die
letzten 20 je Dokument (rund 3½ Stunden Arbeit bei gleichmäßigen Abständen).
Es ist ein Sicherheitsnetz gegen Versehen, kein Versionsverwaltungssystem —
- wer weiter zurück will, hat Git. */
-const LS_SNAPS = 'werkbaum-snaps';
-const SNAP_EVERY = 10 * 60 * 1000;
-const SNAP_KEEP = 20;
+ wer weiter zurück will, hat Git.
+
+ Die Regeln stehen in snapshots.js und sind dort getestet; hier bleibt nur,
+ was DOM oder Speicher berührt: welches Dokument aktiv ist, ob sein Text
+ beschreibbar ist (Pad-Dokumente, D31, bleiben außen vor — ein alter Stand
+ ließe sich dort gar nicht einsetzen), und das Nachzeichnen des Menüs. */
let snaps = {}; /* {docId: [{t, text}, …]} — ältester zuerst */
let snapBase = ''; /* Text bei Dokumentwechsel; Vergleich, solange es keinen Stand gibt */
-function loadSnaps(){
- try{ const o = JSON.parse(localStorage.getItem(LS_SNAPS) || '{}');
- snaps = o && typeof o === 'object' ? o : {}; }
- catch(_){ snaps = {}; }
-}
-/* Der Platz im localStorage ist geteilt. Läuft er über, sollen die
- **Dokumente** überleben, nicht ihre Stände — der Fehlerfall wirft deshalb
- Stände weg, bis es passt, notfalls alle. */
-function persistSnaps(){
- for(;;){
- try{ localStorage.setItem(LS_SNAPS, JSON.stringify(snaps)); return true; }
- catch(_){
- if(!dropOldestSnap()){ try{ localStorage.removeItem(LS_SNAPS); }catch(_){} return false; }
- }
- }
-}
-function dropOldestSnap(){
- let id = null, t = Infinity;
- for(const k in snaps){
- const l = snaps[k];
- if(l && l.length && l[0].t < t){ t = l[0].t; id = k; }
- }
- if(id === null) return false;
- snaps[id].shift();
- if(!snaps[id].length) delete snaps[id];
- return true;
-}
-/* Legt den aktuellen Text weg, wenn er neu ist. Pad-Dokumente (D31) bleiben
- außen vor: Ihr Text ist schreibgeschützt, ein alter Stand ließe sich dort
- gar nicht wieder einsetzen — Stände zu sammeln, die niemand laden kann,
- wäre nur Ballast. */
-/* `manuell` schaltet die `snapBase`-Sperre ab — und das ist der ganze
- Unterschied zwischen Takt und Knopf. `snapBase` ist der Text beim Öffnen des
- Dokuments; solange nichts daran geändert wurde, soll der **Takt** nichts
- sammeln (sonst legte jedes bloße Ansehen einen Stand an). Für den Knopf wäre
- dieselbe Sperre falsch: „vor der großen Änderung sichern" heißt gerade, dass
- noch nichts geändert ist. Bei leerer Liste ist der Text dann **nirgends**
- gesichert, und der Knopf bestätigte etwas, das nicht stimmte (gemeldet und
- nachgestellt, D54-Nachtrag 2). Verglichen wird für ihn nur noch gegen den
- **letzten Eintrag** — der Doppelte bleibt vermieden, und die Zusage „dein
- Stand ist gesichert" wird in jedem Fall wahr. */
+function loadSnaps(){ snaps = parseSnaps(localStorage.getItem(LS_SNAPS)); }
+
function snapshotNow(manuell){
const d = activeDoc();
if(!d || src.readOnly) return false;
const text = src.value;
- const list = snaps[d.id] || (snaps[d.id] = []);
- const letzter = list.length ? list[list.length-1].text : (manuell ? null : snapBase);
- if(text === letzter) return false;
- list.push({t: Date.now(), text});
- while(list.length > SNAP_KEEP) list.shift();
+ if(!addSnapshot(snaps, d.id, text, Date.now(), {base: snapBase, manual: manuell})) return false;
snapBase = text;
- persistSnaps();
+ persistSnaps(snaps, localStorage);
if(!snapMenu.hidden) renderSnapMenu();
return true;
}
@@ -2997,16 +2958,6 @@ const snapBtn = document.getElementById('snapBtn');
const snapAddBtn = document.getElementById('snapAddBtn');
const snapMenu = document.getElementById('snapMenu');
-function snapLabel(ms){
- const d = new Date(ms);
- const heute = d.toDateString() === new Date().toDateString();
- try{
- return heute
- ? d.toLocaleTimeString(lang, {hour: '2-digit', minute: '2-digit'})
- : d.toLocaleString(lang, {day: '2-digit', month: '2-digit',
- hour: '2-digit', minute: '2-digit'});
- }catch(_){ return d.toISOString().slice(0, 16).replace('T', ' '); }
-}
function renderSnapMenu(){
const d = activeDoc();
const list = d ? (snaps[d.id] || []) : [];
@@ -3025,7 +2976,7 @@ function renderSnapMenu(){
b.className = 'snapitem';
b.setAttribute('role', 'menuitem');
b.innerHTML = '';
- b.firstChild.textContent = snapLabel(s.t);
+ b.firstChild.textContent = snapLabel(s.t, lang, Date.now());
b.lastChild.textContent = t('snapLines', {n: s.text.split('\n').length});
b.addEventListener('click', e => { e.stopPropagation(); loadSnapshot(s); });
snapMenu.appendChild(b);
@@ -3186,7 +3137,7 @@ function deleteDoc(){
if(!window.confirm(t('docDeleteConfirm', {name: d.name}))) return;
if(padSource && padSource.id === d.id) stopPad(); /* danach gibt es nichts mehr zu holen (D31) */
docs = docs.filter(x => x.id !== d.id);
- if(snaps[d.id]){ delete snaps[d.id]; persistSnaps(); } /* mit dem Dokument gehen seine Stände (D54) */
+ if(snaps[d.id]){ delete snaps[d.id]; persistSnaps(snaps, localStorage); } /* mit dem Dokument gehen seine Stände (D54) */
if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }];
activeId = docs[0].id;
foldOverrides.clear();
diff --git a/frontend/src/snapshots.js b/frontend/src/snapshots.js
new file mode 100644
index 0000000..2eb79bb
--- /dev/null
+++ b/frontend/src/snapshots.js
@@ -0,0 +1,106 @@
+/* Frühere Stände (D54) — die entscheidbare Hälfte, ohne DOM und ohne Speicher.
+
+ Herausgezogen, nachdem ein Fehler durch alle Prüfungen kam, den ein Test in
+ einer Zeile gefunden hätte: Der Knopf „von Hand sichern" legte nichts weg,
+ solange am Dokument noch nichts geändert war (D54-Nachtrag 2). Die Regel
+ dafür ist reine Logik — sie hing nur an `localStorage`, `Date.now()` und
+ dem `